Skip to content

Commit a9fab60

Browse files
Resolve merge conflict
2 parents 7023f31 + 98cd6fb commit a9fab60

9 files changed

Lines changed: 1570 additions & 0 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,6 +1516,81 @@ Notes:
15161516
`max(label_field) + 1`, so adversarial inputs with very large `max` and few
15171517
distinct labels will use more memory than a hashmap-based implementation.
15181518

1519+
### Connected-Components Labeling
1520+
1521+
`bioimage-cpp` provides pixel-grid connected-components labeling for 2D and
1522+
3D arrays, mirroring `skimage.measure.label`. Two non-background pixels share
1523+
a component iff there is a path of `connectivity`-neighbour steps between
1524+
them along which the input value is constant.
1525+
1526+
Skimage:
1527+
1528+
```python
1529+
from skimage.measure import label
1530+
1531+
labels = label(image, background=0, connectivity=None)
1532+
```
1533+
1534+
bioimage-cpp:
1535+
1536+
```python
1537+
import bioimage_cpp as bic
1538+
1539+
labels = bic.segmentation.label(image, background=0, connectivity=None)
1540+
```
1541+
1542+
Vigra has a closely related entry point on binary / labeled inputs:
1543+
1544+
```python
1545+
import vigra.analysis as va
1546+
1547+
labels = va.labelMultiArrayWithBackground(
1548+
image, neighborhood="direct", background_value=0,
1549+
)
1550+
```
1551+
1552+
`bic.segmentation.label` covers both cases — it labels equal-value runs (as
1553+
`skimage.measure.label` does), and for binary masks it agrees with vigra's
1554+
`labelMultiArrayWithBackground` partition. `neighborhood="direct"` maps to
1555+
`connectivity=1`, `neighborhood="indirect"` maps to `connectivity=image.ndim`.
1556+
1557+
Important migration notes:
1558+
1559+
- Supported input dtypes are `bool`, `uint8`, `uint16`, `uint32`, `uint64`,
1560+
`int32`, `int64`. Floating-point inputs are rejected. Non-contiguous
1561+
arrays are copied to contiguous memory.
1562+
- `connectivity` is an integer in `[1, image.ndim]`. `1` is orthogonal
1563+
neighbours only (4-connectivity in 2D, 6-connectivity in 3D);
1564+
`image.ndim` enables full diagonal connectivity (8-connectivity in 2D,
1565+
26-connectivity in 3D); `2` in 3D is 18-connectivity. `connectivity=None`
1566+
defaults to `image.ndim`, matching `skimage.measure.label`.
1567+
- `background` is the pixel value treated as background. Background pixels
1568+
stay `0` in the output; other equal-valued pixels start at label `1`.
1569+
- The output dtype is always `uint64`. `skimage.measure.label` returns
1570+
`intp`; cast if you need bit-for-bit dtype parity.
1571+
- Output labels are dense, start at `1`, and are assigned in row-major
1572+
first-occurrence order — same convention as skimage.
1573+
- Passing a `bool` array enables an internal fast path that skips
1574+
per-pixel value-equality compares. Convert `uint8` masks to `bool` first
1575+
if your data is binary.
1576+
- Only 2D and 3D inputs are supported in v1. `skimage.measure.label`
1577+
accepts arbitrary ndim; loop over slices externally if you need 4D+.
1578+
- `return_num=True` from `skimage.measure.label` is not provided. Use
1579+
`int(labels.max())` to get the component count.
1580+
1581+
Performance characteristics (single-threaded, against `skimage 0.25` and
1582+
`vigra 1.11`):
1583+
1584+
- On integer inputs (`uint8`/`uint16`/…), bioimage-cpp clearly beats both
1585+
skimage and vigra across the tested grid (2D 512²–2048², 3D 64³–128³, all
1586+
connectivities, binary and multi-value). Typical margin is **1.5×–3×**
1587+
faster than skimage and **2×–8×** faster than vigra.
1588+
- On `bool` inputs, skimage ships a separately tuned 2D kernel that is very
1589+
fast at large sizes. bioimage-cpp matches it at small/medium sizes and on
1590+
all 3D cases; at 2D 2048² the skimage-bool path is currently ahead by
1591+
roughly 1.7×. Convert to `uint8` to fall back onto the general path if
1592+
you need to win at every 2D size.
1593+
15191594
## Vigra / fastfilters
15201595

15211596
### Image Filters
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
"""Shared logic for the connected-components correctness + runtime comparisons.
2+
3+
Builds a binary or integer label image, then runs
4+
``bioimage_cpp.segmentation.label`` against ``skimage.measure.label`` and,
5+
when available, ``vigra.analysis.labelMultiArrayWithBackground``. Correctness
6+
uses partition equality: exact label integers may differ across
7+
implementations, but the equivalence relation "do these two pixels share a
8+
component" must agree everywhere.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import argparse
14+
import importlib.util
15+
from statistics import median
16+
from time import perf_counter
17+
from typing import Callable
18+
19+
import numpy as np
20+
21+
22+
# --------------------------------------------------------------------------- #
23+
# Data generation
24+
# --------------------------------------------------------------------------- #
25+
26+
27+
def make_binary_problem(
28+
*,
29+
ndim: int,
30+
size: int,
31+
seed: int = 0,
32+
density: float = 0.5,
33+
) -> np.ndarray:
34+
"""Generate a binary mask from ``skimage.data.binary_blobs``."""
35+
from skimage.data import binary_blobs
36+
37+
shape = (size,) * ndim
38+
image = binary_blobs(
39+
length=size,
40+
n_dim=ndim,
41+
volume_fraction=density,
42+
rng=seed,
43+
)
44+
assert image.shape == shape
45+
return image.astype(np.uint8, copy=True)
46+
47+
48+
def make_multi_value_problem(
49+
*,
50+
ndim: int,
51+
size: int,
52+
seed: int = 1,
53+
n_values: int = 4,
54+
) -> np.ndarray:
55+
"""Generate a small-cardinality integer image (0..n_values-1)."""
56+
rng = np.random.default_rng(seed)
57+
shape = (size,) * ndim
58+
return rng.integers(low=0, high=n_values, size=shape, dtype=np.int32)
59+
60+
61+
# --------------------------------------------------------------------------- #
62+
# Adapters
63+
# --------------------------------------------------------------------------- #
64+
65+
66+
def run_bioimage_cpp(image: np.ndarray, *, connectivity: int) -> np.ndarray:
67+
import bioimage_cpp as bic
68+
69+
return bic.segmentation.label(image, connectivity=connectivity)
70+
71+
72+
def run_skimage(image: np.ndarray, *, connectivity: int) -> np.ndarray:
73+
from skimage.measure import label as sk_label
74+
75+
return sk_label(image, connectivity=connectivity)
76+
77+
78+
def vigra_available() -> bool:
79+
return importlib.util.find_spec("vigra") is not None
80+
81+
82+
def _vigra_neighborhood(ndim: int, connectivity: int) -> str:
83+
"""Map a ``connectivity`` value to vigra's neighborhood string.
84+
85+
vigra exposes two neighborhood modes: ``"direct"`` (axis-aligned only)
86+
and ``"indirect"`` (all diagonals). It does not support the intermediate
87+
18-connectivity setting, so ``connectivity=2`` in 3D is approximated by
88+
falling back to ``"indirect"`` here — call sites that need a faithful
89+
comparison should restrict to ``connectivity`` in ``{1, ndim}``.
90+
"""
91+
if connectivity == 1:
92+
return "direct"
93+
if connectivity == ndim:
94+
return "indirect"
95+
return "indirect"
96+
97+
98+
def run_vigra(image: np.ndarray, *, connectivity: int) -> np.ndarray:
99+
import vigra
100+
101+
neighborhood = _vigra_neighborhood(image.ndim, connectivity)
102+
# vigra wants a uint32 view; conversion is done outside the timed region
103+
# by the caller (see ``prepare_vigra_input``). This adapter assumes the
104+
# input is already uint32.
105+
return vigra.analysis.labelMultiArrayWithBackground(
106+
image, neighborhood=neighborhood, background_value=0
107+
)
108+
109+
110+
def prepare_vigra_input(image: np.ndarray) -> np.ndarray:
111+
return np.ascontiguousarray(image.astype(np.uint32, copy=False))
112+
113+
114+
# --------------------------------------------------------------------------- #
115+
# Partition-equality check
116+
# --------------------------------------------------------------------------- #
117+
118+
119+
def assert_same_partition(a: np.ndarray, b: np.ndarray) -> None:
120+
"""Assert that ``a`` and ``b`` describe the same pixel partition.
121+
122+
Raises ``AssertionError`` if a pair of pixels share a label in ``a`` but
123+
not in ``b`` (or vice versa). Exact label integers are allowed to differ.
124+
"""
125+
if a.shape != b.shape:
126+
raise AssertionError(f"shape mismatch: {a.shape} vs {b.shape}")
127+
a_flat = a.ravel().tolist()
128+
b_flat = b.ravel().tolist()
129+
a_to_b: dict[int, int] = {}
130+
b_to_a: dict[int, int] = {}
131+
for av, bv in zip(a_flat, b_flat):
132+
if av in a_to_b:
133+
if a_to_b[av] != bv:
134+
raise AssertionError(
135+
f"label {av} in a maps to multiple labels in b "
136+
f"({a_to_b[av]} and {bv})"
137+
)
138+
else:
139+
a_to_b[av] = bv
140+
if bv in b_to_a:
141+
if b_to_a[bv] != av:
142+
raise AssertionError(
143+
f"label {bv} in b maps to multiple labels in a "
144+
f"({b_to_a[bv]} and {av})"
145+
)
146+
else:
147+
b_to_a[bv] = av
148+
149+
150+
# --------------------------------------------------------------------------- #
151+
# Timing
152+
# --------------------------------------------------------------------------- #
153+
154+
155+
def time_runs_interleaved(
156+
runners: dict[str, Callable[[], np.ndarray]],
157+
repeats: int,
158+
) -> tuple[dict[str, list[float]], dict[str, np.ndarray]]:
159+
"""Run each callable ``repeats`` times, interleaved, returning timings.
160+
161+
Each callable is invoked once outside the timed loop as a warm-up.
162+
"""
163+
for run in runners.values():
164+
run()
165+
166+
timings: dict[str, list[float]] = {name: [] for name in runners}
167+
last_result: dict[str, np.ndarray] = {}
168+
names = list(runners)
169+
for repeat in range(repeats):
170+
# Alternate the order each repeat to spread caching effects.
171+
order = names if repeat % 2 == 0 else list(reversed(names))
172+
for name in order:
173+
start = perf_counter()
174+
result = runners[name]()
175+
elapsed = perf_counter() - start
176+
timings[name].append(elapsed)
177+
last_result[name] = result
178+
return timings, last_result
179+
180+
181+
def print_timing_table(
182+
timings: dict[str, list[float]],
183+
reference_name: str = "skimage",
184+
) -> None:
185+
print()
186+
print(f"{'implementation':<16} {'median (ms)':>12} {'speedup vs ref':>16}")
187+
ref_median = median(timings[reference_name]) if reference_name in timings else None
188+
for name, runs in timings.items():
189+
med_ms = median(runs) * 1000.0
190+
if ref_median is None or ref_median == 0:
191+
ratio_str = "-"
192+
else:
193+
ratio = ref_median / median(runs) if median(runs) > 0 else float("inf")
194+
ratio_str = f"{ratio:.3f}x"
195+
print(f"{name:<16} {med_ms:>12.3f} {ratio_str:>16}")
196+
197+
198+
# --------------------------------------------------------------------------- #
199+
# Top-level orchestration
200+
# --------------------------------------------------------------------------- #
201+
202+
203+
def run_check(
204+
*,
205+
ndim: int,
206+
size: int,
207+
connectivity: int,
208+
repeats: int,
209+
density: float,
210+
problem_kind: str,
211+
) -> None:
212+
if problem_kind == "binary":
213+
image = make_binary_problem(ndim=ndim, size=size, density=density)
214+
elif problem_kind == "multi":
215+
image = make_multi_value_problem(ndim=ndim, size=size)
216+
else:
217+
raise ValueError(f"unknown problem kind: {problem_kind}")
218+
219+
print(
220+
f"Connected components {ndim}D comparison "
221+
f"(problem={problem_kind}, shape={image.shape}, dtype={image.dtype}, "
222+
f"connectivity={connectivity})"
223+
)
224+
225+
bic_labels = run_bioimage_cpp(image, connectivity=connectivity)
226+
sk_labels = run_skimage(image, connectivity=connectivity)
227+
assert_same_partition(bic_labels, sk_labels)
228+
print(f" partition matches skimage: yes ({int(bic_labels.max())} components)")
229+
230+
runners: dict[str, Callable[[], np.ndarray]] = {
231+
"bioimage_cpp": lambda: run_bioimage_cpp(image, connectivity=connectivity),
232+
"skimage": lambda: run_skimage(image, connectivity=connectivity),
233+
}
234+
235+
if vigra_available():
236+
# vigra's labelMultiArrayWithBackground groups together every
237+
# non-background pixel regardless of value, so it only matches
238+
# skimage/bioimage_cpp on binary problems. For multi-value images we
239+
# skip the vigra timing rather than report numbers that come from a
240+
# different problem definition. The supported connectivities are
241+
# "direct" (1) and "indirect" (ndim) — intermediate values fall back
242+
# to "indirect" inside the adapter and are skipped here too.
243+
if problem_kind == "binary" and connectivity in (1, ndim):
244+
vigra_input = prepare_vigra_input(image)
245+
vigra_labels = run_vigra(vigra_input, connectivity=connectivity)
246+
try:
247+
assert_same_partition(np.asarray(vigra_labels), sk_labels)
248+
print(" partition matches vigra: yes")
249+
runners["vigra"] = lambda: run_vigra(
250+
vigra_input, connectivity=connectivity
251+
)
252+
except AssertionError as error:
253+
print(f" partition matches vigra: NO ({error}) — vigra excluded from timing")
254+
else:
255+
print(" vigra excluded from timing for this problem/connectivity")
256+
else:
257+
print(" vigra not installed — skipping vigra timing")
258+
259+
timings, _ = time_runs_interleaved(runners, repeats)
260+
print_timing_table(timings, reference_name="skimage")
261+
262+
263+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
264+
parser.add_argument(
265+
"--repeats",
266+
type=int,
267+
default=5,
268+
help="Number of timed runs per implementation.",
269+
)
270+
parser.add_argument(
271+
"--density",
272+
type=float,
273+
default=0.5,
274+
help="Foreground volume fraction for the binary-blobs generator.",
275+
)
276+
parser.add_argument(
277+
"--problem",
278+
choices=("binary", "multi"),
279+
default="binary",
280+
help="Binary mask (skimage.data.binary_blobs) or multi-value integer image.",
281+
)

0 commit comments

Comments
 (0)