diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf755e..14849c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Character images are checked and regenerated automatically.** Each profile picture and character sheet is inspected after it is rendered; a portrait or sheet that fails (wrong gender, three front views, cut-off feet, elf ears on a human, wounds, scenery behind a studio shot, a different face from the profile picture) is rendered again with a new seed, up to three times, and the attempt with the fewest faults is kept. The character page shows the outcome: checked with no faults, faults still found (listed), or not checked because the checker was unavailable — in which case it stops retrying rather than re-rendering blindly. +- **The character sheet now shows the profile picture's face.** The turnaround and the story moment take their face from the profile portrait (face transfer after rendering), so the portrait and the sheet show the same person; in the story moment only the main character's face is replaced, not the extras'. - **Automatic check for generated character images** (`POST /api/character-image-check`). A vision model reports what it actually sees in a portrait, turnaround or scene — figure count, which way each figure faces, whether heads and feet are in frame, the apparent gender, pointed or round ears, wounds, plain or scenery background, and whether two images show the same person — and the app compares that with the character sheet. An unavailable checker or an unreadable answer counts as a failure, never a pass. It uses the LLM that is already loaded (no model swapping); calibrated at 14/14 on panels with known defects. - **LiteLLM model-group aliases in the model list.** The Active Language Model / OCR model dropdown now also lists the gateway's router aliases (ocr, default, cheap, vision, local-stt …), which LiteLLM's `/models` omits; they are read from `/router/settings` and shown first. Gateways without that endpoint are unaffected. diff --git a/architecture/character-visual-identity.md b/architecture/character-visual-identity.md index 0b6d424..20143c9 100644 --- a/architecture/character-visual-identity.md +++ b/architecture/character-visual-identity.md @@ -95,3 +95,30 @@ studio portrait, and one correct same-person pair), run twice: Ling never judged wrongly but on every turnaround ignored the JSON-only instruction and answered in prose. It also has free-tier rate limits and sends the images to a third party. + + +## Generation with checks and retries (face from the profile) + +The generation job (`POST /api/character-generate-consistent-images`) now runs: + +1. **Identity image** (text-to-image, full body). The profile picture is cut from it. **Portrait check** + on exactly that crop (one person, gender, ears, wounds, plain background). On failure: a new seed, + up to `image_check_max_attempts` (default 3), keeping the attempt with the fewest faults. +2. **Sheet** (turnaround triptych + story moment + props, one ComfyUI run). The profile's face is then + transferred with **ReActor** (`inswapper_128`, CodeFormer 0.5 restore): turnaround faces left to right + (front and side; the rear has none), and in the story panel only the *largest* face — the character in + the foreground, not an extra. **Checks:** turnaround, story panel, and same-person (profile vs. the front + view). Gender is judged *after* the face transfer, on what a viewer actually sees. Retried the same way. +3. The result carries `image_check` = `{verified, passed, attempts, failures[], checks{}}`, saved on the + sheet and shown on the character page: verified with no faults / faults still found (listed) / not + verified. + +**If the checker itself is unavailable, retries stop at once** and the pair is marked *not verified*: +re-rolling images cannot fix a missing checker, and would burn up to three renders per character. + +Why face transfer and not rendering the views from the portrait: the edit model keeps the source pose +(three front views, the pose guide copied into the costume — tried and reverted 2026-09-21). The +text-to-image triptych gets the poses right; swapping only the face afterwards fixes the identity. + +Settings: `image_check_enabled` (default on), `image_check_max_attempts` (1–5, default 3), +`image_check_model` (default: the active LLM). diff --git a/core/character_image_check.py b/core/character_image_check.py index 3de5c2f..2cf2b4e 100644 --- a/core/character_image_check.py +++ b/core/character_image_check.py @@ -176,3 +176,34 @@ def judge(kind: str, observed: dict, expected: dict) -> dict: failures.append(f"unknown panel kind: {kind}") return {"passed": not failures, "failures": failures, "observed": observed} + + +CHECKER_UNAVAILABLE = "checker unavailable" + + +def checker_unavailable(verdict: dict) -> bool: + """True when the verdict says nothing about the image because the checker could not run. + + Re-rendering cannot fix that, so the retry loop must stop instead of re-rolling images. + """ + return any(str(f).startswith(CHECKER_UNAVAILABLE) for f in (verdict or {}).get("failures") or []) + + +def failure_count(verdicts: dict) -> int: + """How far an attempt is from passing; lower is better. Missing verdicts count as one each.""" + return sum(len((v or {}).get("failures") or []) or (0 if (v or {}).get("passed") else 1) + for v in verdicts.values()) + + +def summarise(verdicts: dict, attempts: int, verified: bool) -> dict: + """What gets stored with the image pair and shown in the UI.""" + failures = [f"{kind}: {f}" for kind, v in verdicts.items() for f in ((v or {}).get("failures") or [])] + return { + "verified": verified, + "passed": verified and not failures, + "attempts": attempts, + "failures": failures, + "checks": {kind: {"passed": bool((v or {}).get("passed")), "failures": (v or {}).get("failures") or [], + "observed": (v or {}).get("observed") or {}} + for kind, v in verdicts.items()}, + } diff --git a/routes/conversation.py b/routes/conversation.py index ae195ed..6017d28 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -35,6 +35,7 @@ from core.voice import ( from core.voice_index import rebuild_voice_index from core.database import background_job_create, background_job_get, background_job_store_artifact, background_job_update from core.retry import request_with_retries +from core.character_image_check import checker_unavailable, failure_count, summarise from core.attribution_spans import ( SpanRequestError, build_messages as _span_build_messages, max_tokens_for as _span_max_tokens, parse_response as _span_parse_response, validate_spans as _span_validate, @@ -2695,6 +2696,42 @@ def _image_check_run(kind: str, image_uris: list[str], sheet: dict, settings: di return dict(verdict, expected=expected, model=model) +def _profile_crop_uri(identity_uri: str) -> str: + """The profile picture as the consistent-image workflow cuts it (node 5: 768x768 at x=128, + y=0, then scaled to 1024), so the portrait check sees exactly what the user will see.""" + raw = base64.b64decode(identity_uri.split(",", 1)[1]) + with Image.open(io.BytesIO(raw)) as source: + crop = source.convert("RGB").crop((128, 0, 896, 768)).resize((1024, 1024), Image.Resampling.LANCZOS) + out = io.BytesIO() + crop.save(out, format="PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") + + +def _front_panel_uri(turnaround_uri: str) -> str: + """The left third of the turnaround: the front view, used for the same-person check.""" + raw = base64.b64decode(turnaround_uri.split(",", 1)[1]) + with Image.open(io.BytesIO(raw)) as source: + img = source.convert("RGB") + front = img.crop((0, 0, img.width // 3, img.height)) + out = io.BytesIO() + front.save(out, format="PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") + + +def _check_sheet_panels(panels: dict, sheet: dict, settings: dict) -> dict: + """Check the rendered sheet panels. The story-moment gender is judged after the face + transfer, on what a viewer actually sees.""" + verdicts = {} + if panels.get("turnaround"): + verdicts["turnaround"] = _image_check_run("turnaround", [panels["turnaround"]], sheet, settings) + if panels.get("scene"): + verdicts["scene"] = _image_check_run("scene", [panels["scene"]], sheet, settings) + if panels.get("profile") and panels.get("turnaround"): + verdicts["same_person"] = _image_check_run( + "same_person", [panels["profile"], _front_panel_uri(panels["turnaround"])], sheet, settings) + return verdicts + + @router.post("/api/character-image-check") async def character_image_check(request: Request): """Check one generated panel against the character sheet. @@ -3420,12 +3457,41 @@ def _compose_character_reference(images: dict[str, bytes], prop_count: int) -> s return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode("ascii") +def _graph_add_face_transfer(graph: dict) -> None: + """Put the profile portrait's face onto the turnaround and the story panel (ReActor). + + The turnaround and the story moment are text-to-image renders that never see the portrait, + so the face drifted between the profile picture and the sheet. Drawing them *from* the + portrait with the edit model was tried and reverted (three front views, pose guide copied + into the costume); swapping only the face after the text-to-image render keeps the poses and + fixes the identity. Turnaround: faces left to right (the rear view has none). Story panel: + the largest face, i.e. the character in the foreground rather than an extra. + """ + def swap(target: str, order: str, index: str, key: str) -> str: + graph[f"{key}_face_options"] = {"class_type": "ReActorOptions", "inputs": { + "input_faces_order": order, "input_faces_index": index, "detect_gender_input": "no", + "source_faces_order": "large-small", "source_faces_index": "0", "detect_gender_source": "no", + "console_log_level": 1, "restore_swapped_only": True, + }} + graph[f"{key}_face"] = {"class_type": "ReActorFaceSwapOpt", "inputs": { + "enabled": True, "input_image": [target, 0], "source_image": ["7", 0], + "swap_model": "inswapper_128.onnx", "facedetection": "retinaface_resnet50", + "face_restore_model": "codeformer-v0.1.0.pth", "face_restore_visibility": 1.0, + "codeformer_weight": 0.5, "options": [f"{key}_face_options", 0], + }} + return f"{key}_face" + if "turnaround_save" in graph and "turnaround_decode" in graph: + graph["turnaround_save"]["inputs"]["images"] = [swap("turnaround_decode", "left-right", "0,1", "turnaround"), 0] + if "scene_t2i_save" in graph and "scene_t2i_decode" in graph: + graph["scene_t2i_save"]["inputs"]["images"] = [swap("scene_t2i_decode", "large-small", "0", "scene"), 0] + + def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, identity_image: str, profile_prompt: str, front_prompt: str, side_prompt: str, back_prompt: str, scene_prompt: str, prop_prompts: list[str], style_key: str = "", - progress=None) -> dict: + progress=None, face_transfer: bool = True) -> dict: """Generate independently controlled views, scene and unique prop tiles.""" try: workflow = json.loads(workflow_json) @@ -3462,6 +3528,8 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, graph["68"].setdefault("inputs", {})["seed"] = view_seed("identity-turnaround") for node_id, label in (("26", "prop-1"), ("52", "prop-2"), ("56", "prop-3")): graph[node_id].setdefault("inputs", {})["seed"] = view_seed(label) + if face_transfer: + _graph_add_face_transfer(graph) client_id = str(uuid.uuid4()) base = comfyui_url.rstrip("/") try: @@ -3535,6 +3603,11 @@ def _comfyui_run_consistent_images(comfyui_url: str, workflow_json: str, if key == "profile": result["profile"] = f"data:{vr.headers.get('Content-Type', 'image/png')};base64,{base64.b64encode(vr.content).decode('ascii')}" result["reference"] = _compose_character_reference(raw_images, len(prop_prompts)) + # Internal: the job checks these, then drops them before anything is persisted or returned. + result["_panels"] = { + key: "data:image/png;base64," + base64.b64encode(raw_images[key]).decode("ascii") + for key in ("profile", "turnaround", "scene") if key in raw_images + } return result @@ -3749,26 +3822,75 @@ async def character_generate_consistent_images(request: Request): identity_image = "data:" + (local_image.headers.get("Content-Type") or "image/png") + ";base64," + base64.b64encode(local_image.content).decode("ascii") except Exception as exc: raise RuntimeError(f"Could not load the existing identity image: {exc}") + # Just a heartbeat (refreshes "updated" so /status's stale_seconds doesn't fire a false + # "may be stuck" warning) — the elapsed counter itself is computed client-side. + heartbeat = lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)) + settings_now = _load_settings() + checking = settings_now.get("image_check_enabled", True) is not False + max_attempts = max(1, min(5, int(settings_now.get("image_check_max_attempts") or 3))) if checking else 1 + verdicts: dict = {} + verified = checking + attempts_used = 0 + + # 1) The identity image, from which the profile picture is cut. It is checked first + # because every later panel takes its face from it. if not identity_image: + best = None + for attempt in range(max_attempts): + _cij_check_cancelled(job_id) + _cij_set(job_id, stage="identity", + detail=f"Generating the identity portrait (attempt {attempt + 1} of {max_attempts})…") + candidate = _comfyui_run( + comfy_url, _bundled_character_identity_workflow(), "5", "text", "identity_save", + profile_prompt, f"{generation_key}|identity|{attempt}", progress=heartbeat, + ) + if not checking: + best = (0, candidate, None) + break + _cij_set(job_id, stage="identity", detail=f"Checking the portrait (attempt {attempt + 1})…") + verdict = _image_check_run("portrait", [_profile_crop_uri(candidate)], sheet, settings_now) + if checker_unavailable(verdict): + best, verified = (0, candidate, verdict), False + break + score = len(verdict.get("failures") or []) + if best is None or score < best[0]: + best = (score, candidate, verdict) + if verdict.get("passed"): + break + identity_image = best[1] + if best[2] is not None: + verdicts["portrait"] = best[2] + + # 2) The sheet: turnaround, story moment and props, with the portrait's face transferred. + best_sheet = None + for attempt in range(max_attempts): _cij_check_cancelled(job_id) - _cij_set(job_id, stage="identity", detail="Generating shared identity portrait…") - identity_image = _comfyui_run( - comfy_url, _bundled_character_identity_workflow(), - "5", "text", "identity_save", profile_prompt, generation_key, - # Just a heartbeat (refreshes "updated" so /status's - # stale_seconds doesn't fire a false "may be stuck" - # warning) — the elapsed counter itself is computed - # client-side from "started", not baked into this text. - progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)), + attempts_used = attempt + 1 + _cij_set(job_id, stage="render", + detail=f"Rendering turnaround, scene & props (attempt {attempt + 1} of {max_attempts})…") + candidate = _comfyui_run_consistent_images( + comfy_url, _bundled_consistent_comfyui_workflow(), identity_image, + profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, + f"{generation_key}|sheet|{attempt}", progress=heartbeat, ) - _cij_check_cancelled(job_id) - _cij_set(job_id, stage="render", detail="Rendering profile, turnaround, scene & props…") - result = _comfyui_run_consistent_images( - comfy_url, _bundled_consistent_comfyui_workflow(), identity_image, - profile_prompt, front_prompt, side_prompt, back_prompt, scene_prompt, prop_prompts, - generation_key, - progress=lambda elapsed: (_cij_check_cancelled(job_id), _cij_set(job_id)), - ) + panels = candidate.pop("_panels", {}) + if not (checking and verified): + best_sheet = (0, candidate, {}) + break + _cij_set(job_id, stage="render", detail=f"Checking the character sheet (attempt {attempt + 1})…") + sheet_verdicts = _check_sheet_panels(panels, sheet, settings_now) + if any(checker_unavailable(v) for v in sheet_verdicts.values()): + best_sheet, verified = (0, candidate, sheet_verdicts), False + break + score = failure_count(sheet_verdicts) + if best_sheet is None or score < best_sheet[0]: + best_sheet = (score, candidate, sheet_verdicts) + if score == 0: + break + result = best_sheet[1] + verdicts.update(best_sheet[2]) + if checking: + result["image_check"] = summarise(verdicts, attempts_used, verified) result = _consistent_image_result_metadata(sheet, result) result = _persist_consistent_image_artifacts(job_id, result) _cij_set(job_id, stage="done", detail="Done", done=True, result=result) diff --git a/static/js/library-characters.js b/static/js/library-characters.js index a62c85c..4a5aadb 100644 --- a/static/js/library-characters.js +++ b/static/js/library-characters.js @@ -1470,6 +1470,7 @@ async function _charDetailPage(rec, allChars, opts) { + (visualPairNeedsReview ? '