Skip to content

XPolygonSegWidget

简介

通过绘制多边形,来进行手动分割。主要是在结束绘制后,对分割图像进行处理。通过单击可定义多边形的顶点,双击结束绘制,中途可以通过鼠标左键按下移动控制点来调节多边形的位置。

XPolygonSegWidget

主要方法

  • 订阅多边形闭合事件。
  • 获取当前组件的状态。
  • 获取多边形的顶点。
  • 清除顶点,回到初始的绘制状态。
  • 设置顶点小球的大小。
  • 获得顶点小球的大小。
  • 设置线宽。
  • 获得线宽。

使用演示

vue
<template>
  <div class="poly-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"
      />

      <!-- 新建空白分割 -->
      <button
        class="btn btn-new-seg"
        :disabled="!!loading || !imageLoaded"
        @click="createBlankSegmentation"
      >
        新建分割
      </button>

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

        <template v-if="segLoaded">
          <span class="separator">|</span>

          <!-- 透明度 -->
          <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>

          <span class="separator">|</span>

          <!-- 多边形绘制开关 -->
          <button
            class="btn"
            :class="drawingActive ? 'btn-stop' : 'btn-draw'"
            @click="toggleDrawing"
          >
            {{ drawingActive ? "⬛ 停止绘制" : "✏️ 开始绘制" }}
          </button>

          <!-- 操作模式(仅绘制激活时显示) -->
          <template v-if="drawingActive">
            <div class="mode-group">
              <button
                class="btn btn-mode"
                :class="{ active: drawMode === 'erase' }"
                @click="drawMode = 'erase'"
              >
                擦除
              </button>
              <button
                class="btn btn-mode"
                :class="{ active: drawMode === 'paint' }"
                @click="drawMode = 'paint'"
              >
                涂标签
              </button>
            </div>

            <template v-if="drawMode === 'paint'">
              <label class="slider-label">标签</label>
              <input
                type="number"
                class="label-input"
                v-model.number="paintLabel"
                min="1"
                max="255"
              />
            </template>

            <span class="hint"
              >点击添加顶点 · 双击 / Enter 闭合 · 右键撤销 · Esc 重置</span
            >
          </template>

          <!-- 操作结果提示 -->
          <span v-if="lastResult" class="result-msg">{{ lastResult }}</span>
        </template>
      </template>
    </div>

    <!-- 视图区 -->
    <div class="viewport">
      <XView2D
        ref="view2D"
        viewID="vtk-poly-seg-container"
        :background="[0.0, 0.0, 0.0, 1]"
      />
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } 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 vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData";
import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray";
import {
  XSegmentationActor,
  type LabelColorMap,
} from "@/props/XSegmentationActor";
import { XDataStore } from "@/data/XDataStore";
import vtkWidgetManager from "@kitware/vtk.js/Widgets/Core/WidgetManager";
// @ts-ignore
import XPolygonSegWidget from "@/widgets/XPolygonSegWidget/index.js";
import { applyPolygonMask } from "@/utils/polygonSegUtils";

// ── 常量 ──────────────────────────────────────────────────────────────────────
const IMAGE_NAME = "poly-seg-image";
const SEG_NAME = "poly-seg-segmentation";

const ORIENTATIONS: { value: MPROrientation; label: string }[] = [
  { value: "axial", label: "轴位" },
  { value: "coronal", label: "冠状位" },
  { value: "sagittal", label: "矢状位" },
];

const AXIS_IDX: Record<MPROrientation, [number, number, number]> = {
  axial: [4, 5, 2],
  coronal: [2, 3, 1],
  sagittal: [0, 1, 0],
};

/** 各方向法向量轴(0=X 1=Y 2=Z)——与 polygonSegUtils 中保持一致 */
const NORMAL_AXIS: Record<MPROrientation, 0 | 1 | 2> = {
  axial: 2,
  coronal: 1,
  sagittal: 0,
};

/**
 * 将 manipulator 的平面原点同步到当前切片世界坐标。
 * 优先从 imageActor 的 slicePlane 读取(准确反映滚轮滚动后的 VTK 内部状态),
 * 回退到 slicePos.value(仅由滑块更新)。
 */
function updateWidgetPlane() {
  const axis = NORMAL_AXIS[currentOrientation.value];

  // 从 VTK 的 slicePlane 读取真实当前位置(包含滚轮滚动的效果)
  let worldPos = slicePos.value;
  if (imageActor) {
    const sp = imageActor.getSlicePlane?.();
    if (sp) {
      worldPos = (sp.getOrigin() as number[])[axis];
    }
  }

  const origin: [number, number, number] = [0, 0, 0];
  origin[axis] = worldPos;

  const manipulator = polygonWidget?.getManipulator?.();
  if (manipulator) {
    manipulator.setWidgetOrigin(origin);
  }
}

/**
 * 订阅 imageActor 的 slicePlane.onModified,
 * 确保鼠标滚轮滚动后 slicePos 和 manipulator 平面同步更新。
 */
function setupSlicePlaneObserver() {
  slicePlaneSubscription?.unsubscribe();
  slicePlaneSubscription = null;
  const sp = imageActor?.getSlicePlane?.();
  if (!sp) return;
  slicePlaneSubscription = sp.onModified(() => {
    const axis = NORMAL_AXIS[currentOrientation.value];
    const newPos = (sp.getOrigin() as number[])[axis];
    slicePos.value = newPos; // 同步滑块显示
    if (drawingActive.value) {
      updateWidgetPlane(); // 绘制中:保持 manipulator 跟随切片
    }
  });
}

// ── 组件状态 ──────────────────────────────────────────────────────────────────
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.6);

const drawingActive = ref(false);
const drawMode = ref<"erase" | "paint">("erase");
const paintLabel = ref(1);
const lastResult = ref("");

let imageActor: XImageActor | null = null;
let segActor: XSegmentationActor | null = null;
let cachedImageData: any = null;
let cachedSegData: any = null;

let widgetManager: ReturnType<typeof vtkWidgetManager.newInstance> | null =
  null;
let polygonWidget: any = null;
let activeWidget: any = null;
let activeWidgetView: any = null;
let resultTimer: ReturnType<typeof setTimeout> | null = null;
let slicePlaneSubscription: { unsubscribe: () => void } | null = null;

// ── 初始化 Widget ─────────────────────────────────────────────────────────────
onMounted(() => {
  if (!view2D.value) return;
  const renderer = view2D.value.getRenderer();

  widgetManager = vtkWidgetManager.newInstance();
  widgetManager!.setRenderer(renderer);

  // widget 实例在此创建,但 addWidget 延迟到「开始绘制」时调用,
  // 避免 representation actors 提前加入渲染器并拦截鼠标事件。
  polygonWidget = XPolygonSegWidget.newInstance({
    handleScale: 3,
    lineWidth: 2,
  });
});

onBeforeUnmount(() => {
  if (resultTimer) clearTimeout(resultTimer);
  if (drawingActive.value) stopDrawing();
  slicePlaneSubscription?.unsubscribe();
  slicePlaneSubscription = null;
  widgetManager?.delete?.();
  polygonWidget = null;
  activeWidget = null;
  activeWidgetView = null;
  widgetManager = null;
});

// ── 图像导入 ──────────────────────────────────────────────────────────────────
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);
    let imageData;

    if (files.length > 1 || files[0].name.toLowerCase().endsWith(".dcm")) {
      const result = await ReadDICOM.read(files);
      imageData = result.imageData;
    } else if (
      files[0].name.endsWith(".nii") ||
      files[0].name.endsWith(".nii.gz")
    ) {
      const result = await ReadNifti.read(files[0]);
      imageData = result.imageData;
    } else {
      const result = await ReadImage.read(files[0]);
      imageData = result.imageData;
    }

    XDataStore.getInstance().addImageData(IMAGE_NAME).setImageData(imageData);
    cachedImageData = imageData;

    if (imageActor) {
      slicePlaneSubscription?.unsubscribe();
      slicePlaneSubscription = null;
      view2D.value?.removeImageActor(imageActor);
      imageActor = null;
    }
    if (segActor) {
      view2D.value?.removeSegmentationActor(segActor);
      segActor = null;
      segLoaded.value = false;
      cachedSegData = null;
    }

    currentOrientation.value = "axial";
    imageActor = view2D.value?.addImage(IMAGE_NAME, "axial") ?? null;
    if (!imageActor) return;

    setupSlicePlaneObserver();

    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];
    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);
    cachedSegData = segImageData;

    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 = "";
  }
}

// ── 新建空白分割 ──────────────────────────────────────────────────────────────
function createBlankSegmentation() {
  if (!cachedImageData) return;
  if (drawingActive.value) stopDrawing();

  const dims = cachedImageData.getDimensions() as number[];
  const numVoxels = dims[0] * dims[1] * dims[2];

  const blankScalars = vtkDataArray.newInstance({
    name: "Scalars",
    dataType: "Uint8Array",
    numberOfComponents: 1,
    values: new Uint8Array(numVoxels),
  });

  const segImageData = vtkImageData.newInstance();
  segImageData.setOrigin(cachedImageData.getOrigin());
  segImageData.setSpacing(cachedImageData.getSpacing());
  segImageData.setDirection(cachedImageData.getDirection());
  segImageData.setDimensions(dims);
  segImageData.getPointData().setScalars(blankScalars);

  XDataStore.getInstance().addImageData(SEG_NAME).setImageData(segImageData);
  cachedSegData = segImageData;

  if (segActor) {
    view2D.value?.removeSegmentationActor(segActor);
    segActor = null;
  }

  segActor =
    view2D.value?.addSegmentation(
      SEG_NAME,
      currentOrientation.value,
      IMAGE_NAME,
      segOpacity.value,
      buildDemoColorMap(),
    ) ?? null;

  segLoaded.value = true;
  segVisible.value = true;
  view2D.value?.renderAtOnce();
}

// ── 方向切换 ──────────────────────────────────────────────────────────────────
function onOrientationChange(orientation: MPROrientation) {
  if (!cachedImageData) return;
  // 切换方向时先停止绘制
  if (drawingActive.value) stopDrawing();
  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);
  updateWidgetPlane();
}

// ── 切片控制 ──────────────────────────────────────────────────────────────────
function onSliceChange() {
  view2D.value?.setSlicePosition(slicePos.value);
  updateWidgetPlane();
}

// ── 分割图控制 ────────────────────────────────────────────────────────────────
function onOpacityChange() {
  segActor?.setOpacity(segOpacity.value);
  view2D.value?.renderAtOnce();
}
function toggleSegVisibility() {
  segVisible.value = !segVisible.value;
  segActor?.setVisibility(segVisible.value);
  view2D.value?.renderAtOnce();
}

// ── 多边形绘制 ────────────────────────────────────────────────────────────────
function toggleDrawing() {
  if (drawingActive.value) {
    stopDrawing();
  } else {
    startDrawing();
  }
}

function startDrawing() {
  if (!widgetManager) return;

  activeWidget = polygonWidget;
  if (!activeWidget) return;

  // 每次开始绘制时注册 widget(确保 representation 此时才加入渲染器)
  activeWidgetView = widgetManager.addWidget(activeWidget);
  // 将 manipulator 平面同步到当前切片位置
  updateWidgetPlane();

  activeWidgetView?.onEndInteractionEvent(() => {
    if (!cachedSegData) return;

    const pts: [number, number, number][] =
      activeWidgetView.getPolygonPoints?.() ?? activeWidget.getPolygonPoints();
    if (pts.length < 3) return;

    applyPolygonMask(
      cachedSegData,
      pts,
      currentOrientation.value,
      drawMode.value,
      paintLabel.value,
      null,
    );

    const op =
      drawMode.value === "erase" ? "擦除" : `涂标签 ${paintLabel.value}`;
    lastResult.value = `✓ 已${op}(多边形 ${pts.length} 个顶点)`;
    activeWidget.reset();

    segActor?.getMapper()?.modified?.();
    view2D.value?.renderAtOnce();
    if (resultTimer) clearTimeout(resultTimer);
    resultTimer = setTimeout(() => (lastResult.value = ""), 3000);
  });

  widgetManager.grabFocus(activeWidget);
  drawingActive.value = true;
  lastResult.value = "";
}

function stopDrawing() {
  if (!widgetManager || !activeWidget) return;
  widgetManager.releaseFocus();
  activeWidget?.reset?.();
  // 将 widget 从场景移除,actors 不再加入渲染器,不再拦截事件
  widgetManager.removeWidget(activeWidget);
  activeWidget = null;
  activeWidgetView = null;
  drawingActive.value = false;
}

// ── 演示颜色表 ────────────────────────────────────────────────────────────────
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>
.poly-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: #2a6e3f;
}
.btn-image:hover:not(:disabled) {
  background: #3a8e4f;
}
.btn-seg {
  background: #1a4f8a;
}
.btn-seg:hover:not(:disabled) {
  background: #2a6faa;
}
.btn-new-seg {
  background: #4a2a7a;
}
.btn-new-seg:hover:not(:disabled) {
  background: #6a4a9a;
}
.btn-toggle {
  background: #555;
}
.btn-toggle:hover {
  background: #777;
}
.btn-draw {
  background: #7a4a1a;
}
.btn-draw:hover {
  background: #9a6a3a;
}
.btn-stop {
  background: #7a1a1a;
}
.btn-stop:hover {
  background: #9a3a3a;
}

.orient-group,
.mode-group {
  display: flex;
  gap: 4px;
}
.btn-orient,
.btn-mode {
  background: #444;
}
.btn-orient:hover:not(:disabled),
.btn-mode:hover:not(:disabled) {
  background: #666;
}
.btn-orient.active,
.btn-mode.active {
  background: #2a7a4e;
}

.separator {
  color: #666;
  padding: 0 2px;
}
.slider-label {
  color: #ccc;
  font-size: 12px;
  white-space: nowrap;
}
.slider {
  width: 120px;
  accent-color: #42b883;
}
.val {
  color: #aaa;
  font-size: 12px;
  min-width: 40px;
}
.label-input {
  width: 56px;
  background: #333;
  color: #fff;
  border: 1px solid #555;
  border-radius: 4px;
  padding: 2px 6px;
  font-size: 13px;
}
.hint {
  color: #42b883;
  font-size: 12px;
}
.result-msg {
  color: #f0c040;
  font-size: 12px;
}

.viewport {
  flex: 1;
  overflow: hidden;
  position: relative;
}
</style>