| from __future__ import annotations |
|
|
| import hashlib |
| import logging |
| import shutil |
| import subprocess |
| import tempfile |
| import time |
| from pathlib import Path |
| from typing import Callable |
| from uuid import uuid4 |
|
|
| from .detector import Detector, UltralyticsYOLOEDetector, parse_class_prompt, suppress_duplicate_detections |
| from .models import ActionEvent, Detection, FrameSample, VideoProcessResult |
|
|
|
|
| ProgressCallback = Callable[[int, int | None], None] |
| MP4_CODEC = "mp4v" |
| LOGGER = logging.getLogger(__name__) |
|
|
|
|
| def process_video( |
| *, |
| video_path: str, |
| class_prompt: str | list[str], |
| confidence: float = 0.25, |
| frame_stride: int = 5, |
| sample_interval_sec: float | None = None, |
| max_frames: int = 120, |
| model_name: str = "yoloe-26s-seg.pt", |
| image_size: int | None = None, |
| device: str | None = None, |
| max_detections: int | None = None, |
| tracking_enabled: bool = False, |
| detector: Detector | None = None, |
| output_dir: str | None = None, |
| progress: ProgressCallback | None = None, |
| ) -> VideoProcessResult: |
| """Sample a video, run open-vocabulary detection, and write an annotated clip.""" |
| try: |
| import cv2 |
| except ImportError as exc: |
| raise RuntimeError("Install opencv-python-headless to process videos.") from exc |
|
|
| classes = parse_class_prompt(class_prompt) |
| if not classes: |
| raise ValueError("At least one class prompt is required.") |
| if frame_stride < 1: |
| raise ValueError("frame_stride must be at least 1.") |
| if sample_interval_sec is not None and sample_interval_sec <= 0: |
| raise ValueError("sample_interval_sec must be greater than 0.") |
| if max_frames < 1: |
| raise ValueError("max_frames must be at least 1.") |
| if image_size is not None and image_size < 32: |
| raise ValueError("image_size must be at least 32.") |
| if max_detections is not None and max_detections < 1: |
| raise ValueError("max_detections must be at least 1.") |
|
|
| if detector is None: |
| LOGGER.info( |
| "Loading detector model=%s classes=%s device=%s tracking=%s", |
| model_name, |
| ", ".join(classes), |
| device or "auto", |
| tracking_enabled, |
| ) |
| detector_started = time.perf_counter() |
| detector = UltralyticsYOLOEDetector( |
| class_names=classes, |
| model_name=model_name, |
| device=device or None, |
| tracking_enabled=tracking_enabled, |
| ) |
| LOGGER.info("Detector loaded in %.2fs", time.perf_counter() - detector_started) |
| capture = cv2.VideoCapture(video_path) |
| if not capture.isOpened(): |
| raise ValueError(f"Could not open video: {video_path}") |
|
|
| source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 30.0) |
| effective_frame_stride = _sampling_frame_stride( |
| source_fps=source_fps, |
| frame_stride=frame_stride, |
| sample_interval_sec=sample_interval_sec, |
| ) |
| width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) |
| height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) |
| output_fps = source_fps |
| output_size = _browser_frame_size(width, height) |
| output_path = _output_path(video_path, output_dir) |
| writer = _create_browser_mp4_writer(output_path, output_fps, output_size) |
| if writer is None: |
| capture.release() |
| raise ValueError(f"Could not create annotated video: {output_path}") |
|
|
| detections: list[Detection] = [] |
| frames: list[FrameSample] = [] |
| processed_frames = 0 |
| frame_index = -1 |
| latest_detections: list[Detection] = [] |
| LOGGER.info( |
| "Processing video=%s fps=%.2f size=%sx%s sample_stride=%s max_frames=%s", |
| video_path, |
| source_fps, |
| width, |
| height, |
| effective_frame_stride, |
| max_frames, |
| ) |
| try: |
| while True: |
| ok, frame = capture.read() |
| if not ok: |
| break |
| frame_index += 1 |
| if frame_index % effective_frame_stride == 0: |
| if processed_frames >= max_frames: |
| break |
| timestamp_sec = frame_index / source_fps |
| frames.append(FrameSample(frame_index=frame_index, timestamp_sec=timestamp_sec)) |
| LOGGER.info( |
| "Detecting sampled frame %s/%s source_frame=%s timestamp=%.2fs", |
| processed_frames + 1, |
| max_frames, |
| frame_index, |
| timestamp_sec, |
| ) |
| detect_started = time.perf_counter() |
| frame_detections = detector.detect( |
| frame.copy(), |
| frame_index=frame_index, |
| timestamp_sec=timestamp_sec, |
| confidence=confidence, |
| image_size=image_size, |
| max_detections=max_detections, |
| ) |
| frame_detections = suppress_duplicate_detections(frame_detections) |
| latest_detections = frame_detections |
| detections.extend(latest_detections) |
| processed_frames += 1 |
| tracked_count = sum(1 for detection in latest_detections if detection.track_id is not None) |
| LOGGER.info( |
| "Detected sampled frame %s/%s in %.2fs: detections=%s tracked=%s", |
| processed_frames, |
| max_frames, |
| time.perf_counter() - detect_started, |
| len(latest_detections), |
| tracked_count, |
| ) |
| if progress: |
| progress(processed_frames, max_frames) |
|
|
| _draw_detections(frame, latest_detections) |
| _write_frame(writer, _fit_frame_to_output(frame, output_size)) |
| finally: |
| writer.release() |
| capture.release() |
| LOGGER.info("Finalizing annotated video %s", output_path) |
| _finalize_browser_mp4(output_path) |
| LOGGER.info( |
| "Finished video processing: sampled_frames=%s detections=%s output=%s", |
| processed_frames, |
| len(detections), |
| output_path, |
| ) |
|
|
| return VideoProcessResult( |
| output_video_path=str(output_path), |
| classes=classes, |
| detections=detections, |
| frames=frames, |
| processed_frames=processed_frames, |
| source_fps=source_fps, |
| output_fps=output_fps, |
| frame_stride=effective_frame_stride, |
| sample_interval_sec=sample_interval_sec, |
| ) |
|
|
|
|
| def render_automation_video( |
| *, |
| source_video_path: str, |
| detections: list[Detection], |
| events: list[ActionEvent], |
| frame_stride: int, |
| sample_interval_sec: float | None = None, |
| max_frames: int, |
| output_dir: str | None = None, |
| ) -> str: |
| """Render detections plus fired automation events without rerunning inference.""" |
| try: |
| import cv2 |
| except ImportError as exc: |
| raise RuntimeError("Install opencv-python-headless to render videos.") from exc |
|
|
| if frame_stride < 1: |
| raise ValueError("frame_stride must be at least 1.") |
| if sample_interval_sec is not None and sample_interval_sec <= 0: |
| raise ValueError("sample_interval_sec must be greater than 0.") |
| if max_frames < 1: |
| raise ValueError("max_frames must be at least 1.") |
|
|
| capture = cv2.VideoCapture(source_video_path) |
| if not capture.isOpened(): |
| raise ValueError(f"Could not open video: {source_video_path}") |
|
|
| source_fps = float(capture.get(cv2.CAP_PROP_FPS) or 30.0) |
| effective_frame_stride = _sampling_frame_stride( |
| source_fps=source_fps, |
| frame_stride=frame_stride, |
| sample_interval_sec=sample_interval_sec, |
| ) |
| width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0) |
| height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0) |
| output_fps = source_fps |
| output_size = _browser_frame_size(width, height) |
| output_path = _output_path(source_video_path, output_dir, suffix="automated") |
| writer = _create_browser_mp4_writer(output_path, output_fps, output_size) |
| if writer is None: |
| capture.release() |
| raise ValueError(f"Could not create automation video: {output_path}") |
|
|
| detections_by_frame = _group_detections_by_frame(detections) |
| events_by_frame = _group_events_by_frame(events) |
| processed_frames = 0 |
| frame_index = -1 |
| latest_detections: list[Detection] = [] |
| latest_events: list[ActionEvent] = [] |
| LOGGER.info( |
| "Rendering automation video=%s fps=%.2f sample_stride=%s max_frames=%s", |
| source_video_path, |
| source_fps, |
| effective_frame_stride, |
| max_frames, |
| ) |
| try: |
| while True: |
| ok, frame = capture.read() |
| if not ok: |
| break |
| frame_index += 1 |
| if frame_index % effective_frame_stride == 0: |
| if processed_frames >= max_frames: |
| break |
| latest_detections = detections_by_frame.get(frame_index, []) |
| latest_events = events_by_frame.get(frame_index, []) |
| processed_frames += 1 |
|
|
| _draw_detections(frame, latest_detections) |
| if latest_events: |
| _draw_action_events(frame, latest_events) |
| _write_frame(writer, _fit_frame_to_output(frame, output_size)) |
| finally: |
| writer.release() |
| capture.release() |
| LOGGER.info("Finalizing automation video %s", output_path) |
| _finalize_browser_mp4(output_path) |
| LOGGER.info("Finished automation render: output=%s", output_path) |
|
|
| return str(output_path) |
|
|
|
|
| def _output_path(video_path: str, output_dir: str | None, *, suffix: str = "annotated") -> Path: |
| base_dir = Path(output_dir) if output_dir else Path(tempfile.gettempdir()) / "tiny-trigger" |
| base_dir.mkdir(parents=True, exist_ok=True) |
| return base_dir / f"{Path(video_path).stem}-{uuid4().hex[:8]}-{suffix}.mp4" |
|
|
|
|
| def _sampling_frame_stride( |
| *, |
| source_fps: float, |
| frame_stride: int, |
| sample_interval_sec: float | None, |
| ) -> int: |
| if sample_interval_sec is None: |
| return frame_stride |
| return max(1, round(source_fps * sample_interval_sec)) |
|
|
|
|
| def _browser_frame_size(width: int, height: int) -> tuple[int, int]: |
| output_width = width - (width % 2) |
| output_height = height - (height % 2) |
| if output_width < 2 or output_height < 2: |
| raise ValueError("Video dimensions are too small to render.") |
| return (output_width, output_height) |
|
|
|
|
| def _create_browser_mp4_writer(output_path: Path, fps: float, frame_size: tuple[int, int]): |
| import cv2 |
|
|
| writer = cv2.VideoWriter(str(output_path), cv2.VideoWriter_fourcc(*MP4_CODEC), fps, frame_size) |
| if writer.isOpened(): |
| return writer |
| writer.release() |
| return None |
|
|
|
|
| def _finalize_browser_mp4(output_path: Path) -> None: |
| ffmpeg_executable = _ffmpeg_executable() |
| if not ffmpeg_executable or not output_path.exists(): |
| return |
|
|
| faststart_path = output_path.with_name(f"{output_path.stem}-faststart-{uuid4().hex[:8]}{output_path.suffix}") |
| try: |
| subprocess.run( |
| [ |
| ffmpeg_executable, |
| "-y", |
| "-loglevel", |
| "error", |
| "-i", |
| str(output_path), |
| "-c:v", |
| "libx264", |
| "-pix_fmt", |
| "yuv420p", |
| "-preset", |
| "veryfast", |
| "-movflags", |
| "+faststart", |
| str(faststart_path), |
| ], |
| check=True, |
| capture_output=True, |
| ) |
| if faststart_path.exists() and faststart_path.stat().st_size > 0: |
| faststart_path.replace(output_path) |
| except (OSError, subprocess.CalledProcessError): |
| if faststart_path.exists(): |
| faststart_path.unlink() |
|
|
|
|
| def _ffmpeg_executable() -> str | None: |
| if ffmpeg_path := shutil.which("ffmpeg"): |
| return ffmpeg_path |
| try: |
| import imageio_ffmpeg |
| except ImportError: |
| return None |
| return imageio_ffmpeg.get_ffmpeg_exe() |
|
|
|
|
| def _fit_frame_to_output(frame, output_size: tuple[int, int]): |
| output_width, output_height = output_size |
| height, width = frame.shape[:2] |
| if width == output_width and height == output_height: |
| return frame |
| return frame[:output_height, :output_width] |
|
|
|
|
| def _write_frame(writer, frame) -> None: |
| writer.write(frame) |
|
|
|
|
| def _draw_detections(frame, detections: list[Detection]) -> None: |
| import cv2 |
|
|
| for detection in detections: |
| x1, y1, x2, y2 = [int(value) for value in detection.bbox_xyxy] |
| color = _color_for_label(detection.label) |
| track = f" #{detection.track_id}" if detection.track_id is not None else "" |
| label = f"{detection.label}{track} {detection.confidence:.2f}" |
| cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) |
| text_y = max(18, y1 - 8) |
| cv2.putText(frame, label, (x1, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA) |
|
|
|
|
| def _draw_action_events(frame, events: list[ActionEvent]) -> None: |
| import cv2 |
|
|
| height, width = frame.shape[:2] |
| banner_height = min(110, max(70, height // 9)) |
| overlay = frame.copy() |
| cv2.rectangle(overlay, (0, 0), (width, banner_height), (0, 96, 255), -1) |
| cv2.addWeighted(overlay, 0.78, frame, 0.22, 0, frame) |
|
|
| cv2.putText(frame, "FIRED", (24, 44), cv2.FONT_HERSHEY_SIMPLEX, 1.15, (255, 255, 255), 3, cv2.LINE_AA) |
| details = " | ".join(f"{event.rule}: {event.action}" for event in events[:3]) |
| cv2.putText(frame, details, (24, banner_height - 18), cv2.FONT_HERSHEY_SIMPLEX, 0.58, (255, 255, 255), 2, cv2.LINE_AA) |
|
|
|
|
| def _color_for_label(label: str) -> tuple[int, int, int]: |
| digest = hashlib.md5(label.encode("utf-8")).digest() |
| return (int(digest[0]), int(digest[1]), int(digest[2])) |
|
|
|
|
| def _group_detections_by_frame(detections: list[Detection]) -> dict[int, list[Detection]]: |
| grouped: dict[int, list[Detection]] = {} |
| for detection in detections: |
| grouped.setdefault(detection.frame_index, []).append(detection) |
| return grouped |
|
|
|
|
| def _group_events_by_frame(events: list[ActionEvent]) -> dict[int, list[ActionEvent]]: |
| grouped: dict[int, list[ActionEvent]] = {} |
| for event in events: |
| grouped.setdefault(event.frame_index, []).append(event) |
| return grouped |
|
|