ndslice 0.7.0__tar.gz → 0.9.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.
- {ndslice-0.7.0 → ndslice-0.9.0}/PKG-INFO +17 -1
- {ndslice-0.7.0 → ndslice-0.9.0}/README.md +15 -1
- ndslice-0.9.0/ndslice/__init__.py +15 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice/__main__.py +28 -4
- ndslice-0.9.0/ndslice/config.py +205 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice/file_interpreters.py +98 -3
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice/imageview2d.py +86 -13
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice/ndslice.py +1433 -258
- ndslice-0.9.0/ndslice/range_slider.py +170 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice/selectors.py +165 -4
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice/video_export.py +299 -92
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice.egg-info/PKG-INFO +17 -1
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice.egg-info/SOURCES.txt +2 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice.egg-info/requires.txt +4 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/pyproject.toml +4 -2
- ndslice-0.7.0/ndslice/__init__.py +0 -9
- {ndslice-0.7.0 → ndslice-0.9.0}/LICENSE +0 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice.egg-info/dependency_links.txt +0 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice.egg-info/entry_points.txt +0 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/ndslice.egg-info/top_level.txt +0 -0
- {ndslice-0.7.0 → ndslice-0.9.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndslice
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Interactive N-dimensional numpy array viewer with FFT support
|
|
5
5
|
Author-email: Henric Rydén <henric.ryden@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,6 +26,8 @@ License-File: LICENSE
|
|
|
26
26
|
Requires-Dist: numpy>=1.20.0
|
|
27
27
|
Requires-Dist: pyqtgraph>=0.14.0
|
|
28
28
|
Requires-Dist: PyQt5>=5.15.0
|
|
29
|
+
Requires-Dist: platformdirs>=3.0.0
|
|
30
|
+
Requires-Dist: tomli>=1.1.0; python_version < "3.11"
|
|
29
31
|
Requires-Dist: h5py>=3.0.0
|
|
30
32
|
Requires-Dist: scipy>=1.7.0
|
|
31
33
|
Requires-Dist: pydicom>=2.4.0
|
|
@@ -67,6 +69,10 @@ pha = np.pi/4 * (X + Y + Z)
|
|
|
67
69
|
complex_data = mag * np.exp(1j * pha)
|
|
68
70
|
|
|
69
71
|
ndslice(complex_data, title='3D Complex Gaussian')
|
|
72
|
+
# Optional dimension labels:
|
|
73
|
+
ndslice(data, dim_labels=["X", "Y", "Z"])
|
|
74
|
+
# Optional mask overlay:
|
|
75
|
+
ndslice(data, mask=mask_data)
|
|
70
76
|
```
|
|
71
77
|
|
|
72
78
|

|
|
@@ -115,6 +121,9 @@ Click arrow icons (⬇️/⬆️ and ⬅️/➡️) next to dimension labels to
|
|
|
115
121
|
Default orientation is image-style (origin lower-left).
|
|
116
122
|
Flip the primary axis for matrix-style (origin upper-left).
|
|
117
123
|
|
|
124
|
+
**Mask overlay**
|
|
125
|
+
Pass `mask=` to overlay a label volume on the main image. Mask values must be integer labels from `0` to `32`; label `0` is transparent, opacity is adjustable, and the small color swatch cycles between solid RGBCMY colors and a Glasbey rainbow palette for the labels present in the mask.
|
|
126
|
+
|
|
118
127
|
**Non-blocking windows**
|
|
119
128
|
|
|
120
129
|
By default, windows open in separate processes, allowing multiple simultaneous views:
|
|
@@ -129,11 +138,18 @@ ndslice(data1, block=True) # Script pauses here
|
|
|
129
138
|
ndslice(data2) # Shown after first closes
|
|
130
139
|
```
|
|
131
140
|
|
|
141
|
+
If Qt is already initialized in the current process, `ndslice(..., block=False)`
|
|
142
|
+
cannot safely fork a child process. In that case:
|
|
143
|
+
|
|
144
|
+
- In IPython/Jupyter with `%gui qt`, ndslice opens inline in the current process (still non-blocking).
|
|
145
|
+
- Without an active Qt event loop, ndslice falls back to blocking mode and emits a warning.
|
|
132
146
|
|
|
133
147
|
### Command Line
|
|
148
|
+
|
|
134
149
|
```bash
|
|
135
150
|
ndslice data.npy # Numpy file
|
|
136
151
|
ndslice image.nii.gz
|
|
152
|
+
ndslice image.nii.gz --mask mask.nii.gz
|
|
137
153
|
ndslice image.dcm
|
|
138
154
|
ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
|
|
139
155
|
ndslice --help # Show all options
|
|
@@ -25,6 +25,10 @@ pha = np.pi/4 * (X + Y + Z)
|
|
|
25
25
|
complex_data = mag * np.exp(1j * pha)
|
|
26
26
|
|
|
27
27
|
ndslice(complex_data, title='3D Complex Gaussian')
|
|
28
|
+
# Optional dimension labels:
|
|
29
|
+
ndslice(data, dim_labels=["X", "Y", "Z"])
|
|
30
|
+
# Optional mask overlay:
|
|
31
|
+
ndslice(data, mask=mask_data)
|
|
28
32
|
```
|
|
29
33
|
|
|
30
34
|

|
|
@@ -73,6 +77,9 @@ Click arrow icons (⬇️/⬆️ and ⬅️/➡️) next to dimension labels to
|
|
|
73
77
|
Default orientation is image-style (origin lower-left).
|
|
74
78
|
Flip the primary axis for matrix-style (origin upper-left).
|
|
75
79
|
|
|
80
|
+
**Mask overlay**
|
|
81
|
+
Pass `mask=` to overlay a label volume on the main image. Mask values must be integer labels from `0` to `32`; label `0` is transparent, opacity is adjustable, and the small color swatch cycles between solid RGBCMY colors and a Glasbey rainbow palette for the labels present in the mask.
|
|
82
|
+
|
|
76
83
|
**Non-blocking windows**
|
|
77
84
|
|
|
78
85
|
By default, windows open in separate processes, allowing multiple simultaneous views:
|
|
@@ -87,11 +94,18 @@ ndslice(data1, block=True) # Script pauses here
|
|
|
87
94
|
ndslice(data2) # Shown after first closes
|
|
88
95
|
```
|
|
89
96
|
|
|
97
|
+
If Qt is already initialized in the current process, `ndslice(..., block=False)`
|
|
98
|
+
cannot safely fork a child process. In that case:
|
|
99
|
+
|
|
100
|
+
- In IPython/Jupyter with `%gui qt`, ndslice opens inline in the current process (still non-blocking).
|
|
101
|
+
- Without an active Qt event loop, ndslice falls back to blocking mode and emits a warning.
|
|
90
102
|
|
|
91
103
|
### Command Line
|
|
104
|
+
|
|
92
105
|
```bash
|
|
93
106
|
ndslice data.npy # Numpy file
|
|
94
107
|
ndslice image.nii.gz
|
|
108
|
+
ndslice image.nii.gz --mask mask.nii.gz
|
|
95
109
|
ndslice image.dcm
|
|
96
110
|
ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
|
|
97
111
|
ndslice --help # Show all options
|
|
@@ -183,4 +197,4 @@ Henric Rydén
|
|
|
183
197
|
|
|
184
198
|
Karolinska University Hospital
|
|
185
199
|
|
|
186
|
-
Stockholm, Sweden
|
|
200
|
+
Stockholm, Sweden
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ndslice - Interactive N-dimensional array viewer with FFT support
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from .ndslice import ndslice, NDSliceWindow, Domain
|
|
8
|
+
from .imageview2d import ImageView2D
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("ndslice")
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
__version__ = "0+unknown"
|
|
14
|
+
|
|
15
|
+
__all__ = ["ndslice", "NDSliceWindow", "ImageView2D", "Domain", "__version__"]
|
|
@@ -6,7 +6,7 @@ import argparse
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from .ndslice import ndslice
|
|
9
|
-
from .selectors import
|
|
9
|
+
from .selectors import h5_selector_for_path, NpzDatasetSelector, MatDatasetSelector
|
|
10
10
|
from .file_interpreters import load_path
|
|
11
11
|
|
|
12
12
|
|
|
@@ -31,8 +31,19 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
31
31
|
)
|
|
32
32
|
parser.add_argument('files', type=str, nargs='+',
|
|
33
33
|
help='Path(s) to data files or DICOM directories')
|
|
34
|
+
parser.add_argument('--mask', type=str, default=None,
|
|
35
|
+
help='Optional mask volume to overlay on a single main file')
|
|
34
36
|
|
|
35
37
|
args = parser.parse_args()
|
|
38
|
+
|
|
39
|
+
mask_path = None
|
|
40
|
+
if args.mask is not None:
|
|
41
|
+
if len(args.files) != 1:
|
|
42
|
+
parser.error('--mask requires exactly one main file')
|
|
43
|
+
|
|
44
|
+
mask_path = Path(args.mask)
|
|
45
|
+
if not mask_path.exists():
|
|
46
|
+
parser.error(f'Mask file not found: {mask_path}')
|
|
36
47
|
|
|
37
48
|
for file_arg in args.files:
|
|
38
49
|
filepath = Path(file_arg)
|
|
@@ -50,16 +61,29 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
50
61
|
detected_format = loaded.metadata.get('detected_format')
|
|
51
62
|
if detected_format:
|
|
52
63
|
title = f"{title} [{detected_format}]"
|
|
53
|
-
|
|
64
|
+
mask_data = load_path(mask_path).data if mask_path is not None else None
|
|
65
|
+
ndslice(data=loaded.data, title=title, block=False, filepath=filepath,
|
|
66
|
+
dim_labels=loaded.metadata.get('dim_labels'),
|
|
67
|
+
voxel_spacing=loaded.metadata.get('voxel_spacing'),
|
|
68
|
+
mask=mask_data)
|
|
54
69
|
continue
|
|
55
70
|
|
|
56
71
|
# Multi-dataset formats - use selectors
|
|
57
72
|
selector = None
|
|
58
73
|
if suffix in ['.h5', '.hdf5']:
|
|
59
|
-
|
|
74
|
+
if mask_path is not None:
|
|
75
|
+
print("--mask is not supported for multi-dataset main files yet")
|
|
76
|
+
continue
|
|
77
|
+
selector = h5_selector_for_path(filepath)
|
|
60
78
|
elif suffix == '.npz':
|
|
79
|
+
if mask_path is not None:
|
|
80
|
+
print("--mask is not supported for multi-dataset main files yet")
|
|
81
|
+
continue
|
|
61
82
|
selector = NpzDatasetSelector(filepath)
|
|
62
83
|
elif suffix == '.mat':
|
|
84
|
+
if mask_path is not None:
|
|
85
|
+
print("--mask is not supported for multi-dataset main files yet")
|
|
86
|
+
continue
|
|
63
87
|
selector = MatDatasetSelector(filepath)
|
|
64
88
|
else:
|
|
65
89
|
print(f"Unsupported file type: {suffix}. Supported types: directories with DICOM .dcm files, .h5, .hdf5, .npy, .npz, .mat, .REC, .cfl, .dcm, .nii, .nii.gz, .txt")
|
|
@@ -77,4 +101,4 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
77
101
|
|
|
78
102
|
|
|
79
103
|
if __name__ == '__main__':
|
|
80
|
-
main()
|
|
104
|
+
main()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Persistent user configuration for ndslice."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from platformdirs import user_config_dir
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import tomllib
|
|
9
|
+
except ImportError: # pragma: no cover - exercised only on Python < 3.11
|
|
10
|
+
import tomli as tomllib
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
INITIAL_INDEX_FIRST = "first"
|
|
14
|
+
INITIAL_INDEX_CENTER = "center"
|
|
15
|
+
INITIAL_INDEX_LAST = "last"
|
|
16
|
+
VALID_INITIAL_INDEX = {INITIAL_INDEX_FIRST, INITIAL_INDEX_CENTER, INITIAL_INDEX_LAST}
|
|
17
|
+
DEFAULT_SLICE_FIRST = "first"
|
|
18
|
+
DEFAULT_SLICE_LAST = "last"
|
|
19
|
+
VALID_DEFAULT_SLICE = {DEFAULT_SLICE_FIRST, DEFAULT_SLICE_LAST}
|
|
20
|
+
|
|
21
|
+
DEFAULT_COLORMAP = "gray"
|
|
22
|
+
ANGLE_COLORMAP_SAME = "same"
|
|
23
|
+
DEFAULT_CHANNEL = "auto"
|
|
24
|
+
SUPPORTED_CHANNELS = (
|
|
25
|
+
"auto",
|
|
26
|
+
"real",
|
|
27
|
+
"abs",
|
|
28
|
+
"imag",
|
|
29
|
+
"angle",
|
|
30
|
+
)
|
|
31
|
+
DEFAULT_ORIGIN = "lower_left"
|
|
32
|
+
SUPPORTED_ORIGINS = (
|
|
33
|
+
"upper_left",
|
|
34
|
+
"lower_left",
|
|
35
|
+
"upper_right",
|
|
36
|
+
"lower_right",
|
|
37
|
+
)
|
|
38
|
+
DEFAULT_DISPLAY_MODE = "square_pixels"
|
|
39
|
+
SUPPORTED_DISPLAY_MODES = (
|
|
40
|
+
"square_pixels",
|
|
41
|
+
"square_fov",
|
|
42
|
+
"auto",
|
|
43
|
+
)
|
|
44
|
+
DISPLAY_MODE_ALIASES = {
|
|
45
|
+
"fit": "auto",
|
|
46
|
+
}
|
|
47
|
+
DEFAULT_MASK_OPACITY = 0.8
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ViewerConfig:
|
|
52
|
+
initial_index: str = INITIAL_INDEX_FIRST
|
|
53
|
+
default_slice: str = DEFAULT_SLICE_FIRST
|
|
54
|
+
initial_origin: str = DEFAULT_ORIGIN
|
|
55
|
+
default_channel: str = DEFAULT_CHANNEL
|
|
56
|
+
default_colormap: str = DEFAULT_COLORMAP
|
|
57
|
+
angle_colormap: str = ANGLE_COLORMAP_SAME
|
|
58
|
+
default_display_mode: str = DEFAULT_DISPLAY_MODE
|
|
59
|
+
default_mask_opacity: float = DEFAULT_MASK_OPACITY
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_config_path():
|
|
63
|
+
return Path(user_config_dir("ndslice", appauthor=False)) / "config.toml"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(path=None, colormap_names=None):
|
|
67
|
+
"""Load user config, falling back to defaults for any invalid value."""
|
|
68
|
+
config_path = Path(path) if path is not None else get_config_path()
|
|
69
|
+
valid_colormaps = tuple(colormap_names) if colormap_names is not None else None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
73
|
+
except Exception:
|
|
74
|
+
return ViewerConfig()
|
|
75
|
+
|
|
76
|
+
startup = raw.get("startup", {})
|
|
77
|
+
if not isinstance(startup, dict):
|
|
78
|
+
startup = {}
|
|
79
|
+
|
|
80
|
+
display = raw.get("display", {})
|
|
81
|
+
if not isinstance(display, dict):
|
|
82
|
+
display = {}
|
|
83
|
+
|
|
84
|
+
initial_index = startup.get("initial_index", INITIAL_INDEX_FIRST)
|
|
85
|
+
if initial_index not in VALID_INITIAL_INDEX:
|
|
86
|
+
initial_index = INITIAL_INDEX_FIRST
|
|
87
|
+
|
|
88
|
+
default_slice = startup.get("default_slice", DEFAULT_SLICE_FIRST)
|
|
89
|
+
if default_slice not in VALID_DEFAULT_SLICE:
|
|
90
|
+
default_slice = DEFAULT_SLICE_FIRST
|
|
91
|
+
|
|
92
|
+
initial_origin = startup.get("initial_origin", DEFAULT_ORIGIN)
|
|
93
|
+
if initial_origin not in SUPPORTED_ORIGINS:
|
|
94
|
+
initial_origin = DEFAULT_ORIGIN
|
|
95
|
+
|
|
96
|
+
default_channel = display.get("default_channel", DEFAULT_CHANNEL)
|
|
97
|
+
if default_channel not in SUPPORTED_CHANNELS:
|
|
98
|
+
default_channel = DEFAULT_CHANNEL
|
|
99
|
+
|
|
100
|
+
default_colormap = display.get("default_colormap", DEFAULT_COLORMAP)
|
|
101
|
+
if valid_colormaps is not None and default_colormap not in valid_colormaps:
|
|
102
|
+
default_colormap = DEFAULT_COLORMAP
|
|
103
|
+
|
|
104
|
+
angle_colormap = display.get("angle_colormap", ANGLE_COLORMAP_SAME)
|
|
105
|
+
valid_angle_colormaps = (
|
|
106
|
+
(ANGLE_COLORMAP_SAME,) + valid_colormaps
|
|
107
|
+
if valid_colormaps is not None
|
|
108
|
+
else None
|
|
109
|
+
)
|
|
110
|
+
if valid_angle_colormaps is not None and angle_colormap not in valid_angle_colormaps:
|
|
111
|
+
angle_colormap = ANGLE_COLORMAP_SAME
|
|
112
|
+
|
|
113
|
+
default_display_mode = display.get("default_display_mode", DEFAULT_DISPLAY_MODE)
|
|
114
|
+
default_display_mode = DISPLAY_MODE_ALIASES.get(default_display_mode, default_display_mode)
|
|
115
|
+
if default_display_mode not in SUPPORTED_DISPLAY_MODES:
|
|
116
|
+
default_display_mode = DEFAULT_DISPLAY_MODE
|
|
117
|
+
|
|
118
|
+
default_mask_opacity = _clean_mask_opacity(
|
|
119
|
+
display.get("default_mask_opacity", DEFAULT_MASK_OPACITY)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return ViewerConfig(
|
|
123
|
+
initial_index=initial_index,
|
|
124
|
+
default_slice=default_slice,
|
|
125
|
+
initial_origin=initial_origin,
|
|
126
|
+
default_channel=default_channel,
|
|
127
|
+
default_colormap=default_colormap,
|
|
128
|
+
angle_colormap=angle_colormap,
|
|
129
|
+
default_display_mode=default_display_mode,
|
|
130
|
+
default_mask_opacity=default_mask_opacity,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def save_config(config, path=None, colormap_names=None):
|
|
135
|
+
"""Write the complete viewer config to TOML."""
|
|
136
|
+
config_path = Path(path) if path is not None else get_config_path()
|
|
137
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
valid_colormaps = tuple(colormap_names) if colormap_names is not None else None
|
|
139
|
+
|
|
140
|
+
initial_index = config.initial_index
|
|
141
|
+
if initial_index not in VALID_INITIAL_INDEX:
|
|
142
|
+
initial_index = INITIAL_INDEX_FIRST
|
|
143
|
+
|
|
144
|
+
default_slice = config.default_slice
|
|
145
|
+
if default_slice not in VALID_DEFAULT_SLICE:
|
|
146
|
+
default_slice = DEFAULT_SLICE_FIRST
|
|
147
|
+
|
|
148
|
+
initial_origin = config.initial_origin
|
|
149
|
+
if initial_origin not in SUPPORTED_ORIGINS:
|
|
150
|
+
initial_origin = DEFAULT_ORIGIN
|
|
151
|
+
|
|
152
|
+
default_channel = config.default_channel
|
|
153
|
+
if default_channel not in SUPPORTED_CHANNELS:
|
|
154
|
+
default_channel = DEFAULT_CHANNEL
|
|
155
|
+
|
|
156
|
+
default_colormap = config.default_colormap
|
|
157
|
+
if valid_colormaps is not None and default_colormap not in valid_colormaps:
|
|
158
|
+
default_colormap = DEFAULT_COLORMAP
|
|
159
|
+
|
|
160
|
+
angle_colormap = config.angle_colormap
|
|
161
|
+
valid_angle_colormaps = (
|
|
162
|
+
(ANGLE_COLORMAP_SAME,) + valid_colormaps
|
|
163
|
+
if valid_colormaps is not None
|
|
164
|
+
else None
|
|
165
|
+
)
|
|
166
|
+
if valid_angle_colormaps is not None and angle_colormap not in valid_angle_colormaps:
|
|
167
|
+
angle_colormap = ANGLE_COLORMAP_SAME
|
|
168
|
+
|
|
169
|
+
default_display_mode = config.default_display_mode
|
|
170
|
+
default_display_mode = DISPLAY_MODE_ALIASES.get(default_display_mode, default_display_mode)
|
|
171
|
+
if default_display_mode not in SUPPORTED_DISPLAY_MODES:
|
|
172
|
+
default_display_mode = DEFAULT_DISPLAY_MODE
|
|
173
|
+
|
|
174
|
+
default_mask_opacity = _clean_mask_opacity(config.default_mask_opacity)
|
|
175
|
+
|
|
176
|
+
text = (
|
|
177
|
+
"[startup]\n"
|
|
178
|
+
f'initial_index = "{initial_index}"\n'
|
|
179
|
+
f'default_slice = "{default_slice}"\n'
|
|
180
|
+
f'initial_origin = "{initial_origin}"\n'
|
|
181
|
+
"\n"
|
|
182
|
+
"[display]\n"
|
|
183
|
+
f'default_channel = "{default_channel}"\n'
|
|
184
|
+
f'default_colormap = "{default_colormap}"\n'
|
|
185
|
+
f'angle_colormap = "{angle_colormap}"\n'
|
|
186
|
+
f'default_display_mode = "{default_display_mode}"\n'
|
|
187
|
+
f"default_mask_opacity = {default_mask_opacity:.6g}\n"
|
|
188
|
+
)
|
|
189
|
+
config_path.write_text(text, encoding="utf-8")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def update_config(path=None, colormap_names=None, **changes):
|
|
193
|
+
config = replace(load_config(path, colormap_names=colormap_names), **changes)
|
|
194
|
+
save_config(config, path, colormap_names=colormap_names)
|
|
195
|
+
return config
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _clean_mask_opacity(value):
|
|
199
|
+
try:
|
|
200
|
+
opacity = float(value)
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
return DEFAULT_MASK_OPACITY
|
|
203
|
+
if not 0.0 <= opacity <= 1.0:
|
|
204
|
+
return DEFAULT_MASK_OPACITY
|
|
205
|
+
return opacity
|
|
@@ -23,6 +23,75 @@ def remove_trailing_singletons(data):
|
|
|
23
23
|
return data.reshape(shape[:dim+1])
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
def _nifti_dim_labels(ndim):
|
|
27
|
+
labels = [''] * ndim
|
|
28
|
+
for dim, label in enumerate(('X', 'Y', 'Z', 'T')):
|
|
29
|
+
if dim < ndim:
|
|
30
|
+
labels[dim] = label
|
|
31
|
+
return labels
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _valid_spacing_value(value):
|
|
35
|
+
try:
|
|
36
|
+
value = float(value)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
return None
|
|
39
|
+
return value if np.isfinite(value) and value > 0 else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _nifti_voxel_spacing(zooms, ndim):
|
|
43
|
+
spacing = [None] * ndim
|
|
44
|
+
for dim in range(min(ndim, 3, len(zooms))):
|
|
45
|
+
spacing[dim] = _valid_spacing_value(zooms[dim])
|
|
46
|
+
return spacing
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dicom_pixel_voxel_spacing(pixel_spacing, ndim):
|
|
50
|
+
spacing = [None] * ndim
|
|
51
|
+
if pixel_spacing is None or ndim < 2:
|
|
52
|
+
return spacing
|
|
53
|
+
|
|
54
|
+
values = list(pixel_spacing)
|
|
55
|
+
if len(values) < 2:
|
|
56
|
+
return spacing
|
|
57
|
+
|
|
58
|
+
spacing[-2] = _valid_spacing_value(values[0])
|
|
59
|
+
spacing[-1] = _valid_spacing_value(values[1])
|
|
60
|
+
return spacing
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _metadata_voxel_spacing(voxel_spacing, ndim):
|
|
64
|
+
spacing = [None] * ndim
|
|
65
|
+
if voxel_spacing is None:
|
|
66
|
+
return spacing
|
|
67
|
+
|
|
68
|
+
for dim, value in enumerate(voxel_spacing):
|
|
69
|
+
if dim >= ndim:
|
|
70
|
+
break
|
|
71
|
+
spacing[dim] = _valid_spacing_value(value)
|
|
72
|
+
return spacing
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _append_dim_label(dim_labels, label):
|
|
76
|
+
labels = list(dim_labels or [])
|
|
77
|
+
labels.append(label or '')
|
|
78
|
+
return labels
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _append_voxel_spacing(voxel_spacing, spacing):
|
|
82
|
+
values = list(voxel_spacing or [])
|
|
83
|
+
values.append(spacing)
|
|
84
|
+
return values
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reverse_spatial_metadata(values, ndim):
|
|
88
|
+
values = list(values or [])
|
|
89
|
+
values = (values + [None] * ndim)[:ndim]
|
|
90
|
+
spatial_ndim = min(3, ndim)
|
|
91
|
+
values[:spatial_ndim] = reversed(values[:spatial_ndim])
|
|
92
|
+
return values
|
|
93
|
+
|
|
94
|
+
|
|
26
95
|
class PhilipsRECLoader:
|
|
27
96
|
def __init__(self, rec_path):
|
|
28
97
|
self.rec_path = Path(rec_path)
|
|
@@ -273,14 +342,16 @@ class DicomLoader:
|
|
|
273
342
|
)
|
|
274
343
|
|
|
275
344
|
data = remove_trailing_singletons(np.asarray(dcm.pixel_array))
|
|
345
|
+
pixel_spacing = getattr(dcm, 'PixelSpacing', None)
|
|
276
346
|
self.metadata.update({
|
|
277
347
|
'shape': tuple(data.shape),
|
|
278
348
|
'dtype': str(data.dtype),
|
|
349
|
+
'voxel_spacing': _dicom_pixel_voxel_spacing(pixel_spacing, data.ndim),
|
|
279
350
|
'dicom_metadata': {
|
|
280
351
|
'patient_name': str(getattr(dcm, 'PatientName', '')),
|
|
281
352
|
'series_description': str(getattr(dcm, 'SeriesDescription', '')),
|
|
282
353
|
'modality': str(getattr(dcm, 'Modality', '')),
|
|
283
|
-
'pixel_spacing': list(
|
|
354
|
+
'pixel_spacing': list(pixel_spacing) if pixel_spacing is not None else None,
|
|
284
355
|
'image_position_patient': list(getattr(dcm, 'ImagePositionPatient', [])) if hasattr(dcm, 'ImagePositionPatient') else None,
|
|
285
356
|
},
|
|
286
357
|
})
|
|
@@ -399,6 +470,8 @@ class DicomDirectoryLoader:
|
|
|
399
470
|
nifti_paths = []
|
|
400
471
|
json_paths = []
|
|
401
472
|
sidecar_metadata = []
|
|
473
|
+
nifti_dim_labels = []
|
|
474
|
+
nifti_voxel_spacings = []
|
|
402
475
|
|
|
403
476
|
for nifti_path, json_path in nifti_outputs:
|
|
404
477
|
nifti_loader = NiftiLoader(nifti_path)
|
|
@@ -406,9 +479,18 @@ class DicomDirectoryLoader:
|
|
|
406
479
|
nifti_paths.append(str(nifti_path))
|
|
407
480
|
json_paths.append(str(json_path) if json_path else None)
|
|
408
481
|
sidecar_metadata.append(_read_json_sidecar(json_path))
|
|
482
|
+
nifti_dim_labels.append(nifti_loader.metadata.get('dim_labels', []))
|
|
483
|
+
nifti_voxel_spacings.append(nifti_loader.metadata.get('voxel_spacing', []))
|
|
409
484
|
|
|
410
485
|
if len(arrays) == 1:
|
|
411
486
|
data = arrays[0]
|
|
487
|
+
base_dim_labels = list(nifti_dim_labels[0]) if nifti_dim_labels else _nifti_dim_labels(data.ndim)
|
|
488
|
+
dim_labels = _reverse_spatial_metadata(base_dim_labels, data.ndim)
|
|
489
|
+
base_voxel_spacing = _metadata_voxel_spacing(
|
|
490
|
+
nifti_voxel_spacings[0] if nifti_voxel_spacings else None,
|
|
491
|
+
data.ndim,
|
|
492
|
+
)
|
|
493
|
+
voxel_spacing = base_voxel_spacing
|
|
412
494
|
stacking_label = None
|
|
413
495
|
stacking_key = None
|
|
414
496
|
stacking_values = None
|
|
@@ -430,10 +512,20 @@ class DicomDirectoryLoader:
|
|
|
430
512
|
if any(description is not None for description in series_descriptions):
|
|
431
513
|
print(f" SeriesDescription: {series_descriptions}")
|
|
432
514
|
data = np.stack(arrays, axis=-1)
|
|
515
|
+
base_dim_labels = list(nifti_dim_labels[0]) if nifti_dim_labels else _nifti_dim_labels(arrays[0].ndim)
|
|
516
|
+
base_dim_labels = _reverse_spatial_metadata(base_dim_labels, arrays[0].ndim)
|
|
517
|
+
dim_labels = _append_dim_label(base_dim_labels, stacking_label)
|
|
518
|
+
base_voxel_spacing = _metadata_voxel_spacing(
|
|
519
|
+
nifti_voxel_spacings[0] if nifti_voxel_spacings else None,
|
|
520
|
+
arrays[0].ndim,
|
|
521
|
+
)
|
|
522
|
+
voxel_spacing = _append_voxel_spacing(base_voxel_spacing, None)
|
|
433
523
|
|
|
434
524
|
self.metadata.update({
|
|
435
525
|
'shape': tuple(data.shape),
|
|
436
526
|
'dtype': str(data.dtype),
|
|
527
|
+
'dim_labels': dim_labels,
|
|
528
|
+
'voxel_spacing': voxel_spacing,
|
|
437
529
|
'nifti_output_path': nifti_paths[0] if len(nifti_paths) == 1 else nifti_paths,
|
|
438
530
|
'sidecar_json_path': json_paths[0] if len(json_paths) == 1 else json_paths,
|
|
439
531
|
'stacked_dimension': stacking_label,
|
|
@@ -463,11 +555,14 @@ class NiftiLoader:
|
|
|
463
555
|
"Install it with: pip install nibabel"
|
|
464
556
|
)
|
|
465
557
|
|
|
466
|
-
|
|
558
|
+
image = nib.load(self.file_path)
|
|
559
|
+
data = image.get_fdata()
|
|
467
560
|
data = remove_trailing_singletons(data)
|
|
468
561
|
self.metadata.update({
|
|
469
562
|
'shape': tuple(data.shape),
|
|
470
563
|
'dtype': str(data.dtype),
|
|
564
|
+
'dim_labels': _nifti_dim_labels(data.ndim),
|
|
565
|
+
'voxel_spacing': _nifti_voxel_spacing(image.header.get_zooms(), data.ndim),
|
|
471
566
|
})
|
|
472
567
|
return data
|
|
473
568
|
|
|
@@ -601,4 +696,4 @@ def load_file(filepath):
|
|
|
601
696
|
NumPy array
|
|
602
697
|
|
|
603
698
|
"""
|
|
604
|
-
return load_path(filepath).data
|
|
699
|
+
return load_path(filepath).data
|