regrender 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,638 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import cv2
7
+ import imageio.v3 as iio
8
+ import numpy as np
9
+ from argclz import AbstractParser, argument
10
+ from brainglobe_atlasapi import BrainGlobeAtlas
11
+ from neuralib.atlas.ccf.matrix import SLICE_DIMENSION_10um
12
+ from neuralib.atlas.typing import PLANE_TYPE
13
+ from neuralib.atlas.view import get_slice_view
14
+
15
+ from regrender._app import TerminalLog, printf
16
+ from regrender._core import (
17
+ apply_transformation,
18
+ boundary_mask,
19
+ estimate_transform,
20
+ load_transform,
21
+ read_oriented,
22
+ region_name,
23
+ rotate,
24
+ save_transform,
25
+ to_uint8,
26
+ )
27
+
28
+ __all__ = ['RegisterOptions']
29
+
30
+
31
+ class RegisterOptions(AbstractParser):
32
+ DESCRIPTION = 'Interactively register a histology slice to the Allen CCF (napari)'
33
+
34
+ raw_image: Path | None = argument(
35
+ '-I', '--image',
36
+ default=None,
37
+ help='histology image path (optional; can also load it from the GUI)'
38
+ )
39
+
40
+ directory: Path | None = argument(
41
+ '-D', '--directory',
42
+ default=None,
43
+ help='folder of serial sections; step through them with Prev/Next in the GUI'
44
+ )
45
+
46
+ load: Path | None = argument(
47
+ '--load',
48
+ default=None,
49
+ help='resume from a saved *_transform.json (restores points + index/dw/dh/rotate/flips)'
50
+ )
51
+
52
+ output_dir: Path | None = argument(
53
+ '-O', '--output-dir',
54
+ default=None,
55
+ help='output directory (default: <image-dir>/transformations)'
56
+ )
57
+
58
+ # noinspection PyTypeChecker
59
+ cut_plane: PLANE_TYPE = argument(
60
+ '-P', '--plane-type',
61
+ default='coronal',
62
+ help='cutting orientation'
63
+ )
64
+
65
+ resolution: int = argument('--resolution', default=10, help='atlas resolution (um)')
66
+ name: str | None = argument('--name', default=None, help='output name (default: image stem)')
67
+ flip_lr: bool = argument('--flip-lr', help='flip histology left-right before registration')
68
+ flip_ud: bool = argument('--flip-ud', help='flip histology up-down before registration')
69
+ affine: bool = argument('--affine', help='use affine instead of projective transform')
70
+
71
+ #
72
+ AXIS = {'coronal': ('ML', 'DV'), 'sagittal': ('AP', 'DV')}
73
+ _IMG_EXT = {'.tif', '.tiff', '.png', '.jpg', '.jpeg'}
74
+
75
+ def __init__(self):
76
+ self._oriented = None
77
+
78
+ def _list_images(self, d: Path) -> list[Path]:
79
+ return sorted(p for p in Path(d).iterdir() if p.suffix.lower() in self._IMG_EXT)
80
+
81
+ def run(self):
82
+ if self.cut_plane not in SLICE_DIMENSION_10um:
83
+ raise ValueError(f'plane {self.cut_plane!r} not supported yet '
84
+ f'(available: {list(SLICE_DIMENSION_10um)})')
85
+
86
+ load = load_transform(self.load) if self.load else None
87
+
88
+ if load: # resume: preprocessing must match the saved session
89
+ self.flip_lr = load.get('flip_lr', self.flip_lr)
90
+ self.flip_ud = load.get('flip_ud', self.flip_ud)
91
+ if self.raw_image is None:
92
+ raise ValueError('--load needs the matching image via -I/--image')
93
+
94
+ files = self._list_images(self.directory) if self.directory else []
95
+ if files and self.raw_image is None:
96
+ self.raw_image = files[0]
97
+
98
+ base = self.raw_image.parent if self.raw_image else Path.cwd()
99
+ out_dir = self.output_dir or base / 'transformations'
100
+ name = self.name or (self.raw_image.stem if self.raw_image else 'untitled')
101
+
102
+ view = get_slice_view('reference', self.cut_plane, resolution=self.resolution, check_latest=False)
103
+ atlas_w = int(view.width)
104
+
105
+ self._oriented = self._read_oriented(self.raw_image) # None until an image is loaded
106
+ self._launch_napari(view, atlas_w, out_dir, name, load, files)
107
+
108
+ def _read_oriented(self, path: Path | None) -> np.ndarray | None:
109
+ """read an image with the current flips (pre-rotate, pre-resize), or None"""
110
+ return None if path is None else read_oriented(path, self.flip_lr, self.flip_ud)
111
+
112
+ def _launch_napari(self, ref_view, atlas_w: int, out_dir: Path, name: str,
113
+ load: dict | None = None, files: list[Path] | None = None):
114
+ import napari
115
+ from magicgui.widgets import (
116
+ CheckBox,
117
+ ComboBox,
118
+ Container,
119
+ Label,
120
+ PushButton,
121
+ SpinBox,
122
+ )
123
+ from matplotlib.colors import to_rgb
124
+ from napari.utils import Colormap
125
+
126
+ load = load or {}
127
+ dim = SLICE_DIMENSION_10um[self.cut_plane]
128
+
129
+ def make_hist(angle: float) -> np.ndarray:
130
+ if self._oriented is None:
131
+ return np.zeros((dim[1], dim[0])) # placeholder until an image is loaded
132
+ return cv2.resize(rotate(self._oriented, angle), dim)
133
+
134
+ angle0 = float(load.get('rotate', 0.0))
135
+ state = {'index': int(load.get('slice_index', int(ref_view.n_planes) // 2)),
136
+ 'dw': int(load.get('dw', 0)), 'dh': int(load.get('dh', 0)),
137
+ 'expect': 'atlas', 'ann': None, 'hist': make_hist(angle0),
138
+ 'name': name, 'out_dir': out_dir, 'path': self.raw_image,
139
+ 'files': files or [], 'cursor': 0}
140
+ if state['files'] and self.raw_image in state['files']:
141
+ state['cursor'] = state['files'].index(self.raw_image)
142
+
143
+ ann_view = get_slice_view('annotation', self.cut_plane, resolution=self.resolution, check_latest=False)
144
+ structures = BrainGlobeAtlas(f'allen_mouse_{self.resolution}um', check_latest=False).structures
145
+
146
+ def plane_image() -> np.ndarray:
147
+ i, dw, dh = state['index'], state['dw'], state['dh']
148
+ od = lambda v: v + 1 if v != 0 else 0
149
+ state['ann'] = ann_view.plane_at(i).with_offset(od(dw), od(dh)).image
150
+ ref_plane = ref_view.plane_at(i).with_offset(od(dw), od(dh))
151
+ state['ref_mm'] = ref_plane.reference_value
152
+ return ref_plane.image
153
+
154
+ def pts_kw(color) -> dict[str, Any]:
155
+ return dict(
156
+ face_color=color, border_color=color, symbol='cross', size=20, ndim=2,
157
+ features={'n': np.empty(0, dtype='<U3')},
158
+ text={'string': '{n}', 'color': color, 'size': 12, 'translation': [-12, 0]}
159
+ )
160
+
161
+ brgb = to_rgb('orange') # (r, g, b) in 0..1
162
+ state['brgb'] = brgb
163
+ bcmap = Colormap([[0, 0, 0], list(brgb)], name='boundary') # 0 -> transparent (additive), 1 -> color
164
+
165
+ viewer = napari.Viewer(title=f'regrender register — {name}')
166
+ viewer.text_overlay.visible = True
167
+ viewer.text_overlay.font_size = 18
168
+ viewer.text_overlay.color = 'yellow'
169
+ atlas_layer = viewer.add_image(plane_image(), name='atlas', colormap='gray')
170
+ bound_layer = viewer.add_image(boundary_mask(state['ann']), name='boundaries',
171
+ colormap=bcmap, blending='additive', opacity=0.9)
172
+ # filled overlay of the atlas region under the cursor (updated in on_move)
173
+ hcmap = Colormap([[0, 0, 0], [0.3, 0.6, 1.0]], name='highlight')
174
+ highlight_layer = viewer.add_image(np.zeros(state['ann'].shape, dtype=float),
175
+ name='region_highlight', colormap=hcmap,
176
+ blending='additive', opacity=0.4)
177
+ state['hover_id'] = None
178
+ hist_layer = viewer.add_image(state['hist'], name='histology', translate=(0, atlas_w))
179
+
180
+ # reference xy grid over the histology (toggled off), 100 px spacing. drawn as an
181
+ # image on the pixel grid (nearest) so it scales with the slice and is zoom-stable
182
+ step = 100
183
+
184
+ def make_grid(w: int, h: int) -> np.ndarray:
185
+ g = np.zeros((h, w), dtype=float)
186
+ g[::step, :] = 1
187
+ g[:, ::step] = 1
188
+ g[-1, :] = g[:, -1] = 1
189
+ return g
190
+
191
+ grid_layer = viewer.add_image(make_grid(*dim), name='xy_grid', translate=(0, atlas_w),
192
+ colormap='gray', blending='additive', opacity=0.5,
193
+ interpolation2d='nearest')
194
+ grid_layer.visible = False
195
+
196
+ atlas_pts = viewer.add_points(name='atlas_pts', **pts_kw('red'))
197
+ slice_pts = viewer.add_points(name='slice_pts', **pts_kw('cyan'))
198
+
199
+ def set_histology(img: np.ndarray):
200
+ # re-create the layer so napari re-detects rgb/ndim (grayscale<->RGB safely), keep its position
201
+ nonlocal hist_layer
202
+ idx = viewer.layers.index(hist_layer)
203
+ viewer.layers.remove(hist_layer)
204
+ hist_layer = viewer.add_image(img, name='histology', translate=(0, atlas_w))
205
+ viewer.layers.move(len(viewer.layers) - 1, idx)
206
+
207
+ def renumber(layer):
208
+ layer.features = {'n': np.array([str(i + 1) for i in range(len(layer.data))], dtype='<U3')}
209
+
210
+ def add_pt(layer, pos):
211
+ layer.data = np.vstack([layer.data, pos]) if len(layer.data) else np.array([pos])
212
+ renumber(layer)
213
+ sync_orient_lock()
214
+
215
+ @viewer.mouse_drag_callbacks.append
216
+ def on_click(_v, event):
217
+ dragged = False
218
+ yield
219
+ while event.type == 'mouse_move':
220
+ dragged = True
221
+ yield
222
+ if dragged or not pick_w.value:
223
+ return
224
+ y, x = event.position # world coords (row, col)
225
+ n = len(atlas_pts.data) + 1
226
+ if state['expect'] == 'atlas':
227
+ if x >= atlas_w:
228
+ status.value = f'expected an ATLAS click (left side) for pt {n}'
229
+ return
230
+ add_pt(atlas_pts, (y, x))
231
+ state['expect'] = 'slice'
232
+ status.value = f'now click the matching point on the histology (slice pt {n})'
233
+ else:
234
+ if x < atlas_w:
235
+ status.value = f'expected a SLICE click (right side) for pt {len(slice_pts.data) + 1}'
236
+ return
237
+ add_pt(slice_pts, (y, x))
238
+ state['expect'] = 'atlas'
239
+ status.value = f'pair {len(slice_pts.data)} set — click atlas landmark {len(slice_pts.data) + 1}'
240
+
241
+ w_axis, h_axis = self.AXIS.get(self.cut_plane, ('w', 'h'))
242
+ res = self.resolution
243
+ plane_w = ComboBox(label='plane', choices=list(SLICE_DIMENSION_10um), value=self.cut_plane)
244
+ idx_w = SpinBox(label='slice index (voxel)', value=state['index'], min=0, max=int(ref_view.n_planes) - 1)
245
+ dw_w = SpinBox(label=f'dw / {w_axis} tilt (voxel)', value=state['dw'], min=-200, max=200)
246
+ dh_w = SpinBox(label=f'dh / {h_axis} tilt (voxel)', value=state['dh'], min=-200, max=200)
247
+ rot_w = SpinBox(label='rotate (deg)', value=angle0, min=-180, max=180)
248
+ flip_lr_w = CheckBox(label='flip L-R', value=self.flip_lr)
249
+ flip_ud_w = CheckBox(label='flip U-D', value=self.flip_ud)
250
+ pick_w = CheckBox(label='pick points', value=True)
251
+ grid_w = CheckBox(label='xy grid', value=False)
252
+ grid_w.changed.connect(lambda *_: setattr(grid_layer, 'visible', grid_w.value))
253
+
254
+ color_w = ComboBox(label='boundary color', value='orange',
255
+ choices=['orange', 'red', 'cyan', 'yellow', 'magenta', 'lime', 'white', 'blue'])
256
+
257
+ def set_bcolor(*_):
258
+ state['brgb'] = to_rgb(color_w.value)
259
+ cm = Colormap([[0, 0, 0], list(state['brgb'])], name='boundary')
260
+ bound_layer.colormap = cm
261
+
262
+ color_w.changed.connect(set_bcolor)
263
+
264
+ def set_rotation(*_):
265
+ state['hist'] = make_hist(rot_w.value)
266
+ hist_layer.data = state['hist']
267
+ slice_pts.data = np.empty((0, 2)) # slice points are stale once the image rotates
268
+ renumber(slice_pts)
269
+ state['expect'] = 'atlas' if len(atlas_pts.data) == 0 else (
270
+ 'slice' if len(atlas_pts.data) > len(slice_pts.data) else 'atlas')
271
+ status.value = f'rotated {rot_w.value}° — re-pick the slice points'
272
+
273
+ rot_w.changed.connect(set_rotation)
274
+
275
+ def set_flip(*_):
276
+ self.flip_lr, self.flip_ud = flip_lr_w.value, flip_ud_w.value
277
+ self._oriented = self._read_oriented(state['path']) # flips applied at read time
278
+ state['hist'] = make_hist(rot_w.value)
279
+ set_histology(state['hist'])
280
+ slice_pts.data = np.empty((0, 2)) # slice points are stale once the image flips
281
+ renumber(slice_pts)
282
+ state['expect'] = 'atlas' if len(atlas_pts.data) == len(slice_pts.data) else 'slice'
283
+ status.value = 'flipped — re-pick the slice points'
284
+
285
+ flip_lr_w.changed.connect(set_flip)
286
+ flip_ud_w.changed.connect(set_flip)
287
+
288
+ for w in (plane_w, rot_w, flip_lr_w, flip_ud_w, idx_w, dw_w, dh_w):
289
+ w.tooltip = 'clear points (or Re-register) to change the atlas plane / orientation'
290
+
291
+ def sync_orient_lock():
292
+ # plane-first: the plane, atlas index/tilt and slice orientation are fixed once any
293
+ # point is picked, since changing them moves the plane out from under the points
294
+ locked = bool(len(atlas_pts.data) or len(slice_pts.data))
295
+ for w in (plane_w, rot_w, flip_lr_w, flip_ud_w, idx_w, dw_w, dh_w):
296
+ w.enabled = not locked
297
+
298
+ def info_text() -> str:
299
+ return (f"index {state['index']} = {state.get('ref_mm', '?')} mm from Bregma · "
300
+ f"dw {state['dw'] * res} µm, dh {state['dh'] * res} µm")
301
+
302
+ info_w = Label(value=info_text())
303
+ img_lbl = Label(value='no image loaded') # current file (i/N) shown in the Image section
304
+ img_lbl.native.setWordWrap(True)
305
+ # scrolling terminal-style log: every status.value = msg appends a timestamped, colored line
306
+ status_label = Label(value='')
307
+ status_label.native.setStyleSheet(
308
+ 'font-family: Menlo, Consolas, monospace; font-size: 12px; '
309
+ 'background: #11131a; padding: 6px;') # per-line color comes from TerminalLog HTML
310
+ status_label.native.setWordWrap(True)
311
+ from qtpy.QtCore import Qt
312
+ from qtpy.QtWidgets import QSizePolicy
313
+ status_label.native.setTextFormat(Qt.RichText)
314
+ status_label.native.setAlignment(Qt.AlignLeft | Qt.AlignTop)
315
+ status_label.native.setMinimumHeight(220)
316
+ status_label.native.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
317
+ status = TerminalLog(status_label)
318
+ status.value = 'click an atlas landmark (left), then its match on the slice (right)'
319
+
320
+ def reset_highlight():
321
+ highlight_layer.data = np.zeros(state['ann'].shape, dtype=float)
322
+ state['hover_id'] = None
323
+
324
+ def refresh(*_):
325
+ state['index'], state['dw'], state['dh'] = idx_w.value, dw_w.value, dh_w.value
326
+ atlas_layer.data = plane_image()
327
+ bound_layer.data = boundary_mask(state['ann'])
328
+ reset_highlight() # annotation plane changed; stale mask
329
+ info_w.value = info_text()
330
+
331
+ idx_w.changed.connect(refresh)
332
+ dw_w.changed.connect(refresh)
333
+ dh_w.changed.connect(refresh)
334
+
335
+ @viewer.mouse_move_callbacks.append
336
+ def on_move(_v, event):
337
+ y, x = event.position
338
+ ann = state['ann']
339
+ viewer.text_overlay.text = region_name(ann, structures, y, x)
340
+ rid = (int(ann[int(y), int(x)])
341
+ if 0 <= y < ann.shape[0] and 0 <= x < ann.shape[1] else 0)
342
+ if rid != state['hover_id']:
343
+ state['hover_id'] = rid
344
+ # full-plane mask (~1e6 px), recomputed only when crossing a region edge
345
+ highlight_layer.data = (ann == rid).astype(float) if rid else np.zeros(ann.shape)
346
+
347
+ def collect() -> tuple[np.ndarray, np.ndarray]:
348
+ # napari points are (row, col); convert to (x, y), un-translate the slice side
349
+ a = atlas_pts.data[:, ::-1]
350
+ s = (slice_pts.data - np.array([0, atlas_w]))[:, ::-1]
351
+ return s, a
352
+
353
+ def on_preview():
354
+ s, a = collect()
355
+ try:
356
+ m = estimate_transform(s, a, affine=self.affine)
357
+ except ValueError as e:
358
+ status.value = f'preview failed: {e}'
359
+ return
360
+ # warp the histology into atlas space and overlay it on the atlas (left) side, under the
361
+ # real atlas boundaries -> you see the transformed histology with the straight boundaries
362
+ warped = apply_transformation(state['hist'], m)
363
+ if 'preview_transformed' in viewer.layers:
364
+ viewer.layers.remove('preview_transformed') # re-create (grayscale<->RGB safe)
365
+ pv = viewer.add_image(warped, name='preview_transformed', opacity=1.0)
366
+ # order bottom->top: atlas, transformed histology, boundaries, points
367
+ viewer.layers.move(viewer.layers.index(pv), viewer.layers.index(bound_layer))
368
+ for layer in (atlas_pts, slice_pts):
369
+ viewer.layers.move(viewer.layers.index(layer), len(viewer.layers) - 1)
370
+ status.value = 'preview on: transformed histology under the atlas boundaries — toggle off/on to refresh after moving points'
371
+
372
+ def on_exit_preview():
373
+ if 'preview_transformed' in viewer.layers:
374
+ viewer.layers.remove('preview_transformed')
375
+ status.value = 'preview closed'
376
+
377
+ def confirm(title: str, text: str) -> bool:
378
+ from qtpy.QtWidgets import QMessageBox
379
+ return QMessageBox.question(None, title, text) == QMessageBox.Yes
380
+
381
+ def on_save():
382
+ if self._oriented is None:
383
+ status.value = 'load an image first'
384
+ return
385
+ s, a = collect()
386
+ try:
387
+ m = estimate_transform(s, a, affine=self.affine)
388
+ except ValueError as e:
389
+ status.value = f'save failed: {e}'
390
+ return
391
+ out_dir, name = state['out_dir'], state['name']
392
+ js = out_dir / f'{name}_transform.json'
393
+ if js.exists() and not confirm('Overwrite registration?',
394
+ f'{js.name} already exists. Overwrite it?'):
395
+ status.value = 'save cancelled'
396
+ return
397
+ contrast = tuple(float(v) for v in hist_layer.contrast_limits)
398
+ js = save_transform(m, output_dir=out_dir, name=name, plane=self.cut_plane,
399
+ resolution=self.resolution, slice_index=state['index'],
400
+ dw=state['dw'], dh=state['dh'], slice_xy=s, atlas_xy=a,
401
+ rotate=rot_w.value, flip_lr=self.flip_lr, flip_ud=self.flip_ud,
402
+ contrast=contrast)
403
+ status.value = f'saved -> {js}'
404
+
405
+ # warped histology in atlas space, and a copy with the boundaries burned in.
406
+ # bake the layer's contrast window so the .tif matches what you see.
407
+ warped = to_uint8(apply_transformation(state['hist'], m), contrast)
408
+ trans_path = out_dir / f'{name}_transformed.tif'
409
+ iio.imwrite(trans_path, warped)
410
+ status.value = f'saved -> {trans_path}'
411
+
412
+ # overlay = histology + boundaries, segmented to the area the boundaries enclose:
413
+ # everything outside the annotated brain is dropped (alpha 0), not painted black
414
+ ann = np.asarray(state['ann'])
415
+ inside = ann > 0 # annotated brain area; 0 = outside the atlas plane
416
+ rgb = warped if warped.ndim == 3 else np.stack([warped] * 3, axis=-1)
417
+ rgba = np.zeros((*ann.shape, 4), dtype=np.uint8)
418
+ rgba[inside, :3] = rgb[..., :3][inside]
419
+ # use the color currently shown on the left atlas panel so the saved overlay matches
420
+ bcol = np.asarray(bound_layer.colormap.colors[-1])[:3]
421
+ mask = boundary_mask(ann).astype(bool)
422
+ rgba[mask, :3] = [int(c * 255) for c in bcol]
423
+ rgba[inside | mask, 3] = 255
424
+ overlay_path = out_dir / f'{name}_overlay.png'
425
+ iio.imwrite(overlay_path, rgba)
426
+ status.value = f'saved -> {overlay_path}'
427
+
428
+ def on_undo():
429
+ # remove the most recently added point and restore the alternation state
430
+ if len(atlas_pts.data) > len(slice_pts.data):
431
+ atlas_pts.data = atlas_pts.data[:-1]
432
+ renumber(atlas_pts)
433
+ state['expect'] = 'atlas'
434
+ elif len(slice_pts.data):
435
+ slice_pts.data = slice_pts.data[:-1]
436
+ renumber(slice_pts)
437
+ state['expect'] = 'slice'
438
+ sync_orient_lock()
439
+ status.value = f'{len(slice_pts.data)} complete pair(s); next: {state["expect"]} point'
440
+
441
+ def on_clear():
442
+ for layer in (atlas_pts, slice_pts):
443
+ layer.data = np.empty((0, 2))
444
+ renumber(layer)
445
+ state['expect'] = 'atlas'
446
+ sync_orient_lock()
447
+ status.value = 'cleared all points'
448
+
449
+ def on_reregister():
450
+ if not (len(atlas_pts.data) or len(slice_pts.data)):
451
+ status.value = 'nothing to re-register — no points loaded'
452
+ return
453
+ if not confirm('Re-register this slice?',
454
+ 'Discard the loaded points and start picking again?\n'
455
+ '(the saved file is kept until you Save again)'):
456
+ return
457
+ on_clear() # unlocks plane / index / tilt / orientation
458
+ status.value = 're-registering: plane & orientation unlocked — pick points again'
459
+
460
+ def rebuild_plane(*_):
461
+ # plane drives the atlas/annotation views, dimensions and atlas_w; rebuild them all
462
+ nonlocal ref_view, ann_view, atlas_w, dim
463
+ self.cut_plane = plane_w.value
464
+ ref_view = get_slice_view('reference', self.cut_plane, resolution=self.resolution, check_latest=False)
465
+ ann_view = get_slice_view('annotation', self.cut_plane, resolution=self.resolution, check_latest=False)
466
+ atlas_w = int(ref_view.width)
467
+ dim = SLICE_DIMENSION_10um[self.cut_plane]
468
+
469
+ state['index'] = min(state['index'], ref_view.n_planes - 1)
470
+ idx_w.max = ref_view.n_planes - 1
471
+ idx_w.value = state['index']
472
+ wa, ha = self.AXIS.get(self.cut_plane, ('w', 'h'))
473
+ dw_w.label, dh_w.label = f'dw / {wa} tilt (voxel)', f'dh / {ha} tilt (voxel)'
474
+
475
+ atlas_layer.data = plane_image()
476
+ bound_layer.data = boundary_mask(state['ann'])
477
+ reset_highlight() # new plane/shape
478
+ state['hist'] = make_hist(rot_w.value)
479
+ set_histology(state['hist'])
480
+
481
+ grid_layer.data = make_grid(*dim) # rebuild the grid for the new dimensions/placement
482
+ grid_layer.translate = (0, atlas_w)
483
+
484
+ on_clear()
485
+ info_w.value = info_text()
486
+ status.value = f'plane: {self.cut_plane}'
487
+
488
+ plane_w.changed.connect(rebuild_plane)
489
+
490
+ def restore_from_meta(meta: dict):
491
+ # set widgets first (their callbacks rebuild the view / clear stale points), then points.
492
+ # plane first: rebuild_plane resets dims/atlas_w/index, so it must run before the rest
493
+ saved_plane = meta.get('plane')
494
+ if saved_plane and saved_plane != plane_w.value:
495
+ plane_w.value = saved_plane # triggers rebuild_plane
496
+ idx_w.value = int(meta.get('slice_index', idx_w.value))
497
+ dw_w.value = int(meta.get('dw', 0))
498
+ dh_w.value = int(meta.get('dh', 0))
499
+ rot_w.value = float(meta.get('rotate', 0.0))
500
+ flip_lr_w.value = bool(meta.get('flip_lr', False))
501
+ flip_ud_w.value = bool(meta.get('flip_ud', False))
502
+ ax = np.asarray(meta.get('atlas_xy', []), dtype=float).reshape(-1, 2)
503
+ sx = np.asarray(meta.get('slice_xy', []), dtype=float).reshape(-1, 2)
504
+ atlas_pts.data = ax[:, ::-1] if len(ax) else np.empty((0, 2))
505
+ slice_pts.data = sx[:, ::-1] + np.array([0, atlas_w]) if len(sx) else np.empty((0, 2))
506
+ renumber(atlas_pts)
507
+ renumber(slice_pts)
508
+ sync_orient_lock()
509
+ state['expect'] = 'atlas' if len(ax) == len(sx) else 'slice'
510
+
511
+ def load_image_path(p: Path):
512
+ p = Path(p)
513
+ state['path'] = p
514
+ self._oriented = self._read_oriented(p)
515
+ state['name'] = self.name or p.stem
516
+ state['out_dir'] = self.output_dir or p.parent / 'transformations'
517
+ state['hist'] = make_hist(rot_w.value)
518
+ set_histology(state['hist'])
519
+ on_clear()
520
+ viewer.title = f'regrender register — {state["name"]}'
521
+ files = state['files']
522
+ pos = f'{files.index(p) + 1}/{len(files)} ' if p in files else ''
523
+ img_lbl.value = f'{pos}{p.name}'
524
+ js = state['out_dir'] / f'{p.stem}_transform.json'
525
+ if js.exists():
526
+ restore_from_meta(load_transform(js))
527
+ status.value = f'{p.name} — resumed saved registration'
528
+ else:
529
+ status.value = f'loaded {p.name} — pick points'
530
+
531
+ def on_load_image():
532
+ from qtpy.QtWidgets import QFileDialog
533
+ path, _ = QFileDialog.getOpenFileName(
534
+ caption='Load histology image',
535
+ filter='Images (*.tif *.tiff *.png *.jpg *.jpeg);;All files (*)')
536
+ if path:
537
+ state['files'] = [] # single image: leave serial mode
538
+ load_image_path(Path(path))
539
+
540
+ def on_load_json():
541
+ # reuse another slice's registration (plane/index/tilt/orientation + points) on this image
542
+ from qtpy.QtWidgets import QFileDialog
543
+ path, _ = QFileDialog.getOpenFileName(
544
+ caption='Load transform JSON', filter='JSON (*.json);;All files (*)')
545
+ if path:
546
+ restore_from_meta(load_transform(Path(path)))
547
+ status.value = f'loaded {Path(path).name} — Preview to check, Save to write it here'
548
+
549
+ def on_load_dir():
550
+ from qtpy.QtWidgets import QFileDialog
551
+ d = QFileDialog.getExistingDirectory(caption='Load serial-section folder')
552
+ if not d:
553
+ return
554
+ state['files'] = self._list_images(Path(d))
555
+ state['cursor'] = 0
556
+ if not state['files']:
557
+ status.value = 'no images found in folder'
558
+ return
559
+ load_image_path(state['files'][0])
560
+ status.value = f'1/{len(state["files"])}: {state["files"][0].name}'
561
+
562
+ def load_slice(delta: int):
563
+ files = state['files']
564
+ if not files:
565
+ status.value = 'not in directory mode — use Load dir'
566
+ return
567
+ state['cursor'] = int(np.clip(state['cursor'] + delta, 0, len(files) - 1))
568
+ p = files[state['cursor']]
569
+ load_image_path(p)
570
+ status.value = f'{state["cursor"] + 1}/{len(files)}: {p.name}' + (
571
+ ' (resumed)' if (state['out_dir'] / f'{p.stem}_transform.json').exists() else '')
572
+
573
+ load_btn = PushButton(text='Load image')
574
+ load_btn.changed.connect(on_load_image)
575
+ load_dir_btn = PushButton(text='Load dir (serial)')
576
+ load_dir_btn.changed.connect(on_load_dir)
577
+ load_json_btn = PushButton(text='Load transform (json)')
578
+ load_json_btn.changed.connect(on_load_json)
579
+ prev_btn = PushButton(text='◀ Prev slice')
580
+ prev_btn.changed.connect(lambda *_: load_slice(-1))
581
+ next_btn = PushButton(text='Next slice ▶')
582
+ next_btn.changed.connect(lambda *_: load_slice(+1))
583
+ preview_w = CheckBox(label='preview overlay', value=False)
584
+ preview_w.changed.connect(lambda *_: on_preview() if preview_w.value else on_exit_preview())
585
+ save_btn = PushButton(text='Save transform')
586
+ save_btn.changed.connect(on_save)
587
+ undo_btn = PushButton(text='Undo last point')
588
+ undo_btn.changed.connect(on_undo)
589
+ clear_btn = PushButton(text='Clear all points')
590
+ clear_btn.changed.connect(on_clear)
591
+ reregister_btn = PushButton(text='Re-register (clear & redo)')
592
+ reregister_btn.changed.connect(on_reregister)
593
+
594
+ if load.get('slice_xy') or load.get('atlas_xy'):
595
+ restore_from_meta(load)
596
+ status.value = f'resumed: {len(load.get("slice_xy", []))} pair(s) loaded'
597
+ elif state['files']:
598
+ load_slice(0) # show "i/N" + auto-resume the first serial section
599
+ elif self.raw_image is not None: # single -I: auto-resume its saved transform like directory mode
600
+ js = out_dir / f'{name}_transform.json'
601
+ if js.exists():
602
+ restore_from_meta(load_transform(js))
603
+ status.value = f'{name} — resumed saved registration'
604
+
605
+ def header(text):
606
+ lbl = Label(value=text)
607
+ lbl.native.setStyleSheet('font-weight: bold; color: #88c0d0; padding-top: 6px;')
608
+ return lbl
609
+
610
+ def row(*ws):
611
+ return Container(widgets=list(ws), layout='horizontal', labels=False)
612
+
613
+ panel = Container(
614
+ widgets=[
615
+ header('Image'), img_lbl, load_btn, load_dir_btn, load_json_btn, row(prev_btn, next_btn),
616
+ header('Atlas plane'), plane_w, idx_w, dw_w, dh_w, info_w,
617
+ header('Orientation'), rot_w, flip_lr_w, flip_ud_w,
618
+ header('Display'), grid_w, color_w,
619
+ header('Points'), pick_w, row(undo_btn, clear_btn), reregister_btn,
620
+ header('Overlay & save'), preview_w, save_btn,
621
+ status_label,
622
+ ]
623
+ )
624
+ panel.native.setStyleSheet('QPushButton { padding: 4px; }')
625
+ # scroll the panel so the bottom (save/status) stays reachable on short screens
626
+ from qtpy.QtWidgets import QScrollArea
627
+ scroll = QScrollArea()
628
+ scroll.setWidgetResizable(True)
629
+ scroll.setWidget(panel.native)
630
+ viewer.window.add_dock_widget(
631
+ scroll, area='right', name='register'
632
+ )
633
+ printf(f'registering {name}: pick points, Preview to verify, Save when done')
634
+ napari.run()
635
+
636
+
637
+ if __name__ == '__main__':
638
+ RegisterOptions().main()