festifun/backend/tests/test_sfm.py
m3ultra bb542960c2 Lane B (M2 + M8 geometry): COLMAP reconstruction, geometry math, graceful degradation
Implements spec M2 (frame sampling, COLMAP orchestration, TXT-model parsing, scene
normalization, pose interpolation, PLY + DB export) and the M8 geometry functions.

geometry.py (M8): slerp_pose (shortest-arc, double-cover), ray_from_pixel (COLMAP
+y-down back-projection), triangulate_rays (closest-point + parallel guard),
nearest_point_on_ray (in-front radius cylinder). Frozen colmap_to_threejs untouched.

frames.py (M2): variance-of-Laplacian sharpness + windowed sharpest-frame sampling.

sfm.py (M2): hand-written COLMAP images/cameras/points3D parsers; normalize_scene
(centroid->0, camera sphere r->10, up->+Y) as one similarity transform over points+poses;
interpolate_poses (slerp+lerp, no extrapolation); build-aware COLMAP CLI orchestration
(3.x/4.x option detection, CPU SIFT, single-camera-per-folder, undistort->TXT, largest
component by images.bin header count); run_reconstruct with full graceful degradation
(COLMAP absent / <60% / <2 videos / no frames / missing files -> poses left untouched).

Tests: 61 pass (24 foundation + 37 lane-B) with independent oracles (scipy Slerp,
projection-inverse, closed-form geometry) and every degradation/DB-safety branch covered.
Validated end-to-end against real COLMAP 4.1.0 (6 components, largest selected, weak ->
graceful degradation, exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:40:46 +10:00

495 lines
22 KiB
Python

"""Tests for lane B: frames.py (sampling) + sfm.py (parsers, normalization,
interpolation, and the graceful-degradation reconstruct pipeline).
Everything here is verifiable without COLMAP: parsers run on hand-written TXT snippets, the
export path runs on a hand-built (synthetic-pose) COLMAP model injected in place of a real
COLMAP run, and the failure paths are exercised by monkeypatching. Spec M2 acceptance:
"on the synthetic fixture (which skips COLMAP and injects known poses) the whole export path
runs; if COLMAP is installed, the pipeline runs end-to-end without crashing."
"""
from __future__ import annotations
import struct
from pathlib import Path
import numpy as np
import pytest
from festival4d import config, db, frames, sfm, synthetic
from festival4d.geometry import quat_to_mat
# ===========================================================================
# COLMAP TXT parsers (hand-written snippets)
# ===========================================================================
IMAGES_TXT = """\
# Image list with two lines of data per image:
# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME
# POINTS2D[] as (X, Y, POINT3D_ID)
# Number of images: 2, mean observations per image: 2
1 0.9998 0.0 0.02 0.0 -1.5 0.3 8.0 1 1/1_0.jpg
480.0 270.0 5 500.1 260.2 -1
2 0.7071 0.0 0.7071 0.0 2.0 0.1 7.5 2 2/2_30.jpg
100.0 120.0 -1 300.0 200.0 5
"""
CAMERAS_TXT = """\
# Camera list with one line of data per camera:
# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]
# Number of cameras: 2
1 PINHOLE 640 360 500.0 500.0 320.0 180.0
2 OPENCV 640 360 510.0 511.0 321.0 181.0 0.01 -0.02 0.0 0.0
"""
POINTS3D_TXT = """\
# 3D point list with one line of data per point:
# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)
# Number of points: 2, mean track length: 2
5 1.0 2.0 3.0 200 100 50 0.5 1 0 2 1
9 -1.0 0.0 4.0 10 20 30 0.8 1 1
"""
def test_parse_images_txt(tmp_path):
p = tmp_path / "images.txt"
p.write_text(IMAGES_TXT)
recs = sfm.parse_images_txt(p)
assert len(recs) == 2
assert recs[0]["image_id"] == 1
assert recs[0]["camera_id"] == 1
assert recs[0]["name"] == "1/1_0.jpg"
np.testing.assert_allclose(
[recs[0]["qw"], recs[0]["qx"], recs[0]["qy"], recs[0]["qz"]],
[0.9998, 0.0, 0.02, 0.0])
np.testing.assert_allclose(
[recs[1]["tx"], recs[1]["ty"], recs[1]["tz"]], [2.0, 0.1, 7.5])
def test_parse_cameras_txt(tmp_path):
p = tmp_path / "cameras.txt"
p.write_text(CAMERAS_TXT)
cams = sfm.parse_cameras_txt(p)
assert set(cams) == {1, 2}
assert cams[1]["model"] == "PINHOLE"
assert (cams[1]["fx"], cams[1]["fy"], cams[1]["cx"], cams[1]["cy"]) == \
(500.0, 500.0, 320.0, 180.0)
# OPENCV: first 4 params are fx, fy, cx, cy; distortion ignored
assert (cams[2]["fx"], cams[2]["fy"], cams[2]["cx"], cams[2]["cy"]) == \
(510.0, 511.0, 321.0, 181.0)
def test_parse_points3d_txt(tmp_path):
p = tmp_path / "points3D.txt"
p.write_text(POINTS3D_TXT)
points, colors = sfm.parse_points3d_txt(p)
assert points.shape == (2, 3) and colors.shape == (2, 3)
np.testing.assert_allclose(points[0], [1.0, 2.0, 3.0])
assert tuple(colors[0]) == (200, 100, 50)
assert points.dtype == np.float32 and colors.dtype == np.uint8
def test_parse_points3d_empty(tmp_path):
p = tmp_path / "points3D.txt"
p.write_text("# header only\n")
points, colors = sfm.parse_points3d_txt(p)
assert points.shape == (0, 3) and colors.shape == (0, 3)
# ===========================================================================
# Scene normalization
# ===========================================================================
def _all_synth_poses():
poses = []
for i in range(3):
poses.extend(synthetic.camera_track(i, 20.0, 30, 640, 360))
return poses
def test_normalize_scene_invariants():
points, _ = synthetic.generate_point_cloud()
poses = _all_synth_poses()
npoints, nposes = sfm.normalize_scene(points.astype(float), poses)
# centroid at origin
assert np.linalg.norm(npoints.mean(axis=0)) < 1e-3
# camera bounding sphere (from origin = point centroid) has radius 10
centers = np.array([sfm._camera_center(p) for p in nposes])
assert abs(float(np.max(np.linalg.norm(centers, axis=1))) - 10.0) < 1e-6
# average camera-up aligns with +Y
ups = np.array([sfm._camera_up(p) for p in nposes])
up_avg = ups.mean(axis=0)
up_avg /= np.linalg.norm(up_avg)
np.testing.assert_allclose(up_avg, [0.0, 1.0, 0.0], atol=1e-6)
def test_normalize_scene_preserves_projection():
"""A world point projects to the same pixel before and after normalization
(a similarity transform of the world must not change any image)."""
points, _ = synthetic.generate_point_cloud()
poses = _all_synth_poses()
intr = synthetic.intrinsics(640, 360)
for p in poses:
p.update(intr)
X = np.array([0.7, 0.9, 0.3]) # arbitrary world point
def project(pose, Xw):
R = quat_to_mat([pose["qw"], pose["qx"], pose["qy"], pose["qz"]])
t = np.array([pose["tx"], pose["ty"], pose["tz"]])
xc = R @ Xw + t
return np.array([pose["fx"] * xc[0] / xc[2] + pose["cx"],
pose["fy"] * xc[1] / xc[2] + pose["cy"]])
npoints, nposes = sfm.normalize_scene(points.astype(float), poses)
# transform the same world point by X' = s*Rn@(X-c): recover s,Rn,c from the point map.
# Easier: the invariant is projection equality, so map X through the same transform used
# on the cloud by fitting it — instead, just check pixel equality using each pose pair.
# Reconstruct transform from three non-collinear cloud points is overkill; use the fact
# that projection is invariant, so compare original point's pixel to the transformed
# point's pixel where the transform is inferred from the point cloud centroid+scale.
c = points.astype(float).mean(axis=0)
centers = np.array([sfm._camera_center(p) for p in poses])
scale = 10.0 / float(np.max(np.linalg.norm(centers - c, axis=1)))
ups = np.array([sfm._camera_up(p) for p in poses])
up_avg = ups.mean(axis=0)
Rn = sfm._rotation_aligning(up_avg, np.array([0.0, 1.0, 0.0]))
Xn = scale * Rn @ (X - c)
for p_old, p_new in zip(poses, nposes):
np.testing.assert_allclose(project(p_old, X), project(p_new, Xn), atol=1e-6)
# ===========================================================================
# Pose interpolation
# ===========================================================================
def _pose(frame_idx, t, q, t3, registered=True):
return {"frame_idx": frame_idx, "t_video_s": t,
"qw": q[0], "qx": q[1], "qy": q[2], "qz": q[3],
"tx": t3[0], "ty": t3[1], "tz": t3[2],
"fx": 500.0, "fy": 500.0, "cx": 320.0, "cy": 180.0,
"registered": registered, "video_id": 1}
def test_interpolate_poses_fills_gaps_no_extrapolation():
q0 = [1.0, 0.0, 0.0, 0.0]
ang = np.radians(60.0)
q1 = [np.cos(ang / 2), 0.0, np.sin(ang / 2), 0.0] # 60 deg yaw about Y
registered = [_pose(0, 0.0, q0, [0, 0, 0]), _pose(60, 2.0, q1, [6, 0, 0])]
sampled = [{"frame_idx": f, "t_video_s": f / 30.0} for f in (0, 30, 60, 90)]
out = sfm.interpolate_poses(registered, sampled)
by_frame = {p["frame_idx"]: p for p in out}
assert set(by_frame) == {0, 30, 60} # frame 90 dropped (beyond last registered)
assert by_frame[30]["registered"] is False # interpolated
assert by_frame[0]["registered"] is True and by_frame[60]["registered"] is True
# midpoint translation is the lerp; rotation is ~30 deg
np.testing.assert_allclose(
[by_frame[30]["tx"], by_frame[30]["ty"], by_frame[30]["tz"]], [3, 0, 0], atol=1e-9)
mid_angle = 2.0 * np.degrees(np.arccos(min(1.0, abs(by_frame[30]["qw"]))))
assert abs(mid_angle - 30.0) < 1e-6
def test_interpolate_poses_empty_registered():
assert sfm.interpolate_poses([], [{"frame_idx": 0, "t_video_s": 0.0}]) == []
# ===========================================================================
# frames.py — sharpness + windowed sampling
# ===========================================================================
def test_sharpness_sharp_beats_blurred():
import cv2
rng = np.random.default_rng(0)
noise = (rng.integers(0, 256, size=(120, 120), dtype=np.uint8)) # high-frequency
flat = np.full((120, 120), 128, dtype=np.uint8) # no edges
blurred = cv2.GaussianBlur(noise, (0, 0), sigmaX=5)
assert frames.sharpness(noise) > frames.sharpness(blurred) > frames.sharpness(flat)
# accepts BGR too
bgr = cv2.cvtColor(noise, cv2.COLOR_GRAY2BGR)
assert frames.sharpness(bgr) > 0
def test_sample_frames_picks_sharpest_per_window(tmp_path):
import cv2
w = h = 128
fps = 30.0
n_frames = 30 # 1.0 s -> two 0.5 s windows
vid_path = tmp_path / "clip.mp4"
writer = cv2.VideoWriter(str(vid_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
if not writer.isOpened():
pytest.skip("no usable VideoWriter codec in this environment")
checker = np.indices((h, w)).sum(axis=0) % 2 # fine checkerboard -> sharp
sharp = (checker * 255).astype(np.uint8)
flat = np.full((h, w), 128, dtype=np.uint8)
sharp_frames = {7, 22}
for i in range(n_frames):
g = sharp if i in sharp_frames else flat
writer.write(cv2.cvtColor(g, cv2.COLOR_GRAY2BGR))
writer.release()
out = frames.sample_frames(vid_path, video_id=5, out_dir=tmp_path / "frames")
got_frames = sorted(int(Path(p).stem.split("_")[1]) for p in out)
# ground-truth: decode the written stream and take the per-window argmax ourselves
cap = cv2.VideoCapture(str(vid_path))
sh, idx = [], 0
while True:
ok, fr = cap.read()
if not ok:
break
sh.append(frames.sharpness(fr)); idx += 1
cap.release()
bucket = {}
for i, s in enumerate(sh):
win = int((i / fps) / 0.5)
if win not in bucket or s > bucket[win][0]:
bucket[win] = (s, i)
expected = sorted(v[1] for v in bucket.values())
assert got_frames == expected
assert all(Path(p).name.startswith("5_") for p in out)
# ===========================================================================
# run_reconstruct — graceful degradation + full export path (no real COLMAP)
# ===========================================================================
def _snapshot_poses():
return {
v.id: [(p.frame_idx, round(p.qw, 9), round(p.tx, 9), p.registered)
for p in db.get_poses(v.id)]
for v in db.get_videos()
}
def _write_colmap_model(model_dir: Path, records: list[dict], cameras: dict,
points, colors) -> None:
"""Write a COLMAP TXT model (images/cameras/points3D) from pose records."""
model_dir.mkdir(parents=True, exist_ok=True)
with open(model_dir / "images.txt", "w") as f:
f.write("# image list\n")
for i, r in enumerate(records, start=1):
f.write(f"{i} {r['qw']} {r['qx']} {r['qy']} {r['qz']} "
f"{r['tx']} {r['ty']} {r['tz']} {r['camera_id']} {r['name']}\n")
f.write("100.0 100.0 -1\n") # dummy points2D line
with open(model_dir / "cameras.txt", "w") as f:
f.write("# camera list\n")
for cid, cam in cameras.items():
f.write(f"{cid} PINHOLE {cam['width']} {cam['height']} "
f"{cam['fx']} {cam['fy']} {cam['cx']} {cam['cy']}\n")
with open(model_dir / "points3D.txt", "w") as f:
f.write("# point list\n")
for j, (p, c) in enumerate(zip(points, colors), start=1):
f.write(f"{j} {p[0]} {p[1]} {p[2]} {int(c[0])} {int(c[1])} {int(c[2])} 0.5 1 0\n")
def test_reconstruct_no_videos():
db.init_engine()
db.reset_db()
result = sfm.run_reconstruct()
assert result["status"] == "no_videos"
def test_reconstruct_skips_without_colmap(monkeypatch):
synthetic.build(run_ffmpeg=False)
monkeypatch.setattr(sfm, "colmap_available", lambda: False)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "skipped_no_colmap"
assert _snapshot_poses() == before # poses untouched
def _fake_sampling(monkeypatch, frames_per_video):
"""Make sampling hermetic: placeholder raw files + fake sample_frames output."""
for v in db.get_videos():
(config.RAW_DIR / v.filename).parent.mkdir(parents=True, exist_ok=True)
(config.RAW_DIR / v.filename).write_bytes(b"stub")
def fake_sample(video_path, video_id, out_dir, **kw):
return [Path(f"{video_id}_{f}.jpg") for f in frames_per_video]
monkeypatch.setattr(sfm.frames, "sample_frames", fake_sample)
monkeypatch.setattr(sfm, "colmap_available", lambda: True)
def test_reconstruct_failed_no_model(monkeypatch):
synthetic.build(run_ffmpeg=False)
_fake_sampling(monkeypatch, [0, 30, 60])
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: None)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "failed_no_model"
assert _snapshot_poses() == before # poses untouched
def test_reconstruct_failed_weak(monkeypatch, tmp_path):
synthetic.build(run_ffmpeg=False)
_fake_sampling(monkeypatch, [0, 30, 60]) # 3 videos x 3 = 9 sampled
# model registers only one image of one video -> ~11%, < 60% and < 2 videos
track = synthetic.camera_track(0, 20.0, 30, 640, 360)
rec = dict(track[0]); rec["camera_id"] = 1; rec["name"] = "1_0.jpg"
cams = {1: {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}}
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "weak"
_write_colmap_model(model_dir, [rec], cams, pts[:50], cols[:50])
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: model_dir)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "failed_weak"
assert _snapshot_poses() == before # poses untouched
def test_reconstruct_full_export_path(monkeypatch, tmp_path):
"""The whole export path runs on injected known poses (spec M2 acceptance)."""
synthetic.build(run_ffmpeg=False)
videos = db.get_videos()
id_by_cam = {i: videos[i].id for i in range(len(videos))} # cam index -> db id
sampled_frames = [0, 15, 30, 45, 60]
_fake_sampling(monkeypatch, sampled_frames)
# Build a model: videos for cam0/cam1 fully registered; cam2 registers a subset so
# interpolation must fill its gaps. All frame indices come from the synthetic tracks.
records, cameras = [], {}
reg_plan = {0: sampled_frames, 1: sampled_frames, 2: [0, 30, 60]}
for cam_idx, frame_list in reg_plan.items():
vid = id_by_cam[cam_idx]
cameras[vid] = {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}
track = {p["frame_idx"]: p for p in synthetic.camera_track(cam_idx, 20.0, 30, 640, 360)}
for f in frame_list:
p = track[f]
records.append({**p, "camera_id": vid, "name": f"{vid}_{f}.jpg"})
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "good"
_write_colmap_model(model_dir, records, cameras, pts, cols)
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: model_dir)
result = sfm.run_reconstruct()
assert result["status"] == "ok"
assert result["registered"] == 13 # 5 + 5 + 3
assert result["videos_in_model"] == 3
assert result["interpolated"] == 2 # cam2 frames 15 and 45
# cam2 poses: 5 total (3 registered + 2 interpolated), sorted, with interp flags
cam2_poses = db.get_poses(id_by_cam[2])
assert len(cam2_poses) == 5
assert sum(1 for p in cam2_poses if not p.registered) == 2
assert [p.frame_idx for p in cam2_poses] == [0, 15, 30, 45, 60]
# normalized point cloud written in the frozen PLY format; camera sphere radius ~ 10
assert config.POINTS_PLY.exists()
npoints, _ = synthetic.read_ply(config.POINTS_PLY)
assert len(npoints) == len(pts)
all_centers = []
for v in videos:
for p in db.get_poses(v.id):
R = quat_to_mat([p.qw, p.qx, p.qy, p.qz])
all_centers.append(-R.T @ np.array([p.tx, p.ty, p.tz]))
max_r = float(np.max(np.linalg.norm(np.array(all_centers), axis=1)))
assert abs(max_r - 10.0) < 0.5 # registered cams normalized to radius 10
def test_colmap_model_parser_roundtrip(tmp_path):
"""Writing then parsing a model recovers the poses/intrinsics/points."""
track = synthetic.camera_track(0, 20.0, 30, 640, 360)[:3]
records = [{**p, "camera_id": 1, "name": f"1_{p['frame_idx']}.jpg"} for p in track]
cams = {1: {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}}
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "rt"
_write_colmap_model(model_dir, records, cams, pts[:20], cols[:20])
images = sfm.parse_images_txt(model_dir / "images.txt")
cameras = sfm.parse_cameras_txt(model_dir / "cameras.txt")
ppoints, pcolors = sfm.parse_points3d_txt(model_dir / "points3D.txt")
assert len(images) == 3
np.testing.assert_allclose(images[0]["qw"], track[0]["qw"], atol=1e-6)
assert cameras[1]["fx"] == pytest.approx(synthetic.intrinsics(640, 360)["fx"])
assert len(ppoints) == 20
def test_parse_cameras_txt_truncated_params(tmp_path):
"""A camera line with too few PARAMS gives a clean ValueError, not a raw IndexError."""
p = tmp_path / "cameras.txt"
p.write_text("# header\n7 PINHOLE 640 360 500.0 500.0 320.0\n") # PINHOLE needs 4 params
with pytest.raises(ValueError, match="too few PARAMS"):
sfm.parse_cameras_txt(p)
def test_largest_model_dir_picks_by_registered_count(tmp_path):
"""Binary models are ranked by the images.bin header count, not file size."""
sparse = tmp_path / "sparse"
# model 0: 10 registered images but a LARGE images.bin (many keypoint observations)
(sparse / "0").mkdir(parents=True)
(sparse / "0" / "images.bin").write_bytes(struct.pack("<Q", 10) + b"\x00" * 5000)
# model 1: 15 registered images but a SMALL images.bin (few keypoints)
(sparse / "1").mkdir(parents=True)
(sparse / "1" / "images.bin").write_bytes(struct.pack("<Q", 15) + b"\x00" * 100)
assert sfm._registered_image_count(sparse / "1") == 15
assert sfm._largest_model_dir(sparse).name == "1" # more images wins despite smaller file
def test_interpolate_poses_drops_before_first_registered():
"""Lower no-extrapolation guard: a sampled frame before the first registered one is dropped."""
q = [1.0, 0.0, 0.0, 0.0]
registered = [_pose(30, 1.0, q, [3, 0, 0]), _pose(60, 2.0, q, [6, 0, 0])]
sampled = [{"frame_idx": f, "t_video_s": f / 30.0} for f in (0, 30, 45, 60)]
out = sfm.interpolate_poses(registered, sampled)
frames_out = {p["frame_idx"] for p in out}
assert 0 not in frames_out # tv=0 < t_first=1.0 -> dropped, no extrapolation
assert frames_out == {30, 45, 60}
by = {p["frame_idx"]: p for p in out}
assert by[45]["registered"] is False # interior gap still interpolated
def test_reconstruct_subset_leaves_others_untouched(monkeypatch, tmp_path):
"""When only a subset of videos registers, the rest keep their existing poses (DB safety)."""
synthetic.build(run_ffmpeg=False)
videos = db.get_videos()
id_by_cam = {i: videos[i].id for i in range(len(videos))}
sampled_frames = [0, 15, 30, 45, 60]
_fake_sampling(monkeypatch, sampled_frames)
# Register cam0 + cam1 fully (10/15 = 67% >= 60%, 2 videos -> ok); cam2 NOT in the model.
records, cameras = [], {}
for cam_idx in (0, 1):
vid = id_by_cam[cam_idx]
cameras[vid] = {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}
track = {p["frame_idx"]: p for p in synthetic.camera_track(cam_idx, 20.0, 30, 640, 360)}
for f in sampled_frames:
records.append({**track[f], "camera_id": vid, "name": f"{vid}_{f}.jpg"})
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "subset"
_write_colmap_model(model_dir, records, cameras, pts, cols)
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: model_dir)
cam2_before = [(p.frame_idx, p.qw, p.tx, p.registered) for p in db.get_poses(id_by_cam[2])]
result = sfm.run_reconstruct()
assert result["status"] == "ok"
assert result["videos_in_model"] == 2
# cam2 absent from the model: its rows must be byte-identical afterward
cam2_after = [(p.frame_idx, p.qw, p.tx, p.registered) for p in db.get_poses(id_by_cam[2])]
assert cam2_after == cam2_before
assert len(cam2_before) == 41 # original synthetic poses intact
assert len(db.get_poses(id_by_cam[0])) == 5 # cam0 replaced with sampled frames
def test_reconstruct_no_frames_when_videos_missing(monkeypatch):
"""All raw files absent -> videos skipped -> no_frames, poses untouched."""
synthetic.build(run_ffmpeg=False)
for f in config.RAW_DIR.glob("*.mp4"): # clear stubs left by other tests
f.unlink()
monkeypatch.setattr(sfm, "colmap_available", lambda: True)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "no_frames"
assert _snapshot_poses() == before
def test_reconstruct_no_frames_empty_sampling(monkeypatch):
"""Raw files exist but sampling yields nothing -> no_frames, poses untouched."""
synthetic.build(run_ffmpeg=False)
_fake_sampling(monkeypatch, []) # sample_frames returns []
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "no_frames"
assert _snapshot_poses() == before