Merge pull request #2005 from roboflow/develop
supervision-0.27.0 release
This commit is contained in:
commit
a61440ee0b
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Clear cache
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
console.log("Starting cache cleanup...")
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
|
@ -42,6 +42,6 @@ jobs:
|
|||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
attestations: true
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
|
@ -40,6 +40,6 @@ jobs:
|
|||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
attestations: true
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
uv run twine check --strict dist/*
|
||||
|
||||
- name: 🚀 Publish to Test-PyPi
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
attestations: true
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
fetch-depth: 0
|
||||
|
||||
- name: 🐍 Install uv and set Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@bd01e18f51369d5a26f1651c3cb451d3417e3bba # v6.3.1
|
||||
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
activate-environment: true
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ repos:
|
|||
additional_dependencies: ["bandit[toml]"]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.3
|
||||
rev: v0.12.5
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,73 @@
|
|||
# Changelog
|
||||
|
||||
### 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.
|
||||
|
||||
- Added [#2006](https://github.com/roboflow/supervision/pull/2006): [`sv.xyxy_to_mask`](https://supervision.roboflow.com/0.27.0/detection/utils/converters/#supervision.detection.utils.converters.xyxy_to_mask) to convert bounding boxes into 2D boolean masks, where each mask corresponds to a single box.
|
||||
|
||||
- Added [#1943](https://github.com/roboflow/supervision/pull/1943): [`sv.tint_image`](https://supervision.roboflow.com/0.27.0/utils/image/#supervision.utils.image.tint_image) to apply a solid color overlay to an image at a given opacity. Works with both NumPy and PIL inputs.
|
||||
|
||||
- Added [#1943](https://github.com/roboflow/supervision/pull/1943): [`sv.grayscale_image`](https://supervision.roboflow.com/0.27.0/utils/image/#supervision.utils.image.tint_image) to convert an image to 3 channel grayscale for compatibility with color based drawing utilities.
|
||||
|
||||
- Added [#2014](https://github.com/roboflow/supervision/pull/2014): [`sv.get_image_resolution_wh`](https://supervision.roboflow.com/0.27.0/utils/image/#supervision.utils.image.get_image_resolution_wh) as a unified way to read image width and height from NumPy and PIL inputs.
|
||||
|
||||
- Added [#1912](https://github.com/roboflow/supervision/pull/1912): [`sv.edit_distance`](https://supervision.roboflow.com/0.27.0/detection/utils/vlms/#supervision.detection.utils.vlms.edit_distance) for Levenshtein distance between two strings. Supports insert, delete, and substitute operations.
|
||||
|
||||
- Added [#1912](https://github.com/roboflow/supervision/pull/1912): [`sv.fuzzy_match_index`](https://supervision.roboflow.com/0.27.0/detection/utils/vlms/#supervision.detection.utils.vlms.fuzzy_match_index) to find the first close match in a list using edit distance.
|
||||
|
||||
- Changed [#2015](https://github.com/roboflow/supervision/pull/2015): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections.from_vlm) and legacy `from_lmm` now support Qwen3 VL via `vlm=sv.VLM.QWEN_3_VL`.
|
||||
|
||||
- Changed [#1884](https://github.com/roboflow/supervision/pull/1884): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections.from_vlm) and legacy `from_lmm` now support DeepSeek VL 2 via `vlm=sv.VLM.DEEPSEEK_VL_2`.
|
||||
|
||||
- Changed [#2015](https://github.com/roboflow/supervision/pull/2015): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections.from_vlm) now parses Qwen 2.5 VL outputs more robustly and handles incomplete or truncated JSON responses.
|
||||
|
||||
- Changed [#2014](https://github.com/roboflow/supervision/pull/2014): [`sv.InferenceSlicer`](https://supervision.roboflow.com/0.27.0/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now uses a new offset generation logic that removes redundant tiles and aligns borders cleanly. This reduces the number of processed tiles and shortens inference time without hurting detection quality.
|
||||
|
||||
- Changed [#2016](https://github.com/roboflow/supervision/pull/2016): [`sv.Detections`](https://supervision.roboflow.com/0.27.0/detection/core/#supervision.detection.core.Detections) now includes a `box_aspect_ratio` property for vectorized aspect ratio computation, useful for filtering detections based on box shape.
|
||||
|
||||
- Changed [#2001](https://github.com/roboflow/supervision/pull/2001): Significantly improved the performance of [`sv.box_iou_batch`](https://supervision.roboflow.com/0.27.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.box_iou_batch). On internal benchmarks, processing runs approximately 2x to 5x faster.
|
||||
|
||||
- Changed [#1997](https://github.com/roboflow/supervision/pull/1997): [`sv.process_video`](https://supervision.roboflow.com/0.27.0/utils/video/#supervision.utils.video.process_video) now uses a threaded reader, processor, and writer pipeline. This removes I/O stalls and improves throughput while keeping the callback single threaded and safe for stateful models.
|
||||
|
||||
- Changed: [`sv.denormalize_boxes`](https://supervision.roboflow.com/0.27.0/detection/utils/boxes/#supervision.detection.utils.boxes.denormalize_boxes) now supports batch conversion of bounding boxes. The function accepts arrays of shape `(N, 4)` and returns a batch of absolute pixel coordinates.
|
||||
|
||||
- Changed [#1917](https://github.com/roboflow/supervision/pull/1917): [`sv.LabelAnnotator`](https://supervision.roboflow.com/develop/0.27.0/annotators/#supervision.annotators.core.LabelAnnotator) and [`sv.RichLabelAnnotator`](https://supervision.roboflow.com/develop/0.27.0/annotators/#supervision.annotators.core.LabelAnnotator) now accept `text_offset=(x, y)` to shift the label relative to `text_position`. Works with smart label position and line wrapping.
|
||||
|
||||
!!! failure "Removed"
|
||||
Removed the deprecated `overlap_ratio_wh` argument from `sv.InferenceSlicer`. Use the pixel based `overlap_wh` argument to control slice overlap.
|
||||
|
||||
!!! info "Tip"
|
||||
Convert your old ratio based overlap to pixel based overlap by multiplying each ratio by the slice dimensions.
|
||||
|
||||
```python
|
||||
# before
|
||||
|
||||
slice_wh = (640, 640)
|
||||
overlap_ratio_wh = (0.25, 0.25)
|
||||
|
||||
slicer = sv.InferenceSlicer(
|
||||
callback=callback,
|
||||
slice_wh=slice_wh,
|
||||
overlap_ratio_wh=overlap_ratio_wh,
|
||||
overlap_filter=sv.OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
)
|
||||
|
||||
# after
|
||||
|
||||
overlap_wh = (
|
||||
int(overlap_ratio_wh[0] * slice_wh[0]),
|
||||
int(overlap_ratio_wh[1] * slice_wh[1]),
|
||||
)
|
||||
|
||||
slicer = sv.InferenceSlicer(
|
||||
callback=callback,
|
||||
slice_wh=slice_wh,
|
||||
overlap_wh=overlap_wh,
|
||||
overlap_filter=sv.OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
)
|
||||
```
|
||||
|
||||
### 0.26.1 <small>Jul 22, 2025</small>
|
||||
|
||||
- Fixed [1894](https://github.com/roboflow/supervision/pull/1894): Error in [`sv.MeanAveragePrecision`](https://supervision.roboflow.com/0.26.1/metrics/mean_average_precision/#supervision.metrics.mean_average_precision.MeanAveragePrecision) where the area used for size-specific evaluation (small / medium / large) was always zero unless explicitly provided in `sv.Detections.data`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Annotators
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Detections
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Boxes Utils
|
||||
|
|
|
|||
|
|
@ -58,3 +58,9 @@ status: new
|
|||
</div>
|
||||
|
||||
:::supervision.detection.utils.converters.polygon_to_xyxy
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.converters.xyxy_to_mask">xyxy_to_mask</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.converters.xyxy_to_mask
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# IoU and NMS Utils
|
||||
|
|
|
|||
|
|
@ -22,3 +22,9 @@ status: new
|
|||
</div>
|
||||
|
||||
:::supervision.detection.utils.masks.contains_multiple_segments
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.masks.filter_segments_by_distance">filter_segments_by_distance</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.masks.filter_segments_by_distance
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Polygons Utils
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# VLMs Utils
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.vlms.edit_distance">edit_distance</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.vlms.edit_distance
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.vlms.fuzzy_match_index">fuzzy_match_index</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.vlms.fuzzy_match_index
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -331,12 +331,9 @@ for i in range(16):
|
|||
annotated_image = label_annotator.annotate(annotated_image, annotations, labels)
|
||||
annotated_images.append(annotated_image)
|
||||
|
||||
grid = sv.create_tiles(
|
||||
sv.plot_images_grid(
|
||||
annotated_images,
|
||||
grid_size=(4, 4),
|
||||
single_tile_size=(400, 400),
|
||||
tile_padding_color=sv.Color.WHITE,
|
||||
tile_margin_color=sv.Color.WHITE
|
||||
)
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ with
|
|||
model = get_model(model_id="yolov8n-640")
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>)
|
||||
|
||||
with sv.JSONSink(<TARGET_CSV_PATH>) as sink:
|
||||
with sv.JSONSink(<TARGET_JSON_PATH>) as sink:
|
||||
for frame_index, frame in enumerate(frames_generator):
|
||||
|
||||
results = model.infer(image)[0]
|
||||
|
|
@ -250,7 +250,7 @@ with
|
|||
model = YOLO("yolov8n.pt")
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>)
|
||||
|
||||
with sv.JSONSink(<TARGET_CSV_PATH>) as sink:
|
||||
with sv.JSONSink(<TARGET_JSON_PATH>) as sink:
|
||||
for frame_index, frame in enumerate(frames_generator):
|
||||
|
||||
results = model(frame)[0]
|
||||
|
|
@ -268,7 +268,7 @@ with
|
|||
model = DetrForObjectDetection.from_pretrained("facebook/detr-resnet-50")
|
||||
frames_generator = sv.get_video_frames_generator(<SOURCE_VIDEO_PATH>)
|
||||
|
||||
with sv.JSONSink(<TARGET_CSV_PATH>) as sink:
|
||||
with sv.JSONSink(<TARGET_JSON_PATH>) as sink:
|
||||
for frame_index, frame in enumerate(frames_generator):
|
||||
|
||||
frame = sv.cv2_to_pillow(frame)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Track Objects
|
||||
|
|
@ -345,7 +344,7 @@ Supervision is versatile and compatible with various models. Check this [link](/
|
|||
|
||||
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.
|
||||
|
||||
Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypoint/annotators/#supervision.keypoint.annotators.EdgeAnnotator) and [`VertexAnnotator`](https://supervision.roboflow.com/latest/keypoint/annotators/#supervision.keypoint.annotators.VertexAnnotator).
|
||||
Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`VertexAnnotator`](https://supervision.roboflow.com/latest/keypoint/annotators/#supervision.key_points.annotators.VertexAnnotator).
|
||||
|
||||
=== "Ultralytics"
|
||||
|
||||
|
|
@ -408,7 +407,7 @@ Let's immediately visualize the results with our [`EdgeAnnotator`](/latest/keypo
|
|||
|
||||
### Convert to Detections
|
||||
|
||||
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`KeyPoints.as_detections()`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.as_detections) function.
|
||||
Keypoint tracking is currently supported via the conversion of `KeyPoints` to `Detections`. This is achieved with the [`KeyPoints.as_detections()`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.as_detections) function.
|
||||
|
||||
Let's convert to detections and visualize the results with our [`BoxAnnotator`](/latest/detection/annotators/#supervision.annotators.core.BoxAnnotator).
|
||||
|
||||
|
|
|
|||
|
|
@ -78,19 +78,19 @@ comments: true
|
|||
</div>
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.keypoint.annotators.VertexAnnotator">VertexAnnotator</a></h2>
|
||||
<h2><a href="#supervision.key_points.annotators.VertexAnnotator">VertexAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.keypoint.annotators.VertexAnnotator
|
||||
:::supervision.key_points.annotators.VertexAnnotator
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.keypoint.annotators.EdgeAnnotator">EdgeAnnotator</a></h2>
|
||||
<h2><a href="#supervision.key_points.annotators.EdgeAnnotator">EdgeAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.keypoint.annotators.EdgeAnnotator
|
||||
:::supervision.key_points.annotators.EdgeAnnotator
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.keypoint.annotators.VertexLabelAnnotator">VertexLabelAnnotator</a></h2>
|
||||
<h2><a href="#supervision.key_points.annotators.VertexLabelAnnotator">VertexLabelAnnotator</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.keypoint.annotators.VertexLabelAnnotator
|
||||
:::supervision.key_points.annotators.VertexLabelAnnotator
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Keypoint Detection
|
||||
|
||||
:::supervision.keypoint.core.KeyPoints
|
||||
:::supervision.key_points.core.KeyPoints
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Mean Average Precision
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
---
|
||||
comments: true
|
||||
status: new
|
||||
---
|
||||
|
||||
# Image Utils
|
||||
|
|
@ -29,10 +30,22 @@ comments: true
|
|||
:::supervision.utils.image.letterbox_image
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.overlay_image">overlay_image</a></h2>
|
||||
<h2><a href="#supervision.utils.image.tint_image">tint_image</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.utils.image.overlay_image
|
||||
:::supervision.utils.image.tint_image
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.grayscale_image">grayscale_image</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.utils.image.grayscale_image
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.get_image_resolution_wh">get_image_resolution_wh</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.utils.image.get_image_resolution_wh
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.utils.image.ImageSink">ImageSink</a></h2>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
supervision
|
||||
ultralytics
|
||||
inference
|
||||
pytube
|
||||
# https://github.com/pytube/pytube/issues/2044
|
||||
# pytube
|
||||
pytubefix
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
import argparse
|
||||
import os
|
||||
|
||||
from pytube import YouTube
|
||||
from pytubefix import YouTube
|
||||
|
||||
|
||||
def main(url: str, output_path: str | None, file_name: str | None) -> None:
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ extra:
|
|||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
- stylesheets/cookbooks-card.css
|
||||
- stylesheets/cookbooks_card.css
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
|
|
@ -47,6 +47,7 @@ nav:
|
|||
- Boxes: detection/utils/boxes.md
|
||||
- Masks: detection/utils/masks.md
|
||||
- Polygons: detection/utils/polygons.md
|
||||
- VLMs: detection/utils/vlms.md
|
||||
- Keypoint Detection:
|
||||
- Core: keypoint/core.md
|
||||
- Annotators: keypoint/annotators.md
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
name = "supervision"
|
||||
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
|
||||
license = { text = "MIT" }
|
||||
version = "0.26.1"
|
||||
version = "0.27.0"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
authors = [
|
||||
|
|
@ -67,11 +67,12 @@ dev = [
|
|||
"ipywidgets>=8.1.1",
|
||||
"jupytext>=1.16.1",
|
||||
"nbconvert>=7.14.2",
|
||||
"docutils!=0.21"
|
||||
"docutils!=0.21",
|
||||
"pre-commit>=3.8.0"
|
||||
]
|
||||
docs = [
|
||||
"mkdocs-material[imaging]>=9.5.5",
|
||||
"mkdocstrings>=0.25.2,<0.30.0",
|
||||
"mkdocstrings>=0.25.2,<0.31.0",
|
||||
"mkdocstrings-python>=1.10.9",
|
||||
"mike>=2.0.0",
|
||||
"mkdocs-jupyter>=0.24.3",
|
||||
|
|
@ -81,7 +82,7 @@ docs = [
|
|||
build = [
|
||||
"twine>=5.1.1,<7.0.0",
|
||||
"wheel>=0.40,<0.46",
|
||||
"build>=0.10,<1.3"
|
||||
"build>=0.10,<1.4"
|
||||
]
|
||||
|
||||
[tool.bandit]
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ from supervision.detection.utils.converters import (
|
|||
polygon_to_xyxy,
|
||||
xcycwh_to_xyxy,
|
||||
xywh_to_xyxy,
|
||||
xyxy_to_mask,
|
||||
xyxy_to_polygons,
|
||||
xyxy_to_xcycarh,
|
||||
xyxy_to_xywh,
|
||||
|
|
@ -86,12 +87,14 @@ from supervision.detection.utils.masks import (
|
|||
calculate_masks_centroids,
|
||||
contains_holes,
|
||||
contains_multiple_segments,
|
||||
filter_segments_by_distance,
|
||||
move_masks,
|
||||
)
|
||||
from supervision.detection.utils.polygons import (
|
||||
approximate_polygon,
|
||||
filter_polygons_by_area,
|
||||
)
|
||||
from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index
|
||||
from supervision.detection.vlm import LMM, VLM
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.draw.utils import (
|
||||
|
|
@ -107,24 +110,26 @@ from supervision.draw.utils import (
|
|||
)
|
||||
from supervision.geometry.core import Point, Position, Rect
|
||||
from supervision.geometry.utils import get_polygon_center
|
||||
from supervision.keypoint.annotators import (
|
||||
from supervision.key_points.annotators import (
|
||||
EdgeAnnotator,
|
||||
VertexAnnotator,
|
||||
VertexLabelAnnotator,
|
||||
)
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.key_points.core import KeyPoints
|
||||
from supervision.metrics.detection import ConfusionMatrix, MeanAveragePrecision
|
||||
from supervision.tracker.byte_tracker.core import ByteTrack
|
||||
from supervision.utils.conversion import cv2_to_pillow, pillow_to_cv2
|
||||
from supervision.utils.file import list_files_with_extensions
|
||||
from supervision.utils.image import (
|
||||
ImageSink,
|
||||
create_tiles,
|
||||
crop_image,
|
||||
get_image_resolution_wh,
|
||||
grayscale_image,
|
||||
letterbox_image,
|
||||
overlay_image,
|
||||
resize_image,
|
||||
scale_image,
|
||||
tint_image,
|
||||
)
|
||||
from supervision.utils.notebook import plot_image, plot_images_grid
|
||||
from supervision.utils.video import (
|
||||
|
|
@ -205,7 +210,6 @@ __all__ = [
|
|||
"clip_boxes",
|
||||
"contains_holes",
|
||||
"contains_multiple_segments",
|
||||
"create_tiles",
|
||||
"crop_image",
|
||||
"cv2_to_pillow",
|
||||
"draw_filled_polygon",
|
||||
|
|
@ -215,10 +219,15 @@ __all__ = [
|
|||
"draw_polygon",
|
||||
"draw_rectangle",
|
||||
"draw_text",
|
||||
"edit_distance",
|
||||
"filter_polygons_by_area",
|
||||
"filter_segments_by_distance",
|
||||
"fuzzy_match_index",
|
||||
"get_coco_class_index_mapping",
|
||||
"get_image_resolution_wh",
|
||||
"get_polygon_center",
|
||||
"get_video_frames_generator",
|
||||
"grayscale_image",
|
||||
"letterbox_image",
|
||||
"list_files_with_extensions",
|
||||
"mask_iou_batch",
|
||||
|
|
@ -242,8 +251,10 @@ __all__ = [
|
|||
"rle_to_mask",
|
||||
"scale_boxes",
|
||||
"scale_image",
|
||||
"tint_image",
|
||||
"xcycwh_to_xyxy",
|
||||
"xywh_to_xyxy",
|
||||
"xyxy_to_mask",
|
||||
"xyxy_to_polygons",
|
||||
"xyxy_to_xcycarh",
|
||||
"xyxy_to_xywh",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,7 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import TypeVar
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
|
||||
ImageType = TypeVar("ImageType", np.ndarray, Image.Image)
|
||||
"""
|
||||
An image of type `np.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
Unlike a `Union`, ensures the type remains consistent. If a function
|
||||
takes an `ImageType` argument and returns an `ImageType`, when you
|
||||
pass an `np.ndarray`, you will get an `np.ndarray` back.
|
||||
"""
|
||||
from supervision.draw.base import ImageType
|
||||
|
||||
|
||||
class BaseAnnotator(ABC):
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import cv2
|
|||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from scipy.interpolate import splev, splprep
|
||||
|
||||
from supervision.annotators.base import BaseAnnotator, ImageType
|
||||
from supervision.annotators.base import BaseAnnotator
|
||||
from supervision.annotators.utils import (
|
||||
PENDING_TRACK_ID,
|
||||
ColorLookup,
|
||||
|
|
@ -28,12 +29,13 @@ from supervision.detection.utils.converters import (
|
|||
polygon_to_mask,
|
||||
xyxy_to_polygons,
|
||||
)
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.draw.color import Color, ColorPalette
|
||||
from supervision.draw.utils import draw_polygon, draw_rounded_rectangle, draw_text
|
||||
from supervision.geometry.core import Point, Position, Rect
|
||||
from supervision.utils.conversion import (
|
||||
ensure_cv2_image_for_annotation,
|
||||
ensure_pil_image_for_annotation,
|
||||
ensure_cv2_image_for_class_method,
|
||||
ensure_pil_image_for_class_method,
|
||||
)
|
||||
from supervision.utils.image import (
|
||||
crop_image,
|
||||
|
|
@ -51,25 +53,28 @@ class _BaseLabelAnnotator(BaseAnnotator):
|
|||
|
||||
Attributes:
|
||||
color (Union[Color, ColorPalette]): The color to use for the label background.
|
||||
color_lookup (ColorLookup): The method used to determine the color of the label.
|
||||
text_color (Union[Color, ColorPalette]): The color to use for the label text.
|
||||
text_padding (int): The padding around the label text, in pixels.
|
||||
text_anchor (Position): The position of the text relative to the detection
|
||||
bounding box.
|
||||
color_lookup (ColorLookup): The method used to determine the color of the label.
|
||||
bounding box.
|
||||
text_offset (Tuple[int, int]): A tuple of 2D coordinates `(x, y)` to
|
||||
offset the text position from the anchor point, in pixels.
|
||||
border_radius (int): The radius of the label background corners, in pixels.
|
||||
smart_position (bool): Whether to intelligently adjust the label position to
|
||||
avoid overlapping with other elements.
|
||||
avoid overlapping with other elements.
|
||||
max_line_length (Optional[int]): Maximum number of characters per line before
|
||||
wrapping the text. None means no wrapping.
|
||||
wrapping the text. None means no wrapping.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
color: Color | ColorPalette = ColorPalette.DEFAULT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_color: Color | ColorPalette = Color.WHITE,
|
||||
text_padding: int = 10,
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_offset: tuple[int, int] = (0, 0),
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
max_line_length: int | None = None,
|
||||
|
|
@ -79,27 +84,29 @@ class _BaseLabelAnnotator(BaseAnnotator):
|
|||
|
||||
Args:
|
||||
color (Union[Color, ColorPalette], optional): The color to use for the label
|
||||
background.
|
||||
background.
|
||||
color_lookup (ColorLookup, optional): The method used to determine the color
|
||||
of the label
|
||||
text_color (Union[Color, ColorPalette], optional): The color to use for the
|
||||
label text.
|
||||
label text.
|
||||
text_padding (int, optional): The padding around the label text, in pixels.
|
||||
text_position (Position, optional): The position of the text relative to the
|
||||
detection bounding box.
|
||||
color_lookup (ColorLookup, optional): The method used to determine the color
|
||||
of the label
|
||||
detection bounding box.
|
||||
text_offset (Tuple[int, int], optional): A tuple of 2D coordinates
|
||||
`(x, y)` to offset the text position from the anchor point, in pixels.
|
||||
border_radius (int, optional): The radius of the label background corners,
|
||||
in pixels.
|
||||
in pixels.
|
||||
smart_position (bool, optional): Whether to intelligently adjust the label
|
||||
position to avoid overlapping with other elements.
|
||||
position to avoid overlapping with other elements.
|
||||
max_line_length (Optional[int], optional): Maximum number of characters per
|
||||
line before wrapping the text. None means no wrapping.
|
||||
|
||||
line before wrapping the text. None means no wrapping.
|
||||
"""
|
||||
self.color: Color | ColorPalette = color
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.text_color: Color | ColorPalette = text_color
|
||||
self.text_padding: int = text_padding
|
||||
self.text_anchor: Position = text_position
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.text_offset: tuple[int, int] = text_offset
|
||||
self.border_radius: int = border_radius
|
||||
self.smart_position = smart_position
|
||||
self.max_line_length: int | None = max_line_length
|
||||
|
|
@ -171,7 +178,7 @@ class BoxAnnotator(BaseAnnotator):
|
|||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -254,7 +261,7 @@ class OrientedBoxAnnotator(BaseAnnotator):
|
|||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -343,7 +350,7 @@ class MaskAnnotator(BaseAnnotator):
|
|||
self.opacity = opacity
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -433,7 +440,7 @@ class PolygonAnnotator(BaseAnnotator):
|
|||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -520,7 +527,7 @@ class ColorAnnotator(BaseAnnotator):
|
|||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.opacity = opacity
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -616,7 +623,7 @@ class HaloAnnotator(BaseAnnotator):
|
|||
self.color_lookup: ColorLookup = color_lookup
|
||||
self.kernel_size: int = kernel_size
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -716,7 +723,7 @@ class EllipseAnnotator(BaseAnnotator):
|
|||
self.end_angle: int = end_angle
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -808,7 +815,7 @@ class BoxCornerAnnotator(BaseAnnotator):
|
|||
self.corner_length: int = corner_length
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -897,7 +904,7 @@ class CircleAnnotator(BaseAnnotator):
|
|||
self.thickness: int = thickness
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -996,7 +1003,7 @@ class DotAnnotator(BaseAnnotator):
|
|||
self.outline_thickness = outline_thickness
|
||||
self.outline_color: Color | ColorPalette = outline_color
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -1076,37 +1083,100 @@ class LabelAnnotator(_BaseLabelAnnotator):
|
|||
def __init__(
|
||||
self,
|
||||
color: Color | ColorPalette = ColorPalette.DEFAULT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_color: Color | ColorPalette = Color.WHITE,
|
||||
text_scale: float = 0.5,
|
||||
text_thickness: int = 1,
|
||||
text_padding: int = 10,
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_offset: tuple[int, int] = (0, 0),
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
max_line_length: int | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, ColorPalette]): The color or color palette to use for
|
||||
annotating the text background.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
text_color (Union[Color, ColorPalette]): The color or color palette to use
|
||||
for the text.
|
||||
text_scale (float): Font scale for the text.
|
||||
text_thickness (int): Thickness of the text characters.
|
||||
text_padding (int): Padding around the text within its background box.
|
||||
text_position (Position): Position of the text relative to the detection.
|
||||
Possible values are defined in the `Position` enum.
|
||||
text_offset (Tuple[int, int]): A tuple of 2D coordinates `(x, y)` to
|
||||
offset the text position from the anchor point, in pixels.
|
||||
border_radius (int): The radius to apply round edges. If the selected
|
||||
value is higher than the lower dimension, width or height, is clipped.
|
||||
smart_position (bool): Spread out the labels to avoid overlapping.
|
||||
max_line_length (Optional[int]): Maximum number of characters per line
|
||||
before wrapping the text. None means no wrapping.
|
||||
"""
|
||||
self.text_scale: float = text_scale
|
||||
self.text_thickness: int = text_thickness
|
||||
super().__init__(
|
||||
color=color,
|
||||
color_lookup=color_lookup,
|
||||
text_color=text_color,
|
||||
text_padding=text_padding,
|
||||
text_position=text_position,
|
||||
color_lookup=color_lookup,
|
||||
text_offset=text_offset,
|
||||
border_radius=border_radius,
|
||||
smart_position=smart_position,
|
||||
max_line_length=max_line_length,
|
||||
)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType, # Ensure scene is initially a NumPy array here
|
||||
scene: ImageType,
|
||||
detections: Detections,
|
||||
labels: list[str] | None = None,
|
||||
custom_color_lookup: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Annotates the given scene with labels based on the provided detections.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where labels will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray`
|
||||
or `PIL.Image.Image`.
|
||||
detections (Detections): Object detections to annotate.
|
||||
labels (Optional[List[str]]): Custom labels for each detection.
|
||||
custom_color_lookup (Optional[np.ndarray]): Custom color lookup array.
|
||||
Allows to override the default color mapping strategy.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```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)
|
||||
]
|
||||
|
||||
label_annotator = sv.LabelAnnotator(text_position=sv.Position.CENTER)
|
||||
annotated_frame = label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections,
|
||||
labels=labels
|
||||
)
|
||||
```
|
||||
|
||||

|
||||
"""
|
||||
assert isinstance(scene, np.ndarray)
|
||||
validate_labels(labels, detections)
|
||||
|
||||
|
|
@ -1144,7 +1214,12 @@ class LabelAnnotator(_BaseLabelAnnotator):
|
|||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
|
||||
for label, center_coords in zip(labels, anchors_coordinates):
|
||||
for label, center_coordinates in zip(labels, anchors_coordinates):
|
||||
center_coordinates = (
|
||||
center_coordinates[0] + self.text_offset[0],
|
||||
center_coordinates[1] + self.text_offset[1],
|
||||
)
|
||||
|
||||
wrapped_lines = wrap_text(label, self.max_line_length)
|
||||
line_heights = []
|
||||
line_widths = []
|
||||
|
|
@ -1170,7 +1245,7 @@ class LabelAnnotator(_BaseLabelAnnotator):
|
|||
height_padded = total_height + 2 * self.text_padding
|
||||
|
||||
text_background_xyxy = resolve_text_background_xyxy(
|
||||
center_coordinates=tuple(center_coords),
|
||||
center_coordinates=center_coordinates,
|
||||
text_wh=(width_padded, height_padded),
|
||||
position=self.text_anchor,
|
||||
)
|
||||
|
|
@ -1317,31 +1392,54 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
|||
def __init__(
|
||||
self,
|
||||
color: Color | ColorPalette = ColorPalette.DEFAULT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_color: Color | ColorPalette = Color.WHITE,
|
||||
font_path: str | None = None,
|
||||
font_size: int = 10,
|
||||
text_padding: int = 10,
|
||||
text_position: Position = Position.TOP_LEFT,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
text_offset: tuple[int, int] = (0, 0),
|
||||
border_radius: int = 0,
|
||||
smart_position: bool = False,
|
||||
max_line_length: int | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
color (Union[Color, ColorPalette]): The color or color palette to use for
|
||||
annotating the text background.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
text_color (Union[Color, ColorPalette]): The color to use for the text.
|
||||
font_path (Optional[str]): Path to the font file (e.g., ".ttf" or ".otf")
|
||||
to use for rendering text. If `None`, the default PIL font will be used.
|
||||
font_size (int): Font size for the text.
|
||||
text_padding (int): Padding around the text within its background box.
|
||||
text_position (Position): Position of the text relative to the detection.
|
||||
Possible values are defined in the `Position` enum.
|
||||
text_offset (Tuple[int, int]): A tuple of 2D coordinates `(x, y)` to
|
||||
offset the text position from the anchor point, in pixels.
|
||||
border_radius (int): The radius to apply round edges. If the selected
|
||||
value is higher than the lower dimension, width or height, is clipped.
|
||||
smart_position (bool): Spread out the labels to avoid overlapping.
|
||||
max_line_length (Optional[int]): Maximum number of characters per line
|
||||
before wrapping the text. None means no wrapping.
|
||||
"""
|
||||
self.font_path = font_path
|
||||
self.font_size = font_size
|
||||
self.font = self._load_font(font_size, font_path)
|
||||
super().__init__(
|
||||
color=color,
|
||||
color_lookup=color_lookup,
|
||||
text_color=text_color,
|
||||
text_padding=text_padding,
|
||||
text_position=text_position,
|
||||
color_lookup=color_lookup,
|
||||
text_offset=text_offset,
|
||||
border_radius=border_radius,
|
||||
smart_position=smart_position,
|
||||
max_line_length=max_line_length,
|
||||
)
|
||||
|
||||
@ensure_pil_image_for_annotation
|
||||
@ensure_pil_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -1349,6 +1447,44 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
|||
labels: list[str] | None = None,
|
||||
custom_color_lookup: np.ndarray | None = None,
|
||||
) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene with labels based on the provided
|
||||
detections, with support for Unicode characters.
|
||||
|
||||
Args:
|
||||
scene (ImageType): The image where labels will be drawn.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray`
|
||||
or `PIL.Image.Image`.
|
||||
detections (Detections): Object detections to annotate.
|
||||
labels (Optional[List[str]]): Custom labels for each detection.
|
||||
custom_color_lookup (Optional[np.ndarray]): Custom color lookup array.
|
||||
Allows to override the default color mapping strategy.
|
||||
|
||||
Returns:
|
||||
The annotated image, matching the type of `scene` (`numpy.ndarray`
|
||||
or `PIL.Image.Image`)
|
||||
|
||||
Example:
|
||||
```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)
|
||||
]
|
||||
|
||||
rich_label_annotator = sv.RichLabelAnnotator(font_path="path/to/font.ttf")
|
||||
annotated_frame = label_annotator.annotate(
|
||||
scene=image.copy(),
|
||||
detections=detections,
|
||||
labels=labels
|
||||
)
|
||||
```
|
||||
"""
|
||||
assert isinstance(scene, Image.Image)
|
||||
validate_labels(labels, detections)
|
||||
|
||||
|
|
@ -1386,7 +1522,12 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
|||
anchor=self.text_anchor
|
||||
).astype(int)
|
||||
|
||||
for label, center_coords in zip(labels, anchor_coordinates):
|
||||
for label, center_coordinates in zip(labels, anchor_coordinates):
|
||||
center_coordinates = (
|
||||
center_coordinates[0] + self.text_offset[0],
|
||||
center_coordinates[1] + self.text_offset[1],
|
||||
)
|
||||
|
||||
wrapped_lines = wrap_text(label, self.max_line_length)
|
||||
|
||||
# Calculate the total text height and maximum width
|
||||
|
|
@ -1409,7 +1550,7 @@ class RichLabelAnnotator(_BaseLabelAnnotator):
|
|||
height_padded = int(total_height + 2 * self.text_padding)
|
||||
|
||||
text_background_xyxy = resolve_text_background_xyxy(
|
||||
center_coordinates=tuple(center_coords),
|
||||
center_coordinates=center_coordinates,
|
||||
text_wh=(width_padded, height_padded),
|
||||
position=self.text_anchor,
|
||||
)
|
||||
|
|
@ -1525,7 +1666,7 @@ class IconAnnotator(BaseAnnotator):
|
|||
self.position = icon_position
|
||||
self.offset_xy = offset_xy
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self, scene: ImageType, detections: Detections, icon_path: str | list[str]
|
||||
) -> ImageType:
|
||||
|
|
@ -1614,7 +1755,7 @@ class BlurAnnotator(BaseAnnotator):
|
|||
"""
|
||||
self.kernel_size: int = kernel_size
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -1681,6 +1822,7 @@ class TraceAnnotator(BaseAnnotator):
|
|||
position: Position = Position.CENTER,
|
||||
trace_length: int = 30,
|
||||
thickness: int = 2,
|
||||
smooth: bool = False,
|
||||
color_lookup: ColorLookup = ColorLookup.CLASS,
|
||||
):
|
||||
"""
|
||||
|
|
@ -1692,15 +1834,17 @@ class TraceAnnotator(BaseAnnotator):
|
|||
trace_length (int): The maximum length of the trace in terms of historical
|
||||
points. Defaults to `30`.
|
||||
thickness (int): The thickness of the trace lines. Defaults to `2`.
|
||||
smooth (bool): Smooth the trace lines.
|
||||
color_lookup (ColorLookup): Strategy for mapping colors to annotations.
|
||||
Options are `INDEX`, `CLASS`, `TRACK`.
|
||||
"""
|
||||
self.color: Color | ColorPalette = color
|
||||
self.trace = Trace(max_size=trace_length, anchor=position)
|
||||
self.thickness = thickness
|
||||
self.smooth = smooth
|
||||
self.color_lookup: ColorLookup = color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -1769,10 +1913,18 @@ class TraceAnnotator(BaseAnnotator):
|
|||
else custom_color_lookup,
|
||||
)
|
||||
xy = self.trace.get(tracker_id=tracker_id)
|
||||
spline_points = xy.astype(np.int32)
|
||||
|
||||
if len(xy) > 3 and self.smooth:
|
||||
x, y = xy[:, 0], xy[:, 1]
|
||||
tck, u = splprep([x, y], s=20)
|
||||
x_new, y_new = splev(np.linspace(0, 1, 100), tck)
|
||||
spline_points = np.stack([x_new, y_new], axis=1).astype(np.int32)
|
||||
|
||||
if len(xy) > 1:
|
||||
scene = cv2.polylines(
|
||||
scene,
|
||||
[xy.astype(np.int32)],
|
||||
[spline_points],
|
||||
False,
|
||||
color=color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
|
|
@ -1814,7 +1966,7 @@ class HeatMapAnnotator(BaseAnnotator):
|
|||
self.low_hue = low_hue
|
||||
self.heat_mask: npt.NDArray[np.float32] | None = None
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, detections: Detections) -> ImageType:
|
||||
"""
|
||||
Annotates the scene with a heatmap based on the provided detections.
|
||||
|
|
@ -1896,7 +2048,7 @@ class PixelateAnnotator(BaseAnnotator):
|
|||
"""
|
||||
self.pixel_size: int = pixel_size
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -1993,7 +2145,7 @@ class TriangleAnnotator(BaseAnnotator):
|
|||
self.outline_thickness: int = outline_thickness
|
||||
self.outline_color: Color | ColorPalette = outline_color
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -2105,7 +2257,7 @@ class RoundBoxAnnotator(BaseAnnotator):
|
|||
raise ValueError("roundness attribute must be float between (0, 1.0]")
|
||||
self.roundness: float = roundness
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -2245,7 +2397,7 @@ class PercentageBarAnnotator(BaseAnnotator):
|
|||
else int(0.15 * self.height)
|
||||
)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -2426,7 +2578,7 @@ class CropAnnotator(BaseAnnotator):
|
|||
self.border_thickness: int = border_thickness
|
||||
self.border_color_lookup: ColorLookup = border_color_lookup
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self,
|
||||
scene: ImageType,
|
||||
|
|
@ -2575,7 +2727,7 @@ class BackgroundOverlayAnnotator(BaseAnnotator):
|
|||
self.opacity = opacity
|
||||
self.force_box = force_box
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, detections: Detections) -> ImageType:
|
||||
"""
|
||||
Applies a colored overlay to the scene outside of the detected regions.
|
||||
|
|
@ -2673,7 +2825,7 @@ class ComparisonAnnotator:
|
|||
self.label_scale = label_scale
|
||||
self.text_thickness = int(self.label_scale + 1.2)
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(
|
||||
self, scene: ImageType, detections_1: Detections, detections_2: Detections
|
||||
) -> ImageType:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import textwrap
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
|
@ -142,35 +143,45 @@ def resolve_color(
|
|||
detection_idx=detection_idx,
|
||||
color_lookup=color_lookup,
|
||||
)
|
||||
if color_lookup == ColorLookup.TRACK and idx == PENDING_TRACK_ID:
|
||||
if (
|
||||
isinstance(color_lookup, ColorLookup)
|
||||
and color_lookup == ColorLookup.TRACK
|
||||
and idx == PENDING_TRACK_ID
|
||||
):
|
||||
return PENDING_TRACK_COLOR
|
||||
return get_color_by_index(color=color, idx=idx)
|
||||
|
||||
|
||||
def wrap_text(text: str, max_line_length=None) -> list[str]:
|
||||
def wrap_text(text: Any, max_line_length=None) -> list[str]:
|
||||
"""
|
||||
Wraps text to the specified maximum line length, respecting existing newlines.
|
||||
Uses the textwrap library for robust text wrapping.
|
||||
Wrap `text` to the specified maximum line length, respecting existing
|
||||
newlines. Falls back to str() if `text` is not already a string.
|
||||
|
||||
Args:
|
||||
text (str): The text to wrap.
|
||||
text (Any): The text (or object) to wrap.
|
||||
max_line_length (int | None): Maximum width for each wrapped line.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of text lines after wrapping.
|
||||
list[str]: Wrapped lines.
|
||||
"""
|
||||
|
||||
if not text:
|
||||
return [""]
|
||||
|
||||
if not isinstance(text, str):
|
||||
text = str(text)
|
||||
|
||||
if max_line_length is None:
|
||||
return text.splitlines() or [""]
|
||||
|
||||
if max_line_length <= 0:
|
||||
raise ValueError("max_line_length must be a positive integer")
|
||||
|
||||
paragraphs = text.split("\n")
|
||||
all_lines = []
|
||||
all_lines: list[str] = []
|
||||
|
||||
for paragraph in paragraphs:
|
||||
if not paragraph:
|
||||
# Keep empty lines
|
||||
if paragraph == "":
|
||||
all_lines.append("")
|
||||
continue
|
||||
|
||||
|
|
@ -182,12 +193,9 @@ def wrap_text(text: str, max_line_length=None) -> list[str]:
|
|||
drop_whitespace=True,
|
||||
)
|
||||
|
||||
if wrapped:
|
||||
all_lines.extend(wrapped)
|
||||
else:
|
||||
all_lines.append("")
|
||||
all_lines.extend(wrapped or [""])
|
||||
|
||||
return all_lines if all_lines else [""]
|
||||
return all_lines or [""]
|
||||
|
||||
|
||||
def validate_labels(labels: list[str] | None, detections: Detections):
|
||||
|
|
|
|||
|
|
@ -40,12 +40,14 @@ from supervision.detection.utils.masks import calculate_masks_centroids
|
|||
from supervision.detection.vlm import (
|
||||
LMM,
|
||||
VLM,
|
||||
from_deepseek_vl_2,
|
||||
from_florence_2,
|
||||
from_google_gemini_2_0,
|
||||
from_google_gemini_2_5,
|
||||
from_moondream,
|
||||
from_paligemma,
|
||||
from_qwen_2_5_vl,
|
||||
from_qwen_3_vl,
|
||||
validate_vlm_parameters,
|
||||
)
|
||||
from supervision.geometry.core import Position
|
||||
|
|
@ -295,18 +297,24 @@ class Detections:
|
|||
class_id=np.arange(len(ultralytics_results)),
|
||||
)
|
||||
|
||||
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int)
|
||||
class_names = np.array([ultralytics_results.names[i] for i in class_id])
|
||||
return cls(
|
||||
xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(),
|
||||
confidence=ultralytics_results.boxes.conf.cpu().numpy(),
|
||||
class_id=class_id,
|
||||
mask=extract_ultralytics_masks(ultralytics_results),
|
||||
tracker_id=ultralytics_results.boxes.id.int().cpu().numpy()
|
||||
if ultralytics_results.boxes.id is not None
|
||||
else None,
|
||||
data={CLASS_NAME_DATA_FIELD: class_names},
|
||||
)
|
||||
if (
|
||||
hasattr(ultralytics_results, "boxes")
|
||||
and ultralytics_results.boxes is not None
|
||||
):
|
||||
class_id = ultralytics_results.boxes.cls.cpu().numpy().astype(int)
|
||||
class_names = np.array([ultralytics_results.names[i] for i in class_id])
|
||||
return cls(
|
||||
xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(),
|
||||
confidence=ultralytics_results.boxes.conf.cpu().numpy(),
|
||||
class_id=class_id,
|
||||
mask=extract_ultralytics_masks(ultralytics_results),
|
||||
tracker_id=ultralytics_results.boxes.id.int().cpu().numpy()
|
||||
if ultralytics_results.boxes.id is not None
|
||||
else None,
|
||||
data={CLASS_NAME_DATA_FIELD: class_names},
|
||||
)
|
||||
|
||||
return cls.empty()
|
||||
|
||||
@classmethod
|
||||
def from_yolo_nas(cls, yolo_nas_results) -> Detections:
|
||||
|
|
@ -830,6 +838,7 @@ class Detections:
|
|||
| Google Gemini 2.0 | `GOOGLE_GEMINI_2_0` | detection | `resolution_wh` | `classes` |
|
||||
| Google Gemini 2.5 | `GOOGLE_GEMINI_2_5` | detection, segmentation | `resolution_wh` | `classes` |
|
||||
| Moondream | `MOONDREAM` | detection | `resolution_wh` | |
|
||||
| DeepSeek-VL2 | `DEEPSEEK_VL_2` | detection | `resolution_wh` | `classes` |
|
||||
|
||||
Args:
|
||||
lmm (Union[LMM, str]): The type of LMM (Large Multimodal Model) to use.
|
||||
|
|
@ -943,6 +952,36 @@ class Detections:
|
|||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Qwen3-VL"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
qwen_3_vl_result = \"\"\"```json
|
||||
[
|
||||
{"bbox_2d": [139, 768, 315, 954], "label": "cat"},
|
||||
{"bbox_2d": [366, 679, 536, 849], "label": "dog"}
|
||||
]
|
||||
```\"\"\"
|
||||
detections = sv.Detections.from_lmm(
|
||||
sv.LMM.QWEN_3_VL,
|
||||
qwen_3_vl_result,
|
||||
resolution_wh=(1000, 1000),
|
||||
classes=['cat', 'dog'],
|
||||
)
|
||||
detections.xyxy
|
||||
# array([[139., 768., 315., 954.], [366., 679., 536., 849.]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['cat', 'dog'], dtype='<U10')}
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Gemini 2.0"
|
||||
```python
|
||||
import supervision as sv
|
||||
|
|
@ -1117,6 +1156,47 @@ class Detections:
|
|||
# array([[1752.28, 818.82, 2165.72, 1229.14],
|
||||
# [1908.01, 1346.67, 2585.99, 2024.11]])
|
||||
```
|
||||
|
||||
!!! example "DeepSeek-VL2"
|
||||
|
||||
|
||||
??? tip "Prompt engineering"
|
||||
|
||||
To get the best results from DeepSeek-VL2, use optimized prompts that leverage
|
||||
its object detection and visual grounding capabilities effectively.
|
||||
|
||||
**For general object detection, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|ref|>The giraffe at the front<|/ref|>
|
||||
```
|
||||
|
||||
**For visual grounding, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|grounding|>Detect the giraffes
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
deepseek_vl2_result = "<|ref|>The giraffe at the back<|/ref|><|det|>[[580, 270, 999, 904]]<|/det|><|ref|>The giraffe at the front<|/ref|><|det|>[[26, 31, 632, 998]]<|/det|><|end▁of▁sentence|>"
|
||||
|
||||
detections = sv.Detections.from_vlm(
|
||||
vlm=sv.VLM.DEEPSEEK_VL_2, result=deepseek_vl2_result, resolution_wh=image.size
|
||||
)
|
||||
|
||||
detections.xyxy
|
||||
# array([[ 420, 293, 724, 982],
|
||||
# [ 18, 33, 458, 1084]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['The giraffe at the back', 'The giraffe at the front'], dtype='<U24')}
|
||||
```
|
||||
""" # noqa: E501
|
||||
|
||||
# filler logic mapping old from_lmm to new from_vlm
|
||||
|
|
@ -1124,6 +1204,7 @@ class Detections:
|
|||
LMM.PALIGEMMA: VLM.PALIGEMMA,
|
||||
LMM.FLORENCE_2: VLM.FLORENCE_2,
|
||||
LMM.QWEN_2_5_VL: VLM.QWEN_2_5_VL,
|
||||
LMM.DEEPSEEK_VL_2: VLM.DEEPSEEK_VL_2,
|
||||
LMM.GOOGLE_GEMINI_2_0: VLM.GOOGLE_GEMINI_2_0,
|
||||
LMM.GOOGLE_GEMINI_2_5: VLM.GOOGLE_GEMINI_2_5,
|
||||
}
|
||||
|
|
@ -1161,9 +1242,11 @@ class Detections:
|
|||
| PaliGemma | `PALIGEMMA` | detection | `resolution_wh` | `classes` |
|
||||
| PaliGemma 2 | `PALIGEMMA` | detection | `resolution_wh` | `classes` |
|
||||
| Qwen2.5-VL | `QWEN_2_5_VL` | detection | `resolution_wh`, `input_wh` | `classes` |
|
||||
| Qwen3-VL | `QWEN_3_VL` | detection | `resolution_wh`, | `classes` |
|
||||
| Google Gemini 2.0 | `GOOGLE_GEMINI_2_0` | detection | `resolution_wh` | `classes` |
|
||||
| Google Gemini 2.5 | `GOOGLE_GEMINI_2_5` | detection, segmentation | `resolution_wh` | `classes` |
|
||||
| Moondream | `MOONDREAM` | detection | `resolution_wh` | |
|
||||
| DeepSeek-VL2 | `DEEPSEEK_VL_2` | detection | `resolution_wh` | `classes` |
|
||||
|
||||
Args:
|
||||
vlm (Union[VLM, str]): The type of VLM (Vision Language Model) to use.
|
||||
|
|
@ -1277,6 +1360,36 @@ class Detections:
|
|||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Qwen3-VL"
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
qwen_3_vl_result = \"\"\"```json
|
||||
[
|
||||
{"bbox_2d": [139, 768, 315, 954], "label": "cat"},
|
||||
{"bbox_2d": [366, 679, 536, 849], "label": "dog"}
|
||||
]
|
||||
```\"\"\"
|
||||
detections = sv.Detections.from_vlm(
|
||||
sv.VLM.QWEN_3_VL,
|
||||
qwen_3_vl_result,
|
||||
resolution_wh=(1000, 1000),
|
||||
classes=['cat', 'dog'],
|
||||
)
|
||||
detections.xyxy
|
||||
# array([[139., 768., 315., 954.], [366., 679., 536., 849.]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['cat', 'dog'], dtype='<U10')}
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
```
|
||||
|
||||
!!! example "Gemini 2.0"
|
||||
```python
|
||||
import supervision as sv
|
||||
|
|
@ -1452,6 +1565,47 @@ class Detections:
|
|||
# [1908.01, 1346.67, 2585.99, 2024.11]])
|
||||
```
|
||||
|
||||
!!! example "DeepSeek-VL2"
|
||||
|
||||
|
||||
??? tip "Prompt engineering"
|
||||
|
||||
To get the best results from DeepSeek-VL2, use optimized prompts that leverage
|
||||
its object detection and visual grounding capabilities effectively.
|
||||
|
||||
**For general object detection, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|ref|>The giraffe at the front<|/ref|>
|
||||
```
|
||||
|
||||
**For visual grounding, use the following user prompt:**
|
||||
|
||||
```
|
||||
<image>\\n<|grounding|>Detect the giraffes
|
||||
```
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import supervision as sv
|
||||
|
||||
deepseek_vl2_result = "<|ref|>The giraffe at the back<|/ref|><|det|>[[580, 270, 999, 904]]<|/det|><|ref|>The giraffe at the front<|/ref|><|det|>[[26, 31, 632, 998]]<|/det|><|end▁of▁sentence|>"
|
||||
|
||||
detections = sv.Detections.from_vlm(
|
||||
vlm=sv.VLM.DEEPSEEK_VL_2, result=deepseek_vl2_result, resolution_wh=image.size
|
||||
)
|
||||
|
||||
detections.xyxy
|
||||
# array([[ 420, 293, 724, 982],
|
||||
# [ 18, 33, 458, 1084]])
|
||||
|
||||
detections.class_id
|
||||
# array([0, 1])
|
||||
|
||||
detections.data
|
||||
# {'class_name': array(['The giraffe at the back', 'The giraffe at the front'], dtype='<U24')}
|
||||
```
|
||||
|
||||
""" # noqa: E501
|
||||
|
||||
vlm = validate_vlm_parameters(vlm, result, kwargs)
|
||||
|
|
@ -1464,6 +1618,18 @@ class Detections:
|
|||
if vlm == VLM.QWEN_2_5_VL:
|
||||
xyxy, class_id, class_name = from_qwen_2_5_vl(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
confidence = np.ones(len(xyxy), dtype=float)
|
||||
return cls(xyxy=xyxy, class_id=class_id, confidence=confidence, data=data)
|
||||
|
||||
if vlm == VLM.QWEN_3_VL:
|
||||
xyxy, class_id, class_name = from_qwen_3_vl(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
confidence = np.ones(len(xyxy), dtype=float)
|
||||
return cls(xyxy=xyxy, class_id=class_id, confidence=confidence, data=data)
|
||||
|
||||
if vlm == VLM.DEEPSEEK_VL_2:
|
||||
xyxy, class_id, class_name = from_deepseek_vl_2(result, **kwargs)
|
||||
data = {CLASS_NAME_DATA_FIELD: class_name}
|
||||
return cls(xyxy=xyxy, class_id=class_id, data=data)
|
||||
|
||||
if vlm == VLM.FLORENCE_2:
|
||||
|
|
@ -1922,6 +2088,43 @@ class Detections:
|
|||
"""
|
||||
return (self.xyxy[:, 3] - self.xyxy[:, 1]) * (self.xyxy[:, 2] - self.xyxy[:, 0])
|
||||
|
||||
@property
|
||||
def box_aspect_ratio(self) -> np.ndarray:
|
||||
"""
|
||||
Compute the aspect ratio (width divided by height) for each bounding box.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Array of shape `(N,)` containing aspect ratios, where `N` is the
|
||||
number of boxes (width / height for each box).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
xyxy = np.array([
|
||||
[10, 10, 50, 50],
|
||||
[60, 10, 180, 50],
|
||||
[10, 60, 50, 180],
|
||||
])
|
||||
|
||||
detections = sv.Detections(xyxy=xyxy)
|
||||
|
||||
detections.box_aspect_ratio
|
||||
# array([1.0, 3.0, 0.33333333])
|
||||
|
||||
ar = detections.box_aspect_ratio
|
||||
detections[(ar < 2.0) & (ar > 0.5)].xyxy
|
||||
# array([[10., 10., 50., 50.]])
|
||||
```
|
||||
"""
|
||||
widths = self.xyxy[:, 2] - self.xyxy[:, 0]
|
||||
heights = self.xyxy[:, 3] - self.xyxy[:, 1]
|
||||
|
||||
aspect_ratios = np.full_like(widths, np.nan, dtype=np.float64)
|
||||
np.divide(widths, heights, out=aspect_ratios, where=heights != 0)
|
||||
return aspect_ratios
|
||||
|
||||
def with_nms(
|
||||
self,
|
||||
threshold: float = 0.5,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ from supervision.detection.core import Detections
|
|||
from supervision.detection.utils.boxes import move_boxes, move_oriented_boxes
|
||||
from supervision.detection.utils.iou_and_nms import OverlapFilter, OverlapMetric
|
||||
from supervision.detection.utils.masks import move_masks
|
||||
from supervision.utils.image import crop_image
|
||||
from supervision.utils.internal import (
|
||||
SupervisionWarnings,
|
||||
warn_deprecated,
|
||||
)
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.utils.image import crop_image, get_image_resolution_wh
|
||||
from supervision.utils.internal import SupervisionWarnings
|
||||
|
||||
|
||||
def move_detections(
|
||||
|
|
@ -53,111 +51,106 @@ def move_detections(
|
|||
|
||||
class InferenceSlicer:
|
||||
"""
|
||||
InferenceSlicer performs slicing-based inference for small target detection. This
|
||||
method, often referred to as
|
||||
[Slicing Adaptive Inference (SAHI)](https://ieeexplore.ieee.org/document/9897990),
|
||||
involves dividing a larger image into smaller slices, performing inference on each
|
||||
slice, and then merging the detections.
|
||||
Perform tiled inference on large images by slicing them into overlapping patches.
|
||||
|
||||
This class divides an input image into overlapping slices of configurable size
|
||||
and overlap, runs inference on each slice through a user-provided callback, and
|
||||
merges the resulting detections. The slicing process allows efficient processing
|
||||
of large images with limited resources while preserving detection accuracy via
|
||||
configurable overlap and post-processing of overlaps. Uses multi-threading for
|
||||
parallel slice inference.
|
||||
|
||||
Args:
|
||||
slice_wh (Tuple[int, int]): Dimensions of each slice measured in pixels. The
|
||||
tuple should be in the format `(width, height)`.
|
||||
overlap_ratio_wh (Optional[Tuple[float, float]]): [⚠️ Deprecated: please set
|
||||
to `None` and use `overlap_wh`] A tuple representing the
|
||||
desired overlap ratio for width and height between consecutive slices.
|
||||
Each value should be in the range [0, 1), where 0 means no overlap and
|
||||
a value close to 1 means high overlap.
|
||||
overlap_wh (Optional[Tuple[int, int]]): A tuple representing the desired
|
||||
overlap for width and height between consecutive slices measured in pixels.
|
||||
Each value should be greater than or equal to 0. Takes precedence over
|
||||
`overlap_ratio_wh`.
|
||||
overlap_filter (Union[OverlapFilter, str]): Strategy for
|
||||
filtering or merging overlapping detections in slices.
|
||||
iou_threshold (float): Intersection over Union (IoU) threshold
|
||||
used when filtering by overlap.
|
||||
overlap_metric (Union[OverlapMetric, str]): Metric used for matching detections
|
||||
in slices.
|
||||
callback (Callable): A function that performs inference on a given image
|
||||
slice and returns detections.
|
||||
thread_workers (int): Number of threads for parallel execution.
|
||||
callback (Callable[[ImageType], Detections]): Inference function that takes
|
||||
a sliced image and returns a `Detections` object.
|
||||
slice_wh (int or tuple[int, int]): Size of each slice `(width, height)`.
|
||||
If int, both width and height are set to this value.
|
||||
overlap_wh (int or tuple[int, int]): Overlap size `(width, height)` between
|
||||
slices. If int, both width and height are set to this value.
|
||||
overlap_filter (OverlapFilter or str): Strategy to merge overlapping
|
||||
detections (`NON_MAX_SUPPRESSION`, `NON_MAX_MERGE`, or `NONE`).
|
||||
iou_threshold (float): IOU threshold used in merging overlap filtering.
|
||||
overlap_metric (OverlapMetric or str): Metric to compute overlap
|
||||
(`IOU` or `IOS`).
|
||||
thread_workers (int): Number of threads for concurrent slice inference.
|
||||
|
||||
Note:
|
||||
The class ensures that slices do not exceed the boundaries of the original
|
||||
image. As a result, the final slices in the row and column dimensions might be
|
||||
smaller than the specified slice dimensions if the image's width or height is
|
||||
not a multiple of the slice's width or height minus the overlap.
|
||||
Raises:
|
||||
ValueError: If `slice_wh` or `overlap_wh` are invalid or inconsistent.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from rfdetr import RFDETRMedium
|
||||
|
||||
model = RFDETRMedium()
|
||||
|
||||
def callback(tile):
|
||||
return model.predict(tile)
|
||||
|
||||
slicer = sv.InferenceSlicer(callback, slice_wh=640, overlap_wh=100)
|
||||
|
||||
image = cv2.imread("example.png")
|
||||
detections = slicer(image)
|
||||
```
|
||||
|
||||
```python
|
||||
import supervision as sv
|
||||
from PIL import Image
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO("yolo11m.pt")
|
||||
|
||||
def callback(tile):
|
||||
results = model(tile)[0]
|
||||
return sv.Detections.from_ultralytics(results)
|
||||
|
||||
slicer = sv.InferenceSlicer(callback, slice_wh=640, overlap_wh=100)
|
||||
|
||||
image = Image.open("example.png")
|
||||
detections = slicer(image)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
callback: Callable[[np.ndarray], Detections],
|
||||
slice_wh: tuple[int, int] = (320, 320),
|
||||
overlap_ratio_wh: tuple[float, float] | None = (0.2, 0.2),
|
||||
overlap_wh: tuple[int, int] | None = None,
|
||||
callback: Callable[[ImageType], Detections],
|
||||
slice_wh: int | tuple[int, int] = 640,
|
||||
overlap_wh: int | tuple[int, int] = 100,
|
||||
overlap_filter: OverlapFilter | str = OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
iou_threshold: float = 0.5,
|
||||
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
|
||||
thread_workers: int = 1,
|
||||
):
|
||||
if overlap_ratio_wh is not None:
|
||||
warn_deprecated(
|
||||
"`overlap_ratio_wh` in `InferenceSlicer.__init__` is deprecated and "
|
||||
"will be removed in `supervision-0.27.0`. Please manually set it to "
|
||||
"`None` and use `overlap_wh` instead."
|
||||
)
|
||||
slice_wh_norm = self._normalize_slice_wh(slice_wh)
|
||||
overlap_wh_norm = self._normalize_overlap_wh(overlap_wh)
|
||||
|
||||
self._validate_overlap(overlap_ratio_wh, overlap_wh)
|
||||
self.overlap_ratio_wh = overlap_ratio_wh
|
||||
self.overlap_wh = overlap_wh
|
||||
self._validate_overlap(slice_wh=slice_wh_norm, overlap_wh=overlap_wh_norm)
|
||||
|
||||
self.slice_wh = slice_wh
|
||||
self.slice_wh = slice_wh_norm
|
||||
self.overlap_wh = overlap_wh_norm
|
||||
self.iou_threshold = iou_threshold
|
||||
self.overlap_metric = OverlapMetric.from_value(overlap_metric)
|
||||
self.overlap_filter = OverlapFilter.from_value(overlap_filter)
|
||||
self.callback = callback
|
||||
self.thread_workers = thread_workers
|
||||
|
||||
def __call__(self, image: np.ndarray) -> Detections:
|
||||
def __call__(self, image: ImageType) -> Detections:
|
||||
"""
|
||||
Performs slicing-based inference on the provided image using the specified
|
||||
callback.
|
||||
Perform tiled inference on the full image and return merged detections.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The input image on which inference needs to be
|
||||
performed. The image should be in the format
|
||||
`(height, width, channels)`.
|
||||
image (ImageType): The full image to run inference on.
|
||||
|
||||
Returns:
|
||||
Detections: A collection of detections for the entire image after merging
|
||||
results from all slices and applying NMS.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from ultralytics import YOLO
|
||||
|
||||
image = cv2.imread(SOURCE_IMAGE_PATH)
|
||||
model = YOLO(...)
|
||||
|
||||
def callback(image_slice: np.ndarray) -> sv.Detections:
|
||||
result = model(image_slice)[0]
|
||||
return sv.Detections.from_ultralytics(result)
|
||||
|
||||
slicer = sv.InferenceSlicer(
|
||||
callback=callback,
|
||||
overlap_filter_strategy=sv.OverlapFilter.NON_MAX_SUPPRESSION,
|
||||
)
|
||||
|
||||
detections = slicer(image)
|
||||
```
|
||||
Detections: Merged detections across all slices.
|
||||
"""
|
||||
detections_list = []
|
||||
resolution_wh = (image.shape[1], image.shape[0])
|
||||
detections_list: list[Detections] = []
|
||||
resolution_wh = get_image_resolution_wh(image)
|
||||
|
||||
offsets = self._generate_offset(
|
||||
resolution_wh=resolution_wh,
|
||||
slice_wh=self.slice_wh,
|
||||
overlap_ratio_wh=self.overlap_ratio_wh,
|
||||
overlap_wh=self.overlap_wh,
|
||||
)
|
||||
|
||||
|
|
@ -171,129 +164,178 @@ class InferenceSlicer:
|
|||
merged = Detections.merge(detections_list=detections_list)
|
||||
if self.overlap_filter == OverlapFilter.NONE:
|
||||
return merged
|
||||
elif self.overlap_filter == OverlapFilter.NON_MAX_SUPPRESSION:
|
||||
if self.overlap_filter == OverlapFilter.NON_MAX_SUPPRESSION:
|
||||
return merged.with_nms(
|
||||
threshold=self.iou_threshold, overlap_metric=self.overlap_metric
|
||||
threshold=self.iou_threshold,
|
||||
overlap_metric=self.overlap_metric,
|
||||
)
|
||||
elif self.overlap_filter == OverlapFilter.NON_MAX_MERGE:
|
||||
if self.overlap_filter == OverlapFilter.NON_MAX_MERGE:
|
||||
return merged.with_nmm(
|
||||
threshold=self.iou_threshold, overlap_metric=self.overlap_metric
|
||||
threshold=self.iou_threshold,
|
||||
overlap_metric=self.overlap_metric,
|
||||
)
|
||||
else:
|
||||
warnings.warn(
|
||||
f"Invalid overlap filter strategy: {self.overlap_filter}",
|
||||
category=SupervisionWarnings,
|
||||
)
|
||||
return merged
|
||||
|
||||
def _run_callback(self, image, offset) -> Detections:
|
||||
warnings.warn(
|
||||
f"Invalid overlap filter strategy: {self.overlap_filter}",
|
||||
category=SupervisionWarnings,
|
||||
)
|
||||
return merged
|
||||
|
||||
def _run_callback(self, image: ImageType, offset: np.ndarray) -> Detections:
|
||||
"""
|
||||
Run the provided callback on a slice of an image.
|
||||
Run detection callback on a sliced portion of the image and adjust coordinates.
|
||||
|
||||
Args:
|
||||
image (np.ndarray): The input image on which inference needs to run
|
||||
offset (np.ndarray): An array of shape `(4,)` containing coordinates
|
||||
for the slice.
|
||||
image (ImageType): The full image.
|
||||
offset (numpy.ndarray): Coordinates `(x_min, y_min, x_max, y_max)` defining
|
||||
the slice region.
|
||||
|
||||
Returns:
|
||||
Detections: A collection of detections for the slice.
|
||||
Detections: Detections adjusted to the full image coordinate system.
|
||||
"""
|
||||
image_slice = crop_image(image=image, xyxy=offset)
|
||||
image_slice: ImageType = crop_image(image=image, xyxy=offset)
|
||||
detections = self.callback(image_slice)
|
||||
resolution_wh = (image.shape[1], image.shape[0])
|
||||
resolution_wh = get_image_resolution_wh(image)
|
||||
|
||||
detections = move_detections(
|
||||
detections=detections, offset=offset[:2], resolution_wh=resolution_wh
|
||||
detections=detections,
|
||||
offset=offset[:2],
|
||||
resolution_wh=resolution_wh,
|
||||
)
|
||||
return detections
|
||||
|
||||
@staticmethod
|
||||
def _normalize_slice_wh(
|
||||
slice_wh: int | tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
if isinstance(slice_wh, int):
|
||||
if slice_wh <= 0:
|
||||
raise ValueError(
|
||||
f"`slice_wh` must be a positive integer. Received: {slice_wh}"
|
||||
)
|
||||
return slice_wh, slice_wh
|
||||
|
||||
if isinstance(slice_wh, tuple) and len(slice_wh) == 2:
|
||||
width, height = slice_wh
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(
|
||||
f"`slice_wh` values must be positive. Received: {slice_wh}"
|
||||
)
|
||||
return width, height
|
||||
|
||||
raise ValueError(
|
||||
"`slice_wh` must be an int or a tuple of two positive integers "
|
||||
"(slice_w, slice_h). "
|
||||
f"Received: {slice_wh}"
|
||||
)
|
||||
|
||||
return detections
|
||||
@staticmethod
|
||||
def _normalize_overlap_wh(
|
||||
overlap_wh: int | tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
if isinstance(overlap_wh, int):
|
||||
if overlap_wh < 0:
|
||||
raise ValueError(
|
||||
"`overlap_wh` must be a non negative integer. "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
return overlap_wh, overlap_wh
|
||||
|
||||
if isinstance(overlap_wh, tuple) and len(overlap_wh) == 2:
|
||||
overlap_w, overlap_h = overlap_wh
|
||||
if overlap_w < 0 or overlap_h < 0:
|
||||
raise ValueError(
|
||||
f"`overlap_wh` values must be non negative. Received: {overlap_wh}"
|
||||
)
|
||||
return overlap_w, overlap_h
|
||||
|
||||
raise ValueError(
|
||||
"`overlap_wh` must be an int or a tuple of two non negative integers "
|
||||
"(overlap_w, overlap_h). "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_offset(
|
||||
resolution_wh: tuple[int, int],
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_ratio_wh: tuple[float, float] | None,
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
overlap_wh: tuple[int, int],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Generate offset coordinates for slicing an image based on the given resolution,
|
||||
slice dimensions, and overlap ratios.
|
||||
Generate bounding boxes defining the coordinates of image slices with overlap.
|
||||
|
||||
Args:
|
||||
resolution_wh (Tuple[int, int]): A tuple representing the width and height
|
||||
of the image to be sliced.
|
||||
slice_wh (Tuple[int, int]): Dimensions of each slice measured in pixels. The
|
||||
tuple should be in the format `(width, height)`.
|
||||
overlap_ratio_wh (Optional[Tuple[float, float]]): A tuple representing the
|
||||
desired overlap ratio for width and height between consecutive slices.
|
||||
Each value should be in the range [0, 1), where 0 means no overlap and
|
||||
a value close to 1 means high overlap.
|
||||
overlap_wh (Optional[Tuple[int, int]]): A tuple representing the desired
|
||||
overlap for width and height between consecutive slices measured in
|
||||
pixels. Each value should be greater than or equal to 0.
|
||||
resolution_wh (tuple[int, int]): Image resolution `(width, height)`.
|
||||
slice_wh (tuple[int, int]): Size of each slice `(width, height)`.
|
||||
overlap_wh (tuple[int, int]): Overlap size between slices `(width, height)`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: An array of shape `(n, 4)` containing coordinates for each
|
||||
slice in the format `[xmin, ymin, xmax, ymax]`.
|
||||
|
||||
Note:
|
||||
The function ensures that slices do not exceed the boundaries of the
|
||||
original image. As a result, the final slices in the row and column
|
||||
dimensions might be smaller than the specified slice dimensions if the
|
||||
image's width or height is not a multiple of the slice's width or
|
||||
height minus the overlap.
|
||||
numpy.ndarray: Array of shape `(num_slices, 4)` with each row as
|
||||
`(x_min, y_min, x_max, y_max)` coordinates for a slice.
|
||||
"""
|
||||
slice_width, slice_height = slice_wh
|
||||
image_width, image_height = resolution_wh
|
||||
overlap_width = (
|
||||
overlap_wh[0]
|
||||
if overlap_wh is not None
|
||||
else int(overlap_ratio_wh[0] * slice_width)
|
||||
overlap_width, overlap_height = overlap_wh
|
||||
|
||||
stride_x = slice_width - overlap_width
|
||||
stride_y = slice_height - overlap_height
|
||||
|
||||
def _compute_axis_starts(
|
||||
image_size: int,
|
||||
slice_size: int,
|
||||
stride: int,
|
||||
) -> list[int]:
|
||||
if image_size <= slice_size:
|
||||
return [0]
|
||||
|
||||
if stride == slice_size:
|
||||
return np.arange(0, image_size, stride).tolist()
|
||||
|
||||
last_start = image_size - slice_size
|
||||
starts = np.arange(0, last_start, stride).tolist()
|
||||
if not starts or starts[-1] != last_start:
|
||||
starts.append(last_start)
|
||||
return starts
|
||||
|
||||
x_starts = _compute_axis_starts(
|
||||
image_size=image_width,
|
||||
slice_size=slice_width,
|
||||
stride=stride_x,
|
||||
)
|
||||
overlap_height = (
|
||||
overlap_wh[1]
|
||||
if overlap_wh is not None
|
||||
else int(overlap_ratio_wh[1] * slice_height)
|
||||
y_starts = _compute_axis_starts(
|
||||
image_size=image_height,
|
||||
slice_size=slice_height,
|
||||
stride=stride_y,
|
||||
)
|
||||
|
||||
width_stride = slice_width - overlap_width
|
||||
height_stride = slice_height - overlap_height
|
||||
x_min, y_min = np.meshgrid(x_starts, y_starts)
|
||||
x_max = np.clip(x_min + slice_width, 0, image_width)
|
||||
y_max = np.clip(y_min + slice_height, 0, image_height)
|
||||
|
||||
ws = np.arange(0, image_width, width_stride)
|
||||
hs = np.arange(0, image_height, height_stride)
|
||||
|
||||
xmin, ymin = np.meshgrid(ws, hs)
|
||||
xmax = np.clip(xmin + slice_width, 0, image_width)
|
||||
ymax = np.clip(ymin + slice_height, 0, image_height)
|
||||
|
||||
offsets = np.stack([xmin, ymin, xmax, ymax], axis=-1).reshape(-1, 4)
|
||||
offsets = np.stack(
|
||||
[x_min, y_min, x_max, y_max],
|
||||
axis=-1,
|
||||
).reshape(-1, 4)
|
||||
|
||||
return offsets
|
||||
|
||||
@staticmethod
|
||||
def _validate_overlap(
|
||||
overlap_ratio_wh: tuple[float, float] | None,
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_wh: tuple[int, int],
|
||||
) -> None:
|
||||
if overlap_ratio_wh is not None and overlap_wh is not None:
|
||||
overlap_w, overlap_h = overlap_wh
|
||||
slice_w, slice_h = slice_wh
|
||||
|
||||
if overlap_w < 0 or overlap_h < 0:
|
||||
raise ValueError(
|
||||
"Both `overlap_ratio_wh` and `overlap_wh` cannot be provided. "
|
||||
"Please provide only one of them."
|
||||
)
|
||||
if overlap_ratio_wh is None and overlap_wh is None:
|
||||
raise ValueError(
|
||||
"Either `overlap_ratio_wh` or `overlap_wh` must be provided. "
|
||||
"Please provide one of them."
|
||||
"Overlap values must be greater than or equal to 0. "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
|
||||
if overlap_ratio_wh is not None:
|
||||
if not (0 <= overlap_ratio_wh[0] < 1 and 0 <= overlap_ratio_wh[1] < 1):
|
||||
raise ValueError(
|
||||
"Overlap ratios must be in the range [0, 1). "
|
||||
f"Received: {overlap_ratio_wh}"
|
||||
)
|
||||
if overlap_wh is not None:
|
||||
if not (overlap_wh[0] >= 0 and overlap_wh[1] >= 0):
|
||||
raise ValueError(
|
||||
"Overlap values must be greater than or equal to 0. "
|
||||
f"Received: {overlap_wh}"
|
||||
)
|
||||
if overlap_w >= slice_w or overlap_h >= slice_h:
|
||||
raise ValueError(
|
||||
"`overlap_wh` must be smaller than `slice_wh` in both dimensions "
|
||||
f"to keep a positive stride. Received overlap_wh={overlap_wh}, "
|
||||
f"slice_wh={slice_wh}."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ def clip_boxes(xyxy: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarray:
|
|||
xyxy (np.ndarray): A numpy array of shape `(N, 4)` where each
|
||||
row corresponds to a bounding box in
|
||||
the format `(x_min, y_min, x_max, y_max)`.
|
||||
resolution_wh (Tuple[int, int]): A tuple of the form `(width, height)`
|
||||
representing the resolution of the frame.
|
||||
resolution_wh (Tuple[int, int]): A tuple of the form
|
||||
`(width, height)` representing the resolution of the frame.
|
||||
|
||||
Returns:
|
||||
np.ndarray: A numpy array of shape `(N, 4)` where each row
|
||||
|
|
@ -95,24 +95,27 @@ def pad_boxes(xyxy: np.ndarray, px: int, py: int | None = None) -> np.ndarray:
|
|||
|
||||
|
||||
def denormalize_boxes(
|
||||
normalized_xyxy: np.ndarray,
|
||||
xyxy: np.ndarray,
|
||||
resolution_wh: tuple[int, int],
|
||||
normalization_factor: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Converts normalized bounding box coordinates to absolute pixel values.
|
||||
Convert normalized bounding box coordinates to absolute pixel coordinates.
|
||||
|
||||
Multiplies each bounding box coordinate by image size and divides by
|
||||
`normalization_factor`, mapping values from normalized `[0, normalization_factor]`
|
||||
to absolute pixel values for a given resolution.
|
||||
|
||||
Args:
|
||||
normalized_xyxy (np.ndarray): A numpy array of shape `(N, 4)` where each row
|
||||
contains normalized coordinates in the format `(x_min, y_min, x_max, y_max)`,
|
||||
with values between 0 and `normalization_factor`.
|
||||
resolution_wh (Tuple[int, int]): A tuple `(width, height)` representing the
|
||||
target image resolution.
|
||||
normalization_factor (float, optional): The normalization range of the input
|
||||
coordinates. Defaults to 1.0.
|
||||
xyxy (`numpy.ndarray`): Normalized bounding boxes of shape `(N, 4)`,
|
||||
where each row is `(x_min, y_min, x_max, y_max)`, values in
|
||||
`[0, normalization_factor]`.
|
||||
resolution_wh (`tuple[int, int]`): Target image resolution as `(width, height)`.
|
||||
normalization_factor (`float`): Maximum value of input coordinate range.
|
||||
Defaults to `1.0`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: An array of shape `(N, 4)` with absolute coordinates in
|
||||
(`numpy.ndarray`): Array of shape `(N, 4)` with absolute coordinates in
|
||||
`(x_min, y_min, x_max, y_max)` format.
|
||||
|
||||
Examples:
|
||||
|
|
@ -120,35 +123,39 @@ def denormalize_boxes(
|
|||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
# Default normalization (0-1)
|
||||
normalized_xyxy = np.array([
|
||||
xyxy = np.array([
|
||||
[0.1, 0.2, 0.5, 0.6],
|
||||
[0.3, 0.4, 0.7, 0.8]
|
||||
[0.3, 0.4, 0.7, 0.8],
|
||||
[0.2, 0.1, 0.6, 0.5]
|
||||
])
|
||||
resolution_wh = (100, 200)
|
||||
sv.denormalize_boxes(normalized_xyxy, resolution_wh)
|
||||
# array([
|
||||
# [ 10., 40., 50., 120.],
|
||||
# [ 30., 80., 70., 160.]
|
||||
# ])
|
||||
|
||||
# Custom normalization (0-100)
|
||||
normalized_xyxy = np.array([
|
||||
[10., 20., 50., 60.],
|
||||
[30., 40., 70., 80.]
|
||||
])
|
||||
sv.denormalize_boxes(normalized_xyxy, resolution_wh, normalization_factor=100.0)
|
||||
sv.denormalize_boxes(xyxy, (1280, 720))
|
||||
# array([
|
||||
# [ 10., 40., 50., 120.],
|
||||
# [ 30., 80., 70., 160.]
|
||||
# [128., 144., 640., 432.],
|
||||
# [384., 288., 896., 576.],
|
||||
# [256., 72., 768., 360.]
|
||||
# ])
|
||||
```
|
||||
""" # noqa E501 // docs
|
||||
width, height = resolution_wh
|
||||
result = normalized_xyxy.copy()
|
||||
|
||||
result[[0, 2]] = (result[[0, 2]] * width) / normalization_factor
|
||||
result[[1, 3]] = (result[[1, 3]] * height) / normalization_factor
|
||||
```
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
xyxy = np.array([
|
||||
[256., 128., 768., 640.]
|
||||
])
|
||||
|
||||
sv.denormalize_boxes(xyxy, (1280, 720), normalization_factor=1024.0)
|
||||
# array([
|
||||
# [320., 90., 960., 450.]
|
||||
# ])
|
||||
```
|
||||
"""
|
||||
width, height = resolution_wh
|
||||
result = xyxy.copy()
|
||||
|
||||
result[:, [0, 2]] = (result[:, [0, 2]] * width) / normalization_factor
|
||||
result[:, [1, 3]] = (result[:, [1, 3]] * height) / normalization_factor
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -229,6 +229,70 @@ def mask_to_xyxy(masks: np.ndarray) -> np.ndarray:
|
|||
return xyxy
|
||||
|
||||
|
||||
def xyxy_to_mask(boxes: np.ndarray, resolution_wh: tuple[int, int]) -> np.ndarray:
|
||||
"""
|
||||
Converts a 2D `np.ndarray` of bounding boxes into a 3D `np.ndarray` of bool masks.
|
||||
|
||||
Parameters:
|
||||
boxes (np.ndarray): A 2D `np.ndarray` of shape `(N, 4)`
|
||||
containing bounding boxes `(x_min, y_min, x_max, y_max)`
|
||||
resolution_wh (Tuple[int, int]): A tuple `(width, height)` specifying
|
||||
the resolution of the output masks
|
||||
|
||||
Returns:
|
||||
np.ndarray: A 3D `np.ndarray` of shape `(N, height, width)`
|
||||
containing 2D bool masks for each bounding box
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
boxes = np.array([[0, 0, 2, 2]])
|
||||
|
||||
sv.xyxy_to_mask(boxes, (5, 5))
|
||||
# array([
|
||||
# [[ True, True, True, False, False],
|
||||
# [ True, True, True, False, False],
|
||||
# [ True, True, True, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False]]
|
||||
# ])
|
||||
|
||||
boxes = np.array([[0, 0, 1, 1], [3, 3, 4, 4]])
|
||||
|
||||
sv.xyxy_to_mask(boxes, (5, 5))
|
||||
# array([
|
||||
# [[ True, True, False, False, False],
|
||||
# [ True, True, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False]],
|
||||
#
|
||||
# [[False, False, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, False, False],
|
||||
# [False, False, False, True, True],
|
||||
# [False, False, False, True, True]]
|
||||
# ])
|
||||
```
|
||||
"""
|
||||
width, height = resolution_wh
|
||||
n = boxes.shape[0]
|
||||
masks = np.zeros((n, height, width), dtype=bool)
|
||||
|
||||
for i, (x_min, y_min, x_max, y_max) in enumerate(boxes):
|
||||
x_min = max(0, int(x_min))
|
||||
y_min = max(0, int(y_min))
|
||||
x_max = min(width - 1, int(x_max))
|
||||
y_max = min(height - 1, int(y_max))
|
||||
|
||||
if x_max >= x_min and y_max >= y_min:
|
||||
masks[i, y_min : y_max + 1, x_min : x_max + 1] = True
|
||||
|
||||
return masks
|
||||
|
||||
|
||||
def mask_to_polygons(mask: np.ndarray) -> list[np.ndarray]:
|
||||
"""
|
||||
Converts a binary mask to a list of polygons.
|
||||
|
|
|
|||
|
|
@ -271,7 +271,6 @@ def merge_metadata(metadata_list: list[dict[str, Any]]) -> dict[str, Any]:
|
|||
"{type(value)}, {type(other_value)}."
|
||||
)
|
||||
else:
|
||||
print("hm")
|
||||
if merged_metadata[key] != value:
|
||||
raise ValueError(f"Conflicting metadata for key: '{key}'.")
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ class OverlapMetric(Enum):
|
|||
IOS = "IOS"
|
||||
|
||||
@classmethod
|
||||
def list(cls):
|
||||
def list(cls) -> list[str]:
|
||||
return list(map(lambda c: c.value, cls))
|
||||
|
||||
@classmethod
|
||||
|
|
@ -72,7 +72,7 @@ class OverlapMetric(Enum):
|
|||
if isinstance(value, cls):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
value = value.lower()
|
||||
value = value.upper()
|
||||
try:
|
||||
return cls(value)
|
||||
except ValueError:
|
||||
|
|
@ -86,91 +86,107 @@ class OverlapMetric(Enum):
|
|||
def box_iou(
|
||||
box_true: list[float] | np.ndarray,
|
||||
box_detection: list[float] | np.ndarray,
|
||||
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
|
||||
) -> float:
|
||||
r"""
|
||||
Compute the Intersection over Union (IoU) between two bounding boxes.
|
||||
"""
|
||||
Compute overlap metric between two bounding boxes.
|
||||
|
||||
\[
|
||||
\text{IoU} = \frac{|\text{box}_{\text{true}} \cap \text{box}_{\text{detection}}|}{|\text{box}_{\text{true}} \cup \text{box}_{\text{detection}}|}
|
||||
\]
|
||||
|
||||
Note:
|
||||
Use `box_iou` when computing IoU between two individual boxes.
|
||||
For comparing multiple boxes (arrays of boxes), use `box_iou_batch` for better
|
||||
performance.
|
||||
Supports standard IOU (intersection-over-union) and IOS
|
||||
(intersection-over-smaller-area) metrics. Returns the overlap value in range
|
||||
`[0, 1]`.
|
||||
|
||||
Args:
|
||||
box_true (Union[List[float], np.ndarray]): A single bounding box represented as
|
||||
[x_min, y_min, x_max, y_max].
|
||||
box_detection (Union[List[float], np.ndarray]):
|
||||
A single bounding box represented as [x_min, y_min, x_max, y_max].
|
||||
box_true (`list[float]` or `numpy.array`): Ground truth box in format
|
||||
`(x_min, y_min, x_max, y_max)`.
|
||||
box_detection (`list[float]` or `numpy.array`): Detected box in format
|
||||
`(x_min, y_min, x_max, y_max)`.
|
||||
overlap_metric (`OverlapMetric` or `str`): Overlap type.
|
||||
Use `OverlapMetric.IOU` for IOU or
|
||||
`OverlapMetric.IOS` for IOS. Defaults to `OverlapMetric.IOU`.
|
||||
|
||||
Returns:
|
||||
IoU (float): IoU score between the two boxes. Ranges from 0.0 (no overlap)
|
||||
to 1.0 (perfect overlap).
|
||||
(`float`): Overlap value between boxes in `[0, 1]`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `overlap_metric` is not IOU or IOS.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
```
|
||||
import supervision as sv
|
||||
|
||||
box_true = np.array([100, 100, 200, 200])
|
||||
box_detection = np.array([150, 150, 250, 250])
|
||||
box_true = [100, 100, 200, 200]
|
||||
box_detection = [150, 150, 250, 250]
|
||||
|
||||
sv.box_iou(box_true=box_true, box_detection=box_detection)
|
||||
# 0.14285814285714285
|
||||
sv.box_iou(box_true, box_detection, overlap_metric=sv.OverlapMetric.IOU)
|
||||
# 0.14285714285714285
|
||||
|
||||
sv.box_iou(box_true, box_detection, overlap_metric=sv.OverlapMetric.IOS)
|
||||
# 0.25
|
||||
```
|
||||
""" # noqa: E501
|
||||
box_true = np.array(box_true)
|
||||
box_detection = np.array(box_detection)
|
||||
"""
|
||||
overlap_metric = OverlapMetric.from_value(overlap_metric)
|
||||
x_min_true, y_min_true, x_max_true, y_max_true = np.array(box_true)
|
||||
x_min_det, y_min_det, x_max_det, y_max_det = np.array(box_detection)
|
||||
|
||||
inter_x1 = max(box_true[0], box_detection[0])
|
||||
inter_y1 = max(box_true[1], box_detection[1])
|
||||
inter_x2 = min(box_true[2], box_detection[2])
|
||||
inter_y2 = min(box_true[3], box_detection[3])
|
||||
x_min_inter = max(x_min_true, x_min_det)
|
||||
y_min_inter = max(y_min_true, y_min_det)
|
||||
x_max_inter = min(x_max_true, x_max_det)
|
||||
y_max_inter = min(y_max_true, y_max_det)
|
||||
|
||||
inter_w = max(0, inter_x2 - inter_x1)
|
||||
inter_h = max(0, inter_y2 - inter_y1)
|
||||
inter_w = max(0.0, x_max_inter - x_min_inter)
|
||||
inter_h = max(0.0, y_max_inter - y_min_inter)
|
||||
|
||||
inter_area = inter_w * inter_h
|
||||
area_inter = inter_w * inter_h
|
||||
|
||||
area_true = (box_true[2] - box_true[0]) * (box_true[3] - box_true[1])
|
||||
area_detection = (box_detection[2] - box_detection[0]) * (
|
||||
box_detection[3] - box_detection[1]
|
||||
)
|
||||
area_true = (x_max_true - x_min_true) * (y_max_true - y_min_true)
|
||||
area_det = (x_max_det - x_min_det) * (y_max_det - y_min_det)
|
||||
|
||||
union_area = area_true + area_detection - inter_area
|
||||
if overlap_metric == OverlapMetric.IOU:
|
||||
area_norm = area_true + area_det - area_inter
|
||||
elif overlap_metric == OverlapMetric.IOS:
|
||||
area_norm = min(area_true, area_det)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"overlap_metric {overlap_metric} is not supported, "
|
||||
"only 'IOU' and 'IOS' are supported"
|
||||
)
|
||||
|
||||
return inter_area / union_area + 1e-6
|
||||
if area_norm <= 0.0:
|
||||
return 0.0
|
||||
|
||||
return float(area_inter / area_norm)
|
||||
|
||||
|
||||
def box_iou_batch(
|
||||
boxes_true: np.ndarray,
|
||||
boxes_detection: np.ndarray,
|
||||
overlap_metric: OverlapMetric = OverlapMetric.IOU,
|
||||
overlap_metric: OverlapMetric | str = OverlapMetric.IOU,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Compute Intersection over Union (IoU) of two sets of bounding boxes -
|
||||
`boxes_true` and `boxes_detection`. Both sets
|
||||
of boxes are expected to be in `(x_min, y_min, x_max, y_max)` format.
|
||||
Compute pairwise overlap scores between batches of bounding boxes.
|
||||
|
||||
Note:
|
||||
Use `box_iou` when computing IoU between two individual boxes.
|
||||
For comparing multiple boxes (arrays of boxes), use `box_iou_batch` for better
|
||||
performance.
|
||||
Supports standard IOU (intersection-over-union) and IOS
|
||||
(intersection-over-smaller-area) metrics for all `boxes_true` and
|
||||
`boxes_detection` pairs. Returns a matrix of overlap values in range
|
||||
`[0, 1]`, matching each box from the first batch to each from the second.
|
||||
|
||||
Args:
|
||||
boxes_true (np.ndarray): 2D `np.ndarray` representing ground-truth boxes.
|
||||
`shape = (N, 4)` where `N` is number of true objects.
|
||||
boxes_detection (np.ndarray): 2D `np.ndarray` representing detection boxes.
|
||||
`shape = (M, 4)` where `M` is number of detected objects.
|
||||
overlap_metric (OverlapMetric): Metric used to compute the degree of overlap
|
||||
between pairs of boxes (e.g., IoU, IoS).
|
||||
boxes_true (`numpy.array`): Array of reference boxes in
|
||||
shape `(N, 4)` as `(x_min, y_min, x_max, y_max)`.
|
||||
boxes_detection (`numpy.array`): Array of detected boxes in
|
||||
shape `(M, 4)` as `(x_min, y_min, x_max, y_max)`.
|
||||
overlap_metric (`OverlapMetric` or `str`): Overlap type.
|
||||
Use `OverlapMetric.IOU` for intersection-over-union,
|
||||
`OverlapMetric.IOS` for intersection-over-smaller-area.
|
||||
Defaults to `OverlapMetric.IOU`.
|
||||
|
||||
Returns:
|
||||
np.ndarray: 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.
|
||||
(`numpy.array`): Overlap matrix of shape `(N, M)`, where entry
|
||||
`[i, j]` is the overlap between `boxes_true[i]` and
|
||||
`boxes_detection[j]`.
|
||||
|
||||
Raises:
|
||||
ValueError: If `overlap_metric` is not IOU or IOS.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
|
|
@ -186,49 +202,57 @@ def box_iou_batch(
|
|||
[320, 320, 420, 420]
|
||||
])
|
||||
|
||||
sv.box_iou_batch(boxes_true=boxes_true, boxes_detection=boxes_detection)
|
||||
# array([
|
||||
# [0.14285714, 0. ],
|
||||
# [0. , 0.47058824]
|
||||
# ])
|
||||
sv.box_iou_batch(boxes_true, boxes_detection, overlap_metric=OverlapMetric.IOU)
|
||||
# array([[0.14285715, 0. ],
|
||||
# [0. , 0.47058824]])
|
||||
|
||||
sv.box_iou_batch(boxes_true, boxes_detection, overlap_metric=OverlapMetric.IOS)
|
||||
# array([[0.25, 0. ],
|
||||
# [0. , 0.64]])
|
||||
```
|
||||
"""
|
||||
overlap_metric = OverlapMetric.from_value(overlap_metric)
|
||||
x_min_true, y_min_true, x_max_true, y_max_true = boxes_true.T
|
||||
x_min_det, y_min_det, x_max_det, y_max_det = boxes_detection.T
|
||||
count_true, count_det = boxes_true.shape[0], boxes_detection.shape[0]
|
||||
|
||||
def box_area(box):
|
||||
return (box[2] - box[0]) * (box[3] - box[1])
|
||||
if count_true == 0 or count_det == 0:
|
||||
return np.empty((count_true, count_det), dtype=np.float32)
|
||||
|
||||
area_true = box_area(boxes_true.T)
|
||||
area_detection = box_area(boxes_detection.T)
|
||||
x_min_inter = np.empty((count_true, count_det), dtype=np.float32)
|
||||
x_max_inter = np.empty_like(x_min_inter)
|
||||
y_min_inter = np.empty_like(x_min_inter)
|
||||
y_max_inter = np.empty_like(x_min_inter)
|
||||
|
||||
top_left = np.maximum(boxes_true[:, None, :2], boxes_detection[:, :2])
|
||||
bottom_right = np.minimum(boxes_true[:, None, 2:], boxes_detection[:, 2:])
|
||||
np.maximum(x_min_true[:, None], x_min_det[None, :], out=x_min_inter)
|
||||
np.minimum(x_max_true[:, None], x_max_det[None, :], out=x_max_inter)
|
||||
np.maximum(y_min_true[:, None], y_min_det[None, :], out=y_min_inter)
|
||||
np.minimum(y_max_true[:, None], y_max_det[None, :], out=y_max_inter)
|
||||
|
||||
area_inter = np.prod(np.clip(bottom_right - top_left, a_min=0, a_max=None), 2)
|
||||
# we reuse x_max_inter and y_max_inter to store inter_w and inter_h
|
||||
np.subtract(x_max_inter, x_min_inter, out=x_max_inter) # inter_w
|
||||
np.subtract(y_max_inter, y_min_inter, out=y_max_inter) # inter_h
|
||||
np.clip(x_max_inter, 0.0, None, out=x_max_inter)
|
||||
np.clip(y_max_inter, 0.0, None, out=y_max_inter)
|
||||
|
||||
area_inter = x_max_inter * y_max_inter # inter_w * inter_h
|
||||
|
||||
area_true = (x_max_true - x_min_true) * (y_max_true - y_min_true)
|
||||
area_det = (x_max_det - x_min_det) * (y_max_det - y_min_det)
|
||||
|
||||
if overlap_metric == OverlapMetric.IOU:
|
||||
union_area = area_true[:, None] + area_detection - area_inter
|
||||
ious = np.divide(
|
||||
area_inter,
|
||||
union_area,
|
||||
out=np.zeros_like(area_inter, dtype=float),
|
||||
where=union_area != 0,
|
||||
)
|
||||
area_norm = area_true[:, None] + area_det[None, :] - area_inter
|
||||
elif overlap_metric == OverlapMetric.IOS:
|
||||
small_area = np.minimum(area_true[:, None], area_detection)
|
||||
ious = np.divide(
|
||||
area_inter,
|
||||
small_area,
|
||||
out=np.zeros_like(area_inter, dtype=float),
|
||||
where=small_area != 0,
|
||||
)
|
||||
area_norm = np.minimum(area_true[:, None], area_det[None, :])
|
||||
else:
|
||||
raise ValueError(
|
||||
f"overlap_metric {overlap_metric} is not supported, "
|
||||
"only 'IOU' and 'IOS' are supported"
|
||||
)
|
||||
|
||||
ious = np.nan_to_num(ious)
|
||||
return ious
|
||||
out = np.zeros_like(area_inter, dtype=np.float32)
|
||||
np.divide(area_inter, area_norm, out=out, where=area_norm > 0)
|
||||
return out
|
||||
|
||||
|
||||
def _jaccard(box_a: list[float], box_b: list[float], is_crowd: bool) -> float:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
|
@ -260,3 +262,139 @@ def resize_masks(masks: np.ndarray, max_dimension: int = 640) -> np.ndarray:
|
|||
resized_masks = masks[:, yv, xv]
|
||||
|
||||
return resized_masks.reshape(masks.shape[0], new_height, new_width)
|
||||
|
||||
|
||||
def filter_segments_by_distance(
|
||||
mask: npt.NDArray[np.bool_],
|
||||
absolute_distance: float | None = 100.0,
|
||||
relative_distance: float | None = None,
|
||||
connectivity: int = 8,
|
||||
mode: Literal["edge", "centroid"] = "edge",
|
||||
) -> npt.NDArray[np.bool_]:
|
||||
"""
|
||||
Keep the largest connected component and any other components within a distance
|
||||
threshold.
|
||||
|
||||
Distance can be absolute in pixels or relative to the image diagonal.
|
||||
|
||||
Args:
|
||||
mask: Boolean mask HxW.
|
||||
absolute_distance: Max allowed distance in pixels to the main component.
|
||||
Ignored if `relative_distance` is provided.
|
||||
relative_distance: Fraction of the diagonal. If set, threshold = fraction * sqrt(H^2 + W^2).
|
||||
connectivity: Defines which neighboring pixels are considered connected.
|
||||
- 4-connectedness: Only orthogonal neighbors.
|
||||
```
|
||||
[ ][X][ ]
|
||||
[X][O][X]
|
||||
[ ][X][ ]
|
||||
```
|
||||
- 8-connectedness: Includes diagonal neighbors.
|
||||
```
|
||||
[X][X][X]
|
||||
[X][O][X]
|
||||
[X][X][X]
|
||||
```
|
||||
Default is 8.
|
||||
mode: Defines how distance between components is measured.
|
||||
- "edge": Uses distance between nearest edges (via distance transform).
|
||||
- "centroid": Uses distance between component centroids.
|
||||
|
||||
Returns:
|
||||
Boolean mask after filtering.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import numpy as np
|
||||
import supervision as sv
|
||||
|
||||
mask = np.array([
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
], dtype=bool)
|
||||
|
||||
sv.filter_segments_by_distance(
|
||||
mask,
|
||||
absolute_distance=2,
|
||||
mode="edge",
|
||||
connectivity=8
|
||||
).astype(int)
|
||||
|
||||
# np.array([
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
# ], dtype=bool)
|
||||
|
||||
# The nearby 2×2 block at columns 6–7 is kept because its edge distance
|
||||
# is within 2 pixels. The distant block at columns 9-10 is removed.
|
||||
```
|
||||
""" # noqa E501 // docs
|
||||
if mask.dtype != bool:
|
||||
raise TypeError("mask must be boolean")
|
||||
|
||||
height, width = mask.shape
|
||||
if not np.any(mask):
|
||||
return mask.copy()
|
||||
|
||||
image = mask.astype(np.uint8)
|
||||
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
||||
image, connectivity=connectivity
|
||||
)
|
||||
|
||||
if num_labels <= 1:
|
||||
return mask.copy()
|
||||
|
||||
areas = stats[1:, cv2.CC_STAT_AREA]
|
||||
main_label = 1 + int(np.argmax(areas))
|
||||
|
||||
if relative_distance is not None:
|
||||
diagonal = float(np.hypot(height, width))
|
||||
threshold = float(relative_distance) * diagonal
|
||||
else:
|
||||
threshold = float(absolute_distance)
|
||||
|
||||
keep_labels = np.zeros(num_labels, dtype=bool)
|
||||
keep_labels[main_label] = True
|
||||
|
||||
if mode == "centroid":
|
||||
differences = centroids[1:] - centroids[main_label]
|
||||
distances = np.sqrt(np.sum(differences**2, axis=1))
|
||||
nearby = 1 + np.where(distances <= threshold)[0]
|
||||
keep_labels[nearby] = True
|
||||
elif mode == "edge":
|
||||
main_mask = (labels == main_label).astype(np.uint8)
|
||||
inverse = 1 - main_mask
|
||||
distance_transform = cv2.distanceTransform(inverse, cv2.DIST_L2, 3)
|
||||
for label in range(1, num_labels):
|
||||
if label == main_label:
|
||||
continue
|
||||
component = labels == label
|
||||
if not np.any(component):
|
||||
continue
|
||||
min_distance = float(distance_transform[component].min())
|
||||
if min_distance <= threshold:
|
||||
keep_labels[label] = True
|
||||
else:
|
||||
raise ValueError("mode must be 'edge' or 'centroid'")
|
||||
|
||||
return keep_labels[labels]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> int:
|
||||
"""
|
||||
Calculates the minimum number of single-character edits required
|
||||
to transform one string into another. Allowed operations are insertion,
|
||||
deletion, and substitution.
|
||||
|
||||
Args:
|
||||
string_1 (str): The source string to be transformed.
|
||||
string_2 (str): The target string to transform into.
|
||||
case_sensitive (bool, optional): Whether comparison should be case-sensitive.
|
||||
Defaults to True.
|
||||
|
||||
Returns:
|
||||
int: The minimum number of edits required to convert `string_1`
|
||||
into `string_2`.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
sv.edit_distance("hello", "hello")
|
||||
# 0
|
||||
|
||||
sv.edit_distance("Test", "test", case_sensitive=True)
|
||||
# 1
|
||||
|
||||
sv.edit_distance("abc", "xyz")
|
||||
# 3
|
||||
|
||||
sv.edit_distance("hello", "")
|
||||
# 5
|
||||
|
||||
sv.edit_distance("", "")
|
||||
# 0
|
||||
|
||||
sv.edit_distance("hello world", "helloworld")
|
||||
# 1
|
||||
```
|
||||
"""
|
||||
if not case_sensitive:
|
||||
string_1 = string_1.lower()
|
||||
string_2 = string_2.lower()
|
||||
|
||||
if len(string_1) < len(string_2):
|
||||
string_1, string_2 = string_2, string_1
|
||||
|
||||
prev_row = list(range(len(string_2) + 1))
|
||||
curr_row = [0] * (len(string_2) + 1)
|
||||
|
||||
for i in range(1, len(string_1) + 1):
|
||||
curr_row[0] = i
|
||||
for j in range(1, len(string_2) + 1):
|
||||
if string_1[i - 1] == string_2[j - 1]:
|
||||
substitution_cost = 0
|
||||
else:
|
||||
substitution_cost = 1
|
||||
curr_row[j] = min(
|
||||
prev_row[j] + 1,
|
||||
curr_row[j - 1] + 1,
|
||||
prev_row[j - 1] + substitution_cost,
|
||||
)
|
||||
prev_row, curr_row = curr_row, prev_row
|
||||
|
||||
return prev_row[len(string_2)]
|
||||
|
||||
|
||||
def fuzzy_match_index(
|
||||
candidates: list[str],
|
||||
query: str,
|
||||
threshold: int,
|
||||
case_sensitive: bool = True,
|
||||
) -> int | None:
|
||||
"""
|
||||
Searches for the first string in `candidates` whose edit distance
|
||||
to `query` is less than or equal to `threshold`.
|
||||
|
||||
Args:
|
||||
candidates (list[str]): List of strings to search.
|
||||
query (str): String to compare against the candidates.
|
||||
threshold (int): Maximum allowed edit distance for a match.
|
||||
case_sensitive (bool, optional): Whether matching should be case-sensitive.
|
||||
|
||||
Returns:
|
||||
Optional[int]: Index of the first matching string in candidates,
|
||||
or None if no match is found.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
fuzzy_match_index(["cat", "dog", "rat"], "dat", threshold=1)
|
||||
# 0
|
||||
|
||||
fuzzy_match_index(["alpha", "beta", "gamma"], "bata", threshold=1)
|
||||
# 1
|
||||
|
||||
fuzzy_match_index(["one", "two", "three"], "ten", threshold=2)
|
||||
# None
|
||||
```
|
||||
"""
|
||||
for idx, candidate in enumerate(candidates):
|
||||
if edit_distance(candidate, query, case_sensitive=case_sensitive) <= threshold:
|
||||
return idx
|
||||
return None
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
|
|
@ -27,7 +28,8 @@ class LMM(Enum):
|
|||
Attributes:
|
||||
PALIGEMMA: Google's PaliGemma vision-language model.
|
||||
FLORENCE_2: Microsoft's Florence-2 vision-language model.
|
||||
QWEN_2_5_VL: Qwen2.5-VL open vision-language model from Alibaba.
|
||||
QWEN_2_5_VL: Qwen2.5-VL open vision-language model from Alibaba.\
|
||||
QWEN_3_VL: Qwen3-VL open vision-language model from Alibaba.
|
||||
GOOGLE_GEMINI_2_0: Google Gemini 2.0 vision-language model.
|
||||
GOOGLE_GEMINI_2_5: Google Gemini 2.5 vision-language model.
|
||||
MOONDREAM: The Moondream vision-language model.
|
||||
|
|
@ -36,6 +38,8 @@ class LMM(Enum):
|
|||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
QWEN_2_5_VL = "qwen_2_5_vl"
|
||||
QWEN_3_VL = "qwen_3_vl"
|
||||
DEEPSEEK_VL_2 = "deepseek_vl_2"
|
||||
GOOGLE_GEMINI_2_0 = "gemini_2_0"
|
||||
GOOGLE_GEMINI_2_5 = "gemini_2_5"
|
||||
MOONDREAM = "moondream"
|
||||
|
|
@ -68,6 +72,7 @@ class VLM(Enum):
|
|||
PALIGEMMA: Google's PaliGemma vision-language model.
|
||||
FLORENCE_2: Microsoft's Florence-2 vision-language model.
|
||||
QWEN_2_5_VL: Qwen2.5-VL open vision-language model from Alibaba.
|
||||
QWEN_3_VL: Qwen3-VL open vision-language model from Alibaba.
|
||||
GOOGLE_GEMINI_2_0: Google Gemini 2.0 vision-language model.
|
||||
GOOGLE_GEMINI_2_5: Google Gemini 2.5 vision-language model.
|
||||
MOONDREAM: The Moondream vision-language model.
|
||||
|
|
@ -76,6 +81,8 @@ class VLM(Enum):
|
|||
PALIGEMMA = "paligemma"
|
||||
FLORENCE_2 = "florence_2"
|
||||
QWEN_2_5_VL = "qwen_2_5_vl"
|
||||
QWEN_3_VL = "qwen_3_vl"
|
||||
DEEPSEEK_VL_2 = "deepseek_vl_2"
|
||||
GOOGLE_GEMINI_2_0 = "gemini_2_0"
|
||||
GOOGLE_GEMINI_2_5 = "gemini_2_5"
|
||||
MOONDREAM = "moondream"
|
||||
|
|
@ -104,6 +111,8 @@ RESULT_TYPES: dict[VLM, type] = {
|
|||
VLM.PALIGEMMA: str,
|
||||
VLM.FLORENCE_2: dict,
|
||||
VLM.QWEN_2_5_VL: str,
|
||||
VLM.QWEN_3_VL: str,
|
||||
VLM.DEEPSEEK_VL_2: str,
|
||||
VLM.GOOGLE_GEMINI_2_0: str,
|
||||
VLM.GOOGLE_GEMINI_2_5: str,
|
||||
VLM.MOONDREAM: dict,
|
||||
|
|
@ -113,6 +122,8 @@ REQUIRED_ARGUMENTS: dict[VLM, list[str]] = {
|
|||
VLM.PALIGEMMA: ["resolution_wh"],
|
||||
VLM.FLORENCE_2: ["resolution_wh"],
|
||||
VLM.QWEN_2_5_VL: ["input_wh", "resolution_wh"],
|
||||
VLM.QWEN_3_VL: ["resolution_wh"],
|
||||
VLM.DEEPSEEK_VL_2: ["resolution_wh"],
|
||||
VLM.GOOGLE_GEMINI_2_0: ["resolution_wh"],
|
||||
VLM.GOOGLE_GEMINI_2_5: ["resolution_wh"],
|
||||
VLM.MOONDREAM: ["resolution_wh"],
|
||||
|
|
@ -122,6 +133,8 @@ ALLOWED_ARGUMENTS: dict[VLM, list[str]] = {
|
|||
VLM.PALIGEMMA: ["resolution_wh", "classes"],
|
||||
VLM.FLORENCE_2: ["resolution_wh"],
|
||||
VLM.QWEN_2_5_VL: ["input_wh", "resolution_wh", "classes"],
|
||||
VLM.QWEN_3_VL: ["resolution_wh", "classes"],
|
||||
VLM.DEEPSEEK_VL_2: ["resolution_wh", "classes"],
|
||||
VLM.GOOGLE_GEMINI_2_0: ["resolution_wh", "classes"],
|
||||
VLM.GOOGLE_GEMINI_2_5: ["resolution_wh", "classes"],
|
||||
VLM.MOONDREAM: ["resolution_wh"],
|
||||
|
|
@ -230,6 +243,51 @@ def from_paligemma(
|
|||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def recover_truncated_qwen_2_5_vl_response(text: str) -> Any | None:
|
||||
"""
|
||||
Attempt to recover and parse a truncated or malformed JSON snippet from Qwen-2.5-VL
|
||||
output.
|
||||
|
||||
This utility extracts a JSON-like portion from a string that may be truncated or
|
||||
malformed, cleans trailing commas, and attempts to parse it into a Python object.
|
||||
|
||||
Args:
|
||||
text (str): Raw text containing the JSON snippet possibly truncated or
|
||||
incomplete.
|
||||
|
||||
Returns:
|
||||
Parsed Python object (usually list) if recovery and parsing succeed;
|
||||
otherwise `None`.
|
||||
"""
|
||||
try:
|
||||
first_bracket = text.find("[")
|
||||
if first_bracket == -1:
|
||||
return None
|
||||
snippet = text[first_bracket:]
|
||||
|
||||
last_brace = snippet.rfind("}")
|
||||
if last_brace == -1:
|
||||
return None
|
||||
|
||||
snippet = snippet[: last_brace + 1]
|
||||
|
||||
prefix_end = snippet.find("[")
|
||||
if prefix_end == -1:
|
||||
return None
|
||||
|
||||
prefix = snippet[: prefix_end + 1]
|
||||
body = snippet[prefix_end + 1 :].rstrip()
|
||||
|
||||
if body.endswith(","):
|
||||
body = body[:-1].rstrip()
|
||||
|
||||
repaired = prefix + body + "]"
|
||||
|
||||
return json.loads(repaired)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def from_qwen_2_5_vl(
|
||||
result: str,
|
||||
input_wh: tuple[int, int],
|
||||
|
|
@ -237,7 +295,7 @@ def from_qwen_2_5_vl(
|
|||
classes: list[str] | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
|
||||
"""
|
||||
Parse and scale bounding boxes from Qwen-2.5-VL style JSON output.
|
||||
Parse and rescale bounding boxes and class labels from Qwen-2.5-VL JSON output.
|
||||
|
||||
The JSON is expected to be enclosed in triple backticks with the format:
|
||||
```json
|
||||
|
|
@ -248,38 +306,52 @@ def from_qwen_2_5_vl(
|
|||
```
|
||||
|
||||
Args:
|
||||
result: String containing the JSON snippet enclosed by triple backticks.
|
||||
input_wh: (input_width, input_height) describing the original bounding box
|
||||
scale.
|
||||
resolution_wh: (output_width, output_height) to which we rescale the boxes.
|
||||
classes: Optional list of valid class names. If provided, returned boxes/labels
|
||||
are filtered to only those classes found here.
|
||||
result (str): String containing Qwen-2.5-VL JSON bounding box and label data.
|
||||
input_wh (tuple[int, int]): Width and height of the coordinate space where boxes
|
||||
are normalized.
|
||||
resolution_wh (tuple[int, int]): Target width and height to scale bounding
|
||||
boxes.
|
||||
classes (list[str] or None): Optional list of valid class names to filter
|
||||
results. If provided, only boxes with labels in this list are returned.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`
|
||||
class_id (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class indices for each bounding box (or None if `classes` is not
|
||||
provided)
|
||||
class_name (np.ndarray): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box
|
||||
xyxy (np.ndarray): Array of shape `(N, 4)` with rescaled bounding boxes in
|
||||
`(x_min, y_min, x_max, y_max)` format.
|
||||
class_id (np.ndarray or None): Array of shape `(N,)` with indices of classes,
|
||||
or `None` if no filtering applied.
|
||||
class_name (np.ndarray): Array of shape `(N,)` with class names as strings.
|
||||
"""
|
||||
|
||||
in_w, in_h = validate_resolution(input_wh)
|
||||
out_w, out_h = validate_resolution(resolution_wh)
|
||||
|
||||
pattern = re.compile(r"```json\s*(.*?)\s*```", re.DOTALL)
|
||||
text = result.strip()
|
||||
text = re.sub(r"^```(json)?", "", text, flags=re.IGNORECASE).strip()
|
||||
text = re.sub(r"```$", "", text).strip()
|
||||
|
||||
match = pattern.search(result)
|
||||
if not match:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
json_snippet = match.group(1)
|
||||
start = text.find("[")
|
||||
end = text.rfind("]")
|
||||
if start != -1 and end != -1 and end > start:
|
||||
text = text[start : end + 1].strip()
|
||||
|
||||
try:
|
||||
data = json.loads(json_snippet)
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
repaired = recover_truncated_qwen_2_5_vl_response(text)
|
||||
if repaired is not None:
|
||||
data = repaired
|
||||
else:
|
||||
try:
|
||||
data = ast.literal_eval(text)
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
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))
|
||||
|
||||
boxes_list = []
|
||||
labels_list = []
|
||||
|
|
@ -291,7 +363,7 @@ def from_qwen_2_5_vl(
|
|||
labels_list.append(item["label"])
|
||||
|
||||
if not boxes_list:
|
||||
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 = np.array(boxes_list, dtype=float)
|
||||
class_name = np.array(labels_list, dtype=str)
|
||||
|
|
@ -310,6 +382,109 @@ def from_qwen_2_5_vl(
|
|||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_qwen_3_vl(
|
||||
result: str,
|
||||
resolution_wh: tuple[int, int],
|
||||
classes: list[str] | None = None,
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
|
||||
"""
|
||||
Parse and scale bounding boxes from Qwen-3-VL style JSON output.
|
||||
|
||||
Args:
|
||||
result (str): String containing the Qwen-3-VL JSON output.
|
||||
resolution_wh (tuple[int, int]): Target resolution `(width, height)` to
|
||||
scale bounding boxes.
|
||||
classes (list[str] or None): Optional list of valid classes to filter
|
||||
results.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): Array of bounding boxes with shape `(N, 4)` in
|
||||
`(x_min, y_min, x_max, y_max)` format scaled to `resolution_wh`.
|
||||
class_id (np.ndarray or None): Array of class indices for each box, or
|
||||
None if no filtering by classes.
|
||||
class_name (np.ndarray): Array of class names as strings.
|
||||
"""
|
||||
return from_qwen_2_5_vl(
|
||||
result=result,
|
||||
input_wh=(1000, 1000),
|
||||
resolution_wh=resolution_wh,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
|
||||
def from_deepseek_vl_2(
|
||||
result: str, resolution_wh: tuple[int, int], classes: list[str] | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray]:
|
||||
"""
|
||||
Parse bounding boxes from deepseek-vl2-formatted text, scale them to the specified
|
||||
resolution, and optionally filter by classes.
|
||||
|
||||
The DeepSeek-VL2 output typically contains pairs of <|ref|> ... <|/ref|> labels
|
||||
and <|det|> ... <|/det|> bounding box definitions. Each <|det|> section may
|
||||
contain one or more bounding boxes in the form [[x1, y1, x2, y2], [x1, y1, x2, y2], ...]
|
||||
(scaled to 0..999). For example:
|
||||
|
||||
```
|
||||
<|ref|>The giraffe at the back<|/ref|><|det|>[[580, 270, 999, 904]]<|/det|><|ref|>The giraffe at the front<|/ref|><|det|>[[26, 31, 632, 998]]<|/det|><|end▁of▁sentence|>
|
||||
```
|
||||
|
||||
Args:
|
||||
result: String containing deepseek-vl2-formatted locations and labels.
|
||||
resolution_wh: Tuple (width, height) to which we scale the box coordinates.
|
||||
classes: Optional list of valid class names. If provided, boxes and labels not
|
||||
in this list are filtered out.
|
||||
|
||||
Returns:
|
||||
xyxy (np.ndarray): An array of shape `(n, 4)` containing
|
||||
the bounding boxes coordinates in format `[x1, y1, x2, y2]`.
|
||||
class_id (Optional[np.ndarray]): An array of shape `(n,)` containing
|
||||
the class indices for each bounding box (or `None` if classes is not
|
||||
provided).
|
||||
class_name (np.ndarray): An array of shape `(n,)` containing
|
||||
the class labels for each bounding box.
|
||||
""" # noqa: E501
|
||||
|
||||
width, height = resolution_wh
|
||||
label_segments = re.findall(r"<\|ref\|>(.*?)<\|/ref\|>", result, flags=re.S)
|
||||
detection_segments = re.findall(r"<\|det\|>(.*?)<\|/det\|>", result, flags=re.S)
|
||||
|
||||
if len(label_segments) != len(detection_segments):
|
||||
raise ValueError(
|
||||
f"Number of ref tags ({len(label_segments)}) "
|
||||
f"and det tags ({len(detection_segments)}) in the result must be equal."
|
||||
)
|
||||
|
||||
xyxy, class_name_list = [], []
|
||||
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(
|
||||
[
|
||||
(x1 / 999 * width),
|
||||
(y1 / 999 * height),
|
||||
(x2 / 999 * width),
|
||||
(y2 / 999 * height),
|
||||
]
|
||||
)
|
||||
class_name_list.append(current_class_name)
|
||||
|
||||
xyxy = np.array(xyxy, dtype=np.float32)
|
||||
class_name = np.array(class_name_list)
|
||||
|
||||
if classes is not None:
|
||||
mask = np.array([name in classes for name in class_name], dtype=bool)
|
||||
xyxy = xyxy[mask]
|
||||
class_name = class_name[mask]
|
||||
class_id = np.array([classes.index(name) for name in class_name])
|
||||
else:
|
||||
unique_classes = sorted(list(set(class_name)))
|
||||
class_to_id = {name: i for i, name in enumerate(unique_classes)}
|
||||
class_id = np.array([class_to_id[name] for name in class_name])
|
||||
|
||||
return xyxy, class_id, class_name
|
||||
|
||||
|
||||
def from_florence_2(
|
||||
result: dict, resolution_wh: tuple[int, int]
|
||||
) -> tuple[np.ndarray, np.ndarray | None, np.ndarray | None, np.ndarray | None]:
|
||||
|
|
@ -460,7 +635,7 @@ def from_google_gemini_2_0(
|
|||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
labels = []
|
||||
boxes_list = []
|
||||
xyxy = []
|
||||
|
||||
for item in data:
|
||||
if "box_2d" not in item or "label" not in item:
|
||||
|
|
@ -468,18 +643,16 @@ def from_google_gemini_2_0(
|
|||
labels.append(item["label"])
|
||||
box = item["box_2d"]
|
||||
# Gemini bbox order is [y_min, x_min, y_max, x_max]
|
||||
boxes_list.append(
|
||||
denormalize_boxes(
|
||||
np.array([box[1], box[0], box[3], box[2]]).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
normalization_factor=1000,
|
||||
)
|
||||
)
|
||||
xyxy.append([box[1], box[0], box[3], box[2]])
|
||||
|
||||
if not boxes_list:
|
||||
if len(xyxy) == 0:
|
||||
return np.empty((0, 4)), None, np.empty((0,), dtype=str)
|
||||
|
||||
xyxy = np.array(boxes_list)
|
||||
xyxy = denormalize_boxes(
|
||||
np.array(xyxy, dtype=np.float64),
|
||||
resolution_wh=(w, h),
|
||||
normalization_factor=1000,
|
||||
)
|
||||
class_name = np.array(labels)
|
||||
class_id = None
|
||||
|
||||
|
|
@ -571,10 +744,10 @@ def from_google_gemini_2_5(
|
|||
box = item["box_2d"]
|
||||
# Gemini bbox order is [y_min, x_min, y_max, x_max]
|
||||
absolute_bbox = denormalize_boxes(
|
||||
np.array([box[1], box[0], box[3], box[2]]).astype(np.float64),
|
||||
np.array([[box[1], box[0], box[3], box[2]]]).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
normalization_factor=1000,
|
||||
)
|
||||
)[0]
|
||||
boxes_list.append(absolute_bbox)
|
||||
|
||||
if "mask" in item:
|
||||
|
|
@ -657,7 +830,7 @@ def from_google_gemini_2_5(
|
|||
def from_moondream(
|
||||
result: dict,
|
||||
resolution_wh: tuple[int, int],
|
||||
) -> tuple[np.ndarray]:
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Parse and scale bounding boxes from moondream JSON output.
|
||||
|
||||
|
|
@ -695,7 +868,7 @@ def from_moondream(
|
|||
if "objects" not in result or not isinstance(result["objects"], list):
|
||||
return np.empty((0, 4), dtype=float)
|
||||
|
||||
denormalize_xyxy = []
|
||||
xyxy = []
|
||||
|
||||
for item in result["objects"]:
|
||||
if not all(k in item for k in ["x_min", "y_min", "x_max", "y_max"]):
|
||||
|
|
@ -706,14 +879,12 @@ def from_moondream(
|
|||
x_max = item["x_max"]
|
||||
y_max = item["y_max"]
|
||||
|
||||
denormalize_xyxy.append(
|
||||
denormalize_boxes(
|
||||
np.array([x_min, y_min, x_max, y_max]).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
)
|
||||
)
|
||||
xyxy.append([x_min, y_min, x_max, y_max])
|
||||
|
||||
if not denormalize_xyxy:
|
||||
if len(xyxy) == 0:
|
||||
return np.empty((0, 4))
|
||||
|
||||
return np.array(denormalize_xyxy, dtype=float)
|
||||
return denormalize_boxes(
|
||||
np.array(xyxy).astype(np.float64),
|
||||
resolution_wh=(w, h),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
from typing import TypeVar
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
ImageType = TypeVar("ImageType", np.ndarray, Image.Image)
|
||||
"""
|
||||
An image of type `np.ndarray` or `PIL.Image.Image`.
|
||||
|
||||
Unlike a `Union`, ensures the type remains consistent. If a function
|
||||
takes an `ImageType` argument and returns an `ImageType`, when you
|
||||
pass an `np.ndarray`, you will get an `np.ndarray` back.
|
||||
"""
|
||||
|
|
@ -346,28 +346,50 @@ def draw_image(
|
|||
|
||||
def calculate_optimal_text_scale(resolution_wh: tuple[int, int]) -> float:
|
||||
"""
|
||||
Calculate font scale based on the resolution of an image.
|
||||
Calculate optimal font scale based on image resolution. Adjusts font scale
|
||||
proportionally to the smallest dimension of the given image resolution for
|
||||
consistent readability.
|
||||
|
||||
Parameters:
|
||||
resolution_wh (Tuple[int, int]): A tuple representing the width and height
|
||||
of the image.
|
||||
Args:
|
||||
resolution_wh (tuple[int, int]): (width, height) of the image in pixels
|
||||
|
||||
Returns:
|
||||
float: The calculated font scale factor.
|
||||
float: recommended font scale factor
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
sv.calculate_optimal_text_scale((1920, 1080))
|
||||
# 1.08
|
||||
sv.calculate_optimal_text_scale((640, 480))
|
||||
# 0.48
|
||||
```
|
||||
"""
|
||||
return min(resolution_wh) * 1e-3
|
||||
|
||||
|
||||
def calculate_optimal_line_thickness(resolution_wh: tuple[int, int]) -> int:
|
||||
"""
|
||||
Calculate line thickness based on the resolution of an image.
|
||||
Calculate optimal line thickness based on image resolution. Adjusts the line
|
||||
thickness for readability depending on the smallest dimension of the provided
|
||||
image resolution.
|
||||
|
||||
Parameters:
|
||||
resolution_wh (Tuple[int, int]): A tuple representing the width and height
|
||||
of the image.
|
||||
Args:
|
||||
resolution_wh (tuple[int, int]): (width, height) of the image in pixels
|
||||
|
||||
Returns:
|
||||
int: The calculated line thickness in pixels.
|
||||
int: recommended line thickness in pixels
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
sv.calculate_optimal_line_thickness((1920, 1080))
|
||||
# 4
|
||||
sv.calculate_optimal_line_thickness((640, 480))
|
||||
# 2
|
||||
```
|
||||
"""
|
||||
if min(resolution_wh) < 1080:
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ from logging import warn
|
|||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.detection.utils.boxes import pad_boxes, spread_out_boxes
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.draw.color import Color
|
||||
from supervision.draw.utils import draw_rounded_rectangle
|
||||
from supervision.geometry.core import Rect
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.keypoint.skeletons import SKELETONS_BY_VERTEX_COUNT
|
||||
from supervision.utils.conversion import ensure_cv2_image_for_annotation
|
||||
from supervision.key_points.core import KeyPoints
|
||||
from supervision.key_points.skeletons import SKELETONS_BY_VERTEX_COUNT
|
||||
from supervision.utils.conversion import ensure_cv2_image_for_class_method
|
||||
|
||||
|
||||
class BaseKeyPointAnnotator(ABC):
|
||||
|
|
@ -43,7 +43,7 @@ class VertexAnnotator(BaseKeyPointAnnotator):
|
|||
self.color = color
|
||||
self.radius = radius
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene with skeleton vertices based on the provided key
|
||||
|
|
@ -120,7 +120,7 @@ class EdgeAnnotator(BaseKeyPointAnnotator):
|
|||
self.thickness = thickness
|
||||
self.edges = edges
|
||||
|
||||
@ensure_cv2_image_for_annotation
|
||||
@ensure_cv2_image_for_class_method
|
||||
def annotate(self, scene: ImageType, key_points: KeyPoints) -> ImageType:
|
||||
"""
|
||||
Annotates the given scene by drawing lines between specified key points to form
|
||||
|
|
@ -10,7 +10,7 @@ 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_keypoints_fields
|
||||
from supervision.validators import validate_key_points_fields
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -23,7 +23,7 @@ class KeyPoints:
|
|||
|
||||
=== "Ultralytics"
|
||||
|
||||
Use [`sv.KeyPoints.from_ultralytics`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_ultralytics)
|
||||
Use [`sv.KeyPoints.from_ultralytics`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_ultralytics)
|
||||
method, which accepts [YOLOv8-pose](https://docs.ultralytics.com/models/yolov8/), [YOLO11-pose](https://docs.ultralytics.com/models/yolo11/)
|
||||
[pose](https://docs.ultralytics.com/tasks/pose/) result.
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ class KeyPoints:
|
|||
|
||||
=== "Inference"
|
||||
|
||||
Use [`sv.KeyPoints.from_inference`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_inference)
|
||||
Use [`sv.KeyPoints.from_inference`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_inference)
|
||||
method, which accepts [Inference](https://inference.roboflow.com/) pose result.
|
||||
|
||||
```python
|
||||
|
|
@ -58,7 +58,7 @@ class KeyPoints:
|
|||
|
||||
=== "MediaPipe"
|
||||
|
||||
Use [`sv.KeyPoints.from_mediapipe`](/latest/keypoint/core/#supervision.keypoint.core.KeyPoints.from_mediapipe)
|
||||
Use [`sv.KeyPoints.from_mediapipe`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_mediapipe)
|
||||
method, which accepts [MediaPipe](https://github.com/google-ai-edge/mediapipe)
|
||||
pose result.
|
||||
|
||||
|
|
@ -89,10 +89,61 @@ class KeyPoints:
|
|||
pose_landmarker_result, (image_width, image_height))
|
||||
```
|
||||
|
||||
=== "Transformers"
|
||||
|
||||
Use [`sv.KeyPoints.from_transformers`](/latest/keypoint/core/#supervision.key_points.core.KeyPoints.from_transformers)
|
||||
method, which accepts [ViTPose](https://huggingface.co/docs/transformers/en/model_doc/vitpose) result.
|
||||
|
||||
```python
|
||||
from PIL import Image
|
||||
import requests
|
||||
import supervision as sv
|
||||
import torch
|
||||
from transformers import (
|
||||
AutoProcessor,
|
||||
RTDetrForObjectDetection,
|
||||
VitPoseForPoseEstimation,
|
||||
)
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
image = Image.open(<SOURCE_IMAGE_PATH>)
|
||||
|
||||
DETECTION_MODEL_ID = "PekingU/rtdetr_r50vd_coco_o365"
|
||||
|
||||
detection_processor = AutoProcessor.from_pretrained(DETECTION_MODEL_ID, use_fast=True)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = detection_model(**inputs)
|
||||
|
||||
target_size = torch.tensor([(frame.height, frame.width)])
|
||||
results = detection_processor.post_process_object_detection(
|
||||
outputs, target_sizes=target_size, threshold=0.3)
|
||||
|
||||
detections = sv.Detections.from_transformers(results[0])
|
||||
boxes = sv.xyxy_to_xywh(detections[detections.class_id == 0].xyxy)
|
||||
|
||||
POSE_ESTIMATION_MODEL_ID = "usyd-community/vitpose-base-simple"
|
||||
|
||||
pose_estimation_processor = AutoProcessor.from_pretrained(POSE_ESTIMATION_MODEL_ID)
|
||||
pose_estimation_model = VitPoseForPoseEstimation.from_pretrained(
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=DEVICE)
|
||||
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(DEVICE)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = pose_estimation_model(**inputs)
|
||||
|
||||
results = pose_estimation_processor.post_process_pose_estimation(outputs, boxes=[boxes])
|
||||
key_point = sv.KeyPoints.from_transformers(results[0])
|
||||
```
|
||||
|
||||
Attributes:
|
||||
xy (np.ndarray): An array of shape `(n, m, 2)` containing
|
||||
`n` detected objects, each composed of `m` equally-sized
|
||||
sets of keypoints, where each point is `[x, y]`.
|
||||
sets of key points, where each point is `[x, y]`.
|
||||
class_id (Optional[np.ndarray]): An array of shape
|
||||
`(n,)` containing the class ids of the detected objects.
|
||||
confidence (Optional[np.ndarray]): An array of shape
|
||||
|
|
@ -109,7 +160,7 @@ class KeyPoints:
|
|||
data: dict[str, npt.NDArray[Any] | list] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
validate_keypoints_fields(
|
||||
validate_key_points_fields(
|
||||
xy=self.xy,
|
||||
confidence=self.confidence,
|
||||
class_id=self.class_id,
|
||||
|
|
@ -514,13 +565,13 @@ class KeyPoints:
|
|||
return cls.empty()
|
||||
|
||||
@classmethod
|
||||
def from_transformers(cls, transfomers_results: Any) -> KeyPoints:
|
||||
def from_transformers(cls, transformers_results: Any) -> KeyPoints:
|
||||
"""
|
||||
Create a `sv.KeyPoints` object from the
|
||||
[Transformers](https://github.com/huggingface/transformers) inference result.
|
||||
|
||||
Args:
|
||||
transfomers_results (Any): The output of a
|
||||
transformers_results (Any): The output of a
|
||||
Transformers model containing instances with prediction data.
|
||||
|
||||
Returns:
|
||||
|
|
@ -545,9 +596,9 @@ class KeyPoints:
|
|||
DETECTION_MODEL_ID = "PekingU/rtdetr_r50vd_coco_o365"
|
||||
|
||||
detection_processor = AutoProcessor.from_pretrained(DETECTION_MODEL_ID, use_fast=True)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=DEVICE)
|
||||
detection_model = RTDetrForObjectDetection.from_pretrained(DETECTION_MODEL_ID, device_map=device)
|
||||
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(DEVICE)
|
||||
inputs = detection_processor(images=frame, return_tensors="pt").to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = detection_model(**inputs)
|
||||
|
|
@ -563,9 +614,9 @@ class KeyPoints:
|
|||
|
||||
pose_estimation_processor = AutoProcessor.from_pretrained(POSE_ESTIMATION_MODEL_ID)
|
||||
pose_estimation_model = VitPoseForPoseEstimation.from_pretrained(
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=DEVICE)
|
||||
POSE_ESTIMATION_MODEL_ID, device_map=device)
|
||||
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(DEVICE)
|
||||
inputs = pose_estimation_processor(frame, boxes=[boxes], return_tensors="pt").to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = pose_estimation_model(**inputs)
|
||||
|
|
@ -576,8 +627,8 @@ class KeyPoints:
|
|||
|
||||
""" # noqa: E501 // docs
|
||||
|
||||
if "keypoints" in transfomers_results[0]:
|
||||
if transfomers_results[0]["keypoints"].cpu().numpy().size == 0:
|
||||
if "keypoints" in transformers_results[0]:
|
||||
if transformers_results[0]["keypoints"].cpu().numpy().size == 0:
|
||||
return cls.empty()
|
||||
|
||||
result_data = [
|
||||
|
|
@ -585,7 +636,7 @@ class KeyPoints:
|
|||
result["keypoints"].cpu().numpy(),
|
||||
result["scores"].cpu().numpy(),
|
||||
)
|
||||
for result in transfomers_results
|
||||
for result in transformers_results
|
||||
]
|
||||
|
||||
xy, scores = zip(*result_data)
|
||||
|
|
@ -599,55 +650,68 @@ class KeyPoints:
|
|||
return cls.empty()
|
||||
|
||||
def __getitem__(
|
||||
self, index: int | slice | list[int] | np.ndarray | str
|
||||
) -> KeyPoints | list | np.ndarray | None:
|
||||
"""
|
||||
Get a subset of the `sv.KeyPoints` object or access an item from its data field.
|
||||
|
||||
When provided with an integer, slice, list of integers, or a numpy array, this
|
||||
method returns a new `sv.KeyPoints` object that represents a subset of the
|
||||
original `sv.KeyPoints`. When provided with a string, it accesses the
|
||||
corresponding item in the data dictionary.
|
||||
|
||||
Args:
|
||||
index (Union[int, slice, List[int], np.ndarray, str]): The index, indices,
|
||||
or key to access a subset of the `sv.KeyPoints` or an item from the
|
||||
data.
|
||||
|
||||
Returns:
|
||||
A subset of the `sv.KeyPoints` object or an item from the data field.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
import supervision as sv
|
||||
|
||||
key_points = sv.KeyPoints()
|
||||
|
||||
# access the first keypoint using an integer index
|
||||
key_points[0]
|
||||
|
||||
# access the first 10 keypoints using index slice
|
||||
key_points[0:10]
|
||||
|
||||
# access selected keypoints using a list of indices
|
||||
key_points[[0, 2, 4]]
|
||||
|
||||
# access keypoints with selected class_id
|
||||
key_points[key_points.class_id == 0]
|
||||
|
||||
# access keypoints with confidence greater than 0.5
|
||||
key_points[key_points.confidence > 0.5]
|
||||
```
|
||||
"""
|
||||
self, index: int | slice | list[int] | np.ndarray | tuple | str
|
||||
) -> KeyPoints | np.ndarray | list | None:
|
||||
if isinstance(index, str):
|
||||
return self.data.get(index)
|
||||
if isinstance(index, int):
|
||||
index = [index]
|
||||
|
||||
if not isinstance(index, tuple):
|
||||
index = (index, slice(None))
|
||||
|
||||
i, j = index
|
||||
|
||||
if isinstance(i, int):
|
||||
i = [i]
|
||||
|
||||
if isinstance(i, list) and all(isinstance(x, bool) for x in i):
|
||||
i = np.array(i)
|
||||
if isinstance(j, list) and all(isinstance(x, bool) for x in j):
|
||||
j = np.array(j)
|
||||
|
||||
if isinstance(i, np.ndarray) and i.dtype == bool:
|
||||
i = np.flatnonzero(i)
|
||||
if isinstance(j, np.ndarray) and j.dtype == bool:
|
||||
j = np.flatnonzero(j)
|
||||
|
||||
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)
|
||||
|
||||
xy_selected = self.xy[i, j]
|
||||
|
||||
conf_selected = self.confidence[i, j] if self.confidence is not None else None
|
||||
|
||||
class_id_selected = self.class_id[i] if self.class_id is not None else None
|
||||
|
||||
data_selected = get_data_item(self.data, 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)
|
||||
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, ...]
|
||||
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]
|
||||
|
||||
return KeyPoints(
|
||||
xy=self.xy[index],
|
||||
confidence=self.confidence[index] if self.confidence is not None else None,
|
||||
class_id=self.class_id[index] if self.class_id is not None else None,
|
||||
data=get_data_item(self.data, index),
|
||||
xy=xy_selected,
|
||||
confidence=conf_selected,
|
||||
class_id=class_id_selected,
|
||||
data=data_selected,
|
||||
)
|
||||
|
||||
def __setitem__(self, key: str, value: np.ndarray | list):
|
||||
|
|
@ -668,12 +732,12 @@ class KeyPoints:
|
|||
model = YOLO('yolov8s.pt')
|
||||
|
||||
result = model(image)[0]
|
||||
keypoints = sv.KeyPoints.from_ultralytics(result)
|
||||
key_points = sv.KeyPoints.from_ultralytics(result)
|
||||
|
||||
keypoints['class_name'] = [
|
||||
key_points['class_name'] = [
|
||||
model.model.names[class_id]
|
||||
for class_id
|
||||
in keypoints.class_id
|
||||
in key_points.class_id
|
||||
]
|
||||
```
|
||||
"""
|
||||
|
|
@ -688,7 +752,7 @@ class KeyPoints:
|
|||
@classmethod
|
||||
def empty(cls) -> KeyPoints:
|
||||
"""
|
||||
Create an empty Keypoints object with no keypoints.
|
||||
Create an empty KeyPoints object with no key points.
|
||||
|
||||
Returns:
|
||||
An empty `sv.KeyPoints` object.
|
||||
|
|
@ -706,9 +770,9 @@ class KeyPoints:
|
|||
"""
|
||||
Returns `True` if the `KeyPoints` object is considered empty.
|
||||
"""
|
||||
empty_keypoints = KeyPoints.empty()
|
||||
empty_keypoints.data = self.data
|
||||
return self == empty_keypoints
|
||||
empty_key_points = KeyPoints.empty()
|
||||
empty_key_points.data = self.data
|
||||
return self == empty_key_points
|
||||
|
||||
def as_detections(
|
||||
self, selected_keypoint_indices: Iterable[int] | None = None
|
||||
|
|
@ -716,21 +780,21 @@ class KeyPoints:
|
|||
"""
|
||||
Convert a KeyPoints object to a Detections object. This
|
||||
approximates the bounding box of the detected object by
|
||||
taking the bounding box that fits all keypoints.
|
||||
taking the bounding box that fits all key points.
|
||||
|
||||
Arguments:
|
||||
selected_keypoint_indices (Optional[Iterable[int]]): The
|
||||
indices of the keypoints to include in the bounding box
|
||||
calculation. This helps focus on a subset of keypoints,
|
||||
e.g. when some are occluded. Captures all keypoints by default.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
detections (Detections): The converted detections object.
|
||||
|
||||
Examples:
|
||||
```python
|
||||
keypoints = sv.KeyPoints.from_inference(...)
|
||||
detections = keypoints.as_detections()
|
||||
key_points = sv.KeyPoints.from_inference(...)
|
||||
detections = key_points.as_detections()
|
||||
```
|
||||
"""
|
||||
if self.is_empty():
|
||||
|
|
@ -102,12 +102,12 @@ class MeanAveragePrecisionResult:
|
|||
f"maxDets=100 ] = {self.map50:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.75 | area= all | "
|
||||
f"maxDets=100 ] = {self.map75:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= small | maxDets=100 ] "
|
||||
f"= {self.small_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | maxDets=100 ] \
|
||||
= {self.medium_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= large | maxDets=100 ] \
|
||||
= {self.large_objects.map50_95:.3f}"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= small | "
|
||||
f"maxDets=100 ] = {self.small_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area=medium | "
|
||||
f"maxDets=100 ] = {self.medium_objects.map50_95:.3f}\n"
|
||||
f"Average Precision (AP) @[ IoU=0.50:0.95 | area= large | "
|
||||
f"maxDets=100 ] = {self.large_objects.map50_95:.3f}"
|
||||
)
|
||||
|
||||
def to_pandas(self) -> pd.DataFrame:
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import cv2
|
|||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.draw.base import ImageType
|
||||
|
||||
|
||||
def ensure_cv2_image_for_annotation(annotate_func):
|
||||
def ensure_cv2_image_for_class_method(annotate_func):
|
||||
"""
|
||||
Decorates `BaseAnnotator.annotate` implementations, converts scene to
|
||||
an image type used internally by the annotators, converts back when annotation
|
||||
|
|
@ -32,7 +32,7 @@ def ensure_cv2_image_for_annotation(annotate_func):
|
|||
return wrapper
|
||||
|
||||
|
||||
def ensure_cv2_image_for_processing(image_processing_fun):
|
||||
def ensure_cv2_image_for_standalone_function(image_processing_fun):
|
||||
"""
|
||||
Decorates image processing functions that accept np.ndarray, converting `image` to
|
||||
np.ndarray, converts back when processing is complete.
|
||||
|
|
@ -55,7 +55,7 @@ def ensure_cv2_image_for_processing(image_processing_fun):
|
|||
return wrapper
|
||||
|
||||
|
||||
def ensure_pil_image_for_annotation(annotate_func):
|
||||
def ensure_pil_image_for_class_method(annotate_func):
|
||||
"""
|
||||
Decorates image processing functions that accept np.ndarray, converting `image` to
|
||||
PIL image, converts back when processing is complete.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,7 +4,7 @@ import cv2
|
|||
import matplotlib.pyplot as plt
|
||||
from PIL import Image
|
||||
|
||||
from supervision.annotators.base import ImageType
|
||||
from supervision.draw.base import ImageType
|
||||
from supervision.utils.conversion import pillow_to_cv2
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable, Generator
|
||||
from dataclasses import dataclass
|
||||
from queue import Queue
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
|
@ -196,63 +198,126 @@ def process_video(
|
|||
source_path: str,
|
||||
target_path: str,
|
||||
callback: Callable[[np.ndarray, int], np.ndarray],
|
||||
*,
|
||||
max_frames: int | None = None,
|
||||
prefetch: int = 32,
|
||||
writer_buffer: int = 32,
|
||||
show_progress: bool = False,
|
||||
progress_message: str = "Processing video",
|
||||
) -> None:
|
||||
"""
|
||||
Process a video file by applying a callback function on each frame
|
||||
and saving the result to a target video file.
|
||||
Process video frames asynchronously using a threaded pipeline.
|
||||
|
||||
This function orchestrates a three-stage pipeline to optimize video processing
|
||||
throughput:
|
||||
|
||||
1. Reader thread: Continuously reads frames from the source video file and
|
||||
enqueues them into a bounded queue (`frame_read_queue`). The queue size is
|
||||
limited by the `prefetch` parameter to control memory usage.
|
||||
2. Main thread (Processor): Dequeues frames from `frame_read_queue`, applies the
|
||||
user-defined `callback` function to process each frame, then enqueues the
|
||||
processed frames into another bounded queue (`frame_write_queue`) for writing.
|
||||
The processing happens in the main thread, simplifying use of stateful objects
|
||||
without synchronization.
|
||||
3. Writer thread: Dequeues processed frames from `frame_write_queue` and writes
|
||||
them sequentially to the output video file.
|
||||
|
||||
Args:
|
||||
source_path (str): The path to the source video file.
|
||||
target_path (str): The path to the target video file.
|
||||
callback (Callable[[np.ndarray, int], np.ndarray]): A function that takes in
|
||||
a numpy ndarray representation of a video frame and an
|
||||
int index of the frame and returns a processed numpy ndarray
|
||||
representation of the frame.
|
||||
max_frames (Optional[int]): The maximum number of frames to process.
|
||||
show_progress (bool): Whether to show a progress bar.
|
||||
progress_message (str): The message to display in the progress bar.
|
||||
source_path (str): Path to the input video file.
|
||||
target_path (str): Path where the processed video will be saved.
|
||||
callback (Callable[[numpy.ndarray, int], numpy.ndarray]): Function called for
|
||||
each frame, accepting the frame as a numpy array and its zero-based index,
|
||||
returning the processed frame.
|
||||
max_frames (int | None): Optional maximum number of frames to process.
|
||||
If None, the entire video is processed (default).
|
||||
prefetch (int): Maximum number of frames buffered by the reader thread.
|
||||
Controls memory use; default is 32.
|
||||
writer_buffer (int): Maximum number of frames buffered before writing.
|
||||
Controls output buffer size; default is 32.
|
||||
show_progress (bool): Whether to display a tqdm progress bar during processing.
|
||||
Default is False.
|
||||
progress_message (str): Description shown in the progress bar.
|
||||
|
||||
Examples:
|
||||
Returns:
|
||||
None
|
||||
|
||||
Example:
|
||||
```python
|
||||
import cv2
|
||||
import supervision as sv
|
||||
from rfdetr import RFDETRMedium
|
||||
|
||||
def callback(scene: np.ndarray, index: int) -> np.ndarray:
|
||||
...
|
||||
model = RFDETRMedium()
|
||||
|
||||
def callback(frame, frame_index):
|
||||
return model.predict(frame)
|
||||
|
||||
process_video(
|
||||
source_path=<SOURCE_VIDEO_PATH>,
|
||||
target_path=<TARGET_VIDEO_PATH>,
|
||||
callback=callback
|
||||
source_path="source.mp4",
|
||||
target_path="target.mp4",
|
||||
callback=frame_callback,
|
||||
)
|
||||
```
|
||||
"""
|
||||
source_video_info = VideoInfo.from_video_path(video_path=source_path)
|
||||
video_frames_generator = get_video_frames_generator(
|
||||
source_path=source_path, end=max_frames
|
||||
video_info = VideoInfo.from_video_path(video_path=source_path)
|
||||
total_frames = (
|
||||
min(video_info.total_frames, max_frames)
|
||||
if max_frames is not None
|
||||
else video_info.total_frames
|
||||
)
|
||||
with VideoSink(target_path=target_path, video_info=source_video_info) as sink:
|
||||
total_frames = (
|
||||
min(source_video_info.total_frames, max_frames)
|
||||
if max_frames is not None
|
||||
else source_video_info.total_frames
|
||||
|
||||
frame_read_queue: Queue[tuple[int, np.ndarray] | None] = Queue(maxsize=prefetch)
|
||||
frame_write_queue: Queue[np.ndarray | None] = Queue(maxsize=writer_buffer)
|
||||
|
||||
def reader_thread() -> None:
|
||||
frame_generator = get_video_frames_generator(
|
||||
source_path=source_path,
|
||||
end=max_frames,
|
||||
)
|
||||
for index, frame in enumerate(
|
||||
tqdm(
|
||||
video_frames_generator,
|
||||
total=total_frames,
|
||||
disable=not show_progress,
|
||||
desc=progress_message,
|
||||
)
|
||||
):
|
||||
result_frame = callback(frame, index)
|
||||
sink.write_frame(frame=result_frame)
|
||||
else:
|
||||
for index, frame in enumerate(video_frames_generator):
|
||||
result_frame = callback(frame, index)
|
||||
sink.write_frame(frame=result_frame)
|
||||
for frame_index, frame in enumerate(frame_generator):
|
||||
frame_read_queue.put((frame_index, frame))
|
||||
frame_read_queue.put(None)
|
||||
|
||||
def writer_thread(video_sink: VideoSink) -> None:
|
||||
while True:
|
||||
frame = frame_write_queue.get()
|
||||
if frame is None:
|
||||
break
|
||||
video_sink.write_frame(frame=frame)
|
||||
|
||||
reader_worker = threading.Thread(target=reader_thread, daemon=True)
|
||||
with VideoSink(target_path=target_path, video_info=video_info) as video_sink:
|
||||
writer_worker = threading.Thread(
|
||||
target=writer_thread,
|
||||
args=(video_sink,),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
reader_worker.start()
|
||||
writer_worker.start()
|
||||
|
||||
progress_bar = tqdm(
|
||||
total=total_frames,
|
||||
disable=not show_progress,
|
||||
desc=progress_message,
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
read_item = frame_read_queue.get()
|
||||
if read_item is None:
|
||||
break
|
||||
|
||||
frame_index, frame = read_item
|
||||
processed_frame = callback(frame, frame_index)
|
||||
|
||||
frame_write_queue.put(processed_frame)
|
||||
progress_bar.update(1)
|
||||
finally:
|
||||
frame_write_queue.put(None)
|
||||
reader_worker.join()
|
||||
writer_worker.join()
|
||||
progress_bar.close()
|
||||
|
||||
|
||||
class FPSMonitor:
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ def validate_confidence(confidence: Any, n: int) -> None:
|
|||
)
|
||||
|
||||
|
||||
def validate_keypoint_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
def validate_key_point_confidence(confidence: Any, n: int, m: int) -> None:
|
||||
expected_shape = f"({n, m})"
|
||||
actual_shape = str(getattr(confidence, "shape", None))
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ def validate_detections_fields(
|
|||
validate_data(data, n)
|
||||
|
||||
|
||||
def validate_keypoints_fields(
|
||||
def validate_key_points_fields(
|
||||
xy: Any,
|
||||
class_id: Any,
|
||||
confidence: Any,
|
||||
|
|
@ -136,7 +136,7 @@ def validate_keypoints_fields(
|
|||
m = len(xy[0]) if len(xy) > 0 else 0
|
||||
validate_xy(xy, n, m)
|
||||
validate_class_id(class_id, n)
|
||||
validate_keypoint_confidence(confidence, n, m)
|
||||
validate_key_point_confidence(confidence, n, m)
|
||||
validate_data(data, n)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from contextlib import ExitStack as DoesNotRaise
|
|||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.annotators.utils import ColorLookup, resolve_color_idx
|
||||
from supervision.annotators.utils import ColorLookup, resolve_color_idx, wrap_text
|
||||
from supervision.detection.core import Detections
|
||||
from test.test_utils import mock_detections
|
||||
|
||||
|
|
@ -97,7 +97,7 @@ from test.test_utils import mock_detections
|
|||
def test_resolve_color_idx(
|
||||
detections: Detections,
|
||||
detection_idx: int,
|
||||
color_lookup: ColorLookup,
|
||||
color_lookup: ColorLookup | np.ndarray,
|
||||
expected_result: int | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
|
|
@ -108,3 +108,67 @@ def test_resolve_color_idx(
|
|||
color_lookup=color_lookup,
|
||||
)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text, max_line_length, expected_result, exception",
|
||||
[
|
||||
(None, None, [""], DoesNotRaise()), # text is None
|
||||
("", None, [""], DoesNotRaise()), # empty string
|
||||
(" \t ", 3, [""], DoesNotRaise()), # whitespace-only (spaces + tab)
|
||||
(12345, None, ["12345"], DoesNotRaise()), # plain integer
|
||||
(-6789, None, ["-6789"], DoesNotRaise()), # negative integer
|
||||
(np.int64(1000), None, ["1000"], DoesNotRaise()), # NumPy int64
|
||||
([1, 2, 3], None, ["[1, 2, 3]"], DoesNotRaise()), # list to string
|
||||
(
|
||||
"When you play the game of thrones, you win or you die.\nFear cuts deeper than swords.\nA mind needs books as a sword needs a whetstone.", # noqa: E501
|
||||
None,
|
||||
[
|
||||
"When you play the game of thrones, you win or you die.",
|
||||
"Fear cuts deeper than swords.",
|
||||
"A mind needs books as a sword needs a whetstone.",
|
||||
],
|
||||
DoesNotRaise(),
|
||||
), # Game-of-Thrones quotes, multiline
|
||||
("\n", None, [""], DoesNotRaise()), # single newline
|
||||
(
|
||||
"valarmorghulisvalardoharis",
|
||||
6,
|
||||
["valarm", "orghul", "isvala", "rdohar", "is"],
|
||||
DoesNotRaise(),
|
||||
), # long Valyrian phrase, wrapped
|
||||
(
|
||||
"Winter is coming\nFire and blood",
|
||||
10,
|
||||
[
|
||||
"Winter is",
|
||||
"coming",
|
||||
"Fire and",
|
||||
"blood",
|
||||
],
|
||||
DoesNotRaise(),
|
||||
), # mix of short/long with newline
|
||||
(
|
||||
"What is dead may never die",
|
||||
0,
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # width 0 - invalid
|
||||
(
|
||||
"A Lannister always pays his debts",
|
||||
-1,
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
), # width -1 - invalid
|
||||
(None, 10, [""], DoesNotRaise()), # text None, width set
|
||||
],
|
||||
)
|
||||
def test_wrap_text(
|
||||
text: object,
|
||||
max_line_length: int | None,
|
||||
expected_result: list[str],
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = wrap_text(text=text, max_line_length=max_line_length)
|
||||
assert result == expected_result
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ from contextlib import nullcontext as does_not_raise
|
|||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.config import CLASS_NAME_DATA_FIELD
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.vlm import (
|
||||
VLM,
|
||||
from_florence_2,
|
||||
from_google_gemini_2_0,
|
||||
from_google_gemini_2_5,
|
||||
|
|
@ -317,6 +320,43 @@ def test_from_paligemma(
|
|||
np.array(["dog"], dtype=str),
|
||||
),
|
||||
), # out-of-bounds box
|
||||
(
|
||||
does_not_raise(),
|
||||
"""[
|
||||
{'bbox_2d': [10, 20, 110, 120], 'label': 'cat'}
|
||||
]""",
|
||||
(640, 640),
|
||||
(1280, 720),
|
||||
None,
|
||||
(
|
||||
np.array([[20.0, 22.5, 220.0, 135.0]]),
|
||||
None,
|
||||
np.array(["cat"], dtype=str),
|
||||
),
|
||||
), # python-style list, single quotes, no fences
|
||||
(
|
||||
does_not_raise(),
|
||||
"""```json
|
||||
[
|
||||
{"bbox_2d": [0, 0, 64, 64], "label": "dog"},
|
||||
{"bbox_2d": [10, 20, 110, 120], "label": "cat"},
|
||||
{"bbox_2d": [30, 40, 130, 140], "label":
|
||||
""",
|
||||
(640, 640),
|
||||
(640, 640),
|
||||
None,
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 64.0, 64.0],
|
||||
[10.0, 20.0, 110.0, 120.0],
|
||||
],
|
||||
dtype=float,
|
||||
),
|
||||
None,
|
||||
np.array(["dog", "cat"], dtype=str),
|
||||
),
|
||||
), # truncated response, last object unfinished, previous ones recovered
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"""```json
|
||||
|
|
@ -327,8 +367,8 @@ def test_from_paligemma(
|
|||
(0, 640),
|
||||
(1280, 720),
|
||||
None,
|
||||
None, # won't be compared because we expect an exception
|
||||
), # zero input width -> ValueError
|
||||
None, # invalid input_wh
|
||||
),
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"""```json
|
||||
|
|
@ -339,8 +379,8 @@ def test_from_paligemma(
|
|||
(640, 640),
|
||||
(1280, -100),
|
||||
None,
|
||||
None,
|
||||
), # negative resolution height -> ValueError
|
||||
None, # invalid resolution_wh
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_from_qwen_2_5_vl(
|
||||
|
|
@ -1122,3 +1162,110 @@ def test_from_google_gemini_2_5(
|
|||
assert masks is not None
|
||||
assert masks.shape == expected_results[4].shape
|
||||
assert np.array_equal(masks, expected_results[4])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception, result, resolution_wh, classes, expected_detections",
|
||||
[
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # empty text
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"random text",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # random text
|
||||
(
|
||||
does_not_raise(),
|
||||
"<|ref|>cat<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(1000, 1000),
|
||||
None,
|
||||
Detections(
|
||||
xyxy=np.array([[100.1, 200.2, 300.3, 400.4]]),
|
||||
class_id=np.array([0]),
|
||||
data={CLASS_NAME_DATA_FIELD: np.array(["cat"])},
|
||||
),
|
||||
), # single box, no classes
|
||||
(
|
||||
does_not_raise(),
|
||||
"<|ref|>cat<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(1000, 1000),
|
||||
["cat", "dog"],
|
||||
Detections(
|
||||
xyxy=np.array([[100.1, 200.2, 300.3, 400.4]]),
|
||||
class_id=np.array([0]),
|
||||
data={CLASS_NAME_DATA_FIELD: np.array(["cat"])},
|
||||
),
|
||||
), # single box, with classes
|
||||
(
|
||||
does_not_raise(),
|
||||
"<|ref|>person<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(1000, 1000),
|
||||
["cat", "dog"],
|
||||
Detections.empty(),
|
||||
), # single box, wrong class
|
||||
(
|
||||
does_not_raise(),
|
||||
(
|
||||
"<|ref|>cat<|/ref|><|det|>[[100, 200, 300, 400]]<|/det|>"
|
||||
"<|ref|>dog<|/ref|><|det|>[[500, 600, 700, 800]]<|/det|>"
|
||||
),
|
||||
(1000, 1000),
|
||||
["cat"],
|
||||
Detections(
|
||||
xyxy=np.array([[100.1, 200.2, 300.3, 400.4]]),
|
||||
class_id=np.array([0]),
|
||||
data={CLASS_NAME_DATA_FIELD: np.array(["cat"])},
|
||||
),
|
||||
), # multiple boxes, one class correct
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"<|ref|>cat<|/ref|>",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # only ref
|
||||
(
|
||||
pytest.raises(ValueError),
|
||||
"<|det|>[[100, 200, 300, 400]]<|/det|>",
|
||||
(100, 100),
|
||||
None,
|
||||
None,
|
||||
), # only det
|
||||
],
|
||||
)
|
||||
def test_from_deepseek_vl_2(
|
||||
exception,
|
||||
result: str,
|
||||
resolution_wh: tuple[int, int],
|
||||
classes: list[str] | None,
|
||||
expected_detections: Detections,
|
||||
):
|
||||
with exception:
|
||||
detections = Detections.from_vlm(
|
||||
vlm=VLM.DEEPSEEK_VL_2,
|
||||
result=result,
|
||||
resolution_wh=resolution_wh,
|
||||
classes=classes,
|
||||
)
|
||||
|
||||
if expected_detections is None:
|
||||
return
|
||||
|
||||
assert len(detections) == len(expected_detections)
|
||||
|
||||
if len(detections) == 0:
|
||||
return
|
||||
|
||||
assert np.allclose(detections.xyxy, expected_detections.xyxy, atol=1e-1)
|
||||
assert np.array_equal(detections.class_id, expected_detections.class_id)
|
||||
assert np.array_equal(
|
||||
detections.data[CLASS_NAME_DATA_FIELD],
|
||||
expected_detections.data[CLASS_NAME_DATA_FIELD],
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack as DoesNotRaise
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.tools.inference_slicer import InferenceSlicer
|
||||
from supervision.detection.utils.iou_and_nms import OverlapFilter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
@ -20,54 +17,10 @@ def mock_callback():
|
|||
return callback
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"slice_wh, overlap_ratio_wh, overlap_wh, expected_overlap, exception",
|
||||
[
|
||||
# Valid case: explicit overlap_wh in pixels
|
||||
((128, 128), None, (26, 26), (26, 26), DoesNotRaise()),
|
||||
# Valid case: overlap_wh in pixels
|
||||
((128, 128), None, (20, 20), (20, 20), DoesNotRaise()),
|
||||
# Invalid case: negative overlap_wh, should raise ValueError
|
||||
((128, 128), None, (-10, 20), None, pytest.raises(ValueError)),
|
||||
# Invalid case: no overlaps defined
|
||||
((128, 128), None, None, None, pytest.raises(ValueError)),
|
||||
# Valid case: overlap_wh = 50 pixels
|
||||
((256, 256), None, (50, 50), (50, 50), DoesNotRaise()),
|
||||
# Valid case: overlap_wh = 60 pixels
|
||||
((200, 200), None, (60, 60), (60, 60), DoesNotRaise()),
|
||||
# Valid case: small overlap_wh values
|
||||
((100, 100), None, (0.1, 0.1), (0.1, 0.1), DoesNotRaise()),
|
||||
# Invalid case: negative overlap_wh values
|
||||
((128, 128), None, (-10, -10), None, pytest.raises(ValueError)),
|
||||
# Invalid case: overlap_wh greater than slice size
|
||||
((128, 128), None, (150, 150), (150, 150), DoesNotRaise()),
|
||||
# Valid case: zero overlap
|
||||
((128, 128), None, (0, 0), (0, 0), DoesNotRaise()),
|
||||
],
|
||||
)
|
||||
def test_inference_slicer_overlap(
|
||||
mock_callback,
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_ratio_wh: tuple[float, float] | None,
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
expected_overlap: tuple[int, int] | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
slicer = InferenceSlicer(
|
||||
callback=mock_callback,
|
||||
slice_wh=slice_wh,
|
||||
overlap_ratio_wh=overlap_ratio_wh,
|
||||
overlap_wh=overlap_wh,
|
||||
overlap_filter=OverlapFilter.NONE,
|
||||
)
|
||||
assert slicer.overlap_wh == expected_overlap
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"resolution_wh, slice_wh, overlap_wh, expected_offsets",
|
||||
[
|
||||
# Case 1: No overlap, exact slices fit within image dimensions
|
||||
# Case 1: Square image, square slices, no overlap
|
||||
(
|
||||
(256, 256),
|
||||
(128, 128),
|
||||
|
|
@ -81,7 +34,7 @@ def test_inference_slicer_overlap(
|
|||
]
|
||||
),
|
||||
),
|
||||
# Case 2: Overlap of 64 pixels in both directions
|
||||
# Case 2: Square image, square slices, non-zero overlap
|
||||
(
|
||||
(256, 256),
|
||||
(128, 128),
|
||||
|
|
@ -91,96 +44,154 @@ def test_inference_slicer_overlap(
|
|||
[0, 0, 128, 128],
|
||||
[64, 0, 192, 128],
|
||||
[128, 0, 256, 128],
|
||||
[192, 0, 256, 128],
|
||||
[0, 64, 128, 192],
|
||||
[64, 64, 192, 192],
|
||||
[128, 64, 256, 192],
|
||||
[192, 64, 256, 192],
|
||||
[0, 128, 128, 256],
|
||||
[64, 128, 192, 256],
|
||||
[128, 128, 256, 256],
|
||||
[192, 128, 256, 256],
|
||||
[0, 192, 128, 256],
|
||||
[64, 192, 192, 256],
|
||||
[128, 192, 256, 256],
|
||||
[192, 192, 256, 256],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 3: Image not perfectly divisible by slice size (no overlap)
|
||||
# Case 3: Rectangle image (horizontal), square slices, no overlap
|
||||
(
|
||||
(300, 300),
|
||||
(128, 128),
|
||||
(192, 128),
|
||||
(64, 64),
|
||||
(0, 0),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 128, 128],
|
||||
[128, 0, 256, 128],
|
||||
[256, 0, 300, 128],
|
||||
[0, 128, 128, 256],
|
||||
[128, 128, 256, 256],
|
||||
[256, 128, 300, 256],
|
||||
[0, 256, 128, 300],
|
||||
[128, 256, 256, 300],
|
||||
[256, 256, 300, 300],
|
||||
[0, 0, 64, 64],
|
||||
[64, 0, 128, 64],
|
||||
[128, 0, 192, 64],
|
||||
[0, 64, 64, 128],
|
||||
[64, 64, 128, 128],
|
||||
[128, 64, 192, 128],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 4: Overlap of 32 pixels, image not perfectly divisible by slice size
|
||||
# Case 4: Rectangle image (horizontal), square slices, non-zero overlap
|
||||
(
|
||||
(300, 300),
|
||||
(128, 128),
|
||||
(192, 128),
|
||||
(64, 64),
|
||||
(32, 32),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 128, 128],
|
||||
[96, 0, 224, 128],
|
||||
[192, 0, 300, 128],
|
||||
[288, 0, 300, 128],
|
||||
[0, 96, 128, 224],
|
||||
[96, 96, 224, 224],
|
||||
[192, 96, 300, 224],
|
||||
[288, 96, 300, 224],
|
||||
[0, 192, 128, 300],
|
||||
[96, 192, 224, 300],
|
||||
[192, 192, 300, 300],
|
||||
[288, 192, 300, 300],
|
||||
[0, 288, 128, 300],
|
||||
[96, 288, 224, 300],
|
||||
[192, 288, 300, 300],
|
||||
[288, 288, 300, 300],
|
||||
[0, 0, 64, 64],
|
||||
[32, 0, 96, 64],
|
||||
[64, 0, 128, 64],
|
||||
[96, 0, 160, 64],
|
||||
[128, 0, 192, 64],
|
||||
[0, 32, 64, 96],
|
||||
[32, 32, 96, 96],
|
||||
[64, 32, 128, 96],
|
||||
[96, 32, 160, 96],
|
||||
[128, 32, 192, 96],
|
||||
[0, 64, 64, 128],
|
||||
[32, 64, 96, 128],
|
||||
[64, 64, 128, 128],
|
||||
[96, 64, 160, 128],
|
||||
[128, 64, 192, 128],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 5: Image smaller than slice size (no overlap)
|
||||
# Case 5: Rectangle image (vertical), square slices, no overlap
|
||||
(
|
||||
(100, 100),
|
||||
(128, 128),
|
||||
(128, 192),
|
||||
(64, 64),
|
||||
(0, 0),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 100, 100],
|
||||
[0, 0, 64, 64],
|
||||
[64, 0, 128, 64],
|
||||
[0, 64, 64, 128],
|
||||
[64, 64, 128, 128],
|
||||
[0, 128, 64, 192],
|
||||
[64, 128, 128, 192],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 6: Rectangle image (vertical), square slices, non-zero overlap
|
||||
(
|
||||
(128, 192),
|
||||
(64, 64),
|
||||
(32, 32),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 64, 64],
|
||||
[32, 0, 96, 64],
|
||||
[64, 0, 128, 64],
|
||||
[0, 32, 64, 96],
|
||||
[32, 32, 96, 96],
|
||||
[64, 32, 128, 96],
|
||||
[0, 64, 64, 128],
|
||||
[32, 64, 96, 128],
|
||||
[64, 64, 128, 128],
|
||||
[0, 96, 64, 160],
|
||||
[32, 96, 96, 160],
|
||||
[64, 96, 128, 160],
|
||||
[0, 128, 64, 192],
|
||||
[32, 128, 96, 192],
|
||||
[64, 128, 128, 192],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 7: Square image, rectangular slices (horizontal), no overlap
|
||||
(
|
||||
(160, 160),
|
||||
(80, 40),
|
||||
(0, 0),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 80, 40],
|
||||
[80, 0, 160, 40],
|
||||
[0, 40, 80, 80],
|
||||
[80, 40, 160, 80],
|
||||
[0, 80, 80, 120],
|
||||
[80, 80, 160, 120],
|
||||
[0, 120, 80, 160],
|
||||
[80, 120, 160, 160],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 8: Square image, rectangular slices (vertical), non-zero overlap
|
||||
(
|
||||
(160, 160),
|
||||
(40, 80),
|
||||
(10, 20),
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 40, 80],
|
||||
[30, 0, 70, 80],
|
||||
[60, 0, 100, 80],
|
||||
[90, 0, 130, 80],
|
||||
[120, 0, 160, 80],
|
||||
[0, 60, 40, 140],
|
||||
[30, 60, 70, 140],
|
||||
[60, 60, 100, 140],
|
||||
[90, 60, 130, 140],
|
||||
[120, 60, 160, 140],
|
||||
[0, 80, 40, 160],
|
||||
[30, 80, 70, 160],
|
||||
[60, 80, 100, 160],
|
||||
[90, 80, 130, 160],
|
||||
[120, 80, 160, 160],
|
||||
]
|
||||
),
|
||||
),
|
||||
# Case 6: Overlap_wh is greater than the slice size
|
||||
((256, 256), (128, 128), (150, 150), np.array([]).reshape(0, 4)),
|
||||
],
|
||||
)
|
||||
def test_generate_offset(
|
||||
resolution_wh: tuple[int, int],
|
||||
slice_wh: tuple[int, int],
|
||||
overlap_wh: tuple[int, int] | None,
|
||||
overlap_wh: tuple[int, int],
|
||||
expected_offsets: np.ndarray,
|
||||
) -> None:
|
||||
offsets = InferenceSlicer._generate_offset(
|
||||
resolution_wh=resolution_wh,
|
||||
slice_wh=slice_wh,
|
||||
overlap_ratio_wh=None,
|
||||
overlap_wh=overlap_wh,
|
||||
)
|
||||
|
||||
# Verify that the generated offsets match the expected offsets
|
||||
assert np.array_equal(offsets, expected_offsets), (
|
||||
f"Expected {expected_offsets}, got {offsets}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ from contextlib import ExitStack as DoesNotRaise
|
|||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.utils.boxes import clip_boxes, move_boxes, scale_boxes
|
||||
from supervision.detection.utils.boxes import (
|
||||
clip_boxes,
|
||||
denormalize_boxes,
|
||||
move_boxes,
|
||||
scale_boxes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -142,3 +147,88 @@ def test_scale_boxes(
|
|||
with exception:
|
||||
result = scale_boxes(xyxy=xyxy, factor=factor)
|
||||
assert np.array_equal(result, expected_result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"xyxy, resolution_wh, normalization_factor, expected_result, exception",
|
||||
[
|
||||
(
|
||||
np.empty(shape=(0, 4)),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.empty(shape=(0, 4)),
|
||||
DoesNotRaise(),
|
||||
), # empty array
|
||||
(
|
||||
np.array([[0.1, 0.2, 0.5, 0.6]]),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.array([[128.0, 144.0, 640.0, 432.0]]),
|
||||
DoesNotRaise(),
|
||||
), # single box with default normalization
|
||||
(
|
||||
np.array([[0.1, 0.2, 0.5, 0.6], [0.3, 0.4, 0.7, 0.8]]),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.array([[128.0, 144.0, 640.0, 432.0], [384.0, 288.0, 896.0, 576.0]]),
|
||||
DoesNotRaise(),
|
||||
), # two boxes with default normalization
|
||||
(
|
||||
np.array(
|
||||
[[0.1, 0.2, 0.5, 0.6], [0.3, 0.4, 0.7, 0.8], [0.2, 0.1, 0.6, 0.5]]
|
||||
),
|
||||
(1280, 720),
|
||||
1.0,
|
||||
np.array(
|
||||
[
|
||||
[128.0, 144.0, 640.0, 432.0],
|
||||
[384.0, 288.0, 896.0, 576.0],
|
||||
[256.0, 72.0, 768.0, 360.0],
|
||||
]
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # three boxes - regression test for issue #1959
|
||||
(
|
||||
np.array([[10.0, 20.0, 50.0, 60.0]]),
|
||||
(100, 200),
|
||||
100.0,
|
||||
np.array([[10.0, 40.0, 50.0, 120.0]]),
|
||||
DoesNotRaise(),
|
||||
), # single box with custom normalization factor
|
||||
(
|
||||
np.array([[10.0, 20.0, 50.0, 60.0], [30.0, 40.0, 70.0, 80.0]]),
|
||||
(100, 200),
|
||||
100.0,
|
||||
np.array([[10.0, 40.0, 50.0, 120.0], [30.0, 80.0, 70.0, 160.0]]),
|
||||
DoesNotRaise(),
|
||||
), # two boxes with custom normalization factor
|
||||
(
|
||||
np.array([[0.0, 0.0, 1.0, 1.0]]),
|
||||
(1920, 1080),
|
||||
1.0,
|
||||
np.array([[0.0, 0.0, 1920.0, 1080.0]]),
|
||||
DoesNotRaise(),
|
||||
), # full frame box
|
||||
(
|
||||
np.array([[0.5, 0.5, 0.5, 0.5]]),
|
||||
(640, 480),
|
||||
1.0,
|
||||
np.array([[320.0, 240.0, 320.0, 240.0]]),
|
||||
DoesNotRaise(),
|
||||
), # zero-area box (point)
|
||||
],
|
||||
)
|
||||
def test_denormalize_boxes(
|
||||
xyxy: np.ndarray,
|
||||
resolution_wh: tuple[int, int],
|
||||
normalization_factor: float,
|
||||
expected_result: np.ndarray,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = denormalize_boxes(
|
||||
xyxy=xyxy,
|
||||
resolution_wh=resolution_wh,
|
||||
normalization_factor=normalization_factor,
|
||||
)
|
||||
assert np.allclose(result, expected_result)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
from supervision.detection.utils.converters import (
|
||||
xcycwh_to_xyxy,
|
||||
xywh_to_xyxy,
|
||||
xyxy_to_mask,
|
||||
xyxy_to_xcycarh,
|
||||
xyxy_to_xywh,
|
||||
)
|
||||
|
|
@ -129,3 +130,174 @@ def test_xyxy_to_xcycarh(xyxy: np.ndarray, expected_result: np.ndarray) -> None:
|
|||
def test_xcycwh_to_xyxy(xcycwh: np.ndarray, expected_result: np.ndarray) -> None:
|
||||
result = xcycwh_to_xyxy(xcycwh)
|
||||
np.testing.assert_array_equal(result, expected_result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boxes,resolution_wh,expected",
|
||||
[
|
||||
# 0) Empty input
|
||||
(
|
||||
np.array([], dtype=float).reshape(0, 4),
|
||||
(5, 4),
|
||||
np.array([], dtype=bool).reshape(0, 4, 5),
|
||||
),
|
||||
# 1) Single pixel box
|
||||
(
|
||||
np.array([[2, 1, 2, 1]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, True, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 2) Horizontal line, inclusive bounds
|
||||
(
|
||||
np.array([[1, 2, 3, 2]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, True, True, True, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 3) Vertical line, inclusive bounds
|
||||
(
|
||||
np.array([[3, 0, 3, 2]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, True, False],
|
||||
[False, False, False, True, False],
|
||||
[False, False, False, True, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 4) Proper rectangle fill
|
||||
(
|
||||
np.array([[1, 1, 3, 2]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, True, True, True, False],
|
||||
[False, True, True, True, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 5) Negative coordinates clipped to [0, 0]
|
||||
(
|
||||
np.array([[-2, -1, 1, 1]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[True, True, False, False, False],
|
||||
[True, True, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 6) Overflow coordinates clipped to width-1 and height-1
|
||||
(
|
||||
np.array([[3, 2, 10, 10]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, True, True],
|
||||
[False, False, False, True, True],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 7) Invalid box where max < min after ints, mask stays empty
|
||||
(
|
||||
np.array([[3, 2, 1, 4]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 8) Fractional coordinates are floored by int conversion
|
||||
# (0.2,0.2)-(2.8,1.9) -> (0,0)-(2,1)
|
||||
(
|
||||
np.array([[0.2, 0.2, 2.8, 1.9]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
[
|
||||
[True, True, True, False, False],
|
||||
[True, True, True, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
]
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
# 9) Multiple boxes, separate masks
|
||||
(
|
||||
np.array([[0, 0, 1, 0], [2, 1, 4, 3]], dtype=float),
|
||||
(5, 4),
|
||||
np.array(
|
||||
[
|
||||
# Box 0: row 0, cols 0..1
|
||||
[
|
||||
[True, True, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
[False, False, False, False, False],
|
||||
],
|
||||
# Box 1: rows 1..3, cols 2..4
|
||||
[
|
||||
[False, False, False, False, False],
|
||||
[False, False, True, True, True],
|
||||
[False, False, True, True, True],
|
||||
[False, False, True, True, True],
|
||||
],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) -> None:
|
||||
result = xyxy_to_mask(boxes, resolution_wh)
|
||||
assert result.dtype == np.bool_
|
||||
assert result.shape == expected.shape
|
||||
np.testing.assert_array_equal(result, expected)
|
||||
|
|
|
|||
|
|
@ -6,11 +6,15 @@ import numpy as np
|
|||
import pytest
|
||||
|
||||
from supervision.detection.utils.iou_and_nms import (
|
||||
OverlapMetric,
|
||||
_group_overlapping_boxes,
|
||||
box_iou,
|
||||
box_iou_batch,
|
||||
box_non_max_suppression,
|
||||
mask_non_max_merge,
|
||||
mask_non_max_suppression,
|
||||
)
|
||||
from test.test_utils import random_boxes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -631,3 +635,497 @@ def test_mask_non_max_merge(
|
|||
sorted_result = sorted([sorted(group) for group in result])
|
||||
sorted_expected_result = sorted([sorted(group) for group in expected_result])
|
||||
assert sorted_result == sorted_expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"box_true, box_detection, overlap_metric, expected_overlap, exception",
|
||||
[
|
||||
(
|
||||
[100.0, 100.0, 200.0, 200.0],
|
||||
[150.0, 150.0, 250.0, 250.0],
|
||||
OverlapMetric.IOU,
|
||||
0.14285714285714285,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap, IOU
|
||||
(
|
||||
[100.0, 100.0, 200.0, 200.0],
|
||||
[150.0, 150.0, 250.0, 250.0],
|
||||
OverlapMetric.IOS,
|
||||
0.25,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap, IOS
|
||||
(
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOU
|
||||
(
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
np.array([0.0, 0.0, 10.0, 10.0], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOS
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"iou",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOU as lowercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"ios",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOS as lowercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"IOU",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOU as uppercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
"IOU",
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # identical boxes, both boxes are arrays, IOS as uppercase string
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[20.0, 20.0, 30.0, 30.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # no overlap, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[20.0, 20.0, 30.0, 30.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # no overlap, IOS
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 0.0, 20.0, 10.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # boxes touch at edge, zero intersection, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 0.0, 20.0, 10.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # boxes touch at edge, zero intersection, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[2.0, 2.0, 8.0, 8.0],
|
||||
OverlapMetric.IOU,
|
||||
0.36,
|
||||
DoesNotRaise(),
|
||||
), # one box inside another, IOU
|
||||
(
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[2.0, 2.0, 8.0, 8.0],
|
||||
OverlapMetric.IOS,
|
||||
1.0,
|
||||
DoesNotRaise(),
|
||||
), # one box inside another, IOS
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # degenerate true box with zero area, IOU
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # degenerate true box with zero area, IOS
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
OverlapMetric.IOU,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # both boxes fully degenerate, IOU
|
||||
(
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 0.0],
|
||||
OverlapMetric.IOS,
|
||||
0.0,
|
||||
DoesNotRaise(),
|
||||
), # both boxes fully degenerate, IOS
|
||||
(
|
||||
[-5.0, 0.0, 5.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOU,
|
||||
1.0 / 3.0,
|
||||
DoesNotRaise(),
|
||||
), # negative x_min, overlapping boxes, IOU is 1/3
|
||||
(
|
||||
[-5.0, 0.0, 5.0, 10.0],
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
OverlapMetric.IOS,
|
||||
0.5,
|
||||
DoesNotRaise(),
|
||||
), # negative x_min, overlapping boxes, IOS is 0.5
|
||||
(
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 1.5, 1.5],
|
||||
OverlapMetric.IOU,
|
||||
0.14285714285714285,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap with fractional coordinates, IOU
|
||||
(
|
||||
[0.0, 0.0, 1.0, 1.0],
|
||||
[0.5, 0.5, 1.5, 1.5],
|
||||
OverlapMetric.IOS,
|
||||
0.25,
|
||||
DoesNotRaise(),
|
||||
), # partial overlap with fractional coordinates, IOS
|
||||
],
|
||||
)
|
||||
def test_box_iou(
|
||||
box_true: list[float] | np.ndarray,
|
||||
box_detection: list[float] | np.ndarray,
|
||||
overlap_metric: str | OverlapMetric,
|
||||
expected_overlap: float,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = box_iou(
|
||||
box_true=box_true,
|
||||
box_detection=box_detection,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
assert result == pytest.approx(expected_overlap, rel=1e-6, abs=1e-12)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boxes_true, boxes_detection, overlap_metric, expected_overlap, exception",
|
||||
[
|
||||
# both inputs empty
|
||||
(
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.empty((0, 0), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# one true box, no detections
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.empty((1, 0), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# no true boxes, one detection
|
||||
(
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.empty((0, 1), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 partial overlap, IOU
|
||||
(
|
||||
np.array([[100.0, 100.0, 200.0, 200.0]], dtype=np.float32),
|
||||
np.array([[150.0, 150.0, 250.0, 250.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.14285715]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 partial overlap, IOS
|
||||
(
|
||||
np.array([[100.0, 100.0, 200.0, 200.0]], dtype=np.float32),
|
||||
np.array([[150.0, 150.0, 250.0, 250.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.25]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOU as lowercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"iou",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOS as lowercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"ios",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOU as uppercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"IOU",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 identical boxes, IOS as uppercase string
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"IOS",
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 no overlap, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[20.0, 20.0, 30.0, 30.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 no overlap, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[20.0, 20.0, 30.0, 30.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 touching at edge, zero intersection, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[10.0, 0.0, 20.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 touching at edge, zero intersection, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[10.0, 0.0, 20.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 box inside another, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[2.0, 2.0, 8.0, 8.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.36]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 box inside another, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[2.0, 2.0, 8.0, 8.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[1.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 degenerate true box, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 degenerate true box, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 both boxes degenerate, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 both boxes degenerate, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 0.0, 0.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 negative coordinate, partial overlap, IOU
|
||||
(
|
||||
np.array([[-5.0, 0.0, 5.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[1.0 / 3.0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 negative coordinate, partial overlap, IOS
|
||||
(
|
||||
np.array([[-5.0, 0.0, 5.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.5]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 fractional coordinates, partial overlap, IOU
|
||||
(
|
||||
np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float32),
|
||||
np.array([[0.5, 0.5, 1.5, 1.5]], dtype=np.float32),
|
||||
OverlapMetric.IOU,
|
||||
np.array([[0.14285715]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# 1x1 fractional coordinates, partial overlap, IOS
|
||||
(
|
||||
np.array([[0.0, 0.0, 1.0, 1.0]], dtype=np.float32),
|
||||
np.array([[0.5, 0.5, 1.5, 1.5]], dtype=np.float32),
|
||||
OverlapMetric.IOS,
|
||||
np.array([[0.25]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# true batch case, 2x2, IOU
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 10.0, 20.0, 20.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[5.0, 5.0, 15.0, 15.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
OverlapMetric.IOU,
|
||||
np.array(
|
||||
[
|
||||
[1.0, 0.14285715],
|
||||
[0.0, 0.14285715],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# true batch case, 2x2, IOS
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[10.0, 10.0, 20.0, 20.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[
|
||||
[0.0, 0.0, 10.0, 10.0],
|
||||
[5.0, 5.0, 15.0, 15.0],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
OverlapMetric.IOS,
|
||||
np.array(
|
||||
[
|
||||
[1.0, 0.25],
|
||||
[0.0, 0.25],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# invalid overlap_metric
|
||||
(
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
np.array([[0.0, 0.0, 10.0, 10.0]], dtype=np.float32),
|
||||
"invalid",
|
||||
None,
|
||||
pytest.raises(ValueError),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_box_iou_batch(
|
||||
boxes_true: np.ndarray,
|
||||
boxes_detection: np.ndarray,
|
||||
overlap_metric: str | OverlapMetric,
|
||||
expected_overlap: np.ndarray | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = box_iou_batch(
|
||||
boxes_true=boxes_true,
|
||||
boxes_detection=boxes_detection,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == expected_overlap.shape
|
||||
assert np.allclose(
|
||||
result,
|
||||
expected_overlap,
|
||||
rtol=1e-6,
|
||||
atol=1e-12,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_true, num_det",
|
||||
[
|
||||
(5, 5),
|
||||
(5, 10),
|
||||
(10, 5),
|
||||
(10, 10),
|
||||
(20, 30),
|
||||
(30, 20),
|
||||
(50, 50),
|
||||
(100, 100),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"overlap_metric",
|
||||
[OverlapMetric.IOU, OverlapMetric.IOS],
|
||||
)
|
||||
def test_box_iou_batch_symmetric_large(
|
||||
num_true: int,
|
||||
num_det: int,
|
||||
overlap_metric: OverlapMetric,
|
||||
) -> None:
|
||||
boxes_true = random_boxes(num_true)
|
||||
boxes_det = random_boxes(num_det)
|
||||
|
||||
result_ab = box_iou_batch(
|
||||
boxes_true=boxes_true,
|
||||
boxes_detection=boxes_det,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
result_ba = box_iou_batch(
|
||||
boxes_true=boxes_det,
|
||||
boxes_detection=boxes_true,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
|
||||
assert result_ab.shape == (num_true, num_det)
|
||||
assert result_ba.shape == (num_det, num_true)
|
||||
assert np.allclose(
|
||||
result_ab,
|
||||
result_ba.T,
|
||||
rtol=1e-6,
|
||||
atol=1e-12,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from supervision.detection.utils.masks import (
|
|||
calculate_masks_centroids,
|
||||
contains_holes,
|
||||
contains_multiple_segments,
|
||||
filter_segments_by_distance,
|
||||
move_masks,
|
||||
)
|
||||
|
||||
|
|
@ -500,3 +501,228 @@ def test_contains_multiple_segments(
|
|||
with exception:
|
||||
result = contains_multiple_segments(mask=mask, connectivity=connectivity)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mask, connectivity, mode, absolute_distance, relative_distance, expected_result, exception", # noqa: E501
|
||||
[
|
||||
# single component, unchanged
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0,
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# two components, edge distance 2, kept with abs=1
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0,
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 1, 1, 1, 0, 1],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# centroid mode, far centroids, dropped with small relative threshold
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"centroid",
|
||||
None,
|
||||
0.3, # diagonal ~8.49, threshold ~2.55, centroid gap ~4.24
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# centroid mode, larger relative threshold, kept
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"centroid",
|
||||
None,
|
||||
0.6, # diagonal ~8.49, threshold ~5.09, centroid gap ~4.24
|
||||
np.array(
|
||||
[
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[1, 1, 1, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
[0, 0, 0, 1, 1, 1],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# empty mask
|
||||
(
|
||||
np.zeros((4, 4), dtype=bool),
|
||||
4,
|
||||
"edge",
|
||||
2.0,
|
||||
None,
|
||||
np.zeros((4, 4), dtype=bool),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# full mask
|
||||
(
|
||||
np.ones((4, 4), dtype=bool),
|
||||
8,
|
||||
"centroid",
|
||||
None,
|
||||
0.2,
|
||||
np.ones((4, 4), dtype=bool),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# two components, pixel distance = 2, kept with abs=2
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0, # was 1.0
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 1, 1, 1, 0, 1, 1, 1],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
# two components, pixel distance = 3, dropped with abs=2
|
||||
(
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[0, 1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[0, 1, 1, 1, 0, 0, 0, 1, 1],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
8,
|
||||
"edge",
|
||||
2.0, # keep threshold below 3 so the right blob is removed
|
||||
None,
|
||||
np.array(
|
||||
[
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 1, 1, 1, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
[0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
],
|
||||
dtype=bool,
|
||||
),
|
||||
DoesNotRaise(),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_filter_segments_by_distance_sweep(
|
||||
mask: npt.NDArray,
|
||||
connectivity: int,
|
||||
mode: str,
|
||||
absolute_distance: float | None,
|
||||
relative_distance: float | None,
|
||||
expected_result: npt.NDArray | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
with exception:
|
||||
result = filter_segments_by_distance(
|
||||
mask=mask,
|
||||
connectivity=connectivity,
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
absolute_distance=absolute_distance,
|
||||
relative_distance=relative_distance,
|
||||
)
|
||||
assert np.array_equal(result, expected_result)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
import pytest
|
||||
|
||||
from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"string_1, string_2, case_sensitive, expected_result",
|
||||
[
|
||||
# identical strings, various cases
|
||||
("hello", "hello", True, 0),
|
||||
("hello", "hello", False, 0),
|
||||
# case sensitive vs insensitive
|
||||
("Test", "test", True, 1),
|
||||
("Test", "test", False, 0),
|
||||
("CASE", "case", True, 4),
|
||||
("CASE", "case", False, 0),
|
||||
# completely different
|
||||
("abc", "xyz", True, 3),
|
||||
("abc", "xyz", False, 3),
|
||||
# one string empty
|
||||
("hello", "", True, 5),
|
||||
("", "world", True, 5),
|
||||
# single character cases
|
||||
("a", "b", True, 1),
|
||||
("A", "a", True, 1),
|
||||
("A", "a", False, 0),
|
||||
# whitespaces
|
||||
("hello world", "helloworld", True, 1),
|
||||
("test", " test", True, 1),
|
||||
# unicode and emoji
|
||||
("😊", "😊", True, 0),
|
||||
("😊", "😢", True, 1),
|
||||
# long string vs empty
|
||||
("a" * 100, "", True, 100),
|
||||
("", "b" * 100, True, 100),
|
||||
# prefix/suffix
|
||||
("prefix", "prefixes", True, 2),
|
||||
("suffix", "asuffix", True, 1),
|
||||
# leading/trailing whitespace
|
||||
(" hello", "hello", True, 1),
|
||||
("hello", "hello ", True, 1),
|
||||
# long almost-equal string
|
||||
(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"The quick brown fox jumps over the lazy cog",
|
||||
True,
|
||||
1,
|
||||
),
|
||||
(
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"The quick brown fox jumps over the lazy cog",
|
||||
False,
|
||||
1,
|
||||
),
|
||||
# both empty
|
||||
("", "", True, 0),
|
||||
("", "", False, 0),
|
||||
# mixed case with symbols
|
||||
("123ABC!", "123abc!", True, 3),
|
||||
("123ABC!", "123abc!", False, 0),
|
||||
],
|
||||
)
|
||||
def test_edit_distance(string_1, string_2, case_sensitive, expected_result):
|
||||
assert (
|
||||
edit_distance(string_1, string_2, case_sensitive=case_sensitive)
|
||||
== expected_result
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidates, query, threshold, case_sensitive, expected_result",
|
||||
[
|
||||
# exact match at index 0
|
||||
(["cat", "dog", "rat"], "cat", 0, True, 0),
|
||||
# match at index 2 within threshold
|
||||
(["cat", "dog", "rat"], "dat", 1, True, 0),
|
||||
# no match due to high threshold
|
||||
(["cat", "dog", "rat"], "bat", 0, True, None),
|
||||
# multiple possible matches, returns first
|
||||
(["apple", "apply", "appla"], "apple", 1, True, 0),
|
||||
# case-insensitive match
|
||||
(["Alpha", "beta", "Gamma"], "alpha", 0, False, 0),
|
||||
# case-sensitive: no match
|
||||
(["Alpha", "beta", "Gamma"], "alpha", 0, True, None),
|
||||
# threshold boundary
|
||||
(["alpha", "beta", "gamma"], "bata", 1, True, 1),
|
||||
# no match (all distances too high)
|
||||
(["one", "two", "three"], "ten", 1, True, None),
|
||||
# unicode/emoji match
|
||||
(["😊", "😢", "😁"], "😄", 1, True, 0),
|
||||
(["😊", "😢", "😁"], "😊", 0, True, 0),
|
||||
# empty candidates
|
||||
([], "any", 2, True, None),
|
||||
# empty query, non-empty candidates
|
||||
(["", "abc"], "", 0, True, 0),
|
||||
(["", "abc"], "", 1, True, 0),
|
||||
(["a", "b", "c"], "", 1, True, 0),
|
||||
# non-empty query, empty candidate
|
||||
(["", ""], "a", 1, True, 0),
|
||||
# all candidates require higher edit than threshold
|
||||
(["short", "words", "only"], "longerword", 2, True, None),
|
||||
# repeated candidates
|
||||
(["a", "a", "a"], "b", 1, True, 0),
|
||||
],
|
||||
)
|
||||
def test_fuzzy_match_index(
|
||||
candidates, query, threshold, case_sensitive, expected_result
|
||||
):
|
||||
assert (
|
||||
fuzzy_match_index(
|
||||
candidates=candidates,
|
||||
query=query,
|
||||
threshold=threshold,
|
||||
case_sensitive=case_sensitive,
|
||||
)
|
||||
== expected_result
|
||||
)
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
from contextlib import nullcontext as DoesNotRaise
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.key_points.core import KeyPoints
|
||||
from test.test_utils import mock_key_points
|
||||
|
||||
KEY_POINTS = mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
[[20, 21], [22, 23], [24, 25], [26, 27], [28, 29]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
[0.1, 0.6, 0.8, 0.2, 0.7],
|
||||
],
|
||||
class_id=[0, 1, 2],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key_points, index, expected_result, exception",
|
||||
[
|
||||
(
|
||||
KeyPoints.empty(),
|
||||
slice(None),
|
||||
KeyPoints.empty(),
|
||||
DoesNotRaise(),
|
||||
), # slice all key points when key points object empty
|
||||
(
|
||||
KEY_POINTS,
|
||||
slice(None),
|
||||
KEY_POINTS,
|
||||
DoesNotRaise(),
|
||||
), # slice all key points when key points object nonempty
|
||||
(
|
||||
KEY_POINTS,
|
||||
slice(0, 1),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]],
|
||||
confidence=[[0.8, 0.2, 0.6, 0.1, 0.5]],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first skeleton by slice
|
||||
(
|
||||
KEY_POINTS,
|
||||
slice(0, 2),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first skeleton by slice
|
||||
(
|
||||
KEY_POINTS,
|
||||
0,
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]]],
|
||||
confidence=[[0.8, 0.2, 0.6, 0.1, 0.5]],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
-1,
|
||||
mock_key_points(
|
||||
xy=[[[20, 21], [22, 23], [24, 25], [26, 27], [28, 29]]],
|
||||
confidence=[[0.1, 0.6, 0.8, 0.2, 0.7]],
|
||||
class_id=[2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the last skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
[0, 1],
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two skeletons by index; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
np.array([0, 1]),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two skeletons by index; np.array
|
||||
(
|
||||
KEY_POINTS,
|
||||
[True, True, False],
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only skeletons associated with positive filter; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
np.array([True, True, False]),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7], [8, 9]],
|
||||
[[10, 11], [12, 13], [14, 15], [16, 17], [18, 19]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8, 0.2, 0.6, 0.1, 0.5],
|
||||
[0.7, 0.9, 0.3, 0.4, 0.0],
|
||||
],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only skeletons associated with positive filter; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), slice(None)),
|
||||
KEY_POINTS,
|
||||
DoesNotRaise(),
|
||||
), # slice all anchors from all skeletons
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), slice(0, 1)),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1]], [[10, 11]], [[20, 21]]],
|
||||
confidence=[[0.8], [0.7], [0.1]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # slice the first anchor from every skeleton
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), slice(0, 2)),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # slice the first anchor two anchors from every skeleton
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), 0),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1]], [[10, 11]], [[20, 21]]],
|
||||
confidence=[[0.8], [0.7], [0.1]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first anchor from every skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), -1),
|
||||
mock_key_points(
|
||||
xy=[[[8, 9]], [[18, 19]], [[28, 29]]],
|
||||
confidence=[[0.5], [0.0], [0.7]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the last anchor from every skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), [0, 1]),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two anchors from every skeleton by index; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), np.array([0, 1])),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first two anchors from every skeleton by index; np.array
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), [True, True, False, False, False]),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only anchors associated with positive filter; list
|
||||
(
|
||||
KEY_POINTS,
|
||||
(slice(None), np.array([True, True, False, False, False])),
|
||||
mock_key_points(
|
||||
xy=[[[0, 1], [2, 3]], [[10, 11], [12, 13]], [[20, 21], [22, 23]]],
|
||||
confidence=[[0.8, 0.2], [0.7, 0.9], [0.1, 0.6]],
|
||||
class_id=[0, 1, 2],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select only anchors associated with positive filter; np.array
|
||||
(
|
||||
KEY_POINTS,
|
||||
(0, 0),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[0, 1]],
|
||||
],
|
||||
confidence=[
|
||||
[0.8],
|
||||
],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the first anchor from the first skeleton by index
|
||||
(
|
||||
KEY_POINTS,
|
||||
(0, -1),
|
||||
mock_key_points(
|
||||
xy=[
|
||||
[[8, 9]],
|
||||
],
|
||||
confidence=[
|
||||
[0.5],
|
||||
],
|
||||
class_id=[0],
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # select the last anchor from the first skeleton by index
|
||||
],
|
||||
)
|
||||
def test_key_points_getitem(key_points, index, expected_result, exception):
|
||||
with exception:
|
||||
result = key_points[index]
|
||||
assert result == expected_result
|
||||
|
|
@ -1,16 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.keypoint.core import KeyPoints
|
||||
from supervision.key_points.core import KeyPoints
|
||||
|
||||
|
||||
def mock_detections(
|
||||
xyxy: npt.NDArray[np.float32],
|
||||
xyxy: list[list[float]],
|
||||
mask: list[np.ndarray] | None = None,
|
||||
confidence: list[float] | None = None,
|
||||
class_id: list[int] | None = None,
|
||||
|
|
@ -34,9 +34,9 @@ def mock_detections(
|
|||
)
|
||||
|
||||
|
||||
def mock_keypoints(
|
||||
xy: npt.NDArray[np.float32],
|
||||
confidence: list[float] | None = None,
|
||||
def mock_key_points(
|
||||
xy: list[list[list[float]]],
|
||||
confidence: list[list[float]] | None = None,
|
||||
class_id: list[int] | None = None,
|
||||
data: dict[str, list[Any]] | None = None,
|
||||
) -> KeyPoints:
|
||||
|
|
@ -53,5 +53,49 @@ def mock_keypoints(
|
|||
)
|
||||
|
||||
|
||||
def random_boxes(
|
||||
count: int,
|
||||
image_size: tuple[int, int] = (1920, 1080),
|
||||
min_box_size: int = 20,
|
||||
max_box_size: int = 200,
|
||||
seed: int | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Generate random bounding boxes within given image dimensions and size constraints.
|
||||
|
||||
Creates `count` bounding boxes randomly positioned and sized, ensuring each
|
||||
stays within image bounds and has width and height in the specified range.
|
||||
|
||||
Args:
|
||||
count (`int`): Number of random bounding boxes to generate.
|
||||
image_size (`tuple[int, int]`): Image size as `(width, height)`.
|
||||
min_box_size (`int`): Minimum side length (pixels) for generated boxes.
|
||||
max_box_size (`int`): Maximum side length (pixels) for generated boxes.
|
||||
seed (`int` or `None`): Optional random seed for reproducibility.
|
||||
|
||||
Returns:
|
||||
(`numpy.ndarray`): Array of shape `(count, 4)` with bounding boxes as
|
||||
`(x_min, y_min, x_max, y_max)`.
|
||||
"""
|
||||
if seed is not None:
|
||||
random.seed(seed)
|
||||
|
||||
img_w, img_h = image_size
|
||||
out = np.zeros((count, 4), dtype=np.float32)
|
||||
|
||||
for i in range(count):
|
||||
w = random.uniform(min_box_size, max_box_size)
|
||||
h = random.uniform(min_box_size, max_box_size)
|
||||
|
||||
x_min = random.uniform(0, img_w - w)
|
||||
y_min = random.uniform(0, img_h - h)
|
||||
x_max = x_min + w
|
||||
y_max = y_min + h
|
||||
|
||||
out[i] = (x_min, y_min, x_max, y_max)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def assert_almost_equal(actual, expected, tolerance=1e-5):
|
||||
assert abs(actual - expected) < tolerance, f"Expected {expected}, but got {actual}."
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from PIL import Image, ImageChops
|
|||
|
||||
from supervision.utils.conversion import (
|
||||
cv2_to_pillow,
|
||||
ensure_cv2_image_for_processing,
|
||||
ensure_cv2_image_for_standalone_function,
|
||||
images_to_cv2,
|
||||
pillow_to_cv2,
|
||||
)
|
||||
|
|
@ -16,7 +16,7 @@ def test_ensure_cv2_image_for_processing_when_pillow_image_submitted(
|
|||
param_a_value = 3
|
||||
param_b_value = "some"
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def my_custom_processing_function(
|
||||
image: np.ndarray,
|
||||
param_a: int,
|
||||
|
|
@ -55,7 +55,7 @@ def test_ensure_cv2_image_for_processing_when_cv2_image_submitted(
|
|||
param_a_value = 3
|
||||
param_b_value = "some"
|
||||
|
||||
@ensure_cv2_image_for_processing
|
||||
@ensure_cv2_image_for_standalone_function
|
||||
def my_custom_processing_function(
|
||||
image: np.ndarray,
|
||||
param_a: int,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import numpy as np
|
|||
import pytest
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from supervision import Color, Point
|
||||
from supervision.utils.image import create_tiles, letterbox_image, resize_image
|
||||
from supervision.utils.image import (
|
||||
crop_image,
|
||||
get_image_resolution_wh,
|
||||
letterbox_image,
|
||||
resize_image,
|
||||
)
|
||||
|
||||
|
||||
def test_resize_image_for_opencv_image() -> None:
|
||||
|
|
@ -98,145 +102,59 @@ def test_letterbox_image_for_pillow_image() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_create_tiles_with_one_image(
|
||||
one_image: np.ndarray, single_image_tile: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=[one_image], single_tile_size=(240, 240))
|
||||
|
||||
# # then
|
||||
assert np.allclose(result, single_image_tile, atol=5.0)
|
||||
@pytest.mark.parametrize(
|
||||
"image, xyxy, expected_size",
|
||||
[
|
||||
# NumPy RGB
|
||||
(
|
||||
np.zeros((4, 6, 3), dtype=np.uint8),
|
||||
(2, 1, 5, 3),
|
||||
(3, 2), # width = 5-2, height = 3-1
|
||||
),
|
||||
# NumPy grayscale
|
||||
(
|
||||
np.zeros((5, 5), dtype=np.uint8),
|
||||
(1, 1, 4, 4),
|
||||
(3, 3),
|
||||
),
|
||||
# Pillow RGB
|
||||
(
|
||||
Image.new("RGB", (6, 4), color=0),
|
||||
(2, 1, 5, 3),
|
||||
(3, 2),
|
||||
),
|
||||
# Pillow grayscale
|
||||
(
|
||||
Image.new("L", (5, 5), color=0),
|
||||
(1, 1, 4, 4),
|
||||
(3, 3),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_crop_image(image, xyxy, expected_size):
|
||||
cropped = crop_image(image=image, xyxy=xyxy)
|
||||
if isinstance(image, np.ndarray):
|
||||
assert isinstance(cropped, np.ndarray)
|
||||
assert cropped.shape[1] == expected_size[0] # width
|
||||
assert cropped.shape[0] == expected_size[1] # height
|
||||
else:
|
||||
assert isinstance(cropped, Image.Image)
|
||||
assert cropped.size == expected_size
|
||||
|
||||
|
||||
def test_create_tiles_with_one_image_and_enforced_grid(
|
||||
one_image: np.ndarray, single_image_tile_enforced_grid: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=[one_image],
|
||||
grid_size=(None, 3),
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, single_image_tile_enforced_grid, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_two_images(
|
||||
two_images: list[np.ndarray], two_images_tile: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=two_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, two_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_three_images(
|
||||
three_images: list[np.ndarray], three_images_tile: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=three_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, three_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_four_images(
|
||||
four_images: list[np.ndarray],
|
||||
four_images_tile: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=four_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, four_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images(
|
||||
all_images: list[np.ndarray],
|
||||
all_images_tile: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(images=all_images, single_tile_size=(240, 240))
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_custom_grid(
|
||||
all_images: list[np.ndarray], all_images_tile_and_custom_grid: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
grid_size=(3, 3),
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_custom_grid, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_custom_colors(
|
||||
all_images: list[np.ndarray], all_images_tile_and_custom_colors: np.ndarray
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
tile_margin_color=(127, 127, 127),
|
||||
tile_padding_color=(224, 224, 224),
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_custom_colors, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_titles(
|
||||
all_images: list[np.ndarray],
|
||||
all_images_tile_and_custom_colors_and_titles: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
titles=["Image 1", None, "Image 3", "Image 4"],
|
||||
single_tile_size=(240, 240),
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_custom_colors_and_titles, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_titles_with_custom_configs(
|
||||
all_images: list[np.ndarray],
|
||||
all_images_tile_and_titles_with_custom_configs: np.ndarray,
|
||||
) -> None:
|
||||
# when
|
||||
result = create_tiles(
|
||||
images=all_images,
|
||||
titles=["Image 1", None, "Image 3", "Image 4"],
|
||||
single_tile_size=(240, 240),
|
||||
titles_anchors=[
|
||||
Point(x=200, y=300),
|
||||
Point(x=300, y=400),
|
||||
None,
|
||||
Point(x=300, y=400),
|
||||
],
|
||||
titles_color=Color.RED,
|
||||
titles_scale=1.5,
|
||||
titles_thickness=3,
|
||||
titles_padding=20,
|
||||
titles_background_color=Color.BLACK,
|
||||
default_title_placement="bottom",
|
||||
)
|
||||
|
||||
# then
|
||||
assert np.allclose(result, all_images_tile_and_titles_with_custom_configs, atol=5.0)
|
||||
|
||||
|
||||
def test_create_tiles_with_all_images_and_custom_grid_to_small_to_fit_images(
|
||||
all_images: list[np.ndarray],
|
||||
) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_ = create_tiles(images=all_images, grid_size=(2, 2))
|
||||
@pytest.mark.parametrize(
|
||||
"image, expected",
|
||||
[
|
||||
# NumPy RGB
|
||||
(np.zeros((4, 6, 3), dtype=np.uint8), (6, 4)),
|
||||
# NumPy grayscale
|
||||
(np.zeros((10, 20), dtype=np.uint8), (20, 10)),
|
||||
# Pillow RGB
|
||||
(Image.new("RGB", (6, 4), color=0), (6, 4)),
|
||||
# Pillow grayscale
|
||||
(Image.new("L", (20, 10), color=0), (20, 10)),
|
||||
],
|
||||
)
|
||||
def test_get_image_resolution_wh(image, expected):
|
||||
resolution = get_image_resolution_wh(image)
|
||||
assert resolution == expected
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class MockDataclass:
|
|||
"metadata",
|
||||
"area",
|
||||
"box_area",
|
||||
"box_aspect_ratio",
|
||||
},
|
||||
DoesNotRaise(),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue