PyNutil 0.3.3__tar.gz → 0.4.2__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 (76) hide show
  1. {pynutil-0.3.3 → pynutil-0.4.2}/PKG-INFO +5 -4
  2. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/config.py +19 -25
  3. pynutil-0.4.2/PyNutil/context.py +84 -0
  4. pynutil-0.4.2/PyNutil/io/__init__.py +63 -0
  5. pynutil-0.4.2/PyNutil/io/colormap.py +94 -0
  6. pynutil-0.4.2/PyNutil/io/file_operations.py +221 -0
  7. pynutil-0.4.2/PyNutil/io/loaders.py +323 -0
  8. pynutil-0.4.2/PyNutil/io/meshview_writer.py +316 -0
  9. pynutil-0.4.2/PyNutil/io/propagation.py +133 -0
  10. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/io/section_visualisation.py +95 -67
  11. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/io/volume_nifti.py +0 -4
  12. pynutil-0.4.2/PyNutil/logging_utils.py +82 -0
  13. pynutil-0.4.2/PyNutil/main.py +616 -0
  14. pynutil-0.4.2/PyNutil/processing/__init__.py +96 -0
  15. pynutil-0.4.2/PyNutil/processing/adapters/__init__.py +105 -0
  16. pynutil-0.4.2/PyNutil/processing/adapters/anchoring.py +63 -0
  17. pynutil-0.4.2/PyNutil/processing/adapters/base.py +161 -0
  18. pynutil-0.4.2/PyNutil/processing/adapters/damage.py +146 -0
  19. pynutil-0.4.2/PyNutil/processing/adapters/deformation.py +184 -0
  20. pynutil-0.4.2/PyNutil/processing/adapters/registry.py +127 -0
  21. pynutil-0.4.2/PyNutil/processing/adapters/segmentation.py +223 -0
  22. {pynutil-0.3.3/PyNutil/processing → pynutil-0.4.2/PyNutil/processing/adapters}/visualign_deformations.py +67 -144
  23. pynutil-0.4.2/PyNutil/processing/analysis/__init__.py +21 -0
  24. {pynutil-0.3.3/PyNutil/processing → pynutil-0.4.2/PyNutil/processing/analysis}/aggregator.py +3 -1
  25. pynutil-0.4.2/PyNutil/processing/analysis/data_analysis.py +337 -0
  26. pynutil-0.4.2/PyNutil/processing/analysis/region_counting.py +242 -0
  27. pynutil-0.4.2/PyNutil/processing/atlas_map.py +376 -0
  28. pynutil-0.4.2/PyNutil/processing/pipeline/__init__.py +17 -0
  29. pynutil-0.4.2/PyNutil/processing/pipeline/batch_processor.py +420 -0
  30. pynutil-0.4.2/PyNutil/processing/pipeline/connected_components.py +224 -0
  31. pynutil-0.4.2/PyNutil/processing/pipeline/section_processor.py +508 -0
  32. pynutil-0.4.2/PyNutil/processing/section_volume.py +462 -0
  33. pynutil-0.4.2/PyNutil/processing/transforms.py +221 -0
  34. pynutil-0.4.2/PyNutil/processing/utils.py +235 -0
  35. pynutil-0.4.2/PyNutil/results.py +127 -0
  36. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil.egg-info/PKG-INFO +5 -4
  37. pynutil-0.4.2/PyNutil.egg-info/SOURCES.txt +58 -0
  38. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil.egg-info/requires.txt +1 -0
  39. {pynutil-0.3.3 → pynutil-0.4.2}/README.md +3 -3
  40. {pynutil-0.3.3 → pynutil-0.4.2}/setup.py +1 -0
  41. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_coordinate_scaling.py +49 -52
  42. pynutil-0.4.2/tests/test_meshview_regression.py +161 -0
  43. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_transformations.py +1 -1
  44. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_visualisations.py +1 -1
  45. pynutil-0.3.3/PyNutil/io/__init__.py +0 -1
  46. pynutil-0.3.3/PyNutil/io/file_operations.py +0 -284
  47. pynutil-0.3.3/PyNutil/io/propagation.py +0 -123
  48. pynutil-0.3.3/PyNutil/io/read_and_write.py +0 -498
  49. pynutil-0.3.3/PyNutil/main.py +0 -603
  50. pynutil-0.3.3/PyNutil/processing/__init__.py +0 -1
  51. pynutil-0.3.3/PyNutil/processing/coordinate_extraction.py +0 -1097
  52. pynutil-0.3.3/PyNutil/processing/counting_and_load.py +0 -564
  53. pynutil-0.3.3/PyNutil/processing/data_analysis.py +0 -426
  54. pynutil-0.3.3/PyNutil/processing/generate_target_slice.py +0 -53
  55. pynutil-0.3.3/PyNutil/processing/image_loaders.py +0 -23
  56. pynutil-0.3.3/PyNutil/processing/section_volume.py +0 -373
  57. pynutil-0.3.3/PyNutil/processing/transform.py +0 -53
  58. pynutil-0.3.3/PyNutil/processing/transformations.py +0 -146
  59. pynutil-0.3.3/PyNutil/processing/utils.py +0 -356
  60. pynutil-0.3.3/PyNutil.egg-info/SOURCES.txt +0 -43
  61. {pynutil-0.3.3 → pynutil-0.4.2}/LICENSE +0 -0
  62. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/__init__.py +0 -0
  63. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/io/atlas_loader.py +0 -0
  64. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/io/nifti_writer.py +0 -0
  65. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil/io/reconstruct_dzi.py +0 -0
  66. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil.egg-info/dependency_links.txt +0 -0
  67. {pynutil-0.3.3 → pynutil-0.4.2}/PyNutil.egg-info/top_level.txt +0 -0
  68. {pynutil-0.3.3 → pynutil-0.4.2}/setup.cfg +0 -0
  69. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_build_volume_from_sections.py +0 -0
  70. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_cellpose_quantification.py +0 -0
  71. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_damage_volume_interpolation.py +0 -0
  72. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_helpers.py +0 -0
  73. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_intensity_quantification.py +0 -0
  74. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_interpolate_volume_value_modes.py +0 -0
  75. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_quantification.py +0 -0
  76. {pynutil-0.3.3 → pynutil-0.4.2}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNutil
3
- Version: 0.3.3
3
+ Version: 0.4.2
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
@@ -14,6 +14,7 @@ Requires-Dist: xmltodict
14
14
  Requires-Dist: opencv-python
15
15
  Requires-Dist: scipy
16
16
  Requires-Dist: nibabel
17
+ Requires-Dist: orjson
17
18
  Dynamic: description
18
19
  Dynamic: description-content-type
19
20
  Dynamic: home-page
@@ -23,9 +24,8 @@ Dynamic: requires-dist
23
24
  Dynamic: summary
24
25
 
25
26
  # PyNutil
26
- PyNutil is under development.
27
27
 
28
- **Warning:** There are known errors in the PyNutil reports (incorrect results). Please use Nutil until these are fixed.
28
+ ## PyNutil is still under development and the API is subject to change
29
29
 
30
30
  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).
31
31
 
@@ -89,7 +89,7 @@ pnt = PyNutil(
89
89
  colour=[0, 0, 0],
90
90
  atlas_name='allen_mouse_25um'
91
91
  #If you would like to use cellpose segmentations add:
92
- #cellpose = True
92
+ #segmentation_format = 'cellpose'
93
93
  )
94
94
 
95
95
  #optionally, if you want to generate a 3D heatmap
@@ -139,6 +139,7 @@ Each column name has the following definition
139
139
  | Right hemi | For each of the other columns, what is that value for the right hemisphere alone |
140
140
  | Damaged | For each of the other columns, what is that value for the areas marked damaged alone|
141
141
  | Undamaged | For each of the other columns, what is that value for the areas marked undamaged alone|
142
+
142
143
  If you choose to measure the intensity of images rather than segmentations you will not get object counts. Instead you will get
143
144
  | Column | Definition |
144
145
  |---------------|-------------------------------------------------------------------------------------|
@@ -5,13 +5,6 @@ from dataclasses import dataclass
5
5
  from typing import Any, Dict, Optional
6
6
 
7
7
 
8
- def _unwrap_singleton_list(value: Any) -> Any:
9
- """Unwrap legacy settings fields like [null] -> None, ["x"] -> "x"."""
10
- if isinstance(value, list) and len(value) == 1:
11
- return value[0]
12
- return value
13
-
14
-
15
8
  @dataclass
16
9
  class PyNutilConfig:
17
10
  segmentation_folder: Optional[str] = None
@@ -27,7 +20,7 @@ class PyNutilConfig:
27
20
  voxel_size_um: Optional[float] = None
28
21
  min_intensity: Optional[int] = None
29
22
  max_intensity: Optional[int] = None
30
- cellpose: bool = False
23
+ segmentation_format: str = "binary" # "binary" or "cellpose"
31
24
 
32
25
  @classmethod
33
26
  def from_settings_file(cls, settings_file: str) -> "PyNutilConfig":
@@ -37,10 +30,6 @@ class PyNutilConfig:
37
30
 
38
31
  @classmethod
39
32
  def from_settings_dict(cls, settings: Dict[str, Any]) -> "PyNutilConfig":
40
- # Be tolerant of legacy settings saved as singleton lists.
41
- def g(key: str, default: Any = None) -> Any:
42
- return _unwrap_singleton_list(settings.get(key, default))
43
-
44
33
  # alignment_json is required in settings files.
45
34
  if "alignment_json" not in settings:
46
35
  raise KeyError(
@@ -48,20 +37,20 @@ class PyNutilConfig:
48
37
  )
49
38
 
50
39
  cfg = cls(
51
- segmentation_folder=g("segmentation_folder"),
52
- image_folder=g("image_folder"),
40
+ segmentation_folder=settings.get("segmentation_folder"),
41
+ image_folder=settings.get("image_folder"),
53
42
  alignment_json=settings["alignment_json"],
54
- colour=g("colour"),
55
- intensity_channel=g("intensity_channel"),
56
- atlas_name=g("atlas_name"),
57
- atlas_path=g("atlas_path"),
58
- label_path=g("label_path"),
59
- hemi_path=g("hemi_path"),
60
- custom_region_path=g("custom_region_path"),
61
- voxel_size_um=g("voxel_size_um"),
62
- min_intensity=g("min_intensity"),
63
- max_intensity=g("max_intensity"),
64
- cellpose=bool(g("cellpose", False)),
43
+ colour=settings.get("colour"),
44
+ intensity_channel=settings.get("intensity_channel"),
45
+ atlas_name=settings.get("atlas_name"),
46
+ atlas_path=settings.get("atlas_path"),
47
+ label_path=settings.get("label_path"),
48
+ hemi_path=settings.get("hemi_path"),
49
+ custom_region_path=settings.get("custom_region_path"),
50
+ voxel_size_um=settings.get("voxel_size_um"),
51
+ min_intensity=settings.get("min_intensity"),
52
+ max_intensity=settings.get("max_intensity"),
53
+ segmentation_format=settings.get("segmentation_format", "binary"),
65
54
  )
66
55
 
67
56
  # If atlas_path/label_path are present but empty/null, treat as not provided.
@@ -84,6 +73,10 @@ class PyNutilConfig:
84
73
  return self
85
74
 
86
75
  def validate(self) -> None:
76
+ self._validate_folders()
77
+ self._validate_atlas()
78
+
79
+ def _validate_folders(self) -> None:
87
80
  if self.segmentation_folder and self.image_folder:
88
81
  raise ValueError(
89
82
  "Please specify either segmentation_folder or image_folder, not both."
@@ -101,6 +94,7 @@ class PyNutilConfig:
101
94
  "You can't specify both colour and image_folder since there are no segmentations"
102
95
  )
103
96
 
97
+ def _validate_atlas(self) -> None:
104
98
  if (self.atlas_path or self.label_path) and self.atlas_name:
105
99
  raise ValueError(
106
100
  "Please specify either atlas_path and label_path or atlas_name. Atlas and label paths are only used for loading custom atlases."
@@ -0,0 +1,84 @@
1
+ """Immutable context objects for pipeline orchestration.
2
+
3
+ These frozen dataclasses carry state through the batch and section
4
+ processing layers.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import List, Optional, Union
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from .processing.adapters.base import SliceInfo
16
+ from .processing.adapters.segmentation import SegmentationAdapter
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class PipelineContext:
21
+ """Immutable state shared across all sections in a batch run.
22
+
23
+ Attributes
24
+ ----------
25
+ atlas_labels : pd.DataFrame
26
+ Region lookup table (idx, name, r, g, b …).
27
+ atlas_volume : np.ndarray or None
28
+ 3-D annotation volume for atlas-based slicing.
29
+ hemi_map : np.ndarray or None
30
+ 3-D hemisphere mask (same shape as *atlas_volume*).
31
+ segmentation_adapter : SegmentationAdapter
32
+ Pre-resolved adapter for the current segmentation format.
33
+ non_linear : bool
34
+ Whether to apply non-linear deformation.
35
+ object_cutoff : int
36
+ Minimum connected-component area (binary pipeline).
37
+ use_flat : bool
38
+ If True, load flat-file atlas maps instead of slicing the volume.
39
+ pixel_id : object
40
+ Pixel colour to match, or ``"auto"`` for auto-detection.
41
+ apply_damage_mask : bool
42
+ Apply the damage / exclusion mask when available.
43
+ intensity_channel : str or None
44
+ Channel name for the intensity pipeline (``None`` in binary mode).
45
+ min_intensity : int or None
46
+ Lower intensity bound (intensity pipeline only).
47
+ max_intensity : int or None
48
+ Upper intensity bound (intensity pipeline only).
49
+ """
50
+
51
+ atlas_labels: pd.DataFrame
52
+ atlas_volume: Optional[np.ndarray]
53
+ hemi_map: Optional[np.ndarray]
54
+ segmentation_adapter: SegmentationAdapter
55
+ non_linear: bool
56
+ object_cutoff: int
57
+ use_flat: bool
58
+ pixel_id: object
59
+ apply_damage_mask: bool
60
+ intensity_channel: Optional[str] = None
61
+ min_intensity: Optional[int] = None
62
+ max_intensity: Optional[int] = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class SectionContext:
67
+ """Immutable per-section state built inside the batch loop.
68
+
69
+ Attributes
70
+ ----------
71
+ section_number : int
72
+ Numeric section identifier matching the alignment JSON.
73
+ slice_info : SliceInfo
74
+ Registration data for this section (anchoring, deformation, damage …).
75
+ segmentation_path : str
76
+ Path to the segmentation / image file on disk.
77
+ flat_file_path : str or None
78
+ Path to the corresponding flat-file atlas, if any.
79
+ """
80
+
81
+ section_number: int
82
+ slice_info: SliceInfo
83
+ segmentation_path: str
84
+ flat_file_path: Optional[str] = None
@@ -0,0 +1,63 @@
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
+ """
14
+
15
+ from .atlas_loader import (
16
+ load_atlas_data,
17
+ load_atlas_labels,
18
+ load_custom_atlas,
19
+ process_atlas_volume,
20
+ )
21
+ from .file_operations import save_analysis_output, SaveContext
22
+ from .loaders import (
23
+ load_quint_json,
24
+ load_segmentation,
25
+ number_sections,
26
+ get_flat_files,
27
+ get_current_flat_file,
28
+ open_custom_region_file,
29
+ read_flat_file,
30
+ read_seg_file,
31
+ )
32
+ from .meshview_writer import (
33
+ create_region_dict,
34
+ write_hemi_points_to_meshview,
35
+ write_points_to_meshview,
36
+ )
37
+ from .colormap import get_colormap_color
38
+ from .volume_nifti import save_volume_niftis
39
+
40
+ __all__ = [
41
+ # atlas_loader
42
+ "load_atlas_data",
43
+ "load_atlas_labels",
44
+ "load_custom_atlas",
45
+ "process_atlas_volume",
46
+ # file_operations
47
+ "save_analysis_output",
48
+ "SaveContext",
49
+ # loaders
50
+ "load_quint_json",
51
+ "load_segmentation",
52
+ "open_custom_region_file",
53
+ "read_flat_file",
54
+ "read_seg_file",
55
+ # meshview_writer
56
+ "create_region_dict",
57
+ "write_hemi_points_to_meshview",
58
+ "write_points_to_meshview",
59
+ # colormap
60
+ "get_colormap_color",
61
+ # volume_nifti
62
+ "save_volume_niftis",
63
+ ]
@@ -0,0 +1,94 @@
1
+ """Colormap utilities for PyNutil.
2
+
3
+ This module provides simple colormap implementations for mapping
4
+ intensity values to RGB colors, avoiding matplotlib dependency.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Tuple
10
+
11
+ import numpy as np
12
+
13
+
14
+ def _viridis(t):
15
+ return 1.0 - t, t, 0.5 + 0.5 * t
16
+
17
+
18
+ def _plasma(t):
19
+ return t, 1.0 - t, 1.0 - 0.5 * t
20
+
21
+
22
+ def _magma(t):
23
+ return t, t**2, 1.0 - t
24
+
25
+
26
+ def _hot(t):
27
+ return (
28
+ np.minimum(1.0, t * 3),
29
+ np.minimum(1.0, np.maximum(0.0, t * 3 - 1)),
30
+ np.minimum(1.0, np.maximum(0.0, t * 3 - 2)),
31
+ )
32
+
33
+
34
+ _COLORMAPS = {
35
+ "viridis": _viridis,
36
+ "plasma": _plasma,
37
+ "magma": _magma,
38
+ "hot": _hot,
39
+ }
40
+
41
+ # Pre-built 256-entry LUT cache: {name: (N, 3) uint8 array}
42
+ _LUT_CACHE: dict[str, np.ndarray] = {}
43
+
44
+
45
+ def _build_lut(name: str) -> np.ndarray:
46
+ """Build a (256, 3) uint8 lookup table for *name*."""
47
+ if name in _LUT_CACHE:
48
+ return _LUT_CACHE[name]
49
+ t = np.arange(256, dtype=np.float64) / 255.0
50
+ fn = _COLORMAPS.get(name)
51
+ if fn is None:
52
+ # gray
53
+ v = np.arange(256, dtype=np.uint8)
54
+ lut = np.column_stack([v, v, v])
55
+ else:
56
+ r, g, b = fn(t)
57
+ lut = np.column_stack([
58
+ np.clip(np.asarray(r) * 255, 0, 255).astype(np.uint8),
59
+ np.clip(np.asarray(g) * 255, 0, 255).astype(np.uint8),
60
+ np.clip(np.asarray(b) * 255, 0, 255).astype(np.uint8),
61
+ ])
62
+ _LUT_CACHE[name] = lut
63
+ return lut
64
+
65
+
66
+ def get_colormap_colors(values: np.ndarray, name: str = "gray") -> np.ndarray:
67
+ """Vectorised colormap lookup for an array of intensity values (0-255).
68
+
69
+ Returns a (N, 3) uint8 array of RGB colours.
70
+ """
71
+ lut = _build_lut(name)
72
+ idx = np.clip(values, 0, 255).astype(np.intp)
73
+ 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,221 @@
1
+ import os
2
+ import json
3
+ from dataclasses import dataclass
4
+ from typing import Optional, List
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from .meshview_writer import write_hemi_points_to_meshview
10
+
11
+
12
+ @dataclass
13
+ class SaveContext:
14
+ """Groups the many parameters needed by :func:`save_analysis_output`.
15
+
16
+ Replaces 26+ positional parameters with a single, documented object.
17
+ """
18
+
19
+ # Core data
20
+ pixel_points: Optional[np.ndarray] = None
21
+ centroids: Optional[np.ndarray] = None
22
+ label_df: Optional[pd.DataFrame] = None
23
+ per_section_df: Optional[list] = None
24
+ labeled_points: Optional[np.ndarray] = None
25
+ labeled_points_centroids: Optional[np.ndarray] = None
26
+ points_hemi_labels: Optional[np.ndarray] = None
27
+ centroids_hemi_labels: Optional[np.ndarray] = None
28
+ points_len: Optional[list] = None
29
+ centroids_len: Optional[list] = None
30
+ segmentation_filenames: Optional[list] = None
31
+ atlas_labels: Optional[pd.DataFrame] = None
32
+ point_intensities: Optional[np.ndarray] = None
33
+
34
+ # Configuration / metadata (saved to settings JSON)
35
+ segmentation_folder: Optional[str] = None
36
+ image_folder: Optional[str] = None
37
+ alignment_json: Optional[str] = None
38
+ colour: Optional[list] = None
39
+ intensity_channel: Optional[str] = None
40
+ atlas_name: Optional[str] = None
41
+ custom_region_path: Optional[str] = None
42
+ atlas_path: Optional[str] = None
43
+ label_path: Optional[str] = None
44
+ settings_file: Optional[str] = None
45
+
46
+ # Output control
47
+ prepend: str = ""
48
+ colormap: str = "gray"
49
+
50
+
51
+ def _ensure_analysis_output_dirs(output_folder: str) -> None:
52
+ os.makedirs(output_folder, exist_ok=True)
53
+ for subdir in (
54
+ "whole_series_report",
55
+ "per_section_meshview",
56
+ "per_section_reports",
57
+ "whole_series_meshview",
58
+ ):
59
+ os.makedirs(f"{output_folder}/{subdir}", exist_ok=True)
60
+
61
+
62
+ def save_analysis_output(ctx: SaveContext, output_folder: str):
63
+ """
64
+ Save the analysis output to the specified folder.
65
+
66
+ Parameters
67
+ ----------
68
+ ctx : SaveContext
69
+ All data and configuration needed for saving.
70
+ output_folder : str
71
+ The folder where the output will be saved.
72
+ """
73
+ # Create the output folder if it doesn't exist
74
+ _ensure_analysis_output_dirs(output_folder)
75
+
76
+ if ctx.label_df is not None:
77
+ report_name = "intensity.csv" if ctx.image_folder else "counts.csv"
78
+ ctx.label_df.to_csv(
79
+ f"{output_folder}/whole_series_report/{ctx.prepend}{report_name}",
80
+ sep=";",
81
+ na_rep="",
82
+ index=False,
83
+ )
84
+ elif not ctx.prepend:
85
+ print("No quantification found, so only coordinates will be saved.")
86
+ print(
87
+ "If you want to save the quantification, please run quantify_coordinates."
88
+ )
89
+
90
+ if ctx.per_section_df is not None and ctx.segmentation_filenames is not None:
91
+ _save_per_section_reports(ctx, output_folder)
92
+ if ctx.pixel_points is not None:
93
+ _save_whole_series_meshview(ctx, output_folder)
94
+
95
+ _save_settings_json(ctx, output_folder)
96
+
97
+
98
+ def _save_settings_json(ctx: SaveContext, output_folder: str) -> None:
99
+ """Write a reference settings JSON to *output_folder*."""
100
+ settings_dict = {
101
+ "segmentation_folder": ctx.segmentation_folder,
102
+ "image_folder": ctx.image_folder,
103
+ "alignment_json": ctx.alignment_json,
104
+ "colour": ctx.colour,
105
+ "intensity_channel": ctx.intensity_channel,
106
+ "custom_region_path": ctx.custom_region_path,
107
+ }
108
+
109
+ for key, val in [
110
+ ("atlas_name", ctx.atlas_name),
111
+ ("atlas_path", ctx.atlas_path),
112
+ ("label_path", ctx.label_path),
113
+ ("settings_file", ctx.settings_file),
114
+ ]:
115
+ if val:
116
+ settings_dict[key] = val
117
+
118
+ settings_file_path = os.path.join(output_folder, "pynutil_settings.json")
119
+ with open(settings_file_path, "w") as f:
120
+ json.dump(settings_dict, f, indent=4)
121
+
122
+
123
+ def _save_per_section_reports(ctx: SaveContext, output_folder: str):
124
+ """Write per-section CSVs and MeshView JSONs."""
125
+ prev_pl = 0
126
+ prev_cl = 0
127
+
128
+ # Handle None for points_len and centroids_len (e.g. in intensity mode)
129
+ points_len = ctx.points_len or [0] * len(ctx.segmentation_filenames)
130
+ centroids_len = ctx.centroids_len or [0] * len(ctx.segmentation_filenames)
131
+
132
+ for pl, cl, fn, df in zip(
133
+ points_len,
134
+ centroids_len,
135
+ ctx.segmentation_filenames,
136
+ ctx.per_section_df,
137
+ ):
138
+ split_fn = fn.split(os.sep)[-1].split(".")[0]
139
+ df.to_csv(
140
+ f"{output_folder}/per_section_reports/{ctx.prepend}{split_fn}.csv",
141
+ sep=";",
142
+ na_rep="",
143
+ index=False,
144
+ )
145
+ if ctx.pixel_points is not None or ctx.centroids is not None:
146
+ section_intensities = (
147
+ ctx.point_intensities[prev_pl : pl + prev_pl]
148
+ if ctx.point_intensities is not None
149
+ else None
150
+ )
151
+ _save_per_section_meshview(
152
+ ctx,
153
+ output_folder,
154
+ split_fn,
155
+ pl,
156
+ cl,
157
+ prev_pl,
158
+ prev_cl,
159
+ section_intensities,
160
+ )
161
+ prev_cl += cl
162
+ prev_pl += pl
163
+
164
+
165
+ def _save_per_section_meshview(
166
+ ctx: SaveContext,
167
+ output_folder: str,
168
+ split_fn: str,
169
+ pl: int,
170
+ cl: int,
171
+ prev_pl: int,
172
+ prev_cl: int,
173
+ section_intensities=None,
174
+ ):
175
+ """Write per-section MeshView JSONs for pixels and centroids."""
176
+ write_hemi_points_to_meshview(
177
+ ctx.pixel_points[prev_pl : pl + prev_pl]
178
+ if ctx.pixel_points is not None
179
+ else None,
180
+ ctx.labeled_points[prev_pl : pl + prev_pl]
181
+ if ctx.labeled_points is not None
182
+ else None,
183
+ ctx.points_hemi_labels[prev_pl : pl + prev_pl]
184
+ if ctx.points_hemi_labels is not None
185
+ else None,
186
+ f"{output_folder}/per_section_meshview/{ctx.prepend}{split_fn}_pixels.json",
187
+ ctx.atlas_labels,
188
+ section_intensities,
189
+ colormap=ctx.colormap,
190
+ )
191
+ if ctx.centroids is not None:
192
+ write_hemi_points_to_meshview(
193
+ ctx.centroids[prev_cl : cl + prev_cl],
194
+ ctx.labeled_points_centroids[prev_cl : cl + prev_cl],
195
+ ctx.centroids_hemi_labels[prev_cl : cl + prev_cl],
196
+ f"{output_folder}/per_section_meshview/{ctx.prepend}{split_fn}_centroids.json",
197
+ ctx.atlas_labels,
198
+ colormap=ctx.colormap,
199
+ )
200
+
201
+
202
+ def _save_whole_series_meshview(ctx: SaveContext, output_folder: str):
203
+ """Write whole-series MeshView JSONs for pixels and centroids."""
204
+ write_hemi_points_to_meshview(
205
+ ctx.pixel_points,
206
+ ctx.labeled_points,
207
+ ctx.points_hemi_labels,
208
+ f"{output_folder}/whole_series_meshview/{ctx.prepend}pixels_meshview.json",
209
+ ctx.atlas_labels,
210
+ ctx.point_intensities,
211
+ colormap=ctx.colormap,
212
+ )
213
+ if ctx.centroids is not None:
214
+ write_hemi_points_to_meshview(
215
+ ctx.centroids,
216
+ ctx.labeled_points_centroids,
217
+ ctx.centroids_hemi_labels,
218
+ f"{output_folder}/whole_series_meshview/{ctx.prepend}objects_meshview.json",
219
+ ctx.atlas_labels,
220
+ colormap=ctx.colormap,
221
+ )