1278 lines
45 KiB
Vue
1278 lines
45 KiB
Vue
<template>
|
||
<div v-dialogDrag v-show="visible">
|
||
<div class="el-dialog">
|
||
<div class="el-dialog__header">
|
||
<div class="btnBox">
|
||
<div :class="['tool-item', activeTool === 'histogram_RectangleROI' ? 'tool-item-active' : '']"
|
||
:title="$t('trials:histogram:button:histogramRectangleROI')"
|
||
@click.prevent="setToolActive('histogram_RectangleROI')">
|
||
<svg-icon icon-class="rectangle" class="svg-icon" />
|
||
</div>
|
||
<div :class="['tool-item', activeTool === 'histogram_CircleROI' ? 'tool-item-active' : '']"
|
||
:title="$t('trials:histogram:button:histogramCircleROI')"
|
||
@click.prevent="setToolActive('histogram_CircleROI')">
|
||
<svg-icon icon-class="oval" class="svg-icon" />
|
||
</div>
|
||
<div :class="['tool-item', activeTool === 'histogram_PlanarFreehandROI' ? 'tool-item-active' : '']"
|
||
:title="$t('trials:histogram:button:histogramPlanarFreehandROI')"
|
||
@click.prevent="setToolActive('histogram_PlanarFreehandROI')">
|
||
<svg-icon icon-class="polygon" class="svg-icon" />
|
||
</div>
|
||
</div>
|
||
<div class="title">{{ $t("trials:histogram:title:histogram") }}</div>
|
||
<i class="el-icon-circle-close closeBtn" @click.stop="close"></i>
|
||
</div>
|
||
<div class="histogram">
|
||
<div ref="chartContainer" style="width: 450px; height: 220px;" v-loading="loading"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</template>
|
||
<script>
|
||
import * as echarts from 'echarts/core';
|
||
import { LineChart } from 'echarts/charts';
|
||
import { AxisBreak } from "echarts/features.js"
|
||
import {
|
||
TitleComponent,
|
||
TooltipComponent,
|
||
GridComponent,
|
||
DataZoomComponent,
|
||
LegendComponent,
|
||
DatasetComponent,
|
||
// 内置数据转换器组件 (filter, sort)
|
||
TransformComponent
|
||
} from 'echarts/components';
|
||
// 标签自动布局、全局过渡动画等特性
|
||
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
|
||
import { CanvasRenderer } from 'echarts/renderers';
|
||
echarts.use([
|
||
TitleComponent,
|
||
TooltipComponent,
|
||
GridComponent,
|
||
DatasetComponent,
|
||
TransformComponent,
|
||
DataZoomComponent,
|
||
LegendComponent,
|
||
LineChart,
|
||
AxisBreak,
|
||
LabelLayout,
|
||
UniversalTransition,
|
||
CanvasRenderer
|
||
]);
|
||
import {
|
||
getRenderingEngine,
|
||
cache,
|
||
utilities,
|
||
Enums,
|
||
} from '@cornerstonejs/core';
|
||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||
const {
|
||
ToolGroupManager,
|
||
Enums: csToolsEnums,
|
||
CrosshairsTool,
|
||
annotation
|
||
} = cornerstoneTools;
|
||
const { MouseBindings, Events: toolsEvents } = csToolsEnums
|
||
export default {
|
||
name: "histogram",
|
||
props: {
|
||
viewportKey: {
|
||
type: String,
|
||
default: 'viewport'
|
||
},
|
||
activeViewportIndex: {
|
||
type: Number,
|
||
default: 0
|
||
},
|
||
renderingEngineId: {
|
||
type: String,
|
||
required: true
|
||
},
|
||
visible: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
activeTool: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
},
|
||
data() {
|
||
return {
|
||
chart: null,
|
||
loading: false,
|
||
defaultColors: [
|
||
'#0ca8df',
|
||
'#ffd10a',
|
||
'#b6d634',
|
||
'#3fbe95',
|
||
'#785db0',
|
||
'#5070dd',
|
||
'#505372',
|
||
'#ff994d',
|
||
'#fb628b',
|
||
],
|
||
colors: [],
|
||
seriesData: {}
|
||
}
|
||
},
|
||
mounted() {
|
||
// this.initChart()
|
||
},
|
||
methods: {
|
||
setToolActive(toolName) {
|
||
const toolGroupId = `${this.viewportKey}-${this.activeViewportIndex}`
|
||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId)
|
||
if (this.activeTool === toolName) {
|
||
if (toolName === CrosshairsTool.toolName) {
|
||
toolGroup.setToolDisabled(this.activeTool)
|
||
} else {
|
||
toolGroup.setToolPassive(this.activeTool)
|
||
}
|
||
this.$emit('update:activeTool', '')
|
||
} else {
|
||
if (this.activeTool) {
|
||
if (toolName === CrosshairsTool.toolName) {
|
||
toolGroup.setToolDisabled(this.activeTool)
|
||
} else {
|
||
toolGroup.setToolPassive(this.activeTool)
|
||
}
|
||
}
|
||
toolGroup.setToolActive(toolName, {
|
||
bindings: [{ mouseButton: MouseBindings.Primary }]
|
||
})
|
||
this.$emit('update:activeTool', toolName)
|
||
}
|
||
},
|
||
clearnAnnotation() {
|
||
let annotations = annotation.state.getAllAnnotations().filter(item => item.metadata.toolName.includes('histogram_'));
|
||
annotations.forEach(item => {
|
||
annotation.state.removeAnnotation(item.annotationUID)
|
||
})
|
||
let viewportIds = ['viewport-0', 'viewport-1', 'viewport-2', 'viewport-3', 'viewport-MPR-0', 'viewport-MPR-1', 'viewport-MPR-2']
|
||
for (let i = 0; i < viewportIds.length; i++) {
|
||
const viewportId = viewportIds[i]
|
||
let renderingEngine = getRenderingEngine(this.renderingEngineId)
|
||
const viewport = renderingEngine.getViewport(viewportId)
|
||
if (!viewport) continue
|
||
viewport.render()
|
||
}
|
||
},
|
||
close() {
|
||
this.dispose()
|
||
this.seriesData = {}
|
||
this.clearnAnnotation()
|
||
this.$emit("update:visible", false)
|
||
},
|
||
async init() {
|
||
this.dispose()
|
||
this.clearnAnnotation()
|
||
this.colors = this.defaultColors
|
||
let _data = await this.generateData();
|
||
this.seriesData = {}
|
||
this.seriesData['default'] = _data.seriesData
|
||
this.seriesData['default'].color = this.colors[0]
|
||
this.initChart()
|
||
},
|
||
async initToolValue(an) {
|
||
let data = await this.generateData(an);
|
||
if (this.seriesData[an.annotationUID]) {
|
||
this.seriesData[an.annotationUID] = Object.assign(this.seriesData[an.annotationUID], data.seriesData)
|
||
} else {
|
||
let color = null;
|
||
let keys = Object.keys(this.seriesData)
|
||
if (keys.length >= this.defaultColors.length) {
|
||
let index = keys.length % this.defaultColors.length
|
||
color = this.randomNearColor(this.defaultColors[index], 30)
|
||
this.colors.push(color)
|
||
} else {
|
||
color = this.defaultColors[keys.length]
|
||
}
|
||
annotation.config.style.setAnnotationStyles(an.annotationUID, {
|
||
color: this.hex2Rgb(color),
|
||
colorLocked: this.hex2Rgb(color),
|
||
colorSelected: this.hex2Rgb(color),
|
||
});
|
||
this.seriesData[an.annotationUID] = data.seriesData
|
||
this.seriesData[an.annotationUID].color = color
|
||
}
|
||
this.initChart()
|
||
},
|
||
initChart() {
|
||
if (!this.chart) {
|
||
this.chart = echarts.init(this.$refs.chartContainer);
|
||
}
|
||
let seriesData = []
|
||
Object.keys(this.seriesData).forEach(key => {
|
||
seriesData.push(this.seriesData[key])
|
||
})
|
||
const option = {
|
||
useUTC: true,
|
||
title: {
|
||
show: false,
|
||
text: this.$t("trials:histogram:title:histogram"),
|
||
textStyle: {
|
||
color: "#fff"
|
||
},
|
||
left: 0,
|
||
top: 0
|
||
},
|
||
color: this.colors,
|
||
grid: {
|
||
top: 0,
|
||
left: 50,
|
||
bottom: 80,
|
||
right: 20
|
||
},
|
||
tooltip: {
|
||
show: true,
|
||
trigger: 'axis'
|
||
},
|
||
xAxis: [
|
||
{
|
||
type: 'category',
|
||
axisLabel: {
|
||
textStyle: {
|
||
color: '#fff'
|
||
},
|
||
|
||
},
|
||
axisLine: { // 设置 x 轴线颜色
|
||
lineStyle: {
|
||
color: '#fff',
|
||
}
|
||
},
|
||
}
|
||
],
|
||
yAxis: {
|
||
axisLabel: {
|
||
textStyle: {
|
||
color: '#fff',
|
||
}
|
||
},
|
||
axisLine: {
|
||
show: true,
|
||
lineStyle: {
|
||
color: '#fff',
|
||
}
|
||
},
|
||
},
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
xAxisIndex: 0,
|
||
},
|
||
{
|
||
type: 'slider',
|
||
xAxisIndex: 0,
|
||
}
|
||
],
|
||
series: seriesData
|
||
};
|
||
this.chart.setOption(option);
|
||
},
|
||
async generateData(annotation) {
|
||
var seriesData = [];
|
||
var min = null;
|
||
var max = null;
|
||
let res = null
|
||
if (annotation) {
|
||
res = await this[`get${annotation.metadata.toolName.split('histogram_')[1]}Values`](this.renderingEngineId, `${this.viewportKey}-${this.activeViewportIndex}`, annotation)
|
||
} else {
|
||
res = await this.getCurrentSliceValuesFromVoxelManager(this.renderingEngineId, `${this.viewportKey}-${this.activeViewportIndex}`)
|
||
}
|
||
if (res) {
|
||
let obj = {
|
||
|
||
}
|
||
res.values.forEach(item => {
|
||
if (obj[item]) {
|
||
obj[item]++
|
||
} else {
|
||
obj[item] = 1
|
||
}
|
||
if (min > item) {
|
||
min = item
|
||
}
|
||
if (max < item) {
|
||
max = item
|
||
}
|
||
})
|
||
Object.keys(obj).forEach(key => {
|
||
if (key > -1020) {
|
||
let arr = [key, obj[key]]
|
||
seriesData.push(arr)
|
||
}
|
||
|
||
})
|
||
}
|
||
seriesData.sort((a, b) => a[0] - b[0])
|
||
return {
|
||
seriesData: {
|
||
type: 'line',
|
||
symbolSize: 0,
|
||
data: seriesData
|
||
},
|
||
min: min,
|
||
max: max
|
||
};
|
||
},
|
||
resize() {
|
||
if (this.chart) {
|
||
this.chart.resize()
|
||
}
|
||
},
|
||
dispose() {
|
||
if (this.chart) {
|
||
this.chart.dispose()
|
||
this.chart = null
|
||
}
|
||
},
|
||
async getPlanarFreehandROIValues(
|
||
renderingEngineId,
|
||
viewportId,
|
||
annotation,
|
||
) {
|
||
const renderingEngine = getRenderingEngine(renderingEngineId);
|
||
const viewport = renderingEngine.getViewport(viewportId);
|
||
|
||
const volumeId = viewport.getVolumeId();
|
||
if (!volumeId) {
|
||
throw new Error('No volumeId found on viewport');
|
||
}
|
||
|
||
const volume = cache.getVolume(volumeId);
|
||
if (!volume) {
|
||
throw new Error(`Volume not found in cache: ${volumeId}`);
|
||
}
|
||
|
||
if (!volume.load) {
|
||
await volume.load();
|
||
}
|
||
|
||
const voxelManager = volume.voxelManager;
|
||
if (!voxelManager) {
|
||
throw new Error('voxelManager not found on volume');
|
||
}
|
||
|
||
const metadata = volume.metadata || {};
|
||
const modality = metadata.Modality || metadata.modality || 'UNKNOWN';
|
||
const [dimX, dimY, dimZ] = volume.dimensions;
|
||
|
||
const contourPointsWorld = this.getFreehandPointsFromAnnotation(annotation);
|
||
if (!contourPointsWorld.length) {
|
||
throw new Error('Freehand contour points not found');
|
||
}
|
||
|
||
// 世界坐标 -> IJK
|
||
const contourPointsIJK = contourPointsWorld.map((p) =>
|
||
utilities.transformWorldToIndex(volume.imageData, p)
|
||
);
|
||
|
||
const orientation = this.guessOrthogonalOrientation(
|
||
viewport.getCamera().viewPlaneNormal
|
||
);
|
||
|
||
let values = [];
|
||
|
||
if (orientation === Enums.OrientationAxis.AXIAL) {
|
||
// 使用 x,y 做二维点,z 固定
|
||
const k = this.clamp(
|
||
Math.round(this.avg(contourPointsIJK.map((p) => p[2]))),
|
||
0,
|
||
dimZ - 1
|
||
);
|
||
|
||
const polygon2D = contourPointsIJK.map((p) => [p[0], p[1]]);
|
||
|
||
const minX = this.clamp(
|
||
Math.floor(Math.min(...polygon2D.map((p) => p[0]))),
|
||
0,
|
||
dimX - 1
|
||
);
|
||
const maxX = this.clamp(
|
||
Math.ceil(Math.max(...polygon2D.map((p) => p[0]))),
|
||
0,
|
||
dimX - 1
|
||
);
|
||
const minY = this.clamp(
|
||
Math.floor(Math.min(...polygon2D.map((p) => p[1]))),
|
||
0,
|
||
dimY - 1
|
||
);
|
||
const maxY = this.clamp(
|
||
Math.ceil(Math.max(...polygon2D.map((p) => p[1]))),
|
||
0,
|
||
dimY - 1
|
||
);
|
||
|
||
for (let y = minY; y <= maxY; y++) {
|
||
for (let x = minX; x <= maxX; x++) {
|
||
if (this.isPointInPolygon([x + 0.5, y + 0.5], polygon2D)) {
|
||
const raw = voxelManager.getAtIJK(x, y, k);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'AXIAL',
|
||
sliceIndex: k,
|
||
bounds: { minX, maxX, minY, maxY },
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.CORONAL) {
|
||
// 使用 x,z 做二维点,y 固定
|
||
const j = this.clamp(
|
||
Math.round(this.avg(contourPointsIJK.map((p) => p[1]))),
|
||
0,
|
||
dimY - 1
|
||
);
|
||
|
||
const polygon2D = contourPointsIJK.map((p) => [p[0], p[2]]);
|
||
|
||
const minX = this.clamp(
|
||
Math.floor(Math.min(...polygon2D.map((p) => p[0]))),
|
||
0,
|
||
dimX - 1
|
||
);
|
||
const maxX = this.clamp(
|
||
Math.ceil(Math.max(...polygon2D.map((p) => p[0]))),
|
||
0,
|
||
dimX - 1
|
||
);
|
||
const minZ = this.clamp(
|
||
Math.floor(Math.min(...polygon2D.map((p) => p[1]))),
|
||
0,
|
||
dimZ - 1
|
||
);
|
||
const maxZ = this.clamp(
|
||
Math.ceil(Math.max(...polygon2D.map((p) => p[1]))),
|
||
0,
|
||
dimZ - 1
|
||
);
|
||
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
for (let x = minX; x <= maxX; x++) {
|
||
if (this.isPointInPolygon([x + 0.5, z + 0.5], polygon2D)) {
|
||
const raw = voxelManager.getAtIJK(x, j, z);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'CORONAL',
|
||
sliceIndex: j,
|
||
bounds: { minX, maxX, minZ, maxZ },
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.SAGITTAL) {
|
||
// 使用 y,z 做二维点,x 固定
|
||
const i = this.clamp(
|
||
Math.round(this.avg(contourPointsIJK.map((p) => p[0]))),
|
||
0,
|
||
dimX - 1
|
||
);
|
||
|
||
const polygon2D = contourPointsIJK.map((p) => [p[1], p[2]]);
|
||
|
||
const minY = this.clamp(
|
||
Math.floor(Math.min(...polygon2D.map((p) => p[0]))),
|
||
0,
|
||
dimY - 1
|
||
);
|
||
const maxY = this.clamp(
|
||
Math.ceil(Math.max(...polygon2D.map((p) => p[0]))),
|
||
0,
|
||
dimY - 1
|
||
);
|
||
const minZ = this.clamp(
|
||
Math.floor(Math.min(...polygon2D.map((p) => p[1]))),
|
||
0,
|
||
dimZ - 1
|
||
);
|
||
const maxZ = this.clamp(
|
||
Math.ceil(Math.max(...polygon2D.map((p) => p[1]))),
|
||
0,
|
||
dimZ - 1
|
||
);
|
||
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
for (let y = minY; y <= maxY; y++) {
|
||
if (this.isPointInPolygon([y + 0.5, z + 0.5], polygon2D)) {
|
||
const raw = voxelManager.getAtIJK(i, y, z);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'SAGITTAL',
|
||
sliceIndex: i,
|
||
bounds: { minY, maxY, minZ, maxZ },
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
throw new Error('Only standard orthogonal orientation is supported');
|
||
},
|
||
async getCircleROIValues(
|
||
renderingEngineId,
|
||
viewportId,
|
||
annotation,
|
||
) {
|
||
const renderingEngine = getRenderingEngine(renderingEngineId);
|
||
const viewport = renderingEngine.getViewport(viewportId);
|
||
|
||
const volumeId = viewport.getVolumeId();
|
||
if (!volumeId) {
|
||
throw new Error('No volumeId found on viewport');
|
||
}
|
||
|
||
const volume = cache.getVolume(volumeId);
|
||
if (!volume) {
|
||
throw new Error(`Volume not found in cache: ${volumeId}`);
|
||
}
|
||
|
||
if (!volume.load) {
|
||
await volume.load();
|
||
}
|
||
|
||
const voxelManager = volume.voxelManager;
|
||
if (!voxelManager) {
|
||
throw new Error('voxelManager not found on volume');
|
||
}
|
||
|
||
const metadata = volume.metadata || {};
|
||
const modality = metadata.Modality || metadata.modality || 'UNKNOWN';
|
||
const [dimX, dimY, dimZ] = volume.dimensions;
|
||
|
||
const points = annotation?.data?.handles?.points;
|
||
if (!points || points.length < 2) {
|
||
throw new Error('CircleROI annotation points not found');
|
||
}
|
||
|
||
// 假设第一个点是圆心,第二个点在圆边
|
||
const centerWorld = points[0];
|
||
const edgeWorld = points[1];
|
||
|
||
const centerIJK = utilities.transformWorldToIndex(volume.imageData, centerWorld);
|
||
const edgeIJK = utilities.transformWorldToIndex(volume.imageData, edgeWorld);
|
||
|
||
const orientation = this.guessOrthogonalOrientation(
|
||
viewport.getCamera().viewPlaneNormal
|
||
);
|
||
|
||
let values = [];
|
||
|
||
if (orientation === Enums.OrientationAxis.AXIAL) {
|
||
// 圆位于 XY 平面,固定 z
|
||
const cx = centerIJK[0];
|
||
const cy = centerIJK[1];
|
||
const k = this.clamp(Math.round(centerIJK[2]), 0, dimZ - 1);
|
||
|
||
const dx = edgeIJK[0] - cx;
|
||
const dy = edgeIJK[1] - cy;
|
||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||
|
||
const minX = this.clamp(Math.floor(cx - radius), 0, dimX - 1);
|
||
const maxX = this.clamp(Math.ceil(cx + radius), 0, dimX - 1);
|
||
const minY = this.clamp(Math.floor(cy - radius), 0, dimY - 1);
|
||
const maxY = this.clamp(Math.ceil(cy + radius), 0, dimY - 1);
|
||
|
||
for (let y = minY; y <= maxY; y++) {
|
||
for (let x = minX; x <= maxX; x++) {
|
||
const dist2 = (x - cx) * (x - cx) + (y - cy) * (y - cy);
|
||
if (dist2 <= radius * radius) {
|
||
const raw = voxelManager.getAtIJK(x, y, k);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'AXIAL',
|
||
center: { x: cx, y: cy, z: k },
|
||
radius,
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.CORONAL) {
|
||
// 圆位于 XZ 平面,固定 y
|
||
const cx = centerIJK[0];
|
||
const cz = centerIJK[2];
|
||
const j = this.clamp(Math.round(centerIJK[1]), 0, dimY - 1);
|
||
|
||
const dx = edgeIJK[0] - cx;
|
||
const dz = edgeIJK[2] - cz;
|
||
const radius = Math.sqrt(dx * dx + dz * dz);
|
||
|
||
const minX = this.clamp(Math.floor(cx - radius), 0, dimX - 1);
|
||
const maxX = this.clamp(Math.ceil(cx + radius), 0, dimX - 1);
|
||
const minZ = this.clamp(Math.floor(cz - radius), 0, dimZ - 1);
|
||
const maxZ = this.clamp(Math.ceil(cz + radius), 0, dimZ - 1);
|
||
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
for (let x = minX; x <= maxX; x++) {
|
||
const dist2 = (x - cx) * (x - cx) + (z - cz) * (z - cz);
|
||
if (dist2 <= radius * radius) {
|
||
const raw = voxelManager.getAtIJK(x, j, z);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'CORONAL',
|
||
center: { x: cx, y: j, z: cz },
|
||
radius,
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.SAGITTAL) {
|
||
// 圆位于 YZ 平面,固定 x
|
||
const cy = centerIJK[1];
|
||
const cz = centerIJK[2];
|
||
const i = this.clamp(Math.round(centerIJK[0]), 0, dimX - 1);
|
||
|
||
const dy = edgeIJK[1] - cy;
|
||
const dz = edgeIJK[2] - cz;
|
||
const radius = Math.sqrt(dy * dy + dz * dz);
|
||
|
||
const minY = this.clamp(Math.floor(cy - radius), 0, dimY - 1);
|
||
const maxY = this.clamp(Math.ceil(cy + radius), 0, dimY - 1);
|
||
const minZ = this.clamp(Math.floor(cz - radius), 0, dimZ - 1);
|
||
const maxZ = this.clamp(Math.ceil(cz + radius), 0, dimZ - 1);
|
||
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
for (let y = minY; y <= maxY; y++) {
|
||
const dist2 = (y - cy) * (y - cy) + (z - cz) * (z - cz);
|
||
if (dist2 <= radius * radius) {
|
||
const raw = voxelManager.getAtIJK(i, y, z);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'SAGITTAL',
|
||
center: { x: i, y: cy, z: cz },
|
||
radius,
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
throw new Error('Only standard orthogonal orientation is supported');
|
||
},
|
||
async getRectangleROIValues(
|
||
renderingEngineId,
|
||
viewportId,
|
||
annotation,
|
||
) {
|
||
const renderingEngine = getRenderingEngine(renderingEngineId);
|
||
const viewport = renderingEngine.getViewport(viewportId);
|
||
|
||
const volumeId = viewport.getVolumeId();
|
||
if (!volumeId) {
|
||
throw new Error('No volumeId found on viewport');
|
||
}
|
||
|
||
const volume = cache.getVolume(volumeId);
|
||
if (!volume) {
|
||
throw new Error(`Volume not found in cache: ${volumeId}`);
|
||
}
|
||
|
||
if (!volume.load) {
|
||
await volume.load();
|
||
}
|
||
|
||
const voxelManager = volume.voxelManager;
|
||
if (!voxelManager) {
|
||
throw new Error('voxelManager not found on volume');
|
||
}
|
||
|
||
const metadata = volume.metadata || {};
|
||
const modality = metadata.Modality || metadata.modality || 'UNKNOWN';
|
||
|
||
const [dimX, dimY, dimZ] = volume.dimensions;
|
||
|
||
const points = annotation?.data?.handles?.points;
|
||
if (!points || points.length < 4) {
|
||
throw new Error('RectangleROI annotation points not found');
|
||
}
|
||
|
||
// 世界坐标 -> IJK
|
||
const ijkPoints = points.map((p) =>
|
||
utilities.transformWorldToIndex(volume.imageData, p)
|
||
);
|
||
|
||
// 根据当前切面方向,确定遍历哪两个轴
|
||
const orientation = this.guessOrthogonalOrientation(
|
||
viewport.getCamera().viewPlaneNormal
|
||
);
|
||
|
||
let values = [];
|
||
|
||
if (orientation === Enums.OrientationAxis.AXIAL) {
|
||
// 固定 z = k,遍历 x,y
|
||
const k = this.clamp(Math.round(this.avg(ijkPoints.map((p) => p[2]))), 0, dimZ - 1);
|
||
|
||
const minX = this.clamp(Math.floor(Math.min(...ijkPoints.map((p) => p[0]))), 0, dimX - 1);
|
||
const maxX = this.clamp(Math.ceil(Math.max(...ijkPoints.map((p) => p[0]))), 0, dimX - 1);
|
||
|
||
const minY = this.clamp(Math.floor(Math.min(...ijkPoints.map((p) => p[1]))), 0, dimY - 1);
|
||
const maxY = this.clamp(Math.ceil(Math.max(...ijkPoints.map((p) => p[1]))), 0, dimY - 1);
|
||
|
||
for (let y = minY; y <= maxY; y++) {
|
||
for (let x = minX; x <= maxX; x++) {
|
||
const raw = voxelManager.getAtIJK(x, y, k);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'AXIAL',
|
||
bounds: { minX, maxX, minY, maxY, k },
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.CORONAL) {
|
||
// 固定 y = j,遍历 x,z
|
||
const j = this.clamp(Math.round(this.avg(ijkPoints.map((p) => p[1]))), 0, dimY - 1);
|
||
|
||
const minX = this.clamp(Math.floor(Math.min(...ijkPoints.map((p) => p[0]))), 0, dimX - 1);
|
||
const maxX = this.clamp(Math.ceil(Math.max(...ijkPoints.map((p) => p[0]))), 0, dimX - 1);
|
||
|
||
const minZ = this.clamp(Math.floor(Math.min(...ijkPoints.map((p) => p[2]))), 0, dimZ - 1);
|
||
const maxZ = this.clamp(Math.ceil(Math.max(...ijkPoints.map((p) => p[2]))), 0, dimZ - 1);
|
||
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
for (let x = minX; x <= maxX; x++) {
|
||
const raw = voxelManager.getAtIJK(x, j, z);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'CORONAL',
|
||
bounds: { minX, maxX, j, minZ, maxZ },
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.SAGITTAL) {
|
||
// 固定 x = i,遍历 y,z
|
||
const i = this.clamp(Math.round(this.avg(ijkPoints.map((p) => p[0]))), 0, dimX - 1);
|
||
|
||
const minY = this.clamp(Math.floor(Math.min(...ijkPoints.map((p) => p[1]))), 0, dimY - 1);
|
||
const maxY = this.clamp(Math.ceil(Math.max(...ijkPoints.map((p) => p[1]))), 0, dimY - 1);
|
||
|
||
const minZ = this.clamp(Math.floor(Math.min(...ijkPoints.map((p) => p[2]))), 0, dimZ - 1);
|
||
const maxZ = this.clamp(Math.ceil(Math.max(...ijkPoints.map((p) => p[2]))), 0, dimZ - 1);
|
||
|
||
for (let z = minZ; z <= maxZ; z++) {
|
||
for (let y = minY; y <= maxY; y++) {
|
||
const raw = voxelManager.getAtIJK(i, y, z);
|
||
const physicalValue = this.convertToPhysicalValue(raw, metadata, modality);
|
||
values.push(physicalValue);
|
||
}
|
||
}
|
||
|
||
return {
|
||
modality,
|
||
valueType: this.getValueType(modality, metadata),
|
||
orientation: 'SAGITTAL',
|
||
bounds: { i, minY, maxY, minZ, maxZ },
|
||
count: values.length,
|
||
values,
|
||
stats: this.getStats(values),
|
||
};
|
||
}
|
||
|
||
throw new Error('Only standard orthogonal orientation is supported');
|
||
},
|
||
async getCurrentSliceValuesFromVoxelManager(renderingEngineId, viewportId) {
|
||
const renderingEngine = getRenderingEngine(renderingEngineId);
|
||
const viewport = renderingEngine.getViewport(viewportId);
|
||
|
||
const volumeId = viewport.getVolumeId();
|
||
if (!volumeId) {
|
||
throw new Error('No volumeId found on viewport');
|
||
}
|
||
|
||
const volume = cache.getVolume(volumeId);
|
||
if (!volume) {
|
||
throw new Error(`Volume not found in cache: ${volumeId}`);
|
||
}
|
||
|
||
if (!volume.load) {
|
||
await volume.load();
|
||
}
|
||
|
||
const voxelManager = volume.voxelManager;
|
||
if (!voxelManager) {
|
||
throw new Error('voxelManager not found on volume');
|
||
}
|
||
|
||
const [dimX, dimY, dimZ] = volume.dimensions;
|
||
|
||
const camera = viewport.getCamera();
|
||
const focalPoint = camera.focalPoint;
|
||
const orientation = this.guessOrthogonalOrientation(camera.viewPlaneNormal);
|
||
|
||
const ijk = utilities.transformWorldToIndex(volume.imageData, focalPoint);
|
||
const i = this.clamp(Math.round(ijk[0]), 0, dimX - 1);
|
||
const j = this.clamp(Math.round(ijk[1]), 0, dimY - 1);
|
||
const k = this.clamp(Math.round(ijk[2]), 0, dimZ - 1);
|
||
|
||
const modality =
|
||
volume.metadata?.Modality ||
|
||
volume.metadata?.modality ||
|
||
'UNKNOWN';
|
||
|
||
let width, height, values;
|
||
|
||
if (orientation === Enums.OrientationAxis.AXIAL) {
|
||
width = dimX;
|
||
height = dimY;
|
||
values = new Float32Array(width * height);
|
||
|
||
let idx = 0;
|
||
for (let y = 0; y < dimY; y++) {
|
||
for (let x = 0; x < dimX; x++) {
|
||
const raw = voxelManager.getAtIJK(x, y, k);
|
||
values[idx++] = this.convertToPhysicalValue(raw, volume.metadata, modality);
|
||
}
|
||
}
|
||
|
||
return { orientation: 'AXIAL', sliceIndex: k, width, height, values, modality };
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.CORONAL) {
|
||
width = dimX;
|
||
height = dimZ;
|
||
values = new Float32Array(width * height);
|
||
|
||
let idx = 0;
|
||
for (let z = 0; z < dimZ; z++) {
|
||
for (let x = 0; x < dimX; x++) {
|
||
const raw = voxelManager.getAtIJK(x, j, z);
|
||
values[idx++] = this.convertToPhysicalValue(raw, volume.metadata, modality);
|
||
}
|
||
}
|
||
|
||
return { orientation: 'CORONAL', sliceIndex: j, width, height, values, modality };
|
||
}
|
||
|
||
if (orientation === Enums.OrientationAxis.SAGITTAL) {
|
||
width = dimY;
|
||
height = dimZ;
|
||
values = new Float32Array(width * height);
|
||
|
||
let idx = 0;
|
||
for (let z = 0; z < dimZ; z++) {
|
||
for (let y = 0; y < dimY; y++) {
|
||
const raw = voxelManager.getAtIJK(i, y, z);
|
||
values[idx++] = this.convertToPhysicalValue(raw, volume.metadata, modality);
|
||
}
|
||
}
|
||
|
||
return { orientation: 'SAGITTAL', sliceIndex: i, width, height, values, modality };
|
||
}
|
||
|
||
throw new Error('Unsupported orientation');
|
||
},
|
||
|
||
convertToPhysicalValue(storedValue, metadata, modality) {
|
||
if (modality === 'CT') {
|
||
return this.convertCTToHU(storedValue, metadata);
|
||
}
|
||
|
||
if (modality === 'PT' || modality === 'PET') {
|
||
return this.convertPETToSUV(storedValue, metadata);
|
||
}
|
||
|
||
return storedValue;
|
||
},
|
||
|
||
convertCTToHU(value, metadata) {
|
||
const slope = Number(metadata?.RescaleSlope ?? metadata?.rescaleSlope ?? 1);
|
||
const intercept = Number(
|
||
metadata?.RescaleIntercept ?? metadata?.rescaleIntercept ?? 0
|
||
);
|
||
|
||
return value * slope + intercept;
|
||
},
|
||
|
||
convertPETToSUV(value, metadata) {
|
||
// 先尝试判断当前值是否已经是 SUV
|
||
const units = metadata?.Units || metadata?.units || '';
|
||
const correctedImage = metadata?.CorrectedImage || metadata?.correctedImage;
|
||
// 如果数据已明确是 SUV,可以直接返回
|
||
if (
|
||
typeof units === 'string' &&
|
||
units.toUpperCase().includes('SUV')
|
||
) {
|
||
return value;
|
||
}
|
||
|
||
// 有些数据会先经过Rescale
|
||
const slope = Number(metadata?.RescaleSlope ?? metadata?.rescaleSlope ?? 1);
|
||
const intercept = Number(
|
||
metadata?.RescaleIntercept ?? metadata?.rescaleIntercept ?? 0
|
||
);
|
||
const activityConcentration = value * slope + intercept;
|
||
|
||
// 如果 metadata 中已有 suvFactor,优先使用
|
||
const suvFactor = metadata?.suvFactor ?? metadata?.SUVFactor;
|
||
if (suvFactor != null) {
|
||
return activityConcentration * Number(suvFactor);
|
||
}
|
||
|
||
// 如果没有足够信息计算 SUV,就退化返回 rescale 后值
|
||
// 这可能是 Bq/ml,而不是真正SUV
|
||
return activityConcentration;
|
||
},
|
||
|
||
getValueType(modality, metadata) {
|
||
if (modality === 'CT') {
|
||
return 'HU';
|
||
}
|
||
|
||
if (modality === 'PT' || modality === 'PET') {
|
||
const units = metadata?.Units || metadata?.units || '';
|
||
if (typeof units === 'string' && units.toUpperCase().includes('SUV')) {
|
||
return 'SUV';
|
||
}
|
||
|
||
if (metadata?.suvFactor != null || metadata?.SUVFactor != null) {
|
||
return 'SUV';
|
||
}
|
||
|
||
return 'PET';
|
||
}
|
||
|
||
return 'RAW';
|
||
},
|
||
|
||
clamp(v, min, max) {
|
||
return Math.max(min, Math.min(max, v));
|
||
},
|
||
avg(arr) {
|
||
return arr.reduce((s, v) => s + v, 0) / arr.length;
|
||
},
|
||
getStats(values) {
|
||
if (!values.length) {
|
||
return { min: null, max: null, mean: null, stdDev: null };
|
||
}
|
||
|
||
let min = Infinity;
|
||
let max = -Infinity;
|
||
let sum = 0;
|
||
|
||
for (const v of values) {
|
||
if (v < min) min = v;
|
||
if (v > max) max = v;
|
||
sum += v;
|
||
}
|
||
|
||
const mean = sum / values.length;
|
||
|
||
let varianceSum = 0;
|
||
for (const v of values) {
|
||
varianceSum += (v - mean) * (v - mean);
|
||
}
|
||
|
||
const stdDev = Math.sqrt(varianceSum / values.length);
|
||
|
||
return { min, max, mean, stdDev };
|
||
},
|
||
guessOrthogonalOrientation(viewPlaneNormal) {
|
||
const [nx, ny, nz] = viewPlaneNormal.map(Math.abs);
|
||
|
||
if (nz >= nx && nz >= ny) {
|
||
return Enums.OrientationAxis.AXIAL;
|
||
}
|
||
|
||
if (ny >= nx && ny >= nz) {
|
||
return Enums.OrientationAxis.CORONAL;
|
||
}
|
||
|
||
return Enums.OrientationAxis.SAGITTAL;
|
||
},
|
||
getFreehandPointsFromAnnotation(annotation) {
|
||
if (!annotation?.data) {
|
||
return [];
|
||
}
|
||
|
||
// 常见结构 1
|
||
if (Array.isArray(annotation.data.contour?.polyline)) {
|
||
return annotation.data.contour.polyline;
|
||
}
|
||
|
||
// 常见结构 2
|
||
if (Array.isArray(annotation.data.polyline)) {
|
||
return annotation.data.polyline;
|
||
}
|
||
|
||
// 常见结构 3
|
||
if (Array.isArray(annotation.data.handles?.points)) {
|
||
return annotation.data.handles.points;
|
||
}
|
||
|
||
return [];
|
||
},
|
||
isPointInPolygon(point, polygon) {
|
||
const [px, py] = point;
|
||
let inside = false;
|
||
|
||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||
const [xi, yi] = polygon[i];
|
||
const [xj, yj] = polygon[j];
|
||
|
||
const intersect =
|
||
yi > py !== yj > py &&
|
||
px < ((xj - xi) * (py - yi)) / (yj - yi + 1e-12) + xi;
|
||
|
||
if (intersect) {
|
||
inside = !inside;
|
||
}
|
||
}
|
||
|
||
return inside;
|
||
},
|
||
hex2Rgb(hexValue, alpha = 1) {
|
||
const rgx = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||
const hex = hexValue.replace(rgx, (m, r, g, b) => r + r + g + g + b + b);
|
||
const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||
if (!rgb) {
|
||
return hexValue;
|
||
}
|
||
const r = parseInt(rgb[1], 16),
|
||
g = parseInt(rgb[2], 16),
|
||
b = parseInt(rgb[3], 16);
|
||
return `rgb(${r},${g},${b})`;
|
||
},
|
||
randomNearColor(hex, range = 3) {
|
||
if (!/^#([0-9a-fA-F]{6})$/.test(hex)) {
|
||
throw new Error('请输入正确的 6 位十六进制颜色值,例如 #000000');
|
||
}
|
||
|
||
const hexToRgb = (hex) => ({
|
||
r: parseInt(hex.slice(1, 3), 16),
|
||
g: parseInt(hex.slice(3, 5), 16),
|
||
b: parseInt(hex.slice(5, 7), 16),
|
||
});
|
||
|
||
const rgbToHsl = (r, g, b) => {
|
||
r /= 255; g /= 255; b /= 255;
|
||
const max = Math.max(r, g, b);
|
||
const min = Math.min(r, g, b);
|
||
let h = 0, s = 0;
|
||
const l = (max + min) / 2;
|
||
|
||
if (max !== min) {
|
||
const d = max - min;
|
||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||
|
||
switch (max) {
|
||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||
case g: h = (b - r) / d + 2; break;
|
||
case b: h = (r - g) / d + 4; break;
|
||
}
|
||
|
||
h *= 60;
|
||
}
|
||
|
||
return { h, s: s * 100, l: l * 100 };
|
||
};
|
||
|
||
const hslToRgb = (h, s, l) => {
|
||
h /= 360; s /= 100; l /= 100;
|
||
let r, g, b;
|
||
|
||
if (s === 0) {
|
||
r = g = b = l;
|
||
} else {
|
||
const hue2rgb = (p, q, t) => {
|
||
if (t < 0) t += 1;
|
||
if (t > 1) t -= 1;
|
||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||
if (t < 1 / 2) return q;
|
||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||
return p;
|
||
};
|
||
|
||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||
const p = 2 * l - q;
|
||
|
||
r = hue2rgb(p, q, h + 1 / 3);
|
||
g = hue2rgb(p, q, h);
|
||
b = hue2rgb(p, q, h - 1 / 3);
|
||
}
|
||
|
||
return {
|
||
r: Math.round(r * 255),
|
||
g: Math.round(g * 255),
|
||
b: Math.round(b * 255),
|
||
};
|
||
};
|
||
|
||
const rgbToHex = (r, g, b) =>
|
||
`#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`;
|
||
|
||
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
|
||
const randomOffset = (n) => (Math.random() * 2 - 1) * n;
|
||
|
||
const { r, g, b } = hexToRgb(hex);
|
||
const { h, s, l } = rgbToHsl(r, g, b);
|
||
|
||
const nh = (h + randomOffset(range) + 360) % 360;
|
||
const ns = clamp(s + randomOffset(range), 0, 100);
|
||
const nl = clamp(l + randomOffset(range), 0, 100);
|
||
|
||
const rgb = hslToRgb(nh, ns, nl);
|
||
return rgbToHex(rgb.r, rgb.g, rgb.b);
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
<style lang="scss" scoped>
|
||
.el-dialog {
|
||
position: fixed;
|
||
top: 10px;
|
||
margin-top: 110px;
|
||
left: 220px;
|
||
z-index: 9999;
|
||
width: 450px;
|
||
text-align: center;
|
||
|
||
.el-dialog__header {
|
||
text-align: left;
|
||
padding: 5px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
|
||
.btnBox {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.closeBtn {
|
||
cursor: pointer;
|
||
}
|
||
}
|
||
|
||
.svg-icon {
|
||
color: #fff;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.tool-item {
|
||
padding: 5px;
|
||
margin: 0 5px;
|
||
border: 1px solid #333;
|
||
// font-size: 20px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tool-item-active {
|
||
background-color: #607d8b;
|
||
}
|
||
|
||
.tool-disabled {
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.tool-frame {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: flex-start;
|
||
align-items: center;
|
||
// margin-right: 20px;
|
||
padding: 5px;
|
||
margin-bottom: 10px;
|
||
border-bottom: 1px solid #404040;
|
||
|
||
.icon {
|
||
padding: 5px;
|
||
border-right: 1px solid #404040;
|
||
cursor: pointer;
|
||
text-align: center;
|
||
|
||
.svg-icon {
|
||
font-size: 20px;
|
||
color: #ddd;
|
||
}
|
||
}
|
||
|
||
.select-wrapper {
|
||
width: 60px;
|
||
background-color: black;
|
||
color: #ddd;
|
||
border: none;
|
||
font-size: 13px;
|
||
outline: none;
|
||
}
|
||
|
||
.text {
|
||
position: relative;
|
||
font-size: 12px;
|
||
margin-top: 5px;
|
||
color: #d0d0d0;
|
||
display: none;
|
||
}
|
||
}
|
||
}
|
||
</style> |