Skip to content

perf(detection): count mask pixels with count_nonzero - #2361

Merged
Borda merged 7 commits into
roboflow:developfrom
RubenHaisma:perf/mask-area-count-nonzero
Jul 1, 2026
Merged

perf(detection): count mask pixels with count_nonzero#2361
Borda merged 7 commits into
roboflow:developfrom
RubenHaisma:perf/mask-area-count-nonzero

Conversation

@RubenHaisma

Copy link
Copy Markdown
Contributor

Description

Mask pixel-area counting used np.sum: np.array([np.sum(m) for m in masks]) in Detections.area, and np.sum(mask, axis=(1, 2)) in the metrics helper get_mask_size_category.

For boolean masks, np.count_nonzero (with no axis) dispatches to NumPy's SIMD popcount over the raw byte buffer, whereas every axis-reduction form — np.sum(..., axis=...) and even np.count_nonzero(..., axis=...) — falls back to a slower generic reduction. So counting per-mask with np.count_nonzero is several times faster than the "obvious" vectorized sum, while producing bit-identical integer counts. (The plain masks.sum(axis=(1, 2)) vectorization, by contrast, is only ~1.1x — it's the primitive, not the loop-removal, that matters here.)

Both sites now use:

np.fromiter((np.count_nonzero(m) for m in masks), dtype=np.int64, count=len(masks))

dtype=np.int64 preserves the documented Detections.area mask-branch dtype on every platform (a bare np.array([...]) of Python ints is int32 on Windows).

Performance

~5x on 640×640 masks, density-independent:

path N before after speedup
Detections.area 100 7.8 ms 1.5 ms 5.3×
Detections.area 300 24.5 ms 4.6 ms 5.3×
get_mask_size_category 300 ~24 ms ~4.4 ms 5.5×

get_mask_size_category feeds the size-bucketed F1Score / Precision / Recall / MeanAveragePrecision / MeanAverageRecall metrics, where it runs repeatedly (SMALL/MEDIUM/LARGE × predictions+targets) across a dataset.

Correctness

Counting True pixels equals summing a bool array exactly (integers, no float error). Verified bit-identical over 400 randomized trials plus empty / all-true / all-false / 1×1 edge cases, with int64 dtype preserved. Existing Detections.area and metrics suites pass unchanged.

Tests

Adds parity tests for Detections.area (dense mask) and get_mask_size_category, each against an np.sum reference, plus threshold-boundary coverage. ruff check/format clean.

Mask pixel-area counting used `np.sum` — `np.array([np.sum(m) for m in
masks])` in `Detections.area` and `np.sum(mask, axis=(1, 2))` in the metrics
`get_mask_size_category`. For boolean masks `np.count_nonzero` (with no axis)
dispatches to NumPy's SIMD popcount over the raw byte buffer, whereas every
axis-reduction form — `np.sum(..., axis=...)` and even `np.count_nonzero(...,
axis=...)` — falls back to a slower generic reduction. So counting per mask
with `np.count_nonzero` is several times faster than the "obvious" vectorized
sum, while producing bit-identical integer counts.

Route both sites through `np.fromiter((np.count_nonzero(m) for m in masks),
dtype=np.int64, count=len(masks))`. `dtype=np.int64` preserves the documented
`Detections.area` mask-branch dtype on every platform (a bare
`np.array([...])` of Python ints would be int32 on Windows).

Measured ~5x on 640x640 masks (e.g. `Detections.area`, N=300: ~24ms -> ~4ms),
faster across densities. `get_mask_size_category` feeds the size-bucketed
F1/Precision/Recall/mAP/mAR metrics, where it is invoked repeatedly per
dataset. Counts are integer-exact (verified over 400 randomized trials plus
empty / all-true / all-false / 1x1 edge cases).

Adds parity tests for `Detections.area` (dense mask) and
`get_mask_size_category` against an `np.sum` reference.
@RubenHaisma
RubenHaisma requested a review from SkalskiP as a code owner June 27, 2026 13:24
@codecov

codecov Bot commented Jun 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84%. Comparing base (8f576b0) to head (d42aead).

Additional details and impacted files
@@           Coverage Diff           @@
##           develop   #2361   +/-   ##
=======================================
  Coverage       84%     84%           
=======================================
  Files           70      70           
  Lines         9923    9926    +3     
=======================================
+ Hits          8313    8316    +3     
  Misses        1610    1610           
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Borda Borda added the enhancement New feature or request label Jul 1, 2026
@Borda Borda self-assigned this Jul 1, 2026
@Borda
Borda requested a review from Copilot July 1, 2026 12:00

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 improves mask pixel-area counting performance in Supervision’s detection and metrics code paths by switching from np.sum reductions to a faster per-mask np.count_nonzero + np.fromiter approach, while preserving bit-identical results (for boolean masks) and maintaining an int64 area dtype.

Changes:

  • Updated Detections.area (mask branch) to compute per-mask areas via np.fromiter((np.count_nonzero(m) ...), dtype=np.int64).
  • Updated get_mask_size_category to use the same count_nonzero + fromiter strategy for faster size bucketing.
  • Added tests to verify parity with np.sum references and to cover threshold boundary behavior and dense-mask area expectations.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/supervision/detection/core.py Speeds up Detections.area for boolean masks using count_nonzero + fromiter with stable int64 output.
src/supervision/metrics/utils/object_size.py Speeds up mask-size categorization by computing areas via per-mask count_nonzero + fromiter.
tests/detection/test_core.py Adds a dense-mask parity test to ensure Detections.area matches pixel counts / sum and remains int64.
tests/metrics/utils/test_object_size.py Adds reference-parity and boundary tests for get_mask_size_category.
tests/metrics/utils/__init__.py Adds/keeps the tests.metrics.utils package marker (empty file).

@Borda Borda changed the title perf(detection): count mask pixels with count_nonzero perf(detection): count mask pixels with count_nonzero Jul 1, 2026
Borda and others added 6 commits July 1, 2026 21:03
- Add test_empty_detections_with_mask_returns_empty_area: verifies empty
  mask branch returns shape (0,) with dtype int64 (locks in the
  float64→int64 fix introduced by count_nonzero + fromiter)
- Add test_mask_boundary_fills: parametrized test for all-False (area=0)
  and all-True (area=H*W) boundary masks; guards count_nonzero contract
  at the extremes

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

- Add module docstring to tests/metrics/utils/test_object_size.py
- Add mask-branch doctest to Detections.area: 2-mask example guarding
  the int64 dtype contract under --doctest-modules
- Extend core.py:2639 comment to note C-contiguous assumption for SIMD
  speedup (F-order slices skip the popcount path)

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Extend comment at object_size.py:162 to:
- Explicitly warn that np.sum(mask, axis=(1,2)) is ~6x SLOWER, not
  faster, than the current count_nonzero loop — prevents future
  "simplification" revert
- Document dtype=np.int64 rationale: Windows NumPy defaults to int32;
  explicit dtype guarantees consistent 64-bit areas cross-platform

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
- Add count_mask_pixels(masks) to detection/utils/masks.py: shared
  helper wrapping np.fromiter + np.count_nonzero (no axis) with
  Google-style docstring and doctest; dtype=np.int64 guaranteed
- Replace inline fromiter expression in Detections.area (core.py) and
  get_mask_size_category (object_size.py) with count_mask_pixels call,
  eliminating 3-line duplication across both sites
- Apply count_mask_pixels to calculate_masks_centroids (masks.py:128),
  updating the missed total_pixels = masks.sum(axis=(1,2)) callsite for
  consistency with the count_nonzero pattern from this PR

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda
Borda merged commit 058d8fd into roboflow:develop Jul 1, 2026
23 checks passed
@Borda Borda mentioned this pull request Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants