diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c290f38 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black isort flake8 mypy + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Check code formatting with Black + run: | + black --check --line-length 140 . + + - name: Check import sorting with isort + run: | + isort --check-only --profile black --line-length 140 . + + - name: Lint with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # Exit-zero treats all errors as warnings + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=140 --statistics --extend-ignore=E203,W503 + + test: + runs-on: ubuntu-latest + needs: lint + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg libsm6 libxext6 libxrender-dev + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Run tests (if they exist) + run: | + if [ -d tests ]; then + pytest --cov=Application --cov-report=xml --cov-report=term-missing + else + echo "No tests directory found, skipping tests" + fi + + - name: Upload coverage reports + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + fail_ci_if_error: false + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build package + run: | + python -m build + + - name: Check package + run: | + pip install twine + twine check dist/* diff --git a/.gitignore b/.gitignore index 35a51c5..5ed9e84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,18 +1,87 @@ - +# Test footage and media files generate test footage/images/ - generate test footage/3.MP4 input/* short.mp4 - -__pycache__/ -.vscode/ *.mp4 - *.weights *.m4v - -/output/*.txt - docs/ueberblick.drawio.png + +# Output and cache +output/ +/output/*.txt tmp/tmp.prof + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# Testing +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.pytest_cache/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Jupyter +.ipynb_checkpoints +*.ipynb + +# Environment variables +.env +.env.local + +# Pre-commit +.pre-commit-config.yaml.bak diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..8f141e0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +# Pre-commit hooks for code quality +# Install with: pip install pre-commit && pre-commit install + +repos: + # General file checks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + + # Python code formatting + - repo: https://github.com/psf/black + rev: 23.12.1 + hooks: + - id: black + args: ['--line-length=140'] + + # Import sorting + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile', 'black', '--line-length', '140'] + + # Linting + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + args: ['--max-line-length=140', '--extend-ignore=E203,W503'] + + # Type checking (optional - can be slow) + # Uncomment to enable mypy checks + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v1.8.0 + # hooks: + # - id: mypy + # additional_dependencies: [types-all] + # args: [--ignore-missing-imports] diff --git a/Application/Classifiers/Classifier.py b/Application/Classifiers/Classifier.py index 2a6e3a0..0a80545 100644 --- a/Application/Classifiers/Classifier.py +++ b/Application/Classifiers/Classifier.py @@ -106,7 +106,7 @@ class Classifier(ClassifierInterface): image_np_expanded = np.expand_dims(image, axis=0) # Actual detection. - (boxes, scores, classes, num) = self.sess.run( + boxes, scores, classes, num = self.sess.run( [self.detection_boxes, self.detection_scores, self.detection_classes, self.num_detections], feed_dict={self.image_tensor: image_np_expanded}, ) diff --git a/Application/Config.py b/Application/Config.py index 1b57bf2..3f1c7c9 100644 --- a/Application/Config.py +++ b/Application/Config.py @@ -1,5 +1,10 @@ import json import os +from typing import Any, Optional + +from Application.Logger import get_logger + +logger = get_logger(__name__) class Config: @@ -20,24 +25,32 @@ class Config: "avgNum": 10, } - def __init__(self, config_path): - """This is basically just a wrapper for a json / python dict""" - if os.path.isfile(config_path): - print("using supplied configuration at", config_path) - # fail if config can not be parsed - with open(config_path) as file: - self.c = json.load(file) + def __init__(self, config_path: Optional[str]): + """ + Initialize configuration from file or use defaults. + + Args: + config_path: Path to JSON configuration file. If None or invalid, uses defaults. + """ + if config_path and os.path.isfile(config_path): + logger.info(f"Using supplied configuration at {config_path}") + try: + with open(config_path) as file: + self.c = json.load(file) + except (json.JSONDecodeError, IOError) as e: + logger.error(f"Failed to parse config file: {e}") + logger.warning("Falling back to default configuration") else: - print("using default configuration") + logger.info("Using default configuration") - print("Current Config:") + logger.info("Current Configuration:") for key, value in self.c.items(): - print(f"{key}:\t\t{value}") + logger.info(f" {key}: {value}") - def __getitem__(self, key): + def __getitem__(self, key: str) -> Any: if key not in self.c: return None return self.c[key] - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: Any) -> None: self.c[key] = value diff --git a/Application/ContourExctractor.py b/Application/ContourExctractor.py index aebd639..218eab3 100644 --- a/Application/ContourExctractor.py +++ b/Application/ContourExctractor.py @@ -86,7 +86,7 @@ class ContourExtractor: masks = [] for c in cnts: ca = cv2.contourArea(c) - (x, y, w, h) = cv2.boundingRect(c) + x, y, w, h = cv2.boundingRect(c) if ca < self.min_area or ca > self.max_area: continue contours.append((x, y, w, h)) diff --git a/Application/Exporter.py b/Application/Exporter.py index 7dcae42..978a476 100644 --- a/Application/Exporter.py +++ b/Application/Exporter.py @@ -29,7 +29,7 @@ class Exporter: def export_layers(self, layers): list_of_frames = self.make_list_of_frames(layers) with VideoReader(self.config, list_of_frames) as video_reader: - + underlay = cv2.VideoCapture(self.footage_path).read()[1] underlay = cv2.cvtColor(underlay, cv2.COLOR_BGR2RGB) @@ -48,7 +48,7 @@ class Exporter: frame_count, frame = video_reader.pop() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame2 = np.copy(underlay) - for (x, y, w, h) in layer.bounds[frame_count - layer.start_frame]: + for x, y, w, h in layer.bounds[frame_count - layer.start_frame]: if x is None: continue factor = video_reader.w / self.resize_width @@ -105,7 +105,9 @@ class Exporter: cv2.imshow("changes x", background) cv2.waitKey(10) & 0xFF - self.add_timestamp(frames[frame_count - layer.start_frame + layer.export_offset], videoReader, frame_count, x, y, w, h) + self.add_timestamp( + frames[frame_count - layer.start_frame + layer.export_offset], videoReader, frame_count, x, y, w, h + ) except: continue diff --git a/Application/HeatMap.py b/Application/HeatMap.py index 7381aa3..6021547 100644 --- a/Application/HeatMap.py +++ b/Application/HeatMap.py @@ -1,6 +1,7 @@ import numpy as np from matplotlib import pyplot as plt + class HeatMap: def __init__(self, x, y, contours, resize_factor=1): self.image_bw = np.zeros(shape=[y, x, 3], dtype=np.float64) @@ -25,4 +26,4 @@ class HeatMap: plt.show() def save_image(self, path): - plt.imsave(path, (255 * self.image_bw).astype(np.uint8)) \ No newline at end of file + plt.imsave(path, (255 * self.image_bw).astype(np.uint8)) diff --git a/Application/Importer.py b/Application/Importer.py index ecae7dc..2fc6410 100644 --- a/Application/Importer.py +++ b/Application/Importer.py @@ -1,5 +1,6 @@ -import pickle import os.path +import pickle + class Importer: def __init__(self, config): diff --git a/Application/Layer.py b/Application/Layer.py index 886d675..a2ced60 100644 --- a/Application/Layer.py +++ b/Application/Layer.py @@ -63,7 +63,9 @@ class Layer: for b1s, b2s in zip(bounds[::10], layer2.bounds[:max_len:10]): for b1 in b1s: for b2 in b2s: - if self.contours_overlay((b1[0], b1[1] + b1[3]), (b1[0] + b1[2], b1[1]), (b2[0], b2[1] + b2[3]), (b2[0] + b2[2], b2[1])): + if self.contours_overlay( + (b1[0], b1[1] + b1[3]), (b1[0] + b1[2], b1[1]), (b2[0], b2[1] + b2[3]), (b2[0] + b2[2], b2[1]) + ): overlap = True break return overlap diff --git a/Application/LayerFactory.py b/Application/LayerFactory.py index a2fa901..431031b 100644 --- a/Application/LayerFactory.py +++ b/Application/LayerFactory.py @@ -61,7 +61,7 @@ class LayerFactory: frame_number = data[0] bounds = data[1] mask = data[2] - (x, y, w, h) = bounds + x, y, w, h = bounds tol = self.tolerance found_layer_i_ds = set() @@ -75,7 +75,7 @@ class LayerFactory: for j, bounds in enumerate(sorted(last_bounds, reverse=True)): if bounds is None: break - (x2, y2, w2, h2) = bounds + x2, y2, w2, h2 = bounds if self.contours_overlay((x - tol, y + h + tol), (x + w + tol, y - tol), (x2, y2 + h2), (x2 + w2, y2)): layer.add(frame_number, (x, y, w, h), mask) found_layer_i_ds.add(i) diff --git a/Application/LayerManager.py b/Application/LayerManager.py index da1852e..881e45a 100644 --- a/Application/LayerManager.py +++ b/Application/LayerManager.py @@ -4,12 +4,20 @@ from multiprocessing.pool import ThreadPool import cv2 import numpy as np -from Application.Classifiers.Classifier import Classifier from Application.Config import Config from Application.Exporter import Exporter from Application.Layer import Layer from Application.VideoReader import VideoReader +# Optional: Classifier (requires TensorFlow) +try: + from Application.Classifiers.Classifier import Classifier + + CLASSIFIER_AVAILABLE = True +except ImportError: + CLASSIFIER_AVAILABLE = False + Classifier = None + class LayerManager: def __init__(self, config, layers): @@ -36,7 +44,7 @@ class LayerManager: print("Before deleting sparse layers ", len(self.layers)) self.delete_sparse() print("after deleting sparse layers ", len(self.layers)) - #self.calcTimeOffset() + # self.calcTimeOffset() def delete_sparse(self): to_delete = [] @@ -82,7 +90,7 @@ class LayerManager: frame_count, frame = video_reader.pop() frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) data = [] - for (x, y, w, h) in layer.bounds[frame_count - layer.start_frame]: + for x, y, w, h in layer.bounds[frame_count - layer.start_frame]: if x is None: break factor = video_reader.w / self.resize_width diff --git a/Application/Logger.py b/Application/Logger.py new file mode 100644 index 0000000..6ae027c --- /dev/null +++ b/Application/Logger.py @@ -0,0 +1,56 @@ +"""Logging configuration for Video Summary application.""" + +import logging +import sys +from typing import Optional + + +def setup_logger(name: str = "video_summary", level: int = logging.INFO, log_file: Optional[str] = None) -> logging.Logger: + """ + Configure and return a logger instance. + + Args: + name: Logger name + level: Logging level (default: INFO) + log_file: Optional log file path. If None, logs to stdout only. + + Returns: + Configured logger instance + """ + logger = logging.getLogger(name) + logger.setLevel(level) + + # Prevent duplicate handlers + if logger.handlers: + return logger + + # Create formatter + formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S") + + # Console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # File handler (optional) + if log_file: + file_handler = logging.FileHandler(log_file) + file_handler.setLevel(level) + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + + +def get_logger(name: str = "video_summary") -> logging.Logger: + """ + Get an existing logger instance. + + Args: + name: Logger name + + Returns: + Logger instance + """ + return logging.getLogger(name) diff --git a/Application/__init__.py b/Application/__init__.py new file mode 100644 index 0000000..880dc86 --- /dev/null +++ b/Application/__init__.py @@ -0,0 +1,41 @@ +"""Video Summary Application Package. + +This package provides tools for video summarization through contour extraction +and layer-based processing. +""" + +__version__ = "0.1.0" +__author__ = "Askill" + +# Core imports +from Application.Config import Config +from Application.Layer import Layer + +# Import optional components that may have additional dependencies +__all__ = ["Config", "Layer"] + +# Try to import video processing components +try: + from Application.ContourExctractor import ContourExtractor + from Application.Exporter import Exporter + from Application.HeatMap import HeatMap + from Application.Importer import Importer + from Application.LayerFactory import LayerFactory + from Application.VideoReader import VideoReader + + __all__.extend(["ContourExtractor", "Exporter", "HeatMap", "Importer", "LayerFactory", "VideoReader"]) +except ImportError as e: + import warnings + + warnings.warn(f"Some video processing components could not be imported: {e}") + +# Try to import LayerManager (may require TensorFlow for classification features) +try: + from Application.LayerManager import LayerManager + + __all__.append("LayerManager") +except ImportError: + import warnings + + warnings.warn("LayerManager could not be imported. TensorFlow may be required for classification features.") + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..086cae2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,184 @@ +# Contributing to Video-Summary + +Thank you for considering contributing to Video-Summary! This document provides guidelines and instructions for contributing. + +## Getting Started + +### Prerequisites + +- Python 3.8 or higher +- Git +- ffmpeg (for video processing) + +### Setting up Development Environment + +1. **Clone the repository** + ```bash + git clone https://github.com/Askill/Video-Summary.git + cd Video-Summary + ``` + +2. **Create a virtual environment** + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +3. **Install dependencies** + ```bash + pip install -r requirements.txt + pip install -e ".[dev]" # Install development dependencies + ``` + +4. **Install pre-commit hooks** + ```bash + pip install pre-commit + pre-commit install + ``` + +## Development Workflow + +### Code Style + +We use the following tools to maintain code quality: + +- **Black**: Code formatting (line length: 140) +- **isort**: Import sorting +- **flake8**: Linting +- **mypy**: Type checking (optional but recommended) + +Run these tools before committing: + +```bash +# Format code +black . +isort . + +# Check for issues +flake8 . +mypy Application/ main.py +``` + +Or simply commit - pre-commit hooks will run automatically! + +### Making Changes + +1. **Create a feature branch** + ```bash + git checkout -b feature/your-feature-name + ``` + +2. **Make your changes** + - Write clean, readable code + - Add type hints where applicable + - Update documentation as needed + - Add tests for new functionality + +3. **Test your changes** + ```bash + # Run tests (if available) + pytest + + # Test the CLI + python main.py path/to/test/video.mp4 output_test + ``` + +4. **Commit your changes** + ```bash + git add . + git commit -m "feat: Add your feature description" + ``` + + We follow [Conventional Commits](https://www.conventionalcommits.org/): + - `feat:` - New feature + - `fix:` - Bug fix + - `docs:` - Documentation changes + - `refactor:` - Code refactoring + - `test:` - Adding tests + - `chore:` - Maintenance tasks + +5. **Push and create a Pull Request** + ```bash + git push origin feature/your-feature-name + ``` + +### Pull Request Guidelines + +- Keep PRs focused on a single feature or fix +- Write a clear description of what the PR does +- Reference any related issues +- Ensure CI passes (linting, tests, build) +- Update documentation if needed +- Add screenshots for UI changes + +## Testing + +While we're building out the test suite, please ensure: + +1. Your code runs without errors +2. You've tested with sample videos +3. Edge cases are handled (missing files, corrupt videos, etc.) +4. Memory usage is reasonable + +## Reporting Issues + +When reporting issues, please include: + +1. **Environment details** + - OS and version + - Python version + - Dependency versions + +2. **Steps to reproduce** + - Exact commands run + - Input file characteristics (if applicable) + +3. **Expected vs. actual behavior** + +4. **Error messages and logs** + +5. **Screenshots** (if applicable) + +## Architecture Overview + +``` +Video-Summary/ +├── Application/ # Core processing modules +│ ├── Config.py # Configuration management +│ ├── ContourExctractor.py # Extract contours from video +│ ├── LayerFactory.py # Group contours into layers +│ ├── LayerManager.py # Manage and clean layers +│ ├── Exporter.py # Export processed results +│ └── ... +├── main.py # CLI entry point +├── pyproject.toml # Project configuration +└── requirements.txt # Dependencies +``` + +### Key Components + +1. **ContourExtractor**: Analyzes video frames to detect movement +2. **LayerFactory**: Groups related contours across frames +3. **LayerManager**: Filters and optimizes layers +4. **Exporter**: Generates output videos + +## Code Review Process + +1. Maintainers will review your PR +2. Address any requested changes +3. Once approved, your PR will be merged +4. Your contribution will be credited in releases + +## Questions? + +- Open an issue for questions +- Tag maintainers for urgent matters +- Be patient and respectful + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. + +--- + +Thank you for contributing to Video-Summary! 🎥✨ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9ae5d5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Askill + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 263a453..3c5c0b0 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,204 @@ +# Video-Summary -# Video Summary and Classification +[![CI](https://github.com/Askill/Video-Summary/workflows/CI/badge.svg)](https://github.com/Askill/Video-Summary/actions) +[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -## Example: +A Python-based video summarization tool that extracts contours from video frames to create condensed summaries. Perfect for analyzing surveillance footage, time-lapse videos, or any static camera recording where you want to extract and visualize movement over time. - usage: - main.py input_video.mp4 output_dir ?config_file.json - -![docs/demo.gif](./docs/demo.gif) -What you see above is a 15 second excerpt of a 2 minute overlayed synopsis of a 2.5h video from an on campus web cam. -The synopsis took 40 minutes from start to finish on a 8 core machine and used a maximum of 6Gb of RAM. +## ✨ Features -However since the contour extraction could be performed on a video stream, the benchmark results show that a single core would be enough to process a video faster than real time. +- **Movement Detection**: Automatically detects and extracts moving objects from static camera footage +- **Layer-Based Processing**: Groups related movements across frames into coherent layers +- **Heatmap Generation**: Visualizes areas of activity in the video +- **Configurable**: Extensive configuration options for fine-tuning detection sensitivity +- **Efficient**: Processes video faster than real-time on modern hardware +- **Caching**: Saves intermediate results for faster re-processing with different parameters -## Heatmap -![](./docs/heatmap_x23.png) +## 🚀 Quick Start +### Installation -## Benchmark -Below you can find the benchmark results for a 10 minutes clip, with the stacked time per component on the x-axis. -The tests were done on a machine with a Ryzen 3700X with 8 cores 16 threads and 32 Gb of RAM. -On my configuration 1 minutes of of the original Video can be processed in about 20 seconds, the expected processing time is about 1/3 of the orignial video length. +```bash +# Clone the repository +git clone https://github.com/Askill/Video-Summary.git +cd Video-Summary -- CE = Contour Extractor -- LE = LayerFactory -- LM = LayerManager -- EX = Exporter +# Create virtual environment +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate -![docs/demo.gif](./docs/bm.jpg) +# Install dependencies +pip install -r requirements.txt -### Configuration +# Install system dependencies (Linux) +sudo apt-get install ffmpeg libsm6 libxext6 libxrender-dev +``` -./Application/Config.py +### Basic Usage - "min_area": 100, min area in pixels, of a single contour, smaller is ignored - "max_area": 9000000, max area in pixels, of a single contour, larger is ignored - "threshold": 6, luminance difference threshold, sensitivity of movement detection - "resizeWidth": 600, video is scaled down internally - "inputPath": None, overwritten in main.py - "outputPath": None, overwritten in main.py - "maxLayerLength": 5000, max length of Layer in frames - "minLayerLength": 10, min length of Layer in frames - "tolerance": 100, max distance (in pixels) between contours to be aggragated into layer - "maxLength": None, - "ttolerance": 60, number of frames movement can be apart until a new layer is created - "videoBufferLength": 100, Buffer Length of Video Reader Componenent - "LayersPerContour": 2, number of layers a single contour can belong to - "avgNum": 10, number of images that should be averaged before calculating the difference - (computationally expensive, needed in outdoor scenarios due to clouds, leaves moving in the wind ...) - +```bash +# Process a video with default settings +python main.py input_video.mp4 output_dir -### notes: -optional: +# Use custom configuration +python main.py input_video.mp4 output_dir config.json -install tensorflow==1.15.0 and tensorflow-gpu==1.15.0, cuda 10.2 and 10.0, copy missing files from 10.0 to 10.2, restart computer, set maximum vram +# Enable verbose logging +python main.py input_video.mp4 output_dir --verbose +``` + +## 📊 Example Output + +![Demo GIF](./docs/demo.gif) + +*A 15-second excerpt of a 2-minute overlaid synopsis of a 2.5-hour video from a campus webcam.* + +### Heatmap Visualization + +![Heatmap](./docs/heatmap_x23.png) + +The heatmap shows areas of activity throughout the video, with brighter regions indicating more movement. + +## ⚙️ Configuration + +Create a JSON configuration file to customize processing parameters: + +```json +{ + "min_area": 300, + "max_area": 900000, + "threshold": 7, + "resizeWidth": 700, + "maxLayerLength": 5000, + "minLayerLength": 40, + "tolerance": 20, + "ttolerance": 50, + "videoBufferLength": 250, + "LayersPerContour": 220, + "avgNum": 10 +} +``` + +### Configuration Parameters + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `min_area` | Minimum contour area in pixels (smaller ignored) | 300 | +| `max_area` | Maximum contour area in pixels (larger ignored) | 900000 | +| `threshold` | Luminance difference threshold for movement detection | 7 | +| `resizeWidth` | Video is scaled to this width internally for processing | 700 | +| `maxLayerLength` | Maximum length of a layer in frames | 5000 | +| `minLayerLength` | Minimum length of a layer in frames | 40 | +| `tolerance` | Max distance (pixels) between contours to aggregate into layer | 20 | +| `ttolerance` | Number of frames movement can be apart before creating new layer | 50 | +| `videoBufferLength` | Buffer length of Video Reader component | 250 | +| `LayersPerContour` | Number of layers a single contour can belong to | 220 | +| `avgNum` | Number of images to average before calculating difference | 10 | + +> **Note**: `avgNum` is computationally expensive but needed in outdoor scenarios with clouds, leaves moving in wind, etc. + +## 📈 Performance Benchmarks + +**Test Configuration:** +- Hardware: Ryzen 3700X (8 cores, 16 threads), 32GB RAM +- Video: 10-minute clip +- Processing Speed: ~20 seconds per minute of video (1:3 ratio) +- Memory Usage: Max 6GB RAM + +**Component Breakdown:** +- CE = Contour Extractor +- LF = Layer Factory +- LM = Layer Manager +- EX = Exporter + +![Benchmark](./docs/bm.jpg) + +## 🏗️ Architecture + +``` +Video-Summary/ +├── Application/ # Core processing modules +│ ├── Config.py # Configuration management +│ ├── ContourExctractor.py # Movement detection +│ ├── LayerFactory.py # Layer extraction +│ ├── LayerManager.py # Layer optimization +│ ├── Exporter.py # Output generation +│ ├── VideoReader.py # Video I/O +│ ├── HeatMap.py # Heatmap generation +│ ├── Importer.py # Cache loading +│ ├── Layer.py # Layer data structure +│ └── Logger.py # Logging utilities +├── main.py # CLI entry point +├── pyproject.toml # Package configuration +└── requirements.txt # Dependencies +``` + +### Processing Pipeline + +1. **Video Reading**: Load and preprocess video frames +2. **Contour Extraction**: Detect movement by comparing consecutive frames +3. **Layer Creation**: Group related contours across frames +4. **Layer Management**: Filter and optimize layers based on configuration +5. **Export**: Generate output video with overlaid movement and heatmap + +## 🧪 Development + +### Code Quality Tools + +We use modern Python development tools: + +- **Black**: Code formatting +- **isort**: Import sorting +- **flake8**: Linting +- **mypy**: Type checking +- **pre-commit**: Automated checks + +```bash +# Install development dependencies +pip install -e ".[dev]" + +# Install pre-commit hooks +pre-commit install + +# Run formatting +black . +isort . + +# Run linting +flake8 . +mypy Application/ main.py +``` + +### Running Tests + +```bash +# Run all tests +pytest + +# Run with coverage +pytest --cov=Application --cov-report=html +``` + +## 📝 Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +The original Creative Commons licensed documentation can be found in [licens.txt](licens.txt). + +## 🙏 Acknowledgments + +- Built with OpenCV, NumPy, and imageio +- Inspired by video synopsis research in computer vision + +## 📮 Contact + +For questions or issues, please [open an issue](https://github.com/Askill/Video-Summary/issues) on GitHub. + +--- + +**Note**: TensorFlow support is optional and not required for core functionality. The project works perfectly fine without GPU acceleration, though processing times will be longer for large videos. diff --git a/main.py b/main.py index d6accac..c2110cc 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ -import os -import time import argparse +import os +import sys +import time from Application.Config import Config from Application.ContourExctractor import ContourExtractor @@ -9,58 +10,130 @@ from Application.HeatMap import HeatMap from Application.Importer import Importer from Application.LayerFactory import LayerFactory from Application.LayerManager import LayerManager +from Application.Logger import get_logger, setup_logger from Application.VideoReader import VideoReader +# Setup logging +setup_logger() +logger = get_logger(__name__) -def main(config): + +def main(config: Config) -> int: + """ + Main processing pipeline for video summarization. + + Args: + config: Configuration object with processing parameters + + Returns: + Exit code (0 for success, 1 for failure) + """ start_total = time.time() - if os.path.exists(config["cachePath"] + "_layers.txt"): - layers, contours, masks = Importer(config).import_raw_data() - layers = LayerFactory(config).extract_layers(contours, masks) - else: - contours, masks = ContourExtractor(config).extract_contours() - layers = LayerFactory(config).extract_layers(contours, masks) + try: + # Check if cached data exists + cache_path = config["cachePath"] + "_layers.txt" + if os.path.exists(cache_path): + logger.info(f"Loading cached data from {cache_path}") + layers, contours, masks = Importer(config).import_raw_data() + layers = LayerFactory(config).extract_layers(contours, masks) + else: + logger.info("Extracting contours from video...") + contours, masks = ContourExtractor(config).extract_contours() + logger.info("Extracting layers from contours...") + layers = LayerFactory(config).extract_layers(contours, masks) - layer_manager = LayerManager(config, layers) - layer_manager.clean_layers() + logger.info("Cleaning layers...") + layer_manager = LayerManager(config, layers) + layer_manager.clean_layers() - # layerManager.tagLayers() - if len(layer_manager.layers) == 0: - exit(1) + # Check if we have any layers to process + if len(layer_manager.layers) == 0: + logger.error("No layers found to process. Exiting.") + return 1 - heatmap = HeatMap( - config["w"], config["h"], [contour for layer in layer_manager.layers for contour in layer.bounds], 1920 / config["resizeWidth"] - ) - heatmap.show_image() - #heatmap.save_image(config["outputPath"].split(".")[0] + "_heatmap.png") # not working yet + # Generate heatmap + logger.info("Generating heatmap...") + heatmap = HeatMap( + config["w"], config["h"], [contour for layer in layer_manager.layers for contour in layer.bounds], 1920 / config["resizeWidth"] + ) + heatmap.show_image() - print(f"Exporting {len(contours)} Contours and {len(layer_manager.layers)} Layers") - Exporter(config).export(layer_manager.layers, contours, masks, raw=True, overlayed=True) - print("Total time: ", time.time() - start_total) + # Export results + logger.info(f"Exporting {len(contours)} Contours and {len(layer_manager.layers)} Layers") + Exporter(config).export(layer_manager.layers, contours, masks, raw=True, overlayed=True) + + total_time = time.time() - start_total + logger.info(f"Total processing time: {total_time:.2f} seconds") + return 0 + + except Exception as e: + logger.error(f"Error during processing: {e}", exc_info=True) + return 1 if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Extract movement from static camera recording", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s input_video.mp4 output_dir + %(prog)s input_video.mp4 output_dir custom_config.json + """, + ) + parser.add_argument("input", metavar="input_file", type=str, help="Input video file to extract movement from") + parser.add_argument( + "output", + metavar="output_dir", + type=str, + nargs="?", + default="output", + help="Output directory to save results and cached files (default: output)", + ) + parser.add_argument("config", metavar="config", type=str, nargs="?", default=None, help="Path to configuration JSON file (optional)") + parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") - parser = argparse.ArgumentParser(description='Extract movement from static camera recording') - parser.add_argument('input', metavar='input_file', type=str, - help='input video to extract movement from') - parser.add_argument('output', metavar='output_dir', type=str, nargs="?", default="output", - help='output directory to save results and cached files into') - parser.add_argument('config', metavar='config', type=str, nargs="?", default=None, - help='relative path to config.json') args = parser.parse_args() - config = Config(args.config) + # Setup logging level + if args.verbose: + setup_logger(level=10) # DEBUG level - input_path = os.path.join(os.path.dirname(__file__), args.input) - output_path = os.path.join(os.path.dirname(__file__), args.output) + try: + # Load configuration + config = Config(args.config) - file_name = input_path.split("/")[-1] + # Resolve paths + input_path = os.path.join(os.path.dirname(__file__), args.input) + output_path = os.path.join(os.path.dirname(__file__), args.output) - config["inputPath"] = input_path - config["outputPath"] = os.path.join(output_path, file_name) - config["cachePath"] = os.path.join(output_path, file_name.split(".")[0]) - config["w"], config["h"] = VideoReader(config).get_wh() + # Validate input file exists + if not os.path.exists(input_path): + logger.error(f"Input file not found: {input_path}") + sys.exit(1) - main(config) + # Create output directory if it doesn't exist + os.makedirs(output_path, exist_ok=True) + + file_name = input_path.split("/")[-1] + + # Configure paths + config["inputPath"] = input_path + config["outputPath"] = os.path.join(output_path, file_name) + config["cachePath"] = os.path.join(output_path, file_name.split(".")[0]) + + # Get video dimensions + logger.info("Reading video dimensions...") + config["w"], config["h"] = VideoReader(config).get_wh() + + # Run main processing + exit_code = main(config) + sys.exit(exit_code) + + except KeyboardInterrupt: + logger.warning("Processing interrupted by user") + sys.exit(130) + except Exception as e: + logger.error(f"Unhandled exception: {e}", exc_info=True) + sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 6920464..400cf2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,101 @@ -# Example configuration for Black. +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" -# NOTE: you have to use single-quoted strings in TOML for regular expressions. -# It's the equivalent of r-strings in Python. Multiline strings are treated as -# verbose regular expressions by Black. Use [ ] to denote a significant space -# character. +[project] +name = "video-summary" +version = "0.1.0" +description = "A Python-based video summarization tool that extracts contours from video frames to create condensed summaries" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Askill", email = ""} +] +keywords = ["video", "summarization", "computer-vision", "opencv", "contour-extraction"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Multimedia :: Video", + "Topic :: Scientific/Engineering :: Image Recognition", +] +dependencies = [ + "opencv-python>=4.5.0,<5.0.0", + "numpy>=1.21.0,<2.0.0", + "imutils>=0.5.4", + "imageio>=2.9.0", + "imageio-ffmpeg>=0.4.0", + "matplotlib>=3.3.0", +] + +[project.optional-dependencies] +dev = [ + "black>=23.0.0", + "isort>=5.12.0", + "flake8>=6.0.0", + "mypy>=1.0.0", + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pre-commit>=3.0.0", +] +tensorflow = [ + "tensorflow>=2.10.0,<3.0.0", +] + +[project.scripts] +video-summary = "main:main" [tool.black] line-length = 140 -target-version = ['py36', 'py37', 'py38', 'py39'] -include = '\.pyi?$' \ No newline at end of file +target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +line_length = 140 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false +disallow_incomplete_defs = false +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +follow_imports = "normal" +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 6df5dc0..8b0252c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,15 @@ -opencv-python -numpy -imutils -imageio -tensorflow -matplotlib -imageio-ffmpeg \ No newline at end of file +# Core dependencies for video processing +opencv-python>=4.5.0,<5.0.0 +numpy>=1.21.0,<2.0.0 +imutils>=0.5.4 + +# Video I/O +imageio>=2.9.0 +imageio-ffmpeg>=0.4.0 + +# Visualization +matplotlib>=3.3.0 + +# Optional: Machine Learning (for classification features) +# Uncomment if you need TensorFlow support: +# tensorflow>=2.10.0,<3.0.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..478bbaf --- /dev/null +++ b/setup.py @@ -0,0 +1,10 @@ +"""Setup script for Video-Summary package. + +This is a shim for backward compatibility. +The real configuration is in pyproject.toml. +""" + +from setuptools import setup + +if __name__ == "__main__": + setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fae6326 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package initialization.""" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..143859e --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,65 @@ +"""Tests for Config module.""" +import json +import os +import tempfile + +import pytest + +from Application.Config import Config + + +class TestConfig: + """Test suite for Config class.""" + + def test_default_config(self): + """Test that default config is loaded when no file provided.""" + config = Config(None) + assert config["min_area"] == 300 + assert config["max_area"] == 900000 + assert config["threshold"] == 7 + + def test_load_config_from_file(self): + """Test loading config from a JSON file.""" + test_config = {"min_area": 500, "max_area": 1000000, "threshold": 10} + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(test_config, f) + temp_path = f.name + + try: + config = Config(temp_path) + assert config["min_area"] == 500 + assert config["max_area"] == 1000000 + assert config["threshold"] == 10 + finally: + os.unlink(temp_path) + + def test_config_with_invalid_file(self): + """Test that default config is used when file doesn't exist.""" + config = Config("/nonexistent/path/config.json") + assert config["min_area"] == 300 # Should use defaults + + def test_config_getitem(self): + """Test __getitem__ method.""" + config = Config(None) + assert config["min_area"] is not None + assert config["nonexistent_key"] is None + + def test_config_setitem(self): + """Test __setitem__ method.""" + config = Config(None) + config["new_key"] = "new_value" + assert config["new_key"] == "new_value" + + def test_config_with_malformed_json(self): + """Test handling of malformed JSON file.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{invalid json content") + temp_path = f.name + + try: + config = Config(temp_path) + # Should fall back to defaults + assert config["min_area"] == 300 + finally: + os.unlink(temp_path)