Extract row subject route policy

This commit is contained in:
2026-06-27 09:49:50 +02:00
parent d31d513ec3
commit f7164480df
5 changed files with 268 additions and 51 deletions
@@ -155,6 +155,10 @@ Already isolated:
- row subject-context routing for single, couple, configured-cast, group, and - row subject-context routing for single, couple, configured-cast, group, and
layout subjects lives in `subject_context.py`; it combines appearance policy, layout subjects lives in `subject_context.py`; it combines appearance policy,
cast metadata, and generator subject pools behind one row-facing entry point. cast metadata, and generator subject pools behind one row-facing entry point.
- row subject route orchestration, character slot/profile precedence,
configured-cast POV labels, visible cast descriptor collection, and
descriptor prompt cleanup live in `row_subject_route.py`;
`prompt_builder.py` keeps a public delegate wrapper.
- ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter - ethnicity/filter choices, advanced filter JSON, ethnicity-list JSON, filter
parsing, and ethnicity normalization live in `filter_config.py`; character parsing, and ethnicity normalization live in `filter_config.py`; character
routes and builder filters use `prompt_builder.py` delegate wrappers. routes and builder filters use `prompt_builder.py` delegate wrappers.
+7 -6
View File
@@ -86,6 +86,7 @@ Core helper ownership:
| `generation_profile_config.py` | Generation profile presets, profile option overrides, trigger policy, expression/pose/clothing config normalization, and profile config parsing. | | `generation_profile_config.py` | Generation profile presets, profile option overrides, trigger policy, expression/pose/clothing config normalization, and profile config parsing. |
| `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. | | `seed_config.py` | Seed axis salts/aliases, seed mode choices, global/axis lock JSON builders, seed config parsing, row seed math, and deterministic axis RNG construction. |
| `subject_context.py` | Row subject-context routing for single, couple, configured-cast, group, and layout subjects, combining appearance policy, cast metadata, and generator subject pools. | | `subject_context.py` | Row subject-context routing for single, couple, configured-cast, group, and layout subjects, combining appearance policy, cast metadata, and generator subject pools. |
| `row_subject_route.py` | Row subject route orchestration, character slot/profile precedence, configured-cast POV labels, visible cast descriptor collection, and descriptor prompt cleanup. |
| `location_config.py` | Location/composition preset schemas, themed location packs, custom location/composition parsing, pool merge behavior, and location/composition config parsing. | | `location_config.py` | Location/composition preset schemas, themed location packs, custom location/composition parsing, pool merge behavior, and location/composition config parsing. |
| `row_location.py` | Built-in row location/composition config application, deterministic scene/composition choice, source metadata, and legacy prompt/caption rewrites. | | `row_location.py` | Built-in row location/composition config application, deterministic scene/composition choice, source metadata, and legacy prompt/caption rewrites. |
| `row_expression.py` | Row expression cleanup, expression intensity weighting, character-slot/cast expression override resolution, per-character expression selection, and action-aware character-expression sanitizing. | | `row_expression.py` | Row expression cleanup, expression intensity weighting, character-slot/cast expression override resolution, per-character expression selection, and action-aware character-expression sanitizing. |
@@ -498,12 +499,12 @@ plain prompt text. When debugging, inspect these fields before editing pools.
| `camera_config` | Camera nodes/parser | Krea/SDXL/debug | Structured camera settings. | | `camera_config` | Camera nodes/parser | Krea/SDXL/debug | Structured camera settings. |
| `camera_directive` | `_camera_directive` | Krea/Naturalizer/prompt text | Human camera sentence. Suppressed for POV. | | `camera_directive` | `_camera_directive` | Krea/Naturalizer/prompt text | Human camera sentence. Suppressed for POV. |
| `camera_scene_directive` | scene-camera adapter | Krea/Naturalizer/prompt text | Location-aware camera layout sentence. | | `camera_scene_directive` | scene-camera adapter | Krea/Naturalizer/prompt text | Location-aware camera layout sentence. |
| `subject_type`, `subject_phrase` | Subject/context builder | Formatters | Single/couple/group/configured cast route. | | `subject_type`, `subject_phrase` | `row_subject_route.resolve_subject_route` | Formatters | Single/couple/group/configured cast route. |
| `women_count`, `men_count`, `person_count` | Cast route | Pair/formatters/debug | Effective cast counts. | | `women_count`, `men_count`, `person_count` | `row_subject_route.resolve_subject_route` | Pair/formatters/debug | Effective cast counts. |
| `cast_descriptors`, `cast_descriptor_text` | Character/cast route | Krea/SDXL/Naturalizer | Visible cast descriptors. | | `cast_descriptors`, `cast_descriptor_text` | `row_subject_route.resolve_subject_route` | Krea/SDXL/Naturalizer | Visible cast descriptors. |
| `character_cast_slots` | Character slot chain | POV/camera/formatters | Raw configured slots. | | `character_cast_slots` | `row_subject_route.resolve_subject_route` | POV/camera/formatters | Raw configured slots. |
| `character_slot_status`, `character_profile_status` | Character/profile application | Debug | Explains whether slot/profile was applied or skipped. | | `character_slot_status`, `character_profile_status` | `row_subject_route.resolve_subject_route` | Debug | Explains whether slot/profile was applied or skipped. |
| `pov_character_labels` | Character slot presence mode | Krea/prompt/camera | Labels omitted from visible cast and rewritten as first-person POV. | | `pov_character_labels` | `row_subject_route.resolve_subject_route` | Krea/prompt/camera | Labels omitted from visible cast and rewritten as first-person POV. |
| `hardcore_position_config` | Hardcore position/filter nodes | Debug | Active hardcore family/position/action/interaction constraints, including `interaction_only` and `manual_only`. | | `hardcore_position_config` | Hardcore position/filter nodes | Debug | Active hardcore family/position/action/interaction constraints, including `interaction_only` and `manual_only`. |
| `negative_prompt` | Category/pair/default negative route | Formatter output | Base negative text before formatter extras. | | `negative_prompt` | Category/pair/default negative route | Formatter output | Base negative text before formatter extras. |
| `trigger` | Builder input | Formatter/fallback/debug | Active trigger after fallback to default. | | `trigger` | Builder input | Formatter/fallback/debug | Active trigger after fallback to default. |
+57 -45
View File
@@ -43,6 +43,7 @@ try:
from . import row_pools as row_pool_policy from . import row_pools as row_pool_policy
from . import row_rendering as row_rendering_policy from . import row_rendering as row_rendering_policy
from . import row_route_metadata as row_route_policy from . import row_route_metadata as row_route_policy
from . import row_subject_route as row_subject_route_policy
from . import seed_config as seed_policy from . import seed_config as seed_policy
from . import subject_context as subject_context_policy from . import subject_context as subject_context_policy
from .hardcore_text_cleanup import ( from .hardcore_text_cleanup import (
@@ -89,6 +90,7 @@ except ImportError: # Allows local smoke tests with `python -c`.
import row_pools as row_pool_policy import row_pools as row_pool_policy
import row_rendering as row_rendering_policy import row_rendering as row_rendering_policy
import row_route_metadata as row_route_policy import row_route_metadata as row_route_policy
import row_subject_route as row_subject_route_policy
import seed_config as seed_policy import seed_config as seed_policy
import subject_context as subject_context_policy import subject_context as subject_context_policy
from hardcore_text_cleanup import ( from hardcore_text_cleanup import (
@@ -1960,6 +1962,37 @@ def _subject_context(
) )
def _subject_route(
*,
subject_type: str,
seed_config: dict[str, int],
seed: int,
row_number: int,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
women_count: int,
men_count: int,
character_profile: str | dict[str, Any] | None = None,
character_cast: str | dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any]:
return row_subject_route_policy.resolve_subject_route(
subject_type=subject_type,
seed_config=seed_config,
seed=seed,
row_number=row_number,
ethnicity=ethnicity,
figure=figure,
no_plus_women=no_plus_women,
no_black=no_black,
women_count=women_count,
men_count=men_count,
character_profile=character_profile,
character_cast=character_cast,
)
def _scene_pool( def _scene_pool(
category: dict[str, Any], category: dict[str, Any],
subcategory: dict[str, Any], subcategory: dict[str, Any],
@@ -2020,7 +2053,6 @@ def _build_custom_row(
location_config: str | dict[str, Any] | None = None, location_config: str | dict[str, Any] | None = None,
composition_config: str | dict[str, Any] | None = None, composition_config: str | dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
person_rng = _axis_rng(seed_config, "person", seed, row_number)
scene_rng = _axis_rng(seed_config, "scene", seed, row_number) scene_rng = _axis_rng(seed_config, "scene", seed, row_number)
pose_rng = _axis_rng(seed_config, "pose", seed, row_number) pose_rng = _axis_rng(seed_config, "pose", seed, row_number)
role_rng = _axis_rng(seed_config, "role", seed, row_number) role_rng = _axis_rng(seed_config, "role", seed, row_number)
@@ -2054,41 +2086,35 @@ def _build_custom_row(
item_formatter_hints = dict(category_route.get("formatter_hints") or {}) item_formatter_hints = dict(category_route.get("formatter_hints") or {})
is_pose_category = bool(category_route.get("is_pose_category")) is_pose_category = bool(category_route.get("is_pose_category"))
subject_type = str(_merged_field(category, subcategory, item, "subject_type", "single_any")) subject_type = str(_merged_field(category, subcategory, item, "subject_type", "single_any"))
context = _subject_context(person_rng, subject_type, ethnicity, figure, no_plus_women, no_black, women_count, men_count) subject_route = _subject_route(
character_slots = _parse_character_cast(character_cast) subject_type=subject_type,
character_slot_map = _character_slot_label_map(character_slots) seed_config=seed_config,
applied_slot: dict[str, Any] = {} seed=seed,
slot_status = "none" row_number=row_number,
if context.get("subject_type") in ("woman", "man"): ethnicity=ethnicity,
slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A" figure=figure,
if slot_label in character_slot_map: no_plus_women=no_plus_women,
context, applied_slot = _character_context_for_label( no_black=no_black,
slot_label, women_count=women_count,
character_slot_map, men_count=men_count,
person_rng, character_profile=character_profile,
ethnicity, character_cast=character_cast,
figure,
no_plus_women,
no_black,
)
slot_status = f"applied:{slot_label}"
applied_profile, profile_status = {}, "skipped_character_slot"
else:
context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile)
else:
context, applied_profile, profile_status = _apply_character_profile_to_context(context, character_profile)
subject_type = context["subject_type"]
pov_character_labels = (
_pov_character_labels(character_slot_map, men_count)
if subject_type == "configured_cast"
else []
) )
context = dict(subject_route["context"])
subject_type = str(subject_route.get("subject_type") or context.get("subject_type") or subject_type)
character_slots = list(subject_route.get("character_slots") or [])
character_slot_map = dict(subject_route.get("character_slot_map") or {})
applied_slot = dict(subject_route.get("applied_slot") or {})
slot_status = str(subject_route.get("character_slot_status") or "none")
applied_profile = dict(subject_route.get("applied_profile") or {})
profile_status = str(subject_route.get("character_profile_status") or "none")
pov_character_labels = list(subject_route.get("pov_character_labels") or [])
cast_descriptors = list(subject_route.get("cast_descriptors") or [])
cast_descriptor_text = str(subject_route.get("cast_descriptor_text") or "")
source_role_graph = build_hardcore_role_graph(role_rng, subcategory, context, item_axis_values, pov_character_labels) source_role_graph = build_hardcore_role_graph(role_rng, subcategory, context, item_axis_values, pov_character_labels)
if is_pose_category: if is_pose_category:
source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph) source_role_graph = _sanitize_hardcore_environment_anchors(source_role_graph)
role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels) role_graph = _pov_role_graph_prompt(source_role_graph, pov_character_labels)
cast_descriptors: list[str] = []
cast_descriptor_text = ""
expression_intensity_source = expression_intensity_source or "input" expression_intensity_source = expression_intensity_source or "input"
expression_disabled = not bool(expression_enabled) expression_disabled = not bool(expression_enabled)
if expression_disabled: if expression_disabled:
@@ -2113,20 +2139,6 @@ def _build_custom_row(
) )
if expression_intensity is None: if expression_intensity is None:
expression_disabled = True expression_disabled = True
if subject_type == "configured_cast" and character_slots:
cast_descriptors, _descriptor_slots = _cast_descriptor_entries(
seed_config,
seed,
row_number,
ethnicity,
figure,
no_plus_women,
no_black,
women_count,
men_count,
character_slots,
)
cast_descriptor_text = _insta_of_prompt_cast_descriptors("; ".join(cast_descriptors))
scene_slug, scene = _choose_pair( scene_slug, scene = _choose_pair(
scene_rng, scene_rng,
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any
try:
from . import cast_context as cast_context_policy
from . import character_appearance as character_appearance_policy
from . import character_profile as character_profile_policy
from . import character_slot as character_slot_policy
from . import pair_cast
from . import pov_policy
from . import seed_config as seed_policy
from . import subject_context as subject_context_policy
except ImportError: # Allows local smoke tests from the repository root.
import cast_context as cast_context_policy
import character_appearance as character_appearance_policy
import character_profile as character_profile_policy
import character_slot as character_slot_policy
import pair_cast
import pov_policy
import seed_config as seed_policy
import subject_context as subject_context_policy
def resolve_subject_route(
*,
subject_type: str,
seed_config: dict[str, int],
seed: int,
row_number: int,
ethnicity: str,
figure: str,
no_plus_women: bool,
no_black: bool,
women_count: int,
men_count: int,
character_profile: str | dict[str, Any] | None = None,
character_cast: str | dict[str, Any] | list[Any] | None = None,
) -> dict[str, Any]:
person_rng = seed_policy.axis_rng(seed_config, "person", seed, row_number)
context = subject_context_policy.subject_context(
person_rng,
subject_type,
ethnicity,
figure,
no_plus_women,
no_black,
women_count,
men_count,
)
character_slots = character_slot_policy.parse_character_cast(character_cast)
character_slot_map = cast_context_policy.character_slot_label_map(character_slots)
applied_slot: dict[str, Any] = {}
slot_status = "none"
if context.get("subject_type") in ("woman", "man"):
slot_label = "Woman A" if context["subject_type"] == "woman" else "Man A"
if slot_label in character_slot_map:
context, applied_slot = character_appearance_policy.character_context_for_label(
slot_label,
character_slot_map,
person_rng,
ethnicity,
figure,
no_plus_women,
no_black,
)
slot_status = f"applied:{slot_label}"
applied_profile, profile_status = {}, "skipped_character_slot"
else:
context, applied_profile, profile_status = character_profile_policy.apply_character_profile_to_context(
context,
character_profile,
)
else:
context, applied_profile, profile_status = character_profile_policy.apply_character_profile_to_context(
context,
character_profile,
)
resolved_subject_type = str(context.get("subject_type") or subject_type)
pov_character_labels = (
pov_policy.pov_character_labels(character_slot_map, men_count)
if resolved_subject_type == "configured_cast"
else []
)
cast_descriptors: list[str] = []
cast_descriptor_text = ""
if resolved_subject_type == "configured_cast" and character_slots:
cast_descriptors, _descriptor_slots = pair_cast.cast_descriptor_entries_from_slots(
seed_config=seed_config,
seed=seed,
row_number=row_number,
ethnicity=ethnicity,
figure=figure,
no_plus_women=no_plus_women,
no_black=no_black,
women_count=women_count,
men_count=men_count,
character_slots=character_slots,
character_slot_map=character_slot_map,
primary_descriptor="",
axis_rng=seed_policy.axis_rng,
character_context_for_label=character_appearance_policy.character_context_for_label,
slot_is_pov=pov_policy.slot_is_pov,
)
cast_descriptor_text = pair_cast.prompt_cast_descriptors("; ".join(cast_descriptors))
return {
"context": context,
"subject_type": resolved_subject_type,
"character_slots": character_slots,
"character_slot_map": character_slot_map,
"applied_slot": applied_slot or {},
"character_slot_status": slot_status,
"applied_profile": applied_profile or {},
"character_profile_status": profile_status,
"pov_character_labels": pov_character_labels,
"cast_descriptors": cast_descriptors,
"cast_descriptor_text": cast_descriptor_text,
}
+80
View File
@@ -60,6 +60,7 @@ import row_location # noqa: E402
import row_pools # noqa: E402 import row_pools # noqa: E402
import row_rendering # noqa: E402 import row_rendering # noqa: E402
import row_route_metadata # noqa: E402 import row_route_metadata # noqa: E402
import row_subject_route # noqa: E402
import server_routes # noqa: E402 import server_routes # noqa: E402
import sdxl_formatter # noqa: E402 import sdxl_formatter # noqa: E402
import sdxl_presets # noqa: E402 import sdxl_presets # noqa: E402
@@ -1061,6 +1062,84 @@ def smoke_category_cast_config_policy() -> None:
_expect((empty_cast.get("women_count"), empty_cast.get("men_count")) == (1, 0), "Empty custom cast was not corrected") _expect((empty_cast.get("women_count"), empty_cast.get("men_count")) == (1, 0), "Empty custom cast was not corrected")
def smoke_row_subject_route_policy() -> None:
seed_cfg = seed_config.parse_seed_config({})
slot_cast = pb.build_character_slot_json(
subject_type="woman",
label="A",
age="32-year-old adult",
ethnicity="western_european",
figure="balanced",
body="slim",
hair="short silver bob",
eyes="gray eyes",
descriptor_detail="full",
)["character_cast"]
profile = {
"profile_type": "character",
"subject_type": "woman",
"age": "45-year-old adult",
"body": "average",
"body_phrase": "average figure",
"skin": "profile skin",
"hair": "profile hair",
"eyes": "profile eyes",
}
route = row_subject_route.resolve_subject_route(
subject_type="woman",
seed_config=seed_cfg,
seed=501,
row_number=1,
ethnicity="any",
figure="balanced",
no_plus_women=False,
no_black=False,
women_count=1,
men_count=0,
character_profile=profile,
character_cast=slot_cast,
)
delegated = pb._subject_route(
subject_type="woman",
seed_config=seed_cfg,
seed=501,
row_number=1,
ethnicity="any",
figure="balanced",
no_plus_women=False,
no_black=False,
women_count=1,
men_count=0,
character_profile=profile,
character_cast=slot_cast,
)
_expect(delegated == route, "Prompt builder subject route should delegate to row_subject_route")
_expect(route["subject_type"] == "woman", "Subject route changed single-woman subject type")
_expect(route["character_slot_status"] == "applied:Woman A", "Subject route did not apply matching Woman A slot")
_expect(route["character_profile_status"] == "skipped_character_slot", "Subject route should skip profile when slot applies")
_expect(route["context"].get("age") == "32-year-old adult", "Subject route lost slot age override")
_expect(route["context"].get("hair") == "short silver bob", "Subject route lost slot hair override")
_expect(route["applied_profile"] == {}, "Subject route should not apply profile over matching slot")
cast_route = row_subject_route.resolve_subject_route(
subject_type="configured_cast",
seed_config=seed_cfg,
seed=502,
row_number=1,
ethnicity="western_european",
figure="balanced",
no_plus_women=False,
no_black=False,
women_count=1,
men_count=1,
character_cast=_character_cast(pov_man=True),
)
_expect(cast_route["subject_type"] == "configured_cast", "Configured-cast subject route changed subject type")
_expect(cast_route["pov_character_labels"] == ["Man A"], "Subject route lost configured-cast POV man label")
_expect("Woman A:" in cast_route["cast_descriptor_text"], "Subject route lost visible woman descriptor")
_expect("Man A:" not in cast_route["cast_descriptor_text"], "Subject route should not describe POV man as visible cast")
def smoke_generation_profile_config_policy() -> None: def smoke_generation_profile_config_policy() -> None:
_expect( _expect(
pb.GENERATION_PROFILE_PRESETS is generation_profile_config.GENERATION_PROFILE_PRESETS, pb.GENERATION_PROFILE_PRESETS is generation_profile_config.GENERATION_PROFILE_PRESETS,
@@ -4313,6 +4392,7 @@ SMOKE_CASES: list[tuple[str, Callable[[], None]]] = [
("row_generation_policy", smoke_row_generation_policy), ("row_generation_policy", smoke_row_generation_policy),
("category_extensions_policy", smoke_category_extensions_policy), ("category_extensions_policy", smoke_category_extensions_policy),
("category_cast_config_policy", smoke_category_cast_config_policy), ("category_cast_config_policy", smoke_category_cast_config_policy),
("row_subject_route_policy", smoke_row_subject_route_policy),
("generation_profile_config_policy", smoke_generation_profile_config_policy), ("generation_profile_config_policy", smoke_generation_profile_config_policy),
("filter_config_policy", smoke_filter_config_policy), ("filter_config_policy", smoke_filter_config_policy),
("character_config_policy", smoke_character_config_policy), ("character_config_policy", smoke_character_config_policy),