Skip to content

feat(segmentation): FBL and NPM1 nucleolar workflows on CPU or GPU - #64

Draft
alxndrkalinin wants to merge 7 commits into
feat/mesh-gpufrom
feat/aics-nucleolar-workflows
Draft

alxndrkalinin wants to merge 7 commits into
feat/mesh-gpufrom
feat/aics-nucleolar-workflows

Conversation

@alxndrkalinin

@alxndrkalinin alxndrkalinin commented Oct 31, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #63 (base feat/mesh-gpu). It is independent of the mesh work apart from the shared SciPy-rounding Gaussian helper that #63 added; retarget once the stack merges.

Summary

Ports two Allen Cell Structure Segmenter classic workflows to cubic.segmentation, alongside SEC61B and TOMM20:

  • workflow_fbl: aicssegmentation's Workflow_fbl, the fibrillarin / dense fibrillar component of the nucleolus.
  • workflow_npm1: Workflow_npm1, the nucleophosmin / granular component.

As with the existing workflows, NumPy input stays on the CPU, CuPy input stays on the GPU, and a 2D plane is segmented as a one-plane volume.

How parity is kept.

  • Scalar thresholds (triangle, median, global and per-object Otsu) are computed on the host. Their per-label histograms are binned on the device, exactly as np.histogram bins them.
  • The 2D spot filter is a CUDA kernel that replays SciPy's NI_Correlate1D summation order for symmetric kernels. cupyx sums tap by tap, which rounds near-zero second-derivative values differently.

Tunable parameters. Every workflow constant is a keyword-only argument: normalization bounds, smoothing, spot sigma and cutoff (None skips the step), size limits, NPM1's hole filter and dilation, and a factor on each object's local Otsu level for both workflows (local_otsu_factor; FBL's defaults to None, the reference behaviour). The defaults are the reference's values, so parity holds at the defaults. The classic workflows are meant to be re-tuned per dataset. On the 2018 confocal tiles (immunostained fibrillarin and EtBr):

  • FBL's default spot cutoff marks noise speckle throughout every nucleus.
  • FBL's low-level objects are whole cells, so the per-object Otsu level separates nucleus from cytoplasm rather than nucleoli from nucleoplasm, and the mask covers about half of each nucleus. Scaling that level fixes it.

Settings that outline nucleoli on those tiles, judged on renderings across several physical samples:

  • fibroblasts: FBL smoothing_sigma=2, dot_cutoff=None, local_otsu_factor=2.0; NPM1 the same with factor 2.5;
  • PC3: FBL factor 1.5; NPM1 factor 1.75.

Evidence

Bitwise parity on real data. These are 3D confocal tiles from Kalinin et al. 2018, cast from uint16 to float32. The measurement script lives outside the repo.

tiles voxels differing: CPU vs aicssegmentation voxels differing: GPU vs CPU
24 fibroblast (31–38 × 1024²): 12 anti-fibrillarin through FBL, 12 EtBr through NPM1 0 on 24/24 0 on 24/24
10 PC3 (52–71 × 1024²), FBL and NPM1 0 on 10/10 0 on 10/10

Speed. The GPU takes 0.2–5.8 s per tile (A40), against 22–570 s for the CPU reference.

Two GPU bugs, found on PC3 and fixed. Both are now covered by regression tests.

  • Past 30 labels, cupyx's labeled minimum/maximum switches to a sort that treats label 0 as background. The first object's histogram came out empty, so its threshold was 0 (41b9235). On one tile this flipped 375,291 voxels; with the fix it flips 0.
  • The spot-filter summation order differed from SciPy's, flipping 1 voxel on one tile (b899881).

Test plan

  • tests/segmentation/test_aics_workflows.py: 70 passed and 0 skipped, in the GPU environment with aicssegmentation and itk
  • Full suite on CPU: 797 passed, 180 skipped (GPU and cellpose tests). It ran during the parameter commit, so CI is the check for the final state.
  • ruff and mypy, run per commit by the implementing agent
  • CI

Notes

  • The weights for the spot-filter kernel come from SciPy's private scipy.ndimage._filters._gaussian_kernel1d, which guarantees the same taps as SciPy. A future SciPy may move it; the import would then fail loudly, not silently drift.
  • The scikit-image min_size semantics follow size_filter_inclusive, as in the existing workflows.

🤖 Generated with Claude Code

https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT

Summary by Sourcery

Add bitwise-compatible CPU and GPU FBL and NPM1 nucleolar segmentation workflows alongside the existing Allen Cell Structure Segmenter integrations.

New Features:

  • Add CPU- and GPU-compatible FBL and NPM1 nucleolar segmentation workflows with configurable processing parameters.
  • Expose the new workflows through the segmentation package API while preserving 2D plane support.

Bug Fixes:

  • Ensure per-label histogram reductions and GPU spot filtering match NumPy, SciPy, and aicssegmentation results, including cases with many labels.

Enhancements:

  • Implement device-aware histogram thresholds, local Otsu segmentation, and SciPy-compatible slice-wise spot filtering for nucleolar workflows.

Documentation:

  • Document the new FBL and NPM1 workflows and their CPU/GPU parity in segmentation guidance.

Tests:

  • Add comprehensive CPU/GPU, reference-parity, parameterization, histogram, thresholding, spot-filter, and regression coverage for the nucleolar workflows.

alxndrkalinin and others added 6 commits October 31, 2026 11:52
…r workflows

Shared primitives for the aicssegmentation FBL and NPM1 ports, running on
NumPy or CuPy input with reference-identical rounding:

- _label_histograms bins each label as np.histogram(values, 256) does
  (NumPy's float32 linspace edges, its float32 bin estimate and the
  one-bin edge correction), voxel-wise on the device; Otsu (through
  skimage's hist= path) and the triangle threshold (skimage's code after
  its histogram call) then run on the host per label.
- _median reproduces np.percentile(x, 50) from the middle order
  statistics of a device sort.
- _greater and _local_threshold_mask compare against host scalars with
  NumPy's promotion, so float64 thresholds compare in float64 on GPU too.
- dot_slice_by_slice ports seg_dot.dot_slice_by_slice: per-plane
  gaussian_laplace as 1D passes, float64 on GPU, stored in float32 after
  each pass as SciPy does.
- _low_level_mask: the (triangle + median) / 2 threshold with the
  700-voxel size filter.

Host extremes use ufunc.at; SciPy's labeled minimum/maximum argsorts the
whole image and took 70 s of 138 s on a 31x1024x1024 tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
workflow_fbl and workflow_npm1 port aicssegmentation's Workflow_fbl and
Workflow_npm1 with their fixed parameters (rescale_ratio=-1), returning a
boolean mask on the input's device; a 2D plane is segmented as a
one-plane volume. FBL thresholds each low-level object at its own Otsu
level and merges 2D spot detections; NPM1 dilates the low level by
ball(2), keeps objects whose Otsu level beats 0.333 x the global Otsu,
adds bright spots inside the low level and cuts dark spots as holes.

On 24 fibroblast tiles (31-38 x 1024 x 1024, slides 157-160; 12
anti-fibrillarin tiles through FBL, 12 EtBr through NPM1) the CPU masks
differed from aicssegmentation's by 0 voxels and the GPU masks from the
CPU's by 0 voxels. Per tile: reference 23-97 s, CPU 22-125 s, GPU
0.23-1.70 s (single-CPU host, A40 shared with another process).

The tests add synthetic nucleoli that reach every branch, and extend the
GPU-vs-CPU, aicssegmentation, plane-mode and dimensionality tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
… GPU

_group_extremes passed 0-based groups to cupyx's labeled minimum and
maximum. Past 30 labels cupyx switches from a per-label loop to a lexsort
that reads extremes at the starts of labels 1..max, so label 0's
minimum and maximum stayed at 0. The first object then had an empty
histogram and a threshold of 0, so every voxel in it passed FBL's local
Otsu step. On a 34-object PC3 tile (171 c1_g004, 52x1024x1024) the GPU
mask differed from the CPU mask in 375291 voxels; with 1-based labels it
differs in 0, and every intermediate matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
dot_slice_by_slice ran cupyx's gaussian_filter1d on a float64 copy.
That accumulates in float64 like SciPy but tap by tap, while SciPy's
NI_Correlate1D sums a symmetric kernel as centre * w0 plus
(left + right) * w_k from the outermost pair inwards. Where a
second-derivative pass cancels to near zero, the two orders round to
different float32 values: 3 and 6 voxels in the two order-2 passes of a
52x1024x1024 PC3 tile, and 1 voxel of the final response.

A CUDA kernel now replays SciPy's loop with SciPy's own reversed
weights, for float32 and float64 input, in reflect mode. The response
on that tile and on 16x256x256 uniform noise (seed 0, sigma 2; 1 voxel
differed before) is bitwise SciPy's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
The Allen Cell Segmenter workflows are meant to be re-tuned per dataset,
and on the 2018 fibroblast/PC3 confocal tiles the defaults mark noise
speckle (FBL) and EtBr-bright cytoplasm (NPM1). workflow_fbl and
workflow_npm1 now take keyword-only parameters for their constants:
normalization bounds, Gaussian sigma, spot-filter sigma(s) and cutoff
(None skips the step), low-level and final minimum sizes, and for NPM1
the hole sigmas and cutoff, dilation radius (0 skips it), local-cutoff
factor and local Otsu factor. The defaults are the reference's values
and its expressions, so the aicssegmentation parity tests are unchanged.

Tests check that disabling or loosening each step changes the mask in
the documented direction, and that GPU equals CPU at non-default values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT
@sourcery-ai

sourcery-ai Bot commented Oct 31, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Ports the Allen Cell Structure Segmenter FBL and NPM1 nucleolar workflows to the segmentation package for NumPy and CuPy inputs, using host-computed scalar thresholds, device-binned histograms, and a custom SciPy-order CUDA spot filter to maintain reference and CPU/GPU bitwise parity; adds public exports, documentation, and extensive regression coverage.

Sequence diagram for CPU or GPU FBL segmentation

sequenceDiagram
    participant Caller
    participant workflow_fbl
    participant Device as NumPy_or_CuPy
    participant Host as Host_thresholds
    participant Spot as SciPy_order_spot_filter

    Caller->>workflow_fbl: workflow_fbl(image, parameters)
    workflow_fbl->>Device: intensity_normalization()
    workflow_fbl->>Device: _gaussian_nearest()
    workflow_fbl->>Host: _histogram_thresholds(smooth, _triangle_from_histogram)
    Host-->>workflow_fbl: triangle threshold
    workflow_fbl->>Host: _median(smooth)
    Host-->>workflow_fbl: median threshold
    workflow_fbl->>Device: _label_faces() and _group_extremes()
    workflow_fbl->>Host: _histogram_thresholds(smooth, _otsu_from_histogram, labels, count)
    Host-->>workflow_fbl: per-object Otsu thresholds
    workflow_fbl->>Device: _local_threshold_mask()
    opt dot_cutoff is not None
        workflow_fbl->>Spot: dot_slice_by_slice(smooth, dot_sigma)
        Spot-->>workflow_fbl: spot response
        workflow_fbl->>Device: remove_small_objects_aics()
    end
    workflow_fbl-->>Caller: boolean mask
Loading

Sequence diagram for CPU or GPU NPM1 segmentation

sequenceDiagram
    participant Caller
    participant workflow_npm1
    participant Device as NumPy_or_CuPy
    participant Host as Host_thresholds
    participant Spot as SciPy_order_spot_filter

    Caller->>workflow_npm1: workflow_npm1(image, parameters)
    workflow_npm1->>Device: intensity_normalization()
    workflow_npm1->>Device: _gaussian_nearest()
    workflow_npm1->>Host: _histogram_thresholds(smooth, _triangle_from_histogram)
    Host-->>workflow_npm1: triangle threshold
    workflow_npm1->>Host: _median(smooth)
    Host-->>workflow_npm1: median threshold
    workflow_npm1->>Device: _label_faces() and binary_dilation()
    workflow_npm1->>Host: _histogram_thresholds(smooth, _otsu_from_histogram)
    Host-->>workflow_npm1: global Otsu threshold
    workflow_npm1->>Host: _histogram_thresholds(smooth, _otsu_from_histogram, labels, count)
    Host-->>workflow_npm1: per-object Otsu thresholds
    workflow_npm1->>Device: _local_threshold_mask()
    opt dot_cutoff is not None
        workflow_npm1->>Spot: dot_slice_by_slice(smooth, dot_sigma)
        Spot-->>workflow_npm1: bright spot response
    end
    opt hole_cutoff is not None
        workflow_npm1->>Spot: dot_slice_by_slice(inverted, sigma)
        Spot-->>workflow_npm1: dark spot response
    end
    workflow_npm1->>Device: remove_small_objects_aics()
    workflow_npm1-->>Caller: boolean mask
Loading

File-Level Changes

Change Details Files
Add CPU/GPU implementations of the FBL and NPM1 nucleolar segmentation workflows with configurable reference-compatible processing stages.
  • Implement normalization, smoothing, low-level triangle/median masks, per-object Otsu thresholds, spot detection, hole filtering, dilation, and size filtering.
  • Expose all workflow parameters as keyword-only options while preserving 2D plane handling and device placement.
  • Export both workflows through the segmentation package API and document their behavior.
cubic/segmentation/aics_workflows.py
cubic/segmentation/__init__.py
AGENTS.md
Reproduce reference thresholding and spot-filter arithmetic across NumPy and CuPy backends.
  • Build per-label 256-bin histograms using device-side binning with host-side scalar threshold computation matching NumPy/scikit-image semantics.
  • Work around CuPy labeled-reduction behavior for large label counts.
  • Add a CUDA symmetric Gaussian-correlation kernel that follows SciPy's summation order and private Gaussian weights for bitwise spot-filter parity.
  • Handle NumPy scalar promotion consistently when applying thresholds on CPU and GPU.
cubic/segmentation/aics_workflows.py
Expand regression and parity coverage for the new workflows and their backend-specific behavior.
  • Test histogram, threshold, median, spot-filter, dtype-promotion, dimensionality, parameter, and CPU/GPU parity behavior.
  • Validate FBL/NPM1 masks against aicssegmentation and exercise synthetic nucleolar branches, including holes, spots, local thresholds, and size filters.
tests/segmentation/test_aics_workflows.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

On the 2018 confocal tiles FBL's low-level objects span whole cells, so
each object's Otsu level separates nucleus from cytoplasm rather than
nucleoli from nucleoplasm, and the mask covers ~50% of every nucleus
even without the spot step. local_otsu_factor scales each object's Otsu
level, as NPM1's 0.98 factor does; None (default) keeps the reference.
With sigma 2, no spots and a factor of 2 (fibroblast) or 1.5 (PC3) the
mask outlines the nucleolar clusters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hEQ1TZJUbmhYKcC14RYHT

This branch has not been deployed

No deployments
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.

1 participant