Person-into-scene image generation
Put a SPECIFIC real person into a scene template, photorealistically, keeping their
actual identity. Built + battle-tested 2026-06-10 (Markus). Assets:
~/Documents/image-gen/ (scene-templates/, markus-references/, generated/,
MANIFEST.md, HANDOFF.md). Code lives in ~/Projects/imagegen/ (own repo since
2026-06-10, split out of phenome) — run just/uv commands from there.
ASK THE DESTINATION FIRST (before generating anything)
The output format decides scene orientation AND final crop — get it wrong and you
regenerate. Markus's images are for dating apps (Hinge/Tinder/Bumble), not
iPhone wallpaper. (Assuming "iPhone Pro dimensions" = 19.5:9 screen ratio cost a
round-trip — he called it "way too thin." That's wallpaper, not a photo.) Confirm:
- Dating-app photo (default): generate portrait
--size 1024x1536; finish
with cohere_photo.py --app safe (3:4 master that survives all three) or per-app
(bumble 3:4, tinder/hinge 4:5). Keep the subject centred WITH MARGIN — apps
re-crop algorithmically.
- Wallpaper (rare):
--app-less, use cohere_photo.py --iphone (19.5:9).
- Landscape scenes (e.g. camel-desert) LOSE most of the frame when cropped to
portrait — generate them portrait from the start, don't crop a landscape down.
See
[[feedback_image_iphone_pro_dimensions]].
The engine (plan d4712f73 — BUILT 2026-06-10): use it, don't hand-drive the stages
The slow human-in-the-loop is gone. One engine (imagegen, src/imagegen/engine.py) runs a whole
recipe from a declarative identity-profile + shot-spec YAML: gpt-image-2 generate
→ face-swap + BiRefNet matte (ONE warm Modal app, all models resident) → local finish/crop
→ VLM auto-QC rank. It surfaces the top 1–2 + flags so you read ~2 finalists, not ~40.
Three faces over the same engine — prefer them over hand-running the stages:
- CLI:
just image-run markus camel-desert (or scripts/tools/image_engine.py run --profile … --shot …)
- MCP (agent-native, preferred for agents):
image_pipeline(profile="markus", shot="camel-desert")
on the imagegen MCP server (registered in ~/Projects/imagegen/.mcp.json) — also
image_swap, image_finish, image_generate, image_engine_status.
- UI (human cull):
just image-ui — Gradio: generate → auto-ranked gallery → you pick;
plus a FREE local re-finish tab (tweak soften/grain/crop with live preview, no regen).
Recipes live in ~/Documents/image-gen/profiles/markus.yaml + shots/*.yaml. Deploy the
Modal app once per code change: just image-deploy. The four model weights coexist in one
container (numpy<2 ABI proven — just image-probe); re-validate if deps change.
2026 SOTA re-check (research-validated, keep this architecture): the generate-then-swap
decoupling is still correct — even Nano Banana Pro / FLUX / InfiniteYou reconstruct a face
like the person (5%+ drift, no persistent identity), so the face-swap stays the identity-
truth step. Restorer A/B MEASURED 2026-06-10 (see backlog doc): GPEN-BFR-1024 is the
default restore — the only model that GAINS identity vs the raw swap (+0.002 bright,
+0.017 dark face) with natural texture; GFPGAN is runner-up (−0.01/−0.018, slight wax);
CodeFormer was REJECTED as default (−0.04, codebook prior pulls toward a generic face —
the memo's prediction didn't survive measurement). Override per profile/shot YAML:
finish.restorer: gfpgan|codeformer|gpen_bfr_1024. Remaining candidates in
`/Projects/imagegen/docs/research/image-engine-upgrade-backlog.md: Nano Banana Pro as generator, DreamID / BFS diffusion head-swap (probed via scripts/modal/bfs_head_swap_probe.py`).
THE core architecture — read first (it's why this works)
Identity is IMPOSED, never GENERATED. gpt-image-2 (and every diffusion model)
cannot reproduce a specific real face from references — it averages refs into a
generic-handsome-model face. Do NOT try to fix likeness with more refs or better
prompts; that's a model ceiling and chasing it wastes rounds + money (it already
cost a full session once). Instead, two stages:
- Scene + body + pose + lighting →
gpt-image-2 (it's great at this).
- Identity → face-swap the real face in with InsightFace
inswapper
(real pixels, faithful, no AI-face look).
Same principle as the photo-cleanup tool (clean_ai_photo.py): never trust
diffusion with the face; paste/swap the real one as a post-step.
Tools (the engine + its three faces; the per-stage code below is "under the hood")
- Engine library:
imagegen (~/Projects/imagegen/src/imagegen/) — run_pipeline(profile, shot) does the whole
recipe and returns ranked variants (run.json sidecar). load_profile/load_shot,
finish_image/FinishSpec, build_prompt, judge_image are also exported.
- Unified Modal app (
image-engine): scripts/modal/image_engine_modal.py — ONE app
with buffalo_l + inswapper + GFPGAN + BiRefNet resident. swap_and_matte (engine path);
one-off CLI: ::swap (batch swap+matte), ::matte (matte only), ::smoke (single).
just image-deploy after edits. Replaces the old face-swap + birefnet apps (3→1).
- Stage CLIs (now thin wrappers over the engine — single source of logic):
scripts/tools/imagegen_scene.py (gen only, → gen.py),
scripts/tools/cohere_photo.py (finish only, → finish.py). Use the engine CLI/MCP for
the full flow; reach for these only for a single isolated stage.
- De-swirl (DEMOTED — last resort):
scripts/modal/clean_ai_photo.py. For gpt-image-2
skin artifacts, reproduce-clean (regen + swap) beats de-swirl; use it ONLY to preserve an
exact existing frame you can't regenerate. NOT in the engine hot path.
- enhance (swap-side face restore, GPEN-BFR-1024 by default) lives in the profile
(
enhance: 0.6); blend 0.3 (subtle) → 0.7 (crisp), Markus rejects plastic so don't
exceed ~0.7. finish.restorer: switches the model (gfpgan | codeformer | gpen_bfr_1024).
- Best matte model (2026): BiRefNet (MIT, hair-grade). SAM 3 (Meta, ICLR 2026) gives
instance/concept masks not alpha mattes — only for text-targeting a specific bg entity.
Workflow
# PRIMARY — one recipe, end to end (gen → swap+matte → finish → auto-QC rank):
just image-deploy # once per engine code change (cached image ⇒ ~2s)
just image-run markus camel-desert # bare names under ~/Documents/image-gen/
# → ranked gallery in generated/engine-runs/<ts>-…/final/, top pick + flags printed.
# MCP: image_pipeline(profile="markus", shot="camel-desert")
# UI: just image-ui (human cull + free local re-finish with live preview)
# A shot is a small YAML diff against the profile (shots/<name>.yaml):
# scene: camel-desert.jpg
# scene_desc: "..."; gaze: away; n: 3
# extra: "subtle Burning Man cues, faint distant mutant vehicles" # de-genericise trick
# target: {app: safe} # bumble|tinder|hinge|safe|wallpaper
# UNDER THE HOOD (only for a single isolated stage):
# gen: uv run --with openai python3 scripts/tools/imagegen_scene.py --scene … --n 4
# swap+matte: uvx --with modal modal run scripts/modal/image_engine_modal.py::swap \
# --targets "a.png,b.png" --source selfie_2026-02-10.jpg --out-dir out/
# finish: uv run python3 scripts/tools/cohere_photo.py --input swap.png --matte m.png \
# --output final.png --downscale 0.92 --soften 0.7 --grain 3 --app safe
Locked recipe (don't re-derive — all dialed across the 2026-06-10 session)
- Face refs: casual selfies, NOT studio portraits (studio → smug/"douchy"):
img_1901, selfie_2026-04-22, selfie_2026-02-10, selfie_2026-03-04.
- Swap source (the face imposed in stage 2):
selfie_2026-02-10 — proven best
this session (likeness 0.92–0.95, recent clear frontal). Default to it.
- Body refs:
photo_2017-01-22 (frame) + 24698343-...png (BULK shape — Markus
wants the fuller, broad-shouldered build, not lean).
- Bulk body-desc (pass via
--body-desc): "fit muscular male-model physique,
broad shoulders, full developed chest, strong defined arms — more muscular than
lean, athletic but natural, never gross/vascular bodybuilder; even tan, smooth
skin, no chest hair, no bulging veins."
- Gaze: usually
away — Markus prefers NOT looking into the camera (candid).
- Veins: a few natural forearm veins are FINE — not extreme, NONE on hands
(biceps a touch ok but unneeded). Do NOT prompt "no veins whatsoever" (over-smooth).
- Background context trick: to de-genericise a scene, add "subtle signs of
[place], faint distant [objects], hazy, not crowded" to
--scene-desc/--extra.
Worked well for Burning Man (mutant vehicles/art on the playa horizon).
- Face blur: inswapper's 128px face reads softer than the body — pass
--enhance 0.6 on the swap (GPEN-1024 restore, the measured default). Tune 0.3–0.7; he rejects
plastic, don't exceed ~0.7.
- Finish = SUBTLE, not bokeh (
[[feedback_image_finish_subtle_not_bokeh.md]]):
light grain + background MOSTLY IN FOCUS. cohere_photo.py --downscale 0.92 --soften 0.7 --grain 3. Heavy matte-DoF bokeh reads "too professional/staged" —
keep --soften ≤1.5. The BiRefNet matte still helps keep the subject crisp + a
GENTLE background touch; just don't crank it.
- Polaroid look (when asked):
cohere_photo.py --polaroid [--polaroid-lift 8 for night] [--border] — instant-film grade (lifted blacks, warm cast, vignette, grain).
- Reproduce-clean > de-swirl for gpt-image-2 skin artifacts (waxy/swirly chest):
REGENERATE the scene via
imagegen_scene.py (anti-artifact prompt) then swap, NOT
clean_ai_photo.py de-swirl. De-swirl is only for preserving an exact existing frame.
- Run n=3–4, cull by face-similarity (+ eye), keep best 1–2.
gpt-image-2 facts (verified 2026-06-09)
- Quality:
low|medium|high|auto — no xhigh. high ≈ $0.21/img + ref input tokens
(~$0.26 with refs).
input_fidelity is auto-high for gpt-image-2 — omit it (passing it can error).
- Person-in-scene compositing is officially supported; anchor realism with natural
lighting, no cinematic grading.
READ BEFORE YOU CLAIM (hook-enforced in ~/Projects/imagegen)
Never present an image as "fixed/clean/final" without verifying it. The verification
stack, in order of authority:
- Flash localizer (primary):
uv run python3 scripts/tools/flash_inspect.py img.png
(in ~/Projects/imagegen) — schema-constrained gemini-3-flash artifact findings with
severity score + box_2d. Run it on every candidate BEFORE presenting; when a defect
appears, BISECT stages with it (gen → swap → finish variants — it isolated the A3
color-match root cause in one pass after agent eyes missed it six times).
- Your own Read (secondary): the exact file being sent, full frame AND native-res
crops of any region you make claims about. Thumbnails/contact sheets hide discs,
washes, seams. Draw mask/patch rectangles on the image before trusting measurements
through them.
A PreToolUse hook in imagegen blocks SendUserFile for never-Read images; honor the
spirit, not just the gate. After fixing a pipeline bug, regenerate canonical-named
outputs in place (stale plain-named files are what the user opens).
Hard line
Never use the face-swap to deceive real people (dating-profile catfishing, fraud,
impersonating someone else). It's for the subject's own generated art only.
1---2name: person-into-scene3description: Use when: photoreal Markus in scene template, 'put me in this photo', dating/lifestyle/cinematic shots, likeness fix. NOT generic image gen (/gpt-image-2).4---56# Person-into-scene image generation78Put a SPECIFIC real person into a scene template, photorealistically, keeping their9actual identity. Built + battle-tested 2026-06-10 (Markus). Assets:10`~/Documents/image-gen/` (`scene-templates/`, `markus-references/`, `generated/`,11`MANIFEST.md`, `HANDOFF.md`). Code lives in `~/Projects/imagegen/` (own repo since122026-06-10, split out of phenome) — run `just`/`uv` commands from there.1314## ASK THE DESTINATION FIRST (before generating anything)1516The output format decides scene orientation AND final crop — get it wrong and you17regenerate. Markus's images are for **dating apps (Hinge/Tinder/Bumble)**, not18iPhone wallpaper. (Assuming "iPhone Pro dimensions" = 19.5:9 screen ratio cost a19round-trip — he called it "way too thin." That's wallpaper, not a photo.) Confirm:20- **Dating-app photo (default):** generate **portrait** `--size 1024x1536`; finish21 with `cohere_photo.py --app safe` (3:4 master that survives all three) or per-app22 (`bumble` 3:4, `tinder`/`hinge` 4:5). Keep the subject centred WITH MARGIN — apps23 re-crop algorithmically.24- **Wallpaper (rare):** `--app`-less, use `cohere_photo.py --iphone` (19.5:9).25- **Landscape scenes** (e.g. camel-desert) LOSE most of the frame when cropped to26 portrait — generate them portrait from the start, don't crop a landscape down.27See `[[feedback_image_iphone_pro_dimensions]]`.2829## The engine (plan d4712f73 — BUILT 2026-06-10): use it, don't hand-drive the stages3031The slow human-in-the-loop is gone. One engine (`imagegen`, `src/imagegen/engine.py`) runs a whole32recipe from a declarative **identity-profile** + **shot-spec** YAML: gpt-image-2 generate33→ face-swap + BiRefNet matte (ONE warm Modal app, all models resident) → local finish/crop34→ **VLM auto-QC rank**. It surfaces the top 1–2 + flags so you read ~2 finalists, not ~40.3536Three faces over the same engine — prefer them over hand-running the stages:37- **CLI:** `just image-run markus camel-desert` (or `scripts/tools/image_engine.py run --profile … --shot …`)38- **MCP (agent-native, preferred for agents):** `image_pipeline(profile="markus", shot="camel-desert")`39 on the `imagegen` MCP server (registered in `~/Projects/imagegen/.mcp.json`) — also40 `image_swap`, `image_finish`, `image_generate`, `image_engine_status`.41- **UI (human cull):** `just image-ui` — Gradio: generate → auto-ranked gallery → you pick;42 plus a FREE local re-finish tab (tweak soften/grain/crop with live preview, no regen).4344Recipes live in `~/Documents/image-gen/profiles/markus.yaml` + `shots/*.yaml`. Deploy the45Modal app once per code change: `just image-deploy`. The four model weights coexist in one46container (numpy<2 ABI proven — `just image-probe`); re-validate if deps change.4748**2026 SOTA re-check (research-validated, keep this architecture):** the generate-then-swap49decoupling is still correct — even Nano Banana Pro / FLUX / InfiniteYou reconstruct a face50*like* the person (~5%+ drift, no persistent identity), so the face-swap stays the identity-51truth step. Restorer A/B MEASURED 2026-06-10 (see backlog doc): **GPEN-BFR-1024 is the52default restore** — the only model that GAINS identity vs the raw swap (+0.002 bright,53+0.017 dark face) with natural texture; GFPGAN is runner-up (−0.01/−0.018, slight wax);54CodeFormer was REJECTED as default (−0.04, codebook prior pulls toward a generic face —55the memo's prediction didn't survive measurement). Override per profile/shot YAML:56`finish.restorer: gfpgan|codeformer|gpen_bfr_1024`. Remaining candidates in57`~/Projects/imagegen/docs/research/image-engine-upgrade-backlog.md`:58Nano Banana Pro as generator, DreamID / BFS diffusion head-swap (probed via59`scripts/modal/bfs_head_swap_probe.py`).6061## THE core architecture — read first (it's why this works)6263**Identity is IMPOSED, never GENERATED.** gpt-image-2 (and every diffusion model)64*cannot* reproduce a specific real face from references — it averages refs into a65generic-handsome-model face. Do NOT try to fix likeness with more refs or better66prompts; that's a model ceiling and chasing it wastes rounds + money (it already67cost a full session once). Instead, two stages:68691. **Scene + body + pose + lighting** → `gpt-image-2` (it's great at this).702. **Identity** → **face-swap the real face in** with InsightFace `inswapper`71 (real pixels, faithful, no AI-face look).7273Same principle as the photo-cleanup tool (`clean_ai_photo.py`): never trust74diffusion with the face; paste/swap the real one as a post-step.7576## Tools (the engine + its three faces; the per-stage code below is "under the hood")7778- **Engine library:** `imagegen` (`~/Projects/imagegen/src/imagegen/`) — `run_pipeline(profile, shot)` does the whole79 recipe and returns ranked variants (`run.json` sidecar). `load_profile`/`load_shot`,80 `finish_image`/`FinishSpec`, `build_prompt`, `judge_image` are also exported.81- **Unified Modal app (`image-engine`):** `scripts/modal/image_engine_modal.py` — ONE app82 with buffalo_l + inswapper + GFPGAN + BiRefNet resident. `swap_and_matte` (engine path);83 one-off CLI: `::swap` (batch swap+matte), `::matte` (matte only), `::smoke` (single).84 `just image-deploy` after edits. Replaces the old face-swap + birefnet apps (3→1).85- **Stage CLIs (now thin wrappers over the engine — single source of logic):**86 `scripts/tools/imagegen_scene.py` (gen only, → `gen.py`),87 `scripts/tools/cohere_photo.py` (finish only, → `finish.py`). Use the engine CLI/MCP for88 the full flow; reach for these only for a single isolated stage.89- **De-swirl (DEMOTED — last resort):** `scripts/modal/clean_ai_photo.py`. For gpt-image-290 skin artifacts, reproduce-clean (regen + swap) beats de-swirl; use it ONLY to preserve an91 exact existing frame you can't regenerate. NOT in the engine hot path.92- **enhance** (swap-side face restore, GPEN-BFR-1024 by default) lives in the profile93 (`enhance: 0.6`); blend 0.3 (subtle) → 0.7 (crisp), Markus rejects plastic so don't94 exceed ~0.7. `finish.restorer:` switches the model (gfpgan | codeformer | gpen_bfr_1024).95- **Best matte model (2026):** BiRefNet (MIT, hair-grade). SAM 3 (Meta, ICLR 2026) gives96 instance/concept masks not alpha mattes — only for text-targeting a specific bg entity.9798## Workflow99100```101# PRIMARY — one recipe, end to end (gen → swap+matte → finish → auto-QC rank):102just image-deploy # once per engine code change (cached image ⇒ ~2s)103just image-run markus camel-desert # bare names under ~/Documents/image-gen/104# → ranked gallery in generated/engine-runs/<ts>-…/final/, top pick + flags printed.105# MCP: image_pipeline(profile="markus", shot="camel-desert")106# UI: just image-ui (human cull + free local re-finish with live preview)107108# A shot is a small YAML diff against the profile (shots/<name>.yaml):109# scene: camel-desert.jpg110# scene_desc: "..."; gaze: away; n: 3111# extra: "subtle Burning Man cues, faint distant mutant vehicles" # de-genericise trick112# target: {app: safe} # bumble|tinder|hinge|safe|wallpaper113114# UNDER THE HOOD (only for a single isolated stage):115# gen: uv run --with openai python3 scripts/tools/imagegen_scene.py --scene … --n 4116# swap+matte: uvx --with modal modal run scripts/modal/image_engine_modal.py::swap \117# --targets "a.png,b.png" --source selfie_2026-02-10.jpg --out-dir out/118# finish: uv run python3 scripts/tools/cohere_photo.py --input swap.png --matte m.png \119# --output final.png --downscale 0.92 --soften 0.7 --grain 3 --app safe120```121122## Locked recipe (don't re-derive — all dialed across the 2026-06-10 session)123124- **Face refs: casual selfies, NOT studio portraits** (studio → smug/"douchy"):125 `img_1901`, `selfie_2026-04-22`, `selfie_2026-02-10`, `selfie_2026-03-04`.126- **Swap source (the face imposed in stage 2):** `selfie_2026-02-10` — proven best127 this session (likeness 0.92–0.95, recent clear frontal). Default to it.128- **Body refs:** `photo_2017-01-22` (frame) + `24698343-...png` (BULK shape — Markus129 wants the fuller, broad-shouldered build, not lean).130- **Bulk body-desc** (pass via `--body-desc`): "fit muscular male-model physique,131 broad shoulders, full developed chest, strong defined arms — more muscular than132 lean, athletic but natural, never gross/vascular bodybuilder; even tan, smooth133 skin, no chest hair, no bulging veins."134- **Gaze: usually `away`** — Markus prefers NOT looking into the camera (candid).135- **Veins:** a few natural forearm veins are FINE — *not extreme*, *NONE on hands*136 (biceps a touch ok but unneeded). Do NOT prompt "no veins whatsoever" (over-smooth).137- **Background context trick:** to de-genericise a scene, add "subtle signs of138 [place], faint distant [objects], hazy, not crowded" to `--scene-desc`/`--extra`.139 Worked well for Burning Man (mutant vehicles/art on the playa horizon).140- **Face blur:** inswapper's 128px face reads softer than the body — pass `--enhance141 0.6` on the swap (GPEN-1024 restore, the measured default). Tune 0.3–0.7; he rejects142 plastic, don't exceed ~0.7.143- **Finish = SUBTLE, not bokeh** (`[[feedback_image_finish_subtle_not_bokeh.md]]`):144 light grain + background MOSTLY IN FOCUS. `cohere_photo.py --downscale 0.92145 --soften 0.7 --grain 3`. Heavy matte-DoF bokeh reads "too professional/staged" —146 keep `--soften ≤1.5`. The BiRefNet matte still helps keep the subject crisp + a147 GENTLE background touch; just don't crank it.148- **Polaroid look** (when asked): `cohere_photo.py --polaroid [--polaroid-lift 8 for149 night] [--border]` — instant-film grade (lifted blacks, warm cast, vignette, grain).150- **Reproduce-clean > de-swirl** for gpt-image-2 skin artifacts (waxy/swirly chest):151 REGENERATE the scene via `imagegen_scene.py` (anti-artifact prompt) then swap, NOT152 `clean_ai_photo.py` de-swirl. De-swirl is only for preserving an exact existing frame.153- **Run n=3–4, cull by face-similarity (+ eye), keep best 1–2.**154155## gpt-image-2 facts (verified 2026-06-09)156157- Quality: `low|medium|high|auto` — **no `xhigh`**. high ≈ $0.21/img + ref input tokens158 (~$0.26 with refs).159- `input_fidelity` is **auto-high** for gpt-image-2 — omit it (passing it can error).160- Person-in-scene compositing is officially supported; anchor realism with natural161 lighting, no cinematic grading.162163## READ BEFORE YOU CLAIM (hook-enforced in ~/Projects/imagegen)164165Never present an image as "fixed/clean/final" without verifying it. The verification166stack, in order of authority:1671. **Flash localizer (primary):** `uv run python3 scripts/tools/flash_inspect.py img.png`168 (in ~/Projects/imagegen) — schema-constrained gemini-3-flash artifact findings with169 severity score + box_2d. Run it on every candidate BEFORE presenting; when a defect170 appears, BISECT stages with it (gen → swap → finish variants — it isolated the A3171 color-match root cause in one pass after agent eyes missed it six times).1722. **Your own Read (secondary):** the exact file being sent, full frame AND native-res173 crops of any region you make claims about. Thumbnails/contact sheets hide discs,174 washes, seams. Draw mask/patch rectangles on the image before trusting measurements175 through them.176A PreToolUse hook in imagegen blocks SendUserFile for never-Read images; honor the177spirit, not just the gate. After fixing a pipeline bug, regenerate canonical-named178outputs in place (stale plain-named files are what the user opens).179180## Hard line181182Never use the face-swap to deceive real people (dating-profile catfishing, fraud,183impersonating someone else). It's for the subject's own generated art only.