cornerstone.js 使用總結
技術標籤:前端javascript
cornerstone.js 使用總結
dicom 簡介
dicom(Digital Imaging and Communications in Medicine), 醫學影像的一組協議。包含了儲存、列印、傳輸等。具體詳細介紹。官網
dicom file, 儲存包含病人、檢查、影像資訊的檔案,通常以dcm為副檔名。檔案格式
dicom file中由data elements 構成。每一個data element 都有一個tag(xxxx, xxxx)x為十六進位制數,vr資料型別,vl資料長度,資料值。dicom tags
例如CT掃描生成的影象,其CT值在tag為(7FE0,0010)的element中。
cornertone-core簡介
由javascript編寫,構建web端的醫學影像平臺中的顯示環節。對於複雜的web端的醫學影像視覺化專案,底層可以依賴於cornerstone-core做二維影象渲染。
渲染一張dicom格式的影象
影象資訊
對於一張由CT掃描的影象,要準確的渲染成一張數字影象。需要理既以下相關的 data elements
- Samples per Pixel (0028, 0002), 字面理解每個畫素的取樣數,灰度圖1, RGB圖為3
- Photometric Interpretation (0028, 0004) 顏色空間
- Planar Configuration (0028,0006), 只對於samples per pixel 大於1時起作用,主要針對rgb影象,其三個顏色成分的儲存排列。0: color by pixel, R1-G1-B1-R2-G2-B2…, 1: color By Plane, R1-R2-R3…-G1-G2…-B1-B2…
- Rows (0028, 0010), 垂直方向上向下取樣因子的倍數。簡單認為在在影象上一列畫素的總數
- Columns (0028, 0011), 類似Rows,方向為水平方向
- Bits Allocated (0028,0100) 給每個取樣的畫素值分配的儲存空間
- Bits Stored (0028,0101) 每個取樣的畫素值儲存時所佔的bit數
- High Bit (0028, 0102) 取樣畫素的最高有效位
- Pixel Representation (0028, 0103), 畫素值資料型別0:無符號整型, 1:有符號數
- Pixel Data (7FE0, 0010) 構成影象的畫素資料流,從左到右,從上到下。左上角的畫素位置記為(1, 1)
- Slice Thickness(0018, 0050), 切片的層厚
- Image Position(Patient) (0020, 0032) 影象左上角第一個畫素在病人座標系下的值
- Image Orientation(Patient) (0020, 0037) 在病人座標系下,影象的第一行與第一列的方向餘弦值。?待圖解
- Pixel Spacing (0028, 0030), 相鄰兩行(兩列)畫素在病人座標系的物理距離值
影象顯示
假定dicom file已經解析完成。所有tag資料以key/value形式作為dicom實列的屬性。
const result = parseDicomFile(url);
const dicom = {
pixels: result.pixelsData
rows: result.rows
...
}
渲染dicom影象
- "<“img src=“imageURL” />”
- "<“svg />”
- "<“canvas />”
使用canvas方式顯示,既然知道pixel data, 只需將其值對映成螢幕畫素值即可。
由於pixel值的範圍並不是螢幕畫素0-255範圍內,需要做相應的對映。modality lut, voi lut - modality lut, 畫素儲存值到實際輸出值(OD值,CT值等)的對映,output = slope(0028, 1053) * store_value + intercept(0028, 1052)
- voi lut ,對映函式由linear、linear_exact、sigmoid,其具體取值由voi lut function(0028, 1056)決定,預設值為linear. voi lut 函式的引數是windowCenter(0028, 1050)和window width(0028, 1051).對於linear形式,窗寬窗位決定了一維直線上的一段區域,落在該區域的CT值(假定)將會被線性對映到0-255區間內,在其範圍外的將被clamp
- 畫素值的對映有一定的順序,先做modality lut, 在做voi lut
const {pixels, rows, columns} = dicom;
const canvas = initCanvas();
const context = canvas.getContext('2d');
// draw
for (let i = 0; i < rows; i++) {
for (let j = 0; j < coloumns; j++) {
const value = voiLutFunc(modalityLutFunc(pixels[i * columns + j]))
context.save();
context.fillStyle(value)
context.fillRect(i, j, 1, 1);
context.restore();
}
}
太低效
canvasContext2d.putImageData(imageData, dx, dy)
imageData 物件,表示一定矩形區域內的畫素值。每個像數值由RGBA四分分量,每個分量的資料型別為Uint8ClampedArray(對於超出0-255區間內的值會被裁剪)。data屬性為Uint8ClampedArray陣列,畫素值在陣列中依次排列。
imageData物件,可以由CanvasRenderingContext2D.createImageData/getImageData建立。
//...
// 假設影象為灰度圖
const imageData = context.createImageData(rows, columns);
const storedPixels = imageData.data;
let index = 0;
for (let i = 0; i < rows; i++) {
for (let j = 0; j < coloumns; j++) {
const grayValue = voiLutFunc(modalityLutFunc(pixels[i*columns + j]))
storedPixels[index++] = grayValue; // r
storedPixels[index++] = grayValue; // g
storedPixels[index++] = grayValue; // b
storedPixels[index++] = grayValue; // a
}
}
context.putImageData(imageData, 0, 0);
影象上的每個畫素都需要做兩次lut操作,耗時也低效。對於一維的資料的視覺化,可以使用通用的lookuutable的方法,建立一個一維表將CT value對映成pixel value,不同的CT value對應不同的pixel value。其表的索引代表CT value, 該索引下的值為pixel value。 這樣在遍歷填充畫素時,畫素值直接從標準獲取不需要做計算
// create lookupTable
const {minValue, maxValue} = getMinAndMaxValueOfDicomStoredValue();
// 避免陣列內負索引
const offset = Math.min(0, minValue);
const lookupTable = new UnitClampedArray(maxValue - offset + 1);
for (let storedValue = minValue; i <= maxValue; storedValue++) {
lookupTable[storedValue - offset] = voiLutFunc(modalityLutFunc(storedValue))
}
// store pixels
const imageData = context.createImageData(rows, columns);
const storedPixels = imageData.data;
let index = 0;
let start = 0;
const pixelsNum = storedPixels.length;
if (minValue < 0) {
while (index < pixelsNum) {
const grayValue = lookupTable[pixels[start++] - minValue];
storedPixels[index++] = grayValue; // r
storedPixels[index++] = grayValue; // g
storedPixels[index++] = grayValue; // b
storedPixels[index++] = grayValue; // a
}
} else {
while (index < pixelsNum) {
const grayValue = lookupTable[pixels[start++]];
storedPixels[index++] = grayValue; // r
storedPixels[index++] = grayValue; // g
storedPixels[index++] = grayValue; // b
storedPixels[index++] = grayValue; // a
}
}
...
上述列子只是針對 Samples per Pixel為1的影象渲染成灰度圖的處理,還有Samples per Pixel為3的影象渲染成RGB圖/偽彩圖等等。過程相似。
通常在瀏覽dicom影象時,可以調節窗寬與窗位來檢視不同窗值下的灰度影象。在上述渲染流程中,只需修改voiLutFunc函式的引數,再次生成表進行渲染。
// linear voi lut
function voiLutLinearFunc(pixel, windowWidth, windowCenter) {
windowWidth = Math.max(windwoWidth, 1);
// 由於使用的Unit8ClampedArray資料型別會自動裁剪不在0-255範圍的值
return ((pixel - windowCenter) / windowWidth + 0.5) * 255;
}
執行putImageData後canvas上將會顯示大小為rows * columns的影象,由於使用者實際看圖的視窗並不為canvas的大小。為此可將影象做縮放後放在使用者視窗的中心。引入viewport 概念
顯示dicom影象
渲染生成的canvas影象可以看成一張普通的影象,將其畫在視窗上。canvasRenderingContext2D.drawImage(),將一張影象畫在canvas圖層上。為了合適的顯示一張影象
- 建立視窗canvas
- 將影象畫到視窗指定的顯示區域
// create viewport canvas
const canvas = initCanvas();
const context = canvas.getContext('2d');
context.fillStyle('black');
context.clearRect(0, 0, canvas.width, canvas.height);
// draw dicom image(rendered canvas)
const scale = Math.min(canvas.width / image.width, canvas.height/ image.height, 0.8)
context.drawImage(
image,
0,
0,
image.width,
image.height,
canvas.widht /2- image.wdth * scale / 2,
canvas.height /2- image.height * scale / 2,
image.width * scale,
image.height * height
)
若使用者想要對視窗內的影象做簡單的互動操作,如平移縮放渲染等變化?
對於平移和縮放可以修改drawImage引數實現,而旋轉無法實現。
變化
- 矩陣
在二維座標系下,對物體的仿射變化都可以通過乘以一個齊次矩陣實現
// 平移矩陣
// T, 平移x, y
/*
[
1 0 x
0 1 y
0 0 1
]
*/
// R旋轉,順時針旋轉r
/*
[
cosr -sinr 0
sinr cosr 0
0 0 1
]
*/
// S縮放,縮放量s
/*
[
s 0 0
0 s 0
0 0 1
]
*/
矩陣變化的優勢,是多次變化可以轉變為各個變化矩陣的乘積(物體變化的順序是其左乘矩陣的順序),從而減少運算量。
- canvas2d實現
對於視窗,其座標原點為左上角點,向右為x的正方向。向下為y的正方向。視窗座標系固定不變,影象顯示在視窗中。影象可以看成視窗座標系中的物體
互動時,讓影象圍繞自身的中心平移旋轉縮放。存在一個矩陣T,包含了本次互動的所有變化。讓T右乘影象,即可改變影象在視窗中的位置
canvasRenderingContext2D.setTransform(T), T為變化矩陣。通過該介面可以改變畫布在視窗座標系下的位置。
影象畫在畫布上,setTranform(T)的作用相當於T矩陣右乘影象,從而改變影象 - 變化的合成
在二維座標系下,對物體右乘矩陣實現變化時。是相對座標系的原點。而實際需求中需要圍繞影象中心做旋轉、縮放等變化。為此,可以分步實現,首先將影象
的中心平移至座標系原點,在做旋轉縮放變化,這樣影象是基於自身中心在變化。其次在對影象做平移變化,最後對影象做首次平移變化的反變化。
cornerstone 顯示
資料結構
enabledElement
enabledElement 與 一個HTMLElement元素(通常為DIV)繫結。其屬性包含了cornerstone中典型的資料結構image, viewport, canvas等。當通過HTMLElement獲取enabledElement時,可以獲取顯示影象的所有資訊。
對外暴露了enable/disenable介面實現繫結/解綁。繫結的過程。
enabledElements.js檔案(模組)中,定義了enabledElements陣列變數,暴露get,set藉口操作enabledElement
enable一個元素過程
-
生成一個enabledElement,將其新增到enabledElements
-
對外觸發一個enabled事件,對HTMLElement繫結resize事件
-
實現一個draw函式繪製圖像,內部呼叫requestAnimationFrame不斷重繪
實現借鑑
-
模組化的思想,將生成的enabledElement例項放在另一個模組中(enableElements)管理
-
丟擲一個自定義事件
function triggerEvent (el, type, detail = null) { let event; // This check is needed to polyfill CustomEvent on IE11- if (typeof window.CustomEvent === 'function') { event = new CustomEvent(type, { detail, // 事件攜帶的資料儲存在detail中 cancelable: true }); } else { event = document.createEvent('CustomEvent'); event.initCustomEvent(type, true, true, detail); } // dispatch過程時立即的,監聽的元素會立即執行 return el.dispatchEvent(event);
}
image
image物件是針對dicom影象的具體描述,每個image物件都有一個imageId。根據dicom檔案中的dicom element解析為minPixelValue/maxPixelValue/slope/windowCenter/rows等屬性,也提供獲取畫素值getPixelData,獲取影象getCanvas等介面
image為需要顯示的影象,image作為enabledElement的屬性。如果其不存在,則無法渲染。
cornerstone本身並沒有提供介面用於生成image物件,而是將建立流程交給了使用者。
imageLoader (Function)
cornerstone提供了loadImage/loadImageAndCache介面用於載入image。接受一個引數imageId(String),返回一個物件{promise, error}promise為Promise, resolve(image),error表示載入失敗
imageId的結構(pluginName + ‘:’ + any)定義了一種imageLoader,imageLoader作為外掛的形式由使用者編寫,通過registerImageLoader(pluginName, imageLoader)進行註冊使用。
// image loader
const myImageLoader = (imageId) => {
let error = null
const imagePromise = new Promise(async (resolve, reject) => {
try {
const dicomData = await fetch(imageId.replace("mayLoader:", ""));
const image = createImage(imageId, dicomData);
resolve(image)
} catch(err) {
error = err;
}
});
return {
promise: imagePromise,
error
}
}
// register
cornerstone.registerImageLoader('mayLoader', myImageLoader);
// create image
// imageId should have imageLoader name
function createImage(imageId, dicomData) {
const {rows, columns,slope, intercept, windowCenter, windowWidth} = dicomData;
const {minPixelValue, maxPixelValue} = findMinAndMaxPixel(dicomData.pixels)
return {
imageId,
getPixelData: () => dicomData.pixels,
getCanvas: () => null, // apply in RGB image
minPixelValue ,
maxPixelValue ,
slope: 1.0,
intercept ,
windowCenter ,
windowWidth ,
render: cornerstone.renderGrayscaleImage,
getPixelData: getPixelData,
rows,
columns,
color: false, // is RGB image
columnPixelSpacing: 0.67578,
rowPixelSpacing: 0.67578,
sizeInBytes: width * height * 2,
}
}
CornerstoneWODOImageLoader基於dicom協議中的WODO編寫的imageloader。
imageLoader的意義
- 外掛的形式編寫,可實現多種不同獲取image的形式
- 返回結果的非同步,滿足web端對資源獲取的非同步性
- 提供了快取的功能,可定製的快取載入過的影象
實現借鑑
imageLoader.js模組,內部定義了imageLoaderObject物件,其key為imageLoader的name, value為imageLoader。
通過註冊過後的imageLoader都儲存在imageLoaderObject中,在呼叫loadImage介面時通過imageId中包含的imageLoader name可以從imageLoaderObject中找到相對應的imageLoader。從而支援多種方式/影象的載入。
imageCache.js模組,內部定義了imageCacheDict物件和cachedImages陣列,它們都存cacheImage這種資料結構
- loaded [Boolean]
- imageId
- imageLoadObject : imageLoader返回的物件{promise, error}
- timeStamp
- sizeInBytes
- image
imageCacheDict以imageId為key,value為cacheImage, cacheImages存了cacheImage。
通過imageLoaderObject由於其返回Promise, 因此可以被多處使用,並且該Promise的fullied狀態的*[[PromiseResult]]儲存了image物件。
快取實現:在loadImage/loadImageAndCache時,第一步根據imageId從cacheImages中找到cahcheImage物件,返回cacheImage的imageLoadObject屬性。通過呼叫其promise找到之前reslove過的image物件
// imageLoadObject 多處使用
// 首次載入影象, 內部呼叫promise,丟擲一個載入完成事件
imageLoadObject.promise.then(function (image) {
triggerEvent(events, EVENTS.IMAGE_LOADED, { image });
}, function (error) {
const errorObject = {
imageId,
error
};
triggerEvent(events, EVENTS.IMAGE_LOAD_FAILED, errorObject);
});
// 作為loadImage/loadImageAndCache的返回值
return imageLoadObject
// 影象載入後,快取的處理
imageLoadObject.promise.then(function (image) {
cachedImage.image = image;
cachedImage.sizeInBytes = image.sizeInBytes;
cacheSizeInBytes += cachedImage.sizeInBytes;
const eventDetails = {
action: 'addImage',
image: cachedImage
};
triggerEvent(events, EVENTS.IMAGE_CACHE_CHANGED, eventDetails);
})
viewport
視窗作為影象的載體,決定了如何顯示一張dicom影象。以及對影象的縮放、翻轉、平移,窗寬窗位的操作等
viewport物件含有scale, voi, displayArea等屬性,根據viewport,計算視窗canvas的transform。canvasRendering2dContext.setTransform(transform)更改canvas座標系(相當於作用影象)。前面已經介紹了仿射變換以及窗值變化的原理實現。
座標系轉化
- 視窗座標系, 左上角為座標系的原點,向右為x的正方向,向下為y的正方向
- 影象座標系,dicom影象的畫素以row-order方式儲存在陣列中,比如,將陣列內的資料按個填充在一個矩形網格中。從左到右,從上到下。網格的左上角座標系的原點,向右向下為正。
一個座標系(二維),可以用原點(p)和一組基表示(u, v), 在座標系中的任意一點座標值(u, v)可描述為
q +uu + vv ,假設存在一個全域性的座標系原點o ,基為x, y。設視窗座標系為全域性座標系,影象座標系為原點(q)和基為(u, v)的座標系。q,u, v的值都是相對全域性座標系的。存在一點p在視窗座標系下的值為(xe,ye),在影象座標系下的值為(up, vp)。座標值描述的點只是基於該座標系原點和基的簡寫。公式推到來自虎書
至此,推匯出了從一個座標系到另一個座標系的變化矩陣T
對於影象座標系和視窗座標系之間的轉換,可以這樣理解。最初的時候二者是重合的,變化矩陣T為單位陣,當執行setTransform(transform)操作後。改變了影象座標系,此時T為transform,表示影象座標系到視窗座標系的變化。對於(i, j, 1)左乘transform可以得到(x, y, 1).同樣(x, y,1)左乘invert(trannsform)可以得到(i, j, 1)
實踐借鑑
- 封裝了Tranform類,用於二維座標下的矩陣運算
- 定義了一個顯示區域,將dicom影象畫在視窗的顯示區域上
Transform內部用一個長度為6的陣列m描述了變化矩陣T,因為T的最後一列為(0, 0, 1)所以可以簡化處理。陣列m為資料儲存為行階矩陣。gl為列階矩陣。
viewport定義了一個顯示區域displayArea{brhc: {x, y}, tlhc: {x, y}},通常來說左上角tlhc位置(1, 1)。右下角位置為影象的行數、列數。在生成一張dicom影象以及設定完canvas的Transform後。畫布上下文將影象畫在畫布上
const sx = enabledElement.viewport.displayedArea.tlhc.x - 1;
const sy = enabledElement.viewport.displayedArea.tlhc.y - 1;
const width = enabledElement.viewport.displayedArea.brhc.x - sx;
const height = enabledElement.viewport.displayedArea.brhc.y - sy;
// 保證了初始時,影象和視窗重合。
context.drawImage(renderCanvas, sx, sy, width, height, 0, 0, width, height);
對於mpr的影象,由於行列方向上的spacing可能不同。存在另種顯示模式presentationSizeMode(viewport的屬性)預設的‘’NONE“,“SCALE TO FIT”。
- NONE 會根據spacing的比值調整縮放值viewport.scale
- SCALE TO FIT, 適應當前視窗計算縮放值
events
ornerstone所有的事件為自定義事件,事件名稱全部定義在events模組中。內部實現中triggerEvent函式新建自定義事件,然後dispatchEvent丟擲事件。函式的第一個引數為事件觸發/接受物件。對於dom元素,它們都有addEventListener/dispatchEvent介面。但triggerEvent並不是只針對dom節點元素。因此在event模組中封裝了一個EventTarget類,它是對DOM EventTaget介面的一種實現。讓EventTarget的例項擁有和DOM元素一樣的事件監聽/觸發機制。 關於dipatchEvent
- dipatchEvent與原生dom事件不同,dispatch事件後會立即執行,而不是非同步的執行。事件handler中不能拋錯,否則影響整個程式的執行。
- dispatchEvent事件後,如果任意一個事件監聽者的回撥中執行了Event.preventDefault,則返回false,否則為true;conerstone的事件handler中並沒有按照這樣的規則實現。
alph 通道快速渲染灰度圖
imageData的data屬性是指影象的畫素。每個畫素由四個8位無符號整型數(0-255)構成,分別代表r,g,b,a。對於CT,很多情況下展現的是灰度圖。將dicom影象儲存的畫素值,通過modalityLUT, voiLUT操作後對映成0-255區間的灰度值。在填充畫素時,將畫素值的r,g,b都設為該灰度值,a通道身為255。這樣完成了灰度圖的渲染。
如果,將影象在初始化時全部填充白色畫素值(255, 255, 255, 255).然後在渲染填充畫素時,只將a通道值設為灰度值。那麼也能完成CT灰度圖的渲染。因為在0-255區間內CT值大,影象越白。通過a通道的值控制灰度圖的黑白,從而達到一樣的顯示效果,但在遍歷填充畫素時的操作減少,使整個渲染效能提升。
composite layers
有時需要將多張影象同時畫在一個畫布上,如pet ct影象。enabledElement物件定義了layers[Array]屬性, 陣列內部元素為layer物件,layer可以認為是簡化版的enabledELement, 它同樣含有自己的image,viewport。同時其options屬性定義了其特有的屬性,如visible, opacity等。layers模組實現了layer的相關介面,addLayer, removeLayer, getLayer。
在呼叫addLayer時函式返回一個layerId, layerId為layer物件的屬性。通過layerId作為get/remove/setImage/activeLayer等函式的必要引數。多層layers中有一層作為active(base) layer, 該layer的imge,viewport為它們所繫結enabledElement物件的image,viewport。並將layerId設為enabledElement.activeLayerId;
enabledElement.activeLayerId = layerId;
enabledElement.image = layer.image;
enabledElement.viewport = layer.viewport;
渲染過程
- 每一幀渲染時,若enabledElement 元素存在layers屬性,則呼叫drawCompositeImage
- viewport同步處理,所謂同步,是讓其他非activeLayer跟隨activeLayer的viewport變化而變化。
- enabledElement.syncViewports屬性表示是否所有layers同步渲染。 enabledElement.lastSyncViewportsState記錄上次同步的狀態,可以減少同步運算
- 每個layer定義syncProps屬性,裡面存放上一次渲染改layer的scale值。通過對比這次渲染的scale,求出變化量,從而更新同步
- 遍歷所有可見的layers(visible不為false, opacity不為0),根據其viewport和對應的render(gray/color…)生成畫布,將其畫在enabledElement.canvas上。
webgl
cornstone支援webgl渲染dicom影象來提高效能。
cornestone.enable()元素,若options中含有renderer欄位,且其值為webgl,則表示使用webgl渲染。
- 檢查是否瀏覽器支援webgl環境
- 初始化webgl中使用的shader和buffer(只初始化一次)
- shader, 針對每種pixel的資料型別,建立一個fragment shader。webgl使用的webgl1, 對資料型別的並不支援float型別。若pixel的Int16型別, 需要將其值存在三個uint8型別的數中。使用gl.RGB作為紋理引數的format。fragmentShader中,讀取紋理值後(unit8)轉化成int16型別,在進行modalitylut 和voilut,計算出灰度值. 在vertexShader需要根據影象的寬高比,將頂點座標轉化成NDC座標值
- buffer, ,只需要在vertexShader定義四個頂點(正方形),。紋理座標值通過插值在fragmentShader中使用。
- dicom影象的主要tag值通過uniform變數傳到fragmentShader中,在shader中做lut處理
- 每一幀渲染時,通過webgl渲染一張原始大小的dicom影象,再畫到畫布上。