diff --git a/examples/compact_mask/benchmark.py b/examples/compact_mask/benchmark.py index 7ef5b137..7a7a1fb3 100644 --- a/examples/compact_mask/benchmark.py +++ b/examples/compact_mask/benchmark.py @@ -115,6 +115,9 @@ class ScenarioResult: merge_ok: bool | None offset_ok: bool | None centroids_ok: bool | None + dense_resize_s: float # nan when dense_skipped + compact_resize_s: float + resize_ok: bool | None # skip flags dense_skipped: bool = field(default=False) iou_dense_skipped: bool = field(default=False) @@ -327,6 +330,20 @@ def stage_build( return xyxy, masks_dense, class_ids, compact_mask +def _resize_dense_to_shape(masks: np.ndarray, new_h: int, new_w: int) -> np.ndarray: + """Nearest-neighbour resize of (N, H, W) bool masks to (N, new_h, new_w). + + Uses floor-division indexing (``arange * src // dst``) to match the + strategy in ``_rle_resize``, ensuring pixel-exact parity for correctness + comparisons in :func:`stage_resize`. + """ + orig_h, orig_w = masks.shape[1], masks.shape[2] + x = np.arange(new_w) * orig_w // new_w + y = np.arange(new_h) * orig_h // new_h + xv, yv = np.meshgrid(x, y) + return masks[:, yv, xv] + + def stage_encode( masks_dense: np.ndarray, xyxy: np.ndarray, @@ -563,6 +580,44 @@ def stage_centroids( return dense_centroids_s, compact_centroids_s, centroids_ok +def stage_resize( + masks_dense: np.ndarray, + compact_mask: CompactMask, + image_height: int, + image_width: int, + dense_skipped: bool, +) -> tuple[float, float, bool | None]: + """Time resize to half resolution; check pixel-level correctness. + + Dense path uses numpy fancy-indexing via ``_resize_dense_to_shape``. + Compact path times ``CompactMask.resize()``, which uses direct RLE + arithmetic for sparse masks (below ``_L3_DENSITY_THRESHOLD``) and + falls back to ``cv2.INTER_NEAREST`` decode/resize/re-encode for dense + masks. The two nearest-neighbour strategies can differ by 1 px at + bbox boundaries, so correctness is checked with 1-pixel tolerance. + """ + new_h, new_w = image_height // 2, image_width // 2 + new_shape = (new_h, new_w) + + # Use parallel=1 to avoid nested ThreadPoolExecutor contention: + # CompactMask.resize() itself spawns a thread pool for N >= _PARALLEL_THRESHOLD, + # and time_reps' own parallel outer loop would cause oversubscription. + compact_resize_s = time_reps(lambda: compact_mask.resize(new_shape), parallel=1) + if dense_skipped: + return math.nan, compact_resize_s, None + + resized_dense = _resize_dense_to_shape(masks_dense, new_h, new_w) + resized_compact = compact_mask.resize(new_shape).to_dense() + resize_ok = bool( + np.abs(resized_dense.astype(np.int8) - resized_compact.astype(np.int8)).max() + <= 1 + ) + dense_resize_s = time_reps( + lambda: _resize_dense_to_shape(masks_dense, new_h, new_w) + ) + return dense_resize_s, compact_resize_s, resize_ok + + # ══════════════════════════════════════════════════════════════════════════════ # Scenario runner — orchestrates stages # ══════════════════════════════════════════════════════════════════════════════ @@ -681,6 +736,9 @@ def run_scenario( dense_centroids_s, compact_centroids_s, centroids_ok = stage_centroids( masks_dense, compact_mask, dense_skipped ) + dense_resize_s, compact_resize_s, resize_ok = stage_resize( + masks_dense, compact_mask, image_height, image_width, dense_skipped + ) def _timing_line(label: str, dense_s: float, compact_s: float) -> str: compact_ms = f"{compact_s * 1e3:.2f} ms" @@ -704,6 +762,7 @@ def run_scenario( console.print(_timing_line("merge ", dense_merge_s, compact_merge_s)) console.print(_timing_line("nms ", dense_nms_s, compact_nms_s)) console.print(_timing_line("offset ", dense_offset_s, compact_offset_s)) + console.print(_timing_line("resize ", dense_resize_s, compact_resize_s)) checks = { "pixel-perfect": pixel_perfect, @@ -714,6 +773,7 @@ def run_scenario( "merge": merge_ok, "offset": offset_ok, "centroids": centroids_ok, + "resize": resize_ok, } parts = [] for k, v in checks.items(): @@ -777,6 +837,9 @@ def run_scenario( merge_ok=merge_ok, offset_ok=offset_ok, centroids_ok=centroids_ok, + dense_resize_s=dense_resize_s, + compact_resize_s=compact_resize_s, + resize_ok=resize_ok, dense_skipped=dense_skipped, iou_dense_skipped=iou_dense_skipped, ) @@ -803,7 +866,17 @@ def _time_compact_annotate(scene: np.ndarray, det_compact: sv.Detections) -> flo # Rich summary table # ══════════════════════════════════════════════════════════════════════════════ -_OPS = ("area", "filter", "annot", "iou", "nms", "merge", "offset", "centroids") +_OPS = ( + "area", + "filter", + "annot", + "iou", + "nms", + "merge", + "offset", + "centroids", + "resize", +) def _build_summary_df(results: list[ScenarioResult]) -> pd.DataFrame: @@ -835,6 +908,7 @@ def _build_summary_df(results: list[ScenarioResult]) -> pd.DataFrame: "merge_ok", "offset_ok", "centroids_ok", + "resize_ok", ] df["ok"] = df.apply( lambda row: ( @@ -899,6 +973,7 @@ def print_summary(results: list[ScenarioResult]) -> None: table.add_column("NMS\nop.", justify="right", min_width=6) table.add_column("Merge\nop.", justify="right", min_width=6) table.add_column("Offset\nop.", justify="right", min_width=6) + table.add_column("Resize\nop.", justify="right", min_width=6) table.add_column("Centr\nop.", justify="right", min_width=6) table.add_column("OK?", justify="center", min_width=4) @@ -940,6 +1015,7 @@ def print_summary(results: list[ScenarioResult]) -> None: _fmt_speedup(row["dense_nms_s"], row["compact_nms_s"]), _fmt_speedup(row["dense_merge_s"], row["compact_merge_s"]), _fmt_speedup(row["dense_offset_s"], row["compact_offset_s"]), + _fmt_speedup(row["dense_resize_s"], row["compact_resize_s"]), _fmt_speedup(row["dense_centroids_s"], row["compact_centroids_s"]), ok_cell, ) @@ -967,6 +1043,7 @@ def print_summary(results: list[ScenarioResult]) -> None: "NMS x — mask_non_max_suppression speedup", "Merge x — Detections.merge speedup", "Offset x — move_masks vs with_offset speedup", + "Resize x — resize-to-half speedup", "Centroids x — calculate_masks_centroids speedup", "dim ms — dense skipped, compact absolute time shown", ] @@ -1020,6 +1097,7 @@ def save_results_csv(results: list[ScenarioResult], path: Path) -> None: "encode_ms_per_mask": (df["encode_s"] * 1e3).round(4), "decode_ms_per_mask": (df["decode_s"] * 1e3).round(4), **{f"{op}_speedup": df[f"{op}_speedup"].round(2) for op in _OPS}, + "resize_ok": df["resize_ok"], "ok": df["ok"], } ).to_csv(path, index=False) diff --git a/src/supervision/detection/compact_mask.py b/src/supervision/detection/compact_mask.py index c3e307db..b2ffdac0 100644 --- a/src/supervision/detection/compact_mask.py +++ b/src/supervision/detection/compact_mask.py @@ -11,6 +11,7 @@ crop boundaries, so no extra metadata is required from the caller. from __future__ import annotations +import os from collections.abc import Iterator from typing import Any @@ -45,6 +46,346 @@ def _rle_area(rle: npt.NDArray[np.int32]) -> int: return int(np.sum(rle[1::2])) +def _rle_split_cols( + rle: npt.NDArray[np.int32], + crop_h: int, + crop_w: int, +) -> list[list[int]]: + """Split a flat F-order RLE into per-column run lists. + + With F-order (column-major) RLE the flat pixel sequence visits all rows + of column 0, then all rows of column 1, etc. Each column therefore + contains ``crop_h`` contiguous pixels. + + Runs that cross column boundaries are split at the boundary. Each + returned list starts with a ``False``-run count (possibly 0), matching + the convention of :func:`_mask_to_rle_counts`. + + Args: + rle: int32 run-length array as produced by + :func:`~supervision.detection.utils.converters._mask_to_rle_counts`. + crop_h: Number of rows (pixels per column). + crop_w: Number of columns. + + Returns: + List of ``crop_w`` run lists, one per column. Each list sums to + ``crop_h``. + + Examples: + ```pycon + >>> import numpy as np + >>> from supervision.detection.compact_mask import _rle_split_cols + >>> from supervision.detection.utils.converters import _mask_to_rle_counts + >>> mask = np.array([[True, False], [True, True]], dtype=bool) + >>> rle = _mask_to_rle_counts(mask) + >>> rle.tolist() + [0, 2, 1, 1] + >>> _rle_split_cols(rle, 2, 2) + [[0, 2], [1, 1]] + + ``` + """ + per_col: list[list[int]] = [[] for _ in range(crop_w)] + col = 0 + row = 0 + + for run_idx, run_len in enumerate(rle): + is_true = run_idx % 2 == 1 + remaining = int(run_len) + while remaining > 0: + space_in_col = crop_h - row + take = min(remaining, space_in_col) + if len(per_col[col]) == 0: + if is_true: + per_col[col].append(0) # leading False count = 0 + # Check if last run has same parity (True/False) as current chunk. + # Last element's parity: index (len-1) odd → True, even → False. + elif is_true == ((len(per_col[col]) - 1) % 2 == 1): + per_col[col][-1] += take + remaining -= take + row += take + if row >= crop_h: + row = 0 + col += 1 + continue + per_col[col].append(take) + remaining -= take + row += take + if row >= crop_h: + row = 0 + col += 1 + if col >= crop_w: + break + + # Fill any empty columns (all-False). + for c in range(crop_w): + if not per_col[c]: + per_col[c] = [crop_h] + + return per_col + + +def _rle_scale_col( + col_runs: list[int], + src_h: int, + row_map: npt.NDArray[np.int32], +) -> list[int]: + """Scale one column's run list to a new height using a precomputed row map. + + Each output row is mapped to a source row via ``row_map``, which + implements nearest-neighbour resampling in the vertical direction. + + Args: + col_runs: Per-column run list starting with a ``False``-run count. + src_h: Height of the source column (sum of ``col_runs``). + row_map: int32 array of length ``new_crop_h``; ``row_map[r']`` is the + source row index for output row ``r'``. Use + ``(np.arange(new_crop_h) * src_h // new_crop_h)`` for + ``cv2.INTER_NEAREST``-compatible mapping. + + Returns: + Scaled run list of total length ``len(row_map)``, always starting + with a ``False``-run count. + + Examples: + ```pycon + >>> import numpy as np + >>> from supervision.detection.compact_mask import _rle_scale_col + >>> col_runs = [0, 2, 2] # F=0, T=2, F=2 → [T, T, F, F] + >>> row_map = np.array([0, 1, 2, 3, 0, 1, 2, 3], dtype=np.int32) + >>> _rle_scale_col(col_runs, 4, row_map) + [0, 2, 2, 2, 2] + + ``` + """ + new_crop_h = len(row_map) + if new_crop_h == 0: + return [0] + + # Reconstruct per-source-row boolean values from run list. + src_values: npt.NDArray[np.bool_] = np.empty(src_h, dtype=np.bool_) + pos = 0 + for ri, rl in enumerate(col_runs): + src_values[pos : pos + rl] = ri % 2 == 1 # odd index → True + pos += rl + if pos < src_h: + src_values[pos:] = False # pad truncated RLE + + # Map output rows to source values. + out_values = src_values[row_map] + + # RLE-encode the output column; vectorised via np.diff on bool view. + out_uint8 = out_values.view(np.uint8) + boundaries = np.flatnonzero(np.diff(out_uint8)) + run_starts: npt.NDArray[np.int64] = np.empty(len(boundaries) + 1, dtype=np.int64) + run_ends: npt.NDArray[np.int64] = np.empty(len(boundaries) + 1, dtype=np.int64) + run_starts[0] = 0 + run_starts[1:] = boundaries + 1 + run_ends[:-1] = boundaries + 1 + run_ends[-1] = new_crop_h + result_runs: list[int] = (run_ends - run_starts).tolist() + # RLE starts with a False count; prepend 0 if output begins with True. + if bool(out_values[0]): + result_runs.insert(0, 0) + return result_runs + + +def _rle_join_cols( + scaled_cols: list[list[int]], + new_total: int, +) -> npt.NDArray[np.int32]: + """Concatenate per-column run lists into a flat RLE, merging junctions. + + Each column run list starts with a ``False``-run count. Two junction types + can be merged across column boundaries: + + * ``False``/``False``: the trailing False run merges with the leading False + run of the next column (leading count may be zero). + * ``True``/``True``: when the accumulated output ends on a True run and the + next column's leading False count is zero (column starts with True), the + two True runs are merged to avoid inserting a zero-length False run that + would inflate ``len(rle)`` and skew the density metric in + :func:`_resize_crop`. + + Args: + scaled_cols: List of per-column run lists, each starting with a + ``False``-run count. + new_total: Total pixel count of the output (fallback for empty input). + + Returns: + Flat int32 RLE array starting with a ``False``-run count. + + Examples: + ```pycon + >>> import numpy as np + >>> from supervision.detection.compact_mask import _rle_join_cols + >>> cols = [[1, 2], [1, 2]] # each col: F=1, T=2 + >>> _rle_join_cols(cols, 6).tolist() + [1, 2, 1, 2] + + ``` + """ + output_runs: list[int] = [] + for col_runs in scaled_cols: + if not output_runs: + output_runs.extend(col_runs) + else: + last_is_true = (len(output_runs) - 1) % 2 == 1 + # col_runs always starts with a False count → first_is_true=False + if not last_is_true: # last == False == first → merge + output_runs[-1] += col_runs[0] + output_runs.extend(col_runs[1:]) + elif col_runs[0] == 0 and len(col_runs) > 1: + # last run = True; column also starts True (leading False = 0) + # → merge to avoid a zero-length False run at the junction. + output_runs[-1] += col_runs[1] + output_runs.extend(col_runs[2:]) + else: + output_runs.extend(col_runs) + + return np.array(output_runs if output_runs else [new_total], dtype=np.int32) + + +def _rle_resize( + rle: npt.NDArray[np.int32], + crop_h: int, + crop_w: int, + new_crop_h: int, + new_crop_w: int, +) -> npt.NDArray[np.int32]: + """Resize an F-order RLE-encoded crop via nearest-neighbour resampling. + + Manipulates run lengths directly without decoding to a full 2D boolean + array. Delegates to :func:`_rle_split_cols`, :func:`_rle_scale_col`, + and :func:`_rle_join_cols`. + + The nearest-neighbour mapping ``src = floor(dst * src_size / dst_size)`` + is bit-exact with ``cv2.INTER_NEAREST``. + + Args: + rle: int32 array of F-order run lengths as produced by + :func:`~supervision.detection.utils.converters._mask_to_rle_counts`. + Starts with a ``False``-run count (may be 0). + crop_h: Height of the original crop. + crop_w: Width of the original crop. + new_crop_h: Height of the resized crop. + new_crop_w: Width of the resized crop. + + Returns: + int32 array of F-order run lengths for the resized crop, starting + with the ``False``-run count. + + Examples: + Upscale a 3x3 mask with a diagonal True stripe to 6x6: + + ```pycon + >>> import numpy as np + >>> from supervision.detection.compact_mask import _rle_resize + >>> from supervision.detection.utils.converters import ( + ... _mask_to_rle_counts, _rle_counts_to_mask, + ... ) + >>> mask = np.array([ + ... [True, False, False], + ... [False, True, False], + ... [False, False, True ], + ... ], dtype=bool) + >>> rle = _mask_to_rle_counts(mask) + >>> resized_rle = _rle_resize(rle, 3, 3, 6, 6) + >>> result = _rle_counts_to_mask(resized_rle, 6, 6) + >>> result.astype(int) + array([[1, 1, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0], + [0, 0, 1, 1, 0, 0], + [0, 0, 1, 1, 0, 0], + [0, 0, 0, 0, 1, 1], + [0, 0, 0, 0, 1, 1]]) + + ``` + """ + new_total = new_crop_h * new_crop_w + + if crop_h * crop_w == 0 or new_total == 0: + return np.array([0], dtype=np.int32) + if len(rle) == 1 or int(np.sum(rle[1::2])) == 0: + return np.array([new_total], dtype=np.int32) + if len(rle) == 2 and rle[0] == 0: + return np.array([0, new_total], dtype=np.int32) + + per_col = _rle_split_cols(rle, crop_h, crop_w) + + # cv2.INTER_NEAREST column mapping: src = floor(dst * src_w / dst_w) + col_map = (np.arange(new_crop_w) * crop_w // new_crop_w).astype(np.int32) + + # cv2.INTER_NEAREST row mapping: src = floor(dst * src_h / dst_h) + row_map = (np.arange(new_crop_h) * crop_h // new_crop_h).astype(np.int32) + + # Scale each unique source column once; reuse via cache for repeated cols. + col_cache: dict[int, list[int]] = {} + scaled_cols = [] + for src_c in col_map: + if src_c not in col_cache: + col_cache[src_c] = _rle_scale_col(per_col[src_c], crop_h, row_map) + scaled_cols.append(col_cache[src_c]) + + return _rle_join_cols(scaled_cols, new_total) + + +# Fraction of (run_count / pixel_count) below which _rle_resize is used +# instead of the decode → cv2 → re-encode path. Sparse masks have few long +# runs; dense/complex masks approach 1 run per 2 pixels. +_L3_DENSITY_THRESHOLD: float = 0.25 +# Thread overhead outweighs gains below this mask count. +_PARALLEL_THRESHOLD: int = 8 + + +def _resize_crop( + rle: npt.NDArray[np.int32], + orig_h: int, + orig_w: int, + new_h: int, + new_w: int, +) -> npt.NDArray[np.int32]: + """Resize one RLE crop to ``(new_h, new_w)``, choosing the fastest path. + + Dispatch order: + + 1. **All-False fast path** — returns a single False run; no decode. + 2. **L3 direct RLE path** — used when run density is below + :data:`_L3_DENSITY_THRESHOLD`; manipulates run lengths without + allocating a 2D array. + 3. **cv2 fallback** — decodes to ``uint8``, calls + ``cv2.resize(INTER_NEAREST)``, re-encodes; used for dense masks. + + Args: + rle: int32 run-length array for the source crop. + orig_h: Height of the source crop. + orig_w: Width of the source crop. + new_h: Target height. + new_w: Target width. + + Returns: + int32 RLE array for the resized crop. + """ + import cv2 + + # All-False: skip decode entirely. + if _rle_area(rle) == 0: + return np.array([new_h * new_w], dtype=np.int32) + + # L3: direct RLE arithmetic for sparse masks. + if len(rle) / max(1, orig_h * orig_w) < _L3_DENSITY_THRESHOLD: + return _rle_resize(rle, orig_h, orig_w, new_h, new_w) + + # cv2 fallback for dense masks. + crop = _rle_counts_to_mask(rle, orig_h, orig_w) + resized = cv2.resize( + crop.view(np.uint8), + (new_w, new_h), + interpolation=cv2.INTER_NEAREST, + ).astype(bool) + return _mask_to_rle_counts(resized) + + class CompactMask: """Memory-efficient crop-RLE mask storage for instance segmentation. @@ -843,3 +1184,123 @@ class CompactMask: np.array(out_offsets_list, dtype=np.int32), new_image_shape, ) + + # ------------------------------------------------------------------ + # Resize + # ------------------------------------------------------------------ + + def resize(self, new_image_shape: tuple[int, int]) -> CompactMask: + """Return a new CompactMask scaled to a different image resolution. + + Each crop mask is resized with nearest-neighbour interpolation. + Sparse masks use direct RLE arithmetic (:func:`_rle_resize`); dense + masks fall back to ``cv2.resize(INTER_NEAREST)``. Offsets and crop + dimensions are scaled proportionally to the new image size. + + Performance notes: + + * Coordinate arithmetic is fully vectorised (no Python loop over N). + * All-``False`` crops skip decode/resize entirely. + * For N >= 8, resize runs in a thread pool — NumPy and OpenCV + release the GIL so crops execute in parallel on multi-core CPUs. + + Args: + new_image_shape: ``(H, W)`` of the target image. + + Returns: + New :class:`CompactMask` with updated ``image_shape``, scaled + offsets, scaled crop shapes, and re-encoded RLE crops. + + Raises: + ValueError: If any dimension in *new_image_shape* is ``<= 0``. + + Examples: + ```pycon + >>> import numpy as np + >>> from supervision.detection.compact_mask import CompactMask + >>> masks = np.zeros((1, 100, 100), dtype=bool) + >>> masks[0, 20:40, 30:60] = True + >>> xyxy = np.array([[30, 20, 59, 39]], dtype=np.float32) + >>> cm = CompactMask.from_dense(masks, xyxy, image_shape=(100, 100)) + >>> small = cm.resize((50, 50)) + >>> small.shape + (1, 50, 50) + >>> small.offsets[0].tolist() + [15, 10] + + ``` + """ + from concurrent.futures import ThreadPoolExecutor + + new_h, new_w = new_image_shape + if new_h <= 0 or new_w <= 0: + raise ValueError("new_image_shape must contain positive dimensions") + + # fast path — identity resize; list() creates a new container but the + # individual RLE numpy arrays are shared (shallow copy). Callers must + # not mutate returned RLE arrays in-place. + if (new_h, new_w) == self._image_shape: + return CompactMask( + list(self._rles), + self._crop_shapes.copy(), + self._offsets.copy(), + new_image_shape, + ) + + # empty guard + if len(self) == 0: + return CompactMask( + [], + np.empty((0, 2), dtype=np.int32), + np.empty((0, 2), dtype=np.int32), + new_image_shape, + ) + + img_h, img_w = self._image_shape + sx = new_w / img_w + sy = new_h / img_h + + # L1 — vectorised coordinate arithmetic; no Python loop over N masks. + x1s = self._offsets[:, 0].astype(np.float64) + y1s = self._offsets[:, 1].astype(np.float64) + x2s = x1s + self._crop_shapes[:, 1] - 1 # inclusive right edge + y2s = y1s + self._crop_shapes[:, 0] - 1 # inclusive bottom edge + + new_x1s = np.clip(np.round(x1s * sx), 0, new_w - 1).astype(np.int32) + new_y1s = np.clip(np.round(y1s * sy), 0, new_h - 1).astype(np.int32) + new_x2s = np.clip(np.round(x2s * sx), 0, new_w - 1).astype(np.int32) + new_y2s = np.clip(np.round(y2s * sy), 0, new_h - 1).astype(np.int32) + new_crop_ws: npt.NDArray[np.int32] = np.maximum( + 1, new_x2s - new_x1s + 1 + ).astype(np.int32) + new_crop_hs: npt.NDArray[np.int32] = np.maximum( + 1, new_y2s - new_y1s + 1 + ).astype(np.int32) + + # L2b — parallel per-crop resize; NumPy and OpenCV release the GIL. + orig_crop_hs = self._crop_shapes[:, 0] + orig_crop_ws = self._crop_shapes[:, 1] + + args = [ + ( + self._rles[i], + int(orig_crop_hs[i]), + int(orig_crop_ws[i]), + int(new_crop_hs[i]), + int(new_crop_ws[i]), + ) + for i in range(len(self)) + ] + + n = len(self) + if n >= _PARALLEL_THRESHOLD: + with ThreadPoolExecutor(max_workers=min(n, os.cpu_count() or 4)) as pool: + new_rles: list[npt.NDArray[np.int32]] = list( + pool.map(lambda a: _resize_crop(*a), args) + ) + else: + new_rles = [_resize_crop(*a) for a in args] + + new_crop_shapes = np.column_stack((new_crop_hs, new_crop_ws)).astype(np.int32) + new_offsets = np.column_stack((new_x1s, new_y1s)).astype(np.int32) + return CompactMask(new_rles, new_crop_shapes, new_offsets, new_image_shape) diff --git a/tests/detection/test_compact_mask.py b/tests/detection/test_compact_mask.py index f7106799..c05bc876 100644 --- a/tests/detection/test_compact_mask.py +++ b/tests/detection/test_compact_mask.py @@ -977,3 +977,615 @@ class TestCompactMaskWithOffsetRandom: 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])