Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ import {
import VideoRedactionTool from './tools/annotation/VideoRedactionTool';

import * as Enums from './enums';
import {
sampleVoxelsFromCanvas,
getCanvasVoxelSamplingStep,
} from './utilities/sampleVoxelsFromCanvas';

export {
VideoRedactionTool,
Expand Down Expand Up @@ -193,6 +197,7 @@ export {
WholeBodySegmentTool,
LabelmapBaseTool,
LabelMapEditWithContourTool,
sampleVoxelsFromCanvas,
// Spline classes
splines,
// Version
Expand Down
139 changes: 32 additions & 107 deletions packages/tools/src/tools/annotation/PlanarFreehandROITool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ import type {
} from '../../types/ToolSpecificAnnotationTypes';
import type { PlanarFreehandROICommonData } from '../../utilities/math/polyline/planarFreehandROIInternalTypes';

import { getLineSegmentIntersectionsCoordinates } from '../../utilities/math/polyline';
import { getIntersectionIterator } from '../../utilities/math/polyline';
import { isViewportPreScaled } from '../../utilities/viewport/isViewportPreScaled';
import { BasicStatsCalculator } from '../../utilities/math/basic';
import ContourSegmentationBaseTool from '../base/ContourSegmentationBaseTool';
import { KeyboardBindings, ChangeTypes, MeasurementType } from '../../enums';
import { getPixelValueUnits } from '../../utilities/getPixelValueUnits';
import snapIndexBounds from '../../utilities/boundingBox/snapIndexBounds';
import {
getCanvasVoxelSamplingStep,
sampleVoxelsFromCanvas,
} from '../../utilities/sampleVoxelsFromCanvas';

const { pointCanProjectOnLine } = polyline;
const { EPSILON } = CONSTANTS;
Expand Down Expand Up @@ -910,43 +913,6 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool {
const { areaUnit, unit } = calibratedScale;

const indexPoints = points.map((point) => imageData.worldToIndex(point));
const dims = imageData.getDimensions();

let iMin = Number.MAX_SAFE_INTEGER;
let iMax = Number.MIN_SAFE_INTEGER;
let jMin = Number.MAX_SAFE_INTEGER;
let jMax = Number.MIN_SAFE_INTEGER;
let kMin = Number.MAX_SAFE_INTEGER;
let kMax = Number.MIN_SAFE_INTEGER;

for (let j = 0; j < points.length; j++) {
const worldPosIndex = indexPoints[j];

iMin = Math.min(iMin, worldPosIndex[0]);
iMax = Math.max(iMax, worldPosIndex[0]);

jMin = Math.min(jMin, worldPosIndex[1]);
jMax = Math.max(jMax, worldPosIndex[1]);

kMin = Math.min(kMin, worldPosIndex[2]);
kMax = Math.max(kMax, worldPosIndex[2]);
}

// Clamp the accumulated bounds into the image extent so the bounding box
// never spills outside the volume. Clamping the min/max here is equivalent
// to clamping every point (clamp is monotonic, so it commutes with
// min/max) but avoids the per-point allocation. The values are left
// unfloored so snapIndexBounds can still detect planar (delta <= 1) ROIs.
iMin = Math.max(0, Math.min(dims[0] - 1, iMin));
iMax = Math.max(0, Math.min(dims[0] - 1, iMax));
jMin = Math.max(0, Math.min(dims[1] - 1, jMin));
jMax = Math.max(0, Math.min(dims[1] - 1, jMax));
kMin = Math.max(0, Math.min(dims[2] - 1, kMin));
kMax = Math.max(0, Math.min(dims[2] - 1, kMax));

[iMin, iMax] = snapIndexBounds(iMin, iMax);
[jMin, jMax] = snapIndexBounds(jMin, jMax);
[kMin, kMax] = snapIndexBounds(kMin, kMax);

// Convert from canvas_pixels ^2 to mm^2
const area = polyline.getArea(canvasCoordinates) * deltaInX * deltaInY;
Expand All @@ -957,74 +923,33 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool {
closed
);

// Expand bounding box
const iDelta = 0.01 * (iMax - iMin);
const jDelta = 0.01 * (jMax - jMin);
const kDelta = 0.01 * (kMax - kMin);

iMin = Math.floor(iMin - iDelta);
iMax = Math.ceil(iMax + iDelta);
jMin = Math.floor(jMin - jDelta);
jMax = Math.ceil(jMax + jDelta);
kMin = Math.floor(kMin - kDelta);
kMax = Math.ceil(kMax + kDelta);

const boundsIJK = [
[iMin, iMax],
[jMin, jMax],
[kMin, kMax],
] as [Types.Point2, Types.Point2, Types.Point2];

const worldPosEnd = imageData.indexToWorld([iMax, jMax, kMax]);
const canvasPosEnd = viewport.worldToCanvas(worldPosEnd);

let curRow = 0;
let intersections = [];
let intersectionCounter = 0;

let pointsInShape;
if (voxelManager) {
pointsInShape = voxelManager.forEach(
this.configuration.statsCalculator.statsCallback,
{
imageData,
isInObject: (pointLPS, _pointIJK) => {
let result = true;
const point = viewport.worldToCanvas(pointLPS);
if (point[1] != curRow) {
intersectionCounter = 0;
curRow = point[1];
intersections = getLineSegmentIntersectionsCoordinates(
canvasCoordinates,
point,
[canvasPosEnd[0], point[1]]
);
intersections.sort(
(function (index) {
return function (a, b) {
return a[index] === b[index]
? 0
: a[index] < b[index]
? -1
: 1;
};
})(0)
);
}
if (intersections.length && point[0] > intersections[0][0]) {
intersections.shift();
intersectionCounter++;
}
if (intersectionCounter % 2 === 0) {
result = false;
}
return result;
},
boundsIJK,
returnPoints: this.configuration.storePointData,
}
);
}
// Viewport-space ROI sampling for both orthogonal and oblique MPR views.
//
// Instead of iterating a 3D IJK bounding box, we rasterize the ROI in
// canvas space, project each canvas pixel back to world space, convert
// to IJK, and sample the corresponding voxel.
//
// This avoids the instability of voxel-driven scanline traversal in
// oblique views, where IJK iteration order no longer matches canvas
// row order, leading to missing voxels and invalid statistics.
//
// Complexity scales with projected ROI size rather than IJK volume,
// making it stable and efficient for all viewport orientations.

const canvasStep = getCanvasVoxelSamplingStep(viewport, imageData);
const pixelIterator = getIntersectionIterator(
canvasCoordinates,
canvasStep
);

const pointsInShape = sampleVoxelsFromCanvas({
iterator: pixelIterator,
imageData,
viewport,
voxelManager,
statsCallback: this.configuration.statsCalculator.statsCallback,
});

const stats = this.configuration.statsCalculator.getStatistics();

const namedArea: Statistics = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { Types } from '@cornerstonejs/core';
import { vec3 } from 'gl-matrix';

/**
* Computes the effective voxel spacing along an arbitrary world-space direction.
*
* For a unit direction vector `d` and volume axes i, j, k with spacings
* s_i, s_j, s_k, the effective spacing is:
*
* s_eff = 1 / sqrt( (d·i / s_i)² + (d·j / s_j)² + (d·k / s_k)² )
*
* This represents the world-space distance you must travel along `d` to cross
* one voxel-equivalent boundary. It naturally reduces to the orthogonal case:
* when `d` is aligned with axis i, d·j = d·k = 0, so s_eff = s_i.
*
* @param direction - A normalized world-space direction vector.
* @param iVector - The volume's I direction (row).
* @param jVector - The volume's J direction (column).
* @param kVector - The volume's K direction (slice).
* @param volumeSpacing - The [i, j, k] voxel spacings.
* @returns The effective spacing along `direction`.
*/
function computeEffectiveVoxelSpacing(
direction: Types.Point3,
iVector: Types.Point3,
jVector: Types.Point3,
kVector: Types.Point3,
volumeSpacing: number[]
): number {
const dotI = vec3.dot(
direction as unknown as vec3,
iVector as unknown as vec3
);
const dotJ = vec3.dot(
direction as unknown as vec3,
jVector as unknown as vec3
);
const dotK = vec3.dot(
direction as unknown as vec3,
kVector as unknown as vec3
);

const sum =
(dotI * dotI) / (volumeSpacing[0] * volumeSpacing[0]) +
(dotJ * dotJ) / (volumeSpacing[1] * volumeSpacing[1]) +
(dotK * dotK) / (volumeSpacing[2] * volumeSpacing[2]);

return 1.0 / Math.sqrt(sum);
}

export default computeEffectiveVoxelSpacing;
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Types } from '@cornerstonejs/core';
import getAABB from './getAABB';
import getLineSegmentIntersectionsCoordinates from './getLineSegmentIntersectionsCoordinates';

/**
* A 2D canvas-space scanline rasterizer that identifies all screen pixels
* residing inside a closed hand-drawn ROI polygon.
* @param canvasCoordinates - An array of 2D points `[x, y]` defining the boundary of the drawn ROI.
* @param canvasStep - Canvas-space step between sample points. Use a value less than 1 when
* zoomed out so adjacent samples stay within one voxel in index space.
* @returns A generator that yields individual `[cx, cy]` canvas pixel coordinates located inside the ROI.
*/
function* getIntersectionIterator(canvasCoordinates, canvasStep = 1) {
const {
maxX: canvasMaxX,
maxY: canvasMaxY,
minX: canvasMinX,
minY: canvasMinY,
} = getAABB(canvasCoordinates);

const startX = Math.floor(canvasMinX);
const endX = Math.ceil(canvasMaxX);
const startY = Math.floor(canvasMinY);
const endY = Math.ceil(canvasMaxY);

const canvasMaxXPadded = endX + 1;

for (let cy = startY; cy <= endY; cy += canvasStep) {
// Compute all intersections of the polygon with this scanline row
const intersections = getLineSegmentIntersectionsCoordinates(
canvasCoordinates,
[startX - 1, cy] as Types.Point2,
[canvasMaxXPadded, cy] as Types.Point2
);

if (!intersections || intersections.length === 0) {
continue;
}

// Sort intersections by X coordinate
intersections.sort((a, b) => a[0] - b[0]);

// Walk through intersection pairs (entry/exit)
for (let i = 0; i + 1 < intersections.length; i += 2) {
const xEnter = intersections[i][0];
const xExit = intersections[i + 1][0];
const firstX = Math.ceil(xEnter / canvasStep) * canvasStep;
const lastX = Math.floor(xExit / canvasStep) * canvasStep;

for (let cx = firstX; cx <= lastX; cx += canvasStep) {
yield [cx, cy];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
}

export default getIntersectionIterator;
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { StackViewport } from '@cornerstonejs/core';
import type { Types } from '@cornerstonejs/core';
import { vec3 } from 'gl-matrix';
import computeEffectiveVoxelSpacing from './computeEffectiveVoxelSpacing';

const EPSILON = 1e-3;

Expand Down Expand Up @@ -47,9 +48,12 @@ const getSubPixelSpacingAndXYDirections = (
const jVector = direction.slice(3, 6) as Types.Point3;
const kVector = direction.slice(6, 9) as Types.Point3;

const viewRight = vec3.create(); // Get the X direction of the viewport
const normalizedViewUp = vec3.create();
vec3.normalize(normalizedViewUp, <vec3>viewUp);

vec3.cross(viewRight, <vec3>viewUp, <vec3>viewPlaneNormal);
const viewRight = vec3.create(); // Get the X direction of the viewport
vec3.cross(viewRight, normalizedViewUp, <vec3>viewPlaneNormal);
vec3.normalize(viewRight, viewRight);

const absViewRightDotI = Math.abs(vec3.dot(viewRight, iVector));
const absViewRightDotJ = Math.abs(vec3.dot(viewRight, jVector));
Expand All @@ -67,12 +71,22 @@ const getSubPixelSpacingAndXYDirections = (
xSpacing = volumeSpacing[2];
xDir = kVector;
} else {
throw new Error('No support yet for oblique plane planar contours');
// Oblique plane: viewRight is not aligned with any single volume axis.
// Compute effective spacing by projecting the view direction onto
// the volume's voxel grid.
xSpacing = computeEffectiveVoxelSpacing(
viewRight as unknown as Types.Point3,
iVector,
jVector,
kVector,
volumeSpacing
);
xDir = Array.from(viewRight) as Types.Point3;
}

const absViewUpDotI = Math.abs(vec3.dot(viewUp, iVector));
const absViewUpDotJ = Math.abs(vec3.dot(viewUp, jVector));
const absViewUpDotK = Math.abs(vec3.dot(viewUp, kVector));
const absViewUpDotI = Math.abs(vec3.dot(normalizedViewUp, iVector));
const absViewUpDotJ = Math.abs(vec3.dot(normalizedViewUp, jVector));
const absViewUpDotK = Math.abs(vec3.dot(normalizedViewUp, kVector));

// Get Y spacing
let ySpacing;
Expand All @@ -86,7 +100,15 @@ const getSubPixelSpacingAndXYDirections = (
ySpacing = volumeSpacing[2];
yDir = kVector;
} else {
throw new Error('No support yet for oblique plane planar contours');
// Oblique plane: compute effective spacing along viewUp
ySpacing = computeEffectiveVoxelSpacing(
normalizedViewUp as unknown as Types.Point3,
iVector,
jVector,
kVector,
volumeSpacing
);
yDir = Array.from(normalizedViewUp) as Types.Point3;
}

spacing = [xSpacing, ySpacing];
Expand Down
4 changes: 4 additions & 0 deletions packages/tools/src/utilities/math/polyline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import getLineSegmentIntersectionsIndexes from './getLineSegmentIntersectionsInd
import getLineSegmentIntersectionsCoordinates from './getLineSegmentIntersectionsCoordinates';
import getClosestLineSegmentIntersection from './getClosestLineSegmentIntersection';
import getSubPixelSpacingAndXYDirections from './getSubPixelSpacingAndXYDirections';
import getIntersectionIterator from './getIntersectionIterator';
import computeEffectiveVoxelSpacing from './computeEffectiveVoxelSpacing';
import pointsAreWithinCloseContourProximity from './pointsAreWithinCloseContourProximity';
import addCanvasPointsToArray from './addCanvasPointsToArray';
import pointCanProjectOnLine from './pointCanProjectOnLine';
Expand All @@ -42,6 +44,8 @@ export {
getLineSegmentIntersectionsCoordinates,
getClosestLineSegmentIntersection,
getSubPixelSpacingAndXYDirections,
getIntersectionIterator,
computeEffectiveVoxelSpacing,
pointsAreWithinCloseContourProximity,
addCanvasPointsToArray,
pointCanProjectOnLine,
Expand Down
Loading
Loading