ndslice 0.6.0__tar.gz → 0.7.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.6.0 → ndslice-0.7.0}/PKG-INFO +34 -28
- {ndslice-0.6.0 → ndslice-0.7.0}/README.md +25 -5
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/__main__.py +12 -7
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/file_interpreters.py +264 -32
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/ndslice.py +338 -1
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/video_export.py +4 -4
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice.egg-info/PKG-INFO +34 -28
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice.egg-info/requires.txt +0 -19
- {ndslice-0.6.0 → ndslice-0.7.0}/pyproject.toml +9 -29
- {ndslice-0.6.0 → ndslice-0.7.0}/LICENSE +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/__init__.py +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/imageview2d.py +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice/selectors.py +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice.egg-info/SOURCES.txt +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice.egg-info/dependency_links.txt +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice.egg-info/entry_points.txt +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/ndslice.egg-info/top_level.txt +0 -0
- {ndslice-0.6.0 → ndslice-0.7.0}/setup.cfg +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndslice
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/henricryden/ndslice
|
|
8
8
|
Project-URL: Documentation, https://github.com/henricryden/ndslice#readme
|
|
9
9
|
Project-URL: Bug Tracker, https://github.com/henricryden/ndslice/issues
|
|
@@ -11,7 +11,6 @@ Keywords: visualization,numpy,fft,image-viewer,data-visualization
|
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Intended Audience :: Science/Research
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
14
|
Classifier: Programming Language :: Python :: 3
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.8
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.9
|
|
@@ -27,31 +26,18 @@ License-File: LICENSE
|
|
|
27
26
|
Requires-Dist: numpy>=1.20.0
|
|
28
27
|
Requires-Dist: pyqtgraph>=0.14.0
|
|
29
28
|
Requires-Dist: PyQt5>=5.15.0
|
|
29
|
+
Requires-Dist: h5py>=3.0.0
|
|
30
|
+
Requires-Dist: scipy>=1.7.0
|
|
31
|
+
Requires-Dist: pydicom>=2.4.0
|
|
32
|
+
Requires-Dist: nibabel>=4.0.0
|
|
33
|
+
Requires-Dist: imageio>=2.9.0
|
|
34
|
+
Requires-Dist: imageio-ffmpeg>=0.4.2
|
|
35
|
+
Requires-Dist: Pillow>=8.0.0
|
|
30
36
|
Provides-Extra: pyqt6
|
|
31
37
|
Requires-Dist: PyQt6>=6.4.0; extra == "pyqt6"
|
|
32
38
|
Provides-Extra: dev
|
|
33
39
|
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
34
40
|
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
35
|
-
Provides-Extra: hdf5
|
|
36
|
-
Requires-Dist: h5py>=3.0.0; extra == "hdf5"
|
|
37
|
-
Provides-Extra: matlab
|
|
38
|
-
Requires-Dist: scipy>=1.7.0; extra == "matlab"
|
|
39
|
-
Provides-Extra: dicom
|
|
40
|
-
Requires-Dist: pydicom>=2.4.0; extra == "dicom"
|
|
41
|
-
Provides-Extra: nifti
|
|
42
|
-
Requires-Dist: nibabel>=4.0.0; extra == "nifti"
|
|
43
|
-
Provides-Extra: video-export
|
|
44
|
-
Requires-Dist: imageio>=2.9.0; extra == "video-export"
|
|
45
|
-
Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "video-export"
|
|
46
|
-
Requires-Dist: Pillow>=8.0.0; extra == "video-export"
|
|
47
|
-
Provides-Extra: all
|
|
48
|
-
Requires-Dist: h5py>=3.0.0; extra == "all"
|
|
49
|
-
Requires-Dist: scipy>=1.7.0; extra == "all"
|
|
50
|
-
Requires-Dist: pydicom>=2.4.0; extra == "all"
|
|
51
|
-
Requires-Dist: nibabel>=4.0.0; extra == "all"
|
|
52
|
-
Requires-Dist: imageio>=2.9.0; extra == "all"
|
|
53
|
-
Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "all"
|
|
54
|
-
Requires-Dist: Pillow>=8.0.0; extra == "all"
|
|
55
41
|
Dynamic: license-file
|
|
56
42
|
|
|
57
43
|
[](https://pypi.org/project/ndslice/)
|
|
@@ -147,6 +133,9 @@ ndslice(data2) # Shown after first closes
|
|
|
147
133
|
### Command Line
|
|
148
134
|
```bash
|
|
149
135
|
ndslice data.npy # Numpy file
|
|
136
|
+
ndslice image.nii.gz
|
|
137
|
+
ndslice image.dcm
|
|
138
|
+
ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
|
|
150
139
|
ndslice --help # Show all options
|
|
151
140
|
```
|
|
152
141
|
|
|
@@ -160,10 +149,13 @@ ndslice has CLI support and can conveniently display:
|
|
|
160
149
|
| [BART](https://mrirecon.github.io/bart/) | `.cfl` + `.hdr` | — |
|
|
161
150
|
| Philips REC | `.REC` + `.xml` | — |
|
|
162
151
|
| [NIfTI](https://nifti.nimh.nih.gov/) | `.nii`, `.nii.gz` | nibabel |
|
|
163
|
-
| DICOM
|
|
152
|
+
| DICOM file | `.dcm` | pydicom |
|
|
153
|
+
| DICOM directory | directory containing `.dcm` files | pydicom, nibabel, `dcm2niix` on `PATH` |
|
|
164
154
|
|
|
165
155
|
HDF5 files can be compound complex dtype, or real/imag fields.
|
|
166
156
|
|
|
157
|
+
For DICOM directories, ndslice does not infer series dimensions itself. It runs `dcm2niix`, then loads the produced NIfTI volume.
|
|
158
|
+
|
|
167
159
|
If there are multiple datasets in the file, a selection GUI appears which highlights arrays supported by ndslice (essentially numeric).
|
|
168
160
|
Double click to open.
|
|
169
161
|
|
|
@@ -175,11 +167,19 @@ Double click to open.
|
|
|
175
167
|
### From PyPI
|
|
176
168
|
|
|
177
169
|
```bash
|
|
178
|
-
pip install ndslice
|
|
179
|
-
|
|
180
|
-
pip install ndslice
|
|
170
|
+
pip install ndslice
|
|
171
|
+
# Or, if you want just the package without pulling in dependencies:
|
|
172
|
+
pip install --no-deps ndslice
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For DICOM directories you also need the external `dcm2niix` binary available on `PATH`. A practical install route is:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
conda install -c conda-forge dcm2niix
|
|
181
179
|
```
|
|
182
180
|
|
|
181
|
+
If `dcm2niix` is missing or conversion fails, ndslice reports a clear error instead of trying to infer the series layout itself.
|
|
182
|
+
|
|
183
183
|
### From source
|
|
184
184
|
|
|
185
185
|
```bash
|
|
@@ -199,7 +199,13 @@ pip install -e .
|
|
|
199
199
|
- NumPy >= 1.20.0
|
|
200
200
|
- PyQtGraph >= 0.12.0
|
|
201
201
|
- PyQt5 >= 5.15.0
|
|
202
|
-
- h5py >= 3.0.0
|
|
202
|
+
- h5py >= 3.0.0
|
|
203
|
+
- scipy >= 1.7.0
|
|
204
|
+
- pydicom >= 2.4.0
|
|
205
|
+
- nibabel >= 4.0.0
|
|
206
|
+
- imageio >= 2.9.0
|
|
207
|
+
- imageio-ffmpeg >= 0.4.2
|
|
208
|
+
- Pillow >= 8.0.0
|
|
203
209
|
|
|
204
210
|
## License
|
|
205
211
|
|
|
@@ -91,6 +91,9 @@ ndslice(data2) # Shown after first closes
|
|
|
91
91
|
### Command Line
|
|
92
92
|
```bash
|
|
93
93
|
ndslice data.npy # Numpy file
|
|
94
|
+
ndslice image.nii.gz
|
|
95
|
+
ndslice image.dcm
|
|
96
|
+
ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
|
|
94
97
|
ndslice --help # Show all options
|
|
95
98
|
```
|
|
96
99
|
|
|
@@ -104,10 +107,13 @@ ndslice has CLI support and can conveniently display:
|
|
|
104
107
|
| [BART](https://mrirecon.github.io/bart/) | `.cfl` + `.hdr` | — |
|
|
105
108
|
| Philips REC | `.REC` + `.xml` | — |
|
|
106
109
|
| [NIfTI](https://nifti.nimh.nih.gov/) | `.nii`, `.nii.gz` | nibabel |
|
|
107
|
-
| DICOM
|
|
110
|
+
| DICOM file | `.dcm` | pydicom |
|
|
111
|
+
| DICOM directory | directory containing `.dcm` files | pydicom, nibabel, `dcm2niix` on `PATH` |
|
|
108
112
|
|
|
109
113
|
HDF5 files can be compound complex dtype, or real/imag fields.
|
|
110
114
|
|
|
115
|
+
For DICOM directories, ndslice does not infer series dimensions itself. It runs `dcm2niix`, then loads the produced NIfTI volume.
|
|
116
|
+
|
|
111
117
|
If there are multiple datasets in the file, a selection GUI appears which highlights arrays supported by ndslice (essentially numeric).
|
|
112
118
|
Double click to open.
|
|
113
119
|
|
|
@@ -119,11 +125,19 @@ Double click to open.
|
|
|
119
125
|
### From PyPI
|
|
120
126
|
|
|
121
127
|
```bash
|
|
122
|
-
pip install ndslice
|
|
123
|
-
|
|
124
|
-
pip install ndslice
|
|
128
|
+
pip install ndslice
|
|
129
|
+
# Or, if you want just the package without pulling in dependencies:
|
|
130
|
+
pip install --no-deps ndslice
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
For DICOM directories you also need the external `dcm2niix` binary available on `PATH`. A practical install route is:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
conda install -c conda-forge dcm2niix
|
|
125
137
|
```
|
|
126
138
|
|
|
139
|
+
If `dcm2niix` is missing or conversion fails, ndslice reports a clear error instead of trying to infer the series layout itself.
|
|
140
|
+
|
|
127
141
|
### From source
|
|
128
142
|
|
|
129
143
|
```bash
|
|
@@ -143,7 +157,13 @@ pip install -e .
|
|
|
143
157
|
- NumPy >= 1.20.0
|
|
144
158
|
- PyQtGraph >= 0.12.0
|
|
145
159
|
- PyQt5 >= 5.15.0
|
|
146
|
-
- h5py >= 3.0.0
|
|
160
|
+
- h5py >= 3.0.0
|
|
161
|
+
- scipy >= 1.7.0
|
|
162
|
+
- pydicom >= 2.4.0
|
|
163
|
+
- nibabel >= 4.0.0
|
|
164
|
+
- imageio >= 2.9.0
|
|
165
|
+
- imageio-ffmpeg >= 0.4.2
|
|
166
|
+
- Pillow >= 8.0.0
|
|
147
167
|
|
|
148
168
|
## License
|
|
149
169
|
|
|
@@ -7,7 +7,7 @@ import numpy as np
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from .ndslice import ndslice
|
|
9
9
|
from .selectors import H5DatasetSelector, NpzDatasetSelector, MatDatasetSelector
|
|
10
|
-
from .file_interpreters import
|
|
10
|
+
from .file_interpreters import load_path
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
def main():
|
|
@@ -21,6 +21,7 @@ Examples:
|
|
|
21
21
|
ndslice data.h5 data2.npy data3.npz # View multiple files
|
|
22
22
|
ndslice scan.REC # View Philips REC/XML pair
|
|
23
23
|
ndslice ref.cfl # View BART CFL/HDR pair
|
|
24
|
+
ndslice dicomdir/ # Convert DICOM directory via dcm2niix, then view
|
|
24
25
|
ndslice scan.dcm # View DICOM file
|
|
25
26
|
ndslice scan.nii # View NIfTI file
|
|
26
27
|
ndslice data.txt # View text file with numeric data
|
|
@@ -29,7 +30,7 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
29
30
|
"""
|
|
30
31
|
)
|
|
31
32
|
parser.add_argument('files', type=str, nargs='+',
|
|
32
|
-
help='Path(s) to data
|
|
33
|
+
help='Path(s) to data files or DICOM directories')
|
|
33
34
|
|
|
34
35
|
args = parser.parse_args()
|
|
35
36
|
|
|
@@ -42,10 +43,14 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
42
43
|
|
|
43
44
|
try:
|
|
44
45
|
suffix = ''.join(filepath.suffixes).lower()
|
|
45
|
-
# Single-dataset formats
|
|
46
|
-
if suffix in ['.npy', '.rec', '.cfl', '.dcm', '.nii', '.nii.gz', '.txt']:
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
# Single-dataset formats and DICOM directories are handled by file_interpreters.load_path
|
|
47
|
+
if filepath.is_dir() or suffix in ['.npy', '.rec', '.cfl', '.dcm', '.nii', '.nii.gz', '.txt']:
|
|
48
|
+
loaded = load_path(filepath)
|
|
49
|
+
title = filepath.name or str(filepath)
|
|
50
|
+
detected_format = loaded.metadata.get('detected_format')
|
|
51
|
+
if detected_format:
|
|
52
|
+
title = f"{title} [{detected_format}]"
|
|
53
|
+
ndslice(data=loaded.data, title=title, block=False, filepath=filepath)
|
|
49
54
|
continue
|
|
50
55
|
|
|
51
56
|
# Multi-dataset formats - use selectors
|
|
@@ -57,7 +62,7 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
57
62
|
elif suffix == '.mat':
|
|
58
63
|
selector = MatDatasetSelector(filepath)
|
|
59
64
|
else:
|
|
60
|
-
print(f"Unsupported file type: {suffix}. Supported types: .h5, .hdf5, .npy, .npz, .mat, .REC, .cfl, .dcm, .nii, .txt")
|
|
65
|
+
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")
|
|
61
66
|
continue
|
|
62
67
|
|
|
63
68
|
# Select and view dataset (shows GUI if multiple datasets)
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import xml.etree.ElementTree as ET
|
|
2
2
|
import numpy as np
|
|
3
3
|
import struct
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass
|
|
4
7
|
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LoadedPath:
|
|
13
|
+
data: np.ndarray
|
|
14
|
+
metadata: dict
|
|
5
15
|
|
|
6
16
|
|
|
7
17
|
def remove_trailing_singletons(data):
|
|
@@ -233,6 +243,10 @@ class BartLoader:
|
|
|
233
243
|
class DicomLoader:
|
|
234
244
|
def __init__(self, dcm_path):
|
|
235
245
|
self.dcm_path = Path(dcm_path)
|
|
246
|
+
self.metadata = {
|
|
247
|
+
'source_path': str(self.dcm_path),
|
|
248
|
+
'detected_format': 'dicom_file',
|
|
249
|
+
}
|
|
236
250
|
|
|
237
251
|
if not self.dcm_path.exists():
|
|
238
252
|
raise FileNotFoundError(f"DICOM file not found: {self.dcm_path}")
|
|
@@ -247,12 +261,195 @@ class DicomLoader:
|
|
|
247
261
|
)
|
|
248
262
|
|
|
249
263
|
dcm = pydicom.dcmread(self.dcm_path)
|
|
250
|
-
|
|
264
|
+
if not hasattr(dcm, 'pixel_array'):
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"DICOM file does not contain pixel data: {self.dcm_path}"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if getattr(dcm, 'SamplesPerPixel', 1) != 1:
|
|
270
|
+
raise ValueError(
|
|
271
|
+
"Single-file DICOM loading currently supports grayscale pixel data only. "
|
|
272
|
+
f"Unsupported SamplesPerPixel={dcm.SamplesPerPixel} for {self.dcm_path}"
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
data = remove_trailing_singletons(np.asarray(dcm.pixel_array))
|
|
276
|
+
self.metadata.update({
|
|
277
|
+
'shape': tuple(data.shape),
|
|
278
|
+
'dtype': str(data.dtype),
|
|
279
|
+
'dicom_metadata': {
|
|
280
|
+
'patient_name': str(getattr(dcm, 'PatientName', '')),
|
|
281
|
+
'series_description': str(getattr(dcm, 'SeriesDescription', '')),
|
|
282
|
+
'modality': str(getattr(dcm, 'Modality', '')),
|
|
283
|
+
'pixel_spacing': list(getattr(dcm, 'PixelSpacing', [])) if hasattr(dcm, 'PixelSpacing') else None,
|
|
284
|
+
'image_position_patient': list(getattr(dcm, 'ImagePositionPatient', [])) if hasattr(dcm, 'ImagePositionPatient') else None,
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
return data
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _find_dicom_files(directory_path):
|
|
291
|
+
directory_path = Path(directory_path)
|
|
292
|
+
return sorted(
|
|
293
|
+
path for path in directory_path.rglob('*')
|
|
294
|
+
if path.is_file() and path.suffix.lower() == '.dcm'
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _json_sidecar_for_nifti(nifti_path):
|
|
299
|
+
nifti_path = Path(nifti_path)
|
|
300
|
+
json_path = nifti_path.with_suffix('.json')
|
|
301
|
+
if nifti_path.suffix == '.gz':
|
|
302
|
+
json_path = nifti_path.with_suffix('').with_suffix('.json')
|
|
303
|
+
return json_path if json_path.exists() else None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _pick_converted_niftis(output_dir):
|
|
307
|
+
output_dir = Path(output_dir)
|
|
308
|
+
nifti_paths = sorted(output_dir.glob('*.nii')) + sorted(output_dir.glob('*.nii.gz'))
|
|
309
|
+
|
|
310
|
+
if not nifti_paths:
|
|
311
|
+
raise ValueError("dcm2niix did not produce a NIfTI file")
|
|
312
|
+
|
|
313
|
+
return [(nifti_path, _json_sidecar_for_nifti(nifti_path)) for nifti_path in nifti_paths]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _read_json_sidecar(json_path):
|
|
317
|
+
if json_path is None:
|
|
318
|
+
return {}
|
|
319
|
+
|
|
320
|
+
import json
|
|
321
|
+
|
|
322
|
+
with open(json_path, 'r') as json_file:
|
|
323
|
+
return json.load(json_file)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _describe_stacking_axis(sidecar_metadata):
|
|
327
|
+
axis_candidates = [
|
|
328
|
+
('EchoNumber', 'echoes'),
|
|
329
|
+
('FlipAngle', 'flip angles'),
|
|
330
|
+
('DiffusionBValue', 'b-values'),
|
|
331
|
+
('SeriesNumber', 'series'),
|
|
332
|
+
('AcquisitionNumber', 'acquisitions'),
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
for key, label in axis_candidates:
|
|
336
|
+
values = [metadata.get(key) for metadata in sidecar_metadata]
|
|
337
|
+
if any(value is not None for value in values) and len(set(values)) > 1:
|
|
338
|
+
return label, key, values
|
|
339
|
+
|
|
340
|
+
names = [metadata.get('ProtocolName') or metadata.get('SeriesDescription') for metadata in sidecar_metadata]
|
|
341
|
+
if any(name for name in names) and len(set(names)) > 1:
|
|
342
|
+
return 'series', 'SeriesDescription', names
|
|
343
|
+
|
|
344
|
+
return 'series', None, None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _run_dcm2niix(directory_path, output_dir):
|
|
348
|
+
dcm2niix_path = shutil.which('dcm2niix')
|
|
349
|
+
if dcm2niix_path is None:
|
|
350
|
+
raise RuntimeError(
|
|
351
|
+
"dcm2niix is required to load DICOM directories. Install it, for example, with "
|
|
352
|
+
"'conda install -c conda-forge dcm2niix', and ensure it is on PATH."
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
command = [
|
|
356
|
+
dcm2niix_path,
|
|
357
|
+
'-b', 'y',
|
|
358
|
+
'-z', 'n',
|
|
359
|
+
'-f', 'ndslice_%s',
|
|
360
|
+
'-o', str(output_dir),
|
|
361
|
+
str(directory_path),
|
|
362
|
+
]
|
|
363
|
+
completed = subprocess.run(command, capture_output=True)
|
|
364
|
+
if completed.returncode != 0:
|
|
365
|
+
stdout = completed.stdout.decode('utf-8', errors='replace').strip()
|
|
366
|
+
stderr = completed.stderr.decode('utf-8', errors='replace').strip()
|
|
367
|
+
error_output = '\n'.join(part for part in [stdout, stderr] if part)
|
|
368
|
+
raise RuntimeError(
|
|
369
|
+
"dcm2niix failed to convert the DICOM directory. "
|
|
370
|
+
f"Input: {directory_path}\n{error_output}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
return _pick_converted_niftis(output_dir)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class DicomDirectoryLoader:
|
|
377
|
+
def __init__(self, directory_path):
|
|
378
|
+
self.directory_path = Path(directory_path)
|
|
379
|
+
self.metadata = {
|
|
380
|
+
'source_path': str(self.directory_path),
|
|
381
|
+
'detected_format': 'dicom_directory',
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if not self.directory_path.exists():
|
|
385
|
+
raise FileNotFoundError(f"DICOM directory not found: {self.directory_path}")
|
|
386
|
+
if not self.directory_path.is_dir():
|
|
387
|
+
raise NotADirectoryError(f"Expected a directory, got: {self.directory_path}")
|
|
388
|
+
|
|
389
|
+
def load(self):
|
|
390
|
+
dicom_files = _find_dicom_files(self.directory_path)
|
|
391
|
+
if not dicom_files:
|
|
392
|
+
raise ValueError(
|
|
393
|
+
f"No DICOM files with suffix .dcm were found under directory: {self.directory_path}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
with tempfile.TemporaryDirectory(prefix='ndslice_dicom_') as temp_dir:
|
|
397
|
+
nifti_outputs = _run_dcm2niix(self.directory_path, temp_dir)
|
|
398
|
+
arrays = []
|
|
399
|
+
nifti_paths = []
|
|
400
|
+
json_paths = []
|
|
401
|
+
sidecar_metadata = []
|
|
402
|
+
|
|
403
|
+
for nifti_path, json_path in nifti_outputs:
|
|
404
|
+
nifti_loader = NiftiLoader(nifti_path)
|
|
405
|
+
arrays.append(nifti_loader.load())
|
|
406
|
+
nifti_paths.append(str(nifti_path))
|
|
407
|
+
json_paths.append(str(json_path) if json_path else None)
|
|
408
|
+
sidecar_metadata.append(_read_json_sidecar(json_path))
|
|
409
|
+
|
|
410
|
+
if len(arrays) == 1:
|
|
411
|
+
data = arrays[0]
|
|
412
|
+
stacking_label = None
|
|
413
|
+
stacking_key = None
|
|
414
|
+
stacking_values = None
|
|
415
|
+
else:
|
|
416
|
+
shapes = [tuple(array.shape) for array in arrays]
|
|
417
|
+
if len(set(shapes)) != 1:
|
|
418
|
+
paths = ', '.join(Path(path).name for path in nifti_paths)
|
|
419
|
+
raise ValueError(
|
|
420
|
+
"dcm2niix produced multiple NIfTI files with different shapes, so ndslice "
|
|
421
|
+
f"cannot stack them automatically. Outputs: {paths}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
stacking_label, stacking_key, stacking_values = _describe_stacking_axis(sidecar_metadata)
|
|
425
|
+
print(f"Dataset stacked automatically along new dimension: {stacking_label}")
|
|
426
|
+
if stacking_key is not None and stacking_values is not None:
|
|
427
|
+
print(f" {stacking_key}: {stacking_values}")
|
|
428
|
+
if stacking_key == 'SeriesNumber':
|
|
429
|
+
series_descriptions = [metadata.get('SeriesDescription') for metadata in sidecar_metadata]
|
|
430
|
+
if any(description is not None for description in series_descriptions):
|
|
431
|
+
print(f" SeriesDescription: {series_descriptions}")
|
|
432
|
+
data = np.stack(arrays, axis=-1)
|
|
433
|
+
|
|
434
|
+
self.metadata.update({
|
|
435
|
+
'shape': tuple(data.shape),
|
|
436
|
+
'dtype': str(data.dtype),
|
|
437
|
+
'nifti_output_path': nifti_paths[0] if len(nifti_paths) == 1 else nifti_paths,
|
|
438
|
+
'sidecar_json_path': json_paths[0] if len(json_paths) == 1 else json_paths,
|
|
439
|
+
'stacked_dimension': stacking_label,
|
|
440
|
+
'stacked_dimension_key': stacking_key,
|
|
441
|
+
'stacked_dimension_values': stacking_values,
|
|
442
|
+
})
|
|
443
|
+
return data
|
|
251
444
|
|
|
252
445
|
|
|
253
446
|
class NiftiLoader:
|
|
254
447
|
def __init__(self, file_path):
|
|
255
448
|
self.file_path = Path(file_path)
|
|
449
|
+
self.metadata = {
|
|
450
|
+
'source_path': str(self.file_path),
|
|
451
|
+
'detected_format': 'nifti',
|
|
452
|
+
}
|
|
256
453
|
|
|
257
454
|
if not self.file_path.exists():
|
|
258
455
|
raise FileNotFoundError(f"NIfTI file not found: {self.file_path}")
|
|
@@ -267,12 +464,21 @@ class NiftiLoader:
|
|
|
267
464
|
)
|
|
268
465
|
|
|
269
466
|
data = nib.load(self.file_path).get_fdata()
|
|
270
|
-
|
|
467
|
+
data = remove_trailing_singletons(data)
|
|
468
|
+
self.metadata.update({
|
|
469
|
+
'shape': tuple(data.shape),
|
|
470
|
+
'dtype': str(data.dtype),
|
|
471
|
+
})
|
|
472
|
+
return data
|
|
271
473
|
|
|
272
474
|
|
|
273
475
|
class TextLoader:
|
|
274
476
|
def __init__(self, file_path):
|
|
275
477
|
self.file_path = Path(file_path)
|
|
478
|
+
self.metadata = {
|
|
479
|
+
'source_path': str(self.file_path),
|
|
480
|
+
'detected_format': 'text',
|
|
481
|
+
}
|
|
276
482
|
|
|
277
483
|
if not self.file_path.exists():
|
|
278
484
|
raise FileNotFoundError(f"Text file not found: {self.file_path}")
|
|
@@ -318,7 +524,58 @@ class TextLoader:
|
|
|
318
524
|
# Different row lengths - flatten to 1D
|
|
319
525
|
data = np.array([val for row in numeric_lines for val in row], dtype=np.float32)
|
|
320
526
|
|
|
321
|
-
|
|
527
|
+
data = remove_trailing_singletons(data)
|
|
528
|
+
self.metadata.update({
|
|
529
|
+
'shape': tuple(data.shape),
|
|
530
|
+
'dtype': str(data.dtype),
|
|
531
|
+
})
|
|
532
|
+
return data
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def load_path(filepath):
|
|
536
|
+
filepath = Path(filepath)
|
|
537
|
+
|
|
538
|
+
if filepath.is_dir():
|
|
539
|
+
loader = DicomDirectoryLoader(filepath)
|
|
540
|
+
data = loader.load()
|
|
541
|
+
return LoadedPath(data=data, metadata=loader.metadata)
|
|
542
|
+
|
|
543
|
+
suffix = ''.join(filepath.suffixes).lower()
|
|
544
|
+
|
|
545
|
+
if suffix == '.npy':
|
|
546
|
+
data = np.load(filepath)
|
|
547
|
+
return LoadedPath(
|
|
548
|
+
data=data,
|
|
549
|
+
metadata={
|
|
550
|
+
'source_path': str(filepath),
|
|
551
|
+
'detected_format': 'numpy',
|
|
552
|
+
'shape': tuple(data.shape),
|
|
553
|
+
'dtype': str(data.dtype),
|
|
554
|
+
},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if suffix == '.rec':
|
|
558
|
+
loader = PhilipsRECLoader(filepath)
|
|
559
|
+
elif suffix == '.cfl':
|
|
560
|
+
loader = BartLoader(filepath)
|
|
561
|
+
elif suffix == '.dcm':
|
|
562
|
+
loader = DicomLoader(filepath)
|
|
563
|
+
elif suffix in ['.nii', '.nii.gz']:
|
|
564
|
+
loader = NiftiLoader(filepath)
|
|
565
|
+
elif suffix == '.txt':
|
|
566
|
+
loader = TextLoader(filepath)
|
|
567
|
+
else:
|
|
568
|
+
raise ValueError(f"Unsupported file format: {suffix}")
|
|
569
|
+
|
|
570
|
+
data = loader.load()
|
|
571
|
+
metadata = {
|
|
572
|
+
'source_path': str(filepath),
|
|
573
|
+
'detected_format': suffix.lstrip('.'),
|
|
574
|
+
'shape': tuple(data.shape),
|
|
575
|
+
'dtype': str(data.dtype),
|
|
576
|
+
}
|
|
577
|
+
metadata.update(getattr(loader, 'metadata', {}))
|
|
578
|
+
return LoadedPath(data=data, metadata=metadata)
|
|
322
579
|
|
|
323
580
|
|
|
324
581
|
|
|
@@ -326,9 +583,10 @@ class TextLoader:
|
|
|
326
583
|
|
|
327
584
|
def load_file(filepath):
|
|
328
585
|
"""
|
|
329
|
-
Generic
|
|
586
|
+
Generic path loader - automatically detects format and returns a NumPy array.
|
|
330
587
|
|
|
331
588
|
Supported formats:
|
|
589
|
+
- directory: DICOM directory via dcm2niix
|
|
332
590
|
- .npy: NumPy binary format
|
|
333
591
|
- .REC: Philips XLM+REC (requires xml file)
|
|
334
592
|
- .cfl: BART format (requires hdr file)
|
|
@@ -337,36 +595,10 @@ def load_file(filepath):
|
|
|
337
595
|
- .txt: Simple text files with numeric data
|
|
338
596
|
|
|
339
597
|
Args:
|
|
340
|
-
filepath: Path to data file
|
|
598
|
+
filepath: Path to data file or DICOM directory
|
|
341
599
|
|
|
342
600
|
Returns:
|
|
343
601
|
NumPy array
|
|
344
602
|
|
|
345
603
|
"""
|
|
346
|
-
|
|
347
|
-
suffix = ''.join(Path(filepath).suffixes).lower()
|
|
348
|
-
|
|
349
|
-
if suffix == '.npy':
|
|
350
|
-
return np.load(filepath)
|
|
351
|
-
|
|
352
|
-
elif suffix == '.rec':
|
|
353
|
-
loader = PhilipsRECLoader(filepath)
|
|
354
|
-
return loader.load()
|
|
355
|
-
|
|
356
|
-
elif suffix == '.cfl':
|
|
357
|
-
loader = BartLoader(filepath)
|
|
358
|
-
return loader.load()
|
|
359
|
-
|
|
360
|
-
elif suffix == '.dcm':
|
|
361
|
-
loader = DicomLoader(filepath)
|
|
362
|
-
return loader.load()
|
|
363
|
-
elif suffix in ['.nii', '.nii.gz']:
|
|
364
|
-
loader = NiftiLoader(filepath)
|
|
365
|
-
return loader.load()
|
|
366
|
-
|
|
367
|
-
elif suffix == '.txt':
|
|
368
|
-
loader = TextLoader(filepath)
|
|
369
|
-
return loader.load()
|
|
370
|
-
|
|
371
|
-
else:
|
|
372
|
-
raise ValueError(f"Unsupported file format: {suffix}")
|
|
604
|
+
return load_path(filepath).data
|
|
@@ -4,7 +4,9 @@ import pyqtgraph.Qt as Qt
|
|
|
4
4
|
from pyqtgraph.Qt import QtWidgets, QtGui
|
|
5
5
|
import os
|
|
6
6
|
import math
|
|
7
|
+
import platform
|
|
7
8
|
from enum import Enum
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
from .imageview2d import ImageView2D
|
|
9
11
|
from .video_export import VideoExportWorker, VideoExportDialog, VideoExportSettingsDialog
|
|
10
12
|
import multiprocessing as mp
|
|
@@ -26,6 +28,292 @@ class Domain(Enum):
|
|
|
26
28
|
NATIVE=0
|
|
27
29
|
FOURIER=1
|
|
28
30
|
|
|
31
|
+
|
|
32
|
+
QT_SIGNAL = getattr(Qt.QtCore, 'Signal', getattr(Qt.QtCore, 'pyqtSignal'))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RangeSlider(QtWidgets.QWidget):
|
|
36
|
+
"""A minimal horizontal two-handle slider for inclusive integer ranges."""
|
|
37
|
+
|
|
38
|
+
valuesChanged = QT_SIGNAL(int, int)
|
|
39
|
+
|
|
40
|
+
def __init__(self, parent=None, minimum=0, maximum=0):
|
|
41
|
+
super().__init__(parent)
|
|
42
|
+
self._minimum = int(minimum)
|
|
43
|
+
self._maximum = max(int(minimum), int(maximum))
|
|
44
|
+
self._lower_value = self._minimum
|
|
45
|
+
self._upper_value = self._maximum
|
|
46
|
+
self._active_handle = None
|
|
47
|
+
self.setMouseTracking(True)
|
|
48
|
+
self.setMinimumHeight(30)
|
|
49
|
+
|
|
50
|
+
def sizeHint(self):
|
|
51
|
+
return Qt.QtCore.QSize(240, 30)
|
|
52
|
+
|
|
53
|
+
def values(self):
|
|
54
|
+
return self._lower_value, self._upper_value
|
|
55
|
+
|
|
56
|
+
def setValues(self, lower_value, upper_value):
|
|
57
|
+
lower_value = max(self._minimum, min(int(lower_value), self._maximum))
|
|
58
|
+
upper_value = max(self._minimum, min(int(upper_value), self._maximum))
|
|
59
|
+
if lower_value > upper_value:
|
|
60
|
+
lower_value, upper_value = upper_value, lower_value
|
|
61
|
+
|
|
62
|
+
if (lower_value, upper_value) == (self._lower_value, self._upper_value):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
self._lower_value = lower_value
|
|
66
|
+
self._upper_value = upper_value
|
|
67
|
+
self.valuesChanged.emit(self._lower_value, self._upper_value)
|
|
68
|
+
self.update()
|
|
69
|
+
|
|
70
|
+
def paintEvent(self, event):
|
|
71
|
+
del event
|
|
72
|
+
painter = QtGui.QPainter(self)
|
|
73
|
+
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
|
|
74
|
+
|
|
75
|
+
palette = self.palette()
|
|
76
|
+
groove_rect = self._groove_rect()
|
|
77
|
+
lower_center = self._handle_center(self._lower_value)
|
|
78
|
+
upper_center = self._handle_center(self._upper_value)
|
|
79
|
+
|
|
80
|
+
painter.setPen(Qt.QtCore.Qt.PenStyle.NoPen)
|
|
81
|
+
painter.setBrush(palette.midlight())
|
|
82
|
+
painter.drawRoundedRect(groove_rect, 3, 3)
|
|
83
|
+
|
|
84
|
+
selected_rect = Qt.QtCore.QRectF(
|
|
85
|
+
lower_center.x(),
|
|
86
|
+
groove_rect.top(),
|
|
87
|
+
max(upper_center.x() - lower_center.x(), 1),
|
|
88
|
+
groove_rect.height(),
|
|
89
|
+
)
|
|
90
|
+
painter.setBrush(palette.highlight())
|
|
91
|
+
painter.drawRoundedRect(selected_rect, 3, 3)
|
|
92
|
+
|
|
93
|
+
self._paint_handle(painter, lower_center, self._active_handle == 'lower')
|
|
94
|
+
self._paint_handle(painter, upper_center, self._active_handle == 'upper')
|
|
95
|
+
|
|
96
|
+
def mousePressEvent(self, event):
|
|
97
|
+
if event.button() != Qt.QtCore.Qt.MouseButton.LeftButton:
|
|
98
|
+
event.ignore()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
point = self._event_point(event)
|
|
102
|
+
self._active_handle = self._closest_handle(point)
|
|
103
|
+
self._move_active_handle(point.x())
|
|
104
|
+
event.accept()
|
|
105
|
+
|
|
106
|
+
def mouseMoveEvent(self, event):
|
|
107
|
+
point = self._event_point(event)
|
|
108
|
+
if self._active_handle is not None:
|
|
109
|
+
self._move_active_handle(point.x())
|
|
110
|
+
event.accept()
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
self.setCursor(
|
|
114
|
+
QtGui.QCursor(
|
|
115
|
+
Qt.QtCore.Qt.CursorShape.SizeHorCursor if self._is_over_handle(point) else Qt.QtCore.Qt.CursorShape.ArrowCursor
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
event.ignore()
|
|
119
|
+
|
|
120
|
+
def mouseReleaseEvent(self, event):
|
|
121
|
+
if event.button() == Qt.QtCore.Qt.MouseButton.LeftButton:
|
|
122
|
+
self._active_handle = None
|
|
123
|
+
self.update()
|
|
124
|
+
event.accept()
|
|
125
|
+
|
|
126
|
+
def leaveEvent(self, event):
|
|
127
|
+
self.setCursor(QtGui.QCursor(Qt.QtCore.Qt.CursorShape.ArrowCursor))
|
|
128
|
+
super().leaveEvent(event)
|
|
129
|
+
|
|
130
|
+
def _event_point(self, event):
|
|
131
|
+
if hasattr(event, 'position'):
|
|
132
|
+
return event.position()
|
|
133
|
+
if hasattr(event, 'localPos'):
|
|
134
|
+
return event.localPos()
|
|
135
|
+
return Qt.QtCore.QPointF(event.pos())
|
|
136
|
+
|
|
137
|
+
def _handle_radius(self):
|
|
138
|
+
return 8.0
|
|
139
|
+
|
|
140
|
+
def _groove_rect(self):
|
|
141
|
+
margin = self._handle_radius() + 4.0
|
|
142
|
+
return Qt.QtCore.QRectF(
|
|
143
|
+
margin,
|
|
144
|
+
self.height() / 2.0 - 3.0,
|
|
145
|
+
max(self.width() - 2.0 * margin, 1.0),
|
|
146
|
+
6.0,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _handle_center(self, value):
|
|
150
|
+
groove_rect = self._groove_rect()
|
|
151
|
+
span = max(self._maximum - self._minimum, 1)
|
|
152
|
+
ratio = (value - self._minimum) / span
|
|
153
|
+
return Qt.QtCore.QPointF(groove_rect.left() + groove_rect.width() * ratio, groove_rect.center().y())
|
|
154
|
+
|
|
155
|
+
def _handle_rect(self, value):
|
|
156
|
+
center = self._handle_center(value)
|
|
157
|
+
radius = self._handle_radius()
|
|
158
|
+
return Qt.QtCore.QRectF(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0)
|
|
159
|
+
|
|
160
|
+
def _paint_handle(self, painter, center, active):
|
|
161
|
+
radius = self._handle_radius()
|
|
162
|
+
palette = self.palette()
|
|
163
|
+
rect = Qt.QtCore.QRectF(center.x() - radius, center.y() - radius, radius * 2.0, radius * 2.0)
|
|
164
|
+
painter.setPen(QtGui.QPen(palette.shadow().color(), 1.0))
|
|
165
|
+
painter.setBrush(palette.highlight() if active else palette.button())
|
|
166
|
+
painter.drawEllipse(rect)
|
|
167
|
+
|
|
168
|
+
inner_rect = rect.adjusted(3.0, 3.0, -3.0, -3.0)
|
|
169
|
+
painter.setPen(Qt.QtCore.Qt.PenStyle.NoPen)
|
|
170
|
+
painter.setBrush(palette.base())
|
|
171
|
+
painter.drawEllipse(inner_rect)
|
|
172
|
+
|
|
173
|
+
def _closest_handle(self, point):
|
|
174
|
+
lower_distance = abs(point.x() - self._handle_center(self._lower_value).x())
|
|
175
|
+
upper_distance = abs(point.x() - self._handle_center(self._upper_value).x())
|
|
176
|
+
return 'lower' if lower_distance <= upper_distance else 'upper'
|
|
177
|
+
|
|
178
|
+
def _is_over_handle(self, point):
|
|
179
|
+
expanded_lower = self._handle_rect(self._lower_value).adjusted(-4, -4, 4, 4)
|
|
180
|
+
expanded_upper = self._handle_rect(self._upper_value).adjusted(-4, -4, 4, 4)
|
|
181
|
+
return expanded_lower.contains(point) or expanded_upper.contains(point)
|
|
182
|
+
|
|
183
|
+
def _value_from_position(self, position_x):
|
|
184
|
+
groove_rect = self._groove_rect()
|
|
185
|
+
clamped_x = min(max(position_x, groove_rect.left()), groove_rect.right())
|
|
186
|
+
ratio = (clamped_x - groove_rect.left()) / max(groove_rect.width(), 1.0)
|
|
187
|
+
return int(round(self._minimum + ratio * (self._maximum - self._minimum)))
|
|
188
|
+
|
|
189
|
+
def _move_active_handle(self, position_x):
|
|
190
|
+
value = self._value_from_position(position_x)
|
|
191
|
+
if self._active_handle == 'lower':
|
|
192
|
+
self.setValues(min(value, self._upper_value), self._upper_value)
|
|
193
|
+
elif self._active_handle == 'upper':
|
|
194
|
+
self.setValues(self._lower_value, max(value, self._lower_value))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class SaveRangeDialog(QtWidgets.QDialog):
|
|
198
|
+
"""Dialog for selecting an inclusive index range along each dimension."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, parent, data_shape):
|
|
201
|
+
super().__init__(parent)
|
|
202
|
+
self.setWindowTitle('Save as NumPy')
|
|
203
|
+
self._controls = []
|
|
204
|
+
self._data_shape = tuple(data_shape)
|
|
205
|
+
|
|
206
|
+
layout = QtWidgets.QVBoxLayout(self)
|
|
207
|
+
layout.setContentsMargins(12, 12, 12, 12)
|
|
208
|
+
layout.setSpacing(10)
|
|
209
|
+
|
|
210
|
+
scroll_area = QtWidgets.QScrollArea()
|
|
211
|
+
scroll_area.setWidgetResizable(True)
|
|
212
|
+
scroll_area.setHorizontalScrollBarPolicy(Qt.QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
213
|
+
content = QtWidgets.QWidget()
|
|
214
|
+
content_layout = QtWidgets.QVBoxLayout(content)
|
|
215
|
+
content_layout.setContentsMargins(4, 4, 4, 4)
|
|
216
|
+
content_layout.setSpacing(10)
|
|
217
|
+
|
|
218
|
+
for dim, size in enumerate(data_shape):
|
|
219
|
+
max_index = max(0, size - 1)
|
|
220
|
+
row_widget = QtWidgets.QWidget()
|
|
221
|
+
row_layout = QtWidgets.QGridLayout(row_widget)
|
|
222
|
+
row_layout.setContentsMargins(0, 0, 0, 0)
|
|
223
|
+
row_layout.setHorizontalSpacing(8)
|
|
224
|
+
row_layout.setVerticalSpacing(4)
|
|
225
|
+
|
|
226
|
+
dimension_label = QtWidgets.QLabel(f'Dim {dim}')
|
|
227
|
+
dimension_label.setStyleSheet('QLabel { font-size: 9pt; font-weight: bold; }')
|
|
228
|
+
slider = RangeSlider(row_widget, 0, max_index)
|
|
229
|
+
|
|
230
|
+
start_spinbox = QtWidgets.QSpinBox()
|
|
231
|
+
end_spinbox = QtWidgets.QSpinBox()
|
|
232
|
+
for spinbox in (start_spinbox, end_spinbox):
|
|
233
|
+
spinbox.setRange(0, max_index)
|
|
234
|
+
spinbox.setButtonSymbols(QtWidgets.QAbstractSpinBox.ButtonSymbols.NoButtons)
|
|
235
|
+
spinbox.setStyleSheet(NDSliceWindow.SPINBOX_STYLE)
|
|
236
|
+
spinbox.setFixedWidth(70)
|
|
237
|
+
|
|
238
|
+
end_spinbox.setValue(max_index)
|
|
239
|
+
start_spinbox.valueChanged.connect(lambda value, end_box=end_spinbox: end_box.setMinimum(value))
|
|
240
|
+
end_spinbox.valueChanged.connect(lambda value, start_box=start_spinbox: start_box.setMaximum(value))
|
|
241
|
+
start_spinbox.valueChanged.connect(lambda value, slider_widget=slider, end_box=end_spinbox: slider_widget.setValues(value, end_box.value()))
|
|
242
|
+
end_spinbox.valueChanged.connect(lambda value, slider_widget=slider, start_box=start_spinbox: slider_widget.setValues(start_box.value(), value))
|
|
243
|
+
start_spinbox.valueChanged.connect(lambda _value: self._update_output_shape_label())
|
|
244
|
+
end_spinbox.valueChanged.connect(lambda _value: self._update_output_shape_label())
|
|
245
|
+
slider.valuesChanged.connect(lambda lower, upper, start_box=start_spinbox, end_box=end_spinbox: self._sync_spinboxes(start_box, end_box, lower, upper))
|
|
246
|
+
|
|
247
|
+
slider.setValues(0, max_index)
|
|
248
|
+
|
|
249
|
+
row_layout.addWidget(dimension_label, 0, 0)
|
|
250
|
+
row_layout.addWidget(slider, 0, 1, 1, 4)
|
|
251
|
+
row_layout.addWidget(QtWidgets.QLabel('start'), 1, 0)
|
|
252
|
+
row_layout.addWidget(start_spinbox, 1, 1)
|
|
253
|
+
row_layout.addItem(QtWidgets.QSpacerItem(24, 1, QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Minimum), 1, 2)
|
|
254
|
+
row_layout.addWidget(QtWidgets.QLabel('end'), 1, 3)
|
|
255
|
+
row_layout.addWidget(end_spinbox, 1, 4)
|
|
256
|
+
row_layout.setColumnStretch(1, 0)
|
|
257
|
+
row_layout.setColumnStretch(2, 1)
|
|
258
|
+
row_layout.setColumnStretch(4, 0)
|
|
259
|
+
content_layout.addWidget(row_widget)
|
|
260
|
+
self._controls.append((slider, start_spinbox, end_spinbox))
|
|
261
|
+
|
|
262
|
+
content_layout.addStretch(1)
|
|
263
|
+
|
|
264
|
+
scroll_area.setWidget(content)
|
|
265
|
+
scroll_area.setMaximumHeight(min(440, 84 * max(len(data_shape), 1)))
|
|
266
|
+
layout.addWidget(scroll_area)
|
|
267
|
+
|
|
268
|
+
footer_layout = QtWidgets.QHBoxLayout()
|
|
269
|
+
footer_layout.setContentsMargins(0, 0, 0, 0)
|
|
270
|
+
footer_layout.setSpacing(12)
|
|
271
|
+
self._squeeze_checkbox = QtWidgets.QCheckBox('Squeeze singleton dimensions')
|
|
272
|
+
self._squeeze_checkbox.setChecked(True)
|
|
273
|
+
self._squeeze_checkbox.toggled.connect(self._update_output_shape_label)
|
|
274
|
+
self._output_shape_label = QtWidgets.QLabel()
|
|
275
|
+
self._output_shape_label.setStyleSheet('QLabel { font-size: 9pt; color: palette(windowText); }')
|
|
276
|
+
footer_layout.addWidget(self._squeeze_checkbox)
|
|
277
|
+
footer_layout.addStretch(1)
|
|
278
|
+
footer_layout.addWidget(self._output_shape_label)
|
|
279
|
+
layout.addLayout(footer_layout)
|
|
280
|
+
|
|
281
|
+
buttons = QtWidgets.QDialogButtonBox(
|
|
282
|
+
QtWidgets.QDialogButtonBox.StandardButton.Save | QtWidgets.QDialogButtonBox.StandardButton.Cancel
|
|
283
|
+
)
|
|
284
|
+
buttons.accepted.connect(self.accept)
|
|
285
|
+
buttons.rejected.connect(self.reject)
|
|
286
|
+
layout.addWidget(buttons)
|
|
287
|
+
|
|
288
|
+
self._update_output_shape_label()
|
|
289
|
+
self.resize(640, min(560, 150 + 84 * len(data_shape)))
|
|
290
|
+
|
|
291
|
+
def _sync_spinboxes(self, start_spinbox, end_spinbox, lower_value, upper_value):
|
|
292
|
+
start_spinbox.blockSignals(True)
|
|
293
|
+
end_spinbox.blockSignals(True)
|
|
294
|
+
start_spinbox.setValue(lower_value)
|
|
295
|
+
end_spinbox.setValue(upper_value)
|
|
296
|
+
start_spinbox.blockSignals(False)
|
|
297
|
+
end_spinbox.blockSignals(False)
|
|
298
|
+
self._update_output_shape_label()
|
|
299
|
+
|
|
300
|
+
def _selected_shape(self):
|
|
301
|
+
selected_shape = [max(end.value() - start.value() + 1, 0) for _, start, end in self._controls]
|
|
302
|
+
if self.should_squeeze():
|
|
303
|
+
squeezed_shape = [size for size in selected_shape if size != 1]
|
|
304
|
+
return squeezed_shape or [1]
|
|
305
|
+
return selected_shape
|
|
306
|
+
|
|
307
|
+
def _update_output_shape_label(self):
|
|
308
|
+
self._output_shape_label.setText(f'Output shape: {self._selected_shape()}')
|
|
309
|
+
|
|
310
|
+
def get_ranges(self):
|
|
311
|
+
"""Return Python slice bounds as (start, stop) tuples."""
|
|
312
|
+
return [(start.value(), end.value() + 1) for _, start, end in self._controls]
|
|
313
|
+
|
|
314
|
+
def should_squeeze(self):
|
|
315
|
+
return self._squeeze_checkbox.isChecked()
|
|
316
|
+
|
|
29
317
|
class NDSliceWindow(QtWidgets.QMainWindow):
|
|
30
318
|
# Styling constants — use pt (point) units so font sizes are DPI-independent
|
|
31
319
|
DIMENSION_LABEL_STYLE = "QLabel { font-size: 9pt; padding: 1px; margin: 2px; }"
|
|
@@ -34,6 +322,13 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
34
322
|
SPINBOX_STYLE = "QSpinBox { font-size: 9pt; } QSpinBox:disabled { color: palette(mid); }"
|
|
35
323
|
RADIO_BUTTON_STYLE = "QRadioButton { font-size: 9pt; }"
|
|
36
324
|
GROUPBOX_BASE_STYLE = "QGroupBox { font-size: 9pt; font-weight: bold; border: 1px solid palette(mid); border-radius: 3px; margin-top: 1.4ex; padding-top: 3pt; } QGroupBox::title { subcontrol-origin: margin; left: 8px; padding: 0 3px; }"
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _set_emoji_font(widget):
|
|
328
|
+
if platform.system() == 'Darwin':
|
|
329
|
+
font = widget.font()
|
|
330
|
+
font.setFamily('Apple Color Emoji')
|
|
331
|
+
widget.setFont(font)
|
|
37
332
|
|
|
38
333
|
def __init__(self, data, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
|
|
39
334
|
super(NDSliceWindow, self).__init__()
|
|
@@ -164,12 +459,14 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
164
459
|
flip_label.mousePressEvent = lambda event, i=i: self.flipAxisClicked(event, i)
|
|
165
460
|
flip_label.setStyleSheet(self.FLIP_ICON_STYLE)
|
|
166
461
|
flip_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignLeft | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
|
|
462
|
+
self._set_emoji_font(flip_label)
|
|
167
463
|
|
|
168
464
|
# Set up complex indicator labels with click handlers
|
|
169
465
|
for i, complex_label in enumerate(self.widgets['labels']['complex']):
|
|
170
466
|
complex_label.mousePressEvent = lambda event, i=i: self.complexOrRealClicked(event, i)
|
|
171
467
|
complex_label.setStyleSheet(self.FLIP_ICON_STYLE)
|
|
172
468
|
complex_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignRight | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
|
|
469
|
+
self._set_emoji_font(complex_label)
|
|
173
470
|
|
|
174
471
|
# Apply compact styling to dimension control widgets
|
|
175
472
|
for label in self.widgets['labels']['dims']:
|
|
@@ -241,6 +538,8 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
241
538
|
w.valueChanged.connect(self.update)
|
|
242
539
|
|
|
243
540
|
self._setup_export_context_menus()
|
|
541
|
+
self._save_shortcut = QtGui.QShortcut(QtGui.QKeySequence.StandardKey.Save, self)
|
|
542
|
+
self._save_shortcut.activated.connect(self._save_current_numpy_file)
|
|
244
543
|
|
|
245
544
|
# Create a single compact control panel with all radio buttons
|
|
246
545
|
controls_widget = QtWidgets.QWidget()
|
|
@@ -450,6 +749,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
450
749
|
self._reload_btn.setFixedSize(28, 20)
|
|
451
750
|
self._reload_btn.clicked.connect(self._reload_file)
|
|
452
751
|
self._reload_btn.setVisible(filepath is not None)
|
|
752
|
+
self._set_emoji_font(self._reload_btn)
|
|
453
753
|
self.layouts['topUp'].addWidget(self._reload_btn)
|
|
454
754
|
self.layouts['topUp'].addWidget(self.widgets['labels']['pixelValue'])
|
|
455
755
|
self.layouts['topUp'].addWidget(self.widgets['labels']['arrayInfo'])
|
|
@@ -670,7 +970,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
670
970
|
button.setEnabled(enabled_channels[name])
|
|
671
971
|
|
|
672
972
|
if checked_channel not in enabled_channels or not enabled_channels[checked_channel]:
|
|
673
|
-
checked_channel = 'abs' if
|
|
973
|
+
checked_channel = 'abs' if is_complex else 'real'
|
|
674
974
|
|
|
675
975
|
channel_buttons[checked_channel].setChecked(True)
|
|
676
976
|
|
|
@@ -1489,6 +1789,43 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
1489
1789
|
progress_dialog = VideoExportDialog(self)
|
|
1490
1790
|
progress_dialog.start_export(worker, self.data.shape[export_dim])
|
|
1491
1791
|
|
|
1792
|
+
def _save_current_numpy_file(self):
|
|
1793
|
+
"""Save the currently displayed array state to a NumPy .npy file."""
|
|
1794
|
+
range_dialog = SaveRangeDialog(self, self.data.shape)
|
|
1795
|
+
if range_dialog.exec() != QtWidgets.QDialog.DialogCode.Accepted:
|
|
1796
|
+
return
|
|
1797
|
+
|
|
1798
|
+
ranges = range_dialog.get_ranges()
|
|
1799
|
+
sliced_data = self.data[tuple(slice(start, stop) for start, stop in ranges)]
|
|
1800
|
+
output_data = np.squeeze(sliced_data) if range_dialog.should_squeeze() else sliced_data
|
|
1801
|
+
|
|
1802
|
+
default_name = 'ndslice.npy'
|
|
1803
|
+
if self._filepath is not None:
|
|
1804
|
+
source_path = Path(self._filepath)
|
|
1805
|
+
source_name = source_path.name
|
|
1806
|
+
if source_name.lower().endswith('.nii.gz'):
|
|
1807
|
+
default_name = source_name[:-7] + '.npy'
|
|
1808
|
+
else:
|
|
1809
|
+
default_name = f"{source_path.stem}.npy"
|
|
1810
|
+
|
|
1811
|
+
file_path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
1812
|
+
self,
|
|
1813
|
+
"Save current array as NumPy file",
|
|
1814
|
+
default_name,
|
|
1815
|
+
"NumPy files (*.npy)"
|
|
1816
|
+
)
|
|
1817
|
+
if not file_path:
|
|
1818
|
+
return
|
|
1819
|
+
|
|
1820
|
+
if not file_path.lower().endswith('.npy'):
|
|
1821
|
+
file_path += '.npy'
|
|
1822
|
+
|
|
1823
|
+
try:
|
|
1824
|
+
np.save(file_path, output_data)
|
|
1825
|
+
print(f"Saved array {list(output_data.shape)} to {file_path}")
|
|
1826
|
+
except Exception as e:
|
|
1827
|
+
QtWidgets.QMessageBox.warning(self, "Save Error", f"Failed to save NumPy file:\n{e}")
|
|
1828
|
+
|
|
1492
1829
|
def _on_file_changed(self, path):
|
|
1493
1830
|
"""Called by QFileSystemWatcher when the source file changes on disk."""
|
|
1494
1831
|
self._reload_btn.setText("⚠️")
|
|
@@ -452,15 +452,15 @@ class VideoExportSettingsDialog(QtWidgets.QDialog):
|
|
|
452
452
|
format_options = [
|
|
453
453
|
("PNG frames", "png", True),
|
|
454
454
|
("GIF", "gif", True),
|
|
455
|
-
("MP4
|
|
456
|
-
("WebM
|
|
455
|
+
("MP4", "mp4", True),
|
|
456
|
+
("WebM", "webm", True),
|
|
457
457
|
]
|
|
458
458
|
else:
|
|
459
459
|
format_options = [
|
|
460
460
|
("PNG frames", "png", True),
|
|
461
461
|
("GIF", "gif", True),
|
|
462
|
-
("MP4", "mp4", False),
|
|
463
|
-
("WebM", "webm", False),
|
|
462
|
+
("MP4 (requires imageio-ffmpeg)", "mp4", False),
|
|
463
|
+
("WebM (requires imageio-ffmpeg)", "webm", False),
|
|
464
464
|
]
|
|
465
465
|
|
|
466
466
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndslice
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
-
License: MIT
|
|
6
|
+
License-Expression: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/henricryden/ndslice
|
|
8
8
|
Project-URL: Documentation, https://github.com/henricryden/ndslice#readme
|
|
9
9
|
Project-URL: Bug Tracker, https://github.com/henricryden/ndslice/issues
|
|
@@ -11,7 +11,6 @@ Keywords: visualization,numpy,fft,image-viewer,data-visualization
|
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Intended Audience :: Science/Research
|
|
14
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
15
14
|
Classifier: Programming Language :: Python :: 3
|
|
16
15
|
Classifier: Programming Language :: Python :: 3.8
|
|
17
16
|
Classifier: Programming Language :: Python :: 3.9
|
|
@@ -27,31 +26,18 @@ License-File: LICENSE
|
|
|
27
26
|
Requires-Dist: numpy>=1.20.0
|
|
28
27
|
Requires-Dist: pyqtgraph>=0.14.0
|
|
29
28
|
Requires-Dist: PyQt5>=5.15.0
|
|
29
|
+
Requires-Dist: h5py>=3.0.0
|
|
30
|
+
Requires-Dist: scipy>=1.7.0
|
|
31
|
+
Requires-Dist: pydicom>=2.4.0
|
|
32
|
+
Requires-Dist: nibabel>=4.0.0
|
|
33
|
+
Requires-Dist: imageio>=2.9.0
|
|
34
|
+
Requires-Dist: imageio-ffmpeg>=0.4.2
|
|
35
|
+
Requires-Dist: Pillow>=8.0.0
|
|
30
36
|
Provides-Extra: pyqt6
|
|
31
37
|
Requires-Dist: PyQt6>=6.4.0; extra == "pyqt6"
|
|
32
38
|
Provides-Extra: dev
|
|
33
39
|
Requires-Dist: build>=0.10.0; extra == "dev"
|
|
34
40
|
Requires-Dist: twine>=4.0.0; extra == "dev"
|
|
35
|
-
Provides-Extra: hdf5
|
|
36
|
-
Requires-Dist: h5py>=3.0.0; extra == "hdf5"
|
|
37
|
-
Provides-Extra: matlab
|
|
38
|
-
Requires-Dist: scipy>=1.7.0; extra == "matlab"
|
|
39
|
-
Provides-Extra: dicom
|
|
40
|
-
Requires-Dist: pydicom>=2.4.0; extra == "dicom"
|
|
41
|
-
Provides-Extra: nifti
|
|
42
|
-
Requires-Dist: nibabel>=4.0.0; extra == "nifti"
|
|
43
|
-
Provides-Extra: video-export
|
|
44
|
-
Requires-Dist: imageio>=2.9.0; extra == "video-export"
|
|
45
|
-
Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "video-export"
|
|
46
|
-
Requires-Dist: Pillow>=8.0.0; extra == "video-export"
|
|
47
|
-
Provides-Extra: all
|
|
48
|
-
Requires-Dist: h5py>=3.0.0; extra == "all"
|
|
49
|
-
Requires-Dist: scipy>=1.7.0; extra == "all"
|
|
50
|
-
Requires-Dist: pydicom>=2.4.0; extra == "all"
|
|
51
|
-
Requires-Dist: nibabel>=4.0.0; extra == "all"
|
|
52
|
-
Requires-Dist: imageio>=2.9.0; extra == "all"
|
|
53
|
-
Requires-Dist: imageio-ffmpeg>=0.4.2; extra == "all"
|
|
54
|
-
Requires-Dist: Pillow>=8.0.0; extra == "all"
|
|
55
41
|
Dynamic: license-file
|
|
56
42
|
|
|
57
43
|
[](https://pypi.org/project/ndslice/)
|
|
@@ -147,6 +133,9 @@ ndslice(data2) # Shown after first closes
|
|
|
147
133
|
### Command Line
|
|
148
134
|
```bash
|
|
149
135
|
ndslice data.npy # Numpy file
|
|
136
|
+
ndslice image.nii.gz
|
|
137
|
+
ndslice image.dcm
|
|
138
|
+
ndslice some_dicom_dir/ # Automatically attemps to form an nd-array from DICOM the files
|
|
150
139
|
ndslice --help # Show all options
|
|
151
140
|
```
|
|
152
141
|
|
|
@@ -160,10 +149,13 @@ ndslice has CLI support and can conveniently display:
|
|
|
160
149
|
| [BART](https://mrirecon.github.io/bart/) | `.cfl` + `.hdr` | — |
|
|
161
150
|
| Philips REC | `.REC` + `.xml` | — |
|
|
162
151
|
| [NIfTI](https://nifti.nimh.nih.gov/) | `.nii`, `.nii.gz` | nibabel |
|
|
163
|
-
| DICOM
|
|
152
|
+
| DICOM file | `.dcm` | pydicom |
|
|
153
|
+
| DICOM directory | directory containing `.dcm` files | pydicom, nibabel, `dcm2niix` on `PATH` |
|
|
164
154
|
|
|
165
155
|
HDF5 files can be compound complex dtype, or real/imag fields.
|
|
166
156
|
|
|
157
|
+
For DICOM directories, ndslice does not infer series dimensions itself. It runs `dcm2niix`, then loads the produced NIfTI volume.
|
|
158
|
+
|
|
167
159
|
If there are multiple datasets in the file, a selection GUI appears which highlights arrays supported by ndslice (essentially numeric).
|
|
168
160
|
Double click to open.
|
|
169
161
|
|
|
@@ -175,11 +167,19 @@ Double click to open.
|
|
|
175
167
|
### From PyPI
|
|
176
168
|
|
|
177
169
|
```bash
|
|
178
|
-
pip install ndslice
|
|
179
|
-
|
|
180
|
-
pip install ndslice
|
|
170
|
+
pip install ndslice
|
|
171
|
+
# Or, if you want just the package without pulling in dependencies:
|
|
172
|
+
pip install --no-deps ndslice
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
For DICOM directories you also need the external `dcm2niix` binary available on `PATH`. A practical install route is:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
conda install -c conda-forge dcm2niix
|
|
181
179
|
```
|
|
182
180
|
|
|
181
|
+
If `dcm2niix` is missing or conversion fails, ndslice reports a clear error instead of trying to infer the series layout itself.
|
|
182
|
+
|
|
183
183
|
### From source
|
|
184
184
|
|
|
185
185
|
```bash
|
|
@@ -199,7 +199,13 @@ pip install -e .
|
|
|
199
199
|
- NumPy >= 1.20.0
|
|
200
200
|
- PyQtGraph >= 0.12.0
|
|
201
201
|
- PyQt5 >= 5.15.0
|
|
202
|
-
- h5py >= 3.0.0
|
|
202
|
+
- h5py >= 3.0.0
|
|
203
|
+
- scipy >= 1.7.0
|
|
204
|
+
- pydicom >= 2.4.0
|
|
205
|
+
- nibabel >= 4.0.0
|
|
206
|
+
- imageio >= 2.9.0
|
|
207
|
+
- imageio-ffmpeg >= 0.4.2
|
|
208
|
+
- Pillow >= 8.0.0
|
|
203
209
|
|
|
204
210
|
## License
|
|
205
211
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
numpy>=1.20.0
|
|
2
2
|
pyqtgraph>=0.14.0
|
|
3
3
|
PyQt5>=5.15.0
|
|
4
|
-
|
|
5
|
-
[all]
|
|
6
4
|
h5py>=3.0.0
|
|
7
5
|
scipy>=1.7.0
|
|
8
6
|
pydicom>=2.4.0
|
|
@@ -15,22 +13,5 @@ Pillow>=8.0.0
|
|
|
15
13
|
build>=0.10.0
|
|
16
14
|
twine>=4.0.0
|
|
17
15
|
|
|
18
|
-
[dicom]
|
|
19
|
-
pydicom>=2.4.0
|
|
20
|
-
|
|
21
|
-
[hdf5]
|
|
22
|
-
h5py>=3.0.0
|
|
23
|
-
|
|
24
|
-
[matlab]
|
|
25
|
-
scipy>=1.7.0
|
|
26
|
-
|
|
27
|
-
[nifti]
|
|
28
|
-
nibabel>=4.0.0
|
|
29
|
-
|
|
30
16
|
[pyqt6]
|
|
31
17
|
PyQt6>=6.4.0
|
|
32
|
-
|
|
33
|
-
[video_export]
|
|
34
|
-
imageio>=2.9.0
|
|
35
|
-
imageio-ffmpeg>=0.4.2
|
|
36
|
-
Pillow>=8.0.0
|
|
@@ -4,18 +4,17 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ndslice"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Interactive N-dimensional numpy array viewer with FFT support"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
11
11
|
{name = "Henric Rydén", email = "henric.ryden@gmail.com"}
|
|
12
12
|
]
|
|
13
|
-
license =
|
|
13
|
+
license = "MIT"
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
17
|
"Intended Audience :: Science/Research",
|
|
18
|
-
"License :: OSI Approved :: MIT License",
|
|
19
18
|
"Programming Language :: Python :: 3",
|
|
20
19
|
"Programming Language :: Python :: 3.8",
|
|
21
20
|
"Programming Language :: Python :: 3.9",
|
|
@@ -32,6 +31,13 @@ dependencies = [
|
|
|
32
31
|
"numpy>=1.20.0",
|
|
33
32
|
"pyqtgraph>=0.14.0",
|
|
34
33
|
"PyQt5>=5.15.0",
|
|
34
|
+
"h5py>=3.0.0",
|
|
35
|
+
"scipy>=1.7.0",
|
|
36
|
+
"pydicom>=2.4.0",
|
|
37
|
+
"nibabel>=4.0.0",
|
|
38
|
+
"imageio>=2.9.0",
|
|
39
|
+
"imageio-ffmpeg>=0.4.2",
|
|
40
|
+
"Pillow>=8.0.0",
|
|
35
41
|
]
|
|
36
42
|
|
|
37
43
|
[project.scripts]
|
|
@@ -43,32 +49,6 @@ dev = [
|
|
|
43
49
|
"build>=0.10.0",
|
|
44
50
|
"twine>=4.0.0",
|
|
45
51
|
]
|
|
46
|
-
hdf5 = [
|
|
47
|
-
"h5py>=3.0.0",
|
|
48
|
-
]
|
|
49
|
-
matlab = [
|
|
50
|
-
"scipy>=1.7.0",
|
|
51
|
-
]
|
|
52
|
-
dicom = [
|
|
53
|
-
"pydicom>=2.4.0",
|
|
54
|
-
]
|
|
55
|
-
nifti = [
|
|
56
|
-
"nibabel>=4.0.0",
|
|
57
|
-
]
|
|
58
|
-
video_export = [
|
|
59
|
-
"imageio>=2.9.0",
|
|
60
|
-
"imageio-ffmpeg>=0.4.2",
|
|
61
|
-
"Pillow>=8.0.0",
|
|
62
|
-
]
|
|
63
|
-
all = [
|
|
64
|
-
"h5py>=3.0.0",
|
|
65
|
-
"scipy>=1.7.0",
|
|
66
|
-
"pydicom>=2.4.0",
|
|
67
|
-
"nibabel>=4.0.0",
|
|
68
|
-
"imageio>=2.9.0",
|
|
69
|
-
"imageio-ffmpeg>=0.4.2",
|
|
70
|
-
"Pillow>=8.0.0",
|
|
71
|
-
]
|
|
72
52
|
[project.urls]
|
|
73
53
|
Homepage = "https://github.com/henricryden/ndslice"
|
|
74
54
|
Documentation = "https://github.com/henricryden/ndslice#readme"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|