stevafernandes commited on
Commit
73a6a55
·
verified ·
1 Parent(s): ccfd786

Upload 6 files

Browse files
README.md CHANGED
@@ -1,13 +1,83 @@
1
  ---
2
- title: Iman1
3
- emoji: 😻
4
- colorFrom: gray
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+