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 }; +}