基于 Cornerstone.js 的 Vue2 DICOM 医学图像浏览器开发实录

引言

在医疗信息化快速发展的今天,DICOM(Digital Imaging and Communications in Medicine)作为医学影像的标准格式,在各类医疗系统中扮演着重要角色。传统的DICOM查看器多为桌面应用,存在部署复杂、更新困难等问题。

本文将介绍如何使用现代Web技术(Vue.js + Cornerstone.js)开发一个功能完善的DICOM医学影像查看器,实现患者列表管理、DICOM图像加载与交互操作等核心功能。

技术选型

1. Cornerstone.js

Cornerstone.js是一个专门为医学影像设计的开源JavaScript库,提供:

  • DICOM图像解析与渲染
  • 图像交互工具(平移、缩放、窗宽窗位调节)
  • 多帧图像支持
  • 高性能渲染优化

2. Vue.js框架

Vue.js作为前端框架的优势:

  • 响应式数据绑定,简化UI更新逻辑
  • 组件化开发,提高代码复用性
  • 丰富的生态系统(Vuex、Vue Router等)

3. Element UI

选用Element UI作为UI组件库的原因:

  • 提供丰富的预制组件(表格、分页、卡片等)
  • 符合医疗系统简洁专业的设计风格
  • 良好的文档和社区支持

核心功能实现

1. 页面结构(患者列表+canvas医疗影像展示区域)

html 复制代码
<template>
  <div class="transmit-data">
    <div class="page-title">
      <span class="self">DICOM</span>
    </div>
    <div class="main">
      <div class="dicom-container">
        <el-row :gutter="20" class="top-row">
          <!-- 患者列表 -->
          <el-col class="top-table" :span="8">
            <el-card class="patient-card">
              <el-input
                v-model="searchQuery"
                placeholder="搜索患者ID/姓名"
                clearable
                @clear="fetchPatients"
                @keyup.enter="fetchPatients"
              ></el-input>

              <el-table
                :data="currentPageData"
                highlight-current-row
                @row-click="handlePatientSelect"
                height="660"
              >
                <el-table-column
                  prop="patientId"
                  label="患者ID"
                  min-width="120"
                ></el-table-column>
                <el-table-column
                  prop="name"
                  label="姓名"
                  min-width="80"
                ></el-table-column>
                <el-table-column
                  prop="studyDate"
                  label="检查日期"
                  min-width="120"
                ></el-table-column>
              </el-table>
              <el-pagination
                @size-change="handleSizeChange"
                @current-change="handleCurrentChange"
                :current-page="currentPage"
                :page-sizes="[5, 10, 20, 50]"
                :page-size="pageSize"
                layout="total, sizes, prev, pager, next, jumper"
                :total="patientList.length"
                class="pagination-container"
              ></el-pagination>
            </el-card>
          </el-col>
          <!-- DICOM 查看器 -->
          <el-col :span="16">
            <el-card class="viewer-card">
              <div class="viewer-tools">
                <el-button-group>
                  <el-button @click="zoomIn" icon="el-icon-zoom-in"></el-button>
                  <el-button
                    @click="zoomOut"
                    icon="el-icon-zoom-out"
                  ></el-button>
                  <el-button
                    @click="resetView"
                    icon="el-icon-refresh-left"
                  ></el-button>
                </el-button-group>

                <el-select
                  v-model="activeSeriesIndex"
                  placeholder="选择序列"
                  style="width: 200px; margin-left: 10px"
                >
                  <el-option
                    v-for="(series, index) in seriesList"
                    :key="index"
                    :label="series.description"
                    :value="index"
                  ></el-option>
                </el-select>
              </div>

              <!-- DICOM 影像渲染区域 -->
              <div id="dicom-viewer" ref="dicomViewer"></div>

              <!-- 患者信息 -->
              <div class="patient-info" v-if="currentPatient">
                <h3>患者信息</h3>
                <el-descriptions :column="2" border>
                  <el-descriptions-item label="患者ID">{{
                    currentPatient.patientId
                  }}</el-descriptions-item>
                  <el-descriptions-item label="姓名">{{
                    currentPatient.name
                  }}</el-descriptions-item>
                  <el-descriptions-item label="性别">{{
                    currentPatient.sex
                  }}</el-descriptions-item>
                  <el-descriptions-item label="年龄">{{
                    currentPatient.age
                  }}</el-descriptions-item>
                  <el-descriptions-item label="检查日期">{{
                    currentPatient.studyDate
                  }}</el-descriptions-item>
                  <el-descriptions-item label="检查类型">{{
                    currentPatient.studyDescription
                  }}</el-descriptions-item>
                </el-descriptions>
              </div>
            </el-card>
          </el-col>
        </el-row>
      </div>
    </div>
  </div>
</template>

2. 需要导入使用到的库

js 复制代码
import cornerstone from "cornerstone-core";
import cornerstoneWADOImageLoader from "cornerstone-wado-image-loader";
import dicomParser from "dicom-parser";

3. 患者列表管理和其他所需数据

解释测试文件.dcm资源的引用位置和来源:

js 复制代码
data() {
    return {
      currentPage: 1,
      pageSize: 5,
      searchQuery: "",
      patientList: [
        {
          patientId: "P2023060001",
          name: "张明",
          sex: "男",
          age: "45岁",
          studyDate: "2023-06-15",
          studyDescription: "张明·头部CT",
          imageIds: [
            "wadouri:http://localhost:8888/dicom-images/series-000001/image-000001.dcm",
            "wadouri:http://localhost:8888/dicom-images/series-000001/image-000002.dcm",
          ],
        },
        {
          patientId: "P2023060002",
          name: "王绪",
          sex: "女",
          age: "24岁",
          studyDate: "2023-08-05",
          studyDescription: "王绪·头部CT",
          imageIds: [
            "wadouri:http://localhost:8888/dicom-images/series-000001/image-000002.dcm",
            "wadouri:http://localhost:8888/dicom-images/series-000001/image-000002.dcm",
          ],
        },
        {
          patientId: "P2023060003",
          name: "李亮",
          sex: "男",
          age: "28岁",
          studyDate: "2023-12-11",
          studyDescription: "李亮·头部CT",
          imageIds: [
            "wadouri:http://localhost:8888/dicom-images/series-000001/image-000003.dcm",
            "wadouri:http://localhost:8888/dicom-images/series-000001/image-000003.dcm",
          ],
        },
      ],
      currentPatient: null,
      seriesList: [],
      activeSeriesIndex: 0,
      showAnnotations: false,
      viewerElement: null,
      toolState: {},
      // sb
      isPanning: false, // 是否正在平移
      lastPanPosition: { x: 0, y: 0 }, // 上一次平移位置
      currentViewport: {
        // 当前视口状态
        translation: { x: 0, y: 0 },
        scale: 1,
      },
    };
  },

4. 监听选择的序列

js 复制代码
watch: {
  activeSeriesIndex(newVal) {
    if (this.seriesList[newVal]) {
      this.loadSeries(this.seriesList[newVal]);
    }
  },
},
computed: {
    // 计算当前页显示的数据
    currentPageData() {
    const start = (this.currentPage - 1) * this.pageSize;
    const end = start + this.pageSize;
    return this.patientList.slice(start, end);
  },
},

5. mounted()初始化内容

js 复制代码
mounted() {
  // cornerstone配置
  cornerstoneWADOImageLoader.external.cornerstone = cornerstone;
  cornerstoneWADOImageLoader.external.dicomParser = dicomParser;
  // Web Worker配置
  cornerstoneWADOImageLoader.webWorkerManager.initialize({
    maxWebWorkers: navigator.hardwareConcurrency || 4,
    startWebWorkersOnDemand: true,
    webWorkerPath: "/cornerstoneWADOImageLoaderWebWorker.js",
    taskConfiguration: {
      decodeTask: {
        loadCodecsOnStartup: true,
        initializeCodecsOnStartup: false,
        codecsPath: "/cornerstoneWADOImageLoaderCodecs.js",
      },
    },
  });
  this.viewerElement = this.$refs.dicomViewer;
  cornerstone.enable(this.viewerElement);
  this.handlePatientSelect(this.patientList[0]); // 默认选择第一个患者
  // 初始化鼠标拖拽
  this.initPanTool();
  // 设置初始光标样式
  this.viewerElement.style.cursor = "grab";
},

6. 鼠标拖拽

js 复制代码
// 鼠标拖拽
initPanTool() {
  const element = this.viewerElement;
  element.addEventListener("mousedown", this.handlePanStart);
  element.addEventListener("mousemove", this.handlePan);
  element.addEventListener("mouseup", this.handlePanEnd);
  element.addEventListener("mouseleave", this.handlePanEnd);
  // 触摸支持
  element.addEventListener("touchstart", this.handlePanStart);
  element.addEventListener("touchmove", this.handlePan);
  element.addEventListener("touchend", this.handlePanEnd);
},

7. DICOM图像加载

js 复制代码
    // 加载DICOM序列
async loadSeries(series) {
  try {
    const imageId = series.imageIds[0];

    // 等待加载完成
    const image = await cornerstone.loadImage(imageId);

    // 启用图像容器(确保只启用一次)
    if (!cornerstone.getEnabledElement(this.viewerElement)) {
      cornerstone.enable(this.viewerElement);
   }
    // 显示图像(v2版本)
    cornerstone.displayImage(this.viewerElement, image);
    cornerstone.reset(this.viewerElement);
    // 设置窗宽窗位
    const viewport = cornerstone.getViewport(this.viewerElement);
    viewport.voi.windowWidth = 400;
    viewport.voi.windowCenter = 40;
    cornerstone.setViewport(this.viewerElement, viewport);
    // 预加载其余图像(可选)
    series.imageIds.slice(1).forEach((imgId) => {
      cornerstone.loadAndCacheImage(imgId);
    });
  } catch (err) {
    console.error("加载图像失败:", err);
    this.$message.error("DICOM 加载失败: " + err.message);
  }
},

优化策略:

  • 使用Web Worker加速图像解码
  • 预加载相邻图像
  • 错误处理和用户反馈

8. 图像交互功能

平移功能实现

javascript 复制代码
// 平移开始
handlePanStart(e) {
  this.isPanning = true;
  const pos = this.getMousePosition(e);
  this.lastPanPosition = { x: pos.x, y: pos.y };
  this.viewerElement.style.cursor = "grabbing";
}

// 平移中
handlePan(e) {
  if (!this.isPanning) return;

  const pos = this.getMousePosition(e);
  const deltaX = pos.x - this.lastPanPosition.x;
  const deltaY = pos.y - this.lastPanPosition.y;

  const viewport = cornerstone.getViewport(this.viewerElement);
  viewport.translation.x += deltaX;
  viewport.translation.y += deltaY;

  cornerstone.setViewport(this.viewerElement, viewport);
  this.lastPanPosition = pos;
}

// 平移结束 
handlePanEnd() {
  this.isPanning = false;
  this.viewerElement.style.cursor = "grab";
}

9. 获取鼠标位置

js 复制代码
// 获取鼠标位置
getMousePosition(e) {
  const rect = this.viewerElement.getBoundingClientRect();
  let clientX, clientY;
  if (e.type.includes("touch")) {
    clientX = e.touches[0].clientX;
    clientY = e.touches[0].clientY;
  } else {
    clientX = e.clientX;
    clientY = e.clientY;
  }
  return {
    x: clientX - rect.left,
    y: clientY - rect.top,
 };
},

10. 缩放功能实现

javascript 复制代码
zoomIn() {
  const viewport = cornerstone.getViewport(this.viewerElement);
  viewport.scale += 0.5;
  cornerstone.setViewport(this.viewerElement, viewport);
}

zoomOut() {
  const viewport = cornerstone.getViewport(this.viewerElement);
  viewport.scale = Math.max(0.5, viewport.scale - 0.5);
  cornerstone.setViewport(this.viewerElement, viewport);
}

11. 重置视图

js 复制代码
resetView() {
  cornerstone.reset(this.viewerElement);
},

12. 搜索患者获取患者列表

js 复制代码
// 获取患者列表
async fetchPatients() {
  const params = { q: this.searchQuery };
  // 这里替换真实的接口
  const res = await this.$axios.get("/api/dicom/patients", { params });
  this.patientList = res.data;
},

13. 选择患者

js 复制代码
async handlePatientSelect(patient) {
  console.log(patient);
  this.currentPatient = patient;
  // 模拟从API获取序列数据
  this.seriesList = [
    {
      description: patient.studyDescription,
      imageIds: patient.imageIds,
    },
  ];
  this.activeSeriesIndex = 0;
  // 初始化工具 需要的可以加上这个函数 可以初始化一些方法
  // this.initTools();
  // 加载系列
  await this.loadSeries(this.seriesList[0]);
},

14. 分页

js 复制代码
// 新增分页方法
handleSizeChange(val) {
  this.pageSize = val;
  this.currentPage = 1; // 重置到第一页
},
handleCurrentChange(val) {
  this.currentPage = val;
},

交互优化:

  • 支持鼠标拖拽平移
  • 支持触摸屏操作
  • 平滑的动画过渡
  • 光标状态反馈

样式

css 复制代码
<style lang="less" scoped>
/* 新增分页样式 */
.pagination-container {
  margin-top: 16px;
  text-align: right;
  padding: 10px 0;
}
.file-list {
  margin-top: 16px;
  max-height: 200px;
  overflow-y: auto;
  border: 1px solid #ebeef5;
  border-radius: 4px;
  padding: 8px;

  .file-item {
    padding: 8px;
    color: #606266;
    font-size: 14px;
    transition: background-color 0.3s;

    &:hover {
      background-color: #f5f7fa;
    }
  }
}

.upload-action {
  margin-top: 16px;
  text-align: right;
}

.watermark-tip {
  margin-top: 20px;
  padding: 5px 16px;
  background: rgba(32, 132, 232, 0.1);
  border-radius: 4px;
  .tip-content {
    color: #606266;
    .warning {
      padding-bottom: 12px;
      // color: #e6a23c;
      border-bottom: 1px solid #409eff;
      .highlight {
        color: #2084e8;
        font-weight: bold;
      }
    }
    .watermark-info {
      display: flex;
      width: 606px;
      div {
        margin-bottom: 10px;
      }
    }
  }
}
.transmit-data {
  min-width: auto !important;
  overflow: hidden;
  box-sizing: border-box;
  .page-title {
    font-size: 18px;
    font-weight: bold;
    background: #ffffff;
    border-radius: 8px;
    padding: 10px;
    color: #999;
    margin-bottom: 8px;
    .self {
      color: #2e303c;
    }
  }
  .main {
    display: flex;
    height: calc(100% - 47px);
    background: #ffffff;
    box-shadow: 0px 4px 4px 1px rgba(34, 72, 177, 0.08);
    border-radius: 8px 8px 8px 8px;
    overflow: auto;
    box-sizing: border-box;
    .dicom-container {
      padding: 20px;
      width: 100%;
      // height: calc(100vh - 60px);
      .top-row {
        display: flex;
        height: 800px;
      }
    }

    .patient-card,
    .viewer-card {
      height: 100%;
    }

    #dicom-viewer {
      width: 100%;
      height: 500px;
      background-color: #000;
      margin-bottom: 20px;
      // 确保图像居中显示
      display: flex;
      justify-content: center;
      align-items: center;
      cursor: grab; /* 默认光标样式 */
      user-select: none; /* 防止拖动时选中文本 */
      canvas {
        // 图像最大不超过容器
        max-width: 100%;
        max-height: 100%;
      }
    }
    #dicom-viewer:active {
      cursor: grabbing; /* 拖动时光标样式 */
    }

    .viewer-tools {
      margin-bottom: 15px;
    }

    .patient-info {
      margin-top: 20px;
    }
  }
  .no-data {
    height: calc(100% - 35px);
    padding-top: 266px;
    text-align: center;
    color: #999;
    background: #fff;
    border-radius: 8px;
    overflow: hidden;
    box-sizing: border-box;
    box-shadow: 0px 4px 4px 1px rgba(34, 72, 177, 0.08);
  }
}
</style>

性能优化实践

  1. 图像加载优化

    • 使用WADO Image Loader的渐进式加载
    • 实现图像缓存策略
    • 后台预加载相邻切片
  2. 内存管理

    • 及时清理不再使用的图像数据
    • 监听组件销毁事件释放资源
    javascript 复制代码
      beforeDestroy() {
        cornerstone.disable(this.viewerElement);
        
        const element = this.viewerElement;
    
        element.removeEventListener("mousedown", this.handlePanStart);
        element.removeEventListener("mousemove", this.handlePan);
        element.removeEventListener("mouseup", this.handlePanEnd);
        element.removeEventListener("mouseleave", this.handlePanEnd);
    
        element.removeEventListener("touchstart", this.handlePanStart);
        element.removeEventListener("touchmove", this.handlePan);
        element.removeEventListener("touchend", this.handlePanEnd);
      },
  3. 渲染性能优化

    • 使用requestAnimationFrame优化动画
    • 减少不必要的重绘
    • 合理设置Canvas尺寸

文章总结与展望

本文介绍了一个基于Web技术的DICOM医学影像查看器的完整实现方案,核心功能包括:

  1. 患者信息管理与检索
  2. DICOM图像加载与渲染
  3. 丰富的图像交互功能(平移、缩放、重置)
  4. 响应式用户界面

未来扩展方向:

  • 添加标注和测量工具
  • 实现多平面重建(MPR)功能
  • 集成AI辅助诊断
  • 支持DICOM影像的导出和分享

通过现代Web技术实现的DICOM查看器,不仅具备了传统桌面应用的核心功能,还拥有更好的可访问性和易用性,为医疗信息化建设提供了新的解决方案。

附录

关键依赖库版本

  • Cornerstone-core: ^2.6.1
  • Cornerstone-wado-image-loader: ^4.1.1
  • Vue: ^2.6.14
  • Element UI: ^2.15.9

参考资料

  1. Cornerstone.js官方文档
  2. DICOM标准官方文档
  3. Web医疗影像处理最佳实践
相关推荐
DoraBigHead11 分钟前
你写前端按钮,他们扛服务器压力:搞懂后端那些“黑话”!
前端·javascript·架构
前端世界1 小时前
鸿蒙UI开发全解:JS与Java双引擎实战指南
javascript·ui·harmonyos
Xiaouuuuua1 小时前
一个简单的脚本,让pdf开启夜间模式
java·前端·pdf
@Dream_Chaser1 小时前
uniapp ruoyi-app 中使用checkbox 无法选中问题
前端·javascript·uni-app
深耕AI1 小时前
【教程】在ubuntu安装Edge浏览器
前端·edge
倔强青铜三1 小时前
苦练Python第4天:Python变量与数据类型入门
前端·后端·python
倔强青铜三2 小时前
苦练Python第3天:Hello, World! + input()
前端·后端·python
上单带刀不带妹2 小时前
JavaScript中的Request详解:掌握Fetch API与XMLHttpRequest
开发语言·前端·javascript·ecmascript
倔强青铜三2 小时前
苦练Python第2天:安装 Python 与设置环境
前端·后端·python
ningmengjing_2 小时前
在 PyCharm 中安装并配置 Node.js 的指南
开发语言·javascript·ecmascript