Phase 2: Add comprehensive docstrings, tests, and Docker support
Co-authored-by: Askill <16598120+Askill@users.noreply.github.com>
This commit is contained in:
parent
883e05f2a5
commit
6d671afdad
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
15
README.md
15
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
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue