PyNutil 0.5.1__tar.gz → 0.5.4__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.5.1 → pynutil-0.5.4}/PKG-INFO +25 -29
  2. pynutil-0.5.4/PyNutil/__init__.py +30 -0
  3. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/config.py +3 -3
  4. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/context.py +37 -0
  5. pynutil-0.5.4/PyNutil/io/__init__.py +13 -0
  6. pynutil-0.5.4/PyNutil/io/atlas_loader.py +131 -0
  7. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/colormap.py +0 -23
  8. pynutil-0.5.4/PyNutil/io/file_operations.py +171 -0
  9. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/loaders.py +49 -143
  10. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/meshview_writer.py +134 -123
  11. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/reconstruct_dzi.py +3 -5
  12. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/section_visualisation.py +48 -62
  13. pynutil-0.5.4/PyNutil/processing/__init__.py +17 -0
  14. pynutil-0.5.4/PyNutil/processing/adapters/__init__.py +26 -0
  15. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/anchoring.py +28 -21
  16. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/base.py +26 -1
  17. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/damage.py +26 -57
  18. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/deformation.py +34 -10
  19. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/registry.py +4 -5
  20. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/segmentation.py +56 -17
  21. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/adapters/visualign_deformations.py +67 -60
  22. pynutil-0.5.4/PyNutil/processing/analysis/data_analysis.py +264 -0
  23. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/analysis/region_counting.py +75 -73
  24. pynutil-0.5.4/PyNutil/processing/atlas_map.py +494 -0
  25. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/pipeline/__init__.py +4 -4
  26. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/pipeline/batch_processor.py +212 -250
  27. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/pipeline/connected_components.py +34 -42
  28. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/pipeline/section_processor.py +185 -206
  29. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/section_volume.py +166 -123
  30. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/utils.py +17 -73
  31. pynutil-0.5.4/PyNutil/results/__init__.py +13 -0
  32. pynutil-0.5.4/PyNutil/results/atlas.py +17 -0
  33. pynutil-0.5.4/PyNutil/results/extraction.py +78 -0
  34. pynutil-0.5.4/PyNutil/results/section.py +61 -0
  35. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil.egg-info/PKG-INFO +25 -29
  36. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil.egg-info/SOURCES.txt +4 -4
  37. {pynutil-0.5.1 → pynutil-0.5.4}/README.md +24 -28
  38. pynutil-0.5.4/tests/test_helpers.py +79 -0
  39. pynutil-0.5.1/PyNutil/__init__.py +0 -1
  40. pynutil-0.5.1/PyNutil/io/__init__.py +0 -63
  41. pynutil-0.5.1/PyNutil/io/atlas_loader.py +0 -87
  42. pynutil-0.5.1/PyNutil/io/file_operations.py +0 -221
  43. pynutil-0.5.1/PyNutil/io/propagation.py +0 -133
  44. pynutil-0.5.1/PyNutil/main.py +0 -720
  45. pynutil-0.5.1/PyNutil/processing/__init__.py +0 -94
  46. pynutil-0.5.1/PyNutil/processing/adapters/__init__.py +0 -98
  47. pynutil-0.5.1/PyNutil/processing/analysis/data_analysis.py +0 -354
  48. pynutil-0.5.1/PyNutil/processing/atlas_map.py +0 -440
  49. pynutil-0.5.1/PyNutil/processing/transforms.py +0 -221
  50. pynutil-0.5.1/PyNutil/results.py +0 -127
  51. pynutil-0.5.1/tests/test_helpers.py +0 -23
  52. {pynutil-0.5.1 → pynutil-0.5.4}/LICENSE +0 -0
  53. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/nifti_writer.py +0 -0
  54. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/io/volume_nifti.py +0 -0
  55. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/logging_utils.py +0 -0
  56. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/analysis/__init__.py +0 -0
  57. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil/processing/analysis/aggregator.py +0 -0
  58. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil.egg-info/dependency_links.txt +0 -0
  59. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil.egg-info/requires.txt +0 -0
  60. {pynutil-0.5.1 → pynutil-0.5.4}/PyNutil.egg-info/top_level.txt +0 -0
  61. {pynutil-0.5.1 → pynutil-0.5.4}/setup.cfg +0 -0
  62. {pynutil-0.5.1 → pynutil-0.5.4}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNutil
3
- Version: 0.5.1
3
+ Version: 0.5.4
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
@@ -26,7 +26,9 @@ Dynamic: summary
26
26
 
27
27
  # PyNutil
28
28
 
29
- ## PyNutil is still under development and the API is subject to change
29
+ > [!WARNING]
30
+ > PyNutil is still under development and the API is subject to change.
31
+
30
32
 
31
33
  PyNutil is a Python library for brain-wide quantification and spatial analysis of features in serial section images from the brain. It aims to replicate the Quantifier feature of the Nutil software (RRID: SCR_017183).
32
34
 
@@ -71,37 +73,31 @@ As input, PyNutil requires:
71
73
  3. A segmentation file for each brain section with the features to be quantified displayed with a unique RGB colour code (it currently accepts many image formats: png, jpg, jpeg, etc).
72
74
 
73
75
  ```python
74
- from PyNutil import PyNutil
75
-
76
- """
77
- Here we define a quantifier object
78
- The segmentations should be images which come out of ilastik, segmenting objects-of-interest
79
- The alignment json should be from DeepSlice, QuickNII, or VisuAlign, it defines the sections position in an atlas
80
- The colour says which colour is the object you want to quantify in your segmentation. It is defined in RGB
81
- Finally the atlas name is the relevant atlas from brainglobe_atlasapi or a custom atlas in nrrd format.
82
-
83
- basic_example.py (brainglobe_atlasapi)
84
- basic_example_custom_atlas.py (custom atlas)
85
-
86
- """
87
- pnt = PyNutil(
88
- segmentation_folder='../tests/test_data/non_linear_allen_mouse/segmentations/',
89
- alignment_json='../tests/test_data/non_linear_allen_mouse/alignment.json',
90
- colour=[0, 0, 0],
91
- atlas_name='allen_mouse_25um'
92
- #If you would like to use cellpose segmentations add:
93
- #segmentation_format = 'cellpose'
76
+ from brainglobe_atlasapi import BrainGlobeAtlas
77
+ import PyNutil as pnt
78
+
79
+ # Load an atlas (BrainGlobe) and alignment
80
+ atlas = BrainGlobeAtlas("allen_mouse_25um")
81
+ alignment = pnt.read_alignment("path/to/alignment.json")
82
+
83
+ # Extract coordinates from segmentations
84
+ coords = pnt.seg_to_coords(
85
+ "path/to/segmentations/",
86
+ alignment,
87
+ atlas,
88
+ pixel_id=[0, 0, 0],
89
+ # For cellpose segmentations: segmentation_format="cellpose"
94
90
  )
95
91
 
96
- #optionally, if you want to generate a 3D heatmap
97
- pnt.interpolate_volume()
98
-
99
- pnt.get_coordinates(object_cutoff=0)
92
+ # Quantify by atlas region
93
+ label_df = pnt.quantify_coords(coords, atlas)
100
94
 
101
- pnt.quantify_coordinates()
102
-
103
- pnt.save_analysis("PyNutil/test_result/myResults")
95
+ # Save results
96
+ pnt.save_analysis("path/to/output", coords, atlas, label_df=label_df)
104
97
  ```
98
+
99
+ For custom atlases (not from BrainGlobe), use `pnt.load_custom_atlas()` instead.
100
+ See `demos/basic_example.py` and `demos/basic_example_custom_atlas.py` for complete examples.
105
101
  PyNutil generates a series of reports in the folder which you specify.
106
102
 
107
103
  ## Per-Hemisphere Quantification
@@ -0,0 +1,30 @@
1
+ from .results import AtlasData, ExtractionResult, PointSetResult
2
+ from .processing.adapters.base import RegistrationData
3
+ from .processing.adapters import read_alignment
4
+ from .io.atlas_loader import load_atlas_data, load_custom_atlas
5
+ from .processing.pipeline.batch_processor import (
6
+ seg_to_coords,
7
+ image_to_coords,
8
+ xy_to_coords,
9
+ )
10
+ from .processing.analysis.data_analysis import quantify_coords
11
+ from .io.file_operations import save_analysis
12
+ from .processing.section_volume import interpolate_volume
13
+ from .io.volume_nifti import save_volume_niftis
14
+
15
+ __all__ = [
16
+ "AtlasData",
17
+ "ExtractionResult",
18
+ "PointSetResult",
19
+ "RegistrationData",
20
+ "read_alignment",
21
+ "load_atlas_data",
22
+ "load_custom_atlas",
23
+ "seg_to_coords",
24
+ "image_to_coords",
25
+ "xy_to_coords",
26
+ "quantify_coords",
27
+ "save_analysis",
28
+ "interpolate_volume",
29
+ "save_volume_niftis",
30
+ ]
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  from dataclasses import dataclass
5
4
  from typing import Any, Dict, Optional
6
5
 
6
+ from .io.loaders import load_json_file
7
+
7
8
 
8
9
  @dataclass
9
10
  class PyNutilConfig:
@@ -25,8 +26,7 @@ class PyNutilConfig:
25
26
 
26
27
  @classmethod
27
28
  def from_settings_file(cls, settings_file: str) -> "PyNutilConfig":
28
- with open(settings_file, "r") as f:
29
- settings = json.load(f)
29
+ settings = load_json_file(settings_file)
30
30
  return cls.from_settings_dict(settings)
31
31
 
32
32
  @classmethod
@@ -62,6 +62,43 @@ class PipelineContext:
62
62
  min_intensity: Optional[int] = None
63
63
  max_intensity: Optional[int] = None
64
64
 
65
+ @classmethod
66
+ def from_format(
67
+ cls,
68
+ *,
69
+ segmentation_format: str,
70
+ atlas_labels,
71
+ atlas_volume,
72
+ hemi_map,
73
+ non_linear: bool,
74
+ object_cutoff: int,
75
+ use_flat: bool,
76
+ pixel_id,
77
+ apply_damage_mask: bool,
78
+ flat_label_path=None,
79
+ intensity_channel=None,
80
+ min_intensity=None,
81
+ max_intensity=None,
82
+ ) -> "PipelineContext":
83
+ """Construct a PipelineContext, resolving *segmentation_format* to an adapter."""
84
+ from .processing.adapters.segmentation import SegmentationAdapterRegistry
85
+
86
+ return cls(
87
+ atlas_labels=atlas_labels,
88
+ atlas_volume=atlas_volume,
89
+ hemi_map=hemi_map,
90
+ segmentation_adapter=SegmentationAdapterRegistry.get(segmentation_format),
91
+ non_linear=non_linear,
92
+ object_cutoff=object_cutoff,
93
+ use_flat=use_flat,
94
+ pixel_id=pixel_id,
95
+ apply_damage_mask=apply_damage_mask,
96
+ flat_label_path=flat_label_path,
97
+ intensity_channel=intensity_channel,
98
+ min_intensity=min_intensity,
99
+ max_intensity=max_intensity,
100
+ )
101
+
65
102
 
66
103
  @dataclass(frozen=True)
67
104
  class SectionContext:
@@ -0,0 +1,13 @@
1
+ """PyNutil I/O subpackage.
2
+
3
+ This package contains modules for reading and writing data:
4
+ - atlas_loader: Loading BrainGlobe and custom atlases
5
+ - file_operations: Saving analysis outputs
6
+ - loaders: File loading (custom regions, flat files, segmentations, JSON)
7
+ - meshview_writer: MeshView JSON output
8
+ - colormap: Colormap utilities
9
+ - section_visualisation: Creating section PNG visualizations
10
+ - volume_nifti: NIfTI volume output
11
+ - propagation: Slice anchoring interpolation
12
+ - reconstruct_dzi: DZI image reconstruction
13
+ """
@@ -0,0 +1,131 @@
1
+ import brainglobe_atlasapi
2
+ import pandas as pd
3
+ import numpy as np
4
+ import nrrd
5
+ from functools import lru_cache
6
+
7
+ from ..results import AtlasData
8
+
9
+
10
+ def load_atlas_labels(atlas=None, atlas_name=None):
11
+ if atlas_name:
12
+ atlas = brainglobe_atlasapi.BrainGlobeAtlas(atlas_name=atlas_name)
13
+ if not atlas_name and not atlas:
14
+ raise Exception("Either atlas or atlas name must be specified")
15
+ atlas_structures = {
16
+ "idx": [],
17
+ "name": [],
18
+ "r": [],
19
+ "g": [],
20
+ "b": [],
21
+ }
22
+ for structure in atlas.structures_list:
23
+ atlas_structures["idx"].append(structure["id"])
24
+ atlas_structures["name"].append(structure["name"])
25
+ rgb = structure["rgb_triplet"]
26
+ atlas_structures["r"].append(rgb[0])
27
+ atlas_structures["g"].append(rgb[1])
28
+ atlas_structures["b"].append(rgb[2])
29
+ atlas_structures["idx"].insert(0, 0)
30
+ atlas_structures["name"].insert(0, "Clear Label")
31
+ atlas_structures["r"].insert(0, 0)
32
+ atlas_structures["g"].insert(0, 0)
33
+ atlas_structures["b"].insert(0, 0)
34
+ atlas_labels = pd.DataFrame(atlas_structures)
35
+ return atlas_labels
36
+
37
+
38
+ def resolve_atlas(atlas):
39
+ """Convert an atlas argument to AtlasData.
40
+
41
+ Accepts an ``AtlasData`` instance (returned as-is) or a
42
+ ``BrainGlobeAtlas``-like object (converted via volume processing and
43
+ label loading).
44
+ """
45
+ if isinstance(atlas, AtlasData):
46
+ return atlas
47
+ # Assume BrainGlobeAtlas-like object
48
+ volume = process_atlas_volume(atlas.annotation)
49
+ hemi_map = process_atlas_volume(atlas.hemispheres)
50
+ labels = load_atlas_labels(atlas)
51
+ return AtlasData(volume=volume, hemi_map=hemi_map, labels=labels)
52
+
53
+
54
+ def resolve_atlas_labels(atlas_labels):
55
+ """Resolve atlas labels input into a DataFrame.
56
+
57
+ Accepts a raw labels DataFrame, AtlasData-like objects exposing ``labels``,
58
+ or BrainGlobeAtlas-like objects exposing ``structures_list``.
59
+ """
60
+ if isinstance(atlas_labels, pd.DataFrame):
61
+ return atlas_labels
62
+ if hasattr(atlas_labels, "labels"):
63
+ return atlas_labels.labels
64
+ if hasattr(atlas_labels, "structures_list"):
65
+ return load_atlas_labels(atlas_labels)
66
+ raise TypeError(
67
+ "atlas_labels must be a pandas DataFrame, AtlasData-like (.labels), "
68
+ "or BrainGlobeAtlas-like (.structures_list)."
69
+ )
70
+
71
+
72
+ @lru_cache(maxsize=8)
73
+ def load_atlas_data(atlas_name):
74
+ """
75
+ Loads atlas data using the brainglobe_atlasapi.
76
+
77
+ Parameters
78
+ ----------
79
+ atlas_name : str
80
+ Name of the atlas to load.
81
+
82
+ Returns
83
+ -------
84
+ AtlasData
85
+ Bundle containing atlas volume, hemisphere map, and labels.
86
+ """
87
+ atlas = brainglobe_atlasapi.BrainGlobeAtlas(atlas_name=atlas_name)
88
+ atlas_labels = load_atlas_labels(atlas)
89
+ atlas_volume = process_atlas_volume(atlas.annotation)
90
+ hemi_map = process_atlas_volume(atlas.hemispheres)
91
+ print("atlas labels loaded ✅")
92
+ return AtlasData(volume=atlas_volume, hemi_map=hemi_map, labels=atlas_labels)
93
+
94
+
95
+ def process_atlas_volume(vol):
96
+ """
97
+ Processes the atlas volume by transposing and reversing axes.
98
+
99
+ Parameters
100
+ ----------
101
+ vol : numpy.ndarray
102
+ The atlas volume to process.
103
+
104
+ Returns
105
+ -------
106
+ numpy.ndarray
107
+ The processed atlas volume.
108
+ """
109
+ return np.transpose(vol, [2, 0, 1])[::-1, ::-1, ::-1]
110
+
111
+
112
+ @lru_cache(maxsize=8)
113
+ def load_custom_atlas(atlas_path, hemi_path, label_path):
114
+ """
115
+ Loads a custom atlas from provided file paths.
116
+
117
+ Returns
118
+ -------
119
+ AtlasData
120
+ Bundle containing atlas volume, hemisphere map, and labels.
121
+ """
122
+ atlas_volume, _ = nrrd.read(atlas_path)
123
+
124
+ if hemi_path:
125
+ hemi_volume, _ = nrrd.read(hemi_path)
126
+ else:
127
+ hemi_volume = None
128
+
129
+ atlas_labels = pd.read_csv(label_path)
130
+
131
+ return AtlasData(volume=atlas_volume, hemi_map=hemi_volume, labels=atlas_labels)
@@ -6,8 +6,6 @@ intensity values to RGB colors, avoiding matplotlib dependency.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from typing import Tuple
10
-
11
9
  import numpy as np
12
10
 
13
11
 
@@ -71,24 +69,3 @@ def get_colormap_colors(values: np.ndarray, name: str = "gray") -> np.ndarray:
71
69
  lut = _build_lut(name)
72
70
  idx = np.clip(values, 0, 255).astype(np.intp)
73
71
  return lut[idx]
74
-
75
-
76
- def get_colormap_color(value: int, name: str = "gray") -> Tuple[int, int, int]:
77
- """Map an intensity value (0-255) to RGB color based on colormap name.
78
-
79
- Parameters
80
- ----------
81
- value : int
82
- Intensity value (0-255).
83
- name : str
84
- Colormap name. Options: "gray", "viridis", "plasma", "magma", "hot".
85
-
86
- Returns
87
- -------
88
- tuple
89
- (r, g, b) color values (0-255).
90
- """
91
- lut = _build_lut(name)
92
- idx = int(np.clip(value, 0, 255))
93
- row = lut[idx]
94
- return int(row[0]), int(row[1]), int(row[2])
@@ -0,0 +1,171 @@
1
+ import os
2
+ import json
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Dict, Any
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from .meshview_writer import write_hemi_points_to_meshview
10
+ from .atlas_loader import resolve_atlas_labels
11
+
12
+
13
+ @dataclass
14
+ class SaveContext:
15
+ """Groups the parameters needed by :func:`save_analysis_output`."""
16
+
17
+ # Core data
18
+ points: Optional[np.ndarray] = None
19
+ objects: Optional[np.ndarray] = None
20
+ label_df: Optional[pd.DataFrame] = None
21
+ point_labels: Optional[np.ndarray] = None
22
+ object_labels: Optional[np.ndarray] = None
23
+ points_hemi_labels: Optional[np.ndarray] = None
24
+ objects_hemi_labels: Optional[np.ndarray] = None
25
+ atlas_labels: Optional[pd.DataFrame] = None
26
+ point_values: Optional[np.ndarray] = None
27
+
28
+ # Whether this is intensity mode (affects report filename)
29
+ is_intensity: bool = False
30
+
31
+ # Settings dict written to pynutil_settings.json (optional)
32
+ settings_dict: Optional[Dict[str, Any]] = None
33
+
34
+ # Output control
35
+ prepend: str = ""
36
+ colormap: str = "gray"
37
+
38
+
39
+ def _ensure_analysis_output_dirs(output_folder: str) -> None:
40
+ os.makedirs(output_folder, exist_ok=True)
41
+ for subdir in (
42
+ "whole_series_report",
43
+ "whole_series_meshview",
44
+ ):
45
+ os.makedirs(f"{output_folder}/{subdir}", exist_ok=True)
46
+
47
+
48
+ def save_analysis_output(ctx: SaveContext, output_folder: str):
49
+ """
50
+ Save the analysis output to the specified folder.
51
+
52
+ Parameters
53
+ ----------
54
+ ctx : SaveContext
55
+ All data and configuration needed for saving.
56
+ output_folder : str
57
+ The folder where the output will be saved.
58
+ """
59
+ # Create the output folder if it doesn't exist
60
+ _ensure_analysis_output_dirs(output_folder)
61
+
62
+ if ctx.label_df is not None:
63
+ report_name = "intensity.csv" if ctx.is_intensity else "counts.csv"
64
+ ctx.label_df.to_csv(
65
+ f"{output_folder}/whole_series_report/{ctx.prepend}{report_name}",
66
+ sep=";",
67
+ na_rep="",
68
+ index=False,
69
+ )
70
+ elif not ctx.prepend:
71
+ print("No quantification found, so only coordinates will be saved.")
72
+ print(
73
+ "If you want to save the quantification, please run quantify_coordinates."
74
+ )
75
+
76
+ if ctx.points is not None:
77
+ _save_whole_series_meshview(ctx, output_folder)
78
+
79
+ _save_settings_json(ctx, output_folder)
80
+
81
+
82
+ def _save_settings_json(ctx: SaveContext, output_folder: str) -> None:
83
+ """Write a reference settings JSON to *output_folder*."""
84
+ if ctx.settings_dict is None:
85
+ return
86
+ settings_file_path = os.path.join(output_folder, "pynutil_settings.json")
87
+ with open(settings_file_path, "w") as f:
88
+ json.dump(ctx.settings_dict, f, indent=4)
89
+
90
+
91
+ def _save_whole_series_meshview(ctx: SaveContext, output_folder: str):
92
+ """Write whole-series MeshView JSONs for pixels and centroids."""
93
+ write_hemi_points_to_meshview(
94
+ ctx.points,
95
+ ctx.point_labels,
96
+ ctx.points_hemi_labels,
97
+ f"{output_folder}/whole_series_meshview/{ctx.prepend}pixels_meshview.json",
98
+ ctx.atlas_labels,
99
+ ctx.point_values,
100
+ colormap=ctx.colormap,
101
+ )
102
+ if ctx.objects is not None:
103
+ write_hemi_points_to_meshview(
104
+ ctx.objects,
105
+ ctx.object_labels,
106
+ ctx.objects_hemi_labels,
107
+ f"{output_folder}/whole_series_meshview/{ctx.prepend}objects_meshview.json",
108
+ ctx.atlas_labels,
109
+ colormap=ctx.colormap,
110
+ )
111
+
112
+
113
+ def save_analysis(
114
+ output_folder,
115
+ result,
116
+ atlas_labels,
117
+ label_df=None,
118
+ *,
119
+ colormap="gray",
120
+ settings_dict=None,
121
+ ):
122
+ """Save analysis output to the specified directory.
123
+
124
+ Args:
125
+ output_folder: Directory to write output files.
126
+ result: ExtractionResult from coordinate extraction.
127
+ atlas_labels: Atlas labels DataFrame (or AtlasData — ``.labels`` used).
128
+ label_df: Whole-series quantification DataFrame.
129
+ colormap: Colormap for MeshView intensity output.
130
+ settings_dict: Optional dict written to pynutil_settings.json.
131
+ """
132
+ atlas_labels = resolve_atlas_labels(atlas_labels)
133
+
134
+ ctx = SaveContext(
135
+ points=result.points.filtered_points() if result else None,
136
+ objects=(
137
+ result.objects.filtered_points()
138
+ if (result and result.objects is not None)
139
+ else None
140
+ ),
141
+ label_df=label_df,
142
+ point_labels=result.points.filtered_labels() if result else None,
143
+ object_labels=(
144
+ result.objects.filtered_labels()
145
+ if (result and result.objects is not None)
146
+ else None
147
+ ),
148
+ points_hemi_labels=result.points.filtered_hemi_labels() if result else None,
149
+ objects_hemi_labels=(
150
+ result.objects.filtered_hemi_labels()
151
+ if (result and result.objects is not None)
152
+ else None
153
+ ),
154
+ atlas_labels=atlas_labels,
155
+ point_values=result.points.filtered_point_values() if result else None,
156
+ is_intensity=result.region_intensities is not None if result else False,
157
+ settings_dict=settings_dict,
158
+ colormap=colormap,
159
+ )
160
+ save_analysis_output(ctx, output_folder)
161
+
162
+ # Remap compressed IDs if present
163
+ if label_df is not None and "original_idx" in label_df.columns:
164
+ remapped = label_df.copy()
165
+ remapped["idx"] = remapped["original_idx"]
166
+ remapped = remapped.drop(columns=["original_idx"])
167
+ remapped.to_csv(
168
+ f"{output_folder}/whole_series_report/counts.csv",
169
+ sep=";",
170
+ index=False,
171
+ )