← ALL ENTRIES

Cornerstone3D 시작하기 — 개념·기본 사용법·뷰포트 종류


📋 요약 한 줄 요약 Cornerstone3D는 브라우저에서 의료영상을 그리는 렌더링 라이브러리다. <img> 와 달리 원본 픽셀 값을 그대로 들고 있어서 윈도잉·계측·3D 재구성이 된다. 핵심은 네 가지 — imageId(영상 주소), ImageLoader(주소→픽셀), RenderingEngine(WebGL 소유자), Viewport(그리는 창). 뷰포트 종류는 “데이터를 2D 낱장으로 보느냐 3D 덩어리로 보느냐”로 갈린다.

1. 이게 왜 필요한가

의료영상을 <img src="ct.jpg"> 로 못 그리는 건 아니다. 문제는 브라우저가 그 순간 픽셀을 뭉개버린다는 점이다.

CT 한 장은 보통 1216비트로, 값의 범위가 대략 -1000(공기) ~ +3000(뼈) 이다. 이걸 JPG로 만들면 0255로 눌러 담아야 하고, 그 과정에서 어떤 밝기 구간을 살릴지 한 번 정해지면 되돌릴 수 없다. 폐를 보려고 만든 JPG로는 뼈를 볼 수 없다.

Cornerstone3D는 원본 값을 그대로 GPU로 넘기고, 화면에 보일 때만 “지금은 -1000200 구간을 0255로 펴서 보여줘”를 적용한다. 이걸 윈도잉(windowing) / VOI(Value Of Interest) 라고 한다. 판독자가 슬라이더를 움직이면 원본에서 다시 계산하므로 정보 손실이 없다.

기능<img>Cornerstone3D
Pixel data (픽셀 값)0~255 로 이미 눌림원본 비트심도 유지
Windowing / VOI (밝기·대비)CSS filter (되돌릴 수 없는 근사)원본에서 재계산
Measurement (길이·각도 계측)불가 (mm 정보가 없음)가능 (pixelSpacing 사용)
Stack scroll (여러 장 넘기기)이미지 교체스택 인덱스 이동
MPR (단면 재구성)불가볼륨 뷰포트

💡 TIP 일반 사진(JPG/PNG)에도 쓸 만한가 쓸 만하다. 포맷별로 뷰어가 갈라지는 걸 막을 수 있다. → Cornerstone3D 로 JPG·PNG 까지 렌더링하기

2. 패키지 구성

@cornerstonejs/core                 렌더링 엔진·뷰포트·캐시  (필수)
@cornerstonejs/tools                확대/팬/윈도잉/계측 도구  (선택)
@cornerstonejs/dicom-image-loader   DICOM 파싱·디코딩        (DICOM 쓸 때)

core 만으로도 그림은 그려진다. 마우스 조작이 필요할 때 tools 를 얹는다.

3. 핵심 개념 4가지

 imageId                ImageLoader              RenderingEngine        Viewport
"wadouri:https://…"  →  주소를 실제 픽셀로   →   WebGL 컨텍스트 1개  →  <div> 안의 화면
"web:blob:…"            (스킴별로 등록)          (앱 전체에 하나)       (여러 개 가능)
"dicomfile:0"

                        metaData provider
                     (크기·비트심도·픽셀간격 등)

imageId — 영상 한 장의 주소

스킴:나머지 형태의 문자열이다. 스킴이 어떤 로더를 쓸지 결정한다.

imageId 예시로더쓰임
wadouri:https://…/1.dcmdicom-image-loader서버의 DICOM 파일
dicomfile:0dicom-image-loader사용자가 방금 올린 DICOM
web:https://…/a.jpg직접 등록일반 이미지

ImageLoader — 주소를 픽셀로

스킴별로 등록한다. 반환값이 Promise가 아니라 { promise } 객체라는 게 첫 함정이다.

imageLoader.registerImageLoader('web', (imageId) => ({ promise: /* Promise<IImage> */ }))

metaData provider — “이 영상이 몇 픽셀짜리인지”

로더와 별개로 등록해야 한다. 뷰포트는 픽셀을 받기 전에 크기·비트심도·픽셀 간격을 먼저 물어본다. 내 스킴이 아니면 반드시 undefined 를 반환해서 다음 provider에게 넘겨야 한다.

RenderingEngine — WebGL 컨텍스트 소유자

브라우저는 WebGL 컨텍스트 수를 제한한다(보통 8~16개). 그래서 엔진은 앱에 하나만 만들고 뷰포트를 여러 개 붙인다. 9분할 화면이어도 엔진 1개 / 뷰포트 9개다.

⚠️ 주의 engine.resize() 는 전체 뷰포트에 영향을 준다 resize(immediate, keepCamera) 에서 keepCamera=false엔진에 붙은 모든 뷰포트의 카메라가 초기화된다. 타일 하나가 리사이즈될 때 옆 타일의 확대/반전 상태까지 같이 날아간다. 상태를 엔진 밖에 따로 보관했다가 복구해야 한다.

Viewport — 실제로 그리는 창

<div> 하나에 뷰포트 하나가 붙는다. cornerstone이 그 안에 <canvas> 를 만든다.

4. 최소 사용법

import { init, RenderingEngine, Enums, type Types } from '@cornerstonejs/core'

await init()                                   // 앱에서 한 번만

const engine = new RenderingEngine('engine')   // 앱에서 한 번만
engine.enableElement({
  viewportId: 'vp1',                           // 영숫자로. React useId()는 `:r0:` 라 그대로 못 쓴다
  type: Enums.ViewportType.STACK,
  element: divRef.current,                     // 크기가 0이면 아무것도 안 보인다
})

const viewport = engine.getViewport('vp1') as Types.IStackViewport
await viewport.setStack(imageIds, 0)           // 두 번째 인자 = 시작 인덱스
viewport.render()

// 정리
engine.disableElement('vp1')

조작 몇 가지:

await viewport.setImageIdIndex(3)                    // Stack scroll — 3번째 장으로
viewport.getCurrentImageIdIndex()                    // 지금 몇 번째인지
viewport.setProperties({ voiRange: { lower: -1000, upper: 200 } })  // Windowing
viewport.setProperties({ invert: true, colormap: { name: 'hsv' } }) // Invert / Colormap
viewport.setCamera({ flipHorizontal: true })         // Flip
viewport.resetCamera()                               // Reset camera

🚨 위험 좌우 반전은 판독 오류로 이어진다 flipHorizontal 은 표시용 보정일 뿐이다. 실제 촬영 영상에 임의로 걸면 좌/우안이 뒤바뀌어 오진의 원인이 된다. 목업·데모 자산 정렬 같은 명확한 용도가 아니면 쓰지 않는다.

React에서는 SSR을 꺼야 한다 (WebGL·Web Worker를 쓴다).

const Viewer = dynamic(() => import('./Viewer'), { ssr: false })

5. 뷰포트 종류 — 무엇이 다른가

Enums.ViewportType 에 정의돼 있다. 갈리는 기준은 입력 데이터의 모양이다.

2D 낱장의 나열            3D 복셀 격자              특수 포맷
─────────────────        ─────────────────        ─────────────────
STACK                    ORTHOGRAPHIC             VIDEO
                         PERSPECTIVE              WHOLE_SLIDE
                         VOLUME_3D                ECG

전체 표 (v5 기준)

ViewportType실제 클래스데이터언제 쓰나
STACKStackViewportimageId 배열가장 흔함. X-ray, 안저, 내시경, CT를 한 장씩 넘겨 보기
ORTHOGRAPHICVolumeViewport3D 볼륨MPR — 축상/관상/시상 단면. 평행 투영
PERSPECTIVEVolumeViewport3D 볼륨원근 투영. 가상 내시경 같은 “안에서 보는” 화면
VOLUME_3DVolumeViewport3D3D 볼륨볼륨 렌더링 — 덩어리를 통째로 3D로
VIDEOVideoViewport비디오 스트림초음파 시네, 혈관조영 동영상
WHOLE_SLIDEWSIViewport타일 피라미드디지털 병리 슬라이드 (기가픽셀급, 지도처럼 타일 로딩)
ECGECGViewport파형심전도

📝 NOTE *_NEXT 타입은 직접 쓰지 않는다 PLANAR_NEXT, VIDEO_NEXT 등은 v5가 내부적으로 도입한 차세대 구현이다. BaseRenderingEngine.NEXT_TYPE_REMAPSTACK/ORTHOGRAPHICPLANAR_NEXT 처럼 알아서 바꿔치기한다. 코드에는 계속 STACK 을 쓰면 된다.

Stack vs Volume — 실질적인 차이

이 둘의 구분이 제일 중요하다.

StackViewportVolumeViewport (ORTHOGRAPHIC)
데이터2D 이미지 N장의 목록하나의 3D 복셀 격자
장끼리의 관계없음. 그냥 순서실제 3차원 위치로 정렬됨
로딩보이는 장만 받으면 됨전부 받아야 볼륨이 완성됨
크기가 달라도 되나된다 (한 장씩 독립)안 된다 (격자가 균일해야 함)
임의 단면 자르기불가가능 (MPR)
초기 비용낮음높음 (메모리·시간)

핵심은 이렇다. 스택은 사진첩을 넘기는 것이고, 볼륨은 식빵 덩어리를 아무 각도로나 썰어 보는 것이다.

CT 200장을 스택으로 열면 촬영된 그 방향(축상)으로만 볼 수 있다. 같은 200장을 볼륨으로 만들면 옆에서 자른 단면(관상·시상)을 새로 계산해 낼 수 있다. 대신 200장이 다 도착할 때까지 기다려야 하고 메모리도 훨씬 많이 쓴다.

// 스택 — 낱장을 나열
const vp = engine.getViewport(id) as Types.IStackViewport
await vp.setStack(imageIds, 0)

// 볼륨 — 3D 격자를 만들고 방향을 정해서 본다
const volume = await volumeLoader.createAndCacheVolume('vol1', { imageIds })
volume.load()
const vp3d = engine.getViewport(id) as Types.IVolumeViewport
await vp3d.setVolumes([{ volumeId: 'vol1' }])
vp3d.setOrientation(Enums.OrientationAxis.AXIAL)

단면 방향Enums.OrientationAxis 로 정한다 — AXIAL(가로로 썰기), CORONAL(정면에서 썰기), SAGITTAL(옆에서 썰기), ACQUISITION(촬영된 그대로).

그래서 뭘 골라야 하나

  • 안저·X-ray·내시경 등 2D 촬영STACK. 고민할 것 없다
  • CT/MRI를 한 장씩만 넘겨 봄STACK 으로 충분하다. 볼륨은 과하다
  • CT/MRI를 여러 방향 단면으로 봐야 함ORTHOGRAPHIC
  • 3D 입체로 보여줘야 함VOLUME_3D
  • 동영상/병리 슬라이드VIDEO / WHOLE_SLIDE

💡 TIP 스택으로 시작하고, 필요할 때 볼륨으로 utilities.convertVolumeToStackViewport 같은 전환 헬퍼가 있다. 처음부터 볼륨으로 갈 이유는 대개 없다.

6. 자주 밟는 함정

  • 엔진은 하나 — 뷰포트마다 만들면 WebGL 컨텍스트가 금방 마른다
  • engine.resize(_, false) 는 전 뷰포트 카메라를 초기화 — 확대·반전 상태가 옆 타일까지 날아간다
  • 크기 변화를 자동 추적하지 않는다ResizeObserver + engine.resize() 를 직접 붙인다
  • 한 스택에 같은 imageId를 두 번 넣지 말 것 — 로딩 완료 후 imageIds.indexOf(imageId) 로 위치를 되찾아 현재 인덱스와 대조하는데, 중복이면 항상 첫 번째를 가리켜서 뒤쪽 장이 아무 오류 없이 안 그려진다
  • 로더 반환값은 { promise } — Promise 자체가 아니다
  • 첫 렌더가 검게 나오는 하드웨어가 있다Cornerstone3D 첫 렌더 검은 화면 (Apple GPU · ANGLE Metal)
  • <div> 크기가 0이면 아무것도 안 보인다 — 부모 레이아웃부터 확인

🚨 위험 DICOM 헤더에는 환자 식별정보가 들어 있다 PatientName·PatientID·StudyDate 가 파일에 그대로 박혀 있다. 브라우저에서 열어 보는 건 괜찮지만, 저장·전송 전에는 서버에서 태그를 제거하고 비식별 이미지로 변환해야 한다.

관련 노트