feat(imagegen): reference-image editing for instruction-edit models
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

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.
This commit is contained in:
2026-07-30 21:21:35 -04:00
parent a941f5ff4a
commit 2c70d32fd4
3 changed files with 154 additions and 2 deletions
+73
View File
@@ -152,3 +152,76 @@ func TestImageEditWithoutMaskOmitsField(t *testing.T) {
t.Error("mask field sent for unmasked edit; want omitted")
}
}
// TestImageEditByReferenceUsesTxt2ImgExtraImages pins the instruction-edit
// wire shape. It is a DIFFERENT endpoint and a DIFFERENT field from img2img,
// and the difference is not cosmetic: measured against FLUX.1-Kontext on
// 2026-07-30, the same prompt sent as init_images left the thing it was told
// to change untouched and drifted everything else, while extra_images changed
// exactly what was asked and left the rest of the frame numerically
// unchanged. Routing a reference edit down the img2img path would look like
// a working call and silently produce the wrong picture.
func TestImageEditByReferenceUsesTxt2ImgExtraImages(t *testing.T) {
var gotPath string
var gotBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
im, _ := p.ImageModel("imagegen-flux-kontext")
ed := im.(imagegen.Editor)
ref := editInit(t)
if _, err := ed.Edit(context.Background(), imagegen.EditRequest{
Prompt: "make the sign read OPEN",
// Init/Mask/Strength are set and must be IGNORED — they describe a
// pipeline this model does not run.
Init: ref,
Mask: ref,
Strength: func() *float64 { s := 0.75; return &s }(),
}, imagegen.WithEditRefImages(ref)); err != nil {
t.Fatalf("reference edit: %v", err)
}
if gotPath != "/sdapi/v1/txt2img" {
t.Errorf("path = %q, want /sdapi/v1/txt2img (there is no init latent to denoise)", gotPath)
}
extra, ok := gotBody["extra_images"].([]any)
if !ok || len(extra) != 1 {
t.Fatalf("extra_images = %v, want the one reference image", gotBody["extra_images"])
}
if _, present := gotBody["init_images"]; present {
t.Error("init_images must NOT be sent on the reference path — it re-noises the picture")
}
if _, present := gotBody["denoising_strength"]; present {
t.Error("denoising_strength must NOT be sent on the reference path")
}
if _, present := gotBody["mask"]; present {
t.Error("mask must NOT be sent on the reference path")
}
}
// TestImageEditByReferenceRejectsEmptyRefs guards the case that would
// otherwise silently become a plain txt2img: a reference edit whose only
// reference carries no bytes has nothing to condition on, and rendering the
// prompt from scratch is not what the caller asked for.
func TestImageEditByReferenceRejectsEmptyRefs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"images":["` + onePixelPNG + `"]}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
im, _ := p.ImageModel("imagegen-flux-kontext")
ed := im.(imagegen.Editor)
_, err := ed.Edit(context.Background(), imagegen.EditRequest{Prompt: "anything"},
imagegen.WithEditRefImages(imagegen.Image{MIME: "image/png"}))
if !errors.Is(err, llm.ErrUnsupported) {
t.Fatalf("err = %v, want ErrUnsupported for an all-empty reference set", err)
}
}