Skip to content

Commit 316db93

Browse files
authored
perf: give NMS and greedy NMM back the stored-pair path they lost in 0.12.5 (#1443)
1 parent d7a2bd0 commit 316db93

3 files changed

Lines changed: 120 additions & 11 deletions

File tree

sahi/postprocess/_numpy_backend.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
from sahi.postprocess._sparse_backend import (
1313
_safe_ratio,
1414
build_sparse_matches,
15+
greedy_nmm_sparse,
1516
greedy_nmm_streaming,
1617
nmm_sparse,
1718
nmm_streaming,
19+
nms_sparse,
1820
nms_streaming,
1921
should_stream_nmm,
22+
should_stream_survivor,
2023
should_use_sparse,
2124
)
2225

@@ -400,8 +403,11 @@ def nms_numpy(
400403
if matches_all_pairs(match_threshold):
401404
return nms_match_all(predictions)
402405
if should_use_sparse(len(predictions), match_threshold):
403-
boxes, areas, sorted_idxs = _prepare_streaming(predictions)
404-
return nms_streaming(boxes, areas, match_metric, match_threshold, sorted_idxs)
406+
if should_stream_survivor(predictions[:, :4]):
407+
boxes, areas, sorted_idxs = _prepare_streaming(predictions)
408+
return nms_streaming(boxes, areas, match_metric, match_threshold, sorted_idxs)
409+
indptr, indices, sorted_idxs = _prepare_sparse(predictions, match_metric, match_threshold)
410+
return nms_sparse(indptr, indices, sorted_idxs)
405411
matrix, sorted_idxs = _prepare_matrix(predictions, match_metric)
406412
return nms_from_matrix(matrix, sorted_idxs, match_threshold)
407413

@@ -415,8 +421,11 @@ def greedy_nmm_numpy(
415421
if matches_all_pairs(match_threshold):
416422
return greedy_nmm_match_all(predictions)
417423
if should_use_sparse(len(predictions), match_threshold):
418-
boxes, areas, sorted_idxs = _prepare_streaming(predictions)
419-
return greedy_nmm_streaming(boxes, areas, match_metric, match_threshold, sorted_idxs)
424+
if should_stream_survivor(predictions[:, :4]):
425+
boxes, areas, sorted_idxs = _prepare_streaming(predictions)
426+
return greedy_nmm_streaming(boxes, areas, match_metric, match_threshold, sorted_idxs)
427+
indptr, indices, sorted_idxs = _prepare_sparse(predictions, match_metric, match_threshold)
428+
return greedy_nmm_sparse(indptr, indices, sorted_idxs)
420429
matrix, sorted_idxs = _prepare_matrix(predictions, match_metric)
421430
return greedy_nmm_from_matrix(matrix, sorted_idxs, match_threshold)
422431

sahi/postprocess/_sparse_backend.py

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
# crossover barely moves with N, so it is a degree and not a pair budget.
3333
NMM_MAX_DEGREE = 40.0
3434

35+
# NMS and greedy NMM only ever read rows of boxes that survived, so they settle
36+
# far fewer rows than NMM and the stored list keeps paying off much longer. Both
37+
# measure the same crossover, around degree 175; staying under it keeps the pair
38+
# list bounded on the crowded inputs streaming exists to protect.
39+
SURVIVOR_MAX_DEGREE = 150.0
40+
3541
# Probing the tree in one call would return sample x degree pairs at once, which
3642
# on crowded boxes dwarfs everything the path being chosen goes on to allocate.
3743
# Only the count is wanted, so the probe is spent a chunk at a time.
@@ -96,17 +102,31 @@ def _dominates(left: int | np.ndarray, right: np.ndarray, scores: np.ndarray, bo
96102
return lower_score | (score_equal & ~left_lt)
97103

98104

99-
def should_stream_nmm(boxes: np.ndarray) -> bool:
100-
"""Return whether NMM should answer rows from the tree instead of storing pairs.
105+
def should_stream(boxes: np.ndarray, max_degree: float) -> bool:
106+
"""Return whether to answer rows from the tree instead of storing pairs.
101107
102108
Args:
103109
boxes: Array of shape (N, 4) with columns [x1, y1, x2, y2].
110+
max_degree: Mean degree above which streaming wins, per caller.
104111
105112
Returns:
106113
True when the boxes intersect each other often enough that reading rows
107114
from the tree beats storing them.
108115
"""
109-
return estimate_mean_degree(boxes) > NMM_MAX_DEGREE
116+
return estimate_mean_degree(boxes) > max_degree
117+
118+
119+
def should_stream_nmm(boxes: np.ndarray) -> bool:
120+
"""Return whether NMM should answer rows from the tree instead of storing pairs."""
121+
return should_stream(boxes, NMM_MAX_DEGREE)
122+
123+
124+
def should_stream_survivor(boxes: np.ndarray) -> bool:
125+
"""Return whether NMS and greedy NMM should read rows from the tree.
126+
127+
Both settle a row only for a box that survived, so they share a crossover.
128+
"""
129+
return should_stream(boxes, SURVIVOR_MAX_DEGREE)
110130

111131

112132
def should_use_sparse(n: int, match_threshold: float) -> bool:
@@ -383,6 +403,70 @@ def _dominates_all(indptr: np.ndarray, indices: np.ndarray, scores: np.ndarray,
383403
return _dominates(rows, indices, scores, boxes)
384404

385405

406+
def nms_sparse(indptr: np.ndarray, indices: np.ndarray, sorted_idxs: np.ndarray) -> list[int]:
407+
"""NMS over a CSR match adjacency. Mirrors ``nms_from_matrix``.
408+
409+
Suppressing a whole row is one vectorized store, so while the pair list
410+
stays bounded this beats querying the tree per survivor.
411+
412+
Args:
413+
indptr: CSR row pointers of length N + 1.
414+
indices: CSR column indices.
415+
sorted_idxs: Indices sorted by score descending.
416+
417+
Returns:
418+
List of kept indices sorted by score descending.
419+
"""
420+
keep: list[int] = []
421+
suppressed = np.zeros(len(indptr) - 1, dtype=bool)
422+
423+
for idx in sorted_idxs:
424+
if suppressed[idx]:
425+
continue
426+
keep.append(int(idx))
427+
suppressed[indices[indptr[idx] : indptr[idx + 1]]] = True
428+
429+
return keep
430+
431+
432+
def greedy_nmm_sparse(
433+
indptr: np.ndarray,
434+
indices: np.ndarray,
435+
sorted_idxs: np.ndarray,
436+
) -> dict[int, list[int]]:
437+
"""Greedy NMM over a CSR match adjacency. Mirrors ``greedy_nmm_from_matrix``.
438+
439+
Args:
440+
indptr: CSR row pointers of length N + 1.
441+
indices: CSR column indices.
442+
sorted_idxs: Indices sorted by score descending.
443+
444+
Returns:
445+
Dict mapping each kept index to a list of indices merged into it.
446+
"""
447+
n = len(indptr) - 1
448+
suppressed = np.zeros(n, dtype=bool)
449+
450+
# The dense loop only considers candidates that come later in score order,
451+
# and emits them in that order.
452+
rank = np.empty(n, dtype=np.intp)
453+
rank[sorted_idxs] = np.arange(n)
454+
455+
keep_to_merge_list: dict[int, list[int]] = {}
456+
for position, idx in enumerate(sorted_idxs):
457+
if suppressed[idx]:
458+
continue
459+
460+
neighbours = indices[indptr[idx] : indptr[idx + 1]]
461+
merge_indices = neighbours[(rank[neighbours] > position) & ~suppressed[neighbours]]
462+
merge_indices = merge_indices[np.argsort(rank[merge_indices])]
463+
464+
suppressed[merge_indices] = True
465+
keep_to_merge_list[int(idx)] = merge_indices.tolist()
466+
467+
return keep_to_merge_list
468+
469+
386470
def nmm_sparse(
387471
indptr: np.ndarray,
388472
indices: np.ndarray,

tests/test_sparse_backend.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@
1919
SPARSE_MIN_BOXES,
2020
MatchQuery,
2121
build_sparse_matches,
22+
greedy_nmm_sparse,
2223
greedy_nmm_streaming,
2324
nmm_sparse,
2425
nmm_streaming,
26+
nms_sparse,
2527
nms_streaming,
2628
should_stream_nmm,
29+
should_stream_survivor,
2730
should_use_sparse,
2831
)
2932

@@ -109,10 +112,12 @@ def test_dense_csr_and_streaming_agree(match_metric: str, match_threshold: float
109112
case = _case(predictions, match_metric, match_threshold)
110113
streaming_args = (case.boxes, case.areas, match_metric, match_threshold, case.sorted_idxs)
111114

112-
assert nms_streaming(*streaming_args) == nms_from_matrix(case.matrix, case.sorted_idxs, match_threshold)
113-
assert greedy_nmm_streaming(*streaming_args) == greedy_nmm_from_matrix(
114-
case.matrix, case.sorted_idxs, match_threshold
115-
)
115+
dense_nms = nms_from_matrix(case.matrix, case.sorted_idxs, match_threshold)
116+
assert nms_streaming(*streaming_args) == dense_nms
117+
assert nms_sparse(case.indptr, case.indices, case.sorted_idxs) == dense_nms
118+
dense_greedy = greedy_nmm_from_matrix(case.matrix, case.sorted_idxs, match_threshold)
119+
assert greedy_nmm_streaming(*streaming_args) == dense_greedy
120+
assert greedy_nmm_sparse(case.indptr, case.indices, case.sorted_idxs) == dense_greedy
116121

117122
dense_nmm = nmm_from_matrix(case.matrix, case.sorted_idxs, case.scores, case.boxes, match_threshold)
118123
assert nmm_sparse(case.indptr, case.indices, case.sorted_idxs, case.scores, case.boxes) == dense_nmm
@@ -138,7 +143,13 @@ def test_streaming_agrees_with_dense_once_the_tree_is_rebuilt(match_metric: str)
138143
streaming_args = (case.boxes, case.areas, match_metric, 0.3, case.sorted_idxs)
139144

140145
assert nms_streaming(*streaming_args) == nms_from_matrix(case.matrix, case.sorted_idxs, 0.3)
146+
assert nms_sparse(case.indptr, case.indices, case.sorted_idxs) == nms_from_matrix(
147+
case.matrix, case.sorted_idxs, 0.3
148+
)
141149
assert greedy_nmm_streaming(*streaming_args) == greedy_nmm_from_matrix(case.matrix, case.sorted_idxs, 0.3)
150+
assert greedy_nmm_sparse(case.indptr, case.indices, case.sorted_idxs) == greedy_nmm_from_matrix(
151+
case.matrix, case.sorted_idxs, 0.3
152+
)
142153

143154
result = nmm_streaming(*streaming_args, case.scores)
144155
assert result == nmm_from_matrix(case.matrix, case.sorted_idxs, case.scores, case.boxes, 0.3)
@@ -177,6 +188,11 @@ def test_nmm_streams_only_when_boxes_are_crowded() -> None:
177188
assert should_stream_nmm(scattered[:, :4]) is False
178189
assert should_stream_nmm(crowded[:, :4]) is True
179190

191+
# NMS and greedy NMM settle fewer rows than NMM, so they keep the stored
192+
# pairs for longer, but the same crowding must still push them to streaming.
193+
assert should_stream_survivor(scattered[:, :4]) is False
194+
assert should_stream_survivor(crowded[:, :4]) is True
195+
180196
# Both routes must agree with the stored-pair result and still group every box.
181197
for name, predictions in (("scattered", scattered), ("crowded", crowded)):
182198
boxes, scores = predictions[:, :4], predictions[:, 4]

0 commit comments

Comments
 (0)