import { Point3, Point2, fitCircleFrom3Points, vectorAdd } from './mathUtils'; import { utilities as csUtils, Types as CoreTypes, getEnabledElement, } from '@cornerstonejs/core'; import { AnnotationTool, utilities, Types, annotation, drawing, } from '@cornerstonejs/tools'; const { drawHandles, drawLinkedTextBox, drawLine: drawLineSvg, drawCircle: drawCircleSvg, } = drawing; import { PublicToolProps, ToolProps, EventTypes, SVGDrawingHelper, } from '@cornerstonejs/tools/dist/esm/types'; // 注解初始配置接口 interface AnnotationInitialConfig { acetabulumRadius?: number; // 髋臼圆初始半径(像素) femurRadius?: number; // 股骨头圆初始半径(像素) circleDistance?: number; // 两圆心之间的初始距离(像素) } // 注解数据接口 interface HipDIAnnotation extends Types.Annotation { data: { handles: { points: CoreTypes.Point3[]; activeHandleIndex: number | null; }; textBox?: CoreTypes.Point2; // DI文本框位置 cachedStats?: { [targetId: string]: { acetabulumCircle?: { center: CoreTypes.Point3; radius: number; }; femurCircle?: { center: CoreTypes.Point3; radius: number; }; distance: number; DI: number; }; }; }; } // 导出的注解数据接口 export interface ExportedDIData { points: CoreTypes.Point3[]; // 6个点的世界坐标 circles: { acetabulum: { center: CoreTypes.Point3; radius: number; }; femur: { center: CoreTypes.Point3; radius: number; }; }; measurements: { distance: number; DI: number; }; textBoxPosition?: CoreTypes.Point2; metadata: { viewPlaneNormal: CoreTypes.Point3; viewUp: CoreTypes.Point3; FrameOfReferenceUID: string; referencedImageId: string; }; } export default class HipDIMeasurementTool extends AnnotationTool { static toolName = 'HipDIMeasurementTool'; editData: { annotation: Types.Annotation; viewportIdsToRender: string[]; handleIndex?: number; newAnnotation?: boolean; hasMoved?: boolean; textBoxBeingMoved?: boolean; textBoxOffset?: CoreTypes.Point2; } | null = null; isDrawing: boolean = false; /** * 创建一个预设的注解,带有6个默认点 * @param element HTML元素 * @param viewport Viewport实例 * @param config 可选的初始配置 */ static createDefaultAnnotation( element: HTMLDivElement, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport, config?: AnnotationInitialConfig ): HipDIAnnotation { const enabledElement = getEnabledElement(element); if (!enabledElement) { throw new Error('Element is not enabled'); } // 获取viewport的尺寸 const canvas = viewport.canvas; const { width, height } = canvas; const centerX = width / 2; const centerY = height / 2; // 应用默认值 const acetabulumRadius = config?.acetabulumRadius ?? 80; const femurRadius = config?.femurRadius ?? 60; const circleDistance = config?.circleDistance ?? 100; // 创建髋臼圆的3个点(左侧圆,点1,2,3) // 在圆周上均匀分布3个点(0度、120度、240度) const acetabulumCenterX = centerX - circleDistance / 2; const acetabulumCenterY = centerY; const angle1 = 0; const angle2 = (2 * Math.PI) / 3; // 120度 const angle3 = (4 * Math.PI) / 3; // 240度 const acetabulumPoints: CoreTypes.Point2[] = [ [ acetabulumCenterX + acetabulumRadius * Math.cos(angle1), acetabulumCenterY + acetabulumRadius * Math.sin(angle1) ], [ acetabulumCenterX + acetabulumRadius * Math.cos(angle2), acetabulumCenterY + acetabulumRadius * Math.sin(angle2) ], [ acetabulumCenterX + acetabulumRadius * Math.cos(angle3), acetabulumCenterY + acetabulumRadius * Math.sin(angle3) ] ]; // 创建股骨头圆的3个点(右侧圆,点4,5,6) const femurCenterX = centerX + circleDistance / 2; const femurCenterY = centerY; const femurPoints: CoreTypes.Point2[] = [ [ femurCenterX + femurRadius * Math.cos(angle1), femurCenterY + femurRadius * Math.sin(angle1) ], [ femurCenterX + femurRadius * Math.cos(angle2), femurCenterY + femurRadius * Math.sin(angle2) ], [ femurCenterX + femurRadius * Math.cos(angle3), femurCenterY + femurRadius * Math.sin(angle3) ] ]; const canvasPoints: CoreTypes.Point2[] = [ ...acetabulumPoints, ...femurPoints ]; // 转换为world坐标 const worldPoints = canvasPoints.map((canvasPoint) => viewport.canvasToWorld(canvasPoint) ); const camera = viewport.getCamera(); const { viewPlaneNormal, viewUp } = camera; if (viewPlaneNormal === undefined) { throw new Error('viewPlaneNormal is undefined'); } if (viewUp === undefined) { throw new Error('viewUp is undefined'); } const annotationData = { invalidated: true, highlighted: false, metadata: { viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3, viewUp: [...viewUp] as CoreTypes.Point3, FrameOfReferenceUID: viewport.getFrameOfReferenceUID(), referencedImageId: viewport.getCurrentImageId?.() || '', toolName: HipDIMeasurementTool.toolName, }, data: { label: '', handles: { points: worldPoints, activeHandleIndex: null, }, cachedStats: {}, }, } as HipDIAnnotation; return annotationData; } /** * 导出注解数据 * @param annotation 要导出的注解 * @returns 导出的数据对象 */ static exportAnnotationData( annotation: HipDIAnnotation, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport ): ExportedDIData | null { const targetId = `imageId:${viewport.getCurrentImageId?.() || ''}`; const cachedStats = annotation.data.cachedStats?.[targetId]; if (!cachedStats || !cachedStats.acetabulumCircle || !cachedStats.femurCircle) { return null; } return { points: [...annotation.data.handles.points], circles: { acetabulum: { center: cachedStats.acetabulumCircle.center, radius: cachedStats.acetabulumCircle.radius, }, femur: { center: cachedStats.femurCircle.center, radius: cachedStats.femurCircle.radius, }, }, measurements: { distance: cachedStats.distance, DI: cachedStats.DI, }, textBoxPosition: annotation.data.textBox, metadata: { viewPlaneNormal: annotation.metadata?.viewPlaneNormal || [0, 0, 1], viewUp: annotation.metadata?.viewUp || [0, 1, 0], FrameOfReferenceUID: annotation.metadata?.FrameOfReferenceUID || '', referencedImageId: annotation.metadata?.referencedImageId || '', }, }; } /** * 从导出的数据恢复注解 * @param exportedData 导出的数据 * @param element HTML元素 * @param viewport Viewport实例 * @returns 恢复的注解 */ static restoreFromExportedData( exportedData: ExportedDIData, element: HTMLDivElement, viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport ): HipDIAnnotation { const enabledElement = getEnabledElement(element); if (!enabledElement) { throw new Error('Element is not enabled'); } const annotationData = { invalidated: true, highlighted: false, metadata: { ...exportedData.metadata, toolName: HipDIMeasurementTool.toolName, }, data: { label: '', handles: { points: [...exportedData.points], activeHandleIndex: null, }, textBox: exportedData.textBoxPosition, cachedStats: {}, }, } as HipDIAnnotation; return annotationData; } constructor( toolProps: PublicToolProps = {}, defaultToolProps: ToolProps = { supportedInteractionTypes: ['Mouse', 'Touch'], configuration: { shadow: true, preventHandleOutsideImage: false, }, } ) { super(toolProps, defaultToolProps); } /** * 添加新注解 - 禁用此功能 */ addNewAnnotation( evt: EventTypes.InteractionEventType ): HipDIAnnotation { evt.preventDefault(); return {} as HipDIAnnotation; } /** * 检查点是否靠近工具 */ isPointNearTool( element: HTMLDivElement, annotation: HipDIAnnotation, canvasCoords: CoreTypes.Point2, proximity: number ): boolean { const enabledElement = getEnabledElement(element); if (!enabledElement) { return false; } const { viewport } = enabledElement; const points = annotation.data.handles.points; // 检查是否靠近任意一个手柄点 for (let i = 0; i < points.length; i++) { const point = points[i]; const canvasPoint = viewport.worldToCanvas(point); const distance = Math.sqrt( Math.pow(canvasPoint[0] - canvasCoords[0], 2) + Math.pow(canvasPoint[1] - canvasCoords[1], 2) ); if (distance < proximity) { return true; } } return false; } /** * 检查点击是否在文本框内 */ private _isPointInTextBox( canvasPoint: CoreTypes.Point2, textBoxPosition: CoreTypes.Point2, textWidth: number = 100, textHeight: number = 40 ): boolean { const [x, y] = canvasPoint; const [tx, ty] = textBoxPosition; const margin = 10; return ( x >= tx - margin && x <= tx + textWidth + margin && y >= ty - margin && y <= ty + textHeight + margin ); } /** * 取消操作 */ cancel(element: HTMLDivElement): string { if (this.isDrawing) { this.isDrawing = false; this._deactivateDraw(element); this._deactivateModify(element); const enabledElement = getEnabledElement(element); if (enabledElement) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); } this.editData = null; return this.getToolName(); } return ''; } /** * 激活绘制模式 */ _activateDraw(element: HTMLDivElement): void { element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_DRAG', this._dragCallback as EventListener ); element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_UP', this._endCallback as EventListener ); element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_CLICK', this._endCallback as EventListener ); } /** * 取消激活绘制模式 */ _deactivateDraw(element: HTMLDivElement): void { element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_DRAG', this._dragCallback as EventListener ); element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_UP', this._endCallback as EventListener ); element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_CLICK', this._endCallback as EventListener ); } /** * 激活修改模式 */ _activateModify(element: HTMLDivElement): void { element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_DOWN', this._mouseDownModifyCallback as EventListener ); element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_DRAG', this._mouseDragModifyCallback as EventListener ); element.addEventListener( 'CORNERSTONE_TOOLS_MOUSE_UP', this._mouseUpModifyCallback as EventListener ); } /** * 取消激活修改模式 */ _deactivateModify(element: HTMLDivElement): void { element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_DOWN', this._mouseDownModifyCallback as EventListener ); element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_DRAG', this._mouseDragModifyCallback as EventListener ); element.removeEventListener( 'CORNERSTONE_TOOLS_MOUSE_UP', this._mouseUpModifyCallback as EventListener ); } /** * 鼠标按下回调 - 用于修改模式 */ _mouseDownModifyCallback = (evt: EventTypes.InteractionEventType): void => { const eventDetail = evt.detail; const { element, currentPoints } = eventDetail; const canvasCoords = currentPoints.canvas; const enabledElement = getEnabledElement(element); if (!enabledElement) { return; } const { viewport } = enabledElement; const annotations = annotation.state.getAnnotations(this.getToolName(), element); if (!annotations || annotations.length === 0) { return; } // 先检查是否点击在文本框上 for (const ann of annotations) { const customAnn = ann as HipDIAnnotation; const targetId = this.getTargetId(viewport); const cachedStats = targetId ? customAnn.data.cachedStats?.[targetId] : undefined; if (cachedStats?.femurCircle) { const femurCenterCanvas = viewport.worldToCanvas(cachedStats.femurCircle.center); const textBox: CoreTypes.Point2 = customAnn.data.textBox || [ femurCenterCanvas[0] + 20, femurCenterCanvas[1] - 20, ]; if (this._isPointInTextBox(canvasCoords, textBox)) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); const textBoxOffset: CoreTypes.Point2 = [ canvasCoords[0] - textBox[0], canvasCoords[1] - textBox[1], ]; this.editData = { annotation: customAnn, viewportIdsToRender, hasMoved: false, textBoxBeingMoved: true, textBoxOffset: textBoxOffset, }; customAnn.highlighted = true; utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender); evt.preventDefault(); evt.stopPropagation(); return; } } } // 查找最近的手柄 for (const ann of annotations) { const customAnn = ann as HipDIAnnotation; const handle = this.getHandleNearImagePoint( element, customAnn, canvasCoords, 6 ); if (handle) { const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); this.editData = { annotation: customAnn, viewportIdsToRender, handleIndex: customAnn.data.handles.activeHandleIndex || 0, hasMoved: false, }; customAnn.highlighted = true; utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); evt.stopPropagation(); return; } } }; /** * 鼠标拖拽回调 - 用于修改模式 */ _mouseDragModifyCallback = (evt: EventTypes.InteractionEventType): void => { if (!this.editData) { return; } const eventDetail = evt.detail; const { currentPoints } = eventDetail; const canvasCoords = currentPoints.canvas; const enabledElement = getEnabledElement(eventDetail.element); if (!enabledElement) { return; } const { annotation: ann, viewportIdsToRender, textBoxBeingMoved } = this.editData; const customAnn = ann as HipDIAnnotation; const { data } = customAnn; // 如果正在拖拽文本框 if (textBoxBeingMoved && this.editData.textBoxOffset) { const newTextBoxPosition: CoreTypes.Point2 = [ canvasCoords[0] - this.editData.textBoxOffset[0], canvasCoords[1] - this.editData.textBoxOffset[1], ]; data.textBox = newTextBoxPosition; this.editData.hasMoved = true; utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); evt.stopPropagation(); return; } // 处理手柄拖拽 const worldPos = currentPoints.world; const activeHandleIndex = data.handles.activeHandleIndex; if (activeHandleIndex !== null && activeHandleIndex >= 0 && activeHandleIndex < data.handles.points.length) { data.handles.points[activeHandleIndex] = worldPos; this._updateCachedStats(customAnn, enabledElement); this.editData.hasMoved = true; utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); evt.stopPropagation(); } }; /** * 鼠标释放回调 - 用于修改模式 */ _mouseUpModifyCallback = (evt: EventTypes.InteractionEventType): void => { if (!this.editData) { return; } const { annotation: ann } = this.editData; const customAnn = ann as HipDIAnnotation; customAnn.data.handles.activeHandleIndex = null; customAnn.highlighted = false; const eventDetail = evt.detail; const { element } = eventDetail; const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); this.editData = null; evt.preventDefault(); evt.stopPropagation(); }; /** * 处理选中回调 */ handleSelectedCallback( evt: EventTypes.InteractionEventType, annotation: HipDIAnnotation ): void { const eventDetail = evt.detail; const { element } = eventDetail; annotation.highlighted = true; const enabledElement = getEnabledElement(element); if (!enabledElement) { return; } const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender( element, this.getToolName() ); utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); evt.preventDefault(); } /** * 工具选中回调 */ toolSelectedCallback( evt: EventTypes.InteractionEventType, annotation: HipDIAnnotation ): void { // 实现工具选中逻辑 } /** * 处理鼠标拖拽 */ _dragCallback = (evt: EventTypes.InteractionEventType): void => { this.isDrawing = true; const eventDetail = evt.detail; const { element } = eventDetail; const enabledElement = getEnabledElement(element); if (!enabledElement || !this.editData) { return; } const { annotation, viewportIdsToRender } = this.editData; const { data } = annotation as HipDIAnnotation; if (annotation) { const { currentPoints } = eventDetail; const worldPos = currentPoints.world; const points = data.handles.points; const activeHandleIndex = data.handles.activeHandleIndex; if (activeHandleIndex !== null && activeHandleIndex < points.length) { points[activeHandleIndex] = worldPos; this._updateCachedStats( annotation as HipDIAnnotation, enabledElement ); this.editData!.hasMoved = true; } utilities.triggerAnnotationRenderForViewportIds( viewportIdsToRender ); } }; /** * 处理鼠标抬起/点击 */ _endCallback = (evt: EventTypes.InteractionEventType): void => { const eventDetail = evt.detail; const { element } = eventDetail; if (!this.editData) { return; } const { annotation, hasMoved } = this.editData; const { data } = annotation as HipDIAnnotation; if (hasMoved) { this.editData.hasMoved = false; } }; /** * 获取靠近图像点的手柄 */ getHandleNearImagePoint( element: HTMLDivElement, annotation: HipDIAnnotation, canvasCoords: CoreTypes.Point2, proximity: number ): Types.ToolHandle | undefined { const enabledElement = getEnabledElement(element); if (!enabledElement) { return undefined; } const { viewport } = enabledElement; const points = annotation.data.handles.points; const handleProximity = Math.max(proximity, 15); for (let i = 0; i < points.length; i++) { const point = points[i]; const canvasPoint = viewport.worldToCanvas(point); const distance = Math.sqrt( Math.pow(canvasPoint[0] - canvasCoords[0], 2) + Math.pow(canvasPoint[1] - canvasCoords[1], 2) ); if (distance < handleProximity) { annotation.data.handles.activeHandleIndex = i; return { worldPosition: point, } as Types.ToolHandle; } } annotation.data.handles.activeHandleIndex = null; return undefined; } /** * 更新缓存的统计数据 */ _updateCachedStats( annotation: HipDIAnnotation, enabledElement: CoreTypes.IEnabledElement ): void { const { viewport } = enabledElement; const { data } = annotation; const points = data.handles.points; const targetId = this.getTargetId(viewport); if (!targetId) { return; } if (!data.cachedStats) { data.cachedStats = {}; } if (!data.cachedStats[targetId]) { data.cachedStats[targetId] = { distance: 0, DI: 0, }; } const stats = data.cachedStats[targetId]; if (points.length >= 6) { // 拟合髋臼圆(点1,2,3) const acetabulumCircle = fitCircleFrom3Points( points[0], points[1], points[2] ); // 拟合股骨头圆(点4,5,6) const femurCircle = fitCircleFrom3Points( points[3], points[4], points[5] ); if (!acetabulumCircle || !femurCircle) { console.warn('圆拟合失败,三点可能共线'); return; } // 存储圆数据 stats.acetabulumCircle = acetabulumCircle; stats.femurCircle = femurCircle; // 计算圆心距离d const M1 = acetabulumCircle.center; const M2 = femurCircle.center; const distance = Math.sqrt( Math.pow(M2[0] - M1[0], 2) + Math.pow(M2[1] - M1[1], 2) + Math.pow(M2[2] - M1[2], 2) ); stats.distance = distance; // 计算DI指数 const DI = distance / femurCircle.radius; stats.DI = DI; } } /** * 渲染注解 */ renderAnnotation = ( enabledElement: CoreTypes.IEnabledElement, svgDrawingHelper: SVGDrawingHelper ): boolean => { let renderStatus = false; const { viewport } = enabledElement; const { element } = viewport; let annotations = annotation.state.getAnnotations(this.getToolName(), element); if (!annotations?.length) { return renderStatus; } for (let i = 0; i < annotations.length; i++) { const annotation = annotations[i] as HipDIAnnotation; const { annotationUID, data } = annotation; const points = data.handles.points; if (points.length < 1) { continue; } const targetId = this.getTargetId(viewport); const cachedStats = targetId ? data.cachedStats?.[targetId] : undefined; // 确定颜色(选中状态为蓝色,非选中为白色) const color = annotation.highlighted ? 'rgb(0, 0, 255)' : 'rgb(255, 255, 255)'; // 转换所有点为 canvas 坐标 const canvasPoints = points.map((p) => viewport.worldToCanvas(p)); // 绘制髋臼圆 if (cachedStats?.acetabulumCircle && annotationUID) { const center = cachedStats.acetabulumCircle.center; const centerCanvas = viewport.worldToCanvas(center); // 计算半径的像素值 const radiusVector: Point3 = [cachedStats.acetabulumCircle.radius, 0, 0]; const endPoint = vectorAdd(center, radiusVector); const endCanvas = viewport.worldToCanvas(endPoint); const radiusPixels = Math.sqrt( Math.pow(endCanvas[0] - centerCanvas[0], 2) + Math.pow(endCanvas[1] - centerCanvas[1], 2) ); const circleUID = `${annotationUID}-acetabulum-circle`; drawCircleSvg( svgDrawingHelper, annotationUID, circleUID, centerCanvas, radiusPixels, { color: color, width: 2, } ); } // 绘制股骨头圆 if (cachedStats?.femurCircle && annotationUID) { const center = cachedStats.femurCircle.center; const centerCanvas = viewport.worldToCanvas(center); // 计算半径的像素值 const radiusVector: Point3 = [cachedStats.femurCircle.radius, 0, 0]; const endPoint = vectorAdd(center, radiusVector); const endCanvas = viewport.worldToCanvas(endPoint); const radiusPixels = Math.sqrt( Math.pow(endCanvas[0] - centerCanvas[0], 2) + Math.pow(endCanvas[1] - centerCanvas[1], 2) ); const circleUID = `${annotationUID}-femur-circle`; drawCircleSvg( svgDrawingHelper, annotationUID, circleUID, centerCanvas, radiusPixels, { color: color, width: 2, } ); } // 绘制圆心连线 if (cachedStats?.acetabulumCircle && cachedStats?.femurCircle && annotationUID) { const M1 = cachedStats.acetabulumCircle.center; const M2 = cachedStats.femurCircle.center; const M1Canvas = viewport.worldToCanvas(M1); const M2Canvas = viewport.worldToCanvas(M2); const lineUID = `${annotationUID}-center-line`; drawLineSvg( svgDrawingHelper, annotationUID, lineUID, M1Canvas, M2Canvas, { color: color, width: 2, } ); } // 绘制6个控制点 if (annotationUID) { const handleGroupUID = '0'; drawHandles( svgDrawingHelper, annotationUID, handleGroupUID, canvasPoints, { color: 'rgb(255, 255, 255)', handleRadius: 6, } ); } // 绘制DI数值标注(黄色,可拖拽) if (cachedStats?.femurCircle && cachedStats.DI !== undefined && annotationUID) { const femurCenterCanvas = viewport.worldToCanvas(cachedStats.femurCircle.center); const textLines = [`DI: ${cachedStats.DI.toFixed(2)}`]; const textUID = `${annotationUID}-DI-text`; const textBoxPosition: CoreTypes.Point2 = data.textBox || [ femurCenterCanvas[0] + 20, femurCenterCanvas[1] - 20, ]; drawLinkedTextBox( svgDrawingHelper, annotationUID, textUID, textLines, textBoxPosition, [femurCenterCanvas], {}, { color: 'rgb(255, 255, 0)', // 黄色 } ); } renderStatus = true; } return renderStatus; }; }