diff --git a/packages/adapters/src/adapters/Cornerstone3D/Bidirectional.ts b/packages/adapters/src/adapters/Cornerstone3D/Bidirectional.ts index 42cb9a2f84..c2fa3bc4a8 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/Bidirectional.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/Bidirectional.ts @@ -1,6 +1,11 @@ import { utilities } from 'dcmjs'; import MeasurementReport from './MeasurementReport'; -import { scoordToWorld, toScoord, toArray } from '../helpers'; +import { + scoordToWorld, + toScoord, + toArray, + validateNumericValue, +} from '../helpers'; import BaseAdapter3D from './BaseAdapter3D'; const { Bidirectional: TID300Bidirectional } = utilities.TID300; @@ -137,8 +142,8 @@ class Bidirectional extends BaseAdapter3D { point1: shortAxisStartImage, point2: shortAxisEndImage, }, - longAxisLength: length, - shortAxisLength: width, + longAxisLength: validateNumericValue(length), + shortAxisLength: validateNumericValue(width), unit, trackingIdentifierTextValue: this.trackingIdentifierTextValue, finding: finding, diff --git a/packages/adapters/src/adapters/Cornerstone3D/CircleROI.ts b/packages/adapters/src/adapters/Cornerstone3D/CircleROI.ts index 422af6b36b..b2f200978b 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/CircleROI.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/CircleROI.ts @@ -1,7 +1,7 @@ import { utilities } from 'dcmjs'; import MeasurementReport from './MeasurementReport'; import BaseAdapter3D from './BaseAdapter3D'; -import { toScoord } from '../helpers'; +import { toScoord, validateNumericValue } from '../helpers'; import { extractAllNUMGroups, restoreAdditionalMetrics } from './metricHandler'; const { Circle: TID300Circle } = utilities.TID300; @@ -93,16 +93,16 @@ class CircleROI extends BaseAdapter3D { const perimeter = 2 * Math.PI * radius; return { - area, + area: validateNumericValue(area), areaUnit, - perimeter, + perimeter: validateNumericValue(perimeter), modalityUnit, radiusUnit, - radius, - max, - min, - stdDev, - mean, + radius: validateNumericValue(radius), + max: validateNumericValue(max), + min: validateNumericValue(min), + stdDev: validateNumericValue(stdDev), + mean: validateNumericValue(mean), points: [center, end], trackingIdentifierTextValue: this.trackingIdentifierTextValue, finding, diff --git a/packages/adapters/src/adapters/Cornerstone3D/EllipticalROI.ts b/packages/adapters/src/adapters/Cornerstone3D/EllipticalROI.ts index c31df8e57a..aa87fda1d8 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/EllipticalROI.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/EllipticalROI.ts @@ -3,7 +3,7 @@ import { utilities } from 'dcmjs'; import MeasurementReport from './MeasurementReport'; import BaseAdapter3D from './BaseAdapter3D'; -import { toScoord } from '../helpers'; +import { toScoord, validateNumericValue } from '../helpers'; import { extractAllNUMGroups, restoreAdditionalMetrics } from './metricHandler'; const { Ellipse: TID300Ellipse } = utilities.TID300; @@ -109,12 +109,12 @@ class EllipticalROI extends BaseAdapter3D { const convertedPoints = points.map((point) => toScoord(scoordProps, point)); return { - area, + area: validateNumericValue(area), areaUnit, - max, - min, - mean, - stdDev, + max: validateNumericValue(max), + min: validateNumericValue(min), + mean: validateNumericValue(mean), + stdDev: validateNumericValue(stdDev), modalityUnit, points: convertedPoints, trackingIdentifierTextValue: this.trackingIdentifierTextValue, diff --git a/packages/adapters/src/adapters/Cornerstone3D/Length.ts b/packages/adapters/src/adapters/Cornerstone3D/Length.ts index e176a2ec23..fa43f0bf77 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/Length.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/Length.ts @@ -1,7 +1,7 @@ import { utilities } from 'dcmjs'; import MeasurementReport from './MeasurementReport'; import BaseAdapter3D from './BaseAdapter3D'; -import { toScoord } from '../helpers'; +import { toScoord, validateNumericValue } from '../helpers'; const { Length: TID300Length } = utilities.TID300; @@ -76,7 +76,7 @@ export default class Length extends BaseAdapter3D { return { point1, point2, - distance, + distance: validateNumericValue(distance), trackingIdentifierTextValue: this.trackingIdentifierTextValue, finding, findingSites: findingSites || [], diff --git a/packages/adapters/src/adapters/Cornerstone3D/PlanarFreehandROI.ts b/packages/adapters/src/adapters/Cornerstone3D/PlanarFreehandROI.ts index 610cef5155..2908925808 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/PlanarFreehandROI.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/PlanarFreehandROI.ts @@ -3,7 +3,7 @@ import { utilities } from 'dcmjs'; import { vec3 } from 'gl-matrix'; import BaseAdapter3D from './BaseAdapter3D'; import { extractAllNUMGroups, restoreAdditionalMetrics } from './metricHandler'; -import { toScoords, toArray } from '../helpers'; +import { toScoords, toArray, validateNumericValue } from '../helpers'; import ControlPointPolyline from './ControlPointPolyline'; import { SPLINE_TYPE_CODE } from './constants'; @@ -142,13 +142,13 @@ class PlanarFreehandROI extends BaseAdapter3D { /** From cachedStats */ points, controlPoints, - area, + area: validateNumericValue(area), areaUnit, - perimeter: perimeter ?? length, + perimeter: validateNumericValue(perimeter ?? length), modalityUnit, - mean, - max, - stdDev, + mean: validateNumericValue(mean), + max: validateNumericValue(max), + stdDev: validateNumericValue(stdDev), /** Other */ splineType: data.spline?.type, trackingIdentifierTextValue: this.trackingIdentifierTextValue, diff --git a/packages/adapters/src/adapters/Cornerstone3D/RectangleROI.ts b/packages/adapters/src/adapters/Cornerstone3D/RectangleROI.ts index 3d1a8a0156..393bc89615 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/RectangleROI.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/RectangleROI.ts @@ -1,6 +1,6 @@ import { utilities } from 'dcmjs'; -import { toScoords } from '../helpers'; +import { toScoords, validateNumericValue } from '../helpers'; import MeasurementReport from './MeasurementReport'; import BaseAdapter3D from './BaseAdapter3D'; import { extractAllNUMGroups, restoreAdditionalMetrics } from './metricHandler'; @@ -95,11 +95,11 @@ export class RectangleROI extends BaseAdapter3D { return { points: [corners[0], corners[1], corners[3], corners[2], corners[0]], - area, - perimeter, - max, - mean, - stdDev, + area: validateNumericValue(area), + perimeter: validateNumericValue(perimeter), + max: validateNumericValue(max), + mean: validateNumericValue(mean), + stdDev: validateNumericValue(stdDev), areaUnit, modalityUnit, trackingIdentifierTextValue: this.trackingIdentifierTextValue, diff --git a/packages/adapters/src/adapters/helpers/index.ts b/packages/adapters/src/adapters/helpers/index.ts index 59c574b61c..9cfb13d516 100644 --- a/packages/adapters/src/adapters/helpers/index.ts +++ b/packages/adapters/src/adapters/helpers/index.ts @@ -2,6 +2,8 @@ import { toArray } from './toArray'; import { codeMeaningEquals } from './codeMeaningEquals'; import { graphicTypeEquals } from './graphicTypeEquals'; import { downloadDICOMData } from './downloadDICOMData'; +import { validateNumericValue } from './validateNumericValue'; + export { copyStudyTags } from './copyStudyTags'; export { copySeriesTags } from './copySeriesTags'; @@ -9,4 +11,10 @@ export * from './toScoordType'; export * from './scoordToWorld'; export * from './toPoint3'; -export { toArray, codeMeaningEquals, graphicTypeEquals, downloadDICOMData }; +export { + toArray, + codeMeaningEquals, + graphicTypeEquals, + downloadDICOMData, + validateNumericValue, +}; diff --git a/packages/adapters/src/adapters/helpers/validateNumericValue.ts b/packages/adapters/src/adapters/helpers/validateNumericValue.ts new file mode 100644 index 0000000000..f2c226b5b5 --- /dev/null +++ b/packages/adapters/src/adapters/helpers/validateNumericValue.ts @@ -0,0 +1,14 @@ +/** + * Validates a numeric value for DICOM SR NumericValue field (VR: DS). + * DS (Decimal String) does not support Infinity, -Infinity, or NaN. + * + * @param value - The numeric value to validate + * @returns The value if valid, undefined if invalid (Infinity, -Infinity, NaN, null, or undefined) + */ +export function validateNumericValue( + value: number | null | undefined +): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} diff --git a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts index 26c3aa89a2..41a70e51e6 100644 --- a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts +++ b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts @@ -961,16 +961,44 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { let intersections = []; let intersectionCounter = 0; + const { viewPlaneNormal } = viewport.getCamera(); + + /** + * Detect if the current viewport orientation is oblique. + * For oblique planes, the worldToCanvas transformation can produce + * slightly different floating-point Y values for points that belong + * to the same scanline due to floating-point precision and projection. + * + * This causes the scanline intersection logic to reset frequently, + * resulting in missing voxels and incorrect min/max/density statistics. + * + * To stabilize scanline detection, we introduce a tolerance (rowDelta) + * when comparing the current canvas Y coordinate with the previous row. + * + * - For orthogonal views, no tolerance is required (rowDelta = 0). + * - For oblique views, we allow a small half-pixel tolerance (0.5) + * so that points with very small floating-point differences are + * treated as belonging to the same scanline. + */ + const TOLERANCE = 0.5; + + const isOblique = + viewPlaneNormal.filter((c) => Math.abs(c) > EPSILON).length > 1; + + const rowDelta = isOblique ? TOLERANCE : 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) { + // Use tolerance-based comparison to avoid scanline resets caused + // by floating-point precision differences in oblique projections. + if (Math.abs(point[1] - curRow) > rowDelta) { intersectionCounter = 0; curRow = point[1]; intersections = getLineSegmentIntersectionsCoordinates( @@ -994,10 +1022,7 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { intersections.shift(); intersectionCounter++; } - if (intersectionCounter % 2 === 0) { - result = false; - } - return result; + return intersectionCounter % 2 === 1; }, boundsIJK, returnPoints: this.configuration.storePointData,