Skip to content

Commit 8ec03af

Browse files
authored
Merge pull request #43 from WEHI-SODA-Hub/fix/label-mask-dtype-overflow
fix: label masks silently truncate past 65,535 cells
2 parents c697c00 + 72f7f0d commit 8ec03af

6 files changed

Lines changed: 143 additions & 8 deletions

File tree

‎.github/workflows/linting.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ jobs:
2929

3030
pytest:
3131
# Unit tests for the bin/ scripts. Deliberately dependency-light: these cover
32-
# the pure logic (channel naming, exclusion, the marker report), so they need
33-
# no torch, no GPU and no gated model weights, and run in seconds.
32+
# the pure logic (channel naming, exclusion, the marker report, the label
33+
# dtype), so they need no torch, no GPU and no gated model weights, and
34+
# run in seconds. The geo stack is here only because bin/parquet_to_tiff.py
35+
# imports it at module level; all of it is plain wheels.
3436
runs-on: ubuntu-latest
3537
steps:
3638
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4
@@ -41,7 +43,7 @@ jobs:
4143
python-version: "3.12"
4244

4345
- name: Install test dependencies
44-
run: pip install pytest numpy tifffile
46+
run: pip install pytest numpy tifffile rasterio shapely pyarrow typer
4547

4648
- name: Run pytest
4749
run: pytest tests/python -q

‎bin/cellsam_segment.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
import cellSAM
1212

1313

14+
# This is an intermediate mask, smoothed downstream, so compress it: label masks
15+
# are long runs of a repeated ID and deflate shrinks them ~90x. Only the final
16+
# smoothed mask is left uncompressed for viewers.
17+
MASK_COMPRESSION = "zlib"
18+
MASK_COMPRESSION_ARGS = {"level": 1}
19+
20+
1421
def _local_tag_name(tag):
1522
'''Return XML tag name without namespace.'''
1623
return tag.split('}', 1)[-1]
@@ -397,7 +404,10 @@ def main():
397404
mask = remove_small_cells(mask, args.min_area)
398405

399406
# Save segmentation mask
400-
tifffile.imwrite(args.output, mask.astype(np.uint32))
407+
tifffile.imwrite(
408+
args.output, mask.astype(np.uint32),
409+
compression=MASK_COMPRESSION, compressionargs=MASK_COMPRESSION_ARGS,
410+
)
401411
print(f"Segmentation mask saved to {args.output}")
402412

403413

‎bin/parquet_to_tiff.py‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@
2323
from typing import Annotated
2424

2525

26+
# Label IDs are pixel values, so the dtype has to index every cell. uint32
27+
# covers 4.29e9 cells -- orders of magnitude past any real slide -- so one wide
28+
# dtype avoids the silent truncation uint16 hits beyond 65535 cells (rasterize()
29+
# caps at the dtype maximum, it never raises). rasterize() has no uint64.
30+
LABEL_DTYPE = np.uint32
31+
32+
# Label masks are long runs of a repeated ID, so deflate shrinks them ~90x.
33+
MASK_COMPRESSION = "zlib"
34+
MASK_COMPRESSION_ARGS = {"level": 1}
35+
36+
2637
def get_image_dimensions(tiff_path: Path) -> tuple[int, int]:
2738
"""
2839
Extracts image dimensions from the OME-TIFF metadata.
@@ -67,6 +78,15 @@ def main(
6778
# Create incrementing IDs for each geometry (1-based as 0 is background)
6879
ids = [id for id in range(1, len(geometries) + 1)]
6980

81+
# Guard the (physically implausible) case of more cells than uint32 can
82+
# index, so we never silently truncate the way uint16 did.
83+
if len(geometries) > np.iinfo(LABEL_DTYPE).max:
84+
raise ValueError(
85+
f"{len(geometries)} geometries exceeds the "
86+
f"{np.iinfo(LABEL_DTYPE).max} label limit of "
87+
f"{np.dtype(LABEL_DTYPE).name} masks."
88+
)
89+
7090
# Get X and Y dimensions from the TIFF file
7191
(size_x, size_y) = get_image_dimensions(tiff_path)
7292

@@ -84,12 +104,25 @@ def main(
84104
width=size_x,
85105
height=size_y
86106
),
87-
dtype=np.uint16,
107+
dtype=LABEL_DTYPE,
88108
all_touched=True # Fill shapes
89109
)
90110

111+
# stdout carries the TIFF, so diagnostics go to stderr.
112+
max_label = int(mask.max())
113+
print(
114+
f"Rasterised {len(geometries)} geometries as {mask.dtype.name}; "
115+
f"max label {max_label}",
116+
file=sys.stderr,
117+
)
118+
91119
# Write TIFF output
92-
imwrite(sys.stdout.buffer, np.flipud(mask))
120+
imwrite(
121+
sys.stdout.buffer,
122+
np.flipud(mask),
123+
compression=MASK_COMPRESSION,
124+
compressionargs=MASK_COMPRESSION_ARGS,
125+
)
93126

94127

95128
if __name__ == "__main__":

‎modules/local/cellmeasurement/environment.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ dependencies:
88
- uv
99
- pip
1010
- pip:
11-
- git+https://github.com/WEHI-SODA-Hub/cellmeasurement-py@0.2.1
11+
- git+https://github.com/WEHI-SODA-Hub/cellmeasurement-py@0.2.2

‎modules/local/cellmeasurement/main.nf‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ process CELLMEASUREMENT {
33
label 'process_multi'
44

55
conda "${moduleDir}/environment.yml"
6-
container 'ghcr.io/wehi-soda-hub/cellmeasurement-py:0.2.1'
6+
container 'ghcr.io/wehi-soda-hub/cellmeasurement-py:0.2.2'
77

88
input:
99
tuple val(meta),
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Unit tests for label-dtype selection in ``bin/parquet_to_tiff.py``.
2+
3+
Label IDs are stored as pixel values, so a mask dtype too narrow for the largest
4+
ID silently truncates the segmentation. This is worth pinning because there is
5+
no error to notice: ``rasterio.features.rasterize`` does not raise on overflow,
6+
it caps at the dtype maximum and every cell past that point vanishes. It only
7+
bites on whole-slide images, which is exactly where nobody is watching.
8+
9+
Pure python -- no pipeline run required.
10+
"""
11+
12+
import numpy as np
13+
import rasterio.features
14+
from shapely.geometry import box
15+
16+
import tifffile
17+
18+
from parquet_to_tiff import (
19+
LABEL_DTYPE,
20+
MASK_COMPRESSION,
21+
MASK_COMPRESSION_ARGS,
22+
)
23+
24+
UINT16_MAX = np.iinfo(np.uint16).max # 65535
25+
UINT32_MAX = np.iinfo(np.uint32).max
26+
27+
28+
def _label_shapes(n):
29+
"""``n`` disjoint 1px cells with 1-based IDs, laid out in a 100-wide grid."""
30+
return ((box(i % 100, i // 100, i % 100 + 0.9, i // 100 + 0.9), i) for i in range(1, n + 1))
31+
32+
33+
def _rasterize(n, dtype):
34+
height = (n // 100) + 2
35+
return rasterio.features.rasterize(
36+
_label_shapes(n), out_shape=(height, 110), fill=0, dtype=dtype, all_touched=True
37+
)
38+
39+
40+
# ----------------------------------------------------------------------------
41+
# the label dtype
42+
# ----------------------------------------------------------------------------
43+
44+
45+
def test_label_dtype_indexes_far_more_cells_than_any_real_slide():
46+
"""uint32 is the wide fixed choice: 4.29e9 IDs, well past any real mask."""
47+
assert np.dtype(LABEL_DTYPE).name == "uint32"
48+
assert np.iinfo(LABEL_DTYPE).max == UINT32_MAX
49+
50+
51+
# ----------------------------------------------------------------------------
52+
# the truncation this guards against
53+
# ----------------------------------------------------------------------------
54+
55+
56+
def test_uint16_silently_truncates_beyond_its_maximum():
57+
"""Documents the failure mode uint32 avoids: no exception, missing cells."""
58+
mask = _rasterize(UINT16_MAX + 2, np.uint16)
59+
60+
assert mask.max() == UINT16_MAX
61+
assert len(np.unique(mask)) - 1 == UINT16_MAX # two labels lost, silently
62+
63+
64+
def test_label_dtype_preserves_every_label_past_uint16():
65+
n = UINT16_MAX + 2
66+
mask = _rasterize(n, LABEL_DTYPE)
67+
68+
assert mask.dtype == np.uint32
69+
assert mask.max() == n
70+
assert len(np.unique(mask)) - 1 == n
71+
72+
73+
# ----------------------------------------------------------------------------
74+
# compression
75+
# ----------------------------------------------------------------------------
76+
77+
78+
def test_mask_compression_is_lossless_and_smaller(tmp_path):
79+
"""These masks are written to and re-read from shared scratch, so raw byte
80+
count is the cost. Compression must not change a single label."""
81+
mask = _rasterize(5000, np.uint32)
82+
path = tmp_path / "mask.tiff"
83+
84+
tifffile.imwrite(
85+
path, mask,
86+
compression=MASK_COMPRESSION, compressionargs=MASK_COMPRESSION_ARGS,
87+
)
88+
89+
assert path.stat().st_size < mask.nbytes
90+
assert np.array_equal(tifffile.imread(path), mask)

0 commit comments

Comments
 (0)