"""Render a task video into one contact sheet for visual review. The numeric check only reads final object poses, and it will pass an episode where the cup landed upside down or a gripper hung empty in the air. Looking at the frames is the only way to catch those, so every task gets a sheet before it is called done. """ import argparse, os import imageio.v2 as iio from PIL import Image, ImageDraw ap = argparse.ArgumentParser() ap.add_argument("videos", nargs="+") ap.add_argument("--out", default="outputs/sheets") ap.add_argument("--cols", type=int, default=6) ap.add_argument("--w", type=int, default=340) a = ap.parse_args() os.makedirs(a.out, exist_ok=True) for v in a.videos: if not os.path.exists(v): print(f"[sheet] MISSING {v}"); continue r = iio.get_reader(v) n = r.count_frames() idx = [min(n-1, int(n*f)) for f in [i/(a.cols-1) for i in range(a.cols)]] idx[0] = min(2, n-1) frames = [Image.fromarray(r.get_data(i)) for i in idx] fw, fh = frames[0].size h = int(a.w*fh/fw) sheet = Image.new("RGB", (a.w*len(frames), h), (20, 20, 22)) for k, f in enumerate(frames): sheet.paste(f.resize((a.w, h), Image.LANCZOS), (a.w*k, 0)) ImageDraw.Draw(sheet).text((a.w*k+6, h-16), f"{idx[k]}/{n}", fill=(255, 220, 60)) p = os.path.join(a.out, os.path.basename(v).replace(".mp4", ".png")) sheet.save(p) print(f"[sheet] {p} ({n} frames)")