Skip to content

feat: add ClickSegmentTool for click-to-segment lesion segmentation#2780

Merged
sedghi merged 12 commits into
mainfrom
feat/one-click-segment
Jul 10, 2026
Merged

feat: add ClickSegmentTool for click-to-segment lesion segmentation#2780
sedghi merged 12 commits into
mainfrom
feat/one-click-segment

Conversation

@sedghi

@sedghi sedghi commented Jul 2, 2026

Copy link
Copy Markdown
Member

Context

Segmenting a PET lesion today means picking a strategy and tuning parameters
(disk radius, mean/percent thresholds, deltas) before you get a result you can
trust. This PR adds ClickSegmentTool — a configuration-free,
click-to-segment lesion tool built for PET-style hot lesions but
modality-agnostic. There is nothing to configure: no disk radii, no
strategies, no deltas.

The tool deliberately does not promise to segment anything you click: it
only accepts clicks on a coherent, contrast-distinct, lesion-scale region, and
the hover cursor tells you up front whether a click will land.

Changes & Results

New tool — ClickSegmentTool (exported publicly from @cornerstonejs/tools):

  • Hover previews segmentability. A + cursor means a click here produces
    a meaningful segment; a blocked cursor means it will not (flat area, noise
    speck, or an unbounded region), and a pending cursor means the region is
    still being evaluated. Clicks on blocked spots are no-ops. Verdicts attach to
    regions, not pixels, so the cursor does not flicker as you move within one
    structure.
  • Click derives a one-sided intensity threshold dynamically — everything at
    least as intense as the clicked structure and connected to it — so the
    hottest core of a lesion is always included and there are no interior holes.
  • expand() / shrink() step deterministically along the growth curve
    measured at click time: each step clears the previous result and refills at
    the next/previous stable threshold, so the segment visibly grows or shrinks
    every step rather than silently no-op'ing.
  • Lesion-ness is judged by shape, not size. The would-be segment must be
    one coherent, self-contained entity (compactness against its 3D bounding box,
    plus a through-slice self-containment check). Large solid lesions pass;
    sprawling webs (chained bones, noise) are rejected. There is no size or
    extent cap.
  • Undo/redo. Each click and each expand/shrink step is recorded as one
    LabelmapMemo on the segmentation history stack, so it steps through
    click operations the same way it does through brush strokes.

Supporting utilities (under packages/tools/src/utilities/segmentation/):

  • runFloodFillSegmentation — orchestrates the fill: dynamic band resolution,
    lazy 3D flood, commit, external/internal island removal, and optional
    history recording via a historyVoxelManager option.
  • floodFillSliceLazy — 3D BFS flood fill with per-slice visit masks and
    on-demand slice loading, with budget and shape-gate hooks.
  • adaptiveRegionIntensityRange — the config-free adaptive strategy (VOI-mapped
    display bytes, seed snapping, one-sided band, quiet-run tolerance selection).
  • commitSliceMasksToLabelmap, createEnsureSliceLoadedForVolume,
    floodFillIslandRemoval, neighborhoodStats, getViewportVoiMappingForVolume.
  • Core: viewportVoiIntensityMapping utilities to map between display VOI and
    raw scalar intensity.

Demo & helpers:

  • New clickSegment example (PET-only single viewport, hover-cursor legend,
    Shrink/Expand/Clear, Undo/Redo).
  • validateAndSortVolumeIds helper (spatial slice ordering/validation) and
    addDropdownToToolbar support for async (Promise) option sources.

Testing

  1. yarn run example clickSegment and open the example.
  2. Hover over the PET image: a + cursor appears over lesions, a blocked
    cursor over background/flat areas.
  3. Click a lesion — it segments in one click with no interior holes.
  4. Use Expand / Shrink and confirm the segment visibly grows/shrinks
    each step; Undo / Redo (or Ctrl/Cmd+Z, Shift+Z, Ctrl+Y) step through
    each operation.

Unit tests (Jest) added and passing:
adaptiveRegionIntensityRange, floodFillSliceLazy, floodFillIslandRemoval,
commitSliceMasksToLabelmap.

Checklist

PR

  • My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • "OS: macOS"
  • "Node version: 22.x"
  • "Browser: Chrome"

Summary by CodeRabbit

  • New Features
    • Added a click-to-segment tool with hover feedback, expand/shrink controls, refresh, and undo/redo support.
    • Added adaptive flood-fill segmentation for more precise region selection.
    • Added support for loading and applying slice-based segmentation updates more reliably.
    • Added new volume validation and VOI/intensity mapping helpers for better segmentation behavior.
    • Added a new demo example for the click-to-segment workflow.

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a one-click PET lesion segmentation feature: VOI intensity mapping utilities, adaptive grow-cut region probing, a lazy 3D flood-fill algorithm, labelmap commit and island-removal helpers, a ClickSegmentTool, a demo example, and a demo helper for validating/sorting volume image IDs plus async dropdown option loading.

Changes

One-Click PET Lesion Segmentation

Layer / File(s) Summary
VOI mapping utilities and logger
packages/core/src/utilities/viewportVoiIntensityMapping.ts, packages/core/src/utilities/index.ts, packages/core/src/utilities/logger.ts
Adds forward/inverse VOI-to-intensity mapping functions, band-to-raw-range conversion, the ViewportVoiMappingProps type, and a growCutLog logger; re-exports these from the utilities index.
Lazy flood fill and labelmap commit
packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts, .../commitSliceMasksToLabelmap.ts, .../createEnsureSliceLoadedForVolume.ts, packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js, .../commitSliceMasksToLabelmap.jest.js
Implements a BFS-based lazy 3D flood fill with cancellation, budgets, and bounding-box tracking; commits per-slice masks to a labelmap volume with dense-fill and per-voxel write paths; adds idempotent per-slice image loading; adds corresponding tests.
Island removal post-processing
packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts, packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js
Adds IslandRemoval class classifying voxel topology to remove external/internal islands from segmentation results, with tests.
Adaptive grow-cut intensity probing
.../growCut/intensityRange/adaptiveRegionIntensityRange.ts, .../growCut/getViewportVoiMappingForVolume.ts, .../growCut/neighborhoodStats.ts, .../growCut/floodFillIntensityRangeTypes.ts, packages/tools/test/utilities/segmentation/adaptiveRegionIntensityRange.jest.js
Adds adaptive band probing using neighborhood stats, MSER-style threshold selection, deterministic expand/shrink context, VOI mapping lookup, and public probe types; adds tests covering region detection, polarity, VOI mapping, and rejection cases.
Flood-fill segmentation orchestration
packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts
Adds runFloodFillSegmentation combining intensity range resolution, flood fill, labelmap commit, and island removal into one entry point.
ClickSegmentTool and exports
packages/tools/src/tools/annotation/ClickSegmentTool.ts, .../regionSegmentHoverCursors.ts, packages/tools/src/tools/index.ts, packages/tools/src/index.ts
Adds ClickSegmentTool with hover probing, confinement dry-run, click-to-segment fill, expand/shrink/refresh stepping, and cursor assets; wires exports through package indexes.
Click segment example
packages/tools/examples/clickSegment/index.ts, utils/ExampleRunner/example-info.json
Adds a PET demo using ClickSegmentTool with toolbar controls, keyboard shortcuts, and stack status display; registers the example in the runner metadata.

Estimated code review effort: 5 (Critical) | ~120 minutes

Demo Helper Utilities

Layer / File(s) Summary
Volume ID validation and sorting
utils/demo/helpers/validateAndSortVolumeIds.js, utils/demo/helpers/index.js
Adds a validator/sorter that checks frame-of-reference, orientation, position consistency, and spacing uniformity across slices, exported via the helpers index.
Dropdown toolbar async options
utils/demo/helpers/addDropdownToToolbar.ts
Refactors dropdown option rendering to preserve placeholders and support Promise-based option loading with disabled/loading/error states.

Estimated code review effort: 2 (Simple) | ~12 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ClickSegmentTool
  participant probeAdaptiveRegion
  participant runFloodFillSegmentation
  participant IslandRemoval
  participant Labelmap

  User->>ClickSegmentTool: hover over pixel
  ClickSegmentTool->>probeAdaptiveRegion: dry-run confinement probe
  probeAdaptiveRegion-->>ClickSegmentTool: viability, band, context
  ClickSegmentTool-->>User: cursor verdict (plus/blocked/pending)

  User->>ClickSegmentTool: click to segment
  ClickSegmentTool->>probeAdaptiveRegion: resolve intensity band
  probeAdaptiveRegion-->>ClickSegmentTool: band, expandContext
  ClickSegmentTool->>runFloodFillSegmentation: fillWithRange(seed, band)
  runFloodFillSegmentation->>IslandRemoval: clean up islands
  IslandRemoval-->>runFloodFillSegmentation: modified slices
  runFloodFillSegmentation->>Labelmap: commit voxel masks
  Labelmap-->>ClickSegmentTool: segmentation modified event
Loading

Suggested reviewers: wayfarer3130

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise, semantic-release compliant, and accurately summarizes the main change: adding ClickSegmentTool.
Description check ✅ Passed The description follows the template closely with context, changes, testing, and checklist items filled in.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/one-click-segment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

sedghi and others added 2 commits July 2, 2026 18:40
Self-contained one-click segmentation tool: hover previews segmentability
(plus/blocked/evaluating cursor), a single click derives a one-sided
intensity threshold from the click and floods it in 3D, and expand/shrink
step the result along its measured growth curve.

Brings only OneClickSegment and its necessities on top of main:
- New flood-fill engine (runFloodFillSegmentation, floodFillSliceLazy,
  commitSliceMasksToLabelmap, createEnsureSliceLoadedForVolume) and the
  config-less adaptive-region strategy (adaptiveRegionIntensityRange).
- A standalone floodFillIslandRemoval + local neighborhoodStats so main's
  shared islandRemoval/GrowCutBaseTool/calculateNeighborhoodStats are left
  untouched (OneClickSegment adapts to main's async GrowCutBaseTool).
- core: viewportVoiIntensityMapping + growCutLog; additive utility exports.
- Example + unit tests; additive demo-helper support (async dropdown
  options, validateAndSortVolumeIds).

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
@sedghi
sedghi force-pushed the feat/one-click-segment branch from ede7b8b to 857c5f6 Compare July 2, 2026 22:42

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

🧹 Nitpick comments (4)
utils/demo/helpers/addDropdownToToolbar.ts (1)

170-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse applyDropdownOptions in the error path instead of duplicating clear logic.

The .catch() handler (Lines 174-184) manually clears and repopulates the <select> instead of going through applyDropdownOptions, so it doesn't get placeholder preservation, data-placeholder/data-loading bookkeeping, or map reset consistency that the success path gets. It also leaves currentMap pointing to whatever it was before (likely undefined), which is fine here but is incidental rather than explicit.

♻️ Suggested consolidation
       .catch((error) => {
         console.error('addDropdownToToolbar: failed to resolve options', error);
-        const elErrorOption = document.createElement('option');
-        elErrorOption.value = '';
-        elErrorOption.innerText = 'Failed to load';
-        elErrorOption.selected = true;
-        while (elSelect.options.length > 0) {
-          elSelect.remove(0);
-        }
-        elSelect.append(elErrorOption);
+        currentMap = undefined;
+        applyDropdownOptions(elSelect, {
+          values: [''],
+          labels: ['Failed to load'],
+        } as optionTypeDefaultValue & optionTypeValues);
       })

Also consider marking the error option disabled so it can't be mistaken for a real selectable choice, though this is low-impact since it's the sole option present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@utils/demo/helpers/addDropdownToToolbar.ts` around lines 170 - 193, Reuse
applyDropdownOptions in the addDropdownToToolbar error path instead of manually
clearing and repopulating the select. In the .catch() block, build the
failed-state options and pass them through applyDropdownOptions so placeholder
handling, data-placeholder/data-loading cleanup, and currentMap reset stay
consistent with the success path. If you keep the fallback “Failed to load”
option, make it disabled as well so it can’t be selected as a real value.
packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts (1)

252-259: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use queue.length instead of rescanning full slice masks.

Each setVisited is paired with a queue.push, including the seed, so queue.length is already the exact filled count. This avoids scanning every allocated full-frame mask after sparse fills or hover dry-runs.

♻️ Proposed simplification
-  let voxelCount = 0;
-  for (const arr of sliceMasks.values()) {
-    for (let i = 0; i < arr.length; i++) {
-      if (arr[i] & FLOOD_SLICE_FLAG_VISITED) {
-        voxelCount++;
-      }
-    }
-  }
+  const voxelCount = queue.length;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts` around lines
252 - 259, The voxel count in the flood-fill logic is being recomputed by
rescanning all slice masks, even though each `setVisited` already corresponds to
a `queue.push`. Update the counting logic in `floodFillSliceLazy` to use
`queue.length` as the filled voxel count instead of iterating over
`sliceMasks.values()`, and keep the seed inclusion consistent with the existing
queue behavior. Make sure the final count returned from this path reflects the
queue size directly so sparse fills and hover dry-runs avoid the extra
full-frame scan.
packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js (1)

10-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for lazy slice loading.

The current cases do not exercise ensureSliceLoaded, so the streaming-slice contract could regress without test coverage.

🧪 Suggested test case
+  it('loads slices before reading from them', async () => {
+    const loaded = new Set();
+    const result = await floodFill3dSliceLazy(
+      (_x, _y, z) => (loaded.has(z) ? 1 : undefined),
+      [20, 20, 1],
+      {
+        width: SIZE,
+        height: SIZE,
+        depth: 3,
+        equals,
+        yieldEvery: 0,
+        maxDeltaIJ: 0,
+        ensureSliceLoaded: async (z) => {
+          loaded.add(z);
+        },
+      }
+    );
+
+    expect(result.truncated).toBe(false);
+    expect(result.voxelCount).toBe(3);
+    expect([...loaded].sort((a, b) => a - b)).toEqual([0, 1, 2]);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js` around
lines 10 - 83, Add a regression test around floodFill3dSliceLazy that
specifically verifies lazy slice loading via ensureSliceLoaded. The current
budget/shape gate cases only assert fill behavior and truncation, so add a
scenario that starts with unloaded slices and confirms the algorithm requests
them through the getter/loader path during traversal. Use the existing
floodFill3dSliceLazy test setup and unique symbols like ensureSliceLoaded,
getter, and floodFill3dSliceLazy to place the new assertion where the
streaming-slice contract can be validated.
packages/tools/examples/oneClickSegment/index.ts (1)

168-170: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Avoid the innerHTML sink in the legend.

The values are hard-coded today, but this still trips the unsafe-HTML rule. Build the SVG/text nodes with DOM APIs so future label changes cannot turn this into an injection sink.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/examples/oneClickSegment/index.ts` around lines 168 - 170, The
legend rendering in the oneClickSegment example uses the unsafe innerHTML sink
on item, which should be replaced with DOM construction even though the current
values are hard-coded. Update the legend-building logic around the item
assignment to create the SVG and any text content with DOM APIs, appending nodes
directly instead of concatenating HTML, so future label changes cannot become an
injection sink.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/tools/src/tools/annotation/OneClickSegmentTool.ts`:
- Around line 1123-1165: The refill path in refillAtTolerance is not
transactional: it clears the previously accepted voxels from
lastClick.filledPoints before fillWithRange succeeds, so a later failure can
leave the labelmap empty. Update the OneClickSegmentTool refill flow to either
stage the new fill and commit it only after success, or restore the prior
segment state from lastClick.filledPoints in the catch path before rethrowing.
Make sure the rollback/commit logic is kept near the existing segmentIndex
clearing loop and the fillWithRange call, and that lastClick.filledPoints only
gets replaced once the refill has succeeded.
- Around line 917-933: The fill path in OneClickSegmentTool’s `fillWithRange`
can still run after the cached referenced volume has been evicted, so
`indexToWorld` and `computeVoxelBudget` may receive `undefined`. Add a guard
immediately after `cache.getVolume(referencedVolumeId)` and before building the
flood-fill arguments, and bail out or abort if the volume is missing. Keep the
check near the existing `runFloodFillSegmentation` call so all later
expand/refresh paths are protected.
- Around line 386-391: The shape gate in OneClickSegmentTool’s k-bounds check is
treating negative maxDeltaK values like a real limit, which causes all regions
to be rejected. Update the predicate in the function that returns the
bbox/voxelCount filter so it only applies the k-extent comparison when maxDeltaK
is non-negative, matching the behavior used by floodFill3dSliceLazy and
preserving -1 as unbounded.

In `@packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts`:
- Around line 41-44: Validate the labelmap dimensions at the start of
commitSliceMasksToLabelmap before any voxel writes. Compare the caller-provided
width/height-derived frameSize against labelmapVolume.dimensions and ensure the
computed expectedLen matches the volume’s linear size; if they diverge,
throw/fail fast before entering the dense or fallback write paths. Keep the
check near the existing depth/frameSize/expectedLen setup so
commitSliceMasksToLabelmap and its voxelManager offset writes cannot proceed
with mismatched dimensions.

In `@packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts`:
- Around line 335-344: The external-island clearing logic in
floodFillIslandRemoval.ts is checking sourceVoxelManager instead of the current
preview state, so newly painted preview voxels over background are skipped.
Update the callback inside the island-removal pass to read the value from
previewVoxelManager before clearing, and only use sourceVoxelManager as a
secondary guard to avoid counting inherited/source voxels as cleared. Keep the
fix localized to the callback that iterates rle ranges and updates
clearedVoxels.

In
`@packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts`:
- Line 3: The import in adaptiveRegionIntensityRange is using a non-existent
NumberVoxelManager export, which breaks compilation. Update the type import to
use the generic VoxelManager<T> from '`@cornerstonejs/core/utilities`' and adjust
the related type annotations in this module so the existing getAtIndex/getAtIJK
calls continue to type-check against VoxelManager. Use the
adaptiveRegionIntensityRange function and any helper types in this file to
locate and replace the incorrect NumberVoxelManager reference.

In `@packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts`:
- Line 2: The neighborhoodStats module has the same broken NumberVoxelManager
import and will not compile independently. Update the import in
neighborhoodStats.ts to use the correct export/source that matches the rest of
the growCut utilities, and verify the local references in neighborhoodStats stay
consistent after the change. Use the neighborhoodStats module name and the
NumberVoxelManager symbol to locate and fix the import.

In
`@packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts`:
- Line 758: The onCommitted callback in runFloodFillSegmentation currently
reports the pre-cleanup floodedPoints, but island cleanup can change the final
painted voxel set afterward. Update the callback to emit the final
committed/painted point set produced after island removal and any promotion
logic, using the existing runFloodFillSegmentation flow so OneClickSegmentTool
stores the correct points for later shrink/expand cleanup.
- Around line 771-788: The cancellation path in runFloodFillSegmentation
currently promotes and returns a partial labelmap result, which should not
happen. In the runFloodFillSegmentation flow, check options.isCancelled()
immediately after the flood-fill work and before any call to
commitSliceMasksToLabelmapVolume or promotePreviewSegmentToFinal, then return
null without mutating labelmap. Keep the cancellation handling localized to the
flood-fill/commit logic so a user-triggered cancel never persists partial
segmentation.

In `@utils/demo/helpers/addDropdownToToolbar.ts`:
- Around line 34-40: Guard the dropdown option lookup in addDropdownToToolbar so
explicit values that are not present in map do not crash when selecting the
default. In addDropdownToToolbar, before using map.get(value) and setting
selected, verify the entry exists or skip/continue when it is undefined; apply
the same defensive check in the default-selection path that uses
defaultValue/defaultIndex. This keeps the toolbar setup safe even when
optionTypeValues diverges from map keys.

In `@utils/demo/helpers/validateAndSortVolumeIds.js`:
- Around line 55-59: The validation in validateAndSortVolumeIds is only checking
array shape, which lets invalid numeric IPP/IOP metadata reach projectOntoNormal
and be treated as valid. Tighten the checks in validateAndSortVolumeIds and the
related projection path so firstPlane.imagePositionPatient must contain exactly
3 finite numbers and firstIop must contain exactly 6 finite numbers before any
sorting/projection logic runs. Apply the same guard in the later validation
block referenced by the review so NaN-based results cannot pass through as
valid.
- Around line 41-44: The validateAndSortVolumeIds flow is passing the
caller-owned imageIds array directly into sortImageIdsAndGetSpacing, which can
mutate it for wadouri inputs. Update the validateAndSortVolumeIds logic to pass
a copy of imageIds into sortImageIdsAndGetSpacing while keeping sortedImageIds
assignment unchanged, so the caller’s original order is preserved.

In `@utils/ExampleRunner/example-info.json`:
- Line 368: The example description in the example-info registry does not match
the actual PET-only workflow. Update the description entry for the PET lesion
segmentation example so it refers to PET only, and keep the wording aligned with
the example’s single-series loading behavior in the same registry entry.

---

Nitpick comments:
In `@packages/tools/examples/oneClickSegment/index.ts`:
- Around line 168-170: The legend rendering in the oneClickSegment example uses
the unsafe innerHTML sink on item, which should be replaced with DOM
construction even though the current values are hard-coded. Update the
legend-building logic around the item assignment to create the SVG and any text
content with DOM APIs, appending nodes directly instead of concatenating HTML,
so future label changes cannot become an injection sink.

In `@packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts`:
- Around line 252-259: The voxel count in the flood-fill logic is being
recomputed by rescanning all slice masks, even though each `setVisited` already
corresponds to a `queue.push`. Update the counting logic in `floodFillSliceLazy`
to use `queue.length` as the filled voxel count instead of iterating over
`sliceMasks.values()`, and keep the seed inclusion consistent with the existing
queue behavior. Make sure the final count returned from this path reflects the
queue size directly so sparse fills and hover dry-runs avoid the extra
full-frame scan.

In `@packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js`:
- Around line 10-83: Add a regression test around floodFill3dSliceLazy that
specifically verifies lazy slice loading via ensureSliceLoaded. The current
budget/shape gate cases only assert fill behavior and truncation, so add a
scenario that starts with unloaded slices and confirms the algorithm requests
them through the getter/loader path during traversal. Use the existing
floodFill3dSliceLazy test setup and unique symbols like ensureSliceLoaded,
getter, and floodFill3dSliceLazy to place the new assertion where the
streaming-slice contract can be validated.

In `@utils/demo/helpers/addDropdownToToolbar.ts`:
- Around line 170-193: Reuse applyDropdownOptions in the addDropdownToToolbar
error path instead of manually clearing and repopulating the select. In the
.catch() block, build the failed-state options and pass them through
applyDropdownOptions so placeholder handling, data-placeholder/data-loading
cleanup, and currentMap reset stay consistent with the success path. If you keep
the fallback “Failed to load” option, make it disabled as well so it can’t be
selected as a real value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7370a71a-c646-4e16-a162-62ec5ab4e983

📥 Commits

Reviewing files that changed from the base of the PR and between 357796f and ede7b8b.

📒 Files selected for processing (23)
  • packages/core/src/utilities/index.ts
  • packages/core/src/utilities/logger.ts
  • packages/core/src/utilities/viewportVoiIntensityMapping.ts
  • packages/tools/examples/oneClickSegment/index.ts
  • packages/tools/src/index.ts
  • packages/tools/src/tools/annotation/OneClickSegmentTool.ts
  • packages/tools/src/tools/annotation/regionSegmentHoverCursors.ts
  • packages/tools/src/tools/index.ts
  • packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts
  • packages/tools/src/utilities/segmentation/createEnsureSliceLoadedForVolume.ts
  • packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
  • packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts
  • packages/tools/src/utilities/segmentation/growCut/floodFillIntensityRangeTypes.ts
  • packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts
  • packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts
  • packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts
  • packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts
  • packages/tools/test/utilities/segmentation/adaptiveRegionIntensityRange.jest.js
  • packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js
  • utils/ExampleRunner/example-info.json
  • utils/demo/helpers/addDropdownToToolbar.ts
  • utils/demo/helpers/index.js
  • utils/demo/helpers/validateAndSortVolumeIds.js

Comment thread packages/tools/src/tools/annotation/ClickSegmentTool.ts
Comment thread packages/tools/src/tools/annotation/ClickSegmentTool.ts
Comment thread packages/tools/src/tools/annotation/OneClickSegmentTool.ts Outdated
Comment on lines +335 to +344
const callback = (index, rle) => {
const [, jPrime, kPrime] = segmentSet.toIJK(index);
if (rle.value !== SegmentationEnum.ISLAND) {
for (let iPrime = rle.start; iPrime < rle.end; iPrime++) {
const clearPoint = toIJK([iPrime, jPrime, kPrime]);
const sourceVal = sourceVoxelManager.getAtIJKPoint(clearPoint);
if (sourceVal === this.previewSegmentIndex) {
previewVoxelManager.setAtIJKPoint(clearPoint, null);
clearedVoxels += 1;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Check the preview value before clearing external islands.

Line 340 reads from sourceVoxelManager, so newly added preview voxels over background are never cleared. Use the current preview value, and keep the source check only to avoid counting inherited/source voxels as cleared.

🐛 Proposed fix
           const clearPoint = toIJK([iPrime, jPrime, kPrime]);
+          const previewVal = previewVoxelManager.getAtIJKPoint(clearPoint);
           const sourceVal = sourceVoxelManager.getAtIJKPoint(clearPoint);
-          if (sourceVal === this.previewSegmentIndex) {
+          if (
+            previewVal === this.previewSegmentIndex &&
+            sourceVal !== this.previewSegmentIndex
+          ) {
             previewVoxelManager.setAtIJKPoint(clearPoint, null);
             clearedVoxels += 1;
           }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const callback = (index, rle) => {
const [, jPrime, kPrime] = segmentSet.toIJK(index);
if (rle.value !== SegmentationEnum.ISLAND) {
for (let iPrime = rle.start; iPrime < rle.end; iPrime++) {
const clearPoint = toIJK([iPrime, jPrime, kPrime]);
const sourceVal = sourceVoxelManager.getAtIJKPoint(clearPoint);
if (sourceVal === this.previewSegmentIndex) {
previewVoxelManager.setAtIJKPoint(clearPoint, null);
clearedVoxels += 1;
}
const callback = (index, rle) => {
const [, jPrime, kPrime] = segmentSet.toIJK(index);
if (rle.value !== SegmentationEnum.ISLAND) {
for (let iPrime = rle.start; iPrime < rle.end; iPrime++) {
const clearPoint = toIJK([iPrime, jPrime, kPrime]);
const previewVal = previewVoxelManager.getAtIJKPoint(clearPoint);
const sourceVal = sourceVoxelManager.getAtIJKPoint(clearPoint);
if (
previewVal === this.previewSegmentIndex &&
sourceVal !== this.previewSegmentIndex
) {
previewVoxelManager.setAtIJKPoint(clearPoint, null);
clearedVoxels += 1;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts` around
lines 335 - 344, The external-island clearing logic in floodFillIslandRemoval.ts
is checking sourceVoxelManager instead of the current preview state, so newly
painted preview voxels over background are skipped. Update the callback inside
the island-removal pass to read the value from previewVoxelManager before
clearing, and only use sourceVoxelManager as a secondary guard to avoid counting
inherited/source voxels as cleared. Keep the fix localized to the callback that
iterates rle ranges and updates clearedVoxels.

Comment thread packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts Outdated
Comment thread utils/demo/helpers/addDropdownToToolbar.ts
Comment thread utils/demo/helpers/validateAndSortVolumeIds.js Outdated
Comment thread utils/demo/helpers/validateAndSortVolumeIds.js
Comment thread utils/ExampleRunner/example-info.json Outdated
sedghi added 2 commits July 2, 2026 20:39
A click and each expand/shrink step now push one LabelmapMemo onto the
segmentation history stack: runFloodFillSegmentation gained a
historyVoxelManager option so the flood commit, preview promotion and
island removal all write through the memo's RLE history layer, and
expand/shrink records its clear+refill as a single undoable step. The
example gets Undo/Redo buttons plus Ctrl/Cmd+Z, Shift+Z and Ctrl+Y.

Also folds in review feedback on these code paths: guard against an
evicted referenced volume before filling, restore the cleared voxels in
place when a refill fails (no more empty labelmap on a blocked expand),
never commit a cancelled fill, report the post-island-removal point set
to onCommitted, validate labelmap vs mask dimensions before writing
linear offsets, honor negative maxDeltaK as unbounded in the shape
gate, and use VoxelManager<number> instead of the non-exported
NumberVoxelManager core alias.
- floodFillSliceLazy: derive voxelCount from the BFS queue length
  instead of rescanning every allocated slice mask; add a regression
  test for lazy slice loading via ensureSliceLoaded
- adaptiveRegionIntensityRange / neighborhoodStats: import the exported
  VoxelManager<number> type instead of the non-existent
  NumberVoxelManager core export (fixes the CI type error)
- floodFillIslandRemoval: document why external island clearing reads
  the write-through source and clears with null (the suggested
  preview-value check would break the history-layer wiring)
- addDropdownToToolbar: guard map lookups against missing entries and
  reuse applyDropdownOptions in the async error path
- validateAndSortVolumeIds: pass a copy so the sorter cannot mutate the
  caller's imageIds; require finite IPP/IOP values before projection
- example-info: the oneClickSegment example is PET-only

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts`:
- Around line 788-816: `committedPoints` is only refreshed after island removal
in the `usePreview` path, so when preview is disabled it still reports the
pre-cleanup `floodedPoints`. Update `runFloodFillSegmentation` so the final
points passed to `onCommitted` always reflect the post-cleanup painted voxels,
regardless of `usePreview`, by recomputing or assigning `committedPoints` after
`removeInternalIslands()` / `removeExternalIslands()` in the main flood-fill
flow. Keep the fix in the `runFloodFillSegmentation` callback path that
`OneClickSegmentTool` consumes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0c54a247-f44d-419d-a717-9656b5c5b3b7

📥 Commits

Reviewing files that changed from the base of the PR and between 857c5f6 and 517c313.

📒 Files selected for processing (13)
  • packages/tools/examples/oneClickSegment/index.ts
  • packages/tools/src/tools/annotation/OneClickSegmentTool.ts
  • packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts
  • packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
  • packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts
  • packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts
  • packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts
  • packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts
  • packages/tools/test/utilities/segmentation/commitSliceMasksToLabelmap.jest.js
  • packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js
  • utils/ExampleRunner/example-info.json
  • utils/demo/helpers/addDropdownToToolbar.ts
  • utils/demo/helpers/validateAndSortVolumeIds.js
🚧 Files skipped from review as they are similar to previous changes (10)
  • utils/ExampleRunner/example-info.json
  • packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts
  • packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
  • packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts
  • utils/demo/helpers/validateAndSortVolumeIds.js
  • packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts
  • packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts
  • packages/tools/examples/oneClickSegment/index.ts
  • utils/demo/helpers/addDropdownToToolbar.ts
  • packages/tools/src/tools/annotation/OneClickSegmentTool.ts

…t mode

The previous fix only refreshed committedPoints in the preview path.
Track the exact points removeInternalIslands paints (a value-scan of the
modified slices would over-collect same-segment voxels from earlier
operations) and concat them into the reported set in both preview and
direct-paint modes; the preview promotion now reuses that same set,
replacing the collectPreviewPointsOnSlices slice scan.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js (1)

55-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Test-created DOM element is never removed.

createViewport appends an element to document.body but nothing removes it, even in afterEach. Harmless for a single test but will accumulate stray nodes if more tests are added to this suite later.

♻️ Suggested cleanup
   afterEach(() => {
     cache.purgeCache();
     renderingEngine?.destroy();
+    viewport?.element?.remove();
   });

Also applies to: 107-125

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js`
around lines 55 - 58, The test setup in createViewport leaves behind the DOM
element it appends to document.body, so the suite should explicitly clean it up
in afterEach alongside cache.purgeCache() and renderingEngine?.destroy(). Update
the test helper(s) used in floodFillIslandRemoval.jest.js to retain a reference
to the created element and remove it from the DOM during teardown, including the
other affected createViewport usages in the suite.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js`:
- Around line 55-58: The test setup in createViewport leaves behind the DOM
element it appends to document.body, so the suite should explicitly clean it up
in afterEach alongside cache.purgeCache() and renderingEngine?.destroy(). Update
the test helper(s) used in floodFillIslandRemoval.jest.js to retain a reference
to the created element and remove it from the DOM during teardown, including the
other affected createViewport usages in the suite.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ade3d66-edc7-4b00-acac-600a5ea5452a

📥 Commits

Reviewing files that changed from the base of the PR and between 517c313 and 7d384b7.

📒 Files selected for processing (3)
  • packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
  • packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts
  • packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
  • packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts

@sedghi sedghi changed the title feat/one click segment one click pt segment Jul 3, 2026
@sedghi sedghi changed the title one click pt segment feat(tools): one-click PET lesion segmentation tool (OneClickSegmentTool) Jul 3, 2026
@sedghi sedghi changed the title feat(tools): one-click PET lesion segmentation tool (OneClickSegmentTool) feat: one click pt segment Jul 3, 2026
@sedghi sedghi changed the title feat: one click pt segment feat: add ClickSegmentTool for click-to-segment lesion segmentation Jul 7, 2026
sedghi and others added 3 commits July 7, 2026 13:50
The 'one-click' name overpromised — the tool only segments coherent,
contrast-distinct, lesion-scale regions, not arbitrary clicks. Rename the
class, toolName, example, and docs to ClickSegmentTool / clickSegment.
Shared growcut utilities (runOneClickGrowCut) are unrelated and untouched.

@wayfarer3130 wayfarer3130 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've looked over it generally and am happy with it, but did find a few things from clod:
Code correctness & risks
growCutLog.setLevel('info') in core logger.ts is a global side effect at import time. [logger.ts]. This forces info-level logging for the growCut logger regardless of the host app's configuration, and runFloodFillSegmentation emits many log.info calls per click/expand plus ungated console.time/console.timeEnd/console.warn/console.info. In a library this is real console/perf noise in production. Recommend defaulting to warn (or leaving level to the consumer) and gating the console.time* timing labels behind the existing ENABLE_VERBOSE_FLOOD_FILL_LOGS flag.

floodFillIslandRemoval.ts (519 lines) is a fork of the existing islandRemoval.ts (383 lines). Same SegmentationEnum, same IslandRemoval class and algorithm, with added features (getInternalFilledPoints, usingPreviewLayer, no-preview warning, verbose logging). Two near-identical island-removal implementations in the same directory is a maintenance hazard — divergent bug fixes are almost guaranteed. Strongly prefer extending the existing class (or adding the new hooks to it) over forking. The base tool still imports the old one (../../utilities/segmentation/islandRemoval), so both will live on indefinitely.

Oblique-view path throws from preMouseDownCallback. super.preMouseDownCallback throws 'Oblique view is not supported yet' and this override doesn't wrap it, producing a rejected promise on click. Hover already blocks oblique via _isOrthogonalView → BLOCKED_CURSOR, so in practice the click is gated, but a click without a fresh probe (e.g. probe stale/expired) could still reach this throw. Consider catching it and calling notifySegmentationError for a clean UX.

validateAndSortVolumeIds is added and exported but unused by the new example, which sorts with csUtils.sortImageIdsAndGetSpacing directly. Either wire it into the example (it's the more robust validator) or drop it from this PR — as-is it's dead weight with no exercising caller.

Performance
Hover is heavier than it looks. Each throttled hover flush runs probeAdaptiveRegion (a ~193×193 window sweep + bucket-queue flood) once for the pointer plus up to 4 more times in neighborsAgree — ~5 full window analyses synchronously on the main thread per flush, before the async 3D dry-run. On large/fine-spaced volumes this is a lot of redundant per-hover compute. Consider caching the center probe result and/or sampling neighbors more cheaply (the neighbor votes don't need the full growth-curve sweep).

sedghi added 3 commits July 10, 2026 13:07
…ver cost

- Leave growCutLog level to the consumer (no import-time setLevel) and gate
  the flood-fill console.time/timeEnd diagnostics behind the existing
  ENABLE_VERBOSE_FLOOD_FILL_LOGS flag; bare console.warn calls now go
  through the logger.
- Rework floodFillIslandRemoval as a subclass of the existing IslandRemoval
  instead of a fork: the base algorithm lives in one place, with two small
  hooks (protected fill options and an onInternalPointFilled callback) so
  the subclass only carries the delta-safe external removal, painted-point
  tracking, and verbose logging.
- Catch the unsupported-view throw from the base preMouseDownCallback so an
  oblique click surfaces a segmentation error instead of a rejected promise.
- Wire validateAndSortVolumeIds into the clickSegment example so the helper
  has an exercising caller and the series is validated as a clean volume.
- Record the click's 4 in-plane neighbor join levels during the adaptive
  probe's threshold sweep and read the hover consensus votes off that,
  so each hover flush runs one window analysis instead of five.
@sedghi

sedghi commented Jul 10, 2026

Copy link
Copy Markdown
Member Author

@wayfarer3130 Thanks for the careful pass — all five points were valid and are addressed in the latest push (also merged latest main, no conflicts):

  • growCutLog level / console noise: removed the import-time setLevel('info') — the level is now left to the consumer like the other cs3d loggers (a comment on growCutLog documents how to opt in). The console.time/timeEnd timing labels in runFloodFillSegmentation are gated behind the existing ENABLE_VERBOSE_FLOOD_FILL_LOGS flag, and the bare console.warn calls now go through the logger, so the default production experience is silent.

  • floodFillIslandRemoval fork: reworked as a subclass of the existing IslandRemoval instead of a copy. The base class gained two minimal hooks (fillInternalEdge/maxInternalRemove became protected, and removeInternalIslands calls an overridable no-op onInternalPointFilled(point) for each hole voxel it paints). The subclass now only carries its real deltas: the delta-safe removeExternalIslands (preview-layer guard + source-value check), getInternalFilledPoints(), and the verbose logging. Net effect: the algorithm lives in one place again (~520 lines down to ~200, all of them additive behavior).

  • Oblique click throw: ClickSegmentTool.preMouseDownCallback now wraps the base setup in try/catch; a click that reaches the oblique guard (e.g. with a stale/expired hover probe) surfaces through notifySegmentationError and returns false instead of producing a rejected promise.

  • validateAndSortVolumeIds: wired into the clickSegment example as suggested — it replaces the direct sortImageIdsAndGetSpacing call and warns with the specific reason when the series is not a clean volume.

  • Hover cost: fixed at the source rather than by caching. The adaptive probe's bucket-queue sweep already visits every window pixel, so it now records the tolerance level at which each of the click's 4 in-plane neighbors joins the region (clickNeighborJoinLevels on the probe result). neighborsAgree reads its votes off that with the same consensus semantics (2-of-4, edge leniency), so each hover flush runs one window analysis instead of five; the remaining per-hover cost is the single probe plus the budgeted async 3D dry-run.

All segmentation utility jest suites pass against the refactor (including the internal-fill tracking test now running through the subclass + base hook path).

@sedghi
sedghi requested a review from wayfarer3130 July 10, 2026 18:03

@wayfarer3130 wayfarer3130 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the fixes.

@sedghi
sedghi merged commit 30f3b60 into main Jul 10, 2026
17 checks passed
@sedghi
sedghi deleted the feat/one-click-segment branch July 10, 2026 18:37
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