将 OHIF 医学影像查看器集成到微信小程序:从架构到实战

96 阅读4分钟

将 OHIF 医学影像查看器集成到微信小程序:从架构到实战

本文深度解析如何将开源医学影像查看器 OHIF 集成到微信小程序,实现医生随时随地查看患者影像的需求。

一、背景与需求

1.1 业务场景

医生需要:

  • 📱 移动端快速查看患者影像
  • 🏥 会诊时随时调阅历史影像
  • 🚑 急诊场景下的远程诊断
  • 👥 多学科协作时的影像共享

1.2 OHIF 简介

OHIF 是开源医学影像查看器,基于:

  • React + Cornerstone3D
  • DICOMweb 标准
  • 插件化架构

二、技术方案对比

方案一:WebView 嵌入(快速但受限)

实现:

<web-view src="https://your-domain.com/ohif/viewer?StudyInstanceUID=xxx"></web-view>

优点: 快速、复用现有功能 缺点: 无法使用小程序 API、性能受限

方案二:混合架构(推荐)

架构:

小程序原生页面(患者列表、影像列表)
         ↓
    WebView(影像查看器)
         ↓
    中间层 API(认证、缓存、转换)
         ↓
    PACS/DICOMweb 服务器

三、推荐方案详细设计

3.1 前端层(小程序)

// pages/patient-list/index.js
Page({
  data: { patients: [] },
  
  async onLoad() {
    const patients = await wx.cloud.callFunction({
      name: 'getPatients'
    });
    this.setData({ patients: patients.result.data });
  },
  
  viewStudies(e) {
    const { patientId } = e.currentTarget.dataset;
    wx.navigateTo({
      url: `/pages/study-list/index?patientId=${patientId}`
    });
  }
});
// pages/study-list/index.js  
Page({
  async onLoad(options) {
    const studies = await this.fetchStudies(options.patientId);
    this.setData({ studies });
  },
  
  openViewer(e) {
    const { studyInstanceUID } = e.currentTarget.dataset;
    const token = wx.getStorageSync('token');
    const url = `https://your-domain.com/viewer?` +
      `StudyInstanceUID=${studyInstanceUID}&token=${token}`;
    
    wx.navigateTo({
      url: `/pages/viewer/index?url=${encodeURIComponent(url)}`
    });
  }
});

3.2 中间层 API

// server/routes/api.js
const express = require('express');
const router = express.Router();

// 影像列表接口
router.get('/api/studies', authenticateToken, async (req, res) => {
  const { patientId } = req.query;
  
  // 调用 PACS DICOMweb 接口
  const response = await axios.get(
    `${PACS_URL}/dicomweb/studies`,
    {
      params: { PatientID: patientId },
      headers: { 'Accept': 'application/dicom+json' }
    }
  );
  
  // 数据转换
  const studies = response.data.map(study => ({
    studyInstanceUID: study['0020000D'].Value[0],
    studyDate: study['00080020']?.Value[0],
    studyDescription: study['00081030']?.Value[0],
    modality: study['00080060']?.Value[0]
  }));
  
  res.json({ success: true, data: studies });
});

// 影像数据代理
router.get('/api/wado/*', authenticateToken, async (req, res) => {
  const wadoPath = req.params[0];
  const response = await axios.get(
    `${PACS_URL}/dicomweb/${wadoPath}`,
    { responseType: 'arraybuffer' }
  );
  res.set(response.headers);
  res.send(response.data);
});

3.3 OHIF 配置

// ohif-config.js
window.config = {
  showStudyList: false,
  
  dataSources: [{
    namespace: '@ohif/extension-default.dataSourcesModule.dicomweb',
    sourceName: 'miniprogram',
    configuration: {
      qidoRoot: 'https://your-api.com/api/qido',
      wadoRoot: 'https://your-api.com/api/wado',
      enableStudyLazyLoad: true,
      
      onConfiguration: (config, { query }) => {
        const token = query.get('token');
        if (token) {
          config.headers = { 'Authorization': `Bearer ${token}` };
        }
        return config;
      }
    }
  }],
  
  maxNumberOfWebWorkers: 2 // 移动端优化
};

四、核心难点与解决方案

4.1 快速定位患者影像

解决方案:

  1. 智能搜索
async onSearch(keyword) {
  const results = await wx.cloud.callFunction({
    name: 'searchPatients',
    data: {
      keyword,
      searchFields: ['name', 'patientId', 'admissionNumber'],
      fuzzyMatch: true
    }
  });
  this.setData({ searchResults: results.data });
}
  1. 扫码快速打开
async scanQRCode() {
  const res = await wx.scanCode();
  const patientInfo = JSON.parse(res.result);
  this.openPatientStudies(patientInfo);
}
  1. 收藏与最近查看
// 收藏患者
await wx.cloud.database().collection('favorites').add({
  data: { doctorId, patientId, createTime: new Date() }
});

// 记录最近查看
await wx.cloud.database().collection('recent').add({
  data: { doctorId, patientId, studyUID, viewTime: new Date() }
});

4.2 大文件传输优化

分层加载策略:

class LayeredImageLoader {
  // 第一层:缩略图
  async loadLevel1() {
    const thumbnails = await this.fetchThumbnails();
    return thumbnails;
  }
  
  // 第二层:预览质量
  async loadLevel2(seriesUID) {
    const mediumQuality = await this.fetchImages(seriesUID, 'medium');
    return mediumQuality;
  }
  
  // 第三层:全分辨率
  async loadLevel3(instanceUID) {
    if (this.cache.has(instanceUID)) {
      return this.cache.get(instanceUID);
    }
    const fullRes = await this.fetchFullResolution(instanceUID);
    this.cache.set(instanceUID, fullRes);
    return fullRes;
  }
}

缓存策略:

class CacheManager {
  constructor() {
    this.maxSize = 100 * 1024 * 1024; // 100MB
  }
  
  async set(key, data) {
    if (this.needCleanup(data)) {
      await this.cleanupLRU();
    }
    await wx.setStorage({ key, data });
  }
  
  async cleanupLRU() {
    // 清理最久未使用的缓存
    const entries = this.getSortedEntries();
    for (const [key] of entries) {
      await wx.removeStorage({ key });
      if (this.hasEnoughSpace()) break;
    }
  }
}

4.3 触摸交互适配

class TouchGestureAdapter {
  handleTouchStart(e) {
    const touches = e.touches;
    if (touches.length === 1) {
      this.startPan(touches[0]); // 单指平移
    } else if (touches.length === 2) {
      this.startPinch(touches); // 双指缩放
    }
  }
  
  startPan(touch) {
    this.lastX = touch.clientX;
    this.lastY = touch.clientY;
  }
  
  updatePan(touch) {
    const deltaX = touch.clientX - this.lastX;
    const deltaY = touch.clientY - this.lastY;
    cornerstone.pan(this.element, { x: deltaX, y: deltaY });
  }
  
  startPinch(touches) {
    this.initialDistance = this.getDistance(touches[0], touches[1]);
  }
  
  updatePinch(touches) {
    const currentDistance = this.getDistance(touches[0], touches[1]);
    const scale = currentDistance / this.initialDistance;
    cornerstone.zoom(this.element, scale);
  }
}

4.4 数据安全

认证流程:

// 1. 小程序登录
wx.login({
  success: async (res) => {
    const { token } = await wx.cloud.callFunction({
      name: 'login',
      data: { code: res.code }
    });
    wx.setStorageSync('token', token);
  }
});

// 2. API 请求带 token
const response = await fetch(url, {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

// 3. 服务端验证
function authenticateToken(req, res, next) {
  const token = req.headers['authorization']?.split(' ')[1];
  if (!token) return res.sendStatus(401);
  
  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) return res.sendStatus(403);
    req.user = user;
    next();
  });
}

数据加密:

// 敏感数据加密传输
const crypto = require('crypto');

function encryptData(data) {
  const cipher = crypto.createCipher('aes-256-cbc', SECRET_KEY);
  let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex');
  encrypted += cipher.final('hex');
  return encrypted;
}

function decryptData(encrypted) {
  const decipher = crypto.createDecipher('aes-256-cbc', SECRET_KEY);
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
  return JSON.parse(decrypted);
}

五、性能优化

5.1 网络优化

// 请求合并
class RequestBatcher {
  constructor() {
    this.queue = [];
    this.timer = null;
  }
  
  add(request) {
    this.queue.push(request);
    this.scheduleFlush();
  }
  
  scheduleFlush() {
    if (this.timer) return;
    this.timer = setTimeout(() => {
      this.flush();
    }, 50);
  }
  
  async flush() {
    const batch = this.queue.splice(0);
    await this.executeBatch(batch);
    this.timer = null;
  }
}

// 并发控制
class ConcurrencyController {
  constructor(maxConcurrency = 5) {
    this.max = maxConcurrency;
    this.running = 0;
    this.queue = [];
  }
  
  async run(fn) {
    while (this.running >= this.max) {
      await this.waitForSlot();
    }
    this.running++;
    try {
      return await fn();
    } finally {
      this.running--;
      this.processQueue();
    }
  }
}

5.2 渲染优化

// 虚拟滚动
class VirtualScroller {
  constructor(container, itemHeight) {
    this.container = container;
    this.itemHeight = itemHeight;
    this.visibleCount = Math.ceil(container.height / itemHeight) + 2;
  }
  
  render(scrollTop, totalItems) {
    const startIndex = Math.floor(scrollTop / this.itemHeight);
    const endIndex = Math.min(startIndex + this.visibleCount, totalItems);
    
    return {
      startIndex,
      endIndex,
      offsetY: startIndex * this.itemHeight
    };
  }
}

// 帧率控制
function throttleRender(fn, fps = 30) {
  const interval = 1000 / fps;
  let lastTime = 0;
  
  return function(...args) {
    const now = Date.now();
    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

六、部署方案

6.1 服务端部署

# docker-compose.yml
version: '3'
services:
  api:
    image: node:18
    volumes:
      - ./server:/app
    ports:
      - "3000:3000"
    environment:
      - PACS_URL=http://pacs:8042
      - REDIS_URL=redis://redis:6379
  
  redis:
    image: redis:7
    ports:
      - "6379:6379"
  
  nginx:
    image: nginx:latest
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
      - ./ohif-dist:/usr/share/nginx/html
    ports:
      - "80:80"
      - "443:443"

七、总结

实施步骤

  1. 第一阶段(1-2周): 搭建基础架构

    • 部署 PACS 服务器
    • 开发中间层 API
    • 配置 OHIF
  2. 第二阶段(2-3周): 开发小程序

    • 患者列表页
    • 影像列表页
    • WebView 集成
  3. 第三阶段(1-2周): 优化与测试

    • 性能优化
    • 安全加固
    • 用户测试

技术栈总结

前端:

  • 小程序原生框架
  • OHIF Viewer
  • Cornerstone3D

后端:

  • Node.js / Python
  • Redis(缓存)
  • Nginx(反向代理)

存储:

  • PACS(Orthanc / DCM4CHEE)
  • 云存储(OSS / S3)

关键指标

  • 首屏加载:< 3秒
  • 影像切换:< 500ms
  • 缓存命中率:> 80%
  • 并发支持:> 100 用户

作者: [whm]

开源地址:github.com/OHIF/Viewer…

标签: #医学影像 #OHIF #微信小程序 #DICOMweb