setiastrosuitepro 1.6.7__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 (394) 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/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2480 @@
1
+ # pro/plate_solver.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import math
7
+ import tempfile
8
+ from typing import Tuple, Dict, Any, Optional
9
+ from functools import lru_cache
10
+
11
+ import numpy as np
12
+ import json
13
+ import time
14
+ import requests
15
+ from astropy.io import fits
16
+ from astropy.io.fits import Header
17
+ from astropy.wcs import WCS
18
+
19
+ from PyQt6.QtCore import QProcess, QTimer, QEventLoop, Qt, QCoreApplication
20
+ from PyQt6.QtGui import QIcon
21
+ from PyQt6.QtWidgets import (
22
+ QDialog, QLabel, QPushButton, QVBoxLayout, QHBoxLayout,
23
+ QFileDialog, QComboBox, QStackedWidget, QWidget, QMessageBox,
24
+ QLineEdit, QTextEdit, QApplication, QProgressBar
25
+ )
26
+
27
+ # === our I/O & stretch — migrate from SASv2 ===
28
+ from setiastro.saspro.legacy.image_manager import load_image, save_image # <<<< IMPORTANT
29
+ try:
30
+ from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
31
+ except Exception:
32
+ stretch_mono_image = None
33
+ stretch_color_image = None
34
+
35
+
36
+ _NONFITS_META_KEYS = {
37
+ "FILE_PATH",
38
+ "FITS_HEADER",
39
+ "BIT_DEPTH",
40
+ "WCS_HEADER",
41
+ "__HEADER_SNAPSHOT__",
42
+ "ORIGINAL_HEADER",
43
+ "PRE_SOLVE_HEADER",
44
+ }
45
+
46
+ def _strip_nonfits_meta_keys_from_header(h: Header | None) -> Header:
47
+ """
48
+ Return a copy of the header with all of our internal, non-FITS metadata
49
+ keys removed. This prevents HIERARCH warnings and WCS failures on keys
50
+ like FILE_PATH with very long values.
51
+ """
52
+ if not isinstance(h, Header):
53
+ return Header()
54
+
55
+ out = h.copy()
56
+ for k in list(out.keys()):
57
+ if k.upper() in _NONFITS_META_KEYS:
58
+ try:
59
+ out.remove(k)
60
+ except Exception:
61
+ pass
62
+ return out
63
+
64
+ # --- Lightweight, modeless status popup for headless runs ---
65
+ _STATUS_POPUP = None # module-level singleton
66
+
67
+ class _SolveStatusPopup(QDialog):
68
+ def __init__(self, parent=None):
69
+ super().__init__(parent, Qt.WindowType.Tool)
70
+ self.setObjectName("plate_solve_status_popup")
71
+ self.setWindowTitle(self.tr("Plate Solving"))
72
+ self.setWindowModality(Qt.WindowModality.NonModal)
73
+ self.setWindowFlag(Qt.WindowType.WindowStaysOnTopHint, True)
74
+ self.setMinimumWidth(420)
75
+
76
+ lay = QVBoxLayout(self)
77
+ lay.setContentsMargins(12, 12, 12, 12)
78
+ lay.setSpacing(10)
79
+
80
+ self.label = QLabel(self.tr("Starting…"), self)
81
+ self.label.setWordWrap(True)
82
+ lay.addWidget(self.label)
83
+
84
+ self.bar = QProgressBar(self)
85
+ self.bar.setRange(0, 0) # indeterminate
86
+ lay.addWidget(self.bar)
87
+
88
+ row = QHBoxLayout()
89
+ row.addStretch(1)
90
+ hide_btn = QPushButton(self.tr("Hide"), self)
91
+ hide_btn.clicked.connect(self.hide)
92
+ row.addWidget(hide_btn)
93
+ lay.addLayout(row)
94
+
95
+ def update_text(self, text: str):
96
+ self.label.setText(text or "")
97
+ self.label.repaint() # quick visual feedback
98
+ QApplication.processEvents()
99
+
100
+ def _status_popup_open(parent, text: str = ""):
101
+ """Show (or create) the singleton status popup for headless runs."""
102
+ global _STATUS_POPUP
103
+ if _STATUS_POPUP is None:
104
+ host = parent if isinstance(parent, QWidget) else QApplication.activeWindow()
105
+ _STATUS_POPUP = _SolveStatusPopup(host)
106
+ if text:
107
+ _STATUS_POPUP.update_text(text)
108
+ _STATUS_POPUP.show()
109
+ _STATUS_POPUP.raise_()
110
+ QApplication.processEvents()
111
+ return _STATUS_POPUP
112
+
113
+ def _status_popup_update(text: str):
114
+ global _STATUS_POPUP
115
+ if _STATUS_POPUP is not None:
116
+ _STATUS_POPUP.update_text(text)
117
+
118
+ def _status_popup_close():
119
+ global _STATUS_POPUP
120
+ if _STATUS_POPUP is None:
121
+ return
122
+ try:
123
+ _STATUS_POPUP.hide()
124
+ except Exception:
125
+ pass
126
+
127
+
128
+ def _sleep_ui(ms: int):
129
+ """Non-blocking sleep that keeps the UI responsive."""
130
+ loop = QEventLoop()
131
+ QTimer.singleShot(ms, loop.quit)
132
+ loop.exec()
133
+
134
+ def _with_events():
135
+ """Yield to the UI event loop briefly."""
136
+ QApplication.processEvents()
137
+
138
+ def _set_status_ui(parent, text: str):
139
+ try:
140
+ updated_any = False
141
+
142
+ target = None
143
+ if hasattr(parent, "status") and isinstance(getattr(parent, "status"), QLabel):
144
+ target = parent.status
145
+ if target is None and hasattr(parent, "findChild"):
146
+ target = parent.findChild(QLabel, "status_label")
147
+ if target is not None:
148
+ target.setText(text)
149
+ updated_any = True
150
+
151
+ logw = getattr(parent, "log", None)
152
+ if logw and hasattr(logw, "append"):
153
+ tr_status = QCoreApplication.translate("PlateSolver", "Status:")
154
+ if text and (text.startswith("Status:") or text.startswith(tr_status) or text.startswith("▶") or text.startswith("✔") or text.startswith("❌")):
155
+ logw.append(text)
156
+ updated_any = True
157
+
158
+ if not updated_any:
159
+ _status_popup_open(parent, text)
160
+ else:
161
+ _status_popup_update(text)
162
+
163
+ QApplication.processEvents()
164
+ except Exception:
165
+ try:
166
+ _status_popup_open(parent, text)
167
+ except Exception:
168
+ pass
169
+
170
+
171
+ def _wait_process(proc: QProcess, timeout_ms: int, parent=None) -> bool:
172
+ """
173
+ Incrementally wait for a QProcess while pumping UI events so the dialog stays responsive.
174
+ Returns True if the process finished with NormalExit, else False.
175
+ """
176
+ deadline = time.monotonic() + (timeout_ms / 1000.0)
177
+ step_ms = 100
178
+
179
+ while time.monotonic() < deadline:
180
+ if proc.state() == QProcess.ProcessState.NotRunning:
181
+ break
182
+ _sleep_ui(step_ms)
183
+
184
+ if proc.state() != QProcess.ProcessState.NotRunning:
185
+ # Timed out: try to stop the process cleanly, then force kill.
186
+ try:
187
+ proc.terminate()
188
+ if not proc.waitForFinished(2000):
189
+ proc.kill()
190
+ proc.waitForFinished(2000)
191
+ except Exception:
192
+ pass
193
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: process timed out."))
194
+ return False
195
+
196
+ if proc.exitStatus() != QProcess.ExitStatus.NormalExit:
197
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: process did not exit normally."))
198
+ return False
199
+
200
+ return True
201
+
202
+ # --- astrometry.net config (web API) ---
203
+ ASTROMETRY_API_URL_DEFAULT = "https://nova.astrometry.net/api/"
204
+
205
+ def _get_astrometry_api_url(settings) -> str:
206
+ return (settings.value("astrometry/server_url", "", type=str) or ASTROMETRY_API_URL_DEFAULT).rstrip("/") + "/"
207
+
208
+ def _get_solvefield_exe(settings) -> str:
209
+ # Support both SASpro-style and legacy keys
210
+ cand = [
211
+ settings.value("paths/solve_field", "", type=str) or "",
212
+ settings.value("astrometry/solvefield_path", "", type=str) or "",
213
+ ]
214
+ for p in cand:
215
+ if p and os.path.exists(p):
216
+ return p
217
+ return cand[0] # may be empty (used to decide web vs. local)
218
+
219
+ def _get_astrometry_api_key(settings) -> str:
220
+ """
221
+ Canonical key: 'api/astrometry_key' (matches SettingsDialog).
222
+ Also check older legacy keys for backward compatibility.
223
+ """
224
+ if settings is None:
225
+ return ""
226
+
227
+ # ✅ canonical
228
+ key = settings.value("api/astrometry_key", "", type=str) or ""
229
+ key = key.strip()
230
+ if key:
231
+ return key
232
+
233
+ # 🔁 legacy fallbacks (if you ever stored them differently)
234
+ for k in (
235
+ "api/astrometry", # old guess
236
+ "astrometry/api_key",
237
+ "astrometry/key",
238
+ "astrometry_key",
239
+ "plate_solver/astrometry_key",
240
+ ):
241
+ v = settings.value(k, "", type=str) or ""
242
+ v = v.strip()
243
+ if v:
244
+ # migrate forward so it works next time
245
+ settings.setValue("api/astrometry_key", v)
246
+ try:
247
+ settings.remove(k)
248
+ except Exception:
249
+ pass
250
+ try:
251
+ settings.sync()
252
+ except Exception:
253
+ pass
254
+ return v
255
+
256
+ return ""
257
+
258
+
259
+ def _set_astrometry_api_key(settings, key: str) -> None:
260
+ if settings is None:
261
+ return
262
+ settings.setValue("api/astrometry_key", (key or "").strip())
263
+ try:
264
+ settings.sync()
265
+ except Exception:
266
+ pass
267
+
268
+ def _wcs_header_from_astrometry_calib(calib: dict, image_shape: tuple[int, ...]) -> Header:
269
+ """
270
+ calib: dict with keys 'ra','dec','pixscale'(arcsec/px),'orientation'(deg, +CCW).
271
+ image_shape: (H, W) or (H, W, C). CRPIX is image center (1-based vs 0-based—astropy expects pixel coordinates in "fits" sense; mid-frame is fine).
272
+ """
273
+ H = int(image_shape[0]); W = int(image_shape[1])
274
+ h = Header()
275
+ h["CTYPE1"] = "RA---TAN"
276
+ h["CTYPE2"] = "DEC--TAN"
277
+ h["CRPIX1"] = W / 2.0
278
+ h["CRPIX2"] = H / 2.0
279
+ h["CRVAL1"] = float(calib["ra"])
280
+ h["CRVAL2"] = float(calib["dec"])
281
+ scale_deg = float(calib["pixscale"]) / 3600.0 # deg/px
282
+ theta = math.radians(float(calib.get("orientation", 0.0)))
283
+ # note: same sign convention as your SASv2 builder
284
+ h["CD1_1"] = -scale_deg * math.cos(theta)
285
+ h["CD1_2"] = scale_deg * math.sin(theta)
286
+ h["CD2_1"] = -scale_deg * math.sin(theta)
287
+ h["CD2_2"] = -scale_deg * math.cos(theta)
288
+ h["RADECSYS"] = "ICRS"
289
+ h["WCSAXES"] = 2
290
+ return h
291
+
292
+ # If you already ship 'requests', this is simplest:
293
+
294
+ # ---- Seed controls (persisted in QSettings) ----
295
+ def _get_seed_mode(settings) -> str:
296
+ # "auto" (from header), "manual" (use user values), "none" (blind)
297
+ return (settings.value("astap/seed_mode", "auto", type=str) or "auto").lower()
298
+
299
+ def _set_seed_mode(settings, mode: str):
300
+ settings.setValue("astap/seed_mode", (mode or "auto").lower())
301
+
302
+ def _get_manual_ra(settings) -> str:
303
+ # store raw string so user can type hh:mm:ss or degrees
304
+ return settings.value("astap/manual_ra", "", type=str) or ""
305
+
306
+ def _get_manual_dec(settings) -> str:
307
+ return settings.value("astap/manual_dec", "", type=str) or ""
308
+
309
+ def _get_manual_scale(settings) -> float | None:
310
+ try:
311
+ v = settings.value("astap/manual_scale_arcsec", "", type=str)
312
+ return float(v) if v not in (None, "",) else None
313
+ except Exception:
314
+ return None
315
+
316
+ @lru_cache(maxsize=256)
317
+ def _parse_ra_input_to_deg(s: str) -> float | None:
318
+ """Parse RA input string to degrees. Cached for repeated lookups."""
319
+ s = (s or "").strip()
320
+ if not s: return None
321
+ # allow plain degrees if > 24 or contains "deg"
322
+ try:
323
+ if re.search(r"[a-zA-Z]", s) is None and ":" not in s and " " not in s:
324
+ x = float(s)
325
+ return x if x > 24.0 else x * 15.0
326
+ except Exception:
327
+ pass
328
+ parts = re.split(r"[:\s]+", s)
329
+ try:
330
+ if len(parts) >= 3:
331
+ hh, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
332
+ elif len(parts) == 2:
333
+ hh, mm, ss = float(parts[0]), float(parts[1]), 0.0
334
+ else:
335
+ hh, mm, ss = float(parts[0]), 0.0, 0.0
336
+ return (abs(hh) + mm/60.0 + ss/3600.0) * 15.0
337
+ except Exception:
338
+ return None
339
+
340
+ @lru_cache(maxsize=256)
341
+ def _parse_dec_input_to_deg(s: str) -> float | None:
342
+ """Parse DEC input string to degrees. Cached for repeated lookups."""
343
+ s = (s or "").strip()
344
+ if not s: return None
345
+ sign = -1.0 if s.startswith("-") else 1.0
346
+ s = s.lstrip("+-")
347
+ parts = re.split(r"[:\s]+", s)
348
+ try:
349
+ if len(parts) >= 3:
350
+ dd, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
351
+ elif len(parts) == 2:
352
+ dd, mm, ss = float(parts[0]), float(parts[1]), 0.0
353
+ else:
354
+ return sign * float(parts[0])
355
+ return sign * (abs(dd) + mm/60.0 + ss/3600.0)
356
+ except Exception:
357
+ return None
358
+
359
+ def _set_manual_seed(settings, ra: str, dec: str, scale_arcsec: float | None):
360
+ settings.setValue("astap/manual_ra", ra or "")
361
+ settings.setValue("astap/manual_dec", dec or "")
362
+ if scale_arcsec is None:
363
+ settings.setValue("astap/manual_scale_arcsec", "")
364
+ else:
365
+ settings.setValue("astap/manual_scale_arcsec", str(float(scale_arcsec)))
366
+
367
+ def _astrometry_api_request(method: str, url: str, *, data=None, files=None,
368
+ timeout=(10, 60),
369
+ max_retries: int = 5,
370
+ parent=None,
371
+ stage: str = "") -> dict | None:
372
+ """
373
+ Robust request with retries, exponential backoff + jitter.
374
+ """
375
+ if requests is None:
376
+ print("Requests not available for astrometry.net API.")
377
+ return None
378
+
379
+ import random
380
+ import requests as _rq
381
+ for attempt in range(1, max_retries + 1):
382
+ try:
383
+ if method.upper() == "POST":
384
+ # ✅ IMPORTANT: rewind any file handles before each attempt,
385
+ # because requests consumes them.
386
+ if files:
387
+ try:
388
+ for v in files.values():
389
+ if hasattr(v, "seek"):
390
+ v.seek(0)
391
+ except Exception:
392
+ pass
393
+
394
+ r = requests.post(url, data=data, files=files, timeout=timeout)
395
+ else:
396
+ r = requests.get(url, timeout=timeout)
397
+
398
+ if r.status_code == 200:
399
+ try:
400
+ return r.json()
401
+ except Exception:
402
+ return None
403
+
404
+ if r.status_code in (429, 500, 502, 503, 504):
405
+ raise _rq.RequestException(f"HTTP {r.status_code}")
406
+ else:
407
+ print(f"Astrometry API HTTP {r.status_code} (no retry).")
408
+ return None
409
+
410
+ except (_rq.Timeout, _rq.ConnectionError, _rq.RequestException) as e:
411
+ print(f"Astrometry API request error ({stage}): {e}")
412
+ if attempt >= max_retries:
413
+ break
414
+ delay = min(8.0, 0.5 * (2 ** (attempt - 1))) + random.random() * 0.2
415
+ msg = QCoreApplication.translate("PlateSolver", "Status: {0} retry {1}/{2}…").format(stage or 'request', attempt, max_retries)
416
+ _set_status_ui(parent, msg)
417
+ _sleep_ui(int(delay * 1000))
418
+ return None
419
+
420
+
421
+ # ---------------------------------------------------------------------
422
+ # Utilities (headers, parsing, normalization)
423
+ # ---------------------------------------------------------------------
424
+
425
+ def _parse_astap_wcs_file(wcs_path: str) -> Dict[str, Any]:
426
+ """
427
+ Robustly load the .wcs file using astropy (instead of line parsing).
428
+ Returns a dictionary of key → value.
429
+ """
430
+ if not wcs_path or not os.path.exists(wcs_path):
431
+ return {}
432
+
433
+ try:
434
+ header = fits.getheader(wcs_path)
435
+ return dict(header)
436
+ except Exception as e:
437
+ print(f"[ASTAP] Failed to parse .wcs with astropy: {e}")
438
+ return {}
439
+
440
+
441
+ def _get_astap_exe(settings) -> str:
442
+ # Support both SASpro and SASv2 keys.
443
+ cand = [
444
+ settings.value("paths/astap", "", type=str) or "",
445
+ settings.value("astap/exe_path", "", type=str) or "",
446
+ ]
447
+ for p in cand:
448
+ if p and os.path.exists(p):
449
+ return p
450
+ return cand[0] # return first even if missing so we can error nicely
451
+
452
+
453
+ def _as_header(hdr_like: Any) -> Header | None:
454
+ """
455
+ Try to coerce whatever we have in metadata to a proper astropy Header.
456
+ Accepts: fits.Header, dict, flattened string blobs (best effort).
457
+ """
458
+ if hdr_like is None:
459
+ return None
460
+ if isinstance(hdr_like, Header):
461
+ return hdr_like
462
+
463
+ # 1) flattened single string? try hard to parse
464
+ if isinstance(hdr_like, str):
465
+ h = _parse_header_blob_to_header(hdr_like)
466
+ return h if len(h.keys()) else None
467
+
468
+ # 2) dict-ish
469
+ try:
470
+ d = dict(hdr_like)
471
+ h = Header()
472
+ int_keys = {"A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER", "WCSAXES", "NAXIS", "NAXIS1", "NAXIS2", "NAXIS3"}
473
+ for k, v in d.items():
474
+ K = str(k).upper()
475
+
476
+ # 🚫 Never promote our internal metadata keys to FITS cards
477
+ if K in _NONFITS_META_KEYS:
478
+ continue
479
+
480
+ try:
481
+ if K in int_keys:
482
+ h[K] = int(float(str(v).strip().split()[0]))
483
+ elif re.match(r"^(?:A|B|AP|BP)_\d+_\d+$", K) or \
484
+ re.match(r"^(?:CRPIX|CRVAL|CDELT|CD|PC|CROTA|LATPOLE|LONPOLE|EQUINOX)\d?_?\d*$", K):
485
+ h[K] = float(str(v).strip().split()[0])
486
+ elif K.startswith("CTYPE") or K.startswith("CUNIT") or K in {"RADECSYS"}:
487
+ h[K] = str(v).strip().strip("'\"")
488
+ else:
489
+ h[K] = v
490
+ except Exception:
491
+ pass
492
+
493
+ # SIP order parity
494
+ if "A_ORDER" in h and "B_ORDER" not in h:
495
+ h["B_ORDER"] = int(h["A_ORDER"])
496
+ if "B_ORDER" in h and "A_ORDER" not in h:
497
+ h["A_ORDER"] = int(h["B_ORDER"])
498
+ return h
499
+ except Exception:
500
+ return None
501
+
502
+
503
+ def _parse_header_blob_to_header(blob: str) -> Header:
504
+ """
505
+ Turn a flattened header blob into a real fits.Header.
506
+ Handles 80-char concatenated cards or KEY=VAL regex fallback.
507
+ """
508
+ s = (blob or "").strip()
509
+ h = fits.Header()
510
+
511
+ # A) 80-char card chunking (if truly concatenated FITS cards)
512
+ if len(s) >= 80 and len(s) % 80 == 0:
513
+ cards = [s[i:i+80] for i in range(0, len(s), 80)]
514
+ for line in cards:
515
+ try:
516
+ card = fits.Card.fromstring(line)
517
+ if card.keyword not in ("COMMENT", "HISTORY", "END", ""):
518
+ h.append(card)
519
+ except Exception:
520
+ pass
521
+ if len(h.keys()):
522
+ return h
523
+
524
+ # B) Fallback regex KEY = value … next KEY
525
+ pattern = r"([A-Z0-9_]+)\s*=\s*([^=]*?)(?=\s{2,}[A-Z0-9_]+\s*=|$)"
526
+ for m in re.finditer(pattern, s):
527
+ key = m.group(1).strip().upper()
528
+ vraw = m.group(2).strip()
529
+ if vraw.startswith("'") and vraw.endswith("'"):
530
+ val = vraw[1:-1].strip()
531
+ else:
532
+ try:
533
+ if re.fullmatch(r"[+-]?\d+", vraw): val = int(vraw)
534
+ else: val = float(vraw)
535
+ except Exception:
536
+ val = vraw
537
+ try: h[key] = val
538
+ except Exception as e:
539
+ import logging
540
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
541
+
542
+ if "A_ORDER" in h and "B_ORDER" not in h:
543
+ h["B_ORDER"] = int(h["A_ORDER"])
544
+ if "B_ORDER" in h and "A_ORDER" not in h:
545
+ h["A_ORDER"] = int(h["B_ORDER"])
546
+
547
+ return h
548
+
549
+
550
+ def _strip_wcs_keys(h: Header) -> Header:
551
+ """Return a copy without WCS/SIP keys (so ASTAP can write fresh)."""
552
+ h = h.copy()
553
+ for key in list(h.keys()):
554
+ ku = key.upper()
555
+ for prefix in (
556
+ "CRPIX", "CRVAL", "CDELT", "CROTA",
557
+ "CD1_", "CD2_", "PC", "CTYPE", "CUNIT",
558
+ "WCSAXES", "LATPOLE", "LONPOLE", "EQUINOX",
559
+ "PV1_", "PV2_", "SIP",
560
+ "A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER",
561
+ "A_", "B_", "AP_", "BP_", "PLTSOLVD"
562
+ ):
563
+ if ku.startswith(prefix):
564
+ h.pop(key, None)
565
+ break
566
+ return h
567
+
568
+ def _minimal_header_for_gray2d(h: int, w: int) -> Header:
569
+ hdu = Header()
570
+ hdu["SIMPLE"] = True
571
+ hdu["BITPIX"] = -32
572
+ hdu["NAXIS"] = 2
573
+ hdu["NAXIS1"] = int(w)
574
+ hdu["NAXIS2"] = int(h)
575
+ hdu["BZERO"] = 0.0
576
+ hdu["BSCALE"] = 1.0
577
+ hdu.add_comment("Temp FITS written for ASTAP solve.")
578
+ return hdu
579
+
580
+ def _minimal_header_for(img: np.ndarray, is_mono: bool) -> Header:
581
+ H = int(img.shape[0]) if img.ndim >= 2 else 1
582
+ W = int(img.shape[1]) if img.ndim >= 2 else 1
583
+ C = int(img.shape[2]) if (img.ndim == 3) else 1
584
+ h = Header()
585
+ h["SIMPLE"] = True
586
+ h["BITPIX"] = -32
587
+ h["NAXIS"] = 2 if is_mono else 3
588
+ h["NAXIS1"] = W
589
+ h["NAXIS2"] = H
590
+ if not is_mono:
591
+ h["NAXIS3"] = C
592
+ h["BZERO"] = 0.0
593
+ h["BSCALE"] = 1.0
594
+ h.add_comment("Temp FITS written for ASTAP solve.")
595
+ return h
596
+
597
+ def _write_temp_fit_web_16bit(gray2d_unit: np.ndarray) -> str:
598
+ """
599
+ Write full-res mono FITS as 16-bit unsigned for web upload.
600
+ gray2d_unit must be float32 in [0,1].
601
+ Returns path to temp .fits.
602
+ """
603
+ import os
604
+ import tempfile
605
+ import numpy as np
606
+ from astropy.io import fits
607
+ from astropy.io.fits import Header
608
+
609
+ if gray2d_unit.ndim != 2:
610
+ raise ValueError("Expected 2-D grayscale array for web FITS.")
611
+
612
+ g = np.clip(gray2d_unit.astype(np.float32), 0.0, 1.0)
613
+ u16 = (g * 65535.0 + 0.5).astype(np.uint16)
614
+
615
+ H, W = u16.shape
616
+ hdr = Header()
617
+ hdr["SIMPLE"] = True
618
+ hdr["BITPIX"] = 16
619
+ hdr["NAXIS"] = 2
620
+ hdr["NAXIS1"] = int(W)
621
+ hdr["NAXIS2"] = int(H)
622
+ hdr.add_comment("Temp FITS (16-bit) written for Astrometry.net upload.")
623
+
624
+ tmp = tempfile.NamedTemporaryFile(suffix=".fits", delete=False)
625
+ tmp_path = tmp.name
626
+ tmp.close()
627
+
628
+ fits.PrimaryHDU(u16, header=hdr).writeto(tmp_path, overwrite=True, output_verify="silentfix")
629
+
630
+ try:
631
+ print(f"[tempfits-web] Saved 16-bit FITS to: {tmp_path} (size={os.path.getsize(tmp_path)} bytes)")
632
+ except Exception:
633
+ pass
634
+
635
+ return tmp_path
636
+
637
+
638
+ def _astrometry_download_wcs_file(settings, job_id: int, parent=None) -> Header | None:
639
+ """
640
+ Download the solved WCS FITS from astrometry.net.
641
+ This includes SIP terms when present.
642
+ Returns fits.Header or None.
643
+ """
644
+ import os
645
+ import tempfile
646
+ from astropy.io import fits
647
+ from astropy.io.fits import Header
648
+
649
+ base_site = _get_astrometry_api_url(settings).split("/api/")[0].rstrip("/") + "/"
650
+ url = base_site + f"wcs_file/{int(job_id)}"
651
+
652
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Downloading WCS file (with SIP) from Astrometry.net…"))
653
+ try:
654
+ r = requests.get(url, timeout=(10, 60))
655
+ if r.status_code != 200 or len(r.content) < 2000:
656
+ print(f"[Astrometry] WCS download failed HTTP {r.status_code}, bytes={len(r.content)}")
657
+ return None
658
+
659
+ tmp = tempfile.NamedTemporaryFile(suffix=".wcs.fits", delete=False)
660
+ tmp_path = tmp.name
661
+ tmp.write(r.content)
662
+ tmp.close()
663
+
664
+ try:
665
+ hdr = fits.getheader(tmp_path)
666
+ h2 = Header()
667
+ for k, v in dict(hdr).items():
668
+ if k not in ("COMMENT", "HISTORY", "END"):
669
+ h2[k] = v
670
+ return h2
671
+ finally:
672
+ try: os.remove(tmp_path)
673
+ except Exception as e:
674
+ import logging
675
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
676
+
677
+ except Exception as e:
678
+ print("[Astrometry] WCS download exception:", e)
679
+ return None
680
+
681
+
682
+ def _float01(arr: np.ndarray) -> np.ndarray:
683
+ a = np.asarray(arr)
684
+ if a.dtype.kind in "ui":
685
+ info = np.iinfo(a.dtype)
686
+ if info.max == 0: return a.astype(np.float32)
687
+ return (a.astype(np.float32) / float(info.max))
688
+ return np.clip(a.astype(np.float32), 0.0, 1.0)
689
+
690
+
691
+ def _normalize_for_astap(img: np.ndarray) -> np.ndarray:
692
+ """
693
+ Use migrated stretch functions when available.
694
+ Returns float32 in [0,1], 2D for mono or 3D for color.
695
+ Guaranteed to return something usable even if stretch funcs fail.
696
+ """
697
+ f01 = _float01(img)
698
+
699
+ # Mono
700
+ if f01.ndim == 2 or (f01.ndim == 3 and f01.shape[2] == 1):
701
+ if stretch_mono_image is not None:
702
+ try:
703
+ print("DEBUG stretching mono")
704
+ out = stretch_mono_image(f01, 0.1, False)
705
+ return np.clip(out.astype(np.float32), 0.0, 1.0)
706
+ except Exception as e:
707
+ print("DEBUG mono stretch failed, fallback:", e)
708
+ return np.clip(f01.astype(np.float32), 0.0, 1.0)
709
+
710
+ # Color
711
+ if stretch_color_image is not None:
712
+ try:
713
+ print("DEBUG stretching color")
714
+ out = stretch_color_image(f01, 0.1, False, False)
715
+ return np.clip(out.astype(np.float32), 0.0, 1.0)
716
+ except Exception as e:
717
+ print("DEBUG color stretch failed, fallback:", e)
718
+
719
+ return np.clip(f01.astype(np.float32), 0.0, 1.0)
720
+
721
+
722
+
723
+
724
+ def _first_float(v):
725
+ if v is None: return None
726
+ if isinstance(v, (int, float)): return float(v)
727
+ s = str(v)
728
+ m = re.search(r"[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?", s)
729
+ return float(m.group(0)) if m else None
730
+
731
+
732
+ def _first_int(v):
733
+ if v is None: return None
734
+ if isinstance(v, int): return v
735
+ if isinstance(v, float): return int(round(v))
736
+ s = str(v)
737
+ m = re.search(r"[+-]?\d+", s)
738
+ return int(m.group(0)) if m else None
739
+
740
+
741
+ def _parse_ra_deg(h: Header) -> float | None:
742
+ ra = _first_float(h.get("CRVAL1"))
743
+ if ra is not None: return ra
744
+ ra = _first_float(h.get("RA"))
745
+ if ra is not None and 0.0 <= ra < 360.0: return ra
746
+ for key in ("OBJCTRA", "RA"):
747
+ s = h.get(key);
748
+ if not s: continue
749
+ s = str(s).strip()
750
+ parts = re.split(r"[:\s]+", s)
751
+ try:
752
+ if len(parts) >= 3:
753
+ hh, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
754
+ elif len(parts) == 2:
755
+ hh, mm, ss = float(parts[0]), float(parts[1]), 0.0
756
+ else:
757
+ x = float(parts[0]);
758
+ return x if x > 24 else x*15.0
759
+ return (abs(hh) + mm/60.0 + ss/3600.0) * 15.0
760
+ except Exception:
761
+ pass
762
+ return None
763
+
764
+
765
+ def _parse_dec_deg(h: Header) -> float | None:
766
+ dec = _first_float(h.get("CRVAL2"))
767
+ if dec is not None: return dec
768
+ dec = _first_float(h.get("DEC"))
769
+ if dec is not None and -90 <= dec <= 90: return dec
770
+ for key in ("OBJCTDEC","DEC"):
771
+ s = h.get(key);
772
+ if not s: continue
773
+ s = str(s).strip()
774
+ sign = -1.0 if s.startswith("-") else 1.0
775
+ s = s.lstrip("+-")
776
+ parts = re.split(r"[:\s]+", s)
777
+ try:
778
+ if len(parts) >= 3:
779
+ dd, mm, ss = float(parts[0]), float(parts[1]), float(parts[2])
780
+ elif len(parts) == 2:
781
+ dd, mm = float(parts[0]), float(parts[1]); ss = 0.0
782
+ else:
783
+ return sign * float(parts[0])
784
+ return sign * (abs(dd) + mm/60.0 + ss/3600.0)
785
+ except Exception:
786
+ pass
787
+ return None
788
+
789
+
790
+ def _compute_scale_arcsec_per_pix(h: Header) -> float | None:
791
+ """
792
+ Try to compute pixel scale from WCS / instrument metadata.
793
+ If the result is obviously insane, return None so we can fall back
794
+ to RA/Dec-only seeding.
795
+ """
796
+ def _sanity(val: float | None) -> float | None:
797
+ if val is None or not np.isfinite(val) or val <= 0:
798
+ return None
799
+ # Typical imaging: ~0.1"–100"/px. Allow up to ~1000"/px for very wide,
800
+ # but anything beyond that is almost certainly bogus.
801
+ if val > 1000.0:
802
+ return None
803
+ return float(val)
804
+
805
+ cd11 = _first_float(h.get("CD1_1"))
806
+ cd21 = _first_float(h.get("CD2_1"))
807
+ cdelt1 = _first_float(h.get("CDELT1"))
808
+ cdelt2 = _first_float(h.get("CDELT2"))
809
+
810
+ # 1) CD matrix
811
+ if cd11 is not None or cd21 is not None:
812
+ cd11 = cd11 or 0.0
813
+ cd21 = cd21 or 0.0
814
+ val = ((cd11**2 + cd21**2)**0.5) * 3600.0
815
+ val = _sanity(val)
816
+ if val is not None:
817
+ return val
818
+
819
+ # 2) CDELT
820
+ if cdelt1 is not None or cdelt2 is not None:
821
+ cdelt1 = cdelt1 or 0.0
822
+ cdelt2 = cdelt2 or 0.0
823
+ val = ((cdelt1**2 + cdelt2**2)**0.5) * 3600.0
824
+ val = _sanity(val)
825
+ if val is not None:
826
+ return val
827
+
828
+ # 3) Pixel size + focal length
829
+ px_um_x = _first_float(h.get("XPIXSZ"))
830
+ px_um_y = _first_float(h.get("YPIXSZ"))
831
+ focal_mm = _first_float(h.get("FOCALLEN"))
832
+ if focal_mm and (px_um_x or px_um_y):
833
+ px_um = px_um_x if (px_um_x and not px_um_y) else px_um_y if (px_um_y and not px_um_x) else None
834
+ if px_um is None:
835
+ px_um = (px_um_x + px_um_y) / 2.0
836
+ bx = _first_int(h.get("XBINNING")) or _first_int(h.get("XBIN")) or 1
837
+ by = _first_int(h.get("YBINNING")) or _first_int(h.get("YBIN")) or 1
838
+ bin_factor = (bx + by) / 2.0
839
+ px_um_eff = px_um * bin_factor
840
+ val = 206.264806 * px_um_eff / float(focal_mm)
841
+ val = _sanity(val)
842
+ if val is not None:
843
+ return val
844
+
845
+ return None
846
+
847
+
848
+ def _build_astap_seed_with_overrides(settings, header: Header | None, image: np.ndarray) -> tuple[list[str], str, float | None]:
849
+ """
850
+ Decide seed based on seed_mode:
851
+ - auto: derive from header (existing logic)
852
+ - manual: use user-provided RA/Dec/Scale
853
+ - none: return [], "blind"
854
+ Returns: (args, dbg, scale_arcsec)
855
+ """
856
+ mode = _get_seed_mode(settings)
857
+
858
+ if mode == "none":
859
+ return [], "seed disabled (blind)", None
860
+
861
+ if mode == "manual":
862
+ ra_s = _get_manual_ra(settings)
863
+ dec_s = _get_manual_dec(settings)
864
+ scl = _get_manual_scale(settings)
865
+ ra_deg = _parse_ra_input_to_deg(ra_s)
866
+ dec_deg = _parse_dec_input_to_deg(dec_s)
867
+ dbg = []
868
+ if ra_deg is None: dbg.append("RA?")
869
+ if dec_deg is None: dbg.append("Dec?")
870
+ if scl is None or not np.isfinite(scl) or scl <= 0: dbg.append("scale?")
871
+ if dbg:
872
+ return [], "manual seed invalid: " + ", ".join(dbg), None
873
+ ra_h = ra_deg / 15.0
874
+ spd = dec_deg + 90.0
875
+ args = ["-ra", f"{ra_h:.6f}", "-spd", f"{spd:.6f}", "-scale", f"{scl:.3f}"]
876
+ return args, f"manual RA={ra_h:.6f}h | SPD={spd:.6f}° | scale={scl:.3f}\"/px", float(scl)
877
+
878
+ # auto (default): from header
879
+ if isinstance(header, Header):
880
+ args, dbg = _build_astap_seed(header)
881
+ scl = None
882
+ if args:
883
+ try:
884
+ if "-scale" in args:
885
+ scl = float(args[args.index("-scale")+1])
886
+ except Exception:
887
+ scl = None
888
+ return args, "auto: " + dbg, scl
889
+
890
+ return [], "no header available for auto seed", None
891
+
892
+
893
+ def _build_astap_seed(h: Header) -> Tuple[list[str], str]:
894
+ """
895
+ Build ASTAP seed args from a header.
896
+ RA/Dec are REQUIRED. Scale is OPTIONAL and sanity-checked.
897
+ """
898
+ dbg = []
899
+ ra_deg = _parse_ra_deg(h)
900
+ dec_deg = _parse_dec_deg(h)
901
+
902
+ if ra_deg is None:
903
+ dbg.append("RA unknown")
904
+ if dec_deg is None:
905
+ dbg.append("Dec unknown")
906
+
907
+ # If we don't have RA/Dec, there's nothing useful to seed.
908
+ if ra_deg is None or dec_deg is None:
909
+ return [], " / ".join(dbg) if dbg else "RA/Dec unknown"
910
+
911
+ # Scale is now optional
912
+ scale = _estimate_scale_arcsec_from_header(h)
913
+ if scale is None:
914
+ dbg.append("scale unknown")
915
+
916
+ ra_h = ra_deg / 15.0
917
+ spd = dec_deg + 90.0
918
+
919
+ args = ["-ra", f"{ra_h:.6f}", "-spd", f"{spd:.6f}"]
920
+ if scale is not None:
921
+ args += ["-scale", f"{scale:.3f}"]
922
+
923
+ dbg_str = f"RA={ra_h:.6f} h | SPD={spd:.6f}°"
924
+ if scale is not None:
925
+ dbg_str += f" | scale={scale:.3f}\"/px"
926
+ else:
927
+ dbg_str += " | scale unknown"
928
+
929
+ return args, dbg_str
930
+
931
+
932
+
933
+ def _astrometry_login(settings, parent=None) -> str | None:
934
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Logging in to Astrometry.net…"))
935
+ api_key = _get_astrometry_api_key(settings)
936
+ if not api_key:
937
+ from PyQt6.QtWidgets import QInputDialog
938
+ key, ok = QInputDialog.getText(None, QCoreApplication.translate("PlateSolver", "Astrometry.net API Key"), QCoreApplication.translate("PlateSolver", "Enter your Astrometry.net API key:"))
939
+ if not ok or not key:
940
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Login canceled (no API key)."))
941
+ return None
942
+ _set_astrometry_api_key(settings, key)
943
+ api_key = key
944
+
945
+ base = _get_astrometry_api_url(settings)
946
+ resp = _astrometry_api_request(
947
+ "POST", base + "login",
948
+ data={'request-json': json.dumps({"apikey": api_key})},
949
+ parent=parent, stage="login"
950
+ )
951
+ if resp and resp.get("status") == "success":
952
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Login successful."))
953
+ return resp.get("session")
954
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Login failed."))
955
+ return None
956
+
957
+ def _astrometry_upload(settings, session: str, image_path: str, parent=None) -> int | None:
958
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Uploading image to Astrometry.net…"))
959
+ base = _get_astrometry_api_url(settings)
960
+
961
+ try:
962
+ sz = os.path.getsize(image_path)
963
+ if sz < 1024: # fits headers alone are ~2880 bytes
964
+ print(f"[Astrometry] temp FITS too small ({sz} bytes): {image_path}")
965
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Upload failed (temp FITS empty)."))
966
+ return None
967
+ except Exception:
968
+ pass
969
+
970
+ try:
971
+ with open(image_path, "rb") as f:
972
+ files = {"file": f}
973
+ data = {'request-json': json.dumps({
974
+ "publicly_visible": "y",
975
+ "allow_modifications": "d",
976
+ "session": session,
977
+ "allow_commercial_use": "d"
978
+ })}
979
+ resp = _astrometry_api_request(
980
+ "POST", base + "upload",
981
+ data=data, files=files,
982
+ timeout=(15, 180),
983
+ parent=parent, stage="upload"
984
+ )
985
+ if resp and resp.get("status") == "success":
986
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Upload complete."))
987
+ return int(resp["subid"])
988
+ except Exception as e:
989
+ print("Upload error:", e)
990
+
991
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Upload failed."))
992
+ return None
993
+
994
+
995
+
996
+ def _solve_with_local_solvefield(parent, settings, tmp_fit_path: str) -> tuple[bool, Header | str]:
997
+ solvefield = _get_solvefield_exe(settings)
998
+ if not solvefield or not os.path.exists(solvefield):
999
+ return False, QCoreApplication.translate("PlateSolver", "solve-field not configured.")
1000
+
1001
+ args = [
1002
+ "--overwrite",
1003
+ "--no-remove-lines",
1004
+ "--cpulimit", "300",
1005
+ "--downsample", "2",
1006
+ "--write-wcs", "wcs",
1007
+ tmp_fit_path
1008
+ ]
1009
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Running local solve-field…"))
1010
+ print("Running solve-field:", solvefield, " ".join(args))
1011
+ p = QProcess(parent)
1012
+ p.start(solvefield, args)
1013
+ if not p.waitForStarted(5000):
1014
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: solve-field failed to start."))
1015
+ return False, f"Failed to start solve-field: {p.errorString()}"
1016
+
1017
+ if not _wait_process(p, 300000, parent=parent):
1018
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: solve-field timed out."))
1019
+ return False, "solve-field timed out."
1020
+
1021
+ if p.exitCode() != 0:
1022
+ out = bytes(p.readAllStandardOutput()).decode(errors="ignore")
1023
+ err = bytes(p.readAllStandardError()).decode(errors="ignore")
1024
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: solve-field failed."))
1025
+ print("solve-field failed.\nSTDOUT:\n", out, "\nSTDERR:\n", err)
1026
+ return False, "solve-field returned non-zero exit."
1027
+
1028
+ wcs_path = os.path.splitext(tmp_fit_path)[0] + ".wcs"
1029
+ new_path = os.path.splitext(tmp_fit_path)[0] + ".new"
1030
+
1031
+ if os.path.exists(wcs_path):
1032
+ d = _parse_astap_wcs_file(wcs_path)
1033
+ if d:
1034
+ d = _ensure_ctypes(_coerce_wcs_numbers(d))
1035
+ return True, Header({k: v for k, v in d.items()})
1036
+
1037
+ if os.path.exists(new_path):
1038
+ try:
1039
+ with fits.open(new_path, memmap=False) as hdul:
1040
+ h = Header()
1041
+ for k, v in dict(hdul[0].header).items():
1042
+ if k not in ("COMMENT","HISTORY","END"):
1043
+ h[k] = v
1044
+ return True, h
1045
+ except Exception as e:
1046
+ print("Failed reading .new FITS:", e)
1047
+
1048
+ return False, "solve-field produced no WCS."
1049
+
1050
+
1051
+ def _astrometry_poll_job(settings, subid: int, *, max_wait_s=900, parent=None) -> int | None:
1052
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Waiting for job assignment…"))
1053
+ base = _get_astrometry_api_url(settings)
1054
+ t0 = time.time()
1055
+ while time.time() - t0 < max_wait_s:
1056
+ resp = _astrometry_api_request("GET", base + f"submissions/{subid}",
1057
+ parent=parent, stage="poll job")
1058
+ if resp:
1059
+ jobs = resp.get("jobs", [])
1060
+ if jobs and jobs[0] is not None:
1061
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Job assigned (ID {0}).").format(jobs[0]))
1062
+ try: return int(jobs[0])
1063
+ except Exception as e:
1064
+ import logging
1065
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1066
+ _sleep_ui(1000)
1067
+ return None
1068
+
1069
+ def _astrometry_poll_calib(settings, job_id: int, *, max_wait_s=900, parent=None) -> dict | None:
1070
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Waiting for solution…"))
1071
+ base = _get_astrometry_api_url(settings)
1072
+ t0 = time.time()
1073
+ while time.time() - t0 < max_wait_s:
1074
+ resp = _astrometry_api_request("GET", base + f"jobs/{job_id}/calibration/",
1075
+ parent=parent, stage="poll calib")
1076
+ if resp and all(k in resp for k in ("ra","dec","pixscale")):
1077
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solution received."))
1078
+ return resp
1079
+ _sleep_ui(1500)
1080
+ return None
1081
+
1082
+ # ---- ASTAP seed controls ----
1083
+ # modes for radius: "auto" -> -r 0, "value" -> -r <user>, default "auto"
1084
+ def _get_astap_radius_mode(settings) -> str:
1085
+ return (settings.value("astap/seed_radius_mode", "auto", type=str) or "auto").lower()
1086
+
1087
+ def _get_astap_radius_value(settings) -> float:
1088
+ try:
1089
+ return float(settings.value("astap/seed_radius_value", 5.0, type=float))
1090
+ except Exception:
1091
+ return 5.0
1092
+
1093
+ # modes for fov: "auto" -> -fov 0, "compute" -> use computed FOV, "value" -> user number; default "compute"
1094
+ def _get_astap_fov_mode(settings) -> str:
1095
+ return (settings.value("astap/seed_fov_mode", "compute", type=str) or "compute").lower()
1096
+
1097
+ def _get_astap_fov_value(settings) -> float:
1098
+ try:
1099
+ return float(settings.value("astap/seed_fov_value", 0.0, type=float))
1100
+ except Exception:
1101
+ return 0.0
1102
+
1103
+
1104
+ def _read_header_from_fits(path: str) -> Dict[str, Any]:
1105
+ with fits.open(path, memmap=False) as hdul:
1106
+ d = dict(hdul[0].header)
1107
+ d.pop("COMMENT", None); d.pop("HISTORY", None); d.pop("END", None)
1108
+ return d
1109
+
1110
+
1111
+ def _header_from_text_block(s: str) -> Header:
1112
+ """Parse ASTAP .wcs or flattened blocks into a proper Header."""
1113
+ h = Header()
1114
+ if not s: return h
1115
+ lines = s.splitlines()
1116
+ if len(lines) <= 1:
1117
+ # single blob: split on KEY=
1118
+ lines = re.split(r"(?=(?:^|\s{2,})([A-Za-z][A-Za-z0-9_]+)\s*=)", s)
1119
+ lines = ["".join(lines[i:i+2]).strip() for i in range(1, len(lines), 2)]
1120
+ card_re = re.compile(r"^\s*([A-Za-z][A-Za-z0-9_]+)\s*=\s*(.*)$")
1121
+ num_re = re.compile(r"^[+-]?\d*\.?\d+(?:[eE][+-]?\d+)?$")
1122
+ for raw in lines:
1123
+ raw = raw.strip()
1124
+ if not raw or raw.upper().startswith(("COMMENT","HISTORY","END")):
1125
+ continue
1126
+ m = card_re.match(raw)
1127
+ if not m: continue
1128
+ key, rest = m.group(1).upper(), m.group(2).strip()
1129
+ if " /" in rest:
1130
+ val_str = rest.split(" /", 1)[0].strip()
1131
+ else:
1132
+ val_str = rest
1133
+ if (len(val_str) >= 2) and ((val_str[0] == "'" and val_str[-1] == "'") or (val_str[0] == '"' and val_str[-1] == '"')):
1134
+ val = val_str[1:-1].strip()
1135
+ else:
1136
+ try:
1137
+ if num_re.match(val_str):
1138
+ val = float(val_str)
1139
+ if re.match(r"^[+-]?\d+$", val_str): val = int(val)
1140
+ else:
1141
+ val = val_str
1142
+ except Exception:
1143
+ val = val_str
1144
+ try: h[key] = val
1145
+ except Exception as e:
1146
+ import logging
1147
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1148
+ if "A_ORDER" in h and "B_ORDER" not in h:
1149
+ h["B_ORDER"] = int(h["A_ORDER"])
1150
+ if "B_ORDER" in h and "A_ORDER" not in h:
1151
+ h["A_ORDER"] = int(h["B_ORDER"])
1152
+ return h
1153
+
1154
+ def _coerce_wcs_numbers(d: dict[str, Any]) -> dict[str, Any]:
1155
+ """
1156
+ Convert values for common WCS/SIP keys to int/float where appropriate.
1157
+ Mirrors SASv2 logic.
1158
+ """
1159
+ numeric_keys = {
1160
+ "CRPIX1", "CRPIX2", "CRVAL1", "CRVAL2", "CDELT1", "CDELT2",
1161
+ "CD1_1", "CD1_2", "CD2_1", "CD2_2", "CROTA1", "CROTA2",
1162
+ "EQUINOX", "WCSAXES", "A_ORDER", "B_ORDER", "AP_ORDER", "BP_ORDER",
1163
+ }
1164
+
1165
+ out = {}
1166
+ for k, v in d.items():
1167
+ key = k.upper()
1168
+ try:
1169
+ if key in numeric_keys or re.match(r"^(A|B|AP|BP)_\d+_\d+$", key):
1170
+ if isinstance(v, str):
1171
+ val = float(v.strip())
1172
+ if val.is_integer(): val = int(val)
1173
+ else:
1174
+ val = v
1175
+ out[key] = val
1176
+ else:
1177
+ out[key] = v
1178
+ except Exception:
1179
+ out[key] = v
1180
+ return out
1181
+
1182
+
1183
+ def _ensure_ctypes(d: dict[str, Any]) -> dict[str, Any]:
1184
+ """
1185
+ Ensure CTYPE1/2 exist and are proper strings. Fallback to TAN if missing.
1186
+ """
1187
+ if "CTYPE1" not in d:
1188
+ d["CTYPE1"] = "RA---TAN"
1189
+ if "CTYPE2" not in d:
1190
+ d["CTYPE2"] = "DEC--TAN"
1191
+ d["CTYPE1"] = str(d["CTYPE1"]).strip()
1192
+ d["CTYPE2"] = str(d["CTYPE2"]).strip()
1193
+ return d
1194
+
1195
+ def _merge_wcs_into_base_header(base_header: Header | None, wcs_header: Header | None) -> Header:
1196
+ """
1197
+ Merge a WCS/SIP solution into a base acquisition header.
1198
+
1199
+ - base_header: original FITS header with OBJECT, EXPTIME, GAIN, etc.
1200
+ - wcs_header: header containing CRPIX/CRVAL/CD/SIP/etc. from ASTAP or Astrometry.
1201
+
1202
+ Non-WCS cards in base_header are preserved.
1203
+ WCS/SIP/PLTSOLVD/etc. from wcs_header override any existing ones.
1204
+ """
1205
+ if not isinstance(base_header, Header):
1206
+ base_header = Header()
1207
+ # Always strip our internal meta keys from the acquisition header
1208
+ base_header = _strip_nonfits_meta_keys_from_header(base_header)
1209
+
1210
+ if not isinstance(wcs_header, Header):
1211
+ # nothing special to merge; just normalize the base and return it.
1212
+ d0 = _ensure_ctypes(_coerce_wcs_numbers(dict(base_header)))
1213
+ out = Header()
1214
+ for k, v in d0.items():
1215
+ try:
1216
+ out[k] = v
1217
+ except Exception:
1218
+ pass
1219
+ return out
1220
+
1221
+ # Start from a copy of the acquisition header (drop COMMENT/HISTORY from it)
1222
+ base = base_header.copy()
1223
+
1224
+
1225
+ # Start from a copy of the acquisition header (drop COMMENT/HISTORY from it)
1226
+ base = base_header.copy()
1227
+ for k in ("COMMENT", "HISTORY", "END"):
1228
+ if k in base:
1229
+ base.remove(k)
1230
+
1231
+ merged = dict(base)
1232
+
1233
+ # Only import *WCS-ish* keys from the solver, not things like BITPIX/NAXIS.
1234
+ wcs_prefixes = (
1235
+ "CRPIX", "CRVAL", "CDELT", "CD1_", "CD2_", "PC",
1236
+ "CTYPE", "CUNIT", "PV1_", "PV2_", "A_", "B_", "AP_", "BP_"
1237
+ )
1238
+ wcs_extras = {
1239
+ "WCSAXES", "LATPOLE", "LONPOLE", "EQUINOX",
1240
+ "PLTSOLVD", "WARNING", "RADESYS", "RADECSYS", "RADECSYS"
1241
+ }
1242
+
1243
+ for key, val in wcs_header.items():
1244
+ ku = key.upper()
1245
+ if ku.startswith(wcs_prefixes) or ku in wcs_extras:
1246
+ merged[ku] = val
1247
+
1248
+ # Coerce numeric types and ensure CTYPEs.
1249
+ merged = _ensure_ctypes(_coerce_wcs_numbers(merged))
1250
+
1251
+ # Ensure TAN-SIP if SIP terms exist.
1252
+ try:
1253
+ sip_present = any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in merged.keys())
1254
+ if sip_present:
1255
+ c1 = str(merged.get("CTYPE1", "RA---TAN"))
1256
+ c2 = str(merged.get("CTYPE2", "DEC--TAN"))
1257
+ if not c1.endswith("-SIP"):
1258
+ merged["CTYPE1"] = "RA---TAN-SIP"
1259
+ if not c2.endswith("-SIP"):
1260
+ merged["CTYPE2"] = "DEC--TAN-SIP"
1261
+ except Exception:
1262
+ pass
1263
+
1264
+ # CROTA from CD if missing.
1265
+ try:
1266
+ if ("CROTA1" not in merged or "CROTA2" not in merged) and \
1267
+ ("CD1_1" in merged and "CD1_2" in merged):
1268
+ rot = math.degrees(math.atan2(float(merged["CD1_2"]), float(merged["CD1_1"])))
1269
+ merged["CROTA1"] = rot
1270
+ merged["CROTA2"] = rot
1271
+ except Exception:
1272
+ pass
1273
+
1274
+ out = Header()
1275
+ for k, v in merged.items():
1276
+ try:
1277
+ out[k] = v
1278
+ except Exception:
1279
+ # Skip weird/invalid keys silently
1280
+ pass
1281
+ return out
1282
+
1283
+
1284
+ def _build_header_from_astap_outputs(
1285
+ tmp_fits: str,
1286
+ sidecar_wcs: Optional[str],
1287
+ base_header: Header | None
1288
+ ) -> Header:
1289
+ """
1290
+ Build final header as: base_header (acquisition) + WCS/SIP from .wcs.
1291
+ """
1292
+ _debug_dump_header("ASTAP: BASE_HEADER ARG INTO _build_header_from_astap_outputs", base_header)
1293
+ """
1294
+ Build final header as: base_header (acquisition) + WCS/SIP from .wcs.
1295
+
1296
+ tmp_fits is only used as a last-resort source if base_header is None.
1297
+ """
1298
+ # 1) Determine base header (acquisition)
1299
+ if isinstance(base_header, Header):
1300
+ base_hdr = base_header
1301
+ else:
1302
+ # Fallback: read whatever ASTAP wrote into the temp FITS.
1303
+ base_dict: Dict[str, Any] = {}
1304
+ try:
1305
+ with fits.open(tmp_fits, memmap=False) as hdul:
1306
+ base_dict = dict(hdul[0].header)
1307
+ for k in ("COMMENT", "HISTORY", "END"):
1308
+ base_dict.pop(k, None)
1309
+ except Exception as e:
1310
+ print("Failed reading temp FITS header:", e)
1311
+ base_hdr = Header()
1312
+ for k, v in base_dict.items():
1313
+ try:
1314
+ base_hdr[k] = v
1315
+ except Exception:
1316
+ pass
1317
+ _debug_dump_header("ASTAP: BASE_HDR (acquisition header after fallback)", base_hdr)
1318
+ # 2) Load WCS from sidecar
1319
+ wcs_hdr = Header()
1320
+ if sidecar_wcs and os.path.exists(sidecar_wcs):
1321
+ try:
1322
+ wcs_dict = _parse_astap_wcs_file(sidecar_wcs)
1323
+ for k, v in wcs_dict.items():
1324
+ if k not in ("COMMENT", "HISTORY", "END"):
1325
+ try:
1326
+ wcs_hdr[k] = v
1327
+ except Exception:
1328
+ pass
1329
+ except Exception as e:
1330
+ print("Error parsing .wcs file:", e)
1331
+ _debug_dump_header("ASTAP: WCS_HDR FROM SIDECAR .WCS", wcs_hdr)
1332
+ # 3) Merge WCS into base acquisition header (base wins for non-WCS keys)
1333
+ final_hdr = _merge_wcs_into_base_header(base_hdr, wcs_hdr)
1334
+
1335
+ _debug_dump_header("ASTAP: FINAL MERGED HEADER (base_hdr + wcs_hdr)", final_hdr)
1336
+
1337
+
1338
+ return final_hdr
1339
+
1340
+
1341
+ def _write_temp_fit_via_save_image(gray2d: np.ndarray, _header: Header | None) -> tuple[str, str]:
1342
+ """
1343
+ Write a 2-D mono float32 FITS using legacy.save_image(), return (fit_path, sidecar_wcs_path).
1344
+
1345
+ NOTE: We intentionally ignore the incoming header's axis cards and
1346
+ build a clean 2-axis header to avoid 'NAXISj out of range' errors.
1347
+ """
1348
+ # ensure 2-D float32 in [0,1]
1349
+ if gray2d.ndim != 2:
1350
+ raise ValueError("Expected a 2-D grayscale array for ASTAP temp FITS.")
1351
+ g = np.clip(gray2d.astype(np.float32), 0.0, 1.0)
1352
+
1353
+ H, W = int(g.shape[0]), int(g.shape[1])
1354
+
1355
+ # Build a *fresh* 2-axis header (no NAXIS3, no old WCS)
1356
+ clean_header = Header()
1357
+ clean_header["SIMPLE"] = True
1358
+ clean_header["BITPIX"] = -32
1359
+ clean_header["NAXIS"] = 2
1360
+ clean_header["NAXIS1"] = W
1361
+ clean_header["NAXIS2"] = H
1362
+ clean_header["BZERO"] = 0.0
1363
+ clean_header["BSCALE"] = 1.0
1364
+ clean_header.add_comment("Temp FITS written for ASTAP solve (mono 2-D).")
1365
+
1366
+ # Write using legacy.save_image (forces a valid 2-axis primary HDU)
1367
+ tmp = tempfile.NamedTemporaryFile(suffix=".fit", delete=False)
1368
+ tmp_path = tmp.name
1369
+ tmp.close()
1370
+
1371
+ save_image(
1372
+ img_array=g,
1373
+ filename=tmp_path,
1374
+ original_format="fit", # (our stack expects 'fit')
1375
+ bit_depth="32-bit floating point",
1376
+ original_header=clean_header,
1377
+ is_mono=True # <-- important: keep it 2-D/mono
1378
+ )
1379
+
1380
+ # Resolve the actual path in case save_image normalized the extension
1381
+ base, _ = os.path.splitext(tmp_path)
1382
+ candidates = [tmp_path, base + ".fit", base + ".fits", base + ".FIT", base + ".FITS"]
1383
+ fit_path = next((p for p in candidates if os.path.exists(p)), tmp_path)
1384
+
1385
+ print(f"Saved FITS image to: {fit_path}")
1386
+ return fit_path, os.path.splitext(fit_path)[0] + ".wcs"
1387
+
1388
+ def _solve_numpy_with_astrometry(
1389
+ parent,
1390
+ settings,
1391
+ image: np.ndarray,
1392
+ base_header: Header | None
1393
+ ) -> tuple[bool, Header | str]:
1394
+ """
1395
+ Try local solve-field first; if unavailable/failed, try astrometry.net web API.
1396
+
1397
+ WEB MODE:
1398
+ - keep ORIGINAL dimensions (no downsample)
1399
+ - stretch to non-linear for star detectability
1400
+ - quantize to 16-bit unsigned FITS to reduce upload size
1401
+ - prefer solved WCS file from astrometry.net (includes SIP)
1402
+ """
1403
+ import os
1404
+ import numpy as np
1405
+ from astropy.io.fits import Header
1406
+
1407
+ # Build full-res mono in [0,1], but NON-LINEAR (stretched) for detectability
1408
+ norm_full = _normalize_for_astap(image) # float32 [0,1], mono/color
1409
+ gray_full = _to_gray2d_unit(norm_full) # 2D float32 [0,1]
1410
+ Hfull, Wfull = int(gray_full.shape[0]), int(gray_full.shape[1])
1411
+
1412
+ # Always write a full-res temp for LOCAL solve-field (float32)
1413
+ tmp_fit_full, _unused_sidecar = _write_temp_fit_via_save_image(gray_full, None)
1414
+
1415
+ try:
1416
+ # 1) local solve-field path (full-res float FITS)
1417
+ ok, res = _solve_with_local_solvefield(parent, settings, tmp_fit_full)
1418
+ if ok:
1419
+ hdr = res if isinstance(res, Header) else None
1420
+ if hdr is not None:
1421
+ d = _ensure_ctypes(_coerce_wcs_numbers(dict(hdr)))
1422
+ if any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in d.keys()):
1423
+ if not str(d.get("CTYPE1","RA---TAN")).endswith("-SIP"):
1424
+ d["CTYPE1"] = "RA---TAN-SIP"
1425
+ if not str(d.get("CTYPE2","DEC--TAN")).endswith("-SIP"):
1426
+ d["CTYPE2"] = "DEC--TAN-SIP"
1427
+ hh = Header()
1428
+ for k, v in d.items():
1429
+ try: hh[k] = v
1430
+ except Exception as e:
1431
+ import logging
1432
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1433
+ return True, hh
1434
+ return False, QCoreApplication.translate("PlateSolver", "solve-field returned no header.")
1435
+
1436
+ # 2) web API fallback (full-res, 16-bit upload)
1437
+ if requests is None:
1438
+ return False, QCoreApplication.translate("PlateSolver", "requests not available for astrometry.net API.")
1439
+
1440
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Preparing full-res 16-bit FITS for web solve…"))
1441
+
1442
+ tmp_fit_web = _write_temp_fit_web_16bit(gray_full)
1443
+
1444
+ # Verify web temp file isn't empty
1445
+ try:
1446
+ sz = os.path.getsize(tmp_fit_web)
1447
+ if sz < 3000:
1448
+ return False, QCoreApplication.translate("PlateSolver", "Temp FITS for web upload is empty/tiny ({0} bytes).").format(sz)
1449
+ except Exception:
1450
+ pass
1451
+
1452
+ session = _astrometry_login(settings, parent=parent)
1453
+ if not session:
1454
+ return False, QCoreApplication.translate("PlateSolver", "Astrometry.net login failed.")
1455
+
1456
+ subid = _astrometry_upload(settings, session, tmp_fit_web, parent=parent)
1457
+ if not subid:
1458
+ return False, QCoreApplication.translate("PlateSolver", "Astrometry.net upload failed.")
1459
+
1460
+ job_id = _astrometry_poll_job(settings, subid, parent=parent)
1461
+ if not job_id:
1462
+ return False, QCoreApplication.translate("PlateSolver", "Astrometry.net job ID not received in time.")
1463
+
1464
+ # Prefer full WCS file (includes SIP)
1465
+ hdr_wcs = _astrometry_download_wcs_file(settings, job_id, parent=parent)
1466
+
1467
+ if hdr_wcs is None:
1468
+ # fallback to calibration (no SIP)
1469
+ calib = _astrometry_poll_calib(settings, job_id, parent=parent)
1470
+ if not calib:
1471
+ return False, QCoreApplication.translate("PlateSolver", "Astrometry.net calibration not received in time.")
1472
+
1473
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Building WCS header from calibration…"))
1474
+ hdr_wcs = _wcs_header_from_astrometry_calib(calib, (Hfull, Wfull))
1475
+
1476
+ # Coerce & ensure TAN-SIP if SIP terms exist
1477
+ d = _ensure_ctypes(_coerce_wcs_numbers(dict(hdr_wcs)))
1478
+ if any(re.match(r"^(A|B|AP|BP)_\d+_\d+$", k) for k in d.keys()):
1479
+ if not str(d.get("CTYPE1","RA---TAN")).endswith("-SIP"):
1480
+ d["CTYPE1"] = "RA---TAN-SIP"
1481
+ if not str(d.get("CTYPE2","DEC--TAN")).endswith("-SIP"):
1482
+ d["CTYPE2"] = "DEC--TAN-SIP"
1483
+
1484
+ # Build a WCS-only Header from d
1485
+ wcs_hdr = Header()
1486
+ for k, v in d.items():
1487
+ try:
1488
+ wcs_hdr[k] = v
1489
+ except Exception:
1490
+ pass
1491
+
1492
+ # Merge with acquisition header (base_header)
1493
+ merged = _merge_wcs_into_base_header(base_header, wcs_hdr)
1494
+
1495
+ # clean temp web file ...
1496
+ try:
1497
+ if os.path.exists(tmp_fit_web):
1498
+ os.remove(tmp_fit_web)
1499
+ except Exception:
1500
+ pass
1501
+
1502
+ return True, merged
1503
+
1504
+ finally:
1505
+ # clean temp + solve-field byproducts next to tmp_fit_full
1506
+ try:
1507
+ base = os.path.splitext(tmp_fit_full)[0]
1508
+ for ext in (".fit",".fits",".wcs",".axy",".corr",".rdls",".solved",".new",".match",".ngc",".png",".ppm",".xyls"):
1509
+ p = base + ext
1510
+ if os.path.exists(p):
1511
+ os.remove(p)
1512
+ except Exception:
1513
+ pass
1514
+
1515
+
1516
+ def _solve_numpy_with_fallback(parent, settings, image: np.ndarray, seed_header: Header | None) -> tuple[bool, Header | str]:
1517
+ # Try ASTAP first
1518
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solving with ASTAP…"))
1519
+ ok, res = _solve_numpy_with_astap(parent, settings, image, seed_header)
1520
+ if ok:
1521
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solved with ASTAP."))
1522
+ return True, res
1523
+
1524
+ # ASTAP failed → tell the user and fall back
1525
+ err_msg = str(res) if res is not None else "unknown error"
1526
+ print("ASTAP failed:", err_msg)
1527
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP failed ({0}). Falling back to Astrometry.net…").format(err_msg))
1528
+ QApplication.processEvents()
1529
+
1530
+ # Fallback: astrometry.net (local solve-field first, then web API inside)
1531
+ ok2, res2 = _solve_numpy_with_astrometry(parent, settings, image, seed_header)
1532
+ if ok2:
1533
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Solved via Astrometry.net."))
1534
+ else:
1535
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: Astrometry.net failed ({0}).").format(res2))
1536
+
1537
+ return ok2, res2
1538
+
1539
+
1540
+ def _save_temp_fits_via_save_image(norm_img: np.ndarray, clean_header: Header, is_mono: bool) -> str:
1541
+ """
1542
+ Legacy helper used elsewhere. Make sure header axes match the data we write.
1543
+ If is_mono=True we force a 2-D primary HDU; otherwise we allow 3-D (H,W,C).
1544
+ """
1545
+ hdr = Header()
1546
+ # sanitize header/axes
1547
+ if is_mono:
1548
+ # force 2-axis
1549
+ if norm_img.ndim != 2:
1550
+ raise ValueError("Expected 2-D array for mono temp FITS.")
1551
+ H, W = int(norm_img.shape[0]), int(norm_img.shape[1])
1552
+ hdr["SIMPLE"] = True
1553
+ hdr["BITPIX"] = -32
1554
+ hdr["NAXIS"] = 2
1555
+ hdr["NAXIS1"] = W
1556
+ hdr["NAXIS2"] = H
1557
+ else:
1558
+ # allow color (H, W, C)
1559
+ if norm_img.ndim != 3 or norm_img.shape[2] < 3:
1560
+ raise ValueError("Expected 3-D array (H,W,C) for color temp FITS.")
1561
+ H, W, C = int(norm_img.shape[0]), int(norm_img.shape[1]), int(norm_img.shape[2])
1562
+ hdr["SIMPLE"] = True
1563
+ hdr["BITPIX"] = -32
1564
+ hdr["NAXIS"] = 3
1565
+ hdr["NAXIS1"] = W
1566
+ hdr["NAXIS2"] = H
1567
+ hdr["NAXIS3"] = C
1568
+
1569
+ hdr["BZERO"] = 0.0
1570
+ hdr["BSCALE"] = 1.0
1571
+ hdr.add_comment("Temp FITS written for ASTAP solve.")
1572
+
1573
+ # write
1574
+ tmp = tempfile.NamedTemporaryFile(suffix=".fit", delete=False)
1575
+ tmp_path = tmp.name
1576
+ tmp.close()
1577
+
1578
+ save_image(
1579
+ img_array=np.clip(norm_img.astype(np.float32), 0.0, 1.0),
1580
+ filename=tmp_path,
1581
+ original_format="fit",
1582
+ bit_depth="32-bit floating point",
1583
+ original_header=hdr,
1584
+ is_mono=is_mono
1585
+ )
1586
+
1587
+ return tmp_path
1588
+
1589
+
1590
+
1591
+ def _active_doc_from_parent(parent) -> object | None:
1592
+ """Try your helpers to get the active document."""
1593
+ if hasattr(parent, "_active_doc"):
1594
+ try:
1595
+ return parent._active_doc()
1596
+ except Exception:
1597
+ pass
1598
+ sw = getattr(parent, "mdi", None)
1599
+ if sw and hasattr(sw, "activeSubWindow"):
1600
+ asw = sw.activeSubWindow()
1601
+ if asw:
1602
+ w = asw.widget()
1603
+ return getattr(w, "document", None)
1604
+ return None
1605
+
1606
+ def _to_gray(arr: np.ndarray) -> np.ndarray:
1607
+ """Always produce a 2-D grayscale float32 in [0,1]."""
1608
+ a = np.asarray(arr)
1609
+ # normalize to 0..1 first
1610
+ if a.dtype.kind in "ui":
1611
+ info = np.iinfo(a.dtype)
1612
+ a = a.astype(np.float32) / max(float(info.max), 1.0)
1613
+ else:
1614
+ a = np.clip(a.astype(np.float32), 0.0, 1.0)
1615
+
1616
+ if a.ndim == 2:
1617
+ return a
1618
+ if a.ndim == 3:
1619
+ if a.shape[2] >= 3:
1620
+ return (0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]).astype(np.float32)
1621
+ return a[...,0].astype(np.float32)
1622
+ # anything else, just flatten safely
1623
+ return a.reshape(a.shape[0], -1).astype(np.float32)
1624
+
1625
+ def _to_gray2d_unit(arr: np.ndarray) -> np.ndarray:
1626
+ """
1627
+ Return a 2-D float32 array in [0,1].
1628
+ """
1629
+ a = np.asarray(arr)
1630
+ if a.dtype.kind in "ui":
1631
+ info = np.iinfo(a.dtype)
1632
+ a = a.astype(np.float32) / max(float(info.max), 1.0)
1633
+ else:
1634
+ a = np.clip(a.astype(np.float32), 0.0, 1.0)
1635
+
1636
+ if a.ndim == 2:
1637
+ return a
1638
+ if a.ndim == 3:
1639
+ # perceptual luminance → 2-D
1640
+ if a.shape[2] >= 3:
1641
+ return (0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]).astype(np.float32)
1642
+ return a[...,0].astype(np.float32)
1643
+ # last resort: collapse to (H, W)
1644
+ return a.reshape(a.shape[0], -1).astype(np.float32)
1645
+
1646
+
1647
+ # ---------------------------------------------------------------------
1648
+ # Core ASTAP solving for a numpy image + seed header
1649
+ # ---------------------------------------------------------------------
1650
+
1651
+ def _solve_numpy_with_astap(parent, settings, image: np.ndarray, seed_header: Header | None) -> Tuple[bool, Header | str]:
1652
+ """
1653
+ Normalize → write temp mono FITS → run ASTAP → return the EXACT FITS header ASTAP wrote.
1654
+ """
1655
+ astap_exe = _get_astap_exe(settings)
1656
+ if not astap_exe or not os.path.exists(astap_exe):
1657
+ return False, QCoreApplication.translate("PlateSolver", "ASTAP path is not set (see Preferences) or file not found.")
1658
+
1659
+ # normalize and force 2-D luminance in [0,1]
1660
+ norm = _normalize_for_astap(image)
1661
+ #gray = _to_gray2d_unit(image)
1662
+ gray = _to_gray2d_unit(norm)
1663
+
1664
+ # build a clean temp header (strip old WCS but KEEP acquisition keys)
1665
+ if isinstance(seed_header, Header):
1666
+ clean_for_temp = _strip_wcs_keys(seed_header)
1667
+ base_for_merge = clean_for_temp # acquisition info lives here
1668
+ _debug_dump_header("ASTAP: CLEAN_FOR_TEMP (seed_header with WCS stripped)", clean_for_temp)
1669
+ _debug_dump_header("ASTAP: BASE_FOR_MERGE (acquisition header we expect to preserve)", base_for_merge)
1670
+ else:
1671
+ clean_for_temp = _minimal_header_for_gray2d(*gray.shape)
1672
+ base_for_merge = None
1673
+ _debug_dump_header("ASTAP: CLEAN_FOR_TEMP (minimal header, no seed)", clean_for_temp)
1674
+
1675
+ tmp_fit, sidecar_wcs = _write_temp_fit_via_save_image(gray, clean_for_temp)
1676
+ print(f"[ASTAP] Temp FITS: {tmp_fit}, sidecar WCS: {sidecar_wcs}")
1677
+
1678
+ # seed if possible; otherwise blind
1679
+ seed_args: list[str] = []
1680
+ scale_arcsec = None
1681
+ try:
1682
+ seed_args, dbg, scale_arcsec = _build_astap_seed_with_overrides(settings, seed_header, gray)
1683
+ if seed_args:
1684
+ # radius & fov modes (already implemented)
1685
+ radius_mode = _get_astap_radius_mode(settings) # "auto" or "value"
1686
+ fov_mode = _get_astap_fov_mode(settings) # "auto", "compute", "value"
1687
+
1688
+ # radius
1689
+ if radius_mode == "auto":
1690
+ r_arg = ["-r", "0"] # ASTAP auto
1691
+ r_dbg = "r=auto(0)"
1692
+ else:
1693
+ r_val = max(0.0, float(_get_astap_radius_value(settings)))
1694
+ r_arg = ["-r", f"{r_val:.3f}"]
1695
+ r_dbg = f"r={r_val:.3f}°"
1696
+
1697
+ # fov
1698
+ if fov_mode == "auto":
1699
+ fov_arg = ["-fov", "0"]
1700
+ f_dbg = "fov=auto(0)"
1701
+ elif fov_mode == "value":
1702
+ fv = max(0.0, float(_get_astap_fov_value(settings)))
1703
+ fov_arg = ["-fov", f"{fv:.4f}"]
1704
+ f_dbg = f"fov={fv:.4f}°"
1705
+ else: # "compute"
1706
+ fv = _compute_fov_deg(gray, scale_arcsec) or 0.0
1707
+ fov_arg = ["-fov", f"{fv:.4f}"]
1708
+ f_dbg = f"fov(computed)={fv:.4f}°"
1709
+
1710
+ seed_args = seed_args + r_arg + fov_arg
1711
+ print("ASTAP seed:", dbg, "|", r_dbg, "|", f_dbg)
1712
+ else:
1713
+ print("Seed disabled/invalid → blind:", dbg)
1714
+ except Exception as e:
1715
+ print("Seed build error:", e)
1716
+
1717
+ if not seed_args:
1718
+ seed_args = ["-r", "179", "-fov", "0", "-z", "0"]
1719
+ print("ASTAP BLIND: using arguments:", " ".join(seed_args))
1720
+
1721
+ args = ["-f", tmp_fit] + seed_args + ["-wcs", "-sip"]
1722
+ print("Running ASTAP with:", " ".join([astap_exe] + args))
1723
+
1724
+ proc = QProcess(parent)
1725
+ proc.start(astap_exe, args)
1726
+ if not proc.waitForStarted(5000):
1727
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP failed to start."))
1728
+ return False, QCoreApplication.translate("PlateSolver", "Failed to start ASTAP: {0}").format(proc.errorString())
1729
+
1730
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP solving…"))
1731
+ if not _wait_process(proc, 300000, parent=parent):
1732
+ _set_status_ui(parent, QCoreApplication.translate("PlateSolver", "Status: ASTAP timed out."))
1733
+ return False, QCoreApplication.translate("PlateSolver", "ASTAP timed out.")
1734
+
1735
+ if proc.exitCode() != 0:
1736
+ out = bytes(proc.readAllStandardOutput()).decode(errors="ignore")
1737
+ err = bytes(proc.readAllStandardError()).decode(errors="ignore")
1738
+ print("ASTAP failed.\nSTDOUT:\n", out, "\nSTDERR:\n", err)
1739
+ try: os.remove(tmp_fit)
1740
+ except Exception as e:
1741
+ import logging
1742
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1743
+ try:
1744
+ if os.path.exists(sidecar_wcs): os.remove(sidecar_wcs)
1745
+ except Exception as e:
1746
+ import logging
1747
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1748
+ return False, QCoreApplication.translate("PlateSolver", "ASTAP returned a non-zero exit code.")
1749
+
1750
+ # >>> THIS is the key change: read the header **directly** from the FITS ASTAP wrote
1751
+ try:
1752
+ # Use acquisition header as base + WCS from .wcs
1753
+ hdr = _build_header_from_astap_outputs(tmp_fit, sidecar_wcs, base_for_merge)
1754
+ finally:
1755
+ try: os.remove(tmp_fit)
1756
+ except Exception as e:
1757
+ import logging
1758
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1759
+ try:
1760
+ if os.path.exists(sidecar_wcs): os.remove(sidecar_wcs)
1761
+ except Exception as e:
1762
+ import logging
1763
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1764
+
1765
+ # return a REAL fits.Header (no blobs/strings/dicts)
1766
+ return True, hdr
1767
+
1768
+
1769
+
1770
+ # ---------------------------------------------------------------------
1771
+ # Solve active doc in-place
1772
+ # ---------------------------------------------------------------------
1773
+
1774
+ # --- Debug helpers ---------------------------------------------------
1775
+ DEBUG_PLATESOLVE_HEADERS = False # set False to silence all header dumps
1776
+
1777
+
1778
+ def _debug_dump_header(label: str, hdr: Header | None):
1779
+ """Print a full FITS Header to the console for debugging."""
1780
+ if not DEBUG_PLATESOLVE_HEADERS:
1781
+ return
1782
+
1783
+ print(f"\n===== {label} =====")
1784
+ if hdr is None:
1785
+ print(" (None)")
1786
+ elif isinstance(hdr, Header):
1787
+ print(f" (#cards = {len(hdr)})")
1788
+ for k, v in hdr.items():
1789
+ print(f" {k:8s} = {v!r}")
1790
+ else:
1791
+ print(f" (not a Header: {type(hdr)!r})")
1792
+ print("========================================\n")
1793
+
1794
+ def _debug_dump_meta(label: str, meta: dict):
1795
+ if not DEBUG_PLATESOLVE_HEADERS:
1796
+ return
1797
+ print(f"\n===== {label} (meta keys) =====")
1798
+ for k in sorted(meta.keys()):
1799
+ v = meta[k]
1800
+ print(f" {k}: {type(v).__name__}")
1801
+ print("================================\n")
1802
+
1803
+ def tr(s: str) -> str:
1804
+ return QCoreApplication.translate("PlateSolver", s)
1805
+
1806
+ def plate_solve_doc_inplace(parent, doc, settings) -> Tuple[bool, Header | str]:
1807
+ img = getattr(doc, "image", None)
1808
+ if img is None:
1809
+ return False, QCoreApplication.translate("PlateSolver", "Active document has no image data.")
1810
+
1811
+ # Make sure metadata is a dict we can mutate
1812
+ meta = getattr(doc, "metadata", {}) or {}
1813
+ if not isinstance(meta, dict):
1814
+ try:
1815
+ meta = dict(meta)
1816
+ except Exception:
1817
+ meta = {}
1818
+
1819
+ _debug_dump_meta("META BEFORE SOLVE", meta)
1820
+ _debug_dump_header("META['original_header'] BEFORE SOLVE", meta.get("original_header"))
1821
+
1822
+ seed_h = _seed_header_from_meta(meta)
1823
+ _debug_dump_header("SEED HEADER FROM META (seed_h)", seed_h)
1824
+
1825
+ # Keep a copy of acquisition header (no WCS) for merge
1826
+ # Prefer the true acquisition header if we have it, otherwise fall back.
1827
+ raw_acq = meta.get("original_header") or meta.get("fits_header")
1828
+
1829
+ acq_base: Header | None = None
1830
+ if isinstance(raw_acq, Header):
1831
+ # Use the original acquisition header (OBJECT, EXPTIME, GAIN, etc.)
1832
+ acq_base = _strip_wcs_keys(raw_acq.copy())
1833
+ _debug_dump_header("ACQ_BASE (original/fits header with WCS stripped)", acq_base)
1834
+ elif isinstance(seed_h, Header):
1835
+ # Fallback: use the seed header as our acquisition base
1836
+ acq_base = _strip_wcs_keys(seed_h.copy())
1837
+ _debug_dump_header("ACQ_BASE (seed_h with WCS stripped)", acq_base)
1838
+ else:
1839
+ acq_base = None
1840
+ _debug_dump_header("ACQ_BASE (none available)", None)
1841
+
1842
+ # Better debug: use our new scale estimator
1843
+ try:
1844
+ if isinstance(seed_h, Header):
1845
+ ra = seed_h.get("CRVAL1", None)
1846
+ dec = seed_h.get("CRVAL2", None)
1847
+ scale = _estimate_scale_arcsec_from_header(seed_h)
1848
+ print(f"[PlateSolve seed] CRVAL1={ra}, CRVAL2={dec}, scale≈{scale} \"/px")
1849
+ else:
1850
+ print("[PlateSolve seed] No valid seed header available.")
1851
+ except Exception as e:
1852
+ print("Seed: debug print failed:", e)
1853
+
1854
+ # Determine if we have inline status/log widgets; if not, show the popup.
1855
+ headless = not (
1856
+ (hasattr(parent, "status") and isinstance(getattr(parent, "status"), QLabel)) or
1857
+ (hasattr(parent, "log") and hasattr(getattr(parent, "log"), "append")) or
1858
+ (hasattr(parent, "findChild") and parent.findChild(QLabel, "status_label") is not None)
1859
+ )
1860
+ if headless:
1861
+ _status_popup_open(parent, tr("Status: Preparing plate solve…"))
1862
+
1863
+ ok_solve = False
1864
+ try:
1865
+ ok, res = _solve_numpy_with_fallback(parent, settings, img, seed_h)
1866
+ if not ok:
1867
+ return False, res
1868
+
1869
+ hdr: Header = res
1870
+ _debug_dump_header("SOLVER RAW HEADER (from _solve_numpy_with_fallback)", hdr)
1871
+
1872
+ # Final header = acquisition + new WCS (solver)
1873
+ if isinstance(acq_base, Header) and isinstance(hdr, Header):
1874
+ hdr_final = _merge_wcs_into_base_header(acq_base, hdr)
1875
+ else:
1876
+ hdr_final = hdr if isinstance(hdr, Header) else Header()
1877
+
1878
+ _debug_dump_header("FINAL MERGED HEADER (hdr_final)", hdr_final)
1879
+ # 🔹 NEW: stash pre-solve header ONCE so we never lose it
1880
+ try:
1881
+ if "original_header" in meta and "pre_solve_header" not in meta:
1882
+ old = meta["original_header"]
1883
+ if isinstance(old, Header):
1884
+ meta["pre_solve_header"] = old.copy()
1885
+ except Exception as e:
1886
+ print("plate_solve_doc_inplace: failed to stash pre_solve_header:", e)
1887
+
1888
+ # 🔹 Ensure doc.metadata is our updated dict
1889
+ doc.metadata = meta
1890
+
1891
+ # Store merged header as the current "original_header"
1892
+ doc.metadata["original_header"] = hdr_final
1893
+ _debug_dump_header("DOC.METADATA['original_header'] AFTER SOLVE", doc.metadata.get("original_header"))
1894
+
1895
+
1896
+ # Build WCS object from the same header we just stored
1897
+ try:
1898
+ wcs_obj = WCS(hdr_final)
1899
+ doc.metadata["wcs"] = wcs_obj
1900
+ except Exception as e:
1901
+ print("WCS build FAILED:", e)
1902
+
1903
+ # Notify UI
1904
+ if hasattr(doc, "changed"):
1905
+ try: doc.changed.emit()
1906
+ except Exception as e:
1907
+ import logging
1908
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1909
+
1910
+ if hasattr(parent, "header_viewer") and hasattr(parent.header_viewer, "set_document"):
1911
+ QTimer.singleShot(0, lambda: parent.header_viewer.set_document(doc))
1912
+ if hasattr(parent, "_refresh_header_viewer"):
1913
+ QTimer.singleShot(0, lambda: parent._refresh_header_viewer(doc))
1914
+ if hasattr(parent, "currentDocumentChanged"):
1915
+ QTimer.singleShot(0, lambda: parent.currentDocumentChanged.emit(doc))
1916
+
1917
+ _set_status_ui(parent, tr("Status: Plate solve completed."))
1918
+
1919
+
1920
+ ok_solve = True
1921
+ if headless:
1922
+ QTimer.singleShot(1200, _status_popup_close)
1923
+ else:
1924
+ _status_popup_close()
1925
+ return True, hdr
1926
+ finally:
1927
+ if not ok_solve:
1928
+ _status_popup_close()
1929
+
1930
+
1931
+
1932
+ def _estimate_scale_arcsec_from_header(hdr: Header) -> float | None:
1933
+ """
1934
+ Estimate pixel scale in arcsec/pixel from a FITS Header.
1935
+ Tries WCS, then CD matrix, then PC*CDELT, then PIXSCALE-style keys.
1936
+ Returns None if we can't get a sane value.
1937
+ """
1938
+ # Always work on a copy with our internal meta keys stripped
1939
+ hdr = _strip_nonfits_meta_keys_from_header(hdr)
1940
+
1941
+ # 1) Try astropy WCS, which handles CD vs PC*CDELT automatically
1942
+ try:
1943
+ w = WCS(hdr)
1944
+ from astropy.wcs.utils import proj_plane_pixel_scales
1945
+ scales_deg = proj_plane_pixel_scales(w) # degrees/pixel
1946
+ if scales_deg is not None and len(scales_deg) >= 2:
1947
+ s_deg = float(np.mean(scales_deg[:2]))
1948
+ scale = s_deg * 3600.0 # arcsec/pixel
1949
+ if 0 < scale < 10000:
1950
+ return scale
1951
+ except Exception as e:
1952
+ print("Seed: WCS->scale via proj_plane_pixel_scales failed:", e)
1953
+
1954
+ # 2) Try CD matrix directly
1955
+ cd11 = hdr.get("CD1_1")
1956
+ cd21 = hdr.get("CD2_1")
1957
+ try:
1958
+ if cd11 is not None or cd21 is not None:
1959
+ cd11 = float(cd11 or 0.0)
1960
+ cd21 = float(cd21 or 0.0)
1961
+ s_deg = (cd11 * cd11 + cd21 * cd21) ** 0.5
1962
+ scale = s_deg * 3600.0
1963
+ if 0 < scale < 10000:
1964
+ return scale
1965
+ except Exception as e:
1966
+ print("Seed: CD-based scale failed:", e)
1967
+
1968
+ # 3) Try PC * CDELT fallback
1969
+ try:
1970
+ cdelt1 = hdr.get("CDELT1")
1971
+ cdelt2 = hdr.get("CDELT2")
1972
+ pc11 = hdr.get("PC1_1")
1973
+ pc21 = hdr.get("PC2_1")
1974
+ if cdelt1 is not None and pc11 is not None:
1975
+ cd11 = float(cdelt1) * float(pc11)
1976
+ else:
1977
+ cd11 = None
1978
+ if cdelt2 is not None and pc21 is not None:
1979
+ cd21 = float(cdelt2) * float(pc21)
1980
+ else:
1981
+ cd21 = None
1982
+
1983
+ if cd11 is not None or cd21 is not None:
1984
+ s_deg = ( (cd11 or 0.0)**2 + (cd21 or 0.0)**2 ) ** 0.5
1985
+ scale = s_deg * 3600.0
1986
+ if 0 < scale < 10000:
1987
+ return scale
1988
+ except Exception as e:
1989
+ print("Seed: PC*CDELT-based scale failed:", e)
1990
+
1991
+ # 4) Fallback on explicit pixscale-like keywords, if present
1992
+ for key in ("PIXSCALE", "SECPIX"):
1993
+ if key in hdr:
1994
+ try:
1995
+ scale = float(hdr[key])
1996
+ if 0 < scale < 10000:
1997
+ return scale
1998
+ except Exception:
1999
+ pass
2000
+
2001
+ # If we get here, we couldn't find a sane scale
2002
+ return None
2003
+
2004
+ def _seed_header_from_meta(meta: dict) -> Header:
2005
+ """
2006
+ Build the header used for ASTAP seeding from doc.metadata.
2007
+
2008
+ Priority:
2009
+ 1. original_header (if present)
2010
+ 2. meta as a dict
2011
+ Then merge in any WCS info from:
2012
+ - meta['wcs_header'] (Header or string)
2013
+ - meta['wcs'] (WCS object)
2014
+ """
2015
+ # Base: original FITS header if present, otherwise treat meta dict as header
2016
+ base_src = meta.get("original_header") or meta.get("fits_header") or meta
2017
+ base = _as_header(base_src)
2018
+
2019
+ wcs_hdr: Header | None = None
2020
+
2021
+ # 1) Use explicit wcs_header if present
2022
+ raw_wcs = meta.get("wcs_header")
2023
+ if isinstance(raw_wcs, Header):
2024
+ wcs_hdr = raw_wcs
2025
+ elif isinstance(raw_wcs, str):
2026
+ # This is your case: stored as Header.tostring()
2027
+ try:
2028
+ # In real metadata this likely has newlines; sep='\n' handles that.
2029
+ wcs_hdr = fits.Header.fromstring(raw_wcs, sep='\n')
2030
+ except Exception as e:
2031
+ print("Seed: failed to parse wcs_header string:", e)
2032
+
2033
+ # 2) Fallback: derive from WCS object if we still don't have a header
2034
+ if wcs_hdr is None:
2035
+ wcs_obj = meta.get("wcs")
2036
+ if isinstance(wcs_obj, WCS):
2037
+ try:
2038
+ wcs_hdr = wcs_obj.to_header(relax=True)
2039
+ except Exception as e:
2040
+ print("Seed: failed to derive WCS header from WCS object:", e)
2041
+
2042
+ # 3) Merge WCS header into base header, with WCS keys winning
2043
+ if wcs_hdr is not None:
2044
+ if not isinstance(base, Header):
2045
+ base = Header()
2046
+ else:
2047
+ base = base.copy()
2048
+ for k, v in wcs_hdr.items():
2049
+ try:
2050
+ base[k] = v
2051
+ except Exception:
2052
+ pass
2053
+
2054
+ return _strip_nonfits_meta_keys_from_header(base)
2055
+
2056
+
2057
+ def _compute_fov_deg(image: np.ndarray, arcsec_per_px: float | None) -> float | None:
2058
+ if arcsec_per_px is None or not np.isfinite(arcsec_per_px) or arcsec_per_px <= 0:
2059
+ return None
2060
+ H = int(image.shape[0]) if image.ndim >= 2 else 0
2061
+ if H <= 0:
2062
+ return None
2063
+ return (H * arcsec_per_px) / 3600.0 # vertical FOV in degrees
2064
+
2065
+ def plate_solve_active_document(parent, settings) -> tuple[bool, Header | str]:
2066
+ """
2067
+ Convenience wrapper:
2068
+ - Finds the active document from the given parent (main window, ImagePeeker, etc.)
2069
+ - Calls plate_solve_doc_inplace(...)
2070
+
2071
+ Returns (ok, Header | error_message).
2072
+ """
2073
+ doc = _active_doc_from_parent(parent)
2074
+ if doc is None:
2075
+ return False, QCoreApplication.translate("PlateSolver", "No active document to plate-solve.")
2076
+
2077
+ return plate_solve_doc_inplace(parent, doc, settings)
2078
+
2079
+ # ---------------------------------------------------------------------
2080
+ # Dialog UI with Active/File and Batch modes
2081
+ # ---------------------------------------------------------------------
2082
+
2083
+ class PlateSolverDialog(QDialog):
2084
+ """
2085
+ Plate-solve either:
2086
+ - Active View (default)
2087
+ - Single File (via load_image/save_image)
2088
+ - Batch (directory → directory)
2089
+ Uses settings key: 'paths/astap' or 'astap/exe_path' for ASTAP executable.
2090
+ """
2091
+ def __init__(self, settings, parent=None, icon: QIcon | None = None):
2092
+ super().__init__(parent)
2093
+ self.settings = settings
2094
+ self.setWindowTitle(self.tr("Plate Solver"))
2095
+ self.setMinimumWidth(560)
2096
+ self.setWindowFlag(Qt.WindowType.Window, True)
2097
+ self.setWindowModality(Qt.WindowModality.NonModal)
2098
+ self.setModal(False)
2099
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
2100
+
2101
+ # ---------------- Main containers ----------------
2102
+ main = QVBoxLayout(self)
2103
+ main.setContentsMargins(10, 10, 10, 10)
2104
+ main.setSpacing(10)
2105
+
2106
+ # ---- Top row: Mode selector ----
2107
+ top = QHBoxLayout()
2108
+ top.addWidget(QLabel(self.tr("Mode:"), self))
2109
+ self.mode_combo = QComboBox(self)
2110
+ self.mode_combo.addItem(self.tr("Active View"), "Active View")
2111
+ self.mode_combo.addItem(self.tr("File"), "File")
2112
+ self.mode_combo.addItem(self.tr("Batch"), "Batch")
2113
+ top.addWidget(self.mode_combo, 1)
2114
+ top.addStretch(1)
2115
+ main.addLayout(top)
2116
+
2117
+ # ---- Seeding group (shared) ----
2118
+ from PyQt6.QtWidgets import QGroupBox, QFormLayout
2119
+ seed_box = QGroupBox(self.tr("Seeding & Constraints"), self)
2120
+ seed_form = QFormLayout(seed_box)
2121
+ seed_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
2122
+ seed_form.setHorizontalSpacing(8)
2123
+ seed_form.setVerticalSpacing(6)
2124
+
2125
+ # Seed mode
2126
+ self.cb_seed_mode = QComboBox(seed_box)
2127
+ self.cb_seed_mode.addItem(self.tr("Auto (from header)"), "Auto (from header)")
2128
+ self.cb_seed_mode.addItem(self.tr("Manual"), "Manual")
2129
+ self.cb_seed_mode.addItem(self.tr("None (blind)"), "None (blind)")
2130
+ seed_form.addRow(self.tr("Seed mode:"), self.cb_seed_mode)
2131
+
2132
+ # Manual RA/Dec/Scale row
2133
+ manual_row = QHBoxLayout()
2134
+ self.le_ra = QLineEdit(seed_box); self.le_ra.setPlaceholderText(self.tr("RA (e.g. 22:32:14 or 338.1385)"))
2135
+ self.le_dec = QLineEdit(seed_box); self.le_dec.setPlaceholderText(self.tr("Dec (e.g. +40:42:43 or 40.7123)"))
2136
+ self.le_scale = QLineEdit(seed_box); self.le_scale.setPlaceholderText(self.tr('Scale [" / px] (e.g. 1.46)'))
2137
+ manual_row.addWidget(self.le_ra, 1)
2138
+ manual_row.addWidget(self.le_dec, 1)
2139
+ manual_row.addWidget(self.le_scale, 1)
2140
+ seed_form.addRow(self.tr("Manual RA/Dec/Scale:"), manual_row)
2141
+
2142
+ # Search radius (-r)
2143
+ rad_row = QHBoxLayout()
2144
+ self.cb_radius_mode = QComboBox(seed_box)
2145
+ self.cb_radius_mode.addItem(self.tr("Auto (-r 0)"), "Auto (-r 0)")
2146
+ self.cb_radius_mode.addItem(self.tr("Value (deg)"), "Value (deg)")
2147
+ self.le_radius_val = QLineEdit(seed_box); self.le_radius_val.setPlaceholderText(self.tr("e.g. 5.0"))
2148
+ self.le_radius_val.setFixedWidth(120)
2149
+ rad_row.addWidget(self.cb_radius_mode)
2150
+ rad_row.addWidget(self.le_radius_val)
2151
+ rad_row.addStretch(1)
2152
+ seed_form.addRow(self.tr("Search radius:"), rad_row)
2153
+
2154
+ # FOV (-fov)
2155
+ fov_row = QHBoxLayout()
2156
+ self.cb_fov_mode = QComboBox(seed_box)
2157
+ self.cb_fov_mode.addItem(self.tr("Compute from scale"), "Compute from scale")
2158
+ self.cb_fov_mode.addItem(self.tr("Auto (-fov 0)"), "Auto (-fov 0)")
2159
+ self.cb_fov_mode.addItem(self.tr("Value (deg)"), "Value (deg)")
2160
+ self.le_fov_val = QLineEdit(seed_box); self.le_fov_val.setPlaceholderText(self.tr("e.g. 1.80"))
2161
+ self.le_fov_val.setFixedWidth(120)
2162
+ fov_row.addWidget(self.cb_fov_mode)
2163
+ fov_row.addWidget(self.le_fov_val)
2164
+ fov_row.addStretch(1)
2165
+ seed_form.addRow(self.tr("FOV:"), fov_row)
2166
+
2167
+ # Tooltips
2168
+ self.cb_seed_mode.setToolTip(self.tr("Use FITS header, your manual RA/Dec/scale, or blind solve."))
2169
+ self.le_scale.setToolTip(self.tr('Pixel scale in arcseconds/pixel (e.g., 1.46).'))
2170
+ self.cb_radius_mode.setToolTip(self.tr("ASTAP -r. Auto lets ASTAP choose; Value forces a cone radius."))
2171
+ self.cb_fov_mode.setToolTip(self.tr("ASTAP -fov. Compute uses image height × scale; Auto lets ASTAP infer."))
2172
+
2173
+ main.addWidget(seed_box)
2174
+
2175
+ # ---------------- Stacked pages ----------------
2176
+ self.stack = QStackedWidget(self)
2177
+ main.addWidget(self.stack, 1)
2178
+
2179
+ # Page 0: Active View
2180
+ p0 = QWidget(self); l0 = QVBoxLayout(p0)
2181
+ l0.addWidget(QLabel(self.tr("Solve the currently active image view."), p0))
2182
+ l0.addStretch(1)
2183
+ self.stack.addWidget(p0)
2184
+
2185
+ # Page 1: File picker
2186
+ p1 = QWidget(self); l1 = QVBoxLayout(p1)
2187
+ file_row = QHBoxLayout()
2188
+ self.le_path = QLineEdit(p1); self.le_path.setPlaceholderText(self.tr("Choose an image…"))
2189
+ btn_browse = QPushButton(self.tr("Browse…"), p1)
2190
+ file_row.addWidget(self.le_path, 1); file_row.addWidget(btn_browse)
2191
+ l1.addLayout(file_row); l1.addStretch(1)
2192
+ self.stack.addWidget(p1)
2193
+
2194
+ # Page 2: Batch
2195
+ p2 = QWidget(self); l2 = QVBoxLayout(p2)
2196
+ in_row = QHBoxLayout(); out_row = QHBoxLayout()
2197
+ self.le_in = QLineEdit(p2); self.le_in.setPlaceholderText(self.tr("Input directory"))
2198
+ self.le_out = QLineEdit(p2); self.le_out.setPlaceholderText(self.tr("Output directory"))
2199
+ b_in = QPushButton(self.tr("Browse Input…"), p2)
2200
+ b_out = QPushButton(self.tr("Browse Output…"), p2)
2201
+ in_row.addWidget(self.le_in, 1); in_row.addWidget(b_in)
2202
+ out_row.addWidget(self.le_out, 1); out_row.addWidget(b_out)
2203
+ self.log = QTextEdit(p2); self.log.setReadOnly(True); self.log.setMinimumHeight(160)
2204
+ l2.addLayout(in_row); l2.addLayout(out_row); l2.addWidget(QLabel(self.tr("Status:"), p2)); l2.addWidget(self.log, 1)
2205
+ self.stack.addWidget(p2)
2206
+
2207
+ # ---------------- Status + buttons ----------------
2208
+ self.status = QLabel("", self)
2209
+ self.status.setMinimumHeight(20)
2210
+ main.addWidget(self.status)
2211
+
2212
+ btn_row = QHBoxLayout()
2213
+ btn_row.addStretch(1)
2214
+ self.btn_go = QPushButton(self.tr("Start"), self)
2215
+ self.btn_close = QPushButton(self.tr("Close"), self)
2216
+ btn_row.addWidget(self.btn_go)
2217
+ btn_row.addWidget(self.btn_close)
2218
+ main.addLayout(btn_row)
2219
+
2220
+ # ---------------- Connections ----------------
2221
+ self.mode_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
2222
+ btn_browse.clicked.connect(self._browse_file)
2223
+ b_in.clicked.connect(self._browse_in)
2224
+ b_out.clicked.connect(self._browse_out)
2225
+ self.btn_go.clicked.connect(self._run)
2226
+ self.btn_close.clicked.connect(self.close)
2227
+
2228
+ # ---------------- Load settings & init UI ----------------
2229
+ mode_map = {"auto": 0, "manual": 1, "none": 2}
2230
+ self.cb_seed_mode.setCurrentIndex(mode_map.get(_get_seed_mode(self.settings), 0))
2231
+ self.le_ra.setText(_get_manual_ra(self.settings))
2232
+ self.le_dec.setText(_get_manual_dec(self.settings))
2233
+ scl = _get_manual_scale(self.settings)
2234
+ self.le_scale.setText("" if scl is None else str(scl))
2235
+
2236
+ self.cb_radius_mode.setCurrentIndex(0 if _get_astap_radius_mode(self.settings) == "auto" else 1)
2237
+ self.le_radius_val.setText(str(_get_astap_radius_value(self.settings)))
2238
+
2239
+ fov_mode = _get_astap_fov_mode(self.settings)
2240
+ self.cb_fov_mode.setCurrentIndex(1 if fov_mode == "auto" else (2 if fov_mode == "value" else 0))
2241
+ self.le_fov_val.setText(str(_get_astap_fov_value(self.settings)))
2242
+
2243
+ def _update_visibility():
2244
+ manual = (self.cb_seed_mode.currentIndex() == 1)
2245
+ self.le_ra.setEnabled(manual)
2246
+ self.le_dec.setEnabled(manual)
2247
+ self.le_scale.setEnabled(manual)
2248
+ self.le_radius_val.setEnabled(self.cb_radius_mode.currentIndex() == 1)
2249
+ self.le_fov_val.setEnabled(self.cb_fov_mode.currentIndex() == 2)
2250
+
2251
+ self.cb_seed_mode.currentIndexChanged.connect(_update_visibility)
2252
+ self.cb_radius_mode.currentIndexChanged.connect(_update_visibility)
2253
+ self.cb_fov_mode.currentIndexChanged.connect(_update_visibility)
2254
+ _update_visibility()
2255
+
2256
+ if icon:
2257
+ self.setWindowIcon(icon)
2258
+
2259
+ self.status.setObjectName("status_label")
2260
+ # if batch page exists:
2261
+ self.log.setObjectName("batch_log")
2262
+
2263
+ # ---------- file/batch pickers ----------
2264
+ def _browse_file(self):
2265
+ f, _ = QFileDialog.getOpenFileName(
2266
+ self, self.tr("Choose Image"),
2267
+ "", self.tr("Images (*.fits *.fit *.xisf *.tif *.tiff *.png *.jpg *.jpeg);;All files (*)")
2268
+ )
2269
+ if f:
2270
+ self.le_path.setText(f)
2271
+
2272
+ def _browse_in(self):
2273
+ d = QFileDialog.getExistingDirectory(self, self.tr("Choose input directory"))
2274
+ if d: self.le_in.setText(d)
2275
+
2276
+ def _browse_out(self):
2277
+ d = QFileDialog.getExistingDirectory(self, self.tr("Choose output directory"))
2278
+ if d: self.le_out.setText(d)
2279
+
2280
+ # ---------- actions ----------
2281
+ def _run(self):
2282
+ astap_exe = _get_astap_exe(self.settings)
2283
+ if not astap_exe or not os.path.exists(astap_exe):
2284
+ self.status.setText(self.tr("ASTAP path missing. Set Preferences → ASTAP executable."))
2285
+ QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("ASTAP path missing.\nSet it in Preferences → ASTAP executable."))
2286
+ return
2287
+
2288
+ idx = self.cb_seed_mode.currentIndex()
2289
+ _set_seed_mode(self.settings, "auto" if idx == 0 else ("manual" if idx == 1 else "none"))
2290
+ # manual values
2291
+ try:
2292
+ manual_scale = float(self.le_scale.text().strip()) if self.le_scale.text().strip() else None
2293
+ except Exception:
2294
+ manual_scale = None
2295
+ _set_manual_seed(self.settings, self.le_ra.text().strip(), self.le_dec.text().strip(), manual_scale)
2296
+ # radius
2297
+ self.settings.setValue("astap/seed_radius_mode", "auto" if self.cb_radius_mode.currentIndex()==0 else "value")
2298
+ try:
2299
+ self.settings.setValue("astap/seed_radius_value", float(self.le_radius_val.text().strip()))
2300
+ except Exception:
2301
+ pass
2302
+ # fov
2303
+ self.settings.setValue("astap/seed_fov_mode",
2304
+ "compute" if self.cb_fov_mode.currentIndex()==0 else ("auto" if self.cb_fov_mode.currentIndex()==1 else "value"))
2305
+ try:
2306
+ self.settings.setValue("astap/seed_fov_value", float(self.le_fov_val.text().strip()))
2307
+ except Exception:
2308
+ pass
2309
+
2310
+ mode = self.stack.currentIndex()
2311
+ if mode == 0:
2312
+ # Active view
2313
+ doc = _active_doc_from_parent(self.parent())
2314
+ if not doc:
2315
+ QMessageBox.information(self, self.tr("Plate Solver"), self.tr("No active image view."))
2316
+ return
2317
+ ok, res = plate_solve_doc_inplace(self, doc, self.settings)
2318
+ if ok:
2319
+ self.status.setText(self.tr("Solved with ASTAP (WCS + SIP applied to active doc)."))
2320
+ QTimer.singleShot(0, self.accept) # close when done
2321
+ else:
2322
+ self.status.setText(str(res))
2323
+ elif mode == 1:
2324
+ # Single file
2325
+ path = self.le_path.text().strip()
2326
+ if not path:
2327
+ QMessageBox.information(self, self.tr("Plate Solver"), self.tr("Choose a file to solve."))
2328
+ return
2329
+ if not os.path.exists(path):
2330
+ QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("Selected file does not exist."))
2331
+ return
2332
+ self._solve_file(path)
2333
+ else:
2334
+ self._run_batch()
2335
+
2336
+ def _solve_file(self, path: str):
2337
+ # Load using legacy.load_image()
2338
+ try:
2339
+ image_data, original_header, bit_depth, is_mono = load_image(path)
2340
+ except Exception as e:
2341
+ QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("Cannot read image:\n{0}").format(e))
2342
+ return
2343
+ if image_data is None:
2344
+ QMessageBox.warning(self, self.tr("Plate Solver"), self.tr("Unsupported or unreadable image."))
2345
+ return
2346
+
2347
+ # Seed header from original_header
2348
+ seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
2349
+
2350
+ # Acquisition base for final merge (strip old WCS)
2351
+ acq_base: Header | None = None
2352
+ if isinstance(seed_h, Header):
2353
+ acq_base = _strip_wcs_keys(seed_h)
2354
+
2355
+ # Solve
2356
+ ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
2357
+ if not ok:
2358
+ self.status.setText(str(res))
2359
+ return
2360
+ solver_hdr: Header = res
2361
+
2362
+ # Merge solver WCS into acquisition header
2363
+ if isinstance(acq_base, Header) and isinstance(solver_hdr, Header):
2364
+ hdr_final = _merge_wcs_into_base_header(acq_base, solver_hdr)
2365
+ else:
2366
+ hdr_final = solver_hdr if isinstance(solver_hdr, Header) else Header()
2367
+
2368
+ # Save-as using legacy.save_image() with ORIGINAL pixels (not normalized)
2369
+ save_path, _ = QFileDialog.getSaveFileName(
2370
+ self,
2371
+ self.tr("Save Plate-Solved FITS"),
2372
+ "",
2373
+ self.tr("FITS files (*.fits *.fit)")
2374
+ )
2375
+ if save_path:
2376
+ try:
2377
+ # never persist 'file_path' inside FITS
2378
+ h2 = Header()
2379
+ for k in hdr_final.keys():
2380
+ if k.upper() != "FILE_PATH":
2381
+ h2[k] = hdr_final[k]
2382
+
2383
+ save_image(
2384
+ img_array=image_data,
2385
+ filename=save_path,
2386
+ original_format="fit",
2387
+ bit_depth="32-bit floating point",
2388
+ original_header=h2,
2389
+ is_mono=is_mono
2390
+ )
2391
+ self.status.setText(self.tr("Solved FITS saved:\n{0}").format(save_path))
2392
+ QTimer.singleShot(0, self.accept)
2393
+ except Exception as e:
2394
+ QMessageBox.critical(self, self.tr("Save Error"), self.tr("Failed to save: {0}").format(e))
2395
+ else:
2396
+ self.status.setText(self.tr("Solved (not saved)."))
2397
+
2398
+
2399
+ def _run_batch(self):
2400
+ in_dir = self.le_in.text().strip()
2401
+ out_dir = self.le_out.text().strip()
2402
+ if not in_dir or not os.path.isdir(in_dir):
2403
+ QMessageBox.warning(self, self.tr("Batch"), self.tr("Please choose a valid input directory."))
2404
+ return
2405
+ if not out_dir or not os.path.isdir(out_dir):
2406
+ QMessageBox.warning(self, self.tr("Batch"), self.tr("Please choose a valid output directory."))
2407
+ return
2408
+
2409
+ exts = {".xisf", ".fits", ".fit", ".tif", ".tiff", ".png", ".jpg", ".jpeg"}
2410
+ files = [
2411
+ os.path.join(in_dir, f)
2412
+ for f in os.listdir(in_dir)
2413
+ if os.path.splitext(f)[1].lower() in exts
2414
+ ]
2415
+ if not files:
2416
+ QMessageBox.information(self, self.tr("Batch"), self.tr("No acceptable image files found."))
2417
+ return
2418
+
2419
+ self.log.clear()
2420
+ self.log.append(self.tr("Found {0} files. Starting batch…").format(len(files)))
2421
+ QApplication.processEvents()
2422
+
2423
+ for path in files:
2424
+ base = os.path.splitext(os.path.basename(path))[0]
2425
+ out = os.path.join(out_dir, base + "_plate_solved.fits")
2426
+ self.log.append(f"▶ {path}") # Symbol, no need to translate
2427
+ QApplication.processEvents()
2428
+
2429
+ try:
2430
+ # Load using legacy.load_image()
2431
+ image_data, original_header, bit_depth, is_mono = load_image(path)
2432
+ if image_data is None:
2433
+ self.log.append(self.tr(" ❌ Failed to load"))
2434
+ continue
2435
+
2436
+ # Seed header from original_header
2437
+ seed_h = _as_header(original_header) if isinstance(original_header, (dict, Header)) else None
2438
+
2439
+ # Acquisition base for final merge (strip old WCS)
2440
+ acq_base: Header | None = None
2441
+ if isinstance(seed_h, Header):
2442
+ acq_base = _strip_wcs_keys(seed_h)
2443
+
2444
+ # Solve
2445
+ ok, res = _solve_numpy_with_fallback(self, self.settings, image_data, seed_h)
2446
+ if not ok:
2447
+ self.log.append(f" ❌ {res}")
2448
+ continue
2449
+ hdr: Header = res
2450
+
2451
+ # Merge solver WCS into acquisition header
2452
+ if isinstance(acq_base, Header) and isinstance(hdr, Header):
2453
+ hdr_final = _merge_wcs_into_base_header(acq_base, hdr)
2454
+ else:
2455
+ hdr_final = hdr if isinstance(hdr, Header) else Header()
2456
+
2457
+ # Build header to save (and strip FILE_PATH)
2458
+ h2 = Header()
2459
+ for k in hdr_final.keys():
2460
+ if k.upper() != "FILE_PATH":
2461
+ h2[k] = hdr_final[k]
2462
+
2463
+ # Save using original pixels
2464
+ save_image(
2465
+ img_array=image_data,
2466
+ filename=out,
2467
+ original_format="fit",
2468
+ bit_depth="32-bit floating point",
2469
+ original_header=h2,
2470
+ is_mono=is_mono
2471
+ )
2472
+ self.log.append(self.tr(" ✔ saved: ") + out)
2473
+
2474
+ except Exception as e:
2475
+ self.log.append(self.tr(" ❌ error: ") + str(e))
2476
+
2477
+ QApplication.processEvents()
2478
+
2479
+ self.log.append(self.tr("Batch plate solving completed."))
2480
+