#!/usr/bin/env python # -*- coding: utf-8 -*- # File: annotations_to_csv.py # # Convert manually-placed anatomical landmarks (the GOLD STANDARD) into the # --landmarks-csv format that prepare_data.py consumes: # # volume_name,index,x,y,z # # Supported inputs: # * 3D Slicer markups .mrk.json (Slicer 5+) --format slicer-json # * 3D Slicer fiducials .fcsv --format fcsv # * ITK-SNAP label / simple "x y z name" text --format xyz # # IMPORTANT: Slicer/ITK-SNAP store points in PHYSICAL (LPS/RAS) millimetre # coordinates. The RL pipeline works in VOXEL indices. Pass the matching # converted NIfTI with --nii so points are mapped physical -> voxel correctly # (requires SimpleITK; without it, points are assumed already in voxel indices). # # Usage: # python annotations_to_csv.py --format slicer-json \ # --in caseA.mrk.json --volume-name 3_MRI00019_20241125_15_s2458 \ # --nii data/images/3_MRI00019_20241125_15_s2458.nii.gz --out points.csv import argparse import json import os try: import SimpleITK as sitk _HAVE_SITK = True except Exception: _HAVE_SITK = False def _phys_to_voxel(points_mm, nii): """Map physical LPS mm -> voxel index using the NIfTI geometry.""" if not (nii and _HAVE_SITK and os.path.exists(nii)): return [tuple(round(c) for c in p) for p in points_mm] img = sitk.ReadImage(nii) out = [] for p in points_mm: vox = img.TransformPhysicalPointToIndex([float(p[0]), float(p[1]), float(p[2])]) out.append(tuple(int(v) for v in vox)) return out def read_slicer_json(path): data = json.load(open(path)) pts = [] for mk in data.get("markups", []): for cp in mk.get("controlPoints", []): pts.append(cp["position"]) # [x,y,z] in the file's coordinate system return pts def read_fcsv(path): pts = [] for line in open(path): if line.startswith("#") or not line.strip(): continue f = line.split(",") if len(f) >= 4: pts.append([float(f[1]), float(f[2]), float(f[3])]) return pts def read_xyz(path): pts = [] for line in open(path): f = line.replace(",", " ").split() if len(f) >= 3: try: pts.append([float(f[0]), float(f[1]), float(f[2])]) except ValueError: continue return pts def main(): ap = argparse.ArgumentParser() ap.add_argument("--format", required=True, choices=["slicer-json", "fcsv", "xyz"]) ap.add_argument("--in", dest="inp", required=True) ap.add_argument("--volume-name", required=True, help="must match the .nii.gz basename produced by prepare_data.py") ap.add_argument("--nii", default=None, help="matching converted volume (for mm->voxel)") ap.add_argument("--out", default="points.csv") ap.add_argument("--append", action="store_true") a = ap.parse_args() reader = {"slicer-json": read_slicer_json, "fcsv": read_fcsv, "xyz": read_xyz}[a.format] pts_mm = reader(a.inp) pts = _phys_to_voxel(pts_mm, a.nii) mode = "a" if a.append else "w" write_header = not (a.append and os.path.exists(a.out)) with open(a.out, mode) as f: if write_header: f.write("volume_name,index,x,y,z\n") for i, (x, y, z) in enumerate(pts): f.write(f"{a.volume_name},{i},{int(x)},{int(y)},{int(z)}\n") print(f"wrote {len(pts)} points for {a.volume_name} -> {a.out}" + ("" if (a.nii and _HAVE_SITK) else " [assumed voxel coords; pass --nii + SimpleITK for mm->voxel]")) if __name__ == "__main__": main()