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/__init__.py
ADDED
|
File without changes
|
regrender/__main__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from argclz import AbstractParser
|
|
2
|
+
from argclz.commands import parse_command_args
|
|
3
|
+
|
|
4
|
+
from .main_probe import ProbeOptions
|
|
5
|
+
from .main_register import RegisterOptions
|
|
6
|
+
from .main_roi import RoiOptions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SetupOptions(AbstractParser):
|
|
10
|
+
DESCRIPTION = ('Pre-download the Allen CCF atlas so the first register/roi/probe run '
|
|
11
|
+
'does not stall on a multi-minute download.')
|
|
12
|
+
|
|
13
|
+
def run(self):
|
|
14
|
+
from brainglobe_atlasapi import BrainGlobeAtlas
|
|
15
|
+
|
|
16
|
+
from ._app import printf
|
|
17
|
+
|
|
18
|
+
name = 'allen_mouse_10um' # the resolution the app is built around (roi/probe assume 10 µm)
|
|
19
|
+
printf(f'fetching {name} (one-time, may take a few minutes)...')
|
|
20
|
+
BrainGlobeAtlas(name) # downloads + caches if missing, no-op if already local
|
|
21
|
+
printf(f'{name} ready')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
parse_command_args(
|
|
26
|
+
usage='python -m regrender ...',
|
|
27
|
+
description='regrender cli usage',
|
|
28
|
+
parsers=dict(
|
|
29
|
+
register=RegisterOptions,
|
|
30
|
+
probe=ProbeOptions,
|
|
31
|
+
roi=RoiOptions,
|
|
32
|
+
setup=SetupOptions
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == '__main__':
|
|
38
|
+
main()
|
regrender/_app.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Shared scaffolding for the napari reconstruction modes (``probe`` and ``roi``).
|
|
2
|
+
|
|
3
|
+
Both modes step through registered serial sections, pick points that resolve to Allen CCF
|
|
4
|
+
mm, paint atlas region boundaries / the region under the cursor, pick atlas regions to render,
|
|
5
|
+
and shell out to a brainrender CLI. That common plumbing lives here; each mode subclasses
|
|
6
|
+
:class:`SliceReconstructOptions` and supplies only its own image display + click semantics +
|
|
7
|
+
render argv. The pure (GUI-free) coordinate/image helpers stay in :mod:`regrender._core`.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from collections import deque
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from argclz import AbstractParser, argument
|
|
18
|
+
from neuralib.atlas.view import get_slice_view
|
|
19
|
+
|
|
20
|
+
from regrender._core import region_name
|
|
21
|
+
|
|
22
|
+
__all__ = ['SliceReconstructOptions', 'RegionPicker', 'TerminalLog', 'printf']
|
|
23
|
+
|
|
24
|
+
_IMG_EXT = {'.tif', '.tiff', '.png', '.jpg', '.jpeg'}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _console():
|
|
28
|
+
"""Cached rich Console for the terminal mirror (created lazily to keep import light)."""
|
|
29
|
+
global _CONSOLE
|
|
30
|
+
if _CONSOLE is None:
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
_CONSOLE = Console()
|
|
33
|
+
return _CONSOLE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_CONSOLE = None
|
|
37
|
+
|
|
38
|
+
# level -> (panel HTML hex, rich terminal style)
|
|
39
|
+
_LEVEL_STYLE = {
|
|
40
|
+
'error': ('#ff6b6b', 'bold red'),
|
|
41
|
+
'warning': ('#f2c14e', 'yellow'),
|
|
42
|
+
'save': ('#8bd450', 'green'),
|
|
43
|
+
'io': ('#56b6c2', 'cyan'),
|
|
44
|
+
'info': ('#d0d0d0', ''),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _infer_level(msg: str) -> str:
|
|
49
|
+
m = str(msg).lower()
|
|
50
|
+
if any(k in m for k in ('fail', 'error', 'cannot', "n't", 'invalid')):
|
|
51
|
+
return 'error'
|
|
52
|
+
if any(k in m for k in ('missing', 'cancel', 'no points', 'nothing', 'not ', 'stale')):
|
|
53
|
+
return 'warning'
|
|
54
|
+
if any(k in m for k in ('saved', 'save', '->', 'wrote', 'written')):
|
|
55
|
+
return 'save'
|
|
56
|
+
if any(k in m for k in ('load', 'render', 'resum', 'projected')):
|
|
57
|
+
return 'io'
|
|
58
|
+
return 'info'
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def printf(msg: str, level: str | None = None) -> None:
|
|
62
|
+
"""Consistent timestamped terminal output (rich). The single path for every terminal line —
|
|
63
|
+
CLI messages and the napari status mirror both go through here, so they read the same.
|
|
64
|
+
Level (hence color) is inferred from the text unless passed explicitly."""
|
|
65
|
+
from datetime import datetime
|
|
66
|
+
|
|
67
|
+
from rich.text import Text
|
|
68
|
+
|
|
69
|
+
msg = str(msg)
|
|
70
|
+
_, style = _LEVEL_STYLE[level or _infer_level(msg)]
|
|
71
|
+
line = Text()
|
|
72
|
+
line.append(datetime.now().strftime('%H:%M:%S') + ' ', style='dim')
|
|
73
|
+
line.append(msg, style=style) # append is literal — no markup injection from msg
|
|
74
|
+
_console().print(line)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TerminalLog:
|
|
78
|
+
"""Append-only rich-console view over a magicgui Label: ``status.value = msg`` appends a
|
|
79
|
+
timestamped, color-highlighted line (QLabel renders HTML) and scrolls like a terminal,
|
|
80
|
+
keeping the last ``maxlines`` messages. Level (hence color) is inferred from the message;
|
|
81
|
+
pass it explicitly with ``log.log(msg, 'error')`` when the heuristic isn't enough."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, label, maxlines: int = 200, echo: bool = True):
|
|
84
|
+
self._label = label
|
|
85
|
+
self._lines: deque[str] = deque(maxlen=maxlines)
|
|
86
|
+
self._echo = echo # also mirror each line to the terminal (persistent, copyable log)
|
|
87
|
+
|
|
88
|
+
def log(self, msg: str, level: str | None = None):
|
|
89
|
+
import html
|
|
90
|
+
from datetime import datetime
|
|
91
|
+
|
|
92
|
+
msg = str(msg)
|
|
93
|
+
level = level or _infer_level(msg)
|
|
94
|
+
hexc, _ = _LEVEL_STYLE[level]
|
|
95
|
+
ts = datetime.now().strftime('%H:%M:%S')
|
|
96
|
+
|
|
97
|
+
self._lines.append(
|
|
98
|
+
f'<span style="color:#6b7280">{ts}</span> '
|
|
99
|
+
f'<span style="color:{hexc}">{html.escape(msg)}</span>'
|
|
100
|
+
)
|
|
101
|
+
self._label.value = '<br>'.join(self._lines)
|
|
102
|
+
|
|
103
|
+
if self._echo: # mirror to terminal via the shared printer
|
|
104
|
+
printf(msg, level)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def value(self) -> str:
|
|
108
|
+
return '\n'.join(self._lines)
|
|
109
|
+
|
|
110
|
+
@value.setter
|
|
111
|
+
def value(self, msg: str):
|
|
112
|
+
self.log(msg)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def region_table_html(region_colors: dict[str, str]) -> str:
|
|
116
|
+
"""HTML table of regions to render; each acronym is a ``rdel:<acr>`` link to remove it."""
|
|
117
|
+
if not region_colors:
|
|
118
|
+
return 'no regions'
|
|
119
|
+
rows = ['<tr><th>region</th><th>color</th></tr>']
|
|
120
|
+
for acr, c in region_colors.items():
|
|
121
|
+
rows.append(f'<tr><td><a href="rdel:{acr}">{acr} ✕</a></td>'
|
|
122
|
+
f'<td><font color="{c}">■</font> {c}</td></tr>')
|
|
123
|
+
return '<table border=1 cellspacing=0 cellpadding=3>' + ''.join(rows) + '</table>'
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# atlas regions to render as meshes; identical picker in both modes
|
|
127
|
+
_REGION_COLORS = ['red', 'salmon', 'darkred', 'cyan', 'yellow', 'magenta',
|
|
128
|
+
'lime', 'green', 'blue', 'orange', 'white', 'black']
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class RegionPicker:
|
|
132
|
+
"""Filterable atlas-region picker + colored render list. ``.widgets`` go in the panel;
|
|
133
|
+
``.colors`` is the ordered ``{acronym: color}`` map (render order = insertion order)."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, structures):
|
|
136
|
+
from magicgui.widgets import ComboBox, Label, LineEdit, PushButton, Select
|
|
137
|
+
self._pairs = [(f"{a} — {structures[a]['name']}", a)
|
|
138
|
+
for a in sorted(structures.acronym_to_id_map)]
|
|
139
|
+
self.colors: dict[str, str] = {}
|
|
140
|
+
self._search = LineEdit(label='filter')
|
|
141
|
+
self._select = Select(label='regions', choices=self._pairs)
|
|
142
|
+
self._select.native.setMinimumHeight(240)
|
|
143
|
+
self._color = ComboBox(label='color', choices=_REGION_COLORS, value='red')
|
|
144
|
+
self._add = PushButton(text='+ Add region(s)')
|
|
145
|
+
self._table = Label(value='no regions')
|
|
146
|
+
self._table.native.setOpenExternalLinks(False)
|
|
147
|
+
|
|
148
|
+
self._search.changed.connect(self._apply_filter)
|
|
149
|
+
self._add.changed.connect(self._add_regions)
|
|
150
|
+
self._table.native.linkActivated.connect(self._on_link)
|
|
151
|
+
self._refresh()
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def widgets(self) -> list:
|
|
155
|
+
return [self._search, self._select, self._color, self._add, self._table]
|
|
156
|
+
|
|
157
|
+
def _apply_filter(self, *_):
|
|
158
|
+
q = self._search.value.strip().lower()
|
|
159
|
+
self._select.choices = [p for p in self._pairs if q in p[0].lower()] if q else list(self._pairs)
|
|
160
|
+
|
|
161
|
+
def _refresh(self):
|
|
162
|
+
self._table.value = region_table_html(self.colors)
|
|
163
|
+
|
|
164
|
+
def _add_regions(self, *_):
|
|
165
|
+
for acr in self._select.value:
|
|
166
|
+
self.colors[acr] = self._color.value # add or recolor
|
|
167
|
+
self._refresh()
|
|
168
|
+
|
|
169
|
+
def _on_link(self, href: str):
|
|
170
|
+
if href.startswith('rdel:'):
|
|
171
|
+
self.colors.pop(href[len('rdel:'):], None)
|
|
172
|
+
self._refresh()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SliceReconstructOptions(AbstractParser):
|
|
176
|
+
"""Base for ``probe``/``roi``: shared CLI args + napari/render plumbing."""
|
|
177
|
+
|
|
178
|
+
directory: Path | None = argument(
|
|
179
|
+
'-D', '--directory', default=None,
|
|
180
|
+
help='folder of serial sections (steps through them; reads transformations/<stem>_transform.json)'
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
raw_image: Path | None = argument(
|
|
184
|
+
'-I', '--image', default=None, help='single registered histology image (alternative to -D)'
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
transform_dir: Path | None = argument(
|
|
188
|
+
'--transform-dir', default=None,
|
|
189
|
+
help='where the *_transform.json live (default: <image-dir>/transformations)'
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
output: Path | None = argument(
|
|
193
|
+
'-O', '--output', default=None, help='output points csv (default: <dir>/<mode default>)'
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
_IMG_EXT = _IMG_EXT
|
|
197
|
+
|
|
198
|
+
def _list_images(self, d: Path) -> list[Path]:
|
|
199
|
+
return sorted(p for p in Path(d).iterdir() if p.suffix.lower() in self._IMG_EXT)
|
|
200
|
+
|
|
201
|
+
def _transform_path(self, img: Path) -> Path:
|
|
202
|
+
return self._tdir / f'{img.stem}_transform.json'
|
|
203
|
+
|
|
204
|
+
def _resolve_paths(self, default_csv: str, *, require_transform: bool = True) -> list[Path]:
|
|
205
|
+
"""Common run() prologue: resolve image(s), transform dir, output csv. Returns the file list.
|
|
206
|
+
|
|
207
|
+
``require_transform=False`` for modes that label before registration exists (roi)."""
|
|
208
|
+
files = self._list_images(self.directory) if self.directory else []
|
|
209
|
+
if files and self.raw_image is None:
|
|
210
|
+
self.raw_image = files[0]
|
|
211
|
+
if self.raw_image is None:
|
|
212
|
+
raise ValueError('provide an image via -I/--image or a folder via -D/--directory')
|
|
213
|
+
base = self.raw_image.parent
|
|
214
|
+
self._tdir = self.transform_dir or base / 'transformations'
|
|
215
|
+
self._out = self.output or base / default_csv
|
|
216
|
+
if require_transform and not self._transform_path(self.raw_image).exists():
|
|
217
|
+
raise FileNotFoundError(
|
|
218
|
+
f'no native registration {self._transform_path(self.raw_image)} — '
|
|
219
|
+
f'register with `regrender register` first')
|
|
220
|
+
return files
|
|
221
|
+
|
|
222
|
+
# --- shared napari helpers --------------------------------------------------
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def make_status_log():
|
|
226
|
+
"""A scrolling terminal-style status: a monospace ``Label`` + its :class:`TerminalLog`.
|
|
227
|
+
Returns ``(label_widget, TerminalLog)``; ``log.value = msg`` appends a line."""
|
|
228
|
+
from magicgui.widgets import Label
|
|
229
|
+
from qtpy.QtCore import Qt
|
|
230
|
+
from qtpy.QtWidgets import QSizePolicy
|
|
231
|
+
label = Label(value='')
|
|
232
|
+
label.native.setStyleSheet(
|
|
233
|
+
'font-family: Menlo, Consolas, monospace; font-size: 12px; '
|
|
234
|
+
'background: #11131a; padding: 6px;') # per-line color comes from TerminalLog HTML
|
|
235
|
+
label.native.setWordWrap(True)
|
|
236
|
+
label.native.setTextFormat(Qt.RichText)
|
|
237
|
+
label.native.setAlignment(Qt.AlignLeft | Qt.AlignTop) # fill from the top
|
|
238
|
+
label.native.setMinimumHeight(220) # roomy console area
|
|
239
|
+
label.native.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # grows with the dock
|
|
240
|
+
return label, TerminalLog(label)
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def make_view_cache():
|
|
244
|
+
"""``get_views(plane, res) -> (reference_view, annotation_view)``, cached (volume load is slow)."""
|
|
245
|
+
views: dict = {}
|
|
246
|
+
|
|
247
|
+
def get_views(plane: str, res: int):
|
|
248
|
+
key = (plane, res)
|
|
249
|
+
if key not in views:
|
|
250
|
+
views[key] = (get_slice_view('reference', plane, resolution=res, check_latest=False),
|
|
251
|
+
get_slice_view('annotation', plane, resolution=res, check_latest=False))
|
|
252
|
+
return views[key]
|
|
253
|
+
|
|
254
|
+
return get_views
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def make_hover(viewer, state, structures, highlight_layer):
|
|
258
|
+
"""Region-name text overlay + filled highlight of the atlas region under the cursor.
|
|
259
|
+
Reads ``state['ann_img']`` / ``state['hover_id']``. Returns the mouse-move callback."""
|
|
260
|
+
def on_move(_v, event):
|
|
261
|
+
ann = state.get('ann_img')
|
|
262
|
+
if ann is None:
|
|
263
|
+
return
|
|
264
|
+
y, x = event.position
|
|
265
|
+
viewer.text_overlay.text = region_name(ann, structures, y, x)
|
|
266
|
+
rid = (int(ann[int(y), int(x)])
|
|
267
|
+
if 0 <= y < ann.shape[0] and 0 <= x < ann.shape[1] else 0)
|
|
268
|
+
if rid != state.get('hover_id'):
|
|
269
|
+
state['hover_id'] = rid
|
|
270
|
+
highlight_layer.data = (ann == rid).astype(float) if rid else np.zeros(ann.shape)
|
|
271
|
+
return on_move
|
|
272
|
+
|
|
273
|
+
def launch_render(self, argv: list[str], status=None, msg: str = ''):
|
|
274
|
+
"""Shell out to a brainrender CLI in a separate window (non-blocking).
|
|
275
|
+
|
|
276
|
+
The child inherits our terminal's stdout/stderr, so a crash is visible where regrender
|
|
277
|
+
was launched. brainrender's ``Scene`` re-checks the atlas version over the network (no
|
|
278
|
+
timeout, 5 retries) on every launch, stalling the render for minutes on a slow link — so
|
|
279
|
+
we disable that check in the child before running the CLI."""
|
|
280
|
+
if status is not None:
|
|
281
|
+
status.value = msg
|
|
282
|
+
module, *rest = argv
|
|
283
|
+
boot = (
|
|
284
|
+
'import runpy, sys, brainglobe_atlasapi.bg_atlas as b;'
|
|
285
|
+
'b.BrainGlobeAtlas.check_latest_version = lambda self, *a, **k: None;'
|
|
286
|
+
f'sys.argv = [{module!r}, *sys.argv[1:]];'
|
|
287
|
+
f'runpy.run_module({module!r}, run_name="__main__")'
|
|
288
|
+
)
|
|
289
|
+
subprocess.Popen([sys.executable, '-c', boot, *rest])
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def header(text):
|
|
293
|
+
from magicgui.widgets import Label
|
|
294
|
+
lbl = Label(value=text)
|
|
295
|
+
lbl.native.setStyleSheet('font-weight: bold; color: #88c0d0; padding-top: 6px;')
|
|
296
|
+
return lbl
|
|
297
|
+
|
|
298
|
+
_CH = {'R': 0, 'G': 1, 'B': 2}
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def channel_view(cls, img, channel: str):
|
|
302
|
+
# show one RGB channel as grayscale (easier to see labels); pixel coords are unchanged
|
|
303
|
+
if channel == 'merge' or getattr(img, 'ndim', 0) != 3:
|
|
304
|
+
return img
|
|
305
|
+
idx = cls._CH.get(channel, 0)
|
|
306
|
+
return img[..., idx] if idx < img.shape[2] else img
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def srow(*ws):
|
|
310
|
+
from magicgui.widgets import Container
|
|
311
|
+
return Container(widgets=list(ws), layout='horizontal', labels=False)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def add_scroll_dock(viewer, panel, name: str):
|
|
315
|
+
from qtpy.QtCore import Qt
|
|
316
|
+
from qtpy.QtWidgets import QScrollArea
|
|
317
|
+
scroll = QScrollArea()
|
|
318
|
+
scroll.setWidgetResizable(True) # panel fills the dock width; scrolls vertically when tall
|
|
319
|
+
scroll.setWidget(panel.native)
|
|
320
|
+
scroll.setMinimumWidth(360)
|
|
321
|
+
dock = viewer.window.add_dock_widget(scroll, area='right', name=name)
|
|
322
|
+
# start the dock wider (user can still drag it narrower down to the 360 minimum)
|
|
323
|
+
try:
|
|
324
|
+
viewer.window._qt_window.resizeDocks([dock], [460], Qt.Horizontal)
|
|
325
|
+
except Exception: # noqa: BLE001 - private API; harmless if it moves/changes
|
|
326
|
+
pass
|
regrender/_core.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Pure, GUI-free building blocks shared across regrender modes.
|
|
2
|
+
|
|
3
|
+
Everything here is side-effect-light and unit-testable without napari, so future
|
|
4
|
+
modes (cell annotation, probe tracks, ...) can reuse the same transform/image/atlas
|
|
5
|
+
helpers. The napari front-ends live in the ``main_*`` modules.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypedDict
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import imageio.v3 as iio
|
|
15
|
+
import numpy as np
|
|
16
|
+
import PIL.Image
|
|
17
|
+
from neuralib.atlas.ccf.matrix import SLICE_DIMENSION_10um
|
|
18
|
+
from neuralib.atlas.typing import PLANE_TYPE
|
|
19
|
+
|
|
20
|
+
# whole-slide scans routinely exceed Pillow's 0.18 GPixel decompression-bomb cap; the
|
|
21
|
+
# files are our own, not untrusted uploads
|
|
22
|
+
PIL.Image.MAX_IMAGE_PIXELS = None
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
'read_oriented', 'rotate', 'to_uint8', 'boundary_mask', 'region_name',
|
|
26
|
+
'estimate_transform', 'save_transform', 'apply_transformation',
|
|
27
|
+
'plane_point_to_ccf_mm', 'ccf_mm_to_plane_point',
|
|
28
|
+
'raw_points_to_atlas', 'TransformMeta', 'load_transform',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- transform meta --------------------------------------------------
|
|
33
|
+
|
|
34
|
+
class TransformMeta(TypedDict):
|
|
35
|
+
"""Schema of a ``*_transform.json`` — the histology→atlas registration written by
|
|
36
|
+
``save_transform`` and consumed by the probe/roi modes. ``rotate``/``flip_lr``/``flip_ud``
|
|
37
|
+
record the preprocessing so raw points can be replayed into atlas space."""
|
|
38
|
+
matrix: list[list[float]]
|
|
39
|
+
"""3x3 homography (resized-slice -> atlas-plane pixels)"""
|
|
40
|
+
plane: PLANE_TYPE
|
|
41
|
+
"""cutting plane, selects the resize dimension"""
|
|
42
|
+
resolution: int
|
|
43
|
+
"""atlas resolution (um)"""
|
|
44
|
+
slice_index: int
|
|
45
|
+
"""voxel plane index the slice was registered to"""
|
|
46
|
+
dw: int
|
|
47
|
+
"""resize width the matrix was fit on (SLICE_DIMENSION_10um[plane])"""
|
|
48
|
+
dh: int
|
|
49
|
+
"""resize height the matrix was fit on"""
|
|
50
|
+
rotate: float
|
|
51
|
+
"""rotation (deg) applied before resize/matrix, for replay"""
|
|
52
|
+
flip_lr: bool
|
|
53
|
+
"""left-right flip applied before rotate, for replay"""
|
|
54
|
+
flip_ud: bool
|
|
55
|
+
"""up-down flip applied before flip_lr, for replay"""
|
|
56
|
+
contrast: list[float] | None
|
|
57
|
+
"""(lo, hi) window used for uint8 display, if any"""
|
|
58
|
+
slice_xy: list[list[float]]
|
|
59
|
+
"""[N, 2] (x, y) points picked on the resized slice"""
|
|
60
|
+
atlas_xy: list[list[float]]
|
|
61
|
+
"""[N, 2] matched (x, y) points picked on the atlas plane"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_transform(path: Path) -> TransformMeta:
|
|
65
|
+
"""Read and parse a ``*_transform.json``."""
|
|
66
|
+
return json.loads(Path(path).read_text())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- image preprocessing ---------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def read_oriented(path: Path, flip_lr: bool = False, flip_ud: bool = False) -> np.ndarray:
|
|
72
|
+
"""read an image, normalize channel-first tiffs to (H, W, C), and apply flips"""
|
|
73
|
+
img = iio.imread(path)
|
|
74
|
+
if img.ndim == 3 and img.shape[0] in (3, 4) and img.shape[-1] not in (3, 4):
|
|
75
|
+
img = np.moveaxis(img, 0, -1) # (C, H, W) -> (H, W, C)
|
|
76
|
+
if flip_ud:
|
|
77
|
+
img = np.flipud(img)
|
|
78
|
+
if flip_lr:
|
|
79
|
+
img = np.fliplr(img)
|
|
80
|
+
return img
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def rotate(img: np.ndarray, deg: float) -> np.ndarray:
|
|
84
|
+
"""rotate about the image center, keeping the original shape"""
|
|
85
|
+
if not deg:
|
|
86
|
+
return img
|
|
87
|
+
h, w = img.shape[:2]
|
|
88
|
+
m = cv2.getRotationMatrix2D((w / 2, h / 2), deg, 1.0)
|
|
89
|
+
return cv2.warpAffine(img, m, (w, h))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_uint8(img: np.ndarray, contrast: tuple[float, float] | None = None) -> np.ndarray:
|
|
93
|
+
"""map to uint8 using a (lo, hi) contrast window, else full min-max"""
|
|
94
|
+
img = np.asarray(img, dtype=float)
|
|
95
|
+
lo, hi = contrast if contrast is not None else (float(img.min()), float(img.max()))
|
|
96
|
+
return (np.clip((img - lo) / ((hi - lo) or 1.0), 0, 1) * 255).astype(np.uint8)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# --- atlas annotation ------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def boundary_mask(ann: np.ndarray) -> np.ndarray:
|
|
102
|
+
"""region boundaries = pixels where the annotation id changes vs its right/down neighbour"""
|
|
103
|
+
b = np.zeros(ann.shape, dtype=float)
|
|
104
|
+
b[:, :-1] = np.maximum(b[:, :-1], ann[:, :-1] != ann[:, 1:])
|
|
105
|
+
b[:-1, :] = np.maximum(b[:-1, :], ann[:-1, :] != ann[1:, :])
|
|
106
|
+
return b
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def region_name(ann: np.ndarray | None, structures: Any, y: float, x: float) -> str:
|
|
110
|
+
"""acronym/name of the atlas region at (y, x) in the annotation plane, '' if none"""
|
|
111
|
+
if ann is None or not (0 <= y < ann.shape[0] and 0 <= x < ann.shape[1]):
|
|
112
|
+
return ''
|
|
113
|
+
rid = int(ann[int(y), int(x)])
|
|
114
|
+
if rid == 0:
|
|
115
|
+
return ''
|
|
116
|
+
try:
|
|
117
|
+
s = structures[rid]
|
|
118
|
+
return f"{s['acronym']} — {s['name']}"
|
|
119
|
+
except KeyError:
|
|
120
|
+
return f'id {rid}'
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- transform -------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def estimate_transform(slice_xy: np.ndarray, atlas_xy: np.ndarray, *, affine: bool = False) -> np.ndarray:
|
|
126
|
+
"""Estimate the 3x3 matrix mapping histology (slice) points onto atlas points.
|
|
127
|
+
|
|
128
|
+
Matches ``cv2.warpPerspective`` convention: the matrix warps the slice into atlas space.
|
|
129
|
+
|
|
130
|
+
:param slice_xy: ``Array[float, [N, 2]]`` (x, y) points on the (resized) histology slice.
|
|
131
|
+
:param atlas_xy: ``Array[float, [N, 2]]`` matched (x, y) points on the atlas plane.
|
|
132
|
+
:param affine: estimate an affine (6 DOF) instead of projective (8 DOF) transform.
|
|
133
|
+
:return: ``Array[float64, [3, 3]]``
|
|
134
|
+
"""
|
|
135
|
+
slice_xy = np.asarray(slice_xy, dtype=np.float64)
|
|
136
|
+
atlas_xy = np.asarray(atlas_xy, dtype=np.float64)
|
|
137
|
+
if slice_xy.shape != atlas_xy.shape:
|
|
138
|
+
raise ValueError(f'point count mismatch: {slice_xy.shape} vs {atlas_xy.shape}')
|
|
139
|
+
|
|
140
|
+
if affine:
|
|
141
|
+
if len(slice_xy) < 3:
|
|
142
|
+
raise ValueError('affine transform needs >=3 matched point pairs')
|
|
143
|
+
m, _ = cv2.estimateAffine2D(slice_xy, atlas_xy)
|
|
144
|
+
return np.vstack([m, [0, 0, 1]]).astype(np.float64)
|
|
145
|
+
|
|
146
|
+
if len(slice_xy) < 4:
|
|
147
|
+
raise ValueError('projective transform needs >=4 matched point pairs')
|
|
148
|
+
|
|
149
|
+
m, _ = cv2.findHomography(slice_xy, atlas_xy)
|
|
150
|
+
|
|
151
|
+
return m.astype(np.float64)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save_transform(matrix: np.ndarray, *,
|
|
155
|
+
output_dir: Path, name: str,
|
|
156
|
+
plane: PLANE_TYPE, resolution: int,
|
|
157
|
+
slice_index: int, dw: int, dh: int,
|
|
158
|
+
slice_xy: np.ndarray, atlas_xy: np.ndarray,
|
|
159
|
+
rotate: float = 0.0, flip_lr: bool = False, flip_ud: bool = False,
|
|
160
|
+
contrast: tuple[float, float] | None = None) -> Path:
|
|
161
|
+
"""Save the 3x3 matrix and metadata into a single ``.json``. Returns its path.
|
|
162
|
+
|
|
163
|
+
``rotate``/``flip_lr``/``flip_ud`` record the preprocessing so the result can be
|
|
164
|
+
reproduced (raw -> flip -> rotate -> resize -> apply matrix) and the session resumed.
|
|
165
|
+
"""
|
|
166
|
+
output_dir = Path(output_dir)
|
|
167
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
meta: TransformMeta = {
|
|
169
|
+
'matrix': np.asarray(matrix, dtype=float).tolist(),
|
|
170
|
+
'plane': plane,
|
|
171
|
+
'resolution': resolution,
|
|
172
|
+
'slice_index': int(slice_index),
|
|
173
|
+
'dw': int(dw),
|
|
174
|
+
'dh': int(dh),
|
|
175
|
+
'rotate': float(rotate),
|
|
176
|
+
'flip_lr': bool(flip_lr),
|
|
177
|
+
'flip_ud': bool(flip_ud),
|
|
178
|
+
'contrast': list(contrast) if contrast is not None else None,
|
|
179
|
+
'slice_xy': np.asarray(slice_xy, dtype=float).tolist(),
|
|
180
|
+
'atlas_xy': np.asarray(atlas_xy, dtype=float).tolist(),
|
|
181
|
+
}
|
|
182
|
+
js = output_dir / f'{name}_transform.json'
|
|
183
|
+
js.write_text(json.dumps(meta, indent=2))
|
|
184
|
+
return js
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def apply_transformation(img: np.ndarray, m: np.ndarray) -> np.ndarray:
|
|
188
|
+
h, w = img.shape[:2]
|
|
189
|
+
return cv2.warpPerspective(img, m, (w, h))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- probe reconstruction --------------------------------------------------
|
|
193
|
+
|
|
194
|
+
def plane_point_to_ccf_mm(
|
|
195
|
+
plane_num: float, x: float, y: float, *,
|
|
196
|
+
project_index: tuple[int, int, int],
|
|
197
|
+
resolution: int,
|
|
198
|
+
bregma_10um: tuple[int, int, int] = (540, 0, 570),
|
|
199
|
+
) -> tuple[float, float, float]:
|
|
200
|
+
"""A clicked atlas-plane pixel ``(x, y)`` on plane number ``plane_num`` -> bregma-relative
|
|
201
|
+
CCF ``(AP, DV, ML)`` in mm.
|
|
202
|
+
|
|
203
|
+
This is the inverse of ``neuralib.atlas.util.allen_to_brainrender_coord``, so a CSV of these
|
|
204
|
+
points feeds ``ProbeRenderCLI`` (the existing interpolation + brainrender) directly.
|
|
205
|
+
|
|
206
|
+
:param plane_num: voxel plane index the pixel sits on (``slice_index`` + tilt offset).
|
|
207
|
+
:param x: atlas-plane column (the view's ``project_index`` x axis).
|
|
208
|
+
:param y: atlas-plane row (the view's ``project_index`` y axis).
|
|
209
|
+
:param project_index: ``view.project_index`` — (plane, x, y) positions within (AP, DV, ML).
|
|
210
|
+
:param resolution: atlas resolution (µm).
|
|
211
|
+
:param bregma_10um: bregma in 10µm voxels, AP/DV/ML (``ALLEN_CCF_10um_BREGMA``).
|
|
212
|
+
"""
|
|
213
|
+
pidx, xidx, yidx = project_index
|
|
214
|
+
idx = [0.0, 0.0, 0.0]
|
|
215
|
+
idx[pidx], idx[xidx], idx[yidx] = plane_num, x, y
|
|
216
|
+
ap, dv, ml = (v * resolution for v in idx) # voxel -> µm (absolute)
|
|
217
|
+
bap, bdv, bml = (b * 10 for b in bregma_10um) # 10µm voxel -> µm
|
|
218
|
+
return (bap - ap) / 1000, (dv - bdv) / 1000, (bml - ml) / 1000
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def ccf_mm_to_voxel(
|
|
222
|
+
ccf: tuple[float, float, float], *,
|
|
223
|
+
resolution: int = 10,
|
|
224
|
+
bregma_10um: tuple[int, int, int] = (540, 0, 570),
|
|
225
|
+
) -> tuple[float, float, float]:
|
|
226
|
+
"""Bregma-relative CCF ``(AP, DV, ML)`` mm -> absolute atlas voxel ``(AP, DV, ML)``.
|
|
227
|
+
|
|
228
|
+
Same index math as :func:`ccf_mm_to_plane_point` but without the ``project_index`` reorder,
|
|
229
|
+
so it feeds ``BrainGlobeAtlas.structure_from_coords`` directly (allen space ``ap, si, rl``).
|
|
230
|
+
"""
|
|
231
|
+
ap_mm, dv_mm, ml_mm = ccf
|
|
232
|
+
bap, bdv, bml = (b * 10 for b in bregma_10um) # 10µm voxel -> µm
|
|
233
|
+
return ((bap - ap_mm * 1000) / resolution,
|
|
234
|
+
(dv_mm * 1000 + bdv) / resolution,
|
|
235
|
+
(bml - ml_mm * 1000) / resolution)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def shank_distances(pts: dict[tuple[int, str], tuple[float, float, float]]) -> list[tuple[int, float]]:
|
|
239
|
+
"""Per-shank dorsal<->ventral euclidean distance (µm), from a ``{(shank, point): (AP, DV, ML) mm}``
|
|
240
|
+
map. Only shanks with both points are returned, sorted by shank number."""
|
|
241
|
+
out = []
|
|
242
|
+
for s in sorted({k for k, _ in pts}):
|
|
243
|
+
d, v = pts.get((s, 'dorsal')), pts.get((s, 'ventral'))
|
|
244
|
+
if d is not None and v is not None:
|
|
245
|
+
out.append((s, float(np.linalg.norm(np.array(v, float) - np.array(d, float))) * 1000))
|
|
246
|
+
return out
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def raw_points_to_atlas(
|
|
250
|
+
pts_xy: np.ndarray, *,
|
|
251
|
+
matrix: np.ndarray,
|
|
252
|
+
raw_shape: tuple[int, int],
|
|
253
|
+
plane: PLANE_TYPE,
|
|
254
|
+
rotate_deg: float = 0.0,
|
|
255
|
+
flip_lr: bool = False,
|
|
256
|
+
flip_ud: bool = False
|
|
257
|
+
) -> np.ndarray:
|
|
258
|
+
"""Forward-map raw-image ``(x, y)`` pixels into atlas-plane ``(x, y)`` pixels.
|
|
259
|
+
|
|
260
|
+
Replays register's preprocessing pipeline (read -> flip -> rotate -> resize-to-dim ->
|
|
261
|
+
matrix) on points, so ROIs labelled on the original histology land in the same atlas-plane
|
|
262
|
+
space ``plane_point_to_ccf_mm`` consumes. The matrix was fit on the image resized to
|
|
263
|
+
``SLICE_DIMENSION_10um[plane]`` (see ``slice_transform_helper``), so resize happens here too.
|
|
264
|
+
|
|
265
|
+
:param pts_xy: ``Array[float, [N, 2]]`` (x, y) on the raw image.
|
|
266
|
+
:param matrix: 3x3 homography from the saved ``*_transform.json``.
|
|
267
|
+
:param raw_shape: ``(H, W)`` of the raw image file.
|
|
268
|
+
:param plane: cutting plane (selects the resize dimension).
|
|
269
|
+
:param rotate_deg: rotation recorded at registration (same convention as :func:`rotate`).
|
|
270
|
+
:return: ``Array[float64, [N, 2]]`` atlas-plane (x, y).
|
|
271
|
+
"""
|
|
272
|
+
pts = np.asarray(pts_xy, dtype=np.float64).reshape(-1, 2)
|
|
273
|
+
h, w = raw_shape[:2]
|
|
274
|
+
x, y = pts[:, 0].copy(), pts[:, 1].copy()
|
|
275
|
+
if flip_lr:
|
|
276
|
+
x = (w - 1) - x
|
|
277
|
+
if flip_ud:
|
|
278
|
+
y = (h - 1) - y
|
|
279
|
+
if rotate_deg:
|
|
280
|
+
m = cv2.getRotationMatrix2D((w / 2, h / 2), rotate_deg, 1.0) # same matrix warpAffine applies
|
|
281
|
+
xy = m @ np.vstack([x, y, np.ones_like(x)])
|
|
282
|
+
x, y = xy[0], xy[1]
|
|
283
|
+
dim = SLICE_DIMENSION_10um[plane] # (width, height)
|
|
284
|
+
x = x * (dim[0] / w)
|
|
285
|
+
y = y * (dim[1] / h)
|
|
286
|
+
src = np.stack([x, y], axis=1).reshape(-1, 1, 2)
|
|
287
|
+
return cv2.perspectiveTransform(src, np.asarray(matrix, dtype=np.float64)).reshape(-1, 2)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def ccf_mm_to_plane_point(
|
|
291
|
+
ccf: tuple[float, float, float], *,
|
|
292
|
+
project_index: tuple[int, int, int],
|
|
293
|
+
resolution: int,
|
|
294
|
+
bregma_10um: tuple[int, int, int] = (540, 0, 570),
|
|
295
|
+
) -> tuple[float, float, float]:
|
|
296
|
+
"""Inverse of :func:`plane_point_to_ccf_mm`: bregma-relative CCF ``(AP, DV, ML)`` mm ->
|
|
297
|
+
``(plane_num, x, y)`` in atlas voxels. Lets a saved coordinate be re-placed on a slice
|
|
298
|
+
(the cross belongs on the slice whose ``plane_offset`` at ``(y, x)`` equals ``plane_num``).
|
|
299
|
+
"""
|
|
300
|
+
ap_mm, dv_mm, ml_mm = ccf
|
|
301
|
+
bap, bdv, bml = (b * 10 for b in bregma_10um) # 10µm voxel -> µm
|
|
302
|
+
idx = [(bap - ap_mm * 1000) / resolution, # AP, DV, ML voxel indices
|
|
303
|
+
(dv_mm * 1000 + bdv) / resolution,
|
|
304
|
+
(bml - ml_mm * 1000) / resolution]
|
|
305
|
+
pidx, xidx, yidx = project_index
|
|
306
|
+
return idx[pidx], idx[xidx], idx[yidx]
|