@cornerstonejs/tools 5.4.12 → 5.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/esm/eventListeners/segmentation/segmentationDataModifiedEventListener.js +2 -2
  2. package/dist/esm/index.d.ts +2 -2
  3. package/dist/esm/index.js +2 -2
  4. package/dist/esm/stateManagement/segmentation/SegmentationRenderingEngine.d.ts +2 -1
  5. package/dist/esm/stateManagement/segmentation/SegmentationRenderingEngine.js +45 -1
  6. package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.d.ts +12 -2
  7. package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.js +21 -1
  8. package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapImageReferenceResolver.d.ts +5 -0
  9. package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapImageReferenceResolver.js +47 -1
  10. package/dist/esm/stateManagement/segmentation/labelmapModel/privateLabelmap.d.ts +5 -1
  11. package/dist/esm/stateManagement/segmentation/labelmapModel/privateLabelmap.js +49 -4
  12. package/dist/esm/tools/annotation/ClickSegmentTool.d.ts +51 -0
  13. package/dist/esm/tools/annotation/ClickSegmentTool.js +748 -0
  14. package/dist/esm/tools/annotation/regionSegmentHoverCursors.d.ts +4 -0
  15. package/dist/esm/tools/annotation/regionSegmentHoverCursors.js +4 -0
  16. package/dist/esm/tools/displayTools/Labelmap/addVolumesAsIndependentComponents.js +42 -11
  17. package/dist/esm/tools/index.d.ts +2 -1
  18. package/dist/esm/tools/index.js +2 -1
  19. package/dist/esm/tools/segmentation/BrushTool.js +1 -0
  20. package/dist/esm/tools/segmentation/strategies/BrushStrategy.js +21 -0
  21. package/dist/esm/tools/segmentation/strategies/utils/crossLayerErase.js +20 -0
  22. package/dist/esm/utilities/segmentation/commitSliceMasksToLabelmap.d.ts +12 -0
  23. package/dist/esm/utilities/segmentation/commitSliceMasksToLabelmap.js +128 -0
  24. package/dist/esm/utilities/segmentation/createEnsureSliceLoadedForVolume.d.ts +2 -0
  25. package/dist/esm/utilities/segmentation/createEnsureSliceLoadedForVolume.js +36 -0
  26. package/dist/esm/utilities/segmentation/createLabelmapMemo.d.ts +7 -0
  27. package/dist/esm/utilities/segmentation/createLabelmapMemo.js +41 -8
  28. package/dist/esm/utilities/segmentation/floodFillIslandRemoval.d.ts +20 -0
  29. package/dist/esm/utilities/segmentation/floodFillIslandRemoval.js +118 -0
  30. package/dist/esm/utilities/segmentation/floodFillSliceLazy.d.ts +33 -0
  31. package/dist/esm/utilities/segmentation/floodFillSliceLazy.js +167 -0
  32. package/dist/esm/utilities/segmentation/growCut/floodFillIntensityRangeTypes.d.ts +51 -0
  33. package/dist/esm/utilities/segmentation/growCut/floodFillIntensityRangeTypes.js +0 -0
  34. package/dist/esm/utilities/segmentation/growCut/getViewportVoiMappingForVolume.d.ts +10 -0
  35. package/dist/esm/utilities/segmentation/growCut/getViewportVoiMappingForVolume.js +21 -0
  36. package/dist/esm/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.d.ts +57 -0
  37. package/dist/esm/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.js +559 -0
  38. package/dist/esm/utilities/segmentation/growCut/neighborhoodStats.d.ts +10 -0
  39. package/dist/esm/utilities/segmentation/growCut/neighborhoodStats.js +44 -0
  40. package/dist/esm/utilities/segmentation/growCut/runFloodFillSegmentation.d.ts +61 -0
  41. package/dist/esm/utilities/segmentation/growCut/runFloodFillSegmentation.js +524 -0
  42. package/dist/esm/utilities/segmentation/islandRemoval.d.ts +3 -2
  43. package/dist/esm/utilities/segmentation/islandRemoval.js +3 -0
  44. package/dist/esm/version.d.ts +1 -1
  45. package/dist/esm/version.js +1 -1
  46. package/package.json +4 -4
@@ -0,0 +1,748 @@
1
+ import { cache, Enums as CoreEnums, eventTarget, utilities as csUtils, getEnabledElement, getRenderingEngine, } from '@cornerstonejs/core';
2
+ import GrowCutBaseTool from '../base/GrowCutBaseTool.js';
3
+ import { runFloodFillSegmentation } from '../../utilities/segmentation/growCut/runFloodFillSegmentation.js';
4
+ import { floodFill3dSliceLazy } from '../../utilities/segmentation/floodFillSliceLazy.js';
5
+ import { probeAdaptiveRegion, resolveAdaptiveBandAtTolerance, } from '../../utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.js';
6
+ import { getViewportVoiMappingForVolume } from '../../utilities/segmentation/growCut/getViewportVoiMappingForVolume.js';
7
+ import { activeSegmentation } from '../../stateManagement/segmentation/index.js';
8
+ import { triggerSegmentationDataModified } from '../../stateManagement/segmentation/triggerSegmentationEvents.js';
9
+ import * as LabelmapMemo from '../../utilities/segmentation/createLabelmapMemo.js';
10
+ import { PLUS_CURSOR, BLOCKED_CURSOR, PENDING_CURSOR, } from './regionSegmentHoverCursors.js';
11
+ import { ToolModes } from '../../enums/index.js';
12
+ const { growCutLog } = csUtils.logger;
13
+ const HOVER_PROBE_THROTTLE_MS = 100;
14
+ const PROBE_REUSE_DISTANCE_PX = 6;
15
+ const PROBE_FRESH_MS = 1500;
16
+ const EXPAND_STEP_FACTOR = 1.15;
17
+ const SHRINK_STEP_FACTOR = 0.85;
18
+ const MIN_COMPACTNESS = 0.08;
19
+ const COMPACTNESS_CHECK_MIN_VOLUME_MM3 = 2000;
20
+ const COMPUTE_BUDGET_MM3 = 2_000_000;
21
+ const NOT_LESION_MESSAGE = 'The region here does not form a self-contained, lesion-like shape; nothing was segmented.';
22
+ const DRY_RUN_YIELD_EVERY = 10_000;
23
+ const CONSENSUS_MIN_NEIGHBOR_VOTES = 2;
24
+ const VERDICT_CACHE_TTL_MS = 2500;
25
+ const MAX_REJECTED_REGIONS = 8;
26
+ class ClickSegmentTool extends GrowCutBaseTool {
27
+ constructor(toolProps = {}, defaultToolProps = {
28
+ supportedInteractionTypes: ['Mouse', 'Touch'],
29
+ configuration: {
30
+ maxDeltaK: 25,
31
+ maxDeltaIJ: 512,
32
+ actions: {
33
+ cancelInProgress: {
34
+ method: 'cancelInProgress',
35
+ bindings: [
36
+ {
37
+ key: 'Escape',
38
+ },
39
+ ],
40
+ },
41
+ },
42
+ },
43
+ }) {
44
+ super(toolProps, defaultToolProps);
45
+ this.segmentationInProgress = false;
46
+ this.hoverThrottleTimer = null;
47
+ this.pendingHoverEvent = null;
48
+ this.lastHoverElement = null;
49
+ this.lastProbe = null;
50
+ this.lastClick = null;
51
+ this.clickAbortController = null;
52
+ this.dryRunToken = null;
53
+ this.confirmedRegion = null;
54
+ this.rejectedRegions = [];
55
+ }
56
+ notifySegmentationError(message) {
57
+ const event = new CustomEvent(CoreEnums.Events.ERROR_EVENT, {
58
+ detail: {
59
+ type: 'Segmentation',
60
+ message,
61
+ },
62
+ cancelable: true,
63
+ });
64
+ eventTarget.dispatchEvent(event);
65
+ }
66
+ cancelInProgress() {
67
+ return this.cancelActiveOperation();
68
+ }
69
+ cancelActiveOperation() {
70
+ if (!this.clickAbortController ||
71
+ this.clickAbortController.signal.aborted) {
72
+ return false;
73
+ }
74
+ this.clickAbortController.abort();
75
+ return true;
76
+ }
77
+ clearHoverState() {
78
+ if (this.hoverThrottleTimer !== null) {
79
+ window.clearTimeout(this.hoverThrottleTimer);
80
+ this.hoverThrottleTimer = null;
81
+ }
82
+ this.pendingHoverEvent = null;
83
+ this.lastProbe = null;
84
+ this.confirmedRegion = null;
85
+ this.rejectedRegions = [];
86
+ if (this.dryRunToken) {
87
+ this.dryRunToken.cancelled = true;
88
+ this.dryRunToken = null;
89
+ }
90
+ if (this.lastHoverElement) {
91
+ this.lastHoverElement.style.cursor = '';
92
+ this.lastHoverElement = null;
93
+ }
94
+ }
95
+ pointerOnConfirmedRegion(refVolume, viewport, world) {
96
+ const region = this.confirmedRegion;
97
+ if (!region ||
98
+ region.viewportId !== viewport.id ||
99
+ Date.now() > region.expiresAt) {
100
+ return false;
101
+ }
102
+ const ijk = csUtils
103
+ .transformWorldToIndex(refVolume.imageData, world)
104
+ .map(Math.round);
105
+ for (let axis = 0; axis < 3; axis++) {
106
+ if (ijk[axis] < region.bbox.min[axis] ||
107
+ ijk[axis] > region.bbox.max[axis]) {
108
+ return false;
109
+ }
110
+ }
111
+ const [width, height] = refVolume.dimensions;
112
+ const scalar = Number(refVolume.voxelManager.getAtIndex(ijk[2] * width * height + ijk[1] * width + ijk[0]));
113
+ if (!Number.isFinite(scalar) ||
114
+ scalar < region.bandMin ||
115
+ scalar > region.bandMax) {
116
+ return false;
117
+ }
118
+ region.expiresAt = Date.now() + VERDICT_CACHE_TTL_MS;
119
+ return true;
120
+ }
121
+ static bandsAreSimilar(aMin, aMax, bMin, bMax) {
122
+ const edgeMatches = (a, b) => {
123
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
124
+ return a === b;
125
+ }
126
+ const scale = Math.max(Math.abs(a), Math.abs(b), 1e-6);
127
+ return Math.abs(a - b) <= 0.25 * scale;
128
+ };
129
+ return edgeMatches(aMin, bMin) && edgeMatches(aMax, bMax);
130
+ }
131
+ matchesRejectedRegion(viewportId, range) {
132
+ const now = Date.now();
133
+ this.rejectedRegions = this.rejectedRegions.filter((entry) => entry.expiresAt > now);
134
+ const seed = range.ijkStart;
135
+ const match = this.rejectedRegions.find((entry) => {
136
+ if (entry.viewportId !== viewportId) {
137
+ return false;
138
+ }
139
+ for (let axis = 0; axis < 3; axis++) {
140
+ if (seed[axis] < entry.bbox.min[axis] ||
141
+ seed[axis] > entry.bbox.max[axis]) {
142
+ return false;
143
+ }
144
+ }
145
+ return ClickSegmentTool.bandsAreSimilar(range.min, range.max, entry.bandMin, entry.bandMax);
146
+ });
147
+ if (match) {
148
+ match.expiresAt = now + VERDICT_CACHE_TTL_MS;
149
+ return true;
150
+ }
151
+ return false;
152
+ }
153
+ rememberRejectedRegion(viewportId, bbox, bandMin, bandMax) {
154
+ if (!bbox) {
155
+ return;
156
+ }
157
+ this.rejectedRegions.push({
158
+ viewportId,
159
+ bbox: {
160
+ min: [...bbox.min],
161
+ max: [...bbox.max],
162
+ },
163
+ bandMin,
164
+ bandMax,
165
+ expiresAt: Date.now() + VERDICT_CACHE_TTL_MS,
166
+ });
167
+ if (this.rejectedRegions.length > MAX_REJECTED_REGIONS) {
168
+ this.rejectedRegions.splice(0, this.rejectedRegions.length - MAX_REJECTED_REGIONS);
169
+ }
170
+ }
171
+ safeSpacing(volume) {
172
+ const spacing = volume.spacing ?? [1, 1, 1];
173
+ return [0, 1, 2].map((axis) => Number.isFinite(spacing[axis]) && spacing[axis] > 0 ? spacing[axis] : 1);
174
+ }
175
+ computeVoxelBudget(volume) {
176
+ const [sx, sy, sz] = this.safeSpacing(volume);
177
+ return Math.max(1000, Math.ceil(COMPUTE_BUDGET_MM3 / (sx * sy * sz)));
178
+ }
179
+ makeRegionShapeGate(volume) {
180
+ const spacing = this.safeSpacing(volume);
181
+ const voxelVolumeMm3 = spacing[0] * spacing[1] * spacing[2];
182
+ const maxDeltaK = this.configuration.maxDeltaK;
183
+ return ({ voxelCount, bbox }) => {
184
+ if (typeof maxDeltaK === 'number' && maxDeltaK >= 0) {
185
+ const kExtent = bbox.max[2] - bbox.min[2] + 1;
186
+ if (kExtent >= 2 * maxDeltaK + 1) {
187
+ return false;
188
+ }
189
+ }
190
+ const volumeMm3 = voxelCount * voxelVolumeMm3;
191
+ if (volumeMm3 < COMPACTNESS_CHECK_MIN_VOLUME_MM3) {
192
+ return true;
193
+ }
194
+ let bboxMm3 = 1;
195
+ for (let axis = 0; axis < 3; axis++) {
196
+ bboxMm3 *= (bbox.max[axis] - bbox.min[axis] + 1) * spacing[axis];
197
+ }
198
+ return volumeMm3 / bboxMm3 >= MIN_COMPACTNESS;
199
+ };
200
+ }
201
+ onSetToolPassive() {
202
+ this.clearHoverState();
203
+ }
204
+ onSetToolDisabled() {
205
+ this.clearHoverState();
206
+ }
207
+ buildProbeOptions(viewport, element, referencedVolumeId) {
208
+ return {
209
+ viewport,
210
+ element,
211
+ referencedVolumeId,
212
+ voiMapping: getViewportVoiMappingForVolume(viewport, referencedVolumeId) ??
213
+ undefined,
214
+ };
215
+ }
216
+ mouseMoveCallback(evt) {
217
+ if (this.mode !== ToolModes.Active) {
218
+ return;
219
+ }
220
+ const { element } = evt.detail;
221
+ this.lastHoverElement = element;
222
+ if (this.segmentationInProgress) {
223
+ element.style.cursor = 'wait';
224
+ return;
225
+ }
226
+ this.queueHoverProbe(evt);
227
+ }
228
+ queueHoverProbe(evt) {
229
+ this.pendingHoverEvent = evt;
230
+ if (this.hoverThrottleTimer !== null) {
231
+ return;
232
+ }
233
+ this.flushHoverProbe();
234
+ }
235
+ flushHoverProbe() {
236
+ const evt = this.pendingHoverEvent;
237
+ this.pendingHoverEvent = null;
238
+ if (!evt) {
239
+ return;
240
+ }
241
+ void this.runHoverProbe(evt);
242
+ this.hoverThrottleTimer = window.setTimeout(() => {
243
+ this.hoverThrottleTimer = null;
244
+ if (this.pendingHoverEvent) {
245
+ this.flushHoverProbe();
246
+ }
247
+ }, HOVER_PROBE_THROTTLE_MS);
248
+ }
249
+ getViewportSliceIndex(viewport) {
250
+ const getSliceIndex = viewport.getSliceIndex;
251
+ if (typeof getSliceIndex !== 'function') {
252
+ return null;
253
+ }
254
+ try {
255
+ const sliceIndex = getSliceIndex.call(viewport);
256
+ return Number.isFinite(sliceIndex) ? sliceIndex : null;
257
+ }
258
+ catch {
259
+ return null;
260
+ }
261
+ }
262
+ async runHoverProbe(evt) {
263
+ if (this.mode !== ToolModes.Active || this.segmentationInProgress) {
264
+ return;
265
+ }
266
+ const { element, currentPoints } = evt.detail;
267
+ const enabledElement = getEnabledElement(element);
268
+ const viewport = enabledElement?.viewport;
269
+ if (!viewport) {
270
+ return;
271
+ }
272
+ if (this.dryRunToken) {
273
+ this.dryRunToken.cancelled = true;
274
+ this.dryRunToken = null;
275
+ }
276
+ let state = 'unknown';
277
+ let dryRunInput = null;
278
+ try {
279
+ if (activeSegmentation.getActiveSegmentation(viewport.id)) {
280
+ const labelmapData = await this.getLabelmapSegmentationData(viewport);
281
+ if (labelmapData) {
282
+ const { referencedVolumeId } = labelmapData;
283
+ if (!this._isOrthogonalView(viewport, referencedVolumeId)) {
284
+ state = 'blocked';
285
+ }
286
+ else {
287
+ const refVolume = cache.getVolume(referencedVolumeId);
288
+ if (refVolume) {
289
+ if (this.pointerOnConfirmedRegion(refVolume, viewport, currentPoints.world)) {
290
+ state = 'plus';
291
+ }
292
+ else {
293
+ const options = this.buildProbeOptions(viewport, element, referencedVolumeId);
294
+ const probe = probeAdaptiveRegion(refVolume, currentPoints.world, options);
295
+ if (!probe.viable || !probe.range) {
296
+ state = 'blocked';
297
+ }
298
+ else if (this.matchesRejectedRegion(viewport.id, probe.range)) {
299
+ state = 'blocked';
300
+ }
301
+ else if (!this.neighborsAgree(probe)) {
302
+ state = 'blocked';
303
+ }
304
+ else {
305
+ state = 'pending';
306
+ dryRunInput = { refVolume, range: probe.range };
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ catch (err) {
315
+ growCutLog.debug('hover probe: could not evaluate', {
316
+ message: err instanceof Error ? err.message : String(err),
317
+ });
318
+ state = 'unknown';
319
+ }
320
+ const verdict = state === 'unknown'
321
+ ? null
322
+ : {
323
+ viewportId: viewport.id,
324
+ canvas: [...currentPoints.canvas],
325
+ sliceIndex: this.getViewportSliceIndex(viewport),
326
+ ok: state !== 'blocked',
327
+ at: Date.now(),
328
+ };
329
+ this.lastProbe = verdict;
330
+ element.style.cursor =
331
+ state === 'plus'
332
+ ? PLUS_CURSOR
333
+ : state === 'blocked'
334
+ ? BLOCKED_CURSOR
335
+ : PENDING_CURSOR;
336
+ if (state === 'pending' && dryRunInput && verdict) {
337
+ const token = { cancelled: false };
338
+ this.dryRunToken = token;
339
+ void this.runConfinementDryRun(dryRunInput.refVolume, dryRunInput.range, token, verdict, element);
340
+ }
341
+ }
342
+ neighborsAgree(probe) {
343
+ const joinLevels = probe.clickNeighborJoinLevels;
344
+ const tolerance = probe.toleranceBytes;
345
+ if (!joinLevels || typeof tolerance !== 'number') {
346
+ return true;
347
+ }
348
+ let votes = 0;
349
+ let evaluated = 0;
350
+ for (const joinLevel of joinLevels) {
351
+ if (joinLevel === null) {
352
+ continue;
353
+ }
354
+ evaluated++;
355
+ if (joinLevel >= 0 && joinLevel <= tolerance) {
356
+ votes++;
357
+ }
358
+ }
359
+ return evaluated < CONSENSUS_MIN_NEIGHBOR_VOTES
360
+ ? votes === evaluated
361
+ : votes >= CONSENSUS_MIN_NEIGHBOR_VOTES;
362
+ }
363
+ async runConfinementDryRun(refVolume, range, token, verdict, element) {
364
+ const { dimensions } = refVolume;
365
+ const [width, height, depth] = dimensions;
366
+ const pixelsPerSlice = width * height;
367
+ const voxelManager = refVolume.voxelManager;
368
+ const { min, max } = range;
369
+ const shapeGate = this.makeRegionShapeGate(refVolume);
370
+ try {
371
+ const { truncated, voxelCount, bbox } = await floodFill3dSliceLazy((x, y, z) => Number(voxelManager.getAtIndex(z * pixelsPerSlice + y * width + x)), [...range.ijkStart], {
372
+ width,
373
+ height,
374
+ depth,
375
+ equals: (val) => typeof val === 'number' &&
376
+ Number.isFinite(val) &&
377
+ val >= min &&
378
+ val <= max,
379
+ yieldEvery: DRY_RUN_YIELD_EVERY,
380
+ maxDeltaK: this.configuration.maxDeltaK,
381
+ maxDeltaIJ: this.configuration.maxDeltaIJ,
382
+ isCancelled: () => token.cancelled,
383
+ maxVoxels: this.computeVoxelBudget(refVolume),
384
+ shouldContinue: shapeGate,
385
+ });
386
+ if (token.cancelled) {
387
+ return;
388
+ }
389
+ const lesionLike = !truncated && bbox && shapeGate({ voxelCount, bbox });
390
+ if (lesionLike) {
391
+ this.confirmedRegion = {
392
+ viewportId: verdict.viewportId,
393
+ bbox,
394
+ bandMin: min,
395
+ bandMax: max,
396
+ expiresAt: Date.now() + VERDICT_CACHE_TTL_MS,
397
+ };
398
+ if (this.lastProbe === verdict) {
399
+ verdict.ok = true;
400
+ element.style.cursor = PLUS_CURSOR;
401
+ }
402
+ }
403
+ else {
404
+ this.rememberRejectedRegion(verdict.viewportId, bbox, min, max);
405
+ if (this.lastProbe === verdict) {
406
+ verdict.ok = false;
407
+ element.style.cursor = BLOCKED_CURSOR;
408
+ }
409
+ }
410
+ }
411
+ catch (err) {
412
+ growCutLog.debug('hover dry-run: could not evaluate', {
413
+ message: err instanceof Error ? err.message : String(err),
414
+ });
415
+ }
416
+ finally {
417
+ if (this.dryRunToken === token) {
418
+ this.dryRunToken = null;
419
+ }
420
+ }
421
+ }
422
+ probeAppliesToClick(viewport, canvas) {
423
+ const probe = this.lastProbe;
424
+ if (!probe || probe.viewportId !== viewport.id) {
425
+ return false;
426
+ }
427
+ if (Date.now() - probe.at > PROBE_FRESH_MS) {
428
+ return false;
429
+ }
430
+ if (probe.sliceIndex !== this.getViewportSliceIndex(viewport)) {
431
+ return false;
432
+ }
433
+ const dx = canvas[0] - probe.canvas[0];
434
+ const dy = canvas[1] - probe.canvas[1];
435
+ return Math.sqrt(dx * dx + dy * dy) <= PROBE_REUSE_DISTANCE_PX;
436
+ }
437
+ async preMouseDownCallback(evt) {
438
+ if (this.segmentationInProgress) {
439
+ return false;
440
+ }
441
+ const { currentPoints, element } = evt.detail;
442
+ const { world: worldPoint, canvas: canvasPoint } = currentPoints;
443
+ const enabledElement = element ? getEnabledElement(element) : undefined;
444
+ if (enabledElement?.viewport &&
445
+ this.probeAppliesToClick(enabledElement.viewport, canvasPoint) &&
446
+ this.lastProbe?.ok === false) {
447
+ growCutLog.info('click ignored: hover probe reported no proper region', {
448
+ canvasPoint,
449
+ });
450
+ return false;
451
+ }
452
+ let setupOk = false;
453
+ try {
454
+ setupOk = await super.preMouseDownCallback(evt);
455
+ }
456
+ catch (err) {
457
+ const message = err instanceof Error ? err.message : 'Click segmentation failed.';
458
+ growCutLog.info('ClickSegment: click setup rejected', { message });
459
+ this.notifySegmentationError(message);
460
+ return false;
461
+ }
462
+ if (!setupOk || !this.growCutData) {
463
+ return false;
464
+ }
465
+ const clickData = this.growCutData;
466
+ this.growCutData = null;
467
+ void this.runClick(clickData, worldPoint, element).catch((err) => {
468
+ const message = err instanceof Error ? err.message : 'Click segmentation failed.';
469
+ this.notifySegmentationError(message);
470
+ growCutLog.error('ClickSegment: segmentation failed', { message });
471
+ });
472
+ return true;
473
+ }
474
+ async runClick(clickData, worldPoint, element) {
475
+ const { segmentation, viewportId, renderingEngineId } = clickData;
476
+ const { referencedVolumeId, labelmapVolumeId, segmentIndex } = segmentation;
477
+ const renderingEngine = getRenderingEngine(renderingEngineId);
478
+ const viewport = renderingEngine?.getViewport(viewportId);
479
+ if (!viewport) {
480
+ throw new Error('ClickSegment: viewport not found for click.');
481
+ }
482
+ const refVolume = cache.getVolume(referencedVolumeId);
483
+ const labelmapVolume = cache.getVolume(labelmapVolumeId);
484
+ if (!refVolume || !labelmapVolume) {
485
+ throw new Error('ClickSegment: referenced or labelmap volume not in cache.');
486
+ }
487
+ const probe = probeAdaptiveRegion(refVolume, worldPoint, this.buildProbeOptions(viewport, viewport.element, referencedVolumeId));
488
+ if (!probe.viable || !probe.range || !probe.expandContext) {
489
+ growCutLog.info('ClickSegment: click found no meaningful region', {
490
+ reason: probe.reason,
491
+ regionAreaMm2: probe.regionAreaMm2,
492
+ });
493
+ this.notifySegmentationError('No meaningful region at the clicked location. Move the pointer until the plus cursor appears.');
494
+ return;
495
+ }
496
+ growCutLog.info('ClickSegment: click', {
497
+ worldPoint,
498
+ toleranceBytes: probe.toleranceBytes,
499
+ regionAreaMm2: probe.regionAreaMm2,
500
+ band: { min: probe.range.min, max: probe.range.max },
501
+ });
502
+ const memo = this.beginLabelmapMemo(segmentation, labelmapVolume);
503
+ let filledPoints;
504
+ try {
505
+ filledPoints = await this.fillWithRange(probe.range, {
506
+ segmentation,
507
+ viewportId,
508
+ renderingEngineId,
509
+ }, viewport, labelmapVolume, element, memo);
510
+ }
511
+ finally {
512
+ this.doneEditMemo();
513
+ }
514
+ this.lastClick = {
515
+ expandContext: probe.expandContext,
516
+ toleranceBytes: probe.expandContext.chosenToleranceBytes,
517
+ worldPoint: [...worldPoint],
518
+ filledPoints,
519
+ segmentation,
520
+ viewportId,
521
+ renderingEngineId,
522
+ };
523
+ growCutLog.info('ClickSegment: click complete', {
524
+ filledVoxels: filledPoints.length,
525
+ segmentIndex,
526
+ });
527
+ }
528
+ async fillWithRange(range, target, viewport, labelmapVolume, element, memo) {
529
+ const { segmentation } = target;
530
+ const { referencedVolumeId, segmentIndex, segmentationId } = segmentation;
531
+ this.segmentationInProgress = true;
532
+ if (element) {
533
+ element.style.cursor = 'wait';
534
+ }
535
+ const abortController = new AbortController();
536
+ this.clickAbortController = abortController;
537
+ let filledPoints = [];
538
+ let notLesionLike = false;
539
+ try {
540
+ const referencedVolume = cache.getVolume(referencedVolumeId);
541
+ if (!referencedVolume) {
542
+ throw new Error('ClickSegment: referenced volume is no longer cached; cannot fill.');
543
+ }
544
+ const result = await runFloodFillSegmentation({
545
+ referencedVolumeId,
546
+ worldPosition: this.indexToWorld(referencedVolume, range.ijkStart),
547
+ viewport,
548
+ labelmapVolume,
549
+ options: {
550
+ segmentIndex,
551
+ getIntensityRange: () => range,
552
+ element: viewport.element,
553
+ applyExternalIslandRemoval: true,
554
+ applyInternalIslandRemoval: true,
555
+ maxDeltaK: this.configuration.maxDeltaK,
556
+ maxDeltaIJ: this.configuration.maxDeltaIJ,
557
+ isCancelled: () => abortController.signal.aborted,
558
+ maxVoxels: this.computeVoxelBudget(referencedVolume),
559
+ shouldContinueRegion: this.makeRegionShapeGate(referencedVolume),
560
+ historyVoxelManager: memo?.voxelManager,
561
+ onRejected: ({ voxelCount, bbox }) => {
562
+ notLesionLike = true;
563
+ growCutLog.info('ClickSegment: fill rejected by shape gate', {
564
+ voxelCount,
565
+ bbox,
566
+ });
567
+ },
568
+ onCommitted: (points) => {
569
+ filledPoints = points;
570
+ },
571
+ },
572
+ });
573
+ if (!result) {
574
+ if (abortController.signal.aborted) {
575
+ throw new Error('ClickSegment: fill cancelled.');
576
+ }
577
+ throw new Error(notLesionLike
578
+ ? NOT_LESION_MESSAGE
579
+ : 'ClickSegment: fill produced no result (band rejected).');
580
+ }
581
+ triggerSegmentationDataModified(segmentationId);
582
+ return filledPoints;
583
+ }
584
+ finally {
585
+ if (this.clickAbortController === abortController) {
586
+ this.clickAbortController = null;
587
+ }
588
+ this.segmentationInProgress = false;
589
+ if (element) {
590
+ element.style.cursor = PENDING_CURSOR;
591
+ }
592
+ }
593
+ }
594
+ indexToWorld(volume, ijk) {
595
+ return csUtils.transformIndexToWorld(volume.imageData, ijk);
596
+ }
597
+ beginLabelmapMemo(segmentation, labelmapVolume) {
598
+ const memo = LabelmapMemo.createLabelmapMemo(segmentation.segmentationId, labelmapVolume.voxelManager);
599
+ this.memo = memo;
600
+ return memo;
601
+ }
602
+ curveSizeAt(context, toleranceBytes) {
603
+ let size = 0;
604
+ for (const [level, levelSize] of context.growthCurve) {
605
+ if (level > toleranceBytes) {
606
+ break;
607
+ }
608
+ size = levelSize;
609
+ }
610
+ return size;
611
+ }
612
+ pickNextTolerance(direction) {
613
+ const lastClick = this.lastClick;
614
+ if (!lastClick) {
615
+ return null;
616
+ }
617
+ const { expandContext, toleranceBytes } = lastClick;
618
+ const currentSize = this.curveSizeAt(expandContext, toleranceBytes);
619
+ if (direction > 0) {
620
+ for (const [level, size] of expandContext.growthCurve) {
621
+ if (level <= toleranceBytes) {
622
+ continue;
623
+ }
624
+ if (size >= Math.max(currentSize + 1, currentSize * EXPAND_STEP_FACTOR)) {
625
+ return level;
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+ for (let index = expandContext.growthCurve.length - 1; index >= 0; index--) {
631
+ const [level, size] = expandContext.growthCurve[index];
632
+ if (level >= toleranceBytes) {
633
+ continue;
634
+ }
635
+ if (size <= Math.min(currentSize - 1, currentSize * SHRINK_STEP_FACTOR)) {
636
+ return level;
637
+ }
638
+ }
639
+ return null;
640
+ }
641
+ expand() {
642
+ this.stepTolerance(1);
643
+ }
644
+ shrink() {
645
+ this.stepTolerance(-1);
646
+ }
647
+ refresh() {
648
+ const lastClick = this.lastClick;
649
+ if (!lastClick || this.segmentationInProgress) {
650
+ return;
651
+ }
652
+ void this.refillAtTolerance(lastClick.toleranceBytes).catch((err) => {
653
+ growCutLog.error('ClickSegment: refresh failed', {
654
+ message: err instanceof Error ? err.message : String(err),
655
+ });
656
+ });
657
+ }
658
+ stepTolerance(direction) {
659
+ if (this.segmentationInProgress) {
660
+ return;
661
+ }
662
+ const lastClick = this.lastClick;
663
+ if (!lastClick) {
664
+ growCutLog.info('ClickSegment: no click to expand/shrink yet');
665
+ return;
666
+ }
667
+ const next = this.pickNextTolerance(direction);
668
+ if (next === null) {
669
+ growCutLog.info('ClickSegment: no further step available', {
670
+ direction: direction > 0 ? 'expand' : 'shrink',
671
+ toleranceBytes: lastClick.toleranceBytes,
672
+ });
673
+ return;
674
+ }
675
+ growCutLog.info('ClickSegment: stepping tolerance', {
676
+ direction: direction > 0 ? 'expand' : 'shrink',
677
+ fromToleranceBytes: lastClick.toleranceBytes,
678
+ toToleranceBytes: next,
679
+ predictedRegionPx: this.curveSizeAt(lastClick.expandContext, next),
680
+ });
681
+ const previousTolerance = lastClick.toleranceBytes;
682
+ void this.refillAtTolerance(next).catch((err) => {
683
+ const message = err instanceof Error ? err.message : String(err);
684
+ if (message === NOT_LESION_MESSAGE && direction > 0) {
685
+ growCutLog.info('ClickSegment: expand blocked by the lesion shape gate; previous result kept', { previousTolerance });
686
+ this.notifySegmentationError('Expanding further would leak beyond a lesion-like shape; kept the previous result.');
687
+ return;
688
+ }
689
+ growCutLog.error('ClickSegment: expand/shrink failed', { message });
690
+ });
691
+ }
692
+ async refillAtTolerance(toleranceBytes) {
693
+ const lastClick = this.lastClick;
694
+ if (!lastClick) {
695
+ return;
696
+ }
697
+ const { segmentation, viewportId, renderingEngineId } = lastClick;
698
+ const labelmapVolume = cache.getVolume(segmentation.labelmapVolumeId);
699
+ const renderingEngine = getRenderingEngine(renderingEngineId);
700
+ const viewport = renderingEngine?.getViewport(viewportId);
701
+ if (!labelmapVolume || !viewport) {
702
+ growCutLog.warn('ClickSegment: labelmap or viewport gone; cannot expand/shrink');
703
+ return;
704
+ }
705
+ const memo = this.beginLabelmapMemo(segmentation, labelmapVolume);
706
+ try {
707
+ const { voxelManager } = labelmapVolume;
708
+ const historyVm = memo.voxelManager;
709
+ const [width, height] = labelmapVolume.dimensions;
710
+ const pixelsPerSlice = width * height;
711
+ const { segmentIndex } = segmentation;
712
+ const clearedIndices = [];
713
+ for (const [x, y, z] of lastClick.filledPoints) {
714
+ const index = z * pixelsPerSlice + y * width + x;
715
+ if (voxelManager.getAtIndex(index) === segmentIndex) {
716
+ historyVm.setAtIndex(index, 0);
717
+ clearedIndices.push(index);
718
+ }
719
+ }
720
+ const range = resolveAdaptiveBandAtTolerance(lastClick.expandContext, toleranceBytes);
721
+ try {
722
+ const filledPoints = await this.fillWithRange(range, {
723
+ segmentation,
724
+ viewportId,
725
+ renderingEngineId,
726
+ }, viewport, labelmapVolume, this.lastHoverElement ?? undefined, memo);
727
+ lastClick.toleranceBytes = toleranceBytes;
728
+ lastClick.filledPoints = filledPoints;
729
+ growCutLog.info('ClickSegment: refill complete', {
730
+ toleranceBytes,
731
+ filledVoxels: filledPoints.length,
732
+ });
733
+ }
734
+ catch (err) {
735
+ for (const index of clearedIndices) {
736
+ historyVm.setAtIndex(index, segmentIndex);
737
+ }
738
+ triggerSegmentationDataModified(segmentation.segmentationId);
739
+ throw err;
740
+ }
741
+ }
742
+ finally {
743
+ this.doneEditMemo();
744
+ }
745
+ }
746
+ }
747
+ ClickSegmentTool.toolName = 'ClickSegment';
748
+ export default ClickSegmentTool;