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/core/stats.py ADDED
@@ -0,0 +1,90 @@
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
+ from . import limits
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ArrayStats:
14
+ finite_min: float | None # None when no finite values exist
15
+ finite_max: float | None
16
+ nan_count: int
17
+ pos_inf_count: int
18
+ neg_inf_count: int
19
+ sampled: bool = False # computed from a subsample of a large array
20
+
21
+ @property
22
+ def has_anomaly(self) -> bool:
23
+ return self.nan_count > 0 or self.pos_inf_count > 0 or self.neg_inf_count > 0
24
+
25
+ def anomaly_str(self) -> str:
26
+ """Compact anomaly summary, e.g. 'NaN: 3 +Inf: 1'.
27
+
28
+ For sampled stats, exact counts would be misleading (sparse anomalies
29
+ may be missed or over-represented), so only a qualitative note is given.
30
+ """
31
+ if self.sampled:
32
+ return "NaN/Inf present in sample" if self.has_anomaly else "no anomaly in sample"
33
+ parts = []
34
+ if self.nan_count:
35
+ parts.append(f"NaN: {self.nan_count}")
36
+ if self.pos_inf_count:
37
+ parts.append(f"+Inf: {self.pos_inf_count}")
38
+ if self.neg_inf_count:
39
+ parts.append(f"-Inf: {self.neg_inf_count}")
40
+ return " ".join(parts)
41
+
42
+ def range_str(self) -> str:
43
+ """Return 'finite range [a, b]', plain 'range [a, b]', or 'no finite values'."""
44
+ if self.finite_min is None:
45
+ return "no finite values"
46
+ prefix = "finite range" if self.has_anomaly else "range"
47
+ approx = " (approx)" if self.sampled else ""
48
+ return f"{prefix} [{self.finite_min:.4g}, {self.finite_max:.4g}]{approx}"
49
+
50
+
51
+ def is_real_numeric(array: np.ndarray) -> bool:
52
+ """True for real integer and floating dtypes; False for complex and non-numeric."""
53
+ return (
54
+ np.issubdtype(array.dtype, np.number)
55
+ and not np.issubdtype(array.dtype, np.complexfloating)
56
+ )
57
+
58
+
59
+ def array_stats(array: np.ndarray) -> ArrayStats | None:
60
+ """Compute finite range and anomaly counts for numeric arrays.
61
+
62
+ Returns None for non-numeric, complex, or empty arrays.
63
+ Integer arrays cannot contain NaN/Inf, so anomaly counts are always 0.
64
+ Arrays whose element count exceeds HIST_MAX_SAMPLES are subsampled
65
+ (range/counts become approximate, ``sampled=True``) to avoid materializing a
66
+ full finite mask. This is a compute budget independent of the byte-based I/O
67
+ threshold; downsample_stride returns 1 when within budget.
68
+ """
69
+ if not is_real_numeric(array) or array.size == 0:
70
+ return None
71
+
72
+ flat = array.reshape(-1)
73
+ stride = limits.downsample_stride(flat.size, limits.HIST_MAX_SAMPLES)
74
+ sampled = stride > 1
75
+ sample = np.asarray(flat[::stride]) if sampled else array
76
+
77
+ if np.issubdtype(array.dtype, np.integer):
78
+ lo, hi = float(sample.min()), float(sample.max())
79
+ return ArrayStats(lo, hi, 0, 0, 0, sampled)
80
+
81
+ nan_count = int(np.sum(np.isnan(sample)))
82
+ pos_inf_count = int(np.sum(np.isposinf(sample)))
83
+ neg_inf_count = int(np.sum(np.isneginf(sample)))
84
+ finite = sample[np.isfinite(sample)]
85
+ if finite.size == 0:
86
+ return ArrayStats(None, None, nan_count, pos_inf_count, neg_inf_count, sampled)
87
+ return ArrayStats(
88
+ float(finite.min()), float(finite.max()),
89
+ nan_count, pos_inf_count, neg_inf_count, sampled,
90
+ )
npyquick/main.py ADDED
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 LiukDiihMieu
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+
9
+ from PySide6.QtWidgets import QApplication
10
+
11
+ from npyquick.app import MainWindow
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(
16
+ description="npyquick — a fast, lightweight viewer for NumPy arrays (.npy / .npz)"
17
+ )
18
+ parser.add_argument(
19
+ "file", nargs="?", help="Path to a .npy or .npz file to open on launch"
20
+ )
21
+ args, qt_argv = parser.parse_known_args()
22
+
23
+ app = QApplication([sys.argv[0]] + qt_argv)
24
+ app.setApplicationName("npyquick")
25
+
26
+ window = MainWindow()
27
+ window.show()
28
+
29
+ if args.file:
30
+ window.load_file(args.file)
31
+
32
+ sys.exit(app.exec())
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
npyquick/model.py ADDED
@@ -0,0 +1,84 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+ from .core import limits
8
+ from .core.npyheader import MemberMeta, peek_npy, peek_npz
9
+
10
+
11
+ class NpyDataModel:
12
+ def __init__(self) -> None:
13
+ self.array: np.ndarray | None = None
14
+ self.path: str = ""
15
+ self._is_npz = False
16
+ self._metas: dict[str, MemberMeta] = {}
17
+ self._selected_key: str = ""
18
+
19
+ def load(self, path: str) -> None:
20
+ if path.endswith(".npz"):
21
+ metas = peek_npz(path)
22
+ if not metas:
23
+ raise ValueError("NPZ archive contains no arrays")
24
+ # Deliberately do NOT materialize any member here: the first member
25
+ # may be huge (or even over NPZ_MEMBER_CEILING). Caller discovers
26
+ # available arrays via available_array_meta(), then calls
27
+ # select_array() to materialize the chosen one.
28
+ self._is_npz = True
29
+ self._metas = metas
30
+ self._selected_key = ""
31
+ self.array = None
32
+ self.path = path
33
+ return
34
+
35
+ meta = peek_npy(path)
36
+ # Materialize up front so a bad file raises before we commit state.
37
+ array = self._materialize(path, False, "", meta)
38
+
39
+ self._is_npz = False
40
+ self._metas = {"": meta}
41
+ self._selected_key = ""
42
+ self.array = array
43
+ self.path = path
44
+
45
+ def _materialize(
46
+ self, path: str, is_npz: bool, key: str, meta: MemberMeta
47
+ ) -> np.ndarray:
48
+ if is_npz:
49
+ if meta.nbytes > limits.NPZ_MEMBER_CEILING:
50
+ raise ValueError(
51
+ f"Array '{key}' is {meta.nbytes / 1024**3:.1f} GiB, exceeding the "
52
+ f"{limits.NPZ_MEMBER_CEILING / 1024**3:.0f} GiB limit for .npz "
53
+ f"members (they cannot be memory-mapped)."
54
+ )
55
+ with np.load(path, allow_pickle=False) as f:
56
+ return f[key]
57
+ return self._load_npy(path, meta)
58
+
59
+ @staticmethod
60
+ def _load_npy(path: str, meta: MemberMeta) -> np.ndarray:
61
+ # 0-d arrays are always tiny and pointless to map; everything else,
62
+ # including structured dtypes, is memory-mappable and should be mapped
63
+ # when large. (Structured arrays load fine here but only the table view
64
+ # handles them — image/histogram/stats gate on is_real_numeric.)
65
+ zero_d = meta.shape == ()
66
+ if zero_d or meta.nbytes <= limits.LARGE_BYTES:
67
+ return np.load(path, allow_pickle=False)
68
+ # Large array: memory-map. Do NOT silently fall back to a full load on
69
+ # failure — that would defeat the protection and risk OOM.
70
+ try:
71
+ return np.load(path, mmap_mode="r", allow_pickle=False)
72
+ except (ValueError, OSError) as exc:
73
+ raise RuntimeError(
74
+ f"Array is {meta.nbytes / 1024**3:.1f} GiB and could not be "
75
+ f"memory-mapped: {exc}"
76
+ ) from exc
77
+
78
+ def available_array_meta(self) -> dict[str, MemberMeta]:
79
+ return dict(self._metas)
80
+
81
+ def select_array(self, name: str) -> None:
82
+ meta = self._metas[name]
83
+ self.array = self._materialize(self.path, self._is_npz, name, meta)
84
+ self._selected_key = name
@@ -0,0 +1 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
npyquick/views/base.py ADDED
@@ -0,0 +1,117 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ import re
8
+ from typing import TYPE_CHECKING
9
+
10
+ import numpy as np
11
+ from PySide6.QtCore import Qt, QSettings
12
+ from PySide6.QtGui import QImage
13
+ from PySide6.QtWidgets import (
14
+ QApplication, QFileDialog, QMainWindow, QMenu, QWidget,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from ..core.stats import ArrayStats
19
+
20
+
21
+ class ExportableMixin:
22
+ """Right-click + Ctrl+C / Ctrl+S export for any FigureCanvas subclass."""
23
+ panel_name: str = "Figure"
24
+
25
+ def __init__(self, *args, **kwargs) -> None:
26
+ super().__init__(*args, **kwargs)
27
+ # matplotlib canvases default to NoFocus; ClickFocus lets a click mark
28
+ # this panel as the Ctrl+C / Ctrl+S target (resolved via focusWidget).
29
+ self.setFocusPolicy(Qt.ClickFocus)
30
+
31
+ def _show_status(self, msg: str, timeout: int = 2000) -> None:
32
+ win = self.window()
33
+ if isinstance(win, QMainWindow):
34
+ win.statusBar().showMessage(msg, timeout)
35
+
36
+ def _exports_allowed(self) -> bool:
37
+ # Duck-typed so the mixin stays decoupled from MainWindow (and works in
38
+ # standalone tests where the parent has no _has_data method).
39
+ check = getattr(self.window(), "_has_data", None)
40
+ return bool(check()) if callable(check) else True
41
+
42
+ def contextMenuEvent(self, ev) -> None:
43
+ if not self._exports_allowed():
44
+ return
45
+ menu = QMenu(self)
46
+ menu.addAction("Export this plot…", self._export_figure)
47
+ menu.addAction("Copy to clipboard", self._copy_to_clipboard)
48
+ menu.exec(ev.globalPos())
49
+
50
+ def _copy_to_clipboard(self) -> None:
51
+ buf = io.BytesIO()
52
+ self.figure.savefig(buf, format="png", dpi=300, bbox_inches="tight")
53
+ buf.seek(0)
54
+ QApplication.clipboard().setImage(QImage.fromData(buf.getvalue()))
55
+ self._show_status(f"{self.panel_name} copied to clipboard")
56
+
57
+ def _export_figure(self) -> None:
58
+ s = QSettings("npyquick", "npyquick")
59
+ start = s.value("last_export_dir") or s.value("last_dir", "")
60
+ # Seed the dialog with a panel-named default (no extension) so it is
61
+ # clear which plot is being saved without locking in a format — the
62
+ # chosen filter's extension is appended below at save time.
63
+ default_name = self.panel_name.replace(" ", "_")
64
+ path, selected_filter = QFileDialog.getSaveFileName(
65
+ self, f"Export {self.panel_name}",
66
+ os.path.join(start, default_name) if start else default_name,
67
+ "PNG (*.png);;SVG (*.svg);;PDF (*.pdf)",
68
+ )
69
+ if not path:
70
+ return
71
+ # Qt does not auto-append the extension — extract it from the chosen filter.
72
+ m = re.search(r'\*(\.\w+)', selected_filter)
73
+ if m:
74
+ ext = m.group(1).lower()
75
+ if not path.lower().endswith(ext):
76
+ path += ext
77
+ s.setValue("last_export_dir", os.path.dirname(os.path.abspath(path)))
78
+ self.figure.savefig(path, dpi=300, bbox_inches="tight")
79
+ self._show_status(f"{self.panel_name} saved to {os.path.basename(path)}", 3000)
80
+
81
+
82
+ class SpatialView:
83
+ """Mixin: view supports physical pixel-size scaling."""
84
+ def set_pixel_size(self, ps: float, unit: str) -> None:
85
+ raise NotImplementedError
86
+
87
+
88
+ class ColormappedView:
89
+ """Mixin: view supports matplotlib colormap selection."""
90
+ def set_colormap(self, name: str) -> None:
91
+ raise NotImplementedError
92
+
93
+
94
+ class BaseView(QWidget):
95
+ VIEW_ID: str = ""
96
+ VIEW_NAME: str = ""
97
+
98
+ def __init__(self) -> None:
99
+ super().__init__()
100
+ self._on_status: callable = lambda _: None
101
+
102
+ def set_on_status(self, cb: callable) -> None:
103
+ self._on_status = cb
104
+
105
+ def refresh_status(self) -> None:
106
+ """Push the view's current status to the status bar. Called on tab switch."""
107
+
108
+ @classmethod
109
+ def can_handle(cls, array: np.ndarray) -> bool:
110
+ raise NotImplementedError
111
+
112
+ def set_data(self, array: np.ndarray, stats: ArrayStats | None = None) -> None:
113
+ raise NotImplementedError
114
+
115
+ def export_targets(self) -> list[tuple[str, callable]]:
116
+ """Return [(panel_name, export_fn), …] for File › Export Plot menu."""
117
+ return []
@@ -0,0 +1,333 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
7
+ from matplotlib.figure import Figure
8
+ from PySide6.QtCore import Qt, QSettings
9
+ from PySide6.QtWidgets import (
10
+ QCheckBox, QComboBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
11
+ )
12
+
13
+ from ..core import limits
14
+ from ..core.stats import ArrayStats, array_stats, is_real_numeric
15
+ from .base import BaseView, ExportableMixin
16
+
17
+ _BIN_OPTIONS = ["auto", "64", "128", "256", "512"]
18
+
19
+
20
+ def finite_sample(array: np.ndarray) -> tuple[np.ndarray, int, int]:
21
+ """Return (finite_values, n_total, n_used) for histogram and statistics.
22
+
23
+ Subsampling is driven by element count against HIST_MAX_SAMPLES (a compute
24
+ budget independent of the byte-based I/O threshold), applied before the
25
+ finite mask so a memmap is never fully read. downsample_stride returns 1
26
+ when within budget. n_used is the sample size before masking.
27
+ """
28
+ flat = array.reshape(-1)
29
+ n_total = flat.size
30
+ stride = limits.downsample_stride(n_total, limits.HIST_MAX_SAMPLES)
31
+ sample = np.asarray(flat[::stride])
32
+ n_used = sample.size
33
+ if np.issubdtype(array.dtype, np.inexact):
34
+ finite = sample[np.isfinite(sample)]
35
+ else:
36
+ finite = sample
37
+ return finite, n_total, n_used
38
+
39
+
40
+ class HistogramCanvas(ExportableMixin, FigureCanvas):
41
+ panel_name = "Histogram"
42
+ def __init__(self) -> None:
43
+ self._fig = Figure(constrained_layout=True)
44
+ super().__init__(self._fig)
45
+ self._ax = self._fig.add_subplot(111)
46
+ self._on_status: callable = lambda _: None
47
+ self._idle_status: str = ""
48
+ self._n_bins: int | str = "auto"
49
+ self._log: bool = False
50
+ self._array: np.ndarray | None = None
51
+ self._finite: np.ndarray | None = None
52
+ self._n_total: int = 0
53
+ self._n_used: int = 0
54
+ self._edges: np.ndarray | None = None
55
+ self._counts: np.ndarray | None = None
56
+ self._clim: tuple[float, float] | None = None
57
+ self._vline_lo = None
58
+ self._vline_hi = None
59
+ self._vtext_lo = None
60
+ self._vtext_hi = None
61
+
62
+ self.mpl_connect("button_press_event", self._on_press)
63
+ self.mpl_connect("motion_notify_event", self._on_motion)
64
+ self.mpl_connect("axes_leave_event", self._on_axes_leave)
65
+ self.mpl_connect("scroll_event", self._on_scroll)
66
+
67
+ def set_on_status(self, cb: callable) -> None:
68
+ self._on_status = cb
69
+
70
+ def set_idle_status(self, s: str) -> None:
71
+ self._idle_status = s
72
+
73
+ def plot(self, array: np.ndarray) -> None:
74
+ self._array = array
75
+ finite, self._n_total, self._n_used = finite_sample(array)
76
+ self._finite = finite
77
+ self._render()
78
+
79
+ def _render(self) -> None:
80
+ """Redraw bars from the cached _finite sample (no re-sampling)."""
81
+ self._ax.cla()
82
+ self._ax.set_xlabel("Value")
83
+ self._ax.set_ylabel("Count")
84
+ self._edges = None
85
+ self._counts = None
86
+ self._vline_lo = None
87
+ self._vline_hi = None
88
+ self._vtext_lo = None
89
+ self._vtext_hi = None
90
+ finite = self._finite
91
+
92
+ if finite is None or finite.size == 0:
93
+ self._ax.text(
94
+ 0.5, 0.5, "No finite values",
95
+ transform=self._ax.transAxes, ha="center", va="center",
96
+ color="gray", fontsize=12,
97
+ )
98
+ else:
99
+ bins = self._n_bins if self._n_bins == "auto" else int(self._n_bins)
100
+ counts, edges = np.histogram(finite, bins=bins)
101
+ self._counts = counts
102
+ self._edges = edges
103
+ self._ax.bar(
104
+ edges[:-1], counts, width=np.diff(edges), align="edge",
105
+ color="steelblue", edgecolor="none",
106
+ )
107
+ if self._clim is not None:
108
+ self._draw_clim_markers(*self._clim)
109
+
110
+ self._ax.set_yscale("log" if self._log else "linear")
111
+ self.draw_idle()
112
+
113
+ def _draw_clim_markers(self, vmin: float, vmax: float) -> None:
114
+ self._vline_lo = self._ax.axvline(
115
+ vmin, color="tomato", lw=1.5, ls="--", alpha=0.85, zorder=5
116
+ )
117
+ self._vline_hi = self._ax.axvline(
118
+ vmax, color="tomato", lw=1.5, ls="--", alpha=0.85, zorder=5
119
+ )
120
+ tr = self._ax.get_xaxis_transform()
121
+ self._vtext_lo = self._ax.text(
122
+ vmin, 0.98, "vmin", transform=tr,
123
+ ha="center", va="top", color="tomato", fontsize=7, clip_on=True,
124
+ )
125
+ self._vtext_lo.set_in_layout(False)
126
+ self._vtext_hi = self._ax.text(
127
+ vmax, 0.98, "vmax", transform=tr,
128
+ ha="center", va="top", color="tomato", fontsize=7, clip_on=True,
129
+ )
130
+ self._vtext_hi.set_in_layout(False)
131
+
132
+ def set_bins(self, n: int | str) -> None:
133
+ self._n_bins = n
134
+ if self._array is not None:
135
+ self._render() # re-bin the cached sample; no re-sampling
136
+
137
+ def set_log_scale(self, enable: bool) -> None:
138
+ self._log = enable
139
+ if self._array is not None:
140
+ # Only the y-axis scale changes — no need to re-sample or re-bin.
141
+ self._ax.set_yscale("log" if enable else "linear")
142
+ self.draw_idle()
143
+
144
+ def set_clim_marker(self, vmin: float | None, vmax: float | None) -> None:
145
+ """Store clim without redrawing — called before plot()."""
146
+ self._clim = (vmin, vmax) if vmin is not None and vmax is not None else None
147
+
148
+ def update_clim_marker(self, vmin: float | None, vmax: float | None) -> None:
149
+ """Live-update vlines on an already-rendered histogram."""
150
+ self._clim = (vmin, vmax) if vmin is not None and vmax is not None else None
151
+ for artist in (self._vline_lo, self._vline_hi, self._vtext_lo, self._vtext_hi):
152
+ if artist is not None:
153
+ artist.remove()
154
+ self._vline_lo = self._vline_hi = None
155
+ self._vtext_lo = self._vtext_hi = None
156
+ if self._clim is not None and self._edges is not None:
157
+ self._draw_clim_markers(*self._clim)
158
+ self.draw_idle()
159
+
160
+ def _on_press(self, ev) -> None:
161
+ if ev.dblclick and ev.inaxes is self._ax:
162
+ self.xlim_full()
163
+
164
+ def _on_motion(self, ev) -> None:
165
+ if ev.inaxes is not self._ax or self._edges is None or ev.xdata is None:
166
+ return
167
+ idx = min(int(np.searchsorted(self._edges[1:], ev.xdata)), len(self._counts) - 1)
168
+ lo, hi = self._edges[idx], self._edges[idx + 1]
169
+ self._on_status(f"bin [{lo:.4g}, {hi:.4g}) count: {self._counts[idx]}")
170
+
171
+ def _on_axes_leave(self, ev) -> None:
172
+ self._on_status(self._idle_status)
173
+
174
+ def _on_scroll(self, ev) -> None:
175
+ if ev.inaxes is not self._ax or self._edges is None:
176
+ return
177
+ factor = 0.8 if ev.step > 0 else 1.25
178
+ xc = ev.xdata
179
+ xl = self._ax.get_xlim()
180
+ self._ax.set_xlim(xc + (xl[0] - xc) * factor, xc + (xl[1] - xc) * factor)
181
+ self.draw_idle()
182
+
183
+ def xlim_full(self) -> None:
184
+ if self._edges is None:
185
+ return
186
+ self._ax.set_xlim(self._edges[0], self._edges[-1])
187
+ self.draw_idle()
188
+
189
+ def xlim_robust(self) -> None:
190
+ if self._finite is None or self._finite.size < 2:
191
+ return
192
+ lo, hi = np.percentile(self._finite, [2, 98])
193
+ if lo == hi:
194
+ delta = abs(lo) * 0.05 if lo != 0 else 0.5
195
+ lo, hi = lo - delta, hi + delta
196
+ self._ax.set_xlim(lo, hi)
197
+ self.draw_idle()
198
+
199
+
200
+ class HistogramView(BaseView):
201
+ VIEW_ID = "histogram"
202
+ VIEW_NAME = "Histogram"
203
+
204
+ def __init__(self) -> None:
205
+ super().__init__()
206
+ self._status: str = ""
207
+ self._canvas = HistogramCanvas()
208
+
209
+ _s = QSettings("npyquick", "npyquick")
210
+
211
+ self._bins_combo = QComboBox()
212
+ self._bins_combo.addItems(_BIN_OPTIONS)
213
+ self._bins_combo.setFixedWidth(80)
214
+ self._bins_combo.currentTextChanged.connect(self._on_bins_changed)
215
+ _saved_bins = _s.value("histogram_bins", "auto")
216
+ if _saved_bins in _BIN_OPTIONS:
217
+ self._bins_combo.setCurrentText(_saved_bins)
218
+
219
+ self._log_check = QCheckBox("Log scale")
220
+ self._log_check.toggled.connect(self._canvas.set_log_scale)
221
+ self._log_check.toggled.connect(
222
+ lambda checked: QSettings("npyquick", "npyquick").setValue("histogram_log", checked)
223
+ )
224
+ if _s.value("histogram_log", False, type=bool):
225
+ self._log_check.setChecked(True)
226
+
227
+ self._full_btn = QPushButton("Full")
228
+ self._full_btn.setFixedWidth(48)
229
+ self._full_btn.clicked.connect(self._canvas.xlim_full)
230
+
231
+ self._robust_btn = QPushButton("Robust")
232
+ self._robust_btn.setFixedWidth(56)
233
+ self._robust_btn.setToolTip("Set x-range to p2–p98")
234
+ self._robust_btn.clicked.connect(self._canvas.xlim_robust)
235
+
236
+ self._stats_label = QLabel()
237
+ self._stats_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
238
+
239
+ self._sample_label = QLabel()
240
+ self._sample_label.setStyleSheet("color: #b8860b;")
241
+ self._sample_label.setVisible(False)
242
+
243
+ self._anomaly_label = QLabel()
244
+ self._anomaly_label.setStyleSheet("color: red;")
245
+ self._anomaly_label.setVisible(False)
246
+
247
+ ctrl = QWidget()
248
+ ctrl_layout = QHBoxLayout(ctrl)
249
+ ctrl_layout.setContentsMargins(6, 3, 6, 3)
250
+ ctrl_layout.addWidget(QLabel("Bins:"))
251
+ ctrl_layout.addWidget(self._bins_combo)
252
+ ctrl_layout.addSpacing(12)
253
+ ctrl_layout.addWidget(self._log_check)
254
+ ctrl_layout.addSpacing(16)
255
+ ctrl_layout.addWidget(QLabel("X range:"))
256
+ ctrl_layout.addWidget(self._full_btn)
257
+ ctrl_layout.addWidget(self._robust_btn)
258
+ ctrl_layout.addStretch()
259
+ ctrl_layout.addWidget(self._stats_label)
260
+ ctrl_layout.addSpacing(8)
261
+ ctrl_layout.addWidget(self._sample_label)
262
+ ctrl_layout.addSpacing(8)
263
+ ctrl_layout.addWidget(self._anomaly_label)
264
+
265
+ layout = QVBoxLayout(self)
266
+ layout.setContentsMargins(0, 0, 0, 0)
267
+ layout.setSpacing(0)
268
+ layout.addWidget(ctrl)
269
+ layout.addWidget(self._canvas)
270
+
271
+ @classmethod
272
+ def can_handle(cls, array: np.ndarray) -> bool:
273
+ return is_real_numeric(array) and array.size > 0
274
+
275
+ def set_data(self, array: np.ndarray, stats: ArrayStats | None = None) -> None:
276
+ self._canvas.set_clim_marker(None, None) # reset; app.py syncs from ImageView
277
+ self._canvas.plot(array) # samples once; stores _finite / _n_total / _n_used
278
+
279
+ finite = self._canvas._finite
280
+ n_total = self._canvas._n_total
281
+ n_used = self._canvas._n_used
282
+
283
+ if finite is not None and finite.size > 0:
284
+ fmin, fmax = float(finite.min()), float(finite.max())
285
+ mean = float(np.mean(finite))
286
+ std = float(np.std(finite))
287
+ p1, p50, p99 = (float(v) for v in np.percentile(finite, [1, 50, 99]))
288
+ stats_str = (
289
+ f"min {fmin:.4g} max {fmax:.4g}"
290
+ f" mean {mean:.4g} std {std:.4g}"
291
+ f" | p1 {p1:.4g} p50 {p50:.4g} p99 {p99:.4g}"
292
+ )
293
+ else:
294
+ stats_str = "no finite values"
295
+ self._stats_label.setText(stats_str)
296
+
297
+ sampled = n_used < n_total
298
+ if sampled:
299
+ self._sample_label.setText(f"sampled {n_used:,} / {n_total:,}")
300
+ self._sample_label.setVisible(True)
301
+ else:
302
+ self._sample_label.setVisible(False)
303
+
304
+ self._status = f"shape {array.shape} dtype {array.dtype} | {stats_str}"
305
+ if sampled:
306
+ self._status += " (sampled)"
307
+
308
+ if stats is None:
309
+ stats = array_stats(array)
310
+ if stats is not None and stats.has_anomaly:
311
+ self._anomaly_label.setText(stats.anomaly_str())
312
+ self._anomaly_label.setVisible(True)
313
+ else:
314
+ self._anomaly_label.setVisible(False)
315
+
316
+ self._canvas.set_idle_status(self._status)
317
+
318
+ def update_clim_marker(self, vmin: float | None, vmax: float | None) -> None:
319
+ self._canvas.update_clim_marker(vmin, vmax)
320
+
321
+ def refresh_status(self) -> None:
322
+ self._on_status(self._status)
323
+
324
+ def export_targets(self):
325
+ return [("Histogram", self._canvas._export_figure)]
326
+
327
+ def set_on_status(self, cb: callable) -> None:
328
+ super().set_on_status(cb)
329
+ self._canvas.set_on_status(cb)
330
+
331
+ def _on_bins_changed(self, text: str) -> None:
332
+ QSettings("npyquick", "npyquick").setValue("histogram_bins", text)
333
+ self._canvas.set_bins(text if text == "auto" else int(text))