Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,11 @@ usage: movie-barcodes [-h] -i INPUT_VIDEO_PATH [-d [DESTINATION_PATH]] [-t {hori
# Examples
## Sequential Processing
```python
python -m src.main -i "path/to/video" --width 200 -w 1
python -m movie_barcodes -i "path/to/video" --width 200 -w 1
```
## Parallel Processing
```python
python -m src.main -i "path/to/video" --width 200 -w 8
python -m movie_barcodes -i "path/to/video" --width 200 -w 8
```

# Development Setup
Expand All @@ -94,7 +94,7 @@ $ uv pip install pytest pytest-cov
$ uv run pytest tests/

# Run package locally
$ uv run python -m src.main -i "path_to_video.mp4"
$ uv run python -m movie_barcodes -i "path_to_video.mp4"
```

# Todo
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ Homepage = "https://github.com/Wazzabeee/movie-barcodes"
Repository = "https://github.com/Wazzabeee/movie-barcodes"

[project.scripts]
movie-barcodes = "src.main:main"
movie-barcodes = "movie_barcodes.cli:main"

[tool.setuptools]
package-dir = {"" = "src"}

[tool.setuptools.packages.find]
where = ["src"]


[tool.setuptools_scm]
version_scheme = "guess-next-dev"
Expand Down
20 changes: 20 additions & 0 deletions src/movie_barcodes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Public API for movie_barcodes.
This package provides CLI and library functions to generate movie color barcodes.
"""

from . import barcode_generation as barcode
from . import barcode_generation as barcode_generation
from . import color_extraction
from . import video_processing
from .cli import main as main
from . import utility

__all__ = [
"barcode",
"barcode_generation",
"color_extraction",
"video_processing",
"utility",
"main",
]
4 changes: 4 additions & 0 deletions src/movie_barcodes/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .cli import main

if __name__ == "__main__":
main()
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ def get_dominant_color_kmeans(frame: np.ndarray, k: int = 3) -> np.ndarray:
Gets the dominant color of a frame using KMeans clustering.

:param np.ndarray frame: The frame as a NumPy array.
:param int k: Number of clusters for KMeans algorithm. Defaults to 1.
:param int k: Number of clusters for KMeans algorithm. Defaults to 3.
:return: Dominant color as a NumPy array.
"""
# Reshape the frame to be a list of pixels
pixels = frame.reshape(-1, 3)

# Apply KMeans clustering
kmeans = KMeans(n_clusters=k, n_init=10)
kmeans = KMeans(n_clusters=k, n_init=10, random_state=0)
kmeans.fit(pixels)

# Get the RGB values of the cluster centers
Expand Down
20 changes: 8 additions & 12 deletions src/utility.py → src/movie_barcodes/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,13 @@ def validate_args(args: argparse.Namespace, frame_count: int, MAX_PROCESSES: int
if args.width <= 0:
raise ValueError("Width must be greater than 0.")
if args.width > frame_count:
raise ValueError(
f"Specified width ({args.width}) cannot be greater than the number of frames ({frame_count}) in the "
f"video."
)
raise ValueError("Width must be less than or equal to the number of frames.")

if args.height is not None:
if args.height <= 0:
raise ValueError("Height must be greater than 0.")
if args.height > frame_count:
raise ValueError(
f"Specified height ({args.height}) cannot be greater than the number of frames ({frame_count}) in the "
f"video."
)
raise ValueError("Height must be less than or equal to the number of frames.")

if frame_count < MIN_FRAME_COUNT:
raise ValueError(f"The video must have at least {MIN_FRAME_COUNT} frames.")
Expand Down Expand Up @@ -142,7 +136,8 @@ def save_barcode_image(barcode: np.ndarray, base_name: str, args: argparse.Names
:param str method: The method used for color extraction.
"""
current_dir = path.dirname(path.abspath(__file__))
project_root = path.dirname(current_dir) # Go up one directory to get to the project root
# Go up two directories to reach the repository root (…/src/movie_barcodes -> …/src -> repo root)
project_root = path.dirname(path.dirname(current_dir))
# If destination_path isn't specified, construct one based on the video's name
if not args.destination_path:
barcode_dir = path.join(project_root, "barcodes")
Expand All @@ -159,9 +154,10 @@ def save_barcode_image(barcode: np.ndarray, base_name: str, args: argparse.Names

destination_path = path.join(barcode_dir, destination_name)
else:
# In case a destination_path is provided, consider appending the method
# or managing as per your requirement
destination_path = path.join(project_root, args.destination_path)
# Use absolute path as-is; if relative, make it relative to project root
destination_path = args.destination_path
if not path.isabs(destination_path):
destination_path = path.join(project_root, destination_path)

if barcode.shape[2] == 4: # If the image has an alpha channel (RGBA)
image = Image.fromarray(barcode, "RGBA")
Expand Down
File renamed without changes.
10 changes: 10 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import os
import sys


# Ensure the src/ directory is on sys.path so 'movie_barcodes' is importable in tests
_THIS_DIR = os.path.dirname(__file__)
_REPO_ROOT = os.path.abspath(os.path.join(_THIS_DIR, ".."))
_SRC_DIR = os.path.join(_REPO_ROOT, "src")
if _SRC_DIR not in sys.path:
sys.path.insert(0, _SRC_DIR)
4 changes: 2 additions & 2 deletions tests/test_barcode_generation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import unittest
import numpy as np
from src import (
from movie_barcodes import (
barcode_generation,
) # Adjust the import as per your project structure and naming
)


class TestBarcodeGeneration(unittest.TestCase):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_color_extraction.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import unittest
import numpy as np
from src import (
from movie_barcodes import (
color_extraction,
)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import sys
from unittest.mock import patch

from src import main
from movie_barcodes import cli as main


class TestIntegration(unittest.TestCase):
Expand Down
84 changes: 69 additions & 15 deletions tests/test_utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import numpy as np


from src import utility
from movie_barcodes import utility


class TestUtility(unittest.TestCase):
Expand All @@ -32,14 +32,14 @@ def setUp(self) -> None:
self.MAX_PROCESSES = 8
self.MIN_FRAME_COUNT = 100
# Mock setup for path.exists and access to ensure they return True by default
patcher_exists = patch("src.utility.path.exists", return_value=True)
patcher_access = patch("src.utility.access", return_value=True)
patcher_exists = patch("movie_barcodes.utility.path.exists", return_value=True)
patcher_access = patch("movie_barcodes.utility.access", return_value=True)
self.addCleanup(patcher_exists.stop)
self.addCleanup(patcher_access.stop)
self.mock_exists = patcher_exists.start()
self.mock_access = patcher_access.start()

@patch("src.utility.path.exists")
@patch("movie_barcodes.utility.path.exists")
def test_file_not_found_error(self, mock_exists: MagicMock) -> None:
"""
Test that validate_args raises a FileNotFoundError when the input video file does not exist.
Expand All @@ -50,7 +50,7 @@ def test_file_not_found_error(self, mock_exists: MagicMock) -> None:
with self.assertRaises(FileNotFoundError):
utility.validate_args(self.args, self.frame_count, self.MAX_PROCESSES, self.MIN_FRAME_COUNT)

@patch("src.utility.path.exists")
@patch("movie_barcodes.utility.path.exists")
def test_invalid_extension_error(self, mock_exists: MagicMock) -> None:
"""
Test that validate_args raises a ValueError when the input video file has an invalid extension.
Expand All @@ -62,8 +62,8 @@ def test_invalid_extension_error(self, mock_exists: MagicMock) -> None:
with self.assertRaises(ValueError):
utility.validate_args(self.args, self.frame_count, self.MAX_PROCESSES, self.MIN_FRAME_COUNT)

@patch("src.utility.path.exists")
@patch("src.utility.access")
@patch("movie_barcodes.utility.path.exists")
@patch("movie_barcodes.utility.access")
def test_destination_path_not_writable(self, mock_access: MagicMock, mock_exists: MagicMock) -> None:
"""
Test that validate_args raises a PermissionError when the destination path is not writable.
Expand All @@ -76,7 +76,7 @@ def test_destination_path_not_writable(self, mock_access: MagicMock, mock_exists
with self.assertRaises(PermissionError):
utility.validate_args(self.args, self.frame_count, self.MAX_PROCESSES, self.MIN_FRAME_COUNT)

@patch("src.utility.path.exists")
@patch("movie_barcodes.utility.path.exists")
def test_workers_value_error(self, mock_exists: MagicMock) -> None:
"""
Test that validate_args raises a ValueError when the number of workers is invalid.
Expand Down Expand Up @@ -127,6 +127,21 @@ def test_frame_count_too_low(self) -> None:
with self.assertRaises(ValueError):
utility.validate_args(self.args, low_frame_count, self.MAX_PROCESSES, self.MIN_FRAME_COUNT)

@patch("movie_barcodes.utility.path.exists")
def test_frame_count_too_low_specific_branch(self, mock_exists: MagicMock) -> None:
"""
Ensure the frame_count < MIN_FRAME_COUNT branch in validate_args is executed
by avoiding earlier width/height validations.
:param mock_exists: MagicMock for path.exists
:return: None
"""
mock_exists.return_value = True
self.args.width = None
self.args.height = None
with self.assertRaises(ValueError) as cm:
utility.validate_args(self.args, self.MIN_FRAME_COUNT - 1, self.MAX_PROCESSES, self.MIN_FRAME_COUNT)
self.assertIn(f"at least {self.MIN_FRAME_COUNT} frames", str(cm.exception))

def test_all_methods_and_method_error(self) -> None:
"""
Test that validate_args raises a ValueError when the --all_methods flag is used with the --method argument.
Expand All @@ -144,8 +159,8 @@ def test_no_error_raised(self) -> None:
"""
utility.validate_args(self.args, self.frame_count, self.MAX_PROCESSES, self.MIN_FRAME_COUNT)

@patch("src.utility.makedirs")
@patch("src.utility.path.exists")
@patch("movie_barcodes.utility.makedirs")
@patch("movie_barcodes.utility.path.exists")
def test_ensure_directory_creates_directory(self, mock_exists: MagicMock, mock_makedirs: MagicMock) -> None:
"""
Test ensure_directory creates the directory when it does not exist.
Expand Down Expand Up @@ -239,7 +254,7 @@ def test_get_dominant_color_function_returns_specific_function(self) -> None:
with self.assertRaises(ValueError):
utility.get_dominant_color_function("invalid_method")

@patch("src.utility.path.getsize")
@patch("movie_barcodes.utility.path.getsize")
def test_get_video_properties(self, mock_getsize: MagicMock) -> None:
"""
Test that get_video_properties correctly extracts video properties.
Expand Down Expand Up @@ -274,10 +289,10 @@ def test_get_video_properties(self, mock_getsize: MagicMock) -> None:
mock_video.get.assert_any_call(cv2.CAP_PROP_FPS)
mock_getsize.assert_called_once_with(args.input_video_path)

@patch("src.utility.path.join")
@patch("src.utility.Image.fromarray")
@patch("src.utility.path.dirname")
@patch("src.utility.path.abspath")
@patch("movie_barcodes.utility.path.join")
@patch("movie_barcodes.utility.Image.fromarray")
@patch("movie_barcodes.utility.path.dirname")
@patch("movie_barcodes.utility.path.abspath")
def test_save_barcode_image_variations(
self,
mock_abspath: MagicMock,
Expand Down Expand Up @@ -335,6 +350,45 @@ def test_save_barcode_image_variations(
utility.save_barcode_image(barcode, base_name, args_without_workers, "avg")
mock_image.save.assert_called() # Ensure the image is attempted to be saved

@patch("movie_barcodes.utility.path.join")
@patch("movie_barcodes.utility.Image.fromarray")
@patch("movie_barcodes.utility.path.isabs")
@patch("movie_barcodes.utility.path.dirname")
@patch("movie_barcodes.utility.path.abspath")
def test_save_barcode_image_with_relative_destination_path(
self,
mock_abspath: MagicMock,
mock_dirname: MagicMock,
mock_isabs: MagicMock,
mock_fromarray: MagicMock,
mock_path_join: MagicMock,
) -> None:
"""
When a relative destination_path is provided, ensure it is resolved relative to project root.
"""
mock_abspath.return_value = "/fake/root/src/movie_barcodes/utility.py"
# save_barcode_image calls dirname three times: dirname(abspath(...)) and dirname(dirname(current_dir))
mock_dirname.side_effect = [
"/fake/root/src/movie_barcodes", # dirname of abspath
"/fake/root/src", # inner dirname(current_dir)
"/fake/root", # outer dirname(<above>) => project root
]
mock_isabs.return_value = False
mock_path_join.side_effect = lambda *args: "/".join(args)

barcode = np.zeros((2, 2, 3), dtype=np.uint8)
args = argparse.Namespace(
destination_path="relative/output.png",
output_name=None,
barcode_type="type1",
workers=None,
)

utility.save_barcode_image(barcode, "video_sample", args, "avg")

expected_saved_path = "/fake/root/relative/output.png"
mock_fromarray.return_value.save.assert_called_with(expected_saved_path)


if __name__ == "__main__":
unittest.main()
10 changes: 5 additions & 5 deletions tests/test_video_processing.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import unittest
from unittest.mock import patch, MagicMock
from src import video_processing
from movie_barcodes import video_processing


class TestVideoProcessing(unittest.TestCase):
Expand All @@ -27,7 +27,7 @@ def mock_color_extractor(frame):
"""
return frame

@patch("src.video_processing.cv2.VideoCapture")
@patch("movie_barcodes.video_processing.cv2.VideoCapture")
def test_load_video_raises_error_on_file_not_open(self, mock_video: MagicMock) -> None:
"""
Test load_video raises ValueError when the video file cannot be opened.
Expand All @@ -42,7 +42,7 @@ def test_load_video_raises_error_on_file_not_open(self, mock_video: MagicMock) -
str(context.exception),
)

@patch("src.video_processing.cv2.VideoCapture")
@patch("movie_barcodes.video_processing.cv2.VideoCapture")
def test_load_video_raises_error_on_no_frames(self, mock_video: MagicMock) -> None:
"""
Test load_video raises ValueError when the video has no frames.
Expand All @@ -58,7 +58,7 @@ def test_load_video_raises_error_on_no_frames(self, mock_video: MagicMock) -> No
str(context.exception),
)

@patch("src.video_processing.cv2.VideoCapture")
@patch("movie_barcodes.video_processing.cv2.VideoCapture")
def test_load_video_raises_error_on_invalid_dimensions(self, mock_video: MagicMock) -> None:
"""
Test load_video raises ValueError when video has invalid dimensions.
Expand All @@ -78,7 +78,7 @@ def test_load_video_raises_error_on_invalid_dimensions(self, mock_video: MagicMo
str(context.exception),
)

@patch("src.video_processing.cv2.VideoCapture")
@patch("movie_barcodes.video_processing.cv2.VideoCapture")
def test_load_video_success(self, mock_video: MagicMock) -> None:
"""
Test load_video successfully returns video properties.
Expand Down