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
1177 lines
46 KiB
Python
1177 lines
46 KiB
Python
"""CompactMask demo & benchmark.
|
|
|
|
Demonstrates that ``CompactMask`` is a drop-in replacement for dense
|
|
``(N, H, W)`` bool arrays in ``supervision.Detections``, while using
|
|
significantly less memory and enabling faster annotation. The annotation
|
|
timing reports frame size, detection count, mask area ratio, and
|
|
``MaskAnnotator`` speedup from ROI-only blending.
|
|
|
|
Run with:
|
|
uv run python examples/compact_mask/benchmark.py
|
|
|
|
No GPU or real model is required — everything is synthesized with NumPy.
|
|
Mask complexity is controlled by ``num_vertices``: random polygons with more
|
|
vertices produce jaggier boundaries and more RLE runs per row.
|
|
"""
|
|
|
|
import dataclasses
|
|
import gc
|
|
import json
|
|
import math
|
|
import time
|
|
import tracemalloc
|
|
from collections.abc import Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pandas as pd
|
|
from rich import box
|
|
from rich.console import Console
|
|
from rich.progress import (
|
|
BarColumn,
|
|
MofNCompleteColumn,
|
|
Progress,
|
|
TaskProgressColumn,
|
|
TextColumn,
|
|
TimeElapsedColumn,
|
|
)
|
|
from rich.table import Table
|
|
|
|
import supervision as sv
|
|
from supervision.detection.compact_mask import CompactMask
|
|
|
|
console = Console(width=240, force_terminal=True)
|
|
|
|
REPETITIONS = 4
|
|
# How many reps to run concurrently in time_reps. Each thread times itself
|
|
# independently; results are averaged. Numpy releases the GIL for its C-level
|
|
# work so threads can truly run in parallel on multi-core machines.
|
|
# Set to 1 to disable parallelism and revert to a sequential timing loop.
|
|
PARALLEL = 3
|
|
# Dense timing is skipped when the dense (N,H,W) array would exceed this
|
|
# threshold — avoids OOM / swap thrashing on extreme scenarios while still
|
|
# reporting the theoretical memory footprint.
|
|
DENSE_SKIP_GB = 16.0
|
|
# Dense IoU *and NMS* timing are skipped above this threshold: pairwise
|
|
# (N,H,W) AND is extremely expensive — NMS calls IoU internally so both are
|
|
# gated by the same threshold.
|
|
IOU_DENSE_SKIP_GB = 1.0
|
|
# Reps for dense IoU/NMS — a single pass already takes several seconds.
|
|
IOU_NMS_REPS = 2
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Result container
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
@dataclass
|
|
class ScenarioResult:
|
|
name: str
|
|
resolution: str # e.g. "1920x1080"
|
|
num_objects: int
|
|
fill_name: str # mask area ratio, e.g. "5%"
|
|
num_vertices: int # polygon vertex count — complexity proxy
|
|
# memory (theoretical: raw numpy nbytes)
|
|
dense_bytes: int
|
|
compact_bytes_theoretical: int
|
|
# memory (actual: tracemalloc peak; dense_bytes_actual=0 when dense_skipped=True)
|
|
dense_bytes_actual: int
|
|
compact_bytes_actual: int
|
|
# compactness overhead — absolute times for conversion (always measured)
|
|
encode_s: float # CompactMask.from_dense() dense → compact
|
|
decode_s: float # compact_mask.to_dense() compact → dense
|
|
# timing (nan when dense_skipped=True)
|
|
dense_area_s: float
|
|
compact_area_s: float
|
|
dense_filter_s: float
|
|
compact_filter_s: float
|
|
dense_annot_s: float
|
|
compact_annot_s: float
|
|
# pipeline stages (nan when respective skip flag is True)
|
|
dense_iou_s: float # nan when iou_dense_skipped
|
|
compact_iou_s: float
|
|
dense_nms_s: float # nan when dense_skipped
|
|
compact_nms_s: float
|
|
dense_merge_s: float # nan when dense_skipped
|
|
compact_merge_s: float
|
|
dense_offset_s: float # nan when dense_skipped
|
|
compact_offset_s: float
|
|
dense_centroids_s: float # nan when dense_skipped
|
|
compact_centroids_s: float
|
|
# correctness (None when the stage was skipped)
|
|
pixel_perfect: bool | None
|
|
areas_match: bool | None
|
|
roundtrip_ok: bool | None
|
|
iou_ok: bool | None
|
|
nms_ok: bool | None
|
|
nms_mismatch_count: (
|
|
int # detections with different NMS decisions (0 when dense_skipped)
|
|
)
|
|
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)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Synthetic data helpers
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def make_scene(image_height: int, image_width: int) -> np.ndarray:
|
|
"""Random BGR image."""
|
|
return np.random.default_rng(42).integers(
|
|
0, 255, (image_height, image_width, 3), dtype=np.uint8
|
|
)
|
|
|
|
|
|
def _make_polygon_mask(
|
|
image_height: int,
|
|
image_width: int,
|
|
center_x: int,
|
|
center_y: int,
|
|
axis_x: int,
|
|
axis_y: int,
|
|
rng: np.random.Generator,
|
|
num_vertices: int,
|
|
) -> np.ndarray:
|
|
"""Random polygon mask.
|
|
|
|
*num_vertices* is a direct complexity proxy: more vertices → more
|
|
independent radius samples → jaggier boundary → more RLE runs per row.
|
|
No smoothing is applied so the relationship is monotone.
|
|
"""
|
|
angles = np.sort(rng.uniform(0, 2 * np.pi, num_vertices))
|
|
radii = rng.uniform(0.3, 1.0, num_vertices)
|
|
pts_x = np.clip(
|
|
(center_x + axis_x * radii * np.cos(angles)).astype(np.int32),
|
|
0,
|
|
image_width - 1,
|
|
)
|
|
pts_y = np.clip(
|
|
(center_y + axis_y * radii * np.sin(angles)).astype(np.int32),
|
|
0,
|
|
image_height - 1,
|
|
)
|
|
pts = np.column_stack([pts_x, pts_y]).reshape(-1, 1, 2)
|
|
canvas = np.zeros((image_height, image_width), dtype=np.uint8)
|
|
cv2.fillPoly(canvas, [pts], 1)
|
|
return canvas.astype(bool)
|
|
|
|
|
|
def make_detections(
|
|
num_objects: int,
|
|
image_height: int,
|
|
image_width: int,
|
|
fill_fraction: float,
|
|
num_vertices: int = 20,
|
|
seed: int = 0,
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
"""Return ``(xyxy, masks_dense, class_ids)`` with random polygon masks.
|
|
|
|
*num_vertices* controls mask complexity: more vertices → jaggier boundary.
|
|
"""
|
|
rng = np.random.default_rng(seed)
|
|
half = max(
|
|
2,
|
|
int(
|
|
(image_height * image_width * fill_fraction / (np.pi * num_objects)) ** 0.5
|
|
),
|
|
)
|
|
xyxy_list = []
|
|
masks = np.zeros((num_objects, image_height, image_width), dtype=bool)
|
|
for index in range(num_objects):
|
|
center_x = int(rng.integers(half + 1, image_width - half - 1))
|
|
center_y = int(rng.integers(half + 1, image_height - half - 1))
|
|
axis_x = int(rng.integers(max(2, half // 2), half * 2 + 1))
|
|
axis_y = int(rng.integers(max(2, half // 2), half * 2 + 1))
|
|
masks[index] = _make_polygon_mask(
|
|
image_height,
|
|
image_width,
|
|
center_x,
|
|
center_y,
|
|
axis_x,
|
|
axis_y,
|
|
rng,
|
|
num_vertices,
|
|
)
|
|
xyxy_list.append(
|
|
[
|
|
max(0, center_x - axis_x),
|
|
max(0, center_y - axis_y),
|
|
min(image_width - 1, center_x + axis_x),
|
|
min(image_height - 1, center_y + axis_y),
|
|
]
|
|
)
|
|
xyxy = np.array(xyxy_list, dtype=np.float32)
|
|
class_ids = rng.integers(0, 10, num_objects, dtype=int)
|
|
return xyxy, masks, class_ids
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Memory helpers
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def dense_memory_bytes(masks: np.ndarray) -> int:
|
|
"""Theoretical dense footprint: raw numpy buffer size."""
|
|
return int(masks.nbytes)
|
|
|
|
|
|
def compact_memory_bytes_theoretical(compact_mask: CompactMask) -> int:
|
|
"""Theoretical compact footprint: sum of all internal numpy buffer sizes."""
|
|
return int(
|
|
compact_mask._crop_shapes.nbytes
|
|
+ compact_mask._offsets.nbytes
|
|
+ sum(rle.nbytes for rle in compact_mask._rles),
|
|
)
|
|
|
|
|
|
def measure_peak_bytes(func: Callable[[], object]) -> int:
|
|
"""Wrapper that runs *func* under tracemalloc and returns peak allocation.
|
|
|
|
tracemalloc captures every Python-level allocation — numpy buffers, list
|
|
nodes, object headers — giving the true heap cost of anything *func*
|
|
builds. The return value of *func* is discarded so the object does not
|
|
stay alive.
|
|
"""
|
|
tracemalloc.start()
|
|
tracemalloc.clear_traces()
|
|
func()
|
|
_, peak = tracemalloc.get_traced_memory()
|
|
tracemalloc.stop()
|
|
return int(peak)
|
|
|
|
|
|
def dense_memory_bytes_actual(
|
|
num_objects: int, image_height: int, image_width: int
|
|
) -> int:
|
|
"""Actual dense footprint: peak bytes during (N, H, W) bool array alloc."""
|
|
return measure_peak_bytes(
|
|
lambda: np.zeros((num_objects, image_height, image_width), dtype=bool),
|
|
)
|
|
|
|
|
|
def compact_memory_bytes_actual(
|
|
masks_dense: np.ndarray,
|
|
xyxy: np.ndarray,
|
|
image_shape: tuple[int, int],
|
|
) -> int:
|
|
"""Actual compact footprint: peak bytes during CompactMask.from_dense()."""
|
|
return measure_peak_bytes(
|
|
lambda: CompactMask.from_dense(masks_dense, xyxy, image_shape=image_shape),
|
|
)
|
|
|
|
|
|
def time_reps(
|
|
func: Callable[[], object],
|
|
repeats: int = REPETITIONS,
|
|
parallel: int = PARALLEL,
|
|
) -> float:
|
|
"""Run *func* *reps* times and return mean wall-clock seconds per call.
|
|
|
|
When ``parallel > 1``, up to ``parallel`` calls run simultaneously in
|
|
threads. Numpy and OpenCV release the GIL for their C-level work, so
|
|
threads can execute in parallel on multi-core machines. Each thread
|
|
records its own elapsed time; the mean across all *reps* is returned.
|
|
|
|
When ``parallel == 1`` the original sequential loop is used, avoiding
|
|
any thread-scheduling overhead and improving accuracy for cheap functions.
|
|
|
|
A full GC cycle is run before timing so accumulated garbage from earlier
|
|
stages does not trigger collection mid-measurement and inflate results.
|
|
"""
|
|
gc.collect()
|
|
if parallel <= 1:
|
|
t0 = time.perf_counter()
|
|
for _ in range(repeats):
|
|
func()
|
|
return (time.perf_counter() - t0) / repeats
|
|
|
|
def _timed() -> float:
|
|
t0 = time.perf_counter()
|
|
func()
|
|
return time.perf_counter() - t0
|
|
|
|
with ThreadPoolExecutor(max_workers=min(parallel, repeats)) as pool:
|
|
timings = list(pool.map(lambda _: _timed(), range(repeats)))
|
|
return sum(timings) / repeats
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Benchmark stages
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def stage_build(
|
|
num_objects: int,
|
|
image_height: int,
|
|
image_width: int,
|
|
fill_fraction: float,
|
|
num_vertices: int = 20,
|
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray, CompactMask]:
|
|
"""Synthesize polygon masks and build the CompactMask."""
|
|
xyxy, masks_dense, class_ids = make_detections(
|
|
num_objects, image_height, image_width, fill_fraction, num_vertices
|
|
)
|
|
compact_mask = CompactMask.from_dense(
|
|
masks_dense, xyxy, image_shape=(image_height, image_width)
|
|
)
|
|
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,
|
|
image_height: int,
|
|
image_width: int,
|
|
) -> float:
|
|
"""Per-mask encode time: encode each mask individually and average over N.
|
|
|
|
Calling from_dense one mask at a time (rather than batching all N) isolates
|
|
the per-shape cost — each polygon has a different RLE run count, so the
|
|
average reflects true shape variance.
|
|
"""
|
|
num_masks = len(masks_dense)
|
|
image_shape = (image_height, image_width)
|
|
|
|
def _encode_each() -> None:
|
|
for i in range(num_masks):
|
|
CompactMask.from_dense(
|
|
masks_dense[i : i + 1], xyxy[i : i + 1], image_shape=image_shape
|
|
)
|
|
|
|
return time_reps(_encode_each) / max(num_masks, 1)
|
|
|
|
|
|
def stage_decode(compact_mask: CompactMask) -> float:
|
|
"""Per-mask decode time: decode each mask individually and average over N.
|
|
|
|
Building a list via compact_mask[i] decodes each crop separately, giving
|
|
the per-mask cost of materialising a single RLE back to a dense array.
|
|
"""
|
|
num_masks = len(compact_mask)
|
|
return time_reps(lambda: [compact_mask[i] for i in range(num_masks)]) / max(
|
|
num_masks, 1
|
|
)
|
|
|
|
|
|
def stage_area(
|
|
det_dense: sv.Detections, det_compact: sv.Detections
|
|
) -> tuple[float, float]:
|
|
"""Time .area on both representations."""
|
|
return (
|
|
time_reps(lambda: det_dense.area),
|
|
time_reps(lambda: det_compact.area),
|
|
)
|
|
|
|
|
|
def stage_filter(
|
|
det_dense: sv.Detections, det_compact: sv.Detections
|
|
) -> tuple[float, float]:
|
|
"""Time boolean filtering (keep every other detection)."""
|
|
keep = np.arange(len(det_dense)) % 2 == 0
|
|
return (
|
|
time_reps(lambda: det_dense[keep]),
|
|
time_reps(lambda: det_compact[keep]),
|
|
)
|
|
|
|
|
|
def stage_annotate(
|
|
scene: np.ndarray, det_dense: sv.Detections, det_compact: sv.Detections
|
|
) -> tuple[float, float]:
|
|
"""Time MaskAnnotator on both representations."""
|
|
annotator = sv.MaskAnnotator(opacity=0.5)
|
|
return (
|
|
time_reps(lambda: annotator.annotate(scene.copy(), det_dense)),
|
|
time_reps(lambda: annotator.annotate(scene.copy(), det_compact)),
|
|
)
|
|
|
|
|
|
def stage_correctness(
|
|
scene: np.ndarray,
|
|
masks_dense: np.ndarray,
|
|
compact_mask: CompactMask,
|
|
det_dense: sv.Detections,
|
|
det_compact: sv.Detections,
|
|
) -> tuple[bool, bool, bool]:
|
|
"""Return (pixel_perfect, areas_match, roundtrip_ok)."""
|
|
annotator = sv.MaskAnnotator(opacity=0.5)
|
|
out_dense = annotator.annotate(scene.copy(), det_dense)
|
|
out_compact = annotator.annotate(scene.copy(), det_compact)
|
|
pixel_perfect = bool(np.array_equal(out_dense, out_compact))
|
|
areas_match = bool(np.allclose(det_dense.area, det_compact.area))
|
|
roundtrip_ok = bool(np.array_equal(compact_mask.to_dense(), masks_dense))
|
|
return pixel_perfect, areas_match, roundtrip_ok
|
|
|
|
|
|
def stage_iou(
|
|
masks_dense: np.ndarray,
|
|
compact_mask: CompactMask,
|
|
iou_dense_skipped: bool,
|
|
) -> tuple[float, float, bool | None]:
|
|
"""Time pairwise self-IoU using dense (N,H,W) AND and compact crop filter.
|
|
|
|
Correctness is checked on the first 10 masks only to keep it fast,
|
|
regardless of whether full dense IoU timing is skipped.
|
|
"""
|
|
correct_n = min(len(compact_mask), 10)
|
|
iou_compact_small = sv.mask_iou_batch(
|
|
compact_mask[:correct_n], compact_mask[:correct_n]
|
|
)
|
|
iou_dense_small = sv.mask_iou_batch(
|
|
masks_dense[:correct_n], masks_dense[:correct_n]
|
|
)
|
|
iou_ok = bool(np.allclose(iou_dense_small, iou_compact_small, atol=1e-4))
|
|
|
|
compact_iou_s = time_reps(lambda: sv.mask_iou_batch(compact_mask, compact_mask))
|
|
if iou_dense_skipped:
|
|
dense_iou_s = math.nan
|
|
else:
|
|
dense_iou_s = time_reps(
|
|
lambda: sv.mask_iou_batch(masks_dense, masks_dense),
|
|
repeats=IOU_NMS_REPS,
|
|
)
|
|
return dense_iou_s, compact_iou_s, iou_ok
|
|
|
|
|
|
def stage_nms(
|
|
xyxy: np.ndarray,
|
|
confidence: np.ndarray,
|
|
class_ids: np.ndarray,
|
|
masks_dense: np.ndarray,
|
|
compact_mask: CompactMask,
|
|
dense_skipped: bool,
|
|
iou_dense_skipped: bool,
|
|
) -> tuple[float, float, bool | None, int]:
|
|
"""Time mask NMS. Dense resizes to 640 before IoU; compact uses exact crop IoU.
|
|
|
|
Compact NMS is strictly more accurate than dense: it computes pixel-level IoU
|
|
directly on the full-resolution RLE crops instead of a lossy 640px-downsampled
|
|
approximation. For pairs whose true IoU is very close to the 0.5 threshold,
|
|
the resize step in the dense path can flip a keep/suppress decision.
|
|
|
|
``n_diff`` counts detections whose decision differs between the two paths.
|
|
``nms_ok`` is True when ``n_diff == 0``.
|
|
|
|
Dense NMS is skipped when ``dense_skipped`` *or* ``iou_dense_skipped`` is True:
|
|
NMS calls mask_iou_batch internally so the cost is the same as IoU.
|
|
|
|
Returns:
|
|
Tuple of ``(dense_nms_s, compact_nms_s, nms_ok, n_diff)``.
|
|
"""
|
|
predictions = np.c_[xyxy, confidence, class_ids.astype(float)]
|
|
|
|
compact_nms_s = time_reps(
|
|
lambda: sv.mask_non_max_suppression(predictions, compact_mask)
|
|
)
|
|
if dense_skipped or iou_dense_skipped:
|
|
return math.nan, compact_nms_s, None, 0
|
|
|
|
keep_dense = sv.mask_non_max_suppression(predictions, masks_dense)
|
|
keep_compact = sv.mask_non_max_suppression(predictions, compact_mask)
|
|
n_diff = int(np.sum(keep_dense != keep_compact))
|
|
nms_ok = n_diff == 0
|
|
dense_nms_s = time_reps(
|
|
lambda: sv.mask_non_max_suppression(predictions, masks_dense),
|
|
repeats=IOU_NMS_REPS,
|
|
)
|
|
return dense_nms_s, compact_nms_s, nms_ok, n_diff
|
|
|
|
|
|
def stage_merge(
|
|
det_dense: sv.Detections | None,
|
|
det_compact: sv.Detections,
|
|
dense_skipped: bool,
|
|
) -> tuple[float, float, bool | None]:
|
|
"""Time Detections.merge on two half-splits.
|
|
|
|
Dense: np.vstack; compact: RLE concat.
|
|
Splits are pre-computed so the timed lambda measures only the merge.
|
|
"""
|
|
half = len(det_compact) // 2
|
|
compact_a, compact_b = det_compact[:half], det_compact[half:]
|
|
|
|
compact_merge_s = time_reps(lambda: sv.Detections.merge([compact_a, compact_b]))
|
|
if dense_skipped or det_dense is None:
|
|
return math.nan, compact_merge_s, None
|
|
|
|
dense_a, dense_b = det_dense[:half], det_dense[half:]
|
|
merged_d = sv.Detections.merge([dense_a, dense_b])
|
|
merged_c = sv.Detections.merge([compact_a, compact_b])
|
|
merge_ok = bool(np.allclose(merged_d.area, merged_c.area))
|
|
dense_merge_s = time_reps(lambda: sv.Detections.merge([dense_a, dense_b]))
|
|
return dense_merge_s, compact_merge_s, merge_ok
|
|
|
|
|
|
def stage_offset(
|
|
masks_dense: np.ndarray,
|
|
compact_mask: CompactMask,
|
|
image_height: int,
|
|
image_width: int,
|
|
dense_skipped: bool,
|
|
) -> tuple[float, float, bool | None]:
|
|
"""Time mask offset: move_masks (N,H,W) copy vs O(N) offset update."""
|
|
dx, dy = 10, 10
|
|
# Expand the canvas by the offset so no shifted crop overflows boundary.
|
|
# Both move_masks and with_offset.to_dense() operate on identical space.
|
|
new_h, new_w = image_height + dy, image_width + dx
|
|
new_shape = (new_h, new_w)
|
|
|
|
compact_offset_s = time_reps(
|
|
lambda: compact_mask.with_offset(dx, dy, new_image_shape=new_shape)
|
|
)
|
|
if dense_skipped:
|
|
return math.nan, compact_offset_s, None
|
|
|
|
moved_dense = sv.move_masks(
|
|
masks_dense, np.array([dx, dy]), resolution_wh=(new_w, new_h)
|
|
)
|
|
moved_compact = compact_mask.with_offset(
|
|
dx, dy, new_image_shape=new_shape
|
|
).to_dense()
|
|
offset_ok = bool(np.array_equal(moved_dense, moved_compact))
|
|
dense_offset_s = time_reps(
|
|
lambda: sv.move_masks(
|
|
masks_dense, np.array([dx, dy]), resolution_wh=(new_w, new_h)
|
|
)
|
|
)
|
|
return dense_offset_s, compact_offset_s, offset_ok
|
|
|
|
|
|
def stage_centroids(
|
|
masks_dense: np.ndarray,
|
|
compact_mask: CompactMask,
|
|
dense_skipped: bool,
|
|
) -> tuple[float, float, bool | None]:
|
|
"""Time centroid: np.tensordot on full stack (dense) vs per-crop (compact)."""
|
|
compact_centroids_s = time_reps(lambda: sv.calculate_masks_centroids(compact_mask))
|
|
if dense_skipped:
|
|
return math.nan, compact_centroids_s, None
|
|
|
|
c_dense = sv.calculate_masks_centroids(masks_dense)
|
|
c_compact = sv.calculate_masks_centroids(compact_mask)
|
|
centroids_ok = bool(np.allclose(c_dense, c_compact, atol=1.0)) # 1-pixel tolerance
|
|
dense_centroids_s = time_reps(lambda: sv.calculate_masks_centroids(masks_dense))
|
|
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
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def run_scenario(
|
|
name: str,
|
|
num_objects: int,
|
|
image_height: int,
|
|
image_width: int,
|
|
fill_fraction: float = 0.10,
|
|
num_vertices: int = 20,
|
|
) -> ScenarioResult:
|
|
resolution = f"{image_width}x{image_height}"
|
|
fill_name = f"{fill_fraction:.0%}"
|
|
console.rule(
|
|
f"[bold]{name}[/bold] | {num_objects} objects · {resolution} "
|
|
f"· fill≈{fill_name} · polygon/{num_vertices} vertices"
|
|
)
|
|
|
|
xyxy, masks_dense, class_ids, compact_mask = stage_build(
|
|
num_objects, image_height, image_width, fill_fraction, num_vertices
|
|
)
|
|
scene = make_scene(image_height, image_width)
|
|
|
|
# ── memory ──────────────────────────────────────────────────────────────
|
|
dense_bytes = dense_memory_bytes(masks_dense)
|
|
dense_skipped = dense_bytes > DENSE_SKIP_GB * 1e9
|
|
compact_theoretical = compact_memory_bytes_theoretical(compact_mask)
|
|
|
|
# Only measure dense tracemalloc when it's safe to allocate the full array.
|
|
dense_actual = (
|
|
0
|
|
if dense_skipped
|
|
else dense_memory_bytes_actual(num_objects, image_height, image_width)
|
|
)
|
|
compact_actual = compact_memory_bytes_actual(
|
|
masks_dense, xyxy, (image_height, image_width)
|
|
)
|
|
|
|
encode_s = stage_encode(masks_dense, xyxy, image_height, image_width)
|
|
decode_s = stage_decode(compact_mask)
|
|
|
|
theory_ratio = dense_bytes / max(compact_theoretical, 1)
|
|
if dense_skipped:
|
|
malloc_ratio_str = "[dim]—[/dim]"
|
|
dense_actual_str = "[dim]skipped[/dim]"
|
|
else:
|
|
malloc_ratio = dense_actual / max(compact_actual, 1)
|
|
malloc_ratio_str = _fmt_ratio(malloc_ratio)
|
|
dense_actual_str = f"{dense_actual / 1e6:.1f} MB"
|
|
console.print(
|
|
f"\tmemory >>\n"
|
|
f"\t\ttheory :: dense={dense_bytes / 1e6:.1f} MB "
|
|
f"| compact={compact_theoretical / 1e3:.0f} KB "
|
|
f"\t{_fmt_ratio(theory_ratio)}\n"
|
|
f"\t\tmalloc :: dense={dense_actual_str} "
|
|
f"| compact={compact_actual / 1e3:.0f} KB "
|
|
f"\t{malloc_ratio_str}"
|
|
)
|
|
console.print(f"\t<create> encode (from_dense)\t={encode_s * 1e3:.3f} ms/mask")
|
|
console.print(f"\t<export> decode (to_dense)\t={decode_s * 1e3:.3f} ms/mask")
|
|
|
|
# ── skip flags ──────────────────────────────────────────────────────────
|
|
iou_dense_skipped = dense_bytes > IOU_DENSE_SKIP_GB * 1e9
|
|
if dense_skipped:
|
|
console.print(
|
|
f"\t[yellow]dense array is {dense_bytes / 1e9:.1f} GB "
|
|
f"(>{DENSE_SKIP_GB:.0f} GB threshold) — skipping dense timing"
|
|
f"[/yellow]"
|
|
)
|
|
elif iou_dense_skipped:
|
|
console.print(
|
|
f"\t[yellow]dense IoU skipped (>{IOU_DENSE_SKIP_GB:.0f}GB thr.)[/yellow]"
|
|
)
|
|
|
|
confidence = (
|
|
np.random.default_rng(1).uniform(0.3, 0.99, num_objects).astype(np.float32)
|
|
)
|
|
det_compact = sv.Detections(xyxy=xyxy, mask=compact_mask, class_id=class_ids)
|
|
|
|
if dense_skipped:
|
|
dense_area_s = dense_filter_s = dense_annot_s = math.nan
|
|
compact_area_s = _time_compact_area(det_compact)
|
|
compact_filter_s = _time_compact_filter(det_compact)
|
|
compact_annot_s = _time_compact_annotate(scene, det_compact)
|
|
pixel_perfect = areas_match = roundtrip_ok = None
|
|
det_dense = None
|
|
else:
|
|
det_dense = sv.Detections(xyxy=xyxy, mask=masks_dense, class_id=class_ids)
|
|
dense_area_s, compact_area_s = stage_area(det_dense, det_compact)
|
|
dense_filter_s, compact_filter_s = stage_filter(det_dense, det_compact)
|
|
dense_annot_s, compact_annot_s = stage_annotate(scene, det_dense, det_compact)
|
|
pixel_perfect, areas_match, roundtrip_ok = stage_correctness(
|
|
scene, masks_dense, compact_mask, det_dense, det_compact
|
|
)
|
|
|
|
dense_iou_s, compact_iou_s, iou_ok = stage_iou(
|
|
masks_dense, compact_mask, iou_dense_skipped
|
|
)
|
|
dense_nms_s, compact_nms_s, nms_ok, nms_diff = stage_nms(
|
|
xyxy,
|
|
confidence,
|
|
class_ids,
|
|
masks_dense,
|
|
compact_mask,
|
|
dense_skipped,
|
|
iou_dense_skipped,
|
|
)
|
|
dense_merge_s, compact_merge_s, merge_ok = stage_merge(
|
|
det_dense, det_compact, dense_skipped
|
|
)
|
|
dense_offset_s, compact_offset_s, offset_ok = stage_offset(
|
|
masks_dense, compact_mask, image_height, image_width, dense_skipped
|
|
)
|
|
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"
|
|
if math.isnan(dense_s):
|
|
return (
|
|
f"\t{label}\t -> dense=[dim]—[/dim]"
|
|
f"\t\t | compact={compact_ms}\t | speedup=[dim]—[/dim]"
|
|
)
|
|
dense_ms = f"{dense_s * 1e3:.2f} ms"
|
|
speedup = _fmt_ratio(dense_s / max(compact_s, 1e-9))
|
|
return (
|
|
f"\t{label}\t -> dense={dense_ms}\t | "
|
|
f"compact={compact_ms}\t | speedup={speedup}"
|
|
)
|
|
|
|
console.print(_timing_line(".area ", dense_area_s, compact_area_s))
|
|
console.print(_timing_line("annotate ", dense_annot_s, compact_annot_s))
|
|
console.print(_timing_line("centroids", dense_centroids_s, compact_centroids_s))
|
|
console.print(_timing_line("filter ", dense_filter_s, compact_filter_s))
|
|
console.print(_timing_line("iou ", dense_iou_s, compact_iou_s))
|
|
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,
|
|
"areas": areas_match,
|
|
"roundtrip": roundtrip_ok,
|
|
"iou": iou_ok,
|
|
"nms": nms_ok,
|
|
"merge": merge_ok,
|
|
"offset": offset_ok,
|
|
"centroids": centroids_ok,
|
|
"resize": resize_ok,
|
|
}
|
|
parts = []
|
|
for k, v in checks.items():
|
|
if k == "nms" and v is False:
|
|
parts.append(f"nms=[red]✗({nms_diff})[/red]")
|
|
else:
|
|
parts.append(
|
|
f"{k}="
|
|
+ (
|
|
"[dim]—[/dim]"
|
|
if v is None
|
|
else "[green]✓[/green]"
|
|
if v
|
|
else "[red]✗[/red]"
|
|
)
|
|
)
|
|
all_checked = [v for v in checks.values() if v is not None]
|
|
overall = (
|
|
"[green]✓ all correct[/green]"
|
|
if all_checked and all(all_checked)
|
|
else "[red]✗ MISMATCH[/red]"
|
|
if any(v is False for v in checks.values())
|
|
else "[dim]—[/dim]"
|
|
)
|
|
console.print(" correctness >> " + " | ".join(parts) + f" | {overall}")
|
|
|
|
return ScenarioResult(
|
|
name=name,
|
|
resolution=resolution,
|
|
num_objects=num_objects,
|
|
fill_name=fill_name,
|
|
num_vertices=num_vertices,
|
|
dense_bytes=dense_bytes,
|
|
compact_bytes_theoretical=compact_theoretical,
|
|
dense_bytes_actual=dense_actual,
|
|
compact_bytes_actual=compact_actual,
|
|
encode_s=encode_s,
|
|
decode_s=decode_s,
|
|
dense_area_s=dense_area_s,
|
|
compact_area_s=compact_area_s,
|
|
dense_filter_s=dense_filter_s,
|
|
compact_filter_s=compact_filter_s,
|
|
dense_annot_s=dense_annot_s,
|
|
compact_annot_s=compact_annot_s,
|
|
dense_iou_s=dense_iou_s,
|
|
compact_iou_s=compact_iou_s,
|
|
dense_nms_s=dense_nms_s,
|
|
compact_nms_s=compact_nms_s,
|
|
dense_merge_s=dense_merge_s,
|
|
compact_merge_s=compact_merge_s,
|
|
dense_offset_s=dense_offset_s,
|
|
compact_offset_s=compact_offset_s,
|
|
dense_centroids_s=dense_centroids_s,
|
|
compact_centroids_s=compact_centroids_s,
|
|
pixel_perfect=pixel_perfect,
|
|
areas_match=areas_match,
|
|
roundtrip_ok=roundtrip_ok,
|
|
iou_ok=iou_ok,
|
|
nms_ok=nms_ok,
|
|
nms_mismatch_count=nms_diff,
|
|
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,
|
|
)
|
|
|
|
|
|
def _time_compact_area(det_compact: sv.Detections) -> float:
|
|
"""Time .area on the compact detections (used when dense timing is skipped)."""
|
|
return time_reps(lambda: det_compact.area)
|
|
|
|
|
|
def _time_compact_filter(det_compact: sv.Detections) -> float:
|
|
"""Time boolean-index filtering on the compact detections (dense-skip path)."""
|
|
keep = np.arange(len(det_compact)) % 2 == 0
|
|
return time_reps(lambda: det_compact[keep])
|
|
|
|
|
|
def _time_compact_annotate(scene: np.ndarray, det_compact: sv.Detections) -> float:
|
|
"""Time MaskAnnotator on the compact detections (dense-skip path)."""
|
|
annotator = sv.MaskAnnotator(opacity=0.5)
|
|
return time_reps(lambda: annotator.annotate(scene.copy(), det_compact))
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Rich summary table
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
_OPS = (
|
|
"area",
|
|
"filter",
|
|
"annot",
|
|
"iou",
|
|
"nms",
|
|
"merge",
|
|
"offset",
|
|
"centroids",
|
|
"resize",
|
|
)
|
|
|
|
|
|
def _build_summary_df(results: list[ScenarioResult]) -> pd.DataFrame:
|
|
"""Compute derived summary columns from scenario results.
|
|
|
|
Returns a DataFrame with all ScenarioResult fields plus derived columns
|
|
(ratios, speedups, ok) as raw floats. Consumers apply their own formatting.
|
|
"""
|
|
df = pd.DataFrame([dataclasses.asdict(r) for r in results])
|
|
df["ratio_theory"] = df["dense_bytes"] / df["compact_bytes_theoretical"].clip(
|
|
lower=1
|
|
)
|
|
df["ratio_malloc"] = df["dense_bytes_actual"] / df["compact_bytes_actual"].clip(
|
|
lower=1
|
|
)
|
|
# dense_bytes_actual == 0 (not measured) when dense_skipped — clear those cells
|
|
df.loc[df["dense_skipped"], "ratio_malloc"] = None
|
|
for op in _OPS:
|
|
df[f"{op}_speedup"] = df[f"dense_{op}_s"] / df[f"compact_{op}_s"].clip(
|
|
lower=1e-9
|
|
)
|
|
|
|
check_cols = [
|
|
"pixel_perfect",
|
|
"areas_match",
|
|
"roundtrip_ok",
|
|
"iou_ok",
|
|
"nms_ok",
|
|
"merge_ok",
|
|
"offset_ok",
|
|
"centroids_ok",
|
|
"resize_ok",
|
|
]
|
|
df["ok"] = df.apply(
|
|
lambda row: (
|
|
False
|
|
if any(row[c] is False for c in check_cols)
|
|
else True
|
|
if any(row[c] is True for c in check_cols)
|
|
else None
|
|
),
|
|
axis=1,
|
|
)
|
|
return df
|
|
|
|
|
|
def _fmt_ratio(ratio: float) -> str:
|
|
"""Format a speedup/compression ratio with colour coding.
|
|
|
|
≥10 → green (large win), 1-10 → yellow (modest win), <1 → red (regression).
|
|
Integer for ≥10, two decimals otherwise.
|
|
"""
|
|
fmt = f"{ratio:.0f}x" if ratio >= 10 else f"{ratio:.2f}x"
|
|
if ratio >= 10:
|
|
return f"[green]{fmt}[/green]"
|
|
elif ratio >= 1:
|
|
return f"[yellow]{fmt}[/yellow]"
|
|
else:
|
|
return f"[red]{fmt}[/red]"
|
|
|
|
|
|
def _fmt_speedup(dense_s: float, compact_s: float) -> str:
|
|
if math.isnan(dense_s):
|
|
# Dense was skipped — show compact absolute time so the column isn't empty.
|
|
return f"[dim]{compact_s * 1e3:.1f} ms[/dim]"
|
|
return _fmt_ratio(dense_s / max(compact_s, 1e-9))
|
|
|
|
|
|
def print_summary(results: list[ScenarioResult]) -> None:
|
|
table = Table(
|
|
title="CompactMask — benchmark summary",
|
|
box=box.ROUNDED,
|
|
show_lines=True,
|
|
header_style="bold cyan",
|
|
min_width=console.width,
|
|
)
|
|
table.add_column("Scenario", style="bold", min_width=22)
|
|
table.add_column("Objects", justify="right", min_width=7)
|
|
table.add_column("Resolution", min_width=12, no_wrap=True)
|
|
table.add_column("Mask\narea", justify="right", min_width=5, no_wrap=True)
|
|
table.add_column("Vertices", justify="right", min_width=8, no_wrap=True)
|
|
table.add_column("Dense\ntheory", justify="right", min_width=10)
|
|
table.add_column("Compact\ntheory", justify="right", style="green", min_width=9)
|
|
table.add_column("Ratio\ntheory", justify="right", min_width=7)
|
|
table.add_column("Dense\nmalloc", justify="right", style="cyan", min_width=9)
|
|
table.add_column("Compact\nmalloc", justify="right", style="cyan", min_width=9)
|
|
table.add_column("Ratio\nmalloc", justify="right", min_width=7)
|
|
table.add_column("Encode\n(ms/mask)", justify="right", style="yellow", min_width=7)
|
|
table.add_column("Decode\n(ms/mask)", justify="right", style="yellow", min_width=7)
|
|
table.add_column("Area\natt.", justify="right", min_width=6)
|
|
table.add_column("Filter\nop.", justify="right", min_width=6)
|
|
table.add_column("Annot\nop.", justify="right", min_width=6)
|
|
table.add_column("IoU\nop.", justify="right", min_width=6)
|
|
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)
|
|
|
|
for _, row in _build_summary_df(results).iterrows():
|
|
ok = row["ok"]
|
|
ok_cell = (
|
|
"[red]✗[/red]"
|
|
if ok is False
|
|
else "[green]✓[/green]"
|
|
if ok is True
|
|
else "[dim]—[/dim]"
|
|
)
|
|
dense_malloc_cell = (
|
|
"[dim]—[/dim]"
|
|
if row["dense_skipped"]
|
|
else f"{row['dense_bytes_actual'] / 1e6:.1f} MB"
|
|
)
|
|
malloc_ratio_cell = (
|
|
"[dim]—[/dim]" if row["dense_skipped"] else _fmt_ratio(row["ratio_malloc"])
|
|
)
|
|
table.add_row(
|
|
row["name"],
|
|
str(row["num_objects"]),
|
|
row["resolution"],
|
|
row["fill_name"],
|
|
str(row["num_vertices"]),
|
|
f"{row['dense_bytes'] / 1e6:.1f} MB",
|
|
f"{row['compact_bytes_theoretical'] / 1e3:.0f} KB",
|
|
_fmt_ratio(row["ratio_theory"]),
|
|
dense_malloc_cell,
|
|
f"{row['compact_bytes_actual'] / 1e3:.0f} KB",
|
|
malloc_ratio_cell,
|
|
f"{row['encode_s'] * 1e3:.1f}",
|
|
f"{row['decode_s'] * 1e3:.1f}",
|
|
_fmt_speedup(row["dense_area_s"], row["compact_area_s"]),
|
|
_fmt_speedup(row["dense_filter_s"], row["compact_filter_s"]),
|
|
_fmt_speedup(row["dense_annot_s"], row["compact_annot_s"]),
|
|
_fmt_speedup(row["dense_iou_s"], row["compact_iou_s"]),
|
|
_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,
|
|
)
|
|
|
|
console.print(table)
|
|
console.print(
|
|
"[dim]"
|
|
+ " · ".join(
|
|
[
|
|
"Vertices — polygon vertex count "
|
|
"(complexity proxy: more = jaggier boundary)",
|
|
"Dense theory — NxHxW bytes (raw numpy buffer)",
|
|
"Compact theory — sum of internal numpy buffer sizes",
|
|
"Ratio (theory) — dense / compact theoretical ratio",
|
|
"Dense malloc — tracemalloc peak during np.zeros allocation",
|
|
"Compact malloc — tracemalloc peak during .from_dense()",
|
|
"Ratio (malloc) — dense / compact tracemalloc peak ratio",
|
|
"Encode ms/mask — from_dense() / N (dense→compact overhead per mask)",
|
|
"Decode ms/mask — to_dense() / N (compact→dense overhead per mask)",
|
|
"Area x — .area speedup (RLE sum, no materialisation)",
|
|
"Filter x — boolean-index speedup",
|
|
"Annot x — MaskAnnotator speedup "
|
|
"(ROI-only blend vs full-frame overlay)",
|
|
f"IoU x — pairwise self-IoU speedup "
|
|
f"(dense skipped >{IOU_DENSE_SKIP_GB:.0f} GB)",
|
|
"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",
|
|
]
|
|
)
|
|
+ "[/dim]"
|
|
)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Results persistence
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _append_result(result: ScenarioResult, path: Path) -> None:
|
|
"""Append one scenario result as a JSON line to *path*.
|
|
|
|
``math.nan`` (used for skipped dense timings) is serialised as ``null``
|
|
so the file is valid JSON-Lines and can be read back with any JSON parser.
|
|
"""
|
|
row = {
|
|
k: (None if isinstance(v, float) and math.isnan(v) else v)
|
|
for k, v in dataclasses.asdict(result).items()
|
|
}
|
|
with path.open("a", encoding="utf-8") as fh:
|
|
fh.write(json.dumps(row) + "\n")
|
|
|
|
|
|
def save_results_csv(results: list[ScenarioResult], path: Path) -> None:
|
|
"""Write the summary table to *path* as a CSV file.
|
|
|
|
Each row mirrors the Rich summary table: scenario metadata, memory ratios,
|
|
encode/decode overhead, and per-operation speedups. Columns whose dense
|
|
timing was skipped are written as empty cells.
|
|
"""
|
|
df = _build_summary_df(results)
|
|
pd.DataFrame(
|
|
{
|
|
"scenario": df["name"],
|
|
"objects": df["num_objects"],
|
|
"resolution": df["resolution"],
|
|
"fill": df["fill_name"],
|
|
"vertices": df["num_vertices"],
|
|
"dense_theory_mb": (df["dense_bytes"] / 1e6).round(1),
|
|
"compact_theory_kb": (df["compact_bytes_theoretical"] / 1e3).round(1),
|
|
"ratio_theory": df["ratio_theory"].round(0),
|
|
"dense_malloc_mb": (df["dense_bytes_actual"] / 1e6)
|
|
.where(~df["dense_skipped"])
|
|
.round(1),
|
|
"compact_malloc_kb": (df["compact_bytes_actual"] / 1e3).round(1),
|
|
"ratio_malloc": df["ratio_malloc"].round(0),
|
|
"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)
|
|
|
|
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
# Entry point
|
|
# ══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def main() -> None:
|
|
# ── parameter matrix ──────────────────────────────────────────────────────
|
|
# (tier_label, (image_width, image_height), num_objects)
|
|
TIERS: list[tuple[str, tuple[int, int], int]] = [
|
|
("FHD", (1920, 1080), 100), # full comparison (0.21 GB < 1 GB IoU thr.)
|
|
("FHD", (1920, 1080), 200), # full comparison (0.41 GB < 1 GB IoU thr.)
|
|
("FHD", (1920, 1080), 400), # full comparison (0.83 GB < 1 GB IoU thr.)
|
|
("4K", (3840, 2160), 100), # full comparison (0.83 GB < 1 GB IoU thr.)
|
|
("4K", (3840, 2160), 200), # dense excl. IoU/NMS (1.66 GB > 1 GB thr.)
|
|
("SAT", (8192, 8192), 200), # dense excl. IoU/NMS (13.4 GB > 1 GB thr.)
|
|
]
|
|
FILL_FRACTIONS = [0.05, 0.20, 0.50] # sparse / moderate / SAM-everything
|
|
VERTEX_COUNTS = [8, 128, 600] # low / realistic / YOLOv8-seg default
|
|
|
|
scenarios = [
|
|
{
|
|
"name": f"{tier}-{num_objects}-{fill_fraction:.0%}-v{num_vertices}",
|
|
"num_objects": num_objects,
|
|
"image_height": img_h,
|
|
"image_width": img_w,
|
|
"fill_fraction": fill_fraction,
|
|
"num_vertices": num_vertices,
|
|
}
|
|
for tier, (img_w, img_h), num_objects in TIERS
|
|
for fill_fraction in FILL_FRACTIONS
|
|
for num_vertices in VERTEX_COUNTS
|
|
]
|
|
|
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
results_path = Path(__file__).parent / f"results_{timestamp}.jsonl"
|
|
|
|
console.print(
|
|
f"[bold]supervision[/bold]"
|
|
f" {sv.__version__} · numpy {np.__version__} · {len(scenarios)} scenarios"
|
|
f" · saving to [dim]{results_path.name}[/dim]"
|
|
)
|
|
|
|
results = []
|
|
progress = Progress(
|
|
TextColumn("[progress.description]{task.description}"),
|
|
BarColumn(),
|
|
MofNCompleteColumn(),
|
|
TaskProgressColumn(),
|
|
TimeElapsedColumn(),
|
|
console=console,
|
|
)
|
|
with progress:
|
|
task = progress.add_task("benchmarking…", total=len(scenarios))
|
|
for params in scenarios:
|
|
progress.update(task, description=f"[bold]{params['name']}[/bold]")
|
|
result = run_scenario(**params)
|
|
results.append(result)
|
|
_append_result(result, results_path)
|
|
gc.collect() # flush scenario temporaries before next run
|
|
progress.advance(task)
|
|
|
|
print_summary(results)
|
|
|
|
csv_path = results_path.with_suffix(".csv")
|
|
save_results_csv(results, csv_path)
|
|
console.print(f"[dim]results saved → {results_path.name} · {csv_path.name}[/dim]")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|