Extract character appearance policy

This commit is contained in:
2026-06-27 08:37:04 +02:00
parent e9cc75bd5f
commit 70a8698cbe
5 changed files with 326 additions and 195 deletions
+268
View File
@@ -0,0 +1,268 @@
from __future__ import annotations
import random
from typing import Any
try:
from . import character_config as character_policy
from . import character_profile as character_profile_policy
from . import character_slot as character_slot_policy
from . import generate_prompt_batches as g
from . import seed_config as seed_policy
except ImportError: # Allows local smoke tests with top-level imports.
import character_config as character_policy
import character_profile as character_profile_policy
import character_slot as character_slot_policy
import generate_prompt_batches as g
import seed_config as seed_policy
def _choose(rng: random.Random, items: list[Any]) -> Any:
return items[rng.randrange(len(items))]
def slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
if not slot:
return ""
outfit = character_policy.slot_value(slot.get("softcore_outfit"))
if outfit:
return outfit
if rng is None:
return ""
return character_policy.characteristic_choice(
character_policy.parse_characteristics_config(slot.get("characteristics")),
"softcore_outfits",
rng,
)
def slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
if not slot:
return ""
clothing = character_policy.slot_value(slot.get("hardcore_clothing"))
if clothing:
return clothing
if rng is None:
return ""
return character_policy.characteristic_choice(
character_policy.parse_characteristics_config(slot.get("characteristics")),
"hardcore_clothing",
rng,
)
def hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str:
hair_config = character_policy.parse_hair_config(slot.get("hair_config"))
color_choice = character_policy.normalize_hair_choice(slot.get("hair_color"), character_policy.CHARACTER_HAIR_COLOR_CHOICES)
length_choice = character_policy.normalize_hair_choice(slot.get("hair_length"), character_policy.CHARACTER_HAIR_LENGTH_CHOICES)
style_choice = character_policy.normalize_hair_choice(slot.get("hair_style"), character_policy.CHARACTER_HAIR_STYLE_CHOICES)
color_options = hair_config.get("colors") or []
length_options = hair_config.get("lengths") or []
style_options = hair_config.get("styles") or []
if (
color_choice == "random"
and length_choice == "random"
and style_choice == "random"
and not color_options
and not length_options
and not style_options
):
return ""
if color_choice != "random":
color_key = color_choice
elif color_options:
color_key = _choose(rng, color_options)
else:
color_key = character_policy.infer_hair_color_key(base_hair)
if length_choice != "random":
length_key = length_choice
elif length_options:
length_key = _choose(rng, length_options)
else:
length_key = character_policy.infer_hair_length_key(base_hair)
if style_choice != "random":
style_key = style_choice
elif style_options:
style_key = _choose(rng, style_options)
else:
style_key = character_policy.infer_hair_style_key(base_hair)
if color_key == "random":
color_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_COLOR_CHOICES)
if length_key == "random":
length_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_LENGTH_CHOICES)
if style_key == "random":
style_key = character_policy.choose_hair_key(rng, character_policy.CHARACTER_HAIR_STYLE_CHOICES)
if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"):
style_key = _choose(rng, ["ponytail", "braid", "bun", "messy_bun"])
return character_policy.hair_phrase_from_parts(color_key, length_key, style_key)
def appearance_for_subject(
rng: random.Random,
subject_type: str,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
) -> dict[str, str]:
if subject_type == "single_any":
subject_type = "woman" if rng.random() < 0.82 else "man"
if subject_type == "man":
men_ethnicity = ethnicity if ethnicity else "any"
subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity))
return {
"subject_type": "man",
"subject": subject,
"subject_phrase": subject,
"age": age,
"body": body,
"skin": skin,
"hair": hair,
"eyes": eyes,
"body_phrase": f"{body} figure",
}
subject, age, body, skin, hair, eyes = g.choose_woman(rng, ethnicity, no_plus_women, no_black)
figure_note = g.choose(rng, g.figure_pool(figure))
return {
"subject_type": "woman",
"subject": subject,
"subject_phrase": subject,
"age": age,
"body": body,
"skin": skin,
"hair": hair,
"eyes": eyes,
"body_phrase": character_profile_policy.body_phrase(body, figure_note),
"figure": figure_note,
}
def context_from_character_slot(
rng: random.Random,
slot: dict[str, Any],
subject_type: str,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
) -> dict[str, Any]:
slot_ethnicity = character_policy.slot_value(slot.get("ethnicity"))
slot_body = character_policy.slot_value(slot.get("body"))
effective_ethnicity = slot_ethnicity or ethnicity
effective_figure = character_slot_policy.slot_effective_figure(slot, subject_type, figure)
effective_no_plus = bool(no_plus_women) and not slot_body
effective_no_black = bool(no_black) and not slot_ethnicity
appearance_rng = character_slot_policy.slot_context_rng(slot, rng)
context = appearance_for_subject(
appearance_rng,
subject_type,
effective_ethnicity,
effective_figure,
effective_no_plus,
effective_no_black,
)
characteristics = character_policy.parse_characteristics_config(slot.get("characteristics"))
age = character_policy.slot_value(slot.get("age")) or character_policy.characteristic_choice(characteristics, "ages", appearance_rng)
body_phrase = character_policy.slot_value(slot.get("body_phrase"))
if not slot_body:
slot_body = character_policy.characteristic_choice(characteristics, "bodies", appearance_rng)
if age:
context["age"] = age
if slot_body:
context["body"] = slot_body
if subject_type == "woman":
context["body_phrase"] = character_profile_policy.body_phrase(slot_body, context.get("figure", ""))
else:
context["body_phrase"] = f"{slot_body} figure"
if body_phrase:
context["body_phrase"] = body_phrase
skin_value = character_policy.slot_value(slot.get("skin"))
if skin_value:
context["skin"] = skin_value
eyes_value = character_policy.slot_value(slot.get("eyes"))
if not eyes_value:
eyes_value = character_policy.eye_phrase_from_key(character_policy.characteristic_choice(characteristics, "eyes", appearance_rng))
if eyes_value:
context["eyes"] = eyes_value
hair_value = character_policy.slot_value(slot.get("hair"))
if hair_value:
context["hair"] = hair_value
else:
hair_descriptor = hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng)
if hair_descriptor:
context["hair"] = hair_descriptor
context["descriptor_detail"] = character_policy.normalize_descriptor_detail(slot.get("descriptor_detail"))
context["presence_mode"] = character_policy.normalize_presence_mode(slot.get("presence_mode"), subject_type)
context["expression_enabled"] = character_slot_policy.slot_expression_enabled(slot)
expression_intensity = character_slot_policy.slot_expression_intensity(slot)
if expression_intensity is not None:
context["expression_intensity"] = expression_intensity
context["subject_type"] = subject_type
context["subject"] = subject_type
context["subject_phrase"] = subject_type
return context
def character_context_for_label(
label: str,
label_map: dict[str, dict[str, Any]],
rng: random.Random,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
) -> tuple[dict[str, Any], dict[str, Any] | None]:
subject_type = "man" if label.startswith("Man ") else "woman"
slot = label_map.get(label)
if slot:
return context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot
return appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None
def apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
for key in (
"subject_type",
"subject",
"subject_phrase",
"age",
"body",
"body_phrase",
"skin",
"hair",
"eyes",
"figure",
"descriptor_detail",
"presence_mode",
"expression_enabled",
"expression_intensity",
):
value = context.get(key)
if value is not None and value != "":
row[key] = value
if context.get("age"):
row["age_band"] = context["age"]
return row
def row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]:
slots = character_slot_policy.parse_character_cast(character_slot)
if not slots:
return {}
slot = slots[-1]
if character_slot_policy.slot_seed(slot) >= 0:
subject_type = str(slot.get("subject_type") or "woman")
return context_from_character_slot(
random.Random(seed_policy.row_seed(character_slot_policy.slot_seed(slot), 1, 719)),
slot,
subject_type,
"any",
"curvy",
False,
False,
)
return slot
+5 -2
View File
@@ -144,8 +144,11 @@ Already isolated:
- character slot JSON construction, character-cast parsing, slot normalization,
slot summary text, slot expression override policy, slot seed helpers, and
slot figure/ethnicity normalization live in `character_slot.py`;
`prompt_builder.py` keeps public delegate wrappers and still resolves
generation-time appearance context from normalized slots.
`prompt_builder.py` keeps public delegate wrappers.
- generation-time subject appearance selection, normalized-slot context
resolution, slot hair/outfit/clothing selection, character-context row
application, and character-slot-to-profile-row conversion live in
`character_appearance.py`; `prompt_builder.py` keeps public delegate wrappers.
- character manual-detail config, profile name/path policy, profile JSON
normalization, descriptor assembly, save/load/rename/delete operations,
fallback profile loading, and context override application live in
+4 -3
View File
@@ -72,6 +72,7 @@ Core helper ownership:
| `category_cast_config.py` | Category preset and cast preset schemas, category/cast config JSON builders, choice lists, and config parsers used by route nodes. |
| `cast_context.py` | Generation-time cast count phrases, configured-cast context metadata, character-slot label assignment, cast-summary wording, scene-kind labels, and couple count normalization. |
| `camera_config.py` | Camera option schema, direct/orbit/Qwen camera JSON builders, camera config parsing, plain camera directive text, and camera caption labels. |
| `character_appearance.py` | Generation-time subject appearance selection, normalized-slot context resolution, slot hair/outfit/clothing selection, character-context row application, and character-slot-to-profile-row conversion. |
| `character_config.py` | Character choice lists, descriptor detail/presence/slot-seed normalization, characteristic-list JSON builders/parsers, eye labels, hair config builders/parsers, and hair phrase helpers. |
| `character_profile.py` | Character manual-detail config, profile name/path policy, profile JSON normalization, descriptor assembly, save/load/rename/delete operations, fallback profile loading, and context override application. |
| `character_slot.py` | Character slot JSON construction, character-cast parsing, slot normalization, slot summary text, slot expression override policy, slot seed helpers, and slot figure/ethnicity normalization. |
@@ -390,9 +391,9 @@ Important behavior:
Edit targets:
- Character slot JSON/parsing/summary: `character_slot.py`; appearance field
generation: `_context_from_character_slot`, `_character_context_for_label`;
character-slot label assignment:
- Character slot JSON/parsing/summary: `character_slot.py`; generation-time
appearance field resolution: `character_appearance.py`; character-slot label
assignment:
`cast_context.character_slot_label_map`; pair cast descriptor entry assembly:
`pair_cast.cast_descriptor_entries`.
- Profile save/load: `SxCPCharacterProfileSave`,
+31 -190
View File
@@ -26,6 +26,7 @@ try:
from . import camera_config as camera_policy
from . import cast_context as cast_context_policy
from . import category_template_metadata as item_template_policy
from . import character_appearance as character_appearance_policy
from . import character_config as character_policy
from . import character_profile as character_profile_policy
from . import character_slot as character_slot_policy
@@ -71,6 +72,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
import camera_config as camera_policy
import cast_context as cast_context_policy
import category_template_metadata as item_template_policy
import character_appearance as character_appearance_policy
import character_config as character_policy
import character_profile as character_profile_policy
import character_slot as character_slot_policy
@@ -2117,51 +2119,7 @@ def _hair_phrase_from_parts(color_key: str, length_key: str, style_key: str) ->
def _hair_descriptor_from_slot(base_hair: Any, slot: dict[str, Any], rng: random.Random) -> str:
hair_config = _parse_hair_config(slot.get("hair_config"))
color_choice = _normalize_hair_choice(slot.get("hair_color"), CHARACTER_HAIR_COLOR_CHOICES)
length_choice = _normalize_hair_choice(slot.get("hair_length"), CHARACTER_HAIR_LENGTH_CHOICES)
style_choice = _normalize_hair_choice(slot.get("hair_style"), CHARACTER_HAIR_STYLE_CHOICES)
color_options = hair_config.get("colors") or []
length_options = hair_config.get("lengths") or []
style_options = hair_config.get("styles") or []
if (
color_choice == "random"
and length_choice == "random"
and style_choice == "random"
and not color_options
and not length_options
and not style_options
):
return ""
if color_choice != "random":
color_key = color_choice
elif color_options:
color_key = g.choose(rng, color_options)
else:
color_key = _infer_hair_color_key(base_hair)
if length_choice != "random":
length_key = length_choice
elif length_options:
length_key = g.choose(rng, length_options)
else:
length_key = _infer_hair_length_key(base_hair)
if style_choice != "random":
style_key = style_choice
elif style_options:
style_key = g.choose(rng, style_options)
else:
style_key = _infer_hair_style_key(base_hair)
if color_key == "random":
color_key = _choose_hair_key(rng, CHARACTER_HAIR_COLOR_CHOICES)
if length_key == "random":
length_key = _choose_hair_key(rng, CHARACTER_HAIR_LENGTH_CHOICES)
if style_key == "random":
style_key = _choose_hair_key(rng, CHARACTER_HAIR_STYLE_CHOICES)
if length_key == "updo" and style_key not in ("ponytail", "braid", "braids", "bun", "messy_bun", "locs", "twists"):
style_key = g.choose(rng, ["ponytail", "braid", "bun", "messy_bun"])
return _hair_phrase_from_parts(color_key, length_key, style_key)
return character_appearance_policy.hair_descriptor_from_slot(base_hair, slot, rng)
def _normalize_character_slot(slot: dict[str, Any]) -> dict[str, Any]:
@@ -2272,25 +2230,11 @@ def _pov_composition_prompt(composition: Any, pov_labels: list[str]) -> str:
def _slot_softcore_outfit(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
if not slot:
return ""
outfit = _slot_value(slot.get("softcore_outfit"))
if outfit:
return outfit
if rng is None:
return ""
return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "softcore_outfits", rng)
return character_appearance_policy.slot_softcore_outfit(slot, rng)
def _slot_hardcore_clothing(slot: dict[str, Any] | None, rng: random.Random | None = None) -> str:
if not slot:
return ""
clothing = _slot_value(slot.get("hardcore_clothing"))
if clothing:
return clothing
if rng is None:
return ""
return _characteristic_choice(_parse_characteristics_config(slot.get("characteristics")), "hardcore_clothing", rng)
return character_appearance_policy.slot_hardcore_clothing(slot, rng)
def _context_from_character_slot(
@@ -2302,63 +2246,16 @@ def _context_from_character_slot(
no_plus_women: bool,
no_black: bool,
) -> dict[str, str]:
slot_ethnicity = _slot_value(slot.get("ethnicity"))
slot_body = _slot_value(slot.get("body"))
effective_ethnicity = slot_ethnicity or ethnicity
effective_figure = _slot_effective_figure(slot, subject_type, figure)
effective_no_plus = bool(no_plus_women) and not slot_body
effective_no_black = bool(no_black) and not slot_ethnicity
appearance_rng = _slot_context_rng(slot, rng)
context = _appearance_for_subject(
appearance_rng,
return character_appearance_policy.context_from_character_slot(
rng,
slot,
subject_type,
effective_ethnicity,
effective_figure,
effective_no_plus,
effective_no_black,
ethnicity,
figure,
no_plus_women,
no_black,
)
characteristics = _parse_characteristics_config(slot.get("characteristics"))
age = _slot_value(slot.get("age")) or _characteristic_choice(characteristics, "ages", appearance_rng)
body_phrase = _slot_value(slot.get("body_phrase"))
if not slot_body:
slot_body = _characteristic_choice(characteristics, "bodies", appearance_rng)
if age:
context["age"] = age
if slot_body:
context["body"] = slot_body
if subject_type == "woman":
context["body_phrase"] = _body_phrase(slot_body, context.get("figure", ""))
else:
context["body_phrase"] = f"{slot_body} figure"
if body_phrase:
context["body_phrase"] = body_phrase
skin_value = _slot_value(slot.get("skin"))
if skin_value:
context["skin"] = skin_value
eyes_value = _slot_value(slot.get("eyes"))
if not eyes_value:
eyes_value = _eye_phrase_from_key(_characteristic_choice(characteristics, "eyes", appearance_rng))
if eyes_value:
context["eyes"] = eyes_value
hair_value = _slot_value(slot.get("hair"))
if hair_value:
context["hair"] = hair_value
else:
hair_descriptor = _hair_descriptor_from_slot(context.get("hair"), slot, appearance_rng)
if hair_descriptor:
context["hair"] = hair_descriptor
context["descriptor_detail"] = _normalize_descriptor_detail(slot.get("descriptor_detail"))
context["presence_mode"] = _normalize_presence_mode(slot.get("presence_mode"), subject_type)
context["expression_enabled"] = _slot_expression_enabled(slot)
expression_intensity = _slot_expression_intensity(slot)
if expression_intensity is not None:
context["expression_intensity"] = expression_intensity
context["subject_type"] = subject_type
context["subject"] = subject_type
context["subject_phrase"] = subject_type
return context
def _character_context_for_label(
label: str,
@@ -2369,36 +2266,19 @@ def _character_context_for_label(
no_plus_women: bool,
no_black: bool,
) -> tuple[dict[str, str], dict[str, Any] | None]:
subject_type = "man" if label.startswith("Man ") else "woman"
slot = label_map.get(label)
if slot:
return _context_from_character_slot(rng, slot, subject_type, ethnicity, figure, no_plus_women, no_black), slot
return _appearance_for_subject(rng, subject_type, ethnicity, figure, no_plus_women, no_black), None
return character_appearance_policy.character_context_for_label(
label,
label_map,
rng,
ethnicity,
figure,
no_plus_women,
no_black,
)
def _apply_character_context_to_row(row: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
for key in (
"subject_type",
"subject",
"subject_phrase",
"age",
"body",
"body_phrase",
"skin",
"hair",
"eyes",
"figure",
"descriptor_detail",
"presence_mode",
"expression_enabled",
"expression_intensity",
):
value = context.get(key)
if value is not None and value != "":
row[key] = value
if context.get("age"):
row["age_band"] = context["age"]
return row
return character_appearance_policy.apply_character_context_to_row(row, context)
def _cast_descriptor_entries(
@@ -2439,22 +2319,7 @@ def _row_from_profile_metadata(metadata_json: str | dict[str, Any] | None) -> di
def _row_from_character_slot(character_slot: str | dict[str, Any] | None) -> dict[str, Any]:
slots = _parse_character_cast(character_slot)
if not slots:
return {}
slot = slots[-1]
if _slot_seed(slot) >= 0:
subject_type = str(slot.get("subject_type") or "woman")
return _context_from_character_slot(
random.Random(_row_seed(_slot_seed(slot), 1, 719)),
slot,
subject_type,
"any",
"curvy",
False,
False,
)
return slot
return character_appearance_policy.row_from_character_slot(character_slot)
def _character_profile_descriptor(profile: dict[str, Any]) -> str:
@@ -2591,38 +2456,14 @@ def _appearance_for_subject(
no_plus_women: bool,
no_black: bool,
) -> dict[str, str]:
if subject_type == "single_any":
subject_type = "woman" if rng.random() < 0.82 else "man"
if subject_type == "man":
men_ethnicity = ethnicity if ethnicity else "any"
subject, age, body, skin, hair, eyes = g.choose(rng, g.by_ethnicity(g.MEN, men_ethnicity))
return {
"subject_type": "man",
"subject": subject,
"subject_phrase": subject,
"age": age,
"body": body,
"skin": skin,
"hair": hair,
"eyes": eyes,
"body_phrase": f"{body} figure",
}
subject, age, body, skin, hair, eyes = g.choose_woman(rng, ethnicity, no_plus_women, no_black)
figure_note = g.choose(rng, g.figure_pool(figure))
return {
"subject_type": "woman",
"subject": subject,
"subject_phrase": subject,
"age": age,
"body": body,
"skin": skin,
"hair": hair,
"eyes": eyes,
"body_phrase": _body_phrase(body, figure_note),
"figure": figure_note,
}
return character_appearance_policy.appearance_for_subject(
rng,
subject_type,
ethnicity,
figure,
no_plus_women,
no_black,
)
def _count_phrase(count: int, singular: str, plural: str) -> str:
+18
View File
@@ -28,6 +28,7 @@ import caption_naturalizer # noqa: E402
import caption_policy # noqa: E402
import cast_context # noqa: E402
import category_template_metadata # noqa: E402
import character_appearance # noqa: E402
import character_config # noqa: E402
import character_profile # noqa: E402
import character_slot # noqa: E402
@@ -935,6 +936,23 @@ def smoke_character_config_policy() -> None:
== character_slot.slot_effective_figure({"slot_seed": 123, "figure": "random"}, "woman", "curvy"),
"Prompt builder seeded slot figure should delegate to character_slot",
)
_expect(
pb._appearance_for_subject(random.Random(9), "woman", "western_european", "balanced", False, False)
== character_appearance.appearance_for_subject(random.Random(9), "woman", "western_european", "balanced", False, False),
"Prompt builder appearance selection should delegate to character_appearance",
)
_expect(
pb._context_from_character_slot(random.Random(11), slot, "man", "any", "curvy", False, False)
== character_appearance.context_from_character_slot(random.Random(11), slot, "man", "any", "curvy", False, False),
"Prompt builder slot context should delegate to character_appearance",
)
_expect(
pb._row_from_character_slot(slot_result["character_slot"])
== character_appearance.row_from_character_slot(slot_result["character_slot"]),
"Prompt builder slot row conversion should delegate to character_appearance",
)
row = character_appearance.apply_character_context_to_row({}, {"age": "44-year-old adult", "body": "stocky"})
_expect(row.get("age_band") == "44-year-old adult" and row.get("body") == "stocky", "Character context row application changed")
def smoke_character_profile_policy() -> None: