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 ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 LiukDiihMieu
3
+
4
+ __version__ = "0.1.0"
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
@@ -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
@@ -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