Spaces:
Sleeping
Sleeping
File size: 6,325 Bytes
377b913 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | #!/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)
|