| """ |
| vlm_judge.py — uses Qwen3-VL-4B-Instruct (a real vision-language model) |
| to automatically judge a sketch's generated keyframe sequence. |
| |
| Runs in its OWN separate environment (Python 3.9+, transformers>=4.57.0), |
| NOT the main "mosketch" environment used for classify/narrate/deform — |
| Qwen3-VL requires transformers>=4.57.0, which requires Python>=3.9, and |
| the main environment is confirmed pinned to Python 3.8 (max installable |
| transformers there is 4.46.3). pipeline.py calls this script as a |
| SEPARATE PROCESS using the judge environment's python interpreter, not |
| as an in-process import, specifically so the two environments never need |
| to coexist in the same Python process. |
| |
| Takes the frames/{sketch}/kf0.png ... kf4.png images produced by |
| pipeline.py and asks the VLM to score them on: |
| 1. Plausibility — does the pose progression look like coherent, |
| physically believable motion (not static, not erratic)? |
| 2. Faithfulness — if a caption is available, does the sequence match |
| what was supposed to happen? |
| |
| Connectivity was deliberately dropped as a criterion: ARAP guarantees it |
| by construction (that's what the seam-constraint work was for), so asking |
| a VLM to check for it is redundant. A "completeness" criterion (missing |
| limbs from incomplete source strokes) was also tried and removed — see |
| the pipeline discussion for why that's a structural limitation of |
| stroke-deformation rather than something the judge can meaningfully score. |
| |
| Requires transformers >= 4.57.0 and Python >= 3.9 — run this from the |
| separate judge environment (see docstring above), not the mosketch env. |
| |
| Usage: |
| python vlm_judge.py dog9 |
| # frames auto-derived from frames/dog9/, caption auto-pulled by sketch name |
| |
| python vlm_judge.py dog9 --frames-dir custom/path --caption "custom text" |
| # override either default individually |
| |
| python vlm_judge.py dog9 --no-caption |
| # skip caption lookup entirely; faithfulness scored as N/A |
| """ |
|
|
| import argparse |
| import itertools |
| import json |
| import os |
| import re |
|
|
| import numpy as np |
|
|
| |
| |
| CAPTION_FILE_DEFAULT = "/user/HS400/rk01499/my_scratch/sketch/data/raw/60sketches/caption.txt" |
|
|
|
|
| def build_joint_legend_text(objects_info): |
| """ |
| Rest-pose joint legend per ARAP object — index, semantic part name (when available |
| from VLM-guided handle selection), rest (x,y), anchor flag. |
| When semantic handle selection succeeded, each joint carries a human-readable name |
| ("hand", "mouth", etc.) so the VLM can reason about NAMED PARTS rather than bare |
| indices — "move the hand joint toward the mouth" instead of "move joint_2 toward |
| joint_1's region". The index is still shown alongside (joint_2 = hand) so the |
| existing index-based deform_outputs/joint_feedback schema works unchanged. |
| """ |
| sections = [] |
| for name, info in objects_info.items(): |
| part_names = info.get("part_names", {}) |
| lines = [] |
| for i, j in enumerate(info["joints"]): |
| anchor_tag = " <-- ANCHOR (should stay near rest every keyframe)" if i == info["anchor_idx"] else "" |
| part_label = f" ({part_names[i]})" if i in part_names else "" |
| lines.append(f" joint_{i}{part_label}: rest (x={j[0]:.1f}, y={j[1]:.1f}){anchor_tag}") |
| sections.append(f'Object "{name}" joints:\n' + "\n".join(lines)) |
| return "\n\n".join(sections) |
|
|
|
|
| def build_joint_targets_text(objects_info, deform_outputs, n_keyframes=5): |
| """ |
| Per-keyframe joint TARGET coordinates (post-clip, what the generator |
| actually asked for this attempt) for each ARAP object — NOT the |
| deformed stroke geometry. Deliberately excludes per-stroke coordinate |
| text: that format gave the judge no joint identity to anchor feedback |
| to, which is the specific problem this rework addresses. Per-keyframe |
| deformed stroke text may be added back later as a separate experiment |
| once joint-level feedback quality is evaluated on its own. |
| """ |
| sections = [] |
| for name, info in objects_info.items(): |
| obj_targets = (deform_outputs or {}).get(name) |
| if not obj_targets: |
| continue |
| kf_lines = [] |
| for kf in range(n_keyframes): |
| kf_key = f"kf{kf}" |
| if kf_key not in obj_targets: |
| continue |
| parts = ", ".join(f"{j}=({v[0]:.1f},{v[1]:.1f})" for j, v in obj_targets[kf_key].items()) |
| kf_lines.append(f" {kf_key}: {parts}") |
| if kf_lines: |
| sections.append(f'Object "{name}" joint TARGETS this attempt:\n' + "\n".join(kf_lines)) |
| return "\n\n".join(sections) |
|
|
|
|
| def build_handle_distance_text(objects_info, deform_outputs, n_keyframes=5): |
| """ |
| Code-computed pairwise distance between every pair of HANDLE joints (not the anchor) within |
| the same object, at rest and at each keyframe, with delta-from-rest — e.g. "hand <-> mouth: |
| rest=80.3px, kf4=79.0px (change from rest: -1.3px)". |
| |
| WHY THIS EXISTS: confirmed on real hardware (eat2, multiple runs) that the judge's own |
| motion_checks scores can directly contradict the numbers it was already given. One verdict |
| scored magnitude=3 ("large enough to be visually meaningful") for a hand that moved from |
| 80.3px to 79.0px from the mouth — a 1.3px net change from rest, functionally no motion — |
| while target_coords in the SAME verdict cited the joint's own current position (a separate, |
| also-confirmed failure; see filter_noop_feedback). The existing joint_feedback_instruction's |
| "never cite the current position" rule is stated once, then the judge has to derive |
| magnitude/relationship facts by eye from images and scattered per-joint coordinates. That's |
| an inference task with room to get the number wrong or ignore it. This function does the |
| arithmetic in code instead and hands the judge a fact table to check against directly, |
| positioned immediately before the scoring instructions that need it — contradicting an |
| explicit adjacent number is a harder mistake than violating an abstract rule stated earlier |
| in a long prompt. This does NOT depend on --interaction-constraints being authored (eat2 has |
| none); it works from objects_info's own handle list, so it's available for any object with |
| 2+ handles regardless of whether relational intent was ever hand-specified. |
| |
| Does not replace interaction_constraints — when both exist, interaction_constraints still |
| carries the semantic RELATIONSHIP ("mouth near target") and CRITICAL_KEYFRAME; this only |
| supplies the raw numbers. Neither is a hard gate — the judge can still reason about what the |
| distance means, but can no longer claim a number the prompt itself contradicts without at |
| least having seen it. |
| """ |
| sections = [] |
| for name, info in objects_info.items(): |
| handle_idxs = info.get("handle_idxs") or [] |
| if len(handle_idxs) < 2: |
| continue |
| joints = info.get("joints") |
| part_names = info.get("part_names") or {} |
| obj_targets = (deform_outputs or {}).get(name) |
| pair_lines = [] |
| for a_i, b_i in itertools.combinations(handle_idxs, 2): |
| a_label = part_names.get(a_i, f"joint_{a_i}") |
| b_label = part_names.get(b_i, f"joint_{b_i}") |
| rest_dist = float(np.linalg.norm(np.array(joints[a_i]) - np.array(joints[b_i]))) |
| kf_lines = [] |
| for kf in range(n_keyframes): |
| kf_key = f"kf{kf}" |
| kf_targets = (obj_targets or {}).get(kf_key) |
| if not kf_targets or f"joint_{a_i}" not in kf_targets or f"joint_{b_i}" not in kf_targets: |
| continue |
| a_pos = np.array(kf_targets[f"joint_{a_i}"]) |
| b_pos = np.array(kf_targets[f"joint_{b_i}"]) |
| dist = float(np.linalg.norm(a_pos - b_pos)) |
| delta = dist - rest_dist |
| kf_lines.append(f" {kf_key}: {dist:.1f}px (change from rest: {delta:+.1f}px)") |
| if kf_lines: |
| pair_lines.append( |
| f' "{a_label}" <-> "{b_label}": rest={rest_dist:.1f}px\n' + "\n".join(kf_lines) |
| ) |
| if pair_lines: |
| sections.append(f'Object "{name}" handle-to-handle distances (CODE-COMPUTED — exact, ' |
| f'from actual target coordinates, not an estimate):\n' + "\n".join(pair_lines)) |
| return "\n\n".join(sections) |
|
|
|
|
| def build_bbox_trajectory_text(object_names, real_trajectories, n_keyframes=5): |
| """ |
| Per-keyframe bounding-box position for EVERY object (ARAP and |
| TRAJ_ONLY alike) — sourced from preprocessed trajectory data, i.e. |
| FIXED ground truth, not something the judge should critique or the |
| generator can change. Included purely as spatial context so the judge |
| isn't reasoning about joint deformation in a positional vacuum (e.g. |
| "is the dog's head reaching toward the frisbee" requires knowing |
| where the frisbee bbox actually is at that keyframe). |
| """ |
| from lib1 import bbox_deltas |
| sections = [] |
| for name in object_names: |
| traj = real_trajectories.get(name) |
| if traj is None: |
| continue |
| dx_vals, dy_vals = bbox_deltas(traj) |
| parts = ", ".join(f"kf{kf}=(dx={dx_vals[kf]:.1f},dy={dy_vals[kf]:.1f})" for kf in range(n_keyframes)) |
| sections.append(f'Object "{name}" bbox trajectory (FIXED, given, do not critique or ask to change): {parts}') |
| return "\n".join(sections) |
|
|
|
|
| def build_interaction_constraints_text(interaction_constraints, objects_info=None): |
| """ |
| Renders structured interaction constraints (source_object/source_part/target_object/ |
| relationship/critical_keyframe) as text for the judge prompt — see |
| lib.load_interaction_constraints for the schema and where these come from. |
| |
| When objects_info is given, each constraint's source_part is cross-checked against that |
| object's part_names (populated by semantic handle selection) and, if a match is found, the |
| matching joint index is appended so the judge can go straight to "joint_i" instead of having |
| to re-derive which joint is the mouth/hand/etc from the legend itself. No match (KMeans |
| fallback ran, or the part name doesn't line up with what semantic selection found) is not an |
| error — the judge still gets the relationship in words and can reason from the images. |
| """ |
| if not interaction_constraints: |
| return "" |
| lines = [] |
| for c in interaction_constraints: |
| joint_hint = "" |
| if objects_info is not None: |
| src_info = objects_info.get(c["source_object"]) |
| if src_info: |
| for idx, part in (src_info.get("part_names") or {}).items(): |
| if part == c["source_part"]: |
| joint_hint = f" (joint_{idx} in that object's legend)" |
| break |
| lines.append( |
| f' - "{c["source_object"]}"\'s {c["source_part"]}{joint_hint} should be {c["relationship"]} ' |
| f'"{c["target_object"]}", and this should be TRUE AT kf{c["critical_keyframe"]} specifically ' |
| f'(not necessarily at other keyframes).' |
| ) |
| return "\n".join(lines) |
|
|
|
|
| DISCONNECTION_LANGUAGE_PATTERNS = [ |
| "disconnect", "float", "unsupported", "not touching", "not connected", |
| "ungripped", "not gripped", "gap between", "detached", "separated from", |
| "not attached", "off the rest pose", "off from the rest pose", "away from the rest pose", |
| ] |
|
|
|
|
| def is_disconnection_feedback(issue_text): |
| """ |
| CODE-SIDE backstop for a rule the PROMPT already states explicitly (floating/disconnection is |
| NEVER a valid joint_feedback reason, because the deformation solver moves points along a |
| connected mesh and cannot cause or fix a disconnected/floating appearance — see PLAUSIBILITY's |
| instruction text). CONFIRMED on real hardware, more than once, that the model produces this |
| category of complaint anyway despite the explicit instruction — different phrasings each time |
| ("hand appears to float", "mouth position... making it appear disconnected", "gap between"), |
| which is exactly why this is a substring/keyword filter checked against the ACTUAL issue text |
| rather than trusting the model to self-police. This is deliberately a coarse, over-inclusive |
| filter — false positives (rejecting a genuinely different, legitimate complaint that happens to |
| share a word like "detached") are an acceptable cost given the alternative is silently letting |
| an uncorrectable "fix" back into the generator's prompt. |
| """ |
| if not issue_text: |
| return False |
| lowered = issue_text.lower() |
| return any(pattern in lowered for pattern in DISCONNECTION_LANGUAGE_PATTERNS) |
|
|
|
|
| def filter_disconnection_feedback(joint_feedback, source_label=""): |
| """ |
| Removes any joint_feedback entry whose issue text matches disconnection/floating language. |
| Applied ONCE, at the single point every path (single-vote and multi-vote) funnels through |
| before reaching the generator, so the filter can't be bypassed by adding a new call site later |
| without also wiring this in. Prints what it removed, so a recurrence of this failure mode is |
| visible in logs rather than silently disappearing (useful for noticing if the prompt instruction |
| is being ignored MORE often over time, which would be a real signal worth acting on separately). |
| """ |
| if not joint_feedback: |
| return joint_feedback |
| kept, removed = [], [] |
| for entry in joint_feedback: |
| if isinstance(entry, dict) and is_disconnection_feedback(entry.get("issue")): |
| removed.append(entry) |
| else: |
| kept.append(entry) |
| if removed: |
| label = f" ({source_label})" if source_label else "" |
| for r in removed: |
| print(f" FILTERED disconnection-language joint_feedback{label}: " |
| f"{r.get('object')}.joint_{r.get('joint')} @ kf{r.get('keyframe')} — " |
| f"\"{r.get('issue')}\" (this class of correction is never valid, see prompt instruction)") |
| return kept |
|
|
|
|
| def filter_invalid_object_feedback(joint_feedback, deform_outputs, source_label=""): |
| """ |
| Removes any joint_feedback entry whose 'object' isn't a real ARAP object — i.e. isn't a key |
| in deform_outputs, which only ever contains entries for objects that actually went through |
| narrate+deform (TRAJ_ONLY objects like a spoon or ball never get one, since they have no |
| joints to target at all). |
| |
| CONFIRMED on real hardware (eat2): the judge hallucinated a joint_feedback entry for |
| 'spoon' — a TRAJ_ONLY object — citing "spoon.joint_1" as if it had a joint legend, when |
| spoon has never had one. validate_judge_response already correctly IDENTIFIES this |
| ("not an ARAP object with a joint legend in this prompt") but that check is diagnostic |
| only — it prints a warning and does nothing else. Nothing downstream previously stripped |
| the entry: compute_joint_feedback_deltas looks up deform_outputs.get(obj_name), gets None |
| for a nonexistent object, and leaves delta_px=None — which routes the entry through the |
| exact same "ungrounded" formatting path as legitimate prose-only feedback. The result: |
| "[UNGROUNDED] spoon.joint_1 @ kf4: ..." was sent to the next attempt looking exactly like |
| real feedback about a joint that cannot structurally exist, silently consuming one of the |
| (small, limited) joint_feedback slots for garbage. |
| |
| Same pattern as filter_disconnection_feedback and filter_noop_feedback: applied once, at |
| the same choke point, loud print so this doesn't silently recur unnoticed. |
| """ |
| if not joint_feedback: |
| return joint_feedback |
| valid_objects = set((deform_outputs or {}).keys()) |
| kept, removed = [], [] |
| for entry in joint_feedback: |
| if isinstance(entry, dict) and entry.get("object") not in valid_objects: |
| removed.append(entry) |
| else: |
| kept.append(entry) |
| if removed: |
| label = f" ({source_label})" if source_label else "" |
| for r in removed: |
| print(f" FILTERED invalid-object joint_feedback{label}: " |
| f"'{r.get('object')}' has no joint legend in this scene (valid ARAP objects: " |
| f"{sorted(valid_objects)}) — \"{r.get('issue')}\" (likely hallucinated, e.g. a " |
| f"TRAJ_ONLY object mistakenly given joint feedback)") |
| return kept |
|
|
|
|
| def filter_noop_feedback(joint_feedback, min_delta_px=2.0, source_label=""): |
| """ |
| DOWNGRADES (not deletes) any joint_feedback entry whose target_coords is a no-op — within |
| min_delta_px of the joint's OWN current position (delta_px already computed by |
| compute_joint_feedback_deltas, which must run before this). This is a real, confirmed |
| failure mode, not a hypothetical one: on eat2, EVERY attempt's joint_feedback had |
| delta_px ~= {0.0, 0.0} because the judge grounded target_coords to the joint's own current |
| stroke instead of the stroke it should move toward — e.g. citing the hand's own current |
| position as its "target," which subtracts to zero and gives the next attempt no actual |
| corrective signal, while still looking like real grounded feedback in the logs (anchored_to |
| was validly disambiguated, target_coords was non-null — nothing else in |
| validate_judge_response catches this, since a no-op coordinate is structurally identical to |
| a valid one, just numerically equal to the current position instead of different from it). |
| |
| REVISION — was originally DELETING the whole entry outright. CONFIRMED on real hardware |
| (eat2) that this was a real regression, not just a theoretical concern: deleting the entry |
| also deletes its prose `issue` text, which format_joint_feedback_for_object has ALWAYS had a |
| separate, working fallback for — an ungrounded entry (delta_px=None) still gets its issue |
| text included in the next attempt's prompt, just without a coordinate. Full deletion skips |
| that fallback entirely. Comparing runs before and after this filter existed: the run BEFORE |
| this filter existed showed real, varying joint targets across attempts, even though every |
| correction was numerically useless (delta_px~=0) — because the prose issue text was still |
| reaching the generator. Every run AFTER this filter started fully deleting entries showed |
| byte-identical targets across all attempts, with baseline_targets re-feeding the exact same |
| previous numbers forward and nothing joint-specific left to tell the model what to change |
| (only the coarse, whole-scene overall_verdict sentence survives full deletion). Downgrading |
| instead of deleting keeps the numeric fix (no more fake/useless coordinates reaching |
| compute_joint_feedback_deltas's delta math or the generator) while restoring the prose |
| signal that was actually doing useful work. |
| |
| Same pattern as filter_disconnection_feedback in spirit (applied once, at the same choke |
| point, prints what it changed) but NOT in effect — disconnection feedback is deleted outright |
| because it describes a problem that categorically cannot be corrected by any target (see |
| is_disconnection_feedback), so there is nothing useful left to keep. A no-op is different: |
| the *coordinate* is useless, but the *issue description* the judge wrote is often still an |
| accurate observation — the judge correctly noticed the hand looked wrong, it just failed to |
| ground a real correction coordinate for it. Keeping the observation while dropping only the |
| bad coordinate matches what's actually wrong versus what's actually right about the entry. |
| |
| min_delta_px=2.0 gives slack for the judge's own rounding (target_coords values seen in |
| practice are rounded to ~1 decimal place) without being so loose it swallows genuinely small |
| but real corrections. |
| |
| Only filters entries where delta_px was actually computed (i.e. current_coords is known) — an |
| entry with delta_px=None (ungrounded, or filtered upstream for an ambiguous anchor) is left |
| alone here; that is a different, already-handled case. |
| """ |
| if not joint_feedback: |
| return joint_feedback |
| kept, downgraded = [], [] |
| for entry in joint_feedback: |
| if not isinstance(entry, dict): |
| kept.append(entry) |
| continue |
| delta = entry.get("delta_px") |
| if isinstance(delta, dict) and abs(delta.get("dx", 0)) < min_delta_px and abs(delta.get("dy", 0)) < min_delta_px: |
| |
| |
| |
| |
| |
| |
| downgraded_entry = dict(entry) |
| downgraded_entry["_original_target_coords"] = entry.get("target_coords") |
| downgraded_entry["_original_delta_px"] = entry.get("delta_px") |
| downgraded_entry["target_coords"] = None |
| downgraded_entry["delta_px"] = None |
| downgraded_entry["_downgraded_from_noop"] = True |
| downgraded.append(downgraded_entry) |
| kept.append(downgraded_entry) |
| else: |
| kept.append(entry) |
| if downgraded: |
| label = f" ({source_label})" if source_label else "" |
| for d in downgraded: |
| print(f" DOWNGRADED no-op joint_feedback{label}: {d.get('object')}.joint_{d.get('joint')} @ " |
| f"kf{d.get('keyframe')} — target_coords {d.get('_original_target_coords')} was within " |
| f"{min_delta_px}px of the joint's own current position {d.get('current_coords')} " |
| f"(delta_px was {d.get('_original_delta_px')}) — this restated the current position " |
| f"instead of proposing a correction, so the coordinate is stripped. The prose issue is " |
| f"KEPT and still reaches the next attempt (ungrounded): \"{d.get('issue')}\"") |
| return kept |
|
|
|
|
| def compute_joint_feedback_deltas(joint_feedback, deform_outputs): |
| """ |
| Turns the judge's target_coords into an actual pixel delta by |
| SUBTRACTION — not model math. The judge only has to say "the mouth |
| stroke is at this coordinate" (a lookup/grounding task); this function |
| does target - actual arithmetic exactly, so the number the generator |
| receives can't inherit any model arithmetic error, only whatever |
| error is in the judge's chosen target_coords itself. |
| |
| Mutates each entry in place, adding: |
| - "current_coords": {"x":..., "y":...} — where the joint actually |
| is right now (from deform_outputs), for reference |
| - "delta_px": {"dx":..., "dy":...} — target - current, or None if |
| target_coords was null/missing/malformed (ungrounded feedback is |
| left as prose-only, NOT silently defaulted to a zero or guessed |
| delta) |
| |
| Entries with joint=None handling, missing objects, or missing |
| keyframes in deform_outputs are left with delta_px=None rather than |
| raising — a judge referencing a keyframe/joint that doesn't exist in |
| deform_outputs is a validation problem to surface, not something to |
| paper over with a computed number. |
| |
| Disconnection/floating-language entries are FILTERED OUT before any of the above — see |
| filter_disconnection_feedback. This is the single point every caller (single-vote and |
| multi-vote) passes through, so the filter can't be silently bypassed by a new call site. |
| |
| Entries citing an object with no joint legend in this scene (e.g. a TRAJ_ONLY object like |
| a spoon or ball) are ALSO filtered out here — see filter_invalid_object_feedback. Confirmed |
| real on eat2: without this, such an entry silently reached the generator formatted exactly |
| like legitimate feedback, wasting a joint_feedback slot on something that cannot exist. |
| |
| No-op entries — target_coords within min_delta_px of the joint's own current position, i.e. |
| the judge cited the joint's own current stroke instead of the stroke it should move toward — |
| are FILTERED OUT after delta computation. See filter_noop_feedback for why this exists: it is |
| a confirmed real failure mode (eat2), not a hypothetical one, and nothing else catches it |
| since a no-op coordinate is structurally identical to a valid one. |
| """ |
| joint_feedback = filter_disconnection_feedback(joint_feedback, source_label="compute_joint_feedback_deltas") |
| joint_feedback = filter_invalid_object_feedback(joint_feedback, deform_outputs, source_label="compute_joint_feedback_deltas") |
| for entry in joint_feedback or []: |
| entry["current_coords"] = None |
| entry["delta_px"] = None |
|
|
| obj_name = entry.get("object") |
| joint_idx = entry.get("joint") |
| kf = entry.get("keyframe") |
| target = entry.get("target_coords") |
|
|
| if target is None or target.get("x") is None or target.get("y") is None: |
| continue |
|
|
| |
| |
| |
| |
| |
| anchor = entry.get("anchored_to", "") |
| disambiguated = isinstance(anchor, str) and any( |
| term in anchor.lower() for term in ("kf", "keyframe", "deformed", "rest-pose", "rest pose")) |
| if not disambiguated: |
| continue |
|
|
| obj_targets = (deform_outputs or {}).get(obj_name) |
| if obj_targets is None: |
| continue |
| kf_key = f"kf{kf}" |
| joint_key = f"joint_{joint_idx}" |
| current = obj_targets.get(kf_key, {}).get(joint_key) |
| if current is None: |
| continue |
|
|
| entry["current_coords"] = {"x": current[0], "y": current[1]} |
| entry["delta_px"] = { |
| "dx": round(target["x"] - current[0], 1), |
| "dy": round(target["y"] - current[1], 1), |
| } |
| return filter_noop_feedback(joint_feedback, source_label="compute_joint_feedback_deltas") |
|
|
|
|
| def build_judge_prompt(sketch_name, caption=None, dino_stagnation=None, dino_temporal=None, clip_scores=None, |
| cap_utilization=None, objects_info=None, deform_outputs=None, |
| real_trajectories=None, all_object_names=None, n_keyframes=5, |
| deformed_geometry_text=None, narratives=None, past_verdicts=None, |
| interaction_constraints=None): |
| """ |
| interaction_constraints: [{"source_object", "source_part", "target_object", "relationship", |
| "critical_keyframe"}, ...] — see lib.load_interaction_constraints. Optional; [] or None |
| both mean "no structured constraints for this sketch," same as before this was added. |
| When given, adds an explicit MOTION_CHECKS block (see below) and each constraint is |
| checked specifically at its stated critical_keyframe. |
| |
| MOTION_CHECKS (added per the handoff doc's TODO: "Upgrade judge with Section 9's 7 explicit |
| checks: direction, magnitude, timing, relative relationship, deformation quality, temporal |
| coherence"). NOTE: only 6 names were listed in the summary handed to this session — Section 9 |
| itself (the actual project document) was not available here, so this is a best-effort |
| reconstruction from those 6 names, not a verified match to whatever the 7th check is. Treat |
| this schema as a first draft to revise once the real Section 9 text is available, not as a |
| faithful implementation of an unseen spec. |
| - direction: for each object with a clear intended motion, did joints move the CORRECT |
| way (toward the target/goal), not backward or sideways relative to it? |
| - magnitude: was displacement large enough to be visually meaningful (not technically |
| nonzero but negligible), without being wildly implausible for the action? |
| - timing: does displacement build up and settle at sensible points across the 5 sparse |
| keyframes (not all displacement crammed into one keyframe, not front-loaded then idle)? |
| - relative_relationship: for objects involved in an interaction_constraint (see above), |
| is the stated relationship (e.g. "mouth near frisbee") actually true AT the |
| constraint's critical_keyframe specifically? Only scored when interaction_constraints |
| is non-empty; "N/A" otherwise. |
| - deformation_quality: does the deformed shape stay a recognizable, undistorted version |
| of the source object (distinct from QUALITY's anchor-stability check below — this is |
| about the MOVING parts, not the parts that should stay still)? |
| - temporal_coherence: across the 5 keyframes, does the sequence progress in one |
| direction consistent with the narrative, without an unexplained keyframe that jumps |
| back toward rest and then forward again? |
| These are ADDITIVE diagnostics — they do not change the existing plausibility/faithfulness/ |
| quality thresholds that gate retries; they surface in motion_checks and (for |
| relative_relationship failures) can generate joint_feedback the same way any other criterion |
| does. Wiring these into their own retry-gating threshold is a separate decision left to the |
| caller, not made here. |
| |
| dino_stagnation: the dict returned by dino_similarity.stagnation_score() |
| comparing this attempt to the previous one (None on attempt 1). |
| dino_temporal: list of consecutive-keyframe DINOv2 similarities within |
| THIS attempt (from dino_similarity.temporal_consistency()). |
| clip_scores: the dict returned by clip_score.compute_sequence_clip_scores() |
| (None if no caption was available to score against). |
| cap_utilization: {obj_name: mean_fraction_0_to_1} — how much of the |
| allowed movement range each object's joints actually used. |
| past_verdicts: [{"attempt": int, "plausibility_score": ..., "faithfulness_score": ..., |
| "quality_score": ..., "overall_verdict": str}, ...] — THIS JUDGE's own verdicts from |
| earlier attempts on the SAME sketch. Without this, every judge call reasons from scratch |
| with no memory of what it said before, so it can't explicitly say "this is better/worse |
| than attempt 2" — it can only independently score the current attempt and hope the |
| numbers happen to reflect real improvement. Given this, the judge is instructed to |
| explicitly compare against its own prior reasoning, not just re-derive scores in isolation. |
| CONFIRMED on real hardware (horsecar5's walking person) that Qwen |
| can propose displacement well within its allowance without any |
| signal catching it — a leg moved only 18% of its permitted range, |
| invisible at render scale despite technically being "movement." |
| |
| narratives: {obj_name: [n_keyframes pose description strings]} — the |
| GENERATOR's OWN stated intent for each keyframe (same data written |
| to {name}_narratives.json). When given, FAITHFULNESS is judged as |
| narrative-vs-EXECUTION (does what the generator claimed happen |
| actually match the rendered images/coordinates?) instead of |
| narrative-vs-caption. This catches a failure mode the caption-only |
| check cannot: a generator that writes a plausible story but |
| produces timid or wrong joint targets that don't execute it — the |
| old check only ever looked at the final image against the |
| caption, never verified the generator's own stated intent was |
| actually carried out. If omitted, falls back to the original |
| image-vs-caption faithfulness check. |
| |
| objects_info / deform_outputs / real_trajectories / all_object_names: |
| if given, the judge is shown (a) rest-pose stroke geometry per ARAP |
| object, (b) a rest-pose joint index legend, (c) this attempt's |
| joint TARGET coordinates per keyframe, and (d) every object's fixed |
| bbox trajectory. This is what makes joint_feedback possible: the |
| judge has a joint vocabulary to point at instead of only prose |
| description. If none of these are given, the prompt falls back to |
| the original image-only behavior and joint_feedback is not requested. |
| |
| deformed_geometry_text: PER-KEYFRAME actual deformed stroke geometry |
| (from pipe.py's build_all_keyframes_deformed_geometry_text) — the |
| judge is instructed to anchor target_coords against THIS, not the |
| rest-pose geometry, because a stroke's rest coordinate is only |
| correct for whichever keyframe happens to match rest; every other |
| keyframe needs that stroke's TRUE position at that specific |
| keyframe. If omitted, falls back to rest-pose-only anchoring |
| (less accurate per-keyframe, but still grounded rather than a |
| free guess). |
| """ |
| caption_block = "" |
| if caption: |
| caption_block = (f'\nThe sketch is supposed to depict: "{caption}" (background context for ' |
| f'PLAUSIBILITY only — FAITHFULNESS below is judged against the narrative, not this)\n') |
|
|
| past_verdicts_block = "" |
| if past_verdicts: |
| verdict_parts = [] |
| for pv in past_verdicts: |
| verdict_parts.append( |
| f" Attempt {pv['attempt']}: plausibility={pv.get('plausibility_score')}, " |
| f"faithfulness={pv.get('faithfulness_score')}, quality={pv.get('quality_score')} — " |
| f"{pv.get('overall_verdict', '')}" |
| ) |
| past_verdicts_block = ( |
| "\nYOUR OWN past verdicts on EARLIER attempts for this same sketch (you wrote these):\n" |
| + "\n".join(verdict_parts) + |
| "\nUse these as a real comparison point, not just context to skim: explicitly judge whether " |
| "THIS attempt is better, worse, or about the same as your best-scoring past attempt, and say so " |
| "in overall_verdict. Score based on what you actually see in THIS attempt's images/data — do not " |
| "inflate or deflate scores just to show improvement or consistency with past attempts; if this " |
| "attempt is genuinely worse, score it worse, even if that breaks an apparent improving trend.\n" |
| ) |
|
|
| narrative_block = "" |
| faithfulness_criterion = ( |
| 'FAITHFULNESS (1-5, or "N/A" if no caption was given above): Does the sequence match what the caption ' |
| 'describes should be happening? 5 = clearly matches, 1 = unrelated to the description.' |
| ) |
| if narratives: |
| narrative_parts = [] |
| for obj_name, kf_list in narratives.items(): |
| kf_lines = "\n".join(f" kf{i}: {desc}" for i, desc in enumerate(kf_list)) |
| narrative_parts.append(f'Object "{obj_name}":\n{kf_lines}') |
| narrative_block = ( |
| "\nThe GENERATOR's OWN stated intent for each keyframe (what it claims is happening, written " |
| "BEFORE rendering):\n" + "\n\n".join(narrative_parts) + "\n" |
| ) |
| faithfulness_criterion = ( |
| 'FAITHFULNESS (1-5): Does what ACTUALLY happened (the rendered images and joint coordinates above) ' |
| 'match what the generator ITSELF claimed would happen (the stated narrative above) — NOT whether ' |
| 'it matches the caption. 5 = the narrative\'s claims are clearly executed in the actual poses/' |
| 'coordinates. 1 = the narrative describes an action but the actual joint positions barely differ ' |
| 'from rest, or move somewhere unrelated to what was claimed. This catches a generator that writes ' |
| 'a plausible story without actually executing it.' |
| ) |
|
|
| interaction_block = "" |
| interaction_checklist_text = build_interaction_constraints_text(interaction_constraints, objects_info) |
| if interaction_checklist_text: |
| interaction_block = ( |
| "\nSTRUCTURED INTERACTION CONSTRAINTS for this scene (author-specified, not something the " |
| "generator can change — check whether each one actually holds in the rendered images):\n" |
| + interaction_checklist_text + "\n" |
| ) |
|
|
| metrics_lines = [] |
| if dino_stagnation is not None: |
| sims = ", ".join(f"kf{i}={s:.3f}" for i, s in enumerate(dino_stagnation["per_keyframe_similarity"])) |
| metrics_lines.append( |
| f"- DINOv2 similarity to the PREVIOUS attempt, per keyframe (1.0 = identical, this attempt " |
| f"made no change at all; lower = more different): {sims} (mean {dino_stagnation['mean_similarity']:.3f})" |
| ) |
| if dino_temporal is not None: |
| temp_str = ", ".join(f"kf{i}->kf{i+1}={s:.3f}" for i, s in enumerate(dino_temporal)) |
| metrics_lines.append( |
| f"- DINOv2 similarity between CONSECUTIVE keyframes within this attempt (lower = more visual " |
| f"change between those two frames, higher = little change): {temp_str}" |
| ) |
| if cap_utilization is not None: |
| util_str = ", ".join(f"{obj}={frac*100:.0f}%" for obj, frac in cap_utilization.items()) |
| metrics_lines.append( |
| f"- Movement allowance used, per object (how much of the permitted displacement range each " |
| f"object's joints actually used — low values mean technically-nonzero but visually negligible " |
| f"motion): {util_str}" |
| ) |
| if clip_scores is not None: |
| clip_str = ", ".join(f"kf{i}={s:.3f}" for i, s in enumerate(clip_scores["per_keyframe_clip_score"])) |
| metrics_lines.append( |
| f"- CLIP image-caption similarity, per keyframe (higher = the image better matches the caption " |
| f"text; CLIP was trained on photographs, not sketches, so treat this as a rough signal, not " |
| f"ground truth): {clip_str} (mean {clip_scores['mean_clip_score']:.3f})" |
| ) |
| metrics_block = "" |
| if metrics_lines: |
| metrics_block = "\nObjective measurements computed for this attempt (use these to INFORM your " \ |
| "reasoning and cite them in your notes where relevant, but judge primarily from " \ |
| "what you actually see in the images):\n" + "\n".join(metrics_lines) + "\n" |
|
|
| |
| has_coord_context = objects_info is not None |
| geometry_block = "" |
| joint_feedback_instruction = "" |
| joint_feedback_schema = "" |
| if has_coord_context: |
| rest_geometry_parts = [] |
| for name, info in objects_info.items(): |
| strokes = info.get("strokes") |
| if strokes: |
| from lib1 import build_stroke_geometry_text |
| rest_geometry_parts.append( |
| f'Object "{name}" rest-pose strokes (undeformed, for identifying which body part each ' |
| f'joint is near):\n{build_stroke_geometry_text(strokes, n_points=2)}' |
| ) |
| rest_geometry_text = "\n\n".join(rest_geometry_parts) |
|
|
| joint_legend_text = build_joint_legend_text(objects_info) |
| joint_targets_text = build_joint_targets_text(objects_info, deform_outputs, n_keyframes=n_keyframes) |
| bbox_text = "" |
| if real_trajectories is not None and all_object_names is not None: |
| bbox_text = build_bbox_trajectory_text(all_object_names, real_trajectories, n_keyframes=n_keyframes) |
|
|
| if deformed_geometry_text: |
| anchor_source_block = f""" |
| PER-KEYFRAME deformed stroke geometry — each stroke's ACTUAL position AT THAT SPECIFIC KEYFRAME (not rest). |
| THIS is the correct anchor source for target_coords: a stroke's position can differ keyframe to keyframe, so |
| when grounding a target for kf3, use kf3's coordinates below, not the rest-pose ones: |
| {deformed_geometry_text} |
| |
| Rest-pose stroke geometry (undeformed — for orientation/context only; do NOT use these coordinates as a |
| target_coords anchor for a specific keyframe, use the per-keyframe geometry above instead): |
| {rest_geometry_text}""" |
| anchor_instruction = ( |
| "find that stroke's EXACT coordinates AT THE SAME KEYFRAME you are giving feedback for, in the " |
| "per-keyframe deformed geometry above — NOT the rest-pose geometry below, which is only correct " |
| "for keyframes that happen to match rest and is included for orientation only. When you cite " |
| "anchored_to, you MUST state which block you took the coordinate from, e.g. 'kf4 deformed " |
| "geometry, stroke_2' — NOT just 'stroke_2', since both blocks use the same stroke numbering and " |
| "a bare stroke number does not say which one you actually used" |
| ) |
| else: |
| anchor_source_block = f""" |
| Rest-pose stroke geometry, with EXACT coordinates (use the IMAGES to identify what each stroke/region actually |
| is — e.g. "that stroke is the mouth", "that stroke is the spoon" — then use these coordinates as the real, |
| exact position of that thing. Do NOT estimate a position by eye from the image when a matching coordinate is |
| available here. NOTE: these are REST-POSE coordinates only — no per-keyframe geometry was provided, so treat |
| any target_coords derived from these as approximate for keyframes that have moved far from rest): |
| {rest_geometry_text}""" |
| anchor_instruction = ( |
| "find that stroke's EXACT coordinates in the rest-pose stroke geometry above (no per-keyframe " |
| "geometry was provided this call, so this is an approximation for keyframes far from rest). " |
| "State 'rest-pose geometry, stroke_N' in anchored_to" |
| ) |
|
|
| handle_distance_text = build_handle_distance_text(objects_info, deform_outputs, n_keyframes=n_keyframes) |
|
|
| geometry_block = f""" |
| The FIRST attached image is the rest pose (undeformed). The remaining {n_keyframes} attached images are this |
| attempt's actual rendered keyframes, in order. |
| {anchor_source_block} |
| |
| Joint index legend (rest positions, same coordinate space as the strokes above, y increases DOWNWARD): |
| {joint_legend_text} |
| |
| This attempt's joint TARGET positions per keyframe (what was actually requested — compare against rest to see |
| what moved and how far): |
| {joint_targets_text} |
| {f''' |
| {handle_distance_text} |
| |
| The distances above are EXACT, computed by code from the actual coordinates — not your estimate from the |
| images. Your magnitude/direction/relative_relationship scores below MUST be consistent with these numbers: a |
| handle pair whose distance barely changed from rest (a few px) has NOT moved meaningfully, regardless of how |
| the rendered lines look, and should not be scored as if it had. If your own visual read of the images |
| disagrees with the number above, the number is correct — it was computed by subtraction from the real target |
| coordinates you were also given, not inferred from the render. |
| ''' if handle_distance_text else ''} |
| Every object's bounding-box trajectory (FIXED, preprocessed ground truth — this is GIVEN, not something the |
| generator controls or you should critique; use it only as spatial context, e.g. to check whether a reaching |
| pose is plausible given where the other object actually is): |
| {bbox_text} |
| """ |
| arap_object_names = list(objects_info.keys()) |
| arap_list_str = ", ".join(f'"{n}"' for n in arap_object_names) |
| |
| |
| |
| |
| |
| |
| |
| |
| joint_feedback_instruction = ( |
| "\n4. For any ARAP object whose deformation looks wrong, give PRECISE corrective feedback as a " |
| f"coordinate, not a description: name the object (from this scene's ARAP objects: {arap_list_str}), " |
| "the specific joint_i index (from the legend above), and the SINGLE worst keyframe (not every " |
| "keyframe it's visible in). To decide the target coordinate: look at the images to identify which " |
| "stroke(s) the joint should move toward or align with (e.g. the mouth, another object's edge), " |
| f"then {anchor_instruction}, and use that coordinate (or a point clearly interpolated between two " |
| "nearby stroke coordinates) as target_coords. " |
| "CRITICAL — target_coords must be the coordinate of the STROKE THE JOINT SHOULD MOVE TOWARD, " |
| "NEVER the joint's own current/existing position: if you catch yourself citing the same stroke " |
| "the joint is already anchored to (i.e. the joint's own current location), that is NOT a " |
| "correction — it just restates where the joint already is, and provides zero corrective signal " |
| "to the next attempt. target_coords must differ meaningfully from where the joint is now, in the " |
| "direction that fixes the issue you are describing. If the joint should not have moved as far as " |
| "it did, target a point BETWEEN its current position and its rest position — not the current " |
| "position itself. " |
| "Do NOT invent a coordinate you cannot trace back to " |
| "the geometry text or joint legend — if you cannot find a grounded coordinate, describe the issue " |
| "in words instead of guessing a number, and state that no grounded target was found. Always name " |
| "what you anchored the target to in anchored_to. Do NOT create an entry for any object name other " |
| "than this scene's ARAP objects listed above — appearing elsewhere in this prompt (caption, " |
| "narrative, bbox trajectory) does NOT mean it has joints. NEVER create a joint_feedback entry for " |
| "something described as floating, disconnected, ungripped, or not touching another object/part — " |
| "the deformation method here moves points along a connected mesh and CANNOT tear or disconnect " |
| "geometry, so anything that looks disconnected either already was in the rest pose, or is two " |
| "separate objects that were never connected — no joint target, for any object, can cause or fix " |
| "this. Repeatedly proposing a coordinate for it just produces a stuck retry loop chasing something " |
| "that cannot be corrected. If MULTIPLE objects have a real, deformation-caused DISTORTION problem " |
| "(a part's own shape changing wrongly — NOT a floating/disconnection issue), include an entry for " |
| "EACH of them. If nothing specific and fixable is wrong anywhere, return an empty list.\n" |
| ) |
| joint_feedback_schema = ( |
| ',\n "joint_feedback": [\n {"object": "<name>", "joint": <joint index int>, ' |
| '"keyframe": <single int, the worst one>, "issue": "<one sentence>", ' |
| '"target_coords": {"x": <number or null if ungrounded>, "y": <number or null if ungrounded>}, ' |
| '"anchored_to": "<what stroke/coordinate you traced this to, or \'none - description only\'>"}\n ]' |
| ) |
|
|
| |
| |
| motion_checks_instruction = f""" |
| Additionally, fill in MOTION_CHECKS — a more granular breakdown than the three criteria above. |
| Score each 1-5 (or "N/A" where noted): |
| - direction: did joints move the CORRECT way relative to their goal, not backward/sideways? |
| - magnitude: was displacement large enough to be visually meaningful, not technically nonzero |
| but negligible, and not implausibly large for the action? |
| - timing: does displacement build up and settle at sensible points across the {n_keyframes} |
| sparse keyframes, rather than all crammed into one keyframe or front-loaded then idle? |
| - relative_relationship (1-5, or "N/A" if no interaction constraints were given above): for |
| EACH interaction constraint above, is the stated relationship actually true in the image AT |
| its specific critical_keyframe? Score the WORST-performing constraint if there are several. |
| - deformation_quality: do the MOVING parts stay a recognizable, undistorted version of the |
| source object (this is about moving parts — QUALITY above already covers parts that should |
| stay STILL)? |
| - temporal_coherence: does the sequence progress consistent with the narrative, without an |
| unexplained keyframe that jumps back toward rest and then forward again? |
| For each, also give a one-sentence note. If a relative_relationship constraint fails, add a |
| joint_feedback entry for the relevant object/joint/critical_keyframe the same way you would for |
| any other criterion (grounded to real geometry, per the instructions above — never invent a |
| coordinate). |
| """ |
| motion_checks_schema = ( |
| ',\n "motion_checks": {\n' |
| ' "direction": {"score": <1-5>, "note": "<one sentence>"},\n' |
| ' "magnitude": {"score": <1-5>, "note": "<one sentence>"},\n' |
| ' "timing": {"score": <1-5>, "note": "<one sentence>"},\n' |
| ' "relative_relationship": {"score": <1-5 or "N/A">, "note": "<one sentence>"},\n' |
| ' "deformation_quality": {"score": <1-5>, "note": "<one sentence>"},\n' |
| ' "temporal_coherence": {"score": <1-5>, "note": "<one sentence>"}\n' |
| ' }' |
| ) |
|
|
| return f"""You are judging a sequence of {n_keyframes} keyframe images generated for an animated sketch named "{sketch_name}". The images are provided in order (keyframe 0 through keyframe {n_keyframes - 1}), showing an object's pose changing over time. |
| |
| IMPORTANT: these are SPARSE keyframes sampled across the ENTIRE action from start to finish — NOT consecutive video frames a fraction of a second apart. Large, dramatic differences between consecutive keyframes are normal and correct; the actual in-between motion frames are generated separately later by a different model. Do NOT judge these like near-continuous animation frames, and do NOT penalize a keyframe for looking very different from its neighbor — that is expected. Judge whether the SEQUENCE of poses tells a coherent story of the action from start to finish. |
| {caption_block}{past_verdicts_block}{narrative_block}{metrics_block}{geometry_block}{interaction_block} |
| Evaluate the sequence on these criteria: |
| |
| 1. PLAUSIBILITY (1-5): Does the sequence of poses, taken as sparse waypoints across the whole action, tell a coherent and physically believable story from start to finish? 5 = each keyframe is a sensible, meaningfully different stage of the action, in a believable order. 1 = static (no visible change between keyframes at all) or the poses themselves are physically impossible/nonsensical — NOT simply "very different from the previous keyframe," which is expected and correct. Do NOT score down or flag "floating," "disconnected," or "ungripped" appearances (e.g. a hand not touching an object, one object not touching another) — the deformation method used here moves points along a connected mesh and CANNOT tear or disconnect geometry that wasn't already separate; anything that looks disconnected was EITHER already that way in the rest pose, OR is two genuinely separate objects that were never connected to begin with (no per-attempt correction can join them). This is a structural property of the drawing/scene, not something any joint_feedback target_coords can cause or fix — treat it as outside PLAUSIBILITY's scope entirely, not something to lower the score for or write a correction about. |
| |
| 2. {faithfulness_criterion} |
| |
| 3. QUALITY (1-5): Does each frame still look like a clean, recognizable line drawing of the object — NOT the motion, just the visual rendering itself? 5 = clean, coherent lines throughout, 1 = garbled, noisy, or unrecognizable as the object in any frame. THIS INCLUDES checking each joint marked <-- ANCHOR in the legend above: an anchor is supposed to stay near its rest position in EVERY keyframe, so also check whether the region around each anchor (and any other part of the object that should stay visually stable while only the moving joints change) has held its shape correctly across the sequence — not just whether lines are crisp. Unintended DISTORTION of a part that was supposed to remain stable (e.g. a face warping, a torso changing shape, when only a hand/limb was meant to move) IS a real QUALITY failure to flag and correct — this is different from "floating"/"disconnection" (see PLAUSIBILITY above, which is NEVER something to flag): distortion means a part's own shape changed wrongly, which deformation CAN cause and joint targets CAN fix; floating/disconnection means two parts aren't touching, which deformation cannot cause or fix. Score distortion low (1-2) even if nothing looks "noisy," but do not confuse it with the floating/disconnection case, which stays out of scope entirely. |
| {joint_feedback_instruction}{motion_checks_instruction} |
| IMPORTANT — overall_verdict is a single passage covering the WHOLE scene, used for logging/summary only. The |
| generator receives the SAME global feedback string for every object (per-object text scoping was tried and |
| REMOVED — numeric joint_feedback, not prose, is the actual per-object correction mechanism; keep overall_verdict |
| to ONE whole-scene passage, do not produce per-object prose breakdowns, to keep response length manageable |
| given joint_feedback below already requires one entry per ARAP object). |
| |
| Respond with ONLY a JSON object, no other text, in this exact format: |
| {{ |
| "plausibility_score": <1-5>, |
| "plausibility_notes": "<one sentence — if score is low due to something looking physically disconnected/unsupported within a keyframe (not just the sequence overall), say specifically what and which keyframe(s)>", |
| "faithfulness_score": <1-5 or "N/A">, |
| "faithfulness_notes": "<one sentence, for logging/reasoning only>", |
| "quality_score": <1-5>, |
| "quality_notes": "<one sentence — if score is low due to unintended distortion of a supposedly-stable part (not just messy lines), say specifically what part and which keyframe(s)>", |
| "overall_verdict": "<ONE whole-scene passage, for logging/summary only>"{joint_feedback_schema}{motion_checks_schema} |
| }} |
| """ |
|
|
|
|
| def parse_judge_response(response_text): |
| match = re.search(r"\{.*\}", response_text, re.DOTALL) |
| if not match: |
| raise ValueError("No JSON object found in response:\n" + response_text) |
| from lib1 import repair_json_trailing_commas |
| return json.loads(repair_json_trailing_commas(match.group(0))) |
|
|
|
|
| def check_frozen_joints_vs_high_scores(motion_checks, deform_outputs, freeze_threshold_px=2.0, |
| score_threshold=4): |
| """ |
| Detects the OPPOSITE failure from disconnection-language: instead of forbidden reasoning |
| driving a score DOWN, the judge confidently claims real motion occurred (justifying a HIGH |
| score) when the actual joint target numbers show the joint never moved at all. |
| |
| CONFIRMED on real hardware (eat2, Qwen2.5-VL-7B): a handle joint's target was byte-identical |
| across every single keyframe (kf1=kf2=kf3=kf4=[69.5,152.7], 0% cap utilization the entire |
| run) while the judge scored magnitude=5, timing=5, and specifically |
| relative_relationship=5 with the note "The spoon moved towards the mouth, maintaining a |
| logical relationship with the face" — a concrete, checkable, and FALSE claim, not vague |
| language. This got a perfect 5/5/5/5/5/5/5/5/5 across every main score and every |
| motion_checks sub-score, and would have sailed straight through the hard gates (all well |
| above threshold=4) with nothing catching it: filter_disconnection_feedback and the notes- |
| substantiveness checks are built to catch forbidden NEGATIVE reasoning lowering a score, |
| not confident FALSE POSITIVE claims of motion inflating one. This is a structurally |
| different exploit and needs its own check. |
| |
| For each ARAP object with 2+ keyframes of real (non-anchor) joint targets: if EVERY handle |
| joint's max pairwise displacement across all keyframes is under freeze_threshold_px (i.e. |
| the object's deformation is provably frozen), AND the judge scored magnitude, timing, or |
| relative_relationship at score_threshold or above, that is a direct contradiction between |
| the judge's claim and the numbers it was given — flagged as a validation problem the same |
| way every other confirmed exploit this session has been surfaced, not silently corrected |
| (there's no way to know what the CORRECT score should have been, only that this one |
| contradicts the evidence). |
| """ |
| problems = [] |
| if not isinstance(motion_checks, dict) or not isinstance(deform_outputs, dict): |
| return problems |
| for obj_name, kf_targets in deform_outputs.items(): |
| if len(kf_targets) < 2: |
| continue |
| |
| joint_positions = {} |
| for kf_key, joints in kf_targets.items(): |
| for joint_name, coord in joints.items(): |
| joint_positions.setdefault(joint_name, []).append(coord) |
| if not joint_positions: |
| continue |
| all_frozen = all( |
| max( |
| (float(np.hypot(a[0] - b[0], a[1] - b[1]))) |
| for i, a in enumerate(positions) for b in positions[i + 1:] |
| ) < freeze_threshold_px |
| for positions in joint_positions.values() if len(positions) >= 2 |
| ) |
| if not all_frozen: |
| continue |
| for check_name in ("magnitude", "timing", "relative_relationship"): |
| entry = motion_checks.get(check_name) |
| if not isinstance(entry, dict): |
| continue |
| score = entry.get("score") |
| if isinstance(score, (int, float)) and score >= score_threshold: |
| problems.append( |
| f"motion_checks['{check_name}']['score']={score} for object '{obj_name}', but " |
| f"EVERY one of its joint targets is frozen (max pairwise displacement under " |
| f"{freeze_threshold_px}px across all keyframes) — this score claims meaningful " |
| f"motion that the actual joint data directly contradicts. note: " |
| f"\"{entry.get('note')}\"" |
| ) |
| return problems |
|
|
|
|
| def validate_judge_response(parsed, valid_arap_objects=None, deform_outputs=None): |
| """ |
| valid_arap_objects: the actual list/set of ARAP object names that were |
| given a joint legend in this call's prompt (e.g. list(objects_info.keys())). |
| When given, any joint_feedback entry naming an object OUTSIDE this set |
| is a hallucination — the object either doesn't exist in this scene or |
| has no joints (e.g. a TRAJ_ONLY object) — and is flagged as a |
| validation problem rather than silently trusted. This is the code-side |
| backstop for the prompt's "only reference these ARAP objects" instruction: |
| the prompt asks the model not to do this, but doesn't structurally |
| prevent it, so this check exists for when it does anyway. |
| """ |
| required = ["plausibility_score", "faithfulness_score", "quality_score", "overall_verdict"] |
| problems = [f"missing key '{k}'" for k in required if k not in parsed] |
| for score_key in ["plausibility_score", "quality_score"]: |
| val = parsed.get(score_key) |
| if val is not None and not (isinstance(val, (int, float)) and 1 <= val <= 5): |
| problems.append(f"'{score_key}' = {val!r}, expected a number 1-5") |
|
|
| |
| |
| |
| if "joint_feedback" in parsed: |
| jf = parsed["joint_feedback"] |
| if not isinstance(jf, list): |
| problems.append(f"'joint_feedback' = {jf!r}, expected a list") |
| else: |
| required_entry_keys = ["object", "joint", "keyframe", "issue", "target_coords", "anchored_to"] |
| for i, entry in enumerate(jf): |
| if not isinstance(entry, dict): |
| problems.append(f"joint_feedback[{i}] is not an object: {entry!r}") |
| continue |
| missing = [k for k in required_entry_keys if k not in entry] |
| if missing: |
| problems.append(f"joint_feedback[{i}] missing keys: {missing}") |
| |
| |
| |
| |
| |
| |
| if valid_arap_objects is not None: |
| obj = entry.get("object") |
| if obj is not None and obj not in valid_arap_objects: |
| problems.append( |
| f"joint_feedback[{i}]['object'] = {obj!r} is not an ARAP object with a joint " |
| f"legend in this prompt (valid: {sorted(valid_arap_objects)}) — likely " |
| f"hallucinated, e.g. a TRAJ_ONLY object mistakenly given joint feedback" |
| ) |
| if "joint" in entry and not isinstance(entry["joint"], int): |
| problems.append(f"joint_feedback[{i}]['joint'] = {entry['joint']!r}, expected an int") |
| if "keyframe" in entry and not isinstance(entry["keyframe"], int): |
| problems.append(f"joint_feedback[{i}]['keyframe'] = {entry['keyframe']!r}, expected an int") |
| tc = entry.get("target_coords") |
| if tc is not None: |
| if not isinstance(tc, dict) or "x" not in tc or "y" not in tc: |
| problems.append(f"joint_feedback[{i}]['target_coords'] = {tc!r}, expected {{'x':num,'y':num}} or null") |
| else: |
| for axis in ("x", "y"): |
| v = tc.get(axis) |
| if v is not None and not isinstance(v, (int, float)): |
| problems.append(f"joint_feedback[{i}]['target_coords']['{axis}'] = {v!r}, expected a number or null") |
| |
| |
| |
| |
| |
| anchor = entry.get("anchored_to", "") |
| has_x = tc.get("x") is not None |
| if has_x and isinstance(anchor, str) and anchor.strip(): |
| disambiguated = any(term in anchor.lower() for term in |
| ("kf", "keyframe", "deformed", "rest-pose", "rest pose")) |
| if not disambiguated: |
| problems.append( |
| f"joint_feedback[{i}]['anchored_to'] = {anchor!r} does not specify which " |
| f"geometry block (per-keyframe deformed vs rest-pose) the coordinate came " |
| f"from — ambiguous, both blocks reuse the same stroke numbering" |
| ) |
|
|
| |
| |
| |
| if "motion_checks" in parsed: |
| mc = parsed["motion_checks"] |
| expected_checks = ("direction", "magnitude", "timing", "relative_relationship", |
| "deformation_quality", "temporal_coherence") |
| if not isinstance(mc, dict): |
| problems.append(f"'motion_checks' = {mc!r}, expected an object") |
| else: |
| for check_name in expected_checks: |
| if check_name not in mc: |
| problems.append(f"'motion_checks' missing key '{check_name}'") |
| continue |
| entry = mc[check_name] |
| if not isinstance(entry, dict) or "score" not in entry: |
| problems.append(f"motion_checks['{check_name}'] = {entry!r}, expected {{'score':..., 'note':...}}") |
| continue |
| score = entry["score"] |
| is_na = isinstance(score, str) and score.strip().upper() == "N/A" |
| is_valid_score = (isinstance(score, (int, float)) and 1 <= score <= 5) or is_na |
| if not is_valid_score: |
| problems.append(f"motion_checks['{check_name}']['score'] = {score!r}, expected 1-5 or 'N/A'") |
| elif is_na and check_name != "relative_relationship": |
| problems.append( |
| f"motion_checks['{check_name}']['score'] = 'N/A' — only 'relative_relationship' is " |
| f"allowed to be N/A (when no interaction constraints were given); the rest should " |
| f"always be a real 1-5 score" |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| for notes_key in ("plausibility_notes", "overall_verdict"): |
| notes_text = parsed.get(notes_key) |
| if isinstance(notes_text, str) and is_disconnection_feedback(notes_text): |
| problems.append( |
| f"'{notes_key}' cites disconnection/floating language (\"{notes_text}\") — this is " |
| f"NEVER a valid reason to lower plausibility_score per the prompt's own instruction; " |
| f"the score this justifies should be treated with suspicion, not trusted at face value" |
| ) |
|
|
| |
| |
| |
| |
| |
| |
| motion_checks = parsed.get("motion_checks") |
| if isinstance(motion_checks, dict): |
| for check_name, entry in motion_checks.items(): |
| if not isinstance(entry, dict): |
| continue |
| note_text = entry.get("note") |
| if isinstance(note_text, str) and is_disconnection_feedback(note_text): |
| problems.append( |
| f"motion_checks['{check_name}']['note'] cites disconnection/floating language " |
| f"(\"{note_text}\") — this is NEVER a valid reason to lower ANY score per the " |
| f"prompt's own instruction; motion_checks['{check_name}']['score']=" |
| f"{entry.get('score')!r} should be treated with suspicion, not trusted at face value" |
| ) |
|
|
| problems.extend(check_frozen_joints_vs_high_scores(motion_checks, deform_outputs)) |
| return problems |
|
|
|
|
| def load_keyframe_images(frames_dir, n_keyframes=5): |
| from PIL import Image |
| images = [] |
| for kf in range(n_keyframes): |
| path = os.path.join(frames_dir, f"kf{kf}.png") |
| if not os.path.exists(path): |
| raise SystemExit(f"Missing expected keyframe image: {path}") |
| images.append(Image.open(path).convert("RGB")) |
| return images |
|
|
|
|
| def run_judge(model, processor, images, prompt): |
| import torch |
| content = [{"type": "image", "image": img} for img in images] |
| content.append({"type": "text", "text": prompt}) |
| messages = [{"role": "user", "content": content}] |
|
|
| text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) |
| inputs = processor(text=[text], images=images, return_tensors="pt").to(model.device) |
|
|
| with torch.no_grad(): |
| output_ids = model.generate(**inputs, max_new_tokens=1200, temperature=0.2, do_sample=True) |
|
|
| generated = output_ids[:, inputs["input_ids"].shape[1]:] |
| response = processor.batch_decode(generated, skip_special_tokens=True)[0] |
| return response |
|
|
|
|
| def run_judge_voted(model, processor, images, prompt, n_votes=1, valid_arap_objects=None, |
| deform_outputs=None): |
| """ |
| Calls run_judge N times on the SAME prompt/images and aggregates the results by MEDIAN, rather |
| than trusting a single call. Loosely based on the multi-view voting idea in "Handle-based Mesh |
| Deformation Guided By Vision Language Model" (Sun et al. 2025) — there, uncertainty in a single |
| VLM coordinate prediction is reduced by querying from multiple camera views and voting; here, |
| with only one 2D view available, the analogous move is multiple independent judge calls on the |
| SAME view, aggregated the same way. NOT identical to the paper's setting — resampling one view |
| is a weaker source of independent signal than genuinely different views — this is untested for |
| whether it actually helps here. |
| |
| n_votes=1 is the previous, unchanged single-call behavior (returns run_judge's raw response |
| directly, no aggregation, no extra parsing cost). |
| |
| For n_votes>1: parses EACH of the n_votes raw responses individually (using this module's own |
| parse_judge_response/validate_judge_response — a response that fails to parse or fails |
| validation is EXCLUDED from the vote, not treated as a zero or default, so one malformed |
| response can't silently corrupt the median), then returns a SYNTHESIZED verdict dict — NOT one |
| of the raw responses verbatim — built by: |
| - plausibility_score / faithfulness_score / quality_score: MEDIAN across valid responses |
| (faithfulness "N/A" entries are excluded from that score's median specifically, not |
| counted as 0) |
| - overall_verdict / *_notes: taken from whichever valid response's plausibility_score was |
| CLOSEST to the final median plausibility_score (a real piece of text explaining a |
| real-ish outcome, rather than trying to synthesize new prose no model actually wrote) |
| - joint_feedback: for each (object, joint, keyframe) combination that appears with grounded |
| target_coords in at least one valid response, target_coords is the MEDIAN of every |
| response that gave a grounded value for that exact combination; entries only ever |
| ungrounded across all votes stay ungrounded (never fabricate a coordinate that no single |
| vote actually proposed) |
| - past_verdicts (if given in the prompt) don't need special handling here — this function |
| only aggregates responses TO one prompt, not across attempts |
| |
| Returns (verdict_dict, list_of_raw_response_strings) — the raw strings are still all saved by |
| the caller for inspection, same as the n_votes=1 case, just now there are n_votes of them. |
| """ |
| import statistics |
|
|
| raw_responses = [run_judge(model, processor, images, prompt) for _ in range(max(1, n_votes))] |
|
|
| if n_votes <= 1: |
| |
| return raw_responses[0], raw_responses |
|
|
| parsed_votes = [] |
| for raw in raw_responses: |
| try: |
| v = parse_judge_response(raw) |
| except (ValueError, Exception): |
| continue |
| problems = validate_judge_response(v, valid_arap_objects=valid_arap_objects, |
| deform_outputs=deform_outputs) |
| |
| |
| |
| structural_problems = [p for p in problems if "missing key" in p or "expected a number 1-5" in p] |
| if structural_problems: |
| continue |
| |
| |
| |
| |
| v["joint_feedback"] = filter_disconnection_feedback(v.get("joint_feedback"), source_label="per-vote") |
| parsed_votes.append(v) |
|
|
| if not parsed_votes: |
| |
| |
| return raw_responses[0], raw_responses |
|
|
| def _median_score(key, votes): |
| vals = [v.get(key) for v in votes if isinstance(v.get(key), (int, float))] |
| return statistics.median(vals) if vals else None |
|
|
| plaus_median = _median_score("plausibility_score", parsed_votes) |
| faith_median = _median_score("faithfulness_score", parsed_votes) |
| qual_median = _median_score("quality_score", parsed_votes) |
|
|
| |
| |
| def _closest_vote(): |
| if plaus_median is None: |
| return parsed_votes[0] |
| return min(parsed_votes, key=lambda v: abs((v.get("plausibility_score") or 0) - plaus_median)) |
| source_vote = _closest_vote() |
|
|
| |
| |
| grouped = {} |
| for v in parsed_votes: |
| for entry in (v.get("joint_feedback") or []): |
| tc = entry.get("target_coords") |
| if not isinstance(entry, dict) or tc is None or tc.get("x") is None or tc.get("y") is None: |
| continue |
| key = (entry.get("object"), entry.get("joint"), entry.get("keyframe")) |
| grouped.setdefault(key, {"xs": [], "ys": [], "issues": [], "anchors": []}) |
| grouped[key]["xs"].append(tc["x"]) |
| grouped[key]["ys"].append(tc["y"]) |
| grouped[key]["issues"].append(entry.get("issue", "")) |
| grouped[key]["anchors"].append(entry.get("anchored_to", "")) |
|
|
| voted_joint_feedback = [] |
| for (obj, joint, kf), data in grouped.items(): |
| voted_joint_feedback.append({ |
| "object": obj, "joint": joint, "keyframe": kf, |
| "issue": data["issues"][0] if data["issues"] else "", |
| "target_coords": {"x": statistics.median(data["xs"]), "y": statistics.median(data["ys"])}, |
| "anchored_to": f"MEDIAN of {len(data['xs'])}/{len(parsed_votes)} votes; first vote's citation: {data['anchors'][0] if data['anchors'] else 'n/a'}", |
| }) |
| |
| |
| |
| ungrounded_seen = {} |
| for v in parsed_votes: |
| for entry in (v.get("joint_feedback") or []): |
| tc = entry.get("target_coords") |
| if isinstance(entry, dict) and (tc is None or tc.get("x") is None): |
| key = (entry.get("object"), entry.get("joint"), entry.get("keyframe")) |
| if key not in grouped: |
| ungrounded_seen[key] = entry |
| for entry in ungrounded_seen.values(): |
| voted_joint_feedback.append(entry) |
|
|
| |
| |
| |
| |
| |
| motion_check_names = ("direction", "magnitude", "timing", "relative_relationship", |
| "deformation_quality", "temporal_coherence") |
| voted_motion_checks = {} |
| for check_name in motion_check_names: |
| vals = [] |
| for v in parsed_votes: |
| entry = (v.get("motion_checks") or {}).get(check_name) |
| if isinstance(entry, dict) and isinstance(entry.get("score"), (int, float)): |
| vals.append(entry["score"]) |
| if vals: |
| voted_motion_checks[check_name] = { |
| "score": statistics.median(vals), |
| "note": ((source_vote.get("motion_checks") or {}).get(check_name) or {}).get("note", ""), |
| } |
| else: |
| |
| |
| fallback = (source_vote.get("motion_checks") or {}).get(check_name) |
| if fallback is not None: |
| voted_motion_checks[check_name] = fallback |
|
|
| synthesized_verdict = { |
| "plausibility_score": plaus_median, |
| "plausibility_notes": source_vote.get("plausibility_notes"), |
| "faithfulness_score": faith_median if faith_median is not None else source_vote.get("faithfulness_score"), |
| "faithfulness_notes": source_vote.get("faithfulness_notes"), |
| "quality_score": qual_median, |
| "quality_notes": source_vote.get("quality_notes"), |
| "overall_verdict": ( |
| f"[MEDIAN of {len(parsed_votes)}/{n_votes} valid votes] " + source_vote.get("overall_verdict", "") |
| ), |
| "joint_feedback": voted_joint_feedback, |
| } |
| if voted_motion_checks: |
| synthesized_verdict["motion_checks"] = voted_motion_checks |
| return synthesized_verdict, raw_responses |
|
|
|
|
| def load_vlm(model_path, quantize=None): |
| """ |
| quantize: "8bit", "4bit", or None (full precision) — currently |
| defaulting to None (full precision) per explicit choice to accept |
| the risk of the CUDA OOM this GPU has repeatedly hit before, over |
| the ~8-bit slowdown (dequantize overhead per layer means 8bit is |
| typically SLOWER per-token than full precision, not faster — it |
| only exists as a memory-fit tradeoff, never was a speed win). |
| |
| CONFIRMED on real hardware: full-precision loading consistently uses |
| ~13.8 GiB on a GPU with ~14.6 GiB actually usable (15.59 GiB nominal |
| minus this machine's desktop/display overhead), leaving under 100 MiB |
| free — repeatedly triggered CUDA OOM across multiple different |
| sketches (dog9, ice2). If this recurs, pass quantize="8bit" or "4bit". |
| |
| Model class is auto-selected from model_path, since Qwen3-VL and |
| Qwen2.5-VL are NOT interchangeable — different class, not just a |
| different string. Qwen2.5-VL requires transformers>=4.49.0 (confirmed |
| fine on this environment's transformers==5.14.1); it was NOT viable |
| back when this environment was still on Python 3.8. |
| """ |
| if "qwen2.5-vl" in model_path.lower() or "qwen2_5_vl" in model_path.lower(): |
| from transformers import Qwen2_5_VLForConditionalGeneration as ModelClass, AutoProcessor |
| elif "qwen2-vl" in model_path.lower(): |
| from transformers import Qwen2VLForConditionalGeneration as ModelClass, AutoProcessor |
| else: |
| from transformers import Qwen3VLForConditionalGeneration as ModelClass, AutoProcessor |
| print(f"loading VLM from {model_path} (class={ModelClass.__name__}, quantize={quantize}) ...") |
|
|
| kwargs = {"device_map": "auto"} |
| if quantize == "8bit": |
| from transformers import BitsAndBytesConfig |
| kwargs["quantization_config"] = BitsAndBytesConfig(load_in_8bit=True) |
| elif quantize == "4bit": |
| from transformers import BitsAndBytesConfig |
| kwargs["quantization_config"] = BitsAndBytesConfig( |
| load_in_4bit=True, bnb_4bit_compute_dtype="float16", bnb_4bit_quant_type="nf4") |
| else: |
| kwargs["dtype"] = "auto" |
|
|
| model = ModelClass.from_pretrained(model_path, **kwargs) |
| processor = AutoProcessor.from_pretrained(model_path) |
| print("VLM loaded.") |
| return model, processor |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("sketch", type=str, |
| help="sketch name — used to auto-derive the frames folder and look up the caption") |
| ap.add_argument("--frames-dir", type=str, default=None, |
| help="directory containing kf0.png ... kf4.png (default: frames/{sketch}, " |
| "matching pipeline.py's default output layout)") |
| ap.add_argument("--model", type=str, default="Qwen/Qwen3-VL-4B-Instruct") |
| ap.add_argument("--caption", type=str, default=None, |
| help="override the caption directly instead of looking it up") |
| ap.add_argument("--caption-file", type=str, default=CAPTION_FILE_DEFAULT) |
| ap.add_argument("--no-caption", action="store_true", |
| help="skip caption lookup entirely (faithfulness will be judged as N/A)") |
| ap.add_argument("--out", type=str, default=None, |
| help="where to write the verdict JSON (default: json/{sketch}/judge_verdict.json)") |
| args = ap.parse_args() |
|
|
| frames_dir = args.frames_dir or os.path.join("frames", args.sketch) |
|
|
| caption = None |
| if args.no_caption: |
| print("--no-caption given, skipping caption lookup") |
| elif args.caption: |
| caption = args.caption |
| else: |
| from lib1 import get_caption |
| caption = get_caption(args.caption_file, args.sketch) |
| print(f"looked up caption for '{args.sketch}': {caption!r}") |
|
|
| images = load_keyframe_images(frames_dir) |
| print(f"loaded {len(images)} keyframe images from {frames_dir}") |
|
|
| prompt = build_judge_prompt(args.sketch, caption) |
| print("\n--- JUDGE PROMPT ---") |
| print(prompt) |
|
|
| model, processor = load_vlm(args.model) |
| response = run_judge(model, processor, images, prompt) |
| print("\n--- RAW RESPONSE ---") |
| print(response) |
|
|
| parsed = parse_judge_response(response) |
| problems = validate_judge_response(parsed) |
| print("\n--- PARSED VERDICT ---") |
| print(json.dumps(parsed, indent=2)) |
| if problems: |
| print("--- VALIDATION PROBLEMS ---") |
| for p in problems: |
| print(f" - {p}") |
|
|
| out_path = args.out |
| if not out_path: |
| out_path = os.path.join(os.path.dirname(os.path.dirname(frames_dir.rstrip("/"))), |
| "json", args.sketch, "judge_verdict.json") |
| os.makedirs(os.path.dirname(out_path), exist_ok=True) |
| with open(out_path, "w") as f: |
| json.dump(parsed, f, indent=2) |
| print(f"\nwrote {out_path}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|