1. 程式人生 > 其它 >cornerstone.js 使用總結

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)。座標值描述的點只是基於該座標系原點和基的簡寫。公式推到來自虎書

截圖2020-11-11 下午8.05.14

截圖2020-11-11 下午8.05.36

至此,推匯出了從一個座標系到另一個座標系的變化矩陣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影象,再畫到畫布上。