Skip to content

Commit bcb37c2

Browse files
authored
Merge branch 'develop' into feat/zone-coord-transform
2 parents 981e090 + 30354bf commit bcb37c2

4 files changed

Lines changed: 88 additions & 11 deletions

File tree

‎docs/changelog.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ date_modified: 2026-10-16
1111

1212
- Transformed `sv.PolygonZone` anchors just outside the mask remain outside even when rounding would move a fractional coordinate onto the edge pixel. ([#2657](https://github.com/roboflow/supervision/pull/2657))
1313

14+
- `sv.Detections.from_ultralytics` now removes letterbox padding using the rounded content dimensions. Fractional padding no longer shifts or truncates segmentation masks when restoring the original image resolution. ([#2739](https://github.com/roboflow/supervision/pull/2739))
15+
1416
- `sv.Detections` indexed with a Python list of booleans, such as `detections[[c == 0 for c in detections.class_id]]`, now selects list-valued `data` with the same mask as its arrays. The list was read as positions, so `True` and `False` became the indices `1` and `0`: an all-`True` mask returned the second item for every row (`["cat", "dog", "bird"]` became `["dog", "dog", "dog"]`), any other mask failed with `ValueError: Length of list for key ... must be ...`, and a list of `np.bool_` values raised `TypeError`. Integer lists, slices and NumPy index arrays are unchanged. ([#2737](https://github.com/roboflow/supervision/pull/2737)) Integer-list selection allocates only for requested rows, avoiding quadratic position-array allocation when the smoother selects rows repeatedly.
1517

1618
- `sv.move_boxes` and `InferenceSlicer` translations no longer wrap integer coordinates when an offset crosses the input dtype's bounds. A `uint8` box at `[250, 0, 255, 10]` shifted by `[10, 0]` previously became `[4, 0, 9, 10]`; translations now promote integer results to `int64`, or `float64` when the result exceeds the `int64` range, while floating-point behavior is unchanged. ([#2734](https://github.com/roboflow/supervision/pull/2734))

‎src/supervision/detection/utils/internal.py‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ def extract_ultralytics_masks(yolov8_results: Any) -> npt.NDArray[np.bool_] | No
4242
"""Extract boolean masks from Ultralytics results, cropping letterbox padding.
4343
4444
Handles the case where the inference resolution differs from the original image
45-
shape by computing the letterbox padding offsets, cropping them out, and resizing
46-
each proto mask back to `orig_shape`. Thresholds at 0.5 to match Ultralytics'
47-
semantics and avoid dilating masks through float interpolation.
45+
shape by recovering the rounded letterbox content size, cropping its padding,
46+
and resizing each proto mask back to `orig_shape`. Thresholds at 0.5 to match
47+
Ultralytics' semantics and avoid dilating masks through float interpolation.
4848
4949
Args:
5050
yolov8_results: Ultralytics results object with `.masks` and `.orig_shape`.
@@ -59,20 +59,22 @@ def extract_ultralytics_masks(yolov8_results: Any) -> npt.NDArray[np.bool_] | No
5959
orig_shape = yolov8_results.orig_shape
6060
inference_shape = tuple(yolov8_results.masks.data.shape[1:])
6161

62-
pad = (0, 0)
62+
top, left = 0, 0
63+
bottom, right = inference_shape
6364

6465
if inference_shape != orig_shape:
6566
gain = min(
6667
inference_shape[0] / orig_shape[0],
6768
inference_shape[1] / orig_shape[1],
6869
)
69-
pad = (
70-
(inference_shape[1] - orig_shape[1] * gain) / 2,
71-
(inference_shape[0] - orig_shape[0] * gain) / 2,
72-
)
73-
74-
top, left = int(pad[1]), int(pad[0])
75-
bottom, right = int(inference_shape[0] - pad[1]), int(inference_shape[1] - pad[0])
70+
# LetterBox rounds the resized content before splitting padding. An odd
71+
# padding pixel belongs to the bottom/right, not to the content crop.
72+
content_height = round(orig_shape[0] * gain)
73+
content_width = round(orig_shape[1] * gain)
74+
top = (inference_shape[0] - content_height) // 2
75+
left = (inference_shape[1] - content_width) // 2
76+
bottom = top + content_height
77+
right = left + content_width
7678

7779
mask_maps = []
7880
masks = _to_numpy(yolov8_results.masks.data)

‎tests/detection/test_ultralytics_arrays.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,38 @@ def _boxes(backend: str, count: int, tracked: bool = False) -> SimpleNamespace:
5757
class TestUltralyticsArrayResults:
5858
"""Keep tensor and NumPy Results equivalent across the public adapters."""
5959

60+
@pytest.mark.parametrize("backend", ["numpy", "tensor"])
61+
@pytest.mark.parametrize("portrait", [False, True])
62+
@pytest.mark.parametrize("with_boxes", [False, True])
63+
def test_restores_letterboxed_foreground(
64+
self, backend: str, portrait: bool, with_boxes: bool
65+
) -> None:
66+
"""Removing rounded letterbox padding preserves every foreground pixel."""
67+
masks = np.zeros((1, 448, 640), dtype=np.float32)
68+
masks[:, 11:437, :] = 1
69+
orig_shape = (333, 500)
70+
if portrait:
71+
masks = masks.transpose(0, 2, 1)
72+
orig_shape = orig_shape[::-1]
73+
height, width = orig_shape
74+
xyxy = np.array([[0, 0, width - 1, height - 1]], dtype=np.float32)
75+
boxes = _boxes(backend, 1)
76+
boxes.xyxy = _property(xyxy, backend)
77+
results = SimpleNamespace(
78+
boxes=boxes if with_boxes else None,
79+
obb=None,
80+
masks=SimpleNamespace(data=_property(masks, backend)),
81+
orig_shape=orig_shape,
82+
names={0: "person"},
83+
)
84+
85+
detections = Detections.from_ultralytics(results)
86+
87+
np.testing.assert_array_equal(
88+
detections.mask, np.ones((1, height, width), bool)
89+
)
90+
np.testing.assert_array_equal(detections.xyxy, xyxy)
91+
6092
@pytest.mark.parametrize("backend", ["numpy", "tensor"])
6193
@pytest.mark.parametrize(
6294
"tracked", [pytest.param(False, id="plain"), pytest.param(True, id="tracked")]

‎tests/detection/utils/test_internal.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,47 @@ def test_extract_ultralytics_masks_thresholds_resized_proto_at_half() -> None:
6868
assert masks[0].sum() < int((resized > 0).sum())
6969

7070

71+
class TestExtractUltralyticsLetterboxMasks:
72+
"""Restore mask geometry after rounded and asymmetric letterbox padding."""
73+
74+
@pytest.mark.parametrize("portrait", [False, True])
75+
@pytest.mark.parametrize(
76+
("orig_shape", "inference_shape", "content_rows"),
77+
[
78+
pytest.param((333, 500), (448, 640), (11, 437), id="fractional-padding"),
79+
pytest.param((332, 500), (448, 640), (11, 436), id="odd-padding"),
80+
pytest.param((480, 640), (640, 640), (80, 560), id="integer-padding"),
81+
pytest.param((320, 320), (640, 640), (0, 640), id="resize-without-padding"),
82+
pytest.param((17, 19), (17, 19), (0, 17), id="original-resolution"),
83+
],
84+
)
85+
def test_preserves_content_geometry(
86+
self,
87+
portrait: bool,
88+
orig_shape: tuple[int, int],
89+
inference_shape: tuple[int, int],
90+
content_rows: tuple[int, int],
91+
) -> None:
92+
"""Foreground bands are scaled from content pixels, excluding padding."""
93+
top, bottom = content_rows
94+
content = np.zeros((bottom - top, inference_shape[1]), dtype=np.float32)
95+
content[2:-2, 2:-2] = 1
96+
content[5:9] = 0
97+
proto = np.zeros((1, *inference_shape), dtype=np.float32)
98+
proto[0, top:bottom] = content
99+
if portrait:
100+
proto = proto.transpose(0, 2, 1)
101+
content = content.T
102+
orig_shape = orig_shape[::-1]
103+
expected = cv2.resize(content, (orig_shape[1], orig_shape[0])) > 0.5
104+
results = _FakeYOLOMaskResults(masks_arr=proto, orig_shape=orig_shape)
105+
106+
actual = extract_ultralytics_masks(results)
107+
108+
assert actual is not None
109+
np.testing.assert_array_equal(actual[0], expected)
110+
111+
71112
def _pred(
72113
yx: tuple[float, float] = (1.5, 1.5),
73114
size: tuple[float, float] = (2.0, 2.0),

0 commit comments

Comments
 (0)