Add InstanSeg nucleus segmentation for H&E whole-slide images - #9
Merged
Conversation
rushin682
force-pushed
the
worktree-instanseg-segmentation
branch
2 times, most recently
from
August 31, 2026 16:33
e0e07dc to
486bd64
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #9 +/- ##
==========================================
+ Coverage 64.43% 65.62% +1.18%
==========================================
Files 9 13 +4
Lines 1119 1338 +219
==========================================
+ Hits 721 878 +157
- Misses 398 460 +62
🚀 New features to boost your workflow:
|
Adds nucleus segmentation on H&E whole-slide images using InstanSeg, writing
the result into a SpatialData zarr.
The decisive property for this pipeline is that InstanSeg owns the whole-slide
layer: eval_whole_slide_image handles tiling, cross-tile label matching, an
Otsu tissue prefilter, and GeoJSON export. Writing that layer by hand is the
expensive part of whole-slide segmentation -- cross-tile instance
deduplication in particular. Its runtime requirements are also undemanding
(numpy>=1.24, torch>=2.0, no upper bounds), so it installs additively without
disturbing numpy, pydantic, zarr or spatialdata.
What this adds
--------------
- spatialrefinery.segmentation: segment_wsi() and geojson_to_spatialdata(),
with thin CLI wrappers in scripts/ following the convert_to_ometiff.py
pattern.
- slurm/segment_node_worker.sh and segment_slurm.sh for multi-node, multi-GPU
batches. The two stages exchange a cells.geojson rather than being merged,
so it acts as a resume boundary: a failed conversion does not force
re-segmentation.
- An optional "segmentation" extra. Opt-in because it pulls torch and
multi-GB CUDA wheels that most uses of this package do not need, so the CI
test environment does not install it and the tests that need instanseg skip
rather than fail.
- 24 new tests.
The extra names rasterio and geojson directly rather than taking them via
instanseg-torch[io]. That extra also carries zarr>=2.0.0,<3, and installing it
silently downgrades zarr, numcodecs and tiffslide, which breaks spatialdata.
Three upstream InstanSeg bugs are bridged
-----------------------------------------
Patched at call time in segmentation/_compat.py, never vendored, so deleting
that module is all it will take once they are fixed upstream. Each is pinned
by a test.
1. eval_whole_slide_image() builds its label canvas with
zarr.DirectoryStore, which zarr 3 renamed to zarr.storage.LocalStore.
LocalStore is a drop-in for all four operations InstanSeg performs on it.
2. read_slide() calls TiffSlide() without importing it -- every import of
that name in the module is function-local -- so any whole-slide call
raised NameError before reading a single tile.
3. The GeoJSON writer leaves a trailing comma before the closing bracket, so
the file it produces is not valid JSON and QuPath rejects it too.
Bugs 2 and 3 together mean InstanSeg's whole-slide path cannot have been run
end to end upstream.
Implementation notes
--------------------
- InstanSeg writes its .zarr and .geojson next to the input file; slides
usually sit on read-only dataset mounts, so segment_wsi runs the model
against a symlink inside the output directory.
- The image element is built lazily from the slide's own pyramid via
tifffile's zarr interface, so dask reads only the blocks a write touches.
- gpd.read_file tags GeoJSON as EPSG:4326, but these are pixel coordinates;
left in place, .centroid is computed against a spherical datum and every
nucleus centroid drifts. The CRS is cleared first.
Sensitivity
-----------
On weakly haematoxylin-stained slides InstanSeg misses pale nuclei: its
per-tile percentile normalisation is a global stretch, and where bright
cytoplasm dominates the histogram it cannot lift faint nuclei above the seed
threshold. Two opt-in controls, both off by default so runs stay reproducible:
--clahe applies locally adaptive contrast per tile, and --seed-threshold
overrides the model default of 0.7.
On a pale kidney H&E, `--clahe 2.0 --seed-threshold 0.4` found 83,554 nuclei
against 67,168 by default (+24.4%) with no measurable runtime cost. The area
distribution shifted up at every percentile, so the extra objects are
normal-sized nuclei rather than fragments.
rushin682
force-pushed
the
worktree-instanseg-segmentation
branch
from
August 31, 2026 17:00
486bd64 to
ffea1f3
Compare
This was referenced Aug 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds nucleus segmentation on H&E whole-slide images to
spatialrefinery, using InstanSeg, and writes the result into a SpatialData zarr.InstanSeg owns the whole-slide layer we need — tiling, cross-tile label matching, an Otsu tissue prefilter and GeoJSON export — and its runtime requirements (
numpy>=1.24,torch>=2.0, no upper bounds) sit inside the environment we already have, so it installs additively without disturbingnumpy,pydantic,zarrorspatialdata.What this adds
src/spatialrefinery/segmentation/instanseg.pysegment_wsi()— slide →cells.geojsonsrc/spatialrefinery/segmentation/to_spatialdata.pygeojson_to_spatialdata()— GeoJSON + slide → SpatialData zarrsrc/spatialrefinery/segmentation/_compat.pyscripts/instanseg_segment.pyscripts/geojson_to_spatialdata.pyslurm/segment_node_worker.sh,segment_slurm.shtests/test_compat.py,tests/test_segmentation.pyLogic lives in the package and
scripts/holds thin wrappers, matching the existingscripts/convert_to_ometiff.pypattern.The two stages exchange a
cells.geojsonrather than being merged, so it acts as a resume boundary: a failed conversion does not force re-segmentation. Both run in the same interpreter.python scripts/instanseg_segment.py --wsi-path slide.ome.tif --outdir seg/ python scripts/geojson_to_spatialdata.py \ --geojson-path seg/slide.ome.tif/cells.geojson \ --zarr-outdir zarr/ --wsi-path slide.ome.tif --template-adata panel.h5adDependencies
New optional
segmentationextra, opt-in because it pullstorchand multi-GB CUDA wheels that most uses of this package do not need. The CI test environment does not install it, so the tests that needinstansegskip rather than fail.Important
The extra names
rasterioandgeojsondirectly rather than taking them viainstanseg-torch[io]. That extra also carrieszarr>=2.0.0,<3, and installing it silently downgradeszarr,numcodecsandtiffslide, which breaksspatialdata. Its stated reason ("tiffslide doesn't support zarr v3 yet") is stale as of tiffslide 4.0 / Bayer-Group/tiffslide#97.Three upstream InstanSeg bugs are bridged
Patched at call time in
segmentation/_compat.py, never vendored, so deleting that module is all it takes once upstream fixes them.eval_whole_slide_imagebuilds its label canvas withzarr.DirectoryStore, which zarr 3 renamed tozarr.storage.LocalStore.LocalStoreis a drop-in for all four operations InstanSeg performs on the store.TiffSlideis never imported.read_slidecallsTiffSlide(...)atinference_class.py:236, but every import of that name in the module is function-local, so the module global is unbound and any whole-slide call raisesNameErrorbefore reading a single tile....}},\n]—json.loadrejects it, and so does QuPath. Repaired in the artefact rather than worked around at read time, so the published file is valid for any consumer.Bugs 2 and 3 together mean InstanSeg's whole-slide path cannot have been run end to end upstream. Each bridge is pinned by a test so a future release that changes them fails fast rather than halfway through a multi-minute run.
Sensitivity on pale slides
On weakly haematoxylin-stained slides InstanSeg misses pale nuclei: its per-tile
percentile_normalizeis a global stretch, so where bright cytoplasm dominates the histogram it cannot lift faint nuclei above the seed threshold (default 0.7).Two controls, both opt-in and off by default:
--claheapplies locally adaptive contrast per tile (to L in LAB, so the H&E hue balance is preserved), and--seed-thresholdoverrides the model default. CLAHE is injected by wrappingmodel._to_tensor, the single point every tile passes through in the whole-slide loop, so no fork is needed.Whole-slide result on a pale kidney H&E (33427 × 11949, mpp 0.2738):
--clahe 2.0 --seed-threshold 0.4The area distribution shifted up at every percentile from 1 to 99, so the extra 16,386 objects are normal-sized nuclei rather than fragments. As an external check, Xenium's own DAPI-based segmentation of this sample has 97,560 cells: the default recovered 69% of that from H&E, CLAHE 86%.
Ruled out along the way: resolution is not the bottleneck (InstanSeg downsamples 1.83× from 0.2738 to its native 0.5 µm; suppressing that gained +2%), and haematoxylin colour deconvolution actively hurts (−54%) — the brightfield model wants true H&E appearance.
Implementation notes
.zarrand.geojsonnext to the input file; slides usually live on read-only dataset mounts, sosegment_wsiruns the model against a symlink inside the output directory.tifffile's zarr interface, sodaskreads only the blocks a write touches — a level-0 plane on the test slide is 33427 × 11949 × 3.gpd.read_filetags GeoJSON as EPSG:4326, but these are pixel coordinates; left in place,.centroidis computed against a spherical datum and every nucleus centroid drifts. Pinned bytest_centroids_are_planar_pixel_coordinates.Verification
numpy 2.4.6,pydantic 2.12.5,zarr 3.3.0,spatialdata 0.8.0,anndata 0.13.2all unchanged.pytest→ 86 passed locally; 84 passed + 2 skipped in the CI hatch envs on both py3.12 and py3.14.prek run --all-filesclean.Xenium_V1_hKidney_nondiseased_section_he_image.ome.tif(867 MB): segmentation 7m49s → 51 MBcells.geojson; conversion 67s → SpatialData zarr. Read back with the expected 5-level image pyramid, 83,554 polygons, and a(83554, 377)table over the Xenium panel.var_names, confirming those changes were behaviour-neutral.slurm/SEGMENTATION_PLAN.mdcarries the full design notes and measurements.Notes for review
uv.lockbumpsrevision = 2 → 3and drops 25 exotic-platform wheel entries (riscv64, s390x, ppc64le, Windows free-threaded builds) alongside the new dependencies. This is not hand-editing: restoring the file frommainand runninguv syncreproduces it byte for byte, so it is simply what current uv generates for this project..gitignoregains explicit negations./scripts/*.pyand/slurm/are ignored as scratch by design; rather thangit add -f, this adds exceptions for the pipeline files so the intent is visible in the repo.--template-adatais required but only contributes column names to an all-zero table, which exists so the shapes element carries a SpatialData-valid annotation. Happy to make it optional if that suits downstream use better.--clahe 2.0may want tuning per batch before it becomes a default. GPU memory ran ~35 GB with CLAHE versus ~12 GB without — fine on a 46 GB card, but lower--tile-sizeif fanning out 4 GPUs per node hits OOM.