PaperPod/sample_data/make_sample_video.py
ilia 6a78c84bcd Initial scaffold: capture + vision modules with detect CLI
- capture/: frame sampling at configurable fps, ffmpeg audio extraction (WAV, whisper-ready)
- vision/: changed-pixel motion scoring, stable/moving segmentation, contour + perspective document detection, Laplacian sharpness scoring, optional CLAHE enhancement
- pipeline: two-pass detect (motion timeline, then best-frame crop per stable window) writing crops, debug frames, report.json, and motion_scores.csv
- CLI: probe / detect / extract-audio subcommands
- config.yaml with tunable thresholds; placeholder packages for events, transcribe, ocr, naming, pdf, review_cli
- synthetic sample video generator + 16 unit tests
2026-07-07 16:24:46 -04:00

129 lines
4.8 KiB
Python

"""Generate a synthetic overhead-table test video for pipeline verification.
Timeline (default 24s @ 24fps, 1280x720):
0-3s empty table
3-4s hand slides receipt 1 in (motion)
4-9s receipt 1 stable
9-10s receipt 1 removed (motion)
10-12s empty table
12-13s document 2 placed (motion)
13-17s document 2 page 1 stable
17-18s page flip (content changes, hand passes over)
18-22s document 2 page 2 stable
22-23s document 2 removed
23-24s empty table
Usage: python sample_data/make_sample_video.py [output.mp4]
"""
from __future__ import annotations
import sys
from pathlib import Path
import cv2
import numpy as np
FPS = 24
W, H = 1280, 720
RNG = np.random.default_rng(42)
def table_background() -> np.ndarray:
"""Wood-ish brown table with mild noise so it isn't perfectly uniform."""
bg = np.full((H, W, 3), (60, 90, 120), dtype=np.uint8) # BGR brownish
noise = RNG.integers(-8, 8, size=(H, W, 1), dtype=np.int16)
return np.clip(bg.astype(np.int16) + noise, 0, 255).astype(np.uint8)
def make_document(w: int, h: int, lines: int, seed: int) -> np.ndarray:
"""White page with dark text-like lines."""
rng = np.random.default_rng(seed)
doc = np.full((h, w, 3), 245, dtype=np.uint8)
y = int(h * 0.12)
for _ in range(lines):
x0 = int(w * 0.1)
x1 = int(w * rng.uniform(0.5, 0.9))
cv2.line(doc, (x0, y), (x1, y), (30, 30, 30), thickness=max(2, h // 80))
y += int(h * 0.08)
if y > h * 0.92:
break
cv2.rectangle(doc, (0, 0), (w - 1, h - 1), (180, 180, 180), 2)
return doc
def paste_rotated(canvas: np.ndarray, doc: np.ndarray, center: tuple[int, int], angle: float) -> None:
"""Paste doc onto canvas rotated by angle degrees around its center."""
dh, dw = doc.shape[:2]
big = np.zeros((H, W, 3), dtype=np.uint8)
mask = np.zeros((H, W), dtype=np.uint8)
x0, y0 = center[0] - dw // 2, center[1] - dh // 2
x0 = max(0, min(W - dw, x0))
y0 = max(0, min(H - dh, y0))
big[y0 : y0 + dh, x0 : x0 + dw] = doc
mask[y0 : y0 + dh, x0 : x0 + dw] = 255
matrix = cv2.getRotationMatrix2D((x0 + dw / 2, y0 + dh / 2), angle, 1.0)
big = cv2.warpAffine(big, matrix, (W, H))
mask = cv2.warpAffine(mask, matrix, (W, H))
canvas[mask > 0] = big[mask > 0]
def draw_hand(canvas: np.ndarray, t01: float, entering: bool) -> None:
"""A skin-toned blob sweeping across the frame to create motion."""
progress = t01 if entering else 1.0 - t01
cx = int(W * (1.1 - progress * 0.6))
cy = int(H * 0.5 + 60 * np.sin(progress * np.pi))
cv2.ellipse(canvas, (cx, cy), (110, 70), 20, 0, 360, (120, 150, 200), -1)
def render(out_path: Path) -> None:
receipt = make_document(280, 520, lines=10, seed=1)
page1 = make_document(560, 420, lines=8, seed=2)
page2 = make_document(560, 420, lines=8, seed=3)
bg = table_background()
writer = cv2.VideoWriter(
str(out_path), cv2.VideoWriter_fourcc(*"mp4v"), FPS, (W, H)
)
duration = 24
for i in range(duration * FPS):
t = i / FPS
frame = bg.copy()
if 3 <= t < 4: # receipt entering
draw_hand(frame, t - 3, entering=True)
paste_rotated(frame, receipt, (int(W * (1.2 - (t - 3) * 0.7)), 360), 4)
elif 4 <= t < 9: # receipt stable
paste_rotated(frame, receipt, (int(W * 0.5), 360), 4)
elif 9 <= t < 10: # receipt leaving
paste_rotated(frame, receipt, (int(W * (0.5 - (t - 9) * 0.7)), 360), 4)
draw_hand(frame, t - 9, entering=False)
elif 12 <= t < 13: # doc2 entering
draw_hand(frame, t - 12, entering=True)
paste_rotated(frame, page1, (int(W * (1.2 - (t - 12) * 0.7)), 360), -3)
elif 13 <= t < 17: # doc2 page 1 stable
paste_rotated(frame, page1, (int(W * 0.5), 360), -3)
elif 17 <= t < 18: # page flip
paste_rotated(frame, page1 if t < 17.5 else page2, (int(W * 0.5), 360), -3)
draw_hand(frame, (t - 17), entering=t < 17.5)
elif 18 <= t < 22: # doc2 page 2 stable
paste_rotated(frame, page2, (int(W * 0.5), 360), -3)
elif 22 <= t < 23: # doc2 leaving
paste_rotated(frame, page2, (int(W * (0.5 - (t - 22) * 0.7)), 360), -3)
draw_hand(frame, t - 22, entering=False)
# else: empty table
# Slight per-frame sensor noise so "stable" is realistic, not identical.
noise = RNG.integers(-2, 2, size=(H, W, 1), dtype=np.int16)
frame = np.clip(frame.astype(np.int16) + noise, 0, 255).astype(np.uint8)
writer.write(frame)
writer.release()
print(f"Wrote {out_path} ({duration}s @ {FPS}fps, {W}x{H})")
if __name__ == "__main__":
default = Path(__file__).parent / "videos" / "synthetic_sample.mp4"
out = Path(sys.argv[1]) if len(sys.argv) > 1 else default
out.parent.mkdir(parents=True, exist_ok=True)
render(out)