Phase 1: Add foundation improvements - logging, configuration, testing, CI/CD
Co-authored-by: Askill <16598120+Askill@users.noreply.github.com>
This commit is contained in:
parent
4f4652ef4a
commit
883e05f2a5
|
|
@ -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/*
|
||||||
|
|
@ -1,18 +1,87 @@
|
||||||
|
# Test footage and media files
|
||||||
generate test footage/images/
|
generate test footage/images/
|
||||||
|
|
||||||
generate test footage/3.MP4
|
generate test footage/3.MP4
|
||||||
input/*
|
input/*
|
||||||
short.mp4
|
short.mp4
|
||||||
|
|
||||||
__pycache__/
|
|
||||||
.vscode/
|
|
||||||
*.mp4
|
*.mp4
|
||||||
|
|
||||||
*.weights
|
*.weights
|
||||||
*.m4v
|
*.m4v
|
||||||
|
|
||||||
/output/*.txt
|
|
||||||
|
|
||||||
docs/ueberblick.drawio.png
|
docs/ueberblick.drawio.png
|
||||||
|
|
||||||
|
# Output and cache
|
||||||
|
output/
|
||||||
|
/output/*.txt
|
||||||
tmp/tmp.prof
|
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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -106,7 +106,7 @@ class Classifier(ClassifierInterface):
|
||||||
image_np_expanded = np.expand_dims(image, axis=0)
|
image_np_expanded = np.expand_dims(image, axis=0)
|
||||||
# Actual detection.
|
# 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],
|
[self.detection_boxes, self.detection_scores, self.detection_classes, self.num_detections],
|
||||||
feed_dict={self.image_tensor: image_np_expanded},
|
feed_dict={self.image_tensor: image_np_expanded},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from Application.Logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|
@ -20,24 +25,32 @@ class Config:
|
||||||
"avgNum": 10,
|
"avgNum": 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, config_path):
|
def __init__(self, config_path: Optional[str]):
|
||||||
"""This is basically just a wrapper for a json / python dict"""
|
"""
|
||||||
if os.path.isfile(config_path):
|
Initialize configuration from file or use defaults.
|
||||||
print("using supplied configuration at", config_path)
|
|
||||||
# fail if config can not be parsed
|
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:
|
with open(config_path) as file:
|
||||||
self.c = json.load(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:
|
else:
|
||||||
print("using default configuration")
|
logger.info("Using default configuration")
|
||||||
|
|
||||||
print("Current Config:")
|
logger.info("Current Configuration:")
|
||||||
for key, value in self.c.items():
|
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:
|
if key not in self.c:
|
||||||
return None
|
return None
|
||||||
return self.c[key]
|
return self.c[key]
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
def __setitem__(self, key: str, value: Any) -> None:
|
||||||
self.c[key] = value
|
self.c[key] = value
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class ContourExtractor:
|
||||||
masks = []
|
masks = []
|
||||||
for c in cnts:
|
for c in cnts:
|
||||||
ca = cv2.contourArea(c)
|
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:
|
if ca < self.min_area or ca > self.max_area:
|
||||||
continue
|
continue
|
||||||
contours.append((x, y, w, h))
|
contours.append((x, y, w, h))
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class Exporter:
|
||||||
frame_count, frame = video_reader.pop()
|
frame_count, frame = video_reader.pop()
|
||||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
frame2 = np.copy(underlay)
|
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:
|
if x is None:
|
||||||
continue
|
continue
|
||||||
factor = video_reader.w / self.resize_width
|
factor = video_reader.w / self.resize_width
|
||||||
|
|
@ -105,7 +105,9 @@ class Exporter:
|
||||||
cv2.imshow("changes x", background)
|
cv2.imshow("changes x", background)
|
||||||
cv2.waitKey(10) & 0xFF
|
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:
|
except:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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):
|
def __init__(self, x, y, contours, resize_factor=1):
|
||||||
self.image_bw = np.zeros(shape=[y, x, 3], dtype=np.float64)
|
self.image_bw = np.zeros(shape=[y, x, 3], dtype=np.float64)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import pickle
|
|
||||||
import os.path
|
import os.path
|
||||||
|
import pickle
|
||||||
|
|
||||||
|
|
||||||
class Importer:
|
class Importer:
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@ class Layer:
|
||||||
for b1s, b2s in zip(bounds[::10], layer2.bounds[:max_len:10]):
|
for b1s, b2s in zip(bounds[::10], layer2.bounds[:max_len:10]):
|
||||||
for b1 in b1s:
|
for b1 in b1s:
|
||||||
for b2 in b2s:
|
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
|
overlap = True
|
||||||
break
|
break
|
||||||
return overlap
|
return overlap
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ class LayerFactory:
|
||||||
frame_number = data[0]
|
frame_number = data[0]
|
||||||
bounds = data[1]
|
bounds = data[1]
|
||||||
mask = data[2]
|
mask = data[2]
|
||||||
(x, y, w, h) = bounds
|
x, y, w, h = bounds
|
||||||
tol = self.tolerance
|
tol = self.tolerance
|
||||||
|
|
||||||
found_layer_i_ds = set()
|
found_layer_i_ds = set()
|
||||||
|
|
@ -75,7 +75,7 @@ class LayerFactory:
|
||||||
for j, bounds in enumerate(sorted(last_bounds, reverse=True)):
|
for j, bounds in enumerate(sorted(last_bounds, reverse=True)):
|
||||||
if bounds is None:
|
if bounds is None:
|
||||||
break
|
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)):
|
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)
|
layer.add(frame_number, (x, y, w, h), mask)
|
||||||
found_layer_i_ds.add(i)
|
found_layer_i_ds.add(i)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,20 @@ from multiprocessing.pool import ThreadPool
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from Application.Classifiers.Classifier import Classifier
|
|
||||||
from Application.Config import Config
|
from Application.Config import Config
|
||||||
from Application.Exporter import Exporter
|
from Application.Exporter import Exporter
|
||||||
from Application.Layer import Layer
|
from Application.Layer import Layer
|
||||||
from Application.VideoReader import VideoReader
|
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:
|
class LayerManager:
|
||||||
def __init__(self, config, layers):
|
def __init__(self, config, layers):
|
||||||
|
|
@ -36,7 +44,7 @@ class LayerManager:
|
||||||
print("Before deleting sparse layers ", len(self.layers))
|
print("Before deleting sparse layers ", len(self.layers))
|
||||||
self.delete_sparse()
|
self.delete_sparse()
|
||||||
print("after deleting sparse layers ", len(self.layers))
|
print("after deleting sparse layers ", len(self.layers))
|
||||||
#self.calcTimeOffset()
|
# self.calcTimeOffset()
|
||||||
|
|
||||||
def delete_sparse(self):
|
def delete_sparse(self):
|
||||||
to_delete = []
|
to_delete = []
|
||||||
|
|
@ -82,7 +90,7 @@ class LayerManager:
|
||||||
frame_count, frame = video_reader.pop()
|
frame_count, frame = video_reader.pop()
|
||||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
data = []
|
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:
|
if x is None:
|
||||||
break
|
break
|
||||||
factor = video_reader.w / self.resize_width
|
factor = video_reader.w / self.resize_width
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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.")
|
||||||
|
|
||||||
|
|
@ -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! 🎥✨
|
||||||
|
|
@ -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.
|
||||||
223
README.md
223
README.md
|
|
@ -1,55 +1,204 @@
|
||||||
|
# Video-Summary
|
||||||
|
|
||||||
# Video Summary and Classification
|
[](https://github.com/Askill/Video-Summary/actions)
|
||||||
|
[](https://www.python.org/downloads/)
|
||||||
|
[](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:
|
## ✨ Features
|
||||||
main.py input_video.mp4 output_dir ?config_file.json
|
|
||||||
|
|
||||||

|
- **Movement Detection**: Automatically detects and extracts moving objects from static camera footage
|
||||||
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.
|
- **Layer-Based Processing**: Groups related movements across frames into coherent layers
|
||||||
The synopsis took 40 minutes from start to finish on a 8 core machine and used a maximum of 6Gb of RAM.
|
- **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
|
||||||
|
|
||||||
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.
|
## 🚀 Quick Start
|
||||||
|
|
||||||
## Heatmap
|
### Installation
|
||||||

|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone https://github.com/Askill/Video-Summary.git
|
||||||
|
cd Video-Summary
|
||||||
|
|
||||||
## Benchmark
|
# Create virtual environment
|
||||||
Below you can find the benchmark results for a 10 minutes clip, with the stacked time per component on the x-axis.
|
python -m venv venv
|
||||||
The tests were done on a machine with a Ryzen 3700X with 8 cores 16 threads and 32 Gb of RAM.
|
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||||
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.
|
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install system dependencies (Linux)
|
||||||
|
sudo apt-get install ffmpeg libsm6 libxext6 libxrender-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Process a video with default settings
|
||||||
|
python main.py input_video.mp4 output_dir
|
||||||
|
|
||||||
|
# Use custom configuration
|
||||||
|
python main.py input_video.mp4 output_dir config.json
|
||||||
|
|
||||||
|
# Enable verbose logging
|
||||||
|
python main.py input_video.mp4 output_dir --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Example Output
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*A 15-second excerpt of a 2-minute overlaid synopsis of a 2.5-hour video from a campus webcam.*
|
||||||
|
|
||||||
|
### Heatmap Visualization
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
- CE = Contour Extractor
|
||||||
- LE = LayerFactory
|
- LF = Layer Factory
|
||||||
- LM = LayerManager
|
- LM = Layer Manager
|
||||||
- EX = Exporter
|
- EX = Exporter
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
### Configuration
|
## 🏗️ Architecture
|
||||||
|
|
||||||
./Application/Config.py
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
"min_area": 100, min area in pixels, of a single contour, smaller is ignored
|
### Processing Pipeline
|
||||||
"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 ...)
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
### notes:
|
## 🧪 Development
|
||||||
optional:
|
|
||||||
|
|
||||||
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
|
### 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.
|
||||||
|
|
|
||||||
107
main.py
107
main.py
|
|
@ -1,6 +1,7 @@
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
from Application.Config import Config
|
from Application.Config import Config
|
||||||
from Application.ContourExctractor import ContourExtractor
|
from Application.ContourExctractor import ContourExtractor
|
||||||
|
|
@ -9,58 +10,130 @@ from Application.HeatMap import HeatMap
|
||||||
from Application.Importer import Importer
|
from Application.Importer import Importer
|
||||||
from Application.LayerFactory import LayerFactory
|
from Application.LayerFactory import LayerFactory
|
||||||
from Application.LayerManager import LayerManager
|
from Application.LayerManager import LayerManager
|
||||||
|
from Application.Logger import get_logger, setup_logger
|
||||||
from Application.VideoReader import VideoReader
|
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()
|
start_total = time.time()
|
||||||
|
|
||||||
if os.path.exists(config["cachePath"] + "_layers.txt"):
|
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, contours, masks = Importer(config).import_raw_data()
|
||||||
layers = LayerFactory(config).extract_layers(contours, masks)
|
layers = LayerFactory(config).extract_layers(contours, masks)
|
||||||
else:
|
else:
|
||||||
|
logger.info("Extracting contours from video...")
|
||||||
contours, masks = ContourExtractor(config).extract_contours()
|
contours, masks = ContourExtractor(config).extract_contours()
|
||||||
|
logger.info("Extracting layers from contours...")
|
||||||
layers = LayerFactory(config).extract_layers(contours, masks)
|
layers = LayerFactory(config).extract_layers(contours, masks)
|
||||||
|
|
||||||
|
logger.info("Cleaning layers...")
|
||||||
layer_manager = LayerManager(config, layers)
|
layer_manager = LayerManager(config, layers)
|
||||||
layer_manager.clean_layers()
|
layer_manager.clean_layers()
|
||||||
|
|
||||||
# layerManager.tagLayers()
|
# Check if we have any layers to process
|
||||||
if len(layer_manager.layers) == 0:
|
if len(layer_manager.layers) == 0:
|
||||||
exit(1)
|
logger.error("No layers found to process. Exiting.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Generate heatmap
|
||||||
|
logger.info("Generating heatmap...")
|
||||||
heatmap = HeatMap(
|
heatmap = HeatMap(
|
||||||
config["w"], config["h"], [contour for layer in layer_manager.layers for contour in layer.bounds], 1920 / config["resizeWidth"]
|
config["w"], config["h"], [contour for layer in layer_manager.layers for contour in layer.bounds], 1920 / config["resizeWidth"]
|
||||||
)
|
)
|
||||||
heatmap.show_image()
|
heatmap.show_image()
|
||||||
#heatmap.save_image(config["outputPath"].split(".")[0] + "_heatmap.png") # not working yet
|
|
||||||
|
|
||||||
print(f"Exporting {len(contours)} Contours and {len(layer_manager.layers)} Layers")
|
# 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)
|
Exporter(config).export(layer_manager.layers, contours, masks, raw=True, overlayed=True)
|
||||||
print("Total time: ", time.time() - start_total)
|
|
||||||
|
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__":
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Setup logging level
|
||||||
|
if args.verbose:
|
||||||
|
setup_logger(level=10) # DEBUG level
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load configuration
|
||||||
config = Config(args.config)
|
config = Config(args.config)
|
||||||
|
|
||||||
|
# Resolve paths
|
||||||
input_path = os.path.join(os.path.dirname(__file__), args.input)
|
input_path = os.path.join(os.path.dirname(__file__), args.input)
|
||||||
output_path = os.path.join(os.path.dirname(__file__), args.output)
|
output_path = os.path.join(os.path.dirname(__file__), args.output)
|
||||||
|
|
||||||
|
# Validate input file exists
|
||||||
|
if not os.path.exists(input_path):
|
||||||
|
logger.error(f"Input file not found: {input_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create output directory if it doesn't exist
|
||||||
|
os.makedirs(output_path, exist_ok=True)
|
||||||
|
|
||||||
file_name = input_path.split("/")[-1]
|
file_name = input_path.split("/")[-1]
|
||||||
|
|
||||||
|
# Configure paths
|
||||||
config["inputPath"] = input_path
|
config["inputPath"] = input_path
|
||||||
config["outputPath"] = os.path.join(output_path, file_name)
|
config["outputPath"] = os.path.join(output_path, file_name)
|
||||||
config["cachePath"] = os.path.join(output_path, file_name.split(".")[0])
|
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()
|
config["w"], config["h"] = VideoReader(config).get_wh()
|
||||||
|
|
||||||
main(config)
|
# 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)
|
||||||
|
|
|
||||||
102
pyproject.toml
102
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.
|
[project]
|
||||||
# It's the equivalent of r-strings in Python. Multiline strings are treated as
|
name = "video-summary"
|
||||||
# verbose regular expressions by Black. Use [ ] to denote a significant space
|
version = "0.1.0"
|
||||||
# character.
|
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]
|
[tool.black]
|
||||||
line-length = 140
|
line-length = 140
|
||||||
target-version = ['py36', 'py37', 'py38', 'py39']
|
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
|
||||||
include = '\.pyi?$'
|
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_*"]
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
opencv-python
|
# Core dependencies for video processing
|
||||||
numpy
|
opencv-python>=4.5.0,<5.0.0
|
||||||
imutils
|
numpy>=1.21.0,<2.0.0
|
||||||
imageio
|
imutils>=0.5.4
|
||||||
tensorflow
|
|
||||||
matplotlib
|
# Video I/O
|
||||||
imageio-ffmpeg
|
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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Test package initialization."""
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue