Skip to content

fix: label masks silently truncate past 65,535 cells - #43

Merged
mikemcka merged 7 commits into
devfrom
fix/label-mask-dtype-overflow
Aug 13, 2026
Merged

mikemcka merged 7 commits into
devfrom
fix/label-mask-dtype-overflow

Conversation

@mikemcka

@mikemcka mikemcka commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

parquet_to_tiff.py rasterised label masks as uint16, which caps them at 65,535 cells. Past that limit rasterio.features.rasterize does not raise — it silently caps at the dtype maximum and drops every geometry beyond it.

A 370 mm² section (~370k cells) came back reporting exactly Matched 65535 cells; another section reported 65534. The discarded cells collapse onto low label IDs, producing single labels with parts scattered across the whole slide — which is also why the downstream overlap pass went from 36s to 16 hours.

Only affects the SOPA/Cellpose route (PARQUETTOTIFF). CellSAM writes uint32. Present since the file was created in 10a29f4 (2025-07-14).

Changes

  1. Pick the narrowest dtype that fits the geometry count, and raise rather than truncate if even uint32 is too narrow. Masks under 65,535 cells still come out uint16, so existing outputs and snapshots are byte-identical.
  2. Compress the intermediate masks. Widening the dtype doubles them (3.0 → 6.0 GB on a large slide); deflate more than cancels that. A dense whole-slide mask goes 2.87 GB → 31 MB, and the compressed write is faster than uncompressed even on local disk. Applied to parquet_to_tiff.py and both write sites in smooth_masks.py.
  3. CI installs rasterio/shapely/pyarrow/typer so the new tests can import the script.

Verification

End to end through the > file.tiff redirect the module uses:

   500 cells -> uint16   500/500 labels kept    (357x smaller on disk)
 70000 cells -> uint32   70000/70000 kept       (12x smaller on disk)

12 tests in tests/python/test_parquet_to_tiff.py, including one pinning the silent-truncation behaviour so a fixed dtype fails loudly in future.

The final pipeline output mask is compressed separately in WEHI-SODA-Hub/cellmeasurement-py#7.

🤖 Generated with Claude Code

parquet_to_tiff.py hardcoded dtype=np.uint16 when rasterising segmentation
geometries. Label IDs are stored as pixel values, so that caps a mask at 65535
distinct cells -- and rasterio.features.rasterize does not raise on overflow,
it silently saturates at the dtype maximum and drops every geometry past it.

The failure is invisible on the crops used in testing and only surfaces on
whole-slide images. On a 370 mm^2 section (~370k cells at 0.4964 um/px) it
truncated to exactly 65535 labels, with the discarded cells collapsing onto
low label IDs. That leaves single labels whose parts are scattered across the
whole slide, whose bounding boxes span the image, and whose geometries are
correspondingly expensive downstream.

Select the narrowest dtype that fits the geometry count instead, and raise
rather than truncate if even uint32 is too narrow. Masks under 65535 cells are
unchanged, so existing outputs and snapshots stay byte-identical.

Diagnostics go to stderr because stdout carries the TIFF.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

nf-core pipelines lint overall result: Passed ✅ ⚠️

Posted for pipeline commit 72f7f0d

+| ✅  93 tests passed       |+
#| ❔  29 tests were ignored |#
!| ❗   5 tests had warnings |!
Details

❗ Test warnings:

  • pipeline_todos - TODO string in README.md: Add bibliography of tools and data used in your pipeline
  • pipeline_todos - TODO string in main.nf: Optionally add in-text citation tools to this list.
  • pipeline_todos - TODO string in main.nf: Optionally add bibliographic entries to this list.
  • pipeline_todos - TODO string in main.nf: Only uncomment below if logic in toolCitationText/toolBibliographyText has been filled!
  • pipeline_name_conventions - Naming does not adhere to nf-core conventions: Contains non alphanumeric characters

❔ Tests ignored:

  • files_exist - File is ignored: conf/igenomes.config
  • files_exist - File is ignored: conf/igenomes_ignored.config
  • files_exist - File is ignored: assets/multiqc_config.yml
  • files_exist - File is ignored: conf/igenomes.config
  • files_exist - File is ignored: conf/igenomes_ignored.config
  • files_exist - File is ignored: conf/test.config
  • files_exist - File is ignored: conf/test_full.config
  • files_exist - File is ignored: assets/multiqc_config.yml
  • files_exist - File is ignored: .github/ISSUE_TEMPLATE/config.yml
  • files_exist - File is ignored: .github/workflows/nf-test.yml
  • files_exist - File is ignored: .github/workflows/awstest.yml
  • files_exist - File is ignored: .github/workflows/awsfulltest.yml
  • files_exist - File is ignored: .github/workflows/download_pipeline.yml
  • files_exist - File is ignored: .github/workflows/fix-linting.yml
  • files_exist - File is ignored: .github/workflows/release-announcements.yml
  • files_exist - File is ignored: .github/workflows/template_version_comment.yml
  • files_exist - File is ignored: .github/actions/get-shards/action.yml
  • files_exist - File is ignored: .github/actions/nf-test/action.yml
  • files_exist - File is ignored: .github/actions/
  • files_exist - File is ignored: assets/nf-core-sp_segment_logo_light.png
  • files_exist - File is ignored: docs/images/nf-core-sp_segment_logo_light.png
  • files_exist - File is ignored: docs/images/nf-core-sp_segment_logo_dark.png
  • files_exist - File is ignored: tests/default.nf.test
  • nextflow_config - nextflow_config
  • files_unchanged - Required pipeline config not found - {'manifest.contributors'}
  • actions_nf_test - '.github/workflows/nf-test.yml' not found
  • actions_awstest - 'awstest.yml' workflow not found: /home/runner/work/sp_segment/sp_segment/.github/workflows/awstest.yml
  • schema_params - schema_params
  • multiqc_config - multiqc_config

✅ Tests passed:

Run details

  • nf-core/tools version 3.3.2
  • Run at 2026-08-13 00:29:32

mikemcka and others added 2 commits August 11, 2026 10:49
test_parquet_to_tiff.py exercises label dtype selection, which lives in
bin/parquet_to_tiff.py. That module imports rasterio, shapely, pyarrow and
typer at import time, so the tests cannot reach the function without them and
collection failed with ModuleNotFoundError.

Skipping the module on missing imports would have made CI green while leaving
the truncation bug unguarded, which is the opposite of the point. These are
plain wheels with no GPU or gated weights, so the job stays within its stated
dependency-light intent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widening the label dtype doubles these masks: on a 36583x40986 slide the
PARQUETTOTIFF output goes from 3.0 GB to 6.0 GB, and SMOOTHMASKS then reads
that and writes another copy. All of it lands on shared scratch, where a
recent run spent six hours on a single 2.87 GB mask write.

Label masks are long runs of a repeated ID, so deflate more than cancels the
widening -- a dense whole-slide mask goes from 2.87 GB to 31 MB, and the
compressed write is faster than the uncompressed one even on local disk.

Verified end to end through the stdout redirect the module uses, since TIFF
writing needs a seekable stream: 70,000 cells round-trip as uint32 with every
label intact, 12x smaller on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikemcka
mikemcka requested a review from mcmero August 11, 2026 01:18

@mcmero mcmero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, some minor comments.

Comment thread bin/parquet_to_tiff.py Outdated
Comment thread bin/smooth_masks.py Outdated
mikemcka and others added 3 commits August 12, 2026 10:07
Replace the uint16/uint32 selection (select_label_dtype + LABEL_DTYPES)
with a single fixed uint32 dtype. uint32 indexes 4.29e9 cells, orders of
magnitude past any real slide, so there is no case where narrowing to
uint16 is worth the extra code -- and it removes the sorted-tuple
assumption raised in review. A fail-fast guard still rejects the
implausible >uint32 case rather than truncating silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The smoothed mask is the viewer-facing output and some viewers choke on
compressed-strip TIFFs, so drop compression here entirely and write a
plain TIFF. No option is exposed -- this output is meant to be opened, not
squeezed onto scratch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CellSAM segmenter wrote its label mask uncompressed, unlike the
Cellpose path (parquet_to_tiff). Both are intermediates that get smoothed
downstream, so compress this one the same way -- zlib level 1, ~90x on
label masks. Only the final smoothed mask is left uncompressed for
viewers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mikemcka

Copy link
Copy Markdown
Collaborator Author

Have set uint32 as default, have set intermediate masks as compressed and final output mask as uncompressed.

0.2.2 carries WEHI-SODA-Hub/cellmeasurement-py#7, the STRtree broad phase for
the GeoJSON export overlap pass — the companion to the mask changes here. It
also carries #6, which drops the geometric descriptors from the erosion and
expansion bins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mikemcka
mikemcka merged commit 8ec03af into dev Aug 13, 2026
5 checks passed
@mikemcka
mikemcka deleted the fix/label-mask-dtype-overflow branch August 13, 2026 01:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants