RectangleGrayscaleMeasurementTool.ts 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249
  1. /**
  2. * 矩形区域灰度测量注解接口
  3. */
  4. interface RectangleGrayscaleAnnotation extends Types.Annotation {
  5. data: {
  6. handles: {
  7. // 矩形四个顶点(世界坐标)
  8. points: CoreTypes.Point3[];
  9. // 当前激活的手柄索引(0-7,对应4个角点和4条边中点)
  10. activeHandleIndex: number | null;
  11. };
  12. label?: string;
  13. // 文本框位置(Canvas坐标)
  14. textBox?: CoreTypes.Point2;
  15. // 缓存的统计结果
  16. cachedStats?: {
  17. [targetId: string]: {
  18. // 平均灰度值
  19. mean: number;
  20. // 最小灰度值
  21. min: number;
  22. // 最大灰度值
  23. max: number;
  24. // 采样像素数量
  25. pixelCount: number;
  26. // 矩形面积 (mm²)
  27. area: number;
  28. // 矩形宽度 (mm)
  29. width: number;
  30. // 矩形高度 (mm)
  31. height: number;
  32. };
  33. };
  34. };
  35. }
  36. /**
  37. * 像素采样结果
  38. */
  39. interface PixelSampleResult {
  40. values: number[];
  41. coordinates: Array<{ x: number; y: number }>;
  42. boundingBox: { minX: number; maxX: number; minY: number; maxY: number };
  43. totalPixels: number;
  44. }
  45. import {
  46. utilities as csUtils,
  47. Types as CoreTypes,
  48. getEnabledElement,
  49. cache,
  50. } from '@cornerstonejs/core';
  51. import * as cornerstone from '@cornerstonejs/core';
  52. import {
  53. AnnotationTool,
  54. utilities,
  55. Types,
  56. annotation,
  57. drawing,
  58. } from '@cornerstonejs/tools';
  59. const {
  60. drawHandles,
  61. drawLinkedTextBox,
  62. drawRect: drawRectSvg,
  63. drawPath: drawPathSvg,
  64. } = drawing;
  65. import {
  66. PublicToolProps,
  67. ToolProps,
  68. EventTypes,
  69. SVGDrawingHelper,
  70. } from '@cornerstonejs/tools/dist/esm/types';
  71. /**
  72. * 矩形区域灰度测量工具
  73. * 功能: 在医学影像上绘制矩形区域,计算并显示区域内像素的灰度值统计信息
  74. */
  75. export default class RectangleGrayscaleMeasurementTool extends AnnotationTool {
  76. static toolName = 'RectangleGrayscaleMeasurementTool';
  77. editData: {
  78. annotation: Types.Annotation;
  79. viewportIdsToRender: string[];
  80. handleIndex?: number;
  81. newAnnotation?: boolean;
  82. hasMoved?: boolean;
  83. textBoxBeingMoved?: boolean;
  84. textBoxOffset?: CoreTypes.Point2;
  85. wholeToolOffset?: CoreTypes.Point2;
  86. originalPoints?: CoreTypes.Point3[]; // 保存拖拽开始时的原始点坐标
  87. } | null = null;
  88. isDrawing: boolean = false;
  89. /**
  90. * 创建默认注解(工具激活时自动创建)
  91. */
  92. static createDefaultAnnotation(
  93. element: HTMLDivElement,
  94. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  95. ): RectangleGrayscaleAnnotation {
  96. const enabledElement = getEnabledElement(element);
  97. if (!enabledElement) {
  98. throw new Error('Element is not enabled');
  99. }
  100. // 获取viewport的尺寸和初始位置
  101. const canvas = viewport.canvas;
  102. const { width, height } = canvas;
  103. const centerX = width / 2;
  104. const centerY = height / 2;
  105. const rectWidth = 100;
  106. const rectHeight = 80;
  107. // 设置矩形四个顶点(Canvas坐标)
  108. const topLeftCanvas: CoreTypes.Point2 = [centerX - rectWidth / 2, centerY - rectHeight / 2];
  109. const topRightCanvas: CoreTypes.Point2 = [centerX + rectWidth / 2, centerY - rectHeight / 2];
  110. const bottomRightCanvas: CoreTypes.Point2 = [centerX + rectWidth / 2, centerY + rectHeight / 2];
  111. const bottomLeftCanvas: CoreTypes.Point2 = [centerX - rectWidth / 2, centerY + rectHeight / 2];
  112. // 转换为世界坐标
  113. const topLeftWorld = viewport.canvasToWorld(topLeftCanvas);
  114. const topRightWorld = viewport.canvasToWorld(topRightCanvas);
  115. const bottomRightWorld = viewport.canvasToWorld(bottomRightCanvas);
  116. const bottomLeftWorld = viewport.canvasToWorld(bottomLeftCanvas);
  117. const camera = viewport.getCamera();
  118. const { viewPlaneNormal, viewUp } = camera;
  119. if (viewPlaneNormal === undefined || viewUp === undefined) {
  120. throw new Error('Camera parameters are undefined');
  121. }
  122. // 创建注解对象
  123. const annotationData = {
  124. invalidated: true,
  125. highlighted: false,
  126. metadata: {
  127. viewPlaneNormal: [...viewPlaneNormal] as CoreTypes.Point3,
  128. viewUp: [...viewUp] as CoreTypes.Point3,
  129. FrameOfReferenceUID: viewport.getFrameOfReferenceUID(),
  130. referencedImageId: viewport.getCurrentImageId?.() || '',
  131. toolName: RectangleGrayscaleMeasurementTool.toolName,
  132. },
  133. data: {
  134. label: '',
  135. handles: {
  136. points: [topLeftWorld, topRightWorld, bottomRightWorld, bottomLeftWorld],
  137. activeHandleIndex: null,
  138. },
  139. cachedStats: {},
  140. },
  141. } as RectangleGrayscaleAnnotation;
  142. return annotationData;
  143. }
  144. constructor(
  145. toolProps: PublicToolProps = {},
  146. defaultToolProps: ToolProps = {
  147. supportedInteractionTypes: ['Mouse', 'Touch'],
  148. configuration: {
  149. shadow: true,
  150. preventHandleOutsideImage: false,
  151. },
  152. }
  153. ) {
  154. super(toolProps, defaultToolProps);
  155. }
  156. /**
  157. * 禁用手动绘制(仅支持预设注解)
  158. */
  159. addNewAnnotation(
  160. evt: EventTypes.InteractionEventType
  161. ): RectangleGrayscaleAnnotation {
  162. evt.preventDefault();
  163. return {} as RectangleGrayscaleAnnotation;
  164. }
  165. /**
  166. * 检查点是否靠近工具
  167. */
  168. isPointNearTool(
  169. element: HTMLDivElement,
  170. annotation: RectangleGrayscaleAnnotation,
  171. canvasCoords: CoreTypes.Point2,
  172. proximity: number
  173. ): boolean {
  174. const enabledElement = getEnabledElement(element);
  175. if (!enabledElement) {
  176. return false;
  177. }
  178. const { viewport } = enabledElement;
  179. const points = annotation.data.handles.points;
  180. // 检查是否靠近四个角点
  181. for (let i = 0; i < points.length; i++) {
  182. const point = points[i];
  183. const canvasPoint = viewport.worldToCanvas(point);
  184. const distance = Math.sqrt(
  185. Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
  186. Math.pow(canvasPoint[1] - canvasCoords[1], 2)
  187. );
  188. if (distance < proximity) {
  189. return true;
  190. }
  191. }
  192. // 检查是否在矩形内部
  193. if (points.length >= 4) {
  194. return this._isPointInRectangle(canvasCoords, points, viewport);
  195. }
  196. return false;
  197. }
  198. /**
  199. * 检查点是否在矩形内
  200. */
  201. private _isPointInRectangle(
  202. point: CoreTypes.Point2,
  203. rectanglePoints: CoreTypes.Point3[],
  204. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  205. ): boolean {
  206. // 将世界坐标转换为Canvas坐标
  207. const canvasPoints = rectanglePoints.map(p => viewport.worldToCanvas(p));
  208. // 使用射线投射算法判断点是否在多边形内
  209. let inside = false;
  210. for (let i = 0, j = canvasPoints.length - 1; i < canvasPoints.length; j = i++) {
  211. if (((canvasPoints[i][1] > point[1]) !== (canvasPoints[j][1] > point[1])) &&
  212. (point[0] < (canvasPoints[j][0] - canvasPoints[i][0]) * (point[1] - canvasPoints[i][1]) / (canvasPoints[j][1] - canvasPoints[i][1]) + canvasPoints[i][0])) {
  213. inside = !inside;
  214. }
  215. }
  216. return inside;
  217. }
  218. /**
  219. * 检查点是否在线段上(矩形的边)
  220. */
  221. private _isPointNearLine(
  222. point: CoreTypes.Point2,
  223. rectanglePoints: CoreTypes.Point3[],
  224. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport,
  225. proximity: number = 6
  226. ): boolean {
  227. const canvasPoints = rectanglePoints.map(p => viewport.worldToCanvas(p));
  228. // 检查四条边
  229. for (let i = 0; i < canvasPoints.length; i++) {
  230. const start = canvasPoints[i];
  231. const end = canvasPoints[(i + 1) % canvasPoints.length];
  232. if (this._isPointNearLineSegment(point, start, end, proximity)) {
  233. return true;
  234. }
  235. }
  236. return false;
  237. }
  238. /**
  239. * 检查点是否靠近线段
  240. */
  241. private _isPointNearLineSegment(
  242. point: CoreTypes.Point2,
  243. lineStart: CoreTypes.Point2,
  244. lineEnd: CoreTypes.Point2,
  245. proximity: number
  246. ): boolean {
  247. const x0 = point[0];
  248. const y0 = point[1];
  249. const x1 = lineStart[0];
  250. const y1 = lineStart[1];
  251. const x2 = lineEnd[0];
  252. const y2 = lineEnd[1];
  253. const dx = x2 - x1;
  254. const dy = y2 - y1;
  255. const length = Math.sqrt(dx * dx + dy * dy);
  256. if (length === 0) {
  257. // 线段退化为点
  258. const distance = Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2));
  259. return distance <= proximity;
  260. }
  261. // 计算点到线段的最短距离
  262. const t = Math.max(0, Math.min(1, ((x0 - x1) * dx + (y0 - y1) * dy) / (length * length)));
  263. const projX = x1 + t * dx;
  264. const projY = y1 + t * dy;
  265. const distance = Math.sqrt(Math.pow(x0 - projX, 2) + Math.pow(y0 - projY, 2));
  266. return distance <= proximity;
  267. }
  268. /**
  269. * 检查点是否在文本框上
  270. */
  271. private _isPointInTextBox(
  272. element: HTMLDivElement,
  273. annotation: RectangleGrayscaleAnnotation,
  274. canvasCoords: CoreTypes.Point2
  275. ): boolean {
  276. const enabledElement = getEnabledElement(element);
  277. if (!enabledElement) {
  278. return false;
  279. }
  280. const { viewport } = enabledElement;
  281. const textBox = annotation.data.textBox;
  282. if (!textBox) {
  283. // 如果没有自定义位置,使用默认位置(右下角)
  284. const points = annotation.data.handles.points;
  285. if (points.length < 4) return false;
  286. const defaultPos = viewport.worldToCanvas(points[2]);
  287. return this._checkTextBoxBounds(canvasCoords, defaultPos);
  288. }
  289. return this._checkTextBoxBounds(canvasCoords, textBox);
  290. }
  291. /**
  292. * 检查点是否在文本框边界内(估算文本框大小)
  293. */
  294. private _checkTextBoxBounds(
  295. point: CoreTypes.Point2,
  296. textBoxPos: CoreTypes.Point2
  297. ): boolean {
  298. // 估算文本框大小(基于典型的文本行数和字体大小)
  299. const padding = 8;
  300. const lineHeight = 18;
  301. const textWidth = 150; // 估算宽度
  302. const textHeight = lineHeight * 4 + padding * 2; // 4行文本
  303. const x = textBoxPos[0];
  304. const y = textBoxPos[1];
  305. return (
  306. point[0] >= x - padding &&
  307. point[0] <= x + textWidth &&
  308. point[1] >= y - padding &&
  309. point[1] <= y + textHeight
  310. );
  311. }
  312. /**
  313. * 取消操作
  314. */
  315. cancel(element: HTMLDivElement): string {
  316. if (this.isDrawing) {
  317. this.isDrawing = false;
  318. this._deactivateDraw(element);
  319. this._deactivateModify(element);
  320. const enabledElement = getEnabledElement(element);
  321. if (enabledElement) {
  322. const viewportIdsToRender =
  323. utilities.viewportFilters.getViewportIdsWithToolToRender(
  324. element,
  325. this.getToolName()
  326. );
  327. utilities.triggerAnnotationRenderForViewportIds(
  328. viewportIdsToRender
  329. );
  330. }
  331. this.editData = null;
  332. return this.getToolName();
  333. }
  334. return '';
  335. }
  336. /**
  337. * 处理工具选中
  338. */
  339. toolSelectedCallback(
  340. evt: EventTypes.InteractionEventType,
  341. annotation: RectangleGrayscaleAnnotation
  342. ): void {
  343. // 实现工具选中逻辑
  344. }
  345. /**
  346. * 处理注解选中
  347. */
  348. handleSelectedCallback(
  349. evt: EventTypes.InteractionEventType,
  350. annotation: RectangleGrayscaleAnnotation
  351. ): void {
  352. const eventDetail = evt.detail;
  353. const { element } = eventDetail;
  354. annotation.highlighted = true;
  355. const viewportIdsToRender =
  356. utilities.viewportFilters.getViewportIdsWithToolToRender(
  357. element,
  358. this.getToolName()
  359. );
  360. utilities.triggerAnnotationRenderForViewportIds(
  361. viewportIdsToRender
  362. );
  363. evt.preventDefault();
  364. }
  365. /**
  366. * 渲染注解
  367. */
  368. renderAnnotation = (
  369. enabledElement: CoreTypes.IEnabledElement,
  370. svgDrawingHelper: SVGDrawingHelper
  371. ): boolean => {
  372. const { viewport } = enabledElement;
  373. const { element } = viewport;
  374. let annotations = annotation.state.getAnnotations(
  375. RectangleGrayscaleMeasurementTool.toolName,
  376. element
  377. ) as RectangleGrayscaleAnnotation[];
  378. if (!annotations || annotations.length === 0) {
  379. return false;
  380. }
  381. const targetId = `imageId:${(viewport as any).getCurrentImageId?.() || ''}`;
  382. const styleSpecifier: any = {
  383. toolGroupId: this.toolGroupId,
  384. toolName: this.getToolName(),
  385. viewportId: viewport.id,
  386. };
  387. for (const annotationItem of annotations) {
  388. const { annotationUID, data } = annotationItem;
  389. const { points } = data.handles;
  390. const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p));
  391. if (!annotationUID) {
  392. continue;
  393. }
  394. styleSpecifier.annotationUID = annotationUID;
  395. const lineWidth = this.getStyle('lineWidth', styleSpecifier, annotationItem);
  396. const color = this.getStyle('color', styleSpecifier, annotationItem);
  397. // 绘制矩形边框 - 使用路径绘制以保持正确的矩形形状
  398. if (canvasCoordinates.length >= 4) {
  399. const rectUID = `${annotationUID}-rect`;
  400. // 创建封闭的矩形路径:从第一个点开始,顺时针遍历所有点,最后回到起点
  401. const rectanglePath = [...canvasCoordinates, canvasCoordinates[0]]; // 复制第一个点作为结束点
  402. drawPathSvg(
  403. svgDrawingHelper,
  404. annotationUID as string,
  405. rectUID,
  406. rectanglePath,
  407. {
  408. color: color as string,
  409. width: lineWidth as number,
  410. }
  411. );
  412. }
  413. // 绘制手柄 - 根据选中和高亮状态调整大小
  414. const handleGroupUID = `${annotationUID}-handles`;
  415. // 根据状态设置手柄半径
  416. let handleRadius = 6; // 默认半径
  417. if (annotationItem.isSelected) {
  418. handleRadius = 12; // 选中状态下半径变大
  419. } else if (annotationItem.highlighted) {
  420. handleRadius = 9; // 悬停高亮时半径适中
  421. }
  422. drawHandles(
  423. svgDrawingHelper,
  424. annotationUID,
  425. handleGroupUID,
  426. canvasCoordinates,
  427. {
  428. color,
  429. handleRadius,
  430. }
  431. );
  432. // 绘制统计文本
  433. const stats = data.cachedStats?.[targetId];
  434. if (stats) {
  435. const textLines = [
  436. `平均: ${stats.mean}`,
  437. `最小: ${stats.min}`,
  438. `最大: ${stats.max}`,
  439. `面积: ${stats.area.toFixed(2)}mm²`,
  440. ];
  441. const textBoxPosition = data.textBox || canvasCoordinates[2]; // 右下角
  442. const textBoxUID = `${annotationUID}-text`;
  443. drawLinkedTextBox(
  444. svgDrawingHelper,
  445. annotationUID,
  446. textBoxUID,
  447. textLines,
  448. textBoxPosition,
  449. [canvasCoordinates[2]],
  450. {},
  451. {
  452. color,
  453. }
  454. );
  455. }
  456. }
  457. return true;
  458. };
  459. /**
  460. * 更新缓存的统计数据
  461. */
  462. private _updateCachedStats(
  463. annotation: RectangleGrayscaleAnnotation,
  464. enabledElement: CoreTypes.IEnabledElement
  465. ): void {
  466. const { viewport } = enabledElement;
  467. const { points } = annotation.data.handles;
  468. if (points.length < 4) {
  469. return;
  470. }
  471. // 使用Cornerstone缓存系统获取图像
  472. const imageId = viewport.getCurrentImageId?.();
  473. if (!imageId) {
  474. console.warn('[RectangleGrayscaleTool] No imageId available');
  475. return;
  476. }
  477. const image = cornerstone.cache.getImage(imageId);
  478. if (!image) {
  479. console.warn('[RectangleGrayscaleTool] Image not found in cache');
  480. return;
  481. }
  482. // 采样矩形内的像素
  483. const sampleResult = this._samplePixelsInRectangle(points, image, viewport);
  484. // 计算统计值
  485. const stats = this._calculateGrayscaleStats(sampleResult.values, points);
  486. // 更新缓存
  487. const targetId = `imageId:${imageId}`;
  488. if (!annotation.data.cachedStats) {
  489. annotation.data.cachedStats = {};
  490. }
  491. annotation.data.cachedStats[targetId] = stats;
  492. }
  493. /**
  494. * 在矩形区域内采样像素
  495. */
  496. private _samplePixelsInRectangle(
  497. rectanglePoints: CoreTypes.Point3[],
  498. image: any,
  499. viewport: CoreTypes.IStackViewport | CoreTypes.IVolumeViewport
  500. ): PixelSampleResult {
  501. const pixelData = image.getPixelData();
  502. const { width, height } = image;
  503. // 获取矩形的边界框(像素坐标)
  504. const canvasPoints = rectanglePoints.map(p => viewport.worldToCanvas(p));
  505. let minX = Math.min(...canvasPoints.map(p => p[0]));
  506. let maxX = Math.max(...canvasPoints.map(p => p[0]));
  507. let minY = Math.min(...canvasPoints.map(p => p[1]));
  508. let maxY = Math.max(...canvasPoints.map(p => p[1]));
  509. // 限制在图像边界内
  510. minX = Math.max(0, Math.floor(minX));
  511. maxX = Math.min(width - 1, Math.floor(maxX));
  512. minY = Math.max(0, Math.floor(minY));
  513. maxY = Math.min(height - 1, Math.floor(maxY));
  514. const values: number[] = [];
  515. const coordinates: Array<{ x: number; y: number }> = [];
  516. // 对每个像素进行采样
  517. for (let y = minY; y <= maxY; y++) {
  518. for (let x = minX; x <= maxX; x++) {
  519. // 检查点是否在矩形内(使用多边形内点测试)
  520. if (this._isPointInPolygon([x, y], canvasPoints)) {
  521. const index = y * width + x;
  522. const value = pixelData[index];
  523. values.push(value);
  524. coordinates.push({ x, y });
  525. }
  526. }
  527. }
  528. return {
  529. values,
  530. coordinates,
  531. boundingBox: { minX, maxX, minY, maxY },
  532. totalPixels: values.length,
  533. };
  534. }
  535. /**
  536. * 使用射线投射算法检查点是否在多边形内
  537. */
  538. private _isPointInPolygon(
  539. point: CoreTypes.Point2,
  540. polygon: CoreTypes.Point2[]
  541. ): boolean {
  542. let inside = false;
  543. for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
  544. if (((polygon[i][1] > point[1]) !== (polygon[j][1] > point[1])) &&
  545. (point[0] < (polygon[j][0] - polygon[i][0]) * (point[1] - polygon[i][1]) / (polygon[j][1] - polygon[i][1]) + polygon[i][0])) {
  546. inside = !inside;
  547. }
  548. }
  549. return inside;
  550. }
  551. /**
  552. * 计算灰度统计值
  553. */
  554. private _calculateGrayscaleStats(
  555. values: number[],
  556. rectanglePoints: CoreTypes.Point3[]
  557. ): {
  558. mean: number;
  559. min: number;
  560. max: number;
  561. pixelCount: number;
  562. area: number;
  563. width: number;
  564. height: number;
  565. } {
  566. if (values.length === 0) {
  567. return {
  568. mean: 0,
  569. min: 0,
  570. max: 0,
  571. pixelCount: 0,
  572. area: 0,
  573. width: 0,
  574. height: 0,
  575. };
  576. }
  577. // 计算平均值
  578. const sum = values.reduce((acc, val) => acc + val, 0);
  579. const mean = Math.round(sum / values.length);
  580. // 计算最小值和最大值
  581. const min = Math.min(...values);
  582. const max = Math.max(...values);
  583. // 计算几何尺寸
  584. const p1 = rectanglePoints[0];
  585. const p2 = rectanglePoints[2]; // 对角点
  586. const width = Math.abs(p2[0] - p1[0]);
  587. const height = Math.abs(p2[1] - p1[1]);
  588. const area = width * height;
  589. return {
  590. mean,
  591. min,
  592. max,
  593. pixelCount: values.length,
  594. area: parseFloat(area.toFixed(2)),
  595. width: parseFloat(width.toFixed(2)),
  596. height: parseFloat(height.toFixed(2)),
  597. };
  598. }
  599. // 空的辅助方法声明,避免编译错误
  600. _activateDraw(element: HTMLDivElement): void {}
  601. _deactivateDraw(element: HTMLDivElement): void {}
  602. _activateModify(element: HTMLDivElement): void {
  603. element.addEventListener(
  604. 'CORNERSTONE_TOOLS_MOUSE_DOWN',
  605. this._mouseDownModifyCallback as EventListener
  606. );
  607. element.addEventListener(
  608. 'CORNERSTONE_TOOLS_MOUSE_DRAG',
  609. this._mouseDragModifyCallback as EventListener
  610. );
  611. element.addEventListener(
  612. 'CORNERSTONE_TOOLS_MOUSE_UP',
  613. this._mouseUpModifyCallback as EventListener
  614. );
  615. element.addEventListener(
  616. 'CORNERSTONE_TOOLS_MOUSE_MOVE',
  617. this._mouseMoveModifyCallback as EventListener
  618. );
  619. element.addEventListener(
  620. 'keydown',
  621. this._keyDownCallback as EventListener
  622. );
  623. }
  624. _deactivateModify(element: HTMLDivElement): void {
  625. element.removeEventListener(
  626. 'CORNERSTONE_TOOLS_MOUSE_DOWN',
  627. this._mouseDownModifyCallback as EventListener
  628. );
  629. element.removeEventListener(
  630. 'CORNERSTONE_TOOLS_MOUSE_DRAG',
  631. this._mouseDragModifyCallback as EventListener
  632. );
  633. element.removeEventListener(
  634. 'CORNERSTONE_TOOLS_MOUSE_UP',
  635. this._mouseUpModifyCallback as EventListener
  636. );
  637. element.removeEventListener(
  638. 'CORNERSTONE_TOOLS_MOUSE_MOVE',
  639. this._mouseMoveModifyCallback as EventListener
  640. );
  641. element.removeEventListener(
  642. 'keydown',
  643. this._keyDownCallback as EventListener
  644. );
  645. }
  646. _mouseDownModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  647. const eventDetail = evt.detail;
  648. const { element, currentPoints } = eventDetail;
  649. const canvasCoords = currentPoints.canvas;
  650. const enabledElement = getEnabledElement(element);
  651. if (!enabledElement) {
  652. return;
  653. }
  654. const { viewport } = enabledElement;
  655. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  656. if (!annotations || annotations.length === 0) {
  657. return;
  658. }
  659. // 查找最近的手柄或矩形区域
  660. for (const ann of annotations) {
  661. const customAnn = ann as RectangleGrayscaleAnnotation;
  662. // 优先检查是否点击在文本框上
  663. if (this._isPointInTextBox(element, customAnn, canvasCoords)) {
  664. const viewportIdsToRender =
  665. utilities.viewportFilters.getViewportIdsWithToolToRender(
  666. element,
  667. this.getToolName()
  668. );
  669. const textBoxPos = customAnn.data.textBox || viewport.worldToCanvas(customAnn.data.handles.points[2]);
  670. const textBoxOffset: CoreTypes.Point2 = [
  671. canvasCoords[0] - textBoxPos[0],
  672. canvasCoords[1] - textBoxPos[1],
  673. ];
  674. this.editData = {
  675. annotation: customAnn,
  676. viewportIdsToRender,
  677. handleIndex: -2, // -2表示拖拽文本框
  678. hasMoved: false,
  679. textBoxBeingMoved: true,
  680. textBoxOffset: textBoxOffset,
  681. };
  682. customAnn.isSelected = true;
  683. customAnn.highlighted = true;
  684. utilities.triggerAnnotationRenderForViewportIds(
  685. viewportIdsToRender
  686. );
  687. evt.preventDefault();
  688. evt.stopPropagation();
  689. return;
  690. }
  691. const handle = this.getHandleNearImagePoint(
  692. element,
  693. customAnn,
  694. canvasCoords,
  695. 6
  696. );
  697. if (handle) {
  698. // 点击在手柄上
  699. const viewportIdsToRender =
  700. utilities.viewportFilters.getViewportIdsWithToolToRender(
  701. element,
  702. this.getToolName()
  703. );
  704. // 保存拖拽开始时的原始点坐标,用于计算新的矩形位置
  705. this.editData = {
  706. annotation: customAnn,
  707. viewportIdsToRender,
  708. handleIndex: customAnn.data.handles.activeHandleIndex || 0,
  709. hasMoved: false,
  710. originalPoints: [...customAnn.data.handles.points], // 复制原始点
  711. };
  712. customAnn.isSelected = true;
  713. customAnn.highlighted = true;
  714. utilities.triggerAnnotationRenderForViewportIds(
  715. viewportIdsToRender
  716. );
  717. evt.preventDefault();
  718. evt.stopPropagation();
  719. return;
  720. }
  721. // 检查是否点击在线段上
  722. if (this._isPointNearLine(canvasCoords, customAnn.data.handles.points, viewport, 6)) {
  723. const viewportIdsToRender =
  724. utilities.viewportFilters.getViewportIdsWithToolToRender(
  725. element,
  726. this.getToolName()
  727. );
  728. // 计算鼠标点击相对于工具中心的偏移(用于拖拽)
  729. const toolCenter = this._calculateRectangleCenter(customAnn.data.handles.points);
  730. const toolCenterCanvas = viewport.worldToCanvas(toolCenter);
  731. const wholeToolOffset: CoreTypes.Point2 = [
  732. canvasCoords[0] - toolCenterCanvas[0],
  733. canvasCoords[1] - toolCenterCanvas[1],
  734. ];
  735. // 开始拖拽整个矩形
  736. this.editData = {
  737. annotation: customAnn,
  738. viewportIdsToRender,
  739. handleIndex: -1, // -1表示拖拽整个矩形
  740. hasMoved: false,
  741. wholeToolOffset: wholeToolOffset,
  742. };
  743. customAnn.isSelected = true;
  744. customAnn.highlighted = true;
  745. utilities.triggerAnnotationRenderForViewportIds(
  746. viewportIdsToRender
  747. );
  748. evt.preventDefault();
  749. evt.stopPropagation();
  750. return;
  751. }
  752. // 检查是否点击在矩形内部(但不在线段上)
  753. if (this.isPointNearTool(element, customAnn, canvasCoords, 10)) {
  754. // 仅选中,不拖拽
  755. const viewportIdsToRender =
  756. utilities.viewportFilters.getViewportIdsWithToolToRender(
  757. element,
  758. this.getToolName()
  759. );
  760. customAnn.isSelected = true;
  761. utilities.triggerAnnotationRenderForViewportIds(
  762. viewportIdsToRender
  763. );
  764. evt.preventDefault();
  765. evt.stopPropagation();
  766. return;
  767. }
  768. }
  769. // 如果没有点击在工具上,取消所有选中状态
  770. for (const ann of annotations) {
  771. const customAnn = ann as RectangleGrayscaleAnnotation;
  772. customAnn.isSelected = false;
  773. }
  774. // 触发渲染以更新选中状态
  775. const viewportIdsToRender =
  776. utilities.viewportFilters.getViewportIdsWithToolToRender(
  777. element,
  778. this.getToolName()
  779. );
  780. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  781. };
  782. _mouseDragModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  783. if (!this.editData) {
  784. return;
  785. }
  786. const eventDetail = evt.detail;
  787. const { currentPoints } = eventDetail;
  788. const canvasCoords = currentPoints.canvas;
  789. const enabledElement = getEnabledElement(eventDetail.element);
  790. if (!enabledElement) {
  791. return;
  792. }
  793. const { annotation: ann, viewportIdsToRender } = this.editData;
  794. const customAnn = ann as RectangleGrayscaleAnnotation;
  795. const { data } = customAnn;
  796. // 如果正在拖拽文本框
  797. if (this.editData.handleIndex === -2 && this.editData.textBoxBeingMoved) {
  798. const newTextBoxPos: CoreTypes.Point2 = [
  799. canvasCoords[0] - this.editData.textBoxOffset![0],
  800. canvasCoords[1] - this.editData.textBoxOffset![1],
  801. ];
  802. // 更新文本框位置
  803. data.textBox = newTextBoxPos;
  804. this.editData.hasMoved = true;
  805. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  806. evt.preventDefault();
  807. evt.stopPropagation();
  808. return;
  809. }
  810. // 如果正在拖拽整个矩形
  811. if (this.editData.handleIndex === -1) {
  812. const { viewport } = enabledElement;
  813. // 计算当前矩形质心(四个角点的平均位置)
  814. const currentCenterWorld = this._calculateRectangleCenter(data.handles.points);
  815. // 计算新的质心位置 = 当前鼠标位置 - 初始偏移
  816. const newCenterCanvas: CoreTypes.Point2 = [
  817. canvasCoords[0] - this.editData.wholeToolOffset![0],
  818. canvasCoords[1] - this.editData.wholeToolOffset![1],
  819. ];
  820. // 将新的质心位置转换为world坐标
  821. const newCenterWorld = viewport.canvasToWorld(newCenterCanvas);
  822. // 计算从当前质心到新质心的偏移
  823. const worldOffset: CoreTypes.Point3 = [
  824. newCenterWorld[0] - currentCenterWorld[0],
  825. newCenterWorld[1] - currentCenterWorld[1],
  826. 0, // Z轴保持不变
  827. ];
  828. // 应用相同的偏移到所有四个顶点
  829. for (let i = 0; i < data.handles.points.length; i++) {
  830. data.handles.points[i] = [
  831. data.handles.points[i][0] + worldOffset[0],
  832. data.handles.points[i][1] + worldOffset[1],
  833. data.handles.points[i][2],
  834. ] as CoreTypes.Point3;
  835. }
  836. // 仅在拖拽结束时更新统计,避免拖拽过程中的频繁计算
  837. this.editData.hasMoved = true;
  838. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  839. evt.preventDefault();
  840. evt.stopPropagation();
  841. return;
  842. }
  843. // 处理单个手柄拖拽 - 实现矩形的自由伸缩
  844. const worldPos = currentPoints.world;
  845. const activeHandleIndex = data.handles.activeHandleIndex;
  846. if (activeHandleIndex !== null && activeHandleIndex >= 0 && activeHandleIndex < data.handles.points.length && this.editData.originalPoints) {
  847. // 计算拖拽的增量
  848. const originalPos = this.editData.originalPoints[activeHandleIndex];
  849. const deltaX = worldPos[0] - originalPos[0];
  850. const deltaY = worldPos[1] - originalPos[1];
  851. // 根据被拖拽的角点索引,重新计算所有四个顶点的位置
  852. // 保持矩形几何关系:直角和平行边
  853. const originalPoints = this.editData.originalPoints;
  854. // 确定矩形的变换规则:
  855. // 如果拖拽顶点0(左上角),则改变矩形的左边和上边
  856. // 如果拖拽顶点1(右上角),相反,改变右边和上边
  857. // 如果拖拽顶点2(右下角),改变右边和下边
  858. // 如果拖拽顶点3(左下角),改变左边和下边
  859. switch (activeHandleIndex) {
  860. case 0: // 拖拽左上角
  861. data.handles.points[0] = worldPos;
  862. // 固定右下角(2)
  863. data.handles.points[2] = [...originalPoints[2]];
  864. // 更新右上角(1): x同右下角, y同左上角
  865. data.handles.points[1] = [originalPoints[2][0], worldPos[1], worldPos[2]];
  866. // 更新左下角(3): x同左上角, y同右下角
  867. data.handles.points[3] = [worldPos[0], originalPoints[2][1], worldPos[2]];
  868. break;
  869. case 1: // 拖拽右上角
  870. data.handles.points[1] = worldPos;
  871. // 固定左下角(3)
  872. data.handles.points[3] = [...originalPoints[3]];
  873. // 更新左上角(0): x同左下角, y同右上角
  874. data.handles.points[0] = [originalPoints[3][0], worldPos[1], worldPos[2]];
  875. // 更新右下角(2): x同右上角, y同左下角
  876. data.handles.points[2] = [worldPos[0], originalPoints[3][1], worldPos[2]];
  877. break;
  878. case 2: // 拖拽右下角
  879. data.handles.points[2] = worldPos;
  880. // 固定左上角(0)
  881. data.handles.points[0] = [...originalPoints[0]];
  882. // 更新右上角(1): x同右下角, y同左上角
  883. data.handles.points[1] = [worldPos[0], originalPoints[0][1], worldPos[2]];
  884. // 更新左下角(3): x同左上角, y同右下角
  885. data.handles.points[3] = [originalPoints[0][0], worldPos[1], worldPos[2]];
  886. break;
  887. case 3: // 拖拽左下角
  888. data.handles.points[3] = worldPos;
  889. // 固定右上角(1)
  890. data.handles.points[1] = [...originalPoints[1]];
  891. // 更新左上角(0): x同左下角, y同右上角
  892. data.handles.points[0] = [worldPos[0], originalPoints[1][1], worldPos[2]];
  893. // 更新右下角(2): x同右上角, y同左下角
  894. data.handles.points[2] = [originalPoints[1][0], worldPos[1], worldPos[2]];
  895. break;
  896. }
  897. this.editData.hasMoved = true;
  898. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  899. evt.preventDefault();
  900. evt.stopPropagation();
  901. }
  902. };
  903. _mouseUpModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  904. if (!this.editData) {
  905. return;
  906. }
  907. const { annotation: ann, hasMoved } = this.editData;
  908. const customAnn = ann as RectangleGrayscaleAnnotation;
  909. customAnn.data.handles.activeHandleIndex = null;
  910. customAnn.highlighted = false;
  911. const eventDetail = evt.detail;
  912. const { element } = eventDetail;
  913. const enabledElement = getEnabledElement(element);
  914. if (enabledElement) {
  915. const { viewport } = enabledElement;
  916. // 如果工具被移动了,更新缓存的统计数据
  917. if (hasMoved) {
  918. this._updateCachedStats(customAnn, enabledElement);
  919. }
  920. }
  921. const viewportIdsToRender =
  922. utilities.viewportFilters.getViewportIdsWithToolToRender(
  923. element,
  924. this.getToolName()
  925. );
  926. utilities.triggerAnnotationRenderForViewportIds(
  927. viewportIdsToRender
  928. );
  929. this.editData = null;
  930. evt.preventDefault();
  931. evt.stopPropagation();
  932. };
  933. _mouseMoveModifyCallback = (evt: EventTypes.InteractionEventType): void => {
  934. const eventDetail = evt.detail;
  935. const { element, currentPoints } = eventDetail;
  936. if (!currentPoints || !currentPoints.canvas) {
  937. return;
  938. }
  939. const canvasCoords = currentPoints.canvas;
  940. const enabledElement = getEnabledElement(element);
  941. if (!enabledElement) {
  942. return;
  943. }
  944. const { viewport } = enabledElement;
  945. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  946. if (!annotations || annotations.length === 0) {
  947. element.style.cursor = 'default';
  948. return;
  949. }
  950. let isHovering = false;
  951. // 检查是否悬停在工具的不同部分上
  952. for (const ann of annotations) {
  953. const customAnn = ann as RectangleGrayscaleAnnotation;
  954. // 优先检查文本框(手型光标)
  955. if (this._isPointInTextBox(element, customAnn, canvasCoords)) {
  956. element.style.cursor = 'pointer';
  957. customAnn.highlighted = true;
  958. isHovering = true;
  959. break;
  960. }
  961. // 检查是否靠近手柄(十字准星光标)
  962. const handle = this.getHandleNearImagePoint(element, customAnn, canvasCoords, 6);
  963. if (handle) {
  964. element.style.cursor = 'crosshair';
  965. customAnn.highlighted = true;
  966. isHovering = true;
  967. break;
  968. }
  969. // 检查是否在线段上(十字准星光标)
  970. if (this._isPointNearLine(canvasCoords, customAnn.data.handles.points, viewport, 6)) {
  971. element.style.cursor = 'crosshair';
  972. customAnn.highlighted = true;
  973. isHovering = true;
  974. break;
  975. }
  976. // 检查是否在矩形内部(默认光标)
  977. if (this.isPointNearTool(element, customAnn, canvasCoords, 10)) {
  978. element.style.cursor = 'default';
  979. customAnn.highlighted = true;
  980. isHovering = true;
  981. break;
  982. }
  983. }
  984. // 如果没有悬停,重置高亮和光标
  985. if (!isHovering) {
  986. for (const ann of annotations) {
  987. const customAnn = ann as RectangleGrayscaleAnnotation;
  988. customAnn.highlighted = false;
  989. }
  990. element.style.cursor = 'default';
  991. }
  992. // 触发渲染以更新高亮状态
  993. const viewportIdsToRender = utilities.viewportFilters.getViewportIdsWithToolToRender(
  994. element,
  995. this.getToolName()
  996. );
  997. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  998. };
  999. _keyDownCallback = (evt: KeyboardEvent): void => {
  1000. if (evt.key === 'Delete') {
  1001. const element = document.activeElement as HTMLDivElement;
  1002. if (!element) return;
  1003. const annotations = annotation.state.getAnnotations(this.getToolName(), element);
  1004. // 查找选中的注解
  1005. const selectedAnnotation = annotations.find(ann => ann.isSelected);
  1006. if (selectedAnnotation) {
  1007. // 删除选中的注解
  1008. annotation.state.removeAnnotation(selectedAnnotation.annotationUID || '');
  1009. // 触发视图更新
  1010. const viewportIdsToRender =
  1011. utilities.viewportFilters.getViewportIdsWithToolToRender(
  1012. element,
  1013. this.getToolName()
  1014. );
  1015. utilities.triggerAnnotationRenderForViewportIds(viewportIdsToRender);
  1016. evt.preventDefault();
  1017. evt.stopPropagation();
  1018. }
  1019. }
  1020. };
  1021. getHandleNearImagePoint(
  1022. element: HTMLDivElement,
  1023. annotation: RectangleGrayscaleAnnotation,
  1024. canvasCoords: CoreTypes.Point2,
  1025. proximity: number
  1026. ): Types.ToolHandle | undefined {
  1027. const enabledElement = getEnabledElement(element);
  1028. if (!enabledElement) {
  1029. return undefined;
  1030. }
  1031. const { viewport } = enabledElement;
  1032. const points = annotation.data.handles.points;
  1033. const handleProximity = Math.max(proximity, 15);
  1034. for (let i = 0; i < points.length; i++) {
  1035. const point = points[i];
  1036. const canvasPoint = viewport.worldToCanvas(point);
  1037. const distance = Math.sqrt(
  1038. Math.pow(canvasPoint[0] - canvasCoords[0], 2) +
  1039. Math.pow(canvasPoint[1] - canvasCoords[1], 2)
  1040. );
  1041. if (distance < handleProximity) {
  1042. annotation.data.handles.activeHandleIndex = i;
  1043. return {
  1044. worldPosition: point,
  1045. } as Types.ToolHandle;
  1046. }
  1047. }
  1048. annotation.data.handles.activeHandleIndex = null;
  1049. return undefined;
  1050. }
  1051. /**
  1052. * 计算矩形四个顶点的质心(中心点)
  1053. */
  1054. private _calculateRectangleCenter(points: CoreTypes.Point3[]): CoreTypes.Point3 {
  1055. if (points.length < 4) {
  1056. return [0, 0, 0];
  1057. }
  1058. // 计算四个顶点的平均位置作为质心
  1059. let sumX = 0, sumY = 0, sumZ = 0;
  1060. for (const point of points) {
  1061. sumX += point[0];
  1062. sumY += point[1];
  1063. sumZ += point[2];
  1064. }
  1065. return [
  1066. sumX / points.length,
  1067. sumY / points.length,
  1068. sumZ / points.length,
  1069. ];
  1070. }
  1071. }