Compare commits

...
89 Commits
Author SHA1 Message Date
Jirka Borovec 49ecac0376 Releasing supervision 0.29.1 (#2350) 2026-06-23 21:53:06 +02:00
dependabot[bot] 9251893bd9 ⬆️ Bump jupyterlab from 4.5.7 to 4.5.9 in the uv group across 1 directory (#2348)
---
updated-dependencies:
- dependency-name: jupyterlab
  dependency-version: 4.5.9
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:17:44 +02:00
dependabot[bot] ccd7098f2f ⬆️ Bump actions/checkout from 6 to 7 in the github-actions group (#2347)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 08:55:18 +02:00
jirka 07d182be56 docs: refine Table of Contents formatting with collapsible details 2026-06-19 09:21:02 +02:00
Vikas sainiandpre-commit-ci[bot] cca3c14911 Docs: Refactor README formatting and fix pre-commit hook (#2316)
Updated README to improve formatting and structure.
Updated links, section headers, and example code paths in README.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 15:42:25 +02:00
44546a13f2 fix(vlm): handle malformed Gemini/Qwen model output without crashing (#2342)
Two ways the VLM parsers crashed on adversarial model output instead of
degrading gracefully (the contract they already honor for invalid JSON):

1. Gemini 2.5: a mask value that is not a 'data:image/png;base64,' string
   appended an empty mask and then 'continue'd, skipping the confidence
   handler at the bottom of the loop. The item's box was recorded but its
   confidence was not, so the confidence array ended up shorter than xyxy
   and Detections.from_vlm raised a shape ValueError. Replaced the
   'continue' with an if/else so the confidence handler always runs.

2. Gemini 2.0 / Gemini 2.5 / Qwen 2.5: valid JSON whose top level is not a
   list, or whose elements are not dicts (e.g. '[1, 2, 3]'), raised
   TypeError from the 'key not in item' membership test. Added a top-level
   list guard (Gemini 2.0/2.5; Qwen already had one) and a per-element
   dict guard so wrong-shaped JSON degrades to empty Detections.

Add regression tests for the mask/confidence alignment and for graceful
degradation across all three parsers.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 15:31:31 +02:00
4b60bbc9cc fix(dataset): stop Pascal VOC export from mutating source detections (#2341)
object_to_pascal_voc applied the 1-index offset in place (xyxy += 1).
Because Detections.__iter__ yields each row of xyxy as a view sharing
memory with detections.xyxy, detections_to_pascal_voc wrote the +1 shift
straight back into the caller's array. A single export shifted every box
by +1px; a second export compounded it, producing wrong XML. A single
export-then-reload happened to round-trip because from_pascal_voc
subtracts 1, which is why no test caught it.

Rebind to a new array (xyxy = xyxy + 1) instead of mutating in place.
On-disk output is unchanged; the source detections are left intact.

Add regression tests asserting object_to_pascal_voc does not mutate its
inputs and that two consecutive exports are identical and leave xyxy
unchanged.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 15:29:37 +02:00
6918d44190 docs(cookbooks): add Oriented Bounding Boxes cookbook (#2314)
- Added an end-to-end cookbook focused on oriented bounding boxes (OBB), demonstrating how OBB detections differ from axis-aligned boxes, why oriented overlap and NMS matter, how to use footprint-based filtering with `Detections.area`, and how to export annotations in YOLO OBB format.
- Added visual examples that clearly compare axis-aligned and oriented boxes, including a close-up showing how axis-aligned envelopes can significantly overestimate object footprints for angled objects.
- Improved the cookbook narrative to center on the practical consequences of using oriented versus axis-aligned boxes, including tighter localization and more appropriate NMS behavior for densely packed, rotated objects.
- Updated cookbook references, naming, dependency versions, image attribution, and changelog links to align with the released 0.29.0 documentation.
- Fixed the 0.29.0 changelog by removing a duplicate `Detections.area` entry and keeping the more accurate correctness-fix classification.

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 13:33:30 +02:00
a48532f6f5 perf(annotators): share mask painting and give HaloAnnotator the CompactMask path (#2339)
MaskAnnotator paints CompactMask detections into their bounding-box crop, but
HaloAnnotator never got that path: it materialized every mask full-frame, painted
via full-frame boolean indexing, and built its foreground mask as a 2M-element
Python list per frame. Extract the shared compact/dense painting into a single
_paint_masks_by_area helper used by both annotators. On a 1080p frame with 30
masks, HaloAnnotator on CompactMask runs about 4x faster; output is unchanged.

- Replace in-place `union` param with `collect_union: bool` return value;
  HaloAnnotator now captures the returned union array
- Add Google-style Args/Returns to `_paint_masks_by_area` docstring
- Fix HaloAnnotator.annotate() example (was maskless → no-op) and scene arg wording
- Add section comment above shared helper for discoverability
- Add union accumulation tests (dense + CompactMask paths via collect_union=True)
- Add test documenting out-of-bbox True-pixel divergence between compact and dense
- Add image-edge bbox test for CompactMask annotators
- Move helper tests into TestPaintMasksByArea and TestCompactMaskParity classes
- Rename test_annotate_with_empty_masks → test_annotate_with_all_false_mask

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 12:37:11 +02:00
11a586c133 perf(detection): compute mask IoU via matmul instead of an (N, M, H, W) intermediate (#2323)
- Reimplemented dense mask IoU/IoS computation using matrix multiplication on flattened masks instead of constructing a full `(N, M, H, W)` overlap tensor
- Significantly reduced memory usage and improved performance for large mask sets while preserving identical IoU/IoS results
- Added automatic precision handling for large masks to keep intersection and area counts numerically accurate
- Improved memory-limit handling and chunking logic to reflect actual matmul memory usage
- Added validation that compared mask sets share the same spatial dimensions
- Added validation for invalid mask tensor ranks and input shapes
- Added safe handling for empty-mask inputs
- Added warnings when inputs exceed the minimum memory footprint that chunking cannot reduce
- Fixed large-mask area calculations that could produce incorrect IoU values
- Suppressed spurious runtime warnings during valid matrix-multiplication computations
- Added regression coverage for correctness, chunking, rectangular matrices, empty inputs, shape mismatches, large-mask precision, and memory-limit edge cases

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 10:45:56 +02:00
31e84f7909 feat: add KeyPoints.with_nms() method (#2338)
* feat: add with_nms() method to KeyPoints class Derive axis-aligned bounding boxes from valid keypoints and delegate to box_non_max_suppression for filtering. Requires detection_confidence; supports class-aware and class-agnostic modes.

- Add overlap_metric: OverlapMetric = OverlapMetric.IOU param to KeyPoints.with_nms() for API parity with Detections.with_nms()
- Integrate self.visible into keypoint validity: valid = valid & self.visible when visible is not None
- Pass overlap_metric through to box_non_max_suppression
- Fix docstring: add Defaults to for threshold/class_agnostic, threshold range constraint, overlap_metric arg
- Add UnReleased changelog entry
- Add 5 new test cases: all-zero-skeleton-passes-through, visible-mask-excludes-keypoints-from-bbox, single-valid-keypoint-zero-area-bbox, threshold boundary 0.0/1.0
- Add missing raises test: no-detection-confidence-class-agnostic
- Update `with_nms` method to raise `ValueError` instead of `AssertionError` for missing required fields (`detection_confidence`, `class_id` when `class_agnostic=False`).
- Adjust corresponding test to check for `ValueError` with match argument.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 10:42:43 +02:00
jirka 7e765eb054 docs: reformat entries in changelog for improved readability 2026-06-17 22:41:17 +02:00
393ff52954 fix(json_sink): serialize NumPy scalars in custom_data (#2334)
- Fixed `JSONSink` to correctly serialize NumPy scalar values stored in `custom_data`
- Added JSON serialization support for NumPy arrays by converting them to standard JSON-compatible lists
- Prevented buffered export failures caused by non-serializable NumPy values during `json.dump`
- Added regression coverage for multiple NumPy scalar types, NumPy arrays, and unsupported-object error handling
- Updated documentation and changelog to describe NumPy serialization behavior in `JSONSink`

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 22:27:03 +02:00
c9962c9262 fix(smoother): handle detections without confidence (#2333)
- Fixed `DetectionsSmoother` to work with detections that have no confidence scores
- Changed confidence aggregation to average only the confidence values that are present, leaving confidence as `None` when no values exist
- Fixed smoothing of mixed-confidence tracks (some frames with confidence, some without) while preserving available confidence information
- Fixed crashes when merging smoothed tracks that disagree on confidence availability by normalizing confidence fields before merge
- Added regression coverage for no-confidence, mixed-confidence, multi-track, full-window, and tracker-id-missing scenarios
- Updated documentation and changelog to reflect the new confidence-handling behavior

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 21:16:43 +02:00
04b6d45ea2 fix(polygons): honor target point count in approximate_polygon (#2332)
- Fixed `approximate_polygon` so the returned polygon respects the requested point-count reduction target instead of returning an over-budget approximation
- Preserved the minimum valid polygon size (3 points) while simplifying polygons
- Added validation requiring `epsilon_step > 0` to prevent invalid and non-terminating configurations
- Added regression tests covering target-point budgeting, polygon validity, invalid percentage values, and invalid `epsilon_step` values
- Improved documentation with clearer behavior guarantees, validation rules, examples, and edge-case explanations

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 21:11:26 +02:00
2854932965 fix(metrics): count false positives on background images and absent classes (#2331)
- Fixed Precision and F1Score metrics to correctly count false positives on images with no ground-truth objects
- Fixed Precision and F1Score metrics to include prediction-only classes when computing class statistics and confusion-matrix aggregates
- Corrected MICRO and MACRO averaging so false positives from absent classes affect the score as expected
- Preserved WEIGHTED averaging behavior by weighting only classes with ground-truth support
- Added safeguards for all-background evaluation batches, returning stable zero-valued weighted scores when no support exists
- Fixed handling of predictions with `class_id=None` in background-only evaluation paths
- Added regression coverage for background-image false positives, absent-class predictions, averaging modes, and zero-support edge cases
- Updated metric documentation and result metadata to reflect that tracked classes now include prediction-only classes

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 19:46:10 +02:00
bcc785fbb5 fix(ci): clear latest alias before deploying docs to release/latest (#2340)
mike deploy fails when target name exists as an alias; delete it first.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-17 19:36:25 +02:00
c0df72b84a perf: vectorize mask_to_xyxy and KeyPoints.as_detections (bit-identical) (#2330)
- Vectorized `mask_to_xyxy` by replacing per-mask pixel scans with batched occupancy-profile reductions, yielding large speedups while preserving identical outputs
- Added direct test coverage for `mask_to_xyxy`, including edge and corner-pixel mask cases
- Vectorized `KeyPoints.as_detections` by computing all bounding boxes in a single batch operation instead of constructing and merging per-skeleton `Detections`
- Vectorized keypoint-confidence aggregation in `KeyPoints.as_detections` using NumPy reductions
- Preserved exact output behavior for bounding boxes, confidence values, class IDs, metadata, selected-keypoint subsets, and missing-keypoint handling
- Added regression coverage for selected-keypoint indexing, mixed valid/invalid skeleton batches, detection-confidence paths, and confidence aggregation behavior
- Fixed strict mypy typing issues introduced by the vectorized implementations
- Improved documentation for `mask_to_xyxy` and `selected_keypoint_indices` behavior

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 18:58:47 +02:00
15cf80abac fix: accept deprecated confidence kwarg in KeyPoints() constructor (#2335)
- Move conflict ValueError before warn_deprecated so stray warning not
  emitted on invalid calls (both kwargs passed)
- Add backtick-quoted version numbers to deprecation message for style
  consistency with other warn_deprecated calls in the same class
- Make ValueError message actionable: name deprecated param and remedy
- Add Google-style docstring to __init__: Args + Raises sections
- Fix pre-existing mypy error in detection/core.py (_merge_obb_corners
  had bare np.ndarray without type args)
- Add docstrings to all test methods in TestDeprecatedConfidenceConstructor
- Parametrize ValueError test with both kwarg orderings (confidence-first
  and keypoint-confidence-first) to document order-independence
- Add test_constructor_normal_keypoint_confidence_path: confirms custom
  __init__ initialises all fields and emits no warning on normal path
- Add test_constructor_confidence_none_does_not_warn: guards the
  `if confidence is not None` branch against guard-condition typos
- Add test_constructor_data_none_defaults_to_empty_dict: tests the
  None->{} normalisation path introduced by the custom __init__
- Add test_keypoints_init_covers_all_dataclass_fields: drift guard that
  asserts dataclasses.fields(KeyPoints) == __init__ params, catching
  future field additions that forget to update __init__

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 16:15:34 +02:00
Jirka Borovec b9c24ddafd docs(changelog): backfill missing 0.29.0 entries and fix duplicate (#2329) 2026-06-16 12:16:38 +02:00
c2490e6015 chore: preserve all polygons when exporting multi-part masks to COCO (#2322)
* test(coco): strengthen multi-polygon export test coverage
* docs(coco): document segmentation shape and iscrowd routing in docstring
* test(coco): parametrize multipart polygon tests

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-16 00:43:19 +02:00
Jirka Borovec a914a00ab8 docs: fix broken links in changelog file (#2328) 2026-06-16 00:16:00 +02:00
dependabot[bot] d3a0a84dd5 ⬆️ Bump the uv group across 1 directory with 2 updates (#2326)
Bumps the uv group with 2 updates in the / directory: [cryptography](https://github.com/pyca/cryptography) and [tornado](https://github.com/tornadoweb/tornado).


Updates `cryptography` from 46.0.7 to 48.0.1
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.1)

Updates `tornado` from 6.5.6 to 6.5.7
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.6...v6.5.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 23:45:40 +02:00
SkalskiP 1e551ec9c0 releasing 0.29.0 2026-06-15 23:36:28 +02:00
SkalskiP 1fa735d0d9 releasing 0.29.0rc1 2026-06-15 23:24:53 +02:00
Piotr Skalski 62a24d7960 Unify deprecation policy: enforce 3 minor release minimum window (#2324)
* Unify deprecation policy: 3 minor releases minimum

* Add 0.29.0 changelog entry

* Bump version to 0.29.0
2026-06-15 23:04:55 +02:00
Piotr Skalski 3d07099cf9 Fix ellipse annotators to draw level-by-level instead of point-by-point (#2325) 2026-06-15 22:51:06 +02:00
483e3e9335 fix(detection): make oriented_box_iou_batch exact and gate non-overlapping pairs (#2317)
* fix(detection): make oriented_box_iou_batch exact and gate non-overlapping pairs
* test(detection): cover gate passthrough and invalid metric in oriented IoU
* test(detection): add edge-case OBB coverage for oriented_box_iou_batch

- Parametrize test_self_comparison_is_symmetric_with_unit_diagonal with N=1
  and N=2 (R6: N=1 path exercising triangular-mirror with single (0,0) pair)
- Add test_degenerate_boxes_score_zero: collapsed, collinear, zero-area
  self-comparison → 0.0 (documents divergence from box_iou_batch semantics)
- Add test_empty_input_returns_correct_shape: (0,M), (N,0), (0,0) variants
  exercising early-return path at TestOrientedBoxIouBatch level
- Add test_invalid_shape_raises_value_error: 3-D wrong inner dims, 2-D wrong
  columns, 1-D input — matches exact error messages from implementation
- Fix test_is_invariant_to_canvas_transforms: remove stale pixel-IoU /
  canvas reference; tighten tolerances to rtol=1e-5 / atol=1e-7 (exact
  arithmetic no longer has quantization noise)
- Add Raises: section to oriented_box_iou_batch documenting all four
  ValueError paths (3-D wrong inner dims, 2-D wrong columns, wrong ndim,
  unsupported overlap_metric)
- Add Note: block documenting is_self_comparison identity-based contract
  (disabled by upstream .copy()), convexity precondition, and NaN/Inf
  silent-zero behavior
- Align Returns: style with box_iou_batch sibling (named entry semantics)
- Add Examples: doctest block (doctest: +ELLIPSIS for IoU value)
- Strengthen np.clip comment: explicitly mark as load-bearing; explains
  that cv2 intersection in float32 can exceed float64 area by ~25 ULP
- Add one-line comment at NMS caller: is_self_comparison trigger context
- Add Args:/Returns: to _polygon_areas and _aabb_envelopes private helpers
- Expand _overlapping_envelope_pairs docstring: Note (correctness guarantee
  — not an approximation), Args: and Returns: blocks

* refactor(detection): fuse _overlapping_envelope_pairs to halve peak memory
* test(detection): fix degenerate-collinear OBB NMM expectation for exact IoU

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-15 20:54:06 +02:00
e0e1abd865 fix(obb): NMM now computes geometric union via min-area rotated rect (#2312)
* fix(detection): OBB NMM now computes geometric union via min-area rotated rect

Previously with_nmm for OBB detections kept the winner's OBB geometry unchanged
(only confidence was merged), making it inconsistent with AABB NMM which expands
to the union envelope. Now computes cv2.minAreaRect over all N×4 corners from
the merge group — the MARC degenerates to the axis-aligned union for zero-rotation
OBBs, preserving full consistency with AABB NMM.

- Replace winner-OBB xyxy patch with MARC of all merged corners
- Update ORIENTED_BOX_COORDINATES in data to reflect merged geometry
- Rename test to reflect new expected behaviour (union, not winner AABB)
- Add consistency test asserting axis-aligned OBB NMM == AABB NMM xyxy

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* code(detection): defensive reshape + clarify xyxy-override intent in OBB NMM

- Add .reshape(4, 2) to OBB corner extraction loop so flat-adjacent shapes are normalised before cv2.minAreaRect
- Add inline comment at xyxy override: OBB groups intentionally discard AABB-union xyxy from reduce() to stay consistent with MARC corners

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* test(detection): expand OBB NMM coverage — rotated, 3-group, passthrough, class-agnostic, IOS, flat-format

- Add test_rotated_obb_merge_produces_marc: two 45-degree OBBs, assert MARC encompasses all corners
- Add test_three_detection_group_merge: three overlapping OBBs, assert merged len==1 and envelope spans all inputs
- Add test_single_detection_passthrough_preserves_obb: non-overlapping OBB passes through unchanged
- Add test_class_agnostic_obb_merge: class_agnostic=True merges cross-class OBBs
- Add test_overlap_metric_ios_obb_merge: IOS metric merges contained OBBs
- Add test_flat_n8_obb_format_raises_value_error: documents that (N,8) flat format is unsupported (canonical is (N,4,2))
- Import OverlapMetric for IOS test

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* docs(detection): document OBB NMM MARC semantics in with_nmm + changelog entry

- Add Note section to with_nmm docstring explaining MARC behavior: union for zero-rotation OBBs, MARC for rotated OBBs, single-group passthrough
- Add changelog UnReleased entry for #2312 behavioral change

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* fix(detection): OBB NMM uses winner's angle instead of free MARC to avoid overshoot

cv2.minAreaRect picks a 45-degree rect for diagonal staircase arrangements of
axis-aligned boxes, producing an AABB like [-10,-10,54,54] that extends outside
every input. Fix: lock merged OBB to winner's angle by projecting all corners
onto the winner's principal axes (from first edge vector), computing AABB there,
and back-rotating — for zero-rotation inputs this gives exactly the axis-aligned
union; for same-angle groups the result equals the prior MARC.

- Remove cv2 dependency from the OBB merge block (pure numpy now)
- Add test_diagonal_staircase_obb_merge_stays_within_union regression test
- Rename test to reflect winner-angle semantics

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* fix(detection): fix changelog wording + add explicit OBB shape guard in NMM

- docs/changelog.md: replace stale MARC/cv2.minAreaRect wording with
  winner's-angle description matching the actual implementation
- core.py: validate ORIENTED_BOX_COORDINATES shape is (N, 4, 2) at the
  start of the OBB merge block; raises ValueError("corners must have
  shape (N, 4, 2)") for flat (N, 8) input instead of silently mis-reshaping

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* refactor: parametrize OBB NMM tests in TestDetectionsWithNmm Consolidate 7 individual OBB NMM test methods into a single parametrized test_obb_nmm_merge with explicit expected_confidence and expected_corners assertions. Add cases for mixed-angle merges, multiple merge groups, and degenerate collinear OBBs. Add standalone test_obb_nmm_empty_detections for empty inputs.

* refactor(tests): simplify OBB NMM test cases by replacing `np.array` usage with nested lists

- Update test parameters to use plain Python lists instead of `numpy` arrays for corner definitions.
- Adjust the `_make_obb_detections` setup to preprocess corners into `numpy` arrays.
- Add explicit conversion of `expected_corners` to `numpy` arrays in the assertions.

* feat: add xyxyxyxy_to_xyxy utility for OBB-to-AABB conversion Vectorized conversion of oriented bounding box corners (N, 4, 2) to axis-aligned bounding boxes (N, 4). Used internally in with_nmm and exposed via top-level import.

* deprecate: mark merge_inner_detections_objects for removal in 0.34.0 Function is unused dead code with no external callers. Decorator emits FutureWarning while preserving existing behavior.

* refactor: extract _merge_obb_corners and _merge_detection_group from with_nmm Replace inline OBB post-processing and reduce-based merging with two private helpers using single-pass area-weighted confidence. Deprecate merge_inner_detection_object_pair and merge_inner_detections_objects_without_iou (0.29.0 -> 0.34.0). Rename TestDetectionsWithNmm -> TestDetectionsWithNMM and expand TestMergeDetectionGroup to assert all output fields via expected_detections.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: SkalskiP <piotr.skalski92@gmail.com>
2026-06-15 16:23:31 +02:00
117a9baab8 fix(coco): preserve segmentation in as_coco() round-trip (#2321)
* fix(coco): preserve segmentation in as_coco() round-trip (#2285)
* fix(coco): address Copilot review — normalize flat polygon lists, tighten test assertions
* fix(coco): type annotation, config constant, merge-compat, lint fixes
* test(coco): add two-polygon and RLE round-trip tests
* fix(tests): update expected_results for always-store coco_raw_segmentation
* fix(coco): use COCO_RAW_SEGMENTATION key in export fallback
* fix(coco): adjust mask_bool assignment to ensure proper usage in iscrowd logic
* test: simplify COCO segmentation regression cases
* fix(coco): add type annotation for raw_segs to ensure clarity
* test(coco): improve docstring and add comments for segmentation round-trip validation

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-06-15 15:00:27 +02:00
dependabot[bot] 497f336d13 ⬆️ Bump tornado from 6.5.5 to 6.5.6 in the uv group across 1 directory (#2315)
Bumps the uv group with 1 update in the / directory: [tornado](https://github.com/tornadoweb/tornado).


Updates `tornado` from 6.5.5 to 6.5.6
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.5...v6.5.6)

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.6
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 01:03:43 +02:00
jirka ef8ba557a1 releasing 0.29.0.rc0 2026-06-11 18:37:56 +02:00
caf060521d refactor: enrich KeyPoints with visibility mask, split confidence fields, and redesign uncertainty annotator (#2286)
- Added `KeyPoints.visible` mask support for per-keypoint visibility
- Split confidence into `keypoint_confidence` and `detection_confidence`
- Kept legacy `KeyPoints.confidence` as deprecated forwarding alias
- Updated `KeyPoints` slicing/filtering to preserve visibility and confidence fields
- Fixed `KeyPoints.__getitem__` row-index normalization for NumPy scalar, 0-D array, and boolean indexing
- Fixed `detection_confidence` indexing to use normalized row indices
- Updated `VertexAnnotator` to skip invisible keypoints
- Updated `EdgeAnnotator` to skip invisible keypoints and edges
- Added per-class skeleton support to `EdgeAnnotator`
- Added multi-skeleton support to `VertexLabelAnnotator`
- Added label validation for `VertexLabelAnnotator`
- Added color-list length validation for keypoint annotators
- Added `VertexEllipseAreaAnnotator`
- Added `VertexEllipseOutlineAnnotator`
- Added `VertexEllipseHaloAnnotator`
- Kept/exported `VertexEllipseAnnotator` alongside the new ellipse variants
- Standardized keypoint annotator docstrings and executable examples
- Added/updated `validate_detection_confidence` and `validate_visible`
- Removed/cleaned old keypoint validator shims
- Added regression tests for visibility, confidence fields, multi-skeleton behavior, and indexing edge cases
- Updated helpers and RF-DETR/keypoint tests for the new confidence/visibility model
- Added API design principles to contributing docs
- Ignored local multi-skeleton test script in `.gitignore`

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-06-11 18:34:33 +02:00
Jirka Borovec b8ebc14489 docs(agents): refine AGENTS.md (#2308) 2026-06-10 22:25:31 +02:00
8a4063086f chore: update YOLO OBB annotation export support (#2302)
* fix(yolo): validate OBB corner shape and test is_obb=False passthrough
* docs(config): add attribute docstring to ORIENTED_BOX_COORDINATES

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-10 15:28:42 +02:00
a549f44792 Add OBB support to ConfusionMatrix via MetricTarget (#2247)
* feat(metrics): OBB support in ConfusionMatrix, MASKS guard, shape validation
* refactor(tests): extract reusable ConfusionMatrix MASKS test cases
* refine(metrics): exclude metric_target from ConfusionMatrix.__eq__
* refactor(metrics): consolidate _assert_supported_target call sites
* fix(metrics): normalise OBB (N,4,2) shape explicitly in detections_to_tensor
* test(metrics): add OBB wrong-target-cols case to validate_input_tensors
* test(metrics): add OBB edge-case parametrize cases to detections_to_tensor
* test(metrics): replace validate_input_tensors with _validate_input_tensors in test_detection
* refactor(metrics): make ConfusionMatrix unhashable
* fix(metrics): add shape validation to evaluate_detection_batch
* fix(metrics): cast class_id to float32 in detections_to_tensor
* test(metrics): fix test_evaluate_detection_batch targets shape

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-10 13:29:33 +02:00
cb9d3dccb8 chore(deprecation): use TargetMode enum to silence deprecate FutureWarnings on import (#2304)
* fix(deprecation): use TargetMode enum to silence deprecate FutureWarnings on import
* chore: bump `pydeprecate` dependency to v0.9 and update lockfile

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Codex <codex@openai.com>
2026-06-09 23:40:16 +02:00
ace3ebd03e fix(detection): make Detections.area OBB-aware (#2306)
* fix(detection): make Detections.area OBB-aware

When detections carry ORIENTED_BOX_COORDINATES (the four xyxyxyxy corners),
the area property returned the area of the derived axis-aligned bounding
box instead of the rotated body. The AABB overestimates by up to ~2x for a
45-degree rotation, which silently miscomputes downstream values — most
visibly the area-sorted z-ordering inside MaskAnnotator / HaloAnnotator,
and any user code that filters detections by area.

* docs(detection): use string literal in Detections.area doctest
* test(detection): single-line docstring on test_uses_oriented_box_corners_when_present
* fix(detection): validate (N,4,2) shape of OBB data field in Detections.area
* perf(detection): replace np.roll pair with cross-diagonal shoelace in Detections.area
* perf(detection): cast x/y slices to float64 instead of full corners array
* refactor(detection): extract obb_polygon_area to detection/utils/boxes.py
* test(detection): add test_raises_on_malformed_obb_coordinates_shape
* test(detection): assert per-branch dtype contract for Detections.area
* docs(detection): document OBB dispatch contract and dtype in Detections.area docstring

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-09 22:20:34 +02:00
Jirka Borovecandpre-commit-ci[bot] 9faa4f6133 chore: update mdformat hook arguments to disable wrapping (#2307)
* chore: update `mdformat` hook arguments to disable wrapping
* fix(pre_commit): 🎨 auto format pre-commit hooks
* Apply suggestions from code review

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-09 16:15:05 +02:00
97f4951f08 docs(contributing): formalise test conventions and doctest guidelines (#2305)
* formalise test conventions and doctest guidelines
* strengthen doctest rule and add syntax guide
* relax doctest rule to strong recommendation
* fix line-length ref and heading depth rules

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-09 15:51:18 +02:00
3b485f719a refine(detection): make with_nms and with_nmm OBB-aware (#2303)
* fix(detection): make with_nms and with_nmm OBB-aware
* perf(detection): bound rasterization canvas in oriented_box_iou_batch
* fix(detection): with_nmm OBB/AABB xyxy fix; 3-path dispatch docs
* fix(detection): shape validation, NMM assert, docstring/Examples
* test(detection): with_nmm fallback, OBB AABB fix, boundary and IOS tests
* docs(changelog): document OBB with_nms/nmm behaviour change for #2303
* docs(detection): convert OBB NMS/NMM examples to doctests
* refactor(test): group OBB NMS/NMM tests into classes
* refactor(test): merge duplicate NMS class-awareness tests via parametrize
* refactor(test): parametrize overlap-metric and dispatch tests

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-06-09 13:11:09 +02:00
c92458b70e fix(dataset): preserve OBB rotation in DetectionDataset.as_yolo (#2289)
DetectionDataset.from_yolo accepts is_obb=True and stores the four
corners in detections.data["xyxyxyxy"], but DetectionDataset.as_yolo
has no matching option and only reads xyxy/mask. The standard
from_yolo -> split -> as_yolo flow silently writes 5-token
axis-aligned lines, and re-loading the saved file with is_obb=True
crashes the validator because it expects 9 tokens.

Add is_obb to as_yolo, save_yolo_annotations, and
detections_to_yolo_annotations. When True, the four corners from
data["xyxyxyxy"] are serialized via the existing object_to_yolo
polygon path. Masks are ignored, mirroring from_yolo(is_obb=True)
semantics. A missing xyxyxyxy raises ValueError early.

- Add UserWarning in as_yolo when area/approx params passed with is_obb=True (silently ignored)
- Add UserWarning in detections_to_yolo_annotations when mask present + is_obb=True
- Update ValueError message to include expected shape (N, 4, 2) for manual callers
- Add Google-style docstrings to detections_to_yolo_annotations and save_yolo_annotations
- Add test: N>1 OBB detections per image (corner indexing via data-dict slicing)
- Add test: dataset round-trip with background-only (no label file) image
- Add test: as_yolo() without is_obb=True on OBB-loaded dataset emits 5-token lines
- Replace all tempfile.TemporaryDirectory / os.path.join / os.makedirs
  with pytest tmp_path and pathlib Path
- Merge 3 load-mask tests into parametrized test_load_yolo_annotations_mask_behaviour
  (obb-no-mask, obb-force_masks-ignored, segmentation-produces-mask)
- Merge token-count tests into parametrized test_dataset_as_yolo_obb_output_token_count
  (obb-save-nine-tokens, default-save-five-tokens)
- Split corner accuracy into dedicated test_dataset_as_yolo_obb_round_trip_corner_accuracy
- Drop import os and import tempfile

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-08 15:31:04 +02:00
dependabot[bot] eef8c96d95 ⬆️ Bump the github-actions group with 2 updates (#2300)
Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `astral-sh/setup-uv` from 8.1.0 to 8.2.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/08807647e7069bb48b6ef5acd8ec9567f424441b...fac544c07dec837d0ccb6301d7b5580bf5edae39)

Updates `codecov/codecov-action` from 6 to 7
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: codecov/codecov-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-08 13:45:31 +02:00
b82d6f95be fix: normalize file extension filters (#2298)
* fix: normalize file extension filters
* fix(file): add is_file and empty-ext guards
* test(file): collapse extension tests into parametrized form
* fix: match multi-part extension suffix tails

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Codex <codex@openai.com>
2026-06-08 13:41:25 +02:00
b18a30ea1a fix: support grayscale letterbox images (#2297)
- Delete letterbox_image alpha block (lines 266-270): block wrote to
  caller's input array, not image_with_borders; used wrong coordinate
  system (resized vs original dims); redundant since cv2.copyMakeBorder
  already sets alpha=0 in padded regions when given a 3-element value
- Add test_letterbox_image_for_rgba_opencv_image: asserts padded alpha=0,
  interior alpha preserved, and input array not mutated after call
- Update letterbox_image docstring: image param lists (H,W,3)/(H,W,4)/
  (H,W)/PIL shapes; add Note on BGRA alpha behavior; add grayscale doctest

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-07 11:06:21 -06:00
35006d7342 fix: sort YOLO class names by numeric keys (#2296)
* fix: sort yolo class names by numeric keys
* fix(yolo): reject double-hyphen keys in _is_int_like predicate
* fix(yolo): raise ValueError for mixed numeric/non-numeric names keys
* test(yolo): parametrize _extract_class_names with all key-type cases
* refine(yolo): rename lambda param key→k to avoid shadowing outer variable
* docs(yolo): comment bool guard in _is_int_like
* docs(yolo): docstring for _extract_class_names

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-07 10:54:43 -06:00
3410d92daa refactor: privatize validation helpers (#2294)
* refactor: privatize validation helpers
* fix: correct error message in _validate_keypoint_confidence

- Fix f"({n, m})" -> f"({n}, {m})" (set literal -> proper shape string)
- Fix error message "1D" -> "2D" to match actual array dimensionality

* fix: correct error message in _validate_xy

- Fix f"({n, m},)" -> f"({n}, {m}, 2) or ({n}, {m}, 3)"
- Fix error message "2D" -> "3D" to match actual array dimensionality

* bump pydeprecate version to >=0.9,<0.10 and fix type ignore tag for decorator
* chore: extend deprecation removal timeline to 0.32.0 across validators

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-06 13:15:54 -06:00
Piotr SkalskiandCursor 5a8e211a04 Docs/api design principles contributing (#2292)
* fix: resolve ruff line-length and mypy union syntax errors in keypoints module

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: add API design principles to CONTRIBUTING.md

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 18:09:09 -06:00
Piotr Skalski 1e8a48559b Revert "feat: store keypoints on detections (#2290)" (#2291)
This reverts commit e03111e67c.
2026-06-04 11:43:48 -06:00
e03111e67c feat: store keypoints on detections (#2290)
* docs: add API design principles to contribution guidelines
* feat: store keypoints on detections
* test: add "keypoints" to internal test cases
* docs: document keypoints field semantics and add docstring + dtype guard
* refactor: deduplicate keypoints shape check and add K-mismatch guard
* docs: clarify Detections.keypoints vs sv.KeyPoints decision rule and add KeyPoints filter example
* feat(key_points): add KeyPoints.from_detections() cross-container adapter
* test: extend keypoints test coverage — dtype guard, __eq__, dynamic field sets
* test: expand keypoints test coverage (M7/M8)
* fix: correct validate_xy expected_shape and dimensionality message
* fix: add ndim guard in KeyPoints.from_detections
* test: add unit tests for KeyPoints.from_detections adapter
* refactor: fix class_id cast and import formatting in key_points

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-04 09:09:37 -06:00
Lourdhu RajuandClaude Opus 4.7 0c67942d32 Refactor/polygon zone doctest (#2264)
Replaces the static python code block with a pycon doctest using
primitive numpy inputs, so the example is now executed and verified
by `pytest --doctest-modules`. Removes the external YOLO, ByteTrack,
and cv2 dependencies from the example.

Follows the same pattern as PR #2207 (LineZone). Part of the
documentation-as-tests effort tracked in #2106.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:33:46 +02:00
7d2259669b Fix OBB IoU for non-square canvases (#2282)
* Fix OBB IoU canvas dimensions
* test(metrics): fix OBB metric parametrize — remove MAP, add smoke test
* test(metrics): clarify MAP OBB test is smoke test only
* test(iou): parametrize OBB scaling invariance test with y-dominant case
* fix(iou): add empty-input guard and x/y axis docs to oriented_box_iou_batch

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-01 09:20:38 +02:00
fb02e5c959 docs: clarify MaskAnnotator mask requirements (#2279)
* docs: clarify MaskAnnotator mask requirements
* Potential fix for pull request finding

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-29 10:24:45 +02:00
918b6139ef feat(keypoints): add keypoint conversion bridge (#2277)
* feat(keypoints): add RF-DETR keypoint conversion bridge
* feat: add RF-DETR keypoint uncertainty visualization
* refactor(keypoints): remove deprecated RF-DETR keypoint conversion logic
* refactor: improve internal handling of keypoint data and detection utilities
* fix(keypoints): handle non-finite confidence values and empty keypoint arrays
* docs(keypoint): add VertexEllipseAnnotator to annotators docs
* docs(keypoints): add Example block to KeyPoints.from_rfdetr docstring
* docs(keypoints): document source_shape HW ordering in from_rfdetr
* feat(keypoints): validate precision_cholesky shape in from_rfdetr
* refine(keypoints): add warning log for silent precision matrix failures
* fix(keypoints): fix mypy type errors in VertexEllipseAnnotator
* test(keypoints): add confidence_threshold filter test for VertexEllipseAnnotator
* test(keypoints): add max_axis_length cap and constructor validator tests
* docs(keypoints): document max_axis_length=None risk in VertexEllipseAnnotator
* docs(keypoints): add Raises section to VertexEllipseAnnotator.annotate docstring
* docs(keypoints): document confidence scale convention in from_rfdetr
* docs(keypoints): note from_rfdetr input convention in KeyPoints class docstring
* fix(types): improve type hinting for internal and keypoints modules
* fix(keypoints): handle None class_id in from_rfdetr conversion

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-28 10:36:41 +02:00
81218c5f1b fix(coco): emit 1-indexed category_id in COCO export (#2276)
* fix): emit 1-indexed category_id in COCO export
* test): add regression and guard tests for 1-indexed category_id
* docs): warn in coco_annotations_to_detections that remap is required

---------

Co-authored-by: madhavcodez <madhavcodez@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-27 10:40:32 +02:00
2c3a2ef6f9 fix(detection): preserve class_name string dtype on empty Detections.from_inference (#2270)
* fix): preserve class_name string dtype on empty Detections.from_inference
* test): fix stale float64 dtype expectation in test_process_roboflow_result
* docs): document data[class_name] contract in from_inference Returns
* fix): set string-dtype class_name on empty from_ultralytics and from_vlm paths
* test): strengthen from_inference empty-path dtype test
* test): cover SDK .dict() path for empty predictions in from_inference
* docs): add docstring to process_roboflow_result
* test): use dtype.kind comparison for empty/non-empty class_name
* docs): update example in `process_roboflow_result` docstring

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-27 10:39:49 +02:00
2fdb970430 fix(annotators): avoid divide-by-zero in HeatMapAnnotator on empty detections (#2269)
When HeatMapAnnotator is called on a fresh annotator with empty detections
(common on the first frames of a video before the model produces any output),
self.heat_mask is all zeros, so temp / temp.max() raises
RuntimeWarning: invalid value encountered in divide and produces nan/inf
in-flight. Skip the normalisation when temp.max() == 0; the resulting
all-zero heat mask filters out via the > 0 check below, so the scene is
returned unchanged.

- Fix `kernel_size: int = 25` → `int | None = 25`; document None disables blur
- Add Note to annotate docstring: empty detections returns scene unchanged
- Add happy path test: single detection must produce visible heat output
- Add stateful tests: empty→real and real→empty sequence coverage

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-26 22:27:07 +02:00
dependabot[bot] fb2dec9775 ⬆️ Update pydeprecate requirement from <0.8,>=0.7 to >=0.7,<0.9 (#2268)
Updates the requirements on [pydeprecate](https://github.com/Borda/pyDeprecate) to permit the latest version.
- [Release notes](https://github.com/Borda/pyDeprecate/releases)
- [Changelog](https://github.com/Borda/pyDeprecate/blob/main/CHANGELOG.md)
- [Commits](https://github.com/Borda/pyDeprecate/compare/v0.7.0...v0.8.0)

---
updated-dependencies:
- dependency-name: pydeprecate
  dependency-version: 0.8.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-25 11:30:37 +02:00
befdb7c661 fix(dataset): make COCO annotation/image ids chainable across splits (#2267)
* fix(dataset): make COCO annotation/image ids chainable across splits (#768)

Exporting train/valid/test splits with DetectionDataset.as_coco
previously restarted image_id and annotation_id at 1 for every split,
producing three JSON files whose ids collided and could not be safely
merged into a single COCO collection.

Adds optional starting_image_id and starting_annotation_id parameters
to save_coco_annotations and DetectionDataset.as_coco (default 1 to
preserve existing behavior) and returns a (next_image_id,
next_annotation_id) tuple so callers can feed the result of one
export straight into the next:

    next_image, next_ann = train.as_coco(annotations_path="train.json")
    next_image, next_ann = valid.as_coco(
        annotations_path="valid.json",
        starting_image_id=next_image,
        starting_annotation_id=next_ann,
    )
    test.as_coco(
        annotations_path="test.json",
        starting_image_id=next_image,
        starting_annotation_id=next_ann,
    )

The images-only branch of as_coco (annotations_path=None) round-trips
the starting ids unchanged so chaining still works there.

Adds 4 regression tests covering defaults, custom starting ids,
end-to-end three-split chaining with global uniqueness assertions,
and the images-only round-trip.

* docs: address review polish on COCO id-chaining
* fix(dataset): align save_coco_annotations approximation_percentage default to 0.0
* docs(dataset): add one-line summary to save_coco_annotations docstring
* docs: add changelog entry for COCO id chaining (PR #2267)
* docs(dataset): document file_name uniqueness limitation in save_coco_annotations
* feat(dataset): validate starting_image_id and starting_annotation_id >= 1
* docs(dataset): add Example section to save_coco_annotations docstring
* docs(dataset): unpack final as_coco return value in chaining example
* test(dataset): add COCO chaining tests and fix test helper for zero detections

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 21:41:42 +02:00
cb259065a5 Add from_coco regression coverage for multi-segment COCO masks (#2258)
* test: cover from_coco multi-segment masks
* test: add from_coco multi-segment regression
* test: extract coco multi-segment fixture
* test(coco): move multi-segment mask test to TestFromCocoMasks
* test(coco): fix fixture area and document bbox intent
* test(coco): add force_masks=True sibling test for multi-segment masks
* test(coco): add uneven-length segments case to TestFromCocoMasks
* test(coco): parametrize force_masks in TestFromCocoMasks

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Borda <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 20:42:11 +02:00
Madhav-C 4b856f1c04 docs(examples): fix outdated CLI flag names in heatmap_and_track README (#2266)
The README listed `--track_threshold` and `--match_threshold`, but
script.py exposes `--track_activation_threshold` and
`--minimum_matching_threshold` (the CLI surface is derived from the
main() signature by jsonargparse.auto_cli). Following the README as
written produced "unknown argument" errors.

Aligns the README with the actual CLI surface.
2026-05-22 20:00:30 +02:00
6461d3fbac Restructure annotator docs tabs to avoid Material’s 20-tab limit (#2257)
* Initial plan
* fix: split annotator docs tabs into categories
* test: harden annotator docs regression test
* docs: fix blur example assignment
* docs(annotators): restructure tabs, harden regression tests
* docs(annotators): remove broken oriented box preview image

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Borda <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 19:54:33 +02:00
Jirka Borovec 2c13b269e6 Update changelog after version 0.28.0 release
Updated the date modified and added a new entry for version 0.28.0 with details on sv.CompactMask.
2026-05-22 19:14:29 +02:00
Jirka Borovec f1176270eb Bump version to 0.29.0.dev 2026-05-22 19:12:20 +02:00
5b883fed5b Guard InferenceSlicer against OBB callback crashes when thread_workers > 1 (#2256)
* fix: serialize OBB inference slicer callbacks
* test: simplify OBB slicer regression test
* refactor: simplify OBB slicer fallback path
* test: make OBB slicer regression deterministic
* fix(slicer): add thread_workers validation and lock for OBB warn flag
* docs(slicer): document OBB fallback, merge order, perf note, dual-use key

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Borda <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-22 18:53:26 +02:00
Jirka Borovec e7376d558e ci(tests): disable matplotlib GUI and exclude non-test dirs from collection (#2262) 2026-05-20 23:40:21 +02:00
dependabot[bot] 17d7717515 ⬆️ Bump pymdown-extensions from 10.16.1 to 10.21.3 in the uv group across 1 directory (#2255)
Bumps the uv group with 1 update in the / directory: [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions).


Updates `pymdown-extensions` from 10.16.1 to 10.21.3
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.16.1...10.21.3)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-version: 10.21.3
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 11:59:25 +02:00
dependabot[bot] 7e6df44d86 ⬆️ Bump idna from 3.10 to 3.15 in the uv group across 1 directory (#2254)
Bumps the uv group with 1 update in the / directory: [idna](https://github.com/kjd/idna).


Updates `idna` from 3.10 to 3.15
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.md)
- [Commits](https://github.com/kjd/idna/compare/v3.10...v3.15)

---
updated-dependencies:
- dependency-name: idna
  dependency-version: '3.15'
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 11:50:16 +02:00
01ec36b31d fix: return empty int ndarray instead of None for class_id on empty VLM parse (#2239)
When from_paligemma or from_google_gemini_2_0 find no detections (no regex
matches, JSON decode error, or empty bounding-box list), they previously
returned None for class_id. All other early-exit and filter paths already
return a zero-length ndarray of dtype int. This inconsistency causes
downstream AttributeError when callers unconditionally call .shape or
iterate over the result.

Affected paths:
- from_paligemma: matches.shape[0] == 0 branch
- from_google_gemini_2_0: JSONDecodeError branch and len(xyxy) == 0 branch

---------

Co-authored-by: YousefZahran1 <youssefzahran.y@gmail.com>
Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
2026-05-19 14:31:44 +02:00
f4b0767a88 docs: add sample image download and sv.plot_image() to detect_and_annotate tutorial (#2242)
* docs: add sample image download and sv.plot_image() to detect_and_annotate tutorial
* Potential fix for pull request finding

---------

Co-authored-by: PatelPrem21 <prem_23247@ldrp.ac.in>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 14:22:49 +02:00
7e28315595 fix: preserve audio stream in process_video (#2252)
- Move `tempfile.mkstemp` + `os.close` inside `try` block so OSError (disk full,
  unwritable dir) is caught by the existing `except Exception` handler instead of
  propagating to the caller, preserving the warn-and-degrade contract
- Pass `dir=os.path.dirname(os.path.abspath(video_path))` so the temp file is on
  the same filesystem as the output, restoring `os.rename` semantics in `shutil.move`
- Initialise `tmp_path = None` before `try`; guard `finally` with
  `tmp_path is not None` to satisfy mypy and avoid referencing an unbound name
- Add `-loglevel error -nostats` so ffmpeg only writes actual errors to stderr
  (eliminates progress/stats spam that would buffer in PIPE indefinitely)
- Decode `result.stderr` and include it in the warning when ffmpeg exits
  non-zero, so failure messages surface diagnostically instead of being discarded
- Change bare `process_video(...)` call to `sv.process_video(...)` so the
  example matches the public API pattern and does not raise NameError for users
  copying the snippet
- Remove unused `import cv2` which was never referenced in the example body
- Clarify that missing/failing ffmpeg warns and continues rather than raising
- Add install hint for ffmpeg (apt/brew)
- Note that audio is truncated to match the processed video duration (-shortest)
- test_mux_audio_moves_file_on_success: mock subprocess.run returncode=0;
  assert shutil.move is called once with video_path as destination — catches
  any regression that drops the move call after a successful ffmpeg run
- test_mux_audio_swallows_subprocess_exception: mock subprocess.run raising
  OSError; assert no exception escapes _mux_audio and original file is intact
- Fix failed_result.stderr = b"" in test_mux_audio_warns_on_ffmpeg_failure
  to match the updated _mux_audio which now decodes result.stderr
- Skip _mux_audio when writer_worker.is_alive() after join timeout to avoid
  muxing an incomplete output file
- Fix test_mux_audio_moves_file_on_success: patch os.replace (not shutil.move)
  to match implementation changed in 2027938d
- Move four test_mux_audio_* free functions into TestMuxAudio class
- Strip mux_audio_ prefix from method names; class carries the unit
- Condense multi-line docstrings to single-line per testing rules
- Collapse test_warns_when_ffmpeg_missing, test_warns_on_ffmpeg_failure,
  test_swallows_subprocess_exception into one parametrized
  test_file_unchanged_on_failure[ffmpeg_missing|ffmpeg_fails|subprocess_raises]
- Promote two class methods back to module-level functions
- Collapse nested with-patch statements into single with a, b: form

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-19 14:09:45 +02:00
b68b41ae7a ci(docs): skip RC tags, strip .postX, fix latest alias deploy (#2253)
- Skip mike deploy for RC releases (tags containing rc/RC)
- Strip .postX suffix before deploying (0.27.0.post1 → 0.27.0)
- Add -u flag to latest deploy to handle existing alias

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-19 12:02:24 +02:00
dependabot[bot] 6fb4e80837 ⬆️ Bump mistune from 3.1.3 to 3.2.1 in the uv group across 1 directory (#2250)
Bumps the uv group with 1 update in the / directory: [mistune](https://github.com/lepture/mistune).


Updates `mistune` from 3.1.3 to 3.2.1
- [Release notes](https://github.com/lepture/mistune/releases)
- [Changelog](https://github.com/lepture/mistune/blob/main/docs/changes.rst)
- [Commits](https://github.com/lepture/mistune/compare/v3.1.3...v3.2.1)

---
updated-dependencies:
- dependency-name: mistune
  dependency-version: 3.2.1
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-14 13:02:13 +02:00
7dce60697f Simplified wording Update README.md (#2249)
---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-13 18:25:06 +02:00
dependabot[bot] 26ae68a2b6 ⬆️ Bump AButler/upload-release-assets from 3.0 to 4.0 in the github-actions group (#2243)
Bumps the github-actions group with 1 update: [AButler/upload-release-assets](https://github.com/abutler/upload-release-assets).


Updates `AButler/upload-release-assets` from 3.0 to 4.0
- [Release notes](https://github.com/abutler/upload-release-assets/releases)
- [Commits](https://github.com/abutler/upload-release-assets/compare/v3.0...v4.0)

---
updated-dependencies:
- dependency-name: AButler/upload-release-assets
  dependency-version: '4.0'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 17:58:05 +02:00
dependabot[bot] 878e6414da ⬆️ Bump jupyter-server from 2.16.0 to 2.18.0 in the uv group across 1 directory (#2245)
Bumps the uv group with 1 update in the / directory: [jupyter-server](https://github.com/jupyter-server/jupyter_server).


Updates `jupyter-server` from 2.16.0 to 2.18.0
- [Release notes](https://github.com/jupyter-server/jupyter_server/releases)
- [Changelog](https://github.com/jupyter-server/jupyter_server/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter-server/jupyter_server/compare/v2.16.0...v2.18.0)

---
updated-dependencies:
- dependency-name: jupyter-server
  dependency-version: 2.18.0
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-13 17:57:51 +02:00
2beeda7851 docs: link "Memory-Efficient Instance Segmentation" notebook and update deprecations (#2241)
* link "Memory-Efficient Instance Segmentation" notebook and update deprecations
* fix(notebook): correct broken Colab badge URL in compact-mask-sam3

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
2026-05-01 00:49:15 +02:00
dependabot[bot] 6ec8fa9a23 ⬆️ Bump notebook from 7.4.4 to 7.5.6 in the uv group across 1 directory (#2238)
Bumps the uv group with 1 update in the / directory: [notebook](https://github.com/jupyter/notebook).


Updates `notebook` from 7.4.4 to 7.5.6
- [Release notes](https://github.com/jupyter/notebook/releases)
- [Changelog](https://github.com/jupyter/notebook/blob/@jupyter-notebook/tree@7.5.6/CHANGELOG.md)
- [Commits](https://github.com/jupyter/notebook/compare/@jupyter-notebook/tree@7.4.4...@jupyter-notebook/tree@7.5.6)

---
updated-dependencies:
- dependency-name: notebook
  dependency-version: 7.5.6
  dependency-type: direct:development
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-30 23:52:40 +02:00
Jirka Borovec 1f199de502 drop outdated release_process.md 2026-04-30 23:52:00 +02:00
Jirka Borovec d8a25481c4 chore(notebooks): migrate demo scripts to Jupyter notebooks (#2240)
- Removed `convert_to_ipynb.sh` script and standalone `.py` demo files.
- Added `docs/notebooks/compact-mask-sam3.ipynb` showcasing new features (`sv.CompactMask` and `sv.Detections.from_sam3`) introduced in version `0.28.0`.
2026-04-30 23:49:11 +02:00
87b1b86f39 releasing 0.28.0 (#2220)
* docs: add 0.28.0 changelog entries and release demo notebook
* feat: add script for converting Python scripts to Jupyter notebooks
* chore: reorganize imports in release-demo notebook and update lint config
* docs: fix broken link in changelog entry for ConfusionMatrix

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-04-30 17:11:50 +02:00
pre-commit-ci[bot] 1170a90ffc chore(pre_commit): ⬆ pre_commit autoupdate (#2235)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.15.11 → v0.15.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.11...v0.15.12)
- [github.com/pre-commit/mirrors-mypy: v1.20.1 → v1.20.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.20.1...v1.20.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-28 09:03:07 +02:00
dependabot[bot] 2e0e8d9132 ⬆️ Update wheel requirement from <0.47,>=0.40 to >=0.40,<0.48 (#2234)
Updates the requirements on [wheel](https://github.com/pypa/wheel) to permit the latest version.
- [Release notes](https://github.com/pypa/wheel/releases)
- [Changelog](https://github.com/pypa/wheel/blob/main/docs/news.rst)
- [Commits](https://github.com/pypa/wheel/compare/0.40.0...0.47.0)

---
updated-dependencies:
- dependency-name: wheel
  dependency-version: 0.47.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 09:28:09 +02:00
Jirka BorovecandCopilot 15730b2b5d docs: update author references and improve metadata (#2233)
- Standardize author naming by removing aliases (e.g., "SkalskiP" → "Piotr Skalski").
- Refine JSON-LD schema with added fields, `speakable` specifications, and expanded FAQs.
- Update copyright year to 2026.
- Highlight project milestones (GitHub stars and PyPI downloads) for visibility.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-25 07:15:59 +02:00
2db1e3af34 docs: improve SEO and GEO docs publishing (#2232)
* docs: improve SEO and GEO docs publishing
* docs: refine language for model compatibility and package features
* fix: correct spelling "Underestand" → "Understand" in notebook title
* fix: derive JSON-LD SearchAction URL from config.site_url
* lint: reformat notebook JSON (indent 1→4, pretty-format-json)
* docs: refine description for `supervision.assets` video addition

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-04-24 23:39:22 +02:00
jirka 840226afce chore: update pre-commit hooks configuration
- Add `check-json` hook and re-add `check-yaml` hook.
- Configure `check-added-large-files` with a max size of 500 KB.
- Format `ruff-check` hook arguments for consistency.
2026-04-24 18:07:59 +02:00
dc03ceac31 fix(docs): expand llms.txt, add FAQ schema, author attribution to how… (#2231)
- Expand llms.txt from 12 to 36+ URLs with FAQ, benchmarking, and API sections
- FAQPage schema: 5 → 12 Q&As in theme/main.html
- TechArticle schema: add dynamic author/date from page meta
- Add authors + date_modified frontmatter to all 8 how-to pages
- Add description meta to 7 reference pages (detection/core, trackers, datasets/core, metrics/mean_average_precision, cookbooks, assets, changelog)
- FAQ sections to all 8 how-to pages (Q&A format for AI citation)
- Author bios at bottom of each how-to page (E-E-A-T signal)
- FAQPage + TechArticle schema for how_to, reference, and cookbook pages in theme/main.html
- llms.txt Key APIs: expand from bare list to descriptive prose paragraphs
- Changelog: add date_modified frontmatter

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 18:33:14 +02:00
dependabot[bot] f9ab57afde ⬆️ Bump nbconvert from 7.17.0 to 7.17.1 in the uv group across 1 directory (#2226)
Bumps the uv group with 1 update in the / directory: [nbconvert](https://github.com/jupyter/nbconvert).


Updates `nbconvert` from 7.17.0 to 7.17.1
- [Release notes](https://github.com/jupyter/nbconvert/releases)
- [Changelog](https://github.com/jupyter/nbconvert/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jupyter/nbconvert/compare/v7.17.0...v7.17.1)

---
updated-dependencies:
- dependency-name: nbconvert
  dependency-version: 7.17.1
  dependency-type: direct:development
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 20:33:54 +02:00
pre-commit-ci[bot] fb19852919 chore(pre_commit): ⬆ pre_commit autoupdate (#2225)
updates:
- [github.com/JoC0de/pre-commit-prettier: v3.8.2 → v3.8.3](https://github.com/JoC0de/pre-commit-prettier/compare/v3.8.2...v3.8.3)
- [github.com/astral-sh/ruff-pre-commit: v0.15.10 → v0.15.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.10...v0.15.11)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-04-22 20:29:35 +02:00
116 changed files with 13174 additions and 2077 deletions
+25 -73
View File
@@ -2,129 +2,81 @@
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socioeconomic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall
community
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
community-reports@roboflow.com.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at community-reports@roboflow.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][mozilla coc].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][mozilla coc].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][faq]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][faq]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[faq]: https://www.contributor-covenant.org/faq
[homepage]: https://www.contributor-covenant.org
+121 -4
View File
@@ -12,6 +12,7 @@ Please read and adhere to our [Code of Conduct](https://supervision.roboflow.com
- [Contribution Guidelines](#contribution-guidelines)
- [Contributing Features](#contributing-features)
- [API Design Principles](#api-design-principles)
- [How to Contribute Changes](#how-to-contribute-changes)
- [Installation for Contributors](#installation-for-contributors)
- [Code Style and Quality](#code-style-and-quality)
@@ -41,6 +42,15 @@ For example, counting objects that cross a line anywhere on an image is a common
Before you contribute a new feature, consider submitting an Issue to discuss the feature so the community can weigh in and assist.
### API Design Principles
Supervision APIs should remain generic, composable, and predictable across model families. Before adding a new integration, annotator option, or data conversion method, check the existing `sv.Detections`, `sv.KeyPoints`, and annotator patterns and follow these principles:
1. **Model integrations normalize raw external outputs into existing Supervision containers.** Use `sv.Detections` for detection, segmentation, and other instance-level predictions that include boxes, masks, class ids, confidence scores, or extra per-instance fields. Use `sv.KeyPoints` for standalone keypoint or pose predictions when keypoints exist independently of detection boxes (e.g. pure pose estimation, landmark detection on pre-cropped images). Use `Detections.keypoints` when keypoints are always co-incident with boxes from the same model — the field stores an `(n, K, 2)` or `(n, K, 3)` array where the optional third channel is per-point confidence in `[0, 1]`.
2. **Do not add a `from_<model>` method when the model already returns a Supervision object.** `from_*` methods are for converting raw outputs from external packages such as Ultralytics, Transformers, Inference, or MediaPipe. If a model's `predict()` method already returns `sv.Detections`, keep that result type and store additional structured payloads in `detections.data` or `detections.metadata` using documented keys.
3. **Annotators render data; filtering and visibility are container state.** Filtering by confidence, class id, tracker id, geometry, or custom data should happen before annotation through the container slicing APIs, for example `detections[detections.confidence > 0.7]` or `key_points[key_points.confidence > 0.5]`. Per-point presentation state, such as a `KeyPoints.visible` mask, may live on the container and be honored consistently by annotators.
4. **Annotator constructor arguments should describe visual presentation, not model-quality gates.** Use constructor arguments for color, thickness, opacity, text, position, style, and generic visualization parameters such as sigma levels. Annotators may skip invalid geometry defensively, including missing points, zero-area boxes, non-finite coordinates, or points marked invisible on the container. They should not introduce confidence thresholds or model-specific quality gates as rendering options.
## How to Contribute Changes
First, fork this repository to your own GitHub account. Click "fork" in the top corner of the `supervision` repository to get started:
@@ -230,9 +240,37 @@ All new functions and classes in `supervision` should include docstrings. This i
`supervision` adheres to the [Google Python docstring style](https://google.github.io/styleguide/pyguide.html#383-functions-and-methods). Please refer to the style guide while writing docstrings for your contribution.
Every docstring should include a usage example. When the example only uses `supervision`, NumPy, and the standard library — no optional extras, no external files or network access — strongly prefer `>>>` doctest format so it is automatically verified by the test suite. See [Doctests](#doctests) below for syntax guidance and for when fenced ```` ```python ```` blocks are appropriate instead.
### Type checking
Currently, there is no systematic type checking with mypy implemented in the project. This is a known limitation that may be addressed in future updates.
Type hints are required on all new code. mypy is enforced by the pre-commit hook configured in `.pre-commit-config.yaml` — your PR will fail CI if mypy reports errors.
### Performance
- Avoid unnecessary copies of NumPy arrays.
- Prefer vectorized operations over Python loops in hot paths.
- Lazy-import heavy framework dependencies (`torch`, `transformers`, `ultralytics`) inside the function that needs them — never at module top level.
### Deprecation policy
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
Use the appropriate mechanism depending on what is being deprecated:
- **Module-level alias**: `supervision.utils.internal.warn_deprecated` in the deprecated module's `__init__.py`
- **Renamed parameter**: `supervision.utils.internal.deprecated_parameter` decorator
- **Public function, method, or class**: `@deprecated` from `pydeprecate`
Always specify both the deprecation version and the planned removal version in the message or decorator arguments.
### Deprecated module aliases
`supervision.keypoint` is deprecated since `0.27.0` and will be removed in `0.30.0`. Always import from `supervision.key_points`:
```python
from supervision.key_points import KeyPoints # correct
```
## 📝 Documentation
@@ -258,9 +296,7 @@ You can learn more about mkdocs on the [mkdocs website](https://www.mkdocs.org/)
## 🧑‍🍳 Cookbooks
We are always looking for new examples and cookbooks to add to the `supervision`
documentation. If you have a use case that you think would be helpful to others, please
submit a PR with your example. Here are some guidelines for submitting a new example:
We are always looking for new examples and cookbooks to add to the `supervision` documentation. If you have a use case that you think would be helpful to others, please submit a PR with your example. Here are some guidelines for submitting a new example:
- Create a new notebook in the [`docs/notebooks`](https://github.com/roboflow/supervision/tree/develop/docs/notebooks) folder.
- Add a link to the new notebook in [`docs/theme/cookbooks.html`](https://github.com/roboflow/supervision/blob/develop/docs/theme/cookbooks.html). Make sure to add the path to the new notebook, as well as a title, labels, author and supervision version.
@@ -286,6 +322,87 @@ To run tests with coverage:
uv run pytest --cov=supervision
```
### Test Structure
Follow **Arrange-Act-Assert (AAA)**: one setup block, one action, one assertion group per test. Never put two independent actions in the same test.
**Class grouping:** Group related tests into a class. The class name carries the unit under test; method names describe the expected outcome only — not the mechanism.
```python
class TestDetectionsWithNms:
def test_keeps_highest_confidence_detection(self): ...
def test_suppresses_lower_score_when_overlap_exceeds_threshold(self): ...
def test_raises_when_confidence_missing(self): ...
```
**Parametrize aggressively:** Three or more structurally identical tests should become a single `@pytest.mark.parametrize` case. Use `pytest.param(..., id="slug")` per case — not `ids=[...]` on the decorator — so the ID stays co-located with its arguments and survives reordering.
```python
@pytest.mark.parametrize(
("overlap_metric", "expected_keep"),
[
pytest.param(OverlapMetric.IOU, [True, True], id="iou-keeps-both"),
pytest.param(OverlapMetric.IOS, [True, False], id="ios-suppresses-small"),
],
)
def test_overlap_metric_determines_suppression(
overlap_metric: OverlapMetric, expected_keep: list[bool]
) -> None:
"""Small box inside large: IOU keeps both; IOS suppresses small."""
...
```
**Docstrings:** Every test function/method requires at minimum a one-line docstring (within the project line length configured in `pyproject.toml`). Describe the scenario, not the implementation.
### Doctests
**Guidance:** when an example uses only `supervision`, NumPy, and the standard library — no optional extras (e.g. no `--extra metrics` packages), no external files, no network, no devices — prefer `>>>` doctest format so it is automatically verified by the test suite. Fenced ```` ```python ```` blocks are appropriate when the example cannot reasonably be executed (e.g. loading a third-party model, reading a video file) or when the primary purpose is demonstrating error/exception behaviour rather than return values.
Doctests run automatically as part of the test suite via `--doctest-modules` in `pyproject.toml`. The `ELLIPSIS` and `NORMALIZE_WHITESPACE` flags are enabled globally, so `...` matches any output fragment and minor whitespace differences are ignored.
```bash
uv run pytest --doctest-modules src/
```
**Writing a doctest**
Use the `Example:` section of a Google-style docstring. Prefix each input line with `>>>` and each continuation line with `...`. Place expected output immediately after the last input line with no blank line between them.
```python
def clip_boxes(xyxy: np.ndarray, resolution_wh: tuple) -> np.ndarray:
"""Clip bounding boxes to frame boundaries.
Args:
xyxy: Box coordinates as (N, 4) float array.
resolution_wh: Frame size as (width, height).
Returns:
Clipped boxes as (N, 4) float array.
Example:
>>> import numpy as np
>>> import supervision as sv
>>> boxes = np.array([[-10, -5, 120, 80]], dtype=np.float32)
>>> sv.clip_boxes(boxes, resolution_wh=(100, 60))
array([[ 0., 0., 100., 60.]], dtype=float32)
"""
```
### Key rules
- **Single-line expression** — write the repr as expected output: `>>> len(result)` → `1`
- **Multi-line statement** — use `...` continuation: `>>> arr = np.array([` / `... [1, 2],` / `... ])`
- **Print output** — write the printed string as expected output (no quotes).
- **`None` return** — no output line needed (suppress with assignment or `_ =`).
- **Large/variable arrays** — use `ELLIPSIS`: `array([...])` matches any content.
- **`# doctest: +SKIP`** — use only as a last resort for genuinely non-runnable lines (e.g. a GPU-only call inside an otherwise runnable example). Prefer splitting the example into two blocks instead.
Fenced ```` ```python ```` blocks remain appropriate for:
- Examples that import optional extras (`supervision[metrics]`, `torch`, `ultralytics`).
- Examples that read files, capture video, or require a running service.
- Illustrative pseudocode that is intentionally incomplete.
## 🔍 PR Review Guidelines
These guidelines help reviewers provide consistent, actionable feedback efficiently. Your goals: validate completeness, identify risks, provide actionable feedback, and highlight quality gaps.
+3 -3
View File
@@ -141,6 +141,6 @@ Quick checklist:
## 🎯 Context-Aware Behavior
**For general development tasks**: Follow [AGENTS.md](../AGENTS.md)
**For pull request reviews**: Follow [PR Review Guidelines](CONTRIBUTING.md#pr-review-guidelines)
**For detailed processes**: Consult [CONTRIBUTING.md](CONTRIBUTING.md)
- **For general development tasks**: Follow [AGENTS.md](../AGENTS.md)
- **For pull request reviews**: Follow [PR Review Guidelines](CONTRIBUTING.md#pr-review-guidelines)
- **For detailed processes**: Consult [CONTRIBUTING.md](CONTRIBUTING.md)
+1
View File
@@ -19,6 +19,7 @@ exclude = [
"http://127.0.0.1:8000", # hint for local docs server
"https://sam2.metademolab.com/", # returns 403 Forbidden
"https://snyk.io/advisor/python/supervision/badge.svg", # badge URL
"https://trendshift.io", # badge API times out in CI
"https://universe.roboflow.com/",
"https://universe.roboflow.com/model-examples/segmented-animals-basic",
# fixme: this page returns 401 Unauthorized when accessed and 404 Not Found when accessed with browser,
+2 -2
View File
@@ -20,10 +20,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🐍 Install uv and set Python version ${{ inputs.python-version }}
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: ${{ inputs.python-version }}
activate-environment: true
+2 -2
View File
@@ -22,10 +22,10 @@ jobs:
timeout-minutes: 10
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🐍 Install uv and set Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: "3.10"
activate-environment: true
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 🔗 Link Checker
uses: lycheeverse/lychee-action@v2
+3 -3
View File
@@ -33,10 +33,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: ${{ matrix.python-version }}
activate-environment: true
@@ -56,7 +56,7 @@ jobs:
coverage report
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: "coverage.xml"
+70 -10
View File
@@ -1,9 +1,15 @@
name: Docs/Build and Publish
# Deploy matrix:
# push develop -> mike deploy develop
# push release/latest -> mike deploy latest
# release published -> mike deploy <tag> only; does not move /latest/
on:
push:
branches:
- develop
- release/latest
workflow_dispatch:
release:
types: [published]
@@ -26,12 +32,12 @@ jobs:
timeout-minutes: 10
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: 🐍 Install uv and set Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
python-version: "3.10"
activate-environment: true
@@ -51,13 +57,37 @@ jobs:
run: |
mike deploy --push develop
- name: 🚀 Deploy Release Docs
if: github.event_name == 'release' && github.event.action == 'published'
- name: 🚀 Deploy Latest Docs
if: github.event_name == 'push' && github.ref == 'refs/heads/release/latest'
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.GITHUB_TOKEN }}
run: |
latest_tag=$(git tag --sort=-v:refname | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
mike deploy --push --update-aliases $latest_tag latest
if mike list | grep -Eq '^latest(\s|$)'; then mike delete latest; fi
mike deploy --push latest
- name: 🏷️ Determine release deployment metadata
id: release_metadata
run: |
is_rc=false
release_tag=""
if [[ "$GITHUB_EVENT_NAME" == "release" ]]; then
release_tag="${GITHUB_REF_NAME#v}"
release_tag="${release_tag%.post*}"
release_tag_lower="${release_tag,,}"
# Match RC suffixes with separators (1.0-rc1, 1.0.rc1) or compact form (1.0rc1).
if [[ "$release_tag_lower" =~ (^|[._-])rc[0-9]+$ ]] || [[ "$release_tag_lower" =~ [0-9]rc[0-9]+$ ]]; then
is_rc=true
fi
fi
echo "is_rc=$is_rc" >> "$GITHUB_OUTPUT"
echo "release_tag=$release_tag" >> "$GITHUB_OUTPUT"
- name: 🚀 Deploy Release Docs
if: github.event_name == 'release' && github.event.action == 'published' && steps.release_metadata.outputs.is_rc != 'true'
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.GITHUB_TOKEN }}
run: |
mike deploy --push "${{ steps.release_metadata.outputs.release_tag }}"
# IndexNow key: 0d5d9799b1cc4a39825146388c6781eb
# This key must stay in sync across three files:
@@ -70,25 +100,55 @@ jobs:
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/develop') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'release' && github.event.action == 'published')
(github.event_name == 'push' && github.ref == 'refs/heads/release/latest') ||
(github.event_name == 'release' && github.event.action == 'published' && steps.release_metadata.outputs.is_rc != 'true')
run: |
cp docs/robots.txt /tmp/robots.txt
cp docs/llms.txt /tmp/llms.txt
cp docs/llms.full.txt /tmp/llms.full.txt
cp docs/llms-100k.txt /tmp/llms-100k.txt
cp docs/_headers /tmp/headers.txt
cp docs/0d5d9799b1cc4a39825146388c6781eb.txt /tmp/indexnow.txt
if [[ "$GITHUB_REF" == "refs/heads/release/latest" ]]; then
version_dir="latest"
else
version_dir=""
fi
git fetch origin gh-pages
git checkout gh-pages
cp /tmp/robots.txt robots.txt
cp /tmp/llms.txt llms.txt
cp /tmp/llms.full.txt llms.full.txt
cp /tmp/llms-100k.txt llms-100k.txt
cp /tmp/headers.txt _headers
cp /tmp/indexnow.txt 0d5d9799b1cc4a39825146388c6781eb.txt
git add robots.txt llms.txt 0d5d9799b1cc4a39825146388c6781eb.txt
git diff --cached --quiet || git commit -m "chore: update GEO root files (robots.txt, llms.txt, indexnow)"
if [[ -n "$version_dir" && -f "$version_dir/sitemap.xml" ]]; then
cp "$version_dir/sitemap.xml" sitemap.xml
gzip -9 -c sitemap.xml > sitemap.xml.gz
fi
files_to_add=(
robots.txt
llms.txt
llms.full.txt
llms-100k.txt
_headers
0d5d9799b1cc4a39825146388c6781eb.txt
)
if [[ -f "sitemap.xml" ]]; then
files_to_add+=(sitemap.xml)
fi
if [[ -f "sitemap.xml.gz" ]]; then
files_to_add+=(sitemap.xml.gz)
fi
git add "${files_to_add[@]}"
git diff --cached --quiet || git commit -m "chore: update GEO root files"
git push origin gh-pages
- name: 📡 Notify IndexNow
if: >
(github.event_name == 'push' && github.ref == 'refs/heads/develop') ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'release' && github.event.action == 'published')
(github.event_name == 'push' && github.ref == 'refs/heads/release/latest')
run: |
curl -s -o /dev/null -w "%{http_code}" -X POST "https://api.indexnow.org/IndexNow" \
-H "Content-Type: application/json; charset=utf-8" \
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- name: 📦 Upload assets to Release
if: github.event_name == 'release'
uses: AButler/upload-release-assets@v3.0
uses: AButler/upload-release-assets@v4.0
with:
files: "dist/*"
repo-token: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -169,3 +169,4 @@ _resolutions/
_reviews/
tasks/
*.local.md
scripts/test_multi_skeleton.py
+9 -6
View File
@@ -13,11 +13,14 @@ repos:
hooks:
- id: trailing-whitespace
- id: check-yaml
exclude: mkdocs.yml
args: ["--unsafe"]
- id: check-executables-have-shebangs
- id: check-json
- id: check-toml
- id: check-case-conflict
- id: check-added-large-files
args: ["--maxkb=500", "--enforce-all"]
exclude: uv.lock|.*\.ipynb$
- id: detect-private-key
- id: pretty-format-json
exclude: demo.ipynb
@@ -26,7 +29,7 @@ repos:
- id: mixed-line-ending
- repo: https://github.com/JoC0de/pre-commit-prettier
rev: v3.8.2 # using tag; previously pinned SHA when tags were not persistent
rev: v3.8.3 # using tag; previously pinned SHA when tags were not persistent
hooks:
- id: prettier
files: \.(ya?ml|toml)$
@@ -44,10 +47,10 @@ repos:
- id: validate-pyproject
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.10
rev: v0.15.12
hooks:
- id: ruff-check
args: [--fix]
args: ["--fix"]
- id: ruff-format
types_or: [python, pyi, jupyter]
@@ -58,11 +61,11 @@ repos:
additional_dependencies:
- "mdformat-mkdocs[recommended]>=2.1.0"
- "mdformat-ruff"
args: ["--number"]
args: ["--number", "--wrap=no"]
exclude: ^(docs/changelog\.md|docs/deprecated\.md)$
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.20.1
rev: v1.20.2
hooks:
- id: mypy
additional_dependencies:
+110 -62
View File
@@ -1,98 +1,146 @@
# Agent Guidelines for `supervision`
These instructions define how AI agents (GitHub Copilot, Claude, etc.) should behave when
assigned an issue, task, or multi-step problem in this repository.
Behave like a senior contributor: precise, efficient, aligned with the project's
philosophy, and focused on maintainability and clarity.
Behave like a senior contributor: precise, efficient, maintainable. When this file and [CONTRIBUTING.md](.github/CONTRIBUTING.md) conflict, **CONTRIBUTING.md wins**.
---
## 1. Before You Code
- Read the task/issue thoroughly before acting.
- Identify missing information; ask **one targeted clarification question** if needed.
- Outline a step-by-step plan before making changes.
- Check whether the feature or fix already exists under a different name.
- Confirm alignment with the repository's architecture (`src/supervision/`).
- Read the task thoroughly; group clarifications into one ask.
- Outline a plan before making changes.
- Check whether the feature already exists under a different name.
- Confirm alignment with `src/supervision/` architecture.
---
## 2. Repository Conventions
## 2. Repository Architecture
All work must follow the conventions of the `supervision` library
(see [CONTRIBUTING.md](.github/CONTRIBUTING.md) for full details).
**Package root**: `src/supervision/` — all library code. **Tests**: `tests/` — mirrors `src/supervision/`. **Public API**: `src/supervision/__init__.py`.
### Branching & Commits
```
src/supervision/
├── detection/
│ ├── core.py — Detections dataclass; all model connectors as classmethods
│ ├── compact_mask.py — compact mask representation
│ ├── vlm.py — VLM connectors (Florence-2, Gemini, Qwen, PaliGemma)
│ ├── utils/ — pure NumPy helpers: boxes, converters, iou_and_nms, masks, polygons
│ ├── line_zone.py — LineZone
│ └── tools/ — InferenceSlicer, PolygonZone, CSVSink, JSONSink, DetectionsSmoother
├── annotators/core.py — BoxAnnotator, MaskAnnotator, LabelAnnotator, … each: .annotate(scene, detections)
├── key_points/ — KeyPoints, EdgeAnnotator, VertexAnnotator (use this, NOT keypoint/ — see §4)
├── tracker/ — DEPRECATED
├── dataset/core.py — DetectionDataset / ClassificationDataset (YOLO / COCO / Pascal VOC)
├── geometry/core.py — Point, Rect, Vector, Position
├── metrics/ — mAP, confusion matrix (requires --extra metrics)
├── utils/internal.py — warn_deprecated, deprecated_parameter, internal helpers
└── config.py — string constants; always import from here, never use literals
```
- Branch from `develop` using prefixes: `feat/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`.
- Use **conventional commits**: `feat:`, `fix:`, `docs:`, `refactor:`, `perf:`, `test:`, `chore:`.
- PRs must target the `develop` branch.
### Key design patterns
### Code Style
- **Formatting and linting** are enforced by **pre-commit**.
The hook chain typically includes: ruff-check, ruff-format, codespell, mdformat,
prettier, pyproject-fmt, and standard pre-commit-hooks (trailing whitespace, YAML, TOML, etc.).
- **Type hints**: required on all new code. Type checking with mypy is encouraged but not
currently enforced systematically by pre-commit; see [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md)
for the latest type-checking expectations.
- **Docstrings**: Google Python docstring style. Required for all new functions and classes.
Docstrings should include usage examples demonstrating the function with primitive values
so they serve as runnable documentation.
### API Consistency
- Follow existing naming patterns.
- Maintain backward compatibility unless explicitly allowed.
- Prefer functional utilities over complex classes unless justified.
### Performance
- Avoid unnecessary copies of NumPy arrays.
- Prefer vectorized operations over Python loops in hot paths.
- Use OpenCV operations efficiently.
- **`Detections` is the lingua franca** — every connector, tracker, and annotator speaks `Detections`. New connector = `@classmethod from_<framework>(cls, result) -> Detections`.
- **Annotators are composable** — receive `scene` (BGR `np.ndarray`) + `detections`, return annotated copy.
- **`data` dict extensibility** — per-detection metadata in `detections.data` as `np.ndarray` aligned with `xyxy`. Keys are constants from `config.py`.
- **Vectorized throughout** — NumPy arrays, no Python loops in hot paths. Never write `for det in detections`.
- **Lazy-import heavy deps** — `torch`, `transformers`, `ultralytics` must be imported inside the function that needs them, never at module top level.
---
## 3. Implementing Features
## 3. Agent-Critical Rules
- Provide a minimal, clean implementation.
- Include type hints and Google-style docstrings with usage examples.
- All new functionality must be covered with tests, including edge cases.
- Add or update documentation (docstrings + mkdocs entries if applicable).
- Ensure compatibility with core dependencies: NumPy, OpenCV, SciPy.
These supplement [CONTRIBUTING.md](.github/CONTRIBUTING.md) — covering gaps or agent-specific failure modes.
**Doc headings**: `###` max in docstrings and docs. `####` renders identically to bold in mkdocs — use `**bold**` instead.
**Type hints**: required on all new code. mypy is enforced by pre-commit (`.pre-commit-config.yaml`).
**Doctest determinism** — output must be reproducible across platforms:
- Use `# doctest: +ELLIPSIS` for floats that vary by platform.
- Seed any RNG before calling it.
- Never assert `dict` or `set` iteration order.
- No network or filesystem access outside `supervision/assets/`.
**⚠ Test structure** — agents frequently fail here; read [CONTRIBUTING.md §Tests](.github/CONTRIBUTING.md#-tests) carefully: AAA structure, class grouping, parametrize with `pytest.param(..., id="slug")`, one-line docstring per test.
For branching, commit, code style, and API design conventions see [CONTRIBUTING.md](.github/CONTRIBUTING.md).
---
## 4. Fixing Bugs
## 4. Deprecated Module Aliases
1. Reproduce and understand the root cause.
2. Write a test that reproduces the bug (it should fail before the fix).
3. Apply a minimal, targeted fix.
4. Verify the test passes and no other components break.
`supervision.keypoint` deprecated since `0.27.0`, removed in `0.30.0`. Always import from `supervision.key_points`, not `supervision.keypoint`.
---
## 5. Refactoring
## 5. Deprecating APIs
- Preserve behavior and API stability.
- Improve readability or performance.
- Reduce duplication.
- Avoid large, sweeping refactors unless explicitly requested.
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
- Module-level: `supervision.utils.internal.warn_deprecated` in the deprecated module's own `__init__.py`
- Parameter renamed (old→new): `supervision.utils.internal.deprecated_parameter` decorator
- Public function, method, or class: `@deprecated` from `pydeprecate`
Always name the version introduced and the removal version:
```python
warn_deprecated("'foo' deprecated in `0.29.0`, removed in `0.32.0`. Use 'bar'.")
```
---
## 6. Before You Commit
## 6. Implementing Features
Always run these before committing:
- Minimal implementation; type hints and Google docstrings with usage examples.
- Tests covering new functionality and edge cases (see [CONTRIBUTING.md §Tests](.github/CONTRIBUTING.md#-tests)).
- Update docstrings and mkdocs entries as needed.
**Extending `Detections`**: store metadata in `detections.data` as `np.ndarray` aligned with `xyxy`; define the key as a constant in `config.py` (e.g. `CLASS_NAME_DATA_FIELD`, `ORIENTED_BOX_COORDINATES`).
**New model connector** (`detection/core.py`):
```python
@classmethod
def from_myframework(cls, result) -> "Detections":
import myframework # noqa: F401 — lazy import
xyxy = ... # (N, 4)
return cls(
xyxy=xyxy,
confidence=...,
class_id=...,
data={CLASS_NAME_DATA_FIELD: np.array([...])},
)
```
VLM connectors go in `detection/vlm.py`, not `core.py`.
---
## 7. Bugs & Refactoring
**Bugs**: reproduce → write failing test → minimal fix → verify no regressions.
**Refactoring**: preserve behavior and API; reduce duplication; avoid sweeping changes unless requested; apply §5 deprecation when removing public API.
---
## 8. Before You Commit
```bash
uv run pytest --cov=supervision
uv run pre-commit run --all-files
```
- All pre-commit hooks must pass (formatting, linting, type checking, spell check, etc.).
- All tests must pass before opening a PR. Note: some existing tests in the repo may
already be failing — your changes must not introduce new failures.
- Fix any issues reported and re-run until clean.
Capture a baseline before changes to avoid introducing new failures:
```bash
STASH_BEFORE=$(git rev-parse refs/stash 2>/dev/null)
git stash push --include-untracked
uv run pytest -q 2>&1 | tee /tmp/baseline.txt
[ "$(git rev-parse refs/stash 2>/dev/null)" != "$STASH_BEFORE" ] && git stash pop
uv run pytest -q 2>&1 | tee /tmp/after.txt
diff /tmp/baseline.txt /tmp/after.txt
```
Any test passing in baseline but failing after = blocker.
+3 -15
View File
@@ -2,20 +2,8 @@ MIT License
Copyright (c) 2022 Roboflow
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+37 -31
View File
@@ -1,6 +1,6 @@
<div align="center">
<p>
<a align="center" href="" target="https://supervision.roboflow.com">
<a align="center" href="https://supervision.roboflow.com" target="_blank">
<img
width="100%"
src="https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png?updatedAt=1678995927529"
@@ -14,16 +14,9 @@
<br>
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision)
[![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision)
[![license](https://img.shields.io/pypi/l/supervision)](LICENSE.md)
[![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
[![codecov](https://codecov.io/gh/roboflow/supervision/graph/badge.svg?token=HMNJ5FVZ36)](https://codecov.io/gh/roboflow/supervision)
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision) [![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision) [![license](https://img.shields.io/pypi/l/supervision)](LICENSE.md) [![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision) [![codecov](https://codecov.io/gh/roboflow/supervision/graph/badge.svg?token=HMNJ5FVZ36)](https://codecov.io/gh/roboflow/supervision)
[![snyk](https://snyk.io/advisor/python/supervision/badge.svg)](https://snyk.io/advisor/python/supervision)
[![colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow/supervision/blob/main/demo.ipynb)
[![gradio](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces/Roboflow/Annotators)
[![discord](https://img.shields.io/discord/1159501506232451173?logo=discord&label=discord&labelColor=fff&color=5865f2&link=https%3A%2F%2Fdiscord.gg%2FGbfgXGJ8Bk)](https://discord.gg/GbfgXGJ8Bk)
[![snyk](https://snyk.io/advisor/python/supervision/badge.svg)](https://snyk.io/advisor/python/supervision) [![colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow/supervision/blob/main/demo.ipynb) [![gradio](https://img.shields.io/badge/%F0%9F%A4%97%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces/Roboflow/Annotators) [![discord](https://img.shields.io/discord/1159501506232451173?logo=discord&label=discord&labelColor=fff&color=5865f2&link=https%3A%2F%2Fdiscord.gg%2FGbfgXGJ8Bk)](https://discord.gg/GbfgXGJ8Bk)
<div align="center">
<a href="https://trendshift.io/repositories/124" target="_blank"><img src="https://trendshift.io/api/badge/repositories/124" alt="roboflow%2Fsupervision | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -31,14 +24,29 @@
</div>
## 👋 hello
<details>
<summary><strong>📑 Table of Contents</strong></summary>
**We write your reusable computer vision tools.** Whether you need to load your dataset from your hard drive, draw detections on an image or video, or count how many detections are in a zone. You can count on us! 🤝
- [👋 Hello](#-hello)
- [💻 Install](#-install)
- [🔥 Quickstart](#-quickstart)
- [Models](#models)
- [Annotators](#annotators)
- [Datasets](#datasets)
- [🎬 Tutorials](#-tutorials)
- [💜 Built with Supervision](#-built-with-supervision)
- [📚 Documentation](#-documentation)
- [🏆 Contribution](#-contribution)
## 💻 install
</details>
Pip install the supervision package in a
[**Python>=3.9**](https://www.python.org/) environment.
## 👋 Hello
**We are your essential toolkit for computer vision.** From data loading to real-time zone counting, we provide the building blocks so you can focus on building applications around your models. 🤝
## 💻 Install
Pip install the supervision package in a [**Python>=3.9**](https://www.python.org/) environment.
```bash
pip install supervision
@@ -46,9 +54,9 @@ pip install supervision
Read more about conda, mamba, and installing from source in our [guide](https://roboflow.github.io/supervision/).
## 🔥 quickstart
## 🔥 Quickstart
### models
### Models
Supervision was designed to be model agnostic. Just plug in any classification, detection, or segmentation model. For your convenience, we have created [connectors](https://supervision.roboflow.com/latest/detection/core/#detections) for the most popular libraries like Ultralytics, Transformers, MMDetection, or Inference. Other integrations, like `rfdetr`, already return `sv.Detections` directly.
@@ -59,7 +67,7 @@ import supervision as sv
from PIL import Image
from rfdetr import RFDETRSmall
image = Image.open(...)
image = Image.open("path/to/image.jpg")
model = RFDETRSmall()
detections = model.predict(image, threshold=0.5)
@@ -79,7 +87,7 @@ len(detections)
from PIL import Image
from inference import get_model
image = Image.open(...)
image = Image.open("path/to/image.jpg")
model = get_model(model_id="rfdetr-small", api_key="ROBOFLOW_API_KEY")
result = model.infer(image)[0]
detections = sv.Detections.from_inference(result)
@@ -90,7 +98,7 @@ len(detections)
</details>
### annotators
### Annotators
Supervision offers a wide range of highly customizable [annotators](https://supervision.roboflow.com/latest/detection/annotators/), allowing you to compose the perfect visualization for your use case.
@@ -98,7 +106,8 @@ Supervision offers a wide range of highly customizable [annotators](https://supe
import cv2
import supervision as sv
image = cv2.imread(...)
image = cv2.imread("path/to/image.jpg")
# Assuming detections are obtained from a model
detections = sv.Detections(...)
box_annotator = sv.BoxAnnotator()
@@ -107,7 +116,7 @@ annotated_frame = box_annotator.annotate(scene=image.copy(), detections=detectio
https://github.com/roboflow/supervision/assets/26109316/691e219c-0565-4403-9218-ab5644f39bce
### datasets
### Datasets
Supervision provides a set of [utils](https://supervision.roboflow.com/latest/datasets/core/) that allow you to load, split, merge, and save datasets in one of the supported formats.
@@ -131,7 +140,7 @@ for path, image, annotation in ds:
pass
```
<details close>
<details>
<summary>👉 more dataset utils</summary>
- load
@@ -221,7 +230,7 @@ for path, image, annotation in ds:
</details>
## 🎬 tutorials
## 🎬 Tutorials
Want to learn how to use Supervision? Explore our [how-to guides](https://supervision.roboflow.com/develop/how_to/detect_and_annotate/), [end-to-end examples](./examples), [cheatsheet](https://roboflow.github.io/cheatsheet-supervision/), and [cookbooks](https://supervision.roboflow.com/develop/cookbooks/)!
@@ -241,7 +250,7 @@ Want to learn how to use Supervision? Explore our [how-to guides](https://superv
<div><strong>Created: 11 Jan 2024</strong></div>
<br/>Learn how to track and estimate the speed of vehicles using YOLO, ByteTrack, and Roboflow Inference. This comprehensive tutorial covers object detection, multi-object tracking, filtering detections, perspective transformation, speed estimation, visualization improvements, and more.</p>
## 💜 built with supervision
## 💜 Built with Supervision
Did you build something cool using supervision? [Let us know!](https://github.com/roboflow/supervision/discussions/categories/built-with-supervision)
@@ -251,11 +260,11 @@ https://github.com/roboflow/supervision/assets/26109316/c9436828-9fbf-4c25-ae8c-
https://github.com/roboflow/supervision/assets/26109316/3ac6982f-4943-4108-9b7f-51787ef1a69f
## 📚 documentation
## 📚 Documentation
Visit our [documentation](https://roboflow.github.io/supervision) page to learn how supervision can help you build computer vision applications faster and more reliably.
## 🏆 contribution
## 🏆 Contribution
We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md) to get started. Thank you 🙏 to all our contributors!
@@ -267,8 +276,6 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
<br>
<div align="center">
<div align="center">
<a href="https://youtube.com/roboflow">
<img
@@ -303,6 +310,7 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
src="https://media.roboflow.com/notebooks/template/icons/purple/forum.png?ik-sdk-version=javascript-1.4.3&updatedAt=1672949633584"
width="3%"
/>
</a>
<img src="https://raw.githubusercontent.com/ultralytics/assets/main/social/logo-transparent.png" width="3%"/>
<a href="https://blog.roboflow.com">
<img
@@ -310,6 +318,4 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
width="3%"
/>
</a>
</a>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' https: data:; media-src 'self' https: data:; font-src 'self' https: data:; style-src 'self'; script-src 'self'; connect-src 'self' https:;
+27
View File
@@ -0,0 +1,27 @@
---
comments: true
description: About Supervision, Roboflow's open-source Python library for building reusable computer vision workflows.
---
# About Supervision
Supervision is an open-source Python library by Roboflow for building computer vision applications. It gives developers a model-agnostic toolkit for loading predictions, annotating images and video, tracking objects, counting detections in zones, processing datasets, and evaluating model performance.
The project centers on a unified `Detections` API with converters for supported outputs from Ultralytics, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers such as Florence-2, PaliGemma, Qwen VL, Gemini, DeepSeek VL 2, and Moondream. That common representation lets teams change models without rewriting annotation, filtering, tracking, dataset, or metrics code.
Supervision is maintained by Roboflow and an open-source contributor community. The library is MIT licensed, developed in public on GitHub, and published on PyPI.
## Project Links
- Source code: [github.com/roboflow/supervision](https://github.com/roboflow/supervision)
- Package: [pypi.org/project/supervision](https://pypi.org/project/supervision/)
- Community: [Roboflow Discord](https://discord.gg/GbfgXGJ8Bk)
- Roboflow: [roboflow.com](https://roboflow.com/)
## Maintainers and Contributors
Supervision has been shaped by Roboflow engineers and community contributors, including Piotr Skalski, Borda, onuralpszr, Soumik Mandal, and many others. The complete contributor history is available in the GitHub repository.
## Citation
If Supervision helps your research or production system, cite the project using the citation block in [llms.txt](https://supervision.roboflow.com/llms.txt) or link to the GitHub repository.
+2 -2
View File
@@ -1,11 +1,11 @@
---
comments: true
description: API reference for supervision's assets module — download sample video and image files for demos, testing, and tutorials.
---
# Assets
Supervision offers an assets download utility that allows you to download image and video files
that you can use in your demos.
Supervision offers an assets download utility that allows you to download image and video files that you can use in your demos.
<div class="md-typeset">
<h2><a href="#supervision.assets.downloader.download_assets.download_assets">download_assets</a></h2>
+150 -3
View File
@@ -1,12 +1,119 @@
---
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
date_modified: 2026-06-16
---
# Changelog
### 0.28.0 <small>Unreleased</small>
### 0.29.1 <small>Jun 23, 2026</small>
- Changed [#2178](https://github.com/roboflow/supervision/pull/2178): [`sv.Detections.from_inference`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.from_inference) now supports compressed COCO RLE masks. Inference responses with `rle` or `rle_mask` fields containing a compressed counts string (as produced by `pycocotools`) are decoded directly into binary masks, avoiding a lossy polygon round-trip.
- Added [#2338](https://github.com/roboflow/supervision/pull/2338): [`sv.KeyPoints.with_nms`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints.with_nms) — non-maximum suppression for keypoint detections. Derives axis-aligned bounding boxes from valid (non-zero and visible) keypoints and applies `box_non_max_suppression`. Requires `detection_confidence`; supports class-aware and class-agnostic modes via `threshold`, `class_agnostic`, and `overlap_metric`.
- Fixed [#2342](https://github.com/roboflow/supervision/pull/2342): `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.QWEN_2_5_VL` no longer raises when the model returns valid JSON of the wrong shape (non-list top-level or non-dict elements). A non-string or malformed `"mask"` value in Gemini 2.5 output no longer triggers `AttributeError`; invalid base64 or non-PNG mask data falls back to an empty mask, keeping `xyxy`, `confidence`, and `masks` arrays aligned.
- Fixed [#2341](https://github.com/roboflow/supervision/pull/2341): `sv.DetectionDataset.as_pascal_voc` no longer mutates the source `Detections.xyxy` by the 1-index offset on every call. Previously, repeated exports accumulated a `+1` shift in the caller's bounding boxes.
- Fixed [#2334](https://github.com/roboflow/supervision/pull/2334): `sv.JSONSink` now serializes NumPy scalars (e.g. `np.int64` frame indices) in `custom_data` as JSON numbers instead of raising `TypeError` at close time. File handle is now guaranteed to close even when serialization fails.
- Fixed [#2333](https://github.com/roboflow/supervision/pull/2333): [`sv.DetectionsSmoother`](https://supervision.roboflow.com/latest/detection/tools/smoother/#supervision.detection.tools.smoother.DetectionsSmoother) no longer raises when smoothing detections without `confidence`. Confidence is now averaged over the frames that carry it; when tracks in the same frame disagree on confidence presence, `confidence` is set to `None` for all smoothed detections.
- Fixed [#2332](https://github.com/roboflow/supervision/pull/2332): `sv.approximate_polygon` now returns a polygon within the requested point-count budget (at most `floor(N * (1 - percentage))` points, minimum 3). The function now also validates that `epsilon_step > 0`.
- Fixed [#2331](https://github.com/roboflow/supervision/pull/2331): `sv.Precision` and `sv.F1Score` now count predictions on background images (empty target set) as false positives, and count predictions of classes absent from ground truth as false positives under `MICRO` and `MACRO` averaging. Previously both edge cases were silently ignored, inflating scores. `WEIGHTED` averaging is unchanged — absent classes retain weight 0, consistent with scikit-learn. Users relying on previous scores should re-evaluate after upgrading; no API change is required.
- Fixed [#2322](https://github.com/roboflow/supervision/pull/2322): COCO export now preserves all polygon parts for multi-component masks. Previously, only the first polygon was written when a non-crowd mask had disjoint segments; all parts are now included.
- Performance [#2339](https://github.com/roboflow/supervision/pull/2339): `sv.HaloAnnotator` now uses the same CompactMask painting path as `sv.MaskAnnotator` via a shared `_paint_masks_by_area` helper. On a 1080p frame with 30 CompactMask detections, `HaloAnnotator` runs approximately 4× faster; annotated output is unchanged.
- Performance [#2330](https://github.com/roboflow/supervision/pull/2330): `sv.mask_to_xyxy` and `sv.KeyPoints.as_detections` are now vectorized. `mask_to_xyxy` uses batched occupancy-profile reductions instead of per-mask pixel scans; `KeyPoints.as_detections` computes all bounding boxes in a single batch operation. Both produce bit-identical results.
- Performance [#2323](https://github.com/roboflow/supervision/pull/2323): Mask IoU computation now uses matrix multiplication on flattened masks instead of an explicit `(N, M, H, W)` intersection tensor, reducing peak memory for large mask sets. For masks larger than 4096×4096 pixels, computation automatically promotes to float64 to preserve exact pixel counts. Results are numerically identical.
### 0.29.0.post0 <small>Jun 17, 2026</small>
- Fixed [#2335](https://github.com/roboflow/supervision/pull/2335): `sv.KeyPoints(confidence=...)` now works again. The `0.29.0` refactor accidentally dropped the deprecated `confidence` constructor kwarg; it is now accepted and mapped to `keypoint_confidence` with a deprecation warning.
### 0.29.0 <small>Jun 15, 2026</small>
- Added [#2314](https://github.com/roboflow/supervision/pull/2314): new cookbook **Oriented Bounding Boxes** showing how an oriented box differs from an axis-aligned one on a marina of boats: DOTA-pretrained detection, the effect on [`with_nms`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nms) and [`Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area), and YOLO OBB dataset export.
- Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area) now returns the rotated body's area for detections carrying `data["xyxyxyxy"]` (oriented box corners) instead of the area of the derived axis-aligned bounding box, which overestimates by up to ~2x at 45° rotation. Affects annotator z-ordering inside [`MaskAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.HaloAnnotator), and any user code that filters or sorts OBB detections by area. The mask path and the non-OBB AABB fallback are unchanged.
- Added [#2277](https://github.com/roboflow/supervision/pull/2277), [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.VertexEllipseAreaAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseAreaAnnotator), [`sv.VertexEllipseOutlineAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseOutlineAnnotator), and [`sv.VertexEllipseHaloAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseHaloAnnotator) for visualizing keypoint uncertainty as covariance ellipses. Requires models that output keypoint uncertainty (e.g. RF-DETR keypoint models).
- Added [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.oriented_box_non_max_suppression`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_non_max_suppression) and [`sv.oriented_box_non_max_merge`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_non_max_merge) for performing NMS and NMM directly on oriented bounding boxes using oriented-box IoU instead of axis-aligned IoU.
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/0.29.0/detection/metrics/#supervision.metrics.detection.ConfusionMatrix) now supports `MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` on `xyxyxyxy` corners. Previously, OBB inputs silently fell back to axis-aligned bounding-box IoU, producing incorrect match scores for rotated detections.
- Added [#2252](https://github.com/roboflow/supervision/pull/2252): [`sv.process_video`](https://supervision.roboflow.com/0.29.0/utils/video/#supervision.utils.video.process_video) gains a `preserve_audio` parameter. When enabled, the audio stream from the source video is muxed into the output using ffmpeg.
- Added [#2302](https://github.com/roboflow/supervision/pull/2302), [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) gains an `is_obb` parameter for exporting oriented bounding box annotations in the YOLO OBB format (9-token lines with 4 corner coordinates).
- Added [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.xyxyxyxy_to_xyxy`](https://supervision.roboflow.com/0.29.0/detection/utils/boxes/#supervision.detection.utils.boxes.xyxyxyxy_to_xyxy) — vectorised utility that converts oriented bounding box corners `(N, 4, 2)` to axis-aligned bounding boxes `(N, 4)`.
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.KeyPoints`](https://supervision.roboflow.com/0.29.0/keypoint/core/#supervision.key_points.core.KeyPoints) now separates keypoint-level and detection-level confidence into distinct fields: `keypoint_confidence` (shape `(n, m)`) and `detection_confidence` (shape `(n,)`). A new `visible` mask (shape `(n, m)`) controls per-keypoint visibility. The legacy `KeyPoints.confidence` property still works but is deprecated.
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.EdgeAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`sv.VertexAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexAnnotator) now respect the `visible` mask. Invisible keypoints and their edges are skipped during rendering.
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.EdgeAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`sv.VertexLabelAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexLabelAnnotator) now support per-class skeleton definitions, enabling correct rendering when multiple skeleton topologies (e.g. person + animal) coexist in one frame.
- Changed [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.Detections.with_nms`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nms) and [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now use oriented-box IoU when `data["xyxyxyxy"]` coordinates are present, instead of axis-aligned box IoU. Callers relying on the previous axis-aligned behaviour should remove `data["xyxyxyxy"]` before calling, or recalibrate any IoU thresholds.
- Changed [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now computes the merged oriented bounding box as the tightest rectangle at the winner's orientation enclosing all corners from every detection in a merge group.
- Changed [#2325](https://github.com/roboflow/supervision/pull/2325): [`sv.VertexEllipseAreaAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseAreaAnnotator), [`sv.VertexEllipseOutlineAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseOutlineAnnotator), and [`sv.VertexEllipseHaloAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseHaloAnnotator) now draw sigma levels level-by-level (outermost first) across all points, ensuring correct visual layering when ellipses overlap.
- Changed [#2256](https://github.com/roboflow/supervision/pull/2256): [`sv.InferenceSlicer`](https://supervision.roboflow.com/0.29.0/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now detects OBB outputs from callbacks and automatically falls back to sequential processing to avoid thread-safety issues when `thread_workers > 1`.
- Changed [#2324](https://github.com/roboflow/supervision/pull/2324): Project-wide deprecation policy unified to a minimum 3-minor-release window. All current deprecations (including `KeyPoints.confidence` and `validate_*` helpers) are scheduled for removal in `0.32.0`.
- Fixed [#2252](https://github.com/roboflow/supervision/pull/2252): [`sv.process_video`](https://supervision.roboflow.com/0.29.0/utils/video/#supervision.utils.video.process_video) audio muxing path now correctly creates temp files on the same filesystem, decodes ffmpeg errors, and avoids muxing incomplete output.
- Fixed [#2282](https://github.com/roboflow/supervision/pull/2282), [#2317](https://github.com/roboflow/supervision/pull/2317): [`sv.oriented_box_iou_batch`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_iou_batch) now computes exact IoU via convex polygon intersection (`cv2.intersectConvexConvex`) and uses an axis-aligned bounding box envelope gate to skip pairs that cannot overlap, improving both accuracy and performance. Previously, rasterization on a discrete grid was used, which assumed square dimensions and introduced quantisation noise.
- Fixed [#2239](https://github.com/roboflow/supervision/pull/2239): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_vlm) no longer returns `None` for `class_id` on empty VLM parses; now returns an empty int ndarray.
- Fixed [#2270](https://github.com/roboflow/supervision/pull/2270): [`sv.Detections.from_inference`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_inference) now preserves `class_name` as a string-dtype array when predictions are empty.
- Fixed [#2269](https://github.com/roboflow/supervision/pull/2269): [`sv.HeatMapAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.HeatMapAnnotator) no longer crashes with a divide-by-zero when called with empty detections.
- Fixed [#2276](https://github.com/roboflow/supervision/pull/2276): COCO export now emits 1-indexed `category_id` values as required by the COCO specification.
- Fixed [#2267](https://github.com/roboflow/supervision/pull/2267): COCO annotation and image IDs are now sequential across train/val/test splits via `starting_image_id` and `starting_annotation_id` parameters.
- Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) no longer loses OBB rotation when exporting oriented bounding boxes.
- Fixed [#2296](https://github.com/roboflow/supervision/pull/2296): YOLO dataset loading now sorts class names by numeric keys when `data.yaml` uses integer class IDs.
- Fixed [#2297](https://github.com/roboflow/supervision/pull/2297): Letterbox utility now supports grayscale images.
- Fixed [#2298](https://github.com/roboflow/supervision/pull/2298): File extension filters now normalize casing (e.g. `.JPG` matches `.jpg`).
- Fixed [#2321](https://github.com/roboflow/supervision/pull/2321): [`sv.DetectionDataset.as_coco()`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) now round-trips polygon and RLE segmentation data. Segmentations loaded from COCO annotations are preserved in `detections.data["coco_raw_segmentation"]` and written back on export, preventing data loss in train/val/test split workflows.
- Deprecated: `KeyPoints.confidence` (use `KeyPoints.keypoint_confidence`), `merge_inner_detection_object_pair`, `merge_inner_detections_objects`, `merge_inner_detections_objects_without_iou`, `validate_detections_fields`, `validate_vlm_parameters`, `validate_fields_both_defined_or_none`, `validate_xyxy`, `validate_mask`, `validate_class_id`, `validate_confidence`, `validate_tracker_id`, `validate_data`, `validate_xy`, `validate_key_point_confidence`, `validate_key_points_fields`, `validate_resolution`, `validate_custom_values`, `validate_input_tensors`, and `validate_labels` are deprecated in `0.29.0` and will be removed in `0.32.0`.
### 0.28.0 <small>Apr 30, 2026</small>
- Added [#2159](https://github.com/roboflow/supervision/pull/2159): [`sv.CompactMask`](https://supervision.roboflow.com/latest/detection/compact_mask/#supervision.detection.compact_mask.CompactMask) for memory-efficient mask storage. Masks are stored as crop-region bounding boxes plus RLE-encoded data instead of full-resolution bitmaps, reducing memory by up to 240× for sparse masks. Integrates transparently with `sv.Detections.mask` — filtering, merging, and `area` all work without materialising the full array.
- Added [#2227](https://github.com/roboflow/supervision/pull/2227): [`sv.CompactMask.resize(new_image_shape)`](https://supervision.roboflow.com/latest/detection/compact_mask/#supervision.detection.compact_mask.CompactMask.resize) rescales all stored crops to match a new image resolution, enabling use across frames or after image resizing pipelines.
- Added [#2178](https://github.com/roboflow/supervision/pull/2178): [`sv.Detections.from_inference`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.from_inference) now supports compressed COCO RLE masks. Inference responses with `rle` or `rle_mask` fields containing a compressed counts string (as produced by `pycocotools`) are decoded directly into binary masks, avoiding a lossy polygon round-trip.
- Added [#2004](https://github.com/roboflow/supervision/pull/2004): [`sv.Color.from_hex`](https://supervision.roboflow.com/latest/utils/draw/#supervision.draw.color.Color.from_hex) now accepts 8-digit hexadecimal RGBA codes (e.g. `#ff00ff80`). [`Color.as_hex()`](https://supervision.roboflow.com/latest/utils/draw/#supervision.draw.color.Color.as_hex) serialises back, including alpha when not fully opaque. New utility functions `sv.hex_to_rgba`, `sv.rgba_to_hex`, and `sv.is_valid_hex` are exported at the top level.
- Added [#709](https://github.com/roboflow/supervision/pull/709): [`sv.BlurAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.BlurAnnotator) and [`sv.PixelateAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.PixelateAnnotator) now support dynamic sizing. When `kernel_size=None` or `pixel_size=None` (the new default), the size is computed per detection as a fraction of the shorter bounding-box dimension, producing consistent visual results across objects of different sizes.
- Added [#2186](https://github.com/roboflow/supervision/pull/2186): [`sv.InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now emits a warning when detections returned by the callback fall outside the tile boundaries, helping catch coordinate-system bugs in custom callbacks.
- Added [#2103](https://github.com/roboflow/supervision/pull/2103), [#2152](https://github.com/roboflow/supervision/pull/2152): New [`sv.Detections.from_sam3()`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.from_sam3) classmethod parses SAM3 PCS (text-prompted) and PVS (visual-prompted video segmentation) response formats into a standard `sv.Detections`, both from the local `inference` package and from Roboflow-hosted server responses.
- Added [#2154](https://github.com/roboflow/supervision/pull/2154): The library now uses Python's `logging` module instead of `print` for diagnostic output. Messages are emitted under the `supervision` logger so applications can capture, filter, or silence them through standard `logging` configuration.
- Added [#932](https://github.com/roboflow/supervision/pull/932): [`sv.ImageAssets`](https://supervision.roboflow.com/latest/assets/) for downloading sample images alongside existing video assets, useful for examples and tutorials.
- Changed [#2169](https://github.com/roboflow/supervision/pull/2169): [`sv.MeanAveragePrecisionResult`](https://supervision.roboflow.com/latest/metrics/mean_average_precision/) and related metric arrays (`mAP_scores`, `ap_per_class`, `iou_thresholds`, precision/recall) are now `float32` instead of `float64`. Reduces memory and speeds up computation; numerical results may differ in the last few digits.
- Changed [#2178](https://github.com/roboflow/supervision/pull/2178): [`sv.rle_to_mask`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.rle_to_mask) and [`sv.mask_to_rle`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.mask_to_rle) moved to `supervision.detection.utils.converters`. The old import path `supervision.dataset.utils` continues to work but is deprecated.
@@ -14,6 +121,46 @@ description: "Full version history of the supervision Python library — release
- Fixed [#2210](https://github.com/roboflow/supervision/pull/2210): [`sv.VideoInfo.fps`](https://supervision.roboflow.com/latest/utils/video/#supervision.utils.video.VideoInfo) now returns a `float` instead of a truncated `int`. Previously, frame rates like 23.976, 29.97, and 59.94 were silently truncated, causing frame-timing drift that accumulates over long videos. The type of `VideoInfo.fps` has changed from `int` to `float`; callers that pass `fps` to APIs requiring an integer (such as `deque(maxlen=...)` or `TraceAnnotator(trace_length=...)`) should wrap the value with `int()`.
- Fixed [#2209](https://github.com/roboflow/supervision/pull/2209): [`sv.Detections.is_empty()`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.is_empty) now returns `True` for detections filtered down to zero rows, even when `tracker_id` is an empty array. Previously this case incorrectly returned `False`.
- Fixed [#2199](https://github.com/roboflow/supervision/pull/2199): [`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) now correctly slices numpy array values in `custom_data` per row. Previously the full array was written for every detection.
- Fixed [#2216](https://github.com/roboflow/supervision/pull/2216): [`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) and [`sv.JSONSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.json_sink.JSONSink) now slice plain Python `list` and `tuple` values in `custom_data` per detection row. Lists and tuples matching the detection count are indexed per row, consistent with `np.ndarray` behavior.
- Fixed [#2217](https://github.com/roboflow/supervision/pull/2217): [`sv.TraceAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.TraceAnnotator) no longer crashes in `smooth` mode when a tracker remains stationary. Duplicate consecutive points caused `splprep` to fail; the annotator now deduplicates anchor points and falls back to a raw polyline when fewer than 4 unique points are available.
- Fixed [#2218](https://github.com/roboflow/supervision/pull/2218): [`load_coco_annotations`](https://supervision.roboflow.com/latest/datasets/core/) now rejects COCO annotations whose `file_name` escapes the images directory via `../` traversal or absolute paths, preventing path-traversal attacks from malicious annotation files.
- Fixed [#2187](https://github.com/roboflow/supervision/pull/2187): Extreme memory usage when loading OBB (oriented bounding box) datasets, caused by allocating full-image masks for each rotated box, has been resolved.
- Fixed [#2188](https://github.com/roboflow/supervision/pull/2188): [`sv.KeyPoints`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints) boolean mask indexing now works correctly when all instances have the same keypoint count (uniform-count selection).
- Fixed [#2185](https://github.com/roboflow/supervision/pull/2185): [`sv.DetectionDataset.as_coco()`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) now preserves `area` and `iscrowd` fields instead of silently dropping them in the round-trip.
- Fixed [#1746](https://github.com/roboflow/supervision/pull/1746): Precision loss when converting annotations with `force_mask=True` in dataset format converters.
- Fixed [#1991](https://github.com/roboflow/supervision/pull/1991): [`sv.PolygonZone`](https://supervision.roboflow.com/latest/detection/tools/polygon_zone/) no longer double-counts the same object when multiple zones overlap. Detection bounding boxes were incorrectly clipped to each zone's ROI before anchor computation, causing the same detection to appear at a different anchor point in each zone; anchor is now computed from the original bounding box so containment is independent per zone.
- Fixed [#1868](https://github.com/roboflow/supervision/pull/1868): [`sv.LineZone`](https://supervision.roboflow.com/latest/detection/tools/line_zone/) no longer mis-attributes crossings when a tracker reuses the same `tracker_id` across different classes. Class-aware bookkeeping prevents a new object from inheriting another class's prior crossing state.
- Fixed [#2022](https://github.com/roboflow/supervision/pull/2022): [`sv.process_video`](https://supervision.roboflow.com/latest/utils/video/#supervision.utils.video.process_video) now raises immediately when the user callback throws, instead of silently swallowing the exception and hanging until the writer is flushed.
- Fixed [#2156](https://github.com/roboflow/supervision/pull/2156): [`sv.DetectionDataset`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset) now populates `data["class_name"]` on every loaded annotation, matching what model connectors produce. Downstream code can rely on `class_name` being present whether detections come from a dataset or a model.
- Fixed [#1364](https://github.com/roboflow/supervision/pull/1364): [`sv.ByteTrack`](https://supervision.roboflow.com/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) now preserves externally assigned `tracker_id` values instead of overwriting them with internal ids on the first update.
- Fixed [#1853](https://github.com/roboflow/supervision/pull/1853): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/latest/detection/metrics/#supervision.metrics.detection.ConfusionMatrix) `evaluate_detection_batch` now matches predictions to ground truth correctly when multiple detections fall on the same target. Previously, double-counting inflated false-positive and false-negative counts.
- Fixed [#2136](https://github.com/roboflow/supervision/pull/2136): [`sv.MeanAverageRecall`](https://supervision.roboflow.com/latest/metrics/mean_average_recall/) now computes mAR@K using the top-K detections per image, matching the COCO definition. Previous values were inflated relative to `pycocotools`.
- Fixed [#1086](https://github.com/roboflow/supervision/pull/1086), [#265](https://github.com/roboflow/supervision/pull/265): COCO export and `force_masks` behaviour are now consistent across dataset formats. Empty polygons no longer raise during `as_coco`, and `force_masks=True` produces masks regardless of source format.
- Deprecated [#2215](https://github.com/roboflow/supervision/pull/2215): [`sv.ByteTrack`](https://supervision.roboflow.com/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) is deprecated in favour of `ByteTrackTracker` from the external [`trackers`](https://pypi.org/project/trackers/) package (`pip install trackers`). The update method is renamed from `update_with_detections()` to `update()`. Removal planned for `supervision-0.30.0`.
- Deprecated [#2214](https://github.com/roboflow/supervision/pull/2214): `supervision.keypoint` module is deprecated; use `supervision.key_points` instead. `create_tiles` in `supervision.utils.image`, `ensure_cv2_image_for_processing` in `supervision.utils.conversion`, and keypoint validation utilities in `supervision.validators` are deprecated. The `LMM` enum (use `VLM`) and `from_lmm` method (use `from_vlm`) were deprecated in 0.26.0; this release migrates their deprecation mechanism to `pydeprecate`.
- Deprecated: `normalized_xyxy` argument in [`sv.denormalize_boxes`](https://supervision.roboflow.com/latest/detection/utils/boxes/#supervision.detection.utils.boxes.denormalize_boxes) renamed to `xyxy`. Passing `normalized_xyxy=` now emits a `FutureWarning`; support will be removed in `supervision-0.30.0`.
### 0.27.0 <small>Nov 16, 2025</small>
- Added [#2008](https://github.com/roboflow/supervision/pull/2008): [`sv.filter_segments_by_distance`](https://supervision.roboflow.com/0.27.0/detection/utils/masks/#supervision.detection.utils.masks.filter_segments_by_distance) to keep the largest connected component and nearby components within an absolute or relative distance threshold. Useful for cleaning segmentation predictions from models such as SAM, SAM2, YOLO segmentation, and RF-DETR segmentation.
@@ -307,7 +454,7 @@ sv.process_video(
- Added `as_detections` method to [`KeyPoints`](https://supervision.roboflow.com/0.25.0/keypoint/core/#supervision.keypoint.core.KeyPoints) that converts `KeyPoints` to `Detections`. ([#1658](https://github.com/roboflow/supervision/pull/1658))
- Added a new video to `supervision[assets]`. ([#1657](https://github.com/roboflow/supervision/pull/1657))
- Added a new video to the `supervision.assets` download catalog. ([#1657](https://github.com/roboflow/supervision/pull/1657))
```python
from supervision.assets import download_assets, VideoAssets
+29
View File
@@ -0,0 +1,29 @@
---
comments: true
description: Contact and support channels for Supervision documentation, bugs, feature requests, security reports, and community help.
---
# Contact
Use the channel that matches the kind of request you have.
## Bugs and Feature Requests
Open a GitHub issue for reproducible bugs, API requests, documentation fixes, and feature proposals:
[github.com/roboflow/supervision/issues](https://github.com/roboflow/supervision/issues)
## Community Support
Join the Roboflow Discord for community questions, examples, and implementation discussion:
[discord.gg/GbfgXGJ8Bk](https://discord.gg/GbfgXGJ8Bk)
## Package and Source
- PyPI package: [pypi.org/project/supervision](https://pypi.org/project/supervision/)
- GitHub repository: [github.com/roboflow/supervision](https://github.com/roboflow/supervision)
## Security Reports
For security-sensitive reports, avoid posting public exploit details in an issue. Use GitHub's private vulnerability reporting flow for the repository when available, or contact Roboflow through the security and support channels listed on [roboflow.com](https://roboflow.com/).
+1
View File
@@ -1,6 +1,7 @@
---
template: cookbooks.html
comments: true
description: Collection of practical computer vision cookbooks — object tracking, zero-shot detection, SAHI small object detection, occupancy analytics, and more.
hide:
- navigation
- toc
+1 -2
View File
@@ -7,8 +7,7 @@ description: API reference for supervision's DetectionDataset and Classification
!!! warning
Dataset API is still fluid and may change. If you use Dataset API in your project until further notice, freeze the
`supervision` version in your `requirements.txt` or `setup.py`.
Dataset API is still fluid and may change. If you use Dataset API in your project until further notice, freeze the `supervision` version in your `requirements.txt` or `setup.py`.
<div class="md-typeset">
<h2>DetectionDataset</h2>
+15 -4
View File
@@ -5,20 +5,31 @@ status: deprecated
# Deprecated
These features are phased out due to better alternatives or potential issues in future versions. Deprecated functionalities are supported for **five subsequent releases**, providing time for users to transition to updated methods.
These features are phased out due to better alternatives or potential issues in future versions. Deprecated functionalities are typically supported for multiple subsequent releases, providing time for users to transition to updated methods.
- `overlap_ratio_wh` in [`InferenceSlicer.__init__`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/) is deprecated and will be removed in `supervision-0.27.0`. Please set it to `None` and use `overlap_wh` instead.
- [`sv.ByteTrack`](https://supervision.roboflow.com/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) is deprecated in favour of `ByteTrackTracker` from the external [`trackers`](https://pypi.org/project/trackers/) package (`pip install trackers`). The update method is renamed from `update_with_detections()` to `update()`. Removal planned for `supervision-0.30.0`.
- `supervision.keypoint` module is deprecated; use `supervision.key_points` instead. Will be removed in `supervision-0.30.0`.
- `create_tiles` in `supervision.utils.image` is deprecated. Will be removed in `supervision-0.31.0`.
- `ensure_cv2_image_for_processing` in `supervision.utils.conversion` is deprecated. Will be removed in `supervision-0.31.0`.
- Keypoint validation utilities in `supervision.validators` are deprecated. Will be removed in `supervision-0.31.0`.
- `normalized_xyxy` argument in [`sv.denormalize_boxes`](https://supervision.roboflow.com/latest/detection/utils/boxes/#supervision.detection.utils.boxes.denormalize_boxes) is renamed to `xyxy`. Passing `normalized_xyxy=` emits a `FutureWarning`; support will be removed in `supervision-0.30.0`.
- `supervision.dataset.utils` import path for [`sv.rle_to_mask`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.rle_to_mask) and [`sv.mask_to_rle`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.mask_to_rle) is deprecated. These functions moved to `supervision.detection.utils.converters`. Will be removed in `supervision-0.30.0`.
- `sv.LMM` enum is deprecated and will be removed in `supervision-0.31.0`. Use `sv.VLM` instead.
- [`sv.Detections.from_lmm`](https://supervision.roboflow.com/0.26.0/detection/core/#supervision.detection.core.Detections.from_lmm) property is deprecated and will be removed in `supervision-0.31.0`. Use [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.26.0/detection/core/#supervision.detection.core.Detections.from_vlm) instead.
- [`sv.Detections.from_lmm`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.from_lmm) property is deprecated and will be removed in `supervision-0.31.0`. Use [`sv.Detections.from_vlm`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.from_vlm) instead.
- Public `validate_*` helper functions are deprecated and will be removed in `supervision-0.31.0`. Supervision internals now use private `_validate_*` helpers.
# Removed
### 0.27.0
- `overlap_ratio_wh` parameter in [`sv.InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/) has been removed. Use the pixel-based `overlap_wh` parameter instead.
- `overlap_filter_strategy` parameter in [`sv.InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/) has been removed. Use `overlap_strategy` instead.
### 0.26.0
- The `sv.DetectionDataset.images` property has been removed in `supervision-0.26.0`. Please loop over images with `for path, image, annotation in dataset:`, as that does not require loading all images into memory. Also, constructing `sv.DetectionDataset` with parameter `images` as `Dict[str, np.ndarray]` is deprecated and has been removed in `supervision-0.26.0`. Please pass a list of paths `List[str]` instead.
- The name `sv.BoundingBoxAnnotator` is deprecated and has been removed in `supervision-0.26.0`. It has been renamed to [`sv.BoxAnnotator`](https://supervision.roboflow.com/0.22.0/detection/annotators/#supervision.annotators.core.BoxAnnotator).
### 0.24.0
- The `frame_resolution_wh ` parameter in [`sv.PolygonZone`](detection/tools/polygon_zone.md/#supervision.detection.tools.polygon_zone.PolygonZone) has been removed.
+445 -424
View File
@@ -7,510 +7,531 @@ description: API reference for supervision's annotator classes — draw bounding
Annotators accept detections and apply box or mask visualizations to the detections. Annotators have many available styles.
=== "Box"
=== "Outlines"
```python
import supervision as sv
=== "Box"
image = ...
detections = sv.Detections(...)
```python
import supervision as sv
box_annotator = sv.BoxAnnotator()
annotated_frame = box_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
image = ...
detections = sv.Detections(...)
<div class="result" markdown>
![bounding-box-annotator-example](https://media.roboflow.com/supervision-annotator-examples/bounding-box-annotator-example-purple.png){ align=center width="800" }
</div>
=== "RoundBox"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
round_box_annotator = sv.RoundBoxAnnotator()
annotated_frame = round_box_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![round-box-annotator-example](https://media.roboflow.com/supervision-annotator-examples/round-box-annotator-example-purple.png){ align=center width="800" }
</div>
=== "BoxCorner"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
corner_annotator = sv.BoxCornerAnnotator()
annotated_frame = corner_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![box-corner-annotator-example](https://media.roboflow.com/supervision-annotator-examples/box-corner-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Color"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
color_annotator = sv.ColorAnnotator()
annotated_frame = color_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![box-mask-annotator-example](https://media.roboflow.com/supervision-annotator-examples/box-mask-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Circle"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
circle_annotator = sv.CircleAnnotator()
annotated_frame = circle_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![circle-annotator-example](https://media.roboflow.com/supervision-annotator-examples/circle-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Dot"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
dot_annotator = sv.DotAnnotator()
annotated_frame = dot_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![dot-annotator-example](https://media.roboflow.com/supervision-annotator-examples/dot-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Triangle"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
triangle_annotator = sv.TriangleAnnotator()
annotated_frame = triangle_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![triangle-annotator-example](https://media.roboflow.com/supervision-annotator-examples/triangle-annotator-example.png){ align=center width="800" }
</div>
=== "Ellipse"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
ellipse_annotator = sv.EllipseAnnotator()
annotated_frame = ellipse_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![ellipse-annotator-example](https://media.roboflow.com/supervision-annotator-examples/ellipse-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Halo"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
halo_annotator = sv.HaloAnnotator()
annotated_frame = halo_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![halo-annotator-example](https://media.roboflow.com/supervision-annotator-examples/halo-annotator-example-purple.png){ align=center width="800" }
</div>
=== "PercentageBar"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
percentage_bar_annotator = sv.PercentageBarAnnotator()
annotated_frame = percentage_bar_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![percentage-bar-annotator-example](https://media.roboflow.com/supervision-annotator-examples/percentage-bar-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Mask"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
mask_annotator = sv.MaskAnnotator()
annotated_frame = mask_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![mask-annotator-example](https://media.roboflow.com/supervision-annotator-examples/mask-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Polygon"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
polygon_annotator = sv.PolygonAnnotator()
annotated_frame = polygon_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![polygon-annotator-example](https://media.roboflow.com/supervision-annotator-examples/polygon-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Label"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
labels = [
f"{class_name} {confidence:.2f}"
for class_name, confidence in zip(
detections["class_name"],
detections.confidence,
box_annotator = sv.BoxAnnotator()
annotated_frame = box_annotator.annotate(
scene=image.copy(),
detections=detections,
)
]
```
label_annotator = sv.LabelAnnotator(text_position=sv.Position.CENTER)
annotated_frame = label_annotator.annotate(
scene=image.copy(), detections=detections, labels=labels
)
```
<div class="result" markdown>
<div class="result" markdown>
![bounding-box-annotator-example](https://media.roboflow.com/supervision-annotator-examples/bounding-box-annotator-example-purple.png){ align=center width="800" }
![label-annotator-example](https://media.roboflow.com/supervision-annotator-examples/label-annotator-example-purple.png){ align=center width="800" }
</div>
</div>
=== "RoundBox"
=== "RichLabel"
```python
import supervision as sv
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
image = ...
detections = sv.Detections(...)
labels = [
f"{class_name} {confidence:.2f}"
for class_name, confidence in zip(
detections["class_name"],
detections.confidence,
round_box_annotator = sv.RoundBoxAnnotator()
annotated_frame = round_box_annotator.annotate(
scene=image.copy(),
detections=detections,
)
]
```
rich_label_annotator = sv.RichLabelAnnotator(
font_path="TTF_FONT_PATH",
text_position=sv.Position.CENTER,
)
annotated_frame = rich_label_annotator.annotate(
scene=image.copy(),
detections=detections,
labels=labels,
)
```
<div class="result" markdown>
<div class="result" markdown>
![round-box-annotator-example](https://media.roboflow.com/supervision-annotator-examples/round-box-annotator-example-purple.png){ align=center width="800" }
![label-annotator-example](https://media.roboflow.com/supervision-annotator-examples/label-annotator-example-purple.png){ align=center width="800" }
</div>
</div>
=== "BoxCorner"
=== "Icon"
```python
import supervision as sv
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
image = ...
detections = sv.Detections(...)
corner_annotator = sv.BoxCornerAnnotator()
annotated_frame = corner_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
icon_paths = ["<ICON_PATH>" for _ in detections]
<div class="result" markdown>
icon_annotator = sv.IconAnnotator()
annotated_frame = icon_annotator.annotate(
scene=image.copy(),
detections=detections,
icon_path=icon_paths,
)
```
![box-corner-annotator-example](https://media.roboflow.com/supervision-annotator-examples/box-corner-annotator-example-purple.png){ align=center width="800" }
<div class="result" markdown>
</div>
![icon-annotator-example](https://media.roboflow.com/supervision-annotator-examples/icon-annotator-example.png){ align=center width="800" }
=== "Circle"
</div>
```python
import supervision as sv
<!-- === "Crop"
image = ...
detections = sv.Detections(...)
```python
import supervision as sv
circle_annotator = sv.CircleAnnotator()
annotated_frame = circle_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
image = ...
detections = sv.Detections(...)
<div class="result" markdown>
crop_annotator = sv.CropAnnotator()
annotated_frame = crop_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
![circle-annotator-example](https://media.roboflow.com/supervision-annotator-examples/circle-annotator-example-purple.png){ align=center width="800" }
<div class="result" markdown>
</div>
![crop-annotator-example](https://media.roboflow.com/supervision-annotator-examples/crop-annotator-example.png){ align=center width="800" }
=== "Ellipse"
</div>
```python
import supervision as sv
-->
image = ...
detections = sv.Detections(...)
=== "Blur"
ellipse_annotator = sv.EllipseAnnotator()
annotated_frame = ellipse_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
```python
import supervision as sv
<div class="result" markdown>
image = ...
detections = sv.Detections(...)
![ellipse-annotator-example](https://media.roboflow.com/supervision-annotator-examples/ellipse-annotator-example-purple.png){ align=center width="800" }
blur_annotator = sv.BlurAnnotator()
annotated_frame = (blur_annotator.annotate(scene=image.copy(), detections=detections),)
```
</div>
<div class="result" markdown>
=== "Polygon"
![blur-annotator-example](https://media.roboflow.com/supervision-annotator-examples/blur-annotator-example-purple.png){ align=center width="800" }
```python
import supervision as sv
</div>
image = ...
detections = sv.Detections(...)
=== "Pixelate"
polygon_annotator = sv.PolygonAnnotator()
annotated_frame = polygon_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
```python
import supervision as sv
<div class="result" markdown>
image = ...
detections = sv.Detections(...)
![polygon-annotator-example](https://media.roboflow.com/supervision-annotator-examples/polygon-annotator-example-purple.png){ align=center width="800" }
pixelate_annotator = sv.PixelateAnnotator()
annotated_frame = pixelate_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
</div>
<div class="result" markdown>
=== "Shading"
![pixelate-annotator-example](https://media.roboflow.com/supervision-annotator-examples/pixelate-annotator-example-10.png){ align=center width="800" }
=== "Color"
</div>
```python
import supervision as sv
=== "Trace"
image = ...
detections = sv.Detections(...)
```python
import supervision as sv
from ultralytics import YOLO
color_annotator = sv.ColorAnnotator()
annotated_frame = color_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
model = YOLO("yolov8x.pt")
<div class="result" markdown>
trace_annotator = sv.TraceAnnotator()
![box-mask-annotator-example](https://media.roboflow.com/supervision-annotator-examples/box-mask-annotator-example-purple.png){ align=center width="800" }
video_info = sv.VideoInfo.from_video_path(video_path="...")
frames_generator = sv.get_video_frames_generator(source_path="...")
tracker = sv.ByteTrack()
</div>
with sv.VideoSink(target_path="...", video_info=video_info) as sink:
for frame in frames_generator:
result = model(frame)[0]
detections = sv.Detections.from_ultralytics(result)
detections = tracker.update_with_detections(detections)
annotated_frame = trace_annotator.annotate(
scene=frame.copy(),
detections=detections,
=== "Halo"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
halo_annotator = sv.HaloAnnotator()
annotated_frame = halo_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![halo-annotator-example](https://media.roboflow.com/supervision-annotator-examples/halo-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Mask"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
mask_annotator = sv.MaskAnnotator()
annotated_frame = mask_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
!!! note
`MaskAnnotator` expects `detections.mask` to contain instance segmentation masks aligned to the image passed to `annotate`. For dense masks, provide a boolean array of shape `(N, H, W)` where `(H, W)` matches the image height and width (it also accepts `sv.CompactMask`). If your model returns framework-specific results, convert them to `sv.Detections` first, for example with `sv.Detections.from_ultralytics(...)` or `sv.Detections.from_inference(...)`.
<div class="result" markdown>
![mask-annotator-example](https://media.roboflow.com/supervision-annotator-examples/mask-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Markers"
=== "Dot"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
dot_annotator = sv.DotAnnotator()
annotated_frame = dot_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![dot-annotator-example](https://media.roboflow.com/supervision-annotator-examples/dot-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Triangle"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
triangle_annotator = sv.TriangleAnnotator()
annotated_frame = triangle_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![triangle-annotator-example](https://media.roboflow.com/supervision-annotator-examples/triangle-annotator-example.png){ align=center width="800" }
</div>
=== "Labels"
=== "Label"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
labels = [
f"{class_name} {confidence:.2f}"
for class_name, confidence in zip(
detections["class_name"],
detections.confidence,
)
sink.write_frame(frame=annotated_frame)
```
]
<div class="result" markdown>
label_annotator = sv.LabelAnnotator(text_position=sv.Position.CENTER)
annotated_frame = label_annotator.annotate(
scene=image.copy(), detections=detections, labels=labels
)
```
![trace-annotator-example](https://media.roboflow.com/supervision-annotator-examples/trace-annotator-example-purple.png){ align=center width="800" }
<div class="result" markdown>
</div>
![label-annotator-example](https://media.roboflow.com/supervision-annotator-examples/label-annotator-example-purple.png){ align=center width="800" }
=== "HeatMap"
</div>
```python
import supervision as sv
from ultralytics import YOLO
=== "RichLabel"
model = YOLO("yolov8x.pt")
```python
import supervision as sv
heat_map_annotator = sv.HeatMapAnnotator()
image = ...
detections = sv.Detections(...)
video_info = sv.VideoInfo.from_video_path(video_path="...")
frames_generator = sv.get_video_frames_generator(source_path="...")
with sv.VideoSink(target_path="...", video_info=video_info) as sink:
for frame in frames_generator:
result = model(frame)[0]
detections = sv.Detections.from_ultralytics(result)
annotated_frame = heat_map_annotator.annotate(
scene=frame.copy(),
detections=detections,
labels = [
f"{class_name} {confidence:.2f}"
for class_name, confidence in zip(
detections["class_name"],
detections.confidence,
)
sink.write_frame(frame=annotated_frame)
```
]
<div class="result" markdown>
rich_label_annotator = sv.RichLabelAnnotator(
font_path="TTF_FONT_PATH",
text_position=sv.Position.CENTER,
)
annotated_frame = rich_label_annotator.annotate(
scene=image.copy(),
detections=detections,
labels=labels,
)
```
![heat-map-annotator-example](https://media.roboflow.com/supervision-annotator-examples/heat-map-annotator-example-purple.png){ align=center width="800" }
<div class="result" markdown>
</div>
![label-annotator-example](https://media.roboflow.com/supervision-annotator-examples/label-annotator-example-purple.png){ align=center width="800" }
=== "Background Color"
</div>
```python
import supervision as sv
=== "Transformative"
image = ...
detections = sv.Detections(...)
=== "Blur"
background_overlay_annotator = sv.BackgroundOverlayAnnotator()
annotated_frame = background_overlay_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
```python
import supervision as sv
<div class="result" markdown>
image = ...
detections = sv.Detections(...)
![background-overlay-annotator-example](https://media.roboflow.com/supervision-annotator-examples/background-color-annotator-example-purple.png){ align=center width="800" }
blur_annotator = sv.BlurAnnotator()
annotated_frame = blur_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
</div>
<div class="result" markdown>
=== "Comparison"
![blur-annotator-example](https://media.roboflow.com/supervision-annotator-examples/blur-annotator-example-purple.png){ align=center width="800" }
```python
import supervision as sv
</div>
image = ...
detections_1 = sv.Detections(...)
detections_2 = sv.Detections(...)
=== "Pixelate"
comparison_annotator = sv.ComparisonAnnotator()
annotated_frame = comparison_annotator.annotate(
scene=image.copy(),
detections_1=detections_1,
detections_2=detections_2,
)
```
```python
import supervision as sv
<div class="result" markdown>
image = ...
detections = sv.Detections(...)
![comparison-annotator-example](https://media.roboflow.com/supervision-annotator-examples/comparison-annotator-example.png){ align=center width="800" }
pixelate_annotator = sv.PixelateAnnotator()
annotated_frame = pixelate_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
</div>
<div class="result" markdown>
![pixelate-annotator-example](https://media.roboflow.com/supervision-annotator-examples/pixelate-annotator-example-10.png){ align=center width="800" }
</div>
<!-- === "Crop"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
crop_annotator = sv.CropAnnotator()
annotated_frame = crop_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![crop-annotator-example](https://media.roboflow.com/supervision-annotator-examples/crop-annotator-example.png){ align=center width="800" }
</div>
-->
=== "Tracking & Aggregation"
=== "Trace"
```python
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8x.pt")
trace_annotator = sv.TraceAnnotator()
video_info = sv.VideoInfo.from_video_path(video_path="...")
frames_generator = sv.get_video_frames_generator(source_path="...")
tracker = sv.ByteTrack()
with sv.VideoSink(target_path="...", video_info=video_info) as sink:
for frame in frames_generator:
result = model(frame)[0]
detections = sv.Detections.from_ultralytics(result)
detections = tracker.update_with_detections(detections)
annotated_frame = trace_annotator.annotate(
scene=frame.copy(),
detections=detections,
)
sink.write_frame(frame=annotated_frame)
```
<div class="result" markdown>
![trace-annotator-example](https://media.roboflow.com/supervision-annotator-examples/trace-annotator-example-purple.png){ align=center width="800" }
</div>
=== "HeatMap"
```python
import supervision as sv
from ultralytics import YOLO
model = YOLO("yolov8x.pt")
heat_map_annotator = sv.HeatMapAnnotator()
video_info = sv.VideoInfo.from_video_path(video_path="...")
frames_generator = sv.get_video_frames_generator(source_path="...")
with sv.VideoSink(target_path="...", video_info=video_info) as sink:
for frame in frames_generator:
result = model(frame)[0]
detections = sv.Detections.from_ultralytics(result)
annotated_frame = heat_map_annotator.annotate(
scene=frame.copy(),
detections=detections,
)
sink.write_frame(frame=annotated_frame)
```
<div class="result" markdown>
![heat-map-annotator-example](https://media.roboflow.com/supervision-annotator-examples/heat-map-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Others"
=== "PercentageBar"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
percentage_bar_annotator = sv.PercentageBarAnnotator()
annotated_frame = percentage_bar_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![percentage-bar-annotator-example](https://media.roboflow.com/supervision-annotator-examples/percentage-bar-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Icon"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
icon_paths = ["<ICON_PATH>" for _ in detections]
icon_annotator = sv.IconAnnotator()
annotated_frame = icon_annotator.annotate(
scene=image.copy(),
detections=detections,
icon_path=icon_paths,
)
```
<div class="result" markdown>
![icon-annotator-example](https://media.roboflow.com/supervision-annotator-examples/icon-annotator-example.png){ align=center width="800" }
</div>
=== "Background Color"
```python
import supervision as sv
image = ...
detections = sv.Detections(...)
background_overlay_annotator = sv.BackgroundOverlayAnnotator()
annotated_frame = background_overlay_annotator.annotate(
scene=image.copy(),
detections=detections,
)
```
<div class="result" markdown>
![background-overlay-annotator-example](https://media.roboflow.com/supervision-annotator-examples/background-color-annotator-example-purple.png){ align=center width="800" }
</div>
=== "Comparison"
```python
import supervision as sv
image = ...
detections_1 = sv.Detections(...)
detections_2 = sv.Detections(...)
comparison_annotator = sv.ComparisonAnnotator()
annotated_frame = comparison_annotator.annotate(
scene=image.copy(),
detections_1=detections_1,
detections_2=detections_2,
)
```
<div class="result" markdown>
![comparison-annotator-example](https://media.roboflow.com/supervision-annotator-examples/comparison-annotator-example.png){ align=center width="800" }
</div>
<div class="md-typeset">
<h2>Try Supervision Annotators on your own image</h2>
+1 -2
View File
@@ -4,8 +4,7 @@ comments: true
# Legacy Metrics
Starting with `0.23.0`, a new metrics module is being introduced to supervision.
Metrics here are part of the legacy evaluation API and will be deprecated in the future.
Starting with `0.23.0`, a new metrics module is being introduced to supervision. Metrics here are part of the legacy evaluation API and will be deprecated in the future.
<div class="md-typeset">
<h2><a href="#supervision.metrics.detection.ConfusionMatrix">ConfusionMatrix</a></h2>
+6
View File
@@ -33,3 +33,9 @@ comments: true
</div>
:::supervision.detection.utils.boxes.denormalize_boxes
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.boxes.xyxyxyxy_to_xyxy">xyxyxyxy_to_xyxy</a></h2>
</div>
:::supervision.detection.utils.boxes.xyxyxyxy_to_xyxy
+12
View File
@@ -69,3 +69,15 @@ comments: true
</div>
:::supervision.detection.utils.iou_and_nms.mask_non_max_merge
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.iou_and_nms.oriented_box_non_max_suppression">oriented_box_non_max_suppression</a></h2>
</div>
:::supervision.detection.utils.iou_and_nms.oriented_box_non_max_suppression
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.iou_and_nms.oriented_box_non_max_merge">oriented_box_non_max_merge</a></h2>
</div>
:::supervision.detection.utils.iou_and_nms.oriented_box_non_max_merge
+58
View File
@@ -0,0 +1,58 @@
---
comments: true
description: Frequently asked questions about installing Supervision, supported computer vision models, datasets, tracking, metrics, and licensing.
---
# Frequently Asked Questions
## What is Supervision?
Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified `Detections` class with converters for supported object detection, segmentation, and VLM outputs.
## How do I install Supervision?
Install the base package with:
```bash
pip install supervision
```
Use the `metrics` extra when you need optional metric dependencies:
```bash
pip install "supervision[metrics]"
```
Sample asset utilities are part of the base package under `supervision.assets`.
## Which object detection models work with Supervision?
Supervision is model agnostic. `sv.Detections` includes converters for Ultralytics YOLO, Roboflow Inference, Hugging Face Transformers outputs, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers including Florence-2, PaliGemma, Qwen VL, Gemini, DeepSeek VL 2, and Moondream. Keypoint outputs have separate `sv.KeyPoints` converters, including MediaPipe.
## What can I do with Supervision?
You can annotate images and video, filter detections, track objects, count objects in zones or across lines, load and convert datasets, evaluate models with detection metrics, and export predictions for downstream analysis.
## How do I track objects across video frames?
Assign persistent tracker IDs before visualization. The built-in `sv.ByteTrack` wrapper accepts `Detections` through `update_with_detections()`. After tracking, combine the output with annotators such as `sv.TraceAnnotator`, `sv.BoxAnnotator`, and `sv.LabelAnnotator`.
## What dataset formats does Supervision support?
For detection datasets, Supervision supports YOLO, COCO JSON, and Pascal VOC. Use `DetectionDataset.from_yolo()`, `DetectionDataset.from_coco()`, or `DetectionDataset.from_pascal_voc()` to load datasets, and the matching `as_*` methods to export them.
## How do I count objects in a zone?
Use `sv.PolygonZone` for arbitrary polygon regions and `sv.LineZone` for line-crossing counts. Line crossing requires `detections.tracker_id`, so run a tracker before calling the line zone trigger.
## How do I benchmark a model?
Use `supervision.metrics.mean_average_precision.MeanAveragePrecision` for mAP and `sv.ConfusionMatrix` for confusion matrices. Accumulate predictions and ground-truth `Detections`, then call `compute()` to calculate metrics.
## Is Supervision free to use?
Yes. Supervision is free and open source under the MIT license.
## Where is the source code?
The source code is available at [github.com/roboflow/supervision](https://github.com/roboflow/supervision).
+32 -10
View File
@@ -1,6 +1,11 @@
---
comments: true
description: Benchmark object detection models with supervision — compute mAP, confusion matrix, and per-class metrics to compare model performance.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
date_modified: 2026-04-22
---
![Corgi Example](https://media.roboflow.com/supervision/image-examples/how-to/benchmark-models/corgi-sorted-2.png)
@@ -125,8 +130,7 @@ Evaluating your model requires careful selection of the dataset. Which images sh
- **Validation Set**: This is the set of images used to validate the model during training. Every Nth training epoch, the model is evaluated on the validation set. Often the training is stopped once the validation loss stops improving. Therefore, even while the images aren't used to train the model, it still indirectly influences the training outcome.
- **Test Set**: This is the set of images kept aside for model testing. It is exactly the set you should use for benchmarking. If the dataset was split correctly, none of these images would be shown to the model during training.
Therefore, an unrelated dataset or the `test` set is the best choice for benchmarking.
Several other problems may arise:
Therefore, an unrelated dataset or the `test` set is the best choice for benchmarking. Several other problems may arise:
- **Extra Classes**: An unrelated dataset may contain additional classes which you may need to [filter out](https://supervision.roboflow.com/how_to/filter_detections/#by-set-of-classes) before computing metrics.
- **Class Mismatch**: In an unrelated dataset, the class names or IDs may be different to what your model produces, you'll need to remap them, which is [shown in this guide](#running-a-model).
@@ -140,8 +144,7 @@ At this stage, you should have:
- A dataset of labeled images to evaluate the model.
- A model prepared for benchmarking.
With these ready, we can now run the model and obtain predictions.
We'll use `supervision` to create a dataset iterator, and then run the model on each image.
With these ready, we can now run the model and obtain predictions. We'll use `supervision` to create a dataset iterator, and then run the model on each image.
=== "Inference"
@@ -193,8 +196,7 @@ We'll use `supervision` to create a dataset iterator, and then run the model on
## Remapping classes
Did you notice an issue in the above logic?
Since we're using an unrelated dataset, the class names and IDs may be different from what the model was trained on.
Did you notice an issue in the above logic? Since we're using an unrelated dataset, the class names and IDs may be different from what the model was trained on.
We need to remap them to match the dataset classes. Here's how to do it:
@@ -254,8 +256,7 @@ Let's also remove the predictions that are not in the dataset classes.
Dataset class names and IDs can be found in the `data.yaml` file, or by printing `dataset.classes`.
Each model will have a different class mapping, so make sure to check the model's documentation. In this case, the model was trained on the COCO dataset, with a class
configuration found [here](https://github.com/ultralytics/ultralytics/blob/main/ultralytics/cfg/datasets/coco8.yaml).
Each model will have a different class mapping, so make sure to check the model's documentation. In this case, the model was trained on the COCO dataset, with a class configuration found [here](https://github.com/ultralytics/ultralytics/blob/main/ultralytics/cfg/datasets/coco8.yaml).
```python
import supervision as sv
@@ -288,8 +289,7 @@ Let's also remove the predictions that are not in the dataset classes.
## Visualizing Predictions
The first step in evaluating your model’s performance is to visualize its predictions.
This gives an intuitive sense of how well your model is detecting objects and where it might be failing.
The first step in evaluating your model’s performance is to visualize its predictions. This gives an intuitive sense of how well your model is detecting objects and where it might be failing.
```python
import supervision as sv
@@ -445,3 +445,25 @@ A condensed version of this guide is also available as a [Colab Notebook](https:
For more details, be sure to check out our [documentation](https://supervision.roboflow.com/latest/) and join our community discussions. If you find any issues, please let us know on [GitHub](https://github.com/roboflow/supervision/issues).
Best of luck with your benchmarking!
## Frequently Asked Questions
### How do I benchmark a model with supervision?
Use `supervision.metrics.mean_average_precision.MeanAveragePrecision` — accumulate prediction and ground-truth `Detections` with `update(...)` and then call `compute()`. For confusion matrices, use `sv.ConfusionMatrix.from_detections(predictions=predictions, targets=targets, classes=classes)`.
### What IoU thresholds does MeanAveragePrecision use?
It computes mAP over IoU thresholds from 0.50 to 0.95 in steps of 0.05 (mAP@50:95), plus mAP@50 and mAP@75 individually.
### Can I benchmark segmentation models?
Yes, if you want to evaluate their bounding boxes. Convert model outputs to `Detections` and pass them to `MeanAveragePrecision.update(...)`; the current mAP path prepares COCO-style bounding boxes from `detections.xyxy`.
### What is a ConfusionMatrix and how do I use it?
`sv.ConfusionMatrix` visualizes true positives, false positives, and false negatives per class. Create one with `sv.ConfusionMatrix.from_detections(predictions=predictions, targets=targets, classes=classes, conf_threshold=0.5, iou_threshold=0.5)`, then call `metric.plot()` to render a heatmap.
## Author
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
+23
View File
@@ -1,6 +1,11 @@
---
comments: true
description: Count objects entering a polygon zone in images and video using supervision's PolygonZone — measure throughput and density in any region.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
date_modified: 2026-04-22
---
With supervision, you can count the number of objects in a zone in an image or video. In this guide, we will show how to count the number of cars in a traffic video.
@@ -132,3 +137,21 @@ Here is an example of inference run on the video:
<video width="100%" loop muted autoplay>
<source src="https://blog.roboflow.com/content/media/2023/03/trim-counting.mp4" type="video/mp4">
</video>
## Frequently Asked Questions
### How do I count objects in a zone with supervision?
Create `sv.PolygonZone` with a polygon defining your region. Call `zone.trigger(detections)` on each frame — it returns a mask of detections inside the zone.
### Can I count objects crossing a line instead of entering a zone?
Yes. Use `sv.LineZone` — define a start and end point. `zone.trigger(detections)` returns a tuple of two boolean arrays, `(crossed_in, crossed_out)`, indicating which detections crossed the line in each direction. `LineZone` requires `detections.tracker_id`; run a tracker first so the same object can be matched across frames.
### Can I combine zone counting with tracking?
Yes. You can pass tracker IDs from `sv.ByteTrack` alongside your detections, but `sv.PolygonZone` still evaluates the zone on each frame and reports which objects are currently inside it. If you want to count each object only once when it first enters the zone, maintain a set of seen `tracker_id` values after filtering detections with `zone.trigger(detections)`, or use a dedicated entry/crossing counting tool such as `sv.LineZone` when it better matches your use case.
## Author
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
+63 -32
View File
@@ -1,24 +1,37 @@
---
comments: true
description: Learn to load model predictions, create Detections objects, and annotate images with bounding boxes, labels, and masks using supervision.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
- name: Borda
role: Open Source Engineer, Roboflow
github: https://github.com/borda
date_modified: 2026-04-22
---
# Detect and Annotate
Supervision provides a seamless process for annotating predictions generated by various
object detection and segmentation models. This guide shows how to perform inference
with the [Inference](https://github.com/roboflow/inference),
[Ultralytics](https://github.com/ultralytics/ultralytics) or
[Transformers](https://github.com/huggingface/transformers) packages. Following this,
you'll learn how to import these predictions into Supervision and use them to annotate
source image.
!!! tip "Sample Image"
Don't have an image? Download the one used in this tutorial:
```bash
wget https://media.roboflow.com/notebooks/examples/dog.jpeg
```
```
Then replace `<SOURCE_IMAGE_PATH>` with `"dog.jpeg"`.
```
Supervision provides a seamless process for annotating predictions generated by various object detection and segmentation models. This guide shows how to perform inference with the [Inference](https://github.com/roboflow/inference), [Ultralytics](https://github.com/ultralytics/ultralytics) or [Transformers](https://github.com/huggingface/transformers) packages. Following this, you'll learn how to import these predictions into Supervision and use them to annotate source image.
![basic-annotation](https://media.roboflow.com/supervision_detect_and_annotate_example_1.png)
## Run Detection
First, you'll need to obtain predictions from your object detection or segmentation
model.
First, you'll need to obtain predictions from your object detection or segmentation model.
To run inference, initialize your chosen model and pass the source image to its predict or infer method. Supervision supports Roboflow Inference, Ultralytics YOLO, and Hugging Face Transformers -- select the tab matching your framework. The result is a framework-specific object you will convert to a `Detections` instance in the next step.
@@ -29,7 +42,7 @@ To run inference, initialize your chosen model and pass the source image to its
from inference import get_model
model = get_model(model_id="yolov8n-640")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model.infer(image)[0]
```
@@ -40,7 +53,7 @@ To run inference, initialize your chosen model and pass the source image to its
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model(image)[0]
```
@@ -54,7 +67,7 @@ To run inference, initialize your chosen model and pass the source image to its
processor = DetrImageProcessor.from_pretrained("facebook/detr-resnet-50")
model = DetrForObjectDetection.from_pretrained("facebook/detr-resnet-50")
image = Image.open("<SOURCE_IMAGE_PATH>")
image = Image.open("dog.jpeg")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
@@ -83,7 +96,7 @@ Each supported framework has a dedicated class method on `sv.Detections` that co
from inference import get_model
model = get_model(model_id="yolov8n-640")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model.infer(image)[0]
detections = sv.Detections.from_inference(results)
```
@@ -98,7 +111,7 @@ Each supported framework has a dedicated class method on `sv.Detections` that co
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model(image)[0]
detections = sv.Detections.from_ultralytics(results)
```
@@ -116,7 +129,7 @@ Each supported framework has a dedicated class method on `sv.Detections` that co
processor = DetrImageProcessor.from_pretrained("facebook/detr-resnet-50")
model = DetrForObjectDetection.from_pretrained("facebook/detr-resnet-50")
image = Image.open("<SOURCE_IMAGE_PATH>")
image = Image.open("dog.jpeg")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
@@ -153,7 +166,7 @@ To draw bounding boxes and class labels on your image, create a `BoxAnnotator` a
from inference import get_model
model = get_model(model_id="yolov8n-640")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model.infer(image)[0]
detections = sv.Detections.from_inference(results)
@@ -174,7 +187,7 @@ To draw bounding boxes and class labels on your image, create a `BoxAnnotator` a
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model(image)[0]
detections = sv.Detections.from_ultralytics(results)
@@ -198,7 +211,7 @@ To draw bounding boxes and class labels on your image, create a `BoxAnnotator` a
processor = DetrImageProcessor.from_pretrained("facebook/detr-resnet-50")
model = DetrForObjectDetection.from_pretrained("facebook/detr-resnet-50")
image = Image.open("<SOURCE_IMAGE_PATH>")
image = Image.open("dog.jpeg")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
@@ -225,9 +238,7 @@ To draw bounding boxes and class labels on your image, create a `BoxAnnotator` a
## Display Custom Labels
By default, [`sv.LabelAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.LabelAnnotator)
will label each detection with its `class_name` (if possible) or `class_id`. You can
override this behavior by passing a list of custom `labels` to the `annotate` method.
By default, [`sv.LabelAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.LabelAnnotator) will label each detection with its `class_name` (if possible) or `class_id`. You can override this behavior by passing a list of custom `labels` to the `annotate` method.
=== "Inference"
@@ -237,7 +248,7 @@ override this behavior by passing a list of custom `labels` to the `annotate` me
from inference import get_model
model = get_model(model_id="yolov8n-640")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model.infer(image)[0]
detections = sv.Detections.from_inference(results)
@@ -264,7 +275,7 @@ override this behavior by passing a list of custom `labels` to the `annotate` me
from ultralytics import YOLO
model = YOLO("yolov8n.pt")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model(image)[0]
detections = sv.Detections.from_ultralytics(results)
@@ -294,7 +305,7 @@ override this behavior by passing a list of custom `labels` to the `annotate` me
processor = DetrImageProcessor.from_pretrained("facebook/detr-resnet-50")
model = DetrForObjectDetection.from_pretrained("facebook/detr-resnet-50")
image = Image.open("<SOURCE_IMAGE_PATH>")
image = Image.open("dog.jpeg")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
@@ -327,11 +338,7 @@ override this behavior by passing a list of custom `labels` to the `annotate` me
## Annotate Image with Segmentations
If you are running the segmentation model
[`sv.MaskAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.MaskAnnotator)
is a drop-in replacement for
[`sv.BoxAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator)
that will allow you to draw masks instead of boxes.
If you are running the segmentation model [`sv.MaskAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.MaskAnnotator) is a drop-in replacement for [`sv.BoxAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator) that will allow you to draw masks instead of boxes.
=== "Inference"
@@ -341,7 +348,7 @@ that will allow you to draw masks instead of boxes.
from inference import get_model
model = get_model(model_id="yolov8n-seg-640")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model.infer(image)[0]
detections = sv.Detections.from_inference(results)
@@ -356,6 +363,7 @@ that will allow you to draw masks instead of boxes.
scene=annotated_image,
detections=detections,
)
sv.plot_image(annotated_image)
```
=== "Ultralytics"
@@ -366,7 +374,7 @@ that will allow you to draw masks instead of boxes.
from ultralytics import YOLO
model = YOLO("yolov8n-seg.pt")
image = cv2.imread("<SOURCE_IMAGE_PATH>")
image = cv2.imread("dog.jpeg")
results = model(image)[0]
detections = sv.Detections.from_ultralytics(results)
@@ -394,7 +402,7 @@ that will allow you to draw masks instead of boxes.
processor = DetrImageProcessor.from_pretrained("facebook/detr-resnet-50-panoptic")
model = DetrForSegmentation.from_pretrained("facebook/detr-resnet-50-panoptic")
image = Image.open("<SOURCE_IMAGE_PATH>")
image = Image.open("dog.jpeg")
inputs = processor(images=image, return_tensors="pt")
with torch.no_grad():
@@ -427,3 +435,26 @@ that will allow you to draw masks instead of boxes.
```
![segmentation-annotation](https://media.roboflow.com/supervision_detect_and_annotate_example_3.png)
## Frequently Asked Questions
### How do I detect and annotate objects with supervision?
Pass any model's output to `sv.Detections.from_<model>()` to create a unified `Detections` object. Then pass it to `sv.BoxAnnotator` or `sv.MaskAnnotator` to draw predictions on an image.
### Can I annotate both bounding boxes and masks at the same time?
Yes. Chain annotators: first draw boxes with `BoxAnnotator`, then overlay masks with `MaskAnnotator` on the same scene.
### How do I label detections with class names?
Use `sv.LabelAnnotator` and pass custom text with the `labels` parameter. If a connector provides class names, they are stored in `detections["class_name"]` / `detections.data["class_name"]`; when `labels` is omitted, `LabelAnnotator` uses class names first, then class IDs, then detection indices.
### Can I use supervision with Hugging Face models?
Yes. `sv.Detections.from_transformers()` accepts supported Hugging Face object detection and segmentation outputs. Vision-language model outputs are handled through `sv.Detections.from_vlm(...)`, for example with `sv.VLM.FLORENCE_2` or `sv.VLM.PALIGEMMA`.
## Authors
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
- [Borda](https://github.com/borda) — Open Source Engineer, Roboflow
+27 -13
View File
@@ -1,15 +1,16 @@
---
comments: true
description: Detect small objects in images by applying SAHI inference slicing with supervision's InferenceSlicer — improve recall for tiny targets.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
date_modified: 2026-04-22
---
# Detect Small Objects
This guide shows how to detect small objects
with the [Inference](https://github.com/roboflow/inference),
[Ultralytics](https://github.com/ultralytics/ultralytics) or
[Transformers](https://github.com/huggingface/transformers) packages using
[`InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer).
This guide shows how to detect small objects with the [Inference](https://github.com/roboflow/inference), [Ultralytics](https://github.com/ultralytics/ultralytics) or [Transformers](https://github.com/huggingface/transformers) packages using [`InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer).
<video controls>
<source src="https://media.roboflow.com/supervision_detect_small_objects_example.mp4" type="video/mp4">
@@ -17,8 +18,7 @@ with the [Inference](https://github.com/roboflow/inference),
## Baseline Detection
Small object detection in high-resolution images presents challenges due to the objects'
size relative to the image resolution.
Small object detection in high-resolution images presents challenges due to the objects' size relative to the image resolution.
Running a standard detection model on the full image establishes a baseline for comparison. Load your chosen model, pass the image through it, and convert the results into a `Detections` object. This baseline reveals how many small objects the model misses at native resolution, motivating the sliced inference approach shown later.
@@ -111,9 +111,7 @@ Running a standard detection model on the full image establishes a baseline for
## Input Resolution
Modifying the input resolution of images before detection can enhance small object
identification at the cost of processing speed and increased memory usage. This method
is less effective for ultra-high-resolution images (4K and above).
Modifying the input resolution of images before detection can enhance small object identification at the cost of processing speed and increased memory usage. This method is less effective for ultra-high-resolution images (4K and above).
=== "Inference"
@@ -161,9 +159,7 @@ is less effective for ultra-high-resolution images (4K and above).
## Inference Slicer
[`InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer)
processes high-resolution images by dividing them into smaller segments, detecting
objects within each, and aggregating the results.
[`InferenceSlicer`](https://supervision.roboflow.com/latest/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) processes high-resolution images by dividing them into smaller segments, detecting objects within each, and aggregating the results.
<video controls>
<source src="https://media.roboflow.com/supervision_detect_small_objects_example_2.mp4" type="video/mp4">
@@ -331,3 +327,21 @@ objects within each, and aggregating the results.
```
![detection-with-inference-slicer](https://media.roboflow.com/supervision-docs/inference-slicer-segmentation-example.png)
## Frequently Asked Questions
### How do I detect small objects with supervision?
Use `sv.InferenceSlicer` to split a high-resolution image into overlapping tiles, run detection on each tile, and merge results with non-maximum suppression. This dramatically improves recall for tiny targets.
### What overlap should I use between tiles?
`InferenceSlicer` takes overlap in pixels via `overlap_wh`, not as a percentage. The default is `100` pixels in both directions. Increase `overlap_wh` when objects are close to the tile size or often appear on tile boundaries, and decrease it when speed is more important.
### Can I use InferenceSlicer with any detection model?
Yes. Wrap any model or converter path that can produce `sv.Detections` in a callback, pass that callback to `sv.InferenceSlicer(callback=...)`, and then call the slicer with your image.
## Author
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
+36 -16
View File
@@ -1,15 +1,16 @@
---
comments: true
description: Filter and query detection results by class, confidence, or spatial overlap using supervision's Detections API — clean predictions in one line.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
date_modified: 2026-04-22
---
# Filter Detections
The advanced filtering capabilities of the `Detections` class offer users a versatile and efficient way to narrow down
and refine object detections. This section outlines various filtering methods, including filtering by specific class
or a set of classes, confidence, object area, bounding box area, relative area, box dimensions, and designated zones.
Each method is demonstrated with concise code examples to provide users with a clear understanding of how to implement
the filters in their applications.
The advanced filtering capabilities of the `Detections` class offer users a versatile and efficient way to narrow down and refine object detections. This section outlines various filtering methods, including filtering by specific class or a set of classes, confidence, object area, bounding box area, relative area, box dimensions, and designated zones. Each method is demonstrated with concise code examples to provide users with a clear understanding of how to implement the filters in their applications.
### by specific class
@@ -119,8 +120,7 @@ Allows you to select detections with specific confidence value, for example high
### by area
Allows you to select detections based on their size. We define the area as the number of pixels occupied by the
detection in the image. In the example below, we have sifted out the detections that are too small.
Allows you to select detections based on their size. We define the area as the number of pixels occupied by the detection in the image. In the example below, we have sifted out the detections that are too small.
=== "After"
@@ -154,10 +154,7 @@ detection in the image. In the example below, we have sifted out the detections
### by relative area
Allows you to select detections based on their size in relation to the size of whole image. Sometimes the concept of
detection size changes depending on the image. Detection occupying 10000 square px can be large on a 1280x720 image
but small on a 3840x2160 image. In such cases, we can filter out detections based on the percentage of the image area
occupied by them. In the example below, we remove too large detections.
Allows you to select detections based on their size in relation to the size of whole image. Sometimes the concept of detection size changes depending on the image. Detection occupying 10000 square px can be large on a 1280x720 image but small on a 3840x2160 image. In such cases, we can filter out detections based on the percentage of the image area occupied by them. In the example below, we remove too large detections.
=== "After"
@@ -199,9 +196,7 @@ occupied by them. In the example below, we remove too large detections.
### by box dimensions
Allows you to select detections based on their dimensions. The size of the bounding box, as well as its coordinates,
can be criteria for rejecting detection. Implementing such filtering requires a bit of custom code but is relatively
simple and fast.
Allows you to select detections based on their dimensions. The size of the bounding box, as well as its coordinates, can be criteria for rejecting detection. Implementing such filtering requires a bit of custom code but is relatively simple and fast.
=== "After"
@@ -239,8 +234,7 @@ simple and fast.
### by `PolygonZone`
Allows you to use `Detections` in combination with `PolygonZone` to weed out bounding boxes that are in and out of the
zone. In the example below you can see how to filter out all detections located in the lower part of the image.
Allows you to use `Detections` in combination with `PolygonZone` to weed out bounding boxes that are in and out of the zone. In the example below you can see how to filter out all detections located in the lower part of the image.
=== "After"
@@ -313,3 +307,29 @@ zone. In the example below you can see how to filter out all detections located
![original](https://media.roboflow.com/open-source/supervision/supervision-detection-original.png){ align=center width="800" }
</div>
## Frequently Asked Questions
### How do I filter detections by class in supervision?
Use NumPy-style boolean indexing: `detections[detections.class_id == 0]` for class 0. Combine with `&` or `|` for multiple conditions.
### How do I filter by confidence threshold?
`detections[detections.confidence > 0.5]` returns only detections above the threshold. Chain with class filters for precise results.
### How do I filter by bounding box area?
`detections[detections.area > 1000]` filters by pixel area. If masks are present, `detections.area` uses mask area; otherwise it uses bounding box area from `xyxy`. Use `detections.box_area` when you specifically need bounding box area.
### Can I filter by box aspect ratio or dimensions?
Yes. Use `detections.box_aspect_ratio` for aspect ratio filtering. If you need explicit box dimensions, compute them from `detections.xyxy` as `width = detections.xyxy[:, 2] - detections.xyxy[:, 0]` and `height = detections.xyxy[:, 3] - detections.xyxy[:, 1]`.
### How do I remove duplicate detections (NMS) from my results?
Use `detections.with_nms(threshold=0.5)` — it applies non-maximum suppression on the `xyxy` boxes.
## Author
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
+38 -47
View File
@@ -1,29 +1,24 @@
---
comments: true
description: Load, split, merge, and convert computer vision datasets between YOLO, COCO, and Pascal VOC formats using supervision's DetectionDataset.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
date_modified: 2026-04-22
---
With Supervision, you can load and manipulate classification, object detection, and
segmentation datasets. This tutorial will walk you through how to load, split, merge,
visualize, and augment datasets in Supervision.
With Supervision, you can load and manipulate classification, object detection, and segmentation datasets. This tutorial will walk you through how to load, split, merge, visualize, and augment datasets in Supervision.
## Download Dataset
In this tutorial, we will use a dataset from
[Roboflow Universe](https://universe.roboflow.com/), a public repository of
thousands of computer vision datasets. If you already have your dataset in
[COCO](https://roboflow.com/formats/coco-json),
[YOLO](https://roboflow.com/formats/yolov8-pytorch-txt),
or [Pascal VOC](https://roboflow.com/formats/pascal-voc-xml) format, you can skip this
section.
In this tutorial, we will use a dataset from [Roboflow Universe](https://universe.roboflow.com/), a public repository of thousands of computer vision datasets. If you already have your dataset in [COCO](https://roboflow.com/formats/coco-json), [YOLO](https://roboflow.com/formats/yolov8-pytorch-txt), or [Pascal VOC](https://roboflow.com/formats/pascal-voc-xml) format, you can skip this section.
```bash
pip install roboflow
```
Next, log into your Roboflow account and download the dataset of your choice in the
COCO, YOLO, or Pascal VOC format. You can customize the following code snippet with
your workspace ID, project ID, and version number.
Next, log into your Roboflow account and download the dataset of your choice in the COCO, YOLO, or Pascal VOC format. You can customize the following code snippet with your workspace ID, project ID, and version number.
=== "COCO"
@@ -63,10 +58,7 @@ your workspace ID, project ID, and version number.
## Load Dataset
The Supervision library provides convenient functions to load datasets in various
formats. If your dataset is already split into train, test, and valid subsets, you can
load each of those as separate [`sv.DetectionDataset`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset)
instances.
The Supervision library provides convenient functions to load datasets in various formats. If your dataset is already split into train, test, and valid subsets, you can load each of those as separate [`sv.DetectionDataset`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset) instances.
=== "COCO"
@@ -154,9 +146,7 @@ instances.
## Split Dataset
If your dataset is not already split into train, test, and valid subsets, you can
easily do so using the [`sv.DetectionDataset.split`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.split)
method. We can split it as follows, ensuring a random shuffle of the data.
If your dataset is not already split into train, test, and valid subsets, you can easily do so using the [`sv.DetectionDataset.split`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.split) method. We can split it as follows, ensuring a random shuffle of the data.
```python
import supervision as sv
@@ -175,9 +165,7 @@ len(ds_train), len(ds_valid), len(ds_test)
## Merge Dataset
If you have multiple datasets that you would like to merge, you can do so using the
[`sv.DetectionDataset.merge`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.merge)
method.
If you have multiple datasets that you would like to merge, you can do so using the [`sv.DetectionDataset.merge`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.merge) method.
=== "COCO"
@@ -283,10 +271,7 @@ method.
## Iterate over Dataset
There are two ways to loop over a `sv.DetectionDataset`: using a direct
[for loop](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.__iter__)
called on the `sv.DetectionDataset` instance or loading `sv.DetectionDataset` entries
[by index](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.__getitem__).
There are two ways to loop over a `sv.DetectionDataset`: using a direct [for loop](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.__iter__) called on the `sv.DetectionDataset` instance or loading `sv.DetectionDataset` entries [by index](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.__getitem__).
```python
import supervision as sv
@@ -305,13 +290,7 @@ for idx in range(len(ds)):
## Visualize Dataset
The Supervision library provides tools for easily visualizing your detection dataset.
You can create a grid of annotated images to quickly inspect your data and labels.
First, initialize the [`sv.BoxAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator)
and [`sv.LabelAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.LabelAnnotator).
Then, iterate through a subset of the dataset (e.g., the first 25 images), drawing
bounding boxes and class labels on each image. Finally, combine the annotated images
into a grid for display.
The Supervision library provides tools for easily visualizing your detection dataset. You can create a grid of annotated images to quickly inspect your data and labels. First, initialize the [`sv.BoxAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator) and [`sv.LabelAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.LabelAnnotator). Then, iterate through a subset of the dataset (e.g., the first 25 images), drawing bounding boxes and class labels on each image. Finally, combine the annotated images into a grid for display.
```python
import supervision as sv
@@ -390,22 +369,13 @@ sv.plot_images_grid(
## Augment Dataset
In this section, we'll explore using Supervision in combination with Albumentations to
augment our dataset. Data augmentation is a common technique in computer vision to
increase the size and diversity of training datasets, leading to improved model
performance and generalization.
In this section, we'll explore using Supervision in combination with Albumentations to augment our dataset. Data augmentation is a common technique in computer vision to increase the size and diversity of training datasets, leading to improved model performance and generalization.
```bash
pip install albumentations
```
Albumentations provides a flexible and powerful API for image augmentation. The core of
the library is the [`Compose`](https://albumentations.ai/docs/api-reference/albumentations/core/composition/#Compose)
class, which allows you to chain multiple image transformations together. Each
transformation is defined using a dedicated class, such as
[`HorizontalFlip`](https://albumentations.ai/docs/api-reference/albumentations/augmentations/geometric/flip/#HorizontalFlip),
[`RandomBrightnessContrast`](https://albumentations.ai/docs/api-reference/albumentations/augmentations/pixel/transforms/#RandomBrightnessContrast),
or [`Perspective`](https://albumentations.ai/docs/api-reference/albumentations/augmentations/geometric/transforms/#Perspective).
Albumentations provides a flexible and powerful API for image augmentation. The core of the library is the [`Compose`](https://albumentations.ai/docs/api-reference/albumentations/core/composition/#Compose) class, which allows you to chain multiple image transformations together. Each transformation is defined using a dedicated class, such as [`HorizontalFlip`](https://albumentations.ai/docs/api-reference/albumentations/augmentations/geometric/flip/#HorizontalFlip), [`RandomBrightnessContrast`](https://albumentations.ai/docs/api-reference/albumentations/augmentations/pixel/transforms/#RandomBrightnessContrast), or [`Perspective`](https://albumentations.ai/docs/api-reference/albumentations/augmentations/geometric/transforms/#Perspective).
```python
import albumentations as A
@@ -423,8 +393,7 @@ augmentation = A.Compose(
)
```
The key is to set `format='pascal_voc'`, which corresponds to the
`[x_min, y_min, x_max, y_max]` bounding box format used in Supervision.
The key is to set `format='pascal_voc'`, which corresponds to the `[x_min, y_min, x_max, y_max]` bounding box format used in Supervision.
```python
import numpy as np
@@ -450,3 +419,25 @@ augmented_annotations = replace(
```
![augment-dataset](https://media.roboflow.com/supervision-docs/augment-dataset.png)
## Frequently Asked Questions
### What dataset formats does supervision support?
For detection datasets, supervision supports YOLO, COCO JSON, and Pascal VOC. Use `DetectionDataset.from_yolo()`, `from_coco()`, or `from_pascal_voc()` to load, and `as_yolo()`, `as_coco()`, or `as_pascal_voc()` to save. Classification datasets use `ClassificationDataset.from_folder_structure()` and `as_folder_structure()`.
### Can I split a dataset into train/val/test sets?
`DetectionDataset.split(split_ratio=0.8)` returns exactly two datasets: train (80%) and test (20%). If you need a validation set, split one of those subsets in a separate step.
### Can I merge two datasets together?
Yes. `DetectionDataset.merge([dataset_a, dataset_b])` combines multiple datasets into one. Useful for combining datasets from different sources.
### What augmentations are available?
Common augmentations such as flip, rotate, translate, scale, crop, color jitter, and Gaussian blur can be applied using an external library like Albumentations, as shown in the augmentation example above. Supervision does not provide an `sv.Augmenter` pipeline.
## Author
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
+32 -26
View File
@@ -1,23 +1,20 @@
---
comments: true
description: Save object detection results to CSV or JSON with supervision's CSVSink and JSONSink — export predictions for analysis and downstream pipelines.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
date_modified: 2026-04-22
---
# Save Detections
Supervision enables an easy way to save detections in .CSV and .JSON files for offline
processing. This guide demonstrates how to perform video inference using the
[Inference](https://github.com/roboflow/inference),
[Ultralytics](https://github.com/ultralytics/ultralytics) or
[Transformers](https://github.com/huggingface/transformers) packages and save their results with
[`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) and
[`sv.JSONSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.JSONSink).
Supervision enables an easy way to save detections in .CSV and .JSON files for offline processing. This guide demonstrates how to perform video inference using the [Inference](https://github.com/roboflow/inference), [Ultralytics](https://github.com/ultralytics/ultralytics) or [Transformers](https://github.com/huggingface/transformers) packages and save their results with [`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) and [`sv.JSONSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.json_sink.JSONSink).
## Run Detection
First, you'll need to obtain predictions from your object detection or segmentation
model. You can learn more on this topic in our
[How to Detect and Annotate](https://supervision.roboflow.com/latest/how_to/detect_and_annotate/) guide.
First, you'll need to obtain predictions from your object detection or segmentation model. You can learn more on this topic in our [How to Detect and Annotate](https://supervision.roboflow.com/latest/how_to/detect_and_annotate/) guide.
To generate predictions for saving, initialize your model and iterate over video frames using `sv.get_video_frames_generator`. Each frame is passed to the model, and the raw output is converted into a `sv.Detections` object. This detection loop forms the foundation for both CSV and JSON export workflows shown below.
@@ -77,11 +74,7 @@ To generate predictions for saving, initialize your model and iterate over video
## Save Detections as CSV
To save detections to a `.CSV` file, open our
[`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink)
and then pass the
[`sv.Detections`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections)
object resulting from the inference to it. Its fields are parsed and saved on disk.
To save detections to a `.CSV` file, open our [`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) and then pass the [`sv.Detections`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections) object resulting from the inference to it. Its fields are parsed and saved on disk.
=== "Inference"
@@ -153,12 +146,7 @@ object resulting from the inference to it. Its fields are parsed and saved on di
## Custom Fields
Besides regular fields in
[`sv.Detections`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections),
[`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink)
also allows you to add custom information to each row, which can be passed via the
`custom_data` dictionary. Let's utilize this feature to save information about the
frame index from which the detections originate.
Besides regular fields in [`sv.Detections`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections), [`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) also allows you to add custom information to each row, which can be passed via the `custom_data` dictionary. Let's utilize this feature to save information about the frame index from which the detections originate.
=== "Inference"
@@ -230,11 +218,7 @@ frame index from which the detections originate.
## Save Detections as JSON
If you prefer to save the result in a `.JSON` file instead of a `.CSV` file, all you
need to do is replace
[`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink)
with
[`sv.JSONSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.JSONSink).
If you prefer to save the result in a `.JSON` file instead of a `.CSV` file, all you need to do is replace [`sv.CSVSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.csv_sink.CSVSink) with [`sv.JSONSink`](https://supervision.roboflow.com/latest/detection/tools/save_detections/#supervision.detection.tools.json_sink.JSONSink).
=== "Inference"
@@ -297,3 +281,25 @@ with
detections = sv.Detections.from_transformers(results)
sink.append(detections, {"frame_index": frame_index})
```
## Frequently Asked Questions
### How do I save detections to CSV with supervision?
Open `sv.CSVSink("output.csv")` as a context manager and call `sink.append(detections)` for each frame. The CSV includes box coordinates, confidence, class ID, tracker ID, and any fields stored in `detections.data`.
### Can I save detections to JSON instead?
Yes. Open `sv.JSONSink("output.json")` as a context manager and call `sink.append(detections)` for each frame. The file is written as a JSON array when the context exits.
### Can I add custom fields to the saved output?
Yes. Pass a dict as the second argument: `sink.append(detections, {"frame_index": 5})` — the keys become extra columns in the CSV or extra fields in the JSON.
### Can I save only specific classes or confidence levels?
Filter the `Detections` object before saving: `sink.append(detections[detections.confidence > 0.7])`.
## Author
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
+40 -39
View File
@@ -1,24 +1,23 @@
---
comments: true
description: Track objects across video frames with ByteTrack in supervision — assign persistent IDs and analyze motion from any object detection model.
authors:
- name: Piotr Skalski
role: Computer Vision Engineer, Roboflow
github: https://github.com/SkalskiP
- name: Soumik Mandal
role: ML Engineer, Roboflow
github: https://github.com/soumik12345
date_modified: 2026-04-22
---
# Track Objects
Leverage Supervision's advanced capabilities for enhancing your video analysis by
seamlessly [tracking](https://supervision.roboflow.com/latest/trackers/) objects recognized by
a multitude of object detection, segmentation and keypoint models. This comprehensive guide will
take you through the steps to perform inference using the YOLOv8 model via either the
[Inference](https://github.com/roboflow/inference) or
[Ultralytics](https://github.com/ultralytics/ultralytics) packages. Following this,
you'll discover how to track these objects efficiently and annotate your video content
for a deeper analysis.
Leverage Supervision's advanced capabilities for enhancing your video analysis by seamlessly [tracking](https://supervision.roboflow.com/latest/trackers/) objects recognized by a multitude of object detection, segmentation and keypoint models. This comprehensive guide will take you through the steps to perform inference using the YOLOv8 model via either the [Inference](https://github.com/roboflow/inference) or [Ultralytics](https://github.com/ultralytics/ultralytics) packages. Following this, you'll discover how to track these objects efficiently and annotate your video content for a deeper analysis.
## Object Detection & Segmentation
To make it easier for you to follow our tutorial download the video we will use as an
example. You can do this using
[`supervision[assets]`](https://supervision.roboflow.com/latest/assets/) extension.
To make it easier for you to follow our tutorial download the video we will use as an example. You can do this using the [`supervision.assets`](https://supervision.roboflow.com/latest/assets/) module included in the base package.
This section demonstrates how to detect and segment objects in video frames using YOLOv8 with either the Inference or Ultralytics package. You will download a sample video, define a per-frame callback function that runs model prediction, and process the entire video to produce an annotated output file.
@@ -34,16 +33,9 @@ download_assets(VideoAssets.PEOPLE_WALKING)
### Run Inference
First, you'll need to obtain predictions from your object detection or segmentation
model. In this tutorial, we are using the YOLOv8 model as an example. However,
Supervision is versatile and compatible with various models. Check this
[link](https://supervision.roboflow.com/latest/how_to/detect_and_annotate/#load-predictions-into-supervision)
for guidance on how to plug in other models.
First, you'll need to obtain predictions from your object detection or segmentation model. In this tutorial, we are using the YOLOv8 model as an example. However, Supervision is versatile and compatible with various models. Check this [link](https://supervision.roboflow.com/latest/how_to/detect_and_annotate/#load-predictions-into-supervision) for guidance on how to plug in other models.
We will define a `callback` function, which will process each frame of the video
by obtaining model predictions and then annotating the frame based on these predictions.
This `callback` function will be essential in the subsequent steps of the tutorial, as
it will be modified to include tracking, labeling, and trace annotations.
We will define a `callback` function, which will process each frame of the video by obtaining model predictions and then annotating the frame based on these predictions. This `callback` function will be essential in the subsequent steps of the tutorial, as it will be modified to include tracking, labeling, and trace annotations.
!!! tip
@@ -99,11 +91,7 @@ it will be modified to include tracking, labeling, and trace annotations.
### Tracking
After running inference and obtaining predictions, the next step is to track the
detected objects throughout the video. Utilizing Supervision’s
[`sv.ByteTrack`](https://supervision.roboflow.com/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack)
functionality, each detected object is assigned a unique tracker ID,
enabling the continuous following of the object's motion path across different frames.
After running inference and obtaining predictions, the next step is to track the detected objects throughout the video. Utilizing Supervision’s [`sv.ByteTrack`](https://supervision.roboflow.com/latest/trackers/#supervision.tracker.byte_tracker.core.ByteTrack) functionality, each detected object is assigned a unique tracker ID, enabling the continuous following of the object's motion path across different frames.
=== "Ultralytics"
@@ -155,11 +143,7 @@ enabling the continuous following of the object's motion path across different f
### Annotate Video with Tracking IDs
Annotating the video with tracking IDs helps in distinguishing and following each object
distinctly. With the
[`sv.LabelAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.LabelAnnotator)
in Supervision, we can overlay the tracker IDs and class labels on the detected objects,
offering a clear visual representation of each object's class and unique identifier.
Annotating the video with tracking IDs helps in distinguishing and following each object distinctly. With the [`sv.LabelAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.LabelAnnotator) in Supervision, we can overlay the tracker IDs and class labels on the detected objects, offering a clear visual representation of each object's class and unique identifier.
=== "Ultralytics"
@@ -237,11 +221,7 @@ offering a clear visual representation of each object's class and unique identif
### Annotate Video with Traces
Adding traces to the video involves overlaying the historical paths of the detected
objects. This feature, powered by the
[`sv.TraceAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.TraceAnnotator),
allows for visualizing the trajectories of objects, helping in understanding the
movement patterns and interactions between objects in the video.
Adding traces to the video involves overlaying the historical paths of the detected objects. This feature, powered by the [`sv.TraceAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.TraceAnnotator), allows for visualizing the trajectories of objects, helping in understanding the movement patterns and interactions between objects in the video.
=== "Ultralytics"
@@ -327,8 +307,7 @@ movement patterns and interactions between objects in the video.
Models aren't limited to object detection and segmentation. Keypoint detection allows for detailed analysis of body joints and connections, especially valuable for applications like human pose estimation. This section introduces keypoint tracking. We'll walk through the steps of annotating keypoints, converting them into bounding box detections compatible with `ByteTrack`, and applying detection smoothing for enhanced stability.
To make it easier for you to follow our tutorial, let's download the video we will use as an
example. You can do this using [`supervision[assets]`](https://supervision.roboflow.com/latest/assets/) extension.
To make it easier for you to follow our tutorial, let's download the video we will use as an example. You can do this using the [`supervision.assets`](https://supervision.roboflow.com/latest/assets/) module included in the base package.
```python
from supervision.assets import download_assets, VideoAssets
@@ -342,8 +321,7 @@ download_assets(VideoAssets.SKIING)
### Keypoint Detection
First, you'll need to obtain predictions from your keypoint detection model. In this tutorial, we are using the YOLOv8 model as an example. However,
Supervision is versatile and compatible with various models. Check this [link](https://supervision.roboflow.com/latest/keypoint/core/) for guidance on how to plug in other models.
First, you'll need to obtain predictions from your keypoint detection model. In this tutorial, we are using the YOLOv8 model as an example. However, Supervision is versatile and compatible with various models. Check this [link](https://supervision.roboflow.com/latest/keypoint/core/) for guidance on how to plug in other models.
We will define a `callback` function, which will process each frame of the video by obtaining model predictions and then annotating the frame based on these predictions.
@@ -656,3 +634,26 @@ We could stop here as we have successfully tracked the object detected by the ke
</video>
This structured walkthrough should give a detailed pathway to annotate videos effectively using Supervision’s various functionalities, including object tracking and trace annotations.
## Frequently Asked Questions
### How do I track objects across video frames with supervision?
Pass `Detections` to `sv.ByteTrack.update_with_detections()` on each frame. The tracker assigns persistent IDs. Combine with `sv.TraceAnnotator` to visualize trajectories. `sv.ByteTrack` is deprecated in favor of `ByteTrackTracker` from the `trackers` package, where the update method is named `update()`.
### What should I know about ByteTrack?
ByteTrack uses low-confidence detections during association, which can improve continuity during missed or weak detections. Supervision's built-in `ByteTrack` wrapper is deprecated in favor of the external `trackers` package.
### Can I track instances instead of bounding boxes?
Yes. ByteTrack tracks bounding boxes. For instance masks, use `sv.MaskAnnotator` with the tracker IDs to color-code each tracked object consistently.
### Does ByteTrack work with any detection model?
Yes. ByteTrack is model-agnostic - it accepts any `Detections` object with bounding boxes, regardless of the supported converter or model output that produced it.
## Authors
- [Piotr Skalski](https://github.com/SkalskiP) — Computer Vision Engineer, Roboflow
- [Soumik Mandal](https://github.com/soumik12345) — ML Engineer, Roboflow
+7 -20
View File
@@ -26,11 +26,11 @@ description: Open-source Python library providing computer vision tools for anno
## What is Supervision?
Supervision is an open-source Python library by Roboflow for building computer vision applications. It provides a unified `Detections` object that works with YOLO, SAM, Grounding DINO, Transformers, and 20+ model frameworks — use any detector, get the same composable API.
Supervision is an open-source Python library by Roboflow for building computer vision applications. It provides a unified `Detections` object with converters for supported outputs from Ultralytics, Roboflow Inference, Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers.
With Supervision you can annotate images and video with bounding boxes, masks, and labels; track objects across frames with persistent IDs; count and filter detections inside polygon zones; load and convert datasets between YOLO, COCO, and Pascal VOC formats; and benchmark model performance with mAP and confusion matrices.
Trusted by researchers (cited in 4,000+ papers) and practitioners (38,000+ GitHub stars, 1M+ monthly PyPI downloads), Supervision is the standard toolkit for production computer vision workflows.
Supervision is MIT licensed, has 38,000+ GitHub stars, and over 1 million monthly PyPI downloads. It is developed in public on GitHub for production computer vision workflows.
## 👋 Hello
@@ -45,17 +45,13 @@ We write your reusable computer vision tools. Whether you need to load your data
## 💻 Install
You can install `supervision` in a
[**Python>=3.9**](https://www.python.org/) environment.
You can install `supervision` in a [**Python>=3.9**](https://www.python.org/) environment.
!!! example "Installation"
=== "pip (recommended)"
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision)
[![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision)
[![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md)
[![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision) [![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision) [![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md) [![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
```bash
pip install supervision
@@ -63,10 +59,7 @@ You can install `supervision` in a
=== "poetry"
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision)
[![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision)
[![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md)
[![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision) [![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision) [![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md) [![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
```bash
poetry add supervision
@@ -74,10 +67,7 @@ You can install `supervision` in a
=== "uv"
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision)
[![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision)
[![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md)
[![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision) [![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision) [![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md) [![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
```bash
uv pip install supervision
@@ -91,10 +81,7 @@ You can install `supervision` in a
=== "rye"
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision)
[![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision)
[![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md)
[![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
[![version](https://badge.fury.io/py/supervision.svg)](https://badge.fury.io/py/supervision) [![downloads](https://img.shields.io/pypi/dm/supervision)](https://pypistats.org/packages/supervision) [![license](https://img.shields.io/pypi/l/supervision)](../LICENSE.md) [![python-version](https://img.shields.io/pypi/pyversions/supervision)](https://badge.fury.io/py/supervision)
```bash
rye add supervision
+73
View File
@@ -77,6 +77,61 @@ comments: true
</div>
=== "VertexEllipseAreaAnnotator"
```python
import supervision as sv
image = ...
key_points = sv.KeyPoints(...)
area_annotator = sv.VertexEllipseAreaAnnotator(
color=sv.Color.GREEN,
sigma=2.0,
)
annotated_frame = area_annotator.annotate(
scene=image.copy(),
key_points=key_points,
)
```
=== "VertexEllipseOutlineAnnotator"
```python
import supervision as sv
image = ...
key_points = sv.KeyPoints(...)
outline_annotator = sv.VertexEllipseOutlineAnnotator(
color=sv.Color.GREEN,
sigma=2.0,
thickness=2,
)
annotated_frame = outline_annotator.annotate(
scene=image.copy(),
key_points=key_points,
)
```
=== "VertexEllipseHaloAnnotator"
```python
import supervision as sv
image = ...
key_points = sv.KeyPoints(...)
halo_annotator = sv.VertexEllipseHaloAnnotator(
color=sv.Color.GREEN,
sigma=2.0,
)
annotated_frame = halo_annotator.annotate(
scene=image.copy(),
key_points=key_points,
)
```
<div class="md-typeset">
<h2><a href="#supervision.key_points.annotators.VertexAnnotator">VertexAnnotator</a></h2>
</div>
@@ -94,3 +149,21 @@ comments: true
</div>
:::supervision.key_points.annotators.VertexLabelAnnotator
<div class="md-typeset">
<h2><a href="#supervision.key_points.annotators.VertexEllipseAreaAnnotator">VertexEllipseAreaAnnotator</a></h2>
</div>
:::supervision.key_points.annotators.VertexEllipseAreaAnnotator
<div class="md-typeset">
<h2><a href="#supervision.key_points.annotators.VertexEllipseOutlineAnnotator">VertexEllipseOutlineAnnotator</a></h2>
</div>
:::supervision.key_points.annotators.VertexEllipseOutlineAnnotator
<div class="md-typeset">
<h2><a href="#supervision.key_points.annotators.VertexEllipseHaloAnnotator">VertexEllipseHaloAnnotator</a></h2>
</div>
:::supervision.key_points.annotators.VertexEllipseHaloAnnotator
+192
View File
@@ -0,0 +1,192 @@
# supervision
> Large-context AI crawler summary for Supervision documentation.
Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a model-agnostic `Detections` class and composable tools for object detection, instance segmentation, keypoint detection, annotation, tracking, zone counting, dataset conversion, and model evaluation.
Supervision is MIT licensed, published on PyPI, developed on GitHub, and used by researchers and practitioners in production computer vision systems. The library includes converters for supported outputs from Ultralytics, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers.
## Primary Links
- Latest stable docs: https://supervision.roboflow.com/latest/
- Development docs: https://supervision.roboflow.com/develop/
- Source code: https://github.com/roboflow/supervision
- PyPI package: https://pypi.org/project/supervision/
- Changelog: https://supervision.roboflow.com/latest/changelog/
- Sitemap: https://supervision.roboflow.com/sitemap.xml
- Standard LLM summary: https://supervision.roboflow.com/llms.txt
- Full LLM summary: https://supervision.roboflow.com/llms.full.txt
## AI Access
The documentation is static HTML and is open for AI crawler consumption. `robots.txt` explicitly allows general crawlers plus GPTBot, ClaudeBot, PerplexityBot, Bytespider, CCBot, GoogleOther, and Applebot.
## Install
```bash
pip install supervision
```
Optional extras:
```bash
pip install "supervision[metrics]"
```
Sample asset utilities are included in the base package under `supervision.assets`.
## Core Concepts
### `sv.Detections`
`sv.Detections` is the central data structure in Supervision. It stores bounding boxes, segmentation masks, confidence scores, class IDs, tracker IDs, and arbitrary per-detection metadata in a `data` dictionary. It supports NumPy-style indexing for filtering by confidence, class, area, and spatial constraints.
Most connectors, annotators, trackers, dataset tools, and metrics either accept or return `Detections`, which makes it possible to change the upstream model while keeping downstream processing code stable.
### Model Connectors
Supervision normalizes outputs from multiple computer vision frameworks into the same `Detections` API. Common constructors include:
- `sv.Detections.from_ultralytics(...)`
- `sv.Detections.from_inference(...)`
- `sv.Detections.from_transformers(...)`
- `sv.Detections.from_vlm(...)`
- `sv.Detections.from_sam(...)`
- `sv.Detections.from_detectron2(...)`
- `sv.Detections.from_mmdetection(...)`
### Annotation
Supervision includes annotators for drawing boxes, masks, labels, traces, zones, vertices, edges, and other overlays on images and video frames. Common annotators include:
- `sv.BoxAnnotator`
- `sv.MaskAnnotator`
- `sv.LabelAnnotator`
- `sv.TraceAnnotator`
- `sv.PolygonZoneAnnotator`
- `sv.LineZoneAnnotator`
### Tracking
The built-in `sv.ByteTrack` wrapper assigns persistent IDs across video frames through `update_with_detections()`. The docs also note the migration path toward `ByteTrackTracker` from the external `trackers` package. Tracked detections can be passed to label, trace, zone, and line-counting annotators.
### Zones and Counting
`sv.PolygonZone` checks whether detections are inside an arbitrary polygon. `sv.LineZone` counts line crossings and requires `detections.tracker_id` so objects can be matched across frames. These tools are typically used with video callbacks and annotators to build traffic, occupancy, queue, and throughput analytics.
### Datasets
`sv.DetectionDataset` loads, merges, splits, and converts object detection datasets. Supported formats include YOLO, COCO JSON, and Pascal VOC. `sv.ClassificationDataset` supports folder-structured classification datasets.
### Metrics
Supervision includes detection metrics including mean average precision, mean average recall, precision, recall, F1 score, and confusion matrices. The current mAP workflow uses `supervision.metrics.mean_average_precision.MeanAveragePrecision` with `update(...)` and `compute()`.
## How-To Guides
- Detect and annotate: https://supervision.roboflow.com/latest/how_to/detect_and_annotate/
- Track objects: https://supervision.roboflow.com/latest/how_to/track_objects/
- Detect small objects: https://supervision.roboflow.com/latest/how_to/detect_small_objects/
- Filter detections: https://supervision.roboflow.com/latest/how_to/filter_detections/
- Save detections: https://supervision.roboflow.com/latest/how_to/save_detections/
- Count in zone: https://supervision.roboflow.com/latest/how_to/count_in_zone/
- Benchmark a model: https://supervision.roboflow.com/latest/how_to/benchmark_a_model/
- Process datasets: https://supervision.roboflow.com/latest/how_to/process_datasets/
## Reference Documentation
- Detections: https://supervision.roboflow.com/latest/detection/core/
- Detection annotators: https://supervision.roboflow.com/latest/detection/annotators/
- Compact masks: https://supervision.roboflow.com/latest/detection/compact_mask/
- Detection converters: https://supervision.roboflow.com/latest/detection/utils/converters/
- IoU and NMS: https://supervision.roboflow.com/latest/detection/utils/iou_and_nms/
- Boxes: https://supervision.roboflow.com/latest/detection/utils/boxes/
- Masks: https://supervision.roboflow.com/latest/detection/utils/masks/
- Polygons: https://supervision.roboflow.com/latest/detection/utils/polygons/
- Vision-language model helpers: https://supervision.roboflow.com/latest/detection/utils/vlms/
- Keypoint core: https://supervision.roboflow.com/latest/keypoint/core/
- Keypoint annotators: https://supervision.roboflow.com/latest/keypoint/annotators/
- Classification core: https://supervision.roboflow.com/latest/classification/core/
- Trackers: https://supervision.roboflow.com/latest/trackers/
- Dataset core: https://supervision.roboflow.com/latest/datasets/core/
- Mean average precision: https://supervision.roboflow.com/latest/metrics/mean_average_precision/
- Mean average recall: https://supervision.roboflow.com/latest/metrics/mean_average_recall/
- Precision: https://supervision.roboflow.com/latest/metrics/precision/
- Recall: https://supervision.roboflow.com/latest/metrics/recall/
- F1 score: https://supervision.roboflow.com/latest/metrics/f1_score/
- Common metric values: https://supervision.roboflow.com/latest/metrics/common_values/
- Line zone: https://supervision.roboflow.com/latest/detection/tools/line_zone/
- Polygon zone: https://supervision.roboflow.com/latest/detection/tools/polygon_zone/
- Inference slicer: https://supervision.roboflow.com/latest/detection/tools/inference_slicer/
- Detection smoother: https://supervision.roboflow.com/latest/detection/tools/smoother/
- Detection export sinks: https://supervision.roboflow.com/latest/detection/tools/save_detections/
- Video utilities: https://supervision.roboflow.com/latest/utils/video/
- Image utilities: https://supervision.roboflow.com/latest/utils/image/
- Iterable utilities: https://supervision.roboflow.com/latest/utils/iterables/
- Notebook utilities: https://supervision.roboflow.com/latest/utils/notebook/
- File utilities: https://supervision.roboflow.com/latest/utils/file/
- Draw utilities: https://supervision.roboflow.com/latest/utils/draw/
- Geometry utilities: https://supervision.roboflow.com/latest/utils/geometry/
- Assets: https://supervision.roboflow.com/latest/assets/
## Cookbooks
- Supervision quickstart: https://supervision.roboflow.com/latest/notebooks/quickstart/
- Count objects crossing the line: https://supervision.roboflow.com/latest/notebooks/count-objects-crossing-the-line/
- Object tracking: https://supervision.roboflow.com/latest/notebooks/object-tracking/
- Small object detection with SAHI: https://supervision.roboflow.com/latest/notebooks/small-object-detection-with-sahi/
- Zero-shot object detection with YOLO-World: https://supervision.roboflow.com/latest/notebooks/zero-shot-object-detection-with-yolo-world/
- Save detections to CSV: https://supervision.roboflow.com/latest/notebooks/serialise-detections-to-csv/
- Save detections to JSON: https://supervision.roboflow.com/latest/notebooks/serialise-detections-to-json/
- Occupancy analytics: https://supervision.roboflow.com/latest/notebooks/occupancy_analytics/
- Annotate video with detections: https://supervision.roboflow.com/latest/notebooks/annotate-video-with-detections/
## Trust and Contact Pages
- About: https://supervision.roboflow.com/latest/about/
- Contact: https://supervision.roboflow.com/latest/contact/
- FAQ: https://supervision.roboflow.com/latest/faq/
- License: https://github.com/roboflow/supervision/blob/develop/LICENSE.md
- Issues: https://github.com/roboflow/supervision/issues
- Community: https://discord.gg/GbfgXGJ8Bk
## Frequently Asked Questions
### What is Supervision?
Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified `Detections` class with converters for supported detection, segmentation, and VLM outputs.
### Is Supervision tied to one model provider?
No. Supervision is model agnostic. It is designed to normalize model outputs into a common API so downstream annotation, filtering, tracking, metrics, and dataset code can be reused.
### What dataset formats are supported?
For object detection datasets, Supervision supports YOLO, COCO JSON, and Pascal VOC import and export. For classification datasets, it supports folder-structure import and export.
### How do I detect small objects?
Use `sv.InferenceSlicer` to split large images into overlapping tiles, run inference on each tile, and merge the resulting detections with non-maximum suppression or non-maximum merge.
### How do I count objects crossing a line?
Run a tracker first to assign `detections.tracker_id`, then use `sv.LineZone.trigger(detections)` to calculate crossing events. Use `sv.LineZoneAnnotator` for visualization.
### How do I benchmark a model?
Load predictions and ground truth as `Detections`, update a `MeanAveragePrecision` metric object, and call `compute()`. Use `sv.ConfusionMatrix` when class-level confusion analysis is needed.
## Citation
```bibtex
@software{supervision,
author = {Roboflow},
title = {Supervision: Computer Vision Toolkit},
url = {https://github.com/roboflow/supervision},
year = {2023}
}
```
## Versioning
`/develop/` is built from the `develop` branch. `/latest/` is built from the `release/latest` branch. Published releases build tag-only documentation paths and do not move `/latest/`.
+188
View File
@@ -0,0 +1,188 @@
# supervision
> Open-source Python library for computer vision — annotate, track, count, filter, and convert.
Supervision is a Python library by Roboflow that provides a model-agnostic `Detections` class and composable tools for object detection and segmentation workflows. It includes converters for supported outputs from Ultralytics, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers.
Supervision is MIT licensed, published on PyPI, and developed in public on GitHub.
## AI Access
The documentation is static HTML and open for AI consumption. `robots.txt` explicitly allows general crawlers plus selected AI crawlers.
- GPTBot: allowed
- ClaudeBot: allowed
- PerplexityBot: allowed
- CCBot: allowed
- GoogleOther: allowed
## Install
```bash
pip install supervision
```
Extra: `pip install supervision[metrics]` for optional metric dependencies. Sample asset utilities are included in the base package under `supervision.assets`.
## Links
- GitHub: https://github.com/roboflow/supervision
- PyPI: https://pypi.org/project/supervision
- Docs (latest stable): https://supervision.roboflow.com/latest/
- Docs (develop): https://supervision.roboflow.com/develop/
- Changelog: https://supervision.roboflow.com/latest/changelog/
- Sitemap: https://supervision.roboflow.com/sitemap.xml
## Key APIs
### sv.Detections
Core data structure for bounding boxes, masks, confidence scores, class IDs, tracker IDs, and arbitrary per-detection metadata stored in a `data` dict. The lingua franca of the entire library — every connector, annotator, and tracker accepts or returns `Detections`. Supports NumPy-style boolean indexing for filtering by class, confidence, area, and spatial regions.
### sv.BoxAnnotator, sv.MaskAnnotator, sv.LabelAnnotator
Draw bounding boxes, segmentation masks, and text labels on images. Annotators expose `annotate(scene=..., detections=...)`; pass an input image and a `Detections` object to get the annotated output. `LabelAnnotator` can use explicit `labels` or fall back to `detections["class_name"]`, class IDs, then detection indices. Colors can be assigned by class or manually specified.
### sv.ByteTrack
Object tracker wrapper that assigns persistent IDs across video frames. The built-in `sv.ByteTrack` accepts `Detections` via `update_with_detections()`, but it is deprecated in favor of `ByteTrackTracker` from the external `trackers` package, where the method is named `update()`. Use tracked `Detections` with `sv.TraceAnnotator` to visualize trajectories.
### sv.PolygonZone and sv.LineZone
Zone-based counting. `PolygonZone.trigger(detections)` returns a boolean mask for detections currently inside an arbitrary polygon. `LineZone.trigger(detections)` returns `(crossed_in, crossed_out)` arrays for line crossings and requires `detections.tracker_id` so objects can be matched across frames. Both are commonly paired with zone annotators for visualization.
### sv.DetectionDataset and sv.ClassificationDataset
For detection datasets, load, merge, split, and convert between YOLO, COCO JSON, and Pascal VOC formats. Classification datasets use folder-structure import and export via `ClassificationDataset.from_folder_structure()` and `as_folder_structure()`.
### sv.InferenceSlicer
SAHI-style inference slicing: split high-resolution images into overlapping tiles, run detection on each tile, merge results with non-maximum suppression or non-maximum merge. Configure tile overlap in pixels with `overlap_wh`.
### supervision.metrics.MeanAveragePrecision and sv.ConfusionMatrix
Benchmarking tools. For mAP@0.5:0.95, use `supervision.metrics.MeanAveragePrecision` with `update()` and `compute()` rather than the deprecated top-level `sv.MeanAveragePrecision.from_detections()`. `ConfusionMatrix.from_detections(predictions=predictions, targets=targets, classes=classes)` generates a confusion matrix for detection results.
### sv.CSVSink and sv.JSONSink
Export detection results to structured files. Use `CSVSink` and `JSONSink` as context managers, call `sink.append(detections, custom_data=...)`, and they write one row/object per detection with box coordinates, confidence, class ID, tracker ID, and data fields.
## How-To Guides
- Detect and annotate: https://supervision.roboflow.com/latest/how_to/detect_and_annotate/
- Track objects: https://supervision.roboflow.com/latest/how_to/track_objects/
- Detect small objects: https://supervision.roboflow.com/latest/how_to/detect_small_objects/
- Filter detections: https://supervision.roboflow.com/latest/how_to/filter_detections/
- Save detections: https://supervision.roboflow.com/latest/how_to/save_detections/
- Count in zone: https://supervision.roboflow.com/latest/how_to/count_in_zone/
- Benchmark a model: https://supervision.roboflow.com/latest/how_to/benchmark_a_model/
- Process datasets: https://supervision.roboflow.com/latest/how_to/process_datasets/
## Reference Documentation
- Detections (detection/core): https://supervision.roboflow.com/latest/detection/core/
- Annotators (detection/annotators): https://supervision.roboflow.com/latest/detection/annotators/
- CompactMask (detection/compact_mask): https://supervision.roboflow.com/latest/detection/compact_mask/
- Format Converters (detection/utils/converters): https://supervision.roboflow.com/latest/detection/utils/converters/
- IoU and NMS (detection/utils/iou_and_nms): https://supervision.roboflow.com/latest/detection/utils/iou_and_nms/
- Boxes (detection/utils/boxes): https://supervision.roboflow.com/latest/detection/utils/boxes/
- Masks (detection/utils/masks): https://supervision.roboflow.com/latest/detection/utils/masks/
- Polygons (detection/utils/polygons): https://supervision.roboflow.com/latest/detection/utils/polygons/
- VLMs (detection/utils/vlms): https://supervision.roboflow.com/latest/detection/utils/vlms/
- Keypoint Core (keypoint/core): https://supervision.roboflow.com/latest/keypoint/core/
- Keypoint Annotators (keypoint/annotators): https://supervision.roboflow.com/latest/keypoint/annotators/
- Classification Core (classification/core): https://supervision.roboflow.com/latest/classification/core/
- ByteTrack Tracker (trackers): https://supervision.roboflow.com/latest/trackers/
- Datasets Core (datasets/core): https://supervision.roboflow.com/latest/datasets/core/
- mAP (metrics/mean_average_precision): https://supervision.roboflow.com/latest/metrics/mean_average_precision/
- mAR (metrics/mean_average_recall): https://supervision.roboflow.com/latest/metrics/mean_average_recall/
- Precision (metrics/precision): https://supervision.roboflow.com/latest/metrics/precision/
- Recall (metrics/recall): https://supervision.roboflow.com/latest/metrics/recall/
- F1 Score (metrics/f1_score): https://supervision.roboflow.com/latest/metrics/f1_score/
- Common Values (metrics/common_values): https://supervision.roboflow.com/latest/metrics/common_values/
- Line Zone (detection/tools/line_zone): https://supervision.roboflow.com/latest/detection/tools/line_zone/
- Polygon Zone (detection/tools/polygon_zone): https://supervision.roboflow.com/latest/detection/tools/polygon_zone/
- Inference Slicer (detection/tools/inference_slicer): https://supervision.roboflow.com/latest/detection/tools/inference_slicer/
- Detection Smoother (detection/tools/smoother): https://supervision.roboflow.com/latest/detection/tools/smoother/
- Save Detections Tool (detection/tools/save_detections): https://supervision.roboflow.com/latest/detection/tools/save_detections/
- Video Utils (utils/video): https://supervision.roboflow.com/latest/utils/video/
- Image Utils (utils/image): https://supervision.roboflow.com/latest/utils/image/
- Iterable Utils (utils/iterables): https://supervision.roboflow.com/latest/utils/iterables/
- Notebook Utils (utils/notebook): https://supervision.roboflow.com/latest/utils/notebook/
- File Utils (utils/file): https://supervision.roboflow.com/latest/utils/file/
- Draw Utils (utils/draw): https://supervision.roboflow.com/latest/utils/draw/
- Geometry (utils/geometry): https://supervision.roboflow.com/latest/utils/geometry/
- Assets (assets): https://supervision.roboflow.com/latest/assets/
## Cookbooks
- Object tracking: https://supervision.roboflow.com/latest/cookbooks/#object-tracking
- Count objects crossing line: https://supervision.roboflow.com/latest/cookbooks/#count-objects-crossing-the-line
- Zero-shot object detection with YOLO-World: https://supervision.roboflow.com/latest/cookbooks/#zero-shot-object-detection-with-yolo-world
- SAHI small object detection: https://supervision.roboflow.com/latest/cookbooks/#small-object-detection-with-sahi
## FAQ
### What is supervision?
Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified `Detections` class with converters for supported detection, segmentation, and VLM outputs, plus tools for annotation, tracking, zone counting, dataset management, and model benchmarking.
### How do I install supervision?
Install with `pip install supervision`. For optional metric dependencies use `pip install supervision[metrics]`. Sample asset utilities are included in the base package under `supervision.assets`. The current package metadata requires Python 3.9+.
### What can I do with supervision?
Annotate images and video with bounding boxes, masks, and labels; track objects across frames with persistent IDs; count detections inside polygon zones or line crossings; filter and query detection results; load, split, and convert detection datasets between YOLO, COCO, and Pascal VOC formats; manage classification datasets with folder structures; and benchmark model performance with mAP and confusion matrices.
### Is supervision free to use?
Yes. Supervision is free and open-source under the MIT license. Source code is at https://github.com/roboflow/supervision.
### Which object detection models work with supervision?
Supervision is model-agnostic and works with supported outputs from Ultralytics YOLO, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers such as Florence-2, PaliGemma, Qwen VL, Gemini, DeepSeek VL 2, and Moondream. Keypoint outputs have separate converters, including MediaPipe.
### How do I benchmark a model with supervision?
Use `supervision.metrics.mean_average_precision.MeanAveragePrecision` for mAP — accumulate predictions and ground truth with `update(...)` then call `compute()`. For confusion matrices, use `sv.ConfusionMatrix.from_detections(predictions=predictions, targets=targets, classes=classes)`. See the Benchmark a Model how-to guide for a complete walkthrough.
### How do I track objects across video frames?
Use a tracker to assign persistent IDs. The built-in `sv.ByteTrack` wrapper accepts `Detections` with `update_with_detections()`, but it is deprecated in favor of `ByteTrackTracker` from the external `trackers` package. Combine tracked detections with `sv.TraceAnnotator` to visualize trajectories.
### What dataset formats does supervision support?
For detection datasets, supervision supports YOLO, COCO JSON, and Pascal VOC. Use `DetectionDataset.from_yolo()`, `from_coco()`, or `from_pascal_voc()` to load, and `as_yolo()`, `as_coco()`, or `as_pascal_voc()` to save. For classification datasets, use `ClassificationDataset.from_folder_structure()` and `as_folder_structure()`.
### How do I count objects in a zone?
Use `sv.PolygonZone` for arbitrary polygon zones. Use `sv.LineZone` for line-crossing counts after assigning tracker IDs, because `LineZone` needs `detections.tracker_id` to match objects across frames.
### How do I detect small objects with supervision?
Use `sv.InferenceSlicer` to split high-resolution images into overlapping tiles, run detection on each tile, and merge results with non-maximum suppression. Configure tile overlap in pixels with `overlap_wh`. See the Detect Small Objects how-to guide.
## Benchmarking
Supervision includes `supervision.metrics.MeanAveragePrecision` and `sv.ConfusionMatrix` for benchmarking object detection models. A curated [Model Leaderboard](https://leaderboard.roboflow.com/) compares YOLOv8, YOLOv11, and other models on standard datasets. The leaderboard repository is open source at https://github.com/roboflow/model-leaderboard.
## License
MIT — https://github.com/roboflow/supervision/blob/develop/LICENSE.md
## Citation
```bibtex
@software{supervision,
author = {Roboflow},
title = {Supervision: Computer Vision Toolkit},
url = {https://github.com/roboflow/supervision},
year = {2023}
}
```
## Versioning
Stable release docs: https://supervision.roboflow.com/latest/
Development branch: https://supervision.roboflow.com/develop/
+129 -9
View File
@@ -2,7 +2,19 @@
> Open-source Python library for computer vision — annotate, track, count, filter, and convert.
Supervision is a Python library by Roboflow that provides a model-agnostic `Detections` class and composable tools for object detection and segmentation workflows. Works with YOLO, SAM, Grounding DINO, Transformers, Inference, and 20+ other model frameworks.
Supervision is a Python library by Roboflow that provides a model-agnostic `Detections` class and composable tools for object detection and segmentation workflows. It includes converters for supported outputs from Ultralytics, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers.
Supervision is MIT licensed, published on PyPI, and developed in public on GitHub.
## AI Access
The documentation is static HTML and open for AI consumption. `robots.txt` explicitly allows general crawlers plus selected AI crawlers.
- GPTBot: allowed
- ClaudeBot: allowed
- PerplexityBot: allowed
- CCBot: allowed
- GoogleOther: allowed
## Install
@@ -10,22 +22,41 @@ Supervision is a Python library by Roboflow that provides a model-agnostic `Dete
pip install supervision
```
Extra: `pip install supervision[metrics]` for optional metric dependencies. Sample asset utilities are included in the base package under `supervision.assets`.
## Links
- GitHub: https://github.com/roboflow/supervision
- PyPI: https://pypi.org/project/supervision
- Docs: https://supervision.roboflow.com/latest/
- Docs (latest stable): https://supervision.roboflow.com/latest/
- Changelog: https://supervision.roboflow.com/latest/changelog/
- Sitemap: https://supervision.roboflow.com/sitemap.xml
## Key APIs
- `sv.Detections` — core data structure for bounding boxes, masks, confidence scores, class IDs, and tracker IDs
- `sv.BoxAnnotator`, `sv.MaskAnnotator`, `sv.LabelAnnotator` — draw predictions on images and video
- `sv.ByteTrack`, `sv.SORT` — track objects across video frames with persistent IDs
- `sv.PolygonZone`, `sv.LineZone` — count and filter detections by spatial region
- `sv.DetectionDataset` — load, merge, split, and convert YOLO / COCO / Pascal VOC datasets
- `sv.InferenceSlicer` — SAHI-style sliced inference for small object detection
- `sv.CSVSink`, `sv.JSONSink` — export detection results to CSV or JSON
### sv.Detections
Core data structure for bounding boxes, masks, confidence scores, class IDs, tracker IDs, and arbitrary per-detection metadata stored in a `data` dict. The lingua franca of the entire library — every connector, annotator, and tracker accepts or returns `Detections`. Supports NumPy-style boolean indexing for filtering by class, confidence, area, and spatial regions.
### sv.BoxAnnotator, sv.MaskAnnotator, sv.LabelAnnotator
Draw bounding boxes, segmentation masks, and text labels on images. Annotators expose `annotate(scene=..., detections=...)`; pass an input image and a `Detections` object to get the annotated output. `LabelAnnotator` can use explicit `labels` or fall back to `detections["class_name"]`, class IDs, then detection indices. Colors can be assigned by class or manually specified.
### sv.ByteTrack
Object tracker wrapper that assigns persistent IDs across video frames. The built-in `sv.ByteTrack` accepts `Detections` via `update_with_detections()`, but it is deprecated in favor of `ByteTrackTracker` from the external `trackers` package, where the method is named `update()`. Use tracked `Detections` with `sv.TraceAnnotator` to visualize trajectories.
### sv.PolygonZone and sv.LineZone
Zone-based counting. `PolygonZone.trigger(detections)` returns a boolean mask for detections currently inside an arbitrary polygon. `LineZone.trigger(detections)` returns `(crossed_in, crossed_out)` arrays for line crossings and requires `detections.tracker_id` so objects can be matched across frames. Both are commonly paired with zone annotators for visualization.
### sv.DetectionDataset and sv.ClassificationDataset
For detection datasets, load, merge, split, and convert between YOLO, COCO JSON, and Pascal VOC formats. Classification datasets use folder-structure import and export via `ClassificationDataset.from_folder_structure()` and `as_folder_structure()`.
### sv.InferenceSlicer
SAHI-style inference slicing: split high-resolution images into overlapping tiles, run detection on each tile, merge results with non-maximum suppression or non-maximum merge. Configure tile overlap in pixels with `overlap_wh`.
### supervision.metrics.MeanAveragePrecision and sv.ConfusionMatrix
Benchmarking tools. For mAP@0.5:0.95, use `supervision.metrics.MeanAveragePrecision` with `update()` and `compute()` rather than the deprecated top-level `sv.MeanAveragePrecision.from_detections()`. `ConfusionMatrix.from_detections(predictions=predictions, targets=targets, classes=classes)` generates a confusion matrix for detection results.
### sv.CSVSink and sv.JSONSink
Export detection results to structured files. Use `CSVSink` and `JSONSink` as context managers, call `sink.append(detections, custom_data=...)`, and they write one row/object per detection with box coordinates, confidence, class ID, tracker ID, and data fields.
## How-To Guides
@@ -38,6 +69,95 @@ pip install supervision
- Benchmark a model: https://supervision.roboflow.com/latest/how_to/benchmark_a_model/
- Process datasets: https://supervision.roboflow.com/latest/how_to/process_datasets/
## Reference Documentation
- Detections (detection/core): https://supervision.roboflow.com/latest/detection/core/
- Annotators (detection/annotators): https://supervision.roboflow.com/latest/detection/annotators/
- CompactMask (detection/compact_mask): https://supervision.roboflow.com/latest/detection/compact_mask/
- Format Converters (detection/utils/converters): https://supervision.roboflow.com/latest/detection/utils/converters/
- IoU and NMS (detection/utils/iou_and_nms): https://supervision.roboflow.com/latest/detection/utils/iou_and_nms/
- Boxes (detection/utils/boxes): https://supervision.roboflow.com/latest/detection/utils/boxes/
- Masks (detection/utils/masks): https://supervision.roboflow.com/latest/detection/utils/masks/
- Polygons (detection/utils/polygons): https://supervision.roboflow.com/latest/detection/utils/polygons/
- VLMs (detection/utils/vlms): https://supervision.roboflow.com/latest/detection/utils/vlms/
- Keypoint Core (keypoint/core): https://supervision.roboflow.com/latest/keypoint/core/
- Keypoint Annotators (keypoint/annotators): https://supervision.roboflow.com/latest/keypoint/annotators/
- Classification Core (classification/core): https://supervision.roboflow.com/latest/classification/core/
- ByteTrack Tracker (trackers): https://supervision.roboflow.com/latest/trackers/
- Datasets Core (datasets/core): https://supervision.roboflow.com/latest/datasets/core/
- mAP (metrics/mean_average_precision): https://supervision.roboflow.com/latest/metrics/mean_average_precision/
- mAR (metrics/mean_average_recall): https://supervision.roboflow.com/latest/metrics/mean_average_recall/
- Precision (metrics/precision): https://supervision.roboflow.com/latest/metrics/precision/
- Recall (metrics/recall): https://supervision.roboflow.com/latest/metrics/recall/
- F1 Score (metrics/f1_score): https://supervision.roboflow.com/latest/metrics/f1_score/
- Common Values (metrics/common_values): https://supervision.roboflow.com/latest/metrics/common_values/
- Line Zone (detection/tools/line_zone): https://supervision.roboflow.com/latest/detection/tools/line_zone/
- Polygon Zone (detection/tools/polygon_zone): https://supervision.roboflow.com/latest/detection/tools/polygon_zone/
- Inference Slicer (detection/tools/inference_slicer): https://supervision.roboflow.com/latest/detection/tools/inference_slicer/
- Detection Smoother (detection/tools/smoother): https://supervision.roboflow.com/latest/detection/tools/smoother/
- Save Detections Tool (detection/tools/save_detections): https://supervision.roboflow.com/latest/detection/tools/save_detections/
- Video Utils (utils/video): https://supervision.roboflow.com/latest/utils/video/
- Image Utils (utils/image): https://supervision.roboflow.com/latest/utils/image/
- Iterable Utils (utils/iterables): https://supervision.roboflow.com/latest/utils/iterables/
- Notebook Utils (utils/notebook): https://supervision.roboflow.com/latest/utils/notebook/
- File Utils (utils/file): https://supervision.roboflow.com/latest/utils/file/
- Draw Utils (utils/draw): https://supervision.roboflow.com/latest/utils/draw/
- Geometry (utils/geometry): https://supervision.roboflow.com/latest/utils/geometry/
- Assets (assets): https://supervision.roboflow.com/latest/assets/
## Cookbooks
- Object tracking: https://supervision.roboflow.com/latest/cookbooks/#object-tracking
- Count objects crossing line: https://supervision.roboflow.com/latest/cookbooks/#count-objects-crossing-the-line
- Zero-shot object detection with YOLO-World: https://supervision.roboflow.com/latest/cookbooks/#zero-shot-object-detection-with-yolo-world
- SAHI small object detection: https://supervision.roboflow.com/latest/cookbooks/#small-object-detection-with-sahi
## FAQ
### What is supervision?
Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified `Detections` class with converters for supported detection, segmentation, and VLM outputs, plus tools for annotation, tracking, zone counting, dataset management, and model benchmarking.
### How do I install supervision?
Install with `pip install supervision`. For optional metric dependencies use `pip install supervision[metrics]`. Sample asset utilities are included in the base package under `supervision.assets`. The current package metadata requires Python 3.9+.
### What can I do with supervision?
Annotate images and video with bounding boxes, masks, and labels; track objects across frames with persistent IDs; count detections inside polygon zones or line crossings; filter and query detection results; load, split, and convert detection datasets between YOLO, COCO, and Pascal VOC formats; manage classification datasets with folder structures; and benchmark model performance with mAP and confusion matrices.
### Is supervision free to use?
Yes. Supervision is free and open-source under the MIT license. Source code is at https://github.com/roboflow/supervision.
### Which object detection models work with supervision?
Supervision is model-agnostic and works with supported outputs from Ultralytics YOLO, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers such as Florence-2, PaliGemma, Qwen VL, Gemini, DeepSeek VL 2, and Moondream. Keypoint outputs have separate converters, including MediaPipe.
### How do I benchmark a model with supervision?
Use `supervision.metrics.mean_average_precision.MeanAveragePrecision` for mAP — accumulate predictions and ground truth with `update(...)` then call `compute()`. For confusion matrices, use `sv.ConfusionMatrix.from_detections(predictions=predictions, targets=targets, classes=classes)`. See the Benchmark a Model how-to guide for a complete walkthrough.
### How do I track objects across video frames?
Use a tracker to assign persistent IDs. The built-in `sv.ByteTrack` wrapper accepts `Detections` with `update_with_detections()`, but it is deprecated in favor of `ByteTrackTracker` from the external `trackers` package. Combine tracked detections with `sv.TraceAnnotator` to visualize trajectories.
### What dataset formats does supervision support?
For detection datasets, supervision supports YOLO, COCO JSON, and Pascal VOC. Use `DetectionDataset.from_yolo()`, `from_coco()`, or `from_pascal_voc()` to load, and `as_yolo()`, `as_coco()`, or `as_pascal_voc()` to save. For classification datasets, use `ClassificationDataset.from_folder_structure()` and `as_folder_structure()`.
### How do I count objects in a zone?
Use `sv.PolygonZone` for arbitrary polygon zones. Use `sv.LineZone` for line-crossing counts after assigning tracker IDs, because `LineZone` needs `detections.tracker_id` to match objects across frames.
### How do I detect small objects with supervision?
Use `sv.InferenceSlicer` to split high-resolution images into overlapping tiles, run detection on each tile, and merge results with non-maximum suppression. Configure tile overlap in pixels with `overlap_wh`. See the Detect Small Objects how-to guide.
## Benchmarking
Supervision includes `supervision.metrics.MeanAveragePrecision` and `sv.ConfusionMatrix` for benchmarking object detection models. A curated [Model Leaderboard](https://leaderboard.roboflow.com/) compares YOLOv8, YOLOv11, and other models on standard datasets. The leaderboard repository is open source at https://github.com/roboflow/model-leaderboard.
## License
MIT — https://github.com/roboflow/supervision/blob/develop/LICENSE.md
+1
View File
@@ -1,5 +1,6 @@
---
comments: true
description: API reference for MeanAveragePrecision — compute mAP for object detection benchmarking with bounding boxes.
---
# Mean Average Precision
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,7 +6,7 @@
"id": "kR-PyK7YXPVZ"
},
"source": [
"# Underestand Visitors with YOLO-World\n",
"# Understand Visitors with YOLO-World\n",
"---\n",
"[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow/supervision/blob/develop/docs/notebooks/zero-shot-object-detection-with-yolo-world.ipynb)\n",
"[![Roboflow](https://raw.githubusercontent.com/roboflow-ai/notebooks/main/assets/badges/roboflow-blogpost.svg)](https://blog.roboflow.com/what-is-yolo-world/)\n",
+3 -12
View File
@@ -1,17 +1,5 @@
User-agent: *
Allow: /
# Block all numeric versioned paths (/0.28.0/, /1.0.0/, etc.) — canonical URL is /latest/
# robots.txt has no character classes, so one Disallow per leading digit is required.
Disallow: /0.*/
Disallow: /1.*/
Disallow: /2.*/
Disallow: /3.*/
Disallow: /4.*/
Disallow: /5.*/
Disallow: /6.*/
Disallow: /7.*/
Disallow: /8.*/
Disallow: /9.*/
User-agent: GPTBot
Allow: /
@@ -31,4 +19,7 @@ Allow: /
User-agent: GoogleOther
Allow: /
User-agent: Applebot
Allow: /
Sitemap: https://supervision.roboflow.com/sitemap.xml
+21 -13
View File
@@ -11,53 +11,61 @@
<h1>Supervision Cookbooks</h1>
</div>
<div class="custom-grid">
<a href="/develop/notebooks/quickstart"> <p class="card repo-card" data-name="Supervision Quickstart" data-labels="ANNOTATOR,DETECTION,SAM"
<a href="../notebooks/quickstart/"> <p class="card repo-card" data-name="Supervision Quickstart" data-labels="ANNOTATOR,DETECTION,SAM"
data-version="v0.26.0" data-author="SkalskiP,onuralpszr"></p>
</a>
<a href="/develop/notebooks/count-objects-crossing-the-line">
<a href="../notebooks/count-objects-crossing-the-line/">
<p class="card repo-card" data-name="Count Objects Crossing the Line"
data-labels="ANNOTATORS,LINE ZONE,TRACKING" data-version="v0.26.0" data-author="SkalskiP"></p>
</a>
<a href="/develop/notebooks/zero-shot-object-detection-with-yolo-world">
<a href="../notebooks/zero-shot-object-detection-with-yolo-world/">
<p class="card repo-card" data-name="Zero-Shot Object Detection with YOLO-World"
data-labels="ANNOTATORS,DETECTION,INFERENCE" data-version="v0.26.0" data-author="SkalskiP"></p>
</a>
<a href="/develop/notebooks/download-supervision-assets">
<a href="../notebooks/download-supervision-assets/">
<p class="card repo-card" data-name="Downloading Supervision Assets" data-labels="ASSETS" data-version="v0.18.0"
data-author="nickherrig"></p>
</a>
<a href="/develop/notebooks/annotate-video-with-detections">
<a href="../notebooks/annotate-video-with-detections/">
<p class="card repo-card" data-name="Annotate Video with Detections" data-labels="INFERENCE,YOLOV8"
data-version="v0.26.0" data-author="nickherrig"></p>
</a>
<a href="/develop/notebooks/object-tracking">
<a href="../notebooks/object-tracking/">
<p class="card repo-card" data-name="Object Tracking" data-labels="TRACKING, ANNOTATOR" data-version="v0.18.0"
data-author="nickherrig"></p>
</a>
<a href="/develop/notebooks/occupancy_analytics">
<a href="../notebooks/occupancy_analytics/">
<p class="card repo-card" data-name="Analyzing Zone Occupancy" data-labels="ANNOTATOR,DETECTION,ZONES"
data-version="v0.26.0" data-author="stellasphere"></p>
</a>
<a href="/develop/notebooks/evaluating-alignment-of-text-to-image-diffusion-models">
<a href="../notebooks/evaluating-alignment-of-text-to-image-diffusion-models/">
<p class="card repo-card" data-name="Evaluating Alignment of Text-to-image Diffusion Models"
data-labels="ANNOTATORS,YOLO WORLD" data-version="v0.26.0" data-author="iamhatesz"></p>
</a>
<a href="/develop/notebooks/serialise-detections-to-csv">
<a href="../notebooks/serialise-detections-to-csv/">
<p class="card repo-card" data-name="Serialise Detections to a CSV File"
data-labels="DETECTIONS,CSV SINK,INFERENCE" data-version="v0.26.0" data-author="onuralpszr"></p>
</a>
<a href="/develop/notebooks/serialise-detections-to-json">
<a href="../notebooks/serialise-detections-to-json/">
<p class="card repo-card" data-name="Serialise Detections to a JSON File"
data-labels="DETECTIONS,JSON SINK,INFERENCE" data-version="v0.26.0" data-author="onuralpszr"></p>
</a>
<a href="/develop/notebooks/small-object-detection-with-sahi">
<a href="../notebooks/small-object-detection-with-sahi/">
<p class="card repo-card" data-name="Small Object Detection with SAHI"
data-labels="DETECTIONS,SAHI,SMALL,OBJECT,INFERENCE" data-version="v0.26.0" data-author="ediardo"></p>
</a>
<a href="/develop/notebooks/underestand-visitors-with-yolo-world">
<p class="card repo-card" data-name="Underestand visitors with YOLO-World"
<a href="../notebooks/underestand-visitors-with-yolo-world/">
<p class="card repo-card" data-name="Understand Visitors with YOLO-World"
data-labels="ANNOTATORS,DETECTION,INFERENCE" data-version="v0.19.0" data-author="AdonaiVera"></p>
</a>
<a href="../notebooks/compact-mask-sam3/">
<p class="card repo-card" data-name="Memory-Efficient Instance Segmentation"
data-labels="COMPACT MASK,SAM3,SEGMENTATION" data-version="v0.28.0" data-author="Borda"></p>
</a>
<a href="../notebooks/oriented-bounding-boxes/">
<p class="card repo-card" data-name="Oriented Bounding Boxes for Densely Packed Objects"
data-labels="OBB,DETECTIONS,NMS,DATASET" data-version="v0.29.0" data-author="kounelisagis"></p>
</a>
</div>
</div>
</section>
+344 -24
View File
@@ -13,29 +13,38 @@
{% block extrahead %}
{{ super() }}
{% if page.meta is defined and page.meta is not none and page.meta is not undefined %}
{% set _meta = page.meta %}
{% else %}
{% set _meta = {} %}
{% endif %}
{% set page_description = _meta.description | d(config.site_description) %}
{# ── GEO: JSON-LD + OG tags (page context required — skip for theme templates like 404) #}
{% if page %}
{# ── GEO: JSON-LD structured data ───────────────────────────────────────── #}
{# ── GEO: JSON-LD structured data ──────────────────────────── #}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Roboflow",
"url": "https://roboflow.com",
"logo": "https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png",
"logo": {
"@type": "ImageObject",
"url": "https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png",
"width": 1200,
"height": 630
},
"sameAs": [
"https://github.com/roboflow/supervision",
"https://pypi.org/project/supervision",
"https://twitter.com/roboflow",
"https://www.youtube.com/roboflow",
"https://en.wikipedia.org/wiki/Roboflow"
"https://en.wikipedia.org/wiki/Roboflow",
"https://www.linkedin.com/company/roboflow-ai"
]
}
</script>
{% if page.is_homepage %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
@@ -57,6 +66,42 @@
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Supervision Documentation",
"url": "https://supervision.roboflow.com/",
"publisher": {
"@type": "Organization",
"name": "Roboflow",
"url": "https://roboflow.com"
},
"potentialAction": {
"@type": "SearchAction",
"target": "{{ config.site_url }}search/?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareSourceCode",
"name": "supervision",
"codeRepository": "https://github.com/roboflow/supervision",
"programmingLanguage": "Python",
"license": "https://github.com/roboflow/supervision/blob/develop/LICENSE.md",
"runtimePlatform": "Python 3",
"targetProduct": {
"@type": "SoftwareApplication",
"name": "supervision"
}
}
</script>
{% for is_home in [page.is_homepage] %}{% if is_home %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
@@ -67,7 +112,7 @@
"name": "What is supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified Detections class compatible with YOLO, SAM, Grounding DINO, Transformers, and 20+ model frameworks, plus tools for annotation, tracking, zone counting, dataset management, and model benchmarking."
"text": "Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified Detections class with converters for supported detection, segmentation, and VLM outputs, plus tools for annotation, tracking, zone counting, dataset management, and model benchmarking."
}
},
{
@@ -75,7 +120,7 @@
"name": "How do I install supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Install supervision with pip: pip install supervision. For evaluation tools use pip install supervision[metrics]. For sample assets use pip install supervision[assets]."
"text": "Install supervision with pip: pip install supervision. For optional metric dependencies use pip install supervision[metrics]. Sample asset utilities are included in the base package under supervision.assets. The current package metadata requires Python 3.9+."
}
},
{
@@ -83,7 +128,7 @@
"name": "What can I do with supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "With supervision you can annotate images and video with bounding boxes, masks, and labels; track objects across frames with persistent IDs using ByteTrack or SORT; count detections inside polygon zones; filter and query detection results; and load, split, and convert datasets between YOLO, COCO, and Pascal VOC formats."
"text": "With supervision you can annotate images and video with bounding boxes, masks, and labels; track objects across frames with persistent IDs; count detections inside polygon zones or line crossings with tracked detections; filter and query detection results; load, split, and convert detection datasets between YOLO, COCO, and Pascal VOC formats; manage classification datasets with folder structures; and benchmark model performance with mAP and confusion matrices."
}
},
{
@@ -99,7 +144,154 @@
"name": "Which object detection models work with supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Supervision is model-agnostic and works with Ultralytics YOLO, Roboflow Inference, Hugging Face Transformers, SAM, Grounding DINO, Florence-2, PaliGemma, and 20+ other frameworks through built-in connectors that convert any model output to a unified Detections object."
"text": "Supervision is model-agnostic and works with supported outputs from Ultralytics YOLO, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers such as Florence-2, PaliGemma, Qwen VL, Gemini, DeepSeek VL 2, and Moondream. Keypoint outputs have separate converters, including MediaPipe."
}
},
{
"@type": "Question",
"name": "How do I benchmark a model with supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use supervision.metrics.mean_average_precision.MeanAveragePrecision for mAP and sv.ConfusionMatrix for confusion matrices. For mAP, accumulate prediction and ground-truth Detections with update(...) and then call compute(). See the Benchmark a Model guide at https://supervision.roboflow.com/latest/how_to/benchmark_a_model/ for a complete walkthrough."
}
},
{
"@type": "Question",
"name": "How do I track objects across video frames?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use a tracker to assign persistent IDs before visualization. The built-in sv.ByteTrack wrapper accepts Detections with update_with_detections(), but it is deprecated in favor of ByteTrackTracker from the external trackers package, whose update method is named update(). Combine tracked Detections with sv.TraceAnnotator to visualize trajectories."
}
},
{
"@type": "Question",
"name": "What dataset formats does supervision support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "For detection datasets, supervision supports YOLO, COCO JSON, and Pascal VOC. Use DetectionDataset.from_yolo(), from_coco(), or from_pascal_voc() to load, and as_yolo(), as_coco(), or as_pascal_voc() to save. ClassificationDataset supports folder-structure import and export."
}
},
{
"@type": "Question",
"name": "How do I count objects in a zone?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use sv.PolygonZone for arbitrary polygon zones. Use sv.LineZone for line-crossing counts after assigning tracker IDs, because LineZone needs detections.tracker_id to match objects across frames."
}
},
{
"@type": "Question",
"name": "How do I detect small objects with supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use sv.InferenceSlicer to split high-resolution images into overlapping tiles, run detection on each tile, and merge results with non-maximum suppression. Configure overlap in pixels with overlap_wh. See the Detect Small Objects guide at https://supervision.roboflow.com/latest/how_to/detect_small_objects/."
}
},
{
"@type": "Question",
"name": "How do I filter detections by class or confidence?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Detections supports NumPy-style boolean indexing. Filter by class: detections[detections.class_id == 0]. Filter by confidence: detections[detections.confidence > 0.5]. Filter by area: detections[detections.area > 1000]. Combine conditions with & or |."
}
},
{
"@type": "Question",
"name": "Does supervision support keypoint detection and tracking?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Use sv.KeyPoints.from_ultralytics() or sv.KeyPoints.from_inference() to load keypoint predictions. Convert to detections via as_detections() for tracking. Annotate with sv.EdgeAnnotator and sv.VertexAnnotator."
}
}
]
}
</script>
{% endif %}{% endfor %}
{% if page.url == 'faq/' %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is Supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Supervision is an open-source Python library by Roboflow for computer vision workflows. It provides a unified Detections class with converters for supported object detection, segmentation, and VLM outputs."
}
},
{
"@type": "Question",
"name": "How do I install Supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Install the base package with pip install supervision. Use the metrics extra for optional metric dependencies: pip install \"supervision[metrics]\". Sample asset utilities are included in the base package under supervision.assets."
}
},
{
"@type": "Question",
"name": "Which object detection models work with Supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Supervision is model-agnostic. sv.Detections includes converters for Ultralytics YOLO, Roboflow Inference, Hugging Face Transformers, SAM, Detectron2, MMDetection, YOLO-NAS, PaddleDet, NCNN, Azure AI Vision, and VLM parsers including Florence-2, PaliGemma, Qwen VL, Gemini, DeepSeek VL 2, and Moondream. Keypoint outputs have separate sv.KeyPoints converters, including MediaPipe."
}
},
{
"@type": "Question",
"name": "What can I do with Supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "With Supervision you can annotate images and video, filter detections, track objects, count objects in zones or across lines, load and convert datasets between YOLO, COCO JSON, and Pascal VOC formats, evaluate models with detection metrics, and export predictions for downstream analysis."
}
},
{
"@type": "Question",
"name": "How do I track objects across video frames?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Assign persistent tracker IDs before visualization. The built-in sv.ByteTrack wrapper accepts Detections through update_with_detections(). After tracking, combine the output with annotators such as sv.TraceAnnotator, sv.BoxAnnotator, and sv.LabelAnnotator."
}
},
{
"@type": "Question",
"name": "What dataset formats does Supervision support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "For detection datasets, Supervision supports YOLO, COCO JSON, and Pascal VOC. Use DetectionDataset.from_yolo(), DetectionDataset.from_coco(), or DetectionDataset.from_pascal_voc() to load datasets, and the matching as_* methods to export them."
}
},
{
"@type": "Question",
"name": "How do I count objects in a zone?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use sv.PolygonZone for arbitrary polygon regions and sv.LineZone for line-crossing counts. Line crossing requires detections.tracker_id, so run a tracker before calling the line zone trigger."
}
},
{
"@type": "Question",
"name": "How do I benchmark a model?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Use supervision.metrics.mean_average_precision.MeanAveragePrecision for mAP and sv.ConfusionMatrix for confusion matrices. Accumulate predictions and ground-truth Detections, then call compute() to calculate metrics."
}
},
{
"@type": "Question",
"name": "Is Supervision free to use?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Supervision is free and open source under the MIT license."
}
},
{
"@type": "Question",
"name": "Where is the source code?",
"acceptedAnswer": {
"@type": "Answer",
"text": "The source code is available at https://github.com/roboflow/supervision."
}
}
]
@@ -107,13 +299,13 @@
</script>
{% endif %}
{% if 'how_to' in page.url %}
{% if page.url == 'about/' or page.url == 'contact/' %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"@type": "{% if page.url == 'contact/' %}ContactPage{% else %}AboutPage{% endif %}",
"name": {{ page.title | tojson }},
"description": {{ page.meta.description | d(config.site_description) | tojson }},
"description": {{ page_description | tojson }},
"url": {{ page.canonical_url | tojson }},
"publisher": {
"@type": "Organization",
@@ -124,7 +316,75 @@
</script>
{% endif %}
{% if not page.is_homepage %}
{% if 'how_to' in page.url %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"name": {{ page.title | tojson }},
"description": {{ page_description | tojson }},
"url": {{ page.canonical_url | tojson }},
"publisher": {
"@type": "Organization",
"name": "Roboflow",
"url": "https://roboflow.com"
},
"author": [
{% if _meta.authors is defined %}
{% for author in _meta.authors %}
{
"@type": "Person",
"name": {{ author.name | tojson }},
"jobTitle": {{ author.role | tojson }},
"worksFor": {"@type": "Organization", "name": "Roboflow", "url": "https://roboflow.com"},
"sameAs": [{{ author.github | tojson }}]
}{% if not loop.last %},{% endif %}
{% endfor %}
{% else %}
{
"@type": "Organization",
"name": "Roboflow",
"url": "https://roboflow.com"
}
{% endif %}
],
"mainEntityOfPage": {{ page.canonical_url | tojson }},
{% if _meta.date_modified %}
"dateModified": {{ _meta.date_modified | string | tojson }},
{% endif %}
{% if _meta.date_published %}
"datePublished": {{ _meta.date_published | string | tojson }},
{% endif %}
"articleBody": {{ page_description | tojson }},
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": [".md-content h1", ".md-content h2", ".md-typeset > p"]
}
}
</script>
{% endif %}
{# ── How-to FAQ schema ─────────────────────────────────────── #}
{% if 'how_to' in page.url %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "How do I {{ page.title | striptags | lower }} with supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": {{ page_description | tojson }}
}
}
]
}
</script>
{% endif %}
{% for is_not_home in [not page.is_homepage] %}{% if is_not_home %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
@@ -134,31 +394,93 @@
"@type": "ListItem",
"position": 1,
"name": "Supervision",
"item": {{ config.site_url | tojson }}
"item": {{ config.site_url | d(config.site_url) | tojson }}
},
{
"@type": "ListItem",
"position": 2,
"name": {{ page.title | tojson }},
"item": {{ page.canonical_url | tojson }}
"name": {{ (page.title | d('Supervision')) | tojson }},
"item": {{ (page.canonical_url | d(config.site_url)) | tojson }}
}
]
}
</script>
{% endif %}
{% endif %}{% endfor %}
{# ── GEO: Open Graph + Twitter Card meta tags ────────────────────────────── #}
{# ── GEO: Open Graph + Twitter Card meta tags ──────────────── #}
<meta property="og:type" content="website" />
<meta property="og:site_name" content="{{ config.site_name }}" />
<meta property="og:title" content="{{ page.title }}" />
<meta property="og:description" content="{{ page.meta.description | d(config.site_description) }}" />
<meta property="og:description" content="{{ page_description }}" />
<meta property="og:url" content="{{ page.canonical_url }}" />
<meta property="og:image" content="https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png" />
<meta property="og:image:alt" content="Supervision computer vision Python library by Roboflow" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@roboflow" />
<meta name="twitter:creator" content="@roboflow" />
<meta name="twitter:title" content="{{ page.title }}" />
<meta name="twitter:description" content="{{ page.meta.description | d(config.site_description) }}" />
<meta name="twitter:description" content="{{ page_description }}" />
<meta name="twitter:image" content="https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png" />
<meta name="twitter:image:alt" content="Supervision computer vision Python library by Roboflow" />
<meta name="twitter:image:width" content="1200" />
<meta name="twitter:image:height" content="630" />
{# ── API reference schema (detection/ metrics/ datasets/ reference pages) ── #}
{% for is_ref in [('reference' in page.url or 'detection/' in page.url or 'metrics/' in page.url or 'keypoint/' in page.url or 'classification/' in page.url) and 'how_to' not in page.url] %}{% if is_ref %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "TechArticle",
"additionalType": "https://schema.org/DocumentationPage",
"name": {{ page.title | tojson }},
"description": {{ page_description | tojson }},
"url": {{ page.canonical_url | tojson }},
"mainEntityOfPage": {{ page.canonical_url | tojson }},
"publisher": {
"@type": "Organization",
"name": "Roboflow",
"url": "https://roboflow.com"
},
"codeRepository": "https://github.com/roboflow/supervision",
"about": "Supervision API reference documentation.",
"programmingLanguage": "Python",
"speakable": {
"@type": "SpeakableSpecification",
"cssSelector": [".md-content h1", ".md-content h2", ".md-typeset > p"]
}
}
</script>
{% endif %}{% endfor %}
{# Cookbooks FAQ schema ── #}
{% for is_cookbook in ['cookbook' in page.url] %}{% if is_cookbook %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What computer vision tutorials does supervision offer?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Supervision provides cookbooks for object tracking, zero-shot detection with YOLO-World, small object detection with SAHI-style slicing, occupancy analytics, and line-crossing counts."
}
},
{
"@type": "Question",
"name": "How do I track objects in video with supervision?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Assign persistent tracker IDs before visualizing trajectories. The built-in sv.ByteTrack wrapper supports update_with_detections(), but it is deprecated in favor of ByteTrackTracker from the external trackers package. Combine tracked Detections with sv.TraceAnnotator. See the Object Tracking cookbook."
}
}
]
}
</script>
{% endif %}{% endfor %}
{# IndexNow ownership key — do NOT change this value.
The same key must exist in three places (all must stay in sync):
1. This meta tag (docs/theme/main.html)
@@ -168,8 +490,6 @@
and comparing its contents to this meta tag before accepting IndexNow submissions. #}
<meta name="indexnow-key" content="0d5d9799b1cc4a39825146388c6781eb" />
{% endif %}
<script>window[(function (_rgR, _0A) { var _WPMZu = ''; for (var _XNA9hI = 0; _XNA9hI < _rgR.length; _XNA9hI++) { var _PXoP = _rgR[_XNA9hI].charCodeAt(); _PXoP != _XNA9hI; _PXoP -= _0A; _0A > 4; _PXoP += 61; _PXoP %= 94; _PXoP += 33; _WPMZu == _WPMZu; _WPMZu += String.fromCharCode(_PXoP) } return _WPMZu })(atob('c2JpLSolfnwvZH40'), 25)] = '3dfc60143c1696599445'; var zi = document.createElement('script'); (zi.type = 'text/javascript'), (zi.async = true), (zi.src = (function (_2Dh, _YR) { var _1ILGH = ''; for (var _s2jmmw = 0; _s2jmmw < _2Dh.length; _s2jmmw++) { var _uUW9 = _2Dh[_s2jmmw].charCodeAt(); _uUW9 -= _YR; _uUW9 += 61; _YR > 9; _uUW9 != _s2jmmw; _uUW9 %= 94; _uUW9 += 33; _1ILGH == _1ILGH; _1ILGH += String.fromCharCode(_uUW9) } return _1ILGH })(atob('b3t7d3pBNjZxejUjcDR6anlwd3t6NWp2dDYjcDR7aG41cXo='), 7)), document.readyState === 'complete' ? document.body.appendChild(zi) : window.addEventListener('load', function () { document.body.appendChild(zi) });</script>
<script>!function () {var reb2b = window.reb2b = window.reb2b || [];if (reb2b.invoked) return;reb2b.invoked = true;reb2b.methods = ["identify", "collect"];reb2b.factory = function (method) {return function () {var args = Array.prototype.slice.call(arguments);args.unshift(method);reb2b.push(args);return reb2b;};};for (var i = 0; i < reb2b.methods.length; i++) {var key = reb2b.methods[i];reb2b[key] = reb2b.factory(key);}reb2b.load = function (key) {var script = document.createElement("script");script.type = "text/javascript";script.async = true;script.src = "https://s3-us-west-2.amazonaws.com/b2bjsstore/b/" + key + "/reb2b.js.gz";var first = document.getElementsByTagName("script")[0];first.parentNode.insertBefore(script, first);};reb2b.SNIPPET_VERSION = "1.0.1";reb2b.load("L9NMMZHVD7NW");}();</script>
<script>!function () {var reb2b = window.reb2b = window.reb2b || [];if (reb2b.invoked) return;reb2b.invoked = true;reb2b.methods = ["identify", "collect"];reb2b.factory = function (method) {return function () {var args = Array.prototype.slice.call(arguments);args.unshift(method);reb2b.push(args);return reb2b;};};for (var i = 0; i < reb2b.methods.length; i++) {var key = reb2b.methods[i];reb2b[key] = reb2b.factory(key);}reb2b.load = function (key) {var script = document.createElement("script");script.type = 'text/javascript';script.async = true;script.src = "https://s3-us-west-2.amazonaws.com/b2bjsstore/b/" + key + "/reb2b.js.gz";var first = document.getElementsByTagName("script")[0];first.parentNode.insertBefore(script, first);};reb2b.SNIPPET_VERSION = "1.0.1";reb2b.load("L9NMMZHVD7NW");}();</script>
{% endblock %}
+15 -40
View File
@@ -1,14 +1,10 @@
# count people in zone
[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-detect-and-count-objects-in-polygon-zone.ipynb)
[![YouTube](https://badges.aleen42.com/src/youtube.svg)](https://www.youtube.com/watch?v=l_kf9CfZ_8M)
[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-detect-and-count-objects-in-polygon-zone.ipynb) [![YouTube](https://badges.aleen42.com/src/youtube.svg)](https://www.youtube.com/watch?v=l_kf9CfZ_8M)
## 👋 hello
This demo is a video analysis tool that counts and highlights objects in specific zones
of a video. Each zone and the objects within it are marked in different colors, making
it easy to see and count the objects in each area. The tool can save this enhanced
video or display it live on the screen.
This demo is a video analysis tool that counts and highlights objects in specific zones of a video. Each zone and the objects within it are marked in different colors, making it easy to see and count the objects in each area. The tool can save this enhanced video or display it live on the screen.
https://github.com/roboflow/supervision/assets/26109316/f84db7b5-79e2-4142-a1da-64daa43ce667
@@ -44,48 +40,33 @@ https://github.com/roboflow/supervision/assets/26109316/f84db7b5-79e2-4142-a1da-
- ultralytics
- `--source_weights_path` (optional): The path to the YOLO model's weights file.
Defaults to `"yolov8x.pt"` if not specified.
- `--source_weights_path` (optional): The path to the YOLO model's weights file. Defaults to `"yolov8x.pt"` if not specified.
- `--zone_configuration_path`: Specifies the path to the JSON file containing zone
configurations. This file defines the polygonal areas in the video where objects will
be counted.
- `--zone_configuration_path`: Specifies the path to the JSON file containing zone configurations. This file defines the polygonal areas in the video where objects will be counted.
- `--source_video_path`: The path to the source video file that will be analyzed.
- `--target_video_path` (optional): The path to save the output video with annotations.
If not provided, the processed video will be displayed in real-time.
- `--target_video_path` (optional): The path to save the output video with annotations. If not provided, the processed video will be displayed in real-time.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model
to filter detections. Default is `0.3`.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model to filter detections. Default is `0.3`.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model. Default is `0.7`.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model. Default is `0.7`.
- inference
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided
directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment
variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key)
to acquire your `API KEY`.
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key) to acquire your `API KEY`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default
value is `"yolov8x-1280"`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default value is `"yolov8x-1280"`.
- `--zone_configuration_path`: Specifies the path to the JSON file containing zone
configurations. This file defines the polygonal areas in the video where objects will
be counted.
- `--zone_configuration_path`: Specifies the path to the JSON file containing zone configurations. This file defines the polygonal areas in the video where objects will be counted.
- `--source_video_path`: The path to the source video file that will be analyzed.
- `--target_video_path` (optional): The path to save the output video with annotations.
If not provided, the processed video will be displayed in real-time.
- `--target_video_path` (optional): The path to save the output video with annotations. If not provided, the processed video will be displayed in real-time.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model
to filter detections. Default is `0.3`.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model to filter detections. Default is `0.3`.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model. Default is `0.7`.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model. Default is `0.7`.
## 📌 zone configuration
@@ -121,12 +102,6 @@ https://github.com/roboflow/supervision/assets/26109316/f84db7b5-79e2-4142-a1da-
This demo integrates two main components, each with its own licensing:
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed
under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE).
You can find more details about this license here.
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You can find more details about this license here.
- supervision: The analytics code that powers the zone-based analysis in this demo is
based on the Supervision library, which is licensed under the
[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
makes the Supervision part of the code fully open source and freely usable in your
projects.
- supervision: The analytics code that powers the zone-based analysis in this demo is based on the Supervision library, which is licensed under the [MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This makes the Supervision part of the code fully open source and freely usable in your projects.
+9 -24
View File
@@ -2,9 +2,7 @@
## 👋 hello
This script performs heatmap and tracking analysis using YOLOv8, an object-detection method and
ByteTrack, a simple yet effective online multi-object tracking method. It uses the
supervision package for multiple tasks such as drawing heatmap annotations, tracking objects, etc.
This script performs heatmap and tracking analysis using YOLOv8, an object-detection method and ByteTrack, a simple yet effective online multi-object tracking method. It uses the supervision package for multiple tasks such as drawing heatmap annotations, tracking objects, etc.
## 💻 install
@@ -30,23 +28,16 @@ supervision package for multiple tasks such as drawing heatmap annotations, trac
## 🛠️ script arguments
- `--source_weights_path`: Required. Specifies the path to the weights file for the
YOLO model. This file contains the trained model data necessary for object detection.
- `--source_video_path` (optional): The path to the source video file that will be
analyzed. This is the input video on which crowd analysis will be performed.
If not specified default is `people-walking.mp4` from supervision assets
- `--source_weights_path`: Required. Specifies the path to the weights file for the YOLO model. This file contains the trained model data necessary for object detection.
- `--source_video_path` (optional): The path to the source video file that will be analyzed. This is the input video on which crowd analysis will be performed. If not specified default is `people-walking.mp4` from supervision assets
- `--target_video_path` (optional): The path to save the output.mp4 video with annotations.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model
to filter detections. Default is `0.3`. This determines how confident the model should
be to recognize an object in the video.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model. Default is 0.7. This value is used to manage object detection accuracy,
particularly in distinguishing between different objects.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model to filter detections. Default is `0.3`. This determines how confident the model should be to recognize an object in the video.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model. Default is 0.7. This value is used to manage object detection accuracy, particularly in distinguishing between different objects.
- `--heatmap_alpha` (optional): Opacity of the overlay mask, between 0 and 1.
- `--radius` (optional): Radius of the heat circle.
- `--track_threshold` (optional): Detection confidence threshold for track activation.
- `--track_activation_threshold` (optional): Detection confidence threshold for track activation.
- `--track_seconds` (optional): Number of seconds to buffer when a track is lost.
- `--match_threshold` (optional): Threshold for matching tracks with detections.
- `--minimum_matching_threshold` (optional): Threshold for matching tracks with detections.
## ⚙️ run
@@ -63,12 +54,6 @@ python script.py \
This demo integrates two main components, each with its own licensing:
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed
under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE).
You can find more details about this license here.
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You can find more details about this license here.
- supervision: The analytics code that powers the zone-based analysis in this demo is
based on the Supervision library, which is licensed under the
[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
makes the Supervision part of the code fully open source and freely usable in your
projects.
- supervision: The analytics code that powers the zone-based analysis in this demo is based on the Supervision library, which is licensed under the [MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This makes the Supervision part of the code fully open source and freely usable in your projects.
+12 -38
View File
@@ -1,21 +1,14 @@
# speed estimation
[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-estimate-vehicle-speed-with-computer-vision.ipynb)
[![YouTube](https://badges.aleen42.com/src/youtube.svg)](https://youtu.be/uWP6UjDeZvY)
[![Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/roboflow-ai/notebooks/blob/main/notebooks/how-to-estimate-vehicle-speed-with-computer-vision.ipynb) [![YouTube](https://badges.aleen42.com/src/youtube.svg)](https://youtu.be/uWP6UjDeZvY)
## 👋 hello
This example performs speed estimation analysis using various object-detection models
and ByteTrack - a simple yet effective online multi-object tracking method. It uses the
supervision package for multiple tasks such as tracking, annotations, etc.
This example performs speed estimation analysis using various object-detection models and ByteTrack - a simple yet effective online multi-object tracking method. It uses the supervision package for multiple tasks such as tracking, annotations, etc.
https://github.com/roboflow/supervision/assets/26109316/d50118c1-2ae4-458d-915a-5d860fd36f71
> [!IMPORTANT]
> Adjust the [`SOURCE`](https://github.com/roboflow/supervision/blob/e32b05a636dab2ea1f39299e529c4b22b8baa8da/examples/speed_estimation/ultralytics_example.py#L10)
> and [`TARGET`](https://github.com/roboflow/supervision/blob/e32b05a636dab2ea1f39299e529c4b22b8baa8da/examples/speed_estimation/ultralytics_example.py#L15)
> configuration if you plan to run a speed estimation script on your video file. Those must be adjusted separately for each camera view. You can learn more
> from our YouTube [tutorial](https://youtu.be/uWP6UjDeZvY).
> [!IMPORTANT] Adjust the [`SOURCE`](https://github.com/roboflow/supervision/blob/e32b05a636dab2ea1f39299e529c4b22b8baa8da/examples/speed_estimation/ultralytics_example.py#L10) and [`TARGET`](https://github.com/roboflow/supervision/blob/e32b05a636dab2ea1f39299e529c4b22b8baa8da/examples/speed_estimation/ultralytics_example.py#L15) configuration if you plan to run a speed estimation script on your video file. Those must be adjusted separately for each camera view. You can learn more from our YouTube [tutorial](https://youtu.be/uWP6UjDeZvY).
## 💻 install
@@ -47,32 +40,19 @@ https://github.com/roboflow/supervision/assets/26109316/d50118c1-2ae4-458d-915a-
## 🛠️ script arguments
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided
directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment
variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key)
to acquire your `API KEY`.
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key) to acquire your `API KEY`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default
value is `"yolov8x-1280"`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default value is `"yolov8x-1280"`.
- `--source_weights_path`: Required. Specifies the path to the YOLO model's weights
file, which is essential for the object detection process. This file contains the
data that the model uses to identify objects in the video.
- `--source_weights_path`: Required. Specifies the path to the YOLO model's weights file, which is essential for the object detection process. This file contains the data that the model uses to identify objects in the video.
- `--source_video_path`: Required. The path to the source video file that will be
analyzed. This is the input video on which traffic flow analysis will be performed.
- `--source_video_path`: Required. The path to the source video file that will be analyzed. This is the input video on which traffic flow analysis will be performed.
- `--target_video_path`: The path to save the output video with
annotations. If not specified, the processed video will be displayed in real-time
without being saved.
- `--target_video_path`: The path to save the output video with annotations. If not specified, the processed video will be displayed in real-time without being saved.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO
model to filter detections. Default is `0.3`. This determines how confident the
model should be to recognize an object in the video.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model to filter detections. Default is `0.3`. This determines how confident the model should be to recognize an object in the video.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model. Default is 0.7. This value is used to manage object detection
accuracy, particularly in distinguishing between different objects.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model. Default is 0.7. This value is used to manage object detection accuracy, particularly in distinguishing between different objects.
## ⚙️ run
@@ -111,12 +91,6 @@ https://github.com/roboflow/supervision/assets/26109316/d50118c1-2ae4-458d-915a-
This demo integrates two main components, each with its own licensing:
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed
under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE).
You can find more details about this license here.
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You can find more details about this license here.
- supervision: The analytics code that powers the zone-based analysis in this demo is
based on the Supervision library, which is licensed under the
[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
makes the Supervision part of the code fully open source and freely usable in your
projects.
- supervision: The analytics code that powers the zone-based analysis in this demo is based on the Supervision library, which is licensed under the [MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This makes the Supervision part of the code fully open source and freely usable in your projects.
+5 -19
View File
@@ -4,10 +4,7 @@
## 👋 hello
Practical demonstration on leveraging computer vision for analyzing wait times and
monitoring the duration that objects or individuals spend in predefined areas of video
frames. This example project, perfect for retail analytics or traffic management
applications.
Practical demonstration on leveraging computer vision for analyzing wait times and monitoring the duration that objects or individuals spend in predefined areas of video frames. This example project, perfect for retail analytics or traffic management applications.
https://github.com/roboflow/supervision/assets/26109316/d051cc8a-dd15-41d4-aa36-d38b86334c39
@@ -59,9 +56,7 @@ python scripts/download_from_youtube.py \
### `stream_from_file`
This script allows you to stream video files from a directory. It's an awesome way to
mock a live video stream for local testing. Video will be streamed in a loop under
`rtsp://localhost:8554/live0.stream` URL. This script requires docker to be installed.
This script allows you to stream video files from a directory. It's an awesome way to mock a live video stream for local testing. Video will be streamed in a loop under `rtsp://localhost:8554/live0.stream` URL. This script requires docker to be installed.
- `--video_directory`: Directory containing video files to stream.
- `--number_of_streams`: Number of video files to stream.
@@ -80,10 +75,7 @@ python scripts/stream_from_file.py \
### `draw_zones`
If you want to test zone time in zone analysis on your own video, you can use this
script to design custom zones and save results as a JSON file. The script will open a
window where you can draw polygons on the source image or video file. The polygons will
be saved as a JSON file.
If you want to test zone time in zone analysis on your own video, you can use this script to design custom zones and save results as a JSON file. The script will open a window where you can draw polygons on the source image or video file. The polygons will be saved as a JSON file.
- `--source_path`: Path to the source image or video file for drawing polygons.
- `--zone_configuration_path`: Path where the polygon annotations will be saved as a JSON file.
@@ -324,12 +316,6 @@ python ultralytics_stream_example.py \
This demo integrates two main components, each with its own licensing:
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed
under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE).
You can find more details about this license here.
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You can find more details about this license here.
- supervision: The analytics code that powers the zone-based analysis in this demo is
based on the Supervision library, which is licensed under the
[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
makes the Supervision part of the code fully open source and freely usable in your
projects.
- supervision: The analytics code that powers the zone-based analysis in this demo is based on the Supervision library, which is licensed under the [MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This makes the Supervision part of the code fully open source and freely usable in your projects.
+14 -39
View File
@@ -2,8 +2,7 @@
## 👋 hello
This script provides functionality for processing videos using YOLOv8 for object
detection and Supervision for tracking and annotation.
This script provides functionality for processing videos using YOLOv8 for object detection and Supervision for tracking and annotation.
## 💻 install
@@ -31,47 +30,29 @@ detection and Supervision for tracking and annotation.
- ultralytics
- `--source_weights_path`: Required. Specifies the path to the YOLO model's weights
file, which is essential for the object detection process. This file contains the data
that the model uses to identify objects in the video.
- `--source_weights_path`: Required. Specifies the path to the YOLO model's weights file, which is essential for the object detection process. This file contains the data that the model uses to identify objects in the video.
- `--source_video_path`: Required. The path to the source video file to be processed.
This is the video on which object detection and annotation will be performed.
- `--source_video_path`: Required. The path to the source video file to be processed. This is the video on which object detection and annotation will be performed.
- `--target_video_path`: Required. The path where the processed video, with annotations
added, will be saved. This is your output video file.
- `--target_video_path`: Required. The path where the processed video, with annotations added, will be saved. This is your output video file.
- `--confidence_threshold` (optional): Sets the confidence level at which the model
identifies objects in the video. Default is `0.3`. A higher threshold makes the model
more selective, while a lower threshold makes it more inclusive in identifying objects.
- `--confidence_threshold` (optional): Sets the confidence level at which the model identifies objects in the video. Default is `0.3`. A higher threshold makes the model more selective, while a lower threshold makes it more inclusive in identifying objects.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model, defaulting to `0.7`. This parameter helps in differentiating between
distinct objects, especially in crowded scenes.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model, defaulting to `0.7`. This parameter helps in differentiating between distinct objects, especially in crowded scenes.
- inference
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided
directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment
variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key)
to acquire your `API KEY`.
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key) to acquire your `API KEY`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default
value is `"yolov8x-1280"`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default value is `"yolov8x-1280"`.
- `--source_video_path`: Required. The path to the source video file to be processed.
This is the video on which object detection and annotation will be performed.
- `--source_video_path`: Required. The path to the source video file to be processed. This is the video on which object detection and annotation will be performed.
- `--target_video_path`: Required. The path where the processed video, with annotations
added, will be saved. This is your output video file.
- `--target_video_path`: Required. The path where the processed video, with annotations added, will be saved. This is your output video file.
- `--confidence_threshold` (optional): Sets the confidence level at which the model
identifies objects in the video. Default is `0.3`. A higher threshold makes the model
more selective, while a lower threshold makes it more inclusive in identifying objects.
- `--confidence_threshold` (optional): Sets the confidence level at which the model identifies objects in the video. Default is `0.3`. A higher threshold makes the model more selective, while a lower threshold makes it more inclusive in identifying objects.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model, defaulting to `0.7`. This parameter helps in differentiating between
distinct objects, especially in crowded scenes.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model, defaulting to `0.7`. This parameter helps in differentiating between distinct objects, especially in crowded scenes.
## ⚙️ run
@@ -97,12 +78,6 @@ detection and Supervision for tracking and annotation.
This demo integrates two main components, each with its own licensing:
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed
under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE).
You can find more details about this license here.
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You can find more details about this license here.
- supervision: The analytics code that powers the zone-based analysis in this demo is
based on the Supervision library, which is licensed under the
[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
makes the Supervision part of the code fully open source and freely usable in your
projects.
- supervision: The analytics code that powers the zone-based analysis in this demo is based on the Supervision library, which is licensed under the [MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This makes the Supervision part of the code fully open source and freely usable in your projects.
+14 -42
View File
@@ -2,9 +2,7 @@
## 👋 hello
This script performs traffic flow analysis using YOLOv8, an object-detection method and
ByteTrack, a simple yet effective online multi-object tracking method. It uses the
supervision package for multiple tasks such as tracking, annotations, etc.
This script performs traffic flow analysis using YOLOv8, an object-detection method and ByteTrack, a simple yet effective online multi-object tracking method. It uses the supervision package for multiple tasks such as tracking, annotations, etc.
https://github.com/roboflow/supervision/assets/26109316/c9436828-9fbf-4c25-ae8c-60e9c81b3900
@@ -40,49 +38,29 @@ https://github.com/roboflow/supervision/assets/26109316/c9436828-9fbf-4c25-ae8c-
- ultralytics
- `--source_weights_path`: Required. Specifies the path to the YOLO model's weights
file, which is essential for the object detection process. This file contains the
data that the model uses to identify objects in the video.
- `--source_weights_path`: Required. Specifies the path to the YOLO model's weights file, which is essential for the object detection process. This file contains the data that the model uses to identify objects in the video.
- `--source_video_path`: Required. The path to the source video file that will be
analyzed. This is the input video on which traffic flow analysis will be performed.
- `--source_video_path`: Required. The path to the source video file that will be analyzed. This is the input video on which traffic flow analysis will be performed.
- `--target_video_path` (optional): The path to save the output video with
annotations. If not specified, the processed video will be displayed in real-time
without being saved.
- `--target_video_path` (optional): The path to save the output video with annotations. If not specified, the processed video will be displayed in real-time without being saved.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO
model to filter detections. Default is `0.3`. This determines how confident the
model should be to recognize an object in the video.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model to filter detections. Default is `0.3`. This determines how confident the model should be to recognize an object in the video.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model. Default is 0.7. This value is used to manage object detection
accuracy, particularly in distinguishing between different objects.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model. Default is 0.7. This value is used to manage object detection accuracy, particularly in distinguishing between different objects.
- inference
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided
directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment
variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key)
to acquire your `API KEY`.
- `--roboflow_api_key` (optional): The API key for Roboflow services. If not provided directly, the script tries to fetch it from the `ROBOFLOW_API_KEY` environment variable. Follow [this guide](https://docs.roboflow.com/api-reference/authentication#retrieve-an-api-key) to acquire your `API KEY`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default
value is `"vehicle-count-in-drone-video/6"`.
- `--model_id` (optional): Designates the Roboflow model ID to be used. The default value is `"vehicle-count-in-drone-video/6"`.
- `--source_video_path`: Required. The path to the source video file that will be
analyzed. This is the input video on which traffic flow analysis will be performed.
- `--source_video_path`: Required. The path to the source video file that will be analyzed. This is the input video on which traffic flow analysis will be performed.
- `--target_video_path` (optional): The path to save the output video with
annotations. If not specified, the processed video will be displayed in real-time
without being saved.
- `--target_video_path` (optional): The path to save the output video with annotations. If not specified, the processed video will be displayed in real-time without being saved.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO
model to filter detections. Default is `0.3`. This determines how confident the
model should be to recognize an object in the video.
- `--confidence_threshold` (optional): Sets the confidence threshold for the YOLO model to filter detections. Default is `0.3`. This determines how confident the model should be to recognize an object in the video.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold
for the model. Default is 0.7. This value is used to manage object detection
accuracy, particularly in distinguishing between different objects.
- `--iou_threshold` (optional): Specifies the IOU (Intersection Over Union) threshold for the model. Default is 0.7. This value is used to manage object detection accuracy, particularly in distinguishing between different objects.
## ⚙️ run
@@ -112,12 +90,6 @@ https://github.com/roboflow/supervision/assets/26109316/c9436828-9fbf-4c25-ae8c-
This demo integrates two main components, each with its own licensing:
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed
under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE).
You can find more details about this license here.
- ultralytics: The object detection model used in this demo, YOLOv8, is distributed under the [AGPL-3.0 license](https://github.com/ultralytics/ultralytics/blob/main/LICENSE). You can find more details about this license here.
- supervision: The analytics code that powers the zone-based analysis in this demo is
based on the Supervision library, which is licensed under the
[MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This
makes the Supervision part of the code fully open source and freely usable in your
projects.
- supervision: The analytics code that powers the zone-based analysis in this demo is based on the Supervision library, which is licensed under the [MIT license](https://github.com/roboflow/supervision/blob/develop/LICENSE.md). This makes the Supervision part of the code fully open source and freely usable in your projects.
+5 -2
View File
@@ -1,10 +1,10 @@
site_name: Supervision
site_url: https://supervision.roboflow.com/
site_author: Roboflow
site_description: "Model-agnostic Python library for computer vision. Annotate, track, filter, and export detections. Works with YOLO, SAM, Transformers, and 20+ frameworks."
site_description: "Model-agnostic Python library for computer vision. Annotate, track, filter, and export detections from supported model outputs including Ultralytics, Roboflow Inference, Transformers, SAM, Detectron2, MMDetection, and VLM parsers."
repo_name: roboflow/supervision
edit_uri: https://github.com/roboflow/supervision/tree/main/docs
copyright: Roboflow 2025. All rights reserved.
copyright: Roboflow 2026. All rights reserved.
extra:
social:
@@ -30,6 +30,9 @@ extra_css:
nav:
- Home: index.md
- About: about.md
- Contact: contact.md
- FAQ: faq.md
- Learn:
- Detect and Annotate: how_to/detect_and_annotate.md
- Save Detections: how_to/save_detections.md
+8 -3
View File
@@ -4,7 +4,7 @@ requires = [ "setuptools>=61" ]
[project]
name = "supervision"
version = "0.28.0rc2"
version = "0.29.1"
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
readme = "README.md"
keywords = [
@@ -53,7 +53,7 @@ dependencies = [
"numpy>=1.21.2",
"opencv-python>=4.5.5.64",
"pillow>=9.4",
"pydeprecate>=0.7,<0.8",
"pydeprecate>=0.9,<0.10",
"pyyaml>=5.3",
"requests>=2.26",
"scipy>=1.10",
@@ -90,7 +90,7 @@ docs = [
build = [
"build>=0.10,<1.5",
"twine>=5.1.1,<7",
"wheel>=0.40,<0.47",
"wheel>=0.40,<0.48",
]
[tool.setuptools]
@@ -154,6 +154,10 @@ lint.select = [
]
lint.ignore = []
lint.per-file-ignores."__init__.py" = [ "E402", "F401" ]
lint.per-file-ignores."notebooks/**" = [
"PT018", # Assertion should be broken down into multiple parts
"S101", # Use of `assert` detected
]
lint.per-file-ignores."src/**" = [
"S101", # TODO: Replace asserts with proper error handling
]
@@ -204,6 +208,7 @@ ini_options.addopts = [
"--color=yes",
]
ini_options.doctest_optionflags = "ELLIPSIS NORMALIZE_WHITESPACE"
ini_options.norecursedirs = [ "examples", "docs", "notebooks", ".venv", ".git", "dist", "build" ]
[tool.autoflake]
check = true
-24
View File
@@ -1,24 +0,0 @@
# Release Process
This doc outlines how supervision is released into production.
It assumes you already have the code changes, as well as a draft of the release notes.
1. Make sure you have all required changes were merged into `develop`.
2. Create and merge a PR, merging `develop` into `main`, containing:
- A commit that updates the project version in `pyproject.toml`.
- All changes made during the release.
3. Tag the commit with the new supervision version.
- make sure to pull from `main` !
- Verify that the latest merge commits exists. `git log`.
- Run `git tag x.y.z`, with your version
- Check with `git log`.
- Run `git push origin --tags`
- Upon pushing the Github release, the [PyPi](https://pypi.org/project/supervision/) should update to the new version. Check this!
4. Open and merge a PR, merging `main` into `develop`.
5. Update the docs by running the [Supervision Release Documentation Workflow 📚](https://github.com/roboflow/supervision/actions/workflows/publish-release-docs.yml) workflow from GitHub.
- Select the `main` branch from the dropdown.
6. Create a release on GitHub.
- Go to releases
- Assign the release notes to the tag created in step 3.
- Publish the release.
+13
View File
@@ -62,6 +62,7 @@ from supervision.detection.utils.boxes import (
move_boxes,
pad_boxes,
scale_boxes,
xyxyxyxy_to_xyxy,
)
from supervision.detection.utils.converters import (
is_compressed_rle,
@@ -90,6 +91,8 @@ from supervision.detection.utils.iou_and_nms import (
mask_non_max_merge,
mask_non_max_suppression,
oriented_box_iou_batch,
oriented_box_non_max_merge,
oriented_box_non_max_suppression,
)
from supervision.detection.utils.masks import (
calculate_masks_centroids,
@@ -121,6 +124,10 @@ from supervision.geometry.utils import get_polygon_center
from supervision.key_points.annotators import (
EdgeAnnotator,
VertexAnnotator,
VertexEllipseAnnotator,
VertexEllipseAreaAnnotator,
VertexEllipseHaloAnnotator,
VertexEllipseOutlineAnnotator,
VertexLabelAnnotator,
)
from supervision.key_points.core import KeyPoints
@@ -204,6 +211,10 @@ __all__ = [
"TraceAnnotator",
"TriangleAnnotator",
"VertexAnnotator",
"VertexEllipseAnnotator",
"VertexEllipseAreaAnnotator",
"VertexEllipseHaloAnnotator",
"VertexEllipseOutlineAnnotator",
"VertexLabelAnnotator",
"VideoInfo",
"VideoSink",
@@ -251,6 +262,8 @@ __all__ = [
"move_boxes",
"move_masks",
"oriented_box_iou_batch",
"oriented_box_non_max_merge",
"oriented_box_non_max_suppression",
"overlay_image",
"pad_boxes",
"pillow_to_cv2",
+108 -56
View File
@@ -7,6 +7,7 @@ from typing import Any, cast, overload
import cv2
import numpy as np
import numpy.typing as npt
from deprecate import deprecated, void
from PIL import Image, ImageDraw, ImageFont
from scipy.interpolate import splev, splprep
@@ -15,6 +16,7 @@ from supervision.annotators.utils import (
PENDING_TRACK_ID,
ColorLookup,
Trace,
_validate_labels,
calculate_dynamic_kernel_size,
calculate_dynamic_pixel_size,
get_labels_text,
@@ -22,7 +24,6 @@ from supervision.annotators.utils import (
resolve_color,
resolve_text_background_xyxy,
snap_boxes,
validate_labels,
wrap_text,
)
from supervision.config import ORIENTED_BOX_COORDINATES
@@ -360,6 +361,65 @@ class OrientedBoxAnnotator(BaseAnnotator):
return scene
# --- Shared mask-painting utilities ---
def _paint_masks_by_area(
canvas: npt.NDArray[np.uint8],
detections: Detections,
color: Color | ColorPalette,
color_lookup: ColorLookup | npt.NDArray[np.int_],
collect_union: bool = False,
) -> npt.NDArray[np.bool_] | None:
"""Paint each detection's mask into `canvas` in descending-area order.
Smaller masks are drawn on top of larger ones. `CompactMask` detections
are painted into their bounding-box crop only, avoiding a full `(H, W)`
allocation per mask; dense masks fall back to full-frame boolean indexing.
Args:
canvas: BGR image array painted in place. Shape ``(H, W, 3)``.
detections: Detections whose masks to paint. Returns immediately
without modifying `canvas` when ``detections.mask`` is ``None``.
color: Single color or palette used to resolve each detection's color.
color_lookup: Strategy for mapping colors to detection indices.
collect_union: When ``True``, allocate and return a ``(H, W)``
boolean array that accumulates the union of all painted masks
(useful for callers like `HaloAnnotator` that need the combined
mask footprint). When ``False`` (default), returns ``None``.
Returns:
A ``(H, W)`` boolean union array when ``collect_union=True``,
otherwise ``None``.
"""
masks = detections.mask
if masks is None:
return None
union: npt.NDArray[np.bool_] | None = (
np.zeros(canvas.shape[:2], dtype=bool) if collect_union else None
)
compact_mask = masks if isinstance(masks, CompactMask) else None
for detection_idx in np.flip(np.argsort(detections.area)):
color_bgr = resolve_color(
color=color,
detections=detections,
detection_idx=detection_idx,
color_lookup=color_lookup,
).as_bgr()
if compact_mask is not None:
x1 = int(compact_mask.offsets[detection_idx, 0])
y1 = int(compact_mask.offsets[detection_idx, 1])
crop_m = compact_mask.crop(detection_idx)
crop_h, crop_w = crop_m.shape
canvas[y1 : y1 + crop_h, x1 : x1 + crop_w][crop_m] = color_bgr
if union is not None:
union[y1 : y1 + crop_h, x1 : x1 + crop_w] |= crop_m
else:
mask = np.asarray(masks[detection_idx], dtype=bool)
canvas[mask] = color_bgr
if union is not None:
union |= mask
return union
class MaskAnnotator(BaseAnnotator):
"""
A class for drawing masks on an image using provided detections.
@@ -436,35 +496,12 @@ class MaskAnnotator(BaseAnnotator):
return scene
colored_mask = np.array(scene, copy=True, dtype=np.uint8)
compact_mask = (
detections.mask if isinstance(detections.mask, CompactMask) else None
_paint_masks_by_area(
colored_mask,
detections,
self.color,
self.color_lookup if custom_color_lookup is None else custom_color_lookup,
)
for detection_idx in np.flip(np.argsort(detections.area)):
color = resolve_color(
color=self.color,
detections=detections,
detection_idx=detection_idx,
color_lookup=self.color_lookup
if custom_color_lookup is None
else custom_color_lookup,
)
if compact_mask is not None:
# Paint only the bounding-box crop — avoids a full (H, W) alloc.
x1 = int(compact_mask.offsets[detection_idx, 0])
y1 = int(compact_mask.offsets[detection_idx, 1])
crop_m = compact_mask.crop(detection_idx)
crop_h, crop_w = crop_m.shape
colored_mask[y1 : y1 + crop_h, x1 : x1 + crop_w][crop_m] = (
color.as_bgr()
)
else:
mask = np.asarray(
detections.mask[detection_idx],
dtype=bool,
)
colored_mask[mask] = color.as_bgr()
cv2.addWeighted(
colored_mask, self.opacity, scene, 1 - self.opacity, 0, dst=scene
)
@@ -700,7 +737,7 @@ class HaloAnnotator(BaseAnnotator):
Annotates the given scene with halos based on the provided detections.
Args:
scene: The image where masks will be drawn.
scene: The image where the halo effect will be applied.
`ImageType` is a flexible type, accepting either `numpy.ndarray`
or `PIL.Image.Image`.
detections: Object detections to annotate.
@@ -718,6 +755,7 @@ class HaloAnnotator(BaseAnnotator):
>>> image = np.zeros((100, 100, 3), dtype=np.uint8)
>>> detections = sv.Detections(
... xyxy=np.array([[20, 20, 80, 80]]),
... mask=np.zeros((1, 100, 100), dtype=bool),
... class_id=np.array([0])
... )
>>> halo_annotator = sv.HaloAnnotator()
@@ -736,28 +774,23 @@ class HaloAnnotator(BaseAnnotator):
if detections.mask is None:
return scene
colored_mask = np.zeros_like(scene, dtype=np.uint8)
fmask = np.array([False] * scene.shape[0] * scene.shape[1]).reshape(
scene.shape[0], scene.shape[1]
fmask = _paint_masks_by_area(
colored_mask,
detections,
self.color,
self.color_lookup if custom_color_lookup is None else custom_color_lookup,
collect_union=True,
)
for detection_idx in np.flip(np.argsort(detections.area)):
color = resolve_color(
color=self.color,
detections=detections,
detection_idx=detection_idx,
color_lookup=self.color_lookup
if custom_color_lookup is None
else custom_color_lookup,
)
mask = np.asarray(detections.mask[detection_idx], dtype=bool)
fmask = np.logical_or(fmask, mask)
color_bgr = color.as_bgr()
colored_mask[mask] = color_bgr
assert fmask is not None # collect_union=True always returns an array
colored_mask = cv2.blur(colored_mask, (self.kernel_size, self.kernel_size))
colored_mask[fmask] = [0, 0, 0]
gray = cv2.cvtColor(colored_mask, cv2.COLOR_BGR2GRAY)
alpha = self.opacity * gray / gray.max()
gray_max = gray.max()
if gray_max == 0:
# no halo to draw (e.g. empty masks); leave the scene untouched
return scene
alpha = self.opacity * gray / gray_max
alpha_mask = alpha[:, :, np.newaxis]
blended_scene = np.uint8(scene * (1 - alpha_mask) + colored_mask * self.opacity)
np.copyto(scene, blended_scene)
@@ -1269,7 +1302,7 @@ class LabelAnnotator(_BaseLabelAnnotator):
"""
if not isinstance(scene, np.ndarray):
return scene
validate_labels(labels, detections)
_validate_labels(labels, detections)
labels = get_labels_text(detections, labels)
label_properties: npt.NDArray[np.float32] = self._get_label_properties(
@@ -1584,7 +1617,7 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
```
"""
assert isinstance(scene, Image.Image)
validate_labels(labels, detections)
_validate_labels(labels, detections)
draw = ImageDraw.Draw(scene)
labels = get_labels_text(detections, labels)
@@ -2095,7 +2128,7 @@ class HeatMapAnnotator(BaseAnnotator):
position: Position = Position.BOTTOM_CENTER,
opacity: float = 0.2,
radius: int = 40,
kernel_size: int = 25,
kernel_size: int | None = 25,
top_hue: int = 0,
low_hue: int = 125,
):
@@ -2103,9 +2136,10 @@ class HeatMapAnnotator(BaseAnnotator):
Args:
position: The position of the heatmap. Defaults to
`BOTTOM_CENTER`.
opacity: Opacity of the overlay mask, between 0 and 1.
opacity: Opacity of the overlay mask. Must be between `0` and `1`.
radius: Radius of the heat circle.
kernel_size: Kernel size for blurring the heatmap.
kernel_size: Kernel size for blurring the heatmap. Pass `None`
to disable blurring entirely.
top_hue: Hue at the top of the heatmap. Defaults to 0 (red).
low_hue: Hue at the bottom of the heatmap. Defaults to 125 (blue).
"""
@@ -2132,6 +2166,10 @@ class HeatMapAnnotator(BaseAnnotator):
The annotated image, matching the type of `scene` (`numpy.ndarray`
or `PIL.Image.Image`)
Note:
When `detections` is empty or no heat has accumulated yet, the
scene is returned unchanged without raising a ``RuntimeWarning``.
Example:
```python
import supervision as sv
@@ -2174,7 +2212,9 @@ class HeatMapAnnotator(BaseAnnotator):
)
self.heat_mask = mask + self.heat_mask
temp = self.heat_mask.copy()
temp = self.low_hue - temp / temp.max() * (self.low_hue - self.top_hue)
max_val = temp.max()
if max_val > 0:
temp = self.low_hue - temp / max_val * (self.low_hue - self.top_hue)
temp = temp.astype(np.uint8)
if self.kernel_size is not None:
temp = cv2.blur(temp, (self.kernel_size, self.kernel_size))
@@ -2633,7 +2673,7 @@ class PercentageBarAnnotator(BaseAnnotator):
"""
if not isinstance(scene, np.ndarray):
return scene
self.validate_custom_values(custom_values=custom_values, detections=detections)
self._validate_custom_values(custom_values=custom_values, detections=detections)
anchors = detections.get_anchors_coordinates(anchor=self.position)
for detection_idx in range(len(detections)):
@@ -2708,7 +2748,7 @@ class PercentageBarAnnotator(BaseAnnotator):
return (cx, cy), (cx + width, cy + height)
@staticmethod
def validate_custom_values(
def _validate_custom_values(
custom_values: npt.NDArray[np.float64] | list[float] | None,
detections: Detections,
) -> None:
@@ -2734,6 +2774,18 @@ class PercentageBarAnnotator(BaseAnnotator):
if not all(0 <= value <= 1 for value in custom_values):
raise ValueError("All values in custom_values must be between 0 and 1.")
@staticmethod
@deprecated( # type: ignore[untyped-decorator]
target=_validate_custom_values.__func__, # type: ignore[attr-defined]
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_custom_values(
custom_values: npt.NDArray[np.float64] | list[float] | None,
detections: Detections,
) -> None:
void(custom_values, detections)
class CropAnnotator(BaseAnnotator):
"""
@@ -3007,7 +3059,7 @@ class ComparisonAnnotator:
color_2: Color of areas only present in the second set of
detections.
color_overlap: Color of areas present in both sets of detections.
opacity: Annotator opacity, from `0` to `1`.
opacity: Opacity of the overlay mask. Must be between `0` and `1`.
label_1: Label for the first set of detections.
label_2: Label for the second set of detections.
label_overlap: Label for areas present in both sets of detections.
+11 -1
View File
@@ -7,6 +7,7 @@ from typing import Any
import numpy as np
import numpy.typing as npt
from deprecate import deprecated, void
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
@@ -201,7 +202,7 @@ def wrap_text(text: Any, max_line_length: int | None = None) -> list[str]:
return all_lines or [""]
def validate_labels(labels: list[str] | None, detections: Detections) -> None:
def _validate_labels(labels: list[str] | None, detections: Detections) -> None:
"""
Validates that the number of provided labels matches the number of detections.
@@ -222,6 +223,15 @@ def validate_labels(labels: list[str] | None, detections: Detections) -> None:
)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_labels,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_labels(labels: list[str] | None, detections: Detections) -> None:
void(labels, detections)
def get_labels_text(
detections: Detections, custom_labels: list[str] | None
) -> list[str]:
+11
View File
@@ -1,2 +1,13 @@
CLASS_NAME_DATA_FIELD: str = "class_name"
COCO_RAW_SEGMENTATION: str = "coco_raw_segmentation"
#: Key for oriented bounding-box corner coordinates in ``Detections.data``.
#:
#: Value layout: ``np.ndarray`` of shape ``(N, 4, 2)``, dtype ``float32``, pixel
#: coordinates ordered as ``[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]`` per
#: detection where the four points are the corners of the oriented box.
#: Used by :func:`~supervision.dataset.formats.yolo.detections_to_yolo_annotations`
#: (``is_obb=True``) and
#: :func:`~supervision.dataset.formats.yolo.yolo_annotations_to_detections`
#: (``is_obb=True``).
#: Also triggers sequential mode in ``InferenceSlicer`` when present.
ORIENTED_BOX_COORDINATES: str = "xyxyxyxy"
+65 -2
View File
@@ -509,6 +509,7 @@ class DetectionDataset(BaseDataset):
min_image_area_percentage: float = 0.0,
max_image_area_percentage: float = 1.0,
approximation_percentage: float = 0.0,
is_obb: bool = False,
) -> None:
"""
Exports the dataset to YOLO format. This method saves the
@@ -537,7 +538,26 @@ class DetectionDataset(BaseDataset):
be removed from the input polygon, in the range [0, 1).
This is useful for simplifying the annotations.
Argument is used only for segmentation datasets.
is_obb: If True, exports annotations in OBB format
(`class_id x1 y1 x2 y2 x3 y3 x4 y4`) using the oriented
corners stored in `detections.data["xyxyxyxy"]`. Mirrors
`from_yolo(..., is_obb=True)`. Masks are ignored when
`is_obb=True`.
"""
if is_obb and (
min_image_area_percentage != 0.0
or max_image_area_percentage != 1.0
or approximation_percentage != 0.0
):
import warnings
warnings.warn(
"`min_image_area_percentage`, `max_image_area_percentage`, and "
"`approximation_percentage` have no effect when `is_obb=True`; "
"OBB annotations use corner coordinates directly.",
UserWarning,
stacklevel=2,
)
if images_directory_path is not None:
save_dataset_images(
dataset=self, images_directory_path=images_directory_path
@@ -549,6 +569,7 @@ class DetectionDataset(BaseDataset):
min_image_area_percentage=min_image_area_percentage,
max_image_area_percentage=max_image_area_percentage,
approximation_percentage=approximation_percentage,
is_obb=is_obb,
)
if data_yaml_path is not None:
save_data_yaml(data_yaml_path=data_yaml_path, classes=self.classes)
@@ -609,7 +630,9 @@ class DetectionDataset(BaseDataset):
min_image_area_percentage: float = 0.0,
max_image_area_percentage: float = 1.0,
approximation_percentage: float = 0.0,
) -> None:
starting_image_id: int = 1,
starting_annotation_id: int = 1,
) -> tuple[int, int]:
"""
Exports the dataset to COCO format. This method saves the
images and their corresponding annotations in COCO format.
@@ -645,19 +668,59 @@ class DetectionDataset(BaseDataset):
to be removed from the input polygon,
in the range [0, 1). This is useful for simplifying the annotations.
Argument is used only for segmentation datasets.
starting_image_id: First image id to assign in the exported file.
Defaults to ``1``. Override when exporting multiple splits into
a coordinated COCO collection so ids remain unique across the
set (see example below).
starting_annotation_id: First annotation id to assign in the
exported file. Defaults to ``1``. Override for the same
multi-split reason as ``starting_image_id``.
Returns:
A ``(next_image_id, next_annotation_id)`` tuple containing the
first unused ids after this export. Feed them straight back into
``starting_image_id`` and ``starting_annotation_id`` on the next
split so ids stay globally unique. When ``annotations_path`` is
``None`` (images-only export) the starting ids are returned
unchanged so chaining still composes.
Example:
```python
# Exporting train, valid, and test splits with non-colliding ids
# so the three annotation files can later be merged into one COCO.
next_image_id, next_annotation_id = train_ds.as_coco(
images_directory_path="out/train/images",
annotations_path="out/train/annotations.json",
)
next_image_id, next_annotation_id = valid_ds.as_coco(
images_directory_path="out/valid/images",
annotations_path="out/valid/annotations.json",
starting_image_id=next_image_id,
starting_annotation_id=next_annotation_id,
)
_, _ = test_ds.as_coco(
images_directory_path="out/test/images",
annotations_path="out/test/annotations.json",
starting_image_id=next_image_id,
starting_annotation_id=next_annotation_id,
) # return value not needed — no further split
```
"""
if images_directory_path is not None:
save_dataset_images(
dataset=self, images_directory_path=images_directory_path
)
if annotations_path is not None:
save_coco_annotations(
return save_coco_annotations(
dataset=self,
annotation_path=annotations_path,
min_image_area_percentage=min_image_area_percentage,
max_image_area_percentage=max_image_area_percentage,
approximation_percentage=approximation_percentage,
starting_image_id=starting_image_id,
starting_annotation_id=starting_annotation_id,
)
return starting_image_id, starting_annotation_id
@dataclass
+211 -13
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Union, cast
import numpy as np
import numpy.typing as npt
from supervision.config import COCO_RAW_SEGMENTATION
from supervision.dataset.utils import (
approximate_mask_with_polygons,
map_detections_class_id,
@@ -45,13 +46,38 @@ def build_coco_class_index_mapping(
def classes_to_coco_categories(classes: list[str]) -> list[CocoDict]:
"""Convert a list of class names to COCO ``categories`` entries.
Category ids are emitted 1-indexed to comply with the COCO specification
and tools such as CVAT, which require ``category_id`` values to start at
``1``. The id assigned to the class at position ``class_index`` is
``class_index + 1``, keeping it consistent with the ``category_id`` written
by [`detections_to_coco_annotations`](#detections_to_coco_annotations).
Args:
classes: Class names ordered by their internal (0-indexed) class id.
Returns:
A list of COCO category dictionaries with 1-indexed ``id`` values.
Examples:
```python
from supervision.dataset.formats.coco import classes_to_coco_categories
classes_to_coco_categories(classes=["cat", "dog"])
# [
# {"id": 1, "name": "cat", "supercategory": "common-objects"},
# {"id": 2, "name": "dog", "supercategory": "common-objects"},
# ]
```
"""
return [
{
"id": class_id,
"id": class_index + 1,
"name": class_name,
"supercategory": "common-objects",
}
for class_id, class_name in enumerate(classes)
for class_index, class_name in enumerate(classes)
]
@@ -122,17 +148,43 @@ def coco_annotations_to_detections(
with_masks: bool,
use_iscrowd: bool = True,
) -> Detections:
"""Convert COCO annotation dicts for a single image into a `Detections` object.
.. warning::
The returned ``Detections.class_id`` contains **raw COCO** ``category_id``
values, not the final 0-indexed internal class ids. Callers **must** pass
the result through :func:`map_detections_class_id` with the appropriate
``source_to_target_mapping`` (built by
:func:`build_coco_class_index_mapping`) before the ``class_id`` values are
meaningful. Skipping the remap step yields 1-based ids in a field that the
rest of supervision treats as 0-based.
Args:
image_annotations: List of COCO annotation dicts for one image.
resolution_wh: ``(width, height)`` of the image, used for mask decoding.
with_masks: Whether to decode segmentation fields into binary masks.
use_iscrowd: When ``True``, store ``iscrowd`` and ``area`` in
``Detections.data``.
Returns:
Detections with ``class_id`` set to raw COCO ``category_id`` values.
Call :func:`map_detections_class_id` on the result before use.
When ``with_masks=False``, ``detections.data[COCO_RAW_SEGMENTATION]`` is
populated as an object array (shape ``(N,)``) holding the raw polygon list or
RLE dict per annotation; consumed by :func:`detections_to_coco_annotations`
for a coordinate-preserving round-trip.
"""
if not image_annotations:
return Detections.empty()
class_ids = [
image_annotation["category_id"] for image_annotation in image_annotations
]
xyxy = [image_annotation["bbox"] for image_annotation in image_annotations]
xyxy = np.asarray(xyxy, dtype=np.float32)
xyxy_list = [image_annotation["bbox"] for image_annotation in image_annotations]
xyxy: npt.NDArray[np.float32] = np.asarray(xyxy_list, dtype=np.float32)
xyxy[:, 2:4] += xyxy[:, 0:2]
data: dict[str, npt.NDArray[np.generic]] = {}
data: dict[str, Union[npt.NDArray[np.generic], list[Any]]] = {}
if use_iscrowd:
iscrowd = [
image_annotation["iscrowd"] for image_annotation in image_annotations
@@ -148,6 +200,14 @@ def coco_annotations_to_detections(
)
else:
mask = None
# Preserve raw polygon/RLE data so as_coco() can round-trip without
# binary-mask encoding. Stored as an object array (one entry per detection).
raw_segs: npt.NDArray[np.object_] = np.empty(
len(image_annotations), dtype=object
)
for k, _ann in enumerate(image_annotations):
raw_segs[k] = _ann.get("segmentation", [])
data[COCO_RAW_SEGMENTATION] = raw_segs
return Detections(
class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask, data=data
@@ -162,6 +222,62 @@ def detections_to_coco_annotations(
max_image_area_percentage: float = 1.0,
approximation_percentage: float = 0.75,
) -> tuple[list[CocoDict], int]:
"""Convert `Detections` to COCO ``annotations`` entries.
The internal 0-indexed ``Detections.class_id`` is serialized as a 1-indexed
COCO ``category_id`` (``category_id = class_id + 1``). This complies with the
COCO specification and tools such as CVAT, and stays consistent with the ids
emitted by [`classes_to_coco_categories`](#classes_to_coco_categories), so a
detection with internal ``class_id=k`` maps to ``category_id=k + 1``.
Args:
detections: The detections to convert. ``class_id`` must not be ``None``.
image_id: COCO ``image_id`` shared by every produced annotation.
annotation_id: First annotation id to assign; incremented per detection.
min_image_area_percentage: Lower bound on detection area / image area,
used only when approximating masks with polygons.
max_image_area_percentage: Upper bound on detection area / image area,
used only when approximating masks with polygons.
approximation_percentage: Polygon-simplification ratio in ``[0, 1)``.
Returns:
A ``(coco_annotations, next_annotation_id)`` tuple, where
``next_annotation_id`` is one greater than the last id assigned.
Raises:
ValueError: If any detection has ``class_id`` equal to ``None``.
Note:
For ``iscrowd=0`` annotations, ``segmentation`` is a
``list[list[float]]`` where each inner list encodes one polygon
part as flat ``[x1, y1, x2, y2, ...]`` coordinates. A single
object with *N* disjoint parts produces *N* inner lists.
When ``iscrowd`` is not in ``detections.data``, masks with holes
or multiple disjoint segments are auto-encoded as RLE
(``iscrowd=1``); simple single-region masks use polygon format
(``iscrowd=0``). Supply ``data={"iscrowd": np.array([0])}`` to
force polygon output regardless of mask topology.
Examples:
```python
import numpy as np
from supervision import Detections
from supervision.dataset.formats.coco import (
detections_to_coco_annotations,
)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0], dtype=int),
)
annotations, next_id = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
annotations[0]["category_id"]
# 1
```
"""
coco_annotations: list[CocoDict] = []
for xyxy, mask, _, class_id, _, data in detections:
if class_id is None:
@@ -169,21 +285,25 @@ def detections_to_coco_annotations(
box_width, box_height = xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]
segmentation: Union[list[list[float]], dict[str, list[int]]] = []
if mask is not None:
mask_bool = cast(npt.NDArray[np.bool_], mask)
if "iscrowd" in data:
iscrowd = int(np.asarray(data["iscrowd"]).item())
else:
iscrowd = int(
contains_holes(mask=mask) or contains_multiple_segments(mask=mask)
contains_holes(mask=mask_bool)
or contains_multiple_segments(mask=mask_bool)
)
if iscrowd:
segmentation = {
"counts": cast(list[int], mask_to_rle(mask=mask, compressed=False)),
"counts": cast(
list[int], mask_to_rle(mask=mask_bool, compressed=False)
),
"size": list(mask.shape[:2]),
}
else:
polygons = approximate_mask_with_polygons(
mask=mask,
mask=mask_bool,
min_image_area_percentage=min_image_area_percentage,
max_image_area_percentage=max_image_area_percentage,
approximation_percentage=approximation_percentage,
@@ -191,7 +311,8 @@ def detections_to_coco_annotations(
# Small/noisy masks can be filtered out by approximation settings.
# Guard against empty output and keep a valid COCO annotation record.
if polygons:
segmentation = [list(polygons[0].flatten())]
# Export ALL polygons so disjoint mask components are preserved.
segmentation = [list(p.flatten()) for p in polygons]
else:
warnings.warn(
"Skipping COCO polygon segmentation for annotation "
@@ -201,12 +322,28 @@ def detections_to_coco_annotations(
)
else:
iscrowd = int(np.asarray(data.get("iscrowd", 0)).item())
# When masks were not decoded during loading, fall back to the raw
# polygon/RLE stored in data["segmentation"] for a lossless round-trip.
raw_seg = data.get(COCO_RAW_SEGMENTATION)
if raw_seg is not None and bool(raw_seg):
if isinstance(raw_seg, dict):
# RLE format — pass through unchanged
segmentation = raw_seg
elif (
isinstance(raw_seg, list)
and raw_seg
and not isinstance(raw_seg[0], (list, tuple))
):
# Flat list shorthand [x1,y1,...] — wrap to list-of-lists
segmentation = [list(raw_seg)]
else:
segmentation = list(raw_seg)
area: float = float(np.asarray(data.get("area", box_width * box_height)).item())
coco_annotation = {
"id": annotation_id,
"image_id": image_id,
"category_id": int(class_id),
"category_id": int(class_id) + 1,
"bbox": [xyxy[0], xyxy[1], box_width, box_height],
"area": area,
"segmentation": segmentation,
@@ -372,8 +509,68 @@ def save_coco_annotations(
annotation_path: str,
min_image_area_percentage: float = 0.0,
max_image_area_percentage: float = 1.0,
approximation_percentage: float = 0.75,
) -> None:
approximation_percentage: float = 0.0,
starting_image_id: int = 1,
starting_annotation_id: int = 1,
) -> tuple[int, int]:
"""Save a DetectionDataset to a COCO-format ``annotations.json`` file.
Args:
dataset: The DetectionDataset to write.
annotation_path: Output path for the COCO ``annotations.json``.
min_image_area_percentage: Lower bound on detection area / image area;
used only for segmentation datasets.
max_image_area_percentage: Upper bound on detection area / image area;
used only for segmentation datasets.
approximation_percentage: Polygon-simplification ratio in ``[0, 1)``;
used only for segmentation datasets.
starting_image_id: First image id to assign in the exported file.
Defaults to ``1``. Override when exporting multiple splits into
a coordinated COCO collection so ids remain unique across the set.
starting_annotation_id: First annotation id to assign in the exported
file. Defaults to ``1``. Override for the same multi-split reason
as ``starting_image_id``.
Returns:
A ``(next_image_id, next_annotation_id)`` tuple. The returned values
are one greater than the highest ids written, so they can be fed
directly back into ``starting_image_id`` and ``starting_annotation_id``
when exporting another split into a coordinated COCO collection
(see ``DetectionDataset.as_coco`` for the chaining pattern). When the
dataset is empty the starting ids are returned unchanged.
.. note::
This function ensures globally unique integer ``id`` values across
splits. It does **not** ensure unique ``file_name`` values — the
``file_name`` field is set to the bare image basename, so splits
that share filenames (e.g. ``000001.jpg`` in both train and valid)
will have duplicate ``file_name`` values when their COCO files are
merged. Use distinct output directories or rename images before
merging if downstream tools require unique ``file_name`` keys.
Example:
```python
import supervision as sv
from supervision.dataset.formats.coco import save_coco_annotations
ds = sv.DetectionDataset.from_yolo(
images_directory_path="train/images",
annotations_directory_path="train/labels",
data_yaml_path="data.yaml",
)
next_img_id, next_ann_id = save_coco_annotations(
dataset=ds, annotation_path="out/train/annotations.json"
)
# next_img_id and next_ann_id are the first unused ids — pass them
# to the next split to keep ids globally unique across files.
```
"""
if starting_image_id < 1 or starting_annotation_id < 1:
raise ValueError(
"starting_image_id and starting_annotation_id must be >= 1 "
"(COCO spec requires 1-indexed ids); "
f"got {starting_image_id=}, {starting_annotation_id=}"
)
Path(annotation_path).parent.mkdir(parents=True, exist_ok=True)
licenses = [
{
@@ -387,7 +584,7 @@ def save_coco_annotations(
coco_images = []
coco_categories = classes_to_coco_categories(classes=dataset.classes)
image_id, annotation_id = 1, 1
image_id, annotation_id = starting_image_id, starting_annotation_id
for image_path, image, annotation in dataset:
image_height, image_width, _ = image.shape
image_name = f"{Path(image_path).stem}{Path(image_path).suffix}"
@@ -421,3 +618,4 @@ def save_coco_annotations(
"annotations": coco_annotations,
}
save_json_file(annotation_dict, file_path=annotation_path)
return image_id, annotation_id
+37 -4
View File
@@ -21,13 +21,41 @@ def object_to_pascal_voc(
name: str,
polygon: npt.NDArray[np.number] | None = None,
) -> Element:
"""Build a Pascal VOC ``<object>`` XML element for one detection.
Coordinates are converted to 1-indexed Pascal VOC convention before writing.
The input arrays are never mutated; new arrays are allocated for the offset.
Args:
xyxy: Bounding box in zero-indexed pixel coordinates ``[x1, y1, x2, y2]``.
Shape ``(4,)``.
name: Class label string written to the ``<name>`` child element.
polygon: Optional segmentation polygon in zero-indexed pixel coordinates.
Shape ``(N, 2)``.
Returns:
An XML ``Element`` rooted at ``<object>`` containing ``<name>``,
``<bndbox>``, and optionally ``<polygon>`` children.
Examples:
>>> import numpy as np
>>> from supervision.dataset.formats.pascal_voc import object_to_pascal_voc
>>> elem = object_to_pascal_voc(np.array([0, 0, 9, 9]), name="cat")
>>> elem.find("bndbox/xmin").text
'1'
>>> elem.find("bndbox/xmax").text
'10'
"""
root = Element("object")
object_name = SubElement(root, "name")
object_name.text = name
# https://github.com/roboflow/supervision/issues/144
xyxy += 1
# Pascal VOC coordinates are 1-indexed (https://github.com/roboflow/supervision/issues/144).
# Rebind to a new array instead of `+= 1`: `xyxy` is a view into the source
# `Detections.xyxy` (yielded by `Detections.__iter__`), so an in-place add
# would corrupt the caller's detections by +1 on every export.
xyxy = xyxy + 1
bndbox = SubElement(root, "bndbox")
xmin = SubElement(bndbox, "xmin")
@@ -40,8 +68,8 @@ def object_to_pascal_voc(
ymax.text = str(int(xyxy[3]))
if polygon is not None:
# https://github.com/roboflow/supervision/issues/144
polygon += 1
# 1-indexed, rebound to avoid mutating the caller's array (see above).
polygon = polygon + 1
object_polygon = SubElement(root, "polygon")
for index, point in enumerate(polygon, start=1):
x_coordinate, y_coordinate = point
@@ -81,6 +109,11 @@ def detections_to_pascal_voc(
polygon points to be removed from the input polygon, in the range [0, 1).
Returns:
An XML string in Pascal VOC format representing the detections.
Note:
``detections`` is never mutated by this function; the source ``xyxy``
array is unchanged after the call. The function is therefore safe to
call multiple times on the same ``Detections`` object.
"""
height, width, depth = image_shape
+160 -3
View File
@@ -68,6 +68,25 @@ def _with_seg_mask(lines: list[str]) -> bool:
def _extract_class_names(file_path: str) -> list[str]:
"""Return class names from a YOLO data.yaml file ordered by class index.
Supports list and dict forms of the ``names`` field. Dict keys that are
all int-like (plain ints or digit strings) are sorted numerically so
class index 10 follows index 9. All-non-numeric keys are sorted
lexicographically. Mixed numeric/non-numeric keys raise ``ValueError``.
Boolean YAML keys (``true``/``false``) are excluded from numeric sorting
because ``bool`` is a subclass of ``int`` in Python.
Args:
file_path: Path to the data.yaml file.
Returns:
Class names in class-index order.
Raises:
ValueError: If the YAML root is not a mapping, if ``names`` is
neither a list nor a dict, or if the dict has mixed key types.
"""
data: dict[str, Any] = read_yaml_file(file_path=file_path)
if not isinstance(data, dict):
raise ValueError(
@@ -76,7 +95,33 @@ def _extract_class_names(file_path: str) -> list[str]:
)
names = data.get("names")
if isinstance(names, dict):
return [str(names[key]) for key in sorted(names.keys())]
keys = list(names.keys())
def _is_int_like(key: Any) -> bool:
# bool subclasses int; YAML `true`/`false` must not become class indices
if isinstance(key, bool):
return False
if isinstance(key, int):
return True
if isinstance(key, str):
stripped = key.strip()
return stripped.isdigit()
return False
int_like = [_is_int_like(k) for k in keys]
if any(int_like) and not all(int_like):
mixed_numeric = [k for k, il in zip(keys, int_like) if il][:3]
mixed_other = [k for k, il in zip(keys, int_like) if not il][:3]
raise ValueError(
f"Expected 'names' dict in data.yaml at '{file_path}' to have either "
f"all numeric or all non-numeric keys, got a mix: "
f"numeric {mixed_numeric} and non-numeric {mixed_other} keys."
)
if all(int_like):
sorted_keys = sorted(keys, key=lambda k: int(k))
else:
sorted_keys = sorted(keys, key=str)
return [str(names[key]) for key in sorted_keys]
if isinstance(names, list):
return [str(name) for name in names]
raise ValueError(
@@ -254,9 +299,75 @@ def detections_to_yolo_annotations(
min_image_area_percentage: float = 0.0,
max_image_area_percentage: float = 1.0,
approximation_percentage: float = 0.75,
is_obb: bool = False,
) -> list[str]:
annotation = []
for xyxy, mask, _, class_id, _, _ in detections:
"""Convert detections to YOLO annotation lines.
Args:
detections: The detections to serialize. Each detection must have a
valid integer ``class_id``. When ``is_obb=True``, each non-empty
detection must also carry ``detections.data['xyxyxyxy']`` with
shape ``(N, 4, 2)``.
image_shape: The ``(height, width, channels)`` shape of the source
image, used to normalize coordinates to ``[0, 1]``.
min_image_area_percentage: Minimum detection area as a fraction of the
image area; smaller detections are omitted. Ignored when
``is_obb=True``.
max_image_area_percentage: Maximum detection area as a fraction of the
image area; larger detections are omitted. Ignored when
``is_obb=True``.
approximation_percentage: Fraction of polygon points removed during
contour approximation when saving mask annotations. Ignored when
``is_obb=True``.
is_obb: If ``True``, serializes oriented bounding-box corners from
``detections.data['xyxyxyxy']`` as a 9-token YOLO OBB line
``class_id x1 y1 x2 y2 x3 y3 x4 y4``. Mask data is ignored.
Returns:
A list of YOLO annotation strings, one per detection (or one per
polygon for instance-segmentation annotations).
Raises:
ValueError: If any detection has ``class_id=None`` or a non-integer
``class_id``.
ValueError: If ``is_obb=True`` and any non-empty detection is missing
``'xyxyxyxy'`` in ``detections.data``.
Examples:
>>> import numpy as np
>>> from supervision.detection.core import Detections
>>> from supervision.dataset.formats.yolo import detections_to_yolo_annotations
>>> detections = Detections(
... xyxy=np.array([[10, 10, 90, 90]], dtype=np.float32),
... class_id=np.array([0]),
... )
>>> detections_to_yolo_annotations(detections, image_shape=(100, 100, 3))
['0 0.50000 0.50000 0.80000 0.80000']
"""
if (
is_obb
and len(detections) > 0
and ORIENTED_BOX_COORDINATES not in detections.data
):
raise ValueError(
f"`is_obb=True` requires `'{ORIENTED_BOX_COORDINATES}'` in "
"`detections.data` with shape (N, 4, 2). Load OBB datasets via "
"`DetectionDataset.from_yolo(..., is_obb=True)` or set "
f"`detections.data['{ORIENTED_BOX_COORDINATES}']` "
"(shape (N, 4, 2)) before exporting."
)
if is_obb and detections.mask is not None:
warnings.warn(
"`detections.mask` is ignored when `is_obb=True`; "
"OBB annotations use corner coordinates from "
f"`detections.data['{ORIENTED_BOX_COORDINATES}']`.",
UserWarning,
stacklevel=2,
)
annotation: list[str] = []
for xyxy, mask, _, class_id, _, data in detections:
if class_id is None:
raise ValueError("Class ID is required for YOLO annotations.")
if not isinstance(class_id, (int, np.integer)):
@@ -266,6 +377,24 @@ def detections_to_yolo_annotations(
)
class_id_int = int(class_id)
if is_obb:
corners = np.asarray(data[ORIENTED_BOX_COORDINATES], dtype=np.float32)
if corners.shape != (4, 2):
raise ValueError(
f"OBB data for each detection must have shape (4, 2), "
f"got {corners.shape}. Ensure "
f"`detections.data['{ORIENTED_BOX_COORDINATES}']` has "
"shape (N, 4, 2) before exporting."
)
next_object = object_to_yolo(
xyxy=xyxy,
class_id=class_id_int,
image_shape=image_shape,
polygon=corners,
)
annotation.append(next_object)
continue
if mask is not None:
polygons = approximate_mask_with_polygons(
mask=mask,
@@ -296,7 +425,34 @@ def save_yolo_annotations(
min_image_area_percentage: float = 0.0,
max_image_area_percentage: float = 1.0,
approximation_percentage: float = 0.75,
is_obb: bool = False,
) -> None:
"""Save dataset annotations in YOLO format.
Args:
dataset: The dataset whose annotations are saved.
annotations_directory_path: Path to the directory where annotation
``.txt`` files are written; created automatically if absent.
min_image_area_percentage: Minimum detection area as a fraction of the
image area; smaller detections are omitted. Ignored when
``is_obb=True``.
max_image_area_percentage: Maximum detection area as a fraction of the
image area; larger detections are omitted. Ignored when
``is_obb=True``.
approximation_percentage: Fraction of polygon points removed during
contour approximation when saving mask annotations. Ignored when
``is_obb=True``.
is_obb: If ``True``, writes oriented bounding-box annotations using
the 9-token format ``class_id x1 y1 x2 y2 x3 y3 x4 y4``. Each
non-empty detection must carry ``detections.data['xyxyxyxy']``
with shape ``(N, 4, 2)``.
Examples:
>>> from supervision.dataset.core import DetectionDataset
>>> from supervision.dataset.formats.yolo import save_yolo_annotations
>>> dataset = DetectionDataset(classes=["cat"], images={}, annotations={})
>>> save_yolo_annotations(dataset, "/tmp/labels")
"""
Path(annotations_directory_path).mkdir(parents=True, exist_ok=True)
for image_path, image, annotation in dataset:
image_name = Path(image_path).name
@@ -310,6 +466,7 @@ def save_yolo_annotations(
min_image_area_percentage=min_image_area_percentage,
max_image_area_percentage=max_image_area_percentage,
approximation_percentage=approximation_percentage,
is_obb=is_obb,
)
save_text_file(lines=lines, file_path=yolo_annotations_path)
+240 -23
View File
@@ -7,6 +7,7 @@ from typing import Any, cast
import numpy as np
import numpy.typing as npt
from deprecate import deprecated, void
from supervision.config import (
CLASS_NAME_DATA_FIELD,
@@ -18,6 +19,7 @@ from supervision.detection.tools.transformers import (
process_transformers_v4_segmentation_result,
process_transformers_v5_segmentation_result,
)
from supervision.detection.utils.boxes import obb_polygon_area, xyxyxyxy_to_xyxy
from supervision.detection.utils.converters import (
mask_to_xyxy,
polygon_to_mask,
@@ -40,11 +42,14 @@ from supervision.detection.utils.iou_and_nms import (
mask_iou_batch,
mask_non_max_merge,
mask_non_max_suppression,
oriented_box_non_max_merge,
oriented_box_non_max_suppression,
)
from supervision.detection.utils.masks import calculate_masks_centroids
from supervision.detection.vlm import (
LMM,
VLM,
_validate_vlm_parameters,
from_deepseek_vl_2,
from_florence_2,
from_google_gemini_2_0,
@@ -53,11 +58,10 @@ from supervision.detection.vlm import (
from_paligemma,
from_qwen_2_5_vl,
from_qwen_3_vl,
validate_vlm_parameters,
)
from supervision.geometry.core import Position
from supervision.utils.internal import get_instance_variables, warn_deprecated
from supervision.validators import validate_detections_fields, validate_resolution
from supervision.validators import _validate_detections_fields, _validate_resolution
@dataclass
@@ -158,7 +162,7 @@ class Detections:
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
validate_detections_fields(
_validate_detections_fields(
xyxy=self.xyxy,
mask=self.mask,
confidence=self.confidence,
@@ -324,7 +328,9 @@ class Detections:
data={CLASS_NAME_DATA_FIELD: class_names},
)
return cls.empty()
empty = cls.empty()
empty.data = {CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)}
return empty
@classmethod
def from_yolo_nas(cls, yolo_nas_results: Any) -> Detections:
@@ -626,6 +632,10 @@ class Detections:
Returns:
A Detections object containing the bounding boxes, class IDs,
and confidences of the predictions.
`detections.data["class_name"]` is always present as a
string-dtype NumPy array aligned with the detections; it is
empty (shape `(0,)`, dtype str) when `predictions` is empty
or absent.
Example:
```python
@@ -650,7 +660,7 @@ class Detections:
if np.asarray(xyxy).shape[0] == 0:
empty_detection = cls.empty()
empty_detection.data = {CLASS_NAME_DATA_FIELD: np.empty(0)}
empty_detection.data = data
return empty_detection
return cls(
@@ -756,7 +766,7 @@ class Detections:
)
```
"""
width, height = validate_resolution(resolution_wh)
width, height = _validate_resolution(resolution_wh)
masks = []
confidences = []
@@ -1870,7 +1880,7 @@ class Detections:
""" # noqa: E501
vlm = validate_vlm_parameters(vlm, result, kwargs)
vlm = _validate_vlm_parameters(vlm, result, kwargs)
if vlm == VLM.PALIGEMMA:
assert isinstance(result, str)
@@ -1910,7 +1920,9 @@ class Detections:
assert isinstance(result, dict)
xyxy, labels, mask, xyxyxyxy = from_florence_2(result, **kwargs)
if len(xyxy) == 0:
return cls.empty()
empty = cls.empty()
empty.data = {CLASS_NAME_DATA_FIELD: np.empty(0, dtype=str)}
return empty
data = {}
if labels is not None:
@@ -2156,7 +2168,7 @@ class Detections:
return Detections.empty()
for detections in detections_list:
validate_detections_fields(
_validate_detections_fields(
xyxy=detections.xyxy,
mask=detections.mask,
confidence=detections.confidence,
@@ -2355,20 +2367,49 @@ class Detections:
def area(self) -> npt.NDArray[np.generic]:
"""
Calculate the area of each detection in the set of object detections.
If masks field is defined property returns are of each mask.
If only box is given property return area of each box.
Selection order:
1. If ``mask`` is set, return the area of each mask.
2. Else, if ``data[ORIENTED_BOX_COORDINATES]`` is set, return the area of
the rotated body (shoelace formula on the four corners).
3. Otherwise, return the axis-aligned box area (``box_area``).
**OBB dispatch contract**: presence of ``data[ORIENTED_BOX_COORDINATES]``
with shape ``(N, 4, 2)`` is the canonical signal that a detection carries
oriented bounding box geometry. The same presence-of-key check governs
``with_nms``, ``with_nmm``, and this property — always store OBB corners
under ``config.ORIENTED_BOX_COORDINATES`` with that shape.
**Return dtype**: ``float64`` (OBB branch), input dtype (AABB fallback),
``int64`` (mask branch).
Returns:
An array of floats containing the area of each detection
An array containing the area of each detection
in the format of `(area_1, area_2, ..., area_n)`,
where n is the number of detections.
Example:
>>> import numpy as np
>>> import supervision as sv
>>> corners = np.array(
... [[[0, 5], [5, 10], [10, 5], [5, 0]]], dtype=np.float32
... )
>>> detections = sv.Detections(
... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
... class_id=np.array([0]),
... data={"xyxyxyxy": corners},
... )
>>> detections.area
array([50.])
"""
if self.mask is not None:
if isinstance(self.mask, CompactMask):
return self.mask.area
return np.array([np.sum(mask) for mask in self.mask])
else:
return self.box_area
if ORIENTED_BOX_COORDINATES in self.data:
return obb_polygon_area(self.data[ORIENTED_BOX_COORDINATES])
return self.box_area
@property
def box_area(self) -> npt.NDArray[np.generic]:
@@ -2426,8 +2467,10 @@ class Detections:
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> Detections:
"""
Performs non-max suppression on detection set. If the detections result
from a segmentation model, the IoU mask is applied. Otherwise, box IoU is used.
Performs non-max suppression on detection set. Dispatch order: (1) if mask
data present, IoU mask is used; (2) else if oriented-box coordinates
(``data[ORIENTED_BOX_COORDINATES]``) present, oriented-box IoU is used; (3)
otherwise, axis-aligned box IoU is used.
Args:
threshold: The intersection-over-union threshold
@@ -2476,6 +2519,15 @@ class Detections:
iou_threshold=threshold,
overlap_metric=overlap_metric,
)
elif ORIENTED_BOX_COORDINATES in self.data:
indices = oriented_box_non_max_suppression(
predictions=predictions,
oriented_boxes=np.asarray(
self.data[ORIENTED_BOX_COORDINATES], dtype=np.float32
),
iou_threshold=threshold,
overlap_metric=overlap_metric,
)
else:
indices = box_non_max_suppression(
predictions=predictions,
@@ -2493,6 +2545,9 @@ class Detections:
) -> Detections:
"""
Perform non-maximum merging on the current set of object detections.
Dispatch order: (1) if mask data present, IoU mask is used; (2) else if
oriented-box coordinates (``data[ORIENTED_BOX_COORDINATES]``) present,
oriented-box IoU is used; (3) otherwise, axis-aligned box IoU is used.
Args:
threshold: The intersection-over-union threshold
@@ -2507,6 +2562,17 @@ class Detections:
A new Detections object containing the subset of detections
after non-maximum merging.
Note:
For detections carrying oriented bounding box data
(``data[ORIENTED_BOX_COORDINATES]``), each merge group's output OBB
is the tightest rectangle at the winner's orientation enclosing all
corners contributed by every detection in the group. The winner is
the highest-confidence detection in the group. The axis-aligned
``xyxy`` field is updated to the tight bounding box of that rect.
For zero-rotation OBBs this equals the axis-aligned union exactly;
for rotated OBBs the merged rect inherits the winner's rotation angle.
Groups of size 1 keep the original OBB unchanged.
Raises:
AssertionError: If `confidence` is None or `class_id` is None and
class_agnostic is False.
@@ -2542,6 +2608,15 @@ class Detections:
iou_threshold=threshold,
overlap_metric=overlap_metric,
)
elif ORIENTED_BOX_COORDINATES in self.data:
merge_groups = oriented_box_non_max_merge(
predictions=predictions,
oriented_boxes=np.asarray(
self.data[ORIENTED_BOX_COORDINATES], dtype=np.float32
),
iou_threshold=threshold,
overlap_metric=overlap_metric,
)
else:
merge_groups = box_non_max_merge(
predictions=predictions,
@@ -2551,15 +2626,144 @@ class Detections:
result: list[Detections] = []
for merge_group in merge_groups:
unmerged_detections = [cast(Detections, self[i]) for i in merge_group]
merged_detections = merge_inner_detections_objects_without_iou(
unmerged_detections
)
result.append(merged_detections)
group = [cast(Detections, self[i]) for i in merge_group]
result.append(_merge_detection_group(group))
return Detections.merge(result)
def _merge_obb_corners(
corners_list: list[npt.NDArray[np.floating]],
) -> npt.NDArray[np.floating]:
"""Merge multiple OBB corner arrays using winner-angle projection.
The first entry in *corners_list* is the winner. Its orientation angle
(derived from its first edge) defines the local frame in which the
tightest enclosing axis-aligned rectangle is computed. That rectangle is
then rotated back to produce the merged OBB.
Args:
corners_list: List of (4, 2) corner arrays. First is the winner.
Returns:
Merged OBB corners as a (4, 2) float32 array.
"""
all_corners = np.concatenate(corners_list, axis=0).astype(np.float32)
# Use winner's first edge to derive orientation angle -- avoids
# cv2.minAreaRect surprises (e.g. 90-degree flip for wide rects).
winner_edge = corners_list[0][1] - corners_list[0][0]
angle = float(np.arctan2(winner_edge[1], winner_edge[0]))
cos, sin = float(np.cos(angle)), float(np.sin(angle))
# De-rotate all corners into the winner's local frame
to_local = np.array([[cos, -sin], [sin, cos]], dtype=np.float32)
local_corners = all_corners @ to_local
x_min = float(local_corners[:, 0].min())
x_max = float(local_corners[:, 0].max())
y_min = float(local_corners[:, 1].min())
y_max = float(local_corners[:, 1].max())
# Rotate the enclosing AABB back to world frame
to_world = np.array([[cos, sin], [-sin, cos]], dtype=np.float32)
merged: npt.NDArray[np.floating] = (
np.array(
[[x_min, y_min], [x_max, y_min], [x_max, y_max], [x_min, y_max]],
dtype=np.float32,
)
@ to_world
)
return merged
def _merge_detection_group(detections: list[Detections]) -> Detections:
"""Merge a group of single-object Detections into one merged detection.
Used internally by :meth:`Detections.with_nmm` to combine each merge group
into a single output detection. The highest-confidence detection is the
"winner" whose class_id, tracker_id, and data fields are preserved.
Args:
detections: List of Detections, each containing exactly one object.
Returns:
A single merged Detections object of length 1.
"""
if len(detections) == 1:
return detections[0]
confidences = np.array(
[d.confidence[0] if d.confidence is not None else 0.0 for d in detections],
dtype=np.float64,
)
winner_idx = int(np.argmax(confidences))
winner = detections[winner_idx]
# Area-weighted confidence: each box contributes proportionally to its
# footprint so large overlapping boxes dominate over small slivers.
all_xyxy = np.array([d.xyxy[0] for d in detections], dtype=np.float32)
areas = (all_xyxy[:, 2] - all_xyxy[:, 0]) * (all_xyxy[:, 3] - all_xyxy[:, 1])
if winner.confidence is not None:
total_area = float(areas.sum())
if total_area > 0:
confidence = np.array(
[float(np.dot(areas, confidences) / total_area)],
dtype=np.float32,
)
else:
confidence = winner.confidence
else:
confidence = None
# OBB path: merge corners via winner-angle projection, then derive xyxy
# from the merged OBB so both stay consistent.
has_obb = ORIENTED_BOX_COORDINATES in winner.data
if has_obb:
corners_list = []
for det in detections:
c = np.asarray(det.data[ORIENTED_BOX_COORDINATES])
if c.ndim != 3 or c.shape[1:] != (4, 2):
raise ValueError(f"corners must have shape (N, 4, 2); got {c.shape}")
corners_list.append(c[0].reshape(4, 2))
merged_corners = _merge_obb_corners(corners_list)
xyxy = xyxyxyxy_to_xyxy(merged_corners[np.newaxis])
data = {**winner.data, ORIENTED_BOX_COORDINATES: merged_corners[np.newaxis]}
else:
# AABB path: union bounding box across all group members
xyxy = np.array(
[
[
all_xyxy[:, 0].min(),
all_xyxy[:, 1].min(),
all_xyxy[:, 2].max(),
all_xyxy[:, 3].max(),
]
],
dtype=np.float32,
)
data = winner.data
# Mask union via logical OR
masks = [d.mask for d in detections if d.mask is not None]
if masks:
mask = np.logical_or.reduce(np.concatenate(masks, axis=0))[np.newaxis]
else:
mask = None
metadata = merge_metadata([d.metadata for d in detections])
return Detections(
xyxy=xyxy,
mask=mask,
confidence=confidence,
class_id=winner.class_id,
tracker_id=winner.tracker_id,
data=data,
metadata=metadata,
)
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
def merge_inner_detection_object_pair(
detections_1: Detections, detections_2: Detections
) -> Detections:
@@ -2606,7 +2810,7 @@ def merge_inner_detection_object_pair(
if len(detections_1) != 1 or len(detections_2) != 1:
raise ValueError("Both Detections should have exactly 1 detected object.")
validate_fields_both_defined_or_none(detections_1, detections_2)
_validate_fields_both_defined_or_none(detections_1, detections_2)
xyxy_1 = detections_1.xyxy[0]
xyxy_2 = detections_2.xyxy[0]
@@ -2652,6 +2856,7 @@ def merge_inner_detection_object_pair(
)
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
def merge_inner_detections_objects(
detections: list[Detections],
threshold: float = 0.5,
@@ -2679,6 +2884,7 @@ def merge_inner_detections_objects(
return detections_1
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
def merge_inner_detections_objects_without_iou(
detections: list[Detections],
) -> Detections:
@@ -2693,7 +2899,7 @@ def merge_inner_detections_objects_without_iou(
return reduce(merge_inner_detection_object_pair, detections)
def validate_fields_both_defined_or_none(
def _validate_fields_both_defined_or_none(
detections_1: Detections, detections_2: Detections
) -> None:
"""
@@ -2715,3 +2921,14 @@ def validate_fields_both_defined_or_none(
f"Field '{attribute}' should be consistently None or not None in both "
"Detections."
)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_fields_both_defined_or_none,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_fields_both_defined_or_none(
detections_1: Detections, detections_2: Detections
) -> None:
void(detections_1, detections_2)
@@ -84,6 +84,13 @@ class InferenceSlicer:
iou_threshold: IOU threshold used in merging overlap filtering.
overlap_metric: Metric to compute overlap (`IOU` or `IOS`).
thread_workers: Number of threads for concurrent slice inference.
Must be a positive integer. When the first slice returns oriented
bounding boxes (OBB), Supervision probes additional slices until a
non-empty result is found, then falls back to sequential processing
for all remaining slices to avoid thread-safety issues in common OBB
inference backends. Note: the first slice always runs synchronously
regardless of this setting, so for grids with few slices
(e.g. two-slice images) effective parallelism is reduced.
compact_masks: If ``True``, dense ``(N, H, W)`` boolean mask
arrays returned by the callback are immediately converted to a
:class:`~supervision.detection.compact_mask.CompactMask`. This
@@ -95,7 +102,8 @@ class InferenceSlicer:
Defaults to ``False`` for backward compatibility.
Raises:
ValueError: If `slice_wh` or `overlap_wh` are invalid or inconsistent.
ValueError: If `slice_wh`, `overlap_wh`, or `thread_workers` are
invalid or inconsistent.
Example:
```python
@@ -148,6 +156,12 @@ class InferenceSlicer:
self._validate_overlap(slice_wh=slice_wh_norm, overlap_wh=overlap_wh_norm)
if thread_workers < 1:
raise ValueError(
"`thread_workers` must be a positive integer. "
f"Received: {thread_workers}"
)
self.slice_wh = slice_wh_norm
self.overlap_wh = overlap_wh_norm
self.iou_threshold = iou_threshold
@@ -158,11 +172,21 @@ class InferenceSlicer:
self.compact_masks = compact_masks
self._out_of_slice_bounds_warned: bool = False
self._out_of_slice_bounds_lock = threading.Lock()
self._obb_thread_workers_warned: bool = False
self._obb_thread_workers_lock = threading.Lock()
def __call__(self, image: ImageType) -> Detections:
"""
Perform tiled inference on the full image and return merged detections.
The first slice always runs synchronously so the output type can be
inspected before committing to a threading strategy. Detections are
merged in a deterministic order: the first slice is always at index 0,
followed by any probe slices, then the remaining slices in source order.
If oriented bounding boxes are detected, all remaining slices are
processed sequentially and a ``SupervisionWarnings`` warning is emitted
once per slicer instance.
Args:
image: The full image to run inference on.
@@ -178,12 +202,55 @@ class InferenceSlicer:
overlap_wh=self.overlap_wh,
)
with ThreadPoolExecutor(max_workers=self.thread_workers) as executor:
futures = [
executor.submit(self._run_callback, image, offset) for offset in offsets
]
for future in as_completed(futures):
detections_list.append(future.result())
first_offset = offsets[0]
first_detections = self._run_callback(image, first_offset)
detections_list.append(first_detections)
remaining_offsets = offsets[1:]
obb_detected = ORIENTED_BOX_COORDINATES in first_detections.data
should_run_sequentially = self.thread_workers <= 1 or obb_detected
probe_index = 0
if not should_run_sequentially and len(first_detections) == 0:
while probe_index < len(remaining_offsets):
probe_offset = remaining_offsets[probe_index]
probe_detections = self._run_callback(image, probe_offset)
detections_list.append(probe_detections)
probe_index += 1
if ORIENTED_BOX_COORDINATES in probe_detections.data:
obb_detected = True
should_run_sequentially = True
break
if len(probe_detections) > 0:
break
remaining_offsets = remaining_offsets[probe_index:]
if should_run_sequentially:
if self.thread_workers > 1 and obb_detected:
with self._obb_thread_workers_lock:
if not self._obb_thread_workers_warned:
self._obb_thread_workers_warned = True
warnings.warn(
"InferenceSlicer detected oriented bounding boxes while "
"`thread_workers > 1`. Remaining slices will be processed "
"sequentially because many OBB inference backends are not "
"thread-safe and can crash when shared across threads.",
category=SupervisionWarnings,
stacklevel=2,
)
for offset in remaining_offsets:
detections_list.append(self._run_callback(image, offset))
else:
with ThreadPoolExecutor(max_workers=self.thread_workers) as executor:
futures = [
executor.submit(self._run_callback, image, offset)
for offset in remaining_offsets
]
for future in as_completed(futures):
detections_list.append(future.result())
merged = Detections.merge(detections_list=detections_list)
if self.overlap_filter == OverlapFilter.NONE:
+33 -2
View File
@@ -24,6 +24,8 @@ class JSONSink:
When a list or tuple value in custom_data (or detections.data) has the
same length as the detection count, each element is written to the
corresponding detection row; any other value is broadcast to all rows.
NumPy scalars (e.g. ``np.int64``, ``np.float32``) are serialized as
JSON numbers; NumPy arrays are serialized as JSON arrays.
Args:
file_name: The name of the JSON file where the detections will be stored.
@@ -79,13 +81,42 @@ class JSONSink:
self.file = open(self.file_name, "w")
@staticmethod
def _json_default(value: Any) -> Any:
"""Return a JSON-serializable equivalent of a NumPy scalar or array.
Called as the ``default`` hook by :func:`json.dump`. Converts
:class:`numpy.generic` scalars via ``.item()`` and
:class:`numpy.ndarray` instances via ``.tolist()``.
Args:
value: Object the standard JSON encoder could not serialize.
Returns:
A Python scalar or nested list equivalent of ``value``.
Raises:
TypeError: If ``value`` is neither a NumPy scalar nor an ndarray.
"""
if isinstance(value, np.generic):
return value.item()
if isinstance(value, np.ndarray):
return value.tolist()
raise TypeError(
f"Object of type {type(value).__name__} is not JSON serializable"
)
def write_and_close(self) -> None:
"""
Write and close the JSON file.
"""
if self.file:
json.dump(self.data, self.file, indent=4)
self.file.close()
try:
json.dump(
self.data, self.file, indent=4, default=JSONSink._json_default
)
finally:
self.file.close()
@staticmethod
def _slice_value(value: Any, i: int, n: int) -> Any:
+13 -18
View File
@@ -36,25 +36,20 @@ class PolygonZone:
mask: The 2D bool mask for the polygon zone
Example:
```python
import supervision as sv
from ultralytics import YOLO
import numpy as np
import cv2
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> polygon = np.array([[100, 200], [200, 100], [300, 200], [200, 300]])
>>> polygon_zone = sv.PolygonZone(polygon=polygon)
>>> detections = sv.Detections(
... xyxy=np.array([[180, 100, 220, 200], [400, 400, 450, 500]])
... )
>>> is_detections_in_zone = polygon_zone.trigger(detections)
>>> is_detections_in_zone
array([ True, False])
>>> polygon_zone.current_count
1
image = cv2.imread("<SOURCE_IMAGE_PATH>")
model = YOLO("yolo11s")
tracker = sv.ByteTrack()
polygon = np.array([[100, 200], [200, 100], [300, 200], [200, 300]])
polygon_zone = sv.PolygonZone(polygon=polygon)
result = model.infer(image)[0]
detections = sv.Detections.from_ultralytics(result)
detections = tracker.update_with_detections(detections)
is_detections_in_zone = polygon_zone.trigger(detections)
print(polygon_zone.current_count)
```
"""
+29 -1
View File
@@ -29,6 +29,9 @@ class DetectionsSmoother:
[Roboflow Trackers](/latest/trackers/) for
information on integrating tracking into your inference pipeline.
- This class is not compatible with segmentation models.
- When detections in a frame disagree on confidence presence — some tracks
carry confidence scores and others do not — `confidence` is set to `None`
for all smoothed detections in that frame.
Example:
```pycon
@@ -128,6 +131,20 @@ class DetectionsSmoother:
return self.get_smoothed_detections()
def get_track(self, track_id: int) -> Detections | None:
"""Return the smoothed `Detections` for a single track.
Averages `xyxy` over all valid (non-`None`) frames in the track window.
`confidence` is averaged only over frames that carry it; frames with
`confidence=None` are excluded. Returns `None` when the track is unknown
or its entire window is empty.
Args:
track_id: The tracker ID whose smoothed detection to retrieve.
Returns:
Smoothed `Detections` for the track, or `None` if the track is
unknown or all frames in its window are empty.
"""
track = self.tracks.get(track_id, None)
if track is None:
return None
@@ -138,7 +155,11 @@ class DetectionsSmoother:
ret = deepcopy(valid[0])
ret.xyxy = np.mean([d.xyxy for d in valid], axis=0)
ret.confidence = np.mean([d.confidence for d in valid], axis=0)
# Average confidence only over frames that carry it; frames with
# confidence=None contribute nothing to the mean. Retain None when
# no frame in the window carries confidence.
confidences = [d.confidence for d in valid if d.confidence is not None]
ret.confidence = np.mean(np.array(confidences), axis=0) if confidences else None
return ret
@@ -149,6 +170,13 @@ class DetectionsSmoother:
if track is not None:
tracked_detections.append(track)
# Detections.merge requires all-or-none for optional fields.
# When tracks disagree on confidence presence, drop it from all to
# prevent ValueError inside Detections.merge (stack_or_none invariant).
if tracked_detections and any(d.confidence is None for d in tracked_detections):
for d in tracked_detections:
d.confidence = None
detections = Detections.merge(tracked_detections)
if len(detections) == 0:
detections.tracker_id = np.array([], dtype=int)
+76
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import numpy as np
import numpy.typing as npt
from deprecate import TargetMode, deprecated
from supervision.detection.utils.iou_and_nms import box_iou_batch
@@ -95,10 +96,17 @@ def pad_boxes(
return result
@deprecated( # type: ignore[untyped-decorator]
target=TargetMode.ARGS_REMAP,
deprecated_in="0.27.0",
remove_in="0.30.0",
args_mapping={"normalized_xyxy": "xyxy"},
)
def denormalize_boxes(
xyxy: npt.NDArray[np.number],
resolution_wh: tuple[int, int],
normalization_factor: float = 1.0,
normalized_xyxy: npt.NDArray[np.number] | None = None,
) -> npt.NDArray[np.number]:
"""
Convert normalized bounding box coordinates to absolute pixel coordinates.
@@ -233,6 +241,74 @@ def move_oriented_boxes(
return xyxyxyxy + offset
def obb_polygon_area(corners: npt.NDArray) -> npt.NDArray[np.float64]:
"""Compute the area of N oriented bounding boxes using the shoelace formula.
Args:
corners: OBB corner coordinates with shape `(N, 4, 2)`.
Returns:
Area of each box as a 1-D float64 array of shape `(N,)`.
Raises:
ValueError: If `corners` does not have shape `(N, 4, 2)`.
Examples:
>>> import numpy as np
>>> from supervision.detection.utils.boxes import obb_polygon_area
>>> corners = np.array([[[0, 5], [5, 10], [10, 5], [5, 0]]], dtype=np.float32)
>>> obb_polygon_area(corners)
array([50.])
"""
corners = np.asarray(corners)
if corners.ndim != 3 or corners.shape[-2:] != (4, 2):
raise ValueError(f"corners must have shape (N, 4, 2); got {corners.shape}")
x = corners[..., 0].astype(np.float64, copy=False)
y = corners[..., 1].astype(np.float64, copy=False)
cross = x * np.roll(y, -1, axis=-1) - y * np.roll(x, -1, axis=-1)
return 0.5 * np.abs(np.sum(cross, axis=-1))
def xyxyxyxy_to_xyxy(
xyxyxyxy: npt.NDArray[np.number],
) -> npt.NDArray[np.number]:
"""Convert oriented bounding box corners to axis-aligned bounding boxes.
Args:
xyxyxyxy: OBB corner coordinates with shape `(N, 4, 2)` where each
box is represented as `[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]`.
Returns:
Axis-aligned bounding boxes as an array of shape `(N, 4)`
in `(x_min, y_min, x_max, y_max)` format.
Raises:
ValueError: If `xyxyxyxy` does not have shape `(N, 4, 2)`.
Examples:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> corners = np.array([
... [[0, 0], [10, 0], [10, 5], [0, 5]],
... [[5, 5], [15, 5], [15, 10], [5, 10]],
... ], dtype=np.float32)
>>> sv.xyxyxyxy_to_xyxy(corners)
array([[ 0., 0., 10., 5.],
[ 5., 5., 15., 10.]], dtype=float32)
```
"""
xyxyxyxy = np.asarray(xyxyxyxy)
if xyxyxyxy.ndim != 3 or xyxyxyxy.shape[-2:] != (4, 2):
raise ValueError(f"xyxyxyxy must have shape (N, 4, 2); got {xyxyxyxy.shape}")
x_min = xyxyxyxy[..., 0].min(axis=-1)
y_min = xyxyxyxy[..., 1].min(axis=-1)
x_max = xyxyxyxy[..., 0].max(axis=-1)
y_max = xyxyxyxy[..., 1].max(axis=-1)
return np.stack([x_min, y_min, x_max, y_max], axis=-1)
def scale_boxes(
xyxy: npt.NDArray[np.float64], factor: float
) -> npt.NDArray[np.float64]:
+30 -9
View File
@@ -204,23 +204,44 @@ def mask_to_xyxy(masks: npt.NDArray[np.bool_]) -> npt.NDArray[np.int_]:
Converts a 3D `np.array` of 2D bool masks into a 2D `np.array` of bounding boxes.
Args:
masks: A 3D `np.array` of shape `(N, W, H)` containing 2D bool masks.
masks: A 3D `np.array` of shape `(N, H, W)` containing 2D bool masks.
Returns:
A 2D `np.array` of shape `(N, 4)` containing the bounding boxes
`(x_min, y_min, x_max, y_max)` for each mask.
Examples:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> masks = np.array([
... [[False, False, False],
... [False, True, True],
... [False, True, True]],
... ])
>>> sv.mask_to_xyxy(masks)
array([[1, 1, 2, 2]])
```
"""
n = masks.shape[0]
xyxy = np.zeros((n, 4), dtype=int)
n, height, width = masks.shape
if masks.size == 0:
return np.zeros((n, 4), dtype=int)
for i, mask in enumerate(masks):
rows, cols = np.where(mask)
# Reduce the mask stack to per-row / per-column occupancy, then read the
# tight bounds straight off those 1D profiles instead of scanning every
# pixel of every mask with `np.where`.
rows_any = cast(npt.NDArray[np.bool_], masks.any(axis=2)) # (N, H)
cols_any = cast(npt.NDArray[np.bool_], masks.any(axis=1)) # (N, W)
if len(rows) > 0 and len(cols) > 0:
x_min, x_max = int(np.min(cols)), int(np.max(cols))
y_min, y_max = int(np.min(rows)), int(np.max(rows))
xyxy[i, :] = [x_min, y_min, x_max, y_max]
x_min = cols_any.argmax(axis=1)
x_max = width - 1 - cols_any[:, ::-1].argmax(axis=1)
y_min = rows_any.argmax(axis=1)
y_max = height - 1 - rows_any[:, ::-1].argmax(axis=1)
xyxy = np.stack((x_min, y_min, x_max, y_max), axis=1).astype(int)
# Empty masks have no bounds; keep the original all-zeros box for them.
xyxy[~rows_any.any(axis=1)] = 0
return xyxy
+32 -7
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import logging
from itertools import chain
from typing import Any, cast
from typing import Any, Union, cast
import cv2
import numpy as np
@@ -61,6 +61,29 @@ def process_roboflow_result(
npt.NDArray[np.integer] | None,
dict[str, npt.NDArray[np.generic]],
]:
"""Parse a Roboflow API or Inference package result into detection arrays.
The returned ``data`` dict always contains ``CLASS_NAME_DATA_FIELD`` as a
string-dtype NumPy array. When ``predictions`` is empty, the array has
shape ``(0,)`` with ``dtype=str``, preserving dtype contracts for callers
that mix empty and non-empty results.
Args:
roboflow_result: Raw dict from the Roboflow REST API or the Inference
package (after ``.dict()`` serialisation).
Returns:
A 6-tuple of ``(xyxy, confidence, class_id, masks, tracker_ids, data)``
where each array is aligned with the others. ``masks`` and
``tracker_ids`` are ``None`` when absent from the predictions.
Examples:
>>> from supervision.detection.utils.internal import process_roboflow_result
>>> result = {"predictions": [], "image": {"width": 100, "height": 100}}
>>> _, _, _, _, _, data = process_roboflow_result(result)
>>> data["class_name"].dtype.kind
'U'
"""
if not roboflow_result["predictions"]:
return (
np.empty((0, 4), dtype=np.float64),
@@ -133,12 +156,14 @@ def process_roboflow_result(
polygon = np.array(
[[point["x"], point["y"]] for point in prediction["points"]], dtype=int
)
mask = polygon_to_mask(polygon, resolution_wh=(image_width, image_height))
mask = polygon_to_mask(
polygon, resolution_wh=(image_width, image_height)
).astype(bool)
xyxy.append([x_min, y_min, x_max, y_max])
class_id.append(prediction["class_id"])
class_name.append(prediction["class"])
confidence.append(prediction["confidence"])
masks.append(mask.astype(bool))
masks.append(mask)
if "tracker_id" in prediction:
tracker_ids.append(prediction["tracker_id"])
@@ -175,8 +200,8 @@ def process_roboflow_result(
def is_data_equal(
data_a: dict[str, npt.NDArray[np.generic]],
data_b: dict[str, npt.NDArray[np.generic]],
data_a: dict[str, npt.NDArray[np.generic] | list[Any]],
data_b: dict[str, npt.NDArray[np.generic] | list[Any]],
) -> bool:
"""
Compares the data payloads of two Detections instances.
@@ -249,7 +274,7 @@ def merge_data(
"All data values within a single object must have equal length."
)
merged_data: dict[str, list[Any]] = {key: [] for key in all_keys_sets[0]}
merged_data: dict[str, Any] = {key: [] for key in all_keys_sets[0]}
for data in data_list:
for key in data:
merged_data[key].append(data[key])
@@ -271,7 +296,7 @@ def merge_data(
f"types are allowed."
)
return merged_data
return cast(dict[str, Union[npt.NDArray[np.generic], list[Any]]], merged_data)
def merge_metadata(metadata_list: list[dict[str, Any]]) -> dict[str, Any]:
+586 -112
View File
@@ -1,13 +1,15 @@
from __future__ import annotations
import warnings
from collections.abc import Callable
from enum import Enum
from typing import Any, cast
import cv2
import numpy as np
import numpy.typing as npt
from supervision.detection.compact_mask import CompactMask
from supervision.detection.utils.converters import polygon_to_mask
from supervision.detection.utils.masks import resize_masks
@@ -358,47 +360,210 @@ def box_iou_batch_with_jaccard(
return ious
def _polygon_areas(polygons: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
"""Compute the area of each oriented-box polygon using the shoelace formula.
Args:
polygons: ``(N, 4, 2)`` array of polygon corners.
Returns:
``(N,)`` array of polygon areas.
"""
x = polygons[:, :, 0]
y = polygons[:, :, 1]
cross = x * np.roll(y, -1, axis=1) - np.roll(x, -1, axis=1) * y
return cast(npt.NDArray[np.floating], 0.5 * np.abs(cross.sum(axis=1)))
def _aabb_envelopes(polygons: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
"""Compute the axis-aligned bounding envelope of each oriented box.
Args:
polygons: ``(N, 4, 2)`` array of polygon corners.
Returns:
``(N, 4)`` array of ``(x_min, y_min, x_max, y_max)`` envelopes.
"""
xs = polygons[:, :, 0]
ys = polygons[:, :, 1]
return np.stack(
[xs.min(axis=1), ys.min(axis=1), xs.max(axis=1), ys.max(axis=1)], axis=1
)
def _overlapping_envelope_pairs(
envelopes_true: npt.NDArray[np.floating],
envelopes_detection: npt.NDArray[np.floating],
) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]]:
"""Return index pairs ``(i, j)`` whose axis-aligned envelopes overlap.
Uses a fused boolean evaluation to halve peak transient memory compared to
named-intermediate form (4 separate NxM float64 arrays vs 1 boolean array).
Note:
This gate is a correctness guarantee, not an approximation: if two
axis-aligned bounding boxes do not overlap, the convex polygons they
contain cannot overlap either.
Args:
envelopes_true: ``(N, 4)`` array of ``(x_min, y_min, x_max, y_max)``
envelopes for the ground-truth boxes.
envelopes_detection: ``(M, 4)`` array of ``(x_min, y_min, x_max, y_max)``
envelopes for the detection boxes.
Returns:
A pair of 1-D index arrays ``(rows, cols)`` identifying the overlapping
pairs.
"""
et = envelopes_true[:, None, :]
ed = envelopes_detection[None, :, :]
overlap = (
np.minimum(et[..., 2], ed[..., 2]) > np.maximum(et[..., 0], ed[..., 0])
) & (np.minimum(et[..., 3], ed[..., 3]) > np.maximum(et[..., 1], ed[..., 1]))
return cast(tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]], np.nonzero(overlap))
def oriented_box_iou_batch(
boxes_true: npt.NDArray[np.number], boxes_detection: npt.NDArray[np.number]
boxes_true: npt.NDArray[np.number],
boxes_detection: npt.NDArray[np.number],
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> npt.NDArray[np.floating]:
"""
Compute Intersection over Union (IoU) of two sets of oriented bounding boxes -
`boxes_true` and `boxes_detection`. Both sets of boxes are expected to be in
Compute pairwise overlap scores between two sets of oriented bounding boxes
using the configured `overlap_metric`.
Overlap areas are computed exactly via convex-polygon intersection, gated by
a cheap axis-aligned envelope pre-filter — no rasterization is involved, so
the result is exact (free of pixel-quantization error) and independent of the
coordinate magnitudes.
`boxes_true` and `boxes_detection` are expected to be in
`((x1, y1), (x2, y2), (x3, y3), (x4, y4))` format.
Note:
Inputs must be **convex** quads with finite coordinates. Self-intersecting
or non-convex polygons produce undefined results via
``cv2.intersectConvexConvex``. NaN or Inf coordinates propagate silently
as ``0.0`` — validate inputs before calling if needed.
When ``boxes_true is boxes_detection`` (the same Python object, not just
equal values), the function computes only the upper triangle of the
matrix and mirrors it. This optimization is used automatically by the
NMS/NMM callers that pass the same array twice. A defensive ``.copy()``
at the call site would disable the optimization silently — see the
NMS caller comment for context.
Args:
boxes_true: A `np.ndarray` representing ground-truth boxes.
`shape = (N, 4, 2)` where `N` is number of true objects.
Last axis convention: `[..., 0]` = x-coordinates,
`[..., 1]` = y-coordinates.
boxes_detection: A `np.ndarray` representing detection boxes.
`shape = (M, 4, 2)` where `M` is number of detected objects.
Last axis convention: `[..., 0]` = x-coordinates,
`[..., 1]` = y-coordinates.
overlap_metric: Metric used to compute the degree of overlap
between pairs of oriented boxes (e.g., IoU, IoS).
Returns:
Pairwise IoU of boxes from `boxes_true` and `boxes_detection`.
`shape = (N, M)` where `N` is number of true objects and
`M` is number of detected objects.
Overlap matrix of shape `(N, M)`, where entry `[i, j]` is the overlap
score between `boxes_true[i]` and `boxes_detection[j]`, in the range
`[0, 1]` under the configured :attr:`overlap_metric`.
Raises:
ValueError: If ``boxes_true`` or ``boxes_detection`` is 3-D with inner
dimensions other than ``(4, 2)``.
ValueError: If ``boxes_true`` or ``boxes_detection`` is 2-D with a
column count other than 8.
ValueError: If ``boxes_true`` or ``boxes_detection`` is not 2-D or 3-D.
ValueError: If ``overlap_metric`` is not
:attr:`~supervision.config.OverlapMetric.IOU` or
:attr:`~supervision.config.OverlapMetric.IOS`.
Examples:
>>> import numpy as np
>>> import supervision as sv
>>> a = np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32)
>>> b = np.array([[[1, 0], [3, 0], [3, 2], [1, 2]]], dtype=np.float32)
>>> sv.oriented_box_iou_batch(a, b) # doctest: +ELLIPSIS
array([[0.333...]])
"""
boxes_true = boxes_true.reshape(-1, 4, 2)
boxes_detection = boxes_detection.reshape(-1, 4, 2)
for name, arr in (("boxes_true", boxes_true), ("boxes_detection", boxes_detection)):
if arr.ndim == 3 and arr.shape[1:] != (4, 2):
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 4, 2) "
f"— each box must have exactly 4 corners with (x, y) coordinates."
)
elif arr.ndim == 2 and arr.shape[1] != 8:
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 8) for flat "
f"YOLO format or (N, 4, 2) for corner format."
)
elif arr.ndim not in (2, 3):
raise ValueError(
f"`{name}` must be 2-D (N, 8) or 3-D (N, 4, 2), got shape {arr.shape}."
)
max_height = int(max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max()) + 1)
# adding 1 because we are 0-indexed
max_width = int(max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max()) + 1)
mask_true = np.zeros((boxes_true.shape[0], max_height, max_width), dtype=np.uint8)
for box_idx, box_true in enumerate(boxes_true):
mask_true[box_idx] = polygon_to_mask(box_true, (max_width, max_height))
mask_detection = np.zeros(
(boxes_detection.shape[0], max_height, max_width), dtype=np.uint8
)
for box_idx, box_detection in enumerate(boxes_detection):
mask_detection[box_idx] = polygon_to_mask(
box_detection, (max_width, max_height)
if overlap_metric == OverlapMetric.IOU:
normalize_by_union = True
elif overlap_metric == OverlapMetric.IOS:
normalize_by_union = False
else:
raise ValueError(
f"overlap_metric {overlap_metric} is not supported, "
"only 'IOU' and 'IOS' are supported"
)
ious = mask_iou_batch(mask_true, mask_detection)
return ious
# Capture identity before reshape: NMS / NMM pass the same array twice, so
# the matrix is symmetric and we can compute only its upper triangle.
is_self_comparison = boxes_true is boxes_detection
boxes_true = boxes_true.reshape(-1, 4, 2).astype(np.float64)
boxes_detection = boxes_detection.reshape(-1, 4, 2).astype(np.float64)
n, m = len(boxes_true), len(boxes_detection)
if n == 0 or m == 0:
return np.zeros((n, m), dtype=np.float64)
areas_true = _polygon_areas(boxes_true)
areas_detection = _polygon_areas(boxes_detection)
envelopes_true = _aabb_envelopes(boxes_true)
envelopes_detection = (
envelopes_true if is_self_comparison else _aabb_envelopes(boxes_detection)
)
rows, cols = _overlapping_envelope_pairs(envelopes_true, envelopes_detection)
if is_self_comparison:
upper = rows <= cols
rows, cols = rows[upper], cols[upper]
polygons_true = [box.astype(np.float32) for box in boxes_true]
polygons_detection = [box.astype(np.float32) for box in boxes_detection]
ious: npt.NDArray[np.float64] = np.zeros((n, m), dtype=np.float64)
for i, j in zip(rows, cols):
intersection, _ = cv2.intersectConvexConvex(
polygons_true[i], polygons_detection[j]
)
if intersection <= 0:
continue
denominator = (
areas_true[i] + areas_detection[j] - intersection
if normalize_by_union
else min(areas_true[i], areas_detection[j])
)
if denominator > 0:
score = intersection / denominator
ious[i, j] = score
if is_self_comparison:
ious[j, i] = score
# DO NOT remove this clip. cv2.intersectConvexConvex computes in float32
# internally while polygon areas are computed in float64; the intersection
# area can exceed the float64 area by ~25 ULP (~1e-7), producing raw IoU
# or IoS values microscopically above 1.0 for identical boxes. The clip is
# load-bearing, not defensive duplication.
return cast(npt.NDArray[np.floating], np.clip(ious, 0.0, 1.0))
def compact_mask_iou_batch(
@@ -523,12 +688,23 @@ def _mask_iou_batch_split(
Returns:
Pairwise IoU of masks from `masks_true` and `masks_detection`.
"""
intersection_area = np.logical_and(masks_true[:, None], masks_detection).sum(
axis=(2, 3)
# The overlap of two binary masks is the dot product of their flattened
# pixels, so the whole (N, M) intersection matrix is a single matmul.
# float32 counts pixels exactly up to 2**24; for larger masks (beyond
# ~4096x4096) we promote to float64 so the counts stay exact.
pixels = int(np.prod(masks_true.shape[1:]))
count_dtype = np.float32 if pixels <= 2**24 else np.float64
true_flat = masks_true.reshape(masks_true.shape[0], pixels).astype(
count_dtype, copy=False
)
detection_flat = masks_detection.reshape(masks_detection.shape[0], pixels).astype(
count_dtype, copy=False
)
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
intersection_area: npt.NDArray[np.floating[Any]] = true_flat @ detection_flat.T
masks_true_area = masks_true.sum(axis=(1, 2)) # (area1, area2, ...)
masks_detection_area = masks_detection.sum(axis=(1, 2)) # (area1)
masks_true_area = true_flat.sum(axis=1)
masks_detection_area = detection_flat.sum(axis=1)
if overlap_metric == OverlapMetric.IOU:
union_area = masks_true_area[:, None] + masks_detection_area - intersection_area
@@ -579,10 +755,20 @@ def mask_iou_batch(
overlap_metric: Metric used to compute the degree of overlap
between pairs of masks (e.g., IoU, IoS).
memory_limit: Memory limit in MB, default is 1024 * 5 MB (5GB).
Ignored when both inputs are CompactMask.
Controls chunking of ``masks_true`` so that flattened detection
masks plus each chunk's buffers stay within this limit. A
``UserWarning`` is raised when ``masks_detection`` alone
exceeds the limit, as chunking cannot reduce peak memory
below that floor. Ignored when both inputs are
:class:`~supervision.detection.compact_mask.CompactMask`.
Returns:
Pairwise IoU of masks from `masks_true` and `masks_detection`.
Raises:
ValueError: If ``masks_true`` or ``masks_detection`` are not 3D
``(N, H, W)`` arrays, or if they do not share the same
spatial dimensions ``(H, W)``.
"""
if isinstance(masks_true, CompactMask) and isinstance(masks_detection, CompactMask):
@@ -594,29 +780,43 @@ def mask_iou_batch(
if isinstance(masks_detection, CompactMask):
masks_detection = np.asarray(masks_detection)
memory = (
masks_true.shape[0]
* masks_true.shape[1]
* masks_true.shape[2]
* masks_detection.shape[0]
/ 1024
/ 1024
)
if memory <= memory_limit:
if masks_true.ndim != 3 or masks_detection.ndim != 3:
raise ValueError(
"masks_true and masks_detection must be 3D (N, H, W); got "
f"ndim={masks_true.ndim} and ndim={masks_detection.ndim}."
)
if masks_true.shape[1:] != masks_detection.shape[1:]:
raise ValueError(
"masks_true and masks_detection must share the same (H, W); got "
f"{masks_true.shape[1:]} and {masks_detection.shape[1:]}."
)
# A single pass already handles empty inputs and avoids np.vstack([]) below.
if masks_true.shape[0] == 0 or masks_detection.shape[0] == 0:
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
# Peak memory of a single matmul pass: the flattened detection masks (shared
# across chunks) plus, per true-mask row, its flattened pixels and the three
# (N, M) matrices it touches (intersection, denominator and output). The
# previous (N, M, H, W) estimate overcounted by a factor of M and forced
# needless chunking now that the intersection is a matmul.
pixels = masks_true.shape[1] * masks_true.shape[2]
itemsize = 4 if pixels <= 2**24 else 8
limit_bytes = memory_limit * 1024 * 1024
detection_bytes = masks_detection.shape[0] * pixels * itemsize
per_true_row = pixels * itemsize + 3 * masks_detection.shape[0] * 8
if detection_bytes > limit_bytes > 0:
warnings.warn(
f"detection masks ({detection_bytes // 1024 // 1024} MB) exceed "
f"memory_limit ({memory_limit} MB); chunking cannot reduce peak "
"memory below this floor.",
UserWarning,
stacklevel=2,
)
if detection_bytes + masks_true.shape[0] * per_true_row <= limit_bytes:
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
ious = []
step = max(
memory_limit
* 1024
* 1024
// (
masks_detection.shape[0]
* masks_detection.shape[1]
* masks_detection.shape[2]
),
1,
)
step = max((limit_bytes - detection_bytes) // per_true_row, 1)
for chunk_start in range(0, masks_true.shape[0], step):
ious.append(
_mask_iou_batch_split(
@@ -696,6 +896,45 @@ def mask_non_max_suppression(
return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()])
def _prepare_predictions_for_nms(
predictions: npt.NDArray[np.floating],
) -> tuple[npt.NDArray[np.int_], npt.NDArray[np.floating], npt.NDArray[np.floating]]:
"""Add an agnostic class column when missing, sort by descending score.
Returns the score-descending sort index, the reordered predictions, and the
category vector for the loop callers to consume.
"""
rows, columns = predictions.shape
if columns == 5:
predictions = np.c_[predictions, np.zeros(rows)]
sort_index = np.flip(predictions[:, 4].argsort())
predictions = predictions[sort_index]
categories = predictions[:, 5]
return sort_index, predictions, categories
def _nms_loop_from_iou_matrix(
ious: npt.NDArray[np.floating],
categories: npt.NDArray[np.floating],
iou_threshold: float,
) -> npt.NDArray[np.bool_]:
"""Greedy NMS suppression loop given a precomputed pairwise IoU matrix.
Assumes `ious` is square with row/column order matching `categories`.
Detections sharing a category whose IoU exceeds `iou_threshold` are dropped
in favour of the higher-confidence entry.
"""
rows = len(ious)
ious = ious - np.eye(rows)
keep: npt.NDArray[np.bool_] = np.ones(rows, dtype=bool)
for index, (iou, category) in enumerate(zip(ious, categories)):
if not keep[index]:
continue
condition = (iou > iou_threshold) & (categories == category)
keep = keep & ~condition
return keep
def box_non_max_suppression(
predictions: npt.NDArray[np.floating],
iou_threshold: float = 0.5,
@@ -725,32 +964,9 @@ def box_non_max_suppression(
"Value of `iou_threshold` must be in the closed range from 0 to 1, "
f"{iou_threshold} given."
)
rows, columns = predictions.shape
# add column #5 - category filled with zeros for agnostic nms
if columns == 5:
predictions = np.c_[predictions, np.zeros(rows)]
# sort predictions column #4 - score
sort_index = np.flip(predictions[:, 4].argsort())
predictions = predictions[sort_index]
boxes = predictions[:, :4]
categories = predictions[:, 5]
ious = box_iou_batch(boxes, boxes, overlap_metric)
ious = ious - np.eye(rows)
keep = np.ones(rows, dtype=bool)
for index, (iou, category) in enumerate(zip(ious, categories)):
if not keep[index]:
continue
# drop detections with iou > iou_threshold and
# same category as current detections
condition = (iou > iou_threshold) & (categories == category)
keep = keep & ~condition
sort_index, predictions, categories = _prepare_predictions_for_nms(predictions)
ious = box_iou_batch(predictions[:, :4], predictions[:, :4], overlap_metric)
keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold)
return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()])
@@ -886,6 +1102,70 @@ def mask_non_max_merge(
return merge_groups
def _greedy_nmm_via_iou_callback(
predictions: npt.NDArray[np.float64],
iou_against_candidate: Callable[
[npt.NDArray[np.int_], int], npt.NDArray[np.floating]
],
iou_threshold: float,
) -> list[list[int]]:
"""Greedy non-maximum merging loop, independent of how overlap is computed.
``iou_against_candidate(order_indices, candidate_idx)`` must return the IoU
vector between every prediction in ``order_indices`` and the candidate at
``candidate_idx``. Predictions whose IoU meets ``iou_threshold`` are
grouped with the candidate.
"""
merge_groups: list[list[int]] = []
scores = predictions[:, 4]
order = scores.argsort()
while len(order) > 0:
idx = int(order[-1])
order = order[:-1]
if len(order) == 0:
merge_groups.append([idx])
break
ious = iou_against_candidate(order, idx)
above_threshold = ious >= iou_threshold
merge_group = [idx, *np.flip(order[above_threshold]).tolist()]
merge_groups.append(merge_group)
order = order[~above_threshold]
return merge_groups
def _non_max_merge_per_category(
predictions: npt.NDArray[np.float64],
group_within: Callable[[npt.NDArray[np.int_]], list[list[int]]],
) -> list[list[int]]:
"""Dispatch NMM grouping per class, then translate local indices back to
the global row positions of ``predictions``.
``group_within(global_indices)`` must return merge groups expressed in
terms of *positions inside `global_indices`*, not absolute row positions.
When ``predictions`` has no class column, a single pass over all rows is
performed instead of per-category iteration.
"""
if predictions.shape[1] == 5:
global_indices = np.arange(len(predictions), dtype=int)
return [
global_indices[group].tolist() for group in group_within(global_indices)
]
category_ids = predictions[:, 5]
merge_groups: list[list[int]] = []
for category_id in np.unique(category_ids):
curr_indices = np.where(category_ids == category_id)[0]
for local_group in group_within(curr_indices):
merge_groups.append(curr_indices[local_group].tolist())
for merge_group in merge_groups:
if len(merge_group) == 0:
raise ValueError(
f"Empty group detected when non-max-merging detections: {merge_groups}"
)
return merge_groups
def _group_overlapping_boxes(
predictions: npt.NDArray[np.float64],
iou_threshold: float = 0.5,
@@ -908,30 +1188,19 @@ def _group_overlapping_boxes(
Groups of prediction indices to be merged.
Each group may have 1 or more elements.
"""
merge_groups: list[list[int]] = []
scores = predictions[:, 4]
order = scores.argsort()
def iou_against_candidate(
order: npt.NDArray[np.int_], idx: int
) -> npt.NDArray[np.floating]:
return box_iou_batch(
predictions[order][:, :4],
predictions[idx : idx + 1, :4],
overlap_metric,
).flatten()
while len(order) > 0:
idx = int(order[-1])
order = order[:-1]
if len(order) == 0:
merge_groups.append([idx])
break
merge_candidate = np.expand_dims(predictions[idx], axis=0)
ious = box_iou_batch(
predictions[order][:, :4], merge_candidate[:, :4], overlap_metric
)
ious = ious.flatten()
above_threshold = ious >= iou_threshold
merge_group = [idx, *np.flip(order[above_threshold]).tolist()]
merge_groups.append(merge_group)
order = order[~above_threshold]
return merge_groups
return _greedy_nmm_via_iou_callback(
predictions, iou_against_candidate, iou_threshold
)
def box_non_max_merge(
@@ -957,23 +1226,228 @@ def box_non_max_merge(
list[list[int]]: Groups of prediction indices be merged.
Each group may have 1 or more elements.
"""
if predictions.shape[1] == 5:
return _group_overlapping_boxes(predictions, iou_threshold, overlap_metric)
category_ids = predictions[:, 5]
merge_groups = []
for category_id in np.unique(category_ids):
curr_indices = np.where(category_ids == category_id)[0]
merge_class_groups = _group_overlapping_boxes(
predictions[curr_indices], iou_threshold, overlap_metric
def group_within(global_indices: npt.NDArray[np.int_]) -> list[list[int]]:
return _group_overlapping_boxes(
predictions[global_indices], iou_threshold, overlap_metric
)
for merge_class_group in merge_class_groups:
merge_groups.append(curr_indices[merge_class_group].tolist())
return _non_max_merge_per_category(predictions, group_within)
for merge_group in merge_groups:
if len(merge_group) == 0:
def oriented_box_non_max_suppression(
predictions: npt.NDArray[np.floating],
oriented_boxes: npt.NDArray[np.floating],
iou_threshold: float = 0.5,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> npt.NDArray[np.bool_]:
"""
Perform Non-Maximum Suppression on oriented bounding box predictions.
Overlap is computed via :func:`oriented_box_iou_batch` on the four
corners of each box, so detections whose axis-aligned bounding boxes
overlap heavily but whose oriented bodies do not are kept — unlike
:func:`box_non_max_suppression`, which would suppress them.
Args:
predictions: An array of object detection predictions in the
format ``(x_min, y_min, x_max, y_max, score)`` or
``(x_min, y_min, x_max, y_max, score, class)``. Shape ``(N, 5)``
or ``(N, 6)``. Only the score (column 4) and optional class
(column 5) are read; the axis-aligned coordinates are not used.
oriented_boxes: Array of shape ``(N, 4, 2)`` containing the four
``(x, y)`` corners of each oriented box, aligned with
``predictions`` row-by-row.
iou_threshold: The intersection-over-union threshold to use for
non-maximum suppression.
overlap_metric: Metric used to compute the degree of overlap
between pairs of oriented boxes (e.g., IoU, IoS).
Returns:
A boolean array of shape ``(N,)`` indicating which predictions
to keep after non-maximum suppression.
Raises:
AssertionError: If ``iou_threshold`` is not within the closed
range from 0 to 1.
ValueError: If ``predictions`` and ``oriented_boxes`` have
mismatched lengths or invalid shapes.
Examples:
>>> import numpy as np
>>> import supervision as sv
>>> oriented_boxes = np.array([
... [[10, 10], [50, 10], [50, 30], [10, 30]],
... [[11, 11], [51, 11], [51, 31], [11, 31]],
... ], dtype=np.float32)
>>> predictions = np.array([
... [10, 10, 50, 30, 0.9, 0],
... [11, 11, 51, 31, 0.8, 0],
... ], dtype=np.float32)
>>> keep = sv.oriented_box_non_max_suppression(
... predictions=predictions,
... oriented_boxes=oriented_boxes,
... iou_threshold=0.5,
... )
>>> keep
array([ True, False])
"""
assert 0 <= iou_threshold <= 1, (
"Value of `iou_threshold` must be in the closed range from 0 to 1, "
f"{iou_threshold} given."
)
for name, arr in (("predictions", predictions), ("oriented_boxes", oriented_boxes)):
if name == "predictions":
if arr.ndim != 2 or arr.shape[1] not in (5, 6):
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 5) or (N, 6)."
)
continue
if arr.ndim == 3 and arr.shape[1:] != (4, 2):
raise ValueError(
f"Empty group detected when non-max-merging detections: {merge_groups}"
f"`{name}` has shape {arr.shape}; expected (N, 4, 2) "
f"— each box must have exactly 4 corners with (x, y) coordinates."
)
return merge_groups
elif arr.ndim == 2 and arr.shape[1] != 8:
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 8) for flat "
f"YOLO format or (N, 4, 2) for corner format."
)
elif arr.ndim not in (2, 3):
raise ValueError(
f"`{name}` must be 2-D (N, 8) or 3-D (N, 4, 2), got shape {arr.shape}."
)
if len(predictions) != len(oriented_boxes):
raise ValueError(
f"`predictions` and `oriented_boxes` must have the same length, "
f"got {len(predictions)} and {len(oriented_boxes)}."
)
sort_index, _, categories = _prepare_predictions_for_nms(predictions)
oriented_boxes = oriented_boxes[sort_index]
# same object intentional — triggers upper-triangle optimization
ious = oriented_box_iou_batch(oriented_boxes, oriented_boxes, overlap_metric)
keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold)
return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()])
def _group_overlapping_oriented_boxes(
predictions: npt.NDArray[np.floating],
oriented_boxes: npt.NDArray[np.floating],
iou_threshold: float = 0.5,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> list[list[int]]:
"""
Greedy non-maximum merging on oriented boxes. Mirrors
:func:`_group_overlapping_boxes` but uses :func:`oriented_box_iou_batch`.
"""
def iou_against_candidate(
order: npt.NDArray[np.int_], idx: int
) -> npt.NDArray[np.floating]:
return oriented_box_iou_batch(
oriented_boxes[order],
oriented_boxes[idx][None, ...],
overlap_metric,
).flatten()
return _greedy_nmm_via_iou_callback(
predictions, iou_against_candidate, iou_threshold
)
def oriented_box_non_max_merge(
predictions: npt.NDArray[np.floating],
oriented_boxes: npt.NDArray[np.floating],
iou_threshold: float = 0.5,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> list[list[int]]:
"""
Perform Non-Maximum Merging on oriented bounding box predictions,
grouped per category.
Mirrors :func:`box_non_max_merge` but uses oriented-box IoU, so groups
of rotated detections sharing the same body — rather than the same
axis-aligned bounding box — are merged.
Args:
predictions: An array of shape ``(n, 5)`` or ``(n, 6)`` containing
the axis-aligned coordinates ``[x1, y1, x2, y2]``, confidence
scores, and optionally class ids. Only the score and optional
class are used by the grouping logic; overlap is computed on
``oriented_boxes``.
oriented_boxes: Array of shape ``(N, 4, 2)`` containing the four
``(x, y)`` corners of each oriented box.
iou_threshold: The intersection-over-union threshold to use for
non-maximum merging.
overlap_metric: Metric used to compute the degree of overlap
between pairs of oriented boxes (e.g., IoU, IoS).
Returns:
Groups of prediction indices to be merged. Each group may have 1
or more elements.
Raises:
AssertionError: If ``iou_threshold`` is not within the closed
range from 0 to 1.
ValueError: If ``predictions`` and ``oriented_boxes`` have
mismatched lengths or invalid shapes.
Examples:
>>> import numpy as np
>>> import supervision as sv
>>> oriented_boxes = np.array([
... [[10, 10], [50, 10], [50, 30], [10, 30]],
... [[11, 11], [51, 11], [51, 31], [11, 31]],
... ], dtype=np.float32)
>>> predictions = np.array([
... [10, 10, 50, 30, 0.9, 0],
... [11, 11, 51, 31, 0.8, 0],
... ], dtype=np.float32)
>>> groups = sv.oriented_box_non_max_merge(
... predictions=predictions,
... oriented_boxes=oriented_boxes,
... iou_threshold=0.5,
... )
>>> len(groups)
1
"""
for name, arr in (("predictions", predictions), ("oriented_boxes", oriented_boxes)):
if name == "predictions":
if arr.ndim != 2 or arr.shape[1] not in (5, 6):
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 5) or (N, 6)."
)
continue
if arr.ndim == 3 and arr.shape[1:] != (4, 2):
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 4, 2) "
f"— each box must have exactly 4 corners with (x, y) coordinates."
)
elif arr.ndim == 2 and arr.shape[1] != 8:
raise ValueError(
f"`{name}` has shape {arr.shape}; expected (N, 8) for flat "
f"YOLO format or (N, 4, 2) for corner format."
)
elif arr.ndim not in (2, 3):
raise ValueError(
f"`{name}` must be 2-D (N, 8) or 3-D (N, 4, 2), got shape {arr.shape}."
)
if len(predictions) != len(oriented_boxes):
raise ValueError(
f"`predictions` and `oriented_boxes` must have the same length, "
f"got {len(predictions)} and {len(oriented_boxes)}."
)
assert 0 <= iou_threshold <= 1, (
"Value of `iou_threshold` must be in the closed range from 0 to 1, "
f"{iou_threshold} given."
)
def group_within(global_indices: npt.NDArray[np.int_]) -> list[list[int]]:
return _group_overlapping_oriented_boxes(
predictions[global_indices],
oriented_boxes[global_indices],
iou_threshold,
overlap_metric,
)
return _non_max_merge_per_category(predictions, group_within)
+40 -13
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import cast
import cv2
import numpy as np
import numpy.typing as npt
@@ -57,19 +55,47 @@ def approximate_polygon(
the `x`, `y` coordinates of the input polygon's points.
percentage: The percentage of points to be removed from the
input polygon, in the range `[0, 1)`.
epsilon_step: Approximation accuracy step.
epsilon_step: Approximation accuracy step, must be positive.
Epsilon is the maximum distance between the original curve
and its approximation.
Returns:
A new 2D NumPy array of shape `(M, 2)`,
where `M <= N * (1 - percentage)`, containing
the `x`, `y` coordinates of the
approximated polygon's points.
A new 2D NumPy array of shape `(M, 2)` containing the `x`, `y`
coordinates of the approximated polygon's points. `M` is at most
`floor(N * (1 - percentage))` (minimum 3). Because epsilon
advances in discrete `epsilon_step` increments, `M` may be
noticeably smaller than the budget when a single step crosses
the target band. At least 3 points are always kept; when further
simplification would collapse the polygon below 3 points the last
valid approximation is returned and `M` may exceed the budget.
Raises:
ValueError: If `percentage` is outside the range `[0, 1)`.
ValueError: If `epsilon_step` is not positive.
Examples:
Reduce a polygon to at most half its original point count:
>>> import numpy as np
>>> polygon = np.array([[0, 0], [10, 0], [10, 10], [0, 10],
... [5, 10], [5, 5], [3, 7], [1, 9]])
>>> result = approximate_polygon(polygon, percentage=0.5)
>>> result.shape[1]
2
>>> len(result) <= max(int(len(polygon) * 0.5), 3)
True
Polygon already at or below target — returned unchanged:
>>> tiny = np.array([[0, 0], [5, 0], [2, 4]])
>>> approximate_polygon(tiny, percentage=0.5) is tiny
True
"""
if percentage < 0 or percentage >= 1:
raise ValueError("Percentage must be in the range [0, 1).")
if epsilon_step <= 0:
raise ValueError("epsilon_step must be positive.")
target_points = max(int(len(polygon) * (1 - percentage)), 3)
@@ -78,12 +104,13 @@ def approximate_polygon(
epsilon: float = 0
approximated_points = polygon
while True:
while len(approximated_points) > target_points:
epsilon += epsilon_step
new_approximated_points = cv2.approxPolyDP(polygon, epsilon, closed=True)
if len(new_approximated_points) > target_points:
approximated_points = new_approximated_points
else:
candidate = np.squeeze(cv2.approxPolyDP(polygon, epsilon, closed=True), axis=1)
# Stop before the approximation collapses below a valid polygon; keep the
# last result with at least three points.
if len(candidate) < 3:
break
approximated_points = candidate
return cast(npt.NDArray[np.number], np.squeeze(approximated_points, axis=1))
return approximated_points
+85 -50
View File
@@ -10,12 +10,13 @@ from typing import Any, cast
import numpy as np
import numpy.typing as npt
from deprecate import deprecated, void
from PIL import Image
from supervision.detection.utils.boxes import denormalize_boxes
from supervision.detection.utils.converters import polygon_to_mask, polygon_to_xyxy
from supervision.utils.internal import warn_deprecated
from supervision.validators import validate_resolution
from supervision.validators import _validate_resolution
class LMM(Enum):
@@ -159,7 +160,9 @@ SUPPORTED_TASKS_FLORENCE_2 = [
]
def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any]) -> VLM:
def _validate_vlm_parameters(
vlm: VLM | str, result: Any, kwargs: dict[str, Any]
) -> VLM:
"""
Validates the parameters and result type for a given Vision-Language Model (VLM).
@@ -200,6 +203,15 @@ def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any])
return vlm
@deprecated( # type: ignore[untyped-decorator]
target=_validate_vlm_parameters,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any]) -> VLM:
return void(vlm, result, kwargs) # type: ignore[no-any-return]
def from_paligemma(
result: str, resolution_wh: tuple[int, int], classes: list[str] | None = None
) -> tuple[npt.NDArray[Any], npt.NDArray[Any] | None, npt.NDArray[Any]]:
@@ -220,29 +232,29 @@ def from_paligemma(
is an array of shape `(n,)` with class labels.
"""
w, h = validate_resolution(resolution_wh)
w, h = _validate_resolution(resolution_wh)
pattern = re.compile(
r"(?<!<loc\d{4}>)<loc(\d{4})><loc(\d{4})><loc(\d{4})><loc(\d{4})> ([\w\s\-]+)"
)
matches = pattern.findall(result)
matches = np.array(matches) if matches else np.empty((0, 5))
matches_arr: npt.NDArray[Any] = np.array(matches) if matches else np.empty((0, 5))
if matches.shape[0] == 0:
return np.empty((0, 4)), None, np.empty(0, dtype=str)
if matches_arr.shape[0] == 0:
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)
xyxy, class_name = matches[:, [1, 0, 3, 2]], matches[:, 4]
xyxy = xyxy.astype(int) / 1024 * np.array([w, h, w, h])
class_name = np.char.strip(class_name.astype(str))
class_id = None
xyxy_arr = np.array(matches_arr[:, [1, 0, 3, 2]], dtype=float)
xyxy_arr = xyxy_arr.astype(int) / 1024 * np.array([w, h, w, h])
class_name = np.char.strip(matches_arr[:, 4].astype(str))
class_id: npt.NDArray[Any] | None = None
if classes is not None:
mask = np.array([name in classes for name in class_name], dtype=bool)
xyxy = xyxy[mask]
xyxy_arr = xyxy_arr[mask]
class_name = class_name[mask]
class_id = np.array([classes.index(name) for name in class_name])
return xyxy, class_id, class_name
return xyxy_arr, class_id, class_name
def recover_truncated_qwen_2_5_vl_response(text: str) -> Any | None:
@@ -322,8 +334,8 @@ def from_qwen_2_5_vl(
`class_name` is an array of shape `(N,)` with class names.
"""
in_w, in_h = validate_resolution(input_wh)
out_w, out_h = validate_resolution(resolution_wh)
in_w, in_h = _validate_resolution(input_wh)
out_w, out_h = _validate_resolution(resolution_wh)
text = result.strip()
text = re.sub(r"^```(json)?", "", text, flags=re.IGNORECASE).strip()
@@ -357,7 +369,7 @@ def from_qwen_2_5_vl(
labels_list = []
for item in data:
if "bbox_2d" not in item or "label" not in item:
if not isinstance(item, dict) or "bbox_2d" not in item or "label" not in item:
continue
boxes_list.append(item["bbox_2d"])
labels_list.append(item["label"])
@@ -448,12 +460,13 @@ def from_deepseek_vl_2(
f"and det tags ({len(detection_segments)}) in the result must be equal."
)
xyxy, class_name_list = [], []
xyxy_list: list[list[float]] = []
class_name_list: list[str] = []
for label, detection_blob in zip(label_segments, detection_segments):
current_class_name = label.strip()
for box in re.findall(r"\[(.*?)\]", detection_blob):
x1, y1, x2, y2 = map(float, box.strip("[]").split(","))
xyxy.append(
xyxy_list.append(
[
(x1 / 999 * width),
(y1 / 999 * height),
@@ -463,7 +476,7 @@ def from_deepseek_vl_2(
)
class_name_list.append(current_class_name)
xyxy = np.array(xyxy, dtype=np.float32)
xyxy = np.array(xyxy_list, dtype=np.float32)
class_name = np.array(class_name_list)
if classes is not None:
@@ -529,15 +542,15 @@ def from_florence_2(
return xyxy, labels, None, xyxyxyxy
if task in ["<REFERRING_EXPRESSION_SEGMENTATION>", "<REGION_TO_SEGMENTATION>"]:
xyxy_list = []
masks_list = []
xyxy_list: list[npt.NDArray[Any]] = []
masks_list: list[npt.NDArray[Any]] = []
for polygons_of_same_class in result["polygons"]:
for polygon in polygons_of_same_class:
polygon = np.reshape(polygon, (-1, 2)).astype(np.int32)
mask = polygon_to_mask(polygon, resolution_wh).astype(bool)
masks_list.append(mask)
xyxy = polygon_to_xyxy(polygon)
xyxy_list.append(xyxy)
xyxy_box = polygon_to_xyxy(polygon)
xyxy_list.append(xyxy_box)
# per-class labels also provided, but they are ["", "", "", ...]
# when we figure out how to set class names, we can do
# zip(result["labels"], result["polygons"])
@@ -565,7 +578,7 @@ def from_florence_2(
f"Expected string to end in location tags, but got {result}"
)
w, h = validate_resolution(resolution_wh)
w, h = _validate_resolution(resolution_wh)
xyxy = np.array([match.groups()], dtype=np.float32)
xyxy *= np.array([w, h, w, h]) / 1000
result_string = result[: match.start()]
@@ -614,7 +627,7 @@ def from_google_gemini_2_0(
"""
w, h = validate_resolution(resolution_wh)
w, h = _validate_resolution(resolution_wh)
lines = result.splitlines()
for i, line in enumerate(lines):
@@ -626,13 +639,16 @@ def from_google_gemini_2_0(
try:
data = json.loads(result)
except json.JSONDecodeError:
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
if not isinstance(data, list):
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
labels = []
xyxy = []
for item in data:
if "box_2d" not in item or "label" not in item:
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
continue
labels.append(item["label"])
box = item["box_2d"]
@@ -640,7 +656,7 @@ def from_google_gemini_2_0(
xyxy.append([box[1], box[0], box[3], box[2]])
if len(xyxy) == 0:
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
xyxy = denormalize_boxes(
np.array(xyxy, dtype=np.float64),
@@ -702,7 +718,7 @@ def from_google_gemini_2_5(
scores, and `masks` is an optional array of shape `(n, h, w)` with
segmentation masks.
"""
w, h = validate_resolution(resolution_wh)
w, h = _validate_resolution(resolution_wh)
lines = result.splitlines()
for i, line in enumerate(lines):
@@ -722,13 +738,22 @@ def from_google_gemini_2_5(
None,
)
if not isinstance(data, list):
return (
np.empty((0, 4)),
np.array([], dtype=int),
np.array([], dtype=str),
np.array([], dtype=float),
None,
)
boxes_list: list[Any] = []
labels_list: list[str] = []
confidence_list: list[float] | None = []
masks_list: list[npt.NDArray[Any]] | None = []
for item in data:
if "box_2d" not in item or "label" not in item:
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
continue
labels_list.append(item["label"])
box = item["box_2d"]
@@ -743,29 +768,39 @@ def from_google_gemini_2_5(
if "mask" in item:
if masks_list is not None:
png_str = item["mask"]
if not png_str.startswith("data:image/png;base64,"):
if not isinstance(png_str, str) or not png_str.startswith(
"data:image/png;base64,"
):
# Malformed mask: keep an empty mask but still fall through to
# the confidence handling below, so the per-item arrays stay
# aligned (a `continue` here desynced confidence vs boxes).
masks_list.append(np.zeros((h, w), dtype=bool))
continue
png_str = png_str.removeprefix("data:image/png;base64,")
png_str = base64.b64decode(png_str)
mask_img = Image.open(io.BytesIO(png_str))
y_min, y_max = int(absolute_bbox[1]), int(absolute_bbox[3])
x_min, x_max = int(absolute_bbox[0]), int(absolute_bbox[2])
bbox_height = y_max - y_min
bbox_width = x_max - x_min
if bbox_height > 0 and bbox_width > 0:
mask_img = mask_img.resize(
(bbox_width, bbox_height), resample=Image.Resampling.BILINEAR
)
np_mask: npt.NDArray[np.bool_] = np.zeros((h, w), dtype=bool)
np_mask[y_min:y_max, x_min:x_max] = np.array(mask_img) > 0
masks_list.append(np_mask)
else:
masks_list.append(np.zeros((h, w), dtype=bool))
png_str = png_str.removeprefix("data:image/png;base64,")
try:
png_bytes = base64.b64decode(png_str)
mask_img = Image.open(io.BytesIO(png_bytes)).convert("L")
except Exception:
masks_list.append(np.zeros((h, w), dtype=bool))
else:
y_min, y_max = int(absolute_bbox[1]), int(absolute_bbox[3])
x_min, x_max = int(absolute_bbox[0]), int(absolute_bbox[2])
bbox_height = y_max - y_min
bbox_width = x_max - x_min
if bbox_height > 0 and bbox_width > 0:
mask_img = mask_img.resize(
(bbox_width, bbox_height),
resample=Image.Resampling.BILINEAR,
)
np_mask: npt.NDArray[np.bool_] = np.zeros(
(h, w), dtype=bool
)
np_mask[y_min:y_max, x_min:x_max] = np.array(mask_img) > 0
masks_list.append(np_mask)
else:
masks_list.append(np.zeros((h, w), dtype=bool))
else:
masks_list = None
+682 -162
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any
from typing import cast
import cv2
import numpy as np
@@ -51,7 +51,8 @@ class VertexAnnotator(BaseKeyPointAnnotator):
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
"""
Annotates the given scene with skeleton vertices based on the provided key
points. It draws circles at each key point location.
points. It draws circles at each key point location. Anchors marked as
not visible via ``key_points.visible`` are skipped.
Args:
scene: The image where skeleton vertices will be drawn. `ImageType` is a
@@ -67,20 +68,19 @@ class VertexAnnotator(BaseKeyPointAnnotator):
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((100, 100, 3), dtype=np.uint8)
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array([[[50, 50], [60, 60]]], dtype=np.float32)
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]]],
... dtype=np.float32,
... ),
... class_id=np.array([0]),
... visible=np.array([[True, True, True]]),
... )
>>> vertex_annotator = sv.VertexAnnotator(
... color=sv.Color.GREEN,
... radius=10
>>> annotator = sv.VertexAnnotator(
... color=sv.Color.ROBOFLOW, radius=10
... )
>>> annotated_frame = vertex_annotator.annotate(
... scene=image.copy(),
... key_points=key_points
... )
>>> annotated_frame.shape
(100, 100, 3)
>>> result = annotator.annotate(image.copy(), key_points)
```
"""
@@ -88,8 +88,15 @@ class VertexAnnotator(BaseKeyPointAnnotator):
if len(key_points) == 0:
return scene
for xy in key_points.xy:
for x, y in xy:
for detection_index, xy in enumerate(key_points.xy):
for point_index, (x, y) in enumerate(xy):
if np.allclose((x, y), 0):
continue
if (
key_points.visible is not None
and not key_points.visible[detection_index, point_index]
):
continue
cv2.circle(
img=scene,
center=(int(x), int(y)),
@@ -111,14 +118,20 @@ class EdgeAnnotator(BaseKeyPointAnnotator):
self,
color: Color = Color.ROBOFLOW,
thickness: int = 2,
edges: Sequence[tuple[int, int]] | None = None,
edges: (
Sequence[tuple[int, int]] | dict[int, Sequence[tuple[int, int]]] | None
) = None,
) -> None:
"""
Args:
color: The color to use for the edges.
thickness: The thickness of the edges.
edges: The edges to draw. If set to `None`, will attempt to select
automatically.
edges: The edges to draw. If set to ``None``, will attempt to
auto-detect the skeleton by vertex count. A
``Sequence[tuple[int, int]]`` applies a single skeleton to
every instance. A ``dict[int, Sequence[tuple[int, int]]]``
maps ``class_id`` to skeleton edges, enabling correct
rendering for datasets with multiple skeleton types.
"""
self.color = color
self.thickness = thickness
@@ -128,7 +141,8 @@ class EdgeAnnotator(BaseKeyPointAnnotator):
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
"""
Annotates the given scene by drawing lines between specified key points to form
edges.
edges. Edges where either endpoint is marked as not visible via
``key_points.visible`` are skipped.
Args:
scene: The image where skeleton edges will be drawn. `ImageType` is a
@@ -141,23 +155,53 @@ class EdgeAnnotator(BaseKeyPointAnnotator):
or `PIL.Image.Image`)
Example:
Single-skeleton example:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((100, 100, 3), dtype=np.uint8)
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array([[[50, 50], [60, 60]]], dtype=np.float32)
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]]],
... dtype=np.float32,
... ),
... class_id=np.array([0]),
... visible=np.array([[True, True, True]]),
... )
>>> edge_annotator = sv.EdgeAnnotator(
... color=sv.Color.GREEN,
... thickness=5
>>> annotator = sv.EdgeAnnotator(
... color=sv.Color.ROBOFLOW,
... thickness=3,
... edges=[(1, 2), (1, 3)],
... )
>>> annotated_frame = edge_annotator.annotate(
... scene=image.copy(),
... key_points=key_points
>>> result = annotator.annotate(image.copy(), key_points)
```
Multi-skeleton example with per-class edges:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]],
... [[700, 300], [650, 500], [0, 0]]],
... dtype=np.float32,
... ),
... class_id=np.array([0, 1]),
... visible=np.array(
... [[True, True, True],
... [True, True, False]],
... ),
... )
>>> annotated_frame.shape
(100, 100, 3)
>>> annotator = sv.EdgeAnnotator(
... color=sv.Color.ROBOFLOW,
... thickness=3,
... edges={0: [(1, 2), (1, 3)], 1: [(1, 2)]},
... )
>>> result = annotator.annotate(image.copy(), key_points)
```
"""
@@ -165,21 +209,43 @@ class EdgeAnnotator(BaseKeyPointAnnotator):
if len(key_points) == 0:
return scene
for xy in key_points.xy:
edges = self.edges
if not edges:
edges = SKELETONS_BY_VERTEX_COUNT.get(len(xy))
if not edges:
logger.warning("No skeleton found with %d vertices", len(xy))
return scene
for detection_index, xy in enumerate(key_points.xy):
if isinstance(self.edges, dict):
class_id = (
int(key_points.class_id[detection_index])
if key_points.class_id is not None
else None
)
if class_id is None:
raise ValueError(
"edges is a dict but class_id is None; "
"KeyPoints must have class_id set."
)
if class_id not in self.edges:
raise ValueError(f"No edges defined for class_id={class_id}.")
edges = self.edges[class_id]
elif self.edges:
edges = self.edges
else:
_looked_up = SKELETONS_BY_VERTEX_COUNT.get(len(xy))
if not _looked_up:
logger.warning("No skeleton found with %d vertices", len(xy))
continue
edges = _looked_up
for class_a, class_b in edges:
xy_a = xy[class_a - 1]
xy_b = xy[class_b - 1]
missing_a = np.allclose(xy_a, 0)
missing_b = np.allclose(xy_b, 0)
if missing_a or missing_b:
idx_a = class_a - 1
idx_b = class_b - 1
xy_a = xy[idx_a]
xy_b = xy[idx_b]
if np.allclose(xy_a, 0) or np.allclose(xy_b, 0):
continue
if key_points.visible is not None:
if (
not key_points.visible[detection_index, idx_a]
or not key_points.visible[detection_index, idx_b]
):
continue
cv2.line(
img=scene,
@@ -192,6 +258,448 @@ class EdgeAnnotator(BaseKeyPointAnnotator):
return scene
class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
"""Private base for ellipse-based keypoint annotators.
Handles sigma/color validation, sorting, covariance extraction and
eigendecomposition shared by all VertexEllipse* variants.
"""
def __init__(
self,
sigma: float | Sequence[float] = (1.0, 2.0, 3.0),
color: Color | Sequence[Color] = (Color.GREEN, Color.YELLOW, Color.RED),
max_axis: float | None = None,
) -> None:
sigma_seq: Sequence[float] = (
(sigma,) if isinstance(sigma, (int, float)) else sigma
)
color_seq: Sequence[Color] = (color,) if isinstance(color, Color) else color
if len(sigma_seq) == 0:
raise ValueError("sigma must contain at least one value")
if any(s <= 0 for s in sigma_seq):
raise ValueError("All sigma values must be positive")
if max_axis is not None and max_axis <= 0:
raise ValueError("max_axis must be positive when provided")
if len(color_seq) != len(sigma_seq):
raise ValueError(
f"color length ({len(color_seq)}) must match "
f"sigma length ({len(sigma_seq)})"
)
sorted_indices = sorted(
range(len(sigma_seq)), key=lambda i: sigma_seq[i], reverse=True
)
self.sigma = [sigma_seq[i] for i in sorted_indices]
self.color = [color_seq[i] for i in sorted_indices]
self.max_axis = max_axis
def _get_covariances(self, key_points: KeyPoints) -> npt.NDArray[np.float32]:
covariances = key_points.data.get("covariance")
if covariances is None:
raise ValueError(
"key_points.data must contain 'covariance' with shape (N, K, 2, 2)."
)
covariances_array = cast(
npt.NDArray[np.float32], np.asarray(covariances, dtype=np.float32)
)
expected_shape = (*key_points.xy.shape[:2], 2, 2)
if covariances_array.shape != expected_shape:
raise ValueError(
f"Expected covariance shape {expected_shape}, "
f"got {covariances_array.shape}."
)
return covariances_array
def _decompose_covariance(
self, covariance: npt.NDArray[np.float32]
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]] | None:
"""Eigendecompose a 2x2 covariance, returning sorted (eigenvalues, vectors)."""
if not np.isfinite(covariance).all():
return None
try:
eigenvalues, eigenvectors = np.linalg.eigh(covariance.astype(np.float64))
except np.linalg.LinAlgError:
return None
if not np.isfinite(eigenvalues).all() or np.any(eigenvalues <= 0):
return None
order = np.argsort(eigenvalues)[::-1]
return eigenvalues[order], eigenvectors[:, order]
def _iter_ellipse_params(
self, key_points: KeyPoints
) -> list[list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]]:
"""Return ellipse params grouped by sigma level (outermost first)."""
covariances = self._get_covariances(key_points)
levels: list[
list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]
] = [[] for _ in self.sigma]
for detection_index, xy in enumerate(key_points.xy):
for point_index, (x, y) in enumerate(xy):
if np.allclose((x, y), 0):
continue
if (
key_points.visible is not None
and not key_points.visible[detection_index, point_index]
):
continue
covariance = covariances[detection_index, point_index]
decomposition = self._decompose_covariance(covariance)
if decomposition is None:
continue
eigenvalues, eigenvectors = decomposition
angle = float(
np.degrees(np.arctan2(eigenvectors[1, 0], eigenvectors[0, 0]))
)
center = (round(x), round(y))
for level_idx, (sigma, color) in enumerate(zip(self.sigma, self.color)):
axes = sigma * np.sqrt(eigenvalues)
if self.max_axis is not None:
axes = np.minimum(axes, self.max_axis)
axis_lengths = (
max(1, round(axes[0])),
max(1, round(axes[1])),
)
levels[level_idx].append(
(center, axis_lengths, angle, sigma, color)
)
return levels
class VertexEllipseAreaAnnotator(_BaseVertexEllipseAnnotator):
"""
Draws filled semi-transparent covariance ellipses at multiple sigma levels
around each keypoint, each ring in a different color. This produces a
bullseye-like uncertainty visualization where inner rings represent higher
probability density.
!!! warning
This annotator uses `key_points.data["covariance"]` with shape
`(N, K, 2, 2)` in pixel coordinates.
"""
def __init__(
self,
sigma: float | Sequence[float] = (1.0, 2.0, 3.0),
color: Color | Sequence[Color] = (Color.GREEN, Color.YELLOW, Color.RED),
opacity: float = 0.4,
max_axis: float | None = None,
) -> None:
"""
Args:
sigma: Sigma multipliers for each ring, drawn from outermost to
innermost. Accepts a single float or a sequence of floats.
Defaults to ``(1.0, 2.0, 3.0)``.
color: The color for each sigma level. Accepts a single
``Color`` or a sequence of colors (one per sigma level).
Defaults to ``(Color.GREEN, Color.YELLOW, Color.RED)``.
opacity: Opacity of the overlay mask. Must be between ``0`` and
``1``.
max_axis: Optional cap for ellipse semi-axis lengths in pixels.
"""
super().__init__(sigma=sigma, color=color, max_axis=max_axis)
self.opacity = opacity
@ensure_cv2_image_for_class_method
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
"""
Draws filled semi-transparent covariance ellipses around each keypoint.
Args:
scene: The image to annotate. ``ImageType`` accepts either
``numpy.ndarray`` or ``PIL.Image.Image``.
key_points: Key points with covariance data in
``key_points.data["covariance"]``.
Returns:
The annotated image, matching the type of ``scene``.
Example:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]]],
... dtype=np.float32,
... ),
... class_id=np.array([0]),
... visible=np.array([[True, True, True]]),
... data={
... "covariance": np.array(
... [[[[800, 0], [0, 400]],
... [[400, 0], [0, 800]],
... [[600, 0], [0, 600]]]],
... dtype=np.float32,
... )
... },
... )
>>> annotator = sv.VertexEllipseAreaAnnotator(
... sigma=[1.0, 2.0],
... color=[sv.Color.GREEN, sv.Color.RED],
... )
>>> result = annotator.annotate(image.copy(), key_points)
```
"""
assert isinstance(scene, np.ndarray)
if len(key_points) == 0:
return scene
overlay = scene.copy()
for level in self._iter_ellipse_params(key_points):
for center, axis_lengths, angle, _sigma, color in level:
cv2.ellipse(
img=overlay,
center=center,
axes=axis_lengths,
angle=angle,
startAngle=0,
endAngle=360,
color=color.as_bgr(),
thickness=-1,
lineType=cv2.LINE_AA,
)
cv2.addWeighted(overlay, self.opacity, scene, 1 - self.opacity, 0, dst=scene)
return scene
class VertexEllipseOutlineAnnotator(_BaseVertexEllipseAnnotator):
"""
Draws stroke-only concentric covariance ellipse rings at multiple sigma
levels around each keypoint.
!!! warning
This annotator uses `key_points.data["covariance"]` with shape
`(N, K, 2, 2)` in pixel coordinates.
"""
def __init__(
self,
sigma: float | Sequence[float] = (1.0, 2.0, 3.0),
color: Color | Sequence[Color] = (Color.GREEN, Color.YELLOW, Color.RED),
thickness: int = 2,
max_axis: float | None = None,
) -> None:
"""
Args:
sigma: Sigma multipliers for each ring, drawn from outermost to
innermost. Accepts a single float or a sequence of floats.
Defaults to ``(1.0, 2.0, 3.0)``.
color: The color for each sigma level. Accepts a single
``Color`` or a sequence of colors (one per sigma level).
Defaults to ``(Color.GREEN, Color.YELLOW, Color.RED)``.
thickness: Line thickness of the ellipse outlines.
max_axis: Optional cap for ellipse semi-axis lengths in pixels.
"""
super().__init__(sigma=sigma, color=color, max_axis=max_axis)
self.thickness = thickness
@ensure_cv2_image_for_class_method
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
"""
Draws stroke-only covariance ellipse outlines around each keypoint.
Args:
scene: The image to annotate. ``ImageType`` accepts either
``numpy.ndarray`` or ``PIL.Image.Image``.
key_points: Key points with covariance data in
``key_points.data["covariance"]``.
Returns:
The annotated image, matching the type of ``scene``.
Example:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]]],
... dtype=np.float32,
... ),
... class_id=np.array([0]),
... visible=np.array([[True, True, True]]),
... data={
... "covariance": np.array(
... [[[[800, 0], [0, 400]],
... [[400, 0], [0, 800]],
... [[600, 0], [0, 600]]]],
... dtype=np.float32,
... )
... },
... )
>>> annotator = sv.VertexEllipseOutlineAnnotator(
... sigma=[1.0, 2.0],
... color=[sv.Color.GREEN, sv.Color.RED],
... thickness=2,
... )
>>> result = annotator.annotate(image.copy(), key_points)
```
"""
assert isinstance(scene, np.ndarray)
if len(key_points) == 0:
return scene
for level in self._iter_ellipse_params(key_points):
for center, axis_lengths, angle, _sigma, color in level:
cv2.ellipse(
img=scene,
center=center,
axes=axis_lengths,
angle=angle,
startAngle=0,
endAngle=360,
color=color.as_bgr(),
thickness=self.thickness,
lineType=cv2.LINE_AA,
)
return scene
class VertexEllipseHaloAnnotator(_BaseVertexEllipseAnnotator):
"""
Draws filled covariance ellipses with a radial fade: full opacity at the
center, smoothly falling off to zero at the ellipse boundary. The falloff
follows a power curve controlled by ``decay``, producing a soft glow that
is strongest near the keypoint.
!!! warning
This annotator uses `key_points.data["covariance"]` with shape
`(N, K, 2, 2)` in pixel coordinates.
"""
_DECAY: float = 2.0
def __init__(
self,
sigma: float | Sequence[float] = (1.0, 2.0, 3.0),
color: Color | Sequence[Color] = (Color.GREEN, Color.YELLOW, Color.RED),
opacity: float = 0.6,
max_axis: float | None = None,
) -> None:
"""
Args:
sigma: Sigma multipliers for each ring, drawn from outermost to
innermost. Accepts a single float or a sequence of floats.
Defaults to ``(1.0, 2.0, 3.0)``.
color: The color for each sigma level. Accepts a single
``Color`` or a sequence of colors (one per sigma level).
Defaults to ``(Color.GREEN, Color.YELLOW, Color.RED)``.
opacity: Peak opacity at the ellipse center. Must be between ``0``
and ``1``.
max_axis: Optional cap for ellipse semi-axis lengths in pixels.
"""
super().__init__(sigma=sigma, color=color, max_axis=max_axis)
self.opacity = opacity
@ensure_cv2_image_for_class_method
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
"""
Draws radially-fading covariance ellipses around each keypoint.
Args:
scene: The image to annotate. ``ImageType`` accepts either
``numpy.ndarray`` or ``PIL.Image.Image``.
key_points: Key points with covariance data in
``key_points.data["covariance"]``.
Returns:
The annotated image, matching the type of ``scene``.
Example:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]]],
... dtype=np.float32,
... ),
... class_id=np.array([0]),
... visible=np.array([[True, True, True]]),
... data={
... "covariance": np.array(
... [[[[800, 0], [0, 400]],
... [[400, 0], [0, 800]],
... [[600, 0], [0, 600]]]],
... dtype=np.float32,
... )
... },
... )
>>> annotator = sv.VertexEllipseHaloAnnotator(
... sigma=[1.0, 2.0],
... color=[sv.Color.GREEN, sv.Color.RED],
... )
>>> result = annotator.annotate(image.copy(), key_points)
```
"""
assert isinstance(scene, np.ndarray)
if len(key_points) == 0:
return scene
h, w = scene.shape[:2]
composite: npt.NDArray[np.float32] = scene.astype(np.float32)
for level in self._iter_ellipse_params(key_points):
for center, axis_lengths, angle, _sigma, color in level:
ax, ay = axis_lengths
if ax == 0 or ay == 0:
continue
pad = 2
roi_half_w = ax + pad
roi_half_h = ay + pad
cx, cy = center
x_min = max(cx - roi_half_w, 0)
x_max = min(cx + roi_half_w, w)
y_min = max(cy - roi_half_h, 0)
y_max = min(cy + roi_half_h, h)
if x_min >= x_max or y_min >= y_max:
continue
ys = np.arange(y_min, y_max, dtype=np.float32) - cy
xs = np.arange(x_min, x_max, dtype=np.float32) - cx
grid_x, grid_y = np.meshgrid(xs, ys)
angle_rad = np.radians(-angle)
cos_a = np.cos(angle_rad)
sin_a = np.sin(angle_rad)
rx = grid_x * cos_a - grid_y * sin_a
ry = grid_x * sin_a + grid_y * cos_a
dist_sq = (rx / ax) ** 2 + (ry / ay) ** 2
inside = dist_sq <= 1.0
falloff = np.zeros_like(dist_sq)
falloff[inside] = (1.0 - dist_sq[inside]) ** self._DECAY
scaled_alpha = falloff * self.opacity
bgr = np.array(color.as_bgr(), dtype=np.float32)
roi = composite[y_min:y_max, x_min:x_max]
alpha_3 = scaled_alpha[:, :, np.newaxis]
roi[:] = roi * (1 - alpha_3) + bgr * alpha_3
np.copyto(scene, composite.astype(np.uint8))
return scene
VertexEllipseAnnotator = VertexEllipseAreaAnnotator
class VertexLabelAnnotator:
"""
A class that draws labels of skeleton vertices on images. It uses specified key
@@ -210,15 +718,15 @@ class VertexLabelAnnotator:
):
"""
Args:
color: The color to use for each keypoint label. If a list is provided,
the colors will be used in order for each keypoint.
text_color: The color to use for the labels. If a list is provided, the
colors will be used in order for each keypoint.
color: The color to use for each keypoint label. If a list is
provided, the colors will be used in order for each keypoint.
text_color: The color to use for the labels. If a list is
provided, the colors will be used in order for each keypoint.
text_scale: The scale of the text.
text_thickness: The thickness of the text.
text_padding: The padding around the text.
border_radius: The radius of the rounded corners of the boxes. Set to a
high value to produce circles.
border_radius: The radius of the rounded corners of the boxes.
Set to a high value to produce circles.
smart_position: Spread out the labels to avoid overlap.
"""
self.border_radius: int = border_radius
@@ -233,96 +741,88 @@ class VertexLabelAnnotator:
self,
scene: ImageType,
key_points: KeyPoints,
labels: list[str] | None = None,
labels: list[str] | dict[int, list[str]] | None = None,
) -> ImageType:
"""
A class that draws labels of skeleton vertices on images. It uses specified key
points to determine the locations where the vertices should be drawn.
Draws labels at skeleton vertex positions on the image. Vertices
marked not visible via ``key_points.visible`` are skipped.
Args:
scene: The image where vertex labels will be drawn. `ImageType` is a
flexible type, accepting either `numpy.ndarray` or `PIL.Image.Image`.
key_points: A collection of key points where each key point consists of x
and y coordinates.
labels: A list of labels to be displayed on the annotated image. If not
provided, keypoint indices will be used.
labels: Labels to display at each keypoint. If ``None``, keypoint
indices are used. A ``list[str]`` applies the same labels to
every instance. A ``dict[int, list[str]]`` maps ``class_id``
to per-class label lists, enabling correct labeling for
datasets with multiple skeleton types.
Returns:
The annotated image, matching the type of `scene` (`numpy.ndarray`
or `PIL.Image.Image`)
Example:
Single-skeleton example:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((100, 100, 3), dtype=np.uint8)
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array([[[50, 50], [60, 60]]], dtype=np.float32)
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]]],
... dtype=np.float32,
... ),
... class_id=np.array([0]),
... visible=np.array([[True, True, True]]),
... )
>>> vertex_label_annotator = sv.VertexLabelAnnotator(
... color=sv.Color.GREEN,
... text_color=sv.Color.BLACK,
... border_radius=5
>>> annotator = sv.VertexLabelAnnotator(
... color=sv.Color.ROBOFLOW,
... text_color=sv.Color.WHITE,
... border_radius=5,
... )
>>> annotated_frame = vertex_label_annotator.annotate(
... scene=image.copy(),
... key_points=key_points
... )
>>> annotated_frame.shape
(100, 100, 3)
```
![vertex-label-annotator-example](https://media.roboflow.com/supervision-annotator-examples/vertex-label-annotator-example.png)
!!! tip
`VertexLabelAnnotator` allows to customize the color of each keypoint label
values.
Example:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((100, 100, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array([[[50, 50], [60, 60], [70, 70], [80, 80],
... [90, 90], [40, 40], [30, 30], [20, 20],
... [10, 10], [5, 5], [15, 15], [25, 25],
... [35, 35], [45, 45], [55, 55], [65, 65],
... [75, 75]]], dtype=np.float32)
... )
>>> LABELS = [
... "nose", "left eye", "right eye", "left ear",
... "right ear", "left shoulder", "right shoulder", "left elbow",
... "right elbow", "left wrist", "right wrist", "left hip",
... "right hip", "left knee", "right knee", "left ankle",
... "right ankle"
... ]
>>> COLORS = [
... "#FF6347", "#FF6347", "#FF6347", "#FF6347",
... "#FF6347", "#FF1493", "#00FF00", "#FF1493",
... "#00FF00", "#FF1493", "#00FF00", "#FFD700",
... "#00BFFF", "#FFD700", "#00BFFF", "#FFD700",
... "#00BFFF"
... ]
>>> COLORS = [sv.Color.from_hex(color_hex=c) for c in COLORS]
>>> vertex_label_annotator = sv.VertexLabelAnnotator(
... color=COLORS,
... text_color=sv.Color.BLACK,
... border_radius=5
... )
>>> annotated_frame = vertex_label_annotator.annotate(
>>> result = annotator.annotate(
... scene=image.copy(),
... key_points=key_points,
... labels=LABELS
... labels=["head", "L-foot", "R-foot"],
... )
>>> annotated_frame.shape
(100, 100, 3)
```
![vertex-label-annotator-custom-example](https://media.roboflow.com/supervision-annotator-examples/vertex-label-annotator-custom-example.png)
Multi-skeleton example with per-class labels:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> image = np.zeros((800, 800, 3), dtype=np.uint8)
>>> key_points = sv.KeyPoints(
... xy=np.array(
... [[[400, 200], [300, 500], [500, 500]],
... [[700, 300], [650, 500], [0, 0]]],
... dtype=np.float32,
... ),
... class_id=np.array([0, 1]),
... visible=np.array(
... [[True, True, True],
... [True, True, False]],
... ),
... )
>>> annotator = sv.VertexLabelAnnotator(
... color=sv.Color.ROBOFLOW,
... text_color=sv.Color.WHITE,
... border_radius=5,
... )
>>> result = annotator.annotate(
... scene=image.copy(),
... key_points=key_points,
... labels={
... 0: ["head", "L-foot", "R-foot"],
... 1: ["top", "bottom", "pad"],
... },
... )
```
"""
assert isinstance(scene, np.ndarray)
font = cv2.FONT_HERSHEY_SIMPLEX
@@ -331,33 +831,39 @@ class VertexLabelAnnotator:
if skeletons_count == 0:
return scene
anchors = key_points.xy.reshape(points_count * skeletons_count, 2).astype(int)
mask = np.all(anchors != 0, axis=1)
all_anchors: list[tuple[int, int]] = []
all_labels: list[str] = []
all_colors: list[Color] = []
all_text_colors: list[Color] = []
if not np.any(mask):
for i in range(skeletons_count):
xy = key_points.xy[i]
class_id = (
int(key_points.class_id[i]) if key_points.class_id is not None else None
)
instance_labels = self._resolve_labels(labels, points_count, class_id)
instance_colors = self._resolve_color_list(self.color, points_count)
instance_text_colors = self._resolve_color_list(
self.text_color, points_count
)
for j in range(points_count):
if key_points.visible is not None:
if not key_points.visible[i, j]:
continue
elif np.allclose(xy[j], 0):
continue
anchor = (int(xy[j][0]), int(xy[j][1]))
all_anchors.append(anchor)
all_labels.append(instance_labels[j])
all_colors.append(instance_colors[j])
all_text_colors.append(instance_text_colors[j])
if not all_anchors:
return scene
colors = self.preprocess_and_validate_colors(
colors=self.color,
points_count=points_count,
skeletons_count=skeletons_count,
)
text_colors = self.preprocess_and_validate_colors(
colors=self.text_color,
points_count=points_count,
skeletons_count=skeletons_count,
)
processed_labels = self.preprocess_and_validate_labels(
labels=labels, points_count=points_count, skeletons_count=skeletons_count
)
anchors = anchors[mask]
colors = colors[mask]
text_colors = text_colors[mask]
filtered_labels = processed_labels[mask]
xyxy = np.array(
[
self.get_text_bounding_box(
@@ -365,9 +871,9 @@ class VertexLabelAnnotator:
font=font,
text_scale=self.text_scale,
text_thickness=self.text_thickness,
center_coordinates=tuple(anchor),
center_coordinates=anchor,
)
for anchor, label in zip(anchors, filtered_labels)
for anchor, label in zip(all_anchors, all_labels)
]
)
xyxy_padded = pad_boxes(xyxy=xyxy, px=self.text_padding)
@@ -377,7 +883,7 @@ class VertexLabelAnnotator:
xyxy = pad_boxes(xyxy=xyxy_padded, px=-self.text_padding)
for text, color, text_color, box, box_padded in zip(
filtered_labels, colors, text_colors, xyxy, xyxy_padded
all_labels, all_colors, all_text_colors, xyxy, xyxy_padded
):
draw_rounded_rectangle(
scene=scene,
@@ -421,32 +927,46 @@ class VertexLabelAnnotator:
)
@staticmethod
def preprocess_and_validate_labels(
labels: list[str] | None, points_count: int, skeletons_count: int
) -> npt.NDArray[np.str_]:
if labels and len(labels) != points_count:
raise ValueError(
f"Number of labels ({len(labels)}) must match number of key points "
f"({points_count})."
)
def _resolve_labels(
labels: list[str] | dict[int, list[str]] | None,
points_count: int,
class_id: int | None = None,
) -> list[str]:
"""Return the label list for a single instance."""
if labels is None:
labels = [str(i) for i in range(points_count)]
return [str(j) for j in range(points_count)]
return np.array(labels * skeletons_count)
resolved: list[str]
if isinstance(labels, dict):
if class_id is None:
raise ValueError(
"labels is a dict but class_id is None; "
"KeyPoints must have class_id set."
)
if class_id not in labels:
raise ValueError(f"No labels defined for class_id={class_id}.")
resolved = labels[class_id]
else:
resolved = labels
if len(resolved) != points_count:
raise ValueError(
f"Number of labels ({len(resolved)}) must match "
f"number of key points ({points_count})."
)
return resolved
@staticmethod
def preprocess_and_validate_colors(
colors: Color | list[Color] | None,
def _resolve_color_list(
colors: Color | list[Color],
points_count: int,
skeletons_count: int,
) -> npt.NDArray[Any]:
if isinstance(colors, list) and len(colors) != points_count:
raise ValueError(
f"Number of colors ({len(colors)}) must match number of key points "
f"({points_count})."
)
return (
np.array(colors * skeletons_count)
if isinstance(colors, list)
else np.array([colors] * points_count * skeletons_count)
)
) -> list[Color]:
"""Return a per-keypoint color list for a single instance."""
if isinstance(colors, list):
if len(colors) != points_count:
raise ValueError(
f"Number of colors ({len(colors)}) must match "
f"number of key points ({points_count})."
)
return colors
return [colors] * points_count
+408 -72
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, field
from typing import Any, Union, cast
@@ -10,7 +11,14 @@ import numpy.typing as npt
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
from supervision.detection.utils.internal import get_data_item, is_data_equal
from supervision.validators import validate_key_points_fields
from supervision.detection.utils.iou_and_nms import (
OverlapMetric,
box_non_max_suppression,
)
from supervision.utils.internal import warn_deprecated
from supervision.validators import _validate_keypoints_fields
logger = logging.getLogger(__name__)
Index1D = Union[
int,
@@ -21,9 +29,49 @@ Index1D = Union[
npt.NDArray[np.bool_],
]
Index2D = tuple[Index1D, Index1D]
_RowIndexInput = Union[
int,
np.integer[Any],
npt.NDArray[np.generic],
list[Any],
slice,
]
_NormalizedRowIndex = Union[npt.NDArray[np.generic], list[Any], slice]
@dataclass
def _optional_array_equal(
first: npt.NDArray[np.generic] | None,
second: npt.NDArray[np.generic] | None,
) -> bool:
if first is None or second is None:
return first is None and second is None
return np.array_equal(first, second)
def _normalize_row_index(
i: _RowIndexInput,
) -> _NormalizedRowIndex:
"""Normalise *i* to a 1-D row index for 1-D per-object fields.
Handles:
- Python int or np.integer scalar -> np.array([int(i)])
- boolean np.ndarray (any shape) -> np.flatnonzero(i.ravel())
- non-bool 0-d np.ndarray -> reshaped to shape (1,)
- list of bool -> np.flatnonzero(np.array(i))
- slice, list of ints, 1-D ndarray -> returned as-is
"""
if isinstance(i, (int, np.integer)):
return cast(_NormalizedRowIndex, np.array([int(i)]))
if isinstance(i, np.ndarray) and i.dtype == bool:
return cast(_NormalizedRowIndex, np.flatnonzero(i.ravel()))
if isinstance(i, np.ndarray) and i.ndim == 0:
return cast(_NormalizedRowIndex, i.reshape(1))
if isinstance(i, list) and i and all(isinstance(x, bool) for x in i):
return cast(_NormalizedRowIndex, np.flatnonzero(np.array(i)))
return i
@dataclass(init=False)
class KeyPoints:
"""
The `sv.KeyPoints` class in the Supervision library standardizes results from
@@ -31,6 +79,23 @@ class KeyPoints:
class simplifies data manipulation and filtering, providing a uniform API for
integration with Supervision [keypoints annotators](/latest/keypoint/annotators).
=== "RF-DETR"
[RF-DETR](https://github.com/roboflow/rf-detr) keypoint models return
`sv.KeyPoints` directly from `model.predict()` — no additional
conversion is needed.
```python
import cv2
import supervision as sv
from rfdetr import RFDETRKeypointPreview
image = cv2.imread("<SOURCE_IMAGE_PATH>")
model = RFDETRKeypointPreview()
key_points = model.predict(image)
```
=== "Ultralytics"
Use [`sv.KeyPoints.from_ultralytics`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_ultralytics)
@@ -156,8 +221,14 @@ class KeyPoints:
sets of key points, where each point is `[x, y]`.
class_id: An array of shape
`(n,)` containing the class ids of the detected objects.
confidence: An array of shape
keypoint_confidence: An array of shape
`(n, m)` containing the confidence scores of each keypoint.
detection_confidence: An array of shape
`(n,)` containing the detection-level confidence scores.
visible: An optional boolean array of shape
`(n, m)` indicating which keypoints are visible. When ``None``,
all keypoints are treated as visible. Set this to filter anchors
without removing data: ``key_points.visible = key_points.keypoint_confidence > 0.3``.
data: A dictionary containing additional
data where each key is a string representing the data type, and the value
is either a NumPy array or a list of corresponding data of length `n`
@@ -166,17 +237,91 @@ class KeyPoints:
xy: npt.NDArray[np.float32]
class_id: npt.NDArray[np.int_] | None = None
confidence: npt.NDArray[np.float32] | None = None
keypoint_confidence: npt.NDArray[np.float32] | None = None
detection_confidence: npt.NDArray[np.float32] | None = None
visible: npt.NDArray[np.bool_] | None = None
data: dict[str, npt.NDArray[np.generic] | list[Any]] = field(default_factory=dict)
def __init__(
self,
xy: npt.NDArray[np.float32],
class_id: npt.NDArray[np.int_] | None = None,
keypoint_confidence: npt.NDArray[np.float32] | None = None,
detection_confidence: npt.NDArray[np.float32] | None = None,
visible: npt.NDArray[np.bool_] | None = None,
data: dict[str, npt.NDArray[np.generic] | list[Any]] | None = None,
*,
confidence: npt.NDArray[np.float32] | None = None,
) -> None:
"""Initialize KeyPoints.
Args:
xy: Array of shape `(n, m, 2)` with keypoint coordinates.
class_id: Array of shape `(n,)` with class IDs. Defaults to None.
keypoint_confidence: Array of shape `(n, m)` with per-keypoint
confidence scores. Defaults to None.
detection_confidence: Array of shape `(n,)` with detection-level
confidence scores. Defaults to None.
visible: Boolean array of shape `(n, m)` indicating visible
keypoints. Defaults to None.
data: Dictionary of additional per-detection data arrays.
Defaults to an empty dict.
confidence: Deprecated since `0.29.0`, removed in `0.32.0`.
Use ``keypoint_confidence`` instead. Raises ``ValueError``
if passed together with ``keypoint_confidence``.
Raises:
ValueError: If both ``confidence`` and ``keypoint_confidence``
are provided.
"""
if confidence is not None:
if keypoint_confidence is not None:
raise ValueError(
"Cannot pass both 'confidence' and 'keypoint_confidence'. "
"'confidence' is deprecated — use 'keypoint_confidence' only."
)
warn_deprecated(
"'confidence' parameter in `KeyPoints()` is deprecated since "
"`0.29.0` and will be removed in `0.32.0`. Use "
"'keypoint_confidence' instead."
)
keypoint_confidence = confidence
self.xy = xy
self.class_id = class_id
self.keypoint_confidence = keypoint_confidence
self.detection_confidence = detection_confidence
self.visible = visible
self.data = data if data is not None else {}
self.__post_init__()
def __post_init__(self) -> None:
validate_key_points_fields(
_validate_keypoints_fields(
xy=self.xy,
confidence=self.confidence,
class_id=self.class_id,
confidence=self.keypoint_confidence,
detection_confidence=self.detection_confidence,
visible=self.visible,
data=self.data,
)
@property
def confidence(self) -> npt.NDArray[np.float32] | None:
"""Deprecated since 0.29.0. Use ``keypoint_confidence`` instead."""
warn_deprecated(
"'KeyPoints.confidence' is deprecated since 0.29.0 and will be "
"removed in 0.32.0. Use 'KeyPoints.keypoint_confidence' instead."
)
return self.keypoint_confidence
@confidence.setter
def confidence(self, value: npt.NDArray[np.float32] | None) -> None:
warn_deprecated(
"'KeyPoints.confidence' is deprecated since 0.29.0 and will be "
"removed in 0.32.0. Use 'KeyPoints.keypoint_confidence' instead."
)
self.keypoint_confidence = value
def __len__(self) -> int:
"""
Returns the number of objects in the `sv.KeyPoints` object.
@@ -209,12 +354,14 @@ class KeyPoints:
]:
"""
Iterates over the Keypoint object and yield a tuple of
`(xy, confidence, class_id, data)` for each object detection.
`(xy, keypoint_confidence, class_id, data)` for each object detection.
"""
for i in range(len(self.xy)):
yield (
self.xy[i],
self.confidence[i] if self.confidence is not None else None,
self.keypoint_confidence[i]
if self.keypoint_confidence is not None
else None,
self.class_id[i] if self.class_id is not None else None,
get_data_item(self.data, i),
)
@@ -225,8 +372,14 @@ class KeyPoints:
return all(
[
np.array_equal(self.xy, other.xy),
np.array_equal(self.class_id, other.class_id),
np.array_equal(self.confidence, other.confidence),
_optional_array_equal(self.class_id, other.class_id),
_optional_array_equal(
self.keypoint_confidence, other.keypoint_confidence
),
_optional_array_equal(
self.detection_confidence, other.detection_confidence
),
_optional_array_equal(self.visible, other.visible),
is_data_equal(self.data, other.data),
]
)
@@ -304,11 +457,13 @@ class KeyPoints:
class_id.append(prediction["class_id"])
class_names.append(prediction["class"])
data = {CLASS_NAME_DATA_FIELD: np.array(class_names)}
data: dict[str, npt.NDArray[np.generic] | list[Any]] = {
CLASS_NAME_DATA_FIELD: np.array(class_names)
}
return cls(
xy=np.array(xy, dtype=np.float32),
confidence=np.array(confidence, dtype=np.float32),
keypoint_confidence=np.array(confidence, dtype=np.float32),
class_id=np.array(class_id, dtype=int),
data=data,
)
@@ -436,7 +591,7 @@ class KeyPoints:
return cls(
xy=np.array(xy, dtype=np.float32),
confidence=np.array(confidence, dtype=np.float32),
keypoint_confidence=np.array(confidence, dtype=np.float32),
)
@classmethod
@@ -473,8 +628,10 @@ class KeyPoints:
class_names = np.array([ultralytics_results.names[i] for i in class_id])
confidence = ultralytics_results.keypoints.conf.cpu().numpy()
data = {CLASS_NAME_DATA_FIELD: class_names}
return cls(xy, class_id, confidence, data)
data: dict[str, npt.NDArray[np.generic] | list[Any]] = {
CLASS_NAME_DATA_FIELD: class_names
}
return cls(xy=xy, class_id=class_id, keypoint_confidence=confidence, data=data)
@classmethod
def from_yolo_nas(cls, yolo_nas_results: Any) -> KeyPoints:
@@ -519,7 +676,7 @@ class KeyPoints:
else:
class_id = None
data = {}
data: dict[str, npt.NDArray[np.generic] | list[Any]] = {}
if class_id is not None and yolo_nas_results.class_names is not None:
class_names = []
for c_id in class_id:
@@ -529,7 +686,7 @@ class KeyPoints:
return cls(
xy=xy,
confidence=confidence,
keypoint_confidence=confidence,
class_id=class_id,
data=data,
)
@@ -574,7 +731,7 @@ class KeyPoints:
xy=detectron2_results["instances"]
.pred_keypoints.cpu()
.numpy()[:, :, :2],
confidence=detectron2_results["instances"]
keypoint_confidence=detectron2_results["instances"]
.pred_keypoints.cpu()
.numpy()[:, :, 2],
class_id=detectron2_results["instances"]
@@ -664,7 +821,7 @@ class KeyPoints:
return cls(
xy=np.stack(xy).astype(np.float32),
confidence=np.stack(scores).astype(np.float32),
keypoint_confidence=np.stack(scores).astype(np.float32),
class_id=np.arange(len(xy)).astype(int),
)
else:
@@ -725,33 +882,93 @@ class KeyPoints:
)
k = int(counts[0]) if n > 0 else 0
xy_selected = np.zeros((n, k, self.xy.shape[2]), dtype=self.xy.dtype)
conf_selected: npt.NDArray[np.float32] | None = None
if self.confidence is not None:
conf_selected = cast(
keypoint_confidence_selected: npt.NDArray[np.float32] | None = None
if self.keypoint_confidence is not None:
keypoint_confidence_selected = cast(
npt.NDArray[np.float32],
np.zeros((n, k), dtype=self.confidence.dtype),
np.zeros((n, k), dtype=self.keypoint_confidence.dtype),
)
visible_selected: npt.NDArray[np.bool_] | None = None
if self.visible is not None:
visible_selected = np.zeros((n, k), dtype=bool)
for row in range(n):
row_indices = np.flatnonzero(mask[row])
xy_selected[row] = self.xy[row, row_indices]
if conf_selected is not None and self.confidence is not None:
conf_selected[row] = self.confidence[row, row_indices]
if (
keypoint_confidence_selected is not None
and self.keypoint_confidence is not None
):
keypoint_confidence_selected[row] = self.keypoint_confidence[
row, row_indices
]
if visible_selected is not None and self.visible is not None:
visible_selected[row] = self.visible[row, row_indices]
detection_confidence_selected = None
if self.detection_confidence is not None:
detection_confidence_selected = self.detection_confidence.copy()
class_id_selected = None
if self.class_id is not None:
class_id_selected = self.class_id.copy()
data_selected = get_data_item(self.data, slice(None))
return KeyPoints(
xy=xy_selected,
confidence=conf_selected,
class_id=self.class_id.copy() if self.class_id is not None else None,
data=get_data_item(self.data, slice(None)),
keypoint_confidence=keypoint_confidence_selected,
detection_confidence=detection_confidence_selected,
visible=visible_selected,
class_id=class_id_selected,
data=data_selected,
)
def __getitem__(
self,
index: Index1D | Index2D | str,
) -> KeyPoints | npt.NDArray[np.generic] | list[Any] | None:
"""
Get a subset of the KeyPoints object or access an item from its data field.
Supports detection-level (skeleton) filtering, keypoint-level (anchor)
filtering, combined tuple indexing, and data field access by string key.
Args:
index: The index, indices, or key to access a subset of the KeyPoints
or an item from the data.
Returns:
A subset of the KeyPoints object or an item from the data field.
Examples:
```python
import supervision as sv
key_points = sv.KeyPoints(...)
# detection-level filtering (returns KeyPoints)
high_conf = key_points[key_points.detection_confidence > 0.5]
class_0 = key_points[key_points.class_id == 0]
# keypoint-level filtering (returns KeyPoints)
visible = key_points[key_points.keypoint_confidence > 0.3]
# indexing
first = key_points[0]
first_two = key_points[0:2]
subset = key_points[[0, 2]]
# anchor selection (uniform across all skeletons)
nose_and_eyes = key_points[:, [0, 1, 2]]
# data field access
class_names = key_points['class_name']
```
"""
if isinstance(index, str):
return self.data.get(index)
if isinstance(index, np.ndarray) and index.ndim == 2 and index.dtype == bool:
return self._get_by_2d_bool_mask(index)
return self._get_by_2d_bool_mask(cast(npt.NDArray[np.bool_], index))
if not isinstance(index, tuple):
index = (index, slice(None))
@@ -771,43 +988,73 @@ class KeyPoints:
if isinstance(j, np.ndarray) and j.dtype == bool:
j = np.flatnonzero(j)
raw_i = i
if (
isinstance(i, (list, np.ndarray))
and isinstance(j, (list, np.ndarray))
and not np.isscalar(i)
and not np.isscalar(j)
):
i, j = np.ix_(i, j)
i_ix, j_ix = np.ix_(cast(Any, i), cast(Any, j))
i = cast(Any, i_ix)
j = cast(Any, j_ix)
row_i = _normalize_row_index(raw_i)
xy_selected = self.xy[i, j]
conf_selected = self.confidence[i, j] if self.confidence is not None else None
keypoint_confidence_selected = None
if self.keypoint_confidence is not None:
keypoint_confidence_selected = self.keypoint_confidence[i, j]
class_id_selected = self.class_id[i] if self.class_id is not None else None
detection_confidence_selected = None
if self.detection_confidence is not None:
detection_confidence_selected = self.detection_confidence[row_i]
data_selected = get_data_item(self.data, i)
visible_selected = None
if self.visible is not None:
visible_selected = self.visible[i, j]
class_id_selected = self.class_id[row_i] if self.class_id is not None else None
data_selected = get_data_item(self.data, cast(Any, row_i))
if xy_selected.ndim == 1:
xy_selected = xy_selected.reshape(1, 1, 2)
if conf_selected is not None:
conf_selected = conf_selected.reshape(1, 1)
if keypoint_confidence_selected is not None:
keypoint_confidence_selected = keypoint_confidence_selected.reshape(
1, 1
)
if visible_selected is not None:
visible_selected = visible_selected.reshape(1, 1)
elif xy_selected.ndim == 2:
if np.isscalar(index[0]) or (
isinstance(index[0], np.ndarray) and index[0].ndim == 0
):
xy_selected = xy_selected[np.newaxis, ...]
if conf_selected is not None:
conf_selected = conf_selected[np.newaxis, ...]
if keypoint_confidence_selected is not None:
keypoint_confidence_selected = keypoint_confidence_selected[
np.newaxis, ...
]
if visible_selected is not None:
visible_selected = visible_selected[np.newaxis, ...]
elif np.isscalar(index[1]) or (
isinstance(index[1], np.ndarray) and index[1].ndim == 0
):
xy_selected = xy_selected[:, np.newaxis, :]
if conf_selected is not None:
conf_selected = conf_selected[:, np.newaxis]
if keypoint_confidence_selected is not None:
keypoint_confidence_selected = keypoint_confidence_selected[
:, np.newaxis
]
if visible_selected is not None:
visible_selected = visible_selected[:, np.newaxis]
return KeyPoints(
xy=xy_selected,
confidence=conf_selected,
keypoint_confidence=keypoint_confidence_selected,
detection_confidence=detection_confidence_selected,
visible=visible_selected,
class_id=class_id_selected,
data=data_selected,
)
@@ -886,6 +1133,94 @@ class KeyPoints:
empty_key_points.data = self.data
return self == empty_key_points
def with_nms(
self,
threshold: float = 0.5,
class_agnostic: bool = False,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> KeyPoints:
"""
Performs non-max suppression on the keypoint detections. Bounding boxes
are derived from valid keypoints of each skeleton, and standard box NMS
is applied. A keypoint is considered valid when its coordinates are not
all-zero and its `visible` flag is `True` (if `visible` is set).
Args:
threshold: The intersection-over-union threshold to use for
non-maximum suppression. Must be in [0, 1]. Defaults to 0.5.
class_agnostic: Whether to perform class-agnostic non-maximum
suppression. If True, the class_id of each detection will be
ignored. Defaults to False.
overlap_metric: Metric used to compute the degree of overlap
between pairs of bounding boxes. Defaults to
`OverlapMetric.IOU`.
Returns:
A new `sv.KeyPoints` object after non-maximum suppression.
Raises:
ValueError: If `detection_confidence` is None.
ValueError: If `class_agnostic` is False and `class_id`
is None.
Examples:
```python
import cv2
import supervision as sv
from rfdetr import RFDETRKeypointPreview
image = cv2.imread("<SOURCE_IMAGE_PATH>")
model = RFDETRKeypointPreview()
key_points = model.predict(image)
key_points = key_points.with_nms(threshold=0.5)
```
"""
if len(self) == 0:
return self
if self.detection_confidence is None:
raise ValueError(
"KeyPoints detection_confidence must be given for NMS to be executed."
)
if not class_agnostic and self.class_id is None:
raise ValueError(
"KeyPoints class_id must be given for NMS to be executed. If "
"you intended to perform class agnostic NMS set "
"class_agnostic=True."
)
xy = self.xy
valid = ~np.all(xy == 0, axis=-1)
if self.visible is not None:
valid = valid & self.visible
x_min = np.min(np.where(valid, xy[..., 0], np.inf), axis=1)
y_min = np.min(np.where(valid, xy[..., 1], np.inf), axis=1)
x_max = np.max(np.where(valid, xy[..., 0], -np.inf), axis=1)
y_max = np.max(np.where(valid, xy[..., 1], -np.inf), axis=1)
xyxy = np.stack([x_min, y_min, x_max, y_max], axis=1).astype(np.float32)
if class_agnostic:
predictions = np.hstack([xyxy, self.detection_confidence.reshape(-1, 1)])
else:
class_id = cast(npt.NDArray[np.int_], self.class_id)
predictions = np.hstack(
[
xyxy,
self.detection_confidence.reshape(-1, 1),
class_id.reshape(-1, 1),
]
)
keep = box_non_max_suppression(
predictions=predictions,
iou_threshold=threshold,
overlap_metric=overlap_metric,
)
return cast(KeyPoints, self[keep])
def as_detections(
self, selected_keypoint_indices: Iterable[int] | None = None
) -> Detections:
@@ -898,7 +1233,9 @@ class KeyPoints:
selected_keypoint_indices: The
indices of the key points to include in the bounding box
calculation. This helps focus on a subset of key points,
e.g. when some are occluded. Captures all key points by default.
e.g. when some are occluded. Captures all key points by
default. An empty sequence (`[]`) is treated the same as
`None` and selects all key points.
Returns:
detections: The converted detections object.
@@ -919,40 +1256,39 @@ class KeyPoints:
if self.is_empty():
return Detections.empty()
detections_list = []
for i, xy in enumerate(self.xy):
xy = self.xy
if selected_keypoint_indices:
indices = np.asarray(list(selected_keypoint_indices), dtype=np.intp)
xy = xy[:, indices, :]
# [0, 0] is used by some frameworks to indicate a missing keypoint; those
# points are excluded from each skeleton's bounding box.
valid = ~np.all(xy == 0, axis=2) # (N, M)
has_valid = valid.any(axis=1) # (N,)
x, y = xy[:, :, 0], xy[:, :, 1]
x_min = np.where(valid, x, np.inf).min(axis=1)
y_min = np.where(valid, y, np.inf).min(axis=1)
x_max = np.where(valid, x, -np.inf).max(axis=1)
y_max = np.where(valid, y, -np.inf).max(axis=1)
xyxy = np.stack((x_min, y_min, x_max, y_max), axis=1).astype(np.float32)
# Skeletons with no valid keypoints keep the original empty [0, 0, 0, 0] box.
xyxy[~has_valid] = 0.0
if self.detection_confidence is not None:
confidence = self.detection_confidence.astype(np.float32)
elif self.keypoint_confidence is not None:
keypoint_confidence = self.keypoint_confidence
if selected_keypoint_indices:
xy = xy[selected_keypoint_indices]
keypoint_confidence = keypoint_confidence[:, indices]
confidence = keypoint_confidence.mean(axis=1).astype(np.float32)
else:
confidence = None
# [0, 0] used by some frameworks to indicate missing keypoints
xy = xy[~np.all(xy == 0, axis=1)]
if len(xy) == 0:
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
else:
x_min = xy[:, 0].min()
x_max = xy[:, 0].max()
y_min = xy[:, 1].min()
y_max = xy[:, 1].max()
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
if self.confidence is None:
confidence = None
else:
confidence = self.confidence[i]
if selected_keypoint_indices:
confidence = confidence[selected_keypoint_indices]
confidence = np.array([confidence.mean()], dtype=np.float32)
detections_list.append(
Detections(
xyxy=xyxy,
confidence=confidence,
)
)
detections = Detections.merge(detections_list)
detections = Detections(xyxy=xyxy, confidence=confidence)
detections.class_id = self.class_id
detections.data = self.data
detections = cast(Detections, detections[detections.area > 0])
detections = cast(Detections, detections[cast(Any, detections.area) > 0])
return detections
+254 -45
View File
@@ -7,32 +7,130 @@ import matplotlib
import matplotlib.pyplot as plt
import numpy as np
import numpy.typing as npt
from deprecate import deprecated_class
from deprecate import TargetMode, deprecated, deprecated_class, void
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.dataset.core import DetectionDataset
from supervision.detection.core import Detections
from supervision.detection.utils.iou_and_nms import box_iou_batch
from supervision.detection.utils.iou_and_nms import (
box_iou_batch,
oriented_box_iou_batch,
)
from supervision.metrics.core import MetricTarget
def _assert_supported_target(metric_target: MetricTarget) -> None:
if metric_target == MetricTarget.MASKS:
raise ValueError(
"MetricTarget.MASKS is not currently supported for ConfusionMatrix."
)
def detections_to_tensor(
detections: Detections, with_confidence: bool = False
detections: Detections,
with_confidence: bool = False,
metric_target: MetricTarget = MetricTarget.BOXES,
) -> npt.NDArray[np.float32]:
"""
Convert Supervision Detections to numpy tensors for further computation
Convert Supervision Detections to a numpy tensor for metric computation.
Args:
detections: Detections/Targets in the format of sv.Detections
with_confidence: Whether to include confidence in the tensor
detections: Detections/Targets in the format of sv.Detections.
with_confidence: Whether to include confidence as the last column.
metric_target: The type of detection data to use.
Supports `MetricTarget.BOXES` and
`MetricTarget.ORIENTED_BOUNDING_BOXES`.
Returns:
Detections as numpy tensors as in (xyxy, class_id, confidence) order
Detections as a float32 numpy array. Shape depends on `metric_target`
and `with_confidence`:
| `metric_target` | `with_confidence` | shape |
|----------------------------------------|-------------------|-----------|
| `MetricTarget.BOXES` | `False` | `(N, 5)` |
| `MetricTarget.BOXES` | `True` | `(N, 6)` |
| `MetricTarget.ORIENTED_BOUNDING_BOXES` | `False` | `(N, 9)` |
| `MetricTarget.ORIENTED_BOUNDING_BOXES` | `True` | `(N, 10)` |
Column layout:
- `BOXES`: ``[x_min, y_min, x_max, y_max, class_id [, confidence]]``
- `ORIENTED_BOUNDING_BOXES`:
``[x1, y1, x2, y2, x3, y3, x4, y4, class_id [, confidence]]``
Raises:
ValueError: If `metric_target` is `MetricTarget.MASKS`.
ValueError: If `detections.class_id` is `None`.
ValueError: If `with_confidence=True` and `detections.confidence` is `None`.
ValueError: If `metric_target` is `MetricTarget.ORIENTED_BOUNDING_BOXES`
and `detections.data` does not contain `ORIENTED_BOX_COORDINATES`,
or if the stored array does not have exactly `N * 8` elements.
Examples:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> from supervision.metrics.core import MetricTarget
>>> from supervision.config import ORIENTED_BOX_COORDINATES
>>> detections = sv.Detections(
... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
... class_id=np.array([0]),
... confidence=np.array([0.9]),
... )
>>> tensor = detections_to_tensor(detections, with_confidence=True)
>>> tensor.shape
(1, 6)
>>> obb_coords = np.array([[0, 0, 10, 0, 10, 10, 0, 10]], dtype=np.float32)
>>> det_obb = sv.Detections(
... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
... class_id=np.array([0]),
... data={ORIENTED_BOX_COORDINATES: obb_coords},
... )
>>> tensor_obb = detections_to_tensor(
... det_obb, metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES
... )
>>> tensor_obb.shape
(1, 9)
```
"""
_assert_supported_target(metric_target)
if detections.class_id is None:
raise ValueError(
"ConfusionMatrix can only be calculated for Detections with class_id"
)
arrays_to_concat = [detections.xyxy, np.expand_dims(detections.class_id, 1)]
if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
if obb is None:
if len(detections) > 0:
raise ValueError(
"ORIENTED_BOUNDING_BOXES requested, but "
f"{ORIENTED_BOX_COORDINATES} is missing from detections.data"
)
box_data = np.empty((0, 8), dtype=np.float32)
else:
obb_arr = np.asarray(obb, dtype=np.float32)
# Normalize (N, 4, 2) → (N, 8) as produced by from_ultralytics.
if obb_arr.ndim == 3 and obb_arr.shape[1:] == (4, 2):
obb_arr = obb_arr.reshape(-1, 8)
if obb_arr.size != len(detections) * 8:
raise ValueError(
f"Expected {ORIENTED_BOX_COORDINATES} to contain "
f"{len(detections) * 8} elements "
f"(N={len(detections)} detections x 8 coordinates), "
f"but got {obb_arr.size}. "
"Each OBB must be stored as [x1, y1, x2, y2, x3, y3, x4, y4]."
)
box_data = obb_arr.reshape(-1, 8)
else:
box_data = detections.xyxy
arrays_to_concat = [
box_data,
np.expand_dims(detections.class_id.astype(np.float32), 1),
]
if with_confidence:
if detections.confidence is None:
@@ -45,9 +143,10 @@ def detections_to_tensor(
return result
def validate_input_tensors(
def _validate_input_tensors(
predictions: list[npt.NDArray[np.float32]],
targets: list[npt.NDArray[np.float32]],
metric_target: MetricTarget = MetricTarget.BOXES,
) -> None:
"""
Checks for shape consistency of input tensors.
@@ -62,20 +161,41 @@ def validate_input_tensors(
targets[0], np.ndarray
):
raise ValueError(
f"Predictions and targets must be lists of numpy arrays."
"Predictions and targets must be lists of numpy arrays. "
f"Got {type(predictions[0])} and {type(targets[0])} instead."
)
if predictions[0].shape[1] != 6:
expected_pred_cols = (
10 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 6
)
expected_target_cols = (
9 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 5
)
if predictions[0].shape[1] != expected_pred_cols:
raise ValueError(
f"Predictions must have shape (N, 6)."
f"Predictions must have shape (N, {expected_pred_cols}). "
f"Got {predictions[0].shape} instead."
)
if targets[0].shape[1] != 5:
if targets[0].shape[1] != expected_target_cols:
raise ValueError(
f"Targets must have shape (N, 5). Got {targets[0].shape} instead."
f"Targets must have shape (N, {expected_target_cols}). "
f"Got {targets[0].shape} instead."
)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_input_tensors,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_input_tensors(
predictions: list[npt.NDArray[np.float32]],
targets: list[npt.NDArray[np.float32]],
) -> None:
void(predictions, targets)
@dataclass
class ConfusionMatrix:
"""
@@ -89,12 +209,30 @@ class ConfusionMatrix:
Detections with lower confidence will be excluded from the matrix.
iou_threshold: Detection IoU threshold between `0` and `1`.
Detections with lower IoU will be classified as `FP`.
metric_target: The type of detection data used for IoU computation.
Informational metadata set by `from_detections` and `from_tensors`.
Excluded from `__eq__` comparisons — two `ConfusionMatrix` instances
with identical `matrix`, `classes`, `conf_threshold`, and
`iou_threshold` compare as equal regardless of `metric_target`.
"""
matrix: npt.NDArray[np.int32]
classes: list[str]
conf_threshold: float
iou_threshold: float
metric_target: MetricTarget = MetricTarget.BOXES
def __eq__(self, other: object) -> bool:
if not isinstance(other, ConfusionMatrix):
return NotImplemented
return (
np.array_equal(self.matrix, other.matrix)
and self.classes == other.classes
and self.conf_threshold == other.conf_threshold
and self.iou_threshold == other.iou_threshold
)
__hash__ = None # type: ignore[assignment]
@classmethod
def from_detections(
@@ -104,6 +242,7 @@ class ConfusionMatrix:
classes: list[str],
conf_threshold: float = 0.3,
iou_threshold: float = 0.5,
metric_target: MetricTarget = MetricTarget.BOXES,
) -> ConfusionMatrix:
"""
Calculate confusion matrix based on predicted and ground-truth detections.
@@ -116,6 +255,15 @@ class ConfusionMatrix:
Detections with lower confidence will be excluded.
iou_threshold: Detection IoU threshold between `0` and `1`.
Detections with lower IoU will be classified as `FP`.
metric_target: The type of detection data to use.
Supports `MetricTarget.BOXES` (default) and
`MetricTarget.ORIENTED_BOUNDING_BOXES`. When using
`MetricTarget.ORIENTED_BOUNDING_BOXES`, each `Detections`
object must include OBB coordinates in
`detections.data[ORIENTED_BOX_COORDINATES]` as a float32
array of shape `(N, 8)` (flat) or `(N, 4, 2)` (as stored by
`from_ultralytics`); both are normalised to `(N, 8)` internally.
`MetricTarget.MASKS` is not supported.
Returns:
New instance of ConfusionMatrix.
@@ -126,15 +274,15 @@ class ConfusionMatrix:
>>> import supervision as sv
>>> targets = [
... sv.Detections(
... xyxy=np.array([[0, 0, 10, 10]]),
... class_id=np.array([0])
... xyxy=np.array([[0, 0, 10, 10], [50, 50, 60, 60]]),
... class_id=np.array([0, 0])
... )
... ]
>>> predictions = [
... sv.Detections(
... xyxy=np.array([[0, 0, 10, 10]]),
... class_id=np.array([0]),
... confidence=np.array([0.9])
... xyxy=np.array([[0, 0, 10, 10], [100, 100, 110, 110]]),
... class_id=np.array([0, 0]),
... confidence=np.array([0.9, 0.8])
... )
... ]
>>> confusion_matrix = sv.ConfusionMatrix.from_detections(
@@ -143,8 +291,8 @@ class ConfusionMatrix:
... classes=['person']
... )
>>> confusion_matrix.matrix
array([[1., 0.],
[0., 0.]])
array([[1., 1.],
[1., 0.]])
```
"""
@@ -152,15 +300,22 @@ class ConfusionMatrix:
target_tensors = []
for prediction, target in zip(predictions, targets):
prediction_tensors.append(
detections_to_tensor(prediction, with_confidence=True)
detections_to_tensor(
prediction, with_confidence=True, metric_target=metric_target
)
)
target_tensors.append(
detections_to_tensor(
target, with_confidence=False, metric_target=metric_target
)
)
target_tensors.append(detections_to_tensor(target, with_confidence=False))
return cls.from_tensors(
predictions=prediction_tensors,
targets=target_tensors,
classes=classes,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
metric_target=metric_target,
)
@classmethod
@@ -171,24 +326,34 @@ class ConfusionMatrix:
classes: list[str],
conf_threshold: float = 0.3,
iou_threshold: float = 0.5,
metric_target: MetricTarget = MetricTarget.BOXES,
) -> ConfusionMatrix:
"""
Calculate confusion matrix based on predicted and ground-truth detections.
Args:
predictions: Each element of the list describes a single
image and has `shape = (M, 6)` where `M` is the number of detected
objects. Each row is expected to be in
image and has `shape = (M, 6)` or `shape = (M, 10)` depending on
`metric_target`.
If `MetricTarget.BOXES`, each row is in
`(x_min, y_min, x_max, y_max, class, conf)` format.
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
`(x1, y1, x2, y2, x3, y3, x4, y4, class, conf)` format.
targets: Each element of the list describes a single
image and has `shape = (N, 5)` where `N` is the number of
ground-truth objects. Each row is expected to be in
image and has `shape = (N, 5)` or `shape = (N, 9)` depending on
`metric_target`.
If `MetricTarget.BOXES`, each row is in
`(x_min, y_min, x_max, y_max, class)` format.
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
`(x1, y1, x2, y2, x3, y3, x4, y4, class)` format.
classes: Model class names.
conf_threshold: Detection confidence threshold between `0` and `1`.
Detections with lower confidence will be excluded.
iou_threshold: Detection iou threshold between `0` and `1`.
iou_threshold: Detection iou threshold between `0` and `1`.
Detections with lower iou will be classified as `FP`.
metric_target: The type of detection data to use.
Determines expected tensor shapes (see Args above for column
layouts). `MetricTarget.MASKS` is not supported.
Returns:
New instance of ConfusionMatrix.
@@ -223,7 +388,8 @@ class ConfusionMatrix:
```
"""
validate_input_tensors(predictions, targets)
_assert_supported_target(metric_target)
_validate_input_tensors(predictions, targets, metric_target=metric_target)
num_classes = len(classes)
matrix = np.zeros((num_classes + 1, num_classes + 1))
@@ -234,12 +400,14 @@ class ConfusionMatrix:
num_classes=num_classes,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
metric_target=metric_target,
)
return cls(
matrix=matrix,
classes=classes,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
metric_target=metric_target,
)
@staticmethod
@@ -249,38 +417,70 @@ class ConfusionMatrix:
num_classes: int,
conf_threshold: float,
iou_threshold: float,
metric_target: MetricTarget = MetricTarget.BOXES,
) -> npt.NDArray[np.int32]:
"""
Calculate confusion matrix for a batch of detections for a single image.
Args:
predictions: Batch prediction. Describes a single image and
has `shape = (M, 6)` where `M` is the number of detected objects.
Each row is expected to be in
has `shape = (M, 6)` or `shape = (M, 10)` depending on
`metric_target`.
If `MetricTarget.BOXES`, each row is in
`(x_min, y_min, x_max, y_max, class, conf)` format.
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
`(x1, y1, x2, y2, x3, y3, x4, y4, class, conf)` format.
targets: Batch target labels. Describes a single image and
has `shape = (N, 5)` where `N` is the number of ground-truth objects.
Each row is expected to be in
has `shape = (N, 5)` or `shape = (N, 9)` depending on
`metric_target`.
If `MetricTarget.BOXES`, each row is in
`(x_min, y_min, x_max, y_max, class)` format.
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
`(x1, y1, x2, y2, x3, y3, x4, y4, class)` format.
num_classes: Number of classes.
conf_threshold: Detection confidence threshold between `0` and `1`.
Detections with lower confidence will be excluded.
iou_threshold: Detection iou threshold between `0` and `1`.
iou_threshold: Detection iou threshold between `0` and `1`.
Detections with lower iou will be classified as `FP`.
metric_target: The type of detection data to use.
Determines IoU function (`box_iou_batch` vs
`oriented_box_iou_batch`) and coordinate column count.
`MetricTarget.MASKS` is not supported.
Returns:
Confusion matrix based on a single image.
"""
_assert_supported_target(metric_target)
expected_pred_cols = (
10 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 6
)
expected_target_cols = (
9 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 5
)
if predictions.ndim != 2 or predictions.shape[1] != expected_pred_cols:
raise ValueError(
f"Predictions must have shape (M, {expected_pred_cols}). "
f"Got {predictions.shape} instead."
)
if targets.ndim != 2 or targets.shape[1] != expected_target_cols:
raise ValueError(
f"Targets must have shape (N, {expected_target_cols}). "
f"Got {targets.shape} instead."
)
result_matrix = np.zeros((num_classes + 1, num_classes + 1))
# Filter predictions by confidence threshold
conf_idx = 5
coords_dim = 8 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 4
class_id_idx = coords_dim
conf_idx = coords_dim + 1
confidence = predictions[:, conf_idx]
detection_batch_filtered = predictions[confidence >= conf_threshold]
if len(detection_batch_filtered) == 0:
# No detections pass confidence threshold - all GT are FN
class_id_idx = 4
true_classes = np.array(targets[:, class_id_idx], dtype=np.int16)
for gt_class in true_classes:
result_matrix[gt_class, num_classes] += 1
@@ -288,7 +488,6 @@ class ConfusionMatrix:
if len(targets) == 0:
# No ground truth - all detections are FP
class_id_idx = 4
detection_classes = np.array(
detection_batch_filtered[:, class_id_idx], dtype=np.int16
)
@@ -296,18 +495,22 @@ class ConfusionMatrix:
result_matrix[num_classes, det_class] += 1
return result_matrix
class_id_idx = 4
true_classes = np.array(targets[:, class_id_idx], dtype=np.int16)
detection_classes = np.array(
detection_batch_filtered[:, class_id_idx], dtype=np.int16
)
true_boxes = targets[:, :class_id_idx]
detection_boxes = detection_batch_filtered[:, :class_id_idx]
true_boxes = targets[:, :coords_dim]
detection_boxes = detection_batch_filtered[:, :coords_dim]
# Calculate IoU matrix
iou_batch = box_iou_batch(
boxes_true=true_boxes, boxes_detection=detection_boxes
)
if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
iou_batch = oriented_box_iou_batch(
boxes_true=true_boxes, boxes_detection=detection_boxes
)
else:
iou_batch = box_iou_batch(
boxes_true=true_boxes, boxes_detection=detection_boxes
)
# Find all valid matches (IoU > threshold, regardless of class)
# Use vectorized operations to avoid nested Python loops
@@ -393,6 +596,7 @@ class ConfusionMatrix:
callback: Callable[[npt.NDArray[np.uint8]], Detections],
conf_threshold: float = 0.3,
iou_threshold: float = 0.5,
metric_target: MetricTarget = MetricTarget.BOXES,
) -> ConfusionMatrix:
"""
Calculate confusion matrix from dataset and callback function.
@@ -405,6 +609,10 @@ class ConfusionMatrix:
Detections with lower confidence will be excluded.
iou_threshold: Detection IoU threshold between `0` and `1`.
Detections with lower IoU will be classified as `FP`.
metric_target: The type of detection data to use.
Supports `MetricTarget.BOXES` and
`MetricTarget.ORIENTED_BOUNDING_BOXES`. Passed through to
`from_detections`. `MetricTarget.MASKS` is not supported.
Returns:
New instance of ConfusionMatrix.
@@ -446,6 +654,7 @@ class ConfusionMatrix:
classes=dataset.classes,
conf_threshold=conf_threshold,
iou_threshold=iou_threshold,
metric_target=metric_target,
)
def plot(
@@ -539,7 +748,7 @@ class ConfusionMatrix:
@deprecated_class(
target=None,
target=TargetMode.NOTIFY,
deprecated_in="0.27.0",
remove_in="0.31.0",
)
@@ -723,7 +932,7 @@ class MeanAveragePrecision:
```
"""
validate_input_tensors(predictions, targets)
_validate_input_tensors(predictions, targets)
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats = []
+50 -6
View File
@@ -152,6 +152,14 @@ class F1Score(Metric):
def _compute(
self, predictions_list: list[Detections], targets_list: list[Detections]
) -> F1ScoreResult:
"""Build per-image stats tuples and delegate to class-level computation.
Each stats tuple is ``(matches, confidence, class_ids, true_class_ids)``:
- Both empty: skip (no information).
- Targets empty, predictions present: all predictions are FPs; true_class_ids
is ``zeros((0,))``.
- Targets present: IoU matching produces ``matches`` array.
"""
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats: list[Any] = []
@@ -159,7 +167,22 @@ class F1Score(Metric):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) > 0:
if len(targets) == 0 and len(predictions) > 0:
# Only predictions are present (e.g. a background image); every
# prediction is a false positive.
if predictions.class_id is None or predictions.confidence is None:
continue
stats.append(
(
np.zeros(
(len(predictions), iou_thresholds.size), dtype=np.bool_
),
predictions.confidence,
predictions.class_id,
np.zeros((0,), dtype=np.int32),
)
)
elif len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
@@ -244,10 +267,23 @@ class F1Score(Metric):
npt.NDArray[np.float64],
npt.NDArray[np.int32],
]:
"""Compute F1 scores from concatenated stats across all images.
``unique_classes`` is the union of GT and predicted classes so that
predictions of classes absent from GT still count as false positives.
"""
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
# Predictions whose class never appears in the ground truth are still
# false positives, so include those classes in the confusion matrix
# (their true-instance count is zero).
unique_classes = np.unique(
np.concatenate((true_class_ids, prediction_class_ids))
)
true_classes, true_counts = np.unique(true_class_ids, return_counts=True)
class_counts = np.zeros(unique_classes.shape[0], dtype=int)
class_counts[np.searchsorted(unique_classes, true_classes)] = true_counts
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
@@ -265,7 +301,13 @@ class F1Score(Metric):
f1_scores = self._compute_f1(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
f1_scores = np.average(f1_per_class, axis=0, weights=class_counts)
if class_counts.sum() == 0:
# No ground-truth support (e.g. only false-positive classes, or a
# size bucket with predictions but no targets): weighting is
# undefined, so report 0 as the empty case did before.
f1_scores = np.zeros(f1_per_class.shape[1])
else:
f1_scores = np.average(f1_per_class, axis=0, weights=class_counts)
return f1_scores, f1_per_class, unique_classes
@@ -483,10 +525,12 @@ class F1ScoreResult:
f1_scores: the F1 scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
f1_per_class: the F1 scores per class and IoU threshold.
Shape: `(num_target_classes, num_iou_thresholds)`
Shape: `(num_classes, num_iou_thresholds)`
iou_thresholds: the IoU thresholds used in the calculations.
matched_classes: the class IDs of all matched classes.
Corresponds to the rows of `f1_per_class`.
matched_classes: the class IDs present in either predictions or ground
truth. Corresponds to the rows of `f1_per_class`. Classes that
appear only in predictions (no ground-truth instances) are
included; their per-threshold F1 values will be `0.0`.
small_objects: the F1 metric results
for small objects (area < 32²).
medium_objects: the F1 metric results
+52 -10
View File
@@ -155,6 +155,14 @@ class Precision(Metric):
def _compute(
self, predictions_list: list[Detections], targets_list: list[Detections]
) -> PrecisionResult:
"""Build per-image stats tuples and delegate to class-level computation.
Each stats tuple is ``(matches, confidence, class_ids, true_class_ids)``:
- Both empty: skip (no information).
- Targets empty, predictions present: all predictions are FPs; true_class_ids
is ``zeros((0,))``.
- Targets present: IoU matching produces ``matches`` array.
"""
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats: list[Any] = []
@@ -162,7 +170,22 @@ class Precision(Metric):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) > 0:
if len(targets) == 0 and len(predictions) > 0:
# Only predictions are present (e.g. a background image); every
# prediction is a false positive.
if predictions.class_id is None or predictions.confidence is None:
continue
stats.append(
(
np.zeros(
(len(predictions), iou_thresholds.size), dtype=np.bool_
),
predictions.confidence,
predictions.class_id,
np.zeros((0,), dtype=np.int32),
)
)
elif len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
@@ -247,10 +270,23 @@ class Precision(Metric):
npt.NDArray[np.float64],
npt.NDArray[np.int32],
]:
"""Compute precision scores from concatenated stats across all images.
``unique_classes`` is the union of GT and predicted classes so that
predictions of classes absent from GT still count as false positives.
"""
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
# Predictions whose class never appears in the ground truth are still
# false positives, so include those classes in the confusion matrix
# (their true-instance count is zero).
unique_classes = np.unique(
np.concatenate((true_class_ids, prediction_class_ids))
)
true_classes, true_counts = np.unique(true_class_ids, return_counts=True)
class_counts = np.zeros(unique_classes.shape[0], dtype=int)
class_counts[np.searchsorted(unique_classes, true_classes)] = true_counts
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
@@ -268,9 +304,15 @@ class Precision(Metric):
precision_scores = self._compute_precision(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
precision_scores = np.average(
precision_per_class, axis=0, weights=class_counts
)
if class_counts.sum() == 0:
# No ground-truth support (e.g. only false-positive classes, or a
# size bucket with predictions but no targets): weighting is
# undefined, so report 0 as the empty case did before.
precision_scores = np.zeros(precision_per_class.shape[1])
else:
precision_scores = np.average(
precision_per_class, axis=0, weights=class_counts
)
return precision_scores, precision_per_class, unique_classes
@@ -428,8 +470,6 @@ class Precision(Metric):
raise ValueError(f"Invalid metric target: {self._metric_target}")
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
self, detections: Detections, size_category: ObjectSizeCategory
) -> Detections:
@@ -494,10 +534,12 @@ class PrecisionResult:
precision_scores: the precision scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
precision_per_class: the precision scores per class and
IoU threshold. Shape: `(num_target_classes, num_iou_thresholds)`
IoU threshold. Shape: `(num_classes, num_iou_thresholds)`
iou_thresholds: the IoU thresholds used in the calculations.
matched_classes: the class IDs of all matched classes.
Corresponds to the rows of `precision_per_class`.
matched_classes: the class IDs present in either predictions or ground
truth. Corresponds to the rows of `precision_per_class`. Classes
that appear only in predictions (no ground-truth instances) are
included; their per-threshold precision values will be `0.0`.
small_objects: the Precision metric results
for small objects (area < 32²).
medium_objects: the Precision metric results
+2 -2
View File
@@ -4,7 +4,7 @@ from typing import cast
import numpy as np
import numpy.typing as npt
from deprecate import deprecated_class
from deprecate import TargetMode, deprecated_class
from supervision.detection.core import Detections
from supervision.detection.utils.iou_and_nms import box_iou_batch
@@ -15,7 +15,7 @@ from supervision.tracker.byte_tracker.utils import IdCounter
@deprecated_class(
target=None,
target=TargetMode.NOTIFY,
deprecated_in="0.28.0",
remove_in="0.30.0",
)
+3 -1
View File
@@ -158,7 +158,9 @@ def pillow_to_cv2(image: Image.Image) -> npt.NDArray[np.uint8]:
"""
scene = np.array(image)
scene = cv2.cvtColor(scene, cv2.COLOR_RGB2BGR)
return scene.astype(np.uint8)
# cvtColor already returns uint8 here, so astype is a no-op other than the
# full-image copy it forces; copy=False keeps the dtype guard without it.
return scene.astype(np.uint8, copy=False)
def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image:
+23 -5
View File
@@ -28,8 +28,12 @@ def list_files_with_extensions(
Args:
directory: The directory path as a string or Path object.
extensions: A list of file extensions to filter.
Default is None, which lists all files.
extensions: A list of file extensions to filter. Extensions may be
supplied with or without a leading dot (e.g. ``'jpg'`` and
``'.jpg'`` are equivalent). Matching is case-insensitive.
Multi-part extensions are supported (e.g. ``'tar.gz'``). Pass
``None`` (default) to list all files; pass an empty list to
return no files.
Returns:
A list of Path objects for the matching files.
@@ -50,9 +54,9 @@ def list_files_with_extensions(
>>> files = sv.list_files_with_extensions(directory=tmpdir)
>>> len(files)
3
>>> # List only files with '.txt' and '.md' extensions
>>> # Leading dot accepted; matching is case-insensitive
>>> files = sv.list_files_with_extensions(
... directory=tmpdir, extensions=['txt', 'md'])
... directory=tmpdir, extensions=['.txt', 'md'])
>>> len(files)
2
@@ -63,8 +67,22 @@ def list_files_with_extensions(
files_with_extensions: list[Path] = []
if extensions is not None:
candidates = [p for p in directory.glob("*") if p.is_file()]
path_index: dict[Path, set[str]] = {}
for path in candidates:
suffixes = [suffix.lower().lstrip(".") for suffix in path.suffixes]
path_index[path] = {
".".join(suffixes[index:]) for index in range(len(suffixes))
}
seen_paths: set[Path] = set()
for ext in extensions:
files_with_extensions.extend(directory.glob(f"*.{ext}"))
normalized_extension = ext.lower().lstrip(".")
if not normalized_extension:
continue
for path, path_extensions in path_index.items():
if path not in seen_paths and normalized_extension in path_extensions:
files_with_extensions.append(path)
seen_paths.add(path)
else:
files_with_extensions.extend(directory.glob("*"))
+17 -14
View File
@@ -11,7 +11,7 @@ from typing import Any, Literal, cast
import cv2
import numpy as np
import numpy.typing as npt
from deprecate import deprecated
from deprecate import TargetMode, deprecated
from PIL import Image
from supervision.draw.base import ImageType
@@ -217,14 +217,20 @@ def letterbox_image(
maintaining aspect ratio.
Args:
image: The image to resize and pad.
image: The image to resize and pad. Accepts BGR arrays of shape
``(H, W, 3)``, BGRA arrays of shape ``(H, W, 4)``, grayscale
arrays of shape ``(H, W)``, or a PIL ``Image``.
resolution_wh: Target resolution as `(width, height)`.
color: Padding color. If tuple, should
be in BGR format. Defaults to `Color.BLACK`.
color: Padding color. If tuple, should be in BGR format.
Defaults to `Color.BLACK`.
Returns:
Letterboxed image matching input
type.
Letterboxed image matching input type.
Note:
For BGRA inputs, the alpha channel in the padding region is set to
0 (fully transparent). Grayscale inputs receive scalar padding
from ``color[0]``.
Examples:
```pycon
@@ -238,6 +244,9 @@ def letterbox_image(
... )
>>> letterboxed_image.shape
(1000, 1000, 3)
>>> gray = np.zeros((4, 6), dtype=np.uint8)
>>> sv.letterbox_image(image=gray, resolution_wh=(10, 10)).shape
(10, 10)
```
@@ -263,17 +272,11 @@ def letterbox_image(
value=color,
)
if image.shape[2] == 4:
image[:padding_top, :, 3] = 0
image[height_new - padding_bottom :, :, 3] = 0
image[:, :padding_left, 3] = 0
image[:, width_new - padding_right :, 3] = 0
return image_with_borders
@deprecated( # type: ignore[untyped-decorator]
target=None,
target=TargetMode.NOTIFY,
deprecated_in="0.27.0",
remove_in="0.31.0",
)
@@ -555,7 +558,7 @@ class ImageSink:
@deprecated( # type: ignore[untyped-decorator]
target=None,
target=TargetMode.NOTIFY,
deprecated_in="0.27.0",
remove_in="0.31.0",
)
+89 -2
View File
@@ -1,5 +1,9 @@
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import threading
import time
from collections import deque
@@ -133,6 +137,73 @@ class VideoSink:
self.__writer.release()
def _mux_audio(source_path: str, video_path: str) -> None:
"""Mux audio from `source_path` into `video_path` in-place using ffmpeg.
Args:
source_path: Path to the original video file containing the audio stream.
video_path: Path to the video-only file to be updated with audio.
"""
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path is None:
logger.warning(
"ffmpeg not found on PATH. Audio will not be preserved. "
"Install ffmpeg to enable audio preservation."
)
return
tmp_path = None
try:
tmp_fd, tmp_path = tempfile.mkstemp(
suffix=os.path.splitext(video_path)[1],
dir=os.path.dirname(os.path.abspath(video_path)),
)
os.close(tmp_fd)
result = subprocess.run( # noqa: S603
[
ffmpeg_path,
"-y",
"-loglevel",
"error",
"-nostats",
"-i",
video_path,
"-i",
source_path,
"-c:v",
"copy",
"-c:a",
"copy",
"-map",
"0:v:0",
"-map",
"1:a:0?",
"-shortest",
tmp_path,
],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
timeout=300,
)
if result.returncode != 0:
stderr_msg = result.stderr.decode(errors="replace").strip()
logger.warning(
"ffmpeg failed to mux audio (return code %d)%s. "
"The output video will not have audio.",
result.returncode,
f": {stderr_msg}" if stderr_msg else "",
)
return
os.replace(tmp_path, video_path)
except Exception as exc:
logger.warning(
"Audio muxing failed: %s. Output video will not have audio.", exc
)
finally:
if tmp_path is not None and os.path.exists(tmp_path):
os.remove(tmp_path)
def _validate_and_setup_video(
source_path: str, start: int, end: int | None, iterative_seek: bool = False
) -> tuple[cv2.VideoCapture, int, int]:
@@ -219,6 +290,7 @@ def process_video(
writer_buffer: int = 32,
show_progress: bool = False,
progress_message: str = "Processing video",
preserve_audio: bool = False,
) -> None:
"""
Process video frames asynchronously using a threaded pipeline.
@@ -252,13 +324,18 @@ def process_video(
show_progress: Whether to display a tqdm progress bar during processing.
Default is False.
progress_message: Description shown in the progress bar.
preserve_audio: If True, copy the audio stream from `source_path` into
`target_path` after frame processing. Requires `ffmpeg` on PATH
(e.g. `apt install ffmpeg`, `brew install ffmpeg`). If ffmpeg is
not found or the mux step fails, a warning is logged and the output
video is saved without audio — no exception is raised. Audio is
truncated to match the processed video duration. Default is False.
Returns:
None
Example:
```python
import cv2
import supervision as sv
from rfdetr import RFDETRMedium
@@ -267,10 +344,11 @@ def process_video(
def callback(frame, frame_index):
return model.predict(frame)
process_video(
sv.process_video(
source_path="source.mp4",
target_path="target.mp4",
callback=callback,
preserve_audio=True,
)
```
"""
@@ -368,6 +446,15 @@ def process_video(
if exception_in_worker is not None:
raise exception_in_worker
if preserve_audio:
if writer_worker.is_alive():
logger.warning(
"Writer thread did not finish in time; skipping audio mux "
"to avoid reading an incomplete output file."
)
else:
_mux_audio(source_path=source_path, video_path=target_path)
class FPSMonitor:
"""
+180 -36
View File
@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Optional
import numpy as np
from deprecate import deprecated, void
@@ -7,11 +7,11 @@ from supervision.detection.compact_mask import CompactMask
from supervision.utils.internal import warn_deprecated
def validate_xyxy(xyxy: Any) -> None:
def _validate_xyxy(xyxy: Any) -> None:
"""Validate that xyxy is a 2D np.ndarray with shape (N, 4).
```pycon
>>> validate_xyxy(np.array([[0, 0, 1, 1], [1, 1, 2, 2]]))
>>> _validate_xyxy(np.array([[0, 0, 1, 1], [1, 1, 2, 2]]))
```
"""
@@ -25,7 +25,16 @@ def validate_xyxy(xyxy: Any) -> None:
)
def validate_mask(mask: Any, n: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_xyxy,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_xyxy(xyxy: Any) -> None:
void(xyxy)
def _validate_mask(mask: Any, n: int) -> None:
if mask is None:
return
@@ -63,7 +72,16 @@ def validate_mask(mask: Any, n: int) -> None:
)
def validate_class_id(class_id: Any, n: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_mask,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_mask(mask: Any, n: int) -> None:
void(mask, n)
def _validate_class_id(class_id: Any, n: int) -> None:
expected_shape = f"({n},)"
actual_shape = str(getattr(class_id, "shape", None))
is_valid = class_id is None or (
@@ -76,7 +94,17 @@ def validate_class_id(class_id: Any, n: int) -> None:
)
def validate_confidence(confidence: Any, n: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_class_id,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_class_id(class_id: Any, n: int) -> None:
void(class_id, n)
def _validate_confidence(confidence: Any, n: int) -> None:
"""Validate detection-level confidence: 1D ``np.ndarray`` with shape ``(n,)``."""
expected_shape = f"({n},)"
actual_shape = str(getattr(confidence, "shape", None))
is_valid = confidence is None or (
@@ -89,21 +117,48 @@ def validate_confidence(confidence: Any, n: int) -> None:
)
def validate_key_point_confidence(confidence: Any, n: int, m: int) -> None:
expected_shape = f"({n, m})"
@deprecated( # type: ignore[untyped-decorator]
target=_validate_confidence,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_confidence(confidence: Any, n: int) -> None:
void(confidence, n)
def _validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
"""Validate per-keypoint confidence: 2D ``np.ndarray`` with shape ``(n, m)``."""
actual_shape = str(getattr(confidence, "shape", None))
if confidence is not None:
is_valid = isinstance(confidence, np.ndarray) and confidence.shape == (n, m)
if not is_valid:
if not isinstance(confidence, np.ndarray) or confidence.ndim != 2:
raise ValueError(
f"confidence must be a 1D np.ndarray with shape {expected_shape}, but "
f"keypoint_confidence must be a 2D np.ndarray with shape (n, m), but "
f"got shape {actual_shape}"
)
if confidence.shape[0] != n:
raise ValueError(
f"keypoint_confidence first dimension must be {n}, "
f"but got shape {actual_shape}"
)
if n > 0 and confidence.shape[1] != m:
raise ValueError(
f"keypoint_confidence second dimension must be {m}, but "
f"got shape {actual_shape}"
)
@deprecated( # type: ignore[untyped-decorator]
target=validate_key_point_confidence,
target=_validate_keypoint_confidence,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_key_point_confidence(confidence: Any, n: int, m: int) -> None:
void(confidence, n, m)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_keypoint_confidence,
deprecated_in="0.27.0",
remove_in="0.31.0",
)
@@ -111,7 +166,7 @@ def validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
void(confidence, n, m)
def validate_tracker_id(tracker_id: Any, n: int) -> None:
def _validate_tracker_id(tracker_id: Any, n: int) -> None:
expected_shape = f"({n},)"
actual_shape = str(getattr(tracker_id, "shape", None))
is_valid = tracker_id is None or (
@@ -124,7 +179,16 @@ def validate_tracker_id(tracker_id: Any, n: int) -> None:
)
def validate_data(data: dict[str, Any], n: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_tracker_id,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_tracker_id(tracker_id: Any, n: int) -> None:
void(tracker_id, n)
def _validate_data(data: dict[str, Any], n: int) -> None:
for key, value in data.items():
if isinstance(value, list):
if len(value) != n:
@@ -140,20 +204,80 @@ def validate_data(data: dict[str, Any], n: int) -> None:
raise ValueError(f"Value for key '{key}' must be a list or np.ndarray")
def validate_xy(xy: Any, n: int, m: int) -> None:
expected_shape = f"({n, m},)"
@deprecated( # type: ignore[untyped-decorator]
target=_validate_data,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_data(data: dict[str, Any], n: int) -> None:
void(data, n)
def _validate_xy(xy: Any, n: int, m: int) -> None:
expected_shape = f"({n}, {m}, 2) or ({n}, {m}, 3)"
actual_shape = str(getattr(xy, "shape", None))
is_valid = isinstance(xy, np.ndarray) and (
xy.shape == (n, m, 2) or xy.shape == (n, m, 3)
)
if not is_valid:
if not isinstance(xy, np.ndarray) or xy.ndim != 3 or xy.shape[2] not in (2, 3):
raise ValueError(
f"xy must be a 2D np.ndarray with shape {expected_shape}, but got shape "
f"xy must be a 3D np.ndarray with shape {expected_shape}, but got shape "
f"{actual_shape}"
)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_xy,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_xy(xy: Any, n: int, m: int) -> None:
void(xy, n, m)
def _validate_visible(visible: Any, n: int, m: int) -> None:
"""Validate per-keypoint visibility mask.
Expects a 2D bool ``np.ndarray`` with shape ``(n, m)``.
"""
if visible is None:
return
actual_shape = str(getattr(visible, "shape", None))
if not isinstance(visible, np.ndarray) or visible.ndim != 2:
raise ValueError(
"visible must be a 2D np.ndarray with shape (n, m), but "
f"got shape {actual_shape}"
)
if visible.shape[0] != n:
raise ValueError(
f"visible first dimension must be {n}, but got shape {actual_shape}"
)
if n > 0 and visible.shape[1] != m:
raise ValueError(
f"visible second dimension must be {m}, but got shape {actual_shape}"
)
def _validate_detections_fields(
xyxy: Any,
mask: Any,
class_id: Any,
confidence: Any,
tracker_id: Any,
data: dict[str, Any],
) -> None:
_validate_xyxy(xyxy)
n = len(xyxy)
_validate_mask(mask, n)
_validate_class_id(class_id, n)
_validate_confidence(confidence, n)
_validate_tracker_id(tracker_id, n)
_validate_data(data, n)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_detections_fields,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_detections_fields(
xyxy: Any,
mask: Any,
@@ -162,31 +286,42 @@ def validate_detections_fields(
tracker_id: Any,
data: dict[str, Any],
) -> None:
validate_xyxy(xyxy)
n = len(xyxy)
validate_mask(mask, n)
validate_class_id(class_id, n)
validate_confidence(confidence, n)
validate_tracker_id(tracker_id, n)
validate_data(data, n)
void(xyxy, mask, class_id, confidence, tracker_id, data)
def validate_key_points_fields(
def _validate_keypoints_fields(
xy: Any,
class_id: Any,
confidence: Any,
data: dict[str, Any],
detection_confidence: Any = None,
visible: Any = None,
data: Optional[dict[str, Any]] = None,
) -> None:
n = len(xy)
m = len(xy[0]) if len(xy) > 0 else 0
validate_xy(xy, n, m)
validate_class_id(class_id, n)
validate_key_point_confidence(confidence, n, m)
validate_data(data, n)
_validate_xy(xy, n, m)
_validate_class_id(class_id, n)
_validate_keypoint_confidence(confidence, n, m)
if detection_confidence is not None:
_validate_confidence(detection_confidence, n)
_validate_visible(visible, n, m)
if data is not None:
_validate_data(data, n)
@deprecated( # type: ignore[untyped-decorator]
target=validate_key_points_fields,
target=_validate_keypoints_fields,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_key_points_fields(
xy: Any, class_id: Any, confidence: Any, data: dict[str, Any]
) -> None:
void(xy, class_id, confidence, data)
@deprecated( # type: ignore[untyped-decorator]
target=_validate_keypoints_fields,
deprecated_in="0.27.0",
remove_in="0.31.0",
)
@@ -196,7 +331,7 @@ def validate_keypoints_fields(
void(xy, class_id, confidence, data)
def validate_resolution(resolution: Any) -> tuple[int, int]:
def _validate_resolution(resolution: Any) -> tuple[int, int]:
if not (isinstance(resolution, tuple) and len(resolution) == 2):
raise ValueError(
f"""
@@ -217,3 +352,12 @@ def validate_resolution(resolution: Any) -> tuple[int, int]:
f"Both dimensions in resolution must be positive. Got ({w}, {h})."
)
return w, h
@deprecated( # type: ignore[untyped-decorator]
target=_validate_resolution,
deprecated_in="0.29.0",
remove_in="0.32.0",
)
def validate_resolution(resolution: Any) -> tuple[int, int]:
return void(resolution) # type: ignore[no-any-return]
+215
View File
@@ -2,6 +2,8 @@
Tests for supervision/annotators/core.py
"""
import warnings
import numpy as np
import pytest
@@ -17,6 +19,7 @@ from supervision.annotators.core import (
DotAnnotator,
EllipseAnnotator,
HaloAnnotator,
HeatMapAnnotator,
LabelAnnotator,
MaskAnnotator,
OrientedBoxAnnotator,
@@ -27,8 +30,10 @@ from supervision.annotators.core import (
RoundBoxAnnotator,
TraceAnnotator,
TriangleAnnotator,
_paint_masks_by_area,
)
from supervision.annotators.utils import ColorLookup
from supervision.detection.compact_mask import CompactMask
from supervision.detection.core import Detections
from supervision.draw.color import Color
from supervision.geometry.core import Position
@@ -360,6 +365,216 @@ class TestHaloAnnotator:
)
assert np.array_equal(result_bool, result_uint8)
def test_annotate_with_all_false_mask_preserves_scene(self):
"""Test that an all-False mask leaves the scene unchanged, not corrupted."""
scene = np.full((100, 100, 3), 127, dtype=np.uint8)
masks = [np.zeros((100, 100), dtype=bool)]
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=masks, class_id=[0]
)
result = HaloAnnotator().annotate(scene=scene.copy(), detections=detections)
assert np.array_equal(result, scene)
class TestPaintMasksByArea:
"""Tests for the _paint_masks_by_area helper function."""
def test_paint_masks_by_area_is_noop_without_masks(self):
"""_paint_masks_by_area is a no-op when detections carry no mask."""
canvas = np.full((10, 10, 3), 7, dtype=np.uint8)
detections = _create_detections(xyxy=[[1, 1, 8, 8]], class_id=[0])
_paint_masks_by_area(canvas, detections, Color.RED, ColorLookup.INDEX)
assert np.array_equal(canvas, np.full((10, 10, 3), 7, dtype=np.uint8))
def test_union_accumulation_dense(self):
"""Dense path: collect_union=True returns array covering all painted pixels."""
height, width = 50, 60
canvas = np.zeros((height, width, 3), dtype=np.uint8)
masks = [np.zeros((height, width), dtype=bool)]
masks[0][5:20, 10:40] = True
detections = _create_detections(
xyxy=[[10.0, 5.0, 40.0, 20.0]], mask=masks, class_id=[0]
)
result_union = _paint_masks_by_area(
canvas, detections, Color.RED, ColorLookup.INDEX, collect_union=True
)
assert result_union is not None
# every painted pixel must be in the union (RED is BGR (0, 0, 255),
# so detect painted pixels via any non-zero channel)
painted = canvas.any(axis=-1)
assert np.array_equal(painted, result_union)
def test_union_accumulation_compact(self):
"""CompactMask path: collect_union=True returns array matching dense."""
height, width = 50, 60
mask = np.zeros((height, width), dtype=bool)
mask[5:20, 10:40] = True
xyxy = np.array([[10.0, 5.0, 40.0, 20.0]])
canvas_dense = np.zeros((height, width, 3), dtype=np.uint8)
dense = _create_detections(xyxy=xyxy.tolist(), mask=[mask], class_id=[0])
union_dense = _paint_masks_by_area(
canvas_dense, dense, Color.RED, ColorLookup.INDEX, collect_union=True
)
canvas_compact = np.zeros((height, width, 3), dtype=np.uint8)
compact = _create_detections(xyxy=xyxy.tolist(), mask=[mask], class_id=[0])
compact.mask = CompactMask.from_dense(
np.array([mask]), compact.xyxy, (height, width)
)
union_compact = _paint_masks_by_area(
canvas_compact, compact, Color.RED, ColorLookup.INDEX, collect_union=True
)
assert union_dense is not None
assert union_compact is not None
# compact union must cover exactly the same pixels as dense union
assert np.array_equal(union_dense, union_compact)
def test_compact_mask_drops_pixels_outside_bbox(self):
"""CompactMask is lossy: True pixels outside xyxy bbox are silently dropped.
This test documents that compact and dense paths diverge when a mask has
True pixels outside its bounding box — the 'bit-identical' claim holds
only for bbox-contained masks.
"""
height, width = 50, 60
mask = np.zeros((height, width), dtype=bool)
mask[5:25, 10:40] = True # mask extends 5 rows beyond bbox bottom
bbox = [[10.0, 5.0, 40.0, 20.0]] # y2=20 clips the mask at row 20
canvas_dense = np.zeros((height, width, 3), dtype=np.uint8)
dense = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
_paint_masks_by_area(canvas_dense, dense, Color.RED, ColorLookup.INDEX)
canvas_compact = np.zeros((height, width, 3), dtype=np.uint8)
compact = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
compact.mask = CompactMask.from_dense(
np.array([mask]), compact.xyxy, (height, width)
)
_paint_masks_by_area(canvas_compact, compact, Color.RED, ColorLookup.INDEX)
# Dense paints all True pixels incl. rows 21-24; compact only within bbox.
assert not np.array_equal(canvas_dense, canvas_compact), (
"Expected divergence: compact mask drops True pixels outside bbox"
)
# Compact subset: every pixel painted by compact is also painted by dense.
compact_painted = canvas_compact.any(axis=-1)
dense_painted = canvas_dense.any(axis=-1)
assert np.all(dense_painted[compact_painted])
class TestCompactMaskParity:
"""Tests that CompactMask and dense mask produce identical annotator output."""
@pytest.mark.parametrize(
"annotator_factory",
[
pytest.param(
lambda: MaskAnnotator(opacity=1.0, color_lookup=ColorLookup.INDEX),
id="mask",
),
pytest.param(
lambda: HaloAnnotator(kernel_size=15, color_lookup=ColorLookup.INDEX),
id="halo",
),
],
)
def test_annotator_compact_mask_matches_dense_mask(self, annotator_factory):
"""CompactMask detections annotate identically to dense bool masks."""
height, width = 120, 160
rng = np.random.default_rng(0)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
boxes = [[10, 10, 70, 60], [40, 30, 150, 110], [90, 70, 140, 115]]
masks = []
for x1, y1, x2, y2 in boxes:
mask = np.zeros((height, width), dtype=bool)
mask[y1 : y2 + 1, x1 : x2 + 1] = True
masks.append(mask)
class_id = [0, 1, 2]
xyxy = [[float(value) for value in box] for box in boxes]
dense = _create_detections(xyxy=xyxy, mask=masks, class_id=class_id)
compact = _create_detections(xyxy=xyxy, mask=masks, class_id=class_id)
compact.mask = CompactMask.from_dense(
np.array(masks), compact.xyxy, (height, width)
)
result_dense = annotator_factory().annotate(
scene=scene.copy(), detections=dense
)
result_compact = annotator_factory().annotate(
scene=scene.copy(), detections=compact
)
assert not np.array_equal(result_dense, scene), "annotator painted nothing"
assert np.array_equal(result_dense, result_compact)
def test_annotator_compact_mask_handles_edge_clipping(self):
"""CompactMask detection straddling image edge paints via NumPy clip."""
height, width = 50, 60
rng = np.random.default_rng(42)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
# Box extends 10 pixels beyond right/bottom edges
mask = np.zeros((height, width), dtype=bool)
mask[40:height, 50:width] = True
bbox = [[50.0, 40.0, width + 10.0, height + 10.0]]
detections = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
detections.mask = CompactMask.from_dense(
np.array([mask]), detections.xyxy, (height, width)
)
annotator = MaskAnnotator(opacity=1.0, color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=scene.copy(), detections=detections)
# Result must differ from scene (something was painted) and must not raise
assert not np.array_equal(result, scene), "Expected pixels to be painted"
class TestHeatMapAnnotator:
"""Tests for HeatMapAnnotator class"""
def test_annotate_with_no_detections_does_not_warn(
self, test_image: np.ndarray
) -> None:
"""Empty detections must not trigger a divide-by-zero RuntimeWarning."""
detections = Detections.empty()
annotator = HeatMapAnnotator()
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert np.array_equal(test_image, result)
def test_annotate_with_single_detection(self, test_image: np.ndarray) -> None:
"""Single detection must produce visible heat — result differs from input."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert not np.array_equal(test_image, result)
def test_annotate_state_preserved_after_empty_call(
self, test_image: np.ndarray
) -> None:
"""Empty call must not poison accumulated heat."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
annotator.annotate(scene=test_image.copy(), detections=Detections.empty())
result = annotator.annotate(scene=test_image.copy(), detections=detections)
assert not np.array_equal(test_image, result)
def test_annotate_empty_after_real_does_not_warn(
self, test_image: np.ndarray
) -> None:
"""Empty call after heat accumulated must not trigger RuntimeWarning."""
annotator = HeatMapAnnotator()
detections = _create_detections(xyxy=[[20, 20, 60, 60]])
annotator.annotate(scene=test_image.copy(), detections=detections)
with warnings.catch_warnings():
warnings.simplefilter("error", RuntimeWarning)
annotator.annotate(scene=test_image.copy(), detections=Detections.empty())
class TestEllipseAnnotator:
"""Tests for EllipseAnnotator class"""
+148
View File
@@ -0,0 +1,148 @@
"""Regression tests for docs/detection/annotators.md tab structure."""
import ast
import re
from pathlib import Path
import pytest
_REPO_ROOT = Path(__file__).resolve().parent
while not (_REPO_ROOT / "pyproject.toml").exists():
_REPO_ROOT = _REPO_ROOT.parent
REPO_ROOT = _REPO_ROOT
EXPECTED_ANNOTATOR_TAB_GROUPS: dict[str, list[str]] = {
"Outlines": [
"Box",
"RoundBox",
"BoxCorner",
"Circle",
"Ellipse",
"Polygon",
],
"Shading": ["Color", "Halo", "Mask"],
"Markers": ["Dot", "Triangle"],
"Labels": ["Label", "RichLabel"],
"Transformative": ["Blur", "Pixelate"],
"Tracking & Aggregation": ["Trace", "HeatMap"],
"Others": [
"PercentageBar",
"Icon",
"Background Color",
"Comparison",
],
}
def _extract_annotator_tab_groups() -> dict[str, list[str]]:
"""Parse annotator tab structure from docs/detection/annotators.md.
Returns:
Mapping of category name to list of annotator tab labels within that
category.
"""
docs_path = REPO_ROOT / "docs" / "detection" / "annotators.md"
content = docs_path.read_text(encoding="utf-8")
start_marker = '=== "Outlines"'
end_marker = "Try Supervision Annotators on your own image"
start = content.find(start_marker)
if start == -1:
pytest.fail(
f"Could not find start marker {start_marker!r} in {docs_path} while "
"parsing annotator example tab groups."
)
end = content.find(end_marker, start)
if end == -1:
pytest.fail(
f"Could not find end marker {end_marker!r} in {docs_path} while "
"parsing annotator example tab groups."
)
if end <= start:
pytest.fail(
f"End marker {end_marker!r} must appear after start marker "
f"{start_marker!r} in {docs_path}."
)
example_section = content[start:end]
groups: dict[str, list[str]] = {}
current_group = None
for line in example_section.splitlines():
if match := re.match(r'^(?P<indent>\s*)=== "(?P<label>[^"]+)"\s*$', line):
indent = 1 if match.group("indent") else 0
label = match.group("label")
if indent == 0:
current_group = label
groups[current_group] = []
elif indent > 0 and current_group:
groups[current_group].append(label)
return groups
def test_all_expected_annotators_have_tab_entries() -> None:
"""Assert every annotator in EXPECTED_ANNOTATOR_TAB_GROUPS has a tab in the docs.
Tests flat membership only — does not enforce which category each annotator
belongs to. Update EXPECTED_ANNOTATOR_TAB_GROUPS when adding or removing
annotator tab entries.
"""
tab_groups = _extract_annotator_tab_groups()
actual_tabs = {tab for group in tab_groups.values() for tab in group}
expected_tabs = {
tab for group in EXPECTED_ANNOTATOR_TAB_GROUPS.values() for tab in group
}
assert actual_tabs == expected_tabs, (
f"Tab set mismatch. "
f"Missing from docs: {expected_tabs - actual_tabs!r}. "
f"Extra in docs (add to EXPECTED_ANNOTATOR_TAB_GROUPS): "
f"{actual_tabs - expected_tabs!r}."
)
def test_annotator_example_tab_groups_stay_within_material_limit() -> None:
"""Assert no tab group exceeds MkDocs Material's 20-tab rendering limit."""
tab_groups = _extract_annotator_tab_groups()
# Outer-tab guard is defense-in-depth; the original failure was strictly an
# inner-tab overflow (22 inner tabs in a single flat group).
assert len(tab_groups) <= 20
assert all(len(group) <= 20 for group in tab_groups.values())
def test_annotator_code_examples_have_no_tuple_assignment() -> None:
"""Assert no annotated_frame assignment wraps the call in a tuple.
Guards against the paren-comma typo: ``annotated_frame = (call(),)``
which creates a 1-tuple instead of the annotated image.
"""
docs_path = REPO_ROOT / "docs" / "detection" / "annotators.md"
content = docs_path.read_text(encoding="utf-8")
fenced_blocks = re.findall(r"```python\n(.*?)```", content, re.DOTALL)
for block_idx, block in enumerate(fenced_blocks, 1):
try:
tree = ast.parse(block)
except SyntaxError:
continue
for node in ast.walk(tree):
if (
isinstance(node, ast.Assign)
and any(
isinstance(t, ast.Name) and t.id == "annotated_frame"
for t in node.targets
)
and isinstance(node.value, ast.Tuple)
):
pytest.fail(
f"Block {block_idx}: 'annotated_frame' assigned a tuple "
"(paren-comma typo). "
"Use 'annotated_frame = call()' not 'annotated_frame = (call(),)'."
)
+3
View File
@@ -1,6 +1,9 @@
import matplotlib
import numpy as np
import pytest
matplotlib.use("Agg")
import supervision as sv
from tests.helpers import _create_key_points
+779 -6
View File
@@ -2,19 +2,23 @@ from __future__ import annotations
import json
from contextlib import ExitStack as DoesNotRaise
from pathlib import Path
import cv2
import numpy as np
import pytest
from supervision import Detections
from supervision import DetectionDataset, Detections
from supervision.dataset.formats.coco import (
build_coco_class_index_mapping,
classes_to_coco_categories,
coco_annotations_to_detections,
coco_annotations_to_masks,
coco_categories_to_classes,
detections_to_coco_annotations,
group_coco_annotations_by_image_id,
load_coco_annotations,
save_coco_annotations,
)
@@ -40,6 +44,14 @@ def mock_coco_annotation(
}
def _empty_raw_segs(n: int) -> np.ndarray:
"""Object-dtype array of n empty lists for coco_raw_segmentation (bbox-only)."""
arr = np.empty(n, dtype=object)
for i in range(n):
arr[i] = []
return arr
@pytest.fixture
def coco_data_with_and_without_segmentation() -> dict[str, object]:
return {
@@ -279,6 +291,7 @@ def test_group_coco_annotations_by_image_id(
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={"coco_raw_segmentation": _empty_raw_segs(1)},
),
DoesNotRaise(),
), # single image annotations
@@ -297,6 +310,7 @@ def test_group_coco_annotations_by_image_id(
data={
"iscrowd": np.array([0], dtype=int),
"area": np.array([100 * 100]),
"coco_raw_segmentation": _empty_raw_segs(1),
},
),
DoesNotRaise(),
@@ -318,6 +332,7 @@ def test_group_coco_annotations_by_image_id(
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
),
class_id=np.array([0, 0], dtype=int),
data={"coco_raw_segmentation": _empty_raw_segs(2)},
),
DoesNotRaise(),
), # two image annotations
@@ -341,6 +356,7 @@ def test_group_coco_annotations_by_image_id(
data={
"iscrowd": np.array([0, 0], dtype=int),
"area": np.array([100 * 100, 100 * 100]),
"coco_raw_segmentation": _empty_raw_segs(2),
},
),
DoesNotRaise(),
@@ -830,11 +846,11 @@ def test_build_coco_class_index_mapping(
0,
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
category_id=1, bbox=(0, 0, 100, 100), area=100 * 100
)
],
DoesNotRaise(),
), # no segmentation mask
), # no segmentation mask; internal class_id 0 -> COCO category_id 1
(
Detections(
xyxy=np.array([[0, 0, 4, 5]], dtype=np.float32),
@@ -856,7 +872,7 @@ def test_build_coco_class_index_mapping(
0,
[
mock_coco_annotation(
category_id=0,
category_id=1,
bbox=(0, 0, 4, 5),
area=4 * 5,
segmentation=[[0, 0, 0, 4, 3, 4, 3, 0]],
@@ -886,7 +902,7 @@ def test_build_coco_class_index_mapping(
0,
[
mock_coco_annotation(
category_id=0,
category_id=1,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
@@ -919,7 +935,7 @@ def test_build_coco_class_index_mapping(
0,
[
mock_coco_annotation(
category_id=0,
category_id=1,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
@@ -980,6 +996,80 @@ def test_detections_to_coco_annotations_handles_empty_approximated_polygons() ->
assert annotations[0]["iscrowd"] == 0
_DISJOINT_2X2_MASK = np.array(
[
[
[1, 1, 0, 0, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
]
],
dtype=bool,
)
_SINGLE_COMPONENT_MASK = np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
],
dtype=bool,
)
def _make_iscrowd0_detections(mask: np.ndarray) -> Detections:
"""Build a single-detection Detections with iscrowd=0 from a (1, H, W) mask."""
_, h, w = mask.shape
return Detections(
xyxy=np.array([[0, 0, w, h]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask,
data={"iscrowd": np.array([0])},
)
@pytest.mark.parametrize(
("mask", "expected_segment_count"),
[
pytest.param(_DISJOINT_2X2_MASK, 2, id="disjoint-two-parts"),
pytest.param(_SINGLE_COMPONENT_MASK, 1, id="single-component"),
],
)
def test_detections_to_coco_annotations_segmentation_count(
mask: np.ndarray, expected_segment_count: int
) -> None:
"""Non-crowd mask export produces one polygon list entry per connected component."""
annotations, _ = detections_to_coco_annotations(
detections=_make_iscrowd0_detections(mask), image_id=0, annotation_id=0
)
segmentation = annotations[0]["segmentation"]
assert annotations[0]["iscrowd"] == 0
assert isinstance(segmentation, list)
assert len(segmentation) == expected_segment_count
assert all(len(part) >= 6 for part in segmentation)
assert all(np.isfinite(c) for part in segmentation for c in part)
def test_detections_to_coco_annotations_round_trip_disjoint_mask() -> None:
"""Two-part disjoint mask round-trips through COCO export and import unchanged."""
W, H = 5, 5
annotations, _ = detections_to_coco_annotations(
detections=_make_iscrowd0_detections(_DISJOINT_2X2_MASK),
image_id=0,
annotation_id=0,
)
reloaded = coco_annotations_to_masks([annotations[0]], resolution_wh=(W, H))
assert np.array_equal(reloaded[0], _DISJOINT_2X2_MASK[0])
def test_detections_to_coco_annotations_preserves_area_from_data() -> None:
"""area stored in detections.data should be used instead of bbox area."""
detections = Detections(
@@ -1329,3 +1419,686 @@ def test_load_coco_annotations_force_masks_handles_missing_segmentation(
assert image_annotations.mask.shape == (1, 5, 5)
assert not image_annotations.mask.any()
assert np.array_equal(image_annotations.xyxy, np.array([[0, 0, 2, 2]], dtype=float))
@pytest.fixture
def coco_data_with_multi_segment_segmentation() -> dict[str, object]:
return {
"categories": [
{
"id": 1,
"name": "cat_eye",
"supercategory": "animal_parts",
}
],
"images": [
{
"id": 1,
"file_name": "image.jpg",
"width": 5,
"height": 5,
}
],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
# bbox spans both segments; area = sum of two 1x1 polygon areas
"bbox": [0, 0, 5, 5],
"area": 2,
"segmentation": [
[0, 0, 1, 0, 1, 1, 0, 1],
[3, 3, 4, 3, 4, 4, 3, 4],
],
"iscrowd": 0,
}
],
}
class TestFromCocoMasks:
"""Integration: DetectionDataset.from_coco loads multi-segment masks."""
@pytest.mark.parametrize("force_masks", [False, True])
def test_multi_segment_masks_merged(
self,
tmp_path,
coco_data_with_multi_segment_segmentation: dict[str, object],
force_masks: bool,
) -> None:
"""Multi-segment masks merge correctly for both force_masks values."""
images_directory = tmp_path / "images"
images_directory.mkdir()
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(
json.dumps(coco_data_with_multi_segment_segmentation), encoding="utf-8"
)
dataset = DetectionDataset.from_coco(
images_directory_path=str(images_directory),
annotations_path=str(annotations_path),
force_masks=force_masks,
)
annotation = dataset.annotations[str(images_directory / "image.jpg")]
assert annotation.mask is not None
assert annotation.mask.shape == (1, 5, 5)
np.testing.assert_array_equal(
annotation.mask,
np.array(
[
[
[1, 1, 0, 0, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
]
],
dtype=bool,
),
)
def test_multi_segment_masks_uneven_length_no_value_error(self, tmp_path) -> None:
"""Uneven-length segments load without ValueError (issue #1209 regression)."""
images_directory = tmp_path / "images"
images_directory.mkdir()
annotations_path = tmp_path / "annotations.json"
coco_data = {
"categories": [
{"id": 1, "name": "cat_eye", "supercategory": "animal_parts"}
],
"images": [{"id": 1, "file_name": "image.jpg", "width": 5, "height": 5}],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 1,
"bbox": [0, 0, 5, 5],
"area": 2,
"segmentation": [
[0, 0, 1, 0, 1, 1, 0, 1], # 4 points (8 coords)
[3, 3, 4, 3, 4, 4, 3, 4, 2, 4], # 5 points (10 coords)
],
"iscrowd": 0,
}
],
}
annotations_path.write_text(json.dumps(coco_data), encoding="utf-8")
dataset = DetectionDataset.from_coco(
images_directory_path=str(images_directory),
annotations_path=str(annotations_path),
)
annotation = dataset.annotations[str(images_directory / "image.jpg")]
assert annotation.mask is not None
assert annotation.mask.shape == (1, 5, 5)
# --- category_id 1-indexing (regression for #1181) ---
@pytest.mark.parametrize(
("classes", "expected_ids"),
[
([], []), # empty classes
(["object"], [1]), # single class starts at 1
(["cat", "dog", "bird"], [1, 2, 3]), # ids are sequential and 1-indexed
],
)
def test_classes_to_coco_categories_ids_start_at_one(
classes: list[str], expected_ids: list[int]
) -> None:
"""COCO categories[].id must be 1-indexed (COCO spec / CVAT requirement)."""
categories = classes_to_coco_categories(classes=classes)
assert [category["id"] for category in categories] == expected_ids
def test_detections_to_coco_annotations_category_id_is_one_indexed() -> None:
"""Internal class_id k must serialize to COCO category_id k + 1."""
detections = Detections(
xyxy=np.array([[0, 0, 10, 10], [5, 5, 15, 15], [1, 1, 4, 4]], dtype=np.float32),
class_id=np.array([0, 1, 2], dtype=int),
)
annotations, _ = detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
assert [annotation["category_id"] for annotation in annotations] == [1, 2, 3]
def test_coco_round_trip_preserves_class_ids_and_writes_one_indexed_categories(
tmp_path,
) -> None:
"""as_coco -> from_coco is lossless for internal class_ids while the
on-disk COCO category ids are 1-indexed (regression for #1181)."""
classes = ["cat", "dog"]
image_paths: list[str] = []
annotations: dict[str, Detections] = {}
expected_class_ids = {}
for index, class_id in enumerate([0, 1]):
path = str(tmp_path / f"image_{index}.jpg")
assert cv2.imwrite(path, np.zeros((10, 10, 3), dtype=np.uint8))
image_paths.append(path)
detections = Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([class_id], dtype=int),
)
annotations[path] = detections
expected_class_ids[Path(path).name] = class_id
dataset = DetectionDataset(
classes=classes, images=image_paths, annotations=annotations
)
annotation_path = tmp_path / "annotations.json"
dataset.as_coco(annotations_path=str(annotation_path))
# On-disk COCO ids are 1-indexed.
with open(annotation_path) as f:
payload = json.load(f)
assert sorted(category["id"] for category in payload["categories"]) == [1, 2]
assert sorted(ann["category_id"] for ann in payload["annotations"]) == [1, 2]
# Reading back preserves internal 0-indexed class_ids losslessly.
loaded = DetectionDataset.from_coco(
images_directory_path=str(tmp_path),
annotations_path=str(annotation_path),
)
assert loaded.classes == classes
for image_path, _, detections in loaded:
name = Path(image_path).name
assert detections.class_id is not None
assert detections.class_id.tolist() == [expected_class_ids[name]]
# --- save_coco_annotations: cross-split id chaining (regression for #768) ---
def _tiny_detection_dataset(
tmp_path, prefix: str, num_images: int, dets_per_image: int
) -> DetectionDataset:
"""Build a DetectionDataset of ``num_images`` 10x10 RGB images on disk,
each holding ``dets_per_image`` 1x1 detections of class 0. Image content
is irrelevant; only the per-image Detections drive the COCO write path."""
classes = ["object"]
image_paths: list[str] = []
annotations: dict[str, Detections] = {}
for i in range(num_images):
path = str(tmp_path / f"{prefix}_{i}.jpg")
assert cv2.imwrite(path, np.zeros((10, 10, 3), dtype=np.uint8))
image_paths.append(path)
xyxy = np.array(
[[float(j), 0.0, float(j) + 1.0, 1.0] for j in range(dets_per_image)],
dtype=float,
).reshape(-1, 4)
annotations[path] = Detections(
xyxy=xyxy,
class_id=np.zeros(dets_per_image, dtype=int),
confidence=np.ones(dets_per_image, dtype=float),
)
return DetectionDataset(
classes=classes, images=image_paths, annotations=annotations
)
def _read_ids(annotation_path) -> tuple[list[int], list[int]]:
with open(annotation_path) as f:
payload = json.load(f)
image_ids = [img["id"] for img in payload["images"]]
annotation_ids = [ann["id"] for ann in payload["annotations"]]
return image_ids, annotation_ids
def test_save_coco_annotations_defaults_start_at_one(tmp_path):
dataset = _tiny_detection_dataset(tmp_path, "img", num_images=2, dets_per_image=3)
annotation_path = tmp_path / "annotations.json"
next_image_id, next_annotation_id = save_coco_annotations(
dataset=dataset, annotation_path=str(annotation_path)
)
image_ids, annotation_ids = _read_ids(annotation_path)
assert image_ids == [1, 2]
assert annotation_ids == [1, 2, 3, 4, 5, 6]
# Returned ids are one greater than the highest written, ready to chain.
assert next_image_id == 3
assert next_annotation_id == 7
def test_save_coco_annotations_respects_starting_ids(tmp_path):
dataset = _tiny_detection_dataset(tmp_path, "img", num_images=2, dets_per_image=2)
annotation_path = tmp_path / "annotations.json"
next_image_id, next_annotation_id = save_coco_annotations(
dataset=dataset,
annotation_path=str(annotation_path),
starting_image_id=100,
starting_annotation_id=500,
)
image_ids, annotation_ids = _read_ids(annotation_path)
assert image_ids == [100, 101]
assert annotation_ids == [500, 501, 502, 503]
assert next_image_id == 102
assert next_annotation_id == 504
def test_as_coco_chains_ids_across_splits_without_collision(tmp_path):
"""Regression for #768: exporting train/valid/test splits with the
returned ids fed forward yields globally unique image and annotation ids."""
train = _tiny_detection_dataset(tmp_path, "train", num_images=3, dets_per_image=2)
valid = _tiny_detection_dataset(tmp_path, "valid", num_images=2, dets_per_image=4)
test = _tiny_detection_dataset(tmp_path, "test", num_images=1, dets_per_image=5)
train_path = tmp_path / "train.json"
valid_path = tmp_path / "valid.json"
test_path = tmp_path / "test.json"
next_image_id, next_annotation_id = train.as_coco(annotations_path=str(train_path))
next_image_id, next_annotation_id = valid.as_coco(
annotations_path=str(valid_path),
starting_image_id=next_image_id,
starting_annotation_id=next_annotation_id,
)
test.as_coco(
annotations_path=str(test_path),
starting_image_id=next_image_id,
starting_annotation_id=next_annotation_id,
)
all_image_ids: list[int] = []
all_annotation_ids: list[int] = []
for path in (train_path, valid_path, test_path):
image_ids, annotation_ids = _read_ids(path)
all_image_ids.extend(image_ids)
all_annotation_ids.extend(annotation_ids)
assert len(all_image_ids) == len(set(all_image_ids)), (
"image ids collide across splits"
)
assert len(all_annotation_ids) == len(set(all_annotation_ids)), (
"annotation ids collide across splits"
)
# Concrete chained values.
assert all_image_ids == [1, 2, 3, 4, 5, 6]
assert all_annotation_ids == list(range(1, 6 + 8 + 5 + 1))
def test_save_coco_annotations_empty_dataset_returns_starting_ids(tmp_path):
"""An empty dataset writes a valid (but empty) COCO file and returns
the starting ids unchanged so chaining still composes around it."""
dataset = DetectionDataset(classes=["object"], images=[], annotations={})
annotation_path = tmp_path / "annotations.json"
next_image_id, next_annotation_id = save_coco_annotations(
dataset=dataset,
annotation_path=str(annotation_path),
starting_image_id=7,
starting_annotation_id=42,
)
image_ids, annotation_ids = _read_ids(annotation_path)
assert image_ids == []
assert annotation_ids == []
assert next_image_id == 7
assert next_annotation_id == 42
def test_as_coco_without_annotations_path_returns_starting_ids(tmp_path):
"""When only writing images, the starting ids round-trip unchanged so
chaining still works in the images-only branch."""
dataset = _tiny_detection_dataset(tmp_path, "img", num_images=2, dets_per_image=1)
next_image_id, next_annotation_id = dataset.as_coco(
images_directory_path=str(tmp_path / "imgs"),
starting_image_id=42,
starting_annotation_id=99,
)
assert next_image_id == 42
assert next_annotation_id == 99
def test_save_coco_annotations_annotation_image_id_references_correct_image(tmp_path):
"""Every annotation's image_id must reference an image id present in the
same file, even when a non-default starting_image_id is used."""
dataset = _tiny_detection_dataset(tmp_path, "img", num_images=3, dets_per_image=2)
annotation_path = tmp_path / "annotations.json"
save_coco_annotations(
dataset=dataset,
annotation_path=str(annotation_path),
starting_image_id=100,
starting_annotation_id=500,
)
with open(annotation_path) as f:
coco = json.load(f)
image_id_set = {img["id"] for img in coco["images"]}
annotation_image_ids = {ann["image_id"] for ann in coco["annotations"]}
assert annotation_image_ids <= image_id_set, (
"annotation image_id values reference unknown image ids"
)
def test_save_coco_annotations_zero_annotation_images(tmp_path):
"""Dataset with images but zero detections per image: image ids are
assigned sequentially but annotation list stays empty."""
dataset = _tiny_detection_dataset(tmp_path, "img", num_images=2, dets_per_image=0)
annotation_path = tmp_path / "annotations.json"
next_image_id, next_annotation_id = save_coco_annotations(
dataset=dataset, annotation_path=str(annotation_path)
)
image_ids, annotation_ids = _read_ids(annotation_path)
assert image_ids == [1, 2]
assert annotation_ids == []
assert next_image_id == 3
assert next_annotation_id == 1
# --- Regression: legacy 0-indexed COCO files still load correctly (#1181) ---
def test_from_coco_loads_legacy_zero_indexed_category_ids(tmp_path) -> None:
"""COCO files with 0-indexed category ids (written by supervision <=0.28.x)
must still load and produce correct internal 0-indexed class_ids."""
images_dir = tmp_path / "images"
images_dir.mkdir()
img_path = images_dir / "img.jpg"
assert cv2.imwrite(str(img_path), np.zeros((10, 10, 3), dtype=np.uint8))
coco_data = {
"categories": [
{"id": 0, "name": "cat", "supercategory": "none"},
{"id": 1, "name": "dog", "supercategory": "none"},
],
"images": [{"id": 1, "file_name": "img.jpg", "width": 10, "height": 10}],
"annotations": [
{
"id": 1,
"image_id": 1,
"category_id": 0,
"bbox": [0, 0, 5, 5],
"area": 25,
"iscrowd": 0,
},
{
"id": 2,
"image_id": 1,
"category_id": 1,
"bbox": [1, 1, 3, 3],
"area": 9,
"iscrowd": 0,
},
],
}
annotations_path = tmp_path / "annotations.json"
annotations_path.write_text(json.dumps(coco_data), encoding="utf-8")
dataset = DetectionDataset.from_coco(
images_directory_path=str(images_dir),
annotations_path=str(annotations_path),
)
assert dataset.classes == ["cat", "dog"]
dets = dataset.annotations[str(img_path)]
assert dets.class_id is not None
assert sorted(dets.class_id.tolist()) == [0, 1]
# --- save_coco_annotations ValueError guards ---
@pytest.mark.parametrize(
("starting_image_id", "starting_annotation_id"),
[
(0, 1),
(1, 0),
(0, 0),
],
)
def test_save_coco_annotations_rejects_zero_starting_ids(
tmp_path, starting_image_id: int, starting_annotation_id: int
) -> None:
"""starting_image_id and starting_annotation_id below 1 must raise ValueError."""
dataset = DetectionDataset(classes=["object"], images=[], annotations={})
annotation_path = tmp_path / "annotations.json"
with pytest.raises(ValueError, match="must be >= 1"):
save_coco_annotations(
dataset=dataset,
annotation_path=str(annotation_path),
starting_image_id=starting_image_id,
starting_annotation_id=starting_annotation_id,
)
# --- detections_to_coco_annotations: class_id=None guard ---
def test_detections_to_coco_annotations_raises_when_class_id_is_none() -> None:
"""Detections with no class_id must raise ValueError before +1 arithmetic."""
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=None,
)
with pytest.raises(ValueError, match="class_id"):
detections_to_coco_annotations(
detections=detections,
image_id=1,
annotation_id=1,
)
# --- Round-trip: multi-class-per-image case ---
def test_coco_round_trip_multi_class_single_image(tmp_path) -> None:
"""Single image with two detections of different classes round-trips losslessly."""
img_path = str(tmp_path / "img.jpg")
assert cv2.imwrite(img_path, np.zeros((10, 10, 3), dtype=np.uint8))
dataset = DetectionDataset(
classes=["cat", "dog"],
images=[img_path],
annotations={
img_path: Detections(
xyxy=np.array([[0, 0, 5, 5], [1, 1, 4, 4]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
)
},
)
annotation_path = tmp_path / "annotations.json"
dataset.as_coco(annotations_path=str(annotation_path))
with open(annotation_path) as f:
payload = json.load(f)
assert sorted(ann["category_id"] for ann in payload["annotations"]) == [1, 2]
loaded = DetectionDataset.from_coco(
images_directory_path=str(tmp_path),
annotations_path=str(annotation_path),
)
dets = loaded.annotations[img_path]
assert dets.class_id is not None
assert sorted(dets.class_id.tolist()) == [0, 1]
# --- Regression: segmentation round-trip (#2285) ---
def _coco_annotation_with_segmentation(
segmentation: list[list[int]],
bbox: tuple[float, float, float, float] = (0, 0, 5, 5),
area: float = 25,
) -> dict:
return mock_coco_annotation(
annotation_id=1,
image_id=1,
category_id=1,
bbox=bbox,
area=area,
segmentation=segmentation,
)
def _single_image_coco_data(annotation: dict) -> dict[str, object]:
return {
"info": {},
"licenses": [],
"categories": [{"id": 1, "name": "cat", "supercategory": ""}],
"images": [{"id": 1, "file_name": "img.jpg", "width": 10, "height": 10}],
"annotations": [annotation],
}
def test_detections_to_coco_annotations_exports_all_polygons() -> None:
"""All polygons from a multi-component mask must be exported, not just the first."""
# Build a mask with two separate rectangles (disjoint components)
mask = np.zeros((20, 20), dtype=bool)
mask[1:4, 1:4] = True # top-left component
mask[14:18, 14:18] = True # bottom-right component
detections = Detections(
xyxy=np.array([[1, 1, 4, 4]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array([mask]),
data={"iscrowd": np.array([0], dtype=int)},
)
annotations, _ = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
assert len(annotations) == 1
seg = annotations[0]["segmentation"]
# Both components must appear as separate polygon entries (list of lists)
assert isinstance(seg, list), "segmentation must be a list of polygons"
assert len(seg) >= 2
@pytest.mark.parametrize(
("segmentation", "bbox", "area", "expected_min_polygon_count"),
[
pytest.param(
[[0, 0, 4, 0, 4, 4, 0, 4]],
(0, 0, 5, 5),
25,
1,
id="single-polygon",
),
pytest.param(
[[0, 0, 4, 0, 4, 4, 0, 4], [6, 6, 9, 6, 9, 9, 6, 9]],
(0, 0, 9, 9),
32,
2,
id="multi-polygon",
),
],
)
def test_coco_polygon_segmentation_survives_roundtrip(
tmp_path,
segmentation: list[list[int]],
bbox: tuple[float, float, float, float],
area: float,
expected_min_polygon_count: int,
) -> None:
"""COCO polygon segmentation survives the load/export sequence.
1. Write source COCO JSON with polygon segmentation.
2. Load it through DetectionDataset.from_coco().
3. Export it back to COCO JSON with as_coco().
4. Assert the exported segmentation keeps the expected polygon component count.
"""
images_dir = tmp_path / "images"
images_dir.mkdir()
img_path = images_dir / "img.jpg"
assert cv2.imwrite(str(img_path), np.zeros((10, 10, 3), dtype=np.uint8))
# 1. Write source COCO JSON with polygon segmentation.
ann_path = tmp_path / "annotations.json"
ann_path.write_text(
json.dumps(
_single_image_coco_data(
_coco_annotation_with_segmentation(
segmentation=segmentation, bbox=bbox, area=area
)
)
),
encoding="utf-8",
)
# 2. Load it through the internal DetectionDataset representation.
ds = DetectionDataset.from_coco(
images_directory_path=str(images_dir),
annotations_path=str(ann_path),
)
# 3. Export it back to COCO JSON.
out_ann_path = tmp_path / "out_annotations.json"
ds.as_coco(annotations_path=str(out_ann_path))
with open(out_ann_path) as f:
out = json.load(f)
# 4. Assert polygon component count survives the load/export sequence.
assert len(out["annotations"]) == 1
seg = out["annotations"][0]["segmentation"]
assert isinstance(seg, list)
assert len(seg) >= expected_min_polygon_count
def test_coco_raw_segmentation_preserved_when_masks_not_decoded() -> None:
"""When masks are NOT decoded (with_masks=False), raw polygon data stored in
data['segmentation'] is used as a lossless fallback so as_coco() still emits
non-empty segmentation."""
image_annotations = [
_coco_annotation_with_segmentation(segmentation=[[0, 0, 4, 0, 4, 4, 0, 4]])
]
# Load WITHOUT mask decoding — mask must be None
detections = coco_annotations_to_detections(
image_annotations=image_annotations,
resolution_wh=(10, 10),
with_masks=False,
)
assert detections.mask is None
# Raw segmentation must be stored in data for fallback
assert "coco_raw_segmentation" in detections.data
# Export must still produce non-empty segmentation via fallback
annotations, _ = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
assert len(annotations) == 1
assert annotations[0]["segmentation"] != []
def test_coco_iscrowd_mask_exports_as_rle() -> None:
"""Multi-segment mask exports segmentation as RLE dict (iscrowd inferred as 1)."""
mask = np.zeros((10, 10), dtype=bool)
mask[1:3, 1:3] = True # top-left component
mask[7:9, 7:9] = True # bottom-right component (two separate regions)
detections = Detections(
xyxy=np.array([[1, 1, 8, 8]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array([mask]),
)
annotations, _ = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
assert len(annotations) == 1
seg = annotations[0]["segmentation"]
assert isinstance(seg, dict), "multi-segment mask must export as RLE dict, not list"
assert "counts" in seg
assert "size" in seg
+42
View File
@@ -8,6 +8,7 @@ from defusedxml import ElementTree
from supervision.dataset.formats.pascal_voc import (
detections_from_xml_obj,
detections_to_pascal_voc,
object_to_pascal_voc,
parse_polygon_points,
)
@@ -72,6 +73,47 @@ def test_object_to_pascal_voc(
assert are_xml_elements_equal(result, expected_result)
def test_object_to_pascal_voc_does_not_mutate_inputs():
"""Serializing an object must not write the 1-index offset back into the inputs."""
xyxy = np.array([10, 20, 30, 40], dtype=np.float32)
polygon = np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32)
object_to_pascal_voc(xyxy=xyxy, name="test", polygon=polygon)
assert np.array_equal(xyxy, np.array([10, 20, 30, 40], dtype=np.float32))
assert np.array_equal(
polygon, np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32)
)
def test_object_to_pascal_voc_does_not_mutate_view_input():
"""Mutation guard holds when xyxy is a NumPy row-view (the actual bug scenario)."""
base = np.array([[10, 20, 30, 40]], dtype=np.float32)
xyxy_view = base[0] # row-view, shares memory with base
object_to_pascal_voc(xyxy=xyxy_view, name="test", polygon=None)
assert np.array_equal(base[0], np.array([10, 20, 30, 40], dtype=np.float32)), (
"object_to_pascal_voc mutated the source array via a view"
)
def test_detections_to_pascal_voc_does_not_mutate_detections():
"""Exporting detections must not shift the source xyxy, and must be repeatable."""
detections = _create_detections(xyxy=[[10, 20, 30, 40]], class_id=[0])
expected_xyxy = detections.xyxy.copy()
first = detections_to_pascal_voc(
detections, classes=["test"], filename="image.jpg", image_shape=(100, 100, 3)
)
second = detections_to_pascal_voc(
detections, classes=["test"], filename="image.jpg", image_shape=(100, 100, 3)
)
assert np.array_equal(detections.xyxy, expected_xyxy)
assert first == second
@pytest.mark.parametrize(
("polygon_element", "expected_result", "exception"),
[
+312 -99
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
import os
import tempfile
from contextlib import ExitStack as DoesNotRaise
from pathlib import Path
@@ -9,7 +7,10 @@ import numpy as np
import pytest
from PIL import Image
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.dataset.core import DetectionDataset
from supervision.dataset.formats.yolo import (
_extract_class_names,
_image_name_to_annotation_name,
_with_seg_mask,
detections_to_yolo_annotations,
@@ -233,6 +234,50 @@ def test_image_name_to_annotation_name(
assert result == expected_result
@pytest.mark.parametrize(
("yaml_text", "expected_names", "exception"),
[
(
"names:\n '0': background\n '1': person\n"
" '2': car\n '10': traffic_light\n",
["background", "person", "car", "traffic_light"],
DoesNotRaise(),
), # quoted string numeric keys sort by integer value, not lexicographically
(
"names:\n 0: background\n 2: car\n 10: traffic_light\n",
["background", "car", "traffic_light"],
DoesNotRaise(),
), # native int keys (most common YOLO format from Ultralytics/Roboflow)
(
"names:\n cat: 0\n dog: 1\n",
["0", "1"],
DoesNotRaise(),
), # non-numeric string keys fall back to lexicographic sort
(
"names: {}\n",
[],
DoesNotRaise(),
), # empty names dict returns empty list
(
"names:\n '--1': ignore\n '0': person\n",
None,
pytest.raises(ValueError, match="mix"),
), # mixed numeric/non-numeric keys raise ValueError
],
)
def test_extract_class_names_sorts_numeric_string_keys(
tmp_path: Path,
yaml_text: str,
expected_names: list[str] | None,
exception: Exception,
) -> None:
"""_extract_class_names returns class names sorted by class index."""
data_yaml_path = tmp_path / "data.yaml"
data_yaml_path.write_text(yaml_text, encoding="utf-8")
with exception:
assert _extract_class_names(file_path=str(data_yaml_path)) == expected_names
@pytest.mark.parametrize(
("xyxy", "class_id", "image_shape", "polygon", "expected_result", "exception"),
[
@@ -315,105 +360,53 @@ def test_detections_to_yolo_annotations_raises_for_non_integer_class_id() -> Non
)
def test_load_yolo_annotations_obb_does_not_generate_masks() -> None:
"""OBB annotations must not produce mask arrays (memory regression test)."""
with tempfile.TemporaryDirectory() as tmp_dir:
images_dir = os.path.join(tmp_dir, "images")
labels_dir = os.path.join(tmp_dir, "labels")
os.makedirs(images_dir)
os.makedirs(labels_dir)
@pytest.mark.parametrize(
("annotation_line", "load_kwargs", "expect_mask"),
[
pytest.param(
"0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6",
{"is_obb": True},
False,
id="obb-no-mask",
),
pytest.param(
"0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6",
{"is_obb": True, "force_masks": True},
False,
id="obb-force_masks-ignored",
),
pytest.param(
"0 0.1 0.1 0.9 0.1 0.9 0.9",
{"is_obb": False},
True,
id="segmentation-produces-mask",
),
],
)
def test_load_yolo_annotations_mask_behaviour(
tmp_path: Path,
annotation_line: str,
load_kwargs: dict,
expect_mask: bool,
) -> None:
"""Mask presence depends on annotation format and OBB/segmentation flag."""
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
Image.new("RGB", (100, 100)).save(images_dir / "test.jpg")
(labels_dir / "test.txt").write_text(annotation_line + "\n")
(tmp_path / "data.yaml").write_text("names: ['object']\n")
# Create a small RGB image
img = Image.new("RGB", (100, 100))
img.save(os.path.join(images_dir, "test.jpg"))
_, _, annotations = load_yolo_annotations(
images_directory_path=str(images_dir),
annotations_directory_path=str(labels_dir),
data_yaml_path=str(tmp_path / "data.yaml"),
**load_kwargs,
)
# OBB annotation: class_id x1 y1 x2 y2 x3 y3 x4 y4 (9 values per line)
with open(os.path.join(labels_dir, "test.txt"), "w") as f:
f.write("0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6\n")
# Create a minimal data.yaml
data_yaml_path = os.path.join(tmp_dir, "data.yaml")
with open(data_yaml_path, "w") as f:
f.write("names: ['object']\n")
_, _, annotations = load_yolo_annotations(
images_directory_path=images_dir,
annotations_directory_path=labels_dir,
data_yaml_path=data_yaml_path,
is_obb=True,
)
assert len(annotations) == 1
detection = next(iter(annotations.values()))
assert detection.mask is None, (
"OBB annotations must not produce mask arrays to avoid excessive memory use"
)
def test_load_yolo_annotations_obb_force_masks_ignored() -> None:
"""force_masks=True must have no effect when is_obb=True."""
with tempfile.TemporaryDirectory() as tmp_dir:
images_dir = os.path.join(tmp_dir, "images")
labels_dir = os.path.join(tmp_dir, "labels")
os.makedirs(images_dir)
os.makedirs(labels_dir)
img = Image.new("RGB", (100, 100))
img.save(os.path.join(images_dir, "test.jpg"))
with open(os.path.join(labels_dir, "test.txt"), "w") as f:
f.write("0 0.4 0.4 0.6 0.4 0.6 0.6 0.4 0.6\n")
data_yaml_path = os.path.join(tmp_dir, "data.yaml")
with open(data_yaml_path, "w") as f:
f.write("names: ['object']\n")
_, _, annotations = load_yolo_annotations(
images_directory_path=images_dir,
annotations_directory_path=labels_dir,
data_yaml_path=data_yaml_path,
is_obb=True,
force_masks=True,
)
assert len(annotations) == 1
detection = next(iter(annotations.values()))
assert detection.mask is None, (
"force_masks=True must be ignored for OBB annotations"
)
def test_load_yolo_annotations_segmentation_produces_masks() -> None:
"""Segmentation annotations with is_obb=False must still produce masks."""
with tempfile.TemporaryDirectory() as tmp_dir:
images_dir = os.path.join(tmp_dir, "images")
labels_dir = os.path.join(tmp_dir, "labels")
os.makedirs(images_dir)
os.makedirs(labels_dir)
img = Image.new("RGB", (100, 100))
img.save(os.path.join(images_dir, "test.jpg"))
# Polygon annotation: class_id + 3 x,y pairs (7 tokens > 5 triggers mask)
with open(os.path.join(labels_dir, "test.txt"), "w") as f:
f.write("0 0.1 0.1 0.9 0.1 0.9 0.9\n")
data_yaml_path = os.path.join(tmp_dir, "data.yaml")
with open(data_yaml_path, "w") as f:
f.write("names: ['object']\n")
_, _, annotations = load_yolo_annotations(
images_directory_path=images_dir,
annotations_directory_path=labels_dir,
data_yaml_path=data_yaml_path,
is_obb=False,
)
assert len(annotations) == 1
detection = next(iter(annotations.values()))
assert detection.mask is not None, (
"Segmentation annotations with is_obb=False must produce mask arrays"
)
detection = next(iter(annotations.values()))
assert (detection.mask is not None) == expect_mask
def test_polygons_to_masks_multiple_polygons_shape() -> None:
@@ -538,3 +531,223 @@ def test_yolo_polygon_mask_precision_no_coord_drift_round_trip_iou(
f"Mask IoU {iou:.6f} too low after YOLO load/save round-trip — "
"precision regression in polygon mask conversion"
)
def test_detections_to_yolo_annotations_obb_emits_nine_tokens() -> None:
"""`is_obb=True` must serialize the 4 corners from `data['xyxyxyxy']`."""
corners = np.array(
[[[50.0, 10.0], [90.0, 50.0], [50.0, 90.0], [10.0, 50.0]]], dtype=np.float32
)
detections = Detections(
xyxy=np.array([[10.0, 10.0, 90.0, 90.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={ORIENTED_BOX_COORDINATES: corners},
)
lines = detections_to_yolo_annotations(
detections=detections, image_shape=(100, 100, 3), is_obb=True
)
assert len(lines) == 1
tokens = lines[0].split()
assert len(tokens) == 9, (
f"OBB export must produce 9 tokens (class + 4 (x,y) pairs), got {tokens}"
)
assert tokens[0] == "0"
np.testing.assert_allclose(
np.array(tokens[1:], dtype=np.float32),
np.array([0.5, 0.1, 0.9, 0.5, 0.5, 0.9, 0.1, 0.5], dtype=np.float32),
atol=1e-5,
)
def test_detections_to_yolo_annotations_obb_raises_without_corners() -> None:
"""`is_obb=True` without `'xyxyxyxy'` in data must fail loudly, not silently."""
detections = Detections(
xyxy=np.array([[10.0, 10.0, 90.0, 90.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
)
with pytest.raises(ValueError, match=ORIENTED_BOX_COORDINATES):
detections_to_yolo_annotations(
detections=detections, image_shape=(100, 100, 3), is_obb=True
)
def test_detections_to_yolo_annotations_obb_empty_emits_no_lines() -> None:
"""`is_obb=True` on an empty `Detections` must not raise — image had no labels."""
lines = detections_to_yolo_annotations(
detections=Detections.empty(), image_shape=(100, 100, 3), is_obb=True
)
assert lines == []
def test_detections_to_yolo_annotations_obb_multiple_detections() -> None:
"""OBB export must correctly serialize N>1 detections per image."""
corners = np.array(
[
[[50.0, 10.0], [90.0, 50.0], [50.0, 90.0], [10.0, 50.0]],
[[20.0, 20.0], [80.0, 20.0], [80.0, 40.0], [20.0, 40.0]],
],
dtype=np.float32,
)
detections = Detections(
xyxy=np.array(
[[10.0, 10.0, 90.0, 90.0], [20.0, 20.0, 80.0, 40.0]], dtype=np.float32
),
class_id=np.array([0, 1], dtype=int),
data={ORIENTED_BOX_COORDINATES: corners},
)
lines = detections_to_yolo_annotations(
detections=detections, image_shape=(100, 100, 3), is_obb=True
)
assert len(lines) == 2, f"Expected 2 annotation lines, got {len(lines)}"
for i, line in enumerate(lines):
tokens = line.split()
assert len(tokens) == 9, (
f"Detection {i}: expected 9 tokens, got {len(tokens)}: {tokens}"
)
assert lines[0].split()[0] == "0"
assert lines[1].split()[0] == "1"
np.testing.assert_allclose(
np.array(lines[1].split()[1:], dtype=np.float32),
np.array([0.2, 0.2, 0.8, 0.2, 0.8, 0.4, 0.2, 0.4], dtype=np.float32),
atol=1e-5,
)
def test_detections_to_yolo_annotations_obb_data_ignored_when_is_obb_false() -> None:
"""OBB data in detections.data must be silently ignored when is_obb=False."""
corners = np.array(
[[[50.0, 10.0], [90.0, 50.0], [50.0, 90.0], [10.0, 50.0]]],
dtype=np.float32,
)
detections = Detections(
xyxy=np.array([[10.0, 10.0, 90.0, 90.0]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={ORIENTED_BOX_COORDINATES: corners},
)
lines = detections_to_yolo_annotations(
detections=detections, image_shape=(100, 100, 3), is_obb=False
)
assert len(lines) == 1
assert len(lines[0].split()) == 5, (
"Without is_obb=True, OBB data must be ignored and bbox format (5 tokens) used"
)
@pytest.mark.parametrize(
("is_obb_save", "expected_tokens"),
[
pytest.param(True, 9, id="obb-save-nine-tokens"),
pytest.param(False, 5, id="default-save-five-tokens"),
],
)
def test_dataset_as_yolo_obb_output_token_count(
tmp_path: Path, is_obb_save: bool, expected_tokens: int
) -> None:
"""Token count in saved YOLO line reflects the is_obb flag at export time."""
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
Image.new("RGB", (100, 100)).save(images_dir / "test.jpg")
(labels_dir / "test.txt").write_text("0 0.5 0.1 0.9 0.5 0.5 0.9 0.1 0.5\n")
(tmp_path / "data.yaml").write_text("names: ['object']\n")
loaded = DetectionDataset.from_yolo(
images_directory_path=str(images_dir),
annotations_directory_path=str(labels_dir),
data_yaml_path=str(tmp_path / "data.yaml"),
is_obb=True,
)
out_labels_dir = tmp_path / "out_labels"
loaded.as_yolo(
annotations_directory_path=str(out_labels_dir),
data_yaml_path=str(tmp_path / "out_data.yaml"),
is_obb=is_obb_save,
)
tokens = (out_labels_dir / "test.txt").read_text().split()
assert len(tokens) == expected_tokens, (
f"expected {expected_tokens}-token line, got {len(tokens)}: {tokens}"
)
def test_dataset_as_yolo_obb_round_trip_corner_accuracy(tmp_path: Path) -> None:
"""OBB round-trip via `from_yolo` -> `as_yolo` must preserve the 4 corners."""
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
# Non-square image and rotated rhombus exercise both axes.
Image.new("RGB", (200, 100)).save(images_dir / "test.jpg")
(labels_dir / "test.txt").write_text("0 0.5 0.1 0.9 0.5 0.5 0.9 0.1 0.5\n")
(tmp_path / "data.yaml").write_text("names: ['object']\n")
loaded = DetectionDataset.from_yolo(
images_directory_path=str(images_dir),
annotations_directory_path=str(labels_dir),
data_yaml_path=str(tmp_path / "data.yaml"),
is_obb=True,
)
out_labels_dir = tmp_path / "out_labels"
loaded.as_yolo(
annotations_directory_path=str(out_labels_dir),
data_yaml_path=str(tmp_path / "out_data.yaml"),
is_obb=True,
)
reloaded = DetectionDataset.from_yolo(
images_directory_path=str(images_dir),
annotations_directory_path=str(out_labels_dir),
data_yaml_path=str(tmp_path / "out_data.yaml"),
is_obb=True,
)
original = next(iter(loaded.annotations.values()))
round_tripped = next(iter(reloaded.annotations.values()))
np.testing.assert_allclose(
round_tripped.data[ORIENTED_BOX_COORDINATES],
original.data[ORIENTED_BOX_COORDINATES],
atol=1e-3,
)
def test_dataset_as_yolo_obb_round_trip_with_background_image(
tmp_path: Path,
) -> None:
"""OBB round-trip with a label-less image must not raise ValueError."""
images_dir = tmp_path / "images"
labels_dir = tmp_path / "labels"
images_dir.mkdir()
labels_dir.mkdir()
Image.new("RGB", (100, 100)).save(images_dir / "annotated.jpg")
Image.new("RGB", (100, 100)).save(images_dir / "background.jpg")
(labels_dir / "annotated.txt").write_text("0 0.5 0.1 0.9 0.5 0.5 0.9 0.1 0.5\n")
# No labels/background.txt — background image has no annotations.
(tmp_path / "data.yaml").write_text("names: ['object']\n")
loaded = DetectionDataset.from_yolo(
images_directory_path=str(images_dir),
annotations_directory_path=str(labels_dir),
data_yaml_path=str(tmp_path / "data.yaml"),
is_obb=True,
)
out_labels_dir = tmp_path / "out_labels"
loaded.as_yolo(
annotations_directory_path=str(out_labels_dir),
data_yaml_path=str(tmp_path / "out_data.yaml"),
is_obb=True,
)
bg_label = out_labels_dir / "background.txt"
assert bg_label.exists(), "Background image must produce a label file"
assert bg_label.read_text().strip() == "", (
"Background image label file must be empty"
)
+972 -1
View File
@@ -6,7 +6,15 @@ from contextlib import ExitStack as DoesNotRaise
import numpy as np
import pytest
from supervision.detection.core import Detections, merge_inner_detection_object_pair
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import (
Detections,
_merge_detection_group,
_merge_obb_corners,
merge_inner_detection_object_pair,
)
from supervision.detection.utils.boxes import xyxyxyxy_to_xyxy
from supervision.detection.utils.iou_and_nms import OverlapMetric
from supervision.geometry.core import Position
from supervision.utils.internal import SupervisionWarnings
from tests.helpers import _create_detections
@@ -933,3 +941,966 @@ def test_merge_inner_detection_object_pair(
def test_is_empty(detections: Detections, expected: bool) -> None:
"""Verify is_empty() returns True iff the Detections object has zero detections."""
assert detections.is_empty() == expected
def test_from_inference_empty_class_name_dtype_matches_non_empty() -> None:
"""Empty and non-empty results should produce string-kind class_name arrays."""
empty_result = {"predictions": [], "image": {"width": 100, "height": 100}}
non_empty_result = {
"predictions": [
{
"x": 50,
"y": 50,
"width": 20,
"height": 20,
"confidence": 0.9,
"class": "cat",
"class_id": 0,
}
],
"image": {"width": 100, "height": 100},
}
empty = Detections.from_inference(empty_result)
non_empty = Detections.from_inference(non_empty_result)
# null-safety: class_name must be an array, not None
assert empty["class_name"] is not None
assert non_empty["class_name"] is not None
# dtype kind must match between empty and non-empty paths
assert empty["class_name"].dtype.kind == non_empty["class_name"].dtype.kind == "U"
# all data keys and dtypes must match between empty and non-empty paths
assert set(empty.data.keys()) == set(non_empty.data.keys())
for key in non_empty.data:
assert empty.data[key].dtype.kind == non_empty.data[key].dtype.kind, key
# concatenation across empty+non-empty must produce a string-kind array
concat = np.concatenate([empty["class_name"], non_empty["class_name"]])
assert concat.dtype.kind == "U"
def test_from_inference_sdk_dict_path_empty_preserves_class_name_dtype() -> None:
"""SDK objects with .dict() and empty predictions produce string-kind class_name."""
class _FakeSdkResult:
def dict(self, **kwargs: object) -> dict:
return {"predictions": [], "image": {"width": 100, "height": 100}}
detections = Detections.from_inference(_FakeSdkResult())
assert detections["class_name"] is not None
assert detections["class_name"].dtype.kind == "U"
def _rotated_rect(
cx: float, cy: float, w: float, h: float, angle_deg: float
) -> np.ndarray:
angle = np.deg2rad(angle_deg)
cos, sin = np.cos(angle), np.sin(angle)
rot = np.array([[cos, -sin], [sin, cos]])
corners = np.array(
[[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]]
)
return (corners @ rot.T + [cx, cy]).astype(np.float32)
def _make_obb_detections(
quads: list[np.ndarray], scores: list[float], class_ids: list[int]
) -> Detections:
"""Build OBB Detections from a list of (4, 2) corner arrays."""
oriented_boxes = np.stack(quads)
xyxy = xyxyxyxy_to_xyxy(oriented_boxes)
return Detections(
xyxy=xyxy,
confidence=np.array(scores, dtype=np.float32),
class_id=np.array(class_ids, dtype=int),
data={ORIENTED_BOX_COORDINATES: oriented_boxes},
)
class TestDetectionsObbDispatch:
"""Shared OBB-aware dispatch behaviour for `with_nms` and `with_nmm`."""
@pytest.mark.parametrize(
"method",
[
pytest.param("with_nms", id="with_nms"),
pytest.param("with_nmm", id="with_nmm"),
],
)
def test_uses_obb_iou_when_oriented_box_coordinates_present(
self, method: str
) -> None:
"""X-pattern OBBs: both survive under either method because OBB IoU < 0.5."""
quad_a = _rotated_rect(50, 50, 100, 10, +45)
quad_b = _rotated_rect(50, 50, 100, 10, -45)
detections = _make_obb_detections([quad_a, quad_b], [0.9, 0.85], [0, 0])
result = getattr(detections, method)(threshold=0.5)
assert len(result) == 2
@pytest.mark.parametrize(
"method",
[
pytest.param("with_nms", id="with_nms"),
pytest.param("with_nmm", id="with_nmm"),
],
)
def test_falls_back_without_obb_data(self, method: str) -> None:
"""Non-OBB heavily-overlapping AABBs collapse to one under either method."""
detections = Detections(
xyxy=np.array([[0, 0, 100, 100], [10, 10, 110, 110]], dtype=np.float32),
confidence=np.array([0.9, 0.85], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
)
result = getattr(detections, method)(threshold=0.5)
assert len(result) == 1
class TestMergeObbCorners:
"""_merge_obb_corners"""
@pytest.mark.parametrize(
("corners_list", "expected"),
[
pytest.param(
[np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32)],
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
id="single-box-passthrough",
),
pytest.param(
[
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
np.array([[2, 2], [12, 2], [12, 7], [2, 7]], dtype=np.float32),
],
np.array([[0, 0], [12, 0], [12, 7], [0, 7]], dtype=np.float32),
id="two-axis-aligned",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 10, 45),
_rotated_rect(55, 55, 40, 10, 45),
],
None,
id="two-same-angle",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 10, 30),
_rotated_rect(55, 50, 40, 10, -15),
],
None,
id="two-different-angles",
),
pytest.param(
[
np.array([[0, 0], [20, 0], [20, 10], [0, 10]], dtype=np.float32),
np.array([[5, 5], [25, 5], [25, 15], [5, 15]], dtype=np.float32),
np.array([[10, 0], [30, 0], [30, 10], [10, 10]], dtype=np.float32),
],
np.array([[0, 0], [30, 0], [30, 15], [0, 15]], dtype=np.float32),
id="three-boxes-axis-aligned",
),
pytest.param(
[
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
],
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
id="identical-boxes",
),
pytest.param(
[
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
np.array([[3, 3], [7, 3], [7, 3], [3, 3]], dtype=np.float32),
],
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
id="degenerate-collinear",
),
],
)
def test_merge(
self, corners_list: list[np.ndarray], expected: np.ndarray | None
) -> None:
"""Produces correct merged OBB corners."""
result = _merge_obb_corners(corners_list)
assert result.shape == (4, 2)
if expected is not None:
assert np.allclose(result, expected, atol=0.5)
else:
assert result.dtype == np.float32
class TestMergeDetectionGroup:
"""_merge_detection_group"""
@pytest.mark.parametrize(
("detections", "expected_detections"),
[
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([1]),
),
],
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([1]),
),
id="single-passthrough",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="two-aabb-merge",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[10, 10, 20, 20]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 20, 20]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="three-aabb-merge",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
mask=np.array([[[True, False], [False, False]]], dtype=bool),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
mask=np.array([[[False, True], [False, False]]], dtype=bool),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
mask=np.array([[[True, True], [False, False]]], dtype=bool),
),
id="two-aabb-with-mask",
),
pytest.param(
[
_make_obb_detections(
[
np.array(
[[0, 0], [10, 0], [10, 5], [0, 5]],
dtype=np.float32,
)
],
[0.9],
[0],
),
_make_obb_detections(
[
np.array(
[[2, 2], [12, 2], [12, 7], [2, 7]],
dtype=np.float32,
)
],
[0.7],
[0],
),
],
Detections(
xyxy=np.array([[0, 0, 12, 7]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="two-obb-axis-aligned",
),
pytest.param(
[
_make_obb_detections(
[_rotated_rect(50, 50, 40, 10, 45)], [0.9], [0]
),
_make_obb_detections(
[_rotated_rect(55, 55, 40, 10, 45)], [0.8], [0]
),
],
Detections(
xyxy=np.array([[32.32, 32.32, 72.68, 72.68]], dtype=np.float32),
confidence=np.array([0.85], dtype=np.float32),
class_id=np.array([0]),
),
id="two-obb-rotated",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([1]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([2]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([1]),
),
id="winner-takes-class-id",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
tracker_id=np.array([42]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([0]),
tracker_id=np.array([99]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
tracker_id=np.array([42]),
),
id="winner-takes-tracker-id",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
data={"class_name": np.array(["cat"])},
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([1]),
data={"class_name": np.array(["dog"])},
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
data={"class_name": np.array(["cat"])},
),
id="winner-takes-data",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=None,
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=None,
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=None,
class_id=np.array([0]),
),
id="no-confidence",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=None,
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=None,
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=None,
),
id="no-class-id",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
data={"score": np.array([1.5])},
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([0]),
data={"score": np.array([2.5])},
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
data={"score": np.array([1.5])},
),
id="custom-data-field-preserved",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 5, 5]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
id="zero-area-box-in-group",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
metadata={"source": "model_a"},
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
metadata={"source": "model_a"},
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="metadata-merge",
),
],
)
def test_merge(
self,
detections: list[Detections],
expected_detections: Detections,
) -> None:
"""Merges detection group correctly."""
result = _merge_detection_group(detections)
assert len(result) == 1
assert np.allclose(result.xyxy, expected_detections.xyxy, atol=0.5)
if expected_detections.confidence is not None:
assert np.allclose(
result.confidence, expected_detections.confidence, atol=1e-3
)
else:
assert result.confidence is None
if expected_detections.class_id is not None:
assert np.array_equal(result.class_id, expected_detections.class_id)
else:
assert result.class_id is None
if expected_detections.tracker_id is not None:
assert np.array_equal(result.tracker_id, expected_detections.tracker_id)
else:
assert result.tracker_id is None
if expected_detections.mask is not None:
assert np.array_equal(result.mask, expected_detections.mask)
else:
assert result.mask is None
for key, val in expected_detections.data.items():
assert np.array_equal(result.data[key], val)
if ORIENTED_BOX_COORDINATES in result.data:
corners = result.data[ORIENTED_BOX_COORDINATES]
assert np.allclose(result.xyxy, xyxyxyxy_to_xyxy(corners), atol=1e-5)
class TestDetectionsWithNMM:
"""NMM-specific behaviour tests for `Detections.with_nmm`."""
@pytest.mark.parametrize(
(
"corners",
"confidence",
"class_ids",
"iou_threshold",
"class_agnostic",
"overlap_metric",
"expected_corners",
"expected_confidence",
"exception",
),
[
pytest.param(
[
[[10, 10], [50, 10], [50, 30], [10, 30]],
[[11, 11], [51, 11], [51, 31], [11, 31]],
],
[0.9, 0.85],
[0, 0],
0.5,
False,
OverlapMetric.IOU,
[[[10, 10], [51, 10], [51, 31], [10, 31]]],
[0.875],
DoesNotRaise(),
id="axis-aligned-merge",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 10, 45).tolist(),
_rotated_rect(55, 55, 40, 10, 45).tolist(),
],
[0.9, 0.8],
[0, 0],
0.3,
False,
OverlapMetric.IOU,
[[[39.39, 32.32], [72.68, 65.61], [65.61, 72.68], [32.32, 39.39]]],
[0.85],
DoesNotRaise(),
id="rotated-45deg-merge",
),
pytest.param(
[
[[0, 0], [20, 0], [20, 10], [0, 10]],
[[5, 5], [25, 5], [25, 15], [5, 15]],
[[10, 0], [30, 0], [30, 10], [10, 10]],
],
[0.9, 0.8, 0.7],
[0, 0, 0],
0.2,
False,
OverlapMetric.IOU,
[[[0, 0], [30, 0], [30, 15], [0, 15]]],
[0.8],
DoesNotRaise(),
id="three-group-merge",
),
pytest.param(
[
[[10, 10], [50, 10], [50, 30], [10, 30]],
],
[0.9],
[0],
0.5,
False,
OverlapMetric.IOU,
[[[10, 10], [50, 10], [50, 30], [10, 30]]],
[0.9],
DoesNotRaise(),
id="single-passthrough",
),
pytest.param(
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 5], [35, 5], [35, 25], [5, 25]],
],
[0.9, 0.8],
[0, 1],
0.3,
True,
OverlapMetric.IOU,
[[[0, 0], [35, 0], [35, 25], [0, 25]]],
[0.85],
DoesNotRaise(),
id="class-agnostic",
),
pytest.param(
[
[[0, 0], [40, 0], [40, 30], [0, 30]],
[[10, 10], [30, 10], [30, 20], [10, 20]],
],
[0.9, 0.8],
[0, 0],
0.3,
False,
OverlapMetric.IOS,
[[[0, 0], [40, 0], [40, 30], [0, 30]]],
[0.885714],
DoesNotRaise(),
id="ios-metric",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 15, 30).tolist(),
_rotated_rect(55, 50, 40, 15, -15).tolist(),
],
[0.9, 0.7],
[0, 0],
0.2,
False,
OverlapMetric.IOU,
[[[43.65, 20.99], [81.56, 42.88], [62.12, 76.56], [24.21, 54.68]]],
[0.813652],
DoesNotRaise(),
id="mixed-angle-merge",
),
pytest.param(
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 5], [35, 5], [35, 25], [5, 25]],
[[200, 200], [240, 200], [240, 220], [200, 220]],
[[205, 205], [245, 205], [245, 225], [205, 225]],
],
[0.9, 0.7, 0.85, 0.6],
[0, 0, 0, 0],
0.2,
False,
OverlapMetric.IOU,
[
[[0, 0], [35, 0], [35, 25], [0, 25]],
[[200, 200], [245, 200], [245, 225], [200, 225]],
],
[0.8, 0.725],
DoesNotRaise(),
id="two-separate-groups",
),
pytest.param(
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 10], [25, 10], [25, 10], [5, 10]],
],
[0.9, 0.7],
[0, 0],
0.01,
False,
OverlapMetric.IOU,
# A zero-area (collinear) OBB scores IoU 0 (see
# test_degenerate_boxes_score_zero), so it cannot group and the
# two detections are not merged.
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 10], [25, 10], [25, 10], [5, 10]],
],
[0.9, 0.7],
DoesNotRaise(),
id="degenerate-collinear-obb",
),
pytest.param(
None,
[0.9, 0.8],
[0, 0],
0.4,
False,
OverlapMetric.IOU,
None,
None,
pytest.raises(ValueError, match="corners must have shape"),
id="flat-n8-raises",
),
],
)
def test_obb_nmm_merge(
self,
corners: list[list[list[float]]] | None,
confidence: list[float],
class_ids: list[int],
iou_threshold: float,
class_agnostic: bool,
overlap_metric: OverlapMetric,
expected_corners: list[list[list[float]]] | None,
expected_confidence: list[float] | None,
exception: DoesNotRaise,
) -> None:
"""OBB NMM produces correct geometry and confidence."""
if corners is None:
xyxy = np.array(
[[0, 0, 30, 20], [5, 5, 35, 25]],
dtype=np.float32,
)
flat = np.array(
[
[0, 0, 30, 0, 30, 20, 0, 20],
[5, 5, 35, 5, 35, 25, 5, 25],
],
dtype=np.float32,
)
detections = Detections(
xyxy=xyxy,
confidence=np.array(confidence, dtype=np.float32),
class_id=np.array(class_ids),
data={ORIENTED_BOX_COORDINATES: flat},
)
else:
corner_arrays = [np.array(corner, dtype=np.float32) for corner in corners]
detections = _make_obb_detections(corner_arrays, confidence, class_ids)
with exception:
result = detections.with_nmm(
threshold=iou_threshold,
class_agnostic=class_agnostic,
overlap_metric=overlap_metric,
)
assert expected_confidence is not None
assert expected_corners is not None
assert len(result) == len(expected_confidence)
for i, exp_c in enumerate(expected_confidence):
assert result.confidence[i] == pytest.approx(exp_c, abs=1e-3)
result_corners = result.data[ORIENTED_BOX_COORDINATES]
expected_corner_array = np.array(expected_corners, dtype=np.float32)
assert np.allclose(
result_corners,
expected_corner_array,
atol=0.5,
)
def test_obb_nmm_matches_aabb_for_axis_aligned(self) -> None:
"""Axis-aligned OBB NMM produces same envelope as AABB NMM."""
xyxy = np.array([[0, 0, 30, 20], [5, 5, 35, 25]], dtype=np.float32)
confidence = np.array([0.9, 0.5], dtype=np.float32)
class_id = np.array([0, 0])
aabb_detections = Detections(
xyxy=xyxy,
confidence=confidence,
class_id=class_id,
)
obb_detections = _make_obb_detections(
[
np.array(
[[0, 0], [30, 0], [30, 20], [0, 20]],
dtype=np.float32,
),
np.array(
[[5, 5], [35, 5], [35, 25], [5, 25]],
dtype=np.float32,
),
],
confidence.tolist(),
class_id.tolist(),
)
aabb_result = aabb_detections.with_nmm(threshold=0.4)
obb_result = obb_detections.with_nmm(threshold=0.4)
assert len(aabb_result) == 1
assert len(obb_result) == 1
assert np.allclose(aabb_result.xyxy, obb_result.xyxy, atol=1e-4)
def test_staircase_obb_merge_within_union(self) -> None:
"""Diagonal staircase OBBs: merged AABB equals axis-aligned union."""
quads = [
np.array(
[[0, 0], [20, 0], [20, 20], [0, 20]],
dtype=np.float32,
),
np.array(
[[12, 12], [32, 12], [32, 32], [12, 32]],
dtype=np.float32,
),
np.array(
[[24, 24], [44, 24], [44, 44], [24, 44]],
dtype=np.float32,
),
]
detections = _make_obb_detections(quads, [0.7, 0.9, 0.8], [0, 0, 0])
result = detections.with_nmm(threshold=0.05)
assert len(result) == 1
assert np.allclose(result.xyxy, [[0.0, 0.0, 44.0, 44.0]], atol=0.5)
def test_obb_nmm_empty_detections(self) -> None:
"""Empty OBB detections return empty result."""
dets = Detections(
xyxy=np.empty((0, 4), dtype=np.float32),
confidence=np.array([], dtype=np.float32),
class_id=np.array([], dtype=int),
data={ORIENTED_BOX_COORDINATES: np.empty((0, 4, 2), dtype=np.float32)},
)
result = dets.with_nmm(threshold=0.5)
assert len(result) == 0
class TestDetectionsArea:
"""Selection order for the `area` property: mask → OBB → AABB."""
@pytest.mark.parametrize(
("width", "height", "angle_deg", "expected_area"),
[
pytest.param(20, 10, 0, 200.0, id="axis-aligned"),
pytest.param(20, 10, 45, 200.0, id="45-deg rotation"),
pytest.param(20, 10, 30, 200.0, id="30-deg rotation"),
pytest.param(20, 10, -60, 200.0, id="negative rotation"),
],
)
def test_uses_oriented_box_corners_when_present(
self, width: float, height: float, angle_deg: float, expected_area: float
) -> None:
"""Area equals the rotated body's area regardless of rotation, not the AABB."""
quad = _rotated_rect(50, 50, width, height, angle_deg)
detections = _make_obb_detections([quad], [0.9], [0])
assert np.allclose(detections.area, [expected_area])
def test_falls_back_to_box_area_without_obb_data(self) -> None:
"""Without ORIENTED_BOX_COORDINATES, area mirrors box_area (AABB)."""
detections = Detections(
xyxy=np.array([[0, 0, 20, 10]], dtype=np.float32),
class_id=np.array([0], dtype=int),
)
assert np.allclose(detections.area, [200.0])
assert np.allclose(detections.area, detections.box_area)
def test_mask_takes_precedence_over_oriented_box(self) -> None:
"""When both `mask` and `ORIENTED_BOX_COORDINATES` are present, area is
computed from the mask."""
mask = np.zeros((40, 40), dtype=bool)
mask[10:30, 10:25] = True # 20 rows x 15 cols = 300 pixels
quad = _rotated_rect(20, 20, 20, 10, 0) # OBB area = 200
detections = Detections(
xyxy=np.array([[10, 10, 25, 30]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask[None, ...],
data={ORIENTED_BOX_COORDINATES: quad[None, ...]},
)
assert np.allclose(detections.area, [300.0])
def test_empty_detections_with_obb_data_returns_empty_array(self) -> None:
"""Boundary case: empty Detections carrying an OBB data field must
return an empty area array (matches the mask / box_area branches)."""
detections = Detections(
xyxy=np.empty((0, 4), dtype=np.float32),
class_id=np.array([], dtype=int),
data={ORIENTED_BOX_COORDINATES: np.empty((0, 4, 2), dtype=np.float32)},
)
assert detections.area.shape == (0,)
def test_degenerate_oriented_box_has_zero_area(self) -> None:
"""An OBB whose four corners coincide has zero area — the shoelace
formula must not produce NaN or a negative value."""
quad = np.full((4, 2), 5.0, dtype=np.float32)
detections = _make_obb_detections([quad], [0.9], [0])
assert np.allclose(detections.area, [0.0])
def test_handles_batched_oriented_boxes(self) -> None:
"""Multiple OBBs in one `Detections` each get their own correct area.
Guards against the shoelace reduction collapsing across boxes instead
of along the per-box corner axis."""
quads = [
_rotated_rect(50, 50, 20, 10, 0), # 200
_rotated_rect(100, 100, 20, 10, 45), # 200 (rotation must not change it)
_rotated_rect(150, 150, 30, 5, 30), # 150
]
detections = _make_obb_detections(quads, [0.9, 0.9, 0.9], [0, 0, 0])
assert np.allclose(detections.area, [200.0, 200.0, 150.0])
@pytest.mark.parametrize(
"bad_shape",
[
pytest.param((1, 8), id="flat-N8"),
pytest.param((1, 3, 2), id="triangle"),
],
)
def test_raises_on_malformed_obb_coordinates_shape(self, bad_shape: tuple) -> None:
"""ValueError when OBB data shape is wrong for area computation."""
bad_corners = np.zeros(bad_shape, dtype=np.float32)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
data={ORIENTED_BOX_COORDINATES: bad_corners},
)
with pytest.raises(ValueError, match="must have shape"):
_ = detections.area
@pytest.mark.parametrize(
("branch", "expected_dtype"),
[
pytest.param("obb", np.float64, id="obb-branch-float64"),
pytest.param("aabb", np.float32, id="aabb-branch-preserves-input-dtype"),
pytest.param("mask", np.int64, id="mask-branch-int64"),
],
)
def test_area_return_dtype_per_branch(
self, branch: str, expected_dtype: type
) -> None:
"""Area dtype matches the documented per-branch contract."""
if branch == "obb":
quad = _rotated_rect(50, 50, 20, 10, 0)
detections = _make_obb_detections([quad], [0.9], [0])
elif branch == "aabb":
detections = Detections(
xyxy=np.array([[0, 0, 20, 10]], dtype=np.float32),
class_id=np.array([0], dtype=int),
)
else:
mask = np.zeros((1, 40, 40), dtype=bool)
mask[0, 10:30, 10:30] = True
detections = Detections(
xyxy=np.array([[10, 10, 30, 30]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask,
)
assert detections.area.dtype == expected_dtype
+47
View File
@@ -395,6 +395,53 @@ def test_json_sink(
assert_json_equal(file_name, expected_result)
@pytest.mark.parametrize(
("scalar", "expected"),
[
pytest.param(np.int64(7), 7, id="np_int64"),
pytest.param(np.float32(0.5), pytest.approx(0.5), id="np_float32"),
pytest.param(np.float64(1.5), pytest.approx(1.5), id="np_float64"),
pytest.param(np.int32(3), 3, id="np_int32"),
pytest.param(np.bool_(True), True, id="np_bool"),
],
)
def test_json_sink_serializes_numpy_scalar_custom_data(
tmp_path: Any, scalar: Any, expected: Any
) -> None:
"""NumPy scalar in custom_data serializes as a JSON number or boolean."""
file_name = str(tmp_path / "test_numpy_scalar.json")
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
confidence=np.array([0.9]),
)
with sv.JSONSink(file_name) as sink:
sink.append(detections, custom_data={"value": scalar})
with open(file_name) as f:
data = json.load(f)
assert data[0]["value"] == expected
def test_json_sink_serializes_nested_numpy_array_custom_data(tmp_path: Any) -> None:
"""NumPy array nested inside a custom_data dict value serializes as a JSON array."""
file_name = str(tmp_path / "test_nested_array.json")
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
)
with sv.JSONSink(file_name) as sink:
sink.append(detections, custom_data={"meta": {"arr": np.array([1, 2, 3])}})
with open(file_name) as f:
data = json.load(f)
assert data[0]["meta"]["arr"] == [1, 2, 3]
def test_json_default_raises_for_unserializable_type() -> None:
"""_json_default raises TypeError for non-numpy objects."""
with pytest.raises(TypeError, match="is not JSON serializable"):
sv.JSONSink._json_default(object())
def assert_json_equal(file_name, expected_rows):
with open(file_name) as file:
data = json.load(file)
+56 -10
View File
@@ -27,49 +27,49 @@ from supervision.detection.vlm import (
"",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # empty text
(
does_not_raise(),
"",
(1000, 1000),
["cat", "dog"],
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # empty text, classes
(
does_not_raise(),
"\n",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # newline only
(
does_not_raise(),
"the quick brown fox jumps over the lazy dog.",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # random text, no location
(
does_not_raise(),
"<loc0256><loc0768><loc0768> cat",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # partial location
(
does_not_raise(),
"<loc0256><loc0256><loc0768><loc0768><loc0768> cat",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # extra loc
(
does_not_raise(),
"<loc0256><loc0256><loc0768><loc0768>",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0).astype(str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0).astype(str)),
), # no class
(
does_not_raise(),
@@ -436,21 +436,21 @@ def test_from_qwen_2_5_vl(
"random text",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0, dtype=str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)),
), # random text without JSON format
(
does_not_raise(),
"```json\ninvalid json\n```",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0, dtype=str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)),
), # invalid JSON within code blocks
(
does_not_raise(),
"```json\n[]\n```",
(1000, 1000),
None,
(np.empty((0, 4)), None, np.empty(0, dtype=str)),
(np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)),
), # empty JSON array
(
does_not_raise(),
@@ -1297,3 +1297,49 @@ def test_from_deepseek_vl_2(
detections.data[CLASS_NAME_DATA_FIELD],
expected_detections.data[CLASS_NAME_DATA_FIELD],
)
def test_from_google_gemini_2_5_malformed_mask_keeps_confidence_aligned():
"""A non-data-URI mask must not skip the item's confidence and desync arrays."""
result = (
'[{"box_2d": [10, 10, 100, 100], "label": "cat", "mask": "bad", '
'"confidence": 0.8}, {"box_2d": [20, 20, 120, 120], "label": "dog", '
'"mask": "bad", "confidence": 0.9}]'
)
xyxy, _, _, confidence, masks = from_google_gemini_2_5(
result=result, resolution_wh=(640, 480)
)
assert xyxy.shape == (2, 4)
assert confidence is not None
assert confidence.shape == (2,)
assert np.allclose(confidence, [0.8, 0.9])
assert masks is not None
assert masks.shape == (2, 480, 640)
@pytest.mark.parametrize(
("parser", "result"),
[
pytest.param(
from_google_gemini_2_0, "[1, 2, 3]", id="gemini_2_0_non_dict_items"
),
pytest.param(from_google_gemini_2_0, "42", id="gemini_2_0_non_list"),
pytest.param(
from_google_gemini_2_5, "[1, 2, 3]", id="gemini_2_5_non_dict_items"
),
pytest.param(from_google_gemini_2_5, "42", id="gemini_2_5_non_list"),
pytest.param(from_qwen_2_5_vl, "[1, 2, 3]", id="qwen_2_5_non_dict_items"),
pytest.param(from_qwen_2_5_vl, "42", id="qwen_2_5_non_list"),
],
)
def test_vlm_parsers_degrade_on_malformed_json(parser, result):
"""Valid JSON of the wrong shape should yield empty results, not raise."""
kwargs: dict = {"resolution_wh": (640, 480)}
if parser is from_qwen_2_5_vl:
kwargs["input_wh"] = (640, 480)
xyxy = parser(result=result, **kwargs)[0]
assert xyxy.shape == (0, 4)
@@ -1,12 +1,15 @@
from __future__ import annotations
import threading
import warnings
import numpy as np
import pytest
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections
from supervision.detection.tools.inference_slicer import InferenceSlicer
from supervision.detection.utils.iou_and_nms import OverlapFilter
from supervision.utils.internal import SupervisionWarnings
@@ -382,3 +385,115 @@ def test_run_callback_does_not_rewarn_on_second_call() -> None:
and "outside the slice bounds" in str(w.message)
]
assert len(out_of_bounds_warnings) == 1
def test_obb_callbacks_run_sequentially_even_with_multiple_workers() -> None:
"""Test that OBB callbacks are serialized even when thread_workers > 1."""
active_calls = 0
max_active_calls = 0
concurrent_callbacks = 0
callback_lock = threading.Lock()
def obb_callback(_: np.ndarray) -> Detections:
nonlocal active_calls, max_active_calls, concurrent_callbacks
with callback_lock:
active_calls += 1
max_active_calls = max(max_active_calls, active_calls)
if active_calls > 1:
concurrent_callbacks += 1
with callback_lock:
active_calls -= 1
return Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=float),
confidence=np.array([0.9]),
class_id=np.array([0]),
data={
ORIENTED_BOX_COORDINATES: np.array(
[[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=float
)
},
)
image = np.zeros((128, 128, 3), dtype=np.uint8)
slicer = InferenceSlicer(
callback=obb_callback,
slice_wh=64,
overlap_wh=0,
thread_workers=4,
)
with pytest.warns(SupervisionWarnings, match="oriented bounding boxes"):
detections = slicer(image)
assert max_active_calls == 1
assert concurrent_callbacks == 0
assert len(detections) == 4
def _rotated_rect(
cx: float, cy: float, w: float, h: float, angle_deg: float
) -> np.ndarray:
angle = np.deg2rad(angle_deg)
cos, sin = np.cos(angle), np.sin(angle)
rot = np.array([[cos, -sin], [sin, cos]])
corners = np.array(
[[-w / 2, -h / 2], [w / 2, -h / 2], [w / 2, h / 2], [-w / 2, h / 2]]
)
return (corners @ rot.T + [cx, cy]).astype(np.float32)
@pytest.mark.parametrize(
"overlap_filter",
[OverlapFilter.NON_MAX_SUPPRESSION, OverlapFilter.NON_MAX_MERGE],
)
def test_inference_slicer_keeps_crossed_obb_detections(
overlap_filter: OverlapFilter,
) -> None:
"""Regression for issue #1679: the SAHI workflow with OBB detections
dropped valid detections at the merge step because `with_nms`/`with_nmm`
historically used axis-aligned IoU. For crossed thin rectangles the AABBs
are nearly identical (IoU ≈ 1.0) while the OBBs barely overlap (IoU ≈ 0.06)
— so AABB-NMS suppressed one of them.
Both crossed OBBs must survive end-to-end through `InferenceSlicer`.
"""
quad_a = _rotated_rect(50, 50, 80, 8, +45)
quad_b = _rotated_rect(50, 50, 80, 8, -45)
aabb_a = [
quad_a[:, 0].min(),
quad_a[:, 1].min(),
quad_a[:, 0].max(),
quad_a[:, 1].max(),
]
aabb_b = [
quad_b[:, 0].min(),
quad_b[:, 1].min(),
quad_b[:, 0].max(),
quad_b[:, 1].max(),
]
def callback(_: np.ndarray) -> Detections:
return Detections(
xyxy=np.array([aabb_a, aabb_b], dtype=np.float32),
confidence=np.array([0.9, 0.85], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
data={ORIENTED_BOX_COORDINATES: np.stack([quad_a, quad_b])},
)
image = np.zeros((100, 100, 3), dtype=np.uint8)
slicer = InferenceSlicer(
callback=callback,
slice_wh=100,
overlap_wh=0,
thread_workers=1,
overlap_filter=overlap_filter,
iou_threshold=0.5,
)
detections = slicer(image)
assert len(detections) == 2
+128
View File
@@ -0,0 +1,128 @@
"""Tests for DetectionsSmoother bounding-box and confidence smoothing."""
from __future__ import annotations
import numpy as np
import pytest
from numpy.testing import assert_allclose
from supervision.detection.core import Detections
from supervision.detection.tools.smoother import DetectionsSmoother
from supervision.utils.internal import SupervisionWarnings
class TestDetectionsSmoother:
@pytest.mark.parametrize(
("conf1", "conf2", "expected_confidence"),
[
pytest.param(
np.array([0.5]),
np.array([0.7]),
np.array([0.6]),
id="with_confidence",
),
pytest.param(
None,
None,
None,
id="no_confidence",
),
pytest.param(
np.array([0.5]),
None,
np.array([0.5]),
id="mixed_window_averages_present",
),
],
)
def test_smoother_confidence_scenarios(
self,
conf1: np.ndarray | None,
conf2: np.ndarray | None,
expected_confidence: np.ndarray | None,
) -> None:
"""Boxes average over window; confidence averages present values or None."""
smoother = DetectionsSmoother(length=3)
smoother.update_with_detections(
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=conf1,
tracker_id=np.array([1]),
)
)
smoothed = smoother.update_with_detections(
Detections(
xyxy=np.array([[2, 2, 12, 12]], dtype=np.float32),
confidence=conf2,
tracker_id=np.array([1]),
)
)
assert_allclose(smoothed.xyxy, np.array([[1, 1, 11, 11]]), atol=1e-5)
if expected_confidence is None:
assert smoothed.confidence is None
else:
assert smoothed.confidence is not None
assert_allclose(smoothed.confidence, expected_confidence, atol=1e-5)
def test_smoother_multi_track_mixed_confidence_does_not_crash(self) -> None:
"""Two tracks with different confidence presence must not raise on merge."""
smoother = DetectionsSmoother(length=3)
smoother.update_with_detections(
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.5]),
tracker_id=np.array([1]),
)
)
smoothed = smoother.update_with_detections(
Detections(
xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32),
tracker_id=np.array([2]),
)
)
assert len(smoothed) == 2
assert smoothed.confidence is None
def test_smoother_tracker_id_none_warns_and_returns_unchanged(self) -> None:
"""update_with_detections warns and returns input when tracker_id is None."""
smoother = DetectionsSmoother(length=3)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
tracker_id=None,
)
with pytest.warns(SupervisionWarnings):
result = smoother.update_with_detections(detections)
assert result is detections
def test_smoother_window_full_averages_all_frames(self) -> None:
"""Full window (length=3) averages all 3 frames, not just the last two."""
smoother = DetectionsSmoother(length=3)
smoother.update_with_detections(
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.3]),
tracker_id=np.array([1]),
)
)
smoother.update_with_detections(
Detections(
xyxy=np.array([[3, 3, 13, 13]], dtype=np.float32),
confidence=np.array([0.6]),
tracker_id=np.array([1]),
)
)
smoothed = smoother.update_with_detections(
Detections(
xyxy=np.array([[6, 6, 16, 16]], dtype=np.float32),
confidence=np.array([0.9]),
tracker_id=np.array([1]),
)
)
assert_allclose(smoothed.xyxy, np.array([[3, 3, 13, 13]]), atol=1e-5)
assert smoothed.confidence is not None
assert_allclose(smoothed.confidence, np.array([0.6]), atol=1e-5)
+38
View File
@@ -10,6 +10,7 @@ from supervision.detection.utils.boxes import (
denormalize_boxes,
move_boxes,
scale_boxes,
xyxyxyxy_to_xyxy,
)
@@ -232,3 +233,40 @@ def test_denormalize_boxes(
normalization_factor=normalization_factor,
)
assert np.allclose(result, expected_result)
@pytest.mark.parametrize(
("corners", "expected"),
[
pytest.param(
np.array([[[0, 0], [10, 0], [10, 5], [0, 5]]], dtype=np.float32),
np.array([[0, 0, 10, 5]], dtype=np.float32),
id="single-axis-aligned",
),
pytest.param(
np.array(
[
[[0, 0], [10, 0], [10, 5], [0, 5]],
[[5, 5], [15, 5], [15, 10], [5, 10]],
],
dtype=np.float32,
),
np.array([[0, 0, 10, 5], [5, 5, 15, 10]], dtype=np.float32),
id="batch-axis-aligned",
),
pytest.param(
np.array([[[5, 0], [10, 5], [5, 10], [0, 5]]], dtype=np.float32),
np.array([[0, 0, 10, 10]], dtype=np.float32),
id="rotated-diamond",
),
pytest.param(
np.empty((0, 4, 2), dtype=np.float32),
np.empty((0, 4), dtype=np.float32),
id="empty-input",
),
],
)
def test_xyxyxyxy_to_xyxy(corners: np.ndarray, expected: np.ndarray) -> None:
"""Converts OBB corners to axis-aligned bounding boxes."""
result = xyxyxyxy_to_xyxy(corners)
assert np.allclose(result, expected, atol=1e-5)
+81
View File
@@ -15,6 +15,7 @@ from supervision.detection.utils.converters import (
_rle_counts_to_mask,
is_compressed_rle,
mask_to_rle,
mask_to_xyxy,
rle_to_mask,
xcycwh_to_xyxy,
xywh_to_xyxy,
@@ -315,6 +316,86 @@ def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) ->
np.testing.assert_array_equal(result, expected)
def _mask_to_xyxy_reference(masks: np.ndarray) -> np.ndarray:
"""Per-mask `np.where` loop used as a ground-truth oracle."""
xyxy = np.zeros((masks.shape[0], 4), dtype=int)
for i, mask in enumerate(masks):
rows, cols = np.where(mask)
if len(rows) > 0 and len(cols) > 0:
xyxy[i, :] = [
int(cols.min()),
int(rows.min()),
int(cols.max()),
int(rows.max()),
]
return xyxy
class TestMaskToXyxy:
@pytest.mark.parametrize(
("masks", "expected"),
[
pytest.param(
np.zeros((0, 5, 5), dtype=bool),
np.zeros((0, 4), dtype=int),
id="no-masks",
),
pytest.param(
np.zeros((2, 5, 5), dtype=bool),
np.zeros((2, 4), dtype=int),
id="all-empty-masks",
),
pytest.param(
np.array([[[False, False], [False, True]]], dtype=bool),
np.array([[1, 1, 1, 1]], dtype=int),
id="single-pixel-bottom-right",
),
pytest.param(
np.array([[[True, False], [False, False]]], dtype=bool),
np.array([[0, 0, 0, 0]], dtype=int),
id="single-pixel-top-left",
),
pytest.param(
np.array([[[False, True], [False, False]]], dtype=bool),
np.array([[1, 0, 1, 0]], dtype=int),
id="single-pixel-top-right",
),
pytest.param(
np.array([[[False, False], [True, False]]], dtype=bool),
np.array([[0, 1, 0, 1]], dtype=int),
id="single-pixel-bottom-left",
),
pytest.param(
np.ones((1, 4, 6), dtype=bool),
np.array([[0, 0, 5, 3]], dtype=int),
id="full-mask",
),
],
)
def test_mask_to_xyxy_known_values(
self, masks: np.ndarray, expected: np.ndarray
) -> None:
"""Known boxes for empty, single-pixel, and full masks."""
result = mask_to_xyxy(masks)
assert result.dtype == expected.dtype
assert result.shape == expected.shape
np.testing.assert_array_equal(result, expected)
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_mask_to_xyxy_matches_reference(self, seed: int) -> None:
"""Vectorized output is bit-identical to the per-mask scalar loop."""
rng = np.random.default_rng(seed)
masks = rng.random((6, 30, 40)) < 0.1
masks[0] = False # include an empty mask in the batch
result = mask_to_xyxy(masks)
reference = _mask_to_xyxy_reference(masks)
assert result.dtype == reference.dtype
np.testing.assert_array_equal(result, reference)
@pytest.mark.parametrize(
("mask", "compressed", "expected_rle", "exception"),
[

Some files were not shown because too many files have changed in this diff Show More