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.
Files changed (96) hide show
  1. superiorvision/__init__.py +19 -0
  2. superiorvision-0.30.0.dev0.dist-info/METADATA +398 -0
  3. superiorvision-0.30.0.dev0.dist-info/RECORD +96 -0
  4. superiorvision-0.30.0.dev0.dist-info/WHEEL +5 -0
  5. superiorvision-0.30.0.dev0.dist-info/licenses/LICENSE.md +9 -0
  6. superiorvision-0.30.0.dev0.dist-info/top_level.txt +2 -0
  7. supervision/__init__.py +318 -0
  8. supervision/annotators/__init__.py +0 -0
  9. supervision/annotators/base.py +21 -0
  10. supervision/annotators/core.py +3551 -0
  11. supervision/annotators/utils.py +541 -0
  12. supervision/assets/__init__.py +4 -0
  13. supervision/assets/downloader.py +166 -0
  14. supervision/assets/list.py +84 -0
  15. supervision/classification/__init__.py +0 -0
  16. supervision/classification/core.py +216 -0
  17. supervision/config.py +13 -0
  18. supervision/dataset/__init__.py +0 -0
  19. supervision/dataset/core.py +1313 -0
  20. supervision/dataset/formats/__init__.py +0 -0
  21. supervision/dataset/formats/coco.py +708 -0
  22. supervision/dataset/formats/createml.py +331 -0
  23. supervision/dataset/formats/labelme.py +405 -0
  24. supervision/dataset/formats/pascal_voc.py +449 -0
  25. supervision/dataset/formats/yolo.py +502 -0
  26. supervision/dataset/utils.py +265 -0
  27. supervision/detection/__init__.py +0 -0
  28. supervision/detection/compact_mask.py +1927 -0
  29. supervision/detection/core.py +3864 -0
  30. supervision/detection/line_zone.py +924 -0
  31. supervision/detection/overlap_filter.py +426 -0
  32. supervision/detection/tensor_utils.py +1596 -0
  33. supervision/detection/tensor_views.py +48 -0
  34. supervision/detection/tools/__init__.py +0 -0
  35. supervision/detection/tools/csv_sink.py +242 -0
  36. supervision/detection/tools/inference_slicer.py +776 -0
  37. supervision/detection/tools/json_sink.py +215 -0
  38. supervision/detection/tools/polygon_zone.py +266 -0
  39. supervision/detection/tools/smoother.py +218 -0
  40. supervision/detection/tools/transformers.py +265 -0
  41. supervision/detection/utils/__init__.py +12 -0
  42. supervision/detection/utils/_typing.py +11 -0
  43. supervision/detection/utils/boxes.py +510 -0
  44. supervision/detection/utils/converters.py +830 -0
  45. supervision/detection/utils/internal.py +806 -0
  46. supervision/detection/utils/iou_and_nms.py +1606 -0
  47. supervision/detection/utils/masks.py +625 -0
  48. supervision/detection/utils/polygons.py +128 -0
  49. supervision/detection/utils/vlms.py +97 -0
  50. supervision/detection/vlm.py +945 -0
  51. supervision/draw/__init__.py +0 -0
  52. supervision/draw/base.py +14 -0
  53. supervision/draw/color.py +598 -0
  54. supervision/draw/utils.py +527 -0
  55. supervision/geometry/__init__.py +0 -0
  56. supervision/geometry/core.py +208 -0
  57. supervision/geometry/utils.py +54 -0
  58. supervision/key_points/__init__.py +0 -0
  59. supervision/key_points/annotators.py +997 -0
  60. supervision/key_points/core.py +1506 -0
  61. supervision/key_points/skeletons.py +2646 -0
  62. supervision/keypoint/__init__.py +13 -0
  63. supervision/keypoint/annotators.py +13 -0
  64. supervision/keypoint/core.py +8 -0
  65. supervision/metrics/__init__.py +40 -0
  66. supervision/metrics/core.py +74 -0
  67. supervision/metrics/detection.py +1632 -0
  68. supervision/metrics/f1_score.py +815 -0
  69. supervision/metrics/mean_average_precision.py +1723 -0
  70. supervision/metrics/mean_average_recall.py +734 -0
  71. supervision/metrics/precision.py +815 -0
  72. supervision/metrics/recall.py +774 -0
  73. supervision/metrics/utils/__init__.py +0 -0
  74. supervision/metrics/utils/matching.py +56 -0
  75. supervision/metrics/utils/object_size.py +256 -0
  76. supervision/metrics/utils/utils.py +9 -0
  77. supervision/py.typed +0 -0
  78. supervision/tracker/__init__.py +5 -0
  79. supervision/tracker/byte_tracker/__init__.py +0 -0
  80. supervision/tracker/byte_tracker/core.py +448 -0
  81. supervision/tracker/byte_tracker/kalman_filter.py +186 -0
  82. supervision/tracker/byte_tracker/matching.py +211 -0
  83. supervision/tracker/byte_tracker/single_object_track.py +194 -0
  84. supervision/tracker/byte_tracker/utils.py +34 -0
  85. supervision/utils/__init__.py +0 -0
  86. supervision/utils/conversion.py +223 -0
  87. supervision/utils/deprecate.py +54 -0
  88. supervision/utils/file.py +209 -0
  89. supervision/utils/image.py +988 -0
  90. supervision/utils/internal.py +209 -0
  91. supervision/utils/iterables.py +103 -0
  92. supervision/utils/logger.py +61 -0
  93. supervision/utils/notebook.py +124 -0
  94. supervision/utils/tensor.py +362 -0
  95. supervision/utils/video.py +533 -0
  96. 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,4 @@
1
+ from supervision.assets.downloader import download_assets
2
+ from supervision.assets.list import ImageAssets, VideoAssets
3
+
4
+ __all__ = ["ImageAssets", "VideoAssets", "download_assets"]
@@ -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