iman1 / processing.py
AI-RESEARCHER-2024's picture
Upload 317 files
94fee7e verified
Raw
History Blame Contribute Delete
56.1 kB
"""
Core image-processing pipeline for cochlear neurofilament tracing.
Handles:
* Loading Zeiss CZI z-stacks and generic TIFF stacks (with voxel sizes).
* Channel identification (Neurofilament vs Myo7a).
* 3D tracing of the neurofilament network into a single continuous skeleton.
* Myo7a-guided splitting of the field into an IHC region and an OHC region.
* Per-region quantification: number of fibers, diameter, length,
branch points and area covered within the field of view.
The module has no Gradio dependency so it can be unit-tested on its own.
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Optional
import numpy as np
from scipy import ndimage as ndi
from skimage.filters import gaussian, threshold_otsu, sato
from skimage.morphology import (remove_small_objects, remove_small_holes,
skeletonize)
from skan import Skeleton, summarize
def _cellpose_available() -> bool:
try:
import cellpose # noqa: F401
return True
except Exception:
return False
CELLPOSE_AVAILABLE = _cellpose_available()
_CP_MODEL = None # lazily-created, cached Cellpose model
# --------------------------------------------------------------------------- #
# Data containers
# --------------------------------------------------------------------------- #
FREQ_CHOICES = ["8kHz", "16kHz", "22kHz", "32kHz", "64kHz", "Other / unknown"]
@dataclass
class LoadedImage:
"""A loaded multi-channel z-stack."""
data: np.ndarray # (C, Z, Y, X) float32
channels: list # list of dicts: {name, dye, color}
voxel: tuple # (dz, dy, dx) in microns
source_name: str = ""
@property
def n_channels(self) -> int:
return self.data.shape[0]
@dataclass
class RegionMetrics:
"""Quantification for one region (whole field, IHC region or OHC region)."""
region: str = ""
n_fibers: int = 0
total_length_um: float = 0.0
mean_diameter_um: float = 0.0
median_diameter_um: float = 0.0
n_branch_points: int = 0
area_covered_um2: float = 0.0
fov_area_um2: float = 0.0
pct_area_covered: float = 0.0
n_hair_cells: int = -1 # -1 = not measured (no Myo7a detection run)
fibers_per_hc: float = -1.0 # -1 = not applicable (hair-cell count unknown)
length_per_hc_um: float = -1.0
n_radial: int = -1 # fibers running IHC->OHC (radial); -1 = n/a
n_offaxis: int = -1 # fibers running along the rows (misdirected)
pct_radial: float = -1.0 # % of fibers that are radially directed
def as_row(self) -> dict:
def opt(value, ndigits=2):
"""Blank when the metric was not applicable (negative sentinel)."""
return round(value, ndigits) if value is not None and value >= 0 else ""
row = {
"Region": self.region,
"Number of fibers": self.n_fibers,
"Hair cells (Myo7a)": (self.n_hair_cells
if self.n_hair_cells >= 0 else ""),
"Fibers / hair cell": opt(self.fibers_per_hc, 3),
"Total length (um)": round(self.total_length_um, 2),
"Length / hair cell (um)": opt(self.length_per_hc_um, 2),
"Mean diameter (um)": round(self.mean_diameter_um, 3),
"Median diameter (um)": round(self.median_diameter_um, 3),
"Branch points": self.n_branch_points,
"Radial fibers (IHC->OHC)": (self.n_radial
if self.n_radial >= 0 else ""),
"Off-axis fibers": (self.n_offaxis if self.n_offaxis >= 0 else ""),
"Radial fibers (%)": opt(self.pct_radial, 1),
"Area covered (um^2)": round(self.area_covered_um2, 2),
"FOV area (um^2)": round(self.fov_area_um2, 2),
"Area covered (% of FOV)": round(self.pct_area_covered, 2),
}
return row
@dataclass
class TraceResult:
"""Everything produced by tracing one image."""
mask: np.ndarray # 3D bool
skeleton: np.ndarray # 3D bool
distance_um: np.ndarray # 3D float, EDT in microns
voxel: tuple
metrics: dict = field(default_factory=dict) # region name -> RegionMetrics
# --------------------------------------------------------------------------- #
# Loading
# --------------------------------------------------------------------------- #
def detect_frequency(filename: str) -> str:
"""Guess the frequency-region label from a filename (e.g. '16kHz')."""
m = re.search(r"(\d+)\s*k\s*hz", filename, re.IGNORECASE)
if m:
label = f"{int(m.group(1))}kHz"
if label in FREQ_CHOICES:
return label
return "Other / unknown"
def _czi_channel_meta(czi) -> list:
"""Extract per-channel name/dye/color from CZI metadata (best effort)."""
meta = czi.meta
seen = {}
order = []
for ch in meta.iter("Channel"):
name = ch.get("Name")
if not name:
continue
dye = ch.findtext("DyeName") or ch.findtext("Fluor")
color = ch.findtext("Color")
ex = ch.findtext("ExcitationWavelength")
if name not in seen:
seen[name] = {"name": name, "dye": None, "color": None, "ex": None}
order.append(name)
rec = seen[name]
rec["dye"] = rec["dye"] or dye
rec["color"] = rec["color"] or color
rec["ex"] = rec["ex"] or ex
return [seen[n] for n in order]
def _czi_voxel(czi) -> tuple:
"""Return (dz, dy, dx) in microns from CZI scaling metadata."""
scale = {}
for d in czi.meta.iter("Distance"):
idv = d.get("Id")
val = d.findtext("Value")
if idv in ("X", "Y", "Z") and val:
scale[idv] = float(val) * 1e6 # metres -> microns
dx = scale.get("X", 0.0895)
dy = scale.get("Y", dx)
dz = scale.get("Z", 0.35)
return (dz, dy, dx)
def load_czi(path: str) -> LoadedImage:
from aicspylibczi import CziFile
czi = CziFile(path)
img, shp = czi.read_image()
dims = [d for d, _ in shp]
arr = np.asarray(img)
# collapse everything except C, Z, Y, X
# find axis indices
idx = {d: i for i, d in enumerate(dims)}
# move to C,Z,Y,X ordering, squeezing singletons (B,V,T,...)
keep = ["C", "Z", "Y", "X"]
order = [idx[k] for k in keep if k in idx]
other = [i for i in range(arr.ndim) if i not in order]
arr = np.transpose(arr, other + order)
arr = arr.reshape((-1,) + arr.shape[len(other):]) if other else arr
# after reshape leading axis is product of others -> take first
if other:
arr = arr[0]
# arr now (C,Z,Y,X) or missing Z
if "Z" not in idx:
arr = arr[:, None]
arr = arr.astype(np.float32)
channels = _czi_channel_meta(czi)
if len(channels) != arr.shape[0]:
channels = [{"name": f"Channel {i}", "dye": None, "color": None}
for i in range(arr.shape[0])]
return LoadedImage(arr, channels, _czi_voxel(czi), os.path.basename(path))
def _reorder_tiff_by_axes(arr: np.ndarray, axes: str):
"""Reorder a TIFF array to (C, Z, Y, X) using tifffile's axes string.
Treats colour samples ('S', e.g. RGB) and channels ('C') as channels, keeps
'Z', and squeezes any other axes (T, etc.). Returns None if it cannot map.
"""
axes = axes.upper()
if "Y" not in axes or "X" not in axes or len(axes) != arr.ndim:
return None
# Rank: channel-like first, then Z, then Y, X; unknown axes go last.
prio = {"C": 0, "S": 0, "Z": 1, "Y": 2, "X": 3}
order = sorted(range(len(axes)), key=lambda i: (prio.get(axes[i], 4), i))
arr = np.transpose(arr, order)
new_axes = "".join(axes[i] for i in order)
# Collapse all leading channel-like axes (C and/or S) into one channel axis.
n_chan = sum(1 for c in new_axes if c in ("C", "S"))
if n_chan >= 1:
arr = arr.reshape((int(np.prod(arr.shape[:n_chan])),) + arr.shape[n_chan:])
new_axes = "C" + new_axes[n_chan:]
else:
arr = arr[None]
new_axes = "C" + new_axes
if "Z" not in new_axes: # single-plane image -> add Z=1
arr = arr[:, None]
new_axes = new_axes[0] + "Z" + new_axes[1:]
while arr.ndim > 4: # drop any leftover trailing axes
arr = arr[..., 0]
return arr
def load_tiff(path: str, dz=0.35, dy=0.0895, dx=0.0895) -> LoadedImage:
import tifffile
with tifffile.TiffFile(path) as tf:
arr = tf.asarray()
try:
axes = tf.series[0].axes
except Exception:
axes = None
arr = np.asarray(arr)
reordered = _reorder_tiff_by_axes(arr, axes) if axes else None
if reordered is not None:
arr = reordered
else:
# Fallback heuristic when axis metadata is missing/unusable.
arr = np.squeeze(arr)
if arr.ndim == 2: # (Y, X)
arr = arr[None, None]
elif arr.ndim == 3:
# trailing RGB/RGBA samples -> channels; else small first axis = C
if arr.shape[-1] in (3, 4) and arr.shape[-1] < min(arr.shape[:2]):
arr = np.moveaxis(arr, -1, 0)[:, None] # (C, 1, Y, X)
elif arr.shape[0] <= 5:
arr = arr[:, None] # (C, 1, Y, X)
else:
arr = arr[None] # (1, Z, Y, X)
elif arr.ndim == 4:
if arr.shape[-1] in (3, 4) and arr.shape[-1] < min(arr.shape[1:3]):
arr = np.moveaxis(arr, -1, 0) # (C, Z, Y, X)
else:
yx = sorted(range(4), key=lambda a: arr.shape[a])[-2:]
rest = [a for a in range(4) if a not in yx]
c_axis = min(rest, key=lambda a: arr.shape[a])
z_axis = [a for a in rest if a != c_axis][0]
arr = np.transpose(arr, (c_axis, z_axis, *sorted(yx)))
else:
raise ValueError(f"Unsupported TIFF with {arr.ndim} dimensions")
arr = np.ascontiguousarray(arr).astype(np.float32)
channels = [{"name": f"Channel {i}", "dye": None, "color": None}
for i in range(arr.shape[0])]
return LoadedImage(arr, channels, (dz, dy, dx), os.path.basename(path))
def load_image(path: str, dz=0.35, dy=0.0895, dx=0.0895) -> LoadedImage:
ext = os.path.splitext(path)[1].lower()
if ext == ".czi":
return load_czi(path)
if ext in (".tif", ".tiff"):
return load_tiff(path, dz, dy, dx)
raise ValueError(f"Unsupported file type: {ext}")
# --------------------------------------------------------------------------- #
# IMARIS ground-truth ingestion
# --------------------------------------------------------------------------- #
def read_imaris_filament_length(source: str) -> Optional[float]:
"""Return IMARIS 'Filament Length (sum)' in microns for an image, or None.
IMARIS exports this exact statistic to
``<name>_Statistics/<name>_Filament_Length_(sum).csv`` beside the image.
``source`` may be that CSV, a ``_Statistics`` folder, or the image path
(``.czi``/``.tif``) whose sibling ``_Statistics`` folder is searched. When a
filament CSV lists several filament objects the per-object sums are added.
This is the ONLY way to reproduce IMARIS's exact number — it reads IMARIS's
own output rather than re-estimating it with a different algorithm.
"""
import glob
import re
csvs = []
if source and os.path.isfile(source) and source.lower().endswith(".csv"):
csvs = [source]
else:
cand = None
if source and source.lower().endswith((".czi", ".tif", ".tiff")):
stem = os.path.splitext(os.path.basename(source))[0]
cand = os.path.join(os.path.dirname(source), f"{stem}_Statistics")
elif source and os.path.isdir(source):
cand = source
if cand and os.path.isdir(cand):
csvs = sorted(glob.glob(
os.path.join(cand, "*_Filament_Length_(sum).csv")))
if not csvs:
return None
total, found = 0.0, False
for csv in csvs[:1]: # one filament CSV per image
try:
with open(csv, errors="ignore") as fh:
for line in fh:
m = re.match(r"\s*([-+]?[0-9]*\.?[0-9]+)\s*,\s*µm", line)
if m:
total += float(m.group(1))
found = True
except OSError:
return None
return round(total, 3) if found else None
# --------------------------------------------------------------------------- #
# Channel identification
# --------------------------------------------------------------------------- #
# Common fluorophores in these cochlear stacks. Each entry maps a token that
# may appear in the CZI dye/fluor name to a friendly name and a colour family.
# Emission colour drives the family; the family drives auto channel assignment.
# blue -> reference marker (Myo7a is Alexa-405 here)
# orange -> neurofilament (Alexa-555 here) — preferred trace channel
# green / red -> additional markers, selectable in the UI.
KNOWN_DYES = [
("atto 390", "ATTO 390", "blue"),
("405", "Alexa Fluor 405", "blue"),
("dapi", "DAPI", "blue"),
("488", "Alexa Fluor 488", "green"),
("atto 488", "ATTO 488", "green"),
("fitc", "FITC", "green"),
("gfp", "GFP", "green"),
("514", "Alexa Fluor 514", "green"),
("532", "Alexa Fluor 532", "orange"),
("546", "Alexa Fluor 546", "orange"),
("555", "Alexa Fluor 555", "orange"),
("568", "Alexa Fluor 568", "orange"),
("cy3", "Cy3", "orange"),
("594", "Alexa Fluor 594", "red"),
("633", "Alexa Fluor 633", "red"),
("647", "Alexa Fluor 647", "red"),
("atto 647", "ATTO 647", "red"),
("cy5", "Cy5", "red"),
("680", "Alexa Fluor 680", "red"),
]
def _channel_color_family(ch: dict) -> str:
"""Classify a channel as blue / green / orange / red / none (transmitted).
Uses the dye/fluor name first, then the excitation wavelength, then the
metadata colour swatch. This lets the app recognise many more dyes
(405/488/514/532/555/568/594/633/647/ATTO/Cy…) than just 405 vs "any dye".
"""
dye = (ch.get("dye") or "").lower()
color = (ch.get("color") or "").upper().lstrip("#")
ex = ch.get("ex")
try:
ex = float(ex) if ex else None
except (TypeError, ValueError):
ex = None
for token, _name, family in KNOWN_DYES:
if token in dye:
return family
if ex is not None:
if ex < 430:
return "blue"
if ex < 505:
return "green"
if ex < 565:
return "orange"
return "red"
# metadata colour swatch (Zeiss stores ARGB or RGB hex)
if color:
hexrgb = color[-6:] if len(color) >= 6 else color
try:
r = int(hexrgb[0:2], 16); g = int(hexrgb[2:4], 16); b = int(hexrgb[4:6], 16)
if b > r and b > g:
return "blue"
if g > r and g > b:
return "green"
if r > g and r > b and g > b // 2:
return "orange"
if r > g and r > b:
return "red"
except (ValueError, IndexError):
pass
if dye and dye not in ("", "none"):
return "other"
return "none" # e.g. transmitted-light PMT
def channel_dye_label(ch: dict) -> str:
"""Friendly dye label for the channel dropdown (recognises known dyes)."""
dye = (ch.get("dye") or "").strip()
if dye:
low = dye.lower()
for token, name, _family in KNOWN_DYES:
if token in low:
return name
return dye
fam = _channel_color_family(ch)
return "no dye / transmitted" if fam == "none" else f"{fam} marker"
def guess_channels(img: LoadedImage) -> tuple:
"""Best-effort (neurofilament_index, myo7a_index) from metadata + content."""
nf_idx, myo_idx = None, None
families = [_channel_color_family(ch) for ch in img.channels]
blue_like = [i for i, f in enumerate(families) if f == "blue"]
orange_like = [i for i, f in enumerate(families) if f == "orange"]
red_like = [i for i, f in enumerate(families) if f == "red"]
green_like = [i for i, f in enumerate(families) if f == "green"]
# Myo7a reference = the bluest (405-like) fluorescent channel.
if blue_like:
myo_idx = blue_like[0]
# Neurofilament = Alexa-555 (orange) here; then fall back to red/green.
for group in (orange_like, red_like, green_like):
if group:
nf_idx = next((i for i in group if i != myo_idx), group[0])
break
# Fall back to image content when metadata is missing (e.g. plain TIFF).
# Fluorescence channels have a mostly-dark background; transmitted-light
# (brightfield) channels fill the frame, so we skip those. We cannot
# reliably tell fibers from blobs automatically, so we default NF to the
# first fluorescent channel and Myo7a to the last — the user confirms
# via the channel previews in the UI.
if nf_idx is None or myo_idx is None:
fluo = [i for i in range(img.n_channels)
if _dark_fraction(img.data[i]) >= 0.2]
if not fluo:
fluo = list(range(img.n_channels))
if nf_idx is None:
nf_idx = fluo[0]
if myo_idx is None or myo_idx == nf_idx:
myo_idx = fluo[-1] if fluo[-1] != nf_idx else fluo[0]
return nf_idx, myo_idx
def _dark_fraction(vol: np.ndarray) -> float:
"""Fraction of the (normalised) MIP that is near-black background."""
mip = vol.max(0).astype(np.float32)
mip = (mip - mip.min()) / (np.ptp(mip) + 1e-6)
return float((mip < 0.15).mean())
# --------------------------------------------------------------------------- #
# Neurofilament tracing
# --------------------------------------------------------------------------- #
def _threshold_volume(vol: np.ndarray, sensitivity: float) -> np.ndarray:
"""Smooth + Otsu threshold. `sensitivity` (0.5-1.5) scales the threshold;
higher sensitivity -> lower threshold -> more fibers captured."""
lo, hi = np.percentile(vol, 1), np.percentile(vol, 99.8)
norm = np.clip((vol - lo) / (hi - lo + 1e-6), 0, 1)
sm = gaussian(norm, sigma=(0.6, 1.0, 1.0), preserve_range=True)
fg = sm[sm > sm.mean() * 0.3]
base = threshold_otsu(fg) if fg.size else sm.mean()
thr = base * (2.0 - sensitivity) # sensitivity 1.0 -> base
return sm > thr
def _remove_small_components(skel: np.ndarray, voxel: tuple,
min_len_um: float) -> np.ndarray:
"""Drop connected skeleton components shorter than ``min_len_um``."""
if min_len_um <= 0 or skel.sum() == 0:
return skel
dz, dy, dx = voxel
step = float(np.mean([dy, dx])) # ~ length of one skeleton step
min_vox = max(2, int(round(min_len_um / step)))
struct = np.ones((3,) * skel.ndim, int)
lbl, n = ndi.label(skel, structure=struct)
if n == 0:
return skel
counts = np.bincount(lbl.ravel())
keep = np.zeros(counts.size, bool)
keep[1:] = counts[1:] >= min_vox
return keep[lbl]
def prune_skeleton(skel: np.ndarray, voxel: tuple,
spur_um: float = 3.0, min_component_um: Optional[float] = None,
iterations: int = 4) -> np.ndarray:
"""Remove short terminal spurs and small isolated fragments.
Terminal twigs (junction→endpoint branches) shorter than ``spur_um`` are
trimmed while their junction pixel is preserved, so the backbone stays
connected. Isolated components shorter than ``min_component_um`` are dropped
entirely. Iterated a few times because trimming one spur can expose another.
"""
if spur_um <= 0 and (min_component_um or 0) <= 0:
return skel
if min_component_um is None:
min_component_um = spur_um
cur = skel.copy()
for _ in range(iterations):
if cur.sum() == 0 or spur_um <= 0:
break
try:
S = Skeleton(cur, spacing=voxel)
df = summarize(S, separator="_")
except Exception:
break
k = np.ones((3,) * cur.ndim, int)
nb = ndi.convolve(cur.astype(np.uint8), k, mode="constant") - cur
junction = cur & (nb > 2) # protect branch points
drop = df.index[
((df["branch_type"] == 1) & (df["branch_distance"] < spur_um)) |
(df["branch_type"].isin([0, 3]) &
(df["branch_distance"] < min_component_um))]
if len(drop) == 0:
break
removal = np.zeros_like(cur)
for i in drop:
coords = S.path_coordinates(i).astype(int)
removal[tuple(coords.T)] = True
removal &= ~junction
new = cur & ~removal
if int(new.sum()) == int(cur.sum()):
break
cur = new
return _remove_small_components(cur, voxel, min_component_um)
def trace_neurites(nf_vol: np.ndarray, voxel: tuple,
sensitivity: float = 1.0,
min_object_vox: int = 64,
prune_um: float = 3.0) -> TraceResult:
"""Segment and skeletonise the neurofilament network in 3D.
``prune_um`` removes terminal spurs and isolated fragments shorter than this
many microns from the skeleton, giving cleaner fiber lines (0 = no pruning).
"""
dz, dy, dx = voxel
mask = _threshold_volume(nf_vol, sensitivity)
mask = remove_small_objects(mask, min_object_vox)
# Close within each z-plane. A full 3D element would erode a single-plane
# (Z=1) stack to nothing, since its z-neighbours fall outside the array.
mask = ndi.binary_closing(mask, structure=np.ones((1, 3, 3), bool))
if mask.sum() == 0:
z = np.zeros_like(mask)
return TraceResult(mask, z, np.zeros(mask.shape, np.float32), voxel)
skel = skeletonize(mask)
if prune_um and prune_um > 0:
skel = prune_skeleton(skel, voxel, spur_um=float(prune_um))
dist = ndi.distance_transform_edt(mask, sampling=(dz, dy, dx)).astype(np.float32)
return TraceResult(mask, skel, dist, voxel)
def trace_from_probability(prob: np.ndarray, voxel: tuple,
threshold: float = 0.5,
min_object_px: int = 32,
prune_um: float = 3.0) -> TraceResult:
"""Trace a 2D fiber-probability map (e.g. a MedCLIP map) into a skeleton.
``prob`` is a 2D float map in [0, 1] at the MIP resolution; ``threshold``
binarises it into a fiber mask which is cleaned, skeletonised and pruned.
The result is wrapped as a 3D (Z=1) ``TraceResult`` so the standard
``compute_metrics`` / ``skeleton_image`` helpers apply unchanged and the
total length is measured with the same (dy, dx) spacing-aware code path.
"""
dz, dy, dx = voxel
prob = np.asarray(prob, dtype=np.float32)
mask = prob >= float(threshold)
if mask.any():
mask = remove_small_objects(mask, int(min_object_px))
mask = ndi.binary_closing(mask, structure=np.ones((3, 3), bool))
if not mask.any():
z = np.zeros((1,) + prob.shape, bool)
return TraceResult(z, z.copy(), np.zeros(z.shape, np.float32), voxel)
# Wrap the 2D skeleton as a single-plane (Z=1) volume so the 3D-voxel
# pruning / metrics helpers apply unchanged (dz never affects in-plane steps).
skel = skeletonize(mask)[None]
if prune_um and prune_um > 0:
skel = prune_skeleton(skel, voxel, spur_um=float(prune_um))
dist = ndi.distance_transform_edt(mask, sampling=(dy, dx)).astype(np.float32)
return TraceResult(mask[None], skel, dist[None], voxel)
def _safe_otsu(values: np.ndarray, fallback: float) -> float:
"""threshold_otsu that never raises on constant / tiny inputs."""
v = np.asarray(values).ravel()
v = v[np.isfinite(v)]
if v.size < 8 or np.unique(v).size < 2:
return float(fallback)
try:
return float(threshold_otsu(v))
except Exception:
return float(fallback)
def trace_fibers_medclip_gated(nf_mip: np.ndarray, prob: np.ndarray, voxel: tuple,
gate_threshold: float = 0.4,
fiber_radii_um: tuple = (0.2, 0.35, 0.6),
gate_dilate_um: float = 4.0,
max_fiber_half_width_um: float = 1.5,
bright_floor: float = 0.5,
min_object_px: int = 32,
prune_um: float = 3.0) -> TraceResult:
"""MedCLIP-gated tubular fiber tracer.
MedCLIP ``prob`` is used ONLY as a semantic region prior (WHERE fibers are).
The thin fiber GEOMETRY comes from a multiscale Sato tubeness response on the
RAW neurofilament MIP, so ``skeletonize`` acts on thin ridges (clean lines),
never on the coarse probability slab (which would yield a medial-axis
honeycomb). Seeds are placed where strong tubeness meets the MedCLIP region;
those seeds are then grown along the raw ridge over the WHOLE image, so the
long radial (vertical) spiral-ganglion fibers descending below the band -
where ``prob`` is ~0 - are recovered. A final EDT half-width gate deletes any
residual wide medial-axis ridge. Returns a Z=1 ``TraceResult`` so
``compute_metrics`` / ``skeleton_image`` / the length path are unchanged.
Parameters
----------
nf_mip : 2D uint8/float RAW neurofilament MIP (``channel_preview`` output).
prob : 2D MedCLIP fiber-probability in [0, 1] at MIP resolution.
voxel : (dz, dy, dx) in microns.
gate_threshold : MedCLIP prob level defining the semantic region (UI slider).
"""
dz, dy, dx = voxel
px = float(np.mean([dy, dx])) # microns / pixel
raw = np.asarray(nf_mip, np.float32)
prob = np.asarray(prob, np.float32)
if prob.shape != raw.shape: # robust to any mismatch
from skimage.transform import resize
prob = resize(prob, raw.shape, order=1, preserve_range=True).astype(np.float32)
def _empty():
z = np.zeros((1,) + raw.shape, bool)
return TraceResult(z, z.copy(), np.zeros(z.shape, np.float32), voxel)
# 1. Raw intensity, robustly normalised (reuse existing helper).
I = _norm(raw)
# 2. Multiscale tubeness on the RAW signal -> thin, orientation-invariant
# ridge response (present for vertical radials as much as band fibers).
sigmas = sorted({max(1.0, round(float(r) / max(dx, 1e-6), 2))
for r in fiber_radii_um})
T = sato(I, sigmas=sigmas, black_ridges=False).astype(np.float32)
T = np.nan_to_num(T, nan=0.0, posinf=0.0, neginf=0.0)
thi = np.percentile(T, 99.5)
T = np.clip(T / (thi + 1e-6), 0.0, 1.0)
# 3. MedCLIP semantic region (WHERE) -> generous, dilated gate.
region = prob >= float(gate_threshold)
if not region.any():
region = prob >= np.percentile(prob, 90.0) # never empty
if region.any():
region = remove_small_objects(region, int(min_object_px))
region = ndi.binary_closing(region, structure=np.ones((3, 3), bool))
if not region.any():
return _empty()
iters = max(1, int(round(float(gate_dilate_um) / px)))
gate = ndi.binary_dilation(region, iterations=iters)
# 4. Weak brightness floor (rejects pure-noise ridges; keeps dim radials).
ipos = I[I > 0]
bright = I >= float(bright_floor) * _safe_otsu(ipos, 0.15)
# 5. Seed = strong tubeness INSIDE the semantic gate.
thr_hi = _safe_otsu(T[gate & (T > 0)], 0.3)
thr_hi = float(np.clip(thr_hi, 0.02, 0.99))
thr_lo = 0.5 * thr_hi
core = (T >= thr_hi) & gate & bright
# 6. Growth domain = thin raw ridges over the WHOLE image (radial recovery).
permissive = (T >= thr_lo) & bright
if core.any():
# 8-connected reconstruction: follow each in-band seed down its fiber,
# out of the band, recovering the long vertical radials.
fiber = ndi.binary_propagation(core, mask=permissive,
structure=np.ones((3, 3), bool))
else:
# Empty-seed fallback: thin tubeness inside the gate. NEVER skeletonise
# the prob blob (that is the original honeycomb bug).
fiber = (T >= thr_hi) & gate & bright
fiber = remove_small_objects(fiber, int(min_object_px))
if not fiber.any():
return _empty()
# Bridge sub-pixel gaps without fusing adjacent parallel fibers.
fiber = remove_small_holes(fiber, area_threshold=4)
# 7. Skeletonise the THIN mask -> clean single-pixel centrelines.
dist = ndi.distance_transform_edt(fiber, sampling=(dy, dx)).astype(np.float32)
skel = skeletonize(fiber)
# 8. Artifact (filled-slab) rejection: drop skeleton pixels whose local
# half-width exceeds a real fiber -> removes any residual medial-axis mesh
# in the dense band, keyed on WIDTH (not orientation), so thin verticals
# are never touched.
max_hw = max(px, float(max_fiber_half_width_um))
skel = skel & (dist <= max_hw)
if not skel.any():
return _empty()
skel = skel[None]
if prune_um and prune_um > 0:
skel = prune_skeleton(skel, voxel, spur_um=float(prune_um))
return TraceResult(fiber[None], skel, dist[None], voxel)
# --------------------------------------------------------------------------- #
# Region definition (IHC vs OHC) from Myo7a
# --------------------------------------------------------------------------- #
def myo_band_profile(myo_vol: np.ndarray) -> np.ndarray:
"""Row-wise (Y) intensity profile of the Myo7a hair-cell band."""
mip = myo_vol.max(0).astype(np.float32)
sm = gaussian(mip, 3, preserve_range=True)
return sm.sum(1)
def suggest_boundary(myo_vol: np.ndarray) -> float:
"""Suggest an IHC/OHC boundary as a fraction (0-1) along the Y axis.
Places the boundary at the centre of the Myo7a hair-cell band, which sits
between the (single) IHC row and the (three) OHC rows in a well-oriented
organ-of-Corti image. Users can refine this manually.
"""
prof = myo_band_profile(myo_vol)
if prof.sum() == 0:
return 0.5
ys = np.arange(prof.size)
centroid = float((ys * prof).sum() / prof.sum())
return centroid / prof.size
RADIAL_AXIS_INDEX = {"Z": 0, "Y": 1, "X": 2}
def boundary_line_coords(shape_yx: tuple, boundary_frac: float, axis: str = "Y",
angle_deg: float = 0.0, curvature: float = 0.0) -> tuple:
"""Pixel coordinates of the (possibly tilted / curved) boundary line.
Returns (ys, xs) integer arrays to index a 2D image, so the same line is
drawn on every overlay and used to build the region masks — they can never
disagree. ``angle_deg`` tilts the line; ``curvature`` bows it (a parabola,
positive = bow toward higher coordinates in the middle). For axis="Z" there
is no in-plane line, so empty arrays are returned.
"""
ny, nx = shape_yx
if axis.upper() == "Y":
xs = np.arange(nx)
cx, hx = nx / 2.0, max(nx / 2.0, 1.0)
ys = (boundary_frac * ny + np.tan(np.deg2rad(angle_deg)) * (xs - cx)
+ curvature * ((xs - cx) / hx) ** 2 * ny)
ys = np.clip(np.round(ys), 0, ny - 1).astype(int)
return ys, xs
if axis.upper() == "X":
ys = np.arange(ny)
cy, hy = ny / 2.0, max(ny / 2.0, 1.0)
xs = (boundary_frac * nx + np.tan(np.deg2rad(angle_deg)) * (ys - cy)
+ curvature * ((ys - cy) / hy) ** 2 * nx)
xs = np.clip(np.round(xs), 0, nx - 1).astype(int)
return ys, xs
return np.array([], int), np.array([], int) # axis == "Z"
def make_region_masks(shape: tuple, boundary_frac: float,
ihc_side: str = "low", axis: str = "Y",
angle_deg: float = 0.0, curvature: float = 0.0) -> tuple:
"""Return (ihc_roi, ohc_roi) boolean masks split by the boundary.
``shape`` may be (ny, nx) or (nz, ny, nx). axis="Y" splits along rows
(radial axis, the usual case), axis="X" splits along columns; both accept a
tilt (``angle_deg``) and a bow (``curvature``) and return 2D masks that are
broadcast over Z during quantification. axis="Z" splits by depth and
returns 3D masks. ``ihc_side`` selects which side ("low" = smaller
coordinate) is IHC.
"""
axis = axis.upper()
if axis == "Z":
if len(shape) != 3:
raise ValueError("Z split needs a 3D (nz, ny, nx) shape")
nz, ny, nx = shape
if nz < 2:
raise ValueError("Z (depth) split needs a multi-plane stack "
"(nz >= 2); this image has a single plane.")
b = int(round(np.clip(boundary_frac, 0, 1) * nz))
low = np.zeros((nz, ny, nx), bool)
low[:b] = True
ihc = low if ihc_side == "low" else ~low
return ihc, ~ihc
ny, nx = shape[-2], shape[-1]
yy, xx = np.mgrid[0:ny, 0:nx]
ang = np.tan(np.deg2rad(angle_deg))
if axis == "Y":
cx, hx = nx / 2.0, max(nx / 2.0, 1.0)
yline = (boundary_frac * ny + ang * (xx - cx)
+ curvature * ((xx - cx) / hx) ** 2 * ny)
low = yy < yline
else: # axis == "X"
cy, hy = ny / 2.0, max(ny / 2.0, 1.0)
xline = (boundary_frac * nx + ang * (yy - cy)
+ curvature * ((yy - cy) / hy) ** 2 * nx)
low = xx < xline
ihc = low if ihc_side == "low" else ~low
return ihc, ~ihc
# --------------------------------------------------------------------------- #
# Hair-cell detection (Cellpose assist, with a classical fallback)
# --------------------------------------------------------------------------- #
# A mouse cochlear hair cell body is roughly this wide; used to pick the
# working scale so cells land near Cellpose's preferred pixel size.
HAIR_CELL_DIAMETER_UM = 8.0
_CP_TARGET_PX = 30
def _norm(mip: np.ndarray) -> np.ndarray:
lo, hi = np.percentile(mip, 1), np.percentile(mip, 99.5)
return np.clip((mip - lo) / (hi - lo + 1e-6), 0, 1).astype(np.float32)
def custom_cellpose_model_path() -> Optional[str]:
"""Path to a fine-tuned Cellpose hair-cell model, if one is provided.
Looked up in order:
1. the ``NEURON_TRACER_CP_MODEL`` environment variable, then
2. a bundled ``models/hair_cell_cpsam`` file next to this module.
Returns None to use the stock pretrained Cellpose model. This is how a
model trained with ``training/train_hair_cells.py`` gets picked up by the
app: drop it at ``models/hair_cell_cpsam`` (or point the env var at it).
"""
p = os.environ.get("NEURON_TRACER_CP_MODEL")
if p and os.path.exists(p):
return p
here = os.path.dirname(os.path.abspath(__file__))
bundled = os.path.join(here, "models", "hair_cell_cpsam")
return bundled if os.path.exists(bundled) else None
def _get_cellpose_model():
global _CP_MODEL
if _CP_MODEL is None:
from cellpose import models
mp = custom_cellpose_model_path()
if mp:
_CP_MODEL = models.CellposeModel(gpu=False, pretrained_model=mp)
else:
_CP_MODEL = models.CellposeModel(gpu=False)
return _CP_MODEL
def prep_detection_image(myo_vol: np.ndarray, voxel: tuple) -> tuple:
"""The exact Myo7a image the detector feeds to Cellpose, plus its scale.
Returns ``(img_uint8, scale)`` where ``img_uint8`` is the contrast-
normalised max-projection rescaled so hair cells sit near Cellpose's
working size, and ``scale`` maps its coordinates back to full resolution
(full = detected / scale). Exposed so ``training/prepare_data.py`` can
export annotation images at the *same* scale the model sees at inference —
keeping training and inference consistent.
"""
dx = voxel[2]
mip = _norm(myo_vol.max(0).astype(np.float32))
cell_px_full = HAIR_CELL_DIAMETER_UM / dx # e.g. ~89 px
scale = float(np.clip(_CP_TARGET_PX / cell_px_full, 0.2, 1.0))
from skimage.transform import rescale
small = (rescale(mip, scale, anti_aliasing=True, preserve_range=True
).astype(np.float32) if scale < 0.999 else mip)
small = _norm(small)
return (small * 255).astype(np.uint8), scale
def _watershed_cells(img: np.ndarray, cell_px: float) -> np.ndarray:
"""Classical blob segmentation fallback (no deep-learning dependency)."""
from skimage.feature import peak_local_max
from skimage.segmentation import watershed
sm = gaussian(img, max(cell_px / 6.0, 1.0), preserve_range=True)
try:
mask = sm > threshold_otsu(sm)
except Exception:
return np.zeros(img.shape, int)
mask = ndi.binary_opening(mask, iterations=1)
dist = ndi.distance_transform_edt(mask)
coords = peak_local_max(dist, min_distance=max(int(cell_px * 0.5), 3),
labels=mask)
if len(coords) == 0:
return np.zeros(img.shape, int)
markers = np.zeros(img.shape, int)
markers[tuple(coords.T)] = np.arange(1, len(coords) + 1)
return watershed(-dist, markers, mask=mask)
def detect_hair_cells(myo_vol: np.ndarray, voxel: tuple,
prefer_cellpose: bool = True) -> dict:
"""Detect Myo7a hair-cell bodies on the max-projection.
Uses Cellpose when available (better on touching cells), otherwise a
watershed fallback. If a fine-tuned model is provided (see
``custom_cellpose_model_path``) it is used automatically and the engine is
reported as ``cellpose-custom``. Returns full-resolution centroids plus the
count and which engine ran. The image is rescaled so cells are ~30 px,
which is where Cellpose performs best.
"""
mip_shape = myo_vol.max(0).shape
small, scale = prep_detection_image(myo_vol, voxel)
small = small.astype(np.float32) / 255.0
engine = "watershed"
masks = None
if prefer_cellpose and CELLPOSE_AVAILABLE:
try:
masks = _get_cellpose_model().eval(small, diameter=_CP_TARGET_PX)[0]
engine = ("cellpose-custom" if custom_cellpose_model_path()
else "cellpose")
except Exception:
masks = None
if masks is None:
masks = _watershed_cells(small, _CP_TARGET_PX)
n = int(masks.max())
if n:
cent_small = np.array(ndi.center_of_mass(
np.ones_like(masks), masks, range(1, n + 1)))
centroids = cent_small / scale # back to full-res Y,X
else:
centroids = np.zeros((0, 2))
return {"centroids": centroids, "count": n, "engine": engine,
"scale": scale, "mip_shape": mip_shape}
# Anatomy-based thresholds for accepting an IHC/OHC split (mouse organ of Corti).
_MIN_TWO_ROW_EXTENT_UM = 18.0 # IHC row + tunnel + OHC rows span well past this;
# a single hair-cell row spans < ~15 µm radially.
_MIN_TUNNEL_UM = 7.0 # the IHC/OHC gap (tunnel of Corti) is ~10-25 µm;
# inter-cell spacing within a row is smaller.
def auto_regions_from_cells(centroids: np.ndarray, shape_yx: tuple,
um_per_px: Optional[float] = None) -> dict:
"""Suggest an IHC/OHC boundary from hair-cell centroids.
IHCs form a single row and OHCs form three rows separated from the IHCs by
the tunnel of Corti. We project cells onto the radial axis (perpendicular
to the hair-cell band), find the widest cell-free gap that leaves at least
two cells on each side, and call the sparser/tighter side IHC.
Crucially, a split is only proposed when the cells actually span more than
one row (radial extent) *and* the gap is a real cell-free tunnel. When only
a single hair-cell row is present, ``single_row`` is returned True and no
split is made — the field should be reported whole-field, not forced into a
meaningless IHC/OHC division.
"""
ny, nx = shape_yx
result = {"boundary_frac": 0.5, "ihc_side": "low", "confidence": "low",
"n_ihc_cells": 0, "n_ohc_cells": 0, "single_row": False,
"reason": "not enough hair cells for an automatic split"}
if len(centroids):
result["boundary_frac"] = float(np.clip(centroids[:, 0].mean() / ny, 0, 1))
if len(centroids) < 6:
result["single_row"] = True
result["reason"] = (f"only {len(centroids)} hair cell(s) detected — "
"cannot separate IHC vs OHC; report whole-field")
return result
c = centroids - centroids.mean(0)
_, _, vt = np.linalg.svd(c, full_matrices=False)
radial = vt[1]
if radial[0] < 0:
radial = -radial # point toward +Y
r = c @ radial
order = np.argsort(r)
rs = r[order]
gaps = np.diff(rs)
# Convert radial distances to microns (or fall back to image-fraction units).
if um_per_px and um_per_px > 0:
to_um = um_per_px
extent = (rs[-1] - rs[0]) * to_um
min_extent, min_gap = _MIN_TWO_ROW_EXTENT_UM, _MIN_TUNNEL_UM
unit = "µm"
else: # unknown scale
to_um = 1.0
extent = (rs[-1] - rs[0]) / max(ny, 1) # fraction of image
min_extent, min_gap = 0.12, 0.04
unit = "frac"
# Candidate splits must leave >=2 cells on each side (ignore stray outliers).
valid = [(i, gaps[i]) for i in range(1, len(gaps) - 1)]
biggest_gap = (max(valid, key=lambda t: t[1])[1] * to_um) if valid else 0.0
# Single-row / no-tunnel guard: refuse to split an unresolved band.
if extent < min_extent or not valid or biggest_gap < min_gap:
result["single_row"] = True
result["reason"] = (
f"hair cells span only {extent:.1f} {unit} radially with no clear "
f"tunnel gap — looks like a single row; IHC vs OHC cannot be "
f"separated from this field. Report whole-field or set the boundary "
f"by hand if you know the anatomy.")
return result
gi = max(valid, key=lambda t: t[1])[0]
split_r = (rs[gi] + rs[gi + 1]) / 2.0
left = r <= split_r
n_left, n_right = int(left.sum()), int((~left).sum())
s_left = float(r[left].std()) if n_left > 1 else 0.0
s_right = float(r[~left].std()) if n_right > 1 else 0.0
# IHC = single row: fewer cells and tighter spread
score = (1 if n_right > n_left else -1) + (1 if s_right > s_left else -1)
ihc_is_left = score >= 0
ihc_side = "low" if ihc_is_left else "high"
n_ihc = n_left if ihc_is_left else n_right
n_ohc = n_right if ihc_is_left else n_left
ymid = centroids[:, 0].mean() + split_r * radial[0]
bfrac = float(np.clip(ymid / ny, 0, 1))
ratio = n_ohc / max(n_ihc, 1)
strong_gap = (biggest_gap >= 2 * min_gap)
if strong_gap and extent >= 1.6 * min_extent and 1.8 <= ratio <= 5.0:
conf = "high"
else:
conf = "medium"
return {"boundary_frac": bfrac, "ihc_side": ihc_side, "confidence": conf,
"n_ihc_cells": n_ihc, "n_ohc_cells": n_ohc, "single_row": False,
"reason": f"tunnel gap {biggest_gap:.1f} {unit}, radial extent "
f"{extent:.1f} {unit}, IHC:OHC cell ratio 1:{ratio:.1f}"}
def count_cells_in_roi(centroids: np.ndarray, roi_yx: np.ndarray) -> int:
"""Count hair-cell centroids falling inside a 2D ROI mask."""
if len(centroids) == 0:
return 0
ny, nx = roi_yx.shape
yy = np.clip(centroids[:, 0].round().astype(int), 0, ny - 1)
xx = np.clip(centroids[:, 1].round().astype(int), 0, nx - 1)
return int(roi_yx[yy, xx].sum())
def _draw_boundary_rgb(rgb: np.ndarray, boundary_frac: Optional[float],
axis: str = "Y", angle_deg: float = 0.0,
curvature: float = 0.0, color=(255, 255, 0)) -> np.ndarray:
"""Draw the (tilted / curved) boundary line onto an RGB image in place.
For axis="Z" there is no in-plane line, so nothing is drawn.
"""
if boundary_frac is None:
return rgb
ny, nx = rgb.shape[:2]
ys, xs = boundary_line_coords((ny, nx), boundary_frac, axis, angle_deg, curvature)
if ys.size:
rgb[ys, xs] = color
return rgb
def hair_cell_overlay(myo_mip_u8: np.ndarray, centroids: np.ndarray,
boundary_frac: Optional[float] = None,
axis: str = "Y", ihc_side: Optional[str] = None,
angle_deg: float = 0.0, curvature: float = 0.0) -> np.ndarray:
"""Myo7a MIP with detected hair cells marked and the boundary drawn.
Cells are coloured by the *actual* region mask (so tilt/curvature are
honoured): cyan = IHC region, magenta = OHC region, neutral green when no
split is defined.
"""
rgb = np.stack([myo_mip_u8] * 3, axis=-1).copy()
ny, nx = myo_mip_u8.shape
ihc_mask = None
if (boundary_frac is not None and ihc_side is not None
and axis.upper() in ("Y", "X")):
ihc_mask, _ = make_region_masks((ny, nx), boundary_frac, ihc_side,
axis, angle_deg, curvature)
for (y, x) in centroids.astype(int):
yc, xc = min(max(y, 0), ny - 1), min(max(x, 0), nx - 1)
col = (0, 255, 0)
if ihc_mask is not None:
col = (0, 220, 255) if ihc_mask[yc, xc] else (255, 60, 200)
ys, xs = slice(max(0, y - 3), y + 4), slice(max(0, x - 3), x + 4)
rgb[ys, xs] = col
_draw_boundary_rgb(rgb, boundary_frac, axis, angle_deg, curvature)
return rgb
# --------------------------------------------------------------------------- #
# Quantification
# --------------------------------------------------------------------------- #
def _branch_point_count(skel: np.ndarray) -> int:
if skel.sum() == 0:
return 0
k = np.ones((3, 3, 3), int) if skel.ndim == 3 else np.ones((3, 3), int)
nb = ndi.convolve(skel.astype(np.uint8), k, mode="constant") - skel
return int((skel & (nb > 2)).sum())
def _fiber_stats(skel: np.ndarray, distance_um: np.ndarray, voxel: tuple,
min_fiber_um: float, min_diam_um: float, max_diam_um: float,
radial_axis: Optional[int]) -> tuple:
"""Per-fiber (connected-component) filtering and radial-direction counting.
A fiber is kept only if its length >= ``min_fiber_um`` **and** its mean
diameter is within [``min_diam_um``, ``max_diam_um``] (``max_diam_um`` <= 0
means no upper limit). When ``radial_axis`` is given, each kept fiber is
classified as radial (its principal axis aligns with the IHC->OHC axis) or
off-axis (running along the rows — a "misdirected" fiber).
Returns (kept_skel_bool, n_fibers, total_length_um, n_radial, n_offaxis).
``n_radial``/``n_offaxis`` are -1 when direction was not computed.
"""
struct = np.ones((3,) * skel.ndim, int)
lbl, n = ndi.label(skel, structure=struct)
if n == 0:
return np.zeros_like(skel), 0, 0.0, -1, -1
# Length per component: skan gives spacing-aware branch lengths; map each
# branch to its connected component via a pixel lying on the branch.
comp_len = {}
try:
S = Skeleton(skel, spacing=tuple(voxel))
df = summarize(S, separator="_")
for pi in df.index:
coords = np.round(S.path_coordinates(pi)).astype(int)
for a in range(coords.shape[1]):
coords[:, a] = np.clip(coords[:, a], 0, skel.shape[a] - 1)
c = 0
for p in (coords[len(coords) // 2], coords[0], coords[-1]):
c = int(lbl[tuple(p)])
if c:
break
if c:
comp_len[c] = comp_len.get(c, 0.0) + float(df.loc[pi, "branch_distance"])
except Exception:
step = float(np.mean(voxel))
for c in range(1, n + 1):
comp_len[c] = float((lbl == c).sum()) * step
spacing = np.asarray(voxel, float)
slices = ndi.find_objects(lbl)
kept = np.zeros(n + 1, bool)
n_radial = n_offaxis = 0
do_dir = radial_axis is not None
for c in range(1, n + 1):
sl = slices[c - 1]
if sl is None:
continue
# A component with no measured length is a degenerate fragment (e.g. an
# isolated voxel skan emits no branch for) — never count it as a fiber,
# even when min_fiber_um is 0. This also guarantees kept[c] => c in
# comp_len, so the total_length sum below can index comp_len safely.
if c not in comp_len or comp_len[c] < min_fiber_um:
continue
sub = lbl[sl] == c
dsub = 2.0 * distance_um[sl][sub]
mean_d = float(dsub.mean()) if dsub.size else 0.0
if mean_d < min_diam_um or (max_diam_um > 0 and mean_d > max_diam_um):
continue
kept[c] = True
if do_dir:
offset = np.array([s.start for s in sl], float)
pts = (np.argwhere(sub).astype(float) + offset) * spacing
if len(pts) >= 2:
pc = pts - pts.mean(0)
_, _, vt = np.linalg.svd(pc, full_matrices=False)
principal = vt[0]
cos_r = abs(principal[radial_axis]) / (np.linalg.norm(principal) + 1e-9)
n_radial += int(cos_r >= 0.5)
n_offaxis += int(cos_r < 0.5)
else:
n_offaxis += 1
kept_skel = kept[lbl]
n_fibers = int(kept[1:].sum())
total_length = float(sum(comp_len.get(c, 0.0)
for c in range(1, n + 1) if kept[c]))
if not do_dir:
n_radial = n_offaxis = -1
return kept_skel, n_fibers, total_length, n_radial, n_offaxis
def compute_metrics(trace: TraceResult, region_name: str,
roi_yx: Optional[np.ndarray] = None,
min_fiber_um: float = 5.0,
hair_cell_centroids: Optional[np.ndarray] = None,
min_diameter_um: float = 0.0,
max_diameter_um: float = 0.0,
manual_hair_cells: Optional[int] = None,
radial_axis_name: str = "Y") -> RegionMetrics:
"""Quantify the skeleton, optionally restricted to an ROI (2D or 3D).
Fibers are filtered by length (``min_fiber_um``) and, when set, by mean
diameter (``min_diameter_um`` / ``max_diameter_um``). ``manual_hair_cells``
overrides the detected hair-cell count for normalisation; otherwise
``hair_cell_centroids`` (if given) are counted inside the region.
``radial_axis_name`` ("Y"/"X"/"Z") is the IHC->OHC axis used to classify
fiber direction.
"""
dz, dy, dx = trace.voxel
skel = trace.skeleton
mask = trace.mask
if roi_yx is not None:
roi3d = np.broadcast_to(roi_yx, skel.shape) if roi_yx.ndim == 2 else roi_yx
skel = skel & roi3d
mask = mask & roi3d
m = RegionMetrics(region=region_name)
# 2D footprint of the ROI drives area / FOV (handles 2D and 3D ROIs).
if roi_yx is not None:
roi_foot = roi_yx if roi_yx.ndim == 2 else roi_yx.max(0)
m.fov_area_um2 = float(roi_foot.sum()) * dx * dy
else:
roi_foot = None
m.fov_area_um2 = float(mask.shape[1] * mask.shape[2]) * dx * dy
# Hair-cell count: manual override wins, else count detected centroids.
if manual_hair_cells is not None and manual_hair_cells >= 0:
m.n_hair_cells = int(manual_hair_cells)
elif hair_cell_centroids is not None:
if roi_foot is not None:
m.n_hair_cells = count_cells_in_roi(hair_cell_centroids, roi_foot)
else:
m.n_hair_cells = int(len(hair_cell_centroids))
foot = mask.max(0)
m.area_covered_um2 = float(foot.sum()) * dx * dy
m.pct_area_covered = (100.0 * m.area_covered_um2 / m.fov_area_um2
if m.fov_area_um2 else 0.0)
if skel.sum() >= 2:
name = (radial_axis_name or "Y").upper()
if skel.ndim == 3:
radial_axis = {"Z": 0, "Y": 1, "X": 2}.get(name, 1)
else:
radial_axis = {"Y": 0, "X": 1}.get(name, 0)
kept_skel, n_fibers, total_len, n_radial, n_offaxis = _fiber_stats(
skel, trace.distance_um, (dz, dy, dx), float(min_fiber_um),
float(min_diameter_um), float(max_diameter_um), radial_axis)
m.n_fibers = n_fibers
m.total_length_um = total_len
m.n_branch_points = _branch_point_count(kept_skel)
diam = 2.0 * trace.distance_um[kept_skel]
if diam.size:
m.mean_diameter_um = float(diam.mean())
m.median_diameter_um = float(np.median(diam))
if n_radial >= 0:
m.n_radial = n_radial
m.n_offaxis = n_offaxis
tot = n_radial + n_offaxis
m.pct_radial = (100.0 * n_radial / tot) if tot else 0.0
# Normalisation by hair-cell count (when known and positive).
if m.n_hair_cells > 0:
m.fibers_per_hc = m.n_fibers / m.n_hair_cells
m.length_per_hc_um = m.total_length_um / m.n_hair_cells
return m
# --------------------------------------------------------------------------- #
# Rendering
# --------------------------------------------------------------------------- #
def skeleton_image(skel: np.ndarray, dilate: int = 1) -> np.ndarray:
"""White skeleton on black background (2D uint8), as a MIP over Z."""
flat = skel.max(0) if skel.ndim == 3 else skel
if dilate:
flat = ndi.binary_dilation(flat, iterations=dilate)
return (flat * 255).astype(np.uint8)
def region_overlay(skel: np.ndarray, ihc_roi: np.ndarray, ohc_roi: np.ndarray,
boundary_frac: float, axis: str = "Y",
angle_deg: float = 0.0, curvature: float = 0.0,
dilate: int = 1) -> np.ndarray:
"""Colour-coded RGB preview: IHC fibers cyan, OHC fibers magenta, with the
boundary line in yellow. Works with 2D (broadcast) or 3D (Z-split) ROIs."""
if skel.ndim == 3:
ihc3 = (np.broadcast_to(ihc_roi, skel.shape) if ihc_roi.ndim == 2
else ihc_roi)
ohc3 = (np.broadcast_to(ohc_roi, skel.shape) if ohc_roi.ndim == 2
else ohc_roi)
ihc_pix = (skel & ihc3).max(0)
ohc_pix = (skel & ohc3).max(0)
ny, nx = skel.shape[1], skel.shape[2]
else:
ihc_pix = skel & ihc_roi
ohc_pix = skel & ohc_roi
ny, nx = skel.shape
if dilate:
ihc_pix = ndi.binary_dilation(ihc_pix, iterations=dilate)
ohc_pix = ndi.binary_dilation(ohc_pix, iterations=dilate)
rgb = np.zeros((ny, nx, 3), np.uint8)
rgb[ihc_pix] = (0, 220, 255) # cyan = IHC
rgb[ohc_pix] = (255, 60, 200) # magenta = OHC
_draw_boundary_rgb(rgb, boundary_frac, axis, angle_deg, curvature)
return rgb
def overlay_on_original(base_gray_u8: np.ndarray, skel: np.ndarray,
color=(255, 70, 70), dilate: int = 1) -> np.ndarray:
"""Traced skeleton drawn in colour over the original (grayscale) MIP.
Lets the trace be checked against the raw signal — the reviewers' request
to view the tracing overlaid on the original image.
"""
flat = skel.max(0) if skel.ndim == 3 else skel
if dilate:
flat = ndi.binary_dilation(flat, iterations=dilate)
rgb = np.stack([base_gray_u8] * 3, axis=-1).copy()
rgb[flat] = color
return rgb
def channel_preview(vol: np.ndarray) -> np.ndarray:
"""Contrast-stretched MIP of a channel for display (uint8)."""
mip = vol.max(0).astype(np.float32)
lo, hi = np.percentile(mip, 1), np.percentile(mip, 99.5)
return (np.clip((mip - lo) / (hi - lo + 1e-6), 0, 1) * 255).astype(np.uint8)