Spaces:
Paused
Paused
Upload 6 files
Browse files- README.md +77 -7
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/processing.cpython-313.pyc +0 -0
- app.py +464 -0
- processing.py +655 -0
- requirements.txt +15 -0
README.md
CHANGED
|
@@ -1,13 +1,83 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Cochlear Neurofilament Tracer
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.49.1
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# 🧠 Cochlear Neurofilament Tracer
|
| 14 |
+
|
| 15 |
+
A HuggingFace app that traces auditory-nerve fibers in confocal z-stacks of the
|
| 16 |
+
organ of Corti and quantifies them **per frequency region**, separating
|
| 17 |
+
**IHC-innervating** from **OHC-innervating** fibers.
|
| 18 |
+
|
| 19 |
+
It is an alternative to IMARIS filament tracing that keeps each neuron as a
|
| 20 |
+
**single continuous traced element** instead of splitting it into many
|
| 21 |
+
threshold-dependent segments.
|
| 22 |
+
|
| 23 |
+
## Input
|
| 24 |
+
|
| 25 |
+
- **File type:** Zeiss `.czi` 3D z-stacks. Generic `.tif/.tiff` stacks are also
|
| 26 |
+
accepted for flexibility.
|
| 27 |
+
- **Channels:**
|
| 28 |
+
- *Neurofilament* — traces the neuron.
|
| 29 |
+
- *Myo7a* — marks hair cells; used as a reference to separate IHC- vs
|
| 30 |
+
OHC-innervating fibers. IHCs form a single row and OHCs form three adjacent
|
| 31 |
+
rows, so the Myo7a band is used to place the IHC/OHC boundary.
|
| 32 |
+
- **Frequency region:** selectable (8/16/22/32/64 kHz), auto-detected from the
|
| 33 |
+
file name when possible.
|
| 34 |
+
- Channels are auto-detected from CZI metadata (Alexa-555 → Neurofilament,
|
| 35 |
+
Alexa-405 → Myo7a) but can be reassigned in the UI.
|
| 36 |
+
|
| 37 |
+
## What it does
|
| 38 |
+
|
| 39 |
+
1. Segments and **skeletonises the Neurofilament network in 3D** using physical
|
| 40 |
+
voxel spacing (from CZI metadata, or entered for TIFF).
|
| 41 |
+
2. Uses the **Myo7a channel** to place an IHC/OHC boundary. This can be set
|
| 42 |
+
manually (ROI 1 vs ROI 2) by moving the boundary slider while viewing the
|
| 43 |
+
Myo7a preview, choosing the split axis, and choosing which side is IHC.
|
| 44 |
+
Optionally, **Detect hair cells** runs **Cellpose** (or a classical
|
| 45 |
+
watershed fallback) on the Myo7a channel to mark hair cells, count them per
|
| 46 |
+
region, and *propose* a boundary + side with a confidence score. On dense
|
| 47 |
+
fields this detection is often incomplete, so it is a **visual assist**: the
|
| 48 |
+
quantified numbers come from the deterministic pipeline and the boundary
|
| 49 |
+
stays under your control. Detection is much better on a GPU Space.
|
| 50 |
+
3. Computes, per region (Whole field / IHC / OHC):
|
| 51 |
+
- **Number of fibers** (continuous skeleton components above a minimum length)
|
| 52 |
+
- **Hair cells (Myo7a)** counted in the region (when detection is run)
|
| 53 |
+
- **Thickness / diameter** (from the 3D distance transform)
|
| 54 |
+
- **Length** (µm, spacing-aware)
|
| 55 |
+
- **Branching** (number of branch points)
|
| 56 |
+
- **Area covered** within the field of view (µm² and % of FOV)
|
| 57 |
+
|
| 58 |
+
## Output
|
| 59 |
+
|
| 60 |
+
- A **black-background image** of the traced neurons in **white** (skeletonised
|
| 61 |
+
trace), plus a colour-coded IHC/OHC overlay.
|
| 62 |
+
- An **Excel workbook** with all quantification, organized by frequency region,
|
| 63 |
+
with IHC and OHC fibers reported separately (tidy "Per region" sheet plus
|
| 64 |
+
per-metric frequency × region summary sheets).
|
| 65 |
+
|
| 66 |
+
The **Batch** tab processes several stacks at once (e.g. all frequency regions
|
| 67 |
+
of one cochlea) and compiles one Excel workbook plus a ZIP of skeleton images.
|
| 68 |
+
|
| 69 |
+
## Notes on method
|
| 70 |
+
|
| 71 |
+
Confocal images of the organ of Corti are dense, so fully separating every
|
| 72 |
+
individual axon is inherently ambiguous. This tool traces the network
|
| 73 |
+
continuously and reports metrics **per region surrounding the IHCs / OHCs**,
|
| 74 |
+
with a human-in-the-loop boundary for reliable IHC vs OHC assignment. The
|
| 75 |
+
`sensitivity` control scales the segmentation threshold to capture more or fewer
|
| 76 |
+
thin fibers.
|
| 77 |
+
|
| 78 |
+
## Local run
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
pip install -r requirements.txt
|
| 82 |
+
python app.py
|
| 83 |
+
```
|
__pycache__/app.cpython-313.pyc
ADDED
|
Binary file (26.9 kB). View file
|
|
|
__pycache__/processing.cpython-313.pyc
ADDED
|
Binary file (35 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Cochlear Neurofilament Tracer — HuggingFace Gradio app
|
| 3 |
+
======================================================
|
| 4 |
+
|
| 5 |
+
Traces auditory-nerve fibers (Neurofilament channel) in confocal z-stacks of
|
| 6 |
+
the organ of Corti, uses the Myo7a hair-cell channel to separate
|
| 7 |
+
IHC-innervating from OHC-innervating fibers, and reports per-region
|
| 8 |
+
quantification (number of fibers, diameter, length, branch points, area
|
| 9 |
+
covered) plus a black-background skeleton image and an Excel workbook.
|
| 10 |
+
|
| 11 |
+
Accepts Zeiss **CZI** z-stacks and generic **TIFF** stacks.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import tempfile
|
| 16 |
+
import zipfile
|
| 17 |
+
import traceback
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import pandas as pd
|
| 21 |
+
import gradio as gr
|
| 22 |
+
from skimage.io import imsave
|
| 23 |
+
from scipy import ndimage as ndi
|
| 24 |
+
|
| 25 |
+
import processing as P
|
| 26 |
+
|
| 27 |
+
OUT_DIR = tempfile.mkdtemp(prefix="neuron_tracer_")
|
| 28 |
+
|
| 29 |
+
METRIC_COLS = [
|
| 30 |
+
"File", "Frequency region", "Region", "Number of fibers",
|
| 31 |
+
"Hair cells (Myo7a)", "Total length (um)", "Mean diameter (um)",
|
| 32 |
+
"Median diameter (um)", "Branch points", "Area covered (um^2)",
|
| 33 |
+
"FOV area (um^2)", "Area covered (% of FOV)",
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# --------------------------------------------------------------------------- #
|
| 38 |
+
# Small helpers
|
| 39 |
+
# --------------------------------------------------------------------------- #
|
| 40 |
+
|
| 41 |
+
def _channel_choices(img: P.LoadedImage):
|
| 42 |
+
choices = []
|
| 43 |
+
for i, ch in enumerate(img.channels):
|
| 44 |
+
dye = ch.get("dye") or "no dye / transmitted"
|
| 45 |
+
choices.append((f"Ch {i} — {ch.get('name','?')} ({dye})", i))
|
| 46 |
+
return choices
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _draw_boundary_on(gray_u8, boundary_frac, axis="Y"):
|
| 50 |
+
"""Return an RGB copy of a grayscale MIP with a yellow boundary line."""
|
| 51 |
+
rgb = np.stack([gray_u8] * 3, axis=-1)
|
| 52 |
+
ny, nx = gray_u8.shape
|
| 53 |
+
if axis.upper() == "Y":
|
| 54 |
+
b = min(max(int(round(boundary_frac * ny)), 0), ny - 1)
|
| 55 |
+
rgb[b, :] = (255, 255, 0)
|
| 56 |
+
else:
|
| 57 |
+
b = min(max(int(round(boundary_frac * nx)), 0), nx - 1)
|
| 58 |
+
rgb[:, b] = (255, 255, 0)
|
| 59 |
+
return rgb
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _save_png(arr, name):
|
| 63 |
+
path = os.path.join(OUT_DIR, name)
|
| 64 |
+
imsave(path, arr)
|
| 65 |
+
return path
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _build_excel(rows, path):
|
| 69 |
+
"""Write a tidy per-region sheet plus a frequency×region summary sheet."""
|
| 70 |
+
df = pd.DataFrame(rows, columns=METRIC_COLS)
|
| 71 |
+
with pd.ExcelWriter(path, engine="openpyxl") as xl:
|
| 72 |
+
df.to_excel(xl, sheet_name="Per region", index=False)
|
| 73 |
+
# Summary: only IHC / OHC rows, pivoted by frequency region.
|
| 74 |
+
sub = df[df["Region"].isin(["IHC region", "OHC region"])]
|
| 75 |
+
if not sub.empty:
|
| 76 |
+
for metric in ["Number of fibers", "Hair cells (Myo7a)",
|
| 77 |
+
"Total length (um)", "Mean diameter (um)",
|
| 78 |
+
"Branch points", "Area covered (um^2)"]:
|
| 79 |
+
if metric not in sub or sub[metric].replace("", pd.NA).isna().all():
|
| 80 |
+
continue
|
| 81 |
+
piv = sub.pivot_table(index="Frequency region",
|
| 82 |
+
columns="Region", values=metric,
|
| 83 |
+
aggfunc="mean")
|
| 84 |
+
sheet = metric.split(" (")[0][:28]
|
| 85 |
+
piv.to_excel(xl, sheet_name=f"{sheet}")
|
| 86 |
+
return df
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# --------------------------------------------------------------------------- #
|
| 90 |
+
# Single-image interactive flow
|
| 91 |
+
# --------------------------------------------------------------------------- #
|
| 92 |
+
|
| 93 |
+
def load_and_preview(file_obj, dz, dy, dx):
|
| 94 |
+
if file_obj is None:
|
| 95 |
+
return (None, gr.update(), gr.update(), gr.update(),
|
| 96 |
+
gr.update(), None, None, "Please upload a CZI or TIFF file.")
|
| 97 |
+
try:
|
| 98 |
+
img = P.load_image(file_obj, dz=float(dz), dy=float(dy), dx=float(dx))
|
| 99 |
+
except Exception as e:
|
| 100 |
+
return (None, gr.update(), gr.update(), gr.update(),
|
| 101 |
+
gr.update(), None, None,
|
| 102 |
+
f"❌ Failed to load file:\n{e}\n{traceback.format_exc()}")
|
| 103 |
+
|
| 104 |
+
nf, myo = P.guess_channels(img)
|
| 105 |
+
choices = _channel_choices(img)
|
| 106 |
+
freq = P.detect_frequency(img.source_name)
|
| 107 |
+
bfrac = P.suggest_boundary(img.data[myo])
|
| 108 |
+
|
| 109 |
+
nf_prev = P.channel_preview(img.data[nf])
|
| 110 |
+
myo_prev = _draw_boundary_on(P.channel_preview(img.data[myo]), bfrac, "Y")
|
| 111 |
+
|
| 112 |
+
dz_, dy_, dx_ = img.voxel
|
| 113 |
+
status = (f"✅ Loaded **{img.source_name}** — shape "
|
| 114 |
+
f"{img.data.shape} (C,Z,Y,X)\n\n"
|
| 115 |
+
f"Voxel size: dz={dz_:.3f}, dy={dy_:.4f}, dx={dx_:.4f} µm. "
|
| 116 |
+
f"Detected frequency region: **{freq}**.\n\n"
|
| 117 |
+
f"Auto-picked Neurofilament = Ch {nf}, Myo7a = Ch {myo}. "
|
| 118 |
+
f"Adjust below if needed, then press **Run analysis**.")
|
| 119 |
+
return (img,
|
| 120 |
+
gr.update(choices=choices, value=nf),
|
| 121 |
+
gr.update(choices=choices, value=myo),
|
| 122 |
+
gr.update(value=freq),
|
| 123 |
+
gr.update(value=round(bfrac, 3)),
|
| 124 |
+
nf_prev, myo_prev, status)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def refresh_boundary_preview(img, myo_idx, boundary, axis, hc_cents, ihc_side):
|
| 128 |
+
"""Redraw the Myo7a preview with the boundary and any detected hair cells."""
|
| 129 |
+
if img is None or myo_idx is None:
|
| 130 |
+
return None
|
| 131 |
+
myo_u8 = P.channel_preview(img.data[int(myo_idx)])
|
| 132 |
+
side = "low" if ihc_side.startswith("Low") else "high"
|
| 133 |
+
cents = hc_cents if hc_cents is not None else np.zeros((0, 2))
|
| 134 |
+
return P.hair_cell_overlay(myo_u8, cents, float(boundary), axis, side)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def detect_cells(img, myo_idx, axis):
|
| 138 |
+
"""Run Cellpose (or watershed fallback) on the Myo7a channel, overlay the
|
| 139 |
+
detected hair cells, and propose an IHC/OHC boundary + side."""
|
| 140 |
+
if img is None or myo_idx is None:
|
| 141 |
+
return (None, None, gr.update(), gr.update(),
|
| 142 |
+
"Load an image and select the Myo7a channel first.")
|
| 143 |
+
try:
|
| 144 |
+
myo_idx = int(myo_idx)
|
| 145 |
+
det = P.detect_hair_cells(img.data[myo_idx], img.voxel)
|
| 146 |
+
reg = P.auto_regions_from_cells(det["centroids"], det["mip_shape"])
|
| 147 |
+
side_label = ("Low side = IHC" if reg["ihc_side"] == "low"
|
| 148 |
+
else "High side = IHC")
|
| 149 |
+
myo_u8 = P.channel_preview(img.data[myo_idx])
|
| 150 |
+
ov = P.hair_cell_overlay(myo_u8, det["centroids"],
|
| 151 |
+
reg["boundary_frac"], axis, reg["ihc_side"])
|
| 152 |
+
status = (
|
| 153 |
+
f"🔬 Detected **{det['count']}** hair cells "
|
| 154 |
+
f"(engine: {det['engine']}).\n\n"
|
| 155 |
+
f"Suggested boundary **{reg['boundary_frac']:.3f}**, "
|
| 156 |
+
f"**{side_label}**, confidence: **{reg['confidence']}** "
|
| 157 |
+
f"({reg['reason']}).\n\n"
|
| 158 |
+
f"⚠️ This is a *starting suggestion* — detection is often "
|
| 159 |
+
f"incomplete on dense fields. **Check the overlay** (cyan = IHC, "
|
| 160 |
+
f"magenta = OHC, yellow = boundary) and drag the boundary slider "
|
| 161 |
+
f"to correct it before running.")
|
| 162 |
+
return (det["centroids"], ov,
|
| 163 |
+
gr.update(value=round(reg["boundary_frac"], 3)),
|
| 164 |
+
gr.update(value=side_label), status)
|
| 165 |
+
except Exception as e:
|
| 166 |
+
return (None, None, gr.update(), gr.update(),
|
| 167 |
+
f"❌ Hair-cell detection failed:\n{e}\n{traceback.format_exc()}")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def run_single(img, nf_idx, myo_idx, freq, axis, ihc_side, boundary,
|
| 171 |
+
sensitivity, min_fiber, hc_cents):
|
| 172 |
+
if img is None:
|
| 173 |
+
return None, None, None, None, None, "Please load an image first."
|
| 174 |
+
if nf_idx is None:
|
| 175 |
+
return None, None, None, None, None, "Please select the Neurofilament channel."
|
| 176 |
+
try:
|
| 177 |
+
nf_idx, myo_idx = int(nf_idx), int(myo_idx)
|
| 178 |
+
trace = P.trace_neurites(img.data[nf_idx], img.voxel,
|
| 179 |
+
sensitivity=float(sensitivity))
|
| 180 |
+
shape_yx = img.data[nf_idx].shape[1:]
|
| 181 |
+
side = "low" if ihc_side.startswith("Low") else "high"
|
| 182 |
+
ihc_roi, ohc_roi = P.make_region_masks(shape_yx, float(boundary),
|
| 183 |
+
ihc_side=side, axis=axis)
|
| 184 |
+
cents = hc_cents if (hc_cents is not None and len(hc_cents)) else None
|
| 185 |
+
|
| 186 |
+
whole = P.compute_metrics(trace, "Whole field",
|
| 187 |
+
min_fiber_um=float(min_fiber),
|
| 188 |
+
hair_cell_centroids=cents)
|
| 189 |
+
m_ihc = P.compute_metrics(trace, "IHC region", ihc_roi,
|
| 190 |
+
min_fiber_um=float(min_fiber),
|
| 191 |
+
hair_cell_centroids=cents)
|
| 192 |
+
m_ohc = P.compute_metrics(trace, "OHC region", ohc_roi,
|
| 193 |
+
min_fiber_um=float(min_fiber),
|
| 194 |
+
hair_cell_centroids=cents)
|
| 195 |
+
|
| 196 |
+
skel_img = P.skeleton_image(trace.skeleton)
|
| 197 |
+
region_img = P.region_overlay(trace.skeleton, ihc_roi, ohc_roi,
|
| 198 |
+
float(boundary), axis)
|
| 199 |
+
myo_u8 = P.channel_preview(img.data[myo_idx])
|
| 200 |
+
myo_img = P.hair_cell_overlay(
|
| 201 |
+
myo_u8, cents if cents is not None else np.zeros((0, 2)),
|
| 202 |
+
float(boundary), axis, side)
|
| 203 |
+
|
| 204 |
+
stem = os.path.splitext(img.source_name)[0]
|
| 205 |
+
skel_path = _save_png(skel_img, f"{stem}_skeleton.png")
|
| 206 |
+
|
| 207 |
+
rows = []
|
| 208 |
+
for m in (whole, m_ihc, m_ohc):
|
| 209 |
+
r = m.as_row()
|
| 210 |
+
r = {"File": img.source_name, "Frequency region": freq, **r}
|
| 211 |
+
rows.append(r)
|
| 212 |
+
df = pd.DataFrame(rows, columns=METRIC_COLS)
|
| 213 |
+
xl_path = os.path.join(OUT_DIR, f"{stem}_quantification.xlsx")
|
| 214 |
+
_build_excel(rows, xl_path)
|
| 215 |
+
|
| 216 |
+
hc_note = ""
|
| 217 |
+
if cents is not None:
|
| 218 |
+
hc_note = (f" Hair cells: IHC={m_ihc.n_hair_cells}, "
|
| 219 |
+
f"OHC={m_ohc.n_hair_cells}.")
|
| 220 |
+
status = (f"✅ Done. Traced {int(trace.skeleton.sum())} skeleton voxels. "
|
| 221 |
+
f"IHC={m_ihc.n_fibers} fibers / {m_ihc.total_length_um:.0f} µm, "
|
| 222 |
+
f"OHC={m_ohc.n_fibers} fibers / {m_ohc.total_length_um:.0f} µm."
|
| 223 |
+
+ hc_note)
|
| 224 |
+
# Return skeleton path also as downloadable file
|
| 225 |
+
return (skel_img, region_img, myo_img, df,
|
| 226 |
+
[skel_path, xl_path], status)
|
| 227 |
+
except Exception as e:
|
| 228 |
+
return (None, None, None, None, None,
|
| 229 |
+
f"❌ Error:\n{e}\n{traceback.format_exc()}")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
# --------------------------------------------------------------------------- #
|
| 233 |
+
# Batch flow
|
| 234 |
+
# --------------------------------------------------------------------------- #
|
| 235 |
+
|
| 236 |
+
def run_batch(files, axis, ihc_side, sensitivity, min_fiber, detect_hc,
|
| 237 |
+
dz, dy, dx, progress=gr.Progress()):
|
| 238 |
+
if not files:
|
| 239 |
+
return None, None, None, "Please upload one or more files."
|
| 240 |
+
side = "low" if ihc_side.startswith("Low") else "high"
|
| 241 |
+
all_rows, gallery, skel_paths = [], [], []
|
| 242 |
+
log = []
|
| 243 |
+
for f in progress.tqdm(files, desc="Processing"):
|
| 244 |
+
path = f if isinstance(f, str) else f.name
|
| 245 |
+
name = os.path.basename(path)
|
| 246 |
+
try:
|
| 247 |
+
img = P.load_image(path, dz=float(dz), dy=float(dy), dx=float(dx))
|
| 248 |
+
nf, myo = P.guess_channels(img)
|
| 249 |
+
freq = P.detect_frequency(name)
|
| 250 |
+
trace = P.trace_neurites(img.data[nf], img.voxel,
|
| 251 |
+
sensitivity=float(sensitivity))
|
| 252 |
+
shape_yx = img.data[nf].shape[1:]
|
| 253 |
+
cents = None
|
| 254 |
+
hc_tag = ""
|
| 255 |
+
if detect_hc:
|
| 256 |
+
det = P.detect_hair_cells(img.data[myo], img.voxel)
|
| 257 |
+
reg = P.auto_regions_from_cells(det["centroids"],
|
| 258 |
+
det["mip_shape"])
|
| 259 |
+
bfrac = reg["boundary_frac"]
|
| 260 |
+
cur_side = reg["ihc_side"]
|
| 261 |
+
cents = det["centroids"] if len(det["centroids"]) else None
|
| 262 |
+
hc_tag = (f" | {det['count']} hair cells ({det['engine']}), "
|
| 263 |
+
f"auto-boundary conf={reg['confidence']}")
|
| 264 |
+
else:
|
| 265 |
+
bfrac = P.suggest_boundary(img.data[myo])
|
| 266 |
+
cur_side = side
|
| 267 |
+
ihc_roi, ohc_roi = P.make_region_masks(shape_yx, bfrac,
|
| 268 |
+
ihc_side=cur_side, axis=axis)
|
| 269 |
+
for m in (P.compute_metrics(trace, "Whole field",
|
| 270 |
+
min_fiber_um=float(min_fiber),
|
| 271 |
+
hair_cell_centroids=cents),
|
| 272 |
+
P.compute_metrics(trace, "IHC region", ihc_roi,
|
| 273 |
+
min_fiber_um=float(min_fiber),
|
| 274 |
+
hair_cell_centroids=cents),
|
| 275 |
+
P.compute_metrics(trace, "OHC region", ohc_roi,
|
| 276 |
+
min_fiber_um=float(min_fiber),
|
| 277 |
+
hair_cell_centroids=cents)):
|
| 278 |
+
all_rows.append({"File": name, "Frequency region": freq,
|
| 279 |
+
**m.as_row()})
|
| 280 |
+
skel_img = P.skeleton_image(trace.skeleton)
|
| 281 |
+
stem = os.path.splitext(name)[0]
|
| 282 |
+
sp = _save_png(skel_img, f"{stem}_skeleton.png")
|
| 283 |
+
skel_paths.append(sp)
|
| 284 |
+
gallery.append((skel_img, f"{name} ({freq})"))
|
| 285 |
+
log.append(f"✅ {name}: {freq}{hc_tag}")
|
| 286 |
+
except Exception as e:
|
| 287 |
+
log.append(f"❌ {name}: {e}")
|
| 288 |
+
|
| 289 |
+
if not all_rows:
|
| 290 |
+
return None, None, gallery, "No files processed.\n" + "\n".join(log)
|
| 291 |
+
|
| 292 |
+
xl_path = os.path.join(OUT_DIR, "batch_quantification.xlsx")
|
| 293 |
+
df = _build_excel(all_rows, xl_path)
|
| 294 |
+
|
| 295 |
+
zip_path = os.path.join(OUT_DIR, "batch_skeletons.zip")
|
| 296 |
+
with zipfile.ZipFile(zip_path, "w") as z:
|
| 297 |
+
for sp in skel_paths:
|
| 298 |
+
z.write(sp, os.path.basename(sp))
|
| 299 |
+
z.write(xl_path, os.path.basename(xl_path))
|
| 300 |
+
|
| 301 |
+
return df, [xl_path, zip_path], gallery, "\n".join(log)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# --------------------------------------------------------------------------- #
|
| 305 |
+
# UI
|
| 306 |
+
# --------------------------------------------------------------------------- #
|
| 307 |
+
|
| 308 |
+
INTRO = """
|
| 309 |
+
# 🧠 Cochlear Neurofilament Tracer
|
| 310 |
+
|
| 311 |
+
Trace auditory-nerve fibers in confocal z-stacks and quantify them **per
|
| 312 |
+
frequency region**, separating **IHC-innervating** from **OHC-innervating**
|
| 313 |
+
fibers using the Myo7a hair-cell channel.
|
| 314 |
+
|
| 315 |
+
**Channels expected:** *Neurofilament* (traces the neuron) and *Myo7a* (hair
|
| 316 |
+
cells — reference to split IHC vs OHC). IHCs form a single row, OHCs form three
|
| 317 |
+
rows, so the Myo7a band is used to place the IHC/OHC boundary — which you can
|
| 318 |
+
fine-tune by hand.
|
| 319 |
+
|
| 320 |
+
**Input:** Zeiss `.czi` z-stacks or generic `.tif/.tiff` stacks.
|
| 321 |
+
"""
|
| 322 |
+
|
| 323 |
+
with gr.Blocks(title="Cochlear Neurofilament Tracer", theme=gr.themes.Soft()) as demo:
|
| 324 |
+
gr.Markdown(INTRO)
|
| 325 |
+
|
| 326 |
+
with gr.Tab("Single image (interactive)"):
|
| 327 |
+
img_state = gr.State()
|
| 328 |
+
hc_state = gr.State() # detected hair-cell centroids (Nx2)
|
| 329 |
+
with gr.Row():
|
| 330 |
+
with gr.Column(scale=1):
|
| 331 |
+
file_in = gr.File(label="Upload CZI or TIFF",
|
| 332 |
+
file_types=[".czi", ".tif", ".tiff"],
|
| 333 |
+
type="filepath")
|
| 334 |
+
with gr.Accordion("Voxel size (µm) — used for TIFF; CZI reads "
|
| 335 |
+
"its own", open=False):
|
| 336 |
+
dz_in = gr.Number(0.35, label="dz (µm/plane)")
|
| 337 |
+
dy_in = gr.Number(0.0895, label="dy (µm/px)")
|
| 338 |
+
dx_in = gr.Number(0.0895, label="dx (µm/px)")
|
| 339 |
+
load_btn = gr.Button("① Load & preview", variant="secondary")
|
| 340 |
+
|
| 341 |
+
nf_dd = gr.Dropdown(label="Neurofilament channel", choices=[])
|
| 342 |
+
myo_dd = gr.Dropdown(label="Myo7a channel", choices=[])
|
| 343 |
+
freq_dd = gr.Dropdown(label="Frequency region",
|
| 344 |
+
choices=P.FREQ_CHOICES,
|
| 345 |
+
value="Other / unknown")
|
| 346 |
+
|
| 347 |
+
gr.Markdown("**IHC / OHC region split** (Myo7a-guided)")
|
| 348 |
+
axis_dd = gr.Radio(["Y", "X"], value="Y",
|
| 349 |
+
label="Split axis (Y = radial, usual)")
|
| 350 |
+
side_dd = gr.Radio(["Low side = IHC", "High side = IHC"],
|
| 351 |
+
value="Low side = IHC",
|
| 352 |
+
label="Which side is IHC?")
|
| 353 |
+
boundary_sl = gr.Slider(0.0, 1.0, value=0.5, step=0.005,
|
| 354 |
+
label="Boundary position (fraction "
|
| 355 |
+
"along split axis)")
|
| 356 |
+
detect_btn = gr.Button(
|
| 357 |
+
"🔬 Detect hair cells & suggest boundary "
|
| 358 |
+
f"({'Cellpose' if P.CELLPOSE_AVAILABLE else 'watershed'})",
|
| 359 |
+
variant="secondary")
|
| 360 |
+
gr.Markdown(
|
| 361 |
+
"<sub>Detection is a *visual assist*: it marks hair cells "
|
| 362 |
+
"and proposes a starting boundary. Confirm/adjust against "
|
| 363 |
+
"the Myo7a overlay — it does not replace your judgement.</sub>")
|
| 364 |
+
|
| 365 |
+
gr.Markdown("**Tracing**")
|
| 366 |
+
sens_sl = gr.Slider(0.5, 1.5, value=1.0, step=0.05,
|
| 367 |
+
label="Sensitivity (↑ = capture more/thinner "
|
| 368 |
+
"fibers)")
|
| 369 |
+
minfib_sl = gr.Slider(0.0, 20.0, value=5.0, step=0.5,
|
| 370 |
+
label="Min fiber length to count (µm)")
|
| 371 |
+
run_btn = gr.Button("② Run analysis", variant="primary")
|
| 372 |
+
|
| 373 |
+
with gr.Column(scale=2):
|
| 374 |
+
status = gr.Markdown()
|
| 375 |
+
with gr.Row():
|
| 376 |
+
nf_prev = gr.Image(label="Neurofilament (MIP)",
|
| 377 |
+
height=220)
|
| 378 |
+
myo_prev = gr.Image(label="Myo7a (MIP) + boundary",
|
| 379 |
+
height=220)
|
| 380 |
+
skel_out = gr.Image(label="Traced neurons — white on black",
|
| 381 |
+
height=300)
|
| 382 |
+
region_out = gr.Image(label="Region overlay (cyan = IHC, "
|
| 383 |
+
"magenta = OHC)", height=300)
|
| 384 |
+
table = gr.Dataframe(label="Quantification", wrap=True)
|
| 385 |
+
files_out = gr.Files(label="Downloads (skeleton PNG + Excel)")
|
| 386 |
+
|
| 387 |
+
load_btn.click(load_and_preview,
|
| 388 |
+
[file_in, dz_in, dy_in, dx_in],
|
| 389 |
+
[img_state, nf_dd, myo_dd, freq_dd, boundary_sl,
|
| 390 |
+
nf_prev, myo_prev, status]
|
| 391 |
+
).then(lambda: None, None, hc_state) # clear old cells
|
| 392 |
+
# Live boundary preview (keeps detected hair cells visible)
|
| 393 |
+
for comp in (boundary_sl, myo_dd, axis_dd, side_dd):
|
| 394 |
+
comp.change(refresh_boundary_preview,
|
| 395 |
+
[img_state, myo_dd, boundary_sl, axis_dd, hc_state,
|
| 396 |
+
side_dd], myo_prev)
|
| 397 |
+
detect_btn.click(detect_cells,
|
| 398 |
+
[img_state, myo_dd, axis_dd],
|
| 399 |
+
[hc_state, myo_prev, boundary_sl, side_dd, status])
|
| 400 |
+
run_btn.click(run_single,
|
| 401 |
+
[img_state, nf_dd, myo_dd, freq_dd, axis_dd, side_dd,
|
| 402 |
+
boundary_sl, sens_sl, minfib_sl, hc_state],
|
| 403 |
+
[skel_out, region_out, myo_prev, table, files_out, status])
|
| 404 |
+
|
| 405 |
+
with gr.Tab("Batch (multiple images)"):
|
| 406 |
+
gr.Markdown(
|
| 407 |
+
"Upload several z-stacks (e.g. all frequency regions of one "
|
| 408 |
+
"cochlea). Each is auto-traced with an auto-placed IHC/OHC "
|
| 409 |
+
"boundary, and results are combined into one Excel workbook "
|
| 410 |
+
"organized by frequency region.")
|
| 411 |
+
with gr.Row():
|
| 412 |
+
with gr.Column(scale=1):
|
| 413 |
+
batch_files = gr.File(label="Upload CZI/TIFF files",
|
| 414 |
+
file_count="multiple",
|
| 415 |
+
file_types=[".czi", ".tif", ".tiff"],
|
| 416 |
+
type="filepath")
|
| 417 |
+
b_axis = gr.Radio(["Y", "X"], value="Y", label="Split axis")
|
| 418 |
+
b_side = gr.Radio(["Low side = IHC", "High side = IHC"],
|
| 419 |
+
value="Low side = IHC",
|
| 420 |
+
label="Which side is IHC?")
|
| 421 |
+
b_sens = gr.Slider(0.5, 1.5, value=1.0, step=0.05,
|
| 422 |
+
label="Sensitivity")
|
| 423 |
+
b_minfib = gr.Slider(0.0, 20.0, value=5.0, step=0.5,
|
| 424 |
+
label="Min fiber length (µm)")
|
| 425 |
+
b_detect = gr.Checkbox(
|
| 426 |
+
value=False,
|
| 427 |
+
label="Detect hair cells & auto-place boundary "
|
| 428 |
+
f"({'Cellpose' if P.CELLPOSE_AVAILABLE else 'watershed'}"
|
| 429 |
+
", adds ~15–20 s/image)")
|
| 430 |
+
with gr.Accordion("Voxel size (µm) for TIFF", open=False):
|
| 431 |
+
b_dz = gr.Number(0.35, label="dz")
|
| 432 |
+
b_dy = gr.Number(0.0895, label="dy")
|
| 433 |
+
b_dx = gr.Number(0.0895, label="dx")
|
| 434 |
+
batch_btn = gr.Button("Run batch", variant="primary")
|
| 435 |
+
with gr.Column(scale=2):
|
| 436 |
+
batch_log = gr.Textbox(label="Log", lines=6)
|
| 437 |
+
batch_table = gr.Dataframe(label="Combined quantification",
|
| 438 |
+
wrap=True)
|
| 439 |
+
batch_files_out = gr.Files(label="Downloads (Excel + ZIP)")
|
| 440 |
+
batch_gallery = gr.Gallery(label="Skeleton traces",
|
| 441 |
+
columns=3, height=400)
|
| 442 |
+
batch_btn.click(run_batch,
|
| 443 |
+
[batch_files, b_axis, b_side, b_sens, b_minfib,
|
| 444 |
+
b_detect, b_dz, b_dy, b_dx],
|
| 445 |
+
[batch_table, batch_files_out, batch_gallery, batch_log])
|
| 446 |
+
|
| 447 |
+
gr.Markdown(
|
| 448 |
+
"---\n*Method:* the Neurofilament channel is smoothed, thresholded "
|
| 449 |
+
"(Otsu, scaled by the sensitivity control) and skeletonised in 3D; "
|
| 450 |
+
"length, diameter (from the 3D distance transform), branch points and "
|
| 451 |
+
"footprint area are measured with physical voxel spacing. Each fiber is "
|
| 452 |
+
"a connected skeleton component ≥ the minimum length. The Myo7a band "
|
| 453 |
+
"defines the IHC/OHC boundary, and metrics are reported for each "
|
| 454 |
+
"region.\n\n"
|
| 455 |
+
"*Hair-cell detection* (optional) runs **Cellpose** — or a classical "
|
| 456 |
+
"watershed fallback if Cellpose isn't installed — on the Myo7a "
|
| 457 |
+
"max-projection to mark hair cells, count them per region, and propose "
|
| 458 |
+
"a boundary. On dense fields this detection is often incomplete, so it "
|
| 459 |
+
"is offered as a **visual assist**: the numbers you trust still come "
|
| 460 |
+
"from the deterministic pipeline, and the boundary remains yours to "
|
| 461 |
+
"set. Detection quality improves markedly on a GPU Space.")
|
| 462 |
+
|
| 463 |
+
if __name__ == "__main__":
|
| 464 |
+
demo.launch()
|
processing.py
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core image-processing pipeline for cochlear neurofilament tracing.
|
| 3 |
+
|
| 4 |
+
Handles:
|
| 5 |
+
* Loading Zeiss CZI z-stacks and generic TIFF stacks (with voxel sizes).
|
| 6 |
+
* Channel identification (Neurofilament vs Myo7a).
|
| 7 |
+
* 3D tracing of the neurofilament network into a single continuous skeleton.
|
| 8 |
+
* Myo7a-guided splitting of the field into an IHC region and an OHC region.
|
| 9 |
+
* Per-region quantification: number of fibers, diameter, length,
|
| 10 |
+
branch points and area covered within the field of view.
|
| 11 |
+
|
| 12 |
+
The module has no Gradio dependency so it can be unit-tested on its own.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
from scipy import ndimage as ndi
|
| 24 |
+
from skimage.filters import gaussian, threshold_otsu
|
| 25 |
+
from skimage.morphology import remove_small_objects, skeletonize
|
| 26 |
+
from skan import Skeleton, summarize
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _cellpose_available() -> bool:
|
| 30 |
+
try:
|
| 31 |
+
import cellpose # noqa: F401
|
| 32 |
+
return True
|
| 33 |
+
except Exception:
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
CELLPOSE_AVAILABLE = _cellpose_available()
|
| 38 |
+
_CP_MODEL = None # lazily-created, cached Cellpose model
|
| 39 |
+
|
| 40 |
+
# --------------------------------------------------------------------------- #
|
| 41 |
+
# Data containers
|
| 42 |
+
# --------------------------------------------------------------------------- #
|
| 43 |
+
|
| 44 |
+
FREQ_CHOICES = ["8kHz", "16kHz", "22kHz", "32kHz", "64kHz", "Other / unknown"]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class LoadedImage:
|
| 49 |
+
"""A loaded multi-channel z-stack."""
|
| 50 |
+
|
| 51 |
+
data: np.ndarray # (C, Z, Y, X) float32
|
| 52 |
+
channels: list # list of dicts: {name, dye, color}
|
| 53 |
+
voxel: tuple # (dz, dy, dx) in microns
|
| 54 |
+
source_name: str = ""
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def n_channels(self) -> int:
|
| 58 |
+
return self.data.shape[0]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@dataclass
|
| 62 |
+
class RegionMetrics:
|
| 63 |
+
"""Quantification for one region (whole field, IHC region or OHC region)."""
|
| 64 |
+
|
| 65 |
+
region: str = ""
|
| 66 |
+
n_fibers: int = 0
|
| 67 |
+
total_length_um: float = 0.0
|
| 68 |
+
mean_diameter_um: float = 0.0
|
| 69 |
+
median_diameter_um: float = 0.0
|
| 70 |
+
n_branch_points: int = 0
|
| 71 |
+
area_covered_um2: float = 0.0
|
| 72 |
+
fov_area_um2: float = 0.0
|
| 73 |
+
pct_area_covered: float = 0.0
|
| 74 |
+
n_hair_cells: int = -1 # -1 = not measured (no Myo7a detection run)
|
| 75 |
+
|
| 76 |
+
def as_row(self) -> dict:
|
| 77 |
+
row = {
|
| 78 |
+
"Region": self.region,
|
| 79 |
+
"Number of fibers": self.n_fibers,
|
| 80 |
+
"Hair cells (Myo7a)": (self.n_hair_cells
|
| 81 |
+
if self.n_hair_cells >= 0 else ""),
|
| 82 |
+
"Total length (um)": round(self.total_length_um, 2),
|
| 83 |
+
"Mean diameter (um)": round(self.mean_diameter_um, 3),
|
| 84 |
+
"Median diameter (um)": round(self.median_diameter_um, 3),
|
| 85 |
+
"Branch points": self.n_branch_points,
|
| 86 |
+
"Area covered (um^2)": round(self.area_covered_um2, 2),
|
| 87 |
+
"FOV area (um^2)": round(self.fov_area_um2, 2),
|
| 88 |
+
"Area covered (% of FOV)": round(self.pct_area_covered, 2),
|
| 89 |
+
}
|
| 90 |
+
return row
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@dataclass
|
| 94 |
+
class TraceResult:
|
| 95 |
+
"""Everything produced by tracing one image."""
|
| 96 |
+
|
| 97 |
+
mask: np.ndarray # 3D bool
|
| 98 |
+
skeleton: np.ndarray # 3D bool
|
| 99 |
+
distance_um: np.ndarray # 3D float, EDT in microns
|
| 100 |
+
voxel: tuple
|
| 101 |
+
metrics: dict = field(default_factory=dict) # region name -> RegionMetrics
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# --------------------------------------------------------------------------- #
|
| 105 |
+
# Loading
|
| 106 |
+
# --------------------------------------------------------------------------- #
|
| 107 |
+
|
| 108 |
+
def detect_frequency(filename: str) -> str:
|
| 109 |
+
"""Guess the frequency-region label from a filename (e.g. '16kHz')."""
|
| 110 |
+
m = re.search(r"(\d+)\s*k\s*hz", filename, re.IGNORECASE)
|
| 111 |
+
if m:
|
| 112 |
+
label = f"{int(m.group(1))}kHz"
|
| 113 |
+
if label in FREQ_CHOICES:
|
| 114 |
+
return label
|
| 115 |
+
return "Other / unknown"
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _czi_channel_meta(czi) -> list:
|
| 119 |
+
"""Extract per-channel name/dye/color from CZI metadata (best effort)."""
|
| 120 |
+
meta = czi.meta
|
| 121 |
+
seen = {}
|
| 122 |
+
order = []
|
| 123 |
+
for ch in meta.iter("Channel"):
|
| 124 |
+
name = ch.get("Name")
|
| 125 |
+
if not name:
|
| 126 |
+
continue
|
| 127 |
+
dye = ch.findtext("DyeName") or ch.findtext("Fluor")
|
| 128 |
+
color = ch.findtext("Color")
|
| 129 |
+
ex = ch.findtext("ExcitationWavelength")
|
| 130 |
+
if name not in seen:
|
| 131 |
+
seen[name] = {"name": name, "dye": None, "color": None, "ex": None}
|
| 132 |
+
order.append(name)
|
| 133 |
+
rec = seen[name]
|
| 134 |
+
rec["dye"] = rec["dye"] or dye
|
| 135 |
+
rec["color"] = rec["color"] or color
|
| 136 |
+
rec["ex"] = rec["ex"] or ex
|
| 137 |
+
return [seen[n] for n in order]
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _czi_voxel(czi) -> tuple:
|
| 141 |
+
"""Return (dz, dy, dx) in microns from CZI scaling metadata."""
|
| 142 |
+
scale = {}
|
| 143 |
+
for d in czi.meta.iter("Distance"):
|
| 144 |
+
idv = d.get("Id")
|
| 145 |
+
val = d.findtext("Value")
|
| 146 |
+
if idv in ("X", "Y", "Z") and val:
|
| 147 |
+
scale[idv] = float(val) * 1e6 # metres -> microns
|
| 148 |
+
dx = scale.get("X", 0.0895)
|
| 149 |
+
dy = scale.get("Y", dx)
|
| 150 |
+
dz = scale.get("Z", 0.35)
|
| 151 |
+
return (dz, dy, dx)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def load_czi(path: str) -> LoadedImage:
|
| 155 |
+
from aicspylibczi import CziFile
|
| 156 |
+
|
| 157 |
+
czi = CziFile(path)
|
| 158 |
+
img, shp = czi.read_image()
|
| 159 |
+
dims = [d for d, _ in shp]
|
| 160 |
+
arr = np.asarray(img)
|
| 161 |
+
# collapse everything except C, Z, Y, X
|
| 162 |
+
# find axis indices
|
| 163 |
+
idx = {d: i for i, d in enumerate(dims)}
|
| 164 |
+
# move to C,Z,Y,X ordering, squeezing singletons (B,V,T,...)
|
| 165 |
+
keep = ["C", "Z", "Y", "X"]
|
| 166 |
+
order = [idx[k] for k in keep if k in idx]
|
| 167 |
+
other = [i for i in range(arr.ndim) if i not in order]
|
| 168 |
+
arr = np.transpose(arr, other + order)
|
| 169 |
+
arr = arr.reshape((-1,) + arr.shape[len(other):]) if other else arr
|
| 170 |
+
# after reshape leading axis is product of others -> take first
|
| 171 |
+
if other:
|
| 172 |
+
arr = arr[0]
|
| 173 |
+
# arr now (C,Z,Y,X) or missing Z
|
| 174 |
+
if "Z" not in idx:
|
| 175 |
+
arr = arr[:, None]
|
| 176 |
+
arr = arr.astype(np.float32)
|
| 177 |
+
channels = _czi_channel_meta(czi)
|
| 178 |
+
if len(channels) != arr.shape[0]:
|
| 179 |
+
channels = [{"name": f"Channel {i}", "dye": None, "color": None}
|
| 180 |
+
for i in range(arr.shape[0])]
|
| 181 |
+
return LoadedImage(arr, channels, _czi_voxel(czi), os.path.basename(path))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def load_tiff(path: str, dz=0.35, dy=0.0895, dx=0.0895) -> LoadedImage:
|
| 185 |
+
import tifffile
|
| 186 |
+
|
| 187 |
+
arr = tifffile.imread(path)
|
| 188 |
+
arr = np.squeeze(arr)
|
| 189 |
+
# Heuristic to reach (C, Z, Y, X). The two largest axes are Y, X.
|
| 190 |
+
if arr.ndim == 2: # (Y, X) single channel, single plane
|
| 191 |
+
arr = arr[None, None]
|
| 192 |
+
elif arr.ndim == 3:
|
| 193 |
+
# Could be (Z,Y,X) single channel or (C,Y,X). Assume small first axis = C
|
| 194 |
+
if arr.shape[0] <= 5:
|
| 195 |
+
arr = arr[:, None] # (C, 1, Y, X)
|
| 196 |
+
else:
|
| 197 |
+
arr = arr[None] # (1, Z, Y, X)
|
| 198 |
+
elif arr.ndim == 4:
|
| 199 |
+
# find the two largest axes -> Y, X; of the remaining two the smaller = C
|
| 200 |
+
yx = sorted(range(4), key=lambda a: arr.shape[a])[-2:]
|
| 201 |
+
rest = [a for a in range(4) if a not in yx]
|
| 202 |
+
c_axis = min(rest, key=lambda a: arr.shape[a])
|
| 203 |
+
z_axis = [a for a in rest if a != c_axis][0]
|
| 204 |
+
arr = np.transpose(arr, (c_axis, z_axis, *sorted(yx)))
|
| 205 |
+
else:
|
| 206 |
+
raise ValueError(f"Unsupported TIFF with {arr.ndim} dimensions")
|
| 207 |
+
arr = arr.astype(np.float32)
|
| 208 |
+
channels = [{"name": f"Channel {i}", "dye": None, "color": None}
|
| 209 |
+
for i in range(arr.shape[0])]
|
| 210 |
+
return LoadedImage(arr, channels, (dz, dy, dx), os.path.basename(path))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def load_image(path: str, dz=0.35, dy=0.0895, dx=0.0895) -> LoadedImage:
|
| 214 |
+
ext = os.path.splitext(path)[1].lower()
|
| 215 |
+
if ext == ".czi":
|
| 216 |
+
return load_czi(path)
|
| 217 |
+
if ext in (".tif", ".tiff"):
|
| 218 |
+
return load_tiff(path, dz, dy, dx)
|
| 219 |
+
raise ValueError(f"Unsupported file type: {ext}")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# --------------------------------------------------------------------------- #
|
| 223 |
+
# Channel identification
|
| 224 |
+
# --------------------------------------------------------------------------- #
|
| 225 |
+
|
| 226 |
+
# Wavelength-based hints: neurofilament here is Alexa-555 (red/green range),
|
| 227 |
+
# Myo7a is Alexa-405 (blue). Transmitted-light PMT channels have no dye.
|
| 228 |
+
def guess_channels(img: LoadedImage) -> tuple:
|
| 229 |
+
"""Best-effort (neurofilament_index, myo7a_index) from metadata + content."""
|
| 230 |
+
nf_idx, myo_idx = None, None
|
| 231 |
+
blue_like, red_like, plain = [], [], []
|
| 232 |
+
for i, ch in enumerate(img.channels):
|
| 233 |
+
dye = (ch.get("dye") or "").lower()
|
| 234 |
+
color = (ch.get("color") or "").upper()
|
| 235 |
+
ex = ch.get("ex")
|
| 236 |
+
ex = float(ex) if ex else None
|
| 237 |
+
if "405" in dye or (ex and ex < 430) or color == "#0000FF":
|
| 238 |
+
blue_like.append(i)
|
| 239 |
+
elif dye and dye not in ("", "none"):
|
| 240 |
+
red_like.append(i)
|
| 241 |
+
else:
|
| 242 |
+
plain.append(i) # e.g. transmitted-light PMT
|
| 243 |
+
if blue_like:
|
| 244 |
+
myo_idx = blue_like[0]
|
| 245 |
+
if red_like:
|
| 246 |
+
nf_idx = red_like[0]
|
| 247 |
+
|
| 248 |
+
# Fall back to image content when metadata is missing (e.g. plain TIFF).
|
| 249 |
+
# Fluorescence channels have a mostly-dark background; transmitted-light
|
| 250 |
+
# (brightfield) channels fill the frame, so we skip those. We cannot
|
| 251 |
+
# reliably tell fibers from blobs automatically, so we default NF to the
|
| 252 |
+
# first fluorescent channel and Myo7a to the last — the user confirms
|
| 253 |
+
# via the channel previews in the UI.
|
| 254 |
+
if nf_idx is None or myo_idx is None:
|
| 255 |
+
fluo = [i for i in range(img.n_channels)
|
| 256 |
+
if _dark_fraction(img.data[i]) >= 0.2]
|
| 257 |
+
if not fluo:
|
| 258 |
+
fluo = list(range(img.n_channels))
|
| 259 |
+
if nf_idx is None:
|
| 260 |
+
nf_idx = fluo[0]
|
| 261 |
+
if myo_idx is None or myo_idx == nf_idx:
|
| 262 |
+
myo_idx = fluo[-1] if fluo[-1] != nf_idx else fluo[0]
|
| 263 |
+
return nf_idx, myo_idx
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _dark_fraction(vol: np.ndarray) -> float:
|
| 267 |
+
"""Fraction of the (normalised) MIP that is near-black background."""
|
| 268 |
+
mip = vol.max(0).astype(np.float32)
|
| 269 |
+
mip = (mip - mip.min()) / (np.ptp(mip) + 1e-6)
|
| 270 |
+
return float((mip < 0.15).mean())
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# --------------------------------------------------------------------------- #
|
| 274 |
+
# Neurofilament tracing
|
| 275 |
+
# --------------------------------------------------------------------------- #
|
| 276 |
+
|
| 277 |
+
def _threshold_volume(vol: np.ndarray, sensitivity: float) -> np.ndarray:
|
| 278 |
+
"""Smooth + Otsu threshold. `sensitivity` (0.5-1.5) scales the threshold;
|
| 279 |
+
higher sensitivity -> lower threshold -> more fibers captured."""
|
| 280 |
+
lo, hi = np.percentile(vol, 1), np.percentile(vol, 99.8)
|
| 281 |
+
norm = np.clip((vol - lo) / (hi - lo + 1e-6), 0, 1)
|
| 282 |
+
sm = gaussian(norm, sigma=(0.6, 1.0, 1.0), preserve_range=True)
|
| 283 |
+
fg = sm[sm > sm.mean() * 0.3]
|
| 284 |
+
base = threshold_otsu(fg) if fg.size else sm.mean()
|
| 285 |
+
thr = base * (2.0 - sensitivity) # sensitivity 1.0 -> base
|
| 286 |
+
return sm > thr
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def trace_neurites(nf_vol: np.ndarray, voxel: tuple,
|
| 290 |
+
sensitivity: float = 1.0,
|
| 291 |
+
min_object_vox: int = 64) -> TraceResult:
|
| 292 |
+
"""Segment and skeletonise the neurofilament network in 3D."""
|
| 293 |
+
dz, dy, dx = voxel
|
| 294 |
+
mask = _threshold_volume(nf_vol, sensitivity)
|
| 295 |
+
mask = remove_small_objects(mask, min_object_vox)
|
| 296 |
+
mask = ndi.binary_closing(mask, iterations=1)
|
| 297 |
+
if mask.sum() == 0:
|
| 298 |
+
z = np.zeros_like(mask)
|
| 299 |
+
return TraceResult(mask, z, np.zeros(mask.shape, np.float32), voxel)
|
| 300 |
+
skel = skeletonize(mask)
|
| 301 |
+
dist = ndi.distance_transform_edt(mask, sampling=(dz, dy, dx)).astype(np.float32)
|
| 302 |
+
return TraceResult(mask, skel, dist, voxel)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# --------------------------------------------------------------------------- #
|
| 306 |
+
# Region definition (IHC vs OHC) from Myo7a
|
| 307 |
+
# --------------------------------------------------------------------------- #
|
| 308 |
+
|
| 309 |
+
def myo_band_profile(myo_vol: np.ndarray) -> np.ndarray:
|
| 310 |
+
"""Row-wise (Y) intensity profile of the Myo7a hair-cell band."""
|
| 311 |
+
mip = myo_vol.max(0).astype(np.float32)
|
| 312 |
+
sm = gaussian(mip, 3, preserve_range=True)
|
| 313 |
+
return sm.sum(1)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def suggest_boundary(myo_vol: np.ndarray) -> float:
|
| 317 |
+
"""Suggest an IHC/OHC boundary as a fraction (0-1) along the Y axis.
|
| 318 |
+
|
| 319 |
+
Places the boundary at the centre of the Myo7a hair-cell band, which sits
|
| 320 |
+
between the (single) IHC row and the (three) OHC rows in a well-oriented
|
| 321 |
+
organ-of-Corti image. Users can refine this manually.
|
| 322 |
+
"""
|
| 323 |
+
prof = myo_band_profile(myo_vol)
|
| 324 |
+
if prof.sum() == 0:
|
| 325 |
+
return 0.5
|
| 326 |
+
ys = np.arange(prof.size)
|
| 327 |
+
centroid = float((ys * prof).sum() / prof.sum())
|
| 328 |
+
return centroid / prof.size
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def make_region_masks(shape_yx: tuple, boundary_frac: float,
|
| 332 |
+
ihc_side: str = "low", axis: str = "Y") -> tuple:
|
| 333 |
+
"""Return (ihc_roi, ohc_roi) boolean 2D masks split by a straight line.
|
| 334 |
+
|
| 335 |
+
axis="Y" splits along rows (radial axis, the usual case); axis="X" splits
|
| 336 |
+
along columns. ihc_side selects which side of the boundary is IHC.
|
| 337 |
+
"""
|
| 338 |
+
ny, nx = shape_yx
|
| 339 |
+
low = np.zeros((ny, nx), bool)
|
| 340 |
+
if axis.upper() == "Y":
|
| 341 |
+
b = int(round(np.clip(boundary_frac, 0, 1) * ny))
|
| 342 |
+
low[:b] = True
|
| 343 |
+
else:
|
| 344 |
+
b = int(round(np.clip(boundary_frac, 0, 1) * nx))
|
| 345 |
+
low[:, :b] = True
|
| 346 |
+
ihc = low if ihc_side == "low" else ~low
|
| 347 |
+
return ihc, ~ihc
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
# --------------------------------------------------------------------------- #
|
| 351 |
+
# Hair-cell detection (Cellpose assist, with a classical fallback)
|
| 352 |
+
# --------------------------------------------------------------------------- #
|
| 353 |
+
|
| 354 |
+
# A mouse cochlear hair cell body is roughly this wide; used to pick the
|
| 355 |
+
# working scale so cells land near Cellpose's preferred pixel size.
|
| 356 |
+
HAIR_CELL_DIAMETER_UM = 8.0
|
| 357 |
+
_CP_TARGET_PX = 30
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _norm(mip: np.ndarray) -> np.ndarray:
|
| 361 |
+
lo, hi = np.percentile(mip, 1), np.percentile(mip, 99.5)
|
| 362 |
+
return np.clip((mip - lo) / (hi - lo + 1e-6), 0, 1).astype(np.float32)
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _get_cellpose_model():
|
| 366 |
+
global _CP_MODEL
|
| 367 |
+
if _CP_MODEL is None:
|
| 368 |
+
from cellpose import models
|
| 369 |
+
_CP_MODEL = models.CellposeModel(gpu=False)
|
| 370 |
+
return _CP_MODEL
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def _watershed_cells(img: np.ndarray, cell_px: float) -> np.ndarray:
|
| 374 |
+
"""Classical blob segmentation fallback (no deep-learning dependency)."""
|
| 375 |
+
from skimage.feature import peak_local_max
|
| 376 |
+
from skimage.segmentation import watershed
|
| 377 |
+
sm = gaussian(img, max(cell_px / 6.0, 1.0), preserve_range=True)
|
| 378 |
+
try:
|
| 379 |
+
mask = sm > threshold_otsu(sm)
|
| 380 |
+
except Exception:
|
| 381 |
+
return np.zeros(img.shape, int)
|
| 382 |
+
mask = ndi.binary_opening(mask, iterations=1)
|
| 383 |
+
dist = ndi.distance_transform_edt(mask)
|
| 384 |
+
coords = peak_local_max(dist, min_distance=max(int(cell_px * 0.5), 3),
|
| 385 |
+
labels=mask)
|
| 386 |
+
if len(coords) == 0:
|
| 387 |
+
return np.zeros(img.shape, int)
|
| 388 |
+
markers = np.zeros(img.shape, int)
|
| 389 |
+
markers[tuple(coords.T)] = np.arange(1, len(coords) + 1)
|
| 390 |
+
return watershed(-dist, markers, mask=mask)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def detect_hair_cells(myo_vol: np.ndarray, voxel: tuple,
|
| 394 |
+
prefer_cellpose: bool = True) -> dict:
|
| 395 |
+
"""Detect Myo7a hair-cell bodies on the max-projection.
|
| 396 |
+
|
| 397 |
+
Uses Cellpose when available (better on touching cells), otherwise a
|
| 398 |
+
watershed fallback. Returns full-resolution centroids plus the count and
|
| 399 |
+
which engine ran. The image is rescaled so cells are ~30 px, which is
|
| 400 |
+
where Cellpose performs best.
|
| 401 |
+
"""
|
| 402 |
+
dz, dy, dx = voxel
|
| 403 |
+
mip = _norm(myo_vol.max(0).astype(np.float32))
|
| 404 |
+
cell_px_full = HAIR_CELL_DIAMETER_UM / dx # e.g. ~89 px
|
| 405 |
+
scale = float(np.clip(_CP_TARGET_PX / cell_px_full, 0.2, 1.0))
|
| 406 |
+
|
| 407 |
+
from skimage.transform import rescale
|
| 408 |
+
small = rescale(mip, scale, anti_aliasing=True, preserve_range=True
|
| 409 |
+
).astype(np.float32) if scale < 0.999 else mip
|
| 410 |
+
small = _norm(small)
|
| 411 |
+
|
| 412 |
+
engine = "watershed"
|
| 413 |
+
masks = None
|
| 414 |
+
if prefer_cellpose and CELLPOSE_AVAILABLE:
|
| 415 |
+
try:
|
| 416 |
+
masks = _get_cellpose_model().eval(small, diameter=_CP_TARGET_PX)[0]
|
| 417 |
+
engine = "cellpose"
|
| 418 |
+
except Exception:
|
| 419 |
+
masks = None
|
| 420 |
+
if masks is None:
|
| 421 |
+
masks = _watershed_cells(small, _CP_TARGET_PX)
|
| 422 |
+
|
| 423 |
+
n = int(masks.max())
|
| 424 |
+
if n:
|
| 425 |
+
cent_small = np.array(ndi.center_of_mass(
|
| 426 |
+
np.ones_like(masks), masks, range(1, n + 1)))
|
| 427 |
+
centroids = cent_small / scale # back to full-res Y,X
|
| 428 |
+
else:
|
| 429 |
+
centroids = np.zeros((0, 2))
|
| 430 |
+
return {"centroids": centroids, "count": n, "engine": engine,
|
| 431 |
+
"scale": scale, "mip_shape": mip.shape}
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def auto_regions_from_cells(centroids: np.ndarray, shape_yx: tuple) -> dict:
|
| 435 |
+
"""Suggest an IHC/OHC boundary from hair-cell centroids.
|
| 436 |
+
|
| 437 |
+
IHCs form a single row and OHCs form three rows separated from the IHCs by
|
| 438 |
+
the tunnel of Corti. We project cells onto the radial axis (perpendicular
|
| 439 |
+
to the hair-cell band), find the widest cell-free gap that leaves at least
|
| 440 |
+
two cells on each side, and call the sparser/tighter side IHC. A
|
| 441 |
+
confidence label reflects how clearly the gap and the ~1:3 cell ratio
|
| 442 |
+
appear — real fields are often ambiguous, so this is a suggestion.
|
| 443 |
+
"""
|
| 444 |
+
ny, nx = shape_yx
|
| 445 |
+
result = {"boundary_frac": 0.5, "ihc_side": "low", "confidence": "low",
|
| 446 |
+
"n_ihc_cells": 0, "n_ohc_cells": 0,
|
| 447 |
+
"reason": "not enough hair cells for an automatic split"}
|
| 448 |
+
if len(centroids) < 6:
|
| 449 |
+
# fall back to band centroid
|
| 450 |
+
result["boundary_frac"] = float(np.clip(
|
| 451 |
+
centroids[:, 0].mean() / ny, 0, 1)) if len(centroids) else 0.5
|
| 452 |
+
return result
|
| 453 |
+
|
| 454 |
+
c = centroids - centroids.mean(0)
|
| 455 |
+
_, _, vt = np.linalg.svd(c, full_matrices=False)
|
| 456 |
+
radial = vt[1]
|
| 457 |
+
if radial[0] < 0:
|
| 458 |
+
radial = -radial # point toward +Y
|
| 459 |
+
r = c @ radial
|
| 460 |
+
order = np.argsort(r)
|
| 461 |
+
rs = r[order]
|
| 462 |
+
gaps = np.diff(rs)
|
| 463 |
+
# only accept splits leaving >=2 cells on each side (ignore stray outliers)
|
| 464 |
+
valid = [(i, gaps[i]) for i in range(1, len(gaps) - 1)]
|
| 465 |
+
if not valid:
|
| 466 |
+
result["boundary_frac"] = float(np.clip(
|
| 467 |
+
centroids[:, 0].mean() / ny, 0, 1))
|
| 468 |
+
return result
|
| 469 |
+
gi = max(valid, key=lambda t: t[1])[0]
|
| 470 |
+
biggest_gap = gaps[gi]
|
| 471 |
+
split_r = (rs[gi] + rs[gi + 1]) / 2.0
|
| 472 |
+
|
| 473 |
+
left = r <= split_r
|
| 474 |
+
n_left, n_right = int(left.sum()), int((~left).sum())
|
| 475 |
+
s_left = float(r[left].std()) if n_left > 1 else 0.0
|
| 476 |
+
s_right = float(r[~left].std()) if n_right > 1 else 0.0
|
| 477 |
+
# IHC = single row: fewer cells and tighter spread
|
| 478 |
+
score = (1 if n_right > n_left else -1) + (1 if s_right > s_left else -1)
|
| 479 |
+
ihc_is_left = score >= 0
|
| 480 |
+
ihc_side = "low" if ihc_is_left else "high"
|
| 481 |
+
n_ihc = n_left if ihc_is_left else n_right
|
| 482 |
+
n_ohc = n_right if ihc_is_left else n_left
|
| 483 |
+
|
| 484 |
+
ymid = centroids[:, 0].mean() + split_r * radial[0]
|
| 485 |
+
bfrac = float(np.clip(ymid / ny, 0, 1))
|
| 486 |
+
|
| 487 |
+
med_gap = float(np.median(gaps[gaps > 0])) if (gaps > 0).any() else 1.0
|
| 488 |
+
gap_ratio = biggest_gap / (med_gap + 1e-6)
|
| 489 |
+
ratio = n_ohc / max(n_ihc, 1)
|
| 490 |
+
if gap_ratio >= 3.0 and 1.8 <= ratio <= 5.0:
|
| 491 |
+
conf = "high"
|
| 492 |
+
elif gap_ratio >= 2.0:
|
| 493 |
+
conf = "medium"
|
| 494 |
+
else:
|
| 495 |
+
conf = "low"
|
| 496 |
+
|
| 497 |
+
return {"boundary_frac": bfrac, "ihc_side": ihc_side, "confidence": conf,
|
| 498 |
+
"n_ihc_cells": n_ihc, "n_ohc_cells": n_ohc,
|
| 499 |
+
"reason": f"tunnel gap {gap_ratio:.1f}x median spacing, "
|
| 500 |
+
f"IHC:OHC cell ratio 1:{ratio:.1f}"}
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def count_cells_in_roi(centroids: np.ndarray, roi_yx: np.ndarray) -> int:
|
| 504 |
+
"""Count hair-cell centroids falling inside a 2D ROI mask."""
|
| 505 |
+
if len(centroids) == 0:
|
| 506 |
+
return 0
|
| 507 |
+
ny, nx = roi_yx.shape
|
| 508 |
+
yy = np.clip(centroids[:, 0].round().astype(int), 0, ny - 1)
|
| 509 |
+
xx = np.clip(centroids[:, 1].round().astype(int), 0, nx - 1)
|
| 510 |
+
return int(roi_yx[yy, xx].sum())
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def hair_cell_overlay(myo_mip_u8: np.ndarray, centroids: np.ndarray,
|
| 514 |
+
boundary_frac: Optional[float] = None,
|
| 515 |
+
axis: str = "Y", ihc_side: Optional[str] = None) -> np.ndarray:
|
| 516 |
+
"""Myo7a MIP with detected hair cells marked and the boundary drawn."""
|
| 517 |
+
rgb = np.stack([myo_mip_u8] * 3, axis=-1).copy()
|
| 518 |
+
ny, nx = myo_mip_u8.shape
|
| 519 |
+
b = None
|
| 520 |
+
if boundary_frac is not None:
|
| 521 |
+
if axis.upper() == "Y":
|
| 522 |
+
b = min(max(int(round(boundary_frac * ny)), 0), ny - 1)
|
| 523 |
+
else:
|
| 524 |
+
b = min(max(int(round(boundary_frac * nx)), 0), nx - 1)
|
| 525 |
+
for (y, x) in centroids.astype(int):
|
| 526 |
+
# colour by region if we know the split, else neutral green
|
| 527 |
+
col = (0, 255, 0)
|
| 528 |
+
if b is not None and ihc_side is not None:
|
| 529 |
+
on_low = (y <= b) if axis.upper() == "Y" else (x <= b)
|
| 530 |
+
is_ihc = (on_low and ihc_side == "low") or \
|
| 531 |
+
(not on_low and ihc_side == "high")
|
| 532 |
+
col = (0, 220, 255) if is_ihc else (255, 60, 200)
|
| 533 |
+
ys, xs = slice(max(0, y - 3), y + 4), slice(max(0, x - 3), x + 4)
|
| 534 |
+
rgb[ys, xs] = col
|
| 535 |
+
if b is not None:
|
| 536 |
+
if axis.upper() == "Y":
|
| 537 |
+
rgb[b, :] = (255, 255, 0)
|
| 538 |
+
else:
|
| 539 |
+
rgb[:, b] = (255, 255, 0)
|
| 540 |
+
return rgb
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
# --------------------------------------------------------------------------- #
|
| 544 |
+
# Quantification
|
| 545 |
+
# --------------------------------------------------------------------------- #
|
| 546 |
+
|
| 547 |
+
def _branch_point_count(skel: np.ndarray) -> int:
|
| 548 |
+
if skel.sum() == 0:
|
| 549 |
+
return 0
|
| 550 |
+
k = np.ones((3, 3, 3), int) if skel.ndim == 3 else np.ones((3, 3), int)
|
| 551 |
+
nb = ndi.convolve(skel.astype(np.uint8), k, mode="constant") - skel
|
| 552 |
+
return int((skel & (nb > 2)).sum())
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def compute_metrics(trace: TraceResult, region_name: str,
|
| 556 |
+
roi_yx: Optional[np.ndarray] = None,
|
| 557 |
+
min_fiber_um: float = 5.0,
|
| 558 |
+
hair_cell_centroids: Optional[np.ndarray] = None
|
| 559 |
+
) -> RegionMetrics:
|
| 560 |
+
"""Quantify the skeleton, optionally restricted to a 2D ROI (broadcast in Z).
|
| 561 |
+
|
| 562 |
+
If ``hair_cell_centroids`` is given, the number of Myo7a hair cells within
|
| 563 |
+
the region is also reported.
|
| 564 |
+
"""
|
| 565 |
+
dz, dy, dx = trace.voxel
|
| 566 |
+
skel = trace.skeleton
|
| 567 |
+
mask = trace.mask
|
| 568 |
+
if roi_yx is not None:
|
| 569 |
+
roi3d = np.broadcast_to(roi_yx, skel.shape)
|
| 570 |
+
skel = skel & roi3d
|
| 571 |
+
mask = mask & roi3d
|
| 572 |
+
|
| 573 |
+
m = RegionMetrics(region=region_name)
|
| 574 |
+
m.fov_area_um2 = float(roi_yx.sum() if roi_yx is not None
|
| 575 |
+
else mask.shape[1] * mask.shape[2]) * dx * dy
|
| 576 |
+
|
| 577 |
+
if hair_cell_centroids is not None:
|
| 578 |
+
if roi_yx is not None:
|
| 579 |
+
m.n_hair_cells = count_cells_in_roi(hair_cell_centroids, roi_yx)
|
| 580 |
+
else:
|
| 581 |
+
m.n_hair_cells = int(len(hair_cell_centroids))
|
| 582 |
+
|
| 583 |
+
foot = mask.max(0)
|
| 584 |
+
m.area_covered_um2 = float(foot.sum()) * dx * dy
|
| 585 |
+
m.pct_area_covered = (100.0 * m.area_covered_um2 / m.fov_area_um2
|
| 586 |
+
if m.fov_area_um2 else 0.0)
|
| 587 |
+
|
| 588 |
+
if skel.sum() < 2:
|
| 589 |
+
return m
|
| 590 |
+
|
| 591 |
+
m.n_branch_points = _branch_point_count(skel)
|
| 592 |
+
|
| 593 |
+
diam = 2.0 * trace.distance_um[skel]
|
| 594 |
+
if diam.size:
|
| 595 |
+
m.mean_diameter_um = float(diam.mean())
|
| 596 |
+
m.median_diameter_um = float(np.median(diam))
|
| 597 |
+
|
| 598 |
+
# Length and fiber count via skan (per connected skeleton component).
|
| 599 |
+
try:
|
| 600 |
+
S = Skeleton(skel, spacing=(dz, dy, dx))
|
| 601 |
+
df = summarize(S, separator="_")
|
| 602 |
+
comp_len = df.groupby("skeleton_id")["branch_distance"].sum()
|
| 603 |
+
kept = comp_len[comp_len >= min_fiber_um]
|
| 604 |
+
m.n_fibers = int(kept.size)
|
| 605 |
+
m.total_length_um = float(kept.sum())
|
| 606 |
+
except Exception:
|
| 607 |
+
# Fallback: label components, approximate length by voxel count.
|
| 608 |
+
lbl, n = ndi.label(skel, structure=np.ones((3, 3, 3)))
|
| 609 |
+
m.n_fibers = int(n)
|
| 610 |
+
m.total_length_um = float(skel.sum()) * np.mean([dz, dy, dx])
|
| 611 |
+
return m
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
# --------------------------------------------------------------------------- #
|
| 615 |
+
# Rendering
|
| 616 |
+
# --------------------------------------------------------------------------- #
|
| 617 |
+
|
| 618 |
+
def skeleton_image(skel: np.ndarray, dilate: int = 1) -> np.ndarray:
|
| 619 |
+
"""White skeleton on black background (2D uint8), as a MIP over Z."""
|
| 620 |
+
flat = skel.max(0) if skel.ndim == 3 else skel
|
| 621 |
+
if dilate:
|
| 622 |
+
flat = ndi.binary_dilation(flat, iterations=dilate)
|
| 623 |
+
return (flat * 255).astype(np.uint8)
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def region_overlay(skel: np.ndarray, ihc_roi: np.ndarray, ohc_roi: np.ndarray,
|
| 627 |
+
boundary_frac: float, axis: str = "Y",
|
| 628 |
+
dilate: int = 1) -> np.ndarray:
|
| 629 |
+
"""Colour-coded RGB preview: IHC fibers cyan, OHC fibers magenta,
|
| 630 |
+
with the boundary line drawn in yellow."""
|
| 631 |
+
flat = skel.max(0) if skel.ndim == 3 else skel
|
| 632 |
+
if dilate:
|
| 633 |
+
flat = ndi.binary_dilation(flat, iterations=dilate)
|
| 634 |
+
ny, nx = flat.shape
|
| 635 |
+
rgb = np.zeros((ny, nx, 3), np.uint8)
|
| 636 |
+
ihc_pix = flat & ihc_roi
|
| 637 |
+
ohc_pix = flat & ohc_roi
|
| 638 |
+
rgb[ihc_pix] = (0, 220, 255) # cyan = IHC
|
| 639 |
+
rgb[ohc_pix] = (255, 60, 200) # magenta = OHC
|
| 640 |
+
if axis.upper() == "Y":
|
| 641 |
+
b = int(round(np.clip(boundary_frac, 0, 1) * ny))
|
| 642 |
+
b = min(max(b, 0), ny - 1)
|
| 643 |
+
rgb[b, :] = (255, 255, 0)
|
| 644 |
+
else:
|
| 645 |
+
b = int(round(np.clip(boundary_frac, 0, 1) * nx))
|
| 646 |
+
b = min(max(b, 0), nx - 1)
|
| 647 |
+
rgb[:, b] = (255, 255, 0)
|
| 648 |
+
return rgb
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def channel_preview(vol: np.ndarray) -> np.ndarray:
|
| 652 |
+
"""Contrast-stretched MIP of a channel for display (uint8)."""
|
| 653 |
+
mip = vol.max(0).astype(np.float32)
|
| 654 |
+
lo, hi = np.percentile(mip, 1), np.percentile(mip, 99.5)
|
| 655 |
+
return (np.clip((mip - lo) / (hi - lo + 1e-6), 0, 1) * 255).astype(np.uint8)
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0
|
| 2 |
+
numpy>=1.26
|
| 3 |
+
scipy>=1.11
|
| 4 |
+
scikit-image>=0.22
|
| 5 |
+
skan>=0.12
|
| 6 |
+
pandas>=2.0
|
| 7 |
+
openpyxl>=3.1
|
| 8 |
+
tifffile>=2024.1.1
|
| 9 |
+
aicspylibczi>=3.1.1
|
| 10 |
+
imagecodecs>=2024.1.1
|
| 11 |
+
# Optional — enables the Cellpose hair-cell detection assist. Pulls in torch
|
| 12 |
+
# (~GB) and downloads a ~1.15 GB model on first use; a GPU Space is recommended.
|
| 13 |
+
# If omitted, the app falls back to a lightweight watershed detector.
|
| 14 |
+
cellpose>=4.0
|
| 15 |
+
|