| """ |
| mosketch_pipeline.py β the full pipeline as one script with subcommands. |
| |
| Pipeline: |
| 1. Identify objects -> from the semantic file (no Qwen) |
| 2. Classify ARAP vs. not -> `classify` subcommand (one Qwen call, all objects) |
| 3. Narrate + deform -> `narrate` + `deform` subcommands, ONLY for |
| objects marked ARAP in step 2 |
| 4. Render -> `render` subcommand; every object gets real |
| trajectory translation; ARAP objects |
| additionally get deformation on top |
| |
| Run steps individually for debugging, or use `full` to run everything for |
| one sketch in one process β this loads the Qwen model ONCE and reuses it |
| across classify/narrate/deform, instead of loading it 3 separate times. |
| |
| Examples: |
| # step by step |
| python mosketch_pipeline.py classify --model M --caption-file C --sketch-name S --semantic SEM --out deformation.json |
| python mosketch_pipeline.py narrate --model M --caption-file C --sketch-name S --objects dog --out narratives.json |
| python mosketch_pipeline.py deform --model M --svg S.svg --semantic SEM --traj T --narratives narratives.json --deformation deformation.json --out-dir . |
| python mosketch_pipeline.py render --svg S.svg --semantic SEM --traj T --handles-dir . |
| |
| # everything at once, one model load |
| python mosketch_pipeline.py full --model M --caption-file C --sketch-name S --svg S.svg --semantic SEM --traj T --out-dir . |
| """ |
|
|
| import argparse |
| import json |
| import os |
| import re |
| import sys |
|
|
| import numpy as np |
| import math |
| import statistics |
| from PIL import Image |
| from datetime import datetime |
|
|
| import lib1 as lib |
| from lib1 import ( |
| load_strokes_from_svg, load_semantic_assignments, filter_strokes, flatten_strokes, |
| load_object, deduplicate_points, build_mesh, nearest_mesh_vertex, |
| auto_select_handles_deduped, object_bbox_size, arap_deform, |
| load_trajectories, bbox_deltas, get_caption, build_stroke_geometry_text, |
| load_interaction_constraints, repair_json_trailing_commas, cluster_object_strokes, |
| ) |
|
|
| N_KEYFRAMES = 5 |
| MAX_RETRIES = 5 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| HANDLE_MOVE_CAP_FRACTION = 0.4 |
|
|
| PLAUSIBILITY_THRESHOLD = 4 |
| FAITHFULNESS_THRESHOLD = 4 |
| |
| QUALITY_THRESHOLD = 4 |
| |
| MOTION_CHECKS_THRESHOLD = 4 |
| |
| |
| |
|
|
|
|
| def faithfulness_passed(score, notes=None): |
| """ |
| faithfulness_score can be a number 1-5, the string "N/A" (no caption |
| was given to compare against, so there's nothing to fail), or missing |
| entirely (treated as NOT passed β can't confirm it's good, so err |
| toward regenerating the narrative rather than assuming it's fine). |
| |
| REVISED β "N/A" alone is no longer sufficient to auto-pass. CONFIRMED on real hardware |
| (eat2, Qwen2.5-VL-7B): the judge returned faithfulness_score="N/A" while faithfulness_notes |
| contained a real, substantive critique β "The narrative claims the hand will straighten, |
| but the actual movement is minimal and disconnected from the arm." That is not "nothing to |
| evaluate" (eat2 has a real caption; the judge plainly DID evaluate it and found a problem); |
| it looks like the model using "N/A" as an escape hatch to avoid committing to a low number |
| while still reporting the finding through the notes field. The original design assumed N/A |
| would only appear when there was genuinely nothing to compare β nothing enforced that |
| assumption. Left unchecked, this caused a real, confirmed downstream failure: N/A auto- |
| passed, which set freeze_narrative=True (see `freeze_narrative = faithfulness_ok` in |
| main()), locking the story as "already faithful" for every subsequent attempt even though |
| the judge's own prose said the opposite β the pipeline spent its remaining retries refining |
| numbers for a narrative that was never actually verified. |
| |
| Now: "N/A" only auto-passes when `notes` is empty/trivial β a short, generic phrase (e.g. |
| "no caption provided", "nothing to compare", "not applicable") or genuinely blank. Any |
| substantive notes text alongside "N/A" is treated as NOT passed, on the same principle as |
| the disconnection-language check: if the judge is writing something specific enough that it |
| reads as a real critique, that's real content, not an empty pass-through, and should not be |
| silently trusted at face value as a pass. |
| """ |
| if score is None: |
| return False |
| if isinstance(score, str) and score.strip().upper() == "N/A": |
| if not notes or not isinstance(notes, str): |
| return True |
| stripped = notes.strip().lower() |
| trivial_patterns = ("no caption", "not applicable", "nothing to compare", |
| "n/a", "no comparison", "cannot be evaluated", "cannot evaluate") |
| is_trivial = len(stripped) < 15 or any(p in stripped for p in trivial_patterns) |
| if not is_trivial: |
| print(f" NOTE: faithfulness_score='N/A' but faithfulness_notes contains substantive " |
| f"text (\"{notes}\") β NOT treating this as an automatic pass. A real critique " |
| f"reported through 'N/A' instead of a low number is still a real critique.") |
| return is_trivial |
| if isinstance(score, (int, float)): |
| return score >= FAITHFULNESS_THRESHOLD |
| return False |
|
|
|
|
| MOTION_CHECK_NAMES = ("direction", "magnitude", "timing", "relative_relationship", |
| "deformation_quality", "temporal_coherence") |
|
|
|
|
| def motion_checks_passed(motion_checks, threshold=MOTION_CHECKS_THRESHOLD): |
| """ |
| Requires EVERY one of the 6 motion_checks sub-scores to individually clear `threshold`. |
| |
| ADDED AS A HARD GATE per explicit request, despite a real, named risk: this session has |
| directly confirmed motion_checks sub-scores (specifically relative_relationship and |
| direction) can be justified by forbidden disconnection-language reasoning, the exact same |
| confirmed failure mode already found in plausibility_notes/overall_verdict/faithfulness_score. |
| validate_judge_response now scans motion_checks notes for this too, so a contaminated score |
| at least gets flagged as a VALIDATION PROBLEM β but flagging is visibility, not correction; |
| this gate still trusts the numeric score itself. Six new individually-gating thresholds, |
| layered on top of three (plausibility/faithfulness/quality) that already rarely align |
| simultaneously in this session's real runs, is expected to make full passes LESS common, |
| not better-calibrated ones. Building it as specified anyway; this comment is the record of |
| that tradeoff being made knowingly, not a silent risk. |
| |
| relative_relationship is the ONLY check the judge prompt allows to be "N/A" (when no |
| interaction_constraints were given β see build_judge_prompt's motion_checks instruction). |
| Reuses the exact anti-exploit check just added to faithfulness_passed: "N/A" only passes |
| when the accompanying note is genuinely trivial/empty, not when it's carrying a real |
| critique through the N/A field instead of a low number β CONFIRMED necessary given the |
| identical exploit was just found live on faithfulness_score; there is no reason to assume |
| motion_checks' N/A field is any less exploitable the same way. |
| |
| The other 5 checks have no documented reason to ever be "N/A" β if one is anyway, that is |
| treated as FAILING that check, not passing it. Returns (all_passed: bool, per_check: dict |
| of {name: bool}) so the caller can print exactly which check(s) failed, matching the |
| existing verbosity of the other three gates rather than collapsing to one opaque boolean. |
| """ |
| if not isinstance(motion_checks, dict): |
| return False, {name: False for name in MOTION_CHECK_NAMES} |
| per_check = {} |
| for name in MOTION_CHECK_NAMES: |
| entry = motion_checks.get(name) |
| if not isinstance(entry, dict): |
| per_check[name] = False |
| continue |
| score = entry.get("score") |
| notes = entry.get("note") |
| if name == "relative_relationship" and isinstance(score, str) and score.strip().upper() == "N/A": |
| if not notes or not isinstance(notes, str): |
| per_check[name] = True |
| else: |
| stripped = notes.strip().lower() |
| trivial_patterns = ("no interaction", "not applicable", "no constraint", |
| "n/a", "no comparison", "cannot be evaluated") |
| per_check[name] = len(stripped) < 15 or any(p in stripped for p in trivial_patterns) |
| elif isinstance(score, (int, float)): |
| per_check[name] = score >= threshold |
| else: |
| per_check[name] = False |
| return all(per_check.values()), per_check |
|
|
|
|
| def unload_model(model): |
| """Frees GPU memory before loading a different model. Necessary because |
| the narrate/deform steps use a text Qwen model and the judge step uses |
| a separate vision-language model (Qwen3-VL) β on hardware with limited |
| VRAM (this project's RTX A4000, 16GB, already documented as a tight |
| fit for a single model), loading both at once risks the same OOM issue |
| that blocked Wan2.2 integration earlier. Load/unload sequentially |
| instead of assuming both fit simultaneously. |
| |
| CONFIRMED BUG (found on real hardware, invisible to all mocked testing |
| since no real GPU was available to catch it): `del model` here only |
| clears THIS function's own local reference β it does nothing to the |
| caller's variable, which stays alive and keeps the whole model |
| resident in VRAM. torch.cuda.empty_cache() then has nothing to |
| actually free, because the refcount never reaches zero. Fixed by |
| returning None β callers MUST reassign their variable to this return |
| value (e.g. `model = unload_model(model)`), or the bug reappears.""" |
| import gc |
| del model |
| gc.collect() |
| try: |
| import torch |
| if torch.cuda.is_available(): |
| torch.cuda.empty_cache() |
| except ImportError: |
| pass |
| return None |
|
|
| DEFAULT_COLOR = "#444444" |
| DEFAULT_LINEWIDTH = 1.1 |
| OBJECT_COLORS = {"dog": "black", "person": "#3F4C57", "frisbee": "#B0463C"} |
| OBJECT_LINEWIDTH = {"dog": 1.1, "person": 1.1, "frisbee": 1.4} |
|
|
| |
| |
| DISTINCT_COLOR_PALETTE = [ |
| "#3F4C57", |
| "#B0463C", |
| "#4C7A3F", |
| "#8B5FBF", |
| "#C9A227", |
| "#2F8F9D", |
| "#D6708A", |
| "#6B4226", |
| ] |
|
|
|
|
| def resolve_object_colors(object_names): |
| """ |
| Assigns each object in this scene a distinct, stable render color β previously every |
| object EXCEPT the three names hardcoded in OBJECT_COLORS (dog/person/frisbee, leftovers |
| from the original dog3 test sketch) fell back to the SAME shared DEFAULT_COLOR gray. |
| CONFIRMED as a real usability problem: football7 has four objects (goal, soccer player, |
| goalkeeper, ball), none of which are in OBJECT_COLORS, so all four rendered in identical |
| gray β directly raised as "how do I use color to tell objects apart," and the honest |
| answer before this fix was: there wasn't a working general mechanism, only three |
| hardcoded names from an unrelated sketch. |
| |
| Any name explicitly present in OBJECT_COLORS keeps its manually-tuned color unchanged. |
| Every other name gets assigned from DISTINCT_COLOR_PALETTE, in order of the names SORTED |
| alphabetically (not whatever order object_names happens to iterate in) β so a given |
| object name gets the same color every run of the same sketch, not a color that shifts |
| depending on dict/list ordering that run happened to produce. |
| """ |
| colors = dict(OBJECT_COLORS) |
| |
| |
| |
| |
| |
| used_colors = set(colors.values()) |
| available_palette = [c for c in DISTINCT_COLOR_PALETTE if c not in used_colors] |
| remaining = sorted(n for n in object_names if n not in OBJECT_COLORS) |
| for i, name in enumerate(remaining): |
| colors[name] = available_palette[i % len(available_palette)] if available_palette else DEFAULT_COLOR |
| return colors |
|
|
|
|
| |
| |
| |
|
|
| def query_qwen(model, tokenizer, prompt, device, max_new_tokens=500, temperature=0.1): |
| import torch |
| messages = [{"role": "user", "content": prompt}] |
| text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) |
| inputs = tokenizer([text], return_tensors="pt").to(device) |
| input_token_count = inputs["input_ids"].shape[1] |
| with torch.no_grad(): |
| output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens, |
| temperature=temperature, do_sample=True) |
| generated = output_ids[0][inputs["input_ids"].shape[1]:] |
| output_token_count = generated.shape[0] |
| response_text = tokenizer.decode(generated, skip_special_tokens=True) |
| return response_text, input_token_count, output_token_count |
|
|
|
|
| def load_qwen_model(model_path): |
| from transformers import AutoModelForCausalLM, AutoTokenizer |
| import torch |
| print(f"loading model from {model_path} ...") |
| tokenizer = AutoTokenizer.from_pretrained(model_path) |
| model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.float16, device_map="auto") |
| device = next(model.parameters()).device |
| print("model loaded.") |
| return model, tokenizer, device |
|
|
|
|
| def parse_json_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) |
| return json.loads(repair_json_trailing_commas(match.group(0))) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| KNOWN_RIGID_OBJECT_SUBSTRINGS = ( |
| "ball", "frisbee", "puck", "disc", "coin", "rock", "stone", "brick", "box", "crate", |
| "vehicle", "car", "truck", "bike", "bicycle", "wheel", "arrow", "bullet", |
| ) |
|
|
|
|
| def apply_rigid_object_override(parsed, object_names): |
| """ |
| Forces TRAJ_ONLY for any object whose name contains a KNOWN_RIGID_OBJECT_SUBSTRINGS match, |
| overriding whatever the classify VLM said β see the constant's comment for why this exists |
| as a deterministic check rather than more prompt tuning. Mutates `parsed` in place (same |
| dict that gets written to {name}_deformation.json) so the override is visible in the saved |
| artifact too, not just in the in-memory arap_objs list β printed loudly when it actually |
| changes something, silent when it doesn't (i.e. the VLM already agreed, or no object matched). |
| """ |
| for obj_name in object_names: |
| lower = obj_name.lower() |
| if any(substr in lower for substr in KNOWN_RIGID_OBJECT_SUBSTRINGS): |
| current = str(parsed.get(obj_name, "")).strip().upper() |
| if current != "TRAJ_ONLY": |
| print(f" OVERRIDE: '{obj_name}' matched a known-rigid name pattern β forcing " |
| f"TRAJ_ONLY (classify VLM said {current!r})") |
| parsed[obj_name] = "TRAJ_ONLY" |
| return parsed |
|
|
|
|
| def build_deformation_prompt(caption, object_names): |
| objects_str = ", ".join(f'"{o}"' for o in object_names) |
| lines = "\n".join( |
| f' "{o}": "ARAP" or "TRAJ_ONLY"' + ("," if i < len(object_names) - 1 else "") |
| for i, o in enumerate(object_names) |
| ) |
| return f"""Scene: "{caption}" |
| |
| Objects in this scene: {objects_str} |
| |
| For each object, decide whether representing it correctly needs NON-RIGID DEFORMATION (its body/shape changes β e.g. limbs moving, a neck reaching, a body crouching or leaning) or whether simple RIGID TRANSLATION (the object moves/rotates as a whole, unchanged in shape, or doesn't move at all) is enough. |
| |
| Answer "ARAP" if the object's shape or body configuration changes at any point in the action, even if its overall position doesn't change. Answer "TRAJ_ONLY" if the object is rigid (a vehicle, tool, projectile, furniture, background element) or is simply carried by its own movement without changing shape. |
| |
| Respond with ONLY a JSON object, no other text, in this exact format: |
| {{ |
| {lines} |
| }} |
| """ |
|
|
|
|
| def validate_deformation(parsed, object_names): |
| problems = [] |
| for obj in object_names: |
| if obj not in parsed: |
| problems.append(f"'{obj}' missing from response") |
| continue |
| val = str(parsed[obj]).strip().upper() |
| if val not in ("ARAP", "TRAJ_ONLY"): |
| problems.append(f"'{obj}' has invalid value {parsed[obj]!r}, expected ARAP or TRAJ_ONLY") |
| return problems |
|
|
|
|
| def run_classify(model, tokenizer, device, caption, semantic_path, out_path): |
| assignments = load_semantic_assignments(semantic_path) |
| object_names = list(assignments.keys()) |
| print(f"objects found in {semantic_path}: {object_names}") |
|
|
| prompt = build_deformation_prompt(caption, object_names) |
| print("\n--- CLASSIFY PROMPT ---") |
| print(prompt) |
|
|
| response, in_tok, out_tok = query_qwen(model, tokenizer, prompt, device, |
| max_new_tokens=250, temperature=0.1) |
| print(f"\ntokens: {in_tok} in / {out_tok} out") |
| print("--- RAW RESPONSE ---") |
| print(response) |
|
|
| parsed = parse_json_response(response) |
| problems = validate_deformation(parsed, object_names) |
| print("\n--- PARSED ---") |
| print(json.dumps(parsed, indent=2)) |
| if problems: |
| print("--- VALIDATION PROBLEMS ---") |
| for p in problems: |
| print(f" - {p}") |
|
|
| parsed = apply_rigid_object_override(parsed, object_names) |
|
|
| arap_objs = [o for o in object_names if str(parsed.get(o, "")).strip().upper() == "ARAP"] |
| traj_only_objs = [o for o in object_names if o not in arap_objs] |
| print(f"\nARAP: {arap_objs}") |
| print(f"TRAJ_ONLY: {traj_only_objs}") |
|
|
| with open(out_path, "w") as f: |
| json.dump(parsed, f, indent=2) |
| print(f"wrote {out_path}") |
| return parsed, arap_objs |
|
|
|
|
| def render_candidate_image(points, slices, candidates, out_path): |
| """ |
| Renders this object's own strokes with each candidate cluster's centroid marked and |
| numbered β the image actually shown to the VLM for selection-based handle picking (see |
| cluster_object_strokes and run_semantic_handle_selection). Matches the reference paper's |
| approach of drawing candidate handle points directly on the image the VLM sees (their |
| Figure 4: candidates drawn as small dots, the VLM told to select among them) rather than |
| only describing candidates in text β this session has repeatedly found that what's |
| demonstrated/shown matters more than what's merely instructed in prose. |
| """ |
| import matplotlib.pyplot as plt |
| fig, ax = plt.subplots(figsize=(6, 6)) |
| for start, end in slices: |
| seg = points[start:end] |
| ax.plot(seg[:, 0], seg[:, 1], color="black", linewidth=1.2) |
| for c in candidates: |
| cx, cy = c["centroid"] |
| ax.plot(cx, cy, "o", markersize=16, color="yellow", markeredgecolor="black", markeredgewidth=2) |
| ax.annotate(str(c["cluster_id"]), (cx, cy), fontsize=11, weight="bold", |
| ha="center", va="center") |
| ax.invert_yaxis() |
| ax.set_aspect("equal") |
| ax.set_title("candidate regions (numbered)") |
| fig.savefig(out_path, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| return out_path |
|
|
|
|
| def run_semantic_handle_selection(model, processor, rest_pose_image, obj_name, |
| caption, unique_points, points, slices, bbox_size, |
| candidate_image_dir, max_handles=4): |
| """ |
| VLM-guided handle selection replacing KMeans. Given the rest pose image and caption, |
| asks the VLM to identify WHICH named body/object parts need to move to perform the |
| captioned action. |
| |
| REVISED β selection-based, not free-coordinate. Previously the VLM was asked to freely |
| estimate an (x,y) for each named part, which then got snapped to the nearest real mesh |
| vertex regardless of how far the guess actually was from anything real. CONFIRMED on real |
| hardware (eat2, multiple runs) that this let the model fabricate parts that don't exist β |
| a second hand that was never drawn, a "chin" that landed in the hair β because nothing |
| could tell the difference between a well-grounded guess and a hallucinated one; both look |
| identical to a free (x,y) pair, and nearest-vertex snapping always succeeds regardless. |
| |
| Adapted from the reference paper's principle (candidates come from geometry FIRST, the VLM |
| only ever selects among them, never generates a raw coordinate) β their exact mechanism |
| (3D mesh sub-part segmentation via graph-cut, then cone-singularity detection within that |
| segmented sub-part) doesn't port directly, since we only have flat object-level stroke |
| grouping (person vs spoon), not part-level sub-segmentation (hand vs face within person). |
| Adapted instead as: cluster_object_strokes groups THIS object's own strokes by spatial |
| proximity (DBSCAN, no VLM) into candidate regions BEFORE the VLM is ever called. CONFIRMED |
| on real eat2 geometry: the hand+spoon tangle (34 strokes) forms exactly one clean, spatially |
| isolated cluster, fully separate from the face+hair mass β meaning "claim there are two |
| hands" becomes structurally impossible once the VLM can only point at real candidate |
| clusters, since there is only one hand-region cluster to point at. |
| |
| Returns: (joints, anchor_idx, handle_idxs, joint_mesh_indices, part_names) β same shape as |
| before; every joint's coordinate is now a cluster centroid computed by cluster_object_strokes, |
| never a model-supplied number. |
| |
| Anchor selection: unchanged from the prior revision β the VLM tags exactly one selected |
| candidate role="fixed"; falls back to geometric closest-to-centroid only if it doesn't |
| comply (0 or 2+ fixed tags), same documented risk as before. |
| |
| Falls back to KMeans (returns None) if: clustering finds zero candidate regions (nothing |
| spatially coherent to select from), or the VLM's response doesn't select anything valid. |
| """ |
| import torch, json as _json, re as _re |
|
|
| candidates = cluster_object_strokes(points, slices, bbox_size) |
| if not candidates: |
| print(f" WARNING: no candidate regions found for '{obj_name}' (clustering found " |
| f"nothing spatially coherent) β falling back to KMeans") |
| return None |
|
|
| candidate_image_path = os.path.join(candidate_image_dir, f"{obj_name}_candidates.png") |
| render_candidate_image(points, slices, candidates, candidate_image_path) |
| candidate_image = Image.open(candidate_image_path).convert("RGB") |
|
|
| candidate_list_text = "\n".join( |
| f' - Candidate {c["cluster_id"]}: a region of {c["n_strokes"]} strokes, ' |
| f'roughly centered around ({c["centroid"][0]:.0f}, {c["centroid"][1]:.0f})' |
| for c in candidates |
| ) |
|
|
| prompt = f"""You are analyzing a sketch of "{obj_name}" to determine which parts need to move to perform this action: "{caption}". |
| |
| The image shows this object's strokes with {len(candidates)} candidate regions marked as numbered yellow dots β these are the ONLY real, spatially distinct regions detected in this sketch. You may ONLY select among these candidates; you cannot invent a new region or a new coordinate that isn't one of these dots. If something you'd expect (e.g. a second hand) does NOT have its own candidate dot, it was not detected as a spatially distinct region in this sketch β do not invent one anyway. |
| |
| Candidates in this sketch: |
| {candidate_list_text} |
| |
| For each candidate you select, you need TWO kinds of roles: |
| 1. MOVING: this region needs to deform/move to perform the action described. |
| 2. FIXED: exactly ONE region should be tagged fixed β a stable reference point (e.g. torso, body center, base) that stays still while moving parts move. ARAP needs this as an anchor. Pick whichever candidate makes the most sense as a stable base. |
| |
| For each candidate you select: |
| - Give it a SHORT semantic name matching what that region actually looks like in the image and what THIS caption's action needs β do not name it something not supported by what's actually drawn there. |
| - Reference it by its candidate number (candidate_id) β do NOT provide x/y coordinates, they are not needed; the coordinate is already known from the candidate's own detected position. |
| - Tag its role as "moving" or "fixed" |
| |
| Rules: |
| - Only select candidates DIRECTLY relevant to the captioned action β you don't need to name every candidate shown |
| - Exactly ONE selected candidate must have role "fixed" |
| - Maximum {max_handles} candidates selected total |
| - Never select the same candidate_id twice |
| - Never mark a candidate you also expect to move as "fixed" |
| |
| Respond ONLY with a JSON object, no other text, in this exact format: |
| {{ |
| "parts": [ |
| {{"name": "<short name>", "candidate_id": <int from the list above>, "role": "moving"}}, |
| {{"name": "<short name>", "candidate_id": <int from the list above>, "role": "fixed"}} |
| ], |
| "reasoning": "one sentence explaining which candidates you selected and why, and which one is the fixed reference" |
| }}""" |
|
|
| content = [{"type": "image", "image": rest_pose_image}, |
| {"type": "image", "image": candidate_image}, |
| {"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=[rest_pose_image, candidate_image], return_tensors="pt").to(model.device) |
| with torch.no_grad(): |
| output_ids = model.generate(**inputs, max_new_tokens=300, temperature=0.2, do_sample=True) |
| generated = output_ids[:, inputs["input_ids"].shape[1]:] |
| response = processor.batch_decode(generated, skip_special_tokens=True)[0] |
| print(f" VLM semantic handle response for '{obj_name}': {response[:300]}") |
|
|
| |
| parts = [] |
| try: |
| match = _re.search(r"\{.*\}", response, _re.DOTALL) |
| if match: |
| parsed = _json.loads(match.group(0)) |
| parts = parsed.get("parts", []) |
| reasoning = parsed.get("reasoning", "") |
| print(f" reasoning: {reasoning}") |
| except Exception as e: |
| print(f" WARNING: failed to parse VLM semantic handle response: {e}") |
|
|
| if not parts: |
| print(f" WARNING: VLM returned no parts for '{obj_name}' β falling back to KMeans") |
| return None |
|
|
| candidates_by_id = {c["cluster_id"]: c for c in candidates} |
|
|
| |
| |
| |
| def _nearest_unclaimed_vertex(target, claimed): |
| """Same as nearest_mesh_vertex but skips indices already in `claimed`, returning the |
| next-nearest AVAILABLE vertex instead. Used as collision recovery below β see why this |
| matters there. Returns (idx, distance) or (None, None) if literally every vertex in the |
| mesh is already claimed (only possible when there are fewer mesh points than requested |
| parts β essentially never for a real sketch with normal stroke density).""" |
| dists = np.linalg.norm(unique_points - np.array(target), axis=1) |
| for idx in np.argsort(dists): |
| if int(idx) not in claimed: |
| return int(idx), float(dists[idx]) |
| return None, None |
|
|
| seen_vertices = {} |
| |
| |
| seen_candidate_ids = set() |
| joints_list, mesh_indices_list, part_names_list, roles_list = [], [], [], [] |
| for part in parts[:max_handles]: |
| candidate_id = part.get("candidate_id") |
| if not isinstance(candidate_id, int) or candidate_id not in candidates_by_id: |
| print(f" NOTE: '{part.get('name')}' referenced candidate_id={candidate_id!r}, which " |
| f"is not one of the real detected candidates {sorted(candidates_by_id)} β " |
| f"skipping this part rather than trusting an invalid/hallucinated reference") |
| continue |
| if candidate_id in seen_candidate_ids: |
| print(f" NOTE: candidate_id={candidate_id} was already selected by a previous part " |
| f"in this same response β skipping this duplicate selection") |
| continue |
| seen_candidate_ids.add(candidate_id) |
| coord = candidates_by_id[candidate_id]["centroid"] |
| part_name = part.get("name", f"part_{len(joints_list)}") |
| mv = nearest_mesh_vertex(unique_points, coord) |
| if mv in seen_vertices: |
| claimant_name = seen_vertices[mv] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| recovered_mv, recovered_dist = _nearest_unclaimed_vertex(coord, seen_vertices) |
| if recovered_mv is None: |
| print(f" NOTE: '{part_name}' collided with '{claimant_name}' (already claimed " |
| f"that vertex), and no unclaimed vertex remains anywhere in the mesh β " |
| f"dropping (this mesh has fewer usable points than parts were requested, " |
| f"an unusual case)") |
| continue |
| print(f" NOTE: '{part_name}' collided with '{claimant_name}' (already claimed that " |
| f"vertex) β recovered using the next-nearest UNCLAIMED vertex instead " |
| f"({recovered_dist:.1f}px from the requested coordinate) rather than dropping " |
| f"'{part_name}' entirely") |
| mv = recovered_mv |
| seen_vertices[mv] = part_name |
| joints_list.append(coord) |
| mesh_indices_list.append(mv) |
| part_names_list.append(part_name) |
| role = str(part.get("role", "")).strip().lower() |
| roles_list.append(role if role in ("moving", "fixed") else "") |
|
|
| if not joints_list: |
| print(f" WARNING: no valid parts after vertex dedup for '{obj_name}' β falling back to KMeans") |
| return None |
|
|
| joints = np.array(joints_list) |
|
|
| |
| fixed_indices = [i for i, r in enumerate(roles_list) if r == "fixed"] |
| if len(fixed_indices) == 1: |
| anchor_idx = fixed_indices[0] |
| anchor_source = "semantic (VLM-tagged fixed part)" |
| else: |
| |
| |
| |
| |
| centroid = joints.mean(axis=0) |
| dists = np.linalg.norm(joints - centroid, axis=1) |
| anchor_idx = int(dists.argmin()) |
| anchor_source = f"GEOMETRIC FALLBACK (VLM tagged {len(fixed_indices)} parts as fixed, expected exactly 1 β " \ |
| f"this is the old closest-to-centroid rule and can pick a part that is meant to move)" |
| print(f" WARNING: '{obj_name}' anchor selection used {anchor_source}") |
|
|
| handle_idxs = [i for i in range(len(joints)) if i != anchor_idx] |
| part_names = {i: part_names_list[i] for i in range(len(joints))} |
|
|
| print(f" semantic handles for '{obj_name}' [anchor via: {anchor_source}]: " |
| + ", ".join(f"joint_{i}={part_names_list[i]}({'ANCHOR' if i==anchor_idx else 'handle'})" |
| for i in range(len(joints)))) |
| return joints, anchor_idx, handle_idxs, mesh_indices_list, part_names |
|
|
|
|
| def build_objects_info(svg_path, semantic_path, arap_objects, caption=None, |
| vlm_model=None, vlm_processor=None, rest_pose_image=None, |
| candidate_image_dir=None): |
| """ |
| Builds mesh/joint info for all ARAP objects. When vlm_model, vlm_processor, |
| rest_pose_image, and caption are all provided, uses VLM-guided SEMANTIC handle |
| selection (Option B) instead of KMeans. Falls back to KMeans automatically if |
| VLM selection fails or returns no parts for a given object. |
| |
| The key addition over the KMeans version: objects_info now carries a `part_names` |
| dict ({joint_i: "hand"/"mouth"/etc}) for each object when semantic selection |
| succeeds β this propagates into the joint legend shown to the generator and judge, |
| so the VLM reasons about named parts ("move the hand joint") rather than bare |
| indices ("move joint_2"). |
| |
| candidate_image_dir: where run_semantic_handle_selection saves its candidate-region |
| image ({obj_name}_candidates.png) β required (not None) whenever VLM selection will |
| actually run; defaults to "." only so this function doesn't hard-error when called |
| without VLM args at all (the KMeans-only path never touches this). |
| """ |
| objects_info = {} |
| for obj_name in arap_objects: |
| points, slices = load_object(obj_name, svg_path, semantic_path) |
| bbox_size = object_bbox_size(points) |
| unique_points, p2u = deduplicate_points(points, tol=0.35) |
| tri, edges = build_mesh(unique_points) |
| strokes = filter_strokes(load_strokes_from_svg(svg_path), |
| load_semantic_assignments(semantic_path)[obj_name]) |
|
|
| part_names = {} |
| semantic_result = None |
|
|
| |
| if vlm_model is not None and vlm_processor is not None and \ |
| rest_pose_image is not None and caption: |
| print(f" Attempting VLM semantic handle selection for '{obj_name}'...") |
| semantic_result = run_semantic_handle_selection( |
| vlm_model, vlm_processor, rest_pose_image, obj_name, |
| caption, unique_points, points, slices, bbox_size, |
| candidate_image_dir=candidate_image_dir or ".", max_handles=4) |
|
|
| if semantic_result is not None: |
| joints, anchor_idx, handle_idxs, joint_mesh_indices, part_names = semantic_result |
| else: |
| |
| if vlm_model is not None: |
| print(f" Falling back to KMeans for '{obj_name}'") |
| joints, anchor_idx, handle_idxs, joint_mesh_indices = auto_select_handles_deduped( |
| strokes, unique_points, k=4) |
|
|
| if len(handle_idxs) == 0: |
| print(f"WARNING: '{obj_name}' has no independent handles after dedup, skipping") |
| continue |
|
|
| print(f"'{obj_name}': {len(joints)} joints, anchor={anchor_idx}, handles={handle_idxs}, " |
| f"mesh_indices={joint_mesh_indices}, bbox_size={bbox_size:.1f}" |
| + (f", parts={part_names}" if part_names else "")) |
|
|
| objects_info[obj_name] = { |
| "joints": joints, "anchor_idx": anchor_idx, "handle_idxs": handle_idxs, |
| "joint_mesh_indices": joint_mesh_indices, "bbox_size": bbox_size, "strokes": strokes, |
| "points": points, "slices": slices, "unique_points": unique_points, |
| "p2u": p2u, "edges": edges, |
| "part_names": part_names, |
| } |
| return objects_info |
|
|
|
|
| def build_deformed_stroke_geometry_text(object_info, kf_targets, n_points=2): |
| """ |
| Reconstructs what a SPECIFIC keyframe's ACTUAL DEFORMED shape looks |
| like, as compact text β runs the SAME ARAP solve used for real |
| rendering, so a stroke's coordinates here are that keyframe's true |
| rendered position, not its rest-pose position. This is what makes |
| the judge's target_coords anchoring correct PER KEYFRAME instead of |
| only correct when a keyframe happens to match rest β e.g. a "mouth" |
| stroke's rest coordinate is wrong context if the head itself has |
| moved by kf3, this reconstructs where that stroke actually is at kf3. |
| |
| kf_targets: {joint_i_str: [x, y]} for ONE keyframe, in the same |
| "joint_i" key format apply_deform_clip_and_write saves (i.e. one |
| entry of deform_outputs[obj_name][kf_key]). |
| |
| COST, not glossed over: re-runs arap_deform (10 iterations) once per |
| object per keyframe purely to build this text β measured ~2,866 |
| tokens for a single 64-stroke object across all 5 keyframes, and that |
| multiplies per object in a multi-object scene. This is real compute |
| and real prompt-length cost on top of everything else already in the |
| judge prompt (images, rest geometry, joint legend, targets, bbox). |
| """ |
| import re as _re |
| unique_points = object_info["unique_points"] |
| edges = object_info["edges"] |
| p2u = object_info["p2u"] |
| slices = object_info["slices"] |
| anchor_idx = object_info["anchor_idx"] |
| joints = object_info["joints"] |
| joint_mesh_indices = object_info["joint_mesh_indices"] |
|
|
| handle_mesh_indices, handle_targets = [], [] |
| for key, target in kf_targets.items(): |
| m = _re.match(r"joint_(\d+)", key) |
| if not m: |
| continue |
| joint_i = int(m.group(1)) |
| if joint_i >= len(joint_mesh_indices): |
| continue |
| handle_mesh_indices.append(joint_mesh_indices[joint_i]) |
| handle_targets.append(target) |
| |
| handle_mesh_indices.append(joint_mesh_indices[anchor_idx]) |
| handle_targets.append(joints[anchor_idx].tolist()) |
|
|
| if not handle_mesh_indices: |
| return None |
|
|
| deformed_unique = arap_deform(unique_points, edges, handle_mesh_indices, |
| np.array(handle_targets), iterations=10) |
| deformed_points = deformed_unique[p2u] |
|
|
| lines = [] |
| for i, (start, end) in enumerate(slices): |
| stroke_pts = deformed_points[start:end] |
| idxs = np.linspace(0, len(stroke_pts) - 1, n_points).astype(int) |
| pts = stroke_pts[idxs] |
| pts_str = " -> ".join(f"({x:.0f},{y:.0f})" for x, y in pts) |
| lines.append(f" stroke_{i}: {pts_str}") |
| return "\n".join(lines) |
|
|
|
|
| def build_all_keyframes_deformed_geometry_text(objects_info, deform_outputs, n_keyframes=N_KEYFRAMES): |
| """ |
| For EVERY object and EVERY keyframe of the CURRENT attempt, reconstruct |
| the actual deformed stroke geometry as text β this is the anchor source |
| for the judge's target_coords grounding: the judge is instructed to |
| find a visually-identified stroke's coordinate HERE, per keyframe, not |
| in the rest-pose geometry (which is only correct when that keyframe |
| happens to match rest). |
| """ |
| sections = [] |
| for obj_name, info in objects_info.items(): |
| obj_targets = (deform_outputs or {}).get(obj_name) |
| if not obj_targets: |
| continue |
| kf_parts = [] |
| for kf in range(n_keyframes): |
| kf_key = f"kf{kf}" |
| if kf_key not in obj_targets: |
| continue |
| shape_text = build_deformed_stroke_geometry_text(info, obj_targets[kf_key], n_points=2) |
| if shape_text: |
| kf_parts.append(f" -- {kf_key} --\n{shape_text}") |
| if kf_parts: |
| sections.append(f'Object "{obj_name}":\n' + "\n".join(kf_parts)) |
| return "\n\n".join(sections) |
|
|
|
|
| def render_rest_pose_multi(object_names, svg_path, semantic_path, out_path): |
| """ |
| Renders multiple objects' ORIGINAL strokes together, at their real |
| positions in the source SVG (no deformation, no trajectory translation) |
| β this is the "what does the sketch actually look like" image every |
| attempt is grounded in, so Qwen can see what's actually drawable |
| (e.g. whether the dog's back legs even exist as strokes) instead of |
| only reasoning from the caption's text description. |
| """ |
| import matplotlib.pyplot as plt |
| fig, ax = plt.subplots(figsize=(6, 6)) |
| for name in object_names: |
| points, slices = load_object(name, svg_path, semantic_path) |
| for start, end in slices: |
| seg = points[start:end] |
| ax.plot(seg[:, 0], seg[:, 1], color="black", linewidth=1.2) |
| ax.invert_yaxis() |
| ax.set_aspect("equal") |
| ax.set_title("rest pose") |
| fig.savefig(out_path, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| return out_path |
|
|
|
|
| def format_joint_feedback_for_object(object_name, joint_feedback): |
| """ |
| Filters the judge's structured joint_feedback list down to entries for |
| THIS object and formats them as explicit, actionable lines. When the |
| judge grounded its suggestion in an actual traced coordinate, |
| delta_px (computed by vlm_judge.compute_joint_feedback_deltas via |
| subtraction, NOT model math) is shown as an exact number the |
| generator can apply directly β e.g. |
| "joint_1 at kf3: hand not near mouth -> move by (dx=+12.0, dy=-8.0) |
| px (target anchored to: mouth stroke in face object)" |
| If the judge couldn't ground a target (target_coords was null, or the |
| joint/keyframe didn't match anything in deform_outputs), delta_px is |
| None and the line falls back to the prose issue only β explicitly |
| labeled as ungrounded rather than silently presenting a guess as if |
| it were a computed number. |
| Returns "" if there's no feedback for this object (nothing to add). |
| """ |
| if not joint_feedback: |
| return "" |
| relevant = [f for f in joint_feedback if f.get("object") == object_name] |
| if not relevant: |
| return "" |
| lines = [] |
| for f in relevant: |
| kf = f.get("keyframe") |
| kf_str = f"kf{kf}" if kf is not None else "unspecified keyframe" |
| issue = f.get("issue", "") |
| delta = f.get("delta_px") |
| if delta is not None: |
| anchor = f.get("anchored_to", "unspecified") |
| lines.append( |
| f" - joint_{f.get('joint')} at {kf_str}: {issue} " |
| f"-> move by (dx={delta['dx']:+.1f}, dy={delta['dy']:+.1f}) px " |
| f"(target anchored to: {anchor})" |
| ) |
| else: |
| lines.append( |
| f" - joint_{f.get('joint')} at {kf_str}: {issue} " |
| f"-> [UNGROUNDED β no traceable/disambiguated coordinate was given; use your own judgment]" |
| ) |
| return "\n".join(lines) |
|
|
|
|
| def build_object_history_block(object_name, attempt_history, joint_feedback=None, baseline_targets=None): |
| """ |
| Formats THIS object's history across past attempts as text β one entry per past attempt showing |
| its narrative, SCORES (faithfulness/plausibility/quality), the PER-JOINT corrections the judge |
| gave THAT attempt (numeric target_coords/delta_px where grounded, same format as the current |
| attempt's joint_feedback_block), and the flat feedback string, so the generator sees its own |
| full numeric+prose trajectory across multiple attempts β not just the single most recent one |
| (which baseline_targets/previous_narrative/the current joint_feedback_block already cover in |
| full for the LATEST attempt specifically). |
| |
| The MOST RECENT past attempt's joint_feedback is deliberately EXCLUDED from this history text β |
| that attempt's corrections are already shown in full via the current call's joint_feedback_block |
| (built from the SAME `joint_feedback` param passed in here, which is the most recent attempt's |
| computed deltas) β repeating them here would just duplicate that block. History shows joint |
| corrections for OLDER attempts (attempt 1, 2, ... up to but not including the most recent one), |
| which otherwise had no numeric trace anywhere in the prompt at all. |
| |
| Does NOT repeat baseline numeric targets (deform_outputs) here β baseline_targets already gives |
| the generator the MOST RECENT attempt's exact numbers as the thing to refine; restating older |
| attempts' raw target numbers too would give multiple different numeric anchors for the same |
| joint with no clear precedence, the same redundancy problem already solved for baseline_targets |
| vs joint_feedback for the current attempt. |
| |
| Deliberately scoped to ONE object's own entries β keeps history from re-injecting the confirmed |
| cross-object contamination bug (a critique about a DIFFERENT object showing up here) that was |
| fixed elsewhere in this file. |
| """ |
| if not attempt_history: |
| return "" |
| |
| |
| |
| most_recent_attempt_number = attempt_history[-1]["attempt"] if attempt_history else None |
| parts = [] |
| for entry in attempt_history: |
| obj_narrative = (entry.get("narratives") or {}).get(object_name) |
| obj_targets = (entry.get("deform_outputs") or {}).get(object_name) |
| |
| |
| |
| if not obj_targets and not obj_narrative: |
| continue |
| lines = [f' --- Attempt {entry["attempt"]} ---'] |
| if obj_narrative: |
| lines.append(f" Narrative was: {obj_narrative}") |
| scores = entry.get("scores") |
| if scores: |
| faith, plaus, qual = scores |
| lines.append(f" Scores: faithfulness={faith}, plausibility={plaus}, quality={qual}") |
| if entry["attempt"] != most_recent_attempt_number: |
| entry_joint_feedback_text = format_joint_feedback_for_object(object_name, entry.get("joint_feedback")) |
| if entry_joint_feedback_text: |
| lines.append(f" Per-joint corrections given for that attempt:\n{entry_joint_feedback_text}") |
| if entry.get("feedback"): |
| lines.append(f" Feedback received: {entry['feedback']}") |
| parts.append("\n".join(lines)) |
| if not parts: |
| return "" |
| return ( |
| "\n History for this object across EARLIER attempts (see attached images for what each one " |
| "actually rendered; numeric targets are NOT repeated here β the most recent attempt's exact " |
| "numbers are given separately below as your actual starting point):\n" |
| + "\n".join(parts) + "\n" |
| ) |
|
|
|
|
| def build_own_trajectory_delta_text(object_name, real_trajectories, n_keyframes=N_KEYFRAMES): |
| """ |
| Tells the generator how much THIS object's whole rendered position will shift due to |
| trajectory at each keyframe β code-computed, not something the model has to derive or |
| guess. Exists because build_combined_object_section's joint legend always shows REST |
| positions regardless of keyframe, with zero indication that the WHOLE object also slides |
| across the screen via trajectory independent of anything ARAP does β CONFIRMED gap: this |
| generator prompt never referenced trajectory data anywhere before this. |
| |
| Deliberately does NOT attempt to tell the model where OTHER objects will be at each |
| keyframe. That would require knowing each OTHER object's raw SVG rest position (not just |
| its trajectory delta) to compute an actual absolute screen position, which isn't loaded |
| anywhere at prompt-build time β and CONFIRMED risky to assume otherwise: an earlier version |
| of this pipeline used trajectory's own absolute coordinates directly as ground truth for |
| object placement, and it regressed a previously-correct render (eat2's spoon shifted out of |
| the hand) because trajectory's absolute values don't reliably correspond to actual rendered |
| position once you account for wherever the SVG artist drew things. Only THIS object's own |
| DELTA (frame-0-relative motion) is used here, which has no such dependency β deltas are |
| well-defined and safe regardless of raw SVG positions. Cross-object position awareness |
| would need a separate, larger change (loading every object's rest geometry at prompt-build |
| time, not just render time) β not attempted here. |
| """ |
| if object_name not in real_trajectories: |
| return "" |
| dx_vals, dy_vals = bbox_deltas(real_trajectories[object_name]) |
| lines = [] |
| for kf in range(1, n_keyframes): |
| dx, dy = dx_vals[kf], dy_vals[kf] |
| if abs(dx) > 0.5 or abs(dy) > 0.5: |
| lines.append(f" kf{kf}: this object's WHOLE position additionally shifts by " |
| f"({dx:+.1f}, {dy:+.1f})px due to trajectory (separate from, and added " |
| f"on top of, any joint target you give below)") |
| if not lines: |
| return "" |
| return ( |
| f'\nTrajectory note for "{object_name}" β this is EXTRA context, not something to ' |
| f"react to by picking bigger/smaller joint targets: your joint targets below are still " |
| f"given relative to the REST legend above, exactly as normal. This just tells you that, " |
| f"independent of whatever target you pick, the object's rendered position ALSO moves by " |
| f"this amount at each keyframe β useful mainly so you don't mistake trajectory-driven " |
| f"whole-object movement for something your joint targets need to account for or " |
| f"compensate for:\n" + "\n".join(lines) + "\n" |
| ) |
|
|
|
|
| def build_combined_object_section(object_name, joints, anchor_idx, handle_idxs, bbox_size, |
| previous_narrative=None, feedback=None, n_keyframes=N_KEYFRAMES, |
| freeze_narrative=False, strokes=None, joint_feedback=None, |
| baseline_targets=None, history_block="", part_names=None, |
| real_trajectories=None): |
| """ |
| freeze_narrative: if True, previous_narrative is used as a FIXED target |
| pose description (the object's narrative already passed faithfulness β |
| only the numeric deformation needs to improve, not the story). If |
| False (default), the narrative is regenerated fresh, informed by the |
| attached image(s) and feedback, same as before. |
| |
| strokes: if given, the object's actual stroke points are included as |
| TEXT (not just the rendered image) β added specifically because |
| multimodal LLMs can under-attend to image content relative to text; |
| this gives the same geometric information in a text-native form the |
| model is more likely to actually use. Kept deliberately sparse (2 |
| points per stroke, start+end only) since a complex object can have |
| 60+ strokes β measured on real dog3 data: 2 points/stroke costs |
| ~570 tokens for a 64-stroke object vs ~2650 for the full 12 |
| points/stroke used internally for the ARAP mesh. |
| |
| joint_feedback: the FULL joint_feedback list from the judge's verdict |
| (all objects) β filtered down to this object's entries and formatted |
| as precise per-joint lines. Falls back to nothing (not an error) if |
| the judge didn't return joint_feedback (e.g. coordinate context wasn't |
| given to build_judge_prompt) β the object still gets the old flat |
| `feedback` string via previous_block/feedback_line below. |
| |
| baseline_targets: {kf_key: {joint_i_str: [x,y]}} β this object's EXACT |
| numeric targets from the PREVIOUS attempt, given on EVERY retry (not |
| just after a pass) so the generator refines real numbers instead of |
| reconstructing them from images/prose each time β the "guess |
| coordinates from a picture" problem numeric joint_feedback exists to |
| avoid elsewhere. Joints with a GROUNDED joint_feedback correction are |
| excluded here (that correction takes precedence) β this only shows |
| joints without a specific correction, as a "keep unless you have |
| reason to change" anchor, not a fixed target β unlike `pose_lines` |
| used when freeze_narrative=True, which locks the story, not the |
| coordinates. |
| """ |
| cap = round(bbox_size * HANDLE_MOVE_CAP_FRACTION, 1) |
| trajectory_note = build_own_trajectory_delta_text(object_name, real_trajectories or {}, n_keyframes) |
| joint_feedback_text = format_joint_feedback_for_object(object_name, joint_feedback) |
| joint_feedback_block = ( |
| f"\n Specific per-joint corrections from the judge (apply these precisely, this is not general " |
| f"guidance):\n{joint_feedback_text}\n" |
| ) if joint_feedback_text else "" |
| baseline_block = "" |
| if baseline_targets: |
| |
| |
| |
| |
| |
| |
| corrected_joints = { |
| f.get("joint") for f in (joint_feedback or []) |
| if f.get("object") == object_name and f.get("delta_px") is not None |
| } |
| kf_parts = [] |
| for kf, kf_vals in baseline_targets.items(): |
| shown = {j: v for j, v in kf_vals.items() |
| if int(j.replace("joint_", "")) not in corrected_joints} |
| if shown: |
| kf_parts.append(f"{kf}: {{{', '.join(f'{j}={v}' for j, v in shown.items())}}}") |
| if kf_parts: |
| baseline_block = ( |
| f"\n These are the EXACT numeric targets from the PREVIOUS attempt for joints the judge did " |
| f"NOT give a specific correction for above β a real, working starting point, not a guess: " |
| f"{', '.join(kf_parts)}\n" |
| f" Keep these numbers unless you have a genuine reason to change them β do not discard them " |
| f"and reinvent from scratch. For any joint listed in the corrections above instead, follow " |
| f"that correction, not these numbers (that joint is intentionally omitted here).\n" |
| ) |
| joint_lines = "\n".join( |
| f' - joint_{i}' |
| + (f' ({(part_names or {}).get(i)})' if (part_names or {}).get(i) else "") |
| + f': rest position (x={joints[i][0]:.1f}, y={joints[i][1]:.1f})' |
| + (" <-- ANCHOR, must stay at or near this position in EVERY keyframe" if i == anchor_idx else "") |
| for i in range(len(joints)) |
| ) |
| handle_list_str = ', joint_'.join(str(i) for i in handle_idxs) |
|
|
| geometry_block = "" |
| if strokes: |
| geometry_text = build_stroke_geometry_text(strokes, n_points=2) |
| geometry_block = ( |
| f"\n This object's ACTUAL drawn strokes (start -> end point of each stroke, same coordinate " |
| f"space as the joints above) β use this to know exactly what is and isn't actually drawn, don't " |
| f"invent motion for parts that have no strokes here:\n{geometry_text}\n" |
| ) |
|
|
| if freeze_narrative and previous_narrative: |
| pose_lines = "\n".join(f" kf{i}: {desc}" for i, desc in enumerate(previous_narrative)) |
| feedback_line = f'\n This pose story already matches the intended action β it is FIXED, do not change it. ' \ |
| f'Only the numeric target positions need to improve.' \ |
| + (f' Previous attempt was judged: "{feedback}"' if feedback else "") + \ |
| "\n The attached images show exactly what the previous attempt's target positions " \ |
| "actually looked like when rendered β use them to see specifically what needs to change numerically." |
| return f"""Object: "{object_name}" |
| Joints: |
| {joint_lines} |
| {trajectory_note}{geometry_block} |
| Target pose across all {n_keyframes} keyframes (FIXED, already correct β do not rewrite): |
| {pose_lines} |
| {feedback_line} |
| {joint_feedback_block} |
| {baseline_block} |
| {history_block} |
| For non-anchor joints (joint_{handle_list_str}), do not move more than {cap} pixels from REST in any keyframe. IMPORTANT: these {n_keyframes} keyframes are SPARSE anchor points spanning the ENTIRE action, NOT consecutive video frames β a large, dramatic difference between consecutive keyframes is NORMAL and EXPECTED, not an error; the actual in-between motion will be generated separately later by a different model. Positions should progress in a DIRECTIONALLY COHERENT way (don't make real progress toward the action and then have a LATER keyframe randomly revert backward without the narrative describing a reason to β e.g. only "landing"/"settling" should move back toward rest). Small, timid, barely-different positions between keyframes are themselves a mistake, not a safe choice.""" |
|
|
| previous_block = "" |
| if previous_narrative or feedback: |
| parts = [] |
| if previous_narrative: |
| parts.append(f"Your previous narrative attempt was:\n{json.dumps(previous_narrative, indent=2)}") |
| if feedback: |
| parts.append(f'That attempt was judged and received this critique: "{feedback}"') |
| parts.append("The attached images show exactly what that previous attempt actually looked like when " |
| "rendered. Look at them, understand what specifically was wrong, and revise BOTH the " |
| "narrative and the target positions to fix it β don't just reword the narrative " |
| "superficially while leaving the same underlying problem.") |
| previous_block = "\n " + "\n ".join(parts) + "\n" |
|
|
| return f"""Object: "{object_name}" |
| Joints: |
| {joint_lines} |
| {trajectory_note}{geometry_block} |
| {previous_block} |
| {joint_feedback_block} |
| {baseline_block} |
| {history_block} |
| For non-anchor joints (joint_{handle_list_str}), do not move more than {cap} pixels from REST in any keyframe. IMPORTANT: these {n_keyframes} keyframes are SPARSE anchor points spanning the ENTIRE action, NOT consecutive video frames β a large, dramatic difference between consecutive keyframes is NORMAL and EXPECTED, not an error; the actual in-between motion will be generated separately later by a different model. Positions should progress in a DIRECTIONALLY COHERENT way (don't make real progress toward the action and then have a LATER keyframe randomly revert backward without the narrative describing a reason to β e.g. only "landing"/"settling" should move back toward rest). Small, timid, barely-different positions between keyframes are themselves a mistake, not a safe choice.""" |
|
|
|
|
| FEWSHOT_EXAMPLES = [ |
| { |
| "caption": "The person throws a frisbee through the air, and the dog sits poised, then leaps to catch it.", |
| "object_name": "dog", |
| "data": { |
| |
| |
| "narrative": [ |
| "sitting alert, watching frisbee", |
| "rising, reaching toward frisbee", |
| "mid-leap, reaching further", |
| "peak of jump, maximum reach", |
| "landing, compacting down", |
| ], |
| |
| |
| |
| |
| "reasoning": [ |
| "head: stay; tail: stay; neck: stay", |
| "head: up-left; tail: up-right; neck: up-left", |
| "head: up-left; tail: up-right; neck: up-left", |
| "head: stay; tail: up-right; neck: up-left", |
| "head: down-right; tail: down-left; neck: down-right", |
| ], |
| |
| |
| |
| |
| |
| |
| |
| |
| "targets": { |
| "kf1": {"joint_1": [168.0, 127.0], "joint_2": [232.0, 191.0], "joint_3": [184.0, 144.0]}, |
| "kf2": {"joint_1": [160.0, 120.0], "joint_2": [237.0, 186.0], "joint_3": [177.0, 137.0]}, |
| "kf3": {"joint_1": [159.0, 119.0], "joint_2": [240.0, 183.0], "joint_3": [174.0, 134.0]}, |
| "kf4": {"joint_1": [168.0, 128.0], "joint_2": [231.0, 192.0], "joint_3": [185.0, 146.0]}, |
| }, |
| }, |
| }, |
| { |
| "caption": "A cat crouches low watching a toy mouse, then pounces forward and pins it with a paw.", |
| "object_name": "cat", |
| "data": { |
| |
| |
| |
| |
| "narrative": [ |
| "crouching low, watching target", |
| "coiling back, weight shifting for spring", |
| "leaping forward, paw extended", |
| "airborne, fully extended toward target", |
| "landing on target, paw planted", |
| ], |
| "reasoning": [ |
| "paw: stay; tail: stay", |
| "paw: down-left; tail: down-left", |
| "paw: up-right; tail: up-left", |
| "paw: up-right; tail: up-left", |
| "paw: down-left; tail: down-right", |
| ], |
| |
| |
| |
| |
| "targets": { |
| "kf1": {"joint_1": [51.0, 182.0], "joint_2": [27.0, 168.0]}, |
| "kf2": {"joint_1": [68.0, 172.0], "joint_2": [22.0, 158.0]}, |
| "kf3": {"joint_1": [78.0, 166.0], "joint_2": [17.0, 153.0]}, |
| "kf4": {"joint_1": [74.0, 169.0], "joint_2": [21.0, 157.0]}, |
| }, |
| }, |
| }, |
| { |
| "caption": "A bird perched on a branch flaps its wings and glides down to land on a lower branch.", |
| "object_name": "bird", |
| "data": { |
| |
| |
| |
| |
| |
| "narrative": [ |
| "perched still, wings folded", |
| "wings beginning to open, pushing off", |
| "wings spread wide, gliding down", |
| "wings angled back, descending toward branch", |
| "landing, wings folding back in", |
| ], |
| "reasoning": [ |
| "wing: stay; beak: stay", |
| "wing: up-left; beak: up-left", |
| "wing: up-left; beak: up-left", |
| "wing: down-right; beak: down-right", |
| "wing: up-right; beak: down-right", |
| ], |
| |
| |
| |
| |
| |
| "targets": { |
| "kf1": {"joint_1": [205.0, 85.0], "joint_2": [238.0, 103.0]}, |
| "kf2": {"joint_1": [195.0, 75.0], "joint_2": [233.0, 98.0]}, |
| "kf3": {"joint_1": [200.0, 95.0], "joint_2": [236.0, 102.0]}, |
| "kf4": {"joint_1": [208.0, 92.0], "joint_2": [239.0, 104.0]}, |
| }, |
| }, |
| }, |
| ] |
|
|
| |
| |
| |
| |
| |
| |
| |
| FEWSHOT_EXAMPLES_SHIFTED = [ |
| { |
| "caption": ex["caption"], |
| "object_name": ex["object_name"], |
| "data": { |
| "narrative": ex["data"]["narrative"], |
| "reasoning": ex["data"]["reasoning"], |
| "targets": { |
| kf: {j: [round(x + 400, 1), round(y + 400, 1)] for j, (x, y) in joints.items()} |
| for kf, joints in ex["data"]["targets"].items() |
| }, |
| }, |
| } |
| for ex in FEWSHOT_EXAMPLES |
| ] |
|
|
|
|
| def build_interaction_constraints_block(interaction_constraints): |
| """ |
| Generator-facing rendering of structured interaction constraints (see |
| lib.load_interaction_constraints). Same underlying data the judge is given via |
| vlm_judge.build_interaction_constraints_text, worded as an instruction rather than a check β |
| the generator needs to know it MUST hit these relationships at their stated critical |
| keyframe, not just that it will be graded on them after the fact. |
| """ |
| if not interaction_constraints: |
| return "" |
| lines = [ |
| f' - Make "{c["source_object"]}"\'s {c["source_part"]} {c["relationship"]} ' |
| f'"{c["target_object"]}" β this specifically needs to be true AT keyframe {c["critical_keyframe"]} ' |
| f'(other keyframes are not required to satisfy it).' |
| for c in interaction_constraints |
| ] |
| return ( |
| "\nREQUIRED interaction constraints for this scene (author-specified β treat these as hard " |
| "requirements, not suggestions):\n" + "\n".join(lines) + "\n" |
| ) |
|
|
|
|
| def build_combined_narrate_deform_prompt(objects_info, caption, previous_narratives=None, feedback=None, |
| n_keyframes=N_KEYFRAMES, is_retry=False, few_shot=True, |
| freeze_narrative=False, joint_feedback=None, baseline_targets=None, |
| attempt_history=None, interaction_constraints=None, |
| fewshot_shifted=False, real_trajectories=None): |
| sections, example_parts = [], [] |
| for name, info in objects_info.items(): |
| prev_narrative_for_obj = (previous_narratives or {}).get(name) |
| obj_baseline_targets = (baseline_targets or {}).get(name) |
| history_block = build_object_history_block(name, attempt_history, joint_feedback=joint_feedback, |
| baseline_targets=obj_baseline_targets) |
| sections.append(build_combined_object_section( |
| name, info["joints"], info["anchor_idx"], info["handle_idxs"], info["bbox_size"], |
| previous_narrative=prev_narrative_for_obj, feedback=feedback, n_keyframes=n_keyframes, |
| freeze_narrative=freeze_narrative, strokes=info.get("strokes"), joint_feedback=joint_feedback, |
| baseline_targets=obj_baseline_targets, history_block=history_block, |
| part_names=info.get("part_names"), real_trajectories=real_trajectories, |
| )) |
| kf_examples = ",\n".join( |
| " \"kf%d\": {%s}" % (kf, ", ".join(f'"joint_{i}": [x, y]' for i in info["handle_idxs"])) |
| for kf in range(1, n_keyframes) |
| ) |
| |
| |
| |
| |
| |
| |
| if freeze_narrative: |
| example_parts.append( |
| f' "{name}": {{\n' |
| f' "reasoning": [<{n_keyframes} short strings, one per keyframe β see instruction below>],\n' |
| f' "targets": {{\n{kf_examples}\n }}\n' |
| f' }}' |
| ) |
| else: |
| example_parts.append( |
| f' "{name}": {{\n' |
| f' "narrative": [<{n_keyframes} short pose description strings, one per keyframe>],\n' |
| f' "reasoning": [<{n_keyframes} short strings, one per keyframe β see instruction 3 below>],\n' |
| f' "targets": {{\n{kf_examples}\n }}\n' |
| f' }}' |
| ) |
|
|
| all_sections = "\n\n".join(sections) |
| example_json = "{\n" + ",\n".join(example_parts) + "\n}" |
|
|
| image_context = "" |
| if is_retry: |
| image_context = ("The FIRST image attached is the object's original rest pose (undeformed). " |
| "The remaining images are the actual rendered result of your PREVIOUS attempt, " |
| "one per keyframe, in order.") |
| else: |
| image_context = ("The attached image shows the object's original rest pose (undeformed) β use this " |
| "to understand what strokes actually exist and are available to move; do not " |
| "invent motion for body parts that aren't actually drawn.") |
|
|
| fewshot_block = "" |
| if few_shot: |
| fewshot_examples = FEWSHOT_EXAMPLES_SHIFTED if fewshot_shifted else FEWSHOT_EXAMPLES |
| example_blocks = [] |
| for i, ex in enumerate(fewshot_examples, 1): |
| ex_json = json.dumps({ex["object_name"]: ex["data"]}, indent=2) |
| example_blocks.append(f'Example {i} β for the scene "{ex["caption"]}", a good answer looks like:\n{ex_json}') |
| fewshot_block = "\n\n".join(example_blocks) + f""" |
| |
| Notice across these {len(fewshot_examples)} examples: each narrative keyframe reads as a distinct, substantially different stage of the action β not a near-duplicate of its neighbor, and not a small incremental change from it. The joint targets progress with intent (building up smoothly, or β as in the cat example β coiling back before springing forward, always with the reasoning explaining WHY) and only settle back toward rest at a keyframe the narrative actually describes as settling (landing, folding back in, etc.) β no keyframe overshoots and then has a later keyframe revert back toward rest without a narrative reason to. These 3 examples cover 3 different objects, actions, and coordinate ranges specifically so no single one of them can be mistaken for a reusable template β match the STYLE and numeric consistency shown here, using names, coordinates, and reasoning that come from the actual new scene below, not from any of these examples. |
| |
| """ |
|
|
| if freeze_narrative: |
| output_instruction = ( |
| 'For EACH object above, the narrative/pose story is already fixed (shown above) β ' |
| 'produce:\n' |
| ' 1. "reasoning": for EACH keyframe, ONE crisp string listing every non-anchor joint ' |
| 'and its direction, semicolon-separated: "<part>: <direction>; <part>: <direction>". ' |
| 'Direction must be one of exactly: left, right, up, down, up-left, up-right, down-left, ' |
| 'down-right, or stay. No other words, no explanations, no naming what the joint is moving ' |
| 'toward β e.g. "hand: up-right; mouth: stay", NOT "hand moving toward mouth\'s rest ' |
| 'position to close the gap for the bite." Decide the direction BEFORE picking the number ' |
| 'β if the direction word you would write does not match the target you are about to give, ' |
| 'that is a sign the target needs rethinking, not the wording.\n' |
| ' 2. "targets": target (x, y) positions for its non-anchor joints, at every keyframe, ' |
| 'consistent with the fixed pose story above AND with the reasoning you just gave β the ' |
| 'stated direction and the actual target must agree exactly (e.g. "up-left" means the new ' |
| '(x,y) must be smaller in x AND smaller in y than the previous keyframe value for that ' |
| 'joint, not merely somewhere in that general area).' |
| ) |
| else: |
| output_instruction = ( |
| "For EACH object above, produce:\n" |
| ' 1. "narrative": a plain-English pose description for each keyframe. REMEMBER: these are ' |
| "SPARSE keyframes spanning the WHOLE action, not consecutive video frames β each description " |
| "should be a meaningfully, substantially different stage of the action from its neighbors, not " |
| "a small incremental change. Write these like 5 distinct captions for 5 different moments spread " |
| "across an entire action, not like 5 near-duplicate snapshots a split-second apart. Under 20 " |
| "words each.\n" |
| ' 2. "reasoning": for EACH keyframe, ONE crisp string listing every non-anchor joint and ' |
| 'its direction, semicolon-separated: "<part>: <direction>; <part>: <direction>". Direction ' |
| 'must be one of exactly: left, right, up, down, up-left, up-right, down-left, down-right, ' |
| 'or stay. No other words, no explanations, no naming what the joint is moving toward β e.g. ' |
| '"hand: up-right; mouth: stay", NOT "hand moving toward mouth\'s rest position to close the ' |
| 'gap for the bite." Decide the direction BEFORE picking the number β if the direction word ' |
| 'you would write does not match the target you are about to give, that is a sign the target ' |
| 'needs rethinking, not the wording.\n' |
| ' 3. "targets": target (x, y) positions for its non-anchor joints, at every keyframe, ' |
| "consistent with your own narrative AND with the reasoning you just gave β the stated " |
| 'direction and the actual target must agree exactly (e.g. "up-left" means the new (x,y) ' |
| "must be smaller in x AND smaller in y than the previous keyframe value for that joint, " |
| "not merely somewhere in that general area)." |
| ) |
|
|
| return f"""{fewshot_block}You are directing a {n_keyframes}-keyframe animated sequence for a hand-drawn sketch, viewed from the side. Coordinate system: x increases rightward, y increases DOWNWARD. |
| |
| IMPORTANT: these {n_keyframes} keyframes are SPARSE anchor points sampled across the ENTIRE action from start to finish β NOT consecutive video frames. Think of them like 5 widely-spaced snapshots of a whole motion, not neighboring frames a fraction of a second apart. Large, dramatic pose changes between consecutive keyframes are normal and expected; a separate model will generate the actual in-between motion frames later. Do not treat these like near-continuous animation frames. |
| |
| Scene: "{caption}" |
| {build_interaction_constraints_block(interaction_constraints)} |
| {image_context} |
| |
| {all_sections} |
| |
| {output_instruction} |
| |
| Consider objects together (e.g. a dog reaching toward a frisbee should be spatially consistent with the frisbee's own position) and consider each object's OWN sequence together β these {n_keyframes} keyframes are SPARSE anchor points spanning the WHOLE action, not consecutive video frames, so large differences between consecutive keyframes are expected and correct, not something to avoid. The many actual in-between motion frames will be generated separately later. Only avoid a keyframe making real progress and then a LATER keyframe randomly reverting backward without the narrative describing why. |
| |
| Respond with ONLY one JSON object, no other text, in this exact format: |
| {example_json} |
| """ |
|
|
|
|
| def run_combined_narrate_deform(model, processor, images, prompt, temperature=0.6): |
| """Same multi-image calling pattern as vlm_judge.run_judge β reused |
| here since both are Qwen3-VL calls with a list of images + one prompt. |
| |
| temperature default raised from 0.1 to 0.6 β CONFIRMED on real hardware |
| (eat2, 3 attempts) that at 0.1 the model reproduced its joint targets |
| as an EXACT copy of the rest-position legend values (not just "close |
| to rest" β bit-identical to the decimal) on attempt 1, before any |
| feedback existed to explain it. Near-zero temperature strongly favors |
| the single highest-probability continuation, and copying a number |
| already visible in-context (the rest-position legend, formatted in |
| the same [x, y] style as the requested targets) is a low-risk, easy |
| completion under numeric uncertainty. This is a hypothesis about |
| mechanism, not confirmed root cause β raising temperature is the |
| cheapest test of it before trying a bigger/different model. |
| """ |
| 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=500 * N_KEYFRAMES, |
| temperature=temperature, do_sample=True) |
|
|
| generated = output_ids[:, inputs["input_ids"].shape[1]:] |
| response = processor.batch_decode(generated, skip_special_tokens=True)[0] |
| return response |
|
|
|
|
| def transform_image_and_get_inverse(image, angle_deg, scale, canvas_size): |
| """ |
| Rotates and scales a PIL image around the canvas center, returning the transformed image AND |
| an exact inverse function mapping a coordinate picked from the TRANSFORMED image back to the |
| ORIGINAL image's coordinate space. This is the one piece of this feature that MUST be exactly |
| correct, since a subtle sign/direction error here would silently produce confidently-wrong |
| coordinates for every joint target, worse than not having the feature at all. |
| |
| The rotation formula used here (x' = x*cos+y*sin, y' = -x*sin+y*cos) was determined EMPIRICALLY |
| against PIL's actual Image.rotate() behavior, NOT derived theoretically and trusted β an |
| initial theoretical derivation (standard CCW rotation matrix) was tested against a real PIL |
| render with a marked point and was WRONG (145px recovery error); this formula is the one that |
| was verified correct via a real end-to-end test: render an image with a known marker point, |
| rotate it with PIL, empirically find where the marker actually ended up, and confirm the |
| inverse function maps that found location back to the original marker location. Any future |
| change to this function should be re-verified the same way, not just algebraically. |
| |
| canvas_size: (width, height) of the image BEFORE transforming β the rotation/scale pivot is |
| the center of this canvas, matching where the sketch itself is centered in the fixed |
| xmin/xmax/ymin/ymax render bounds, not the image's own bounding box after rotation (which |
| would shift as the canvas expands to fit a rotated image). |
| """ |
| cx, cy = canvas_size[0] / 2.0, canvas_size[1] / 2.0 |
| theta = math.radians(angle_deg) |
| cos_t, sin_t = math.cos(theta), math.sin(theta) |
|
|
| transformed = image.resize((int(image.width * scale), int(image.height * scale))) if scale != 1.0 else image |
| if angle_deg != 0: |
| transformed = transformed.rotate(angle_deg, resample=Image.BICUBIC, expand=False, |
| center=(transformed.width / 2.0, transformed.height / 2.0), |
| fillcolor=(255, 255, 255)) |
|
|
| def inverse(pt): |
| |
| tcx, tcy = transformed.width / 2.0, transformed.height / 2.0 |
| x, y = pt[0] - tcx, pt[1] - tcy |
| |
| |
| |
| |
| x_s, y_s = x / scale, y / scale |
| |
| |
| x_r = x_s * cos_t - y_s * sin_t |
| y_r = x_s * sin_t + y_s * cos_t |
| |
| return (x_r + cx, y_r + cy) |
|
|
| return transformed, inverse |
|
|
|
|
| def run_combined_narrate_deform_multiview(model, processor, images, prompt, temperature=0.6, |
| views=None, canvas_size=(260, 230)): |
| """ |
| EXPERIMENTAL β generalizes run_combined_narrate_deform to query MULTIPLE rotated/scaled views |
| of the SAME images, then median-aggregates the resulting joint targets per (object, keyframe, |
| joint) back in the ORIGINAL coordinate space. Adapted from the multi-view voting idea in |
| "Handle-based Mesh Deformation Guided By Vision Language Model" (Sun et al. 2025), but applied |
| to the GENERATOR here rather than the judge (see vlm_judge.run_judge_voted for that side) β the |
| hypothesis being tested is that a small/sparse detail (e.g. a hand, a small object) might be |
| picked out more reliably from SOME rotation/scale than others, so combining multiple views |
| could reduce the chance any one view's blind spot dominates the result. |
| |
| IMPORTANT CAVEAT, stated plainly rather than glossed over: unlike the judge-voting case (which |
| resamples the SAME view multiple times), this changes what the model actually SEES between |
| calls β a rotated/scaled image is a genuinely different visual input, closer to the paper's |
| original multi-CAMERA-view setup in spirit. But it is still a 2D sketch, not a 3D mesh with |
| real depth to resolve β there is no geometric ambiguity being resolved here, only a bet that |
| varied presentation might surface details a single fixed view misses. UNTESTED whether this |
| actually helps versus just adding noise from the model reasoning differently about a rotated |
| image (e.g. text/handedness cues in the sketch may become confusing when rotated). |
| |
| views: list of (angle_deg, scale) tuples. If None, defaults to a small combined set: |
| [(0, 1.0), (90, 1.0), (270, 1.0), (0, 1.3), (0, 0.75)] |
| β three rotations (0/90/270, skipping 180 which mostly just flips faces/text unhelpfully) |
| plus two scale variants (zoom in / zoom out) at 0 rotation. This is a starting default, |
| not a validated optimal set. |
| |
| Returns: (aggregated_response_dict_or_None, list_of_per_view_raw_response_strings) |
| aggregated_response_dict is None if EVERY view's response failed to parse β caller should |
| fall back to treating this as a parse failure, same as the single-view case. |
| """ |
| if views is None: |
| views = [(0, 1.0), (90, 1.0), (270, 1.0), (0, 1.3), (0, 0.75)] |
|
|
| per_view_parsed = [] |
| raw_responses = [] |
| for angle_deg, scale in views: |
| view_images = [] |
| view_inverses = [] |
| for img in images: |
| transformed_img, inverse_fn = transform_image_and_get_inverse(img, angle_deg, scale, canvas_size) |
| view_images.append(transformed_img) |
| view_inverses.append(inverse_fn) |
| |
| |
| inverse_fn = view_inverses[0] if view_inverses else (lambda pt: pt) |
|
|
| raw = run_combined_narrate_deform(model, processor, view_images, prompt, temperature=temperature) |
| raw_responses.append(raw) |
| try: |
| parsed = parse_json_response(raw) |
| except (ValueError, json.JSONDecodeError): |
| continue |
|
|
| |
| for obj_name, obj_data in parsed.items(): |
| targets = obj_data.get("targets") if isinstance(obj_data, dict) else None |
| if not targets: |
| continue |
| for kf_key, kf_targets in targets.items(): |
| if not isinstance(kf_targets, dict): |
| continue |
| for joint_key, coord in list(kf_targets.items()): |
| if isinstance(coord, (list, tuple)) and len(coord) == 2: |
| try: |
| x, y = inverse_fn((float(coord[0]), float(coord[1]))) |
| kf_targets[joint_key] = [x, y] |
| except (TypeError, ValueError): |
| pass |
|
|
| per_view_parsed.append(parsed) |
|
|
| if not per_view_parsed: |
| return None, raw_responses |
|
|
| |
| |
| |
| aggregated = {} |
| for parsed in per_view_parsed: |
| for obj_name, obj_data in parsed.items(): |
| if not isinstance(obj_data, dict): |
| continue |
| aggregated.setdefault(obj_name, {"narrative": None, "reasoning": None, "targets": {}}) |
| if obj_data.get("narrative") and aggregated[obj_name]["narrative"] is None: |
| |
| |
| |
| aggregated[obj_name]["narrative"] = obj_data["narrative"] |
| if obj_data.get("reasoning") and aggregated[obj_name]["reasoning"] is None: |
| |
| aggregated[obj_name]["reasoning"] = obj_data["reasoning"] |
| targets = obj_data.get("targets") or {} |
| for kf_key, kf_targets in targets.items(): |
| if not isinstance(kf_targets, dict): |
| continue |
| aggregated[obj_name]["targets"].setdefault(kf_key, {}) |
| for joint_key, coord in kf_targets.items(): |
| if isinstance(coord, (list, tuple)) and len(coord) == 2: |
| aggregated[obj_name]["targets"][kf_key].setdefault(joint_key, {"xs": [], "ys": []}) |
| try: |
| aggregated[obj_name]["targets"][kf_key][joint_key]["xs"].append(float(coord[0])) |
| aggregated[obj_name]["targets"][kf_key][joint_key]["ys"].append(float(coord[1])) |
| except (TypeError, ValueError): |
| pass |
|
|
| final = {} |
| for obj_name, obj_data in aggregated.items(): |
| final[obj_name] = {"narrative": obj_data["narrative"], "reasoning": obj_data["reasoning"], "targets": {}} |
| for kf_key, kf_targets in obj_data["targets"].items(): |
| final[obj_name]["targets"][kf_key] = {} |
| for joint_key, coords in kf_targets.items(): |
| if coords["xs"]: |
| final[obj_name]["targets"][kf_key][joint_key] = [ |
| statistics.median(coords["xs"]), statistics.median(coords["ys"]) |
| ] |
|
|
| return final, raw_responses |
|
|
|
|
| def compute_direction(dx, dy, threshold=1.5): |
| """ |
| x increases rightward, y increases DOWNWARD (matches this pipeline's own coordinate |
| convention, stated directly in the narrate+deform prompt). Same function used to author |
| and verify the few-shot examples' reasoning fields β reused here, not reimplemented, so |
| the SAME definition of "up-left" etc. applies to both what the model is shown and what its |
| own output gets checked against. |
| """ |
| h = "right" if dx > threshold else ("left" if dx < -threshold else "") |
| v = "down" if dy > threshold else ("up" if dy < -threshold else "") |
| if h and v: |
| return f"{v}-{h}" |
| return h or v or "stay" |
|
|
|
|
| def parse_direction_reasoning(reasoning_str): |
| """ |
| Parses the crisp "<part>: <direction>; <part>: <direction>" reasoning format (see |
| build_combined_narrate_deform_prompt's output_instruction) into {part_name: direction_word}. |
| Returns {} for anything that doesn't match this format at all β treated as "nothing to |
| check" rather than an error, since older saved reasoning (pre-format-change) or a response |
| that ignored the format entirely shouldn't crash validation, just skip it. |
| """ |
| result = {} |
| if not isinstance(reasoning_str, str): |
| return result |
| for chunk in reasoning_str.split(";"): |
| if ":" not in chunk: |
| continue |
| part, _, direction = chunk.partition(":") |
| result[part.strip().lower()] = direction.strip().lower() |
| return result |
|
|
|
|
| def check_reasoning_direction_consistency(reasoning_list, deform_outputs_obj, rest_joints, |
| part_names, handle_idxs, n_keyframes=N_KEYFRAMES): |
| """ |
| For each keyframe, parses the generator's OWN stated direction per part (from the crisp |
| reasoning format) and compares it against the ACTUAL direction computed from the real |
| target coordinates it produced β entirely code-computed on both sides, no VLM judgment |
| involved in either the claim or the check. |
| |
| This is a different, stronger signal than replacing the judge's "direction" motion_check: |
| that would still be one VLM (the judge) grading another VLM's (the generator's) output. |
| This instead checks the generator against ITSELF β its own structured claim ("arm: up-left") |
| against its own numbers β which needs no model judgment on either side, only arithmetic. |
| |
| CONFIRMED useful against real hardware before this function was even written: football7 |
| attempt 2's 'soccer player' reasoning claimed "arm: up-left" at every single keyframe |
| (kf1-kf4) while joint_3 (arm)'s actual targets moved (207,119)->(209,121)->(211,120)-> |
| (213,121) β consistently right/down-right, never up-left even once. The same attempt's |
| "leg: stay" claim was contradicted too: joint_1 (leg) moved 7+px down between kf1 and kf2, |
| not stationary. Both would have passed silently before this check existed. |
| |
| Returns a list of mismatch strings (empty if none found, or if reasoning isn't in the |
| crisp format this check depends on). |
| """ |
| problems = [] |
| if not reasoning_list: |
| return problems |
| prev_coords = {idx: rest_joints[idx] for idx in handle_idxs} |
| for kf in range(1, n_keyframes): |
| kf_key = f"kf{kf}" |
| if kf >= len(reasoning_list): |
| continue |
| stated = parse_direction_reasoning(reasoning_list[kf]) |
| if not stated: |
| continue |
| kf_targets = deform_outputs_obj.get(kf_key, {}) |
| for idx in handle_idxs: |
| joint_key = f"joint_{idx}" |
| if joint_key not in kf_targets: |
| continue |
| part = str(part_names.get(idx, joint_key)).strip().lower() |
| cur_coord = kf_targets[joint_key] |
| prev_coord = prev_coords[idx] |
| dx, dy = cur_coord[0] - prev_coord[0], cur_coord[1] - prev_coord[1] |
| computed = compute_direction(dx, dy) |
| claimed = stated.get(part) |
| if claimed is not None and claimed != computed: |
| problems.append( |
| f"{kf_key} '{part}': reasoning claimed direction '{claimed}' but the actual " |
| f"target moved '{computed}' (delta=({dx:+.1f},{dy:+.1f}) from the previous " |
| f"keyframe) β the generator's own stated reasoning contradicts its own target" |
| ) |
| prev_coords[idx] = cur_coord |
| return problems |
|
|
|
|
| def apply_deform_clip_and_write(parsed, objects_info, out_dir, sketch_name, n_keyframes=N_KEYFRAMES, |
| frozen_narratives=None): |
| """ |
| Shared post-processing for the combined call's "targets" section: |
| same hard-clip logic as the old run_deform, applied here instead. |
| Returns (narratives_dict, reasoning_dict, deform_outputs_dict, cap_utilization_dict). |
| |
| reasoning_dict: {obj_name: [<n_keyframes short strings>]} β the model's stated reason for |
| each keyframe's target choice, when it provided one (see build_combined_narrate_deform_prompt). |
| Purely diagnostic, never fed back into any downstream computation β only saved/printed. |
| |
| frozen_narratives: {obj_name: [...]} β used as a fallback when the |
| response doesn't include a "narrative" key for an object, which |
| happens when freeze_narrative=True was used in the prompt (the model |
| was never asked to produce one, so its absence is expected, not an |
| error β carry the frozen one forward instead of losing it). |
| |
| cap_utilization: {obj_name: mean_fraction_of_cap_used} β CONFIRMED on |
| real hardware (horsecar5's person) that Qwen can propose displacement |
| well within the movement cap without ever being told it did so β the |
| handle selection and clipping were both working correctly, but the |
| actual output was too timid to be visible (e.g. a leg moving only 18% |
| of its allowed range). The hard clip only ever catches OVER the cap; |
| nothing previously caught UNDER-using it. This surfaces that as an |
| explicit number so it can be fed back to Qwen directly. |
| """ |
| narratives_out = {} |
| reasoning_out = {} |
| deform_outputs = {} |
| utilization_by_obj = {} |
|
|
| for obj_name, info in objects_info.items(): |
| if obj_name not in parsed: |
| print(f" WARNING: '{obj_name}' missing from response entirely, skipping") |
| continue |
| obj_result = parsed[obj_name] |
| utilization_by_obj[obj_name] = [] |
|
|
| if "narrative" in obj_result: |
| narratives_out[obj_name] = obj_result["narrative"] |
| elif frozen_narratives and obj_name in frozen_narratives: |
| narratives_out[obj_name] = frozen_narratives[obj_name] |
| else: |
| print(f" WARNING: '{obj_name}' has no narrative in response and no frozen narrative " |
| f"to fall back to β narratives.json will be missing this object") |
|
|
| |
| |
| |
| |
| |
| if "reasoning" in obj_result: |
| reasoning_out[obj_name] = obj_result["reasoning"] |
|
|
| deform_outputs[obj_name] = {} |
| targets = obj_result.get("targets", {}) |
| for kf in range(1, n_keyframes): |
| |
| |
| |
| kf_key = f"kf{kf}" |
| if kf_key not in targets: |
| print(f" WARNING: '{obj_name}' missing {kf_key} targets, skipping this frame") |
| continue |
| kf_result = targets[kf_key] |
| out = {} |
| joint_targets_this_kf = {} |
| for name, target in kf_result.items(): |
| m = re.match(r"joint_(\d+)", name) |
| if not m: |
| print(f" WARNING: unexpected key '{name}' for '{obj_name}' {kf_key}, skipping") |
| continue |
| joint_i = int(m.group(1)) |
| if joint_i >= len(info["joint_mesh_indices"]): |
| print(f" WARNING: '{obj_name}' joint_{joint_i} out of range, skipping") |
| continue |
| if joint_i not in info["handle_idxs"]: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| print(f" NOTE: '{obj_name}' {kf_key} returned joint_{joint_i} (the anchor β pinned to " |
| f"rest and not requested from the model) β discarding this phantom value rather " |
| f"than storing it as if it affected the render") |
| continue |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| target_list = target if isinstance(target, (list, tuple)) else [target] |
| if len(target_list) != 2: |
| print(f" WARNING: '{obj_name}' {kf_key} joint_{joint_i} target = {target!r}, expected " |
| f"an [x, y] pair (got {len(target_list)} value(s)) β skipping this joint for this " |
| f"keyframe rather than writing a malformed value that would crash rendering later") |
| continue |
| mesh_idx = info["joint_mesh_indices"][joint_i] |
|
|
| rest = np.array(info["joints"][joint_i]) |
| cap = round(info["bbox_size"] * HANDLE_MOVE_CAP_FRACTION, 1) |
| target_arr = np.array(target_list, dtype=float) |
| disp = target_arr - rest |
| dist = np.linalg.norm(disp) |
| utilization_by_obj[obj_name].append(min(dist / cap, 1.0) if cap > 0 else 0.0) |
| if dist > cap: |
| clipped = rest + disp / dist * cap |
| print(f" CLIPPED '{obj_name}' {kf_key} joint_{joint_i}: requested {dist:.1f}px " |
| f"(cap {cap}px) -> clipped to {cap}px, direction preserved") |
| target = clipped.tolist() |
| else: |
| target = target_arr.tolist() |
|
|
| out[str(mesh_idx)] = target |
| joint_targets_this_kf[f"joint_{joint_i}"] = target |
| anchor_mesh_idx = info["joint_mesh_indices"][info["anchor_idx"]] |
| out[str(anchor_mesh_idx)] = info["joints"][info["anchor_idx"]].tolist() |
| deform_outputs[obj_name][kf_key] = joint_targets_this_kf |
|
|
| out_path = os.path.join(out_dir, f"qwen_{sketch_name}_{obj_name}_kf{kf}.json") |
| with open(out_path, "w") as f: |
| json.dump(out, f, indent=2) |
| print(f" wrote {out_path}") |
|
|
| cap_utilization = {} |
| for obj_name, fractions in utilization_by_obj.items(): |
| if fractions: |
| mean_frac = sum(fractions) / len(fractions) |
| cap_utilization[obj_name] = mean_frac |
| print(f" '{obj_name}': mean cap utilization = {mean_frac*100:.0f}% " |
| f"(across {len(fractions)} joint-keyframe pairs)") |
|
|
| return narratives_out, reasoning_out, deform_outputs, cap_utilization |
| |
| |
| |
|
|
| def run_render(handles_dir, svg_path, semantic_path, traj_path, |
| out_path=None, frames_dir=None, show_handles=False, objects_info=None): |
| """ |
| out_path: if given, ALSO saves the combined strip image (all 5 keyframes |
| side by side) here, outside frames_dir. Optional β pass None |
| to keep output confined to frames_dir only. |
| frames_dir: if given, saves each keyframe as its own individual PNG |
| (kf0.png ... kf4.png) plus the combined strip, named after |
| the sketch itself ({sketch_name}.png), all inside this one |
| folder. |
| show_handles: if True, overlays each ARAP object's handle joints (and its anchor) as |
| labeled markers on every rendered frame, showing exactly where each named |
| part ("arm", "leg", etc.) actually is at that keyframe β not just its rest |
| position, its real post-deformation, post-trajectory position. Added because |
| understanding which real geometry a part name like "arm" refers to previously |
| required reasoning from a raw coordinate number or re-clustering the SVG |
| separately β this makes it directly visible on the actual output instead. |
| Requires `objects_info` (silently does nothing without it, since part |
| names/joint-to-mesh-vertex mappings live there, not in the handles files this |
| function otherwise reads from disk). |
| objects_info: required when show_handles=True β the same dict built by build_objects_info, |
| giving each object's part_names, handle_idxs, anchor_idx, and |
| joint_mesh_indices (which mesh vertex each joint number maps to). Optional |
| and unused when show_handles=False, so existing callers that don't pass it |
| are unaffected. |
| """ |
| import matplotlib.pyplot as plt |
|
|
| sketch_name = os.path.splitext(os.path.basename(svg_path))[0] |
| real_trajectories = load_trajectories(traj_path) |
| object_names = list(real_trajectories.keys()) |
| resolved_colors = resolve_object_colors(object_names) |
|
|
| object_data = {} |
| for name in object_names: |
| points, slices = load_object(name, svg_path, semantic_path) |
| dx_vals, dy_vals = bbox_deltas(real_trajectories[name]) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| unique_points, p2u = deduplicate_points(points, tol=0.35) |
| tri, edges = build_mesh(unique_points) |
| object_data[name] = { |
| "points": points, "slices": slices, |
| "dx": dx_vals, "dy": dy_vals, |
| "unique_points": unique_points, "p2u": p2u, "edges": edges, |
| } |
|
|
| if frames_dir: |
| os.makedirs(frames_dir, exist_ok=True) |
|
|
| |
| |
| keyframe_lines = [] |
| keyframe_markers = {} |
| |
| for kf in range(N_KEYFRAMES): |
| lines_this_kf = {} |
| for name in object_names: |
| od = object_data[name] |
|
|
| if kf == 0: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| deformed_points = od["points"] |
| mode = "REST (kf0 always forced, ARAP skipped)" |
| elif os.path.exists(os.path.join(handles_dir, f"qwen_{sketch_name}_{name}_kf{kf}.json")): |
| handles_path = os.path.join(handles_dir, f"qwen_{sketch_name}_{name}_kf{kf}.json") |
| with open(handles_path) as f: |
| spec = json.load(f) |
| handle_indices = [int(k) for k in spec.keys()] |
| handle_targets = np.array([spec[k] for k in spec.keys()]) |
| n_verts = len(od["unique_points"]) |
| bad = [i for i in handle_indices if i >= n_verts] |
| if bad: |
| print(f" ERROR: {handles_path} has out-of-bounds indices {bad} for '{name}' " |
| f"({n_verts} mesh vertices) β likely from a DIFFERENT sketch's mesh. " |
| f"Falling back to translation-only.") |
| deformed_points = od["points"] |
| mode = "translation-only (handles file failed validation)" |
| else: |
| deformed_unique = arap_deform(od["unique_points"], od["edges"], |
| handle_indices, handle_targets, iterations=10) |
| deformed_points = deformed_unique[od["p2u"]] |
| mode = "ARAP" |
| else: |
| deformed_points = od["points"] |
| mode = "translation-only (no handles file found)" |
|
|
| moved = deformed_points + np.array([od["dx"][kf], od["dy"][kf]]) |
| lines_this_kf[name] = [moved[start:end] for start, end in od["slices"]] |
|
|
| print(f"kf{kf} '{name}': {mode}, points_after_move_range=" |
| f"x[{moved[:,0].min():.1f},{moved[:,0].max():.1f}] " |
| f"y[{moved[:,1].min():.1f},{moved[:,1].max():.1f}]") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if show_handles and objects_info and name in objects_info: |
| info = objects_info[name] |
| joint_mesh_indices = info.get("joint_mesh_indices", []) |
| part_names_map = info.get("part_names", {}) |
| anchor_idx = info.get("anchor_idx") |
| traj_delta = np.array([od["dx"][kf], od["dy"][kf]]) |
| all_joint_idxs = list(info.get("handle_idxs", [])) + ( |
| [anchor_idx] if anchor_idx is not None else []) |
| markers_this_obj = [] |
| for idx in all_joint_idxs: |
| part_name = part_names_map.get(idx, f"joint_{idx}") |
| is_anchor = (idx == anchor_idx) |
| if mode == "ARAP" and idx < len(joint_mesh_indices): |
| mv = joint_mesh_indices[idx] |
| marker_pos = deformed_unique[mv] + traj_delta |
| else: |
| marker_pos = np.array(info["joints"][idx]) + traj_delta |
| markers_this_obj.append((part_name, float(marker_pos[0]), float(marker_pos[1]), is_anchor)) |
| keyframe_markers.setdefault(kf, {})[name] = markers_this_obj |
|
|
| keyframe_lines.append(lines_this_kf) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| all_x, all_y = [], [] |
| for lines_this_kf in keyframe_lines: |
| for segs in lines_this_kf.values(): |
| for seg in segs: |
| if len(seg): |
| all_x.append(seg[:, 0]) |
| all_y.append(seg[:, 1]) |
| if all_x: |
| all_x, all_y = np.concatenate(all_x), np.concatenate(all_y) |
| pad_x = max((all_x.max() - all_x.min()) * 0.08, 8.0) |
| pad_y = max((all_y.max() - all_y.min()) * 0.08, 8.0) |
| xmin, xmax = float(all_x.min() - pad_x), float(all_x.max() + pad_x) |
| ymin, ymax = float(all_y.min() - pad_y), float(all_y.max() + pad_y) |
| else: |
| |
| |
| xmin, xmax, ymin, ymax = 0, 260, 60, 230 |
|
|
| def _draw_handle_markers(ax, kf): |
| """Overlays this keyframe's computed handle/anchor markers, if show_handles is on and |
| any were computed. Anchor markers use a distinct shape (square) and color (red) from |
| moving handles (circle, dark blue) so the fixed reference point is visually obvious at |
| a glance, not just labeled the same as everything else.""" |
| for name, markers in keyframe_markers.get(kf, {}).items(): |
| for part_name, mx, my, is_anchor in markers: |
| if is_anchor: |
| ax.plot(mx, my, "s", markersize=9, color="#CC3333", |
| markeredgecolor="black", markeredgewidth=1.0, zorder=5) |
| else: |
| ax.plot(mx, my, "o", markersize=8, color="#2255CC", |
| markeredgecolor="black", markeredgewidth=1.0, zorder=5) |
| ax.annotate(part_name, (mx, my), xytext=(4, 4), textcoords="offset points", |
| fontsize=8, weight="bold", |
| color="#CC3333" if is_anchor else "#2255CC", zorder=6) |
|
|
| if frames_dir: |
| for kf in range(N_KEYFRAMES): |
| fig_i, ax_i = plt.subplots(figsize=(6, 5.5)) |
| for name, segs in keyframe_lines[kf].items(): |
| for seg in segs: |
| ax_i.plot(seg[:, 0], seg[:, 1], |
| color=resolved_colors.get(name, DEFAULT_COLOR), |
| linewidth=OBJECT_LINEWIDTH.get(name, DEFAULT_LINEWIDTH)) |
| if show_handles: |
| _draw_handle_markers(ax_i, kf) |
| ax_i.set_xlim(xmin, xmax) |
| ax_i.set_ylim(ymax, ymin) |
| ax_i.set_aspect("equal") |
| ax_i.set_title(f"{sketch_name} β kf{kf}", fontsize=12, fontweight="bold") |
| frame_path = os.path.join(frames_dir, f"kf{kf}.png") |
| fig_i.savefig(frame_path, dpi=140, bbox_inches="tight") |
| plt.close(fig_i) |
| print(f" wrote {frame_path}") |
|
|
| |
| fig, axes = plt.subplots(1, N_KEYFRAMES, figsize=(24, 5)) |
| for kf in range(N_KEYFRAMES): |
| ax = axes[kf] |
| for name, segs in keyframe_lines[kf].items(): |
| for seg in segs: |
| ax.plot(seg[:, 0], seg[:, 1], |
| color=resolved_colors.get(name, DEFAULT_COLOR), |
| linewidth=OBJECT_LINEWIDTH.get(name, DEFAULT_LINEWIDTH)) |
| if show_handles: |
| _draw_handle_markers(ax, kf) |
| ax.set_xlim(xmin, xmax) |
| ax.set_ylim(ymax, ymin) |
| ax.set_aspect("equal") |
| ax.set_title(f"kf{kf}", fontsize=13, fontweight="bold") |
|
|
| plt.tight_layout() |
|
|
| if out_path: |
| plt.savefig(out_path, dpi=140, bbox_inches="tight") |
| print(f"wrote {out_path}") |
|
|
| if frames_dir: |
| combined_frame_path = os.path.join(frames_dir, f"{sketch_name}.png") |
| plt.savefig(combined_frame_path, dpi=140, bbox_inches="tight") |
| print(f"wrote {combined_frame_path}") |
|
|
| if not out_path and not frames_dir: |
| print("WARNING: neither out_path nor frames_dir given, combined strip image not saved anywhere") |
|
|
| plt.close(fig) |
|
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| SVG_DIR_DEFAULT = "/user/HS400/rk01499/my_scratch/sketch/data/raw/60sketches/svg" |
| PROCESSED_DIR_DEFAULT = "/user/HS400/rk01499/my_scratch/sketch/data/processed" |
| CAPTION_FILE_DEFAULT = "/user/HS400/rk01499/my_scratch/sketch/data/raw/60sketches/caption.txt" |
| MODEL_PATH_DEFAULT = "/user/HS400/rk01499/my_scratch/models/qwen2.5-7b/" |
|
|
|
|
| def resolve_sketch_paths(sketch, svg_dir, processed_dir, caption_file): |
| """ |
| sketch: either a bare sketch name ("dog9") or a path to its SVG |
| ("/path/to/dog9.svg") β either way, everything else (semantic, |
| traj, caption) is derived from the same naming convention used |
| across this dataset: {name}.svg, {name}/{name}_semantic.txt, |
| {name}/{name}_traj.txt, and a lookup in one shared caption.txt. |
| """ |
| name = os.path.splitext(os.path.basename(sketch))[0] |
| svg_path = sketch if sketch.endswith(".svg") else os.path.join(svg_dir, f"{name}.svg") |
| semantic_path = os.path.join(processed_dir, name, f"{name}_semantic.txt") |
| traj_path = os.path.join(processed_dir, name, f"{name}_traj.txt") |
|
|
| missing = [p for p in [svg_path, semantic_path, traj_path, caption_file] if not os.path.exists(p)] |
| if missing: |
| raise SystemExit( |
| f"Could not find these expected files for sketch '{name}':\n " + |
| "\n ".join(missing) + |
| "\n\nIf your directory layout differs from the default, pass --svg-dir / " |
| "--processed-dir / --caption-file explicitly." |
| ) |
|
|
| caption = get_caption(caption_file, name) |
| return name, svg_path, semantic_path, traj_path, caption |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser( |
| description="Run the full sketch deformation pipeline for one image. " |
| "The only required input is the sketch β everything else " |
| "(semantic assignments, trajectory, caption) is looked up " |
| "automatically from the standard dataset layout.") |
| ap.add_argument("sketch", type=str, |
| help="sketch name (e.g. 'dog9') or path to its .svg file") |
| ap.add_argument("--model", type=str, default=MODEL_PATH_DEFAULT) |
| ap.add_argument("--svg-dir", type=str, default=SVG_DIR_DEFAULT) |
| ap.add_argument("--processed-dir", type=str, default=PROCESSED_DIR_DEFAULT) |
| ap.add_argument("--caption-file", type=str, default=CAPTION_FILE_DEFAULT) |
| ap.add_argument("--out-dir", type=str, default=".") |
| ap.add_argument("--no-fewshot", action="store_true", |
| help="disable the 3 few-shot examples (dog/cat/bird) in narrate (for A/B comparison)") |
| ap.add_argument("--fewshot-shifted", action="store_true", |
| help="DIAGNOSTIC β use FEWSHOT_EXAMPLES_SHIFTED instead of the normal " |
| "few-shot examples: identical relative structure (same displacement magnitudes/" |
| "direction/shape), but every coordinate offset by (+400,+400) into a pixel range " |
| "with no relationship to any real sketch's canvas. Tests whether the generator " |
| "anchors on the LITERAL numbers in the examples rather than reasoning " |
| "proportionally β if a sketch's own output drifts toward the shifted range, " |
| "that confirms literal-number leakage from the examples. Not for production use; " |
| "revert after the test. Ignored if --no-fewshot is also given.") |
| ap.add_argument("--vlm-model-path", type=str, default="Qwen/Qwen2.5-VL-3B-Instruct", |
| help="EXPERIMENTAL β model path/id for the VLM used by semantic handle selection, " |
| "narrate+deform, AND the judge (all three share one model, loaded once per run β " |
| "see the comment above the retry loop). Default is the 3B model this whole " |
| "pipeline has been validated against; swapping to a bigger variant (e.g. " |
| "'Qwen/Qwen2.5-VL-7B-Instruct') is untested against every failure mode " |
| "characterized on the 3B this session (reasoning-target contradictions, " |
| "few-shot copying, timid magnitude, vertex collisions) β a bigger model may " |
| "reason better, or may just replicate the same patterns at a different size. " |
| "load_vlm() already auto-selects the correct model class from this string, so " |
| "any Qwen2-VL/Qwen2.5-VL/Qwen3-VL path should load correctly.") |
| ap.add_argument("--vlm-quantize", type=str, default=None, choices=["8bit", "4bit"], |
| help="Quantization for --vlm-model-path. Default None (full precision) β CONFIRMED " |
| "on real hardware that the 3B model at full precision already uses ~13.8 GiB of " |
| "~14.6 GiB usable on this GPU, ~0.8 GiB headroom. A 7B VL model at full " |
| "precision would need roughly ~32 GiB β will NOT fit on this hardware at all. " |
| "8bit is a rough ~16 GiB estimate β still likely too tight once DINOv2/CLIP are " |
| "also resident. 4bit is the only estimated-safe option for a 7B model on this " |
| "GPU (~8 GiB, real headroom left). If --vlm-model-path contains '7b' or '7B' and " |
| "this flag is left at its default (None), it is AUTOMATICALLY set to '4bit' " |
| "instead of silently attempting a full-precision load that would OOM β see main() " |
| "for where that override happens and prints a message when it fires.") |
| ap.add_argument("--cap-fraction", type=float, default=None, |
| help="EXPERIMENTAL override for HANDLE_MOVE_CAP_FRACTION (module default: " |
| "0.4). Raised from the original 0.25 to 0.4 earlier this session β " |
| "CONFIRMED that fixed a real problem on eat2 (0.25 made hand-to-mouth " |
| "contact geometrically impossible even with a perfect target). NOT yet " |
| "tested on any sketch with 3+ simultaneous ARAP objects (e.g. football7) " |
| "β if a multi-object scene shows worse distortion at 0.4 than 0.25 did, " |
| "that's new evidence this flag exists to let you check directly, rather " |
| "than only being able to run at whatever value is hardcoded. Pass e.g. " |
| "--cap-fraction 0.25 to compare directly against the pre-session default.") |
| ap.add_argument("--narrate-temperature", type=float, default=0.6, |
| help="FLAT sampling temperature for every attempt (default 0.6, raised from the " |
| "original 0.1 β CONFIRMED that 0.1 caused the model to copy rest-position values " |
| "verbatim as targets on attempt 1). Ignored if --narrate-temperature-schedule " |
| "is given instead.") |
| ap.add_argument("--narrate-temperature-schedule", type=str, default=None, |
| help="EXPERIMENTAL β comma-separated per-attempt temperature values, e.g. " |
| "'0.8,0.6,0.5,0.4,0.4' or '0.6,0.4,0.3,0.3,0.3'. Overrides --narrate-temperature " |
| "when given. Hypothesis being tested: high temperature avoids copy-from-rest on " |
| "a cold-start attempt 1 (confirmed); once baseline_targets gives later attempts " |
| "a real number to refine rather than generate from scratch, lower temperature's " |
| "tighter output may help precision more than it risks the original copy-from-rest " |
| "failure β UNTESTED, this is what the schedule exists to check. If fewer values " |
| "are given than MAX_RETRIES, the LAST value is reused for remaining attempts.") |
| ap.add_argument("--force-all-attempts", action="store_true", |
| help="EXPERIMENTAL β always run all MAX_RETRIES attempts, NEVER stop early on a " |
| "pass or on DINOv2 stagnation, then pick the BEST-SCORING attempt at the end " |
| "(ranked by faithfulness_score, tie-broken by plausibility_score). Replaces the " |
| "confirmation-round mechanism (which only ever compared 2 attempts) with a full " |
| "best-of-N comparison across every attempt actually run. Primarily useful when " |
| "combined with --narrate-temperature-schedule, so the WHOLE schedule gets " |
| "exercised rather than potentially stopping before later, lower-temperature " |
| "attempts are ever reached.") |
| ap.add_argument("--show-handles", action="store_true", |
| help="Overlay each ARAP object's handle joints (blue circles) and anchor (red " |
| "square) on every rendered keyframe, labeled with the part name assigned " |
| "during semantic handle selection β e.g. 'arm', 'torso'. Shows each part's " |
| "ACTUAL position at that keyframe (post-deformation, post-trajectory), not " |
| "just its rest position, so it's directly visible which real geometry a " |
| "part name refers to instead of having to infer it from a raw coordinate.") |
| ap.add_argument("--judge-votes", type=int, default=1, |
| help="EXPERIMENTAL β call the judge N times per attempt on the SAME prompt/images and " |
| "aggregate (median) the scores and any grounded target_coords across the N " |
| "responses, instead of trusting a single judge call. 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, there's only " |
| "one view, so the analogous move is multiple independent judge calls on the SAME " |
| "view, voted the same way. Default 1 = current single-call behavior, unchanged. " |
| "UNTESTED whether this actually helps vs. just costing N times more compute β " |
| "the paper's setting (multiple genuinely different camera views) is not identical " |
| "to this one (same view, resampled), so the noise being averaged out may be " |
| "smaller here than in the original technique.") |
| ap.add_argument("--generator-views", action="store_true", |
| help="EXPERIMENTAL β apply the SAME multi-view voting idea to the GENERATOR " |
| "(narrate+deform) instead of just the judge: query multiple ROTATED/SCALED " |
| "versions of the same images (0Β°/90Β°/270Β° rotations, 0.75x/1.3x scale β see " |
| "run_combined_narrate_deform_multiview for the exact set), inverse-transform " |
| "each view's joint targets back to the ORIGINAL coordinate space, and " |
| "median-aggregate per (object, keyframe, joint). Off by default β the " |
| "coordinate transform math is verified correct (empirically tested against " |
| "real PIL rendering, not just algebra), but whether varying the generator's " |
| "input view actually helps it notice small/sparse details (vs. just adding " |
| "noise from reasoning about an unfamiliar rotated image) is UNTESTED. Also " |
| "costs 5x the generator compute per attempt (one call per view) when enabled.") |
| ap.add_argument("--interactions", type=str, default=None, |
| help="path to a structured interaction constraints JSON file (see " |
| "lib.load_interaction_constraints for the schema β source_object/" |
| "source_part/target_object/relationship/critical_keyframe). Optional: " |
| "if omitted, defaults to {processed-dir}/{name}/{name}_interactions.json " |
| "and silently proceeds with no constraints if that file doesn't exist " |
| "either β nothing about this is required for a sketch to run.") |
| ap.add_argument("--deform-only", action="store_true", |
| help="run classify + ONE narrate+deform attempt + render, then STOP β no judge, " |
| "no retries. Prints cap utilization directly and saves the render, so you can " |
| "inspect raw generation quality without the judge's assessment as a confound.") |
| args = ap.parse_args() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| if args.vlm_quantize is None and "7b" in args.vlm_model_path.lower(): |
| args.vlm_quantize = "4bit" |
| print(f" NOTE: --vlm-model-path '{args.vlm_model_path}' looks like a 7B model and " |
| f"--vlm-quantize wasn't set β defaulting to 4bit automatically (full precision would " |
| f"not fit on this GPU; pass --vlm-quantize 8bit explicitly to override this safety " |
| f"default, though that is still a tighter fit than 4bit)") |
| if args.vlm_model_path != "Qwen/Qwen2.5-VL-3B-Instruct": |
| print(f" VLM model path: {args.vlm_model_path} (quantize={args.vlm_quantize}) β " |
| f"UNTESTED against every failure mode characterized this session on the default 3B model") |
|
|
| global HANDLE_MOVE_CAP_FRACTION |
| if args.cap_fraction is not None: |
| print(f" NOTE: --cap-fraction {args.cap_fraction} overrides module default " |
| f"{HANDLE_MOVE_CAP_FRACTION} for this run") |
| HANDLE_MOVE_CAP_FRACTION = args.cap_fraction |
|
|
| |
| |
| |
| |
| temperature_schedule = None |
| if args.narrate_temperature_schedule: |
| temperature_schedule = [float(v.strip()) for v in args.narrate_temperature_schedule.split(",")] |
| print(f" narrate temperature SCHEDULE: {temperature_schedule} " |
| f"(overrides flat --narrate-temperature={args.narrate_temperature})") |
| else: |
| print(f" narrate temperature: {args.narrate_temperature} (flat, no schedule given)") |
|
|
| name, svg_path, semantic_path, traj_path, caption = resolve_sketch_paths( |
| args.sketch, args.svg_dir, args.processed_dir, args.caption_file) |
| print(f"sketch: {name}") |
| print(f" svg: {svg_path}") |
| print(f" semantic: {semantic_path}") |
| print(f" traj: {traj_path}") |
| print(f" caption: {caption!r}") |
|
|
| interactions_path = args.interactions or os.path.join(args.processed_dir, name, f"{name}_interactions.json") |
| interaction_constraints = load_interaction_constraints(interactions_path) |
| if interaction_constraints: |
| print(f" interactions: {interactions_path} ({len(interaction_constraints)} constraint(s))") |
| else: |
| print(f" interactions: none found at {interactions_path} (optional, proceeding without)") |
|
|
| os.makedirs(args.out_dir, exist_ok=True) |
| |
| |
| |
| run_timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") |
| run_label = f"{name}__{run_timestamp}" |
| json_dir = os.path.join(args.out_dir, "json", run_label) |
| os.makedirs(json_dir, exist_ok=True) |
| print(f" json output dir: {json_dir}") |
| print(f" run label: {run_label}") |
|
|
| print("\n########## STEP 1: CLASSIFY ##########") |
| classify_model, classify_tokenizer, classify_device = load_qwen_model(args.model) |
| deformation, arap_objects = run_classify( |
| classify_model, classify_tokenizer, classify_device, caption, semantic_path, |
| os.path.join(json_dir, f"{name}_deformation.json")) |
| classify_model = unload_model(classify_model) |
|
|
| if arap_objects: |
| import vimjudge1 as vlm_judge |
|
|
| temp_dir = os.path.join(args.out_dir, "P_1", run_label) |
|
|
| |
| rest_pose_image_path = os.path.join(json_dir, f"{name}_rest_pose.png") |
| render_rest_pose_multi(arap_objects, svg_path, semantic_path, rest_pose_image_path) |
| rest_pose_image_for_selection = Image.open(rest_pose_image_path).convert("RGB") |
|
|
| |
| |
| print("\n########## STEP 1b: SEMANTIC HANDLE SELECTION ##########") |
| sel_model, sel_processor = vlm_judge.load_vlm(args.vlm_model_path, quantize=args.vlm_quantize) |
| objects_info = build_objects_info(svg_path, semantic_path, arap_objects, |
| caption=caption, |
| vlm_model=sel_model, |
| vlm_processor=sel_processor, |
| rest_pose_image=rest_pose_image_for_selection, |
| candidate_image_dir=json_dir) |
| sel_model = unload_model(sel_model) |
|
|
| if not objects_info: |
| print("No objects with valid handles found after mesh setup. Nothing to do.") |
| objects_info = None |
|
|
| |
| |
| |
| |
| |
| if objects_info: |
| rest_joints_dump = { |
| obj_name: { |
| f"joint_{i}": {"x": float(j[0]), "y": float(j[1]), |
| "is_anchor": i == info["anchor_idx"], |
| "part_name": info.get("part_names", {}).get(i)} |
| for i, j in enumerate(info["joints"]) |
| } |
| for obj_name, info in objects_info.items() |
| } |
| with open(os.path.join(json_dir, f"{name}_rest_joints.json"), "w") as f: |
| json.dump(rest_joints_dump, f, indent=2) |
|
|
| |
| |
| |
| real_trajectories = load_trajectories(traj_path) |
| all_object_names = list(real_trajectories.keys()) |
|
|
| import dino_similarity |
| dino_model, dino_processor = dino_similarity.load_dino_model() |
|
|
| import clip_score |
| clip_model, clip_processor = clip_score.load_clip_model() |
|
|
| feedback = None |
| previous_narratives = None |
| previous_temp_dir = None |
| freeze_narrative = False |
| consecutive_stagnant = 0 |
| joint_feedback = None |
| final_verdict = None |
| winning_attempt = None |
| all_attempts_summary = [] |
| |
| |
| |
| baseline_targets = None |
| |
| |
| |
| |
| |
| |
| first_pass_attempt = None |
| first_pass_scores = None |
| confirmation_used = False |
| |
| |
| |
| |
| |
| |
| |
| |
| attempt_history = [] |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| real_trajectories_for_prompt = load_trajectories(traj_path) if objects_info else {} |
|
|
| vlm_model = vlm_processor = None |
| if objects_info: |
| vlm_model, vlm_processor = vlm_judge.load_vlm(args.vlm_model_path, quantize=args.vlm_quantize) |
|
|
| for attempt in range(1, MAX_RETRIES + 2): |
| |
| |
| if not objects_info: |
| break |
| if attempt > MAX_RETRIES and (first_pass_attempt is None or confirmation_used): |
| |
| |
| break |
| label = f"{attempt}/{MAX_RETRIES}" if attempt <= MAX_RETRIES else f"{attempt} (CONFIRMATION, beyond normal {MAX_RETRIES})" |
| print(f"\n########## ATTEMPT {label} ##########") |
|
|
| attempt_json_dir = os.path.join(json_dir, "attempts", f"attempt_{attempt}") |
| attempt_temp_dir = os.path.join(temp_dir, "attempts", f"attempt_{attempt}") |
| os.makedirs(attempt_json_dir, exist_ok=True) |
|
|
| |
| |
| |
| |
| images = [Image.open(rest_pose_image_path).convert("RGB")] |
| is_retry = attempt > 1 |
| for past in attempt_history: |
| images.extend(vlm_judge.load_keyframe_images(past["temp_dir"])) |
|
|
| prompt = build_combined_narrate_deform_prompt( |
| objects_info, caption, previous_narratives=previous_narratives, |
| feedback=feedback, is_retry=is_retry, few_shot=not args.no_fewshot, |
| attempt_history=attempt_history, |
| freeze_narrative=freeze_narrative, joint_feedback=joint_feedback, |
| baseline_targets=baseline_targets, interaction_constraints=interaction_constraints, |
| fewshot_shifted=args.fewshot_shifted, real_trajectories=real_trajectories_for_prompt) |
| |
| |
| |
| with open(os.path.join(attempt_json_dir, f"{name}_narrate_deform_prompt.txt"), "w") as f: |
| f.write(prompt) |
|
|
| |
| |
| |
| if temperature_schedule: |
| schedule_idx = min(attempt - 1, len(temperature_schedule) - 1) |
| this_attempt_temperature = temperature_schedule[schedule_idx] |
| else: |
| this_attempt_temperature = args.narrate_temperature |
|
|
| print(f"\n---------- STEP 2+3: NARRATE+DEFORM (attempt {attempt}, " |
| f"{len(images)} image{'s' if len(images) != 1 else ''}, " |
| f"temperature={this_attempt_temperature}" |
| f"{', narrative FROZEN' if freeze_narrative else ''}" |
| f"{', MULTI-VIEW' if args.generator_views else ''}) ----------") |
| |
| |
| if args.generator_views: |
| parsed_or_none, per_view_raws = run_combined_narrate_deform_multiview( |
| vlm_model, vlm_processor, images, prompt, temperature=this_attempt_temperature) |
| |
| |
| for view_i, raw in enumerate(per_view_raws): |
| with open(os.path.join(attempt_json_dir, f"{name}_narrate_deform_raw_response_view{view_i}.txt"), "w") as f: |
| f.write(raw) |
| response = json.dumps(parsed_or_none) if parsed_or_none is not None else per_view_raws[0] |
| with open(os.path.join(attempt_json_dir, f"{name}_narrate_deform_raw_response.txt"), "w") as f: |
| f.write(f"[AGGREGATED across {len(per_view_raws)} views]\n" + response) |
| if parsed_or_none is None: |
| print(f"FAILED TO PARSE: every one of {len(per_view_raws)} views failed\nraw: {per_view_raws}") |
| |
| |
| |
| feedback = "the previous attempt's output could not be parsed; produce valid JSON in the exact requested format" |
| all_attempts_summary.append({"attempt": attempt, "plausibility_score": None, "note": "narrate+deform parse failed"}) |
| continue |
| parsed = parsed_or_none |
| else: |
| response = run_combined_narrate_deform(vlm_model, vlm_processor, images, prompt, |
| temperature=this_attempt_temperature) |
| |
| |
| |
| |
| |
| |
| with open(os.path.join(attempt_json_dir, f"{name}_narrate_deform_raw_response.txt"), "w") as f: |
| f.write(response) |
| try: |
| parsed = parse_json_response(response) |
| except (ValueError, json.JSONDecodeError) as e: |
| print(f"FAILED TO PARSE: {e}\nraw: {response}") |
| |
| feedback = "the previous attempt's output could not be parsed; produce valid JSON in the exact requested format" |
| all_attempts_summary.append({"attempt": attempt, "plausibility_score": None, "note": "narrate+deform parse failed"}) |
| continue |
|
|
| narratives_this_attempt, reasoning_this_attempt, deform_outputs_this_attempt, cap_utilization_this_attempt = apply_deform_clip_and_write( |
| parsed, objects_info, attempt_json_dir, name, |
| frozen_narratives=previous_narratives if freeze_narrative else None) |
| with open(os.path.join(attempt_json_dir, f"{name}_narratives.json"), "w") as f: |
| json.dump(narratives_this_attempt, f, indent=2) |
| if reasoning_this_attempt: |
| with open(os.path.join(attempt_json_dir, f"{name}_reasoning.json"), "w") as f: |
| json.dump(reasoning_this_attempt, f, indent=2) |
|
|
| |
| |
| |
| |
| |
| print(f"\n---------- attempt {attempt}: actual joint targets per keyframe ----------") |
| for obj_name, obj_targets in deform_outputs_this_attempt.items(): |
| print(f" '{obj_name}':") |
| obj_reasoning = reasoning_this_attempt.get(obj_name) |
| for kf in range(N_KEYFRAMES): |
| kf_key = f"kf{kf}" |
| if kf_key in obj_targets: |
| print(f" {kf_key}: {obj_targets[kf_key]}") |
| |
| |
| |
| |
| |
| |
| if obj_reasoning and kf < len(obj_reasoning): |
| print(f" reasoning: {obj_reasoning[kf]}") |
|
|
| |
| |
| |
| |
| |
| |
| info = objects_info.get(obj_name, {}) |
| direction_problems = check_reasoning_direction_consistency( |
| obj_reasoning, obj_targets, info.get("joints", []), |
| info.get("part_names", {}), info.get("handle_idxs", [])) |
| for p in direction_problems: |
| print(f" REASONING/TARGET MISMATCH ('{obj_name}'): {p}") |
|
|
| print(f"\n---------- STEP 4: RENDER (attempt {attempt}, no Qwen) ----------") |
| run_render(attempt_json_dir, svg_path, semantic_path, traj_path, frames_dir=attempt_temp_dir, |
| show_handles=args.show_handles, objects_info=objects_info) |
|
|
| if args.deform_only: |
| print(f"\n########## --deform-only: STOPPING after attempt 1, no judge ##########") |
| print(f"cap_utilization (raw, unfiltered by any threshold):") |
| for obj, frac in (cap_utilization_this_attempt or {}).items(): |
| print(f" {obj}: {frac*100:.1f}% of allowed movement used") |
| print(f"\nInspect the actual render directly at: {attempt_temp_dir}") |
| print(f"(kf0.png ... kf4.png, plus the combined strip)") |
| sys.exit(0) |
|
|
| stagnation_result = None |
| if previous_temp_dir: |
| print(f"\n---------- STAGNATION CHECK (attempt {attempt} vs attempt {attempt - 1}) ----------") |
| prev_dino_images = vlm_judge.load_keyframe_images(previous_temp_dir) |
| curr_dino_images = vlm_judge.load_keyframe_images(attempt_temp_dir) |
| stagnation_result = dino_similarity.stagnation_score( |
| dino_model, dino_processor, prev_dino_images, curr_dino_images) |
| stagnant = dino_similarity.is_stagnant(stagnation_result) |
| print(f"mean attempt-to-attempt similarity: {stagnation_result['mean_similarity']:.4f} " |
| f"({'STAGNANT' if stagnant else 'changed'})") |
| consecutive_stagnant = consecutive_stagnant + 1 if stagnant else 0 |
|
|
| print(f"\n---------- TEMPORAL CONSISTENCY (attempt {attempt}, diagnostic only) ----------") |
| temporal_images = vlm_judge.load_keyframe_images(attempt_temp_dir) |
| temporal_result = dino_similarity.temporal_consistency(dino_model, dino_processor, temporal_images) |
|
|
| clip_result = None |
| if caption: |
| print(f"\n---------- CLIP SCORE (attempt {attempt}) ----------") |
| clip_images = vlm_judge.load_keyframe_images(attempt_temp_dir) |
| clip_result = clip_score.compute_sequence_clip_scores(clip_model, clip_processor, clip_images, caption) |
|
|
| print(f"\n---------- STEP 5: JUDGE (attempt {attempt}, images + joint/bbox coordinates) ----------") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| judge_images = [Image.open(rest_pose_image_path).convert("RGB")] |
| judge_images.extend(vlm_judge.load_keyframe_images(attempt_temp_dir)) |
|
|
| deformed_geometry_text = build_all_keyframes_deformed_geometry_text( |
| objects_info, deform_outputs_this_attempt) |
| |
| |
| |
| |
| |
| with open(os.path.join(attempt_json_dir, f"{name}_deformed_geometry_text.txt"), "w") as f: |
| f.write(deformed_geometry_text) |
|
|
| past_verdicts = [a for a in all_attempts_summary if "note" not in a] |
| judge_prompt = vlm_judge.build_judge_prompt( |
| name, caption=caption, dino_stagnation=stagnation_result, |
| dino_temporal=temporal_result, clip_scores=clip_result, |
| objects_info=objects_info, deform_outputs=deform_outputs_this_attempt, |
| real_trajectories=real_trajectories, all_object_names=all_object_names, |
| deformed_geometry_text=deformed_geometry_text, narratives=narratives_this_attempt, |
| past_verdicts=past_verdicts, interaction_constraints=interaction_constraints) |
| |
| |
| |
| with open(os.path.join(attempt_json_dir, f"{name}_judge_prompt.txt"), "w") as f: |
| f.write(judge_prompt) |
| judge_result, judge_raw_responses = vlm_judge.run_judge_voted( |
| vlm_model, vlm_processor, judge_images, judge_prompt, |
| n_votes=args.judge_votes, valid_arap_objects=set(objects_info.keys()), |
| deform_outputs=deform_outputs_this_attempt) |
| |
| |
| |
| |
| for vote_i, raw in enumerate(judge_raw_responses): |
| suffix = f"_vote{vote_i}" if len(judge_raw_responses) > 1 else "" |
| with open(os.path.join(attempt_json_dir, f"{name}_judge_raw_response{suffix}.txt"), "w") as f: |
| f.write(raw) |
|
|
| try: |
| |
| |
| |
| |
| |
| if isinstance(judge_result, dict): |
| verdict = judge_result |
| else: |
| verdict = vlm_judge.parse_judge_response(judge_result) |
| except (ValueError, json.JSONDecodeError) as e: |
| judge_response = judge_result if isinstance(judge_result, str) else judge_raw_responses[0] |
| print(f"JUDGE FAILED TO PARSE: {e}\nraw: {judge_response}") |
| print("Treating as a failed attempt, retrying without specific feedback.") |
| feedback = "the previous attempt's evaluation could not be parsed; try a clearer, more varied pose progression" |
| previous_narratives = narratives_this_attempt |
| previous_temp_dir = attempt_temp_dir |
| freeze_narrative = False |
| joint_feedback = None |
| |
| |
| |
| |
| baseline_targets = deform_outputs_this_attempt |
| attempt_history.append({ |
| "attempt": attempt, "temp_dir": attempt_temp_dir, |
| "deform_outputs": deform_outputs_this_attempt, "narratives": narratives_this_attempt, |
| "feedback": feedback, "scores": None, |
| "joint_feedback": None, |
| }) |
| all_attempts_summary.append({"attempt": attempt, "plausibility_score": None, "note": "judge parse failed"}) |
| continue |
|
|
| problems = vlm_judge.validate_judge_response( |
| verdict, valid_arap_objects=set(objects_info.keys()), |
| deform_outputs=deform_outputs_this_attempt) |
| print("\n--- JUDGE VERDICT ---") |
| print(json.dumps(verdict, indent=2)) |
| if problems: |
| for p in problems: |
| print(f" VALIDATION PROBLEM: {p}") |
|
|
| with open(os.path.join(attempt_json_dir, f"{name}_judge_verdict.json"), "w") as f: |
| json.dump(verdict, f, indent=2) |
|
|
| final_verdict = verdict |
| winning_attempt = attempt |
| score = verdict.get("plausibility_score") |
| faith_score = verdict.get("faithfulness_score") |
| quality_score = verdict.get("quality_score") |
| all_attempts_summary.append({"attempt": attempt, "plausibility_score": score, |
| "plausibility_notes": verdict.get("plausibility_notes"), |
| "faithfulness_score": faith_score, |
| "faithfulness_notes": verdict.get("faithfulness_notes"), |
| "quality_score": quality_score, |
| "quality_notes": verdict.get("quality_notes"), |
| "overall_verdict": verdict.get("overall_verdict"), |
| "dino_stagnant": dino_similarity.is_stagnant(stagnation_result) if stagnation_result else None}) |
|
|
| plausibility_ok = isinstance(score, (int, float)) and score >= PLAUSIBILITY_THRESHOLD |
| faithfulness_ok = faithfulness_passed(faith_score, notes=verdict.get("faithfulness_notes")) |
| quality_ok = isinstance(quality_score, (int, float)) and quality_score >= QUALITY_THRESHOLD |
| motion_checks_ok, motion_checks_per_check = motion_checks_passed(verdict.get("motion_checks")) |
| print(f"\nplausibility_score = {score} (threshold = {PLAUSIBILITY_THRESHOLD}, " |
| f"{'PASS' if plausibility_ok else 'FAIL'})") |
| print(f"faithfulness_score = {faith_score} (threshold = {FAITHFULNESS_THRESHOLD}, " |
| f"{'PASS' if faithfulness_ok else 'FAIL'})") |
| print(f"quality_score = {quality_score} (threshold = {QUALITY_THRESHOLD}, " |
| f"{'PASS' if quality_ok else 'FAIL'})") |
| print(f"motion_checks (threshold = {MOTION_CHECKS_THRESHOLD} each, ALL must pass): " |
| f"{'PASS' if motion_checks_ok else 'FAIL'}" |
| + ("" if motion_checks_ok else " β failed: " + |
| ", ".join(name for name, ok in motion_checks_per_check.items() if not ok))) |
|
|
| if plausibility_ok and faithfulness_ok and quality_ok and motion_checks_ok and not args.force_all_attempts: |
| if first_pass_attempt is None: |
| |
| |
| first_pass_attempt = attempt |
| first_pass_scores = (faith_score, score) |
| print(f"Attempt {attempt} passed all thresholds β running ONE confirmation attempt " |
| f"before finalizing, to see if it can be improved on. If the confirmation " |
| f"attempt is not better, attempt {attempt} is kept exactly as-is. (It also " |
| f"gets this attempt's real numeric targets as its baseline, same as any " |
| f"normal retry now would.)") |
| previous_narratives = narratives_this_attempt |
| previous_temp_dir = attempt_temp_dir |
| baseline_targets = deform_outputs_this_attempt |
| joint_feedback = vlm_judge.compute_joint_feedback_deltas( |
| verdict.get("joint_feedback"), deform_outputs_this_attempt) |
| attempt_history.append({ |
| "attempt": attempt, "temp_dir": attempt_temp_dir, |
| "deform_outputs": deform_outputs_this_attempt, "narratives": narratives_this_attempt, |
| "feedback": feedback, "scores": (faith_score, score, quality_score), |
| "joint_feedback": joint_feedback, |
| }) |
| |
| |
| |
| feedback = ("This attempt already passed all quality thresholds. This is an " |
| "OPTIONAL confirmation attempt: try to match or improve on it, but do " |
| "not discard what is already working.") |
| freeze_narrative = False |
| continue |
| else: |
| |
| confirmation_used = True |
| confirmation_scores = (faith_score, score) |
| if confirmation_scores > first_pass_scores: |
| print(f"Confirmation attempt {attempt} scored better " |
| f"(faithfulness={faith_score}, plausibility={score}) than the original " |
| f"passing attempt {first_pass_attempt} " |
| f"(faithfulness={first_pass_scores[0]}, plausibility={first_pass_scores[1]}) " |
| f"β using attempt {attempt} instead.") |
| winning_attempt = attempt |
| else: |
| print(f"Confirmation attempt {attempt} did NOT score better than the original " |
| f"passing attempt {first_pass_attempt} β keeping attempt {first_pass_attempt} " |
| f"as originally found, discarding the confirmation attempt.") |
| winning_attempt = first_pass_attempt |
| break |
|
|
| if first_pass_attempt is not None and not confirmation_used and not args.force_all_attempts: |
| |
| |
| |
| confirmation_used = True |
| print(f"Confirmation attempt {attempt} did not pass thresholds β keeping the original " |
| f"passing attempt {first_pass_attempt} as the final result.") |
| winning_attempt = first_pass_attempt |
| break |
|
|
| if consecutive_stagnant >= 2 and not args.force_all_attempts: |
| print(f"\nDINOv2 confirmed NO real change across {consecutive_stagnant} consecutive attempts " |
| f"(attempt {attempt} vs {attempt-1}, and {attempt-1} vs {attempt-2}) β further retries " |
| f"are very unlikely to help. Stopping early and keeping this attempt's result rather " |
| f"than burning through the remaining {MAX_RETRIES - attempt} attempts.") |
| break |
|
|
| if args.force_all_attempts and plausibility_ok and faithfulness_ok and quality_ok and motion_checks_ok: |
| print(f"Attempt {attempt} passed all thresholds, but --force-all-attempts is set β " |
| f"continuing to run every remaining attempt rather than stopping early. Best " |
| f"attempt across the FULL run will be selected at the end.") |
|
|
| previous_narratives = narratives_this_attempt |
| previous_temp_dir = attempt_temp_dir |
| |
| |
| |
| |
| |
| |
| baseline_targets = deform_outputs_this_attempt |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| joint_feedback = vlm_judge.compute_joint_feedback_deltas( |
| verdict.get("joint_feedback"), deform_outputs_this_attempt) |
| attempt_history.append({ |
| "attempt": attempt, "temp_dir": attempt_temp_dir, |
| "deform_outputs": deform_outputs_this_attempt, "narratives": narratives_this_attempt, |
| "feedback": feedback, "scores": (faith_score, score, quality_score), |
| "joint_feedback": joint_feedback, |
| }) |
| |
| |
| |
| freeze_narrative = faithfulness_ok |
|
|
| if attempt == MAX_RETRIES: |
| print("Below threshold on the final attempt β no retry left, skipping feedback construction.") |
| else: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| feedback = verdict.get( |
| "overall_verdict", |
| verdict.get("plausibility_notes", "the pose progression needs to look more plausible")) |
|
|
| |
| |
| |
| |
| |
| |
| if stagnation_result and dino_similarity.is_stagnant(stagnation_result): |
| feedback = (f"CRITICAL: your last attempt was measured as nearly IDENTICAL to the one " |
| f"before it (DINOv2 similarity {stagnation_result['mean_similarity']:.3f}) β " |
| f"you are NOT making real changes. You MUST produce substantially different " |
| f"target positions this time, not a superficial rewording. Original feedback: {feedback}") |
|
|
| print(f"Below threshold β retrying with feedback: {feedback!r} " |
| f"(narrative will be {'FROZEN' if freeze_narrative else 'regenerated'})") |
| |
| |
| |
| |
| |
| |
| if joint_feedback: |
| grounded = [f for f in joint_feedback if f.get("delta_px") is not None] |
| ungrounded = [f for f in joint_feedback if f.get("delta_px") is None] |
| print(f" joint_feedback being sent to next attempt: {len(grounded)} grounded " |
| f"(with computed delta_px), {len(ungrounded)} ungrounded") |
| for f in joint_feedback: |
| tag = "GROUNDED" if f.get("delta_px") is not None else "UNGROUNDED" |
| print(f" [{tag}] {f.get('object')}.joint_{f.get('joint')} @ kf{f.get('keyframe')}: " |
| f"{f.get('issue')} (delta_px={f.get('delta_px')})") |
| else: |
| print(" joint_feedback being sent to next attempt: none (empty or not returned by judge)") |
|
|
| else: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ranked = [e for e in attempt_history if e.get("scores") is not None] |
| if ranked: |
| def _rank_key(e): |
| faith, plaus, qual = e["scores"] |
| faith_key = faith if isinstance(faith, (int, float)) else -1 |
| return (faith_key, plaus) |
| best_entry = max(ranked, key=_rank_key) |
| winning_attempt = best_entry["attempt"] |
| label = "--force-all-attempts: ran all" if args.force_all_attempts else "Reached MAX_RETRIES without meeting thresholds β ran" |
| print(f"\n{label} {len(attempt_history)} attempts, best-scoring " |
| f"is attempt {winning_attempt} (faithfulness={best_entry['scores'][0]}, " |
| f"plausibility={best_entry['scores'][1]}, quality={best_entry['scores'][2]}).") |
| else: |
| print(f"\nRan {len(attempt_history)} attempts but NONE had a parseable verdict to " |
| f"rank β falling back to the last attempt's result.") |
|
|
| |
| |
| |
| if vlm_model is not None: |
| vlm_model = unload_model(vlm_model) |
|
|
| |
| |
| print("\n########## ATTEMPT SUMMARY ##########") |
| for a in all_attempts_summary: |
| print(f" attempt {a['attempt']}: plausibility={a.get('plausibility_score')} " |
| f"faithfulness={a.get('faithfulness_score')} quality={a.get('quality_score')} " |
| f"dino_stagnant={a.get('dino_stagnant')} " |
| f"β {a.get('plausibility_notes') or a.get('note', '')}") |
|
|
| if winning_attempt: |
| import shutil |
| winning_json = os.path.join(json_dir, "attempts", f"attempt_{winning_attempt}") |
| winning_temp = os.path.join(temp_dir, "attempts", f"attempt_{winning_attempt}") |
| for f in os.listdir(winning_json): |
| shutil.copy2(os.path.join(winning_json, f), os.path.join(json_dir, f)) |
| for f in os.listdir(winning_temp): |
| src = os.path.join(winning_temp, f) |
| if os.path.isfile(src): |
| shutil.copy2(src, os.path.join(temp_dir, f)) |
| print(f"\ncopied winning attempt ({winning_attempt}) to the top-level " |
| f"json/{run_label}/ and P_1/{run_label}/ locations") |
| print(f"all {len(all_attempts_summary)} attempts preserved under " |
| f"json/{run_label}/attempts/ and P_1/{run_label}/attempts/ for comparison") |
| else: |
| print("\nNo ARAP objects β skipping narrate/deform/judge entirely.") |
| temp_dir = os.path.join(args.out_dir, "P_1", run_label) |
| print("\n########## RENDER (no Qwen) ##########") |
| run_render(json_dir, svg_path, semantic_path, traj_path, frames_dir=temp_dir) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|