irc_web/src/views/trials/trials-panel/reading/dicoms3D/components/histogram.vue

1278 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>