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.
Files changed (99) hide show
  1. canns/analyzer/data/__init__.py +5 -1
  2. canns/analyzer/data/asa/__init__.py +27 -12
  3. canns/analyzer/data/asa/cohospace.py +336 -10
  4. canns/analyzer/data/asa/config.py +3 -0
  5. canns/analyzer/data/asa/embedding.py +48 -45
  6. canns/analyzer/data/asa/path.py +104 -2
  7. canns/analyzer/data/asa/plotting.py +88 -19
  8. canns/analyzer/data/asa/tda.py +11 -4
  9. canns/analyzer/data/cell_classification/__init__.py +97 -0
  10. canns/analyzer/data/cell_classification/core/__init__.py +26 -0
  11. canns/analyzer/data/cell_classification/core/grid_cells.py +633 -0
  12. canns/analyzer/data/cell_classification/core/grid_modules_leiden.py +288 -0
  13. canns/analyzer/data/cell_classification/core/head_direction.py +347 -0
  14. canns/analyzer/data/cell_classification/core/spatial_analysis.py +431 -0
  15. canns/analyzer/data/cell_classification/io/__init__.py +5 -0
  16. canns/analyzer/data/cell_classification/io/matlab_loader.py +417 -0
  17. canns/analyzer/data/cell_classification/utils/__init__.py +39 -0
  18. canns/analyzer/data/cell_classification/utils/circular_stats.py +383 -0
  19. canns/analyzer/data/cell_classification/utils/correlation.py +318 -0
  20. canns/analyzer/data/cell_classification/utils/geometry.py +442 -0
  21. canns/analyzer/data/cell_classification/utils/image_processing.py +416 -0
  22. canns/analyzer/data/cell_classification/visualization/__init__.py +19 -0
  23. canns/analyzer/data/cell_classification/visualization/grid_plots.py +292 -0
  24. canns/analyzer/data/cell_classification/visualization/hd_plots.py +200 -0
  25. canns/analyzer/metrics/__init__.py +2 -1
  26. canns/analyzer/visualization/core/config.py +46 -4
  27. canns/data/__init__.py +6 -1
  28. canns/data/datasets.py +154 -1
  29. canns/data/loaders.py +37 -0
  30. canns/pipeline/__init__.py +13 -9
  31. canns/pipeline/__main__.py +6 -0
  32. canns/pipeline/asa/runner.py +105 -41
  33. canns/pipeline/asa_gui/__init__.py +68 -0
  34. canns/pipeline/asa_gui/__main__.py +6 -0
  35. canns/pipeline/asa_gui/analysis_modes/__init__.py +42 -0
  36. canns/pipeline/asa_gui/analysis_modes/base.py +39 -0
  37. canns/pipeline/asa_gui/analysis_modes/batch_mode.py +21 -0
  38. canns/pipeline/asa_gui/analysis_modes/cohomap_mode.py +56 -0
  39. canns/pipeline/asa_gui/analysis_modes/cohospace_mode.py +194 -0
  40. canns/pipeline/asa_gui/analysis_modes/decode_mode.py +52 -0
  41. canns/pipeline/asa_gui/analysis_modes/fr_mode.py +81 -0
  42. canns/pipeline/asa_gui/analysis_modes/frm_mode.py +92 -0
  43. canns/pipeline/asa_gui/analysis_modes/gridscore_mode.py +123 -0
  44. canns/pipeline/asa_gui/analysis_modes/pathcompare_mode.py +199 -0
  45. canns/pipeline/asa_gui/analysis_modes/tda_mode.py +112 -0
  46. canns/pipeline/asa_gui/app.py +29 -0
  47. canns/pipeline/asa_gui/controllers/__init__.py +6 -0
  48. canns/pipeline/asa_gui/controllers/analysis_controller.py +59 -0
  49. canns/pipeline/asa_gui/controllers/preprocess_controller.py +89 -0
  50. canns/pipeline/asa_gui/core/__init__.py +15 -0
  51. canns/pipeline/asa_gui/core/cache.py +14 -0
  52. canns/pipeline/asa_gui/core/runner.py +1936 -0
  53. canns/pipeline/asa_gui/core/state.py +324 -0
  54. canns/pipeline/asa_gui/core/worker.py +260 -0
  55. canns/pipeline/asa_gui/main_window.py +184 -0
  56. canns/pipeline/asa_gui/models/__init__.py +7 -0
  57. canns/pipeline/asa_gui/models/config.py +14 -0
  58. canns/pipeline/asa_gui/models/job.py +31 -0
  59. canns/pipeline/asa_gui/models/presets.py +21 -0
  60. canns/pipeline/asa_gui/resources/__init__.py +16 -0
  61. canns/pipeline/asa_gui/resources/dark.qss +167 -0
  62. canns/pipeline/asa_gui/resources/light.qss +163 -0
  63. canns/pipeline/asa_gui/resources/styles.qss +130 -0
  64. canns/pipeline/asa_gui/utils/__init__.py +1 -0
  65. canns/pipeline/asa_gui/utils/formatters.py +15 -0
  66. canns/pipeline/asa_gui/utils/io_adapters.py +40 -0
  67. canns/pipeline/asa_gui/utils/validators.py +41 -0
  68. canns/pipeline/asa_gui/views/__init__.py +1 -0
  69. canns/pipeline/asa_gui/views/help_content.py +171 -0
  70. canns/pipeline/asa_gui/views/pages/__init__.py +6 -0
  71. canns/pipeline/asa_gui/views/pages/analysis_page.py +565 -0
  72. canns/pipeline/asa_gui/views/pages/preprocess_page.py +492 -0
  73. canns/pipeline/asa_gui/views/panels/__init__.py +1 -0
  74. canns/pipeline/asa_gui/views/widgets/__init__.py +21 -0
  75. canns/pipeline/asa_gui/views/widgets/artifacts_tab.py +44 -0
  76. canns/pipeline/asa_gui/views/widgets/drop_zone.py +80 -0
  77. canns/pipeline/asa_gui/views/widgets/file_list.py +27 -0
  78. canns/pipeline/asa_gui/views/widgets/gridscore_tab.py +308 -0
  79. canns/pipeline/asa_gui/views/widgets/help_dialog.py +27 -0
  80. canns/pipeline/asa_gui/views/widgets/image_tab.py +50 -0
  81. canns/pipeline/asa_gui/views/widgets/image_viewer.py +97 -0
  82. canns/pipeline/asa_gui/views/widgets/log_box.py +16 -0
  83. canns/pipeline/asa_gui/views/widgets/pathcompare_tab.py +200 -0
  84. canns/pipeline/asa_gui/views/widgets/popup_combo.py +25 -0
  85. canns/pipeline/gallery/__init__.py +15 -5
  86. canns/pipeline/gallery/__main__.py +11 -0
  87. canns/pipeline/gallery/app.py +705 -0
  88. canns/pipeline/gallery/runner.py +790 -0
  89. canns/pipeline/gallery/state.py +51 -0
  90. canns/pipeline/gallery/styles.tcss +123 -0
  91. canns/pipeline/launcher.py +81 -0
  92. {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/METADATA +11 -1
  93. canns-0.14.0.dist-info/RECORD +163 -0
  94. canns-0.14.0.dist-info/entry_points.txt +5 -0
  95. canns/pipeline/_base.py +0 -50
  96. canns-0.13.1.dist-info/RECORD +0 -89
  97. canns-0.13.1.dist-info/entry_points.txt +0 -3
  98. {canns-0.13.1.dist-info → canns-0.14.0.dist-info}/WHEEL +0 -0
  99. {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")