Spaces:
Running on Zero
Running on Zero
File size: 16,605 Bytes
a8047d9 | 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 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 | """Post-inference viz/encode for GEO inference (pred mp4 + sample frames + GT|Pred HUD).
These steps are CPU-bound (per-frame cv2 encode + GT decode + joystick overlay) and run
AFTER the GPU is done. Two modes:
- inline: import run_postprocess() and call it (legacy behaviour).
- detached: the GPU process dumps pred frames + params to disk, spawns this module as a
detached CPU-only worker, and exits — so the NEXT sample's GPU inference
overlaps this sample's encode (batch overlap). See infer_single.py
--bg_postprocess.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
REPO_ROOT = Path(__file__).resolve().parents[2] # scripts/inference/<this> -> repo root
if str(REPO_ROOT) not in sys.path: # so `import evoke` works when run directly
sys.path.insert(0, str(REPO_ROOT))
def _apply_joystick_hud(video_np: np.ndarray, p: dict) -> np.ndarray:
"""Draw the joystick/energy-ring HUD on a COPY of the pred frames (params decide; no-op if unset).
Returns video_np unchanged when the HUD is off or the pose is missing. Never mutates the input:
_write_combined builds the GT|Pred panels from the same array and draws its own HUD, so sharing
an overlaid buffer would double-draw there.
"""
npy = p.get("joystick_c2ws_npy")
if not npy or not Path(npy).is_file():
return video_np
try:
from evoke.utils.ev_validation import add_joystick_overlay_from_c2ws
c2ws = np.load(npy)
# pred frame 0 == first frame after the ref prefix (ref_pix = 0 for i2v/t2v)
s = int(p.get("joystick_ref_pix", 0))
e = min(s + int(video_np.shape[0]), int(c2ws.shape[0]))
if e <= s:
return video_np
n = e - s
out = add_joystick_overlay_from_c2ws(
list(video_np[:n]), c2ws[s:e],
move_scale=p.get("joystick_move_scale"), rot_scale=p.get("joystick_rot_scale"),
label_left="Move", label_right="Rot",
)
# a pose shorter than the video (replayed/extended tail) leaves a plain remainder
merged = np.stack(list(out) + list(video_np[n:])) if n < video_np.shape[0] else np.stack(list(out))
print(f"[postproc] joystick HUD drawn on {n}/{video_np.shape[0]} pred frames", flush=True)
return merged
except Exception as e: # the HUD is decoration; never lose the pred mp4 over it
print(f"[postproc] [WARN] joystick HUD skipped: {type(e).__name__}: {str(e)[:200]}", flush=True)
return video_np
def _write_pred_outputs(output_dir: Path, video_np: np.ndarray, fps: int = 24, params: dict = None) -> None:
"""Write geo_pred.mp4 + 5 sample frames (mirrors the legacy inline block)."""
# EVOKE_DUMP_PRED_PNG=1 -> also dump every pred frame as lossless png. Off by default; this is for
# pixel-level benchmarks (IQA / optical flow / reprojection all read the frames back), where the
# mp4v mp4 below is not a faithful source -- measured 32-35 dB PSNR, maxabs 70, on a still at
# 384x640. Dumped BEFORE the HUD, so the pngs are the model's own pixels either way.
if os.environ.get("EVOKE_DUMP_PRED_PNG", "0") == "1":
pf_dir = output_dir / "pred_frames"
pf_dir.mkdir(parents=True, exist_ok=True)
for i, f_rgb in enumerate(video_np):
# compress_level=1, not PIL's default 6: PNG is lossless at every level, so this changes
# only file size (~+25%) and is 2-3x faster to encode. With 8 shards each writing 177
# frames per case the deflate cost is on the critical path -- measured 144 s per case of
# which only ~50 s is the diffusion itself.
Image.fromarray(f_rgb).save(pf_dir / f"{i:05d}.png", compress_level=1)
print(f"[postproc] pred frames (lossless png): {pf_dir} ({len(video_np)} frames)", flush=True)
p = params or {}
def _encode(frames, path):
h, w = frames.shape[1], frames.shape[2]
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), (int(w), int(h)))
for f_rgb in frames:
writer.write(cv2.cvtColor(f_rgb, cv2.COLOR_RGB2BGR))
writer.release()
print(f"[postproc] pred mp4 saved: {path}", flush=True)
pred_mp4 = output_dir / "geo_pred.mp4"
if p.get("joystick_dual"):
# Two encodes off one generation: clean + overlaid. _apply_joystick_hud draws on a copy
# (add_joystick_overlay_from_c2ws copies every frame), so video_np stays the model's pixels.
# geo_pred.mp4 is written LAST on purpose: infer_batch.py uses it as the per-case DONE
# marker, so writing it first would let a resumed run skip a case whose _hud.mp4 never
# finished.
_encode(_apply_joystick_hud(video_np, p), output_dir / "geo_pred_hud.mp4")
_encode(video_np, pred_mp4)
else:
video_np = _apply_joystick_hud(video_np, p)
_encode(video_np, pred_mp4)
# 5 evenly spaced pngs, for eyeballing a case without opening the mp4. Nothing downstream reads
# them, so a scored sweep turns them off: 2.0 MB per 177-frame case, 1.6 MB per 537-frame one
# (~50 GB over a 3000-case WorldScore run plus a 6230-video VBench-Long run).
if os.environ.get("EVOKE_DUMP_SAMPLE_FRAMES", "1") == "1":
sf_dir = output_dir / "sample_frames"
sf_dir.mkdir(parents=True, exist_ok=True)
n = video_np.shape[0]
for i in [0, n // 4, n // 2, 3 * n // 4, n - 1]:
Image.fromarray(video_np[i]).save(sf_dir / f"pred_{i:03d}.png")
def _concat_chunk_mp4s(geo_dir: Path, suffix: str) -> list:
"""Read all geo_debug/chunk_*_<suffix>.mp4 in chunk order -> one full-length RGB frame list."""
frames: list = []
if not geo_dir.is_dir():
return frames
for mp4 in sorted(geo_dir.glob(f"chunk_*_{suffix}.mp4")):
cap = cv2.VideoCapture(str(mp4))
while True:
ret, bf = cap.read()
if not ret:
break
frames.append(cv2.cvtColor(bf, cv2.COLOR_BGR2RGB))
cap.release()
return frames
def _append_warp_mask_columns(output_dir: Path, combined: list) -> list:
"""Append Warp | WarpMask columns to the GT|Pred frames -> 4-way (GT|Pred|Warp|WarpMask).
Warp/mask are dumped per chunk (geo_debug/chunk_NNN_{warp,vis}.mp4); concatenate them across
chunks to full length and index-align with `combined`. Missing frames -> black panel.
"""
if not combined:
return combined
geo_dir = output_dir / "geo_debug"
warp = _concat_chunk_mp4s(geo_dir, "warp")
vis = _concat_chunk_mp4s(geo_dir, "vis")
if not warp and not vis:
return combined
H, W = combined[0].shape[:2]
pw = W // 2 # GT|Pred = 2 equal panels -> per-panel width
black = np.zeros((H, pw, 3), dtype=np.uint8)
def _fit(seq, i):
f = seq[i] if i < len(seq) else black
return cv2.resize(f, (pw, H)) if (f.shape[0] != H or f.shape[1] != pw) else f
out = []
for i, base in enumerate(combined):
parts = [base]
if warp:
parts.append(_fit(warp, i))
if vis:
parts.append(_fit(vis, i))
out.append(np.concatenate(parts, axis=1))
print(f"[postproc] appended warp/mask cols: warp={len(warp)}f vis={len(vis)}f "
f"-> {len(parts)} panels", flush=True)
return out
def _write_combined(output_dir: Path, video_np: np.ndarray, p: dict) -> None:
"""Build + write the GT|Pred|Warp|WarpMask side-by-side joystick-HUD video."""
from evoke.utils.ev_validation import combine_gt_pred_with_cam_viz # REPO_ROOT: module top
_t_combine = time.time()
print("[postproc] combining GT|Pred + joystick HUD ...", flush=True)
combined = combine_gt_pred_with_cam_viz(
pred_video=video_np,
gt_video_path=str(p["ref_video_for_viz"]),
pose_path=str(p["lingbot_pose_path"]),
height=int(p["height"]), width=int(p["width"]),
num_frames=int(p["num_frames"]),
ref_seconds=float(p["ref_seconds"]), start_seconds=float(p["start_seconds"]),
latent_window_size=9, vae_stride_t=4,
target_fps=24, source_fps=int(p["lingbot_pose_source_fps"]),
source_resolution=tuple(p["lingbot_pose_source_resolution"]),
pose_type=p["lingbot_pose_type"],
fallback_default_intrinsic=bool(p.get("lingbot_fallback_default_intrinsic", False)), # game npz has no intrinsics -> fall back to defaults
panel_label_gt="GT", panel_label_pred="Pred(GEO)" if p["use_geometric_state"] else "Pred",
)
# Append Warp | WarpMask columns (4-way, mirrors the per-chunk segment viz).
combined = _append_warp_mask_columns(output_dir, combined)
sbs_mp4 = output_dir / "gt_vs_pred_cam_viz.mp4"
H_out, W_out = combined[0].shape[:2]
_t_write = time.time()
w2 = cv2.VideoWriter(str(sbs_mp4), cv2.VideoWriter_fourcc(*"mp4v"), 24.0, (int(W_out), int(H_out)))
for f_rgb in combined:
w2.write(cv2.cvtColor(f_rgb, cv2.COLOR_RGB2BGR))
w2.release()
print(f"[postproc] GT|Pred mp4 saved: {sbs_mp4} ({len(combined)} frames, {H_out}x{W_out}) | "
f"combine {_t_write - _t_combine:.1f}s + write {time.time() - _t_write:.1f}s = "
f"{time.time() - _t_combine:.1f}s total", flush=True)
def _ffmpeg_concat(seg_paths: list, out_mp4: Path, list_txt: Path) -> bool:
"""ffmpeg concat demuxer + stream copy: joins segments that share encoder settings in constant
memory. Returns True on success."""
import subprocess
list_txt.write_text("".join(f"file '{p.resolve()}'\n" for p in seg_paths))
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(list_txt), "-c", "copy", str(out_mp4)]
try:
rc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT).returncode
except FileNotFoundError:
print("[assemble] ffmpeg unavailable -> falling back to per-frame cv2 re-encode", flush=True)
return False
if rc == 0 and out_mp4.is_file() and out_mp4.stat().st_size > 0:
return True
print(f"[assemble] ffmpeg -c copy failed (rc={rc}) -> falling back to per-frame cv2 re-encode", flush=True)
return False
def _cv2_concat(seg_paths: list, out_mp4: Path, fps: int) -> bool:
"""Fallback join: read every frame of every segment into a single writer. Constant memory
(one frame at a time) but re-encodes."""
writer = None
n_written = 0
for seg in seg_paths:
cap = cv2.VideoCapture(str(seg))
while True:
ret, bf = cap.read()
if not ret:
break
if writer is None:
h, w = bf.shape[:2]
writer = cv2.VideoWriter(str(out_mp4), cv2.VideoWriter_fourcc(*"mp4v"),
float(fps), (int(w), int(h)))
writer.write(bf)
n_written += 1
cap.release()
if writer is not None:
writer.release()
return n_written > 0
def _mp4_frame_count(mp4: Path) -> int:
cap = cv2.VideoCapture(str(mp4))
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
cap.release()
return n
def assemble_from_segments(output_dir, fps: int = 24, sample_n: int = 8) -> dict:
"""Streaming (very long video) finalization: stitch the full video from per-chunk segment mp4s
in constant memory.
Unlike run_postprocess, which needs the whole pred frame array in memory (60min@384x640 is
~64GB as uint8, ~254GB as fp32, i.e. certain OOM), this works at segment granularity:
segments/segment_*_pred.mp4 → geo_pred.mp4
segments/segment_*_gt_vs_pred.mp4 -> gt_vs_pred_cam_viz.mp4 (only the GT-covered prefix)
Prefers ffmpeg concat + stream copy (no re-encode, seconds); falls back to cv2 otherwise.
Sample frames are the first frame of a few segments (no long seeks).
"""
output_dir = Path(output_dir)
seg_dir = output_dir / "segments"
result = {"pred": None, "sbs": None, "n_pred_seg": 0, "n_sbs_seg": 0}
if not seg_dir.is_dir():
print(f"[assemble] [WARN] no segments directory: {seg_dir} - cannot stitch", flush=True)
return result
def _segments(kind: str) -> list:
"""Collect segments ordered by *numeric* chunk index, matching the suffix exactly.
Two traps: (1) glob 'segment_*_pred.mp4' also matches 'segment_000_gt_vs_pred.mp4'
(the 4-panel video, 4x wider), so an exact regex is required; (2) names are {idx:03d},
so past 999 they grow to 4 digits and lexicographic order puts segment_1000 before
segment_999 (hit by any 60min run = 2400 segments) - hence numeric sorting.
"""
pat = re.compile(rf"^segment_(\d+)_{re.escape(kind)}\.mp4$")
found = []
for p in seg_dir.iterdir():
m = pat.match(p.name)
if m and p.is_file() and p.stat().st_size > 0:
found.append((int(m.group(1)), p))
return [p for _, p in sorted(found, key=lambda t: t[0])]
jobs = [("pred", "pred", output_dir / "geo_pred.mp4"),
("sbs", "gt_vs_pred", output_dir / "gt_vs_pred_cam_viz.mp4")]
for key, kind, out_mp4 in jobs:
segs = _segments(kind)
result[f"n_{key}_seg"] = len(segs)
if not segs:
print(f"[assemble] segment_*_{kind}.mp4: 0 segments, skipping {out_mp4.name}", flush=True)
continue
t0 = time.time()
ok = _ffmpeg_concat(segs, out_mp4, seg_dir / f".concat_{key}.txt")
if not ok:
ok = _cv2_concat(segs, out_mp4, fps)
if ok:
n = _mp4_frame_count(out_mp4)
size_mb = out_mp4.stat().st_size / 1024**2
result[key] = out_mp4
print(f"[assemble] {out_mp4.name}: {len(segs)} segments -> {n} frames "
f"({n / max(fps, 1) / 60.0:.1f} min, {size_mb:.0f} MB, {time.time() - t0:.1f}s)", flush=True)
else:
print(f"[assemble] [WARN] {out_mp4.name} stitching failed", flush=True)
# Sample frames: first frame of evenly spaced pred segments (segment-level seek, constant cost).
# Same switch as in _write_pred_outputs -- off for scored sweeps, nothing reads them.
pred_segs = _segments("pred") if os.environ.get("EVOKE_DUMP_SAMPLE_FRAMES", "1") == "1" else []
if pred_segs:
sf_dir = output_dir / "sample_frames"
sf_dir.mkdir(parents=True, exist_ok=True)
step = max(1, len(pred_segs) // max(1, sample_n))
for seg in pred_segs[::step][:sample_n]:
cap = cv2.VideoCapture(str(seg))
ret, bf = cap.read()
cap.release()
if ret:
Image.fromarray(cv2.cvtColor(bf, cv2.COLOR_BGR2RGB)).save(
sf_dir / f"pred_{seg.stem.split('_')[1]}.png")
return result
def run_postprocess(output_dir, video_np, params: dict) -> None:
"""Write pred outputs + optional GT|Pred HUD. Safe to call inline or from the worker."""
output_dir = Path(output_dir)
_write_pred_outputs(output_dir, video_np, fps=int(params.get("fps", 24)), params=params)
if params.get("ref_video_for_viz") and params.get("lingbot_pose_path"):
try:
_write_combined(output_dir, video_np, params)
except Exception as e: # combine is best-effort; never lose the pred mp4 over it
print(f"[postproc] [WARN] combine_gt_pred_with_cam_viz failed: "
f"{type(e).__name__}: {str(e)[:200]}", flush=True)
def _main_cli() -> None:
"""Detached worker entry: read dumped frames + params, encode, clean up intermediates."""
out_dir = Path(sys.argv[1])
params = json.loads((out_dir / ".postproc_params.json").read_text())
npy = out_dir / ".pred_frames.npy"
video_np = np.load(npy)
print(f"[postproc] worker start: {out_dir} (pred {tuple(video_np.shape)})", flush=True)
try:
run_postprocess(out_dir, video_np, params)
finally:
# drop the heavy intermediates + release the concurrency-guard marker
for f in (npy, out_dir / ".postproc_params.json"):
try:
f.unlink()
except OSError:
pass
marker = params.get("_worker_marker")
if marker:
try:
Path(marker).unlink()
except OSError:
pass
print("[postproc] DONE.", flush=True)
if __name__ == "__main__":
_main_cli()
|