diff --git a/docs/prompt-pool-routing-map.md b/docs/prompt-pool-routing-map.md index e2c05f9..9aef8bb 100644 --- a/docs/prompt-pool-routing-map.md +++ b/docs/prompt-pool-routing-map.md @@ -132,7 +132,7 @@ Core helper ownership: | `krea_row_fields.py` | Shared Krea normal-row field extraction for item, scene, pose, expression, composition/source-composition, camera, and style used by normal and configured-cast routes. | | `krea_cast.py` | Shared formatter cast descriptor parsing, cast labels, cast prose, natural cast descriptor text, and label replacement used by Krea2 and caption routes. | | `prompt_hygiene.py` | Generic prompt, caption, and negative-prompt cleanup, including route-agnostic negative-prompt merge/dedupe. | -| `row_normalization.py` | Final prompt-row and pair metadata normalization: legacy built-in subject/count/scene/item/pose/expression metadata enrichment, trigger prepending, extra-positive append, negative merge/dedupe, caption-part joining, embedded soft/hard row output and side-metadata synchronization, and embedded row sanitation. | +| `row_normalization.py` | Final prompt-row and pair metadata normalization: legacy built-in subject/count/scene/appearance/item/pose/expression metadata enrichment, trigger prepending, extra-positive append, negative merge/dedupe, caption-part joining, embedded soft/hard row output and side-metadata synchronization, and embedded row sanitation. | | `formatter_detail.py` | Shared formatter detail-level choices, normalization, and concise/balanced/dense gates used by Krea2 and caption routes. | | `formatter_input.py` | Shared formatter input parsing: text cleanup, metadata/source JSON detection, trigger-prefix stripping, shared prompt field-label inventory, fallback field-label stripping, `Avoid:` splitting, prompt-field extraction, and metadata row-value fallback. | | `formatter_target.py` | Shared formatter target choices and normalization for `auto`, `single`, `softcore`, and `hardcore`, including pair-side selection and combined-caption inclusion policy. | diff --git a/row_normalization.py b/row_normalization.py index fb5a3d5..8549d6d 100644 --- a/row_normalization.py +++ b/row_normalization.py @@ -4,9 +4,11 @@ import re from typing import Any try: + from . import generate_prompt_batches as prompt_batches from . import row_location as row_location_policy from .prompt_hygiene import combine_negative_text, sanitize_caption_text, sanitize_negative_text, sanitize_prompt_text except ImportError: # Allows local smoke tests with `python tools/prompt_smoke.py`. + import generate_prompt_batches as prompt_batches import row_location as row_location_policy from prompt_hygiene import combine_negative_text, sanitize_caption_text, sanitize_negative_text, sanitize_prompt_text @@ -113,6 +115,64 @@ def _clean_legacy_clothing(value: Any) -> str: return text.strip(" ,") +def _legacy_body_phrase(row: dict[str, Any]) -> str: + body_phrase = _clean_text(row.get("body_phrase")) + if body_phrase: + return body_phrase + body = _clean_text(row.get("body_type") or row.get("body")) + if not body: + return "" + figure_note = _clean_text(row.get("figure") or row.get("figure_note")) + return _clean_text(prompt_batches.make_body_phrase(body, figure_note)) + + +def _strip_legacy_caption_lead(caption: str) -> str: + pieces = caption.split(", ", 1) + if len(pieces) == 2 and pieces[0].strip().lower() not in ("woman", "man"): + return pieces[1].strip() + return caption + + +def _legacy_single_caption_front(row: dict[str, Any]) -> dict[str, str]: + caption = _strip_legacy_caption_lead(_clean_text(row.get("caption"))) + if not caption: + return {} + + subject = _clean_text(row.get("primary_subject") or row.get("subject")) + age = _clean_text(row.get("age_band") or row.get("age")) + body_phrase = _legacy_body_phrase(row) + if subject.lower() in ("woman", "man") and age and body_phrase: + prefix = f"{subject}, {age}, {body_phrase}, " + if caption.lower().startswith(prefix.lower()): + try: + skin, hair, eyes, _rest = caption[len(prefix) :].split(", ", 3) + except ValueError: + return {} + return { + "caption_subject": subject, + "caption_age": age, + "caption_body_phrase": body_phrase, + "caption_skin": skin, + "caption_hair": hair, + "caption_eyes": eyes, + } + + pieces = [piece.strip() for piece in caption.split(", ", 6)] + if len(pieces) < 7: + return {} + subject, age, body_phrase, skin, hair, eyes, _rest = pieces + if subject.lower() not in ("woman", "man"): + return {} + return { + "caption_subject": subject, + "caption_age": age, + "caption_body_phrase": body_phrase, + "caption_skin": skin, + "caption_hair": hair, + "caption_eyes": eyes, + } + + def enrich_legacy_row_metadata(row: dict[str, Any]) -> dict[str, Any]: if row.get("source") != "built_in_generator": return row @@ -133,6 +193,12 @@ def enrich_legacy_row_metadata(row: dict[str, Any]) -> dict[str, Any]: if scene_text: row["scene_text"] = scene_text row.setdefault("scene_entry", {"slug": scene_slug, "prompt": scene_text}) + if subject_type in ("woman", "man"): + front = _legacy_single_caption_front(row) + _setdefault_nonempty(row, "body_phrase", front.get("caption_body_phrase", "")) + _setdefault_nonempty(row, "skin", front.get("caption_skin", "")) + _setdefault_nonempty(row, "hair", front.get("caption_hair", "")) + _setdefault_nonempty(row, "eyes", front.get("caption_eyes", "")) pose = _clean_legacy_pose(_legacy_prompt_field(row, "Pose")) _setdefault_nonempty(row, "pose", pose) expression = _legacy_prompt_field(row, "Facial expression") or _legacy_prompt_field(row, "Facial expressions") diff --git a/tools/prompt_smoke.py b/tools/prompt_smoke.py index 72dbd85..017e1d2 100644 --- a/tools/prompt_smoke.py +++ b/tools/prompt_smoke.py @@ -580,6 +580,10 @@ def smoke_builtin_single() -> None: _expect(row.get("scene_entry", {}).get("slug") == row.get("scene"), "builtin single row lost scene_entry slug") item = _expect_text("builtin_single_woman.item", row.get("item"), 8) pose = _expect_text("builtin_single_woman.pose", row.get("pose"), 8) + body_phrase = _expect_text("builtin_single_woman.body_phrase", row.get("body_phrase"), 8) + skin = _expect_text("builtin_single_woman.skin", row.get("skin"), 8) + hair = _expect_text("builtin_single_woman.hair", row.get("hair"), 5) + eyes = _expect_text("builtin_single_woman.eyes", row.get("eyes"), 4) _expect(row.get("item_label") == "Clothing", "builtin single row lost item label") _expect(row.get("clothing") == item, "builtin single row did not mirror clothing into item metadata") _expect("fashion editorial styling" not in item.lower(), "builtin single item kept generic styling suffix") @@ -613,13 +617,24 @@ def smoke_builtin_single() -> None: ) _expect(item in str(krea_metadata.get("krea_prompt", "")), "Krea metadata-only built-in route lost explicit item") _expect(pose in str(krea_metadata.get("krea_prompt", "")), "Krea metadata-only built-in route lost explicit pose") + _expect(body_phrase in str(krea_metadata.get("krea_prompt", "")), "Krea metadata-only built-in route lost body phrase") + _expect(skin in str(krea_metadata.get("krea_prompt", "")), "Krea metadata-only built-in route lost skin") + _expect(hair in str(krea_metadata.get("krea_prompt", "")), "Krea metadata-only built-in route lost hair") + _expect(eyes in str(krea_metadata.get("krea_prompt", "")), "Krea metadata-only built-in route lost eyes") item_anchor = " ".join(re.findall(r"[a-z0-9]+", item.lower())[:3]) pose_anchor = " ".join(re.findall(r"[a-z0-9]+", pose.lower())[:4]) sdxl_metadata_prompt = str(sdxl_metadata.get("sdxl_prompt", "")).lower() _expect(item_anchor in sdxl_metadata_prompt, "SDXL metadata-only built-in route lost explicit item") _expect(pose_anchor in sdxl_metadata_prompt, "SDXL metadata-only built-in route lost explicit pose") + for body_tag in sdxl_tag_policy.split_tag_text(body_phrase): + _expect(body_tag.lower() in sdxl_metadata_prompt, f"SDXL metadata-only built-in route lost body tag: {body_tag}") + _expect(skin.lower() in sdxl_metadata_prompt, "SDXL metadata-only built-in route lost skin") + _expect(hair.lower() in sdxl_metadata_prompt, "SDXL metadata-only built-in route lost hair") + _expect(eyes.lower() in sdxl_metadata_prompt, "SDXL metadata-only built-in route lost eyes") _expect(caption_metadata_method.endswith("metadata(single)"), "Caption metadata-only built-in route did not use single metadata branch") _expect(item in caption_metadata and pose in caption_metadata, "Caption metadata-only built-in route lost explicit item or pose") + _expect(body_phrase in caption_metadata and skin in caption_metadata, "Caption metadata-only built-in route lost appearance") + _expect(hair in caption_metadata and eyes in caption_metadata, "Caption metadata-only built-in route lost hair or eyes") _expect_formatter_outputs(row, "builtin_single_woman", target="single") @@ -2877,6 +2892,33 @@ def smoke_row_normalization_policy() -> None: _expect("calm smile" in str(legacy_couple.get("expression", "")), "Legacy couple row lost expression metadata") _expect("cast_summary" not in legacy_couple, "Legacy couple row should not gain configured-cast summary") + legacy_single = row_normalization.normalize_prompt_row( + { + "source": "built_in_generator", + "primary_subject": "woman", + "scene": "studio", + "age_band": "25-year-old adult", + "body_type": "curvy", + "figure": "soft curves, defined waist", + "caption": ( + f"{Trigger}, woman, 25-year-old adult, curvy figure with soft curves, defined waist, " + "warm skin, short blonde hair, blue eyes, pose, expression, clothing, scene, composition" + ), + "prompt": ( + "A woman. Scene: old studio. Pose: standing calmly. " + "Facial expression: direct look. Clothing: fitted dress, fashion editorial styling. " + "Composition: vertical portrait." + ), + "negative_prompt": "bad anatomy", + }, + active_trigger=Trigger, + prepend_trigger_to_prompt=False, + ) + _expect(legacy_single.get("body_phrase") == "curvy figure with soft curves, defined waist", "Legacy single row lost body phrase") + _expect(legacy_single.get("skin") == "warm skin", "Legacy single row lost skin metadata") + _expect(legacy_single.get("hair") == "short blonde hair", "Legacy single row lost hair metadata") + _expect(legacy_single.get("eyes") == "blue eyes", "Legacy single row lost eye metadata") + legacy_group = row_normalization.normalize_prompt_row( { "source": "built_in_generator",