Skip to content

Commit c31d732

Browse files
Nms (#46)
* Add lifted nhood construction functionality * Add NMS functionality
1 parent 5cbbf7f commit c31d732

7 files changed

Lines changed: 662 additions & 1 deletion

File tree

MIGRATION_GUIDE.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2047,6 +2047,53 @@ Important differences:
20472047
axis-0 coordinate `-1` and `0` on all other axes. The first row of
20482048
`indices` will then contain `-1` everywhere.
20492049

2050+
### Non-Maximum Distance Suppression
2051+
2052+
`nifty.filters.nonMaximumDistanceSuppression` filters a set of candidate
2053+
points using a distance map: each point's suppression radius is the distance
2054+
value at its own location, and from every group of points that fall within
2055+
one another's radius only the one with the largest distance value is kept.
2056+
`bioimage-cpp` exposes the same algorithm as
2057+
`bic.distance.non_maximum_distance_suppression`.
2058+
2059+
nifty:
2060+
2061+
```python
2062+
from nifty.filters import nonMaximumDistanceSuppression
2063+
2064+
# distanceMap: float32 array; points: uint64 array of shape (N, ndim)
2065+
kept = nonMaximumDistanceSuppression(distanceMap, points)
2066+
```
2067+
2068+
bioimage-cpp:
2069+
2070+
```python
2071+
import bioimage_cpp as bic
2072+
2073+
kept = bic.distance.non_maximum_distance_suppression(distance_map, points)
2074+
```
2075+
2076+
Name mapping:
2077+
2078+
| nifty name | bioimage-cpp name |
2079+
| --- | --- |
2080+
| `nifty.filters.nonMaximumDistanceSuppression` | `non_maximum_distance_suppression` |
2081+
2082+
Important differences:
2083+
2084+
- Snake_case naming, consistent with the rest of `bic.distance`.
2085+
- `points` may be `int64`, `uint64`, `int32`, or `uint32`; the returned array
2086+
has shape `(K, ndim)` and preserves the input `points` dtype (nifty always
2087+
returned `uint64`). Output rows are the retained points in ascending
2088+
input-index order.
2089+
- `distance_map` is coerced to C-contiguous `float32` if needed. The
2090+
per-point radius is dynamic (the distance value at each point), matching
2091+
nifty; there is no fixed-radius mode.
2092+
- The algorithm is otherwise identical to nifty, including its float
2093+
arithmetic, so results match element-for-element. It uses an O(N²)
2094+
pairwise distance matrix; threshold the distance map first to keep the
2095+
candidate count modest.
2096+
20502097
## I/O and Build Dependencies
20512098

20522099
`bioimage-cpp` intentionally does not replace nifty or affogato I/O helpers.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Cross-check bioimage-cpp's non_maximum_distance_suppression against nifty.
2+
3+
Builds random binary masks, computes their Euclidean distance transform, picks
4+
candidate points by thresholding the distance map, and compares
5+
``bic.distance.non_maximum_distance_suppression`` against
6+
``nifty.filters.nonMaximumDistanceSuppression`` for 2D and 3D inputs. Reports
7+
both correctness (set + row order) and per-call runtime.
8+
9+
Not part of the pytest suite; requires nifty and scipy.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import sys
16+
from statistics import median
17+
from time import perf_counter
18+
19+
import numpy as np
20+
21+
import bioimage_cpp as bic
22+
23+
try:
24+
from nifty.filters import nonMaximumDistanceSuppression
25+
except ImportError as error: # pragma: no cover - dev script
26+
sys.stderr.write(f"nifty not installed: {error}\n")
27+
sys.exit(1)
28+
29+
try:
30+
from scipy.ndimage import distance_transform_edt
31+
except ImportError as error: # pragma: no cover - dev script
32+
sys.stderr.write(f"scipy not installed: {error}\n")
33+
sys.exit(1)
34+
35+
36+
CASES = [
37+
# (name, shape, foreground_fraction, threshold)
38+
("2d_small", (60, 60), 0.85, 2.0),
39+
("2d_large", (256, 256), 0.9, 3.0),
40+
("3d_small", (25, 25, 25), 0.85, 2.0),
41+
("3d_large", (40, 40, 40), 0.9, 3.0),
42+
]
43+
44+
45+
def time_call(fn, repeats):
46+
timings = []
47+
result = None
48+
for _ in range(repeats):
49+
start = perf_counter()
50+
result = fn()
51+
timings.append(perf_counter() - start)
52+
return median(timings), result
53+
54+
55+
def run_case(name, shape, fg_fraction, threshold, n_trials, repeats, rng):
56+
rows = []
57+
for trial in range(n_trials):
58+
mask = rng.random(shape) < fg_fraction
59+
dm = distance_transform_edt(mask).astype(np.float32)
60+
coords = np.argwhere(dm > threshold).astype(np.uint64)
61+
if len(coords) == 0:
62+
continue
63+
64+
ref_s, ref = time_call(
65+
lambda: nonMaximumDistanceSuppression(dm, coords), repeats
66+
)
67+
ours_s, ours = time_call(
68+
lambda: bic.distance.non_maximum_distance_suppression(dm, coords), repeats
69+
)
70+
71+
exact = ref.shape == ours.shape and np.array_equal(ref, ours)
72+
same_set = {tuple(r) for r in ref.tolist()} == {tuple(r) for r in ours.tolist()}
73+
rows.append(
74+
{
75+
"case": name,
76+
"trial": trial,
77+
"n_points": len(coords),
78+
"n_ref": len(ref),
79+
"n_ours": len(ours),
80+
"set_ok": same_set,
81+
"order_ok": exact,
82+
"ref_s": ref_s,
83+
"ours_s": ours_s,
84+
}
85+
)
86+
return rows
87+
88+
89+
def main():
90+
parser = argparse.ArgumentParser(description=__doc__)
91+
parser.add_argument("--trials", type=int, default=5)
92+
parser.add_argument("--repeats", type=int, default=3)
93+
parser.add_argument("--seed", type=int, default=0)
94+
args = parser.parse_args()
95+
96+
rng = np.random.default_rng(args.seed)
97+
98+
all_rows = []
99+
for name, shape, fg, thr in CASES:
100+
all_rows.extend(
101+
run_case(name, shape, fg, thr, args.trials, args.repeats, rng)
102+
)
103+
104+
header = (
105+
f"{'case':>10} {'trial':>5} {'n_pts':>7} {'n_ref':>6} {'n_ours':>6}"
106+
f" {'set':>5} {'order':>6} {'nifty_ms':>9} {'bic_ms':>9} {'speedup':>8}"
107+
)
108+
print(header)
109+
print("-" * len(header))
110+
all_ok = True
111+
speedups = []
112+
for r in all_rows:
113+
speedup = r["ref_s"] / r["ours_s"] if r["ours_s"] > 0 else float("nan")
114+
speedups.append(speedup)
115+
print(
116+
f"{r['case']:>10} {r['trial']:>5d} {r['n_points']:>7d}"
117+
f" {r['n_ref']:>6d} {r['n_ours']:>6d}"
118+
f" {'OK' if r['set_ok'] else 'FAIL':>5}"
119+
f" {'OK' if r['order_ok'] else 'FAIL':>6}"
120+
f" {r['ref_s'] * 1e3:>9.3f} {r['ours_s'] * 1e3:>9.3f}"
121+
f" {speedup:>7.2f}x"
122+
)
123+
all_ok = all_ok and r["set_ok"] and r["order_ok"]
124+
125+
finite = [s for s in speedups if np.isfinite(s)]
126+
if finite:
127+
geo_mean = float(np.exp(np.mean(np.log(finite))))
128+
print(
129+
f"\nSpeedup (bic vs nifty): min {min(finite):.2f}x, "
130+
f"max {max(finite):.2f}x, geo-mean {geo_mean:.2f}x"
131+
)
132+
133+
if not all_ok:
134+
print("\nFAIL: output mismatch vs nifty", file=sys.stderr)
135+
sys.exit(1)
136+
print("All cases match nifty (set and row order).")
137+
138+
139+
if __name__ == "__main__":
140+
main()
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
#include "bioimage_cpp/detail/grid.hxx"
5+
6+
#include <algorithm>
7+
#include <cmath>
8+
#include <cstddef>
9+
#include <cstdint>
10+
#include <limits>
11+
#include <stdexcept>
12+
#include <string>
13+
#include <vector>
14+
15+
namespace bioimage_cpp::distance {
16+
17+
namespace detail {
18+
19+
template <class PointT>
20+
inline std::ptrdiff_t point_to_flat(
21+
const PointT *coord_row,
22+
const std::vector<std::ptrdiff_t> &strides,
23+
std::ptrdiff_t ndim
24+
) {
25+
std::ptrdiff_t flat = 0;
26+
for (std::ptrdiff_t d = 0; d < ndim; ++d) {
27+
flat += static_cast<std::ptrdiff_t>(coord_row[d]) *
28+
strides[static_cast<std::size_t>(d)];
29+
}
30+
return flat;
31+
}
32+
33+
} // namespace detail
34+
35+
// Non-maximum suppression of candidate points by a distance map.
36+
//
37+
// For each input point p_i, let d_i = distance_map[p_i]. Among all input
38+
// points (including i itself) within Euclidean distance d_i of p_i, the one
39+
// with the largest distance_map value is selected. The unique set of selected
40+
// indices is returned in ascending order via `kept_indices`.
41+
//
42+
// Matches `nifty.filters.nonMaximumDistanceSuppression`, including its float
43+
// arithmetic: coordinate differences and their squared sum accumulate in
44+
// float, the Euclidean distance is `float(sqrt(sum))`, and the neighborhood
45+
// test compares that distance directly against d_i. Replicating this exactly
46+
// (rather than comparing squared distances) keeps boundary ties identical to
47+
// nifty.
48+
//
49+
// Complexity: O(N^2) time and O(N^2) memory for the symmetric distance matrix.
50+
template <class PointT>
51+
inline void non_maximum_distance_suppression(
52+
const ConstArrayView<float> &distance_map,
53+
const ConstArrayView<PointT> &points,
54+
std::vector<std::size_t> &kept_indices
55+
) {
56+
if (distance_map.ndim() < 1) {
57+
throw std::invalid_argument(
58+
"distance_map must have ndim >= 1, got ndim=0"
59+
);
60+
}
61+
if (points.ndim() != 2) {
62+
throw std::invalid_argument(
63+
"points must have ndim == 2, got ndim=" + std::to_string(points.ndim())
64+
);
65+
}
66+
const auto n_points = points.shape[0];
67+
const auto coord_ndim = points.shape[1];
68+
if (coord_ndim != distance_map.ndim()) {
69+
throw std::invalid_argument(
70+
"points second axis must match distance_map ndim, got points.shape[1]=" +
71+
std::to_string(coord_ndim) + ", distance_map.ndim()=" +
72+
std::to_string(distance_map.ndim())
73+
);
74+
}
75+
76+
kept_indices.clear();
77+
if (n_points == 0) {
78+
return;
79+
}
80+
81+
const auto strides = bioimage_cpp::detail::c_order_strides(distance_map.shape);
82+
const auto n = static_cast<std::size_t>(n_points);
83+
84+
// Precompute flat index and distance value at each point.
85+
std::vector<float> point_dist(n);
86+
for (std::size_t i = 0; i < n; ++i) {
87+
const auto *row =
88+
points.data + static_cast<std::ptrdiff_t>(i) * coord_ndim;
89+
const auto flat = detail::point_to_flat(row, strides, coord_ndim);
90+
point_dist[i] = distance_map.data[flat];
91+
}
92+
93+
// Pairwise Euclidean distance matrix (symmetric, N x N). The float
94+
// accumulation and float(sqrt(...)) match nifty bit-for-bit so the
95+
// neighborhood test below produces identical boundary decisions.
96+
std::vector<float> pd(n * n, 0.0f);
97+
for (std::size_t i = 0; i < n; ++i) {
98+
const auto *row_i =
99+
points.data + static_cast<std::ptrdiff_t>(i) * coord_ndim;
100+
for (std::size_t j = i + 1; j < n; ++j) {
101+
const auto *row_j =
102+
points.data + static_cast<std::ptrdiff_t>(j) * coord_ndim;
103+
float sum_sq = 0.0f;
104+
for (std::ptrdiff_t d = 0; d < coord_ndim; ++d) {
105+
const float diff = static_cast<float>(row_i[d]) -
106+
static_cast<float>(row_j[d]);
107+
sum_sq += diff * diff;
108+
}
109+
const auto val = static_cast<float>(std::sqrt(static_cast<double>(sum_sq)));
110+
pd[i * n + j] = val;
111+
pd[j * n + i] = val;
112+
}
113+
}
114+
115+
// For each point, scan all neighbors within its dynamic radius and keep
116+
// the one with the largest distance_map value. Strict `>` ensures the
117+
// first index encountered wins ties, matching nifty's behavior.
118+
std::vector<std::size_t> bests;
119+
bests.reserve(n);
120+
for (std::size_t i = 0; i < n; ++i) {
121+
const float d_i = point_dist[i];
122+
float best_val = -std::numeric_limits<float>::infinity();
123+
std::size_t best_idx = i;
124+
const auto *pd_row = pd.data() + i * n;
125+
for (std::size_t j = 0; j < n; ++j) {
126+
if (pd_row[j] > d_i) {
127+
continue;
128+
}
129+
const float dj = point_dist[j];
130+
if (dj > best_val) {
131+
best_val = dj;
132+
best_idx = j;
133+
}
134+
}
135+
bests.push_back(best_idx);
136+
}
137+
138+
std::sort(bests.begin(), bests.end());
139+
bests.erase(std::unique(bests.begin(), bests.end()), bests.end());
140+
kept_indices = std::move(bests);
141+
}
142+
143+
} // namespace bioimage_cpp::distance

0 commit comments

Comments
 (0)