๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ

๐Ÿ–ฑ๏ธ ๊ธฐ์ˆ  ๊ฒ€ํ† 

[React] VTK ์ขŒํ‘œ๊ณ„์—์„œ 3๊ฐœ ๋ทฐ ๋™์‹œ์— BBox ๊ทธ๋ฆฌ๊ธฐ

VTK ์ขŒํ‘œ๊ณ„์—์„œ 3๊ฐœ ๋ทฐ ๋™์‹œ์— BBox ๊ทธ๋ฆฌ๊ธฐ
3ํŽธ / 3ํŽธ โ€” ๅฎŒ

VTK ์ขŒํ‘œ๊ณ„์—์„œ
3๊ฐœ ๋ทฐ ๋™์‹œ์— BBox ๊ทธ๋ฆฌ๊ธฐ

Axial ์Šฌ๋ผ์ด์Šค์˜ 2D ํ”ฝ์…€ ์ขŒํ‘œ๋ฅผ ์–ด๋–ป๊ฒŒ 3D ๊ณต๊ฐ„์œผ๋กœ ๋Œ์–ด์˜ฌ๋ฆฌ๊ณ , ๋‹ค์‹œ SagittalยทCoronal ๋ทฐ์˜ Canvas ํ”ฝ์…€๋กœ ๋–จ์–ดํŠธ๋ฆฌ๋Š”์ง€ โ€” VTK ์ขŒํ‘œ ๋ณ€ํ™˜ ์ฒด์ธ์˜ ์ „ ๊ณผ์ •์„ ์ฝ”๋“œ์™€ ํ•จ๊ป˜ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

IJK โ†’ World โ†’ Display 3D BBox ์žฌ๊ตฌ์„ฑ ๋ทฐ๋ณ„ ํˆฌ์˜ ์ „๋žต ์ขŒํ‘œ๊ณ„ ๋ช…๋ช… ์—ญ์ „ onModified ๋™๊ธฐํ™” DPR ๋ณด์ •

๐Ÿ—‚ ์‹œ๋ฆฌ์ฆˆ: SEG NIfTI์—์„œ BBox JSON์œผ๋กœ

1
SEG NIfTI์˜ ๋ฌธ์ œ์™€ BBox JSON ์ „ํ™˜ ๋ฐฐ๊ฒฝ
2
BBox JSON API ์„ค๊ณ„์™€ Canvas ์˜ค๋ฒ„๋ ˆ์ด ๊ตฌํ˜„
3
VTK ์ขŒํ‘œ๊ณ„์—์„œ 3๊ฐœ ๋ทฐ ๋™์‹œ์— BBox ๊ทธ๋ฆฌ๊ธฐ โ† ํ˜„์žฌ
01 ยท The Challenge

๋ฌด์—‡์ด ์–ด๋ ค์šด๊ฐ€

2ํŽธ์—์„œ ๊ตฌํ˜„ํ•œ drawBBoxOverlay๋Š” AxialGridViewer์—์„œ๋งŒ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. Canvas ํ”ฝ์…€ ์ขŒํ‘œ์™€ ๋ณต์…€ ์ธ๋ฑ์Šค๊ฐ€ 1:1๋กœ ๋งคํ•‘๋˜๊ธฐ ๋•Œ๋ฌธ์— ๋ณ€ํ™˜ ์—†์ด ๋ฐ”๋กœ ์“ธ ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

MPR ๋ทฐ(Axial/Sagittal/Coronal)๋Š” ๋‹ค๋ฆ…๋‹ˆ๋‹ค. VTK๋Š” ๋‚ด๋ถ€์ ์œผ๋กœ 3D ๊ณต๊ฐ„์„ WebGL๋กœ ๋ Œ๋”๋งํ•œ ๋’ค, ๊ทธ ๊ฒฐ๊ณผ๋ฅผ Canvas ํ”ฝ์…€๋กœ ๋ณต์‚ฌํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์คŒยทํŒฌ์„ ํ•˜๋ฉด ๊ฐ™์€ ๋ณต์…€์ด ๋‹ค๋ฅธ Canvas ์œ„์น˜์— ๊ทธ๋ ค์ง‘๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ annotation ์ขŒํ‘œ๋ฅผ ๊ทธ ์ˆœ๊ฐ„์˜ ์นด๋ฉ”๋ผ ์ƒํƒœ์— ๋งž๊ฒŒ ๋ณ€ํ™˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์—ฌ๊ธฐ์— ๋˜ ํ•˜๋‚˜์˜ ๋‚œ๊ด€์ด ์žˆ์Šต๋‹ˆ๋‹ค. BE๊ฐ€ ๋‚ด๋ ค์ค€ annotation์€ Axial ์Šฌ๋ผ์ด์Šค ๊ธฐ์ค€์˜ 2D ์ขŒํ‘œ์ž…๋‹ˆ๋‹ค. ์ด ์ขŒํ‘œ๋ฅผ Sagittal, Coronal ๋ทฐ์—์„œ๋„ ๊ทธ๋ฆฌ๋ ค๋ฉด 2D โ†’ 3D โ†’ 2D ๋‘ ๋ฒˆ์˜ ๋ณ€ํ™˜์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“

Axial ๋ทฐ

annotation ์›๋ณธ ์ขŒํ‘œ(ํ”ฝ์…€) โ†’ IJK ๋ณ€ํ™˜ โ†’ World โ†’ Display. ํ˜„์žฌ ์Šฌ๋ผ์ด์Šค์™€ ์ผ์น˜ํ•˜๋Š” annotation๋งŒ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

๐Ÿ“

Sagittal ๋ทฐ

annotation์—์„œ 3D BBox๋ฅผ ์žฌ๊ตฌ์„ฑ โ†’ ํ˜„์žฌ I๊ฐ€ BBox I ๋ฒ”์œ„์— ์†ํ•˜๋ฉด Jร—K ํˆฌ์˜ ์‚ฌ๊ฐํ˜•์„ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

๐Ÿ“

Coronal ๋ทฐ

annotation์—์„œ 3D BBox๋ฅผ ์žฌ๊ตฌ์„ฑ โ†’ ํ˜„์žฌ J๊ฐ€ BBox J ๋ฒ”์œ„์— ์†ํ•˜๋ฉด Iร—K ํˆฌ์˜ ์‚ฌ๊ฐํ˜•์„ ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

02 ยท Naming Trap

๋จผ์ € ๋ฐ˜๋“œ์‹œ ์งš๊ณ  ๊ฐ€์•ผ ํ•  ๊ฒƒ โ€” ๋ช…๋ช… ์—ญ์ „

์ด ํ”„๋กœ์ ํŠธ์—์„œ ๊ฐ€์žฅ ๋งŽ์€ ๋ฒ„๊ทธ๋ฅผ ๋งŒ๋“  ์›์ธ์€ ์ฝ”๋“œ์˜ ๋ณต์žกํ•จ์ด ์•„๋‹ˆ๋ผ ๋ Œ๋”๋Ÿฌ ๋‚ด๋ถ€ ๋ช…์นญ๊ณผ UI ๋ผ๋ฒจ์˜ ๋ถˆ์ผ์น˜์ž…๋‹ˆ๋‹ค. ์ฒ˜์Œ ์ ‘ํ•˜๋ฉด ๋ฐ˜๋“œ์‹œ ํ˜ผ๋ž€์Šค๋Ÿฝ์Šต๋‹ˆ๋‹ค.

๋ Œ๋”๋Ÿฌ ๋‚ด๋ถ€ ๋ช…์นญ vs UI ํ‘œ์‹œ vs ์‹ค์ œ ๊ณ ์ • ์ถ• ๋ Œ๋”๋Ÿฌ ๋‚ด๋ถ€ ๋ช…์นญ UI ํ‘œ์‹œ ๋ผ๋ฒจ ๊ณ ์ • ์ถ• ๋ Œ๋” ํ‰๋ฉด coronal Axial (UI) K์ถ• ๊ณ ์ • I ร— J ํ‰๋ฉด sagittal Sagittal (UI) I์ถ• ๊ณ ์ • K ร— J ํ‰๋ฉด axial Coronal (UI) J์ถ• ๊ณ ์ • I ร— K ํ‰๋ฉด
๐Ÿšจ ์ฝ”๋“œ์—์„œ renderers.coronal์€ ์‚ฌ์šฉ์ž์—๊ฒŒ Axial(์ถ•๋ฐฉํ–ฅ)๋กœ ๋ณด์ด๋Š” ๋ทฐ์ž…๋‹ˆ๋‹ค. renderers.axial์€ Coronal(๊ด€์ƒ๋ฉด)์œผ๋กœ ๋ณด์ž…๋‹ˆ๋‹ค. ์ด ์—ญ์ „์„ ๋ชจ๋ฅด๊ณ  ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๋ฉด ์˜ฌ๋ฐ”๋ฅธ ๋ทฐ์— ์˜ฌ๋ฐ”๋ฅธ ๋ฐ•์Šค๊ฐ€ ๊ทธ๋ ค์ง€์ง€ ์•Š์Šต๋‹ˆ๋‹ค. BBoxOverlay ์ „์ฒด์—์„œ ์ด ๋งคํ•‘์„ ์ผ๊ด€๋˜๊ฒŒ ์ง€์ผœ์•ผ ํ•ฉ๋‹ˆ๋‹ค.
03 ยท Coordinate Chain

์ขŒํ‘œ ๋ณ€ํ™˜ ์ฒด์ธ โ€” IJK โ†’ World โ†’ Display โ†’ Canvas

BBoxOverlay์—์„œ annotation ์ขŒํ‘œ๊ฐ€ Canvas ํ”ฝ์…€๋กœ ๋ณ€ํ™˜๋˜๊ธฐ๊นŒ์ง€ 4๋‹จ๊ณ„๋ฅผ ๊ฑฐ์นฉ๋‹ˆ๋‹ค.

ํ”ฝ์…€ ์ขŒํ‘œ (px, py) annotation.graphic_data BE ์ œ๊ณต ์›์‹œ ์ขŒํ‘œ โ‘  IJK ์ธ๋ฑ์Šค (i, j, k) ๋ณต์…€ ์ขŒํ‘œ px โ†’ i, py โ†’ j, sliceK โ†’ k โ‘ก World ์ขŒํ‘œ (mm) (wx, wy, wz) IJK ร— spacing + origin โ‘ข Display ์ขŒํ‘œ (dx, dy) VTK ํ”ฝ์…€ vtkCoordinate ยท DPR ๋ณด์ • ยท Y๋ฐ˜์ „ โ‘ฃ Canvas ํ”ฝ์…€ stroke!

โ‘  ํ”ฝ์…€ ์ขŒํ‘œ โ†’ IJK

BE๊ฐ€ ๋‚ด๋ ค์ค€ graphic_data์˜ [px, py]๋Š” Axial ์Šฌ๋ผ์ด์Šค์˜ ํ”ฝ์…€ ์ขŒํ‘œ์ž…๋‹ˆ๋‹ค. Axial(coronal ๋ Œ๋”๋Ÿฌ)์—์„œ๋Š” K์ถ•์ด ๊ณ ์ •๋˜๋ฏ€๋กœ px=i, py=j, k=sliceK๋กœ ์ง์ ‘ ๋งคํ•‘๋ฉ๋‹ˆ๋‹ค.

// โ‘  ํ”ฝ์…€ ์ขŒํ‘œ โ†’ IJK
// Axial(coronal ๋ Œ๋”๋Ÿฌ): Iร—J ํ‰๋ฉด, K ๊ณ ์ •
const pixelToIJK = (
  px: number, py: number, sliceK: number
): [number, number, number] => {
  return [px, py, sliceK];  // [i, j, k]
};

โ‘ก IJK โ†’ World (mm)

VTK ImageData์˜ spacing(๋ณต์…€ ๊ฐ„๊ฒฉ)๊ณผ origin(์›์ )์„ ์ด์šฉํ•ด ๋ฌผ๋ฆฌ์  mm ์ขŒํ‘œ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

// โ‘ก IJK โ†’ World (mm ๋‹จ์œ„)
// World = origin + IJK * spacing
const ijkToWorld = (
  ijk: [number, number, number],
  imageData: VtkImageData,
): [number, number, number] => {
  const [si, sj, sk] = imageData.getSpacing();
  const [oi, oj, ok] = imageData.getOrigin();
  return [
    oi + ijk[0] * si,
    oj + ijk[1] * sj,
    ok + ijk[2] * sk,
  ];
};

โ‘ข World โ†’ Display (VTK ํ”ฝ์…€)

VTK์˜ vtkCoordinate๋ฅผ ์ด์šฉํ•ด World ์ขŒํ‘œ๋ฅผ ํ˜„์žฌ ๋ทฐํฌํŠธ์˜ Display ํ”ฝ์…€๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. ์ด ๋ณ€ํ™˜์€ ์นด๋ฉ”๋ผ์˜ ์คŒยทํŒฌ ์ƒํƒœ๋ฅผ ์ž๋™์œผ๋กœ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.

// โ‘ข World โ†’ Display (VTK ๋‚ด๋ถ€ ํ”ฝ์…€ ์ขŒํ‘œ)
// vtkCoordinate๊ฐ€ ์นด๋ฉ”๋ผ ํ–‰๋ ฌ์„ ์ž๋™ ๋ฐ˜์˜
const worldToDisplay = (
  world: [number, number, number],
  renderer: VtkRendererInstance,
  renderWindow: VtkRenderWindow,
): [number, number] => {
  const coord = vtkCoordinate.newInstance();
  coord.setCoordinateSystemToWorld();
  coord.setValue(...world);

  const [dx, dy] = coord.getComputedDisplayValue(renderer);
  coord.delete();  // VTK ๋ฉ”๋ชจ๋ฆฌ ๋ช…์‹œ ํ•ด์ œ

  // VTK Display Y์ถ•์€ ํ•˜๋‹จ ๊ธฐ์ค€ โ†’ ์ƒ๋‹จ ๊ธฐ์ค€์œผ๋กœ ๋ฐ˜์ „
  const [, winH] = renderWindow.getSize();
  return [dx, winH - dy];
};

โ‘ฃ Display โ†’ Canvas (DPR ๋ณด์ •)

๊ณ ํ•ด์ƒ๋„(Retina) ํ™”๋ฉด์—์„œ๋Š” ๋ธŒ๋ผ์šฐ์ €์˜ devicePixelRatio(DPR)๊ฐ€ 1 ์ด์ƒ์ž…๋‹ˆ๋‹ค. VTK Display ์ขŒํ‘œ๋Š” ๋ฌผ๋ฆฌ ํ”ฝ์…€ ๊ธฐ์ค€์ด๋ฏ€๋กœ CSS ํ”ฝ์…€๋กœ ๋‚˜๋ˆ„์–ด Canvas์— ๊ทธ๋ ค์•ผ ํ•ฉ๋‹ˆ๋‹ค.

// โ‘ฃ Display โ†’ Canvas CSS ํ”ฝ์…€ (DPR ๋ณด์ •)
const dpr = window.devicePixelRatio || 1;
const canvasX = displayX / dpr;
const canvasY = displayY / dpr;

// Canvas๋Š” CSS ํ”ฝ์…€ ๊ธฐ์ค€์œผ๋กœ strokeRect / moveTo ํ˜ธ์ถœ
ctx.strokeRect(canvasX, canvasY, width / dpr, height / dpr);
โš ๏ธ DPR ๋ณด์ •์„ ๋น ๋œจ๋ฆฌ๋ฉด Retina ๋””์Šคํ”Œ๋ ˆ์ด์—์„œ bbox๊ฐ€ ์ ˆ๋ฐ˜ ํฌ๊ธฐ๋กœ ๊ทธ๋ ค์ง€๊ฑฐ๋‚˜ ์œ„์น˜๊ฐ€ ์–ด๊ธ‹๋‚ฉ๋‹ˆ๋‹ค. devicePixelRatio๋Š” ์ฐฝ ์ด๋™์ด๋‚˜ ์™ธ๋ถ€ ๋ชจ๋‹ˆํ„ฐ ์—ฐ๊ฒฐ ์‹œ ๋ณ€๊ฒฝ๋˜๋ฏ€๋กœ, drawOverlay() ํ˜ธ์ถœ ์‹œ์ ์— ๋งค๋ฒˆ ์ƒˆ๋กœ ์ฝ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
04 ยท 3D BBox Reconstruction

SagittalยทCoronal ๋ทฐ๋ฅผ ์œ„ํ•œ 3D BBox ์žฌ๊ตฌ์„ฑ

Axial ๋ทฐ๋Š” annotation ์›๋ณธ ์ขŒํ‘œ๋ฅผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ SagittalยทCoronal์—์„œ๋Š” ๋ณ‘๋ณ€์ด ๋ช‡ ๋ฒˆ ์Šฌ๋ผ์ด์Šค๋ถ€ํ„ฐ ๋ช‡ ๋ฒˆ ์Šฌ๋ผ์ด์Šค๊นŒ์ง€ ๊ฑธ์ณ ์žˆ๋Š”์ง€์™€, ๊ฐ€๋กœ์„ธ๋กœ ์–ผ๋งˆ๋‚˜ ํฐ์ง€๋ฅผ ์•Œ์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ด ์ •๋ณด๋ฅผ ๋ชจ๋“  ์Šฌ๋ผ์ด์Šค์˜ annotation์—์„œ ๋ˆ„์ ํ•ด 3D ๋ฐ”์šด๋”ฉ ๋ฐ•์Šค(aneurysm_id๋ณ„ min/max IJK)๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.

components/BBoxOverlay.tsx โ€” buildAneurysmBBoxes()
interface AneurysmBBox {
  iMin: number; iMax: number;  // I์ถ• (๊ฐ€๋กœ) ๋ฒ”์œ„
  jMin: number; jMax: number;  // J์ถ• (์„ธ๋กœ) ๋ฒ”์œ„
  kMin: number; kMax: number;  // K์ถ• (์Šฌ๋ผ์ด์Šค) ๋ฒ”์œ„
  color: string;
}

/**
 * ์ „์ฒด annotation์—์„œ aneurysm_id๋ณ„ 3D BBox๋ฅผ ๊ณ„์‚ฐํ•œ๋‹ค.
 * ๋ชจ๋“  ์Šฌ๋ผ์ด์Šค์˜ graphic_data๋ฅผ ๋ˆ„์ ํ•ด min/max๋ฅผ ๊ตฌํ•œ๋‹ค.
 */
function buildAneurysmBBoxes(
  annotations: BBoxAnnotationItem[]
): Map<number, AneurysmBBox> {

  const bboxMap = new Map<number, AneurysmBBox>();

  for (const ann of annotations) {
    const { aneurysm_id, instance_number: k, graphic_data, color } = ann;

    const xs = graphic_data.map(p => p[0]);  // I ์ขŒํ‘œ
    const ys = graphic_data.map(p => p[1]);  // J ์ขŒํ‘œ

    const prev = bboxMap.get(aneurysm_id);

    bboxMap.set(aneurysm_id, {
      iMin:  prev ? Math.min(prev.iMin, ...xs) : Math.min(...xs),
      iMax:  prev ? Math.max(prev.iMax, ...xs) : Math.max(...xs),
      jMin:  prev ? Math.min(prev.jMin, ...ys) : Math.min(...ys),
      jMax:  prev ? Math.max(prev.jMax, ...ys) : Math.max(...ys),
      kMin:  prev ? Math.min(prev.kMin, k)    : k,
      kMax:  prev ? Math.max(prev.kMax, k)    : k,
      color,
    });
  }

  return bboxMap;  // aneurysm_id โ†’ 3D BBox
}
buildAneurysmBBoxes โ€” ์Šฌ๋ผ์ด์Šค๋ณ„ annotation์„ 3D BBox๋กœ ๋ˆ„์  ์Šฌ๋ผ์ด์Šค 148: bbox [80~160, 60~130] ์Šฌ๋ผ์ด์Šค 149: bbox [78~162, 62~132] ์Šฌ๋ผ์ด์Šค 150: bbox [76~164, 64~134] ์Šฌ๋ผ์ด์Šค 151: bbox [74~162, 63~133] ์Šฌ๋ผ์ด์Šค 152: bbox [76~160, 62~130] ๋ˆ„์  aneurysm #1 3D BBox iMin=74, iMax=164 jMin=60, jMax=134 kMin=148, kMax=152 color: '#ff4444' Sagittal ๋ทฐ (I์ถ• ๊ณ ์ •) ํ˜„์žฌ sliceI๊ฐ€ [74, 164] ๋ฒ”์œ„๋ฉด โ†’ Jร—K ์‚ฌ๊ฐํ˜• ๊ทธ๋ฆฌ๊ธฐ ๊ฐ€๋กœ: kMin~kMax, ์„ธ๋กœ: jMin~jMax Coronal ๋ทฐ (J์ถ• ๊ณ ์ •) ํ˜„์žฌ sliceJ๊ฐ€ [60, 134] ๋ฒ”์œ„๋ฉด โ†’ Iร—K ์‚ฌ๊ฐํ˜• ๊ทธ๋ฆฌ๊ธฐ ๊ฐ€๋กœ: iMin~iMax, ์„ธ๋กœ: kMin~kMax
05 ยท Per-View Strategy

๋ทฐ๋ณ„ ๋ Œ๋”๋ง ์ „๋žต โ€” drawOverlay ๊ตฌํ˜„

์„ธ ๋ทฐ ๊ฐ๊ฐ์˜ ๋ Œ๋”๋ง ๋กœ์ง์„ ์‚ดํŽด๋ด…๋‹ˆ๋‹ค. ํ•ต์‹ฌ์€ ์–ด๋–ค IJK ์ ์„ World๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , ์–ด๋–ค ๋‘ ์ ์ด ์‚ฌ๊ฐํ˜•์˜ ๋‘ ๊ผญ์ง“์ ์ด ๋˜๋Š”๊ฐ€์ž…๋‹ˆ๋‹ค.

components/BBoxOverlay.tsx โ€” drawOverlay() ํ•ต์‹ฌ ๋กœ์ง
function drawOverlay() {
  if (!annotations || !imageData || !sliceIndices) return;

  // 3D BBox ์‚ฌ์ „ ๊ณ„์‚ฐ (annotation ์ „์ฒด ์ˆœํšŒ, 1ํšŒ)
  const bboxMap = buildAneurysmBBoxes(annotations);

  // โ”€โ”€ Axial ๋ทฐ (coronal ๋ Œ๋”๋Ÿฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  // ํ˜„์žฌ K ์Šฌ๋ผ์ด์Šค์— ํ•ด๋‹นํ•˜๋Š” annotation ์›๋ณธ ์ขŒํ‘œ ์‚ฌ์šฉ
  drawForRenderer('coronal', (ctx, renderer, rw) => {
    const k = sliceIndices.k;
    const sliceAnns = annotations.filter(a => a.instance_number === k);

    for (const ann of sliceAnns) {
      const xs = ann.graphic_data.map(p => p[0]);
      const ys = ann.graphic_data.map(p => p[1]);
      // ๋‘ ๊ผญ์ง“์ : (iMin, jMin, k) and (iMax, jMax, k)
      const [x1, y1] = iJKToCanvas([Math.min(...xs), Math.min(...ys), k], renderer, rw);
      const [x2, y2] = iJKToCanvas([Math.max(...xs), Math.max(...ys), k], renderer, rw);
      ctx.strokeStyle = ann.color;
      ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
    }
  });

  // โ”€โ”€ Sagittal ๋ทฐ (sagittal ๋ Œ๋”๋Ÿฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  // ํ˜„์žฌ sliceI๊ฐ€ 3D BBox์˜ I ๋ฒ”์œ„์— ํฌํ•จ๋˜๋ฉด Jร—K ์‚ฌ๊ฐํ˜•
  drawForRenderer('sagittal', (ctx, renderer, rw) => {
    const i = sliceIndices.i;

    bboxMap.forEach((bbox) => {
      if (i < bbox.iMin || i > bbox.iMax) return;  // ๋ฒ”์œ„ ๋ฐ–

      // Sagittal(I์ถ• ๊ณ ์ •): ๊ฐ€๋กœ=K, ์„ธ๋กœ=J
      // ์ขŒ์ƒ๋‹จ (i, jMin, kMin) โ†’ ์šฐํ•˜๋‹จ (i, jMax, kMax)
      const [x1, y1] = iJKToCanvas([i, bbox.jMin, bbox.kMin], renderer, rw);
      const [x2, y2] = iJKToCanvas([i, bbox.jMax, bbox.kMax], renderer, rw);
      ctx.strokeStyle = bbox.color;
      ctx.strokeRect(
        Math.min(x1, x2), Math.min(y1, y2),
        Math.abs(x2 - x1), Math.abs(y2 - y1),
      );
    });
  });

  // โ”€โ”€ Coronal ๋ทฐ (axial ๋ Œ๋”๋Ÿฌ) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
  // ํ˜„์žฌ sliceJ๊ฐ€ 3D BBox์˜ J ๋ฒ”์œ„์— ํฌํ•จ๋˜๋ฉด Iร—K ์‚ฌ๊ฐํ˜•
  drawForRenderer('axial', (ctx, renderer, rw) => {
    const j = sliceIndices.j;

    bboxMap.forEach((bbox) => {
      if (j < bbox.jMin || j > bbox.jMax) return;  // ๋ฒ”์œ„ ๋ฐ–

      // Coronal(J์ถ• ๊ณ ์ •): ๊ฐ€๋กœ=I, ์„ธ๋กœ=K
      // ์ขŒ์ƒ๋‹จ (iMin, j, kMin) โ†’ ์šฐํ•˜๋‹จ (iMax, j, kMax)
      const [x1, y1] = iJKToCanvas([bbox.iMin, j, bbox.kMin], renderer, rw);
      const [x2, y2] = iJKToCanvas([bbox.iMax, j, bbox.kMax], renderer, rw);
      ctx.strokeStyle = bbox.color;
      ctx.strokeRect(
        Math.min(x1, x2), Math.min(y1, y2),
        Math.abs(x2 - x1), Math.abs(y2 - y1),
      );
    });
  });
}
๐Ÿ”‘ iJKToCanvas๋Š” โ‘กโ‘ขโ‘ฃ๋ฅผ ํ•œ ๋ฒˆ์— ์ˆ˜ํ–‰ํ•˜๋Š” ํ—ฌํผ์ž…๋‹ˆ๋‹ค โ€” IJK โ†’ World(ijkToWorld) โ†’ Display(worldToDisplay) โ†’ Canvas(รทDPR). ์ด ํ•จ์ˆ˜๊ฐ€ ์นด๋ฉ”๋ผ ์ƒํƒœ๋ฅผ ์ž๋™ ๋ฐ˜์˜ํ•˜๋ฏ€๋กœ ์คŒยทํŒฌ ํ›„์—๋„ ๋ฐ•์Šค๊ฐ€ ์ •ํ™•ํžˆ ๊ทธ๋ ค์ง‘๋‹ˆ๋‹ค.

๋ทฐ๋ณ„ ํˆฌ์˜ ์ขŒํ‘œ ์š”์•ฝ

๋ทฐ (๋ Œ๋”๋Ÿฌ๋ช…)๊ณ ์ • ์ถ•BBox ๋‘ ๊ผญ์ง“์  IJKํ‘œํ˜„ ํ‰๋ฉด
Axial (coronal) K = sliceK (iMin, jMin, k) ~ (iMax, jMax, k) I(๊ฐ€๋กœ) ร— J(์„ธ๋กœ)
Sagittal (sagittal) I = sliceI (i, jMin, kMin) ~ (i, jMax, kMax) K(๊ฐ€๋กœ) ร— J(์„ธ๋กœ)
Coronal (axial) J = sliceJ (iMin, j, kMin) ~ (iMax, j, kMax) I(๊ฐ€๋กœ) ร— K(์„ธ๋กœ)
06 ยท Camera Sync

์คŒยทํŒฌยท๋ฆฌ์‚ฌ์ด์ฆˆ ๋™๊ธฐํ™” โ€” onModified ๊ตฌ๋…

bbox๊ฐ€ ์ฒ˜์Œ ๊ทธ๋ ค์ง„ ๋’ค ์‚ฌ์šฉ์ž๊ฐ€ ์คŒยทํŒฌํ•˜๋ฉด VTK WebGL ๋ Œ๋”๋ง์€ ์—…๋ฐ์ดํŠธ๋˜์ง€๋งŒ Canvas ์˜ค๋ฒ„๋ ˆ์ด๋Š” ๊ทธ๋Œ€๋กœ์ž…๋‹ˆ๋‹ค. renderWindow.onModified()๋ฅผ ๊ตฌ๋…ํ•ด VTK ๋ทฐ๊ฐ€ ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค Canvas๋ฅผ ์žฌ๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

1
์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ โ€” Canvas ์ƒ์„ฑ & ๊ตฌ๋… ์‹œ์ž‘
containerRef ์•ˆ์— position: absolute์˜ ํˆฌ๋ช… Canvas๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. VTK Canvas์™€ ์™„์ „ํžˆ ๊ฒน์น˜๋„๋ก ํฌ๊ธฐ๋ฅผ ๋งž์ถฅ๋‹ˆ๋‹ค. renderWindow.onModified()๋กœ VTK ๋ Œ๋” ์ด๋ฒคํŠธ๋ฅผ ๊ตฌ๋…ํ•ฉ๋‹ˆ๋‹ค.
2
์‚ฌ์šฉ์ž ์คŒยทํŒฌ โ†’ VTK onModified ๋ฐœ์ƒ
์นด๋ฉ”๋ผ ๋ณ€ํ™˜ ํ–‰๋ ฌ์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค drawOverlay()๊ฐ€ ํ˜ธ์ถœ๋ฉ๋‹ˆ๋‹ค. ์ด ํ•จ์ˆ˜ ๋‚ด์—์„œ worldToDisplay๊ฐ€ ํ˜„์žฌ ์นด๋ฉ”๋ผ ๊ธฐ์ค€์œผ๋กœ Display ์ขŒํ‘œ๋ฅผ ๋‹ค์‹œ ๊ณ„์‚ฐํ•˜๋ฏ€๋กœ bbox๊ฐ€ ์˜ฌ๋ฐ”๋ฅธ ์œ„์น˜์— ๊ทธ๋ ค์ง‘๋‹ˆ๋‹ค.
3
sliceIndices ๋ณ€๊ฒฝ ์‹œ โ€” useEffect๋กœ ์žฌ๊ทธ๋ฆฌ๊ธฐ
์Šฌ๋ผ์ด์Šค๋ฅผ ์Šคํฌ๋กคํ•  ๋•Œ๋Š” VTK onModified๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. useEffect([sliceIndices])์—์„œ drawOverlay()๋ฅผ ์ถ”๊ฐ€๋กœ ํ˜ธ์ถœํ•ด ์Šฌ๋ผ์ด์Šค ์ด๋™๋„ ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.
4
์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ ์‹œ โ€” ๊ตฌ๋… ํ•ด์ œ & Canvas ์ œ๊ฑฐ
sub.unsubscribe()๋กœ VTK ์ด๋ฒคํŠธ ๊ตฌ๋…์„ ํ•ด์ œํ•˜๊ณ , Canvas DOM์„ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€.
// BBoxOverlay.tsx โ€” ์ „์ฒด Effect ๊ตฌ์กฐ
useEffect(() => {
  const rw = fullScreenRendererRef.current?.getRenderWindow();
  if (!rw || !annotations || annotations.length === 0) return;

  // 1. Canvas ์ƒ์„ฑ ๋ฐ ๋ฐฐ์น˜
  const canvas = document.createElement('canvas');
  canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
  containerRef.current?.appendChild(canvas);

  // 2. Canvas ํฌ๊ธฐ โ€” VTK renderWindow ๋ฌผ๋ฆฌ ํ”ฝ์…€ ๊ธฐ์ค€
  const [w, h] = rw.getSize();
  const dpr = window.devicePixelRatio || 1;
  canvas.width  = w;
  canvas.height = h;
  canvas.style.width  = `${w / dpr}px`;
  canvas.style.height = `${h / dpr}px`;

  // 3. VTK onModified ๊ตฌ๋… โ†’ ์คŒ/ํŒฌ/๋ฆฌ์‚ฌ์ด์ฆˆ ์‹œ ์žฌ๊ทธ๋ฆฌ๊ธฐ
  const sub = rw.onModified(() => drawOverlay(canvas));

  drawOverlay(canvas);  // ์ดˆ๊ธฐ ๊ทธ๋ฆฌ๊ธฐ

  return () => {
    sub.unsubscribe();
    canvas.remove();
  };
}, [fullScreenRendererRef, annotations]);

// 4. ์Šฌ๋ผ์ด์Šค ์ด๋™ ์‹œ ์žฌ๊ทธ๋ฆฌ๊ธฐ
useEffect(() => {
  if (canvasRef.current) drawOverlay(canvasRef.current);
}, [sliceIndices]);
07 ยท Full Flow

BBoxOverlay ์ „์ฒด ๋™์ž‘ ํ๋ฆ„

Props: annotations | imageData | sliceIndices | renderers | fullScreenRendererRef BBoxOverlay ์ปดํฌ๋„ŒํŠธ ๋งˆ์šดํŠธ ์‹œ ์ˆ˜์‹  buildAneurysmBBoxes(annotations) aneurysm_id โ†’ {iMin,iMax,jMin,jMax,kMin,kMax} Canvas ์ƒ์„ฑ + renderWindow.onModified() ๊ตฌ๋… DPR ๋ณด์ • ํฌ๊ธฐ ์„ค์ • / pointer-events: none drawOverlay(canvas) Axial (coronal ๋ Œ๋”๋Ÿฌ) sliceK ํ•„ํ„ฐ โ†’ graphic_data ์›๋ณธ โ†’ IJKโ†’Worldโ†’Displayโ†’Canvas Sagittal (sagittal ๋ Œ๋”๋Ÿฌ) iMinโ‰คsliceIโ‰คiMax ์ฒดํฌ โ†’ Jร—K ํˆฌ์˜ โ†’ (i,jMin,kMin)~(i,jMax,kMax) ๋ณ€ํ™˜ Coronal (axial ๋ Œ๋”๋Ÿฌ) jMinโ‰คsliceJโ‰คjMax ์ฒดํฌ โ†’ Iร—K ํˆฌ์˜ โ†’ (iMin,j,kMin)~(iMax,j,kMax) ๋ณ€ํ™˜
08 ยท Pitfalls

๊ตฌํ˜„ ์‹œ ์ฃผ์˜ํ•  ์ 

ํ•จ์ •์ฆ์ƒํ•ด๊ฒฐ
๋ Œ๋”๋Ÿฌ ๋ช…์นญ ์—ญ์ „ ๋ฐ•์Šค๊ฐ€ Axial์— ๊ทธ๋ ค์•ผ ํ•  ๊ฒƒ์ด Coronal์— ๋‚˜ํƒ€๋‚จ coronalโ†’Axial, axialโ†’Coronal ๋งคํ•‘ ํ‘œ๋ฅผ ์ฝ”๋“œ ์ฃผ์„์œผ๋กœ ๋ช…์‹œ
VTK Y์ถ• ๋ฐ˜์ „ ๋ˆ„๋ฝ ๋ฐ•์Šค๊ฐ€ ์œ„์•„๋ž˜ ๋’ค์ง‘ํ˜€ ๋‚˜ํƒ€๋‚จ winH - displayY๋กœ ๋ฐ˜์ „. getSize()[1]์€ ๋งค๋ฒˆ ์ตœ์‹  ๊ฐ’ ์‚ฌ์šฉ
DPR ๋ณด์ • ๋ˆ„๋ฝ Retina ํ™”๋ฉด์—์„œ ๋ฐ•์Šค๊ฐ€ ์ ˆ๋ฐ˜ ํฌ๊ธฐ ๋˜๋Š” ์˜คํ”„์…‹ ์–ด๊ธ‹๋‚จ Display ์ขŒํ‘œ รท devicePixelRatio. Canvas CSS ํฌ๊ธฐ๋„ ๋™์ผ ์ ์šฉ
vtkCoordinate delete ๋ˆ„๋ฝ ์žฅ์‹œ๊ฐ„ ์‚ฌ์šฉ ์‹œ WebGL ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ coord.delete() ํ•ญ์ƒ ํ˜ธ์ถœ. Object Pool ํŒจํ„ด ์ ์šฉ ๊ถŒ์žฅ
drawOverlay ์ค‘๋ณต ํ˜ธ์ถœ ๋น ๋ฅธ ์Šคํฌ๋กค ์‹œ ๋ถˆํ•„์š”ํ•œ ๋ฆฌ๋ Œ๋” ๋ˆ„์  requestAnimationFrame์œผ๋กœ debounce. ์ด๋ฏธ pending์ด๋ฉด skip
sliceIndices ref vs state drawOverlay ํด๋กœ์ €๊ฐ€ ์˜ค๋ž˜๋œ ์ธ๋ฑ์Šค๋ฅผ ์ฐธ์กฐ drawOverlay ๋‚ด๋ถ€์—์„œ sliceIndices๋ฅผ ref๋กœ ์ฝ๊ฑฐ๋‚˜ Effect ์˜์กด์„ฑ ๋ช…์‹œ
โšก vtkCoordinate๋Š” ์ƒ์„ฑ ๋น„์šฉ์ด ๋†’์Šต๋‹ˆ๋‹ค. buildAneurysmBBoxes๋กœ ๊ณ„์‚ฐ๋œ ๊ผญ์ง“์  ์ˆ˜(๋ณ‘๋ณ€ ์ˆ˜ ร— 2)๋Š” ๋ณดํ†ต 10~30๊ฐœ ์ˆ˜์ค€์ด๋ฏ€๋กœ drawOverlay๋‹น ์ƒ์„ฑยท์‚ญ์ œ๊ฐ€ ํฌ๊ฒŒ ๋ฌธ์ œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•˜์ง€๋งŒ annotation์ด ์ˆ˜๋ฐฑ ๊ฐœ๋ผ๋ฉด Object Pool ํŒจํ„ด(์ตœ๋Œ€ 4๊ฐœ ์žฌ์‚ฌ์šฉ)์„ ์ ์šฉํ•˜์„ธ์š”.
09 ยท Series Wrap-up

3ํŽธ ์š”์•ฝ & ์‹œ๋ฆฌ์ฆˆ ๋งˆ๋ฌด๋ฆฌ

์ฃผ์ œํ•ต์‹ฌ ๋‚ด์šฉ
์ขŒํ‘œ๊ณ„ ๋ช…๋ช… ์—ญ์ „coronal ๋ Œ๋”๋Ÿฌ = Axial UI / axial ๋ Œ๋”๋Ÿฌ = Coronal UI
ํ”ฝ์…€ โ†’ IJKAxial: px=i, py=j, k=sliceK ์ง์ ‘ ๋งคํ•‘
IJK โ†’ Worldorigin + IJK ร— spacing (imageData API)
World โ†’ DisplayvtkCoordinate ยท Y์ถ• ๋ฐ˜์ „ (winH - dy)
Display โ†’ Canvasรท devicePixelRatio
3D BBox ์žฌ๊ตฌ์„ฑbuildAneurysmBBoxes โ€” ์Šฌ๋ผ์ด์Šค ์ „์ฒด annotation ๋ˆ„์ 
Sagittal ํˆฌ์˜iMinโ‰คsliceIโ‰คiMax ์ฒดํฌ โ†’ (i,jMin,kMin)~(i,jMax,kMax)
Coronal ํˆฌ์˜jMinโ‰คsliceJโ‰คjMax ์ฒดํฌ โ†’ (iMin,j,kMin)~(iMax,j,kMax)
์นด๋ฉ”๋ผ ๋™๊ธฐํ™”renderWindow.onModified() โ†’ drawOverlay() ์žฌํ˜ธ์ถœ
๐ŸŽฏ ์ด๋ฒˆ ์‹œ๋ฆฌ์ฆˆ์—์„œ ํ•œ ์ผ์„ ๋Œ์•„๋ณด๋ฉด โ€” ์ œ๊ฑฐ๊ฐ€ ํ•ต์‹ฌ์ด์—ˆ์Šต๋‹ˆ๋‹ค. SEG NIfTI fetch, gzip ํ•ด์ œ, Web Worker ๋ฆฌ์ƒ˜ํ”Œ, VTK ImageData ์ƒ์„ฑ, GPU ํ…์Šค์ฒ˜ ์—…๋กœ๋“œ. ์ด ๋‹ค์„ฏ ๋‹จ๊ณ„๋ฅผ ๋ชจ๋‘ ์—†์• ๊ณ  JSON ์ˆ˜ KB๋กœ ๋Œ€์ฒดํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ ๊ฒฐ๊ณผ๋กœ ๋กœ๋”ฉ ์‹œ๊ฐ„์ด ์ค„์—ˆ๊ณ , ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ด ์ค„์—ˆ๊ณ , ์ฝ”๋“œ๊ฐ€ ๋‹จ์ˆœํ•ด์กŒ์œผ๋ฉฐ, 3๊ฐœ ๋ทฐ ๋ชจ๋‘์— ๋ฐ•์Šค๋ฅผ ๊ทธ๋ฆด ์ˆ˜ ์žˆ๊ฒŒ ๋์Šต๋‹ˆ๋‹ค. ์ข‹์€ ์„ฑ๋Šฅ ์ตœ์ ํ™”์˜ ๋Œ€๋ถ€๋ถ„์€ "๋” ์ž˜ํ•˜๋Š” ๊ฒƒ"์ด ์•„๋‹ˆ๋ผ "ํ•˜์ง€ ์•Š๋Š” ๊ฒƒ"์—์„œ ๋‚˜์˜ต๋‹ˆ๋‹ค.

๐ŸŽ‰ ์‹œ๋ฆฌ์ฆˆ ์™„๊ฒฐ

SEG NIfTI โ†’ BBox JSON ์ „ํ™˜ 3๋ถ€์ž‘์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

1ํŽธ ยท ์ „ํ™˜ ๋ฐฐ๊ฒฝ๊ณผ ๋ณ‘๋ชฉ ๋ถ„์„ โ†’ 2ํŽธ ยท API ์„ค๊ณ„์™€ Canvas ๊ตฌํ˜„ โ†’ 3ํŽธ ยท VTK ์ขŒํ‘œ ๋ณ€ํ™˜๊ณผ 3๋ทฐ ๋™๊ธฐํ™”

NIfTI Viewer ํ”„๋กœ์ ํŠธ ยท SEG โ†’ BBox ์ „ํ™˜ ์‹œ๋ฆฌ์ฆˆ 3ํŽธ(ๅฎŒ) ยท 2026