diff --git a/.flake8 b/.flake8 index ef97a3b..939b9af 100644 --- a/.flake8 +++ b/.flake8 @@ -3,3 +3,4 @@ max-line-length = 100 max-complexity = 18 select = B,C,E,F,W,T4,B9 + exclude = .git,__pycache__,.venv,venv,.tox,build,dist,*.egg-info,docs/_build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index caef45b..3d92dd9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.9] + python-version: [3.11] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} @@ -21,6 +21,6 @@ jobs: pip install -r requirements/requirements-dev.txt - name: Check code format and style run: | - isort **/*.py -c -v - black --check colordetect/ tests/ - flake8 colordetect/ tests/ + isort src/colordetect/ tests/ docs/conf.py -c -v + black --check src/colordetect/ tests/ + flake8 src/colordetect/ tests/ diff --git a/CHANGES.rst b/CHANGES.rst index 0945288..cb5df9e 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,6 +2,14 @@ ColorDetect Changelog ===================== +.. _1.6.6: +1.6.6 (28-08-2026) +================== +Feat +--------- + +- Migration to recommended packaging + .. _1.6.5: 1.6.5 (28-02-2026) ================== @@ -53,7 +61,7 @@ Fix Features --------- -- Perform color recognition on a video at a specific time +- Perform color recognition on a video at a specific time - Extract image from video at a specific time @@ -65,12 +73,12 @@ Docs ---- - Update contribution readme with pre-commit configuration. - + Fix --- - Linting of code - + .. _1.5.0: 1.5.0 (11-08-2021) ======================= diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/docs/conf.py b/docs/conf.py index 0344d52..cb36276 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "Marvin Kweyu" # The full version, including alpha/beta/rc tags -release = "1.6.5" +release = "1.6.6" # -- General configuration --------------------------------------------------- diff --git a/docs/videocolor.rst b/docs/videocolor.rst index 07bf156..db8a920 100644 --- a/docs/videocolor.rst +++ b/docs/videocolor.rst @@ -57,7 +57,7 @@ We could, **alternatively**, handle the saving ourselves and go as below: >>> image.write_color_count(font_color=(255,255,255)) - >>> image.save_image(location='path/to/directory/of/choice', filename='filenameofchoice.jpg') + >>> image.save_image(location='path/to/directory/of/choice', file_name='filenameofchoice.jpg') diff --git a/pyproject.toml b/pyproject.toml index ea1e8e0..3407a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" +requires = ["uv_build"] +build-backend = "uv_build" [project] name = "ColorDetect" -version = "1.6.5" +version = "1.6.6" authors = [ {name = "Marvin Kweyu", email = "hello@marvinkweyu.net"}, ] @@ -37,5 +37,5 @@ dev = [ "pre-commit", ] -[tool.setuptools.packages.find] -where = ["."] +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/colordetect/__init__.py b/src/colordetect/__init__.py similarity index 100% rename from colordetect/__init__.py rename to src/colordetect/__init__.py diff --git a/colordetect/col_share.py b/src/colordetect/col_share.py similarity index 100% rename from colordetect/col_share.py rename to src/colordetect/col_share.py diff --git a/colordetect/color_detect.py b/src/colordetect/color_detect.py similarity index 95% rename from colordetect/color_detect.py rename to src/colordetect/color_detect.py index 90758f1..ef1a98b 100644 --- a/colordetect/color_detect.py +++ b/src/colordetect/color_detect.py @@ -160,15 +160,15 @@ def get_segmented_image( segmented = cv2.bitwise_and(self.image_original, self.image_original, mask=mask) - for i in range(len(mask)): - for j in range(len(mask[i])): - if mask[i][j] != 0: - output_image[i][j] = self.image_original[i][j] + output_image[mask != 0] = self.image_original[mask != 0] return output_image, gray, segmented, mask def get_color_count( - self, color_count: int = 5, color_format: str = "human_readable" + self, + color_count: int = 5, + color_format: str = "human_readable", + max_pixels: int = 10_000, ) -> dict: """ .. _get_color_count: @@ -188,6 +188,10 @@ def get_color_count( * rgb - rgb(255, 255, 0) for yellow * hex - #FFFF00 for yellow * human_readable - yellow for yellow + max_pixels: int + Maximum number of pixels sampled for clustering. Reduces memory and + CPU usage on large images with negligible impact on color accuracy. + Set to None to disable downsampling. :return: color description """ @@ -198,8 +202,11 @@ def get_color_count( # convert image from BGR to RGB for better accuracy rgb = cv2.cvtColor(self.image, cv2.COLOR_BGR2RGB) - reshape = rgb.reshape((rgb.shape[0] * rgb.shape[1], 3)) - cluster = KMeans(n_clusters=color_count).fit(reshape) + reshape = rgb.reshape((-1, 3)) + if max_pixels is not None and len(reshape) > max_pixels: + indices = np.random.choice(len(reshape), max_pixels, replace=False) + reshape = reshape[indices] + cluster = KMeans(n_clusters=color_count, n_init=10).fit(reshape) unique_colors = self._find_unique_colors(cluster, cluster.cluster_centers_) @@ -252,7 +259,7 @@ def _find_unique_colors(self, cluster, centroids) -> dict: # Get the number of different clusters, create histogram, and normalize labels = np.arange(0, len(np.unique(cluster.labels_)) + 1) - (hist, _) = np.histogram(cluster.labels_, bins=labels) + hist, _ = np.histogram(cluster.labels_, bins=labels) hist = hist.astype("float") hist /= hist.sum() diff --git a/colordetect/video_color_detect.py b/src/colordetect/video_color_detect.py similarity index 67% rename from colordetect/video_color_detect.py rename to src/colordetect/video_color_detect.py index 49b0472..7c46567 100644 --- a/colordetect/video_color_detect.py +++ b/src/colordetect/video_color_detect.py @@ -9,16 +9,13 @@ >>> from colordetect import VideoColor >>> user_video = VideoColor("") # where frame_color_count is the target most dominant colors to be found. Default set to 5 ->>> colors = user_video.get_video_frames(frame_color_count=7) +>>> colors = user_video.get_video_frames(frame_color_count=7) >>> colors -# alternatively shorten the dictionary to get a specific number of sorted colors from the whole lot +# shorten the result to the N most dominant colors across the whole video >>> from colordetect import col_share >>> top_colors = col_share.sort_order(object_description=colors, key_count=8) """ -import datetime -import sys - import cv2 from . import col_share @@ -31,7 +28,6 @@ class VideoColor: """ def __init__(self, video): - # super().__init__(video) self.video_file = cv2.VideoCapture(video) self.color_description = {} @@ -51,12 +47,14 @@ def get_video_frames( ---------- frame_color_count: int The number of most dominant colors to be obtained from a single frame - color_format:str + color_format: str The format to return the color in. Options * hsv - (60°,100%,100%) * rgb - rgb(255, 255, 0) for yellow * hex - #FFFF00 for yellow + progress: bool + Show a progress bar during processing. Default False. :return: color_description dictionary """ if not isinstance(frame_color_count, int): @@ -74,30 +72,29 @@ def get_video_frames( count = 0 total_frame_count = self.video_file.get(cv2.CAP_PROP_FRAME_COUNT) - while self.video_file.isOpened(): - # how often to extract colors from video frames. Defaults to every 1 second - (success, image) = self._get_frame(time=count * 1000) - if not success: - break # Video is complete - image_object = ColorDetect(image) - colors = image_object.get_color_count( - color_count=frame_color_count, color_format=color_format - ) - # merge dictionaries as they are created - self.color_description = {**self.color_description, **colors} - count += 1 - current_frame_num = self.video_file.get(cv2.CAP_PROP_POS_FRAMES) + try: + while self.video_file.isOpened(): + success, image = self._get_frame(time=count * 1000) + if not success: + break + colors = ColorDetect(image).get_color_count( + color_count=frame_color_count, color_format=color_format + ) + self.color_description.update(colors) + count += 1 + if progress: + col_share.progress_bar( + position=self.video_file.get(cv2.CAP_PROP_POS_FRAMES), + total_length=total_frame_count, + ) if progress: + # Flush bar to 100% for any trailing millis skipped at the end col_share.progress_bar( - position=current_frame_num, total_length=total_frame_count + position=total_frame_count, total_length=total_frame_count ) - if progress: - col_share.progress_bar( - position=total_frame_count, total_length=total_frame_count - ) # Cater for video with extra millis at the end that don't sum upto a full sec, and are thus skipped + finally: + self.video_file.release() - self.video_file.release() - print("\n") return self.color_description def _get_frame(self, time: int = 1000) -> tuple: @@ -112,13 +109,10 @@ def _get_frame(self, time: int = 1000) -> tuple: time: int Time to get color from in parsed image - :return: () + :return: (success, image) """ - # read file every x time in milliseconds self.video_file.set(cv2.CAP_PROP_POS_MSEC, time) - success, image = self.video_file.read() - - return (success, image) + return self.video_file.read() def get_time_frame_color( self, color_count: int = 5, color_format: str = "rgb", time: int = 1000 @@ -136,7 +130,7 @@ def get_time_frame_color( Time to get color from in video in milliseconds color_count: int Number of colors to return at the given time frame - color_format:str + color_format: str The format to return the color in. Options * hsv - (60°,100%,100%) @@ -157,23 +151,23 @@ def get_time_frame_color( if time < 1: raise ValueError("Cannot give negative time to extract color from") - if self._get_video_length() < time: + video_length = self._get_video_length() + if video_length < time: raise ValueError( - f"The time given is longer than the video parsed. Provided {time} while length of video: {self._get_video_length()}" + f"The time given is longer than the video parsed. Provided {time} while length of video: {video_length}" ) - (success, image) = self._get_frame(time) - if success: - image_object = ColorDetect(image) - colors = image_object.get_color_count( - color_count=color_count, color_format=color_format - ) - - self.color_description = colors - - self.video_file.release() + try: + success, image = self._get_frame(time) + if success: + image_object = ColorDetect(image) + self.color_description = image_object.get_color_count( + color_count=color_count, color_format=color_format + ) + finally: + self.video_file.release() - return (image_object, self.color_description) + return image_object, self.color_description def _get_video_length(self) -> int: """ @@ -182,7 +176,7 @@ def _get_video_length(self) -> int: ---------------- get the length of a video - return: the length of a video + return: the length of a video in milliseconds """ frames = self.video_file.get(cv2.CAP_PROP_FRAME_COUNT) fps = self.video_file.get(cv2.CAP_PROP_FPS) diff --git a/tests/test_functional.py b/tests/test_functional.py index 54c4dfb..73f6967 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -8,7 +8,7 @@ import matplotlib.colors as mcolors -from ..colordetect import ColorDetect +from colordetect import ColorDetect def test_existence_of_image_vid_path(image, video): diff --git a/tests/test_unit.py b/tests/test_unit.py index 1adcc0d..5640ed4 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -8,7 +8,7 @@ import pytest -from ..colordetect import ColorDetect, VideoColor, col_share +from colordetect import ColorDetect, VideoColor, col_share def test_image_vid_parsed_to_class(image, video): @@ -177,7 +177,7 @@ def test_get_time_frame_color_returns_video_colors_at_given_time(video): """ user_video = VideoColor(video) - (image, colors_at_time) = user_video.get_time_frame_color(time=10000) + image, colors_at_time = user_video.get_time_frame_color(time=10000) assert isinstance(image, ColorDetect) assert isinstance(colors_at_time, dict)