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

文章目录

什么是CBCT

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

CBCT在口腔医学中几乎覆盖所有亚专科:

​种植牙:评估颌骨密度、神经管位置,辅助种植体定位和手术导板设计。

​正畸与阻生牙:观察牙齿排列、埋伏牙位置及与周围组织关系,减少拔牙风险。

​牙体牙髓治疗:诊断复杂根管、根裂及根尖病变,提高治疗精确性。

​颌面外科:用于肿瘤、骨折的术前评估及术后效果监测。

​颞下颌关节:清晰显示关节结构异常,辅助诊断关节紊乱病。>

CBCT技术路线

使用第三方工具

有不少工具可以实现CBCT效果,例如:Slicer。

使用Python实现

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

使用前端实现

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

方案 技术 适用场景 优缺点
VTK.js WebGL + 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.配置依赖

bash 复制代码
npm i

3.运行

bash 复制代码
npm run dev

4.效果

注:点击左侧可以在线加载演示数据,也可以点击右侧上传本地dicom影像文件。

进阶:VolView配合Python解决卡顿

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

此处给出思路:

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

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

1.修改VtkThreeView.vue

去除原有渲染三维的组件,改为我们自定义的新组件: <Custom3DView />

全部代码如下:

javascript 复制代码
<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。

全部代码如下:

javascript 复制代码
<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:

python 复制代码
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显示三维的情况下,很流畅。

相关推荐
JINX的诅咒7 分钟前
定点除法器设计与实现:从基础算法到数值优化
笔记·python·算法·架构
萌萌哒草头将军8 分钟前
🚀🚀🚀尤雨溪连发两条推特墙裂推荐的这些库你一定要知道!
前端·vue.js·react.js
混血哲谈8 分钟前
webpack的SplitChunksPlugin和在路由或组件级别进行拆分
前端·webpack·node.js
木心操作13 分钟前
webpack使用详细步骤
前端·webpack·node.js
烂蜻蜓1 小时前
深入理解 Vue 3 项目结构与运行机制
前端·javascript·vue.js
han_hanker2 小时前
一个普通的vue权限管理方案-菜单权限控制
前端·javascript·vue.js
老大白菜3 小时前
lunar是一款无第三方依赖的公历 python调用
前端·python
混血哲谈5 小时前
如何使用webpack预加载 CSS 中定义的资源和预加载 CSS 文件
前端·css·webpack
浪遏7 小时前
我的远程实习(二) | git 持续更新版
前端
智商不在服务器7 小时前
XSS 绕过分析:一次循环与两次循环的区别
前端·xss