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,531 @@
1
+ # pro/nbtorgb_stars.py
2
+ from __future__ import annotations
3
+ import os
4
+ import numpy as np
5
+
6
+ from PyQt6.QtCore import (
7
+ Qt, QSize, QEvent, QTimer, QPoint, QThread, pyqtSignal
8
+ )
9
+ from PyQt6.QtWidgets import (
10
+ QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea,
11
+ QFileDialog, QInputDialog, QMessageBox, QGridLayout, QCheckBox, QSizePolicy,
12
+ QSlider
13
+ )
14
+ from PyQt6.QtGui import (
15
+ QPixmap, QImage, QIcon, QPainter, QPen, QColor, QFont, QFontMetrics,
16
+ QCursor, QMovie
17
+ )
18
+
19
+ # Legacy I/O (same used elsewhere in SASpro)
20
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
21
+
22
+ from setiastro.saspro.legacy.numba_utils import applySCNR_numba, adjust_saturation_numba
23
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
24
+
25
+
26
+ # Optional: your stretch helpers (only used if you’d like to pre-stretch inputs)
27
+ # from imageops.stretch import stretch_mono_image, stretch_color_image
28
+
29
+
30
+ class NBtoRGBStars(QWidget):
31
+ """
32
+ SASpro version of NB→RGB Stars:
33
+ - Ha/OIII/SII mono (any subset) and/or OSC stars image
34
+ - Ha↔OIII ratio
35
+ - Optional "star stretch"
36
+ - Live preview on the right (with PPP-style zoom/pan/fit)
37
+ - Push final to a new view via DocManager
38
+ """
39
+ THUMB_ICON_SIZE = QSize(22, 22) # just for button decoration if icon_path provided
40
+
41
+ def __init__(self, doc_manager=None, parent=None, icon_path: str | None = None):
42
+ super().__init__(parent)
43
+ self.doc_manager = doc_manager
44
+ self.setWindowTitle("NB→RGB Stars")
45
+
46
+ if icon_path:
47
+ try:
48
+ self.setWindowIcon(QIcon(icon_path))
49
+ except Exception:
50
+ pass
51
+
52
+ # raw inputs (float32 ~[0..1])
53
+ self.ha : np.ndarray | None = None
54
+ self.oiii : np.ndarray | None = None
55
+ self.sii : np.ndarray | None = None
56
+ self.osc : np.ndarray | None = None # 3-channel stars-only (optional)
57
+
58
+ # filenames / metadata (best-effort)
59
+ self._file_ha = None
60
+ self._file_oiii = None
61
+ self._file_sii = None
62
+ self._file_osc = None
63
+
64
+ # output
65
+ self.final: np.ndarray | None = None
66
+
67
+ # preview pixmap/zoom state
68
+ self._base_pm: QPixmap | None = None
69
+ self._zoom = 1.0
70
+ self._min_zoom = 0.05
71
+ self._max_zoom = 20.0
72
+ self._panning = False
73
+ self._pan_last: QPoint | None = None
74
+
75
+ self._build_ui()
76
+
77
+ # ---------------- UI ----------------
78
+ def _build_ui(self):
79
+ root = QHBoxLayout(self)
80
+
81
+ # -------- left controls
82
+ left = QVBoxLayout()
83
+ left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(320)
84
+
85
+ left.addWidget(QLabel(
86
+ "<b>NB→RGB Stars</b><br>"
87
+ "Load Ha / OIII / (optional SII) and/or OSC stars.<br>"
88
+ "Tune ratio and preview; push to a new view."
89
+ ))
90
+
91
+ # Load buttons + status labels
92
+ self.btn_ha = QPushButton("Load Ha…"); self.btn_ha.clicked.connect(lambda: self._load_channel("Ha"))
93
+ self.btn_oiii = QPushButton("Load OIII…"); self.btn_oiii.clicked.connect(lambda: self._load_channel("OIII"))
94
+ self.btn_sii = QPushButton("Load SII (optional)…"); self.btn_sii.clicked.connect(lambda: self._load_channel("SII"))
95
+ self.btn_osc = QPushButton("Load OSC stars (optional)…"); self.btn_osc.clicked.connect(lambda: self._load_channel("OSC"))
96
+
97
+ self.lbl_ha = QLabel("No Ha loaded.")
98
+ self.lbl_oiii = QLabel("No OIII loaded.")
99
+ self.lbl_sii = QLabel("No SII loaded.")
100
+ self.lbl_osc = QLabel("No OSC stars loaded.")
101
+
102
+ for lab in (self.lbl_ha, self.lbl_oiii, self.lbl_sii, self.lbl_osc):
103
+ lab.setWordWrap(True); lab.setStyleSheet("color:#888; margin-left:8px;")
104
+
105
+ for btn, lab in ((self.btn_ha, self.lbl_ha),
106
+ (self.btn_oiii, self.lbl_oiii),
107
+ (self.btn_sii, self.lbl_sii),
108
+ (self.btn_osc, self.lbl_osc)):
109
+ left.addWidget(btn); left.addWidget(lab)
110
+
111
+ # Ratio (Ha to OIII)
112
+ row = QHBoxLayout()
113
+ self.lbl_ratio = QLabel("Ha:OIII ratio = 0.30")
114
+ self.sld_ratio = QSlider(Qt.Orientation.Horizontal); self.sld_ratio.setRange(0, 100); self.sld_ratio.setValue(30)
115
+ self.sld_ratio.valueChanged.connect(lambda v: self.lbl_ratio.setText(f"Ha:OIII ratio = {v/100:.2f}"))
116
+ row.addWidget(self.lbl_ratio); left.addLayout(row)
117
+ left.addWidget(self.sld_ratio)
118
+
119
+ # Star Stretch
120
+ self.chk_star_stretch = QCheckBox("Enable star stretch"); self.chk_star_stretch.setChecked(True)
121
+ left.addWidget(self.chk_star_stretch)
122
+
123
+ row2 = QHBoxLayout()
124
+ self.lbl_stretch = QLabel("Stretch factor = 5.00")
125
+ self.sld_stretch = QSlider(Qt.Orientation.Horizontal); self.sld_stretch.setRange(0, 800); self.sld_stretch.setValue(500)
126
+ self.sld_stretch.valueChanged.connect(lambda v: self.lbl_stretch.setText(f"Stretch factor = {v/100:.2f}"))
127
+ row2.addWidget(self.lbl_stretch); left.addLayout(row2)
128
+ left.addWidget(self.sld_stretch)
129
+
130
+ row3 = QHBoxLayout()
131
+ self.lbl_sat = QLabel("Saturation = 1.00×")
132
+ self.sld_sat = QSlider(Qt.Orientation.Horizontal)
133
+ self.sld_sat.setRange(0, 300) # 0.00× … 3.00×
134
+ self.sld_sat.setValue(100) # 1.00× by default
135
+ self.sld_sat.valueChanged.connect(lambda v: self.lbl_sat.setText(f"Saturation = {v/100:.2f}×"))
136
+ row3.addWidget(self.lbl_sat)
137
+ left.addLayout(row3)
138
+ left.addWidget(self.sld_sat)
139
+
140
+ # Actions
141
+ act = QHBoxLayout()
142
+ self.btn_preview = QPushButton("Preview Combine"); self.btn_preview.clicked.connect(self._preview_combine)
143
+ self.btn_push = QPushButton("Push Final to New View"); self.btn_push.clicked.connect(self._push_final)
144
+ act.addWidget(self.btn_preview); act.addWidget(self.btn_push)
145
+ left.addLayout(act)
146
+
147
+ self.btn_clear = QPushButton("Clear Inputs"); self.btn_clear.clicked.connect(self._clear_inputs)
148
+ left.addWidget(self.btn_clear)
149
+
150
+ # Spinner (optional)
151
+ self.spinner = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
152
+ self.spinner_movie = QMovie(os.path.join(os.path.dirname(__file__), "spinner.gif"))
153
+ self.spinner.setMovie(self.spinner_movie); self.spinner.hide()
154
+ left.addWidget(self.spinner)
155
+
156
+ left.addStretch(1)
157
+ root.addWidget(left_host, 0)
158
+
159
+ # -------- right: preview (zoom/pan like PPP)
160
+ right = QVBoxLayout()
161
+
162
+ tools = QHBoxLayout()
163
+
164
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
165
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
166
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
167
+
168
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_at(1.25))
169
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_at(0.8))
170
+ self.btn_fit.clicked.connect(self._fit_to_preview)
171
+
172
+ tools.addWidget(self.btn_zoom_in)
173
+ tools.addWidget(self.btn_zoom_out)
174
+ tools.addWidget(self.btn_fit)
175
+ right.addLayout(tools)
176
+
177
+
178
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True)
179
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
180
+ self.preview = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
181
+ self.scroll.setWidget(self.preview)
182
+ self.preview.setMouseTracking(True)
183
+
184
+ # Intercept wheel everywhere to prevent scroll while zooming; pan via drag
185
+ for obj in (self.preview, self.scroll, self.scroll.viewport(),
186
+ self.scroll.horizontalScrollBar(), self.scroll.verticalScrollBar()):
187
+ obj.installEventFilter(self)
188
+
189
+ right.addWidget(self.scroll, 1)
190
+ self.status = QLabel(""); right.addWidget(self.status, 0)
191
+
192
+ right_host = QWidget(self); right_host.setLayout(right)
193
+ root.addWidget(right_host, 1)
194
+
195
+ self.setLayout(root)
196
+ self.setMinimumSize(980, 640)
197
+
198
+ def showEvent(self, e):
199
+ super().showEvent(e)
200
+ QTimer.singleShot(0, self._center_scrollbars)
201
+
202
+ # ---------- file/view loading ----------
203
+ def _set_status_label(self, which: str, text: str | None):
204
+ lab = getattr(self, f"lbl_{which.lower()}")
205
+ if text:
206
+ lab.setText(text); lab.setStyleSheet("color:#2a7; font-weight:600; margin-left:8px;")
207
+ else:
208
+ lab.setText(f"No {which} loaded."); lab.setStyleSheet("color:#888; margin-left:8px;")
209
+
210
+ def _load_channel(self, which: str):
211
+ src, ok = QInputDialog.getItem(self, f"Load {which}", "Source:", ["From View", "From File"], 0, False)
212
+ if not ok: return
213
+
214
+ out = self._load_from_view(which) if src == "From View" else self._load_from_file(which)
215
+ if out is None: return
216
+ img, header, bit_depth, is_mono, path, label = out
217
+
218
+ # Normalize to floats in [0,1]; collapse mono to 2D; ensure OSC is RGB
219
+ if which in ("Ha","OIII","SII"):
220
+ if img.ndim == 3: img = img[...,0]
221
+ setattr(self, which.lower(), self._as_float01(img))
222
+ else: # OSC
223
+ if img.ndim == 2: img = np.stack([img]*3, axis=-1)
224
+ setattr(self, which.lower(), self._as_float01(img))
225
+
226
+ setattr(self, f"_file_{which.lower()}", path)
227
+ self._set_status_label(which, label)
228
+ self.status.setText(f"{which} loaded ({'mono' if img.ndim==2 else 'RGB'}) shape={img.shape}")
229
+
230
+ def _load_from_view(self, which):
231
+ views = self._list_open_views()
232
+ if not views:
233
+ QMessageBox.warning(self, "No Views", "No open image views found."); return None
234
+ labels = [lab for lab, _ in views]
235
+ choice, ok = QInputDialog.getItem(self, f"Select View for {which}", "Choose a view:", labels, 0, False)
236
+ if not ok or not choice: return None
237
+ sw = dict(views)[choice]
238
+ doc = getattr(sw, "document", None)
239
+ if doc is None or getattr(doc, "image", None) is None:
240
+ QMessageBox.warning(self, "Empty View", "Selected view has no image."); return None
241
+ img = doc.image
242
+ meta = getattr(doc, "metadata", {}) or {}
243
+ header = meta.get("original_header", None)
244
+ bit_depth = meta.get("bit_depth", "Unknown")
245
+ is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
246
+ path = meta.get("file_path", None)
247
+ return img, header, bit_depth, is_mono, path, f"From View: {choice}"
248
+
249
+ def _load_from_file(self, which):
250
+ filt = "Images (*.png *.tif *.tiff *.fits *.fit *.xisf)"
251
+ path, _ = QFileDialog.getOpenFileName(self, f"Select {which} File", "", filt)
252
+ if not path: return None
253
+ img, header, bit_depth, is_mono = legacy_load_image(path)
254
+ if img is None:
255
+ QMessageBox.critical(self, "Load Error", f"Could not load {os.path.basename(path)}"); return None
256
+ return img, header, bit_depth, is_mono, path, f"From File: {os.path.basename(path)}"
257
+
258
+ # ---------- combine / preview ----------
259
+ def _preview_combine(self):
260
+ if (self.osc is None) and not (self.ha is not None and self.oiii is not None):
261
+ QMessageBox.warning(self, "Missing Images", "Load OSC, or Ha+OIII (SII optional).")
262
+ return
263
+
264
+ self.spinner.show(); self.spinner_movie.start()
265
+
266
+ ratio = self.sld_ratio.value() / 100.0
267
+ stretch_enabled = self.chk_star_stretch.isChecked()
268
+ stretch_factor = self.sld_stretch.value() / 100.0
269
+ sat_factor = self.sld_sat.value() / 100.0
270
+
271
+ try:
272
+ # 1) combine (no SCNR here)
273
+ rgb = self._combine_nb_rgb(ratio, stretch_enabled, stretch_factor)
274
+
275
+ # 2) ensure float32 & contiguous for numba
276
+ rgb = np.ascontiguousarray(rgb.astype(np.float32))
277
+
278
+ # 3) SCNR (numba)
279
+ rgb = applySCNR_numba(rgb)
280
+
281
+ # 4) Saturation (numba)
282
+ if abs(sat_factor - 1.0) > 1e-3:
283
+ rgb = adjust_saturation_numba(rgb, sat_factor)
284
+
285
+ self.final = np.clip(rgb, 0.0, 1.0)
286
+
287
+ except Exception as e:
288
+ self.spinner.hide(); self.spinner_movie.stop()
289
+ QMessageBox.critical(self, "Combine Error", str(e))
290
+ return
291
+
292
+ self._set_preview_image(self._to_qimage(self.final))
293
+ self.status.setText("Preview updated.")
294
+ self.spinner.hide(); self.spinner_movie.stop()
295
+
296
+
297
+ def _combine_nb_rgb(self, ratio: float, star_stretch: bool, stretch_k: float) -> np.ndarray:
298
+ """
299
+ Combine to RGB:
300
+ - If OSC present: use channels from OSC, optionally blend Ha/SII/OO into them.
301
+ - Else NB-only: R ~ 0.5*(Ha+SII), G ~ mix(Ha, OIII) via ratio, B ~ OIII.
302
+ Shapes must match.
303
+ """
304
+ # Ensure shapes
305
+ shapes = [x.shape[:2] for x in (self.ha, self.oiii, self.sii) if x is not None]
306
+ if self.osc is not None:
307
+ shapes.append(self.osc.shape[:2])
308
+ if shapes and len(set(shapes)) != 1:
309
+ raise ValueError(f"Channel sizes differ: {set(shapes)}")
310
+
311
+ if self.osc is not None:
312
+ r = self.osc[...,0]; g = self.osc[...,1]; b = self.osc[...,2]
313
+ sii = self.sii if self.sii is not None else r
314
+ ha = self.ha if self.ha is not None else r
315
+ oiii= self.oiii if self.oiii is not None else b
316
+
317
+ r_out = 0.5*r + 0.5*sii
318
+ g_out = ratio*ha + (1.0 - ratio)*g
319
+ b_out = oiii
320
+ else:
321
+ if self.ha is None or self.oiii is None:
322
+ raise ValueError("Need Ha and OIII if no OSC image is provided.")
323
+ ha = self.ha
324
+ sii = self.sii if self.sii is not None else ha
325
+ oiii = self.oiii
326
+ r_out = 0.5*ha + 0.5*sii
327
+ g_out = ratio*ha + (1.0 - ratio)*oiii
328
+ b_out = oiii
329
+
330
+ rgb = np.stack([r_out, g_out, b_out], axis=2).astype(np.float32)
331
+ rgb = np.clip(rgb, 0, 1)
332
+
333
+ if star_stretch:
334
+ # Simple non-linear boost; bounded and monotonic
335
+ # ((3^k)*x) / ((3^k - 1)*x + 1)
336
+ t = 3.0 ** float(stretch_k)
337
+ rgb = (t*rgb) / ((t - 1.0)*rgb + 1.0)
338
+ rgb = np.clip(rgb, 0, 1)
339
+
340
+ return rgb
341
+
342
+
343
+ # ---------- preview helpers + zoom/pan ----------
344
+ def _set_preview_image(self, qimg: QImage):
345
+ self._base_pm = QPixmap.fromImage(qimg)
346
+ self._zoom = 1.0
347
+ self._update_preview_pixmap()
348
+ QTimer.singleShot(0, self._center_scrollbars)
349
+
350
+ def _update_preview_pixmap(self):
351
+ if self._base_pm is None: return
352
+ scaled = self._base_pm.scaled(
353
+ self._base_pm.size() * self._zoom,
354
+ Qt.AspectRatioMode.KeepAspectRatio,
355
+ Qt.TransformationMode.SmoothTransformation
356
+ )
357
+ self.preview.setPixmap(scaled)
358
+ self.preview.resize(scaled.size())
359
+
360
+ def _set_zoom(self, new_zoom: float):
361
+ self._zoom = max(self._min_zoom, min(self._max_zoom, new_zoom))
362
+ self._update_preview_pixmap()
363
+
364
+ def _zoom_at(self, factor: float = 1.25, anchor_vp: QPoint | None = None):
365
+ if self._base_pm is None: return
366
+
367
+ old_zoom = self._zoom
368
+ new_zoom = max(self._min_zoom, min(self._max_zoom, old_zoom * factor))
369
+ ratio = new_zoom / max(1e-6, old_zoom)
370
+
371
+ vp = self.scroll.viewport()
372
+ if anchor_vp is None:
373
+ anchor_vp = QPoint(vp.width() // 2, vp.height() // 2) # center of view
374
+
375
+ hbar = self.scroll.horizontalScrollBar()
376
+ vbar = self.scroll.verticalScrollBar()
377
+
378
+ content_x = hbar.value() + anchor_vp.x()
379
+ content_y = vbar.value() + anchor_vp.y()
380
+
381
+ self._set_zoom(new_zoom)
382
+
383
+ if self.preview.width() <= vp.width():
384
+ hbar.setValue((hbar.maximum() + hbar.minimum()) // 2)
385
+ else:
386
+ new_h = int(content_x * ratio - anchor_vp.x())
387
+ hbar.setValue(max(hbar.minimum(), min(hbar.maximum(), new_h)))
388
+
389
+ if self.preview.height() <= vp.height():
390
+ vbar.setValue((vbar.maximum() + vbar.minimum()) // 2)
391
+ else:
392
+ new_v = int(content_y * ratio - anchor_vp.y())
393
+ vbar.setValue(max(vbar.minimum(), min(vbar.maximum(), new_v)))
394
+
395
+ def _fit_to_preview(self):
396
+ if self._base_pm is None: return
397
+ vp = self.scroll.viewport().size()
398
+ pm = self._base_pm.size()
399
+ if pm.width() == 0 or pm.height() == 0: return
400
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
401
+ self._set_zoom(max(self._min_zoom, min(self._max_zoom, k)))
402
+ self._center_scrollbars()
403
+
404
+ def _center_scrollbars(self):
405
+ h = self.scroll.horizontalScrollBar()
406
+ v = self.scroll.verticalScrollBar()
407
+ h.setValue((h.maximum() + h.minimum()) // 2)
408
+ v.setValue((v.maximum() + v.minimum()) // 2)
409
+
410
+ # ---------- utilities ----------
411
+ def _clear_inputs(self):
412
+ self.ha = self.oiii = self.sii = self.osc = None
413
+ self._file_ha = self._file_oiii = self._file_sii = self._file_osc = None
414
+ self.final = None
415
+ self.preview.clear(); self._base_pm = None
416
+ for which in ("Ha","OIII","SII","OSC"):
417
+ self._set_status_label(which, None)
418
+ self.status.setText("Cleared inputs.")
419
+
420
+ @staticmethod
421
+ def _as_float01(arr):
422
+ a = np.asarray(arr)
423
+ if a.dtype == np.uint8: return a.astype(np.float32)/255.0
424
+ if a.dtype == np.uint16: return a.astype(np.float32)/65535.0
425
+ return np.clip(a.astype(np.float32), 0.0, 1.0)
426
+
427
+ @staticmethod
428
+ def _to_qimage(arr):
429
+ a = np.clip(arr, 0, 1)
430
+ if a.ndim == 2:
431
+ u = (a * 255).astype(np.uint8); h, w = u.shape
432
+ return QImage(u.data, w, h, w, QImage.Format.Format_Grayscale8).copy()
433
+ if a.ndim == 3 and a.shape[2] == 3:
434
+ u = (a * 255).astype(np.uint8); h, w, _ = u.shape
435
+ return QImage(u.data, w, h, w*3, QImage.Format.Format_RGB888).copy()
436
+ raise ValueError(f"Unexpected image shape: {a.shape}")
437
+
438
+ def _find_main_window(self):
439
+ w = self
440
+ from PyQt6.QtWidgets import QMainWindow, QApplication
441
+ while w is not None and not isinstance(w, QMainWindow):
442
+ w = w.parentWidget()
443
+ if w: return w
444
+ for tlw in QApplication.topLevelWidgets():
445
+ if isinstance(tlw, QMainWindow):
446
+ return tlw
447
+ return None
448
+
449
+ def _list_open_views(self):
450
+ mw = self._find_main_window()
451
+ if not mw: return []
452
+ try:
453
+ from setiastro.saspro.subwindow import ImageSubWindow
454
+ subs = mw.findChildren(ImageSubWindow)
455
+ except Exception:
456
+ subs = []
457
+ out = []
458
+ for sw in subs:
459
+ title = getattr(sw, "view_title", None) or sw.windowTitle() or getattr(sw.document, "display_name", lambda: "Untitled")()
460
+ out.append((str(title), sw))
461
+ return out
462
+
463
+ def _push_final(self):
464
+ if self.final is None:
465
+ QMessageBox.warning(self, "No Image", "Preview first, then push."); return
466
+ mw = self._find_main_window()
467
+ dm = getattr(mw, "docman", None)
468
+ if not mw or not dm:
469
+ QMessageBox.critical(self, "UI", "Main window or DocManager not available."); return
470
+ title = "NB→RGB Stars"
471
+ try:
472
+ if hasattr(dm, "open_array"):
473
+ doc = dm.open_array(self.final, metadata={"is_mono": False}, title=title)
474
+ elif hasattr(dm, "create_document"):
475
+ doc = dm.create_document(image=self.final, metadata={"is_mono": False}, name=title)
476
+ else:
477
+ raise RuntimeError("DocManager lacks open_array/create_document")
478
+ if hasattr(mw, "_spawn_subwindow_for"):
479
+ mw._spawn_subwindow_for(doc)
480
+ else:
481
+ from setiastro.saspro.subwindow import ImageSubWindow
482
+ sw = ImageSubWindow(doc, parent=mw); sw.setWindowTitle(title); sw.show()
483
+ self.status.setText("Opened final composite in a new view.")
484
+ except Exception as e:
485
+ QMessageBox.critical(self, "Error", f"Failed to open new view:\n{e}")
486
+
487
+ # ---------- event filter (zoom/pan like PPP) ----------
488
+ def eventFilter(self, obj, ev):
489
+ # Ctrl+wheel zoom at mouse (prevent scrolling); wheel without Ctrl: eat it (no scroll)
490
+ if ev.type() == QEvent.Type.Wheel and (
491
+ obj is self.preview
492
+ or obj is self.scroll
493
+ or obj is self.scroll.viewport()
494
+ or obj is self.scroll.horizontalScrollBar()
495
+ or obj is self.scroll.verticalScrollBar()
496
+ ):
497
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
498
+ factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
499
+ # safer: compute anchor in viewport coords via global position
500
+ try:
501
+ anchor_vp = self.scroll.viewport().mapFromGlobal(ev.globalPosition().toPoint())
502
+ except Exception:
503
+ vp = self.scroll.viewport()
504
+ anchor_vp = QPoint(vp.width()//2, vp.height()//2)
505
+ self._zoom_at(factor, anchor_vp)
506
+ ev.accept()
507
+ return True
508
+
509
+ # click-drag pan on viewport
510
+ if obj is self.scroll.viewport():
511
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
512
+ self._panning = True
513
+ self._pan_last = ev.position().toPoint()
514
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
515
+ return True
516
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
517
+ cur = ev.position().toPoint()
518
+ delta = cur - (self._pan_last or cur)
519
+ self._pan_last = cur
520
+ h = self.scroll.horizontalScrollBar()
521
+ v = self.scroll.verticalScrollBar()
522
+ h.setValue(h.value() - delta.x())
523
+ v.setValue(v.value() - delta.y())
524
+ return True
525
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
526
+ self._panning = False
527
+ self._pan_last = None
528
+ self.scroll.viewport().setCursor(QCursor(Qt.CursorShape.ArrowCursor))
529
+ return True
530
+
531
+ return super().eventFilter(obj, ev)