From bcf581195f85aefb970eba2864878cde407ccde2 Mon Sep 17 00:00:00 2001 From: Thomas Mansencal Date: Fri, 5 Jan 2024 19:38:28 +1300 Subject: [PATCH] Stash current work. --- README.rst | 15 +- colour_checker_detection/__init__.py | 16 +- .../detection/__init__.py | 56 +- colour_checker_detection/detection/common.py | 1007 +++++++++++++++-- .../detection/inference.py | 860 +++++--------- .../detection/segmentation.py | 816 ++++++------- .../detection/tests/test_common.py | 317 +++++- .../detection/tests/test_inference.py | 297 +++++ .../detection/tests/test_segmentation.py | 519 ++++----- .../examples/examples_detection.ipynb | 8 +- colour_checker_detection/scripts/inference.py | 27 +- docs/colour_checker_detection.detection.rst | 19 +- docs/index.rst | 10 +- docs/installation.rst | 5 + 14 files changed, 2465 insertions(+), 1507 deletions(-) create mode 100644 colour_checker_detection/detection/tests/test_inference.py diff --git a/README.rst b/README.rst index b506fcc..258bc58 100644 --- a/README.rst +++ b/README.rst @@ -39,7 +39,15 @@ Features The following colour checker detection algorithms are implemented: -- Segmentation +- Segmentation +- Machine learning inference via `Ultralytics YOLOv8 `__ + + - The model is published on `HuggingFace `__, + it was trained on a purposely constructed `dataset `__. + - Inference is performed by a script licensed under the terms of the + *GNU Affero General Public License v3.0* as it uses the + *Ultralytics YOLOv8* API which is incompatible with the + *BSD-3-Clause*. Examples ^^^^^^^^ @@ -72,6 +80,11 @@ Primary Dependencies - `opencv-python >= 4, < 5 `__ - `scipy >= 1.8, < 2 `__ +Secondary Dependencies +~~~~~~~~~~~~~~~~~~~~~~ + +- `ultralytics >= 8, < 9 `__ + Pypi ~~~~ diff --git a/colour_checker_detection/__init__.py b/colour_checker_detection/__init__.py index 5bd3dbb..ded92ca 100644 --- a/colour_checker_detection/__init__.py +++ b/colour_checker_detection/__init__.py @@ -20,11 +20,15 @@ import numpy as np from .detection import ( + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC, + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI, SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC, + SETTINGS_SEGMENTATION_COLORCHECKER_NANO, SETTINGS_SEGMENTATION_COLORCHECKER_SG, - colour_checkers_coordinates_segmentation, + detect_colour_checkers_inference, detect_colour_checkers_segmentation, - extract_colour_checkers_segmentation, + inferencer_default, + segmenter_default, ) __author__ = "Colour Developers" @@ -35,11 +39,15 @@ __status__ = "Production" __all__ = [ + "SETTINGS_INFERENCE_COLORCHECKER_CLASSIC", + "SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI", "SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC", + "SETTINGS_SEGMENTATION_COLORCHECKER_NANO", "SETTINGS_SEGMENTATION_COLORCHECKER_SG", - "colour_checkers_coordinates_segmentation", - "extract_colour_checkers_segmentation", + "detect_colour_checkers_inference", "detect_colour_checkers_segmentation", + "inferencer_default", + "segmenter_default", ] ROOT_RESOURCES: str = os.path.join(os.path.dirname(__file__), "resources") diff --git a/colour_checker_detection/detection/__init__.py b/colour_checker_detection/detection/__init__.py index 61de16b..255fe6a 100644 --- a/colour_checker_detection/detection/__init__.py +++ b/colour_checker_detection/detection/__init__.py @@ -1,35 +1,73 @@ from .common import ( - FLOAT_DTYPE_DEFAULT, + DTYPE_INT_DEFAULT, + DTYPE_FLOAT_DEFAULT, + SETTINGS_DETECTION_COLORCHECKER_CLASSIC, + SETTINGS_DETECTION_COLORCHECKER_SG, + SETTINGS_CONTOUR_DETECTION_DEFAULT, + as_int32_array, + as_float32_array, swatch_masks, - adjust_image, - crop_with_rectangle, + swatch_colours, + reformat_image, + transform_image, + detect_contours, is_square, contour_centroid, scale_contour, approximate_contour, + quadrilateralise_contours, + remove_stacked_contours, + DataDetectionColourChecker, + sample_colour_checker, ) +from .inference import ( + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC, + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI, + inferencer_default, + detect_colour_checkers_inference, +) + from .segmentation import ( SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC, SETTINGS_SEGMENTATION_COLORCHECKER_SG, - colour_checkers_coordinates_segmentation, - extract_colour_checkers_segmentation, + SETTINGS_SEGMENTATION_COLORCHECKER_NANO, + segmenter_default, detect_colour_checkers_segmentation, ) __all__ = [ - "FLOAT_DTYPE_DEFAULT", + "DTYPE_INT_DEFAULT", + "DTYPE_FLOAT_DEFAULT", + "SETTINGS_DETECTION_COLORCHECKER_CLASSIC", + "SETTINGS_DETECTION_COLORCHECKER_SG", + "SETTINGS_CONTOUR_DETECTION_DEFAULT", + "as_int32_array", + "as_float32_array", "swatch_masks", - "adjust_image", - "crop_with_rectangle", + "swatch_colours", + "reformat_image", + "transform_image", + "detect_contours", "is_square", "contour_centroid", "scale_contour", "approximate_contour", + "quadrilateralise_contours", + "remove_stacked_contours", + "DataDetectionColourChecker", + "sample_colour_checker", ] __all__ += [ "SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC", "SETTINGS_SEGMENTATION_COLORCHECKER_SG", - "colour_checkers_coordinates_segmentation", + "SETTINGS_SEGMENTATION_COLORCHECKER_NANO", + "segmenter_default", "extract_colour_checkers_segmentation", "detect_colour_checkers_segmentation", ] +__all__ += [ + "SETTINGS_INFERENCE_COLORCHECKER_CLASSIC", + "SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI", + "inferencer_default", + "detect_colour_checkers_inference", +] diff --git a/colour_checker_detection/detection/common.py b/colour_checker_detection/detection/common.py index b2235c8..8d649bf 100644 --- a/colour_checker_detection/detection/common.py +++ b/colour_checker_detection/detection/common.py @@ -3,28 +3,55 @@ ================ Defines the common utilities objects that don't fall in any specific category. + +References +---------- +- :cite:`Dallas2024` : Dallas, J. (2024). [BUG]: Flipped colour chart. + https://github.com/colour-science/colour-checker-detection/issues/\ +73#issuecomment-1879471360 +- :cite:`Olferuk2019` : Olferuk, A. (2019). How to force approxPolyDP() to + return only the best 4 corners? - Opencv 2.4.2. https://stackoverflow.com/\ +a/55339684/931625 +- :cite:`Walter2022` : Walter, T. (2022). [ENHANCEMENT] Proposal to allow + detection from different perspectives. Retrieved January 8, 2024, from + https://github.com/colour-science/colour-checker-detection/issues/60 """ from __future__ import annotations +from dataclasses import dataclass + import cv2 import numpy as np +from colour.algebra import linear_conversion +from colour.characterisation import CCS_COLOURCHECKERS from colour.hints import ( + Any, ArrayLike, + Dict, DTypeFloat, + DTypeInt, Literal, NDArrayFloat, NDArrayInt, Tuple, Type, + Union, cast, ) +from colour.models import XYZ_to_RGB, xyY_to_XYZ from colour.utilities import ( + MixinDataclassIterable, + Structure, + as_array, as_float_array, - as_int, as_int_array, - as_int_scalar, - orient, + metric_mse, + usage_warning, +) +from colour.utilities.documentation import ( + DocstringDict, + is_documentation_building, ) __author__ = "Colour Developers" @@ -35,19 +62,169 @@ __status__ = "Production" __all__ = [ - "FLOAT_DTYPE_DEFAULT", + "DTYPE_INT_DEFAULT", + "DTYPE_FLOAT_DEFAULT", + "SETTINGS_DETECTION_COLORCHECKER_CLASSIC", + "SETTINGS_DETECTION_COLORCHECKER_SG", + "SETTINGS_CONTOUR_DETECTION_DEFAULT", + "as_int32_array", + "as_float32_array", "swatch_masks", - "adjust_image", - "crop_with_rectangle", + "swatch_colours", + "reformat_image", + "transform_image", + "detect_contours", "is_square", "contour_centroid", "scale_contour", "approximate_contour", + "quadrilateralise_contours", + "remove_stacked_contours", + "DataDetectionColourChecker", + "sample_colour_checker", ] +DTYPE_INT_DEFAULT: Type[DTypeInt] = np.int32 +"""Default int number dtype.""" + +DTYPE_FLOAT_DEFAULT: Type[DTypeFloat] = np.float32 +"""Default floating point number dtype.""" + + +_COLOURCHECKER = CCS_COLOURCHECKERS["ColorChecker24 - After November 2014"] +_COLOURCHECKER_VALUES = XYZ_to_RGB( + xyY_to_XYZ(list(_COLOURCHECKER.data.values())), + "sRGB", + _COLOURCHECKER.illuminant, +) +SETTINGS_DETECTION_COLORCHECKER_CLASSIC: Dict = { + "aspect_ratio": 6 / 4, + "swatches": 24, + "swatches_horizontal": 6, + "swatches_vertical": 4, + "swatches_chromatic_slice": slice(0 + 1, 0 + 6 - 1, 1), + "swatches_achromatic_slice": slice(18 + 1, 18 + 6 - 1, 1), + "working_width": 1440, + "working_height": int(1440 * 4 / 6), + "interpolation_method": cv2.INTER_CUBIC, + "reference_values": _COLOURCHECKER_VALUES, + "transform": None, +} +if is_documentation_building(): # pragma: no cover + SETTINGS_DETECTION_COLORCHECKER_CLASSIC = DocstringDict( + SETTINGS_DETECTION_COLORCHECKER_CLASSIC + ) + SETTINGS_DETECTION_COLORCHECKER_CLASSIC.__doc__ = """ +Settings for the detection of the *X-Rite* *ColorChecker Classic* and +*X-Rite* *ColorChecker Passport*. +""" + +SETTINGS_DETECTION_COLORCHECKER_SG: Dict = ( + SETTINGS_DETECTION_COLORCHECKER_CLASSIC.copy() +) + +# TODO: Update when "Colour" 0.4.5 is released. +_COLOURCHECKER = CCS_COLOURCHECKERS.get("ColorCheckerSG - After November 2014") +if _COLOURCHECKER is not None: + _COLOURCHECKER_VALUES = XYZ_to_RGB( + xyY_to_XYZ(list(_COLOURCHECKER.data.values())), + "sRGB", + _COLOURCHECKER.illuminant, + ) +else: + _COLOURCHECKER_VALUES = None +SETTINGS_DETECTION_COLORCHECKER_SG.update( + { + "swatches": 140, + "swatches_horizontal": 14, + "swatches_vertical": 10, + "swatches_chromatic_slice": slice(48, 48 + 5, 1), + "swatches_achromatic_slice": slice(115, 115 + 5, 1), + "aspect_ratio": 14 / 10, + "working_height": int(1440 * 10 / 14), + "reference_values": _COLOURCHECKER_VALUES, + } +) +if is_documentation_building(): # pragma: no cover + SETTINGS_DETECTION_COLORCHECKER_SG = DocstringDict( + SETTINGS_DETECTION_COLORCHECKER_SG + ) + SETTINGS_DETECTION_COLORCHECKER_SG.__doc__ = """ +Settings for the detection of the *X-Rite* *ColorChecker SG**. +""" + +del _COLOURCHECKER, _COLOURCHECKER_VALUES + +SETTINGS_CONTOUR_DETECTION_DEFAULT: Dict = { + "bilateral_filter_iterations": 5, + "bilateral_filter_kwargs": {"sigmaColor": 5, "sigmaSpace": 5}, + "adaptive_threshold_kwargs": { + "maxValue": 255, + "adaptiveMethod": cv2.ADAPTIVE_THRESH_MEAN_C, + "thresholdType": cv2.THRESH_BINARY, + "blockSize": int(1440 * 0.015) - int(1440 * 0.015) % 2 + 1, + "C": 3, + }, + "convolution_kernel": np.ones([3, 3], np.uint8), + "convolution_iterations": 1, +} +if is_documentation_building(): # pragma: no cover + SETTINGS_CONTOUR_DETECTION_DEFAULT = DocstringDict( + SETTINGS_CONTOUR_DETECTION_DEFAULT + ) + SETTINGS_CONTOUR_DETECTION_DEFAULT.__doc__ = """ +Settings for contour detection. +""" + + +def as_int32_array(a: ArrayLike) -> NDArrayInt: + """ + Convert given variable :math:`a` to :class:`numpy.ndarray` using + `np.int32` :class:`numpy.dtype`. + + Parameters + ---------- + a + Variable :math:`a` to convert. + + Returns + ------- + :class:`numpy.ndarray` + Variable :math:`a` converted to :class:`numpy.ndarray` using + `np.int32` :class:`numpy.dtype`. -FLOAT_DTYPE_DEFAULT: Type[DTypeFloat] = np.float32 -"""Dtype used for the computations.""" + Examples + -------- + >>> as_int32_array([1.5, 2.5, 3.5]) # doctest: +ELLIPSIS + array([1, 2, 3]...) + """ + + return as_int_array(a, dtype=DTYPE_INT_DEFAULT) + + +def as_float32_array(a: ArrayLike) -> NDArrayFloat: + """ + Convert given variable :math:`a` to :class:`numpy.ndarray` using + `np.float32` :class:`numpy.dtype`. + + Parameters + ---------- + a + Variable :math:`a` to convert. + + Returns + ------- + :class:`numpy.ndarray` + Variable :math:`a` converted to :class:`numpy.ndarray` using + `np.float32` :class:`numpy.dtype`. + + Examples + -------- + >>> as_float32_array([1, 2, 3]) # doctest: +ELLIPSIS + array([ 1., 2., 3.]...) + """ + + return as_float_array(a, dtype=DTYPE_FLOAT_DEFAULT) def swatch_masks( @@ -56,7 +233,7 @@ def swatch_masks( swatches_h: int, swatches_v: int, samples: int, -) -> Tuple[NDArrayInt, ...]: +) -> NDArrayInt: """ Return swatch masks for given image width and height and swatches count. @@ -71,7 +248,7 @@ def swatch_masks( swatches_v Vertical swatches count. samples - Samples count. + Sample count. Returns ------- @@ -82,17 +259,17 @@ def swatch_masks( -------- >>> from pprint import pprint >>> pprint(swatch_masks(16, 8, 4, 2, 1)) # doctest: +ELLIPSIS - (array([2, 2, 2, 2]...), - array([2, 2, 6, 6]...), - array([ 2, 2, 10, 10]...), - array([ 2, 2, 14, 14]...), - array([6, 6, 2, 2]...), - array([6, 6, 6, 6]...), - array([ 6, 6, 10, 10]...), - array([ 6, 6, 14, 14]...)) + array([[ 1, 3, 1, 3], + [ 1, 3, 5, 7], + [ 1, 3, 9, 11], + [ 1, 3, 13, 15], + [ 5, 7, 1, 3], + [ 5, 7, 5, 7], + [ 5, 7, 9, 11], + [ 5, 7, 13, 15]]...) """ - samples_half = as_int(samples / 2) + samples_half = max(samples / 2, 1) masks = [] offset_h = width / swatches_h / 2 @@ -100,7 +277,7 @@ def swatch_masks( for j in np.linspace(offset_v, height - offset_v, swatches_v): for i in np.linspace(offset_h, width - offset_h, swatches_h): masks.append( - as_int_array( + as_int32_array( [ j - samples_half, j + samples_half, @@ -110,29 +287,81 @@ def swatch_masks( ) ) - return tuple(masks) + return as_int32_array(masks) + + +def swatch_colours(image: ArrayLike, masks: ArrayLike) -> NDArrayFloat: + """ + Extract the swatch colours from given image using given masks. + + Parameters + ---------- + image + Image to extract the swatch colours from. + masks + Masks to use to extract the swatch colours from the image. + + Returns + ------- + :class:`numpy.ndarray` + Extracted swatch colours. + + Examples + -------- + >>> from colour.utilities import tstack, zeros + >>> x = np.linspace(0, 1, 16) + >>> y = np.linspace(0, 1, 8) + >>> xx, yy = np.meshgrid(x, y) + >>> image = tstack([xx, yy, zeros(xx.shape)]) + >>> swatch_colours(image, swatch_masks(16, 8, 4, 2, 1)) # doctest: +ELLIPSIS + array([[ 0.1 ..., 0.2142857..., 0. ...], + [ 0.3666666..., 0.2142857..., 0. ...], + [ 0.6333333..., 0.2142857..., 0. ...], + [ 0.9 ..., 0.2142857..., 0. ...], + [ 0.1 ..., 0.7857142..., 0. ...], + [ 0.3666666..., 0.7857142..., 0. ...], + [ 0.6333333..., 0.7857142..., 0. ...], + [ 0.9 ..., 0.7857142..., 0. ...]]...) + """ + + image = as_array(image) + masks = as_int32_array(masks) + + return as_float32_array( + [ + np.mean( + image[mask[0] : mask[1], mask[2] : mask[3], ...], + axis=(0, 1), + ) + for mask in masks + ] + ) -def adjust_image( +def reformat_image( image: ArrayLike, target_width: int, interpolation_method: Literal[ cv2.INTER_AREA, # pyright: ignore - cv2.INTER_BITS, # pyright: ignore - cv2.INTER_BITS2, # pyright: ignore cv2.INTER_CUBIC, # pyright: ignore cv2.INTER_LANCZOS4, # pyright: ignore cv2.INTER_LINEAR, # pyright: ignore + cv2.INTER_LINEAR_EXACT, # pyright: ignore + cv2.INTER_MAX, # pyright: ignore + cv2.INTER_NEAREST, # pyright: ignore + cv2.INTER_NEAREST_EXACT, # pyright: ignore + cv2.WARP_FILL_OUTLIERS, # pyright: ignore + cv2.WARP_INVERSE_MAP, # pyright: ignore ] = cv2.INTER_CUBIC, ) -> NDArrayInt | NDArrayFloat: """ - Adjust given image so that it is horizontal and resizes it to given target + Reformat given image so that it is horizontal and resizes it to given target width. Parameters ---------- image - Image to adjust. + Image to reformat. target_width Width the image is resized to. interpolation_method @@ -141,23 +370,47 @@ def adjust_image( Returns ------- :class:`numpy.ndarray` - Resized image. + Reformatted image. Examples -------- >>> image = np.arange(24).reshape([2, 4, 3]) - >>> adjust_image(image, 5) # doctest: +SKIP - array([[[ -0.18225056, 0.8177495 , 1.8177495 ], - [ 1.8322501 , 2.83225 , 3.83225 ], - [ 4.5 , 5.5 , 6.5 ], - [ 7.1677475 , 8.167748 , 9.167748 ], - [ 9.182249 , 10.182249 , 11.182249 ]], + >>> image # doctest: +ELLIPSIS + array([[[ 0, 1, 2], + [ 3, 4, 5], + [ 6, 7, 8], + [ 9, 10, 11]], + + [[12, 13, 14], + [15, 16, 17], + [18, 19, 20], + [21, 22, 23]]]...) + + # NOTE: Need to use `cv2.INTER_NEAREST_EXACT` or `cv2.INTER_LINEAR_EXACT` + # for integer images. + + >>> reformat_image(image, 6, interpolation_method=cv2.INTER_LINEAR_EXACT) + ... # doctest: +ELLIPSIS + array([[[ 0, 1, 2], + [ 2, 3, 4], + [ 4, 5, 6], + [ 5, 6, 7], + [ 8, 9, 10], + [ 9, 10, 11]], - [[ 11.817749 , 12.81775 , 13.817749 ], - [ 13.83225 , 14.832251 , 15.832251 ], - [ 16.5 , 17.5 , 18.5 ], - [ 19.16775 , 20.167747 , 21.167747 ], - [ 21.182247 , 22.18225 , 23.182251 ]]], dtype=float32) + [[ 6, 7, 8], + [ 8, 9, 10], + [10, 11, 12], + [12, 13, 14], + [14, 15, 16], + [15, 16, 17]], + + [[12, 13, 14], + [14, 15, 16], + [16, 17, 18], + [17, 18, 19], + [20, 21, 22], + [21, 22, 23]]]...) """ image = np.asarray(image) @@ -172,93 +425,256 @@ def adjust_image( ratio = width / target_width - if np.allclose(ratio, 1): - return cast(NDArrayInt | NDArrayFloat, image) - else: - return cv2.resize( # pyright: ignore - image, - (as_int_scalar(target_width), as_int_scalar(height / ratio)), - interpolation=interpolation_method, - ) + return cv2.resize( # pyright: ignore + image, + (target_width, int(height / ratio)), + interpolation=interpolation_method, + ) -def crop_with_rectangle( - image: ArrayLike, - rectangle: Tuple[Tuple, Tuple, float], +def transform_image( + image, + translation=np.array([0, 0]), + rotation=0, + scale=np.array([1, 1]), interpolation_method: Literal[ cv2.INTER_AREA, # pyright: ignore - cv2.INTER_BITS, # pyright: ignore - cv2.INTER_BITS2, # pyright: ignore cv2.INTER_CUBIC, # pyright: ignore cv2.INTER_LANCZOS4, # pyright: ignore cv2.INTER_LINEAR, # pyright: ignore + cv2.INTER_LINEAR_EXACT, # pyright: ignore + cv2.INTER_MAX, # pyright: ignore + cv2.INTER_NEAREST, # pyright: ignore + cv2.INTER_NEAREST_EXACT, # pyright: ignore + cv2.WARP_FILL_OUTLIERS, # pyright: ignore + cv2.WARP_INVERSE_MAP, # pyright: ignore ] = cv2.INTER_CUBIC, -) -> NDArrayFloat: +) -> NDArrayInt | NDArrayFloat: """ - Crop and rotate/level given image using given rectangle. + Transform given image using given translation, rotation and scale values. + + The transformation is performed relatively to the image center and in the + following order: + + 1. Scale + 2. Rotation + 3. Translation Parameters ---------- image - Image to crop and rotate/level. - rectangle - Rectangle used to crop and rotate/level the image. + Image to transform. + translation + Translation value. + rotation + Rotation value in degrees. + scale + Scale value. interpolation_method Interpolation method. Returns ------- :class:`numpy.ndarray` - Cropped and rotated/levelled image. + Transformed image. - References + Examples + -------- + >>> image = np.arange(24).reshape([2, 4, 3]) + >>> image # doctest: +ELLIPSIS + array([[[ 0, 1, 2], + [ 3, 4, 5], + [ 6, 7, 8], + [ 9, 10, 11]], + + [[12, 13, 14], + [15, 16, 17], + [18, 19, 20], + [21, 22, 23]]]...) + + # NOTE: Need to use `cv2.INTER_NEAREST` for integer images. + + >>> transform_image( + ... image, + ... translation=np.array([1, 0]), + ... interpolation_method=cv2.INTER_NEAREST + ... ) # doctest: +ELLIPSIS + array([[[ 0, 1, 2], + [ 0, 1, 2], + [ 3, 4, 5], + [ 6, 7, 8]], + + [[12, 13, 14], + [12, 13, 14], + [15, 16, 17], + [18, 19, 20]]]...) + >>> transform_image( + ... image, + ... rotation=90, + ... interpolation_method=cv2.INTER_NEAREST + ... ) # doctest: +ELLIPSIS + array([[[15, 16, 17], + [15, 16, 17], + [15, 16, 17], + [ 3, 4, 5]], + + [[18, 19, 20], + [18, 19, 20], + [18, 19, 20], + [ 6, 7, 8]]]...) + >>> transform_image( + ... image, + ... scale=np.array([2, 0.5]), + ... interpolation_method=cv2.INTER_NEAREST + ... ) # doctest: +ELLIPSIS + array([[[ 3, 4, 5], + [ 6, 7, 8], + [ 6, 7, 8], + [ 9, 10, 11]], + + [[15, 16, 17], + [18, 19, 20], + [18, 19, 20], + [21, 22, 23]]]...) + """ + + image = as_array(image) + + t_x, t_y = as_float32_array(translation) + s_x, s_y = as_float32_array(scale) + + center_x, center_y = image.shape[1] / 2, image.shape[0] / 2 + scale_transform = np.array( + [[s_x, 0, (center_x) * (1 - s_x)], [0, s_y, (center_y) * (1 - s_y)]], + dtype=np.float32, + ) + scale_transform = np.vstack((scale_transform, [0, 0, 1])) + + rotation_transform = cv2.getRotationMatrix2D( + (center_x, center_y), -rotation, 1 + ) + rotation_transform = np.vstack((rotation_transform, [0, 0, 1])) + + transform = np.dot(rotation_transform, scale_transform)[:2, ...] + transform += as_float32_array([[0, 0, t_x], [0, 0, t_y]]) + + return cast( + Union[NDArrayInt, NDArrayFloat], + cv2.warpAffine( + image, + transform, + (image.shape[1], image.shape[0]), + borderMode=cv2.BORDER_REPLICATE, + flags=interpolation_method, + ), + ) + + +def detect_contours( + image: ArrayLike, additional_data: bool = False, **kwargs: Any +) -> Tuple[NDArrayInt] | Tuple[Tuple[NDArrayInt], NDArrayInt | NDArrayFloat]: + """ + Detect the contours of given image using given settings. + + The process is a follows: + + - Input image :math:`image` is converted to a grayscale image + :math:`image_g` and normalised to range [0, 1]. + - Image :math:`image_g` is denoised using multiple bilateral filtering + passes into image :math:`image_d.` + - Image :math:`image_d` is thresholded into image :math:`image_t`. + - Image :math:`image_t` is eroded and dilated to cleanup remaining noise + into image :math:`image_k`. + - Contours are detected on image :math:`image_k` + + Parameters ---------- - :cite:`Abecassis2011` + image + Image to detect the contour of. + additional_data + Whether to output additional data. + + Other Parameters + ---------------- + adaptive_threshold_kwargs + Keyword arguments for :func:`cv2.adaptiveThreshold` definition. + bilateral_filter_iterations + Number of iterations to use for bilateral filtering. + bilateral_filter_kwargs + Keyword arguments for :func:`cv2.bilateralFilter` definition. + convolution_iterations + Number of iterations to use for the erosion / dilation process. + convolution_kernel + Convolution kernel to use for the erosion / dilation process. + + Returns + ------- + :class:`numpy.ndarray` + Detected image contours. + + Warnings + -------- + The process and especially the default settings assume that the image has + been resized to :attr:`SETTINGS_DETECTION_COLORCHECKER_CLASSIC.working_width` + value! Examples -------- - >>> import os - >>> from colour import read_image - >>> from colour_checker_detection import ROOT_RESOURCES_TESTS - >>> path = os.path.join( - ... ROOT_RESOURCES_TESTS, - ... "colour_checker_detection", - ... "detection", - ... "IMG_1967.png", - ... ) - >>> image = adjust_image(read_image(path), 1440) - >>> rectangle = ( - ... (723.29608154, 465.50939941), - ... (461.24377441, 696.34759522), - ... -88.18692780, - ... ) - >>> print(image.shape) - (959, 1440, 3) - >>> image = crop_with_rectangle(image, rectangle) - >>> print(image.shape) - (461, 696, 3) + >>> from colour.utilities import zeros + >>> image = zeros([240, 320, 3]) + >>> image[150:190, 140:180] = 1 + >>> len(detect_contours(image)) + 3 """ - image = as_float_array(image, FLOAT_DTYPE_DEFAULT)[..., :3] + settings = Structure(**SETTINGS_CONTOUR_DETECTION_DEFAULT) + settings.update(**kwargs) - width, height = image.shape[1], image.shape[0] - width_r, height_r = rectangle[1] - centroid = contour_centroid(cv2.boxPoints(rectangle)) - angle = rectangle[-1] + image_g = np.max(image, axis=-1) + + # Normalisation + image_g = ( + linear_conversion(image_g, (np.min(image_g), np.max(image_g)), (0, 1)) + * 255 + ).astype(np.uint8) + + # Denoising + image_d = image_g + for _ in range(settings.bilateral_filter_iterations): + image_d = cv2.bilateralFilter( + image_d, -1, **settings.bilateral_filter_kwargs + ) - width_r, height_r = as_int_array([width_r, height_r]) + # Thresholding + image_t = cv2.adaptiveThreshold( + image_d, **settings.adaptive_threshold_kwargs + ) + + # Erosion / Dilation + image_k = cv2.erode( + image_t, + settings.convolution_kernel, + iterations=settings.convolution_iterations, + ) + image_k = cv2.dilate( + image_k, + settings.convolution_kernel, + iterations=settings.convolution_iterations, + ) - M_r = cv2.getRotationMatrix2D(centroid, angle, 1) + image_k = cast(Union[NDArrayInt, NDArrayFloat], image_k) - image_r = cv2.warpAffine(image, M_r, (width, height), interpolation_method) - image_c = cv2.getRectSubPix( - image_r, (width_r, height_r), (centroid[0], centroid[1]) + # Detecting contours. + contours, _hierarchy = cv2.findContours( + image_k, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE ) - if image_c.shape[0] > image_c.shape[1]: - image_c = orient(image_c, "90 CW") + contours = cast(Tuple[NDArrayInt], contours) - return image_c # pyright: ignore + if additional_data: + return contours, image_k + else: + return contours def is_square(contour: ArrayLike, tolerance: float = 0.015) -> bool: @@ -309,7 +725,7 @@ def contour_centroid(contour: ArrayLike) -> Tuple[float, float]: Returns ------- - :class:`tuple` + :class:`np.ndarray` Contour centroid. Notes @@ -320,21 +736,23 @@ def contour_centroid(contour: ArrayLike) -> Tuple[float, float]: Examples -------- >>> contour = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) - >>> contour_centroid(contour) + >>> contour_centroid(contour) # doctest: +ELLIPSIS (0.5, 0.5) """ - moments = cv2.moments(contour) # pyright: ignore + contour = as_float32_array(contour) + + moments = cv2.moments(contour) centroid = ( moments["m10"] / moments["m00"], moments["m01"] / moments["m00"], ) - return cast(Tuple[float, float], centroid) + return centroid -def scale_contour(contour: ArrayLike, factor: float) -> NDArrayFloat: +def scale_contour(contour: ArrayLike, factor: ArrayLike) -> NDArrayFloat: """ Scale given contour by given scale factor. @@ -350,6 +768,10 @@ def scale_contour(contour: ArrayLike, factor: float) -> NDArrayFloat: :class:`numpy.ndarray` Scaled contour. + Warnings + -------- + This definition returns floating point contours! + Examples -------- >>> contour = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) @@ -357,44 +779,393 @@ def scale_contour(contour: ArrayLike, factor: float) -> NDArrayFloat: array([[...-0.5, ...-0.5], [... 1.5, ...-0.5], [... 1.5, ... 1.5], - [...-0.5, ... 1.5]]) + [...-0.5, ... 1.5]]...) """ - centroid = as_float_array(contour_centroid(contour)) + contour = as_float32_array(contour) + factor = as_float32_array(factor) + + centroid = contour_centroid(contour) - scaled_contour = (as_float_array(contour) - centroid) * factor + centroid + scaled_contour = (contour - centroid) * factor + centroid return scaled_contour -# https://stackoverflow.com/a/55339684/931625 -def approximate_contour(contour, n_corners=4): +def approximate_contour( + contour: ArrayLike, points: int = 4, iterations: int = 100 +) -> NDArrayInt: """ - Binary searches best `epsilon` value to force contour - approximation contain exactly `n_corners` points. + Approximate given contour to have given number of points. + + The process uses binary search to find the best *epsilon* value + producing a contour approximation with exactly ``points``. + + Parameters + ---------- + contour + Contour to approximate. + points + Number of points to approximate the contour to. + iterations + Maximal number of iterations to perform to approximate the contour. + + Returns + ------- + :class:`numpy.ndarray` + Approximated contour. - :param contour: OpenCV2 contour. - :param n_corners: Number of corners (points) the contour must contain. + References + ---------- + :cite:`Olferuk2019` - :returns: Simplified contour in successful case. Otherwise returns initial contour. + Examples + -------- + >>> contour = np.array( + ... [[0, 0], [1, 0], [1, 1], [1, 2], [0, 1]] + ... ) + >>> approximate_contour(contour, 4) # doctest: +ELLIPSIS + array([[0, 0], + [1, 0], + [1, 2], + [0, 1]]...) """ - n_iter, max_iter = 0, 200 - lb, ub = 0, 1 + contour = as_int32_array(contour) + + i = 0 + low, high = 0, 1 while True: - n_iter += 1 - if n_iter > max_iter: + i += 1 + if i > iterations: return contour - k = (lb + ub) / 2 + center = (low + high) / 2 approximation = cv2.approxPolyDP( - contour, k * cv2.arcLength(contour, True), True + contour, center * cv2.arcLength(contour, True), True ) - if len(approximation) > n_corners: - lb = (lb + ub) / 2 - elif len(approximation) < n_corners: - ub = (lb + ub) / 2 + approximation = cast(NDArrayInt, approximation) + + if len(approximation) > points: + low = (low + high) / 2 + elif len(approximation) < points: + high = (low + high) / 2 + else: + return np.squeeze(approximation) + + +def quadrilateralise_contours(contours: ArrayLike) -> Tuple[NDArrayInt, ...]: + """ + Convert given to quadrilaterals. + + Parameters + ---------- + contours + Contours to convert to quadrilaterals + + Returns + ------- + :class:`tuple` + Quadrilateralised contours. + + Examples + -------- + >>> contours = np.array([ + ... [[0, 0], [1, 0], [1, 1], [1, 2], [0, 1]], + ... [[0, 0], [1, 2], [1, 0], [1, 1], [0, 1]] + ... ]) + >>> quadrilateralise_contours(contours) # doctest: +ELLIPSIS + (array([[0, 0], + [1, 0], + [1, 2], + [0, 1]]...), array([[0, 0], + [1, 2], + [1, 0], + [1, 1]]...)) + + """ + + return tuple( + as_int32_array(approximate_contour(contour, 4)) + for contour in contours # pyright: ignore + ) + + +def remove_stacked_contours( + contours: ArrayLike, keep_smallest: bool = True +) -> Tuple[NDArrayInt, ...]: + """ + Remove amd filter out the stacked contours from given contours keeping + either the smallest or the largest ones. + + Parameters + ---------- + contours + Stacked contours to filter. + keep_smallest + Whether to keep the smallest contours. + + Returns + ------- + :class:`tuple` + Filtered contours. + + References + ---------- + :cite:`Walter2022` + + Examples + -------- + >>> contours = np.array([ + ... [[0, 0], [7, 0], [7, 7], [0, 7]], + ... [[0, 0], [8, 0], [8, 8], [0, 8]], + ... [[0, 0], [10, 0], [10, 10], [0, 10]], + ... ]) + >>> remove_stacked_contours(contours) # doctest: +ELLIPSIS + (array([[0, 0], + [7, 0], + [7, 7], + [0, 7]]...) + >>> remove_stacked_contours(contours, False) # doctest: +ELLIPSIS + (array([[ 0, 0], + [10, 0], + [10, 10], + [ 0, 10]]...) + """ + + contours = as_int32_array(contours) + + filtered_contours = [] + + for contour in contours: + centroid = contour_centroid(contour) + + stacked_contours = [ + filtered_contour + for filtered_contour in filtered_contours + if cv2.pointPolygonTest(filtered_contour, centroid, False) > 0 + ] + + if not stacked_contours: + filtered_contours.append(contour) else: - return approximation + areas = as_float32_array( + [ + cv2.contourArea(stacked_contour) + for stacked_contour in stacked_contours + ] + ) + + if keep_smallest: + result = np.all(cv2.contourArea(contour) < areas) + index = 0 + else: + result = np.all(cv2.contourArea(contour) > areas) + index = -1 + + if result: + stacked_contour = as_int32_array(stacked_contours)[ + np.argsort(areas) + ][0] + + index = np.argwhere( + np.all( + as_int32_array(filtered_contours) == stacked_contour, + axis=(1, 2), + ) + )[index][0] + + filtered_contours[index] = contour + + return tuple( + as_int32_array(filtered_contour) + for filtered_contour in filtered_contours + ) + + +@dataclass +class DataDetectionColourChecker(MixinDataclassIterable): + """ + Colour checker swatches data used for plotting, debugging and further + analysis. + + Parameters + ---------- + swatch_colours + Colour checker swatches colours. + swatch_masks + Colour checker swatches masks. + colour_checker + Cropped and levelled Colour checker image. + """ + + swatch_colours: NDArrayFloat + swatch_masks: NDArrayInt + colour_checker: NDArrayFloat + + +def sample_colour_checker( + image: ArrayLike, quadrilateral, rectangle, samples=32, **kwargs +) -> DataDetectionColourChecker: + """ + Sample the colour checker using the given source quadrilateral, i.e. + detected colour checker in the image, and the given target rectangle. + + Parameters + ---------- + image + Image to sample from. + quadrilateral + Detected source quadrilateral where the colour checker has been detected. + rectangle + Target rectangle to warp the detected source quadrilateral onto. + samples + Sample count to use to sample the swatches colours. The effective + sample count is :math:`samples^2`. + + Other Parameters + ---------------- + reference_values + Reference values for the colour checker of interest. + swatches_horizontal + Colour checker swatches horizontal columns count. + swatches_vertical + Colour checker swatches vertical row count. + transform + Transform to apply to the colour checker image post-detection. + working_width + Width the input image is resized to for detection. + working_height + Height the input image is resized to for detection. + + Returns + ------- + :class:`colour_checker.DataDetectionColourChecker` + Sampling process data. + + References + ---------- + :cite:`Dallas2024` + + Examples + -------- + >>> import os + >>> from colour import read_image + >>> from colour_checker_detection import ROOT_RESOURCES_TESTS + >>> path = os.path.join( + ... ROOT_RESOURCES_TESTS, + ... "colour_checker_detection", + ... "detection", + ... "IMG_1967.png", + ... ) + >>> image = read_image(path) + >>> quadrilateral = np.array([[358, 691], [373, 219], [1086, 242], [1071, 713]]) + >>> rectangle = np.array([[1440, 0], [1440, 960], [0, 960], [0, 0]]) + >>> colour_checkers_data = sample_colour_checker(image, quadrilateral, rectangle) + >>> colour_checkers_data.swatch_colours # doctest: +SKIP + array([[ 0.75710917, 0.6763046 , 0.47606474], + [ 0.25871587, 0.21974973, 0.16204563], + [ 0.15012611, 0.11881837, 0.07829906], + [ 0.14475887, 0.11828972, 0.0747117 ], + [ 0.15182742, 0.12059662, 0.07984065], + [ 0.15811475, 0.12584405, 0.07951307], + [ 0.9996331 , 0.827563 , 0.5362377 ], + [ 0.2615244 , 0.22938406, 0.16862768], + [ 0.1580963 , 0.11951645, 0.0775518 ], + [ 0.16762769, 0.13303326, 0.08851139], + [ 0.17338796, 0.14148802, 0.08979498], + [ 0.17304046, 0.1419515 , 0.09080467], + [ 1. , 0.9890205 , 0.6780832 ], + [ 0.25435534, 0.2206379 , 0.1569271 ], + [ 0.15027192, 0.12475526, 0.0784394 ], + [ 0.3458355 , 0.21429974, 0.1121798 ], + [ 0.36254194, 0.2259509 , 0.11665937], + [ 0.62459683, 0.39099 , 0.24112946], + [ 0.97804743, 1. , 0.86419195], + [ 0.25577253, 0.22349517, 0.1584489 ], + [ 0.1595923 , 0.12591116, 0.08147947], + [ 0.35486832, 0.21910854, 0.11063413], + [ 0.3630804 , 0.22740598, 0.12138989], + [ 0.62340593, 0.39334935, 0.24371558]]...) + >>> colour_checkers_data.swatch_masks.shape + (24, 4) + >>> colour_checkers_data.colour_checker.shape + (960, 1440, 3) + """ + + image = as_array(image) + + settings = Structure(**SETTINGS_DETECTION_COLORCHECKER_CLASSIC) + settings.update(**kwargs) + + quadrilateral = as_float32_array(quadrilateral) + rectangle = as_float32_array(rectangle) + + swatches_horizontal = settings.swatches_horizontal + swatches_vertical = settings.swatches_vertical + working_width = settings.working_width + working_height = settings.working_height + + transform = cv2.getPerspectiveTransform(quadrilateral, rectangle) + colour_checker = cv2.warpPerspective( + image, + transform, + (working_width, working_height), + flags=settings.interpolation_method, + ) + + if settings.transform is not None: + colour_checker = transform_image(colour_checker, *settings.transform) + + masks = swatch_masks( + working_width, + working_height, + swatches_horizontal, + swatches_vertical, + samples, + ) + sampled_colours = swatch_colours(colour_checker, masks) + + # TODO: Update when "Colour" 0.4.5 is released. + if settings.reference_values is None: + usage_warning( + "Cannot compute the colour checker orientation because the " + 'reference values are not available! Please update "Colour" to a ' + "version greater-than 0.4.4." + ) + else: + reference_mse = metric_mse(settings.reference_values, sampled_colours) + for _ in range(3): + quadrilateral = np.roll(quadrilateral, 1, 0) + transform = cv2.getPerspectiveTransform( + quadrilateral, + rectangle, + ) + colour_checker_candidate = cv2.warpPerspective( + image, + transform, + (working_width, working_height), + flags=settings.interpolation_method, + ) + + if settings.transform is not None: + colour_checker_candidate = transform_image( + colour_checker_candidate, *settings.transform + ) + + candidate_sampled_colours = swatch_colours( + colour_checker_candidate, masks + ) + candidate_mse = metric_mse( + settings.reference_values, candidate_sampled_colours + ) + if candidate_mse < reference_mse: + reference_mse = candidate_mse + sampled_colours = candidate_sampled_colours + colour_checker = colour_checker_candidate + + colour_checker = cast(NDArrayFloat, colour_checker) + + return DataDetectionColourChecker(sampled_colours, masks, colour_checker) diff --git a/colour_checker_detection/detection/inference.py b/colour_checker_detection/detection/inference.py index 04e2349..61ff57a 100644 --- a/colour_checker_detection/detection/inference.py +++ b/colour_checker_detection/detection/inference.py @@ -1,44 +1,43 @@ """ -Colour Checker Detection - Segmentation -======================================= - -Defines the objects for colour checker detection using segmentation: - -- :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC` -- :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_SG` -- :func:`colour_checker_detection.colour_checkers_coordinates_segmentation` -- :func:`colour_checker_detection.extract_colour_checkers_segmentation` -- :func:`colour_checker_detection.detect_colour_checkers_segmentation` - -References ----------- -- :cite:`Abecassis2011` : Abecassis, F. (2011). OpenCV - Rotation - (Deskewing). Retrieved October 27, 2018, from http://felix.abecassis.me/\ -2011/10/opencv-rotation-deskewing/ +Colour Checker Detection - Inference +==================================== + +Defines the objects for colour checker detection using inference based on +*Ultralytics YOLOv8* machine learning model. + +- :attr:`colour_checker_detection.SETTINGS_INFERENCE_COLORCHECKER_CLASSIC` +- :func:`colour_checker_detection.inferencer_default` +- :func:`colour_checker_detection.detect_colour_checkers_inference` """ from __future__ import annotations -from dataclasses import dataclass +import os +import shutil +import subprocess +import sys +import tempfile import cv2 import numpy as np from colour.hints import ( Any, ArrayLike, + Callable, Dict, - List, NDArrayFloat, NDArrayInt, Tuple, + Union, cast, ) +from colour.io import convert_bit_depth, read_image, write_image +from colour.models import eotf_inverse_sRGB, eotf_sRGB +from colour.plotting import CONSTANTS_COLOUR_STYLE, plot_image from colour.utilities import ( - MixinDataclassIterable, Structure, - as_float_array, - as_int_array, - usage_warning, + as_int_scalar, + is_string, ) from colour.utilities.documentation import ( DocstringDict, @@ -46,14 +45,12 @@ ) from colour_checker_detection.detection.common import ( - FLOAT_DTYPE_DEFAULT, - adjust_image, - as_8_bit_BGR_image, - contour_centroid, - crop_and_level_image_with_rectangle, - is_square, - scale_contour, - swatch_masks, + DTYPE_FLOAT_DEFAULT, + SETTINGS_DETECTION_COLORCHECKER_CLASSIC, + DataDetectionColourChecker, + as_int32_array, + quadrilateralise_contours, + sample_colour_checker, ) __author__ = "Colour Developers" @@ -64,403 +61,104 @@ __status__ = "Production" __all__ = [ - "SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC", - "SETTINGS_SEGMENTATION_COLORCHECKER_SG", - "DataColourCheckersCoordinatesSegmentation", - "colour_checkers_coordinates_segmentation", - "extract_colour_checkers_segmentation", - "DataDetectColourCheckersSegmentation", - "detect_colour_checkers_segmentation", + "SETTINGS_INFERENCE_COLORCHECKER_CLASSIC", + "SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI", + "PATH_INFERENCE_SCRIPT_DEFAULT", + "inferencer_default", + "INFERRED_CLASSES", + "detect_colour_checkers_inference", ] -SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC: Dict = { - "aspect_ratio": 1.5, - "aspect_ratio_minimum": 1.5 * 0.9, - "aspect_ratio_maximum": 1.5 * 1.1, - "swatches": 24, - "swatches_horizontal": 6, - "swatches_vertical": 4, - "swatches_count_minimum": int(24 * 0.75), - "swatches_count_maximum": int(24 * 1.25), - "swatches_chromatic_slice": slice(0 + 1, 0 + 6 - 1, 1), - "swatches_achromatic_slice": slice(18 + 1, 18 + 6 - 1, 1), - "swatch_minimum_area_factor": 200, - "swatch_contour_scale": 1 + 1 / 3, - "cluster_contour_scale": 0.975, - "working_width": 1440, - "fast_non_local_means_denoising_kwargs": { - "h": 10, - "templateWindowSize": 7, - "searchWindowSize": 21, - }, - "adaptive_threshold_kwargs": { - "maxValue": 255, - "adaptiveMethod": cv2.ADAPTIVE_THRESH_MEAN_C, - "thresholdType": cv2.THRESH_BINARY, - "blockSize": int(1440 * 0.015) - int(1440 * 0.015) % 2 + 1, - "C": 3, - }, - "interpolation_method": cv2.INTER_CUBIC, -} +SETTINGS_INFERENCE_COLORCHECKER_CLASSIC: Dict = ( + SETTINGS_DETECTION_COLORCHECKER_CLASSIC.copy() +) if is_documentation_building(): # pragma: no cover - SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC = DocstringDict( - SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC = DocstringDict( + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC ) - SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC.__doc__ = """ -Settings for the segmentation of the *X-Rite* *ColorChecker Classic* and -*X-Rite* *ColorChecker Passport*. + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC.__doc__ = """ +Settings for the inference of the *X-Rite* *ColorChecker Classic*. """ - -SETTINGS_SEGMENTATION_COLORCHECKER_SG: Dict = ( - SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC.copy() -) - -SETTINGS_SEGMENTATION_COLORCHECKER_SG.update( +SETTINGS_INFERENCE_COLORCHECKER_CLASSIC.update( { - "aspect_ratio": 1.4, - "aspect_ratio_minimum": 1.4 * 0.9, - "aspect_ratio_maximum": 1.4 * 1.1, - "swatches": 140, - "swatches_horizontal": 14, - "swatches_vertical": 10, - "swatches_count_minimum": int(140 * 0.50), - "swatches_count_maximum": int(140 * 1.5), - "swatch_minimum_area_factor": 200, - "swatches_chromatic_slice": slice(48, 48 + 5, 1), - "swatches_achromatic_slice": slice(115, 115 + 5, 1), - "swatch_contour_scale": 1 + 1 / 3, - "cluster_contour_scale": 1, + "aspect_ratio": 1000 / 700, + "working_height": int(1440 / (1000 / 700)), + "transform": (np.array([0, 0]), 0, np.array([1.0, 1.05])), + "inferred_class": "ColorCheckerClassic24", + "inferred_confidence": 0.85, } ) + +SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI: Dict = ( + SETTINGS_DETECTION_COLORCHECKER_CLASSIC.copy() +) if is_documentation_building(): # pragma: no cover - SETTINGS_SEGMENTATION_COLORCHECKER_SG = DocstringDict( - SETTINGS_SEGMENTATION_COLORCHECKER_SG + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI = DocstringDict( + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI ) - SETTINGS_SEGMENTATION_COLORCHECKER_SG.__doc__ = """ -Settings for the segmentation of the *X-Rite* *ColorChecker SG**. + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI.__doc__ = """ +Settings for the inference of the *X-Rite* *ColorChecker Classic Mini*. """ +SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI.update( + { + "aspect_ratio": 1000 / 585, + "working_height": int(1440 / (1000 / 585)), + "transform": (np.array([0, 0]), 0, np.array([1.15, 1])), + "inferred_class": "ColorCheckerSG", + "inferred_confidence": 0.85, + } +) -@dataclass -class DataColourCheckersCoordinatesSegmentation(MixinDataclassIterable): - """ - Colour checkers detection data used for plotting, debugging and further - analysis. - - Parameters - ---------- - colour_checkers - Colour checker bounding boxes, i.e., the clusters that have the - relevant count of swatches. - clusters - Detected swatches clusters. - swatches - Detected swatches. - segmented_image - Thresholded/Segmented image. - """ +PATH_INFERENCE_SCRIPT_DEFAULT = os.path.join( + os.path.dirname(__file__), "..", "scripts", "inference.py" +) +""" +Path to the default inference script. - colour_checkers: Tuple[NDArrayInt, ...] - clusters: Tuple[NDArrayInt, ...] - swatches: Tuple[NDArrayInt, ...] - segmented_image: NDArrayFloat +Warnings +-------- +The default script is provided under the terms of the +*GNU Affero General Public License v3.0* as it uses the *Ultralytics YOLOv8* +API which is incompatible with the *BSD-3-Clause*. +""" -def colour_checkers_coordinates_segmentation( - image: ArrayLike, additional_data: bool = False, **kwargs: Any -) -> DataColourCheckersCoordinatesSegmentation | Tuple[NDArrayInt, ...]: +def inferencer_default( + image: str | ArrayLike, + cctf_encoding: Callable = eotf_inverse_sRGB, + apply_cctf_encoding: bool = True, + show: bool = False, +) -> NDArrayInt | NDArrayFloat: """ - Detect the colour checkers coordinates in given image :math:`image` using - segmentation. - - This is the core detection definition. The process is a follows: - - - Input image :math:`image` is converted to a grayscale image - :math:`image_g`. - - Image :math:`image_g` is denoised. - - Image :math:`image_g` is thresholded/segmented to image - :math:`image_s`. - - Image :math:`image_s` is eroded and dilated to cleanup remaining noise. - - Contours are detected on image :math:`image_s`. - - Contours are filtered to only keep squares/swatches above and below - defined surface area. - - Squares/swatches are clustered to isolate region-of-interest that are - potentially colour checkers: Contours are scaled by a third so that - colour checkers swatches are expected to be joined, creating a large - rectangular cluster. Rectangles are fitted to the clusters. - - Clusters with an aspect ratio different to the expected one are - rejected, a side-effect is that the complementary pane of the - *X-Rite* *ColorChecker Passport* is omitted. - - Clusters with a number of swatches close to the expected one are - kept. + Predict the colour checker rectangles in given image using + *Ultralytics YOLOv8*. Parameters ---------- image - Image to detect the colour checkers in. - additional_data - Whether to output additional data. - - Other Parameters - ---------------- - aspect_ratio - Colour checker aspect ratio, e.g. 1.5. - aspect_ratio_minimum - Minimum colour checker aspect ratio for detection: projective geometry - might reduce the colour checker aspect ratio. - aspect_ratio_maximum - Maximum colour checker aspect ratio for detection: projective geometry - might increase the colour checker aspect ratio. - swatches - Colour checker swatches total count. - swatches_horizontal - Colour checker swatches horizontal columns count. - swatches_vertical - Colour checker swatches vertical row count. - swatches_count_minimum - Minimum swatches count to be considered for the detection. - swatches_count_maximum - Maximum swatches count to be considered for the detection. - swatches_chromatic_slice - A `slice` instance defining chromatic swatches used to detect if the - colour checker is upside down. - swatches_achromatic_slice - A `slice` instance defining achromatic swatches used to detect if the - colour checker is upside down. - swatch_minimum_area_factor - Swatch minimum area factor :math:`f` with the minimum area :math:`m_a` - expressed as follows: :math:`m_a = image_w * image_h / s_c / f` where - :math:`image_w`, :math:`image_h` and :math:`s_c` are respectively the - image width, height and the swatches count. - swatch_contour_scale - As the image is filtered, the swatches area will tend to shrink, the - generated contours can thus be scaled. - cluster_contour_scale - As the swatches are clustered, it might be necessary to adjust the - cluster scale so that the masks are centred better on the swatches. - working_width - Size the input image is resized to for detection. - fast_non_local_means_denoising_kwargs - Keyword arguments for :func:`cv2.fastNlMeansDenoising` definition. - adaptive_threshold_kwargs - Keyword arguments for :func:`cv2.adaptiveThreshold` definition. - interpolation_method - Interpolation method used when resizing the images, `cv2.INTER_CUBIC` - and `cv2.INTER_LINEAR` methods are recommended. + Image (or image path to read the image from) to detect the colour + checker rectangles from. + cctf_encoding + Encoding colour component transfer function / opto-electronic + transfer function used when converting the image from float to 8-bit. + apply_cctf_encoding + Apply the encoding colour component transfer function / opto-electronic + transfer function. + show + Whether to show various debug images. Returns ------- - :class:`colour_checker_detection.detection.segmentation.\ -DataColourCheckersCoordinatesSegmentation` or :class:`tuple` - Tuple of colour checkers coordinates or - :class:`DataColourCheckersCoordinatesSegmentation` class - instance with additional data. - - Notes - ----- - - Multiple colour checkers can be detected if presented in ``image``. + :class:`np.ndarray` + Array of inference results as rows of confidence, class, and mask. - Examples + Warnings -------- - >>> import os - >>> from colour import read_image - >>> from colour_checker_detection import ROOT_RESOURCES_TESTS - >>> path = os.path.join( - ... ROOT_RESOURCES_TESTS, - ... "colour_checker_detection", - ... "detection", - ... "IMG_1967.png", - ... ) - >>> image = read_image(path) - >>> colour_checkers_coordinates_segmentation(image) # doctest: +ELLIPSIS - (array([[ 365, 684], - [ 382, 221], - [1077, 247], - [1060, 710]]...) - """ - - image = as_float_array(image, FLOAT_DTYPE_DEFAULT)[..., :3] - - settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) - settings.update(**kwargs) - - image = as_8_bit_BGR_image( - adjust_image( - image, settings.working_width, settings.interpolation_method - ) - ) - - width, height = image.shape[1], image.shape[0] - maximum_area = width * height / settings.swatches - minimum_area = ( - width - * height - / settings.swatches - / settings.swatch_minimum_area_factor - ) - - # Thresholding/Segmentation. - image_g = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) - image_g = cv2.fastNlMeansDenoising( - image_g, None, **settings.fast_non_local_means_denoising_kwargs - ) - image_s = cv2.adaptiveThreshold( - image_g, **settings.adaptive_threshold_kwargs - ) - # Cleanup. - kernel = np.ones([3, 3], np.uint8) - image_c = cv2.erode(image_s, kernel, iterations=1) - image_c = cv2.dilate(image_c, kernel, iterations=1) - - # Detecting contours. - contours, _hierarchy = cv2.findContours( - image_c, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE - ) - - # Filtering squares/swatches contours. - swatches = [] - for contour in contours: - curve = cv2.approxPolyDP( - contour, 0.01 * cv2.arcLength(contour, True), True - ) - if minimum_area < cv2.contourArea(curve) < maximum_area and is_square( - curve - ): - swatches.append( - as_int_array(cv2.boxPoints(cv2.minAreaRect(curve))) - ) - - # Clustering squares/swatches. - contours = np.zeros(image.shape, dtype=np.uint8) - for swatch in [ - as_int_array(scale_contour(swatch, settings.swatch_contour_scale)) - for swatch in swatches - ]: - cv2.drawContours(contours, [swatch], -1, [255] * 3, -1) - contours = cv2.cvtColor(contours, cv2.COLOR_RGB2GRAY) - contours, _hierarchy = cv2.findContours( - contours, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE - ) - clusters = [ - as_int_array( - scale_contour( - cv2.boxPoints(cv2.minAreaRect(cluster)), - settings.cluster_contour_scale, - ) - ) - for cluster in contours - ] - - # Filtering clusters using their aspect ratio. - filtered_clusters = [] - for cluster in clusters[:]: - rectangle = cv2.minAreaRect(cluster) - width = max(rectangle[1][0], rectangle[1][1]) - height = min(rectangle[1][0], rectangle[1][1]) - ratio = width / height - if ( - settings.aspect_ratio_minimum - < ratio - < settings.aspect_ratio_maximum - ): - filtered_clusters.append(as_int_array(cluster)) - clusters = filtered_clusters - - # Filtering swatches within cluster. - counts = [] - for cluster in clusters: - count = 0 - for swatch in swatches: - if ( - cv2.pointPolygonTest(cluster, contour_centroid(swatch), False) - == 1 - ): - count += 1 - counts.append(count) - - indexes = np.where( - np.logical_and( - as_int_array(counts) >= settings.swatches_count_minimum, - as_int_array(counts) <= settings.swatches_count_maximum, - ) - )[0] - - colour_checkers = tuple(clusters[i] for i in indexes) - - if additional_data: - return DataColourCheckersCoordinatesSegmentation( - tuple(colour_checkers), - tuple(clusters), - tuple(swatches), - image_c, # pyright: ignore - ) - else: - return colour_checkers - - -def extract_colour_checkers_segmentation( - image: ArrayLike, **kwargs: Any -) -> Tuple[NDArrayFloat, ...]: - """ - Extract the colour checkers sub-images in given image using segmentation. - - Parameters - ---------- - image - Image to extract the colours checkers sub-images from. - - Other Parameters - ---------------- - aspect_ratio - Colour checker aspect ratio, e.g. 1.5. - aspect_ratio_minimum - Minimum colour checker aspect ratio for detection: projective geometry - might reduce the colour checker aspect ratio. - aspect_ratio_maximum - Maximum colour checker aspect ratio for detection: projective geometry - might increase the colour checker aspect ratio. - swatches - Colour checker swatches total count. - swatches_horizontal - Colour checker swatches horizontal columns count. - swatches_vertical - Colour checker swatches vertical row count. - swatches_count_minimum - Minimum swatches count to be considered for the detection. - swatches_count_maximum - Maximum swatches count to be considered for the detection. - swatches_chromatic_slice - A `slice` instance defining chromatic swatches used to detect if the - colour checker is upside down. - swatches_achromatic_slice - A `slice` instance defining achromatic swatches used to detect if the - colour checker is upside down. - swatch_minimum_area_factor - Swatch minimum area factor :math:`f` with the minimum area :math:`m_a` - expressed as follows: :math:`m_a = image_w * image_h / s_c / f` where - :math:`image_w`, :math:`image_h` and :math:`s_c` are respectively the - image width, height and the swatches count. - swatch_contour_scale - As the image is filtered, the swatches area will tend to shrink, the - generated contours can thus be scaled. - cluster_contour_scale - As the swatches are clustered, it might be necessary to adjust the - cluster scale so that the masks are centred better on the swatches. - working_width - Size the input image is resized to for detection. - fast_non_local_means_denoising_kwargs - Keyword arguments for :func:`cv2.fastNlMeansDenoising` definition. - adaptive_threshold_kwargs - Keyword arguments for :func:`cv2.adaptiveThreshold` definition. - interpolation_method - Interpolation method used when resizing the images, `cv2.INTER_CUBIC` - and `cv2.INTER_LINEAR` methods are recommended. - - Returns - ------- - :class:`tuple` - Tuple of colour checkers sub-images. + This definition sub-processes to a script licensed under the terms of the + *GNU Affero General Public License v3.0* as it uses the *Ultralytics YOLOv8* + API which is incompatible with the *BSD-3-Clause*. Examples -------- @@ -473,128 +171,89 @@ def extract_colour_checkers_segmentation( ... "detection", ... "IMG_1967.png", ... ) - >>> image = read_image(path) - >>> extract_colour_checkers_segmentation(image) - ... # doctest: +SKIP - (array([[[ 0.17908671, 0.14010708, 0.09243158], - [ 0.17805016, 0.13058874, 0.09513047], - [ 0.17175764, 0.13128328, 0.08811688], - ..., - [ 0.15934898, 0.13436384, 0.07479276], - [ 0.17178158, 0.13138185, 0.07703256], - [ 0.15082785, 0.11866678, 0.07680314]], - - [[ 0.16597673, 0.13563241, 0.08780421], - [ 0.16490564, 0.13110894, 0.08601525], - [ 0.16939694, 0.12963502, 0.08783565], - ..., - [ 0.14708202, 0.12856133, 0.0814603 ], - [ 0.16883563, 0.12862256, 0.08452422], - [ 0.16781917, 0.12363558, 0.07361614]], - - [[ 0.16326806, 0.13720085, 0.08925959], - [ 0.16014062, 0.13585283, 0.08104862], - [ 0.16657823, 0.12889633, 0.08870038], - ..., - [ 0.14619341, 0.13086307, 0.07367594], - [ 0.16302426, 0.13062705, 0.07938427], - [ 0.16618022, 0.1266259 , 0.07200021]], - - ..., - [[ 0.1928642 , 0.14578913, 0.11224515], - [ 0.18931177, 0.14416392, 0.10288388], - [ 0.17707473, 0.1436448 , 0.09188452], - ..., - [ 0.16879168, 0.12867133, 0.09001681], - [ 0.1699731 , 0.1287041 , 0.07616285], - [ 0.17137891, 0.129711 , 0.07517841]], - - [[ 0.19514292, 0.1532704 , 0.10375113], - [ 0.18217109, 0.14982903, 0.10452617], - [ 0.18830594, 0.1469499 , 0.10896181], - ..., - [ 0.18234864, 0.12642328, 0.08047272], - [ 0.17617388, 0.13000189, 0.06874527], - [ 0.17108543, 0.13264084, 0.06309374]], - - [[ 0.16243187, 0.14983535, 0.08954653], - [ 0.155507 , 0.14899652, 0.10273992], - [ 0.17993385, 0.1498394 , 0.1099571 ], - ..., - [ 0.18079454, 0.1253967 , 0.07739887], - [ 0.17239226, 0.13181566, 0.07806754], - [ 0.17422497, 0.13277327, 0.07513551]]], dtype=float32),) + >>> results = inferencer_default(path) + >>> results.shape + (1, 3) + >>> results[0][0] # doctest: +ELLIPSIS + array(0.9708795...) + >>> results[0][1] # doctest: +ELLIPSIS + array(0.0...) + >>> results[0][2].shape + (864, 1280) """ - image = as_float_array(image, FLOAT_DTYPE_DEFAULT)[..., :3] - - settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) - settings.update(**kwargs) - - image = adjust_image( - image, settings.working_width, settings.interpolation_method - ) - - colour_checkers = [] - for rectangle in cast( - List[NDArrayFloat], - colour_checkers_coordinates_segmentation(image, **settings), - ): - colour_checker = crop_and_level_image_with_rectangle( - image, - cv2.minAreaRect(rectangle), # pyright: ignore - settings.interpolation_method, + temp_directory = tempfile.mkdtemp() + + try: + if not is_string(image): + input_image = os.path.join(temp_directory, "input-image.png") + + if apply_cctf_encoding: + image = cctf_encoding(image) + + write_image(image, input_image, "uint8") + else: + input_image = image + + output_results = os.path.join(temp_directory, "output-results.npz") + subprocess.call( + [ # noqa: S603 + sys.executable, + PATH_INFERENCE_SCRIPT_DEFAULT, + "--input", + input_image, + "--output", + output_results, + ] + + (["--show"] if show else []) ) - width, height = (colour_checker.shape[1], colour_checker.shape[0]) + results = np.load(output_results, allow_pickle=True)["results"] + finally: + shutil.rmtree(temp_directory) - if width < height: - colour_checker = cv2.rotate( - colour_checker, cv2.ROTATE_90_CLOCKWISE - ) - - colour_checkers.append(colour_checker) - - return tuple(colour_checkers) - - -@dataclass -class DataDetectColourCheckersSegmentation(MixinDataclassIterable): - """ - Colour checker swatches data used for plotting, debugging and further - analysis. + return results - Parameters - ---------- - swatch_colours - Colour checker swatches colours. - colour_checker_image - Cropped and levelled Colour checker image. - swatch_masks - Colour checker swatches masks. - """ - swatch_colours: Tuple[NDArrayFloat, ...] - colour_checker_image: NDArrayFloat - swatch_masks: Tuple[NDArrayInt, ...] +INFERRED_CLASSES: Dict = {0: "ColorCheckerClassic24"} +"""Inferred classes.""" -def detect_colour_checkers_segmentation( - image: ArrayLike, - samples: int = 16, +def detect_colour_checkers_inference( + image: str | ArrayLike, + samples: int = 32, + cctf_decoding=eotf_sRGB, + apply_cctf_decoding: bool = False, + inferencer: Callable = inferencer_default, + inferencer_kwargs: dict | None = None, + show: bool = False, additional_data: bool = False, **kwargs: Any, -) -> Tuple[DataDetectColourCheckersSegmentation | NDArrayFloat, ...]: +) -> Tuple[DataDetectionColourChecker | NDArrayFloat, ...]: """ - Detect the colour checkers swatches in given image using segmentation. + Detect the colour checkers swatches in given image using inference. Parameters ---------- - image : array_like - Image to detect the colour checkers swatches in. - samples : int - Samples count to use to compute the swatches colours. The effective - samples count is :math:`samples^2`. - additional_data : bool, optional + image + Image (or image path to read the image from) to detect the colour + checker rectangles from. + samples + Sample count to use to average (mean) the swatches colours. The effective + sample count is :math:`samples^2`. + cctf_decoding + Decoding colour component transfer function / opto-electronic + transfer function used when converting the image from 8-bit to float. + apply_cctf_decoding + Apply the decoding colour component transfer function / opto-electronic + transfer function. + inferencer + Callable responsible to make predictions on the image and extract the + colour checker rectangles. + inferencer_kwargs + Keyword arguments to pass to the ``inferencer``. + show + Whether to show various debug images. + additional_data Whether to output additional data. Other Parameters @@ -631,9 +290,6 @@ def detect_colour_checkers_segmentation( swatch_contour_scale As the image is filtered, the swatches area will tend to shrink, the generated contours can thus be scaled. - cluster_contour_scale - As the swatches are clustered, it might be necessary to adjust the - cluster scale so that the masks are centred better on the swatches. working_width Size the input image is resized to for detection. fast_non_local_means_denoising_kwargs @@ -647,7 +303,7 @@ def detect_colour_checkers_segmentation( Returns ------- :class`tuple` - Tuple of :class:`DataDetectColourCheckersSegmentation` class + Tuple of :class:`DataDetectionColourChecker` class instances or colour checkers swatches. Examples @@ -662,92 +318,128 @@ def detect_colour_checkers_segmentation( ... "IMG_1967.png", ... ) >>> image = read_image(path) - >>> detect_colour_checkers_segmentation(image) # doctest: +SKIP - (array([[ 0.361626... , 0.2241066..., 0.1187837...], - [ 0.6280594..., 0.3950883..., 0.2434766...], - [ 0.3326232..., 0.3156182..., 0.2891038...], - [ 0.3048414..., 0.2738973..., 0.1069985...], - [ 0.4174869..., 0.3199669..., 0.3081552...], - [ 0.347873 ..., 0.4413193..., 0.2931614...], - [ 0.6816301..., 0.3539050..., 0.0753397...], - [ 0.2731050..., 0.2528467..., 0.3312920...], - [ 0.6192335..., 0.2703833..., 0.1866387...], - [ 0.3068567..., 0.1803366..., 0.1919807...], - [ 0.4866354..., 0.4594004..., 0.0374186...], - [ 0.6518523..., 0.4010608..., 0.0171886...], - [ 0.1941571..., 0.1855801..., 0.2750632...], - [ 0.2799946..., 0.3854609..., 0.1241038...], - [ 0.5537481..., 0.2139004..., 0.1267332...], - [ 0.7208045..., 0.5152904..., 0.0061946...], - [ 0.5778360..., 0.2578533..., 0.2687992...], - [ 0.1809450..., 0.3174742..., 0.2959902...], - [ 0.7427522..., 0.6107554..., 0.4398439...], - [ 0.6296108..., 0.5177606..., 0.3728032...], - [ 0.5139589..., 0.4216307..., 0.2992694...], - [ 0.3704401..., 0.3033927..., 0.2093089...], - [ 0.2641854..., 0.2154007..., 0.1441267...], - [ 0.1650098..., 0.1345239..., 0.0817437...]], dtype=float32),) + >>> detect_colour_checkers_inference(image) # doctest: +SKIP + (array([[ 0.3602327 , 0.22158547, 0.11813926], + [ 0.62800723, 0.39357048, 0.24196433], + [ 0.3284166 , 0.31669423, 0.28818974], + [ 0.3072932 , 0.2744136 , 0.10451803], + [ 0.4204691 , 0.31953654, 0.30901137], + [ 0.34471545, 0.44057423, 0.29297924], + [ 0.678418 , 0.35242617, 0.06670552], + [ 0.27259055, 0.2535471 , 0.32912973], + [ 0.6190633 , 0.27043283, 0.18543543], + [ 0.30721852, 0.18180828, 0.19161244], + [ 0.4858081 , 0.46007228, 0.03085822], + [ 0.6499356 , 0.4018961 , 0.01579806], + [ 0.19425018, 0.18621376, 0.27193058], + [ 0.27500305, 0.38600868, 0.1245231 ], + [ 0.55459476, 0.21477987, 0.12434786], + [ 0.71898675, 0.5149239 , 0.00561224], + [ 0.5787967 , 0.25837064, 0.2693373 ], + [ 0.1743919 , 0.31709513, 0.29550385], + [ 0.7383609 , 0.60645705, 0.43850273], + [ 0.62609893, 0.5172464 , 0.36816722], + [ 0.5117422 , 0.4191487 , 0.3013721 ], + [ 0.36412936, 0.2987345 , 0.20754097], + [ 0.26675388, 0.21421173, 0.14176223], + [ 0.15856811, 0.13483825, 0.07938566]], dtype=float32),) """ - image = as_float_array(image, FLOAT_DTYPE_DEFAULT)[..., :3] + if inferencer_kwargs is None: + inferencer_kwargs = {} - settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) + settings = Structure(**SETTINGS_INFERENCE_COLORCHECKER_CLASSIC) settings.update(**kwargs) - image = adjust_image( - image, settings.working_width, settings.interpolation_method - ) + swatches_horizontal = settings.swatches_horizontal + swatches_vertical = settings.swatches_vertical + working_width = settings.working_width + working_height = settings.working_height + + results = inferencer(image, **inferencer_kwargs) + + if is_string(image): + image = read_image(cast(str, image)) + else: + image = convert_bit_depth( + image, DTYPE_FLOAT_DEFAULT.__name__ # pyright: ignore + ) + + if apply_cctf_decoding: + image = cctf_decoding(image) + + image = cast(Union[NDArrayInt, NDArrayFloat], image) - swatches_h, swatches_v = ( - settings.swatches_horizontal, - settings.swatches_vertical, + rectangle = as_int32_array( + [ + [0, 0], + [0, working_height], + [working_width, working_height], + [working_width, 0], + ] ) - colour_checkers_colours = [] colour_checkers_data = [] - for colour_checker in extract_colour_checkers_segmentation( - image, **settings - ): - width, height = colour_checker.shape[1], colour_checker.shape[0] - masks = swatch_masks(width, height, swatches_h, swatches_v, samples) - - swatch_colours = [] - for mask in masks: - swatch_colours.append( - np.mean( - colour_checker[mask[0] : mask[1], mask[2] : mask[3], ...], - axis=(0, 1), + for result_confidence, result_class, result_mask in results: + if result_confidence < settings.inferred_confidence: + continue + + if settings.inferred_class != INFERRED_CLASSES[int(result_class)]: + continue + + mask = cv2.resize( + result_mask, + image.shape[:2][::-1], + interpolation=cv2.INTER_BITS, + ) + + contours, _hierarchy = cv2.findContours( + mask.astype(np.uint8), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE + ) + for quadrilateral in quadrilateralise_contours(contours): + colour_checkers_data.append( + sample_colour_checker( + image, quadrilateral, rectangle, samples, **settings ) ) - # The colour checker might be flipped: The mean standard deviation - # of some expected normalised chromatic and achromatic neutral - # swatches is computed. If the chromatic mean is lesser than the - # achromatic mean, it means that the colour checker is flipped. - std_means = [] - for slice_ in [ - settings.swatches_chromatic_slice, - settings.swatches_achromatic_slice, - ]: - swatch_std_mean = as_float_array(swatch_colours[slice_]) - swatch_std_mean /= swatch_std_mean[..., 1][..., None] - std_means.append(np.mean(np.std(swatch_std_mean, 0))) - if std_means[0] < std_means[1]: - usage_warning( - "Colour checker was seemingly flipped," - " reversing the samples!" - ) - swatch_colours = swatch_colours[::-1] + if show: + colour_checker = np.copy( + colour_checkers_data[-1].colour_checker + ) + for swatch_mask in colour_checkers_data[-1].swatch_masks: + colour_checker[ + swatch_mask[0] : swatch_mask[1], + swatch_mask[2] : swatch_mask[3], + ..., + ] = 0 + + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding( + colour_checker + ), + text_kwargs={ + "text": ( + f"Class: " + f'"{INFERRED_CLASSES[as_int_scalar(result_class)]}", ' + f"Confidence : {result_confidence:.3f}" + ) + }, + ) - colour_checkers_colours.append(np.asarray(swatch_colours)) - colour_checkers_data.append((colour_checker, masks)) + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding( + np.reshape( + colour_checkers_data[-1].swatch_colours, + [swatches_vertical, swatches_horizontal, 3], + ) + ), + ) if additional_data: + return tuple(colour_checkers_data) + else: return tuple( - DataDetectColourCheckersSegmentation( - tuple(colour_checkers_colours[i]), *colour_checkers_data[i] - ) - for i, colour_checker_colours in enumerate(colour_checkers_colours) + colour_checker_data.swatch_colours + for colour_checker_data in colour_checkers_data ) - else: - return tuple(colour_checkers_colours) diff --git a/colour_checker_detection/detection/segmentation.py b/colour_checker_detection/detection/segmentation.py index cf1af5d..3962598 100644 --- a/colour_checker_detection/detection/segmentation.py +++ b/colour_checker_detection/detection/segmentation.py @@ -6,8 +6,8 @@ - :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC` - :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_SG` -- :func:`colour_checker_detection.colour_checkers_coordinates_segmentation` -- :func:`colour_checker_detection.extract_colour_checkers_segmentation` +- :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_NANO` +- :func:`colour_checker_detection.segmenter_default` - :func:`colour_checker_detection.detect_colour_checkers_segmentation` References @@ -26,21 +26,21 @@ from colour.hints import ( Any, ArrayLike, + Callable, Dict, - List, NDArrayFloat, NDArrayInt, Tuple, + Union, cast, ) -from colour.io import convert_bit_depth -from colour.models import cctf_encoding +from colour.io import convert_bit_depth, read_image +from colour.models import eotf_inverse_sRGB, eotf_sRGB +from colour.plotting import CONSTANTS_COLOUR_STYLE, plot_image from colour.utilities import ( MixinDataclassIterable, Structure, - as_float_array, - as_int_array, - usage_warning, + is_string, ) from colour.utilities.documentation import ( DocstringDict, @@ -48,13 +48,19 @@ ) from colour_checker_detection.detection.common import ( - FLOAT_DTYPE_DEFAULT, - adjust_image, + DTYPE_FLOAT_DEFAULT, + SETTINGS_DETECTION_COLORCHECKER_CLASSIC, + SETTINGS_DETECTION_COLORCHECKER_SG, + DataDetectionColourChecker, + as_int32_array, contour_centroid, - crop_with_rectangle, + detect_contours, is_square, + quadrilateralise_contours, + reformat_image, + remove_stacked_contours, + sample_colour_checker, scale_contour, - swatch_masks, ) __author__ = "Colour Developers" @@ -67,43 +73,26 @@ __all__ = [ "SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC", "SETTINGS_SEGMENTATION_COLORCHECKER_SG", - "DataColourCheckersCoordinatesSegmentation", - "colour_checkers_coordinates_segmentation", - "extract_colour_checkers_segmentation", - "DataDetectColourCheckersSegmentation", + "SETTINGS_SEGMENTATION_COLORCHECKER_NANO", + "DataSegmentationColourCheckers", + "segmenter_default", "detect_colour_checkers_segmentation", ] +SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC: Dict = ( + SETTINGS_DETECTION_COLORCHECKER_CLASSIC.copy() +) -SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC: Dict = { - "aspect_ratio": 1.5, - "aspect_ratio_minimum": 1.5 * 0.9, - "aspect_ratio_maximum": 1.5 * 1.1, - "swatches": 24, - "swatches_horizontal": 6, - "swatches_vertical": 4, - "swatches_count_minimum": int(24 * 0.75), - "swatches_count_maximum": int(24 * 1.25), - "swatches_chromatic_slice": slice(0 + 1, 0 + 6 - 1, 1), - "swatches_achromatic_slice": slice(18 + 1, 18 + 6 - 1, 1), - "swatch_minimum_area_factor": 200, - "swatch_contour_scale": 1 + 1 / 3, - "cluster_contour_scale": 0.975, - "working_width": 1440, - "fast_non_local_means_denoising_kwargs": { - "h": 10, - "templateWindowSize": 7, - "searchWindowSize": 21, - }, - "adaptive_threshold_kwargs": { - "maxValue": 255, - "adaptiveMethod": cv2.ADAPTIVE_THRESH_MEAN_C, - "thresholdType": cv2.THRESH_BINARY, - "blockSize": int(1440 * 0.015) - int(1440 * 0.015) % 2 + 1, - "C": 3, - }, - "interpolation_method": cv2.INTER_CUBIC, -} +SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC.update( + { + "aspect_ratio_minimum": 1.5 * 0.9, + "aspect_ratio_maximum": 1.5 * 1.1, + "swatches_count_minimum": int(24 * 0.75), + "swatches_count_maximum": int(24 * 1.25), + "swatch_minimum_area_factor": 200, + "swatch_contour_scale": 1 + 1 / 3, + } +) if is_documentation_building(): # pragma: no cover SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC = DocstringDict( SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC @@ -114,24 +103,17 @@ """ SETTINGS_SEGMENTATION_COLORCHECKER_SG: Dict = ( - SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC.copy() + SETTINGS_DETECTION_COLORCHECKER_SG.copy() ) SETTINGS_SEGMENTATION_COLORCHECKER_SG.update( { - "aspect_ratio": 1.4, "aspect_ratio_minimum": 1.4 * 0.9, "aspect_ratio_maximum": 1.4 * 1.1, - "swatches": 140, - "swatches_horizontal": 14, - "swatches_vertical": 10, "swatches_count_minimum": int(140 * 0.50), "swatches_count_maximum": int(140 * 1.5), - "swatch_minimum_area_factor": 200, - "swatches_chromatic_slice": slice(48, 48 + 5, 1), - "swatches_achromatic_slice": slice(115, 115 + 5, 1), "swatch_contour_scale": 1 + 1 / 3, - "cluster_contour_scale": 1, + "swatch_minimum_area_factor": 200, } ) if is_documentation_building(): # pragma: no cover @@ -142,16 +124,35 @@ Settings for the segmentation of the *X-Rite* *ColorChecker SG**. """ +SETTINGS_SEGMENTATION_COLORCHECKER_NANO: Dict = ( + SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC.copy() +) + +SETTINGS_SEGMENTATION_COLORCHECKER_NANO.update( + { + "aspect_ratio_minimum": 1.4 * 0.75, + "aspect_ratio_maximum": 1.4 * 1.5, + "swatch_contour_scale": 1 + 1 / 2, + } +) +if is_documentation_building(): # pragma: no cover + SETTINGS_SEGMENTATION_COLORCHECKER_NANO = DocstringDict( + SETTINGS_SEGMENTATION_COLORCHECKER_NANO + ) + SETTINGS_SEGMENTATION_COLORCHECKER_NANO.__doc__ = """ +Settings for the segmentation of the *X-Rite* *ColorChecker Nano**. +""" + @dataclass -class DataColourCheckersCoordinatesSegmentation(MixinDataclassIterable): +class DataSegmentationColourCheckers(MixinDataclassIterable): """ Colour checkers detection data used for plotting, debugging and further analysis. Parameters ---------- - colour_checkers + rectangles Colour checker bounding boxes, i.e., the clusters that have the relevant count of swatches. clusters @@ -159,37 +160,42 @@ class DataColourCheckersCoordinatesSegmentation(MixinDataclassIterable): swatches Detected swatches. segmented_image - Thresholded/Segmented image. + Segmented image. """ - colour_checkers: Tuple[NDArrayInt, ...] - clusters: Tuple[NDArrayInt, ...] - swatches: Tuple[NDArrayInt, ...] + rectangles: NDArrayInt + clusters: NDArrayInt + swatches: NDArrayInt segmented_image: NDArrayFloat -def colour_checkers_coordinates_segmentation( - image: ArrayLike, additional_data: bool = False, **kwargs: Any -) -> DataColourCheckersCoordinatesSegmentation | Tuple[NDArrayInt, ...]: +def segmenter_default( + image: ArrayLike, + cctf_encoding: Callable = eotf_inverse_sRGB, + apply_cctf_encoding: bool = True, + additional_data: bool = False, + **kwargs: Any, +) -> DataSegmentationColourCheckers | NDArrayInt: """ - Detect the colour checkers coordinates in given image :math:`image` using + Detect the colour checker rectangles in given image :math:`image` using segmentation. - This is the core detection definition. The process is a follows: + The process is a follows: - Input image :math:`image` is converted to a grayscale image - :math:`image_g`. - - Image :math:`image_g` is denoised. - - Image :math:`image_g` is thresholded/segmented to image - :math:`image_s`. - - Image :math:`image_s` is eroded and dilated to cleanup remaining noise. - - Contours are detected on image :math:`image_s`. + :math:`image_g` and normalised to range [0, 1]. + - Image :math:`image_g` is denoised using multiple bilateral filtering + passes into image :math:`image_d.` + - Image :math:`image_d` is thresholded into image :math:`image_t`. + - Image :math:`image_t` is eroded and dilated to cleanup remaining noise + into image :math:`image_k`. + - Contours are detected on image :math:`image_k` - Contours are filtered to only keep squares/swatches above and below defined surface area. - Squares/swatches are clustered to isolate region-of-interest that are potentially colour checkers: Contours are scaled by a third so that - colour checkers swatches are expected to be joined, creating a large - rectangular cluster. Rectangles are fitted to the clusters. + colour checkers swatches are joined, creating a large rectangular + cluster. Rectangles are fitted to the clusters. - Clusters with an aspect ratio different to the expected one are rejected, a side-effect is that the complementary pane of the *X-Rite* *ColorChecker Passport* is omitted. @@ -199,12 +205,20 @@ def colour_checkers_coordinates_segmentation( Parameters ---------- image - Image to detect the colour checkers in. + Image to detect the colour checker rectangles from. + cctf_encoding + Encoding colour component transfer function / opto-electronic + transfer function used when converting the image from float to 8-bit. + apply_cctf_encoding + Apply the encoding colour component transfer function / opto-electronic + transfer function. additional_data Whether to output additional data. Other Parameters ---------------- + adaptive_threshold_kwargs + Keyword arguments for :func:`cv2.adaptiveThreshold` definition. aspect_ratio Colour checker aspect ratio, e.g. 1.5. aspect_ratio_minimum @@ -213,54 +227,60 @@ def colour_checkers_coordinates_segmentation( aspect_ratio_maximum Maximum colour checker aspect ratio for detection: projective geometry might increase the colour checker aspect ratio. + bilateral_filter_iterations + Number of iterations to use for bilateral filtering. + bilateral_filter_kwargs + Keyword arguments for :func:`cv2.bilateralFilter` definition. + convolution_iterations + Number of iterations to use for the erosion / dilation process. + convolution_kernel + Convolution kernel to use for the erosion / dilation process. + interpolation_method + Interpolation method used when resizing the images, `cv2.INTER_CUBIC` + and `cv2.INTER_LINEAR` methods are recommended. + reference_values + Reference values for the colour checker of interest. + swatch_contour_scale + As the image is filtered, the swatches area will tend to shrink, the + generated contours can thus be scaled. + swatch_minimum_area_factor + Swatch minimum area factor :math:`f` with the minimum area :math:`m_a` + expressed as follows: :math:`m_a = image_w * image_h / s_c / f` where + :math:`image_w`, :math:`image_h` and :math:`s_c` are respectively the + image width, height and the swatches count. swatches Colour checker swatches total count. + swatches_achromatic_slice + A `slice` instance defining achromatic swatches used to detect if the + colour checker is upside down. + swatches_chromatic_slice + A `slice` instance defining chromatic swatches used to detect if the + colour checker is upside down. + swatches_count_maximum + Maximum swatches count to be considered for the detection. + swatches_count_minimum + Minimum swatches count to be considered for the detection. swatches_horizontal Colour checker swatches horizontal columns count. swatches_vertical Colour checker swatches vertical row count. - swatches_count_minimum - Minimum swatches count to be considered for the detection. - swatches_count_maximum - Maximum swatches count to be considered for the detection. - swatches_chromatic_slice - A `slice` instance defining chromatic swatches used to detect if the - colour checker is upside down. - swatches_achromatic_slice - A `slice` instance defining achromatic swatches used to detect if the - colour checker is upside down. - swatch_minimum_area_factor - Swatch minimum area factor :math:`f` with the minimum area :math:`m_a` - expressed as follows: :math:`m_a = image_w * image_h / s_c / f` where - :math:`image_w`, :math:`image_h` and :math:`s_c` are respectively the - image width, height and the swatches count. - swatch_contour_scale - As the image is filtered, the swatches area will tend to shrink, the - generated contours can thus be scaled. - cluster_contour_scale - As the swatches are clustered, it might be necessary to adjust the - cluster scale so that the masks are centred better on the swatches. + transform + Transform to apply to the colour checker image post-detection. working_width - Size the input image is resized to for detection. - fast_non_local_means_denoising_kwargs - Keyword arguments for :func:`cv2.fastNlMeansDenoising` definition. - adaptive_threshold_kwargs - Keyword arguments for :func:`cv2.adaptiveThreshold` definition. - interpolation_method - Interpolation method used when resizing the images, `cv2.INTER_CUBIC` - and `cv2.INTER_LINEAR` methods are recommended. + Width the input image is resized to for detection. + working_height + Height the input image is resized to for detection. Returns ------- - :class:`colour_checker_detection.detection.segmentation.\ -DataColourCheckersCoordinatesSegmentation` or :class:`tuple` - Tuple of colour checkers coordinates or - :class:`DataColourCheckersCoordinatesSegmentation` class - instance with additional data. + :class:`colour_checker_detection.DataSegmentationColourCheckers` or \ +:class:`np.ndarray` + Colour checker rectangles and additional data or colour checker + rectangles only. Notes ----- - - Multiple colour checkers can be detected if presented in ``image``. + - Multiple colour checkers can be detected if present in ``image``. Examples -------- @@ -274,84 +294,66 @@ def colour_checkers_coordinates_segmentation( ... "IMG_1967.png", ... ) >>> image = read_image(path) - >>> colour_checkers_coordinates_segmentation(image) # doctest: +ELLIPSIS - (array([[ 366, 684], - [ 383, 223], - [1077, 248], - [1061, 709]])...) + >>> segmenter_default(image) # doctest: +ELLIPSIS + array([[[ 358, 691], + [ 373, 219], + [1086, 242], + [1071, 713]]]...) """ settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) settings.update(**kwargs) - image = convert_bit_depth( - cctf_encoding(image[..., :3]), np.uint8.__name__ - )[..., ::-1] + if apply_cctf_encoding: + image = cctf_encoding(image) - image = adjust_image( + image = reformat_image( image, settings.working_width, settings.interpolation_method ) width, height = image.shape[1], image.shape[0] - maximum_area = width * height / settings.swatches minimum_area = ( width * height / settings.swatches / settings.swatch_minimum_area_factor ) + maximum_area = width * height / settings.swatches - # Thresholding/Segmentation. - image_g = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - image_g = cv2.fastNlMeansDenoising( - image_g, None, **settings.fast_non_local_means_denoising_kwargs - ) - image_s = cv2.adaptiveThreshold( - image_g, **settings.adaptive_threshold_kwargs - ) - # Cleanup. - kernel = np.ones([3, 3], np.uint8) - image_c = cv2.erode(image_s, kernel, iterations=1) - image_c = cv2.dilate(image_c, kernel, iterations=1) - - # Detecting contours. - contours, _hierarchy = cv2.findContours( - image_c, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE + contours, image_k = detect_contours( # pyright: ignore + image, True, **settings ) # Filtering squares/swatches contours. - swatches = [] - for contour in contours: - curve = cv2.approxPolyDP( - contour, 0.01 * cv2.arcLength(contour, True), True - ) - if minimum_area < cv2.contourArea(curve) < maximum_area and is_square( - curve - ): - swatches.append( - as_int_array(cv2.boxPoints(cv2.minAreaRect(curve))) + squares = [] + for swatch_contour in quadrilateralise_contours(contours): + if minimum_area < cv2.contourArea( + swatch_contour + ) < maximum_area and is_square(swatch_contour): + squares.append( + as_int32_array(cv2.boxPoints(cv2.minAreaRect(swatch_contour))) ) - # Clustering squares/swatches. - contours = np.zeros(image.shape, dtype=np.uint8) - for swatch in [ - as_int_array(scale_contour(swatch, settings.swatch_contour_scale)) - for swatch in swatches - ]: - cv2.drawContours(contours, [swatch], -1, [255] * 3, -1) - contours = cv2.cvtColor(contours, cv2.COLOR_RGB2GRAY) + # Removing stacked squares. + squares = as_int32_array(remove_stacked_contours(squares)) + + # Clustering swatches. + swatches = [ + scale_contour(square, settings.swatch_contour_scale) + for square in squares + ] + image_c = np.zeros(image.shape, dtype=np.uint8) + cv2.drawContours( + image_c, as_int32_array(swatches), -1, [255] * 3, -1 # pyright: ignore + ) + image_c = cv2.cvtColor(image_c, cv2.COLOR_RGB2GRAY) + contours, _hierarchy = cv2.findContours( - contours, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE + image_c, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE + ) + clusters = as_int32_array( + [cv2.boxPoints(cv2.minAreaRect(contour)) for contour in contours] ) - clusters = [ - as_int_array( - scale_contour( - cv2.boxPoints(cv2.minAreaRect(cluster)), - settings.cluster_contour_scale, - ) - ) - for cluster in contours - ] # Filtering clusters using their aspect ratio. filtered_clusters = [] @@ -360,13 +362,14 @@ def colour_checkers_coordinates_segmentation( width = max(rectangle[1][0], rectangle[1][1]) height = min(rectangle[1][0], rectangle[1][1]) ratio = width / height + if ( settings.aspect_ratio_minimum < ratio < settings.aspect_ratio_maximum ): - filtered_clusters.append(as_int_array(cluster)) - clusters = filtered_clusters + filtered_clusters.append(as_int32_array(cluster)) + clusters = as_int32_array(filtered_clusters) # Filtering swatches within cluster. counts = [] @@ -382,37 +385,66 @@ def colour_checkers_coordinates_segmentation( indexes = np.where( np.logical_and( - as_int_array(counts) >= settings.swatches_count_minimum, - as_int_array(counts) <= settings.swatches_count_maximum, + as_int32_array(counts) >= settings.swatches_count_minimum, + as_int32_array(counts) <= settings.swatches_count_maximum, ) )[0] - colour_checkers = tuple(clusters[i] for i in indexes) + rectangles = clusters[indexes] if additional_data: - return DataColourCheckersCoordinatesSegmentation( - tuple(colour_checkers), - tuple(clusters), - tuple(swatches), - image_c, # pyright: ignore + return DataSegmentationColourCheckers( + rectangles, + clusters, + squares, + image_k, # pyright: ignore ) else: - return colour_checkers + return rectangles -def extract_colour_checkers_segmentation( - image: ArrayLike, **kwargs: Any -) -> Tuple[NDArrayFloat, ...]: +def detect_colour_checkers_segmentation( + image: str | ArrayLike, + samples: int = 32, + cctf_decoding: Callable = eotf_sRGB, + apply_cctf_decoding: bool = False, + segmenter: Callable = segmenter_default, + segmenter_kwargs: dict | None = None, + show: bool = False, + additional_data: bool = False, + **kwargs: Any, +) -> Tuple[DataDetectionColourChecker | NDArrayFloat, ...]: """ - Extract the colour checkers sub-images in given image using segmentation. + Detect the colour checkers swatches in given image using segmentation. Parameters ---------- image - Image to extract the colours checkers sub-images from. + Image (or image path to read the image from) to detect the colour + checkers swatches from. + samples + Sample count to use to average (mean) the swatches colours. The effective + sample count is :math:`samples^2`. + cctf_decoding + Decoding colour component transfer function / opto-electronic + transfer function used when converting the image from 8-bit to float. + apply_cctf_decoding + Apply the decoding colour component transfer function / opto-electronic + transfer function. + segmenter + Callable responsible to segment the image and extract the colour + checker rectangles. + segmenter_kwargs + Keyword arguments to pass to the ``segmenter``. + show + Whether to show various debug images. + additional_data + Whether to output additional data. Other Parameters ---------------- + adaptive_threshold_kwargs + Keyword arguments for :func:`cv2.adaptiveThreshold` definition. aspect_ratio Colour checker aspect ratio, e.g. 1.5. aspect_ratio_minimum @@ -421,234 +453,54 @@ def extract_colour_checkers_segmentation( aspect_ratio_maximum Maximum colour checker aspect ratio for detection: projective geometry might increase the colour checker aspect ratio. - swatches - Colour checker swatches total count. - swatches_horizontal - Colour checker swatches horizontal columns count. - swatches_vertical - Colour checker swatches vertical row count. - swatches_count_minimum - Minimum swatches count to be considered for the detection. - swatches_count_maximum - Maximum swatches count to be considered for the detection. - swatches_chromatic_slice - A `slice` instance defining chromatic swatches used to detect if the - colour checker is upside down. - swatches_achromatic_slice - A `slice` instance defining achromatic swatches used to detect if the - colour checker is upside down. + bilateral_filter_iterations + Number of iterations to use for bilateral filtering. + bilateral_filter_kwargs + Keyword arguments for :func:`cv2.bilateralFilter` definition. + convolution_iterations + Number of iterations to use for the erosion / dilation process. + convolution_kernel + Convolution kernel to use for the erosion / dilation process. + interpolation_method + Interpolation method used when resizing the images, `cv2.INTER_CUBIC` + and `cv2.INTER_LINEAR` methods are recommended. + reference_values + Reference values for the colour checker of interest. + swatch_contour_scale + As the image is filtered, the swatches area will tend to shrink, the + generated contours can thus be scaled. swatch_minimum_area_factor Swatch minimum area factor :math:`f` with the minimum area :math:`m_a` expressed as follows: :math:`m_a = image_w * image_h / s_c / f` where :math:`image_w`, :math:`image_h` and :math:`s_c` are respectively the image width, height and the swatches count. - swatch_contour_scale - As the image is filtered, the swatches area will tend to shrink, the - generated contours can thus be scaled. - cluster_contour_scale - As the swatches are clustered, it might be necessary to adjust the - cluster scale so that the masks are centred better on the swatches. - working_width - Size the input image is resized to for detection. - fast_non_local_means_denoising_kwargs - Keyword arguments for :func:`cv2.fastNlMeansDenoising` definition. - adaptive_threshold_kwargs - Keyword arguments for :func:`cv2.adaptiveThreshold` definition. - interpolation_method - Interpolation method used when resizing the images, `cv2.INTER_CUBIC` - and `cv2.INTER_LINEAR` methods are recommended. - - Returns - ------- - :class:`tuple` - Tuple of colour checkers sub-images. - - Examples - -------- - >>> import os - >>> from colour import read_image - >>> from colour_checker_detection import ROOT_RESOURCES_TESTS - >>> path = os.path.join( - ... ROOT_RESOURCES_TESTS, - ... "colour_checker_detection", - ... "detection", - ... "IMG_1967.png", - ... ) - >>> image = read_image(path) - >>> extract_colour_checkers_segmentation(image) - ... # doctest: +SKIP - (array([[[ 0.17908671, 0.14010708, 0.09243158], - [ 0.17805016, 0.13058874, 0.09513047], - [ 0.17175764, 0.13128328, 0.08811688], - ..., - [ 0.15934898, 0.13436384, 0.07479276], - [ 0.17178158, 0.13138185, 0.07703256], - [ 0.15082785, 0.11866678, 0.07680314]], - - [[ 0.16597673, 0.13563241, 0.08780421], - [ 0.16490564, 0.13110894, 0.08601525], - [ 0.16939694, 0.12963502, 0.08783565], - ..., - [ 0.14708202, 0.12856133, 0.0814603 ], - [ 0.16883563, 0.12862256, 0.08452422], - [ 0.16781917, 0.12363558, 0.07361614]], - - [[ 0.16326806, 0.13720085, 0.08925959], - [ 0.16014062, 0.13585283, 0.08104862], - [ 0.16657823, 0.12889633, 0.08870038], - ..., - [ 0.14619341, 0.13086307, 0.07367594], - [ 0.16302426, 0.13062705, 0.07938427], - [ 0.16618022, 0.1266259 , 0.07200021]], - - ..., - [[ 0.1928642 , 0.14578913, 0.11224515], - [ 0.18931177, 0.14416392, 0.10288388], - [ 0.17707473, 0.1436448 , 0.09188452], - ..., - [ 0.16879168, 0.12867133, 0.09001681], - [ 0.1699731 , 0.1287041 , 0.07616285], - [ 0.17137891, 0.129711 , 0.07517841]], - - [[ 0.19514292, 0.1532704 , 0.10375113], - [ 0.18217109, 0.14982903, 0.10452617], - [ 0.18830594, 0.1469499 , 0.10896181], - ..., - [ 0.18234864, 0.12642328, 0.08047272], - [ 0.17617388, 0.13000189, 0.06874527], - [ 0.17108543, 0.13264084, 0.06309374]], - - [[ 0.16243187, 0.14983535, 0.08954653], - [ 0.155507 , 0.14899652, 0.10273992], - [ 0.17993385, 0.1498394 , 0.1099571 ], - ..., - [ 0.18079454, 0.1253967 , 0.07739887], - [ 0.17239226, 0.13181566, 0.07806754], - [ 0.17422497, 0.13277327, 0.07513551]]], dtype=float32),) - """ - - image = as_float_array(image, FLOAT_DTYPE_DEFAULT)[..., :3] - - settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) - settings.update(**kwargs) - - image = adjust_image( - image, settings.working_width, settings.interpolation_method - ) - - colour_checkers = [] - for rectangle in cast( - List[NDArrayFloat], - colour_checkers_coordinates_segmentation(image, **settings), - ): - colour_checker = crop_with_rectangle( - image, - cv2.minAreaRect(rectangle), # pyright: ignore - settings.interpolation_method, - ) - width, height = (colour_checker.shape[1], colour_checker.shape[0]) - - if width < height: - colour_checker = cv2.rotate( - colour_checker, cv2.ROTATE_90_CLOCKWISE - ) - - colour_checkers.append(colour_checker) - - return tuple(colour_checkers) - - -@dataclass -class DataDetectColourCheckersSegmentation(MixinDataclassIterable): - """ - Colour checker swatches data used for plotting, debugging and further - analysis. - - Parameters - ---------- - swatch_colours - Colour checker swatches colours. - colour_checker_image - Cropped and levelled Colour checker image. - swatch_masks - Colour checker swatches masks. - """ - - swatch_colours: Tuple[NDArrayFloat, ...] - colour_checker_image: NDArrayFloat - swatch_masks: Tuple[NDArrayInt, ...] - - -def detect_colour_checkers_segmentation( - image: ArrayLike, - samples: int = 16, - additional_data: bool = False, - **kwargs: Any, -) -> Tuple[DataDetectColourCheckersSegmentation | NDArrayFloat, ...]: - """ - Detect the colour checkers swatches in given image using segmentation. - - Parameters - ---------- - image : array_like - Image to detect the colour checkers swatches in. - samples : int - Samples count to use to compute the swatches colours. The effective - samples count is :math:`samples^2`. - additional_data : bool, optional - Whether to output additional data. - - Other Parameters - ---------------- - aspect_ratio - Colour checker aspect ratio, e.g. 1.5. - aspect_ratio_minimum - Minimum colour checker aspect ratio for detection: projective geometry - might reduce the colour checker aspect ratio. - aspect_ratio_maximum - Maximum colour checker aspect ratio for detection: projective geometry - might increase the colour checker aspect ratio. swatches Colour checker swatches total count. + swatches_achromatic_slice + A `slice` instance defining achromatic swatches used to detect if the + colour checker is upside down. + swatches_chromatic_slice + A `slice` instance defining chromatic swatches used to detect if the + colour checker is upside down. + swatches_count_maximum + Maximum swatches count to be considered for the detection. + swatches_count_minimum + Minimum swatches count to be considered for the detection. swatches_horizontal Colour checker swatches horizontal columns count. swatches_vertical Colour checker swatches vertical row count. - swatches_count_minimum - Minimum swatches count to be considered for the detection. - swatches_count_maximum - Maximum swatches count to be considered for the detection. - swatches_chromatic_slice - A `slice` instance defining chromatic swatches used to detect if the - colour checker is upside down. - swatches_achromatic_slice - A `slice` instance defining achromatic swatches used to detect if the - colour checker is upside down. - swatch_minimum_area_factor - Swatch minimum area factor :math:`f` with the minimum area :math:`m_a` - expressed as follows: :math:`m_a = image_w * image_h / s_c / f` where - :math:`image_w`, :math:`image_h` and :math:`s_c` are respectively the - image width, height and the swatches count. - swatch_contour_scale - As the image is filtered, the swatches area will tend to shrink, the - generated contours can thus be scaled. - cluster_contour_scale - As the swatches are clustered, it might be necessary to adjust the - cluster scale so that the masks are centred better on the swatches. + transform + Transform to apply to the colour checker image post-detection. working_width - Size the input image is resized to for detection. - fast_non_local_means_denoising_kwargs - Keyword arguments for :func:`cv2.fastNlMeansDenoising` definition. - adaptive_threshold_kwargs - Keyword arguments for :func:`cv2.adaptiveThreshold` definition. - interpolation_method - Interpolation method used when resizing the images, `cv2.INTER_CUBIC` - and `cv2.INTER_LINEAR` methods are recommended. + Width the input image is resized to for detection. + working_height + Height the input image is resized to for detection. Returns ------- :class`tuple` - Tuple of :class:`DataDetectColourCheckersSegmentation` class + Tuple of :class:`DataDetectionColourChecker` class instances or colour checkers swatches. Examples @@ -664,91 +516,137 @@ def detect_colour_checkers_segmentation( ... ) >>> image = read_image(path) >>> detect_colour_checkers_segmentation(image) # doctest: +SKIP - (array([[ 0.361626... , 0.2241066..., 0.1187837...], - [ 0.6280594..., 0.3950883..., 0.2434766...], - [ 0.3326232..., 0.3156182..., 0.2891038...], - [ 0.3048414..., 0.2738973..., 0.1069985...], - [ 0.4174869..., 0.3199669..., 0.3081552...], - [ 0.347873 ..., 0.4413193..., 0.2931614...], - [ 0.6816301..., 0.3539050..., 0.0753397...], - [ 0.2731050..., 0.2528467..., 0.3312920...], - [ 0.6192335..., 0.2703833..., 0.1866387...], - [ 0.3068567..., 0.1803366..., 0.1919807...], - [ 0.4866354..., 0.4594004..., 0.0374186...], - [ 0.6518523..., 0.4010608..., 0.0171886...], - [ 0.1941571..., 0.1855801..., 0.2750632...], - [ 0.2799946..., 0.3854609..., 0.1241038...], - [ 0.5537481..., 0.2139004..., 0.1267332...], - [ 0.7208045..., 0.5152904..., 0.0061946...], - [ 0.5778360..., 0.2578533..., 0.2687992...], - [ 0.1809450..., 0.3174742..., 0.2959902...], - [ 0.7427522..., 0.6107554..., 0.4398439...], - [ 0.6296108..., 0.5177606..., 0.3728032...], - [ 0.5139589..., 0.4216307..., 0.2992694...], - [ 0.3704401..., 0.3033927..., 0.2093089...], - [ 0.2641854..., 0.2154007..., 0.1441267...], - [ 0.1650098..., 0.1345239..., 0.0817437...]], dtype=float32),) + (array([[ 0.360005 , 0.22310828, 0.11760835], + [ 0.6258309 , 0.39448667, 0.24166533], + [ 0.33198 , 0.31600377, 0.28866866], + [ 0.3046006 , 0.273321 , 0.10486555], + [ 0.41751358, 0.31914026, 0.30789137], + [ 0.34866226, 0.43934596, 0.29126382], + [ 0.67983997, 0.35236534, 0.06997226], + [ 0.27118555, 0.25352538, 0.33078724], + [ 0.62091863, 0.27034152, 0.18652563], + [ 0.3071613 , 0.17978874, 0.19181632], + [ 0.48547146, 0.4585586 , 0.03294956], + [ 0.6507678 , 0.40023172, 0.01607676], + [ 0.19286253, 0.18585181, 0.27459183], + [ 0.28054565, 0.38513032, 0.1224441 ], + [ 0.5545431 , 0.21436104, 0.12549178], + [ 0.72068894, 0.51493925, 0.00548734], + [ 0.5772921 , 0.2577179 , 0.2685553 ], + [ 0.17289193, 0.3163792 , 0.2950853 ], + [ 0.7394083 , 0.60953134, 0.4383072 ], + [ 0.6281671 , 0.51759964, 0.37215686], + [ 0.51360977, 0.42048824, 0.2985709 ], + [ 0.36953217, 0.30218402, 0.20827036], + [ 0.26286703, 0.21493268, 0.14277342], + [ 0.16102524, 0.13381621, 0.08047409]]...),) + """ - image = as_float_array(image, FLOAT_DTYPE_DEFAULT)[..., :3] + if segmenter_kwargs is None: + segmenter_kwargs = {} settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) settings.update(**kwargs) - image = adjust_image( + swatches_h = settings.swatches_horizontal + swatches_v = settings.swatches_vertical + working_width = settings.working_width + working_height = int(working_width / settings.aspect_ratio) + + if is_string(image): + image = read_image(cast(str, image)) + else: + image = convert_bit_depth( + image, DTYPE_FLOAT_DEFAULT.__name__ # pyright: ignore + ) + + if apply_cctf_decoding: + image = cctf_decoding(image) + + image = cast(Union[NDArrayInt, NDArrayFloat], image) + + image = reformat_image( image, settings.working_width, settings.interpolation_method ) - swatches_h, swatches_v = ( - settings.swatches_horizontal, - settings.swatches_vertical, + rectangle = as_int32_array( + [ + [working_width, 0], + [working_width, working_height], + [0, working_height], + [0, 0], + ] + ) + + segmentation_colour_checkers_data = segmenter( + image, additional_data=True, **{**segmenter_kwargs, **settings} ) - colour_checkers_colours = [] colour_checkers_data = [] - for colour_checker in extract_colour_checkers_segmentation( - image, **settings - ): - width, height = colour_checker.shape[1], colour_checker.shape[0] - masks = swatch_masks(width, height, swatches_h, swatches_v, samples) - - swatch_colours = [] - for mask in masks: - swatch_colours.append( - np.mean( - colour_checker[mask[0] : mask[1], mask[2] : mask[3], ...], - axis=(0, 1), - ) + for quadrilateral in segmentation_colour_checkers_data.rectangles: + colour_checkers_data.append( + sample_colour_checker( + image, quadrilateral, rectangle, samples, **settings + ) + ) + + if show: + colour_checker = np.copy(colour_checkers_data[-1].colour_checker) + for swatch_mask in colour_checkers_data[-1].swatch_masks: + colour_checker[ + swatch_mask[0] : swatch_mask[1], + swatch_mask[2] : swatch_mask[3], + ..., + ] = 0 + + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding( + colour_checker + ), ) - # The colour checker might be flipped: The mean standard deviation - # of some expected normalised chromatic and achromatic neutral - # swatches is computed. If the chromatic mean is lesser than the - # achromatic mean, it means that the colour checker is flipped. - std_means = [] - for slice_ in [ - settings.swatches_chromatic_slice, - settings.swatches_achromatic_slice, - ]: - swatch_std_mean = as_float_array(swatch_colours[slice_]) - swatch_std_mean /= swatch_std_mean[..., 1][..., None] - std_means.append(np.mean(np.std(swatch_std_mean, 0))) - if std_means[0] < std_means[1]: - usage_warning( - "Colour checker was seemingly flipped," - " reversing the samples!" + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding( + np.reshape( + colour_checkers_data[-1].swatch_colours, + [swatches_v, swatches_h, 3], + ) + ), ) - swatch_colours = swatch_colours[::-1] - colour_checkers_colours.append(np.asarray(swatch_colours)) - colour_checkers_data.append((colour_checker, masks)) + if show: + plot_image( + segmentation_colour_checkers_data.segmented_image, + text_kwargs={"text": "Segmented Image", "color": "black"}, + ) + + image_c = np.copy(image) + + cv2.drawContours( + image_c, + segmentation_colour_checkers_data.swatches, + -1, + (1, 0, 1), + 3, + ) + cv2.drawContours( + image_c, + segmentation_colour_checkers_data.clusters, + -1, + (0, 1, 1), + 3, + ) + + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding(image_c), + text_kwargs={"text": "Swatches & Clusters", "color": "white"}, + ) if additional_data: + return tuple(colour_checkers_data) + else: return tuple( - DataDetectColourCheckersSegmentation( - tuple(colour_checkers_colours[i]), *colour_checkers_data[i] - ) - for i, colour_checker_colours in enumerate(colour_checkers_colours) + colour_checker_data.swatch_colours + for colour_checker_data in colour_checkers_data ) - else: - return tuple(colour_checkers_colours) diff --git a/colour_checker_detection/detection/tests/test_common.py b/colour_checker_detection/detection/tests/test_common.py index d89fa50..7b8c27f 100644 --- a/colour_checker_detection/detection/tests/test_common.py +++ b/colour_checker_detection/detection/tests/test_common.py @@ -10,15 +10,24 @@ import numpy as np from colour import read_image +from colour.constants import TOLERANCE_ABSOLUTE_TESTS +from colour.utilities import tstack, zeros from colour_checker_detection import ROOT_RESOURCES_TESTS from colour_checker_detection.detection.common import ( - adjust_image, + approximate_contour, + as_float32_array, contour_centroid, - crop_with_rectangle, + detect_contours, is_square, + quadrilateralise_contours, + reformat_image, + remove_stacked_contours, + sample_colour_checker, scale_contour, + swatch_colours, swatch_masks, + transform_image, ) from colour_checker_detection.detection.segmentation import ( SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC, @@ -35,18 +44,24 @@ "DETECTION_DIRECTORY", "PNG_FILES", "TestSwatchMasks", - "TestAdjustImage", - "TestCropWithRectangle", + "TestSwatchColours", + "TestReformatImage", + "TestTransformImage", + "TestDetectContours", "TestIsSquare", "TestContourCentroid", "TestScaleContour", + "TestApproximateContour", + "TestQuadrilateraliseContours", + "TestRemoveStackedContours", + "TestSampleColourChecker", ] DETECTION_DIRECTORY = os.path.join( ROOT_RESOURCES_TESTS, "colour_checker_detection", "detection" ) -PNG_FILES = glob.glob(os.path.join(DETECTION_DIRECTORY, "IMG_19*.png")) +PNG_FILES = sorted(glob.glob(os.path.join(DETECTION_DIRECTORY, "IMG_19*.png"))) class TestSwatchMasks(unittest.TestCase): @@ -78,40 +93,51 @@ def test_swatch_masks(self): ) -class TestAdjustImage(unittest.TestCase): +class TestSwatchColours(unittest.TestCase): """ - Define :func:`colour_checker_detection.detection.common.adjust_image` + Define :func:`colour_checker_detection.detection.common.swatch_colours` definition unit tests methods. """ - def test_adjust_image(self): + def test_swatch_colours(self): """ - Define :func:`colour_checker_detection.detection.common.adjust_image` + Define :func:`colour_checker_detection.detection.common.swatch_colours` definition unit tests methods. """ - # Skipping unit test when "png" files are missing, e.g. when testing - # the distributed "Python" package. - if len(PNG_FILES) == 0: - return + x = np.linspace(0, 1, 16) + y = np.linspace(0, 1, 8) + xx, yy = np.meshgrid(x, y) + image = tstack([xx, yy, zeros(xx.shape)]) - image = adjust_image(read_image(PNG_FILES[0]), 1440) - self.assertEqual( - image.shape[1], - SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC["working_width"], + np.testing.assert_allclose( + swatch_colours(image, swatch_masks(16, 8, 4, 2, 1)), + np.array( + [ + [0.10000000, 0.21428572, 0.00000000], + [0.36666667, 0.21428572, 0.00000000], + [0.63333333, 0.21428572, 0.00000000], + [0.89999998, 0.21428572, 0.00000000], + [0.10000000, 0.78571427, 0.00000000], + [0.36666667, 0.78571427, 0.00000000], + [0.63333333, 0.78571427, 0.00000000], + [0.89999998, 0.78571427, 0.00000000], + ] + ), + atol=TOLERANCE_ABSOLUTE_TESTS, ) -class TestCropWithRectangle(unittest.TestCase): +class TestReformatImage(unittest.TestCase): """ - Define :func:`colour_checker_detection.detection.common.\ -crop_with_rectangle` definition unit tests methods. + Define :func:`colour_checker_detection.detection.common.reformat_image` + definition unit tests methods. """ - def test_crop_with_rectangle(self): + def test_reformat_image(self): """ - Define :func:`colour_checker_detection.detection.common.\ -crop_with_rectangle` definition unit tests methods. + Define :func:`colour_checker_detection.detection.common.reformat_image` + definition unit tests methods. """ # Skipping unit test when "png" files are missing, e.g. when testing @@ -119,20 +145,97 @@ def test_crop_with_rectangle(self): if len(PNG_FILES) == 0: return - image = adjust_image(read_image(PNG_FILES[0]), 1440) + path = next(png_file for png_file in PNG_FILES if "1970" in png_file) - rectangle = ( - (832.99865723, 473.05020142), - (209.08610535, 310.13061523), - -88.35559082, + self.assertEqual( + reformat_image(read_image(path), 1440).shape[1], + SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC["working_width"], ) - np.testing.assert_array_equal( - crop_with_rectangle(image, rectangle).shape, - (209, 310, 3), + +class TestTransformImage(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.common.transform_image` + definition unit tests methods. + """ + + def test_transform_image(self): + """ + Define :func:`colour_checker_detection.detection.common.transform_image` + definition unit tests methods. + """ + + image = as_float32_array(np.arange(96)).reshape([4, 8, 3]) + + np.testing.assert_allclose( + transform_image(image, np.array([2, 4]), 45, np.array([2, 3])), + np.array( + [ + [ + [47.68359375, 48.68359375, 49.68359375], + [41.15771866, 42.15770721, 43.15771484], + [37.69516754, 38.69516754, 39.69516373], + [34.04169083, 35.04169464, 36.04168320], + [29.82055664, 30.82055473, 31.82055283], + [22.41366768, 23.41366577, 24.41366577], + [17.44537354, 18.44537163, 19.44537544], + [13.23165703, 14.23165894, 15.23165798], + ], + [ + [56.25146103, 57.25147247, 58.25146484], + [49.13193512, 50.13193512, 51.13193512], + [43.10541153, 44.10540390, 45.10540390], + [40.26855469, 41.26855850, 42.26855469], + [36.38168335, 37.38168335, 38.38168716], + [31.61718750, 32.61718750, 33.61718750], + [24.64370728, 25.64370918, 26.64370537], + [19.65682983, 20.65682983, 21.65682793], + ], + [ + [62.66984177, 63.66983414, 64.66983032], + [58.19916534, 59.19915771, 60.19916153], + [51.70532227, 52.70532227, 53.70532227], + [45.44541168, 46.44540405, 47.44540024], + [42.06518555, 43.06518555, 44.06518555], + [38.61172867, 39.61172485, 40.61172485], + [33.82864380, 34.82863998, 35.82864380], + [26.57885551, 27.57885933, 28.57886314], + ], + [ + [69.03441620, 70.03442383, 71.03441620], + [65.24323273, 66.24321747, 67.24322510], + [60.53915405, 61.53916931, 62.53915405], + [53.50195312, 54.50195312, 55.50195312], + [47.67544556, 48.67544174, 49.67544556], + [44.27664566, 45.27664185, 46.27664185], + [40.54687500, 41.54687500, 42.54687500], + [36.09164429, 37.09164810, 38.09164810], + ], + ], + ), + atol=TOLERANCE_ABSOLUTE_TESTS, ) +class TestDetectContours(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.common.detect_contours` + definition unit tests methods. + """ + + def test_detect_contours(self): + """ + Define :func:`colour_checker_detection.detection.common.detect_contours` + definition unit tests methods. + """ + + image = zeros([240, 320, 3]) + image[100:140, 50:90] = 1 + image[150:190, 140:180] = 1 + + self.assertEqual(len(detect_contours(image)), 5) + + class TestIsSquare(unittest.TestCase): """ Define :func:`colour_checker_detection.detection.common.is_square` @@ -193,5 +296,157 @@ def test_scale_contour(self): ) +class TestApproximateContour(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.common.approximate_contour` + definition unit tests methods. + """ + + def test_approximate_contour(self): + """ + Define :func:`colour_checker_detection.detection.common.approximate_contour` + definition unit tests methods. + """ + + contour = np.array([[0, 0], [1, 0], [1, 1], [1, 2], [0, 1]]) + + np.testing.assert_array_equal( + approximate_contour(contour, 4), + np.array([[0, 0], [1, 0], [1, 2], [0, 1]]), + ) + + np.testing.assert_array_equal( + approximate_contour(contour, 3), + np.array([[0, 0], [1, 0], [1, 2]]), + ) + + +class TestQuadrilateraliseContours(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.common.\ +quadrilateralise_contours` definition unit tests methods. + """ + + def test_quadrilateralise_contours(self): + """ + Define :func:`colour_checker_detection.detection.common.\ +quadrilateralise_contours` definition unit tests methods. + """ + + contours = np.array( + [ + [[0, 0], [1, 0], [1, 1], [1, 2], [0, 1]], + [[0, 0], [1, 2], [1, 0], [1, 1], [0, 1]], + ] + ) + + np.testing.assert_array_equal( + quadrilateralise_contours(contours), + np.array( + [ + [[0, 0], [1, 0], [1, 2], [0, 1]], + [[0, 0], [1, 2], [1, 0], [1, 1]], + ] + ), + ) + + +class TestRemoveStackedContours(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.common.\ +remove_stacked_contours` definition unit tests methods. + """ + + def test_remove_stacked_contours(self): + """ + Define :func:`colour_checker_detection.detection.common.\ +remove_stacked_contours` definition unit tests methods. + """ + + contours = np.array( + [ + [[0, 0], [7, 0], [7, 7], [0, 7]], + [[0, 0], [8, 0], [8, 8], [0, 8]], + [[0, 0], [10, 0], [10, 10], [0, 10]], + ] + ) + + np.testing.assert_array_equal( + remove_stacked_contours(contours), + np.array([[[0, 0], [7, 0], [7, 7], [0, 7]]]), + ) + + np.testing.assert_array_equal( + remove_stacked_contours(contours, False), + np.array([[[0, 0], [10, 0], [10, 10], [0, 10]]]), + ) + + +class TestSampleColourChecker(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.common.\ +remove_stacked_contours` definition unit tests methods. + """ + + def test_sample_colour_checker(self): + """ + Define :func:`colour_checker_detection.detection.common.\ +sample_colour_checker` definition unit tests methods. + """ + + # Skipping unit test when "png" files are missing, e.g. when testing + # the distributed "Python" package. + if len(PNG_FILES) == 0: + return + + path = next(png_file for png_file in PNG_FILES if "1967" in png_file) + + quadrilateral = np.array( + [[358, 691], [373, 219], [1086, 242], [1071, 713]] + ) + rectangle = np.array([[1440, 0], [1440, 960], [0, 960], [0, 0]]) + colour_checkers_data = sample_colour_checker( + read_image(path), quadrilateral, rectangle + ) + + np.testing.assert_allclose( + colour_checkers_data.swatch_colours, + np.array( + [ + [0.75710917, 0.67630458, 0.47606474], + [0.25871587, 0.21974973, 0.16204563], + [0.15012611, 0.11881837, 0.07829906], + [0.14475887, 0.11828972, 0.07471170], + [0.15182742, 0.12059662, 0.07984065], + [0.15811475, 0.12584405, 0.07951307], + [0.99963307, 0.82756299, 0.53623772], + [0.26152441, 0.22938406, 0.16862768], + [0.15809630, 0.11951645, 0.07755180], + [0.16762769, 0.13303326, 0.08851139], + [0.17338796, 0.14148802, 0.08979498], + [0.17304046, 0.14195150, 0.09080467], + [1.00000000, 0.98902053, 0.67808318], + [0.25435534, 0.22063790, 0.15692709], + [0.15027192, 0.12475526, 0.07843940], + [0.34583551, 0.21429974, 0.11217980], + [0.36254194, 0.22595090, 0.11665937], + [0.62459683, 0.39098999, 0.24112946], + [0.97804743, 1.00000000, 0.86419195], + [0.25577253, 0.22349517, 0.15844890], + [0.15959230, 0.12591116, 0.08147947], + [0.35486832, 0.21910854, 0.11063413], + [0.36308041, 0.22740598, 0.12138989], + [0.62340593, 0.39334935, 0.24371558], + ] + ), + atol=TOLERANCE_ABSOLUTE_TESTS, + ) + + self.assertTupleEqual(colour_checkers_data.swatch_masks.shape, (24, 4)) + self.assertTupleEqual( + colour_checkers_data.colour_checker.shape, (960, 1440, 3) + ) + + if __name__ == "__main__": unittest.main() diff --git a/colour_checker_detection/detection/tests/test_inference.py b/colour_checker_detection/detection/tests/test_inference.py new file mode 100644 index 0000000..ae32ac4 --- /dev/null +++ b/colour_checker_detection/detection/tests/test_inference.py @@ -0,0 +1,297 @@ +# !/usr/bin/env python +""" +Define the unit tests for the +:mod:`colour_checker_detection.detection.inference` module. +""" + +import glob +import os +import platform +import sys +import unittest + +import numpy as np +from colour import read_image + +from colour_checker_detection import ROOT_RESOURCES_TESTS +from colour_checker_detection.detection import ( + detect_colour_checkers_inference, + inferencer_default, +) + +__author__ = "Colour Developers" +__copyright__ = "Copyright 2018 Colour Developers" +__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" +__maintainer__ = "Colour Developers" +__email__ = "colour-developers@colour-science.org" +__status__ = "Production" + +__all__ = [ + "DETECTION_DIRECTORY", + "PNG_FILES", + "TestInferencerDefault", + "TestDetectColourCheckersInference", +] + +DETECTION_DIRECTORY = os.path.join( + ROOT_RESOURCES_TESTS, "colour_checker_detection", "detection" +) + +PNG_FILES = sorted( + glob.glob(os.path.join(DETECTION_DIRECTORY, "IMG_19*.png")) +)[:-2] + + +class TestInferencerDefault(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.inference.\ +inferencer_default` definition unit tests methods. + """ + + def test_inferencer_default(self): + """ + Define :func:`colour_checker_detection.detection.inference.\ +inferencer_default` definition unit tests methods. + """ + + # Skipping unit test when "png" files are missing, e.g. when testing + # the distributed "Python" package. + if len(PNG_FILES) == 0: + return + + # TODO: Unit test is only reproducible on "macOs", skipping other OSes. + if platform.system() in ("Windows", "Microsoft", "Linux"): + return + + # TODO: Enable when "torch" is available on Python 3.12. + if sys.version_info[1] > 11: # noqa: YTT203 + return + + shapes = [ + (864, 1280), + (864, 1280), + (1280, 864), + (864, 1280), + ] + + for i, png_file in enumerate(PNG_FILES): + results = inferencer_default(png_file) + self.assertTrue(results[0][0] > 0.85) + self.assertEqual(int(results[0][1]), 0) + self.assertTupleEqual(results[0][2].shape, shapes[i]) + + +class TestDetectColourCheckersInference(unittest.TestCase): + """ + Define :func:`colour_checker_detection.detection.inference.\ +detect_colour_checkers_inference` definition unit tests methods. + """ + + def test_detect_colour_checkers_inference(self): + """ + Define :func:`colour_checker_detection.detection.inference.\ +detect_colour_checkers_inference` definition unit tests methods. + """ + + # Skipping unit test when "png" files are missing, e.g. when testing + # the distributed "Python" package. + if len(PNG_FILES) == 0: + return + + # TODO: Unit test is only reproducible on "macOs", skipping other OSes. + if platform.system() in ("Windows", "Microsoft", "Linux"): + return + + # TODO: Enable when "torch" is available on Python 3.12. + if sys.version_info[1] > 11: # noqa: YTT203 + return + + test_swatches = [ + ( + np.array( + [ + [0.24915336, 0.15333557, 0.08062609], + [0.41582590, 0.25704128, 0.15597928], + [0.21691470, 0.19983344, 0.18059847], + [0.19566718, 0.17250466, 0.06643628], + [0.27478909, 0.20288917, 0.19493811], + [0.23374973, 0.28814146, 0.19005357], + [0.45762375, 0.23317388, 0.05121415], + [0.18061841, 0.16022089, 0.20770057], + [0.39544231, 0.16719289, 0.11211669], + [0.19535244, 0.10992710, 0.11463679], + [0.31359121, 0.29133955, 0.02011782], + [0.43297896, 0.25808954, 0.00887722], + [0.13763773, 0.12528725, 0.17699082], + [0.18486719, 0.24581401, 0.07584951], + [0.35452405, 0.13150652, 0.07456490], + [0.46239638, 0.32247591, 0.00122477], + [0.37084514, 0.15799911, 0.16431224], + [0.11738659, 0.20026121, 0.18583715], + [0.50368768, 0.41160455, 0.29139283], + [0.41439033, 0.33571342, 0.23684677], + [0.32857990, 0.26543081, 0.18447344], + [0.23778303, 0.18715258, 0.12640880], + [0.16784327, 0.13128684, 0.08443624], + [0.10924070, 0.07945030, 0.04722051], + ] + ), + ), + ( + np.array( + [ + [0.35930955, 0.22294356, 0.11652736], + [0.62508970, 0.39426908, 0.24249625], + [0.33216712, 0.31617990, 0.28927535], + [0.30448821, 0.27402756, 0.10357682], + [0.41776320, 0.31983960, 0.30784929], + [0.34831995, 0.44018656, 0.29236460], + [0.68040967, 0.35255539, 0.06841964], + [0.27309224, 0.25288051, 0.33048338], + [0.62147486, 0.27055049, 0.18659429], + [0.30654705, 0.18056440, 0.19198996], + [0.48673603, 0.45989344, 0.03273499], + [0.65062267, 0.40057424, 0.01651634], + [0.19466802, 0.18593474, 0.27454826], + [0.28092542, 0.38499087, 0.12309728], + [0.55481929, 0.21417859, 0.12555254], + [0.72179741, 0.51570368, 0.00593671], + [0.57785285, 0.25778547, 0.26881799], + [0.17877635, 0.31785583, 0.29541582], + [0.74003130, 0.61015379, 0.43858936], + [0.62925828, 0.51779926, 0.37218189], + [0.51435107, 0.42165166, 0.29885665], + [0.37107259, 0.30354372, 0.20976804], + [0.26378226, 0.21566097, 0.14350446], + [0.16334273, 0.13377619, 0.08050516], + ] + ), + ), + ( + np.array( + [ + [0.34787202, 0.22239830, 0.12071132], + [0.58308375, 0.37266096, 0.23445724], + [0.31696445, 0.30354100, 0.27743283], + [0.30365786, 0.27450961, 0.11830053], + [0.42217895, 0.32414007, 0.30887246], + [0.35737351, 0.43327817, 0.29111093], + [0.62362486, 0.32676762, 0.07449088], + [0.25730333, 0.23566855, 0.30452645], + [0.57681578, 0.25527635, 0.17708671], + [0.30016240, 0.18407927, 0.18981223], + [0.48437726, 0.45353514, 0.05076985], + [0.64548796, 0.39820802, 0.02829487], + [0.18757187, 0.17559552, 0.24984352], + [0.26328433, 0.35216329, 0.11459546], + [0.51652521, 0.20295261, 0.11941541], + [0.69001114, 0.49240762, 0.00375420], + [0.56554443, 0.25572431, 0.26536795], + [0.19376248, 0.31754220, 0.29425311], + [0.69671404, 0.56982183, 0.40269485], + [0.58879417, 0.48042127, 0.34024647], + [0.48349753, 0.39325854, 0.27507150], + [0.36126551, 0.29105616, 0.19889101], + [0.26881081, 0.21673009, 0.14677900], + [0.18107167, 0.14177044, 0.09304354], + ] + ), + ), + ( + np.array( + [ + [0.21935888, 0.12829103, 0.05823010], + [0.38621023, 0.23481502, 0.13791171], + [0.19868790, 0.18798569, 0.17158148], + [0.19399667, 0.17182195, 0.06093559], + [0.27950001, 0.21304348, 0.20691113], + [0.24463765, 0.30781290, 0.20621555], + [0.42518276, 0.21057689, 0.03164171], + [0.15967241, 0.14403240, 0.19099186], + [0.39287907, 0.16042854, 0.10488193], + [0.19517671, 0.11038135, 0.11735366], + [0.33836949, 0.31697488, 0.02259050], + [0.48356858, 0.29514906, 0.01217067], + [0.11187637, 0.10295546, 0.15011585], + [0.17635491, 0.23033310, 0.06228105], + [0.35300460, 0.12854379, 0.06986750], + [0.48630539, 0.34021181, 0.00108740], + [0.40561464, 0.17577751, 0.18303318], + [0.12991810, 0.23211640, 0.21919341], + [0.48198688, 0.37970755, 0.25524086], + [0.41261905, 0.32600898, 0.22289814], + [0.34072644, 0.27106091, 0.18362711], + [0.25940242, 0.20172483, 0.13366182], + [0.19378240, 0.15367146, 0.10122680], + [0.13230942, 0.10608750, 0.06544701], + ] + ), + ), + ] + + np.set_printoptions( + formatter={"float": "{:0.8f}".format}, suppress=True + ) + for i, png_file in enumerate(PNG_FILES): + np.testing.assert_allclose( + detect_colour_checkers_inference(read_image(png_file)), + test_swatches[i], + atol=0.0001, + ) + + ( + swatch_colours, + swatch_masks, + colour_checker, + ) = detect_colour_checkers_inference( + read_image(PNG_FILES[0]), additional_data=True + )[ + 0 + ].values + + np.testing.assert_allclose( + swatch_colours, + test_swatches[0][0], + atol=0.0001, + ) + + np.testing.assert_array_equal( + colour_checker.shape[0:2], + np.array([1008, 1440]), + ) + + np.testing.assert_array_equal( + swatch_masks, + np.array( + [ + [110, 142, 104, 136], + [110, 142, 344, 376], + [110, 142, 584, 616], + [110, 142, 824, 856], + [110, 142, 1064, 1096], + [110, 142, 1304, 1336], + [362, 394, 104, 136], + [362, 394, 344, 376], + [362, 394, 584, 616], + [362, 394, 824, 856], + [362, 394, 1064, 1096], + [362, 394, 1304, 1336], + [614, 646, 104, 136], + [614, 646, 344, 376], + [614, 646, 584, 616], + [614, 646, 824, 856], + [614, 646, 1064, 1096], + [614, 646, 1304, 1336], + [866, 898, 104, 136], + [866, 898, 344, 376], + [866, 898, 584, 616], + [866, 898, 824, 856], + [866, 898, 1064, 1096], + [866, 898, 1304, 1336], + ] + ), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/colour_checker_detection/detection/tests/test_segmentation.py b/colour_checker_detection/detection/tests/test_segmentation.py index 13aeaef..91d340e 100644 --- a/colour_checker_detection/detection/tests/test_segmentation.py +++ b/colour_checker_detection/detection/tests/test_segmentation.py @@ -14,9 +14,8 @@ from colour_checker_detection import ROOT_RESOURCES_TESTS from colour_checker_detection.detection import ( - colour_checkers_coordinates_segmentation, detect_colour_checkers_segmentation, - extract_colour_checkers_segmentation, + segmenter_default, ) __author__ = "Colour Developers" @@ -29,8 +28,7 @@ __all__ = [ "DETECTION_DIRECTORY", "PNG_FILES", - "TestColourCheckersCoordinatesSegmentation", - "TestExtractColourCheckersSegmentation", + "TestSegmenterDefault", "TestDetectColourCheckersSegmentation", ] @@ -38,19 +36,19 @@ ROOT_RESOURCES_TESTS, "colour_checker_detection", "detection" ) -PNG_FILES = glob.glob(os.path.join(DETECTION_DIRECTORY, "IMG_19*.png")) +PNG_FILES = sorted(glob.glob(os.path.join(DETECTION_DIRECTORY, "IMG_19*.png"))) -class TestColourCheckersCoordinatesSegmentation(unittest.TestCase): +class TestSegmenterDefault(unittest.TestCase): """ Define :func:`colour_checker_detection.detection.segmentation.\ -colour_checkers_coordinates_segmentation` definition unit tests methods. +segmenter_default` definition unit tests methods. """ - def test_colour_checkers_coordinates_segmentation(self): + def test_segmenter_default(self): """ Define :func:`colour_checker_detection.detection.segmentation.\ -colour_checkers_coordinates_segmentation` definition unit tests methods. +segmenter_default` definition unit tests methods. """ # Skipping unit test when "png" files are missing, e.g. when testing @@ -62,20 +60,19 @@ def test_colour_checkers_coordinates_segmentation(self): if platform.system() in ("Windows", "Microsoft", "Linux"): return - colour_checkers_coordinates = [ - (np.array([[640, 333], [793, 333], [793, 436], [640, 436]]),), - (np.array([[761, 650], [765, 293], [1007, 295], [1004, 653]]),), - (np.array([[366, 684], [383, 223], [1077, 248], [1061, 709]]),), - (np.array([[676, 576], [680, 367], [990, 373], [986, 582]]),), - (np.array([[573, 665], [579, 359], [1039, 367], [1034, 673]]),), - (np.array([[622, 597], [624, 312], [1048, 315], [1046, 601]]),), + colour_checkers_rectangles = [ + np.array([[[671, 578], [675, 364], [994, 370], [990, 584]]]), + np.array([[[358, 691], [373, 219], [1086, 242], [1071, 713]]]), + np.array([[[571, 670], [575, 357], [1045, 364], [1040, 676]]]), + np.array([[[616, 605], [617, 310], [1056, 312], [1055, 607]]]), + np.array([[[639, 333], [795, 333], [795, 437], [639, 437]]]), + np.array([[[759, 654], [762, 288], [1009, 290], [1006, 657]]]), ] for i, png_file in enumerate(PNG_FILES): - np.testing.assert_allclose( - colour_checkers_coordinates_segmentation(read_image(png_file)), - colour_checkers_coordinates[i], - atol=5, + np.testing.assert_array_equal( + segmenter_default(read_image(png_file)), + colour_checkers_rectangles[i], ) ( @@ -83,113 +80,57 @@ def test_colour_checkers_coordinates_segmentation(self): clusters, swatches, segmented_image, - ) = colour_checkers_coordinates_segmentation( + ) = segmenter_default( read_image(PNG_FILES[0]), additional_data=True ).values - np.testing.assert_allclose( + np.testing.assert_array_equal( colour_checkers, - colour_checkers_coordinates[0], - atol=5, + colour_checkers_rectangles[0], ) - np.testing.assert_allclose( + np.testing.assert_array_equal( clusters, - ( - np.array([[627, 482], [783, 482], [783, 580], [627, 580]]), - np.array([[640, 333], [795, 333], [795, 438], [640, 438]]), - ), - atol=5, + np.array([[[671, 578], [675, 364], [994, 370], [990, 584]]]), ) - np.testing.assert_allclose( + np.testing.assert_array_equal( swatches, - ( - [[763, 563], [780, 563], [780, 580], [763, 580]], - [[740, 563], [757, 563], [757, 580], [740, 580]], - [[629, 561], [647, 561], [647, 579], [629, 579]], - [[760, 536], [782, 536], [782, 557], [760, 557]], - [[732, 535], [754, 535], [754, 557], [732, 557]], - [[705, 535], [727, 535], [727, 557], [705, 557]], - [[678, 535], [699, 535], [699, 557], [678, 557]], - [[650, 556], [650, 534], [672, 534], [672, 556]], - [[760, 508], [782, 508], [782, 530], [760, 530]], - [[732, 508], [754, 508], [754, 530], [732, 530]], - [[705, 508], [727, 508], [727, 530], [705, 530]], - [[678, 529], [678, 507], [700, 507], [700, 529]], - [[650, 507], [672, 507], [672, 529], [650, 529]], - [[697, 485], [714, 485], [714, 502], [697, 502]], - [[630, 502], [630, 484], [647, 484], [647, 502]], - [[745, 414], [766, 414], [766, 434], [745, 434]], - [[719, 414], [740, 414], [740, 434], [719, 434]], - [[694, 413], [714, 413], [714, 435], [694, 435]], - [[669, 413], [689, 413], [689, 433], [669, 433]], - [[643, 413], [663, 413], [663, 433], [643, 433]], - [[746, 389], [766, 389], [766, 407], [746, 407]], - [[771, 388], [792, 388], [792, 409], [771, 409]], - [[720, 388], [740, 388], [740, 408], [720, 408]], - [[669, 387], [689, 387], [689, 408], [669, 408]], - [[643, 387], [664, 387], [664, 407], [643, 407]], - [[746, 364], [766, 364], [766, 383], [746, 383]], - [[720, 381], [722, 362], [741, 364], [739, 383]], - [[695, 362], [715, 362], [715, 382], [695, 382]], - [[669, 362], [689, 362], [689, 382], [669, 382]], - [[644, 361], [664, 361], [664, 382], [644, 382]], - [[771, 337], [792, 337], [792, 358], [771, 358]], - [[746, 337], [766, 337], [766, 357], [746, 357]], - [[720, 337], [740, 337], [740, 357], [720, 357]], - [[695, 357], [695, 336], [715, 336], [715, 357]], + np.array( + [ + [[886, 575], [887, 531], [931, 532], [930, 576]], + [[835, 574], [836, 530], [880, 531], [879, 575]], + [[782, 572], [784, 528], [829, 530], [828, 573]], + [[732, 571], [732, 527], [777, 529], [776, 572]], + [[681, 570], [682, 526], [726, 527], [725, 571]], + [[939, 525], [940, 481], [984, 483], [982, 526]], + [[887, 524], [887, 479], [933, 481], [932, 525]], + [[835, 523], [836, 478], [881, 480], [879, 524]], + [[784, 522], [784, 477], [829, 478], [829, 523]], + [[733, 521], [733, 477], [778, 479], [777, 522]], + [[683, 518], [684, 478], [726, 479], [725, 519]], + [[939, 474], [939, 429], [986, 431], [985, 475]], + [[888, 473], [889, 429], [934, 430], [933, 474]], + [[837, 472], [837, 428], [882, 430], [881, 473]], + [[785, 471], [785, 427], [831, 428], [830, 473]], + [[734, 470], [734, 426], [779, 428], [778, 471]], + [[682, 469], [683, 425], [728, 427], [727, 471]], + [[941, 423], [941, 378], [987, 380], [986, 424]], + [[889, 422], [889, 377], [935, 379], [934, 423]], + [[838, 421], [838, 377], [883, 379], [882, 422]], + [[786, 420], [786, 376], [831, 378], [830, 421]], + [[735, 420], [736, 376], [780, 377], [779, 421]], + [[683, 419], [684, 375], [728, 376], [727, 420]], + ], ), - atol=5, ) - np.testing.assert_allclose( + np.testing.assert_array_equal( segmented_image.shape, - (958, 1440), - atol=5, + (959, 1440), ) -class TestExtractColourCheckersSegmentation(unittest.TestCase): - """ - Define :func:`colour_checker_detection.detection.segmentation.\ -extract_colour_checkers_segmentation` definition unit tests methods. - """ - - def test_extract_colour_checkers_segmentation(self): - """ - Define :func:`colour_checker_detection.detection.segmentation.\ -extract_colour_checkers_segmentation` definition unit tests methods. - """ - - # TODO: Unit test is only reproducible on "macOs", skipping other OSes. - if platform.system() in ("Windows", "Microsoft", "Linux"): - return - - colour_checkers_shapes = np.array( - [ - [(105, 155, 3)], - [(241, 357, 3)], - [(463, 696, 3)], - [(209, 310, 3)], - [(305, 459, 3)], - [(284, 426, 3)], - ] - ) - - for i, png_file in enumerate(PNG_FILES): - np.testing.assert_allclose( - [ - colour_checker.shape - for colour_checker in extract_colour_checkers_segmentation( - read_image(png_file) - ) - ], - colour_checkers_shapes[i], - atol=5, - ) - - class TestDetectColourCheckersSegmentation(unittest.TestCase): """ Define :func:`colour_checker_detection.detection.segmentation.\ @@ -215,180 +156,180 @@ def test_detect_colour_checkers_segmentation(self): ( np.array( [ - [0.35921775, 0.21974672, 0.11373826], - [0.61170453, 0.38223811, 0.23506132], - [0.31699433, 0.30329567, 0.28068161], - [0.30062402, 0.27086534, 0.10395446], - [0.42765329, 0.32979061, 0.32321347], - [0.37651571, 0.47334258, 0.32200205], - [0.66219833, 0.34269353, 0.05197808], - [0.25087396, 0.23144240, 0.30867327], - [0.59803160, 0.25718724, 0.17655400], - [0.29072088, 0.16862319, 0.18556926], - [0.50011747, 0.47132109, 0.02669218], - [0.69162823, 0.42877312, 0.00891612], - [0.18406819, 0.16636620, 0.25519332], - [0.27382752, 0.36089569, 0.11240099], - [0.53949638, 0.20005471, 0.11816785], - [0.71929229, 0.51006474, 0.00039430], - [0.58201000, 0.25834369, 0.27286384], - [0.17459366, 0.33211689, 0.31462037], - [0.74890687, 0.60181178, 0.41818177], - [0.62550942, 0.50636506, 0.35586469], - [0.50507406, 0.41105972, 0.28757535], - [0.37983909, 0.30265359, 0.20787124], - [0.26862558, 0.21718989, 0.14771240], - [0.17296679, 0.13739381, 0.08705275], - ] + [0.24832384, 0.15328871, 0.08390528], + [0.41702515, 0.25749645, 0.15524487], + [0.21561633, 0.19969578, 0.18122309], + [0.19740796, 0.17287970, 0.06432715], + [0.27344641, 0.20384602, 0.19483535], + [0.23397270, 0.28900445, 0.18982877], + [0.45813102, 0.23316218, 0.05019258], + [0.18077616, 0.16044182, 0.20741640], + [0.39589149, 0.16688588, 0.11324922], + [0.19540378, 0.11008169, 0.11556859], + [0.31544343, 0.29114881, 0.02143807], + [0.43209532, 0.25869009, 0.00787305], + [0.13758199, 0.12513705, 0.17688018], + [0.18623975, 0.24561603, 0.07675899], + [0.35410595, 0.13109550, 0.07380734], + [0.46201417, 0.32264873, 0.00161150], + [0.37113908, 0.15819809, 0.16307078], + [0.11848511, 0.20048647, 0.18547057], + [0.50230736, 0.41071275, 0.28996509], + [0.41354591, 0.33473289, 0.23615897], + [0.32904309, 0.26473308, 0.18369149], + [0.23612073, 0.18612471, 0.12523490], + [0.16774438, 0.13109615, 0.08313587], + [0.10746267, 0.07903580, 0.04796651], + ], ), ), ( np.array( [ - [0.52661877, 0.33390523, 0.18513118], - [0.88719214, 0.56975287, 0.36187576], - [0.47231212, 0.45517357, 0.42484753], - [0.44641457, 0.40701556, 0.16383042], - [0.61928881, 0.47921490, 0.47194654], - [0.53603960, 0.66471400, 0.45367429], - [0.94688621, 0.50429289, 0.09571916], - [0.37035096, 0.35029038, 0.46444686], - [0.86304017, 0.38359153, 0.27170754], - [0.43000880, 0.25629303, 0.28155157], - [0.70911596, 0.67462002, 0.04686322], - [0.96567029, 0.60476842, 0.01810791], - [0.26848041, 0.25029797, 0.37943655], - [0.39276376, 0.52510734, 0.17521068], - [0.76615518, 0.29531580, 0.18060604], - [0.99615478, 0.72470275, 0.00184510], - [0.81273516, 0.36779556, 0.39294757], - [0.23433227, 0.46588089, 0.44498401], - [0.99932958, 0.84789681, 0.60709837], - [0.87390482, 0.71778395, 0.51724635], - [0.70507921, 0.58187965, 0.41834509], - [0.53014457, 0.43078569, 0.30347199], - [0.37816306, 0.30750085, 0.21542860], - [0.23509498, 0.18902577, 0.12444219], - ] + [0.36000499, 0.22310828, 0.11760835], + [0.62583089, 0.39448667, 0.24166533], + [0.33197999, 0.31600377, 0.28866866], + [0.30460060, 0.27332100, 0.10486555], + [0.41751358, 0.31914026, 0.30789137], + [0.34866226, 0.43934596, 0.29126382], + [0.67983997, 0.35236534, 0.06997226], + [0.27118555, 0.25352538, 0.33078724], + [0.62091863, 0.27034152, 0.18652563], + [0.30716130, 0.17978874, 0.19181632], + [0.48547146, 0.45855859, 0.03294956], + [0.65076780, 0.40023172, 0.01607676], + [0.19286253, 0.18585181, 0.27459183], + [0.28054565, 0.38513032, 0.12244410], + [0.55454308, 0.21436104, 0.12549178], + [0.72068894, 0.51493925, 0.00548734], + [0.57729208, 0.25771791, 0.26855531], + [0.17289193, 0.31637919, 0.29508531], + [0.73940831, 0.60953134, 0.43830720], + [0.62816709, 0.51759964, 0.37215686], + [0.51360977, 0.42048824, 0.29857090], + [0.36953217, 0.30218402, 0.20827036], + [0.26286703, 0.21493268, 0.14277342], + [0.16102524, 0.13381621, 0.08047409], + ], ), ), ( np.array( [ - [0.36039445, 0.22350599, 0.11873420], - [0.62714458, 0.39478764, 0.24229150], - [0.33165672, 0.31563434, 0.28914776], - [0.30480161, 0.27362752, 0.10432182], - [0.41683716, 0.31974182, 0.30811438], - [0.34742230, 0.44155839, 0.29356793], - [0.68121970, 0.35288528, 0.07221610], - [0.27225676, 0.25337765, 0.33124942], - [0.62068969, 0.27035698, 0.18658416], - [0.30677941, 0.17992996, 0.19168894], - [0.48666200, 0.45993966, 0.03378791], - [0.65246701, 0.40165728, 0.01610205], - [0.19140731, 0.18562141, 0.27346352], - [0.27903748, 0.38509497, 0.12212852], - [0.55395961, 0.21471037, 0.12492916], - [0.72143435, 0.51594681, 0.00548300], - [0.57832998, 0.25804800, 0.26887447], - [0.17814353, 0.31652015, 0.29603240], - [0.74455619, 0.61200351, 0.43996775], - [0.63054210, 0.51812100, 0.37331769], - [0.51466727, 0.42153466, 0.29906410], - [0.37171054, 0.30395758, 0.20983726], - [0.26531571, 0.21593873, 0.14360215], - [0.16383056, 0.13378082, 0.08097480], + [0.34738940, 0.22210284, 0.12043952], + [0.58209461, 0.37278461, 0.23478897], + [0.31616706, 0.30381790, 0.27724868], + [0.30311552, 0.27459133, 0.11836217], + [0.42153537, 0.32409266, 0.30891716], + [0.35749745, 0.43314731, 0.29142639], + [0.62254763, 0.32615680, 0.07393268], + [0.25821960, 0.23580971, 0.30404219], + [0.57693988, 0.25513524, 0.17670268], + [0.30006823, 0.18381491, 0.18989067], + [0.48401743, 0.45359546, 0.05218942], + [0.64574641, 0.39810193, 0.02945640], + [0.18849514, 0.17575365, 0.25011680], + [0.26373467, 0.35204440, 0.11400889], + [0.51626253, 0.20278455, 0.11849582], + [0.68842602, 0.49205878, 0.00377697], + [0.56539935, 0.25597996, 0.26512200], + [0.19216073, 0.31681597, 0.29429549], + [0.69482410, 0.56909364, 0.40155384], + [0.58873069, 0.48006463, 0.34068954], + [0.48271653, 0.39254814, 0.27525207], + [0.35977310, 0.28940114, 0.19704697], + [0.26829925, 0.21624289, 0.14619303], + [0.18016167, 0.14157206, 0.09355851], ], ), ), ( np.array( [ - [0.24887842, 0.15303747, 0.08212616], - [0.41606936, 0.25704649, 0.15554601], - [0.21573794, 0.19969735, 0.18036437], - [0.19700749, 0.17282481, 0.06601980], - [0.27340323, 0.20337079, 0.19491209], - [0.23491897, 0.28985065, 0.19005764], - [0.45789737, 0.23304574, 0.05214758], - [0.17908031, 0.16008465, 0.20729132], - [0.39573598, 0.16691849, 0.11239551], - [0.19287089, 0.10994958, 0.11457524], - [0.31488585, 0.29156134, 0.01948227], - [0.43320373, 0.25899616, 0.00871194], - [0.13852786, 0.12482940, 0.17608537], - [0.18533486, 0.24485032, 0.07610976], - [0.35295719, 0.13125142, 0.07479467], - [0.46251380, 0.32267866, 0.00188992], - [0.37160829, 0.15808560, 0.16385144], - [0.11490965, 0.20017006, 0.18578541], - [0.50275546, 0.41144696, 0.29024220], - [0.41369301, 0.33511353, 0.23674881], - [0.32926032, 0.26507148, 0.18402646], - [0.23766708, 0.18695265, 0.12535509], - [0.16787136, 0.13122515, 0.08452621], - [0.10730225, 0.07956661, 0.04697328], + [0.21938626, 0.12745626, 0.05788294], + [0.38446507, 0.23450573, 0.13715136], + [0.19894668, 0.18729475, 0.17246076], + [0.19457349, 0.17182909, 0.06108425], + [0.27911529, 0.21303454, 0.20818119], + [0.24418873, 0.30463865, 0.20364814], + [0.42587042, 0.21036983, 0.03387973], + [0.16159414, 0.14428589, 0.19156294], + [0.39223763, 0.16027561, 0.10546608], + [0.19678101, 0.11048739, 0.11770962], + [0.33779103, 0.31829616, 0.02421810], + [0.48373023, 0.29524034, 0.01024585], + [0.11387850, 0.10201312, 0.15105924], + [0.17331049, 0.22999455, 0.06060657], + [0.35458463, 0.12845674, 0.06968934], + [0.48692712, 0.34049782, 0.00180221], + [0.40718290, 0.17617357, 0.18679525], + [0.13581325, 0.23464102, 0.22059689], + [0.48099062, 0.37892419, 0.25402117], + [0.41220647, 0.32620439, 0.22248317], + [0.34211776, 0.27174956, 0.18414445], + [0.25703457, 0.20097046, 0.13262188], + [0.19677228, 0.15521790, 0.10373505], + [0.13552931, 0.10757796, 0.06661499], ], ), ), ( np.array( [ - [0.34836826, 0.22215524, 0.12135284], - [0.58325237, 0.37308872, 0.23444822], - [0.31704697, 0.30355555, 0.27795714], - [0.30426627, 0.27464610, 0.11857683], - [0.42129153, 0.32407933, 0.30909172], - [0.35933307, 0.43667969, 0.29267272], - [0.62412459, 0.32705662, 0.07551479], - [0.25816506, 0.23593378, 0.30402777], - [0.57699001, 0.25525075, 0.17735666], - [0.30067790, 0.18355943, 0.18990313], - [0.48428565, 0.45408702, 0.05068365], - [0.64917630, 0.39918983, 0.02770220], - [0.18815944, 0.17596889, 0.24910960], - [0.26274660, 0.35171160, 0.11372098], - [0.51511449, 0.20301704, 0.11943004], - [0.69010550, 0.49270925, 0.00358405], - [0.56560254, 0.25560325, 0.26505449], - [0.19258839, 0.31733096, 0.29525691], - [0.69754463, 0.57014823, 0.40287429], - [0.58907270, 0.48075455, 0.34047469], - [0.48401541, 0.39298880, 0.27514425], - [0.36163613, 0.29068086, 0.19965295], - [0.26967442, 0.21660130, 0.14631520], - [0.17994609, 0.14269032, 0.09328640], + [0.35696816, 0.21930856, 0.11403693], + [0.60821688, 0.38355678, 0.23785686], + [0.31905305, 0.30316705, 0.27976859], + [0.30302200, 0.27013323, 0.10675076], + [0.42698881, 0.33104691, 0.32175022], + [0.37930825, 0.47598192, 0.32185996], + [0.66308343, 0.34331027, 0.04840572], + [0.25116241, 0.23189384, 0.31125751], + [0.59939826, 0.25615332, 0.17492208], + [0.29469380, 0.16788639, 0.18995292], + [0.49911833, 0.47258806, 0.02826829], + [0.68877059, 0.43108180, 0.00497004], + [0.18456072, 0.16787593, 0.25497442], + [0.27599725, 0.36154932, 0.11149684], + [0.53841883, 0.20049030, 0.12016004], + [0.72244716, 0.51131296, 0.00018977], + [0.57876974, 0.25831613, 0.27366114], + [0.17160977, 0.33335912, 0.31680414], + [0.76642007, 0.61226171, 0.42404592], + [0.63404596, 0.51218492, 0.36258361], + [0.51259273, 0.41126823, 0.28810981], + [0.38513792, 0.30572626, 0.20889392], + [0.27351549, 0.21982881, 0.15018980], + [0.17083944, 0.13807550, 0.08634300], ], ), ), ( np.array( [ - [0.22017804, 0.12826383, 0.05788412], - [0.38693318, 0.23539165, 0.13782480], - [0.19914666, 0.18839033, 0.17261186], - [0.19430259, 0.17250592, 0.06102788], - [0.28009716, 0.21341877, 0.20753020], - [0.24569865, 0.30791867, 0.20637228], - [0.42701599, 0.21082541, 0.03257031], - [0.16083929, 0.14465059, 0.19211331], - [0.39257592, 0.16077888, 0.10590190], - [0.19661152, 0.11140312, 0.11799492], - [0.33920571, 0.31840333, 0.02249028], - [0.48388398, 0.29522106, 0.01101061], - [0.11242500, 0.10284122, 0.15103099], - [0.17404874, 0.23030786, 0.06279272], - [0.35536617, 0.12874892, 0.06993287], - [0.48747075, 0.34067380, 0.00159611], - [0.40709862, 0.17631432, 0.18579389], - [0.13446636, 0.23369668, 0.22029275], - [0.48294312, 0.38067934, 0.25635651], - [0.41376853, 0.32718080, 0.22381625], - [0.34264213, 0.27225804, 0.18502168], - [0.25755507, 0.20134068, 0.13364507], - [0.19580546, 0.15502162, 0.10322439], - [0.13540818, 0.10658601, 0.06714674], + [0.52302927, 0.33337167, 0.18533763], + [0.88968831, 0.57067597, 0.36026952], + [0.47294277, 0.45575163, 0.42374220], + [0.44841272, 0.40725341, 0.16428013], + [0.61938971, 0.47870743, 0.47080779], + [0.53657967, 0.66325128, 0.45389828], + [0.94704050, 0.50368863, 0.09376666], + [0.36815616, 0.35105297, 0.46465901], + [0.86500686, 0.38415903, 0.27185041], + [0.43016541, 0.25679177, 0.28029662], + [0.70661992, 0.67352915, 0.04775995], + [0.96663320, 0.60355937, 0.01704959], + [0.27100158, 0.25070760, 0.37777221], + [0.39140525, 0.52589625, 0.17211960], + [0.76712126, 0.29462230, 0.18415013], + [0.99539071, 0.72465611, 0.00130395], + [0.81031168, 0.36720258, 0.39325047], + [0.23815468, 0.46509922, 0.44485742], + [0.99919355, 0.84638172, 0.60809588], + [0.87047678, 0.71775925, 0.51714724], + [0.70366037, 0.58129185, 0.41761538], + [0.52873796, 0.42994583, 0.30210295], + [0.37849766, 0.30698854, 0.21497679], + [0.23189870, 0.19013362, 0.12126886], ], ), ), @@ -396,15 +337,15 @@ def test_detect_colour_checkers_segmentation(self): for i, png_file in enumerate(PNG_FILES): np.testing.assert_allclose( - detect_colour_checkers_segmentation(read_image(png_file)), + detect_colour_checkers_segmentation(png_file), test_swatches[i], atol=0.0001, ) ( swatch_colours, - colour_checker_image, swatch_masks, + colour_checker, ) = detect_colour_checkers_segmentation( read_image(PNG_FILES[0]), additional_data=True )[ @@ -417,41 +358,41 @@ def test_detect_colour_checkers_segmentation(self): atol=0.0001, ) - np.testing.assert_allclose( - colour_checker_image.shape[0:2], - (105, 155), - atol=5, + np.testing.assert_array_equal( + colour_checker.shape[0:2], + np.array([960, 1440]), ) - np.testing.assert_allclose( + np.testing.assert_array_equal( swatch_masks, - ( - ([5, 21, 4, 20]), - ([5, 21, 30, 46]), - ([5, 21, 56, 72]), - ([5, 21, 82, 98]), - ([5, 21, 108, 124]), - ([5, 21, 134, 150]), - ([31, 47, 4, 20]), - ([31, 47, 30, 46]), - ([31, 47, 56, 72]), - ([31, 47, 82, 98]), - ([31, 47, 108, 124]), - ([31, 47, 134, 150]), - ([57, 73, 4, 20]), - ([57, 73, 30, 46]), - ([57, 73, 56, 72]), - ([57, 73, 82, 98]), - ([57, 73, 108, 124]), - ([57, 73, 134, 150]), - ([83, 99, 4, 20]), - ([83, 99, 30, 46]), - ([83, 99, 56, 72]), - ([83, 99, 82, 98]), - ([83, 99, 108, 124]), - ([83, 99, 134, 150]), + np.array( + [ + [104, 136, 104, 136], + [104, 136, 344, 376], + [104, 136, 584, 616], + [104, 136, 824, 856], + [104, 136, 1064, 1096], + [104, 136, 1304, 1336], + [344, 376, 104, 136], + [344, 376, 344, 376], + [344, 376, 584, 616], + [344, 376, 824, 856], + [344, 376, 1064, 1096], + [344, 376, 1304, 1336], + [584, 616, 104, 136], + [584, 616, 344, 376], + [584, 616, 584, 616], + [584, 616, 824, 856], + [584, 616, 1064, 1096], + [584, 616, 1304, 1336], + [824, 856, 104, 136], + [824, 856, 344, 376], + [824, 856, 584, 616], + [824, 856, 824, 856], + [824, 856, 1064, 1096], + [824, 856, 1304, 1336], + ] ), - atol=5, ) diff --git a/colour_checker_detection/examples/examples_detection.ipynb b/colour_checker_detection/examples/examples_detection.ipynb index 933bc88..1be4e42 100644 --- a/colour_checker_detection/examples/examples_detection.ipynb +++ b/colour_checker_detection/examples/examples_detection.ipynb @@ -60,10 +60,10 @@ "from colour_checker_detection import (\n", " ROOT_RESOURCES_EXAMPLES,\n", " SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC,\n", - " colour_checkers_coordinates_segmentation,\n", + " segmenter_default,\n", " detect_colour_checkers_segmentation)\n", "from colour_checker_detection.detection.segmentation import (\n", - " adjust_image)\n", + " reformat_image)\n", "\n", "colour.plotting.colour_style()\n", "\n", @@ -400,10 +400,10 @@ "source": [ "for image in COLOUR_CHECKER_IMAGES:\n", " colour_checkers, clusters, swatches, segmented_image = (\n", - " colour_checkers_coordinates_segmentation(\n", + " segmenter_default(\n", " image, additional_data=True).values)\n", " \n", - " image_a = adjust_image(\n", + " image_a = reformat_image(\n", " image,\n", " SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC['working_width'])\n", "\n", diff --git a/colour_checker_detection/scripts/inference.py b/colour_checker_detection/scripts/inference.py index cbb393a..7bf757d 100755 --- a/colour_checker_detection/scripts/inference.py +++ b/colour_checker_detection/scripts/inference.py @@ -18,17 +18,17 @@ import logging import os from pathlib import Path +from time import perf_counter import click import cv2 import numpy as np from colour import read_image from colour.hints import List, Literal, NDArray, Tuple +from colour.io import convert_bit_depth from ultralytics import YOLO from ultralytics.utils.downloads import download -from colour_checker_detection.detection.common import as_8_bit_BGR_image - __author__ = "Colour Developers" __copyright__ = "Copyright 2024 Colour Developers" __license__ = ( @@ -104,6 +104,12 @@ def inference( for result in model(source, show=show, **kwargs): show and cv2.waitKey(0) == ord("n") + if result.boxes is None: + continue + + if result.masks is None: + continue + data_boxes = result.boxes.data data_masks = result.masks.data @@ -193,6 +199,8 @@ def segmentation( Inference results. """ + time_start = perf_counter() + logging.getLogger().setLevel(getattr(logging, logging_level.upper())) if model is None: @@ -209,14 +217,23 @@ def segmentation( source = np.load(input) else: logging.debug('Reading "%s" image...', input) - source = as_8_bit_BGR_image(read_image(input)) + source = convert_bit_depth( + read_image(input)[..., :3], np.uint8.__name__ # pyright: ignore + ) - results = np.array(inference(source, YOLO(model), show), dtype=object) + # NOTE: YOLOv8 expects "BGR" arrays. + results = np.array( + inference(source[..., ::-1], YOLO(model), show), dtype=object + ) if output is None: output = f"{input}.npz" - np.savez(output, results) + np.savez(output, results=results) + + logging.debug( + 'Total segmentation time: "%s" seconds.', perf_counter() - time_start + ) return results diff --git a/docs/colour_checker_detection.detection.rst b/docs/colour_checker_detection.detection.rst index 808056e..40c48ab 100644 --- a/docs/colour_checker_detection.detection.rst +++ b/docs/colour_checker_detection.detection.rst @@ -1,6 +1,21 @@ Colour Checker Detection ======================== +Inference +--------- + +``colour_checker_detection`` + +.. currentmodule:: colour_checker_detection + +.. autosummary:: + :toctree: generated/ + + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC + SETTINGS_INFERENCE_COLORCHECKER_CLASSIC_MINI + inferencer_default + detect_colour_checkers_inference + Segmentation ------------ @@ -13,6 +28,6 @@ Segmentation SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC SETTINGS_SEGMENTATION_COLORCHECKER_SG - colour_checkers_coordinates_segmentation - extract_colour_checkers_segmentation + SETTINGS_SEGMENTATION_COLORCHECKER_NANO + segmenter_default detect_colour_checkers_segmentation diff --git a/docs/index.rst b/docs/index.rst index cb7333d..652d1db 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,15 @@ Features The following colour checker detection algorithms are implemented: -- Segmentation +- Segmentation +- Machine learning inference via `Ultralytics YOLOv8 `__ + + - The model is published on `HuggingFace `__, + it was trained on a purposely constructed `dataset `__. + - Inference is performed by a script licensed under the terms of the + *GNU Affero General Public License v3.0* as it uses the + *Ultralytics YOLOv8* API which is incompatible with the + *BSD-3-Clause*. Examples ^^^^^^^^ diff --git a/docs/installation.rst b/docs/installation.rst index 799eb4b..0cf9d0e 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -20,6 +20,11 @@ Primary Dependencies - `opencv-python >= 4, < 5 `__ - `scipy >= 1.8, < 2 `__ +Secondary Dependencies +~~~~~~~~~~~~~~~~~~~~~~ + +- `ultralytics >= 8, < 9 `__ + Pypi ----