From ef7e93caec750326e3ef74dee0a5678fe1a6a9f0 Mon Sep 17 00:00:00 2001 From: TimWalter Date: Sat, 9 Mar 2024 12:24:56 +0100 Subject: [PATCH 1/8] Warped perspective feature, splitting the segmentation into segmenter&extractor --- colour_checker_detection/__init__.py | 19 + .../detection/__init__.py | 52 + colour_checker_detection/detection/common.py | 110 +- .../detection/segmentation.py | 1882 +++++++++++++++-- .../detection/templates/__init__.py | 11 + .../detection/templates/generate_template.py | 154 ++ .../detection/templates/template_colour.pkl | Bin 0 -> 117781 bytes .../detection/templates/template_colour.png | Bin 0 -> 16968 bytes .../detection/templates/template_colour.py | 82 + .../detection/templates/template_gray.pkl | Bin 0 -> 108339 bytes .../detection/templates/template_gray.png | Bin 0 -> 26786 bytes .../detection/templates/template_gray.py | 85 + colour_checker_detection/scripts/inference.py | 2 +- requirements.txt | 3 + 14 files changed, 2181 insertions(+), 219 deletions(-) create mode 100644 colour_checker_detection/detection/templates/__init__.py create mode 100644 colour_checker_detection/detection/templates/generate_template.py create mode 100644 colour_checker_detection/detection/templates/template_colour.pkl create mode 100644 colour_checker_detection/detection/templates/template_colour.png create mode 100644 colour_checker_detection/detection/templates/template_colour.py create mode 100644 colour_checker_detection/detection/templates/template_gray.pkl create mode 100644 colour_checker_detection/detection/templates/template_gray.png create mode 100644 colour_checker_detection/detection/templates/template_gray.py diff --git a/colour_checker_detection/__init__.py b/colour_checker_detection/__init__.py index 675befc..ce0681d 100644 --- a/colour_checker_detection/__init__.py +++ b/colour_checker_detection/__init__.py @@ -25,10 +25,18 @@ SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC, SETTINGS_SEGMENTATION_COLORCHECKER_NANO, SETTINGS_SEGMENTATION_COLORCHECKER_SG, + Template, detect_colour_checkers_inference, detect_colour_checkers_segmentation, + extractor_default, + extractor_warped, inferencer_default, + plot_colours, + plot_colours_warped, + plot_contours, + plot_swatches_and_clusters, segmenter_default, + segmenter_warped, ) __author__ = "Colour Developers" @@ -48,6 +56,14 @@ "detect_colour_checkers_segmentation", "inferencer_default", "segmenter_default", + "segmenter_warped", + "extractor_default", + "extractor_warped", + "Template", + "plot_contours", + "plot_swatches_and_clusters", + "plot_colours", + "plot_colours_warped", ] ROOT_RESOURCES: str = os.path.join(os.path.dirname(__file__), "resources") @@ -58,6 +74,9 @@ ROOT_RESOURCES, "colour-checker-detection-tests-datasets" ) +ROOT_DETECTION: str = os.path.join(os.path.dirname(__file__), "detection") +ROOT_DETECTION_TEMPLATES: str = os.path.join(ROOT_DETECTION, "templates") + __application_name__ = "Colour - Checker Detection" __major_version__ = "0" diff --git a/colour_checker_detection/detection/__init__.py b/colour_checker_detection/detection/__init__.py index 255fe6a..2fbc18a 100644 --- a/colour_checker_detection/detection/__init__.py +++ b/colour_checker_detection/detection/__init__.py @@ -16,6 +16,8 @@ scale_contour, approximate_contour, quadrilateralise_contours, + largest_convex_quadrilateral, + is_convex_quadrilateral, remove_stacked_contours, DataDetectionColourChecker, sample_colour_checker, @@ -31,10 +33,34 @@ SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC, SETTINGS_SEGMENTATION_COLORCHECKER_SG, SETTINGS_SEGMENTATION_COLORCHECKER_NANO, + filter_contours, + filter_contours_multifeature, + cluster_swatches, + filter_clusters, + filter_clusters_by_swatches, + group_swatches, + order_centroids, + determine_best_transformation, + extract_colours, + correct_flipped, + check_residuals, + plot_contours, + plot_swatches_and_clusters, + plot_colours, + plot_colours_warped, segmenter_default, + segmenter_warped, + extractor_default, + extractor_warped, detect_colour_checkers_segmentation, ) +from .templates import ( + Template, + are_three_collinear, + generate_template, +) + __all__ = [ "DTYPE_INT_DEFAULT", "DTYPE_FLOAT_DEFAULT", @@ -53,6 +79,8 @@ "scale_contour", "approximate_contour", "quadrilateralise_contours", + "largest_convex_quadrilateral", + "is_convex_quadrilateral", "remove_stacked_contours", "DataDetectionColourChecker", "sample_colour_checker", @@ -61,7 +89,25 @@ "SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC", "SETTINGS_SEGMENTATION_COLORCHECKER_SG", "SETTINGS_SEGMENTATION_COLORCHECKER_NANO", + "filter_contours", + "filter_contours_multifeature", + "cluster_swatches", + "filter_clusters", + "filter_clusters_by_swatches", + "group_swatches", + "order_centroids", + "determine_best_transformation", + "extract_colours", + "correct_flipped", + "check_residuals", + "plot_contours", + "plot_swatches_and_clusters", + "plot_colours", + "plot_colours_warped", "segmenter_default", + "segmenter_warped", + "extractor_default", + "extractor_warped", "extract_colour_checkers_segmentation", "detect_colour_checkers_segmentation", ] @@ -71,3 +117,9 @@ "inferencer_default", "detect_colour_checkers_inference", ] + +__all__ += [ + "Template", + "are_three_collinear", + "generate_template", +] diff --git a/colour_checker_detection/detection/common.py b/colour_checker_detection/detection/common.py index 8c0a319..32c266d 100644 --- a/colour_checker_detection/detection/common.py +++ b/colour_checker_detection/detection/common.py @@ -79,6 +79,8 @@ "scale_contour", "approximate_contour", "quadrilateralise_contours", + "largest_convex_quadrilateral", + "is_convex_quadrilateral", "remove_stacked_contours", "DataDetectionColourChecker", "sample_colour_checker", @@ -90,7 +92,6 @@ 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())), @@ -880,9 +881,81 @@ def quadrilateralise_contours(contours: ArrayLike) -> Tuple[NDArrayInt, ...]: ) +def largest_convex_quadrilateral(contour: np.ndarray) -> Tuple[NDArrayInt, bool]: + """ + Return the largest convex quadrilateral contained in the given contour. + + Parameters + ---------- + contour + Contour to process. + + Returns + ------- + :class:`tuple` + (contour of the largest convex quadrilateral, convexity) + + Example: + >>> contour = np.array( + ... [[0, 0], [0, 1], [1, 1], [1, 0], [0.5, 0.5]], dtype=np.float32 + ... ) + >>> largest_convex_quadrilateral(contour) + (array([[ 0, 0], + [ 0, 1], + [ 1, 1], + [ 1, 0]], dtype=float32), True) + """ + while len(contour) > 4: + areas = { + i: cv2.contourArea(np.delete(contour, i, axis=0)) + for i in range(len(contour)) + } + areas = dict(sorted(areas.items(), key=lambda item: item[1])) + + # delete pt, which, if excluded leaves the largest area + contour = np.delete(contour, list(areas.keys())[-1], axis=0) + + return contour, cv2.isContourConvex(contour) + + +def is_convex_quadrilateral(contour: np.ndarray, tolerance: float = 0.1) -> bool: + """ + Return True if the given contour is a convex quadrilateral. + + Parameters + ---------- + contour + Contour to process. + tolerance + Tolerance for the ratio of the areas between the trimmed contour + and the original contour. + + Returns + ------- + :class:`bool` + True if the given contour is a convex quadrilateral. + + Example: + >>> contour = np.array( + ... [[0, 0], [0, 1], [1, 1], [1, 0], [0.5, 0.5]], dtype=np.float32 + ... ) + >>> is_convex_quadrilateral(contour) + False + """ + if len(contour) >= 4: + original_area = cv2.contourArea(contour) + convex_contour, convexity = largest_convex_quadrilateral(contour) + if convexity: + convex_area = cv2.contourArea(convex_contour) + ratio = convex_area / original_area + return np.abs(ratio - 1) < tolerance + + return False + + def remove_stacked_contours( contours: ArrayLike, keep_smallest: bool = True -) -> Tuple[NDArrayInt, ...]: +) -> NDArrayInt: """ Remove amd filter out the stacked contours from given contours keeping either the smallest or the largest ones. @@ -912,16 +985,16 @@ def remove_stacked_contours( ... [[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]]...) + >>> remove_stacked_contours(contours) + array([[[0, 0], + [7, 0], + [7, 7], + [0, 7]]]) + >>> remove_stacked_contours(contours, False) + array([[[ 0, 0], + [10, 0], + [10, 10], + [ 0, 10]]]) """ contours = as_int32_array(contours) @@ -966,8 +1039,8 @@ def remove_stacked_contours( filtered_contours[index] = contour - return tuple( - as_int32_array(filtered_contour) for filtered_contour in filtered_contours + return as_int32_array( + [as_int32_array(filtered_contour) for filtered_contour in filtered_contours] ) @@ -991,8 +1064,8 @@ class DataDetectionColourChecker(MixinDataclassIterable): swatch_colours: NDArrayFloat swatch_masks: NDArrayInt - colour_checker: NDArrayFloat - quadrilateral: NDArrayFloat + colour_checker: NDArrayInt + quadrilateral: NDArrayInt def sample_colour_checker( @@ -1158,5 +1231,8 @@ def sample_colour_checker( colour_checker = cast(NDArrayFloat, colour_checker) return DataDetectionColourChecker( - sampled_colours, masks, colour_checker, quadrilateral + sampled_colours, + masks, + as_int32_array(colour_checker), + as_int32_array(quadrilateral), ) diff --git a/colour_checker_detection/detection/segmentation.py b/colour_checker_detection/detection/segmentation.py index ec1f63c..3a980e7 100644 --- a/colour_checker_detection/detection/segmentation.py +++ b/colour_checker_detection/detection/segmentation.py @@ -7,7 +7,14 @@ - :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC` - :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_SG` - :attr:`colour_checker_detection.SETTINGS_SEGMENTATION_COLORCHECKER_NANO` +- :func:`colour_checker_detection.plot_contours` +- :func:`colour_checker_detection.plot_swatches_and_clusters` +- :func:`colour_checker_detection.plot_colours` +- :func:`colour_checker_detection.plot_colours_warped` - :func:`colour_checker_detection.segmenter_default` +- :func:`colour_checker_detection.segmenter_warped` +- :func:`colour_checker_detection.extractor_default` +- :func:`colour_checker_detection.extractor_warped` - :func:`colour_checker_detection.detect_colour_checkers_segmentation` References @@ -41,11 +48,15 @@ MixinDataclassIterable, Structure, is_string, + usage_warning, ) from colour.utilities.documentation import ( DocstringDict, is_documentation_building, ) +from scipy.optimize import linear_sum_assignment +from scipy.spatial import distance_matrix +from sklearn.cluster import DBSCAN from colour_checker_detection.detection.common import ( DTYPE_FLOAT_DEFAULT, @@ -55,13 +66,16 @@ as_int32_array, contour_centroid, detect_contours, + is_convex_quadrilateral, is_square, + largest_convex_quadrilateral, quadrilateralise_contours, reformat_image, remove_stacked_contours, sample_colour_checker, scale_contour, ) +from colour_checker_detection.detection.templates.generate_template import Template __author__ = "Colour Developers" __copyright__ = "Copyright 2018 Colour Developers" @@ -75,7 +89,26 @@ "SETTINGS_SEGMENTATION_COLORCHECKER_SG", "SETTINGS_SEGMENTATION_COLORCHECKER_NANO", "DataSegmentationColourCheckers", + "WarpingData", + "filter_contours", + "filter_contours_multifeature", + "cluster_swatches", + "filter_clusters", + "filter_clusters_by_swatches", + "group_swatches", + "order_centroids", + "determine_best_transformation", + "extract_colours", + "correct_flipped", + "check_residuals", + "plot_contours", + "plot_swatches_and_clusters", + "plot_colours", + "plot_colours_warped", "segmenter_default", + "segmenter_warped", + "extractor_default", + "extractor_warped", "detect_colour_checkers_segmentation", ] @@ -91,6 +124,7 @@ "swatches_count_maximum": int(24 * 1.25), "swatch_minimum_area_factor": 200, "swatch_contour_scale": 1 + 1 / 3, + "greedy_heuristic": 10, } ) if is_documentation_building(): # pragma: no cover @@ -167,49 +201,1588 @@ class DataSegmentationColourCheckers(MixinDataclassIterable): segmented_image: NDArrayFloat +@dataclass +class WarpingData: + """ + Data class for storing the results of the correspondence finding. + + Parameters + ---------- + cluster_id + The index of the cluster that was used for the correspondence. + cost + The cost of the transformation, which means the average distance of the + warped point from the reference template point. + transformation + The transformation matrix to warp the cluster to the template. + """ + + cluster_id: int = -1 + cost: float = np.inf + transformation: np.ndarray = None # pyright: ignore + + +def filter_contours( + image: NDArrayFloat, + contours: ArrayLike, + swatches: int, + swatch_minimum_area_factor: float, +) -> NDArrayInt: + """ + Filter the contours first by area and whether then by squareness. + + Parameters + ---------- + image + The image containing the contours. Only used for its shape. + contours + The contours from which to filter the swatches. + swatches + The expected number of swatches. + swatch_minimum_area_factor + The minimum area factor of the smallest swatch. + + Returns + ------- + NDArrayInt + The filtered contours. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import filter_contours + >>> image = np.zeros((600, 900, 3)) + >>> contours = [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 200], [300, 200], [250, 100]], + ... [[200, 100], [600, 100], [600, 400], [200, 400]], + ... ] + >>> filter_contours(image, contours, 24, 200) + array([[[100, 100], + [200, 100], + [200, 200], + [100, 200]]]) + """ + width, height = image.shape[1], image.shape[0] + minimum_area = width * height / swatches / swatch_minimum_area_factor + maximum_area = width * height / swatches + 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))) + ) + return as_int32_array(squares) + + +def filter_contours_multifeature( + image: NDArrayFloat, + contours: NDArrayInt | Tuple[NDArrayInt], + swatches: int, + swatch_minimum_area_factor: float, +) -> NDArrayInt: + """ + Filter the contours first by area and whether they are roughly a convex + quadrilateral and afterwards by multiple features namely squareness, + area, aspect ratio and orientation. + + Parameters + ---------- + image + The image containing the contours. Only used for its shape. + contours + The contours from which to filter the swatches. + swatches + The expected number of swatches. + swatch_minimum_area_factor + The minimum area factor of the smallest swatch. + + Returns + ------- + NDArrayInt + The filtered contours. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import filter_contours_multifeature + >>> image = np.zeros((600, 900, 3)) + >>> contours = [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[200, 200], [300, 200], [300, 300], [200, 300]], + ... [[300, 300], [400, 300], [400, 400], [300, 400]], + ... [[400, 400], [500, 400], [500, 500], [400, 500]], + ... [[500, 500], [600, 500], [600, 600], [500, 600]], + ... [[300, 100], [400, 100], [400, 200], [300, 200], [250, 100]], + ... [[200, 100], [600, 100], [600, 400], [200, 400]], + ... ] + >>> filter_contours_multifeature(image, contours, 24, 200) + array([[[[100, 100]], + + [[200, 100]], + + [[200, 200]], + + [[100, 200]]], + + + [[[200, 200]], + + [[300, 200]], + + [[300, 300]], + + [[200, 300]]], + + + [[[300, 300]], + + [[400, 300]], + + [[400, 400]], + + [[300, 400]]], + + + [[[400, 400]], + + [[500, 400]], + + [[500, 500]], + + [[400, 500]]], + + + [[[500, 500]], + + [[600, 500]], + + [[600, 600]], + + [[500, 600]]]], dtype=int32) + """ + width, height = image.shape[1], image.shape[0] + minimum_area = width * height / swatches / swatch_minimum_area_factor + maximum_area = width * height / swatches + + square = np.array([[0, 0], [1, 0], [1, 1], [0, 1]]) + squares = [] + features = [] + for contour in contours: + curve = cv2.approxPolyDP( + as_int32_array(contour), + 0.01 * cv2.arcLength(as_int32_array(contour), True), + True, + ) + if minimum_area < cv2.contourArea( + curve + ) < maximum_area and is_convex_quadrilateral(curve): + squares.append(largest_convex_quadrilateral(curve)[0]) + squareness = cv2.matchShapes( + squares[-1], square, cv2.CONTOURS_MATCH_I2, 0.0 + ) + area = cv2.contourArea(squares[-1]) + aspect_ratio = ( + float(cv2.boundingRect(squares[-1])[2]) + / cv2.boundingRect(squares[-1])[3] + ) + orientation = cv2.minAreaRect(squares[-1])[-1] + features.append([squareness, area, aspect_ratio, orientation]) + + if squares: + features = np.array(features) + features = (features - np.mean(features, axis=0)) / np.std(features, axis=0) + clustering = DBSCAN().fit(features) + mask = clustering.labels_ != -1 + squares = np.array(squares)[mask] + + return squares # pyright: ignore + + +def cluster_swatches( + image: NDArrayFloat, swatches: NDArrayInt, swatch_contour_scale: float +) -> NDArrayInt: + """ + Determine the clusters of swatches by expanding the swatches and + fitting rectangles to overlapping swatches. + + Parameters + ---------- + image + The image containing the swatches. Only used for its shape. + swatches + The swatches to cluster. + swatch_contour_scale + The scale by which to expand the swatches. + + Returns + ------- + NDArrayInt + The clusters of swatches. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import cluster_swatches + >>> image = np.zeros((600, 900, 3)) + >>> swatches = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 200], [300, 200]], + ... ] + ... ) + >>> cluster_swatches(image, swatches, 1.5) + array([[[275, 75], + [425, 75], + [425, 225], + [275, 225]], + + [[ 75, 75], + [225, 75], + [225, 225], + [ 75, 225]]]) + """ + scaled_swatches = [ + scale_contour(swatch, swatch_contour_scale) for swatch in swatches + ] + image_c = np.zeros(image.shape, dtype=np.uint8) + cv2.drawContours( + image_c, + as_int32_array(scaled_swatches), # pyright: ignore + -1, + [255] * 3, + -1, + ) + image_c = cv2.cvtColor(image_c, cv2.COLOR_RGB2GRAY) + + contours, _hierarchy = cv2.findContours( + image_c, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE + ) + clusters = as_int32_array( + [cv2.boxPoints(cv2.minAreaRect(contour)) for contour in contours] + ) + return clusters + + +def filter_clusters( + clusters: NDArrayInt, aspect_ratio_minimum: float, aspect_ratio_maximum: float +) -> NDArrayInt: + """ + Filter the clusters by the expected aspect ratio. + + Parameters + ---------- + clusters + The clusters to filter. + aspect_ratio_minimum + The minimum aspect ratio. + aspect_ratio_maximum + The maximum aspect ratio. + + Returns + ------- + NDArrayInt + The filtered clusters. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import filter_clusters + >>> clusters = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 300], [300, 300]], + ... ] + ... ) + >>> filter_clusters(clusters, 0.9, 1.1) + array([[[100, 100], + [200, 100], + [200, 200], + [100, 200]]]) + """ + 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 aspect_ratio_minimum < ratio < aspect_ratio_maximum: + filtered_clusters.append(as_int32_array(cluster)) + return as_int32_array(filtered_clusters) + + +def filter_clusters_by_swatches( + clusters: NDArrayInt, + swatches: NDArrayInt, + swatches_count_minimum: int, + swatches_count_maximum: int, +) -> NDArrayInt: + """ + Filter the clusters by the number of swatches they contain. + + Parameters + ---------- + clusters + The clusters to filter. + swatches + The swatches to filter by. + swatches_count_minimum + The minimum number of swatches. + swatches_count_maximum + The maximum number of swatches. + + Returns + ------- + NDArrayInt + The filtered clusters. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import filter_clusters_by_swatches + >>> clusters = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 300], [300, 300]], + ... ] + ... ) + >>> swatches = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... ] + ... ) + >>> filter_clusters_by_swatches(clusters, swatches, 1, 4) + array([[[100, 100], + [200, 100], + [200, 200], + [100, 200]]]) + """ + 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_int32_array(counts) >= swatches_count_minimum, + as_int32_array(counts) <= swatches_count_maximum, + ) + )[0] + + rectangles = clusters[indexes] + return rectangles + + +def group_swatches( + clusters: NDArrayInt, swatches: NDArrayInt, template: Template +) -> NDArrayInt: + """ + Transform the swatches into centroids and groups the swatches by cluster. + Also, removes clusters that do not contain the expected number of swatches. + + Parameters + ---------- + clusters + The clusters to group the swatches by. + swatches + The swatches to group. + template + The template that contains the expected number of swatches + + Returns + ------- + NDArrayInt + The clustered swatch centroids. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import group_swatches + >>> from colour_checker_detection.detection.templates.generate_template import ( + ... Template, + ... ) + >>> template = Template(None, None, None, None, None) + >>> template.swatch_centroids = np.array( + ... [ + ... [150, 150], + ... [300, 100], + ... ] + ... ) + >>> clusters = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 300], [300, 300]], + ... ] + ... ) + >>> swatches = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... ] + ... ) + >>> group_swatches(clusters, swatches, template) + array([[[150, 150], + [150, 150]]]) + """ + + clustered_centroids = [] + for cluster in clusters: + centroids_in_cluster = [] + for swatch in swatches: + centroid = contour_centroid(swatch) + if cv2.pointPolygonTest(cluster, centroid, False) == 1: + centroids_in_cluster.append(centroid) + clustered_centroids.append(np.array(centroids_in_cluster)) + + nr_expected_swatches = len(template.swatch_centroids) + clustered_centroids = as_int32_array( + [ + as_int32_array(centroids) + for centroids in clustered_centroids + if nr_expected_swatches / 3 <= len(centroids) <= nr_expected_swatches + ] + ) + return clustered_centroids + + +def order_centroids(clustered_centroids: NDArrayInt) -> NDArrayInt: + """ + Determine the outermost points of the clusters to use as starting + points for the transformation. + + Parameters + ---------- + clustered_centroids + The centroids of all swatches grouped by cluster. + + Returns + ------- + NDArrayInt + The starting points for the transformation. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import order_centroids + >>> clustered_centroids = np.array( + ... [ + ... [[200, 100], [100, 100], [200, 200], [100, 200]], + ... ] + ... ) + >>> order_centroids(clustered_centroids) + array([[[100, 100], + [200, 100], + [200, 200], + [100, 200]]]) + """ + starting_pts = [] + for centroids_in_cluster in clustered_centroids: + cluster_centroid = np.mean(centroids_in_cluster, axis=0) + + distances = np.zeros(len(centroids_in_cluster)) + angles = np.zeros(len(centroids_in_cluster)) + for i, centroid in enumerate(centroids_in_cluster): + distances[i] = np.linalg.norm(centroid - cluster_centroid) + angles[i] = np.arctan2( + (centroid[1] - cluster_centroid[1]), (centroid[0] - cluster_centroid[0]) + ) + + bins = np.linspace( + np.nextafter(np.float32(-np.pi), -np.pi - 1), + np.nextafter(np.float32(np.pi), np.pi + 1), + num=5, + endpoint=True, + ) + bin_indices = np.digitize(angles, bins) + + cluster_starting_pts = [] + for i in range(1, len(bins)): + bin_mask = bin_indices == i + if np.any(bin_mask): + bin_distances = distances[bin_mask] + max_index = np.argmax(bin_distances) + cluster_starting_pts.append(centroids_in_cluster[bin_mask][max_index]) + else: + cluster_starting_pts = None + break + + starting_pts.append(np.array(cluster_starting_pts)) + return as_int32_array(starting_pts) + + +def determine_best_transformation( + template: Template, + clustered_centroids: NDArrayInt, + starting_pts: NDArrayInt, + greedy_heuristic: float, +) -> NDArrayFloat: + """ + Determine the best transformation to warp the clustered centroids to the template. + This is achieved by brute forcing through possible correspondences and calculating + the distance of the warped points to the template points. Some gains are achieved + by employing a greedy heuristic to stop the search early if a good enough + correspondence is found. + + Parameters + ---------- + template + The template to which we want to transform the clustered centroids + clustered_centroids + The centroids of the clusters that are to be transformed to the template. + starting_pts + The points of the cluster that are used to find initial correspondences + in the template. + greedy_heuristic + The heuristic to stop the search early. + + Returns + ------- + NDArrayFloat + The transformation matrix to warp the clustered centroids to the template. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import determine_best_transformation + >>> from colour_checker_detection.detection.templates.generate_template import ( + ... Template, + ... ) + >>> template = Template(None, None, None, None, None) + >>> template.swatch_centroids = np.array( + ... [ + ... [100, 100], + ... [200, 100], + ... [200, 200], + ... [100, 200], + ... ] + ... ) + >>> template.correspondences = np.array( + ... [ + ... (0, 1, 2, 3), + ... ] + ... ) + >>> clustered_centroids = np.array( + ... [ + ... [[200, 100], [100, 100], [200, 200], [100, 200]], + ... ], + ... dtype=np.float32, + ... ) + >>> starting_pts = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... ], + ... dtype=np.float32, + ... ) + >>> determine_best_transformation(template, clustered_centroids, starting_pts, 10) + array([[ 1, 3.3961e-32, -4.2633e-14], + [ 2.1316e-16, 1, -4.2633e-14], + [ 2.1316e-18, 1.9387e-34, 1]]) + """ + warping_data = [ + WarpingData(cluster_id) for cluster_id in range(len(clustered_centroids)) + ] + for cluster_id, (cluster, cluster_pts) in enumerate( + zip(clustered_centroids, starting_pts) + ): + for correspondence in template.correspondences: + transformation = cv2.getPerspectiveTransform( + cluster_pts.astype(np.float32), + template.swatch_centroids[list(correspondence)].astype(np.float32), + ) + warped_pts = cv2.perspectiveTransform( + cluster[None, :, :].astype(np.float32), transformation + ).reshape(-1, 2) + + cost_matrix = distance_matrix(warped_pts, template.swatch_centroids) + row_id, col_id = linear_sum_assignment(cost_matrix) + cost = np.sum(cost_matrix[row_id, col_id]) / len(cluster) + + if cost < warping_data[cluster_id].cost: + warping_data[cluster_id].cost = cost + warping_data[cluster_id].transformation = transformation + if cost < greedy_heuristic: + break + unique_warping_data = [] + for _ in range(len(clustered_centroids)): + unique_warping_data.append(min(warping_data, key=lambda x: x.cost)) + + transformation = min(unique_warping_data, key=lambda x: x.cost).transformation + return transformation + + +def extract_colours(warped_image: ArrayLike, template: Template) -> NDArrayFloat: + """ + Extract the swatch colours from the warped image utilizing the template centroids. + + Parameters + ---------- + warped_image + The warped image. + template + The template providing the centroids. + + Returns + ------- + NDArrayFloat + The swatch colours. + + Examples + -------- + >>> import os + >>> import numpy as np + >>> from colour_checker_detection.detection.templates.generate_template import ( + ... Template, + ... ) + >>> from colour_checker_detection.detection import extract_colours + >>> template = Template(None, None, None, None, None) + >>> template.swatch_centroids = np.array( + ... [ + ... [100, 100], + ... [200, 100], + ... [200, 200], + ... [100, 200], + ... ] + ... ) + >>> warped_image = np.zeros((600, 900, 3)) + >>> warped_image[100:200, 100:200] = 0.2 + >>> extract_colours(warped_image, template) + array([[ 0.05, 0.05, 0.05], + [ 0.05, 0.05, 0.05], + [ 0.05, 0.05, 0.05], + [ 0.05, 0.05, 0.05]]) + """ + swatch_colours = [] + + for swatch_center in template.swatch_centroids: + swatch_slice = warped_image[ # pyright: ignore + swatch_center[1] - 20 : swatch_center[1] + 20, + swatch_center[0] - 20 : swatch_center[0] + 20, + ] + swatch_colours += [np.mean(swatch_slice, axis=(0, 1)).tolist()] # pyright: ignore + return np.array(swatch_colours) + + +def correct_flipped(swatch_colours: NDArrayFloat) -> NDArrayFloat: + """ + Reorder the swatch colours if the colour checker was flipped. + + Parameters + ---------- + swatch_colours + The swatch colours. + + Returns + ------- + NDArrayFloat + The reordered swatch colours. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import correct_flipped + >>> swatch_colours = np.array( + ... [ + ... [0, 0, 0], + ... [1, 0.5, 0.1], + ... ] + ... ) + >>> correct_flipped(swatch_colours) + array([[ 1, 0.5, 0.1], + [ 0, 0, 0]]) + """ + chromatic_std = np.std(swatch_colours[0]) + achromatic_std = np.std(swatch_colours[-1]) + if chromatic_std < achromatic_std: + usage_warning("Colour checker was seemingly flipped, reversing the samples!") + swatch_colours = swatch_colours[::-1] + return swatch_colours + + +def check_residuals(swatch_colours: NDArrayFloat, template: Template) -> NDArrayFloat: + """ + Check the residuals between the template and the swatch colours. + + Parameters + ---------- + swatch_colours + The swatch colours. + template + The template to compare to. + + Returns + ------- + NDArrayFloat + The swatch colours or none if the residuals are too high. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection.detection import check_residuals + >>> from colour_checker_detection.detection.templates.generate_template import ( + ... Template, + ... ) + >>> template = Template(None, None, None, None, None) + >>> template.colours = np.array( + ... [ + ... [0, 0, 0], + ... [1, 0.5, 0.1], + ... ] + ... ) + >>> swatch_colours = np.array( + ... [ + ... [0, 0, 0], + ... [1, 0.5, 0.1], + ... ] + ... ) + >>> check_residuals(swatch_colours, template) + array([[ 0, 0, 0], + [ 1, 0.5, 0.1]]) + """ + residual = [ + np.abs(r - m) for r, m in zip(template.colours, np.array(swatch_colours)) + ] + if np.max(residual) > 0.5: + usage_warning( + "Colour seems wrong, either calibration is very bad or checker " + "was not detected correctly." + "Make sure the checker is not occluded and try again!" + ) + swatch_colours = np.array([]) + return swatch_colours + + +def plot_contours(image: ArrayLike, contours: NDArrayInt | Tuple[NDArrayInt]): + """ + Plot the image and marks the detected contours + + Parameters + ---------- + image + The image with the colour checker. + contours + The contours to highlight. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection import plot_contours + >>> image = np.zeros((600, 900, 3)) + >>> contours = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 200], [300, 200]], + ... ] + ... ) + >>> plot_contours(image, contours) + """ + image_contours = np.copy(image) + cv2.drawContours( + image_contours, + contours, # pyright: ignore + -1, + (0, 1, 0), + 5, + ) + plot_image( + image_contours, + text_kwargs={"text": "Contours", "color": "Green"}, + ) + + +def plot_swatches_and_clusters( + image: ArrayLike, swatches: NDArrayInt, clusters: NDArrayInt +): + """ + Plot the image and marks the swatches and clusters. + + Parameters + ---------- + image + The image with the colour checker. + swatches + The swatches to display. + clusters + The clusters to display. + + Examples + -------- + >>> import numpy as np + >>> from colour_checker_detection import plot_swatches_and_clusters + >>> image = np.zeros((600, 900, 3)) + >>> swatches = np.array( + ... [ + ... [[100, 100], [200, 100], [200, 200], [100, 200]], + ... [[300, 100], [400, 100], [400, 200], [300, 200]], + ... ] + ... ) + >>> clusters = np.array( + ... [ + ... [[50, 50], [500, 50], [500, 500], [50, 500]], + ... ] + ... ) + >>> plot_swatches_and_clusters(image, swatches, clusters) + """ + image_swatches = np.copy(image) + cv2.drawContours( + image_swatches, + swatches, # pyright: ignore + -1, + (1, 0, 1), + 5, + ) + cv2.drawContours( + image_swatches, + clusters, # pyright: ignore + -1, + (0, 1, 1), + 5, + ) + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding(image_swatches), + text_kwargs={"text": "Swatches & Clusters", "color": "Red"}, + ) + + +def plot_colours( + colour_checkers_data: list[DataDetectionColourChecker], + swatches_vertical: int, + swatches_horizontal: int, +): + """ + Plot the warped image with the swatch colours annotated. + + Parameters + ---------- + colour_checkers_data + The colour checkers data. + swatches_vertical + The number of vertical swatches. + swatches_horizontal + The number of horizontal swatches. + + Examples + -------- + >>> import os + >>> import pickle + >>> import numpy as np + >>> from colour_checker_detection.detection.common import DataDetectionColourChecker + >>> from colour_checker_detection import plot_colours + >>> colour_checkers_data = DataDetectionColourChecker(None, None, None, None) + >>> colour_checkers_data.colour_checker = np.zeros((600, 900, 3)) + >>> colour_checkers_data.colour_checker[100:200, 100:200] = 0.2 + >>> colour_checkers_data.swatch_masks = [ + ... [100, 200, 100, 200], + ... [300, 400, 100, 200], + ... ] + >>> colour_checkers_data.swatch_colours = np.array( + ... [ + ... [0, 0, 0], + ... [1, 0.5, 0.1], + ... ] + ... ) + >>> plot_colours([colour_checkers_data], 2, 1) + """ + 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), + ) + + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding( + np.reshape( + colour_checkers_data[-1].swatch_colours, + [swatches_vertical, swatches_horizontal, 3], + ) + ), + ) + + +def plot_colours_warped( + warped_image: ArrayLike, template: Template, swatch_colours: NDArrayFloat +): + """ + Plot the warped image with the swatch colours annotated. + + Parameters + ---------- + warped_image + The warped image. + template + The template corresponding to the colour checker. + swatch_colours + The swatch colours. + + Examples + -------- + >>> import os + >>> import pickle + >>> import numpy as np + >>> from colour_checker_detection import ( + ... ROOT_DETECTION_TEMPLATES, + ... Template, + ... plot_colours_warped, + ... ) + >>> template = Template( + ... **pickle.load( + ... open( + ... os.path.join(ROOT_DETECTION_TEMPLATES, "template_colour.pkl"), "rb" + ... ) + ... ) + ... ) + >>> warped_image = np.zeros((600, 900, 3)) + >>> swatch_colours = np.array([np.random.rand(3) for _ in range(24)]) + >>> plot_colours_warped(warped_image, template, swatch_colours) + """ + annotated_image = np.copy(warped_image) + + for i, swatch_center in enumerate(template.swatch_centroids): + top_left = (int(swatch_center[0] - 20), int(swatch_center[1] - 20)) + bottom_right = (int(swatch_center[0] + 20), int(swatch_center[1] + 20)) + + cv2.rectangle(annotated_image, top_left, bottom_right, (0, 255, 0), 2) + + swatch_colour = swatch_colours[i] + + if swatch_colour.dtype in (np.float32, np.float64): + swatch_colour = (swatch_colour * 255).astype(np.uint8) + + cv2.putText( + annotated_image, + str(swatch_colour), + top_left, + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (255, 255, 255), + 2, + ) + + plot_image( + CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding(annotated_image), + text_kwargs={"text": "Warped Image", "color": "red"}, + ) + + def segmenter_default( image: ArrayLike, - cctf_encoding: Callable = eotf_inverse_sRGB, - apply_cctf_encoding: bool = True, + cctf_encoding: Callable = eotf_inverse_sRGB, + apply_cctf_encoding: bool = True, + show: bool = False, + additional_data: bool = False, + **kwargs: Any, +) -> DataSegmentationColourCheckers | NDArrayInt: + """ + Detect the colour checker rectangles in given image :math:`image` using + segmentation. + + The process is as follows: + + 1. Input image :math:`image` is converted to a grayscale image + :math:`image_g` and normalised to range [0, 1]. + 2. Image :math:`image_g` is denoised using multiple bilateral filtering + passes into image :math:`image_d.` + 3. Image :math:`image_d` is thresholded into image :math:`image_t`. + 4. Image :math:`image_t` is eroded and dilated to cleanup remaining noise + into image :math:`image_k`. + 5. Contours are detected on image :math:`image_k` + 6. Contours are filtered to only keep squares/swatches above and below + defined surface area. + 7. 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 joined, creating a large rectangular + cluster. Rectangles are fitted to the clusters. + 8. 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. + 9. Clusters with a number of swatches close to the expected one are + kept. + + Parameters + ---------- + image + 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. + 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 + 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. + 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. + greedy_heuristic + The heuristic to stop the search for transformations early, + if warped extractor is used. + 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. + 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_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 present in ``image``. + + Examples + -------- + >>> import os + >>> from colour import read_image + >>> from colour_checker_detection import ROOT_RESOURCES_TESTS, segmenter_default + >>> path = os.path.join( + ... ROOT_RESOURCES_TESTS, + ... "colour_checker_detection", + ... "detection", + ... "IMG_1967.png", + ... ) + >>> image = read_image(path) + >>> segmenter_default(image) # doctest: +ELLIPSIS + array([[[ 358, 691], + [ 373, 219], + [1086, 242], + [1071, 713]]]...) + """ + + settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) + settings.update(**kwargs) + + if apply_cctf_encoding: + image = cctf_encoding(image) + + image = reformat_image(image, settings.working_width, settings.interpolation_method) + + image = cast(NDArrayFloat, image) + + contours, image_k = detect_contours(image, True, **settings) # pyright: ignore + + if show: + plot_image(image_k, text_kwargs={"text": "Segmented Image", "color": "black"}) + plot_contours(image, contours) + + squares = filter_contours( + image, contours, settings.swatches, settings.swatch_minimum_area_factor + ) + + swatches = remove_stacked_contours(squares) + + clusters = cluster_swatches(image, swatches, settings.swatch_contour_scale) + + clusters = filter_clusters( + clusters, settings.aspect_ratio_minimum, settings.aspect_ratio_maximum + ) + + if show: + plot_swatches_and_clusters(image, swatches, clusters) + + rectangles = filter_clusters_by_swatches( + clusters, + swatches, + settings.swatches_count_minimum, + settings.swatches_count_maximum, + ) + + if additional_data: + return DataSegmentationColourCheckers( + rectangles, + clusters, + swatches, + image_k, # pyright: ignore + ) + else: + return rectangles + + +def segmenter_warped( + image: ArrayLike, + cctf_encoding: Callable = eotf_inverse_sRGB, + apply_cctf_encoding: bool = True, + show: bool = False, + additional_data: bool = True, + **kwargs: Any, +) -> DataSegmentationColourCheckers | NDArrayInt: + """ + Detect the colour checker rectangles, clusters and swatches in given image + :math:`image` using segmentation. + + The process is as follows: + 1. Input image :math:`image` is converted to a grayscale image :math:`image_g` + and normalised to range [0, 1]. + 2. Image :math:`image_g` is denoised using multiple bilateral filtering passes + into image :math:`image_d.` + 3. Image :math:`image_d` is thresholded into image :math:`image_t`. + 4. Image :math:`image_t` is eroded and dilated to cleanup remaining noise into + image :math:`image_k`. + 5. Contours are detected on image :math:`image_k` + 6. Contours are filtered to only keep squares/swatches above and below defined + surface area, moreover they have + to resemble a convex quadrilateral. Additionally, squareness, area, aspect + ratio and orientation are used as + features to remove any remaining outlier contours. + 7. Stacked contours are removed. + 8. 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 joined, creating a + large rectangular cluster. Rectangles + are fitted to the clusters. + 9. Clusters with a number of swatches close to the expected one are kept. + + Parameters + ---------- + image + 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. + 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 + 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. + 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. + greedy_heuristic + The heuristic to stop the search for transformations early, if warped extractor + is used. + 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. + 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_detection.DataSegmentationColourCheckers` + or :class:`np.ndarray` + Colour checker rectangles and additional data or colour checker rectangles only. + + Notes + ----- + - Since the warped_extractor does not work of the rectangles, additionaldata is + true by default. + + Examples + -------- + >>> import os + >>> from colour import read_image + >>> from colour_checker_detection import ROOT_RESOURCES_TESTS, segmenter_warped + >>> path = os.path.join( + ... ROOT_RESOURCES_TESTS, + ... "colour_checker_detection", + ... "detection", + ... "IMG_1967.png", + ... ) + >>> image = read_image(path) + >>> segmenter_warped(image) # doctest: +ELLIPSIS + DataSegmentationColourCheckers(rectangles=array([[[ 694, 1364],...) + """ + settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) + settings.update(**kwargs) + + if apply_cctf_encoding: + image = cctf_encoding(image) + + image = cast(NDArrayFloat, image) + + contours, image_k = detect_contours(image, True, **settings) # pyright: ignore + + if show: + plot_image(image_k, text_kwargs={"text": "Segmented Image", "color": "black"}) + plot_contours(image, contours) + + squares = filter_contours_multifeature( + image, contours, settings.swatches, settings.swatch_minimum_area_factor + ) + + swatches = remove_stacked_contours(squares, keep_smallest=False) + + clusters = cluster_swatches(image, swatches, settings.swatch_contour_scale) + + if show: + plot_swatches_and_clusters(image, swatches, clusters) + + rectangles = filter_clusters_by_swatches( + clusters, + swatches, + settings.swatches_count_minimum, + settings.swatches_count_maximum, + ) + + if additional_data: + return DataSegmentationColourCheckers( + rectangles, + clusters, + swatches, + image_k, # pyright: ignore + ) + else: + return rectangles + + +def extractor_default( + image: ArrayLike, + segmentation_colour_checkers_data: DataSegmentationColourCheckers, + samples: int = 32, + cctf_decoding: Callable = eotf_sRGB, + apply_cctf_decoding: bool = False, + show: bool = False, + additional_data: bool = False, + **kwargs: Any, +) -> Tuple[DataDetectionColourChecker | NDArrayFloat, ...]: + """ + Extract the colour checker swatches and colours from given image using the previous + segmentation. + Default extractor expects the colour checker to be facing the camera straight. + + Parameters + ---------- + image + Image to extract the colour checker swatches and colours from. + segmentation_colour_checkers_data + Segmentation colour checkers data from the segmenter. + 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. + 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 + 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. + 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. + greedy_heuristic + The heuristic to stop the search for transformations early, if warped extractor + is used. + 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. + 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`tuple` + Tuple of :class:`DataDetectionColourChecker` class + instances or colour checkers swatches. + + Examples + -------- + >>> import os + >>> from colour import read_image + >>> from colour_checker_detection import ( + ... ROOT_RESOURCES_TESTS, + ... segmenter_default, + ... extractor_default, + ... ) + >>> path = os.path.join( + ... ROOT_RESOURCES_TESTS, + ... "colour_checker_detection", + ... "detection", + ... "IMG_1967.png", + ... ) + >>> image = read_image(path) + >>> segmentation_colour_checkers_data = segmenter_default( + ... image, additional_data=True + ... ) + >>> extractor_default( + ... image, segmentation_colour_checkers_data + ... ) # doctest: +ELLIPSIS + (array([[ 0.36001, 0.22311, 0.11761], + [ 0.62583, 0.39449, 0.24167], + [ 0.33198, 0.316, 0.28867], + [ 0.3046, 0.27332, 0.10487], + [ 0.41751, 0.31914, 0.30789], + [ 0.34866, 0.43935, 0.29126], + [ 0.67984, 0.35237, 0.069972], + [ 0.27119, 0.25353, 0.33079], + [ 0.62092, 0.27034, 0.18653], + [ 0.30716, 0.17979, 0.19182], + [ 0.48547, 0.45856, 0.032949], + [ 0.65077, 0.40023, 0.016077], + [ 0.19286, 0.18585, 0.27459], + [ 0.28055, 0.38513, 0.12244], + [ 0.55454, 0.21436, 0.12549], + [ 0.72069, 0.51494, 0.0054873], + [ 0.57729, 0.25772, 0.26856], + [ 0.17289, 0.31638, 0.29509], + [ 0.73941, 0.60953, 0.43831], + [ 0.62817, 0.5176, 0.37216], + [ 0.51361, 0.42049, 0.29857], + [ 0.36953, 0.30218, 0.20827], + [ 0.26287, 0.21493, 0.14277], + [ 0.16103, 0.13382, 0.080474]], dtype=float32),) + """ + settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) + settings.update(**kwargs) + + if apply_cctf_decoding: + image = cctf_decoding(image) + + image = cast(Union[NDArrayInt, NDArrayFloat], image) + image = reformat_image(image, settings.working_width, settings.interpolation_method) + + working_width = settings.working_width + working_height = int(working_width / settings.aspect_ratio) + + rectangle = as_int32_array( + [ + [working_width, 0], + [working_width, working_height], + [0, working_height], + [0, 0], + ] + ) + + colour_checkers_data = [] + for quadrilateral in segmentation_colour_checkers_data.rectangles: + colour_checkers_data.append( + sample_colour_checker(image, quadrilateral, rectangle, samples, **settings) + ) + + if show: + plot_colours( + colour_checkers_data, + settings.swatches_vertical, + settings.swatches_horizontal, + ) + if additional_data: + return tuple(colour_checkers_data) + else: + return tuple( + colour_checker_data.swatch_colours + for colour_checker_data in colour_checkers_data + ) + + +def extractor_warped( + image: ArrayLike, + segmentation_colour_checkers_data: DataSegmentationColourCheckers, + template: Template, + cctf_decoding: Callable = eotf_sRGB, + apply_cctf_decoding: bool = False, + show: bool = False, additional_data: bool = False, **kwargs: Any, -) -> DataSegmentationColourCheckers | NDArrayInt: +) -> Tuple[DataDetectionColourChecker | NDArrayFloat, ...]: """ - Detect the colour checker rectangles in given image :math:`image` using + Extract the colour checker swatches and colours from given image using the previous segmentation. - - 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` - - 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 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. + This extractor should be used when the colour checker is not facing the camera + straight. + + The process is as follows: + 1. The swatches are converted to centroids and used to filter clusters to only + keep the ones that contain the + expected number of swatches. Moreover, the centroids are grouped by the + clusters. + 2. The centroids are ordered within their group to enforce the same ordering as + the template, which is + important to extract the transformation, since openCV's perspective transform + is not invariant to the + ordering of the points. + 3. The best transformation is determined by finding the transformation that + minimizes the average distance of + the warped points from the reference template points. + 4. The image is warped using the determined transformation. + 5. The colours are extracted from the warped image using a 20x20 pixel window + around the centroids. + 6. The colours are corrected if the chromatic swatches have a lower standard + deviation than the achromatic + swatches. Parameters ---------- image - 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 + Image to extract the colour checker swatches and colours from. + segmentation_colour_checkers_data + Segmentation colour checkers data from the segmenter. + template + Template defining the swatches structure, which is exploited to find the best + correspondences between template + and detected swatches, which yield the optimal transformation. + 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. + show + Whether to show various debug images. additional_data Whether to output additional data. @@ -233,6 +1806,9 @@ def segmenter_default( Number of iterations to use for the erosion / dilation process. convolution_kernel Convolution kernel to use for the erosion / dilation process. + greedy_heuristic + The heuristic to stop the search for transformations early, if warped extractor + is used. interpolation_method Interpolation method used when resizing the images, `cv2.INTER_CUBIC` and `cv2.INTER_LINEAR` methods are recommended. @@ -271,20 +1847,22 @@ def segmenter_default( Returns ------- - :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 present in ``image``. + :class`tuple` + Tuple of :class:`DataDetectionColourChecker` class + instances or colour checkers swatches. Examples -------- >>> import os + >>> import pickle >>> from colour import read_image - >>> from colour_checker_detection import ROOT_RESOURCES_TESTS + >>> from colour_checker_detection import ( + ... ROOT_RESOURCES_TESTS, + ... ROOT_DETECTION_TEMPLATES, + ... Template, + ... segmenter_warped, + ... extractor_warped, + ... ) >>> path = os.path.join( ... ROOT_RESOURCES_TESTS, ... "colour_checker_detection", @@ -292,111 +1870,71 @@ def segmenter_default( ... "IMG_1967.png", ... ) >>> image = read_image(path) - >>> segmenter_default(image) # doctest: +ELLIPSIS - array([[[ 358, 691], - [ 373, 219], - [1086, 242], - [1071, 713]]]...) + >>> template = Template( + ... **pickle.load( + ... open( + ... os.path.join(ROOT_DETECTION_TEMPLATES, "template_colour.pkl"), "rb" + ... ) + ... ) + ... ) + >>> segmentation_colour_checkers_data = segmenter_warped(image) + >>> extractor_warped( + ... image, segmentation_colour_checkers_data, template + ... ) # doctest: +SKIP + (array([ 0.36087, 0.22405, 0.11797]), ... """ - settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) settings.update(**kwargs) - if apply_cctf_encoding: - image = cctf_encoding(image) - - image = reformat_image(image, settings.working_width, settings.interpolation_method) + if apply_cctf_decoding: + image = cctf_decoding(image) - width, height = image.shape[1], image.shape[0] - minimum_area = ( - width * height / settings.swatches / settings.swatch_minimum_area_factor + clustered_centroids = group_swatches( + segmentation_colour_checkers_data.clusters, + segmentation_colour_checkers_data.swatches, + template, ) - maximum_area = width * height / settings.swatches - - contours, image_k = detect_contours(image, True, **settings) # pyright: ignore - - # Filtering squares/swatches contours. - 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))) - ) - # Removing stacked squares. - squares = as_int32_array(remove_stacked_contours(squares)) + starting_pts = order_centroids(clustered_centroids) - # 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), # pyright: ignore - -1, - [255] * 3, - -1, + transformation = determine_best_transformation( + template, clustered_centroids, starting_pts, settings.greedy_heuristic ) - image_c = cv2.cvtColor(image_c, cv2.COLOR_RGB2GRAY) - contours, _hierarchy = cv2.findContours( - image_c, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE - ) - clusters = as_int32_array( - [cv2.boxPoints(cv2.minAreaRect(contour)) for contour in contours] - ) + warped_image = cv2.warpPerspective( + image, transformation, (template.width, template.height) + ) # pyright: ignore - # 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 + swatch_colours = extract_colours(warped_image, template) - if settings.aspect_ratio_minimum < ratio < settings.aspect_ratio_maximum: - filtered_clusters.append(as_int32_array(cluster)) - clusters = as_int32_array(filtered_clusters) + swatch_colours = correct_flipped(swatch_colours) - # 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) + swatch_colours = check_residuals(swatch_colours, template) - indexes = np.where( - np.logical_and( - as_int32_array(counts) >= settings.swatches_count_minimum, - as_int32_array(counts) <= settings.swatches_count_maximum, - ) - )[0] + colour_checkers_data = DataDetectionColourChecker( + swatch_colours, + np.array([]), + warped_image, + segmentation_colour_checkers_data.clusters, + ) - rectangles = clusters[indexes] + if show and swatch_colours is not None: + plot_colours_warped(warped_image, template, swatch_colours) if additional_data: - return DataSegmentationColourCheckers( - rectangles, - clusters, - squares, - image_k, # pyright: ignore - ) + return tuple(colour_checkers_data) else: - return rectangles + return tuple(swatch_colours) 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, + extractor: Callable = extractor_default, + extractor_kwargs: dict | None = None, show: bool = False, additional_data: bool = False, **kwargs: Any, @@ -409,9 +1947,6 @@ def detect_colour_checkers_segmentation( image 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. @@ -423,6 +1958,11 @@ def detect_colour_checkers_segmentation( checker rectangles. segmenter_kwargs Keyword arguments to pass to the ``segmenter``. + extractor + Callable responsible to extract the colour checker swatches and colours from the + image. + extractor_kwargs + Keyword arguments to pass to the ``extractor``. show Whether to show various debug images. additional_data @@ -448,6 +1988,9 @@ def detect_colour_checkers_segmentation( Number of iterations to use for the erosion / dilation process. convolution_kernel Convolution kernel to use for the erosion / dilation process. + greedy_heuristic + The heuristic to stop the search for transformations early, if warped + extractor is used. interpolation_method Interpolation method used when resizing the images, `cv2.INTER_CUBIC` and `cv2.INTER_LINEAR` methods are recommended. @@ -494,7 +2037,10 @@ def detect_colour_checkers_segmentation( -------- >>> import os >>> from colour import read_image - >>> from colour_checker_detection import ROOT_RESOURCES_TESTS + >>> from colour_checker_detection import ( + ... ROOT_RESOURCES_TESTS, + ... detect_colour_checkers_segmentation, + ... ) >>> path = os.path.join( ... ROOT_RESOURCES_TESTS, ... "colour_checker_detection", @@ -532,15 +2078,12 @@ def detect_colour_checkers_segmentation( if segmenter_kwargs is None: segmenter_kwargs = {} + if extractor_kwargs is None: + extractor_kwargs = {} settings = Structure(**SETTINGS_SEGMENTATION_COLORCHECKER_CLASSIC) settings.update(**kwargs) - 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: @@ -556,79 +2099,16 @@ def detect_colour_checkers_segmentation( image = reformat_image(image, settings.working_width, settings.interpolation_method) - 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} + image, additional_data=True, show=show, **{**segmenter_kwargs, **settings} ) - colour_checkers_data = [] - 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), - ) - - plot_image( - CONSTANTS_COLOUR_STYLE.colour.colourspace.cctf_encoding( - np.reshape( - colour_checkers_data[-1].swatch_colours, - [swatches_v, swatches_h, 3], - ) - ), - ) - - 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"}, - ) + colour_checkers_data = extractor( + image, + segmentation_colour_checkers_data, + show=show, + additional_data=additional_data, + **{**extractor_kwargs, **settings}, + ) - if additional_data: - return tuple(colour_checkers_data) - else: - return tuple( - colour_checker_data.swatch_colours - for colour_checker_data in colour_checkers_data - ) + return colour_checkers_data diff --git a/colour_checker_detection/detection/templates/__init__.py b/colour_checker_detection/detection/templates/__init__.py new file mode 100644 index 0000000..02e09f1 --- /dev/null +++ b/colour_checker_detection/detection/templates/__init__.py @@ -0,0 +1,11 @@ +from .generate_template import ( + Template, + are_three_collinear, + generate_template, +) + +__all__ = [ + "Template", + "are_three_collinear", + "generate_template", +] diff --git a/colour_checker_detection/detection/templates/generate_template.py b/colour_checker_detection/detection/templates/generate_template.py new file mode 100644 index 0000000..e94f03a --- /dev/null +++ b/colour_checker_detection/detection/templates/generate_template.py @@ -0,0 +1,154 @@ +""" +Colour Checker Detection - generate_template +======================================= + +Generates a template for a colour checker. + +- :attr:`Template` +- :func:`are_three_collinear` +- :func:`generate_template` + +""" + +import pickle +from dataclasses import dataclass +from itertools import combinations, permutations + +import cv2 +import matplotlib.pyplot as plt +import numpy as np + + +@dataclass +class Template: + """ + Template dataclass. + + Parameters + ---------- + swatch_centroids + Centroids of the swatches. + colours + Colours of the swatches. + correspondences + Possible correspondences between the reference swatches and the detected ones. + width + Width of the template. + height + Height of the template. + """ + + swatch_centroids: np.ndarray + colours: np.ndarray + correspondences: list + width: int + height: int + + +def are_three_collinear(points: np.ndarray) -> bool: + """ + Check if three points are collinear. + + Parameters + ---------- + points + Points to check. + + Returns + ------- + bool + True if the points are collinear, False otherwise. + """ + combined_ranks = 0 + for pts in combinations(points, 3): + matrix = np.column_stack((pts, np.ones(len(pts)))) + combined_ranks += np.linalg.matrix_rank(matrix) + return combined_ranks != 12 + + +def generate_template( + swatch_centroids: np.ndarray, + colours: np.ndarray, + name: str, + width: int, + height: int, + visualize: bool = False, +): + """ + Generate a template. + + Parameters + ---------- + swatch_centroids + Centroids of the swatches. + colours + Colours of the swatches. + name + Name of the template. + width + Width of the template. + height + Height of the template. + visualize + Whether to save visualizations of the template. + + """ + template = Template(swatch_centroids, colours, [], width, height) + + valid_correspondences = [] + for correspondence in permutations(range(len(swatch_centroids)), 4): + points = swatch_centroids[list(correspondence)] + centroid = np.mean(points, axis=0) + angle = np.array( + [np.arctan2((pt[1] - centroid[1]), (pt[0] - centroid[0])) for pt in points] + ) + # Account for the border from pi to -pi + angle = np.append(angle[np.argmin(angle) :], angle[: np.argmin(angle)]) + angle_difference = np.diff(angle) + + if np.all(angle_difference > 0) and are_three_collinear(points): + valid_correspondences.append(correspondence) + + # Sort by area as a means to reach promising combinations earlier + valid_correspondences = sorted( + valid_correspondences, + key=lambda x: cv2.contourArea(template.swatch_centroids[list(x)]), + reverse=True, + ) + template.correspondences = valid_correspondences + + with open(f"template_{name}.pkl", "wb") as f: + pickle.dump(template.__dict__, f) + + if visualize: + template_adjacency_matrix = np.zeros( + (len(swatch_centroids), len(swatch_centroids)) + ) + for i, pt1 in enumerate(swatch_centroids): + for j, pt2 in enumerate(swatch_centroids): + if i != j: + template_adjacency_matrix[i, j] = np.linalg.norm(pt1 - pt2) + else: + template_adjacency_matrix[i, j] = np.inf + + dist = np.max(np.min(template_adjacency_matrix, axis=0)) * 1.2 + template_graph = template_adjacency_matrix < dist + + image = np.zeros((height, width)) + plt.scatter(*swatch_centroids.T, s=15) + for nr, pt in enumerate(swatch_centroids): + plt.annotate(str(nr), pt, fontsize=10, color="white") + + for r, row in enumerate(template_graph): + for c, col in enumerate(row): + if col == 1: + cv2.line( + image, + swatch_centroids[r], + swatch_centroids[c], + 255, + thickness=2, + ) # pyright: ignore + plt.imshow(image, cmap="gray") + + plt.savefig(f"template_{name}.png", bbox_inches="tight") diff --git a/colour_checker_detection/detection/templates/template_colour.pkl b/colour_checker_detection/detection/templates/template_colour.pkl new file mode 100644 index 0000000000000000000000000000000000000000..ef50e44243798a0a573b632f8edafa76c3c2e49f GIT binary patch literal 117781 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]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 E0UDz21ONa4 literal 0 HcmV?d00001 diff --git a/colour_checker_detection/detection/templates/template_colour.png b/colour_checker_detection/detection/templates/template_colour.png new file mode 100644 index 0000000000000000000000000000000000000000..16e6bc87b2560de904cc4d2cb0888de8bf6d3fe9 GIT binary patch literal 16968 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