Skip to content

Commit a6210cc

Browse files
Implement label multiset (#41)
1 parent a7bb8f8 commit a6210cc

18 files changed

Lines changed: 2028 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ nanobind_add_module(_core
1919
src/bindings/flow.cxx
2020
src/bindings/graph.cxx
2121
src/bindings/ground_truth.cxx
22+
src/bindings/label_multiset.cxx
2223
src/bindings/segmentation.cxx
2324
src/bindings/transformation.cxx
2425
src/bindings/util.cxx

MIGRATION_GUIDE.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,102 @@ Important differences:
906906
API yet; the implementation is structured so these filters/extra edges can
907907
be added later.
908908

909+
### Label Multiset
910+
911+
The label-multiset data structure stores, for each spatial block of a label
912+
volume, a histogram of `(label, count)` pairs over the underlying voxels,
913+
with identical histograms across blocks deduplicated into shared storage.
914+
It is used by Paintera to build multi-resolution label pyramids.
915+
916+
`nifty.tools` exposes three functions / classes — `readSubset`,
917+
`downsampleMultiset`, and `MultisetMerger` — operating on five flat arrays
918+
(`offsets`, `entry_sizes`, `entry_offsets`, `ids`, `counts`). `bioimage-cpp`
919+
keeps the same algorithm and storage layout but wraps it in a
920+
`LabelMultiset` dataclass plus a level-0 bootstrap helper.
921+
922+
Nifty:
923+
924+
```python
925+
import nifty.tools as nt
926+
927+
# nifty does not provide a "from labels" helper; level-0 multisets are
928+
# typically constructed by the caller (e.g. by writing histograms manually
929+
# to N5 chunks).
930+
blocking = nt.blocking([0, 0, 0], list(labels.shape), [2, 2, 2])
931+
argmax, new_offsets, new_ids, new_counts = nt.downsampleMultiset(
932+
blocking, offsets, entry_sizes, entry_offsets, ids, counts,
933+
restrict_set=-1,
934+
)
935+
936+
ids, counts = nt.readSubset(offsets, sizes, ids, counts, True)
937+
938+
merger = nt.MultisetMerger(unique_offsets, entry_sizes, ids, counts)
939+
merger.update(unique_offsets, entry_sizes, ids, counts, offsets)
940+
```
941+
942+
bioimage-cpp:
943+
944+
```python
945+
import bioimage_cpp as bic
946+
from bioimage_cpp.label_multiset import (
947+
LabelMultiset,
948+
MultisetMerger,
949+
downsample_multiset,
950+
multiset_from_labels,
951+
read_subset,
952+
)
953+
954+
# Build the level-0 multiset directly from a label volume.
955+
ms0 = multiset_from_labels(labels, block_shape=(1, 1, 1))
956+
957+
# Downsample one level. `blocking.roi_end` must match the input's spatial
958+
# extent (i.e. the shape used to build ms0).
959+
blocking = bic.utils.Blocking([0, 0, 0], list(labels.shape), [2, 2, 2])
960+
ms1 = downsample_multiset(ms0, blocking, restrict_set=-1)
961+
962+
# Merge entries from a list of (offset, size) ranges into one histogram.
963+
ids, counts = read_subset(offsets, sizes, ms1.ids, ms1.counts)
964+
965+
# Deduplicating merger — constructor takes one offset per unique entry.
966+
merger = MultisetMerger.from_multiset(ms1)
967+
merger.update(unique_offsets, entry_sizes, ids, counts, offsets)
968+
```
969+
970+
Name and API changes:
971+
972+
| nifty-style name | bioimage-cpp name |
973+
| --- | --- |
974+
| `readSubset` | `read_subset` |
975+
| `downsampleMultiset` | `downsample_multiset` |
976+
| `MultisetMerger` | `MultisetMerger` |
977+
| `MultisetMerger.get_ids()` | `MultisetMerger.ids` (property) |
978+
| `MultisetMerger.get_counts()` | `MultisetMerger.counts` |
979+
| `restrict_set` (keyword) | `restrict_set` (keyword, same default `-1`) |
980+
981+
Notes:
982+
983+
- A `LabelMultiset` carries all five arrays (`offsets`, `entry_offsets`,
984+
`entry_sizes`, `ids`, `counts`) plus `argmax`. Nifty's
985+
`downsampleMultiset` returns only four of them and leaves the caller to
986+
reconstruct `entry_offsets` / `entry_sizes`; `bioimage-cpp` returns them
987+
directly so multi-level downsample chains compose without bookkeeping.
988+
- `multiset_from_labels(labels, block_shape)` builds the level-0 multiset
989+
from a `uint32` or `uint64` label volume in one call. There is no nifty
990+
equivalent.
991+
- `MultisetMerger.__init__` takes one offset per unique entry (length
992+
`n_unique`), matching nifty's contract. Use
993+
`MultisetMerger.from_multiset(ms)` to construct one directly from a
994+
`LabelMultiset`.
995+
- Count dtype is `uint32` (nifty uses `int32`). Convert at the boundary
996+
if you are reading nifty-written data.
997+
- 2D and 3D blockings are both supported. The bindings instantiate `uint64`
998+
ids, `uint32` counts, and `uint64` offsets; wider dtype matrices can be
999+
added on demand.
1000+
- The `LabelMultisetWrapper` z5/N5 reader from
1001+
`nifty/tools/label_multiset_wrapper.hxx` is intentionally **not** ported
1002+
— I/O stays out of the C++ core. Read/write Paintera-format chunks with
1003+
`zarr`/`numpy` in Python if needed.
1004+
9091005
### Lifted Multicut
9101006

9111007
Nifty exposes lifted multicut through a separate objective + solver hierarchy.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Benchmark the bioimage-cpp label_multiset implementation against nifty.
2+
3+
Runs:
4+
- multiset_from_labels (bioimage-cpp only — nifty has no direct equivalent;
5+
upstream code typically writes the level-0 multiset out manually)
6+
- downsampleMultiset
7+
- readSubset
8+
- MultisetMerger.update
9+
10+
on a deterministic 3D label volume. Prints a comparison table and verifies
11+
that the results agree numerically.
12+
13+
Run with:
14+
python development/label_multiset/benchmark.py
15+
"""
16+
from __future__ import annotations
17+
18+
import time
19+
from dataclasses import dataclass
20+
from typing import Callable, Tuple
21+
22+
import numpy as np
23+
24+
from bioimage_cpp._core import Blocking as BicBlocking
25+
from bioimage_cpp.label_multiset import (
26+
MultisetMerger,
27+
downsample_multiset,
28+
multiset_from_labels,
29+
read_subset,
30+
)
31+
32+
try:
33+
import nifty.tools as nt
34+
35+
HAVE_NIFTY = True
36+
except ImportError:
37+
HAVE_NIFTY = False
38+
39+
40+
SHAPE = (256, 256, 256)
41+
N_LABELS = 2000
42+
COARSEN = 2 # voxels per coarse cell (smaller -> more variety per downsample block)
43+
DOWN_BLOCK = (2, 2, 2)
44+
N_SUBSET_QUERIES = 5000
45+
SUBSET_RANGE = 64
46+
N_REPEATS = 3
47+
48+
49+
def make_labels(seed: int = 0) -> np.ndarray:
50+
rng = np.random.default_rng(seed)
51+
coarse_shape = tuple(s // COARSEN for s in SHAPE)
52+
coarse = rng.integers(0, N_LABELS, size=coarse_shape, dtype=np.uint64)
53+
return np.kron(coarse, np.ones((COARSEN,) * len(SHAPE), dtype=np.uint64))
54+
55+
56+
@dataclass
57+
class TimedResult:
58+
seconds: float
59+
value: object
60+
61+
62+
def time_best(fn: Callable[[], object], repeats: int = N_REPEATS) -> TimedResult:
63+
times = []
64+
value = None
65+
for _ in range(repeats):
66+
t0 = time.perf_counter()
67+
value = fn()
68+
t1 = time.perf_counter()
69+
times.append(t1 - t0)
70+
return TimedResult(min(times), value)
71+
72+
73+
def fmt_row(name: str, ours: float, theirs: float | None) -> str:
74+
if theirs is None:
75+
return f" {name:<32} ours: {ours*1000:8.2f} ms nifty: (n/a)"
76+
ratio = ours / theirs if theirs > 0 else float("inf")
77+
return (
78+
f" {name:<32} ours: {ours*1000:8.2f} ms nifty: {theirs*1000:8.2f} ms"
79+
f" ratio: {ratio:5.2f}x"
80+
)
81+
82+
83+
def bench_from_labels(labels: np.ndarray) -> TimedResult:
84+
return time_best(lambda: multiset_from_labels(labels, (1, 1, 1)))
85+
86+
87+
def bench_downsample_ours(ms) -> Tuple[TimedResult, object]:
88+
blocking = BicBlocking([0, 0, 0], list(SHAPE), list(DOWN_BLOCK))
89+
res = time_best(lambda: downsample_multiset(ms, blocking))
90+
return res, blocking
91+
92+
93+
def bench_downsample_nifty(ms) -> TimedResult | None:
94+
if not HAVE_NIFTY:
95+
return None
96+
n_blocking = nt.blocking(
97+
roiBegin=[0, 0, 0], roiEnd=list(SHAPE), blockShape=list(DOWN_BLOCK)
98+
)
99+
n_offsets = ms.offsets.astype(np.uint64)
100+
n_entry_sizes = ms.entry_sizes.astype(np.uint64)
101+
n_entry_offsets = ms.entry_offsets.astype(np.uint64)
102+
n_ids = ms.ids.astype(np.uint64)
103+
n_counts = ms.counts.astype(np.int32)
104+
105+
return time_best(
106+
lambda: nt.downsampleMultiset(
107+
n_blocking, n_offsets, n_entry_sizes, n_entry_offsets,
108+
n_ids, n_counts, restrict_set=-1,
109+
)
110+
)
111+
112+
113+
def bench_read_subset(ms) -> Tuple[TimedResult, TimedResult | None]:
114+
rng = np.random.default_rng(1)
115+
# Random sub-multisets: pick N_SUBSET_QUERIES random spatial positions and
116+
# gather their (offset, size) ranges.
117+
n_spatial = ms.n_spatial
118+
positions = rng.integers(0, n_spatial, size=N_SUBSET_QUERIES)
119+
range_size = SUBSET_RANGE # how many entries to merge per query
120+
offsets_list = []
121+
sizes_list = []
122+
for p in positions:
123+
start = max(0, p - range_size // 2)
124+
end = min(n_spatial, start + range_size)
125+
offsets_list.append(ms.offsets[start:end].astype(np.uint64))
126+
sizes_list.append(
127+
ms.entry_sizes[ms.entry_offsets[start:end]].astype(np.uint64)
128+
)
129+
flat_offsets = np.concatenate(offsets_list)
130+
flat_sizes = np.concatenate(sizes_list)
131+
132+
ours = time_best(lambda: read_subset(flat_offsets, flat_sizes, ms.ids, ms.counts))
133+
134+
if HAVE_NIFTY:
135+
ids_int32 = ms.ids.astype(np.uint64)
136+
counts_int32 = ms.counts.astype(np.int32)
137+
theirs = time_best(
138+
lambda: nt.readSubset(
139+
flat_offsets, flat_sizes, ids_int32, counts_int32, True
140+
)
141+
)
142+
else:
143+
theirs = None
144+
return ours, theirs
145+
146+
147+
def bench_merger(ms_downsampled) -> Tuple[TimedResult, TimedResult | None]:
148+
# Build a merger from the downsampled multiset, then update with itself.
149+
# The constructor expects one offset per unique entry (length n_unique).
150+
entry_sizes = ms_downsampled.entry_sizes.astype(np.uint64)
151+
ids = ms_downsampled.ids.astype(np.uint64)
152+
counts = ms_downsampled.counts.astype(np.uint32)
153+
counts_i32 = ms_downsampled.counts.astype(np.int32)
154+
155+
unique_off = np.array(
156+
[int(ms_downsampled.offsets[
157+
np.where(ms_downsampled.entry_offsets == e)[0][0]])
158+
for e in range(ms_downsampled.n_entries)],
159+
dtype=np.uint64,
160+
)
161+
162+
def run_ours():
163+
m = MultisetMerger(unique_off, entry_sizes, ids, counts)
164+
spatial = ms_downsampled.entry_offsets.astype(np.uint64).copy()
165+
m.update(unique_off, entry_sizes, ids, counts, spatial)
166+
return m
167+
168+
ours = time_best(run_ours, repeats=N_REPEATS)
169+
170+
if HAVE_NIFTY:
171+
def run_nifty():
172+
m = nt.MultisetMerger(unique_off, entry_sizes, ids, counts_i32)
173+
spatial = ms_downsampled.entry_offsets.astype(np.uint64).copy()
174+
m.update(unique_off, entry_sizes, ids, counts_i32, spatial)
175+
return m
176+
theirs = time_best(run_nifty, repeats=N_REPEATS)
177+
else:
178+
theirs = None
179+
return ours, theirs
180+
181+
182+
def main() -> None:
183+
print(f"shape={SHAPE} labels={N_LABELS} downsample={DOWN_BLOCK} repeats={N_REPEATS}")
184+
print(f"nifty available: {HAVE_NIFTY}")
185+
print()
186+
print("Generating label volume...")
187+
labels = make_labels()
188+
print(f" unique labels in volume: {len(np.unique(labels))}")
189+
print()
190+
191+
print("Benchmarks (best of N):")
192+
t_from = bench_from_labels(labels)
193+
print(fmt_row("multiset_from_labels (1,1,1)", t_from.seconds, None))
194+
ms0 = t_from.value
195+
196+
t_down, blocking = bench_downsample_ours(ms0)
197+
t_down_nifty = bench_downsample_nifty(ms0)
198+
print(fmt_row(
199+
f"downsample_multiset {DOWN_BLOCK}",
200+
t_down.seconds,
201+
t_down_nifty.seconds if t_down_nifty else None,
202+
))
203+
ms1 = t_down.value
204+
print(f" -> level-1: n_spatial={ms1.n_spatial} n_entries={ms1.n_entries}")
205+
206+
t_read_ours, t_read_nifty = bench_read_subset(ms0)
207+
print(fmt_row(
208+
f"read_subset (x{N_SUBSET_QUERIES})",
209+
t_read_ours.seconds,
210+
t_read_nifty.seconds if t_read_nifty else None,
211+
))
212+
213+
t_merger_ours, t_merger_nifty = bench_merger(ms1)
214+
print(fmt_row(
215+
"MultisetMerger.update",
216+
t_merger_ours.seconds,
217+
t_merger_nifty.seconds if t_merger_nifty else None,
218+
))
219+
220+
# Correctness cross-check on downsample.
221+
if HAVE_NIFTY and t_down_nifty is not None:
222+
bic_argmax = ms1.argmax
223+
n_argmax = t_down_nifty.value[0]
224+
assert bic_argmax.tolist() == n_argmax.tolist(), "argmax mismatch vs nifty!"
225+
print("\nargmax(downsample) cross-check vs nifty: OK")
226+
227+
228+
if __name__ == "__main__":
229+
main()

0 commit comments

Comments
 (0)