Skip to content

主要的类

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 定义如下:

    ts
    export 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(标量值为标签整数);sharedPlane 3D 体数据时可传入底层图像的 slicePlane 以实现自动同步;opacity 整体不透明度(0-1),默认 0.5。
  • 设置图像显示的切面方向。
  • 设置切片的位置。
  • colorMap 标签值 → [r, g, b, a](0-255);maxLabel LUT 表项总数(建议设为最大标签值 + 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>