9194ef5abd
Docs/Test Workflow / Test docs build (push) Failing after 0s
Check links & references / links-check (push) Failing after 1s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.10) (push) Failing after 0s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.11) (push) Failing after 0s
PR Conflict Labeler / main (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.12) (push) Failing after 2s
Pytest/Test Workflow / Import Test and Pytest Run (ubuntu-latest, 3.13) (push) Failing after 0s
Pytest/Test Workflow / Build this Package (push) Failing after 5s
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (macos-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.10) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.11) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.12) (push) Has been cancelled
Pytest/Test Workflow / Import Test and Pytest Run (windows-latest, 3.13) (push) Has been cancelled
Pytest/Test Workflow / testing-guardian (push) Has been cancelled
1936 lines
72 KiB
Python
1936 lines
72 KiB
Python
"""Unit tests for CompactMask and its private RLE helpers."""
|
|
|
|
from contextlib import ExitStack as DoesNotRaise
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from supervision.detection.compact_mask import (
|
|
CompactMask,
|
|
_rle_area,
|
|
)
|
|
from supervision.detection.utils.converters import (
|
|
_mask_to_rle_counts,
|
|
_rle_counts_to_mask,
|
|
mask_to_rle,
|
|
mask_to_xyxy,
|
|
)
|
|
from supervision.detection.utils.masks import (
|
|
calculate_masks_centroids,
|
|
contains_holes,
|
|
contains_multiple_segments,
|
|
move_masks,
|
|
)
|
|
|
|
|
|
def _make_cm(masks: np.ndarray, image_shape: tuple[int, int]) -> CompactMask:
|
|
"""Build a CompactMask whose crops equal the full bounding-box extents."""
|
|
num_masks = len(masks)
|
|
img_h, img_w = image_shape
|
|
xyxy = np.tile(np.array([0, 0, img_w, img_h], dtype=np.float32), (num_masks, 1))
|
|
return CompactMask.from_dense(masks, xyxy, image_shape=image_shape)
|
|
|
|
|
|
class TestRleHelpers:
|
|
"""Tests for _mask_to_rle_counts, _rle_counts_to_mask, and _rle_area.
|
|
|
|
Verifies that the private RLE encoding round-trips correctly for a range
|
|
of mask shapes (all-False, all-True, diagonal, L-shape, checkerboard,
|
|
single-pixel, and empty), and that _rle_area matches np.sum on the
|
|
original boolean array.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
("mask_2d", "description"),
|
|
[
|
|
pytest.param(np.zeros((5, 5), dtype=bool), "all-False", id="all-false"),
|
|
pytest.param(np.ones((5, 5), dtype=bool), "all-True", id="all-true"),
|
|
pytest.param(np.eye(4, dtype=bool), "diagonal", id="diagonal"),
|
|
pytest.param(
|
|
np.array([[True, True, False], [True, False, False]], dtype=bool),
|
|
"L-shape",
|
|
id="l-shape",
|
|
),
|
|
pytest.param(
|
|
np.indices((4, 4)).sum(axis=0) % 2 == 0,
|
|
"checkerboard",
|
|
id="checkerboard",
|
|
),
|
|
pytest.param(
|
|
np.zeros((1, 1), dtype=bool),
|
|
"single-pixel-False",
|
|
id="single-pixel-false",
|
|
),
|
|
pytest.param(
|
|
np.ones((1, 1), dtype=bool), "single-pixel-True", id="single-pixel-true"
|
|
),
|
|
pytest.param(np.zeros((0, 0), dtype=bool), "empty", id="empty"),
|
|
],
|
|
)
|
|
def test_encode_decode_round_trip(
|
|
self, mask_2d: np.ndarray, description: str
|
|
) -> None:
|
|
"""_mask_to_rle_counts -> _rle_counts_to_mask round-trip is lossless."""
|
|
if mask_2d.size == 0:
|
|
rle = _mask_to_rle_counts(mask_2d)
|
|
assert _rle_area(rle) == 0
|
|
return
|
|
|
|
rle = _mask_to_rle_counts(mask_2d)
|
|
assert rle.dtype == np.int32, "RLE must be int32"
|
|
reconstructed = _rle_counts_to_mask(rle, mask_2d.shape[0], mask_2d.shape[1])
|
|
np.testing.assert_array_equal(
|
|
reconstructed, mask_2d, err_msg=f"Round-trip failed for: {description}"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"mask_2d",
|
|
[
|
|
pytest.param(np.zeros((6, 6), dtype=bool), id="all-false"),
|
|
pytest.param(np.ones((6, 6), dtype=bool), id="all-true"),
|
|
pytest.param(np.eye(6, dtype=bool), id="diagonal"),
|
|
pytest.param(
|
|
np.array([[True, False, True], [False, True, False]], dtype=bool),
|
|
id="mixed",
|
|
),
|
|
],
|
|
)
|
|
def test_area_matches_numpy_sum(self, mask_2d: np.ndarray) -> None:
|
|
"""_rle_area must equal np.sum on the original boolean array."""
|
|
rle = _mask_to_rle_counts(mask_2d)
|
|
assert _rle_area(rle) == int(np.sum(mask_2d))
|
|
|
|
@pytest.mark.parametrize(
|
|
("mask_2d", "expected_rle"),
|
|
[
|
|
# 2x3; F-order flat: [F,T,T,F,T,F] -> 1F,2T,1F,1T,1F
|
|
pytest.param(
|
|
np.array([[False, True, True], [True, False, False]]),
|
|
[1, 2, 1, 1, 1],
|
|
id="2x3-mixed",
|
|
),
|
|
# 3x3 all-False -> single run of 9
|
|
pytest.param(np.zeros((3, 3), dtype=bool), [9], id="3x3-all-false"),
|
|
# 3x1 all-True; F-order scan starts True -> leading zero prepended
|
|
pytest.param(np.ones((3, 1), dtype=bool), [0, 3], id="3x1-all-true"),
|
|
# 2x2; F-order flat: [F,T,F,T] -> alternating single-pixel runs
|
|
pytest.param(
|
|
np.array([[False, False], [True, True]]),
|
|
[1, 1, 1, 1],
|
|
id="2x2-alternating",
|
|
),
|
|
],
|
|
)
|
|
def test_encode_matches_coco_f_order(
|
|
self, mask_2d: np.ndarray, expected_rle: list[int]
|
|
) -> None:
|
|
"""_mask_to_rle_counts produces COCO-compatible F-order RLE for known masks."""
|
|
assert _mask_to_rle_counts(mask_2d).tolist() == expected_rle
|
|
|
|
@pytest.mark.parametrize(
|
|
"mask_2d",
|
|
[
|
|
pytest.param(
|
|
np.array([[False, True, True], [True, False, False]]), id="l-shape"
|
|
),
|
|
pytest.param(np.zeros((4, 4), dtype=bool), id="all-false"),
|
|
pytest.param(np.array([[False, False], [True, True]]), id="alternating"),
|
|
pytest.param(np.ones((3, 1), dtype=bool), id="all-true"),
|
|
],
|
|
)
|
|
def test_encode_agrees_with_mask_to_rle(self, mask_2d: np.ndarray) -> None:
|
|
"""_mask_to_rle_counts output matches the public mask_to_rle encoder."""
|
|
assert _mask_to_rle_counts(mask_2d).tolist() == mask_to_rle(mask_2d)
|
|
|
|
|
|
class TestFromDenseToDense:
|
|
"""Tests for CompactMask.from_dense and to_dense.
|
|
|
|
Verifies that the from_dense → to_dense round-trip is lossless when the
|
|
bounding boxes span the full image (no True pixels fall outside the crop).
|
|
Covers N=0 (empty), N=1 (single mask), and N=5 (several random masks).
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
("num_masks", "image_shape"),
|
|
[
|
|
pytest.param(0, (50, 50), id="zero-masks"),
|
|
pytest.param(1, (50, 50), id="single-mask"),
|
|
pytest.param(5, (50, 50), id="five-masks"),
|
|
],
|
|
)
|
|
def test_round_trip(self, num_masks: int, image_shape: tuple[int, int]) -> None:
|
|
"""from_dense -> to_dense round-trips losslessly for N=0, 1, and 5 masks."""
|
|
rng = np.random.default_rng(42)
|
|
img_h, img_w = image_shape
|
|
masks = rng.integers(0, 2, size=(num_masks, img_h, img_w)).astype(bool)
|
|
cm = _make_cm(masks, image_shape)
|
|
np.testing.assert_array_equal(cm.to_dense(), masks)
|
|
|
|
def test_round_trip_with_mask_to_xyxy(self) -> None:
|
|
"""Round-trip must be lossless with inclusive xyxy from mask_to_xyxy."""
|
|
img_h, img_w = 12, 14
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 3:7, 4:9] = True # non-full-image object
|
|
|
|
xyxy = mask_to_xyxy(masks).astype(np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
np.testing.assert_array_equal(cm.to_dense(), masks)
|
|
|
|
|
|
class TestFromCocoRle:
|
|
"""Tests for CompactMask.from_coco_rle."""
|
|
|
|
def test_empty_collection_has_dense_empty_shape(self) -> None:
|
|
"""Empty COCO RLE input should return an empty CompactMask."""
|
|
compact = CompactMask.from_coco_rle(
|
|
rles=[],
|
|
xyxy=np.empty((0, 4), dtype=np.float32),
|
|
image_shape=(3, 5),
|
|
)
|
|
|
|
assert len(compact) == 0
|
|
assert compact.shape == (0, 3, 5)
|
|
assert compact.area.shape == (0,)
|
|
np.testing.assert_array_equal(
|
|
compact.to_dense(), np.zeros((0, 3, 5), dtype=bool)
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("masks", "xyxy"),
|
|
[
|
|
pytest.param(
|
|
np.array(
|
|
[
|
|
[
|
|
[False, True, False, False, False],
|
|
[False, True, True, False, False],
|
|
[False, False, False, True, False],
|
|
]
|
|
],
|
|
dtype=bool,
|
|
),
|
|
np.array([[1, 0, 3, 2]], dtype=np.float32),
|
|
id="non-square-crop",
|
|
),
|
|
pytest.param(
|
|
np.ones((1, 4, 5), dtype=bool),
|
|
np.array([[-2, 1, 8, 3]], dtype=np.float32),
|
|
id="clipped-box",
|
|
),
|
|
pytest.param(
|
|
np.zeros((1, 4, 5), dtype=bool),
|
|
np.array([[0, 0, 4, 3]], dtype=np.float32),
|
|
id="all-false",
|
|
),
|
|
pytest.param(
|
|
np.ones((1, 4, 5), dtype=bool),
|
|
np.array([[0, 0, 4, 3]], dtype=np.float32),
|
|
id="all-true-full-image",
|
|
),
|
|
pytest.param(
|
|
np.ones((1, 4, 5), dtype=bool),
|
|
np.array([[1, 1, 3, 2]], dtype=np.float32),
|
|
id="all-true-crop",
|
|
),
|
|
pytest.param(
|
|
np.ones((1, 4, 5), dtype=bool),
|
|
np.array([[3, 2, 1, 2]], dtype=np.float32),
|
|
id="invalid-box",
|
|
),
|
|
pytest.param(
|
|
np.array([[[True]]], dtype=bool),
|
|
np.array([[0, 0, 0, 0]], dtype=np.float32),
|
|
id="single-pixel-image",
|
|
),
|
|
],
|
|
)
|
|
def test_matches_dense_reference(self, masks: np.ndarray, xyxy: np.ndarray) -> None:
|
|
"""COCO RLE construction should match dense decode plus from_dense."""
|
|
image_shape = masks.shape[1:]
|
|
rles = [
|
|
{
|
|
"size": list(image_shape),
|
|
"counts": mask_to_rle(mask, compressed=True),
|
|
}
|
|
for mask in masks
|
|
]
|
|
|
|
compact = CompactMask.from_coco_rle(
|
|
rles=rles, xyxy=xyxy, image_shape=image_shape
|
|
)
|
|
|
|
reference = CompactMask.from_dense(masks, xyxy, image_shape=image_shape)
|
|
np.testing.assert_array_equal(compact.to_dense(), reference.to_dense())
|
|
np.testing.assert_array_equal(compact.area, reference.area)
|
|
np.testing.assert_array_equal(compact.bbox_xyxy, reference.bbox_xyxy)
|
|
|
|
def test_matches_dense_reference_for_multiple_masks(self) -> None:
|
|
"""COCO RLE construction handles N>1 batches."""
|
|
masks = np.zeros((2, 5, 6), dtype=bool)
|
|
masks[0, 1:3, 1:4] = True
|
|
masks[1, 3:5, 4:6] = True
|
|
xyxy = np.array([[1, 1, 3, 2], [4, 3, 5, 4]], dtype=np.float32)
|
|
image_shape = masks.shape[1:]
|
|
rles = [
|
|
{"size": list(image_shape), "counts": mask_to_rle(mask)} for mask in masks
|
|
]
|
|
|
|
compact = CompactMask.from_coco_rle(
|
|
rles=rles, xyxy=xyxy, image_shape=image_shape
|
|
)
|
|
|
|
reference = CompactMask.from_dense(masks, xyxy, image_shape=image_shape)
|
|
np.testing.assert_array_equal(compact.to_dense(), reference.to_dense())
|
|
|
|
def test_out_of_frame_box_returns_empty_crop(self) -> None:
|
|
"""Boxes with no image intersection do not collapse onto edge pixels."""
|
|
mask = np.zeros((4, 5), dtype=bool)
|
|
mask[2, 4] = True
|
|
rles = [{"size": [4, 5], "counts": mask_to_rle(mask)}]
|
|
xyxy = np.array([[5, 2, 6, 2]], dtype=np.float32)
|
|
|
|
compact = CompactMask.from_coco_rle(rles=rles, xyxy=xyxy, image_shape=(4, 5))
|
|
|
|
assert compact.area.tolist() == [0]
|
|
np.testing.assert_array_equal(compact.to_dense(), np.zeros((1, 4, 5), bool))
|
|
|
|
def test_rejects_rle_size_mismatch(self) -> None:
|
|
"""COCO RLE size should match the explicit image shape."""
|
|
rles = [{"size": [2, 2], "counts": [4]}]
|
|
xyxy = np.array([[0, 0, 1, 1]], dtype=np.float32)
|
|
|
|
with pytest.raises(ValueError, match="RLE size"):
|
|
CompactMask.from_coco_rle(rles=rles, xyxy=xyxy, image_shape=(3, 2))
|
|
|
|
@pytest.mark.parametrize(
|
|
("rles", "xyxy_arr", "image_shape", "err_match"),
|
|
[
|
|
pytest.param(
|
|
[{"size": [0, 4], "counts": [0]}],
|
|
np.array([[0, 0, 3, 3]], dtype=np.float32),
|
|
(0, 4),
|
|
"positive",
|
|
id="zero-height",
|
|
),
|
|
pytest.param(
|
|
[{"size": [4, 0], "counts": [0]}],
|
|
np.array([[0, 0, 3, 3]], dtype=np.float32),
|
|
(4, 0),
|
|
"positive",
|
|
id="zero-width",
|
|
),
|
|
pytest.param(
|
|
[{"size": [4, 4], "counts": [16]}],
|
|
np.array([[0, 0, 3, 3, 0]], dtype=np.float32),
|
|
(4, 4),
|
|
"shape",
|
|
id="xyxy-shape-mismatch",
|
|
),
|
|
pytest.param(
|
|
[42],
|
|
np.array([[0, 0, 3, 3]], dtype=np.float32),
|
|
(4, 4),
|
|
"mapping",
|
|
id="non-mapping-rle-item",
|
|
),
|
|
pytest.param(
|
|
[{"size": [4, 4]}],
|
|
np.array([[0, 0, 3, 3]], dtype=np.float32),
|
|
(4, 4),
|
|
"'size' and 'counts'",
|
|
id="missing-counts-key",
|
|
),
|
|
pytest.param(
|
|
[{"size": [4, 4], "counts": [1, 2, 3]}],
|
|
np.array([[0, 0, 3, 3]], dtype=np.float32),
|
|
(4, 4),
|
|
"sum",
|
|
id="counts-sum-mismatch",
|
|
),
|
|
pytest.param(
|
|
[],
|
|
np.empty((0, 4), dtype=np.float32),
|
|
(32769, 4),
|
|
"maximum",
|
|
id="max-image-dimension-exceeded",
|
|
),
|
|
],
|
|
)
|
|
def test_raises_on_invalid_input(
|
|
self,
|
|
rles: list,
|
|
xyxy_arr: np.ndarray,
|
|
image_shape: tuple,
|
|
err_match: str,
|
|
) -> None:
|
|
"""from_coco_rle raises ValueError for each documented invalid-input path."""
|
|
with pytest.raises(ValueError, match=err_match):
|
|
CompactMask.from_coco_rle(rles=rles, xyxy=xyxy_arr, image_shape=image_shape)
|
|
|
|
def test_transcodes_without_dense_decode_helpers(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""COCO RLE construction should avoid full-mask dense decode helpers."""
|
|
rles = [{"size": [4, 4], "counts": "52203"}]
|
|
xyxy = np.array([[0, 0, 3, 3]], dtype=np.float32)
|
|
|
|
def fail_dense_helper(*args: object, **kwargs: object) -> None:
|
|
raise AssertionError("dense helper should not be called")
|
|
|
|
monkeypatch.setattr(
|
|
"supervision.detection.compact_mask._mask_to_rle_counts",
|
|
fail_dense_helper,
|
|
)
|
|
monkeypatch.setattr(
|
|
"supervision.detection.compact_mask._rle_counts_to_mask",
|
|
fail_dense_helper,
|
|
)
|
|
|
|
compact = CompactMask.from_coco_rle(rles=rles, xyxy=xyxy, image_shape=(4, 4))
|
|
|
|
assert compact.shape == (1, 4, 4)
|
|
|
|
def test_large_image_column_split_path(self) -> None:
|
|
"""from_coco_rle hits column-split path on large images (H*W > 307200)."""
|
|
H, W = 720, 1280
|
|
assert H * W > 640 * 480, (
|
|
"test must use image above _SMALL_IMAGE_DENSE_THRESHOLD"
|
|
)
|
|
rng = np.random.default_rng(42)
|
|
mask = rng.integers(0, 2, (H, W), dtype=np.uint8).astype(bool)
|
|
xyxy = np.array([[0, 0, W - 1, H - 1]], dtype=np.float32)
|
|
rle = {"size": [H, W], "counts": mask_to_rle(mask, compressed=True)}
|
|
|
|
compact = CompactMask.from_coco_rle(rles=[rle], xyxy=xyxy, image_shape=(H, W))
|
|
reference = CompactMask.from_dense(mask[np.newaxis], xyxy, image_shape=(H, W))
|
|
|
|
np.testing.assert_array_equal(compact.to_dense(), reference.to_dense())
|
|
|
|
def test_bytes_counts_match_string_counts(self) -> None:
|
|
"""from_coco_rle accepts bytes-encoded compressed counts."""
|
|
# Both encodings of "52203" should produce identical crops.
|
|
rle_str = {"size": [4, 4], "counts": "52203"}
|
|
rle_bytes = {"size": [4, 4], "counts": b"52203"}
|
|
xyxy = np.array([[0, 0, 3, 3]], dtype=np.float32)
|
|
|
|
cm_str = CompactMask.from_coco_rle(
|
|
rles=[rle_str], xyxy=xyxy, image_shape=(4, 4)
|
|
)
|
|
cm_bytes = CompactMask.from_coco_rle(
|
|
rles=[rle_bytes], xyxy=xyxy, image_shape=(4, 4)
|
|
)
|
|
|
|
np.testing.assert_array_equal(cm_str.to_dense(), cm_bytes.to_dense())
|
|
|
|
|
|
class TestCocoRleCountsToArray:
|
|
"""Tests for _coco_rle_counts_to_array input-format decoding."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"counts",
|
|
[
|
|
pytest.param("52203", id="str-input"),
|
|
pytest.param(b"52203", id="bytes-input"),
|
|
],
|
|
)
|
|
def test_str_and_bytes_decode_identically(self, counts: object) -> None:
|
|
"""str and bytes inputs decode to the same run-length array."""
|
|
from supervision.detection.compact_mask import _coco_rle_counts_to_array
|
|
|
|
result = _coco_rle_counts_to_array(counts)
|
|
assert result.dtype == np.int32
|
|
assert result.sum() == 16 # total pixels in a 4x4 image
|
|
|
|
@pytest.mark.parametrize(
|
|
("counts", "err_match"),
|
|
[
|
|
pytest.param([2**31], "Invalid", id="int32-overflow"),
|
|
pytest.param([[4, 8], [3, 5]], "one-dimensional", id="two-dimensional"),
|
|
pytest.param([4, -1, 8], "non-negative", id="negative-count"),
|
|
pytest.param(None, "Invalid", id="none"),
|
|
pytest.param("", "empty", id="empty-string"),
|
|
],
|
|
)
|
|
def test_invalid_counts_raise_value_error(
|
|
self, counts: object, err_match: str
|
|
) -> None:
|
|
"""Invalid COCO RLE counts raise ValueError."""
|
|
from supervision.detection.compact_mask import _coco_rle_counts_to_array
|
|
|
|
with pytest.raises(ValueError, match=err_match):
|
|
_coco_rle_counts_to_array(counts)
|
|
|
|
|
|
class TestRleTrimColRuns:
|
|
"""Tests for _rle_trim_col_runs row-crop behavior."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("col_runs", "height", "y1", "y2"),
|
|
[
|
|
pytest.param([0, 2, 3], 5, 0, 2, id="starts-at-row-zero"),
|
|
pytest.param([2, 1, 2], 5, 2, 2, id="single-row-crop"),
|
|
pytest.param([1, 3, 2], 6, 2, 4, id="straddles-both-bounds"),
|
|
pytest.param([3, 2, 1], 6, 4, 5, id="starts-inside-true-run"),
|
|
pytest.param([0, 6], 6, 1, 4, id="all-true-col-interior-crop"),
|
|
],
|
|
)
|
|
def test_matches_decode_slice_encode(
|
|
self, col_runs: list[int], height: int, y1: int, y2: int
|
|
) -> None:
|
|
"""Trimmed column runs match dense slice then encode."""
|
|
from supervision.detection.compact_mask import _rle_trim_col_runs
|
|
|
|
column = _rle_counts_to_mask(np.array(col_runs, dtype=np.int32), height, 1)
|
|
expected = _mask_to_rle_counts(column[y1 : y2 + 1, :]).tolist()
|
|
|
|
result = _rle_trim_col_runs(col_runs, y1, y2)
|
|
|
|
assert result == expected
|
|
assert sum(result) == y2 - y1 + 1
|
|
|
|
def test_returns_all_false_when_no_runs_reach_crop(self) -> None:
|
|
"""Truncated input before y1 returns an all-False crop."""
|
|
from supervision.detection.compact_mask import _rle_trim_col_runs
|
|
|
|
result = _rle_trim_col_runs([2], y1=3, y2=4)
|
|
|
|
assert result == [2]
|
|
|
|
|
|
class TestGetItem:
|
|
"""Tests for CompactMask.__getitem__.
|
|
|
|
Covers four indexing modes:
|
|
- Integer index → dense (H, W) np.ndarray with correct shape and dtype.
|
|
- List of indices → new CompactMask with the selected detections.
|
|
- Slice → new CompactMask with the sliced detections.
|
|
- Boolean ndarray → new CompactMask filtered by the boolean selector.
|
|
"""
|
|
|
|
def test_int_returns_2d_dense(self) -> None:
|
|
"""Integer index returns a dense (H, W) array matching the source mask."""
|
|
img_h, img_w = 30, 40
|
|
rng = np.random.default_rng(0)
|
|
masks = rng.integers(0, 2, size=(3, img_h, img_w)).astype(bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
result = cm[1]
|
|
assert isinstance(result, np.ndarray)
|
|
assert result.shape == (img_h, img_w)
|
|
assert result.dtype == bool
|
|
np.testing.assert_array_equal(result, masks[1])
|
|
|
|
def test_list_returns_compact_mask(self) -> None:
|
|
"""List of indices returns a new CompactMask with the selected detections."""
|
|
img_h, img_w = 20, 20
|
|
masks = np.zeros((4, img_h, img_w), dtype=bool)
|
|
for mask_idx in range(4):
|
|
masks[
|
|
mask_idx,
|
|
mask_idx * 2 : mask_idx * 2 + 2,
|
|
mask_idx * 2 : mask_idx * 2 + 2,
|
|
] = True
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
subset = cm[[0, 2]]
|
|
assert isinstance(subset, CompactMask)
|
|
assert len(subset) == 2
|
|
np.testing.assert_array_equal(subset[0], masks[0])
|
|
np.testing.assert_array_equal(subset[1], masks[2])
|
|
|
|
def test_slice_returns_compact_mask(self) -> None:
|
|
"""Slice indexing returns a new CompactMask with the sliced detections."""
|
|
img_h, img_w = 20, 20
|
|
masks = np.zeros((5, img_h, img_w), dtype=bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
subset = cm[1:4]
|
|
assert isinstance(subset, CompactMask)
|
|
assert len(subset) == 3
|
|
|
|
def test_bool_ndarray(self) -> None:
|
|
"""Boolean ndarray selector filters detections like NumPy boolean masking."""
|
|
img_h, img_w = 15, 15
|
|
rng = np.random.default_rng(7)
|
|
masks = rng.integers(0, 2, size=(4, img_h, img_w)).astype(bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
selector = np.array([True, False, True, False])
|
|
subset = cm[selector]
|
|
assert isinstance(subset, CompactMask)
|
|
assert len(subset) == 2
|
|
np.testing.assert_array_equal(subset[0], masks[0])
|
|
np.testing.assert_array_equal(subset[1], masks[2])
|
|
|
|
def test_bool_list(self) -> None:
|
|
"""Python list[bool] should behave like boolean masking."""
|
|
img_h, img_w = 15, 15
|
|
rng = np.random.default_rng(8)
|
|
masks = rng.integers(0, 2, size=(4, img_h, img_w)).astype(bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
subset = cm[[True, False, True, False]]
|
|
assert isinstance(subset, CompactMask)
|
|
assert len(subset) == 2
|
|
np.testing.assert_array_equal(subset[0], masks[0])
|
|
np.testing.assert_array_equal(subset[1], masks[2])
|
|
|
|
|
|
class TestProperties:
|
|
"""Tests for len, shape, dtype, and area properties.
|
|
|
|
Verifies that the shape tuple follows the (N, H, W) dense convention,
|
|
dtype is always bool, and area returns per-mask True-pixel counts that
|
|
match np.sum on the corresponding dense masks.
|
|
"""
|
|
|
|
def test_len(self) -> None:
|
|
"""len() returns the number of masks in the collection."""
|
|
masks = np.zeros((3, 10, 10), dtype=bool)
|
|
cm = _make_cm(masks, (10, 10))
|
|
assert len(cm) == 3
|
|
|
|
def test_shape(self) -> None:
|
|
"""shape follows the (N, H, W) dense array convention."""
|
|
masks = np.zeros((3, 10, 10), dtype=bool)
|
|
cm = _make_cm(masks, (10, 10))
|
|
assert cm.shape == (3, 10, 10)
|
|
|
|
def test_shape_empty(self) -> None:
|
|
"""shape reports N=0 for an empty CompactMask while keeping (H, W)."""
|
|
cm = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(480, 640),
|
|
)
|
|
assert cm.shape == (0, 480, 640)
|
|
|
|
def test_dtype(self) -> None:
|
|
"""dtype is always bool regardless of the input mask dtype."""
|
|
cm = _make_cm(np.zeros((1, 5, 5), dtype=bool), (5, 5))
|
|
assert cm.dtype == np.dtype(bool)
|
|
|
|
def test_area_matches_dense(self) -> None:
|
|
"""area returns per-mask True-pixel counts matching np.sum on dense masks."""
|
|
img_h, img_w = 20, 20
|
|
rng = np.random.default_rng(3)
|
|
masks = rng.integers(0, 2, size=(4, img_h, img_w)).astype(bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
expected = np.array([mask.sum() for mask in masks])
|
|
np.testing.assert_array_equal(cm.area, expected)
|
|
|
|
def test_area_empty(self) -> None:
|
|
"""area is an empty (0,) array for an empty CompactMask."""
|
|
cm = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(10, 10),
|
|
)
|
|
assert cm.area.shape == (0,)
|
|
|
|
|
|
class TestCrop:
|
|
"""Tests for CompactMask.crop.
|
|
|
|
Verifies that crop(index) returns an array shaped (crop_h, crop_w)
|
|
containing only the pixels within the bounding box, without allocating
|
|
the full (H, W) image.
|
|
"""
|
|
|
|
def test_returns_crop_shape(self) -> None:
|
|
img_h, img_w = 50, 60
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 10:30, 5:25] = True # 20 x 20 region
|
|
xyxy = np.array([[5, 10, 24, 29]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
crop = cm.crop(0)
|
|
assert crop.shape == (20, 20)
|
|
assert crop.all() # the entire crop should be True
|
|
|
|
|
|
class TestArrayProtocol:
|
|
"""Tests for the __array__ protocol.
|
|
|
|
Verifies that np.asarray(cm) materialises the full (N, H, W) dense array
|
|
and that optional dtype casting (e.g. to uint8) is correctly applied.
|
|
"""
|
|
|
|
def test_array_protocol(self) -> None:
|
|
img_h, img_w = 10, 10
|
|
rng = np.random.default_rng(9)
|
|
masks = rng.integers(0, 2, size=(2, img_h, img_w)).astype(bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
arr = np.asarray(cm)
|
|
assert arr.shape == (2, img_h, img_w)
|
|
np.testing.assert_array_equal(arr, masks)
|
|
|
|
def test_dtype_cast(self) -> None:
|
|
masks = np.ones((1, 5, 5), dtype=bool)
|
|
cm = _make_cm(masks, (5, 5))
|
|
arr = np.asarray(cm, dtype=np.uint8)
|
|
assert arr.dtype == np.uint8
|
|
assert arr.sum() == 25
|
|
|
|
|
|
class TestMerge:
|
|
"""Tests for CompactMask.merge.
|
|
|
|
Verifies that multiple CompactMask instances with the same image_shape
|
|
can be concatenated into a single CompactMask, that merging with an empty
|
|
instance works, that an empty input list raises ValueError, and that
|
|
mismatched image shapes raise ValueError.
|
|
"""
|
|
|
|
def test_merge(self) -> None:
|
|
img_h, img_w = 20, 20
|
|
masks1 = np.zeros((2, img_h, img_w), dtype=bool)
|
|
masks2 = np.zeros((3, img_h, img_w), dtype=bool)
|
|
cm1 = _make_cm(masks1, (img_h, img_w))
|
|
cm2 = _make_cm(masks2, (img_h, img_w))
|
|
|
|
merged = CompactMask.merge([cm1, cm2])
|
|
assert len(merged) == 5
|
|
assert merged.shape == (5, img_h, img_w)
|
|
np.testing.assert_array_equal(
|
|
merged.to_dense(), np.concatenate([masks1, masks2], axis=0)
|
|
)
|
|
|
|
def test_merge_with_empty(self) -> None:
|
|
img_h, img_w = 10, 10
|
|
empty_cm = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(img_h, img_w),
|
|
)
|
|
masks = np.zeros((2, img_h, img_w), dtype=bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
merged = CompactMask.merge([empty_cm, cm])
|
|
assert len(merged) == 2
|
|
|
|
def test_merge_empty_list_raises(self) -> None:
|
|
with pytest.raises(ValueError, match="empty list"):
|
|
CompactMask.merge([])
|
|
|
|
def test_merge_mismatched_image_shape_raises(self) -> None:
|
|
cm1 = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(10, 10),
|
|
)
|
|
cm2 = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(20, 20),
|
|
)
|
|
with pytest.raises(ValueError, match="image shapes"):
|
|
CompactMask.merge([cm1, cm2])
|
|
|
|
|
|
class TestEquality:
|
|
"""Tests for CompactMask.__eq__.
|
|
|
|
Verifies element-wise equality between two CompactMask instances and
|
|
between a CompactMask and an equivalent dense (N, H, W) boolean array.
|
|
"""
|
|
|
|
def test_eq_identical(self) -> None:
|
|
masks = np.zeros((2, 10, 10), dtype=bool)
|
|
masks[0, 2:5, 2:5] = True
|
|
cm1 = _make_cm(masks, (10, 10))
|
|
cm2 = _make_cm(masks, (10, 10))
|
|
assert cm1 == cm2
|
|
|
|
def test_eq_different(self) -> None:
|
|
masks_a = np.zeros((2, 10, 10), dtype=bool)
|
|
masks_a[0, 2:5, 2:5] = True
|
|
masks_b = np.zeros((2, 10, 10), dtype=bool)
|
|
masks_b[1, 6:9, 6:9] = True
|
|
cm1 = _make_cm(masks_a, (10, 10))
|
|
cm2 = _make_cm(masks_b, (10, 10))
|
|
assert not (cm1 == cm2)
|
|
|
|
def test_eq_with_dense_array(self) -> None:
|
|
masks = np.zeros((1, 8, 8), dtype=bool)
|
|
masks[0, 1:4, 1:4] = True
|
|
cm = _make_cm(masks, (8, 8))
|
|
assert cm == masks
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for boundary conditions and unusual inputs.
|
|
|
|
Covers: zero-area bounding box (x1 == x2), masks that reach the image
|
|
edge, xyxy values beyond image dimensions (clamped silently), empty
|
|
CompactMask (N=0), sum axis compatibility with area, and with_offset for
|
|
use by InferenceSlicer.
|
|
"""
|
|
|
|
def test_zero_area_mask_clipped_to_1x1(self) -> None:
|
|
"""An invalid bounding box should not crash from_dense."""
|
|
masks = np.zeros((1, 10, 10), dtype=bool)
|
|
xyxy = np.array([[6, 5, 5, 8]], dtype=np.float32)
|
|
with DoesNotRaise():
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(10, 10))
|
|
assert len(cm) == 1
|
|
|
|
def test_mask_at_image_boundary(self) -> None:
|
|
img_h, img_w = 20, 20
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 15:20, 15:20] = True
|
|
xyxy = np.array([[15, 15, 19, 19]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
np.testing.assert_array_equal(cm.to_dense(), masks)
|
|
|
|
def test_xyxy_beyond_image_clipped(self) -> None:
|
|
"""xyxy values beyond the image boundary should be clipped silently."""
|
|
img_h, img_w = 10, 10
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 5:10, 5:10] = True
|
|
xyxy = np.array([[5, 5, 999, 999]], dtype=np.float32)
|
|
with DoesNotRaise():
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
np.testing.assert_array_equal(cm.to_dense(), masks)
|
|
|
|
def test_empty_compact_mask_to_dense(self) -> None:
|
|
cm = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(50, 60),
|
|
)
|
|
dense = cm.to_dense()
|
|
assert dense.shape == (0, 50, 60)
|
|
assert dense.dtype == bool
|
|
|
|
def test_sum_axis_1_2_equals_area(self) -> None:
|
|
rng = np.random.default_rng(11)
|
|
masks = rng.integers(0, 2, size=(4, 15, 15)).astype(bool)
|
|
cm = _make_cm(masks, (15, 15))
|
|
np.testing.assert_array_equal(cm.sum(axis=(1, 2)), cm.area)
|
|
|
|
def test_with_offset(self) -> None:
|
|
img_h, img_w = 20, 20
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 5:10, 5:10] = True
|
|
xyxy = np.array([[5, 5, 9, 9]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
cm2 = cm.with_offset(100, 200, new_image_shape=(400, 400))
|
|
assert cm2.offsets[0].tolist() == [105, 205]
|
|
assert cm2._image_shape == (400, 400)
|
|
np.testing.assert_array_equal(cm2.crop(0), cm.crop(0))
|
|
|
|
def test_with_offset_clips_partial_overlap_like_move_masks(self) -> None:
|
|
"""with_offset must clip partial out-of-frame translations like move_masks."""
|
|
img_h, img_w = 10, 10
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 2:6, 3:8] = True
|
|
xyxy = np.array([[3, 2, 7, 5]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
dx, dy = -4, 3
|
|
cm_shifted = cm.with_offset(dx=dx, dy=dy, new_image_shape=(img_h, img_w))
|
|
expected = move_masks(
|
|
masks=masks,
|
|
offset=np.array([dx, dy], dtype=np.int32),
|
|
resolution_wh=(img_w, img_h),
|
|
)
|
|
|
|
np.testing.assert_array_equal(cm_shifted.to_dense(), expected)
|
|
|
|
def test_with_offset_clips_full_outside_like_move_masks(self) -> None:
|
|
"""Masks shifted fully outside should remain valid and decode to all-False."""
|
|
img_h, img_w = 10, 10
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 2:6, 2:6] = True
|
|
xyxy = np.array([[2, 2, 5, 5]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
dx, dy = 100, 100
|
|
cm_shifted = cm.with_offset(dx=dx, dy=dy, new_image_shape=(img_h, img_w))
|
|
expected = move_masks(
|
|
masks=masks,
|
|
offset=np.array([dx, dy], dtype=np.int32),
|
|
resolution_wh=(img_w, img_h),
|
|
)
|
|
|
|
np.testing.assert_array_equal(cm_shifted.to_dense(), expected)
|
|
|
|
def test_repack_tightens_loose_bbox(self) -> None:
|
|
"""repack() shrinks the crop to the minimal True-pixel rectangle."""
|
|
img_h, img_w = 20, 20
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 5:10, 6:12] = True # True block at (5,6)-(9,11)
|
|
|
|
# Deliberately loose bbox covers full image.
|
|
xyxy = np.array([[0, 0, img_w - 1, img_h - 1]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
# Before repack: crop is the full 20x20 image.
|
|
assert cm._crop_shapes[0].tolist() == [20, 20]
|
|
|
|
repacked = cm.repack()
|
|
|
|
# After repack: crop is exactly the True block.
|
|
assert repacked.offsets[0].tolist() == [6, 5] # (x1, y1)
|
|
assert repacked._crop_shapes[0].tolist() == [5, 6] # (h, w)
|
|
# Pixel content must be identical to the original.
|
|
np.testing.assert_array_equal(repacked.to_dense(), masks)
|
|
|
|
def test_repack_preserves_all_false_mask(self) -> None:
|
|
"""repack() normalises an all-False mask to a 1x1 crop."""
|
|
img_h, img_w = 10, 10
|
|
masks = np.zeros((2, img_h, img_w), dtype=bool)
|
|
masks[1, 3:6, 3:6] = True # only mask 1 is non-empty
|
|
|
|
xyxy = np.array([[0, 0, 9, 9], [0, 0, 9, 9]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
repacked = cm.repack()
|
|
|
|
assert repacked._crop_shapes[0].tolist() == [1, 1] # normalised
|
|
assert repacked._crop_shapes[1].tolist() == [3, 3] # tight True block
|
|
np.testing.assert_array_equal(repacked.to_dense(), masks)
|
|
|
|
def test_repack_empty_collection(self) -> None:
|
|
"""repack() on an empty CompactMask returns another empty CompactMask."""
|
|
cm = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(10, 10),
|
|
)
|
|
repacked = cm.repack()
|
|
assert len(repacked) == 0
|
|
assert repacked._image_shape == (10, 10)
|
|
|
|
def test_repack_already_tight(self) -> None:
|
|
"""repack() is a no-op when bboxes are already tight."""
|
|
img_h, img_w = 15, 15
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 4:9, 3:8] = True
|
|
|
|
# Tight bbox.
|
|
xyxy = np.array([[3, 4, 7, 8]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
repacked = cm.repack()
|
|
|
|
np.testing.assert_array_equal(repacked.offsets, cm.offsets)
|
|
np.testing.assert_array_equal(repacked._crop_shapes, cm._crop_shapes)
|
|
np.testing.assert_array_equal(repacked.to_dense(), masks)
|
|
|
|
|
|
class TestCalculateMasksCentroidsCompact:
|
|
"""Verify calculate_masks_centroids gives identical results for CompactMask.
|
|
|
|
The function has a dedicated CompactMask branch that computes centroids
|
|
per-crop. Results must match the dense path to within integer rounding.
|
|
"""
|
|
|
|
def test_centroids_compact_matches_dense(self) -> None:
|
|
"""Centroid coordinates must be numerically identical for dense and compact."""
|
|
rng = np.random.default_rng(42)
|
|
img_h, img_w = 30, 30
|
|
masks = rng.integers(0, 2, size=(5, img_h, img_w)).astype(bool)
|
|
# Ensure each mask has at least one True pixel.
|
|
for mask_idx in range(5):
|
|
masks[mask_idx, mask_idx * 5, mask_idx * 5] = True
|
|
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
centroids_dense = calculate_masks_centroids(masks)
|
|
centroids_compact = calculate_masks_centroids(cm)
|
|
|
|
np.testing.assert_array_equal(centroids_compact, centroids_dense)
|
|
|
|
def test_centroids_empty_mask(self) -> None:
|
|
"""All-zero masks should return centroid (0, 0) — same as dense."""
|
|
img_h, img_w = 10, 10
|
|
masks = np.zeros((3, img_h, img_w), dtype=bool)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
centroids_dense = calculate_masks_centroids(masks)
|
|
centroids_compact = calculate_masks_centroids(cm)
|
|
|
|
np.testing.assert_array_equal(centroids_compact, centroids_dense)
|
|
|
|
def test_centroids_empty_mask_with_tight_bbox(self) -> None:
|
|
"""All-zero tight crops must still return centroid (0, 0)."""
|
|
img_h, img_w = 10, 10
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
xyxy = np.array([[3, 4, 7, 8]], dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
centroids_dense = calculate_masks_centroids(masks)
|
|
centroids_compact = calculate_masks_centroids(cm)
|
|
|
|
np.testing.assert_array_equal(centroids_compact, centroids_dense)
|
|
|
|
def test_centroids_zero_masks_returns_empty(self) -> None:
|
|
"""Empty CompactMask (0 objects) must return shape (0, 2)."""
|
|
empty_cm = CompactMask(
|
|
[],
|
|
np.empty((0, 2), dtype=np.int32),
|
|
np.empty((0, 2), dtype=np.int32),
|
|
(10, 10),
|
|
)
|
|
result = calculate_masks_centroids(empty_cm)
|
|
assert result.shape == (0, 2)
|
|
|
|
|
|
class TestContainsHolesCompact:
|
|
"""Verify contains_holes result is unchanged after CompactMask roundtrip.
|
|
|
|
contains_holes works on a 2D boolean mask. Encoding then decoding via
|
|
CompactMask must preserve pixel topology so that the function returns
|
|
the same result as on the original array.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
("mask_2d", "expected"),
|
|
[
|
|
# simple foreground blob — no holes
|
|
(
|
|
np.array(
|
|
[[0, 1, 1, 0], [1, 1, 1, 1], [1, 1, 1, 1], [0, 1, 1, 0]],
|
|
dtype=bool,
|
|
),
|
|
False,
|
|
),
|
|
# ring shape — has one hole
|
|
(
|
|
np.array(
|
|
[[1, 1, 1, 0], [1, 0, 1, 0], [1, 1, 1, 0], [0, 0, 0, 0]],
|
|
dtype=bool,
|
|
),
|
|
True,
|
|
),
|
|
# all-False — no holes
|
|
(np.zeros((6, 6), dtype=bool), False),
|
|
# all-True — no holes
|
|
(np.ones((6, 6), dtype=bool), False),
|
|
],
|
|
)
|
|
def test_contains_holes_compact_roundtrip(
|
|
self, mask_2d: np.ndarray, expected: bool
|
|
) -> None:
|
|
"""contains_holes must agree after CompactMask encode→decode."""
|
|
img_h, img_w = mask_2d.shape
|
|
masks = mask_2d[np.newaxis] # (1, H, W)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
decoded = cm.to_dense()[0]
|
|
assert contains_holes(decoded) == expected
|
|
assert contains_holes(decoded) == contains_holes(mask_2d)
|
|
|
|
|
|
class TestContainsMultipleSegmentsCompact:
|
|
"""Verify contains_multiple_segments result survives CompactMask roundtrip.
|
|
|
|
Encoding and decoding must preserve connected-component topology so
|
|
that the multi-segment predicate returns the same value.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
("mask_2d", "connectivity", "expected"),
|
|
[
|
|
# single contiguous blob — not multi-segment
|
|
(
|
|
np.array(
|
|
[[0, 1, 1, 0], [1, 1, 1, 1], [1, 1, 1, 1], [0, 1, 1, 0]],
|
|
dtype=bool,
|
|
),
|
|
4,
|
|
False,
|
|
),
|
|
# two separate blobs — multi-segment
|
|
(
|
|
np.array(
|
|
[[1, 1, 0, 0], [1, 1, 0, 0], [0, 0, 1, 1], [0, 0, 1, 1]],
|
|
dtype=bool,
|
|
),
|
|
4,
|
|
True,
|
|
),
|
|
# diagonal touch — single segment under 8-connectivity
|
|
(
|
|
np.array(
|
|
[[1, 1, 0, 0], [1, 1, 0, 1], [1, 0, 1, 1], [0, 0, 1, 1]],
|
|
dtype=bool,
|
|
),
|
|
8,
|
|
False,
|
|
),
|
|
# all-False — not multi-segment
|
|
(np.zeros((6, 6), dtype=bool), 4, False),
|
|
],
|
|
)
|
|
def test_contains_multiple_segments_compact_roundtrip(
|
|
self, mask_2d: np.ndarray, connectivity: int, expected: bool
|
|
) -> None:
|
|
"""contains_multiple_segments must agree after CompactMask encode→decode."""
|
|
img_h, img_w = mask_2d.shape
|
|
masks = mask_2d[np.newaxis] # (1, H, W)
|
|
cm = _make_cm(masks, (img_h, img_w))
|
|
|
|
decoded = cm.to_dense()[0]
|
|
result = contains_multiple_segments(decoded, connectivity=connectivity)
|
|
assert result == expected
|
|
assert result == contains_multiple_segments(mask_2d, connectivity=connectivity)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Random scenario helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Varying (N, image_h, image_w) combinations for random tests.
|
|
_RANDOM_CONFIGS = [
|
|
(1, 50, 50),
|
|
(5, 50, 50),
|
|
(5, 200, 300),
|
|
(20, 100, 150),
|
|
(20, 200, 300),
|
|
(50, 50, 50),
|
|
(5, 1080, 1920),
|
|
(1, 1080, 1920),
|
|
(20, 480, 640),
|
|
(50, 100, 100),
|
|
]
|
|
|
|
|
|
def _random_masks_and_xyxy(
|
|
rng: np.random.Generator,
|
|
num_masks: int,
|
|
img_h: int,
|
|
img_w: int,
|
|
fill_prob: float = 0.3,
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
|
"""Generate *num_masks* random boolean masks with matching tight xyxy boxes.
|
|
|
|
Each mask is built by filling a random sub-rectangle with Bernoulli noise at
|
|
``fill_prob``, then computing tight bounding boxes via ``mask_to_xyxy``.
|
|
This guarantees every mask has at least one True pixel (for non-degenerate
|
|
bounding boxes).
|
|
"""
|
|
masks = np.zeros((num_masks, img_h, img_w), dtype=bool)
|
|
for mask_idx in range(num_masks):
|
|
y1 = rng.integers(0, img_h)
|
|
y2 = rng.integers(y1, img_h)
|
|
x1 = rng.integers(0, img_w)
|
|
x2 = rng.integers(x1, img_w)
|
|
region = rng.random((y2 - y1 + 1, x2 - x1 + 1)) < fill_prob
|
|
# Ensure at least one True pixel.
|
|
if not region.any():
|
|
region[0, 0] = True
|
|
masks[mask_idx, y1 : y2 + 1, x1 : x2 + 1] = region
|
|
|
|
xyxy = mask_to_xyxy(masks).astype(np.float32)
|
|
return masks, xyxy
|
|
|
|
|
|
class TestCompactMaskRoundtripRandom:
|
|
"""from_dense -> to_dense pixel equality across 10 random seeds.
|
|
|
|
Uses tight bounding boxes so the round-trip must be lossless (all True
|
|
pixels lie strictly within the crop).
|
|
"""
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_parity_seed(self, seed: int) -> None:
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
np.testing.assert_array_equal(
|
|
cm.to_dense(),
|
|
masks,
|
|
err_msg=(
|
|
f"Round-trip failed for seed={seed}, "
|
|
f"N={num_masks}, shape=({img_h},{img_w})"
|
|
),
|
|
)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_shape_and_len(self, seed: int) -> None:
|
|
"""len() and .shape must agree with the dense array."""
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
assert len(cm) == num_masks
|
|
assert cm.shape == (num_masks, img_h, img_w)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_individual_mask_access(self, seed: int) -> None:
|
|
"""cm[i] must equal masks[i] for every index."""
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
for mask_idx in range(num_masks):
|
|
np.testing.assert_array_equal(
|
|
cm[mask_idx],
|
|
masks[mask_idx],
|
|
err_msg=f"cm[{mask_idx}] mismatch for seed={seed}",
|
|
)
|
|
|
|
|
|
class TestCompactMaskAreaRandom:
|
|
"""area from CompactMask equals dense .sum(axis=(1,2)) across 10 seeds."""
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_parity_seed(self, seed: int) -> None:
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
expected_area = masks.sum(axis=(1, 2))
|
|
np.testing.assert_array_equal(
|
|
cm.area,
|
|
expected_area,
|
|
err_msg=(
|
|
f"Area mismatch for seed={seed}, N={num_masks}, shape=({img_h},{img_w})"
|
|
),
|
|
)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_sum_axis_matches_area(self, seed: int) -> None:
|
|
"""cm.sum(axis=(1,2)) must equal cm.area (the fast path)."""
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
np.testing.assert_array_equal(cm.sum(axis=(1, 2)), cm.area)
|
|
|
|
|
|
class TestCompactMaskFilterRandom:
|
|
"""Boolean filter on CompactMask matches dense fancy indexing across 10 seeds."""
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_parity_seed(self, seed: int) -> None:
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
selector = rng.random(num_masks) > 0.5
|
|
# Guarantee at least one True in the selector so we test non-empty subsets.
|
|
if not selector.any():
|
|
selector[0] = True
|
|
|
|
subset_cm = cm[selector]
|
|
subset_dense = masks[selector]
|
|
|
|
assert isinstance(subset_cm, CompactMask)
|
|
assert len(subset_cm) == int(selector.sum())
|
|
np.testing.assert_array_equal(
|
|
subset_cm.to_dense(),
|
|
subset_dense,
|
|
err_msg=f"Boolean filter mismatch for seed={seed}",
|
|
)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_list_index(self, seed: int) -> None:
|
|
"""Integer list indexing must match dense fancy indexing."""
|
|
rng = np.random.default_rng(seed)
|
|
num_masks, img_h, img_w = _RANDOM_CONFIGS[seed]
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
num_selected = min(num_masks, max(1, rng.integers(1, num_masks + 1)))
|
|
indices = sorted(
|
|
rng.choice(num_masks, size=num_selected, replace=False).tolist()
|
|
)
|
|
|
|
subset_cm = cm[indices]
|
|
subset_dense = masks[indices]
|
|
np.testing.assert_array_equal(
|
|
subset_cm.to_dense(),
|
|
subset_dense,
|
|
err_msg=f"List index mismatch for seed={seed}, indices={indices}",
|
|
)
|
|
|
|
|
|
class TestCompactMaskWithOffsetRandom:
|
|
"""with_offset roundtrip matches move_masks across 10 random seeds."""
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_parity_seed(self, seed: int) -> None:
|
|
rng = np.random.default_rng(seed)
|
|
# Use smaller images to keep move_masks fast.
|
|
num_masks = rng.integers(1, 10)
|
|
img_h, img_w = int(rng.integers(30, 80)), int(rng.integers(30, 80))
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
# Random offset that may push some masks partially or fully off-frame.
|
|
dx = int(rng.integers(-img_w, img_w))
|
|
dy = int(rng.integers(-img_h, img_h))
|
|
|
|
cm_shifted = cm.with_offset(dx=dx, dy=dy, new_image_shape=(img_h, img_w))
|
|
expected = move_masks(
|
|
masks=masks,
|
|
offset=np.array([dx, dy], dtype=np.int32),
|
|
resolution_wh=(img_w, img_h),
|
|
)
|
|
|
|
np.testing.assert_array_equal(
|
|
cm_shifted.to_dense(),
|
|
expected,
|
|
err_msg=(
|
|
f"with_offset mismatch for seed={seed}, "
|
|
f"dx={dx}, dy={dy}, shape=({img_h},{img_w})"
|
|
),
|
|
)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_offset_into_larger_canvas(self, seed: int) -> None:
|
|
"""Offset into a larger destination image must preserve pixels."""
|
|
rng = np.random.default_rng(seed + 100)
|
|
num_masks = rng.integers(1, 8)
|
|
img_h, img_w = int(rng.integers(20, 50)), int(rng.integers(20, 50))
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
new_h, new_w = img_h * 2, img_w * 2
|
|
dx = int(rng.integers(0, img_w))
|
|
dy = int(rng.integers(0, img_h))
|
|
|
|
cm_shifted = cm.with_offset(dx=dx, dy=dy, new_image_shape=(new_h, new_w))
|
|
dense_shifted = cm_shifted.to_dense()
|
|
|
|
assert dense_shifted.shape == (num_masks, new_h, new_w)
|
|
# Manually place each original mask into the larger canvas.
|
|
expected = np.zeros((num_masks, new_h, new_w), dtype=bool)
|
|
for mask_idx in range(num_masks):
|
|
expected[mask_idx, dy : dy + img_h, dx : dx + img_w] |= masks[mask_idx]
|
|
|
|
np.testing.assert_array_equal(
|
|
dense_shifted,
|
|
expected,
|
|
err_msg=f"Larger canvas offset mismatch for seed={seed}",
|
|
)
|
|
|
|
|
|
class TestRleSplitCols:
|
|
"""Tests for _rle_split_cols: splitting F-order RLE into per-column lists."""
|
|
|
|
def test_all_true_2x2(self) -> None:
|
|
"""All-True 2x2 splits into two columns each [0, 2]."""
|
|
from supervision.detection.compact_mask import _rle_split_cols
|
|
|
|
mask = np.ones((2, 2), dtype=bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result = _rle_split_cols(rle, 2, 2)
|
|
assert result == [[0, 2], [0, 2]]
|
|
|
|
def test_all_false_3x3(self) -> None:
|
|
"""All-False 3x3 splits into three columns each [3]."""
|
|
from supervision.detection.compact_mask import _rle_split_cols
|
|
|
|
mask = np.zeros((3, 3), dtype=bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result = _rle_split_cols(rle, 3, 3)
|
|
assert result == [[3], [3], [3]]
|
|
|
|
def test_mixed_2x2(self) -> None:
|
|
"""Mixed mask splits correctly per column."""
|
|
from supervision.detection.compact_mask import _rle_split_cols
|
|
|
|
mask = np.array([[True, False], [True, True]], dtype=bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result = _rle_split_cols(rle, 2, 2)
|
|
assert result == [[0, 2], [1, 1]]
|
|
|
|
@pytest.mark.parametrize("seed", list(range(20)))
|
|
def test_round_trip_random(self, seed: int) -> None:
|
|
"""Split then rejoin must reconstruct original mask for random inputs."""
|
|
from supervision.detection.compact_mask import (
|
|
_rle_join_cols,
|
|
_rle_split_cols,
|
|
)
|
|
|
|
rng = np.random.default_rng(seed + 8000)
|
|
crop_h = int(rng.integers(1, 30))
|
|
crop_w = int(rng.integers(1, 30))
|
|
mask = rng.random((crop_h, crop_w)) < 0.4
|
|
rle = _mask_to_rle_counts(mask)
|
|
per_col = _rle_split_cols(rle, crop_h, crop_w)
|
|
|
|
assert len(per_col) == crop_w
|
|
for c in range(crop_w):
|
|
assert sum(per_col[c]) == crop_h, f"col {c} sum mismatch"
|
|
|
|
# Rejoin and verify pixel equality.
|
|
rejoined = _rle_join_cols(per_col, crop_h * crop_w)
|
|
decoded = _rle_counts_to_mask(rejoined, crop_h, crop_w)
|
|
np.testing.assert_array_equal(
|
|
decoded,
|
|
mask,
|
|
err_msg=f"Split→join round-trip failed for seed={seed}",
|
|
)
|
|
|
|
def test_join_true_true_junction_no_zero_run(self) -> None:
|
|
"""_rle_join_cols merges True/True boundary; no zero-length False run inserted.
|
|
|
|
When column A ends True and column B starts True (leading False count = 0),
|
|
the junction must produce a single merged True run, not a zero-length False
|
|
run between two True runs. A zero-length run would inflate len(rle) and
|
|
misroute density-based dispatch in _resize_crop.
|
|
"""
|
|
from supervision.detection.compact_mask import _rle_join_cols
|
|
|
|
# col A: [0, 3] → T=3 (height=3, all True)
|
|
# col B: [0, 3] → T=3 (height=3, all True)
|
|
# Merged: should be [0, 6], NOT [0, 3, 0, 3].
|
|
cols = [[0, 3], [0, 3]]
|
|
result = _rle_join_cols(cols, 6).tolist()
|
|
assert result == [0, 6], (
|
|
f"Expected [0, 6] (merged True runs), got {result}; "
|
|
"zero-length False run would inflate density metric"
|
|
)
|
|
assert 0 not in result[1:], "Zero-length run found after junction merge"
|
|
|
|
|
|
class TestCompactMaskResize:
|
|
"""Tests for CompactMask.resize method.
|
|
|
|
Verifies scaling behaviour, coordinate arithmetic, identity resize,
|
|
empty collections, invalid dimensions, and dense parity with cv2.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
("src_shape", "mask_slice", "target_shape", "description"),
|
|
[
|
|
(
|
|
(10, 10),
|
|
(slice(2, 5), slice(2, 5)),
|
|
(100, 100),
|
|
"10x upscale 10x10 to 100x100",
|
|
),
|
|
(
|
|
(480, 640),
|
|
(slice(100, 200), slice(150, 300)),
|
|
(240, 320),
|
|
"HD halve 480x640 to 240x320",
|
|
),
|
|
(
|
|
(100, 200),
|
|
(slice(20, 40), slice(50, 100)),
|
|
(50, 400),
|
|
"asymmetric: shrink H, grow W",
|
|
),
|
|
],
|
|
)
|
|
def test_scale_shape_and_offsets(
|
|
self,
|
|
src_shape: tuple[int, int],
|
|
mask_slice: tuple[slice, slice],
|
|
target_shape: tuple[int, int],
|
|
description: str,
|
|
) -> None:
|
|
"""Resize scales shape and offsets proportionally."""
|
|
img_h, img_w = src_shape
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, mask_slice[0], mask_slice[1]] = True
|
|
xyxy = mask_to_xyxy(masks)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=src_shape)
|
|
|
|
resized = cm.resize(target_shape)
|
|
|
|
assert resized.shape == (1, target_shape[0], target_shape[1]), description
|
|
|
|
sx = target_shape[1] / src_shape[1]
|
|
sy = target_shape[0] / src_shape[0]
|
|
orig_offset_x = int(cm.offsets[0, 0])
|
|
orig_offset_y = int(cm.offsets[0, 1])
|
|
expected_x = round(orig_offset_x * sx)
|
|
expected_y = round(orig_offset_y * sy)
|
|
assert abs(int(resized.offsets[0, 0]) - expected_x) <= 1, description
|
|
assert abs(int(resized.offsets[0, 1]) - expected_y) <= 1, description
|
|
|
|
def test_identity_preserves_rle(self) -> None:
|
|
"""Resize to same shape returns identical RLE, offsets, and crop shapes."""
|
|
masks = np.zeros((1, 80, 80), dtype=bool)
|
|
masks[0, 10:30, 15:45] = True
|
|
xyxy = mask_to_xyxy(masks)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(80, 80))
|
|
|
|
resized = cm.resize((80, 80))
|
|
|
|
assert resized.shape == cm.shape
|
|
np.testing.assert_array_equal(resized.offsets, cm.offsets)
|
|
np.testing.assert_array_equal(resized._crop_shapes, cm._crop_shapes)
|
|
for orig_rle, new_rle in zip(cm._rles, resized._rles):
|
|
np.testing.assert_array_equal(orig_rle, new_rle)
|
|
|
|
def test_empty_n0(self) -> None:
|
|
"""Resize of an empty CompactMask returns empty with new image_shape."""
|
|
masks = np.zeros((0, 50, 50), dtype=bool)
|
|
xyxy = np.empty((0, 4), dtype=np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(50, 50))
|
|
|
|
resized = cm.resize((100, 200))
|
|
|
|
assert len(resized) == 0
|
|
assert resized.shape == (0, 100, 200)
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_shape",
|
|
[
|
|
(0, 50),
|
|
(-1, 50),
|
|
(50, 0),
|
|
(50, -1),
|
|
],
|
|
)
|
|
def test_invalid_dimensions_raises(self, bad_shape: tuple[int, int]) -> None:
|
|
"""Resize with non-positive dimensions raises ValueError."""
|
|
masks = np.zeros((1, 50, 50), dtype=bool)
|
|
masks[0, 10:20, 10:20] = True
|
|
xyxy = mask_to_xyxy(masks)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(50, 50))
|
|
|
|
with pytest.raises(ValueError, match="positive"):
|
|
cm.resize(bad_shape)
|
|
|
|
def test_multi_mask_each_scales_independently(self) -> None:
|
|
"""N=4 masks at different positions all scale correctly after resize."""
|
|
img_h, img_w = 100, 100
|
|
target_h, target_w = 50, 50
|
|
masks = np.zeros((4, img_h, img_w), dtype=bool)
|
|
masks[0, 10:20, 10:20] = True
|
|
masks[1, 30:50, 30:50] = True
|
|
masks[2, 60:80, 60:80] = True
|
|
masks[3, 5:10, 80:90] = True
|
|
xyxy = mask_to_xyxy(masks)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
resized = cm.resize((target_h, target_w))
|
|
|
|
assert resized.shape == (4, target_h, target_w)
|
|
sx = target_w / img_w
|
|
sy = target_h / img_h
|
|
for i in range(4):
|
|
expected_x = round(int(cm.offsets[i, 0]) * sx)
|
|
expected_y = round(int(cm.offsets[i, 1]) * sy)
|
|
assert abs(int(resized.offsets[i, 0]) - expected_x) <= 1, f"mask {i} x"
|
|
assert abs(int(resized.offsets[i, 1]) - expected_y) <= 1, f"mask {i} y"
|
|
|
|
def test_zero_extent_extreme_downscale(self) -> None:
|
|
"""Extreme downscale that collapses a 1px bbox returns valid 1x1 crop."""
|
|
masks = np.zeros((1, 1000, 1000), dtype=bool)
|
|
masks[0, 500, 500] = True
|
|
xyxy = mask_to_xyxy(masks)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(1000, 1000))
|
|
|
|
resized = cm.resize((2, 2))
|
|
|
|
assert resized.shape == (1, 2, 2)
|
|
assert int(resized._crop_shapes[0, 0]) >= 1
|
|
assert int(resized._crop_shapes[0, 1]) >= 1
|
|
dense = resized.to_dense()
|
|
assert dense.shape == (1, 2, 2)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(10)))
|
|
def test_dense_parity_roundtrip(self, seed: int) -> None:
|
|
"""Resized CompactMask matches OpenCV-resized dense masks within 1px."""
|
|
import cv2
|
|
|
|
rng = np.random.default_rng(seed + 500)
|
|
img_h, img_w = 80, 120
|
|
target_h, target_w = 40, 60
|
|
num_masks = int(rng.integers(1, 5))
|
|
masks, xyxy = _random_masks_and_xyxy(rng, num_masks, img_h, img_w)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
resized = cm.resize((target_h, target_w))
|
|
resized_dense = resized.to_dense()
|
|
|
|
for i in range(num_masks):
|
|
expected = cv2.resize(
|
|
masks[i].astype(np.uint8),
|
|
(target_w, target_h),
|
|
interpolation=cv2.INTER_NEAREST,
|
|
).astype(bool)
|
|
actual = resized_dense[i]
|
|
diff = np.abs(actual.astype(int) - expected.astype(int)).max()
|
|
assert int(diff) <= 1, (
|
|
f"Dense parity mismatch for seed={seed}, mask={i}: "
|
|
f"max pixel diff={diff}"
|
|
)
|
|
|
|
|
|
class TestRleResize:
|
|
"""Tests for _rle_resize direct F-order RLE resizing.
|
|
|
|
Verifies that _rle_resize produces identical results to the decode ->
|
|
cv2.resize(INTER_NEAREST) -> encode path for identity, upscale, downscale,
|
|
non-square, all-False, all-True, single-pixel, and random masks.
|
|
"""
|
|
|
|
def test_identity_4x4(self) -> None:
|
|
"""Identity resize (same dimensions) preserves the decoded mask."""
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.array(
|
|
[
|
|
[False, True, True, False],
|
|
[True, True, False, False],
|
|
[False, False, True, True],
|
|
[True, False, False, True],
|
|
],
|
|
dtype=bool,
|
|
)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, 4, 4, 4, 4)
|
|
result = _rle_counts_to_mask(result_rle, 4, 4)
|
|
np.testing.assert_array_equal(result, mask)
|
|
|
|
def test_2x_upscale(self) -> None:
|
|
"""2x upscale of a 2x2 mask doubles each pixel."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.array(
|
|
[
|
|
[True, False],
|
|
[False, True],
|
|
],
|
|
dtype=bool,
|
|
)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, 2, 2, 4, 4)
|
|
result = _rle_counts_to_mask(result_rle, 4, 4)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8), (4, 4), interpolation=cv2.INTER_NEAREST
|
|
).astype(bool)
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
def test_2x_downscale(self) -> None:
|
|
"""2x downscale of a 4x4 block mask halves dimensions."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.array(
|
|
[
|
|
[True, True, False, False],
|
|
[True, True, False, False],
|
|
[False, False, True, True],
|
|
[False, False, True, True],
|
|
],
|
|
dtype=bool,
|
|
)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, 4, 4, 2, 2)
|
|
result = _rle_counts_to_mask(result_rle, 2, 2)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8), (2, 2), interpolation=cv2.INTER_NEAREST
|
|
).astype(bool)
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
def test_non_square_scale(self) -> None:
|
|
"""Non-square resize: 4x6 to 2x3 with independent axis scaling."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.zeros((4, 6), dtype=bool)
|
|
mask[0:2, 0:3] = True
|
|
mask[2:4, 3:6] = True
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, 4, 6, 2, 3)
|
|
result = _rle_counts_to_mask(result_rle, 2, 3)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8), (3, 2), interpolation=cv2.INTER_NEAREST
|
|
).astype(bool)
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
@pytest.mark.parametrize(
|
|
("src_shape", "dst_shape"),
|
|
[
|
|
((3, 3), (6, 6)),
|
|
((5, 5), (2, 2)),
|
|
((4, 6), (8, 12)),
|
|
((10, 10), (3, 3)),
|
|
],
|
|
)
|
|
def test_all_false(
|
|
self, src_shape: tuple[int, int], dst_shape: tuple[int, int]
|
|
) -> None:
|
|
"""All-False mask resizes to all-False regardless of dimensions."""
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.zeros(src_shape, dtype=bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, *src_shape, *dst_shape)
|
|
result = _rle_counts_to_mask(result_rle, *dst_shape)
|
|
assert not result.any()
|
|
|
|
@pytest.mark.parametrize(
|
|
("src_shape", "dst_shape"),
|
|
[
|
|
((3, 3), (6, 6)),
|
|
((5, 5), (2, 2)),
|
|
((4, 6), (8, 12)),
|
|
((10, 10), (3, 3)),
|
|
],
|
|
)
|
|
def test_all_true(
|
|
self, src_shape: tuple[int, int], dst_shape: tuple[int, int]
|
|
) -> None:
|
|
"""All-True mask resizes to all-True regardless of dimensions."""
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.ones(src_shape, dtype=bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, *src_shape, *dst_shape)
|
|
result = _rle_counts_to_mask(result_rle, *dst_shape)
|
|
assert result.all()
|
|
|
|
def test_single_pixel_true_upscale(self) -> None:
|
|
"""Single True pixel in a 3x3 mask upscaled preserves position."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.zeros((3, 3), dtype=bool)
|
|
mask[1, 1] = True
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, 3, 3, 6, 6)
|
|
result = _rle_counts_to_mask(result_rle, 6, 6)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8), (6, 6), interpolation=cv2.INTER_NEAREST
|
|
).astype(bool)
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
@pytest.mark.parametrize("seed", list(range(45)))
|
|
def test_roundtrip_parity_with_cv2(self, seed: int) -> None:
|
|
"""_rle_resize matches cv2.resize(INTER_NEAREST) within 1-pixel tolerance."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
rng = np.random.default_rng(seed + 7000)
|
|
crop_h = int(rng.integers(1, 50))
|
|
crop_w = int(rng.integers(1, 50))
|
|
new_crop_h = int(rng.integers(1, 100))
|
|
new_crop_w = int(rng.integers(1, 100))
|
|
|
|
mask = rng.random((crop_h, crop_w)) < 0.3
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, crop_h, crop_w, new_crop_h, new_crop_w)
|
|
result = _rle_counts_to_mask(result_rle, new_crop_h, new_crop_w)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8),
|
|
(new_crop_w, new_crop_h),
|
|
interpolation=cv2.INTER_NEAREST,
|
|
).astype(bool)
|
|
diff = np.abs(result.astype(int) - expected.astype(int)).max()
|
|
assert diff <= 1, (
|
|
f"Parity mismatch >1px for seed={seed}, "
|
|
f"src=({crop_h},{crop_w}), dst=({new_crop_h},{new_crop_w}): "
|
|
f"max diff={diff}"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("src_shape", "dst_shape"),
|
|
[
|
|
((1, 10), (1, 5)),
|
|
((10, 1), (5, 1)),
|
|
((1, 20), (1, 40)),
|
|
((20, 1), (40, 1)),
|
|
],
|
|
)
|
|
def test_tall_and_wide_crops(
|
|
self, src_shape: tuple[int, int], dst_shape: tuple[int, int]
|
|
) -> None:
|
|
"""Single-row and single-col crops scale correctly with cv2 parity."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
rng = np.random.default_rng(src_shape[0] * 31 + dst_shape[1] * 17)
|
|
mask = rng.random(src_shape) < 0.5
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, *src_shape, *dst_shape)
|
|
result = _rle_counts_to_mask(result_rle, *dst_shape)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8),
|
|
(dst_shape[1], dst_shape[0]),
|
|
interpolation=cv2.INTER_NEAREST,
|
|
).astype(bool)
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
@pytest.mark.parametrize(
|
|
("src_shape", "dst_shape"),
|
|
[
|
|
((7, 11), (5, 13)),
|
|
((13, 7), (17, 3)),
|
|
((3, 5), (11, 7)),
|
|
((11, 13), (7, 17)),
|
|
],
|
|
)
|
|
def test_prime_sized_crops(
|
|
self, src_shape: tuple[int, int], dst_shape: tuple[int, int]
|
|
) -> None:
|
|
"""Prime-sized crops with non-integer scale ratios match cv2 exactly."""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
rng = np.random.default_rng(src_shape[0] * 101 + dst_shape[1] * 53)
|
|
mask = rng.random(src_shape) < 0.4
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, *src_shape, *dst_shape)
|
|
result = _rle_counts_to_mask(result_rle, *dst_shape)
|
|
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8),
|
|
(dst_shape[1], dst_shape[0]),
|
|
interpolation=cv2.INTER_NEAREST,
|
|
).astype(bool)
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
@pytest.mark.parametrize(
|
|
("src_val", "src_shape", "dst_shape"),
|
|
[
|
|
(True, (1, 1), (32, 32)),
|
|
(False, (1, 1), (32, 32)),
|
|
],
|
|
)
|
|
def test_large_scale_ratio(
|
|
self,
|
|
src_val: bool,
|
|
src_shape: tuple[int, int],
|
|
dst_shape: tuple[int, int],
|
|
) -> None:
|
|
"""1x1 source resized to large shape fills entirely True or False."""
|
|
from supervision.detection.compact_mask import _rle_resize
|
|
|
|
mask = np.full(src_shape, src_val, dtype=bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
result_rle = _rle_resize(rle, *src_shape, *dst_shape)
|
|
result = _rle_counts_to_mask(result_rle, *dst_shape)
|
|
|
|
if src_val:
|
|
assert result.all(), "1x1 True -> large shape must be all True"
|
|
else:
|
|
assert not result.any(), "1x1 False -> large shape must be all False"
|
|
|
|
def test_resize_dispatch_uses_l3_for_sparse(self) -> None:
|
|
"""resize() dispatches to _rle_resize for sparse masks."""
|
|
img_h, img_w = 100, 100
|
|
masks = np.zeros((1, img_h, img_w), dtype=bool)
|
|
masks[0, 50, 50] = True
|
|
xyxy = mask_to_xyxy(masks).astype(np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
resized = cm.resize((200, 200))
|
|
|
|
assert resized.shape == (1, 200, 200)
|
|
dense = resized.to_dense()
|
|
assert dense.sum() > 0
|
|
|
|
def test_resize_dispatch_uses_cv2_for_dense(self) -> None:
|
|
"""_resize_crop falls back to cv2 for dense masks (above _L3_DENSITY_THRESHOLD).
|
|
|
|
Checkerboard yields ~1 run per pixel, far above the 0.25 threshold.
|
|
Result must match cv2.resize(INTER_NEAREST) within 1 pixel.
|
|
"""
|
|
import cv2
|
|
|
|
from supervision.detection.compact_mask import (
|
|
_L3_DENSITY_THRESHOLD,
|
|
_resize_crop,
|
|
)
|
|
from supervision.detection.utils.converters import _mask_to_rle_counts
|
|
|
|
h, w = 20, 20
|
|
# Checkerboard: alternates True/False → very dense RLE.
|
|
rows, cols = np.meshgrid(np.arange(h), np.arange(w), indexing="ij")
|
|
mask = ((rows + cols) % 2).astype(bool)
|
|
rle = _mask_to_rle_counts(mask)
|
|
density = len(rle) / max(1, h * w)
|
|
assert density >= _L3_DENSITY_THRESHOLD, (
|
|
f"Test precondition failed: density {density:.3f} < threshold "
|
|
f"{_L3_DENSITY_THRESHOLD}; checkerboard should be dense"
|
|
)
|
|
|
|
result_rle = _resize_crop(rle, h, w, h // 2, w // 2)
|
|
result = _rle_counts_to_mask(result_rle, h // 2, w // 2)
|
|
expected = cv2.resize(
|
|
mask.astype(np.uint8), (w // 2, h // 2), interpolation=cv2.INTER_NEAREST
|
|
).astype(bool)
|
|
diff = np.abs(result.astype(int) - expected.astype(int)).max()
|
|
assert int(diff) <= 1, f"Dense-path cv2 parity failed; max pixel diff={diff}"
|
|
|
|
|
|
class TestResizeParallelPath:
|
|
"""Tests for CompactMask.resize() thread-pool code path (N >= 8 masks)."""
|
|
|
|
def test_parallel_resize_correctness(self) -> None:
|
|
"""resize() with N=10 masks exercises ThreadPoolExecutor; output is correct."""
|
|
img_h, img_w = 80, 80
|
|
n = 10 # above _PARALLEL_THRESHOLD = 8
|
|
masks = np.zeros((n, img_h, img_w), dtype=bool)
|
|
for i in range(n):
|
|
r = 10 + i * 3
|
|
masks[i, r : r + 8, r : r + 8] = True
|
|
xyxy = mask_to_xyxy(masks).astype(np.float32)
|
|
cm = CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
target = (40, 40)
|
|
resized = cm.resize(target)
|
|
|
|
assert resized.shape == (n, target[0], target[1])
|
|
assert len(resized) == n
|
|
# Each resized mask must be non-empty (the small squares survive downscale).
|
|
for i in range(n):
|
|
assert resized[i].any(), f"Mask {i} is empty after parallel resize"
|
|
|
|
def test_parallel_matches_sequential(self) -> None:
|
|
"""Thread-pool path produces the same result as the sequential path."""
|
|
img_h, img_w = 60, 60
|
|
n_parallel = 10 # triggers thread pool
|
|
n_sequential = 4 # stays sequential
|
|
rng = np.random.default_rng(0)
|
|
|
|
def _make_masks(n: int) -> CompactMask:
|
|
masks = np.zeros((n, img_h, img_w), dtype=bool)
|
|
for i in range(n):
|
|
r, c = int(rng.integers(5, 30)), int(rng.integers(5, 30))
|
|
masks[i, r : r + 10, c : c + 10] = True
|
|
xyxy = mask_to_xyxy(masks).astype(np.float32)
|
|
return CompactMask.from_dense(masks, xyxy, image_shape=(img_h, img_w))
|
|
|
|
cm_par = _make_masks(n_parallel)
|
|
cm_seq = _make_masks(n_sequential)
|
|
|
|
target = (30, 30)
|
|
resized_par = cm_par.resize(target)
|
|
resized_seq = cm_seq.resize(target)
|
|
|
|
# Both return correct shapes.
|
|
assert resized_par.shape == (n_parallel, target[0], target[1])
|
|
assert resized_seq.shape == (n_sequential, target[0], target[1])
|