如何实现图像金字塔及标注功能。

3,946 阅读4分钟

前言:

在公司的一个病理图像标注的过程中,需要实现超过 2G 大小的图像的加载,以及标注功能。以下代码均使用 React Hook 书写,如有其他框架需求,也可进行参考。

主要技术:

  • Fabric.js 用于标注。
  • OpenSeadragon 用于金字塔图像加载。

技术介绍

OpenSeadragon

一种基于 Web 的开源查看器,用于高分辨率可缩放图像,在纯 JavaScript 中实现,用于桌面和移动设备。

具体使用可参考官网: openseadragon.github.io/

Fabric.js

有写过一个关于 Fabric.js 官网整体介绍的文档,就不挪过来,可自行选择是否进行查看。

官网介绍:note.youdao.com/s/XqLk2oSs

使用介绍(vite 版本):github.com/obf1313/fab…

前期准备

库文件准备

  1. 打开 OpenSeadragon 官网:openseadragon.github.io/
  2. 找到该文件,下载并解压:

image.png

图片准备

可使用微软提供的 DeepZoomComposer,因为官网的下载链接我已经找不到了,这里提供一下下载地址。
链接: Deep Zoom Composer
提取码: 6trz

安装完成后新建项目,拖入图片,点击 Export: image.png

在导出的文件夹中找到 dzc_output_images 文件夹,里面的 图片名_files 就是我们所需的文件储存格式,图片名.xml 则是我们所属的图片信息,之后编程会用到。

image.png

代码实现

实例一:单独 OpenSeadragon 实现大图查看

import React, { useEffect } from 'react';
import OpenSeadragon from 'openseadragon';
// serverPath: 资源代理路径,使用 webpack devServer 进行代理
import { serverPath } from '@utils/CommonVars';

interface IFile {
  folderName: string, // 服务器上文件夹名称
  cellSize: string, // 每张切片边长,即之前 xml 文件中的 TileSize 字段
  width: string, // 原始图片宽度,即之前 xml 文件中的 width 字段
  height: string // 原始图片高度,即之前 xml 文件中的  height 字段
}

const OnlyImage = () => {
  const section: IFile = {
    folderName: 'DSI0',
    cellSize: '512',
    width: '46511',
    height: '49974'
  };
  useEffect(() => {
    initOpenSeaDragon();
  }, []);
  // 初始化 openSeadragon
  const initOpenSeaDragon = () => {
    if (section) {
      OpenSeadragon({
        id: 'openSeaDragon',
        // 装有各种按钮名称的文件夹 images 地址,即库文件中的 images 文件夹
        prefixUrl: serverPath + '/images/',
        // 是否显示导航窗口
        showNavigator: true,
        // 以下都是导航配置
        navigatorAutoFade: false,
        navigatorPosition: 'ABSOLUTE',
        navigatorTop: 0,
        navigatorLeft: 0,
        navigatorHeight: '90px',
        navigatorWidth: '200px',
        navigatorBackground: '#fefefe',
        navigatorBorderColor: '#191970',
        navigatorDisplayRegionColor: '#FF0000',
        // 具体图像配置
        tileSources: {
          Image: {
            // 指令集
            xmlns: 'http://schemas.microsoft.com/deepzoom/2009',
            Url: serverPath + section.folderName + '/',
            // 相邻图片直接重叠的像素值
            Overlap: '1',
            // 每张切片的大小
            TileSize: section.cellSize,
            Format: 'jpg',
            Size: {
              Width: section.width,
              Height: section.height
            }
          },
        },
        // 至少 20% 显示在可视区域内
        visibilityRatio: 0.2,
        // 开启调试模式
        // debugMode : true,
        // 是否允许水平拖动
        panHorizontal: true,
        // 初始化默认放大倍数,按home键也返回该层
        // defaultZoomLevel: 5,
        // 最小允许放大倍数
        minZoomLevel: 0.4,
        // 最大允许放大倍数
        maxZoomLevel: 40,
        zoomInButton: 'zoom-in',
        zoomOutButton: 'zoom-out',
        // 设置鼠标单击不可放大
        gestureSettingsMouse: {
          clickToZoom: false
        }
      });
    }
  };
  return (
    <div id="openSeaDragon" style={{ width: '100%', height: 'calc(100vh - 60px)' }} />
  );
};
export default OnlyImage;

实现效果如下:

动画.gif

实例二:OpenSeadragon 结合 Fabric.js 完成图像标注

使用 openSeadragon 上一个实例已经说了,这个实例主要讲讲 fabric.js 结合使用做标注的功能。

  1. 初始化画布
// 初始化画布
const initCanvas = () => {
  // 创建 canvas 画布容器
  canvasDiv = document.createElement('div');
  canvasDiv.style.position = 'absolute';
  canvasDiv.style.left = canvasDiv.style.top = '0';
  canvasDiv.style.width = canvasDiv.style.height = '100%';
  // 将容器放进 openSeadragon 中
  openSeadragon.canvas.appendChild(canvasDiv);
  // 创建画布
  myCanvas = document.createElement('canvas');
  myCanvas.setAttribute('id', 'canvas');
  // 将画布放入 画布容器中
  canvasDiv.appendChild(myCanvas);
  resize();
  fabricCanvas = new fabric.Canvas('canvas', { selection: false });
  // 设置笔刷颜色和宽度
  fabricCanvas.freeDrawingBrush.color = pencilColor;
  fabricCanvas.freeDrawingBrush.width = pencilWidth;
  // 设置 openSeadragon 事件监听
  openSeadragon.addHandler('update-viewport', resize);
  openSeadragon.addHandler('open', resize);
  // 设置 fabric 事件监听
  mouseDown();
  mouseMove();
  mouseUp();
  onSelectObject();
  // 注入批注数据
  getAnnotate();
};
  1. 画布改变监听
// 改变画布
const resize = () => {
  let width = openSeadragon.container.clientWidth;
  let height = openSeadragon.container.clientHeight;
  setCanvasShape([width, height]);
  canvasDiv.setAttribute('width', width);
  myCanvas.setAttribute('width', width);
  canvasDiv.setAttribute('height', height);
  myCanvas.setAttribute('height', height);
};
  1. fabric 事件监听
// 鼠标点击
const mouseDown = () => {
  fabricCanvas.on('mouse:down', (options: any) => {
    if (!annotationView && !ifSelectObj) {
      let offsetX = fabricCanvas.calcOffset().viewportTransform[4];
      let offsetY = fabricCanvas.calcOffset().viewportTransform[5];
      const x: number = Math.round(options.e.offsetX - offsetX);
      const y: number = Math.round(options.e.offsetY - offsetY);
      mouseFrom.x = x;
      mouseFrom.y = y;
      doDrawing = true;
    }
  });
};
// 鼠标移动
const mouseMove = () => {
  fabricCanvas.on('mouse:move', (options: any) => {
    if (selectPencil && doDrawing && !ifSelectObj && !annotationView) {
      if (moveCount % 2 && !doDrawing) {
        return;
      }
      moveCount++;
      let offsetX = fabricCanvas.calcOffset().viewportTransform[4];
      let offsetY = fabricCanvas.calcOffset().viewportTransform[5];
      const x = Math.round(options.e.offsetX - offsetX);
      const y = Math.round(options.e.offsetY - offsetY);
      mouseTo.x = x;
      mouseTo.y = y;
      drawing(x, y);
    }
  });
};
// 鼠标抬起
const mouseUp = () => {
  fabricCanvas.on('mouse:up', (options: any) => {
    let offsetX = fabricCanvas.calcOffset().viewportTransform[4];
    let offsetY = fabricCanvas.calcOffset().viewportTransform[5];
    mouseTo.x = Math.round(options.e.offsetX - offsetX);
    mouseTo.y = Math.round(options.e.offsetY - offsetY);
    if (currCanvasObject) {
      if (Math.abs(currCanvasObject.width) <= 1) {
        fabricCanvas.remove(currCanvasObject).renderAll();
        message.error('标注范围太小,请重新标注!');
        resetCanvasOption();
        return;
      } else if (currCanvasObject) {
        setAnnotationView(true);
      }
    }
  });
};
// 画
const drawing = (offsetX: number, offsetY: number) => {
  if (currCanvasObject) {
    // remove 仅将目前移除,clear 清除上一残留,只剩当前
    fabricCanvas.remove(currCanvasObject);
  }
  const zoom: any = openSeadragon.viewport.getZoom(true);
  let canZoom: any = openSeadragon.viewport.viewportToImageZoom(zoom);
  let canvasObject: any = null;
  let left: number = mouseFrom.x;
  let top: number = mouseFrom.y;
  const radius = Math.sqrt((mouseTo.x - left) * (mouseTo.x - left) + (mouseTo.y - top) * (mouseTo.y - top)) / canZoom;
  const commonParams = {
    stroke: pencilColor,
    strokeWidth: pencilWidth,
    selectionBackgroundColor: 'rgba(0, 0, 0, 0.25)',
    fill: 'rgba(255, 255, 255, 0)'
  };
  switch (selectPencil) {
    case EPencilType.circle:
      canvasObject = new fabric.Circle({
        left: left / canZoom,
        top: top / canZoom,
        originX: 'center',
        originY: 'center',
        radius: radius,
        hasControls: true,
        ...commonParams
      });
      break;
    case EPencilType.rectangle:
      canvasObject = new fabric.Rect({
        top: mouseFrom.y / canZoom,
        left: mouseFrom.x / canZoom,
        width: (mouseTo.x - mouseFrom.x) / canZoom,
        height: (mouseTo.y - mouseFrom.y) / canZoom,
        ...commonParams
      });
      break;
    case EPencilType.polygon:
      lineList.push({
        x: offsetX / canZoom,
        y: offsetY / canZoom
      });
      canvasObject = new fabric.Polygon(lineList, {
        ...commonParams
      });
      break;
    default:
      break;
  }
  if (canvasObject) {
    currCanvasObject = canvasObject;
    selectObj = currCanvasObject;
    fabricCanvas.add(currCanvasObject);
  }
};
// 选择对象
const onSelectObject = () => {
  fabricCanvas.on('selection:created', (options: any) => {
    if (options.target) {
      selectObj = options.target;
      ifSelectObj = true;
      resetCanvasOption();
    }
  });
};
  1. 数据保存,复现
// 获取批注数据
const getAnnotate = () => {
  const note = localStorage.getItem('markData');
  if (note) {
    // 写入 Canvas
    fabricCanvas.loadFromJSON(JSON.parse(note), () => {
      fabricCanvas.renderAll();
    });
  }
};
// 添加批注
const addAnnotate = () => {
  currCanvasObject.id = new Date().valueOf();
  fabricCanvas.renderAll();
  localStorage.setItem('markData', JSON.stringify(fabricCanvas.toJSON(['id'])));
  setAnnotationView(false);
  resetCanvasOption();
};

项目地址

具体代码实现可参考 Git 项目:
github.com/obf1313/ima…

有问题或者探讨可以评论!欢迎讨论。