superiorvision 0.30.0.dev0__py3-none-any.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.
- superiorvision/__init__.py +19 -0
- superiorvision-0.30.0.dev0.dist-info/METADATA +398 -0
- superiorvision-0.30.0.dev0.dist-info/RECORD +96 -0
- superiorvision-0.30.0.dev0.dist-info/WHEEL +5 -0
- superiorvision-0.30.0.dev0.dist-info/licenses/LICENSE.md +9 -0
- superiorvision-0.30.0.dev0.dist-info/top_level.txt +2 -0
- supervision/__init__.py +318 -0
- supervision/annotators/__init__.py +0 -0
- supervision/annotators/base.py +21 -0
- supervision/annotators/core.py +3551 -0
- supervision/annotators/utils.py +541 -0
- supervision/assets/__init__.py +4 -0
- supervision/assets/downloader.py +166 -0
- supervision/assets/list.py +84 -0
- supervision/classification/__init__.py +0 -0
- supervision/classification/core.py +216 -0
- supervision/config.py +13 -0
- supervision/dataset/__init__.py +0 -0
- supervision/dataset/core.py +1313 -0
- supervision/dataset/formats/__init__.py +0 -0
- supervision/dataset/formats/coco.py +708 -0
- supervision/dataset/formats/createml.py +331 -0
- supervision/dataset/formats/labelme.py +405 -0
- supervision/dataset/formats/pascal_voc.py +449 -0
- supervision/dataset/formats/yolo.py +502 -0
- supervision/dataset/utils.py +265 -0
- supervision/detection/__init__.py +0 -0
- supervision/detection/compact_mask.py +1927 -0
- supervision/detection/core.py +3864 -0
- supervision/detection/line_zone.py +924 -0
- supervision/detection/overlap_filter.py +426 -0
- supervision/detection/tensor_utils.py +1596 -0
- supervision/detection/tensor_views.py +48 -0
- supervision/detection/tools/__init__.py +0 -0
- supervision/detection/tools/csv_sink.py +242 -0
- supervision/detection/tools/inference_slicer.py +776 -0
- supervision/detection/tools/json_sink.py +215 -0
- supervision/detection/tools/polygon_zone.py +266 -0
- supervision/detection/tools/smoother.py +218 -0
- supervision/detection/tools/transformers.py +265 -0
- supervision/detection/utils/__init__.py +12 -0
- supervision/detection/utils/_typing.py +11 -0
- supervision/detection/utils/boxes.py +510 -0
- supervision/detection/utils/converters.py +830 -0
- supervision/detection/utils/internal.py +806 -0
- supervision/detection/utils/iou_and_nms.py +1606 -0
- supervision/detection/utils/masks.py +625 -0
- supervision/detection/utils/polygons.py +128 -0
- supervision/detection/utils/vlms.py +97 -0
- supervision/detection/vlm.py +945 -0
- supervision/draw/__init__.py +0 -0
- supervision/draw/base.py +14 -0
- supervision/draw/color.py +598 -0
- supervision/draw/utils.py +527 -0
- supervision/geometry/__init__.py +0 -0
- supervision/geometry/core.py +208 -0
- supervision/geometry/utils.py +54 -0
- supervision/key_points/__init__.py +0 -0
- supervision/key_points/annotators.py +997 -0
- supervision/key_points/core.py +1506 -0
- supervision/key_points/skeletons.py +2646 -0
- supervision/keypoint/__init__.py +13 -0
- supervision/keypoint/annotators.py +13 -0
- supervision/keypoint/core.py +8 -0
- supervision/metrics/__init__.py +40 -0
- supervision/metrics/core.py +74 -0
- supervision/metrics/detection.py +1632 -0
- supervision/metrics/f1_score.py +815 -0
- supervision/metrics/mean_average_precision.py +1723 -0
- supervision/metrics/mean_average_recall.py +734 -0
- supervision/metrics/precision.py +815 -0
- supervision/metrics/recall.py +774 -0
- supervision/metrics/utils/__init__.py +0 -0
- supervision/metrics/utils/matching.py +56 -0
- supervision/metrics/utils/object_size.py +256 -0
- supervision/metrics/utils/utils.py +9 -0
- supervision/py.typed +0 -0
- supervision/tracker/__init__.py +5 -0
- supervision/tracker/byte_tracker/__init__.py +0 -0
- supervision/tracker/byte_tracker/core.py +448 -0
- supervision/tracker/byte_tracker/kalman_filter.py +186 -0
- supervision/tracker/byte_tracker/matching.py +211 -0
- supervision/tracker/byte_tracker/single_object_track.py +194 -0
- supervision/tracker/byte_tracker/utils.py +34 -0
- supervision/utils/__init__.py +0 -0
- supervision/utils/conversion.py +223 -0
- supervision/utils/deprecate.py +54 -0
- supervision/utils/file.py +209 -0
- supervision/utils/image.py +988 -0
- supervision/utils/internal.py +209 -0
- supervision/utils/iterables.py +103 -0
- supervision/utils/logger.py +61 -0
- supervision/utils/notebook.py +124 -0
- supervision/utils/tensor.py +362 -0
- supervision/utils/video.py +533 -0
- supervision/validators/__init__.py +379 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import textwrap
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
|
|
9
|
+
from supervision.config import CLASS_NAME_DATA_FIELD
|
|
10
|
+
from supervision.detection.core import Detections
|
|
11
|
+
from supervision.draw.color import Color, ColorPalette
|
|
12
|
+
from supervision.geometry.core import Position
|
|
13
|
+
from supervision.utils.deprecate import deprecated, void
|
|
14
|
+
from supervision.utils.tensor import to_numpy
|
|
15
|
+
|
|
16
|
+
PENDING_TRACK_COLOR = Color.GREY
|
|
17
|
+
PENDING_TRACK_ID = -1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ColorLookup(Enum):
|
|
21
|
+
"""
|
|
22
|
+
Enumeration class to define strategies for mapping colors to annotations.
|
|
23
|
+
|
|
24
|
+
This enum supports three different lookup strategies:
|
|
25
|
+
- `INDEX`: Colors are determined by the index of the detection within the scene.
|
|
26
|
+
- `CLASS`: Colors are determined by the class label of the detected object.
|
|
27
|
+
- `TRACK`: Colors are determined by the tracking identifier of the object.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
INDEX = "index"
|
|
31
|
+
CLASS = "class"
|
|
32
|
+
TRACK = "track"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def list(cls) -> list[str]:
|
|
36
|
+
return list(map(lambda c: c.value, cls))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_color_idx(
|
|
40
|
+
detections: Detections,
|
|
41
|
+
detection_idx: int,
|
|
42
|
+
color_lookup: ColorLookup | npt.NDArray[np.int_] = ColorLookup.CLASS,
|
|
43
|
+
) -> int:
|
|
44
|
+
if detection_idx >= len(detections):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"Detection index {detection_idx} "
|
|
47
|
+
f"is out of bounds for detections of length {len(detections)}"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if isinstance(color_lookup, np.ndarray):
|
|
51
|
+
if len(color_lookup) != len(detections):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Length of color lookup {len(color_lookup)} "
|
|
54
|
+
f"does not match length of detections {len(detections)}"
|
|
55
|
+
)
|
|
56
|
+
return int(color_lookup[detection_idx])
|
|
57
|
+
elif color_lookup == ColorLookup.INDEX:
|
|
58
|
+
return detection_idx
|
|
59
|
+
elif color_lookup == ColorLookup.CLASS:
|
|
60
|
+
if detections.class_id is None:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"Could not resolve color by class because "
|
|
63
|
+
"Detections do not have class_id. If using an annotator, "
|
|
64
|
+
"try setting color_lookup to sv.ColorLookup.INDEX or "
|
|
65
|
+
"sv.ColorLookup.TRACK."
|
|
66
|
+
)
|
|
67
|
+
return int(detections.class_id[detection_idx])
|
|
68
|
+
elif color_lookup == ColorLookup.TRACK:
|
|
69
|
+
if detections.tracker_id is None:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"Could not resolve color by track because "
|
|
72
|
+
"Detections do not have tracker_id. Did you call "
|
|
73
|
+
"tracker.update_with_detections(...) before annotating?"
|
|
74
|
+
)
|
|
75
|
+
return int(detections.tracker_id[detection_idx])
|
|
76
|
+
raise ValueError(f"Unsupported color lookup strategy: {color_lookup}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_text_background_xyxy(
|
|
80
|
+
center_coordinates: tuple[int, int],
|
|
81
|
+
text_wh: tuple[int, int],
|
|
82
|
+
position: Position,
|
|
83
|
+
) -> tuple[int, int, int, int]:
|
|
84
|
+
"""Compute the background box for text anchored at `position`."""
|
|
85
|
+
center_x, center_y = center_coordinates
|
|
86
|
+
text_w, text_h = text_wh
|
|
87
|
+
|
|
88
|
+
if position == Position.TOP_LEFT:
|
|
89
|
+
return center_x, center_y - text_h, center_x + text_w, center_y
|
|
90
|
+
elif position == Position.TOP_RIGHT:
|
|
91
|
+
return center_x - text_w, center_y - text_h, center_x, center_y
|
|
92
|
+
elif position == Position.TOP_CENTER:
|
|
93
|
+
return (
|
|
94
|
+
center_x - text_w // 2,
|
|
95
|
+
center_y - text_h,
|
|
96
|
+
center_x + text_w // 2,
|
|
97
|
+
center_y,
|
|
98
|
+
)
|
|
99
|
+
elif position == Position.CENTER or position == Position.CENTER_OF_MASS:
|
|
100
|
+
return (
|
|
101
|
+
center_x - text_w // 2,
|
|
102
|
+
center_y - text_h // 2,
|
|
103
|
+
center_x + text_w // 2,
|
|
104
|
+
center_y + text_h // 2,
|
|
105
|
+
)
|
|
106
|
+
elif position == Position.BOTTOM_LEFT:
|
|
107
|
+
return center_x, center_y, center_x + text_w, center_y + text_h
|
|
108
|
+
elif position == Position.BOTTOM_RIGHT:
|
|
109
|
+
return center_x - text_w, center_y, center_x, center_y + text_h
|
|
110
|
+
elif position == Position.BOTTOM_CENTER:
|
|
111
|
+
return (
|
|
112
|
+
center_x - text_w // 2,
|
|
113
|
+
center_y,
|
|
114
|
+
center_x + text_w // 2,
|
|
115
|
+
center_y + text_h,
|
|
116
|
+
)
|
|
117
|
+
elif position == Position.CENTER_LEFT:
|
|
118
|
+
return (
|
|
119
|
+
center_x - text_w,
|
|
120
|
+
center_y - text_h // 2,
|
|
121
|
+
center_x,
|
|
122
|
+
center_y + text_h // 2,
|
|
123
|
+
)
|
|
124
|
+
elif position == Position.CENTER_RIGHT:
|
|
125
|
+
return (
|
|
126
|
+
center_x,
|
|
127
|
+
center_y - text_h // 2,
|
|
128
|
+
center_x + text_w,
|
|
129
|
+
center_y + text_h // 2,
|
|
130
|
+
)
|
|
131
|
+
raise ValueError(f"Unsupported position: {position}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_color_by_index(color: Color | ColorPalette, idx: int) -> Color:
|
|
135
|
+
"""Resolve a color-like object to a concrete `Color` for an index."""
|
|
136
|
+
color_like = cast(Any, color)
|
|
137
|
+
# Accept ColorPalette-like objects without depending on their exact concrete class.
|
|
138
|
+
if callable(getattr(color_like, "by_idx", None)):
|
|
139
|
+
color_like = color_like.by_idx(idx)
|
|
140
|
+
if isinstance(color_like, Color):
|
|
141
|
+
return color_like
|
|
142
|
+
return Color(
|
|
143
|
+
r=int(color_like.r),
|
|
144
|
+
g=int(color_like.g),
|
|
145
|
+
b=int(color_like.b),
|
|
146
|
+
a=int(getattr(color_like, "a", 255)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolve_color(
|
|
151
|
+
color: Color | ColorPalette,
|
|
152
|
+
detections: Detections,
|
|
153
|
+
detection_idx: int,
|
|
154
|
+
color_lookup: ColorLookup | npt.NDArray[np.int_] = ColorLookup.CLASS,
|
|
155
|
+
) -> Color:
|
|
156
|
+
idx = resolve_color_idx(
|
|
157
|
+
detections=detections,
|
|
158
|
+
detection_idx=detection_idx,
|
|
159
|
+
color_lookup=color_lookup,
|
|
160
|
+
)
|
|
161
|
+
if (
|
|
162
|
+
isinstance(color_lookup, ColorLookup)
|
|
163
|
+
and color_lookup == ColorLookup.TRACK
|
|
164
|
+
and idx == PENDING_TRACK_ID
|
|
165
|
+
):
|
|
166
|
+
return PENDING_TRACK_COLOR
|
|
167
|
+
return get_color_by_index(color=color, idx=idx)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def wrap_text(text: object, max_line_length: int | None = None) -> list[str]:
|
|
171
|
+
"""
|
|
172
|
+
Wrap `text` to the specified maximum line length, respecting existing
|
|
173
|
+
newlines. Falls back to str() if `text` is not already a string.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
text: The text (or object) to wrap.
|
|
177
|
+
max_line_length: Maximum width for each wrapped line.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Wrapped lines.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
if not text:
|
|
184
|
+
return [""]
|
|
185
|
+
|
|
186
|
+
if not isinstance(text, str):
|
|
187
|
+
text = str(text)
|
|
188
|
+
|
|
189
|
+
if max_line_length is None:
|
|
190
|
+
return text.splitlines() or [""]
|
|
191
|
+
|
|
192
|
+
if max_line_length <= 0:
|
|
193
|
+
raise ValueError("max_line_length must be a positive integer")
|
|
194
|
+
|
|
195
|
+
paragraphs = text.split("\n")
|
|
196
|
+
all_lines: list[str] = []
|
|
197
|
+
|
|
198
|
+
for paragraph in paragraphs:
|
|
199
|
+
if paragraph == "":
|
|
200
|
+
all_lines.append("")
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
wrapped = textwrap.wrap(
|
|
204
|
+
paragraph,
|
|
205
|
+
width=max_line_length,
|
|
206
|
+
break_long_words=True,
|
|
207
|
+
replace_whitespace=False,
|
|
208
|
+
drop_whitespace=True,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
all_lines.extend(wrapped or [""])
|
|
212
|
+
|
|
213
|
+
return all_lines or [""]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _validate_labels(labels: list[str] | None, detections: Detections) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Validates that the number of provided labels matches the number of detections.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
labels: A list of labels, one for each detection. Can
|
|
222
|
+
be None.
|
|
223
|
+
detections: The detections to be labeled.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
ValueError: If `labels` is not None and its length does not match the number
|
|
227
|
+
of detections.
|
|
228
|
+
"""
|
|
229
|
+
if labels is not None and len(labels) != len(detections):
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"The number of labels ({len(labels)}) does not match the "
|
|
232
|
+
f"number of detections ({len(detections)}). Each detection "
|
|
233
|
+
f"should have exactly 1 label."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@deprecated( # type: ignore[untyped-decorator]
|
|
238
|
+
target=_validate_labels,
|
|
239
|
+
deprecated_in="0.29.0",
|
|
240
|
+
remove_in="0.32.0",
|
|
241
|
+
)
|
|
242
|
+
def validate_labels(labels: list[str] | None, detections: Detections) -> None:
|
|
243
|
+
void(labels, detections)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def get_labels_text(
|
|
247
|
+
detections: Detections, custom_labels: list[str] | None
|
|
248
|
+
) -> list[str]:
|
|
249
|
+
"""
|
|
250
|
+
Retrieves the text labels for the detections.
|
|
251
|
+
|
|
252
|
+
If `custom_labels` are provided, they are used. Otherwise, the labels are
|
|
253
|
+
extracted from the `detections` object, prioritizing the 'class_name' field,
|
|
254
|
+
then the `class_id`, and finally using the detection index as a string.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
detections: The detections to get labels for.
|
|
258
|
+
custom_labels: An optional list of custom labels.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
A list of text labels for each detection.
|
|
262
|
+
"""
|
|
263
|
+
if custom_labels is not None:
|
|
264
|
+
return custom_labels
|
|
265
|
+
|
|
266
|
+
labels = []
|
|
267
|
+
for idx in range(len(detections)):
|
|
268
|
+
if CLASS_NAME_DATA_FIELD in detections.data:
|
|
269
|
+
labels.append(str(detections.data[CLASS_NAME_DATA_FIELD][idx]))
|
|
270
|
+
elif detections.class_id is not None:
|
|
271
|
+
labels.append(str(int(to_numpy(detections.class_id[idx]))))
|
|
272
|
+
else:
|
|
273
|
+
labels.append(str(idx))
|
|
274
|
+
return labels
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def snap_boxes(
|
|
278
|
+
xyxy: npt.NDArray[np.float32],
|
|
279
|
+
resolution_wh: tuple[int, int],
|
|
280
|
+
) -> npt.NDArray[np.float32]:
|
|
281
|
+
"""
|
|
282
|
+
Shifts `label` bounding boxes into the frame so that they are fully contained
|
|
283
|
+
within the given resolution, prioritizing the top/left edge.
|
|
284
|
+
Unlike `clip_boxes`, this function does not crop boxes.
|
|
285
|
+
It moves them entirely if they exceed the frame boundaries.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
xyxy: A numpy array of shape `(N, 4)` where each
|
|
289
|
+
row corresponds to a bounding box in the format
|
|
290
|
+
`(x_min, y_min, x_max, y_max)`.
|
|
291
|
+
resolution_wh: A tuple `(width, height)`
|
|
292
|
+
representing the resolution of the frame.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
A numpy array of shape `(N, 4)` with boxes shifted into frame.
|
|
296
|
+
|
|
297
|
+
Examples:
|
|
298
|
+
```pycon
|
|
299
|
+
>>> import numpy as np
|
|
300
|
+
>>> from supervision.annotators.utils import snap_boxes
|
|
301
|
+
>>> xyxy = np.array([
|
|
302
|
+
... [-10, 10, 30, 50], # Off left edge
|
|
303
|
+
... [310, 200, 350, 250], # Off right edge
|
|
304
|
+
... [100, -20, 150, 30], # Off top edge
|
|
305
|
+
... [200, 220, 250, 270], # Off bottom edge
|
|
306
|
+
... [-20, 10, 350, 50], # Wider than frame (370 vs 320)
|
|
307
|
+
... [10, -20, 30, 260] # Taller than frame (280 vs 240)
|
|
308
|
+
... ])
|
|
309
|
+
>>> resolution_wh = (320, 240)
|
|
310
|
+
>>> snapped_boxes = snap_boxes(xyxy=xyxy, resolution_wh=resolution_wh)
|
|
311
|
+
>>> snapped_boxes
|
|
312
|
+
array([[ 0., 10., 40., 50.],
|
|
313
|
+
[280., 190., 320., 240.],
|
|
314
|
+
[100., 0., 150., 50.],
|
|
315
|
+
[200., 190., 250., 240.],
|
|
316
|
+
[ 0., 10., 370., 50.],
|
|
317
|
+
[ 10., 0., 30., 280.]], dtype=float32)
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
"""
|
|
321
|
+
result: npt.NDArray[np.float32] = np.array(xyxy, dtype=np.float32, copy=True)
|
|
322
|
+
width, height = resolution_wh
|
|
323
|
+
|
|
324
|
+
# X-axis (prioritize left edge)
|
|
325
|
+
left_overflow = result[:, 0] < 0
|
|
326
|
+
result[left_overflow, 0:3:2] -= result[left_overflow, 0:1]
|
|
327
|
+
|
|
328
|
+
right_overflow = (~left_overflow) & (result[:, 2] > width)
|
|
329
|
+
right_shift = width - result[right_overflow, 2]
|
|
330
|
+
result[right_overflow, 0:3:2] += right_shift[:, np.newaxis]
|
|
331
|
+
|
|
332
|
+
# Y-axis (prioritize top edge)
|
|
333
|
+
top_overflow = result[:, 1] < 0
|
|
334
|
+
result[top_overflow, 1:4:2] -= result[top_overflow, 1:2]
|
|
335
|
+
|
|
336
|
+
bottom_overflow = (~top_overflow) & (result[:, 3] > height)
|
|
337
|
+
bottom_shift = height - result[bottom_overflow, 3]
|
|
338
|
+
result[bottom_overflow, 1:4:2] += bottom_shift[:, np.newaxis]
|
|
339
|
+
|
|
340
|
+
return cast(npt.NDArray[np.float32], result.astype(np.float32, copy=False))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class Trace:
|
|
344
|
+
def __init__(
|
|
345
|
+
self,
|
|
346
|
+
max_size: int | None = None,
|
|
347
|
+
start_frame_id: int = 0,
|
|
348
|
+
anchor: Position = Position.CENTER,
|
|
349
|
+
) -> None:
|
|
350
|
+
self.current_frame_id = start_frame_id
|
|
351
|
+
self.max_size = max_size
|
|
352
|
+
self.anchor = anchor
|
|
353
|
+
|
|
354
|
+
self.frame_id: npt.NDArray[np.int_] = np.array([], dtype=int)
|
|
355
|
+
self.xy: npt.NDArray[np.float32] = np.empty((0, 2), dtype=np.float32)
|
|
356
|
+
self.tracker_id: npt.NDArray[np.int_] = np.array([], dtype=int)
|
|
357
|
+
|
|
358
|
+
def put(self, detections: Detections) -> None:
|
|
359
|
+
"""Append a frame of detections to the trace history."""
|
|
360
|
+
if detections.tracker_id is None:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"Could not put detections into Trace because "
|
|
363
|
+
"Detections do not have tracker_id."
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
frame_id: npt.NDArray[np.int_] = np.full(
|
|
367
|
+
len(detections), self.current_frame_id, dtype=int
|
|
368
|
+
)
|
|
369
|
+
self.frame_id = np.concatenate([self.frame_id, frame_id])
|
|
370
|
+
self.xy = np.concatenate(
|
|
371
|
+
[
|
|
372
|
+
self.xy,
|
|
373
|
+
to_numpy(detections.get_anchors_coordinates(self.anchor)),
|
|
374
|
+
]
|
|
375
|
+
)
|
|
376
|
+
self.tracker_id = np.concatenate(
|
|
377
|
+
[self.tracker_id, to_numpy(detections.tracker_id)]
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
unique_frame_id = np.unique(self.frame_id)
|
|
381
|
+
|
|
382
|
+
if self.max_size is not None and 0 < self.max_size < len(unique_frame_id):
|
|
383
|
+
max_allowed_frame_id = self.current_frame_id - self.max_size + 1
|
|
384
|
+
filtering_mask = self.frame_id >= max_allowed_frame_id
|
|
385
|
+
self.frame_id = self.frame_id[filtering_mask]
|
|
386
|
+
self.xy = self.xy[filtering_mask]
|
|
387
|
+
self.tracker_id = self.tracker_id[filtering_mask]
|
|
388
|
+
|
|
389
|
+
self.current_frame_id += 1
|
|
390
|
+
|
|
391
|
+
def get(self, tracker_id: int) -> npt.NDArray[np.float32]:
|
|
392
|
+
xy: npt.NDArray[np.float32] = np.asarray(
|
|
393
|
+
self.xy[self.tracker_id == tracker_id], dtype=np.float32
|
|
394
|
+
)
|
|
395
|
+
return xy
|
|
396
|
+
|
|
397
|
+
def reset(self) -> None:
|
|
398
|
+
"""Restore the trace buffers to their initial empty state.
|
|
399
|
+
|
|
400
|
+
Clears the accumulated `frame_id`, `xy`, and `tracker_id` history and
|
|
401
|
+
rewinds `current_frame_id` to `0`, so the trace can be reused across
|
|
402
|
+
independent streams without carrying over points from a previous run.
|
|
403
|
+
"""
|
|
404
|
+
self.current_frame_id = 0
|
|
405
|
+
self.frame_id = np.array([], dtype=int)
|
|
406
|
+
self.xy = np.empty((0, 2), dtype=np.float32)
|
|
407
|
+
self.tracker_id = np.array([], dtype=int)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]:
|
|
411
|
+
"""
|
|
412
|
+
Converts a hex color string (e.g. "#FF00FF" or "#FF00FF80") to an RGBA tuple.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
hex_color: A hex color string.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
RGBA values in range 0-255.
|
|
419
|
+
|
|
420
|
+
Raises:
|
|
421
|
+
ValueError: If the format is invalid.
|
|
422
|
+
|
|
423
|
+
Examples:
|
|
424
|
+
```pycon
|
|
425
|
+
>>> from supervision.annotators.utils import hex_to_rgba
|
|
426
|
+
>>> hex_to_rgba("#FF00FF")
|
|
427
|
+
(255, 0, 255, 255)
|
|
428
|
+
>>> hex_to_rgba("#FF00FF80")
|
|
429
|
+
(255, 0, 255, 128)
|
|
430
|
+
|
|
431
|
+
```
|
|
432
|
+
"""
|
|
433
|
+
hex_color = hex_color.strip().removeprefix("#")
|
|
434
|
+
if len(hex_color) == 6:
|
|
435
|
+
hex_color += "FF" # default full opacity
|
|
436
|
+
if len(hex_color) != 8:
|
|
437
|
+
raise ValueError(f"Invalid hex color format: {hex_color}")
|
|
438
|
+
try:
|
|
439
|
+
r = int(hex_color[0:2], 16)
|
|
440
|
+
g = int(hex_color[2:4], 16)
|
|
441
|
+
b = int(hex_color[4:6], 16)
|
|
442
|
+
a = int(hex_color[6:8], 16)
|
|
443
|
+
except ValueError as exc:
|
|
444
|
+
raise ValueError(f"Invalid hex digits in {hex_color}") from exc
|
|
445
|
+
return (r, g, b, a)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def rgba_to_hex(rgba: tuple[int, int, int, int]) -> str:
|
|
449
|
+
"""
|
|
450
|
+
Converts an RGBA tuple (0-255 each) to a hex color string.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
rgba: RGBA values in range 0-255.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Hex color string in the format "#RRGGBBAA".
|
|
457
|
+
|
|
458
|
+
Raises:
|
|
459
|
+
ValueError: If `rgba` is not a 4-tuple or contains values outside 0-255.
|
|
460
|
+
|
|
461
|
+
Examples:
|
|
462
|
+
```pycon
|
|
463
|
+
>>> from supervision.annotators.utils import rgba_to_hex
|
|
464
|
+
>>> rgba_to_hex((255, 0, 255, 128))
|
|
465
|
+
'#FF00FF80'
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
"""
|
|
469
|
+
if len(rgba) != 4 or not all(0 <= c <= 255 for c in rgba):
|
|
470
|
+
raise ValueError("RGBA must be a 4-tuple with values between 0-255.")
|
|
471
|
+
return "#{:02X}{:02X}{:02X}{:02X}".format(*rgba)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def is_valid_hex(hex_color: str) -> bool:
|
|
475
|
+
"""
|
|
476
|
+
Checks if a given string is a valid hex color.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
hex_color: A hex color string with an optional leading "#". Supports
|
|
480
|
+
6-digit (RGB) or 8-digit (RGBA) formats.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
True if the string is a valid 6- or 8-digit hex color, otherwise False.
|
|
484
|
+
|
|
485
|
+
Examples:
|
|
486
|
+
```pycon
|
|
487
|
+
>>> from supervision.annotators.utils import is_valid_hex
|
|
488
|
+
>>> is_valid_hex("#FF00FF")
|
|
489
|
+
True
|
|
490
|
+
>>> is_valid_hex("not-a-color")
|
|
491
|
+
False
|
|
492
|
+
|
|
493
|
+
```
|
|
494
|
+
"""
|
|
495
|
+
return bool(re.fullmatch(r"#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", hex_color.strip()))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def calculate_dynamic_kernel_size(x1: int, y1: int, x2: int, y2: int) -> int:
|
|
499
|
+
"""
|
|
500
|
+
Computes a blur kernel size proportional to the shorter side of a bounding box.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
x1: Left edge of the bounding box.
|
|
504
|
+
y1: Top edge of the bounding box.
|
|
505
|
+
x2: Right edge of the bounding box.
|
|
506
|
+
y2: Bottom edge of the bounding box.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Kernel size as one-third of the shorter dimension, minimum 1.
|
|
510
|
+
|
|
511
|
+
Examples:
|
|
512
|
+
```pycon
|
|
513
|
+
>>> calculate_dynamic_kernel_size(0, 0, 90, 60)
|
|
514
|
+
20
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
"""
|
|
518
|
+
return max(1, min(y2 - y1, x2 - x1) // 3)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def calculate_dynamic_pixel_size(x1: int, y1: int, x2: int, y2: int) -> int:
|
|
522
|
+
"""
|
|
523
|
+
Computes a pixelation size proportional to the shorter side of a bounding box.
|
|
524
|
+
|
|
525
|
+
Args:
|
|
526
|
+
x1: Left edge of the bounding box.
|
|
527
|
+
y1: Top edge of the bounding box.
|
|
528
|
+
x2: Right edge of the bounding box.
|
|
529
|
+
y2: Bottom edge of the bounding box.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Pixel size as one-half of the shorter dimension, minimum 1.
|
|
533
|
+
|
|
534
|
+
Examples:
|
|
535
|
+
```pycon
|
|
536
|
+
>>> calculate_dynamic_pixel_size(0, 0, 90, 60)
|
|
537
|
+
30
|
|
538
|
+
|
|
539
|
+
```
|
|
540
|
+
"""
|
|
541
|
+
return max(1, min(y2 - y1, x2 - x1) // 2)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from hashlib import md5
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import copyfileobj
|
|
5
|
+
|
|
6
|
+
from requests import get
|
|
7
|
+
from tqdm.auto import tqdm
|
|
8
|
+
|
|
9
|
+
from supervision.assets.list import MEDIA_ASSETS, Assets
|
|
10
|
+
from supervision.utils.logger import _get_logger
|
|
11
|
+
|
|
12
|
+
logger = _get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_md5_hash_matching(filename: str | Path, original_md5_hash: str) -> bool:
|
|
16
|
+
"""
|
|
17
|
+
Check if the MD5 hash of a file matches the original hash.
|
|
18
|
+
|
|
19
|
+
Note: MD5 is used here for file integrity checking (detecting corruption),
|
|
20
|
+
not for cryptographic security purposes.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
filename: The path to the file to be checked.
|
|
24
|
+
original_md5_hash: The original MD5 hash to compare against.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True if the hashes match, False otherwise.
|
|
28
|
+
"""
|
|
29
|
+
if not os.path.exists(filename):
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
with open(filename, "rb") as file:
|
|
33
|
+
file_contents = file.read()
|
|
34
|
+
computed_md5_hash = md5(file_contents, usedforsecurity=False)
|
|
35
|
+
|
|
36
|
+
return computed_md5_hash.hexdigest() == original_md5_hash
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _download_asset(filename: str, destination: Path) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Download asset bytes to the target destination via a temporary file.
|
|
42
|
+
"""
|
|
43
|
+
response = get(
|
|
44
|
+
MEDIA_ASSETS[filename][0], stream=True, allow_redirects=True, timeout=30
|
|
45
|
+
)
|
|
46
|
+
response.raise_for_status()
|
|
47
|
+
|
|
48
|
+
file_size = int(response.headers.get("Content-Length", 0))
|
|
49
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
temp_path = destination.with_name(f"{destination.name}.part")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
with tqdm.wrapattr(
|
|
54
|
+
response.raw, "read", total=file_size, desc="", colour="#a351fb"
|
|
55
|
+
) as raw_resp:
|
|
56
|
+
with temp_path.open("wb") as file:
|
|
57
|
+
copyfileobj(raw_resp, file)
|
|
58
|
+
except Exception:
|
|
59
|
+
temp_path.unlink(missing_ok=True)
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
os.replace(temp_path, destination)
|
|
64
|
+
finally:
|
|
65
|
+
temp_path.unlink(missing_ok=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _download_verified_asset(
|
|
69
|
+
filename: str,
|
|
70
|
+
original_md5_hash: str,
|
|
71
|
+
destination: Path,
|
|
72
|
+
check_target: str | Path,
|
|
73
|
+
retry_on_mismatch: bool = True,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""
|
|
76
|
+
Download an asset and reject payloads whose MD5 does not match the catalog.
|
|
77
|
+
"""
|
|
78
|
+
_download_asset(filename, destination)
|
|
79
|
+
|
|
80
|
+
if is_md5_hash_matching(check_target, original_md5_hash):
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
logger.warning("File corrupted. Re-downloading...")
|
|
84
|
+
os.remove(check_target)
|
|
85
|
+
|
|
86
|
+
if retry_on_mismatch:
|
|
87
|
+
_download_verified_asset(
|
|
88
|
+
filename=filename,
|
|
89
|
+
original_md5_hash=original_md5_hash,
|
|
90
|
+
destination=destination,
|
|
91
|
+
check_target=check_target,
|
|
92
|
+
retry_on_mismatch=False,
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
raise ValueError(f"Downloaded asset {filename!r} failed MD5 verification.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def download_assets(
|
|
100
|
+
asset_name: Assets | str,
|
|
101
|
+
directory: str | Path | None = None,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Download a specified asset if it doesn't already exist or is corrupted.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
asset_name: The name or type of the asset to be downloaded.
|
|
108
|
+
directory: Optional output directory. Defaults to the current working
|
|
109
|
+
directory for backward compatibility.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The downloaded asset path. When `directory` is omitted, this preserves
|
|
113
|
+
the historical filename-only return value.
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
```pycon
|
|
117
|
+
>>> from supervision.assets import download_assets, ImageAssets, VideoAssets
|
|
118
|
+
>>> download_assets(VideoAssets.VEHICLES) # doctest: +SKIP
|
|
119
|
+
'vehicles.mp4'
|
|
120
|
+
|
|
121
|
+
>>> download_assets(ImageAssets.PEOPLE_WALKING) # doctest: +SKIP
|
|
122
|
+
'people-walking.jpg'
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
filename = asset_name.filename if isinstance(asset_name, Assets) else asset_name
|
|
128
|
+
if directory is None:
|
|
129
|
+
destination = Path.cwd() / filename
|
|
130
|
+
check_target: str | Path = filename
|
|
131
|
+
return_value = filename
|
|
132
|
+
else:
|
|
133
|
+
destination_directory = Path(directory).expanduser().resolve()
|
|
134
|
+
destination = destination_directory / filename
|
|
135
|
+
check_target = str(destination)
|
|
136
|
+
return_value = str(destination)
|
|
137
|
+
|
|
138
|
+
if filename in MEDIA_ASSETS:
|
|
139
|
+
original_md5_hash = MEDIA_ASSETS[filename][1]
|
|
140
|
+
if not Path(check_target).exists():
|
|
141
|
+
logger.info("Downloading %s assets", filename)
|
|
142
|
+
_download_verified_asset(
|
|
143
|
+
filename=filename,
|
|
144
|
+
original_md5_hash=original_md5_hash,
|
|
145
|
+
destination=destination,
|
|
146
|
+
check_target=check_target,
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
if not is_md5_hash_matching(check_target, original_md5_hash):
|
|
150
|
+
logger.warning("File corrupted. Re-downloading...")
|
|
151
|
+
os.remove(check_target)
|
|
152
|
+
_download_verified_asset(
|
|
153
|
+
filename=filename,
|
|
154
|
+
original_md5_hash=original_md5_hash,
|
|
155
|
+
destination=destination,
|
|
156
|
+
check_target=check_target,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
logger.info("%s asset download complete.", filename)
|
|
160
|
+
else:
|
|
161
|
+
valid_assets = ", ".join(filename for filename in MEDIA_ASSETS.keys())
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"Invalid asset. It should be one of the following: {valid_assets}."
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return return_value
|