ndslice 0.5.2__tar.gz → 0.6.1__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.5.2 → ndslice-0.6.1}/PKG-INFO +3 -1
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/__main__.py +1 -1
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/ndslice.py +209 -35
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/selectors.py +35 -7
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice.egg-info/PKG-INFO +3 -1
- {ndslice-0.5.2 → ndslice-0.6.1}/pyproject.toml +3 -1
- {ndslice-0.5.2 → ndslice-0.6.1}/LICENSE +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/README.md +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/__init__.py +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/file_interpreters.py +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/imageview2d.py +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice/video_export.py +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice.egg-info/SOURCES.txt +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice.egg-info/dependency_links.txt +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice.egg-info/entry_points.txt +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice.egg-info/requires.txt +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/ndslice.egg-info/top_level.txt +0 -0
- {ndslice-0.5.2 → ndslice-0.6.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndslice
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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: MIT
|
|
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
23
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
24
|
Requires-Python: >=3.8
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
@@ -45,7 +45,7 @@ For files with multiple datasets (HDF5, NPZ, MAT), a GUI selector will automatic
|
|
|
45
45
|
# Single-dataset formats is handled by file_interpreters.load_file
|
|
46
46
|
if suffix in ['.npy', '.rec', '.cfl', '.dcm', '.nii', '.nii.gz', '.txt']:
|
|
47
47
|
data = load_file(filepath)
|
|
48
|
-
ndslice(data=data, title=filepath.name, block=False)
|
|
48
|
+
ndslice(data=data, title=filepath.name, block=False, filepath=filepath)
|
|
49
49
|
continue
|
|
50
50
|
|
|
51
51
|
# Multi-dataset formats - use selectors
|
|
@@ -4,6 +4,7 @@ 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
|
|
8
9
|
from .imageview2d import ImageView2D
|
|
9
10
|
from .video_export import VideoExportWorker, VideoExportDialog, VideoExportSettingsDialog
|
|
@@ -29,13 +30,20 @@ class Domain(Enum):
|
|
|
29
30
|
class NDSliceWindow(QtWidgets.QMainWindow):
|
|
30
31
|
# Styling constants — use pt (point) units so font sizes are DPI-independent
|
|
31
32
|
DIMENSION_LABEL_STYLE = "QLabel { font-size: 9pt; padding: 1px; margin: 2px; }"
|
|
32
|
-
FLIP_ICON_STYLE = "QLabel { font-size: 15pt; padding: 0px; margin: 0px; }"
|
|
33
|
-
BUTTON_STYLE = "QPushButton { font-size: 9pt; padding: 2px; margin: 2px; }"
|
|
34
|
-
SPINBOX_STYLE = "QSpinBox { font-size: 9pt; }"
|
|
33
|
+
FLIP_ICON_STYLE = "QLabel { font-size: 15pt; padding: 0px; margin: 0px; color: palette(text); }"
|
|
34
|
+
BUTTON_STYLE = "QPushButton { font-size: 9pt; padding: 2px; margin: 2px; } QPushButton:disabled { color: palette(mid); }"
|
|
35
|
+
SPINBOX_STYLE = "QSpinBox { font-size: 9pt; } QSpinBox:disabled { color: palette(mid); }"
|
|
35
36
|
RADIO_BUTTON_STYLE = "QRadioButton { font-size: 9pt; }"
|
|
36
37
|
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; }"
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _set_emoji_font(widget):
|
|
41
|
+
if platform.system() == 'Darwin':
|
|
42
|
+
font = widget.font()
|
|
43
|
+
font.setFamily('Apple Color Emoji')
|
|
44
|
+
widget.setFont(font)
|
|
37
45
|
|
|
38
|
-
def __init__(self, data, complex_dim=None):
|
|
46
|
+
def __init__(self, data, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
|
|
39
47
|
super(NDSliceWindow, self).__init__()
|
|
40
48
|
self.resize(800,800)
|
|
41
49
|
|
|
@@ -45,6 +53,9 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
45
53
|
self.channel = None
|
|
46
54
|
self.scale = None
|
|
47
55
|
self._force_autolevel = False
|
|
56
|
+
self._filepath = filepath
|
|
57
|
+
self._dataset_path = dataset_path
|
|
58
|
+
self._selector_class_name = selector_class_name
|
|
48
59
|
|
|
49
60
|
self.axis_flipped = [False] * data.ndim # Track flip state so that one can toggle dims and come back to the same flip state
|
|
50
61
|
|
|
@@ -121,14 +132,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
121
132
|
self.channel_button_group.addButton(self.widgets['buttons']['channel']['imag'])
|
|
122
133
|
self.channel_button_group.addButton(self.widgets['buttons']['channel']['abs'])
|
|
123
134
|
self.channel_button_group.addButton(self.widgets['buttons']['channel']['angle'])
|
|
124
|
-
|
|
125
|
-
# Set default checked radio button
|
|
126
|
-
if self.widgets['buttons']['channel']['abs'].isEnabled():
|
|
127
|
-
self.widgets['buttons']['channel']['abs'].setChecked(True)
|
|
128
|
-
#self.channel = 'abs'
|
|
129
|
-
else:
|
|
130
|
-
self.widgets['buttons']['channel']['real'].setChecked(True)
|
|
131
|
-
#self.channel = 'real'
|
|
135
|
+
self._update_channel_controls()
|
|
132
136
|
|
|
133
137
|
self.scale_button_group = QtWidgets.QButtonGroup()
|
|
134
138
|
#self.scale_button_group.addButton(self.widgets['buttons']['processing']['log'])
|
|
@@ -168,12 +172,14 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
168
172
|
flip_label.mousePressEvent = lambda event, i=i: self.flipAxisClicked(event, i)
|
|
169
173
|
flip_label.setStyleSheet(self.FLIP_ICON_STYLE)
|
|
170
174
|
flip_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignLeft | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
|
|
175
|
+
self._set_emoji_font(flip_label)
|
|
171
176
|
|
|
172
177
|
# Set up complex indicator labels with click handlers
|
|
173
178
|
for i, complex_label in enumerate(self.widgets['labels']['complex']):
|
|
174
179
|
complex_label.mousePressEvent = lambda event, i=i: self.complexOrRealClicked(event, i)
|
|
175
180
|
complex_label.setStyleSheet(self.FLIP_ICON_STYLE)
|
|
176
181
|
complex_label.setAlignment(Qt.QtCore.Qt.AlignmentFlag.AlignRight | Qt.QtCore.Qt.AlignmentFlag.AlignVCenter)
|
|
182
|
+
self._set_emoji_font(complex_label)
|
|
177
183
|
|
|
178
184
|
# Apply compact styling to dimension control widgets
|
|
179
185
|
for label in self.widgets['labels']['dims']:
|
|
@@ -445,7 +451,17 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
445
451
|
|
|
446
452
|
# Add tab widget to the main layout
|
|
447
453
|
self.layouts['topDown'].addWidget(self.tab_widget)
|
|
448
|
-
|
|
454
|
+
|
|
455
|
+
# Reload / file-changed button (⟳ by default, ⚠️ when file changes on disk)
|
|
456
|
+
self._reload_btn = QtWidgets.QPushButton("⟳")
|
|
457
|
+
self._reload_btn.setStyleSheet("QPushButton { font-size: 18pt; padding: 1px 2px; margin: 0px; border: none; background: transparent; }")
|
|
458
|
+
self._reload_btn.setToolTip("Reload file")
|
|
459
|
+
self._reload_btn.setFlat(True)
|
|
460
|
+
self._reload_btn.setFixedSize(28, 20)
|
|
461
|
+
self._reload_btn.clicked.connect(self._reload_file)
|
|
462
|
+
self._reload_btn.setVisible(filepath is not None)
|
|
463
|
+
self._set_emoji_font(self._reload_btn)
|
|
464
|
+
self.layouts['topUp'].addWidget(self._reload_btn)
|
|
449
465
|
self.layouts['topUp'].addWidget(self.widgets['labels']['pixelValue'])
|
|
450
466
|
self.layouts['topUp'].addWidget(self.widgets['labels']['arrayInfo'])
|
|
451
467
|
|
|
@@ -498,6 +514,13 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
498
514
|
self.update()
|
|
499
515
|
self.show()
|
|
500
516
|
|
|
517
|
+
# Set up file watcher if a filepath was provided (QFileSystemWatcher uses
|
|
518
|
+
# OS-native events: inotify on Linux, FSEvents on macOS, ReadDirectoryChanges on Windows)
|
|
519
|
+
self._file_watcher = None
|
|
520
|
+
if filepath is not None:
|
|
521
|
+
self._file_watcher = Qt.QtCore.QFileSystemWatcher([str(filepath)])
|
|
522
|
+
self._file_watcher.fileChanged.connect(self._on_file_changed)
|
|
523
|
+
|
|
501
524
|
|
|
502
525
|
|
|
503
526
|
|
|
@@ -541,10 +564,12 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
541
564
|
def _apply_fft(self, dim):
|
|
542
565
|
"""Apply forward FFT along specified dimension"""
|
|
543
566
|
self.data = np.fft.ifftshift(np.fft.ifft(np.fft.fftshift(self.data), axis=dim, norm='ortho'))
|
|
567
|
+
self._update_channel_controls()
|
|
544
568
|
|
|
545
569
|
def _apply_ifft(self, dim):
|
|
546
570
|
"""Apply inverse FFT along specified dimension"""
|
|
547
571
|
self.data = np.fft.ifftshift(np.fft.fft(np.fft.fftshift(self.data), axis=dim, norm='ortho'))
|
|
572
|
+
self._update_channel_controls()
|
|
548
573
|
|
|
549
574
|
def flipAxisClicked(self, event, dim):
|
|
550
575
|
"""Handle click on flip axis icon"""
|
|
@@ -635,6 +660,30 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
635
660
|
indicator.setText('')
|
|
636
661
|
indicator.setToolTip('')
|
|
637
662
|
indicator.setCursor(QtGui.QCursor(Qt.QtCore.Qt.CursorShape.ArrowCursor))
|
|
663
|
+
|
|
664
|
+
def _update_channel_controls(self):
|
|
665
|
+
"""Keep channel options in sync with the current array dtype."""
|
|
666
|
+
is_complex = np.iscomplexobj(self.data)
|
|
667
|
+
channel_buttons = self.widgets['buttons']['channel']
|
|
668
|
+
enabled_channels = {
|
|
669
|
+
'real': True,
|
|
670
|
+
'abs': True,
|
|
671
|
+
'imag': is_complex,
|
|
672
|
+
'angle': is_complex,
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
checked_channel = next(
|
|
676
|
+
(name for name, button in channel_buttons.items() if button.isChecked()),
|
|
677
|
+
None,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
for name, button in channel_buttons.items():
|
|
681
|
+
button.setEnabled(enabled_channels[name])
|
|
682
|
+
|
|
683
|
+
if checked_channel not in enabled_channels or not enabled_channels[checked_channel]:
|
|
684
|
+
checked_channel = 'abs' if is_complex else 'real'
|
|
685
|
+
|
|
686
|
+
channel_buttons[checked_channel].setChecked(True)
|
|
638
687
|
|
|
639
688
|
def complexOrRealClicked(self, event, dim):
|
|
640
689
|
if self.can_combine_as_complex[dim] and not self.combined_as_complex[dim]:
|
|
@@ -683,12 +732,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
683
732
|
self.line_plot_dimension = new_dim
|
|
684
733
|
break
|
|
685
734
|
|
|
686
|
-
|
|
687
|
-
for button in self.widgets['buttons']['channel'].values():
|
|
688
|
-
button.setEnabled(True)
|
|
689
|
-
|
|
690
|
-
if not self.widgets['buttons']['channel']['abs'].isChecked():
|
|
691
|
-
self.widgets['buttons']['channel']['abs'].setChecked(True)
|
|
735
|
+
self._update_channel_controls()
|
|
692
736
|
|
|
693
737
|
# Update UI
|
|
694
738
|
self.update_complex_indicators()
|
|
@@ -717,12 +761,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
717
761
|
# Update max value for the spinbox for this dimension
|
|
718
762
|
self.widgets['spins']['slice_indices'][dim].setMaximum(self.data.shape[dim] - 1)
|
|
719
763
|
|
|
720
|
-
|
|
721
|
-
for button in self.widgets['buttons']['channel'].values():
|
|
722
|
-
button.setEnabled(False)
|
|
723
|
-
|
|
724
|
-
# Switch to 'real' channel
|
|
725
|
-
self.widgets['buttons']['channel']['real'].setChecked(True)
|
|
764
|
+
self._update_channel_controls()
|
|
726
765
|
|
|
727
766
|
# Update UI
|
|
728
767
|
self.update_complex_indicators()
|
|
@@ -1185,11 +1224,11 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
1185
1224
|
event.accept()
|
|
1186
1225
|
return
|
|
1187
1226
|
elif key == Qt.QtCore.Qt.Key.Key_5:
|
|
1188
|
-
self.setColormap('
|
|
1227
|
+
self.setColormap('cividis')
|
|
1189
1228
|
event.accept()
|
|
1190
1229
|
return
|
|
1191
1230
|
elif key == Qt.QtCore.Qt.Key.Key_6:
|
|
1192
|
-
self.setColormap('
|
|
1231
|
+
self.setColormap('CET-CBL1')
|
|
1193
1232
|
event.accept()
|
|
1194
1233
|
return
|
|
1195
1234
|
elif key == Qt.QtCore.Qt.Key.Key_7:
|
|
@@ -1200,8 +1239,7 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
1200
1239
|
self.setColormap('d3-warm')
|
|
1201
1240
|
event.accept()
|
|
1202
1241
|
return
|
|
1203
|
-
|
|
1204
|
-
|
|
1242
|
+
|
|
1205
1243
|
# Pass event to parent if not handled
|
|
1206
1244
|
super().keyPressEvent(event)
|
|
1207
1245
|
|
|
@@ -1462,23 +1500,159 @@ class NDSliceWindow(QtWidgets.QMainWindow):
|
|
|
1462
1500
|
progress_dialog = VideoExportDialog(self)
|
|
1463
1501
|
progress_dialog.start_export(worker, self.data.shape[export_dim])
|
|
1464
1502
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1503
|
+
def _on_file_changed(self, path):
|
|
1504
|
+
"""Called by QFileSystemWatcher when the source file changes on disk."""
|
|
1505
|
+
self._reload_btn.setText("⚠️")
|
|
1506
|
+
self._reload_btn.setToolTip("File changed — click to reload")
|
|
1507
|
+
# Re-add the path: handles atomic replacement where the original inode disappears
|
|
1508
|
+
if self._file_watcher:
|
|
1509
|
+
self._file_watcher.addPath(path)
|
|
1510
|
+
|
|
1511
|
+
def _reload_file(self):
|
|
1512
|
+
"""Reload data from the source file, preserving slice positions where possible."""
|
|
1513
|
+
if self._filepath is None:
|
|
1514
|
+
return
|
|
1515
|
+
try:
|
|
1516
|
+
new_data = None
|
|
1517
|
+
new_dataset_path = self._dataset_path
|
|
1518
|
+
|
|
1519
|
+
if self._selector_class_name is None:
|
|
1520
|
+
from .file_interpreters import load_file
|
|
1521
|
+
new_data = load_file(self._filepath)
|
|
1522
|
+
else:
|
|
1523
|
+
from .selectors import H5DatasetSelector, NpzDatasetSelector, MatDatasetSelector
|
|
1524
|
+
selector_map = {
|
|
1525
|
+
'H5DatasetSelector': H5DatasetSelector,
|
|
1526
|
+
'NpzDatasetSelector': NpzDatasetSelector,
|
|
1527
|
+
'MatDatasetSelector': MatDatasetSelector,
|
|
1528
|
+
}
|
|
1529
|
+
selector_cls = selector_map.get(self._selector_class_name)
|
|
1530
|
+
if selector_cls is None:
|
|
1531
|
+
return
|
|
1532
|
+
selector = selector_cls(self._filepath)
|
|
1533
|
+
compatible_keys = {d[0] for d in selector.compatible_datasets}
|
|
1534
|
+
if self._dataset_path is not None and self._dataset_path in compatible_keys:
|
|
1535
|
+
new_data = selector.load_data(self._dataset_path)
|
|
1536
|
+
selector.close()
|
|
1537
|
+
elif selector.requires_gui():
|
|
1538
|
+
selected = selector.show()
|
|
1539
|
+
if selected is None:
|
|
1540
|
+
selector.close()
|
|
1541
|
+
return # User cancelled — keep ⚠️ visible
|
|
1542
|
+
new_data = selector.load_data(selected)
|
|
1543
|
+
new_dataset_path = selected
|
|
1544
|
+
selector.close()
|
|
1545
|
+
else:
|
|
1546
|
+
result = selector.get_single_data()
|
|
1547
|
+
selector.close()
|
|
1548
|
+
if result is None:
|
|
1549
|
+
return
|
|
1550
|
+
new_dataset_path, new_data = result
|
|
1551
|
+
|
|
1552
|
+
if new_data is None:
|
|
1553
|
+
return
|
|
1554
|
+
|
|
1555
|
+
self._dataset_path = new_dataset_path
|
|
1556
|
+
self._reset_data(new_data)
|
|
1557
|
+
self._reload_btn.setText("⟳")
|
|
1558
|
+
self._reload_btn.setToolTip("Reload file")
|
|
1559
|
+
if self._file_watcher and self._filepath:
|
|
1560
|
+
self._file_watcher.addPath(str(self._filepath))
|
|
1561
|
+
except Exception as e:
|
|
1562
|
+
QtWidgets.QMessageBox.warning(self, "Reload Error", f"Failed to reload:\n{e}")
|
|
1563
|
+
|
|
1564
|
+
def _reset_data(self, new_data):
|
|
1565
|
+
"""Replace the displayed data, clamping slice positions to the new shape."""
|
|
1566
|
+
old_ndim = self.data.ndim
|
|
1567
|
+
new_ndim = new_data.ndim
|
|
1568
|
+
|
|
1569
|
+
if new_ndim != old_ndim:
|
|
1570
|
+
# Per-dimension widgets were built for old_ndim and cannot be rebuilt in-place.
|
|
1571
|
+
# Open a fresh window with the new data and close this one.
|
|
1572
|
+
win = NDSliceWindow(new_data,
|
|
1573
|
+
filepath=self._filepath,
|
|
1574
|
+
dataset_path=self._dataset_path,
|
|
1575
|
+
selector_class_name=self._selector_class_name)
|
|
1576
|
+
win.setWindowTitle(self.windowTitle())
|
|
1577
|
+
win.show()
|
|
1578
|
+
self.close()
|
|
1579
|
+
return
|
|
1580
|
+
|
|
1581
|
+
self.data = new_data
|
|
1582
|
+
self.singleton = [e == 1 for e in new_data.shape]
|
|
1583
|
+
|
|
1584
|
+
if np.iscomplexobj(new_data):
|
|
1585
|
+
self.can_combine_as_complex = [False] * new_ndim
|
|
1586
|
+
else:
|
|
1587
|
+
self.can_combine_as_complex = [new_data.shape[i] == 2 for i in range(new_ndim)]
|
|
1588
|
+
self.combined_as_complex = [False] * new_ndim
|
|
1589
|
+
|
|
1590
|
+
# Reset FFT domain state and dim label styling
|
|
1591
|
+
self.domain = [Domain.NATIVE for _ in range(new_ndim)]
|
|
1592
|
+
for i, label in enumerate(self.widgets['labels']['dims']):
|
|
1593
|
+
label.setStyleSheet(self.DIMENSION_LABEL_STYLE)
|
|
1594
|
+
label.setText(f'[{new_data.shape[i]}]')
|
|
1595
|
+
|
|
1596
|
+
# Update spinbox maximums (auto-clamps current value)
|
|
1597
|
+
for i in range(new_ndim):
|
|
1598
|
+
self.widgets['spins']['slice_indices'][i].setMaximum(new_data.shape[i] - 1)
|
|
1599
|
+
|
|
1600
|
+
# Clamp selected_indices: keep existing choices where still valid, fill from valid dims
|
|
1601
|
+
valid_dims = [i for i in range(new_ndim) if not self.singleton[i]]
|
|
1602
|
+
clamped = [i for i in self.selected_indices if i < new_ndim and not self.singleton[i]]
|
|
1603
|
+
for d in valid_dims:
|
|
1604
|
+
if len(clamped) >= 2:
|
|
1605
|
+
break
|
|
1606
|
+
if d not in clamped:
|
|
1607
|
+
clamped.append(d)
|
|
1608
|
+
if not clamped and valid_dims:
|
|
1609
|
+
clamped = [valid_dims[0]]
|
|
1610
|
+
self.selected_indices = clamped[:2]
|
|
1611
|
+
|
|
1612
|
+
# Clamp line_plot_dimension
|
|
1613
|
+
if self.line_plot_dimension >= new_ndim or self.singleton[self.line_plot_dimension]:
|
|
1614
|
+
for d in range(new_ndim):
|
|
1615
|
+
if not self.singleton[d]:
|
|
1616
|
+
self.line_plot_dimension = d
|
|
1617
|
+
break
|
|
1618
|
+
|
|
1619
|
+
self.axis_flipped = [False] * new_ndim
|
|
1620
|
+
|
|
1621
|
+
self._update_channel_controls()
|
|
1622
|
+
self.update_complex_indicators()
|
|
1623
|
+
if len(self.selected_indices) >= 1:
|
|
1624
|
+
self.changedIndex(True, 0, self.selected_indices[0], update=False)
|
|
1625
|
+
if len(self.selected_indices) >= 2:
|
|
1626
|
+
self.changedIndex(True, 1, self.selected_indices[1], update=False)
|
|
1627
|
+
self.update_dimension_controls()
|
|
1628
|
+
self._force_autolevel = True
|
|
1629
|
+
self.update()
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def _run_window(data, title, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
|
|
1467
1633
|
"""Opens a window in a separate process which is blocked on exec()"""
|
|
1468
1634
|
app = pg.mkQApp() # sets QT_ENABLE_HIGHDPI_SCALING + PassThrough rounding before creating QApp
|
|
1469
|
-
|
|
1635
|
+
app.setStyle('Fusion')
|
|
1636
|
+
win = NDSliceWindow(data, complex_dim=complex_dim, filepath=filepath,
|
|
1637
|
+
dataset_path=dataset_path, selector_class_name=selector_class_name)
|
|
1470
1638
|
win.setWindowTitle(title)
|
|
1471
1639
|
win.show()
|
|
1472
1640
|
app.exec()
|
|
1473
1641
|
|
|
1474
|
-
def ndslice(data, title='', block=False, complex_dim=None):
|
|
1642
|
+
def ndslice(data, title='', block=False, complex_dim=None, filepath=None, dataset_path=None, selector_class_name=None):
|
|
1475
1643
|
if not isinstance(data, np.ndarray):
|
|
1476
1644
|
raise TypeError("data must be a numpy array")
|
|
1477
1645
|
if data.ndim < 1:
|
|
1478
1646
|
raise ValueError("data must have at least 1 dimension")
|
|
1479
1647
|
|
|
1480
1648
|
if block:
|
|
1481
|
-
_run_window(data, title, complex_dim
|
|
1649
|
+
_run_window(data, title, complex_dim, filepath=filepath,
|
|
1650
|
+
dataset_path=dataset_path, selector_class_name=selector_class_name)
|
|
1482
1651
|
else:
|
|
1483
|
-
|
|
1652
|
+
import multiprocessing.process as _mp_process
|
|
1653
|
+
p = mp.Process(target=_run_window, args=(data, title, complex_dim),
|
|
1654
|
+
kwargs={'filepath': filepath, 'dataset_path': dataset_path,
|
|
1655
|
+
'selector_class_name': selector_class_name})
|
|
1656
|
+
p.start()
|
|
1657
|
+
_mp_process._children.discard(p)
|
|
1484
1658
|
|
|
@@ -34,10 +34,14 @@ def _show_selector(filepath, selector_class_name, interpret_as_complex):
|
|
|
34
34
|
# Show dialog
|
|
35
35
|
if selected_path := selector.show():
|
|
36
36
|
data = selector.load_data(selected_path)
|
|
37
|
+
selector.close()
|
|
37
38
|
ndslice(data=data,
|
|
38
39
|
title=f"{filepath.name} - {selected_path}",
|
|
39
40
|
block=True,
|
|
40
|
-
complex_dim=interpret_as_complex
|
|
41
|
+
complex_dim=interpret_as_complex,
|
|
42
|
+
filepath=filepath,
|
|
43
|
+
dataset_path=selected_path,
|
|
44
|
+
selector_class_name=selector_class_name)
|
|
41
45
|
|
|
42
46
|
|
|
43
47
|
class DatasetSelector:
|
|
@@ -68,7 +72,11 @@ class DatasetSelector:
|
|
|
68
72
|
def load_data(self, path):
|
|
69
73
|
"""Load data for a given dataset path/name. Returns numpy array."""
|
|
70
74
|
raise NotImplementedError("Subclasses must implement load_data()")
|
|
71
|
-
|
|
75
|
+
|
|
76
|
+
def close(self):
|
|
77
|
+
"""Close any open file handles. Subclasses override if needed."""
|
|
78
|
+
pass
|
|
79
|
+
|
|
72
80
|
def _build_tree(self):
|
|
73
81
|
"""Build the tree widget. Must be implemented by subclasses."""
|
|
74
82
|
raise NotImplementedError("Subclasses must implement _build_tree()")
|
|
@@ -94,10 +102,14 @@ class DatasetSelector:
|
|
|
94
102
|
result = self.get_single_data()
|
|
95
103
|
if result:
|
|
96
104
|
name, data = result
|
|
105
|
+
self.close()
|
|
97
106
|
ndslice(data=data,
|
|
98
107
|
title=f"{self.filepath.name} - {name}",
|
|
99
108
|
block=block,
|
|
100
|
-
complex_dim=self.interpret_as_complex
|
|
109
|
+
complex_dim=self.interpret_as_complex,
|
|
110
|
+
filepath=self.filepath,
|
|
111
|
+
dataset_path=name,
|
|
112
|
+
selector_class_name=self.__class__.__name__)
|
|
101
113
|
return True
|
|
102
114
|
else:
|
|
103
115
|
return False
|
|
@@ -110,16 +122,24 @@ class DatasetSelector:
|
|
|
110
122
|
|
|
111
123
|
if selected_path := self.show():
|
|
112
124
|
data = self.load_data(selected_path)
|
|
125
|
+
self.close()
|
|
113
126
|
ndslice(data=data,
|
|
114
127
|
title=f"{self.filepath.name} - {selected_path}",
|
|
115
128
|
block=True,
|
|
116
|
-
complex_dim=self.interpret_as_complex
|
|
129
|
+
complex_dim=self.interpret_as_complex,
|
|
130
|
+
filepath=self.filepath,
|
|
131
|
+
dataset_path=selected_path,
|
|
132
|
+
selector_class_name=self.__class__.__name__)
|
|
117
133
|
return True
|
|
118
134
|
return False
|
|
119
135
|
else:
|
|
120
136
|
import multiprocessing as mp
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
import multiprocessing.process as _mp_process
|
|
138
|
+
p = mp.Process(target=_show_selector,
|
|
139
|
+
args=(self.filepath, self.__class__.__name__, self.interpret_as_complex))
|
|
140
|
+
p.start()
|
|
141
|
+
_mp_process._children.discard(p)
|
|
142
|
+
self.close()
|
|
123
143
|
return True
|
|
124
144
|
|
|
125
145
|
def _create_dialog(self):
|
|
@@ -249,6 +269,10 @@ class H5DatasetSelector(DatasetSelector):
|
|
|
249
269
|
if hasattr(self, 'h5_file') and self.h5_file:
|
|
250
270
|
self.h5_file.close()
|
|
251
271
|
|
|
272
|
+
def close(self):
|
|
273
|
+
if hasattr(self, 'h5_file') and self.h5_file:
|
|
274
|
+
self.h5_file.close()
|
|
275
|
+
|
|
252
276
|
def _detect_complex_interpretation(self):
|
|
253
277
|
"""
|
|
254
278
|
If there's a single compatible dataset with 'complex' in name and a size-2 dimension,
|
|
@@ -406,7 +430,11 @@ class NpzDatasetSelector(DatasetSelector):
|
|
|
406
430
|
"""Close the NPZ file when selector is destroyed."""
|
|
407
431
|
if hasattr(self, 'npz_file'):
|
|
408
432
|
self.npz_file.close()
|
|
409
|
-
|
|
433
|
+
|
|
434
|
+
def close(self):
|
|
435
|
+
if hasattr(self, 'npz_file'):
|
|
436
|
+
self.npz_file.close()
|
|
437
|
+
|
|
410
438
|
def _find_compatible_datasets(self):
|
|
411
439
|
"""Find all compatible datasets in the NPZ file."""
|
|
412
440
|
compatible = []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ndslice
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.1
|
|
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: MIT
|
|
@@ -18,6 +18,8 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
20
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
23
|
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
22
24
|
Requires-Python: >=3.8
|
|
23
25
|
Description-Content-Type: text/markdown
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ndslice"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.6.1"
|
|
8
8
|
description = "Interactive N-dimensional numpy array viewer with FFT support"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -22,6 +22,8 @@ classifiers = [
|
|
|
22
22
|
"Programming Language :: Python :: 3.10",
|
|
23
23
|
"Programming Language :: Python :: 3.11",
|
|
24
24
|
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
25
27
|
"Topic :: Scientific/Engineering :: Visualization",
|
|
26
28
|
]
|
|
27
29
|
keywords = ["visualization", "numpy", "fft", "image-viewer", "data-visualization"]
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|