Files
steve 2c70d32fd4
Gadfly review (reusable) / review (pull_request) Successful in 4m24s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m24s
CI / Tidy (pull_request) Successful in 9m40s
CI / Build & Test (pull_request) Successful in 11m17s
feat(imagegen): reference-image editing for instruction-edit models
FLUX.1 Kontext and Qwen-Image-Edit are a different kind of edit from img2img
and reach sd-server by a different path, and nothing in imagegen could
express it: EditRequest only had Init, which is noised and denoised back
under the prompt.

Measured against FLUX.1-Kontext on the netherstorm host 2026-07-30, on a
synthetic scene with a red rectangle, a blue rectangle and a flat background,
prompted "change the blue rectangle on the right to bright green, keep
everything else exactly the same":

  via init_images (the only path that existed)
      right rect (60,60,200) -> (47,82,228)   still blue, instruction ignored
      left rect  (200,60,60) -> (229,43,50)   drifted
      background (150,200,240) -> (154,211,229) drifted

  via extra_images (this change)
      right rect (60,60,200) -> (70,254,4)    green, as asked
      left rect  (200,60,60) -> (204,57,57)   intact
      background (150,200,240) -> (151,202,247) intact

No mask, no strength, no compositing — the model is handed the picture as
conditioning and the prompt as an instruction about it.

EditRequest.RefImages selects the path; when set, Init/Mask/Strength are
ignored rather than rejected, so a caller handing the same request to
whichever model is configured gets the better result on a Kontext-class model
instead of an error. The provider posts /sdapi/v1/txt2img with extra_images
(sd-server reads that field on both routes into gen_params.ref_images, where
the CLI's -r/--ref-image also lands); there is no init latent to denoise, so
sending one would only add noise to a pipeline that does not want any.

An all-empty reference set is refused: it would otherwise degrade into a
plain txt2img and render the prompt from scratch, which is not the request.
2026-07-30 21:21:35 -04:00

131 lines
5.2 KiB
Go

package imagegen
import "context"
// EditRequest is an image-to-image (edit) request: a prompt applied to an
// initial image. As with Request, zero values mean "backend default"
// (ADR-0018).
type EditRequest struct {
// Prompt is the text description of the desired edit.
Prompt string
// Init is the initial image the edit starts from. Required, EXCEPT when
// RefImages is set — see there.
Init Image
// RefImages carries reference images for INSTRUCTION-EDIT models
// (FLUX.1 Kontext, Qwen-Image-Edit), which are a different kind of edit
// from img2img and reach the model by a different path.
//
// img2img noises Init and denoises it back under the prompt: the prompt
// describes the DESIRED IMAGE, and how much of the original survives is a
// function of Strength. An instruction-edit model instead takes the
// picture as conditioning and the prompt as an INSTRUCTION about it
// ("change the sign to read OPEN"), leaving everything it was not asked
// to touch bit-for-bit intact — no mask, no strength, no compositing.
//
// Sending one of these models an Init instead of a RefImage does not
// degrade gracefully, it silently does the wrong thing: measured against
// FLUX.1-Kontext on 2026-07-30, "change the blue rectangle to green" via
// init_images left the rectangle blue and drifted every other region,
// while the same prompt via a reference image turned it green and left
// the rest of the frame numerically unchanged.
//
// When RefImages is non-empty, Init/Mask/Strength are IGNORED: they
// describe a pipeline this model does not run.
RefImages []Image
// Mask restricts the edit to a region (inpainting): a single-channel or
// RGB image the same size as Init where WHITE pixels are repainted and
// BLACK pixels are kept. Empty = whole-image edit. Backends without mask
// support must reject a masked request rather than silently ignoring it.
Mask Image
// Strength is the denoising strength in [0,1] — how far the result may
// depart from Init (0 = return the input, 1 = ignore it); nil = backend
// default.
Strength *float64
// N is the number of images to generate; 0 = provider default.
N int
// Size is the requested resolution, e.g. "1024x1024"; "" = provider
// default (usually the init image's own resolution).
Size string
// Steps is the number of diffusion steps; nil = backend default.
Steps *int
// CFGScale is the classifier-free-guidance scale; nil = backend default.
CFGScale *float64
// NegativePrompt steers generation away from concepts; "" = none.
NegativePrompt string
// Sampler selects the sampling method (e.g. "euler", "euler_a");
// "" = backend default.
Sampler string
// Seed fixes the RNG seed for reproducible output; nil = random.
Seed *int64
}
// EditOption mutates an EditRequest before it is sent. Options passed to Edit
// are applied to a copy of the request, so an EditRequest value can be reused.
type EditOption func(*EditRequest)
// WithEditMask restricts the edit to a region (white = repaint, black = keep).
func WithEditMask(m Image) EditOption { return func(r *EditRequest) { r.Mask = m } }
// WithEditRefImages supplies reference images for an instruction-edit model
// (Kontext / Qwen-Image-Edit). See EditRequest.RefImages — this selects a
// different edit path, not a variation on img2img.
func WithEditRefImages(imgs ...Image) EditOption {
return func(r *EditRequest) { r.RefImages = imgs }
}
// WithEditStrength sets the denoising strength in [0,1].
func WithEditStrength(s float64) EditOption { return func(r *EditRequest) { r.Strength = &s } }
// WithEditN sets the number of images to generate.
func WithEditN(n int) EditOption { return func(r *EditRequest) { r.N = n } }
// WithEditSize sets the requested resolution (e.g. "1024x1024").
func WithEditSize(size string) EditOption { return func(r *EditRequest) { r.Size = size } }
// WithEditSteps overrides the number of diffusion steps.
func WithEditSteps(n int) EditOption { return func(r *EditRequest) { r.Steps = &n } }
// WithEditCFGScale overrides the classifier-free-guidance scale.
func WithEditCFGScale(s float64) EditOption { return func(r *EditRequest) { r.CFGScale = &s } }
// WithEditNegativePrompt sets a negative prompt.
func WithEditNegativePrompt(s string) EditOption {
return func(r *EditRequest) { r.NegativePrompt = s }
}
// WithEditSampler overrides the sampling method.
func WithEditSampler(s string) EditOption { return func(r *EditRequest) { r.Sampler = s } }
// WithEditSeed fixes the RNG seed for reproducible output.
func WithEditSeed(seed int64) EditOption { return func(r *EditRequest) { r.Seed = &seed } }
// Apply returns a copy of the request with all options applied. Providers
// call this once at the top of Edit.
func (r EditRequest) Apply(opts ...EditOption) EditRequest {
for _, opt := range opts {
opt(&r)
}
return r
}
// Editor is the image-to-image surface. It is a separate, optional interface
// rather than a method on Model so existing Model implementations keep
// compiling; callers type-assert (`m.(imagegen.Editor)`) or require it
// explicitly.
type Editor interface {
// Edit produces one or more images derived from the request's init image
// under the request's prompt.
Edit(ctx context.Context, req EditRequest, opts ...EditOption) (*Result, error)
}