setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) 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/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2360 @@
1
+ #legacy.image_manager.py
2
+ # --- required imports for this module ---
3
+ import os
4
+ import time
5
+ import gzip
6
+ from io import BytesIO
7
+ from typing import Optional, Dict
8
+ import datetime
9
+ from datetime import timezone
10
+ import numpy as np
11
+ from PIL import Image
12
+ import tifffile as tiff
13
+
14
+ # add this near your other optional imports
15
+ from astropy.io import fits
16
+ try:
17
+ from astropy.io.fits.verify import VerifyError
18
+ except Exception:
19
+ # Fallback for older Astropy – we'll just treat it as a generic Exception
20
+ class VerifyError(Exception):
21
+ pass
22
+
23
+ def _drop_invalid_cards(header: fits.Header) -> fits.Header:
24
+ """
25
+ Return a copy of the FITS header with any cards that raise VerifyError removed.
26
+ This prevents 'Unparsable card (FOO)' from blowing up later on .value access.
27
+ """
28
+ if not isinstance(header, fits.Header):
29
+ return header
30
+
31
+ hdr = header.copy()
32
+ bad_keys = []
33
+ for card in list(hdr.cards):
34
+ try:
35
+ # Accessing .value is what triggers VerifyError for bad cards
36
+ _ = card.value
37
+ except VerifyError as e:
38
+ print(f"[ImageManager] Dropping invalid FITS card {card.keyword!r}: {e}")
39
+ bad_keys.append(card.keyword)
40
+
41
+ for key in bad_keys:
42
+ try:
43
+ del hdr[key]
44
+ except Exception:
45
+ pass
46
+
47
+ return hdr
48
+
49
+
50
+
51
+ try:
52
+ import rawpy
53
+
54
+ except Exception:
55
+ rawpy = None # optional; RAW loading will raise if it's None
56
+
57
+ from setiastro.saspro.xisf import XISF
58
+
59
+ from PyQt6.QtCore import QObject, pyqtSignal
60
+
61
+ def _looks_like_xisf_header(hdr) -> bool:
62
+ try:
63
+ if isinstance(hdr, (fits.Header, dict)):
64
+ for k in hdr.keys():
65
+ if isinstance(k, str) and k.startswith("XISF:"):
66
+ return True
67
+ except Exception:
68
+ pass
69
+ return False
70
+
71
+ def _iter_header_items(hdr):
72
+ """Yield (key, value) safely from fits.Header or dict; else yield nothing."""
73
+ if isinstance(hdr, fits.Header):
74
+ # .items() is supported and yields (key, value)
75
+ for kv in hdr.items():
76
+ yield kv
77
+ elif isinstance(hdr, dict):
78
+ for kv in hdr.items():
79
+ yield kv
80
+
81
+ class ImageManager(QObject):
82
+ """
83
+ Manages multiple image slots with associated metadata and supports undo/redo operations for each slot.
84
+ Emits a signal whenever an image or its metadata changes.
85
+ """
86
+
87
+ # Signal emitted when an image or its metadata changes.
88
+ # Parameters:
89
+ # - slot (int): The slot number.
90
+ # - image (np.ndarray): The new image data.
91
+ # - metadata (dict): Associated metadata for the image.
92
+ image_changed = pyqtSignal(int, np.ndarray, dict)
93
+ current_slot_changed = pyqtSignal(int)
94
+ # Keys we always carry forward unless caller explicitly supplies a non-empty replacement
95
+ PRESERVE_META_KEYS = ("file_path", "FILE", "path", "fits_header", "header")
96
+
97
+
98
+ def __init__(self, max_slots=5, parent=None):
99
+ """
100
+ Initializes the ImageManager with a specified number of slots.
101
+
102
+ :param max_slots: Maximum number of image slots to manage.
103
+ """
104
+ super().__init__()
105
+ self.parent = parent
106
+ self.max_slots = max_slots
107
+ self._images = {i: None for i in range(max_slots)}
108
+ self._metadata = {i: {} for i in range(max_slots)}
109
+ self._undo_stacks = {i: [] for i in range(max_slots)}
110
+ self._redo_stacks = {i: [] for i in range(max_slots)}
111
+ self.current_slot = 0 # Default to the first slot
112
+ self.active_previews = {} # Track active preview windows by slot
113
+ self.mask_manager = MaskManager(max_slots) # Add a MaskManager
114
+
115
+ def _looks_like_path(self, v: object) -> bool:
116
+ if not isinstance(v, str):
117
+ return False
118
+ # treat as path if it has a separator or a known extension
119
+ ext_ok = v.lower().endswith((".fits", ".fit", ".fts", ".fz", ".fits.fz"))
120
+ return (os.path.sep in v) or ext_ok
121
+
122
+ def _attach_step_name(self, merged_meta: Dict, step_name: Optional[str]) -> Dict:
123
+ if step_name is not None and str(step_name).strip():
124
+ merged_meta["step_name"] = step_name.strip()
125
+ return merged_meta
126
+
127
+ def _merge_metadata(self, base: Optional[Dict], updates: Optional[Dict]) -> Dict:
128
+ out = (base or {}).copy()
129
+ if not updates:
130
+ return out
131
+ for k, v in updates.items():
132
+ if k in ("file_path", "FILE", "path"):
133
+ # Only accept if it looks like a real path; ignore labels like "Cropped Image"
134
+ if not self._looks_like_path(v):
135
+ continue
136
+ if k in ("fits_header", "header"):
137
+ # Don’t replace with None/blank
138
+ if v is None or (isinstance(v, str) and not v.strip()):
139
+ continue
140
+ out[k] = v
141
+ return out
142
+
143
+ def _emit_change(self, slot: int):
144
+ """Centralized emitter to avoid passing None metadata to listeners."""
145
+ img = self._images[slot]
146
+ meta = self._metadata[slot]
147
+ self.image_changed.emit(slot, img, meta)
148
+ if self.parent and hasattr(self.parent, "update_undo_redo_action_labels"):
149
+ self.parent.update_undo_redo_action_labels()
150
+ if self.parent and hasattr(self.parent, "update_slot_toolbar_highlight"):
151
+ self.parent.update_slot_toolbar_highlight()
152
+
153
+
154
+ def get_current_image_and_metadata(self):
155
+ slot = self.current_slot
156
+ return self._images[slot], self._metadata[slot]
157
+
158
+ def rename_slot(self, slot: int, new_name: str):
159
+ """Store a custom slot_name in metadata and emit an update."""
160
+ if 0 <= slot < self.max_slots:
161
+ self._metadata[slot]['slot_name'] = new_name
162
+
163
+ # explicitly check for None, avoid ambiguous truth-check on ndarray
164
+ existing = self._images[slot]
165
+ if existing is None:
166
+ img = np.zeros((1,1), dtype=np.uint8)
167
+ else:
168
+ img = existing
169
+
170
+ # re-emit image_changed so UI labels (menus/toolbars) can refresh
171
+ self.image_changed.emit(slot, img, self._metadata[slot])
172
+ else:
173
+ print(f"ImageManager: cannot rename slot {slot}, out of range")
174
+
175
+ def get_mask(self, slot=None):
176
+ """
177
+ Retrieves the mask for the current or specified slot.
178
+ :param slot: Slot number. If None, uses current slot.
179
+ :return: Mask as numpy array or None.
180
+ """
181
+ if slot is None:
182
+ slot = self.current_slot
183
+ return self.mask_manager.get_mask(slot)
184
+
185
+ def set_mask(self, mask, slot=None):
186
+ """
187
+ Sets a mask for the current or specified slot.
188
+ :param mask: Numpy array representing the mask.
189
+ :param slot: Slot number. If None, uses current slot.
190
+ """
191
+ if slot is None:
192
+ slot = self.current_slot
193
+ self.mask_manager.set_mask(slot, mask)
194
+
195
+ def clear_mask(self, slot=None):
196
+ """
197
+ Clears the mask for the current or specified slot.
198
+ :param slot: Slot number. If None, uses current slot.
199
+ """
200
+ if slot is None:
201
+ slot = self.current_slot
202
+ self.mask_manager.clear_mask(slot)
203
+
204
+ def set_current_slot(self, slot):
205
+ if 0 <= slot < self.max_slots:
206
+ self.current_slot = slot
207
+ self.current_slot_changed.emit(slot)
208
+ # Use a non-empty placeholder if the slot is empty
209
+ image_to_emit = self._images[slot] if self._images[slot] is not None and self._images[slot].size > 0 else np.zeros((1, 1), dtype=np.uint8)
210
+ self.image_changed.emit(slot, image_to_emit, self._metadata[slot])
211
+ print(f"ImageManager: Current slot set to {slot}.")
212
+ if self.parent and hasattr(self.parent, "update_slot_toolbar_highlight"):
213
+ self.parent.update_slot_toolbar_highlight()
214
+ else:
215
+ print(f"ImageManager: Slot {slot} is out of range.")
216
+
217
+
218
+ def add_image(self, slot, image, metadata):
219
+ """
220
+ Adds an image and its metadata to a specified slot.
221
+
222
+ :param slot: The slot number where the image will be added.
223
+ :param image: The image data (numpy array).
224
+ :param metadata: A dictionary containing metadata for the image.
225
+ """
226
+ if 0 <= slot < self.max_slots:
227
+ self._images[slot] = image
228
+ self._metadata[slot] = metadata
229
+ # Clear undo/redo stacks when a new image is added
230
+ self._undo_stacks[slot].clear()
231
+ self._redo_stacks[slot].clear()
232
+ self.current_slot = slot
233
+ self.image_changed.emit(slot, image, metadata)
234
+ print(f"ImageManager: Image added to slot {slot} with metadata.")
235
+ else:
236
+ print(f"ImageManager: Slot {slot} is out of range. Max slots: {self.max_slots}")
237
+ if metadata is None:
238
+ metadata = {}
239
+ metadata.setdefault("step_name", "Loaded")
240
+
241
+
242
+ def set_image(self, new_image, metadata, step_name=None):
243
+ slot = self.current_slot
244
+ if self._images[slot] is not None:
245
+ self._undo_stacks[slot].append(
246
+ (self._images[slot].copy(), self._metadata[slot].copy(), step_name or "Unnamed Step")
247
+ )
248
+ self._redo_stacks[slot].clear()
249
+ print(f"ImageManager: Previous image in slot {slot} pushed to undo stack.")
250
+ else:
251
+ print(f"ImageManager: No existing image in slot {slot} to push to undo stack.")
252
+
253
+ merged = self._merge_metadata(self._metadata[slot], metadata)
254
+ merged = self._attach_step_name(merged, step_name) # <-- add this
255
+ self._images[slot] = new_image
256
+ self._metadata[slot] = merged
257
+ self._emit_change(slot)
258
+ print(f"ImageManager: Image set for slot {slot} with merged metadata.")
259
+
260
+
261
+ def set_image_for_slot(self, slot, new_image, metadata, step_name=None):
262
+ if slot < 0 or slot >= self.max_slots:
263
+ print(f"ImageManager: Slot {slot} is out of range. Max slots={self.max_slots}")
264
+ return
265
+
266
+ if self._images[slot] is not None:
267
+ self._undo_stacks[slot].append(
268
+ (self._images[slot].copy(), self._metadata[slot].copy(), step_name or "Unnamed Step")
269
+ )
270
+ self._redo_stacks[slot].clear()
271
+ print(f"ImageManager: Previous image in slot {slot} pushed to undo stack.")
272
+ else:
273
+ print(f"ImageManager: No existing image in slot {slot} to push to undo stack.")
274
+
275
+ merged = self._merge_metadata(self._metadata[slot], metadata)
276
+ merged = self._attach_step_name(merged, step_name)
277
+ self._images[slot] = new_image
278
+ self._metadata[slot] = merged
279
+ self.current_slot = slot
280
+ self._emit_change(slot)
281
+ print(f"ImageManager: Image set for slot {slot} with merged metadata.")
282
+
283
+
284
+ @property
285
+ def image(self):
286
+ return self._images[self.current_slot]
287
+
288
+ @image.setter
289
+ def image(self, new_image):
290
+ """
291
+ Default image setter that stores undo as an unnamed step.
292
+ """
293
+ self.set_image_with_step_name(new_image, self._metadata[self.current_slot], step_name="Unnamed Step")
294
+
295
+ def set_image_with_step_name(self, new_image, metadata, step_name="Unnamed Step"):
296
+ slot = self.current_slot
297
+ if self._images[slot] is not None:
298
+ self._undo_stacks[slot].append(
299
+ (self._images[slot].copy(), self._metadata[slot].copy(), step_name)
300
+ )
301
+ self._redo_stacks[slot].clear()
302
+ print(f"ImageManager: Previous image in slot {slot} pushed to undo stack (step: {step_name})")
303
+ else:
304
+ print(f"ImageManager: No existing image in slot {slot} to push to undo stack.")
305
+
306
+ merged = self._merge_metadata(self._metadata[slot], metadata)
307
+ merged = self._attach_step_name(merged, step_name)
308
+ self._images[slot] = new_image
309
+ self._metadata[slot] = merged
310
+ self._emit_change(slot)
311
+ print(f"ImageManager: Image set for slot {slot} via set_image_with_step_name (merged).")
312
+
313
+
314
+ def get_slot_name(self, slot):
315
+ """
316
+ Returns the display name for a given slot.
317
+ If a slot has been renamed (stored under "slot_name" in metadata), that name is returned.
318
+ Otherwise, it returns "Slot X" (using 1-indexed numbering for display).
319
+ """
320
+ metadata = self._metadata.get(slot, {})
321
+ if 'slot_name' in metadata:
322
+ return metadata['slot_name']
323
+ else:
324
+ return f"Slot {slot}"
325
+
326
+
327
+ def set_metadata(self, metadata):
328
+ slot = self.current_slot
329
+ if self._images[slot] is not None:
330
+ self._undo_stacks[slot].append(
331
+ (self._images[slot].copy(), self._metadata[slot].copy())
332
+ )
333
+ self._redo_stacks[slot].clear()
334
+ print(f"ImageManager: Previous metadata in slot {slot} pushed to undo stack.")
335
+ else:
336
+ print(f"ImageManager: No existing image in slot {slot} to set metadata.")
337
+
338
+ merged = self._merge_metadata(self._metadata[slot], metadata)
339
+ self._metadata[slot] = merged
340
+ self._emit_change(slot)
341
+ print(f"ImageManager: Metadata set for slot {slot} (merged).")
342
+
343
+ def update_image(self, updated_image, metadata=None, slot=None):
344
+ if slot is None:
345
+ slot = self.current_slot
346
+
347
+ self._images[slot] = updated_image
348
+ if metadata is not None:
349
+ merged = self._merge_metadata(self._metadata[slot], metadata)
350
+ self._metadata[slot] = merged
351
+
352
+ self._emit_change(slot)
353
+
354
+ def can_undo(self, slot=None):
355
+ """
356
+ Determines if there are actions available to undo for the specified slot.
357
+
358
+ :param slot: (Optional) The slot number to check. If None, uses current_slot.
359
+ :return: True if undo is possible, False otherwise.
360
+ """
361
+ if slot is None:
362
+ slot = self.current_slot
363
+ if 0 <= slot < self.max_slots:
364
+ return len(self._undo_stacks[slot]) > 0
365
+ else:
366
+ print(f"ImageManager: Slot {slot} is out of range. Cannot check can_undo.")
367
+ return False
368
+
369
+ def can_redo(self, slot=None):
370
+ """
371
+ Determines if there are actions available to redo for the specified slot.
372
+
373
+ :param slot: (Optional) The slot number to check. If None, uses current_slot.
374
+ :return: True if redo is possible, False otherwise.
375
+ """
376
+ if slot is None:
377
+ slot = self.current_slot
378
+ if 0 <= slot < self.max_slots:
379
+ return len(self._redo_stacks[slot]) > 0
380
+ else:
381
+ print(f"ImageManager: Slot {slot} is out of range. Cannot check can_redo.")
382
+ return False
383
+
384
+ def undo(self, slot=None):
385
+ if slot is None:
386
+ slot = self.current_slot
387
+
388
+ if 0 <= slot < self.max_slots and self.can_undo(slot):
389
+ self._redo_stacks[slot].append(
390
+ (self._images[slot].copy(), self._metadata[slot].copy(), "Redo of Previous Step")
391
+ )
392
+
393
+ popped = self._undo_stacks[slot].pop()
394
+ if len(popped) == 3:
395
+ prev_img, prev_meta, step_name = popped
396
+ else:
397
+ prev_img, prev_meta = popped
398
+ step_name = "Unnamed Undo Step"
399
+
400
+ self._images[slot] = prev_img
401
+ self._metadata[slot] = prev_meta
402
+ self.image_changed.emit(slot, prev_img, prev_meta)
403
+
404
+ print(f"ImageManager: Undo performed on slot {slot}: {step_name}")
405
+ return step_name
406
+ else:
407
+ print(f"ImageManager: Cannot perform undo on slot {slot}.")
408
+ return None
409
+
410
+
411
+
412
+ def redo(self, slot=None):
413
+ if slot is None:
414
+ slot = self.current_slot
415
+
416
+ if 0 <= slot < self.max_slots and self.can_redo(slot):
417
+ self._undo_stacks[slot].append(
418
+ (self._images[slot].copy(), self._metadata[slot].copy(), "Undo of Redone Step")
419
+ )
420
+
421
+ popped = self._redo_stacks[slot].pop()
422
+ if len(popped) == 3:
423
+ redo_img, redo_meta, step_name = popped
424
+ else:
425
+ redo_img, redo_meta = popped
426
+ step_name = "Unnamed Redo Step"
427
+
428
+ self._images[slot] = redo_img
429
+ self._metadata[slot] = redo_meta
430
+ self.image_changed.emit(slot, redo_img, redo_meta)
431
+
432
+ print(f"ImageManager: Redo performed on slot {slot}: {step_name}")
433
+ return step_name
434
+ else:
435
+ print(f"ImageManager: Cannot perform redo on slot {slot}.")
436
+ return None
437
+
438
+ def get_history_image(self, slot: int, index: int):
439
+ """
440
+ Get a specific image from the undo stack (not applied, just for preview).
441
+ :param slot: Slot number.
442
+ :param index: Index from the bottom (0 = oldest).
443
+ """
444
+ if 0 <= slot < self.max_slots:
445
+ stack = self._undo_stacks[slot]
446
+ if 0 <= index < len(stack):
447
+ img, meta, _ = stack[index] if len(stack[index]) == 3 else (*stack[index], "Unnamed")
448
+ return img.copy(), meta.copy()
449
+ return None, None
450
+
451
+ def get_image_for_slot(self, slot: int) -> Optional[np.ndarray]:
452
+ """Return the image stored in slot, or None if empty."""
453
+ return self._images.get(slot)
454
+
455
+ class MaskManager(QObject):
456
+ """
457
+ Manages masks and tracks whether a mask is applied to the image.
458
+ """
459
+ mask_changed = pyqtSignal(int, np.ndarray) # Signal to notify mask changes (slot, mask)
460
+ applied_mask_changed = pyqtSignal(int, np.ndarray) # Signal for applied mask updates
461
+
462
+ def __init__(self, max_slots=5):
463
+ super().__init__()
464
+ self.max_slots = max_slots
465
+ self._masks = {i: None for i in range(max_slots)} # Store masks for each slot
466
+ self.applied_mask_slot = None # Slot from which the mask is applied
467
+ self.applied_mask = None # Currently applied mask (numpy array)
468
+
469
+ def set_mask(self, slot, mask):
470
+ """
471
+ Sets the mask for a specific slot.
472
+ """
473
+ if 0 <= slot < self.max_slots:
474
+ self._masks[slot] = mask
475
+ self.mask_changed.emit(slot, mask)
476
+
477
+ def get_mask(self, slot):
478
+ """
479
+ Retrieves the mask from a specific slot.
480
+ """
481
+ return self._masks.get(slot, None)
482
+
483
+ def clear_applied_mask(self):
484
+ """
485
+ Clears the currently applied mask and emits an empty mask.
486
+ """
487
+ self.applied_mask_slot = None
488
+ self.applied_mask = None
489
+
490
+ # Emit an empty mask instead of None
491
+ empty_mask = np.zeros((1, 1), dtype=np.uint8)
492
+ self.applied_mask_changed.emit(-1, empty_mask) # Signal that no mask is applied
493
+
494
+ print("Applied mask cleared.")
495
+
496
+
497
+
498
+ def apply_mask_from_slot(self, slot):
499
+ """
500
+ Applies the mask from the specified slot.
501
+ """
502
+ if slot in self._masks and self._masks[slot] is not None:
503
+ self.applied_mask_slot = slot
504
+ self.applied_mask = self._masks[slot]
505
+ self.applied_mask_changed.emit(slot, self.applied_mask)
506
+ print(f"Mask from slot {slot} applied.")
507
+ else:
508
+ print(f"Mask from slot {slot} cannot be applied (empty).")
509
+
510
+ def get_applied_mask(self):
511
+ """
512
+ Retrieves the currently applied mask.
513
+ """
514
+ return self.applied_mask
515
+
516
+ def get_applied_mask_slot(self):
517
+ """
518
+ Retrieves the slot from which the currently applied mask originated.
519
+ """
520
+ return self.applied_mask_slot
521
+
522
+ def _finalize_loaded_image(arr: np.ndarray) -> np.ndarray:
523
+ """Ensure float32 [finite], C-contiguous for downstream Qt/Numba."""
524
+ if arr is None:
525
+ return None
526
+ # Replace NaN/Inf (can appear after BSCALE/BZERO math)
527
+ arr = np.nan_to_num(arr, nan=0.0, posinf=1.0, neginf=0.0)
528
+ # Force float32 + C-order (copies if needed; detaches from memmap)
529
+ return np.asarray(arr, dtype=np.float32, order="C")
530
+
531
+ def list_fits_extensions(path: str) -> dict:
532
+ """
533
+ Return a dict {extname_or_index: {"index": i, "shape": shape, "dtype": dtype}} for all IMAGE HDUs.
534
+ extname_or_index prefers the HDU name (uppercased) when present, otherwise the numeric index.
535
+ """
536
+ if path.lower().endswith(('.fits.gz', '.fit.gz')):
537
+ with gzip.open(path, 'rb') as f:
538
+ buf = BytesIO(f.read())
539
+ hdul = fits.open(buf, memmap=False)
540
+ else:
541
+ hdul = fits.open(path, memmap=False)
542
+
543
+ info = {}
544
+ with hdul as hdul:
545
+ for i, hdu in enumerate(hdul):
546
+ if getattr(hdu, 'data', None) is None:
547
+ continue
548
+ if not hasattr(hdu, 'data'):
549
+ continue
550
+ key = (hdu.name or str(i)).upper()
551
+ try:
552
+ shp = tuple(hdu.data.shape)
553
+ dt = hdu.data.dtype
554
+ info[key] = {"index": i, "shape": shp, "dtype": str(dt)}
555
+ except Exception:
556
+ pass
557
+ return info
558
+
559
+
560
+ def load_fits_extension(path: str, key: str | int):
561
+ """
562
+ Load a single IMAGE HDU (by extname or index) as float32 in [0..1] (like load_image does).
563
+ Returns (image: np.ndarray, header: fits.Header, bit_depth: str, is_mono: bool).
564
+ """
565
+ if path.lower().endswith(('.fits.gz', '.fit.gz')):
566
+ with gzip.open(path, 'rb') as f:
567
+ buf = BytesIO(f.read())
568
+ hdul = fits.open(buf, memmap=False)
569
+ else:
570
+ hdul = fits.open(path, memmap=False)
571
+
572
+ with hdul as hdul:
573
+ # resolve key
574
+ if isinstance(key, str):
575
+ # find first matching extname (case-insensitive)
576
+ idx = None
577
+ for i, hdu in enumerate(hdul):
578
+ if (hdu.name or '').upper() == key.upper():
579
+ idx = i; break
580
+ if idx is None:
581
+ raise KeyError(f"Extension '{key}' not found in {path}")
582
+ else:
583
+ idx = int(key)
584
+
585
+ hdu = hdul[idx]
586
+ data = hdu.data
587
+ if data is None:
588
+ raise ValueError(f"HDU {key} has no image data")
589
+
590
+ # normalize like your load_image
591
+ import numpy as np
592
+ if data.dtype == np.uint8:
593
+ bit_depth = "8-bit"; img = data.astype(np.float32) / 255.0
594
+ elif data.dtype == np.uint16:
595
+ bit_depth = "16-bit"; img = data.astype(np.float32) / 65535.0
596
+ elif data.dtype == np.uint32:
597
+ bit_depth = "32-bit unsigned";
598
+ bzero = hdu.header.get('BZERO', 0); bscale = hdu.header.get('BSCALE', 1)
599
+ img = data.astype(np.float32) * bscale + bzero
600
+ elif data.dtype == np.int32:
601
+ bit_depth = "32-bit signed";
602
+ bzero = hdu.header.get('BZERO', 0); bscale = hdu.header.get('BSCALE', 1)
603
+ img = data.astype(np.float32) * bscale + bzero
604
+ elif data.dtype == np.float32:
605
+ bit_depth = "32-bit floating point"; img = np.array(data, dtype=np.float32, copy=True, order="C")
606
+ else:
607
+ raise ValueError(f"Unsupported FITS extension dtype: {data.dtype}")
608
+
609
+ img = np.squeeze(img)
610
+ if img.dtype == np.float32 and img.size and img.max() > 1.0:
611
+ img = img / float(img.max())
612
+
613
+ if img.ndim == 2:
614
+ is_mono = True
615
+ elif img.ndim == 3 and img.shape[0] == 3 and img.shape[1] > 1 and img.shape[2] > 1:
616
+ img = np.transpose(img, (1, 2, 0)); is_mono = False
617
+ elif img.ndim == 3 and img.shape[-1] == 3:
618
+ is_mono = False
619
+ else:
620
+ raise ValueError(f"Unsupported FITS ext dimensions: {img.shape}")
621
+
622
+ from .image_manager import _finalize_loaded_image # or adjust import if needed
623
+ img = _finalize_loaded_image(img)
624
+ return img, hdu.header, bit_depth, is_mono
625
+
626
+
627
+ def _normalize_to_float(image_u16: np.ndarray) -> tuple[np.ndarray, str, bool]:
628
+ """Normalize uint16/uint8 arrays to float32 [0,1] and detect mono."""
629
+ if image_u16.dtype == np.uint16:
630
+ bit_depth = "16-bit"
631
+ img = image_u16.astype(np.float32) / 65535.0
632
+ elif image_u16.dtype == np.uint8:
633
+ bit_depth = "8-bit"
634
+ img = image_u16.astype(np.float32) / 255.0
635
+ else:
636
+ bit_depth = str(image_u16.dtype)
637
+ img = image_u16.astype(np.float32)
638
+ mx = float(img.max()) if img.size else 1.0
639
+ if mx > 0:
640
+ img /= mx
641
+ is_mono = (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1)
642
+ if img.ndim == 3 and img.shape[2] == 1:
643
+ img = img[:, :, 0]
644
+ return img, bit_depth, is_mono
645
+
646
+
647
+ def _try_load_raw_with_rawpy(filename: str, allow_thumb_preview: bool = True, debug_thumb: bool = True):
648
+ """
649
+ Open RAW with rawpy/LibRaw and return a normalized [0,1] Bayer mosaic (mono=True).
650
+ Fallbacks:
651
+ 1) raw.raw_image_visible
652
+ 2) raw.raw_image
653
+ 3) raw.postprocess(...) → linear 16-bit RGB (no auto-bright), normalized to [0,1]
654
+ 4) Embedded JPEG preview (8-bit)
655
+ Returns: (image, header, bit_depth, is_mono)
656
+ """
657
+ if rawpy is None:
658
+ raise RuntimeError("rawpy not installed")
659
+
660
+ def _normalize_bayer(arr: np.ndarray, raw) -> tuple[np.ndarray, fits.Header, str, bool]:
661
+ arr = arr.astype(np.float32, copy=False)
662
+ blk = float(np.mean(getattr(raw, "black_level_per_channel", [0, 0, 0, 0])))
663
+ wht = float(getattr(raw, "white_level", max(1.0, float(arr.max()))))
664
+ arr = np.clip(arr - blk, 0, None)
665
+ scale = max(1.0, wht - blk)
666
+ arr /= scale
667
+
668
+ hdr = fits.Header()
669
+ # Fill from raw.metadata first
670
+ hdr = _fill_hdr_from_raw_metadata(raw, hdr)
671
+
672
+ # Optional extra bits you already had:
673
+ try:
674
+ if getattr(raw, "camera_whitebalance", None) is not None:
675
+ hdr["CAMWB0"] = float(raw.camera_whitebalance[0])
676
+ except Exception:
677
+ pass
678
+
679
+ for key, attr in (("EXPTIME", "shutter"),
680
+ ("ISO", "iso_speed"),
681
+ ("FOCAL", "focal_len"),
682
+ ("TIMESTAMP", "timestamp")):
683
+ if hasattr(raw, attr) and key not in hdr:
684
+ hdr[key] = getattr(raw, attr)
685
+
686
+ try:
687
+ cfa = getattr(raw, "raw_colors_visible", None)
688
+ if cfa is not None:
689
+ mapping = {0: "R", 1: "G", 2: "B"}
690
+ desc = "".join(mapping.get(int(v), "?") for v in cfa.flatten()[:4])
691
+ hdr["CFA"] = desc
692
+ except Exception:
693
+ pass
694
+
695
+ return arr, hdr, "16-bit", True # Bayer mosaic → mono=True
696
+
697
+ # Attempt 1: visible mosaic
698
+ try:
699
+ with rawpy.imread(filename) as raw:
700
+ bayer = raw.raw_image_visible
701
+ if bayer is None:
702
+ raise RuntimeError("raw_image_visible is None")
703
+ return _normalize_bayer(bayer, raw)
704
+ except Exception as e1:
705
+ print(f"[rawpy] full decode (visible) failed: {e1}")
706
+
707
+ # Attempt 2: full raw mosaic (no explicit unpack)
708
+ try:
709
+ with rawpy.imread(filename) as raw:
710
+ bayer = getattr(raw, "raw_image", None)
711
+ if bayer is None:
712
+ raise RuntimeError("raw_image is None")
713
+ return _normalize_bayer(bayer, raw)
714
+ except Exception as e2:
715
+ print(f"[rawpy] second pass (raw_image) failed: {e2}")
716
+
717
+ # Attempt 3: safe demosaic (linear, no auto-bright) → RGB float32 [0,1]
718
+ try:
719
+ with rawpy.imread(filename) as raw:
720
+ rgb16 = raw.postprocess(
721
+ output_bps=16,
722
+ gamma=(1, 1), # keep linear
723
+ no_auto_bright=True, # avoid LibRaw “lift”
724
+ use_camera_wb=False, # neutral; you can set True if desired
725
+ output_color=rawpy.ColorSpace.raw,
726
+ user_flip=0,
727
+ )
728
+ img = rgb16.astype(np.float32) / 65535.0 # HxWx3
729
+
730
+ hdr = fits.Header()
731
+ hdr = _fill_hdr_from_raw_metadata(raw, hdr)
732
+ hdr["RAW_DEM"] = (True, "LibRaw postprocess; linear, no auto-bright, RAW color")
733
+
734
+ return img, hdr, "16-bit demosaiced", False
735
+ except Exception as e3:
736
+ print(f"[rawpy] postprocess fallback failed: {e3}")
737
+
738
+ # Attempt 4: embedded JPEG preview
739
+ if allow_thumb_preview:
740
+ try:
741
+ with rawpy.imread(filename) as raw2:
742
+ th = raw2.extract_thumb()
743
+ if debug_thumb:
744
+ kind = getattr(th.format, "name", str(th.format))
745
+ print(f"[rawpy] extract_thumb: kind={kind}, bytes={len(th.data)}")
746
+ from io import BytesIO as _BytesIO
747
+ pil = Image.open(_BytesIO(th.data))
748
+ if pil.mode not in ("RGB", "L"):
749
+ pil = pil.convert("RGB")
750
+ img = np.array(pil, dtype=np.float32) / 255.0
751
+ is_mono = (img.ndim == 2)
752
+
753
+ hdr = fits.Header()
754
+ hdr = _fill_hdr_from_raw_metadata(raw2, hdr)
755
+ hdr["RAW_PREV"] = (True, "Embedded JPEG preview (no linear RAW data)")
756
+
757
+ return img, hdr, "8-bit preview (JPEG from RAW)", is_mono
758
+ except Exception as e4:
759
+ print(f"[rawpy] extract_thumb failed: {e4}")
760
+
761
+
762
+ raise RuntimeError("RAW decode failed (rawpy).")
763
+
764
+ import os
765
+ import datetime
766
+
767
+ import exifread
768
+
769
+ RAW_EXTS = ('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')
770
+
771
+
772
+ def _is_raw_file(path: str) -> bool:
773
+ return path.lower().endswith(RAW_EXTS)
774
+
775
+
776
+ def _parse_fraction_or_float(val) -> float | None:
777
+ """
778
+ Accepts things like '1/125', '0.008', 8, or exifread Ratio objects.
779
+ Returns float seconds or None.
780
+ """
781
+ s = str(val).strip()
782
+ if not s:
783
+ return None
784
+ try:
785
+ # exifread often gives a single Ratio or list of one Ratio
786
+ if hasattr(val, "num") and hasattr(val, "den"):
787
+ return float(val.num) / float(val.den)
788
+ if isinstance(val, (list, tuple)) and val and hasattr(val[0], "num"):
789
+ r = val[0]
790
+ return float(r.num) / float(r.den)
791
+
792
+ if '/' in s:
793
+ num, den = s.split('/', 1)
794
+ return float(num) / float(den)
795
+ return float(s)
796
+ except Exception:
797
+ return None
798
+
799
+
800
+ def _parse_exif_datetime(dt_str: str) -> str | None:
801
+ """
802
+ EXIF typically: 'YYYY:MM:DD HH:MM:SS'.
803
+ Returns ISO-like 'YYYY-MM-DDTHH:MM:SS' or None.
804
+ """
805
+ s = str(dt_str).strip()
806
+ if not s:
807
+ return None
808
+
809
+ # exifread sometimes formats as "YYYY:MM:DD HH:MM:SS"
810
+ try:
811
+ date_part, time_part = s.split(' ', 1)
812
+ y, m, d = date_part.split(':', 2)
813
+ return f"{int(y):04d}-{int(m):02d}-{int(d):02d}T{time_part}"
814
+ except Exception:
815
+ return None
816
+
817
+
818
+ def _ensure_minimal_header(header, file_path: str) -> fits.Header:
819
+ """
820
+ Guarantee we have a FITS Header. For non-FITS sources (TIFF/PNG/JPG/etc),
821
+ synthesize a basic header and fill DATE-OBS from file mtime if missing.
822
+ """
823
+ if header is None:
824
+ header = fits.Header()
825
+ header["SIMPLE"] = True
826
+ header["BITPIX"] = 16
827
+ header["CREATOR"] = "SetiAstroSuite"
828
+
829
+ # Try to provide DATE-OBS if not present
830
+ if "DATE-OBS" not in header:
831
+ try:
832
+ ts = os.path.getmtime(file_path)
833
+ dt = datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
834
+ header["DATE-OBS"] = (
835
+ dt.isoformat(timespec="seconds"),
836
+ "File modification time (UTC) used as DATE-OBS"
837
+ )
838
+ except Exception:
839
+ pass
840
+
841
+ return header
842
+
843
+
844
+ def _enrich_header_from_exif(header: fits.Header, file_path: str) -> fits.Header:
845
+ """
846
+ Merge EXIF metadata from a RAW file into an existing header without
847
+ blowing away other keys. Only fills keys that are missing.
848
+ """
849
+ header = header.copy() if header is not None else fits.Header()
850
+ header.setdefault("SIMPLE", True)
851
+ header.setdefault("BITPIX", 16)
852
+ header.setdefault("CREATOR", "SetiAstroSuite")
853
+
854
+ try:
855
+ with open(file_path, "rb") as f:
856
+ tags = exifread.process_file(f, details=False)
857
+ except Exception:
858
+ # Can't read EXIF → just return what we have
859
+ return header
860
+
861
+ def get_tag(*names):
862
+ for n in names:
863
+ t = tags.get(n)
864
+ if t is not None:
865
+ return t
866
+ return None
867
+
868
+ # Exposure time
869
+ exptime_tag = get_tag("EXIF ExposureTime", "EXIF ShutterSpeedValue")
870
+ if exptime_tag and "EXPTIME" not in header:
871
+ val = _parse_fraction_or_float(exptime_tag.values)
872
+ if val is not None:
873
+ header["EXPTIME"] = (float(val), "Exposure time (s) from EXIF")
874
+
875
+ # ISO
876
+ iso_tag = get_tag("EXIF ISOSpeedRatings", "EXIF PhotographicSensitivity")
877
+ if iso_tag and "ISO" not in header:
878
+ try:
879
+ header["ISO"] = (int(str(iso_tag.values)), "ISO from EXIF")
880
+ except Exception:
881
+ header["ISO"] = (str(iso_tag.values), "ISO from EXIF")
882
+
883
+ # Date/time
884
+ date_tag = get_tag(
885
+ "EXIF DateTimeOriginal",
886
+ "EXIF DateTimeDigitized",
887
+ "Image DateTime",
888
+ )
889
+ if date_tag and "DATE-OBS" not in header:
890
+ dt = _parse_exif_datetime(date_tag.values)
891
+ if dt:
892
+ header["DATE-OBS"] = (dt, "Start of exposure (camera local time)")
893
+
894
+ # Aperture
895
+ fnum_tag = get_tag("EXIF FNumber")
896
+ if fnum_tag and "FNUMBER" not in header:
897
+ val = _parse_fraction_or_float(fnum_tag.values)
898
+ if val is not None:
899
+ header["FNUMBER"] = (float(val), "F-number (aperture)")
900
+
901
+ # Focal length
902
+ fl_tag = get_tag("EXIF FocalLength")
903
+ if fl_tag and "FOCALLEN" not in header:
904
+ val = _parse_fraction_or_float(fl_tag.values)
905
+ if val is not None:
906
+ header["FOCALLEN"] = (float(val), "Focal length (mm)")
907
+
908
+ # Camera make/model
909
+ make_tag = get_tag("Image Make")
910
+ model_tag = get_tag("Image Model")
911
+ cam_parts = []
912
+ if make_tag:
913
+ cam_parts.append(str(make_tag.values).strip())
914
+ if model_tag:
915
+ cam_parts.append(str(model_tag.values).strip())
916
+ camera_str = " ".join(p for p in cam_parts if p)
917
+ if camera_str:
918
+ header.setdefault("INSTRUME", camera_str) # instrument / camera
919
+ header.setdefault("CAMERA", camera_str) # custom keyword
920
+
921
+ return header
922
+
923
+ def _fill_hdr_from_raw_metadata(raw, hdr: fits.Header | None = None) -> fits.Header:
924
+ """
925
+ Merge LibRaw/rawpy metadata into hdr (EXPTIME, ISO, FNUMBER, FOCALLEN, camera, DATE-OBS).
926
+ Does NOT overwrite existing keys.
927
+ """
928
+ if hdr is None:
929
+ hdr = fits.Header()
930
+
931
+ try:
932
+ m = raw.metadata
933
+ except Exception:
934
+ return hdr
935
+
936
+ # Exposure time (seconds)
937
+ if hasattr(m, "exposure") and m.exposure is not None and "EXPTIME" not in hdr:
938
+ try:
939
+ hdr["EXPTIME"] = (float(m.exposure), "Exposure time (s) from RAW metadata")
940
+ except Exception:
941
+ pass
942
+
943
+ # ISO
944
+ if hasattr(m, "iso") and m.iso is not None and "ISO" not in hdr:
945
+ try:
946
+ hdr["ISO"] = (int(m.iso), "ISO from RAW metadata")
947
+ except Exception:
948
+ hdr["ISO"] = (str(m.iso), "ISO from RAW metadata")
949
+
950
+ # Aperture
951
+ if hasattr(m, "aperture") and m.aperture is not None and "FNUMBER" not in hdr:
952
+ try:
953
+ hdr["FNUMBER"] = (float(m.aperture), "F-number (aperture) from RAW metadata")
954
+ except Exception:
955
+ pass
956
+
957
+ # Focal length (mm)
958
+ if hasattr(m, "focal_len") and m.focal_len is not None and "FOCALLEN" not in hdr:
959
+ try:
960
+ hdr["FOCALLEN"] = (float(m.focal_len), "Focal length (mm) from RAW metadata")
961
+ except Exception:
962
+ pass
963
+
964
+ # Camera make/model
965
+ make = getattr(m, "make", None)
966
+ model = getattr(m, "model", None)
967
+ cam_parts = []
968
+ if make:
969
+ cam_parts.append(str(make).strip())
970
+ if model:
971
+ cam_parts.append(str(model).strip())
972
+ camera_str = " ".join(p for p in cam_parts if p)
973
+ if camera_str:
974
+ hdr.setdefault("INSTRUME", camera_str)
975
+ hdr.setdefault("CAMERA", camera_str)
976
+
977
+ # Timestamp → DATE-OBS in UTC
978
+ if hasattr(m, "timestamp") and m.timestamp and "DATE-OBS" not in hdr:
979
+ try:
980
+ dt = datetime.datetime.fromtimestamp(m.timestamp, tz=datetime.timezone.utc)
981
+ hdr["DATE-OBS"] = (dt.isoformat(timespec="seconds"), "RAW timestamp (UTC)")
982
+ except Exception:
983
+ pass
984
+
985
+ return hdr
986
+
987
+ from astropy.wcs import WCS
988
+
989
+ import ast
990
+
991
+ def _coerce_fits_value(v):
992
+ if v is None:
993
+ return None
994
+ if isinstance(v, (int, float, bool)):
995
+ return v
996
+ s = str(v).strip()
997
+
998
+ # PixInsight T/F
999
+ if s in ("T", "TRUE", "True", "true"):
1000
+ return True
1001
+ if s in ("F", "FALSE", "False", "false"):
1002
+ return False
1003
+
1004
+ # int?
1005
+ try:
1006
+ if s.isdigit() or (s.startswith(("+", "-")) and s[1:].isdigit()):
1007
+ return int(s)
1008
+ except Exception:
1009
+ pass
1010
+
1011
+ # float? (handles 8.9669e+03 etc)
1012
+ try:
1013
+ return float(s)
1014
+ except Exception:
1015
+ pass
1016
+
1017
+ # strip quotes
1018
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
1019
+ s = s[1:-1]
1020
+ return s
1021
+
1022
+
1023
+ def xisf_fits_header_from_meta(image_meta: dict, file_meta: dict | None = None) -> fits.Header:
1024
+ """
1025
+ Robustly extract FITSKeywords from XISF wrappers matching your real structure.
1026
+
1027
+ Handles:
1028
+ - image_meta["FITSKeywords"]
1029
+ - image_meta["xisf_meta"]["FITSKeywords"]
1030
+ - image_meta["xisf_meta"] as a stringified dict containing FITSKeywords
1031
+ - file_meta FITSKeywords (only fills missing keys)
1032
+ """
1033
+ hdr = fits.Header()
1034
+
1035
+ def _get_kw_dict(meta: dict):
1036
+ if not isinstance(meta, dict):
1037
+ return None
1038
+
1039
+ # direct
1040
+ kw = meta.get("FITSKeywords")
1041
+ if isinstance(kw, dict):
1042
+ return kw
1043
+
1044
+ # nested dict
1045
+ xm = meta.get("xisf_meta")
1046
+ if isinstance(xm, dict):
1047
+ kw = xm.get("FITSKeywords")
1048
+ if isinstance(kw, dict):
1049
+ return kw
1050
+
1051
+ # stringified dict
1052
+ if isinstance(xm, str) and "FITSKeywords" in xm:
1053
+ try:
1054
+ xm2 = ast.literal_eval(xm)
1055
+ if isinstance(xm2, dict) and isinstance(xm2.get("FITSKeywords"), dict):
1056
+ return xm2["FITSKeywords"]
1057
+ except Exception:
1058
+ pass
1059
+
1060
+ return None
1061
+
1062
+ def _apply_kw_dict(kw: dict, only_missing: bool):
1063
+ for key, entries in kw.items():
1064
+ try:
1065
+ k = str(key).strip()
1066
+ if not k:
1067
+ continue
1068
+ if only_missing and (k in hdr):
1069
+ continue
1070
+
1071
+ # your structure: KEY: [ {"value": "...", "comment": "..."} ]
1072
+ val = None
1073
+ com = None
1074
+ if isinstance(entries, list) and entries:
1075
+ e0 = entries[0]
1076
+ if isinstance(e0, dict):
1077
+ val = _coerce_fits_value(e0.get("value"))
1078
+ com = e0.get("comment")
1079
+ else:
1080
+ val = _coerce_fits_value(e0)
1081
+ elif isinstance(entries, dict):
1082
+ val = _coerce_fits_value(entries.get("value"))
1083
+ com = entries.get("comment")
1084
+ else:
1085
+ val = _coerce_fits_value(entries)
1086
+
1087
+ if com is not None:
1088
+ hdr[k] = (val, str(com))
1089
+ else:
1090
+ hdr[k] = val
1091
+ except Exception:
1092
+ pass
1093
+
1094
+ # First: image-level FITSKeywords (authoritative)
1095
+ kw_img = _get_kw_dict(image_meta) or {}
1096
+ if isinstance(kw_img, dict):
1097
+ _apply_kw_dict(kw_img, only_missing=False)
1098
+
1099
+ # Then: file-level FITSKeywords (fill gaps only)
1100
+ kw_file = _get_kw_dict(file_meta or {}) or {}
1101
+ if isinstance(kw_file, dict):
1102
+ _apply_kw_dict(kw_file, only_missing=True)
1103
+
1104
+ return hdr
1105
+
1106
+
1107
+ def attach_wcs_to_metadata(meta: dict, hdr: fits.Header | dict | None) -> dict:
1108
+ """
1109
+ If hdr contains WCS, create an astropy.wcs.WCS and stash in metadata.
1110
+ """
1111
+ if not hdr or meta is None:
1112
+ return meta or {}
1113
+
1114
+ if meta.get("wcs") is not None:
1115
+ return meta # already present
1116
+
1117
+ try:
1118
+ fhdr = hdr if isinstance(hdr, fits.Header) else fits.Header(hdr)
1119
+
1120
+ # 🔹 Drop problematic long-string cards that upset astropy.wcs
1121
+ # FILE_PATH is the one we saw erroring, but you can add more here if needed.
1122
+ if "FILE_PATH" in fhdr:
1123
+ val = str(fhdr["FILE_PATH"])
1124
+ if len(val) > 68: # FITS cards max 80 chars, ~68 for value
1125
+ print(f"⚠️ Dropping FILE_PATH from WCS header build (too long: {len(val)} chars)")
1126
+ del fhdr["FILE_PATH"]
1127
+
1128
+ # Optional: also run through our invalid-card stripper
1129
+ fhdr = _drop_invalid_cards(fhdr)
1130
+
1131
+ # --- Quick sanity: no basic WCS → bail quietly ---
1132
+ core_keys = ("CTYPE1", "CTYPE2", "CRVAL1", "CRVAL2")
1133
+ if not all(k in fhdr for k in core_keys):
1134
+ return meta
1135
+
1136
+ # --- Attempt 1: basic WCS ---
1137
+ try:
1138
+ w = WCS(fhdr, relax=True)
1139
+ except Exception as e1:
1140
+ print(f"⚠️ WCS(fhdr, relax=True) failed: {e1}")
1141
+ print("⚠️ Retrying WCS with naxis=2 (ignore extra axis).")
1142
+ try:
1143
+ w = WCS(fhdr, relax=True, naxis=2)
1144
+ except Exception as e2:
1145
+ print(f"⚠️ WCS(..., naxis=2) failed: {e2}")
1146
+ print("⚠️ Retrying WCS with naxis=2 after stripping SIP terms.")
1147
+ try:
1148
+ fhdr2 = fhdr.copy()
1149
+ for k in list(fhdr2.keys()):
1150
+ if k.startswith(("A_", "B_", "AP_", "BP_", "A_ORDER", "B_ORDER")):
1151
+ del fhdr2[k]
1152
+ w = WCS(fhdr2, relax=True, naxis=2)
1153
+ except Exception as e3:
1154
+ print(f"⚠️ WCS(..., naxis=2) after SIP-strip failed: {e3}")
1155
+ raise e1 # re-raise original
1156
+
1157
+ if getattr(w, "has_celestial", False):
1158
+ meta["wcs"] = w
1159
+ meta["wcs_header"] = w.to_header(relax=True)
1160
+ meta["wcsaxes"] = int(getattr(w, "naxis", getattr(w.wcs, "naxis", 2)))
1161
+ print(f"🔷 Attached astropy WCS into metadata (naxis={meta['wcsaxes']})")
1162
+ else:
1163
+ print("⚠️ WCS parsed but has no celestial axes; not attaching.")
1164
+
1165
+ except Exception as e:
1166
+ print(f"⚠️ Failed to build WCS from header: {e}")
1167
+
1168
+ return meta
1169
+
1170
+
1171
+ def load_image(filename, max_retries=3, wait_seconds=3, return_metadata: bool = False):
1172
+ """
1173
+ Loads an image from the specified filename with support for various formats.
1174
+ If a "buffer is too small for requested array" error occurs, it retries loading after waiting.
1175
+
1176
+ Parameters:
1177
+ filename (str): Path to the image file.
1178
+ max_retries (int): Number of times to retry on specific buffer error.
1179
+ wait_seconds (int): Seconds to wait before retrying.
1180
+
1181
+ Returns:
1182
+ tuple: (image, original_header, bit_depth, is_mono) or (None, None, None, None) on failure.
1183
+ """
1184
+ attempt = 0
1185
+ while attempt <= max_retries:
1186
+ try:
1187
+ image = None # Ensure 'image' is explicitly declared
1188
+ bit_depth = None
1189
+ is_mono = False
1190
+ original_header = None
1191
+
1192
+ # --- Unified FITS handling ---
1193
+ if filename.lower().endswith(('.fits', '.fit', '.fits.gz', '.fit.gz', '.fz', '.fz')):
1194
+ # Use get_valid_header to retrieve the header and extension index.
1195
+ original_header, ext_index = get_valid_header(filename)
1196
+
1197
+
1198
+ # Open the file appropriately.
1199
+ if filename.lower().endswith(('.fits.gz', '.fit.gz')):
1200
+ print(f"Loading compressed FITS file: {filename}")
1201
+ with gzip.open(filename, 'rb') as f:
1202
+ file_content = f.read()
1203
+ hdul = fits.open(BytesIO(file_content))
1204
+ else:
1205
+ if filename.lower().endswith(('.fz', '.fz')):
1206
+ print(f"Loading Rice-compressed FITS file: {filename}")
1207
+ else:
1208
+ print(f"Loading FITS file: {filename}")
1209
+ hdul = fits.open(filename)
1210
+
1211
+ with hdul as hdul:
1212
+ # Retrieve image data from the extension indicated by get_valid_header.
1213
+ image_data = hdul[ext_index].data
1214
+ if image_data is None:
1215
+ raise ValueError(f"No image data found in FITS file in extension {ext_index}.")
1216
+
1217
+ # Ensure native byte order
1218
+ if image_data.dtype.byteorder not in ('=', '|'):
1219
+ image_data = image_data.astype(image_data.dtype.newbyteorder('='))
1220
+
1221
+ # ---------------------------------------------------------------------
1222
+ # 1) Detect bit depth and convert to float32
1223
+ # ---------------------------------------------------------------------
1224
+ if image_data.dtype == np.uint8:
1225
+ bit_depth = "8-bit"
1226
+ print("Identified 8-bit FITS image.")
1227
+ image = image_data.astype(np.float32) / 255.0
1228
+
1229
+ elif image_data.dtype == np.uint16:
1230
+ bit_depth = "16-bit"
1231
+ print("Identified 16-bit FITS image.")
1232
+ image = image_data.astype(np.float32) / 65535.0
1233
+
1234
+ elif image_data.dtype == np.int16:
1235
+ bit_depth = "16-bit signed"
1236
+ print("Identified 16-bit signed FITS image.")
1237
+ bzero = original_header.get('BZERO', 0)
1238
+ bscale = original_header.get('BSCALE', 1)
1239
+ data = image_data.astype(np.float32) * float(bscale) + float(bzero)
1240
+
1241
+ if bzero != 0 or bscale != 1:
1242
+ image = np.clip(data / 65535.0, 0.0, 1.0)
1243
+ else:
1244
+ dmin = float(data.min())
1245
+ dmax = float(data.max())
1246
+ if dmax > dmin:
1247
+ image = (data - dmin) / (dmax - dmin)
1248
+ else:
1249
+ image = np.zeros_like(data, dtype=np.float32)
1250
+
1251
+ elif image_data.dtype == np.int8:
1252
+ bit_depth = "8-bit signed"
1253
+ print("Identified 8-bit signed FITS image.")
1254
+ # Use BSCALE/BZERO if present, else generic normalize
1255
+ bzero = original_header.get('BZERO', 0)
1256
+ bscale = original_header.get('BSCALE', 1)
1257
+ data = image_data.astype(np.float32) * float(bscale) + float(bzero)
1258
+ dmin = float(data.min())
1259
+ dmax = float(data.max())
1260
+ if dmax > dmin:
1261
+ image = (data - dmin) / (dmax - dmin)
1262
+ else:
1263
+ image = np.zeros_like(data, dtype=np.float32)
1264
+
1265
+ elif image_data.dtype == np.int32:
1266
+ bit_depth = "32-bit signed"
1267
+ print("Identified 32-bit signed FITS image.")
1268
+ bzero = float(original_header.get('BZERO', 0))
1269
+ bscale = float(original_header.get('BSCALE', 1))
1270
+
1271
+ # Rebuild physical values
1272
+ data = image_data.astype(np.float32) * bscale + bzero
1273
+
1274
+ # Normalize to [0,1] for the viewer / pipeline
1275
+ dmin = float(data.min())
1276
+ dmax = float(data.max())
1277
+ if dmax > dmin:
1278
+ image = (data - dmin) / (dmax - dmin)
1279
+ else:
1280
+ image = np.zeros_like(data, dtype=np.float32)
1281
+
1282
+
1283
+ elif image_data.dtype == np.uint32:
1284
+ bit_depth = "32-bit unsigned"
1285
+ print("Identified 32-bit unsigned FITS image.")
1286
+
1287
+ bzero = float(original_header.get('BZERO', 0))
1288
+ bscale = float(original_header.get('BSCALE', 1))
1289
+
1290
+ if bzero == 0.0 and bscale == 1.0:
1291
+ # Literal 0..2^32-1 data → map directly to [0,1]
1292
+ image = image_data.astype(np.float32) / 4294967295.0
1293
+ else:
1294
+ # Non-trivial BSCALE/BZERO: reconstruct physical values, then normalize
1295
+ data = image_data.astype(np.float32) * bscale + bzero
1296
+ dmin = float(data.min())
1297
+ dmax = float(data.max())
1298
+ if dmax > dmin:
1299
+ image = (data - dmin) / (dmax - dmin)
1300
+ else:
1301
+ image = np.zeros_like(data, dtype=np.float32)
1302
+
1303
+
1304
+ elif image_data.dtype == np.float32:
1305
+ bit_depth = "32-bit floating point"
1306
+ print("Identified 32-bit floating point FITS image.")
1307
+ image = np.array(image_data, dtype=np.float32, copy=True, order="C")
1308
+
1309
+ elif image_data.dtype == np.float64:
1310
+ bit_depth = "64-bit floating point"
1311
+ print("Identified 64-bit floating point FITS image.")
1312
+ # Keep dynamic range as-is, just cast down to float32
1313
+ image = image_data.astype(np.float32, copy=True)
1314
+
1315
+ else:
1316
+ raise ValueError(f"Unsupported FITS data type: {image_data.dtype}")
1317
+
1318
+
1319
+ # ---------------------------------------------------------------------
1320
+ # 2) Squeeze out any singleton dimensions (fix weird NAXIS combos)
1321
+ # ---------------------------------------------------------------------
1322
+ image = np.squeeze(image)
1323
+
1324
+ #if image.dtype == np.float32:
1325
+ # max_val = image.max()
1326
+ # if max_val > 1.0:
1327
+ # print(f"Detected float image with max value {max_val:.3f} > 1.0; rescales to [0,1]")
1328
+ # image = image / max_val
1329
+ # ---------------------------------------------------------------------
1330
+ # 3) Interpret final shape to decide if mono or color
1331
+ # ---------------------------------------------------------------------
1332
+ if image.ndim == 2:
1333
+ is_mono = True
1334
+ elif image.ndim == 3:
1335
+ if image.shape[0] == 3 and image.shape[1] > 1 and image.shape[2] > 1:
1336
+ image = np.transpose(image, (1, 2, 0))
1337
+ is_mono = False
1338
+ elif image.shape[-1] == 3:
1339
+ is_mono = False
1340
+ else:
1341
+ raise ValueError(f"Unsupported 3D shape after squeeze: {image.shape}")
1342
+ else:
1343
+ raise ValueError(f"Unsupported FITS dimensions after squeeze: {image.shape}")
1344
+
1345
+ print(f"Loaded FITS image: shape={image.shape}, bit depth={bit_depth}, mono={is_mono}")
1346
+ image = _finalize_loaded_image(image)
1347
+
1348
+ # NEW: build metadata + attach WCS
1349
+ meta = {
1350
+ "file_path": filename,
1351
+ "fits_header": original_header,
1352
+ "bit_depth": bit_depth,
1353
+ "mono": is_mono,
1354
+ }
1355
+ meta = attach_wcs_to_metadata(meta, original_header)
1356
+
1357
+ if return_metadata:
1358
+ return image, original_header, bit_depth, is_mono, meta
1359
+ return image, original_header, bit_depth, is_mono
1360
+
1361
+
1362
+ elif filename.lower().endswith(('.tiff', '.tif')):
1363
+ print(f"Loading TIFF file: {filename}")
1364
+ image_data = tiff.imread(filename)
1365
+ print(f"Loaded TIFF image with dtype: {image_data.dtype}")
1366
+
1367
+ if image_data.dtype == np.uint8:
1368
+ bit_depth = "8-bit"
1369
+ image = image_data.astype(np.float32) / 255.0
1370
+
1371
+ elif image_data.dtype == np.uint16:
1372
+ bit_depth = "16-bit"
1373
+ image = image_data.astype(np.float32) / 65535.0
1374
+
1375
+ elif image_data.dtype == np.int16:
1376
+ bit_depth = "16-bit signed"
1377
+ print("Detected 16-bit signed TIFF image.")
1378
+ data = image_data.astype(np.float32)
1379
+ dmin = float(data.min())
1380
+ dmax = float(data.max())
1381
+ if dmax > dmin:
1382
+ image = (data - dmin) / (dmax - dmin)
1383
+ else:
1384
+ image = np.zeros_like(data, dtype=np.float32)
1385
+
1386
+ elif image_data.dtype == np.uint32:
1387
+ bit_depth = "32-bit unsigned"
1388
+ image = image_data.astype(np.float32) / 4294967295.0
1389
+
1390
+ elif image_data.dtype == np.int32:
1391
+ bit_depth = "32-bit signed"
1392
+ print("Detected 32-bit signed TIFF image.")
1393
+ data = image_data.astype(np.float32)
1394
+ dmin = float(data.min())
1395
+ dmax = float(data.max())
1396
+ if dmax > dmin:
1397
+ image = (data - dmin) / (dmax - dmin)
1398
+ else:
1399
+ image = np.zeros_like(data, dtype=np.float32)
1400
+
1401
+ elif image_data.dtype == np.float32:
1402
+ bit_depth = "32-bit floating point"
1403
+ image = image_data.astype(np.float32)
1404
+
1405
+ elif image_data.dtype == np.float64:
1406
+ bit_depth = "64-bit floating point"
1407
+ image = image_data.astype(np.float32)
1408
+
1409
+ elif np.issubdtype(image_data.dtype, np.integer):
1410
+ # Generic integer fallback (int8, etc.)
1411
+ info = np.iinfo(image_data.dtype)
1412
+ bit_depth = f"{info.bits}-bit signed"
1413
+ print(f"Generic int TIFF; normalizing by [{info.min}, {info.max}]")
1414
+ data = image_data.astype(np.float32)
1415
+ # shift to [0, max-min] then normalize
1416
+ data -= info.min
1417
+ image = data / float(info.max - info.min)
1418
+
1419
+ else:
1420
+ raise ValueError("Unsupported TIFF format!")
1421
+
1422
+
1423
+ #if image.dtype == np.float32:
1424
+ # max_val = image.max()
1425
+ # if max_val > 1.0:
1426
+ # print(f"Detected float image with max value {max_val:.3f} > 1.0; rescales to [0,1]")
1427
+ # image = image / max_val
1428
+
1429
+ # Handle mono or RGB TIFFs
1430
+ if image_data.ndim == 2: # Mono
1431
+ is_mono = True
1432
+ elif image_data.ndim == 3 and image_data.shape[2] == 3: # RGB
1433
+ is_mono = False
1434
+ else:
1435
+ raise ValueError("Unsupported TIFF image dimensions!")
1436
+
1437
+ elif filename.lower().endswith('.xisf'):
1438
+ print(f"Loading XISF file: {filename}")
1439
+ xisf = XISF(filename)
1440
+
1441
+ # Read image data (assuming the first image in the XISF file)
1442
+ image_data = xisf.read_image(0) # Adjust the index if multiple images are present
1443
+
1444
+ # Retrieve metadata
1445
+ image_meta = xisf.get_images_metadata()[0] # Assuming single image
1446
+ file_meta = xisf.get_file_metadata()
1447
+
1448
+
1449
+ # Here we check the maximum pixel value to determine bit depth
1450
+ # --- Detect the bit depth by dtype ---
1451
+ if image_data.dtype == np.uint8:
1452
+ bit_depth = "8-bit"
1453
+ print("Debug: Detected 8-bit dtype. Normalizing by 255.")
1454
+ image = image_data.astype(np.float32) / 255.0
1455
+
1456
+ elif image_data.dtype == np.uint16:
1457
+ bit_depth = "16-bit"
1458
+ print("Debug: Detected 16-bit dtype. Normalizing by 65535.")
1459
+ image = image_data.astype(np.float32) / 65535.0
1460
+
1461
+ elif image_data.dtype == np.uint32:
1462
+ bit_depth = "32-bit unsigned"
1463
+ print("Debug: Detected 32-bit unsigned dtype. Normalizing by 4294967295.")
1464
+ image = image_data.astype(np.float32) / 4294967295.0
1465
+
1466
+ elif image_data.dtype == np.float32 or image_data.dtype == np.float64:
1467
+ bit_depth = "32-bit floating point"
1468
+ print("Debug: Detected float dtype. Casting to float32 (no normalization).")
1469
+ image = image_data.astype(np.float32)
1470
+
1471
+ else:
1472
+ raise ValueError(f"Unsupported XISF data type: {image_data.dtype}")
1473
+
1474
+ # Handle mono or RGB XISF
1475
+ if image_data.ndim == 2:
1476
+ # We know it's mono. Already normalized in `image`.
1477
+ is_mono = True
1478
+ # If you really want to store it in an RGB shape:
1479
+ #image = np.stack([image] * 3, axis=-1)
1480
+
1481
+ elif image_data.ndim == 3 and image_data.shape[2] == 1:
1482
+ # It's mono with shape (H, W, 1)
1483
+ is_mono = True
1484
+ # Squeeze the normalized image, not the original image_data
1485
+ image = np.squeeze(image, axis=2)
1486
+ # If you want an RGB shape, you can do:
1487
+ #image = np.stack([image] * 3, axis=-1)
1488
+
1489
+ elif image_data.ndim == 3 and image_data.shape[2] == 3:
1490
+ is_mono = False
1491
+ # We already stored the normalized float32 data in `image`.
1492
+ # So no change needed if it’s already shape (H, W, 3).
1493
+
1494
+ else:
1495
+ raise ValueError("Unsupported XISF image dimensions!")
1496
+
1497
+ # ─── Build FITS header from PixInsight XISFProperties ─────────────────
1498
+ # ─── Build FITS header from XISFProperties, then fallback to FITSKeywords & Pixel‐Scale ─────────────────
1499
+
1500
+ def _dump_astrometric_keys(props, image_meta, file_meta):
1501
+ print("🔎 [XISF] XISFProperties AstrometricSolution-related keys:")
1502
+ for k in sorted(props.keys()):
1503
+ if "AstrometricSolution" in k or "SplineWorldTransformation" in k or "SIP" in k:
1504
+ print(" ", k)
1505
+
1506
+ def _dump_fk(meta, tag):
1507
+ fk = meta.get("FITSKeywords", {})
1508
+ if not fk:
1509
+ print(f"🔎 [XISF] No FITSKeywords in {tag}")
1510
+ return
1511
+ sip_keys = [k for k in fk.keys() if k.startswith(("A_", "B_", "AP_", "BP_", "A_ORDER", "B_ORDER"))]
1512
+ print(f"🔎 [XISF] FITSKeywords SIP-ish keys in {tag}: {sorted(sip_keys)}")
1513
+
1514
+ _dump_fk(image_meta, "image_meta")
1515
+ _dump_fk(file_meta, "file_meta")
1516
+ # Build base header from FITSKeywords (typed) first
1517
+ hdr = xisf_fits_header_from_meta(image_meta, file_meta) # your new helper
1518
+ _filled = set(hdr.keys())
1519
+
1520
+ # Now get XISFProperties (for PI grids + fallback)
1521
+ props = (image_meta.get("XISFProperties", {}) or
1522
+ file_meta.get("XISFProperties", {}) or {})
1523
+ #_filled = set()
1524
+
1525
+ # 1) PixInsight astrometric solution (fallback only)
1526
+ # 1) PixInsight astrometric solution (fallback only)
1527
+ try:
1528
+ if not all(k in hdr for k in ("CRPIX1","CRPIX2","CRVAL1","CRVAL2")):
1529
+ ref_img = props['PCL:AstrometricSolution:ReferenceImageCoordinates']['value']
1530
+ ref_sky = props['PCL:AstrometricSolution:ReferenceCelestialCoordinates']['value']
1531
+
1532
+ # Some files store extra values; only first two are CRPIX/CRVAL
1533
+ im0, im1 = float(ref_img[0]), float(ref_img[1])
1534
+ w0, w1 = float(ref_sky[0]), float(ref_sky[1])
1535
+
1536
+ hdr['CRPIX1'], hdr['CRPIX2'] = im0, im1
1537
+ hdr['CRVAL1'], hdr['CRVAL2'] = w0, w1
1538
+ hdr.setdefault('CTYPE1', 'RA---TAN-SIP')
1539
+ hdr.setdefault('CTYPE2', 'DEC--TAN-SIP')
1540
+ _filled |= {'CRPIX1','CRPIX2','CRVAL1','CRVAL2','CTYPE1','CTYPE2'}
1541
+ print("🔷 Injected CRPIX/CRVAL from XISFProperties (fallback)")
1542
+ except KeyError:
1543
+ pass
1544
+ except Exception as e:
1545
+ print(f"⚠️ XISFProperties CRPIX/CRVAL parse failed; skipping. Reason: {e}")
1546
+
1547
+ # 2) CD matrix (fallback only)
1548
+ try:
1549
+ if not all(k in hdr for k in ("CD1_1","CD1_2","CD2_1","CD2_2")):
1550
+ lin = np.asarray(props['PCL:AstrometricSolution:LinearTransformationMatrix']['value'], float)
1551
+ hdr['CD1_1'], hdr['CD1_2'] = float(lin[0,0]), float(lin[0,1])
1552
+ hdr['CD2_1'], hdr['CD2_2'] = float(lin[1,0]), float(lin[1,1])
1553
+ _filled |= {'CD1_1','CD1_2','CD2_1','CD2_2'}
1554
+ print("🔷 Injected CD matrix from XISFProperties (fallback)")
1555
+ except KeyError:
1556
+ pass
1557
+
1558
+ # 3) SIP polynomial fitting (CORRECTED for PI ImageToNative grids)
1559
+ def _try_inject_sip_from_fitskeywords(hdr, image_meta, file_meta):
1560
+ """If PI already wrote SIP in FITSKeywords, pull it in verbatim."""
1561
+ def _lookup_kw(key):
1562
+ for meta in (image_meta, file_meta):
1563
+ fk = meta.get("FITSKeywords", {})
1564
+ if key in fk and fk[key]:
1565
+ return fk[key][0].get("value")
1566
+ return None
1567
+
1568
+ a_order = _lookup_kw("A_ORDER")
1569
+ b_order = _lookup_kw("B_ORDER")
1570
+ if a_order is None or b_order is None:
1571
+ return False
1572
+
1573
+ try:
1574
+ a_order = int(a_order); b_order = int(b_order)
1575
+ except Exception:
1576
+ return False
1577
+
1578
+ hdr["A_ORDER"] = a_order
1579
+ hdr["B_ORDER"] = b_order
1580
+
1581
+ # pull all A_i_j / B_i_j that exist in FITSKeywords
1582
+ for order_key, prefix in (("A_ORDER", "A_"), ("B_ORDER", "B_")):
1583
+ o = int(hdr[order_key])
1584
+ for i in range(o + 1):
1585
+ for j in range(o + 1 - i):
1586
+ if i == 0 and j == 0:
1587
+ continue
1588
+ k = f"{prefix}{i}_{j}"
1589
+ v = _lookup_kw(k)
1590
+ if v is not None:
1591
+ try:
1592
+ hdr[k] = float(v)
1593
+ except Exception:
1594
+ pass
1595
+
1596
+ # if CTYPE isn't SIP already, make it SIP
1597
+ hdr.setdefault("CTYPE1", "RA---TAN-SIP")
1598
+ hdr.setdefault("CTYPE2", "DEC--TAN-SIP")
1599
+
1600
+ print(f"🔷 Injected SIP directly from FITSKeywords (A/B order {a_order})")
1601
+ return True
1602
+ # 3a) First try to import SIP directly if PI already gave it to us
1603
+ if _try_inject_sip_from_fitskeywords(hdr, image_meta, file_meta):
1604
+ _filled |= {"A_ORDER", "B_ORDER"} | {k for k in hdr.keys() if k.startswith(("A_", "B_"))}
1605
+ else:
1606
+ try:
1607
+ def _find_image_to_native_grid(props):
1608
+ """
1609
+ Return a dict-like pg with keys GridX/GridY/Delta/Rect in the same shape
1610
+ your SIP fitter expects.
1611
+
1612
+ PI can store this either as:
1613
+ A) one nested property:
1614
+ ...:PointGridInterpolation:ImageToNative -> dict with GridX/GridY/etc
1615
+ B) separate leaf properties:
1616
+ ...:ImageToNative:GridX, :GridY, :Delta, :Rect
1617
+ """
1618
+ base = "PCL:AstrometricSolution:SplineWorldTransformation:PointGridInterpolation:ImageToNative"
1619
+
1620
+ # Case A: full nested block exists
1621
+ if base in props:
1622
+ return props[base]
1623
+
1624
+ # Case B: leaf keys exist — rebuild a pseudo-block
1625
+ gx_key = base + ":GridX"
1626
+ gy_key = base + ":GridY"
1627
+ if gx_key in props and gy_key in props:
1628
+ pg = {
1629
+ "GridX": props[gx_key],
1630
+ "GridY": props[gy_key],
1631
+ "Delta": props.get(base + ":Delta", {"value": 1.0}),
1632
+ "Rect": props.get(base + ":Rect", {"value": None}),
1633
+ }
1634
+ return pg
1635
+
1636
+ return None
1637
+
1638
+ pg = _find_image_to_native_grid(props)
1639
+ if pg is None:
1640
+ raise KeyError("No ImageToNative grid found")
1641
+ gx = np.asarray(pg['GridX']['value'], dtype=float)
1642
+ gy = np.asarray(pg['GridY']['value'], dtype=float)
1643
+ delta = float(pg.get('Delta', {}).get('value', 1.0))
1644
+ rect = np.asarray(pg.get('Rect', {}).get('value', [0,0,gx.shape[1]*delta, gx.shape[0]*delta]), dtype=float)
1645
+ x0, y0 = rect[0], rect[1]
1646
+
1647
+ # grid gives native-plane coords (deg) at sampled pixels
1648
+ # build pixel coord for each grid sample
1649
+ rows, cols = gx.shape
1650
+ xs = x0 + np.arange(cols, dtype=float) * delta
1651
+ ys = y0 + np.arange(rows, dtype=float) * delta
1652
+ Xs, Ys = np.meshgrid(xs, ys)
1653
+
1654
+ # u,v relative to CRPIX for SIP basis
1655
+ crpix1, crpix2 = float(hdr['CRPIX1']), float(hdr['CRPIX2'])
1656
+ u = (Xs - crpix1).ravel()
1657
+ v = (Ys - crpix2).ravel()
1658
+
1659
+ # linear native-plane coords from CD
1660
+ CD = np.array([[hdr['CD1_1'], hdr['CD1_2']],
1661
+ [hdr['CD2_1'], hdr['CD2_2']]], dtype=float)
1662
+ duv = np.vstack([u, v]) # 2×N
1663
+ native_lin = CD @ duv # deg residuals predicted by linear model
1664
+ native_true = np.vstack([gx.ravel(), gy.ravel()]) # deg native coords from PI grids
1665
+
1666
+ # residual in native plane (deg)
1667
+ d_native = native_true - native_lin # 2×N in degrees
1668
+
1669
+ # convert residual degrees back to pixel residuals (dp) using inv(CD)
1670
+ try:
1671
+ invCD = np.linalg.inv(CD)
1672
+ except np.linalg.LinAlgError:
1673
+ invCD = np.linalg.pinv(CD)
1674
+ d_pix = invCD @ d_native # 2×N in pixels
1675
+ dx_pix = d_pix[0]
1676
+ dy_pix = d_pix[1]
1677
+
1678
+ # robust mask to avoid NaNs/infs
1679
+ m = np.isfinite(u) & np.isfinite(v) & np.isfinite(dx_pix) & np.isfinite(dy_pix)
1680
+ u = u[m]; v = v[m]; dx_pix = dx_pix[m]; dy_pix = dy_pix[m]
1681
+
1682
+ def fit_sip_pixels(u, v, dx, dy, order):
1683
+ terms = [(i,j) for i in range(order+1) for j in range(order+1-i) if (i,j)!=(0,0)]
1684
+ M = np.vstack([(u**i)*(v**j) for (i,j) in terms]).T
1685
+ a, *_ = np.linalg.lstsq(M, dx, rcond=None)
1686
+ b, *_ = np.linalg.lstsq(M, dy, rcond=None)
1687
+ rms = np.hypot(dx - M.dot(a), dy - M.dot(b)).std()
1688
+ return a, b, terms, rms
1689
+
1690
+ # cap order hard to avoid overfit; PI splines can be complex
1691
+ best = {'order':None, 'rms':np.inf}
1692
+
1693
+ for order in (2,3,4): # <=4 is plenty for real optics
1694
+ a, b, terms, rms = fit_sip_pixels(u, v, dx_pix, dy_pix, order)
1695
+ if rms < best['rms']:
1696
+ best.update(order=order, a=a, b=b, terms=terms, rms=rms)
1697
+
1698
+ o = best['order']
1699
+ hdr['A_ORDER'] = o; hdr['B_ORDER'] = o
1700
+ _filled |= {'A_ORDER','B_ORDER'}
1701
+
1702
+ for (i,j), coef in zip(best['terms'], best['a']):
1703
+ hdr[f'A_{i}_{j}'] = float(coef); _filled.add(f'A_{i}_{j}')
1704
+ for (i,j), coef in zip(best['terms'], best['b']):
1705
+ hdr[f'B_{i}_{j}'] = float(coef); _filled.add(f'B_{i}_{j}')
1706
+
1707
+ print(f"🔷 Injected SIP order {o} (from PI native grids), rms={best['rms']:.4g}px")
1708
+
1709
+ except KeyError:
1710
+ print("⚠️ No PI ImageToNative grid; skipping SIP")
1711
+ except Exception as e:
1712
+ print(f"⚠️ SIP fit failed; skipping SIP. Reason: {e}")
1713
+
1714
+
1715
+
1716
+ # Helper: look in FITSKeywords dicts
1717
+ def _lookup_kw(key):
1718
+ for meta in (image_meta, file_meta):
1719
+ fk = meta.get('FITSKeywords',{})
1720
+ if key in fk and fk[key]:
1721
+ return fk[key][0]['value']
1722
+ return None
1723
+
1724
+ # 4) Fallback WCS/CD from FITSKeywords
1725
+ for key in ('CRPIX1','CRPIX2','CRVAL1','CRVAL2','CTYPE1','CTYPE2',
1726
+ 'CD1_1','CD1_2','CD2_1','CD2_2'):
1727
+ if key not in hdr:
1728
+ v = _lookup_kw(key)
1729
+ if v is not None:
1730
+ hdr[key] = v
1731
+ _filled.add(key)
1732
+ print(f"🔷 Injected {key} from FITSKeywords")
1733
+
1734
+ # 5) Generic RA/DEC fallback
1735
+ if 'CRVAL1' not in hdr or 'CRVAL2' not in hdr:
1736
+ for ra_kw, dec_kw in (('RA','DEC'),('OBJCTRA','OBJCTDEC')):
1737
+ ra = _lookup_kw(ra_kw); dec = _lookup_kw(dec_kw)
1738
+ if ra and dec:
1739
+ try:
1740
+ ra_deg = float(ra); dec_deg = float(dec)
1741
+ except ValueError:
1742
+ from astropy.coordinates import Angle
1743
+ ra_deg = Angle(str(ra), unit='hourangle').degree
1744
+ dec_deg = Angle(str(dec), unit='deg').degree
1745
+ hdr['CRVAL1'], hdr['CRVAL2'] = ra_deg, dec_deg
1746
+ hdr.setdefault('CTYPE1','RA---TAN'); hdr.setdefault('CTYPE2','DEC--TAN')
1747
+ print(f"🔷 Fallback CRVAL from {ra_kw}/{dec_kw}")
1748
+ break
1749
+
1750
+ # 6) Pixel‐scale fallback → inject CDELT if no CD or CDELT
1751
+ if not any(k in hdr for k in ('CD1_1','CDELT1')):
1752
+ pix_arcsec = None
1753
+ for kw in ('PIXSCALE','SCALE'):
1754
+ val = _lookup_kw(kw)
1755
+ if val:
1756
+ pix_arcsec = float(val); break
1757
+ if pix_arcsec is None:
1758
+ xpsz = _lookup_kw('XPIXSZ'); foc = _lookup_kw('FOCALLEN')
1759
+ if xpsz and foc:
1760
+ pix_arcsec = float(xpsz)*1e-3/float(foc)*206265
1761
+ if pix_arcsec:
1762
+ degpix = pix_arcsec / 3600.0
1763
+ hdr['CDELT1'], hdr['CDELT2'] = -degpix, degpix
1764
+ print(f"🔷 Injected pixel scale {pix_arcsec:.3f}\"/px → CDELT={degpix:.6f}°")
1765
+
1766
+ # 7) Copy any remaining simple FITSKeywords
1767
+ for kw, vals in file_meta.get('FITSKeywords',{}).items():
1768
+ if kw in hdr: continue
1769
+ v = vals[0].get('value')
1770
+ if isinstance(v, (int,float,str)):
1771
+ hdr[kw] = v
1772
+
1773
+ # 8) Binning
1774
+ bx = int(_lookup_kw('XBINNING') or 1)
1775
+ by = int(_lookup_kw('YBINNING') or bx)
1776
+ if bx!=by: print(f"⚠️ Unequal binning {bx}×{by}, averaging")
1777
+ hdr['XBINNING'], hdr['YBINNING'] = bx, by
1778
+
1779
+ original_header = hdr
1780
+ print(f"Loaded XISF header with keys: {_filled}")
1781
+ image = _finalize_loaded_image(image)
1782
+
1783
+ # NEW: build metadata + attach WCS
1784
+ meta = {
1785
+ "file_path": filename,
1786
+ "fits_header": original_header, # your synthesized FITS header
1787
+ "bit_depth": bit_depth,
1788
+ "mono": is_mono,
1789
+ "xisf_meta": image_meta, # optional, handy for debugging later
1790
+ }
1791
+ meta = attach_wcs_to_metadata(meta, original_header)
1792
+
1793
+ if return_metadata:
1794
+ return image, original_header, bit_depth, is_mono, meta
1795
+ return image, original_header, bit_depth, is_mono
1796
+
1797
+ elif filename.lower().endswith(('.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.pef')):
1798
+ print(f"Loading RAW file: {filename}")
1799
+
1800
+ try:
1801
+ image, original_header, bit_depth, is_mono = _try_load_raw_with_rawpy(
1802
+ filename,
1803
+ allow_thumb_preview=True, # keep your current behavior
1804
+ debug_thumb=True
1805
+ )
1806
+
1807
+ if original_header is None:
1808
+ original_header = fits.Header()
1809
+
1810
+ # 🔹 Fold in EXIF, but only for keys missing from raw metadata
1811
+ original_header = _enrich_header_from_exif(original_header, filename)
1812
+
1813
+ # If preview path returned a minimal header, that's fine—upstream UI will message it
1814
+ if "preview" in str(bit_depth).lower():
1815
+ print("RAW decode failed; using embedded JPEG preview (non-linear, 8-bit).")
1816
+
1817
+ image = _finalize_loaded_image(image)
1818
+ return image, original_header, bit_depth, is_mono
1819
+
1820
+ except Exception as e_raw:
1821
+ print(f"rawpy failed: {e_raw}")
1822
+ raise
1823
+
1824
+
1825
+
1826
+ elif filename.lower().endswith('.png'):
1827
+ print(f"Loading PNG file: {filename}")
1828
+ img = Image.open(filename)
1829
+
1830
+ # Convert unsupported modes to RGB
1831
+ if img.mode not in ('L', 'RGB'):
1832
+ print(f"Unsupported PNG mode: {img.mode}, converting to RGB")
1833
+ img = img.convert("RGB")
1834
+
1835
+ # Convert image to numpy array and normalize pixel values to [0, 1]
1836
+ image = np.array(img, dtype=np.float32) / 255.0
1837
+ bit_depth = "8-bit"
1838
+
1839
+ # Determine if the image is grayscale or RGB
1840
+ if len(image.shape) == 2: # Grayscale image
1841
+ is_mono = True
1842
+ elif len(image.shape) == 3 and image.shape[2] == 3: # RGB image
1843
+ is_mono = False
1844
+ else:
1845
+ raise ValueError(f"Unsupported PNG dimensions: {image.shape}")
1846
+
1847
+ print(f"Loaded PNG image: shape={image.shape}, bit depth={bit_depth}, mono={is_mono}")
1848
+
1849
+ elif filename.lower().endswith(('.jpg', '.jpeg')):
1850
+ print(f"Loading JPG file: {filename}")
1851
+ img = Image.open(filename)
1852
+ if img.mode == 'L': # Grayscale
1853
+ is_mono = True
1854
+ image = np.array(img, dtype=np.float32) / 255.0
1855
+ bit_depth = "8-bit"
1856
+ elif img.mode == 'RGB': # RGB
1857
+ is_mono = False
1858
+ image = np.array(img, dtype=np.float32) / 255.0
1859
+ bit_depth = "8-bit"
1860
+ else:
1861
+ raise ValueError("Unsupported JPG format!")
1862
+
1863
+ else:
1864
+ raise ValueError("Unsupported file format!")
1865
+
1866
+ print(f"Loaded image: shape={image.shape}, bit depth={bit_depth}, mono={is_mono}")
1867
+ image = _finalize_loaded_image(image)
1868
+ return image, original_header, bit_depth, is_mono
1869
+
1870
+ except Exception as e:
1871
+ error_message = str(e)
1872
+ if "buffer is too small for requested array" in error_message.lower():
1873
+ if attempt < max_retries:
1874
+ attempt += 1
1875
+ print(f"Error reading image {filename}: {e}")
1876
+ print(f"Retrying in {wait_seconds} seconds... (Attempt {attempt}/{max_retries})")
1877
+ time.sleep(wait_seconds)
1878
+ continue # Retry loading the image
1879
+ else:
1880
+ print(f"Error reading image {filename} after {max_retries} retries: {e}")
1881
+ else:
1882
+ print(f"Error reading image {filename}: {e}")
1883
+ return None, None, None, None
1884
+
1885
+ def get_valid_header(file_path):
1886
+ """
1887
+ Opens the FITS file (handling compressed files as needed), finds the first HDU
1888
+ with image data, and then searches through all HDUs for additional keywords (e.g. BAYERPAT).
1889
+ Returns a composite header (a copy of the image HDU header updated with extra keywords)
1890
+ and the extension index of the image data.
1891
+ """
1892
+ # Open file appropriately for compressed files
1893
+ if file_path.lower().endswith(('.fits.gz', '.fit.gz')):
1894
+
1895
+ with gzip.open(file_path, 'rb') as f:
1896
+ file_content = f.read()
1897
+ hdul = fits.open(BytesIO(file_content))
1898
+ else:
1899
+
1900
+ hdul = fits.open(file_path)
1901
+
1902
+ with hdul as hdul:
1903
+ image_hdu = None
1904
+ image_index = None
1905
+ # First, find the HDU that contains image data
1906
+ for i, hdu in enumerate(hdul):
1907
+
1908
+ if hdu.data is not None:
1909
+ image_hdu = hdu
1910
+ image_index = i
1911
+
1912
+ break
1913
+ if image_hdu is None:
1914
+ raise ValueError("No image data found in FITS file.")
1915
+
1916
+ # Start with a copy of the image HDU header
1917
+ composite_header = image_hdu.header.copy()
1918
+ # Drop any cards that will raise VerifyError later (e.g. broken TELESCOP)
1919
+ composite_header = _drop_invalid_cards(composite_header)
1920
+
1921
+
1922
+ # Now search all HDUs for extra keywords (e.g. BAYERPAT)
1923
+ for i, hdu in enumerate(hdul):
1924
+ if 'BAYERPAT' in hdu.header:
1925
+ composite_header['BAYERPAT'] = hdu.header['BAYERPAT']
1926
+
1927
+ break
1928
+
1929
+ return composite_header, image_index
1930
+
1931
+ def get_bayer_header(file_path):
1932
+ """
1933
+ Iterates through all HDUs in the FITS file (handling compressed files if needed)
1934
+ to find a header that contains the 'BAYERPAT' keyword.
1935
+ Returns the header if found, otherwise None.
1936
+ """
1937
+
1938
+
1939
+ try:
1940
+ # Check for compressed files first.
1941
+ if file_path.lower().endswith(('.fits.gz', '.fit.gz')):
1942
+ with gzip.open(file_path, 'rb') as f:
1943
+ file_content = f.read()
1944
+ hdul = fits.open(BytesIO(file_content))
1945
+ else:
1946
+ hdul = fits.open(file_path)
1947
+ with hdul as hdul:
1948
+ for hdu in hdul:
1949
+ if 'BAYERPAT' in hdu.header:
1950
+ return hdu.header
1951
+ except Exception as e:
1952
+ print(f"Error in get_bayer_header: {e}")
1953
+ return None
1954
+
1955
+
1956
+ _BIT_DEPTH_STRS = {
1957
+ "8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"
1958
+ }
1959
+
1960
+ def _normalize_format(fmt: str) -> str:
1961
+ """Normalize an input format/extension (with or without leading dot)."""
1962
+ f = (fmt or "").lower().lstrip(".")
1963
+ if f == "jpeg": f = "jpg"
1964
+ if f == "tiff": f = "tif"
1965
+ return f
1966
+
1967
+ def _is_header_obj(h) -> bool:
1968
+ """True if h looks like a FITS header-ish object."""
1969
+ return isinstance(h, (fits.Header, dict))
1970
+
1971
+ def _looks_like_xisf_header(hdr) -> bool:
1972
+ """Detects XISF-origin metadata safely without assuming .keys() exists."""
1973
+ try:
1974
+ if isinstance(hdr, fits.Header):
1975
+ # fits.Header supports .keys() and iteration
1976
+ for k in hdr.keys():
1977
+ if isinstance(k, str) and k.startswith("XISF:"):
1978
+ return True
1979
+ elif isinstance(hdr, dict):
1980
+ for k in hdr.keys():
1981
+ if isinstance(k, str) and k.startswith("XISF:"):
1982
+ return True
1983
+ except Exception:
1984
+ pass
1985
+ return False
1986
+
1987
+ def _has_xisf_props(meta) -> bool:
1988
+ """True if meta appears to contain XISFProperties (dict or list-of-dicts)."""
1989
+ try:
1990
+ if isinstance(meta, dict):
1991
+ return "XISFProperties" in meta
1992
+ if isinstance(meta, list) and meta and isinstance(meta[0], dict):
1993
+ return "XISFProperties" in meta[0]
1994
+ except Exception:
1995
+ pass
1996
+ return False
1997
+
1998
+ import logging
1999
+
2000
+ log = logging.getLogger(__name__)
2001
+
2002
+ def save_image(img_array,
2003
+ filename,
2004
+ original_format,
2005
+ bit_depth=None,
2006
+ original_header=None,
2007
+ is_mono=False,
2008
+ image_meta=None,
2009
+ file_meta=None,
2010
+ wcs_header=None): # 🔥 NEW
2011
+ """
2012
+ Save an image array to a file in the specified format and bit depth.
2013
+ - Robust to mis-ordered positional args (header/bit_depth swap).
2014
+ - Never calls .keys() on a non-mapping.
2015
+ - FITS always written as float32; header is sanitized or synthesized.
2016
+ """
2017
+ # 🔊 Debug what we got
2018
+ if isinstance(original_header, fits.Header):
2019
+ log.debug(
2020
+ "[legacy_save_image] original_header: fits.Header with %d cards, first few:",
2021
+ len(original_header)
2022
+ )
2023
+ for i, card in enumerate(original_header.cards):
2024
+ if i >= 20:
2025
+ log.debug("[legacy_save_image] ... (truncated)")
2026
+ break
2027
+ log.debug("[legacy_save_image] %-10s = %r", card.keyword, card.value)
2028
+ else:
2029
+ log.debug(
2030
+ "[legacy_save_image] original_header is %r, wcs_header is %r",
2031
+ type(original_header), type(wcs_header),
2032
+ )
2033
+
2034
+ # --- Fix for accidental positional arg swap: (header <-> bit_depth) -----
2035
+ if isinstance(original_header, str) and original_header in _BIT_DEPTH_STRS and _is_header_obj(bit_depth):
2036
+ original_header, bit_depth = bit_depth, original_header
2037
+
2038
+ # Normalize format and extension
2039
+ fmt = _normalize_format(original_format)
2040
+ base, _ = os.path.splitext(filename)
2041
+ out_ext = "jpg" if fmt == "jpg" else ("tif" if fmt == "tif" else fmt)
2042
+ if not filename.lower().endswith(f".{out_ext}"):
2043
+ filename = f"{base}.{out_ext}"
2044
+
2045
+ # Ensure correct byte order for numpy data
2046
+ img_array = ensure_native_byte_order(img_array)
2047
+
2048
+ # Detect XISF origin (safely)
2049
+ is_xisf = _looks_like_xisf_header(original_header) or _has_xisf_props(image_meta)
2050
+
2051
+ try:
2052
+ # ---------------------------------------------------------------------
2053
+ # PNG/JPG — always write 8-bit preview-style data
2054
+ # ---------------------------------------------------------------------
2055
+ if fmt == "png":
2056
+ img = Image.fromarray((np.clip(img_array, 0, 1) * 255).astype(np.uint8))
2057
+ img.save(filename)
2058
+ print(f"Saved 8-bit PNG image to: {filename}")
2059
+ return
2060
+
2061
+ if fmt == "jpg":
2062
+ img = Image.fromarray((np.clip(img_array, 0, 1) * 255).astype(np.uint8))
2063
+ # You can pass quality=95, subsampling=0 if you want
2064
+ img.save(filename)
2065
+ print(f"Saved 8-bit JPG image to: {filename}")
2066
+ return
2067
+
2068
+ # ---------------------------------------------------------------------
2069
+ # TIFF — honor bit depth (fallback to 32-bit floating point)
2070
+ # ---------------------------------------------------------------------
2071
+ if fmt in ("tif",):
2072
+ bd = bit_depth or "32-bit floating point"
2073
+ if bd == "8-bit":
2074
+ tiff.imwrite(filename, (np.clip(img_array, 0, 1) * 255).astype(np.uint8))
2075
+ elif bd == "16-bit":
2076
+ tiff.imwrite(filename, (np.clip(img_array, 0, 1) * 65535).astype(np.uint16))
2077
+ elif bd == "32-bit unsigned":
2078
+ tiff.imwrite(filename, (np.clip(img_array, 0, 1) * 4294967295).astype(np.uint32))
2079
+ elif bd == "32-bit floating point":
2080
+ tiff.imwrite(filename, img_array.astype(np.float32))
2081
+ else:
2082
+ raise ValueError(f"Unsupported bit depth for TIFF: {bd}")
2083
+ print(f"Saved {bd} TIFF image to: {filename}")
2084
+ return
2085
+
2086
+ # ---------------------------------------------------------------------
2087
+ # FITS — honor bit_depth like TIFF (8/16/32U/32f)
2088
+ # ---------------------------------------------------------------------
2089
+ if fmt in ("fit", "fits"):
2090
+ # Helper to build minimal valid header
2091
+ def _minimal_fits_header(h: int, w: int, is_rgb: bool) -> fits.Header:
2092
+ hdr = fits.Header()
2093
+ hdr["SIMPLE"] = True
2094
+ hdr["BITPIX"] = -32 # will be overridden below if needed
2095
+ hdr["NAXIS"] = 3 if is_rgb else 2
2096
+ hdr["NAXIS1"] = w
2097
+ hdr["NAXIS2"] = h
2098
+ if is_rgb:
2099
+ hdr["NAXIS3"] = 3
2100
+ hdr["BSCALE"] = 1.0
2101
+ hdr["BZERO"] = 0.0
2102
+ hdr["CREATOR"] = "Seti Astro Suite Pro"
2103
+ hdr.add_history("Written by Seti Astro Suite Pro")
2104
+ return hdr
2105
+
2106
+ h, w = img_array.shape[:2]
2107
+ is_rgb = (img_array.ndim == 3 and img_array.shape[2] == 3)
2108
+
2109
+ # Build base header (same as before)
2110
+ if is_xisf:
2111
+ fits_header = fits.Header()
2112
+ props = None
2113
+ if isinstance(image_meta, dict):
2114
+ props = image_meta.get("XISFProperties")
2115
+ elif isinstance(image_meta, list) and image_meta and isinstance(image_meta[0], dict):
2116
+ props = image_meta[0].get("XISFProperties")
2117
+ if isinstance(props, dict):
2118
+ try:
2119
+ if "PCL:AstrometricSolution:ReferenceCoordinates" in props:
2120
+ ra, dec = props["PCL:AstrometricSolution:ReferenceCoordinates"]["value"]
2121
+ fits_header["CRVAL1"] = ra
2122
+ fits_header["CRVAL2"] = dec
2123
+ if "PCL:AstrometricSolution:ReferenceLocation" in props:
2124
+ cx, cy = props["PCL:AstrometricSolution:ReferenceLocation"]["value"]
2125
+ fits_header["CRPIX1"] = cx
2126
+ fits_header["CRPIX2"] = cy
2127
+ if "PCL:AstrometricSolution:PixelSize" in props:
2128
+ px = props["PCL:AstrometricSolution:PixelSize"]["value"]
2129
+ fits_header["CDELT1"] = -px / 3600.0
2130
+ fits_header["CDELT2"] = px / 3600.0
2131
+ if "PCL:AstrometricSolution:LinearTransformationMatrix" in props:
2132
+ m = props["PCL:AstrometricSolution:LinearTransformationMatrix"]["value"]
2133
+ fits_header["CD1_1"] = m[0][0]; fits_header["CD1_2"] = m[0][1]
2134
+ fits_header["CD2_1"] = m[1][0]; fits_header["CD2_2"] = m[1][1]
2135
+ except Exception:
2136
+ pass
2137
+ fits_header.setdefault("CTYPE1", "RA---TAN")
2138
+ fits_header.setdefault("CTYPE2", "DEC--TAN")
2139
+
2140
+ elif _is_header_obj(original_header):
2141
+ # Clean up invalid cards
2142
+ if isinstance(original_header, fits.Header):
2143
+ safe_header = _drop_invalid_cards(original_header)
2144
+ src_items = safe_header.items()
2145
+ else:
2146
+ safe_header = original_header
2147
+ src_items = safe_header.items()
2148
+
2149
+ fits_header = fits.Header()
2150
+ for key, value in src_items:
2151
+ if isinstance(key, str) and key.startswith("XISF:"):
2152
+ continue
2153
+ if key in ("RANGE_LOW", "RANGE_HIGH"):
2154
+ continue
2155
+ if isinstance(value, dict) and "value" in value:
2156
+ value = value["value"]
2157
+ try:
2158
+ fits_header[key] = value
2159
+ except Exception:
2160
+ pass
2161
+ else:
2162
+ fits_header = _minimal_fits_header(h, w, is_rgb)
2163
+
2164
+ # 🔥 Merge explicit WCS header from metadata, if present
2165
+ from astropy.io import fits as _fits_mod
2166
+ if isinstance(wcs_header, _fits_mod.Header):
2167
+ for key, value in wcs_header.items():
2168
+ if key in ("SIMPLE", "BITPIX", "NAXIS", "NAXIS1", "NAXIS2",
2169
+ "NAXIS3", "BSCALE", "BZERO", "EXTEND", "END"):
2170
+ continue
2171
+ try:
2172
+ fits_header[key] = value
2173
+ except Exception:
2174
+ pass
2175
+
2176
+ # --- Shape + base data (float), then quantize based on bit_depth ---
2177
+ if is_rgb:
2178
+ base_data = np.transpose(img_array, (2, 0, 1)) # (3, H, W)
2179
+ fits_header["NAXIS"] = 3
2180
+ fits_header["NAXIS1"] = w
2181
+ fits_header["NAXIS2"] = h
2182
+ fits_header["NAXIS3"] = 3
2183
+ else:
2184
+ if img_array.ndim == 3 and img_array.shape[2] == 1:
2185
+ base_data = img_array[:, :, 0]
2186
+ else:
2187
+ base_data = img_array
2188
+ fits_header["NAXIS"] = 2
2189
+ fits_header["NAXIS1"] = w
2190
+ fits_header["NAXIS2"] = h
2191
+ fits_header.pop("NAXIS3", None)
2192
+
2193
+ bd = (bit_depth or "32-bit floating point").lower()
2194
+
2195
+ if bd == "8-bit":
2196
+ data_to_write = (np.clip(base_data, 0, 1) * 255).astype(np.uint8)
2197
+ fits_header["BITPIX"] = 8
2198
+ elif bd == "16-bit":
2199
+ data_to_write = (np.clip(base_data, 0, 1) * 65535).astype(np.uint16)
2200
+ fits_header["BITPIX"] = 16
2201
+ elif bd == "32-bit unsigned":
2202
+ data_to_write = (np.clip(base_data, 0, 1) * 4294967295).astype(np.uint32)
2203
+ fits_header["BITPIX"] = 32
2204
+ else:
2205
+ # default / 32-bit float
2206
+ data_to_write = base_data.astype(np.float32)
2207
+ fits_header["BITPIX"] = -32
2208
+
2209
+ # Linear scaling for all these
2210
+ fits_header["BSCALE"] = 1.0
2211
+ fits_header["BZERO"] = 0.0
2212
+
2213
+ # --- Write with the same robust path you already had ---
2214
+ hdu = fits.PrimaryHDU(data_to_write, header=fits_header)
2215
+
2216
+ try:
2217
+ hdu.writeto(filename, overwrite=True)
2218
+ except VerifyError as ve:
2219
+ print(f"FITS header verify error while saving {filename}: {ve}")
2220
+ print("Attempting header auto-fix via hdu.verify('fix') and manual cleanup...")
2221
+ try:
2222
+ hdu.verify('fix')
2223
+ except Exception as ve2:
2224
+ print(f"hdu.verify('fix') raised: {ve2}")
2225
+
2226
+ bad_keys = []
2227
+ for card in list(hdu.header.cards):
2228
+ try:
2229
+ _ = str(card)
2230
+ except Exception:
2231
+ bad_keys.append(card.keyword)
2232
+ for key in bad_keys:
2233
+ try:
2234
+ del hdu.header[key]
2235
+ print(f"Dropped invalid FITS header card {key!r}")
2236
+ except Exception:
2237
+ pass
2238
+
2239
+ try:
2240
+ hdu.writeto(filename, overwrite=True)
2241
+ except VerifyError as ve3:
2242
+ print(f"Still failing after cleanup: {ve3}")
2243
+ print("Falling back to minimal FITS header (dropping all original cards).")
2244
+ clean_header = _minimal_fits_header(h, w, is_rgb)
2245
+ hdu2 = fits.PrimaryHDU(data_to_write.astype(np.float32), header=clean_header)
2246
+ hdu2.writeto(filename, overwrite=True)
2247
+
2248
+ print(f"Saved FITS image to: {filename}")
2249
+ return
2250
+ # ---------------------------------------------------------------------
2251
+ # RAW inputs — not writable; convert to FITS (float32)
2252
+ # ---------------------------------------------------------------------
2253
+ if fmt in ("cr2", "nef", "arw", "dng", "orf", "rw2", "pef"):
2254
+ print("RAW formats are not writable. Saving as FITS instead.")
2255
+ filename = f"{base}.fits"
2256
+
2257
+ fits_header = fits.Header()
2258
+ if _is_header_obj(original_header):
2259
+ src_items = (original_header.items()
2260
+ if isinstance(original_header, fits.Header)
2261
+ else original_header.items())
2262
+ for k, v in src_items:
2263
+ try:
2264
+ fits_header[k] = v
2265
+ except Exception:
2266
+ pass
2267
+
2268
+ fits_header["BSCALE"] = 1.0
2269
+ fits_header["BZERO"] = 0.0
2270
+ fits_header["BITPIX"] = -32
2271
+
2272
+ if is_mono:
2273
+ data = (img_array[:, :, 0] if (img_array.ndim == 3 and img_array.shape[2] == 1) else img_array)
2274
+ img_array_fits = data.astype(np.float32)
2275
+ fits_header["NAXIS"] = 2
2276
+ fits_header["NAXIS1"] = img_array.shape[1]
2277
+ fits_header["NAXIS2"] = img_array.shape[0]
2278
+ fits_header.pop("NAXIS3", None)
2279
+ else:
2280
+ img_array_transposed = np.transpose(img_array, (2, 0, 1)) # (C,H,W)
2281
+ img_array_fits = img_array_transposed.astype(np.float32)
2282
+ fits_header["NAXIS"] = 3
2283
+ fits_header["NAXIS1"] = img_array_transposed.shape[2]
2284
+ fits_header["NAXIS2"] = img_array_transposed.shape[1]
2285
+ fits_header["NAXIS3"] = img_array_transposed.shape[0]
2286
+
2287
+ hdu = fits.PrimaryHDU(img_array_fits, header=fits_header)
2288
+ hdu.writeto(filename, overwrite=True)
2289
+ print(f"RAW processed and saved as FITS to: {filename}")
2290
+ return
2291
+
2292
+ # ---------------------------------------------------------------------
2293
+ # XISF — use XISF.write; manage metadata shapes
2294
+ # ---------------------------------------------------------------------
2295
+ if fmt == "xisf":
2296
+ print(f"Original image shape: {img_array.shape}, dtype: {img_array.dtype}")
2297
+ print(f"Bit depth: {bit_depth}")
2298
+
2299
+ bd = bit_depth or "32-bit floating point"
2300
+ if bd == "16-bit":
2301
+ processed_image = (np.clip(img_array, 0, 1) * 65535).astype(np.uint16)
2302
+ elif bd == "32-bit unsigned":
2303
+ processed_image = (np.clip(img_array, 0, 1) * 4294967295).astype(np.uint32)
2304
+ else:
2305
+ processed_image = img_array.astype(np.float32)
2306
+
2307
+ # Normalize metadata shape hints
2308
+ if is_mono:
2309
+ if processed_image.ndim == 3 and processed_image.shape[2] > 1:
2310
+ processed_image = processed_image[:, :, 0]
2311
+ if processed_image.ndim == 2:
2312
+ processed_image = processed_image[:, :, np.newaxis] # H, W, 1
2313
+
2314
+ if not isinstance(image_meta, list):
2315
+ image_meta = [{}]
2316
+ image_meta[0].setdefault("geometry", (processed_image.shape[1], processed_image.shape[0], 1))
2317
+ image_meta[0]["colorSpace"] = "Gray"
2318
+ else:
2319
+ if not isinstance(image_meta, list):
2320
+ image_meta = [{}]
2321
+ ch = processed_image.shape[2] if processed_image.ndim == 3 else 1
2322
+ image_meta[0].setdefault("geometry", (processed_image.shape[1], processed_image.shape[0], ch))
2323
+ image_meta[0]["colorSpace"] = "RGB" if ch >= 3 else "Gray"
2324
+
2325
+ if file_meta is None:
2326
+ file_meta = {}
2327
+
2328
+ print(f"Processed image shape for XISF: {processed_image.shape}, dtype: {processed_image.dtype}")
2329
+
2330
+ XISF.write(
2331
+ filename,
2332
+ processed_image,
2333
+ creator_app="Seti Astro Cosmic Clarity",
2334
+ image_metadata=image_meta[0],
2335
+ xisf_metadata=file_meta,
2336
+ shuffle=True
2337
+ )
2338
+ print(f"Saved {bd} XISF image to: {filename}")
2339
+ return
2340
+
2341
+ # ---------------------------------------------------------------------
2342
+ # Unknown format
2343
+ # ---------------------------------------------------------------------
2344
+ raise ValueError(f"Unsupported file format: {original_format!r}")
2345
+
2346
+ except Exception as e:
2347
+ print(f"Error saving image to {filename}: {e}")
2348
+ raise
2349
+
2350
+
2351
+ def ensure_native_byte_order(array):
2352
+ """
2353
+ Ensures that the array is in the native byte order.
2354
+ If the array is in a non-native byte order, it will convert it.
2355
+ """
2356
+ if array.dtype.byteorder == '=': # Already in native byte order
2357
+ return array
2358
+ elif array.dtype.byteorder in ('<', '>'): # Non-native byte order
2359
+ return array.byteswap().view(array.dtype.newbyteorder('='))
2360
+ return array