diff --git a/common/reviews/api/tools.api.md b/common/reviews/api/tools.api.md index 6dadd7ce47..4dace9b2c1 100644 --- a/common/reviews/api/tools.api.md +++ b/common/reviews/api/tools.api.md @@ -1690,6 +1690,9 @@ function debounce(func: Function, wait?: number, options?: { trailing?: boolean; }): Function; +// @public (undocumented) +function decimate(polyline: Types_2.Point2[], epsilon?: number): Types_2.Point2[]; + // @public (undocumented) const _default: { filterAnnotationsWithinSlice: typeof filterAnnotationsWithinSlice; @@ -3828,6 +3831,7 @@ declare namespace polyline { getNormal3, getNormal2, intersectPolyline, + decimate, getFirstLineSegmentIntersectionIndexes, getLineSegmentIntersectionsIndexes, getLineSegmentIntersectionsCoordinates, @@ -5745,6 +5749,11 @@ function updateContourPolyline(annotation: ContourAnnotation, polylineData: { targetWindingDirection?: ContourWindingDirection; }, transforms: { canvasToWorld: (point: Types_2.Point2) => Types_2.Point3; +}, options?: { + decimate?: { + enabled?: boolean; + epsilon?: number; + }; }): void; declare namespace utilities { diff --git a/packages/tools/src/eventListeners/annotations/contourSegmentation/contourSegmentationCompleted.ts b/packages/tools/src/eventListeners/annotations/contourSegmentation/contourSegmentationCompleted.ts index f93e89ca85..c98244656e 100644 --- a/packages/tools/src/eventListeners/annotations/contourSegmentation/contourSegmentationCompleted.ts +++ b/packages/tools/src/eventListeners/annotations/contourSegmentation/contourSegmentationCompleted.ts @@ -242,7 +242,7 @@ function getContourHolesData( }); } -async function combinePolylines( +function combinePolylines( viewport: Types.IViewport, targetAnnotation: ContourSegmentationAnnotation, targetPolyline: Types.Point2[], diff --git a/packages/tools/src/tools/annotation/LivewireContourTool.ts b/packages/tools/src/tools/annotation/LivewireContourTool.ts index 585e3e209d..a297ed9d9a 100644 --- a/packages/tools/src/tools/annotation/LivewireContourTool.ts +++ b/packages/tools/src/tools/annotation/LivewireContourTool.ts @@ -32,7 +32,6 @@ import { LivewireScissors } from '../../utilities/livewire/LivewireScissors'; import { LivewirePath } from '../../utilities/livewire/LiveWirePath'; import { getViewportIdsWithToolToRender } from '../../utilities/viewportFilters'; import ContourSegmentationBaseTool from '../base/ContourSegmentationBaseTool'; -import updateContourPolyline from '../../utilities/contours/updateContourPolyline'; const CLICK_CLOSE_CURVE_SQR_DIST = 10 ** 2; // px @@ -108,6 +107,20 @@ class LivewireContourTool extends ContourSegmentationBaseTool { */ showInterpolationPolyline: false, }, + + /** + * The polyline may get processed in order to reduce the number of points + * for better performance and storage. + */ + decimate: { + enabled: false, + /** A maximum given distance 'epsilon' to decide if a point should or + * shouldn't be added the resulting polyline which will have a lower + * number of points for higher `epsilon` values. + */ + epsilon: 0.1, + }, + actions: { undo: { method: 'undo', @@ -922,7 +935,7 @@ class LivewireContourTool extends ContourSegmentationBaseTool { imagePoints = [...imagePoints, imagePoints[0]]; } - updateContourPolyline( + this.updateContourPolyline( annotation, { points: imagePoints, diff --git a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts index 20e26e997a..48a597bf5a 100644 --- a/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts +++ b/packages/tools/src/tools/annotation/PlanarFreehandROITool.ts @@ -231,6 +231,18 @@ class PlanarFreehandROITool extends ContourSegmentationBaseTool { // interpolation is complete. onInterpolationComplete: null, }, + /** + * The polyline may get processed in order to reduce the number of points + * for better performance and storage. + */ + decimate: { + enabled: false, + /** A maximum given distance 'epsilon' to decide if a point should or + * shouldn't be added the resulting polyline which will have a lower + * number of points for higher `epsilon` values. + */ + epsilon: 0.1, + }, calculateStats: false, getTextLines: defaultGetTextLines, statsCalculator: BasicStatsCalculator, diff --git a/packages/tools/src/tools/annotation/SplineROITool.ts b/packages/tools/src/tools/annotation/SplineROITool.ts index b54789aa2f..fce37f1de3 100644 --- a/packages/tools/src/tools/annotation/SplineROITool.ts +++ b/packages/tools/src/tools/annotation/SplineROITool.ts @@ -56,7 +56,6 @@ import { LinearSpline } from './splines/LinearSpline'; import { CatmullRomSpline } from './splines/CatmullRomSpline'; import { BSpline } from './splines/BSpline'; import ContourSegmentationBaseTool from '../base/ContourSegmentationBaseTool'; -import updateContourPolyline from '../../utilities/contours/updateContourPolyline'; const SPLINE_MIN_POINTS = 3; const SPLINE_CLICK_CLOSE_CURVE_DIST = 10; @@ -121,6 +120,18 @@ class SplineROITool extends ContourSegmentationBaseTool { * modifier must be pressed when the first point of a new contour is added. */ contourHoleAdditionModifierKey: KeyboardBindings.Shift, + /** + * The polyline may get processed in order to reduce the number of points + * for better performance and storage. + */ + decimate: { + enabled: false, + /** A maximum given distance 'epsilon' to decide if a point should or + * shouldn't be added the resulting polyline which will have a lower + * number of points for higher `epsilon` values. + */ + epsilon: 0.1, + }, spline: { configuration: { [SplineTypesEnum.Cardinal]: { @@ -693,7 +704,7 @@ class SplineROITool extends ContourSegmentationBaseTool { const spline = this._updateSplineInstance(element, annotation); const splinePolylineCanvas = spline.getPolylinePoints(); - updateContourPolyline( + this.updateContourPolyline( annotation, { points: splinePolylineCanvas, diff --git a/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts b/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts index df030c61aa..b471fff7b5 100644 --- a/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts +++ b/packages/tools/src/tools/annotation/planarFreehandROITool/drawLoop.ts @@ -18,7 +18,6 @@ import { PlanarFreehandROIAnnotation } from '../../../types/ToolSpecificAnnotati import findOpenUShapedContourVectorToPeak from './findOpenUShapedContourVectorToPeak'; import { polyline } from '../../../utilities/math'; import { removeAnnotation } from '../../../stateManagement/annotation/annotationState'; -import { updateContourPolyline } from '../../../utilities/contours/'; import reverseIfAntiClockwise from '../../../utilities/contours/reverseIfAntiClockwise'; const { @@ -238,7 +237,7 @@ function completeDrawClosedContour( // contours. A future optimization if we use this for segmentation is to re-do // this rendering with the GPU rather than SVG. - updateContourPolyline( + this.updateContourPolyline( annotation, { points: updatedPoints, @@ -315,7 +314,7 @@ function completeDrawOpenContour( // contours. A future optimisation if we use this for segmentation is to re-do // this rendering with the GPU rather than SVG. - updateContourPolyline( + this.updateContourPolyline( annotation, { points: updatedPoints, diff --git a/packages/tools/src/tools/base/ContourBaseTool.ts b/packages/tools/src/tools/base/ContourBaseTool.ts index 5cdda04e28..667d6e2b6f 100644 --- a/packages/tools/src/tools/base/ContourBaseTool.ts +++ b/packages/tools/src/tools/base/ContourBaseTool.ts @@ -17,7 +17,9 @@ import type { import { drawPath as drawPathSvg } from '../../drawingSvg'; import { StyleSpecifier } from '../../types/AnnotationStyle'; import AnnotationTool from './AnnotationTool'; +import { updateContourPolyline } from '../../utilities/contours/'; import { getContourHolesDataCanvas } from '../../utilities/contours'; +import { ContourWindingDirection } from '../../types/ContourAnnotation'; /** * A contour base class responsible for rendering contour instances such as @@ -210,6 +212,27 @@ abstract class ContourBaseTool extends AnnotationTool { ); } + protected updateContourPolyline( + annotation: ContourAnnotation, + polylineData: { + points: Types.Point2[]; + closed?: boolean; + targetWindingDirection?: ContourWindingDirection; + }, + transforms: { + canvasToWorld: (point: Types.Point2) => Types.Point3; + } + ) { + const decimateConfig = this.configuration?.decimate || {}; + + updateContourPolyline(annotation, polylineData, transforms, { + decimate: { + enabled: !!decimateConfig.enabled, + epsilon: decimateConfig.epsilon, + }, + }); + } + /** * Get polyline points in world space. * Just to give a chance for child classes to override it. diff --git a/packages/tools/src/utilities/contours/updateContourPolyline.ts b/packages/tools/src/utilities/contours/updateContourPolyline.ts index df44167563..b42d9943db 100644 --- a/packages/tools/src/utilities/contours/updateContourPolyline.ts +++ b/packages/tools/src/utilities/contours/updateContourPolyline.ts @@ -14,6 +14,12 @@ import { * @param viewport - Viewport * @param polylineData - Polyline data (points, winding direction and closed) * @param transforms - Methods to convert points to/from canvas and world spaces + * @param options - Options + * - decimate: allow to set some parameters to decimate the polyline reducing + * the amount of points stored which also affects how fast it will draw the + * annotation in a viewport, compute the winding direction, append/remove + * contours and create holes. A higher `epsilon` value results in a polyline + * with less points. */ export default function updateContourPolyline( annotation: ContourAnnotation, @@ -24,11 +30,27 @@ export default function updateContourPolyline( }, transforms: { canvasToWorld: (point: Types.Point2) => Types.Point3; + }, + options?: { + decimate?: { + enabled?: boolean; + epsilon?: number; + }; } ) { const { canvasToWorld } = transforms; const { data } = annotation; - const { points: polyline, targetWindingDirection } = polylineData; + const { targetWindingDirection } = polylineData; + let { points: polyline } = polylineData; + + // Decimate the polyline to reduce tha amount of points + if (options?.decimate?.enabled) { + polyline = math.polyline.decimate( + polylineData.points, + options?.decimate?.epsilon + ); + } + let { closed } = polylineData; const numPoints = polyline.length; const polylineWorldPoints = new Array(numPoints); diff --git a/packages/tools/src/utilities/math/line/distanceToPointSquaredInfo.ts b/packages/tools/src/utilities/math/line/distanceToPointSquaredInfo.ts index f7a11570a5..7ea5763a8c 100644 --- a/packages/tools/src/utilities/math/line/distanceToPointSquaredInfo.ts +++ b/packages/tools/src/utilities/math/line/distanceToPointSquaredInfo.ts @@ -24,7 +24,8 @@ export default function distanceToPointSquaredInfo( let closestPoint: Types.Point2; const distanceSquared = math.point.distanceToPointSquared(lineStart, lineEnd); - // Check if lineStart is the same as lineEnd which means + // Check if lineStart equal to the lineEnd which means the closest point + // is any of these two points if (lineStart[0] === lineEnd[0] && lineStart[1] === lineEnd[1]) { closestPoint = lineStart; } diff --git a/packages/tools/src/utilities/math/polyline/decimate.ts b/packages/tools/src/utilities/math/polyline/decimate.ts new file mode 100644 index 0000000000..c0c8b376b1 --- /dev/null +++ b/packages/tools/src/utilities/math/polyline/decimate.ts @@ -0,0 +1,105 @@ +import type { Types } from '@cornerstonejs/core'; +import * as mathLine from '../line'; + +const DEFAULT_EPSILON = 0.1; + +/** + * Ramer–Douglas–Peucker algorithm implementation to decimate a polyline + * to a similar polyline with fewer points + * + * https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm + * https://rosettacode.org/wiki/Ramer-Douglas-Peucker_line_simplification + * https://karthaus.nl/rdp/ + * + * @param polyline - Polyline to decimate + * @param epsilon - A maximum given distance 'epsilon' to decide if a point + * should or shouldn't be added the decimated polyline version. In each + * iteration the polyline is split into two polylines and the distance of each + * point from those new polylines are checked against the line that connects + * the first and last points. + * @returns Decimated polyline + */ +export default function decimate( + polyline: Types.Point2[], + epsilon = DEFAULT_EPSILON +) { + const numPoints = polyline.length; + + // The polyline must have at least a start and end points + if (numPoints < 3) { + return polyline; + } + + const epsilonSquared = epsilon * epsilon; + const partitionQueue = [[0, numPoints - 1]]; + + // Used a boolean array to set each point that will be in the decimated polyline + // because pre-allocated arrays are 3-4x faster than thousands of push() calls + // to add all points to a new array. + const polylinePointFlags = new Array(numPoints).fill(false); + + // Start and end points are always added to the decimated polyline + let numDecimatedPoints = 2; + + // Add start and end points to the decimated polyline + polylinePointFlags[0] = true; + polylinePointFlags[numPoints - 1] = true; + + // Iterative approach using a queue instead of recursion to reduce the number + // of function calls (performance) + while (partitionQueue.length) { + const [startIndex, endIndex] = partitionQueue.pop(); + + // Return if there is no point between the start and end points + if (endIndex - startIndex === 1) { + continue; + } + + const startPoint = polyline[startIndex]; + const endPoint = polyline[endIndex]; + let maxDistSquared = -Infinity; + let maxDistIndex = -1; + + // Search for the furthest point + for (let i = startIndex + 1; i < endIndex; i++) { + const currentPoint = polyline[i]; + const distSquared = mathLine.distanceToPointSquared( + startPoint, + endPoint, + currentPoint + ); + + if (distSquared > maxDistSquared) { + maxDistSquared = distSquared; + maxDistIndex = i; + } + } + + // Do not add any of the points because the fursthest one is very close to + // the line based on the epsilon value + if (maxDistSquared < epsilonSquared) { + continue; + } + + // Update the flag for the furthest point because it will be added to the + // decimated polyline + polylinePointFlags[maxDistIndex] = true; + numDecimatedPoints++; + + // Partition the points into two parts using maxDistIndex as the pivot point + // and process both sides + partitionQueue.push([maxDistIndex, endIndex]); + partitionQueue.push([startIndex, maxDistIndex]); + } + + // A pre-allocated array is 3-4x faster then multiple push() calls + const decimatedPolyline: Types.Point2[] = new Array(numDecimatedPoints); + + for (let srcIndex = 0, dstIndex = 0; srcIndex < numPoints; srcIndex++) { + if (polylinePointFlags[srcIndex]) { + decimatedPolyline[dstIndex++] = polyline[srcIndex]; + } + } + + return decimatedPolyline; +} diff --git a/packages/tools/src/utilities/math/polyline/index.ts b/packages/tools/src/utilities/math/polyline/index.ts index 0608b144e1..7091c4c3c8 100644 --- a/packages/tools/src/utilities/math/polyline/index.ts +++ b/packages/tools/src/utilities/math/polyline/index.ts @@ -9,6 +9,7 @@ import getNormal3 from './getNormal3'; import getNormal2 from './getNormal2'; import { mergePolylines, subtractPolylines } from './combinePolyline'; import intersectPolyline from './intersectPolyline'; +import decimate from './decimate'; import getFirstLineSegmentIntersectionIndexes from './getFirstLineSegmentIntersectionIndexes'; import getLineSegmentIntersectionsIndexes from './getLineSegmentIntersectionsIndexes'; import getLineSegmentIntersectionsCoordinates from './getLineSegmentIntersectionsCoordinates'; @@ -30,6 +31,7 @@ export { getNormal3, getNormal2, intersectPolyline, + decimate, getFirstLineSegmentIntersectionIndexes, getLineSegmentIntersectionsIndexes, getLineSegmentIntersectionsCoordinates,