| 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 |
|
|