stevafernandes commited on
Commit
d487db0
·
verified ·
1 Parent(s): 9f785a9

Upload 6 files

Browse files
README.md CHANGED
@@ -1,13 +1,76 @@
1
  ---
2
- title: Iman
3
- emoji: 🌍
4
- colorFrom: yellow
5
  colorTo: purple
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
+ 3. Computes, per region (Whole field / IHC / OHC):
45
+ - **Number of fibers** (continuous skeleton components above a minimum length)
46
+ - **Thickness / diameter** (from the 3D distance transform)
47
+ - **Length** (µm, spacing-aware)
48
+ - **Branching** (number of branch points)
49
+ - **Area covered** within the field of view (µm² and % of FOV)
50
+
51
+ ## Output
52
+
53
+ - A **black-background image** of the traced neurons in **white** (skeletonised
54
+ trace), plus a colour-coded IHC/OHC overlay.
55
+ - An **Excel workbook** with all quantification, organized by frequency region,
56
+ with IHC and OHC fibers reported separately (tidy "Per region" sheet plus
57
+ per-metric frequency × region summary sheets).
58
+
59
+ The **Batch** tab processes several stacks at once (e.g. all frequency regions
60
+ of one cochlea) and compiles one Excel workbook plus a ZIP of skeleton images.
61
+
62
+ ## Notes on method
63
+
64
+ Confocal images of the organ of Corti are dense, so fully separating every
65
+ individual axon is inherently ambiguous. This tool traces the network
66
+ continuously and reports metrics **per region surrounding the IHCs / OHCs**,
67
+ with a human-in-the-loop boundary for reliable IHC vs OHC assignment. The
68
+ `sensitivity` control scales the segmentation threshold to capture more or fewer
69
+ thin fibers.
70
+
71
+ ## Local run
72
+
73
+ ```bash
74
+ pip install -r requirements.txt
75
+ python app.py
76
+ ```
__pycache__/app.cpython-313.pyc ADDED
Binary file (21.5 kB). View file
 
__pycache__/processing.cpython-313.pyc ADDED
Binary file (23.5 kB). View file
 
app.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ "Total length (um)", "Mean diameter (um)", "Median diameter (um)",
32
+ "Branch points", "Area covered (um^2)", "FOV area (um^2)",
33
+ "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", "Total length (um)",
77
+ "Mean diameter (um)", "Branch points",
78
+ "Area covered (um^2)"]:
79
+ piv = sub.pivot_table(index="Frequency region",
80
+ columns="Region", values=metric,
81
+ aggfunc="mean")
82
+ sheet = metric.split(" (")[0][:28]
83
+ piv.to_excel(xl, sheet_name=f"{sheet}")
84
+ return df
85
+
86
+
87
+ # --------------------------------------------------------------------------- #
88
+ # Single-image interactive flow
89
+ # --------------------------------------------------------------------------- #
90
+
91
+ def load_and_preview(file_obj, dz, dy, dx):
92
+ if file_obj is None:
93
+ return (None, gr.update(), gr.update(), gr.update(),
94
+ gr.update(), None, None, "Please upload a CZI or TIFF file.")
95
+ try:
96
+ img = P.load_image(file_obj, dz=float(dz), dy=float(dy), dx=float(dx))
97
+ except Exception as e:
98
+ return (None, gr.update(), gr.update(), gr.update(),
99
+ gr.update(), None, None,
100
+ f"❌ Failed to load file:\n{e}\n{traceback.format_exc()}")
101
+
102
+ nf, myo = P.guess_channels(img)
103
+ choices = _channel_choices(img)
104
+ freq = P.detect_frequency(img.source_name)
105
+ bfrac = P.suggest_boundary(img.data[myo])
106
+
107
+ nf_prev = P.channel_preview(img.data[nf])
108
+ myo_prev = _draw_boundary_on(P.channel_preview(img.data[myo]), bfrac, "Y")
109
+
110
+ dz_, dy_, dx_ = img.voxel
111
+ status = (f"✅ Loaded **{img.source_name}** — shape "
112
+ f"{img.data.shape} (C,Z,Y,X)\n\n"
113
+ f"Voxel size: dz={dz_:.3f}, dy={dy_:.4f}, dx={dx_:.4f} µm. "
114
+ f"Detected frequency region: **{freq}**.\n\n"
115
+ f"Auto-picked Neurofilament = Ch {nf}, Myo7a = Ch {myo}. "
116
+ f"Adjust below if needed, then press **Run analysis**.")
117
+ return (img,
118
+ gr.update(choices=choices, value=nf),
119
+ gr.update(choices=choices, value=myo),
120
+ gr.update(value=freq),
121
+ gr.update(value=round(bfrac, 3)),
122
+ nf_prev, myo_prev, status)
123
+
124
+
125
+ def refresh_boundary_preview(img, myo_idx, boundary, axis):
126
+ if img is None or myo_idx is None:
127
+ return None
128
+ myo_prev = P.channel_preview(img.data[int(myo_idx)])
129
+ return _draw_boundary_on(myo_prev, float(boundary), axis)
130
+
131
+
132
+ def run_single(img, nf_idx, myo_idx, freq, axis, ihc_side, boundary,
133
+ sensitivity, min_fiber):
134
+ if img is None:
135
+ return None, None, None, None, None, "Please load an image first."
136
+ if nf_idx is None:
137
+ return None, None, None, None, None, "Please select the Neurofilament channel."
138
+ try:
139
+ nf_idx, myo_idx = int(nf_idx), int(myo_idx)
140
+ trace = P.trace_neurites(img.data[nf_idx], img.voxel,
141
+ sensitivity=float(sensitivity))
142
+ shape_yx = img.data[nf_idx].shape[1:]
143
+ side = "low" if ihc_side.startswith("Low") else "high"
144
+ ihc_roi, ohc_roi = P.make_region_masks(shape_yx, float(boundary),
145
+ ihc_side=side, axis=axis)
146
+
147
+ whole = P.compute_metrics(trace, "Whole field",
148
+ min_fiber_um=float(min_fiber))
149
+ m_ihc = P.compute_metrics(trace, "IHC region", ihc_roi,
150
+ min_fiber_um=float(min_fiber))
151
+ m_ohc = P.compute_metrics(trace, "OHC region", ohc_roi,
152
+ min_fiber_um=float(min_fiber))
153
+
154
+ skel_img = P.skeleton_image(trace.skeleton)
155
+ region_img = P.region_overlay(trace.skeleton, ihc_roi, ohc_roi,
156
+ float(boundary), axis)
157
+ myo_img = _draw_boundary_on(P.channel_preview(img.data[myo_idx]),
158
+ float(boundary), axis)
159
+
160
+ stem = os.path.splitext(img.source_name)[0]
161
+ skel_path = _save_png(skel_img, f"{stem}_skeleton.png")
162
+
163
+ rows = []
164
+ for m in (whole, m_ihc, m_ohc):
165
+ r = m.as_row()
166
+ r = {"File": img.source_name, "Frequency region": freq, **r}
167
+ rows.append(r)
168
+ df = pd.DataFrame(rows, columns=METRIC_COLS)
169
+ xl_path = os.path.join(OUT_DIR, f"{stem}_quantification.xlsx")
170
+ _build_excel(rows, xl_path)
171
+
172
+ status = (f"✅ Done. Traced {int(trace.skeleton.sum())} skeleton voxels. "
173
+ f"IHC={m_ihc.n_fibers} fibers / {m_ihc.total_length_um:.0f} µm, "
174
+ f"OHC={m_ohc.n_fibers} fibers / {m_ohc.total_length_um:.0f} µm.")
175
+ # Return skeleton path also as downloadable file
176
+ return (skel_img, region_img, myo_img, df,
177
+ [skel_path, xl_path], status)
178
+ except Exception as e:
179
+ return (None, None, None, None, None,
180
+ f"❌ Error:\n{e}\n{traceback.format_exc()}")
181
+
182
+
183
+ # --------------------------------------------------------------------------- #
184
+ # Batch flow
185
+ # --------------------------------------------------------------------------- #
186
+
187
+ def run_batch(files, axis, ihc_side, sensitivity, min_fiber, dz, dy, dx,
188
+ progress=gr.Progress()):
189
+ if not files:
190
+ return None, None, None, "Please upload one or more files."
191
+ side = "low" if ihc_side.startswith("Low") else "high"
192
+ all_rows, gallery, skel_paths = [], [], []
193
+ log = []
194
+ for f in progress.tqdm(files, desc="Processing"):
195
+ path = f if isinstance(f, str) else f.name
196
+ name = os.path.basename(path)
197
+ try:
198
+ img = P.load_image(path, dz=float(dz), dy=float(dy), dx=float(dx))
199
+ nf, myo = P.guess_channels(img)
200
+ freq = P.detect_frequency(name)
201
+ trace = P.trace_neurites(img.data[nf], img.voxel,
202
+ sensitivity=float(sensitivity))
203
+ bfrac = P.suggest_boundary(img.data[myo])
204
+ shape_yx = img.data[nf].shape[1:]
205
+ ihc_roi, ohc_roi = P.make_region_masks(shape_yx, bfrac,
206
+ ihc_side=side, axis=axis)
207
+ for m in (P.compute_metrics(trace, "Whole field",
208
+ min_fiber_um=float(min_fiber)),
209
+ P.compute_metrics(trace, "IHC region", ihc_roi,
210
+ min_fiber_um=float(min_fiber)),
211
+ P.compute_metrics(trace, "OHC region", ohc_roi,
212
+ min_fiber_um=float(min_fiber))):
213
+ all_rows.append({"File": name, "Frequency region": freq,
214
+ **m.as_row()})
215
+ skel_img = P.skeleton_image(trace.skeleton)
216
+ stem = os.path.splitext(name)[0]
217
+ sp = _save_png(skel_img, f"{stem}_skeleton.png")
218
+ skel_paths.append(sp)
219
+ gallery.append((skel_img, f"{name} ({freq})"))
220
+ log.append(f"✅ {name}: {freq}")
221
+ except Exception as e:
222
+ log.append(f"❌ {name}: {e}")
223
+
224
+ if not all_rows:
225
+ return None, None, gallery, "No files processed.\n" + "\n".join(log)
226
+
227
+ xl_path = os.path.join(OUT_DIR, "batch_quantification.xlsx")
228
+ df = _build_excel(all_rows, xl_path)
229
+
230
+ zip_path = os.path.join(OUT_DIR, "batch_skeletons.zip")
231
+ with zipfile.ZipFile(zip_path, "w") as z:
232
+ for sp in skel_paths:
233
+ z.write(sp, os.path.basename(sp))
234
+ z.write(xl_path, os.path.basename(xl_path))
235
+
236
+ return df, [xl_path, zip_path], gallery, "\n".join(log)
237
+
238
+
239
+ # --------------------------------------------------------------------------- #
240
+ # UI
241
+ # --------------------------------------------------------------------------- #
242
+
243
+ INTRO = """
244
+ # 🧠 Cochlear Neurofilament Tracer
245
+
246
+ Trace auditory-nerve fibers in confocal z-stacks and quantify them **per
247
+ frequency region**, separating **IHC-innervating** from **OHC-innervating**
248
+ fibers using the Myo7a hair-cell channel.
249
+
250
+ **Channels expected:** *Neurofilament* (traces the neuron) and *Myo7a* (hair
251
+ cells — reference to split IHC vs OHC). IHCs form a single row, OHCs form three
252
+ rows, so the Myo7a band is used to place the IHC/OHC boundary — which you can
253
+ fine-tune by hand.
254
+
255
+ **Input:** Zeiss `.czi` z-stacks or generic `.tif/.tiff` stacks.
256
+ """
257
+
258
+ with gr.Blocks(title="Cochlear Neurofilament Tracer", theme=gr.themes.Soft()) as demo:
259
+ gr.Markdown(INTRO)
260
+
261
+ with gr.Tab("Single image (interactive)"):
262
+ img_state = gr.State()
263
+ with gr.Row():
264
+ with gr.Column(scale=1):
265
+ file_in = gr.File(label="Upload CZI or TIFF",
266
+ file_types=[".czi", ".tif", ".tiff"],
267
+ type="filepath")
268
+ with gr.Accordion("Voxel size (µm) — used for TIFF; CZI reads "
269
+ "its own", open=False):
270
+ dz_in = gr.Number(0.35, label="dz (µm/plane)")
271
+ dy_in = gr.Number(0.0895, label="dy (µm/px)")
272
+ dx_in = gr.Number(0.0895, label="dx (µm/px)")
273
+ load_btn = gr.Button("① Load & preview", variant="secondary")
274
+
275
+ nf_dd = gr.Dropdown(label="Neurofilament channel", choices=[])
276
+ myo_dd = gr.Dropdown(label="Myo7a channel", choices=[])
277
+ freq_dd = gr.Dropdown(label="Frequency region",
278
+ choices=P.FREQ_CHOICES,
279
+ value="Other / unknown")
280
+
281
+ gr.Markdown("**IHC / OHC region split** (Myo7a-guided)")
282
+ axis_dd = gr.Radio(["Y", "X"], value="Y",
283
+ label="Split axis (Y = radial, usual)")
284
+ side_dd = gr.Radio(["Low side = IHC", "High side = IHC"],
285
+ value="Low side = IHC",
286
+ label="Which side is IHC?")
287
+ boundary_sl = gr.Slider(0.0, 1.0, value=0.5, step=0.005,
288
+ label="Boundary position (fraction "
289
+ "along split axis)")
290
+
291
+ gr.Markdown("**Tracing**")
292
+ sens_sl = gr.Slider(0.5, 1.5, value=1.0, step=0.05,
293
+ label="Sensitivity (↑ = capture more/thinner "
294
+ "fibers)")
295
+ minfib_sl = gr.Slider(0.0, 20.0, value=5.0, step=0.5,
296
+ label="Min fiber length to count (µm)")
297
+ run_btn = gr.Button("② Run analysis", variant="primary")
298
+
299
+ with gr.Column(scale=2):
300
+ status = gr.Markdown()
301
+ with gr.Row():
302
+ nf_prev = gr.Image(label="Neurofilament (MIP)",
303
+ height=220)
304
+ myo_prev = gr.Image(label="Myo7a (MIP) + boundary",
305
+ height=220)
306
+ skel_out = gr.Image(label="Traced neurons — white on black",
307
+ height=300)
308
+ region_out = gr.Image(label="Region overlay (cyan = IHC, "
309
+ "magenta = OHC)", height=300)
310
+ table = gr.Dataframe(label="Quantification", wrap=True)
311
+ files_out = gr.Files(label="Downloads (skeleton PNG + Excel)")
312
+
313
+ load_btn.click(load_and_preview,
314
+ [file_in, dz_in, dy_in, dx_in],
315
+ [img_state, nf_dd, myo_dd, freq_dd, boundary_sl,
316
+ nf_prev, myo_prev, status])
317
+ # Live boundary preview
318
+ for comp in (boundary_sl, myo_dd, axis_dd):
319
+ comp.change(refresh_boundary_preview,
320
+ [img_state, myo_dd, boundary_sl, axis_dd], myo_prev)
321
+ run_btn.click(run_single,
322
+ [img_state, nf_dd, myo_dd, freq_dd, axis_dd, side_dd,
323
+ boundary_sl, sens_sl, minfib_sl],
324
+ [skel_out, region_out, myo_prev, table, files_out, status])
325
+
326
+ with gr.Tab("Batch (multiple images)"):
327
+ gr.Markdown(
328
+ "Upload several z-stacks (e.g. all frequency regions of one "
329
+ "cochlea). Each is auto-traced with an auto-placed IHC/OHC "
330
+ "boundary, and results are combined into one Excel workbook "
331
+ "organized by frequency region.")
332
+ with gr.Row():
333
+ with gr.Column(scale=1):
334
+ batch_files = gr.File(label="Upload CZI/TIFF files",
335
+ file_count="multiple",
336
+ file_types=[".czi", ".tif", ".tiff"],
337
+ type="filepath")
338
+ b_axis = gr.Radio(["Y", "X"], value="Y", label="Split axis")
339
+ b_side = gr.Radio(["Low side = IHC", "High side = IHC"],
340
+ value="Low side = IHC",
341
+ label="Which side is IHC?")
342
+ b_sens = gr.Slider(0.5, 1.5, value=1.0, step=0.05,
343
+ label="Sensitivity")
344
+ b_minfib = gr.Slider(0.0, 20.0, value=5.0, step=0.5,
345
+ label="Min fiber length (µm)")
346
+ with gr.Accordion("Voxel size (µm) for TIFF", open=False):
347
+ b_dz = gr.Number(0.35, label="dz")
348
+ b_dy = gr.Number(0.0895, label="dy")
349
+ b_dx = gr.Number(0.0895, label="dx")
350
+ batch_btn = gr.Button("Run batch", variant="primary")
351
+ with gr.Column(scale=2):
352
+ batch_log = gr.Textbox(label="Log", lines=6)
353
+ batch_table = gr.Dataframe(label="Combined quantification",
354
+ wrap=True)
355
+ batch_files_out = gr.Files(label="Downloads (Excel + ZIP)")
356
+ batch_gallery = gr.Gallery(label="Skeleton traces",
357
+ columns=3, height=400)
358
+ batch_btn.click(run_batch,
359
+ [batch_files, b_axis, b_side, b_sens, b_minfib,
360
+ b_dz, b_dy, b_dx],
361
+ [batch_table, batch_files_out, batch_gallery, batch_log])
362
+
363
+ gr.Markdown(
364
+ "---\n*Method:* the Neurofilament channel is smoothed, thresholded "
365
+ "(Otsu, scaled by the sensitivity control) and skeletonised in 3D; "
366
+ "length, diameter (from the 3D distance transform), branch points and "
367
+ "footprint area are measured with physical voxel spacing. Each fiber is "
368
+ "a connected skeleton component ≥ the minimum length. The Myo7a band "
369
+ "defines the IHC/OHC boundary, and metrics are reported for each "
370
+ "region.")
371
+
372
+ if __name__ == "__main__":
373
+ demo.launch()
processing.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Data containers
30
+ # --------------------------------------------------------------------------- #
31
+
32
+ FREQ_CHOICES = ["8kHz", "16kHz", "22kHz", "32kHz", "64kHz", "Other / unknown"]
33
+
34
+
35
+ @dataclass
36
+ class LoadedImage:
37
+ """A loaded multi-channel z-stack."""
38
+
39
+ data: np.ndarray # (C, Z, Y, X) float32
40
+ channels: list # list of dicts: {name, dye, color}
41
+ voxel: tuple # (dz, dy, dx) in microns
42
+ source_name: str = ""
43
+
44
+ @property
45
+ def n_channels(self) -> int:
46
+ return self.data.shape[0]
47
+
48
+
49
+ @dataclass
50
+ class RegionMetrics:
51
+ """Quantification for one region (whole field, IHC region or OHC region)."""
52
+
53
+ region: str = ""
54
+ n_fibers: int = 0
55
+ total_length_um: float = 0.0
56
+ mean_diameter_um: float = 0.0
57
+ median_diameter_um: float = 0.0
58
+ n_branch_points: int = 0
59
+ area_covered_um2: float = 0.0
60
+ fov_area_um2: float = 0.0
61
+ pct_area_covered: float = 0.0
62
+
63
+ def as_row(self) -> dict:
64
+ return {
65
+ "Region": self.region,
66
+ "Number of fibers": self.n_fibers,
67
+ "Total length (um)": round(self.total_length_um, 2),
68
+ "Mean diameter (um)": round(self.mean_diameter_um, 3),
69
+ "Median diameter (um)": round(self.median_diameter_um, 3),
70
+ "Branch points": self.n_branch_points,
71
+ "Area covered (um^2)": round(self.area_covered_um2, 2),
72
+ "FOV area (um^2)": round(self.fov_area_um2, 2),
73
+ "Area covered (% of FOV)": round(self.pct_area_covered, 2),
74
+ }
75
+
76
+
77
+ @dataclass
78
+ class TraceResult:
79
+ """Everything produced by tracing one image."""
80
+
81
+ mask: np.ndarray # 3D bool
82
+ skeleton: np.ndarray # 3D bool
83
+ distance_um: np.ndarray # 3D float, EDT in microns
84
+ voxel: tuple
85
+ metrics: dict = field(default_factory=dict) # region name -> RegionMetrics
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Loading
90
+ # --------------------------------------------------------------------------- #
91
+
92
+ def detect_frequency(filename: str) -> str:
93
+ """Guess the frequency-region label from a filename (e.g. '16kHz')."""
94
+ m = re.search(r"(\d+)\s*k\s*hz", filename, re.IGNORECASE)
95
+ if m:
96
+ label = f"{int(m.group(1))}kHz"
97
+ if label in FREQ_CHOICES:
98
+ return label
99
+ return "Other / unknown"
100
+
101
+
102
+ def _czi_channel_meta(czi) -> list:
103
+ """Extract per-channel name/dye/color from CZI metadata (best effort)."""
104
+ meta = czi.meta
105
+ seen = {}
106
+ order = []
107
+ for ch in meta.iter("Channel"):
108
+ name = ch.get("Name")
109
+ if not name:
110
+ continue
111
+ dye = ch.findtext("DyeName") or ch.findtext("Fluor")
112
+ color = ch.findtext("Color")
113
+ ex = ch.findtext("ExcitationWavelength")
114
+ if name not in seen:
115
+ seen[name] = {"name": name, "dye": None, "color": None, "ex": None}
116
+ order.append(name)
117
+ rec = seen[name]
118
+ rec["dye"] = rec["dye"] or dye
119
+ rec["color"] = rec["color"] or color
120
+ rec["ex"] = rec["ex"] or ex
121
+ return [seen[n] for n in order]
122
+
123
+
124
+ def _czi_voxel(czi) -> tuple:
125
+ """Return (dz, dy, dx) in microns from CZI scaling metadata."""
126
+ scale = {}
127
+ for d in czi.meta.iter("Distance"):
128
+ idv = d.get("Id")
129
+ val = d.findtext("Value")
130
+ if idv in ("X", "Y", "Z") and val:
131
+ scale[idv] = float(val) * 1e6 # metres -> microns
132
+ dx = scale.get("X", 0.0895)
133
+ dy = scale.get("Y", dx)
134
+ dz = scale.get("Z", 0.35)
135
+ return (dz, dy, dx)
136
+
137
+
138
+ def load_czi(path: str) -> LoadedImage:
139
+ from aicspylibczi import CziFile
140
+
141
+ czi = CziFile(path)
142
+ img, shp = czi.read_image()
143
+ dims = [d for d, _ in shp]
144
+ arr = np.asarray(img)
145
+ # collapse everything except C, Z, Y, X
146
+ # find axis indices
147
+ idx = {d: i for i, d in enumerate(dims)}
148
+ # move to C,Z,Y,X ordering, squeezing singletons (B,V,T,...)
149
+ keep = ["C", "Z", "Y", "X"]
150
+ order = [idx[k] for k in keep if k in idx]
151
+ other = [i for i in range(arr.ndim) if i not in order]
152
+ arr = np.transpose(arr, other + order)
153
+ arr = arr.reshape((-1,) + arr.shape[len(other):]) if other else arr
154
+ # after reshape leading axis is product of others -> take first
155
+ if other:
156
+ arr = arr[0]
157
+ # arr now (C,Z,Y,X) or missing Z
158
+ if "Z" not in idx:
159
+ arr = arr[:, None]
160
+ arr = arr.astype(np.float32)
161
+ channels = _czi_channel_meta(czi)
162
+ if len(channels) != arr.shape[0]:
163
+ channels = [{"name": f"Channel {i}", "dye": None, "color": None}
164
+ for i in range(arr.shape[0])]
165
+ return LoadedImage(arr, channels, _czi_voxel(czi), os.path.basename(path))
166
+
167
+
168
+ def load_tiff(path: str, dz=0.35, dy=0.0895, dx=0.0895) -> LoadedImage:
169
+ import tifffile
170
+
171
+ arr = tifffile.imread(path)
172
+ arr = np.squeeze(arr)
173
+ # Heuristic to reach (C, Z, Y, X). The two largest axes are Y, X.
174
+ if arr.ndim == 2: # (Y, X) single channel, single plane
175
+ arr = arr[None, None]
176
+ elif arr.ndim == 3:
177
+ # Could be (Z,Y,X) single channel or (C,Y,X). Assume small first axis = C
178
+ if arr.shape[0] <= 5:
179
+ arr = arr[:, None] # (C, 1, Y, X)
180
+ else:
181
+ arr = arr[None] # (1, Z, Y, X)
182
+ elif arr.ndim == 4:
183
+ # find the two largest axes -> Y, X; of the remaining two the smaller = C
184
+ yx = sorted(range(4), key=lambda a: arr.shape[a])[-2:]
185
+ rest = [a for a in range(4) if a not in yx]
186
+ c_axis = min(rest, key=lambda a: arr.shape[a])
187
+ z_axis = [a for a in rest if a != c_axis][0]
188
+ arr = np.transpose(arr, (c_axis, z_axis, *sorted(yx)))
189
+ else:
190
+ raise ValueError(f"Unsupported TIFF with {arr.ndim} dimensions")
191
+ arr = arr.astype(np.float32)
192
+ channels = [{"name": f"Channel {i}", "dye": None, "color": None}
193
+ for i in range(arr.shape[0])]
194
+ return LoadedImage(arr, channels, (dz, dy, dx), os.path.basename(path))
195
+
196
+
197
+ def load_image(path: str, dz=0.35, dy=0.0895, dx=0.0895) -> LoadedImage:
198
+ ext = os.path.splitext(path)[1].lower()
199
+ if ext == ".czi":
200
+ return load_czi(path)
201
+ if ext in (".tif", ".tiff"):
202
+ return load_tiff(path, dz, dy, dx)
203
+ raise ValueError(f"Unsupported file type: {ext}")
204
+
205
+
206
+ # --------------------------------------------------------------------------- #
207
+ # Channel identification
208
+ # --------------------------------------------------------------------------- #
209
+
210
+ # Wavelength-based hints: neurofilament here is Alexa-555 (red/green range),
211
+ # Myo7a is Alexa-405 (blue). Transmitted-light PMT channels have no dye.
212
+ def guess_channels(img: LoadedImage) -> tuple:
213
+ """Best-effort (neurofilament_index, myo7a_index) from metadata + content."""
214
+ nf_idx, myo_idx = None, None
215
+ blue_like, red_like, plain = [], [], []
216
+ for i, ch in enumerate(img.channels):
217
+ dye = (ch.get("dye") or "").lower()
218
+ color = (ch.get("color") or "").upper()
219
+ ex = ch.get("ex")
220
+ ex = float(ex) if ex else None
221
+ if "405" in dye or (ex and ex < 430) or color == "#0000FF":
222
+ blue_like.append(i)
223
+ elif dye and dye not in ("", "none"):
224
+ red_like.append(i)
225
+ else:
226
+ plain.append(i) # e.g. transmitted-light PMT
227
+ if blue_like:
228
+ myo_idx = blue_like[0]
229
+ if red_like:
230
+ nf_idx = red_like[0]
231
+
232
+ # Fall back to image content when metadata is missing (e.g. plain TIFF).
233
+ # Fluorescence channels have a mostly-dark background; transmitted-light
234
+ # (brightfield) channels fill the frame, so we skip those. We cannot
235
+ # reliably tell fibers from blobs automatically, so we default NF to the
236
+ # first fluorescent channel and Myo7a to the last — the user confirms
237
+ # via the channel previews in the UI.
238
+ if nf_idx is None or myo_idx is None:
239
+ fluo = [i for i in range(img.n_channels)
240
+ if _dark_fraction(img.data[i]) >= 0.2]
241
+ if not fluo:
242
+ fluo = list(range(img.n_channels))
243
+ if nf_idx is None:
244
+ nf_idx = fluo[0]
245
+ if myo_idx is None or myo_idx == nf_idx:
246
+ myo_idx = fluo[-1] if fluo[-1] != nf_idx else fluo[0]
247
+ return nf_idx, myo_idx
248
+
249
+
250
+ def _dark_fraction(vol: np.ndarray) -> float:
251
+ """Fraction of the (normalised) MIP that is near-black background."""
252
+ mip = vol.max(0).astype(np.float32)
253
+ mip = (mip - mip.min()) / (np.ptp(mip) + 1e-6)
254
+ return float((mip < 0.15).mean())
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # Neurofilament tracing
259
+ # --------------------------------------------------------------------------- #
260
+
261
+ def _threshold_volume(vol: np.ndarray, sensitivity: float) -> np.ndarray:
262
+ """Smooth + Otsu threshold. `sensitivity` (0.5-1.5) scales the threshold;
263
+ higher sensitivity -> lower threshold -> more fibers captured."""
264
+ lo, hi = np.percentile(vol, 1), np.percentile(vol, 99.8)
265
+ norm = np.clip((vol - lo) / (hi - lo + 1e-6), 0, 1)
266
+ sm = gaussian(norm, sigma=(0.6, 1.0, 1.0), preserve_range=True)
267
+ fg = sm[sm > sm.mean() * 0.3]
268
+ base = threshold_otsu(fg) if fg.size else sm.mean()
269
+ thr = base * (2.0 - sensitivity) # sensitivity 1.0 -> base
270
+ return sm > thr
271
+
272
+
273
+ def trace_neurites(nf_vol: np.ndarray, voxel: tuple,
274
+ sensitivity: float = 1.0,
275
+ min_object_vox: int = 64) -> TraceResult:
276
+ """Segment and skeletonise the neurofilament network in 3D."""
277
+ dz, dy, dx = voxel
278
+ mask = _threshold_volume(nf_vol, sensitivity)
279
+ mask = remove_small_objects(mask, min_object_vox)
280
+ mask = ndi.binary_closing(mask, iterations=1)
281
+ if mask.sum() == 0:
282
+ z = np.zeros_like(mask)
283
+ return TraceResult(mask, z, np.zeros(mask.shape, np.float32), voxel)
284
+ skel = skeletonize(mask)
285
+ dist = ndi.distance_transform_edt(mask, sampling=(dz, dy, dx)).astype(np.float32)
286
+ return TraceResult(mask, skel, dist, voxel)
287
+
288
+
289
+ # --------------------------------------------------------------------------- #
290
+ # Region definition (IHC vs OHC) from Myo7a
291
+ # --------------------------------------------------------------------------- #
292
+
293
+ def myo_band_profile(myo_vol: np.ndarray) -> np.ndarray:
294
+ """Row-wise (Y) intensity profile of the Myo7a hair-cell band."""
295
+ mip = myo_vol.max(0).astype(np.float32)
296
+ sm = gaussian(mip, 3, preserve_range=True)
297
+ return sm.sum(1)
298
+
299
+
300
+ def suggest_boundary(myo_vol: np.ndarray) -> float:
301
+ """Suggest an IHC/OHC boundary as a fraction (0-1) along the Y axis.
302
+
303
+ Places the boundary at the centre of the Myo7a hair-cell band, which sits
304
+ between the (single) IHC row and the (three) OHC rows in a well-oriented
305
+ organ-of-Corti image. Users can refine this manually.
306
+ """
307
+ prof = myo_band_profile(myo_vol)
308
+ if prof.sum() == 0:
309
+ return 0.5
310
+ ys = np.arange(prof.size)
311
+ centroid = float((ys * prof).sum() / prof.sum())
312
+ return centroid / prof.size
313
+
314
+
315
+ def make_region_masks(shape_yx: tuple, boundary_frac: float,
316
+ ihc_side: str = "low", axis: str = "Y") -> tuple:
317
+ """Return (ihc_roi, ohc_roi) boolean 2D masks split by a straight line.
318
+
319
+ axis="Y" splits along rows (radial axis, the usual case); axis="X" splits
320
+ along columns. ihc_side selects which side of the boundary is IHC.
321
+ """
322
+ ny, nx = shape_yx
323
+ low = np.zeros((ny, nx), bool)
324
+ if axis.upper() == "Y":
325
+ b = int(round(np.clip(boundary_frac, 0, 1) * ny))
326
+ low[:b] = True
327
+ else:
328
+ b = int(round(np.clip(boundary_frac, 0, 1) * nx))
329
+ low[:, :b] = True
330
+ ihc = low if ihc_side == "low" else ~low
331
+ return ihc, ~ihc
332
+
333
+
334
+ # --------------------------------------------------------------------------- #
335
+ # Quantification
336
+ # --------------------------------------------------------------------------- #
337
+
338
+ def _branch_point_count(skel: np.ndarray) -> int:
339
+ if skel.sum() == 0:
340
+ return 0
341
+ k = np.ones((3, 3, 3), int) if skel.ndim == 3 else np.ones((3, 3), int)
342
+ nb = ndi.convolve(skel.astype(np.uint8), k, mode="constant") - skel
343
+ return int((skel & (nb > 2)).sum())
344
+
345
+
346
+ def compute_metrics(trace: TraceResult, region_name: str,
347
+ roi_yx: Optional[np.ndarray] = None,
348
+ min_fiber_um: float = 5.0) -> RegionMetrics:
349
+ """Quantify the skeleton, optionally restricted to a 2D ROI (broadcast in Z)."""
350
+ dz, dy, dx = trace.voxel
351
+ skel = trace.skeleton
352
+ mask = trace.mask
353
+ if roi_yx is not None:
354
+ roi3d = np.broadcast_to(roi_yx, skel.shape)
355
+ skel = skel & roi3d
356
+ mask = mask & roi3d
357
+
358
+ m = RegionMetrics(region=region_name)
359
+ m.fov_area_um2 = float(roi_yx.sum() if roi_yx is not None
360
+ else mask.shape[1] * mask.shape[2]) * dx * dy
361
+
362
+ foot = mask.max(0)
363
+ m.area_covered_um2 = float(foot.sum()) * dx * dy
364
+ m.pct_area_covered = (100.0 * m.area_covered_um2 / m.fov_area_um2
365
+ if m.fov_area_um2 else 0.0)
366
+
367
+ if skel.sum() < 2:
368
+ return m
369
+
370
+ m.n_branch_points = _branch_point_count(skel)
371
+
372
+ diam = 2.0 * trace.distance_um[skel]
373
+ if diam.size:
374
+ m.mean_diameter_um = float(diam.mean())
375
+ m.median_diameter_um = float(np.median(diam))
376
+
377
+ # Length and fiber count via skan (per connected skeleton component).
378
+ try:
379
+ S = Skeleton(skel, spacing=(dz, dy, dx))
380
+ df = summarize(S, separator="_")
381
+ comp_len = df.groupby("skeleton_id")["branch_distance"].sum()
382
+ kept = comp_len[comp_len >= min_fiber_um]
383
+ m.n_fibers = int(kept.size)
384
+ m.total_length_um = float(kept.sum())
385
+ except Exception:
386
+ # Fallback: label components, approximate length by voxel count.
387
+ lbl, n = ndi.label(skel, structure=np.ones((3, 3, 3)))
388
+ m.n_fibers = int(n)
389
+ m.total_length_um = float(skel.sum()) * np.mean([dz, dy, dx])
390
+ return m
391
+
392
+
393
+ # --------------------------------------------------------------------------- #
394
+ # Rendering
395
+ # --------------------------------------------------------------------------- #
396
+
397
+ def skeleton_image(skel: np.ndarray, dilate: int = 1) -> np.ndarray:
398
+ """White skeleton on black background (2D uint8), as a MIP over Z."""
399
+ flat = skel.max(0) if skel.ndim == 3 else skel
400
+ if dilate:
401
+ flat = ndi.binary_dilation(flat, iterations=dilate)
402
+ return (flat * 255).astype(np.uint8)
403
+
404
+
405
+ def region_overlay(skel: np.ndarray, ihc_roi: np.ndarray, ohc_roi: np.ndarray,
406
+ boundary_frac: float, axis: str = "Y",
407
+ dilate: int = 1) -> np.ndarray:
408
+ """Colour-coded RGB preview: IHC fibers cyan, OHC fibers magenta,
409
+ with the boundary line drawn in yellow."""
410
+ flat = skel.max(0) if skel.ndim == 3 else skel
411
+ if dilate:
412
+ flat = ndi.binary_dilation(flat, iterations=dilate)
413
+ ny, nx = flat.shape
414
+ rgb = np.zeros((ny, nx, 3), np.uint8)
415
+ ihc_pix = flat & ihc_roi
416
+ ohc_pix = flat & ohc_roi
417
+ rgb[ihc_pix] = (0, 220, 255) # cyan = IHC
418
+ rgb[ohc_pix] = (255, 60, 200) # magenta = OHC
419
+ if axis.upper() == "Y":
420
+ b = int(round(np.clip(boundary_frac, 0, 1) * ny))
421
+ b = min(max(b, 0), ny - 1)
422
+ rgb[b, :] = (255, 255, 0)
423
+ else:
424
+ b = int(round(np.clip(boundary_frac, 0, 1) * nx))
425
+ b = min(max(b, 0), nx - 1)
426
+ rgb[:, b] = (255, 255, 0)
427
+ return rgb
428
+
429
+
430
+ def channel_preview(vol: np.ndarray) -> np.ndarray:
431
+ """Contrast-stretched MIP of a channel for display (uint8)."""
432
+ mip = vol.max(0).astype(np.float32)
433
+ lo, hi = np.percentile(mip, 1), np.percentile(mip, 99.5)
434
+ return (np.clip((mip - lo) / (hi - lo + 1e-6), 0, 1) * 255).astype(np.uint8)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
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