canns 0.13.1__py3-none-any.whl → 0.14.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.
- canns/analyzer/data/__init__.py +5 -1
- canns/analyzer/data/asa/__init__.py +27 -12
- canns/analyzer/data/asa/cohospace.py +336 -10
- canns/analyzer/data/asa/config.py +3 -0
- canns/analyzer/data/asa/embedding.py +48 -45
- canns/analyzer/data/asa/path.py +104 -2
- canns/analyzer/data/asa/plotting.py +88 -19
- canns/analyzer/data/asa/tda.py +11 -4
- canns/analyzer/data/cell_classification/__init__.py +97 -0
- canns/analyzer/data/cell_classification/core/__init__.py +26 -0
- canns/analyzer/data/cell_classification/core/grid_cells.py +633 -0
- canns/analyzer/data/cell_classification/core/grid_modules_leiden.py +288 -0
- canns/analyzer/data/cell_classification/core/head_direction.py +347 -0
- canns/analyzer/data/cell_classification/core/spatial_analysis.py +431 -0
- canns/analyzer/data/cell_classification/io/__init__.py +5 -0
- canns/analyzer/data/cell_classification/io/matlab_loader.py +417 -0
- canns/analyzer/data/cell_classification/utils/__init__.py +39 -0
- canns/analyzer/data/cell_classification/utils/circular_stats.py +383 -0
- canns/analyzer/data/cell_classification/utils/correlation.py +318 -0
- canns/analyzer/data/cell_classification/utils/geometry.py +442 -0
- canns/analyzer/data/cell_classification/utils/image_processing.py +416 -0
- canns/analyzer/data/cell_classification/visualization/__init__.py +19 -0
- canns/analyzer/data/cell_classification/visualization/grid_plots.py +292 -0
- canns/analyzer/data/cell_classification/visualization/hd_plots.py +200 -0
- canns/analyzer/metrics/__init__.py +2 -1
- canns/analyzer/visualization/core/config.py +46 -4
- canns/data/__init__.py +6 -1
- canns/data/datasets.py +154 -1
- canns/data/loaders.py +37 -0
- canns/pipeline/__init__.py +13 -9
- canns/pipeline/__main__.py +6 -0
- canns/pipeline/asa/runner.py +105 -41
- canns/pipeline/asa_gui/__init__.py +68 -0
- canns/pipeline/asa_gui/__main__.py +6 -0
- canns/pipeline/asa_gui/analysis_modes/__init__.py +42 -0
- canns/pipeline/asa_gui/analysis_modes/base.py +39 -0
- canns/pipeline/asa_gui/analysis_modes/batch_mode.py +21 -0
- canns/pipeline/asa_gui/analysis_modes/cohomap_mode.py +56 -0
- canns/pipeline/asa_gui/analysis_modes/cohospace_mode.py +194 -0
- canns/pipeline/asa_gui/analysis_modes/decode_mode.py +52 -0
- canns/pipeline/asa_gui/analysis_modes/fr_mode.py +81 -0
- canns/pipeline/asa_gui/analysis_modes/frm_mode.py +92 -0
- canns/pipeline/asa_gui/analysis_modes/gridscore_mode.py +123 -0
- canns/pipeline/asa_gui/analysis_modes/pathcompare_mode.py +199 -0
- canns/pipeline/asa_gui/analysis_modes/tda_mode.py +112 -0
- canns/pipeline/asa_gui/app.py +29 -0
- canns/pipeline/asa_gui/controllers/__init__.py +6 -0
- canns/pipeline/asa_gui/controllers/analysis_controller.py +59 -0
- canns/pipeline/asa_gui/controllers/preprocess_controller.py +89 -0
- canns/pipeline/asa_gui/core/__init__.py +15 -0
- canns/pipeline/asa_gui/core/cache.py +14 -0
- canns/pipeline/asa_gui/core/runner.py +1936 -0
- canns/pipeline/asa_gui/core/state.py +324 -0
- canns/pipeline/asa_gui/core/worker.py +260 -0
- canns/pipeline/asa_gui/main_window.py +184 -0
- canns/pipeline/asa_gui/models/__init__.py +7 -0
- canns/pipeline/asa_gui/models/config.py +14 -0
- canns/pipeline/asa_gui/models/job.py +31 -0
- canns/pipeline/asa_gui/models/presets.py +21 -0
- canns/pipeline/asa_gui/resources/__init__.py +16 -0
- canns/pipeline/asa_gui/resources/dark.qss +167 -0
- canns/pipeline/asa_gui/resources/light.qss +163 -0
- canns/pipeline/asa_gui/resources/styles.qss +130 -0
- canns/pipeline/asa_gui/utils/__init__.py +1 -0
- canns/pipeline/asa_gui/utils/formatters.py +15 -0
- canns/pipeline/asa_gui/utils/io_adapters.py +40 -0
- canns/pipeline/asa_gui/utils/validators.py +41 -0
- canns/pipeline/asa_gui/views/__init__.py +1 -0
- canns/pipeline/asa_gui/views/help_content.py +171 -0
- canns/pipeline/asa_gui/views/pages/__init__.py +6 -0
- canns/pipeline/asa_gui/views/pages/analysis_page.py +565 -0
- canns/pipeline/asa_gui/views/pages/preprocess_page.py +492 -0
- canns/pipeline/asa_gui/views/panels/__init__.py +1 -0
- canns/pipeline/asa_gui/views/widgets/__init__.py +21 -0
- canns/pipeline/asa_gui/views/widgets/artifacts_tab.py +44 -0
- canns/pipeline/asa_gui/views/widgets/drop_zone.py +80 -0
- canns/pipeline/asa_gui/views/widgets/file_list.py +27 -0
- canns/pipeline/asa_gui/views/widgets/gridscore_tab.py +308 -0
- canns/pipeline/asa_gui/views/widgets/help_dialog.py +27 -0
- canns/pipeline/asa_gui/views/widgets/image_tab.py +50 -0
- canns/pipeline/asa_gui/views/widgets/image_viewer.py +97 -0
- canns/pipeline/asa_gui/views/widgets/log_box.py +16 -0
- canns/pipeline/asa_gui/views/widgets/pathcompare_tab.py +200 -0
- canns/pipeline/asa_gui/views/widgets/popup_combo.py +25 -0
- canns/pipeline/gallery/__init__.py +15 -5
- canns/pipeline/gallery/__main__.py +11 -0
- canns/pipeline/gallery/app.py +705 -0
- canns/pipeline/gallery/runner.py +790 -0
- canns/pipeline/gallery/state.py +51 -0
- canns/pipeline/gallery/styles.tcss +123 -0
- canns/pipeline/launcher.py +81 -0
- {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/METADATA +11 -1
- canns-0.14.0.dist-info/RECORD +163 -0
- canns-0.14.0.dist-info/entry_points.txt +5 -0
- canns/pipeline/_base.py +0 -50
- canns-0.13.1.dist-info/RECORD +0 -89
- canns-0.13.1.dist-info/entry_points.txt +0 -3
- {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/WHEEL +0 -0
- {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""GridScore result viewer tab."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from PySide6.QtCore import Qt, Signal
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLabel,
|
|
13
|
+
QPushButton,
|
|
14
|
+
QSpinBox,
|
|
15
|
+
QSplitter,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .image_viewer import ImageViewer
|
|
21
|
+
from .popup_combo import PopupComboBox
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GridScoreTab(QWidget):
|
|
25
|
+
"""GridScore viewer with distribution and neuron inspector."""
|
|
26
|
+
|
|
27
|
+
inspectRequested = Signal(int, dict)
|
|
28
|
+
|
|
29
|
+
def __init__(self, title: str = "Grid Score") -> None:
|
|
30
|
+
super().__init__()
|
|
31
|
+
self.title = title
|
|
32
|
+
|
|
33
|
+
self._npz_path: Path | None = None
|
|
34
|
+
self._meta: dict[str, Any] = {}
|
|
35
|
+
self._ids: np.ndarray | None = None
|
|
36
|
+
self._scores: np.ndarray | None = None
|
|
37
|
+
self._spacing: np.ndarray | None = None
|
|
38
|
+
self._orientation: np.ndarray | None = None
|
|
39
|
+
self._id_to_idx: dict[int, int] = {}
|
|
40
|
+
|
|
41
|
+
root = QVBoxLayout(self)
|
|
42
|
+
root.setContentsMargins(0, 0, 0, 0)
|
|
43
|
+
|
|
44
|
+
splitter = QSplitter(Qt.Vertical)
|
|
45
|
+
root.addWidget(splitter, 1)
|
|
46
|
+
|
|
47
|
+
# Distribution panel
|
|
48
|
+
dist = QWidget()
|
|
49
|
+
dist_layout = QVBoxLayout(dist)
|
|
50
|
+
dist_layout.setContentsMargins(8, 8, 8, 8)
|
|
51
|
+
self.dist_header = QLabel("Grid score distribution")
|
|
52
|
+
self.dist_header.setStyleSheet("font-weight: 600;")
|
|
53
|
+
dist_layout.addWidget(self.dist_header)
|
|
54
|
+
|
|
55
|
+
self.dist_viewer = ImageViewer()
|
|
56
|
+
dist_layout.addWidget(self.dist_viewer, 1)
|
|
57
|
+
|
|
58
|
+
splitter.addWidget(dist)
|
|
59
|
+
|
|
60
|
+
# Inspector panel
|
|
61
|
+
insp = QWidget()
|
|
62
|
+
insp_layout = QVBoxLayout(insp)
|
|
63
|
+
insp_layout.setContentsMargins(8, 8, 8, 8)
|
|
64
|
+
|
|
65
|
+
self.insp_header = QLabel("Neuron inspector")
|
|
66
|
+
self.insp_header.setStyleSheet("font-weight: 600;")
|
|
67
|
+
insp_layout.addWidget(self.insp_header)
|
|
68
|
+
|
|
69
|
+
ctrl = QHBoxLayout()
|
|
70
|
+
self.btn_prev = QPushButton("◀")
|
|
71
|
+
self.btn_next = QPushButton("▶")
|
|
72
|
+
self.neuron_id = QSpinBox()
|
|
73
|
+
self.neuron_id.setRange(0, 0)
|
|
74
|
+
self.neuron_id.setValue(0)
|
|
75
|
+
self.sort_combo = PopupComboBox()
|
|
76
|
+
self.sort_combo.addItems(
|
|
77
|
+
[
|
|
78
|
+
"neuron id (asc)",
|
|
79
|
+
"neuron id (desc)",
|
|
80
|
+
"grid score (desc)",
|
|
81
|
+
"grid score (asc)",
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
self.btn_show = QPushButton("Show neuron")
|
|
85
|
+
|
|
86
|
+
ctrl.addWidget(QLabel("neuron_id:"))
|
|
87
|
+
ctrl.addWidget(self.btn_prev)
|
|
88
|
+
ctrl.addWidget(self.neuron_id)
|
|
89
|
+
ctrl.addWidget(self.btn_next)
|
|
90
|
+
ctrl.addSpacing(8)
|
|
91
|
+
ctrl.addWidget(QLabel("sort:"))
|
|
92
|
+
ctrl.addWidget(self.sort_combo)
|
|
93
|
+
ctrl.addStretch(1)
|
|
94
|
+
ctrl.addWidget(self.btn_show)
|
|
95
|
+
insp_layout.addLayout(ctrl)
|
|
96
|
+
|
|
97
|
+
self.lbl_metrics = QLabel("grid_score: — spacing: — orientation: —")
|
|
98
|
+
self.lbl_metrics.setStyleSheet("font-family: Menlo, Consolas, monospace;")
|
|
99
|
+
insp_layout.addWidget(self.lbl_metrics)
|
|
100
|
+
|
|
101
|
+
self.auto_viewer = ImageViewer()
|
|
102
|
+
insp_layout.addWidget(self.auto_viewer, 1)
|
|
103
|
+
|
|
104
|
+
self.lbl_status = QLabel("")
|
|
105
|
+
self.lbl_status.setStyleSheet("color: #666;")
|
|
106
|
+
insp_layout.addWidget(self.lbl_status)
|
|
107
|
+
|
|
108
|
+
splitter.addWidget(insp)
|
|
109
|
+
splitter.setStretchFactor(0, 1)
|
|
110
|
+
splitter.setStretchFactor(1, 2)
|
|
111
|
+
|
|
112
|
+
self.btn_prev.clicked.connect(lambda: self._shift(-1))
|
|
113
|
+
self.btn_next.clicked.connect(lambda: self._shift(+1))
|
|
114
|
+
self.neuron_id.valueChanged.connect(self._on_neuron_changed)
|
|
115
|
+
self.sort_combo.currentIndexChanged.connect(self._on_sort_changed)
|
|
116
|
+
self.btn_show.clicked.connect(self._emit_inspect)
|
|
117
|
+
|
|
118
|
+
self.set_enabled(False)
|
|
119
|
+
|
|
120
|
+
def set_enabled(self, enabled: bool) -> None:
|
|
121
|
+
self.btn_prev.setEnabled(enabled)
|
|
122
|
+
self.btn_next.setEnabled(enabled)
|
|
123
|
+
self.neuron_id.setEnabled(enabled)
|
|
124
|
+
self.sort_combo.setEnabled(enabled)
|
|
125
|
+
self.btn_show.setEnabled(enabled)
|
|
126
|
+
|
|
127
|
+
def clear(self) -> None:
|
|
128
|
+
self._npz_path = None
|
|
129
|
+
self._meta = {}
|
|
130
|
+
self._ids = None
|
|
131
|
+
self._scores = None
|
|
132
|
+
self._spacing = None
|
|
133
|
+
self._orientation = None
|
|
134
|
+
self._id_to_idx = {}
|
|
135
|
+
self._order_ids: list[int] = []
|
|
136
|
+
self._order_index: dict[int, int] = {}
|
|
137
|
+
self._current_order_pos = 0
|
|
138
|
+
self.dist_header.setText("Grid score distribution")
|
|
139
|
+
self.insp_header.setText("Neuron inspector")
|
|
140
|
+
self.lbl_metrics.setText("grid_score: — spacing: — orientation: —")
|
|
141
|
+
self.lbl_status.setText("")
|
|
142
|
+
self.dist_viewer.set_image(None)
|
|
143
|
+
self.auto_viewer.set_image(None)
|
|
144
|
+
self.neuron_id.setRange(0, 0)
|
|
145
|
+
self.neuron_id.setValue(0)
|
|
146
|
+
self.set_enabled(False)
|
|
147
|
+
|
|
148
|
+
def set_distribution_image(self, path: Path | None) -> None:
|
|
149
|
+
if path is not None:
|
|
150
|
+
self.dist_header.setText(f"{self.title} — {path.name}")
|
|
151
|
+
else:
|
|
152
|
+
self.dist_header.setText(f"{self.title} — (no figure)")
|
|
153
|
+
self.dist_viewer.set_image(path)
|
|
154
|
+
|
|
155
|
+
def load_gridscore_npz(self, path: Path) -> None:
|
|
156
|
+
self._npz_path = path
|
|
157
|
+
data = np.load(str(path), allow_pickle=True)
|
|
158
|
+
|
|
159
|
+
self._meta = {}
|
|
160
|
+
for k in (
|
|
161
|
+
"neuron_start",
|
|
162
|
+
"neuron_end",
|
|
163
|
+
"bins",
|
|
164
|
+
"min_occupancy",
|
|
165
|
+
"smoothing",
|
|
166
|
+
"sigma",
|
|
167
|
+
"overlap",
|
|
168
|
+
"mode",
|
|
169
|
+
):
|
|
170
|
+
if k in data:
|
|
171
|
+
v = data[k]
|
|
172
|
+
try:
|
|
173
|
+
self._meta[k] = v.item() if hasattr(v, "item") else v
|
|
174
|
+
except Exception:
|
|
175
|
+
self._meta[k] = v
|
|
176
|
+
|
|
177
|
+
if "grid_score" in data:
|
|
178
|
+
self._scores = np.asarray(data["grid_score"])
|
|
179
|
+
elif "score" in data:
|
|
180
|
+
self._scores = np.asarray(data["score"])
|
|
181
|
+
else:
|
|
182
|
+
self._scores = None
|
|
183
|
+
|
|
184
|
+
self._spacing = np.asarray(data["spacing"]) if "spacing" in data else None
|
|
185
|
+
self._orientation = np.asarray(data["orientation"]) if "orientation" in data else None
|
|
186
|
+
|
|
187
|
+
if "neuron_ids" in data:
|
|
188
|
+
self._ids = np.asarray(data["neuron_ids"]).astype(int)
|
|
189
|
+
else:
|
|
190
|
+
ns = int(self._meta.get("neuron_start", 0))
|
|
191
|
+
ne = int(
|
|
192
|
+
self._meta.get(
|
|
193
|
+
"neuron_end",
|
|
194
|
+
ns + (len(self._scores) if self._scores is not None else 1),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
self._ids = np.arange(ns, ne, dtype=int)
|
|
198
|
+
|
|
199
|
+
self._id_to_idx = {int(nid): int(i) for i, nid in enumerate(self._ids.tolist())}
|
|
200
|
+
|
|
201
|
+
if self._ids.size > 0:
|
|
202
|
+
mn = int(self._ids.min())
|
|
203
|
+
mx = int(self._ids.max())
|
|
204
|
+
self.neuron_id.setRange(mn, mx)
|
|
205
|
+
self.set_enabled(True)
|
|
206
|
+
self._apply_sort(preserve_id=mn)
|
|
207
|
+
self.lbl_status.setText(f"Loaded {len(self._ids)} neurons from {path.name}")
|
|
208
|
+
else:
|
|
209
|
+
self.set_enabled(False)
|
|
210
|
+
self.lbl_status.setText("No neurons found in gridscore.npz")
|
|
211
|
+
|
|
212
|
+
def has_scores(self) -> bool:
|
|
213
|
+
return self._scores is not None and self._ids is not None and len(self._id_to_idx) > 0
|
|
214
|
+
|
|
215
|
+
def get_meta_params(self) -> dict[str, Any]:
|
|
216
|
+
return dict(self._meta) if isinstance(self._meta, dict) else {}
|
|
217
|
+
|
|
218
|
+
def set_autocorr_image(self, path: Path | None) -> None:
|
|
219
|
+
self.auto_viewer.set_image(path)
|
|
220
|
+
|
|
221
|
+
def set_status(self, msg: str) -> None:
|
|
222
|
+
self.lbl_status.setText(msg)
|
|
223
|
+
|
|
224
|
+
def _shift(self, step: int) -> None:
|
|
225
|
+
if not self.neuron_id.isEnabled():
|
|
226
|
+
return
|
|
227
|
+
if not self._order_ids:
|
|
228
|
+
self.neuron_id.setValue(self.neuron_id.value() + int(step))
|
|
229
|
+
return
|
|
230
|
+
new_pos = self._current_order_pos + int(step)
|
|
231
|
+
new_pos = max(0, min(len(self._order_ids) - 1, new_pos))
|
|
232
|
+
self._current_order_pos = new_pos
|
|
233
|
+
self.neuron_id.setValue(int(self._order_ids[new_pos]))
|
|
234
|
+
|
|
235
|
+
def _on_neuron_changed(self, nid: int) -> None:
|
|
236
|
+
if not self.has_scores():
|
|
237
|
+
self.lbl_metrics.setText("grid_score: — spacing: — orientation: —")
|
|
238
|
+
return
|
|
239
|
+
nid = int(nid)
|
|
240
|
+
if self._order_index:
|
|
241
|
+
self._current_order_pos = self._order_index.get(nid, self._current_order_pos)
|
|
242
|
+
idx = self._id_to_idx.get(nid)
|
|
243
|
+
if idx is None:
|
|
244
|
+
self.lbl_metrics.setText("grid_score: — spacing: — orientation: —")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
score = float(self._scores[idx]) if self._scores is not None else float("nan")
|
|
248
|
+
|
|
249
|
+
spc_txt = "—"
|
|
250
|
+
if self._spacing is not None and self._spacing.ndim >= 2:
|
|
251
|
+
spc = self._spacing[idx][:3]
|
|
252
|
+
spc_txt = f"{spc[0]:.2f}, {spc[1]:.2f}, {spc[2]:.2f}"
|
|
253
|
+
|
|
254
|
+
ori_txt = "—"
|
|
255
|
+
if self._orientation is not None and self._orientation.ndim >= 2:
|
|
256
|
+
ori = self._orientation[idx][:3]
|
|
257
|
+
ori_txt = f"{ori[0]:.1f}, {ori[1]:.1f}, {ori[2]:.1f}"
|
|
258
|
+
|
|
259
|
+
self.lbl_metrics.setText(
|
|
260
|
+
f"grid_score: {score:.3f} spacing: {spc_txt} orientation: {ori_txt}"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _on_sort_changed(self) -> None:
|
|
264
|
+
if not self.has_scores():
|
|
265
|
+
return
|
|
266
|
+
self._apply_sort(preserve_id=int(self.neuron_id.value()))
|
|
267
|
+
|
|
268
|
+
def _apply_sort(self, preserve_id: int | None = None) -> None:
|
|
269
|
+
if self._ids is None:
|
|
270
|
+
return
|
|
271
|
+
ids = [int(nid) for nid in self._ids.tolist()]
|
|
272
|
+
scores = None
|
|
273
|
+
if self._scores is not None:
|
|
274
|
+
try:
|
|
275
|
+
scores = [float(s) for s in self._scores.tolist()]
|
|
276
|
+
except Exception:
|
|
277
|
+
scores = None
|
|
278
|
+
|
|
279
|
+
mode = self.sort_combo.currentText()
|
|
280
|
+
if mode == "neuron id (desc)":
|
|
281
|
+
order_ids = sorted(ids, reverse=True)
|
|
282
|
+
elif mode == "grid score (asc)" and scores is not None:
|
|
283
|
+
order_ids = [i for i, _ in sorted(zip(ids, scores, strict=False), key=lambda t: t[1])]
|
|
284
|
+
elif mode == "grid score (desc)" and scores is not None:
|
|
285
|
+
order_ids = [
|
|
286
|
+
i
|
|
287
|
+
for i, _ in sorted(zip(ids, scores, strict=False), key=lambda t: t[1], reverse=True)
|
|
288
|
+
]
|
|
289
|
+
else:
|
|
290
|
+
order_ids = sorted(ids)
|
|
291
|
+
|
|
292
|
+
self._order_ids = order_ids
|
|
293
|
+
self._order_index = {nid: idx for idx, nid in enumerate(order_ids)}
|
|
294
|
+
|
|
295
|
+
if preserve_id is None or preserve_id not in self._order_index:
|
|
296
|
+
preserve_id = order_ids[0] if order_ids else None
|
|
297
|
+
|
|
298
|
+
if preserve_id is not None:
|
|
299
|
+
self._current_order_pos = self._order_index.get(preserve_id, 0)
|
|
300
|
+
self.neuron_id.setValue(int(preserve_id))
|
|
301
|
+
|
|
302
|
+
def _emit_inspect(self) -> None:
|
|
303
|
+
if not self.has_scores():
|
|
304
|
+
return
|
|
305
|
+
nid = int(self.neuron_id.value())
|
|
306
|
+
meta = self.get_meta_params()
|
|
307
|
+
meta["neuron_id"] = nid
|
|
308
|
+
self.inspectRequested.emit(nid, meta)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Help dialog widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QTextBrowser, QVBoxLayout
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HelpDialog(QDialog):
|
|
9
|
+
def __init__(self, title: str, markdown: str, parent=None) -> None:
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
self.setWindowTitle(title)
|
|
12
|
+
self.resize(680, 520)
|
|
13
|
+
|
|
14
|
+
layout = QVBoxLayout(self)
|
|
15
|
+
browser = QTextBrowser()
|
|
16
|
+
browser.setOpenExternalLinks(True)
|
|
17
|
+
browser.setMarkdown(markdown)
|
|
18
|
+
layout.addWidget(browser)
|
|
19
|
+
|
|
20
|
+
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
|
21
|
+
buttons.rejected.connect(self.reject)
|
|
22
|
+
layout.addWidget(buttons)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def show_help_dialog(parent, title: str, markdown: str) -> None:
|
|
26
|
+
dialog = HelpDialog(title=title, markdown=markdown, parent=parent)
|
|
27
|
+
dialog.exec()
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Image tab widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QUrl
|
|
8
|
+
from PySide6.QtGui import QDesktopServices
|
|
9
|
+
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
|
10
|
+
|
|
11
|
+
from .image_viewer import ImageViewer
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ImageTab(QWidget):
|
|
15
|
+
def __init__(self, title: str) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._title = title
|
|
18
|
+
self._path: Path | None = None
|
|
19
|
+
|
|
20
|
+
layout = QVBoxLayout(self)
|
|
21
|
+
header_row = QHBoxLayout()
|
|
22
|
+
self._header = QLabel(title)
|
|
23
|
+
self._header.setStyleSheet("font-weight: 600;")
|
|
24
|
+
header_row.addWidget(self._header)
|
|
25
|
+
header_row.addStretch(1)
|
|
26
|
+
self._btn_open = QPushButton("Open Image")
|
|
27
|
+
self._btn_open.setEnabled(False)
|
|
28
|
+
self._btn_open.clicked.connect(self._open_image)
|
|
29
|
+
header_row.addWidget(self._btn_open)
|
|
30
|
+
layout.addLayout(header_row)
|
|
31
|
+
|
|
32
|
+
self.viewer = ImageViewer()
|
|
33
|
+
layout.addWidget(self.viewer, 1)
|
|
34
|
+
|
|
35
|
+
def set_image(self, path: Path | str | None) -> None:
|
|
36
|
+
if path is None:
|
|
37
|
+
self._header.setText(self._title)
|
|
38
|
+
self._path = None
|
|
39
|
+
self._btn_open.setEnabled(False)
|
|
40
|
+
else:
|
|
41
|
+
path = Path(path)
|
|
42
|
+
self._header.setText(f"{self._title} — {path.name}")
|
|
43
|
+
self._path = path
|
|
44
|
+
self._btn_open.setEnabled(path.exists())
|
|
45
|
+
self.viewer.set_image(path)
|
|
46
|
+
|
|
47
|
+
def _open_image(self) -> None:
|
|
48
|
+
if self._path is None or not self._path.exists():
|
|
49
|
+
return
|
|
50
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._path)))
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Image viewer widget with zoom and pan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QPointF, QRectF, Qt
|
|
8
|
+
from PySide6.QtGui import QColor, QPainter, QPixmap
|
|
9
|
+
from PySide6.QtWidgets import QGraphicsPixmapItem, QGraphicsScene, QGraphicsTextItem, QGraphicsView
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImageViewer(QGraphicsView):
|
|
13
|
+
"""Image viewer with fit-to-view, zoom (wheel), and pan (drag)."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, parent=None) -> None:
|
|
16
|
+
super().__init__(parent)
|
|
17
|
+
self.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
|
|
18
|
+
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
|
|
19
|
+
self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
|
|
20
|
+
self.setDragMode(QGraphicsView.ScrollHandDrag)
|
|
21
|
+
self.setMinimumHeight(200)
|
|
22
|
+
|
|
23
|
+
self._scene = QGraphicsScene(self)
|
|
24
|
+
self.setScene(self._scene)
|
|
25
|
+
self._pixmap_item = QGraphicsPixmapItem()
|
|
26
|
+
self._scene.addItem(self._pixmap_item)
|
|
27
|
+
|
|
28
|
+
self._placeholder = QGraphicsTextItem("No image")
|
|
29
|
+
self._placeholder.setDefaultTextColor(QColor("#888"))
|
|
30
|
+
self._scene.addItem(self._placeholder)
|
|
31
|
+
|
|
32
|
+
self._has_image = False
|
|
33
|
+
self._auto_fit = True
|
|
34
|
+
|
|
35
|
+
def set_image(self, path: str | Path | None) -> None:
|
|
36
|
+
if path is None:
|
|
37
|
+
self._set_placeholder("No image")
|
|
38
|
+
return
|
|
39
|
+
path = Path(path)
|
|
40
|
+
if not path.exists():
|
|
41
|
+
self._set_placeholder(f"Missing: {path}")
|
|
42
|
+
return
|
|
43
|
+
pixmap = QPixmap(str(path))
|
|
44
|
+
if pixmap.isNull():
|
|
45
|
+
self._set_placeholder("Failed to load image")
|
|
46
|
+
return
|
|
47
|
+
self._has_image = True
|
|
48
|
+
self._auto_fit = True
|
|
49
|
+
self._pixmap_item.setPixmap(pixmap)
|
|
50
|
+
self._pixmap_item.setVisible(True)
|
|
51
|
+
self._placeholder.setVisible(False)
|
|
52
|
+
self._scene.setSceneRect(QRectF(pixmap.rect()))
|
|
53
|
+
self.resetTransform()
|
|
54
|
+
self.fitInView(self._pixmap_item, Qt.KeepAspectRatio)
|
|
55
|
+
|
|
56
|
+
def wheelEvent(self, event) -> None: # noqa: N802 - Qt naming
|
|
57
|
+
if not self._has_image:
|
|
58
|
+
return
|
|
59
|
+
delta = event.angleDelta().y()
|
|
60
|
+
if delta == 0:
|
|
61
|
+
return
|
|
62
|
+
factor = 1.2 if delta > 0 else 1 / 1.2
|
|
63
|
+
self._auto_fit = False
|
|
64
|
+
self.scale(factor, factor)
|
|
65
|
+
|
|
66
|
+
def mouseDoubleClickEvent(self, event) -> None: # noqa: N802 - Qt naming
|
|
67
|
+
if self._has_image:
|
|
68
|
+
self._auto_fit = True
|
|
69
|
+
self.resetTransform()
|
|
70
|
+
self.fitInView(self._pixmap_item, Qt.KeepAspectRatio)
|
|
71
|
+
super().mouseDoubleClickEvent(event)
|
|
72
|
+
|
|
73
|
+
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
|
74
|
+
super().resizeEvent(event)
|
|
75
|
+
if self._has_image and self._auto_fit:
|
|
76
|
+
self.fitInView(self._pixmap_item, Qt.KeepAspectRatio)
|
|
77
|
+
self._center_placeholder()
|
|
78
|
+
|
|
79
|
+
def _set_placeholder(self, text: str) -> None:
|
|
80
|
+
self._has_image = False
|
|
81
|
+
self._auto_fit = True
|
|
82
|
+
self._pixmap_item.setPixmap(QPixmap())
|
|
83
|
+
self._pixmap_item.setVisible(False)
|
|
84
|
+
self._placeholder.setPlainText(text)
|
|
85
|
+
self._placeholder.setVisible(True)
|
|
86
|
+
self._scene.setSceneRect(QRectF(0, 0, self.viewport().width(), self.viewport().height()))
|
|
87
|
+
self._center_placeholder()
|
|
88
|
+
|
|
89
|
+
def _center_placeholder(self) -> None:
|
|
90
|
+
if not self._placeholder.isVisible():
|
|
91
|
+
return
|
|
92
|
+
view_rect = self.viewport().rect()
|
|
93
|
+
center_scene = self.mapToScene(view_rect.center())
|
|
94
|
+
br = self._placeholder.boundingRect()
|
|
95
|
+
self._placeholder.setPos(
|
|
96
|
+
QPointF(center_scene.x() - br.width() / 2, center_scene.y() - br.height() / 2)
|
|
97
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Log output widget."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtWidgets import QTextEdit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LogBox(QTextEdit):
|
|
9
|
+
def __init__(self, parent=None) -> None:
|
|
10
|
+
super().__init__(parent)
|
|
11
|
+
self.setReadOnly(True)
|
|
12
|
+
self.setMinimumHeight(160)
|
|
13
|
+
self.setPlaceholderText("Logs will appear here.")
|
|
14
|
+
|
|
15
|
+
def log(self, msg: str) -> None:
|
|
16
|
+
self.append(msg)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""PathCompare result tab (PNG + optional GIF)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt, QTimer, QUrl
|
|
8
|
+
from PySide6.QtGui import QDesktopServices, QMovie
|
|
9
|
+
from PySide6.QtWidgets import (
|
|
10
|
+
QHBoxLayout,
|
|
11
|
+
QLabel,
|
|
12
|
+
QProgressBar,
|
|
13
|
+
QPushButton,
|
|
14
|
+
QSplitter,
|
|
15
|
+
QVBoxLayout,
|
|
16
|
+
QWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .image_viewer import ImageViewer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _FitGifLabel(QLabel):
|
|
23
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
self._movie: QMovie | None = None
|
|
26
|
+
self.setAlignment(Qt.AlignCenter)
|
|
27
|
+
|
|
28
|
+
def setMovie(self, movie: QMovie) -> None: # type: ignore[override]
|
|
29
|
+
self._movie = movie
|
|
30
|
+
super().setMovie(movie)
|
|
31
|
+
movie.frameChanged.connect(self._on_frame)
|
|
32
|
+
self._on_frame()
|
|
33
|
+
|
|
34
|
+
def clearMovie(self) -> None:
|
|
35
|
+
if self._movie is None:
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
self._movie.frameChanged.disconnect(self._on_frame)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
self._movie = None
|
|
42
|
+
self.clear()
|
|
43
|
+
|
|
44
|
+
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
|
45
|
+
super().resizeEvent(event)
|
|
46
|
+
self._on_frame()
|
|
47
|
+
|
|
48
|
+
def _on_frame(self, *_):
|
|
49
|
+
if self._movie is None:
|
|
50
|
+
return
|
|
51
|
+
pix = self._movie.currentPixmap()
|
|
52
|
+
if pix.isNull():
|
|
53
|
+
return
|
|
54
|
+
scaled = pix.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
55
|
+
self.setPixmap(scaled)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PathCompareTab(QWidget):
|
|
59
|
+
def __init__(self, title: str = "Path Compare") -> None:
|
|
60
|
+
super().__init__()
|
|
61
|
+
self.title = title
|
|
62
|
+
self._gif_movie: QMovie | None = None
|
|
63
|
+
self._gif_retry_count = 0
|
|
64
|
+
self._gif_last_path: Path | None = None
|
|
65
|
+
|
|
66
|
+
root = QVBoxLayout(self)
|
|
67
|
+
self.header = QLabel(title)
|
|
68
|
+
self.header.setStyleSheet("font-weight: 600;")
|
|
69
|
+
root.addWidget(self.header)
|
|
70
|
+
|
|
71
|
+
actions = QHBoxLayout()
|
|
72
|
+
self.btn_open_anim = QPushButton("Open Animation")
|
|
73
|
+
self.btn_open_anim.setEnabled(False)
|
|
74
|
+
self.btn_open_anim.clicked.connect(self._open_animation)
|
|
75
|
+
actions.addWidget(self.btn_open_anim)
|
|
76
|
+
actions.addStretch(1)
|
|
77
|
+
root.addLayout(actions)
|
|
78
|
+
|
|
79
|
+
self.splitter = QSplitter(Qt.Vertical)
|
|
80
|
+
|
|
81
|
+
png_wrap = QWidget()
|
|
82
|
+
png_layout = QVBoxLayout(png_wrap)
|
|
83
|
+
png_layout.setContentsMargins(0, 0, 0, 0)
|
|
84
|
+
self.png_label = QLabel("PNG")
|
|
85
|
+
self.png_label.setStyleSheet("color: #666;")
|
|
86
|
+
png_layout.addWidget(self.png_label)
|
|
87
|
+
self.png_view = ImageViewer()
|
|
88
|
+
png_layout.addWidget(self.png_view, 1)
|
|
89
|
+
|
|
90
|
+
gif_wrap = QWidget()
|
|
91
|
+
gif_layout = QVBoxLayout(gif_wrap)
|
|
92
|
+
gif_layout.setContentsMargins(0, 0, 0, 0)
|
|
93
|
+
self.gif_label = QLabel("Animation")
|
|
94
|
+
self.gif_label.setStyleSheet("color: #666;")
|
|
95
|
+
gif_layout.addWidget(self.gif_label)
|
|
96
|
+
self.gif_view = _FitGifLabel("No animation yet")
|
|
97
|
+
self.gif_view.setMinimumHeight(220)
|
|
98
|
+
self.gif_view.setStyleSheet("border: 1px solid #ddd; background: #fafafa;")
|
|
99
|
+
gif_layout.addWidget(self.gif_view, 1)
|
|
100
|
+
|
|
101
|
+
self.anim_progress = QProgressBar()
|
|
102
|
+
self.anim_progress.setRange(0, 100)
|
|
103
|
+
self.anim_progress.setValue(0)
|
|
104
|
+
self.anim_progress.setVisible(False)
|
|
105
|
+
gif_layout.addWidget(self.anim_progress)
|
|
106
|
+
|
|
107
|
+
self.splitter.addWidget(png_wrap)
|
|
108
|
+
self.splitter.addWidget(gif_wrap)
|
|
109
|
+
self.splitter.setStretchFactor(0, 1)
|
|
110
|
+
self.splitter.setStretchFactor(1, 1)
|
|
111
|
+
root.addWidget(self.splitter, 1)
|
|
112
|
+
|
|
113
|
+
self.set_artifacts(None, None)
|
|
114
|
+
|
|
115
|
+
def set_artifacts(self, png_path: Path | None, gif_path: Path | None) -> None:
|
|
116
|
+
self._animation_path = None
|
|
117
|
+
title = self.title
|
|
118
|
+
if png_path:
|
|
119
|
+
title = f"{self.title} — {png_path.name}"
|
|
120
|
+
elif gif_path:
|
|
121
|
+
title = f"{self.title} — {gif_path.name}"
|
|
122
|
+
self.header.setText(title)
|
|
123
|
+
|
|
124
|
+
if png_path and png_path.exists():
|
|
125
|
+
self.png_label.setText(f"PNG: {png_path.name}")
|
|
126
|
+
self.png_view.set_image(png_path)
|
|
127
|
+
else:
|
|
128
|
+
self.png_label.setText("PNG: (none)")
|
|
129
|
+
self.png_view.set_image(None)
|
|
130
|
+
|
|
131
|
+
if self._gif_movie is not None:
|
|
132
|
+
try:
|
|
133
|
+
self.gif_view.clearMovie()
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
self._gif_movie.stop()
|
|
137
|
+
self._gif_movie.deleteLater()
|
|
138
|
+
self._gif_movie = None
|
|
139
|
+
|
|
140
|
+
if gif_path and gif_path.exists():
|
|
141
|
+
self._gif_last_path = gif_path
|
|
142
|
+
self._gif_retry_count = 0
|
|
143
|
+
self._load_gif_movie(gif_path)
|
|
144
|
+
self._animation_path = gif_path
|
|
145
|
+
else:
|
|
146
|
+
self.gif_label.setText("Animation: (none)")
|
|
147
|
+
try:
|
|
148
|
+
self.gif_view.clearMovie()
|
|
149
|
+
except Exception:
|
|
150
|
+
self.gif_view.clear()
|
|
151
|
+
self.gif_view.setText("No animation yet")
|
|
152
|
+
|
|
153
|
+
self.btn_open_anim.setEnabled(self._animation_path is not None)
|
|
154
|
+
|
|
155
|
+
def set_animation(self, path: Path | None) -> None:
|
|
156
|
+
if self._gif_movie is not None:
|
|
157
|
+
try:
|
|
158
|
+
self.gif_view.clearMovie()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
self._gif_movie.stop()
|
|
162
|
+
self._gif_movie.deleteLater()
|
|
163
|
+
self._gif_movie = None
|
|
164
|
+
|
|
165
|
+
if path and path.exists():
|
|
166
|
+
self._animation_path = path
|
|
167
|
+
self.gif_label.setText(f"Animation (MP4): {path.name}")
|
|
168
|
+
self.gif_view.setText("MP4 preview not available. Use Open Animation.")
|
|
169
|
+
else:
|
|
170
|
+
self._animation_path = None
|
|
171
|
+
self.gif_label.setText("Animation: (none)")
|
|
172
|
+
self.gif_view.setText("No animation yet")
|
|
173
|
+
self.btn_open_anim.setEnabled(self._animation_path is not None)
|
|
174
|
+
|
|
175
|
+
def set_animation_progress(self, pct: int) -> None:
|
|
176
|
+
pct = max(0, min(100, int(pct)))
|
|
177
|
+
self.anim_progress.setVisible(True)
|
|
178
|
+
self.anim_progress.setValue(pct)
|
|
179
|
+
if pct >= 100:
|
|
180
|
+
self.anim_progress.setVisible(False)
|
|
181
|
+
|
|
182
|
+
def _open_animation(self) -> None:
|
|
183
|
+
if self._animation_path is None:
|
|
184
|
+
return
|
|
185
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._animation_path)))
|
|
186
|
+
|
|
187
|
+
def _load_gif_movie(self, gif_path: Path) -> None:
|
|
188
|
+
self.gif_label.setText(f"Animation (GIF): {gif_path.name}")
|
|
189
|
+
self._gif_movie = QMovie(str(gif_path))
|
|
190
|
+
if self._gif_movie.isValid():
|
|
191
|
+
self.gif_view.setMovie(self._gif_movie)
|
|
192
|
+
self._gif_movie.start()
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
self.gif_view.setText("GIF is not ready yet…")
|
|
196
|
+
self._gif_retry_count += 1
|
|
197
|
+
if self._gif_retry_count <= 10:
|
|
198
|
+
QTimer.singleShot(200, lambda: self._load_gif_movie(gif_path))
|
|
199
|
+
else:
|
|
200
|
+
self.gif_view.setText("GIF loaded but QMovie is invalid")
|