Phase 2: Add comprehensive docstrings, tests, and Docker support

Co-authored-by: Askill <16598120+Askill@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-15 17:18:57 +00:00
parent 883e05f2a5
commit 6d671afdad
9 changed files with 349 additions and 28 deletions

52
.dockerignore Normal file
View File

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

View File

@ -1,14 +1,40 @@
"""Heatmap generation for video activity visualization."""
from typing import List, Tuple
import numpy as np import numpy as np
from matplotlib import pyplot as plt from matplotlib import pyplot as plt
class HeatMap: 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.image_bw = np.zeros(shape=[y, x, 3], dtype=np.float64)
self._resize_factor = resize_factor self._resize_factor = resize_factor
self._create_image(contours) 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 contour in contours:
for x, y, w, h in contour: for x, y, w, h in contour:
x, y, w, h = ( 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) self.image_bw = np.nan_to_num(self.image_bw / self.image_bw.sum(axis=1)[:, np.newaxis], 0)
def show_image(self): def show_image(self):
"""Display the heatmap using matplotlib."""
plt.imshow(self.image_bw * 255) plt.imshow(self.image_bw * 255)
plt.show() 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)) plt.imsave(path, (255 * self.image_bw).astype(np.uint8))

View File

@ -1,21 +1,45 @@
"""Layer data structure for grouping related contours across frames."""
from typing import Any, Dict, List, Optional, Tuple
import cv2 import cv2
import imutils import imutils
import numpy as np import numpy as np
class Layer: 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), ],] # bounds = [[(x,y,w,h), ],]
start_frame = None start_frame: Optional[int] = None
last_frame = None last_frame: Optional[int] = None
length = None length: Optional[int] = None
def __init__(self, start_frame, data, mask, config): def __init__(self, start_frame: int, data: Tuple[int, int, int, int], mask: np.ndarray, config: Dict[str, Any]):
"""returns a Layer object """
Initialize a Layer.
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
Note:
Layers are collections of contours with a start_frame, Layers are collections of contours with a start_frame,
which is the number of the frame the first contour of which is the number of the frame the first contour of
this layer was extraced from this layer was extracted from.
A Contour is a CV2 Contour, which is a y*x*3 rgb numpy array, 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. but we only care about the corners of the contours.
@ -24,17 +48,24 @@ class Layer:
self.start_frame = start_frame self.start_frame = start_frame
self.last_frame = start_frame self.last_frame = start_frame
self.config = config self.config = config
self.data = [] self.data: List = []
self.bounds = [] self.bounds: List = []
self.masks = [] self.masks: List = []
self.stats = dict() self.stats: Dict[str, Any] = dict()
self.export_offset = 0 self.export_offset: int = 0
self.bounds.append([data]) self.bounds.append([data])
self.masks.append([mask]) self.masks.append([mask])
def add(self, frame_number, bound, mask): def add(self, frame_number: int, bound: Tuple[int, int, int, int], mask: np.ndarray):
"""Adds a bound to the Layer at the layer index which corresponds to the given framenumber""" """
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 index = frame_number - self.start_frame
if index < 0: if index < 0:
return return

View File

@ -1,17 +1,48 @@
"""Video reading utility with buffering support."""
import multiprocessing import multiprocessing
import os import os
import queue import queue
import threading import threading
from typing import Optional, Set, Tuple
import cv2 import cv2
class VideoReader: class VideoReader:
list_of_frames = None """
w = None Asynchronous video reader with frame buffering.
h = None
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"] video_path = config["inputPath"]
if video_path is None: if video_path is None:
raise Exception("ERROR: Video reader needs a video_path!") raise Exception("ERROR: Video reader needs a video_path!")
@ -33,22 +64,38 @@ class VideoReader:
self.list_of_frames = sorted(set_of_frames) self.list_of_frames = sorted(set_of_frames)
def __enter__(self): def __enter__(self):
"""Context manager entry - starts buffer filling."""
self.fill_buffer() self.fill_buffer()
return self return self
def __exit__(self, type, value, traceback): def __exit__(self, type, value, traceback):
"""Context manager exit - stops video reading."""
self.stop() self.stop()
def stop(self): def stop(self):
"""Stop the video reading thread and wait for it to complete."""
self.thread.join() 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) frame_number, frame = self.buffer.get(block=True)
if frame is None: if frame is None:
self.stopped = True self.stopped = True
return frame_number, frame 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)) self.end_frame = int(cv2.VideoCapture(self.video_path).get(cv2.CAP_PROP_FRAME_COUNT))
if list_of_frames is not None: if list_of_frames is not None:
self.list_of_frames = list_of_frames self.list_of_frames = list_of_frames
@ -66,7 +113,7 @@ class VideoReader:
self.thread.start() self.thread.start()
def read_frames(self): 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) self.vc = cv2.VideoCapture(self.video_path)
while self.last_frame < self.end_frame: while self.last_frame < self.end_frame:
res, frame = self.vc.read() res, frame = self.vc.read()

36
Dockerfile Normal file
View File

@ -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"]

View File

@ -35,6 +35,21 @@ pip install -r requirements.txt
sudo apt-get install ffmpeg libsm6 libxext6 libxrender-dev 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 ### Basic Usage
```bash ```bash

22
docker-compose.yml Normal file
View File

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

29
requirements-dev.txt Normal file
View File

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

56
tests/test_logger.py Normal file
View File

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