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..7a24d2d 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]]], dtype=int32) + >>> remove_stacked_contours(contours, False) + array([[[ 0, 0], + [10, 0], + [10, 10], + [ 0, 10]]], dtype=int32) """ 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..b5cbf84 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]]], dtype=int32) + """ + 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]]], dtype=int32) + """ + 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]]], dtype=int32) + """ + 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]]], dtype=int32) + """ + + 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]]], dtype=int32) + """ + 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.00000000e+00, 3.39609879e-32, -4.26325641e-14], + [ 2.13162821e-16, 1.00000000e+00, -4.26325641e-14], + [ 2.13162821e-18, 1.93870456e-34, 1.00000000e+00]]) + """ + 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 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 json + >>> import colour_checker_detection.detection.templates.template_colour + >>> import numpy as np + >>> from colour_checker_detection import ( + ... ROOT_DETECTION_TEMPLATES, + ... Template, + ... plot_colours_warped, + ... ) + >>> template = Template( + ... **json.load( + ... open( + ... os.path.join(ROOT_DETECTION_TEMPLATES, "template_colour.json"), "r" + ... ) + ... ) + ... ) + >>> 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.36000502, 0.22310828, 0.11760838], + [ 0.62583095, 0.39448658, 0.24166538], + [ 0.33197987, 0.31600383, 0.28866863], + [ 0.30460072, 0.27332103, 0.10486546], + [ 0.4175137 , 0.3191403 , 0.30789143], + [ 0.34866208, 0.43934605, 0.29126382], + [ 0.6798398 , 0.35236537, 0.06997224], + [ 0.27118534, 0.25352538, 0.3307873 ], + [ 0.6209186 , 0.27034152, 0.18652563], + [ 0.30716118, 0.1797888 , 0.19181633], + [ 0.48547122, 0.45855856, 0.03294946], + [ 0.6507675 , 0.40023163, 0.01607687], + [ 0.19286261, 0.18585184, 0.27459192], + [ 0.28054565, 0.3851303 , 0.12244403], + [ 0.554543 , 0.21436104, 0.1254918 ], + [ 0.7206889 , 0.51493937, 0.00548728], + [ 0.5772922 , 0.25771797, 0.2685552 ], + [ 0.1728921 , 0.3163792 , 0.2950853 ], + [ 0.7394083 , 0.60953134, 0.43830705], + [ 0.6281669 , 0.5175997 , 0.37215674], + [ 0.51360977, 0.42048815, 0.298571 ], + [ 0.36953208, 0.30218396, 0.20827033], + [ 0.26286718, 0.21493256, 0.14277342], + [ 0.16102536, 0.13381618, 0.08047408]], 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,23 @@ 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 json >>> from colour import read_image - >>> from colour_checker_detection import ROOT_RESOURCES_TESTS + >>> import colour_checker_detection.detection.templates.template_colour + >>> 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 +1871,73 @@ 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( + ... **json.load( + ... open( + ... os.path.join(ROOT_DETECTION_TEMPLATES, "template_colour.json"), "r" + ... ) + ... ) + ... ) + >>> 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) + if apply_cctf_decoding: + image = cctf_decoding(image) - image = reformat_image(image, settings.working_width, settings.interpolation_method) + image = cast(NDArrayFloat, 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 +1950,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 +1961,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 +1991,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 +2040,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 +2081,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 +2102,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..465fc57 --- /dev/null +++ b/colour_checker_detection/detection/templates/generate_template.py @@ -0,0 +1,162 @@ +""" +Colour Checker Detection - generate_template +======================================= + +Generates a template for a colour checker. + +- :attr:`Template` +- :func:`are_three_collinear` +- :func:`generate_template` + +""" + +import json +import os +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(list(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( + os.path.join( + os.path.dirname(os.path.realpath(__file__)), f"template_{name}.json" + ), + "w", + ) as f: + template.swatch_centroids = template.swatch_centroids.tolist() + template.colours = template.colours.tolist() + json.dump(template.__dict__, f, indent=2) + + 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, 255, 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.png b/colour_checker_detection/detection/templates/template_colour.png new file mode 100644 index 0000000..16e6bc8 Binary files /dev/null and b/colour_checker_detection/detection/templates/template_colour.png differ diff --git a/colour_checker_detection/detection/templates/template_colour.py b/colour_checker_detection/detection/templates/template_colour.py new file mode 100644 index 0000000..0a615b4 --- /dev/null +++ b/colour_checker_detection/detection/templates/template_colour.py @@ -0,0 +1,82 @@ +""" +Colour Checker Detection - template_colour +======================================= + +Defines the template for the colour side of the CALIBRITE COLORCHECKER PASSPORT PHOTO 2 + +- :attr:`centroids` +- :attr:`colours` + +""" + + +import numpy as np + +from colour_checker_detection.detection.templates import generate_template + +centroids = np.array( + [ + [51, 56], + [192, 56], + [333, 56], + [474, 56], + [615, 56], + [756, 56], + [51, 205], + [192, 205], + [333, 205], + [474, 205], + [615, 205], + [756, 205], + [51, 354], + [192, 354], + [333, 354], + [474, 354], + [615, 354], + [756, 354], + [51, 503], + [192, 503], + [333, 503], + [474, 503], + [615, 503], + [756, 503], + ], + dtype=int, +) + +colours = np.array( + [ + [0.17355167, 0.07874029, 0.05326058], + [0.55946176, 0.27734355, 0.21194777], + [0.10509124, 0.18955202, 0.32693865], + [0.10506442, 0.15021316, 0.05221047], + [0.22885963, 0.21350031, 0.42346758], + [0.11449231, 0.50663347, 0.41229432], + [0.74499115, 0.20172072, 0.0325174], + [0.0606182, 0.10259253, 0.38373146], + [0.56055825, 0.08072134, 0.11432307], + [0.10983077, 0.04254067, 0.13682661], + [0.32967574, 0.49495612, 0.04886544], + [0.7689789, 0.35655545, 0.02534346], + [0.0225082, 0.04870543, 0.28081679], + [0.0444356, 0.29068277, 0.06458335], + [0.44636923, 0.03676343, 0.0406788], + [0.83803037, 0.57175305, 0.01273052], + [0.52392518, 0.07924915, 0.28656418], + [0.0, 0.23415773, 0.37506175], + [0.87919095, 0.88476747, 0.8349529], + [0.58443959, 0.59212352, 0.58458201], + [0.35767777, 0.36706043, 0.36528718], + [0.19008669, 0.19086038, 0.1898278], + [0.08593528, 0.08873843, 0.08978779], + [0.03135966, 0.03149993, 0.03231098], + ], + dtype=float, +) + +name = "colour" +# scale such that swatches are roughly 100x100 +width = 810 +height = 560 + +generate_template(centroids, colours, name, width, height) diff --git a/colour_checker_detection/detection/templates/template_gray.png b/colour_checker_detection/detection/templates/template_gray.png new file mode 100644 index 0000000..2459fe0 Binary files /dev/null and b/colour_checker_detection/detection/templates/template_gray.png differ diff --git a/colour_checker_detection/detection/templates/template_gray.py b/colour_checker_detection/detection/templates/template_gray.py new file mode 100644 index 0000000..1a465ed --- /dev/null +++ b/colour_checker_detection/detection/templates/template_gray.py @@ -0,0 +1,85 @@ +""" +Colour Checker Detection - template_colour +======================================= + +Defines the template for the gray side of the CALIBRITE COLORCHECKER PASSPORT PHOTO 2 + +- :attr:`centroids` +- :attr:`colours` + +""" + +import numpy as np + +from colour_checker_detection.detection.templates import generate_template + +centroids = np.array( + [ + [46, 50], + [175, 50], + [304, 50], + [433, 50], + [562, 50], + [691, 50], + [820, 50], + [949, 50], + [178, 185], + [338, 185], + [498, 185], + [658, 185], + [818, 185], + [178, 355], + [338, 355], + [498, 355], + [658, 355], + [818, 355], + [46, 490], + [175, 490], + [304, 490], + [433, 490], + [562, 490], + [691, 490], + [820, 490], + [949, 490], + ], + dtype=int, +) + +colours = np.array( + [ + [0.67923814, 0.06980343, 0.07945908], + [1.0, 0.31823821, 0.03654352], + [0.96112153, 0.6609519, 0.01170877], + [0.0, 0.41704516, 0.11468953], + [0.0, 0.36410292, 0.5490582], + [0.01927884, 0.21480208, 0.64918505], + [0.31240024, 0.14104544, 0.41277012], + [0.5978367, 0.07613614, 0.22527837], + [0.58305055, 0.58271855, 0.58193234], + [0.56645735, 0.61388531, 0.61728672], + [0.51232862, 0.60808644, 0.61548368], + [0.4780242, 0.61108785, 0.62536069], + [0.44239077, 0.63319999, 0.65610075], + [0.66795228, 0.55539626, 0.58511335], + [0.6545383, 0.57009509, 0.59099094], + [0.58305055, 0.58271855, 0.58193234], + [0.53814518, 0.59996143, 0.63089161], + [0.49082519, 0.60725164, 0.67226831], + [0.03082092, 0.03117947, 0.03215021], + [0.03593636, 0.03756943, 0.03883438], + [0.04465723, 0.0477725, 0.04860736], + [0.052435, 0.05585386, 0.05590433], + [0.46330457, 0.46845033, 0.46399649], + [0.58285503, 0.58974152, 0.58586303], + [0.72908483, 0.73813111, 0.73902925], + [0.91373491, 0.90723776, 0.87534055], + ], + dtype=float, +) + +name = "gray" +# scale such that swatches are roughly 100x100 +width = 1000 +height = 540 + +generate_template(centroids, colours, name, width, height) diff --git a/colour_checker_detection/scripts/inference.py b/colour_checker_detection/scripts/inference.py index f2a97ad..ee458fd 100755 --- a/colour_checker_detection/scripts/inference.py +++ b/colour_checker_detection/scripts/inference.py @@ -232,4 +232,4 @@ def segmentation( if __name__ == "__main__": logging.basicConfig() - segmentation() # pyright: ignore + segmentation() diff --git a/pyproject.toml b/pyproject.toml index f0cc48c..5cffbbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ numpy = ">= 1.22, < 2" opencv-python = ">= 4, < 5" scipy = ">= 1.8, < 2" typing-extensions = ">= 4, < 5" +scikit-learn = "^1.4.1.post1" [tool.poetry.group.optional.dependencies] matplotlib = ">= 3.5, != 3.5.0, != 3.5.1" diff --git a/requirements.txt b/requirements.txt index 10bcc5a..f4209a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,7 @@ certifi==2023.11.17 ; python_version >= "3.9" and python_version < "3.13" cffi==1.16.0 ; python_version >= "3.9" and python_version < "3.13" cfgv==3.4.0 ; python_version >= "3.9" and python_version < "3.13" charset-normalizer==3.3.2 ; python_version >= "3.9" and python_version < "3.13" +click~=8.1.7 ; python_version >= "3.9" and python_version < "3.13" colorama==0.4.6 ; python_version >= "3.9" and python_version < "3.13" and sys_platform == "win32" colour-science==0.4.4 ; python_version >= "3.9" and python_version < "3.13" comm==0.2.1 ; python_version >= "3.9" and python_version < "3.13" @@ -135,6 +136,7 @@ rfc3986==2.0.0 ; python_version >= "3.9" and python_version < "3.13" rfc3986-validator==0.1.1 ; python_version >= "3.9" and python_version < "3.13" rich==13.7.0 ; python_version >= "3.9" and python_version < "3.13" rpds-py==0.16.2 ; python_version >= "3.9" and python_version < "3.13" +scikit-learn==1.4.0 ; python_version >= "3.9" and python_version < "3.13" scipy==1.11.4 ; python_version >= "3.9" and python_version < "3.13" secretstorage==3.3.3 ; python_version >= "3.9" and python_version < "3.13" and sys_platform == "linux" send2trash==1.8.2 ; python_version >= "3.9" and python_version < "3.13" @@ -161,6 +163,7 @@ traitlets==5.14.1 ; python_version >= "3.9" and python_version < "3.13" twine==4.0.2 ; python_version >= "3.9" and python_version < "3.13" types-python-dateutil==2.8.19.20240106 ; python_version >= "3.9" and python_version < "3.13" typing-extensions==4.9.0 ; python_version >= "3.9" and python_version < "3.13" +ultralytics~=8.1.24 ; python_version >= "3.9" and python_version < "3.13" uri-template==1.3.0 ; python_version >= "3.9" and python_version < "3.13" urllib3==2.1.0 ; python_version >= "3.9" and python_version < "3.13" virtualenv==20.25.0 ; python_version >= "3.9" and python_version < "3.13"