setiastrosuitepro 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (342) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/HRDiagram.png +0 -0
  16. setiastro/images/LExtract.png +0 -0
  17. setiastro/images/LInsert.png +0 -0
  18. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  19. setiastro/images/RGB080604.png +0 -0
  20. setiastro/images/abeicon.png +0 -0
  21. setiastro/images/aberration.png +0 -0
  22. setiastro/images/andromedatry.png +0 -0
  23. setiastro/images/andromedatry_satellited.png +0 -0
  24. setiastro/images/annotated.png +0 -0
  25. setiastro/images/aperture.png +0 -0
  26. setiastro/images/astrosuite.ico +0 -0
  27. setiastro/images/astrosuite.png +0 -0
  28. setiastro/images/astrosuitepro.icns +0 -0
  29. setiastro/images/astrosuitepro.ico +0 -0
  30. setiastro/images/astrosuitepro.png +0 -0
  31. setiastro/images/background.png +0 -0
  32. setiastro/images/background2.png +0 -0
  33. setiastro/images/benchmark.png +0 -0
  34. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  36. setiastro/images/blaster.png +0 -0
  37. setiastro/images/blink.png +0 -0
  38. setiastro/images/clahe.png +0 -0
  39. setiastro/images/collage.png +0 -0
  40. setiastro/images/colorwheel.png +0 -0
  41. setiastro/images/contsub.png +0 -0
  42. setiastro/images/convo.png +0 -0
  43. setiastro/images/copyslot.png +0 -0
  44. setiastro/images/cosmic.png +0 -0
  45. setiastro/images/cosmicsat.png +0 -0
  46. setiastro/images/crop1.png +0 -0
  47. setiastro/images/cropicon.png +0 -0
  48. setiastro/images/curves.png +0 -0
  49. setiastro/images/cvs.png +0 -0
  50. setiastro/images/debayer.png +0 -0
  51. setiastro/images/denoise_cnn_custom.png +0 -0
  52. setiastro/images/denoise_cnn_graph.png +0 -0
  53. setiastro/images/disk.png +0 -0
  54. setiastro/images/dse.png +0 -0
  55. setiastro/images/exoicon.png +0 -0
  56. setiastro/images/eye.png +0 -0
  57. setiastro/images/fliphorizontal.png +0 -0
  58. setiastro/images/flipvertical.png +0 -0
  59. setiastro/images/font.png +0 -0
  60. setiastro/images/freqsep.png +0 -0
  61. setiastro/images/functionbundle.png +0 -0
  62. setiastro/images/graxpert.png +0 -0
  63. setiastro/images/green.png +0 -0
  64. setiastro/images/gridicon.png +0 -0
  65. setiastro/images/halo.png +0 -0
  66. setiastro/images/hdr.png +0 -0
  67. setiastro/images/histogram.png +0 -0
  68. setiastro/images/hubble.png +0 -0
  69. setiastro/images/imagecombine.png +0 -0
  70. setiastro/images/invert.png +0 -0
  71. setiastro/images/isophote.png +0 -0
  72. setiastro/images/isophote_demo_figure.png +0 -0
  73. setiastro/images/isophote_demo_image.png +0 -0
  74. setiastro/images/isophote_demo_model.png +0 -0
  75. setiastro/images/isophote_demo_residual.png +0 -0
  76. setiastro/images/jwstpupil.png +0 -0
  77. setiastro/images/linearfit.png +0 -0
  78. setiastro/images/livestacking.png +0 -0
  79. setiastro/images/mask.png +0 -0
  80. setiastro/images/maskapply.png +0 -0
  81. setiastro/images/maskcreate.png +0 -0
  82. setiastro/images/maskremove.png +0 -0
  83. setiastro/images/morpho.png +0 -0
  84. setiastro/images/mosaic.png +0 -0
  85. setiastro/images/multiscale_decomp.png +0 -0
  86. setiastro/images/nbtorgb.png +0 -0
  87. setiastro/images/neutral.png +0 -0
  88. setiastro/images/nuke.png +0 -0
  89. setiastro/images/openfile.png +0 -0
  90. setiastro/images/pedestal.png +0 -0
  91. setiastro/images/pen.png +0 -0
  92. setiastro/images/pixelmath.png +0 -0
  93. setiastro/images/platesolve.png +0 -0
  94. setiastro/images/ppp.png +0 -0
  95. setiastro/images/pro.png +0 -0
  96. setiastro/images/project.png +0 -0
  97. setiastro/images/psf.png +0 -0
  98. setiastro/images/redo.png +0 -0
  99. setiastro/images/redoicon.png +0 -0
  100. setiastro/images/rescale.png +0 -0
  101. setiastro/images/rgbalign.png +0 -0
  102. setiastro/images/rgbcombo.png +0 -0
  103. setiastro/images/rgbextract.png +0 -0
  104. setiastro/images/rotate180.png +0 -0
  105. setiastro/images/rotateclockwise.png +0 -0
  106. setiastro/images/rotatecounterclockwise.png +0 -0
  107. setiastro/images/satellite.png +0 -0
  108. setiastro/images/script.png +0 -0
  109. setiastro/images/selectivecolor.png +0 -0
  110. setiastro/images/simbad.png +0 -0
  111. setiastro/images/slot0.png +0 -0
  112. setiastro/images/slot1.png +0 -0
  113. setiastro/images/slot2.png +0 -0
  114. setiastro/images/slot3.png +0 -0
  115. setiastro/images/slot4.png +0 -0
  116. setiastro/images/slot5.png +0 -0
  117. setiastro/images/slot6.png +0 -0
  118. setiastro/images/slot7.png +0 -0
  119. setiastro/images/slot8.png +0 -0
  120. setiastro/images/slot9.png +0 -0
  121. setiastro/images/spcc.png +0 -0
  122. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  123. setiastro/images/spinner.gif +0 -0
  124. setiastro/images/stacking.png +0 -0
  125. setiastro/images/staradd.png +0 -0
  126. setiastro/images/staralign.png +0 -0
  127. setiastro/images/starnet.png +0 -0
  128. setiastro/images/starregistration.png +0 -0
  129. setiastro/images/starspike.png +0 -0
  130. setiastro/images/starstretch.png +0 -0
  131. setiastro/images/statstretch.png +0 -0
  132. setiastro/images/supernova.png +0 -0
  133. setiastro/images/uhs.png +0 -0
  134. setiastro/images/undoicon.png +0 -0
  135. setiastro/images/upscale.png +0 -0
  136. setiastro/images/viewbundle.png +0 -0
  137. setiastro/images/whitebalance.png +0 -0
  138. setiastro/images/wimi_icon_256x256.png +0 -0
  139. setiastro/images/wimilogo.png +0 -0
  140. setiastro/images/wims.png +0 -0
  141. setiastro/images/wrench_icon.png +0 -0
  142. setiastro/images/xisfliberator.png +0 -0
  143. setiastro/saspro/__init__.py +20 -0
  144. setiastro/saspro/__main__.py +809 -0
  145. setiastro/saspro/_generated/__init__.py +7 -0
  146. setiastro/saspro/_generated/build_info.py +2 -0
  147. setiastro/saspro/abe.py +1295 -0
  148. setiastro/saspro/abe_preset.py +196 -0
  149. setiastro/saspro/aberration_ai.py +694 -0
  150. setiastro/saspro/aberration_ai_preset.py +224 -0
  151. setiastro/saspro/accel_installer.py +218 -0
  152. setiastro/saspro/accel_workers.py +30 -0
  153. setiastro/saspro/add_stars.py +621 -0
  154. setiastro/saspro/astrobin_exporter.py +1007 -0
  155. setiastro/saspro/astrospike.py +153 -0
  156. setiastro/saspro/astrospike_python.py +1839 -0
  157. setiastro/saspro/autostretch.py +196 -0
  158. setiastro/saspro/backgroundneutral.py +560 -0
  159. setiastro/saspro/batch_convert.py +325 -0
  160. setiastro/saspro/batch_renamer.py +519 -0
  161. setiastro/saspro/blemish_blaster.py +488 -0
  162. setiastro/saspro/blink_comparator_pro.py +2926 -0
  163. setiastro/saspro/bundles.py +61 -0
  164. setiastro/saspro/bundles_dock.py +114 -0
  165. setiastro/saspro/cheat_sheet.py +178 -0
  166. setiastro/saspro/clahe.py +342 -0
  167. setiastro/saspro/comet_stacking.py +1377 -0
  168. setiastro/saspro/common_tr.py +107 -0
  169. setiastro/saspro/config.py +38 -0
  170. setiastro/saspro/config_bootstrap.py +40 -0
  171. setiastro/saspro/config_manager.py +316 -0
  172. setiastro/saspro/continuum_subtract.py +1617 -0
  173. setiastro/saspro/convo.py +1397 -0
  174. setiastro/saspro/convo_preset.py +414 -0
  175. setiastro/saspro/copyastro.py +187 -0
  176. setiastro/saspro/cosmicclarity.py +1564 -0
  177. setiastro/saspro/cosmicclarity_preset.py +407 -0
  178. setiastro/saspro/crop_dialog_pro.py +956 -0
  179. setiastro/saspro/crop_preset.py +189 -0
  180. setiastro/saspro/curve_editor_pro.py +2544 -0
  181. setiastro/saspro/curves_preset.py +375 -0
  182. setiastro/saspro/debayer.py +670 -0
  183. setiastro/saspro/debug_utils.py +29 -0
  184. setiastro/saspro/dnd_mime.py +35 -0
  185. setiastro/saspro/doc_manager.py +2641 -0
  186. setiastro/saspro/exoplanet_detector.py +2166 -0
  187. setiastro/saspro/file_utils.py +284 -0
  188. setiastro/saspro/fitsmodifier.py +745 -0
  189. setiastro/saspro/fix_bom.py +32 -0
  190. setiastro/saspro/free_torch_memory.py +48 -0
  191. setiastro/saspro/frequency_separation.py +1343 -0
  192. setiastro/saspro/function_bundle.py +1594 -0
  193. setiastro/saspro/generate_translations.py +2378 -0
  194. setiastro/saspro/ghs_dialog_pro.py +660 -0
  195. setiastro/saspro/ghs_preset.py +284 -0
  196. setiastro/saspro/graxpert.py +634 -0
  197. setiastro/saspro/graxpert_preset.py +287 -0
  198. setiastro/saspro/gui/__init__.py +0 -0
  199. setiastro/saspro/gui/main_window.py +8567 -0
  200. setiastro/saspro/gui/mixins/__init__.py +33 -0
  201. setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
  202. setiastro/saspro/gui/mixins/file_mixin.py +443 -0
  203. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  204. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  205. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  206. setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
  207. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  208. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  209. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  210. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  211. setiastro/saspro/halobgon.py +462 -0
  212. setiastro/saspro/header_viewer.py +448 -0
  213. setiastro/saspro/headless_utils.py +88 -0
  214. setiastro/saspro/histogram.py +753 -0
  215. setiastro/saspro/history_explorer.py +939 -0
  216. setiastro/saspro/i18n.py +156 -0
  217. setiastro/saspro/image_combine.py +414 -0
  218. setiastro/saspro/image_peeker_pro.py +1601 -0
  219. setiastro/saspro/imageops/__init__.py +37 -0
  220. setiastro/saspro/imageops/mdi_snap.py +292 -0
  221. setiastro/saspro/imageops/scnr.py +36 -0
  222. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  223. setiastro/saspro/imageops/stretch.py +244 -0
  224. setiastro/saspro/isophote.py +1179 -0
  225. setiastro/saspro/layers.py +208 -0
  226. setiastro/saspro/layers_dock.py +714 -0
  227. setiastro/saspro/lazy_imports.py +193 -0
  228. setiastro/saspro/legacy/__init__.py +2 -0
  229. setiastro/saspro/legacy/image_manager.py +2226 -0
  230. setiastro/saspro/legacy/numba_utils.py +3659 -0
  231. setiastro/saspro/legacy/xisf.py +1071 -0
  232. setiastro/saspro/linear_fit.py +534 -0
  233. setiastro/saspro/live_stacking.py +1830 -0
  234. setiastro/saspro/log_bus.py +5 -0
  235. setiastro/saspro/logging_config.py +460 -0
  236. setiastro/saspro/luminancerecombine.py +309 -0
  237. setiastro/saspro/main_helpers.py +201 -0
  238. setiastro/saspro/mask_creation.py +928 -0
  239. setiastro/saspro/masks_core.py +56 -0
  240. setiastro/saspro/mdi_widgets.py +353 -0
  241. setiastro/saspro/memory_utils.py +666 -0
  242. setiastro/saspro/metadata_patcher.py +75 -0
  243. setiastro/saspro/mfdeconv.py +3826 -0
  244. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  245. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  246. setiastro/saspro/mfdeconvsport.py +2382 -0
  247. setiastro/saspro/minorbodycatalog.py +567 -0
  248. setiastro/saspro/morphology.py +382 -0
  249. setiastro/saspro/multiscale_decomp.py +1290 -0
  250. setiastro/saspro/nbtorgb_stars.py +531 -0
  251. setiastro/saspro/numba_utils.py +3044 -0
  252. setiastro/saspro/numba_warmup.py +141 -0
  253. setiastro/saspro/ops/__init__.py +9 -0
  254. setiastro/saspro/ops/command_help_dialog.py +623 -0
  255. setiastro/saspro/ops/command_runner.py +217 -0
  256. setiastro/saspro/ops/commands.py +1594 -0
  257. setiastro/saspro/ops/script_editor.py +1102 -0
  258. setiastro/saspro/ops/scripts.py +1413 -0
  259. setiastro/saspro/ops/settings.py +679 -0
  260. setiastro/saspro/parallel_utils.py +554 -0
  261. setiastro/saspro/pedestal.py +121 -0
  262. setiastro/saspro/perfect_palette_picker.py +1070 -0
  263. setiastro/saspro/pipeline.py +110 -0
  264. setiastro/saspro/pixelmath.py +1600 -0
  265. setiastro/saspro/plate_solver.py +2444 -0
  266. setiastro/saspro/project_io.py +797 -0
  267. setiastro/saspro/psf_utils.py +136 -0
  268. setiastro/saspro/psf_viewer.py +549 -0
  269. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  270. setiastro/saspro/remove_green.py +314 -0
  271. setiastro/saspro/remove_stars.py +1625 -0
  272. setiastro/saspro/remove_stars_preset.py +404 -0
  273. setiastro/saspro/resources.py +477 -0
  274. setiastro/saspro/rgb_combination.py +207 -0
  275. setiastro/saspro/rgb_extract.py +19 -0
  276. setiastro/saspro/rgbalign.py +723 -0
  277. setiastro/saspro/runtime_imports.py +7 -0
  278. setiastro/saspro/runtime_torch.py +754 -0
  279. setiastro/saspro/save_options.py +72 -0
  280. setiastro/saspro/selective_color.py +1552 -0
  281. setiastro/saspro/sfcc.py +1430 -0
  282. setiastro/saspro/shortcuts.py +3043 -0
  283. setiastro/saspro/signature_insert.py +1099 -0
  284. setiastro/saspro/stacking_suite.py +18181 -0
  285. setiastro/saspro/star_alignment.py +7420 -0
  286. setiastro/saspro/star_alignment_preset.py +329 -0
  287. setiastro/saspro/star_metrics.py +49 -0
  288. setiastro/saspro/star_spikes.py +681 -0
  289. setiastro/saspro/star_stretch.py +470 -0
  290. setiastro/saspro/stat_stretch.py +506 -0
  291. setiastro/saspro/status_log_dock.py +78 -0
  292. setiastro/saspro/subwindow.py +3267 -0
  293. setiastro/saspro/supernovaasteroidhunter.py +1716 -0
  294. setiastro/saspro/swap_manager.py +99 -0
  295. setiastro/saspro/torch_backend.py +89 -0
  296. setiastro/saspro/torch_rejection.py +434 -0
  297. setiastro/saspro/translations/de_translations.py +3733 -0
  298. setiastro/saspro/translations/es_translations.py +3923 -0
  299. setiastro/saspro/translations/fr_translations.py +3842 -0
  300. setiastro/saspro/translations/integrate_translations.py +234 -0
  301. setiastro/saspro/translations/it_translations.py +3662 -0
  302. setiastro/saspro/translations/ja_translations.py +3585 -0
  303. setiastro/saspro/translations/pt_translations.py +3853 -0
  304. setiastro/saspro/translations/saspro_de.qm +0 -0
  305. setiastro/saspro/translations/saspro_de.ts +253 -0
  306. setiastro/saspro/translations/saspro_es.qm +0 -0
  307. setiastro/saspro/translations/saspro_es.ts +12520 -0
  308. setiastro/saspro/translations/saspro_fr.qm +0 -0
  309. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  310. setiastro/saspro/translations/saspro_it.qm +0 -0
  311. setiastro/saspro/translations/saspro_it.ts +12520 -0
  312. setiastro/saspro/translations/saspro_ja.qm +0 -0
  313. setiastro/saspro/translations/saspro_ja.ts +257 -0
  314. setiastro/saspro/translations/saspro_pt.qm +0 -0
  315. setiastro/saspro/translations/saspro_pt.ts +257 -0
  316. setiastro/saspro/translations/saspro_zh.qm +0 -0
  317. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  318. setiastro/saspro/translations/zh_translations.py +3659 -0
  319. setiastro/saspro/versioning.py +71 -0
  320. setiastro/saspro/view_bundle.py +1555 -0
  321. setiastro/saspro/wavescale_hdr.py +624 -0
  322. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  323. setiastro/saspro/wavescalede.py +658 -0
  324. setiastro/saspro/wavescalede_preset.py +230 -0
  325. setiastro/saspro/wcs_update.py +374 -0
  326. setiastro/saspro/whitebalance.py +456 -0
  327. setiastro/saspro/widgets/__init__.py +48 -0
  328. setiastro/saspro/widgets/common_utilities.py +306 -0
  329. setiastro/saspro/widgets/graphics_views.py +122 -0
  330. setiastro/saspro/widgets/image_utils.py +518 -0
  331. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  332. setiastro/saspro/widgets/spinboxes.py +275 -0
  333. setiastro/saspro/widgets/themed_buttons.py +13 -0
  334. setiastro/saspro/widgets/wavelet_utils.py +299 -0
  335. setiastro/saspro/window_shelf.py +185 -0
  336. setiastro/saspro/xisf.py +1123 -0
  337. setiastrosuitepro-1.6.1.dist-info/METADATA +267 -0
  338. setiastrosuitepro-1.6.1.dist-info/RECORD +342 -0
  339. setiastrosuitepro-1.6.1.dist-info/WHEEL +4 -0
  340. setiastrosuitepro-1.6.1.dist-info/entry_points.txt +6 -0
  341. setiastrosuitepro-1.6.1.dist-info/licenses/LICENSE +674 -0
  342. setiastrosuitepro-1.6.1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,723 @@
1
+ # pro/rgbalign.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import numpy as np
6
+
7
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal
8
+ from PyQt6.QtWidgets import (
9
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QApplication,
10
+ QComboBox, QCheckBox, QMessageBox, QProgressBar, QPlainTextEdit, QSpinBox
11
+ )
12
+
13
+
14
+ import astroalign
15
+
16
+ import sep
17
+
18
+ import cv2
19
+
20
+ # try to reuse poly from star_alignment if present
21
+ try:
22
+ from setiastro.saspro.star_alignment import PolynomialTransform
23
+ except Exception:
24
+ PolynomialTransform = None
25
+
26
+
27
+ # ─────────────────────────────────────────────────────────────────────
28
+ # Worker
29
+ # ─────────────────────────────────────────────────────────────────────
30
+ class RGBAlignWorker(QThread):
31
+ progress = pyqtSignal(int, str) # (percent, message)
32
+ done = pyqtSignal(np.ndarray) # aligned RGB image
33
+ failed = pyqtSignal(str)
34
+ EDGE_FRAC = 0.55 # 55% of max radius
35
+ MIN_EDGE_PTS = 6 # want at least 6 out there
36
+ EDGE_INNER_FRAC = 0.38 # toss center 38% radius
37
+ MATCH_MAX_DIST = 10.0 # px, generous for CA
38
+ MIN_MATCHES = 6
39
+
40
+
41
+ def __init__(self, img: np.ndarray, model: str, sep_sigma: float = 3.0):
42
+ super().__init__()
43
+ self.img = img
44
+ self.model = model
45
+ self.sep_sigma = float(sep_sigma)
46
+
47
+ self.r_xform = None
48
+ self.b_xform = None
49
+ self.r_pairs = None
50
+ self.b_pairs = None
51
+
52
+ def _pts_too_central(self, pts: np.ndarray | None, shape) -> bool:
53
+ """
54
+ Return True if the matched points are all bunched near the center.
55
+ pts: (N, 2) in x,y
56
+ shape: (H, W)
57
+ """
58
+ if pts is None or len(pts) == 0:
59
+ return True
60
+ h, w = shape[:2]
61
+ cx, cy = w * 0.5, h * 0.5
62
+ # distance of each point from center
63
+ r = np.hypot(pts[:, 0] - cx, pts[:, 1] - cy)
64
+ rmax = np.hypot(cx, cy) # radius to corner
65
+ edge_mask = r > (self.EDGE_FRAC * rmax)
66
+ return edge_mask.sum() < self.MIN_EDGE_PTS
67
+
68
+ def _sep_detect_points(self, img: np.ndarray):
69
+ """Return (N,2) points from SEP, brightest first, using user sigma."""
70
+ if sep is None:
71
+ return None
72
+ data = img.astype(np.float32, copy=False)
73
+ bkg = sep.Background(data)
74
+ data_sub = data - bkg
75
+ # use the promoted sigma here 👇
76
+ objs = sep.extract(data_sub, self.sep_sigma, err=bkg.globalrms)
77
+ if objs is None or len(objs) == 0:
78
+ return None
79
+ idx = np.argsort(objs["peak"])[::-1]
80
+ pts = np.stack([objs["x"][idx], objs["y"][idx]], axis=1)
81
+ return pts
82
+
83
+ def _filter_edge_ring(self, pts: np.ndarray, shape, inner_frac=EDGE_INNER_FRAC):
84
+ """Keep only points outside inner_frac * Rmax."""
85
+ if pts is None or pts.size == 0:
86
+ return None
87
+ h, w = shape[:2]
88
+ cx, cy = w * 0.5, h * 0.5
89
+ r = np.hypot(pts[:,0] - cx, pts[:,1] - cy)
90
+ rmax = np.hypot(cx, cy)
91
+ mask = r >= (inner_frac * rmax)
92
+ pts_edge = pts[mask]
93
+ return pts_edge if pts_edge.size else None
94
+
95
+ def _pair_edge_points(self, src_img, ref_img, shape):
96
+ """Detect in BOTH images, keep only edge ring in REF, then NN-match in SRC."""
97
+ ref_pts = self._sep_detect_points(ref_img)
98
+ src_pts = self._sep_detect_points(src_img)
99
+ if ref_pts is None or src_pts is None:
100
+ return None, None
101
+
102
+ ref_edge = self._filter_edge_ring(ref_pts, shape)
103
+ if ref_edge is None:
104
+ return None, None
105
+
106
+ # brute-force NN, small N, so ok
107
+ src_arr = np.asarray(src_pts, dtype=np.float32)
108
+ pairs_src = []
109
+ pairs_dst = []
110
+ for (x_ref, y_ref) in ref_edge:
111
+ dxy = src_arr - np.array([x_ref, y_ref], dtype=np.float32)
112
+ dist = np.hypot(dxy[:,0], dxy[:,1])
113
+ j = np.argmin(dist)
114
+ if dist[j] <= self.MATCH_MAX_DIST:
115
+ # src point is in the channel we want to warp → source
116
+ pairs_src.append(src_arr[j])
117
+ # ref point is the green channel → destination
118
+ pairs_dst.append([x_ref, y_ref])
119
+
120
+ if len(pairs_src) < self.MIN_MATCHES:
121
+ return None, None
122
+
123
+ return (np.array(pairs_src, dtype=np.float32),
124
+ np.array(pairs_dst, dtype=np.float32))
125
+
126
+
127
+ def run(self):
128
+ if self.img is None or self.img.ndim != 3 or self.img.shape[2] < 3:
129
+ self.failed.emit("Image must be RGB (3 channels).")
130
+ return
131
+ if astroalign is None:
132
+ self.failed.emit("astroalign is not available.")
133
+ return
134
+
135
+ try:
136
+ self.progress.emit(5, "Preparing channels…")
137
+ R = np.ascontiguousarray(self.img[..., 0].astype(np.float32, copy=False))
138
+ G = np.ascontiguousarray(self.img[..., 1].astype(np.float32, copy=False))
139
+ B = np.ascontiguousarray(self.img[..., 2].astype(np.float32, copy=False))
140
+
141
+ # R → G
142
+ self.progress.emit(15, "Aligning Red → Green…")
143
+ kind_R, X_R, (r_src, r_dst) = self._estimate_transform(R, G, self.model)
144
+ self.r_xform = (kind_R, X_R)
145
+ self.r_pairs = (r_src, r_dst)
146
+ self.progress.emit(35, f"Red transform = {kind_R}")
147
+ R_aligned = self._warp_channel(R, kind_R, X_R, G.shape)
148
+
149
+ # B → G
150
+ self.progress.emit(55, "Aligning Blue → Green…")
151
+ kind_B, X_B, (b_src, b_dst) = self._estimate_transform(B, G, self.model)
152
+ self.b_xform = (kind_B, X_B)
153
+ self.b_pairs = (b_src, b_dst)
154
+ self.progress.emit(75, f"Blue transform = {kind_B}")
155
+ B_aligned = self._warp_channel(B, kind_B, X_B, G.shape)
156
+
157
+ out = np.stack([R_aligned, G, B_aligned], axis=2).astype(self.img.dtype, copy=False)
158
+ self.progress.emit(100, "Done.")
159
+ self.done.emit(out)
160
+ except Exception as e:
161
+ self.failed.emit(str(e))
162
+
163
+
164
+ # ───── helpers (basically mini versions of your big star alignment logic) ─────
165
+ def _estimate_transform(self, src: np.ndarray, ref: np.ndarray, model: str):
166
+ H, W = ref.shape[:2]
167
+
168
+ # ── 0) edge-only, SEP-based path ─────────────────────────────
169
+ if model == "edge-sep":
170
+ src_xy, dst_xy = self._pair_edge_points(src, ref, (H, W))
171
+ if src_xy is not None and dst_xy is not None and cv2 is not None:
172
+ # 0a) try homography first (better for corner warp)
173
+ Hh, inliers = cv2.findHomography(
174
+ src_xy, dst_xy,
175
+ method=cv2.RANSAC,
176
+ ransacReprojThreshold=2.5,
177
+ maxIters=2000,
178
+ confidence=0.999,
179
+ )
180
+ if Hh is not None:
181
+ return ("homography", Hh, (src_xy, dst_xy))
182
+
183
+ # 0b) fallback → affine
184
+ A, inliers = cv2.estimateAffine2D(
185
+ src_xy, dst_xy,
186
+ method=cv2.RANSAC,
187
+ ransacReprojThreshold=2.5,
188
+ maxIters=2000,
189
+ confidence=0.999,
190
+ )
191
+ if A is not None:
192
+ return ("affine", A, (src_xy, dst_xy))
193
+ # if SEP failed or cv2 missing → fall through to astroalign normal path
194
+
195
+ # ─────────────────────────────────────────────────────────────
196
+ # 1) astroalign normal pass
197
+ # ─────────────────────────────────────────────────────────────
198
+ tform, (src_pts, dst_pts) = astroalign.find_transform(
199
+ np.ascontiguousarray(src),
200
+ np.ascontiguousarray(ref),
201
+ max_control_points=50,
202
+ detection_sigma=5.0,
203
+ min_area=5,
204
+ )
205
+
206
+ # 2) 'hungry' pass if too central
207
+ if self._pts_too_central(dst_pts, ref.shape):
208
+ tform2, (src_pts2, dst_pts2) = astroalign.find_transform(
209
+ np.ascontiguousarray(src),
210
+ np.ascontiguousarray(ref),
211
+ max_control_points=120,
212
+ detection_sigma=3.0,
213
+ min_area=3,
214
+ )
215
+ if not self._pts_too_central(dst_pts2, ref.shape):
216
+ tform, src_pts, dst_pts = tform2, src_pts2, dst_pts2
217
+
218
+ # 3) original branching
219
+ P = np.asarray(tform.params, dtype=np.float64)
220
+ src_xy = np.asarray(src_pts, dtype=np.float32)
221
+ dst_xy = np.asarray(dst_pts, dtype=np.float32)
222
+
223
+ # affine
224
+ if model == "affine":
225
+ if cv2 is None:
226
+ return ("affine", P[0:2, :], (src_xy, dst_xy))
227
+ A, _ = cv2.estimateAffine2D(
228
+ src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
229
+ )
230
+ if A is None:
231
+ return ("affine", P[0:2, :], (src_xy, dst_xy))
232
+ return ("affine", A, (src_xy, dst_xy))
233
+
234
+ # homography
235
+ if model == "homography":
236
+ if cv2 is None:
237
+ if P.shape == (3, 3):
238
+ return ("homography", P, (src_xy, dst_xy))
239
+ A3 = np.vstack([P[0:2, :], [0, 0, 1]])
240
+ return ("homography", A3, (src_xy, dst_xy))
241
+ Hh, _ = cv2.findHomography(
242
+ src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
243
+ )
244
+ if Hh is None:
245
+ if P.shape == (3, 3):
246
+ return ("homography", P, (src_xy, dst_xy))
247
+ A3 = np.vstack([P[0:2, :], [0, 0, 1]])
248
+ return ("homography", A3, (src_xy, dst_xy))
249
+ return ("homography", Hh, (src_xy, dst_xy))
250
+
251
+ # poly3 / poly4
252
+ if model in ("poly3", "poly4") and PolynomialTransform is not None and cv2 is not None:
253
+ order = 3 if model == "poly3" else 4
254
+ scale_vec = np.array([W, H], dtype=np.float32)
255
+ src_n = src_xy / scale_vec
256
+ dst_n = dst_xy / scale_vec
257
+
258
+ t_poly = PolynomialTransform()
259
+ ok = t_poly.estimate(dst_n, src_n, order=order) # dst → src
260
+ if not ok:
261
+ Hh, _ = cv2.findHomography(
262
+ src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
263
+ )
264
+ return ("homography", Hh, (src_xy, dst_xy))
265
+
266
+ def _warp_poly(img: np.ndarray, out_shape: tuple[int, int]):
267
+ Hh_, Ww_ = out_shape
268
+ yy, xx = np.mgrid[0:Hh_, 0:Ww_].astype(np.float32)
269
+ coords = np.stack([xx, yy], axis=-1).reshape(-1, 2)
270
+ coords_n = coords / scale_vec
271
+ mapped_n = t_poly(coords_n)
272
+ mapped = mapped_n * scale_vec
273
+ map_x = mapped[:, 0].reshape(Hh_, Ww_).astype(np.float32)
274
+ map_y = mapped[:, 1].reshape(Hh_, Ww_).astype(np.float32)
275
+ return cv2.remap(
276
+ img, map_x, map_y,
277
+ interpolation=cv2.INTER_LANCZOS4,
278
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0
279
+ )
280
+
281
+ return (model, _warp_poly, (src_xy, dst_xy))
282
+
283
+ # fallback → homography
284
+ if cv2 is None:
285
+ if P.shape == (3, 3):
286
+ return ("homography", P, (src_xy, dst_xy))
287
+ A3 = np.vstack([P[0:2, :], [0, 0, 1]])
288
+ return ("homography", A3, (src_xy, dst_xy))
289
+
290
+ Hh, _ = cv2.findHomography(
291
+ src_xy, dst_xy, method=cv2.RANSAC, ransacReprojThreshold=3.0
292
+ )
293
+ return ("homography", Hh, (src_xy, dst_xy))
294
+
295
+
296
+
297
+ def _pick_edge_stars_with_sep(self, img, tiles=(3,3), per_tile=2):
298
+ if sep is None:
299
+ return []
300
+ data = img.astype(np.float32, copy=False)
301
+ bkg = sep.Background(data)
302
+ data_sub = data - bkg
303
+ objs = sep.extract(data_sub, 1.5, err=bkg.globalrms)
304
+ H, W = data.shape[:2]
305
+ th, tw = H // tiles[0], W // tiles[1]
306
+ picked = []
307
+ for ty in range(tiles[0]):
308
+ for tx in range(tiles[1]):
309
+ y0, y1 = ty*th, min((ty+1)*th, H)
310
+ x0, x1 = tx*tw, min((tx+1)*tw, W)
311
+ box = objs[
312
+ (objs['y'] >= y0) & (objs['y'] < y1) &
313
+ (objs['x'] >= x0) & (objs['x'] < x1)
314
+ ]
315
+ if len(box) == 0:
316
+ continue
317
+ # brightest first
318
+ box = box[np.argsort(box['peak'])][::-1][:per_tile]
319
+ for o in box:
320
+ picked.append((float(o['x']), float(o['y'])))
321
+ return picked
322
+
323
+
324
+ def _warp_channel(self, ch: np.ndarray, kind: str, X, ref_shape):
325
+ H, W = ref_shape[:2]
326
+ if kind == "affine":
327
+ if cv2 is None:
328
+ return ch
329
+ A = np.asarray(X, dtype=np.float32).reshape(2, 3)
330
+ return cv2.warpAffine(ch, A, (W, H), flags=cv2.INTER_LANCZOS4,
331
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
332
+
333
+ if kind == "homography":
334
+ if cv2 is None:
335
+ return ch
336
+ Hm = np.asarray(X, dtype=np.float32).reshape(3, 3)
337
+ return cv2.warpPerspective(ch, Hm, (W, H), flags=cv2.INTER_LANCZOS4,
338
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
339
+
340
+ if kind.startswith("poly"):
341
+ return X(ch, (H, W))
342
+
343
+ return ch
344
+
345
+
346
+ # ─────────────────────────────────────────────────────────────────────
347
+ # Dialog
348
+ # ─────────────────────────────────────────────────────────────────────
349
+ class RGBAlignDialog(QDialog):
350
+ def __init__(self, parent=None, document=None):
351
+ super().__init__(parent)
352
+ self.setWindowTitle(self.tr("RGB Align"))
353
+ self.parent = parent
354
+ # document could be a view; try to unwrap
355
+ self.doc_view = document
356
+ self.doc = getattr(document, "document", document)
357
+ self.image = getattr(self.doc, "image", None) if self.doc is not None else None
358
+
359
+ lay = QVBoxLayout(self)
360
+ lay.addWidget(QLabel("Align R and B channels to G.\n"
361
+ "Select model and run."))
362
+
363
+ hl = QHBoxLayout()
364
+ hl.addWidget(QLabel(self.tr("Alignment model:")))
365
+ self.model_combo = QComboBox()
366
+ self.model_combo.addItems([
367
+ "EDGE", # ← first, new default
368
+ "Homography",
369
+ "Affine",
370
+ "Poly 3",
371
+ "Poly 4",
372
+ ])
373
+ self.model_combo.setCurrentIndex(0)
374
+
375
+ # tooltips for each mode
376
+ self.model_combo.setItemData(
377
+ 0,
378
+ (
379
+ "EDGE (Edge-Detected Guided Estimator)\n"
380
+ "• Detect stars in both channels with SEP\n"
381
+ "• Keep only outer-ring stars (ignore center)\n"
382
+ "• Try homography first for corner CA\n"
383
+ "• If homography fails → try affine\n"
384
+ "• If that fails → fall back to astroalign"
385
+ ),
386
+ Qt.ItemDataRole.ToolTipRole,
387
+ )
388
+ self.model_combo.setItemData(
389
+ 1,
390
+ "Standard homography using astroalign matches (good general-purpose choice).",
391
+ Qt.ItemDataRole.ToolTipRole,
392
+ )
393
+ self.model_combo.setItemData(
394
+ 2,
395
+ "Affine (shift + scale + rotate + shear). Good when channels are mostly parallel.",
396
+ Qt.ItemDataRole.ToolTipRole,
397
+ )
398
+ self.model_combo.setItemData(
399
+ 3,
400
+ "Polynomial (order 3). Use when you have mild field distortion.",
401
+ Qt.ItemDataRole.ToolTipRole,
402
+ )
403
+ self.model_combo.setItemData(
404
+ 4,
405
+ "Polynomial (order 4). Use for stronger distortion, but needs more/better matches.",
406
+ Qt.ItemDataRole.ToolTipRole,
407
+ )
408
+ hl.addWidget(self.model_combo)
409
+ lay.addLayout(hl)
410
+
411
+ # ── SEP controls ─────────────────────────
412
+ sep_row = QHBoxLayout()
413
+ sep_row.addWidget(QLabel(self.tr("SEP sigma:")))
414
+
415
+ self.sep_spin = QSpinBox()
416
+ self.sep_spin.setRange(1, 100)
417
+ self.sep_spin.setValue(5) # default; 1.5 was too hungry
418
+ self.sep_spin.setToolTip("Detection threshold (σ) for SEP star finding in EDGE mode.\n"
419
+ "Higher = fewer stars, lower = more stars.")
420
+ sep_row.addWidget(self.sep_spin)
421
+
422
+ self.btn_trial_sep = QPushButton(self.tr("Trial detect stars"))
423
+ self.btn_trial_sep.setToolTip("Run SEP on the green channel with this sigma and report how many "
424
+ "stars it finds and how many are in the EDGE ring.")
425
+ self.btn_trial_sep.clicked.connect(self._trial_sep_detect)
426
+ sep_row.addWidget(self.btn_trial_sep)
427
+
428
+ lay.addLayout(sep_row)
429
+
430
+
431
+ self.chk_new_doc = QCheckBox(self.tr("Create new document (keep original)"))
432
+ self.chk_new_doc.setChecked(True)
433
+ lay.addWidget(self.chk_new_doc)
434
+
435
+ # progress
436
+ self.progress_label = QLabel("Idle.")
437
+ self.progress_bar = QProgressBar()
438
+ self.progress_bar.setRange(0, 100)
439
+ self.progress_bar.setValue(0)
440
+ lay.addWidget(self.progress_label)
441
+ lay.addWidget(self.progress_bar)
442
+
443
+ self.summary_box = QPlainTextEdit()
444
+ self.summary_box.setReadOnly(True)
445
+ self.summary_box.setPlaceholderText("Transform summary will appear here…")
446
+ self.summary_box.setMinimumHeight(140)
447
+ # optional: monospace
448
+ self.summary_box.setStyleSheet("font-family: Consolas, 'Courier New', monospace; font-size: 11px;")
449
+ lay.addWidget(self.summary_box)
450
+
451
+ btns = QHBoxLayout()
452
+ self.btn_run = QPushButton(self.tr("Align"))
453
+ self.btn_close = QPushButton(self.tr("Close"))
454
+ btns.addWidget(self.btn_run)
455
+ btns.addWidget(self.btn_close)
456
+ lay.addLayout(btns)
457
+
458
+ self.btn_run.clicked.connect(self._start_align)
459
+ self.btn_close.clicked.connect(self.close)
460
+
461
+ self.worker: RGBAlignWorker | None = None
462
+
463
+ def _trial_sep_detect(self):
464
+ if self.image is None:
465
+ QMessageBox.warning(self, "RGB Align", "No image loaded.")
466
+ return
467
+ if sep is None:
468
+ QMessageBox.warning(self, "RGB Align", "python-sep is not available.")
469
+ return
470
+ self.progress_label.setText(f"Trial Detection In Progress…")
471
+ QApplication.processEvents()
472
+ # use green channel as reference, same as align
473
+ G = np.ascontiguousarray(self.image[..., 1].astype(np.float32, copy=False))
474
+ sigma = float(self.sep_spin.value())
475
+
476
+ # run a mini version of what the worker does
477
+ bkg = sep.Background(G)
478
+ data_sub = G - bkg
479
+ objs = sep.extract(data_sub, sigma, err=bkg.globalrms)
480
+ total = 0 if objs is None else len(objs)
481
+
482
+ # compute how many are in the EDGE ring, using same logic/constants
483
+ h, w = G.shape[:2]
484
+ cx, cy = w * 0.5, h * 0.5
485
+ rmax = np.hypot(cx, cy)
486
+ edge_inner = RGBAlignWorker.EDGE_INNER_FRAC * rmax
487
+
488
+ if objs is not None and total > 0:
489
+ r = np.hypot(objs["x"] - cx, objs["y"] - cy)
490
+ edge_mask = r >= edge_inner
491
+ edge_count = int(edge_mask.sum())
492
+ else:
493
+ edge_count = 0
494
+
495
+ msg = (f"[Trial SEP]\n"
496
+ f"sigma = {sigma}\n"
497
+ f"total stars (green): {total}\n"
498
+ f"outer-ring stars (used by EDGE): {edge_count}")
499
+ self.summary_box.setPlainText(msg)
500
+ self.progress_label.setText(f"Trial SEP: {total} stars, {edge_count} edge")
501
+
502
+
503
+ def _start_align(self):
504
+ if self.image is None:
505
+ QMessageBox.warning(self, "RGB Align", "No image found in active view.")
506
+ return
507
+ if self.image.ndim != 3 or self.image.shape[2] < 3:
508
+ QMessageBox.warning(self, "RGB Align", "Image must be RGB (3 channels).")
509
+ return
510
+ if astroalign is None:
511
+ QMessageBox.warning(self, "RGB Align", "astroalign is not available.")
512
+ return
513
+
514
+ model = self._selected_model()
515
+ sep_sigma = float(self.sep_spin.value())
516
+ self.progress_label.setText("Starting…")
517
+ self.progress_bar.setValue(0)
518
+
519
+ self.worker = RGBAlignWorker(self.image, model, sep_sigma=sep_sigma)
520
+ self.worker.progress.connect(self._on_worker_progress)
521
+ self.worker.done.connect(self._on_worker_done)
522
+ self.worker.failed.connect(self._on_worker_failed)
523
+ self.worker.start()
524
+ self.btn_run.setEnabled(False)
525
+
526
+ def _selected_model(self) -> str:
527
+ txt = self.model_combo.currentText().lower()
528
+ if "edge" in txt:
529
+ return "edge-sep"
530
+ if "affine" in txt:
531
+ return "affine"
532
+ if "poly 3" in txt:
533
+ return "poly3"
534
+ if "poly 4" in txt:
535
+ return "poly4"
536
+ if "homography" in txt:
537
+ return "homography"
538
+ return "edge-sep" # super-safe fallback
539
+
540
+ # slots
541
+ def _on_worker_progress(self, pct: int, msg: str):
542
+ self.progress_bar.setValue(pct)
543
+ self.progress_label.setText(msg)
544
+
545
+ def _on_worker_failed(self, err: str):
546
+ self.btn_run.setEnabled(True)
547
+ self.progress_bar.setValue(0)
548
+ self.progress_label.setText("Failed.")
549
+ QMessageBox.critical(self, "RGB Align", err)
550
+
551
+ def _on_worker_done(self, out: np.ndarray):
552
+ self.btn_run.setEnabled(True)
553
+ self.progress_bar.setValue(100)
554
+ self.progress_label.setText("Applying…")
555
+
556
+ summary_lines = []
557
+ w = self.worker # type: ignore
558
+
559
+ if w is not None:
560
+ def _fmt_mat(M):
561
+ return "\n".join(
562
+ [" " + " ".join(f"{v: .6f}" for v in row) for row in M]
563
+ )
564
+
565
+ def _spread_stats(pts, shape):
566
+ if pts is None:
567
+ return " points: 0"
568
+ pts = np.asarray(pts, dtype=float)
569
+ if pts.size == 0:
570
+ return " points: 0"
571
+ h, w_ = shape[:2]
572
+ cx, cy = w_ * 0.5, h * 0.5
573
+ if pts.ndim != 2 or pts.shape[1] != 2:
574
+ return f" points: {len(pts)} (unusual shape {pts.shape})"
575
+ r = np.hypot(pts[:, 0] - cx, pts[:, 1] - cy)
576
+ rmax = np.hypot(cx, cy)
577
+ edge = r > (RGBAlignWorker.EDGE_FRAC * rmax)
578
+ return (
579
+ f" points: {len(pts)} "
580
+ f"(edge: {edge.sum()} ≥{RGBAlignWorker.EDGE_FRAC*100:.0f}%Rmax)"
581
+ )
582
+
583
+ h_img, w_img = self.image.shape[:2]
584
+
585
+ # ── R → G ──
586
+ if w.r_xform is not None:
587
+ kind, X = w.r_xform
588
+ summary_lines.append("Red → Green:")
589
+ if w.r_pairs is not None and len(w.r_pairs) == 2:
590
+ summary_lines.append(_spread_stats(w.r_pairs[1], (h_img, w_img)))
591
+ summary_lines.append(f" model: {kind}")
592
+ if kind == "affine":
593
+ A = np.asarray(X, dtype=float).reshape(2, 3)
594
+ M = np.vstack([A, [0, 0, 1]])
595
+ summary_lines.append(_fmt_mat(M))
596
+ elif kind == "homography":
597
+ Hm = np.asarray(X, dtype=float).reshape(3, 3)
598
+ summary_lines.append(_fmt_mat(Hm))
599
+ else:
600
+ summary_lines.append(" (non-matrix; warp callable)")
601
+
602
+ # ── B → G ──
603
+ if w.b_xform is not None:
604
+ kind, X = w.b_xform
605
+ summary_lines.append("")
606
+ summary_lines.append("Blue → Green:")
607
+ if w.b_pairs is not None and len(w.b_pairs) == 2:
608
+ summary_lines.append(_spread_stats(w.b_pairs[1], (h_img, w_img)))
609
+ summary_lines.append(f" model: {kind}")
610
+ if kind == "affine":
611
+ A = np.asarray(X, dtype=float).reshape(2, 3)
612
+ M = np.vstack([A, [0, 0, 1]])
613
+ summary_lines.append(_fmt_mat(M))
614
+ elif kind == "homography":
615
+ Hm = np.asarray(X, dtype=float).reshape(3, 3)
616
+ summary_lines.append(_fmt_mat(Hm))
617
+ else:
618
+ summary_lines.append(" (non-matrix; warp callable)")
619
+
620
+ summary_text = "\n".join(summary_lines) if summary_lines else "No transform info."
621
+ self.summary_box.setPlainText(summary_text)
622
+
623
+ if self.parent is not None and hasattr(self.parent, "_log") and callable(self.parent._log):
624
+ self.parent._log("[RGB Align]\n" + summary_text)
625
+
626
+ try:
627
+ if self.chk_new_doc.isChecked():
628
+ dm = getattr(self.parent, "docman", None)
629
+ if dm is not None:
630
+ dm.open_array(out, {"display_name": "RGB Aligned"}, title="RGB Aligned")
631
+ else:
632
+ if hasattr(self.doc, "apply_edit"):
633
+ self.doc.apply_edit(out, {"step_name": "RGB Align"}, step_name="RGB Align")
634
+ else:
635
+ self.doc.image = out
636
+ else:
637
+ if hasattr(self.doc, "apply_edit"):
638
+ self.doc.apply_edit(out, {"step_name": "RGB Align"}, step_name="RGB Align")
639
+ else:
640
+ self.doc.image = out
641
+
642
+ self.progress_label.setText("Done.")
643
+ except Exception as e:
644
+ self.progress_label.setText("Apply failed.")
645
+ QMessageBox.warning(self, "RGB Align", f"Aligned image created, but applying failed:\n{e}")
646
+
647
+
648
+
649
+
650
+
651
+ def align_rgb_array(img: np.ndarray, model: str = "edge-sep", sep_sigma: float = 3.0) -> np.ndarray:
652
+ """
653
+ Headless core: returns a new RGB image with R,B aligned to G.
654
+ Raises RuntimeError on problems.
655
+ """
656
+ if img is None or img.ndim != 3 or img.shape[2] < 3:
657
+ raise RuntimeError("Image must be RGB (3 channels).")
658
+ if astroalign is None:
659
+ raise RuntimeError("astroalign is not available.")
660
+
661
+ worker = RGBAlignWorker(img, model, sep_sigma=sep_sigma)
662
+
663
+ try:
664
+ R = np.ascontiguousarray(img[..., 0].astype(np.float32, copy=False))
665
+ G = np.ascontiguousarray(img[..., 1].astype(np.float32, copy=False))
666
+ B = np.ascontiguousarray(img[..., 2].astype(np.float32, copy=False))
667
+
668
+ def _estimate_and_warp(src, ref):
669
+ # NOTE: _estimate_transform now returns 3 values
670
+ kind, X, _pairs = worker._estimate_transform(src, ref, model)
671
+ return worker._warp_channel(src, kind, X, ref.shape)
672
+
673
+ R_aligned = _estimate_and_warp(R, G)
674
+ B_aligned = _estimate_and_warp(B, G)
675
+
676
+ out = np.stack([R_aligned, G, B_aligned], axis=2)
677
+ if img.dtype != out.dtype:
678
+ out = out.astype(img.dtype, copy=False)
679
+ return out
680
+ except Exception as e:
681
+ raise RuntimeError(str(e))
682
+
683
+ def run_rgb_align_headless(main_window, document, preset: dict | None = None):
684
+ if document is None:
685
+ QMessageBox.warning(main_window, "RGB Align", "No active document.")
686
+ return
687
+
688
+ img = np.asarray(document.image)
689
+ p = dict(preset or {})
690
+ model = p.get("model", "edge").lower()
691
+ sep_sigma = float(p.get("sep_sigma", 3.0))
692
+ create_new = bool(p.get("new_doc", False))
693
+
694
+ sb = getattr(main_window, "statusBar", None)
695
+ if callable(sb):
696
+ sb().showMessage(f"RGB Align ({model})…", 3000)
697
+
698
+ try:
699
+ out = align_rgb_array(img, model=model if model != "edge" else "edge-sep",
700
+ sep_sigma=sep_sigma)
701
+ except Exception as e:
702
+ QMessageBox.critical(main_window, "RGB Align (headless)", str(e))
703
+ return
704
+
705
+ if create_new:
706
+ dm = getattr(main_window, "docman", None)
707
+ if dm is not None:
708
+ dm.open_array(out, {"display_name": "RGB Aligned"}, title="RGB Aligned")
709
+ else:
710
+ # fallback to replace if we can't create new
711
+ try:
712
+ document.apply_edit(out, {"step_name": "RGB Align"})
713
+ except Exception:
714
+ document.image = out
715
+ else:
716
+ # in-place
717
+ if hasattr(document, "apply_edit"):
718
+ document.apply_edit(out, {"step_name": "RGB Align"}, step_name="RGB Align")
719
+ else:
720
+ document.image = out
721
+
722
+ if callable(sb):
723
+ sb().showMessage("RGB Align done.", 3000)