Skip to content

Commit 6c19c2f

Browse files
Add relabel_sequential (#37)
1 parent 98cd6fb commit 6c19c2f

7 files changed

Lines changed: 708 additions & 0 deletions

File tree

MIGRATION_GUIDE.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,44 @@ unspecified.
14781478
labels = bic.segmentation.watershed(image, markers, mask=optional_mask)
14791479
```
14801480

1481+
### Sequential Label Relabeling
1482+
1483+
`bic.segmentation.relabel_sequential` mirrors
1484+
`skimage.segmentation.relabel_sequential`: it remaps an integer label array so
1485+
all non-zero labels become consecutive starting at `offset`, in sorted order
1486+
of the original label values. Label `0` is preserved as background. The
1487+
return is a `(relabeled, forward_map, inverse_map)` tuple with the same
1488+
indexing semantics as skimage (`forward_map[old] == new`,
1489+
`inverse_map[new] == old`).
1490+
1491+
```python
1492+
labels = np.array([0, 5, 10, 5, 0, 200], dtype=np.uint32)
1493+
relabeled, forward_map, inverse_map = bic.segmentation.relabel_sequential(labels)
1494+
# relabeled -> [0, 1, 2, 1, 0, 3]
1495+
# forward_map[5] == 1, forward_map[10] == 2, forward_map[200] == 3
1496+
# inverse_map -> [0, 5, 10, 200]
1497+
1498+
# Custom offset works the same way; only label 0 is treated as background.
1499+
relabeled, _, _ = bic.segmentation.relabel_sequential(labels, offset=10)
1500+
# relabeled -> [0, 10, 11, 10, 0, 12]
1501+
```
1502+
1503+
Notes:
1504+
1505+
- Supported input dtypes are `uint32`, `uint64`, `int32`, and `int64`. The
1506+
`relabeled`, `forward_map`, and `inverse_map` arrays all share the input
1507+
dtype (skimage picks the smallest dtype that fits the output range; this
1508+
implementation does not).
1509+
- `offset` must be a positive integer (`>= 1`).
1510+
- Negative values in signed-dtype inputs are rejected.
1511+
- Non-contiguous inputs are copied before entering C++.
1512+
- Single-threaded but typically ~7–11× faster than skimage and ~12–28×
1513+
faster than `vigra.analysis.relabelConsecutive` on dense label fields
1514+
(1024² and 128³ arrays with hundreds to hundreds-of-thousands of distinct
1515+
labels). The internal kernel allocates a forward-map LUT of size
1516+
`max(label_field) + 1`, so adversarial inputs with very large `max` and few
1517+
distinct labels will use more memory than a hashmap-based implementation.
1518+
14811519
### Connected-Components Labeling
14821520

14831521
`bioimage-cpp` provides pixel-grid connected-components labeling for 2D and
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Benchmark and equivalence check for relabel_sequential.
2+
3+
Compares bioimage_cpp.segmentation.relabel_sequential against:
4+
- skimage.segmentation.relabel_sequential
5+
- vigra.analysis.relabelConsecutive
6+
7+
Not part of the pytest suite. Run from the repository root, e.g.:
8+
9+
python development/segmentation/check_relabel_sequential.py --repeats 5
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
from statistics import median
16+
from time import perf_counter
17+
from typing import Callable
18+
19+
import numpy as np
20+
21+
import bioimage_cpp as bic
22+
23+
24+
def make_problem(shape: tuple[int, ...], n_labels: int, *, seed: int) -> np.ndarray:
25+
"""Build a random label field with roughly ``n_labels`` distinct non-zero labels.
26+
27+
The label values themselves are sparse — drawn from a range larger than
28+
``n_labels`` — to exercise the sorted-unique remapping logic. About 10% of
29+
pixels are background (0).
30+
"""
31+
rng = np.random.default_rng(seed)
32+
value_range = max(n_labels * 4, 16)
33+
label_field = rng.integers(1, value_range, size=shape, dtype=np.int64).astype(
34+
np.uint32
35+
)
36+
background_mask = rng.random(size=shape) < 0.1
37+
label_field[background_mask] = 0
38+
return np.ascontiguousarray(label_field)
39+
40+
41+
def run_bioimage_cpp(label_field: np.ndarray) -> np.ndarray:
42+
relabeled, _, _ = bic.segmentation.relabel_sequential(label_field)
43+
return relabeled
44+
45+
46+
def run_skimage(label_field: np.ndarray) -> np.ndarray:
47+
from skimage.segmentation import relabel_sequential as sk_relabel
48+
49+
relabeled, _, _ = sk_relabel(label_field)
50+
return np.asarray(relabeled)
51+
52+
53+
def run_vigra(label_field: np.ndarray) -> np.ndarray:
54+
import vigra
55+
56+
# vigra.analysis.relabelConsecutive(labels, start_label=1, keep_zeros=True)
57+
# returns (relabeled, max_new_label, mapping_dict). The relabeled array is
58+
# the first element. We pass start_label=1 and keep_zeros=True to match
59+
# skimage's default behavior.
60+
relabeled, _, _ = vigra.analysis.relabelConsecutive(
61+
label_field.astype(np.uint32), start_label=1, keep_zeros=True
62+
)
63+
return np.asarray(relabeled)
64+
65+
66+
def time_calls(
67+
fn: Callable[[np.ndarray], np.ndarray],
68+
label_field: np.ndarray,
69+
repeats: int,
70+
) -> tuple[list[float], np.ndarray]:
71+
# warm-up
72+
result = fn(label_field)
73+
timings: list[float] = []
74+
for _ in range(repeats):
75+
start = perf_counter()
76+
result = fn(label_field)
77+
timings.append(perf_counter() - start)
78+
return timings, result
79+
80+
81+
def check_consecutive(relabeled: np.ndarray, *, offset: int = 1) -> bool:
82+
unique = np.unique(relabeled)
83+
non_pass = unique[unique >= offset]
84+
if non_pass.size == 0:
85+
return True
86+
return bool(np.array_equal(non_pass, np.arange(offset, offset + non_pass.size)))
87+
88+
89+
def report_one(
90+
name: str,
91+
label_field: np.ndarray,
92+
repeats: int,
93+
bic_timings: list[float],
94+
bic_result: np.ndarray,
95+
) -> None:
96+
print(f"\n=== {name} ===")
97+
print(
98+
f"shape={label_field.shape}, dtype={label_field.dtype}, "
99+
f"distinct labels in input={int(np.unique(label_field).size)}"
100+
)
101+
bic_median = median(bic_timings)
102+
print(f"bioimage-cpp median runtime: {bic_median * 1000:.3f} ms")
103+
print(
104+
f"bioimage-cpp produces consecutive labels: "
105+
f"{check_consecutive(bic_result)}"
106+
)
107+
108+
try:
109+
sk_timings, sk_result = time_calls(run_skimage, label_field, repeats)
110+
sk_median = median(sk_timings)
111+
agrees = bool(np.array_equal(sk_result, bic_result))
112+
print(f"skimage median runtime: {sk_median * 1000:.3f} ms")
113+
print(f"skimage / bioimage-cpp ratio: {sk_median / bic_median:.3f}x")
114+
print(f"skimage and bioimage-cpp agree on relabeled array: {agrees}")
115+
except ImportError:
116+
print("skimage not available, skipping skimage comparison")
117+
118+
try:
119+
vi_timings, vi_result = time_calls(run_vigra, label_field, repeats)
120+
vi_median = median(vi_timings)
121+
# vigra preserves first-occurrence order, not sorted order; we expect
122+
# disagreement on the specific label values but agreement on the
123+
# partition the labels induce.
124+
agrees = bool(np.array_equal(vi_result, bic_result))
125+
same_partition = bool(
126+
np.unique(vi_result).size == np.unique(bic_result).size
127+
)
128+
print(f"vigra median runtime: {vi_median * 1000:.3f} ms")
129+
print(f"vigra / bioimage-cpp ratio: {vi_median / bic_median:.3f}x")
130+
print(f"vigra and bioimage-cpp produce identical relabeled array: {agrees}")
131+
print(
132+
f"vigra and bioimage-cpp produce same number of unique labels: "
133+
f"{same_partition}"
134+
)
135+
except ImportError:
136+
print("vigra not available, skipping vigra comparison")
137+
138+
139+
def main() -> None:
140+
parser = argparse.ArgumentParser(description=__doc__)
141+
parser.add_argument("--repeats", type=int, default=5)
142+
parser.add_argument("--seed", type=int, default=0)
143+
args = parser.parse_args()
144+
145+
cases = [
146+
("2D small label set (1024x1024, ~100 labels)", (1024, 1024), 100),
147+
("2D large label set (1024x1024, ~100000 labels)", (1024, 1024), 100_000),
148+
("3D small label set (128x128x128, ~100 labels)", (128, 128, 128), 100),
149+
(
150+
"3D large label set (128x128x128, ~100000 labels)",
151+
(128, 128, 128),
152+
100_000,
153+
),
154+
]
155+
156+
for name, shape, n_labels in cases:
157+
label_field = make_problem(shape, n_labels, seed=args.seed)
158+
bic_timings, bic_result = time_calls(
159+
run_bioimage_cpp, label_field, args.repeats
160+
)
161+
report_one(name, label_field, args.repeats, bic_timings, bic_result)
162+
163+
164+
if __name__ == "__main__":
165+
main()
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
5+
#include <cstddef>
6+
#include <numeric>
7+
#include <stdexcept>
8+
#include <vector>
9+
10+
namespace bioimage_cpp::segmentation {
11+
12+
template <class T>
13+
struct RelabelSequentialMaps {
14+
std::vector<T> forward_map;
15+
std::vector<T> inverse_map;
16+
};
17+
18+
// Relabel an integer array so all non-zero labels become consecutive starting
19+
// at offset, in sorted order. Label 0 is treated as background and always
20+
// maps to 0. Matches skimage.segmentation.relabel_sequential.
21+
template <class T>
22+
RelabelSequentialMaps<T> relabel_sequential(
23+
const ConstArrayView<T> &input,
24+
const T offset,
25+
const ArrayView<T> &out
26+
) {
27+
if (input.shape != out.shape) {
28+
throw std::invalid_argument("out shape must match input shape");
29+
}
30+
31+
const auto n = std::accumulate(
32+
input.shape.begin(),
33+
input.shape.end(),
34+
std::ptrdiff_t{1},
35+
[](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; }
36+
);
37+
38+
RelabelSequentialMaps<T> maps;
39+
maps.inverse_map.assign(static_cast<std::size_t>(offset), T{0});
40+
41+
if (n == 0) {
42+
return maps;
43+
}
44+
45+
// Pass 1: find the maximum label value. Tight, vectorizable loop.
46+
T max_value = T{0};
47+
for (std::ptrdiff_t i = 0; i < n; ++i) {
48+
const T value = input.data[i];
49+
if (value > max_value) {
50+
max_value = value;
51+
}
52+
}
53+
54+
// The forward LUT doubles as a presence bitmap in pass 2 (any non-zero
55+
// value means "present"), then gets rewritten with the final new labels
56+
// by the sorted scan below.
57+
maps.forward_map.assign(static_cast<std::size_t>(max_value) + 1u, T{0});
58+
59+
// Pass 2: mark each input value as present in the LUT.
60+
for (std::ptrdiff_t i = 0; i < n; ++i) {
61+
maps.forward_map[static_cast<std::size_t>(input.data[i])] = T{1};
62+
}
63+
// 0 is reserved background and maps to 0 even if it appeared in input.
64+
maps.forward_map[0] = T{0};
65+
66+
// Sorted-order assignment: walk 1..max_value, replace presence sentinel
67+
// with the next sequential label, and push the old label onto the
68+
// inverse map (already pre-sized to `offset` zeros).
69+
T new_label = offset;
70+
for (std::size_t v = 1; v <= static_cast<std::size_t>(max_value); ++v) {
71+
if (maps.forward_map[v] != T{0}) {
72+
maps.forward_map[v] = new_label;
73+
maps.inverse_map.push_back(static_cast<T>(v));
74+
++new_label;
75+
}
76+
}
77+
78+
// Pass 3: apply the forward LUT to write the relabeled output.
79+
for (std::ptrdiff_t i = 0; i < n; ++i) {
80+
out.data[i] = maps.forward_map[static_cast<std::size_t>(input.data[i])];
81+
}
82+
83+
return maps;
84+
}
85+
86+
} // namespace bioimage_cpp::segmentation

0 commit comments

Comments
 (0)