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.
- perception/__init__.py +14 -0
- perception/approximate_deduplication/__init__.py +301 -0
- perception/approximate_deduplication/debug.py +239 -0
- perception/approximate_deduplication/index.py +433 -0
- perception/approximate_deduplication/serve.py +151 -0
- perception/benchmarking/__init__.py +23 -0
- perception/benchmarking/common.py +653 -0
- perception/benchmarking/extensions.c +31202 -0
- perception/benchmarking/extensions.cp313-win_amd64.pyd +0 -0
- perception/benchmarking/extensions.pyx +112 -0
- perception/benchmarking/image.py +204 -0
- perception/benchmarking/image_transforms.py +42 -0
- perception/benchmarking/video.py +224 -0
- perception/benchmarking/video_transforms.py +198 -0
- perception/extensions.cp313-win_amd64.pyd +0 -0
- perception/extensions.cpp +33687 -0
- perception/extensions.pyx +305 -0
- perception/hashers/__init__.py +33 -0
- perception/hashers/hasher.py +386 -0
- perception/hashers/image/__init__.py +17 -0
- perception/hashers/image/average.py +35 -0
- perception/hashers/image/dhash.py +30 -0
- perception/hashers/image/opencv.py +63 -0
- perception/hashers/image/pdq.py +34 -0
- perception/hashers/image/phash.py +109 -0
- perception/hashers/image/wavelet.py +59 -0
- perception/hashers/tools.py +1178 -0
- perception/hashers/video/__init__.py +4 -0
- perception/hashers/video/framewise.py +102 -0
- perception/hashers/video/tmk.py +219 -0
- perception/local_descriptor_deduplication.py +708 -0
- perception/py.typed +0 -0
- perception/testing/__init__.py +245 -0
- perception/testing/images/README.md +13 -0
- perception/testing/images/image1.jpg +0 -0
- perception/testing/images/image10.jpg +0 -0
- perception/testing/images/image2.jpg +0 -0
- perception/testing/images/image3.jpg +0 -0
- perception/testing/images/image4.jpg +0 -0
- perception/testing/images/image5.jpg +0 -0
- perception/testing/images/image6.jpg +0 -0
- perception/testing/images/image7.jpg +0 -0
- perception/testing/images/image8.jpg +0 -0
- perception/testing/images/image9.jpg +0 -0
- perception/testing/logos/README.md +4 -0
- perception/testing/logos/logoipsum.png +0 -0
- perception/testing/videos/README.md +6 -0
- perception/testing/videos/expected_tmk.json.gz +0 -0
- perception/testing/videos/extra_channel_attached_pic.mp4 +0 -0
- perception/testing/videos/extra_channel_attached_pic_audio.mp4 +0 -0
- perception/testing/videos/rgb.m4v +0 -0
- perception/testing/videos/v1.m4v +0 -0
- perception/testing/videos/v2.m4v +0 -0
- perception/testing/videos/v2s.mov +0 -0
- perception/tools.py +379 -0
- perception/utils.py +2 -0
- perception-0.8.3.dist-info/DELVEWHEEL +1 -0
- perception-0.8.3.dist-info/METADATA +115 -0
- perception-0.8.3.dist-info/RECORD +62 -0
- perception-0.8.3.dist-info/WHEEL +4 -0
- perception-0.8.3.dist-info/licenses/LICENSE +191 -0
- 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
|