BBox JSON API ์ค๊ณ์
Canvas ์ค๋ฒ๋ ์ด ๊ตฌํ
BE๊ฐ ๋ด๋ ค์ฃผ๋ JSON ๊ตฌ์กฐ๋ฅผ ์ด๋ป๊ฒ ์ค๊ณํ๋์ง, FE์์ ๊ทธ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ AxialGridViewer์ BBoxOverlay์์ ์ด๋ป๊ฒ Canvas๋ก ๊ทธ๋ฆฌ๋์ง ๊ตฌํ ์ฝ๋ ์ค์ฌ์ผ๋ก ์ค๋ช ํฉ๋๋ค.
๐ ์๋ฆฌ์ฆ: SEG NIfTI์์ BBox JSON์ผ๋ก
BBox JSON โ ๋ฌด์์ ์ด๋ป๊ฒ ๋ด์๋
๊ฐ์ฅ ๋จผ์ ๊ฒฐ์ ํด์ผ ํ ๊ฒ์ BE๊ฐ FE์ ๋ฌด์์ ๋ด๋ ค์ค ๊ฒ์ธ๊ฐ์์ต๋๋ค. SEG NIfTI์์ ์ฐ๋ฆฌ๊ฐ ์ค์ ๋ก ํ์ํ ์ ๋ณด๋ง ์ถ๋ ค๋ณด๋ฉด ์ธ ๊ฐ์ง์ ๋๋ค.
์ด๋ ์ฌ๋ผ์ด์ค์
๋ณ๋ณ์ด ๋ฑ์ฅํ๋ ์ฌ๋ผ์ด์ค ๋ฒํธ(instance_number). ํ์ฌ ์ฌ๋ผ์ด์ค์ ๋น๊ตํด ๊ทธ๋ฆด์ง ๋ง์ง๋ฅผ ๊ฒฐ์ ํฉ๋๋ค.
์ด๋ ์์น์
bbox๋ผ๋ฉด ์ข์๋จยท์ฐํ๋จ ์ขํ, contour๋ผ๋ฉด ํด๋ฆฌ๋ผ์ธ ์ขํ ๋ฐฐ์ด. Canvas strokeRect / beginPath๋ก ๋ฐ๋ก ์ธ ์ ์๋ ํํ.
์ด๋ค ์์ผ๋ก
aneurysm_id๋ณ ๊ณ ์ ์์. ๊ฐ์ ๋ณ๋ณ์ด ์ฌ๋ฌ ์ฌ๋ผ์ด์ค์ ๊ฑธ์ณ ์์ด๋ ๋์ผํ ์์ผ๋ก ์ถ์ ํ ์ ์์ต๋๋ค.
์ด ์ธ ๊ฐ์ง๋ฅผ ๋ด์ ์ต์ ๊ตฌ์กฐ๊ฐ 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 ํด์ ๊ท์น
| type | graphic_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 |
fetchAxialBBox โ API ํจ์ ์ถ๊ฐ
niftiApi.ts์ BBox ์ ์ฉ fetch ํจ์์ ์๋ต ํ์
์ ์ถ๊ฐํฉ๋๋ค. ๊ธฐ์กด fetch ํจํด๊ณผ ๋์ผํ ๊ตฌ์กฐ์ด๋ฏ๋ก ํต์ผ์ฑ์ ์ ์งํฉ๋๋ค.
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ํด๋ ๋ถ๋ด์ด ์์ต๋๋ค.
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) };
drawBBoxOverlay โ Canvas 2D ๋ ๋๋ง ํจ์
sliceRenderer.ts์ ์ถ๊ฐ๋ ํจ์์
๋๋ค. AxialGridViewer์์ ๊ฐ ์
์ renderAxialSlice() ์งํ ๊ฐ์ ctx์ ํธ์ถํด bbox/contour๋ฅผ ๋ง๊ทธ๋ฆฝ๋๋ค.
/** * 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๊ฐ ์ฌ๋ฐ๋ฅด๊ฒ ๊ทธ๋ ค์ง๋๋ค.
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); } }
BBoxOverlay.tsx โ VTK ๋ทฐํฌํธ ์ Canvas ์ค๋ฒ๋ ์ด
MPR ๋ทฐ(Axial/Sagittal/Coronal)๋ VTK WebGL Canvas ์์ ํฌ๋ช Canvas๋ฅผ ๊ฒน์น๋ ๋ฐฉ์์ผ๋ก ์ค๋ฒ๋ ์ดํฉ๋๋ค. AxialGridViewer์ ๋ฌ๋ฆฌ VTK ์ขํ ๋ณํ์ด ํ์ํ ๊ฒ์ด ํต์ฌ ์ฐจ์ด์ ๋๋ค.
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 ์ด๋ฒคํธ ๊ตฌ๋
์ด ๋ฐ์ํ์ง ์์ต๋๋ค.
2ํธ ๊ตฌํ ์ ์ฒด ๋ฐ์ดํฐ ํ๋ฆ
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 ๊ตฌ๋ |
'๐ฑ๏ธ ๊ธฐ์ ๊ฒํ ' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
| [React] VTK ์ขํ๊ณ์์ 3๊ฐ ๋ทฐ ๋์์ BBox ๊ทธ๋ฆฌ๊ธฐ (0) | 2026.04.03 |
|---|---|
| [React] SEG NIfTI, ์ ๋ฒ๋ ธ๋ ๋ณ๋ชฉ์ ํด๋ถ์ BBox ์ ํ ๋ฐฐ๊ฒฝ (0) | 2026.04.03 |
| [React] SEG ์์ญ๋ง Zarr๋ก,๋๋จธ์ง๋ ์๋ณธ ๊ทธ๋๋ก (0) | 2026.04.03 |
| [React] NIfTI ์์ถ์ ๋ ๊ฐ๋.nii.gz vs Zarr (0) | 2026.04.03 |
| [React] Cornerstone3D์์ NIfTI๋ฅผ ์ด๋ค๋ฉด? 1024ร1024 ํด์๋๊น์ง (0) | 2026.04.03 |