Files
irc_web/src/views/trials/trials-panel/reading/dicoms3D/components/histogram.vue
T
wangxiaoshuang 641c2e6ade
continuous-integration/drone/push Build is passing
直方图取值及样式修改
2026-04-08 16:22:29 +08:00

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>