From 37bdb1e5ed5836580127cb4fb5b245673138df1e Mon Sep 17 00:00:00 2001 From: Tamara Kostova Date: Wed, 29 Apr 2026 15:21:36 +0200 Subject: [PATCH 1/4] sibra atlas tool --- README.md | 37 +++++- agents/medgemma_agent.py | 16 +++ agents/sibra_tool.py | 225 +++++++++++++++++++++++++++++++++ pipeline/fhir_output.py | 13 ++ pipeline/graph.py | 12 +- pipeline/nodes.py | 51 ++++++++ pipeline/state.py | 4 + requirements.txt | 5 + tests/test_atlas_enrichment.py | 136 ++++++++++++++++++++ 9 files changed, 496 insertions(+), 3 deletions(-) create mode 100644 agents/sibra_tool.py create mode 100644 tests/test_atlas_enrichment.py diff --git a/README.md b/README.md index 1af3d3f..c8eed11 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ a LangGraph-based multi-agent pipeline for automated classification of neuroimag | `CNNClassifier` | VGG16 / DenseNet169 / ResNet101 | Task-specific classification | | `SAM3Tool` | SAM3 frozen backbone + linear probe | Lesion segmentation (Dice = 0.836) | | `BiomedCLIPTool` | microsoft/BiomedCLIP (layer 18) | Zero-shot re-ranking for ambiguous multiclass cases | +| `SiibraAtlasTool` | EBRAINS Julich-Brain v2.9 | Anatomical region assignment via MNI152 coordinates | **Routing logic** (derived from MedGemma `diagnosis_confidence`): @@ -53,7 +54,8 @@ MultiAgentMedClassifier/ │ ├── medgemma_agent.py # MedGemma: triage, bbox diagnosis, report │ ├── cnn_tool.py # CNN classifier (VGG16 / DenseNet / ResNet) │ ├── sam3_tool.py # SAM3 segmentation + linear probe head -│ └── biomedclip_tool.py # BiomedCLIP zero-shot / linear probe +│ ├── biomedclip_tool.py # BiomedCLIP zero-shot / linear probe +│ └── sibra_tool.py # EBRAINS siibra: lesion centroid → Julich-Brain region ├── pipeline/ │ ├── graph.py # LangGraph StateGraph assembly │ ├── nodes.py # Node factory functions @@ -69,9 +71,13 @@ MultiAgentMedClassifier/ ├── prompts/ │ ├── system_prompt.txt # MedGemma radiologist persona + JSON schema │ └── system_prompt_bbox.txt # Same schema, bbox-overlay context +├── tests/ +│ └── test_atlas_enrichment.py # Standalone test for atlas node on a single image ├── checkpoints/ # PyTorch state dicts: {arch}_{dataset}_final.pt ├── outputs/ │ ├── explainability/ # Saliency maps: gradcam_pp_*.png, ig_*.png +│ ├── segmentation/ # SAM3 binary masks (mask_*.png) and bbox overlays (guided_*.png) +│ ├── fhir/ # FHIR R4 bundles: fhir_*.json │ └── eval/ # comparison_summary.csv ├── config.py # Central config dataclasses ├── run_pipeline.py # CLI entry point @@ -192,6 +198,35 @@ The report is returned in `state["final_report"]` (plain text, ≤150 words). Th | `explainability_result` | Paths to `gradcam_pp_*.png` and `ig_*.png` (if enabled) | | `verification_result` | MedGemma post-hoc agreement check against Grad-CAM++ saliency map (if explainability enabled) | | `fhir_report` | FHIR R4 DiagnosticReport dict; saved to `outputs/fhir/fhir_.json` | +| `atlas_enrichment` | EBRAINS atlas assignment: `assigned_region`, `hemisphere`, `mni_coords`, `assignment_scores` (only on `sam3_then_cnn` path) | + +## Atlas Enrichment (EBRAINS / siibra) + +On the `sam3_then_cnn` path, after SAM3 produces a binary mask, the pipeline runs an optional anatomical assignment step using [siibra-python](https://siibra-python.readthedocs.io) and the [EBRAINS Julich-Brain Cytoarchitectonic Atlas v2.9](https://search.kg.ebrains.eu). + +The SAM3 mask centroid is mapped to MNI152 coordinates, which are then assigned to the nearest cytoarchitectonic region via a statistical probability map. + +**Coordinate accuracy (best → worst):** + +| Input | How it's used | +|---|---| +| `metadata["nifti_path"]` | NIfTI affine → exact MNI coords | +| `metadata["dicom_path"]` | `ImagePositionPatient` + `PixelSpacing` → scanner-space coords (≈ MNI for pre-registered datasets) | +| PNG only (default) | Pixel centroid normalised to MNI152 range; z locked to axial midplane (0 mm) | + +Pass coordinate metadata at inference time: +```python +initial_state("scan.png", "binary_tumor", metadata={"nifti_path": "scan.nii.gz"}) +``` + +**Test the node standalone (without running the full pipeline):** +```bash +python tests/test_atlas_enrichment.py --image data/scan.png --mask outputs/segmentation/mask_abc123.png +``` + +The `atlas_enrichment` result flows into the MedGemma report prompt and is serialised as an `ebrains-atlas-assignment` extension in the FHIR bundle. + +**Note:** z=0.0 (axial midplane) is assumed when no NIfTI or DICOM is available. Region assignments are anatomically meaningful but spatially approximate. Scores are Julich-Brain probability map values; scores < 0.1 occur near region boundaries or at the midplane. ## Explainability Methods diff --git a/agents/medgemma_agent.py b/agents/medgemma_agent.py index a88dd39..3319136 100644 --- a/agents/medgemma_agent.py +++ b/agents/medgemma_agent.py @@ -169,6 +169,10 @@ def diagnosis_to_routing( Spatial alignment — GradCAM++ ∩ SAM3 mask IoU: {saliency_iou} (IoU < 0.3 suggests the CNN attended to background rather than the lesion) +EBRAINS atlas assignment (Julich-Brain parcellation): +{atlas_enrichment} +(If available, use the assigned_region and hemisphere to contextualise the finding anatomically) + Your response MUST contain exactly two sections in this order: FINDINGS: @@ -306,6 +310,7 @@ def generate_report( biomedclip_result: Optional[dict], verification_result: Optional[dict] = None, saliency_iou: Optional[float] = None, + atlas_enrichment: Optional[dict] = None, ) -> str: def fmt(d) -> str: if d is None: @@ -316,6 +321,16 @@ def fmt(d) -> str: iou_str = f"{saliency_iou:.3f}" if saliency_iou is not None else "Not computed." + if atlas_enrichment: + atlas_str = ( + f"Region: {atlas_enrichment.get('assigned_region', 'unassigned')} | " + f"Hemisphere: {atlas_enrichment.get('hemisphere', 'unknown')} | " + f"MNI: {atlas_enrichment.get('mni_coords', [])} | " + f"Top candidates: {atlas_enrichment.get('assignment_scores', [])[:3]}" + ) + else: + atlas_str = "Not available (SAM3 path not taken or siibra query failed)." + prompt = REPORT_PROMPT_TEMPLATE.format( task=task, routing_path=" → ".join(routing_path), @@ -325,6 +340,7 @@ def fmt(d) -> str: biomedclip_result=fmt(biomedclip_result), verification_result=fmt(verification_result), saliency_iou=iou_str, + atlas_enrichment=atlas_str, ) image = Image.open(image_path).convert("RGB") raw = self._generate(image, prompt, max_new_tokens=600) diff --git a/agents/sibra_tool.py b/agents/sibra_tool.py new file mode 100644 index 0000000..d641bdf --- /dev/null +++ b/agents/sibra_tool.py @@ -0,0 +1,225 @@ +# agents/siibra_tool.py +""" +siibra-python integration for anatomical region assignment. + +Requires: + pip install siibra + EBRAINS account for Knowledge Graph queries (optional — atlas queries work without auth, + KG feature queries require a token: https://ebrains.eu/register) + +siibra queries the EBRAINS Human Brain Atlas (Julich-Brain parcellation by default) +to assign a lesion centroid to a named brain region and retrieve multimodal features. +""" + +import siibra +import nibabel as nib +import numpy as np +from pathlib import Path +from typing import Optional + + +class SiibraAtlasTool: + """ + Wraps siibra-python for anatomical assignment from lesion centroids. + + Usage in the pipeline: + 1. SAM3 outputs a binary mask and bbox in pixel space + 2. NIfTI affine converts pixel centroid → MNI152 mm coordinates + 3. siibra assigns those coordinates to a named parcellation region + 4. Optionally fetches receptor density / connectivity features for that region + + siibra 1.x API note: + Assignment requires a Map object (siibra.get_map), not parcellation.assign(). + Julich Brain uses STATISTICAL (probability) maps — LABELLED maps are not available + for the top-level parcellation and will raise NoMapAvailableError. + """ + + def __init__( + self, + parcellation: str = "julich 2.9", # v2.9 has full STATISTICAL MNI152 coverage + space: str = "mni152", + fetch_features: bool = False, # KG feature queries — requires EBRAINS token + ): + self.parcellation = siibra.parcellations[parcellation] + self.space = siibra.spaces[space] + self.fetch_features = fetch_features + # siibra 1.x: assignment is done on a Map, not the parcellation directly + self._pmap = siibra.get_map( + parcellation=self.parcellation, + space=self.space, + maptype=siibra.MapType.STATISTICAL, + ) + print(f"[SiibraAtlasTool] parcellation={self.parcellation.name}, map={self._pmap}") + + # ── Main entry point ────────────────────────────────────────────────────── + + def assign_lesion( + self, + mask: np.ndarray, # binary mask from SAM3, shape (H, W) + nifti_path: Optional[str] = None, # NIfTI for affine (most accurate) + dicom_path: Optional[str] = None, # DICOM for scanner-space coords + voxel_size_mm: float = 1.0, + ) -> dict: + """ + Assign a SAM3 lesion mask centroid to a brain atlas region. + + Args: + mask: binary 2D mask (from SAM3Tool output) + nifti_path: path to the NIfTI scan the mask was derived from + voxel_size_mm: fallback voxel size if no NIfTI affine is available + + Returns dict with: + mni_coords: [x, y, z] in MNI152 mm + assigned_region: name of the Julich-Brain region + region_description: brief text description (if available) + hemisphere: "left" | "right" | "bilateral" + features: dict of multimodal features (if fetch_features=True) + assignment_scores: probability scores per candidate region + """ + # Step 1: pixel centroid from SAM3 mask + pixel_centroid = self._mask_centroid(mask) + + # Step 2: pixel → MNI mm via affine (NIfTI > DICOM > pixel fallback) + mni_coords = self._pixel_to_mni( + pixel_centroid, nifti_path, voxel_size_mm, + image_size=mask.shape[:2], + dicom_path=dicom_path, + ) + + # Step 3: siibra anatomical assignment via Map (siibra 1.x API) + # Map.assign() returns a DataFrame with columns: + # 'input structure', 'centroid', 'fragment', 'map value', 'region' + point = siibra.Point(tuple(float(v) for v in mni_coords), space=self.space) + assignments = self._pmap.assign(point) + + empty = assignments is None or (hasattr(assignments, "empty") and assignments.empty) + if empty: + return { + "mni_coords": [round(float(v), 2) for v in mni_coords], + "assigned_region": "unassigned", + "hemisphere": "unknown", + "assignment_scores": [], + } + + # Sort by probability score descending + top5 = assignments.nlargest(5, "map value") + best = top5.iloc[0] + region_name = str(best["region"]) + + result = { + "mni_coords": [round(float(v), 2) for v in mni_coords], + "assigned_region": region_name, + "hemisphere": self._hemisphere(region_name), + "assignment_scores": [ + { + "region": str(row["region"]), + "score": round(float(row["map value"]), 4), + } + for _, row in top5.iterrows() + ], + } + + # Step 4: optional KG feature queries + if self.fetch_features: + result["features"] = self._fetch_regional_features(best["region"]) + + return result + + # ── Helpers ─────────────────────────────────────────────────────────────── + + @staticmethod + def _mask_centroid(mask: np.ndarray) -> tuple[float, float]: + """Compute (row, col) centroid of a binary mask.""" + coords = np.argwhere(mask > 0) + if len(coords) == 0: + h, w = mask.shape + return (h / 2, w / 2) + return tuple(coords.mean(axis=0)) + + @staticmethod + def _pixel_to_mni( + pixel_centroid: tuple, + nifti_path: Optional[str], + voxel_size_mm: float, + image_size: tuple = (224, 224), # (H, W) of the mask + dicom_path: Optional[str] = None, + ) -> np.ndarray: + """ + Convert 2D pixel centroid to 3D MNI mm coordinates. + + Priority: + 1. NIfTI affine (most accurate — use when scan is registered to MNI152) + 2. DICOM ImagePositionPatient + PixelSpacing (scanner space, ≈MNI for + pre-registered datasets such as BraTS) + 3. Normalised pixel fallback (approximate — axial centre slice only) + + For the DICOM path, pass the original .dcm file path via + state["metadata"]["dicom_path"]; the main pipeline scan can remain a PNG. + """ + row, col = pixel_centroid + h, w = image_size + + if nifti_path and Path(nifti_path).exists(): + img = nib.load(nifti_path) + affine = img.affine + z_slice = img.shape[2] // 2 + voxel = np.array([col, row, z_slice, 1.0]) + return (affine @ voxel)[:3] + + if dicom_path and Path(dicom_path).exists(): + try: + import pydicom + dcm = pydicom.dcmread(dicom_path) + ipp = np.array(dcm.ImagePositionPatient, dtype=float) # [x,y,z] mm + iop = np.array(dcm.ImageOrientationPatient, dtype=float) + spacing = np.array(dcm.PixelSpacing, dtype=float) # [row_sp, col_sp] + F = iop.reshape(2, 3).T # 3×2 direction-cosine matrix + # Scanner-space coords (≈ MNI for pre-registered BraTS/ADNI scans) + return ipp + F[:, 0] * col * spacing[1] + F[:, 1] * row * spacing[0] + except Exception as e: + print(f"[SiibraAtlasTool] DICOM affine failed ({e}), using pixel fallback") + + # Fallback: normalise pixel position into MNI152 1mm range + # Maps (0,0)→(−91,−109) and (W,H)→(+91,+109) regardless of image resolution + mni_x = (col / w * 182 - 91) * voxel_size_mm + mni_y = (row / h * 218 - 109) * voxel_size_mm + return np.array([mni_x, mni_y, 0.0]) + + @staticmethod + def _hemisphere(region_name: str) -> str: + name = region_name.lower() + if "_l" in name or "left" in name: + return "left" + elif "_r" in name or "right" in name: + return "right" + return "bilateral" + + def _fetch_regional_features(self, region) -> dict: + """ + Fetch multimodal features linked to this region from EBRAINS KG. + Requires EBRAINS authentication token. + """ + features = {} + try: + receptor_features = siibra.get_features( + region, siibra.features.molecular.ReceptorDensityFingerprint + ) + if receptor_features: + features["receptor_densities"] = { + (f.receptors[0] if f.receptors else "unknown"): + round(float(np.mean(list(f.data.values()))), 4) + for f in receptor_features[:3] + } + except Exception: + pass + + try: + conn_features = siibra.get_features( + region, siibra.features.connectivity.StreamlineCounts + ) + if conn_features: + features["structural_connectivity_available"] = True + except Exception: + pass + + return features \ No newline at end of file diff --git a/pipeline/fhir_output.py b/pipeline/fhir_output.py index b93d3b1..b7887ef 100644 --- a/pipeline/fhir_output.py +++ b/pipeline/fhir_output.py @@ -205,6 +205,19 @@ def _build_diagnostic_report( ], }) + atlas = state.get("atlas_enrichment") + if atlas: + report["extension"].append({ + "url": "https://example.org/fhir/StructureDefinition/ebrains-atlas-assignment", + "extension": [ + {"url": "parcellation", "valueString": "Julich-Brain 3.0"}, + {"url": "assigned-region", "valueString": atlas.get("assigned_region", "")}, + {"url": "hemisphere", "valueString": atlas.get("hemisphere", "")}, + {"url": "mni-coordinates", "valueString": str(atlas.get("mni_coords", []))}, + {"url": "top-candidates", "valueString": str(atlas.get("assignment_scores", [])[:3])}, + ], + }) + saliency = state.get("explainability_result") or {} if saliency: report["presentedForm"] = [ diff --git a/pipeline/graph.py b/pipeline/graph.py index 707b805..2b8e129 100644 --- a/pipeline/graph.py +++ b/pipeline/graph.py @@ -10,6 +10,8 @@ cnn_direct sam3_then_cnn biomedclip human_review │ │ │ │ cnn_classify sam3_segment biomedclip │ + │ │ │ │ + │ atlas_enrichment │ │ │ │ │ │ │ cnn_with_mask │ │ │ │ │ │ @@ -31,9 +33,11 @@ from agents.cnn_tool import CNNClassifier from agents.medgemma_agent import MedGemmaAgent from agents.sam3_tool import SAM3Tool +from agents.sibra_tool import SiibraAtlasTool from config import DEFAULT_CONFIG, PipelineConfig from pipeline.nodes import ( human_review_node, + make_atlas_enrichment_node, make_biomedclip_node, make_cnn_node, make_cnn_with_mask_node, @@ -66,11 +70,13 @@ def build_pipeline(cfg: PipelineConfig = None): cnn = CNNClassifier(cfg.model, cfg.preprocess) sam3 = SAM3Tool(cfg.model, output_dir=f"{cfg.output_dir}/segmentation") clip = BiomedCLIPTool(cfg.model, cfg.preprocess) + siibra = SiibraAtlasTool() # ── Create node functions via factories ─────────────────────────────────── triage_fn = make_triage_node(medgemma, cfg.routing) cnn_fn = make_cnn_node(cnn) sam3_fn = make_sam3_node(sam3) + atlas_fn = make_atlas_enrichment_node(siibra) cnn_with_mask_fn = make_cnn_with_mask_node(cnn, agent=medgemma) biomedclip_fn = make_biomedclip_node(clip, cfg.routing) report_fn = make_report_node(medgemma, cfg.routing, skip_report=cfg.skip_report) @@ -83,6 +89,7 @@ def build_pipeline(cfg: PipelineConfig = None): workflow.add_node("triage", triage_fn) workflow.add_node("cnn_classify", cnn_fn) workflow.add_node("sam3_segment", sam3_fn) + workflow.add_node("atlas_enrichment", atlas_fn) workflow.add_node("cnn_with_mask", cnn_with_mask_fn) workflow.add_node("biomedclip", biomedclip_fn) workflow.add_node("verification", verification_fn) @@ -105,8 +112,9 @@ def build_pipeline(cfg: PipelineConfig = None): }, ) - # SAM3 always feeds into CNN-with-mask (MedGemma gets overlay; CNN gets original) - workflow.add_edge("sam3_segment", "cnn_with_mask") + # SAM3 → atlas enrichment → CNN-with-mask (MedGemma gets overlay; CNN gets original) + workflow.add_edge("sam3_segment", "atlas_enrichment") + workflow.add_edge("atlas_enrichment", "cnn_with_mask") # Optional explainability node between CNN classification and verification if cfg.generate_explainability: diff --git a/pipeline/nodes.py b/pipeline/nodes.py index 106e610..c954149 100644 --- a/pipeline/nodes.py +++ b/pipeline/nodes.py @@ -8,6 +8,7 @@ import uuid from pathlib import Path +from agents.sibra_tool import SiibraAtlasTool import cv2 import numpy as np import torchvision.transforms as T @@ -162,6 +163,7 @@ def report_node(state: NeuroimagingState) -> dict: biomedclip_result=state.get("biomedclip_result"), verification_result=state.get("verification_result"), saliency_iou=saliency_iou, + atlas_enrichment=state.get("atlas_enrichment"), ) # Determine final prediction: prefer CNN result, fall back to BiomedCLIP @@ -392,3 +394,52 @@ def route_from_triage(state: NeuroimagingState) -> str: fall through to BiomedCLIP re-ranking. """ return state["routing_decision"] + +def make_atlas_enrichment_node(siibra_tool: SiibraAtlasTool): + """ + Runs after SAM3 segmentation (sam3_then_cnn path only). + Loads the SAM3 binary mask from disk, maps the lesion centroid to a + Julich-Brain region via siibra, and stores the result in atlas_enrichment. + + Coordinate accuracy (best → worst): + NIfTI affine — pass state["metadata"]["nifti_path"] + DICOM affine — pass state["metadata"]["dicom_path"] + pixel fallback — approximate, axial centre slice only + """ + def atlas_enrichment_node(state: NeuroimagingState) -> dict: + seg_result = state.get("segmentation_result") + + if not seg_result or not seg_result.get("mask_path"): + return { + "atlas_enrichment": None, + "routing_path": state["routing_path"] + ["atlas_enrichment"], + } + + mask = np.array( + Image.open(seg_result["mask_path"]).convert("L") + ) > 127 # binary uint8-like bool array + + meta = state.get("metadata") or {} + nifti_path = meta.get("nifti_path") + dicom_path = meta.get("dicom_path") + + try: + atlas_result = siibra_tool.assign_lesion( + mask=mask.astype(np.uint8), + nifti_path=nifti_path, + dicom_path=dicom_path, + ) + print( + f"[atlas_enrichment] lesion → {atlas_result['assigned_region']} " + f"({atlas_result['hemisphere']}) MNI {atlas_result['mni_coords']}" + ) + except Exception as e: + print(f"[atlas_enrichment] siibra query failed: {e}") + atlas_result = None + + return { + "atlas_enrichment": atlas_result, + "routing_path": state["routing_path"] + ["atlas_enrichment"], + } + + return atlas_enrichment_node \ No newline at end of file diff --git a/pipeline/state.py b/pipeline/state.py index d7465fd..dd70bb3 100644 --- a/pipeline/state.py +++ b/pipeline/state.py @@ -52,6 +52,9 @@ class NeuroimagingState(TypedDict): explainability_result: Optional[dict] # saliency map paths keyed by method name saliency_sam3_iou: Optional[float] # IoU between GradCAM++ heatmap and SAM3 mask + # ── Atlas enrichment (siibra) ───────────────────────────────────────────── + atlas_enrichment: Optional[dict] # assigned_region, mni_coords, hemisphere, scores + # ── Final output ────────────────────────────────────────────────────────── final_report: Optional[str] final_predicted_class: Optional[str] @@ -88,6 +91,7 @@ def initial_state( final_confidence=0.0, requires_human_review=False, verification_result=None, + atlas_enrichment=None, fhir_report=None, routing_path=[], ) diff --git a/requirements.txt b/requirements.txt index a11f15c..e08400c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,6 +45,11 @@ seaborn>=0.12.0 # FHIR output fhir.resources>=7.0.0 +# EBRAINS atlas (siibra) +siibra>=1.0a4 +nibabel>=5.0.0 +pydicom>=2.4.0 + # Code quality black>=24.0.0 isort>=5.13.0 diff --git a/tests/test_atlas_enrichment.py b/tests/test_atlas_enrichment.py new file mode 100644 index 0000000..01b774c --- /dev/null +++ b/tests/test_atlas_enrichment.py @@ -0,0 +1,136 @@ +""" +Quick test for the atlas enrichment node on a single image. + +Usage: + python tests/test_atlas_enrichment.py --image path/to/scan.png + python tests/test_atlas_enrichment.py --image scan.png --mask mask.png + python tests/test_atlas_enrichment.py --image scan.png --dicom scan.dcm + python tests/test_atlas_enrichment.py --image scan.png --nifti scan.nii.gz + +If --mask is omitted, a synthetic centre-blob mask is generated from the image +dimensions (simulates a lesion in the left-centre of the brain). + +Requires: + pip install siibra nibabel pydicom +""" + +import argparse +import json +import sys +import tempfile +from pathlib import Path + +import numpy as np +from PIL import Image + +# ── resolve project root so imports work from any cwd ──────────────────────── +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +def make_synthetic_mask(image_path: str) -> str: + """ + Generate a binary mask PNG with an ellipse blob in the left-centre of the + image (rough approximation of a left-hemisphere tumour location). + Saved to a temp file; caller is responsible for cleanup. + """ + img = Image.open(image_path).convert("L") + w, h = img.size + + mask = np.zeros((h, w), dtype=np.uint8) + cy, cx = h // 2, w // 3 # left-centre + ry, rx = h // 6, w // 8 # ellipse radii + + Y, X = np.ogrid[:h, :w] + blob = ((X - cx) / rx) ** 2 + ((Y - cy) / ry) ** 2 <= 1.0 + mask[blob] = 255 + + tmp = tempfile.NamedTemporaryFile(suffix="_mask.png", delete=False) + Image.fromarray(mask).save(tmp.name) + return tmp.name + + +def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_path: str | None): + from agents.sibra_tool import SiibraAtlasTool + from pipeline.nodes import make_atlas_enrichment_node + from pipeline.state import initial_state + + # ── Build a minimal fake state ──────────────────────────────────────────── + synthetic_mask = False + if mask_path is None: + print("[test] No mask supplied — generating synthetic centre-blob mask.") + mask_path = make_synthetic_mask(image_path) + synthetic_mask = True + + state = initial_state(image_path=image_path, task="binary_tumor", metadata={}) + state["segmentation_result"] = { + "mask_path": mask_path, + "bbox": [0, 0, 224, 224], + "guided_image_path": image_path, + "dice_estimate": 0.0, + } + if nifti_path: + state["metadata"]["nifti_path"] = nifti_path + if dicom_path: + state["metadata"]["dicom_path"] = dicom_path + state["routing_path"] = ["triage", "sam3_segment"] + + # ── Run tool directly ───────────────────────────────────────────────────── + print("\n─── SiibraAtlasTool.assign_lesion ───────────────────────────────────") + tool = SiibraAtlasTool(fetch_features=False) + + mask_arr = np.array(Image.open(mask_path).convert("L")) > 127 + atlas_result = tool.assign_lesion( + mask=mask_arr.astype(np.uint8), + nifti_path=nifti_path, + dicom_path=dicom_path, + ) + print(json.dumps(atlas_result, indent=2, default=str)) + + # ── Run through the node ────────────────────────────────────────────────── + print("\n─── make_atlas_enrichment_node (node output) ────────────────────────") + node = make_atlas_enrichment_node(tool) + node_output = node(state) + print(json.dumps(node_output, indent=2, default=str)) + + # ── Summary ────────────────────────────────────────────────────────────── + enrichment = node_output.get("atlas_enrichment") or {} + print("\n─── Summary ─────────────────────────────────────────────────────────") + print(f" Image : {image_path}") + print(f" Mask : {mask_path}{' (synthetic)' if synthetic_mask else ''}") + print(f" MNI coords : {enrichment.get('mni_coords', 'n/a')}") + print(f" Region : {enrichment.get('assigned_region', 'n/a')}") + print(f" Hemisphere : {enrichment.get('hemisphere', 'n/a')}") + top = enrichment.get("assignment_scores", [])[:3] + if top: + print(" Top regions :") + for c in top: + print(f" {c['score']:.4f} {c['region']}") + print(f" Routing path : {node_output.get('routing_path')}") + + if synthetic_mask: + Path(mask_path).unlink(missing_ok=True) + + +def main(): + parser = argparse.ArgumentParser(description="Test atlas enrichment node on a single image.") + parser.add_argument("--image", required=True, help="Path to input brain scan PNG") + parser.add_argument("--mask", default=None, help="Binary mask PNG (white = lesion). Auto-generated if omitted.") + parser.add_argument("--nifti", default=None, help="NIfTI file for accurate MNI affine") + parser.add_argument("--dicom", default=None, help="DICOM file for scanner-space coords") + args = parser.parse_args() + + if not Path(args.image).exists(): + print(f"ERROR: image not found: {args.image}") + sys.exit(1) + + try: + run(args.image, args.mask, args.nifti, args.dicom) + except ModuleNotFoundError as e: + print(f"\nERROR: missing dependency — {e}") + print("Run: pip install siibra nibabel pydicom") + sys.exit(1) + + +if __name__ == "__main__": + main() From 5f07dac15ea0b3d9af1d492f106a7464e6936933 Mon Sep 17 00:00:00 2001 From: Tamara Kostova Date: Wed, 29 Apr 2026 15:51:46 +0200 Subject: [PATCH 2/4] dicom preprocess --- README.md | 17 +++- agents/dicom_tool.py | 212 +++++++++++++++++++++++++++++++++++++++ agents/medgemma_agent.py | 63 +++++++++++- eval/evaluate.py | 44 ++++---- pipeline/fhir_output.py | 14 ++- pipeline/nodes.py | 11 +- pipeline/state.py | 38 ++++++- run_pipeline.py | 12 ++- 8 files changed, 377 insertions(+), 34 deletions(-) create mode 100644 agents/dicom_tool.py diff --git a/README.md b/README.md index c8eed11..264badd 100644 --- a/README.md +++ b/README.md @@ -124,12 +124,25 @@ The pipeline selects the checkpoint automatically based on `--task`. ## Usage -**Single image:** +**Single image (PNG/JPEG):** ```bash python run_pipeline.py --image scan.png --task binary_tumor ``` +**Single DICOM slice:** + +```bash +python run_pipeline.py --image scan.dcm --task binary_tumor +``` + +**DICOM series directory:** + +```bash +python run_pipeline.py --image /path/to/dicom_series --task binary_tumor +``` +Raw DICOM inputs are converted to `outputs/preprocessed/*.png` before routing. For series inputs, the middle slice is selected as the representative 2D image and the chosen slice path is preserved in `metadata["dicom_path"]` for atlas enrichment. + **With explainability (Grad-CAM++ + Integrated Gradients):** ```bash @@ -219,6 +232,8 @@ Pass coordinate metadata at inference time: initial_state("scan.png", "binary_tumor", metadata={"nifti_path": "scan.nii.gz"}) ``` +If the main input itself is DICOM, `initial_state()` now injects `metadata["dicom_path"]` automatically after preprocessing the slice or series into a PNG. + **Test the node standalone (without running the full pipeline):** ```bash python tests/test_atlas_enrichment.py --image data/scan.png --mask outputs/segmentation/mask_abc123.png diff --git a/agents/dicom_tool.py b/agents/dicom_tool.py new file mode 100644 index 0000000..540f3f7 --- /dev/null +++ b/agents/dicom_tool.py @@ -0,0 +1,212 @@ +# agents/dicom_tool.py +""" +DICOM ingestion and preprocessing for the neuroimaging pipeline. + +Handles: + - Single .dcm file (one slice) + - DICOM series directory (multi-slice → selects representative slice) + - Passthrough for PNG/JPEG (no-op) + +Output: + - PNG saved to outputs/preprocessed/_.png (input to existing pipeline) + - Metadata dict injected into NeuroimagingState +""" + +import pydicom +import numpy as np +from PIL import Image +from pathlib import Path +import hashlib + +from pydicom.misc import is_dicom + + +class DICOMPreprocessor: + + def __init__(self, output_dir: str = "outputs/preprocessed"): + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + def prepare(self, input_path: str) -> dict: + """ + Main entry point. Accepts a .dcm file, a directory of .dcm files, + or a PNG/JPEG (passthrough). + + Returns: + { + "image_path": str, # path to PNG ready for CNN/MedGemma + "nifti_path": None, # populated if you add NIfTI conversion (see below) + "dicom_path": str | None, + "dicom_metadata": dict | None + } + """ + p = Path(input_path) + + if p.is_dir(): + return self._process_series(p) + elif self._is_dicom_file(p): + return self._process_single(p) + else: + # Already a PNG/JPEG — passthrough, no metadata + return { + "image_path": str(p), + "nifti_path": None, + "dicom_path": None, + "dicom_metadata": None, + } + + # ── Single slice ────────────────────────────────────────────────────────── + + def _process_single(self, dcm_path: Path) -> dict: + ds = pydicom.dcmread(str(dcm_path)) + metadata = self._extract_metadata(ds) + png_path = self._pixel_array_to_png(ds, self._output_stem(dcm_path)) + return { + "image_path": str(png_path), + "nifti_path": None, + "dicom_path": str(dcm_path), + "dicom_metadata": metadata, + } + + # ── Series (folder of slices) ───────────────────────────────────────────── + + def _process_series(self, series_dir: Path) -> dict: + """ + Load a DICOM series, sort slices by InstanceNumber, + select the middle slice as the representative 2D image, + and extract metadata from that selected slice. + """ + dcm_files = sorted(f for f in series_dir.iterdir() if self._is_dicom_file(f)) + if not dcm_files: + raise ValueError(f"No DICOM files found in {series_dir}") + + # Sort by InstanceNumber (slice position) + slices = [] + for f in dcm_files: + ds = pydicom.dcmread(str(f), stop_before_pixels=False) + instance = int(getattr(ds, "InstanceNumber", 0)) + slices.append((instance, f, ds)) + slices.sort(key=lambda x: x[0]) + + # Representative slice: middle of the series + # For tumour tasks the middle axial slice is usually most informative + # You could also select by maximum lesion area if a rough threshold is known + mid_idx = len(slices) // 2 + _, mid_path, mid_ds = slices[mid_idx] + + metadata = self._extract_metadata(mid_ds) + metadata["total_slices"] = len(slices) + metadata["selected_slice_idx"] = mid_idx + + png_path = self._pixel_array_to_png(mid_ds, self._output_stem(series_dir)) + + return { + "image_path": str(png_path), + "nifti_path": None, # see _series_to_nifti() below if needed + "dicom_path": str(mid_path), + "dicom_metadata": metadata, + } + + # ── Pixel array → normalised PNG ───────────────────────────────────────── + + def _pixel_array_to_png(self, ds: "pydicom.Dataset", stem: str) -> Path: + """ + Convert DICOM pixel array to a normalised 8-bit grayscale PNG. + + Handles: + - Modality LUT (rescale slope/intercept for CT Hounsfield units) + - Window/level for soft tissue vs bone CT windows + - Inversion for some MRI sequences + """ + pixel_array = ds.pixel_array.astype(np.float32) + + # Apply RescaleSlope / RescaleIntercept (standard for CT) + slope = float(getattr(ds, "RescaleSlope", 1.0)) + intercept = float(getattr(ds, "RescaleIntercept", 0.0)) + pixel_array = pixel_array * slope + intercept + + # Apply window/level if present (typical for CT brain window) + window_center = getattr(ds, "WindowCenter", None) + window_width = getattr(ds, "WindowWidth", None) + if window_center and window_width: + wc = float(window_center[0] if hasattr(window_center, "__iter__") else window_center) + ww = float(window_width[0] if hasattr(window_width, "__iter__") else window_width) + lo = wc - ww / 2 + hi = wc + ww / 2 + pixel_array = np.clip(pixel_array, lo, hi) + + if getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1": + pixel_array = pixel_array.max() - pixel_array + + # Min-max normalise to 0–255 + lo, hi = pixel_array.min(), pixel_array.max() + if hi > lo: + pixel_array = (pixel_array - lo) / (hi - lo) * 255.0 + pixel_array = pixel_array.astype(np.uint8) + + # Convert to RGB (your CNNs and MedGemma expect 3-channel input) + img = Image.fromarray(pixel_array, mode="L").convert("RGB") + + out_path = self.output_dir / f"{stem}.png" + img.save(str(out_path)) + return out_path + + @staticmethod + def _is_dicom_file(path: Path) -> bool: + if not path.is_file(): + return False + if path.suffix.lower() in {".dcm", ".dicom", ".ima"}: + return True + try: + return bool(is_dicom(str(path))) + except Exception: + return False + + @staticmethod + def _output_stem(path: Path) -> str: + digest = hashlib.sha1(str(path.resolve()).encode("utf-8")).hexdigest()[:8] + return f"{path.stem}_{digest}" if path.is_file() else f"{path.name}_{digest}" + + # ── Metadata extraction ─────────────────────────────────────────────────── + + @staticmethod + def _extract_metadata(ds: "pydicom.Dataset") -> dict: + """ + Pull the clinically and technically relevant tags from the DICOM header. + These are injected into NeuroimagingState and passed to MedGemma's prompt. + """ + def safe(tag): + val = getattr(ds, tag, None) + if val is None: + return None + # pydicom sequences are not JSON-serialisable — convert to str + return str(val) if not isinstance(val, (int, float, str)) else val + + return { + # Patient context (anonymised in real deployments) + "patient_age": safe("PatientAge"), # e.g. "068Y" + "patient_sex": safe("PatientSex"), # "M" | "F" | "O" + + # Acquisition parameters — critical for modality routing + "modality": safe("Modality"), # "MR" | "CT" + "series_description": safe("SeriesDescription"),# "T2 FLAIR AX" + "sequence_name": safe("SequenceVariant"), + "scanning_sequence": safe("ScanningSequence"), # "EP" | "GR" | "SE" + "field_strength": safe("MagneticFieldStrength"), # 1.5 | 3.0 + + # Scanner provenance + "manufacturer": safe("Manufacturer"), + "manufacturer_model": safe("ManufacturerModelName"), + "institution": safe("InstitutionName"), + + # Geometric parameters (needed for siibra coordinate mapping) + "slice_thickness_mm": safe("SliceThickness"), + "pixel_spacing": safe("PixelSpacing"), # [row_mm, col_mm] + "image_orientation": safe("ImageOrientationPatient"), + "image_position": safe("ImagePositionPatient"), # MNI registration anchor + + # Study context + "study_description": safe("StudyDescription"), + "body_part": safe("BodyPartExamined"), + "accession_number": safe("AccessionNumber"), # links to RIS/PACS + } diff --git a/agents/medgemma_agent.py b/agents/medgemma_agent.py index 3319136..bd8aa14 100644 --- a/agents/medgemma_agent.py +++ b/agents/medgemma_agent.py @@ -151,6 +151,9 @@ def diagnosis_to_routing( Task: {task} Route: {routing_path} +Known scan metadata (DICOM/header context, if available): +{metadata_context} + MedGemma triage diagnosis: {medgemma_dx} @@ -277,25 +280,35 @@ def __init__( # ── Primary triage (raw image) ──────────────────────────────────────────── - def diagnose(self, image_path: str) -> tuple[MedicalDiagnosis, RoutingDecision]: + def diagnose( + self, image_path: str, metadata: Optional[dict] = None + ) -> tuple[MedicalDiagnosis, RoutingDecision]: """ Run system_prompt.txt on the raw image. Returns (structured diagnosis, derived routing decision). """ image = Image.open(image_path).convert("RGB") - dx = self._run_diagnostic_prompt(image, SYSTEM_PROMPT) + dx = self._run_diagnostic_prompt( + image, + self._prepend_metadata_context(SYSTEM_PROMPT, metadata), + ) routing = diagnosis_to_routing(dx, self.routing_cfg) return dx, routing # ── SAM3-guided diagnosis (bbox overlay image) ──────────────────────────── - def diagnose_with_bbox(self, guided_image_path: str) -> MedicalDiagnosis: + def diagnose_with_bbox( + self, guided_image_path: str, metadata: Optional[dict] = None + ) -> MedicalDiagnosis: """ Run system_prompt_bbox.txt on the SAM3 bbox-overlay image. Used on the sam3_then_cnn path after segmentation. """ image = Image.open(guided_image_path).convert("RGB") - return self._run_diagnostic_prompt(image, SYSTEM_PROMPT_BBOX) + return self._run_diagnostic_prompt( + image, + self._prepend_metadata_context(SYSTEM_PROMPT_BBOX, metadata), + ) # ── Report generator ────────────────────────────────────────────────────── @@ -311,6 +324,7 @@ def generate_report( verification_result: Optional[dict] = None, saliency_iou: Optional[float] = None, atlas_enrichment: Optional[dict] = None, + metadata: Optional[dict] = None, ) -> str: def fmt(d) -> str: if d is None: @@ -334,6 +348,7 @@ def fmt(d) -> str: prompt = REPORT_PROMPT_TEMPLATE.format( task=task, routing_path=" → ".join(routing_path), + metadata_context=self._format_metadata_context(metadata), medgemma_dx=fmt(medgemma_dx), cnn_result=fmt(cnn_result), sam3_result=fmt(sam3_result), @@ -388,6 +403,46 @@ def _run_diagnostic_prompt( severity_confidence=None, ) + def _prepend_metadata_context(self, prompt: str, metadata: Optional[dict]) -> str: + context = self._format_metadata_context(metadata) + if context == "Not available.": + return prompt + return ( + "Known scan metadata (DICOM/header context; may be incomplete):\n" + f"{context}\n\n" + f"{prompt}" + ) + + @staticmethod + def _format_metadata_context(metadata: Optional[dict]) -> str: + if not metadata: + return "Not available." + + fields = [ + ("Modality", "modality"), + ("Series description", "series_description"), + ("Scanning sequence", "scanning_sequence"), + ("Sequence variant", "sequence_name"), + ("Field strength", "field_strength"), + ("Patient age", "patient_age"), + ("Patient sex", "patient_sex"), + ("Slice thickness (mm)", "slice_thickness_mm"), + ("Pixel spacing", "pixel_spacing"), + ("Body part", "body_part"), + ] + lines = [] + for label, key in fields: + value = metadata.get(key) + if value in (None, "", [], {}): + continue + if key == "modality" and value == "MR": + value = "MRI" + if isinstance(value, (list, dict)): + value = json.dumps(value) + lines.append(f"- {label}: {value}") + + return "\n".join(lines) if lines else "Not available." + def _generate( self, image: Image.Image, diff --git a/eval/evaluate.py b/eval/evaluate.py index 35ae04b..44b938e 100644 --- a/eval/evaluate.py +++ b/eval/evaluate.py @@ -146,7 +146,7 @@ def compute_oracle_routing(cnn_result: dict, routing_cfg=None) -> str: def load_test_split(dataset_dir: str, task: str) -> list[dict]: """ Load test split from a directory structured as: - // + // Returns list of {"image_path": str, "label": str, "task": str} """ @@ -155,22 +155,32 @@ def load_test_split(dataset_dir: str, task: str) -> list[dict]: for class_dir in sorted(dataset_path.iterdir()): if not class_dir.is_dir(): continue - for img_file in class_dir.glob("*.png"): - samples.append( - { - "image_path": str(img_file), - "label": class_dir.name, - "task": task, - } - ) - for img_file in class_dir.glob("*.jpg"): - samples.append( - { - "image_path": str(img_file), - "label": class_dir.name, - "task": task, - } - ) + for item in sorted(class_dir.iterdir()): + if item.is_file() and item.suffix.lower() in { + ".png", + ".jpg", + ".jpeg", + ".dcm", + ".dicom", + ".ima", + }: + samples.append( + { + "image_path": str(item), + "label": class_dir.name, + "task": task, + } + ) + elif item.is_dir() and any( + f.suffix.lower() in {".dcm", ".dicom", ".ima"} for f in item.iterdir() + ): + samples.append( + { + "image_path": str(item), + "label": class_dir.name, + "task": task, + } + ) return samples diff --git a/pipeline/fhir_output.py b/pipeline/fhir_output.py index b7887ef..e9dadd9 100644 --- a/pipeline/fhir_output.py +++ b/pipeline/fhir_output.py @@ -60,8 +60,16 @@ def _build_patient(patient_id: str) -> dict: def _build_imaging_study(study_id: str, patient_id: str, state: dict) -> dict: """ImagingStudy representing the MRI/CT scan.""" - modality_code = DICOM_MODALITY.get( - (state.get("medgemma_diagnosis") or {}).get("modality", "MRI"), "MR" + metadata = state.get("metadata") or {} + modality_value = metadata.get("modality") or (state.get("medgemma_diagnosis") or {}).get( + "modality", "MRI" + ) + modality_code = DICOM_MODALITY.get(modality_value, modality_value if modality_value in {"MR", "CT"} else "MR") + description = ( + metadata.get("study_description") + or metadata.get("series_description") + or metadata.get("source_image_path") + or state.get("image_path", "unknown") ) return { "resourceType": "ImagingStudy", @@ -72,7 +80,7 @@ def _build_imaging_study(study_id: str, patient_id: str, state: dict) -> dict: "system": "http://dicom.nema.org/resources/ontology/DCM", "code": modality_code, }], - "description": state.get("image_path", "unknown"), + "description": description, } diff --git a/pipeline/nodes.py b/pipeline/nodes.py index c954149..6dc3d0c 100644 --- a/pipeline/nodes.py +++ b/pipeline/nodes.py @@ -35,7 +35,9 @@ def make_triage_node(agent, routing_cfg: RoutingConfig = None): cfg = routing_cfg or DEFAULT_CONFIG.routing def triage_node(state: NeuroimagingState) -> dict: - dx, routing = agent.diagnose(state["image_path"]) + dx, routing = agent.diagnose( + state["image_path"], metadata=state.get("metadata") + ) decision = routing.routing_decision reasoning = routing.reasoning @@ -113,7 +115,9 @@ def cnn_with_mask_node(state: NeuroimagingState) -> dict: if seg_valid and seg.get("guided_image_path") else state["image_path"] ) - bbox_dx = agent.diagnose_with_bbox(overlay_path) + bbox_dx = agent.diagnose_with_bbox( + overlay_path, metadata=state.get("metadata") + ) updates["medgemma_bbox_diagnosis"] = bbox_dx.model_dump() return updates @@ -164,6 +168,7 @@ def report_node(state: NeuroimagingState) -> dict: verification_result=state.get("verification_result"), saliency_iou=saliency_iou, atlas_enrichment=state.get("atlas_enrichment"), + metadata=state.get("metadata"), ) # Determine final prediction: prefer CNN result, fall back to BiomedCLIP @@ -442,4 +447,4 @@ def atlas_enrichment_node(state: NeuroimagingState) -> dict: "routing_path": state["routing_path"] + ["atlas_enrichment"], } - return atlas_enrichment_node \ No newline at end of file + return atlas_enrichment_node diff --git a/pipeline/state.py b/pipeline/state.py index dd70bb3..5c4a1ef 100644 --- a/pipeline/state.py +++ b/pipeline/state.py @@ -2,8 +2,14 @@ LangGraph state definition for the neuroimaging multi-agent pipeline. """ +from pathlib import Path from typing import Optional, TypedDict +from agents.dicom_tool import DICOMPreprocessor + + +_DICOM_PREPROCESSOR: Optional[DICOMPreprocessor] = None + class SegmentationResult(TypedDict): mask_path: str # Path to saved binary mask PNG @@ -70,11 +76,30 @@ class NeuroimagingState(TypedDict): def initial_state( image_path: str, task: str, metadata: dict = None ) -> NeuroimagingState: - """Create a blank state for a new image.""" + """ + Create a blank state for a new image. + + Raw DICOM inputs are converted to a PNG in `outputs/preprocessed/` so the + existing PNG-based agents can run unchanged. Relevant DICOM header fields + are merged into `metadata`, and the original DICOM slice path is preserved + as `metadata["dicom_path"]` for atlas coordinate mapping. + """ + prepared = _prepare_input_image(image_path) + prepared_metadata = dict(prepared.get("dicom_metadata") or {}) + if prepared.get("dicom_path"): + prepared_metadata["dicom_path"] = prepared["dicom_path"] + if prepared.get("nifti_path"): + prepared_metadata["nifti_path"] = prepared["nifti_path"] + prepared_metadata["source_image_path"] = str(Path(image_path)) + + merged_metadata = dict(prepared_metadata) + if metadata: + merged_metadata.update(metadata) + return NeuroimagingState( - image_path=image_path, + image_path=prepared["image_path"], task=task, - metadata=metadata or {}, + metadata=merged_metadata, routing_decision=None, routing_confidence=0.0, routing_reasoning="", @@ -95,3 +120,10 @@ def initial_state( fhir_report=None, routing_path=[], ) + + +def _prepare_input_image(image_path: str) -> dict: + global _DICOM_PREPROCESSOR + if _DICOM_PREPROCESSOR is None: + _DICOM_PREPROCESSOR = DICOMPreprocessor() + return _DICOM_PREPROCESSOR.prepare(image_path) diff --git a/run_pipeline.py b/run_pipeline.py index 653c82d..74018f6 100644 --- a/run_pipeline.py +++ b/run_pipeline.py @@ -2,9 +2,12 @@ Entry point for the multi-agent neuroimaging pipeline. Usage examples: - # Single image: + # Single image (PNG/JPEG or a single DICOM slice): python run_pipeline.py --image image.jpg --task binary_tumor + # DICOM series directory (selects the middle slice automatically): + python run_pipeline.py --image path/to/dicom_series --task binary_tumor + # Full evaluation across all datasets: python run_pipeline.py --eval \ --binary_tumor_dir data/test/binary_tumor \ @@ -34,7 +37,6 @@ from config import DEFAULT_CONFIG, ModelConfig, PipelineConfig, RoutingConfig from eval.evaluate import compare_configurations, load_test_split, run_single from pipeline.graph import build_pipeline -from pipeline.state import initial_state from dotenv import load_dotenv load_dotenv() @@ -45,7 +47,11 @@ def parse_args(): # Mode mode = p.add_mutually_exclusive_group(required=True) - mode.add_argument("--image", type=str, help="Path to a single input image") + mode.add_argument( + "--image", + type=str, + help="Path to PNG/JPEG, a single DICOM file, or a directory of DICOM slices", + ) mode.add_argument("--eval", action="store_true", help="Run full evaluation") # Single image From 47ea28d633fd54a917d4acabfc94da51d3ceda28 Mon Sep 17 00:00:00 2001 From: Tamara Kostova Date: Mon, 11 May 2026 11:00:30 +0200 Subject: [PATCH 3/4] nifti format --- outputs/siibra_test/100.jpg | Bin 0 -> 45459 bytes outputs/siibra_test/100_mask.png | Bin 0 -> 757 bytes outputs/siibra_test/figshare_100_result.json | 22 +++ .../siibra_test/volume_100_slice_83_mask.png | Bin 0 -> 539 bytes .../volume_100_slice_83_result.json | 26 +++ .../siibra_test/volume_100_slice_83_t1ce.png | Bin 0 -> 11882 bytes tests/test_atlas_enrichment.py | 19 +- utils/convert_h5.py | 166 ++++++++++++++++++ utils/{convert.py => convert_mat.py} | 0 9 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 outputs/siibra_test/100.jpg create mode 100644 outputs/siibra_test/100_mask.png create mode 100644 outputs/siibra_test/figshare_100_result.json create mode 100644 outputs/siibra_test/volume_100_slice_83_mask.png create mode 100644 outputs/siibra_test/volume_100_slice_83_result.json create mode 100644 outputs/siibra_test/volume_100_slice_83_t1ce.png create mode 100644 utils/convert_h5.py rename utils/{convert.py => convert_mat.py} (100%) diff --git a/outputs/siibra_test/100.jpg b/outputs/siibra_test/100.jpg new file mode 100644 index 0000000000000000000000000000000000000000..bccaa15bd34dbcae0f74efade364bffbbaa3fb48 GIT binary patch literal 45459 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 literal 0 HcmV?d00001 diff --git a/outputs/siibra_test/100_mask.png b/outputs/siibra_test/100_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..e0b1b3b39d2a46ef05ae6f9613d6b290a3239496 GIT binary patch literal 757 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w3=!$sk4H6Uk?r;B4q#hkZyPUk%~5O8&@ z{`Y_WR96EDmb!_7%(HICZvB{?I;nk1s>05@AYC*7GhWnvWw&^_<7xlHpqrD+YwT0_ zA4Q!kF8@A5=}Kc~KPSSM{+IS=?rbJ#Tv2?EX!luurijRufg^$Q$jyO zoA7}GCJ8@=!<-2_>KJ$M-?^mEu#F?Zf{{nP;USB`9E1W>o&)}k)>3Q-`mQkKd{$b? z7_cOn!LOZnA!Ed2t%ZyMs>TfL4l~a%*j+fjYZ~JQJtGF|J$@++@|r1;=NTfVNi_V8 z)i7k>GUYv>xN%h`!y2Ij1+mNpyb)JS8N`5=Z)4oSwkA`WA)ASL!**>}wg>h{!?}1G zykL$y!f
    #oR%NfHdLO3Vey5$Z+^tXEF*B(!==Yhg<$UERXApg{}hDiyN^fA>rje_~xA8f6(~#{zZj`-?yjlxvtPKf6f1Se2jPE tZPFe4Ie-67Pq1}kra^)r&d3Ax&$%B>liha0E~*Zs!_(EzWt~$(69CO9+BN_H literal 0 HcmV?d00001 diff --git a/outputs/siibra_test/figshare_100_result.json b/outputs/siibra_test/figshare_100_result.json new file mode 100644 index 0000000..7997689 --- /dev/null +++ b/outputs/siibra_test/figshare_100_result.json @@ -0,0 +1,22 @@ +{ + "image": "data/processed/1/100.jpg", + "mask": "data/processed/1_mask/100_mask.png", + "mask_synthetic": false, + "nifti": null, + "dicom": null, + "atlas_enrichment": { + "mni_coords": [ + -20.82, + -14.39, + 0.0 + ], + "assigned_region": "CGL (Metathalamus) right", + "hemisphere": "right", + "assignment_scores": [ + { + "region": "CGL (Metathalamus) right", + "score": 0.0 + } + ] + } +} \ No newline at end of file diff --git a/outputs/siibra_test/volume_100_slice_83_mask.png b/outputs/siibra_test/volume_100_slice_83_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..bd66827b624f040fb9bd7995ab521a0fe865daa6 GIT binary patch literal 539 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 literal 0 HcmV?d00001 diff --git a/tests/test_atlas_enrichment.py b/tests/test_atlas_enrichment.py index 01b774c..7749655 100644 --- a/tests/test_atlas_enrichment.py +++ b/tests/test_atlas_enrichment.py @@ -50,7 +50,7 @@ def make_synthetic_mask(image_path: str) -> str: return tmp.name -def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_path: str | None): +def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_path: str | None, output_path: str | None = None): from agents.sibra_tool import SiibraAtlasTool from pipeline.nodes import make_atlas_enrichment_node from pipeline.state import initial_state @@ -108,6 +108,20 @@ def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_pa print(f" {c['score']:.4f} {c['region']}") print(f" Routing path : {node_output.get('routing_path')}") + if output_path: + record = { + "image": image_path, + "mask": mask_path, + "mask_synthetic": synthetic_mask, + "nifti": nifti_path, + "dicom": dicom_path, + "atlas_enrichment": enrichment, + } + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + with open(output_path, "w") as fh: + json.dump(record, fh, indent=2, default=str) + print(f"\n Saved to : {output_path}") + if synthetic_mask: Path(mask_path).unlink(missing_ok=True) @@ -118,6 +132,7 @@ def main(): parser.add_argument("--mask", default=None, help="Binary mask PNG (white = lesion). Auto-generated if omitted.") parser.add_argument("--nifti", default=None, help="NIfTI file for accurate MNI affine") parser.add_argument("--dicom", default=None, help="DICOM file for scanner-space coords") + parser.add_argument("--output", default=None, help="Save result JSON to this path (e.g. outputs/brats_test/result.json)") args = parser.parse_args() if not Path(args.image).exists(): @@ -125,7 +140,7 @@ def main(): sys.exit(1) try: - run(args.image, args.mask, args.nifti, args.dicom) + run(args.image, args.mask, args.nifti, args.dicom, args.output) except ModuleNotFoundError as e: print(f"\nERROR: missing dependency — {e}") print("Run: pip install siibra nibabel pydicom") diff --git a/utils/convert_h5.py b/utils/convert_h5.py new file mode 100644 index 0000000..88dd63e --- /dev/null +++ b/utils/convert_h5.py @@ -0,0 +1,166 @@ +""" +Convert BraTS2020 per-slice h5 files to PNG + NIfTI for pipeline testing. + +The BraTS2020 training h5 files have the structure: + image: (240, 240, 4) float64 — channels: T1, T1ce, T2, FLAIR + mask: (240, 240, 3) uint8 — channels: NCR/NET, ED, ET (tumour sub-regions) + +Usage: + # Convert volume 100, auto-pick best slice, build NIfTI + python utils/convert_h5.py --volume 100 + + # Specific slice + python utils/convert_h5.py --volume 100 --slice 80 + + # Custom data dir and output dir + python utils/convert_h5.py --volume 100 --data_dir data/BraTS2020/BraTS2020_training_data/data --out_dir outputs/brats_test + +Output files: + volume_{id}_slice_{n}_t1ce.png — T1ce channel greyscale PNG (pipeline input) + volume_{id}_slice_{n}_mask.png — binary tumour mask PNG (white = tumour) + volume_{id}.nii.gz — full 3D T1ce volume in MNI152 space (accurate siibra coords) + +Then test the atlas tool: + python tests/test_atlas_enrichment.py \\ + --image outputs/brats_test/volume_100_slice_80_t1ce.png \\ + --mask outputs/brats_test/volume_100_slice_80_mask.png \\ + --nifti outputs/brats_test/volume_100.nii.gz +""" + +import argparse +import os +from pathlib import Path + +import h5py +import nibabel as nib +import numpy as np +from PIL import Image + +# BraTS2020 is pre-registered to MNI152 1mm isotropic space. +# Standard affine maps voxel (i,j,k) → MNI152 mm. +BRATS_MNI152_AFFINE = np.array([ + [-1, 0, 0, 90], + [ 0, -1, 0, 126], + [ 0, 0, 1, -72], + [ 0, 0, 0, 1], +], dtype=np.float32) + +CHANNEL_NAMES = ["t1", "t1ce", "t2", "flair"] + + +def load_slice(h5_path: Path) -> tuple[np.ndarray, np.ndarray]: + with h5py.File(h5_path, "r") as f: + image = f["image"][()] # (240, 240, 4) float64 + mask = f["mask"][()] # (240, 240, 3) uint8 + return image, mask + + +def find_slices(data_dir: Path, volume_id: int) -> list[Path]: + prefix = f"volume_{volume_id}_slice_" + slices = sorted( + data_dir.glob(f"{prefix}*.h5"), + key=lambda p: int(p.stem.split("_slice_")[-1]), + ) + if not slices: + raise FileNotFoundError(f"No h5 files found for volume {volume_id} in {data_dir}") + return slices + + +def best_slice(slice_paths: list[Path]) -> tuple[int, Path]: + """Return (slice_idx, path) for the slice with the most tumour voxels.""" + best_idx, best_path, best_count = 0, slice_paths[0], 0 + for path in slice_paths: + _, mask = load_slice(path) + count = int((mask > 0).sum()) + if count > best_count: + best_count = count + best_path = path + best_idx = int(path.stem.split("_slice_")[-1]) + return best_idx, best_path + + +def normalise_to_uint8(arr: np.ndarray) -> np.ndarray: + lo, hi = arr.min(), arr.max() + if hi == lo: + return np.zeros_like(arr, dtype=np.uint8) + return ((arr - lo) / (hi - lo) * 255).astype(np.uint8) + + +def save_png(arr_2d: np.ndarray, path: Path) -> None: + Image.fromarray(arr_2d).save(path) + + +def build_nifti(slice_paths: list[Path], channel: int = 1) -> nib.Nifti1Image: + """Stack all slices into a 3D volume and return a NIfTI with BraTS MNI152 affine.""" + slices = [] + for p in slice_paths: + img, _ = load_slice(p) + slices.append(img[:, :, channel]) # (240, 240) + + volume = np.stack(slices, axis=2).astype(np.float32) # (240, 240, N_slices) + return nib.Nifti1Image(volume, affine=BRATS_MNI152_AFFINE) + + +def convert(volume_id: int, data_dir: Path, out_dir: Path, slice_idx: int | None = None, channel: int = 1): + out_dir.mkdir(parents=True, exist_ok=True) + + slice_paths = find_slices(data_dir, volume_id) + print(f"Found {len(slice_paths)} slices for volume {volume_id}") + + if slice_idx is None: + slice_idx, chosen_path = best_slice(slice_paths) + print(f"Auto-selected slice {slice_idx} (most tumour voxels)") + else: + chosen_path = data_dir / f"volume_{volume_id}_slice_{slice_idx}.h5" + if not chosen_path.exists(): + raise FileNotFoundError(f"Slice not found: {chosen_path}") + + image, mask = load_slice(chosen_path) + ch_name = CHANNEL_NAMES[channel] + + # PNG: selected MRI channel + png_path = out_dir / f"volume_{volume_id}_slice_{slice_idx}_{ch_name}.png" + save_png(normalise_to_uint8(image[:, :, channel]), png_path) + print(f"Saved image PNG : {png_path}") + + # Binary mask PNG: union of all 3 tumour sub-regions + binary_mask = (mask.sum(axis=2) > 0).astype(np.uint8) * 255 + mask_path = out_dir / f"volume_{volume_id}_slice_{slice_idx}_mask.png" + save_png(binary_mask, mask_path) + print(f"Saved mask PNG : {mask_path}") + + # NIfTI: full 3D volume with MNI152 affine + nifti_path = out_dir / f"volume_{volume_id}.nii.gz" + nii = build_nifti(slice_paths, channel=channel) + nib.save(nii, nifti_path) + print(f"Saved NIfTI : {nifti_path}") + + print(f"\nRun atlas test with:") + print(f" python tests/test_atlas_enrichment.py \\") + print(f" --image {png_path} \\") + print(f" --mask {mask_path} \\") + print(f" --nifti {nifti_path}") + + return png_path, mask_path, nifti_path + + +def main(): + parser = argparse.ArgumentParser(description="Convert BraTS2020 h5 slices to PNG + NIfTI") + parser.add_argument("--volume", type=int, required=True, help="Volume ID (e.g. 100)") + parser.add_argument("--slice", type=int, default=None, help="Slice index (default: auto, picks slice with most tumour)") + parser.add_argument("--channel", type=int, default=1, help="MRI channel: 0=T1 1=T1ce 2=T2 3=FLAIR (default: 1=T1ce)") + parser.add_argument("--data_dir", default="data/BraTS2020/BraTS2020_training_data/data") + parser.add_argument("--out_dir", default="outputs/brats_test") + args = parser.parse_args() + + convert( + volume_id=args.volume, + data_dir=Path(args.data_dir), + out_dir=Path(args.out_dir), + slice_idx=args.slice, + channel=args.channel, + ) + + +if __name__ == "__main__": + main() diff --git a/utils/convert.py b/utils/convert_mat.py similarity index 100% rename from utils/convert.py rename to utils/convert_mat.py From e75addc6f064cbe865216586bdfd8b58bb709ce6 Mon Sep 17 00:00:00 2001 From: Tamara Kostova Date: Thu, 14 May 2026 16:05:46 +0200 Subject: [PATCH 4/4] add antspy mni152 registration for non-mni datasets --- agents/sibra_tool.py | 120 +++++++++++++++++- outputs/siibra_test/{ => 100}/100.jpg | Bin outputs/siibra_test/{ => 100}/100_mask.png | Bin .../100_result.json} | 2 + .../siibra_test/tumor_slice/tumor_mask.png | Bin 0 -> 435 bytes .../siibra_test/tumor_slice/tumor_slice.png | Bin 0 -> 10371 bytes .../tumor_slice/tumor_slice_result.json | 28 ++++ .../volume_100_slice_83_mask.png | Bin .../volume_100_slice_83_t1ce.png | Bin .../volume_100_slice_83_t1ce_result.json} | 8 +- requirements.txt | 4 + tests/test_atlas_enrichment.py | 52 ++++++-- 12 files changed, 195 insertions(+), 19 deletions(-) rename outputs/siibra_test/{ => 100}/100.jpg (100%) rename outputs/siibra_test/{ => 100}/100_mask.png (100%) rename outputs/siibra_test/{figshare_100_result.json => 100/100_result.json} (88%) create mode 100644 outputs/siibra_test/tumor_slice/tumor_mask.png create mode 100644 outputs/siibra_test/tumor_slice/tumor_slice.png create mode 100644 outputs/siibra_test/tumor_slice/tumor_slice_result.json rename outputs/siibra_test/{ => volume_100_slice_83_t1ce}/volume_100_slice_83_mask.png (100%) rename outputs/siibra_test/{ => volume_100_slice_83_t1ce}/volume_100_slice_83_t1ce.png (100%) rename outputs/siibra_test/{volume_100_slice_83_result.json => volume_100_slice_83_t1ce/volume_100_slice_83_t1ce_result.json} (63%) diff --git a/agents/sibra_tool.py b/agents/sibra_tool.py index d641bdf..85e537f 100644 --- a/agents/sibra_tool.py +++ b/agents/sibra_tool.py @@ -11,6 +11,8 @@ to assign a lesion centroid to a named brain region and retrieve multimodal features. """ +import tempfile + import siibra import nibabel as nib import numpy as np @@ -39,17 +41,25 @@ def __init__( parcellation: str = "julich 2.9", # v2.9 has full STATISTICAL MNI152 coverage space: str = "mni152", fetch_features: bool = False, # KG feature queries — requires EBRAINS token + auto_register: bool = False, # register NIfTI → MNI152 before siibra lookup + registration_type: str = "Affine", # "Affine" (fast, ~5-15s) or "SyN" (accurate, ~90s) ): self.parcellation = siibra.parcellations[parcellation] self.space = siibra.spaces[space] self.fetch_features = fetch_features + self.auto_register = auto_register + self.registration_type = registration_type # siibra 1.x: assignment is done on a Map, not the parcellation directly self._pmap = siibra.get_map( parcellation=self.parcellation, space=self.space, maptype=siibra.MapType.STATISTICAL, ) - print(f"[SiibraAtlasTool] parcellation={self.parcellation.name}, map={self._pmap}") + # Lazily populated by _get_mni152_template() + self._mni152_template_path: Optional[str] = None + # Cache: nifti_path → invtransforms list (avoids re-registering same scan) + self._registration_cache: dict[str, list[str]] = {} + print(f"[SiibraAtlasTool] parcellation={self.parcellation.name}, map={self._pmap}, auto_register={auto_register}") # ── Main entry point ────────────────────────────────────────────────────── @@ -90,7 +100,21 @@ def assign_lesion( # Map.assign() returns a DataFrame with columns: # 'input structure', 'centroid', 'fragment', 'map value', 'region' point = siibra.Point(tuple(float(v) for v in mni_coords), space=self.space) - assignments = self._pmap.assign(point) + try: + assignments = self._pmap.assign(point) + except (IndexError, ValueError) as e: + # Coordinates outside atlas bounds — most often caused by a NIfTI that is + # in native/SRI24 scanner space rather than MNI152. Set auto_register=True + # to run ANTsPy registration before siibra lookup. + print(f"[SiibraAtlasTool] coords {[round(float(v),1) for v in mni_coords]} " + f"outside atlas bounds ({e}). Use auto_register=True for non-MNI152 data.") + return { + "mni_coords": [round(float(v), 2) for v in mni_coords], + "assigned_region": "unassigned", + "hemisphere": "unknown", + "assignment_scores": [], + "error": "coordinates_out_of_bounds", + } empty = assignments is None or (hasattr(assignments, "empty") and assignments.empty) if empty: @@ -136,8 +160,8 @@ def _mask_centroid(mask: np.ndarray) -> tuple[float, float]: return (h / 2, w / 2) return tuple(coords.mean(axis=0)) - @staticmethod def _pixel_to_mni( + self, pixel_centroid: tuple, nifti_path: Optional[str], voxel_size_mm: float, @@ -148,7 +172,8 @@ def _pixel_to_mni( Convert 2D pixel centroid to 3D MNI mm coordinates. Priority: - 1. NIfTI affine (most accurate — use when scan is registered to MNI152) + 1. NIfTI affine → native-space coords; if auto_register=True, ANTsPy + then maps native → MNI152 via a cached registration transform. 2. DICOM ImagePositionPatient + PixelSpacing (scanner space, ≈MNI for pre-registered datasets such as BraTS) 3. Normalised pixel fallback (approximate — axial centre slice only) @@ -164,7 +189,10 @@ def _pixel_to_mni( affine = img.affine z_slice = img.shape[2] // 2 voxel = np.array([col, row, z_slice, 1.0]) - return (affine @ voxel)[:3] + native_coords = (affine @ voxel)[:3] + if self.auto_register: + return self._transform_point_to_mni152(native_coords, nifti_path) + return native_coords if dicom_path and Path(dicom_path).exists(): try: @@ -185,6 +213,88 @@ def _pixel_to_mni( mni_y = (row / h * 218 - 109) * voxel_size_mm return np.array([mni_x, mni_y, 0.0]) + def _get_mni152_template(self) -> str: + """ + Return a path to the MNI152 1mm template NIfTI, downloading via nilearn + on first call and caching for the lifetime of this object. + """ + if self._mni152_template_path and Path(self._mni152_template_path).exists(): + return self._mni152_template_path + + from nilearn import datasets as nilearn_datasets + mni_img = nilearn_datasets.load_mni152_template(resolution=1) + tmp = tempfile.NamedTemporaryFile( + suffix=".nii.gz", delete=False, prefix="mni152_template_" + ) + tmp.close() + nib.save(mni_img, tmp.name) + self._mni152_template_path = tmp.name + print(f"[SiibraAtlasTool] MNI152 template saved to {tmp.name}") + return self._mni152_template_path + + def _transform_point_to_mni152( + self, + native_coords: np.ndarray, + nifti_path: str, + ) -> np.ndarray: + """ + Register a NIfTI scan to MNI152 and map a single native-space point + through the resulting transform. + + Uses an image-based approach to avoid apply_transforms_to_points + convention ambiguity: creates a single-voxel indicator volume at the + native centroid, warps it forward to MNI152 via apply_transforms, then + reads the peak voxel location in MNI152 mm coordinates. + + Registration (fwdtransforms) is cached per nifti_path. + """ + import ants + + if nifti_path not in self._registration_cache: + print(f"[SiibraAtlasTool] Registering {Path(nifti_path).name} → MNI152 " + f"({self.registration_type}) …") + template_path = self._get_mni152_template() + fixed = ants.image_read(template_path) + moving = ants.image_read(nifti_path) + reg = ants.registration( + fixed=fixed, + moving=moving, + type_of_transform=self.registration_type, + ) + self._registration_cache[nifti_path] = reg["fwdtransforms"] + print(f"[SiibraAtlasTool] Registration complete.") + + fwd_transforms = self._registration_cache[nifti_path] + template_path = self._get_mni152_template() + fixed = ants.image_read(template_path) + moving = ants.image_read(nifti_path) + + # Convert native mm coords → voxel indices in moving space + img = nib.load(nifti_path) + inv_affine = np.linalg.inv(img.affine) + vox = np.round((inv_affine @ np.append(native_coords, 1))[:3]).astype(int) + vox = np.clip(vox, 0, np.array(img.shape) - 1) + + # Build a single-voxel indicator volume in moving space and warp it forward + indicator = np.zeros(img.shape, dtype=np.float32) + indicator[vox[0], vox[1], vox[2]] = 1.0 + indicator_ants = moving.new_image_like(indicator) + warped = ants.apply_transforms( + fixed=fixed, + moving=indicator_ants, + transformlist=fwd_transforms, + interpolator="linear", + ) + + # Peak voxel in MNI152 space → MNI152 mm via template affine + warped_data = warped.numpy() + if warped_data.max() < 1e-6: + print(f"[SiibraAtlasTool] Warped indicator is empty, returning native coords") + return native_coords + peak_vox = np.unravel_index(warped_data.argmax(), warped_data.shape) + mni_affine = nib.load(template_path).affine + return (mni_affine @ np.array([*peak_vox, 1]))[:3] + @staticmethod def _hemisphere(region_name: str) -> str: name = region_name.lower() diff --git a/outputs/siibra_test/100.jpg b/outputs/siibra_test/100/100.jpg similarity index 100% rename from outputs/siibra_test/100.jpg rename to outputs/siibra_test/100/100.jpg diff --git a/outputs/siibra_test/100_mask.png b/outputs/siibra_test/100/100_mask.png similarity index 100% rename from outputs/siibra_test/100_mask.png rename to outputs/siibra_test/100/100_mask.png diff --git a/outputs/siibra_test/figshare_100_result.json b/outputs/siibra_test/100/100_result.json similarity index 88% rename from outputs/siibra_test/figshare_100_result.json rename to outputs/siibra_test/100/100_result.json index 7997689..ff93ac8 100644 --- a/outputs/siibra_test/figshare_100_result.json +++ b/outputs/siibra_test/100/100_result.json @@ -4,6 +4,8 @@ "mask_synthetic": false, "nifti": null, "dicom": null, + "auto_register": false, + "registration_type": "Affine", "atlas_enrichment": { "mni_coords": [ -20.82, diff --git a/outputs/siibra_test/tumor_slice/tumor_mask.png b/outputs/siibra_test/tumor_slice/tumor_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..00dd518cc5af69363cb93fff0f657f21d268831f GIT binary patch literal 435 zcmeAS@N?(olHy`uVBq!ia0vp^A3&G`NH9p-ZS!MbV65_VaSW-L^Y+&5yk-LdR)@>~ z|EGUn7^H8qcEjEq*@mC5c^!Pd;lToxr64`HfViw6`^v)hU9m2ws$O4gW_9_sJO2XH z!h2!tGjdB9{`Y@R?v&aXFy)|(_%AnI(Uhcz9bb>}-aCByv-$4Y>3#9}KE(}Hb-s^7 z_wFiuts3jCxpm&ZjgyZF|66;n@YnBUqKmic${U_L+WOo+Q+FS`-1iL2{U09dy*zr0 zi`^%E`}c=$O~vHqoqxqx?psm$LQwYM>x+NmW<3Zgb$gLLXF>k^WmiRQo$t@wUDCBr znEUyQ+p!j&f6DF(zx%mDrTL$C)#YQ%-#^R?|N3~|(&NXr+*F^ow8ziFx!JA%_p`(= za&~2_ZT4m6Ub9G;zVD93(UqN?g-^3TPG~*W`n2M%rT1C=!p=C8bK&M@mvzopr01~Ca Az5oCK literal 0 HcmV?d00001 diff --git a/outputs/siibra_test/tumor_slice/tumor_slice.png b/outputs/siibra_test/tumor_slice/tumor_slice.png new file mode 100644 index 0000000000000000000000000000000000000000..e8d2ba0c9c60df643f8a68caed632f99e50aab1e GIT binary patch literal 10371 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 literal 0 HcmV?d00001 diff --git a/outputs/siibra_test/tumor_slice/tumor_slice_result.json b/outputs/siibra_test/tumor_slice/tumor_slice_result.json new file mode 100644 index 0000000..d90b2ee --- /dev/null +++ b/outputs/siibra_test/tumor_slice/tumor_slice_result.json @@ -0,0 +1,28 @@ +{ + "image": "outputs/siibra_test/tumor_slice/tumor_slice.png", + "mask": "outputs/siibra_test/tumor_slice/tumor_mask.png", + "mask_synthetic": false, + "nifti": "data/BraTS2021/BraTS2021_Training_Data/BraTS2021_00000/BraTS2021_00000_t1ce.nii.gz", + "dicom": null, + "auto_register": true, + "registration_type": "Affine", + "atlas_enrichment": { + "mni_coords": [ + 42.0, + -33.0, + 9.0 + ], + "assigned_region": "Area TE 2.2 (STG) right", + "hemisphere": "right", + "assignment_scores": [ + { + "region": "Area TE 2.2 (STG) right", + "score": 0.2295 + }, + { + "region": "Area TE 1.1 (HESCHL) right", + "score": 0.0022 + } + ] + } +} diff --git a/outputs/siibra_test/volume_100_slice_83_mask.png b/outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_mask.png similarity index 100% rename from outputs/siibra_test/volume_100_slice_83_mask.png rename to outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_mask.png diff --git a/outputs/siibra_test/volume_100_slice_83_t1ce.png b/outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_t1ce.png similarity index 100% rename from outputs/siibra_test/volume_100_slice_83_t1ce.png rename to outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_t1ce.png diff --git a/outputs/siibra_test/volume_100_slice_83_result.json b/outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_t1ce_result.json similarity index 63% rename from outputs/siibra_test/volume_100_slice_83_result.json rename to outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_t1ce_result.json index ba7cbe6..6557e2c 100644 --- a/outputs/siibra_test/volume_100_slice_83_result.json +++ b/outputs/siibra_test/volume_100_slice_83_t1ce/volume_100_slice_83_t1ce_result.json @@ -1,9 +1,11 @@ { - "image": "outputs/brats_test/volume_100_slice_83_t1ce.png", - "mask": "outputs/brats_test/volume_100_slice_83_mask.png", + "image": "outputs/siibra_test/volume_100_slice_83_t1ce.png", + "mask": "outputs/siibra_test/volume_100_slice_83_mask.png", "mask_synthetic": false, - "nifti": "outputs/brats_test/volume_100.nii.gz", + "nifti": "outputs/siibra_test/volume_100.nii.gz", "dicom": null, + "auto_register": true, + "registration_type": "Affine", "atlas_enrichment": { "mni_coords": [ -52.23, diff --git a/requirements.txt b/requirements.txt index 5b446e2..bd8a81b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -55,6 +55,10 @@ siibra>=1.0a4 nibabel>=5.0.0 pydicom>=2.4.0 +# MNI152 registration (ANTsPy + nilearn template) +antspyx>=0.5.0 +nilearn>=0.10.0 + # Code quality black>=24.0.0 isort>=5.13.0 diff --git a/tests/test_atlas_enrichment.py b/tests/test_atlas_enrichment.py index 7749655..29dc3d8 100644 --- a/tests/test_atlas_enrichment.py +++ b/tests/test_atlas_enrichment.py @@ -7,11 +7,17 @@ python tests/test_atlas_enrichment.py --image scan.png --dicom scan.dcm python tests/test_atlas_enrichment.py --image scan.png --nifti scan.nii.gz + # For data NOT already in MNI152 space (BraTS/SRI24, raw scanner DICOMs): + python tests/test_atlas_enrichment.py --image scan.png --nifti scan.nii.gz --register + python tests/test_atlas_enrichment.py --image scan.png --nifti scan.nii.gz --register --registration_type SyN + +Results are saved to outputs/siibra_test/ by default. + If --mask is omitted, a synthetic centre-blob mask is generated from the image dimensions (simulates a lesion in the left-centre of the brain). Requires: - pip install siibra nibabel pydicom + pip install siibra nibabel pydicom antspyx nilearn """ import argparse @@ -50,7 +56,15 @@ def make_synthetic_mask(image_path: str) -> str: return tmp.name -def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_path: str | None, output_path: str | None = None): +def run( + image_path: str, + mask_path: str | None, + nifti_path: str | None, + dicom_path: str | None, + output_path: str | None = None, + auto_register: bool = False, + registration_type: str = "Affine", +): from agents.sibra_tool import SiibraAtlasTool from pipeline.nodes import make_atlas_enrichment_node from pipeline.state import initial_state @@ -77,7 +91,11 @@ def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_pa # ── Run tool directly ───────────────────────────────────────────────────── print("\n─── SiibraAtlasTool.assign_lesion ───────────────────────────────────") - tool = SiibraAtlasTool(fetch_features=False) + tool = SiibraAtlasTool( + fetch_features=False, + auto_register=auto_register, + registration_type=registration_type, + ) mask_arr = np.array(Image.open(mask_path).convert("L")) > 127 atlas_result = tool.assign_lesion( @@ -110,11 +128,13 @@ def run(image_path: str, mask_path: str | None, nifti_path: str | None, dicom_pa if output_path: record = { - "image": image_path, - "mask": mask_path, - "mask_synthetic": synthetic_mask, - "nifti": nifti_path, - "dicom": dicom_path, + "image": image_path, + "mask": mask_path, + "mask_synthetic": synthetic_mask, + "nifti": nifti_path, + "dicom": dicom_path, + "auto_register": auto_register, + "registration_type": registration_type, "atlas_enrichment": enrichment, } Path(output_path).parent.mkdir(parents=True, exist_ok=True) @@ -132,18 +152,28 @@ def main(): parser.add_argument("--mask", default=None, help="Binary mask PNG (white = lesion). Auto-generated if omitted.") parser.add_argument("--nifti", default=None, help="NIfTI file for accurate MNI affine") parser.add_argument("--dicom", default=None, help="DICOM file for scanner-space coords") - parser.add_argument("--output", default=None, help="Save result JSON to this path (e.g. outputs/brats_test/result.json)") + parser.add_argument("--output", default=None, help="Save result JSON to this path (default: outputs/siibra_test/_result.json)") + parser.add_argument("--register", action="store_true", + help="Register NIfTI to MNI152 via ANTsPy before siibra lookup. " + "Required for non-MNI152 data (BraTS/SRI24, raw scanner space).") + parser.add_argument("--registration_type", default="Affine", + choices=["Affine", "SyN"], + help="ANTsPy registration type: Affine (~5-15s) or SyN (~90s, more accurate). Default: Affine") args = parser.parse_args() if not Path(args.image).exists(): print(f"ERROR: image not found: {args.image}") sys.exit(1) + stem = Path(args.image).stem + output_path = args.output or f"outputs/siibra_test/{stem}/{stem}_result.json" + try: - run(args.image, args.mask, args.nifti, args.dicom, args.output) + run(args.image, args.mask, args.nifti, args.dicom, output_path, + auto_register=args.register, registration_type=args.registration_type) except ModuleNotFoundError as e: print(f"\nERROR: missing dependency — {e}") - print("Run: pip install siibra nibabel pydicom") + print("Run: pip install siibra nibabel pydicom antspyx nilearn") sys.exit(1)