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

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

[React] BBox JSON API ์„ค๊ณ„์™€ Canvas ์˜ค๋ฒ„๋ ˆ์ด ๊ตฌํ˜„

BBox JSON API ์„ค๊ณ„์™€ Canvas ์˜ค๋ฒ„๋ ˆ์ด ๊ตฌํ˜„
2ํŽธ / 3ํŽธ

BBox JSON API ์„ค๊ณ„์™€
Canvas ์˜ค๋ฒ„๋ ˆ์ด ๊ตฌํ˜„

BE๊ฐ€ ๋‚ด๋ ค์ฃผ๋Š” JSON ๊ตฌ์กฐ๋ฅผ ์–ด๋–ป๊ฒŒ ์„ค๊ณ„ํ–ˆ๋Š”์ง€, FE์—์„œ ๊ทธ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ›์•„ AxialGridViewer์™€ BBoxOverlay์—์„œ ์–ด๋–ป๊ฒŒ Canvas๋กœ ๊ทธ๋ฆฌ๋Š”์ง€ ๊ตฌํ˜„ ์ฝ”๋“œ ์ค‘์‹ฌ์œผ๋กœ ์„ค๋ช…ํ•ฉ๋‹ˆ๋‹ค.

BBoxAnnotationItem fetchAxialBBox drawBBoxOverlay useNiftiLoader ๋ณ€๊ฒฝ Canvas 2D BBoxOverlay ์ปดํฌ๋„ŒํŠธ

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

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

BBox JSON โ€” ๋ฌด์—‡์„ ์–ด๋–ป๊ฒŒ ๋‹ด์•˜๋‚˜

๊ฐ€์žฅ ๋จผ์ € ๊ฒฐ์ •ํ•ด์•ผ ํ•  ๊ฒƒ์€ BE๊ฐ€ FE์— ๋ฌด์—‡์„ ๋‚ด๋ ค์ค„ ๊ฒƒ์ธ๊ฐ€์˜€์Šต๋‹ˆ๋‹ค. SEG NIfTI์—์„œ ์šฐ๋ฆฌ๊ฐ€ ์‹ค์ œ๋กœ ํ•„์š”ํ•œ ์ •๋ณด๋งŒ ์ถ”๋ ค๋ณด๋ฉด ์„ธ ๊ฐ€์ง€์ž…๋‹ˆ๋‹ค.

๐Ÿ—‚๏ธ

์–ด๋А ์Šฌ๋ผ์ด์Šค์—

๋ณ‘๋ณ€์ด ๋“ฑ์žฅํ•˜๋Š” ์Šฌ๋ผ์ด์Šค ๋ฒˆํ˜ธ(instance_number). ํ˜„์žฌ ์Šฌ๋ผ์ด์Šค์™€ ๋น„๊ตํ•ด ๊ทธ๋ฆด์ง€ ๋ง์ง€๋ฅผ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“

์–ด๋А ์œ„์น˜์—

bbox๋ผ๋ฉด ์ขŒ์ƒ๋‹จยท์šฐํ•˜๋‹จ ์ขŒํ‘œ, contour๋ผ๋ฉด ํด๋ฆฌ๋ผ์ธ ์ขŒํ‘œ ๋ฐฐ์—ด. Canvas strokeRect / beginPath๋กœ ๋ฐ”๋กœ ์“ธ ์ˆ˜ ์žˆ๋Š” ํ˜•ํƒœ.

๐ŸŽจ

์–ด๋–ค ์ƒ‰์œผ๋กœ

aneurysm_id๋ณ„ ๊ณ ์œ  ์ƒ‰์ƒ. ๊ฐ™์€ ๋ณ‘๋ณ€์ด ์—ฌ๋Ÿฌ ์Šฌ๋ผ์ด์Šค์— ๊ฑธ์ณ ์žˆ์–ด๋„ ๋™์ผํ•œ ์ƒ‰์œผ๋กœ ์ถ”์ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ด ์„ธ ๊ฐ€์ง€๋ฅผ ๋‹ด์€ ์ตœ์†Œ ๊ตฌ์กฐ๊ฐ€ BBoxAnnotationItem์ž…๋‹ˆ๋‹ค.

utils/sliceRenderer.ts โ€” BBoxAnnotationItem ํƒ€์ž…
/** BE bbox/contour annotation ๋‹จ์ผ ํ•ญ๋ชฉ */
export interface BBoxAnnotationItem {
  instance_number: number;          // ์Šฌ๋ผ์ด์Šค ๋ฒˆํ˜ธ (0-indexed)
  aneurysm_id:     number;          // ๋ณ‘๋ณ€ ID โ€” ๋™์ผ ๋ณ‘๋ณ€ ์Šฌ๋ผ์ด์Šค ์ถ”์ 
  type:            'bbox' | 'contour'; // ๋ Œ๋”๋ง ๋ฐฉ์‹ ์„ ํƒ
  graphic_data:    [number, number][]; // ํ”ฝ์…€ ์ขŒํ‘œ ๋ฐฐ์—ด
  color:           string;           // CSS ์ƒ‰์ƒ ๋ฌธ์ž์—ด (ex. '#ff4444')
}

์‹ค์ œ ์‘๋‹ต ์˜ˆ์‹œ

// GET /dicomweb/v1/axial-bbox/studies/{study_uid}
{
  "study_uid": "1.2.840.114...",
  "annotations": [

    // ์Šฌ๋ผ์ด์Šค 150๋ฒˆ: aneurysm #1, bbox ํƒ€์ž…
    {
      "instance_number": 150,
      "aneurysm_id":     1,
      "type":            "bbox",
      "graphic_data":    [[120, 80], [200, 160]],
      "color":           "#ff4444"
    },

    // ์Šฌ๋ผ์ด์Šค 151๋ฒˆ: ๊ฐ™์€ aneurysm #1, contour ํƒ€์ž…
    {
      "instance_number": 151,
      "aneurysm_id":     1,
      "type":            "contour",
      "graphic_data": [
        [122, 82], [180, 80], [198, 130],
        [160, 162], [118, 155]
      ],
      "color":           "#ff4444"
    },

    // ์Šฌ๋ผ์ด์Šค 200๋ฒˆ: aneurysm #2 (๋ณ„๋„ ๋ณ‘๋ณ€)
    {
      "instance_number": 200,
      "aneurysm_id":     2,
      "type":            "bbox",
      "graphic_data":    [[300, 200], [380, 280]],
      "color":           "#44aaff"
    }
  ]
}
๐Ÿ’ก graphic_data์˜ ์ขŒํ‘œ๊ณ„๋Š” AxialGridViewer ๊ธฐ์ค€ ํ”ฝ์…€ ์ขŒํ‘œ์ž…๋‹ˆ๋‹ค. Canvas 1:1 ๋ณต์…€ ๋งคํ•‘๊ณผ ์ผ์น˜ํ•˜๋ฏ€๋กœ FE์—์„œ ๋ณ„๋„ ์ขŒํ‘œ ๋ณ€ํ™˜ ์—†์ด ๋ฐ”๋กœ strokeRect / lineTo์— ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. BBoxOverlay(VTK)์—์„œ๋Š” IJKโ†’Worldโ†’Display ๋ณ€ํ™˜์ด ํ•„์š”ํ•˜๋ฉฐ, ์ด๋Š” 3ํŽธ์—์„œ ๋‹ค๋ฃน๋‹ˆ๋‹ค.

graphic_data ํ•ด์„ ๊ทœ์น™

typegraphic_data ํ˜•์‹ํ•ด์„
bbox [[x0, y0], [x1, y1]] (์ตœ์†Œ 2์ ) min(x), min(y) โ†’ max(x), max(y)๋กœ strokeRect ๊ฒฐ์ •
contour [[x0,y0], [x1,y1], ...] (์ตœ์†Œ 3์ ) ์ฒซ ์ ์—์„œ moveTo, ์ดํ›„ lineTo, ๋งˆ์ง€๋ง‰์— closePath
02 ยท API Layer

fetchAxialBBox โ€” API ํ•จ์ˆ˜ ์ถ”๊ฐ€

niftiApi.ts์— BBox ์ „์šฉ fetch ํ•จ์ˆ˜์™€ ์‘๋‹ต ํƒ€์ž…์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ์กด fetch ํŒจํ„ด๊ณผ ๋™์ผํ•œ ๊ตฌ์กฐ์ด๋ฏ€๋กœ ํ†ต์ผ์„ฑ์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค.

api/niftiApi.ts
import type { BBoxAnnotationItem } from '../utils/sliceRenderer';

/** /axial-bbox/ ์—”๋“œํฌ์ธํŠธ ์‘๋‹ต */
export interface AxialBBoxResponse {
  study_uid:   string;
  annotations: BBoxAnnotationItem[];
}

/**
 * Axial BBox annotation JSON์„ API์—์„œ ๊ฐ€์ ธ์˜จ๋‹ค
 * GET /dicomweb/v1/axial-bbox/studies/{study_uid}
 *
 * @param studyUid  ์Šคํ„ฐ๋”” UID
 * @returns         AxialBBoxResponse (study_uid + annotations[])
 */
export async function fetchAxialBBox(studyUid: string): Promise<AxialBBoxResponse> {
  const baseURL = API_SERVER_URI || '';
  const url = `${baseURL}/dicomweb/v1/axial-bbox/studies/${studyUid}`;

  const response = await fetch(url, { headers: getAuthHeaders() });
  if (!response.ok) {
    throw new Error(
      `Axial BBox fetch failed: ${response.status} ${response.statusText}`
    );
  }
  return response.json();
}
โšก ๊ธฐ์กด fetchAxialSegVolume์€ ์ˆ˜์‹ญ MB ArrayBuffer๋ฅผ ๋ฐ›์•„์„œ IndexedDB ์บ์‹ฑ ๋กœ์ง๊นŒ์ง€ ํฌํ•จํ–ˆ์Šต๋‹ˆ๋‹ค. fetchAxialBBox๋Š” JSON ํŒŒ์‹ฑ ํ•œ ์ค„์ด ์ „๋ถ€์ž…๋‹ˆ๋‹ค. ์บ์‹ฑ๋„ ๋ถˆํ•„์š”ํ•ฉ๋‹ˆ๋‹ค โ€” ์‘๋‹ต์ด ์ˆ˜ KB์ด๋ฏ€๋กœ ๋งค๋ฒˆ fetchํ•ด๋„ ๋ถ€๋‹ด์ด ์—†์Šต๋‹ˆ๋‹ค.
03 ยท Hook Changes

useNiftiLoader โ€” Seg์—์„œ BBox๋กœ

ํ•ต์‹ฌ ๋กœ๋”ฉ ํ›…์ธ useNiftiLoader.ts์—์„œ ๊ฐ€์žฅ ๋งŽ์€ ์ฝ”๋“œ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. Seg ๊ด€๋ จ ๋กœ์ง์ด ์ „๋ถ€ ๋น ์ง€๊ณ  BBox fetch๊ฐ€ ๊ทธ ์ž๋ฆฌ๋ฅผ ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค.

์ œ๊ฑฐ๋œ import

// โŒ ์ œ๊ฑฐ
import { fetchAxialSegVolume, fetchSegVolume } from '../api/niftiApi';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import { createSegVtkImageData, createSegPlanarSlices } from '../utils/vtkMapper';
import type { SegProcessRequest, SegProcessResponse } from '../workers/segWorker';

์ถ”๊ฐ€๋œ import

// โœ… ์ถ”๊ฐ€
import { fetchAxialBBox } from '../api/niftiApi';
import type { BBoxAnnotationItem } from '../utils/sliceRenderer';

loadVolumes ๋‚ด๋ถ€ โ€” fetch ๋ณ‘๋ ฌํ™” ๋ณ€๊ฒฝ

// โ”€โ”€โ”€ Before โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const [baseResult, segResult] = await Promise.allSettled([
  fetchBase(),
  fetchSegVolume(studyUid),            // NIfTI ArrayBuffer
]);

// seg Worker ์ฒ˜๋ฆฌ (๋ฆฌ์ƒ˜ํ”Œ + RGBA ๋ณ€ํ™˜) ...
const segResp = await runSegWorker(segBuffer, baseDims);
createSegVtkImageData(segResp);
createSegPlanarSlices(segImageData);

// โ”€โ”€โ”€ After โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const [baseResult, bboxResult] = await Promise.allSettled([
  fetchBase(),
  shouldSkipSeg || !studyUid
    ? Promise.reject(new Error('BBox skipped'))
    : fetchAxialBBox(studyUid),           // JSON ์ˆ˜ KB
]);

// JSON ํŒŒ์‹ฑ๋งŒ โ€” Worker ์—†์Œ, VTK ImageData ์—†์Œ
if (bboxResult.status === 'fulfilled') {
  setBboxAnnotations(bboxResult.value.annotations);
}

๋ฐ˜ํ™˜๊ฐ’์— bboxAnnotations ์ถ”๊ฐ€

// return ํƒ€์ž… ๋ณ€๊ฒฝ
return {
  isLoading,
  error,
  windowLevel,
  loadVolumes,
  bboxAnnotations,    // โœ… ์ƒˆ๋กœ ์ถ”๊ฐ€ (BBoxAnnotationItem[] | null)
};
useNiftiLoader ๋ฐ์ดํ„ฐ ํ๋ฆ„ ๋ณ€ํ™” Before โ€” Seg ํฌํ•จ ๋ณ‘๋ ฌ fetch fetchBase() fetchSegVolume() โ†’ Worker โ†’ VTK allSettled After โ€” BBox JSON ๋ณ‘๋ ฌ fetch fetchBase() fetchAxialBBox() โ†’ JSON ํŒŒ์‹ฑ๋งŒ allSettled Worker ๋Œ€๊ธฐ + VTK ImageData ์ƒ์„ฑ โ†’ +10~30์ดˆ ๋ฉ”๋ชจ๋ฆฌ: +600MB~1.2GB JSON ํŒŒ์‹ฑ ์ˆ˜ ms โ†’ Base fetch ์™„๋ฃŒ ์ „ ์ด๋ฏธ ๋๋‚จ ๋ฉ”๋ชจ๋ฆฌ: ~0 MB
04 ยท Canvas Drawing

drawBBoxOverlay โ€” Canvas 2D ๋ Œ๋”๋ง ํ•จ์ˆ˜

sliceRenderer.ts์— ์ถ”๊ฐ€๋œ ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. AxialGridViewer์—์„œ ๊ฐ ์…€์˜ renderAxialSlice() ์งํ›„ ๊ฐ™์€ ctx์— ํ˜ธ์ถœํ•ด bbox/contour๋ฅผ ๋ง๊ทธ๋ฆฝ๋‹ˆ๋‹ค.

utils/sliceRenderer.ts โ€” drawBBoxOverlay()
/**
 * Canvas 2D์— BBox/Contour ์–ด๋…ธํ…Œ์ด์…˜์„ ๊ทธ๋ฆฐ๋‹ค.
 * renderAxialSlice() ํ˜ธ์ถœ ์งํ›„ ๊ฐ™์€ ctx์— ์‚ฌ์šฉ.
 *
 * @param ctx         ๋ Œ๋”๋ง ๋Œ€์ƒ Canvas 2D Context
 * @param annotations BBoxAnnotationItem ๋ฐฐ์—ด (์ „์ฒด)
 * @param sliceK      ํ˜„์žฌ ์Šฌ๋ผ์ด์Šค ๋ฒˆํ˜ธ (K์ถ• ์ธ๋ฑ์Šค)
 * @param lineWidth   ์„  ๊ตต๊ธฐ (๊ธฐ๋ณธ 2)
 */
export function drawBBoxOverlay(
  ctx:         CanvasRenderingContext2D,
  annotations: BBoxAnnotationItem[],
  sliceK:      number,
  lineWidth:   number = 2,
): void {

  // 1. ํ˜„์žฌ ์Šฌ๋ผ์ด์Šค์— ํ•ด๋‹นํ•˜๋Š” annotation๋งŒ ํ•„ํ„ฐ๋ง
  const sliceAnnotations = annotations.filter(
    a => a.instance_number === sliceK
  );
  if (sliceAnnotations.length === 0) return;  // ์—†์œผ๋ฉด ์ฆ‰์‹œ ์ข…๋ฃŒ

  ctx.save();
  ctx.lineWidth = lineWidth;

  for (const ann of sliceAnnotations) {
    ctx.strokeStyle = ann.color;

    // 2a. BBox โ€” strokeRect์œผ๋กœ ๋‹จ์ผ ์‚ฌ๊ฐํ˜•
    if (ann.type === 'bbox' && ann.graphic_data.length >= 2) {
      const xs = ann.graphic_data.map(p => p[0]);
      const ys = ann.graphic_data.map(p => p[1]);
      ctx.strokeRect(
        Math.min(...xs),               // x ์‹œ์ž‘
        Math.min(...ys),               // y ์‹œ์ž‘
        Math.max(...xs) - Math.min(...xs),  // width
        Math.max(...ys) - Math.min(...ys),  // height
      );
    }

    // 2b. Contour โ€” ํด๋ฆฌ๋ผ์ธ + closePath
    else if (ann.type === 'contour' && ann.graphic_data.length >= 3) {
      ctx.beginPath();
      ctx.moveTo(ann.graphic_data[0][0], ann.graphic_data[0][1]);
      for (let i = 1; i < ann.graphic_data.length; i++) {
        ctx.lineTo(ann.graphic_data[i][0], ann.graphic_data[i][1]);
      }
      ctx.closePath();
      ctx.stroke();
    }
  }

  ctx.restore();  // strokeStyle, lineWidth ์›๋ณต
}
๐Ÿ“ graphic_data์— ์ ์ด 2๊ฐœ ์ด์ƒ์ด๋ฉด min/max๋กœ ์‚ฌ๊ฐํ˜•์„ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค. BE๊ฐ€ ๊ผญ [์ขŒ์ƒ๋‹จ, ์šฐํ•˜๋‹จ] ์ˆœ์„œ๋กœ ์ฃผ์ง€ ์•Š์•„๋„ ๋˜๊ณ , ์—ฌ๋Ÿฌ ์ ์˜ ๋ณผ๋ก ๊ป์งˆ(convex hull)๋กœ ์ „๋‹ฌํ•ด๋„ strokeRect๊ฐ€ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ทธ๋ ค์ง‘๋‹ˆ๋‹ค.
05 ยท AxialGridViewer

AxialGridViewer โ€” ๊ธฐ์กด Canvas์— ๋ง๊ทธ๋ฆฌ๊ธฐ

AxialGridViewer๋Š” ์ด๋ฏธ Canvas 2D๋กœ ์Šฌ๋ผ์ด์Šค๋ฅผ ๊ทธ๋ฆฌ๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์— drawBBoxOverlay๋ฅผ ํ•œ ์ค„๋งŒ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ ๊ตฌ์กฐ ์ž์ฒด๋Š” ๊ฑฐ์˜ ๋ฐ”๋€Œ์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.

Props ์ถ”๊ฐ€

interface AxialGridViewerProps {
  // ... ๊ธฐ์กด props (baseScalars, segScalars, windowLevel ๋“ฑ) ...
  bboxAnnotations: BBoxAnnotationItem[] | null;  // โœ… ์ถ”๊ฐ€
}

renderAll ๋‚ด๋ถ€ โ€” ์Šฌ๋ผ์ด์Šค ๋ Œ๋” ์งํ›„ bbox ์ถ”๊ฐ€

// renderAll ํ•จ์ˆ˜ ๋‚ด๋ถ€ โ€” ๊ฐ ์…€(sliceK) ๋ Œ๋”๋ง ๋ฃจํ”„
for (const { ctx, sliceK } of cells) {

  // ๊ธฐ์กด: ์Šฌ๋ผ์ด์Šค ํ”ฝ์…€ ๋ Œ๋”๋ง (๋ณ€๊ฒฝ ์—†์Œ)
  renderAxialSlice(
    ctx, baseScalars, baseDims, sliceK,
    colorWindow, colorLevel, baseLUT,
    segScalars, segDims, segLabelLUT,
  );

  // โœ… ์ถ”๊ฐ€: ๊ฐ™์€ ctx์— bbox ์˜ค๋ฒ„๋ ˆ์ด
  if (bboxAnnotations && bboxAnnotations.length > 0) {
    drawBBoxOverlay(ctx, bboxAnnotations, sliceK);
  }
}
AxialGridViewer Canvas ๋ Œ๋”๋ง ๋ ˆ์ด์–ด ๊ตฌ์กฐ ์Šฌ๋ผ์ด์Šค ์…€ (sliceK = 150) โ‘  renderAxialSlice() ํ”ฝ์…€ ๋ฐ์ดํ„ฐ ๋ Œ๋”๋ง (๊ทธ๋ ˆ์ด์Šค์ผ€์ผ ๋ณผ๋ฅจ) โ‘ก drawBBoxOverlay() bbox/contour ๋ง๊ทธ๋ฆผ ๊ฐ™์€ ctx ์žฌ์‚ฌ์šฉ Canvas ๋ ˆ์ด์–ด ํ•ฉ์„ฑ ์›๋ฆฌ โ‘  renderAxialSlice(ctx, ...) Base ๋ณผ๋ฅจ ํ”ฝ์…€์„ Canvas์— putImageData โ‘ก drawBBoxOverlay(ctx, annotations, sliceK) ๊ฐ™์€ ctx์— strokeRect / lineTo ์ถ”๊ฐ€ โ€ข ์ƒˆ Canvas ์—†์Œ โ€ข DOM ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์—†์Œ โ€ข ๊ธฐ์กด ctx.save/restore ํ™œ์šฉ โ€ข ์ฝ”๋“œ ์ถ”๊ฐ€ ๋‹จ 2์ค„
06 ยท BBoxOverlay Component

BBoxOverlay.tsx โ€” VTK ๋ทฐํฌํŠธ ์œ„ Canvas ์˜ค๋ฒ„๋ ˆ์ด

MPR ๋ทฐ(Axial/Sagittal/Coronal)๋Š” VTK WebGL Canvas ์œ„์— ํˆฌ๋ช… Canvas๋ฅผ ๊ฒน์น˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์˜ค๋ฒ„๋ ˆ์ดํ•ฉ๋‹ˆ๋‹ค. AxialGridViewer์™€ ๋‹ฌ๋ฆฌ VTK ์ขŒํ‘œ ๋ณ€ํ™˜์ด ํ•„์š”ํ•œ ๊ฒƒ์ด ํ•ต์‹ฌ ์ฐจ์ด์ž…๋‹ˆ๋‹ค.

โš ๏ธ VTK ์ขŒํ‘œ ๋ณ€ํ™˜(IJKโ†’Worldโ†’Display) ๋กœ์ง์€ ์ด ๊ธ€์˜ ๋ฒ”์œ„๋ฅผ ๋ฒ—์–ด๋‚ฉ๋‹ˆ๋‹ค. 3ํŽธ์—์„œ ์ „์ฒด๋ฅผ ๋‹ค๋ฃน๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์„œ๋Š” ์ปดํฌ๋„ŒํŠธ์˜ Props ๊ณ„์•ฝ๊ณผ ๋ Œ๋”๋ง ํŠธ๋ฆฌ๊ฑฐ ๊ตฌ์กฐ๋งŒ ์‚ดํŽด๋ด…๋‹ˆ๋‹ค.

Props ์„ค๊ณ„

interface BBoxOverlayProps {
  containerRef:          React.RefObject<HTMLElement | null>;
  fullScreenRendererRef: React.RefObject<VtkFullScreenRenderWindow | null>;
  renderers:             RendererSet | null;     // 3๊ฐœ ๋ทฐ ๋ Œ๋”๋Ÿฌ ์„ธํŠธ
  imageData:             VtkImageData | null;   // IJK ๊ณต๊ฐ„ ๊ณ„์‚ฐ์šฉ
  sliceIndices:          SliceIndices | null;   // ํ˜„์žฌ I/J/K ์ธ๋ฑ์Šค
  annotations:           BBoxAnnotationItem[] | null;
  lineWidth?:            number;               // ๊ธฐ๋ณธ 2
}

๋ Œ๋”๋ง ํŠธ๋ฆฌ๊ฑฐ โ€” onModified ๊ตฌ๋…

VTK๋Š” ์นด๋ฉ”๋ผ๊ฐ€ ๋ณ€๊ฒฝ(์คŒ/ํŒฌ/๋ฆฌ์‚ฌ์ด์ฆˆ)๋  ๋•Œ๋งˆ๋‹ค renderWindow.onModified ์ด๋ฒคํŠธ๋ฅผ ๋ฐœ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ์ด ์ด๋ฒคํŠธ๋ฅผ ๊ตฌ๋…ํ•ด bbox Canvas๋ฅผ ์ž๋™ ์žฌ๊ทธ๋ฆฌ๊ธฐํ•ฉ๋‹ˆ๋‹ค.

// BBoxOverlay.tsx ๋‚ด๋ถ€ โ€” VTK ๋ณ€๊ฒฝ ๊ตฌ๋…
useEffect(() => {
  const rw = fullScreenRendererRef.current?.getRenderWindow();
  if (!rw) return;

  // VTK ์นด๋ฉ”๋ผ ๋ณ€๊ฒฝ(์คŒยทํŒฌยท๋ฆฌ์‚ฌ์ด์ฆˆ) ์‹œ Canvas ์žฌ๊ทธ๋ฆฌ๊ธฐ
  const sub = rw.onModified(() => {
    drawOverlay();   // IJKโ†’Worldโ†’Display ์ขŒํ‘œ ์žฌ๊ณ„์‚ฐ ํ›„ Canvas ์—…๋ฐ์ดํŠธ
  });

  return () => sub.unsubscribe();
}, [fullScreenRendererRef, annotations, sliceIndices]);

NiftiViewer์—์„œ์˜ ํ†ตํ•ฉ

// NiftiViewer.tsx
const { isLoading, error, windowLevel, loadVolumes, bboxAnnotations } = niftiLoader;

// VTK ์ปจํ…Œ์ด๋„ˆ ๋‚ด๋ถ€์— ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง
{bboxAnnotations && bboxAnnotations.length > 0 && displaySliceIndices && (
  <BBoxOverlay
    containerRef={containerRef}
    fullScreenRendererRef={fullScreenRendererRef}
    renderers={renderersRef.current}
    imageData={baseImageDataRef.current}
    sliceIndices={displaySliceIndices}
    annotations={bboxAnnotations}
  />
)}
๐Ÿงฉ BBoxOverlay๋Š” bboxAnnotations๊ฐ€ ์—†๊ฑฐ๋‚˜ ๋น„์–ด ์žˆ์œผ๋ฉด ์•„์˜ˆ ๋งˆ์šดํŠธ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. SEG๊ฐ€ ์—†๋Š” ์Šคํ„ฐ๋””์—์„œ ๋ถˆํ•„์š”ํ•œ Canvas DOM ์ƒ์„ฑ๊ณผ VTK ์ด๋ฒคํŠธ ๊ตฌ๋…์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
07 ยท Data Flow

2ํŽธ ๊ตฌํ˜„ ์ „์ฒด ๋ฐ์ดํ„ฐ ํ๋ฆ„

BE API GET /axial-bbox/studies/{uid} fetchAxialBBox() api/niftiApi.ts JSON response.json() useNiftiLoader bboxAnnotations ์ƒํƒœ ์ €์žฅ hooks/useNiftiLoader.ts NiftiViewer props ๋ฐฐ๋ถ„ BBoxOverlay.tsx VTK Canvas ์œ„ ํˆฌ๋ช… Canvas IJKโ†’Worldโ†’Display ๋ณ€ํ™˜ AxialGridViewer.tsx Canvas 2D ์ง์ ‘ ๊ทธ๋ฆฌ๊ธฐ drawBBoxOverlay() ํ˜ธ์ถœ ๋ Œ๋”๋ง ๊ฒฐ๊ณผ BBoxOverlay: Axial/Sagittal/Coronal 3๋ทฐ VTK ์œ„์— bbox ์˜ค๋ฒ„๋ ˆ์ด AxialGridViewer: 4ร—4 ์Šฌ๋ผ์ด์Šค ๊ทธ๋ฆฌ๋“œ ์œ„์— bbox ์ง์ ‘ ๋ Œ๋” ๊ณตํ†ต ํƒ€์ž… ๊ณ„์ธต BBoxAnnotationItem (utils/sliceRenderer.ts) AxialBBoxResponse (api/niftiApi.ts)
08 ยท Summary

2ํŽธ ์š”์•ฝ

๊ตฌ์„ฑ ์š”์†Œ์—ญํ• ํ•ต์‹ฌ ๋ณ€๊ฒฝ
BBoxAnnotationItem ์ „์ฒด ์‹œ์Šคํ…œ์˜ ๊ณตํ†ต ๋ฐ์ดํ„ฐ ํƒ€์ž… instance_number, type, graphic_data, color
fetchAxialBBox() JSON ์ˆ˜์‹  API ํ•จ์ˆ˜ ๊ธฐ์กด seg fetch ๋Œ€์ฒด, ์บ์‹ฑ ๋ถˆํ•„์š”
useNiftiLoader ๋กœ๋”ฉ ํ›… Seg Worker ์ œ๊ฑฐ, BBox allSettled ๋ณ‘๋ ฌ fetch
drawBBoxOverlay() Canvas 2D ๋ Œ๋” ํ•จ์ˆ˜ sliceK ํ•„ํ„ฐ โ†’ strokeRect / lineTo
AxialGridViewer 4ร—4 ์Šฌ๋ผ์ด์Šค ๊ทธ๋ฆฌ๋“œ renderAxialSlice ํ›„ drawBBoxOverlay ์ถ”๊ฐ€ 2์ค„
BBoxOverlay.tsx MPR 3๋ทฐ ์˜ค๋ฒ„๋ ˆ์ด ์ปดํฌ๋„ŒํŠธ VTK Canvas ์œ„ ํˆฌ๋ช… Canvas, onModified ๊ตฌ๋…
๐Ÿ“Œ 3ํŽธ ์˜ˆ๊ณ : BBoxOverlay ๋‚ด๋ถ€์—์„œ annotation์˜ 2D ํ”ฝ์…€ ์ขŒํ‘œ๋ฅผ VTK 3D ๊ณต๊ฐ„(IJKโ†’World)์œผ๋กœ ๋ณ€ํ™˜ํ•˜๊ณ , ๋‹ค์‹œ ๊ฐ ๋ทฐ์˜ Display ์ขŒํ‘œ๋กœ ํˆฌ์˜ํ•˜๋Š” ๊ณผ์ • โ€” ๊ทธ๋ฆฌ๊ณ  Axial์—๋Š” ์›๋ณธ ์ขŒํ‘œ๋ฅผ, Sagittal/Coronal์—๋Š” 3D ๋ฐ”์šด๋”ฉ ๋ฐ•์Šค๋ฅผ ํˆฌ์˜ํ•˜๋Š” ๋ทฐ๋ณ„ ๋ Œ๋”๋ง ์ „๋žต์„ ์ƒ์„ธํžˆ ๋‹ค๋ฃน๋‹ˆ๋‹ค.

NIfTI Viewer ํ”„๋กœ์ ํŠธ ยท SEG โ†’ BBox ์ „ํ™˜ ์‹œ๋ฆฌ์ฆˆ 2ํŽธ ยท 2026