#!/usr/bin/env python # -*- coding: utf-8 -*- # File: auto_landmarks.py # # Generate ANATOMICALLY-DEFINED landmarks automatically from a brain volume. # # HONEST SCOPE --------------------------------------------------------------- # These are REPRODUCIBLE, ANATOMICALLY-NAMED GEOMETRIC FIDUCIALS derived from the # brain mask + the scan's true patient orientation (from DICOM). They land in # sensible anatomical places (cranial vertex, frontal/occipital poles, temporal # extremes, midsagittal centre, an AC-PC-ish midline set). They are suitable for: # * driving the physics / geodesic navigation, # * bootstrapping / pre-training, # * sanity-checking the pipeline. # They are NOT a substitute for expert AC/PC-type annotation or atlas-propagated # labels. For a clinical detector use one of the two routes in AUTO_LANDMARKS.md # (atlas registration, or manual annotation -> annotations_to_csv.py). # ---------------------------------------------------------------------------- # # The key idea: we don't take raw voxel-axis extremes (wrong when the scan is # tilted). We project each brain voxel onto the TRUE patient L/P/S directions # (from ImageOrientationPatient) and take extremes in anatomical space. import numpy as np from scipy import ndimage as ndi def brain_mask(vol, pct=55): """Largest connected, hole-filled bright component (a crude brain/head mask).""" thr = np.percentile(vol, pct) m = vol > thr m = ndi.binary_opening(m, iterations=1) lab, n = ndi.label(m) if n == 0: return np.ones_like(vol, dtype=bool) sizes = ndi.sum(np.ones_like(lab), lab, index=range(1, n + 1)) m = lab == (1 + int(np.argmax(sizes))) m = ndi.binary_fill_holes(m) return m def patient_axes(cosines): """cosines = (row_dir, col_dir) each length-3 (DICOM ImageOrientationPatient). Returns L, P, S unit direction vectors in *voxel-axis* space (index 0,1,2 = x,y,z). In LPS patient space, +x=Left, +y=Posterior, +z=Superior. The patient coordinate of voxel (x,y,z) is ~ x*sx*row + y*sy*col + z*sz*normal. So the 'how-Left/Posterior/Superior' score of a voxel is the projection of its (scaled) index onto the row/col/normal component for that patient axis. """ row = np.asarray(cosines[0], float) col = np.asarray(cosines[1], float) nrm = np.cross(row, col) M = np.stack([row, col, nrm], axis=1) # columns: contribution of each voxel axis # M[patient_axis, voxel_axis]; row 0->Left(+x), 1->Posterior(+y), 2->Superior(+z) L = M[0]; P = M[1]; S = M[2] return L, P, S def anatomical_fiducials(vol, cosines=None, spacing=(1, 1, 1), n=15): """Return (names, pts[n,3] voxel indices) of anatomical fiducials.""" vol = np.asarray(vol, float) if vol.ndim == 4: vol = vol[..., 0] m = brain_mask(vol) xs, ys, zs = np.where(m) idx = np.stack([xs, ys, zs], 1).astype(float) # (N,3) voxel indices scaled = idx * np.asarray(spacing, float) # physical-ish centroid = idx.mean(0) if cosines is None: # assume axial identity L = np.array([1., 0, 0]); P = np.array([0, 1., 0]); S = np.array([0, 0, 1.]) else: L, P, S = patient_axes(cosines) def extreme(direction, most=True): proj = scaled @ np.asarray(direction, float) j = int(np.argmax(proj) if most else np.argmin(proj)) return idx[j] fid = { "centroid": centroid, "cranial_vertex": extreme(S, True), # most superior "inferior": extreme(S, False), # most inferior "frontal_pole": extreme(P, False), # most anterior (min Posterior) "occipital_pole": extreme(P, True), # most posterior "left_extreme": extreme(L, True), # most left "right_extreme": extreme(L, False), # most right } # midsagittal centre: centroid pulled onto the L-R midplane (mean L-projection) Lp = scaled @ L mid_val = (Lp.min() + Lp.max()) / 2.0 # closest brain voxel to (mid L-plane, centroid P & S) Pp = scaled @ P; Sp = scaled @ S cP = (idx @ np.eye(3)) # placeholder cscaled = centroid * np.asarray(spacing, float) dist = (Lp - mid_val) ** 2 + (Pp - cscaled @ P) ** 2 + (Sp - cscaled @ S) ** 2 fid["midsagittal_center"] = idx[int(np.argmin(dist))] # AC-PC-ish midline series: along the P axis on the midsagittal plane at # centroid S-level, sample a few interior points (anterior->posterior). near_mid = np.abs(Lp - mid_val) < (0.06 * (Lp.max() - Lp.min()) + 1e-6) near_S = np.abs(Sp - cscaled @ S) < (0.10 * (Sp.max() - Sp.min()) + 1e-6) band = near_mid & near_S names = list(fid.keys()) pts = list(fid.values()) if band.sum() >= 4: bidx = idx[band]; bP = (bidx * np.asarray(spacing, float)) @ P order = np.argsort(bP) picks = np.linspace(0, len(order) - 1, 6).astype(int) for r, pk in enumerate(picks): names.append(f"midline_ap_{r}") pts.append(bidx[order[pk]]) # pad/truncate to exactly n, filling extra slots with interpolations toward centroid while len(pts) < n: a = pts[len(pts) % 7] names.append(f"aux_{len(pts)}") pts.append((np.asarray(a) + centroid) / 2.0) names = names[:n]; pts = np.array(pts[:n]) pts = np.clip(np.round(pts), 0, np.asarray(vol.shape) - 1).astype(int) return names, pts def write_landmark_file(path, pts): with open(path, "w") as f: f.write("\n".join(f"{int(x)},{int(y)},{int(z)}" for x, y, z in pts) + "\n") if __name__ == "__main__": import argparse, os, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from read_nifti import read_nii ap = argparse.ArgumentParser(description="Auto anatomical fiducials for a NIfTI volume.") ap.add_argument("nii") ap.add_argument("--out", default=None) ap.add_argument("--n", type=int, default=15) a = ap.parse_args() v, sp = read_nii(a.nii) names, pts = anatomical_fiducials(v, cosines=None, spacing=sp, n=a.n) out = a.out or (os.path.splitext(a.nii)[0] + "_landmarks.txt") write_landmark_file(out, pts) for nm, p in zip(names, pts): print(f" {nm:20s} {tuple(int(x) for x in p)}") print("wrote", out)