Skip to content

fix(detection): make oriented_box_iou_batch exact and gate non-overlapping pairs#2317

Merged
Borda merged 8 commits into
roboflow:developfrom
kounelisagis:fix/oriented-box-iou-exact
Jun 15, 2026
Merged

fix(detection): make oriented_box_iou_batch exact and gate non-overlapping pairs#2317
Borda merged 8 commits into
roboflow:developfrom
kounelisagis:fix/oriented-box-iou-exact

Conversation

@kounelisagis

Copy link
Copy Markdown
Contributor

oriented_box_iou_batch measures overlap by rasterizing every box onto a shared canvas (up to 1024x1024) and counting pixels. Two problems with that:

  • It's approximate. Pixel quantization shifts the IoU and the error gets worse for small boxes or large coordinates. Two small rectangles whose exact IoU is 0.25 came out as 0.357 (see the accuracy snippet below).
  • It's slow. The cost scales with the canvas area and the number of boxes, not with how many pairs actually overlap, and it builds a full mask per box even when boxes are nowhere near each other.

This PR replaces the rasterization with exact convex polygon intersection (cv2.intersectConvexConvex), gated by a vectorized axis-aligned envelope check. Pairs whose bounding rectangles don't overlap can't overlap as oriented boxes, so they skip the polygon step entirely. When the same set is passed on both sides (the NMS/NMM path) only the upper triangle is computed.

The result is exact and invariant under scaling and translation, so the canvas cap and the loose test tolerances are no longer needed.

The same primitive backs oriented NMS, NMM and the OBB metrics (precision, recall, F1, mAR), so those get faster and more accurate too.

Accuracy

import numpy as np
import supervision as sv

# two rotated rectangles whose exact IoU is 0.25
a = np.array([[[1, 0], [0, 1], [3, 4], [4, 3]]], dtype=np.float32)
b = np.array([[[1, 1], [2, 0], [4, 2], [3, 3]]], dtype=np.float32)
print(sv.oriented_box_iou_batch(a, b)[0, 0])
# develop (rasterized): 0.3571
# this PR (exact):      0.2500

Timings

Self comparison, random oriented boxes, same machine. The two exact columns are an ablation to show where the speedup comes from: dropping rasterization does most of the work, and the envelope gate adds the rest on sparse scenes (the two exact columns produce identical values).

boxes rasterized exact, no gate exact + gate
200 17.8s 0.015s 0.001s
500 105s 0.078s 0.004s
1000 450s 0.313s 0.014s

End to end, MeanAverageRecall over 8 images with 50 OBB each drops from 2.8s to 0.007s with an identical score.

How the timings were measured

The rasterized column is sv.oriented_box_iou_batch on develop; exact + gate is the same call on this branch. Both use these boxes:

import numpy as np
rng = np.random.default_rng(0)

def random_obbs(n, field=1024, side=(15, 60)):
    cx, cy = rng.uniform(0, field, n), rng.uniform(0, field, n)
    w, h = rng.uniform(*side, n), rng.uniform(*side, n)
    angle = rng.uniform(0, np.pi, n)
    c, s = np.cos(angle), np.sin(angle)
    dx = np.stack([-w / 2, w / 2, w / 2, -w / 2], 1)
    dy = np.stack([-h / 2, -h / 2, h / 2, h / 2], 1)
    x = cx[:, None] + dx * c[:, None] - dy * s[:, None]
    y = cy[:, None] + dx * s[:, None] + dy * c[:, None]
    return np.stack([x, y], 2)

The exact, no gate column toggles the envelope pre-filter off (exact intersection over every pair), isolating the gate's contribution:

import cv2, time

def exact(boxes, gate):
    boxes = boxes.astype(np.float64); n = len(boxes)
    x, y = boxes[:, :, 0], boxes[:, :, 1]
    area = 0.5 * np.abs((x * np.roll(y, -1, 1) - np.roll(x, -1, 1) * y).sum(1))
    polys = [b.astype(np.float32) for b in boxes]
    if gate:
        e = np.stack([x.min(1), y.min(1), x.max(1), y.max(1)], 1)
        x1 = np.maximum(e[:, None, 0], e[None, :, 0]); y1 = np.maximum(e[:, None, 1], e[None, :, 1])
        x2 = np.minimum(e[:, None, 2], e[None, :, 2]); y2 = np.minimum(e[:, None, 3], e[None, :, 3])
        ii, jj = np.where((x2 > x1) & (y2 > y1)); keep = ii <= jj; ii, jj = ii[keep], jj[keep]
    else:
        ii, jj = np.triu_indices(n)
    out = np.zeros((n, n))
    for i, j in zip(ii, jj):
        inter, _ = cv2.intersectConvexConvex(polys[i], polys[j])
        d = area[i] + area[j] - inter
        if inter > 0 and d > 0:
            out[i, j] = out[j, i] = inter / d
    return out

for n in (200, 500, 1000):
    boxes = random_obbs(n)
    for gate in (False, True):
        t = time.perf_counter(); m = exact(boxes, gate)
        print(n, "gate" if gate else "no-gate", f"{time.perf_counter() - t:.3f}s")

Rasterization cost is tied to the canvas, so it barely moves with box count; the exact path is tied to the number of pairs, and the gate keeps that to the pairs that can actually overlap.

Tests

  • tightened the invariance tests to exact equality, and fixed the one that asserted the rasterized 0.357 instead of the true 0.25
  • added checks for half overlap, disjoint boxes, IoS containment, and symmetric self comparison with a unit diagonal

…pping pairs

Replace the rasterized mask IoU with exact convex-polygon intersection
(cv2.intersectConvexConvex), pruned by a vectorized axis-aligned envelope
gate. Rasterizing every box onto a shared canvas was both slow (cost scales
with canvas area, not box count) and approximate: pixel quantization skewed
the IoU, e.g. two small rectangles whose true IoU is 0.25 scored above 0.35.

The result is now exact and invariant under affine transforms, so the
canvas-cap workaround and its loose test tolerances are gone. This primitive
also backs oriented NMS, NMM and the OBB metrics (precision, recall, F1,
mAR), which all become faster and more accurate.

Tests now assert exact analytic IoU values, tighten the invariance checks,
and add guards for half-overlap, disjoint boxes and self-comparison.
@kounelisagis kounelisagis requested a review from SkalskiP as a code owner June 13, 2026 17:07
@codecov

codecov Bot commented Jun 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81%. Comparing base (e0e1abd) to head (e18bab8).

Additional details and impacted files
@@           Coverage Diff           @@
##           develop   #2317   +/-   ##
=======================================
  Coverage       80%     81%           
=======================================
  Files           66      66           
  Lines         8948    8971   +23     
=======================================
+ Hits          7196    7222   +26     
+ Misses        1752    1749    -3     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…d IoU

Add the two branches codecov flagged: a pair that clears the envelope gate
but has no exact polygon overlap (scores 0), and an unsupported overlap
metric (raises ValueError).
Borda
Borda previously approved these changes Jun 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates oriented_box_iou_batch to compute oriented-box overlap exactly (via convex polygon intersection) and to skip work for box pairs that cannot overlap (via an axis-aligned envelope gate). This improves both correctness (no rasterization quantization) and performance for downstream oriented NMS/NMM and OBB-based metrics.

Changes:

  • Replace rasterized, canvas-based OBB IoU with exact cv2.intersectConvexConvex intersection, plus a vectorized AABB-envelope prefilter.
  • Add a self-comparison fast path to compute only the upper triangle when the same array is passed for both arguments.
  • Update and extend tests to validate analytic IoU/IoS values, invariance properties, and gating behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/supervision/detection/utils/iou_and_nms.py Reimplements oriented_box_iou_batch using exact convex-polygon intersection with an envelope overlap gate and symmetric self-comparison optimization.
tests/detection/utils/test_iou_and_nms.py Tightens invariance tests and adds new analytic and edge-case coverage for exact oriented IoU/IoS behavior.

Comment thread src/supervision/detection/utils/iou_and_nms.py
Comment thread src/supervision/detection/utils/iou_and_nms.py Outdated
Borda and others added 4 commits June 15, 2026 16:25
Brings PR branch up to date with develop (roboflow#2315 tornado bump, roboflow#2321 coco segmentation fix).
No conflicts with iou_and_nms.py changes in this PR.

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
- Parametrize test_self_comparison_is_symmetric_with_unit_diagonal with N=1
  and N=2 (R6: N=1 path exercising triangular-mirror with single (0,0) pair)
- Add test_degenerate_boxes_score_zero: collapsed, collinear, zero-area
  self-comparison → 0.0 (documents divergence from box_iou_batch semantics)
- Add test_empty_input_returns_correct_shape: (0,M), (N,0), (0,0) variants
  exercising early-return path at TestOrientedBoxIouBatch level
- Add test_invalid_shape_raises_value_error: 3-D wrong inner dims, 2-D wrong
  columns, 1-D input — matches exact error messages from implementation
- Fix test_is_invariant_to_canvas_transforms: remove stale pixel-IoU /
  canvas reference; tighten tolerances to rtol=1e-5 / atol=1e-7 (exact
  arithmetic no longer has quantization noise)

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
- Add Raises: section to oriented_box_iou_batch documenting all four
  ValueError paths (3-D wrong inner dims, 2-D wrong columns, wrong ndim,
  unsupported overlap_metric)
- Add Note: block documenting is_self_comparison identity-based contract
  (disabled by upstream .copy()), convexity precondition, and NaN/Inf
  silent-zero behavior
- Align Returns: style with box_iou_batch sibling (named entry semantics)
- Add Examples: doctest block (doctest: +ELLIPSIS for IoU value)
- Strengthen np.clip comment: explicitly mark as load-bearing; explains
  that cv2 intersection in float32 can exceed float64 area by ~25 ULP
- Add one-line comment at NMS caller: is_self_comparison trigger context
- Add Args:/Returns: to _polygon_areas and _aabb_envelopes private helpers
- Expand _overlapping_envelope_pairs docstring: Note (correctness guarantee
  — not an approximation), Args: and Returns: blocks

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
…emory

Replace 4 named NxM float64 intermediates (x_min, y_min, x_max, y_max)
with a single fused boolean evaluation using broadcast views. Peak transient
memory drops from 5 NxM arrays to 1 boolean array (~17 MB vs ~33 MB at
N=M=1000); speed is unchanged.

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Borda and others added 2 commits June 15, 2026 19:34
…ct IoU

A zero-area (collinear) OBB scores IoU 0 under the exact implementation
(cf. test_degenerate_boxes_score_zero), so it cannot group and the two
detections are no longer merged. Update the expected result to the two
unmerged boxes. The previous expectation (merge to one) reflected the old
rasterized IoU, which gave the zero-area line a small non-zero pixel IoU.
@Borda Borda merged commit 483e3e9 into roboflow:develop Jun 15, 2026
26 checks passed
Borda added a commit that referenced this pull request Jun 16, 2026
- Merge Added [#2312] + Changed [#2312] into single Added entry covering xyxyxyxy_to_xyxy utility and with_nmm OBB merge
- Merge Fixed [#2282] + Fixed [#2317] into single entry [#2282, #2317]; #2317 replaced the rasterization approach from #2282 entirely
- Fold Fixed [#2252] into Added [#2252]; same function, one entry covers both preserve_audio feature and muxing bug fixes
- Fold Fixed [#2289] into Added [#2302, #2289]; same function, one entry covers both is_obb parameter and rotation-loss fix

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants