PyNutil 0.4.2__tar.gz → 0.5__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.
- {pynutil-0.4.2 → pynutil-0.5}/PKG-INFO +2 -1
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/config.py +8 -2
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/context.py +1 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/loaders.py +34 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/section_visualisation.py +2 -3
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/main.py +107 -3
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/__init__.py +0 -2
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/adapters/__init__.py +0 -7
- pynutil-0.5/PyNutil/processing/adapters/anchoring.py +204 -0
- pynutil-0.5/PyNutil/processing/adapters/deformation.py +271 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/adapters/registry.py +8 -10
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/analysis/data_analysis.py +18 -1
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/analysis/region_counting.py +8 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/atlas_map.py +75 -11
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/pipeline/batch_processor.py +126 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/pipeline/section_processor.py +137 -3
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/section_volume.py +3 -2
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/utils.py +10 -4
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil.egg-info/PKG-INFO +2 -1
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil.egg-info/SOURCES.txt +1 -12
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil.egg-info/requires.txt +1 -0
- {pynutil-0.4.2 → pynutil-0.5}/setup.py +2 -1
- pynutil-0.4.2/PyNutil/processing/adapters/anchoring.py +0 -63
- pynutil-0.4.2/PyNutil/processing/adapters/deformation.py +0 -184
- pynutil-0.4.2/tests/test_build_volume_from_sections.py +0 -105
- pynutil-0.4.2/tests/test_cellpose_quantification.py +0 -74
- pynutil-0.4.2/tests/test_coordinate_scaling.py +0 -201
- pynutil-0.4.2/tests/test_damage_volume_interpolation.py +0 -115
- pynutil-0.4.2/tests/test_intensity_quantification.py +0 -131
- pynutil-0.4.2/tests/test_interpolate_volume_value_modes.py +0 -167
- pynutil-0.4.2/tests/test_meshview_regression.py +0 -161
- pynutil-0.4.2/tests/test_quantification.py +0 -166
- pynutil-0.4.2/tests/test_transformations.py +0 -57
- pynutil-0.4.2/tests/test_validation.py +0 -48
- pynutil-0.4.2/tests/test_visualisations.py +0 -94
- {pynutil-0.4.2 → pynutil-0.5}/LICENSE +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/__init__.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/__init__.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/atlas_loader.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/colormap.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/file_operations.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/meshview_writer.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/nifti_writer.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/propagation.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/reconstruct_dzi.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/io/volume_nifti.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/logging_utils.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/adapters/base.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/adapters/damage.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/adapters/segmentation.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/adapters/visualign_deformations.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/analysis/__init__.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/analysis/aggregator.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/pipeline/__init__.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/pipeline/connected_components.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/processing/transforms.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil/results.py +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil.egg-info/dependency_links.txt +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/PyNutil.egg-info/top_level.txt +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/README.md +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/setup.cfg +0 -0
- {pynutil-0.4.2 → pynutil-0.5}/tests/test_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyNutil
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5
|
|
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
|
-
|
|
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
|
|
88
|
+
"Please specify only one of segmentation_folder, image_folder, or coordinate_file."
|
|
83
89
|
)
|
|
84
90
|
|
|
85
91
|
if self.segmentation_folder and (
|
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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):
|
|
@@ -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
|
+
)
|