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/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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ regrender = regrender.__main__:main
@@ -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.