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

View File

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

View File

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

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

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