Appearance
主要的类
XDataStore
简介
XDataStore 是整个库的数据中心,其主要存储两类数据:
XPropData,一个与 vtkPolyData 相关的类XImageData,一个与 vtkImageData 相关的类
主要方法
- 返回全局的 XDataStore 实例。
- 添加或更新 XPropData 数据。XPropData 是三维模型相关的类型,具体信息见下文。
- 添加 XPropData 数据。如果 key 已存在,则直接返回已有的 XPropData 对象。
- 获取指定键的 XPropData 数据,如果不存在则返回 undefined。
- 添加或更新 XImageData 数据。XImageData 是图像相关的类型,具体信息见下文。
- 添加 XImageData 数据。如果 key 已存在,则直接返回已有的 XImageData 对象。
- 获取指定键的 XImageData 数据,如果不存在则返回 undefined。
使用样例
ts
import { XDataStore } from "@/data/XDataStore";
// 新建一个名为 test 的 XPropData,并调用 print 打印其相关信息
XDataStore.getInstance().addPropData("test").print();
// 新建一个名为 test 的 XImageData,后面可执行与 XImageData 相关的内容
XDataStore.getInstance()?.addImageData("test");XPropData
简介
XPropData 类用于管理 vtkPolyData 对象,并提供一些操作方法。同时通过简单的事件机制来通知其他类数据发生变化。
主要方法
- 构造函数,初始化 XPropData 对象。
- 添加一个数据变化的监听器。数据变化时调用回调函数。
- 打印 XPropData 对象的详细信息。
- 设置 vtkPolyData 对象,并通知数据发生了变化。
- 获取 vtkPolyData 对象。
- 获取 vtkPolyData 对象的别名。
- 清空 vtkPolyData 对象,并通知数据发生了变化。
- 添加 vtkPolyData 数据到当前对象,并通知数据发生了变化。
- 添加一条线段到 vtkPolyData 对象,并通知数据发生了变化。
- 平移 vtkPolyData 对象,并通知数据发生了变化。
- 旋转 vtkPolyData 对象,并通知数据发生了变化。
使用样例
ts
// 鼠标按住某个模型,可以平移其位置
const onPropLeftMousePressMove = (propName: string, offset: Vector3) => {
XDataStore.getInstance()?.getPropData(propName)?.translate(offset);
view3D.value?.renderInTime();
};
// 鼠标滚动某个模型,可以对其进行旋转
const onPropWheel = (propName: string, delta: number) => {
XDataStore.getInstance()
?.getPropData(propName)
?.rotate([0, 0, 1], delta > 0 ? 0.5 : -0.5);
view3D.value?.renderInTime();
};XImageData
简介
XImageData 类用于管理 vtkImageData 对象,并提供一些操作方法。
主要方法
- 构造函数,初始化 XImageData 对象。
- 设置 vtkImageData 对象。
- 获取 vtkImageData 对象。
- 打印 XImageData 对象的详细信息。
使用样例
ts
// 读取 DICOM,并存入名为 test 的 XImageData 中。
const result = await ReadDICOM.read(series.get(firstSeriesUID) || [], true);
XDataStore.getInstance().addImageData("test").setImageData(result.imageData);XPropActor
简介
用于定义一个模型 actor,可对其颜色、透明度、可见性以及是否可 pick 进行管理。
主要方法
构造函数,初始化 XPropActor 对象。
根据数据名称设置 actor 的数据,其与 XDataStore 里的名字相对应。
获取 vtkActor 对象。
设置 actor 的颜色。
设置 actor 的透明度,范围为 0 到 1。
获取 actor 的透明度。
设置 actor 的可见性。
获取 actor 的可见性。
根据传入的属性更新 actor 的属性。其中
propActorProperty定义如下:tsexport interface propActorProperty { /** Actor 的颜色,使用 RGB 格式。 */ color?: RGBColor; /** Actor 的透明度,范围从 0.0(完全透明)到 1.0(完全不透明)。 */ opacity?: number; /** Actor 的可见性,true 表示可见,false 表示不可见。 */ visibility?: boolean; /** Actor 的线宽度。 */ lineWidth?: number; /** Actor 是否可拾取。 */ pickable?: boolean; }
使用样例
ts
XDataStore.getInstance()
.addPropData("test")
.add(createLine.line([0, 0, 0], [1, 1, 1]));
// 在 view3D 视图中创建一个名为 test 的 XPropActor,使用的是在 XDataStore 中同名的数据。
if (view3D.value) {
view3D.value.addProp("test", {
color: [1, 0, 0],
opacity: 1,
visibility: true,
pickable: true,
lineWidth: 1,
});
view3D.value.resetCamera();
}XImageActor
简介
对 VTK 图像 Actor 进行了封装,自动根据数据维度选择合适的 mapper:对于 3D 体数据,使用 vtkImageResliceMapper,支持任意平面 MPR;对于 2D 图像,使用 vtkImageMapper。
主要方法
- 构造函数。
- 设置切片方向(仅 3D 体数据有效)。
- 任意斜面(仅 3D 体数据有效)。
- 设置切片位置。
- 设置窗宽和窗位。
- 根据当前图像数据自动计算并设置窗宽窗位。
- 根据当前图像数据自动计算并设置窗宽。
- 根据当前图像数据自动计算并设置窗位。
- 设置透明度。
- 设置可见性。
- 获取 vtkImageSlice 对象。
- 获取 vtkImageMapper 或 vtkImageResliceMapper 对象。
- vtkPlane(仅 3D 体数据时非 null)。
- 获取当前方向的法向量(3D/2D 均可用,供相机设置)。
- 是否是 3D。
使用样例
ts
// 读取并显示 DICOM
const onAddDicom = async () => {
const fileInput = document.getElementById("file-input") as HTMLInputElement;
const files = fileInput.files;
const series = await ReadDICOM.groupBySeries(files ? Array.from(files) : []);
const validSeriesUIDs = [...series.keys()].filter(
(key) => key !== "__unknown__",
);
if (validSeriesUIDs.length > 0) {
const firstSeriesUID = validSeriesUIDs[0];
const result = await ReadDICOM.read(series.get(firstSeriesUID) || [], true);
XDataStore.getInstance()
.addImageData("test")
.setImageData(result.imageData);
const actor = view2D.value?.addImage("test", "axial");
const slice = result.imageData.getDimensions()[2] / 2;
actor?.setSlicePosition(slice);
view2D.value?.renderAtOnce();
}
};XSegmentationActor
简介
在二维视图中显示分割图像,可以通过设置 color map 用于设定分割 label 的颜色。
主要方法
imageData分割 vtkImageData(标量值为标签整数);sharedPlane3D 体数据时可传入底层图像的 slicePlane 以实现自动同步;opacity整体不透明度(0-1),默认 0.5。- 设置图像显示的切面方向。
- 设置切片的位置。
colorMap标签值 → [r, g, b, a](0-255);maxLabelLUT 表项总数(建议设为最大标签值 + 1)。- 快捷设置单个标签的颜色(r/g/b 0-255,a 0-255)。
- 设置切面的不透明度。
- 设置切面的可见性。
- 设置分割图像 actor 的 name。
- 获得图像 actor。
- 获得显示图像所用的 mapper。
- 获得图像显示的切面所在平面。
- 获得图像切面的 normal。
- 获得图像 actor 的 name。
- 返回是否是 3D 视图里的 actor。
使用样例
vue
<template>
<div class="view2d-seg-example">
<!-- 工具栏 -->
<div class="toolbar">
<!-- 原始图像导入 -->
<button
class="btn btn-image"
:disabled="!!loading"
@click="triggerImageInput"
>
{{ loading === "image" ? "加载中..." : "导入原始图像" }}
</button>
<input
ref="imageInputEl"
type="file"
multiple
accept=".dcm,.ima,.dicom,.mha,.mhd,.nrrd,.nii,.nii.gz"
style="display: none"
@change="onImageSelected"
/>
<!-- 分割图导入 -->
<button
class="btn btn-seg"
:disabled="!!loading || !imageLoaded"
@click="triggerSegInput"
>
{{ loading === "seg" ? "加载中..." : "导入分割图" }}
</button>
<input
ref="segInputEl"
type="file"
accept=".nii,.nii.gz,.mha,.mhd,.nrrd,.dcm"
style="display: none"
@change="onSegSelected"
/>
<template v-if="imageLoaded">
<span class="separator">|</span>
<!-- 切片方向 -->
<div class="orient-group">
<button
v-for="ori in ORIENTATIONS"
:key="ori.value"
class="btn btn-orient"
:class="{ active: currentOrientation === ori.value }"
@click="onOrientationChange(ori.value)"
>
{{ ori.label }}
</button>
</div>
<span class="separator">|</span>
<!-- 切片滑块 -->
<label class="slider-label">切片</label>
<input
type="range"
class="slider"
:min="sliceRange[0]"
:max="sliceRange[1]"
:step="sliceStep"
v-model.number="slicePos"
@input="onSliceChange"
/>
<span class="val">{{ slicePos.toFixed(1) }}</span>
<span class="separator">|</span>
<!-- 分割图透明度 -->
<template v-if="segLoaded">
<label class="slider-label">分割透明度</label>
<input
type="range"
class="slider"
min="0"
max="1"
step="0.05"
v-model.number="segOpacity"
@input="onOpacityChange"
/>
<span class="val">{{ segOpacity.toFixed(2) }}</span>
<button class="btn btn-toggle" @click="toggleSegVisibility">
{{ segVisible ? "隐藏分割" : "显示分割" }}
</button>
</template>
</template>
</div>
<!-- 视图区 -->
<div class="viewport">
<XView2D
ref="view2D"
viewID="vtk-seg-container"
:background="[0.0, 0.0, 0.0, 1]"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import XView2D from "@/components/XView2D.vue";
import { ReadDICOM } from "@/io/readDICOM";
import { ReadNifti } from "@/io/readNifti";
import { ReadImage } from "@/io/readImage";
import { XImageActor, type MPROrientation } from "@/props/XImageActor";
import {
XSegmentationActor,
type LabelColorMap,
} from "@/props/XSegmentationActor";
import { XDataStore } from "@/data/XDataStore";
const view2D = ref<InstanceType<typeof XView2D> | null>(null);
const imageInputEl = ref<HTMLInputElement | null>(null);
const segInputEl = ref<HTMLInputElement | null>(null);
const loading = ref<false | "image" | "seg">(false);
const imageLoaded = ref(false);
const segLoaded = ref(false);
const segVisible = ref(true);
const currentOrientation = ref<MPROrientation>("axial");
const sliceRange = ref<[number, number]>([0, 1]);
const sliceStep = ref(1);
const slicePos = ref(0);
const segOpacity = ref(0.5);
const ORIENTATIONS: { value: MPROrientation; label: string }[] = [
{ value: "axial", label: "轴位" },
{ value: "coronal", label: "冠状位" },
{ value: "sagittal", label: "矢状位" },
];
let imageActor: XImageActor | null = null;
let segActor: XSegmentationActor | null = null;
let cachedImageData: any = null;
const IMAGE_NAME = "base-image";
const SEG_NAME = "segmentation";
// ========== 文件选择触发 ==========
function triggerImageInput() {
imageInputEl.value?.click();
}
function triggerSegInput() {
segInputEl.value?.click();
}
// ========== 图像导入 ==========
async function onImageSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
loading.value = "image";
try {
const files = Array.from(input.files);
const firstName = files[0].name.toLowerCase();
const isDicom =
firstName.endsWith(".dcm") ||
firstName.endsWith(".ima") ||
firstName.endsWith(".dicom");
let imageData;
if (isDicom) {
({ imageData } = await ReadDICOM.read(files));
} else {
({ imageData } = await ReadImage.read(files[0]));
}
XDataStore.getInstance().addImageData(IMAGE_NAME).setImageData(imageData);
cachedImageData = imageData;
// 先清除旧 actor
if (imageActor) {
view2D.value?.removeImageActor(imageActor);
imageActor = null;
}
if (segActor) {
view2D.value?.removeSegmentationActor(segActor);
segActor = null;
segLoaded.value = false;
}
currentOrientation.value = "axial";
imageActor = view2D.value?.addImage(IMAGE_NAME, "axial") ?? null;
if (!imageActor) return;
const bounds = imageData.getBounds();
const spacing = imageData.getSpacing();
sliceRange.value = [bounds[4], bounds[5]];
sliceStep.value = spacing[2] > 0 ? spacing[2] : 1;
slicePos.value = (bounds[4] + bounds[5]) / 2;
imageLoaded.value = true;
} catch (e) {
console.error("原始图像读取失败", e);
alert("原始图像读取失败,请检查文件。");
} finally {
loading.value = false;
input.value = "";
}
}
// ========== 分割图导入 ==========
async function onSegSelected(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) return;
loading.value = "seg";
try {
const file = input.files[0];
// 支持 NIfTI / 通用医学图像格式
let segImageData;
if (file.name.endsWith(".nii") || file.name.endsWith(".nii.gz")) {
const result = await ReadNifti.read(file);
segImageData = result.imageData;
} else {
const result = await ReadImage.read(file);
segImageData = result.imageData;
}
XDataStore.getInstance().addImageData(SEG_NAME).setImageData(segImageData);
// 移除旧分割 actor
if (segActor) {
view2D.value?.removeSegmentationActor(segActor);
segActor = null;
}
// 添加分割图,自动共享底层图像的切片平面
segActor =
view2D.value?.addSegmentation(
SEG_NAME,
"axial",
IMAGE_NAME, // 绑定到底层图像的切片平面,滚动自动同步
segOpacity.value,
buildDemoColorMap(), // 自定义颜色(可选)
) ?? null;
segLoaded.value = true;
segVisible.value = true;
view2D.value?.renderAtOnce();
} catch (e) {
console.error("分割图读取失败", e);
alert("分割图读取失败,请检查文件格式。");
} finally {
loading.value = false;
input.value = "";
}
}
// ========== 方向切换 ==========
const AXIS_IDX: Record<MPROrientation, [number, number, number]> = {
axial: [4, 5, 2],
coronal: [2, 3, 1],
sagittal: [0, 1, 0],
};
function onOrientationChange(orientation: MPROrientation) {
if (!cachedImageData) return;
currentOrientation.value = orientation;
view2D.value?.switchOrientation(orientation);
const bounds = cachedImageData.getBounds() as number[];
const spacing = cachedImageData.getSpacing() as number[];
const [minIdx, maxIdx, spIdx] = AXIS_IDX[orientation];
sliceRange.value = [bounds[minIdx], bounds[maxIdx]];
sliceStep.value = spacing[spIdx] > 0 ? spacing[spIdx] : 1;
slicePos.value = (bounds[minIdx] + bounds[maxIdx]) / 2;
view2D.value?.setSlicePosition(slicePos.value);
}
// ========== 切片控制 ==========
function onSliceChange() {
// setSlicePosition 会同时更新所有图像和未绑定平面的分割 actor
// 绑定了底层图像切片平面的分割 actor 会自动跟随,无需额外处理
view2D.value?.setSlicePosition(slicePos.value);
}
// ========== 分割图控制 ==========
function onOpacityChange() {
segActor?.setOpacity(segOpacity.value);
view2D.value?.renderAtOnce();
}
function toggleSegVisibility() {
segVisible.value = !segVisible.value;
segActor?.setVisibility(segVisible.value);
view2D.value?.renderAtOnce();
}
// ========== 演示颜色表(可根据实际标签替换) ==========
/**
* 为演示用途定义几个标签颜色:
* 0 = 背景(透明)
* 1 = 红色(器官 A)
* 2 = 绿色(器官 B)
* 3 = 蓝色(器官 C)
* 4 = 黄色(器官 D)
*/
function buildDemoColorMap(): LabelColorMap {
const map: LabelColorMap = new Map();
map.set(0, [0, 0, 0, 0]); // 背景透明
map.set(1, [255, 80, 80, 220]); // 红
map.set(2, [80, 255, 80, 220]); // 绿
map.set(3, [80, 130, 255, 220]); // 蓝
map.set(4, [255, 230, 50, 220]); // 黄
return map;
}
</script>
<style scoped>
.view2d-seg-example {
display: flex;
flex-direction: column;
width: 100vw;
height: calc(100vh - 150px);
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #1a1a1a;
flex-shrink: 0;
flex-wrap: wrap;
}
.btn {
padding: 4px 14px;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-image {
background: #3a7bd5;
}
.btn-image:hover:not(:disabled) {
background: #2f66bb;
}
.btn-seg {
background: #d55f3a;
}
.btn-seg:hover:not(:disabled) {
background: #bb4b2a;
}
.btn-toggle {
background: #555;
}
.btn-toggle:hover {
background: #777;
}
.orient-group {
display: flex;
gap: 4px;
}
.btn-orient {
background: #444;
}
.btn-orient:hover:not(:disabled) {
background: #666;
}
.btn-orient.active {
background: #2a7a4e;
}
.separator {
color: #555;
}
.slider-label {
color: #ccc;
font-size: 13px;
white-space: nowrap;
}
.slider {
width: 160px;
}
.val {
color: #ccc;
font-size: 13px;
min-width: 40px;
}
.viewport {
flex: 1;
min-height: 0;
}
</style>