PyNutil 0.4.2__tar.gz → 0.5.1__tar.gz

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.
Files changed (62) hide show
  1. {pynutil-0.4.2 → pynutil-0.5.1}/PKG-INFO +2 -1
  2. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/config.py +8 -2
  3. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/context.py +1 -0
  4. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/loaders.py +34 -0
  5. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/section_visualisation.py +2 -3
  6. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/main.py +107 -3
  7. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/__init__.py +0 -2
  8. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/adapters/__init__.py +0 -7
  9. pynutil-0.5.1/PyNutil/processing/adapters/anchoring.py +204 -0
  10. pynutil-0.5.1/PyNutil/processing/adapters/deformation.py +271 -0
  11. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/adapters/registry.py +8 -10
  12. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/analysis/data_analysis.py +18 -1
  13. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/analysis/region_counting.py +8 -0
  14. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/atlas_map.py +75 -11
  15. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/pipeline/batch_processor.py +126 -0
  16. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/pipeline/section_processor.py +137 -3
  17. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/section_volume.py +3 -2
  18. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/utils.py +10 -4
  19. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil.egg-info/PKG-INFO +2 -1
  20. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil.egg-info/SOURCES.txt +1 -12
  21. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil.egg-info/requires.txt +1 -0
  22. {pynutil-0.4.2 → pynutil-0.5.1}/setup.py +2 -1
  23. pynutil-0.4.2/PyNutil/processing/adapters/anchoring.py +0 -63
  24. pynutil-0.4.2/PyNutil/processing/adapters/deformation.py +0 -184
  25. pynutil-0.4.2/tests/test_build_volume_from_sections.py +0 -105
  26. pynutil-0.4.2/tests/test_cellpose_quantification.py +0 -74
  27. pynutil-0.4.2/tests/test_coordinate_scaling.py +0 -201
  28. pynutil-0.4.2/tests/test_damage_volume_interpolation.py +0 -115
  29. pynutil-0.4.2/tests/test_intensity_quantification.py +0 -131
  30. pynutil-0.4.2/tests/test_interpolate_volume_value_modes.py +0 -167
  31. pynutil-0.4.2/tests/test_meshview_regression.py +0 -161
  32. pynutil-0.4.2/tests/test_quantification.py +0 -166
  33. pynutil-0.4.2/tests/test_transformations.py +0 -57
  34. pynutil-0.4.2/tests/test_validation.py +0 -48
  35. pynutil-0.4.2/tests/test_visualisations.py +0 -94
  36. {pynutil-0.4.2 → pynutil-0.5.1}/LICENSE +0 -0
  37. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/__init__.py +0 -0
  38. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/__init__.py +0 -0
  39. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/atlas_loader.py +0 -0
  40. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/colormap.py +0 -0
  41. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/file_operations.py +0 -0
  42. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/meshview_writer.py +0 -0
  43. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/nifti_writer.py +0 -0
  44. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/propagation.py +0 -0
  45. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/reconstruct_dzi.py +0 -0
  46. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/io/volume_nifti.py +0 -0
  47. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/logging_utils.py +0 -0
  48. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/adapters/base.py +0 -0
  49. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/adapters/damage.py +0 -0
  50. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/adapters/segmentation.py +0 -0
  51. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/adapters/visualign_deformations.py +0 -0
  52. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/analysis/__init__.py +0 -0
  53. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/analysis/aggregator.py +0 -0
  54. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/pipeline/__init__.py +0 -0
  55. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/pipeline/connected_components.py +0 -0
  56. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/processing/transforms.py +0 -0
  57. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil/results.py +0 -0
  58. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil.egg-info/dependency_links.txt +0 -0
  59. {pynutil-0.4.2 → pynutil-0.5.1}/PyNutil.egg-info/top_level.txt +0 -0
  60. {pynutil-0.4.2 → pynutil-0.5.1}/README.md +0 -0
  61. {pynutil-0.4.2 → pynutil-0.5.1}/setup.cfg +0 -0
  62. {pynutil-0.4.2 → pynutil-0.5.1}/tests/test_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNutil
3
- Version: 0.4.2
3
+ Version: 0.5.1
4
4
  Summary: a package to quantify atlas registered brain data
5
5
  Home-page: https://github.com/Neural-Systems-at-UIO/PyNutil
6
6
  License: MIT
@@ -15,6 +15,7 @@ Requires-Dist: opencv-python
15
15
  Requires-Dist: scipy
16
16
  Requires-Dist: nibabel
17
17
  Requires-Dist: orjson
18
+ Requires-Dist: tqdm
18
19
  Dynamic: description
19
20
  Dynamic: description-content-type
20
21
  Dynamic: home-page
@@ -9,6 +9,7 @@ from typing import Any, Dict, Optional
9
9
  class PyNutilConfig:
10
10
  segmentation_folder: Optional[str] = None
11
11
  image_folder: Optional[str] = None
12
+ coordinate_file: Optional[str] = None
12
13
  alignment_json: Optional[str] = None
13
14
  colour: Optional[list] = None
14
15
  intensity_channel: Optional[str] = None
@@ -39,6 +40,7 @@ class PyNutilConfig:
39
40
  cfg = cls(
40
41
  segmentation_folder=settings.get("segmentation_folder"),
41
42
  image_folder=settings.get("image_folder"),
43
+ coordinate_file=settings.get("coordinate_file"),
42
44
  alignment_json=settings["alignment_json"],
43
45
  colour=settings.get("colour"),
44
46
  intensity_channel=settings.get("intensity_channel"),
@@ -77,9 +79,13 @@ class PyNutilConfig:
77
79
  self._validate_atlas()
78
80
 
79
81
  def _validate_folders(self) -> None:
80
- if self.segmentation_folder and self.image_folder:
82
+ input_count = sum(
83
+ bool(x)
84
+ for x in [self.segmentation_folder, self.image_folder, self.coordinate_file]
85
+ )
86
+ if input_count > 1:
81
87
  raise ValueError(
82
- "Please specify either segmentation_folder or image_folder, not both."
88
+ "Please specify only one of segmentation_folder, image_folder, or coordinate_file."
83
89
  )
84
90
 
85
91
  if self.segmentation_folder and (
@@ -57,6 +57,7 @@ class PipelineContext:
57
57
  use_flat: bool
58
58
  pixel_id: object
59
59
  apply_damage_mask: bool
60
+ flat_label_path: Optional[str] = None
60
61
  intensity_channel: Optional[str] = None
61
62
  min_intensity: Optional[int] = None
62
63
  max_intensity: Optional[int] = None
@@ -321,3 +321,37 @@ def get_current_flat_file(seg_nr, flat_files, flat_file_nrs, use_flat):
321
321
  current_flat_file_index = np.where([f == seg_nr for f in flat_file_nrs])
322
322
  return flat_files[current_flat_file_index[0][0]]
323
323
  return None
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Coordinate file loading
328
+ # ---------------------------------------------------------------------------
329
+
330
+ _COORDINATE_REQUIRED_COLUMNS = {"X", "Y", "image_width", "image_height", "section number"}
331
+
332
+
333
+ def load_coordinate_file(path: str) -> pd.DataFrame:
334
+ """Load a coordinate CSV file.
335
+
336
+ The CSV must contain columns: X, Y, image_width, image_height, section number.
337
+ Coordinates are in image space and will be transformed to atlas space
338
+ by the coordinate pipeline.
339
+
340
+ Parameters
341
+ ----------
342
+ path : str
343
+ Path to the CSV file.
344
+
345
+ Returns
346
+ -------
347
+ pd.DataFrame
348
+ DataFrame with coordinate data.
349
+ """
350
+ df = pd.read_csv(path)
351
+ missing = _COORDINATE_REQUIRED_COLUMNS - set(df.columns)
352
+ if missing:
353
+ raise ValueError(
354
+ f"Coordinate file is missing required columns: {missing}. "
355
+ f"Expected: {_COORDINATE_REQUIRED_COLUMNS}"
356
+ )
357
+ return df
@@ -5,7 +5,7 @@ Generates colored atlas slice PNGs and optionally overlays segmentation pixels.
5
5
 
6
6
  import os
7
7
  from typing import Dict, List, Tuple, Optional, Union
8
-
8
+ from tqdm import tqdm
9
9
  import cv2
10
10
  import numpy as np
11
11
  import pandas as pd
@@ -283,7 +283,7 @@ def create_section_visualisations(
283
283
  color_lookup = _build_color_lookup(atlas_labels)
284
284
 
285
285
  slices = alignment_json.get("slices", [])
286
- for i, slice_dict in enumerate(slices):
286
+ for i, slice_dict in tqdm(enumerate(slices), total = len(slices), desc="saving atlas images"):
287
287
  try:
288
288
  filename = slice_dict.get("filename", "")
289
289
  base_name = os.path.splitext(filename)[0] if filename else f"slice_{i:03d}"
@@ -314,6 +314,5 @@ def create_section_visualisations(
314
314
  _color_lookup=color_lookup,
315
315
  )
316
316
 
317
- print(f"Created visualisation: {output_filename}")
318
317
  except Exception as e:
319
318
  print(f"Error creating visualisation for slice {i}: {e}")
@@ -17,6 +17,7 @@ from .io.loaders import open_custom_region_file
17
17
  from .processing.pipeline.batch_processor import (
18
18
  folder_to_atlas_space,
19
19
  folder_to_atlas_space_intensity,
20
+ file_to_atlas_space_coordinates,
20
21
  )
21
22
  from .io.volume_nifti import save_volume_niftis
22
23
  from .processing.section_volume import (
@@ -35,7 +36,15 @@ class _BinaryMode:
35
36
  """Binary / Cellpose segmentation pipeline."""
36
37
 
37
38
  @staticmethod
38
- def get_coordinates(ctx, *, non_linear, object_cutoff, use_flat, apply_damage_mask):
39
+ def get_coordinates(
40
+ ctx,
41
+ *,
42
+ non_linear,
43
+ object_cutoff,
44
+ use_flat,
45
+ apply_damage_mask,
46
+ flat_label_path,
47
+ ):
39
48
  (
40
49
  ctx.pixel_points,
41
50
  ctx.centroids,
@@ -62,6 +71,7 @@ class _BinaryMode:
62
71
  ctx.hemi_map,
63
72
  use_flat,
64
73
  apply_damage_mask,
74
+ flat_label_path=flat_label_path,
65
75
  segmentation_format=ctx.segmentation_format,
66
76
  )
67
77
  ctx.apply_damage_mask = apply_damage_mask
@@ -104,7 +114,15 @@ class _IntensityMode:
104
114
  """Image intensity quantification pipeline."""
105
115
 
106
116
  @staticmethod
107
- def get_coordinates(ctx, *, non_linear, object_cutoff, use_flat, apply_damage_mask):
117
+ def get_coordinates(
118
+ ctx,
119
+ *,
120
+ non_linear,
121
+ object_cutoff,
122
+ use_flat,
123
+ apply_damage_mask,
124
+ flat_label_path,
125
+ ):
108
126
  (
109
127
  ctx.region_intensities_list,
110
128
  ctx.segmentation_filenames,
@@ -123,6 +141,7 @@ class _IntensityMode:
123
141
  ctx.hemi_map,
124
142
  use_flat,
125
143
  apply_damage_mask,
144
+ flat_label_path=flat_label_path,
126
145
  min_intensity=ctx.min_intensity,
127
146
  max_intensity=ctx.max_intensity,
128
147
  )
@@ -142,6 +161,79 @@ class _IntensityMode:
142
161
  return quantify_intensity(ctx.region_intensities_list, ctx.atlas_labels)
143
162
 
144
163
 
164
+ class _CoordinateMode:
165
+ """Pre-extracted coordinate data pipeline."""
166
+
167
+ @staticmethod
168
+ def get_coordinates(
169
+ ctx,
170
+ *,
171
+ non_linear,
172
+ object_cutoff,
173
+ use_flat,
174
+ apply_damage_mask,
175
+ flat_label_path,
176
+ ):
177
+ (
178
+ ctx.pixel_points,
179
+ ctx.centroids,
180
+ ctx.points_labels,
181
+ ctx.centroids_labels,
182
+ ctx.points_hemi_labels,
183
+ ctx.centroids_hemi_labels,
184
+ ctx.region_areas_list,
185
+ ctx.points_len,
186
+ ctx.centroids_len,
187
+ ctx.per_point_undamaged,
188
+ ctx.per_centroid_undamaged,
189
+ ctx.total_points_len,
190
+ ctx.total_centroids_len,
191
+ ) = file_to_atlas_space_coordinates(
192
+ ctx.coordinate_file,
193
+ ctx.alignment_json,
194
+ ctx.atlas_labels,
195
+ non_linear,
196
+ ctx.atlas_volume,
197
+ ctx.hemi_map,
198
+ apply_damage_mask,
199
+ )
200
+ ctx.segmentation_filenames = []
201
+ ctx.apply_damage_mask = apply_damage_mask
202
+ if ctx.custom_regions_dict is not None:
203
+ ctx.points_custom_labels = map_to_custom_regions(
204
+ ctx.custom_regions_dict, ctx.points_labels
205
+ )
206
+ ctx.centroids_custom_labels = map_to_custom_regions(
207
+ ctx.custom_regions_dict, ctx.centroids_labels
208
+ )
209
+
210
+ @staticmethod
211
+ def quantify(ctx):
212
+ if not hasattr(ctx, "pixel_points") and not hasattr(ctx, "centroids"):
213
+ raise ValueError(
214
+ "Please run get_coordinates before running quantify_coordinates."
215
+ )
216
+ points = PerEntityArrays(
217
+ labels=ctx.points_labels,
218
+ hemi_labels=ctx.points_hemi_labels,
219
+ undamaged=ctx.per_point_undamaged,
220
+ section_lengths=ctx.total_points_len,
221
+ )
222
+ centroids = PerEntityArrays(
223
+ labels=ctx.centroids_labels,
224
+ hemi_labels=ctx.centroids_hemi_labels,
225
+ undamaged=ctx.per_centroid_undamaged,
226
+ section_lengths=ctx.total_centroids_len,
227
+ )
228
+ return quantify_labeled_points(
229
+ points,
230
+ centroids,
231
+ ctx.region_areas_list,
232
+ ctx.atlas_labels,
233
+ ctx.apply_damage_mask,
234
+ )
235
+
236
+
145
237
  def _apply_custom_regions_to_quantification(ctx):
146
238
  """Shared post-quantification step: remap to custom regions if configured."""
147
239
  if ctx.custom_regions_dict is not None:
@@ -176,6 +268,7 @@ class PyNutil:
176
268
  self,
177
269
  segmentation_folder=None,
178
270
  image_folder=None,
271
+ coordinate_file=None,
179
272
  alignment_json=None,
180
273
  colour=None,
181
274
  intensity_channel=None,
@@ -243,6 +336,7 @@ class PyNutil:
243
336
  cfg = PyNutilConfig(
244
337
  segmentation_folder=segmentation_folder,
245
338
  image_folder=image_folder,
339
+ coordinate_file=coordinate_file,
246
340
  alignment_json=alignment_json,
247
341
  colour=colour,
248
342
  intensity_channel=intensity_channel,
@@ -262,6 +356,7 @@ class PyNutil:
262
356
 
263
357
  self.segmentation_folder = cfg.segmentation_folder
264
358
  self.image_folder = cfg.image_folder
359
+ self.coordinate_file = cfg.coordinate_file
265
360
  self.alignment_json = cfg.alignment_json
266
361
  self.colour = cfg.colour
267
362
  self.intensity_channel = cfg.intensity_channel
@@ -290,7 +385,12 @@ class PyNutil:
290
385
  self.point_intensities = None
291
386
 
292
387
  # Select the quantification strategy based on the input mode (OCP)
293
- self._mode = _IntensityMode() if self.image_folder else _BinaryMode()
388
+ if self.coordinate_file:
389
+ self._mode = _CoordinateMode()
390
+ elif self.image_folder:
391
+ self._mode = _IntensityMode()
392
+ else:
393
+ self._mode = _BinaryMode()
294
394
 
295
395
  def _load_atlas_data(self, cfg):
296
396
  """Load atlas volume, hemisphere map, and labels from config."""
@@ -330,6 +430,7 @@ class PyNutil:
330
430
  object_cutoff=0,
331
431
  use_flat=False,
332
432
  apply_damage_mask=True,
433
+ flat_label_path=None,
333
434
  ):
334
435
  """
335
436
  Retrieves pixel and centroid coordinates from segmentation data,
@@ -342,6 +443,8 @@ class PyNutil:
342
443
  object_cutoff (int, optional): Minimum object size.
343
444
  use_flat (bool, optional): Use flat maps if True.
344
445
  apply_damage_mask (bool, optional): Apply damage mask if True.
446
+ flat_label_path (str, optional): Path to flatmap region-id lookup
447
+ file (.csv or .label) used when flat files store indexed labels.
345
448
 
346
449
  Returns:
347
450
  None: Results are stored in class attributes.
@@ -352,6 +455,7 @@ class PyNutil:
352
455
  object_cutoff=object_cutoff,
353
456
  use_flat=use_flat,
354
457
  apply_damage_mask=apply_damage_mask,
458
+ flat_label_path=flat_label_path,
355
459
  )
356
460
 
357
461
  def quantify_coordinates(self):
@@ -56,8 +56,6 @@ from .adapters import (
56
56
  QuintAnchoringLoader,
57
57
  VisuAlignDeformationProvider,
58
58
  QCAlignDamageProvider,
59
- # Custom deformation support
60
- DisplacementFieldProvider,
61
59
  # Registry
62
60
  AnchoringLoaderRegistry,
63
61
  # Main entry point
@@ -18,11 +18,6 @@ Example:
18
18
  # Linear only (no deformation)
19
19
  data = load_registration("quicknii.json", apply_deformation=False)
20
20
 
21
- # Custom deformation
22
- from PyNutil.processing.adapters import DisplacementFieldProvider
23
- data = load_registration(
24
- "quicknii.json",
25
- deformation_provider=DisplacementFieldProvider("field.npy")
26
21
  )
27
22
  """
28
23
 
@@ -44,7 +39,6 @@ from .anchoring import (
44
39
  # Deformation providers
45
40
  from .deformation import (
46
41
  VisuAlignDeformationProvider,
47
- DisplacementFieldProvider,
48
42
  )
49
43
 
50
44
  # Damage providers
@@ -86,7 +80,6 @@ __all__ = [
86
80
  "QuintAnchoringLoader",
87
81
  # Deformation
88
82
  "VisuAlignDeformationProvider",
89
- "DisplacementFieldProvider",
90
83
  # Damage
91
84
  "QCAlignDamageProvider",
92
85
  # Registry
@@ -0,0 +1,204 @@
1
+ """Anchoring loaders for loading linear registration data.
2
+
3
+ Anchoring loaders extract the basic slice information and linear
4
+ transformation (anchoring) from registration files.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import os
11
+ import re
12
+ from typing import List
13
+
14
+ import numpy as np
15
+
16
+ from .base import AnchoringLoader, RegistrationData, SliceInfo
17
+
18
+
19
+ class QuintAnchoringLoader(AnchoringLoader):
20
+ """Loads anchoring from QUINT JSON files (QuickNII, DeepSlice, VisuAlign).
21
+
22
+ This loader extracts only the linear registration (anchoring).
23
+ Non-linear deformation and damage are handled by separate providers.
24
+ """
25
+
26
+ name: str = "quint"
27
+ file_extensions: List[str] = [".json"]
28
+
29
+ def load(self, path: str) -> RegistrationData:
30
+ """Load anchoring from a QUINT JSON file."""
31
+ import json
32
+
33
+ with open(path, "r") as f:
34
+ data = json.load(f)
35
+
36
+ slices = []
37
+ for s in data.get("slices", []):
38
+ anchoring = s.get("anchoring", [])
39
+ if not anchoring:
40
+ continue
41
+
42
+ slices.append(
43
+ SliceInfo(
44
+ section_id=s.get("filename", str(s.get("nr", 0))),
45
+ section_number=s.get("nr", 0),
46
+ width=s.get("width", 0),
47
+ height=s.get("height", 0),
48
+ anchoring=anchoring,
49
+ deformation=None, # Added by DeformationProvider
50
+ damage_mask=None, # Added by DamageProvider
51
+ metadata={
52
+ "filename": s.get("filename"),
53
+ # Store raw data for providers to use
54
+ "_raw_slice": s,
55
+ },
56
+ )
57
+ )
58
+
59
+ return RegistrationData(
60
+ slices=slices,
61
+ grid_spacing=data.get("gridspacing"),
62
+ metadata={
63
+ "target": data.get("target"),
64
+ "target-resolution": data.get("target-resolution"),
65
+ "propagate": data.get("propagate", False),
66
+ "_raw_data": data, # Keep for providers
67
+ },
68
+ )
69
+
70
+
71
+ class BrainGlobeRegistrationLoader(AnchoringLoader):
72
+ """Loads anchoring from brainglobe-registration output JSON.
73
+
74
+ Converts ``atlas_slice_corners`` (in microns) to a QuickNII-compatible
75
+ anchoring vector by:
76
+ 1. Converting microns to atlas voxels using the atlas resolution.
77
+ 2. Transforming from BrainGlobe orientation to PyNutil orientation
78
+ (which applies ``transpose([2,0,1])[::-1,::-1,::-1]``).
79
+ 3. Computing O, U, V vectors from the TL, TR, BL corners.
80
+ """
81
+
82
+ name: str = "brainglobe"
83
+ file_extensions: List[str] = [".json"]
84
+
85
+ @classmethod
86
+ def can_handle(cls, path: str) -> bool:
87
+ """Detect brainglobe-registration JSON by looking for atlas_slice_corners."""
88
+ import json
89
+
90
+ if not path.endswith(".json"):
91
+ return False
92
+ try:
93
+ with open(path, "r") as f:
94
+ data = json.load(f)
95
+ return "atlas_slice_corners" in data
96
+ except Exception:
97
+ return False
98
+
99
+ @staticmethod
100
+ def _infer_resolution(atlas_name: str) -> float:
101
+ """Extract voxel resolution in microns from the atlas name."""
102
+ m = re.search(r"(\d+(?:\.\d+)?)um$", atlas_name)
103
+ if not m:
104
+ raise ValueError(
105
+ f"Cannot infer resolution from atlas name '{atlas_name}'. "
106
+ "Expected a name ending in '<number>um' (e.g. 'allen_mouse_25um')."
107
+ )
108
+ return float(m.group(1))
109
+
110
+ @staticmethod
111
+ def _get_atlas_shape(atlas_name: str):
112
+ """Return the native (un-reoriented) atlas annotation shape."""
113
+ import brainglobe_atlasapi
114
+
115
+ bg = brainglobe_atlasapi.BrainGlobeAtlas(atlas_name=atlas_name)
116
+ return bg.annotation.shape # (AP, DV, LR)
117
+
118
+ @staticmethod
119
+ def _bg_to_pynutil(corners_vx: np.ndarray, bg_shape) -> np.ndarray:
120
+ """Convert BrainGlobe voxel coords to PyNutil atlas coords.
121
+
122
+ PyNutil reorients the atlas via ``transpose([2,0,1])[::-1,::-1,::-1]``.
123
+ Given BrainGlobe coords ``(bg0, bg1, bg2)`` (AP, DV, LR) and the
124
+ original annotation shape ``(S0, S1, S2)``, the PyNutil coords are:
125
+ px = (S2 - 1) - bg2
126
+ py = (S0 - 1) - bg0
127
+ pz = (S1 - 1) - bg1
128
+ """
129
+ pn = np.zeros_like(corners_vx)
130
+ pn[:, 0] = (bg_shape[2] - 1) - corners_vx[:, 2]
131
+ pn[:, 1] = (bg_shape[0] - 1) - corners_vx[:, 0]
132
+ pn[:, 2] = (bg_shape[1] - 1) - corners_vx[:, 1]
133
+ return pn
134
+
135
+ @staticmethod
136
+ def _find_tiff_dims(reg_dir: str):
137
+ """Read the dimensions of one of the output TIFFs in *reg_dir*."""
138
+ import tifffile
139
+
140
+ for name in ("downsampled.tiff", "registered_atlas.tiff", "registered_hemispheres.tiff"):
141
+ p = os.path.join(reg_dir, name)
142
+ if os.path.isfile(p):
143
+ img = tifffile.imread(p)
144
+ return img.shape[0], img.shape[1] # (height, width)
145
+ return None, None
146
+
147
+ def load(self, path: str) -> RegistrationData:
148
+ """Load anchoring from a brainglobe-registration JSON."""
149
+ import json
150
+
151
+ with open(path, "r") as f:
152
+ data = json.load(f)
153
+
154
+ atlas_name = data["atlas"]
155
+ corners_um = np.array(data["atlas_slice_corners"], dtype=np.float64)
156
+ resolution = self._infer_resolution(atlas_name)
157
+ corners_vx = corners_um / resolution
158
+
159
+ bg_shape = self._get_atlas_shape(atlas_name)
160
+ pn_corners = self._bg_to_pynutil(corners_vx, bg_shape)
161
+
162
+ # TL, TR, BL, BR
163
+ O = pn_corners[0]
164
+ U = pn_corners[1] - pn_corners[0]
165
+ V = pn_corners[2] - pn_corners[0]
166
+ anchoring = O.tolist() + U.tolist() + V.tolist()
167
+
168
+ width = int(math.floor(math.hypot(*U))) + 1
169
+ height = int(math.floor(math.hypot(*V))) + 1
170
+
171
+ reg_dir = os.path.dirname(os.path.abspath(path))
172
+ tiff_h, tiff_w = self._find_tiff_dims(reg_dir)
173
+
174
+ section_number = data.get("atlas_2d_slice_index", 0)
175
+
176
+ slices = [
177
+ SliceInfo(
178
+ section_id=str(section_number),
179
+ section_number=section_number,
180
+ width=width,
181
+ height=height,
182
+ anchoring=anchoring,
183
+ deformation=None,
184
+ damage_mask=None,
185
+ metadata={
186
+ "registration_type": "brainglobe",
187
+ "registration_dir": reg_dir,
188
+ "atlas_name": atlas_name,
189
+ "resolution_um": resolution,
190
+ "bg_atlas_shape": bg_shape,
191
+ "tiff_width": tiff_w,
192
+ "tiff_height": tiff_h,
193
+ },
194
+ )
195
+ ]
196
+
197
+ return RegistrationData(
198
+ slices=slices,
199
+ metadata={
200
+ "registration_type": "brainglobe",
201
+ "atlas_name": atlas_name,
202
+ "_raw_data": data,
203
+ },
204
+ )