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 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]