File size: 13,333 Bytes
7399b6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
"""Agent-in-the-loop task pipeline: RUN -> EVALUATE (VLM) -> UPDATE POLICY -> PROPOSE new tasks.

The demo scripts already report a numeric EPISODE_RESULT, but that number only checks the final
object pose -- it cannot see that a cup landed upside-down, that a basket is sunk into the table,
or that the "lifted" object was actually dragged. So each run is also rendered to a contact sheet
and handed to a vision agent, and the agent's verdict is what drives the next iteration.

Three stages, each usable on its own:

  evaluate  render a contact sheet per video + emit the reviewer prompts, and record verdicts
  update    turn verdicts into concrete parameter edits for the next run of that task
  propose   suggest new tasks from the asset library that are unlike the ones already covered

    python scripts/yam_agent_loop.py evaluate --videos outputs/tasks
    python scripts/yam_agent_loop.py update   --verdicts outputs/verify/verdicts.json
    python scripts/yam_agent_loop.py propose  --n 10

The vision call itself is deliberately NOT hardcoded to one provider: `evaluate` writes a
`review_requests.json` that a harness (Claude Code subagents, or any VLM endpoint) consumes, and
`update` reads back a `verdicts.json`. That keeps credentials out of this repo -- nothing here
uploads your renders anywhere.
"""
from __future__ import annotations

import argparse
import json
import os
import subprocess
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
VERIFY = REPO/"outputs"/"verify"

# ---------------------------------------------------------------- knowledge learned so far
# Each entry: the visual symptom a reviewer can see -> the parameter that actually fixes it.
# These were all derived from real failures in this suite, not invented.
FIX_RULES = [
    {"symptom": "object upside down or lying on its side at the start",
     "cause": "RoboTwin GLBs are authored Y-up, so an identity spawn quaternion lands them rolled",
     "fix": "pass a +90 deg roll: --block_rpy 90,0,0 / --pot_rpy 90,0,0 / --container_rpy 90,0,0"},
    {"symptom": "container is enormous, fills the table, or object never reaches its rim",
     "cause": "the GLB->USD converter reads model_data scale but does not bake it in",
     "fix": "pass the asset's model_data scale explicitly via --container_scale"},
    {"symptom": "arm never moves; tracking error stays huge from the first frame",
     "cause": "a tall container was spawned on top of the arm's home pose and blocks it",
     "fix": "move the container away from the home eef (world ~(0.08,-0.19)) or shrink it"},
    {"symptom": "jaws close fully (finger separation -> 0) and the object stays put",
     "cause": "clamp force too high for a thin-walled vessel, or grasp height on a taper",
     "fix": "lower YAM_GRIP_EFFORT to ~40-55, or grasp at the rim with --grasp_top"},
    {"symptom": "object slips out mid-carry",
     "cause": "clamp force too low for a heavy/large object",
     "fix": "raise YAM_GRIP_EFFORT to ~75-85"},
    {"symptom": "jaws stall but the object does not move (contact with the wrong part)",
     "cause": "jaw axis parallel to a protruding handle instead of across it",
     "fix": "rotate the wrist: --jaw x (or --grip_tilt for an ear handle)"},
    {"symptom": "two-arm lift tilts to one side",
     "cause": "the mesh origin is not centred between the two handles",
     "fix": "give each arm its own offset: --half_y_l / --half_y_r"},
    {"symptom": "second object lands on the first and rolls out of the container",
     "cause": "both objects released at the same drop point",
     "fix": "spread the drop points along x (already automatic in yam_multi_pick)"},
    {"symptom": "descend error > ~4 cm, arm visibly short of the object",
     "cause": "target outside the reliable reach envelope",
     "fix": "move the object within ~0.35 m of the arm root at (-0.2, +-0.2)"},
    {"symptom": "object visibly dragged rather than lifted",
     "cause": "grasp height below the object centre, or grip on a narrowing taper",
     "fix": "re-seat the object on the table first, then grasp at its measured mid-height"},
]

REVIEW_PROMPT = """You are visually verifying a robot manipulation video.

Read the contact sheet {sheet} (7 frames sampled across the episode, left-to-right, top-to-bottom,
in time order). Judge from the IMAGES ONLY -- do not read logs.

Claimed task: {claim}

Report:
- VERDICT: CONFIRMED / PARTIAL / REFUTED
- what the final frames actually show (where did each object end up?)
- visual oddities: object upside-down or on its side, sunk into or floating above a surface,
  landed outside its container, gripper closed beside the object, one arm idle in a two-arm task,
  container tipped, arm contorted, object never left the table.
Be skeptical and concrete."""


def sheet_for(video: Path) -> Path:
    return VERIFY/f"{video.stem}.png"


def make_sheet(video: Path, n_tiles: int = 7) -> Path | None:
    """Render a contact sheet so a vision model can judge the episode from one image."""
    try:
        import imageio.v2 as iio
        from PIL import Image
    except ImportError:
        print("[loop] need imageio + pillow", file=sys.stderr); return None
    try:
        r = iio.get_reader(str(video)); n = r.count_frames()
    except Exception as e:
        print(f"[loop] cannot read {video}: {e}"); return None
    idx = [int(n*t) for t in [i/(n_tiles-1)*0.95+0.02 for i in range(n_tiles)]]
    tiles = [Image.fromarray(r.get_data(min(i, n-1))).resize((360, 272)) for i in idx]
    cols = 4; rows = (len(tiles)+cols-1)//cols
    sheet = Image.new("RGB", (360*cols, 272*rows))
    for k, im in enumerate(tiles):
        sheet.paste(im, (360*(k % cols), 272*(k//cols)))
    VERIFY.mkdir(parents=True, exist_ok=True)
    out = sheet_for(video)
    sheet.save(out)
    return out


def cmd_evaluate(args):
    """Render sheets and emit one review request per video for the vision agents."""
    videos = sorted(Path(args.videos).glob("*.mp4"))
    claims = {}
    cfile = REPO/"tasks"/"claims.json"
    if cfile.exists():
        claims = json.loads(cfile.read_text())
    requests = []
    for v in videos:
        s = make_sheet(v)
        if s is None:
            continue
        claim = claims.get(v.stem, f"the task named {v.stem}")
        requests.append({"video": str(v.relative_to(REPO)), "sheet": str(s.relative_to(REPO)),
                         "claim": claim,
                         "prompt": REVIEW_PROMPT.format(sheet=s.relative_to(REPO), claim=claim)})
        print(f"[loop] sheet {s.relative_to(REPO)}")
    out = VERIFY/"review_requests.json"
    out.write_text(json.dumps(requests, indent=2))
    print(f"\n[loop] {len(requests)} review request(s) -> {out.relative_to(REPO)}")
    print("[loop] hand each `prompt` to a vision agent, then write its verdict into "
          "outputs/verify/verdicts.json as {task: {verdict, notes}}")
    return 0


def suggest_fixes(notes: str) -> list[dict]:
    """Map a reviewer's free-text observation onto the parameter that actually fixes it."""
    low = notes.lower()
    hits = []
    for rule in FIX_RULES:
        key = rule["symptom"].split()[0:3]
        probes = {
            "upside": ["upside", "inverted", "on its side", "sideways", "tipped over"],
            "container": ["enormous", "huge", "too big", "fills the table", "oversized"],
            "arm": ["never moves", "does not move", "blocked", "stuck at home"],
            "jaws": ["closes fully", "closed beside", "closes beside", "misses the object", "no contact"],
            "object": ["slips", "dropped mid", "falls out", "drags", "dragged", "never left the table"],
            "two-arm": ["tilt", "tilted", "one side", "one arm idle"],
            "second": ["rolls out", "lands on the first", "bounced out", "outside the container"],
            "descend": ["short of", "out of reach", "cannot reach", "too far"],
        }.get(key[0], [])
        if any(p in low for p in probes):
            hits.append(rule)
    return hits


def cmd_update(args):
    """Turn reviewer verdicts into the concrete next action for each task."""
    vfile = Path(args.verdicts)
    if not vfile.exists():
        raise SystemExit(f"no verdicts at {vfile}; run `evaluate` and collect agent verdicts first")
    verdicts = json.loads(vfile.read_text())
    plan = {}
    for task, v in verdicts.items():
        verdict = str(v.get("verdict", "")).upper()
        notes = str(v.get("notes", ""))
        if verdict == "CONFIRMED":
            plan[task] = {"action": "keep", "why": "reviewer confirmed the task visually"}
            continue
        fixes = suggest_fixes(notes)
        plan[task] = {
            "action": "rerun_with_changes" if fixes else "needs_human_look",
            "verdict": verdict,
            "reviewer_notes": notes,
            "changes": [{"symptom": f["symptom"], "cause": f["cause"], "fix": f["fix"]} for f in fixes],
        }
    out = VERIFY/"update_plan.json"
    out.write_text(json.dumps(plan, indent=2))
    for task, p in plan.items():
        print(f"\n== {task}: {p['action']}")
        for c in p.get("changes", []):
            print(f"   - {c['symptom']}\n     -> {c['fix']}")
    print(f"\n[loop] plan -> {out.relative_to(REPO)}")
    return 0


# ---------------------------------------------------------------- task proposal
SKILL_AXES = ["pick-place", "insertion", "stacking", "semantic-selection", "tool-use",
              "bimanual-lift", "bimanual-handover", "pouring", "pushing", "sweeping",
              "hanging", "weighing", "constrained-pull"]


def covered_tasks() -> dict[str, list[str]]:
    out = {}
    tdir = REPO/"tasks"
    if not tdir.exists():
        return out
    try:
        import yaml
    except ImportError:
        return out
    for f in tdir.glob("*.yaml"):
        spec = yaml.safe_load(f.read_text())
        out[spec.get("name", f.stem)] = spec.get("tags", [])
    return out


def cmd_propose(args):
    """Propose new tasks that are UNLIKE what is already covered, from assets actually present."""
    usd_root = Path(os.environ.get("ROBOTWIN_USD", "/home/yu/internship_yu/robotwin_usd"))
    have = sorted({p.parent.name for p in usd_root.glob("*/*.usd")}) if usd_root.exists() else []
    covered = covered_tasks()
    used_tags = {t for tags in covered.values() for t in tags}
    gaps = [s for s in SKILL_AXES if s not in used_tags]

    catalogue = {
        "pouring": ("091_kettle", "002_bowl", "pour beads from the kettle into a bowl by rotating the wrist"),
        "sweeping": ("083_brush", "082_smallshovel", "sweep loose beads into a dustpan with the brush"),
        "weighing": ("035_apple", "072_electronicscale", "place the fruit onto the electronic scale"),
        "hanging": ("039_mug", "040_rack", "hang the mug on the rack"),
        "tool-use": ("117_whiteboard-eraser", None, "wipe a marked region with the eraser"),
        "constrained-pull": (None, None, "pull a wooden bar straight out of a slotted cube"),
        "bimanual-handover": ("110_basket", None, "left arm passes the basket to the right arm"),
        "pushing": (None, None, "push a block to a target region without grasping it"),
        "insertion": (None, None, "drop a ring over a standing post"),
        "stacking": ("108_block", None, "build a 3-block tower on a marked square"),
        "semantic-selection": ("006_hamburg", "110_basket", "put only the food items in the basket"),
        "bimanual-lift": ("060_kitchenpot", None, "both arms lift a wide object by its two handles"),
        "pick-place": ("021_cup", "062_plasticbox", "move the cup into a plastic box"),
    }

    print(f"[loop] {len(covered)} task(s) registered; {len(have)} RoboTwin categories converted")
    print(f"[loop] skill axes not yet covered: {', '.join(gaps) if gaps else '(none)'}\n")
    proposals = []
    for skill in (gaps or SKILL_AXES):
        obj, tgt, desc = catalogue.get(skill, (None, None, f"a {skill} task"))
        missing = [a for a in (obj, tgt) if a and a not in have]
        proposals.append({
            "skill": skill, "description": desc,
            "objects": [a for a in (obj, tgt) if a],
            "assets_ready": not missing,
            "convert_first": missing,
        })
        if len(proposals) >= args.n:
            break
    for p in proposals:
        flag = "" if p["assets_ready"] else f"  (convert first: {', '.join(p['convert_first'])})"
        print(f"  [{p['skill']}] {p['description']}{flag}")
    out = VERIFY/"proposals.json"
    VERIFY.mkdir(parents=True, exist_ok=True)
    out.write_text(json.dumps(proposals, indent=2))
    print(f"\n[loop] proposals -> {out.relative_to(REPO)}")
    return 0


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("evaluate"); p.add_argument("--videos", default=str(REPO/"outputs"/"tasks"))
    p = sub.add_parser("update"); p.add_argument("--verdicts", default=str(VERIFY/"verdicts.json"))
    p = sub.add_parser("propose"); p.add_argument("--n", type=int, default=10)
    args = ap.parse_args()
    return {"evaluate": cmd_evaluate, "update": cmd_update, "propose": cmd_propose}[args.cmd](args)


if __name__ == "__main__":
    sys.exit(main() or 0)