本篇介紹如何使用 VTK.js 在網頁上顯示三維的 DICOM 生醫 volume 影像。
VTK.js 是 JavaScript 版本的 VTK 函式庫,架構與概念都跟傳統的 VTK 函式庫非常類似,而且功能也都非常齊全,用來再往業上顯示 3D 的科學或生醫影像非常好用。
在使用 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 官方的 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 影像可以自由旋轉或縮放,至於顏色與透明度等屬性,可以自己從程式中慢慢調整。
在 npm start
執行的期間,會自動檢查原始碼有無更動,必要時會自動更新結果,所以開發者可以一邊修改原始碼,一邊查看即時更新的網頁結果,這個功能非常好用,可以讓開發過程更順暢。
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
載入影像大約只需要一半的時間)。
若要使用 VTK.js 顯示 3D 的 DICOM 影像,就必須把 DICOM 影像先轉為 HttpDataSet,雖然 Paraview 本身有支援 DICOM 的讀取器,但是似乎很容易出問題,所以建議可以先將 DICOM 影像使用 Bio-Formats 或 ImageJ 等軟體轉為 Tiff,再將 Tiff 以 Paraview 轉為 HttpDataSet。
要將 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 以 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 的影像內容顯示在網頁上了。
在開發完成之後,若要發佈完成的網頁,要先將程式編譯成釋出版本:
# 將程式編譯成釋出版本
npm run build:release
然後再將 dist
目錄整個複製到網頁伺服器上面就可以了,另外也要注意正式伺服器上面的檔案路徑是否跟開發環境吻合,否則會出現找不到檔案的問題。
參考資料:Research Gate