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,414 @@
1
+ # pro/image_combine.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt, QPoint, QRect, QEvent
6
+ from PyQt6.QtGui import QImage, QPixmap
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QComboBox, QSlider,
9
+ QCheckBox, QScrollArea, QPushButton, QDialogButtonBox, QApplication, QMessageBox
10
+ )
11
+
12
+ # NEW: optional cv2 for fast gray/resize
13
+ try:
14
+ import cv2
15
+ except Exception:
16
+ cv2 = None
17
+
18
+ # Shared utilities
19
+ from setiastro.saspro.widgets.image_utils import (
20
+ to_float01 as _to_float01,
21
+ extract_mask_from_document as _active_mask_array_from_doc
22
+ )
23
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
24
+
25
+
26
+ _LUMA_WEIGHTS = np.array([0.2126, 0.7152, 0.0722], dtype=np.float32)
27
+
28
+ # ---------- helpers ----------
29
+ def _doc_name(d) -> str:
30
+ try: return d.display_name()
31
+ except Exception: return "Untitled"
32
+
33
+ def _rgb_to_luma(img: np.ndarray) -> np.ndarray:
34
+ f = _to_float01(img)
35
+ if f.ndim == 2: return f
36
+ if f.ndim == 3 and f.shape[2] == 1: return f[..., 0]
37
+ if f.ndim == 3 and f.shape[2] == 3:
38
+ w = _LUMA_WEIGHTS
39
+ return f[..., 0]*w[0] + f[..., 1]*w[1] + f[..., 2]*w[2]
40
+ raise ValueError(f"Unsupported image shape: {img.shape}")
41
+
42
+ def _recombine_luma_into_rgb(Y: np.ndarray, RGB: np.ndarray) -> np.ndarray:
43
+ rgb = _to_float01(RGB)
44
+ if rgb.ndim != 3 or rgb.shape[2] != 3:
45
+ raise ValueError("Recombine requires RGB target.")
46
+ w = _LUMA_WEIGHTS
47
+ orig_Y = rgb[..., 0]*w[0] + rgb[..., 1]*w[1] + rgb[..., 2]*w[2]
48
+ chroma = rgb / (orig_Y[..., None] + 1e-6)
49
+ return np.clip(chroma * Y[..., None], 0.0, 1.0)
50
+
51
+ def _blend_dispatch(A: np.ndarray, B: np.ndarray, mode: str, alpha: float) -> np.ndarray:
52
+ A = _to_float01(A); B = _to_float01(B)
53
+ if A.ndim == 2: A = A[..., None]
54
+ if B.ndim == 2: B = B[..., None]
55
+ if A.shape != B.shape:
56
+ raise ValueError("Images must have same size/channels.")
57
+
58
+ if mode == "Average": return np.clip(0.5*(A+B), 0.0, 1.0)
59
+ if mode == "Blend": return np.clip(A*(1-alpha) + B*alpha, 0.0, 1.0)
60
+ def mix(x): return np.clip(A*(1-alpha) + x*alpha, 0.0, 1.0)
61
+
62
+ eps = 1e-6
63
+ if mode == "Add": return mix(np.clip(A+B, 0.0, 1.0))
64
+ if mode == "Subtract": return mix(np.clip(A-B, 0.0, 1.0))
65
+ if mode == "Multiply": return mix(A*B)
66
+ if mode == "Divide": return mix(np.clip(A/(B+eps), 0.0, 1.0))
67
+ if mode == "Screen": return mix(1.0 - (1.0-A)*(1.0-B))
68
+ if mode == "Overlay": return mix(np.clip(np.where(A<=0.5, 2*A*B, 1-2*(1-A)*(1-B)), 0.0, 1.0))
69
+ if mode == "Difference": return mix(np.abs(A-B))
70
+ return np.clip(A*(1-alpha) + B*alpha, 0.0, 1.0)
71
+
72
+ # ---------- mask helpers ----------
73
+ def _resize_mask_nearest(m: np.ndarray, shape_hw: tuple[int,int]) -> np.ndarray:
74
+ """Resize mask to (H,W) with nearest neighbor."""
75
+ h, w = shape_hw
76
+ if m.shape == (h, w):
77
+ return m
78
+ if cv2 is not None:
79
+ return cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST).astype(np.float32, copy=False)
80
+ # fallback NN without cv2
81
+ yi = (np.linspace(0, m.shape[0]-1, h)).astype(np.int32)
82
+ xi = (np.linspace(0, m.shape[1]-1, w)).astype(np.int32)
83
+ return m[yi][:, xi].astype(np.float32, copy=False)
84
+
85
+ # ---------- dialog ----------
86
+ class ImageCombineDialog(QDialog):
87
+ """
88
+ Views-based Image Combine with realtime preview, zoom/pan, luma-only, and mask overlay.
89
+ Output: replace A or create new view.
90
+ """
91
+ def __init__(self, main_window):
92
+ super().__init__(main_window)
93
+ self.setWindowTitle("Image Combine")
94
+ self.mw = main_window
95
+ self.dm = getattr(main_window, "doc_manager", None) or getattr(main_window, "dm", None)
96
+ self.zoom = 1.0
97
+ self._pan_origin = None
98
+ self._hstart = 0; self._vstart = 0
99
+ self._pix = None # last preview QPixmap
100
+
101
+ # --- UI ---
102
+ root = QVBoxLayout(self)
103
+
104
+ frm = QFormLayout()
105
+ self.cbA = QComboBox(); self.cbB = QComboBox()
106
+ frm.addRow("Source A:", self.cbA)
107
+ frm.addRow("Source B:", self.cbB)
108
+
109
+ row = QHBoxLayout()
110
+ row.addWidget(QLabel("Mode:"))
111
+ self.cbMode = QComboBox()
112
+ self.cbMode.addItems(["Average","Add","Subtract","Blend","Multiply","Divide","Screen","Overlay","Difference"])
113
+ row.addWidget(self.cbMode, 1)
114
+ row.addWidget(QLabel("Opacity:"))
115
+ self.slAlpha = QSlider(Qt.Orientation.Horizontal); self.slAlpha.setRange(0,100); self.slAlpha.setValue(100)
116
+ row.addWidget(self.slAlpha, 2)
117
+ frm.addRow(row)
118
+
119
+ # luma-only
120
+ self.chkLuma = QCheckBox("Combine luminance only (keep A’s color)")
121
+ frm.addRow(self.chkLuma)
122
+
123
+ # mask overlay
124
+ mrow = QHBoxLayout()
125
+ self.chkOverlay = QCheckBox("Show mask overlay")
126
+ self.chkInvert = QCheckBox("Invert mask")
127
+ mrow.addWidget(self.chkOverlay)
128
+ mrow.addWidget(self.chkInvert)
129
+ mrow.addWidget(QLabel("Overlay opacity:"))
130
+ self.slOverlay = QSlider(Qt.Orientation.Horizontal); self.slOverlay.setRange(5,95); self.slOverlay.setValue(40)
131
+ mrow.addWidget(self.slOverlay, 1)
132
+ frm.addRow(mrow)
133
+ root.addLayout(frm)
134
+
135
+ # preview
136
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
137
+ self.lbl = QLabel(""); self.lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
138
+ self.scroll.setWidget(self.lbl)
139
+ root.addWidget(self.scroll, 1)
140
+
141
+ # zoom (themed)
142
+ zrow = QHBoxLayout()
143
+
144
+ btnOut = themed_toolbtn("zoom-out", "Zoom Out")
145
+ btnFit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
146
+ btnIn = themed_toolbtn("zoom-in", "Zoom In")
147
+
148
+ btnOut.clicked.connect(self._zoom_out)
149
+ btnIn .clicked.connect(self._zoom_in)
150
+ btnFit.clicked.connect(self._fit)
151
+
152
+ zrow.addWidget(btnOut)
153
+ zrow.addWidget(btnFit)
154
+ zrow.addWidget(btnIn)
155
+ root.addLayout(zrow)
156
+
157
+ # buttons
158
+ btns = QDialogButtonBox()
159
+ self.btnApply = btns.addButton("Apply", QDialogButtonBox.ButtonRole.AcceptRole)
160
+ self.btnClose = btns.addButton("Close", QDialogButtonBox.ButtonRole.RejectRole)
161
+ self.btnClose.clicked.connect(self.reject)
162
+ self.btnApply.clicked.connect(self._commit)
163
+ root.addWidget(btns)
164
+
165
+ # hooks
166
+ for w in (self.cbA, self.cbB, self.cbMode):
167
+ w.currentIndexChanged.connect(self._update_preview)
168
+ self.slAlpha.valueChanged.connect(self._update_preview)
169
+ self.chkLuma.toggled.connect(self._update_preview)
170
+ self.chkOverlay.toggled.connect(self._update_preview)
171
+ self.chkInvert.toggled.connect(self._update_preview)
172
+ self.slOverlay.valueChanged.connect(self._update_preview)
173
+ self.scroll.viewport().installEventFilter(self)
174
+
175
+ self._populate_docs()
176
+ self._update_preview()
177
+
178
+ # ---------- doc utilities ----------
179
+ def _open_docs(self) -> list:
180
+ if not self.dm: return []
181
+ docs = list(getattr(self.dm, "_docs", []) or [])
182
+ return [d for d in docs if getattr(d, "image", None) is not None]
183
+
184
+ def _active_doc(self):
185
+ if self.dm and hasattr(self.dm, "get_active_document"):
186
+ return self.dm.get_active_document()
187
+ return None
188
+
189
+ def _populate_docs(self):
190
+ docs = self._open_docs()
191
+ self.cbA.blockSignals(True); self.cbB.blockSignals(True)
192
+ self.cbA.clear(); self.cbB.clear()
193
+ for d in docs:
194
+ self.cbA.addItem(_doc_name(d), userData=d)
195
+ self.cbB.addItem(_doc_name(d), userData=d)
196
+ self.cbA.blockSignals(False); self.cbB.blockSignals(False)
197
+ if docs:
198
+ act = self._active_doc()
199
+ if act in docs:
200
+ self.cbA.setCurrentIndex(docs.index(act))
201
+ # B defaults to “other”
202
+ j = 0 if len(docs) < 2 else (1 if docs[0] is act else 0)
203
+ self.cbB.setCurrentIndex(j)
204
+
205
+ # ---------- mask helpers ----------
206
+ def _mask01_for_doc(self, doc, *, shape_hw: tuple[int,int], channels: int | None, invert_flag: bool):
207
+ """
208
+ Return mask for the given doc resized to (H,W).
209
+ If channels is 3 and mask is 2D, expand with np.repeat.
210
+ """
211
+ m = _active_mask_array_from_doc(doc)
212
+ if m is None:
213
+ # last-resort fallback to global mask manager (in case user applied a global mask)
214
+ mm = getattr(getattr(self.mw, "image_manager", None), "mask_manager", None)
215
+ if mm and hasattr(mm, "get_applied_mask"):
216
+ try:
217
+ mg = mm.get_applied_mask()
218
+ if mg is not None:
219
+ mg = np.asarray(mg).astype(np.float32)
220
+ if mg.ndim == 3:
221
+ mg = mg.mean(axis=2)
222
+ if mg.max() > 1.0:
223
+ mg /= 255.0
224
+ m = np.clip(mg, 0.0, 1.0)
225
+ except Exception:
226
+ m = None
227
+ if m is None:
228
+ return None
229
+
230
+ m = _resize_mask_nearest(m, shape_hw)
231
+ if invert_flag:
232
+ m = 1.0 - m
233
+ m = np.clip(m, 0.0, 1.0)
234
+ if channels and channels > 1 and m.ndim == 2:
235
+ m = np.repeat(m[:, :, None], channels, axis=2)
236
+ return m
237
+
238
+ def _apply_overlay(self, img, mask, opacity):
239
+ # show protected region (A) as red wash: vis = 1 - m
240
+ vis = 1.0 - np.clip(mask, 0.0, 1.0)
241
+ if img.ndim == 2:
242
+ rgb = np.stack([img, img, img], axis=-1)
243
+ else:
244
+ rgb = img
245
+ overlay = np.zeros_like(rgb, dtype=np.float32); overlay[..., 0] = 1.0
246
+ if vis.ndim == 2: vis = vis[..., None]
247
+ return np.clip(rgb*(1.0 - vis*opacity) + overlay*(vis*opacity), 0.0, 1.0)
248
+
249
+ # ---------- preview ----------
250
+ def _update_preview(self, *_):
251
+ A = self.cbA.currentData(); B = self.cbB.currentData()
252
+ if not (A and B): return
253
+ imgA = getattr(A, "image", None); imgB = getattr(B, "image", None)
254
+ if imgA is None or imgB is None: return
255
+ if imgA.shape[:2] != imgB.shape[:2]:
256
+ self.lbl.setText("Images must be the same size.")
257
+ return
258
+
259
+ alpha = self.slAlpha.value()/100.0
260
+ mode = self.cbMode.currentText()
261
+
262
+ try:
263
+ if self.chkLuma.isChecked():
264
+ if imgA.ndim != 3 or imgA.shape[2] != 3:
265
+ self.lbl.setText("Luminance mode requires RGB A."); return
266
+ YA = _rgb_to_luma(imgA)
267
+ YB = _rgb_to_luma(imgB)
268
+ Ymix = _blend_dispatch(YA[...,None], YB[...,None], mode, alpha)[...,0]
269
+
270
+ # mask from destination doc (A)
271
+ m = self._mask01_for_doc(A, shape_hw=Ymix.shape[:2], channels=None,
272
+ invert_flag=self.chkInvert.isChecked())
273
+ if m is not None:
274
+ Ymix = Ymix*m + YA*(1.0 - m)
275
+
276
+ blended = _recombine_luma_into_rgb(Ymix, imgA)
277
+
278
+ else:
279
+ A3 = imgA if imgA.ndim == 3 else imgA[..., None]
280
+ B3 = imgB if imgB.ndim == 3 else imgB[..., None]
281
+ blended = _blend_dispatch(A3, B3, mode, alpha)
282
+ if imgA.ndim == 2:
283
+ blended = blended[...,0]
284
+
285
+ # mask from destination doc (A)
286
+ m = self._mask01_for_doc(
287
+ A, shape_hw=blended.shape[:2],
288
+ channels=(blended.shape[2] if blended.ndim == 3 else 1),
289
+ invert_flag=self.chkInvert.isChecked()
290
+ )
291
+ if m is not None:
292
+ blended = np.clip(blended*m + _to_float01(imgA)*(1.0 - m), 0.0, 1.0)
293
+
294
+ # optional red overlay
295
+ if self.chkOverlay.isChecked():
296
+ m = self._mask01_for_doc(
297
+ A, shape_hw=blended.shape[:2],
298
+ channels=(blended.shape[2] if blended.ndim == 3 else 1),
299
+ invert_flag=self.chkInvert.isChecked()
300
+ )
301
+ if m is not None:
302
+ blended = self._apply_overlay(_to_float01(blended), m, self.slOverlay.value()/100.0)
303
+
304
+ # to pixmap
305
+ f = _to_float01(blended); h, w = f.shape[:2]
306
+ if f.ndim == 2:
307
+ buf = (f*255).astype(np.uint8); q = QImage(buf.data, w, h, w, QImage.Format.Format_Grayscale8)
308
+ else:
309
+ buf = (f*255).astype(np.uint8); q = QImage(buf.data, w, h, 3*w, QImage.Format.Format_RGB888)
310
+ self._pix = QPixmap.fromImage(q)
311
+ self._apply_zoom()
312
+ except Exception as e:
313
+ self.lbl.setText(f"Error: {e}")
314
+
315
+ # ---------- apply ----------
316
+ def _commit(self):
317
+ A = self.cbA.currentData(); B = self.cbB.currentData()
318
+ if not (A and B): return
319
+ imgA = getattr(A, "image", None); imgB = getattr(B, "image", None)
320
+ if imgA is None or imgB is None: return
321
+ if imgA.shape[:2] != imgB.shape[:2]:
322
+ QMessageBox.warning(self, "Image Combine", "Image sizes must match."); return
323
+
324
+ alpha = self.slAlpha.value()/100.0
325
+ mode = self.cbMode.currentText()
326
+
327
+ try:
328
+ if self.chkLuma.isChecked():
329
+ YA = _rgb_to_luma(imgA); YB = _rgb_to_luma(imgB)
330
+ Ymix = _blend_dispatch(YA[...,None], YB[...,None], mode, alpha)[...,0]
331
+
332
+ m = self._mask01_for_doc(A, shape_hw=Ymix.shape[:2], channels=None,
333
+ invert_flag=self.chkInvert.isChecked())
334
+ if m is not None:
335
+ Ymix = Ymix*m + YA*(1.0 - m)
336
+
337
+ result = _recombine_luma_into_rgb(Ymix, imgA)
338
+ step = f"Luminance {mode}"
339
+ else:
340
+ A3 = imgA if imgA.ndim == 3 else imgA[..., None]
341
+ B3 = imgB if imgB.ndim == 3 else imgB[..., None]
342
+ result = _blend_dispatch(A3, B3, mode, alpha)
343
+ if imgA.ndim == 2: result = result[...,0]
344
+
345
+ m = self._mask01_for_doc(
346
+ A, shape_hw=result.shape[:2],
347
+ channels=(result.shape[2] if result.ndim == 3 else 1),
348
+ invert_flag=self.chkInvert.isChecked()
349
+ )
350
+ if m is not None:
351
+ result = np.clip(result*m + _to_float01(imgA)*(1.0 - m), 0.0, 1.0)
352
+ step = f"{mode} Combine"
353
+
354
+ result = _to_float01(result)
355
+
356
+ # Replace A (overwrite active view) or create new?
357
+ replace = True
358
+ if replace:
359
+ if hasattr(A, "set_image"):
360
+ A.set_image(result, step_name=f"Image Combine: {step}")
361
+ else:
362
+ A.image = result
363
+ try: self.mw._log(f"Image Combine → replaced '{_doc_name(A)}' ({step})")
364
+ except Exception as e:
365
+ import logging
366
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
367
+ else:
368
+ newdoc = self.dm.create_document(result, metadata={
369
+ "display_name": f"Combined ({step})",
370
+ "bit_depth": "32-bit floating point",
371
+ "is_mono": (result.ndim == 2),
372
+ "source": f"Combine: {step}",
373
+ }, name=f"Combined ({step})")
374
+ self.mw._spawn_subwindow_for(newdoc)
375
+ try: self.mw._log(f"Image Combine → new view '{_doc_name(newdoc)}' ({step})")
376
+ except Exception as e:
377
+ import logging
378
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
379
+
380
+ except Exception as e:
381
+ QMessageBox.critical(self, "Image Combine", f"Failed:\n{e}")
382
+
383
+ # ---------- zoom/pan ----------
384
+ def _apply_zoom(self):
385
+ if self._pix is None: return
386
+ scaled = self._pix.scaled(self._pix.size()*self.zoom, Qt.AspectRatioMode.KeepAspectRatio,
387
+ Qt.TransformationMode.SmoothTransformation)
388
+ self.lbl.setPixmap(scaled)
389
+
390
+ def _zoom_in(self): self.zoom *= 1.25; self._apply_zoom()
391
+ def _zoom_out(self): self.zoom /= 1.25; self._apply_zoom()
392
+ def _fit(self):
393
+ if self._pix is None: return
394
+ area = self.scroll.viewport().size(); pix = self._pix.size()
395
+ sx = area.width()/max(1,pix.width()); sy = area.height()/max(1,pix.height())
396
+ self.zoom = min(sx, sy, 1.0); self._apply_zoom()
397
+
398
+ def eventFilter(self, src, ev):
399
+ if src is self.scroll.viewport():
400
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
401
+ self._pan_origin = ev.pos()
402
+ self._hstart = self.scroll.horizontalScrollBar().value()
403
+ self._vstart = self.scroll.verticalScrollBar().value()
404
+ return True
405
+ if ev.type() == QEvent.Type.MouseMove and self._pan_origin is not None:
406
+ d = ev.pos() - self._pan_origin
407
+ self.scroll.horizontalScrollBar().setValue(self._hstart - d.x())
408
+ self.scroll.verticalScrollBar().setValue(self._vstart - d.y())
409
+ return True
410
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
411
+ self._pan_origin = None; return True
412
+ return False
413
+ return super().eventFilter(src, ev)
414
+