From 887dfc0bbb5b482eb07b145201c935bc124b5f1f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 27 Jun 2026 00:48:46 +0200 Subject: [PATCH] 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 --- README.md | 3 +- agent_bridge.py | 9 +++- docs/CALIBRATION_POLICY.md | 21 +++++++++ nodes/qwen_judge.py | 67 +++++++++++++++++++++++++++-- workflow/workflow_api.json | 1 + workflow/workflow_describe_api.json | 1 + 6 files changed, 95 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9d6b8ca..06a1e81 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/agent_bridge.py b/agent_bridge.py index ecf3426..8421565 100644 --- a/agent_bridge.py +++ b/agent_bridge.py @@ -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: diff --git a/docs/CALIBRATION_POLICY.md b/docs/CALIBRATION_POLICY.md index 9b14308..2b1c308 100644 --- a/docs/CALIBRATION_POLICY.md +++ b/docs/CALIBRATION_POLICY.md @@ -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 / 10–20cm / >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 diff --git a/nodes/qwen_judge.py b/nodes/qwen_judge.py index 66c6258..a9483ed 100644 --- a/nodes/qwen_judge.py +++ b/nodes/qwen_judge.py @@ -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) diff --git a/workflow/workflow_api.json b/workflow/workflow_api.json index 186eb94..206dc4d 100644 --- a/workflow/workflow_api.json +++ b/workflow/workflow_api.json @@ -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, diff --git a/workflow/workflow_describe_api.json b/workflow/workflow_describe_api.json index 3722e68..06a51e8 100644 --- a/workflow/workflow_describe_api.json +++ b/workflow/workflow_describe_api.json @@ -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": "",