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