setiastrosuitepro 1.6.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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (174) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/saspro/__init__.py +20 -0
  3. setiastro/saspro/__main__.py +784 -0
  4. setiastro/saspro/_generated/__init__.py +7 -0
  5. setiastro/saspro/_generated/build_info.py +2 -0
  6. setiastro/saspro/abe.py +1295 -0
  7. setiastro/saspro/abe_preset.py +196 -0
  8. setiastro/saspro/aberration_ai.py +694 -0
  9. setiastro/saspro/aberration_ai_preset.py +224 -0
  10. setiastro/saspro/accel_installer.py +218 -0
  11. setiastro/saspro/accel_workers.py +30 -0
  12. setiastro/saspro/add_stars.py +621 -0
  13. setiastro/saspro/astrobin_exporter.py +1007 -0
  14. setiastro/saspro/astrospike.py +153 -0
  15. setiastro/saspro/astrospike_python.py +1839 -0
  16. setiastro/saspro/autostretch.py +196 -0
  17. setiastro/saspro/backgroundneutral.py +560 -0
  18. setiastro/saspro/batch_convert.py +325 -0
  19. setiastro/saspro/batch_renamer.py +519 -0
  20. setiastro/saspro/blemish_blaster.py +488 -0
  21. setiastro/saspro/blink_comparator_pro.py +2923 -0
  22. setiastro/saspro/bundles.py +61 -0
  23. setiastro/saspro/bundles_dock.py +114 -0
  24. setiastro/saspro/cheat_sheet.py +168 -0
  25. setiastro/saspro/clahe.py +342 -0
  26. setiastro/saspro/comet_stacking.py +1377 -0
  27. setiastro/saspro/config.py +38 -0
  28. setiastro/saspro/config_bootstrap.py +40 -0
  29. setiastro/saspro/config_manager.py +316 -0
  30. setiastro/saspro/continuum_subtract.py +1617 -0
  31. setiastro/saspro/convo.py +1397 -0
  32. setiastro/saspro/convo_preset.py +414 -0
  33. setiastro/saspro/copyastro.py +187 -0
  34. setiastro/saspro/cosmicclarity.py +1564 -0
  35. setiastro/saspro/cosmicclarity_preset.py +407 -0
  36. setiastro/saspro/crop_dialog_pro.py +948 -0
  37. setiastro/saspro/crop_preset.py +189 -0
  38. setiastro/saspro/curve_editor_pro.py +2544 -0
  39. setiastro/saspro/curves_preset.py +375 -0
  40. setiastro/saspro/debayer.py +670 -0
  41. setiastro/saspro/debug_utils.py +29 -0
  42. setiastro/saspro/dnd_mime.py +35 -0
  43. setiastro/saspro/doc_manager.py +2634 -0
  44. setiastro/saspro/exoplanet_detector.py +2166 -0
  45. setiastro/saspro/file_utils.py +284 -0
  46. setiastro/saspro/fitsmodifier.py +744 -0
  47. setiastro/saspro/free_torch_memory.py +48 -0
  48. setiastro/saspro/frequency_separation.py +1343 -0
  49. setiastro/saspro/function_bundle.py +1594 -0
  50. setiastro/saspro/ghs_dialog_pro.py +660 -0
  51. setiastro/saspro/ghs_preset.py +284 -0
  52. setiastro/saspro/graxpert.py +634 -0
  53. setiastro/saspro/graxpert_preset.py +287 -0
  54. setiastro/saspro/gui/__init__.py +0 -0
  55. setiastro/saspro/gui/main_window.py +8494 -0
  56. setiastro/saspro/gui/mixins/__init__.py +33 -0
  57. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  58. setiastro/saspro/gui/mixins/file_mixin.py +445 -0
  59. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  60. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  61. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  62. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  63. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  64. setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
  65. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  66. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  67. setiastro/saspro/halobgon.py +462 -0
  68. setiastro/saspro/header_viewer.py +445 -0
  69. setiastro/saspro/headless_utils.py +88 -0
  70. setiastro/saspro/histogram.py +753 -0
  71. setiastro/saspro/history_explorer.py +939 -0
  72. setiastro/saspro/image_combine.py +414 -0
  73. setiastro/saspro/image_peeker_pro.py +1596 -0
  74. setiastro/saspro/imageops/__init__.py +37 -0
  75. setiastro/saspro/imageops/mdi_snap.py +292 -0
  76. setiastro/saspro/imageops/scnr.py +36 -0
  77. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  78. setiastro/saspro/imageops/stretch.py +244 -0
  79. setiastro/saspro/isophote.py +1179 -0
  80. setiastro/saspro/layers.py +208 -0
  81. setiastro/saspro/layers_dock.py +714 -0
  82. setiastro/saspro/lazy_imports.py +193 -0
  83. setiastro/saspro/legacy/__init__.py +2 -0
  84. setiastro/saspro/legacy/image_manager.py +2226 -0
  85. setiastro/saspro/legacy/numba_utils.py +3659 -0
  86. setiastro/saspro/legacy/xisf.py +1071 -0
  87. setiastro/saspro/linear_fit.py +534 -0
  88. setiastro/saspro/live_stacking.py +1830 -0
  89. setiastro/saspro/log_bus.py +5 -0
  90. setiastro/saspro/logging_config.py +460 -0
  91. setiastro/saspro/luminancerecombine.py +309 -0
  92. setiastro/saspro/main_helpers.py +201 -0
  93. setiastro/saspro/mask_creation.py +928 -0
  94. setiastro/saspro/masks_core.py +56 -0
  95. setiastro/saspro/mdi_widgets.py +353 -0
  96. setiastro/saspro/memory_utils.py +666 -0
  97. setiastro/saspro/metadata_patcher.py +75 -0
  98. setiastro/saspro/mfdeconv.py +3826 -0
  99. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  100. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  101. setiastro/saspro/mfdeconvsport.py +2382 -0
  102. setiastro/saspro/minorbodycatalog.py +567 -0
  103. setiastro/saspro/morphology.py +382 -0
  104. setiastro/saspro/multiscale_decomp.py +1290 -0
  105. setiastro/saspro/nbtorgb_stars.py +531 -0
  106. setiastro/saspro/numba_utils.py +3044 -0
  107. setiastro/saspro/numba_warmup.py +141 -0
  108. setiastro/saspro/ops/__init__.py +9 -0
  109. setiastro/saspro/ops/command_help_dialog.py +623 -0
  110. setiastro/saspro/ops/command_runner.py +217 -0
  111. setiastro/saspro/ops/commands.py +1594 -0
  112. setiastro/saspro/ops/script_editor.py +1102 -0
  113. setiastro/saspro/ops/scripts.py +1413 -0
  114. setiastro/saspro/ops/settings.py +560 -0
  115. setiastro/saspro/parallel_utils.py +554 -0
  116. setiastro/saspro/pedestal.py +121 -0
  117. setiastro/saspro/perfect_palette_picker.py +1053 -0
  118. setiastro/saspro/pipeline.py +110 -0
  119. setiastro/saspro/pixelmath.py +1600 -0
  120. setiastro/saspro/plate_solver.py +2435 -0
  121. setiastro/saspro/project_io.py +797 -0
  122. setiastro/saspro/psf_utils.py +136 -0
  123. setiastro/saspro/psf_viewer.py +549 -0
  124. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  125. setiastro/saspro/remove_green.py +314 -0
  126. setiastro/saspro/remove_stars.py +1625 -0
  127. setiastro/saspro/remove_stars_preset.py +404 -0
  128. setiastro/saspro/resources.py +472 -0
  129. setiastro/saspro/rgb_combination.py +207 -0
  130. setiastro/saspro/rgb_extract.py +19 -0
  131. setiastro/saspro/rgbalign.py +723 -0
  132. setiastro/saspro/runtime_imports.py +7 -0
  133. setiastro/saspro/runtime_torch.py +754 -0
  134. setiastro/saspro/save_options.py +72 -0
  135. setiastro/saspro/selective_color.py +1552 -0
  136. setiastro/saspro/sfcc.py +1425 -0
  137. setiastro/saspro/shortcuts.py +2807 -0
  138. setiastro/saspro/signature_insert.py +1099 -0
  139. setiastro/saspro/stacking_suite.py +17712 -0
  140. setiastro/saspro/star_alignment.py +7420 -0
  141. setiastro/saspro/star_alignment_preset.py +329 -0
  142. setiastro/saspro/star_metrics.py +49 -0
  143. setiastro/saspro/star_spikes.py +681 -0
  144. setiastro/saspro/star_stretch.py +470 -0
  145. setiastro/saspro/stat_stretch.py +502 -0
  146. setiastro/saspro/status_log_dock.py +78 -0
  147. setiastro/saspro/subwindow.py +3267 -0
  148. setiastro/saspro/supernovaasteroidhunter.py +1712 -0
  149. setiastro/saspro/swap_manager.py +99 -0
  150. setiastro/saspro/torch_backend.py +89 -0
  151. setiastro/saspro/torch_rejection.py +434 -0
  152. setiastro/saspro/view_bundle.py +1555 -0
  153. setiastro/saspro/wavescale_hdr.py +624 -0
  154. setiastro/saspro/wavescale_hdr_preset.py +100 -0
  155. setiastro/saspro/wavescalede.py +657 -0
  156. setiastro/saspro/wavescalede_preset.py +228 -0
  157. setiastro/saspro/wcs_update.py +374 -0
  158. setiastro/saspro/whitebalance.py +456 -0
  159. setiastro/saspro/widgets/__init__.py +48 -0
  160. setiastro/saspro/widgets/common_utilities.py +305 -0
  161. setiastro/saspro/widgets/graphics_views.py +122 -0
  162. setiastro/saspro/widgets/image_utils.py +518 -0
  163. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  164. setiastro/saspro/widgets/spinboxes.py +275 -0
  165. setiastro/saspro/widgets/themed_buttons.py +13 -0
  166. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  167. setiastro/saspro/window_shelf.py +185 -0
  168. setiastro/saspro/xisf.py +1123 -0
  169. setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
  170. setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
  171. setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
  172. setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
  173. setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
  174. setiastrosuitepro-1.6.0.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,939 @@
1
+ from __future__ import annotations
2
+ from PyQt6.QtCore import Qt, QSize, QPointF, QEvent, QMimeData
3
+ from PyQt6.QtWidgets import (
4
+ QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QPushButton, QLabel,
5
+ QScrollArea, QWidget, QMessageBox, QSlider, QListWidgetItem, QApplication
6
+ )
7
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QMouseEvent, QDrag
8
+ from PyQt6 import sip
9
+ import numpy as np
10
+ import json
11
+
12
+ from .autostretch import autostretch
13
+ from .dnd_mime import MIME_CMD
14
+ from setiastro.saspro.swap_manager import get_swap_manager
15
+
16
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
17
+
18
+
19
+ # ---------- helpers ----------
20
+ def _pack_cmd_payload(command_id: str, preset: dict | None = None) -> bytes:
21
+ return json.dumps({"command_id": command_id, "preset": preset or {}}).encode("utf-8")
22
+
23
+ # Map human step names → command_id used by replay_last_action_on_base
24
+ _NAME_TO_COMMAND_ID = {
25
+ # Background neutralization / WB
26
+ "background neutralization": "background_neutral",
27
+ "background neutralisation": "background_neutral",
28
+ "background neutral": "background_neutral",
29
+ "white balance": "white_balance",
30
+
31
+ # Simple tools with no real preset
32
+ "pedestal removal": "pedestal",
33
+ "pedestal": "pedestal",
34
+ "linear fit": "linear_fit",
35
+
36
+ # Stretching / tone tools
37
+ "statistical stretch": "stat_stretch",
38
+ "stat stretch": "stat_stretch",
39
+ "star stretch": "star_stretch",
40
+ "curves": "curves",
41
+ "ghs": "ghs",
42
+ "generalized hyperbolic stretch": "ghs",
43
+
44
+ # Background/gradient
45
+ "abe": "abe",
46
+ "automatic background extraction": "abe",
47
+ "graxpert": "graxpert",
48
+
49
+ # Star / color tools
50
+ "remove stars": "remove_stars",
51
+ "remove green": "remove_green",
52
+
53
+ # Convolution / deconvolution
54
+ "convo / deconvo": "convo",
55
+ "convolution / deconvolution": "convo",
56
+ "convolution": "convo",
57
+ "deconvolution": "convo",
58
+
59
+ # Wavescale tools
60
+ "wavescale hdr": "wavescale_hdr",
61
+ "wave scale hdr": "wavescale_hdr",
62
+ "wavescale dark enhancer": "wavescale_dark_enhance",
63
+ "wave scale dark enhancer": "wavescale_dark_enhance",
64
+ "dark structure enhance": "wavescale_dark_enhance",
65
+
66
+ # Other image-processing tools
67
+ "clahe": "clahe",
68
+ "morphology": "morphology",
69
+ "pixel math": "pixel_math",
70
+ "pixelmath": "pixel_math",
71
+ "halo-b-gon": "halo_b_gon",
72
+ "halo b gon": "halo_b_gon",
73
+ "aberration ai": "aberrationai",
74
+ "cosmic clarity": "cosmic_clarity",
75
+ }
76
+
77
+
78
+ def _norm_preset(p) -> dict:
79
+ """Best effort: turn whatever we get into a dict."""
80
+ if not p:
81
+ return {}
82
+ if isinstance(p, dict):
83
+ return dict(p)
84
+ try:
85
+ return dict(p)
86
+ except Exception:
87
+ return {}
88
+
89
+
90
+ def _extract_cmd_payload_from_meta(meta: dict | None) -> dict | None:
91
+ """
92
+ Best-effort: pull a (command_id, preset) payload out of a history meta dict.
93
+
94
+ We look in three places, in order:
95
+
96
+ 1) Embedded payloads (headless_payload / replay_payload / cmd_payload)
97
+ 2) Explicit command_id / cid + preset directly on metadata
98
+ 3) Inference from step_name + preset or special tool keys
99
+ """
100
+ if not isinstance(meta, dict):
101
+ return None
102
+
103
+ # --- 1) Embedded payload dicts ----------------------------
104
+ for key in ("headless_payload", "replay_payload", "cmd_payload"):
105
+ p = meta.get(key)
106
+ if isinstance(p, dict):
107
+ cid = p.get("command_id") or p.get("cid")
108
+ if cid:
109
+ return {
110
+ "command_id": str(cid),
111
+ "preset": _norm_preset(p.get("preset")),
112
+ }
113
+
114
+ # --- 2) Direct command_id + preset on metadata ------------
115
+ cid = meta.get("command_id") or meta.get("cid")
116
+ if cid:
117
+ preset = meta.get("preset") or meta.get("preset_dict") or {}
118
+ return {
119
+ "command_id": str(cid),
120
+ "preset": _norm_preset(preset),
121
+ }
122
+
123
+ # --- 3) Heuristics for tools that only store preset + step_name ----
124
+ preset = _norm_preset(meta.get("preset"))
125
+ step_name_raw = meta.get("step_name") or meta.get("name")
126
+ step_name = str(step_name_raw or "").strip().lower()
127
+
128
+ inferred_cid = None
129
+
130
+ if step_name:
131
+ # Normalize a bit: underscores / hyphens → spaces
132
+ base = step_name.replace("_", " ").replace("-", " ")
133
+
134
+ # Exact match first
135
+ inferred_cid = _NAME_TO_COMMAND_ID.get(base)
136
+
137
+ # Fuzzy: allow things like "Remove Green (mask=ON)"
138
+ if inferred_cid is None:
139
+ for key, val in _NAME_TO_COMMAND_ID.items():
140
+ if key in base:
141
+ inferred_cid = val
142
+ break
143
+
144
+ # Fallback: look for tool-specific keys in metadata
145
+ if inferred_cid is None:
146
+ for k in meta.keys():
147
+ k_norm = str(k).lower()
148
+
149
+ if k_norm in ("remove_green", "remove green"):
150
+ inferred_cid = "remove_green"
151
+ break
152
+ if k_norm in ("stat_stretch", "statistical_stretch"):
153
+ inferred_cid = "stat_stretch"
154
+ break
155
+ if k_norm in ("ghs", "generalized hyperbolic stretch"):
156
+ inferred_cid = "ghs"
157
+ break
158
+ if k_norm in ("abe", "automatic background extraction"):
159
+ inferred_cid = "abe"
160
+ break
161
+ if k_norm in ("wavescale_hdr", "wave_scale_hdr"):
162
+ inferred_cid = "wavescale_hdr"
163
+ break
164
+ if k_norm in ("wavescale_dark_enhance", "dark_structure_enhance"):
165
+ inferred_cid = "wavescale_dark_enhance"
166
+ break
167
+ if k_norm in ("pixel_math", "pixelmath"):
168
+ inferred_cid = "pixel_math"
169
+ break
170
+ if k_norm in ("halo_b_gon", "halo b gon"):
171
+ inferred_cid = "halo_b_gon"
172
+ break
173
+ if k_norm in ("aberrationai", "aberration_ai"):
174
+ inferred_cid = "aberrationai"
175
+ break
176
+ if k_norm in ("cosmic_clarity",):
177
+ inferred_cid = "cosmic_clarity"
178
+ break
179
+ if k_norm in ("convo", "convolution", "deconvolution"):
180
+ inferred_cid = "convo"
181
+ break
182
+
183
+ if inferred_cid is None:
184
+ # Nothing we know how to replay
185
+ return None
186
+
187
+ return {
188
+ "command_id": str(inferred_cid),
189
+ "preset": preset,
190
+ }
191
+
192
+ # Map human step names → command_id used by replay_last_action_on_base
193
+ _NAME_TO_COMMAND_ID = {
194
+ "pedestal removal": "pedestal",
195
+ "pedestal": "pedestal",
196
+
197
+ "statistical stretch": "stat_stretch",
198
+ "stat stretch": "stat_stretch",
199
+
200
+ "curves": "curves",
201
+
202
+ "remove green": "remove_green",
203
+
204
+ "background neutralization": "background_neutral",
205
+ "background neutralisation": "background_neutral",
206
+ "background neutral": "background_neutral",
207
+ "bn": "background_neutral",
208
+
209
+ "white balance": "white_balance",
210
+
211
+ "convo/deconvo": "convo",
212
+ "convolution / deconvolution": "convo",
213
+ "convolution": "convo",
214
+ "deconvolution": "convo",
215
+
216
+ "ghs": "ghs",
217
+ "generalized hyperbolic stretch": "ghs",
218
+
219
+ "automatic background extraction": "abe",
220
+ "abe": "abe",
221
+
222
+ "graxpert": "graxpert",
223
+
224
+ "remove stars": "remove_stars",
225
+
226
+ "star stretch": "star_stretch",
227
+
228
+ "wavescale hdr": "wavescale_hdr",
229
+ "wave scale hdr": "wavescale_hdr",
230
+
231
+ "wavescale dark enhancer": "wavescale_dark_enhance",
232
+ "dark structure enhance": "wavescale_dark_enhance",
233
+
234
+ "clahe": "clahe",
235
+
236
+ "morphology": "morphology",
237
+
238
+ "pixel math": "pixel_math",
239
+ "pixelmath": "pixel_math",
240
+
241
+ "halo-b-gon": "halo_b_gon",
242
+ "halo b gon": "halo_b_gon",
243
+
244
+ "aberration ai": "aberrationai",
245
+
246
+ "cosmic clarity": "cosmic_clarity",
247
+
248
+ "linear fit": "linear_fit",
249
+ }
250
+
251
+
252
+ def _norm_step_label(label: str) -> str:
253
+ """Normalize a human label like 'Statistical Stretch (target=0.25, unlinked)'."""
254
+ s = str(label or "").strip().lower()
255
+ if not s:
256
+ return ""
257
+ # Drop decorations like '(...)' or ' - extra'
258
+ for sep in ("(", "[", " - "):
259
+ idx = s.find(sep)
260
+ if idx > 0:
261
+ s = s[:idx]
262
+ return " ".join(s.split())
263
+
264
+
265
+ def _command_id_for_step_label(label: str) -> str | None:
266
+ """Map a history step label to a canonical command_id."""
267
+ base = _norm_step_label(label)
268
+ if not base:
269
+ return None
270
+
271
+ # Exact match
272
+ cid = _NAME_TO_COMMAND_ID.get(base)
273
+ if cid:
274
+ return cid
275
+
276
+ # Fuzzy: allow 'statistical stretch (target=...)'
277
+ for key, val in _NAME_TO_COMMAND_ID.items():
278
+ if key in base:
279
+ return val
280
+ return None
281
+
282
+
283
+ def _payloads_from_headless_history(main_window, undo_entries):
284
+ """
285
+ Use the main window's headless history to get presets for each undo entry.
286
+
287
+ We walk FORWARD through _headless_history so that:
288
+ - repeated operations get the right preset in order
289
+ - commands on other documents are skipped automatically.
290
+ Returns a list[len(undo_entries)] of payload dicts or None.
291
+ """
292
+ n = len(undo_entries)
293
+ payloads = [None] * n
294
+
295
+ if main_window is None or not hasattr(main_window, "get_headless_history"):
296
+ return payloads
297
+
298
+ try:
299
+ hist = list(main_window.get_headless_history()) or []
300
+ except Exception:
301
+ return payloads
302
+
303
+ if not hist:
304
+ return payloads
305
+
306
+ H = len(hist)
307
+ h_idx = 0
308
+
309
+ for i, (_img, meta, name) in enumerate(undo_entries):
310
+ label = name or (meta or {}).get("step_name") or ""
311
+ cid = _command_id_for_step_label(label)
312
+ if not cid:
313
+ continue
314
+ cid = cid.strip().lower()
315
+
316
+ # Scan forward in global history until we find the next entry with this cid.
317
+ while h_idx < H:
318
+ entry = hist[h_idx]
319
+ h_idx += 1
320
+ entry_cid = str(entry.get("command_id", "")).strip().lower()
321
+ if entry_cid != cid:
322
+ continue
323
+
324
+ preset = entry.get("preset") or {}
325
+ if not isinstance(preset, dict):
326
+ try:
327
+ preset = dict(preset)
328
+ except Exception:
329
+ preset = {}
330
+ payloads[i] = {"command_id": cid, "preset": preset}
331
+ break
332
+
333
+ return payloads
334
+
335
+ # Shared utilities
336
+ from setiastro.saspro.widgets.image_utils import to_float01 as _to_float01
337
+
338
+
339
+ def _mk_qimage_rgb8(float01: np.ndarray) -> tuple[QImage, np.ndarray]:
340
+ """Make a QImage (RGB888) and return it along with the backing uint8 buffer to keep alive."""
341
+ f = float01
342
+ if f.ndim == 2:
343
+ f = np.stack([f] * 3, axis=-1)
344
+ elif f.ndim == 3 and f.shape[2] == 1:
345
+ f = np.repeat(f, 3, axis=2)
346
+ buf8 = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8, copy=False)
347
+ buf8 = np.ascontiguousarray(buf8)
348
+ h, w, _ = buf8.shape
349
+ bpl = buf8.strides[0]
350
+ ptr = sip.voidptr(buf8.ctypes.data)
351
+ qimg = QImage(ptr, w, h, bpl, QImage.Format.Format_RGB888)
352
+ return qimg, buf8
353
+
354
+
355
+ def _extract_undo_entries(doc):
356
+ # Prefer the public getter we just added
357
+ if hasattr(doc, "get_undo_stack"):
358
+ return list(doc.get_undo_stack())
359
+
360
+ # Fallbacks if needed
361
+ for attr in ("_undo_stack", "undo_stack"):
362
+ stack = getattr(doc, attr, None)
363
+ if stack is None:
364
+ continue
365
+ out = []
366
+ for item in stack:
367
+ if isinstance(item, (list, tuple)):
368
+ if len(item) >= 3:
369
+ # item[0] is now swap_id (str) or image (ndarray)
370
+ sid_or_img, meta, name = item[0], item[1] or {}, item[2] or "Unnamed"
371
+ elif len(item) == 2:
372
+ sid_or_img, meta = item
373
+ meta = meta or {}
374
+ name = meta.get("step_name", "Unnamed")
375
+ else:
376
+ continue
377
+ out.append((sid_or_img, meta, str(name)))
378
+
379
+ if out:
380
+ return out
381
+ return []
382
+
383
+
384
+ class HistoryListWidget(QListWidget):
385
+ """
386
+ QListWidget that supports Alt+drag of replayable steps.
387
+ Alt+drag starts a MIME_CMD drag with (command_id, preset).
388
+ """
389
+ def __init__(self, parent=None):
390
+ super().__init__(parent)
391
+ self._press_pos = None
392
+
393
+ def mousePressEvent(self, e: QMouseEvent):
394
+ if e.button() == Qt.MouseButton.LeftButton:
395
+ self._press_pos = e.position().toPoint()
396
+ super().mousePressEvent(e)
397
+
398
+ def mouseMoveEvent(self, e: QMouseEvent):
399
+ if self._press_pos is not None and (e.buttons() & Qt.MouseButton.LeftButton):
400
+ delta = e.position().toPoint() - self._press_pos
401
+ if delta.manhattanLength() >= QApplication.startDragDistance():
402
+ mods = QApplication.keyboardModifiers()
403
+ if mods & Qt.KeyboardModifier.AltModifier:
404
+ item = self.itemAt(self._press_pos)
405
+ if item is not None:
406
+ payload = item.data(Qt.ItemDataRole.UserRole)
407
+ if isinstance(payload, dict) and payload.get("command_id"):
408
+ self._start_drag(payload)
409
+ self._press_pos = None
410
+ return
411
+ super().mouseMoveEvent(e)
412
+
413
+ def mouseReleaseEvent(self, e: QMouseEvent):
414
+ self._press_pos = None
415
+ super().mouseReleaseEvent(e)
416
+
417
+ def _start_drag(self, payload: dict):
418
+ cid = payload.get("command_id")
419
+ preset = payload.get("preset") or {}
420
+ if not cid:
421
+ return
422
+
423
+ md = QMimeData()
424
+ md.setData(MIME_CMD, _pack_cmd_payload(cid, preset))
425
+
426
+ drag = QDrag(self)
427
+ drag.setMimeData(md)
428
+ pm = QPixmap(32, 32)
429
+ pm.fill(Qt.GlobalColor.darkGray)
430
+ drag.setPixmap(pm)
431
+ drag.setHotSpot(pm.rect().center())
432
+ drag.exec(Qt.DropAction.CopyAction)
433
+
434
+
435
+ class HistoryExplorerDialog(QDialog):
436
+ def __init__(self, document, parent=None):
437
+ super().__init__(parent)
438
+ self.setWindowTitle("History Explorer")
439
+ self.setModal(False)
440
+ self.doc = document
441
+
442
+ self.setMinimumSize(700, 500)
443
+ layout = QVBoxLayout(self)
444
+
445
+ self.history_list = HistoryListWidget(self)
446
+ layout.addWidget(self.history_list)
447
+
448
+ # ---- Fetch undo stack ----
449
+ self.undo_entries = _extract_undo_entries(self.doc) # list[(sid_or_img, meta, name)]
450
+ self.items: list[tuple[object, dict, str]] = []
451
+ # Headless command presets aligned to each undo entry
452
+ mw = self._find_main_window()
453
+ self._history_payloads = _payloads_from_headless_history(mw, self.undo_entries)
454
+
455
+
456
+ # DEBUG: log what's in the undo stack
457
+ mw = self._find_main_window()
458
+ log = getattr(mw, "_log", None)
459
+ if log:
460
+ try:
461
+ log(
462
+ f"[HistoryExplorer] doc id={id(self.doc)} has "
463
+ f"{len(self.undo_entries)} undo entries"
464
+ )
465
+ for idx, (img, meta, name) in enumerate(self.undo_entries):
466
+ mk = list((meta or {}).keys())
467
+ payload_meta = _extract_cmd_payload_from_meta(meta or {})
468
+ payload_hist = (
469
+ self._history_payloads[idx]
470
+ if 0 <= idx < len(self._history_payloads)
471
+ else None
472
+ )
473
+ payload = payload_hist or payload_meta
474
+ cid_dbg = None
475
+ if payload:
476
+ cid_dbg = payload.get("command_id") or payload.get("cid")
477
+ src = "hist" if payload_hist else ("meta" if payload_meta else "-")
478
+ log(
479
+ f"[HistoryExplorer] undo[{idx}] name='{name}', "
480
+ f"step_name='{(meta or {}).get('step_name')}', "
481
+ f"meta_keys={mk}, replayable={bool(payload)}, "
482
+ f"cid={cid_dbg}, src={src}"
483
+ )
484
+
485
+ cm = getattr(self.doc, "metadata", {}) or {}
486
+ mk = list(cm.keys())
487
+ payload_meta = _extract_cmd_payload_from_meta(cm)
488
+ payload_hist_last = None
489
+ for p in reversed(self._history_payloads):
490
+ if p:
491
+ payload_hist_last = p
492
+ break
493
+ payload = payload_hist_last or payload_meta
494
+ cid_dbg = None
495
+ if payload:
496
+ cid_dbg = payload.get("command_id") or payload.get("cid")
497
+ src = "hist" if payload_hist_last else ("meta" if payload_meta else "-")
498
+ log(
499
+ f"[HistoryExplorer] current image: "
500
+ f"meta_keys={mk}, replayable={bool(payload)}, "
501
+ f"cid={cid_dbg}, src={src}"
502
+ )
503
+ except Exception:
504
+ pass
505
+
506
+
507
+ # ---- Build rows ----
508
+ # We want:
509
+ # 1. Original Image (oldest snapshot)
510
+ # 2. State after 1st op → label = undo[0].name
511
+ # 3. State after 2nd op → label = undo[1].name
512
+ # ...
513
+ # N+1. State after Nth op (current image) → label = undo[N-1].name
514
+ # N+2. Current Image
515
+
516
+ # 1) Original Image (if any undo entries exist)
517
+ row_index = 0
518
+ if self.undo_entries:
519
+ orig_src, orig_meta, _ = self.undo_entries[0]
520
+ item = QListWidgetItem("1. Original Image")
521
+ self.history_list.addItem(item)
522
+ self.items.append((orig_src, orig_meta, "Original Image"))
523
+ row_index += 1
524
+
525
+ # 2) Per-operation states
526
+ n = len(self.undo_entries)
527
+ for op_idx in range(n):
528
+ op_name = self.undo_entries[op_idx][2] or f"Step {op_idx + 1}"
529
+
530
+ if op_idx + 1 < n:
531
+ src, meta, _ = self.undo_entries[op_idx + 1]
532
+ else:
533
+ # Last operation → use current image + metadata
534
+ src = getattr(self.doc, "image", None)
535
+ meta = getattr(self.doc, "metadata", {}) or {}
536
+
537
+ # 1) Prefer preset from headless history
538
+ payload = None
539
+ if 0 <= op_idx < len(self._history_payloads):
540
+ payload = self._history_payloads[op_idx]
541
+
542
+ # 2) Fallback: infer from metadata for tools that don't yet
543
+ # record into headless history (BN/WB, etc.)
544
+ if payload is None:
545
+ payload = _extract_cmd_payload_from_meta(meta)
546
+
547
+ is_replayable = payload is not None
548
+
549
+ label = f"{row_index + 1}. {op_name}"
550
+ if is_replayable:
551
+ label += " ⟲"
552
+
553
+ item = QListWidgetItem(label)
554
+ if is_replayable:
555
+ item.setData(Qt.ItemDataRole.UserRole, payload)
556
+ item.setToolTip("Replayable step. Alt+Drag to drop onto a view or desktop.")
557
+ self.history_list.addItem(item)
558
+
559
+ self.items.append((src, meta, op_name))
560
+ row_index += 1
561
+
562
+
563
+ # 3) Final "Current Image" row
564
+ cur_img = getattr(self.doc, "image", None)
565
+ cur_meta = getattr(self.doc, "metadata", {}) or {}
566
+
567
+ # Prefer the most recent headless history payload, if any
568
+ cur_payload = None
569
+ for p in reversed(self._history_payloads):
570
+ if p:
571
+ cur_payload = p
572
+ break
573
+
574
+ if cur_payload is None:
575
+ cur_payload = _extract_cmd_payload_from_meta(cur_meta)
576
+
577
+ cur_replay = cur_payload is not None
578
+
579
+ label = f"{row_index + 1}. Current Image"
580
+ if cur_replay:
581
+ label += " ⟲"
582
+ cur_item = QListWidgetItem(label)
583
+ if cur_replay:
584
+ cur_item.setData(Qt.ItemDataRole.UserRole, cur_payload)
585
+ cur_item.setToolTip("Replayable step. Alt+Drag to drop onto a view or desktop.")
586
+ self.history_list.addItem(cur_item)
587
+ self.items.append((cur_img, cur_meta, "Current Image"))
588
+
589
+
590
+ self.history_list.itemDoubleClicked.connect(self._open_preview)
591
+
592
+ row = QHBoxLayout()
593
+ btn_close = QPushButton("Close")
594
+ btn_close.clicked.connect(self.close)
595
+ row.addStretch(1)
596
+ row.addWidget(btn_close)
597
+ layout.addLayout(row)
598
+
599
+ def _open_preview(self, item):
600
+ row = self.history_list.row(item)
601
+ if 0 <= row < len(self.items):
602
+ src, meta, name = self.items[row]
603
+ if src is None:
604
+ QMessageBox.warning(self, "Preview", "No image stored for this step.")
605
+ return
606
+ pv = HistoryImagePreview(src, meta, self.doc, parent=self)
607
+ pv.setWindowTitle(item.text())
608
+ pv.show()
609
+ mw = self._find_main_window()
610
+ if mw and hasattr(mw, "_log"):
611
+ mw._log(f"History: preview opened → {item.text()}")
612
+ else:
613
+ QMessageBox.warning(self, "Preview", "Invalid selection.")
614
+
615
+ def _find_main_window(self):
616
+ p = self.parent()
617
+ while p is not None and not hasattr(p, "docman"):
618
+ p = p.parent()
619
+ return p
620
+
621
+
622
+ class HistoryImagePreview(QWidget):
623
+ """
624
+ Preview a single history entry with zoom/pan, optional display autostretch,
625
+ compare vs current, and restore.
626
+ """
627
+ def __init__(self, image_source: object, metadata: dict, document, parent=None):
628
+ super().__init__(parent, Qt.WindowType.Window)
629
+ self.doc = document
630
+ self.metadata = metadata or {}
631
+
632
+ # Resolve image source (ndarray or swap_id)
633
+ self.image_data = None
634
+ if isinstance(image_source, str):
635
+ # It's a swap ID
636
+ sm = get_swap_manager()
637
+ loaded = sm.load_state(image_source)
638
+ if loaded is not None:
639
+ self.image_data = loaded
640
+ else:
641
+ # Failed to load
642
+ self.image_data = None
643
+ else:
644
+ # Assume it's an ndarray
645
+ self.image_data = image_source
646
+
647
+ if self.image_data is None:
648
+ # Fallback placeholder?
649
+ self.image_data = np.zeros((100, 100, 3), dtype=np.float32)
650
+
651
+ self.zoom = 1.0
652
+ self._panning = False
653
+ self._pan_start = QPointF()
654
+ self._autostretch_on = False
655
+
656
+ self._qimg_src = None
657
+ self._buf8 = None
658
+
659
+ # UI
660
+ self.label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
661
+ self.scroll = QScrollArea(widgetResizable=False)
662
+ self.scroll.setWidget(self.label)
663
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
664
+ self.scroll.viewport().installEventFilter(self)
665
+ self.label.installEventFilter(self)
666
+
667
+ # controls
668
+ self.btn_stretch = QPushButton("Toggle AutoStretch")
669
+ self.btn_stretch.clicked.connect(self._toggle_autostretch)
670
+
671
+ self.btn_fit = QPushButton("Fit")
672
+ self.btn_fit.clicked.connect(self._fit_to_view)
673
+
674
+ self.btn_1to1 = QPushButton("1:1")
675
+ self.btn_1to1.clicked.connect(lambda: self._set_zoom(1.0))
676
+
677
+ self.btn_compare = QPushButton("Compare to Current…")
678
+ self.btn_compare.clicked.connect(self._open_compare)
679
+
680
+ self.btn_restore = QPushButton("Restore This Version")
681
+ self.btn_restore.clicked.connect(self._restore)
682
+
683
+ self.slider = QSlider(Qt.Orientation.Horizontal)
684
+ self.slider.setRange(10, 800)
685
+ self.slider.setValue(100)
686
+ self.slider.valueChanged.connect(lambda v: self._set_zoom(v/100.0))
687
+
688
+ ctrl = QHBoxLayout()
689
+ ctrl.addWidget(self.btn_stretch)
690
+ ctrl.addStretch(1)
691
+ ctrl.addWidget(self.btn_fit)
692
+ ctrl.addWidget(self.btn_1to1)
693
+ ctrl.addWidget(self.slider)
694
+ ctrl.addWidget(self.btn_compare)
695
+ ctrl.addWidget(self.btn_restore)
696
+
697
+ lay = QVBoxLayout(self)
698
+ lay.addWidget(self.scroll, 1)
699
+ lay.addLayout(ctrl)
700
+
701
+ self._rebuild_source()
702
+ self._fit_to_view()
703
+
704
+ # data → qimage
705
+ def _make_vis(self) -> np.ndarray:
706
+ f = _to_float01(self.image_data)
707
+ if f is None:
708
+ return None
709
+ if self._autostretch_on:
710
+ try:
711
+ return np.clip(autostretch(f, target_median=0.25, linked=False), 0, 1)
712
+ except Exception:
713
+ pass
714
+ return np.clip(f, 0, 1)
715
+
716
+ def _rebuild_source(self):
717
+ vis = self._make_vis()
718
+ if vis is None:
719
+ self.label.clear(); self._qimg_src = None; self._buf8 = None
720
+ return
721
+ self._qimg_src, self._buf8 = _mk_qimage_rgb8(vis)
722
+ self._update_scaled()
723
+
724
+ # zoom/pan
725
+ def _set_zoom(self, z: float):
726
+ self.zoom = float(max(0.05, min(z, 8.0)))
727
+ self.slider.blockSignals(True)
728
+ self.slider.setValue(int(self.zoom * 100))
729
+ self.slider.blockSignals(False)
730
+ self._update_scaled()
731
+
732
+ def _fit_to_view(self):
733
+ if self._qimg_src is None:
734
+ return
735
+ vp = self.scroll.viewport().size()
736
+ if self._qimg_src.width() == 0 or self._qimg_src.height() == 0:
737
+ return
738
+ s = min(vp.width() / self._qimg_src.width(), vp.height() / self._qimg_src.height())
739
+ self._set_zoom(max(0.05, s))
740
+
741
+ def _update_scaled(self):
742
+ if self._qimg_src is None:
743
+ return
744
+ sw = max(1, int(self._qimg_src.width() * self.zoom))
745
+ sh = max(1, int(self._qimg_src.height() * self.zoom))
746
+ scaled = self._qimg_src.scaled(sw, sh, Qt.AspectRatioMode.KeepAspectRatio,
747
+ Qt.TransformationMode.SmoothTransformation)
748
+ self.label.setPixmap(QPixmap.fromImage(scaled))
749
+ self.label.resize(scaled.size())
750
+
751
+ # actions
752
+ def _toggle_autostretch(self):
753
+ self._autostretch_on = not self._autostretch_on
754
+ self._rebuild_source()
755
+
756
+ def _open_compare(self):
757
+ cur = getattr(self.doc, "image", None)
758
+ if cur is None:
759
+ QMessageBox.warning(self, "Compare", "No current image to compare.")
760
+ return
761
+
762
+ win = QWidget(self, Qt.WindowType.Window)
763
+ win.setWindowTitle("Compare with Current")
764
+ win.resize(900, 700)
765
+
766
+ v = QVBoxLayout(win)
767
+ self.slider_widget = ComparisonSlider(self.image_data, cur, parent=win)
768
+ v.addWidget(self.slider_widget, 1)
769
+
770
+ bar = QHBoxLayout()
771
+ b_out = QPushButton("Zoom Out"); b_in = QPushButton("Zoom In")
772
+ b_fit = QPushButton("Fit"); b_1 = QPushButton("1:1")
773
+ b_st = QPushButton("Toggle AutoStretch")
774
+ b_out.clicked.connect(self.slider_widget.zoom_out)
775
+ b_in.clicked.connect(self.slider_widget.zoom_in)
776
+ b_fit.clicked.connect(self.slider_widget.fit_to_view)
777
+ b_1.clicked.connect(lambda: self.slider_widget.set_zoom(1.0))
778
+ b_st.clicked.connect(self.slider_widget.toggle_autostretch)
779
+
780
+ bar.addWidget(b_out); bar.addWidget(b_in); bar.addWidget(b_fit); bar.addWidget(b_1)
781
+ bar.addStretch(1); bar.addWidget(b_st)
782
+ v.addLayout(bar)
783
+
784
+ win.show()
785
+ mw = self._find_main_window()
786
+ if mw and hasattr(mw, "_log"):
787
+ mw._log("History: opened Compare with Current.")
788
+
789
+ def _restore(self):
790
+ try:
791
+ # Prefer a method that records step name if available
792
+ if hasattr(self.doc, "set_image"):
793
+ self.doc.set_image(self.image_data.copy(), {"step_name": "Restored from History"})
794
+ elif hasattr(self.doc, "update_image"):
795
+ self.doc.update_image(self.image_data.copy(), {"step_name": "Restored from History"})
796
+ else:
797
+ QMessageBox.critical(self, "Restore", "Document does not support setting image.")
798
+ return
799
+ mw = self._find_main_window()
800
+ if mw and hasattr(mw, "_log"):
801
+ mw._log("History: restored image from history.")
802
+ self.close()
803
+ except Exception as e:
804
+ QMessageBox.critical(self, "Restore failed", str(e))
805
+
806
+ def _find_main_window(self):
807
+ p = self.parent()
808
+ while p is not None and not hasattr(p, "docman"):
809
+ p = p.parent()
810
+ return p
811
+
812
+ # input
813
+ def eventFilter(self, obj, ev):
814
+ if obj is self.scroll.viewport():
815
+ if ev.type() == QEvent.Type.Wheel:
816
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
817
+ if self._qimg_src is None or self.label.pixmap() is None:
818
+ return True
819
+ factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
820
+ pos_vp = ev.position()
821
+ pos_lb = self.label.mapFrom(self.scroll.viewport(), pos_vp.toPoint())
822
+ old = self.label.pixmap().size()
823
+ rel_x = pos_lb.x() / max(1, old.width())
824
+ rel_y = pos_lb.y() / max(1, old.height())
825
+ self._set_zoom(self.zoom * factor)
826
+ new = self.label.pixmap().size()
827
+ hbar = self.scroll.horizontalScrollBar()
828
+ vbar = self.scroll.verticalScrollBar()
829
+ hbar.setValue(int(rel_x * new.width() - self.scroll.viewport().width()/2))
830
+ vbar.setValue(int(rel_y * new.height() - self.scroll.viewport().height()/2))
831
+ return True
832
+ return False
833
+
834
+ if obj is self.scroll.viewport() or obj is self.label:
835
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
836
+ self._panning = True
837
+ self._pan_start = ev.position()
838
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
839
+ return True
840
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
841
+ d = ev.position() - self._pan_start
842
+ hbar = self.scroll.horizontalScrollBar()
843
+ vbar = self.scroll.verticalScrollBar()
844
+ hbar.setValue(hbar.value() - int(d.x()))
845
+ vbar.setValue(vbar.value() - int(d.y()))
846
+ self._pan_start = ev.position()
847
+ return True
848
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
849
+ self._panning = False
850
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
851
+ return True
852
+
853
+ return super().eventFilter(obj, ev)
854
+
855
+
856
+ class ComparisonSlider(QWidget):
857
+ """Before/after slider with Ctrl+wheel zoom, Fit, 1:1, optional display autostretch."""
858
+ def __init__(self, before_image: np.ndarray, after_image: np.ndarray, parent=None):
859
+ super().__init__(parent)
860
+ self.before = np.asarray(before_image)
861
+ self.after = np.asarray(after_image)
862
+ self.zoom = 1.0
863
+ self.autostretch_on = False
864
+ self.slider_pos = 0.5
865
+
866
+ self._q_before = None; self._buf_before = None
867
+ self._q_after = None; self._buf_after = None
868
+ self.setMouseTracking(True)
869
+ self.setMinimumSize(400, 300)
870
+ self._rebuild()
871
+
872
+ def _mk_vis(self, a: np.ndarray) -> np.ndarray:
873
+ f = _to_float01(a)
874
+ if self.autostretch_on:
875
+ try:
876
+ return np.clip(autostretch(f, target_median=0.25, linked=False), 0, 1)
877
+ except Exception:
878
+ pass
879
+ return np.clip(f, 0, 1)
880
+
881
+ def _rebuild(self):
882
+ qb, bb = _mk_qimage_rgb8(self._mk_vis(self.before))
883
+ qa, ba = _mk_qimage_rgb8(self._mk_vis(self.after))
884
+ self._q_before, self._buf_before = qb, bb
885
+ self._q_after, self._buf_after = qa, ba
886
+
887
+ # public controls
888
+ def set_zoom(self, z: float):
889
+ self.zoom = float(max(0.05, min(z, 8.0))); self.update()
890
+ def zoom_in(self): self.set_zoom(self.zoom * 1.25)
891
+ def zoom_out(self): self.set_zoom(self.zoom / 1.25)
892
+ def fit_to_view(self):
893
+ if not self._q_before: return
894
+ W,H = self.width(), self.height()
895
+ iw,ih = self._q_before.width(), self._q_before.height()
896
+ if iw==0 or ih==0: return
897
+ self.set_zoom(min(W/iw, H/ih))
898
+
899
+ def toggle_autostretch(self):
900
+ self.autostretch_on = not self.autostretch_on
901
+ self._rebuild(); self.update()
902
+
903
+ # painting & input
904
+ def paintEvent(self, _ev):
905
+ if not self._q_before or not self._q_after:
906
+ return
907
+ p = QPainter(self)
908
+ W,H = self.width(), self.height()
909
+ iw, ih = self._q_before.width(), self._q_before.height()
910
+ if iw==0 or ih==0: return
911
+ s = min(W/iw, H/ih) * self.zoom
912
+ tw, th = int(iw*s), int(ih*s)
913
+ b = self._q_before.scaled(tw, th, Qt.AspectRatioMode.KeepAspectRatio,
914
+ Qt.TransformationMode.SmoothTransformation)
915
+ a = self._q_after.scaled(tw, th, Qt.AspectRatioMode.KeepAspectRatio,
916
+ Qt.TransformationMode.SmoothTransformation)
917
+ ox = (W - b.width()) // 2
918
+ oy = (H - b.height()) // 2
919
+ cut = int(W * self.slider_pos)
920
+
921
+ p.save(); p.setClipRect(0, 0, cut, H); p.drawImage(ox, oy, b); p.restore()
922
+ p.save(); p.setClipRect(cut, 0, W-cut, H); p.drawImage(ox, oy, a); p.restore()
923
+
924
+ p.setPen(Qt.GlobalColor.red); p.drawLine(cut, 0, cut, H)
925
+
926
+ def mousePressEvent(self, ev):
927
+ if ev.button() == Qt.MouseButton.LeftButton:
928
+ self._set_div(ev.position().x())
929
+ def mouseMoveEvent(self, ev):
930
+ if ev.buttons() & Qt.MouseButton.LeftButton:
931
+ self._set_div(ev.position().x())
932
+ def _set_div(self, x):
933
+ self.slider_pos = min(max(x / max(1, self.width()), 0.0), 1.0); self.update()
934
+ def wheelEvent(self, ev):
935
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
936
+ self.set_zoom(self.zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
937
+ ev.accept()
938
+ else:
939
+ ev.ignore()