Files
PaperPod/paperpod/capture/video.py
T
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

92 lines
2.6 KiB
Python

"""Frame extraction from a pre-recorded video file."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
import cv2
import numpy as np
@dataclass
class VideoMeta:
path: str
fps: float
frame_count: int
duration_s: float
width: int
height: int
@dataclass
class Frame:
index: int # frame index in the source video (native fps)
t: float # timestamp in seconds
image: np.ndarray # BGR
def probe_video(path: str | Path) -> VideoMeta:
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Video not found: {path}")
cap = cv2.VideoCapture(str(path))
if not cap.isOpened():
raise ValueError(f"Could not open video (unsupported codec?): {path}")
try:
fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
finally:
cap.release()
if fps <= 0:
raise ValueError(f"Video reports invalid fps ({fps}): {path}")
return VideoMeta(
path=str(path),
fps=fps,
frame_count=frame_count,
duration_s=frame_count / fps if frame_count > 0 else 0.0,
width=width,
height=height,
)
def iter_frames(
path: str | Path,
sample_fps: float = 8.0,
resize_width: int | None = None,
) -> Iterator[Frame]:
"""Sequentially decode the video, yielding frames at ~sample_fps.
Decoding is sequential (no seeking) so it works reliably across codecs.
If resize_width is set, frames are downscaled to that width preserving
aspect ratio (used for motion analysis; crops use full resolution).
"""
meta = probe_video(path)
step = max(1, round(meta.fps / sample_fps))
cap = cv2.VideoCapture(str(path))
try:
index = 0
while True:
ok = cap.grab()
if not ok:
break
if index % step == 0:
ok, image = cap.retrieve()
if not ok:
break
if resize_width is not None and image.shape[1] > resize_width:
scale = resize_width / image.shape[1]
image = cv2.resize(
image,
(resize_width, max(1, round(image.shape[0] * scale))),
interpolation=cv2.INTER_AREA,
)
yield Frame(index=index, t=index / meta.fps, image=image)
index += 1
finally:
cap.release()