Skip to content

Commit 73d9be7

Browse files
Implement RLE and bump version
1 parent 14d1acf commit 73d9be7

6 files changed

Lines changed: 272 additions & 1 deletion

File tree

MIGRATION_GUIDE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -770,6 +770,31 @@ Notes:
770770
- Every value in the input must be present in the mapping.
771771
- Non-contiguous inputs are copied before entering C++.
772772

773+
### Run-Length Encoding
774+
775+
`nifty.tools.computeRLE` computes the COCO-style binary run-length encoding of an
776+
array. Use `bic.utils.compute_rle`:
777+
778+
```python
779+
import nifty.tools as nt
780+
781+
mask = np.array([[0, 0, 1], [1, 1, 0], [0, 1, 1]], dtype=np.uint8)
782+
783+
rle = nt.computeRLE(mask) # nifty: Python list [2, 3, 2, 2]
784+
rle = bic.utils.compute_rle(mask) # bioimage-cpp: np.int64 array([2, 3, 2, 2])
785+
```
786+
787+
Notes:
788+
789+
- The array is flattened in **C-order** and interpreted as binary
790+
(zero vs. nonzero). Counts always start with a run of zeros and then
791+
alternate; a leading `0` is emitted when the first element is nonzero.
792+
- Supported input dtypes are `bool`, `uint8`, `uint16`, `uint32`, `uint64`,
793+
`int32`, and `int64`.
794+
- **Behavioral difference:** nifty returns a Python `list`; `bioimage-cpp`
795+
returns a 1-D NumPy `int64` array.
796+
- Non-contiguous inputs are copied before entering C++.
797+
773798
### Edge-Weighted Watershed
774799

775800
Nifty:
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#pragma once
2+
3+
#include "bioimage_cpp/array_view.hxx"
4+
5+
#include <cstdint>
6+
#include <numeric>
7+
#include <vector>
8+
9+
namespace bioimage_cpp {
10+
11+
// Compute the COCO-style binary run-length encoding of `mask`.
12+
//
13+
// The array is treated as a flat C-order buffer and interpreted as binary
14+
// (zero vs. nonzero). The returned run lengths always start with a run of
15+
// zeros and then alternate (zeros, ones, zeros, ...). If the first element is
16+
// nonzero a leading 0 is emitted. An empty input yields an empty result.
17+
template <class T>
18+
std::vector<std::int64_t> compute_rle(const ConstArrayView<T> &mask) {
19+
const auto n = std::accumulate(
20+
mask.shape.begin(),
21+
mask.shape.end(),
22+
std::ptrdiff_t{1},
23+
[](const std::ptrdiff_t a, const std::ptrdiff_t b) { return a * b; }
24+
);
25+
26+
std::vector<std::int64_t> counts;
27+
if (n <= 0) {
28+
return counts;
29+
}
30+
31+
bool current_value = false; // runs start with zeros
32+
std::int64_t run_length = 0;
33+
for (std::ptrdiff_t i = 0; i < n; ++i) {
34+
const bool value = mask.data[i] != T{0};
35+
if (value == current_value) {
36+
++run_length;
37+
} else {
38+
counts.push_back(run_length);
39+
current_value = value;
40+
run_length = 1;
41+
}
42+
}
43+
counts.push_back(run_length);
44+
45+
return counts;
46+
}
47+
48+
} // namespace bioimage_cpp

src/bindings/utils.cxx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
#include "bioimage_cpp/array_view.hxx"
44
#include "bioimage_cpp/mesh_smoothing.hxx"
5+
#include "bioimage_cpp/run_length.hxx"
56
#include "bioimage_cpp/take_dict.hxx"
67

78
#include <nanobind/ndarray.h>
89
#include <nanobind/stl/pair.h>
910
#include <nanobind/stl/string.h>
1011

12+
#include <algorithm>
1113
#include <cstddef>
1214
#include <cstdint>
1315
#include <memory>
@@ -151,6 +153,35 @@ std::pair<OutputArray<V>, OutputArray<V>> smooth_mesh_t(
151153
return {std::move(out_verts), std::move(out_normals)};
152154
}
153155

156+
template <class T>
157+
OutputArray<std::int64_t> compute_rle_t(InputArray<T> mask) {
158+
std::vector<std::ptrdiff_t> view_shape(mask.ndim());
159+
for (std::size_t axis = 0; axis < mask.ndim(); ++axis) {
160+
view_shape[axis] = static_cast<std::ptrdiff_t>(mask.shape(axis));
161+
}
162+
163+
ConstArrayView<T> input{
164+
mask.data(),
165+
view_shape,
166+
{},
167+
};
168+
169+
std::vector<std::int64_t> counts;
170+
{
171+
nb::gil_scoped_release release;
172+
counts = compute_rle<T>(input);
173+
}
174+
175+
auto *data = new std::int64_t[counts.size()];
176+
std::copy(counts.begin(), counts.end(), data);
177+
nb::capsule owner(data, [](void *p) noexcept {
178+
delete[] static_cast<std::int64_t *>(p);
179+
});
180+
181+
std::size_t shape[1] = {counts.size()};
182+
return OutputArray<std::int64_t>(data, 1, shape, owner);
183+
}
184+
154185
} // namespace
155186

156187
void bind_utils(nb::module_ &m) {
@@ -202,6 +233,20 @@ void bind_utils(nb::module_ &m) {
202233
nb::arg("n_threads"),
203234
"Laplacian smoothing of a triangular mesh with float64 vertices and normals."
204235
);
236+
m.def("_compute_rle_bool", &compute_rle_t<bool>, nb::arg("mask"),
237+
"COCO-style binary run-length encoding of a contiguous bool array.");
238+
m.def("_compute_rle_uint8", &compute_rle_t<std::uint8_t>, nb::arg("mask"),
239+
"COCO-style binary run-length encoding of a contiguous uint8 array.");
240+
m.def("_compute_rle_uint16", &compute_rle_t<std::uint16_t>, nb::arg("mask"),
241+
"COCO-style binary run-length encoding of a contiguous uint16 array.");
242+
m.def("_compute_rle_uint32", &compute_rle_t<std::uint32_t>, nb::arg("mask"),
243+
"COCO-style binary run-length encoding of a contiguous uint32 array.");
244+
m.def("_compute_rle_uint64", &compute_rle_t<std::uint64_t>, nb::arg("mask"),
245+
"COCO-style binary run-length encoding of a contiguous uint64 array.");
246+
m.def("_compute_rle_int32", &compute_rle_t<std::int32_t>, nb::arg("mask"),
247+
"COCO-style binary run-length encoding of a contiguous int32 array.");
248+
m.def("_compute_rle_int64", &compute_rle_t<std::int64_t>, nb::arg("mask"),
249+
"COCO-style binary run-length encoding of a contiguous int64 array.");
205250
}
206251

207252
} // namespace bioimage_cpp::bindings

src/bioimage_cpp/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.2.0"
1+
__version__ = "0.3.0"

src/bioimage_cpp/utils.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,16 @@
2424
np.dtype("float64"): _core._smooth_mesh_float64,
2525
}
2626

27+
_COMPUTE_RLE_BY_DTYPE = {
28+
np.dtype("bool"): _core._compute_rle_bool,
29+
np.dtype("uint8"): _core._compute_rle_uint8,
30+
np.dtype("uint16"): _core._compute_rle_uint16,
31+
np.dtype("uint32"): _core._compute_rle_uint32,
32+
np.dtype("uint64"): _core._compute_rle_uint64,
33+
np.dtype("int32"): _core._compute_rle_int32,
34+
np.dtype("int64"): _core._compute_rle_int64,
35+
}
36+
2737
_COUNT_TABLE_DTYPE = np.dtype([("label", np.uint64), ("count", np.uint64)])
2838
_OVERLAP_TABLE_DTYPE = np.dtype(
2939
[("label_a", np.uint64), ("label_b", np.uint64), ("count", np.uint64)]
@@ -307,6 +317,47 @@ def take_dict(relabeling: Mapping[Any, Any], to_relabel: np.ndarray) -> np.ndarr
307317
raise
308318

309319

320+
def compute_rle(mask: np.ndarray) -> np.ndarray:
321+
"""Compute the COCO-style binary run-length encoding of an array.
322+
323+
The array is flattened in C-order and interpreted as binary (zero vs.
324+
nonzero). The returned run lengths always start with a run of zeros and then
325+
alternate (zeros, ones, zeros, ...); if the first element is nonzero a
326+
leading ``0`` is emitted. An empty array yields an empty result.
327+
328+
This is the equivalent of ``nifty.tools.computeRLE``. Unlike nifty, which
329+
returns a Python list, this returns a NumPy ``int64`` array.
330+
331+
Parameters
332+
----------
333+
mask:
334+
NumPy array with dtype ``bool``, ``uint8``, ``uint16``, ``uint32``,
335+
``uint64``, ``int32``, or ``int64``. Non-contiguous inputs are copied to
336+
a contiguous array before calling the C++ kernel.
337+
338+
Returns
339+
-------
340+
np.ndarray
341+
1-D ``int64`` array of run lengths.
342+
343+
Raises
344+
------
345+
TypeError
346+
If ``mask`` has an unsupported dtype.
347+
"""
348+
array = np.asarray(mask)
349+
dtype = array.dtype
350+
try:
351+
run = _COMPUTE_RLE_BY_DTYPE[dtype]
352+
except KeyError as error:
353+
supported = ", ".join(str(dtype) for dtype in _COMPUTE_RLE_BY_DTYPE)
354+
raise TypeError(
355+
f"mask must have one of dtypes ({supported}), got dtype={dtype}"
356+
) from error
357+
358+
return run(np.ascontiguousarray(array))
359+
360+
310361
def smooth_mesh(
311362
verts: np.ndarray,
312363
normals: np.ndarray,
@@ -473,6 +524,7 @@ def _validate_normalize_by(normalize_by: str) -> None:
473524
"BlockWithHalo",
474525
"SegmentationOverlap",
475526
"UnionFind",
527+
"compute_rle",
476528
"segmentation_overlap",
477529
"smooth_mesh",
478530
"take_dict",

tests/test_utils_compute_rle.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import numpy as np
2+
import pytest
3+
4+
import bioimage_cpp as bic
5+
6+
SUPPORTED_DTYPES = [
7+
np.bool_,
8+
np.uint8,
9+
np.uint16,
10+
np.uint32,
11+
np.uint64,
12+
np.int32,
13+
np.int64,
14+
]
15+
16+
17+
def _rle(values, dtype=np.uint8):
18+
return bic.utils.compute_rle(np.asarray(values, dtype=dtype))
19+
20+
21+
@pytest.mark.parametrize(
22+
"values, expected",
23+
[
24+
([0, 0, 1, 1, 1, 0, 0, 1, 1], [2, 3, 2, 2]),
25+
([1, 1, 0, 1], [0, 2, 1, 1]),
26+
([1, 1, 1, 1, 1], [0, 5]),
27+
([0, 0, 0, 0], [4]),
28+
([1], [0, 1]),
29+
([0], [1]),
30+
],
31+
)
32+
def test_known_cases(values, expected):
33+
result = _rle(values)
34+
np.testing.assert_array_equal(result, np.asarray(expected, dtype=np.int64))
35+
36+
37+
def test_empty_input():
38+
result = _rle([])
39+
assert result.dtype == np.int64
40+
assert result.shape == (0,)
41+
42+
43+
def test_output_is_int64_1d():
44+
result = _rle([0, 1, 1])
45+
assert result.dtype == np.int64
46+
assert result.ndim == 1
47+
48+
49+
@pytest.mark.parametrize("dtype", SUPPORTED_DTYPES)
50+
def test_dtype_variants(dtype):
51+
mask = np.array([0, 1, 1, 0, 0, 1], dtype=dtype)
52+
result = bic.utils.compute_rle(mask)
53+
np.testing.assert_array_equal(result, np.array([1, 2, 2, 1], dtype=np.int64))
54+
55+
56+
def _reference_rle(flat):
57+
# COCO-style: counts of alternating runs starting with zeros.
58+
counts = []
59+
current = 0
60+
run = 0
61+
for value in flat:
62+
binary = int(value != 0)
63+
if binary == current:
64+
run += 1
65+
else:
66+
counts.append(run)
67+
current = binary
68+
run = 1
69+
counts.append(run)
70+
return np.asarray(counts, dtype=np.int64)
71+
72+
73+
@pytest.mark.parametrize("shape", [(6, 8), (3, 4, 5)])
74+
def test_c_order_flatten(shape):
75+
rng = np.random.default_rng(0)
76+
mask = (rng.integers(0, 2, size=shape)).astype(np.uint8)
77+
result = bic.utils.compute_rle(mask)
78+
expected = _reference_rle(mask.ravel(order="C"))
79+
np.testing.assert_array_equal(result, expected)
80+
81+
82+
def test_non_contiguous_input():
83+
rng = np.random.default_rng(1)
84+
base = (rng.integers(0, 2, size=(5, 6))).astype(np.uint8)
85+
view = base.T # non-contiguous (Fortran-ordered view)
86+
assert not view.flags["C_CONTIGUOUS"]
87+
result = bic.utils.compute_rle(view)
88+
expected = _reference_rle(np.ascontiguousarray(view).ravel(order="C"))
89+
np.testing.assert_array_equal(result, expected)
90+
91+
92+
def test_nonzero_values_are_binary():
93+
# Values other than 0/1 are treated as "set".
94+
mask = np.array([0, 7, 3, 0], dtype=np.int32)
95+
result = bic.utils.compute_rle(mask)
96+
np.testing.assert_array_equal(result, np.array([1, 2, 1], dtype=np.int64))
97+
98+
99+
def test_invalid_dtype_raises():
100+
with pytest.raises(TypeError):
101+
bic.utils.compute_rle(np.array([0.0, 1.0], dtype=np.float32))

0 commit comments

Comments
 (0)