- 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
129 lines
4.8 KiB
Python
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)
|