Add analysis profiles with distance/proximity-aware axes

A discrete verdict collapses magnitude and a generic axis can hide what you're
calibrating (a blowjob where the head is 20cm away still reads sexual_act=oral ->
MATCH). New 'profile' input selects an act-specialized axis set (general / oral /
penetration / handjob / solo) whose act-critical axes capture distance explicitly
(mouth_genital_distance: touching/<5cm/10-20cm/>20cm, oral_depth, insertion_depth,
stroke_position, ...). axes now overrides the profile when set. agent_bridge gains
--profile; workflows + docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 00:48:46 +02:00
parent 06992506d7
commit 887dfc0bbb
6 changed files with 95 additions and 7 deletions
+2 -1
View File
@@ -32,10 +32,11 @@ can act on it.
|---|---|---|---|
| `reference_image` | IMAGE | — | the target |
| `mode` | compare / describe | compare | `describe` = first pass over the reference only → caption + target spec (seeds the prompt). `compare` = score ref vs generated |
| `profile` | general / oral / penetration / handjob / solo | general | **analysis profile** — act-specialized axis set; the act-critical axes are distance/proximity-aware (e.g. `mouth_genital_distance`) so magnitude isn't hidden behind a coarse label |
| `generated_image` | IMAGE (optional) | — | the candidate to score (required for `compare`, ignored for `describe`) |
| `model_path` | STRING | `/media/p5/qwen3vl_4b_abliterated_comfy_convert/hf_bf16` | local dir, **HF repo id** (`org/name`), or alias (`30b-a3b` / `8b` / `4b`) |
| `precision` | bf16 / fp16 / fp8 / nf4 | bf16 | `nf4` = 4-bit (run the 30B judge on 32 GB); `fp8` with the `hf_fp8` copy |
| `axes` | STRING | ~20 axes (identity, body, wardrobe, action, affect, camera, render) | scored/described axes; granular for explicit content. Edit to taste |
| `axes` | STRING | "" (empty) | **override** the profile's axis set with a custom comma/newline list; empty = use `profile` |
| `max_new_tokens` | INT | 1024 | |
| `temperature` | FLOAT | 0.0 | 0 = greedy/repeatable |
| `swap_eval` | BOOL | true | run twice with images swapped, average → cuts position bias |
+7 -2
View File
@@ -48,7 +48,7 @@ def _http_json(url: str, payload: dict | None = None, timeout: int = 30):
def _inject(graph: dict, prompt: str, negative: str, seed: int, run_tag: str, mode: str,
reference_description: str = ""):
reference_description: str = "", profile: str = ""):
"""Set the receptor's prompt/seed and the judge's mode/run_tag in-place.
compare mode needs a receptor (to inject the prompt). describe mode is the first
@@ -69,6 +69,8 @@ def _inject(graph: dict, prompt: str, negative: str, seed: int, run_tag: str, mo
inputs["prompt_used"] = prompt
if reference_description:
inputs["reference_description"] = reference_description
if profile:
inputs["profile"] = profile
if mode == "compare" and not found_receptor:
raise SystemExit(
f"[agent_bridge] no '{RECEPTOR_CLASS}' node in the workflow — add the "
@@ -115,6 +117,8 @@ def main(argv=None):
ap.add_argument("--negative", default="")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--run-tag", default="")
ap.add_argument("--profile", default="",
help="analysis profile on the judge (general/oral/penetration/handjob/solo)")
ap.add_argument("--ref-desc", default="",
help="canonical reference text to anchor compare on (from the describe pass)")
ap.add_argument("--ref-desc-file", default="",
@@ -138,7 +142,8 @@ def main(argv=None):
with open(args.workflow, "r", encoding="utf-8") as f:
graph = json.load(f)
_inject(graph, args.prompt, args.negative, args.seed, args.run_tag, args.mode, ref_desc)
_inject(graph, args.prompt, args.negative, args.seed, args.run_tag, args.mode, ref_desc,
args.profile)
client_id = uuid.uuid4().hex
try:
+21
View File
@@ -56,6 +56,27 @@ Each axis carries a one-line definition in the prompt (so e.g. `gender_mix` is a
not a position). Coarse axes blur the differences that matter for adult imagery; the act /
pose cluster is split into many axes so the agent gets specific, actionable targets.
## Analysis profiles (pick the axis set for the act)
A discrete verdict collapses *magnitude*, and a generic axis can hide the very thing you're
calibrating: for a blowjob, `sexual_act` reads "oral" in both ref and gen → MATCH, even if
in the gen the head is 20 cm from the penis. So the judge has **analysis profiles** — act-
specialized axis sets whose act-critical axes are **distance/proximity-aware**:
| profile | adds (beyond the shared identity/body/render base) |
|---|---|
| `general` | sexual_act, body_orientation, limb_arrangement, penetration, contact_points, genital_visibility, pose |
| `oral` | **mouth_genital_contact**, **mouth_genital_distance** (touching / <5cm / 1020cm / >20cm), **oral_depth** (tip/half/throat), tongue, hand_on_shaft, gaze_up |
| `penetration` | insertion_depth (tip/shallow/half/hilt), insertion_angle, body_orientation, limb_arrangement, … |
| `handjob` | hand_on_shaft, grip_style, stroke_position (base/mid/tip), mouth_genital_contact, … |
| `solo` | self_touch_location, toy_use, insertion_depth, … |
Now "mouth on the tip" vs "head 20 cm away" is a concrete, scored difference
(`mouth_genital_distance: mismatch ref:[contact] gen:[far >20cm]`) — the magnitude lives
in the `ref`/`gen` text. Set `profile` on the node (or `agent_bridge.py --profile oral`),
or override entirely with a custom `axes` list. Profiles are easy to extend in
`PROFILES`/`AXIS_DEFS` in `nodes/qwen_judge.py`.
## Step 0 — first pass (describe / bootstrap)
The very first iteration has no generated image yet, so the judge runs in **describe
+63 -4
View File
@@ -78,8 +78,63 @@ AXIS_DEFS = {
"scene": "location, furniture, props, background",
"lighting_color": "lighting quality and color palette / grade",
"art_style": "rendering style and realism (photoreal, anime, illustration, 3D)",
# --- DISTANCE-AWARE act-specific axes (used by analysis profiles) ---
# Oral: capture proximity/depth explicitly so 'mouth touching' vs 'head far away'
# is a measurable difference, not hidden behind a coarse 'oral' label.
"mouth_genital_contact": "is the mouth in contact with the penis/genitals? options: lips on tip / tip in mouth / shaft in mouth / licking shaft / kissing / NOT in contact",
"mouth_genital_distance": "if NOT in contact, how far is the mouth from the genitals: touching (~0), very close (<5cm), near (~10-20cm), far (>20cm). If in contact, say 'contact'.",
"oral_depth": "how much of the penis is inside the mouth: none / tip only / about half / deep (throat)",
"tongue": "tongue visible and where: not visible / on tip / on shaft / flat / extended",
"hand_on_shaft": "hands on the penis/shaft: none / one hand (base/mid/tip) / two hands",
"gaze_up": "is the giver looking up at the partner, at the camera, down, or eyes closed",
# Penetration depth/angle as measurable values
"insertion_depth": "how deep is penetration: tip only / shallow / half / full/hilt / pulling out",
"insertion_angle": "angle of penetration relative to the body: vertical / horizontal / oblique",
# Handjob
"grip_style": "how the penis is held: loose / firm / two-handed / fingertips / not held",
"stroke_position": "where the hand is along the shaft: base / mid / tip / gliding full length",
# Solo
"self_touch_location": "where the subject is touching themselves: clitoris / labia / breasts / penetration / none",
"toy_use": "any toy/object in use and where: none / dildo / vibrator / other (location)",
}
DEFAULT_AXES = ", ".join(AXIS_DEFS)
# Shared identity/body/wardrobe/affect/camera/render axes (act-independent).
_BASE_AXES = [
"subject_count", "gender_mix", "age_appearance", "ethnicity_skin", "body_type",
"breast_size", "distinctive_features", "hair", "clothing_state",
"facial_expression", "gaze", "framing", "camera_angle", "scene",
"lighting_color", "art_style",
]
# Analysis profiles: act-specialized axis sets. The act-critical axes are made
# distance/proximity-aware so magnitude (e.g. mouth-to-penis distance) is captured.
# Pick on the node via `profile`; leave `axes` empty to use the profile, or set
# `axes` to override entirely.
PROFILES = {
"general": _BASE_AXES + [
"sexual_act", "body_orientation", "limb_arrangement", "penetration",
"contact_points", "genital_visibility", "pose",
],
"oral": _BASE_AXES + [
"body_orientation", "mouth_genital_contact", "mouth_genital_distance",
"oral_depth", "tongue", "hand_on_shaft", "gaze_up", "genital_visibility", "pose",
],
"penetration": _BASE_AXES + [
"sexual_act", "body_orientation", "limb_arrangement", "insertion_depth",
"insertion_angle", "penetration", "contact_points", "genital_visibility", "pose",
],
"handjob": _BASE_AXES + [
"body_orientation", "hand_on_shaft", "grip_style", "stroke_position",
"mouth_genital_contact", "gaze_up", "genital_visibility",
],
"solo": _BASE_AXES + [
"self_touch_location", "toy_use", "insertion_depth", "limb_arrangement",
"genital_visibility", "pose",
],
}
DEFAULT_AXES = ", ".join(PROFILES["general"])
# Cache loaded (model, processor) keyed by (path, precision) so the loop does not
# reload weights every iteration.
@@ -607,9 +662,12 @@ class QwenVLImageJudge:
# describe = reference only -> target description (first pass, seeds the
# initial prompt). compare = ref vs generated -> per-axis scoring.
"mode": (["compare", "describe"], {"default": "compare"}),
# Analysis profile: act-specialized axis set (distance-aware where it
# matters). `axes` below overrides it when non-empty.
"profile": (list(PROFILES.keys()), {"default": "general"}),
"model_path": ("STRING", {"default": DEFAULT_MODEL_PATH}),
"precision": (["bf16", "fp16", "fp8", "nf4"], {"default": "bf16"}),
"axes": ("STRING", {"default": DEFAULT_AXES, "multiline": True}),
"axes": ("STRING", {"default": "", "multiline": True}),
"max_new_tokens": ("INT", {"default": 1024, "min": 64, "max": 4096}),
"temperature": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.5, "step": 0.05}),
"swap_eval": ("BOOLEAN", {"default": True}),
@@ -629,12 +687,13 @@ class QwenVLImageJudge:
}
def judge(self, reference_image, mode, model_path, precision, axes,
max_new_tokens, temperature, swap_eval, generated_image=None,
max_new_tokens, temperature, swap_eval, profile="general", generated_image=None,
keep_loaded=True, auto_download=True,
report_dir="", run_tag="", prompt_used="", reference_description=""):
# `axes` overrides the profile when provided; otherwise use the profile's axis set.
axis_list = [a.strip() for a in re.split(r"[,\n]", axes) if a.strip()]
if not axis_list:
axis_list = [a.strip() for a in DEFAULT_AXES.split(",")]
axis_list = list(PROFILES.get(profile, PROFILES["general"]))
try:
resolved_path = _resolve_model_source(model_path, auto_download)
+1
View File
@@ -67,6 +67,7 @@
"generated_image": ["8", 0],
"model_path": "/media/p5/qwen3vl_4b_abliterated_comfy_convert/hf_bf16",
"precision": "bf16",
"profile": "general",
"axes": "",
"max_new_tokens": 512,
"temperature": 0.0,
+1
View File
@@ -9,6 +9,7 @@
"inputs": {
"reference_image": ["11", 0],
"mode": "describe",
"profile": "general",
"model_path": "/media/p5/qwen3vl_4b_abliterated_comfy_convert/hf_bf16",
"precision": "bf16",
"axes": "",