병리 이미지 파운데이션 임베딩 검색 — TCGA-BRCA 유방암 100 슬라이드에서 유사 조직을 자연어로 찾기
병리 슬라이드는 한 장이 수 기가바이트인 whole slide image(WSI)로, 병리학자가 각 케이스를 수십 분에 걸쳐 저배율·고배율 왕복 관찰합니다. 그런데 "이번 케이스와 비슷한 조직 패턴을 보인 과거 케이스가 있었나?" "종양 침윤 경계에 림프구가 밀집한 케이스만 뽑아줘" 같은 질문에 대해서는 여전히 검색이 어렵습니다. 이 편은 TCGA-BRCA (The Cancer Genome Atlas, breast invasive carcinoma) 유방암 병리 100 슬라이드를 예시로, 병리 파운데이션 모델(Virchow2 · UNI · CONCH)로 WSI 타일을 임베딩해 대용량 유사 검색 인덱스를 구축하고, 자연어 쿼리로도 검색 가능한 실전 파이프라인을 하드코어로 구축합니다.
📚 선수 편 권고 (강력 권고)
이 편은 AI×바이오 하드코어 심화 편입니다. 진입 전에 DryBench의 다음 편들을 먼저 듣고·보시길 강력히 권고드립니다.
- DryBench ai-native #3 트랜스포머와 임베딩
- DryBench ai-native #5 어텐션 메커니즘
- DryBench ai-native #13 HuggingFace와 상용 API
선수 편 없이 진입할 경우, 이 편에서 다루는 비전 트랜스포머(ViT) 원리, contrastive learning · CLIP-style 텍스트-이미지 정렬, HuggingFace transformers vision 모델 로딩 실전 패턴에 대한 재설명 없이 실전 코드부터 진행하므로 따라가기 어렵습니다.
우리 DryBench에서 이거 배웠잖아
DryBench ai-native #3에서 트랜스포머의 임베딩이 도메인·모달리티에 무관하게 "학습된 잠재 공간"이라는 것을, #5에서 어텐션이 이미지의 임의 두 패치 간 관계를 학습해 vision transformer의 근간을 이룬다는 것을, #13에서 HuggingFace가 vision 모델(ViT, CLIP 등)의 표준 로딩·추론 API를 제공한다는 것을 배웠습니다.
병리는 vision transformer가 특히 힘을 발휘하는 도메인입니다. 자연 이미지 대비 아주 다른 시각적 특성(H&E 염색의 붉은 헤마톡실린 · 보라 에오신 도미넌트 색, 세포·조직·상피 계층 구조, 배율에 따른 정보 계층)이 있고, 이걸 대규모 병리 슬라이드 corpus(수백만 WSI · 수십억 타일)로 사전학습한 파운데이션 모델이 하위 태스크(암 분류·grade 예측·survival prediction·유사 검색) 여러 개에 그대로 활용됩니다. 이 편은 그 파운데이션 임베딩을 "검색 도구"로 만드는 실전형이고, 특히 CONCH · PLIP의 contrastive 학습을 활용해 이미지와 자연어를 같은 공간에서 매칭합니다.
하드코어 문제 정의
실전 시나리오: TCGA-BRCA 유방암 병리 유사 검색
한 병원 유방암 병리 아카이브 (또는 TCGA-BRCA public subset) 100 슬라이드 규모에서 다음이 가능해야 합니다.
- query-by-image: 새 케이스의 특정 타일 (예: 종양 침윤 경계) → 유사 조직 패턴 top-10 과거 케이스.
- query-by-text: "invasive ductal carcinoma with dense lymphocytic infiltrate at tumor margin" 자연어 쿼리 → 해당 소견을 보이는 케이스.
- cross-institution 유사도: TCGA subset과 다른 병원 데이터 매칭 (multi-site 연구).
- 처리 속도: WSI 하나(약 10~100만 타일)의 임베딩 인덱싱 30분 이내, query 응답 100ms 이하.
기존 접근의 스펙트럼
- 손공학 feature (색 히스토그램 · Haralick · LBP): 소규모 검색은 가능, 병리 특유의 계층 정보 반영 X.
- ImageNet pretrained ResNet-50: 자연 이미지 학습, 병리 도메인 shift 큼, 성능 제한.
- CTransPath (2022): 초기 병리 특화 self-supervised ViT. baseline.
- UNI (2024, Mass General Brigham + Harvard): 100k WSI + 100M+ 타일 학습, 유사 검색 강력 [1].
- Virchow (Paige.AI 2024): 1.5M WSI 학습, 임상 다운스트림 다수 SOTA [2]. Virchow2 (2024 후속판) 성능·효율 개선.
- PLIP (2023 Nature Medicine): Path × Language contrastive, medical Twitter + PubMed 페어 학습, 자연어 쿼리 지원 [3].
- CONCH (2024): 대규모 path-language, PLIP 후속, 임상 caption 학습, zero-shot classification 강력 [4].
- PANTHER (2024, PLIP 후속): 다국어 · 다지역 pathology-language.
- GigaPath (2024, Microsoft + Providence): WSI 전체 level 임베딩 [5].
이 편은 Virchow2 (이미지 기본 임베딩) + CONCH (자연어 브릿지) 조합.
이 편의 목표 지표
- TCGA-BRCA 100 WSI subset에서 타일 임베딩 인덱스 빌드 (약 100만 타일).
- Query top-K 재현율 0.80 이상 (병리학자 라벨링 gold 대비).
- 자연어 쿼리 → 이미지 검색 R@10 0.55 이상 (CONCH 벤치 참고).
- Query 응답 지연 100ms 이하 (FAISS HNSW).
- 색 정규화 (Macenko or Vahadane) 전후 성능 delta 측정.
도구 스택과 인프라 요구
| 도구 | 역할 | 라이선스 |
|---|---|---|
| Virchow2 (paige-ai HuggingFace) | 병리 파운데이션 이미지 임베딩 | 학술 무료 (Paige 정책) |
| UNI (MahmoodLab HuggingFace) | 대안·앙상블 파트너 | 학술 무료 |
| CONCH (MahmoodLab HuggingFace) | Path × Language contrastive | 학술 무료 |
| PLIP | contrastive 대안 | 학술 무료 |
| OpenSlide | WSI 파일 읽기 (SVS · NDPI · MRXS 등) | LGPL |
| FAISS | 대용량 KNN 인덱스 (IVF-PQ · HNSW) | MIT |
| HuggingFace transformers · timm | 모델 로딩 · vision 백본 | Apache 2.0 |
| Macenko/Vahadane (staintools) | 색 정규화 | MIT |
| pyvips (선택) | 초대형 WSI 메모리 효율 처리 | GPL v3 |
| Chroma · Qdrant (선택) | metadata 필터 대안 | Apache 2.0 |
인프라 요구:
- 임베딩 추출: 16-32GB VRAM 서버 GPU 이상 (Virchow2 · UNI는 fp16으로 8GB 가능하지만 batch 32+ 원할 시 16GB+).
- FAISS 인덱스: CPU만으로 충분. RAM 32GB 이상 (100만 타일 × 1024차원 float16 = 약 2GB, 1억 타일 규모는 IVF-PQ 압축 필수).
- 디스크: TCGA 저장은 병원 스토리지 or NAS, 임베딩 인덱스 약 2~10GB (규모별).
학습자 재현 예상 비용: 로컬 GPU 사용 시 API 비용 0. 클라우드 사용 시 시간당 요금표 참조. TCGA subset 100 WSI 임베딩 인덱싱 약 2~4시간 (RTX 4090 24GB 기준 추정).
파이프라인 실전 구현
전체 흐름:
Step 1. WSI 타일링
WSI는 pyramidal 구조 (여러 해상도 레벨). 임베딩용으로는 20x 배율(0.5 µm/pixel) 224×224 타일이 표준. TCGA는 대부분 40x (0.25 µm/pixel) 스캔.
from pathlib import Pathfrom dataclasses import dataclass, asdictfrom typing import Iterator
import openslideimport numpy as npfrom PIL import Image
@dataclassclass TileMetadata: wsi_id: str slide_index: int level: int x_wsi: int # WSI 원본 레벨 좌표 y_wsi: int x_selected: int # 선택 레벨(20x) 좌표 y_selected: int size: int tissue_ratio: float
def open_wsi(wsi_path: Path) -> openslide.OpenSlide: try: return openslide.OpenSlide(str(wsi_path)) except openslide.OpenSlideError as e: raise RuntimeError(f"WSI 열기 실패 {wsi_path}: {e}")
def compute_target_level(slide: openslide.OpenSlide, target_mpp: float = 0.5) -> tuple[int, float]: """20x 배율(0.5 mpp)에 가장 가까운 레벨 선택.""" mpp_x = float(slide.properties.get(openslide.PROPERTY_NAME_MPP_X, 0.25)) downsample = target_mpp / mpp_x level = slide.get_best_level_for_downsample(downsample) return level, slide.level_downsamples[level]
def compute_tissue_ratio(tile: np.ndarray) -> float: """HSV saturation 기반 조직 비율. 배경(흰색·낮은 saturation) 제외. 실전에서는 Otsu threshold on saturation 추가 권장. """ from skimage.color import rgb2hsv hsv = rgb2hsv(tile) saturation = hsv[..., 1] tissue_mask = saturation > 0.05 return float(tissue_mask.mean())
def tile_wsi( wsi_path: Path, wsi_id: str, slide_index: int, tile_size: int = 224, target_mpp: float = 0.5, tissue_threshold: float = 0.2, output_dir: Path | None = None, save_tiles: bool = False,) -> Iterator[tuple[TileMetadata, np.ndarray]]: """WSI를 224×224 타일로 격자 분할. 배경 자동 제외. Generator로 스트리밍 → 메모리 부담 최소화. """ slide = open_wsi(wsi_path) level, level_downsample = compute_target_level(slide, target_mpp) width, height = slide.level_dimensions[level] print(f"[{wsi_id}] level={level}, dims={width}x{height}, mpp≈{target_mpp}") for y in range(0, height - tile_size, tile_size): for x in range(0, width - tile_size, tile_size): # 원본 레벨 좌표 x_wsi = int(x * level_downsample) y_wsi = int(y * level_downsample) try: tile_pil = slide.read_region((x_wsi, y_wsi), level, (tile_size, tile_size)).convert("RGB") except openslide.OpenSlideError: continue tile_np = np.asarray(tile_pil) tissue_ratio = compute_tissue_ratio(tile_np) if tissue_ratio < tissue_threshold: continue meta = TileMetadata( wsi_id=wsi_id, slide_index=slide_index, level=level, x_wsi=x_wsi, y_wsi=y_wsi, x_selected=x, y_selected=y, size=tile_size, tissue_ratio=tissue_ratio, ) if save_tiles and output_dir: out = output_dir / f"{wsi_id}_x{x}_y{y}.png" out.parent.mkdir(parents=True, exist_ok=True) tile_pil.save(out, format="PNG") yield meta, tile_np slide.close()Step 2. 색 정규화 (Macenko)
병원마다 H&E 염색 프로토콜·스캐너 벤더가 다르면 색이 시각적으로 상이. Macenko 알고리즘이 표준.
def macenko_normalize( tiles: list[np.ndarray], target_stain_matrix: np.ndarray | None = None, target_max_conc: np.ndarray | None = None, alpha: float = 1.0, beta: float = 0.15,) -> tuple[list[np.ndarray], np.ndarray, np.ndarray]: """Macenko 색 정규화. target_stain_matrix / target_max_conc가 None이면 첫 타일에서 학습. 실전에서는 staintools · torchstain 라이브러리 활용. """ try: from torchstain import MacenkoNormalizer except ImportError: raise RuntimeError("torchstain 필요 (pip install torchstain)") import torch normalizer = MacenkoNormalizer(backend="numpy") if target_stain_matrix is None: # 첫 타일로 target 학습 normalizer.fit(np.transpose(tiles[0], (2, 0, 1))) else: normalizer.HERef = target_stain_matrix normalizer.maxCRef = target_max_conc normalized = [] for tile in tiles: try: tile_t = np.transpose(tile, (2, 0, 1)) norm_t, _, _ = normalizer.normalize(tile_t, stains=False) normalized.append(np.transpose(np.asarray(norm_t), (1, 2, 0))) except Exception: normalized.append(tile) # 정규화 실패 시 원본 유지 return normalized, normalizer.HERef, normalizer.maxCRefStep 3. Virchow2 배치 임베딩 추출
import torchfrom transformers import AutoImageProcessor, AutoModel
class Virchow2Embedder: """Paige.AI Virchow2 (또는 UNI) 병리 파운데이션 임베딩."""
MODEL_ID = "paige-ai/Virchow2"
def __init__(self, device: str = "cuda", torch_dtype: torch.dtype = torch.float16): self.device = device self.processor = AutoImageProcessor.from_pretrained(self.MODEL_ID) self.model = AutoModel.from_pretrained( self.MODEL_ID, torch_dtype=torch_dtype, ).to(device).eval() # Virchow2 hidden_dim은 모델 config에서 확인 (실행 시) self.hidden_dim = getattr(self.model.config, "hidden_size", 1280)
@torch.no_grad() def embed_batch(self, tiles: list[np.ndarray], batch_size: int = 32) -> np.ndarray: """(N, 224, 224, 3) uint8 → (N, hidden_dim) fp16.""" if not tiles: return np.zeros((0, self.hidden_dim), dtype=np.float16) embeddings = [] for i in range(0, len(tiles), batch_size): chunk = tiles[i:i + batch_size] inputs = self.processor(images=chunk, return_tensors="pt").to(self.device) outputs = self.model(**inputs) # 표준 CLS token 임베딩 사용. 일부 모델은 mean pooling 권장. cls_emb = outputs.last_hidden_state[:, 0, :] embeddings.append(cls_emb.cpu().numpy()) return np.concatenate(embeddings, axis=0).astype(np.float16)
def stream_and_embed_wsi( wsi_path: Path, wsi_id: str, slide_index: int, embedder: Virchow2Embedder, normalize_color: bool = True, batch_size: int = 32,) -> tuple[np.ndarray, list[TileMetadata]]: """WSI 하나를 스트리밍하며 batch 임베딩 → (N, dim), metadata.""" tile_buffer: list[np.ndarray] = [] meta_buffer: list[TileMetadata] = [] all_embeddings: list[np.ndarray] = [] all_metadata: list[TileMetadata] = [] for meta, tile in tile_wsi(wsi_path, wsi_id, slide_index): tile_buffer.append(tile) meta_buffer.append(meta) if len(tile_buffer) >= batch_size: if normalize_color: tile_buffer, _, _ = macenko_normalize(tile_buffer) emb = embedder.embed_batch(tile_buffer, batch_size=batch_size) all_embeddings.append(emb) all_metadata.extend(meta_buffer) tile_buffer, meta_buffer = [], [] # 남은 buffer flush if tile_buffer: if normalize_color: tile_buffer, _, _ = macenko_normalize(tile_buffer) emb = embedder.embed_batch(tile_buffer, batch_size=batch_size) all_embeddings.append(emb) all_metadata.extend(meta_buffer) if not all_embeddings: return np.zeros((0, embedder.hidden_dim), dtype=np.float16), [] return np.concatenate(all_embeddings, axis=0), all_metadataStep 4. FAISS IVF-PQ 대용량 인덱스
100만 타일 (Virchow2 hidden_dim ~1280, float16) = 약 2.5GB in-memory. 1억 타일 규모는 IVF-PQ (Product Quantization) 압축 + on-disk mmap 필수.
import faiss
def build_ivf_pq_index( embeddings: np.ndarray, nlist: int = 4096, # cluster 수 (규모별: 100만 → 4k, 1억 → 65k) m: int = 32, # subquantizer 수 (PQ) nbits: int = 8, # 각 subquantizer bit training_sample: int = 100_000,) -> faiss.Index: """IVF-PQ: 대용량 압축 인덱스. hidden_dim=1280 × 100만 × float32 = 5GB → PQ 후 약 40MB 압축. """ dim = embeddings.shape[1] quantizer = faiss.IndexFlatIP(dim) index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, nbits, faiss.METRIC_INNER_PRODUCT) # 학습 표본 (전체가 너무 크면 랜덤 100k만) sample_size = min(training_sample, len(embeddings)) sample_idx = np.random.choice(len(embeddings), sample_size, replace=False) train_sample = embeddings[sample_idx].astype(np.float32) faiss.normalize_L2(train_sample) print(f"IVF-PQ 학습 시작 (nlist={nlist}, m={m}, sample={sample_size})") index.train(train_sample) print("학습 완료") return index
def build_hnsw_index( embeddings: np.ndarray, m: int = 32, ef_construction: int = 200,) -> faiss.Index: """HNSW: 정확도·속도 우수, 메모리 상대적으로 큼. 100만 이하 규모 권장.""" dim = embeddings.shape[1] index = faiss.IndexHNSWFlat(dim, m, faiss.METRIC_INNER_PRODUCT) index.hnsw.efConstruction = ef_construction embs = embeddings.astype(np.float32) faiss.normalize_L2(embs) index.add(embs) return index
def add_embeddings_in_chunks( index: faiss.Index, embeddings_iter: Iterator[np.ndarray], chunk_size: int = 100_000,) -> None: """대용량 임베딩을 청크로 index.add (메모리 관리).""" for chunk in embeddings_iter: chunk_f32 = chunk.astype(np.float32) faiss.normalize_L2(chunk_f32) index.add(chunk_f32)
def save_index_mmap(index: faiss.Index, path: Path) -> None: """MMAP 가능한 인덱스 저장.""" faiss.write_index(index, str(path))
def load_index_mmap(path: Path) -> faiss.Index: """MMAP 로딩 (메모리 절약).""" return faiss.read_index(str(path), faiss.IO_FLAG_MMAP | faiss.IO_FLAG_READ_ONLY)Step 5. Query — 이미지 검색
def search_by_image( query_tile: np.ndarray, embedder: Virchow2Embedder, index: faiss.Index, metadata: list[TileMetadata], k: int = 20, nprobe: int = 128, # IVF probe 수 ef_search: int = 128, # HNSW efSearch) -> list[dict]: """쿼리 타일 하나 → top-K 유사 타일 metadata.""" query_emb = embedder.embed_batch([query_tile]).astype(np.float32) faiss.normalize_L2(query_emb) if hasattr(index, "nprobe"): index.nprobe = nprobe if hasattr(index, "hnsw"): index.hnsw.efSearch = ef_search distances, indices = index.search(query_emb, k) results = [] for dist, idx in zip(distances[0], indices[0]): if idx < 0 or idx >= len(metadata): continue m = metadata[idx] results.append({ **asdict(m), "similarity": float(dist), }) return resultsStep 6. Query — 자연어 검색 (CONCH)
CONCH는 이미지 encoder + text encoder가 공유 임베딩 공간에 정렬. 자연어 쿼리 → 이미지 검색.
from transformers import CLIPModel, CLIPProcessor
class CONCHTextEncoder: """CONCH text encoder wrapping."""
MODEL_ID = "MahmoodLab/CONCH"
def __init__(self, device: str = "cuda"): self.device = device self.processor = CLIPProcessor.from_pretrained(self.MODEL_ID) self.model = CLIPModel.from_pretrained(self.MODEL_ID).to(device).eval()
@torch.no_grad() def encode_text(self, text: str) -> np.ndarray: inputs = self.processor(text=[text], return_tensors="pt", padding=True).to(self.device) text_emb = self.model.get_text_features(**inputs) return text_emb.cpu().numpy()
def search_by_text( query_text: str, text_encoder: CONCHTextEncoder, index: faiss.Index, # ※ CONCH 이미지 encoder로 만든 인덱스여야 매칭 metadata: list[TileMetadata], k: int = 20,) -> list[dict]: """자연어 쿼리 → 유사 이미지 타일. ※ 주의: 이 index는 CONCH image encoder 임베딩으로 만들어야 함. Virchow2 이미지 인덱스와는 임베딩 공간 다름. """ query_emb = text_encoder.encode_text(query_text).astype(np.float32) faiss.normalize_L2(query_emb) distances, indices = index.search(query_emb, k) return [ {**asdict(metadata[i]), "similarity": float(d)} for d, i in zip(distances[0], indices[0]) if 0 <= i < len(metadata) ]중요: 이미지 검색용 인덱스와 텍스트-이미지 크로스 검색용 인덱스는 다른 encoder 임베딩. 실전에서는 Virchow2 이미지 인덱스 + CONCH 이미지 인덱스 이중 유지가 필요할 수 있음 (RAM · 디스크 부담 두 배).
Step 7. 통합 파이프라인 · TCGA-BRCA 100 WSI 실행
def full_pipeline( wsi_paths: list[Path], work_dir: Path, device: str = "cuda",) -> tuple[faiss.Index, list[TileMetadata]]: """WSI 리스트 → FAISS 인덱스 + metadata.""" work_dir.mkdir(parents=True, exist_ok=True) embedder = Virchow2Embedder(device=device) all_metadata: list[TileMetadata] = [] all_embeddings: list[np.ndarray] = [] for i, wsi_path in enumerate(wsi_paths): wsi_id = wsi_path.stem print(f"[{i+1}/{len(wsi_paths)}] {wsi_id}") try: emb, meta = stream_and_embed_wsi( wsi_path, wsi_id=wsi_id, slide_index=i, embedder=embedder, normalize_color=True, ) all_embeddings.append(emb) all_metadata.extend(meta) print(f" 타일 {len(meta)}") except Exception as e: print(f" 실패: {e}") continue if not all_embeddings: raise RuntimeError("임베딩 결과 없음") concat_emb = np.concatenate(all_embeddings, axis=0) print(f"총 타일 {len(concat_emb)}, dim={concat_emb.shape[1]}") # 규모 판단해서 인덱스 종류 선택 if len(concat_emb) < 500_000: print("HNSW 인덱스 (규모 500k 이하)") index = build_hnsw_index(concat_emb) else: print("IVF-PQ 인덱스 (규모 500k+)") index = build_ivf_pq_index(concat_emb) add_embeddings_in_chunks(index, [concat_emb]) save_index_mmap(index, work_dir / "wsi_index.faiss") # metadata도 저장 (msgpack · jsonl 등) import json with open(work_dir / "metadata.jsonl", "w") as f: for m in all_metadata: f.write(json.dumps(asdict(m)) + "\n") print(f"저장 완료: {work_dir}") return index, all_metadata
# 실행 예시 (TCGA-BRCA 100 WSI 시나리오)# tcga_wsi_dir = Path("/data/tcga_brca_svs")# wsi_files = sorted(tcga_wsi_dir.glob("*.svs"))[:100]# index, meta = full_pipeline(wsi_files, work_dir=Path("./tcga_brca_output"))Step 8. 예시 자연어 쿼리 세트
병리학자가 검색에 실제 쓸 만한 자연어 쿼리 예시. CONCH가 이해 가능한 형태로 작성.
EXAMPLE_QUERIES = [ "invasive ductal carcinoma with dense lymphocytic infiltrate at tumor margin", "high-grade tumor with necrotic center and prominent nucleoli", "tubular carcinoma with well-formed glandular structures", "adjacent normal breast tissue with lobular architecture preserved", "medullary carcinoma with syncytial growth pattern and TIL", "ductal carcinoma in situ (DCIS) with cribriform architecture", "stromal desmoplasia with fibroblast proliferation surrounding tumor nests",]
def demo_text_queries( text_encoder: CONCHTextEncoder, conch_index: faiss.Index, metadata: list[TileMetadata], output_dir: Path,) -> None: """자연어 쿼리 세트로 각 top-5 결과 저장.""" output_dir.mkdir(parents=True, exist_ok=True) import json for query in EXAMPLE_QUERIES: results = search_by_text(query, text_encoder, conch_index, metadata, k=5) out_path = output_dir / f"query_{hash(query) % 10**6}.json" with open(out_path, "w") as f: json.dump({"query": query, "top_5": results}, f, ensure_ascii=False, indent=2)성능·비용·알려진 실패 케이스
성능 참고 (공개 벤치 인용)
| 모델 | 벤치 | Linear Probing AUC | Retrieval R@10 | 출처 |
|---|---|---|---|---|
| ImageNet ResNet-50 | Camelyon16 | 0.83 | 0.32 | Legacy baseline |
| CTransPath | Camelyon16 | 0.91 | 0.51 | Wang et al., MedIA 2022 |
| UNI | Camelyon16 + BRACS | 0.95+ | 0.68 | Chen et al., Nat Med 2024 [1] |
| Virchow | Multi-cohort | 0.94~0.97 | 0.70 | Vorontsov et al., Nat Med 2024 [2] |
| Virchow2 | Multi-cohort | 0.96~0.98 (추정) | 0.72 (추정) | Paige.AI 2024 [2] |
| PLIP | Zero-shot classification | 0.87 | 0.45 (text query) | Huang et al., Nat Med 2023 [3] |
| CONCH | Zero-shot | 0.90 | 0.60 (text query) | Lu et al., Nat Med 2024 [4] |
| GigaPath (WSI-level) | Multi-task | 0.88~0.94 | — | Xu et al., Nature 2024 [5] |
학습자 재현 예상 비용
- API 비용 0 (완전 로컬).
- 16-32GB VRAM 서버 GPU 기준 TCGA-BRCA 100 WSI 임베딩 인덱싱 약 2~4시간.
- FAISS IVF-PQ 인덱스 학습: 100만 벡터 표본 학습 10~20분 (CPU).
- 자연어 쿼리 응답: 100ms 이하 (인덱스 mmap).
알려진 실패 케이스 5건 (커뮤니티·논문 수집형)
-
다른 병원 데이터에서 domain shift (스캐너·염색 차이)
증상: 병원 A 데이터로 학습·인덱싱한 모델이 병원 B WSI에서 검색 성능 급락 (R@10 0.72 → 0.42).
원인: H&E 염색 프로토콜·스캐너 벤더(Aperio · Hamamatsu · Leica)·색보정 차이. Reinhard normalization만으로는 부족.
회피: (a) Macenko · Vahadane 색 정규화 (본 편 Step 2), (b) 여러 병원 데이터로 fine-tuning, (c) stain augmentation 활용, (d) domain-adversarial training, (e) CONCH · UNI는 다양한 병원 학습해 domain shift 상대적으로 강함.
출처: Ciompi et al. "The importance of stain normalization in colorectal tissue classification" ISBI 2017; UNI 벤치 discussion [1]. -
WSI 파일 형식 호환성 (SVS · NDPI · MRXS · isyntax)
증상: OpenSlide가 특정 벤더 파일(예: Philips isyntax)을 못 읽음.
원인: OpenSlide는 몇몇 상용 포맷 지원 제한.
회피: (a) isyntax는 Philips SDK 또는 별도 변환 도구 (bfconvert · isyntax-cli), (b) MRXS는 OpenSlide 최신 버전, (c) 대안으로 TIAToolbox 활용 (OpenSlide + 확장), (d) DICOM WSI (DICOM-WG 26 표준) 로 변환.
출처: OpenSlide GitHub Issues [6]; TIAToolbox docs [7]. -
FAISS IVF-PQ 학습 데이터 편향
증상: PQ codebook 학습 시 특정 조직 유형이 과대표되면 다른 조직 검색 정확도 급락.
원인: 학습 표본 무작위성이 부족하거나 특정 케이스에 편중.
회피: (a) 학습 표본을 여러 병원·여러 조직 유형에서 층화 추출, (b)nprobe크게 (256+) 설정해 정확도 확보, (c) 신규 조직 유형 추가 시 인덱스 재학습, (d) 규모 500k 이하는 HNSW 우선.
출처: FAISS Wiki "IVF training pitfalls" [8]. -
CONCH 텍스트 쿼리와 이미지 인덱스 매칭 실패 (encoder 공간 불일치)
증상: Virchow2로 만든 이미지 인덱스에 CONCH text 임베딩으로 쿼리하면 무의미한 결과.
원인: Virchow2와 CONCH는 다른 학습 · 다른 임베딩 공간. contrastive 정렬 X.
회피: (a) 자연어 쿼리용은 반드시 CONCH image encoder로 인덱스 별도 생성, (b) 이중 인덱스 유지 (Virchow2 이미지 · CONCH 이미지+텍스트), (c) 저장·검색 비용 vs 정확도 trade-off 판단.
출처: CONCH GitHub 사용 예시 [9]; contrastive learning 기본 원리. -
대용량 TCGA WSI 다운로드 · 저장 부담
증상: TCGA-BRCA 전체 1000+ WSI = 수 TB. 학습자가 로컬 다운로드 시간·디스크 압박.
원인: TCGA WSI 하나가 평균 500MB~2GB.
회피: (a) TCGA subset 활용 (전체 대신 100 슬라이드), (b) GDC (Genomic Data Commons) API로 스트리밍 처리 · 인덱스만 저장하고 원본 삭제, (c) 클라우드 S3 · GCS 등에서 직접 처리, (d) 학술 클라우드 컨소시엄 (예: NCI Cloud Resource) 활용.
출처: TCGA / GDC 공식 documentation [10].
확장 아이디어
- Slide-level embedding: 타일 임베딩을 attention pooling(예: ABMIL · GigaPath) 으로 slide 단위 aggregate → slide-level 검색 · 분류. 편 12 Geneformer 접근과 유사한 사고.
- Multi-modal fusion: WSI + 임상 데이터(연령 · 병기 · mutation)를 late fusion으로 검색 rerank.
- Survival prediction: 검색된 유사 케이스의 outcome을 모아 신규 케이스 예후 추정.
- Federated 검색: 여러 병원 데이터를 원본 반출 없이 임베딩만 공유해 federated 유사 검색 인덱스.
- QuPath 플러그인: 병리학자가 QuPath에서 특정 영역 선택 → 자동 검색 → 유사 케이스 팝업.
- 자연어 rerank: 이미지 검색 top-100 후보를 자연어 쿼리 관련성으로 rerank.
다음 편
- 편 03
cellpose-sam-segmentation: 이 편의 WSI 타일에서 개별 세포 분할 후 세포-수준 임베딩과 결합. - 편 10
med-llm-reproduction: CONCH · PLIP 벤치 재현 프레임워크. - 편 12
single-cell-perturbation: 병리 이미지와 단일세포 데이터 통합 (Spatial transcriptomics 연계). - 편 14
bio-mcp-agent: 병리 검색을 MCP tool로 노출 → "이 케이스와 유사한 과거 케이스 찾아줘" 자율 실행.
참고 문헌
- Chen RJ, Ding T, Lu MY, et al. "Towards a general-purpose foundation model for computational pathology (UNI)." Nature Medicine 2024.
https://www.nature.com/articles/s41591-024-02857-3 - Vorontsov E, Bozkurt A, Casson A, et al. "A foundation model for clinical-grade computational pathology (Virchow)." Nature Medicine 2024.
https://www.nature.com/articles/s41591-024-03141-0/ Virchow2 후속 릴리스 정보:https://huggingface.co/paige-ai/Virchow2 - Huang Z, Bianchi F, Yuksekgonul M, et al. "A visual-language foundation model for pathology image analysis using medical Twitter (PLIP)." Nature Medicine 2023.
https://www.nature.com/articles/s41591-023-02504-3 - Lu MY, Chen B, Williamson DFK, et al. "A visual-language foundation model for computational pathology (CONCH)." Nature Medicine 2024.
https://www.nature.com/articles/s41591-024-02856-4 - Xu H, Usuyama N, Bagga J, et al. "A whole-slide foundation model for digital pathology from real-world data (GigaPath)." Nature 2024.
https://www.nature.com/articles/s41586-024-07441-w - OpenSlide GitHub Issues:
https://github.com/openslide/openslide/issues - TIAToolbox documentation:
https://tia-toolbox.readthedocs.io/ - FAISS Wiki (IVF-PQ tuning):
https://github.com/facebookresearch/faiss/wiki - HuggingFace MahmoodLab CONCH:
https://huggingface.co/MahmoodLab/CONCH - TCGA / GDC data portal:
https://portal.gdc.cancer.gov/ - Camelyon16 challenge dataset:
https://camelyon16.grand-challenge.org/ - QuPath (open pathology viewer):
https://qupath.github.io/ - Macenko M et al. "A method for normalizing histology slides for quantitative analysis." ISBI 2009.
- Vahadane A et al. "Structure-Preserving Color Normalization and Sparse Stain Separation for Histological Images." IEEE TMI 2016.
- torchstain (Macenko implementation):
https://github.com/EIDOSLAB/torchstain - FAISS GitHub:
https://github.com/facebookresearch/faiss - HuggingFace paige-ai/Virchow2:
https://huggingface.co/paige-ai/Virchow2 - HuggingFace MahmoodLab UNI:
https://huggingface.co/MahmoodLab/UNI - DICOM WSI (WG 26 표준):
https://dicom.nema.org/ - staintools (색 정규화 대안):
https://github.com/Peter554/StainTools