Files
majordomo/imagegen/imagegen.go
T
steve ae2615ca68 feat(faceswap): carry the measured outcome, not just the image
A face swap always returns an image and always looks like success. Whether the
likeness actually transferred is a different question, and until now nothing in
the response answered it — so a caller wanting to know went and asked a vision
model instead. That is wrong in precisely the cases that matter: shown a jogger
in a Georgetown cap holding McDonald's cups, a VLM answers "Bill Clinton"
whoever's face is on him. In the run that prompted this it reported failure on
six consecutive CORRECT swaps (measured afterwards at 0.79-0.84 cosine), and
the caller burned 21 minutes chasing a problem that did not exist.

Result.SwappedFaces now carries, per replaced face: pixel size, the target
image's dimensions, head yaw, and cosine similarity between the source face and
the face actually present in the output.

Yaw and FractionOfImage are the two that explain the complaint. The swap in
question replaced a 138px face in a 1010px-wide photo — 14% of the width,
correct and invisible at a glance — and elsewhere a face turned -82 degrees,
where the features carrying identity are edge-on and any swap reads as a
generic person. Same code on a 168px face in a 385px picture (44%, yaw 2) is
unmistakable. None of that was inferable from a bounding box.

Typed on Result rather than stuffed into Raw: a caller has to act on this, and
a value reachable only by type-asserting an `any` is one nobody finds in time.

doRawHeaders is doRaw with the whole header instead of only Content-Type; doRaw
delegates to it, so the other 25 call sites are untouched and there is still
one place where the status check and the size cap live.

A missing or malformed header yields nil, not an error — an older shim sends no
header, and a swap that produced a good image must not fail because the
diagnostics beside it were unreadable. Covered for absent/garbage/wrong-type,
and the parse is break-checked.
2026-07-31 17:49:27 -04:00

153 lines
5.7 KiB
Go

// Package imagegen is majordomo's canonical text-to-image surface. It is a
// deliberately separate contract from the llm package: image generation does
// not fit the chat Request/Response shape (no messages, tools, streaming, or
// failover chains in v1), so it gets its own small Provider/Model interface
// rather than overloading llm.Model.
//
// Generated images are carried as llm.ImagePart (bytes + MIME), so a result
// drops straight back into a chat turn:
//
// res, _ := im.Generate(ctx, imagegen.Request{Prompt: "a red bicycle"})
// msg := llm.UserParts(llm.Text("describe this"), res.Images[0])
//
// The first implementation is provider/llamaswap, which targets the OpenAI
// /v1/images/generations endpoint routed to a stable-diffusion.cpp backend.
package imagegen
import (
"context"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// Image is one generated image: raw bytes plus a MIME type. Aliased to
// llm.ImagePart so generated images are interchangeable with chat content and
// can be fed into llm.UserParts without conversion.
type Image = llm.ImagePart
// Request is a text-to-image generation request. Pointer-free zero values mean
// "provider default": N == 0 yields the backend's default count (usually one),
// and an empty Size leaves the backend's default resolution.
type Request struct {
// Prompt is the text description of the image to generate.
Prompt string
// N is the number of images to generate; 0 = provider default.
N int
// Size is the requested resolution, e.g. "512x512" or "1024x1024";
// "" = provider default.
Size string
// The fields below are optional per-request overrides. Their zero value
// (nil pointer or empty string) means "leave the backend's own default" —
// for stable-diffusion.cpp that is the per-model default baked into the
// llama-swap launch flags. A caller overrides only what it explicitly sets.
// Steps is the number of diffusion steps; nil = backend default.
Steps *int
// CFGScale is the classifier-free-guidance scale; nil = backend default.
// Architecture-sensitive (SDXL likes ~7, Flux wants 1), so prefer leaving
// it nil unless the caller knows the target model.
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
}
// Result is the canonical image-generation result.
type Result struct {
// Images are the generated images, in the order the backend returned them.
Images []Image
// SwappedFaces is the measured outcome of a FaceSwap, one entry per face
// replaced. Empty for every other operation, and empty from a provider
// that does not measure. TYPED rather than tucked into Raw: a caller has
// to act on this — it is the only way to distinguish a swap that
// transferred the likeness from one that returned an image and nothing
// more — and a value reachable solely by type-asserting an `any` is one
// nobody discovers in time to use.
SwappedFaces []SwappedFace
// Raw is the provider-native response object, an escape hatch for
// provider-specific fields. May be nil; never required for normal use.
Raw any
}
// Option mutates a Request before it is sent. Options passed to Generate are
// applied to a copy of the request, so a Request value can be reused.
type Option func(*Request)
// WithN sets the number of images to generate.
func WithN(n int) Option { return func(r *Request) { r.N = n } }
// WithSize sets the requested resolution (e.g. "1024x1024").
func WithSize(size string) Option { return func(r *Request) { r.Size = size } }
// WithSteps overrides the number of diffusion steps.
func WithSteps(n int) Option { return func(r *Request) { r.Steps = &n } }
// WithCFGScale overrides the classifier-free-guidance scale.
func WithCFGScale(s float64) Option { return func(r *Request) { r.CFGScale = &s } }
// WithNegativePrompt sets a negative prompt.
func WithNegativePrompt(s string) Option { return func(r *Request) { r.NegativePrompt = s } }
// WithSampler overrides the sampling method (e.g. "euler", "euler_a").
func WithSampler(s string) Option { return func(r *Request) { r.Sampler = s } }
// WithSeed fixes the RNG seed for reproducible output.
func WithSeed(seed int64) Option { return func(r *Request) { r.Seed = &seed } }
// Apply returns a copy of the request with all options applied. Providers call
// this once at the top of Generate.
func (r Request) Apply(opts ...Option) Request {
for _, opt := range opts {
opt(&r)
}
return r
}
// Model generates images from a text prompt. It is intentionally narrower than
// llm.Model — no Stream, no Capabilities, no tool calls.
type Model interface {
// Generate produces one or more images for the request's prompt.
Generate(ctx context.Context, req Request, opts ...Option) (*Result, error)
}
// ModelOption configures a Model at construction time (Provider.ImageModel).
// Reserved for future per-model settings (e.g. a default size); present now so
// the interface is forward-compatible.
type ModelOption func(*ModelConfig)
// ModelConfig carries per-model construction settings.
type ModelConfig struct{}
// ApplyModelOptions folds options into a config.
func ApplyModelOptions(opts []ModelOption) ModelConfig {
var cfg ModelConfig
for _, opt := range opts {
opt(&cfg)
}
return cfg
}
// Provider mints image Models bound to one backend. It mirrors llm.Provider
// but for image generation.
type Provider interface {
// Name is the registry identifier for the provider.
Name() string
// ImageModel returns a Model bound to the given id (passed through to the
// backend verbatim; no catalog validation).
ImageModel(id string, opts ...ModelOption) (Model, error)
}