PyNutil 0.2.25__tar.gz → 0.3.0__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 (49) hide show
  1. {pynutil-0.2.25 → pynutil-0.3.0}/PKG-INFO +51 -19
  2. pynutil-0.3.0/PyNutil/config.py +112 -0
  3. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/io/atlas_loader.py +8 -18
  4. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/io/file_operations.py +119 -90
  5. pynutil-0.3.0/PyNutil/io/nifti_writer.py +47 -0
  6. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/io/read_and_write.py +179 -19
  7. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/io/reconstruct_dzi.py +3 -2
  8. pynutil-0.3.0/PyNutil/io/section_visualisation.py +291 -0
  9. pynutil-0.3.0/PyNutil/io/volume_nifti.py +95 -0
  10. pynutil-0.3.0/PyNutil/main.py +603 -0
  11. pynutil-0.3.0/PyNutil/processing/aggregator.py +60 -0
  12. pynutil-0.3.0/PyNutil/processing/coordinate_extraction.py +1097 -0
  13. pynutil-0.3.0/PyNutil/processing/counting_and_load.py +564 -0
  14. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/processing/data_analysis.py +113 -43
  15. pynutil-0.3.0/PyNutil/processing/image_loaders.py +23 -0
  16. pynutil-0.3.0/PyNutil/processing/section_volume.py +373 -0
  17. pynutil-0.3.0/PyNutil/processing/transform.py +53 -0
  18. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/processing/transformations.py +19 -12
  19. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/processing/utils.py +108 -16
  20. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/processing/visualign_deformations.py +7 -41
  21. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil.egg-info/PKG-INFO +51 -19
  22. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil.egg-info/SOURCES.txt +19 -1
  23. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil.egg-info/requires.txt +3 -3
  24. {pynutil-0.2.25 → pynutil-0.3.0}/README.md +47 -15
  25. {pynutil-0.2.25 → pynutil-0.3.0}/setup.py +3 -3
  26. pynutil-0.3.0/tests/test_build_volume_from_sections.py +105 -0
  27. pynutil-0.3.0/tests/test_cellpose_quantification.py +74 -0
  28. pynutil-0.3.0/tests/test_coordinate_scaling.py +204 -0
  29. pynutil-0.3.0/tests/test_damage_volume_interpolation.py +115 -0
  30. pynutil-0.3.0/tests/test_helpers.py +23 -0
  31. pynutil-0.3.0/tests/test_intensity_quantification.py +131 -0
  32. pynutil-0.3.0/tests/test_interpolate_volume_value_modes.py +167 -0
  33. pynutil-0.3.0/tests/test_quantification.py +166 -0
  34. pynutil-0.3.0/tests/test_transformations.py +57 -0
  35. pynutil-0.3.0/tests/test_validation.py +48 -0
  36. pynutil-0.3.0/tests/test_visualisations.py +94 -0
  37. pynutil-0.2.25/PyNutil/main.py +0 -303
  38. pynutil-0.2.25/PyNutil/processing/coordinate_extraction.py +0 -641
  39. pynutil-0.2.25/PyNutil/processing/counting_and_load.py +0 -728
  40. pynutil-0.2.25/tests/test_quantification.py +0 -57
  41. {pynutil-0.2.25 → pynutil-0.3.0}/LICENSE +0 -0
  42. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/__init__.py +0 -0
  43. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/io/__init__.py +0 -0
  44. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/io/propagation.py +0 -0
  45. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/processing/__init__.py +0 -0
  46. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil/processing/generate_target_slice.py +0 -0
  47. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil.egg-info/dependency_links.txt +0 -0
  48. {pynutil-0.2.25 → pynutil-0.3.0}/PyNutil.egg-info/top_level.txt +0 -0
  49. {pynutil-0.2.25 → pynutil-0.3.0}/setup.cfg +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyNutil
3
- Version: 0.2.25
3
+ Version: 0.3.0
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
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
- Requires-Dist: numpy
9
+ Requires-Dist: numpy==2.4
10
10
  Requires-Dist: brainglobe-atlasapi
11
11
  Requires-Dist: pandas
12
- Requires-Dist: requests
13
12
  Requires-Dist: pynrrd
14
13
  Requires-Dist: xmltodict
15
14
  Requires-Dist: opencv-python
16
- Requires-Dist: scikit-image
15
+ Requires-Dist: scipy
16
+ Requires-Dist: nibabel
17
17
  Dynamic: description
18
18
  Dynamic: description-content-type
19
19
  Dynamic: home-page
@@ -25,7 +25,12 @@ Dynamic: summary
25
25
  # PyNutil
26
26
  PyNutil is under development.
27
27
 
28
- PyNutil is a Python library for brain-wide quantification and spatial analysis of features in serial section images from mouse and rat brain. It aims to replicate the Quantifier feature of the Nutil software (RRID: SCR_017183). It builds on registration to a standardised reference atlas with the QuickNII (RRID:SCR_016854) and VisuAlign software (RRID:SCR_017978) and feature extraction by segmentation with an image analysis software such as ilastik (RRID:SCR_015246).
28
+ **Warning:** There are known errors in the PyNutil reports (incorrect results). Please use Nutil until these are fixed.
29
+
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
+
32
+ PyNutil is able to integrate outputs from atlas registration software and image segmentation software in order to produce atlas based quantifications, 3D point clouds, and 3D heatmaps of brain derived data.
33
+ ![alt text](docs/assets/PyNutil_fig1.png)
29
34
 
30
35
  For more information about the QUINT workflow:
31
36
  https://quint-workflow.readthedocs.io/en/latest/
@@ -34,17 +39,26 @@ https://quint-workflow.readthedocs.io/en/latest/
34
39
 
35
40
  PyNutil can be run using a custom atlas in .nrrd format (e.g. tests/test_data/Allen_mouse_2017_atlas)
36
41
 
37
- Pynutil can also be used with the atlases available in the [BrainGlobe_Atlas API](https://github.com/brainglobe/brainglobe-atlasapi).
42
+ PyNutil can also be used with the atlases available in the [BrainGlobe_Atlas API](https://GitHub.com/brainglobe/brainglobe-atlasapi).
38
43
 
39
44
  # Installation
40
45
  ## Python package
41
46
  ```
42
- pip install PyNutil
47
+ pip install PyNutil
48
+ ```
49
+
50
+ ## Running demos
51
+
52
+ The scripts in `demos/` assume PyNutil is importable as an installed package.
53
+ For development, install in editable mode from the repository root:
54
+
55
+ ```bash
56
+ pip install -e .
43
57
  ```
44
58
  ## GUI
45
- download the executable for windows and macOS via the [github releases tab](https://github.com/Neural-Systems-at-UIO/PyNutil/releases)
59
+ download the executable for Windows and macOS via the [GitHub releases tab](https://GitHub.com/Neural-Systems-at-UIO/PyNutil/releases)
46
60
 
47
- ![image](https://github.com/user-attachments/assets/c3b3ca11-f0ef-4e2c-96d7-498ecf96514d)
61
+ ![image](https://GitHub.com/user-attachments/assets/c3b3ca11-f0ef-4e2c-96d7-498ecf96514d)
48
62
 
49
63
  # Usage
50
64
 
@@ -74,8 +88,13 @@ pnt = PyNutil(
74
88
  alignment_json='../tests/test_data/non_linear_allen_mouse/alignment.json',
75
89
  colour=[0, 0, 0],
76
90
  atlas_name='allen_mouse_25um'
91
+ #If you would like to use cellpose segmentations add:
92
+ #cellpose = True
77
93
  )
78
94
 
95
+ #optionally, if you want to generate a 3D heatmap
96
+ pnt.interpolate_volume()
97
+
79
98
  pnt.get_coordinates(object_cutoff=0)
80
99
 
81
100
  pnt.quantify_coordinates()
@@ -85,23 +104,32 @@ pnt.save_analysis("PyNutil/test_result/myResults")
85
104
  PyNutil generates a series of reports in the folder which you specify.
86
105
 
87
106
  ## Per-Hemisphere Quantification
88
- If you use an atlas which has a hemisphere map (All brainglobe atlases have this, it is a volume in the shape of the atlas with 1 in the lft hemisphere and 2 in the right) PyNutil will generate per-hemisphere quantifications in addition to total numbers. In addition, PyNutil will also genearte additional per-hemisphere point cloud files for viewing in meshview.
107
+ If you use an atlas which has a hemisphere map (All brainglobe atlases have this, it is a volume in the shape of the atlas with 1 in the left hemisphere and 2 in the right) PyNutil will generate per-hemisphere quantifications in addition to total numbers. In addition, PyNutil will also genearte additional per-hemisphere point cloud files for viewing in meshview.
89
108
  ## Damage Quantification
90
- [The QCAlign tool](https://www.nitrc.org/projects/qcalign) allows you to mark damaged areas on your section. This means that these damaged areas are excluded from your point clouds. In addition, PyNutil will seperately quantify damaged and undamaged areas. Note the undamaged, and damaged column names.
109
+ [The QCAlign tool](https://www.nitrc.org/projects/qcalign) allows you to mark damaged areas on your section. This means that these damaged areas are excluded from your point clouds. In addition, PyNutil will separately quantify damaged and undamaged areas. Note the undamaged and damaged column names.
91
110
  # Meshview json files
92
- PyNutil will produce meshview json files. This can be opened in [MeshView for the Allen Mouse](https://meshview.apps.ebrains.eu/?atlas=ABA_Mouse_CCFv3_2017_25um) or for [the Waxholm Rat](https://meshview.apps.ebrains.eu/)
111
+ PyNutil will produce meshview json files. These can be opened in [MeshView for the Allen Mouse](https://meshview.apps.ebrains.eu/?atlas=ABA_Mouse_CCFv3_2017_25um) or for [the Waxholm Rat](https://meshview.apps.ebrains.eu/)
93
112
 
94
113
  https://github.com/user-attachments/assets/d3a43ca9-133e-40d1-a1b9-9a359deabf2d
95
114
 
115
+
116
+ # Siibra explorer compatible NifTI files
117
+ If you have interpolated your volume you will find an interpolated NifTI volume in your output directory. This can be dragged and dropped directly into [siibra explorer](https://atlases.ebrains.eu/viewer/#/). If you share your data you can also include a shareable link to your data in the Siibra viewer. These files are also viewable in [ITK-SNAP](https://github.com/pyushkevich/itksnap).
118
+
119
+
120
+
121
+ https://github.com/user-attachments/assets/30ca7f7f-92f5-4d83-a92a-b29603181b8f
122
+
123
+
96
124
  # Interpreting the Results
97
125
  Each column name has the following definition
98
126
  | Column | Definition |
99
- |---------------|--------------------------------------------------------------------------------------|
127
+ |---------------|-------------------------------------------------------------------------------------|
100
128
  | idx | The atlas ID of the region. |
101
129
  | name | The name of atlas region. |
102
- | r | The amount of red in the RGB value for for the region colour. |
103
- | g | The amount of green in the RGB value for for the region colour. |
104
- | b | The amount of blue in the RGB value for for the region colour. |
130
+ | r | The amount of red in the RGB value for the region colour. |
131
+ | g | The amount of green in the RGB value for the region colour. |
132
+ | b | The amount of blue in the RGB value for the region colour. |
105
133
  | Region area | Area representing the region on the segmentation in pixel values. |
106
134
  | Object count | Number of objects located in the region. An object is a disconnected group of pixels|
107
135
  | Object pixels | Number of pixels representing objects in this region. |
@@ -111,9 +139,13 @@ Each column name has the following definition
111
139
  | Right hemi | For each of the other columns, what is that value for the right hemisphere alone |
112
140
  | Damaged | For each of the other columns, what is that value for the areas marked damaged alone|
113
141
  | Undamaged | For each of the other columns, what is that value for the areas marked undamaged alone|
114
-
142
+ If you choose to measure the intensity of images rather than segmentations you will not get object counts. Instead you will get
143
+ | Column | Definition |
144
+ |---------------|-------------------------------------------------------------------------------------|
145
+ | Sum intensity | The sum of all image pixels in a region. |
146
+ | Mean inensity | The mean of all image pixels in a region. |
115
147
  # Feature Requests
116
- We are open to feature requests 😊 Simply open an issue in the github describing the feature you would like to see.
148
+ We are open to feature requests 😊 Simply open an issue in the GitHub describing the feature you would like to see.
117
149
 
118
150
  # Acknowledgements
119
151
  PyNutil is developed at the Neural Systems Laboratory at the Institute of Basic Medical Sciences, University of Oslo, Norway with support from the EBRAINS infrastructure, and funding support from the European Union’s Horizon 2020 Framework Programme for Research and Innovation under the Framework Partnership Agreement No. 650003 (HBP FPA).
@@ -136,4 +168,4 @@ Carey H, Pegios M, Martin L, Saleeba C, Turner A, Everett N, Puchades M, Bjaalie
136
168
  Berg S., Kutra D., Kroeger T., Straehle C.N., Kausler B.X., Haubold C., et al. (2019) ilastik:interactive machine learning for (bio) image analysis. Nat Methods. 16, 1226–1232. https://doi.org/10.1038/s41592-019-0582-9
137
169
 
138
170
  # Contact us
139
- Report issues here on Github or email: support@ebrains.eu
171
+ Report issues here on GitHub or email: support@ebrains.eu
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional
6
+
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
+ @dataclass
16
+ class PyNutilConfig:
17
+ segmentation_folder: Optional[str] = None
18
+ image_folder: Optional[str] = None
19
+ alignment_json: Optional[str] = None
20
+ colour: Optional[list] = None
21
+ intensity_channel: Optional[str] = None
22
+ atlas_name: Optional[str] = None
23
+ atlas_path: Optional[str] = None
24
+ label_path: Optional[str] = None
25
+ hemi_path: Optional[str] = None
26
+ custom_region_path: Optional[str] = None
27
+ voxel_size_um: Optional[float] = None
28
+ min_intensity: Optional[int] = None
29
+ max_intensity: Optional[int] = None
30
+ cellpose: bool = False
31
+
32
+ @classmethod
33
+ def from_settings_file(cls, settings_file: str) -> "PyNutilConfig":
34
+ with open(settings_file, "r") as f:
35
+ settings = json.load(f)
36
+ return cls.from_settings_dict(settings)
37
+
38
+ @classmethod
39
+ 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
+ # alignment_json is required in settings files.
45
+ if "alignment_json" not in settings:
46
+ raise KeyError(
47
+ "Settings file must contain alignment_json, and either atlas_path and label_path or atlas_name. It should also contain either segmentation_folder or image_folder."
48
+ )
49
+
50
+ cfg = cls(
51
+ segmentation_folder=g("segmentation_folder"),
52
+ image_folder=g("image_folder"),
53
+ 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)),
65
+ )
66
+
67
+ # If atlas_path/label_path are present but empty/null, treat as not provided.
68
+ if not cfg.atlas_path or not cfg.label_path:
69
+ cfg.atlas_path = None
70
+ cfg.label_path = None
71
+ cfg.hemi_path = None
72
+
73
+ return cfg
74
+
75
+ def normalize(self, *, logger=None) -> "PyNutilConfig":
76
+ # If atlas_name is provided, voxel size is inferred from atlas name and
77
+ # any manually provided voxel_size_um is ignored (existing behavior).
78
+ if self.atlas_name is not None and self.voxel_size_um is not None:
79
+ if logger is not None:
80
+ logger.warning(
81
+ f"Voxel size ({self.voxel_size_um} um) was specified but will be ignored because atlas_name ({self.atlas_name}) is provided. Voxel size will be inferred from the atlas name."
82
+ )
83
+ self.voxel_size_um = None
84
+ return self
85
+
86
+ def validate(self) -> None:
87
+ if self.segmentation_folder and self.image_folder:
88
+ raise ValueError(
89
+ "Please specify either segmentation_folder or image_folder, not both."
90
+ )
91
+
92
+ if self.segmentation_folder and (
93
+ self.min_intensity is not None or self.max_intensity is not None
94
+ ):
95
+ raise ValueError(
96
+ "min_intensity and max_intensity are only supported when using image_folder, not segmentation_folder."
97
+ )
98
+
99
+ if self.image_folder and (self.colour is not None):
100
+ raise ValueError(
101
+ "You can't specify both colour and image_folder since there are no segmentations"
102
+ )
103
+
104
+ if (self.atlas_path or self.label_path) and self.atlas_name:
105
+ raise ValueError(
106
+ "Please specify either atlas_path and label_path or atlas_name. Atlas and label paths are only used for loading custom atlases."
107
+ )
108
+
109
+ if not (self.atlas_path and self.label_path) and not self.atlas_name:
110
+ raise ValueError(
111
+ "When atlas_path and label_path are not specified, atlas_name must be specified."
112
+ )
@@ -2,6 +2,8 @@ import brainglobe_atlasapi
2
2
  import pandas as pd
3
3
  import numpy as np
4
4
  import nrrd
5
+ from functools import lru_cache
6
+
5
7
 
6
8
  def load_atlas_labels(atlas=None, atlas_name=None):
7
9
  if atlas_name:
@@ -23,6 +25,8 @@ def load_atlas_labels(atlas=None, atlas_name=None):
23
25
  atlas_labels = pd.DataFrame(atlas_structures)
24
26
  return atlas_labels
25
27
 
28
+
29
+ @lru_cache(maxsize=8)
26
30
  def load_atlas_data(atlas_name):
27
31
  """
28
32
  Loads atlas data using the brainglobe_atlasapi.
@@ -66,32 +70,18 @@ def process_atlas_volume(vol):
66
70
  return np.transpose(vol, [2, 0, 1])[::-1, ::-1, ::-1]
67
71
 
68
72
 
73
+ @lru_cache(maxsize=8)
69
74
  def load_custom_atlas(atlas_path, hemi_path, label_path):
70
75
  """
71
76
  Loads a custom atlas from provided file paths.
72
-
73
- Parameters
74
- ----------
75
- atlas_path : str
76
- Path to the custom atlas volume file.
77
- hemi_path : str or None
78
- Path to the hemisphere file, if any.
79
- label_path : str
80
- Path to the label CSV file for region info.
81
-
82
- Returns
83
- -------
84
- numpy.ndarray
85
- The loaded atlas volume.
86
- numpy.ndarray or None
87
- The hemisphere array, or None if hemi_path is not provided.
88
- pandas.DataFrame
89
- A dataframe containing atlas labels.
90
77
  """
91
78
  atlas_volume, _ = nrrd.read(atlas_path)
79
+
92
80
  if hemi_path:
93
81
  hemi_volume, _ = nrrd.read(hemi_path)
94
82
  else:
95
83
  hemi_volume = None
84
+
96
85
  atlas_labels = pd.read_csv(label_path)
86
+
97
87
  return atlas_volume, hemi_volume, atlas_labels
@@ -3,6 +3,17 @@ import json
3
3
  from .read_and_write import write_hemi_points_to_meshview
4
4
 
5
5
 
6
+ def _ensure_analysis_output_dirs(output_folder: str) -> None:
7
+ os.makedirs(output_folder, exist_ok=True)
8
+ for subdir in (
9
+ "whole_series_report",
10
+ "per_section_meshview",
11
+ "per_section_reports",
12
+ "whole_series_meshview",
13
+ ):
14
+ os.makedirs(f"{output_folder}/{subdir}", exist_ok=True)
15
+
16
+
6
17
  def save_analysis_output(
7
18
  pixel_points,
8
19
  centroids,
@@ -18,14 +29,18 @@ def save_analysis_output(
18
29
  atlas_labels,
19
30
  output_folder,
20
31
  segmentation_folder=None,
32
+ image_folder=None,
21
33
  alignment_json=None,
22
34
  colour=None,
35
+ intensity_channel=None,
23
36
  atlas_name=None,
24
37
  custom_region_path=None,
25
38
  atlas_path=None,
26
39
  label_path=None,
27
40
  settings_file=None,
28
41
  prepend=None,
42
+ point_intensities=None,
43
+ **kwargs,
29
44
  ):
30
45
  """
31
46
  Save the analysis output to the specified folder.
@@ -37,10 +52,14 @@ def save_analysis_output(
37
52
  The folder where the output will be saved.
38
53
  segmentation_folder : str, optional
39
54
  The folder containing the segmentation files (default is None).
55
+ image_folder : str, optional
56
+ The folder containing the original images (default is None).
40
57
  alignment_json : str, optional
41
58
  The path to the alignment JSON file (default is None).
42
59
  colour : list, optional
43
60
  The RGB colour of the object to be quantified in the segmentation (default is None).
61
+ intensity_channel : str, optional
62
+ The channel used for intensity quantification (default is None).
44
63
  atlas_name : str, optional
45
64
  The name of the atlas in the brainglobe api to be used for quantification (default is None).
46
65
  atlas_path : str, optional
@@ -51,83 +70,67 @@ def save_analysis_output(
51
70
  The path to the settings file that was used (default is None).
52
71
  """
53
72
  # Create the output folder if it doesn't exist
54
- os.makedirs(output_folder, exist_ok=True)
55
- os.makedirs(f"{output_folder}/whole_series_report", exist_ok=True)
56
- os.makedirs(f"{output_folder}/per_section_meshview", exist_ok=True)
57
- os.makedirs(f"{output_folder}/per_section_reports", exist_ok=True)
58
- os.makedirs(f"{output_folder}/whole_series_meshview", exist_ok=True)
73
+ _ensure_analysis_output_dirs(output_folder)
59
74
  # Filter out rows where 'region_area' is 0 in label_df
60
75
  # if label_df is not None and "region_area" in label_df.columns:
61
76
  # label_df = label_df[label_df["region_area"] != 0]
62
77
  if label_df is not None:
78
+ report_name = "intensity.csv" if image_folder else "counts.csv"
63
79
  label_df.to_csv(
64
- f"{output_folder}/whole_series_report/{prepend}counts.csv",
80
+ f"{output_folder}/whole_series_report/{prepend}{report_name}",
65
81
  sep=";",
66
82
  na_rep="",
67
83
  index=False,
68
84
  )
69
- else:
85
+ elif not prepend:
70
86
  print("No quantification found, so only coordinates will be saved.")
71
87
  print(
72
88
  "If you want to save the quantification, please run quantify_coordinates."
73
89
  )
74
90
 
75
- _save_per_section_reports(
76
- per_section_df,
77
- segmentation_filenames,
78
- points_len,
79
- centroids_len,
80
- pixel_points,
81
- centroids,
82
- labeled_points,
83
- labeled_points_centroids,
84
- points_hemi_labels,
85
- centroids_hemi_labels,
86
- atlas_labels,
87
- output_folder,
88
- prepend,
89
- )
90
- _save_whole_series_meshview(
91
- pixel_points,
92
- labeled_points,
93
- centroids,
94
- labeled_points_centroids,
95
- points_hemi_labels,
96
- centroids_hemi_labels,
97
- atlas_labels,
98
- output_folder,
99
- prepend,
100
- )
91
+ if per_section_df is not None and segmentation_filenames is not None:
92
+ _save_per_section_reports(
93
+ per_section_df,
94
+ segmentation_filenames,
95
+ points_len,
96
+ centroids_len,
97
+ pixel_points,
98
+ centroids,
99
+ labeled_points,
100
+ labeled_points_centroids,
101
+ points_hemi_labels,
102
+ centroids_hemi_labels,
103
+ atlas_labels,
104
+ output_folder,
105
+ prepend,
106
+ point_intensities,
107
+ colormap=kwargs.get("colormap", "gray"),
108
+ )
109
+ if pixel_points is not None:
110
+ _save_whole_series_meshview(
111
+ pixel_points,
112
+ labeled_points,
113
+ centroids,
114
+ labeled_points_centroids,
115
+ points_hemi_labels,
116
+ centroids_hemi_labels,
117
+ atlas_labels,
118
+ output_folder,
119
+ prepend,
120
+ point_intensities,
121
+ colormap=kwargs.get("colormap", "gray"),
122
+ )
101
123
 
102
124
  # Save settings to JSON file for reference
103
125
  settings_dict = {
104
126
  "segmentation_folder": segmentation_folder,
127
+ "image_folder": image_folder,
105
128
  "alignment_json": alignment_json,
106
129
  "colour": colour,
130
+ "intensity_channel": intensity_channel,
107
131
  "custom_region_path": custom_region_path,
108
132
  }
109
- pixel_points,
110
- centroids,
111
- label_df,
112
- per_section_df,
113
- labeled_points,
114
- labeled_points_centroids,
115
- points_hemi_labels,
116
- centroids_hemi_labels,
117
- points_len,
118
- centroids_len,
119
- segmentation_filenames,
120
- atlas_labels,
121
- output_folder,
122
- segmentation_folder = (None,)
123
- alignment_json = (None,)
124
- colour = (None,)
125
- atlas_name = (None,)
126
- custom_region_path = (None,)
127
- atlas_path = (None,)
128
- label_path = (None,)
129
- settings_file = (None,)
130
- prepend = (None,)
133
+
131
134
  # Add atlas information to settings
132
135
  if atlas_name:
133
136
  settings_dict["atlas_name"] = atlas_name
@@ -137,8 +140,7 @@ def save_analysis_output(
137
140
  settings_dict["label_path"] = label_path
138
141
  if settings_file:
139
142
  settings_dict["settings_file"] = settings_file
140
- if custom_region_path:
141
- settings_dict["custom_region_path"] = custom_region_path
143
+
142
144
  # Write settings to file
143
145
  settings_file_path = os.path.join(output_folder, "pynutil_settings.json")
144
146
  with open(settings_file_path, "w") as f:
@@ -159,10 +161,18 @@ def _save_per_section_reports(
159
161
  atlas_labels,
160
162
  output_folder,
161
163
  prepend,
164
+ point_intensities=None,
165
+ colormap="gray",
162
166
  ):
163
167
  prev_pl = 0
164
168
  prev_cl = 0
165
169
 
170
+ # Handle None for points_len and centroids_len (e.g. in intensity mode)
171
+ if points_len is None:
172
+ points_len = [0] * len(segmentation_filenames)
173
+ if centroids_len is None:
174
+ centroids_len = [0] * len(segmentation_filenames)
175
+
166
176
  for pl, cl, fn, df in zip(
167
177
  points_len,
168
178
  centroids_len,
@@ -176,22 +186,27 @@ def _save_per_section_reports(
176
186
  na_rep="",
177
187
  index=False,
178
188
  )
179
- _save_per_section_meshview(
180
- split_fn,
181
- pl,
182
- cl,
183
- prev_pl,
184
- prev_cl,
185
- pixel_points,
186
- centroids,
187
- labeled_points,
188
- labeled_points_centroids,
189
- points_hemi_labels,
190
- centroids_hemi_labels,
191
- atlas_labels,
192
- output_folder,
193
- prepend,
194
- )
189
+ if pixel_points is not None or centroids is not None:
190
+ _save_per_section_meshview(
191
+ split_fn,
192
+ pl,
193
+ cl,
194
+ prev_pl,
195
+ prev_cl,
196
+ pixel_points,
197
+ centroids,
198
+ labeled_points,
199
+ labeled_points_centroids,
200
+ points_hemi_labels,
201
+ centroids_hemi_labels,
202
+ atlas_labels,
203
+ output_folder,
204
+ prepend,
205
+ point_intensities[prev_pl : pl + prev_pl]
206
+ if point_intensities is not None
207
+ else None,
208
+ colormap=colormap,
209
+ )
195
210
  prev_cl += cl
196
211
  prev_pl += pl
197
212
 
@@ -211,21 +226,29 @@ def _save_per_section_meshview(
211
226
  atlas_labels,
212
227
  output_folder,
213
228
  prepend,
229
+ point_intensities=None,
230
+ colormap="gray",
214
231
  ):
215
232
  write_hemi_points_to_meshview(
216
- pixel_points[prev_pl : pl + prev_pl],
217
- labeled_points[prev_pl : pl + prev_pl],
218
- points_hemi_labels[prev_pl : pl + prev_pl],
233
+ pixel_points[prev_pl : pl + prev_pl] if pixel_points is not None else None,
234
+ labeled_points[prev_pl : pl + prev_pl] if labeled_points is not None else None,
235
+ points_hemi_labels[prev_pl : pl + prev_pl]
236
+ if points_hemi_labels is not None
237
+ else None,
219
238
  f"{output_folder}/per_section_meshview/{prepend}{split_fn}_pixels.json",
220
239
  atlas_labels,
240
+ point_intensities,
241
+ colormap=colormap,
221
242
  )
222
- write_hemi_points_to_meshview(
223
- centroids[prev_cl : cl + prev_cl],
224
- labeled_points_centroids[prev_cl : cl + prev_cl],
225
- centroids_hemi_labels[prev_cl : cl + prev_cl],
226
- f"{output_folder}/per_section_meshview/{prepend}{split_fn}_centroids.json",
227
- atlas_labels,
228
- )
243
+ if centroids is not None:
244
+ write_hemi_points_to_meshview(
245
+ centroids[prev_cl : cl + prev_cl],
246
+ labeled_points_centroids[prev_cl : cl + prev_cl],
247
+ centroids_hemi_labels[prev_cl : cl + prev_cl],
248
+ f"{output_folder}/per_section_meshview/{prepend}{split_fn}_centroids.json",
249
+ atlas_labels,
250
+ colormap=colormap,
251
+ )
229
252
 
230
253
 
231
254
  def _save_whole_series_meshview(
@@ -238,6 +261,8 @@ def _save_whole_series_meshview(
238
261
  atlas_labels,
239
262
  output_folder,
240
263
  prepend,
264
+ point_intensities=None,
265
+ colormap="gray",
241
266
  ):
242
267
  write_hemi_points_to_meshview(
243
268
  pixel_points,
@@ -245,11 +270,15 @@ def _save_whole_series_meshview(
245
270
  points_hemi_labels,
246
271
  f"{output_folder}/whole_series_meshview/{prepend}pixels_meshview.json",
247
272
  atlas_labels,
273
+ point_intensities,
274
+ colormap=colormap,
248
275
  )
249
- write_hemi_points_to_meshview(
250
- centroids,
251
- labeled_points_centroids,
252
- centroids_hemi_labels,
253
- f"{output_folder}/whole_series_meshview/{prepend}objects_meshview.json",
254
- atlas_labels,
255
- )
276
+ if centroids is not None:
277
+ write_hemi_points_to_meshview(
278
+ centroids,
279
+ labeled_points_centroids,
280
+ centroids_hemi_labels,
281
+ f"{output_folder}/whole_series_meshview/{prepend}objects_meshview.json",
282
+ atlas_labels,
283
+ colormap=colormap,
284
+ )
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+
8
+
9
+ def write_nifti(
10
+ volume: np.ndarray,
11
+ voxel_size_um: float,
12
+ output_path: str,
13
+ origin_offsets_um: Optional[np.ndarray] = None,
14
+ ) -> None:
15
+ """Write a NIfTI volume with a microns-based affine.
16
+
17
+ The header is written with both qform and sform set and units set to microns.
18
+
19
+ Args:
20
+ volume: 3D volume array. Saved as uint8.
21
+ voxel_size_um: Isotropic voxel size in microns.
22
+ output_path: Output path without extension; ".nii.gz" is appended.
23
+ origin_offsets_um: Optional XYZ translation offsets in microns.
24
+ """
25
+
26
+ try:
27
+ import nibabel as nib # type: ignore
28
+ except Exception as exc: # pragma: no cover
29
+ raise ImportError("nibabel is required for write_nifti") from exc
30
+
31
+ if origin_offsets_um is None:
32
+ origin_offsets_um = np.array([0, 0, 0], dtype=np.float32)
33
+
34
+ dims = np.array(volume.shape, dtype=np.float32)
35
+ affine = np.eye(4, dtype=np.float32)
36
+ affine[:3, :3] *= float(voxel_size_um)
37
+ affine[:3, 3] = -0.5 * dims * float(voxel_size_um) + np.asarray(
38
+ origin_offsets_um, dtype=np.float32
39
+ )
40
+
41
+ img = nib.Nifti1Image(np.asarray(volume, dtype=np.uint8), affine)
42
+ img.set_qform(affine, code=1)
43
+ img.set_sform(affine, code=1)
44
+ img.header["xyzt_units"] = 3
45
+
46
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
47
+ nib.save(img, output_path + ".nii.gz")