diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts
index eb851568ef..3ee7e1af25 100644
--- a/packages/core/src/utilities/index.ts
+++ b/packages/core/src/utilities/index.ts
@@ -115,6 +115,12 @@ import getVolumeViewReferenceId from './getVolumeViewReferenceId';
import calculateSpacingBetweenImageIds from './calculateSpacingBetweenImageIds';
export * as logger from './logger';
import { calculateNeighborhoodStats } from './calculateNeighborhoodStats';
+import {
+ mapScalarToViewportVoiIntensity,
+ mapViewportVoiIntensityToScalar,
+ mapMappedBandToRawRange,
+} from './viewportVoiIntensityMapping';
+export type { ViewportVoiMappingProps } from './viewportVoiIntensityMapping';
export * from './getPixelSpacingInformation';
export * from './getPlaneCubeIntersectionDimensions';
export * from './rotateToViewCoordinates';
@@ -253,6 +259,9 @@ export {
getImageDataMetadata,
buildMetadata,
calculateNeighborhoodStats,
+ mapScalarToViewportVoiIntensity,
+ mapViewportVoiIntensityToScalar,
+ mapMappedBandToRawRange,
asArray,
viewportSupportsImageSlices,
viewportSupportsStackCalibration,
diff --git a/packages/core/src/utilities/logger.ts b/packages/core/src/utilities/logger.ts
index 33e1e2302d..3e766de0c5 100644
--- a/packages/core/src/utilities/logger.ts
+++ b/packages/core/src/utilities/logger.ts
@@ -18,3 +18,10 @@ export const {
imageConsistencyLog,
} = logging;
export type Logger = logging.Logger;
+
+/**
+ * One-click flood fill segmentation and island-removal diagnostics. Level is
+ * left to the consumer (like the other cs3d loggers); call
+ * `growCutLog.setLevel('info')` in the host app to see the diagnostics.
+ */
+export const growCutLog = toolsLog.getLogger('growCut');
diff --git a/packages/core/src/utilities/viewportVoiIntensityMapping.ts b/packages/core/src/utilities/viewportVoiIntensityMapping.ts
new file mode 100644
index 0000000000..f5651329c8
--- /dev/null
+++ b/packages/core/src/utilities/viewportVoiIntensityMapping.ts
@@ -0,0 +1,104 @@
+import VOILUTFunctionType from '../enums/VOILUTFunctionType';
+import { logit } from './logit';
+import * as windowLevelUtil from './windowLevel';
+
+const Y_EPS = 1e-6;
+
+export type ViewportVoiMappingProps = {
+ voiRange: { lower: number; upper: number };
+ VOILUTFunction?: string | VOILUTFunctionType;
+ /**
+ * When true the viewport renders the VOI inverted (e.g. PET AC), so the
+ * displayed intensity is `1 − mapped`. Both the forward and inverse maps must
+ * honor this or sampled display luma will inverse-map to the wrong raw end.
+ */
+ invert?: boolean;
+};
+
+/**
+ * Maps a stored scalar to a normalized display intensity in [0, 1] using the same
+ * convention as VTK RGB transfer functions (linear) or DICOM sigmoid (sampled).
+ */
+export function mapScalarToViewportVoiIntensity(
+ value: number,
+ props: ViewportVoiMappingProps
+): number {
+ const { lower, upper } = props.voiRange;
+ const span = upper - lower;
+ const fn = props.VOILUTFunction as string | undefined;
+ const applyInvert = (y: number) => (props.invert === true ? 1 - y : y);
+
+ if (fn === VOILUTFunctionType.SAMPLED_SIGMOID || fn === 'SIGMOID') {
+ const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel(
+ lower,
+ upper
+ );
+ const w = Math.max(Math.abs(windowWidth), 1e-12);
+ return applyInvert(1 / (1 + Math.exp((-4 * (value - windowCenter)) / w)));
+ }
+
+ if (span === 0 || !Number.isFinite(span)) {
+ return applyInvert(0);
+ }
+ return applyInvert(clamp01((value - lower) / span));
+}
+
+/**
+ * Inverse map: normalized intensity Y in (0, 1) back to stored scalar.
+ * Endpoints Y=0 and Y=1 map to lower and upper for linear modes.
+ */
+export function mapViewportVoiIntensityToScalar(
+ mapped01: number,
+ props: ViewportVoiMappingProps
+): number {
+ const { lower, upper } = props.voiRange;
+ const fn = props.VOILUTFunction as string | undefined;
+ // Undo display inversion before mapping back to a stored scalar so the
+ // round-trip with mapScalarToViewportVoiIntensity is exact.
+ const y =
+ props.invert === true ? clamp01(1 - clamp01(mapped01)) : clamp01(mapped01);
+
+ if (fn === VOILUTFunctionType.SAMPLED_SIGMOID || fn === 'SIGMOID') {
+ const { windowCenter, windowWidth } = windowLevelUtil.toWindowLevel(
+ lower,
+ upper
+ );
+ const yy = clamp(y, Y_EPS, 1 - Y_EPS);
+ return logit(yy, windowCenter, windowWidth);
+ }
+
+ const span = upper - lower;
+ if (span === 0 || !Number.isFinite(span)) {
+ return lower;
+ }
+ return lower + y * span;
+}
+
+/**
+ * Converts a tolerance band in mapped [0,1] space to raw [rawMin, rawMax] (ordered).
+ */
+export function mapMappedBandToRawRange(
+ mappedMin: number,
+ mappedMax: number,
+ props: ViewportVoiMappingProps
+): { rawMin: number; rawMax: number } {
+ const a = Math.min(mappedMin, mappedMax);
+ const b = Math.max(mappedMin, mappedMax);
+ const rawAtA = mapViewportVoiIntensityToScalar(a, props);
+ const rawAtB = mapViewportVoiIntensityToScalar(b, props);
+ return {
+ rawMin: Math.min(rawAtA, rawAtB),
+ rawMax: Math.max(rawAtA, rawAtB),
+ };
+}
+
+function clamp01(x: number): number {
+ if (!Number.isFinite(x)) {
+ return 0;
+ }
+ return clamp(x, 0, 1);
+}
+
+function clamp(x: number, lo: number, hi: number): number {
+ return Math.min(hi, Math.max(lo, x));
+}
diff --git a/packages/tools/examples/clickSegment/index.ts b/packages/tools/examples/clickSegment/index.ts
new file mode 100644
index 0000000000..9230edbf79
--- /dev/null
+++ b/packages/tools/examples/clickSegment/index.ts
@@ -0,0 +1,378 @@
+import {
+ RenderingEngine,
+ Enums,
+ imageLoader,
+ cache,
+ metaData,
+ utilities as csUtils,
+} from '@cornerstonejs/core';
+import {
+ initDemo,
+ createImageIdsAndCacheMetaData,
+ setTitleAndDescription,
+ createInfoSection,
+ addButtonToToolbar,
+ addManipulationBindings,
+ validateAndSortVolumeIds,
+} from '../../../../utils/demo/helpers';
+import * as cornerstoneTools from '@cornerstonejs/tools';
+
+console.warn(
+ 'Click on index.ts to open source code for this example --------->'
+);
+
+const {
+ ClickSegmentTool,
+ WindowLevelTool,
+ segmentation,
+ ToolGroupManager,
+ Enums: csToolsEnums,
+} = cornerstoneTools;
+
+const { ViewportType, Events: csCoreEvents } = Enums;
+const { MouseBindings, KeyboardBindings } = csToolsEnums;
+const { DefaultHistoryMemo } = csUtils.HistoryMemo;
+
+const clickToolName = ClickSegmentTool.toolName;
+
+/**
+ * Primary mouse binding = click-to-segment lesions. `ClickSegmentTool`
+ * needs NO configuration: it derives a one-sided intensity threshold
+ * dynamically from the click (a hot lesion is segmented down from its core, so
+ * the brightest voxels are never left as holes) and only accepts clicks on a
+ * coherent, lesion-scale region.
+ */
+const clickToolMap = new Map([[clickToolName, { selected: true }]]);
+
+const WADO_RS_ROOT = 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb';
+
+/** Whole-body PET (FDG) — single series, no CT. */
+const STUDY_INSTANCE_UID =
+ '1.3.6.1.4.1.14519.5.2.1.7009.2403.871108593056125491804754960339';
+const SERIES_INSTANCE_UID =
+ '1.3.6.1.4.1.14519.5.2.1.7009.2403.780462962868572737240023906400';
+
+const renderingEngineId = 'clickSegmentRenderingEngine';
+const viewportId = 'ONE_CLICK_PT';
+const segmentationId = 'ONE_CLICK_SEGMENT_PT';
+const toolGroupId = 'ONE_CLICK_SEGMENT_TOOL_GROUP';
+
+let toolGroup;
+let viewport;
+let renderingEngine;
+
+setTitleAndDescription(
+ 'Click Segment Tool (PET lesion segmentation)',
+ 'Click-to-segment lesions on a whole-body PET series. Hover to scout candidates (the cursor previews whether a click will produce a meaningful, lesion-scale segment), click once to segment, then Shrink/Expand to fine-tune. Nothing to configure.'
+);
+
+const content = document.getElementById('content');
+
+const viewportGrid = document.createElement('div');
+viewportGrid.style.display = 'grid';
+viewportGrid.style.gridTemplateColumns = '500px';
+viewportGrid.style.gap = '10px';
+viewportGrid.style.gridTemplateRows = '500px auto';
+
+const element = document.createElement('div');
+element.oncontextmenu = (e) => e.preventDefault();
+element.style.width = '500px';
+element.style.height = '500px';
+
+const stackStatus = document.createElement('div');
+/** Plain object only — never assign from `element.style` (CSSStyleDeclaration breaks Object.assign). */
+Object.assign(stackStatus.style, {
+ fontSize: '12px',
+ fontFamily: 'ui-monospace, monospace',
+ color: '#333',
+ maxWidth: '500px',
+ lineHeight: '1.35',
+});
+
+viewportGrid.appendChild(element);
+viewportGrid.appendChild(stackStatus);
+content.appendChild(viewportGrid);
+
+function updateStackStatusLabel() {
+ if (!viewport) {
+ return;
+ }
+ const imageIds = viewport.getImageIds?.() ?? [];
+ if (!imageIds.length) {
+ stackStatus.textContent = 'No instances loaded';
+ return;
+ }
+ const idx = viewport.getCurrentImageIdIndex?.() ?? 0;
+ const imageId = imageIds[idx];
+ let positionExtra = '';
+ try {
+ const { imagePositionPatient } = metaData.get('imagePlaneModule', imageId);
+ if (
+ Array.isArray(imagePositionPatient) &&
+ imagePositionPatient.length >= 3
+ ) {
+ const [x, y, z] = imagePositionPatient.map((v) => Number(v).toFixed(1));
+ positionExtra = ` · IPP (${x}, ${y}, ${z}) mm`;
+ }
+ } catch {
+ // metadata may not be ready for every id
+ }
+ stackStatus.textContent = `Instance ${idx + 1} / ${imageIds.length}${positionExtra}`;
+}
+
+// prettier-ignore
+createInfoSection(content)
+ .addInstruction('Hover to scout: the cursor tells you what a click will do. A green plus = a lesion-scale region was confirmed here (one click segments it). A gray dashed circle = still evaluating (or nothing to segment yet). A red no-entry = not segmentable here (flat tissue, noise, or a sprawling non-lesion structure) — clicks there do nothing.')
+ .addInstruction('Primary click on a plus: segment the lesion in 3D. The threshold is one-sided and derived from the click, so the hottest core is always included (no interior holes).')
+ .addInstruction('Shrink / Expand: step the last segment along its measured growth curve — each press visibly shrinks or grows the region (the previous result is cleared and refilled, so both directions retrace exactly). Esc cancels a long-running fill.')
+ .addInstruction('Undo / Redo (buttons or Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y): every click and every expand/shrink step is one entry in the segmentation history.')
+ .addInstruction('Window/Level (Shift+drag) changes what you see and therefore what a click captures.')
+ .addInstruction('Middle mouse / Ctrl+drag: Pan · Right click: Zoom · Wheel / Alt+drag: Stack scroll');
+
+// ==[ Cursor legend ]=========================================================
+
+/**
+ * A small inline legend mirroring the three hover cursors, so the meaning of
+ * plus / evaluating / blocked is visible without hovering the image.
+ */
+function appendCursorLegend(container: HTMLElement) {
+ const legend = document.createElement('div');
+ Object.assign(legend.style, {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: '18px',
+ alignItems: 'center',
+ margin: '4px 0 8px',
+ fontSize: '13px',
+ fontFamily: 'ui-monospace, monospace',
+ });
+
+ const entries: Array<{ svg: string; label: string }> = [
+ {
+ label: 'segmentable (click to segment)',
+ svg: "",
+ },
+ {
+ label: 'evaluating',
+ svg: "",
+ },
+ {
+ label: 'not a lesion here',
+ svg: "",
+ },
+ ];
+
+ for (const { svg, label } of entries) {
+ const item = document.createElement('span');
+ Object.assign(item.style, {
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: '6px',
+ });
+ item.innerHTML =
+ `` +
+ `${label}`;
+ legend.appendChild(item);
+ }
+
+ container.appendChild(legend);
+}
+
+async function addSegmentationToState(imageIds: string[]) {
+ const segImages =
+ await imageLoader.createAndCacheDerivedLabelmapImages(imageIds);
+
+ segmentation.addSegmentations([
+ {
+ segmentationId,
+ representation: {
+ type: csToolsEnums.SegmentationRepresentations.Labelmap,
+ data: {
+ imageIds: segImages.map((it) => it.imageId),
+ },
+ },
+ },
+ ]);
+}
+
+async function run() {
+ await initDemo({});
+
+ cornerstoneTools.addTool(ClickSegmentTool);
+ cornerstoneTools.addTool(WindowLevelTool);
+
+ toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
+
+ addManipulationBindings(toolGroup, { toolMap: clickToolMap });
+
+ toolGroup.addTool(WindowLevelTool.toolName);
+ toolGroup.setToolActive(WindowLevelTool.toolName, {
+ bindings: [
+ {
+ mouseButton: MouseBindings.Primary,
+ modifierKey: KeyboardBindings.Shift,
+ },
+ {
+ numTouchPoints: 1,
+ modifierKey: KeyboardBindings.Shift,
+ },
+ ],
+ });
+
+ renderingEngine = new RenderingEngine(renderingEngineId);
+ renderingEngine.setViewports([
+ {
+ viewportId,
+ type: ViewportType.STACK,
+ element,
+ },
+ ]);
+ viewport = renderingEngine.getViewport(viewportId);
+ toolGroup.addViewport(viewportId, renderingEngineId);
+
+ // Esc cancels a long-running fill; Ctrl/Cmd+Z undoes the last click or
+ // expand/shrink step, Ctrl/Cmd+Shift+Z (or Ctrl+Y) redoes it.
+ document.addEventListener('keydown', (evt) => {
+ if (evt.key === 'Escape') {
+ const toolInstance = toolGroup?.getToolInstance?.(clickToolName) as {
+ cancelActiveOperation?: () => boolean;
+ } | null;
+ if (toolInstance?.cancelActiveOperation?.() === true) {
+ evt.preventDefault();
+ console.info('[clickSegment] cancel requested (Esc)');
+ }
+ return;
+ }
+ const isModifier = evt.ctrlKey || evt.metaKey;
+ if (isModifier && evt.key.toLowerCase() === 'z') {
+ evt.preventDefault();
+ if (evt.shiftKey) {
+ DefaultHistoryMemo.redo();
+ } else {
+ DefaultHistoryMemo.undo();
+ }
+ } else if (isModifier && evt.key.toLowerCase() === 'y') {
+ evt.preventDefault();
+ DefaultHistoryMemo.redo();
+ }
+ });
+
+ const toolbar = document.getElementById('demo-toolbar');
+ appendCursorLegend(toolbar);
+ const operationsToolbar = document.createElement('div');
+ Object.assign(operationsToolbar.style, {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: '12px',
+ alignItems: 'center',
+ marginBottom: '8px',
+ width: '100%',
+ });
+ toolbar.append(operationsToolbar);
+
+ addButtonToToolbar({
+ title: 'Shrink',
+ container: operationsToolbar,
+ onClick: () => {
+ toolGroup.getToolInstance(clickToolName).shrink();
+ },
+ });
+
+ addButtonToToolbar({
+ title: 'Expand',
+ container: operationsToolbar,
+ onClick: () => {
+ toolGroup.getToolInstance(clickToolName).expand();
+ },
+ });
+
+ addButtonToToolbar({
+ title: 'Undo',
+ container: operationsToolbar,
+ onClick: () => {
+ DefaultHistoryMemo.undo();
+ },
+ });
+
+ addButtonToToolbar({
+ title: 'Redo',
+ container: operationsToolbar,
+ onClick: () => {
+ DefaultHistoryMemo.redo();
+ },
+ });
+
+ addButtonToToolbar({
+ title: 'Clear segmentation',
+ container: operationsToolbar,
+ onClick: () => {
+ const segmentationData =
+ segmentation.state.getSegmentation(segmentationId);
+ const labelmapData = segmentationData?.representationData?.Labelmap;
+ if (labelmapData && 'imageIds' in labelmapData && labelmapData.imageIds) {
+ labelmapData.imageIds.forEach((imageId) => {
+ const image = cache.getImage(imageId);
+ image?.voxelManager?.clear();
+ });
+ segmentation.triggerSegmentationEvents.triggerSegmentationDataModified(
+ segmentationId
+ );
+ }
+ },
+ });
+
+ // Load the single PET series directly (no CT, no series discovery).
+ const imageIds = await createImageIdsAndCacheMetaData({
+ StudyInstanceUID: STUDY_INSTANCE_UID,
+ SeriesInstanceUID: SERIES_INSTANCE_UID,
+ wadoRsRoot: WADO_RS_ROOT,
+ });
+
+ if (!imageIds.length) {
+ stackStatus.textContent = 'Failed to load PET series (no instances).';
+ return;
+ }
+
+ // Sort into through-plane (spatial) order and validate the series is a
+ // proper volume (consistent orientation and slice spacing, one frame of
+ // reference). The click-driven 3D flood fill relies on adjacent stack
+ // indices being adjacent slices in patient space.
+ const { valid, sortedImageIds, reason } = validateAndSortVolumeIds(imageIds);
+ if (!valid) {
+ console.warn(
+ `[clickSegment] series is not a clean volume (${reason}); ` +
+ 'segmenting on it anyway, but 3D fills may misbehave across slices'
+ );
+ }
+
+ await addSegmentationToState(sortedImageIds);
+
+ const mid = Math.max(
+ 0,
+ Math.min(Math.floor(sortedImageIds.length / 2), sortedImageIds.length - 1)
+ );
+ await viewport.setStack(sortedImageIds, mid);
+ cornerstoneTools.utilities.stackContextPrefetch.enable(element);
+
+ await segmentation.addSegmentationRepresentations(viewportId, [
+ {
+ segmentationId,
+ type: csToolsEnums.SegmentationRepresentations.Labelmap,
+ },
+ ]);
+ segmentation.activeSegmentation.setActiveSegmentation(
+ viewportId,
+ segmentationId
+ );
+ segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 1);
+
+ element.addEventListener(
+ csCoreEvents.STACK_NEW_IMAGE,
+ updateStackStatusLabel
+ );
+
+ renderingEngine.render();
+ updateStackStatusLabel();
+}
+
+run();
diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts
index 20f08e87a1..40b692855f 100644
--- a/packages/tools/src/index.ts
+++ b/packages/tools/src/index.ts
@@ -83,6 +83,7 @@ import {
VolumeRotateTool,
RegionSegmentPlusTool,
RegionSegmentTool,
+ ClickSegmentTool,
WholeBodySegmentTool,
LabelmapBaseTool,
SegmentLabelTool,
@@ -190,6 +191,7 @@ export {
VolumeRotateTool,
RegionSegmentPlusTool,
RegionSegmentTool,
+ ClickSegmentTool,
WholeBodySegmentTool,
LabelmapBaseTool,
LabelMapEditWithContourTool,
diff --git a/packages/tools/src/tools/annotation/ClickSegmentTool.ts b/packages/tools/src/tools/annotation/ClickSegmentTool.ts
new file mode 100644
index 0000000000..acd4e7e2a2
--- /dev/null
+++ b/packages/tools/src/tools/annotation/ClickSegmentTool.ts
@@ -0,0 +1,1206 @@
+import {
+ cache,
+ Enums as CoreEnums,
+ eventTarget,
+ utilities as csUtils,
+ getEnabledElement,
+ getRenderingEngine,
+} from '@cornerstonejs/core';
+import type { Types } from '@cornerstonejs/core';
+import type { EventTypes, PublicToolProps, ToolProps } from '../../types';
+
+import GrowCutBaseTool from '../base/GrowCutBaseTool';
+import type { GrowCutToolData } from '../base/GrowCutBaseTool';
+import { runFloodFillSegmentation } from '../../utilities/segmentation/growCut/runFloodFillSegmentation';
+import { floodFill3dSliceLazy } from '../../utilities/segmentation/floodFillSliceLazy';
+import {
+ probeAdaptiveRegion,
+ resolveAdaptiveBandAtTolerance,
+} from '../../utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange';
+import type {
+ AdaptiveRegionExpandContext,
+ AdaptiveRegionProbeResult,
+} from '../../utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange';
+import type { FloodFillIntensityRangeOptions } from '../../utilities/segmentation/growCut/floodFillIntensityRangeTypes';
+import { getViewportVoiMappingForVolume } from '../../utilities/segmentation/growCut/getViewportVoiMappingForVolume';
+import { activeSegmentation } from '../../stateManagement/segmentation';
+import { triggerSegmentationDataModified } from '../../stateManagement/segmentation/triggerSegmentationEvents';
+import * as LabelmapMemo from '../../utilities/segmentation/createLabelmapMemo';
+import {
+ PLUS_CURSOR,
+ BLOCKED_CURSOR,
+ PENDING_CURSOR,
+} from './regionSegmentHoverCursors';
+import { ToolModes } from '../../enums';
+
+const { growCutLog } = csUtils.logger;
+
+/** Coalesce hover probes to at most one per interval (leading + trailing). */
+const HOVER_PROBE_THROTTLE_MS = 100;
+/** A click within this canvas distance of the last probe reuses its verdict. */
+const PROBE_REUSE_DISTANCE_PX = 6;
+/** Probe verdicts older than this are ignored (slice may have changed). */
+const PROBE_FRESH_MS = 1500;
+/** Expand aims for the first curve step at least this much larger. */
+const EXPAND_STEP_FACTOR = 1.15;
+/** Shrink aims for the last curve step at least this much smaller. */
+const SHRINK_STEP_FACTOR = 0.85;
+/**
+ * Lesion-ness is judged by SHAPE, not size: the would-be segment must be one
+ * coherent entity. Its volume must fill at least this fraction of its 3D
+ * bounding box — solid blobs (even large ones) score 0.2–0.6, while sprawling
+ * webs like chained bones or noise score far below. Checked periodically as
+ * the fill grows (early rejection) and on the final region.
+ */
+const MIN_COMPACTNESS = 0.08;
+/** Compactness only applies past this volume (tiny fills are trivially ok). */
+const COMPACTNESS_CHECK_MIN_VOLUME_MM3 = 2000;
+/**
+ * Pure compute safety, far above any plausible lesion (≈16 cm sphere).
+ * Never the criterion — shape gates fire long before it.
+ */
+const COMPUTE_BUDGET_MM3 = 2_000_000;
+/** Marker for shape/budget rejections so callers phrase the error correctly. */
+const NOT_LESION_MESSAGE =
+ 'The region here does not form a self-contained, lesion-like shape; nothing was segmented.';
+/** Dry-run flood yields (voxels) — coarse, it only checks the shape gate. */
+const DRY_RUN_YIELD_EVERY = 10_000;
+/** Neighbor votes (of 4, at ±1 voxel) required in addition to the center. */
+const CONSENSUS_MIN_NEIGHBOR_VOTES = 2;
+/**
+ * Verdicts attach to REGIONS, not pixels, for this long: a confirmed lesion
+ * is one contiguous plus zone (its actual 3D extent), and a rejected seed
+ * blocks its whole neighborhood — so the cursor cannot flicker pixel to
+ * pixel across the same structure.
+ */
+const VERDICT_CACHE_TTL_MS = 2500;
+/** Max rejected regions remembered. */
+const MAX_REJECTED_REGIONS = 8;
+
+type HoverProbeVerdict = {
+ viewportId: string;
+ canvas: Types.Point2;
+ sliceIndex: number | null;
+ ok: boolean;
+ at: number;
+};
+
+type LastClickState = {
+ expandContext: AdaptiveRegionExpandContext;
+ /** Threshold depth the labelmap currently reflects. */
+ toleranceBytes: number;
+ worldPoint: Types.Point3;
+ /** Labelmap ijk voxels painted by the last run (pre island removal). */
+ filledPoints: Types.Point3[];
+ segmentation: GrowCutToolData['segmentation'];
+ viewportId: string;
+ renderingEngineId: string;
+};
+
+/**
+ * Click-to-segment with a trustworthy grow/shrink workflow, built for
+ * PET-style hot lesions but modality-agnostic:
+ *
+ * - **Hover** previews segmentability: plus cursor = a click here produces a
+ * meaningful segment; blocked cursor = it will not (flat area, noise speck,
+ * or an unbounded region). Clicks on blocked spots are no-ops.
+ * - **Click** derives a one-sided intensity threshold dynamically (everything
+ * at least as intense as the clicked structure, connected to it), so the
+ * hottest core of a lesion is always included — no interior holes.
+ * - **expand()/shrink()** step deterministically along the growth curve that
+ * was 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 with every step.
+ *
+ * There is nothing to configure: no disk radii, no strategies, no deltas.
+ */
+class ClickSegmentTool extends GrowCutBaseTool {
+ static toolName = 'ClickSegment';
+ private segmentationInProgress = false;
+ private hoverThrottleTimer: number | null = null;
+ private pendingHoverEvent: EventTypes.MouseMoveEventType | null = null;
+ private lastHoverElement: HTMLDivElement | null = null;
+ private lastProbe: HoverProbeVerdict | null = null;
+ private lastClick: LastClickState | null = null;
+ private clickAbortController: AbortController | null = null;
+ private dryRunToken: { cancelled: boolean } | null = null;
+ private confirmedRegion: {
+ viewportId: string;
+ bbox: { min: Types.Point3; max: Types.Point3 };
+ bandMin: number;
+ bandMax: number;
+ expiresAt: number;
+ } | null = null;
+ /**
+ * Regions a dry-run rejected: their explored bbox + band. Any new seed on
+ * the same structure (inside the box, matching intensity) is blocked
+ * instantly — no re-run, no per-pixel re-evaluation across a bone.
+ */
+ private rejectedRegions: Array<{
+ viewportId: string;
+ bbox: { min: Types.Point3; max: Types.Point3 };
+ bandMin: number;
+ bandMax: number;
+ expiresAt: number;
+ }> = [];
+
+ constructor(
+ toolProps: PublicToolProps = {},
+ defaultToolProps: ToolProps = {
+ supportedInteractionTypes: ['Mouse', 'Touch'],
+ configuration: {
+ /** Through-slice safety bound for the 3D fill. */
+ maxDeltaK: 25,
+ /** In-plane safety bound for the 3D fill. */
+ maxDeltaIJ: 512,
+ actions: {
+ cancelInProgress: {
+ method: 'cancelInProgress',
+ bindings: [
+ {
+ key: 'Escape',
+ },
+ ],
+ },
+ },
+ },
+ }
+ ) {
+ super(toolProps, defaultToolProps);
+ }
+
+ private notifySegmentationError(message: string): void {
+ const event = new CustomEvent(CoreEnums.Events.ERROR_EVENT, {
+ detail: {
+ type: 'Segmentation',
+ message,
+ },
+ cancelable: true,
+ });
+ eventTarget.dispatchEvent(event);
+ }
+
+ public cancelInProgress(): boolean {
+ return this.cancelActiveOperation();
+ }
+
+ public cancelActiveOperation(): boolean {
+ if (
+ !this.clickAbortController ||
+ this.clickAbortController.signal.aborted
+ ) {
+ return false;
+ }
+ this.clickAbortController.abort();
+ return true;
+ }
+
+ private clearHoverState(): void {
+ if (this.hoverThrottleTimer !== null) {
+ window.clearTimeout(this.hoverThrottleTimer);
+ this.hoverThrottleTimer = null;
+ }
+ this.pendingHoverEvent = null;
+ this.lastProbe = null;
+ this.confirmedRegion = null;
+ this.rejectedRegions = [];
+ if (this.dryRunToken) {
+ this.dryRunToken.cancelled = true;
+ this.dryRunToken = null;
+ }
+ if (this.lastHoverElement) {
+ this.lastHoverElement.style.cursor = '';
+ this.lastHoverElement = null;
+ }
+ }
+
+ /**
+ * Fast path: the pointer sits on an entity a dry-run already confirmed —
+ * inside its 3D bounding box and within its intensity band. The whole
+ * lesion is one contiguous plus zone.
+ */
+ private pointerOnConfirmedRegion(
+ refVolume: Types.IImageVolume,
+ viewport: Types.IViewport,
+ world: Types.Point3
+ ): boolean {
+ const region = this.confirmedRegion;
+ if (
+ !region ||
+ region.viewportId !== viewport.id ||
+ Date.now() > region.expiresAt
+ ) {
+ return false;
+ }
+ const ijk = csUtils
+ .transformWorldToIndex(refVolume.imageData, world)
+ .map(Math.round) as Types.Point3;
+ for (let axis = 0; axis < 3; axis++) {
+ if (
+ ijk[axis] < region.bbox.min[axis] ||
+ ijk[axis] > region.bbox.max[axis]
+ ) {
+ return false;
+ }
+ }
+ const [width, height] = refVolume.dimensions;
+ const scalar = Number(
+ refVolume.voxelManager.getAtIndex(
+ ijk[2] * width * height + ijk[1] * width + ijk[0]
+ )
+ );
+ if (
+ !Number.isFinite(scalar) ||
+ scalar < region.bandMin ||
+ scalar > region.bandMax
+ ) {
+ return false;
+ }
+ region.expiresAt = Date.now() + VERDICT_CACHE_TTL_MS;
+ return true;
+ }
+
+ /**
+ * Bands describe the same fill when their finite edges agree within a
+ * relative tolerance (infinite sides must match exactly). This is the key
+ * discriminator for verdict caching: a one-sided rejected band like
+ * `[low, Inf)` CONTAINS every hotter structure inside its bounding box
+ * (e.g. a lesion sitting in rejected background haze), so containment must
+ * never be used to inherit a verdict — only same-threshold fills may.
+ */
+ private static bandsAreSimilar(
+ aMin: number,
+ aMax: number,
+ bMin: number,
+ bMax: number
+ ): boolean {
+ const edgeMatches = (a: number, b: number): boolean => {
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
+ return a === b;
+ }
+ const scale = Math.max(Math.abs(a), Math.abs(b), 1e-6);
+ return Math.abs(a - b) <= 0.25 * scale;
+ };
+ return edgeMatches(aMin, bMin) && edgeMatches(aMax, bMax);
+ }
+
+ /**
+ * True when the probe describes a fill a dry-run already rejected: its seed
+ * is inside the rejected region's explored bounding box AND its band has
+ * essentially the same threshold. A hotter structure inside the box (its
+ * own, higher threshold) does NOT inherit the rejection — it gets its own
+ * dry-run.
+ */
+ private matchesRejectedRegion(
+ viewportId: string,
+ range: NonNullable
+ ): boolean {
+ const now = Date.now();
+ this.rejectedRegions = this.rejectedRegions.filter(
+ (entry) => entry.expiresAt > now
+ );
+ const seed = range.ijkStart;
+ const match = this.rejectedRegions.find((entry) => {
+ if (entry.viewportId !== viewportId) {
+ return false;
+ }
+ for (let axis = 0; axis < 3; axis++) {
+ if (
+ seed[axis] < entry.bbox.min[axis] ||
+ seed[axis] > entry.bbox.max[axis]
+ ) {
+ return false;
+ }
+ }
+ return ClickSegmentTool.bandsAreSimilar(
+ range.min,
+ range.max,
+ entry.bandMin,
+ entry.bandMax
+ );
+ });
+ if (match) {
+ match.expiresAt = now + VERDICT_CACHE_TTL_MS;
+ return true;
+ }
+ return false;
+ }
+
+ private rememberRejectedRegion(
+ viewportId: string,
+ bbox: { min: Types.Point3; max: Types.Point3 } | null,
+ bandMin: number,
+ bandMax: number
+ ): void {
+ if (!bbox) {
+ return;
+ }
+ this.rejectedRegions.push({
+ viewportId,
+ bbox: {
+ min: [...bbox.min] as Types.Point3,
+ max: [...bbox.max] as Types.Point3,
+ },
+ bandMin,
+ bandMax,
+ expiresAt: Date.now() + VERDICT_CACHE_TTL_MS,
+ });
+ if (this.rejectedRegions.length > MAX_REJECTED_REGIONS) {
+ this.rejectedRegions.splice(
+ 0,
+ this.rejectedRegions.length - MAX_REJECTED_REGIONS
+ );
+ }
+ }
+
+ private safeSpacing(volume: Types.IImageVolume): [number, number, number] {
+ const spacing = volume.spacing ?? [1, 1, 1];
+ return [0, 1, 2].map((axis) =>
+ Number.isFinite(spacing[axis]) && spacing[axis] > 0 ? spacing[axis] : 1
+ ) as [number, number, number];
+ }
+
+ /** Compute-safety voxel budget for {@link COMPUTE_BUDGET_MM3}. */
+ private computeVoxelBudget(volume: Types.IImageVolume): number {
+ const [sx, sy, sz] = this.safeSpacing(volume);
+ return Math.max(1000, Math.ceil(COMPUTE_BUDGET_MM3 / (sx * sy * sz)));
+ }
+
+ /**
+ * Entity-coherence gate ("is this one lesion?"): the region must end on its
+ * own inside the through-slice window (not be cut off by the safety clamp
+ * on both sides) and stay compact — its volume filling a sensible fraction
+ * of its own bounding box. No absolute size or extent limits: a large solid
+ * lesion passes; chained bones, organs bleeding into neighbors, and noise
+ * webs fail. Used both periodically while the fill grows and on the final
+ * region, by the click, expand/shrink, and the hover dry-run alike.
+ */
+ private makeRegionShapeGate(
+ volume: Types.IImageVolume
+ ): (stats: {
+ voxelCount: number;
+ bbox: { min: Types.Point3; max: Types.Point3 };
+ }) => boolean {
+ const spacing = this.safeSpacing(volume);
+ const voxelVolumeMm3 = spacing[0] * spacing[1] * spacing[2];
+ const maxDeltaK = this.configuration.maxDeltaK;
+ return ({ voxelCount, bbox }) => {
+ // Negative maxDeltaK means "unbounded" (same convention as
+ // floodFill3dSliceLazy) — no through-slice self-containment check then.
+ if (typeof maxDeltaK === 'number' && maxDeltaK >= 0) {
+ const kExtent = bbox.max[2] - bbox.min[2] + 1;
+ if (kExtent >= 2 * maxDeltaK + 1) {
+ return false;
+ }
+ }
+ const volumeMm3 = voxelCount * voxelVolumeMm3;
+ if (volumeMm3 < COMPACTNESS_CHECK_MIN_VOLUME_MM3) {
+ return true;
+ }
+ let bboxMm3 = 1;
+ for (let axis = 0; axis < 3; axis++) {
+ bboxMm3 *= (bbox.max[axis] - bbox.min[axis] + 1) * spacing[axis];
+ }
+ return volumeMm3 / bboxMm3 >= MIN_COMPACTNESS;
+ };
+ }
+
+ onSetToolPassive(): void {
+ this.clearHoverState();
+ }
+
+ onSetToolDisabled(): void {
+ this.clearHoverState();
+ }
+
+ private buildProbeOptions(
+ viewport: Types.IViewport,
+ element: HTMLDivElement,
+ referencedVolumeId: string
+ ): FloodFillIntensityRangeOptions {
+ return {
+ viewport,
+ element,
+ referencedVolumeId,
+ voiMapping:
+ getViewportVoiMappingForVolume(viewport, referencedVolumeId) ??
+ undefined,
+ };
+ }
+
+ mouseMoveCallback(evt: EventTypes.MouseMoveEventType) {
+ if (this.mode !== ToolModes.Active) {
+ return;
+ }
+
+ const { element } = evt.detail;
+ this.lastHoverElement = element;
+
+ if (this.segmentationInProgress) {
+ element.style.cursor = 'wait';
+ return;
+ }
+
+ this.queueHoverProbe(evt);
+ }
+
+ /**
+ * Leading + trailing throttle so a moving pointer probes immediately, then
+ * at most once per {@link HOVER_PROBE_THROTTLE_MS} while it keeps moving.
+ */
+ private queueHoverProbe(evt: EventTypes.MouseMoveEventType): void {
+ this.pendingHoverEvent = evt;
+ if (this.hoverThrottleTimer !== null) {
+ return;
+ }
+ this.flushHoverProbe();
+ }
+
+ private flushHoverProbe(): void {
+ const evt = this.pendingHoverEvent;
+ this.pendingHoverEvent = null;
+ if (!evt) {
+ return;
+ }
+ void this.runHoverProbe(evt);
+ this.hoverThrottleTimer = window.setTimeout(() => {
+ this.hoverThrottleTimer = null;
+ if (this.pendingHoverEvent) {
+ this.flushHoverProbe();
+ }
+ }, HOVER_PROBE_THROTTLE_MS);
+ }
+
+ private getViewportSliceIndex(viewport: Types.IViewport): number | null {
+ const getSliceIndex = (
+ viewport as Types.IViewport & { getSliceIndex?: () => number }
+ ).getSliceIndex;
+ if (typeof getSliceIndex !== 'function') {
+ return null;
+ }
+ try {
+ const sliceIndex = getSliceIndex.call(viewport);
+ return Number.isFinite(sliceIndex) ? sliceIndex : null;
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Probes the pointer with the same function a click would run and reflects
+ * the verdict in the cursor: plus = meaningful segment here, blocked = not.
+ */
+ private async runHoverProbe(
+ evt: EventTypes.MouseMoveEventType
+ ): Promise {
+ if (this.mode !== ToolModes.Active || this.segmentationInProgress) {
+ return;
+ }
+ const { element, currentPoints } = evt.detail;
+ const enabledElement = getEnabledElement(element);
+ const viewport = enabledElement?.viewport;
+ if (!viewport) {
+ return;
+ }
+
+ // Every probe invalidates any in-flight 3D dry-run for the previous spot.
+ if (this.dryRunToken) {
+ this.dryRunToken.cancelled = true;
+ this.dryRunToken = null;
+ }
+
+ // Verdict states: 'plus' (confirmed lesion), 'blocked', 'pending'
+ // (candidate awaiting 3D confirmation — plus is NEVER shown
+ // optimistically, so it cannot flicker), 'unknown' (cannot evaluate).
+ let state: 'plus' | 'blocked' | 'pending' | 'unknown' = 'unknown';
+ let dryRunInput: {
+ refVolume: Types.IImageVolume;
+ range: NonNullable;
+ } | null = null;
+ try {
+ if (activeSegmentation.getActiveSegmentation(viewport.id)) {
+ const labelmapData = await this.getLabelmapSegmentationData(viewport);
+ if (labelmapData) {
+ const { referencedVolumeId } = labelmapData;
+ if (!this._isOrthogonalView(viewport, referencedVolumeId)) {
+ state = 'blocked';
+ } else {
+ const refVolume = cache.getVolume(referencedVolumeId);
+ if (refVolume) {
+ // Fast path: still on an entity a dry-run already confirmed —
+ // the whole lesion is one contiguous plus zone.
+ if (
+ this.pointerOnConfirmedRegion(
+ refVolume,
+ viewport,
+ currentPoints.world
+ )
+ ) {
+ state = 'plus';
+ } else {
+ const options = this.buildProbeOptions(
+ viewport,
+ element,
+ referencedVolumeId
+ );
+ const probe = probeAdaptiveRegion(
+ refVolume,
+ currentPoints.world,
+ options
+ );
+ if (!probe.viable || !probe.range) {
+ state = 'blocked';
+ } else if (
+ this.matchesRejectedRegion(viewport.id, probe.range)
+ ) {
+ // This same fill was just rejected in 3D — same verdict,
+ // no flicker, no re-run. Different fills (e.g. a hotter
+ // lesion inside a rejected background box) re-evaluate.
+ state = 'blocked';
+ } else if (!this.neighborsAgree(probe)) {
+ state = 'blocked';
+ } else {
+ state = 'pending';
+ dryRunInput = { refVolume, range: probe.range };
+ }
+ }
+ }
+ }
+ }
+ }
+ } catch (err) {
+ growCutLog.debug('hover probe: could not evaluate', {
+ message: err instanceof Error ? err.message : String(err),
+ });
+ state = 'unknown';
+ }
+
+ // While pending/unknown the click stays allowed — the fill re-validates
+ // everything anyway; only an explicit 'blocked' gates it.
+ const verdict: HoverProbeVerdict | null =
+ state === 'unknown'
+ ? null
+ : {
+ viewportId: viewport.id,
+ canvas: [...currentPoints.canvas] as Types.Point2,
+ sliceIndex: this.getViewportSliceIndex(viewport),
+ ok: state !== 'blocked',
+ at: Date.now(),
+ };
+ this.lastProbe = verdict;
+
+ // Green is reserved for a CONFIRMED plus; pending/unknown show the gray
+ // evaluating cursor so they can never be mistaken for "segmentable".
+ element.style.cursor =
+ state === 'plus'
+ ? PLUS_CURSOR
+ : state === 'blocked'
+ ? BLOCKED_CURSOR
+ : PENDING_CURSOR;
+
+ // The 2D probe cannot see 3D connectivity (e.g. CT bones chaining across
+ // slices). The plus cursor only appears once the budgeted 3D dry-run
+ // confirms a coherent lesion-like entity.
+ if (state === 'pending' && dryRunInput && verdict) {
+ const token = { cancelled: false };
+ this.dryRunToken = token;
+ void this.runConfinementDryRun(
+ dryRunInput.refVolume,
+ dryRunInput.range,
+ token,
+ verdict,
+ element
+ );
+ }
+ }
+
+ /**
+ * Consensus polling: requires at least
+ * {@link CONSENSUS_MIN_NEIGHBOR_VOTES} of the click's 4 in-plane neighbors
+ * (±1 voxel) to belong to the probed region at its chosen tolerance. A
+ * lesion is a zone, so its plus verdict should be spatially stable — not
+ * "plus here, blocked one pixel left and right". The votes are read off the
+ * center probe's own threshold sweep (it records when each neighbor joined
+ * the region), so no additional window analyses run per hover.
+ */
+ private neighborsAgree(probe: AdaptiveRegionProbeResult): boolean {
+ const joinLevels = probe.clickNeighborJoinLevels;
+ const tolerance = probe.toleranceBytes;
+ if (!joinLevels || typeof tolerance !== 'number') {
+ return true;
+ }
+ let votes = 0;
+ let evaluated = 0;
+ for (const joinLevel of joinLevels) {
+ if (joinLevel === null) {
+ // Outside the volume — not evaluable.
+ continue;
+ }
+ evaluated++;
+ if (joinLevel >= 0 && joinLevel <= tolerance) {
+ votes++;
+ }
+ }
+ // At the volume edge with too few evaluable neighbors, don't punish.
+ return evaluated < CONSENSUS_MIN_NEIGHBOR_VOTES
+ ? votes === evaluated
+ : votes >= CONSENSUS_MIN_NEIGHBOR_VOTES;
+ }
+
+ /**
+ * Shape-gated 3D flood (no painting) that verifies the would-be segment is
+ * one coherent, lesion-like entity. On failure, downgrades the hover
+ * verdict and cursor so the click is blocked before ever running.
+ */
+ private async runConfinementDryRun(
+ refVolume: Types.IImageVolume,
+ range: NonNullable,
+ token: { cancelled: boolean },
+ verdict: HoverProbeVerdict,
+ element: HTMLDivElement
+ ): Promise {
+ const { dimensions } = refVolume;
+ const [width, height, depth] = dimensions;
+ const pixelsPerSlice = width * height;
+ const voxelManager = refVolume.voxelManager as unknown as {
+ getAtIndex: (index: number) => number;
+ };
+ const { min, max } = range;
+ const shapeGate = this.makeRegionShapeGate(refVolume);
+
+ try {
+ const { truncated, voxelCount, bbox } = await floodFill3dSliceLazy(
+ (x, y, z) =>
+ Number(voxelManager.getAtIndex(z * pixelsPerSlice + y * width + x)),
+ [...range.ijkStart] as Types.Point3,
+ {
+ width,
+ height,
+ depth,
+ equals: (val) =>
+ typeof val === 'number' &&
+ Number.isFinite(val) &&
+ val >= min &&
+ val <= max,
+ yieldEvery: DRY_RUN_YIELD_EVERY,
+ maxDeltaK: this.configuration.maxDeltaK,
+ maxDeltaIJ: this.configuration.maxDeltaIJ,
+ isCancelled: () => token.cancelled,
+ maxVoxels: this.computeVoxelBudget(refVolume),
+ shouldContinue: shapeGate,
+ }
+ );
+ if (token.cancelled) {
+ return;
+ }
+ const lesionLike = !truncated && bbox && shapeGate({ voxelCount, bbox });
+ if (lesionLike) {
+ // Attach the plus verdict to the ENTITY: everything inside this
+ // region shows plus without re-running (a contiguous plus zone).
+ this.confirmedRegion = {
+ viewportId: verdict.viewportId,
+ bbox,
+ bandMin: min,
+ bandMax: max,
+ expiresAt: Date.now() + VERDICT_CACHE_TTL_MS,
+ };
+ if (this.lastProbe === verdict) {
+ verdict.ok = true;
+ element.style.cursor = PLUS_CURSOR;
+ }
+ } else {
+ this.rememberRejectedRegion(verdict.viewportId, bbox, min, max);
+ if (this.lastProbe === verdict) {
+ verdict.ok = false;
+ element.style.cursor = BLOCKED_CURSOR;
+ }
+ }
+ } catch (err) {
+ growCutLog.debug('hover dry-run: could not evaluate', {
+ message: err instanceof Error ? err.message : String(err),
+ });
+ } finally {
+ if (this.dryRunToken === token) {
+ this.dryRunToken = null;
+ }
+ }
+ }
+
+ /**
+ * True when the last hover probe evaluated (approximately) the click point
+ * and is still current for the viewport's displayed slice.
+ */
+ private probeAppliesToClick(
+ viewport: Types.IViewport,
+ canvas: Types.Point2
+ ): boolean {
+ const probe = this.lastProbe;
+ if (!probe || probe.viewportId !== viewport.id) {
+ return false;
+ }
+ if (Date.now() - probe.at > PROBE_FRESH_MS) {
+ return false;
+ }
+ if (probe.sliceIndex !== this.getViewportSliceIndex(viewport)) {
+ return false;
+ }
+ const dx = canvas[0] - probe.canvas[0];
+ const dy = canvas[1] - probe.canvas[1];
+ return Math.sqrt(dx * dx + dy * dy) <= PROBE_REUSE_DISTANCE_PX;
+ }
+
+ async preMouseDownCallback(
+ evt: EventTypes.MouseDownActivateEventType
+ ): Promise {
+ if (this.segmentationInProgress) {
+ return false;
+ }
+
+ const { currentPoints, element } = evt.detail;
+ const { world: worldPoint, canvas: canvasPoint } = currentPoints;
+
+ const enabledElement = element ? getEnabledElement(element) : undefined;
+ if (
+ enabledElement?.viewport &&
+ this.probeAppliesToClick(enabledElement.viewport, canvasPoint) &&
+ this.lastProbe?.ok === false
+ ) {
+ growCutLog.info('click ignored: hover probe reported no proper region', {
+ canvasPoint,
+ });
+ return false;
+ }
+
+ // The base setup throws for unsupported views (e.g. 'Oblique view is not
+ // supported yet'). The hover probe normally blocks such clicks already,
+ // but a click without a fresh probe must fail cleanly, not as a rejected
+ // promise.
+ let setupOk = false;
+ try {
+ setupOk = await super.preMouseDownCallback(evt);
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : 'Click segmentation failed.';
+ growCutLog.info('ClickSegment: click setup rejected', { message });
+ this.notifySegmentationError(message);
+ return false;
+ }
+ if (!setupOk || !this.growCutData) {
+ return false;
+ }
+
+ const clickData = this.growCutData;
+ this.growCutData = null;
+
+ void this.runClick(clickData, worldPoint, element).catch((err) => {
+ const message =
+ err instanceof Error ? err.message : 'Click segmentation failed.';
+ this.notifySegmentationError(message);
+ growCutLog.error('ClickSegment: segmentation failed', { message });
+ });
+
+ return true;
+ }
+
+ private async runClick(
+ clickData: GrowCutToolData,
+ worldPoint: Types.Point3,
+ element: HTMLDivElement | undefined
+ ): Promise {
+ const { segmentation, viewportId, renderingEngineId } = clickData;
+ const { referencedVolumeId, labelmapVolumeId, segmentIndex } = segmentation;
+
+ const renderingEngine = getRenderingEngine(renderingEngineId);
+ const viewport = renderingEngine?.getViewport(viewportId);
+ if (!viewport) {
+ throw new Error('ClickSegment: viewport not found for click.');
+ }
+ const refVolume = cache.getVolume(referencedVolumeId);
+ const labelmapVolume = cache.getVolume(labelmapVolumeId);
+ if (!refVolume || !labelmapVolume) {
+ throw new Error(
+ 'ClickSegment: referenced or labelmap volume not in cache.'
+ );
+ }
+
+ const probe = probeAdaptiveRegion(
+ refVolume,
+ worldPoint,
+ this.buildProbeOptions(viewport, viewport.element, referencedVolumeId)
+ );
+ if (!probe.viable || !probe.range || !probe.expandContext) {
+ growCutLog.info('ClickSegment: click found no meaningful region', {
+ reason: probe.reason,
+ regionAreaMm2: probe.regionAreaMm2,
+ });
+ this.notifySegmentationError(
+ 'No meaningful region at the clicked location. Move the pointer until the plus cursor appears.'
+ );
+ return;
+ }
+
+ growCutLog.info('ClickSegment: click', {
+ worldPoint,
+ toleranceBytes: probe.toleranceBytes,
+ regionAreaMm2: probe.regionAreaMm2,
+ band: { min: probe.range.min, max: probe.range.max },
+ });
+
+ // One click = one undoable history entry.
+ const memo = this.beginLabelmapMemo(segmentation, labelmapVolume);
+ let filledPoints: Types.Point3[];
+ try {
+ filledPoints = await this.fillWithRange(
+ probe.range,
+ {
+ segmentation,
+ viewportId,
+ renderingEngineId,
+ },
+ viewport,
+ labelmapVolume,
+ element,
+ memo
+ );
+ } finally {
+ // Commits + pushes when anything was written; a rejected fill records
+ // nothing and pushes nothing.
+ this.doneEditMemo();
+ }
+
+ this.lastClick = {
+ expandContext: probe.expandContext,
+ toleranceBytes: probe.expandContext.chosenToleranceBytes,
+ worldPoint: [...worldPoint] as Types.Point3,
+ filledPoints,
+ segmentation,
+ viewportId,
+ renderingEngineId,
+ };
+ growCutLog.info('ClickSegment: click complete', {
+ filledVoxels: filledPoints.length,
+ segmentIndex,
+ });
+ }
+
+ /**
+ * Runs the 3D fill for an explicit band and returns the painted points.
+ * Shared by click and expand/shrink refills.
+ */
+ private async fillWithRange(
+ range: NonNullable,
+ target: {
+ segmentation: GrowCutToolData['segmentation'];
+ viewportId: string;
+ renderingEngineId: string;
+ },
+ viewport: Types.IViewport,
+ labelmapVolume: Types.IImageVolume,
+ element: HTMLDivElement | undefined,
+ memo?: LabelmapMemo.LabelmapMemo
+ ): Promise {
+ const { segmentation } = target;
+ const { referencedVolumeId, segmentIndex, segmentationId } = segmentation;
+
+ this.segmentationInProgress = true;
+ if (element) {
+ element.style.cursor = 'wait';
+ }
+ const abortController = new AbortController();
+ this.clickAbortController = abortController;
+
+ let filledPoints: Types.Point3[] = [];
+ let notLesionLike = false;
+ try {
+ const referencedVolume = cache.getVolume(referencedVolumeId);
+ if (!referencedVolume) {
+ throw new Error(
+ 'ClickSegment: referenced volume is no longer cached; cannot fill.'
+ );
+ }
+ const result = await runFloodFillSegmentation({
+ referencedVolumeId,
+ worldPosition: this.indexToWorld(referencedVolume, range.ijkStart),
+ viewport,
+ labelmapVolume,
+ options: {
+ segmentIndex,
+ getIntensityRange: () => range,
+ element: viewport.element,
+ applyExternalIslandRemoval: true,
+ applyInternalIslandRemoval: true,
+ maxDeltaK: this.configuration.maxDeltaK,
+ maxDeltaIJ: this.configuration.maxDeltaIJ,
+ isCancelled: () => abortController.signal.aborted,
+ maxVoxels: this.computeVoxelBudget(referencedVolume),
+ shouldContinueRegion: this.makeRegionShapeGate(referencedVolume),
+ historyVoxelManager: memo?.voxelManager,
+ onRejected: ({ voxelCount, bbox }) => {
+ notLesionLike = true;
+ growCutLog.info('ClickSegment: fill rejected by shape gate', {
+ voxelCount,
+ bbox,
+ });
+ },
+ onCommitted: (points) => {
+ filledPoints = points;
+ },
+ },
+ });
+ if (!result) {
+ if (abortController.signal.aborted) {
+ throw new Error('ClickSegment: fill cancelled.');
+ }
+ throw new Error(
+ notLesionLike
+ ? NOT_LESION_MESSAGE
+ : 'ClickSegment: fill produced no result (band rejected).'
+ );
+ }
+ triggerSegmentationDataModified(segmentationId);
+ return filledPoints;
+ } finally {
+ if (this.clickAbortController === abortController) {
+ this.clickAbortController = null;
+ }
+ this.segmentationInProgress = false;
+ if (element) {
+ element.style.cursor = PENDING_CURSOR;
+ }
+ }
+ }
+
+ private indexToWorld(
+ volume: Types.IImageVolume,
+ ijk: Types.Point3
+ ): Types.Point3 {
+ return csUtils.transformIndexToWorld(volume.imageData, ijk) as Types.Point3;
+ }
+
+ /**
+ * Starts recording ONE undoable operation (a click, or one expand/shrink
+ * step) against the labelmap. Every voxel write of the operation must go
+ * through the returned memo's history voxel manager; `doneEditMemo()`
+ * (from BaseTool) then commits it and pushes it onto the shared history
+ * stack, so segmentation undo/redo steps through one-click operations the
+ * same way it does through brush strokes.
+ */
+ private beginLabelmapMemo(
+ segmentation: GrowCutToolData['segmentation'],
+ labelmapVolume: Types.IImageVolume
+ ): LabelmapMemo.LabelmapMemo {
+ const memo = LabelmapMemo.createLabelmapMemo(
+ segmentation.segmentationId,
+ labelmapVolume.voxelManager as Types.IVoxelManager
+ ) as LabelmapMemo.LabelmapMemo;
+ this.memo = memo;
+ return memo;
+ }
+
+ /** Region size (in-plane px) the growth curve predicts for a tolerance. */
+ private curveSizeAt(
+ context: AdaptiveRegionExpandContext,
+ toleranceBytes: number
+ ): number {
+ let size = 0;
+ for (const [level, levelSize] of context.growthCurve) {
+ if (level > toleranceBytes) {
+ break;
+ }
+ size = levelSize;
+ }
+ return size;
+ }
+
+ /**
+ * Picks the next tolerance along the click-time growth curve, requiring a
+ * visible region change (at least ~15% larger / smaller). Returns null when
+ * there is no further step in that direction.
+ */
+ private pickNextTolerance(direction: 1 | -1): number | null {
+ const lastClick = this.lastClick;
+ if (!lastClick) {
+ return null;
+ }
+ const { expandContext, toleranceBytes } = lastClick;
+ const currentSize = this.curveSizeAt(expandContext, toleranceBytes);
+
+ if (direction > 0) {
+ for (const [level, size] of expandContext.growthCurve) {
+ if (level <= toleranceBytes) {
+ continue;
+ }
+ if (
+ size >= Math.max(currentSize + 1, currentSize * EXPAND_STEP_FACTOR)
+ ) {
+ return level;
+ }
+ }
+ return null;
+ }
+
+ for (
+ let index = expandContext.growthCurve.length - 1;
+ index >= 0;
+ index--
+ ) {
+ const [level, size] = expandContext.growthCurve[index];
+ if (level >= toleranceBytes) {
+ continue;
+ }
+ if (size <= Math.min(currentSize - 1, currentSize * SHRINK_STEP_FACTOR)) {
+ return level;
+ }
+ }
+ return null;
+ }
+
+ public expand(): void {
+ this.stepTolerance(1);
+ }
+
+ public shrink(): void {
+ this.stepTolerance(-1);
+ }
+
+ public refresh(): void {
+ const lastClick = this.lastClick;
+ if (!lastClick || this.segmentationInProgress) {
+ return;
+ }
+ void this.refillAtTolerance(lastClick.toleranceBytes).catch((err) => {
+ growCutLog.error('ClickSegment: refresh failed', {
+ message: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }
+
+ private stepTolerance(direction: 1 | -1): void {
+ if (this.segmentationInProgress) {
+ return;
+ }
+ const lastClick = this.lastClick;
+ if (!lastClick) {
+ growCutLog.info('ClickSegment: no click to expand/shrink yet');
+ return;
+ }
+ const next = this.pickNextTolerance(direction);
+ if (next === null) {
+ growCutLog.info('ClickSegment: no further step available', {
+ direction: direction > 0 ? 'expand' : 'shrink',
+ toleranceBytes: lastClick.toleranceBytes,
+ });
+ return;
+ }
+ growCutLog.info('ClickSegment: stepping tolerance', {
+ direction: direction > 0 ? 'expand' : 'shrink',
+ fromToleranceBytes: lastClick.toleranceBytes,
+ toToleranceBytes: next,
+ predictedRegionPx: this.curveSizeAt(lastClick.expandContext, next),
+ });
+ const previousTolerance = lastClick.toleranceBytes;
+ void this.refillAtTolerance(next).catch((err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ if (message === NOT_LESION_MESSAGE && direction > 0) {
+ // Expanding would break the lesion-like shape (leak into another
+ // structure). refillAtTolerance already restored the cleared voxels,
+ // so the previous result is intact — just tell the user.
+ growCutLog.info(
+ 'ClickSegment: expand blocked by the lesion shape gate; previous result kept',
+ { previousTolerance }
+ );
+ this.notifySegmentationError(
+ 'Expanding further would leak beyond a lesion-like shape; kept the previous result.'
+ );
+ return;
+ }
+ growCutLog.error('ClickSegment: expand/shrink failed', { message });
+ });
+ }
+
+ /**
+ * Clears the previous result of the last click and refills at the given
+ * tolerance — expand AND shrink are exact, not additive.
+ */
+ private async refillAtTolerance(toleranceBytes: number): Promise {
+ const lastClick = this.lastClick;
+ if (!lastClick) {
+ return;
+ }
+ const { segmentation, viewportId, renderingEngineId } = lastClick;
+ const labelmapVolume = cache.getVolume(segmentation.labelmapVolumeId);
+ const renderingEngine = getRenderingEngine(renderingEngineId);
+ const viewport = renderingEngine?.getViewport(viewportId);
+ if (!labelmapVolume || !viewport) {
+ growCutLog.warn(
+ 'ClickSegment: labelmap or viewport gone; cannot expand/shrink'
+ );
+ return;
+ }
+
+ // One expand/shrink press = one undoable history entry covering BOTH the
+ // clear of the previous result and the refill.
+ const memo = this.beginLabelmapMemo(segmentation, labelmapVolume);
+ try {
+ // Clear exactly what the last run painted (voxels island removal
+ // already cleared read as 0 and are skipped), then refill with the new
+ // band. Reads use the labelmap; writes go through the memo so undo
+ // restores the pre-step state.
+ const { voxelManager } = labelmapVolume;
+ const historyVm = memo.voxelManager;
+ const [width, height] = labelmapVolume.dimensions;
+ const pixelsPerSlice = width * height;
+ const { segmentIndex } = segmentation;
+ const clearedIndices: number[] = [];
+ for (const [x, y, z] of lastClick.filledPoints) {
+ const index = z * pixelsPerSlice + y * width + x;
+ if (voxelManager.getAtIndex(index) === segmentIndex) {
+ historyVm.setAtIndex(index, 0);
+ clearedIndices.push(index);
+ }
+ }
+
+ const range = resolveAdaptiveBandAtTolerance(
+ lastClick.expandContext,
+ toleranceBytes
+ );
+
+ try {
+ const filledPoints = await this.fillWithRange(
+ range,
+ {
+ segmentation,
+ viewportId,
+ renderingEngineId,
+ },
+ viewport,
+ labelmapVolume,
+ this.lastHoverElement ?? undefined,
+ memo
+ );
+ lastClick.toleranceBytes = toleranceBytes;
+ lastClick.filledPoints = filledPoints;
+ growCutLog.info('ClickSegment: refill complete', {
+ toleranceBytes,
+ filledVoxels: filledPoints.length,
+ });
+ } catch (err) {
+ // The refill did not land (shape gate, cancel, or error). Restore
+ // exactly the voxels the clear removed so a failed step never leaves
+ // the labelmap empty; lastClick keeps the previous points/tolerance.
+ for (const index of clearedIndices) {
+ historyVm.setAtIndex(index, segmentIndex);
+ }
+ triggerSegmentationDataModified(segmentation.segmentationId);
+ throw err;
+ }
+ } finally {
+ // On success this pushes clear+refill as one undo step. On failure the
+ // in-place restore makes the memo a net no-op — still pushed, which
+ // covers the rare case of a partial paint before the error.
+ this.doneEditMemo();
+ }
+ }
+}
+
+export default ClickSegmentTool;
diff --git a/packages/tools/src/tools/annotation/regionSegmentHoverCursors.ts b/packages/tools/src/tools/annotation/regionSegmentHoverCursors.ts
new file mode 100644
index 0000000000..1cf9a93901
--- /dev/null
+++ b/packages/tools/src/tools/annotation/regionSegmentHoverCursors.ts
@@ -0,0 +1,33 @@
+/**
+ * Hover cursors shared by the click-driven region segmentation tools.
+ */
+
+/**
+ * Neutral circle cursor for states where segmentability is unknown (no active
+ * segmentation yet, probe not run, or a legacy strategy without hover info).
+ */
+export const CIRCLE_CURSOR =
+ "url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%3E%3Ccircle%20cx='12'%20cy='12'%20r='9'%20fill='none'%20stroke='%2300dc82'%20stroke-width='2'/%3E%3C/svg%3E\") 12 12, crosshair";
+
+/**
+ * "You can segment here": circle with a plus — the hover probe found a
+ * meaningful region candidate under the pointer.
+ */
+export const PLUS_CURSOR =
+ "url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%3E%3Ccircle%20cx='12'%20cy='12'%20r='9'%20fill='none'%20stroke='%2300dc82'%20stroke-width='2'/%3E%3Cpath%20d='M12%208v8M8%2012h8'%20stroke='%2300dc82'%20stroke-width='2'%20stroke-linecap='round'/%3E%3C/svg%3E\") 12 12, copy";
+
+/**
+ * "Not a good spot": prohibition sign — the probe found nothing segmentable
+ * (flat area, speck of noise, or an unbounded region).
+ */
+export const BLOCKED_CURSOR =
+ "url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%3E%3Ccircle%20cx='12'%20cy='12'%20r='9'%20fill='none'%20stroke='%23ff5a5a'%20stroke-width='2'/%3E%3Cpath%20d='M5.7%205.7L18.3%2018.3'%20stroke='%23ff5a5a'%20stroke-width='2'%20stroke-linecap='round'/%3E%3C/svg%3E\") 12 12, not-allowed";
+
+/**
+ * "Evaluating": dim gray dashed circle shown while the 3D confirmation is
+ * still running (or the spot cannot be evaluated yet). Deliberately NOT green
+ * — green is reserved for a confirmed plus, so a pending spot can never be
+ * mistaken for a segmentable one.
+ */
+export const PENDING_CURSOR =
+ "url(\"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='24'%20height='24'%3E%3Ccircle%20cx='12'%20cy='12'%20r='9'%20fill='none'%20stroke='%23a0a6ad'%20stroke-width='2'%20stroke-dasharray='4%203'/%3E%3C/svg%3E\") 12 12, progress";
diff --git a/packages/tools/src/tools/index.ts b/packages/tools/src/tools/index.ts
index 34ebd72aa0..d348e2db36 100644
--- a/packages/tools/src/tools/index.ts
+++ b/packages/tools/src/tools/index.ts
@@ -46,6 +46,7 @@ import KeyImageTool from './annotation/KeyImageTool';
import AnnotationEraserTool from './AnnotationEraserTool';
import RegionSegmentTool from './annotation/RegionSegmentTool';
import RegionSegmentPlusTool from './annotation/RegionSegmentPlusTool';
+import ClickSegmentTool from './annotation/ClickSegmentTool';
import WholeBodySegmentTool from './annotation/WholeBodySegmentTool';
import LabelmapBaseTool from './segmentation/LabelmapBaseTool';
@@ -133,6 +134,7 @@ export {
VolumeRotateTool,
RegionSegmentTool,
RegionSegmentPlusTool,
+ ClickSegmentTool,
WholeBodySegmentTool,
LabelmapBaseTool,
SegmentBidirectionalTool,
diff --git a/packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts b/packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts
new file mode 100644
index 0000000000..6d80eb659d
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/commitSliceMasksToLabelmap.ts
@@ -0,0 +1,191 @@
+import { cache, utilities, type Types } from '@cornerstonejs/core';
+import { FLOOD_SLICE_FLAG_VISITED } from './floodFillSliceLazy';
+
+const { VoxelManager } = utilities;
+
+type WritableScalarSlice = {
+ length: number;
+ fill: (value: number, start: number, end: number) => void;
+};
+
+/** Structural view of labelmap / per-slice voxel managers (avoids intersecting with private `scalarData`). */
+type LabelmapCommitVm = {
+ scalarData?: WritableScalarSlice | null;
+ modifiedSlices: Set;
+ boundsIJK: Types.BoundsIJK;
+ setAtIndex: (index: number, v: number) => unknown;
+};
+
+/**
+ * Writes slice visit masks into the labelmap using **row runs** (`TypedArray.fill` per run)
+ * when slice `scalarData` is available; otherwise falls back to `setAtIndex` per voxel.
+ *
+ * Also builds `floodedPoints` for downstream preview promotion / island removal.
+ *
+ * When `historyVoxelManager` is provided (an RLE history wrapper around the
+ * labelmap's voxel manager), every write goes through it per-voxel so the
+ * original values are recorded for undo — the dense `scalarData.fill` fast
+ * paths are skipped because they would bypass the history layer.
+ */
+export function commitSliceMasksToLabelmapVolume({
+ labelmapVolume,
+ sliceMasks,
+ width: w,
+ height: h,
+ paintIndex,
+ historyVoxelManager,
+}: {
+ labelmapVolume: Types.IImageVolume;
+ sliceMasks: Map;
+ width: number;
+ height: number;
+ paintIndex: number;
+ historyVoxelManager?: Types.IVoxelManager;
+}): { floodedPoints: Types.Point3[]; voxelCount: number } {
+ const floodedPoints: Types.Point3[] = [];
+ let voxelCount = 0;
+
+ const vm = labelmapVolume.voxelManager as unknown as LabelmapCommitVm;
+ const historyWriter = historyVoxelManager as unknown as
+ | LabelmapCommitVm
+ | undefined;
+ const [labelmapWidth, labelmapHeight, depth] = labelmapVolume.dimensions;
+ if (labelmapWidth !== w || labelmapHeight !== h) {
+ throw new Error(
+ `commitSliceMasksToLabelmapVolume: labelmap in-plane dimensions ` +
+ `${labelmapWidth}x${labelmapHeight} do not match mask dimensions ${w}x${h}`
+ );
+ }
+ const frameSize = w * h;
+ const expectedLen = frameSize * depth;
+
+ const zs = Array.from(sliceMasks.keys()).sort((a, b) => a - b);
+
+ for (let zi = 0; zi < zs.length; zi++) {
+ const z = zs[zi];
+ if (z < 0 || z >= depth) {
+ continue;
+ }
+ const flags = sliceMasks.get(z);
+ if (!flags || flags.length !== frameSize) {
+ continue;
+ }
+
+ let sliceTouched = false;
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ const appendRunFlooded = (y: number, x0: number, x1: number) => {
+ for (let xi = x0; xi < x1; xi++) {
+ floodedPoints.push([xi, y, z]);
+ }
+ voxelCount += x1 - x0;
+ sliceTouched = true;
+ minX = Math.min(minX, x0);
+ maxX = Math.max(maxX, x1 - 1);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ };
+
+ // History recording requires per-voxel writes through the wrapper; the
+ // run-fill fast paths write straight into scalar buffers and would leave
+ // the undo map empty.
+ const denseScalar =
+ !historyWriter &&
+ vm.scalarData &&
+ vm.scalarData.length >= expectedLen &&
+ typeof vm.scalarData.fill === 'function';
+
+ if (denseScalar) {
+ const base = z * frameSize;
+ const data = vm.scalarData as WritableScalarSlice;
+ for (let y = 0; y < h; y++) {
+ const row = y * w;
+ for (let x = 0; x < w; ) {
+ const li = row + x;
+ if (!(flags[li] & FLOOD_SLICE_FLAG_VISITED)) {
+ x++;
+ continue;
+ }
+ const x0 = x;
+ while (x < w && flags[row + x] & FLOOD_SLICE_FLAG_VISITED) {
+ x++;
+ }
+ data.fill(paintIndex, base + row + x0, base + row + x);
+ appendRunFlooded(y, x0, x);
+ }
+ }
+ } else {
+ const imageIds = labelmapVolume.imageIds;
+ const image =
+ imageIds?.length && z < imageIds.length
+ ? cache.getImage(imageIds[z])
+ : null;
+ const svm = image?.voxelManager as unknown as
+ | LabelmapCommitVm
+ | undefined;
+
+ if (
+ !historyWriter &&
+ svm?.scalarData &&
+ svm.scalarData.length >= frameSize &&
+ typeof svm.scalarData.fill === 'function'
+ ) {
+ const data = svm.scalarData as WritableScalarSlice;
+ for (let y = 0; y < h; y++) {
+ const row = y * w;
+ for (let x = 0; x < w; ) {
+ const li = row + x;
+ if (!(flags[li] & FLOOD_SLICE_FLAG_VISITED)) {
+ x++;
+ continue;
+ }
+ const x0 = x;
+ while (x < w && flags[row + x] & FLOOD_SLICE_FLAG_VISITED) {
+ x++;
+ }
+ data.fill(paintIndex, row + x0, row + x);
+ appendRunFlooded(y, x0, x);
+ }
+ }
+ svm.modifiedSlices.add(z);
+ } else {
+ const writer = historyWriter ?? vm;
+ for (let y = 0; y < h; y++) {
+ const row = y * w;
+ for (let x = 0; x < w; x++) {
+ if (!(flags[row + x] & FLOOD_SLICE_FLAG_VISITED)) {
+ continue;
+ }
+ const index = z * frameSize + row + x;
+ writer.setAtIndex(index, paintIndex);
+ floodedPoints.push([x, y, z]);
+ voxelCount++;
+ sliceTouched = true;
+ minX = Math.min(minX, x);
+ maxX = Math.max(maxX, x);
+ minY = Math.min(minY, y);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+ }
+
+ if (sliceTouched) {
+ vm.modifiedSlices.add(z);
+ if (
+ Number.isFinite(minX) &&
+ Number.isFinite(minY) &&
+ Number.isFinite(maxX) &&
+ Number.isFinite(maxY)
+ ) {
+ VoxelManager.addBounds(vm.boundsIJK, [minX, minY, z]);
+ VoxelManager.addBounds(vm.boundsIJK, [maxX, maxY, z]);
+ }
+ }
+ }
+
+ return { floodedPoints, voxelCount };
+}
diff --git a/packages/tools/src/utilities/segmentation/createEnsureSliceLoadedForVolume.ts b/packages/tools/src/utilities/segmentation/createEnsureSliceLoadedForVolume.ts
new file mode 100644
index 0000000000..c03f2fdf60
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/createEnsureSliceLoadedForVolume.ts
@@ -0,0 +1,54 @@
+import { imageLoader } from '@cornerstonejs/core';
+import type { Types } from '@cornerstonejs/core';
+
+/**
+ * Builds an idempotent per-slice loader for volumes backed by `imageIds` (e.g. streaming stacks).
+ * No-op when `imageIds` is missing or empty. Uses `imageLoader.loadImage` for the slice imageId.
+ *
+ * After a slice loads successfully once, it is remembered so callers (e.g. flood fill per voxel)
+ * do not re-enter `loadImage` or await redundant work on the same z index.
+ */
+export function createEnsureSliceLoadedForVolume(
+ volume: Types.IImageVolume
+): (z: number) => Promise {
+ const numSlices = volume.dimensions[2];
+ const imageIds = volume.imageIds;
+ if (!imageIds?.length) {
+ return async () => undefined;
+ }
+
+ const loadedSlices = new Set();
+ const inFlight = new Map>();
+
+ return async function ensureSliceLoaded(z: number): Promise {
+ if (!Number.isFinite(z) || z < 0 || z >= numSlices) {
+ return;
+ }
+
+ if (loadedSlices.has(z)) {
+ return;
+ }
+
+ const existing = inFlight.get(z);
+ if (existing) {
+ return existing;
+ }
+
+ const imageId = imageIds[z];
+ if (!imageId) {
+ return;
+ }
+
+ const promise = imageLoader
+ .loadImage(imageId)
+ .then(() => {
+ loadedSlices.add(z);
+ })
+ .finally(() => {
+ inFlight.delete(z);
+ });
+
+ inFlight.set(z, promise);
+ return promise;
+ };
+}
diff --git a/packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts b/packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
new file mode 100644
index 0000000000..c7ec70b168
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/floodFillIslandRemoval.ts
@@ -0,0 +1,207 @@
+import { utilities } from '@cornerstonejs/core';
+import type { Types } from '@cornerstonejs/core';
+import IslandRemoval, { SegmentationEnum } from './islandRemoval';
+
+const { logger } = utilities;
+const { growCutLog: islandRemovalLog } = logger;
+
+export { SegmentationEnum };
+
+/**
+ * {@link IslandRemoval} specialized for the one-click flood fill tools. It
+ * keeps the base algorithm untouched and adds:
+ *
+ * - **Painted-point tracking**: {@link getInternalFilledPoints} reports the
+ * exact hole voxels the last `removeInternalIslands` run painted, so callers
+ * can report the true final painted set without value-scanning slices (which
+ * would over-collect same-segment voxels from earlier operations).
+ * - **Delta-safe external removal**: `removeExternalIslands` only clears the
+ * voxels this run added (source value still equal to the preview index) and
+ * is skipped with a warning when no preview layer exists — clearing accepted
+ * voxels from earlier runs is never possible.
+ * - **Verbose diagnostics** behind the `verboseLogging` option.
+ */
+export default class FloodFillIslandRemoval extends IslandRemoval {
+ /** When true, log bounds, per-click flood, and voxel counts. */
+ private verboseLogging = false;
+ /**
+ * True when the caller supplied a real preview layer (the input voxel manager
+ * had a `sourceVoxelManager`). External island removal is only meaningful with
+ * a preview layer to hold the per-run delta. See {@link removeExternalIslands}.
+ */
+ private usingPreviewLayer = false;
+ /** Exact points the last removeInternalIslands run painted (filled holes). */
+ private internalFilledPoints: Types.Point3[] = [];
+
+ constructor(options?: {
+ maxInternalRemove?: number;
+ fillInternalEdge?: boolean;
+ verboseLogging?: boolean;
+ }) {
+ super(options);
+ this.verboseLogging = options?.verboseLogging ?? this.verboseLogging;
+ }
+
+ initialize(viewport, segmentationVoxels, options) {
+ this.usingPreviewLayer = !!segmentationVoxels.sourceVoxelManager;
+ const initialized = super.initialize(viewport, segmentationVoxels, options);
+ if (initialized && this.verboseLogging) {
+ const { segmentSet } = this;
+ islandRemovalLog.info('islandRemoval: initialize', {
+ segmentIndex: this.segmentIndex,
+ previewSegmentIndex: this.previewSegmentIndex,
+ segmentSetDimensions: {
+ width: segmentSet.width,
+ height: segmentSet.height,
+ depth: segmentSet.depth,
+ },
+ boundsIJKPrime: segmentSet.normalizer.boundsIJKPrime,
+ clickedPoints: this.selectedPoints,
+ segmentVoxelsInPlaneGrid: FloodFillIslandRemoval.countRleValueVolume(
+ segmentSet,
+ SegmentationEnum.SEGMENT
+ ),
+ });
+ }
+ return initialized;
+ }
+
+ /** Sum of run lengths in the RLE map for a given classification value. */
+ private static countRleValueVolume(
+ segmentSet: Types.RLEVoxelMap,
+ value: SegmentationEnum
+ ): number {
+ let n = 0;
+ segmentSet.forEach((_baseIndex, rle) => {
+ if (rle.value === value) {
+ n += rle.end - rle.start;
+ }
+ });
+ return n;
+ }
+
+ public floodFillSegmentIsland() {
+ if (this.verboseLogging) {
+ const { selectedPoints, segmentSet } = this;
+ const { fromIJK } = segmentSet.normalizer;
+ for (const clickedPoint of selectedPoints) {
+ const ijkPrime = fromIJK(clickedPoint);
+ const atClick = segmentSet.get(segmentSet.toIndex(ijkPrime));
+ if (atClick !== SegmentationEnum.SEGMENT) {
+ islandRemovalLog.info(
+ 'islandRemoval: floodFillSegmentIsland click skipped (not SEGMENT)',
+ {
+ clickedPointVolumeIJK: clickedPoint,
+ ijkPrime,
+ segmentSetAtIndex: atClick,
+ }
+ );
+ }
+ }
+ }
+
+ const floodedCount = super.floodFillSegmentIsland();
+
+ if (this.verboseLogging) {
+ islandRemovalLog.info('islandRemoval: floodFillSegmentIsland done', {
+ totalIslandVoxels: floodedCount,
+ islandVoxelsAfterFlood: FloodFillIslandRemoval.countRleValueVolume(
+ this.segmentSet,
+ SegmentationEnum.ISLAND
+ ),
+ });
+ }
+
+ return floodedCount;
+ }
+
+ /**
+ * Clear segment voxels not connected (in segmentSet topology) to the click.
+ *
+ * External island removal is inherently a delta operation: it clears the
+ * voxels this run added that are not connected to the click. That delta only
+ * exists on a preview layer. When the caller passed a plain segmentation
+ * voxel manager (no preview layer), `initialize` synthesizes a history
+ * wrapper whose source still holds the accepted voxels, so clearing the
+ * preview override reverts straight back to the accepted value — a no-op for
+ * accepted data. Warn so the skipped cleanup is explicit rather than silent.
+ *
+ * @returns Number of voxels cleared in the labelmap.
+ */
+ public removeExternalIslands(): number {
+ const { previewVoxelManager, segmentSet } = this;
+ const { toIJK } = segmentSet.normalizer;
+ const sourceVoxelManager =
+ previewVoxelManager.sourceVoxelManager ?? previewVoxelManager;
+
+ if (!this.usingPreviewLayer) {
+ islandRemovalLog.warn(
+ 'islandRemoval: removeExternalIslands has no preview layer; ' +
+ 'external island cleanup of accepted voxels is skipped. ' +
+ 'Run island removal through a preview layer to clear external islands.'
+ );
+ return 0;
+ }
+
+ // Next, iterate over all points which were set to a new value in the preview
+ // For everything NOT connected to something in set of clicked points,
+ // remove it from the preview.
+ let clearedVoxels = 0;
+
+ 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]);
+ // The preview layer here is a WRITE-THROUGH history voxel manager:
+ // the source already holds this run's values, so reading the source
+ // identifies candidates, and `set(null)` reverts a voxel to its
+ // pre-run value only if this run modified it (no-op otherwise) —
+ // accepted voxels from earlier runs are left untouched.
+ const sourceVal = sourceVoxelManager.getAtIJKPoint(clearPoint);
+ if (sourceVal === this.previewSegmentIndex) {
+ previewVoxelManager.setAtIJKPoint(clearPoint, null);
+ clearedVoxels += 1;
+ }
+ }
+ }
+ };
+
+ segmentSet.forEach(callback, { rowModified: true });
+
+ if (this.verboseLogging) {
+ islandRemovalLog.info('islandRemoval: removeExternalIslands', {
+ clearedVoxels,
+ });
+ }
+
+ return clearedVoxels;
+ }
+
+ public removeInternalIslands() {
+ this.internalFilledPoints = [];
+ const modifiedSlices = super.removeInternalIslands();
+ if (this.verboseLogging) {
+ islandRemovalLog.info('islandRemoval: removeInternalIslands', {
+ modifiedSliceCount: modifiedSlices?.length,
+ internalFilledPoints: this.internalFilledPoints.length,
+ maxInternalRemove: this.maxInternalRemove,
+ });
+ }
+ return modifiedSlices;
+ }
+
+ protected onInternalPointFilled(point: Types.Point3): void {
+ this.internalFilledPoints.push(point);
+ }
+
+ /**
+ * Exact points the last {@link removeInternalIslands} call painted — the
+ * filled internal holes, beyond the caller's own flood. Lets callers report
+ * the true final painted set without value-scanning slices (which would
+ * over-collect same-segment voxels from earlier operations).
+ */
+ public getInternalFilledPoints(): Types.Point3[] {
+ return this.internalFilledPoints;
+ }
+}
diff --git a/packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts b/packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts
new file mode 100644
index 0000000000..85a359ea43
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/floodFillSliceLazy.ts
@@ -0,0 +1,263 @@
+import type { Types } from '@cornerstonejs/core';
+
+/** Visited in slice-lazy flood (bit 0). Room for more flags later (e.g. queued). */
+export const FLOOD_SLICE_FLAG_VISITED = 1;
+
+export type FloodFillSliceLazyOptions = {
+ width: number;
+ height: number;
+ depth: number;
+ equals: (node: unknown, startNode: unknown) => boolean;
+ ensureSliceLoaded?: (z: number) => Promise;
+ yieldEvery?: number;
+ planar?: boolean;
+ /**
+ * Optional bound in index-k from the seed slice: allow only voxels with
+ * `abs(k - seedK) <= maxDeltaK`.
+ */
+ maxDeltaK?: number;
+ /**
+ * Optional in-slice bound from the seed pixel: allow only voxels with
+ * `abs(i - seedI) <= maxDeltaIJ` AND `abs(j - seedJ) <= maxDeltaIJ`.
+ */
+ maxDeltaIJ?: number;
+ /** Cooperative cancellation hook; returns true to stop at next checkpoint. */
+ isCancelled?: () => boolean;
+ /**
+ * Compute-safety budget on filled voxels: the flood stops as soon as more
+ * voxels than this would fill and the result is flagged `truncated`.
+ */
+ maxVoxels?: number;
+ /**
+ * Periodic region-shape gate: called every `validateEvery` filled voxels
+ * with the running stats; returning false stops the flood and flags
+ * `truncated`. Lets callers reject non-coherent regions (e.g. a sprawling
+ * bone web whose shape is nothing like a lesion) long before the budget.
+ */
+ shouldContinue?: (stats: {
+ voxelCount: number;
+ bbox: { min: Types.Point3; max: Types.Point3 };
+ }) => boolean;
+ /** How often (in filled voxels) `shouldContinue` runs. Default 2048. */
+ validateEvery?: number;
+};
+
+export type FloodFillSliceLazyResult = {
+ /** Only slices that received at least one filled voxel are present. */
+ sliceMasks: Map;
+ voxelCount: number;
+ /** True when the fill was stopped by `maxVoxels` or `shouldContinue`. */
+ truncated: boolean;
+ /** Bounding box (inclusive, ijk) of the filled region; null when empty. */
+ bbox: { min: Types.Point3; max: Types.Point3 } | null;
+};
+
+/**
+ * 3D flood fill with **per-slice** `Uint8Array` visit masks (allocated on demand per k).
+ * Uses BFS and a packed linear queue to avoid `Set` hashing and a single giant 3D buffer.
+ */
+export async function floodFill3dSliceLazy(
+ getter: (x: number, y: number, z: number) => number | undefined,
+ seed: Types.Point3,
+ options: FloodFillSliceLazyOptions
+): Promise {
+ const {
+ width: w,
+ height: h,
+ depth: d,
+ equals,
+ ensureSliceLoaded,
+ yieldEvery = 500,
+ planar = false,
+ maxDeltaK,
+ maxDeltaIJ,
+ isCancelled,
+ maxVoxels,
+ shouldContinue,
+ validateEvery = 2048,
+ } = options;
+
+ const [sx, sy, sz] = seed;
+ if (sx < 0 || sx >= w || sy < 0 || sy >= h || sz < 0 || sz >= d) {
+ return {
+ sliceMasks: new Map(),
+ voxelCount: 0,
+ truncated: false,
+ bbox: null,
+ };
+ }
+
+ if (ensureSliceLoaded) {
+ await ensureSliceLoaded(sz);
+ }
+
+ const startNode = getter(sx, sy, sz);
+ if (!equals(startNode, startNode)) {
+ return {
+ sliceMasks: new Map(),
+ voxelCount: 0,
+ truncated: false,
+ bbox: null,
+ };
+ }
+
+ const frameSize = w * h;
+ const sliceMasks = new Map();
+
+ function sliceFlags(z: number): Uint8Array {
+ let a = sliceMasks.get(z);
+ if (!a) {
+ a = new Uint8Array(frameSize);
+ sliceMasks.set(z, a);
+ }
+ return a;
+ }
+
+ function isVisited(z: number, x: number, y: number): boolean {
+ const a = sliceMasks.get(z);
+ if (!a) {
+ return false;
+ }
+ return (a[y * w + x] & FLOOD_SLICE_FLAG_VISITED) !== 0;
+ }
+
+ function setVisited(z: number, x: number, y: number): void {
+ sliceFlags(z)[y * w + x] |= FLOOD_SLICE_FLAG_VISITED;
+ }
+
+ function pack(x: number, y: number, z: number): number {
+ return z * frameSize + y * w + x;
+ }
+
+ function unpack(p: number): [number, number, number] {
+ const x = p % w;
+ const t1 = Math.floor(p / w);
+ const y = t1 % h;
+ const z = Math.floor(t1 / h);
+ return [x, y, z];
+ }
+
+ const dirs = planar
+ ? [
+ [1, 0, 0],
+ [-1, 0, 0],
+ [0, 1, 0],
+ [0, -1, 0],
+ ]
+ : [
+ [1, 0, 0],
+ [-1, 0, 0],
+ [0, 1, 0],
+ [0, -1, 0],
+ [0, 0, 1],
+ [0, 0, -1],
+ ];
+
+ const queue: number[] = [];
+ let qh = 0;
+ queue.push(pack(sx, sy, sz));
+ setVisited(sz, sx, sy);
+
+ let minX = sx;
+ let maxX = sx;
+ let minY = sy;
+ let maxY = sy;
+ let minZ = sz;
+ let maxZ = sz;
+ const currentBBox = () => ({
+ min: [minX, minY, minZ] as Types.Point3,
+ max: [maxX, maxY, maxZ] as Types.Point3,
+ });
+
+ let steps = 0;
+ let truncated = false;
+ let nextValidateAt = validateEvery;
+
+ while (qh < queue.length) {
+ if (isCancelled?.()) {
+ break;
+ }
+ if (maxVoxels !== undefined && queue.length > maxVoxels) {
+ truncated = true;
+ break;
+ }
+ if (shouldContinue && queue.length >= nextValidateAt) {
+ nextValidateAt += validateEvery;
+ if (!shouldContinue({ voxelCount: queue.length, bbox: currentBBox() })) {
+ truncated = true;
+ break;
+ }
+ }
+
+ steps++;
+ if (yieldEvery > 0 && steps % yieldEvery === 0) {
+ await new Promise((r) => setTimeout(r, 0));
+ }
+
+ const p = queue[qh++];
+ const [x, y, z] = unpack(p);
+
+ for (let di = 0; di < dirs.length; di++) {
+ const nx = x + dirs[di][0];
+ const ny = y + dirs[di][1];
+ const nz = z + dirs[di][2];
+ if (nx < 0 || nx >= w || ny < 0 || ny >= h || nz < 0 || nz >= d) {
+ continue;
+ }
+ if (maxDeltaK >= 0 && Math.abs(nz - sz) > maxDeltaK) {
+ continue;
+ }
+ if (
+ maxDeltaIJ >= 0 &&
+ (Math.abs(nx - sx) > maxDeltaIJ || Math.abs(ny - sy) > maxDeltaIJ)
+ ) {
+ continue;
+ }
+ if (planar && nz !== sz) {
+ continue;
+ }
+ if (isVisited(nz, nx, ny)) {
+ continue;
+ }
+ if (ensureSliceLoaded) {
+ await ensureSliceLoaded(nz);
+ if (isCancelled?.()) {
+ break;
+ }
+ }
+ const nv = getter(nx, ny, nz);
+ if (!equals(nv, startNode)) {
+ continue;
+ }
+ setVisited(nz, nx, ny);
+ queue.push(pack(nx, ny, nz));
+ if (nx < minX) {
+ minX = nx;
+ } else if (nx > maxX) {
+ maxX = nx;
+ }
+ if (ny < minY) {
+ minY = ny;
+ } else if (ny > maxY) {
+ maxY = ny;
+ }
+ if (nz < minZ) {
+ minZ = nz;
+ } else if (nz > maxZ) {
+ maxZ = nz;
+ }
+ }
+ }
+
+ // Every setVisited is paired with exactly one queue.push (seed included),
+ // and the queue is head-pointer based (never popped), so its length is the
+ // filled count — no need to rescan the allocated full-frame masks.
+ const voxelCount = queue.length;
+
+ return {
+ sliceMasks,
+ voxelCount,
+ truncated,
+ bbox: voxelCount > 0 ? currentBBox() : null,
+ };
+}
diff --git a/packages/tools/src/utilities/segmentation/growCut/floodFillIntensityRangeTypes.ts b/packages/tools/src/utilities/segmentation/growCut/floodFillIntensityRangeTypes.ts
new file mode 100644
index 0000000000..eb8b3d0e62
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/growCut/floodFillIntensityRangeTypes.ts
@@ -0,0 +1,68 @@
+import type { Types } from '@cornerstonejs/core';
+import type { ViewportVoiMappingForTool } from './getViewportVoiMappingForVolume';
+
+export type FloodFillIntensityRangeDiagnostics = {
+ neighborhoodMean: number;
+ neighborhoodStdDev: number;
+ clickedVoxelValue: number;
+ positiveStdDevMultiplier: number;
+ neighborhoodRadius: number;
+ strategy?: string;
+ strategyMode?: 'triClass' | 'exactRange';
+ mappedBand?: { min: number; max: number };
+ canvasSampleCount?: number;
+ /** True when luma-derived raw band missed the seed; interval was recentred on the voxel scalar. */
+ canvasDiskBandCenteredOnSeed?: boolean;
+ /** Canvas disk samples excluded dominant 0s (letterbox / unused canvas). */
+ canvasDiskLetterboxTrimmed?: boolean;
+ /** Populated by the adaptive (config-less) region strategy. */
+ adaptive?: {
+ /** Chosen threshold depth in display-byte units (0..255). */
+ toleranceBytes: number;
+ /** In-plane region size (pixels) at the chosen tolerance. */
+ regionSizePx: number;
+ /** In-plane region footprint (mm²) at the chosen tolerance. */
+ regionAreaMm2?: number;
+ /** Window-perimeter median display byte used as background estimate. */
+ backgroundByte: number;
+ /** Seed reference display byte (3x3 median at the snapped seed). */
+ seedByte: number;
+ /** +1 = region brighter than surroundings, -1 = darker. */
+ polarity?: number;
+ /** True when the seed moved off the clicked pixel onto higher contrast. */
+ seedSnapped: boolean;
+ /** Analysis window size (in-plane pixels). */
+ windowSize: [number, number];
+ /** Region growth change points: [toleranceLevel, regionSizePx]. */
+ growthCurve?: Array<[number, number]>;
+ /** Tolerance level at which the region exceeded the size cap (-1 if never). */
+ explosionLevel?: number;
+ /** Tolerance level at which the region first touched the window border (-1 if never). */
+ borderTouchLevel?: number;
+ };
+};
+
+export type FloodFillIntensityRangeResult = {
+ min: number;
+ max: number;
+ ijkStart: Types.Point3;
+ diagnostics: FloodFillIntensityRangeDiagnostics;
+};
+
+export type FloodFillIntensityRangeOptions = {
+ positiveStdDevMultiplier?: number;
+ initialNeighborhoodRadius?: number;
+ /** When set, neighborhood stats use mapped intensities and band is inversed to raw. */
+ voiMapping?: ViewportVoiMappingForTool | null;
+ viewport?: Types.IViewport;
+ element?: HTMLDivElement;
+ referencedVolumeId?: string;
+ canvasPoint?: { x: number; y: number };
+ canvasDiskRadiusPx?: number;
+};
+
+export type GetFloodFillIntensityRange = (
+ referencedVolume: Types.IImageVolume,
+ worldPosition: Types.Point3,
+ options?: FloodFillIntensityRangeOptions
+) => FloodFillIntensityRangeResult | null;
diff --git a/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts b/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts
new file mode 100644
index 0000000000..1e2a58b8d6
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/growCut/getViewportVoiMappingForVolume.ts
@@ -0,0 +1,44 @@
+import type { Types } from '@cornerstonejs/core';
+
+export type ViewportVoiMappingForTool = {
+ voiRange: { lower: number; upper: number };
+ VOILUTFunction?: string;
+ /** Viewport invert flag (e.g. PET AC). Needed so display luma inverse-maps to the right raw end. */
+ invert?: boolean;
+};
+
+type ViewportWithProps = Types.IViewport & {
+ getProperties?: (volumeId?: string) => {
+ voiRange?: { lower: number; upper: number };
+ VOILUTFunction?: string;
+ invert?: boolean;
+ } | null;
+};
+
+/**
+ * Reads VOI + LUT function from a volume or stack viewport for intensity mapping.
+ */
+export function getViewportVoiMappingForVolume(
+ viewport: Types.IViewport,
+ volumeId?: string
+): ViewportVoiMappingForTool | null {
+ const getProps = (viewport as ViewportWithProps).getProperties;
+ if (typeof getProps !== 'function') {
+ return null;
+ }
+ const props = volumeId
+ ? getProps.call(viewport, volumeId)
+ : getProps.call(viewport);
+ if (!props?.voiRange) {
+ return null;
+ }
+ const { lower, upper } = props.voiRange;
+ if (typeof lower !== 'number' || typeof upper !== 'number') {
+ return null;
+ }
+ return {
+ voiRange: { lower, upper },
+ VOILUTFunction: props.VOILUTFunction,
+ invert: props.invert === true,
+ };
+}
diff --git a/packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts b/packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts
new file mode 100644
index 0000000000..28fcd53f27
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.ts
@@ -0,0 +1,902 @@
+import { utilities as csUtils } from '@cornerstonejs/core';
+import type { Types } from '@cornerstonejs/core';
+import type { VoxelManager } from '@cornerstonejs/core/utilities';
+import type {
+ FloodFillIntensityRangeOptions,
+ FloodFillIntensityRangeResult,
+} from '../floodFillIntensityRangeTypes';
+import type { GetFloodFillIntensityRange } from '../floodFillIntensityRangeTypes';
+import { getViewportVoiMappingForVolume } from '../getViewportVoiMappingForVolume';
+import type { ViewportVoiMappingForTool } from '../getViewportVoiMappingForVolume';
+import { DEFAULT_POSITIVE_STD_DEV_MULTIPLIER } from '../constants';
+
+type NumberVoxelManager = VoxelManager;
+
+const {
+ transformWorldToIndex,
+ mapScalarToViewportVoiIntensity,
+ mapViewportVoiIntensityToScalar,
+ getVolumeDirectionVectors,
+} = csUtils;
+const { growCutLog: log } = csUtils.logger;
+
+/**
+ * Adaptive one-click region strategy ("no disk"): from a single click the
+ * strategy inspects an in-plane analysis window around the pointer, snaps the
+ * seed onto the most locally-contrasting pixel (so clicking on/near a region
+ * edge still lands inside it), determines the region polarity (brighter or
+ * darker than its surroundings), then sweeps every possible one-sided
+ * intensity threshold in one pass and picks the threshold whose region stays
+ * stable over the widest span before leaking into the surroundings.
+ *
+ * The band is **one-sided**: for a hot region it is "everything at least this
+ * intense" with no upper limit, so the hottest core of a PET lesion can never
+ * be excluded (no interior holes); symmetrically for dark regions. A segment
+ * is only "meaningful" when its in-plane footprint falls within physical
+ * (mm²) lesion-scale bounds — a whole PET body section or a speck of noise is
+ * rejected, and callers surface that as a blocked cursor / no-op click.
+ *
+ * It is intentionally configuration-free: all constants below are internal.
+ */
+
+/** Analysis half-window (in-plane voxels) centered on the click. */
+const WINDOW_RADIUS_PX = 96;
+/** Seed snapping search radius (in-plane voxels) around the clicked pixel. */
+const SEED_SNAP_RADIUS_PX = 4;
+/** Hard floor in pixels regardless of spacing (sub-resolution specks). */
+const MIN_REGION_PX = 6;
+/** Smallest meaningful in-plane footprint (≈3 mm diameter). */
+const MIN_REGION_MM2 = 8;
+/**
+ * Analyzability guard, not a size rule: a region that floods most of the
+ * analysis window has no boundary we can see, so it reads as unbounded.
+ * There is intentionally no physical maximum — large lesions are legitimate;
+ * entity coherence is judged in 3D by the tool (shape gate), not by size.
+ */
+const MAX_REGION_WINDOW_FRACTION = 0.6;
+/** Local contrast (display bytes) below which the vicinity is considered flat. */
+const FLAT_CONTRAST_BYTES = 10;
+/** Minimum threshold depth (display bytes) so the fill tolerates noise. */
+const MIN_TOLERANCE_BYTES = 2;
+/**
+ * A region only counts as properly bounded when its growth stays quiet over at
+ * least this many tolerance levels (its stability run). Areas that grow
+ * substantially at every tolerance never form one and are rejected as
+ * unbounded.
+ */
+const MIN_PLATEAU_WIDTH_BYTES = 4;
+/** Per-level growth (pixels) always considered quiet (boundary trickle). */
+const QUIET_GAIN_ABS_PX = 3;
+/** Per-level growth below this fraction of the region is considered quiet. */
+const QUIET_GAIN_FRACTION = 0.02;
+
+export type AdaptiveRegionProbeReason =
+ | 'ok'
+ | 'outside-volume'
+ | 'flat-region'
+ | 'too-small'
+ | 'unbounded'
+ /** A region exists nearby but the pointer itself is not on it. */
+ | 'off-target';
+
+/**
+ * Everything needed to rebuild the intensity band at a different tolerance
+ * after the click — the basis for trustworthy, deterministic expand/shrink
+ * (each step moves along the recorded growth curve instead of re-guessing).
+ */
+export type AdaptiveRegionExpandContext = {
+ /** Seed reference display byte. */
+ seedByte: number;
+ /** +1 = region brighter than surroundings, -1 = darker. */
+ polarity: 1 | -1;
+ /** VOI mapping snapshot taken at click time (display-space stability). */
+ voiMapping: ViewportVoiMappingForTool | null;
+ /** Raw normalization fallback when no VOI mapping was available. */
+ rawWindow: { min: number; span: number };
+ /** Snapped seed (volume ijk). */
+ seedIjk: Types.Point3;
+ /** Raw scalar at the snapped seed. */
+ seedScalar: number;
+ /** Region growth change points: [toleranceLevel, regionSizePx]. */
+ growthCurve: Array<[number, number]>;
+ /** Tolerance the click ran with. */
+ chosenToleranceBytes: number;
+ /** Last tolerance level covered by the sweep. */
+ growthEndLevel: number;
+ /** mm² covered by one in-plane pixel. */
+ pxAreaMm2: number;
+};
+
+export type AdaptiveRegionProbeResult = {
+ /** True when a proper (bounded, non-trivial) region was found at the click. */
+ viable: boolean;
+ reason: AdaptiveRegionProbeReason;
+ /** Intensity band + seed for the flood fill; null when not viable. */
+ range: FloodFillIntensityRangeResult | null;
+ /** In-plane region size (pixels) at the chosen tolerance. */
+ regionSizePx?: number;
+ /** In-plane region footprint (mm²) at the chosen tolerance. */
+ regionAreaMm2?: number;
+ /** Chosen threshold depth in display-byte units (0..255). */
+ toleranceBytes?: number;
+ /**
+ * Present when viable: tolerance level at which each of the clicked pixel's
+ * 4 in-plane neighbors ([-A, +A, -B, +B]) joined the region during the
+ * sweep (-1 = never joined, null = outside the volume). Lets hover
+ * consensus checks vote from this one sweep instead of re-probing each
+ * neighbor with its own full window analysis.
+ */
+ clickNeighborJoinLevels?: Array;
+ /** Present when viable: context for deterministic expand/shrink. */
+ expandContext?: AdaptiveRegionExpandContext;
+ /** Growth internals for the selection stage (present on its failures). */
+ debug?: {
+ /** Region growth change points: [toleranceLevel, regionSizePx]. */
+ growthCurve: Array<[number, number]>;
+ /** Quiet runs: [startLevel, endLevel, regionSizePx]. */
+ quietRuns: Array<[number, number, number]>;
+ seedByte: number;
+ backgroundByte: number;
+ polarity: number;
+ borderTouchLevel: number;
+ explosionLevel: number;
+ /** Tolerance level at which the clicked pixel joined the region (-1 = never). */
+ clickJoinLevel: number;
+ minRegionPx: number;
+ maxRegionPx: number;
+ pxAreaMm2: number;
+ };
+};
+
+export type AdaptiveRegionCoreInput = {
+ dimensions: Types.Point3;
+ /** Raw scalar accessor; may return undefined/NaN for missing data. */
+ getScalar: (i: number, j: number, k: number) => number;
+ /** Clicked voxel (ijk, already rounded/bounded by the caller). */
+ ijkClick: Types.Point3;
+ /** The two in-plane axis indices (0=i, 1=j, 2=k). Defaults to [0, 1]. */
+ inPlaneAxes?: [number, number];
+ /** mm per pixel along the two in-plane axes. Defaults to [1, 1]. */
+ inPlaneSpacing?: [number, number];
+ /** Viewport VOI mapping; when absent, bytes use window-local raw min/max. */
+ voiMapping?: ViewportVoiMappingForTool | null;
+ /**
+ * Band scale relative to the default: >1 widens (expand), <1 narrows
+ * (shrink). Wired from legacy shrink/expand actions.
+ */
+ toleranceScale?: number;
+};
+
+function medianOf(values: number[]): number {
+ if (!values.length) {
+ return 0;
+ }
+ const sorted = values.slice().sort((a, b) => a - b);
+ return sorted[Math.floor(sorted.length / 2)];
+}
+
+/**
+ * One-sided raw band for a threshold depth below the seed's elevation. The
+ * included side (at and beyond the seed's intensity, in display terms) is
+ * unbounded so a lesion's hottest core is always inside the fill.
+ */
+function rawBandForTolerance(
+ context: Pick<
+ AdaptiveRegionExpandContext,
+ 'seedByte' | 'polarity' | 'voiMapping' | 'rawWindow' | 'seedScalar'
+ >,
+ toleranceBytes: number
+): { min: number; max: number } {
+ const { seedByte, polarity, voiMapping, rawWindow, seedScalar } = context;
+ const thresholdByte = Math.max(
+ 0,
+ Math.min(
+ 255,
+ polarity > 0 ? seedByte - toleranceBytes : seedByte + toleranceBytes
+ )
+ );
+ const includedExtremeByte = polarity > 0 ? 255 : 0;
+
+ const rawFromByte = (byte: number): number =>
+ voiMapping
+ ? mapViewportVoiIntensityToScalar(byte / 255, voiMapping)
+ : rawWindow.min + (byte / 255) * rawWindow.span;
+
+ const rawThreshold = rawFromByte(thresholdByte);
+ const rawExtreme = rawFromByte(includedExtremeByte);
+
+ let min: number;
+ let max: number;
+ if (rawExtreme >= rawThreshold) {
+ min = rawThreshold;
+ max = Infinity;
+ } else {
+ min = -Infinity;
+ max = rawThreshold;
+ }
+ if (Number.isFinite(seedScalar)) {
+ min = Math.min(min, seedScalar);
+ max = Math.max(max, seedScalar);
+ }
+ return { min, max };
+}
+
+/**
+ * Rebuilds the flood-fill intensity range for a stored click context at a new
+ * tolerance. Used by expand/shrink so each step is deterministic against the
+ * click-time growth curve (and immune to later window/level changes).
+ */
+export function resolveAdaptiveBandAtTolerance(
+ context: AdaptiveRegionExpandContext,
+ toleranceBytes: number
+): FloodFillIntensityRangeResult {
+ const tolerance = Math.max(0, Math.min(255, Math.round(toleranceBytes)));
+ const { min, max } = rawBandForTolerance(context, tolerance);
+ return {
+ min,
+ max,
+ ijkStart: [...context.seedIjk] as Types.Point3,
+ diagnostics: {
+ neighborhoodMean: Number.isFinite(context.seedScalar)
+ ? context.seedScalar
+ : 0,
+ neighborhoodStdDev: 0,
+ clickedVoxelValue: Number.isFinite(context.seedScalar)
+ ? context.seedScalar
+ : 0,
+ positiveStdDevMultiplier: 1,
+ neighborhoodRadius: SEED_SNAP_RADIUS_PX,
+ strategy: 'adaptiveRegion:atTolerance',
+ adaptive: {
+ toleranceBytes: tolerance,
+ regionSizePx: -1,
+ backgroundByte: -1,
+ seedByte: context.seedByte,
+ seedSnapped: false,
+ windowSize: [0, 0],
+ polarity: context.polarity,
+ },
+ },
+ };
+}
+
+/**
+ * Core of the adaptive probe, operating on an abstract scalar accessor so it
+ * is unit-testable without viewports or caches. See module doc for the idea.
+ */
+export function probeAdaptiveRegionCore(
+ input: AdaptiveRegionCoreInput
+): AdaptiveRegionProbeResult {
+ const { dimensions, getScalar, ijkClick, voiMapping } = input;
+ const inPlaneAxes = input.inPlaneAxes ?? [0, 1];
+ const inPlaneSpacing = input.inPlaneSpacing ?? [1, 1];
+
+ for (let axis = 0; axis < 3; axis++) {
+ if (ijkClick[axis] < 0 || ijkClick[axis] >= dimensions[axis]) {
+ return { viable: false, reason: 'outside-volume', range: null };
+ }
+ }
+
+ const [axisA, axisB] = inPlaneAxes;
+ const a0 = Math.max(0, ijkClick[axisA] - WINDOW_RADIUS_PX);
+ const a1 = Math.min(
+ dimensions[axisA] - 1,
+ ijkClick[axisA] + WINDOW_RADIUS_PX
+ );
+ const b0 = Math.max(0, ijkClick[axisB] - WINDOW_RADIUS_PX);
+ const b1 = Math.min(
+ dimensions[axisB] - 1,
+ ijkClick[axisB] + WINDOW_RADIUS_PX
+ );
+ const wA = a1 - a0 + 1;
+ const wB = b1 - b0 + 1;
+ const windowArea = wA * wB;
+
+ const spacingA =
+ Number.isFinite(inPlaneSpacing[0]) && inPlaneSpacing[0] > 0
+ ? inPlaneSpacing[0]
+ : 1;
+ const spacingB =
+ Number.isFinite(inPlaneSpacing[1]) && inPlaneSpacing[1] > 0
+ ? inPlaneSpacing[1]
+ : 1;
+ const pxAreaMm2 = spacingA * spacingB;
+
+ // Minimum is physical (specks are not lesions); maximum is only the
+ // analyzability guard relative to the window.
+ const minRegionPx = Math.max(
+ MIN_REGION_PX,
+ Math.ceil(MIN_REGION_MM2 / pxAreaMm2)
+ );
+ const maxRegionPx = Math.max(
+ minRegionPx + 1,
+ Math.floor(windowArea * MAX_REGION_WINDOW_FRACTION)
+ );
+
+ const scalarAt = (x: number, y: number): number => {
+ const ijk: Types.Point3 = [...ijkClick] as Types.Point3;
+ ijk[axisA] = a0 + x;
+ ijk[axisB] = b0 + y;
+ return Number(getScalar(ijk[0], ijk[1], ijk[2]));
+ };
+
+ // Pass 1: raw scalars (kept for the no-VOI fallback normalization).
+ const rawValues = new Float64Array(windowArea);
+ let rawMinInWindow = Infinity;
+ let rawMaxInWindow = -Infinity;
+ for (let y = 0; y < wB; y++) {
+ for (let x = 0; x < wA; x++) {
+ const v = scalarAt(x, y);
+ rawValues[y * wA + x] = v;
+ if (Number.isFinite(v)) {
+ if (v < rawMinInWindow) {
+ rawMinInWindow = v;
+ }
+ if (v > rawMaxInWindow) {
+ rawMaxInWindow = v;
+ }
+ }
+ }
+ }
+ if (!Number.isFinite(rawMinInWindow)) {
+ return { viable: false, reason: 'outside-volume', range: null };
+ }
+
+ // Display bytes 0..255 (what the user sees); -1 marks missing samples.
+ const rawSpan = rawMaxInWindow - rawMinInWindow || 1;
+ const bytes = new Int16Array(windowArea);
+ for (let idx = 0; idx < windowArea; idx++) {
+ const v = rawValues[idx];
+ if (!Number.isFinite(v)) {
+ bytes[idx] = -1;
+ continue;
+ }
+ const mapped = voiMapping
+ ? mapScalarToViewportVoiIntensity(v, voiMapping)
+ : (v - rawMinInWindow) / rawSpan;
+ bytes[idx] = Math.max(0, Math.min(255, Math.round(mapped * 255)));
+ }
+
+ // Background estimate: median display byte on the window perimeter.
+ const ringBytes: number[] = [];
+ for (let x = 0; x < wA; x++) {
+ if (bytes[x] >= 0) {
+ ringBytes.push(bytes[x]);
+ }
+ const bottom = (wB - 1) * wA + x;
+ if (wB > 1 && bytes[bottom] >= 0) {
+ ringBytes.push(bytes[bottom]);
+ }
+ }
+ for (let y = 1; y < wB - 1; y++) {
+ const left = y * wA;
+ const right = y * wA + (wA - 1);
+ if (bytes[left] >= 0) {
+ ringBytes.push(bytes[left]);
+ }
+ if (wA > 1 && bytes[right] >= 0) {
+ ringBytes.push(bytes[right]);
+ }
+ }
+ const backgroundByte = medianOf(ringBytes);
+
+ // Seed snap: most locally-contrasting pixel near the click, so an edge
+ // click lands inside the structure instead of on its partial-volume shell.
+ const clickX = ijkClick[axisA] - a0;
+ const clickY = ijkClick[axisB] - b0;
+ let seedX = clickX;
+ let seedY = clickY;
+ let bestContrast = -1;
+ let bestDist = Infinity;
+ const snapR2 = SEED_SNAP_RADIUS_PX * SEED_SNAP_RADIUS_PX;
+ for (let dy = -SEED_SNAP_RADIUS_PX; dy <= SEED_SNAP_RADIUS_PX; dy++) {
+ for (let dx = -SEED_SNAP_RADIUS_PX; dx <= SEED_SNAP_RADIUS_PX; dx++) {
+ if (dx * dx + dy * dy > snapR2) {
+ continue;
+ }
+ const x = clickX + dx;
+ const y = clickY + dy;
+ if (x < 0 || x >= wA || y < 0 || y >= wB) {
+ continue;
+ }
+ const byte = bytes[y * wA + x];
+ if (byte < 0) {
+ continue;
+ }
+ const contrast = Math.abs(byte - backgroundByte);
+ const dist = dx * dx + dy * dy;
+ if (
+ contrast > bestContrast ||
+ (contrast === bestContrast && dist < bestDist)
+ ) {
+ bestContrast = contrast;
+ bestDist = dist;
+ seedX = x;
+ seedY = y;
+ }
+ }
+ }
+
+ if (bestContrast < 0) {
+ return { viable: false, reason: 'outside-volume', range: null };
+ }
+ if (bestContrast < FLAT_CONTRAST_BYTES) {
+ return { viable: false, reason: 'flat-region', range: null };
+ }
+
+ // Region polarity: brighter or darker than the surroundings. All growth is
+ // then computed in "elevation" space where the structure is always high.
+ const seedPixelByte = bytes[seedY * wA + seedX];
+ const polarity: 1 | -1 = seedPixelByte >= backgroundByte ? 1 : -1;
+ const elevation = (byte: number): number =>
+ polarity > 0 ? byte : 255 - byte;
+
+ // Reference value: median of the 3x3 neighbors on the seed's side of the
+ // background (noise robustness). Restricting to same-side neighbors keeps
+ // thin/tiny structures from dragging the reference onto the background.
+ const seedNeighborhood: number[] = [];
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const x = seedX + dx;
+ const y = seedY + dy;
+ if (x < 0 || x >= wA || y < 0 || y >= wB) {
+ continue;
+ }
+ const byte = bytes[y * wA + x];
+ if (
+ byte >= 0 &&
+ Math.abs(byte - seedPixelByte) <= Math.abs(byte - backgroundByte)
+ ) {
+ seedNeighborhood.push(byte);
+ }
+ }
+ }
+ const v0 = seedNeighborhood.length
+ ? medianOf(seedNeighborhood)
+ : seedPixelByte;
+ const h0 = elevation(v0);
+
+ // One-pass threshold sweep: a bucket-queue flood that grows the connected
+ // region by descending intensity threshold. Cost is one-sided: anything at
+ // or above the seed's elevation joins immediately (level 0) — a lesion's
+ // hot core can never be excluded — and cooler pixels join once the
+ // threshold drops to their elevation.
+ const cost = (idx: number): number => {
+ const byte = bytes[idx];
+ if (byte < 0) {
+ return -1;
+ }
+ return Math.max(0, h0 - elevation(byte));
+ };
+
+ const buckets: number[][] = new Array(256);
+ const pushToBucket = (level: number, idx: number) => {
+ let bucket = buckets[level];
+ if (!bucket) {
+ bucket = [];
+ buckets[level] = bucket;
+ }
+ bucket.push(idx);
+ };
+ const visited = new Uint8Array(windowArea); // 0 unseen, 1 queued, 2 region
+ const seedIdx = seedY * wA + seedX;
+ const seedCost = cost(seedIdx);
+ if (seedCost < 0) {
+ return { viable: false, reason: 'flat-region', range: null };
+ }
+ pushToBucket(seedCost, seedIdx);
+ visited[seedIdx] = 1;
+
+ type CurvePoint = { level: number; size: number };
+ const curve: CurvePoint[] = [];
+ const clickIdx = clickY * wA + clickX;
+ let clickJoinLevel = -1;
+ // The click's 4 in-plane neighbors ([-A, +A, -B, +B]): record when each
+ // joins the region, exactly like the click pixel itself. The window always
+ // contains them unless clamped at the volume edge (null = not evaluable).
+ const neighborOffsets: Array<[number, number]> = [
+ [-1, 0],
+ [1, 0],
+ [0, -1],
+ [0, 1],
+ ];
+ const clickNeighborJoinLevels: Array = [];
+ const neighborWatch = new Map();
+ neighborOffsets.forEach(([dx, dy], slot) => {
+ const x = clickX + dx;
+ const y = clickY + dy;
+ if (x < 0 || x >= wA || y < 0 || y >= wB) {
+ clickNeighborJoinLevels.push(null);
+ return;
+ }
+ clickNeighborJoinLevels.push(-1);
+ neighborWatch.set(y * wA + x, slot);
+ });
+ let size = 0;
+ let borderTouchLevel = -1;
+ let explosionLevel = -1;
+
+ outer: for (let level = 0; level < 256; level++) {
+ const bucket = buckets[level];
+ if (bucket?.length) {
+ // Plain index loop: neighbors with cost <= level append to this bucket.
+ for (let bi = 0; bi < bucket.length; bi++) {
+ const idx = bucket[bi];
+ if (visited[idx] === 2) {
+ continue;
+ }
+ visited[idx] = 2;
+ size++;
+ if (idx === clickIdx) {
+ clickJoinLevel = level;
+ }
+ const watchSlot = neighborWatch.get(idx);
+ if (watchSlot !== undefined) {
+ clickNeighborJoinLevels[watchSlot] = level;
+ }
+
+ const x = idx % wA;
+ const y = Math.floor(idx / wA);
+ if (
+ borderTouchLevel < 0 &&
+ (x === 0 || x === wA - 1 || y === 0 || y === wB - 1)
+ ) {
+ borderTouchLevel = level;
+ }
+ if (size > maxRegionPx) {
+ explosionLevel = level;
+ break outer;
+ }
+
+ const neighbors = [idx - 1, idx + 1, idx - wA, idx + wA];
+ const canLeft = x > 0;
+ const canRight = x < wA - 1;
+ const canUp = y > 0;
+ const canDown = y < wB - 1;
+ const allowed = [canLeft, canRight, canUp, canDown];
+ for (let n = 0; n < 4; n++) {
+ if (!allowed[n]) {
+ continue;
+ }
+ const nIdx = neighbors[n];
+ if (visited[nIdx] !== 0) {
+ continue;
+ }
+ const nCost = cost(nIdx);
+ if (nCost < 0) {
+ continue;
+ }
+ visited[nIdx] = 1;
+ pushToBucket(Math.max(level, nCost), nIdx);
+ }
+ }
+ bucket.length = 0;
+ }
+ const lastSize = curve.length ? curve[curve.length - 1].size : 0;
+ if (size > lastSize) {
+ curve.push({ level, size });
+ }
+ }
+
+ // Threshold selection (MSER-style): find "quiet runs" — spans of tolerance
+ // levels where the region grows at most a trickle (a few boundary pixels per
+ // level). A proper region shows a wide quiet run between filling itself and
+ // leaking into the surroundings; uniform organs and background gradients
+ // grow substantially at every level and never form one. The threshold is
+ // placed mid-run so the 3D fill is robust to slice-to-slice drift.
+ const growthEndLevel = explosionLevel >= 0 ? explosionLevel - 1 : 255;
+ type QuietRun = {
+ startLevel: number;
+ endLevel: number;
+ width: number;
+ size: number;
+ };
+ const runs: QuietRun[] = [];
+ {
+ let runStart = -1;
+ let currentSize = 0;
+ let ci = 0;
+ const closeRun = (endLevel: number, sizeAtEnd: number) => {
+ if (runStart >= 0 && endLevel >= runStart) {
+ runs.push({
+ startLevel: runStart,
+ endLevel,
+ width: endLevel - runStart,
+ size: sizeAtEnd,
+ });
+ }
+ runStart = -1;
+ };
+ for (let level = 0; level <= growthEndLevel; level++) {
+ const sizeBefore = currentSize;
+ let gain = 0;
+ if (ci < curve.length && curve[ci].level === level) {
+ gain = curve[ci].size - currentSize;
+ currentSize = curve[ci].size;
+ ci++;
+ }
+ const quietThreshold = Math.max(
+ QUIET_GAIN_ABS_PX,
+ Math.floor(sizeBefore * QUIET_GAIN_FRACTION)
+ );
+ if (currentSize > 0 && gain <= quietThreshold) {
+ if (runStart < 0) {
+ runStart = level;
+ }
+ } else {
+ // The gain that ends a run is the leak; record the pre-leak size.
+ closeRun(level - 1, sizeBefore);
+ }
+ }
+ closeRun(growthEndLevel, currentSize);
+ }
+
+ let best: QuietRun | null = null;
+ for (const run of runs) {
+ const endLevel =
+ borderTouchLevel >= 0
+ ? Math.min(run.endLevel, borderTouchLevel - 1)
+ : run.endLevel;
+ if (endLevel < run.startLevel) {
+ continue;
+ }
+ const clipped: QuietRun = {
+ ...run,
+ endLevel,
+ width: endLevel - run.startLevel,
+ };
+ if (clipped.size < minRegionPx || clipped.size > maxRegionPx) {
+ continue;
+ }
+ if (!best || clipped.width > best.width) {
+ best = clipped;
+ }
+ }
+
+ const selectionDebug = () => ({
+ growthCurve: curve.map((p): [number, number] => [p.level, p.size]),
+ quietRuns: runs.map((run): [number, number, number] => [
+ run.startLevel,
+ run.endLevel,
+ run.size,
+ ]),
+ seedByte: v0,
+ backgroundByte,
+ polarity,
+ borderTouchLevel,
+ explosionLevel,
+ clickJoinLevel,
+ minRegionPx,
+ maxRegionPx,
+ pxAreaMm2,
+ });
+
+ if (!best || best.width < MIN_PLATEAU_WIDTH_BYTES) {
+ // Distinguish "there was a stable region but it is tiny" from "nothing
+ // ever stabilized" for diagnostics; both show as blocked to the user.
+ const stableButSmall = runs.find(
+ (run) =>
+ run.size < minRegionPx &&
+ run.width >= MIN_PLATEAU_WIDTH_BYTES &&
+ (borderTouchLevel < 0 || run.startLevel < borderTouchLevel)
+ );
+ const failSize = best?.size ?? stableButSmall?.size ?? size;
+ return {
+ viable: false,
+ reason: stableButSmall ? 'too-small' : 'unbounded',
+ range: null,
+ regionSizePx: failSize,
+ regionAreaMm2: failSize * pxAreaMm2,
+ debug: selectionDebug(),
+ };
+ }
+
+ let toleranceBytes = best.startLevel + Math.floor(best.width / 2);
+ toleranceBytes = Math.min(
+ Math.max(toleranceBytes, MIN_TOLERANCE_BYTES),
+ best.endLevel
+ );
+ const toleranceScale =
+ input.toleranceScale && input.toleranceScale > 0 ? input.toleranceScale : 1;
+ toleranceBytes = Math.max(
+ 1,
+ Math.min(255, Math.round(toleranceBytes * toleranceScale))
+ );
+ const chosen = best;
+
+ // The pointer must be ON the region it would segment: if the clicked pixel
+ // itself is not part of the region at the chosen threshold (it only exists
+ // because the seed snapped onto a nearby structure), report off-target so
+ // the cursor blocks the surroundings of a lesion.
+ if (clickJoinLevel < 0 || clickJoinLevel > toleranceBytes) {
+ return {
+ viable: false,
+ reason: 'off-target',
+ range: null,
+ regionSizePx: chosen.size,
+ regionAreaMm2: chosen.size * pxAreaMm2,
+ debug: selectionDebug(),
+ };
+ }
+
+ const seedIjk: Types.Point3 = [...ijkClick] as Types.Point3;
+ seedIjk[axisA] = a0 + seedX;
+ seedIjk[axisB] = b0 + seedY;
+ const seedScalar = rawValues[seedIdx];
+ const clickedScalar = rawValues[clickY * wA + clickX];
+
+ const expandContext: AdaptiveRegionExpandContext = {
+ seedByte: v0,
+ polarity,
+ voiMapping: voiMapping ?? null,
+ rawWindow: { min: rawMinInWindow, span: rawSpan },
+ seedIjk,
+ seedScalar: Number.isFinite(seedScalar) ? seedScalar : 0,
+ growthCurve: curve.map((p): [number, number] => [p.level, p.size]),
+ chosenToleranceBytes: toleranceBytes,
+ growthEndLevel,
+ pxAreaMm2,
+ };
+
+ const { min: rawLo, max: rawHi } = rawBandForTolerance(
+ expandContext,
+ toleranceBytes
+ );
+
+ return {
+ viable: true,
+ reason: 'ok',
+ regionSizePx: chosen.size,
+ regionAreaMm2: chosen.size * pxAreaMm2,
+ toleranceBytes,
+ clickNeighborJoinLevels,
+ expandContext,
+ range: {
+ min: rawLo,
+ max: rawHi,
+ ijkStart: seedIjk,
+ diagnostics: {
+ neighborhoodMean: Number.isFinite(seedScalar) ? seedScalar : 0,
+ neighborhoodStdDev: 0,
+ clickedVoxelValue: Number.isFinite(clickedScalar) ? clickedScalar : 0,
+ positiveStdDevMultiplier: toleranceScale,
+ neighborhoodRadius: SEED_SNAP_RADIUS_PX,
+ strategy: 'adaptiveRegion',
+ adaptive: {
+ toleranceBytes,
+ regionSizePx: chosen.size,
+ regionAreaMm2: chosen.size * pxAreaMm2,
+ backgroundByte,
+ seedByte: v0,
+ polarity,
+ seedSnapped: seedX !== clickX || seedY !== clickY,
+ windowSize: [wA, wB],
+ growthCurve: curve.map((p): [number, number] => [p.level, p.size]),
+ explosionLevel,
+ borderTouchLevel,
+ },
+ },
+ },
+ };
+}
+
+/**
+ * The two volume axes lying in the viewed plane (from the viewport camera for
+ * orthogonal views), defaulting to the acquisition plane [0, 1].
+ */
+export function resolveInPlaneAxes(
+ referencedVolume: Types.IImageVolume,
+ viewport: Types.IViewport | undefined
+): [number, number] {
+ if (!viewport) {
+ return [0, 1];
+ }
+ try {
+ const camera = viewport.getCamera();
+ const { ijkVecSliceDir } = getVolumeDirectionVectors(
+ referencedVolume.imageData,
+ camera
+ );
+ const abs = ijkVecSliceDir.map(Math.abs);
+ let sliceAxis = 0;
+ if (abs[1] >= abs[0] && abs[1] >= abs[2]) {
+ sliceAxis = 1;
+ } else if (abs[2] >= abs[0] && abs[2] >= abs[1]) {
+ sliceAxis = 2;
+ }
+ const axes = [0, 1, 2].filter((axis) => axis !== sliceAxis);
+ return [axes[0], axes[1]];
+ } catch {
+ return [0, 1];
+ }
+}
+
+/**
+ * Probes whether a one-click segmentation at `worldPosition` would produce a
+ * meaningful region, and if so returns the flood-fill intensity band plus the
+ * context for deterministic expand/shrink. Cheap enough to run on hover
+ * (single in-plane window, one pass).
+ */
+export function probeAdaptiveRegion(
+ referencedVolume: Types.IImageVolume,
+ worldPosition: Types.Point3,
+ options?: FloodFillIntensityRangeOptions
+): AdaptiveRegionProbeResult {
+ const { dimensions, imageData, spacing } = referencedVolume;
+ const voxelManager =
+ referencedVolume.voxelManager as unknown as NumberVoxelManager;
+ const [width, height] = dimensions;
+ const pixelsPerSlice = width * height;
+
+ const ijkClick = transformWorldToIndex(imageData, worldPosition).map(
+ Math.round
+ ) as Types.Point3;
+
+ const voiMapping =
+ options?.voiMapping ??
+ (options?.viewport && options?.referencedVolumeId
+ ? getViewportVoiMappingForVolume(
+ options.viewport,
+ options.referencedVolumeId
+ )
+ : null);
+
+ const toleranceScale = options?.positiveStdDevMultiplier
+ ? options.positiveStdDevMultiplier / DEFAULT_POSITIVE_STD_DEV_MULTIPLIER
+ : 1;
+
+ const inPlaneAxes = resolveInPlaneAxes(referencedVolume, options?.viewport);
+ const inPlaneSpacing: [number, number] = [
+ spacing?.[inPlaneAxes[0]] ?? 1,
+ spacing?.[inPlaneAxes[1]] ?? 1,
+ ];
+
+ const result = probeAdaptiveRegionCore({
+ dimensions,
+ getScalar: (i, j, k) =>
+ Number(voxelManager.getAtIndex(k * pixelsPerSlice + j * width + i)),
+ ijkClick,
+ inPlaneAxes,
+ inPlaneSpacing,
+ voiMapping,
+ toleranceScale,
+ });
+
+ log.debug('adaptiveRegion probe', {
+ ijkClick,
+ viable: result.viable,
+ reason: result.reason,
+ regionSizePx: result.regionSizePx,
+ regionAreaMm2: result.regionAreaMm2,
+ toleranceBytes: result.toleranceBytes,
+ band: result.range
+ ? { min: result.range.min, max: result.range.max }
+ : null,
+ });
+
+ return result;
+}
+
+/**
+ * `GetFloodFillIntensityRange`-compatible wrapper around
+ * {@link probeAdaptiveRegion}: returns the band when a meaningful region
+ * exists at the click, otherwise null (surfaced by tools as "cannot segment
+ * here").
+ */
+export const getAdaptiveRegionIntensityRange: GetFloodFillIntensityRange = (
+ referencedVolume,
+ worldPosition,
+ options
+) => {
+ const probe = probeAdaptiveRegion(referencedVolume, worldPosition, options);
+ if (!probe.viable) {
+ log.info('adaptiveRegion: no proper region at click', {
+ reason: probe.reason,
+ regionSizePx: probe.regionSizePx,
+ regionAreaMm2: probe.regionAreaMm2,
+ debug: probe.debug,
+ });
+ return null;
+ }
+ return probe.range;
+};
diff --git a/packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts b/packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts
new file mode 100644
index 0000000000..19adb497a4
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/growCut/neighborhoodStats.ts
@@ -0,0 +1,80 @@
+import type { Types } from '@cornerstonejs/core';
+import type { VoxelManager } from '@cornerstonejs/core/utilities';
+
+type NumberVoxelManager = VoxelManager;
+
+type NeighborhoodStats = {
+ mean: number;
+ stdDev: number;
+ count: number;
+};
+
+/**
+ * Mean and standard deviation over a cubic neighborhood, read through the voxel
+ * manager's `getAtIJK` (no full scalar buffer required).
+ *
+ * Bundled with the flood-fill engine so it can take an optional `mapValue`
+ * (e.g. VOI-mapped intensity) without changing core's shared
+ * `calculateNeighborhoodStats`, whose signature other tools depend on.
+ *
+ * @param mapValue - If set, each sample is passed through this before mean/std.
+ */
+export function calculateNeighborhoodStatsVM(
+ voxelManager: NumberVoxelManager,
+ dimensions: Types.Point3,
+ centerIjk: Types.Point3,
+ radius: number,
+ mapValue?: (v: number) => number
+): NeighborhoodStats {
+ const [width, height, numSlices] = dimensions;
+
+ let sum = 0;
+ let sumSq = 0;
+ let count = 0;
+
+ const [cx, cy, cz] = centerIjk.map(Math.round);
+
+ for (let z = cz - radius; z <= cz + radius; z++) {
+ if (z < 0 || z >= numSlices) {
+ continue;
+ }
+ for (let y = cy - radius; y <= cy + radius; y++) {
+ if (y < 0 || y >= height) {
+ continue;
+ }
+ for (let x = cx - radius; x <= cx + radius; x++) {
+ if (x < 0 || x >= width) {
+ continue;
+ }
+
+ const raw = Number(voxelManager.getAtIJK(x, y, z));
+ const value = mapValue ? mapValue(raw) : raw;
+ sum += value;
+ sumSq += value * value;
+ count++;
+ }
+ }
+ }
+
+ if (count === 0) {
+ if (
+ cx >= 0 &&
+ cx < width &&
+ cy >= 0 &&
+ cy < height &&
+ cz >= 0 &&
+ cz < numSlices
+ ) {
+ const raw = Number(voxelManager.getAtIJK(cx, cy, cz));
+ const centerValue = mapValue ? mapValue(raw) : raw;
+ return { mean: centerValue, stdDev: 0, count: 1 };
+ }
+ return { mean: 0, stdDev: 0, count: 0 };
+ }
+
+ const mean = sum / count;
+ const variance = sumSq / count - mean * mean;
+ const stdDev = Math.sqrt(Math.max(0, variance));
+
+ return { mean, stdDev, count };
+}
diff --git a/packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts b/packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts
new file mode 100644
index 0000000000..37d6c63476
--- /dev/null
+++ b/packages/tools/src/utilities/segmentation/growCut/runFloodFillSegmentation.ts
@@ -0,0 +1,934 @@
+import { utilities as csUtils, cache } from '@cornerstonejs/core';
+import type { Types } from '@cornerstonejs/core';
+import type { VoxelManager } from '@cornerstonejs/core/utilities';
+import IslandRemoval from '../floodFillIslandRemoval';
+import { commitSliceMasksToLabelmapVolume } from '../commitSliceMasksToLabelmap';
+import { createEnsureSliceLoadedForVolume } from '../createEnsureSliceLoadedForVolume';
+import { floodFill3dSliceLazy } from '../floodFillSliceLazy';
+import {
+ DEFAULT_NEIGHBORHOOD_RADIUS,
+ DEFAULT_POSITIVE_STD_DEV_MULTIPLIER,
+} from './constants';
+import { calculateNeighborhoodStatsVM } from './neighborhoodStats';
+import { getViewportVoiMappingForVolume } from './getViewportVoiMappingForVolume';
+import type {
+ FloodFillIntensityRangeResult,
+ FloodFillIntensityRangeOptions,
+ GetFloodFillIntensityRange,
+} from './floodFillIntensityRangeTypes';
+
+const {
+ transformWorldToIndex,
+ mapScalarToViewportVoiIntensity,
+ mapMappedBandToRawRange,
+} = csUtils;
+
+type NumberVoxelManager = VoxelManager;
+const { growCutLog: log } = csUtils.logger;
+const ENABLE_VERBOSE_FLOOD_FILL_LOGS = false;
+
+/** console.time/timeEnd wrappers gated behind the verbose flag. */
+const timeStart = (label: string) => {
+ if (ENABLE_VERBOSE_FLOOD_FILL_LOGS) {
+ console.time(label);
+ }
+};
+const timeEnd = (label: string) => {
+ if (ENABLE_VERBOSE_FLOOD_FILL_LOGS) {
+ console.timeEnd(label);
+ }
+};
+
+export type {
+ FloodFillIntensityRangeResult,
+ FloodFillIntensityRangeOptions,
+ GetFloodFillIntensityRange,
+} from './floodFillIntensityRangeTypes';
+
+/** Ref-volume metadata + labelmap dimension check. */
+const FLOOD_FILL_PREP_TIMING_LABEL = 'cornerstone.tools: floodFill: prep';
+const FLOOD_FILL_PREP_REF_META =
+ 'cornerstone.tools: floodFill: prep: ref_volume_meta';
+const FLOOD_FILL_RANGE_TIMING_LABEL =
+ 'cornerstone.tools: floodFill: intensityRange';
+const FLOOD_FILL_RUN_TIMING_LABEL =
+ 'cornerstone.tools: floodFill: fillAndIslandRemoval';
+const FLOOD_FILL_ISLAND_EXTERNAL_TIMING_LABEL =
+ 'cornerstone.tools: floodFill: islandRemoval:external';
+const FLOOD_FILL_ISLAND_INTERNAL_TIMING_LABEL =
+ 'cornerstone.tools: floodFill: islandRemoval:internal';
+
+type FloodFillSegmentationOptions = {
+ /** Cooperative cancellation hook; returns true to stop at next checkpoint. */
+ isCancelled?: () => boolean;
+ positiveStdDevMultiplier?: number;
+ initialNeighborhoodRadius?: number;
+ segmentIndex?: number;
+ /**
+ * Segment value written during the async flood (e.g. 255) so the active labelmap
+ * can update slice-by-slice. After island removal, those voxels are promoted to
+ * {@link segmentIndex}. Omit to use 255 when `segmentIndex !== 255`, or set equal
+ * to `segmentIndex` to disable preview and paint the final index immediately.
+ */
+ floodPreviewSegmentIndex?: number;
+ maxInternalRemove?: number;
+ /**
+ * When true (default), run planar island flood from the click then clear voxels
+ * not marked ISLAND. Set false to keep the raw 3D flood fill result.
+ */
+ applyExternalIslandRemoval?: boolean;
+ /**
+ * When true (default), run internal hole / speckle cleanup after external removal.
+ * Ignored if applyExternalIslandRemoval is false (nothing to clean in-labelmap).
+ */
+ applyInternalIslandRemoval?: boolean;
+ /** Forwarded to IslandRemoval: logs bounds, per-click flood, and voxel counts. */
+ islandRemovalVerboseLogging?: boolean;
+ getIntensityRange?: GetFloodFillIntensityRange;
+ /** Viewport container; passed into `getIntensityRange` / canvas-disk strategy. */
+ element?: HTMLDivElement;
+ /** Pointer position in viewport canvas coordinates (same space as `canvasDiskRadiusPx`). */
+ canvasPoint?: { x: number; y: number };
+ /** Circular sampling ROI radius in canvas pixels (see tool `intensitySamplingDiskRadiusCanvasPx`). */
+ intensitySamplingDiskRadiusCanvasPx?: number;
+ /**
+ * 3D: load slice images on demand (streaming volumes). Built from volume when omitted.
+ */
+ ensureSliceLoaded?: (sliceIndex: number) => Promise;
+ yieldEvery?: number;
+ /**
+ * When true, forwarded to {@link floodFill} as `planar` (same acquisition slice as seed, fixed k).
+ */
+ planar?: boolean;
+ /** Optional flood bound in k from seed: keep voxels with `abs(k-seedK) <= maxDeltaK`. */
+ maxDeltaK?: number;
+ /**
+ * Optional flood bound in i/j from seed: keep voxels with
+ * `abs(i-seedI) <= maxDeltaIJ` and `abs(j-seedJ) <= maxDeltaIJ`.
+ */
+ maxDeltaIJ?: number;
+ /**
+ * Called with the labelmap ijk points the fill painted (before island
+ * removal). Lets callers track exactly which voxels a run touched — e.g.
+ * to clear and refill on expand/shrink.
+ */
+ onCommitted?: (floodedPoints: Types.Point3[]) => void;
+ /**
+ * Compute-safety budget on filled voxels. When the flood exceeds it,
+ * NOTHING is committed to the labelmap, `onRejected` fires and the run
+ * returns null.
+ */
+ maxVoxels?: number;
+ /**
+ * Periodic region-shape gate forwarded to the flood: returning false stops
+ * the fill and rejects it (nothing committed). Lets tools require the
+ * region to stay entity-like (compact, self-contained) as it grows.
+ */
+ shouldContinueRegion?: (stats: {
+ voxelCount: number;
+ bbox: { min: Types.Point3; max: Types.Point3 };
+ }) => boolean;
+ /** Fired when the fill was rejected by the budget or the shape gate. */
+ onRejected?: (info: {
+ voxelCount: number;
+ bbox: { min: Types.Point3; max: Types.Point3 } | null;
+ }) => void;
+ /**
+ * RLE history voxel manager wrapping the labelmap's voxel manager (e.g. a
+ * `LabelmapMemo.voxelManager`). When provided, EVERY labelmap write — the
+ * flood commit, preview promotion and island removal — goes through it so
+ * the whole run is recorded as one undoable memo. It also acts as the
+ * preview/delta layer for external island removal. Reads still use the
+ * labelmap's own voxel manager (the history wrapper reads its delta map,
+ * not current state).
+ */
+ historyVoxelManager?: Types.IVoxelManager;
+};
+
+function getDisplayVoiSnapshot(
+ viewport: Types.IViewport,
+ referencedVolumeId?: string
+): {
+ lower: number;
+ upper: number;
+ windowWidth: number;
+ windowCenter: number;
+} | null {
+ const getProps = (
+ viewport as Types.IViewport & {
+ getProperties?: (volumeId?: string) => {
+ voiRange?: { lower: number; upper: number };
+ };
+ }
+ ).getProperties;
+ if (typeof getProps !== 'function') {
+ return null;
+ }
+ const props = referencedVolumeId
+ ? getProps.call(viewport, referencedVolumeId)
+ : getProps.call(viewport);
+ const voiRange = props?.voiRange;
+ if (
+ !voiRange ||
+ typeof voiRange.lower !== 'number' ||
+ typeof voiRange.upper !== 'number'
+ ) {
+ return null;
+ }
+ const { lower, upper } = voiRange;
+ return {
+ lower,
+ upper,
+ windowWidth: upper - lower,
+ windowCenter: (lower + upper) / 2,
+ };
+}
+
+/**
+ * Raw neighborhood mean ± kσ (no VOI mapping).
+ */
+function getPositiveIntensityRangeRaw(
+ referencedVolume: Types.IImageVolume,
+ worldPosition: Types.Point3,
+ options?: FloodFillIntensityRangeOptions
+): FloodFillIntensityRangeResult | null {
+ const { dimensions, imageData: refImageData } = referencedVolume;
+ const [width, height, numSlices] = dimensions;
+ const referenceVolumeVoxelManager =
+ referencedVolume.voxelManager as unknown as NumberVoxelManager;
+
+ const neighborhoodRadius =
+ options?.initialNeighborhoodRadius ?? DEFAULT_NEIGHBORHOOD_RADIUS;
+ const positiveK =
+ options?.positiveStdDevMultiplier ?? DEFAULT_POSITIVE_STD_DEV_MULTIPLIER;
+
+ const ijkStart = transformWorldToIndex(refImageData, worldPosition).map(
+ Math.round
+ ) as Types.Point3;
+
+ if (
+ ijkStart[0] < 0 ||
+ ijkStart[0] >= width ||
+ ijkStart[1] < 0 ||
+ ijkStart[1] >= height ||
+ ijkStart[2] < 0 ||
+ ijkStart[2] >= numSlices
+ ) {
+ log.warn('intensity range: click outside volume', {
+ ijkStart,
+ dimensions: [width, height, numSlices],
+ });
+ return null;
+ }
+
+ const initialStats = calculateNeighborhoodStatsVM(
+ referenceVolumeVoxelManager,
+ dimensions,
+ ijkStart,
+ neighborhoodRadius
+ );
+
+ if (initialStats.count === 0) {
+ const seedScalar = Number(
+ referenceVolumeVoxelManager.getAtIJKPoint(ijkStart)
+ );
+ initialStats.mean = seedScalar;
+ initialStats.stdDev = 0;
+ }
+
+ const min = initialStats.mean - positiveK * initialStats.stdDev;
+ const max = initialStats.mean + positiveK * initialStats.stdDev;
+ const startValue = referenceVolumeVoxelManager.getAtIJKPoint(ijkStart);
+
+ if (startValue < min || startValue > max) {
+ log.warn(
+ 'intensity range: clicked voxel intensity is outside the calculated positive range'
+ );
+ return null;
+ }
+
+ return {
+ min,
+ max,
+ ijkStart,
+ diagnostics: {
+ neighborhoodMean: initialStats.mean,
+ neighborhoodStdDev: initialStats.stdDev,
+ clickedVoxelValue: startValue,
+ positiveStdDevMultiplier: positiveK,
+ neighborhoodRadius,
+ strategy: 'meanStdRaw',
+ },
+ };
+}
+
+/**
+ * Neighborhood mean ± kσ in VOI-mapped [0,1] space, band inverted to raw bounds.
+ */
+function getPositiveIntensityRangeVoiMapped(
+ referencedVolume: Types.IImageVolume,
+ worldPosition: Types.Point3,
+ voiMapping: NonNullable,
+ options?: FloodFillIntensityRangeOptions
+): FloodFillIntensityRangeResult | null {
+ const { dimensions, imageData: refImageData } = referencedVolume;
+ const [width, height, numSlices] = dimensions;
+ const referenceVolumeVoxelManager =
+ referencedVolume.voxelManager as unknown as NumberVoxelManager;
+
+ const neighborhoodRadius =
+ options?.initialNeighborhoodRadius ?? DEFAULT_NEIGHBORHOOD_RADIUS;
+ const positiveK =
+ options?.positiveStdDevMultiplier ?? DEFAULT_POSITIVE_STD_DEV_MULTIPLIER;
+
+ const ijkStart = transformWorldToIndex(refImageData, worldPosition).map(
+ Math.round
+ ) as Types.Point3;
+
+ if (
+ ijkStart[0] < 0 ||
+ ijkStart[0] >= width ||
+ ijkStart[1] < 0 ||
+ ijkStart[1] >= height ||
+ ijkStart[2] < 0 ||
+ ijkStart[2] >= numSlices
+ ) {
+ log.info('intensity range (VOI-mapped): click outside volume', {
+ ijkStart,
+ dimensions: [width, height, numSlices],
+ });
+ return null;
+ }
+
+ const mapFn = (v: number) => mapScalarToViewportVoiIntensity(v, voiMapping);
+
+ const initialStats = calculateNeighborhoodStatsVM(
+ referenceVolumeVoxelManager,
+ dimensions,
+ ijkStart,
+ neighborhoodRadius,
+ mapFn
+ );
+
+ if (initialStats.count === 0) {
+ const seedScalar = Number(
+ referenceVolumeVoxelManager.getAtIJKPoint(ijkStart)
+ );
+ initialStats.mean = mapFn(seedScalar);
+ initialStats.stdDev = 0;
+ }
+
+ let yMin = initialStats.mean - positiveK * initialStats.stdDev;
+ let yMax = initialStats.mean + positiveK * initialStats.stdDev;
+ yMin = Math.max(0, Math.min(1, yMin));
+ yMax = Math.max(0, Math.min(1, yMax));
+ if (yMin > yMax) {
+ const t = yMin;
+ yMin = yMax;
+ yMax = t;
+ }
+
+ const { rawMin, rawMax } = mapMappedBandToRawRange(yMin, yMax, voiMapping);
+ const startValue = Number(
+ referenceVolumeVoxelManager.getAtIJKPoint(ijkStart)
+ );
+ const bandLo = Math.min(rawMin, rawMax);
+ const bandHi = Math.max(rawMin, rawMax);
+ const min = Math.min(bandLo, startValue);
+ const max = Math.max(bandHi, startValue);
+
+ return {
+ min,
+ max,
+ ijkStart,
+ diagnostics: {
+ neighborhoodMean: initialStats.mean,
+ neighborhoodStdDev: initialStats.stdDev,
+ clickedVoxelValue: startValue,
+ positiveStdDevMultiplier: positiveK,
+ neighborhoodRadius,
+ strategy: 'meanStdVoiMapped',
+ mappedBand: { min: yMin, max: yMax },
+ },
+ };
+}
+
+function getPositiveIntensityRange(
+ referencedVolume: Types.IImageVolume,
+ worldPosition: Types.Point3,
+ options?: FloodFillIntensityRangeOptions
+): FloodFillIntensityRangeResult | null {
+ if (options?.voiMapping) {
+ return getPositiveIntensityRangeVoiMapped(
+ referencedVolume,
+ worldPosition,
+ options.voiMapping,
+ options
+ );
+ }
+ return getPositiveIntensityRangeRaw(referencedVolume, worldPosition, options);
+}
+
+function resolveIntensityRange(
+ referencedVolume: Types.IImageVolume,
+ worldPosition: Types.Point3,
+ viewport: Types.IViewport,
+ referencedVolumeId: string,
+ options: FloodFillSegmentationOptions,
+ rangeContext: FloodFillIntensityRangeOptions
+): FloodFillIntensityRangeResult | null {
+ if (options.getIntensityRange) {
+ const fromGetter = options.getIntensityRange(
+ referencedVolume,
+ worldPosition,
+ rangeContext
+ );
+ if (!fromGetter) {
+ log.warn(
+ 'flood fill intensity range: strategy/custom getIntensityRange returned null (e.g. missing canvasPoint/VOI for canvas-disk, click outside volume, or fixed-% rejection)',
+ { referencedVolumeId, worldPosition }
+ );
+ }
+ return fromGetter;
+ }
+ const voiFromViewport = getViewportVoiMappingForVolume(
+ viewport,
+ referencedVolumeId
+ );
+ const merged: FloodFillIntensityRangeOptions = {
+ ...rangeContext,
+ voiMapping: voiFromViewport ?? undefined,
+ };
+ let result = getPositiveIntensityRange(
+ referencedVolume,
+ worldPosition,
+ merged
+ );
+ if (!result && merged.voiMapping) {
+ log.info(
+ 'flood fill intensity range: VOI-mapped mean±σ failed; falling back to raw mean±σ',
+ { referencedVolumeId }
+ );
+ result = getPositiveIntensityRange(referencedVolume, worldPosition, {
+ ...merged,
+ voiMapping: undefined,
+ });
+ }
+ if (!result) {
+ log.warn(
+ 'flood fill intensity range: default mean±σ (VOI + raw fallback) could not resolve a band',
+ { referencedVolumeId, worldPosition }
+ );
+ }
+ return result;
+}
+
+function assertFloodFillLabelmapMatchesRef(
+ referencedVolume: Types.IImageVolume,
+ labelmapVolume: Types.IImageVolume
+) {
+ const [rw, rh, rd] = referencedVolume.dimensions;
+ const [lw, lh, ld] = labelmapVolume.dimensions;
+ if (rw !== lw || rh !== lh || rd !== ld) {
+ throw new Error(
+ `runFloodFillSegmentation: labelmap dimensions [${lw},${lh},${ld}] must match referenced volume [${rw},${rh},${rd}]`
+ );
+ }
+}
+
+function resolveFloodPaintIndices(
+ segmentIndex: number,
+ explicitPreview?: number
+): { paintIndex: number; usePreview: boolean } {
+ if (explicitPreview !== undefined) {
+ return {
+ paintIndex: explicitPreview,
+ usePreview: explicitPreview !== segmentIndex,
+ };
+ }
+ if (segmentIndex === 255) {
+ return { paintIndex: 255, usePreview: false };
+ }
+ return { paintIndex: 255, usePreview: true };
+}
+
+function promotePreviewSegmentToFinal(
+ readVoxelManager: NumberVoxelManager,
+ writeVoxelManager: NumberVoxelManager,
+ preview: number,
+ final: number,
+ points: Types.Point3[],
+ numPixelsPerSlice: number,
+ width: number
+) {
+ for (let i = 0; i < points.length; i++) {
+ const [x, y, z] = points[i];
+ const index = z * numPixelsPerSlice + y * width + x;
+ if (readVoxelManager.getAtIndex(index) === preview) {
+ writeVoxelManager.setAtIndex(index, final);
+ }
+ }
+}
+
+async function runFloodFillSegmentation({
+ referencedVolumeId,
+ worldPosition,
+ viewport,
+ labelmapVolume,
+ options = {},
+}: {
+ referencedVolumeId: string;
+ worldPosition: Types.Point3;
+ viewport: Types.IViewport;
+ /** Active segmentation labelmap (same grid as referenced volume). */
+ labelmapVolume: Types.IImageVolume;
+ options?: FloodFillSegmentationOptions;
+}): Promise {
+ timeStart(FLOOD_FILL_PREP_TIMING_LABEL);
+
+ const referencedVolume = cache.getVolume(referencedVolumeId);
+ assertFloodFillLabelmapMatchesRef(referencedVolume, labelmapVolume);
+ const labelmap = labelmapVolume;
+
+ const segmentIndex = options.segmentIndex ?? 1;
+ const { paintIndex, usePreview } = resolveFloodPaintIndices(
+ segmentIndex,
+ options.floodPreviewSegmentIndex
+ );
+
+ timeStart(FLOOD_FILL_PREP_REF_META);
+ const [volMin, volMax] = referencedVolume.voxelManager.getRange();
+ const displayVoi = getDisplayVoiSnapshot(viewport, referencedVolumeId);
+ log.info('segmentation path: flood fill (floodfill_full)', {
+ referencedVolumeId,
+ volumeScalarRange: { min: volMin, max: volMax },
+ displayVoi,
+ floodPreview: usePreview ? paintIndex : null,
+ segmentIndex,
+ });
+ timeEnd(FLOOD_FILL_PREP_REF_META);
+
+ timeEnd(FLOOD_FILL_PREP_TIMING_LABEL);
+
+ const voiMapping = getViewportVoiMappingForVolume(
+ viewport,
+ referencedVolumeId
+ );
+
+ const rangeContext: FloodFillIntensityRangeOptions = {
+ positiveStdDevMultiplier: options.positiveStdDevMultiplier,
+ initialNeighborhoodRadius: options.initialNeighborhoodRadius,
+ viewport,
+ element: options.element,
+ referencedVolumeId,
+ canvasPoint: options.canvasPoint,
+ canvasDiskRadiusPx: options.intensitySamplingDiskRadiusCanvasPx,
+ voiMapping: voiMapping ?? undefined,
+ };
+
+ timeStart(FLOOD_FILL_RANGE_TIMING_LABEL);
+ const rangeResult = resolveIntensityRange(
+ referencedVolume,
+ worldPosition,
+ viewport,
+ referencedVolumeId,
+ options,
+ rangeContext
+ );
+ timeEnd(FLOOD_FILL_RANGE_TIMING_LABEL);
+
+ if (!rangeResult) {
+ log.warn('flood fill: aborted before fill (no intensity range)', {
+ referencedVolumeId,
+ worldPosition,
+ });
+ return null;
+ }
+
+ const { min: rangeMin, max: rangeMax, ijkStart, diagnostics } = rangeResult;
+ let clampedRangeMin = rangeMin;
+ let clampedRangeMax = rangeMax;
+ const MAX_WL_BAND_FRACTION = 0.2;
+ if (voiMapping && diagnostics?.mappedBand) {
+ const mapped = diagnostics.mappedBand;
+ const mappedLo = Math.max(0, Math.min(1, Math.min(mapped.min, mapped.max)));
+ const mappedHi = Math.max(0, Math.min(1, Math.max(mapped.min, mapped.max)));
+ const mappedWidth = mappedHi - mappedLo;
+ if (mappedWidth > MAX_WL_BAND_FRACTION) {
+ const seedScalarPreClamp = Number(
+ (
+ referencedVolume.voxelManager as unknown as NumberVoxelManager
+ ).getAtIJKPoint(ijkStart)
+ );
+ const seedMapped = Math.max(
+ 0,
+ Math.min(
+ 1,
+ mapScalarToViewportVoiIntensity(seedScalarPreClamp, voiMapping)
+ )
+ );
+ const half = MAX_WL_BAND_FRACTION / 2;
+ const clippedMappedLo = Math.max(0, seedMapped - half);
+ const clippedMappedHi = Math.min(1, seedMapped + half);
+ const { rawMin: clipRawMin, rawMax: clipRawMax } =
+ mapMappedBandToRawRange(clippedMappedLo, clippedMappedHi, voiMapping);
+ clampedRangeMin = Math.min(clipRawMin, clipRawMax, seedScalarPreClamp);
+ clampedRangeMax = Math.max(clipRawMin, clipRawMax, seedScalarPreClamp);
+ log.info('flood fill: clipped wide WL band around seed', {
+ maxWlBandFraction: MAX_WL_BAND_FRACTION,
+ originalMappedBand: {
+ min: mappedLo,
+ max: mappedHi,
+ width: mappedWidth,
+ },
+ clippedMappedBand: {
+ min: clippedMappedLo,
+ max: clippedMappedHi,
+ width: clippedMappedHi - clippedMappedLo,
+ },
+ });
+ }
+ }
+
+ log.info('intensity tolerance band', {
+ toleranceMin: clampedRangeMin,
+ toleranceMax: clampedRangeMax,
+ width: clampedRangeMax - clampedRangeMin,
+ ...diagnostics,
+ });
+ if (ENABLE_VERBOSE_FLOOD_FILL_LOGS) {
+ console.info('[cornerstone-tools] flood fill intensity range', {
+ rawMin: clampedRangeMin,
+ rawMax: clampedRangeMax,
+ strategy: diagnostics.strategy,
+ mappedBand: diagnostics.mappedBand,
+ neighborhoodRadius: diagnostics.neighborhoodRadius,
+ });
+ }
+
+ timeStart(FLOOD_FILL_RUN_TIMING_LABEL);
+ try {
+ const { dimensions } = referencedVolume;
+ const [width, height, numSlices] = dimensions;
+ const refVoxelManager =
+ referencedVolume.voxelManager as unknown as NumberVoxelManager;
+ const numPixelsPerSlice = width * height;
+
+ // Reads always come from the labelmap itself; writes go through the
+ // history wrapper when the caller records this run as an undoable memo.
+ const labelmapReadVm =
+ labelmap.voxelManager as unknown as NumberVoxelManager;
+ const labelmapWriteVm = (options.historyVoxelManager ??
+ labelmap.voxelManager) as unknown as NumberVoxelManager;
+
+ let positiveMin = clampedRangeMin;
+ let positiveMax = clampedRangeMax;
+ const seedScalar = Number(refVoxelManager.getAtIJKPoint(ijkStart));
+ if (Number.isFinite(seedScalar)) {
+ if (seedScalar < positiveMin) {
+ log.info('flood fill: expanded tolerance min to include seed voxel', {
+ seedScalar,
+ previousMin: positiveMin,
+ });
+ positiveMin = seedScalar;
+ }
+ if (seedScalar > positiveMax) {
+ log.info('flood fill: expanded tolerance max to include seed voxel', {
+ seedScalar,
+ previousMax: positiveMax,
+ });
+ positiveMax = seedScalar;
+ }
+ }
+ if (ENABLE_VERBOSE_FLOOD_FILL_LOGS) {
+ console.info(
+ '[cornerstone-tools] flood fill seed + effective tolerance',
+ {
+ seedScalar,
+ effectiveMin: positiveMin,
+ effectiveMax: positiveMax,
+ }
+ );
+ }
+
+ const intensityGetter = (
+ x: number,
+ y: number,
+ z: number
+ ): number | undefined => {
+ if (
+ x < 0 ||
+ x >= width ||
+ y < 0 ||
+ y >= height ||
+ z < 0 ||
+ z >= numSlices
+ ) {
+ return undefined;
+ }
+ const idx = z * numPixelsPerSlice + y * width + x;
+ return refVoxelManager.getAtIndex(idx);
+ };
+
+ const inRange = (val: number) => val >= positiveMin && val <= positiveMax;
+
+ const ensureSliceLoaded =
+ options.ensureSliceLoaded ??
+ createEnsureSliceLoadedForVolume(referencedVolume);
+
+ const planar = options.planar === true;
+
+ if (planar) {
+ log.info('flood fill: planar mode (fixed slice index k)', { ijkStart });
+ }
+
+ const {
+ sliceMasks,
+ voxelCount: filledVoxelCount,
+ truncated,
+ bbox,
+ } = await floodFill3dSliceLazy(intensityGetter, ijkStart, {
+ width,
+ height,
+ depth: numSlices,
+ equals: (val, _startVal) =>
+ val !== undefined && typeof val === 'number' && inRange(val),
+ ensureSliceLoaded,
+ yieldEvery: options.yieldEvery ?? 500,
+ planar,
+ maxDeltaK: options.maxDeltaK,
+ maxDeltaIJ: options.maxDeltaIJ,
+ isCancelled: options.isCancelled,
+ maxVoxels: options.maxVoxels,
+ shouldContinue: options.shouldContinueRegion,
+ });
+
+ const finalShapeRejected =
+ !truncated &&
+ bbox &&
+ options.shouldContinueRegion &&
+ !options.shouldContinueRegion({ voxelCount: filledVoxelCount, bbox });
+
+ if (truncated || finalShapeRejected) {
+ log.warn(
+ 'flood fill: rejected — region stopped by budget or shape gate; nothing committed',
+ {
+ maxVoxels: options.maxVoxels,
+ voxelCountAtStop: filledVoxelCount,
+ bbox,
+ ijkStart,
+ }
+ );
+ options.onRejected?.({ voxelCount: filledVoxelCount, bbox });
+ return null;
+ }
+
+ if (filledVoxelCount === 0) {
+ log.info(
+ 'flood fill: zero voxels (range may be too tight or seed isolated)',
+ {
+ ijkStart,
+ toleranceMin: positiveMin,
+ toleranceMax: positiveMax,
+ }
+ );
+ return labelmap;
+ }
+
+ // A cancelled fill must not paint: bail before touching the labelmap.
+ if (options.isCancelled?.() === true) {
+ log.info('flood fill: cancellation requested; nothing committed', {
+ ijkStart,
+ voxelCountAtCancel: filledVoxelCount,
+ });
+ return null;
+ }
+
+ const { floodedPoints, voxelCount: committedVoxels } =
+ commitSliceMasksToLabelmapVolume({
+ labelmapVolume: labelmap,
+ sliceMasks,
+ width,
+ height,
+ paintIndex,
+ historyVoxelManager: options.historyVoxelManager,
+ });
+
+ if (committedVoxels === 0 || floodedPoints.length === 0) {
+ log.warn('flood fill: commit produced no labelmap voxels', {
+ filledVoxelCount,
+ committedVoxels,
+ ijkStart,
+ });
+ return labelmap;
+ }
+
+ // Reported to `onCommitted` once cleanup is done — internal island removal
+ // can add voxels beyond the raw flood, and callers (e.g. expand/shrink)
+ // treat this set as "exactly what this run painted".
+ let committedPoints = floodedPoints;
+
+ log.info('flood fill: complete', {
+ voxelCount: floodedPoints.length,
+ ijkStart,
+ paintIndex,
+ usePreview,
+ });
+
+ const applyExternal = options.applyExternalIslandRemoval !== false;
+ const applyInternal =
+ options.applyInternalIslandRemoval !== false && applyExternal;
+
+ if (!applyExternal && !applyInternal) {
+ if (usePreview) {
+ promotePreviewSegmentToFinal(
+ labelmapReadVm,
+ labelmapWriteVm,
+ paintIndex,
+ segmentIndex,
+ floodedPoints,
+ numPixelsPerSlice,
+ width
+ );
+ }
+ options.onCommitted?.(committedPoints);
+ return labelmap;
+ }
+ const islandVerbose = options.islandRemovalVerboseLogging === true;
+
+ const islandRemoval = new IslandRemoval({
+ maxInternalRemove: options.maxInternalRemove ?? 128,
+ fillInternalEdge: false,
+ verboseLogging: islandVerbose,
+ });
+
+ const ijkPoints = [ijkStart];
+ const initialized = islandRemoval.initialize(
+ viewport,
+ options.historyVoxelManager ?? labelmap.voxelManager,
+ {
+ points: ijkPoints,
+ segmentIndex,
+ previewSegmentIndex: usePreview ? paintIndex : segmentIndex,
+ }
+ );
+
+ if (!initialized) {
+ log.warn('island removal: initialize failed', { segmentIndex, ijkStart });
+ if (usePreview) {
+ promotePreviewSegmentToFinal(
+ labelmapReadVm,
+ labelmapWriteVm,
+ paintIndex,
+ segmentIndex,
+ floodedPoints,
+ numPixelsPerSlice,
+ width
+ );
+ }
+ options.onCommitted?.(committedPoints);
+ return labelmap;
+ }
+
+ let islandFloodVoxels = 0;
+ let externalClearedVoxels = 0;
+ let internalSliceCount: number | undefined;
+
+ if (applyExternal) {
+ timeStart(FLOOD_FILL_ISLAND_EXTERNAL_TIMING_LABEL);
+ islandFloodVoxels = islandRemoval.floodFillSegmentIsland();
+ externalClearedVoxels = islandRemoval.removeExternalIslands();
+ timeEnd(FLOOD_FILL_ISLAND_EXTERNAL_TIMING_LABEL);
+ if (applyInternal) {
+ timeStart(FLOOD_FILL_ISLAND_INTERNAL_TIMING_LABEL);
+ const modifiedSlices = islandRemoval.removeInternalIslands();
+ internalSliceCount = modifiedSlices?.length;
+ timeEnd(FLOOD_FILL_ISLAND_INTERNAL_TIMING_LABEL);
+ }
+ }
+
+ log.info('island removal: complete', {
+ segmentIndex,
+ applyExternalIslandRemoval: applyExternal,
+ applyInternalIslandRemoval: applyInternal,
+ floodedPointsBeforeIsland: floodedPoints.length,
+ islandFloodVoxelsFromSegmentSet: islandFloodVoxels,
+ externalIslandClearVoxels: externalClearedVoxels,
+ internalRemovalModifiedSlices: internalSliceCount,
+ islandRemovalVerboseLogging: islandVerbose,
+ });
+
+ // Internal island removal paints hole voxels beyond the raw flood. Track
+ // them by the exact points island removal painted — a value-scan of the
+ // modified slices would over-collect same-segment voxels from earlier
+ // operations — so the reported set is complete in BOTH preview and
+ // direct-paint modes.
+ const internalFilledPoints = applyInternal
+ ? islandRemoval.getInternalFilledPoints()
+ : [];
+ committedPoints =
+ internalFilledPoints.length > 0
+ ? floodedPoints.concat(internalFilledPoints)
+ : floodedPoints;
+
+ if (usePreview) {
+ promotePreviewSegmentToFinal(
+ labelmapReadVm,
+ labelmapWriteVm,
+ paintIndex,
+ segmentIndex,
+ committedPoints,
+ numPixelsPerSlice,
+ width
+ );
+ }
+
+ options.onCommitted?.(committedPoints);
+
+ // Debug visibility: verify final voxel values at flooded points before returning.
+ if (ENABLE_VERBOSE_FLOOD_FILL_LOGS && floodedPoints.length > 0) {
+ let finalSegmentCount = 0;
+ let previewCount = 0;
+ let zeroCount = 0;
+ let otherCount = 0;
+ for (let i = 0; i < floodedPoints.length; i++) {
+ const [x, y, z] = floodedPoints[i];
+ const index = z * numPixelsPerSlice + y * width + x;
+ const value = Number(labelmap.voxelManager.getAtIndex(index) ?? 0);
+ if (value === segmentIndex) {
+ finalSegmentCount += 1;
+ } else if (value === paintIndex) {
+ previewCount += 1;
+ } else if (value === 0) {
+ zeroCount += 1;
+ } else {
+ otherCount += 1;
+ }
+ }
+ console.info('[cornerstone-tools] flood fill final voxel check', {
+ floodedPoints: floodedPoints.length,
+ segmentIndex,
+ paintIndex,
+ usePreview,
+ finalSegmentCount,
+ previewCount,
+ zeroCount,
+ otherCount,
+ });
+ }
+
+ return labelmap;
+ } finally {
+ timeEnd(FLOOD_FILL_RUN_TIMING_LABEL);
+ }
+}
+
+export {
+ runFloodFillSegmentation as default,
+ runFloodFillSegmentation,
+ getPositiveIntensityRange,
+ getPositiveIntensityRangeRaw,
+ getPositiveIntensityRangeVoiMapped,
+ getDisplayVoiSnapshot,
+};
+export type { FloodFillSegmentationOptions };
diff --git a/packages/tools/src/utilities/segmentation/islandRemoval.ts b/packages/tools/src/utilities/segmentation/islandRemoval.ts
index baaada4691..de8acc892c 100644
--- a/packages/tools/src/utilities/segmentation/islandRemoval.ts
+++ b/packages/tools/src/utilities/segmentation/islandRemoval.ts
@@ -65,9 +65,9 @@ export default class IslandRemoval {
// Options to control the fill
/** Fill out to edges, assuming they are smaller in size than maxInternalRemove */
- private fillInternalEdge = false;
+ protected fillInternalEdge = false;
/** Maximum internal size of an island to remove */
- private maxInternalRemove = 128;
+ protected maxInternalRemove = 128;
constructor(options?: {
maxInternalRemove?: number;
@@ -354,11 +354,20 @@ export default class IslandRemoval {
for (let iPrime = rle.start; iPrime < rle.end; iPrime++) {
const clearPoint = toIJK(segmentSet.toIJK(baseIndex + iPrime));
previewVoxelManager.setAtIJKPoint(clearPoint, previewSegmentIndex);
+ this.onInternalPointFilled(clearPoint);
}
});
return previewVoxelManager.getArrayOfModifiedSlices();
}
+ /**
+ * Hook invoked for every internal-island voxel {@link removeInternalIslands}
+ * paints. Subclasses can override it to track the exact painted set.
+ */
+ protected onInternalPointFilled(_point: Types.Point3): void {
+ // no-op in the base implementation
+ }
+
/**
* Determine if the rle `[start...end)` is covered by row completely, by which
* it is meant that the row has RLE elements from the start to the end of the
diff --git a/packages/tools/test/utilities/segmentation/adaptiveRegionIntensityRange.jest.js b/packages/tools/test/utilities/segmentation/adaptiveRegionIntensityRange.jest.js
new file mode 100644
index 0000000000..7ef0a82bd2
--- /dev/null
+++ b/packages/tools/test/utilities/segmentation/adaptiveRegionIntensityRange.jest.js
@@ -0,0 +1,267 @@
+import { describe, it, expect } from '@jest/globals';
+import {
+ probeAdaptiveRegionCore,
+ resolveAdaptiveBandAtTolerance,
+} from '../../../src/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange';
+
+const WIDTH = 100;
+const HEIGHT = 100;
+const DEPTH = 3;
+
+/**
+ * Builds a synthetic slice-replicated volume accessor. `paint` receives (i, j)
+ * and returns the scalar for every k.
+ */
+function makeVolume(paint) {
+ const slice = new Float32Array(WIDTH * HEIGHT);
+ for (let j = 0; j < HEIGHT; j++) {
+ for (let i = 0; i < WIDTH; i++) {
+ slice[j * WIDTH + i] = paint(i, j);
+ }
+ }
+ return {
+ dimensions: [WIDTH, HEIGHT, DEPTH],
+ getScalar: (i, j, _k) => slice[j * WIDTH + i],
+ };
+}
+
+function probeAt(volume, i, j, extra = {}) {
+ return probeAdaptiveRegionCore({
+ dimensions: volume.dimensions,
+ getScalar: volume.getScalar,
+ ijkClick: [i, j, 1],
+ ...extra,
+ });
+}
+
+const BG = 10;
+const FG = 200;
+
+function squareVolume({ cx, cy, half, fg = FG, bg = BG, noise = 0 }) {
+ let n = 0;
+ return makeVolume((i, j) => {
+ const inside = Math.abs(i - cx) <= half && Math.abs(j - cy) <= half;
+ const base = inside ? fg : bg;
+ if (!noise) {
+ return base;
+ }
+ // Deterministic pseudo-noise so tests are reproducible.
+ n = (n * 1103515245 + 12345) & 0x7fffffff;
+ return base + ((n % (2 * noise + 1)) - noise);
+ });
+}
+
+describe('probeAdaptiveRegionCore', () => {
+ it('finds a meaningful region when clicking inside a distinct square', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10 });
+ const result = probeAt(volume, 50, 50);
+
+ expect(result.viable).toBe(true);
+ expect(result.reason).toBe('ok');
+ // 21x21 square = 441 in-plane pixels.
+ expect(result.regionSizePx).toBe(441);
+ expect(result.range).not.toBeNull();
+ expect(result.range.min).toBeLessThanOrEqual(FG);
+ expect(result.range.max).toBeGreaterThanOrEqual(FG);
+ // The one-sided threshold must not reach the background value.
+ expect(result.range.min).toBeGreaterThan(BG);
+ });
+
+ it('accepts an edge click and snaps the seed inward', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10 });
+ // Square spans i,j in [40, 60]; click exactly on the right edge pixel.
+ const result = probeAt(volume, 60, 50);
+
+ expect(result.viable).toBe(true);
+ // Clicked pixel is part of the region; the seed may sit further inside.
+ const [si, sj] = result.range.ijkStart;
+ expect(Math.abs(si - 50)).toBeLessThanOrEqual(10);
+ expect(Math.abs(sj - 50)).toBeLessThanOrEqual(10);
+ expect(result.range.min).toBeGreaterThan(BG);
+ });
+
+ it('blocks clicks on the surroundings of a region (off-target)', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10 });
+ // 2px outside the right edge: a region is nearby (seed snap reaches it)
+ // but the pointer itself is on background — must NOT show plus.
+ const result = probeAt(volume, 62, 50);
+
+ expect(result.viable).toBe(false);
+ expect(result.reason).toBe('off-target');
+ expect(result.range).toBeNull();
+ });
+
+ it('never excludes the hottest core (one-sided band, no holes)', () => {
+ // Lesion with a mid-intensity shell and a much hotter core (PET-style).
+ const volume = makeVolume((i, j) => {
+ const d = Math.max(Math.abs(i - 50), Math.abs(j - 50));
+ if (d <= 4) {
+ return 250; // hot core
+ }
+ if (d <= 10) {
+ return 150; // shell
+ }
+ return BG;
+ });
+ // Click on the shell, far from the core.
+ const result = probeAt(volume, 41, 50);
+
+ expect(result.viable).toBe(true);
+ // Whole lesion including the 9x9 core: 21x21 = 441 pixels.
+ expect(result.regionSizePx).toBe(441);
+ // Upper side is unbounded so a hotter core can never become a hole.
+ expect(result.range.max).toBe(Infinity);
+ expect(result.range.min).toBeGreaterThan(BG);
+ expect(result.range.min).toBeLessThanOrEqual(150);
+ });
+
+ it('segments dark regions on bright background (negative polarity)', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10, fg: 20, bg: 200 });
+ const result = probeAt(volume, 50, 50);
+
+ expect(result.viable).toBe(true);
+ expect(result.regionSizePx).toBe(441);
+ expect(result.range.diagnostics.adaptive.polarity).toBe(-1);
+ // Included side is "darker": no lower limit, threshold below background.
+ expect(result.range.min).toBe(-Infinity);
+ expect(result.range.max).toBeGreaterThanOrEqual(20);
+ expect(result.range.max).toBeLessThan(200);
+ });
+
+ it('reports flat-region on uniform background', () => {
+ const volume = squareVolume({ cx: 20, cy: 20, half: 5 });
+ // Far away from the square, everything nearby and on the ring is BG.
+ const result = probeAt(volume, 75, 75);
+
+ expect(result.viable).toBe(false);
+ expect(result.reason).toBe('flat-region');
+ expect(result.range).toBeNull();
+ });
+
+ it('rejects a speck of a few pixels as too small', () => {
+ const volume = makeVolume((i, j) => (i === 50 && j === 50 ? FG : BG));
+ const result = probeAt(volume, 50, 50);
+
+ expect(result.viable).toBe(false);
+ expect(result.reason).toBe('too-small');
+ });
+
+ it('does not block large regions by physical size (no maximum rule)', () => {
+ // 41x41 px at 4mm spacing = 26896 mm² — big, but bounded and coherent.
+ // Lesion-ness is judged by shape (tool-level 3D gate), never by size.
+ const volume = squareVolume({ cx: 50, cy: 50, half: 20 });
+ const atOneMm = probeAt(volume, 50, 50, { inPlaneSpacing: [1, 1] });
+ const atFourMm = probeAt(volume, 50, 50, { inPlaneSpacing: [4, 4] });
+
+ expect(atOneMm.viable).toBe(true);
+ expect(atFourMm.viable).toBe(true);
+ expect(atFourMm.regionAreaMm2).toBeGreaterThan(6000);
+ });
+
+ it('rejects a region flooding most of the analysis window as unbounded', () => {
+ // 83x83 = 6889 pixels > 60% of the 100x100 window: no visible boundary.
+ const volume = squareVolume({ cx: 50, cy: 50, half: 41 });
+ const result = probeAt(volume, 50, 50);
+
+ expect(result.viable).toBe(false);
+ expect(result.reason).toBe('unbounded');
+ });
+
+ it('tolerates noise inside the region and still bounds it', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10, noise: 8 });
+ const result = probeAt(volume, 50, 50);
+
+ expect(result.viable).toBe(true);
+ // Most of the 441 square pixels should be captured despite noise.
+ expect(result.regionSizePx).toBeGreaterThan(300);
+ expect(result.range.min).toBeGreaterThan(BG + 8);
+ });
+
+ it('does not leak into a separated brighter structure', () => {
+ const volume = makeVolume((i, j) => {
+ if (Math.abs(i - 40) <= 8 && Math.abs(j - 50) <= 8) {
+ return 120; // dimmer target
+ }
+ if (Math.abs(i - 62) <= 8 && Math.abs(j - 50) <= 8) {
+ return 240; // brighter neighbor (separated by background)
+ }
+ return BG;
+ });
+ const result = probeAt(volume, 40, 50);
+
+ expect(result.viable).toBe(true);
+ // 17x17 target only — the brighter neighbor is not connected.
+ expect(result.regionSizePx).toBe(289);
+ expect(result.range.min).toBeGreaterThan(BG);
+ expect(result.range.min).toBeLessThanOrEqual(120);
+ });
+
+ it('maps the threshold back to raw values through a linear VOI', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10 });
+ const result = probeAt(volume, 50, 50, {
+ voiMapping: { voiRange: { lower: 0, upper: 400 } },
+ });
+
+ expect(result.viable).toBe(true);
+ expect(result.range.min).toBeLessThanOrEqual(FG);
+ expect(result.range.max).toBeGreaterThanOrEqual(FG);
+ expect(result.range.min).toBeGreaterThan(BG);
+ });
+
+ it('honors an inverted VOI display', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10 });
+ const result = probeAt(volume, 50, 50, {
+ voiMapping: { voiRange: { lower: 0, upper: 400 }, invert: true },
+ });
+
+ expect(result.viable).toBe(true);
+ expect(result.range.min).toBeLessThanOrEqual(FG);
+ expect(result.range.max).toBeGreaterThanOrEqual(FG);
+ expect(result.range.min).toBeGreaterThan(BG);
+ });
+
+ it('widens the band with toleranceScale (legacy expand)', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10, noise: 8 });
+ const base = probeAt(volume, 50, 50);
+ const expanded = probeAt(volume, 50, 50, { toleranceScale: 1.5 });
+
+ expect(base.viable).toBe(true);
+ expect(expanded.viable).toBe(true);
+ expect(expanded.toleranceBytes).toBeGreaterThan(base.toleranceBytes);
+ });
+
+ it('returns an expand context whose growth curve supports stepping', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10, noise: 8 });
+ const result = probeAt(volume, 50, 50);
+
+ expect(result.viable).toBe(true);
+ const context = result.expandContext;
+ expect(context).toBeDefined();
+ expect(context.chosenToleranceBytes).toBe(result.toleranceBytes);
+ expect(context.growthCurve.length).toBeGreaterThan(0);
+ expect(context.polarity).toBe(1);
+
+ // Rebuilding the band at the chosen tolerance matches the click's band.
+ const sameBand = resolveAdaptiveBandAtTolerance(
+ context,
+ context.chosenToleranceBytes
+ );
+ expect(sameBand.min).toBeCloseTo(result.range.min, 10);
+ expect(sameBand.max).toBe(result.range.max);
+
+ // A deeper tolerance lowers the threshold (wider region for polarity +1).
+ const wider = resolveAdaptiveBandAtTolerance(
+ context,
+ context.chosenToleranceBytes + 20
+ );
+ expect(wider.min).toBeLessThan(sameBand.min);
+ });
+
+ it('reports outside-volume for clicks off the grid', () => {
+ const volume = squareVolume({ cx: 50, cy: 50, half: 10 });
+ const result = probeAt(volume, -5, 50);
+
+ expect(result.viable).toBe(false);
+ expect(result.reason).toBe('outside-volume');
+ });
+});
diff --git a/packages/tools/test/utilities/segmentation/commitSliceMasksToLabelmap.jest.js b/packages/tools/test/utilities/segmentation/commitSliceMasksToLabelmap.jest.js
new file mode 100644
index 0000000000..15c4224ca7
--- /dev/null
+++ b/packages/tools/test/utilities/segmentation/commitSliceMasksToLabelmap.jest.js
@@ -0,0 +1,103 @@
+import { describe, it, expect } from '@jest/globals';
+import { utilities } from '@cornerstonejs/core';
+import { commitSliceMasksToLabelmapVolume } from '../../../src/utilities/segmentation/commitSliceMasksToLabelmap';
+import { FLOOD_SLICE_FLAG_VISITED } from '../../../src/utilities/segmentation/floodFillSliceLazy';
+
+const { VoxelManager } = utilities;
+
+const W = 8;
+const H = 8;
+const D = 3;
+
+function makeLabelmap() {
+ const scalarData = new Uint8Array(W * H * D);
+ const voxelManager = VoxelManager.createScalarVolumeVoxelManager({
+ dimensions: [W, H, D],
+ scalarData,
+ });
+ return {
+ labelmapVolume: { voxelManager, dimensions: [W, H, D], imageIds: [] },
+ voxelManager,
+ scalarData,
+ };
+}
+
+function makeMask(points) {
+ const flags = new Uint8Array(W * H);
+ for (const [x, y] of points) {
+ flags[y * W + x] |= FLOOD_SLICE_FLAG_VISITED;
+ }
+ return flags;
+}
+
+describe('commitSliceMasksToLabelmapVolume history recording', () => {
+ it('paints the labelmap without a history manager (baseline)', () => {
+ const { labelmapVolume, voxelManager } = makeLabelmap();
+ const sliceMasks = new Map([
+ [
+ 1,
+ makeMask([
+ [2, 2],
+ [3, 2],
+ ]),
+ ],
+ ]);
+
+ const { voxelCount } = commitSliceMasksToLabelmapVolume({
+ labelmapVolume,
+ sliceMasks,
+ width: W,
+ height: H,
+ paintIndex: 255,
+ });
+
+ expect(voxelCount).toBe(2);
+ expect(voxelManager.getAtIJK(2, 2, 1)).toBe(255);
+ expect(voxelManager.getAtIJK(3, 2, 1)).toBe(255);
+ });
+
+ it('records original values through a history voxel manager so undo restores them', () => {
+ const { labelmapVolume, voxelManager } = makeLabelmap();
+ // Pre-existing segment data that the fill will overwrite.
+ voxelManager.setAtIJK(2, 2, 1, 7);
+
+ const historyVoxelManager =
+ VoxelManager.createRLEHistoryVoxelManager(voxelManager);
+
+ const sliceMasks = new Map([
+ [
+ 1,
+ makeMask([
+ [2, 2],
+ [3, 2],
+ [4, 2],
+ ]),
+ ],
+ ]);
+ const { voxelCount } = commitSliceMasksToLabelmapVolume({
+ labelmapVolume,
+ sliceMasks,
+ width: W,
+ height: H,
+ paintIndex: 255,
+ historyVoxelManager,
+ });
+
+ // Writes went through to the labelmap...
+ expect(voxelCount).toBe(3);
+ expect(voxelManager.getAtIJK(2, 2, 1)).toBe(255);
+ expect(voxelManager.getAtIJK(3, 2, 1)).toBe(255);
+ expect(voxelManager.getAtIJK(4, 2, 1)).toBe(255);
+ // ...and the history layer knows which slices changed.
+ expect(historyVoxelManager.modifiedSlices.size).toBeGreaterThan(0);
+
+ // Undo = write the recorded original values back (what restoreMemo does).
+ historyVoxelManager.forEach(({ value, pointIJK }) => {
+ voxelManager.setAtIJKPoint(pointIJK, value);
+ });
+
+ expect(voxelManager.getAtIJK(2, 2, 1)).toBe(7); // pre-existing restored
+ expect(voxelManager.getAtIJK(3, 2, 1)).toBe(0);
+ expect(voxelManager.getAtIJK(4, 2, 1)).toBe(0);
+ });
+});
diff --git a/packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js b/packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js
new file mode 100644
index 0000000000..15b951ce63
--- /dev/null
+++ b/packages/tools/test/utilities/segmentation/floodFillIslandRemoval.jest.js
@@ -0,0 +1,125 @@
+import {
+ RenderingEngine,
+ Enums,
+ cache,
+ volumeLoader,
+ setUseCPURendering,
+} from '@cornerstonejs/core';
+import {
+ describe,
+ it,
+ expect,
+ beforeEach,
+ afterEach,
+ beforeAll,
+} from '@jest/globals';
+import IslandRemoval from '../../../src/utilities/segmentation/floodFillIslandRemoval';
+
+const { OrientationAxis, ViewportType } = Enums;
+
+const dimensions = [32, 32, 5];
+const [width, height, depth] = dimensions;
+const imageSize = width * height * depth;
+const viewportId = 'viewportId';
+const renderingEngineId = 'renderingEngineId';
+const volumeId = 'volumeId';
+
+describe('floodFillIslandRemoval internal fill tracking', function () {
+ let islandRemoval, segmentationVoxels, renderingEngine, viewport;
+
+ beforeAll(() => {
+ window.devicePixelRatio = 1;
+ setUseCPURendering(true);
+ });
+
+ beforeEach(() => {
+ cache.purgeCache();
+ islandRemoval = new IslandRemoval();
+ const scalarData = new Uint8Array(imageSize);
+
+ const volume = volumeLoader.createLocalVolume(volumeId, {
+ dimensions,
+ spacing: [1, 1, 1],
+ scalarData,
+ direction: [1, 0, 0, 0, 1, 0, 0, 0, 1],
+ origin: [0, 0, 0],
+ });
+
+ segmentationVoxels = volume.voxelManager;
+
+ renderingEngine = new RenderingEngine(renderingEngineId);
+ createViewport(renderingEngine, OrientationAxis.ACQUISITION, width, height);
+ viewport = renderingEngine.getViewport(viewportId);
+ });
+
+ afterEach(() => {
+ cache.purgeCache();
+ renderingEngine?.destroy();
+ });
+
+ it('reports exactly the hole voxels removeInternalIslands painted', () => {
+ const x = 10;
+ const y = 10;
+ const z = 2;
+ const w = 5;
+ const h = 5;
+
+ createBox(segmentationVoxels, 1, x, y, z, w, h);
+ // A pre-existing same-segment voxel elsewhere on the slice must NOT be
+ // reported — the tracking is by painted point, not by slice value-scan.
+ segmentationVoxels.setAtIJK(2, 2, z, 1);
+
+ const initialized = islandRemoval.initialize(viewport, segmentationVoxels, {
+ segmentIndex: 1,
+ points: [[x, y, z]],
+ });
+ expect(initialized).toBe(true);
+ islandRemoval.floodFillSegmentIsland();
+ expect(islandRemoval.getInternalFilledPoints()).toEqual([]);
+
+ islandRemoval.removeInternalIslands();
+
+ // 5x5 outline encloses a 3x3 hole.
+ const filled = islandRemoval.getInternalFilledPoints();
+ expect(filled.length).toBe((w - 2) * (h - 2));
+ for (const point of filled) {
+ expect(point[0]).toBeGreaterThan(x);
+ expect(point[0]).toBeLessThan(x + w - 1);
+ expect(point[1]).toBeGreaterThan(y);
+ expect(point[1]).toBeLessThan(y + h - 1);
+ expect(point[2]).toBe(z);
+ expect(segmentationVoxels.getAtIJKPoint(point)).toBe(1);
+ }
+ });
+});
+
+function createBox(voxels, segmentIndex, x = 10, y = 10, z = 2, w = 5, h = 5) {
+ for (let dx = 0; dx < w; dx++) {
+ voxels.setAtIJK(x + dx, y, z, segmentIndex);
+ voxels.setAtIJK(x + dx, y + h - 1, z, segmentIndex);
+ }
+ for (let dy = 0; dy < h; dy++) {
+ voxels.setAtIJK(x, y + dy, z, segmentIndex);
+ voxels.setAtIJK(x + w - 1, y + dy, z, segmentIndex);
+ }
+}
+
+function createViewport(renderingEngine, _orientation, width, height) {
+ const element = document.createElement('div');
+
+ element.style.width = `${width}px`;
+ element.style.height = `${height}px`;
+ document.body.appendChild(element);
+
+ renderingEngine.setViewports([
+ {
+ viewportId: viewportId,
+ type: ViewportType.STACK,
+ element,
+ defaultOptions: {
+ background: [1, 0, 1], // pinkish background
+ },
+ },
+ ]);
+ return element;
+}
diff --git a/packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js b/packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js
new file mode 100644
index 0000000000..a80e9c851c
--- /dev/null
+++ b/packages/tools/test/utilities/segmentation/floodFillSliceLazy.jest.js
@@ -0,0 +1,110 @@
+import { describe, it, expect } from '@jest/globals';
+import { floodFill3dSliceLazy } from '../../../src/utilities/segmentation/floodFillSliceLazy';
+
+const SIZE = 40;
+
+// Uniform volume: every voxel matches, so an unbounded flood fills everything.
+const getter = (_x, _y, _z) => 1;
+const equals = (val) => val === 1;
+
+describe('floodFill3dSliceLazy budget and shape gate', () => {
+ it('fills completely when unconstrained and reports the bounding box', async () => {
+ const result = await floodFill3dSliceLazy(getter, [20, 20, 1], {
+ width: SIZE,
+ height: SIZE,
+ depth: 3,
+ equals,
+ yieldEvery: 0,
+ });
+ expect(result.truncated).toBe(false);
+ expect(result.voxelCount).toBe(SIZE * SIZE * 3);
+ expect(result.bbox).toEqual({
+ min: [0, 0, 0],
+ max: [SIZE - 1, SIZE - 1, 2],
+ });
+ });
+
+ it('stops and flags truncated when the compute budget is exceeded', async () => {
+ const result = await floodFill3dSliceLazy(getter, [20, 20, 1], {
+ width: SIZE,
+ height: SIZE,
+ depth: 3,
+ equals,
+ yieldEvery: 0,
+ maxVoxels: 500,
+ });
+ expect(result.truncated).toBe(true);
+ // It stops promptly after crossing the budget, well short of a full fill.
+ expect(result.voxelCount).toBeLessThan(SIZE * SIZE * 3);
+ });
+
+ it('stops when shouldContinue rejects the growing region', async () => {
+ const seenStats = [];
+ const result = await floodFill3dSliceLazy(getter, [20, 20, 1], {
+ width: SIZE,
+ height: SIZE,
+ depth: 3,
+ equals,
+ yieldEvery: 0,
+ validateEvery: 256,
+ shouldContinue: (stats) => {
+ seenStats.push(stats);
+ return stats.voxelCount < 1000;
+ },
+ });
+ expect(result.truncated).toBe(true);
+ expect(result.voxelCount).toBeLessThan(SIZE * SIZE * 3);
+ expect(seenStats.length).toBeGreaterThan(0);
+ // Stats carry a live bounding box for shape checks.
+ expect(seenStats[0].bbox.min.length).toBe(3);
+ expect(seenStats[0].bbox.max.length).toBe(3);
+ });
+
+ it('loads slices through ensureSliceLoaded before reading from them', async () => {
+ // Voxels read as unloaded (undefined) until ensureSliceLoaded marks their
+ // slice; the fill must request every slice it visits.
+ 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);
+ // A 1x1 column through all three slices: the seed plus its two through-
+ // slice neighbors, each only readable after its slice was loaded.
+ expect(result.voxelCount).toBe(3);
+ expect([...loaded].sort((a, b) => a - b)).toEqual([0, 1, 2]);
+ });
+
+ it('does not truncate a region that ends on its own within the budget', async () => {
+ // Planar 5x5 region bounded by maxDeltaIJ.
+ const result = await floodFill3dSliceLazy(getter, [20, 20, 1], {
+ width: SIZE,
+ height: SIZE,
+ depth: 3,
+ equals,
+ yieldEvery: 0,
+ planar: true,
+ maxDeltaIJ: 2,
+ maxVoxels: 25,
+ shouldContinue: () => true,
+ });
+ expect(result.truncated).toBe(false);
+ expect(result.voxelCount).toBe(25);
+ expect(result.bbox).toEqual({
+ min: [18, 18, 1],
+ max: [22, 22, 1],
+ });
+ });
+});
diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json
index 34f09ae6ca..980a4292af 100644
--- a/utils/ExampleRunner/example-info.json
+++ b/utils/ExampleRunner/example-info.json
@@ -367,6 +367,10 @@
}
},
"tools-segmentation": {
+ "clickSegment": {
+ "name": "Click Segment Tool",
+ "description": "Click-to-segment lesions on PET: hover to scout candidates (plus/blocked cursor), click once to segment with a dynamically-derived one-sided threshold, then Shrink/Expand to fine-tune. No configuration."
+ },
"labelmapRendering": {
"name": "Labelmap Segmentation Rendering",
"description": "Demonstrates how to add a Labelmap to the viewports for rendering"
@@ -680,7 +684,6 @@
"description": "Volume Decimated loading"
}
},
-
"adapters": {
"segmentationStack": {
"name": "DICOM SEG Stack",
diff --git a/utils/demo/helpers/addDropdownToToolbar.ts b/utils/demo/helpers/addDropdownToToolbar.ts
index 5a25db3ffe..a68632eeca 100644
--- a/utils/demo/helpers/addDropdownToToolbar.ts
+++ b/utils/demo/helpers/addDropdownToToolbar.ts
@@ -17,7 +17,9 @@ export type optionTypeValues =
interface configDropdown extends configElement {
id?: string;
placeholder?: string;
- options: optionTypeDefaultValue & optionTypeValues;
+ options:
+ | (optionTypeDefaultValue & optionTypeValues)
+ | Promise;
onSelectedValueChange: (key: number | string, value?: any) => void;
toolGroupId?: string | string[];
label?: configElement;
@@ -25,17 +27,70 @@ interface configDropdown extends configElement {
container?: HTMLElement;
}
-export default function addDropDownToToolbar(config: configDropdown): void {
- config.container =
- config.container ?? document.getElementById('demo-toolbar');
-
+function applyDropdownOptions(
+ elSelect: HTMLSelectElement,
+ options: optionTypeDefaultValue & optionTypeValues
+): Map | undefined {
const {
map,
values = [...map.keys()],
labels,
defaultValue,
- defaultIndex = defaultValue === undefined && 0,
- } = config.options as any;
+ defaultIndex: explicitDefaultIndex,
+ } = options as any;
+
+ const existingLoading = elSelect.querySelector('option[data-loading="true"]');
+ if (existingLoading) {
+ existingLoading.remove();
+ }
+
+ // Preserve a configured placeholder (disabled prompt) across re-render. Detach
+ // it before clearing, then re-add it as the first, selected option.
+ const placeholderOption = elSelect.querySelector(
+ 'option[data-placeholder="true"]'
+ );
+ if (placeholderOption) {
+ placeholderOption.remove();
+ }
+
+ while (elSelect.options.length > 0) {
+ elSelect.remove(0);
+ }
+
+ if (placeholderOption) {
+ placeholderOption.selected = true;
+ elSelect.append(placeholderOption);
+ }
+
+ // Only auto-select the first value when there is no placeholder prompt to keep
+ // selected. An explicitly configured defaultIndex still wins.
+ const defaultIndex =
+ explicitDefaultIndex ??
+ (defaultValue === undefined && !placeholderOption ? 0 : -1);
+
+ values.forEach((value, index) => {
+ const elOption = document.createElement('option');
+ const stringValue = String(value);
+ elOption.value = stringValue;
+ elOption.innerText = labels?.[index] ?? stringValue;
+
+ if (value === defaultValue || index === defaultIndex) {
+ elOption.selected = true;
+
+ if (map?.has(value)) {
+ map.get(value).selected = true;
+ }
+ }
+
+ elSelect.append(elOption);
+ });
+
+ return map;
+}
+
+export default function addDropDownToToolbar(config: configDropdown): void {
+ config.container =
+ config.container ?? document.getElementById('demo-toolbar');
// Create label element if labelText is provided
if (config.label || config.labelText) {
@@ -60,12 +115,14 @@ export default function addDropDownToToolbar(config: configDropdown): void {
);
}
+ let currentMap: Map | undefined;
+
//
const fnChange = (evt: Event) => {
const elSelect = evt.target;
const { value: key } = elSelect;
if (elSelect) {
- config.onSelectedValueChange(key, map?.get(key));
+ config.onSelectedValueChange(key, currentMap?.get(key));
}
};
@@ -92,25 +149,46 @@ export default function addDropDownToToolbar(config: configDropdown): void {
},
html: config.placeholder,
});
+ // Marked so applyDropdownOptions can preserve it across (re)render instead of
+ // wiping it and silently auto-selecting the first real value.
+ elOption.dataset.placeholder = 'true';
elSelect.append(elOption);
}
- values.forEach((value, index) => {
- const elOption = document.createElement('option');
- const stringValue = String(value);
- elOption.value = stringValue;
- elOption.innerText = labels?.[index] ?? stringValue;
-
- if (value === defaultValue || index === defaultIndex) {
- elOption.selected = true;
-
- if (map) {
- map.get(value).selected = true;
- }
- }
-
- elSelect.append(elOption);
- });
+ const maybePromise = config.options as
+ | (optionTypeDefaultValue & optionTypeValues)
+ | Promise;
+ if (typeof (maybePromise as Promise)?.then === 'function') {
+ elSelect.disabled = true;
+ const elLoadingOption = document.createElement('option');
+ elLoadingOption.value = '';
+ elLoadingOption.innerText = 'Loading...';
+ elLoadingOption.selected = true;
+ elLoadingOption.dataset.loading = 'true';
+ elSelect.append(elLoadingOption);
+
+ (maybePromise as Promise)
+ .then((resolvedOptions) => {
+ currentMap = applyDropdownOptions(elSelect, resolvedOptions);
+ })
+ .catch((error) => {
+ console.error('addDropdownToToolbar: failed to resolve options', error);
+ // Same render path as success so placeholder/loading bookkeeping stays
+ // consistent; the single error option carries an empty value.
+ currentMap = applyDropdownOptions(elSelect, {
+ values: [''],
+ labels: ['Failed to load'],
+ } as optionTypeDefaultValue & optionTypeValues);
+ })
+ .finally(() => {
+ elSelect.disabled = false;
+ });
+ } else {
+ currentMap = applyDropdownOptions(
+ elSelect,
+ maybePromise as optionTypeDefaultValue & optionTypeValues
+ );
+ }
return elSelect;
}
diff --git a/utils/demo/helpers/index.js b/utils/demo/helpers/index.js
index de07d8163d..a1b4478365 100644
--- a/utils/demo/helpers/index.js
+++ b/utils/demo/helpers/index.js
@@ -41,6 +41,7 @@ import setCtTransferFunctionForVolumeActor, {
import setPetColorMapTransferFunctionForVolumeActor from './setPetColorMapTransferFunctionForVolumeActor';
import setPetTransferFunctionForVolumeActor from './setPetTransferFunctionForVolumeActor';
import setTitleAndDescription from './setTitleAndDescription';
+import validateAndSortVolumeIds from './validateAndSortVolumeIds';
import { wadoURICreateImageIds } from './WADOURICreateImageIds';
import {
@@ -83,6 +84,7 @@ export {
setPetColorMapTransferFunctionForVolumeActor,
setPetTransferFunctionForVolumeActor,
setTitleAndDescription,
+ validateAndSortVolumeIds,
wadoURICreateImageIds,
createAndCacheGeometriesFromContours,
createAndCacheGeometriesFromSurfaces,
diff --git a/utils/demo/helpers/validateAndSortVolumeIds.js b/utils/demo/helpers/validateAndSortVolumeIds.js
new file mode 100644
index 0000000000..df27e01005
--- /dev/null
+++ b/utils/demo/helpers/validateAndSortVolumeIds.js
@@ -0,0 +1,156 @@
+import { metaData, utilities as csUtils } from '@cornerstonejs/core';
+
+const { sortImageIdsAndGetSpacing } = csUtils;
+
+function approxEqual(a, b, tol = 1e-3) {
+ return Math.abs(a - b) <= tol;
+}
+
+function getMedian(values) {
+ if (!values?.length) {
+ return 0;
+ }
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+ return sorted.length % 2 === 0
+ ? (sorted[mid - 1] + sorted[mid]) / 2
+ : sorted[mid];
+}
+
+function projectOntoNormal(point, normal) {
+ return point[0] * normal[0] + point[1] * normal[1] + point[2] * normal[2];
+}
+
+function hasFiniteValues(values, length) {
+ return (
+ Array.isArray(values) &&
+ values.length >= length &&
+ values.slice(0, length).every((value) => Number.isFinite(Number(value)))
+ );
+}
+
+export default function validateAndSortVolumeIds(imageIds, options = {}) {
+ const {
+ maxSpacingVariationFraction = 0.1,
+ minSpacingVariationAbsMm = 0.1,
+ } = options;
+
+ if (!imageIds?.length) {
+ return { valid: false, sortedImageIds: [], reason: 'no instances' };
+ }
+ if (imageIds.length < 2) {
+ return {
+ valid: false,
+ sortedImageIds: [...imageIds],
+ reason: 'single-slice series',
+ };
+ }
+
+ let sortedImageIds = [...imageIds];
+ try {
+ // Pass a copy: the sorter can reverse the array it is given in place.
+ sortedImageIds = sortImageIdsAndGetSpacing([...imageIds]).sortedImageIds;
+ } catch (err) {
+ return {
+ valid: false,
+ sortedImageIds: [...imageIds],
+ reason: `unable to sort by volumetric position: ${err?.message || err}`,
+ };
+ }
+
+ const firstPlane = metaData.get('imagePlaneModule', sortedImageIds[0]);
+ const firstFor = firstPlane?.frameOfReferenceUID;
+ const firstIop = firstPlane?.imageOrientationPatient;
+ if (
+ !hasFiniteValues(firstIop, 6) ||
+ !hasFiniteValues(firstPlane?.imagePositionPatient, 3) ||
+ !firstFor
+ ) {
+ return {
+ valid: false,
+ sortedImageIds,
+ reason: 'missing image-plane metadata (FOR/IOP/IPP)',
+ };
+ }
+
+ const row = [firstIop[0], firstIop[1], firstIop[2]];
+ const col = [firstIop[3], firstIop[4], firstIop[5]];
+ const normal = [
+ row[1] * col[2] - row[2] * col[1],
+ row[2] * col[0] - row[0] * col[2],
+ row[0] * col[1] - row[1] * col[0],
+ ];
+ const normalMag = Math.hypot(normal[0], normal[1], normal[2]);
+ if (normalMag < 1e-6) {
+ return { valid: false, sortedImageIds, reason: 'invalid orientation normal' };
+ }
+ normal[0] /= normalMag;
+ normal[1] /= normalMag;
+ normal[2] /= normalMag;
+
+ const projectedPositions = [];
+ for (const imageId of sortedImageIds) {
+ const plane = metaData.get('imagePlaneModule', imageId);
+ const forUid = plane?.frameOfReferenceUID;
+ const iop = plane?.imageOrientationPatient;
+ const ipp = plane?.imagePositionPatient;
+
+ if (!forUid || forUid !== firstFor) {
+ return {
+ valid: false,
+ sortedImageIds,
+ reason: 'frame of reference changes between slices',
+ };
+ }
+ if (!hasFiniteValues(iop, 6) || !hasFiniteValues(ipp, 3)) {
+ return {
+ valid: false,
+ sortedImageIds,
+ reason: 'missing per-slice orientation/position metadata',
+ };
+ }
+ for (let i = 0; i < 6; i++) {
+ if (!approxEqual(Number(iop[i]), Number(firstIop[i]), 1e-3)) {
+ return {
+ valid: false,
+ sortedImageIds,
+ reason: 'image orientation changes between slices',
+ };
+ }
+ }
+ projectedPositions.push(projectOntoNormal(ipp, normal));
+ }
+
+ const diffs = [];
+ for (let i = 1; i < projectedPositions.length; i++) {
+ const d = Math.abs(projectedPositions[i] - projectedPositions[i - 1]);
+ // Any zero gap means a duplicate/co-located slice, even if other gaps are
+ // valid (e.g. [0, 1, 1, 2]). Skipping zeros here would hide that.
+ if (d <= 1e-6) {
+ return {
+ valid: false,
+ sortedImageIds,
+ reason: 'zero or duplicate slice spacing',
+ };
+ }
+ diffs.push(d);
+ }
+
+ const medianSpacing = getMedian(diffs);
+ const spacingTolerance = Math.max(
+ minSpacingVariationAbsMm,
+ medianSpacing * maxSpacingVariationFraction
+ );
+ const inconsistent = diffs.some(
+ (d) => Math.abs(d - medianSpacing) > spacingTolerance
+ );
+ if (inconsistent) {
+ return {
+ valid: false,
+ sortedImageIds,
+ reason: `inconsistent slice spacing (median=${medianSpacing.toFixed(3)} mm)`,
+ };
+ }
+
+ return { valid: true, sortedImageIds };
+}