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/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))
|