evoke / scripts /inference /postprocess_viz.py
multimodalart's picture
multimodalart HF Staff
Evoke text-to-video (t2v) demo: vendored engine + Gradio app
a8047d9 verified
Raw History Blame Contribute Delete
16.6 kB
"""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()