modelbeast/server/operators/stereo_depth/run.py
type-two cf402e322c fix(stereo_depth): negative disparity window + percentile normalize — movie stereo works
Movie SBS converges on the screen plane, so real content sits at NEGATIVE
disparity; minDisparity=0 clipped everything behind convergence to black.
Window is now ±num_disp/2 with 2-98 percentile normalization and a validity
mask. Verified on Big Buck Bunny (half-OU) + One Night In Hell (half-SBS):
structured depth + colored clouds that render in Blender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 01:51:55 +10:00

113 lines
4.8 KiB
Python

"""stereo_depth — SBS stereoscopic video/frame → SGBM disparity maps + mid-frame point cloud.
True geometric depth from the built-in L/R parallax (cv2.StereoSGBM). ffmpeg (system)
decodes/splits; depth PNGs + preview mp4 + a colored .ply land as the outputs.
ponytail: uncalibrated disparity (SBS rips have no camera intrinsics) — z is relative,
perfect for Blender displace planes; not metric reconstruction.
"""
import argparse
import glob
import json
import os
import shutil
import subprocess
import sys
import tempfile
import zipfile
import cv2
import numpy as np
FFMPEG = shutil.which("ffmpeg") or "/opt/homebrew/bin/ffmpeg" # headless ssh has no brew PATH
ap = argparse.ArgumentParser()
ap.add_argument("--input", action="append", default=[])
ap.add_argument("--outdir", required=True)
ap.add_argument("--params", default="{}")
a = ap.parse_args()
p = json.loads(a.params)
if not a.input:
print("ERROR: no input")
sys.exit(1)
src = a.input[0]
outdir = a.outdir
max_frames = int(p.get("max_frames", 120))
num_disp = max(16, (int(p.get("num_disp", 128)) // 16) * 16)
block = int(p.get("block", 5)) | 1
tab = p.get("layout", "sbs") == "tab"
work = tempfile.mkdtemp(prefix="stereo_")
frames_dir = os.path.join(work, "frames")
depth_dir = os.path.join(work, "depth")
os.makedirs(frames_dir)
os.makedirs(depth_dir)
# decode → frames (a single image just becomes frame 1)
subprocess.run([FFMPEG, "-y", "-loglevel", "error", "-i", src,
"-frames:v", str(max_frames), os.path.join(frames_dir, "f%05d.png")], check=True)
frames = sorted(glob.glob(os.path.join(frames_dir, "*.png")))
print(f"[stereo] {len(frames)} frames, num_disp={num_disp} block={block}", flush=True)
min_disp = -(num_disp // 2) # movie stereo converges on the screen plane — content
sgbm = cv2.StereoSGBM_create( # lives BOTH sides of it, so the window must go negative
minDisparity=min_disp, numDisparities=num_disp, blockSize=block,
P1=8 * 3 * block * block, P2=32 * 3 * block * block,
disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2,
mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY)
mid = frames[len(frames) // 2]
for i, f in enumerate(frames):
img = cv2.imread(f)
h, w = img.shape[:2]
if tab:
L, R = img[: h // 2], img[h // 2:]
else:
L, R = img[:, : w // 2], img[:, w // 2:]
gl, gr = cv2.cvtColor(L, cv2.COLOR_BGR2GRAY), cv2.cvtColor(R, cv2.COLOR_BGR2GRAY)
disp = sgbm.compute(gl, gr).astype(np.float32) / 16.0
valid = disp > (min_disp - 0.5) # cv2 marks unmatched pixels min_disp-1
if valid.sum() > 100:
lo, hi = np.percentile(disp[valid], [2, 98])
else:
lo, hi = 0.0, 1.0
norm = np.zeros(disp.shape, np.uint8)
norm[valid] = (np.clip((disp[valid] - lo) / max(1e-6, hi - lo), 0, 1) * 255).astype(np.uint8)
cv2.imwrite(os.path.join(depth_dir, os.path.basename(f)), norm)
if f == mid: # colored point cloud of the middle frame
ys, xs = np.where(valid & (norm > 2))
step = max(1, len(ys) // 400_000) # cap cloud size
ys, xs = ys[::step], xs[::step]
z = disp[ys, xs]
rgb = L[ys, xs][:, ::-1] # BGR→RGB
ply = os.path.join(outdir, "midframe_cloud.ply")
with open(ply, "w") as fh:
fh.write("ply\nformat ascii 1.0\n"
f"element vertex {len(ys)}\n"
"property float x\nproperty float y\nproperty float z\n"
"property uchar red\nproperty uchar green\nproperty uchar blue\nend_header\n")
for (yy, xx, zz, (r, g, b)) in zip(ys, xs, z, rgb):
fh.write(f"{xx} {-yy} {zz:.1f} {r} {g} {b}\n")
print(f"[stereo] wrote midframe_cloud.ply ({len(ys)} pts)", flush=True)
if i % 24 == 0:
print(f"[stereo] frame {i + 1}/{len(frames)}", flush=True)
outputs = [{"path": "midframe_cloud.ply", "meta": {"tool": "stereo_sgbm"}}]
if len(frames) > 1: # depth preview video
prev = os.path.join(outdir, "depth_preview.mp4")
subprocess.run([FFMPEG, "-y", "-loglevel", "error", "-framerate", "24",
"-i", os.path.join(depth_dir, "f%05d.png"),
"-pix_fmt", "yuv420p", prev], check=True)
outputs.append({"path": "depth_preview.mp4", "meta": {"tool": "stereo_sgbm"}})
zpath = os.path.join(outdir, "depth_frames.zip")
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as zf:
for f in sorted(glob.glob(os.path.join(depth_dir, "*.png"))):
zf.write(f, os.path.basename(f))
outputs.append({"path": "depth_frames.zip", "meta": {"tool": "stereo_sgbm"}})
json.dump({"outputs": outputs}, open(os.path.join(outdir, "result.json"), "w"))
shutil.rmtree(work, ignore_errors=True)
print("done:", ", ".join(o["path"] for o in outputs), flush=True)