tiny-trigger / tests /test_video.py
Javier Montalvo
Decouple tracking from detection; size-relative motion; UI tuning
114ea19
Raw
History Blame Contribute Delete
9.84 kB
from __future__ import annotations
from pathlib import Path
from typing import Any
from tiny_trigger.models import ActionEvent, Detection
from tiny_trigger.video import _create_browser_mp4_writer, process_video, render_automation_video
class FakeDetector:
class_names = ["cat"]
def detect(
self,
frame: Any,
*,
frame_index: int,
timestamp_sec: float,
confidence: float,
image_size: int | None = None,
max_detections: int | None = None,
) -> list[Detection]:
assert image_size == 960
assert max_detections == 20
return [
Detection(
frame_index=frame_index,
timestamp_sec=timestamp_sec,
label="cat",
confidence=0.99,
bbox_xyxy=(2.0, 2.0, 12.0, 12.0),
bbox_xyxy_norm=(0.1, 0.1, 0.6, 0.6),
)
]
class TrackedDetector:
class_names = ["cat"]
def detect(
self,
frame: Any,
*,
frame_index: int,
timestamp_sec: float,
confidence: float,
image_size: int | None = None,
max_detections: int | None = None,
) -> list[Detection]:
offset = frame_index * 0.01
return [
Detection(
frame_index=frame_index,
timestamp_sec=timestamp_sec,
label="cat",
confidence=0.99,
bbox_xyxy=(2.0 + frame_index, 2.0, 12.0 + frame_index, 12.0),
bbox_xyxy_norm=(0.1 + offset, 0.1, 0.2 + offset, 0.2),
track_id=7,
)
]
class DuplicateDetector:
class_names = ["cat"]
def detect(
self,
frame: Any,
*,
frame_index: int,
timestamp_sec: float,
confidence: float,
image_size: int | None = None,
max_detections: int | None = None,
) -> list[Detection]:
return [
Detection(
frame_index=frame_index,
timestamp_sec=timestamp_sec,
label="cat",
confidence=0.62,
bbox_xyxy=(2.0, 2.0, 16.0, 16.0),
bbox_xyxy_norm=(0.1, 0.1, 0.5, 0.5),
),
Detection(
frame_index=frame_index,
timestamp_sec=timestamp_sec,
label="cat",
confidence=0.91,
bbox_xyxy=(3.0, 3.0, 17.0, 17.0),
bbox_xyxy_norm=(0.11, 0.11, 0.51, 0.51),
),
]
def test_process_video_with_fake_detector(tmp_path: Path) -> None:
cv2 = __import__("cv2")
video_path = _make_video(tmp_path)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=2,
max_frames=2,
image_size=960,
max_detections=20,
detector=FakeDetector(),
output_dir=str(tmp_path),
)
assert Path(result.output_video_path).exists()
assert result.processed_frames == 2
assert [item.frame_index for item in result.detections] == [0, 2]
assert result.frame_stride == 2
assert result.sample_interval_sec is None
capture = cv2.VideoCapture(result.output_video_path)
try:
assert capture.get(cv2.CAP_PROP_FRAME_COUNT) == 4
assert capture.get(cv2.CAP_PROP_FPS) == 10.0
finally:
capture.release()
def test_process_video_does_not_synthesize_track_ids(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=10.0, frames=4)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=1,
max_frames=3,
detector=FakeDetector(),
image_size=960,
max_detections=20,
output_dir=str(tmp_path),
)
assert [item.track_id for item in result.detections] == [None, None, None]
def test_process_video_preserves_detector_track_ids(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=10.0, frames=4)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=1,
max_frames=3,
detector=TrackedDetector(),
output_dir=str(tmp_path),
)
assert [item.track_id for item in result.detections] == [7, 7, 7]
def test_process_video_suppresses_duplicate_same_label_boxes(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=10.0, frames=2)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=1,
max_frames=1,
detector=DuplicateDetector(),
output_dir=str(tmp_path),
)
assert len(result.detections) == 1
assert result.detections[0].confidence == 0.91
def test_process_video_samples_once_per_second(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=30.0, frames=95)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=2,
sample_interval_sec=1.0,
max_frames=3,
image_size=960,
max_detections=20,
detector=FakeDetector(),
output_dir=str(tmp_path),
)
assert result.frame_stride == 30
assert result.sample_interval_sec == 1.0
assert [item.frame_index for item in result.detections] == [0, 30, 60]
def test_process_video_samples_half_second_intervals(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=10.0, frames=16)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
sample_interval_sec=0.5,
max_frames=3,
image_size=960,
max_detections=20,
detector=FakeDetector(),
output_dir=str(tmp_path),
)
assert result.frame_stride == 5
assert [item.frame_index for item in result.detections] == [0, 5, 10]
def test_render_automation_video_with_fired_event(tmp_path: Path) -> None:
video_path = _make_video(tmp_path)
detections = [
Detection(
frame_index=0,
timestamp_sec=0.0,
label="cat",
confidence=0.99,
bbox_xyxy=(2.0, 2.0, 12.0, 12.0),
bbox_xyxy_norm=(0.1, 0.1, 0.6, 0.6),
)
]
events = [
ActionEvent(
rule="cat-rule",
action="feed cat",
type="simulate",
frame_index=0,
timestamp_sec=0.0,
status="simulated",
)
]
output_path = render_automation_video(
source_video_path=str(video_path),
detections=detections,
events=events,
frame_stride=2,
max_frames=2,
output_dir=str(tmp_path),
)
assert Path(output_path).exists()
assert output_path.endswith("-automated.mp4")
def test_render_automation_video_uses_computed_stride(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=30.0, frames=95)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
sample_interval_sec=1.0,
max_frames=3,
image_size=960,
max_detections=20,
detector=FakeDetector(),
output_dir=str(tmp_path),
)
output_path = render_automation_video(
source_video_path=str(video_path),
detections=result.detections,
events=[],
frame_stride=result.frame_stride,
max_frames=3,
output_dir=str(tmp_path),
)
assert Path(output_path).exists()
def test_process_video_writes_full_motion_clip_with_sampled_overlays(tmp_path: Path) -> None:
cv2 = __import__("cv2")
video_path = _make_video(tmp_path, fps=5.0, frames=15)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=5,
max_frames=3,
image_size=960,
max_detections=20,
detector=FakeDetector(),
output_dir=str(tmp_path),
)
capture = cv2.VideoCapture(result.output_video_path)
try:
assert capture.get(cv2.CAP_PROP_FPS) == 5.0
assert capture.get(cv2.CAP_PROP_FRAME_COUNT) == 15
finally:
capture.release()
def test_process_video_writes_faststart_mp4(tmp_path: Path) -> None:
video_path = _make_video(tmp_path, fps=5.0, frames=6)
result = process_video(
video_path=str(video_path),
class_prompt="cat",
frame_stride=2,
max_frames=2,
image_size=960,
max_detections=20,
detector=FakeDetector(),
output_dir=str(tmp_path),
)
data = Path(result.output_video_path).read_bytes()
assert data.find(b"moov") < data.find(b"mdat")
def test_browser_mp4_writer_uses_mp4v_only(monkeypatch, tmp_path: Path) -> None:
calls: list[str] = []
class Writer:
def isOpened(self) -> bool:
return True
def release(self) -> None:
return None
class CV2:
@staticmethod
def VideoWriter_fourcc(*codec):
calls.append("".join(codec))
return 1234
@staticmethod
def VideoWriter(path, fourcc, fps, frame_size):
return Writer()
monkeypatch.setitem(__import__("sys").modules, "cv2", CV2)
writer = _create_browser_mp4_writer(tmp_path / "out.mp4", 8.0, (32, 32))
assert writer is not None
assert calls == ["mp4v"]
def _make_video(tmp_path: Path, *, fps: float = 10.0, frames: int = 4) -> Path:
cv2 = __import__("cv2")
video_path = tmp_path / "input.mp4"
writer = cv2.VideoWriter(str(video_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (32, 32))
for index in range(frames):
frame = __import__("numpy").zeros((32, 32, 3), dtype="uint8")
frame[:] = (index * 20) % 256
writer.write(frame)
writer.release()
return video_path