regrender 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regrender/__init__.py +0 -0
- regrender/__main__.py +38 -0
- regrender/_app.py +326 -0
- regrender/_core.py +306 -0
- regrender/main_probe.py +702 -0
- regrender/main_register.py +638 -0
- regrender/main_roi.py +489 -0
- regrender-0.1.0.dist-info/METADATA +102 -0
- regrender-0.1.0.dist-info/RECORD +12 -0
- regrender-0.1.0.dist-info/WHEEL +4 -0
- regrender-0.1.0.dist-info/entry_points.txt +2 -0
- regrender-0.1.0.dist-info/licenses/LICENSE +21 -0
regrender/main_roi.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""ROI labeling + projection.
|
|
2
|
+
|
|
3
|
+
Workflow order is **roi → register → project**. ROIs (cells) are labelled on the *full
|
|
4
|
+
resolution* raw histology (you need the detail to see them) and saved in **raw pixel**
|
|
5
|
+
coords, independent of any registration. Once the slice is registered (`regrender register`),
|
|
6
|
+
the saved transform is applied to those raw coords to project them into the down-sampled
|
|
7
|
+
Allen atlas / CCF space, then reconstructed with brainrender. Projection is a single
|
|
8
|
+
function used by both the GUI "Project + Render" button and the headless ``--project`` CLI.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tempfile
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import polars as pl
|
|
18
|
+
from argclz import argument
|
|
19
|
+
from brainglobe_atlasapi import BrainGlobeAtlas
|
|
20
|
+
from neuralib.atlas.ccf.matrix import slice_transform_helper
|
|
21
|
+
from neuralib.atlas.util import ALLEN_CCF_10um_BREGMA
|
|
22
|
+
|
|
23
|
+
from regrender._app import RegionPicker, SliceReconstructOptions, printf
|
|
24
|
+
from regrender._core import (
|
|
25
|
+
boundary_mask,
|
|
26
|
+
load_transform,
|
|
27
|
+
plane_point_to_ccf_mm,
|
|
28
|
+
raw_points_to_atlas,
|
|
29
|
+
read_oriented,
|
|
30
|
+
region_name,
|
|
31
|
+
rotate,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__all__ = ['RoiOptions', 'project_raw_rois']
|
|
35
|
+
|
|
36
|
+
_COLORS = ['orange', 'magenta', 'red', 'cyan', 'yellow', 'lime', 'green', 'blue', 'white', 'dimgray']
|
|
37
|
+
SHADER_STYLES = ['plastic', 'cartoon', 'metallic', 'shiny', 'glossy']
|
|
38
|
+
CAMERA_ANGLES = ['three_quarters', 'sagittal', 'sagittal2', 'frontal', 'top', 'top_side']
|
|
39
|
+
|
|
40
|
+
RAW_COLS = ('slice', 'x', 'y', 'raw_h', 'raw_w') # saved raw-ROI csv schema (channel optional, added on save)
|
|
41
|
+
# per-channel cross / render color; 'merge' overridden by the GUI ROI-color picker
|
|
42
|
+
_CH_COLOR = {'merge': 'cyan', 'R': 'red', 'G': 'green', 'B': 'blue'}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def project_raw_rois(rows, get_views, structures, transform_for):
|
|
46
|
+
"""Project raw-pixel ROIs into Allen CCF mm.
|
|
47
|
+
|
|
48
|
+
:param rows: dicts with keys ``slice, x, y, raw_h, raw_w`` (raw image pixels + shape), optional ``channel``.
|
|
49
|
+
:param get_views: ``(plane, res) -> (reference_view, annotation_view)`` (see make_view_cache).
|
|
50
|
+
:param structures: BrainGlobe structures (for region acronyms).
|
|
51
|
+
:param transform_for: ``slice_stem -> meta dict`` (the saved transform) or ``None`` if unregistered.
|
|
52
|
+
:return: ``(ccf_rows, missing_slices)`` — ccf_rows have AP/DV/ML_location + region + source + channel.
|
|
53
|
+
"""
|
|
54
|
+
by_slice: dict[str, list] = defaultdict(list)
|
|
55
|
+
for r in rows:
|
|
56
|
+
by_slice[r['slice']].append(r)
|
|
57
|
+
|
|
58
|
+
ccf_rows, missing = [], []
|
|
59
|
+
for stem, rs in by_slice.items():
|
|
60
|
+
meta = transform_for(stem)
|
|
61
|
+
if meta is None:
|
|
62
|
+
missing.append(stem)
|
|
63
|
+
continue
|
|
64
|
+
plane, res = meta['plane'], int(meta['resolution'])
|
|
65
|
+
idx, dw, dh = int(meta['slice_index']), int(meta['dw']), int(meta['dh'])
|
|
66
|
+
view, ann = get_views(plane, res)
|
|
67
|
+
od = lambda v: v + 1 if v != 0 else 0
|
|
68
|
+
off = view.plane_at(idx).with_offset(od(dw), od(dh)).plane_offset
|
|
69
|
+
ann_img = ann.plane_at(idx).with_offset(od(dw), od(dh)).image
|
|
70
|
+
|
|
71
|
+
raw_shape = (int(rs[0]['raw_h']), int(rs[0]['raw_w'])) # constant per slice
|
|
72
|
+
atlas = raw_points_to_atlas(
|
|
73
|
+
[[r['x'], r['y']] for r in rs], matrix=np.array(meta['matrix'], float),
|
|
74
|
+
raw_shape=raw_shape, plane=plane, rotate_deg=float(meta.get('rotate', 0.0)),
|
|
75
|
+
flip_lr=meta.get('flip_lr', False), flip_ud=meta.get('flip_ud', False))
|
|
76
|
+
for r, (ax, ay) in zip(rs, atlas): # rs and atlas stay aligned (same order)
|
|
77
|
+
xi, yi = int(round(ax)), int(round(ay))
|
|
78
|
+
if not (0 <= yi < off.shape[0] and 0 <= xi < off.shape[1]):
|
|
79
|
+
continue # mapped outside the atlas plane
|
|
80
|
+
ap, dv, ml = plane_point_to_ccf_mm(off[yi, xi], ax, ay, project_index=view.project_index,
|
|
81
|
+
resolution=view.resolution,
|
|
82
|
+
bregma_10um=tuple(ALLEN_CCF_10um_BREGMA))
|
|
83
|
+
acr = region_name(ann_img, structures, ay, ax).split(' — ')[0]
|
|
84
|
+
ccf_rows.append({'AP_location': ap, 'DV_location': dv, 'ML_location': ml,
|
|
85
|
+
'region': acr, 'source': stem, 'channel': r.get('channel', 'merge')})
|
|
86
|
+
return ccf_rows, missing
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _render_tmpdir() -> Path:
|
|
90
|
+
# per-channel render inputs live in a throwaway temp dir; the OS reaps it, the data folder stays clean
|
|
91
|
+
return Path(tempfile.mkdtemp(prefix='regrender_roi_')) # not cleaned up: a few KB in /tmp per render
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_channel_csvs(ccf_rows, dest_dir: Path) -> list[tuple[str, Path]]:
|
|
95
|
+
"""Split projected rows into one plain CCF csv per channel inside ``dest_dir`` (brainrender
|
|
96
|
+
renders one --file per color). These are render inputs, not user artifacts — pass a temp dir.
|
|
97
|
+
Returns ``[(channel, path), ...]`` in stable channel order."""
|
|
98
|
+
groups: dict[str, list] = defaultdict(list)
|
|
99
|
+
for r in ccf_rows:
|
|
100
|
+
groups[r.get('channel', 'merge')].append(r)
|
|
101
|
+
cols = ('AP_location', 'DV_location', 'ML_location', 'region', 'source')
|
|
102
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
out = []
|
|
104
|
+
for ch in sorted(groups):
|
|
105
|
+
p = dest_dir / f'roi_ccf_{ch}.csv'
|
|
106
|
+
pl.DataFrame([{k: r[k] for k in cols} for r in groups[ch]]).write_csv(p)
|
|
107
|
+
out.append((ch, p))
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _render_argv(channel_files: list[tuple[str, Path]], radius: float, ch_color: dict[str, str],
|
|
112
|
+
region_colors: dict[str, str], style: str, no_root: bool, hemisphere: str,
|
|
113
|
+
camera: str = CAMERA_ANGLES[0]) -> list[str]:
|
|
114
|
+
"""``RoiRenderCLI`` argv (after ``-m``): one --file per channel + matching colors + region meshes."""
|
|
115
|
+
cmd = ['neuralib.atlas.brainrender.roi']
|
|
116
|
+
colors = []
|
|
117
|
+
for ch, p in channel_files:
|
|
118
|
+
cmd += ['--file', str(p)]
|
|
119
|
+
colors.append(ch_color.get(ch, 'orange'))
|
|
120
|
+
cmd += ['--roi-colors', ','.join(colors), '--roi-radius', str(radius),
|
|
121
|
+
'--style', style, '--hemisphere', hemisphere, '--camera', camera]
|
|
122
|
+
if no_root:
|
|
123
|
+
cmd.append('--no-root')
|
|
124
|
+
if region_colors:
|
|
125
|
+
cmd += ['--region', ','.join(region_colors)]
|
|
126
|
+
cmd += ['--region-color', ','.join(region_colors.values())]
|
|
127
|
+
return cmd
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class RoiOptions(SliceReconstructOptions):
|
|
131
|
+
DESCRIPTION = ('Label ROIs (cells) on the full-res raw histology and save them in raw pixel '
|
|
132
|
+
'coords. After registration, project them into Allen CCF space and render '
|
|
133
|
+
'(GUI button or `--project`).')
|
|
134
|
+
|
|
135
|
+
project: bool = argument('--project',
|
|
136
|
+
help='headless: project a saved raw-ROI CSV into CCF space (no GUI)')
|
|
137
|
+
render: bool = argument('--render', help='with --project, also launch brainrender')
|
|
138
|
+
roi_radius: float = argument('--roi-radius', default=30, help='rendered ROI sphere radius (µm)')
|
|
139
|
+
roi_color: str = argument('--roi-color', default='orange', help='rendered ROI point color')
|
|
140
|
+
|
|
141
|
+
def run(self):
|
|
142
|
+
if self.project:
|
|
143
|
+
self._run_project_headless()
|
|
144
|
+
return
|
|
145
|
+
files = self._resolve_paths('roi/roi_points_raw.csv', require_transform=False)
|
|
146
|
+
self._launch_napari(files)
|
|
147
|
+
|
|
148
|
+
def _ccf_out(self) -> Path:
|
|
149
|
+
return self._out.with_name('roi_points_ccf.csv')
|
|
150
|
+
|
|
151
|
+
def _transform_for(self, stem: str):
|
|
152
|
+
p = self._tdir / f'{stem}_transform.json'
|
|
153
|
+
return load_transform(p) if p.exists() else None
|
|
154
|
+
|
|
155
|
+
def _project_and_save(self, rows, get_views, structures, status=None):
|
|
156
|
+
"""Shared projection: raw rows -> combined CCF csv. Returns ``(path, ccf_rows)`` or ``None``."""
|
|
157
|
+
ccf_rows, missing = project_raw_rois(rows, get_views, structures, self._transform_for)
|
|
158
|
+
msg = lambda m: status.__setattr__('value', m) if status is not None else printf(m)
|
|
159
|
+
if missing:
|
|
160
|
+
msg(f'no registration for slice(s): {", ".join(sorted(set(missing)))} — register them first')
|
|
161
|
+
if not ccf_rows:
|
|
162
|
+
msg('nothing projected (no registered ROIs)')
|
|
163
|
+
return None
|
|
164
|
+
out = self._ccf_out()
|
|
165
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
pl.DataFrame(ccf_rows).write_csv(out) # combined artifact (with channel column)
|
|
167
|
+
msg(f'projected {len(ccf_rows)} ROI(s) -> {out}') # full path; GUI mirrors it to the terminal
|
|
168
|
+
return out, ccf_rows
|
|
169
|
+
|
|
170
|
+
def _run_project_headless(self):
|
|
171
|
+
# resolve the raw csv input + transform dir without needing the images
|
|
172
|
+
raw_csv = self.output or (
|
|
173
|
+
(self.directory or (self.raw_image.parent if self.raw_image else Path.cwd()))
|
|
174
|
+
/ 'roi' / 'roi_points_raw.csv')
|
|
175
|
+
base = self.directory or (self.raw_image.parent if self.raw_image else raw_csv.parent)
|
|
176
|
+
self._tdir = self.transform_dir or base / 'transformations'
|
|
177
|
+
self._out = raw_csv
|
|
178
|
+
if not raw_csv.exists():
|
|
179
|
+
raise FileNotFoundError(f'no raw-ROI csv {raw_csv} — label ROIs with `regrender roi` first')
|
|
180
|
+
|
|
181
|
+
df = pl.read_csv(raw_csv)
|
|
182
|
+
if not set(RAW_COLS) <= set(df.columns):
|
|
183
|
+
raise ValueError(f'{raw_csv.name}: not a raw-ROI csv (needs {", ".join(RAW_COLS)})')
|
|
184
|
+
get_views = self.make_view_cache()
|
|
185
|
+
structures = BrainGlobeAtlas('allen_mouse_10um', check_latest=False).structures
|
|
186
|
+
res = self._project_and_save(df.to_dicts(), get_views, structures)
|
|
187
|
+
if res is not None and self.render:
|
|
188
|
+
_, ccf_rows = res
|
|
189
|
+
ch_files = write_channel_csvs(ccf_rows, _render_tmpdir()) # per-channel render inputs (temp)
|
|
190
|
+
ch_color = {**_CH_COLOR, 'merge': self.roi_color}
|
|
191
|
+
self.launch_render(_render_argv(ch_files, self.roi_radius, ch_color, {},
|
|
192
|
+
SHADER_STYLES[0], False, 'both'))
|
|
193
|
+
printf(f'rendering {len(ch_files)} channel(s) in a separate window...')
|
|
194
|
+
|
|
195
|
+
def _launch_napari(self, files: list[Path]):
|
|
196
|
+
import napari
|
|
197
|
+
from magicgui.widgets import CheckBox, ComboBox, Container, Label, PushButton
|
|
198
|
+
|
|
199
|
+
viewer = napari.Viewer(title='regrender roi')
|
|
200
|
+
viewer.camera.mouse_pan = False # left-drag must add ROIs, not pan the slice; scroll still zooms
|
|
201
|
+
viewer.text_overlay.visible = True # region name under cursor (verify mode)
|
|
202
|
+
viewer.text_overlay.font_size = 18
|
|
203
|
+
viewer.text_overlay.color = 'yellow'
|
|
204
|
+
|
|
205
|
+
bg = BrainGlobeAtlas('allen_mouse_10um', check_latest=False)
|
|
206
|
+
get_views = self.make_view_cache() # lazy: atlas volumes load only on first Project/Verify
|
|
207
|
+
|
|
208
|
+
# rois[id] = {'slice': stem, 'raw': (y, x)}; shapes[stem] = (H, W)
|
|
209
|
+
state: dict = {'rois': {}, 'next_id': 1, 'shapes': {}, 'files': files, 'cursor': 0,
|
|
210
|
+
'name': None, 'path': None, 'raw_img': None, 'verify': False,
|
|
211
|
+
'ann_img': None, 'hover_id': None}
|
|
212
|
+
|
|
213
|
+
from napari.utils import Colormap
|
|
214
|
+
raw_layer = None # image layer, re-created per slice (grayscale<->RGB safe)
|
|
215
|
+
roi_layer = viewer.add_points(name='rois', face_color='cyan', symbol='cross', size=20, ndim=2)
|
|
216
|
+
hcmap = Colormap([[0, 0, 0], [0.3, 0.6, 1.0]], name='highlight')
|
|
217
|
+
highlight_layer = viewer.add_image(np.zeros((10, 10), dtype=float), name='region_highlight',
|
|
218
|
+
colormap=hcmap, blending='additive', opacity=0.4)
|
|
219
|
+
bound_layer = viewer.add_image(np.zeros((10, 10)), name='boundaries', colormap='red',
|
|
220
|
+
blending='additive', opacity=0.7)
|
|
221
|
+
highlight_layer.visible = bound_layer.visible = False # atlas overlays: verify mode only
|
|
222
|
+
viewer.layers.selection.active = roi_layer
|
|
223
|
+
roi_layer.mode = 'add'
|
|
224
|
+
|
|
225
|
+
def select_roi_layer():
|
|
226
|
+
viewer.layers.selection.active = roi_layer # adding the image layer steals focus
|
|
227
|
+
roi_layer.mode = 'add'
|
|
228
|
+
|
|
229
|
+
def set_raw(img: np.ndarray):
|
|
230
|
+
nonlocal raw_layer
|
|
231
|
+
if raw_layer is not None and raw_layer in viewer.layers:
|
|
232
|
+
viewer.layers.remove(raw_layer)
|
|
233
|
+
raw_layer = viewer.add_image(img, name='histology', colormap='gray')
|
|
234
|
+
|
|
235
|
+
def reorder(): # bottom -> top: image, region highlight, atlas boundaries, ROI crosses
|
|
236
|
+
for i, lyr in enumerate(l for l in (raw_layer, highlight_layer, bound_layer, roi_layer)
|
|
237
|
+
if l is not None and l in viewer.layers):
|
|
238
|
+
viewer.layers.move(viewer.layers.index(lyr), i)
|
|
239
|
+
|
|
240
|
+
status_label, status = self.make_status_log()
|
|
241
|
+
status.value = 'load a raw slice and click cells (no registration needed yet)'
|
|
242
|
+
summary = Label(value='')
|
|
243
|
+
summary.native.setWordWrap(True)
|
|
244
|
+
summary.native.setStyleSheet('font-family: Menlo, Consolas, monospace; font-size: 12px;')
|
|
245
|
+
slice_lbl = Label(value='no slice loaded')
|
|
246
|
+
slice_lbl.native.setWordWrap(True)
|
|
247
|
+
|
|
248
|
+
def set_crosses(yx_list, colors=None):
|
|
249
|
+
# programmatic refresh of the layer; guarded so it doesn't re-enter sync_from_layer
|
|
250
|
+
state['loading'] = True
|
|
251
|
+
roi_layer.selected_data = set() # drop any selection; stale indices crash on data swap
|
|
252
|
+
roi_layer.data = np.array(yx_list) if yx_list else np.empty((0, 2))
|
|
253
|
+
if yx_list and colors is not None:
|
|
254
|
+
roi_layer.face_color = colors # per-point (per-channel) colors
|
|
255
|
+
state['loading'] = False
|
|
256
|
+
|
|
257
|
+
def sync_from_layer(*_):
|
|
258
|
+
# user added/removed points in raw add mode; resync this (slice, channel) subset only
|
|
259
|
+
if state.get('loading') or state['verify'] or state['name'] is None:
|
|
260
|
+
return
|
|
261
|
+
stem, ch = state['name'], channel_w.value
|
|
262
|
+
state['rois'] = {i: r for i, r in state['rois'].items()
|
|
263
|
+
if not (r['slice'] == stem and r.get('channel', 'merge') == ch)}
|
|
264
|
+
for y, x in roi_layer.data:
|
|
265
|
+
rid = state['next_id']
|
|
266
|
+
state['next_id'] += 1
|
|
267
|
+
state['rois'][rid] = {'slice': stem, 'raw': (float(y), float(x)), 'channel': ch}
|
|
268
|
+
refresh_summary()
|
|
269
|
+
status.value = f'{stem} [{ch}]: {len(roi_layer.data)} ROI(s)'
|
|
270
|
+
|
|
271
|
+
roi_layer.events.data.connect(sync_from_layer)
|
|
272
|
+
viewer.mouse_move_callbacks.append(self.make_hover(viewer, state, bg.structures, highlight_layer))
|
|
273
|
+
|
|
274
|
+
def _slice_rois(channel=None):
|
|
275
|
+
# ROIs on the current slice; channel=None -> all channels, else just that channel
|
|
276
|
+
return [r for r in state['rois'].values() if r['slice'] == state['name']
|
|
277
|
+
and (channel is None or r.get('channel', 'merge') == channel)]
|
|
278
|
+
|
|
279
|
+
def display_raw():
|
|
280
|
+
# annotation view: native raw image, click to add ROIs in the active channel's set
|
|
281
|
+
ch = channel_w.value
|
|
282
|
+
state['ann_img'] = None
|
|
283
|
+
set_raw(self.channel_view(state['raw_img'], ch))
|
|
284
|
+
highlight_layer.visible = bound_layer.visible = False
|
|
285
|
+
rois = _slice_rois(ch)
|
|
286
|
+
color = _CH_COLOR.get(ch, 'cyan')
|
|
287
|
+
set_crosses([r['raw'] for r in rois], [color] * len(rois))
|
|
288
|
+
roi_layer.current_face_color = color # newly clicked ROIs take the channel color
|
|
289
|
+
reorder()
|
|
290
|
+
select_roi_layer()
|
|
291
|
+
viewer.camera.mouse_pan = pan_w.value
|
|
292
|
+
status.value = (f'{state["path"].name} [{ch}]: click to add cells (scroll=zoom) — '
|
|
293
|
+
f'{len(rois)} in this channel')
|
|
294
|
+
|
|
295
|
+
def display_warped():
|
|
296
|
+
# verify view: transformed (atlas-aligned) image + boundaries + projected ROI crosses
|
|
297
|
+
meta = self._transform_for(state['name'])
|
|
298
|
+
if meta is None:
|
|
299
|
+
status.value = f'no registration for {state["name"]} — register it first'
|
|
300
|
+
verify_w.value = False # handler switches back to the raw view
|
|
301
|
+
return
|
|
302
|
+
plane, res = meta['plane'], int(meta['resolution'])
|
|
303
|
+
idx, dw, dh = int(meta['slice_index']), int(meta['dw']), int(meta['dh'])
|
|
304
|
+
oimg = rotate(read_oriented(state['path'], meta.get('flip_lr', False),
|
|
305
|
+
meta.get('flip_ud', False)), float(meta.get('rotate', 0.0)))
|
|
306
|
+
_, warped = slice_transform_helper(oimg, np.array(meta['matrix'], float), plane_type=plane)
|
|
307
|
+
od = lambda v: v + 1 if v != 0 else 0
|
|
308
|
+
view, ann = get_views(plane, res)
|
|
309
|
+
ann_img = ann.plane_at(idx).with_offset(od(dw), od(dh)).image
|
|
310
|
+
state['ann_img'], state['hover_id'] = ann_img, None
|
|
311
|
+
set_raw(warped)
|
|
312
|
+
bound_layer.data = boundary_mask(ann_img)
|
|
313
|
+
highlight_layer.data = np.zeros(ann_img.shape, dtype=float)
|
|
314
|
+
highlight_layer.visible = bound_layer.visible = True
|
|
315
|
+
|
|
316
|
+
rois, shape = _slice_rois(), state['shapes'].get(state['name']) # all channels, colored per channel
|
|
317
|
+
here, cols = [], []
|
|
318
|
+
if rois and shape is not None: # raw (y,x) -> atlas (x,y); cross goes at (y_atlas, x_atlas)
|
|
319
|
+
atlas = raw_points_to_atlas([[r['raw'][1], r['raw'][0]] for r in rois],
|
|
320
|
+
matrix=np.array(meta['matrix'], float), raw_shape=shape,
|
|
321
|
+
plane=plane, rotate_deg=float(meta.get('rotate', 0.0)),
|
|
322
|
+
flip_lr=meta.get('flip_lr', False), flip_ud=meta.get('flip_ud', False))
|
|
323
|
+
here = [[ay, ax] for ax, ay in atlas]
|
|
324
|
+
cols = [_CH_COLOR.get(r.get('channel', 'merge'), 'cyan') for r in rois]
|
|
325
|
+
set_crosses(here, cols)
|
|
326
|
+
roi_layer.mode = 'pan_zoom' # atlas space is read-only
|
|
327
|
+
reorder()
|
|
328
|
+
viewer.camera.mouse_pan = True
|
|
329
|
+
status.value = f'{state["name"]}: {len(here)} ROI(s) on the transformed slice (verify)'
|
|
330
|
+
|
|
331
|
+
def display_slice():
|
|
332
|
+
(display_warped if state['verify'] else display_raw)()
|
|
333
|
+
|
|
334
|
+
def load_slice(img: Path):
|
|
335
|
+
state['path'], state['name'] = img, img.stem
|
|
336
|
+
state['raw_img'] = read_oriented(img, flip_lr=False, flip_ud=False) # native; flips at projection
|
|
337
|
+
state['shapes'][img.stem] = state['raw_img'].shape[:2]
|
|
338
|
+
files = state['files']
|
|
339
|
+
pos = f'{state["cursor"] + 1}/{len(files)} ' if files else ''
|
|
340
|
+
slice_lbl.value = f'{pos}{img.name}'
|
|
341
|
+
display_slice()
|
|
342
|
+
viewer.reset_view()
|
|
343
|
+
|
|
344
|
+
def refresh_summary():
|
|
345
|
+
if not state['rois']:
|
|
346
|
+
summary.value = 'no ROIs yet'
|
|
347
|
+
return
|
|
348
|
+
per_ch = defaultdict(int)
|
|
349
|
+
for r in state['rois'].values():
|
|
350
|
+
per_ch[r.get('channel', 'merge')] += 1
|
|
351
|
+
chans = ' '.join(f'{c}:{per_ch[c]}' for c in sorted(per_ch))
|
|
352
|
+
summary.value = f'<b>total {len(state["rois"])}</b><br>{chans}'
|
|
353
|
+
|
|
354
|
+
def remove_last():
|
|
355
|
+
if state['verify']:
|
|
356
|
+
status.value = 'switch off Verify to edit ROIs'
|
|
357
|
+
return
|
|
358
|
+
if state['name'] is None or len(roi_layer.data) == 0:
|
|
359
|
+
status.value = 'no ROI on this slice to undo'
|
|
360
|
+
return
|
|
361
|
+
roi_layer.data = roi_layer.data[:-1] # triggers sync_from_layer to resync state
|
|
362
|
+
|
|
363
|
+
regions = RegionPicker(bg.structures)
|
|
364
|
+
style_w = ComboBox(label='style', choices=SHADER_STYLES, value=SHADER_STYLES[0])
|
|
365
|
+
hemisphere_w = ComboBox(label='hemisphere', choices=['both', 'left', 'right'], value='both')
|
|
366
|
+
camera_w = ComboBox(label='camera', choices=CAMERA_ANGLES, value=CAMERA_ANGLES[0])
|
|
367
|
+
no_root_w = CheckBox(label='no root (hide brain)', value=False)
|
|
368
|
+
color_w = ComboBox(label='ROI color', choices=_COLORS,
|
|
369
|
+
value=self.roi_color if self.roi_color in _COLORS else _COLORS[0])
|
|
370
|
+
|
|
371
|
+
def rows_from_state() -> list[dict]:
|
|
372
|
+
out = []
|
|
373
|
+
for r in state['rois'].values():
|
|
374
|
+
h, w = state['shapes'].get(r['slice'], (None, None))
|
|
375
|
+
y, x = r['raw']
|
|
376
|
+
out.append({'slice': r['slice'], 'x': x, 'y': y, 'raw_h': h, 'raw_w': w,
|
|
377
|
+
'channel': r.get('channel', 'merge')})
|
|
378
|
+
return out
|
|
379
|
+
|
|
380
|
+
def save_csv() -> Path | None:
|
|
381
|
+
if not state['rois']:
|
|
382
|
+
status.value = 'no ROIs to save'
|
|
383
|
+
return None
|
|
384
|
+
self._out.parent.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
pl.DataFrame(rows_from_state()).write_csv(self._out)
|
|
386
|
+
status.value = f'saved {len(state["rois"])} raw ROI(s) -> {self._out}'
|
|
387
|
+
return self._out
|
|
388
|
+
|
|
389
|
+
def load_csv_points(path: Path):
|
|
390
|
+
try:
|
|
391
|
+
df = pl.read_csv(path)
|
|
392
|
+
except Exception as e: # noqa: BLE001 - surface any read error in the GUI
|
|
393
|
+
status.value = f'could not read {path.name}: {e}'
|
|
394
|
+
return
|
|
395
|
+
if not set(RAW_COLS) <= set(df.columns):
|
|
396
|
+
status.value = f'{path.name}: not a raw-ROI csv (needs {", ".join(RAW_COLS)})'
|
|
397
|
+
return
|
|
398
|
+
for r in df.iter_rows(named=True):
|
|
399
|
+
rid = state['next_id']
|
|
400
|
+
state['next_id'] += 1
|
|
401
|
+
state['rois'][rid] = {'slice': r['slice'], 'raw': (r['y'], r['x']),
|
|
402
|
+
'channel': r.get('channel', 'merge')} # back-compat: old csvs -> merge
|
|
403
|
+
state['shapes'].setdefault(r['slice'], (r['raw_h'], r['raw_w']))
|
|
404
|
+
refresh_summary()
|
|
405
|
+
if state['name'] is not None:
|
|
406
|
+
display_slice() # refresh crosses in whichever view is active
|
|
407
|
+
status.value = f'loaded {len(df)} raw ROI(s) from {path.name}'
|
|
408
|
+
|
|
409
|
+
def on_project():
|
|
410
|
+
save_csv() # persist raw coords first
|
|
411
|
+
res = self._project_and_save(rows_from_state(), get_views, bg.structures, status)
|
|
412
|
+
if res is not None:
|
|
413
|
+
_, ccf_rows = res
|
|
414
|
+
ch_files = write_channel_csvs(ccf_rows, _render_tmpdir()) # per-channel render inputs (temp)
|
|
415
|
+
ch_color = {**_CH_COLOR, 'merge': color_w.value}
|
|
416
|
+
self.launch_render(
|
|
417
|
+
_render_argv(ch_files, self.roi_radius, ch_color, regions.colors,
|
|
418
|
+
style_w.value, no_root_w.value, hemisphere_w.value, camera_w.value),
|
|
419
|
+
status, f'rendering {len(ccf_rows)} ROI(s) across {len(ch_files)} channel(s)...')
|
|
420
|
+
|
|
421
|
+
def step(delta: int):
|
|
422
|
+
files = state['files']
|
|
423
|
+
if not files:
|
|
424
|
+
status.value = 'single-image mode — nothing to step'
|
|
425
|
+
return
|
|
426
|
+
state['cursor'] = int(np.clip(state['cursor'] + delta, 0, len(files) - 1))
|
|
427
|
+
load_slice(files[state['cursor']])
|
|
428
|
+
|
|
429
|
+
pan_w = CheckBox(label='Pan (left-drag moves slice)', value=False)
|
|
430
|
+
pan_w.changed.connect(lambda *_: setattr(viewer.camera, 'mouse_pan', pan_w.value)
|
|
431
|
+
if not state['verify'] else None)
|
|
432
|
+
verify_w = CheckBox(label='Verify (warped + atlas)', value=False)
|
|
433
|
+
channel_w = ComboBox(label='channel', choices=['merge', 'R', 'G', 'B'], value='merge')
|
|
434
|
+
channel_w.changed.connect(lambda *_: display_raw()
|
|
435
|
+
if state['name'] is not None and not state['verify'] else None)
|
|
436
|
+
|
|
437
|
+
def on_verify(*_):
|
|
438
|
+
state['verify'] = verify_w.value
|
|
439
|
+
if state['name'] is not None:
|
|
440
|
+
display_slice()
|
|
441
|
+
viewer.reset_view()
|
|
442
|
+
|
|
443
|
+
verify_w.changed.connect(on_verify)
|
|
444
|
+
|
|
445
|
+
prev_btn = PushButton(text='◀ Prev slice')
|
|
446
|
+
prev_btn.changed.connect(lambda *_: step(-1))
|
|
447
|
+
next_btn = PushButton(text='Next slice ▶')
|
|
448
|
+
next_btn.changed.connect(lambda *_: step(+1))
|
|
449
|
+
undo_btn = PushButton(text='Undo last ROI')
|
|
450
|
+
undo_btn.changed.connect(lambda *_: remove_last())
|
|
451
|
+
|
|
452
|
+
def on_load_csv():
|
|
453
|
+
from qtpy.QtWidgets import QFileDialog
|
|
454
|
+
path, _ = QFileDialog.getOpenFileName(caption='Load raw-ROI CSV',
|
|
455
|
+
filter='CSV (*.csv);;All files (*)')
|
|
456
|
+
if path:
|
|
457
|
+
load_csv_points(Path(path))
|
|
458
|
+
|
|
459
|
+
load_btn = PushButton(text='Load CSV')
|
|
460
|
+
load_btn.changed.connect(lambda *_: on_load_csv())
|
|
461
|
+
save_btn = PushButton(text='Save CSV')
|
|
462
|
+
save_btn.changed.connect(lambda *_: save_csv())
|
|
463
|
+
project_btn = PushButton(text='Project + Render (needs registration)')
|
|
464
|
+
project_btn.changed.connect(lambda *_: on_project())
|
|
465
|
+
|
|
466
|
+
panel = Container(widgets=[
|
|
467
|
+
self.header('Slice'), slice_lbl, self.srow(prev_btn, next_btn), verify_w,
|
|
468
|
+
self.header('ROIs'), summary, undo_btn, pan_w, channel_w, color_w,
|
|
469
|
+
self.header('Regions'), *regions.widgets,
|
|
470
|
+
self.header('Render'), self.srow(style_w, hemisphere_w), camera_w, no_root_w, project_btn,
|
|
471
|
+
self.header('CSV'), self.srow(load_btn, save_btn),
|
|
472
|
+
status_label,
|
|
473
|
+
])
|
|
474
|
+
self.add_scroll_dock(viewer, panel, 'roi')
|
|
475
|
+
|
|
476
|
+
if state['files']:
|
|
477
|
+
load_slice(state['files'][0])
|
|
478
|
+
elif self.raw_image:
|
|
479
|
+
load_slice(self.raw_image)
|
|
480
|
+
if self._out.exists():
|
|
481
|
+
load_csv_points(self._out) # auto-resume the session csv
|
|
482
|
+
refresh_summary()
|
|
483
|
+
|
|
484
|
+
printf('roi: click cells on raw slices, Save, then Project + Render after registering')
|
|
485
|
+
napari.run()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
if __name__ == '__main__':
|
|
489
|
+
RoiOptions().main()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: regrender
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 2D mouse brain registration, ROI/Probe annotation, and brainrender reconstruction
|
|
5
|
+
Project-URL: Homepage, https://github.com/ytsimon2004/regrender
|
|
6
|
+
Project-URL: Repository, https://github.com/ytsimon2004/regrender
|
|
7
|
+
Project-URL: Documentation, https://regrender.readthedocs.io
|
|
8
|
+
Author-email: Yu-Ting Wei <ytsimon2004@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: allen-ccf,brain-atlas,brainrender,napari,neuroscience,registration
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Requires-Dist: argclz>=1.0.1
|
|
18
|
+
Requires-Dist: imageio
|
|
19
|
+
Requires-Dist: napari[pyqt5]
|
|
20
|
+
Requires-Dist: neuralib-atlas[brainrender]>=0.7.3
|
|
21
|
+
Requires-Dist: rich
|
|
22
|
+
Provides-Extra: doc
|
|
23
|
+
Requires-Dist: pydata-sphinx-theme; extra == 'doc'
|
|
24
|
+
Requires-Dist: sphinx; extra == 'doc'
|
|
25
|
+
Requires-Dist: sphinx-copybutton; extra == 'doc'
|
|
26
|
+
Provides-Extra: lint
|
|
27
|
+
Requires-Dist: mypy; extra == 'lint'
|
|
28
|
+
Requires-Dist: ruff; extra == 'lint'
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: pytest; extra == 'test'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# regrender
|
|
34
|
+
|
|
35
|
+
**Tools for 2D mouse brain registration to the Allen Common Coordinate Framework (CCF)**
|
|
36
|
+
|
|
37
|
+
A napari-based Python toolkit
|
|
38
|
+
For registering histological brain slices to the Allen Brain Atlas, annotating cells/ROIs,
|
|
39
|
+
reconstructing probe tracks, and rendering the results with brainrender.
|
|
40
|
+
|
|
41
|
+
## Features
|
|
42
|
+
|
|
43
|
+
- **Interactive slice→CCF registration** (`regrender register`) — pick landmark pairs in napari, estimate a homography/affine transform.
|
|
44
|
+
- **ROI annotation** (`regrender roi`) — label cells on raw images, project them into CCF space, and render.
|
|
45
|
+
- **Probe-track reconstruction** (`regrender probe`) — reconstruct electrode shanks from dye labels across serial sections.
|
|
46
|
+
- **brainrender rendering** of ROIs and probes in 3D atlas space.
|
|
47
|
+
|
|
48
|
+
## Demo
|
|
49
|
+
|
|
50
|
+
**`regrender register`** — match landmark pairs between the atlas plane (left) and histology (right):
|
|
51
|
+
|
|
52
|
+
<img src="docs/source/_static/register.png" alt="register" width="800">
|
|
53
|
+
|
|
54
|
+
**`regrender roi`** — label cells per channel, then project + render in 3D with brainrender:
|
|
55
|
+
|
|
56
|
+
<img src="docs/source/_static/roi.png" alt="roi" width="800">
|
|
57
|
+
|
|
58
|
+
**`regrender probe`** — pick dye points per shank and reconstruct the tracks in 3D:
|
|
59
|
+
|
|
60
|
+
<img src="docs/source/_static/probe.png" alt="probe" width="800">
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Option 1 — one-shot CLI install, no clone or env needed (recommended for users)
|
|
67
|
+
uv tool install git+https://github.com/ytsimon2004/regrender.git
|
|
68
|
+
|
|
69
|
+
# Option 2 — uv virtual environment (development)
|
|
70
|
+
git clone https://github.com/ytsimon2004/regrender.git && cd regrender
|
|
71
|
+
uv venv && source .venv/bin/activate
|
|
72
|
+
uv pip install -e .
|
|
73
|
+
|
|
74
|
+
# Option 3 — conda environment (development)
|
|
75
|
+
git clone https://github.com/ytsimon2004/regrender.git && cd regrender
|
|
76
|
+
conda create -n regrender python=3.12 -y && conda activate regrender
|
|
77
|
+
pip install -e .
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
See the [installation docs](https://regrender.readthedocs.io/en/latest/get_start/installation.html) for details.
|
|
81
|
+
|
|
82
|
+
## Quick Start
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# 1. label ROIs on raw slices
|
|
86
|
+
regrender roi -D <slices_dir>
|
|
87
|
+
|
|
88
|
+
# 2. register each slice to the CCF
|
|
89
|
+
regrender register -D <slices_dir>
|
|
90
|
+
|
|
91
|
+
# 3. project ROIs to CCF + render (or use the GUI "Project + Render" button)
|
|
92
|
+
regrender roi --project --render -D <slices_dir>
|
|
93
|
+
|
|
94
|
+
# probe tracks
|
|
95
|
+
regrender probe -D <slices_dir>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
See the [full documentation](https://regrender.readthedocs.io) for the complete workflow.
|
|
99
|
+
|
|
100
|
+
## Contact
|
|
101
|
+
|
|
102
|
+
**Yu-Ting Wei** - ytsimon2004@gmail.com
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
regrender/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
regrender/__main__.py,sha256=W3RzXVBLBAErpoqxVrq-gW8owVLo4yLJHCZaW9FFyZc,1123
|
|
3
|
+
regrender/_app.py,sha256=eSXp6PlihoIOz83Dg6YKr82aRmT0XUy9rkIaTmDdJqk,13541
|
|
4
|
+
regrender/_core.py,sha256=sQV-F1KJH2uEGTI0pvFxdwOIzJmxD1DFS37We8Ai-Ss,12899
|
|
5
|
+
regrender/main_probe.py,sha256=OA63ky4d1cTcbp8eIzIl0B4143bMAec1VB2_2pNhbE8,36739
|
|
6
|
+
regrender/main_register.py,sha256=Wccxr7FFuUM8TyVhPLcea0TcvpAyzus0ZKRzT1pZLH8,30445
|
|
7
|
+
regrender/main_roi.py,sha256=xF0FXXE-AKHHYtSa4bewn6uPAkuaOJMp51qP9fWe4XE,25280
|
|
8
|
+
regrender-0.1.0.dist-info/METADATA,sha256=4M0U49kOR5a8ToJfYDL3hU6MSLku6zOtEhHESaoFWmg,3664
|
|
9
|
+
regrender-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
10
|
+
regrender-0.1.0.dist-info/entry_points.txt,sha256=wrYgF2udJeJvNTZNQTDNIKcqbYc_z6Mu74Kg1V6LesY,54
|
|
11
|
+
regrender-0.1.0.dist-info/licenses/LICENSE,sha256=4AWjAYxYbLp6AiBbzf4F3hPDwHBZvvu0z61Q8yMlEF0,1068
|
|
12
|
+
regrender-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yu-Ting Wei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|