【VolView】纯前端实现CT三维重建-CBCT

什么是CBCT

放射科影像是医学软件必不可少的一部分,对影像的显示、编辑、处理等操作更是重点。在多种放射科影像中,CBCT是关键的一环。CBCT全称为口腔颌面锥形束CT,其工作原理是通过锥形X射线束围绕患者头部旋转扫描,结合计算机算法生成高分辨率的三维图像。

CBCT在口腔医学中几乎覆盖所有亚专科:
​种植牙:评估颌骨密度、神经管位置,辅助种植体定位和手术导板设计。
​正畸与阻生牙:观察牙齿排列、埋伏牙位置及与周围组织关系,减少拔牙风险。
​牙体牙髓治疗:诊断复杂根管、根裂及根尖病变,提高治疗精确性。
​颌面外科:用于肿瘤、骨折的术前评估及术后效果监测。
​颞下颌关节:清晰显示关节结构异常,辅助诊断关节紊乱病。>

CBCT技术路线

使用第三方工具

有不少工具可以实现CBCT效果,例如:Slicer。
在这里插入图片描述

使用Python实现

使用pydicom结合mpl_toolkits实现三维展示。

使用前端实现

使用VTK.js、Three.js及WebAssembly 实现。

方案技术适用场景优缺点
VTK.jsWebGL + VTK医学影像可视化(CT/MRI)高质量、原生支持 Volume Rendering,但数据转换复杂
Three.js + 3D 纹理WebGL + Shader一般 3D 可视化兼容性好,适合前端开发,但医学精度低
WebAssembly + 医学引擎WASM专业医学影像专业级医学软件,性能强,但开发难度大

纯前端实现方案优缺点

类别优点缺点
性能与成本1. 低服务器依赖,节省硬件和维护成本。
2. 实时交互,响应延迟低。
1. 浏览器内存限制大,可能崩溃。
2. 低端设备GPU性能不足,导致渲染卡顿。
数据隐私1. 数据无需上传服务器,符合隐私法规(如HIPAA)。
2. 离线缓存支持断网使用。
1. 数据预处理依赖后端,可能需临时暴露敏感信息。
功能与兼容性1. 支持基础三维操作(旋转、缩放、剖面切割)。1. 复杂算法(如深度学习分割)难以实现。
2. 浏览器兼容性有限(如旧版Safari)。
开发与部署1. 部署便捷,前端静态资源可托管至CDN。
2. 适合轻量级应用(教育、预览)。
1. 大规模数据加载耗时(如全头颅CBCT)。
2. 需额外优化压缩和分块加载逻辑。

使用VolView实现CBCT

VolView是一款基于VTK.js的开源医学影像浏览器,支持在网页端直接拖拽加载DICOM数据并生成2D切片及3D电影级体渲染视图,提供标注、测量等工具,所有数据均在本地处理,确保隐私安全。无需安装软件,可跨平台使用,适用于临床诊断与科研教育。
在这里插入图片描述

官网:https://volview.kitware.com/

关于VolView的介绍可以参考视频:

VolView的使用

1.克隆代码

GitHub地址:https://github.com/Kitware/VolView

2.配置依赖

npm i

3.运行

npm run dev

4.效果

在这里插入图片描述
注:点击左侧可以在线加载演示数据,也可以点击右侧上传本地dicom影像文件。

在这里插入图片描述

进阶:VolView配合Python解决卡顿

上文我们提到,基于vtk.js的纯前端CBCT解决方案,虽然能不依赖其他第三方软件的情况下显示出我们需要的效果,但它对性能的高要求导致打开前端的电脑必须有较高的GPU配置,否则将异常卡顿。

此处给出思路:

由于卡顿主要是三维显示导致,其代码需实时计算得出三维效果,导致浏览器卡顿。要解决卡顿,我们就需要解决三维显示问题。

我们可以将VolView的三维渲染部分替换为server端Python生成stl文件。

1.修改VtkThreeView.vue

在这里插入图片描述
去除原有渲染三维的组件,改为我们自定义的新组件: <Custom3DView />

全部代码如下:

<template>
  <div class="vtk-container-wrapper vtk-three-container">
    <div class="vtk-container" :class="active ? 'active' : ''">
     
      <!-- 此处是绘制3D重建的地方 start-->

      <div class="vtk-sub-container">
        <!-- <div
          class="vtk-view"
          ref="vtkContainerRef"
          data-testid="vtk-view vtk-three-view">
        </div> -->
        <Custom3DView />
      </div>
      <!-- 此处是绘制3D重建的地方 end -->

      <div class="overlay-no-events tool-layer">
        <crop-tool :view-id="viewID" />
        <pan-tool :viewId="viewID" />
      </div>
      <view-overlay-grid class="overlay-no-events view-annotations">
        <template v-slot:top-left>
          <div class="annotation-cell">
            <v-btn
              class="pointer-events-all"
              dark
              icon
              size="medium"
              variant="text"
              @click="resetCamera"
            >
              <v-icon size="medium" class="py-1">
                mdi-camera-flip-outline
              </v-icon>
              <v-tooltip
                location="right"
                activator="parent"
                transition="slide-x-transition"
              >
                Reset Camera
              </v-tooltip>
            </v-btn>
            <span class="ml-3">{{ topLeftLabel }}</span>
          </div>
        </template>
      </view-overlay-grid>
      <transition name="loading">
        <div v-if="isImageLoading" class="overlay-no-events loading">
          <div>Loading the image</div>
          <div>
            <v-progress-circular indeterminate color="blue" />
          </div>
        </div>
      </transition>
    </div>
  </div>
</template>

<script lang="ts">
import {
  computed,
  defineComponent,
  onBeforeUnmount,
  onMounted,
  PropType,
  provide,
  ref,
  toRefs,
  watch,
  Ref,
  nextTick,
} from 'vue';
import { computedWithControl } from '@vueuse/core';
import { vec3 } from 'gl-matrix';

import vtkVolumeRepresentationProxy from '@kitware/vtk.js/Proxy/Representations/VolumeRepresentationProxy';
import { Mode as LookupTableProxyMode } from '@kitware/vtk.js/Proxy/Core/LookupTableProxy';
import vtkPiecewiseFunctionProxy from '@kitware/vtk.js/Proxy/Core/PiecewiseFunctionProxy';
import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import { getDiagonalLength } from '@kitware/vtk.js/Common/DataModel/BoundingBox';
import type { Vector3 } from '@kitware/vtk.js/types';

import { useProxyManager } from '@/src/composables/useProxyManager';
import ViewOverlayGrid from '@/src/components/ViewOverlayGrid.vue';
import { useResizeObserver } from '../composables/useResizeObserver';
import { useCurrentImage } from '../composables/useCurrentImage';
import { useCameraOrientation } from '../composables/useCameraOrientation';
import vtkLPSView3DProxy from '../vtk/LPSView3DProxy';
import { useSceneBuilder } from '../composables/useSceneBuilder';
import { usePersistCameraConfig } from '../composables/usePersistCameraConfig';
import { useModelStore } from '../store/datasets-models';
import { LPSAxisDir } from '../types/lps';
import { useViewProxy } from '../composables/useViewProxy';
import { ViewProxyType } from '../core/proxies';
import { VolumeColorConfig } from '../store/view-configs/types';
import useVolumeColoringStore, {
  DEFAULT_AMBIENT,
  DEFAULT_DIFFUSE,
  DEFAULT_SPECULAR,
} from '../store/view-configs/volume-coloring';
import { getShiftedOpacityFromPreset } from '../utils/vtk-helpers';
import CropTool from './tools/crop/CropTool.vue';
import PanTool from './tools/PanTool.vue';
import { useWidgetManager } from '../composables/useWidgetManager';
import { VTKThreeViewWidgetManager } from '../constants';
import { useCropStore, croppingPlanesEqual } from '../store/tools/crop';
import { isViewAnimating } from '../composables/isViewAnimating';
import { ColoringConfig } from '../types/views';
import useViewCameraStore from '../store/view-configs/camera';
import { Maybe } from '../types';
import { useResetViewsEvents } from './tools/ResetViews.vue';
import Custom3DView from '@/src/components/Custom3DView.vue';

function useCvrEffect(
  config: Ref<Maybe<VolumeColorConfig>>,
  imageRep: Ref<vtkVolumeRepresentationProxy | null>,
  viewProxy: Ref<vtkLPSView3DProxy>
) {
  const cvrParams = computed(() => config.value?.cvr);
  const repMapper = computedWithControl(
    imageRep,
    () => imageRep.value?.getMapper() as vtkVolumeMapper | undefined
  );
  const image = computedWithControl(
    imageRep,
    () => imageRep.value?.getInputDataSet() as vtkImageData | null | undefined
  );
  const volume = computedWithControl(
    imageRep,
    () => imageRep.value?.getVolumes()[0]
  );
  const renderer = computed(() => viewProxy.value.getRenderer());
  const isAnimating = isViewAnimating(viewProxy);
  const cvrEnabled = computed(() => {
    const enabled = !!cvrParams.value?.enabled;
    const animating = isAnimating.value;
    return enabled && !animating;
  });

  const requestRender = () => {
    if (!isAnimating.value) {
      viewProxy.value.renderLater();
    }
  };

  // lights
  const volumeCenter = computed(() => {
    if (!volume.value) return null;
    const volumeBounds = volume.value.getBounds();
    return [
      (volumeBounds[0] + volumeBounds[1]) / 2,
      (volumeBounds[2] + volumeBounds[3]) / 2,
      (volumeBounds[4] + volumeBounds[5]) / 2,
    ] as Vector3;
  });
  const lightFollowsCamera = computed(
    () => cvrParams.value?.lightFollowsCamera ?? true
  );

  watch(
    [volumeCenter, renderer, cvrEnabled, lightFollowsCamera],
    ([center, ren, enabled, lightFollowsCamera_]) => {
      if (!center) return;

      if (ren.getLights().length === 0) {
        ren.createLight();
      }
      const light = ren.getLights()[0];
      if (enabled) {
        light.setFocalPoint(...center);
        light.setColor(1, 1, 1);
        light.setIntensity(1);
        light.setConeAngle(90);
        light.setPositional(true);
        ren.setTwoSidedLighting(false);
        if (lightFollowsCamera_) {
          light.setLightTypeToHeadLight();
          ren.updateLightsGeometryToFollowCamera();
        } else {
          light.setLightTypeToSceneLight();
        }
      } else {
        light.setPositional(false);
      }

      requestRender();
    },
    { immediate: true }
  );

  // sampling distance
  const volumeQuality = computed(() => cvrParams.value?.volumeQuality);

  watch(
    [volume, image, repMapper, volumeQuality, cvrEnabled, isAnimating],
    ([volume_, image_, mapper, volumeQuality_, enabled, animating]) => {
      if (!volume_ || !mapper || volumeQuality_ == null || !image_) return;

      if (animating) {
        mapper.setSampleDistance(0.75);
        mapper.setMaximumSamplesPerRay(1000);
        mapper.setGlobalIlluminationReach(0);
        mapper.setComputeNormalFromOpacity(false);
      } else {
        const dims = image_.getDimensions();
        const spacing = image_.getSpacing();
        const spatialDiagonal = vec3.length(
          vec3.fromValues(
            dims[0] * spacing[0],
            dims[1] * spacing[1],
            dims[2] * spacing[2]
          )
        );

        // Use the average spacing for sampling by default
        let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0;
        // Adjust the volume sampling by the quality slider value
        sampleDistance /= volumeQuality_ > 1 ? 0.5 * volumeQuality_ ** 2 : 1.0;
        const samplesPerRay = spatialDiagonal / sampleDistance + 1;
        mapper.setMaximumSamplesPerRay(samplesPerRay);
        mapper.setSampleDistance(sampleDistance);
        // Adjust the global illumination reach by volume quality slider
        mapper.setGlobalIlluminationReach(enabled ? 0.25 * volumeQuality_ : 0);
        mapper.setComputeNormalFromOpacity(!enabled && volumeQuality_ > 2);
      }

      requestRender();
    },
    { immediate: true }
  );

  // volume properties
  const ambient = computed(() => cvrParams.value?.ambient ?? 0);
  const diffuse = computed(() => cvrParams.value?.diffuse ?? 0);
  const specular = computed(() => cvrParams.value?.specular ?? 0);

  watch(
    [volume, image, ambient, diffuse, specular, cvrEnabled],
    ([volume_, image_, ambient_, diffuse_, specular_, enabled]) => {
      if (!volume_ || !image_) return;

      const property = volume_.getProperty();
      property.setScalarOpacityUnitDistance(
        0,
        (0.5 * getDiagonalLength(image_.getBounds())) /
          Math.max(...image_.getDimensions())
      );

      property.setShade(true);
      property.setUseGradientOpacity(0, !enabled);
      property.setGradientOpacityMinimumValue(0, 0.0);
      const dataRange = image_.getPointData().getScalars().getRange();
      property.setGradientOpacityMaximumValue(
        0,
        (dataRange[1] - dataRange[0]) * 0.01
      );
      property.setGradientOpacityMinimumOpacity(0, 0.0);
      property.setGradientOpacityMaximumOpacity(0, 1.0);

      // do not toggle these parameters when animating
      property.setAmbient(enabled ? ambient_ : DEFAULT_AMBIENT);
      property.setDiffuse(enabled ? diffuse_ : DEFAULT_DIFFUSE);
      property.setSpecular(enabled ? specular_ : DEFAULT_SPECULAR);

      requestRender();
    },
    { immediate: true }
  );

  // volumetric scattering blending
  const useVolumetricScatteringBlending = computed(
    () => cvrParams.value?.useVolumetricScatteringBlending ?? false
  );
  const volumetricScatteringBlending = computed(
    () => cvrParams.value?.volumetricScatteringBlending ?? 0
  );

  watch(
    [
      useVolumetricScatteringBlending,
      volumetricScatteringBlending,
      repMapper,
      cvrEnabled,
    ],
    ([useVsb, vsb, mapper, enabled]) => {
      if (!mapper) return;

      if (enabled && useVsb) {
        mapper.setVolumetricScatteringBlending(vsb);
      } else {
        mapper.setVolumetricScatteringBlending(0);
      }

      requestRender();
    },
    { immediate: true }
  );

  // local ambient occlusion
  const useLocalAmbientOcclusion = computed(
    () => cvrParams.value?.useLocalAmbientOcclusion ?? false
  );
  const laoKernelSize = computed(() => cvrParams.value?.laoKernelSize ?? 0);
  const laoKernelRadius = computed(() => cvrParams.value?.laoKernelRadius ?? 0);

  watch(
    [
      useLocalAmbientOcclusion,
      laoKernelSize,
      laoKernelRadius,
      repMapper,
      cvrEnabled,
    ],
    ([useLao, kernelSize, kernelRadius, mapper, enabled]) => {
      if (!mapper) return;

      if (enabled && useLao) {
        mapper.setLocalAmbientOcclusion(true);
        mapper.setLAOKernelSize(kernelSize);
        mapper.setLAOKernelRadius(kernelRadius);
      } else {
        mapper.setLocalAmbientOcclusion(false);
        mapper.setLAOKernelSize(0);
        mapper.setLAOKernelRadius(0);
      }

      requestRender();
    },
    { immediate: true }
  );
}

function useColoringEffect(
  config: Ref<Maybe<ColoringConfig>>,
  imageRep: Ref<vtkVolumeRepresentationProxy | null>,
  viewProxy: Ref<vtkLPSView3DProxy>
) {
  const colorBy = computed(() => config.value?.colorBy);
  const colorTransferFunction = computed(() => config.value?.transferFunction);
  const opacityFunction = computed(() => config.value?.opacityFunction);

  const proxyManager = useProxyManager();

  watch(
    [imageRep, colorBy, colorTransferFunction, opacityFunction],
    ([rep, colorBy_, colorFunc, opacityFunc]) => {
      if (!rep || !colorBy_ || !colorFunc || !opacityFunc || !proxyManager) {
        return;
      }

      const { arrayName, location } = colorBy_;

      const lut = proxyManager.getLookupTable(arrayName);
      lut.setMode(LookupTableProxyMode.Preset);
      lut.setPresetName(colorFunc.preset);
      lut.setDataRange(...colorFunc.mappingRange);

      const pwf = proxyManager.getPiecewiseFunction(arrayName);
      pwf.setMode(opacityFunc.mode);
      pwf.setDataRange(...opacityFunc.mappingRange);

      switch (opacityFunc.mode) {
        case vtkPiecewiseFunctionProxy.Mode.Gaussians:
          pwf.setGaussians(opacityFunc.gaussians);
          break;
        case vtkPiecewiseFunctionProxy.Mode.Points: {
          const opacityPoints = getShiftedOpacityFromPreset(
            opacityFunc.preset,
            opacityFunc.mappingRange,
            opacityFunc.shift,
            opacityFunc.shiftAlpha
          );
          if (opacityPoints) {
            pwf.setPoints(opacityPoints);
          }
          break;
        }
        case vtkPiecewiseFunctionProxy.Mode.Nodes:
          pwf.setNodes(opacityFunc.nodes);
          break;
        default:
      }

      if (rep) {
        // control color range manually
        rep.setRescaleOnColorBy(false);
        rep.setColorBy(arrayName, location);
      }

      // Need to trigger a render for when we are restoring from a state file
      viewProxy.value.renderLater();
    },
    { immediate: true }
  );
}

export default defineComponent({
  props: {
    id: {
      type: String,
      required: true,
    },
    viewDirection: {
      type: String as PropType<LPSAxisDir>,
      required: true,
    },
    viewUp: {
      type: String as PropType<LPSAxisDir>,
      required: true,
    },
  },
  components: {
    ViewOverlayGrid,
    CropTool,
    PanTool,
    Custom3DView,
  },
  setup(props) {
    const modelStore = useModelStore();
    const volumeColoringStore = useVolumeColoringStore();
    const viewCameraStore = useViewCameraStore();

    const { id: viewID, viewDirection, viewUp } = toRefs(props);

    const vtkContainerRef = ref<HTMLElement>();

    // --- computed vars --- //

    const {
      currentImageID: curImageID,
      currentImageMetadata: curImageMetadata,
      currentImageData,
      isImageLoading,
    } = useCurrentImage();

    // --- view proxy setup --- //

    const { viewProxy, setContainer: setViewProxyContainer } =
      useViewProxy<vtkLPSView3DProxy>(viewID, ViewProxyType.Volume);

    onMounted(() => {
      viewProxy.value.setOrientationAxesVisibility(true);
      viewProxy.value.setOrientationAxesType('cube');
      viewProxy.value.setBackground([0, 0, 0, 0]);
      setViewProxyContainer(vtkContainerRef.value);
    });

    onBeforeUnmount(() => {
      setViewProxyContainer(null);
      viewProxy.value.setContainer(null);
    });

    useResizeObserver(vtkContainerRef, () => viewProxy.value.resize());

    // --- scene setup --- //

    const { baseImageRep } = useSceneBuilder<vtkVolumeRepresentationProxy>(
      viewID,
      {
        baseImage: curImageID,
        models: computed(() => modelStore.idList),
      }
    );

    // --- picking --- //

    // disables picking for crop control and more
    watch(
      baseImageRep,
      (rep) => {
        if (rep) {
          rep.getVolumes().forEach((volume) => volume.setPickable(false));
        }
      },
      { immediate: true }
    );

    // --- widget manager --- //

    const { widgetManager } = useWidgetManager(viewProxy);
    provide(VTKThreeViewWidgetManager, widgetManager);

    // --- camera setup --- //

    const { cameraUpVec, cameraDirVec } = useCameraOrientation(
      viewDirection,
      viewUp,
      curImageMetadata
    );

    const resetCamera = () => {
      const bounds = curImageMetadata.value.worldBounds;
      const center = [
        (bounds[0] + bounds[1]) / 2,
        (bounds[2] + bounds[3]) / 2,
        (bounds[4] + bounds[5]) / 2,
      ] as vec3;

      viewProxy.value.updateCamera(
        cameraDirVec.value,
        cameraUpVec.value,
        center
      );
      viewProxy.value.resetCamera();
      viewProxy.value.renderLater();
    };

    watch(
      [baseImageRep, cameraDirVec, cameraUpVec],
      () => {
        const cameraConfig = viewCameraStore.getConfig(
          viewID.value,
          curImageID.value
        );

        // We don't want to reset the camera if we have a config we are restoring
        if (!cameraConfig) {
          // nextTick ensures resetCamera gets called after
          // useSceneBuilder refreshes the scene.
          nextTick(resetCamera);
        }
      },
      {
        immediate: true,
      }
    );

    const { restoreCameraConfig } = usePersistCameraConfig(
      viewID,
      curImageID,
      viewProxy,
      'position',
      'focalPoint',
      'directionOfProjection',
      'viewUp'
    );

    watch(curImageID, () => {
      // See if we have a camera configuration to restore
      const cameraConfig = viewCameraStore.getConfig(
        viewID.value,
        curImageID.value
      );

      if (cameraConfig) {
        restoreCameraConfig(cameraConfig);

        viewProxy.value.getRenderer().resetCameraClippingRange();
        viewProxy.value.renderLater();
      }
    });

    // --- coloring setup --- //

    const volumeColorConfig = computed(() =>
      volumeColoringStore.getConfig(viewID.value, curImageID.value)
    );

    watch(
      [viewID, curImageID],
      () => {
        if (
          curImageID.value &&
          currentImageData.value &&
          !volumeColorConfig.value
        ) {
          volumeColoringStore.resetToDefaultColoring(
            viewID.value,
            curImageID.value,
            currentImageData.value
          );
        }
      },
      { immediate: true }
    );

    // --- CVR parameters --- //

    useCvrEffect(volumeColorConfig, baseImageRep, viewProxy);

    // --- coloring --- //

    useColoringEffect(volumeColorConfig, baseImageRep, viewProxy);

    // --- cropping planes --- //

    const cropStore = useCropStore();
    const croppingPlanes = cropStore.getComputedVTKPlanes(curImageID);

    watch(
      croppingPlanes,
      (planes, oldPlanes) => {
        const mapper = baseImageRep.value?.getMapper();
        if (
          !mapper ||
          !planes ||
          (oldPlanes && croppingPlanesEqual(planes, oldPlanes))
        )
          return;

        mapper.removeAllClippingPlanes();
        planes.forEach((plane) => mapper.addClippingPlane(plane));
        mapper.modified();
        viewProxy.value.renderLater();
      },
      { immediate: true }
    );

    // --- Listen to ResetViews event --- //
    const events = useResetViewsEvents();
    events.onClick(() => resetCamera());

    // --- template vars --- //

    return {
      vtkContainerRef,
      viewID,
      active: false,
      topLeftLabel: computed(
        () =>
          volumeColorConfig.value?.transferFunction.preset.replace(/-/g, ' ') ??
          ''
      ),
      isImageLoading,
      resetCamera,
    };
  },
});
</script>




<style scoped>
.model-container {
  width: 100%;
  height: 600px;
  position: relative;
}
</style>

<style scoped src="@/src/components/styles/vtk-view.css"></style>
<style scoped src="@/src/components/styles/utils.css"></style>

<style scoped>
.vtk-three-container {
  background-color: black;
  grid-template-columns: auto;
}
</style>

2.新增Custom3DView.vue

在这里插入图片描述

src/components目录下新增Custom3DView.vue。用来显示后端Python生成的stl。

全部代码如下:

<template>
  <div ref="container" class="model-container"></div>
</template>

<script>
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader';
import { toRaw } from 'vue';

export default {
  data() {
    return {
      loadingProgress: 0,
      loadError: null,
      animateId: null
    };
  },
  mounted() {
    this.initThreeContext();
    this.loadSTLModel();
    this.setupAnimation();
  },
  beforeDestroy() {
    this.cleanupResources();
  },
  methods: {
    initThreeContext() {
      const container = this.$refs.container;
      
      // 场景配置
      this._scene = new THREE.Scene();
      this._scene.background = new THREE.Color(0x000000);

      // 相机配置
      this._camera = new THREE.PerspectiveCamera(
        45, // 缩小视角增加近景效果
        container.clientWidth / container.clientHeight,
        0.1,
        500 // 缩小可视范围提升渲染性能
      ); 
      this._camera.position.set(30, 30, 30); // 初始位置更靠近模型

      // 渲染器配置(网页7的黑色背景方案)
      this._renderer = new THREE.WebGLRenderer({ 
        antialias: true,
        alpha: true // 保留alpha通道以备后续扩展
      });
      this._renderer.setClearColor(0x000000, 1); // 双重确保背景颜色
      this._renderer.setSize(container.clientWidth, container.clientHeight);
      container.appendChild(this._renderer.domElement);

      // 光源优化
      const ambientLight = new THREE.AmbientLight(0x404040);
      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
      directionalLight.position.set(15, 15, 15);
      this._scene.add(ambientLight, directionalLight);

      // 控制器配置
      this._controls = new OrbitControls(toRaw(this._camera), this._renderer.domElement);
      this._controls.enableDamping = true;
      this._controls.dampingFactor = 0.05;
    },
    loadSTLModel() {
      const objSTLLoader=new STLLoader()
      objSTLLoader.crossOrigin='Anonymous'
      objSTLLoader.load( 
         'https://stl所在路径.stl', 
        geometry => {
        // 添加模型前清空旧模型
        this.clearExistingModel();
          // 材质配置(浅灰色方案)
          const material  = new THREE.MeshPhongMaterial({
            color: 0xcccccc, // 浅灰色
            specular: 0x222222, 
            shininess: 150, 
            side: THREE.DoubleSide
          });
          
          const mesh = new THREE.Mesh(geometry, material);
          geometry.center();
          mesh.scale.set(0.1, 0.1, 0.1);

          // 自动聚焦模型
          const box = new THREE.Box3().setFromObject(mesh);
          const center = box.getCenter(new THREE.Vector3());
          toRaw(this._camera).lookAt(center);
          
          toRaw(this._scene).add(mesh); 
        },
        progress => {
        this.loadingProgress = (progress.loaded / progress.total) * 100
      },
      error => {
        this.loadError = '模型加载失败,请检查网络或文件路径'
      }
      );
    },

    setupAnimation() {
      const animate = () => {
        this.animateId = requestAnimationFrame(animate);
        toRaw(this._controls).update();
        this._renderer.render(toRaw(this._scene), toRaw(this._camera));
      };
      animate();
    },

    cleanupResources() {
      cancelAnimationFrame(this.animateId);
      toRaw(this._controls).dispose();
      this._renderer.dispose();
      
      toRaw(this._scene).traverse(obj => {
        if (obj.isMesh) {
          obj.geometry.dispose();
          obj.material.dispose();
        }
      });
    }
  }
};
</script>

<style scoped>
.model-container {
  width: 100%;
  height: 600px;
  position: relative;
  background: #000; /* 备用黑色背景 */
}
</style>

3.Python生成stl三维文件

在服务端用Python生成stl:

from pydicom import dcmread
import pylibjpeg

import numpy as np
import pydicom
import pydicom.pixel_data_handlers.gdcm_handler as gdcm_handler

import os
import matplotlib.pyplot as plt
from glob import glob
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
import scipy.ndimage
from skimage import measure
from mpl_toolkits import mplot3d
from stl import mesh
import trimesh
pydicom.config.image_handlers = [None, gdcm_handler]
pydicom.config.image_handlers = ['gdcm_handler']


def load_scan(path):
    slices = []
    # count = 0
    for s in os.listdir(path):
        ds = pydicom.dcmread(path + '/' + s, force=True)
        ds.PhotometricInterpretation = 'YBR_FULL'

        if s != '.DS_Store':  # This is for AttributeError: 'FileDataset' object has no attribute 'InstanceNumber'
            slices.append(ds)
    slices.sort(key=lambda x: int(x.InstanceNumber))
    try:
        slice_thickness = np.abs(slices[0].ImagePositionPatient[2] - slices[1].ImagePositionPatient[2])
    except:
        slice_thickness = np.abs(slices[0].SliceLocation - slices[1].SliceLocation)

    for s in slices:
        s.SliceThickness = slice_thickness
    return slices


def get_pixels_hu(scans):
    image = np.stack([s.pixel_array for s in scans])
    image = image.astype(np.int16)
    image[image == -2000] = 0

    # Convert to Hounsfield units (HU)
    intercept = scans[0].RescaleIntercept
    slope = scans[0].RescaleSlope

    if slope != 1:
        image = slope * image.astype(np.float64)
        image = image.astype(np.int16)

    image += np.int16(intercept)

    return np.array(image, dtype=np.int16)


def make_mesh(image, threshold=-300, step_size=1):
    print("Transposing surface")
    p = image.transpose(2, 1, 0)

    print("Calculating surface")
    verts, faces, norm, val = measure.marching_cubes(p, threshold, step_size=step_size, allow_degenerate=True)
    return verts, faces


def resample(image, scan, new_spacing=[1, 1, 1]):
    # Determine current pixel spacing, change this function to get better result
    spacing = [float(scan[0].SliceThickness)] + [float(i) for i in scan[0].PixelSpacing]
    spacing = np.array(spacing)
    resize_factor = [spacing[0] / new_spacing[0], spacing[1] / new_spacing[1], spacing[2] / new_spacing[2]]
    new_real_shape = np.multiply(image.shape, resize_factor)
    new_shape = np.round(new_real_shape)
    real_resize_factor = new_shape / image.shape
    new_spacing = spacing / real_resize_factor

    image = scipy.ndimage.interpolation.zoom(image, real_resize_factor)

    return image, new_spacing


if __name__ == "__main__":
    from matplotlib.cm import get_cmap
    import matplotlib.colors as mcolors
    data_path = "/mnt/data_18T/data/口腔/CBCT及三维重建/dicom"
    output_path = "/mnt/data_18T/data/口腔/CBCT及三维重建/stl_path/"
    if not os.path.exists(output_path):  # create the output path
        os.mkdir(output_path)
    patient = load_scan(data_path)
    images = get_pixels_hu(patient)

    imgs_after_resamp, spacing = resample(images.astype(np.float64), patient, [1, 0.5, 1])
    v, f = make_mesh(imgs_after_resamp, 350, 1)

    # save the stl file
    vertices = v
    faces = f
    # 创建颜色列表
    colors = get_cmap('Greens')(np.linspace(0, 1, len(vertices)))

    colors = mcolors.to_rgba_array(colors)

    mesh = trimesh.Trimesh(vertices=vertices, faces=faces)
    mesh.export(output_path + 'cube2.stl', file_type="stl")

4.最终效果

在这里插入图片描述
在这里插入图片描述
注:我的是集显,配置不算高,在使用stl显示三维的情况下,很流畅。

### 完整锥束CT三维重建的Matlab代码实现 #### 数据准备与初始化 为了完成锥束CT (Cone Beam Computed Tomography, CBCT) 的三维重建,在MATLAB环境中需先准备好投影数据以及设定必要的参数。 ```matlab % 设置扫描几何参数 numDetectors = 500; % 探测器数量 detectorSpacing = 1; % 探测器间距 sourceToDetectorDist = 900; % 射线源到探测器的距离 sourceToObjectDist = 450; % 射线源到物体中心距离 angles = linspace(0, 2*pi, 720); % 扫描角度范围 % 初始化体素网格尺寸 volumeSize = [256, 256, 256]; % 体积大小 voxelSize = 1; % 单个体素大小 ``` #### 投影模拟 接下来,创建一个虚拟对象并对其进行投影以获取CBCT所需的原始数据集。这里假设已有一个二值化的3D模型作为待重建的对象。 ```matlab % 创建测试用的球形幻影 phantomData = makePhantom(volumeSize); % 对该幻影执行前向投影获得正弦图 [sinogram, ~] = forwardProjection(phantomData, numDetectors, detectorSpacing,... sourceToDetectorDist, sourceToObjectDist, angles); ``` #### 迭代重建算法 采用迭代方法来解决逆问题,即从测量得到的一系列二维投影恢复出真实的三维结构。Feldkamp–Davis–Kress(FDK)滤波反投影法是一种广泛应用于临床实践中的快速近似解法。 ```matlab function reconstructedVolume = fdkReconstruction(sinogram, volumeSize, voxelSize,... numDetectors, detectorSpacing, sourceToDetectorDist, sourceToObjectDist, angles) % FDK核心计算逻辑... end ``` 调用上述定义好的函数来进行实际的数据处理: ```matlab reconstructedVolume = fdkReconstruction(sinogram, volumeSize, voxelSize,... numDetectors, detectorSpacing, sourceToDetectorDist, sourceToObjectDist, angles); ``` #### 结果可视化 最后一步是对重建后的三维图像进行渲染展示,以便直观评估重建效果。 ```matlab figure; isosurface(reconstructedVolume, 0.5); % 使用等值面绘制技术显示内部细节 shading interp; colormap(gray); view([30 30]); axis equal tight off; camlight('headlight'); title('FDK Reconstructed Volume Visualization'); ``` 以上就是利用MATLAB平台实现的一个简化版锥束CT三维重建流程示例[^1]。值得注意的是,这只是一个基础框架;对于更复杂的应用场景可能还需要考虑更多因素如噪声抑制、运动伪影校正等高级特性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值