my-cool-model / vimjudge1.py
reshma0639's picture
Upload folder using huggingface_hub (part 15)
b0ba444 verified
Raw
History Blame Contribute Delete
86.2 kB
"""
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
# Same default as pipeline.py — so the caption is auto-pulled by sketch
# name alone, no need to pass --caption-file every time.
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", {}) # {} when KMeans was used
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:
# Downgrade, don't delete: strip the useless coordinate/delta so
# compute_joint_feedback_deltas's math and format_joint_feedback_for_object's
# "grounded" formatting never see a fake correction, but KEEP the entry itself
# (object, joint, keyframe, issue) so format_joint_feedback_for_object's EXISTING
# ungrounded fallback still surfaces the prose observation to the next attempt —
# see the docstring above for why full deletion was a real, confirmed regression.
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 # visible marker for debugging/logs
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 # judge explicitly said it couldn't ground this — respect that, don't invent a delta
# anchored_to must disambiguate WHICH geometry block the coordinate came from — both the
# per-keyframe deformed block and the rest-pose block reuse "stroke_N" numbering, so a bare
# "stroke_2" can't be trusted to be the right one. Rather than trust an ambiguous anchor and
# silently compute a delta that might be using a stale rest-pose coordinate for a moved
# keyframe, treat it the same as ungrounded: no delta, falls back to prose-only feedback.
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"
# coordinate/geometry context — only built if the caller supplied it
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)
# REVERTED from a mandatory status/exactly-one-entry-per-object schema — CONFIRMED on real
# hardware (football7, real 3-object scene) that forcing a binary needs_correction/
# no_change_needed classification for every object, every time, caused the model to default
# to "no_change_needed" across the board even when its OWN overall_verdict/faithfulness_score
# said something was wrong — a worse failure than the original "silently skips an object"
# problem this was built to fix, since it produces confident-looking but false coverage
# instead of an honest gap. Back to an open list: report whatever objects actually have a
# real problem, omit the rest, no forced per-object judgment call.
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 — see the MOTION_CHECKS section of this function's docstring for what each
# check means and the caveat that only 6 of the claimed 7 were specified in the source doc.
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 # need at least 2 keyframes to detect "never moved"
# collect every joint's positions across all keyframes present
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")
# joint_feedback is OPTIONAL — only present when the prompt included
# coordinate context (objects_info etc. were passed to build_judge_prompt).
# Absence is not itself a problem; malformed entries are.
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}")
# hallucination check: does this entry's object actually have a joint legend in
# this call's prompt? A TRAJ_ONLY or nonexistent object name here means the model
# invented a joint for something that structurally cannot have one — this is
# flagged, not silently trusted, since it wastes the judge's feedback on an
# object that can never use it and can crowd out real feedback for objects that
# actually needed it.
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")
# target_coords was given but anchored_to doesn't say WHICH block (per-keyframe
# deformed vs rest-pose) it came from — both blocks reuse the same "stroke_N"
# numbering, so a bare "stroke_2" can't be trusted to be the right one. This is a
# validation problem because a wrong-block anchor silently produces a wrong
# but confident-looking delta_px downstream, with no other signal catching it.
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"
)
# motion_checks is OPTIONAL — build_judge_prompt always requests it now, but this stays
# permissive for older saved verdicts / prompts that predate this field. Absence alone is not
# flagged as a problem.
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"
)
# CONFIRMED on real hardware (eat2, multiple attempts in the same run): plausibility_notes
# repeatedly cited disconnection/floating language as the STATED REASON for a low
# plausibility_score, despite the prompt explicitly saying this category of complaint is
# NEVER valid (ARAP moves points along a connected mesh and cannot cause or fix a
# disconnected/floating appearance). filter_disconnection_feedback already catches this
# exact language when it shows up in joint_feedback['issue'] and removes those entries —
# but nothing previously caught it when the SAME forbidden reasoning was used to justify
# the plausibility_score itself, which has no equivalent "just remove it" fix (there's no
# fallback score to substitute). This can't be silently corrected, only surfaced — added
# here as a validation problem so a systematically-suppressed score is visible in logs
# rather than silently trusted, using the exact same detector already proven to catch this
# language in joint_feedback.
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"
)
# Same check, extended to motion_checks — CONFIRMED on real hardware (eat2, Qwen2.5-VL-7B):
# relative_relationship cited "The hand is disconnected from the arm, which is not physically
# possible" as its stated reason for a score of 1, the exact forbidden language the check
# above already catches in plausibility_notes/overall_verdict. Without this, motion_checks
# scores were a second, uncovered surface for the identical confirmed failure — worth closing
# before motion_checks gets used for anything beyond display (e.g. ranking/tie-breaking).
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:
# unchanged behavior: caller parses this raw string itself, exactly as before
return raw_responses[0], raw_responses
parsed_votes = []
for raw in raw_responses:
try:
v = parse_judge_response(raw)
except (ValueError, Exception):
continue # malformed response excluded from the vote entirely, not treated as a default
problems = validate_judge_response(v, valid_arap_objects=valid_arap_objects,
deform_outputs=deform_outputs)
# only reject on STRUCTURAL problems (wrong types, missing required keys) — cosmetic issues
# like an ambiguous anchored_to still leave a usable vote for the SCORES even if that one
# joint_feedback entry gets excluded individually below
structural_problems = [p for p in problems if "missing key" in p or "expected a number 1-5" in p]
if structural_problems:
continue
# filter disconnection-language entries out of THIS vote's joint_feedback before it ever
# enters the aggregation — prevents a disconnection "fix" from being averaged into the
# final median at all, rather than letting it through and relying only on the downstream
# filter in compute_joint_feedback_deltas to catch it later.
v["joint_feedback"] = filter_disconnection_feedback(v.get("joint_feedback"), source_label="per-vote")
parsed_votes.append(v)
if not parsed_votes:
# every vote was malformed — fall back to the single raw response a non-voting call would
# have returned, so the caller's existing parse-failure handling still applies unchanged
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)
# pick the vote whose plausibility_score is closest to the median, to source overall_verdict
# and the *_notes fields from a REAL response rather than synthesizing new text
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()
# aggregate joint_feedback: group grounded entries by (object, joint, keyframe), median their
# target_coords across whichever votes proposed a grounded value for that exact combination
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 "", # representative text, not synthesized
"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'}",
})
# also carry forward any entries that were ALWAYS ungrounded across every vote that mentioned
# them, so a real (if unfixable) issue described by every vote isn't silently dropped just
# because it never got a grounded coordinate
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: # never grounded by ANY vote
ungrounded_seen[key] = entry
for entry in ungrounded_seen.values():
voted_joint_feedback.append(entry)
# aggregate motion_checks the same way as the three main scores: median each sub-score across
# whichever votes returned a real (non-N/A) number for it; the note comes from source_vote
# (same "closest to median plausibility" pick used for the other *_notes fields) rather than
# trying to synthesize new prose. A sub-check missing from every vote (e.g. an older prompt
# version, or relative_relationship staying N/A everywhere) is simply omitted.
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:
# no vote gave a numeric score for this check (all N/A, or all votes predate this
# field) — fall back to source_vote's raw entry (may itself be "N/A" or missing)
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()