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,1053 @@
1
+ # pro/perfect_palette_picker.py
2
+ from __future__ import annotations
3
+ import os
4
+ import numpy as np
5
+ from PIL import Image
6
+ import cv2
7
+ from PyQt6.QtCore import Qt, QSize, QEvent, QTimer, QPoint, pyqtSignal
8
+ from PyQt6.QtWidgets import (
9
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea,
10
+ QFileDialog, QInputDialog, QMessageBox, QGridLayout, QCheckBox, QSizePolicy, QDialog
11
+ )
12
+ from PyQt6.QtGui import QPixmap, QImage, QIcon, QPainter, QPen, QColor, QFont, QFontMetrics, QCursor
13
+
14
+ # legacy loader (same one DocManager uses)
15
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
16
+
17
+ # your statistical stretch (mono + color) like SASv2
18
+ # (same signatures you use elsewhere)
19
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
20
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
21
+
22
+ class PaletteAdjustDialog(QDialog):
23
+ adjusted_image = pyqtSignal(np.ndarray)
24
+
25
+ def __init__(self, base_rgb, palette_name, ha_src, oiii_src, sii_src, owner):
26
+ from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QSlider
27
+ from PyQt6.QtCore import QTimer, Qt, QPoint, QEvent
28
+ super().__init__(owner)
29
+ self.setWindowTitle("Adjust Palette Intensities")
30
+ self.setWindowFlag(Qt.WindowType.Window, True)
31
+ self.setModal(False)
32
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
33
+
34
+ self.base_rgb = base_rgb.astype(np.float32)
35
+ self.palette_name = palette_name
36
+ self.ha_src = ha_src
37
+ self.oiii_src = oiii_src
38
+ self.sii_src = sii_src
39
+ self.owner = owner
40
+
41
+ self.ha_factor = 1.0
42
+ self.oiii_factor = 1.0
43
+ self.sii_factor = 1.0
44
+
45
+ self._debounce = QTimer(self); self._debounce.setInterval(300); self._debounce.setSingleShot(True)
46
+ self._debounce.timeout.connect(self._update_preview)
47
+
48
+ self.zoom_factor = 1.0
49
+ self._dragging = False
50
+ self._last_pos = QPoint()
51
+
52
+ vlayout = QVBoxLayout(self)
53
+
54
+ # Zoom controls
55
+ zoom_layout = QHBoxLayout()
56
+ btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
57
+ btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
58
+ btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
59
+ zoom_layout.addWidget(btn_zoom_in); zoom_layout.addWidget(btn_zoom_out); zoom_layout.addWidget(btn_fit)
60
+ vlayout.addLayout(zoom_layout)
61
+
62
+ # Preview
63
+ self.preview_area = QScrollArea(self); self.preview_area.setWidgetResizable(True)
64
+ self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
65
+ self.preview_label.setCursor(Qt.CursorShape.OpenHandCursor)
66
+ self.preview_label.setMouseTracking(True)
67
+ self.preview_area.setWidget(self.preview_label)
68
+ self.preview_label.installEventFilter(self)
69
+ vlayout.addWidget(self.preview_area, stretch=1)
70
+
71
+ # Sliders
72
+ for name in ("Ha","OIII","SII"):
73
+ row = QHBoxLayout()
74
+ row.addWidget(QLabel(f"{name} Intensity:", self))
75
+ sl = QSlider(Qt.Orientation.Horizontal, self); sl.setRange(0,200); sl.setValue(100)
76
+ sl.valueChanged.connect(self._on_slider_change)
77
+ setattr(self, f"_{name.lower()}_slider", sl)
78
+ row.addWidget(sl)
79
+ vlayout.addLayout(row)
80
+
81
+ # Buttons
82
+ btns = QHBoxLayout(); btns.addStretch()
83
+ accept = QPushButton("Accept", self); accept.clicked.connect(self._on_accept)
84
+ reset = QPushButton("Reset", self); reset.clicked.connect(self._on_reset)
85
+ discard = QPushButton("Discard",self); discard.clicked.connect(self.reject)
86
+ btns.addWidget(accept); btns.addWidget(reset); btns.addWidget(discard)
87
+ vlayout.addLayout(btns)
88
+
89
+ self._update_preview()
90
+
91
+ def _on_slider_change(self, _):
92
+ self.ha_factor = self._ha_slider.value()/100.0
93
+ self.oiii_factor = self._oiii_slider.value()/100.0
94
+ self.sii_factor = self._sii_slider.value()/100.0
95
+ self._debounce.start()
96
+
97
+ def _update_preview(self):
98
+ ha = (self.ha_src * self.ha_factor) if self.ha_src is not None else None
99
+ oo = (self.oiii_src * self.oiii_factor) if self.oiii_src is not None else None
100
+ si = (self.sii_src * self.sii_factor) if self.sii_src is not None else None
101
+
102
+ r,g,b = self.owner._map_channels_or_special(self.palette_name, ha, oo, si)
103
+
104
+ # --- make sure channels match the base palette size ---
105
+ H, W = self.base_rgb.shape[:2]
106
+ def fit(ch):
107
+ if ch is None: return None
108
+ if ch.shape[:2] != (H, W):
109
+ return self.owner._resize_to(ch, (W, H))
110
+ return ch
111
+ r, g, b = fit(r), fit(g), fit(b)
112
+ # ------------------------------------------------------
113
+
114
+ img = np.zeros_like(self.base_rgb, dtype=np.float32)
115
+ if r is not None: img[...,0] = r
116
+ if g is not None: img[...,1] = g
117
+ if b is not None: img[...,2] = b
118
+ m = float(img.max()) or 1.0
119
+ img = np.clip(img/m, 0.0, 1.0)
120
+
121
+ qimg = self.owner._to_qimage(img)
122
+ self._base_pixmap = QPixmap.fromImage(qimg)
123
+ self._rescale_pixmap()
124
+
125
+ def _rescale_pixmap(self):
126
+ if not hasattr(self, "_base_pixmap"): return
127
+ w = int(self._base_pixmap.width() * self.zoom_factor)
128
+ h = int(self._base_pixmap.height() * self.zoom_factor)
129
+ scaled = self._base_pixmap.scaled(w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
130
+ self._current_pixmap = scaled
131
+ self.preview_label.setPixmap(scaled)
132
+ self.preview_label.resize(scaled.size())
133
+
134
+ def _change_zoom(self, factor: float):
135
+ self.zoom_factor = max(0.1, min(10.0, self.zoom_factor * factor))
136
+ self._rescale_pixmap()
137
+
138
+ def _fit_to_preview(self):
139
+ if not hasattr(self, "_base_pixmap"): return
140
+ vp_w = self.preview_area.viewport().width()
141
+ self.zoom_factor = vp_w / max(1, self._base_pixmap.width())
142
+ self._rescale_pixmap()
143
+
144
+ def _on_reset(self):
145
+ for s in (self._ha_slider, self._oiii_slider, self._sii_slider):
146
+ s.setValue(100)
147
+ self._on_slider_change(None)
148
+
149
+ def _on_accept(self):
150
+ ha = (self.ha_src * self.ha_factor) if self.ha_src is not None else None
151
+ oo = (self.oiii_src * self.oiii_factor) if self.oiii_src is not None else None
152
+ si = (self.sii_src * self.sii_factor) if self.sii_src is not None else None
153
+
154
+ r,g,b = self.owner._map_channels_or_special(self.palette_name, ha, oo, si)
155
+
156
+ # match base size
157
+ H, W = self.base_rgb.shape[:2]
158
+ def fit(ch):
159
+ if ch is None: return None
160
+ if ch.shape[:2] != (H, W):
161
+ return self.owner._resize_to(ch, (W, H))
162
+ return ch
163
+ r, g, b = fit(r), fit(g), fit(b)
164
+
165
+ final = np.zeros_like(self.base_rgb, dtype=np.float32)
166
+ if r is not None: final[...,0] = r
167
+ if g is not None: final[...,1] = g
168
+ if b is not None: final[...,2] = b
169
+
170
+ m = float(final.max()) or 1.0
171
+ final = np.clip(final/m, 0.0, 1.0)
172
+
173
+ self.adjusted_image.emit(final)
174
+ self.accept()
175
+
176
+ def eventFilter(self, obj, evt):
177
+ if obj is self.preview_label:
178
+ if evt.type() == QEvent.Type.MouseButtonPress and evt.button() == Qt.MouseButton.LeftButton:
179
+ self._dragging = True; self._last_pos = evt.pos()
180
+ self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor); return True
181
+ if evt.type() == QEvent.Type.MouseMove and self._dragging:
182
+ d = evt.pos() - self._last_pos; self._last_pos = evt.pos()
183
+ self.preview_area.horizontalScrollBar().setValue(self.preview_area.horizontalScrollBar().value() - d.x())
184
+ self.preview_area.verticalScrollBar().setValue(self.preview_area.verticalScrollBar().value() - d.y())
185
+ return True
186
+ if evt.type() == QEvent.Type.MouseButtonRelease and evt.button() == Qt.MouseButton.LeftButton:
187
+ self._dragging = False; self.preview_label.setCursor(Qt.CursorShape.OpenHandCursor); return True
188
+ if evt.type() == QEvent.Type.Wheel:
189
+ self._change_zoom(1.1 if evt.angleDelta().y() > 0 else 0.9); return True
190
+ return super().eventFilter(obj, evt)
191
+
192
+
193
+ class PerfectPalettePicker(QWidget):
194
+ THUMB_CROP = 512 # side length for thumbnail center crops
195
+ PALETTES = [
196
+ "SHO","HOO","HSO","HOS",
197
+ "OSS","OHH","OSH","OHS",
198
+ "HSS","Realistic1","Realistic2","Foraxx"
199
+ ]
200
+
201
+ def __init__(self, doc_manager=None, parent=None):
202
+ super().__init__(parent)
203
+ self.doc_manager = doc_manager
204
+ self.setWindowTitle("Perfect Palette Picker")
205
+
206
+ # raw channels (float32 ~[0..1])
207
+ self.ha = None
208
+ self.oiii = None
209
+ self.sii = None
210
+ self.osc1 = None
211
+ self.osc2 = None
212
+
213
+ # stretched cache (per input name → stretched array)
214
+ self._stretched: dict[str, np.ndarray] = {}
215
+
216
+ self.final = None
217
+ self.current_palette = None
218
+ self._thumb_base_pm: dict[str, QPixmap] = {} # palette name -> base pixmap (image only)
219
+ self._selected_name: str | None = None
220
+
221
+ # thumbs
222
+ self._thumb_buttons: dict[str, QPushButton] = {}
223
+
224
+ self._base_pm: QPixmap | None = None
225
+ self._zoom = 1.0
226
+ self._min_zoom = 0.05
227
+ self._max_zoom = 6.0
228
+ self._panning = False
229
+ self._pan_last: QPoint | None = None
230
+
231
+ self._build_ui()
232
+
233
+ # ---------------- UI ----------------
234
+ def _build_ui(self):
235
+ root = QHBoxLayout(self)
236
+
237
+ # -------- left controls
238
+ left = QVBoxLayout()
239
+ left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(300)
240
+
241
+ left.addWidget(QLabel("<b>Load channels</b>"))
242
+
243
+ # Load buttons + status labels
244
+ self.btn_ha = QPushButton("Load Ha…"); self.btn_ha.clicked.connect(lambda: self._load_channel("Ha"))
245
+ self.btn_oiii = QPushButton("Load OIII…"); self.btn_oiii.clicked.connect(lambda: self._load_channel("OIII"))
246
+ self.btn_sii = QPushButton("Load SII…"); self.btn_sii.clicked.connect(lambda: self._load_channel("SII"))
247
+ self.btn_osc1 = QPushButton("Load OSC1 (Ha/OIII)…"); self.btn_osc1.clicked.connect(lambda: self._load_channel("OSC1"))
248
+ self.btn_osc2 = QPushButton("Load OSC2 (SII/OIII)…"); self.btn_osc2.clicked.connect(lambda: self._load_channel("OSC2"))
249
+
250
+ self.lbl_ha = QLabel("No Ha loaded.")
251
+ self.lbl_oiii = QLabel("No OIII loaded.")
252
+ self.lbl_sii = QLabel("No SII loaded.")
253
+ self.lbl_osc1 = QLabel("No OSC1 loaded.")
254
+ self.lbl_osc2 = QLabel("No OSC2 loaded.")
255
+ for lab in (self.lbl_ha, self.lbl_oiii, self.lbl_sii, self.lbl_osc1, self.lbl_osc2):
256
+ lab.setWordWrap(True); lab.setStyleSheet("color:#888; margin-left:8px;")
257
+
258
+ for btn, lab in (
259
+ (self.btn_ha, self.lbl_ha),
260
+ (self.btn_oiii, self.lbl_oiii),
261
+ (self.btn_sii, self.lbl_sii),
262
+ (self.btn_osc1, self.lbl_osc1),
263
+ (self.btn_osc2, self.lbl_osc2),
264
+ ):
265
+ left.addWidget(btn); left.addWidget(lab)
266
+
267
+ # Linear toggle (stretch BEFORE palette build)
268
+ self.chk_linear = QCheckBox("Linear input (apply statistical stretch before build)")
269
+ self.chk_linear.setChecked(True)
270
+ self.chk_linear.stateChanged.connect(self._rebuild_stretch_cache_for_all)
271
+ left.addSpacing(6); left.addWidget(self.chk_linear)
272
+
273
+ # Actions
274
+ self.btn_clear = QPushButton("Clear Loaded Channels")
275
+ self.btn_clear.clicked.connect(self._clear_channels)
276
+ left.addWidget(self.btn_clear)
277
+
278
+ self.btn_create = QPushButton("Create Palettes")
279
+ self.btn_create.clicked.connect(self._create_palettes)
280
+ left.addWidget(self.btn_create)
281
+
282
+ self.btn_push = QPushButton("Push Final to New View")
283
+ self.btn_push.clicked.connect(self._push_final)
284
+ left.addWidget(self.btn_push)
285
+
286
+ left.addStretch(1)
287
+ root.addWidget(left_host, 0)
288
+
289
+ # -------- right: preview + fixed-size 4×3 grid
290
+ right = QVBoxLayout()
291
+
292
+ # zoom toolbar
293
+ # zoom toolbar (themed)
294
+ tools = QHBoxLayout()
295
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
296
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
297
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
298
+
299
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_at(1.25))
300
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_at(0.8))
301
+ self.btn_fit.clicked.connect(self._fit_to_preview)
302
+
303
+ tools.addStretch(1)
304
+ tools.addWidget(self.btn_zoom_out)
305
+ tools.addWidget(self.btn_zoom_in)
306
+ tools.addWidget(self.btn_fit)
307
+ tools.addStretch(1)
308
+ right.addLayout(tools)
309
+
310
+
311
+ # main preview (expands)
312
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
313
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
314
+ self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
315
+ self.scroll.setWidget(self.preview)
316
+ self.preview.setMouseTracking(True)
317
+ self.preview.installEventFilter(self)
318
+ self.scroll.viewport().installEventFilter(self)
319
+ self.scroll.installEventFilter(self)
320
+ self.scroll.horizontalScrollBar().installEventFilter(self) # NEW
321
+ self.scroll.verticalScrollBar().installEventFilter(self) # NEW
322
+ right.addWidget(self.scroll, 1)
323
+
324
+ # fixed-size grid
325
+ self.grid = QGridLayout()
326
+ self.grid.setHorizontalSpacing(8); self.grid.setVerticalSpacing(8)
327
+ self.grid.setContentsMargins(8, 8, 8, 8)
328
+
329
+ self.thumb_size = QSize(220, 110)
330
+ btn_w = self.thumb_size.width() + 2
331
+ btn_h = self.thumb_size.height() + 2
332
+ cols, rows = 4, 3
333
+
334
+ for idx, name in enumerate(self.PALETTES):
335
+ r, c = divmod(idx, cols)
336
+ b = QPushButton("") # we draw the text onto the icon itself
337
+ b.setToolTip(name)
338
+ b.setIconSize(self.thumb_size)
339
+ b.setFixedSize(btn_w, btn_h)
340
+ b.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
341
+ b.clicked.connect(lambda _=None, n=name: self._on_palette_clicked(n))
342
+ b.setStyleSheet("QPushButton{background:#222;border:1px solid #333;} QPushButton:hover{border-color:#555;}")
343
+ self._thumb_buttons[name] = b
344
+ self.grid.addWidget(b, r, c)
345
+
346
+ grid_host = QWidget(self); grid_host.setLayout(self.grid)
347
+ hspacing = self.grid.horizontalSpacing(); vspacing = self.grid.verticalSpacing()
348
+ m = self.grid.contentsMargins()
349
+ grid_w = cols*btn_w + (cols-1)*hspacing + m.left() + m.right()
350
+ grid_h = rows*btn_h + (rows-1)*vspacing + m.top() + m.bottom()
351
+ grid_host.setFixedSize(grid_w, grid_h)
352
+ grid_host.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
353
+ right.addWidget(grid_host, 0, alignment=Qt.AlignmentFlag.AlignHCenter)
354
+
355
+ self.status = QLabel(""); right.addWidget(self.status, 0)
356
+
357
+ right_host = QWidget(self); right_host.setLayout(right)
358
+ root.addWidget(right_host, 1)
359
+
360
+ self.setLayout(root)
361
+ self.setMinimumSize(left_host.width() + grid_w + 48, max(560, grid_h + 200))
362
+
363
+ def _resize_to(self, arr: np.ndarray | None, size: tuple[int, int]) -> np.ndarray | None:
364
+ """Resize np array to (w,h). Keeps dtype/scale. Uses INTER_AREA for downsizing."""
365
+ if arr is None:
366
+ return None
367
+ w, h = size
368
+ if arr.ndim == 2:
369
+ src_h, src_w = arr.shape
370
+ else:
371
+ src_h, src_w = arr.shape[:2]
372
+ if (src_w, src_h) == (w, h):
373
+ return arr
374
+ if cv2 is None:
375
+ # ultra-simple fallback: nearest; OK for thumbs if OpenCV isn't present
376
+ if arr.ndim == 2:
377
+ return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
378
+ return np.array(Image.fromarray((arr*255).astype(np.uint8)).resize((w, h))).astype(np.float32) / 255.0
379
+ interp = cv2.INTER_AREA if (w < src_w or h < src_h) else cv2.INTER_LINEAR
380
+ if arr.ndim == 2:
381
+ return cv2.resize(arr, (w, h), interpolation=interp)
382
+ return cv2.resize(arr, (w, h), interpolation=interp)
383
+
384
+ def _capture_view_state(self):
385
+ """Capture current view center in base-image coordinates + zoom."""
386
+ if self._base_pm is None:
387
+ return None
388
+ vp = self.scroll.viewport()
389
+ hbar = self.scroll.horizontalScrollBar()
390
+ vbar = self.scroll.verticalScrollBar()
391
+
392
+ # center of viewport in viewport coords
393
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
394
+
395
+ # convert to label coords (scaled image coords)
396
+ anchor_lbl = self.preview.mapFrom(vp, anchor_vp)
397
+
398
+ # scaled -> base image coords
399
+ base_x = anchor_lbl.x() / max(self._zoom, 1e-6)
400
+ base_y = anchor_lbl.y() / max(self._zoom, 1e-6)
401
+
402
+ pm = self._base_pm.size()
403
+ fx = 0.5 if pm.width() <= 0 else (base_x / pm.width())
404
+ fy = 0.5 if pm.height() <= 0 else (base_y / pm.height())
405
+
406
+ return {"zoom": float(self._zoom), "fx": float(fx), "fy": float(fy)}
407
+
408
+ def _restore_view_state(self, state):
409
+ """Restore zoom and pan using stored base-image fractions."""
410
+ if not state or self._base_pm is None:
411
+ return
412
+
413
+ # restore zoom first
414
+ self._zoom = max(self._min_zoom, min(self._max_zoom, float(state["zoom"])))
415
+ self._update_preview_pixmap()
416
+
417
+ # now restore center point
418
+ pm = self._base_pm.size()
419
+ fx = float(state.get("fx", 0.5))
420
+ fy = float(state.get("fy", 0.5))
421
+
422
+ base_x = fx * pm.width()
423
+ base_y = fy * pm.height()
424
+
425
+ # base -> scaled label coords
426
+ lbl_x = int(base_x * self._zoom)
427
+ lbl_y = int(base_y * self._zoom)
428
+
429
+ vp = self.scroll.viewport()
430
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2)
431
+
432
+ hbar = self.scroll.horizontalScrollBar()
433
+ vbar = self.scroll.verticalScrollBar()
434
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_x - anchor_vp.x())))
435
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_y - anchor_vp.y())))
436
+
437
+ # ---------- status helpers ----------
438
+ def _set_status_label(self, which: str, text: str | None):
439
+ lab = getattr(self, f"lbl_{which.lower()}")
440
+ if text:
441
+ lab.setText(text)
442
+ lab.setStyleSheet("color:#2a7; font-weight:600; margin-left:8px;")
443
+ else:
444
+ lab.setText(f"No {which} loaded.")
445
+ lab.setStyleSheet("color:#888; margin-left:8px;")
446
+
447
+ # ------------- load by view/file -------------
448
+ def _load_channel(self, which: str):
449
+ src, ok = QInputDialog.getItem(
450
+ self, f"Load {which}", "Source:", ["From View", "From File"], 0, False
451
+ )
452
+ if not ok:
453
+ return
454
+
455
+ if src == "From View":
456
+ out = self._load_from_view(which)
457
+ else:
458
+ out = self._load_from_file(which)
459
+ if out is None:
460
+ return
461
+
462
+ img, header, bit_depth, is_mono, path, label = out
463
+
464
+ # NB channels → mono; OSC → RGB
465
+ if which in ("Ha","OIII","SII"):
466
+ if img.ndim == 3:
467
+ img = img[:, :, 0]
468
+ else:
469
+ if img.ndim == 2:
470
+ img = np.stack([img]*3, axis=-1)
471
+
472
+ # store raw, normalized
473
+ setattr(self, which.lower(), self._as_float01(img))
474
+ self._set_status_label(which, label)
475
+ self.status.setText(f"{which} loaded ({'mono' if img.ndim==2 else 'RGB'}) shape={img.shape}")
476
+
477
+ # build/clear stretched cache for this input
478
+ self._cache_stretch(which)
479
+
480
+ if self.current_palette is None:
481
+ self.current_palette = "SHO"
482
+
483
+ def _load_from_view(self, which):
484
+ views = self._list_open_views()
485
+ if not views:
486
+ QMessageBox.warning(self, "No Views", "No open image views were found.")
487
+ return None
488
+
489
+ labels = [lab for lab, _ in views]
490
+ choice, ok = QInputDialog.getItem(
491
+ self, f"Select View for {which}", "Choose a view (by name):", labels, 0, False
492
+ )
493
+ if not ok or not choice:
494
+ return None
495
+
496
+ sw = dict(views)[choice]
497
+ doc = getattr(sw, "document", None)
498
+ if doc is None or getattr(doc, "image", None) is None:
499
+ QMessageBox.warning(self, "Empty View", "Selected view has no image.")
500
+ return None
501
+
502
+ img = doc.image
503
+ meta = getattr(doc, "metadata", {}) or {}
504
+ header = meta.get("original_header", None)
505
+ bit_depth = meta.get("bit_depth", "Unknown")
506
+ is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
507
+ path = meta.get("file_path", None)
508
+ return img, header, bit_depth, is_mono, path, f"From View: {choice}"
509
+
510
+ def _load_from_file(self, which):
511
+ filt = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
512
+ path, _ = QFileDialog.getOpenFileName(self, f"Select {which} File", "", filt)
513
+ if not path:
514
+ return None
515
+ img, header, bit_depth, is_mono = legacy_load_image(path)
516
+ if img is None:
517
+ QMessageBox.critical(self, "Load Error", f"Could not load {os.path.basename(path)}")
518
+ return None
519
+ label = f"From File: {os.path.basename(path)}"
520
+ return img, header, bit_depth, is_mono, path, label
521
+
522
+ def showEvent(self, e):
523
+ super().showEvent(e)
524
+ QTimer.singleShot(0, self._center_scrollbars)
525
+
526
+ # ------------- build/caches -------------
527
+ def _cache_stretch(self, which: str):
528
+ """Compute and cache stretched version of a just-loaded input (if linear checked)."""
529
+ arr = getattr(self, which.lower())
530
+ if arr is None:
531
+ self._stretched.pop(which, None); return
532
+ if not self.chk_linear.isChecked():
533
+ self._stretched.pop(which, None); return
534
+ self._stretched[which] = self._stretch_input(arr)
535
+
536
+ def _rebuild_stretch_cache_for_all(self, _state: int):
537
+ """Rebuild (or clear) stretched cache for all loaded inputs when checkbox toggles."""
538
+ for which in ("Ha","OIII","SII","OSC1","OSC2"):
539
+ self._cache_stretch(which)
540
+
541
+ def _render_thumb(self, name: str):
542
+ base = self._thumb_base_pm.get(name)
543
+ if base is None:
544
+ return
545
+ pm = base.copy()
546
+
547
+ p = QPainter(pm)
548
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
549
+
550
+ font = QFont("Helvetica", 10, QFont.Weight.DemiBold)
551
+ p.setFont(font)
552
+ fm = QFontMetrics(font)
553
+
554
+ pad = 6
555
+ strip_h = fm.height() + pad * 2
556
+ strip = pm.rect().adjusted(0, pm.height() - strip_h, 0, 0)
557
+
558
+ # translucent bottom strip
559
+ p.fillRect(strip, QColor(0, 0, 0, 160))
560
+ color = QColor(102, 255, 102) if self._selected_name == name else QColor(255, 255, 255)
561
+ p.setPen(QPen(color))
562
+ p.drawText(strip, Qt.AlignmentFlag.AlignCenter, name)
563
+ p.end()
564
+
565
+ btn = self._thumb_buttons[name]
566
+ btn.setIcon(QIcon(pm))
567
+ btn.setIconSize(self.thumb_size) # <- ensures no clipping
568
+
569
+ # ------------- thumbnails -------------
570
+ def _create_palettes(self):
571
+ """
572
+ Build the 12 palette thumbnails from a **center crop of the stretched inputs**
573
+ and draw the palette name directly on each thumbnail. Names turn green when selected.
574
+ """
575
+ ha, oo, si = self._prepared_channels(for_thumbs=True)
576
+ if oo is None or (ha is None and si is None):
577
+ QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
578
+ return
579
+
580
+ built = 0
581
+ for name in self.PALETTES:
582
+ r, g, b = self._map_channels_or_special(name, ha, oo, si)
583
+ if any(ch is None for ch in (r, g, b)):
584
+ self._thumb_base_pm.pop(name, None)
585
+ self._thumb_buttons[name].setIcon(QIcon())
586
+ continue
587
+
588
+ r = np.clip(np.nan_to_num(r), 0, 1)
589
+ g = np.clip(np.nan_to_num(g), 0, 1)
590
+ b = np.clip(np.nan_to_num(b), 0, 1)
591
+ rgb = np.stack([r, g, b], axis=2).astype(np.float32)
592
+
593
+ # scale the thumbnail to EXACTLY the button icon size first
594
+ pm = QPixmap.fromImage(self._to_qimage(rgb)).scaled(
595
+ self.thumb_size, Qt.AspectRatioMode.KeepAspectRatio,
596
+ Qt.TransformationMode.SmoothTransformation
597
+ )
598
+ self._thumb_base_pm[name] = pm
599
+ self._render_thumb(name)
600
+ built += 1
601
+
602
+ self.status.setText(f"Created {built} palette previews.")
603
+
604
+
605
+ def _on_palette_clicked(self, name: str):
606
+ self._selected_name = name
607
+ for n in self.PALETTES:
608
+ self._render_thumb(n)
609
+ self.current_palette = name
610
+ self._generate_for_palette(name)
611
+
612
+ # ------------- palette build helpers -------------
613
+ def _center_crop(self, img: np.ndarray, side: int) -> np.ndarray:
614
+ """Center-crop to a square of size 'side' (no upscaling)."""
615
+ h, w = img.shape[:2]; s = min(side, h, w)
616
+ y0 = (h - s) // 2; x0 = (w - s) // 2
617
+ return img[y0:y0+s, x0:x0+s] if img.ndim == 2 else img[y0:y0+s, x0:x0+s, :]
618
+
619
+ def _center_crop_all_to_side(self, side: int, *imgs):
620
+ """Center-crop all provided images to the same square side (no upscaling)."""
621
+ s = None
622
+ for im in imgs:
623
+ if im is None: continue
624
+ h, w = im.shape[:2]
625
+ s = min(side, h, w) if s is None else min(s, h, w, side)
626
+ if s is None: s = side
627
+ return [self._center_crop(im, s) if im is not None else None for im in imgs], s
628
+
629
+ def _prepared_channels(self, for_thumbs: bool = False):
630
+ """
631
+ Build Ha/OIII/SII bases from inputs. If 'Linear input' is checked,
632
+ **use stretched versions** (cached). Then optionally center-crop for thumbnails.
633
+ """
634
+ # choose raw vs stretched
635
+ def pick(name):
636
+ if self.chk_linear.isChecked() and (name in self._stretched):
637
+ return self._stretched[name]
638
+ return getattr(self, name.lower())
639
+
640
+ ha = pick("Ha")
641
+ oo = pick("OIII")
642
+ si = pick("SII")
643
+ o1 = pick("OSC1")
644
+ o2 = pick("OSC2")
645
+
646
+ # synthesize from stretched OSC first (stretch-before-crop)
647
+ if o1 is not None: # OSC1: R≈Ha, mean(G,B)≈OIII
648
+ h1 = o1[..., 0]
649
+ g1b1 = o1[..., 1:3].mean(axis=2)
650
+ ha = h1 if ha is None else 0.5*ha + 0.5*h1
651
+ oo = g1b1 if oo is None else 0.5*oo + 0.5*g1b1
652
+
653
+ if o2 is not None: # OSC2: R≈SII, mean(G,B)≈OIII
654
+ s2 = o2[..., 0]
655
+ g2b2 = o2[..., 1:3].mean(axis=2)
656
+ si = s2 if si is None else 0.5*si + 0.5*s2
657
+ oo = g2b2 if oo is None else 0.5*oo + 0.5*g2b2
658
+
659
+ # shapes must match for full-size
660
+ shapes = [x.shape for x in (ha, oo, si) if x is not None]
661
+ if len(shapes) and len(set(shapes)) > 1 and not for_thumbs:
662
+ QMessageBox.critical(self, "Size Mismatch", f"Channel sizes differ: {set(shapes)}")
663
+ return None, None, None
664
+
665
+ # thumbnails: crop AFTER stretch/synth
666
+ if for_thumbs:
667
+ # choose a reference (prefer OIII, then Ha, then SII)
668
+ ref = oo if oo is not None else (ha if ha is not None else si)
669
+ if ref is not None:
670
+ ref_h, ref_w = ref.shape[:2]
671
+
672
+ # 1) first, size-match all channels to the reference full frame
673
+ ha = self._resize_to(ha, (ref_w, ref_h)) if ha is not None else None
674
+ oo = self._resize_to(oo, (ref_w, ref_h)) if oo is not None else None
675
+ si = self._resize_to(si, (ref_w, ref_h)) if si is not None else None
676
+
677
+ # 2) then, make a 50% view of the full rectangle
678
+ half_w = max(1, int(ref_w * 0.5))
679
+ half_h = max(1, int(ref_h * 0.5))
680
+ ha = self._resize_to(ha, (half_w, half_h)) if ha is not None else None
681
+ oo = self._resize_to(oo, (half_w, half_h)) if oo is not None else None
682
+ si = self._resize_to(si, (half_w, half_h)) if si is not None else None
683
+
684
+ return ha, oo, si
685
+
686
+ def _generate_for_palette(self, pal: str):
687
+ ha, oo, si = self._prepared_channels()
688
+ if oo is None or (ha is None and si is None):
689
+ return
690
+
691
+ r,g,b = self._map_channels_or_special(pal, ha, oo, si)
692
+ if any(ch is None for ch in (r,g,b)):
693
+ QMessageBox.critical(self, "Palette Error", f"Could not build palette {pal}."); return
694
+
695
+ r = np.clip(np.nan_to_num(r), 0, 1)
696
+ g = np.clip(np.nan_to_num(g), 0, 1)
697
+ b = np.clip(np.nan_to_num(b), 0, 1)
698
+ rgb = np.stack([r,g,b], axis=2).astype(np.float32)
699
+
700
+ mx = float(rgb.max()) or 1.0
701
+ self.final = (rgb / mx).astype(np.float32)
702
+
703
+ # Fit only when there wasn't an existing preview yet
704
+ first = (self._base_pm is None)
705
+ self._set_preview_image(self._to_qimage(self.final), fit=first, preserve_view=True)
706
+ self.status.setText(f"Preview generated: {pal}")
707
+
708
+ def _set_preview_image(self, qimg: QImage, *, fit: bool = False, preserve_view: bool = True):
709
+ state = None
710
+ if preserve_view and (not fit) and (self._base_pm is not None):
711
+ state = self._capture_view_state()
712
+
713
+ self._base_pm = QPixmap.fromImage(qimg)
714
+
715
+ # If we’re fitting, ignore old zoom/pan.
716
+ if fit or state is None:
717
+ self._zoom = 1.0
718
+ self._update_preview_pixmap()
719
+ if fit:
720
+ QTimer.singleShot(0, self._fit_to_preview)
721
+ else:
722
+ QTimer.singleShot(0, self._center_scrollbars)
723
+ return
724
+
725
+ # restore prior zoom/pan
726
+ self._restore_view_state(state)
727
+
728
+
729
+ def _update_preview_pixmap(self):
730
+ if self._base_pm is None:
731
+ return
732
+ # explicit int size (QSize * float can crash on some PyQt6 builds)
733
+ base_sz = self._base_pm.size()
734
+ w = max(1, int(base_sz.width() * self._zoom))
735
+ h = max(1, int(base_sz.height() * self._zoom))
736
+ scaled = self._base_pm.scaled(
737
+ w, h,
738
+ Qt.AspectRatioMode.KeepAspectRatio,
739
+ Qt.TransformationMode.SmoothTransformation
740
+ )
741
+ self.preview.setPixmap(scaled)
742
+ self.preview.resize(scaled.size())
743
+
744
+ def _set_zoom(self, new_zoom: float):
745
+ self._zoom = max(self._min_zoom, min(self._max_zoom, new_zoom))
746
+ self._update_preview_pixmap()
747
+
748
+ def _zoom_at(self, factor: float = 1.25, anchor_vp: QPoint | None = None):
749
+ if self._base_pm is None:
750
+ return
751
+
752
+ vp = self.scroll.viewport()
753
+ if anchor_vp is None:
754
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2) # view center
755
+
756
+ # label coords under the anchor *before* zoom
757
+ lbl_before = self.preview.mapFrom(vp, anchor_vp)
758
+
759
+ old_zoom = self._zoom
760
+ new_zoom = max(self._min_zoom, min(self._max_zoom, old_zoom * factor))
761
+ ratio = new_zoom / max(old_zoom, 1e-6)
762
+ if abs(ratio - 1.0) < 1e-6:
763
+ return
764
+
765
+ # apply zoom (updates label size & scrollbar ranges)
766
+ self._zoom = new_zoom
767
+ self._update_preview_pixmap()
768
+
769
+ # desired label coords *after* zoom
770
+ lbl_after_x = int(lbl_before.x() * ratio)
771
+ lbl_after_y = int(lbl_before.y() * ratio)
772
+
773
+ # move scrollbars so anchor_vp keeps the same content point
774
+ hbar = self.scroll.horizontalScrollBar()
775
+ vbar = self.scroll.verticalScrollBar()
776
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), lbl_after_x - anchor_vp.x())))
777
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), lbl_after_y - anchor_vp.y())))
778
+
779
+
780
+ def _fit_to_preview(self):
781
+ if self._base_pm is None:
782
+ return
783
+ vp = self.scroll.viewport().size()
784
+ pm = self._base_pm.size()
785
+ if pm.width() == 0 or pm.height() == 0:
786
+ return
787
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
788
+ self._set_zoom(max(self._min_zoom, min(self._max_zoom, k)))
789
+ self._center_scrollbars()
790
+
791
+ def _center_scrollbars(self):
792
+ # center the view on the image
793
+ h = self.scroll.horizontalScrollBar()
794
+ v = self.scroll.verticalScrollBar()
795
+ h.setValue((h.maximum() + h.minimum()) // 2)
796
+ v.setValue((v.maximum() + v.minimum()) // 2)
797
+
798
+ def _map_channels_or_special(self, name, ha, oo, si):
799
+ # substitution
800
+ if ha is None and si is not None: ha = si
801
+ if si is None and ha is not None: si = ha
802
+
803
+ basic = {
804
+ "SHO": (si, ha, oo),
805
+ "HOO": (ha, oo, oo),
806
+ "HSO": (ha, si, oo),
807
+ "HOS": (ha, oo, si),
808
+ "OSS": (oo, si, si),
809
+ "OHH": (oo, ha, ha),
810
+ "OSH": (oo, si, ha),
811
+ "OHS": (oo, ha, si),
812
+ "HSS": (ha, si, si),
813
+ }
814
+ if name in basic:
815
+ return basic[name]
816
+
817
+ try:
818
+ if name == "Realistic1":
819
+ r = (ha + si)/2 if (ha is not None and si is not None) else (ha if ha is not None else 0)
820
+ g = 0.3*(ha if ha is not None else 0) + 0.7*(oo if oo is not None else 0)
821
+ b = 0.9*(oo if oo is not None else 0) + 0.1*(ha if ha is not None else 0)
822
+ return r,g,b
823
+ if name == "Realistic2":
824
+ r = 0.7*(ha if ha is not None else 0) + 0.3*(si if si is not None else 0)
825
+ g = 0.3*(si if si is not None else 0) + 0.7*(oo if oo is not None else 0)
826
+ b = (oo if oo is not None else 0)
827
+ return r,g,b
828
+ if name == "Foraxx":
829
+ if ha is not None and oo is not None and si is None:
830
+ r = ha; b = oo
831
+ t = ha * oo
832
+ g = (t**(1 - t))*ha + (1 - (t**(1 - t)))*oo
833
+ return r,g,b
834
+ if ha is not None and oo is not None and si is not None:
835
+ t = np.clip(oo, 1e-6, 1.0)**(1 - np.clip(oo, 1e-6, 1.0))
836
+ r = t*si + (1 - t)*ha
837
+ t2 = ha * oo
838
+ g = (t2**(1 - t2))*ha + (1 - (t2**(1 - t2)))*oo
839
+ b = oo
840
+ return r,g,b
841
+ return basic["SHO"]
842
+ except Exception:
843
+ return basic.get("SHO", (ha, oo, si))
844
+
845
+ return basic.get("SHO", (ha, oo, si))
846
+
847
+ # ------------- push to new subwindow -------------
848
+ # ------------- push to new subwindow -------------
849
+ def _get_doc_manager(self):
850
+ """
851
+ Try several ways to get a DocManager:
852
+ 1) explicit doc_manager passed into PerfectPalettePicker
853
+ 2) main window's .docman or .doc_manager attribute
854
+ """
855
+ if self.doc_manager is not None:
856
+ return self.doc_manager
857
+
858
+ mw = self._find_main_window()
859
+ if mw is None:
860
+ return None
861
+
862
+ return getattr(mw, "docman", None) or getattr(mw, "doc_manager", None)
863
+
864
+ def _push_final(self):
865
+ if self.final is None:
866
+ QMessageBox.warning(self, "No Image", "Generate a palette first.")
867
+ return
868
+
869
+ # Use the SAME prepared channels the palette was built with
870
+ ha_prep, oo_prep, si_prep = self._prepared_channels()
871
+ if oo_prep is None or (ha_prep is None and si_prep is None):
872
+ QMessageBox.warning(self, "Need Channels", "Load at least OIII + (Ha or SII).")
873
+ return
874
+
875
+ dlg = PaletteAdjustDialog(
876
+ base_rgb = self.final, # fully formed palette
877
+ palette_name = self.current_palette or "SHO",
878
+ ha_src = ha_prep, # prepared (stretched/OSC-synth)
879
+ oiii_src = oo_prep,
880
+ sii_src = si_prep,
881
+ owner = self
882
+ )
883
+ adjusted = {"img": None}
884
+ dlg.adjusted_image.connect(lambda img: adjusted.__setitem__("img", img))
885
+ dlg.exec()
886
+
887
+ if adjusted["img"] is None:
888
+ return # user canceled
889
+
890
+ # Update preview with adjusted result and set as final
891
+ self.final = adjusted["img"]
892
+ self._set_preview_image(self._to_qimage(self.final))
893
+
894
+ title = self.current_palette or "Palette"
895
+
896
+ # ---- get DocManager the robust way ----
897
+ dm = self._get_doc_manager()
898
+
899
+ if dm is None:
900
+ # Fallback: open a simple viewer instead of erroring out
901
+ viewer = QDialog(self)
902
+ viewer.setWindowTitle(title)
903
+ vlayout = QVBoxLayout(viewer)
904
+ lbl = QLabel()
905
+ lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
906
+ lbl.setPixmap(QPixmap.fromImage(self._to_qimage(self.final)))
907
+ vlayout.addWidget(lbl)
908
+ viewer.resize(lbl.pixmap().size())
909
+ viewer.show()
910
+ # keep ref so it isn't GC'd
911
+ self._last_popup_viewer = viewer
912
+ self.status.setText("DocManager not found; opened palette in stand-alone viewer.")
913
+ return
914
+
915
+ # ---- normal SAS path: create a new document ----
916
+ try:
917
+ if hasattr(dm, "open_array"):
918
+ # many of your tools already use this signature
919
+ doc = dm.open_array(self.final, metadata={"is_mono": False}, title=title)
920
+ elif hasattr(dm, "create_document"):
921
+ doc = dm.create_document(image=self.final, metadata={"is_mono": False}, name=title)
922
+ else:
923
+ raise RuntimeError("DocManager lacks open_array/create_document")
924
+
925
+ # If DocManager or main window auto-spawns subwindows on new docs,
926
+ # this is all we need. If not, you can optionally keep the
927
+ # _spawn_subwindow_for hook here.
928
+ self.status.setText("Opened final palette in a new view.")
929
+ except Exception as e:
930
+ QMessageBox.critical(self, "Error", f"Failed to open new view:\n{e}")
931
+
932
+
933
+
934
+ # ------------- utilities -------------
935
+ def _clear_channels(self):
936
+ self.ha = self.oiii = self.sii = self.osc1 = self.osc2 = None
937
+ self._stretched.clear()
938
+ self.final = None
939
+ self.preview.clear()
940
+ for which in ("Ha","OIII","SII","OSC1","OSC2"):
941
+ self._set_status_label(which, None)
942
+ for name, b in self._thumb_buttons.items():
943
+ b.setIcon(QIcon())
944
+ self._thumb_base_pm.clear()
945
+ self._selected_name = None
946
+ for b in self._thumb_buttons.values():
947
+ b.setIcon(QIcon())
948
+ self.status.setText("Cleared all loaded channels.")
949
+
950
+ def _as_float01(self, arr):
951
+ a = np.asarray(arr)
952
+ if a.dtype == np.uint8: return a.astype(np.float32)/255.0
953
+ if a.dtype == np.uint16: return a.astype(np.float32)/65535.0
954
+ return np.clip(a.astype(np.float32), 0.0, 1.0)
955
+
956
+ def _stretch_input(self, img):
957
+ """Run statistical stretch on mono or color inputs (target_median=0.25)."""
958
+ if img.ndim == 2:
959
+ return np.clip(stretch_mono_image(img, target_median=0.25), 0.0, 1.0)
960
+ if img.ndim == 3 and img.shape[2] == 3:
961
+ return np.clip(stretch_color_image(img, target_median=0.25, linked=False), 0.0, 1.0)
962
+ if img.ndim == 3 and img.shape[2] == 1:
963
+ mono = img[...,0]
964
+ return np.clip(stretch_mono_image(mono, target_median=0.25), 0.0, 1.0)
965
+ return img
966
+
967
+ def _to_qimage(self, arr):
968
+ a = np.clip(arr, 0, 1)
969
+ if a.ndim == 2:
970
+ u = (a * 255).astype(np.uint8); h, w = u.shape
971
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
972
+ if a.ndim == 3 and a.shape[2] == 3:
973
+ u = (a * 255).astype(np.uint8); h, w, _ = u.shape
974
+ return QImage(u.data, w, h, w*3, QImage.Format.Format_RGB888).copy()
975
+ raise ValueError(f"Unexpected image shape: {a.shape}")
976
+
977
+ def _find_main_window(self):
978
+ w = self
979
+ from PyQt6.QtWidgets import QMainWindow, QApplication
980
+ while w is not None and not isinstance(w, QMainWindow):
981
+ w = w.parentWidget()
982
+ if w: return w
983
+ for tlw in QApplication.topLevelWidgets():
984
+ if isinstance(tlw, QMainWindow):
985
+ return tlw
986
+ return None
987
+
988
+ def _list_open_views(self):
989
+ mw = self._find_main_window()
990
+ if not mw:
991
+ return []
992
+ try:
993
+ from setiastro.saspro.subwindow import ImageSubWindow
994
+ subs = mw.findChildren(ImageSubWindow)
995
+ except Exception:
996
+ subs = []
997
+ out = []
998
+ for sw in subs:
999
+ title = getattr(sw, "view_title", None) or sw.windowTitle() or getattr(sw.document, "display_name", lambda: "Untitled")()
1000
+ out.append((str(title), sw))
1001
+ return out
1002
+
1003
+ def eventFilter(self, obj, ev):
1004
+ # Ctrl+wheel = zoom at mouse (no scrolling). Wheel without Ctrl = eaten.
1005
+ if ev.type() == QEvent.Type.Wheel and (
1006
+ obj is self.preview
1007
+ or obj is self.scroll
1008
+ or obj is self.scroll.viewport()
1009
+ or obj is self.scroll.horizontalScrollBar()
1010
+ or obj is self.scroll.verticalScrollBar()
1011
+ ):
1012
+ # always stop the wheel from scrolling
1013
+ ev.accept()
1014
+
1015
+ # Zoom only when Ctrl is held
1016
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1017
+ factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
1018
+
1019
+ # Get mouse position in global screen coords and map into the viewport
1020
+ vp = self.scroll.viewport()
1021
+ anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
1022
+
1023
+ # Clamp to viewport rect (robust if the event originated on scrollbars)
1024
+ r = vp.rect()
1025
+ if not r.contains(anchor_vp):
1026
+ anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
1027
+ anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
1028
+
1029
+ self._zoom_at(factor, anchor_vp)
1030
+ return True
1031
+ # click-drag pan on viewport
1032
+ if obj is self.scroll.viewport():
1033
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
1034
+ self._panning = True
1035
+ self._pan_last = ev.position().toPoint()
1036
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
1037
+ return True
1038
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
1039
+ cur = ev.position().toPoint()
1040
+ delta = cur - (self._pan_last or cur)
1041
+ self._pan_last = cur
1042
+ h = self.scroll.horizontalScrollBar()
1043
+ v = self.scroll.verticalScrollBar()
1044
+ h.setValue(h.value() - delta.x())
1045
+ v.setValue(v.value() - delta.y())
1046
+ return True
1047
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
1048
+ self._panning = False
1049
+ self._pan_last = None
1050
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ArrowCursor))
1051
+ return True
1052
+
1053
+ return super().eventFilter(obj, ev)