regrender 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,702 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import polars as pl
7
+ from argclz import argument
8
+ from brainglobe_atlasapi import BrainGlobeAtlas
9
+ from neuralib.atlas.ccf.matrix import slice_transform_helper
10
+ from neuralib.atlas.util import ALLEN_CCF_10um_BREGMA
11
+
12
+ from regrender._app import RegionPicker, SliceReconstructOptions, printf
13
+ from regrender._core import (
14
+ boundary_mask,
15
+ ccf_mm_to_plane_point,
16
+ ccf_mm_to_voxel,
17
+ load_transform,
18
+ plane_point_to_ccf_mm,
19
+ read_oriented,
20
+ rotate,
21
+ shank_distances,
22
+ )
23
+
24
+ __all__ = ['ProbeOptions']
25
+
26
+ # superficial = dorsal (top of brain, small DV); deep = ventral. ProbeRenderCLI wants dorsal first.
27
+ MARKS = ('superficial', 'deep')
28
+ _POINT = {'superficial': 'dorsal', 'deep': 'ventral'}
29
+ _COLORS = ['red', 'salmon', 'darkred', 'cyan', 'yellow', 'magenta',
30
+ 'lime', 'green', 'blue', 'orange', 'white', 'black']
31
+ SHADER_STYLES = ['plastic', 'cartoon', 'metallic', 'shiny', 'glossy']
32
+ CAMERA_ANGLES = ['three_quarters', 'sagittal', 'sagittal2', 'frontal', 'top', 'top_side']
33
+
34
+
35
+ def _shank_table_html(pts: dict[tuple[int, str], tuple[float, float, float]],
36
+ src: dict[tuple[int, str], str] | None = None,
37
+ dists: dict[int, float] | None = None) -> str:
38
+ """HTML table of picked points; each shank number is a ``del:<n>`` link to remove it.
39
+ ``dists`` maps shank -> dorsal<->ventral distance (µm), shown once per shank (spanned rows)."""
40
+ if not pts:
41
+ return 'no points yet'
42
+ src = src or {}
43
+ dists = dists or {}
44
+ rows = ['<tr bgcolor="#3b4252">' + ''.join(
45
+ f'<th><font color="#88c0d0">&nbsp;{h}&nbsp;</font></th>'
46
+ for h in ('shank', 'dist', 'point', 'AP', 'DV', 'ML', 'source')) + '</tr>']
47
+ for si, shank in enumerate(sorted({s for s, _ in pts})):
48
+ bg = '#2e3440' if si % 2 else '#343b4a' # zebra by shank (both point rows share a shade)
49
+ present = [p for p in ('dorsal', 'ventral') if (shank, p) in pts]
50
+ for i, point in enumerate(present):
51
+ ap, dv, ml = pts[(shank, point)]
52
+ dist_cell = ''
53
+ if i == 0: # one distance cell per shank (next to shank), spanning its point rows
54
+ d = dists.get(shank)
55
+ txt = f'{d:.0f} µm' if d is not None else '—'
56
+ dist_cell = (f'<td align="right" rowspan="{len(present)}">'
57
+ f'<b><font color="#88c0d0">&nbsp;{txt}&nbsp;</font></b></td>')
58
+ rows.append(
59
+ f'<tr bgcolor="{bg}">'
60
+ f'<td align="center"><a href="del:{shank}"><font color="#88c0d0">{shank} ✕</font></a></td>'
61
+ f'{dist_cell}<td align="center">&nbsp;{point[0]}&nbsp;</td>'
62
+ f'<td align="right">&nbsp;{ap:.2f}&nbsp;</td>'
63
+ f'<td align="right">&nbsp;{dv:.2f}&nbsp;</td><td align="right">&nbsp;{ml:.2f}&nbsp;</td>'
64
+ f'<td>&nbsp;{src.get((shank, point), "")}&nbsp;</td></tr>')
65
+ return ('<table border=0 cellspacing=0 cellpadding=5>' + ''.join(rows)
66
+ + '</table><br><i>AP, DV, ML in mm · dist = dorsal↔ventral µm · '
67
+ 'click a shank ✕ to remove it</i>')
68
+
69
+
70
+ def _render_command(csv: Path, plane: str, shanks: list[int], shank_colors: dict[int, str],
71
+ region_colors: dict[str, str], depth: int | None, interval: int | None,
72
+ style: str = 'plastic', no_root: bool = False, hemisphere: str = 'both',
73
+ camera: str = CAMERA_ANGLES[0]) -> list[str]:
74
+ """``ProbeRenderCLI`` argv (after ``-m``): per-shank dye colors, optional track, region meshes."""
75
+ cmd = ['neuralib.atlas.brainrender.probe',
76
+ '--file', str(csv), '--plane-type', plane, '--style', style, '--hemisphere', hemisphere,
77
+ '--camera', camera,
78
+ '--probe-color', ','.join(shank_colors.get(s, 'red') for s in shanks)]
79
+ if no_root:
80
+ cmd.append('--no-root')
81
+ if depth is None:
82
+ cmd.append('--dye')
83
+ else:
84
+ cmd += ['--depth', str(depth)]
85
+ if interval is not None:
86
+ cmd += ['--interval', str(interval)]
87
+ if region_colors: # insertion order = render order; each region carries its own color
88
+ cmd += ['--region', ','.join(region_colors)]
89
+ cmd += ['--region-color', ','.join(region_colors.values())]
90
+ return cmd
91
+
92
+
93
+ class ProbeOptions(SliceReconstructOptions):
94
+ DESCRIPTION = ('Reconstruct probe shanks from dye labels on registered slices (napari). '
95
+ 'Pick superficial+deep per shank across serial sections, then render with brainrender.')
96
+
97
+ depth: int | None = argument(
98
+ '--depth', default=None,
99
+ help='implant depth (µm); if set, render adds the theoretical track, else dye-only')
100
+
101
+ interval: int | None = argument(
102
+ '--interval', default=None,
103
+ help='shank interval (µm) for a multi-shank theoretical track')
104
+
105
+ def run(self):
106
+ files = self._resolve_paths('probe/probe_shanks.csv')
107
+ self._launch_napari(files)
108
+
109
+ def _launch_napari(self, files: list[Path]):
110
+ import napari
111
+ from magicgui.widgets import (
112
+ CheckBox,
113
+ ComboBox,
114
+ Container,
115
+ Label,
116
+ PushButton,
117
+ SpinBox,
118
+ )
119
+ from napari.utils import Colormap
120
+
121
+ viewer = napari.Viewer(title='regrender probe')
122
+ viewer.text_overlay.visible = True
123
+ viewer.text_overlay.font_size = 18
124
+ viewer.text_overlay.color = 'yellow'
125
+
126
+ bg = BrainGlobeAtlas('allen_mouse_10um', check_latest=False)
127
+
128
+ # pts[(shank, 'dorsal'|'ventral')] = (AP, DV, ML) bregma-relative mm; crosses re-derived per slice
129
+ # src[(shank, 'dorsal'|'ventral')] = source slice stem the point was picked on
130
+ state: dict = {'pts': {}, 'src': {}, 'files': files, 'cursor': 0, 'plane': None,
131
+ 'view': None, 'plane_off': None, 'name': None, 'ann_img': None, 'hover_id': None,
132
+ 'mode': 'single', 'tiles': [], 'grid': None, 'warped': None}
133
+
134
+ warped_layer = None # created on first load (re-created to switch grayscale<->RGB safely)
135
+ bound_layer = viewer.add_image(np.zeros((10, 10)), name='boundaries',
136
+ colormap='red', blending='additive', opacity=0.7)
137
+ hcmap = Colormap([[0, 0, 0], [0.3, 0.6, 1.0]], name='highlight') # region-under-cursor fill
138
+ highlight_layer = viewer.add_image(np.zeros((10, 10), dtype=float), name='region_highlight',
139
+ colormap=hcmap, blending='additive', opacity=0.4)
140
+ click_layer = viewer.add_points(name='clicks', face_color='cyan', symbol='cross',
141
+ size=20, ndim=2,
142
+ features={'label': np.empty(0, dtype='<U8')},
143
+ text={'string': '{label}', 'color': 'cyan', 'size': 12,
144
+ 'translation': [-14, 0]})
145
+ # ruler: a draggable 2-point line that reads out its length in mm + 0.5 mm ticks along it
146
+ ruler_layer = viewer.add_shapes(name='ruler', edge_color='yellow', edge_width=3,
147
+ face_color='transparent')
148
+ ruler_layer.visible = False
149
+ ruler_ticks = viewer.add_points(name='ruler_ticks', face_color='white', symbol='square',
150
+ size=3, ndim=2,
151
+ features={'label': np.empty(0, dtype='<U8')},
152
+ text={'string': '{label}', 'color': 'white', 'size': 8,
153
+ 'translation': [0, 8]})
154
+ ruler_ticks.visible = False
155
+
156
+ def _set_hist_layer(display: np.ndarray):
157
+ # napari can't flip grayscale<->RGB in place; re-create it (reorder() puts it at bottom)
158
+ nonlocal warped_layer
159
+ if warped_layer is not None and warped_layer in viewer.layers:
160
+ viewer.layers.remove(warped_layer)
161
+ warped_layer = viewer.add_image(display, name='histology', colormap='gray')
162
+
163
+ def set_histology(img: np.ndarray):
164
+ state['warped'] = img # keep the full image so the channel selector can re-slice it
165
+ _set_hist_layer(self.channel_view(img, channel_w.value))
166
+
167
+ def reorder():
168
+ # bottom -> top: histology, hover highlight, atlas boundaries, click points
169
+ stack = [warped_layer, highlight_layer, bound_layer, click_layer]
170
+ for i, lyr in enumerate(l for l in stack if l is not None and l in viewer.layers):
171
+ viewer.layers.move(viewer.layers.index(lyr), i)
172
+
173
+ status_label, status = self.make_status_log()
174
+ status.value = 'load a registered slice to begin'
175
+ summary = Label(value='')
176
+ summary.native.setWordWrap(True)
177
+ summary.native.setStyleSheet('font-family: Menlo, Consolas, monospace; font-size: 12px;')
178
+ slice_lbl = Label(value='no slice loaded') # current file (i/N) shown in the Slice section
179
+ slice_lbl.native.setWordWrap(True)
180
+
181
+ get_views = self.make_view_cache()
182
+
183
+ def frames() -> list[dict]:
184
+ # tiles to reconstruct crosses onto: N in all-slices view, 1 (origin 0,0) in single view
185
+ if state['mode'] == 'all':
186
+ return state['tiles']
187
+ if state['view'] is None:
188
+ return []
189
+ return [{'view': state['view'], 'plane_off': state['plane_off'], 'origin': (0, 0)}]
190
+
191
+ def redraw_crosses():
192
+ # reconstruct each point's pixel from its CCF coord; show it on every frame it lands on
193
+ here, labels, keys = [], [], []
194
+ for fr in frames():
195
+ view, off, (oy, ox) = fr['view'], fr['plane_off'], fr['origin']
196
+ for (shank, point), ccf in state['pts'].items():
197
+ plane, x, y = ccf_mm_to_plane_point(ccf, project_index=view.project_index,
198
+ resolution=view.resolution)
199
+ xi, yi = int(round(x)), int(round(y))
200
+ if 0 <= yi < off.shape[0] and 0 <= xi < off.shape[1] and abs(off[yi, xi] - plane) <= 1:
201
+ here.append([oy + y, ox + x])
202
+ labels.append(f'{shank} ({point[0]})') # e.g. "0 (d)" / "0 (v)"
203
+ keys.append((shank, point))
204
+ state['cross_here'], state['cross_keys'] = here, keys # parallel to displayed points
205
+ state['syncing'] = True # guard: our own data write must not re-enter the delete handler
206
+ click_layer.data = np.array(here) if here else np.empty((0, 2))
207
+ click_layer.features = {'label': np.array(labels, dtype='<U8')}
208
+ state['syncing'] = False
209
+
210
+ def on_crosses_edited(_event=None):
211
+ # editing a cross in napari only mutates the layer; mirror it back into state + table
212
+ if state.get('syncing'):
213
+ return
214
+ data = click_layer.data
215
+ keys, old = state.get('cross_keys', []), state.get('cross_here', [])
216
+ if len(data) == len(keys):
217
+ # same count -> a move: re-derive CCF for each cross whose pixel changed
218
+ moved = 0
219
+ for idx, (ny, nx) in enumerate(data):
220
+ oy, ox = old[idx]
221
+ if round(ny, 3) == round(oy, 3) and round(nx, 3) == round(ox, 3):
222
+ continue
223
+ fr = frame_at(ny, nx)
224
+ if fr is None:
225
+ continue
226
+ view, off, (fy, fx) = fr['view'], fr['plane_off'], fr['origin']
227
+ ly, lx = ny - fy, nx - fx # pixel within the slice under the drop
228
+ if not (0 <= ly < off.shape[0] and 0 <= lx < off.shape[1]):
229
+ continue
230
+ state['pts'][keys[idx]] = plane_point_to_ccf_mm(
231
+ off[int(ly), int(lx)], lx, ly, project_index=view.project_index,
232
+ resolution=view.resolution, bregma_10um=tuple(ALLEN_CCF_10um_BREGMA))
233
+ state['src'][keys[idx]] = fr.get('name', state['name'])
234
+ moved += 1
235
+ if moved:
236
+ refresh_summary()
237
+ redraw_crosses()
238
+ status.value = f'moved {moved} point(s)'
239
+ return
240
+ # fewer points than keys -> a deletion: drop the crosses that vanished
241
+ survivors = {(round(y, 3), round(x, 3)) for y, x in data}
242
+ removed = [k for k, (y, x) in zip(keys, old)
243
+ if (round(y, 3), round(x, 3)) not in survivors]
244
+ for k in removed:
245
+ state['pts'].pop(k, None)
246
+ state['src'].pop(k, None)
247
+ refresh_summary()
248
+ redraw_crosses()
249
+ status.value = f'removed {len(removed)} point(s)'
250
+
251
+ def compute_slice(img: Path) -> dict | None:
252
+ # warp one registered slice to atlas space; None if it has no saved registration
253
+ tp = self._transform_path(img)
254
+ if not tp.exists():
255
+ return None
256
+ meta = load_transform(tp)
257
+ plane, res = meta['plane'], int(meta['resolution'])
258
+ idx, dw, dh = int(meta['slice_index']), int(meta['dw']), int(meta['dh'])
259
+ oimg = rotate(read_oriented(img, meta.get('flip_lr', False), meta.get('flip_ud', False)),
260
+ float(meta.get('rotate', 0.0)))
261
+ _, trans = slice_transform_helper(oimg, np.array(meta['matrix'], float), plane_type=plane)
262
+ od = lambda v: v + 1 if v != 0 else 0
263
+ view, ann = get_views(plane, res) # cached: avoids reloading atlas volumes per slice
264
+ sp = view.plane_at(idx).with_offset(od(dw), od(dh))
265
+ ann_sp = ann.plane_at(idx).with_offset(od(dw), od(dh))
266
+ return {'trans': trans, 'view': view, 'plane_off': sp.plane_offset,
267
+ 'ann_img': ann_sp.image, 'plane': plane}
268
+
269
+ def load_slice(img: Path):
270
+ r = compute_slice(img)
271
+ if r is None:
272
+ status.value = f'no registration for {img.name} — skip or register it first'
273
+ return
274
+ view, ann_img = r['view'], r['ann_img']
275
+ state.update(plane=r['plane'], view=view, plane_off=r['plane_off'], name=img.stem,
276
+ ann_img=ann_img, hover_id=None)
277
+ set_histology(r['trans'])
278
+ bound_layer.visible = highlight_layer.visible = True # may have been hidden by all-view
279
+ bound_layer.data = boundary_mask(ann_img)
280
+ highlight_layer.data = np.zeros(ann_img.shape, dtype=float) # stale on plane change
281
+ redraw_crosses() # restore crosses picked on this slice (e.g. after CSV reload)
282
+ reorder()
283
+ viewer.reset_view() # camera was fitted to the tiny placeholder; refit to the slice
284
+ files = state['files']
285
+ pos = f'{state["cursor"] + 1}/{len(files)} ' if files else ''
286
+ slice_lbl.value = f'{pos}{img.name}'
287
+ status.value = f'{img.name}: click the dye, marking {mark_w.value} of shank {shank_w.value}'
288
+
289
+ def refresh_summary():
290
+ dists = dict(shank_distances(state['pts'])) # shank -> dorsal<->ventral µm
291
+ summary.value = _shank_table_html(state['pts'], state['src'], dists)
292
+
293
+ def on_table_link(href: str):
294
+ if not href.startswith('del:'):
295
+ return
296
+ shank = int(href.split(':', 1)[1])
297
+ from qtpy.QtWidgets import QMessageBox
298
+ if QMessageBox.question(None, 'Remove shank?',
299
+ f'Remove shank {shank} (both points)?') != QMessageBox.Yes:
300
+ return
301
+ for point in ('dorsal', 'ventral'):
302
+ state['pts'].pop((shank, point), None)
303
+ state['src'].pop((shank, point), None)
304
+ refresh_summary()
305
+ redraw_crosses()
306
+ status.value = f'removed shank {shank}'
307
+
308
+ summary.native.setOpenExternalLinks(False)
309
+ summary.native.linkActivated.connect(on_table_link)
310
+ click_layer.events.data.connect(on_crosses_edited)
311
+
312
+ def frame_at(y: float, x: float) -> dict | None:
313
+ # which slice was clicked: the single frame, or the mosaic tile under (y, x)
314
+ if state['mode'] == 'all':
315
+ if not state['tiles']:
316
+ return None
317
+ h, w, cols = state['grid']
318
+ row, col = int(y) // h, int(x) // w
319
+ idx = row * cols + col
320
+ return state['tiles'][idx] if 0 <= col < cols and 0 <= idx < len(state['tiles']) else None
321
+ return frames()[0] if frames() else None
322
+
323
+ @viewer.mouse_drag_callbacks.append
324
+ def on_click(_v, event):
325
+ if 'Shift' in event.modifiers: # Shift is the ruler gesture, not a dye pick
326
+ return
327
+ dragged = False
328
+ yield
329
+ while event.type == 'mouse_move':
330
+ dragged = True
331
+ yield
332
+ if dragged:
333
+ return
334
+ y, x = event.position
335
+ fr = frame_at(y, x)
336
+ if fr is None:
337
+ return
338
+ view, off, (oy, ox) = fr['view'], fr['plane_off'], fr['origin']
339
+ ly, lx = y - oy, x - ox # pixel within this slice
340
+ if not (0 <= ly < off.shape[0] and 0 <= lx < off.shape[1]):
341
+ return
342
+ ccf = plane_point_to_ccf_mm(off[int(ly), int(lx)], lx, ly,
343
+ project_index=view.project_index,
344
+ resolution=view.resolution,
345
+ bregma_10um=tuple(ALLEN_CCF_10um_BREGMA))
346
+ key = (int(shank_w.value), _POINT[mark_w.value])
347
+ state['pts'][key] = ccf
348
+ state['src'][key] = fr.get('name', state['name']) # slice this point was picked on
349
+ redraw_crosses()
350
+ status.value = (f'shank {shank_w.value} {mark_w.value} -> '
351
+ f'AP {ccf[0]:.2f}, DV {ccf[1]:.2f}, ML {ccf[2]:.2f} mm')
352
+ refresh_summary()
353
+
354
+ viewer.mouse_move_callbacks.append(self.make_hover(viewer, state, bg.structures, highlight_layer))
355
+
356
+ shank_w = SpinBox(label='shank', value=1, min=1, max=64)
357
+ mark_w = ComboBox(label='marking', choices=list(MARKS), value=MARKS[0])
358
+ channel_w = ComboBox(label='channel', choices=['merge', 'R', 'G', 'B'], value='merge')
359
+
360
+ def on_channel(*_):
361
+ if state['mode'] == 'all':
362
+ show_all() # rebuild the mosaic in the new channel
363
+ elif state.get('warped') is not None:
364
+ set_histology(state['warped']) # re-slice current image; keep camera
365
+ reorder()
366
+
367
+ channel_w.changed.connect(on_channel)
368
+
369
+ regions = RegionPicker(bg.structures)
370
+ style_w = ComboBox(label='style', choices=SHADER_STYLES, value=SHADER_STYLES[0])
371
+ hemisphere_w = ComboBox(label='hemisphere', choices=['both', 'left', 'right'], value='both')
372
+ camera_w = ComboBox(label='camera', choices=CAMERA_ANGLES, value=CAMERA_ANGLES[0])
373
+ no_root_w = CheckBox(label='no root (hide brain)', value=False)
374
+
375
+ # per-shank dye color: pick a shank (above), then set its color here
376
+ shank_colors: dict[int, str] = {} # shank -> dye color; default red
377
+ probe_color_w = ComboBox(label='shank color', choices=_COLORS, value='red')
378
+
379
+ def store_shank_color(*_):
380
+ shank_colors[int(shank_w.value)] = probe_color_w.value
381
+
382
+ def show_shank_color(*_):
383
+ probe_color_w.value = shank_colors.get(int(shank_w.value), 'red')
384
+
385
+ probe_color_w.changed.connect(store_shank_color)
386
+ shank_w.changed.connect(show_shank_color)
387
+
388
+ def _draw_ruler(y0, x0, y1, x1):
389
+ off, view = state.get('plane_off'), state.get('view')
390
+ if off is None or view is None:
391
+ status.value = 'ruler: single-slice view only'
392
+ return
393
+
394
+ def to_mm(y, x):
395
+ yi = int(np.clip(round(y), 0, off.shape[0] - 1))
396
+ xi = int(np.clip(round(x), 0, off.shape[1] - 1))
397
+ return np.array(plane_point_to_ccf_mm(off[yi, xi], x, y,
398
+ project_index=view.project_index,
399
+ resolution=view.resolution,
400
+ bregma_10um=tuple(ALLEN_CCF_10um_BREGMA)))
401
+
402
+ dist_mm = float(np.linalg.norm(to_mm(y1, x1) - to_mm(y0, x0)))
403
+ ruler_layer.visible = ruler_ticks.visible = True
404
+ ruler_layer.data = [np.array([[y0, x0], [y1, x1]])]
405
+ ruler_layer.shape_type = 'line'
406
+ pts, labels = [], [] # a tick every 100 µm from the start, labelled in µm
407
+ for e in np.arange(0, dist_mm + 1e-9, 0.1) if dist_mm > 0 else []:
408
+ f = e / dist_mm
409
+ pts.append([y0 + (y1 - y0) * f, x0 + (x1 - x0) * f])
410
+ labels.append(f'{e * 1000:.0f}')
411
+ ruler_ticks.data = np.array(pts) if pts else np.empty((0, 2))
412
+ ruler_ticks.features = {'label': np.array(labels, dtype='<U8')}
413
+ status.value = f'ruler: {dist_mm * 1000:.0f} µm ({dist_mm:.3f} mm)'
414
+
415
+ @viewer.mouse_drag_callbacks.append
416
+ def on_ruler_drag(_v, event):
417
+ if 'Shift' not in event.modifiers: # Shift+left-drag draws the ruler
418
+ return
419
+ y0, x0 = event.position
420
+ yield
421
+ while event.type == 'mouse_move':
422
+ y1, x1 = event.position
423
+ _draw_ruler(y0, x0, y1, x1)
424
+ yield
425
+
426
+ def load_csv_points(path: Path):
427
+ try:
428
+ df = pl.read_csv(path)
429
+ except Exception as e: # noqa: BLE001 - surface any read error in the GUI
430
+ status.value = f'could not read {path.name}: {e}'
431
+ return
432
+ cols = set(df.columns)
433
+ # accept the new (ap_mm/…) or legacy (AP_location/…) column names
434
+ ap_c, dv_c, ml_c = (('ap_mm', 'dv_mm', 'ml_mm') if 'ap_mm' in cols
435
+ else ('AP_location', 'DV_location', 'ML_location'))
436
+ if not {ap_c, dv_c, ml_c, 'probe_idx', 'point'} <= cols:
437
+ status.value = f'{path.name}: not a probe CSV (needs ap_mm/dv_mm/ml_mm, probe_idx, point)'
438
+ return
439
+ for r in df.iter_rows(named=True):
440
+ key = (int(r['probe_idx']), r['point'])
441
+ state['pts'][key] = (r[ap_c], r[dv_c], r[ml_c])
442
+ state['src'][key] = r.get('source', '') or '' # column optional on older CSVs
443
+ refresh_summary()
444
+ redraw_crosses() # crosses are reconstructed from the loaded coordinates
445
+ status.value = f'loaded {len({s for s, _ in state["pts"]})} shank(s) from {path.name}'
446
+
447
+ def build_rows() -> list[dict] | None:
448
+ shanks = sorted({s for s, _ in state['pts']})
449
+ rows = []
450
+ for s in shanks:
451
+ for point in ('dorsal', 'ventral'): # dorsal first: ProbeRenderCLI reshape order
452
+ if (s, point) not in state['pts']:
453
+ status.value = f'shank {s} missing its {point} point'
454
+ return None
455
+ ap, dv, ml = state['pts'][(s, point)]
456
+ rows.append({'ap_mm': ap, 'dv_mm': dv, 'ml_mm': ml,
457
+ 'probe_idx': s, 'point': point,
458
+ 'source': state['src'].get((s, point), '')})
459
+ if not rows:
460
+ status.value = 'no points to save'
461
+ return None
462
+ return rows
463
+
464
+ def save_csv() -> Path | None:
465
+ rows = build_rows()
466
+ if rows is None:
467
+ return None
468
+ self._out.parent.mkdir(parents=True, exist_ok=True)
469
+ pl.DataFrame(rows).write_csv(self._out)
470
+ status.value = f'saved {len({r["probe_idx"] for r in rows})} shank(s) -> {self._out}'
471
+ return self._out
472
+
473
+ def on_render():
474
+ rows = build_rows() # render from current points without touching the session CSV
475
+ if rows is None:
476
+ return
477
+ import tempfile
478
+ csv = Path(tempfile.gettempdir()) / 'regrender_probe_render.csv'
479
+ # ProbeRenderCLI expects the neuralib column names
480
+ pl.DataFrame(rows).rename({'ap_mm': 'AP_location', 'dv_mm': 'DV_location',
481
+ 'ml_mm': 'ML_location'}).write_csv(csv)
482
+ shanks = sorted({s for s, _ in state['pts']}) # same order as the saved CSV rows
483
+ cmd = _render_command(csv, state['plane'] or 'coronal', shanks, shank_colors,
484
+ regions.colors, self.depth, self.interval, style_w.value,
485
+ no_root_w.value, hemisphere_w.value, camera_w.value)
486
+ self.launch_render(cmd, status,
487
+ f'rendering ({len(shanks)} shank(s)'
488
+ + (f', {len(regions.colors)} region(s)' if regions.colors else '')
489
+ + ') in a separate window...')
490
+
491
+ def region_profile():
492
+ # for each shank, sample dorsal->ventral and show which region each depth band is in
493
+ shanks = sorted({s for s, _ in state['pts']})
494
+ tracks = [] # (shank, runs, d, v, length); runs = [(dv0, dv1, acronym, extrapolated)]
495
+ for s in shanks:
496
+ d, v = state['pts'].get((s, 'dorsal')), state['pts'].get((s, 'ventral'))
497
+ if d is None or v is None:
498
+ status.value = f'shank {s} missing its dorsal/ventral point'
499
+ return
500
+ d, v = np.array(d, float), np.array(v, float)
501
+ vec = v - d # dorsal -> ventral direction
502
+ length = float(np.linalg.norm(vec)) # dye euclidean length (mm)
503
+ if length == 0:
504
+ status.value = f'shank {s}: dorsal and ventral points coincide'
505
+ return
506
+ # extrapolate the dye line to the implant depth (from dorsal) if --depth is set
507
+ t_max = max(1.0, (self.depth / 1000.0) / length) if self.depth else 1.0
508
+ n = max(256, int(256 * t_max))
509
+ ts = np.linspace(0, t_max, n)
510
+ dvs = d[1] + vec[1] * ts # DV (mm) at each sample
511
+ acrs = []
512
+ for t in ts:
513
+ try:
514
+ a = bg.structure_from_coords(ccf_mm_to_voxel(tuple(d + vec * t)),
515
+ microns=False, as_acronym=True)
516
+ except Exception: # noqa: BLE001 - outside the annotated volume
517
+ a = 'out'
518
+ acrs.append(a)
519
+ runs, i = [], 0
520
+ while i < n: # collapse consecutive samples of the same region into one band
521
+ j = i
522
+ while j < n and acrs[j] == acrs[i]:
523
+ j += 1
524
+ k = j if j < n else n - 1 # boundary sample (abut next band, no gaps)
525
+ # (dv0, dv1, acronym, extrapolated, euclid0, euclid1) — euclid = mm from dorsal
526
+ runs.append((dvs[i], dvs[k], acrs[i], ts[i] > 1.0,
527
+ length * ts[i], length * ts[k]))
528
+ i = j
529
+ tracks.append((s, runs, d, v, length))
530
+ if not tracks:
531
+ status.value = 'no shanks to plot'
532
+ return
533
+
534
+ def acr_color(a: str):
535
+ try:
536
+ r, g, b = bg.structures[a]['rgb_triplet']
537
+ return r / 255, g / 255, b / 255
538
+ except KeyError:
539
+ return 0.85, 0.85, 0.85 # 'out' / root / unknown
540
+
541
+ import matplotlib.pyplot as plt
542
+ fig, ax = plt.subplots(figsize=(1.9 * len(tracks) + 1, 6))
543
+ # only label a band that is tall enough to hold the text; thin slivers would just
544
+ # overprint each other (every band is in the csv anyway)
545
+ all_dv = [dv for _s, runs, *_ in tracks for r in runs for dv in r[:2]]
546
+ min_h = 0.025 * (max(all_dv) - min(all_dv))
547
+ for xi, (s, runs, d, v, length) in enumerate(tracks):
548
+ for dv0, dv1, a, extrap, _e0, _e1 in runs:
549
+ lo, hi = min(dv0, dv1), max(dv0, dv1)
550
+ ax.bar(xi, hi - lo, bottom=lo, width=0.8, color=acr_color(a),
551
+ edgecolor='white', linewidth=0.5,
552
+ alpha=0.55 if extrap else 1.0, hatch='//' if extrap else None)
553
+ if hi - lo >= min_h:
554
+ ax.text(xi, (lo + hi) / 2, a, ha='center', va='center', fontsize=7)
555
+ # ruler: euclidean distance from the dorsal point, ticked every 0.5 mm on the left
556
+ vec_dv = v[1] - d[1]
557
+ e_max = length * (max(1.0, (self.depth / 1000.0) / length) if self.depth else 1.0)
558
+ for e in np.arange(0, e_max + 1e-9, 0.5):
559
+ y = d[1] + vec_dv * (e / length) # DV position of this euclidean distance
560
+ ax.plot([xi - 0.45, xi - 0.4], [y, y], color='0.3', linewidth=0.8)
561
+ ax.text(xi - 0.47, y, f'{e:.1f}', ha='right', va='center', fontsize=6, color='0.3')
562
+ # mark the ventral dye point + its euclidean distance from dorsal
563
+ ax.plot([xi - 0.4, xi + 0.4], [v[1], v[1]], color='k', linestyle='--', linewidth=0.8)
564
+ ax.text(xi + 0.42, v[1], f'v {length:.2f} mm', ha='left', va='center', fontsize=6)
565
+ ax.set_xticks(range(len(tracks)))
566
+ ax.set_xticklabels([f'shank {s}' for s, *_ in tracks])
567
+ ax.set_ylabel('DV from bregma (mm) · left ticks = mm from dorsal (euclidean)')
568
+ ax.set_title('Probe region profile' + (' (// = extrapolated to depth)' if self.depth else ''))
569
+ ax.invert_yaxis() # dorsal (smaller DV) at top
570
+ ax.margins(x=0.15)
571
+ fig.tight_layout()
572
+ self._out.parent.mkdir(parents=True, exist_ok=True)
573
+ out = self._out.parent / 'probe_region_profile.pdf'
574
+ fig.savefig(out) # vector figure
575
+ # export the band data (one row per region span per shank)
576
+ csv_rows = [{'shank': s, 'region': a, 'extrapolated': extrap,
577
+ 'dv_start_mm': round(dv0, 4), 'dv_end_mm': round(dv1, 4),
578
+ 'depth_start_mm': round(e0, 4), 'depth_end_mm': round(e1, 4),
579
+ 'length_mm': round(abs(e1 - e0), 4)}
580
+ for s, runs, *_ in tracks for dv0, dv1, a, extrap, e0, e1 in runs]
581
+ csv = self._out.parent / 'probe_region_profile.csv'
582
+ pl.DataFrame(csv_rows).write_csv(csv)
583
+ status.value = f'region profile -> {out.name} + {csv.name}'
584
+ plt.show(block=False)
585
+
586
+ def invert_ml():
587
+ if not state['pts']:
588
+ status.value = 'no points to flip'
589
+ return
590
+ for k, (ap, dv, ml) in list(state['pts'].items()):
591
+ state['pts'][k] = (ap, dv, -ml) # midline is ML=0; mirror to the other hemisphere
592
+ refresh_summary()
593
+ redraw_crosses()
594
+ status.value = 'flipped ML -> other hemisphere'
595
+
596
+ def show_all():
597
+ # tile every registered slice into one canvas; store per-tile geometry for click mapping
598
+ files = state['files'] or ([self.raw_image] if self.raw_image else [])
599
+ items = [(f.stem, r) for f in files if (r := compute_slice(f)) is not None]
600
+ if not items:
601
+ status.value = 'no registered slices to show'
602
+ return
603
+ imgs = [self.channel_view(r['trans'], channel_w.value) for _, r in items]
604
+ tail = imgs[0].shape[2:] # () for grayscale, (3,) / (4,) for RGB(A)
605
+ th = max(i.shape[0] for i in imgs)
606
+ tw = max(i.shape[1] for i in imgs)
607
+ cols = int(np.ceil(np.sqrt(len(imgs))))
608
+ rows = int(np.ceil(len(imgs) / cols))
609
+ big = np.zeros((rows * th, cols * tw) + tail, dtype=imgs[0].dtype)
610
+ bmask = np.zeros((rows * th, cols * tw), dtype=bool) # atlas boundaries, tiled the same way
611
+ tiles = []
612
+ for i, ((name, r), im) in enumerate(zip(items, imgs)):
613
+ oy, ox = (i // cols) * th, (i % cols) * tw
614
+ big[oy:oy + im.shape[0], ox:ox + im.shape[1]] = im
615
+ b = boundary_mask(r['ann_img'])
616
+ bmask[oy:oy + b.shape[0], ox:ox + b.shape[1]] = b
617
+ tiles.append({'view': r['view'], 'plane_off': r['plane_off'],
618
+ 'origin': (oy, ox), 'name': name})
619
+ state.update(tiles=tiles, grid=(th, tw, cols), view=None, plane_off=None, ann_img=None)
620
+ _set_hist_layer(big)
621
+ bound_layer.data = bmask
622
+ bound_layer.visible = True
623
+ highlight_layer.visible = False # pick-only: static boundaries, but no hover highlight
624
+ redraw_crosses()
625
+ reorder()
626
+ viewer.reset_view()
627
+ slice_lbl.value = f'all slices ({len(tiles)})'
628
+ status.value = f'all-slices view: click any slice to mark {mark_w.value} of shank {shank_w.value}'
629
+
630
+ def refresh_view():
631
+ if state['mode'] == 'all':
632
+ show_all()
633
+ elif state['files']:
634
+ load_slice(state['files'][state['cursor']])
635
+ elif self.raw_image:
636
+ load_slice(self.raw_image)
637
+
638
+ def step(delta: int):
639
+ files = state['files']
640
+ if not files:
641
+ status.value = 'single-image mode — nothing to step'
642
+ return
643
+ state['cursor'] = int(np.clip(state['cursor'] + delta, 0, len(files) - 1))
644
+ load_slice(files[state['cursor']])
645
+
646
+ prev_btn = PushButton(text='◀ Prev slice')
647
+ prev_btn.changed.connect(lambda *_: step(-1))
648
+ next_btn = PushButton(text='Next slice ▶')
649
+ next_btn.changed.connect(lambda *_: step(+1))
650
+ view_w = ComboBox(label='view', choices=['single', 'all'], value='single')
651
+
652
+ def on_mode(*_):
653
+ state['mode'] = view_w.value
654
+ single = view_w.value == 'single'
655
+ prev_btn.enabled = next_btn.enabled = single # stepping only makes sense in single view
656
+ refresh_view()
657
+
658
+ view_w.changed.connect(on_mode)
659
+
660
+ def on_load_csv():
661
+ from qtpy.QtWidgets import QFileDialog
662
+ path, _ = QFileDialog.getOpenFileName(caption='Load probe points CSV',
663
+ filter='CSV (*.csv);;All files (*)')
664
+ if path:
665
+ load_csv_points(Path(path))
666
+
667
+ invert_btn = PushButton(text='Invert ML (flip hemisphere)')
668
+ invert_btn.changed.connect(lambda *_: invert_ml())
669
+ load_btn = PushButton(text='Load CSV')
670
+ load_btn.changed.connect(lambda *_: on_load_csv())
671
+ save_btn = PushButton(text='Save CSV')
672
+ save_btn.changed.connect(lambda *_: save_csv())
673
+ render_btn = PushButton(text='Render (brainrender)')
674
+ render_btn.changed.connect(lambda *_: on_render())
675
+ profile_btn = PushButton(text='Region profile plot')
676
+ profile_btn.changed.connect(lambda *_: region_profile())
677
+
678
+ panel = Container(widgets=[
679
+ self.header('Slice'), slice_lbl, view_w, self.srow(prev_btn, next_btn),
680
+ self.header('Dye point'), shank_w, mark_w, channel_w, probe_color_w,
681
+ self.header('Accumulated'), summary, invert_btn,
682
+ self.header('Regions'), *regions.widgets, profile_btn,
683
+ self.header('Render'), self.srow(style_w, hemisphere_w), camera_w, no_root_w, render_btn,
684
+ self.header('CSV'), self.srow(load_btn, save_btn),
685
+ status_label,
686
+ ])
687
+ self.add_scroll_dock(viewer, panel, 'probe')
688
+
689
+ if state['files']:
690
+ load_slice(state['files'][0])
691
+ elif self.raw_image:
692
+ load_slice(self.raw_image)
693
+ if self._out.exists():
694
+ load_csv_points(self._out) # auto-resume the session CSV
695
+ refresh_summary()
696
+
697
+ printf('probe: pick superficial+deep dye per shank (step slices as needed), then Render')
698
+ napari.run()
699
+
700
+
701
+ if __name__ == '__main__':
702
+ ProbeOptions().main()