分類: 網頁開發

VTK.js 網頁顯示 DICOM 3D 生醫影像程式開發流程教學

本篇介紹如何使用 VTK.js 在網頁上顯示三維的 DICOM 生醫 volume 影像。

VTK.js 是 JavaScript 版本的 VTK 函式庫,架構與概念都跟傳統的 VTK 函式庫非常類似,而且功能也都非常齊全,用來再往業上顯示 3D 的科學或生醫影像非常好用。


以下介紹如何從無到有,利用 VTK.js 在網頁上顯示 3D 的 DICOM醫學影像。

基本 VTK.js 應用程式架構

在使用 VTK.js 撰寫程式之前,要建立基本的 VTK.js 應用程式專案架構。首先建立新的專案目錄(名稱可隨意取):

# 建立專案目錄
mkdir vtkjs_dicom

進入專案目錄,初始化專案:

# 初始化專案
cd vtkjs_dicom/
npm init

在執行 npm 初始化專案時,會需要輸入一連串的設定值,除了 entry point(預設為 index.js)要改為 src/index.js 之外,其餘都用預設值即可,產生的 package.json 內容會像這樣:

{
  "name": "vtkjs_dicom",
  "version": "1.0.0",
  "description": "",
  "main": "src/index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

在專案目錄中,安裝 VTK.js 與相關必要套件:

# 安裝 VTK.js 與相關必要套件
npm install vtk.js --save
npm install kw-web-suite --save-dev

接著在 package.json 中加入下設定:

"scripts": {
  "build": "webpack --progress --colors --mode development",
  "build:release": "webpack --progress --colors --mode production",
  "start": "webpack-dev-server --content-base ./dist",

  "commit": "git cz",
  "semantic-release": "semantic-release"
}

建立 webpack.config.js 設定檔,內容如下:

var path = require('path');
var webpack = require('webpack');
var vtkRules = require('vtk.js/Utilities/config/dependency.js').webpack.core.rules;

// 載入 *.css 與 *.module.css 檔案
var cssRules = require('vtk.js/Utilities/config/dependency.js').webpack.css.rules;

var entry = path.join(__dirname, './src/index.js');
const sourcePath = path.join(__dirname, './src');
const outputPath = path.join(__dirname, './dist');

module.exports = {
  entry,
  output: {
    path: outputPath,
    filename: 'MyWebApp.js',
  },
  module: {
    rules: [
        { test: /.html$/, loader: 'html-loader' },
    ].concat(vtkRules, cssRules),
  },
  resolve: {
    modules: [
      path.resolve(__dirname, 'node_modules'),
      sourcePath,
    ],
  },
};

最後建立放置原始碼的 src 目錄,以及放置發布網頁的 dist 目錄:

# 建立原始碼與發布網頁的目錄
mkdir src
mkdir dist

這樣就完成基本的 VTK.js 開發環境與架構了,在開發 VTK.js 網頁應用程式時,所有的原始碼都放在 src 目錄裡面,而編譯出來的結果則會放在 dist 目錄之下。

VTK.js 顯示 Volume 影像

參考 VTK.js 官方的 VolumeMapper 範例,建立一個簡單版的 volume mapper 應用程式如下,將這一段 JavaScript 程式碼儲存為 src/index.js

import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction';
import vtkFullScreenRenderWindow from 'vtk.js/Sources/Rendering/Misc/FullScreenRenderWindow';
import vtkXMLImageDataReader from 'vtk.js/Sources/IO/XML/XMLImageDataReader';
import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction';
import vtkVolume from 'vtk.js/Sources/Rendering/Core/Volume';
import vtkVolumeMapper from 'vtk.js/Sources/Rendering/Core/VolumeMapper';

// 標準 VTK 程式架構
const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({
  background: [0.1, 0.1, 0.1], // 背景顏色
});
const renderer = fullScreenRenderer.getRenderer();
const renderWindow = fullScreenRenderer.getRenderWindow();
const reader = vtkXMLImageDataReader.newInstance();
const actor = vtkVolume.newInstance();
const mapper = vtkVolumeMapper.newInstance();
actor.setMapper(mapper);
mapper.setInputConnection(reader.getOutputPort());

// 建立色彩與透明度函數
const ctfun = vtkColorTransferFunction.newInstance();
ctfun.addRGBPoint(0, 0, 0, 0);
ctfun.addRGBPoint(255, 1.0, 1.0, 1.0);
const ofun = vtkPiecewiseFunction.newInstance();
ofun.addPoint(50.0, 0.0);
ofun.addPoint(255.0, 1.0);

// 設定色彩與透明度函數
actor.getProperty().setRGBTransferFunction(0, ctfun);
actor.getProperty().setScalarOpacity(0, ofun);

// 設定影像載入器
reader.setUrl(`data/headsq.vti`).then(() => {
  reader.loadData().then(() => {
    renderer.addVolume(actor);
    const interactor = renderWindow.getInteractor();

    // 設定旋轉、縮放等動作時,畫面更新頻率
    interactor.setDesiredUpdateRate(15.0);

    renderer.resetCamera();

    // 設定 Camera 初始角度
    renderer.getActiveCamera().zoom(1.3);
    renderer.getActiveCamera().elevation(70);

    renderWindow.render();
  });
});

// -----------------------------------------------------
// 將一些變數儲存為全域變數,可讓開發者直接在瀏覽器中
// 查看變數內容,方便除錯。
// -----------------------------------------------------
global.source = reader;
global.mapper = mapper;
global.actor = actor;
global.ctfun = ctfun;
global.ofun = ofun;
global.renderer = renderer;
global.renderWindow = renderWindow;

這裡為了一開始開發方便,我先把 HttpDataSetReader 換成 XMLImageDataReader,這樣就可以直接拿一般的 vti 影像來測試,不用在這時候處理影像轉檔,先確認基本的程式是可以正常執行的。

設定影像載入器的部份,我直接將測試用的 vti 影像的檔案名稱寫在程式碼之中,這裡用的是 VTK 官方的 headsq.vti 測試影像(也可以自己更換),而這個檔案請自己放在 dist 目錄下對應的位置,以本例來說就是 dist/data/headsq.vti

接著建立 dist/index.html 網頁檔,原始碼如下:

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
  </head>
  <body>
    <script type="text/javascript" src="MyWebApp.js"></script>
  </body>
</html>

在專案目錄中,以 npm 編譯程式:

# 編譯程式
npm run build

若編譯成功,就可以啟動開發用網頁伺服器,觀看結果:

# 啟動開發用網頁伺服器
npm start

開發用網頁伺服器預設會開在 http://localhost:8080/,以瀏覽器打開這個網址就可以看到 volume 的影像了,而這個 3D 的 volume 影像可以自由旋轉或縮放,至於顏色與透明度等屬性,可以自己從程式中慢慢調整。

開發 VTK.js 程式

npm start 執行的期間,會自動檢查原始碼有無更動,必要時會自動更新結果,所以開發者可以一邊修改原始碼,一邊查看即時更新的網頁結果,這個功能非常好用,可以讓開發過程更順暢。

HttpDataSet 影像格式

HttpDataSetReader 是 VTK.js 專門為網頁所設計的影像載入器,其採用的 HttpDataSet 影像格式很特殊,每一個影像都是以一個 JSON metadata 檔案配合對應的二進位檔來儲存,這種設計可以提升影像透過網頁載入的速度,而缺點就是一般的影像都需要經過格式的轉換之後,才能放進來使用。

若要將一般的影像轉換為 HttpDataSet 格式,可以使用 VTK.js 所提供的轉檔工具來處理,不過使用之前要先安裝 Paraview 這套軟體,安裝好 Paraview 之後,利用 Paraview 的 pvpython 來執行 VTK.js 所提供的 vtk-data-converter.py 指令稿,即可進行格式的轉換:

# 將 VTI 影像檔轉換為 HttpDataSet 格式
pvpython -dr node_modules/vtk.js/Utilities/DataGenerator/vtk-data-converter.py 
  --input dist/data/headsq.vti 
  --output dist/data/HttpDataSet

VTK.js 還有提供另外一個 JavaScript 版本的轉換工具,不過它內部其實也是呼叫 vtk-data-converter.py,所以效果是一樣的:

# 將 VTI 影像檔轉換為 HttpDataSet 格式
node node_modules/vtk.js/Utilities/DataGenerator/convert-cli.js 
  --input dist/data/headsq.vti 
  --output dist/data/HttpDataSet 
  --paraview ~/ParaView-5.7.0-MPI-Linux-Python3.7-64bit/

將影像格式轉換為 HttpDataSet 之後,接著就可以修改 src/index.js,將 XMLImageDataReader 改為 HttpDataSetReader,直接載入 HttpDataSet 格式的影像:

import vtkColorTransferFunction from 'vtk.js/Sources/Rendering/Core/ColorTransferFunction';
import vtkFullScreenRenderWindow from 'vtk.js/Sources/Rendering/Misc/FullScreenRenderWindow';
import vtkHttpDataSetReader from 'vtk.js/Sources/IO/Core/HttpDataSetReader'; // 使用 HttpDataSetReader
import vtkPiecewiseFunction from 'vtk.js/Sources/Common/DataModel/PiecewiseFunction';
import vtkVolume from 'vtk.js/Sources/Rendering/Core/Volume';
import vtkVolumeMapper from 'vtk.js/Sources/Rendering/Core/VolumeMapper';

// 標準 VTK 程式架構
const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({
  background: [0.1, 0.1, 0.1], // 背景顏色
});
const renderer = fullScreenRenderer.getRenderer();
const renderWindow = fullScreenRenderer.getRenderWindow();

// 建立 HttpDataSetReader
const reader = vtkHttpDataSetReader.newInstance({
  fetchGzip: true // 讀取 Gzip 壓縮的影像
});

const actor = vtkVolume.newInstance();
const mapper = vtkVolumeMapper.newInstance();
actor.setMapper(mapper);
mapper.setInputConnection(reader.getOutputPort());

// 建立色彩與透明度函數
const ctfun = vtkColorTransferFunction.newInstance();
ctfun.addRGBPoint(0, 0, 0, 0);
ctfun.addRGBPoint(255, 1.0, 1.0, 1.0);
const ofun = vtkPiecewiseFunction.newInstance();
ofun.addPoint(50.0, 0.0);
ofun.addPoint(255.0, 1.0);

// 設定色彩與透明度函數
actor.getProperty().setRGBTransferFunction(0, ctfun);
actor.getProperty().setScalarOpacity(0, ofun);

// 設定影像載入器
reader.setUrl(`data/HttpDataSet/headsq.vti`).then(() => { // 載入 HttpDataSet 格式的影像
  reader.loadData().then(() => {
    renderer.addVolume(actor);
    const interactor = renderWindow.getInteractor();

    // 設定旋轉、縮放等動作時,畫面更新頻率
    interactor.setDesiredUpdateRate(15.0);

    renderer.resetCamera();

    // 設定 Camera 初始角度
    renderer.getActiveCamera().zoom(1.3);
    renderer.getActiveCamera().elevation(70);

    renderWindow.render();
  });
});

// -----------------------------------------------------
// 將一些變數儲存為全域變數,可讓開發者直接在瀏覽器中
// 查看變數內容,方便除錯。
// -----------------------------------------------------
global.source = reader;
global.mapper = mapper;
global.actor = actor;
global.ctfun = ctfun;
global.ofun = ofun;
global.renderer = renderer;
global.renderWindow = renderWindow;

完成之後,網頁的畫面不會有任何改變,不過載入的速度會快非常多(跟 XMLImageDataReader 比較起來,HttpDataSetReader 載入影像大約只需要一半的時間)。

顯示 3D DICOM 影像

若要使用 VTK.js 顯示 3D 的 DICOM 影像,就必須把 DICOM 影像先轉為 HttpDataSet,雖然 Paraview 本身有支援 DICOM 的讀取器,但是似乎很容易出問題,所以建議可以先將 DICOM 影像使用 Bio-Formats 或 ImageJ 等軟體轉為 Tiff,再將 Tiff 以 Paraview 轉為 HttpDataSet。

DICOM 轉為 Tiff

要將 DICOM 轉為 Tiff,可以直接使用 Bio-Formats 的指令工具來轉換:

# 使用 Bio-Formats 直接將 DICOM 轉為 Tiff
bfconvert -compression LZW /path/to/image.dcm /path/to/image.tiff

Bio-Formats 雖然很方便,但是我自己測試的時候,發現轉出來的影像中,後設資料(metadata)的 voxel size 屬性怪怪的,所以後來又改用 ImageJ 加上 Bio-Formats 套件來轉,這樣可以保留比較多 DICOM 的後設資料,以下是以 ImageJ 轉換影像的 JavaScript 指令稿 convert.js

// 引入 ImageJ 套件
importClass(Packages.ij.IJ);

// 使用 Bio-Formats 匯入 DICOM 影像
IJ.run("Bio-Formats Importer", "open=/path/to/image.dcm autoscale color_mode=Default rois_import=[ROI manager] view=Hyperstack stack_order=XYCZT");

// 以 Tiff 格式匯出
imp = IJ.getImage();
IJ.saveAs(imp, "Tiff", "/path/to/image.tiff");

// 關閉影像
imp.close();

// 離開 ImageJ
IJ.run("Quit");

只要在命令列執行這個 ImageJ 的指令稿,就可以將 DICOM 轉換為 Tiff(這裡我是使用 Fiji 版本的 ImageJ,安裝比較方便):

# 執行 ImageJ 指令稿
ImageJ-linux64 --ij2 --run convert.js

Tiff 轉為 HttpDataSet

接著再將 Tiff 以 Paraview 轉為 HttpDataSet 格式:

# 將 Tiff 影像檔轉換為 HttpDataSet 格式
pvpython -dr node_modules/vtk.js/Utilities/DataGenerator/vtk-data-converter.py 
  --input /path/to/image.tiff 
  --output dist/data/HttpDataSet

最後在修改一下 index.js 的影像路徑,即可將 DICOM 的影像內容顯示在網頁上了。

DICOM 影像內容

發佈網頁

在開發完成之後,若要發佈完成的網頁,要先將程式編譯成釋出版本:

# 將程式編譯成釋出版本
npm run build:release

然後再將 dist 目錄整個複製到網頁伺服器上面就可以了,另外也要注意正式伺服器上面的檔案路徑是否跟開發環境吻合,否則會出現找不到檔案的問題。

參考資料:Research Gate

G. T. Wang

個人使用 Linux 經驗長達十餘年,樂於分享各種自由軟體技術與實作文章。

Share
Published by
G. T. Wang

Recent Posts

光陽 KYMCO GP 125 機車接電發動、更換電瓶記錄

本篇記錄我的光陽 KYMCO ...

2 年 ago

[開箱] YubiKey 5C NFC 實體金鑰

本篇是 YubiKey 5C ...

2 年 ago

[DIY] 自製竹火把

本篇記錄我拿竹子加上過期的苦茶...

3 年 ago