1261 lines
44 KiB
Vue
1261 lines
44 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)
|
|
}
|
|
},
|
|
close() {
|
|
this.dispose()
|
|
this.seriesData = {}
|
|
this.$emit("update:visible", false)
|
|
},
|
|
async init() {
|
|
this.colors = this.defaultColors
|
|
let _data = await this.generateData();
|
|
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])
|
|
})
|
|
console.log(seriesData, 'seriesData')
|
|
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> |