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
|
@@ -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()
|