@cornerstonejs/tools 5.4.11 → 5.4.13
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.
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/stateManagement/segmentation/helpers/isSegmentationOverlayCompatible.d.ts +3 -0
- package/dist/esm/stateManagement/segmentation/helpers/isSegmentationOverlayCompatible.js +87 -0
- package/dist/esm/stateManagement/segmentation/internalAddSegmentationRepresentation.js +5 -0
- package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.d.ts +12 -2
- package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapEditTransaction.js +21 -1
- package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapImageReferenceResolver.d.ts +5 -0
- package/dist/esm/stateManagement/segmentation/labelmapModel/labelmapImageReferenceResolver.js +47 -1
- package/dist/esm/stateManagement/segmentation/labelmapModel/privateLabelmap.d.ts +5 -1
- package/dist/esm/stateManagement/segmentation/labelmapModel/privateLabelmap.js +49 -4
- package/dist/esm/tools/annotation/ClickSegmentTool.d.ts +51 -0
- package/dist/esm/tools/annotation/ClickSegmentTool.js +748 -0
- package/dist/esm/tools/annotation/regionSegmentHoverCursors.d.ts +4 -0
- package/dist/esm/tools/annotation/regionSegmentHoverCursors.js +4 -0
- package/dist/esm/tools/displayTools/Labelmap/syncStackLabelmapActors.js +5 -0
- package/dist/esm/tools/index.d.ts +2 -1
- package/dist/esm/tools/index.js +2 -1
- package/dist/esm/tools/segmentation/BrushTool.js +7 -3
- package/dist/esm/tools/segmentation/strategies/BrushStrategy.js +21 -0
- package/dist/esm/tools/segmentation/strategies/utils/crossLayerErase.js +20 -0
- package/dist/esm/utilities/segmentation/commitSliceMasksToLabelmap.d.ts +12 -0
- package/dist/esm/utilities/segmentation/commitSliceMasksToLabelmap.js +128 -0
- package/dist/esm/utilities/segmentation/createEnsureSliceLoadedForVolume.d.ts +2 -0
- package/dist/esm/utilities/segmentation/createEnsureSliceLoadedForVolume.js +36 -0
- package/dist/esm/utilities/segmentation/createLabelmapMemo.d.ts +7 -0
- package/dist/esm/utilities/segmentation/createLabelmapMemo.js +41 -8
- package/dist/esm/utilities/segmentation/floodFillIslandRemoval.d.ts +20 -0
- package/dist/esm/utilities/segmentation/floodFillIslandRemoval.js +118 -0
- package/dist/esm/utilities/segmentation/floodFillSliceLazy.d.ts +33 -0
- package/dist/esm/utilities/segmentation/floodFillSliceLazy.js +167 -0
- package/dist/esm/utilities/segmentation/growCut/floodFillIntensityRangeTypes.d.ts +51 -0
- package/dist/esm/utilities/segmentation/growCut/floodFillIntensityRangeTypes.js +0 -0
- package/dist/esm/utilities/segmentation/growCut/getViewportVoiMappingForVolume.d.ts +10 -0
- package/dist/esm/utilities/segmentation/growCut/getViewportVoiMappingForVolume.js +21 -0
- package/dist/esm/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.d.ts +57 -0
- package/dist/esm/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.js +559 -0
- package/dist/esm/utilities/segmentation/growCut/neighborhoodStats.d.ts +10 -0
- package/dist/esm/utilities/segmentation/growCut/neighborhoodStats.js +44 -0
- package/dist/esm/utilities/segmentation/growCut/runFloodFillSegmentation.d.ts +61 -0
- package/dist/esm/utilities/segmentation/growCut/runFloodFillSegmentation.js +524 -0
- package/dist/esm/utilities/segmentation/islandRemoval.d.ts +3 -2
- package/dist/esm/utilities/segmentation/islandRemoval.js +3 -0
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +5 -5
package/dist/esm/utilities/segmentation/growCut/intensityRange/adaptiveRegionIntensityRange.js
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { utilities as csUtils } from '@cornerstonejs/core';
|
|
2
|
+
import { getViewportVoiMappingForVolume } from '../getViewportVoiMappingForVolume.js';
|
|
3
|
+
import { DEFAULT_POSITIVE_STD_DEV_MULTIPLIER } from '../constants.js';
|
|
4
|
+
const { transformWorldToIndex, mapScalarToViewportVoiIntensity, mapViewportVoiIntensityToScalar, getVolumeDirectionVectors, } = csUtils;
|
|
5
|
+
const { growCutLog: log } = csUtils.logger;
|
|
6
|
+
const WINDOW_RADIUS_PX = 96;
|
|
7
|
+
const SEED_SNAP_RADIUS_PX = 4;
|
|
8
|
+
const MIN_REGION_PX = 6;
|
|
9
|
+
const MIN_REGION_MM2 = 8;
|
|
10
|
+
const MAX_REGION_WINDOW_FRACTION = 0.6;
|
|
11
|
+
const FLAT_CONTRAST_BYTES = 10;
|
|
12
|
+
const MIN_TOLERANCE_BYTES = 2;
|
|
13
|
+
const MIN_PLATEAU_WIDTH_BYTES = 4;
|
|
14
|
+
const QUIET_GAIN_ABS_PX = 3;
|
|
15
|
+
const QUIET_GAIN_FRACTION = 0.02;
|
|
16
|
+
function medianOf(values) {
|
|
17
|
+
if (!values.length) {
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
const sorted = values.slice().sort((a, b) => a - b);
|
|
21
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
22
|
+
}
|
|
23
|
+
function rawBandForTolerance(context, toleranceBytes) {
|
|
24
|
+
const { seedByte, polarity, voiMapping, rawWindow, seedScalar } = context;
|
|
25
|
+
const thresholdByte = Math.max(0, Math.min(255, polarity > 0 ? seedByte - toleranceBytes : seedByte + toleranceBytes));
|
|
26
|
+
const includedExtremeByte = polarity > 0 ? 255 : 0;
|
|
27
|
+
const rawFromByte = (byte) => voiMapping
|
|
28
|
+
? mapViewportVoiIntensityToScalar(byte / 255, voiMapping)
|
|
29
|
+
: rawWindow.min + (byte / 255) * rawWindow.span;
|
|
30
|
+
const rawThreshold = rawFromByte(thresholdByte);
|
|
31
|
+
const rawExtreme = rawFromByte(includedExtremeByte);
|
|
32
|
+
let min;
|
|
33
|
+
let max;
|
|
34
|
+
if (rawExtreme >= rawThreshold) {
|
|
35
|
+
min = rawThreshold;
|
|
36
|
+
max = Infinity;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
min = -Infinity;
|
|
40
|
+
max = rawThreshold;
|
|
41
|
+
}
|
|
42
|
+
if (Number.isFinite(seedScalar)) {
|
|
43
|
+
min = Math.min(min, seedScalar);
|
|
44
|
+
max = Math.max(max, seedScalar);
|
|
45
|
+
}
|
|
46
|
+
return { min, max };
|
|
47
|
+
}
|
|
48
|
+
export function resolveAdaptiveBandAtTolerance(context, toleranceBytes) {
|
|
49
|
+
const tolerance = Math.max(0, Math.min(255, Math.round(toleranceBytes)));
|
|
50
|
+
const { min, max } = rawBandForTolerance(context, tolerance);
|
|
51
|
+
return {
|
|
52
|
+
min,
|
|
53
|
+
max,
|
|
54
|
+
ijkStart: [...context.seedIjk],
|
|
55
|
+
diagnostics: {
|
|
56
|
+
neighborhoodMean: Number.isFinite(context.seedScalar)
|
|
57
|
+
? context.seedScalar
|
|
58
|
+
: 0,
|
|
59
|
+
neighborhoodStdDev: 0,
|
|
60
|
+
clickedVoxelValue: Number.isFinite(context.seedScalar)
|
|
61
|
+
? context.seedScalar
|
|
62
|
+
: 0,
|
|
63
|
+
positiveStdDevMultiplier: 1,
|
|
64
|
+
neighborhoodRadius: SEED_SNAP_RADIUS_PX,
|
|
65
|
+
strategy: 'adaptiveRegion:atTolerance',
|
|
66
|
+
adaptive: {
|
|
67
|
+
toleranceBytes: tolerance,
|
|
68
|
+
regionSizePx: -1,
|
|
69
|
+
backgroundByte: -1,
|
|
70
|
+
seedByte: context.seedByte,
|
|
71
|
+
seedSnapped: false,
|
|
72
|
+
windowSize: [0, 0],
|
|
73
|
+
polarity: context.polarity,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function probeAdaptiveRegionCore(input) {
|
|
79
|
+
const { dimensions, getScalar, ijkClick, voiMapping } = input;
|
|
80
|
+
const inPlaneAxes = input.inPlaneAxes ?? [0, 1];
|
|
81
|
+
const inPlaneSpacing = input.inPlaneSpacing ?? [1, 1];
|
|
82
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
83
|
+
if (ijkClick[axis] < 0 || ijkClick[axis] >= dimensions[axis]) {
|
|
84
|
+
return { viable: false, reason: 'outside-volume', range: null };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const [axisA, axisB] = inPlaneAxes;
|
|
88
|
+
const a0 = Math.max(0, ijkClick[axisA] - WINDOW_RADIUS_PX);
|
|
89
|
+
const a1 = Math.min(dimensions[axisA] - 1, ijkClick[axisA] + WINDOW_RADIUS_PX);
|
|
90
|
+
const b0 = Math.max(0, ijkClick[axisB] - WINDOW_RADIUS_PX);
|
|
91
|
+
const b1 = Math.min(dimensions[axisB] - 1, ijkClick[axisB] + WINDOW_RADIUS_PX);
|
|
92
|
+
const wA = a1 - a0 + 1;
|
|
93
|
+
const wB = b1 - b0 + 1;
|
|
94
|
+
const windowArea = wA * wB;
|
|
95
|
+
const spacingA = Number.isFinite(inPlaneSpacing[0]) && inPlaneSpacing[0] > 0
|
|
96
|
+
? inPlaneSpacing[0]
|
|
97
|
+
: 1;
|
|
98
|
+
const spacingB = Number.isFinite(inPlaneSpacing[1]) && inPlaneSpacing[1] > 0
|
|
99
|
+
? inPlaneSpacing[1]
|
|
100
|
+
: 1;
|
|
101
|
+
const pxAreaMm2 = spacingA * spacingB;
|
|
102
|
+
const minRegionPx = Math.max(MIN_REGION_PX, Math.ceil(MIN_REGION_MM2 / pxAreaMm2));
|
|
103
|
+
const maxRegionPx = Math.max(minRegionPx + 1, Math.floor(windowArea * MAX_REGION_WINDOW_FRACTION));
|
|
104
|
+
const scalarAt = (x, y) => {
|
|
105
|
+
const ijk = [...ijkClick];
|
|
106
|
+
ijk[axisA] = a0 + x;
|
|
107
|
+
ijk[axisB] = b0 + y;
|
|
108
|
+
return Number(getScalar(ijk[0], ijk[1], ijk[2]));
|
|
109
|
+
};
|
|
110
|
+
const rawValues = new Float64Array(windowArea);
|
|
111
|
+
let rawMinInWindow = Infinity;
|
|
112
|
+
let rawMaxInWindow = -Infinity;
|
|
113
|
+
for (let y = 0; y < wB; y++) {
|
|
114
|
+
for (let x = 0; x < wA; x++) {
|
|
115
|
+
const v = scalarAt(x, y);
|
|
116
|
+
rawValues[y * wA + x] = v;
|
|
117
|
+
if (Number.isFinite(v)) {
|
|
118
|
+
if (v < rawMinInWindow) {
|
|
119
|
+
rawMinInWindow = v;
|
|
120
|
+
}
|
|
121
|
+
if (v > rawMaxInWindow) {
|
|
122
|
+
rawMaxInWindow = v;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isFinite(rawMinInWindow)) {
|
|
128
|
+
return { viable: false, reason: 'outside-volume', range: null };
|
|
129
|
+
}
|
|
130
|
+
const rawSpan = rawMaxInWindow - rawMinInWindow || 1;
|
|
131
|
+
const bytes = new Int16Array(windowArea);
|
|
132
|
+
for (let idx = 0; idx < windowArea; idx++) {
|
|
133
|
+
const v = rawValues[idx];
|
|
134
|
+
if (!Number.isFinite(v)) {
|
|
135
|
+
bytes[idx] = -1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const mapped = voiMapping
|
|
139
|
+
? mapScalarToViewportVoiIntensity(v, voiMapping)
|
|
140
|
+
: (v - rawMinInWindow) / rawSpan;
|
|
141
|
+
bytes[idx] = Math.max(0, Math.min(255, Math.round(mapped * 255)));
|
|
142
|
+
}
|
|
143
|
+
const ringBytes = [];
|
|
144
|
+
for (let x = 0; x < wA; x++) {
|
|
145
|
+
if (bytes[x] >= 0) {
|
|
146
|
+
ringBytes.push(bytes[x]);
|
|
147
|
+
}
|
|
148
|
+
const bottom = (wB - 1) * wA + x;
|
|
149
|
+
if (wB > 1 && bytes[bottom] >= 0) {
|
|
150
|
+
ringBytes.push(bytes[bottom]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (let y = 1; y < wB - 1; y++) {
|
|
154
|
+
const left = y * wA;
|
|
155
|
+
const right = y * wA + (wA - 1);
|
|
156
|
+
if (bytes[left] >= 0) {
|
|
157
|
+
ringBytes.push(bytes[left]);
|
|
158
|
+
}
|
|
159
|
+
if (wA > 1 && bytes[right] >= 0) {
|
|
160
|
+
ringBytes.push(bytes[right]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const backgroundByte = medianOf(ringBytes);
|
|
164
|
+
const clickX = ijkClick[axisA] - a0;
|
|
165
|
+
const clickY = ijkClick[axisB] - b0;
|
|
166
|
+
let seedX = clickX;
|
|
167
|
+
let seedY = clickY;
|
|
168
|
+
let bestContrast = -1;
|
|
169
|
+
let bestDist = Infinity;
|
|
170
|
+
const snapR2 = SEED_SNAP_RADIUS_PX * SEED_SNAP_RADIUS_PX;
|
|
171
|
+
for (let dy = -SEED_SNAP_RADIUS_PX; dy <= SEED_SNAP_RADIUS_PX; dy++) {
|
|
172
|
+
for (let dx = -SEED_SNAP_RADIUS_PX; dx <= SEED_SNAP_RADIUS_PX; dx++) {
|
|
173
|
+
if (dx * dx + dy * dy > snapR2) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const x = clickX + dx;
|
|
177
|
+
const y = clickY + dy;
|
|
178
|
+
if (x < 0 || x >= wA || y < 0 || y >= wB) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const byte = bytes[y * wA + x];
|
|
182
|
+
if (byte < 0) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const contrast = Math.abs(byte - backgroundByte);
|
|
186
|
+
const dist = dx * dx + dy * dy;
|
|
187
|
+
if (contrast > bestContrast ||
|
|
188
|
+
(contrast === bestContrast && dist < bestDist)) {
|
|
189
|
+
bestContrast = contrast;
|
|
190
|
+
bestDist = dist;
|
|
191
|
+
seedX = x;
|
|
192
|
+
seedY = y;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (bestContrast < 0) {
|
|
197
|
+
return { viable: false, reason: 'outside-volume', range: null };
|
|
198
|
+
}
|
|
199
|
+
if (bestContrast < FLAT_CONTRAST_BYTES) {
|
|
200
|
+
return { viable: false, reason: 'flat-region', range: null };
|
|
201
|
+
}
|
|
202
|
+
const seedPixelByte = bytes[seedY * wA + seedX];
|
|
203
|
+
const polarity = seedPixelByte >= backgroundByte ? 1 : -1;
|
|
204
|
+
const elevation = (byte) => polarity > 0 ? byte : 255 - byte;
|
|
205
|
+
const seedNeighborhood = [];
|
|
206
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
207
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
208
|
+
const x = seedX + dx;
|
|
209
|
+
const y = seedY + dy;
|
|
210
|
+
if (x < 0 || x >= wA || y < 0 || y >= wB) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const byte = bytes[y * wA + x];
|
|
214
|
+
if (byte >= 0 &&
|
|
215
|
+
Math.abs(byte - seedPixelByte) <= Math.abs(byte - backgroundByte)) {
|
|
216
|
+
seedNeighborhood.push(byte);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const v0 = seedNeighborhood.length
|
|
221
|
+
? medianOf(seedNeighborhood)
|
|
222
|
+
: seedPixelByte;
|
|
223
|
+
const h0 = elevation(v0);
|
|
224
|
+
const cost = (idx) => {
|
|
225
|
+
const byte = bytes[idx];
|
|
226
|
+
if (byte < 0) {
|
|
227
|
+
return -1;
|
|
228
|
+
}
|
|
229
|
+
return Math.max(0, h0 - elevation(byte));
|
|
230
|
+
};
|
|
231
|
+
const buckets = new Array(256);
|
|
232
|
+
const pushToBucket = (level, idx) => {
|
|
233
|
+
let bucket = buckets[level];
|
|
234
|
+
if (!bucket) {
|
|
235
|
+
bucket = [];
|
|
236
|
+
buckets[level] = bucket;
|
|
237
|
+
}
|
|
238
|
+
bucket.push(idx);
|
|
239
|
+
};
|
|
240
|
+
const visited = new Uint8Array(windowArea);
|
|
241
|
+
const seedIdx = seedY * wA + seedX;
|
|
242
|
+
const seedCost = cost(seedIdx);
|
|
243
|
+
if (seedCost < 0) {
|
|
244
|
+
return { viable: false, reason: 'flat-region', range: null };
|
|
245
|
+
}
|
|
246
|
+
pushToBucket(seedCost, seedIdx);
|
|
247
|
+
visited[seedIdx] = 1;
|
|
248
|
+
const curve = [];
|
|
249
|
+
const clickIdx = clickY * wA + clickX;
|
|
250
|
+
let clickJoinLevel = -1;
|
|
251
|
+
const neighborOffsets = [
|
|
252
|
+
[-1, 0],
|
|
253
|
+
[1, 0],
|
|
254
|
+
[0, -1],
|
|
255
|
+
[0, 1],
|
|
256
|
+
];
|
|
257
|
+
const clickNeighborJoinLevels = [];
|
|
258
|
+
const neighborWatch = new Map();
|
|
259
|
+
neighborOffsets.forEach(([dx, dy], slot) => {
|
|
260
|
+
const x = clickX + dx;
|
|
261
|
+
const y = clickY + dy;
|
|
262
|
+
if (x < 0 || x >= wA || y < 0 || y >= wB) {
|
|
263
|
+
clickNeighborJoinLevels.push(null);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
clickNeighborJoinLevels.push(-1);
|
|
267
|
+
neighborWatch.set(y * wA + x, slot);
|
|
268
|
+
});
|
|
269
|
+
let size = 0;
|
|
270
|
+
let borderTouchLevel = -1;
|
|
271
|
+
let explosionLevel = -1;
|
|
272
|
+
outer: for (let level = 0; level < 256; level++) {
|
|
273
|
+
const bucket = buckets[level];
|
|
274
|
+
if (bucket?.length) {
|
|
275
|
+
for (let bi = 0; bi < bucket.length; bi++) {
|
|
276
|
+
const idx = bucket[bi];
|
|
277
|
+
if (visited[idx] === 2) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
visited[idx] = 2;
|
|
281
|
+
size++;
|
|
282
|
+
if (idx === clickIdx) {
|
|
283
|
+
clickJoinLevel = level;
|
|
284
|
+
}
|
|
285
|
+
const watchSlot = neighborWatch.get(idx);
|
|
286
|
+
if (watchSlot !== undefined) {
|
|
287
|
+
clickNeighborJoinLevels[watchSlot] = level;
|
|
288
|
+
}
|
|
289
|
+
const x = idx % wA;
|
|
290
|
+
const y = Math.floor(idx / wA);
|
|
291
|
+
if (borderTouchLevel < 0 &&
|
|
292
|
+
(x === 0 || x === wA - 1 || y === 0 || y === wB - 1)) {
|
|
293
|
+
borderTouchLevel = level;
|
|
294
|
+
}
|
|
295
|
+
if (size > maxRegionPx) {
|
|
296
|
+
explosionLevel = level;
|
|
297
|
+
break outer;
|
|
298
|
+
}
|
|
299
|
+
const neighbors = [idx - 1, idx + 1, idx - wA, idx + wA];
|
|
300
|
+
const canLeft = x > 0;
|
|
301
|
+
const canRight = x < wA - 1;
|
|
302
|
+
const canUp = y > 0;
|
|
303
|
+
const canDown = y < wB - 1;
|
|
304
|
+
const allowed = [canLeft, canRight, canUp, canDown];
|
|
305
|
+
for (let n = 0; n < 4; n++) {
|
|
306
|
+
if (!allowed[n]) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const nIdx = neighbors[n];
|
|
310
|
+
if (visited[nIdx] !== 0) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const nCost = cost(nIdx);
|
|
314
|
+
if (nCost < 0) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
visited[nIdx] = 1;
|
|
318
|
+
pushToBucket(Math.max(level, nCost), nIdx);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
bucket.length = 0;
|
|
322
|
+
}
|
|
323
|
+
const lastSize = curve.length ? curve[curve.length - 1].size : 0;
|
|
324
|
+
if (size > lastSize) {
|
|
325
|
+
curve.push({ level, size });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const growthEndLevel = explosionLevel >= 0 ? explosionLevel - 1 : 255;
|
|
329
|
+
const runs = [];
|
|
330
|
+
{
|
|
331
|
+
let runStart = -1;
|
|
332
|
+
let currentSize = 0;
|
|
333
|
+
let ci = 0;
|
|
334
|
+
const closeRun = (endLevel, sizeAtEnd) => {
|
|
335
|
+
if (runStart >= 0 && endLevel >= runStart) {
|
|
336
|
+
runs.push({
|
|
337
|
+
startLevel: runStart,
|
|
338
|
+
endLevel,
|
|
339
|
+
width: endLevel - runStart,
|
|
340
|
+
size: sizeAtEnd,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
runStart = -1;
|
|
344
|
+
};
|
|
345
|
+
for (let level = 0; level <= growthEndLevel; level++) {
|
|
346
|
+
const sizeBefore = currentSize;
|
|
347
|
+
let gain = 0;
|
|
348
|
+
if (ci < curve.length && curve[ci].level === level) {
|
|
349
|
+
gain = curve[ci].size - currentSize;
|
|
350
|
+
currentSize = curve[ci].size;
|
|
351
|
+
ci++;
|
|
352
|
+
}
|
|
353
|
+
const quietThreshold = Math.max(QUIET_GAIN_ABS_PX, Math.floor(sizeBefore * QUIET_GAIN_FRACTION));
|
|
354
|
+
if (currentSize > 0 && gain <= quietThreshold) {
|
|
355
|
+
if (runStart < 0) {
|
|
356
|
+
runStart = level;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
closeRun(level - 1, sizeBefore);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
closeRun(growthEndLevel, currentSize);
|
|
364
|
+
}
|
|
365
|
+
let best = null;
|
|
366
|
+
for (const run of runs) {
|
|
367
|
+
const endLevel = borderTouchLevel >= 0
|
|
368
|
+
? Math.min(run.endLevel, borderTouchLevel - 1)
|
|
369
|
+
: run.endLevel;
|
|
370
|
+
if (endLevel < run.startLevel) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
const clipped = {
|
|
374
|
+
...run,
|
|
375
|
+
endLevel,
|
|
376
|
+
width: endLevel - run.startLevel,
|
|
377
|
+
};
|
|
378
|
+
if (clipped.size < minRegionPx || clipped.size > maxRegionPx) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (!best || clipped.width > best.width) {
|
|
382
|
+
best = clipped;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const selectionDebug = () => ({
|
|
386
|
+
growthCurve: curve.map((p) => [p.level, p.size]),
|
|
387
|
+
quietRuns: runs.map((run) => [
|
|
388
|
+
run.startLevel,
|
|
389
|
+
run.endLevel,
|
|
390
|
+
run.size,
|
|
391
|
+
]),
|
|
392
|
+
seedByte: v0,
|
|
393
|
+
backgroundByte,
|
|
394
|
+
polarity,
|
|
395
|
+
borderTouchLevel,
|
|
396
|
+
explosionLevel,
|
|
397
|
+
clickJoinLevel,
|
|
398
|
+
minRegionPx,
|
|
399
|
+
maxRegionPx,
|
|
400
|
+
pxAreaMm2,
|
|
401
|
+
});
|
|
402
|
+
if (!best || best.width < MIN_PLATEAU_WIDTH_BYTES) {
|
|
403
|
+
const stableButSmall = runs.find((run) => run.size < minRegionPx &&
|
|
404
|
+
run.width >= MIN_PLATEAU_WIDTH_BYTES &&
|
|
405
|
+
(borderTouchLevel < 0 || run.startLevel < borderTouchLevel));
|
|
406
|
+
const failSize = best?.size ?? stableButSmall?.size ?? size;
|
|
407
|
+
return {
|
|
408
|
+
viable: false,
|
|
409
|
+
reason: stableButSmall ? 'too-small' : 'unbounded',
|
|
410
|
+
range: null,
|
|
411
|
+
regionSizePx: failSize,
|
|
412
|
+
regionAreaMm2: failSize * pxAreaMm2,
|
|
413
|
+
debug: selectionDebug(),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
let toleranceBytes = best.startLevel + Math.floor(best.width / 2);
|
|
417
|
+
toleranceBytes = Math.min(Math.max(toleranceBytes, MIN_TOLERANCE_BYTES), best.endLevel);
|
|
418
|
+
const toleranceScale = input.toleranceScale && input.toleranceScale > 0 ? input.toleranceScale : 1;
|
|
419
|
+
toleranceBytes = Math.max(1, Math.min(255, Math.round(toleranceBytes * toleranceScale)));
|
|
420
|
+
const chosen = best;
|
|
421
|
+
if (clickJoinLevel < 0 || clickJoinLevel > toleranceBytes) {
|
|
422
|
+
return {
|
|
423
|
+
viable: false,
|
|
424
|
+
reason: 'off-target',
|
|
425
|
+
range: null,
|
|
426
|
+
regionSizePx: chosen.size,
|
|
427
|
+
regionAreaMm2: chosen.size * pxAreaMm2,
|
|
428
|
+
debug: selectionDebug(),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const seedIjk = [...ijkClick];
|
|
432
|
+
seedIjk[axisA] = a0 + seedX;
|
|
433
|
+
seedIjk[axisB] = b0 + seedY;
|
|
434
|
+
const seedScalar = rawValues[seedIdx];
|
|
435
|
+
const clickedScalar = rawValues[clickY * wA + clickX];
|
|
436
|
+
const expandContext = {
|
|
437
|
+
seedByte: v0,
|
|
438
|
+
polarity,
|
|
439
|
+
voiMapping: voiMapping ?? null,
|
|
440
|
+
rawWindow: { min: rawMinInWindow, span: rawSpan },
|
|
441
|
+
seedIjk,
|
|
442
|
+
seedScalar: Number.isFinite(seedScalar) ? seedScalar : 0,
|
|
443
|
+
growthCurve: curve.map((p) => [p.level, p.size]),
|
|
444
|
+
chosenToleranceBytes: toleranceBytes,
|
|
445
|
+
growthEndLevel,
|
|
446
|
+
pxAreaMm2,
|
|
447
|
+
};
|
|
448
|
+
const { min: rawLo, max: rawHi } = rawBandForTolerance(expandContext, toleranceBytes);
|
|
449
|
+
return {
|
|
450
|
+
viable: true,
|
|
451
|
+
reason: 'ok',
|
|
452
|
+
regionSizePx: chosen.size,
|
|
453
|
+
regionAreaMm2: chosen.size * pxAreaMm2,
|
|
454
|
+
toleranceBytes,
|
|
455
|
+
clickNeighborJoinLevels,
|
|
456
|
+
expandContext,
|
|
457
|
+
range: {
|
|
458
|
+
min: rawLo,
|
|
459
|
+
max: rawHi,
|
|
460
|
+
ijkStart: seedIjk,
|
|
461
|
+
diagnostics: {
|
|
462
|
+
neighborhoodMean: Number.isFinite(seedScalar) ? seedScalar : 0,
|
|
463
|
+
neighborhoodStdDev: 0,
|
|
464
|
+
clickedVoxelValue: Number.isFinite(clickedScalar) ? clickedScalar : 0,
|
|
465
|
+
positiveStdDevMultiplier: toleranceScale,
|
|
466
|
+
neighborhoodRadius: SEED_SNAP_RADIUS_PX,
|
|
467
|
+
strategy: 'adaptiveRegion',
|
|
468
|
+
adaptive: {
|
|
469
|
+
toleranceBytes,
|
|
470
|
+
regionSizePx: chosen.size,
|
|
471
|
+
regionAreaMm2: chosen.size * pxAreaMm2,
|
|
472
|
+
backgroundByte,
|
|
473
|
+
seedByte: v0,
|
|
474
|
+
polarity,
|
|
475
|
+
seedSnapped: seedX !== clickX || seedY !== clickY,
|
|
476
|
+
windowSize: [wA, wB],
|
|
477
|
+
growthCurve: curve.map((p) => [p.level, p.size]),
|
|
478
|
+
explosionLevel,
|
|
479
|
+
borderTouchLevel,
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
export function resolveInPlaneAxes(referencedVolume, viewport) {
|
|
486
|
+
if (!viewport) {
|
|
487
|
+
return [0, 1];
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
const camera = viewport.getCamera();
|
|
491
|
+
const { ijkVecSliceDir } = getVolumeDirectionVectors(referencedVolume.imageData, camera);
|
|
492
|
+
const abs = ijkVecSliceDir.map(Math.abs);
|
|
493
|
+
let sliceAxis = 0;
|
|
494
|
+
if (abs[1] >= abs[0] && abs[1] >= abs[2]) {
|
|
495
|
+
sliceAxis = 1;
|
|
496
|
+
}
|
|
497
|
+
else if (abs[2] >= abs[0] && abs[2] >= abs[1]) {
|
|
498
|
+
sliceAxis = 2;
|
|
499
|
+
}
|
|
500
|
+
const axes = [0, 1, 2].filter((axis) => axis !== sliceAxis);
|
|
501
|
+
return [axes[0], axes[1]];
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return [0, 1];
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
export function probeAdaptiveRegion(referencedVolume, worldPosition, options) {
|
|
508
|
+
const { dimensions, imageData, spacing } = referencedVolume;
|
|
509
|
+
const voxelManager = referencedVolume.voxelManager;
|
|
510
|
+
const [width, height] = dimensions;
|
|
511
|
+
const pixelsPerSlice = width * height;
|
|
512
|
+
const ijkClick = transformWorldToIndex(imageData, worldPosition).map(Math.round);
|
|
513
|
+
const voiMapping = options?.voiMapping ??
|
|
514
|
+
(options?.viewport && options?.referencedVolumeId
|
|
515
|
+
? getViewportVoiMappingForVolume(options.viewport, options.referencedVolumeId)
|
|
516
|
+
: null);
|
|
517
|
+
const toleranceScale = options?.positiveStdDevMultiplier
|
|
518
|
+
? options.positiveStdDevMultiplier / DEFAULT_POSITIVE_STD_DEV_MULTIPLIER
|
|
519
|
+
: 1;
|
|
520
|
+
const inPlaneAxes = resolveInPlaneAxes(referencedVolume, options?.viewport);
|
|
521
|
+
const inPlaneSpacing = [
|
|
522
|
+
spacing?.[inPlaneAxes[0]] ?? 1,
|
|
523
|
+
spacing?.[inPlaneAxes[1]] ?? 1,
|
|
524
|
+
];
|
|
525
|
+
const result = probeAdaptiveRegionCore({
|
|
526
|
+
dimensions,
|
|
527
|
+
getScalar: (i, j, k) => Number(voxelManager.getAtIndex(k * pixelsPerSlice + j * width + i)),
|
|
528
|
+
ijkClick,
|
|
529
|
+
inPlaneAxes,
|
|
530
|
+
inPlaneSpacing,
|
|
531
|
+
voiMapping,
|
|
532
|
+
toleranceScale,
|
|
533
|
+
});
|
|
534
|
+
log.debug('adaptiveRegion probe', {
|
|
535
|
+
ijkClick,
|
|
536
|
+
viable: result.viable,
|
|
537
|
+
reason: result.reason,
|
|
538
|
+
regionSizePx: result.regionSizePx,
|
|
539
|
+
regionAreaMm2: result.regionAreaMm2,
|
|
540
|
+
toleranceBytes: result.toleranceBytes,
|
|
541
|
+
band: result.range
|
|
542
|
+
? { min: result.range.min, max: result.range.max }
|
|
543
|
+
: null,
|
|
544
|
+
});
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
export const getAdaptiveRegionIntensityRange = (referencedVolume, worldPosition, options) => {
|
|
548
|
+
const probe = probeAdaptiveRegion(referencedVolume, worldPosition, options);
|
|
549
|
+
if (!probe.viable) {
|
|
550
|
+
log.info('adaptiveRegion: no proper region at click', {
|
|
551
|
+
reason: probe.reason,
|
|
552
|
+
regionSizePx: probe.regionSizePx,
|
|
553
|
+
regionAreaMm2: probe.regionAreaMm2,
|
|
554
|
+
debug: probe.debug,
|
|
555
|
+
});
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
return probe.range;
|
|
559
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Types } from '@cornerstonejs/core';
|
|
2
|
+
import type { VoxelManager } from '@cornerstonejs/core/utilities';
|
|
3
|
+
type NumberVoxelManager = VoxelManager<number>;
|
|
4
|
+
type NeighborhoodStats = {
|
|
5
|
+
mean: number;
|
|
6
|
+
stdDev: number;
|
|
7
|
+
count: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function calculateNeighborhoodStatsVM(voxelManager: NumberVoxelManager, dimensions: Types.Point3, centerIjk: Types.Point3, radius: number, mapValue?: (v: number) => number): NeighborhoodStats;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function calculateNeighborhoodStatsVM(voxelManager, dimensions, centerIjk, radius, mapValue) {
|
|
2
|
+
const [width, height, numSlices] = dimensions;
|
|
3
|
+
let sum = 0;
|
|
4
|
+
let sumSq = 0;
|
|
5
|
+
let count = 0;
|
|
6
|
+
const [cx, cy, cz] = centerIjk.map(Math.round);
|
|
7
|
+
for (let z = cz - radius; z <= cz + radius; z++) {
|
|
8
|
+
if (z < 0 || z >= numSlices) {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
for (let y = cy - radius; y <= cy + radius; y++) {
|
|
12
|
+
if (y < 0 || y >= height) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
for (let x = cx - radius; x <= cx + radius; x++) {
|
|
16
|
+
if (x < 0 || x >= width) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const raw = Number(voxelManager.getAtIJK(x, y, z));
|
|
20
|
+
const value = mapValue ? mapValue(raw) : raw;
|
|
21
|
+
sum += value;
|
|
22
|
+
sumSq += value * value;
|
|
23
|
+
count++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (count === 0) {
|
|
28
|
+
if (cx >= 0 &&
|
|
29
|
+
cx < width &&
|
|
30
|
+
cy >= 0 &&
|
|
31
|
+
cy < height &&
|
|
32
|
+
cz >= 0 &&
|
|
33
|
+
cz < numSlices) {
|
|
34
|
+
const raw = Number(voxelManager.getAtIJK(cx, cy, cz));
|
|
35
|
+
const centerValue = mapValue ? mapValue(raw) : raw;
|
|
36
|
+
return { mean: centerValue, stdDev: 0, count: 1 };
|
|
37
|
+
}
|
|
38
|
+
return { mean: 0, stdDev: 0, count: 0 };
|
|
39
|
+
}
|
|
40
|
+
const mean = sum / count;
|
|
41
|
+
const variance = sumSq / count - mean * mean;
|
|
42
|
+
const stdDev = Math.sqrt(Math.max(0, variance));
|
|
43
|
+
return { mean, stdDev, count };
|
|
44
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Types } from '@cornerstonejs/core';
|
|
2
|
+
import type { FloodFillIntensityRangeResult, FloodFillIntensityRangeOptions, GetFloodFillIntensityRange } from './floodFillIntensityRangeTypes.js';
|
|
3
|
+
export type { FloodFillIntensityRangeResult, FloodFillIntensityRangeOptions, GetFloodFillIntensityRange, } from './floodFillIntensityRangeTypes.js';
|
|
4
|
+
type FloodFillSegmentationOptions = {
|
|
5
|
+
isCancelled?: () => boolean;
|
|
6
|
+
positiveStdDevMultiplier?: number;
|
|
7
|
+
initialNeighborhoodRadius?: number;
|
|
8
|
+
segmentIndex?: number;
|
|
9
|
+
floodPreviewSegmentIndex?: number;
|
|
10
|
+
maxInternalRemove?: number;
|
|
11
|
+
applyExternalIslandRemoval?: boolean;
|
|
12
|
+
applyInternalIslandRemoval?: boolean;
|
|
13
|
+
islandRemovalVerboseLogging?: boolean;
|
|
14
|
+
getIntensityRange?: GetFloodFillIntensityRange;
|
|
15
|
+
element?: HTMLDivElement;
|
|
16
|
+
canvasPoint?: {
|
|
17
|
+
x: number;
|
|
18
|
+
y: number;
|
|
19
|
+
};
|
|
20
|
+
intensitySamplingDiskRadiusCanvasPx?: number;
|
|
21
|
+
ensureSliceLoaded?: (sliceIndex: number) => Promise<void>;
|
|
22
|
+
yieldEvery?: number;
|
|
23
|
+
planar?: boolean;
|
|
24
|
+
maxDeltaK?: number;
|
|
25
|
+
maxDeltaIJ?: number;
|
|
26
|
+
onCommitted?: (floodedPoints: Types.Point3[]) => void;
|
|
27
|
+
maxVoxels?: number;
|
|
28
|
+
shouldContinueRegion?: (stats: {
|
|
29
|
+
voxelCount: number;
|
|
30
|
+
bbox: {
|
|
31
|
+
min: Types.Point3;
|
|
32
|
+
max: Types.Point3;
|
|
33
|
+
};
|
|
34
|
+
}) => boolean;
|
|
35
|
+
onRejected?: (info: {
|
|
36
|
+
voxelCount: number;
|
|
37
|
+
bbox: {
|
|
38
|
+
min: Types.Point3;
|
|
39
|
+
max: Types.Point3;
|
|
40
|
+
} | null;
|
|
41
|
+
}) => void;
|
|
42
|
+
historyVoxelManager?: Types.IVoxelManager<number>;
|
|
43
|
+
};
|
|
44
|
+
declare function getDisplayVoiSnapshot(viewport: Types.IViewport, referencedVolumeId?: string): {
|
|
45
|
+
lower: number;
|
|
46
|
+
upper: number;
|
|
47
|
+
windowWidth: number;
|
|
48
|
+
windowCenter: number;
|
|
49
|
+
} | null;
|
|
50
|
+
declare function getPositiveIntensityRangeRaw(referencedVolume: Types.IImageVolume, worldPosition: Types.Point3, options?: FloodFillIntensityRangeOptions): FloodFillIntensityRangeResult | null;
|
|
51
|
+
declare function getPositiveIntensityRangeVoiMapped(referencedVolume: Types.IImageVolume, worldPosition: Types.Point3, voiMapping: NonNullable<FloodFillIntensityRangeOptions['voiMapping']>, options?: FloodFillIntensityRangeOptions): FloodFillIntensityRangeResult | null;
|
|
52
|
+
declare function getPositiveIntensityRange(referencedVolume: Types.IImageVolume, worldPosition: Types.Point3, options?: FloodFillIntensityRangeOptions): FloodFillIntensityRangeResult | null;
|
|
53
|
+
declare function runFloodFillSegmentation({ referencedVolumeId, worldPosition, viewport, labelmapVolume, options, }: {
|
|
54
|
+
referencedVolumeId: string;
|
|
55
|
+
worldPosition: Types.Point3;
|
|
56
|
+
viewport: Types.IViewport;
|
|
57
|
+
labelmapVolume: Types.IImageVolume;
|
|
58
|
+
options?: FloodFillSegmentationOptions;
|
|
59
|
+
}): Promise<Types.IImageVolume | null>;
|
|
60
|
+
export { runFloodFillSegmentation as default, runFloodFillSegmentation, getPositiveIntensityRange, getPositiveIntensityRangeRaw, getPositiveIntensityRangeVoiMapped, getDisplayVoiSnapshot, };
|
|
61
|
+
export type { FloodFillSegmentationOptions };
|