Communicative_CRN / src /auto_landmarks.py
Sanni Henry
Initial deploy: Gradio landmark detection demo
377b913
Raw
History Blame Contribute Delete
6.33 kB
#!/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)