npyquick 0.1.0__py3-none-any.whl
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.
- npyquick/__init__.py +4 -0
- npyquick/app.py +456 -0
- npyquick/core/__init__.py +1 -0
- npyquick/core/coord.py +46 -0
- npyquick/core/limits.py +40 -0
- npyquick/core/npyheader.py +63 -0
- npyquick/core/profile.py +47 -0
- npyquick/core/stats.py +90 -0
- npyquick/main.py +36 -0
- npyquick/model.py +84 -0
- npyquick/views/__init__.py +1 -0
- npyquick/views/base.py +117 -0
- npyquick/views/histogram.py +333 -0
- npyquick/views/image.py +548 -0
- npyquick/views/lineplot.py +376 -0
- npyquick/views/pixel_size_dialog.py +155 -0
- npyquick/views/table.py +168 -0
- npyquick-0.1.0.dist-info/METADATA +213 -0
- npyquick-0.1.0.dist-info/RECORD +22 -0
- npyquick-0.1.0.dist-info/WHEEL +4 -0
- npyquick-0.1.0.dist-info/entry_points.txt +2 -0
- npyquick-0.1.0.dist-info/licenses/LICENSE +674 -0
npyquick/__init__.py
ADDED
npyquick/app.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 LiukDiihMieu
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PySide6.QtCore import Qt, QSettings, QUrl
|
|
10
|
+
from PySide6.QtGui import QAction, QActionGroup, QKeySequence, QShortcut
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QApplication,
|
|
13
|
+
QComboBox,
|
|
14
|
+
QFileDialog,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QLabel,
|
|
17
|
+
QMainWindow,
|
|
18
|
+
QStackedWidget,
|
|
19
|
+
QStatusBar,
|
|
20
|
+
QTabBar,
|
|
21
|
+
QVBoxLayout,
|
|
22
|
+
QWidget,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .core.stats import ArrayStats, array_stats
|
|
26
|
+
from .model import NpyDataModel
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_array_summary(array: np.ndarray, stats: ArrayStats | None = None) -> str:
|
|
30
|
+
parts = [f"shape {array.shape}", f"dtype {array.dtype}"]
|
|
31
|
+
if array.size == 0:
|
|
32
|
+
parts.append("empty")
|
|
33
|
+
else:
|
|
34
|
+
if stats is None:
|
|
35
|
+
stats = array_stats(array)
|
|
36
|
+
if stats is not None:
|
|
37
|
+
parts.append(stats.range_str())
|
|
38
|
+
if stats.has_anomaly:
|
|
39
|
+
parts.append(stats.anomaly_str())
|
|
40
|
+
return " | ".join(parts)
|
|
41
|
+
from .views.base import ColormappedView, ExportableMixin, SpatialView
|
|
42
|
+
from .views.histogram import HistogramView
|
|
43
|
+
from .views.image import ImageView
|
|
44
|
+
from .views.lineplot import LineplotView
|
|
45
|
+
from .views.pixel_size_dialog import PixelSizeDialog
|
|
46
|
+
from .views.table import RawTableView
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MainWindow(QMainWindow):
|
|
50
|
+
def __init__(self) -> None:
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.setWindowTitle("npyquick")
|
|
53
|
+
self.resize(1300, 700)
|
|
54
|
+
self.setAcceptDrops(True)
|
|
55
|
+
|
|
56
|
+
_s = QSettings("npyquick", "npyquick")
|
|
57
|
+
saved = _s.value("last_dir", os.path.expanduser("~"))
|
|
58
|
+
self._last_dir = saved if os.path.isdir(saved) else os.path.expanduser("~")
|
|
59
|
+
self._colormap: str = _s.value("colormap", "gray")
|
|
60
|
+
|
|
61
|
+
self._model = NpyDataModel()
|
|
62
|
+
self._pixel_size: float = 1.0
|
|
63
|
+
self._pixel_unit: str = "None"
|
|
64
|
+
self._pixel_expr: str = "1"
|
|
65
|
+
self._current_path: str = ""
|
|
66
|
+
self._sb = QStatusBar()
|
|
67
|
+
self.setStatusBar(self._sb)
|
|
68
|
+
|
|
69
|
+
self._build_menu()
|
|
70
|
+
self._build_central()
|
|
71
|
+
|
|
72
|
+
copy_sc = QShortcut(QKeySequence.StandardKey.Copy, self)
|
|
73
|
+
copy_sc.activated.connect(self._copy_focused_plot)
|
|
74
|
+
save_sc = QShortcut(QKeySequence.StandardKey.Save, self)
|
|
75
|
+
save_sc.activated.connect(self._export_focused_plot)
|
|
76
|
+
|
|
77
|
+
next_tab_sc = QShortcut(QKeySequence("Ctrl+Tab"), self)
|
|
78
|
+
next_tab_sc.activated.connect(self._next_tab)
|
|
79
|
+
prev_tab_sc = QShortcut(QKeySequence("Ctrl+Shift+Tab"), self)
|
|
80
|
+
prev_tab_sc.activated.connect(self._prev_tab)
|
|
81
|
+
|
|
82
|
+
self._sb.showMessage("File › Open (Ctrl+O) to load a .npy or .npz file.")
|
|
83
|
+
|
|
84
|
+
geom = _s.value("geometry")
|
|
85
|
+
if geom:
|
|
86
|
+
self.restoreGeometry(geom)
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# UI construction
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _build_menu(self) -> None:
|
|
93
|
+
fm = self.menuBar().addMenu("&File")
|
|
94
|
+
self._file_menu = fm
|
|
95
|
+
self._export_actions: list = []
|
|
96
|
+
|
|
97
|
+
open_a = QAction("&Open…", self)
|
|
98
|
+
open_a.setShortcut("Ctrl+O")
|
|
99
|
+
open_a.triggered.connect(self.open_file)
|
|
100
|
+
fm.addAction(open_a)
|
|
101
|
+
|
|
102
|
+
reload_a = QAction("&Reload", self)
|
|
103
|
+
reload_a.setShortcuts([QKeySequence("Ctrl+R"), QKeySequence("F5")])
|
|
104
|
+
reload_a.triggered.connect(self._reload_file)
|
|
105
|
+
reload_a.setEnabled(False)
|
|
106
|
+
fm.addAction(reload_a)
|
|
107
|
+
self._reload_action = reload_a
|
|
108
|
+
|
|
109
|
+
fm.addSeparator()
|
|
110
|
+
quit_a = QAction("&Quit", self)
|
|
111
|
+
quit_a.setShortcut("Ctrl+Q")
|
|
112
|
+
quit_a.triggered.connect(self.close)
|
|
113
|
+
fm.addAction(quit_a)
|
|
114
|
+
self._quit_action = quit_a
|
|
115
|
+
|
|
116
|
+
fm.aboutToShow.connect(self._rebuild_export_menu)
|
|
117
|
+
|
|
118
|
+
vm = self.menuBar().addMenu("&View")
|
|
119
|
+
px_action = QAction("Set Pixel Size…", self)
|
|
120
|
+
px_action.triggered.connect(self._open_pixel_size_dialog)
|
|
121
|
+
vm.addAction(px_action)
|
|
122
|
+
vm.addSeparator()
|
|
123
|
+
cmap_menu = vm.addMenu("Colormap")
|
|
124
|
+
colormaps = [
|
|
125
|
+
("gray", "Gray"),
|
|
126
|
+
("viridis", "Viridis"),
|
|
127
|
+
("plasma", "Plasma"),
|
|
128
|
+
("inferno", "Inferno"),
|
|
129
|
+
("magma", "Magma"),
|
|
130
|
+
("cividis", "Cividis"),
|
|
131
|
+
("hot", "Hot"),
|
|
132
|
+
("coolwarm", "Coolwarm"),
|
|
133
|
+
("RdBu_r", "RdBu (diverging)"),
|
|
134
|
+
("turbo", "Turbo"),
|
|
135
|
+
]
|
|
136
|
+
group = QActionGroup(self)
|
|
137
|
+
group.setExclusive(True)
|
|
138
|
+
for name, label in colormaps:
|
|
139
|
+
a = QAction(label, self, checkable=True)
|
|
140
|
+
a.setChecked(name == self._colormap)
|
|
141
|
+
a.triggered.connect(lambda checked, n=name: self._apply_colormap(n))
|
|
142
|
+
group.addAction(a)
|
|
143
|
+
cmap_menu.addAction(a)
|
|
144
|
+
|
|
145
|
+
def _build_central(self) -> None:
|
|
146
|
+
self._image_view = ImageView()
|
|
147
|
+
self._lineplot_view = LineplotView()
|
|
148
|
+
self._table_view = RawTableView()
|
|
149
|
+
self._histogram_view = HistogramView()
|
|
150
|
+
|
|
151
|
+
self._views: list = [
|
|
152
|
+
self._image_view, self._histogram_view,
|
|
153
|
+
self._lineplot_view, self._table_view,
|
|
154
|
+
]
|
|
155
|
+
for v in self._views:
|
|
156
|
+
v.set_on_status(self._sb.showMessage)
|
|
157
|
+
self._image_view.set_on_clim_change(self._histogram_view.update_clim_marker)
|
|
158
|
+
|
|
159
|
+
self._stack = QStackedWidget()
|
|
160
|
+
for v in self._views:
|
|
161
|
+
self._stack.addWidget(v)
|
|
162
|
+
|
|
163
|
+
# Empty-state page — shown when no data is loaded or no npz member is
|
|
164
|
+
# selected. Lives in the stack at an index that has no corresponding tab.
|
|
165
|
+
self._empty_label = QLabel()
|
|
166
|
+
self._empty_label.setAlignment(Qt.AlignCenter)
|
|
167
|
+
self._empty_label.setStyleSheet("color: #888; font-size: 16px;")
|
|
168
|
+
self._empty_label.setWordWrap(True)
|
|
169
|
+
self._empty_page = QWidget()
|
|
170
|
+
_ep_layout = QVBoxLayout(self._empty_page)
|
|
171
|
+
_ep_layout.addWidget(self._empty_label)
|
|
172
|
+
self._stack.addWidget(self._empty_page)
|
|
173
|
+
|
|
174
|
+
self._tabs = QTabBar()
|
|
175
|
+
for v in self._views:
|
|
176
|
+
self._tabs.addTab(v.VIEW_NAME)
|
|
177
|
+
self._tabs.currentChanged.connect(self._on_tab_changed)
|
|
178
|
+
|
|
179
|
+
self._array_combo = QComboBox()
|
|
180
|
+
self._array_combo.setSizeAdjustPolicy(QComboBox.AdjustToContents)
|
|
181
|
+
self._array_combo.setPlaceholderText("— select an array —")
|
|
182
|
+
# activated fires on every user pick, even when re-selecting the same
|
|
183
|
+
# index. currentIndexChanged would silently skip the first item if the
|
|
184
|
+
# combo already rested on index 0 after npz population.
|
|
185
|
+
self._array_combo.activated.connect(self._on_array_selected)
|
|
186
|
+
self._array_bar = QWidget()
|
|
187
|
+
bar_layout = QHBoxLayout(self._array_bar)
|
|
188
|
+
bar_layout.setContentsMargins(6, 2, 6, 2)
|
|
189
|
+
bar_layout.addWidget(QLabel("Array:"))
|
|
190
|
+
bar_layout.addWidget(self._array_combo)
|
|
191
|
+
bar_layout.addStretch()
|
|
192
|
+
self._array_bar.setVisible(False)
|
|
193
|
+
|
|
194
|
+
container = QWidget()
|
|
195
|
+
layout = QVBoxLayout(container)
|
|
196
|
+
layout.setContentsMargins(0, 0, 0, 0)
|
|
197
|
+
layout.setSpacing(0)
|
|
198
|
+
layout.addWidget(self._array_bar)
|
|
199
|
+
layout.addWidget(self._tabs)
|
|
200
|
+
layout.addWidget(self._stack)
|
|
201
|
+
self.setCentralWidget(container)
|
|
202
|
+
|
|
203
|
+
self._set_tabs_enabled([])
|
|
204
|
+
self._show_empty(
|
|
205
|
+
"Open a .npy or .npz file\n\n"
|
|
206
|
+
"File › Open (Ctrl+O) or drag a file onto the window"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
# Tab state
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def _show_empty(self, msg: str) -> None:
|
|
214
|
+
self._empty_label.setText(msg)
|
|
215
|
+
self._stack.setCurrentWidget(self._empty_page)
|
|
216
|
+
self._tabs.setVisible(False)
|
|
217
|
+
|
|
218
|
+
def _on_tab_changed(self, index: int) -> None:
|
|
219
|
+
if self._stack.currentWidget() is self._empty_page:
|
|
220
|
+
return
|
|
221
|
+
self._stack.setCurrentIndex(index)
|
|
222
|
+
self._views[index].refresh_status()
|
|
223
|
+
|
|
224
|
+
def _set_tabs_enabled(self, compatible: list[str], preferred: str | None = None) -> None:
|
|
225
|
+
for i, v in enumerate(self._views):
|
|
226
|
+
self._tabs.setTabEnabled(i, v.VIEW_ID in compatible)
|
|
227
|
+
self._tabs.setVisible(bool(compatible))
|
|
228
|
+
if not compatible:
|
|
229
|
+
return
|
|
230
|
+
target = preferred if preferred in compatible else None
|
|
231
|
+
for i, v in enumerate(self._views):
|
|
232
|
+
if v.VIEW_ID == target or (target is None and v.VIEW_ID in compatible):
|
|
233
|
+
self._tabs.setCurrentIndex(i)
|
|
234
|
+
self._stack.setCurrentIndex(i)
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------
|
|
238
|
+
# File loading
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def open_file(self) -> None:
|
|
242
|
+
start = self._last_dir if os.path.isdir(self._last_dir) else os.path.expanduser("~")
|
|
243
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
244
|
+
self, "Open NumPy File", start, "NumPy files (*.npy *.npz);;All files (*)"
|
|
245
|
+
)
|
|
246
|
+
if path:
|
|
247
|
+
self.load_file(path)
|
|
248
|
+
|
|
249
|
+
def load_file(self, path: str) -> bool:
|
|
250
|
+
try:
|
|
251
|
+
self._model.load(path)
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
self._sb.showMessage(f"Error loading {path}: {exc}")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
self._current_path = path
|
|
257
|
+
self._reload_action.setEnabled(True)
|
|
258
|
+
self._last_dir = os.path.dirname(os.path.abspath(path))
|
|
259
|
+
QSettings("npyquick", "npyquick").setValue("last_dir", self._last_dir)
|
|
260
|
+
self.setWindowTitle(f"npyquick — {path}")
|
|
261
|
+
|
|
262
|
+
metas = self._model.available_array_meta()
|
|
263
|
+
|
|
264
|
+
if self._model.array is None:
|
|
265
|
+
# .npz opened: no member selected yet — show picker and archive
|
|
266
|
+
# summary; views remain disabled until the user selects an array.
|
|
267
|
+
self._array_combo.blockSignals(True)
|
|
268
|
+
self._array_combo.clear()
|
|
269
|
+
for key, meta in metas.items():
|
|
270
|
+
self._array_combo.addItem(
|
|
271
|
+
f"{key} {list(meta.shape)} {meta.dtype}", key
|
|
272
|
+
)
|
|
273
|
+
# Start with no item highlighted so that picking ANY entry — including
|
|
274
|
+
# the first one — will fire the activated signal.
|
|
275
|
+
self._array_combo.setCurrentIndex(-1)
|
|
276
|
+
self._array_combo.blockSignals(False)
|
|
277
|
+
self._array_bar.setVisible(True)
|
|
278
|
+
self._set_tabs_enabled([])
|
|
279
|
+
self._show_empty("Select an array from the dropdown above")
|
|
280
|
+
n = len(metas)
|
|
281
|
+
self._sb.showMessage(
|
|
282
|
+
f"{os.path.basename(path)} | .npz {n} array{'s' if n != 1 else ''}"
|
|
283
|
+
" — select one above to view"
|
|
284
|
+
)
|
|
285
|
+
else:
|
|
286
|
+
# .npy: single array, no picker needed.
|
|
287
|
+
self._array_bar.setVisible(False)
|
|
288
|
+
self._refresh_views()
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
def _refresh_views(self) -> None:
|
|
292
|
+
array = self._model.array
|
|
293
|
+
if array is None:
|
|
294
|
+
return # .npz with no member selected yet — nothing to display
|
|
295
|
+
# Compute once and fan out to every view + the status summary, instead of
|
|
296
|
+
# each consumer re-sampling and re-scanning the same array.
|
|
297
|
+
stats = array_stats(array)
|
|
298
|
+
compatible = [v.VIEW_ID for v in self._views if v.can_handle(array)]
|
|
299
|
+
for v in self._views:
|
|
300
|
+
if v.VIEW_ID in compatible:
|
|
301
|
+
v.set_data(array, stats)
|
|
302
|
+
if self._image_view.can_handle(array):
|
|
303
|
+
self._histogram_view.update_clim_marker(*self._image_view.get_clim())
|
|
304
|
+
else:
|
|
305
|
+
self._histogram_view.update_clim_marker(None, None)
|
|
306
|
+
self._apply_pixel_size()
|
|
307
|
+
self._apply_colormap(self._colormap)
|
|
308
|
+
preferred = "lineplot" if self._lineplot_view.can_handle(array) else None
|
|
309
|
+
self._set_tabs_enabled(compatible, preferred)
|
|
310
|
+
self._sb.showMessage(
|
|
311
|
+
f"{os.path.basename(self._current_path)} | {_format_array_summary(array, stats)}"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def _on_array_selected(self, index: int) -> None:
|
|
315
|
+
key = self._array_combo.itemData(index)
|
|
316
|
+
if key is None:
|
|
317
|
+
return
|
|
318
|
+
try:
|
|
319
|
+
self._model.select_array(key)
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
self._sb.showMessage(f"Cannot load array '{key}': {exc}")
|
|
322
|
+
return
|
|
323
|
+
self._refresh_views()
|
|
324
|
+
|
|
325
|
+
def _reload_file(self) -> None:
|
|
326
|
+
if self._current_path and self.load_file(self._current_path):
|
|
327
|
+
self._sb.showMessage("File reloaded", 3000)
|
|
328
|
+
|
|
329
|
+
def _next_tab(self) -> None:
|
|
330
|
+
if not self._tabs.isVisible():
|
|
331
|
+
return
|
|
332
|
+
n = self._tabs.count()
|
|
333
|
+
cur = self._tabs.currentIndex()
|
|
334
|
+
for offset in range(1, n + 1):
|
|
335
|
+
i = (cur + offset) % n
|
|
336
|
+
if self._tabs.isTabEnabled(i):
|
|
337
|
+
self._tabs.setCurrentIndex(i)
|
|
338
|
+
break
|
|
339
|
+
|
|
340
|
+
def _prev_tab(self) -> None:
|
|
341
|
+
if not self._tabs.isVisible():
|
|
342
|
+
return
|
|
343
|
+
n = self._tabs.count()
|
|
344
|
+
cur = self._tabs.currentIndex()
|
|
345
|
+
for offset in range(1, n + 1):
|
|
346
|
+
i = (cur - offset) % n
|
|
347
|
+
if self._tabs.isTabEnabled(i):
|
|
348
|
+
self._tabs.setCurrentIndex(i)
|
|
349
|
+
break
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------
|
|
352
|
+
# Pixel size
|
|
353
|
+
# ------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
def _apply_pixel_size(self) -> None:
|
|
356
|
+
for v in self._views:
|
|
357
|
+
if isinstance(v, SpatialView):
|
|
358
|
+
v.set_pixel_size(self._pixel_size, self._pixel_unit)
|
|
359
|
+
|
|
360
|
+
def _apply_colormap(self, name: str) -> None:
|
|
361
|
+
self._colormap = name
|
|
362
|
+
QSettings("npyquick", "npyquick").setValue("colormap", name)
|
|
363
|
+
for v in self._views:
|
|
364
|
+
if isinstance(v, ColormappedView):
|
|
365
|
+
v.set_colormap(name)
|
|
366
|
+
|
|
367
|
+
def _open_pixel_size_dialog(self) -> None:
|
|
368
|
+
dlg = PixelSizeDialog(self._pixel_expr, self._pixel_unit, parent=self)
|
|
369
|
+
if dlg.exec():
|
|
370
|
+
self._pixel_size = dlg.result_value
|
|
371
|
+
self._pixel_unit = dlg.result_unit
|
|
372
|
+
self._pixel_expr = dlg.result_expr
|
|
373
|
+
self._apply_pixel_size()
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------------
|
|
376
|
+
# Drag and drop
|
|
377
|
+
# ------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
def _has_data(self) -> bool:
|
|
380
|
+
"""Single source of truth for whether plots can be exported."""
|
|
381
|
+
return self._model.array is not None
|
|
382
|
+
|
|
383
|
+
def _rebuild_export_menu(self) -> None:
|
|
384
|
+
for a in self._export_actions:
|
|
385
|
+
self._file_menu.removeAction(a)
|
|
386
|
+
self._export_actions.clear()
|
|
387
|
+
|
|
388
|
+
sep = self._file_menu.insertSeparator(self._quit_action)
|
|
389
|
+
self._export_actions.append(sep)
|
|
390
|
+
|
|
391
|
+
targets = (
|
|
392
|
+
self._views[self._tabs.currentIndex()].export_targets()
|
|
393
|
+
if self._has_data() else []
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not targets:
|
|
397
|
+
a = QAction("Export Plot", self)
|
|
398
|
+
a.setEnabled(False)
|
|
399
|
+
self._file_menu.insertAction(self._quit_action, a)
|
|
400
|
+
self._export_actions.append(a)
|
|
401
|
+
elif len(targets) == 1:
|
|
402
|
+
_, fn = targets[0]
|
|
403
|
+
a = QAction("Export Plot…", self)
|
|
404
|
+
a.triggered.connect(fn)
|
|
405
|
+
self._file_menu.insertAction(self._quit_action, a)
|
|
406
|
+
self._export_actions.append(a)
|
|
407
|
+
else:
|
|
408
|
+
from PySide6.QtWidgets import QMenu
|
|
409
|
+
sub = QMenu("Export Plot", self)
|
|
410
|
+
for name, fn in targets:
|
|
411
|
+
sub.addAction(f"{name}…").triggered.connect(fn)
|
|
412
|
+
a = self._file_menu.insertMenu(self._quit_action, sub)
|
|
413
|
+
self._export_actions.append(a)
|
|
414
|
+
|
|
415
|
+
def _focused_canvas(self) -> ExportableMixin | None:
|
|
416
|
+
w = QApplication.focusWidget()
|
|
417
|
+
while w is not None:
|
|
418
|
+
if isinstance(w, ExportableMixin):
|
|
419
|
+
return w
|
|
420
|
+
w = w.parentWidget()
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
def _copy_focused_plot(self) -> None:
|
|
424
|
+
"""Ctrl+C copies the focused canvas; hints if nothing is exportable."""
|
|
425
|
+
if not self._has_data():
|
|
426
|
+
self._sb.showMessage("No plot loaded — open a file first", 2500)
|
|
427
|
+
return
|
|
428
|
+
canvas = self._focused_canvas()
|
|
429
|
+
if canvas is None:
|
|
430
|
+
self._sb.showMessage("Click a plot first, then press Ctrl+C to copy", 2500)
|
|
431
|
+
return
|
|
432
|
+
canvas._copy_to_clipboard()
|
|
433
|
+
|
|
434
|
+
def _export_focused_plot(self) -> None:
|
|
435
|
+
"""Ctrl+S exports the focused canvas; hints if nothing is exportable."""
|
|
436
|
+
if not self._has_data():
|
|
437
|
+
self._sb.showMessage("No plot loaded — open a file first", 2500)
|
|
438
|
+
return
|
|
439
|
+
canvas = self._focused_canvas()
|
|
440
|
+
if canvas is None:
|
|
441
|
+
self._sb.showMessage("Click a plot first, then press Ctrl+S to export", 2500)
|
|
442
|
+
return
|
|
443
|
+
canvas._export_figure()
|
|
444
|
+
|
|
445
|
+
def dragEnterEvent(self, ev) -> None:
|
|
446
|
+
urls = ev.mimeData().urls()
|
|
447
|
+
if urls and all(QUrl.toLocalFile(u).endswith((".npy", ".npz")) for u in urls):
|
|
448
|
+
ev.acceptProposedAction()
|
|
449
|
+
|
|
450
|
+
def dropEvent(self, ev) -> None:
|
|
451
|
+
path = QUrl.toLocalFile(ev.mimeData().urls()[0])
|
|
452
|
+
self.load_file(path)
|
|
453
|
+
|
|
454
|
+
def closeEvent(self, ev) -> None:
|
|
455
|
+
QSettings("npyquick", "npyquick").setValue("geometry", self.saveGeometry())
|
|
456
|
+
super().closeEvent(ev)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
npyquick/core/coord.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class PixelTransform:
|
|
12
|
+
"""Bundles pixel size and physical unit; provides coord conversions.
|
|
13
|
+
|
|
14
|
+
Image data is indexed in pixel coordinates (integers 0..h-1, 0..w-1).
|
|
15
|
+
Display happens in physical coordinates (pixel × pixel_size). A
|
|
16
|
+
transform with pixel_size=1.0 makes physical and pixel coords identical.
|
|
17
|
+
"""
|
|
18
|
+
pixel_size: float = 1.0
|
|
19
|
+
unit: str = "None"
|
|
20
|
+
|
|
21
|
+
def extent(self, h: int, w: int) -> list[float]:
|
|
22
|
+
"""Return matplotlib imshow extent in physical coords."""
|
|
23
|
+
ps = self.pixel_size
|
|
24
|
+
return [-0.5 * ps, (w - 0.5) * ps, (h - 0.5) * ps, -0.5 * ps]
|
|
25
|
+
|
|
26
|
+
def to_pixel(self, physical):
|
|
27
|
+
"""Convert physical coord(s) to pixel coord(s)."""
|
|
28
|
+
return np.asarray(physical) / self.pixel_size
|
|
29
|
+
|
|
30
|
+
def to_physical(self, pixel):
|
|
31
|
+
"""Convert pixel coord(s) to physical coord(s)."""
|
|
32
|
+
return np.asarray(pixel) * self.pixel_size
|
|
33
|
+
|
|
34
|
+
def clamp_x_physical(self, x: float, w: int) -> float:
|
|
35
|
+
"""Clamp physical x to [-0.5*ps, (w-0.5)*ps]."""
|
|
36
|
+
ps = self.pixel_size
|
|
37
|
+
return float(np.clip(x, -0.5 * ps, (w - 0.5) * ps))
|
|
38
|
+
|
|
39
|
+
def clamp_y_physical(self, y: float, h: int) -> float:
|
|
40
|
+
"""Clamp physical y to [-0.5*ps, (h-0.5)*ps]."""
|
|
41
|
+
ps = self.pixel_size
|
|
42
|
+
return float(np.clip(y, -0.5 * ps, (h - 0.5) * ps))
|
|
43
|
+
|
|
44
|
+
def format_unit(self) -> str:
|
|
45
|
+
"""Return unit for display ('' if 'None')."""
|
|
46
|
+
return "" if self.unit == "None" else self.unit
|
npyquick/core/limits.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import ceil
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
# Single definition of "large", based on the in-memory byte size of an array.
|
|
10
|
+
LARGE_BYTES = 512 * 1024**2 # 512 MiB
|
|
11
|
+
|
|
12
|
+
# npz members cannot be memory-mapped, so a selected member must fully
|
|
13
|
+
# materialize into RAM; refuse anything above this hard ceiling.
|
|
14
|
+
NPZ_MEMBER_CEILING = 2 * 1024**3 # 2 GiB
|
|
15
|
+
|
|
16
|
+
# Rendering/compute budgets. These are independent of LARGE_BYTES: I/O cost
|
|
17
|
+
# scales with bytes (mmap / npz ceiling decisions use LARGE_BYTES), while
|
|
18
|
+
# rendering and statistics cost scales with element count. Both budgets are set
|
|
19
|
+
# to 4096^2, so realistic images (<=4096x4096, up to 384 MB RGB float64) render
|
|
20
|
+
# and are summarized at full resolution; only genuinely huge arrays downsample.
|
|
21
|
+
IMAGE_MAX_PIXELS = 16_777_216 # 4096 * 4096 spatial pixels (channels excluded)
|
|
22
|
+
HIST_MAX_SAMPLES = 16_777_216 # 4096 * 4096 flattened samples
|
|
23
|
+
LINEPLOT_MAX_POINTS = 1_000_000 # max display points for interactive line plot
|
|
24
|
+
TABLE_MAX_PER_AXIS = 2_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def array_nbytes(shape, dtype) -> int:
|
|
28
|
+
"""Bytes an array of this shape/dtype would occupy, from header metadata."""
|
|
29
|
+
count = int(np.prod(shape, dtype=np.int64))
|
|
30
|
+
return count * np.dtype(dtype).itemsize
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stride_for(n_total: int, budget: int) -> int:
|
|
34
|
+
"""Per-axis stride for 2D downsampling so kept pixels stay near budget."""
|
|
35
|
+
return max(1, ceil((n_total / budget) ** 0.5))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def downsample_stride(n_total: int, budget: int) -> int:
|
|
39
|
+
"""Stride for 1D (flattened) sampling so kept samples stay within budget."""
|
|
40
|
+
return max(1, ceil(n_total / budget))
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.lib import format as _npy_format
|
|
10
|
+
|
|
11
|
+
from .limits import array_nbytes
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class MemberMeta:
|
|
16
|
+
shape: tuple[int, ...]
|
|
17
|
+
dtype: np.dtype
|
|
18
|
+
nbytes: int
|
|
19
|
+
compressed: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_npy_header(fp) -> tuple[tuple[int, ...], np.dtype]:
|
|
23
|
+
"""Read shape/dtype from a file-like positioned at the start of a .npy stream.
|
|
24
|
+
|
|
25
|
+
Works for a bare .npy file or a .npy member opened from inside a zip.
|
|
26
|
+
Only the header is consumed; the array body is never read.
|
|
27
|
+
"""
|
|
28
|
+
major, _minor = _npy_format.read_magic(fp)
|
|
29
|
+
if major == 1:
|
|
30
|
+
shape, _fortran, dtype = _npy_format.read_array_header_1_0(fp)
|
|
31
|
+
else:
|
|
32
|
+
# 2.0 and 3.0 share the same 4-byte header-length layout.
|
|
33
|
+
shape, _fortran, dtype = _npy_format.read_array_header_2_0(fp)
|
|
34
|
+
return shape, dtype
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def peek_npy(path: str) -> MemberMeta:
|
|
38
|
+
"""Read a .npy file's header only and return its metadata."""
|
|
39
|
+
with open(path, "rb") as fp:
|
|
40
|
+
shape, dtype = read_npy_header(fp)
|
|
41
|
+
return MemberMeta(shape, dtype, array_nbytes(shape, dtype), compressed=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def peek_npz(path: str) -> dict[str, MemberMeta]:
|
|
45
|
+
"""Map each .npz member to its metadata without materializing array bodies.
|
|
46
|
+
|
|
47
|
+
Reuses NumPy's own key list so the returned keys match ``f[key]`` exactly;
|
|
48
|
+
for compressed members reading the header still decompresses a small leading
|
|
49
|
+
chunk of the stream (non-zero cost, not a memmap).
|
|
50
|
+
"""
|
|
51
|
+
metas: dict[str, MemberMeta] = {}
|
|
52
|
+
with np.load(path, allow_pickle=False) as f:
|
|
53
|
+
zf: zipfile.ZipFile = f.zip
|
|
54
|
+
for key in f.files:
|
|
55
|
+
member = key + ".npy"
|
|
56
|
+
zinfo = zf.getinfo(member)
|
|
57
|
+
compressed = zinfo.compress_type != zipfile.ZIP_STORED
|
|
58
|
+
with zf.open(member) as stream:
|
|
59
|
+
shape, dtype = read_npy_header(stream)
|
|
60
|
+
metas[key] = MemberMeta(
|
|
61
|
+
shape, dtype, array_nbytes(shape, dtype), compressed
|
|
62
|
+
)
|
|
63
|
+
return metas
|
npyquick/core/profile.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import ndimage
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_profile(
|
|
10
|
+
array: np.ndarray,
|
|
11
|
+
p0_px: np.ndarray,
|
|
12
|
+
p1_px: np.ndarray,
|
|
13
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
14
|
+
"""Sample a cross-section profile along a line through an array.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
array: 2D (H, W) or 3D (H, W, C) image.
|
|
18
|
+
p0_px, p1_px: endpoint pixel coordinates, each shape (2,) as [x, y].
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
(distances, values):
|
|
22
|
+
distances: shape (N,), pixel distances from p0 to each sample.
|
|
23
|
+
values: shape (N,) for 2D, shape (C, N) for 3D (C = min(array.shape[2], 3)).
|
|
24
|
+
"""
|
|
25
|
+
p0 = np.asarray(p0_px, dtype=float)
|
|
26
|
+
p1 = np.asarray(p1_px, dtype=float)
|
|
27
|
+
diff = p1 - p0
|
|
28
|
+
n = max(2, int(np.hypot(*diff)) + 1)
|
|
29
|
+
h, w = array.shape[:2]
|
|
30
|
+
xs = np.clip(np.linspace(p0[0], p1[0], n), 0, w - 1)
|
|
31
|
+
ys = np.clip(np.linspace(p0[1], p1[1], n), 0, h - 1)
|
|
32
|
+
dists = np.linspace(0.0, float(np.hypot(*diff)), n)
|
|
33
|
+
|
|
34
|
+
# output=float makes map_coordinates interpolate integer inputs correctly
|
|
35
|
+
# (otherwise an integer output dtype truncates each sample) without first
|
|
36
|
+
# materializing a full float copy — the caller may pass a native-dtype
|
|
37
|
+
# display array. order=1 needs no spline prefilter.
|
|
38
|
+
if array.ndim == 3:
|
|
39
|
+
n_ch = min(array.shape[2], 3)
|
|
40
|
+
values = np.stack([
|
|
41
|
+
ndimage.map_coordinates(array[:, :, c], [ys, xs], order=1, output=float)
|
|
42
|
+
for c in range(n_ch)
|
|
43
|
+
])
|
|
44
|
+
else:
|
|
45
|
+
values = ndimage.map_coordinates(array, [ys, xs], order=1, output=float)
|
|
46
|
+
|
|
47
|
+
return dists, values
|