Skip to content

Commit 47a745c

Browse files
Affinities (#19)
* Optimize lifted affinity features * More optimization * Add affinity computation
1 parent 50b9651 commit 47a745c

11 files changed

Lines changed: 879 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ find_package(nanobind CONFIG REQUIRED)
1111

1212
nanobind_add_module(_core
1313
NB_STATIC
14+
src/bindings/affinities.cxx
1415
src/bindings/blocking.cxx
1516
src/bindings/module.cxx
1617
src/bindings/graph.cxx
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Cross-check bioimage-cpp's compute_affinities against affogato.
2+
3+
Benchmarks on the registered ISBI ground-truth segmentation volume
4+
(30 × 512 × 512 = 7.86 M voxels, ~660 distinct labels) using the same
5+
17-channel offset configuration that elf uses for mutex-watershed on this
6+
data. Small enough to fit in memory, big enough that initialization and
7+
allocation effects don't dominate.
8+
9+
Not part of the pytest suite (per CLAUDE.md: external-library comparisons
10+
live under ``development/``, not under ``tests/``).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import sys
17+
from statistics import mean, median
18+
from time import perf_counter
19+
20+
import numpy as np
21+
22+
import bioimage_cpp as bic
23+
from bioimage_cpp._data import ISBI_AFFINITY_OFFSETS, load_isbi_gt_segmentation
24+
25+
try:
26+
import affogato.affinities as affo
27+
except ImportError as error: # pragma: no cover - dev script
28+
sys.stderr.write(f"affogato not installed: {error}\n")
29+
sys.exit(1)
30+
31+
32+
# Subsets of ISBI_AFFINITY_OFFSETS. The "nearest neighbours" subset is the
33+
# typical multicut input (3 channels); the "full 17" subset matches
34+
# elf's mutex-watershed proposal generator and exercises long-range offsets.
35+
OFFSET_SUBSETS = {
36+
"nearest": [(-1, 0, 0), (0, -1, 0), (0, 0, -1)],
37+
"full17": list(ISBI_AFFINITY_OFFSETS),
38+
}
39+
40+
41+
def time_call(fn, repeats):
42+
timings = []
43+
result = None
44+
for _ in range(repeats):
45+
start = perf_counter()
46+
result = fn()
47+
timings.append(perf_counter() - start)
48+
return timings, result
49+
50+
51+
def run_case(labels, offsets, *, repeats, ignore_label=None):
52+
offsets_list = [list(offset) for offset in offsets]
53+
54+
bic_timings, (bic_affs, bic_mask) = time_call(
55+
lambda: bic.affinities.compute_affinities(
56+
labels,
57+
offsets_list,
58+
ignore_label=ignore_label,
59+
return_mask=True,
60+
number_of_threads=1,
61+
),
62+
repeats,
63+
)
64+
affo_timings, (affo_affs, affo_mask) = time_call(
65+
lambda: affo.compute_affinities(
66+
labels,
67+
offsets_list,
68+
have_ignore_label=ignore_label is not None,
69+
ignore_label=ignore_label if ignore_label is not None else 0,
70+
),
71+
repeats,
72+
)
73+
74+
return {
75+
"n_offsets": len(offsets_list),
76+
"ignore": ignore_label,
77+
"ok_affs": np.array_equal(bic_affs, affo_affs),
78+
"ok_mask": np.array_equal(bic_mask, affo_mask),
79+
"bic_median_s": median(bic_timings),
80+
"affo_median_s": median(affo_timings),
81+
"bic_mean_s": mean(bic_timings),
82+
"affo_mean_s": mean(affo_timings),
83+
"max_abs_diff": float(np.max(np.abs(bic_affs - affo_affs))) if bic_affs.shape == affo_affs.shape else float("nan"),
84+
}
85+
86+
87+
def main():
88+
parser = argparse.ArgumentParser(description=__doc__)
89+
parser.add_argument("--repeats", type=int, default=3)
90+
parser.add_argument("--timeout", type=float, default=60.0)
91+
args = parser.parse_args()
92+
93+
labels = load_isbi_gt_segmentation(timeout=args.timeout)
94+
n_labels = int(labels.max()) + 1
95+
n_voxels = int(np.prod(labels.shape))
96+
print(
97+
f"labels: shape={labels.shape}, dtype={labels.dtype}, "
98+
f"n_voxels={n_voxels:,}, n_labels={n_labels}",
99+
flush=True,
100+
)
101+
102+
rows = []
103+
for name, offsets in OFFSET_SUBSETS.items():
104+
for ig in (None, 0):
105+
row = run_case(labels, offsets, repeats=args.repeats, ignore_label=ig)
106+
row["offsets_name"] = name
107+
rows.append(row)
108+
109+
print()
110+
print(
111+
f"{'offsets':>10} {'n':>3} {'ignore':>6} {'affs':>5} {'mask':>5}"
112+
f" {'bic_s':>9} {'affo_s':>9} {'speedup':>8}"
113+
)
114+
print("-" * 68)
115+
all_ok = True
116+
for r in rows:
117+
speedup = r["affo_median_s"] / r["bic_median_s"] if r["bic_median_s"] > 0 else float("inf")
118+
print(
119+
f"{r['offsets_name']:>10} {r['n_offsets']:>3d} {str(r['ignore']):>6}"
120+
f" {'OK' if r['ok_affs'] else 'FAIL':>5}"
121+
f" {'OK' if r['ok_mask'] else 'FAIL':>5}"
122+
f" {r['bic_median_s']:>9.4f} {r['affo_median_s']:>9.4f}"
123+
f" {speedup:>7.2f}x"
124+
)
125+
all_ok = all_ok and r["ok_affs"] and r["ok_mask"]
126+
127+
if not all_ok:
128+
print("\nFAIL: output mismatch", file=sys.stderr)
129+
sys.exit(1)
130+
131+
132+
if __name__ == "__main__":
133+
main()
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
#include "bioimage_cpp/detail/threading.hxx"
5+
6+
#include <algorithm>
7+
#include <array>
8+
#include <cstddef>
9+
#include <cstdint>
10+
#include <optional>
11+
#include <vector>
12+
13+
// Pairwise affinity computation from a label volume. Mirrors affogato's
14+
// `affinities/affinities.hxx::compute_affinities` but without the xtensor
15+
// dependency, with proper input validation at the binding boundary, and with
16+
// optional output mask + optional per-offset parallelism.
17+
//
18+
// For each spatial coordinate ``c`` and offset index ``oi``,
19+
// ``affs[oi, c] = 1`` iff ``labels[c] == labels[c + offsets[oi]]``, else 0.
20+
// ``mask[oi, c] = 1`` iff the offset stays in bounds and neither endpoint
21+
// equals ``ignore_label``; out-of-bounds and ignore-label positions produce
22+
// ``affs = 0`` and ``mask = 0``.
23+
namespace bioimage_cpp::affinities {
24+
25+
// Boolean affinities on a 2D label volume. Preconditions (validated in the
26+
// binding layer):
27+
// * labels.ndim() == 2
28+
// * affs.shape == {n_offsets, labels.shape[0], labels.shape[1]}
29+
// * if mask is non-null: mask->shape == affs.shape
30+
// * each entry of offsets has length 2
31+
template <class LabelT, class AffT>
32+
void compute_affinities_2d(
33+
const ConstArrayView<LabelT> &labels,
34+
const std::vector<std::array<std::ptrdiff_t, 2>> &offsets,
35+
const ArrayView<AffT> &affs,
36+
const ArrayView<std::uint8_t> *mask,
37+
const std::optional<LabelT> ignore_label,
38+
const std::size_t number_of_threads = 1
39+
) {
40+
const auto height = labels.shape[0];
41+
const auto width = labels.shape[1];
42+
const auto plane = height * width;
43+
const auto number_of_offsets = offsets.size();
44+
45+
const auto n_threads = ::bioimage_cpp::detail::normalize_thread_count(
46+
number_of_threads, number_of_offsets
47+
);
48+
49+
::bioimage_cpp::detail::parallel_for_chunks(
50+
n_threads,
51+
number_of_offsets,
52+
[&](const std::size_t, const std::size_t begin, const std::size_t end) {
53+
for (std::size_t oi = begin; oi < end; ++oi) {
54+
const auto dy = offsets[oi][0];
55+
const auto dx = offsets[oi][1];
56+
57+
AffT * const affs_channel =
58+
affs.data + static_cast<std::ptrdiff_t>(oi) * plane;
59+
std::uint8_t * const mask_channel = (mask != nullptr)
60+
? mask->data + static_cast<std::ptrdiff_t>(oi) * plane
61+
: nullptr;
62+
63+
std::fill_n(affs_channel, plane, AffT{0});
64+
if (mask_channel != nullptr) {
65+
std::fill_n(mask_channel, plane, std::uint8_t{0});
66+
}
67+
68+
// Sub-rectangle where (y, x) AND (y+dy, x+dx) are both in bounds.
69+
const auto y_begin = std::max<std::ptrdiff_t>(0, -dy);
70+
const auto y_end = height - std::max<std::ptrdiff_t>(0, dy);
71+
const auto x_begin = std::max<std::ptrdiff_t>(0, -dx);
72+
const auto x_end = width - std::max<std::ptrdiff_t>(0, dx);
73+
if (y_begin >= y_end || x_begin >= x_end) {
74+
continue;
75+
}
76+
77+
for (std::ptrdiff_t y = y_begin; y < y_end; ++y) {
78+
const auto ny = y + dy;
79+
const LabelT * const row = labels.data + y * width;
80+
const LabelT * const neighbor_row = labels.data + ny * width;
81+
AffT * const out_row = affs_channel + y * width;
82+
std::uint8_t * const mask_row = (mask_channel != nullptr)
83+
? mask_channel + y * width : nullptr;
84+
for (std::ptrdiff_t x = x_begin; x < x_end; ++x) {
85+
const LabelT a = row[x];
86+
const LabelT b = neighbor_row[x + dx];
87+
if (ignore_label.has_value()) {
88+
const LabelT ig = *ignore_label;
89+
if (a == ig || b == ig) {
90+
continue; // affs/mask already 0
91+
}
92+
}
93+
out_row[x] = (a == b) ? AffT{1} : AffT{0};
94+
if (mask_row != nullptr) {
95+
mask_row[x] = 1;
96+
}
97+
}
98+
}
99+
}
100+
}
101+
);
102+
}
103+
104+
// Boolean affinities on a 3D label volume. Preconditions identical to the 2D
105+
// case with one extra axis.
106+
template <class LabelT, class AffT>
107+
void compute_affinities_3d(
108+
const ConstArrayView<LabelT> &labels,
109+
const std::vector<std::array<std::ptrdiff_t, 3>> &offsets,
110+
const ArrayView<AffT> &affs,
111+
const ArrayView<std::uint8_t> *mask,
112+
const std::optional<LabelT> ignore_label,
113+
const std::size_t number_of_threads = 1
114+
) {
115+
const auto depth = labels.shape[0];
116+
const auto height = labels.shape[1];
117+
const auto width = labels.shape[2];
118+
const auto volume = depth * height * width;
119+
const auto plane = height * width;
120+
const auto number_of_offsets = offsets.size();
121+
122+
const auto n_threads = ::bioimage_cpp::detail::normalize_thread_count(
123+
number_of_threads, number_of_offsets
124+
);
125+
126+
::bioimage_cpp::detail::parallel_for_chunks(
127+
n_threads,
128+
number_of_offsets,
129+
[&](const std::size_t, const std::size_t begin, const std::size_t end) {
130+
for (std::size_t oi = begin; oi < end; ++oi) {
131+
const auto dz = offsets[oi][0];
132+
const auto dy = offsets[oi][1];
133+
const auto dx = offsets[oi][2];
134+
135+
AffT * const affs_channel =
136+
affs.data + static_cast<std::ptrdiff_t>(oi) * volume;
137+
std::uint8_t * const mask_channel = (mask != nullptr)
138+
? mask->data + static_cast<std::ptrdiff_t>(oi) * volume
139+
: nullptr;
140+
141+
std::fill_n(affs_channel, volume, AffT{0});
142+
if (mask_channel != nullptr) {
143+
std::fill_n(mask_channel, volume, std::uint8_t{0});
144+
}
145+
146+
const auto z_begin = std::max<std::ptrdiff_t>(0, -dz);
147+
const auto z_end = depth - std::max<std::ptrdiff_t>(0, dz);
148+
const auto y_begin = std::max<std::ptrdiff_t>(0, -dy);
149+
const auto y_end = height - std::max<std::ptrdiff_t>(0, dy);
150+
const auto x_begin = std::max<std::ptrdiff_t>(0, -dx);
151+
const auto x_end = width - std::max<std::ptrdiff_t>(0, dx);
152+
if (z_begin >= z_end || y_begin >= y_end || x_begin >= x_end) {
153+
continue;
154+
}
155+
156+
for (std::ptrdiff_t z = z_begin; z < z_end; ++z) {
157+
const auto nz = z + dz;
158+
const LabelT * const slab = labels.data + z * plane;
159+
const LabelT * const neighbor_slab = labels.data + nz * plane;
160+
AffT * const out_slab = affs_channel + z * plane;
161+
std::uint8_t * const mask_slab = (mask_channel != nullptr)
162+
? mask_channel + z * plane : nullptr;
163+
for (std::ptrdiff_t y = y_begin; y < y_end; ++y) {
164+
const auto ny = y + dy;
165+
const LabelT * const row = slab + y * width;
166+
const LabelT * const neighbor_row = neighbor_slab + ny * width;
167+
AffT * const out_row = out_slab + y * width;
168+
std::uint8_t * const mask_row = (mask_slab != nullptr)
169+
? mask_slab + y * width : nullptr;
170+
for (std::ptrdiff_t x = x_begin; x < x_end; ++x) {
171+
const LabelT a = row[x];
172+
const LabelT b = neighbor_row[x + dx];
173+
if (ignore_label.has_value()) {
174+
const LabelT ig = *ignore_label;
175+
if (a == ig || b == ig) {
176+
continue;
177+
}
178+
}
179+
out_row[x] = (a == b) ? AffT{1} : AffT{0};
180+
if (mask_row != nullptr) {
181+
mask_row[x] = 1;
182+
}
183+
}
184+
}
185+
}
186+
}
187+
}
188+
);
189+
}
190+
191+
} // namespace bioimage_cpp::affinities

0 commit comments

Comments
 (0)