|
21 | 21 |
|
22 | 22 | import typing |
23 | 23 | from dataclasses import dataclass |
| 24 | +from itertools import combinations |
24 | 25 |
|
25 | 26 | import cv2 |
26 | 27 | import numpy as np |
|
35 | 36 | DTypeFloat, |
36 | 37 | DTypeInt, |
37 | 38 | Literal, |
38 | | - NDArrayReal, |
39 | 39 | Type, |
40 | 40 | ) |
41 | 41 |
|
|
75 | 75 | "reformat_image", |
76 | 76 | "transform_image", |
77 | 77 | "detect_contours", |
| 78 | + "is_quadrilateral", |
78 | 79 | "is_square", |
79 | 80 | "contour_centroid", |
80 | 81 | "scale_contour", |
| 82 | + "cluster_swatches", |
| 83 | + "filter_clusters", |
81 | 84 | "approximate_contour", |
82 | 85 | "quadrilateralise_contours", |
83 | 86 | "remove_stacked_contours", |
84 | 87 | "DataDetectionColourChecker", |
| 88 | + "DataSegmentationColourCheckers", |
85 | 89 | "sample_colour_checker", |
86 | 90 | ] |
87 | 91 |
|
@@ -664,6 +668,44 @@ def detect_contours( |
664 | 668 | return contours |
665 | 669 |
|
666 | 670 |
|
| 671 | +def is_quadrilateral(points: NDArrayFloat) -> bool: |
| 672 | + """ |
| 673 | + Check if points form a quadrilateral (no three points are collinear). |
| 674 | +
|
| 675 | + Parameters |
| 676 | + ---------- |
| 677 | + points |
| 678 | + Points to check (should be 4 points). |
| 679 | +
|
| 680 | + Returns |
| 681 | + ------- |
| 682 | + :class:`bool` |
| 683 | + True if points form a quadrilateral (no three collinear), False otherwise. |
| 684 | +
|
| 685 | + Notes |
| 686 | + ----- |
| 687 | + This function checks that no three points are collinear, which ensures |
| 688 | + the 4 points form a proper quadrilateral suitable for perspective transformation. |
| 689 | +
|
| 690 | + Examples |
| 691 | + -------- |
| 692 | + >>> points = np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=float) |
| 693 | + >>> is_quadrilateral(points) |
| 694 | + True |
| 695 | + >>> points = np.array([[0, 0], [5, 0], [10, 0], [0, 10]], dtype=float) |
| 696 | + >>> is_quadrilateral(points) # Three points collinear |
| 697 | + False |
| 698 | + """ |
| 699 | + |
| 700 | + for pts in combinations(points, 3): |
| 701 | + matrix = np.column_stack((pts, np.ones(len(pts)))) |
| 702 | + |
| 703 | + if np.linalg.matrix_rank(matrix) < 3: |
| 704 | + return False |
| 705 | + |
| 706 | + return True |
| 707 | + |
| 708 | + |
667 | 709 | def is_square(contour: ArrayLike, tolerance: float = 0.015) -> bool: |
668 | 710 | """ |
669 | 711 | Return if specified contour is a square. |
@@ -775,6 +817,140 @@ def scale_contour(contour: ArrayLike, factor: ArrayLike) -> NDArrayFloat: |
775 | 817 | return (contour - centroid) * factor + centroid |
776 | 818 |
|
777 | 819 |
|
| 820 | +def cluster_swatches( |
| 821 | + image: NDArrayFloat, swatches: NDArrayInt, swatch_contour_scale: float |
| 822 | +) -> NDArrayInt: |
| 823 | + """ |
| 824 | + Cluster swatches by expanding them and fitting rectangles to overlapping areas. |
| 825 | +
|
| 826 | + Parameters |
| 827 | + ---------- |
| 828 | + image |
| 829 | + Image containing the swatches. Only used for its shape. |
| 830 | + swatches |
| 831 | + The swatches to cluster. |
| 832 | + swatch_contour_scale |
| 833 | + The scale by which to expand the swatches. |
| 834 | +
|
| 835 | + Returns |
| 836 | + ------- |
| 837 | + :class:`NDArrayInt` |
| 838 | + The clusters of swatches. |
| 839 | +
|
| 840 | + Examples |
| 841 | + -------- |
| 842 | + >>> import numpy as np |
| 843 | + >>> image = np.zeros((600, 900, 3)) |
| 844 | + >>> swatches = np.array( |
| 845 | + ... [ |
| 846 | + ... [[100, 100], [200, 100], [200, 200], [100, 200]], |
| 847 | + ... [[300, 100], [400, 100], [400, 200], [300, 200]], |
| 848 | + ... ], |
| 849 | + ... dtype=np.int32, |
| 850 | + ... ) |
| 851 | + >>> cluster_swatches(image, swatches, 1.5) |
| 852 | + array([[[275, 75], |
| 853 | + [425, 75], |
| 854 | + [425, 225], |
| 855 | + [275, 225]], |
| 856 | + <BLANKLINE> |
| 857 | + [[ 75, 75], |
| 858 | + [225, 75], |
| 859 | + [225, 225], |
| 860 | + [ 75, 225]]], dtype=int32) |
| 861 | + """ |
| 862 | + |
| 863 | + scaled_swatches = [ |
| 864 | + scale_contour(swatch, swatch_contour_scale) for swatch in swatches |
| 865 | + ] |
| 866 | + image_c = np.zeros(image.shape[:2], dtype=np.uint8) |
| 867 | + |
| 868 | + cv2.drawContours( |
| 869 | + image_c, [as_int32_array(s) for s in scaled_swatches], -1, (255,), -1 |
| 870 | + ) |
| 871 | + |
| 872 | + contours, _ = cv2.findContours(image_c, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE) |
| 873 | + |
| 874 | + return as_int32_array( |
| 875 | + [cv2.boxPoints(cv2.minAreaRect(contour)) for contour in contours] |
| 876 | + ) |
| 877 | + |
| 878 | + |
| 879 | +def filter_clusters( |
| 880 | + clusters: NDArrayInt, |
| 881 | + swatches: NDArrayInt, |
| 882 | + swatches_count_minimum: int, |
| 883 | + swatches_count_maximum: int, |
| 884 | +) -> NDArrayInt: |
| 885 | + """ |
| 886 | + Filter clusters by the number of swatches they contain. |
| 887 | +
|
| 888 | + Parameters |
| 889 | + ---------- |
| 890 | + clusters |
| 891 | + The clusters to filter. |
| 892 | + swatches |
| 893 | + The swatches to count within each cluster. |
| 894 | + swatches_count_minimum |
| 895 | + Minimum number of swatches required in a cluster. |
| 896 | + swatches_count_maximum |
| 897 | + Maximum number of swatches allowed in a cluster. |
| 898 | +
|
| 899 | + Returns |
| 900 | + ------- |
| 901 | + :class:`NDArrayInt` |
| 902 | + The filtered clusters that contain the expected number of swatches. |
| 903 | +
|
| 904 | + Examples |
| 905 | + -------- |
| 906 | + >>> import numpy as np |
| 907 | + >>> clusters = np.array( |
| 908 | + ... [ |
| 909 | + ... [[0, 0], [200, 0], [200, 200], [0, 200]], |
| 910 | + ... [[300, 300], [400, 300], [400, 400], [300, 400]], |
| 911 | + ... ], |
| 912 | + ... dtype=np.int32, |
| 913 | + ... ) |
| 914 | + >>> swatches = np.array( |
| 915 | + ... [ |
| 916 | + ... [[50, 50], [100, 50], [100, 100], [50, 100]], |
| 917 | + ... [[350, 350], [380, 350], [380, 380], [350, 380]], |
| 918 | + ... ], |
| 919 | + ... dtype=np.int32, |
| 920 | + ... ) |
| 921 | + >>> filter_clusters(clusters, swatches, 1, 2) |
| 922 | + array([[[ 0, 0], |
| 923 | + [200, 0], |
| 924 | + [200, 200], |
| 925 | + [ 0, 200]], |
| 926 | + <BLANKLINE> |
| 927 | + [[300, 300], |
| 928 | + [400, 300], |
| 929 | + [400, 400], |
| 930 | + [300, 400]]], dtype=int32) |
| 931 | + """ |
| 932 | + |
| 933 | + if len(clusters) == 0 or len(swatches) == 0: |
| 934 | + return as_int32_array([]).reshape(0, 4, 2) |
| 935 | + |
| 936 | + filtered_clusters = [] |
| 937 | + for cluster in clusters: |
| 938 | + count = 0 |
| 939 | + for swatch in swatches: |
| 940 | + centroid = contour_centroid(swatch) |
| 941 | + if cv2.pointPolygonTest(cluster, centroid, False) >= 0: |
| 942 | + count += 1 |
| 943 | + |
| 944 | + if swatches_count_minimum <= count <= swatches_count_maximum: |
| 945 | + filtered_clusters.append(cluster) |
| 946 | + |
| 947 | + return ( |
| 948 | + as_int32_array(filtered_clusters) |
| 949 | + if len(filtered_clusters) > 0 |
| 950 | + else as_int32_array([]).reshape(0, 4, 2) |
| 951 | + ) |
| 952 | + |
| 953 | + |
778 | 954 | def approximate_contour( |
779 | 955 | contour: ArrayLike, points: int = 4, iterations: int = 100 |
780 | 956 | ) -> NDArrayInt: |
@@ -991,6 +1167,31 @@ class DataDetectionColourChecker(MixinDataclassIterable): |
991 | 1167 | quadrilateral: NDArrayFloat |
992 | 1168 |
|
993 | 1169 |
|
| 1170 | +@dataclass |
| 1171 | +class DataSegmentationColourCheckers(MixinDataclassIterable): |
| 1172 | + """ |
| 1173 | + Colour checkers detection data used for plotting, debugging and further |
| 1174 | + analysis. |
| 1175 | +
|
| 1176 | + Parameters |
| 1177 | + ---------- |
| 1178 | + rectangles |
| 1179 | + Colour checker bounding boxes, i.e., the clusters that have the |
| 1180 | + relevant count of swatches. |
| 1181 | + clusters |
| 1182 | + Detected swatches clusters. |
| 1183 | + swatches |
| 1184 | + Detected swatches. |
| 1185 | + segmented_image |
| 1186 | + Segmented image. |
| 1187 | + """ |
| 1188 | + |
| 1189 | + rectangles: NDArrayInt |
| 1190 | + clusters: NDArrayInt |
| 1191 | + swatches: NDArrayInt |
| 1192 | + segmented_image: NDArrayFloat |
| 1193 | + |
| 1194 | + |
994 | 1195 | def sample_colour_checker( |
995 | 1196 | image: ArrayLike, |
996 | 1197 | quadrilateral: ArrayLike, |
|
0 commit comments