|
| 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