"""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()
