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.
- regrender/__init__.py +0 -0
- regrender/__main__.py +38 -0
- regrender/_app.py +326 -0
- regrender/_core.py +306 -0
- regrender/main_probe.py +702 -0
- regrender/main_register.py +638 -0
- regrender/main_roi.py +489 -0
- regrender-0.1.0.dist-info/METADATA +102 -0
- regrender-0.1.0.dist-info/RECORD +12 -0
- regrender-0.1.0.dist-info/WHEEL +4 -0
- regrender-0.1.0.dist-info/entry_points.txt +2 -0
- regrender-0.1.0.dist-info/licenses/LICENSE +21 -0
regrender/main_probe.py
ADDED
|
@@ -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"> {h} </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"> {txt} </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"> {point[0]} </td>'
|
|
62
|
+
f'<td align="right"> {ap:.2f} </td>'
|
|
63
|
+
f'<td align="right"> {dv:.2f} </td><td align="right"> {ml:.2f} </td>'
|
|
64
|
+
f'<td> {src.get((shank, point), "")} </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()
|