Perception 0.8.3__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. perception/__init__.py +14 -0
  2. perception/approximate_deduplication/__init__.py +301 -0
  3. perception/approximate_deduplication/debug.py +239 -0
  4. perception/approximate_deduplication/index.py +433 -0
  5. perception/approximate_deduplication/serve.py +151 -0
  6. perception/benchmarking/__init__.py +23 -0
  7. perception/benchmarking/common.py +653 -0
  8. perception/benchmarking/extensions.c +31202 -0
  9. perception/benchmarking/extensions.cp313-win_amd64.pyd +0 -0
  10. perception/benchmarking/extensions.pyx +112 -0
  11. perception/benchmarking/image.py +204 -0
  12. perception/benchmarking/image_transforms.py +42 -0
  13. perception/benchmarking/video.py +224 -0
  14. perception/benchmarking/video_transforms.py +198 -0
  15. perception/extensions.cp313-win_amd64.pyd +0 -0
  16. perception/extensions.cpp +33687 -0
  17. perception/extensions.pyx +305 -0
  18. perception/hashers/__init__.py +33 -0
  19. perception/hashers/hasher.py +386 -0
  20. perception/hashers/image/__init__.py +17 -0
  21. perception/hashers/image/average.py +35 -0
  22. perception/hashers/image/dhash.py +30 -0
  23. perception/hashers/image/opencv.py +63 -0
  24. perception/hashers/image/pdq.py +34 -0
  25. perception/hashers/image/phash.py +109 -0
  26. perception/hashers/image/wavelet.py +59 -0
  27. perception/hashers/tools.py +1178 -0
  28. perception/hashers/video/__init__.py +4 -0
  29. perception/hashers/video/framewise.py +102 -0
  30. perception/hashers/video/tmk.py +219 -0
  31. perception/local_descriptor_deduplication.py +708 -0
  32. perception/py.typed +0 -0
  33. perception/testing/__init__.py +245 -0
  34. perception/testing/images/README.md +13 -0
  35. perception/testing/images/image1.jpg +0 -0
  36. perception/testing/images/image10.jpg +0 -0
  37. perception/testing/images/image2.jpg +0 -0
  38. perception/testing/images/image3.jpg +0 -0
  39. perception/testing/images/image4.jpg +0 -0
  40. perception/testing/images/image5.jpg +0 -0
  41. perception/testing/images/image6.jpg +0 -0
  42. perception/testing/images/image7.jpg +0 -0
  43. perception/testing/images/image8.jpg +0 -0
  44. perception/testing/images/image9.jpg +0 -0
  45. perception/testing/logos/README.md +4 -0
  46. perception/testing/logos/logoipsum.png +0 -0
  47. perception/testing/videos/README.md +6 -0
  48. perception/testing/videos/expected_tmk.json.gz +0 -0
  49. perception/testing/videos/extra_channel_attached_pic.mp4 +0 -0
  50. perception/testing/videos/extra_channel_attached_pic_audio.mp4 +0 -0
  51. perception/testing/videos/rgb.m4v +0 -0
  52. perception/testing/videos/v1.m4v +0 -0
  53. perception/testing/videos/v2.m4v +0 -0
  54. perception/testing/videos/v2s.mov +0 -0
  55. perception/tools.py +379 -0
  56. perception/utils.py +2 -0
  57. perception-0.8.3.dist-info/DELVEWHEEL +1 -0
  58. perception-0.8.3.dist-info/METADATA +115 -0
  59. perception-0.8.3.dist-info/RECORD +62 -0
  60. perception-0.8.3.dist-info/WHEEL +4 -0
  61. perception-0.8.3.dist-info/licenses/LICENSE +191 -0
  62. perception.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
perception/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """""" # start delvewheel patch
2
+ def _delvewheel_patch_1_12_0():
3
+ import os
4
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'perception.libs'))):
5
+ os.add_dll_directory(libs_dir)
6
+
7
+
8
+ _delvewheel_patch_1_12_0()
9
+ del _delvewheel_patch_1_12_0
10
+ # end delvewheel patch
11
+
12
+ from importlib import metadata
13
+
14
+ __version__ = metadata.version("perception")
@@ -0,0 +1,301 @@
1
+ import logging
2
+ import math
3
+ import os.path as op
4
+ import typing
5
+
6
+ import faiss
7
+ import networkit as nk
8
+ import numpy as np
9
+ import tqdm
10
+ import typing_extensions
11
+
12
+ LOGGER = logging.getLogger(__name__)
13
+ DEFAULT_PCT_PROBE = 0
14
+
15
+
16
+ # For faiss training on datasets larger than 50,000 vectors, we take a random sub-sample.
17
+ TRAIN_LARGE_SIZE: int = 50_000
18
+
19
+
20
+ class ClusterAssignment(typing_extensions.TypedDict):
21
+ cluster: int
22
+ id: typing.Any
23
+
24
+
25
+ def build_index(
26
+ X: np.ndarray,
27
+ pct_probe: float = DEFAULT_PCT_PROBE,
28
+ approximate: bool = True,
29
+ use_gpu: bool = True,
30
+ ):
31
+ """Buid a FAISS index from a reference dataframe.
32
+
33
+ Args:
34
+ X: The vectors to add to the index.
35
+ pct_probe: The minimum fraction of nearest lists to search. If
36
+ the product of pct_probe and the number of lists is less
37
+ than 1, one list will be searched.
38
+ approximate: Whether to build an approximate or exact index.
39
+
40
+ Returns:
41
+ An (index, lookup) tuple where the lookup returns the filepath
42
+ for a given entry in the index.
43
+ """
44
+ if X is None:
45
+ return None
46
+ X = X.astype("float32")
47
+ d = X.shape[1]
48
+ if approximate:
49
+ ntotal = X.shape[0]
50
+ nlist = int(max(min(4 * np.sqrt(ntotal), ntotal / 39), 1))
51
+ quantizer = faiss.IndexFlatL2(d)
52
+ index = faiss.IndexIVFFlat(quantizer, d, nlist)
53
+ gpu = False
54
+ if use_gpu:
55
+ try:
56
+ res = faiss.StandardGpuResources()
57
+ index = faiss.index_cpu_to_gpu(res, 0, index)
58
+ gpu = True
59
+ except AttributeError:
60
+ LOGGER.info("Building approximate FAISS index on CPU.")
61
+
62
+ if X.shape[0] > TRAIN_LARGE_SIZE:
63
+ # Take random sample of 50,000 or 39 points per centroid.
64
+ # 39 points per centroid is the min for for not getting warnings.
65
+ # https://github.com/facebookresearch/faiss/wiki/FAQ#can-i-ignore-warning-clustering-xxx-points-to-yyy-centroids
66
+ sample_size = max(39 * nlist, TRAIN_LARGE_SIZE)
67
+ index.train(X[np.random.choice(X.shape[0], sample_size, replace=False)])
68
+ else:
69
+ index.train(X)
70
+
71
+ batch_size = 10_000
72
+ for i in range(0, X.shape[0], batch_size):
73
+ index.add(X[i : i + batch_size])
74
+ if gpu:
75
+ index = faiss.index_gpu_to_cpu(index)
76
+ nprobe = max(math.ceil(pct_probe * nlist), 1)
77
+ faiss.ParameterSpace().set_index_parameter(index, "nprobe", nprobe)
78
+ else:
79
+ index = faiss.IndexFlat(d)
80
+ index.add(X)
81
+ return index
82
+
83
+
84
+ def compute_euclidean_pairwise_duplicates_approx(
85
+ X,
86
+ counts,
87
+ threshold,
88
+ minimum_overlap,
89
+ Y=None,
90
+ y_counts=None,
91
+ pct_probe=0.1,
92
+ use_gpu: bool = True,
93
+ faiss_cache_path: str | None = None,
94
+ show_progress: bool = False,
95
+ ):
96
+ """Provides the same result as perception.extensions.compute_pairwise_duplicates_simple
97
+ but uses an approximate search instead of an exhaustive search, which can dramatically reduce
98
+ processing time.
99
+
100
+ Args:
101
+ X: An array of vectors to compute pairs for.
102
+ Y: if provided we search in X for Y vectors.
103
+ counts: A list of counts of vectors for separate files in the
104
+ in the vectors (should add up to the length of X)
105
+ threshold: The threshold for a match as a euclidean distance.
106
+ minimum_overlap: The minimum overlap between two files to qualify as a match.
107
+ pct_probe: The minimum percentage of sublists to search for matches. The larger the
108
+ value, the more exhaustive the search.
109
+ faiss_cache_path: If provided load any existing faiss index from this path, and if
110
+ it does not exist then save the generated faiss index to the path.
111
+ show_progress: Whether or not to show a progress bar while computing pairs
112
+ Returns:
113
+ A list of pairs of matching file indexes.
114
+ """
115
+ assert (
116
+ counts.sum() == X.shape[0]
117
+ ), "Length of counts incompatible with vectors shape."
118
+ assert (Y is None) == (
119
+ y_counts is None
120
+ ), "Must provide both or neither for y, y_counts."
121
+ if X.dtype != "float32":
122
+ # Only make the copy if we have to.
123
+ X = X.astype("float32")
124
+
125
+ if Y is not None and Y.dtype != "float32":
126
+ # Only make the copy if we have to.
127
+ Y = Y.astype("float32")
128
+
129
+ lookup_ = []
130
+ for idx, count in enumerate(counts):
131
+ lookup_.extend([idx] * count)
132
+ lookup = np.array(lookup_)
133
+
134
+ if faiss_cache_path is not None and op.exists(faiss_cache_path):
135
+ LOGGER.debug("Loading cached FAISS index from %s", faiss_cache_path)
136
+ index = faiss.read_index(faiss_cache_path)
137
+ assert (
138
+ X.shape[0] == index.ntotal
139
+ ), "Cached FAISS index does not match provided X."
140
+ else:
141
+ LOGGER.debug("Building FAISS index.")
142
+ index = build_index(X=X, pct_probe=pct_probe, approximate=True, use_gpu=use_gpu)
143
+ if faiss_cache_path is not None:
144
+ faiss.write_index(index, faiss_cache_path)
145
+
146
+ LOGGER.debug("FAISS index ready, start aprox search")
147
+ pairs = []
148
+
149
+ # Only use y_counts if present.
150
+ if y_counts is None:
151
+ iterator_counts = counts
152
+ M = X
153
+ else:
154
+ iterator_counts = y_counts
155
+ M = Y
156
+
157
+ for end, length, query in tqdm.tqdm(
158
+ zip(iterator_counts.cumsum(), iterator_counts, range(len(iterator_counts))),
159
+ total=len(iterator_counts),
160
+ disable=not show_progress,
161
+ desc="Vectors",
162
+ ):
163
+ if length == 0:
164
+ continue
165
+ Xq = M[end - length : end]
166
+ lims, _, idxs = index.range_search(Xq, threshold**2)
167
+ lims = lims.astype("int32")
168
+ matched = [
169
+ match
170
+ for match in np.unique(lookup[list(set(idxs))]) # type: ignore
171
+ if match != query
172
+ or Y is not None # Protect self matches if Y is not present.
173
+ ]
174
+ query_in_match: typing.Mapping[int, set] = {m: set() for m in matched}
175
+ match_in_query: typing.Mapping[int, set] = {m: set() for m in matched}
176
+ for query_idx in range(length):
177
+ for match_idx in idxs[lims[query_idx] : lims[query_idx + 1]]:
178
+ match = lookup[match_idx]
179
+ if (
180
+ match == query and Y is None
181
+ ): # Protect self matches if Y is not present.
182
+ continue
183
+ match_in_query[match].add(match_idx)
184
+ query_in_match[match].add(query_idx)
185
+ for match in matched:
186
+ overlap = min(
187
+ [
188
+ len(query_in_match[match]) / length,
189
+ len(match_in_query[match]) / counts[match],
190
+ ]
191
+ )
192
+ if overlap >= minimum_overlap and overlap > 0:
193
+ if Y is None:
194
+ pairs.append(tuple(sorted([query, match])))
195
+ else:
196
+ pairs.append(tuple([query, match]))
197
+ return list(set(pairs))
198
+
199
+
200
+ def pairs_to_clusters(
201
+ ids: typing.Iterable[str],
202
+ pairs: typing.Iterable[tuple[str, str]],
203
+ strictness: typing_extensions.Literal[
204
+ "clique", "community", "component"
205
+ ] = "clique",
206
+ max_clique_batch_size: int = 1000,
207
+ ) -> list[ClusterAssignment]:
208
+ """Given a list of pairs of matching files, compute sets
209
+ of cliques where all files in a clique are connected.
210
+ Args:
211
+ ids: A list of node ids (e.g., filepaths).
212
+ pairs: A list of pairs of node ids, each pair is assumed to have an edge
213
+ strictness: The level at which groups will be clustered. "component"
214
+ means that all clusters will be connected components. "community"
215
+ will select clusters of files within components that are clustered
216
+ together. "clique" will result in clusters where every file is
217
+ connected to every other file.
218
+ max_clique_batch_size: The maximum batch size for identifying
219
+ cliques.
220
+ Returns:
221
+ A list of cluster assignments (dicts with id and cluster
222
+ entries).
223
+ """
224
+ assert strictness in ["component", "community", "clique"], "Invalid strictness."
225
+ list_ids = list(ids)
226
+ id_to_node_map = {v: i for i, v in enumerate(list_ids)}
227
+ node_to_id_map = {v: k for k, v in id_to_node_map.items()}
228
+
229
+ LOGGER.debug("Building graph.")
230
+ graph = nk.Graph(len(list_ids))
231
+ node_pairs = {(id_to_node_map[pair[0]], id_to_node_map[pair[1]]) for pair in pairs}
232
+ for node_pair in node_pairs:
233
+ graph.addEdge(node_pair[0], node_pair[1])
234
+
235
+ assignments: list[ClusterAssignment] = []
236
+ cluster_index = 0
237
+ cc_query = nk.components.ConnectedComponents(graph)
238
+ cc_query.run()
239
+ components = cc_query.getComponents()
240
+
241
+ for component in components:
242
+ LOGGER.debug("Got component with size: %s", len(component))
243
+ if strictness == "component":
244
+ assignments.extend(
245
+ [{"id": node_to_id_map[n], "cluster": cluster_index} for n in component]
246
+ )
247
+ cluster_index += 1
248
+ continue
249
+ # Map between node values for a connected component
250
+ component_node_map = dict(enumerate(component))
251
+ cc_sub_graph = nk.graphtools.subgraphFromNodes(graph, component, compact=True)
252
+ algo = nk.community.PLP(cc_sub_graph)
253
+ algo.run()
254
+ communities = algo.getPartition()
255
+ community_map = communities.subsetSizeMap()
256
+ for community, size in community_map.items():
257
+ LOGGER.debug("Got community with size: %s", size)
258
+ community_members = list(
259
+ communities.getMembers(community)
260
+ ) # Need to do this to do batching.
261
+ community_members = [component_node_map[i] for i in community_members]
262
+ if strictness == "community":
263
+ assignments.extend(
264
+ [
265
+ {"id": node_to_id_map[n], "cluster": cluster_index}
266
+ for n in community_members
267
+ ]
268
+ )
269
+ cluster_index += 1
270
+ continue
271
+
272
+ for start in range(0, len(community_members), max_clique_batch_size):
273
+ community_nodes = community_members[
274
+ start : start + max_clique_batch_size
275
+ ]
276
+ LOGGER.debug("Creating subgraph with %s nodes.", len(community_nodes))
277
+ # Map between node values for a community
278
+ community_node_map = dict(enumerate(community_nodes))
279
+ subgraph = nk.graphtools.subgraphFromNodes(
280
+ graph, community_nodes, compact=True
281
+ )
282
+
283
+ while subgraph.numberOfNodes() > 0:
284
+ LOGGER.debug("Subgraph size: %s", subgraph.numberOfNodes())
285
+ clique = nk.clique.MaximalCliques(subgraph, maximumOnly=True)
286
+ clique.run()
287
+ clique_members = clique.getCliques()[0]
288
+ assignments.extend(
289
+ [
290
+ {
291
+ "id": node_to_id_map[community_node_map[n]],
292
+ "cluster": cluster_index,
293
+ }
294
+ for n in clique_members
295
+ ]
296
+ )
297
+ cluster_index += 1
298
+ for n in clique_members:
299
+ subgraph.removeNode(n)
300
+
301
+ return assignments
@@ -0,0 +1,239 @@
1
+ import logging
2
+ import random
3
+
4
+ import cv2
5
+ import numpy as np
6
+
7
+ import perception.local_descriptor_deduplication as ldd
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+ # Set a fixed size for drawing, we don't have the real descriptor size.
12
+ KEYPOINT_SIZE: int = 8
13
+
14
+
15
+ def vizualize_pair(
16
+ features_1,
17
+ features_2,
18
+ ratio: float,
19
+ match_metadata=None,
20
+ local_path_col: str | None = None,
21
+ sanitized: bool = False,
22
+ include_all_points=False,
23
+ circle_size=KEYPOINT_SIZE,
24
+ ):
25
+ """Given two rows from a reference df vizualize their overlap.
26
+
27
+ Currently recalcs overlap using cv2 default logic.
28
+
29
+ Args:
30
+ features_1: The row from a reference df for one image.
31
+ features_2: The row from a reference df for the other image.
32
+ ratio: Value for ratio test, suggest re-using value from matching.
33
+ match_metadata: metadata returned from matching, if None will redo brute force matching.
34
+ local_path_col: column in df with path to the image. If None will
35
+ use the index: features_1.name and features_2.name
36
+ sanitized: if True images themselves will not be rendered, only the points.
37
+ include_all_points: if True will draw all points, not just matched points.
38
+ circle_size: size of the circle to draw around keypoints.
39
+ Returns:
40
+ An image of the two images concatted together and matching keypoints drawn.
41
+ """
42
+ # Set a fixed size for drawing, we don't have the real descriptor size.
43
+ if local_path_col is not None:
44
+ features_1_path = features_1[local_path_col]
45
+ features_2_path = features_2[local_path_col]
46
+ else:
47
+ features_1_path = features_1.name
48
+ features_2_path = features_2.name
49
+
50
+ img1 = np.zeros(
51
+ (features_1.dimensions[1], features_1.dimensions[0], 1), dtype="uint8"
52
+ )
53
+ img2 = np.zeros(
54
+ (features_2.dimensions[1], features_2.dimensions[0], 1), dtype="uint8"
55
+ )
56
+
57
+ if not sanitized:
58
+ try:
59
+ img1 = ldd.load_and_preprocess(
60
+ features_1_path, max_size=max(features_1.dimensions), grayscale=False
61
+ )
62
+ except Exception:
63
+ LOGGER.warning("Failed to load image %s", features_1_path)
64
+ try:
65
+ img2 = ldd.load_and_preprocess(
66
+ features_2_path, max_size=max(features_2.dimensions), grayscale=False
67
+ )
68
+ except Exception:
69
+ LOGGER.warning("Failed to load image %s", features_2_path)
70
+
71
+ if match_metadata is not None:
72
+ img_matched = viz_match_data(
73
+ features_1,
74
+ features_2,
75
+ img1,
76
+ img2,
77
+ match_metadata,
78
+ include_all_points=include_all_points,
79
+ circle_size=circle_size,
80
+ )
81
+ else:
82
+ LOGGER.warning(
83
+ """No match_metadata provided, recalculating match points,
84
+ won't match perception match points."""
85
+ )
86
+ img_matched = viz_brute_force(features_1, features_2, img1, img2, ratio=ratio)
87
+
88
+ return img_matched
89
+
90
+
91
+ def viz_match_data(
92
+ features_1,
93
+ features_2,
94
+ img1,
95
+ img2,
96
+ match_metadata,
97
+ include_all_points=False,
98
+ circle_size=KEYPOINT_SIZE,
99
+ ):
100
+ """Given match data viz matching points.
101
+
102
+ Args:
103
+ features_1: The row from a reference df for one image.
104
+ features_2: The row from a reference df for the other image.
105
+ img1: cv2 of first image
106
+ img2: cv2 of second image
107
+ match_metadata: metadata returned from matching, if None will redo
108
+ brute force matching.
109
+ include_all_points: if True will draw all points, not just matched points.
110
+ circle_size: size of the circle to draw around keypoints.
111
+ Returns:
112
+ cv2 img with matching keypoints drawn.
113
+ """
114
+ # NOTE: could refactor to put matches in to correct format and use: cv2.drawMatchesKnn,
115
+ # but python docs on necessary class not clear.
116
+
117
+ # Pad img1 or img2 vertically with black pixels to match the height of the other image
118
+ if img1.shape[0] > img2.shape[0]:
119
+ img2 = np.pad(
120
+ img2,
121
+ ((0, img1.shape[0] - img2.shape[0]), (0, 0), (0, 0)),
122
+ mode="constant",
123
+ constant_values=0,
124
+ )
125
+ elif img1.shape[0] < img2.shape[0]:
126
+ img1 = np.pad(
127
+ img1,
128
+ ((0, img2.shape[0] - img1.shape[0]), (0, 0), (0, 0)),
129
+ mode="constant",
130
+ constant_values=0,
131
+ )
132
+ # draw two images h concat:
133
+ img_matched = np.concatenate((img1, img2), axis=1)
134
+
135
+ overlay = img_matched.copy()
136
+
137
+ if include_all_points:
138
+ # draw all points in kp_1
139
+ for k in features_1["keypoints"]:
140
+ new_color = (
141
+ random.randint(0, 255),
142
+ random.randint(0, 255),
143
+ random.randint(0, 255),
144
+ )
145
+ # Draw semi transparent circle
146
+ cv2.circle(img_matched, (int(k[0]), int(k[1])), circle_size, new_color, 1)
147
+
148
+ # draw all points in kp_2
149
+ for k in features_2["keypoints"]:
150
+ new_color = (
151
+ random.randint(0, 255),
152
+ random.randint(0, 255),
153
+ random.randint(0, 255),
154
+ )
155
+ cv2.circle(
156
+ img_matched,
157
+ (int(k[0] + features_1.dimensions[0]), int(k[1])),
158
+ circle_size,
159
+ new_color,
160
+ 1,
161
+ )
162
+
163
+ # draw lines between matching points
164
+ for i in range(len(match_metadata["final_matched_b_pts"])):
165
+ new_color = (
166
+ random.randint(0, 255),
167
+ random.randint(0, 255),
168
+ random.randint(0, 255),
169
+ )
170
+ a_pt = (
171
+ int(match_metadata["final_matched_a_pts"][i][0]),
172
+ int(match_metadata["final_matched_a_pts"][i][1]),
173
+ )
174
+ b_pt = (
175
+ int(match_metadata["final_matched_b_pts"][i][0] + features_1.dimensions[0]),
176
+ int(match_metadata["final_matched_b_pts"][i][1]),
177
+ )
178
+ cv2.circle(img_matched, a_pt, circle_size, new_color, 1)
179
+ cv2.circle(img_matched, b_pt, circle_size, new_color, 1)
180
+ cv2.line(
181
+ img_matched,
182
+ a_pt,
183
+ b_pt,
184
+ new_color,
185
+ 1,
186
+ )
187
+
188
+ # Re-overlay original image to add some transparency effect to lines and circles.
189
+ alpha = 0.4 # Transparency factor.
190
+ # Following line overlays transparent rectangle over the image
191
+ img_matched = cv2.addWeighted(overlay, alpha, img_matched, 1 - alpha, 0)
192
+
193
+ return img_matched
194
+
195
+
196
+ def viz_brute_force(features_1, features_2, img1, img2, ratio: float):
197
+ """
198
+ Given two rows from a reference df vizualize their overlap.
199
+
200
+ NOTE: It redoes matching using cv2 bruteforce, so will not match the same
201
+ as the perception matching code.
202
+
203
+ Args:
204
+ features_1: The row from a reference df for one image.
205
+ features_2: The row from a reference df for the other image.
206
+ img1: cv2 of first image
207
+ img2: cv2 of second image
208
+ ratio: Value for ratio test, suggest re-using value from matching.
209
+
210
+ Returns:
211
+ An image of the two images concatted together and matching keypoints drawn.
212
+ """
213
+ # Convert numpy keypoints to cv2.KeyPoints
214
+ kp1_fixed = []
215
+ for k in features_1["keypoints"]:
216
+ kp1_fixed.append(cv2.KeyPoint(k[0], k[1], KEYPOINT_SIZE))
217
+
218
+ kp2_fixed = []
219
+ for k in features_2["keypoints"]:
220
+ kp2_fixed.append(cv2.KeyPoint(k[0], k[1], KEYPOINT_SIZE))
221
+ brute_force_matcher = cv2.BFMatcher()
222
+ kn_matches = brute_force_matcher.knnMatch(
223
+ features_1["descriptors"], features_2["descriptors"], k=2
224
+ )
225
+ # Apply ratio test
226
+ good = []
227
+ for nearest_match, next_nearest_match in kn_matches:
228
+ if nearest_match.distance < ratio * next_nearest_match.distance:
229
+ good.append([nearest_match])
230
+ img_matched = cv2.drawMatchesKnn( # type: ignore[call-overload]
231
+ img1,
232
+ kp1_fixed,
233
+ img2,
234
+ kp2_fixed,
235
+ good,
236
+ None,
237
+ flags=cv2.DrawMatchesFlags_DRAW_RICH_KEYPOINTS,
238
+ )
239
+ return img_matched