From 6d671afdadcf3ccae972fd3cab763ca5201cfce9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:18:57 +0000 Subject: [PATCH] Phase 2: Add comprehensive docstrings, tests, and Docker support Co-authored-by: Askill <16598120+Askill@users.noreply.github.com> --- .dockerignore | 52 +++++++++++++++++++++++++++++ Application/HeatMap.py | 39 ++++++++++++++++++++-- Application/Layer.py | 67 ++++++++++++++++++++++++++++---------- Application/VideoReader.py | 61 ++++++++++++++++++++++++++++++---- Dockerfile | 36 ++++++++++++++++++++ README.md | 15 +++++++++ docker-compose.yml | 22 +++++++++++++ requirements-dev.txt | 29 +++++++++++++++++ tests/test_logger.py | 56 +++++++++++++++++++++++++++++++ 9 files changed, 349 insertions(+), 28 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements-dev.txt create mode 100644 tests/test_logger.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1999b56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,52 @@ +# Git +.git +.gitignore + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info +dist +build +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Documentation +docs/ +*.md +!README.md + +# Development +.pre-commit-config.yaml +requirements-dev.txt + +# Output and test files +output/ +input/ +*.mp4 +*.m4v +*.weights + +# OS +.DS_Store +Thumbs.db diff --git a/Application/HeatMap.py b/Application/HeatMap.py index 6021547..d99cb3c 100644 --- a/Application/HeatMap.py +++ b/Application/HeatMap.py @@ -1,14 +1,40 @@ +"""Heatmap generation for video activity visualization.""" + +from typing import List, Tuple + import numpy as np from matplotlib import pyplot as plt class HeatMap: - def __init__(self, x, y, contours, resize_factor=1): + """ + Generate heatmap visualization of video activity. + + Creates a heatmap showing areas of movement/activity in a video by + accumulating contour locations over time. + """ + + def __init__(self, x: int, y: int, contours: List[List[Tuple[int, int, int, int]]], resize_factor: float = 1): + """ + Initialize HeatMap. + + Args: + x: Width of the heatmap image + y: Height of the heatmap image + contours: List of contour lists, where each contour is (x, y, w, h) + resize_factor: Factor to scale contours (for upscaling from processed size) + """ self.image_bw = np.zeros(shape=[y, x, 3], dtype=np.float64) self._resize_factor = resize_factor self._create_image(contours) - def _create_image(self, contours): + def _create_image(self, contours: List[List[Tuple[int, int, int, int]]]): + """ + Create heatmap image from contours. + + Args: + contours: List of contour lists to accumulate into heatmap + """ for contour in contours: for x, y, w, h in contour: x, y, w, h = ( @@ -22,8 +48,15 @@ class HeatMap: self.image_bw = np.nan_to_num(self.image_bw / self.image_bw.sum(axis=1)[:, np.newaxis], 0) def show_image(self): + """Display the heatmap using matplotlib.""" plt.imshow(self.image_bw * 255) plt.show() - def save_image(self, path): + def save_image(self, path: str): + """ + Save heatmap to file. + + Args: + path: Output file path for the heatmap image + """ plt.imsave(path, (255 * self.image_bw).astype(np.uint8)) diff --git a/Application/Layer.py b/Application/Layer.py index a2ced60..5c700c7 100644 --- a/Application/Layer.py +++ b/Application/Layer.py @@ -1,40 +1,71 @@ +"""Layer data structure for grouping related contours across frames.""" + +from typing import Any, Dict, List, Optional, Tuple + import cv2 import imutils import numpy as np class Layer: + """ + Represents a layer of related contours across video frames. + + Layers group contours that represent the same moving object or area + across multiple frames, allowing for coherent tracking and visualization. + + Attributes: + start_frame: Frame number where this layer begins + last_frame: Frame number where this layer ends + length: Total length of the layer in frames + """ + # bounds = [[(x,y,w,h), ],] - start_frame = None - last_frame = None - length = None + start_frame: Optional[int] = None + last_frame: Optional[int] = None + length: Optional[int] = None - def __init__(self, start_frame, data, mask, config): - """returns a Layer object + def __init__(self, start_frame: int, data: Tuple[int, int, int, int], mask: np.ndarray, config: Dict[str, Any]): + """ + Initialize a Layer. - Layers are collections of contours with a start_frame, - which is the number of the frame the first contour of - this layer was extraced from + Args: + start_frame: Frame number where this layer starts + data: Initial contour bounds as (x, y, w, h) + mask: Binary mask for the contour + config: Configuration dictionary - A Contour is a CV2 Contour, which is a y*x*3 rgb numpy array, - but we only care about the corners of the contours. - So we save the bounds (x,y,w,h) in bounds[] and the actual content in data[] + Note: + Layers are collections of contours with a start_frame, + which is the number of the frame the first contour of + this layer was extracted from. + + A Contour is a CV2 Contour, which is a y*x*3 rgb numpy array, + but we only care about the corners of the contours. + So we save the bounds (x,y,w,h) in bounds[] and the actual content in data[] """ self.start_frame = start_frame self.last_frame = start_frame self.config = config - self.data = [] - self.bounds = [] - self.masks = [] - self.stats = dict() - self.export_offset = 0 + self.data: List = [] + self.bounds: List = [] + self.masks: List = [] + self.stats: Dict[str, Any] = dict() + self.export_offset: int = 0 self.bounds.append([data]) self.masks.append([mask]) - def add(self, frame_number, bound, mask): - """Adds a bound to the Layer at the layer index which corresponds to the given framenumber""" + def add(self, frame_number: int, bound: Tuple[int, int, int, int], mask: np.ndarray): + """ + Add a bound to the Layer at the index corresponding to the frame number. + + Args: + frame_number: Frame number for this bound + bound: Contour bounds as (x, y, w, h) + mask: Binary mask for the contour + """ index = frame_number - self.start_frame if index < 0: return diff --git a/Application/VideoReader.py b/Application/VideoReader.py index 4ccaaf6..de344c7 100644 --- a/Application/VideoReader.py +++ b/Application/VideoReader.py @@ -1,17 +1,48 @@ +"""Video reading utility with buffering support.""" + import multiprocessing import os import queue import threading +from typing import Optional, Set, Tuple import cv2 class VideoReader: - list_of_frames = None - w = None - h = None + """ + Asynchronous video reader with frame buffering. - def __init__(self, config, set_of_frames=None, multiprocess=False): + This class provides efficient video reading by using a separate thread/process + to continuously load frames into a buffer, improving I/O performance for video + processing pipelines. + + Attributes: + list_of_frames: Optional list of specific frame numbers to read + w: Video width in pixels + h: Video height in pixels + """ + + list_of_frames: Optional[list] = None + w: Optional[int] = None + h: Optional[int] = None + + def __init__(self, config, set_of_frames: Optional[Set[int]] = None, multiprocess: bool = False): + """ + Initialize VideoReader. + + Args: + config: Configuration dictionary containing: + - inputPath: Path to video file + - videoBufferLength: Size of frame buffer + set_of_frames: Optional set of specific frame numbers to read. + If None, reads all frames. + multiprocess: If True, uses multiprocessing for buffer. + If False, uses threading (default). + + Raises: + Exception: If video_path is not provided in config. + """ video_path = config["inputPath"] if video_path is None: raise Exception("ERROR: Video reader needs a video_path!") @@ -33,22 +64,38 @@ class VideoReader: self.list_of_frames = sorted(set_of_frames) def __enter__(self): + """Context manager entry - starts buffer filling.""" self.fill_buffer() return self def __exit__(self, type, value, traceback): + """Context manager exit - stops video reading.""" self.stop() def stop(self): + """Stop the video reading thread and wait for it to complete.""" self.thread.join() - def pop(self): + def pop(self) -> Tuple[int, Optional[any]]: + """ + Pop next frame from buffer. + + Returns: + Tuple of (frame_number, frame). Frame is None when video ends. + """ frame_number, frame = self.buffer.get(block=True) if frame is None: self.stopped = True return frame_number, frame - def fill_buffer(self, list_of_frames=None): + def fill_buffer(self, list_of_frames: Optional[list] = None): + """ + Start asynchronous buffer filling. + + Args: + list_of_frames: Optional list of specific frame numbers to read. + If None, reads all frames sequentially. + """ self.end_frame = int(cv2.VideoCapture(self.video_path).get(cv2.CAP_PROP_FRAME_COUNT)) if list_of_frames is not None: self.list_of_frames = list_of_frames @@ -66,7 +113,7 @@ class VideoReader: self.thread.start() def read_frames(self): - """Reads video from start to finish""" + """Read video frames sequentially from start to finish.""" self.vc = cv2.VideoCapture(self.video_path) while self.last_frame < self.end_frame: res, frame = self.vc.read() diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..934b126 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Dockerfile for Video-Summary +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + ffmpeg \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY Application/ ./Application/ +COPY main.py . +COPY pyproject.toml . +COPY setup.py . + +# Create output directory +RUN mkdir -p /app/output + +# Set environment variables +ENV PYTHONUNBUFFERED=1 + +# Entry point +ENTRYPOINT ["python", "main.py"] + +# Default command shows help +CMD ["--help"] diff --git a/README.md b/README.md index 3c5c0b0..d94100d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ pip install -r requirements.txt sudo apt-get install ffmpeg libsm6 libxext6 libxrender-dev ``` +### Docker Installation (Recommended) + +For a consistent environment without system dependency issues: + +```bash +# Build the Docker image +docker build -t video-summary . + +# Run with Docker +docker run -v $(pwd)/input:/app/input -v $(pwd)/output:/app/output video-summary /app/input/video.mp4 /app/output + +# Or use Docker Compose +docker-compose run --rm video-summary /app/input/video.mp4 /app/output +``` + ### Basic Usage ```bash diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..dead2be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.8' + +services: + video-summary: + build: . + image: video-summary:latest + volumes: + # Mount your video files and output directory + - ./input:/app/input:ro + - ./output:/app/output + environment: + - PYTHONUNBUFFERED=1 + # Example: Process a video with default settings + # command: /app/input/video.mp4 /app/output + + # Example: Process with custom config + # command: /app/input/video.mp4 /app/output /app/input/config.json + +# Usage: +# 1. Place your video files in ./input/ +# 2. Run: docker-compose run --rm video-summary /app/input/your_video.mp4 /app/output +# 3. Find results in ./output/ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..bc255b2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,29 @@ +# Development dependencies +# Install with: pip install -r requirements-dev.txt + +# Code formatting +black>=23.0.0 +isort>=5.12.0 + +# Linting +flake8>=6.0.0 +flake8-docstrings>=1.7.0 + +# Type checking +mypy>=1.0.0 + +# Testing +pytest>=7.0.0 +pytest-cov>=4.0.0 +pytest-mock>=3.10.0 + +# Pre-commit hooks +pre-commit>=3.0.0 + +# Documentation +sphinx>=5.0.0 +sphinx-rtd-theme>=1.2.0 + +# Build tools +build>=0.10.0 +twine>=4.0.0 diff --git a/tests/test_logger.py b/tests/test_logger.py new file mode 100644 index 0000000..c401282 --- /dev/null +++ b/tests/test_logger.py @@ -0,0 +1,56 @@ +"""Tests for Logger module.""" +import logging +import tempfile + +from Application.Logger import get_logger, setup_logger + + +class TestLogger: + """Test suite for Logger utility functions.""" + + def test_setup_logger_default(self): + """Test setting up logger with default parameters.""" + logger = setup_logger(name="test_logger_1") + assert logger is not None + assert logger.name == "test_logger_1" + assert logger.level == logging.INFO + + def test_setup_logger_with_level(self): + """Test setting up logger with custom level.""" + logger = setup_logger(name="test_logger_2", level=logging.DEBUG) + assert logger.level == logging.DEBUG + + def test_setup_logger_with_file(self): + """Test setting up logger with file output.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False) as f: + log_file = f.name + + logger = setup_logger(name="test_logger_3", log_file=log_file) + logger.info("Test message") + + # Verify file was created and has content + with open(log_file, "r") as f: + content = f.read() + assert "Test message" in content + + def test_get_logger(self): + """Test getting an existing logger.""" + # First create a logger + setup_logger(name="test_logger_4") + + # Then retrieve it + logger = get_logger("test_logger_4") + assert logger is not None + assert logger.name == "test_logger_4" + + def test_logger_prevents_duplicate_handlers(self): + """Test that setting up the same logger twice doesn't add duplicate handlers.""" + logger1 = setup_logger(name="test_logger_5") + handler_count_1 = len(logger1.handlers) + + # Setup the same logger again + logger2 = setup_logger(name="test_logger_5") + handler_count_2 = len(logger2.handlers) + + # Should have the same number of handlers + assert handler_count_1 == handler_count_2