setiastrosuitepro 1.6.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (394) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2727 @@
1
+ # saspro/doc_manager.py
2
+ from __future__ import annotations
3
+ from PyQt6.QtCore import QObject, pyqtSignal, Qt, QTimer
4
+ from PyQt6.QtWidgets import QApplication, QMessageBox
5
+ import os
6
+ import numpy as np
7
+ from setiastro.saspro.xisf import XISF as XISFReader
8
+ from astropy.io import fits # local import; optional dep
9
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image, save_image as legacy_save_image
10
+ from setiastro.saspro.legacy.image_manager import list_fits_extensions, load_fits_extension
11
+ import uuid
12
+ from setiastro.saspro.legacy.image_manager import attach_wcs_to_metadata # or wherever you put it
13
+ from astropy.wcs import WCS # only if not already imported in this module
14
+ from setiastro.saspro.debug_utils import debug_dump_metadata
15
+
16
+ # Memory utilities for lazy loading and caching
17
+ try:
18
+ from setiastro.saspro.memory_utils import get_thumbnail_cache, LazyImage
19
+ except ImportError:
20
+ get_thumbnail_cache = None
21
+ LazyImage = None
22
+
23
+ from setiastro.saspro.swap_manager import get_swap_manager
24
+ from setiastro.saspro.widgets.image_utils import ensure_contiguous
25
+ from typing import Any
26
+
27
+ # --- WCS DEBUGGING ------------------------------------------------------
28
+ _DEBUG_WCS = False # flip to False when you’re done debugging
29
+
30
+ def _debug_log_wcs_context(context: str, meta_or_hdr):
31
+ """
32
+ Tiny helper to print key WCS bits:
33
+ - NAXIS1/2
34
+ - CRPIX1/2
35
+ - CRVAL1/2
36
+ - CDELT / CD if present
37
+ Works if you pass either a metadata dict or a FITS-like header dict.
38
+ """
39
+ if not _DEBUG_WCS:
40
+ return
41
+
42
+ # Try to resolve a header from a metadata dict
43
+ hdr = None
44
+ if isinstance(meta_or_hdr, dict):
45
+ # metadata dict with possible header keys
46
+ hdr = (meta_or_hdr.get("original_header")
47
+ or meta_or_hdr.get("fits_header")
48
+ or meta_or_hdr.get("header"))
49
+ if hdr is None:
50
+ # maybe you passed the header dict directly
51
+ hdr = meta_or_hdr
52
+ else:
53
+ hdr = meta_or_hdr
54
+
55
+ if hdr is None:
56
+ print(f"[WCS DEBUG] {context}: no header found")
57
+ return
58
+
59
+ # Normalize dict-like header
60
+ if hasattr(hdr, "keys"): # astropy Header or dict
61
+ try:
62
+ keys = list(hdr.keys())
63
+ except Exception:
64
+ keys = []
65
+ else:
66
+ print(f"[WCS DEBUG] {context}: header is non-mapping type {type(hdr)}")
67
+ return
68
+
69
+ def _get(k, default=None):
70
+ try:
71
+ return hdr.get(k, default)
72
+ except Exception:
73
+ try:
74
+ return hdr[k]
75
+ except Exception:
76
+ return default
77
+
78
+ naxis1 = _get("NAXIS1")
79
+ naxis2 = _get("NAXIS2")
80
+ crpix1 = _get("CRPIX1")
81
+ crpix2 = _get("CRPIX2")
82
+ crval1 = _get("CRVAL1")
83
+ crval2 = _get("CRVAL2")
84
+
85
+ cd11 = _get("CD1_1")
86
+ cd12 = _get("CD1_2")
87
+ cd21 = _get("CD2_1")
88
+ cd22 = _get("CD2_2")
89
+ cdelt1 = _get("CDELT1")
90
+ cdelt2 = _get("CDELT2")
91
+
92
+ print(f"[WCS DEBUG] {context}:")
93
+ print(f" NAXIS1={naxis1} NAXIS2={naxis2}")
94
+ print(f" CRPIX1={crpix1} CRPIX2={crpix2}")
95
+ print(f" CRVAL1={crval1} CRVAL2={crval2}")
96
+ if any(v is not None for v in (cd11, cd12, cd21, cd22)):
97
+ print(f" CD = [[{cd11}, {cd12}], [{cd21}, {cd22}]]")
98
+ if cdelt1 is not None or cdelt2 is not None:
99
+ print(f" CDELT1={cdelt1} CDELT2={cdelt2}")
100
+ print("")
101
+
102
+ _DEBUG_UNDO = False # set True while chasing the GraXpert crash
103
+
104
+
105
+ def _debug_log_undo(context: str, **info):
106
+ """
107
+ Lightweight logger for undo/redo/update activity.
108
+ Safe: never raises, even if repr() is weird.
109
+ """
110
+ if not _DEBUG_UNDO:
111
+ return
112
+ try:
113
+ bits = []
114
+ for k, v in info.items():
115
+ try:
116
+ s = str(v)
117
+ except Exception:
118
+ try:
119
+ s = repr(v)
120
+ except Exception:
121
+ s = f"<unrepr {type(v)}>"
122
+ bits.append(f"{k}={s}")
123
+ print(f"[UNDO DEBUG] {context}: " + ", ".join(bits))
124
+ except Exception as e:
125
+ # Last-resort safety – don't let logging itself kill us
126
+ try:
127
+ print(f"[UNDO DEBUG] {context}: <logging failed: {e}>")
128
+ except Exception:
129
+ pass
130
+
131
+ from setiastro.saspro.file_utils import _normalize_ext
132
+
133
+ def _normalize_image_01(arr: np.ndarray) -> np.ndarray:
134
+ """
135
+ Normalize an image to [0,1] in-place-ish:
136
+
137
+ 1. If min < 0 → shift so min becomes 0.
138
+ 2. Then if max > 1 → divide by max.
139
+
140
+ NaNs/Infs are ignored when computing min/max.
141
+ Returns float32 array.
142
+ """
143
+ if arr is None:
144
+ return arr
145
+
146
+ a = np.asarray(arr, dtype=np.float32)
147
+ finite = np.isfinite(a)
148
+ if not finite.any():
149
+ # completely bogus; give back zeros
150
+ return np.zeros_like(a, dtype=np.float32)
151
+
152
+ # Step 1: shift up if we have negatives
153
+ min_val = a[finite].min()
154
+ if min_val < 0.0:
155
+ a = a - min_val
156
+ finite = np.isfinite(a)
157
+
158
+ # Step 2: scale down if we exceed 1
159
+ max_val = a[finite].max()
160
+ if max_val > 1.0 and max_val > 0.0:
161
+ a = a / max_val
162
+
163
+ return a
164
+
165
+ _ALLOWED_DEPTHS = {
166
+ "png": {"8-bit"},
167
+ "jpg": {"8-bit"},
168
+ "fits": ["8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"],
169
+ "fit": ["8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"],
170
+ "tif": {"8-bit", "16-bit", "32-bit unsigned", "32-bit floating point"},
171
+ "xisf": {"16-bit", "32-bit unsigned", "32-bit floating point"},
172
+ }
173
+
174
+ class TableDocument(QObject):
175
+ changed = pyqtSignal()
176
+
177
+ def __init__(self, rows: list[list], headers: list[str], metadata: dict | None = None, parent=None):
178
+ super().__init__(parent)
179
+ self.rows = rows # list of list (2D) for QAbstractTableModel
180
+ self.headers = headers # list of column names
181
+ self.metadata = dict(metadata or {})
182
+ self._undo = []
183
+ self._redo = []
184
+
185
+ def display_name(self) -> str:
186
+ dn = self.metadata.get("display_name")
187
+ if dn:
188
+ return dn
189
+ p = self.metadata.get("file_path")
190
+ return os.path.basename(p) if p else "Untitled Table"
191
+
192
+ def can_undo(self) -> bool: return False
193
+ def can_redo(self) -> bool: return False
194
+ def last_undo_name(self) -> str | None: return None
195
+ def last_redo_name(self) -> str | None: return None
196
+ def undo(self) -> str | None: return None
197
+ def redo(self) -> str | None: return None
198
+
199
+ class ImageDocument(QObject):
200
+ changed = pyqtSignal()
201
+
202
+ def __init__(self, image: np.ndarray, metadata: dict | None = None, parent=None):
203
+ super().__init__(parent)
204
+ self.image = image
205
+ self.metadata = dict(metadata or {})
206
+ self.mask = None
207
+ # _undo / _redo now store tuples: (swap_id: str, metadata: dict, step_name: str)
208
+ self._undo: list[tuple[str, dict, str]] = []
209
+ self._redo: list[tuple[str, dict, str]] = []
210
+ self.masks: dict[str, np.ndarray] = {}
211
+ self.active_mask_id: str | None = None
212
+ self.uid = uuid.uuid4().hex # stable identity for DnD, layers, masks, etc.
213
+
214
+ # NEW: operation log — list of simple dicts
215
+ # Each entry: {
216
+ # "id": str,
217
+ # "step": str,
218
+ # "params": dict,
219
+ # "roi": (x,y,w,h) | None,
220
+ # "source": "full" | "roi",
221
+ # "ts": float
222
+ # }
223
+ self._op_log: list[dict] = []
224
+
225
+ # Track unsaved changes explicitly
226
+ self.dirty: bool = False
227
+
228
+ # Copy-on-write support: if this document shares image data with another,
229
+ # _cow_source holds reference to the source. On first write (apply_edit),
230
+ # we copy the image data and clear _cow_source.
231
+ self._cow_source: 'ImageDocument | None' = None
232
+ # --- history helpers (NEW) ---
233
+ # --- operation log helpers (NEW) -----------------------------------
234
+ def record_operation(
235
+ self,
236
+ step_name: str,
237
+ params: dict | None = None,
238
+ roi: tuple[int, int, int, int] | None = None,
239
+ source: str = "full",
240
+ ) -> str:
241
+ """
242
+ Append a param-record for this edit. This is *lightweight* metadata
243
+ used for replaying ROI recipes etc; it does NOT affect undo/redo.
244
+ """
245
+ import time as _time
246
+ op_id = uuid.uuid4().hex
247
+ entry = {
248
+ "id": op_id,
249
+ "step": step_name or "Edit",
250
+ "params": _dm_json_sanitize(params or {}),
251
+ "roi": tuple(roi) if roi else None,
252
+ "source": str(source or "full"),
253
+ "ts": float(_time.time()),
254
+ }
255
+ self._op_log.append(entry)
256
+ return op_id
257
+
258
+ def get_operation_log(self) -> list[dict]:
259
+ """Return a copy of the operation log (for UI / replay)."""
260
+ return list(self._op_log)
261
+
262
+ def clear_operation_log(self):
263
+ """Clear the operation log (does not touch pixel history)."""
264
+ self._op_log.clear()
265
+
266
+
267
+ def can_undo(self) -> bool:
268
+ return bool(self._undo)
269
+
270
+ def can_redo(self) -> bool:
271
+ return bool(self._redo)
272
+
273
+ def last_undo_name(self) -> str | None:
274
+ return self._undo[-1][2] if self._undo else None
275
+
276
+ def last_redo_name(self) -> str | None:
277
+ return self._redo[-1][2] if self._redo else None
278
+
279
+
280
+ def add_mask(self, mask: Any, mask_id: str | None = None, make_active: bool = True) -> str:
281
+ """
282
+ Store a mask on this document.
283
+
284
+ - `mask` can be a numpy array or any mask-like object.
285
+ - If `mask_id` is None, a random UUID is generated.
286
+ - Returns the mask_id used.
287
+ """
288
+ if mask_id is None:
289
+ mask_id = getattr(mask, "id", None) or uuid.uuid4().hex
290
+
291
+ # If it's an array, normalize to float32; otherwise just store as-is.
292
+ try:
293
+ arr = np.asarray(mask, dtype=np.float32)
294
+ self.masks[mask_id] = arr
295
+ except Exception:
296
+ self.masks[mask_id] = mask
297
+
298
+ if make_active:
299
+ self.active_mask_id = mask_id
300
+
301
+ return mask_id
302
+
303
+ def remove_mask(self, mask_id: str):
304
+ self.masks.pop(mask_id, None)
305
+ if self.active_mask_id == mask_id:
306
+ self.active_mask_id = None
307
+
308
+ def get_active_mask(self):
309
+ return self.masks.get(self.active_mask_id) if self.active_mask_id else None
310
+
311
+ def close(self):
312
+ """
313
+ Explicit cleanup of swap files.
314
+ """
315
+ sm = get_swap_manager()
316
+ # Clean up undo stack
317
+ for swap_id, _, _ in self._undo:
318
+ sm.delete_state(swap_id)
319
+ self._undo.clear()
320
+
321
+ # Clean up redo stack
322
+ for swap_id, _, _ in self._redo:
323
+ sm.delete_state(swap_id)
324
+ self._redo.clear()
325
+
326
+ def __del__(self):
327
+ # Fallback cleanup if close() wasn't called (though explicit close is better)
328
+ try:
329
+ self.close()
330
+ except Exception:
331
+ pass
332
+
333
+
334
+ # in class ImageDocument
335
+ def apply_edit(self, new_image: np.ndarray, metadata: dict | None = None, step_name: str = "Edit"):
336
+ """
337
+ Smart edit:
338
+ - If this is an ROI view (has _roi_info), paste back into parent and emit region update.
339
+ - Else: push history on self and emit full-image update.
340
+ - IMPORTANT: merge metadata without nuking FITS/WCS headers.
341
+ """
342
+ import numpy as np
343
+
344
+ def _merge_meta(old_meta: dict | None, new_meta: dict | None, step_name: str):
345
+ """
346
+ Merge new_meta into old_meta but preserve critical header fields
347
+ unless they are explicitly overridden with non-None values.
348
+ """
349
+ old = dict(old_meta or {})
350
+ incoming = dict(new_meta or {})
351
+
352
+ critical_keys = (
353
+ "original_header",
354
+ "fits_header",
355
+ "wcs_header",
356
+ "file_meta",
357
+ "image_meta",
358
+ )
359
+
360
+ # Preserve critical keys unless caller *deliberately* overrides
361
+ for k in critical_keys:
362
+ if k in incoming:
363
+ if incoming[k] is not None:
364
+ old[k] = incoming[k]
365
+ # if not in incoming → leave old value alone
366
+
367
+ # Merge all remaining keys normally
368
+ for k, v in incoming.items():
369
+ if k in critical_keys:
370
+ continue
371
+ old[k] = v
372
+
373
+ if step_name:
374
+ old.setdefault("step_name", step_name)
375
+ return old
376
+
377
+ # ------ ROI-aware branch (auto-pasteback) ------
378
+ roi_info = getattr(self, "_roi_info", None)
379
+ if roi_info:
380
+ parent = roi_info.get("parent_doc")
381
+ roi = roi_info.get("roi")
382
+ if isinstance(parent, ImageDocument) and (getattr(parent, "image", None) is not None) and roi:
383
+ x, y, w, h = map(int, roi)
384
+
385
+ img = np.asarray(new_image)
386
+ if img.dtype != np.float32:
387
+ img = img.astype(np.float32, copy=False)
388
+
389
+ base = np.asarray(parent.image)
390
+ if img.shape[:2] != (h, w):
391
+ raise ValueError(f"Edited preview shape {img.shape[:2]} does not match ROI {(h, w)}")
392
+
393
+ # shape reconciliation
394
+ if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
395
+ img = img[..., 0]
396
+ if base.ndim == 3 and img.ndim == 2:
397
+ img = np.repeat(img[..., None], base.shape[2], axis=2)
398
+
399
+ new_full = base.copy()
400
+ new_full[y:y+h, x:x+w] = img
401
+
402
+ # push onto the PARENT’s history
403
+ if metadata:
404
+ parent.metadata = _merge_meta(parent.metadata, metadata, step_name)
405
+ else:
406
+ parent.metadata.setdefault("step_name", step_name)
407
+
408
+ sm = get_swap_manager()
409
+ sid = sm.save_state(parent.image)
410
+ if sid:
411
+ parent._undo.append((sid, parent.metadata.copy(), step_name))
412
+
413
+ for old_sid, _, _ in parent._redo:
414
+ sm.delete_state(old_sid)
415
+ parent._redo.clear()
416
+
417
+ parent.image = new_full
418
+ parent.dirty = True
419
+ parent.changed.emit()
420
+
421
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
422
+ try:
423
+ if dm is not None and hasattr(dm, "imageRegionUpdated"):
424
+ dm.imageRegionUpdated.emit(parent, (x, y, w, h))
425
+ except Exception:
426
+ print(f"[DocManager] Failed to emit imageRegionUpdated for ROI.")
427
+ return # done
428
+
429
+ # ------ Normal (full-image) branch ------
430
+
431
+ # Copy-on-write
432
+ if self._cow_source is not None and self.image is not None:
433
+ self.image = self.image.copy()
434
+ self._cow_source = None
435
+
436
+ if self.image is not None:
437
+ # snapshot current image + metadata for undo
438
+ try:
439
+ curr = np.asarray(self.image, dtype=np.float32)
440
+ curr = ensure_contiguous(curr)
441
+
442
+ sm = get_swap_manager()
443
+ sid = sm.save_state(curr)
444
+
445
+ _debug_log_undo(
446
+ "ImageDocument.apply_edit.snapshot",
447
+ doc_id=id(self),
448
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
449
+ curr_shape=getattr(curr, "shape", None),
450
+ undo_len_before=len(self._undo),
451
+ redo_len_before=len(self._redo),
452
+ step_name=step_name,
453
+ swap_id=sid
454
+ )
455
+ if sid:
456
+ self._undo.append((sid, self.metadata.copy(), step_name))
457
+ except Exception as e:
458
+ print(f"[ImageDocument] apply_edit: failed to snapshot current image for undo: {e}")
459
+
460
+ # Clear redo stack and delete files
461
+ sm = get_swap_manager()
462
+ for old_sid, _, _ in self._redo:
463
+ sm.delete_state(old_sid)
464
+ self._redo.clear()
465
+
466
+ # --- header-safe metadata merge ---
467
+ if metadata:
468
+ self.metadata = _merge_meta(self.metadata, metadata, step_name)
469
+ else:
470
+ self.metadata.setdefault("step_name", step_name)
471
+
472
+ # normalize new image
473
+ img = np.asarray(new_image, dtype=np.float32)
474
+ if img.size == 0:
475
+ raise ValueError("apply_edit: new image is empty")
476
+
477
+ img = ensure_contiguous(img)
478
+
479
+ _debug_log_undo(
480
+ "ImageDocument.apply_edit.apply",
481
+ doc_id=id(self),
482
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
483
+ new_shape=getattr(img, "shape", None),
484
+ undo_len_after=len(self._undo),
485
+ redo_len_after=len(self._redo),
486
+ step_name=step_name,
487
+ )
488
+
489
+ self.image = img
490
+ self.dirty = True
491
+ self.changed.emit()
492
+
493
+ dm = getattr(self, "_doc_manager", None)
494
+ try:
495
+ if dm is not None and hasattr(dm, "imageRegionUpdated"):
496
+ dm.imageRegionUpdated.emit(self, None)
497
+ except Exception:
498
+ pass
499
+
500
+
501
+
502
+ def undo(self) -> str | None:
503
+ # Extra-safe: if stack is empty, bail early.
504
+ if not self._undo:
505
+ _debug_log_undo(
506
+ "ImageDocument.undo.empty_stack",
507
+ doc_id=id(self),
508
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
509
+ )
510
+ return None
511
+
512
+ _debug_log_undo(
513
+ "ImageDocument.undo.entry",
514
+ doc_id=id(self),
515
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
516
+ undo_len=len(self._undo),
517
+ redo_len=len(self._redo),
518
+ top_step=self._undo[-1][2] if self._undo else None,
519
+ )
520
+
521
+ # Pop with an extra guard in case something cleared _undo between
522
+ # the check above and this call (re-entrancy / threading).
523
+ try:
524
+ prev_sid, prev_meta, name = self._undo.pop()
525
+ except IndexError:
526
+ _debug_log_undo(
527
+ "ImageDocument.undo.pop_index_error",
528
+ doc_id=id(self),
529
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
530
+ undo_len=len(self._undo),
531
+ redo_len=len(self._redo),
532
+ )
533
+ return None
534
+
535
+ # Load previous image from swap
536
+ sm = get_swap_manager()
537
+ prev_img = sm.load_state(prev_sid)
538
+
539
+ # We can delete the swap file now that we have it in RAM
540
+ # (unless we want to keep it for some reason, but standard undo consumes the state)
541
+ sm.delete_state(prev_sid)
542
+
543
+ if prev_img is None:
544
+ print(f"[ImageDocument] undo: failed to load swap state {prev_sid}")
545
+ return None
546
+
547
+ # Normalize previous image before using it
548
+ try:
549
+ prev_arr = np.asarray(prev_img, dtype=np.float32)
550
+ if prev_arr.size == 0:
551
+ raise ValueError("undo: previous image is empty")
552
+ prev_arr = np.ascontiguousarray(prev_arr)
553
+ except Exception as e:
554
+ print(f"[ImageDocument] undo: invalid prev_img in stack ({type(prev_img)}): {e}")
555
+ # Put it back so we don't corrupt history further?
556
+ # Actually if load failed we are in trouble.
557
+ return None
558
+
559
+ _debug_log_undo(
560
+ "ImageDocument.undo.normalized_prev",
561
+ doc_id=id(self),
562
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
563
+ prev_shape=getattr(prev_arr, "shape", None),
564
+ prev_dtype=getattr(prev_arr, "dtype", None),
565
+ step_name=name,
566
+ meta_step=prev_meta.get("step_name", None) if isinstance(prev_meta, dict) else None,
567
+ )
568
+
569
+ # Snapshot current state for redo (best-effort)
570
+ curr_img = self.image
571
+ curr_meta = self.metadata
572
+ try:
573
+ if curr_img is not None:
574
+ curr_arr = np.asarray(curr_img, dtype=np.float32)
575
+ curr_arr = np.ascontiguousarray(curr_arr)
576
+
577
+ # Save to swap for Redo
578
+ sid = sm.save_state(curr_arr)
579
+ if sid:
580
+ self._redo.append((sid, dict(curr_meta), name))
581
+ else:
582
+ # Handle None image? Should not happen usually
583
+ pass
584
+ except Exception as e:
585
+ print(f"[ImageDocument] undo: failed to snapshot current image for redo: {e}")
586
+
587
+ _debug_log_undo(
588
+ "ImageDocument.undo.before_apply",
589
+ doc_id=id(self),
590
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
591
+ curr_shape=getattr(curr_img, "shape", None) if curr_img is not None else None,
592
+ curr_dtype=getattr(curr_img, "dtype", None) if curr_img is not None else None,
593
+ )
594
+
595
+ self.image = prev_arr
596
+ self.metadata = dict(prev_meta or {})
597
+ self.dirty = True
598
+ try:
599
+ self.changed.emit()
600
+ except Exception:
601
+ pass
602
+
603
+ _debug_log_undo(
604
+ "ImageDocument.undo.after_apply",
605
+ doc_id=id(self),
606
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
607
+ new_shape=getattr(self.image, "shape", None),
608
+ new_dtype=getattr(self.image, "dtype", None),
609
+ undo_len=len(self._undo),
610
+ redo_len=len(self._redo),
611
+ )
612
+ return name
613
+
614
+
615
+ def redo(self) -> str | None:
616
+ if not self._redo:
617
+ return None
618
+
619
+ _debug_log_undo(
620
+ "ImageDocument.redo.entry",
621
+ doc_id=id(self),
622
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
623
+ redo_len=len(self._redo),
624
+ undo_len=len(self._undo),
625
+ top_step=self._redo[-1][2] if self._redo else None,
626
+ )
627
+
628
+ nxt_sid, nxt_meta, name = self._redo.pop()
629
+
630
+ # Load next image from swap
631
+ sm = get_swap_manager()
632
+ nxt_img = sm.load_state(nxt_sid)
633
+ sm.delete_state(nxt_sid)
634
+
635
+ if nxt_img is None:
636
+ print(f"[ImageDocument] redo: failed to load swap state {nxt_sid}")
637
+ return None
638
+
639
+ # Normalize next image before using it
640
+ try:
641
+ nxt_arr = np.asarray(nxt_img, dtype=np.float32)
642
+ if nxt_arr.size == 0:
643
+ raise ValueError("redo: next image is empty")
644
+ nxt_arr = np.ascontiguousarray(nxt_arr)
645
+ except Exception as e:
646
+ print(f"[ImageDocument] redo: invalid nxt_img in stack ({type(nxt_img)}): {e}")
647
+ return None
648
+
649
+ _debug_log_undo(
650
+ "ImageDocument.redo.normalized_next",
651
+ doc_id=id(self),
652
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
653
+ nxt_shape=getattr(nxt_arr, "shape", None),
654
+ nxt_dtype=getattr(nxt_arr, "dtype", None),
655
+ step_name=name,
656
+ meta_step=nxt_meta.get("step_name", None) if isinstance(nxt_meta, dict) else None,
657
+ )
658
+ curr_img = self.image
659
+ curr_meta = self.metadata
660
+ try:
661
+ if curr_img is not None:
662
+ curr_arr = np.asarray(curr_img, dtype=np.float32)
663
+ curr_arr = np.ascontiguousarray(curr_arr)
664
+
665
+ # Save current to swap for Undo
666
+ sid = sm.save_state(curr_arr)
667
+ if sid:
668
+ self._undo.append((sid, dict(curr_meta), name))
669
+ else:
670
+ pass
671
+ except Exception as e:
672
+ print(f"[ImageDocument] redo: failed to snapshot current image for undo: {e}")
673
+ _debug_log_undo(
674
+ "ImageDocument.redo.before_apply",
675
+ doc_id=id(self),
676
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
677
+ curr_shape=getattr(curr_img, "shape", None) if curr_img is not None else None,
678
+ curr_dtype=getattr(curr_img, "dtype", None) if curr_img is not None else None,
679
+ )
680
+ self.image = nxt_arr
681
+ self.metadata = dict(nxt_meta or {})
682
+ self.dirty = True
683
+ try:
684
+ self.changed.emit()
685
+ except Exception:
686
+ pass
687
+
688
+ _debug_log_undo(
689
+ "ImageDocument.redo.after_apply",
690
+ doc_id=id(self),
691
+ name=getattr(self, "display_name", lambda: "<no-name>")(),
692
+ new_shape=getattr(self.image, "shape", None),
693
+ new_dtype=getattr(self.image, "dtype", None),
694
+ undo_len=len(self._undo),
695
+ redo_len=len(self._redo),
696
+ )
697
+
698
+ return name
699
+
700
+
701
+ # existing methods unchanged below...
702
+ def set_image(self, img: np.ndarray, metadata: dict | None = None, step_name: str = "Edit"):
703
+ """
704
+ Treat set_image as an editing operation that records history.
705
+ (History previews and “Restore from History” call this.)
706
+ """
707
+ self.apply_edit(img, metadata or {}, step_name=step_name)
708
+
709
+
710
+ # --- Add to ImageDocument (public history helpers) -------------------
711
+
712
+ def get_undo_stack(self):
713
+ """
714
+ Oldest → newest *before* current image.
715
+ Returns [(swap_id, meta, name), ...]
716
+ """
717
+ out = []
718
+ for sid, meta, name in self._undo:
719
+ out.append((sid, meta or {}, name or "Unnamed"))
720
+ return out
721
+
722
+ def display_name(self) -> str:
723
+ # Prefer an explicit display name if set
724
+ dn = self.metadata.get("display_name")
725
+ if dn:
726
+ return dn
727
+ p = self.metadata.get("file_path")
728
+ return os.path.basename(p) if p else "Untitled"
729
+
730
+
731
+ def _dm_json_sanitize(obj):
732
+ """Tiny, local JSON sanitizer: keeps size small & avoids numpy/astropy weirdness."""
733
+ import numpy as _np
734
+ if isinstance(obj, (str, int, float, bool)) or obj is None:
735
+ return obj
736
+ if isinstance(obj, dict):
737
+ return {str(k): _dm_json_sanitize(v) for k, v in obj.items()}
738
+ if isinstance(obj, (list, tuple)):
739
+ return [_dm_json_sanitize(x) for x in obj]
740
+ # numpy array → small placeholder
741
+ try:
742
+ if isinstance(obj, _np.ndarray):
743
+ return {"__nd__": True, "shape": list(obj.shape), "dtype": str(obj.dtype)}
744
+ # numpy scalar
745
+ if hasattr(obj, "item"):
746
+ return obj.item()
747
+ except Exception:
748
+ pass
749
+ try:
750
+ return repr(obj)
751
+ except Exception:
752
+ return str(type(obj))
753
+
754
+ def _compute_cropped_wcs(parent_hdr_like: dict | "fits.Header",
755
+ x: int, y: int, w: int, h: int):
756
+ """
757
+ Returns a plain dict WCS header reflecting a pure pixel crop by (x,y,w,h).
758
+ Keeps CD/CDELT/PC/CRVAL as-is and shifts CRPIX by (+/-) the crop offset.
759
+ Also sets NAXIS1/2 to (w,h) and records custom CROPX/CROPY.
760
+ """
761
+ try:
762
+ from astropy.io.fits import Header # type: ignore
763
+ except Exception:
764
+ Header = None # type: ignore
765
+
766
+ # Normalize to a dict of key->value (no comments needed for the drag payload)
767
+ if Header is not None and isinstance(parent_hdr_like, Header):
768
+ base = {k: parent_hdr_like.get(k) for k in parent_hdr_like.keys()}
769
+ elif isinstance(parent_hdr_like, dict):
770
+ # If it’s an XISF-like dict, try to pull a FITSKeywords block first
771
+ fk = parent_hdr_like.get("FITSKeywords")
772
+ if isinstance(fk, dict) and fk:
773
+ base = {}
774
+ for k, arr in fk.items():
775
+ try:
776
+ base[k] = (arr or [{}])[0].get("value", None)
777
+ except Exception:
778
+ pass
779
+ else:
780
+ base = dict(parent_hdr_like)
781
+ else:
782
+ base = {}
783
+
784
+ # Shift CRPIX by the crop offset (ROI origin is (x,y) in full-image pixels)
785
+ crpix1 = base.get("CRPIX1")
786
+ crpix2 = base.get("CRPIX2")
787
+ if isinstance(crpix1, (int, float)) and isinstance(crpix2, (int, float)):
788
+ new_crpix1 = float(crpix1) - float(x)
789
+ new_crpix2 = float(crpix2) - float(y)
790
+ base["CRPIX1"] = new_crpix1
791
+ base["CRPIX2"] = new_crpix2
792
+ else:
793
+ new_crpix1 = crpix1
794
+ new_crpix2 = crpix2
795
+
796
+ # Update image size keys
797
+ base["NAXIS1"] = int(w)
798
+ base["NAXIS2"] = int(h)
799
+
800
+ # Optional helpful tags
801
+ base["CROPX"] = int(x)
802
+ base["CROPY"] = int(y)
803
+ base["SASKIND"] = "ROI-CROP"
804
+
805
+ # DEBUG: show how CRPIX changed for this crop
806
+ if _DEBUG_WCS:
807
+ print(f"[WCS DEBUG] _compute_cropped_wcs: roi=({x},{y},{w},{h})")
808
+ print(f" CRPIX1: {crpix1} -> {new_crpix1}")
809
+ print(f" CRPIX2: {crpix2} -> {new_crpix2}")
810
+ print("")
811
+
812
+ return base
813
+
814
+ import logging
815
+
816
+ log = logging.getLogger(__name__)
817
+
818
+ def _pick_header_for_save(meta: dict) -> fits.Header | None:
819
+ """
820
+ Choose the best header to write to disk.
821
+
822
+ Priority:
823
+ 1. 'wcs_header' – if you stash a solved header here
824
+ 2. 'fits_header' – common name after ASTAP / plate solve
825
+ 3. 'original_header' – whatever came from disk
826
+ 4. 'header' – older code paths
827
+ """
828
+ if not isinstance(meta, dict):
829
+ return None
830
+
831
+ for key in ("wcs_header", "fits_header", "original_header", "header"):
832
+ hdr = meta.get(key)
833
+ if isinstance(hdr, fits.Header):
834
+ log.debug("[_pick_header_for_save] using %s (%d cards)", key, len(hdr))
835
+ return hdr
836
+
837
+ log.debug("[_pick_header_for_save] no fits.Header found in metadata; "
838
+ "will let legacy_save_image fall back.")
839
+ return None
840
+
841
+ def _snapshot_header_for_metadata(meta: dict):
842
+ """
843
+ If meta contains a header under common keys, add a JSON-safe snapshot at
844
+ meta["__header_snapshot__"] so viewers/project IO never choke.
845
+ """
846
+ if not isinstance(meta, dict):
847
+ return
848
+ if "__header_snapshot__" in meta:
849
+ return
850
+
851
+ hdr = (meta.get("original_header")
852
+ or meta.get("fits_header")
853
+ or meta.get("header"))
854
+
855
+ if hdr is None:
856
+ return
857
+
858
+ snap = None
859
+
860
+ # Try astropy Header (without hard dependency)
861
+ try:
862
+ from astropy.io.fits import Header # type: ignore
863
+ except Exception:
864
+ Header = None # type: ignore
865
+
866
+ try:
867
+ if Header is not None and isinstance(hdr, Header):
868
+ cards = []
869
+ for k in hdr.keys():
870
+ try:
871
+ val = hdr[k]
872
+ except Exception:
873
+ val = ""
874
+ try:
875
+ cmt = hdr.comments[k] if hasattr(hdr, "comments") else ""
876
+ except Exception:
877
+ cmt = ""
878
+ cards.append([str(k), _dm_json_sanitize(val), str(cmt)])
879
+ snap = {"format": "fits-cards", "cards": cards}
880
+ elif isinstance(hdr, dict):
881
+ # Already a dict-like header (e.g., XISF style)
882
+ snap = {"format": "dict",
883
+ "items": {str(k): _dm_json_sanitize(v) for k, v in hdr.items()}}
884
+ else:
885
+ # Last resort string
886
+ snap = {"format": "repr", "text": repr(hdr)}
887
+ except Exception:
888
+ try:
889
+ snap = {"format": "repr", "text": str(hdr)}
890
+ except Exception:
891
+ snap = None
892
+
893
+ if snap:
894
+ meta["__header_snapshot__"] = snap
895
+
896
+ def _safe_str(x) -> str:
897
+ try:
898
+ return str(x)
899
+ except Exception:
900
+ try:
901
+ return repr(x)
902
+ except Exception:
903
+ return "<unrepr>"
904
+
905
+ def _fits_table_to_csv(hdu, out_csv_path: str, max_rows: int = 250000):
906
+ """
907
+ Convert a FITS (Bin)Table HDU to CSV. Returns the CSV path.
908
+ Limits to max_rows to avoid giant dumps.
909
+ """
910
+ try:
911
+ data = hdu.data
912
+ if data is None:
913
+ raise RuntimeError("No table data")
914
+
915
+ # Astropy table→numpy recarray is fine; iterate to strings
916
+ rec = np.asarray(data)
917
+ nrows = int(rec.shape[0]) if rec.ndim >= 1 else 0
918
+ if nrows == 0:
919
+ # write headers only
920
+ names = [str(n) for n in (getattr(data, "names", None) or [])]
921
+ with open(out_csv_path, "w", encoding="utf-8", newline="") as f:
922
+ if names:
923
+ f.write(",".join(names) + "\n")
924
+ return out_csv_path
925
+
926
+ # Column names (fallback to numeric if missing)
927
+ names = list(getattr(data, "names", [])) or [f"C{i+1}" for i in range(rec.shape[1] if rec.ndim == 2 else len(rec.dtype.names or []))]
928
+
929
+ import csv
930
+ with open(out_csv_path, "w", encoding="utf-8", newline="") as f:
931
+ w = csv.writer(f)
932
+ w.writerow([_safe_str(n) for n in names])
933
+
934
+ # Decide how to iterate rows depending on structured vs 2D numeric
935
+ if rec.dtype.names: # structured/record array
936
+ for ri in range(min(nrows, max_rows)):
937
+ row = rec[ri]
938
+ w.writerow([_safe_str(row[name]) for name in rec.dtype.names])
939
+ else:
940
+ # plain 2D numeric table
941
+ if rec.ndim == 1:
942
+ for ri in range(min(nrows, max_rows)):
943
+ w.writerow([_safe_str(rec[ri])])
944
+ else:
945
+ for ri in range(min(nrows, max_rows)):
946
+ w.writerow([_safe_str(x) for x in rec[ri]])
947
+
948
+ return out_csv_path
949
+ except Exception as e:
950
+ raise
951
+
952
+ def _fits_table_to_rows_headers(hdu, max_rows: int = 500000) -> tuple[list[list], list[str]]:
953
+ """
954
+ Convert a FITS (Bin)Table/Table HDU to (rows, headers).
955
+ Truncates to max_rows for safety.
956
+ """
957
+ data = hdu.data
958
+ if data is None:
959
+ return [], []
960
+ rec = np.asarray(data)
961
+ # Column names
962
+ names = list(getattr(data, "names", [])) or (
963
+ list(rec.dtype.names) if rec.dtype.names else [f"C{i+1}" for i in range(rec.shape[1] if rec.ndim == 2 else 1)]
964
+ )
965
+ rows = []
966
+ nrows = int(rec.shape[0]) if rec.ndim >= 1 else 0
967
+ nrows = min(nrows, max_rows)
968
+ if rec.dtype.names: # structured array
969
+ for ri in range(nrows):
970
+ row = rec[ri]
971
+ rows.append([_safe_str(row[name]) for name in rec.dtype.names])
972
+ else:
973
+ # numeric 2D/1D table
974
+ if rec.ndim == 1:
975
+ for ri in range(nrows):
976
+ rows.append([_safe_str(rec[ri])])
977
+ else:
978
+ for ri in range(nrows):
979
+ rows.append([_safe_str(x) for x in rec[ri]])
980
+ return rows, [str(n) for n in names]
981
+
982
+
983
+ _shown_raw_preview_paths: set[str] = set()
984
+ _raw_preview_boxes: list[QMessageBox] = [] # prevent GC while shown
985
+
986
+ def _show_raw_preview_warning_nonmodal(path: str):
987
+ parent = QApplication.activeWindow()
988
+ box = QMessageBox(parent)
989
+ box.setIcon(QMessageBox.Icon.Warning)
990
+ box.setWindowTitle("RAW preview loaded")
991
+ box.setText(
992
+ "Linear RAW decoding failed for:\n"
993
+ f"{path}\n\n"
994
+ "Showing the camera’s embedded JPEG preview instead "
995
+ "(8-bit, non-linear). Some processing tools may be limited."
996
+ )
997
+ box.setStandardButtons(QMessageBox.StandardButton.Ok)
998
+ box.setWindowModality(Qt.WindowModality.NonModal) # ← fix here
999
+ box.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
1000
+
1001
+ _raw_preview_boxes.append(box)
1002
+ box.finished.connect(lambda _=None, b=box: _raw_preview_boxes.remove(b))
1003
+ box.show()
1004
+
1005
+ def maybe_warn_raw_preview(path: str, header):
1006
+ if not header or not bool(header.get("RAW_PREV", False)):
1007
+ return
1008
+ if path in _shown_raw_preview_paths:
1009
+ return
1010
+ _shown_raw_preview_paths.add(path)
1011
+ QTimer.singleShot(0, lambda p=path: _show_raw_preview_warning_nonmodal(p))
1012
+
1013
+ _np = np
1014
+
1015
+ class _RoiViewDocument(ImageDocument):
1016
+ def __init__(self, parent_doc: ImageDocument, roi: tuple[int,int,int,int], name_suffix: str = " (Preview)"):
1017
+ x, y, w, h = roi
1018
+ meta = dict(parent_doc.metadata or {})
1019
+ base = parent_doc.display_name()
1020
+ meta["display_name"] = f"{base}{name_suffix}"
1021
+ meta.setdefault("image_meta", {})
1022
+ meta["image_meta"] = dict(meta["image_meta"], readonly=True, view_kind="roi-preview")
1023
+
1024
+ super().__init__(_np.zeros((max(1,h), max(1,w), 3), dtype=_np.float32), meta, parent=parent_doc.parent())
1025
+
1026
+ self._parent_doc = parent_doc
1027
+ self._roi = ( x, y, w, h )
1028
+ self._roi_info = {"parent_doc": parent_doc, "roi": tuple(self._roi)}
1029
+ self.metadata["_roi_bounds"] = tuple(self._roi)
1030
+ imi = dict(self.metadata.get("image_meta") or {})
1031
+ imi.update({"roi": tuple(self._roi), "view_kind": "roi-preview"})
1032
+ self.metadata["image_meta"] = imi
1033
+
1034
+ # build and store an ROI-shifted WCS header snapshot to use if detached
1035
+ try:
1036
+ phdr = (parent_doc.metadata.get("original_header")
1037
+ or parent_doc.metadata.get("fits_header")
1038
+ or parent_doc.metadata.get("header"))
1039
+ rx, ry, rw, rh = self._roi
1040
+ roi_wcs = _compute_cropped_wcs(phdr, rx, ry, rw, rh)
1041
+ self.metadata["roi_wcs_header"] = roi_wcs # plain dict, drop-in safe
1042
+
1043
+ # 🔴 KEY FIX: for a standalone ROI doc, treat this cropped WCS
1044
+ # as the "original_header" so view-drops / duplicates inherit it.
1045
+ if phdr is not None:
1046
+ # optional: preserve the full parent header
1047
+ self.metadata.setdefault("parent_full_header", phdr)
1048
+ self.metadata["original_header"] = roi_wcs
1049
+ try:
1050
+ from .doc_manager import _snapshot_header_for_metadata # if you move it, adjust import
1051
+ except Exception:
1052
+ _snapshot_header_for_metadata = None
1053
+
1054
+ try:
1055
+ if _snapshot_header_for_metadata is not None:
1056
+ _snapshot_header_for_metadata(self.metadata)
1057
+ except Exception:
1058
+ pass
1059
+
1060
+ # DEBUG: log parent vs ROI WCS
1061
+ if _DEBUG_WCS:
1062
+ base_name = parent_doc.display_name() if hasattr(parent_doc, "display_name") else "<parent>"
1063
+ print(f"[WCS DEBUG] _RoiViewDocument.__init__: parent='{base_name}' roi={self._roi}")
1064
+ _debug_log_wcs_context(" parent_header", phdr)
1065
+ _debug_log_wcs_context(" roi_header", self.metadata)
1066
+ except Exception as e:
1067
+ if _DEBUG_WCS:
1068
+ print(f"[WCS DEBUG] _RoiViewDocument.__init__ exception: {e}")
1069
+ pass
1070
+ self.metadata["image_meta"] = imi
1071
+ # NEW: transient preview overlay for this ROI (None means "show parent slice")
1072
+ self._preview_override: _np.ndarray | None = None
1073
+
1074
+ self._pundo: list[tuple[_np.ndarray, dict, str]] = [] # (img, meta, name)
1075
+ self._predo: list[tuple[_np.ndarray, dict, str]] = [] # (img, meta, name)
1076
+
1077
+ @property
1078
+ def image(self):
1079
+ p = self._parent_doc
1080
+ if p is None or getattr(p, "image", None) is None:
1081
+ return None
1082
+ x, y, w, h = self._roi
1083
+ # If a preview override exists, show it; else show the live parent slice
1084
+ return self._preview_override if self._preview_override is not None else p.image[y:y+h, x:x+w]
1085
+
1086
+ @image.setter
1087
+ def image(self, _val):
1088
+ # ignore: writes should use DocManager(update/commit) paths
1089
+ pass
1090
+
1091
+
1092
+ def commit_to_parent(self, new_image: _np.ndarray | None = None,
1093
+ metadata: dict | None = None, step_name: str = "Edit"):
1094
+ """
1095
+ Paste current preview (or provided new_image) back into the parent image
1096
+ with proper undo and region repaint.
1097
+ """
1098
+ parent = getattr(self, "_parent_doc", None)
1099
+ if parent is None or parent.image is None:
1100
+ return
1101
+
1102
+ x, y, w, h = self._roi
1103
+ # choose source
1104
+ src = new_image
1105
+ if src is None:
1106
+ src = self._preview_override if self._preview_override is not None else parent.image[y:y+h, x:x+w]
1107
+
1108
+ img = _np.asarray(src, dtype=_np.float32, copy=False)
1109
+ base = parent.image
1110
+
1111
+ # channel reconciliation
1112
+ if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
1113
+ img = img[..., 0]
1114
+ if base.ndim == 3 and img.ndim == 2:
1115
+ img = _np.repeat(img[..., None], base.shape[2], axis=2)
1116
+ if img.shape[:2] != (h, w):
1117
+ raise ValueError(f"Commit shape {img.shape[:2]} does not match ROI {(h, w)}")
1118
+
1119
+ # push undo on parent and paste
1120
+ parent._undo.append((base.copy(), parent.metadata.copy(), step_name))
1121
+ parent._redo.clear()
1122
+ if metadata: parent.metadata.update(metadata)
1123
+ parent.metadata.setdefault("step_name", step_name)
1124
+
1125
+ new_full = base.copy()
1126
+ new_full[y:y+h, x:x+w] = img
1127
+ parent.image = new_full
1128
+ try: parent.changed.emit()
1129
+ except Exception as e:
1130
+ import logging
1131
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1132
+
1133
+ # notify region update + repaint
1134
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
1135
+ if dm is not None:
1136
+ try: dm.imageRegionUpdated.emit(parent, (x, y, w, h))
1137
+ except Exception as e:
1138
+ import logging
1139
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1140
+
1141
+
1142
+ # --- helper to snapshot what's currently visible in the Preview
1143
+ def _current_preview_copy(self) -> _np.ndarray:
1144
+ img = self.image # property: returns override or parent slice
1145
+ if img is None:
1146
+ return _np.zeros((1, 1), dtype=_np.float32)
1147
+ arr = _np.asarray(img, dtype=_np.float32)
1148
+ return _np.ascontiguousarray(arr)
1149
+
1150
+ # === KEEP YOUR WORKING BODY; only 3 added lines are marked "NEW" ===
1151
+ def apply_edit(self, new_image, metadata=None, step_name="Edit"):
1152
+ x, y, w, h = self._roi
1153
+ img = np.asarray(new_image, dtype=np.float32, copy=False)
1154
+ base = self._parent_doc.image
1155
+
1156
+ _debug_log_undo(
1157
+ "_RoiViewDocument.apply_edit.entry",
1158
+ roi=(x, y, w, h),
1159
+ parent_id=id(self._parent_doc) if self._parent_doc is not None else None,
1160
+ roi_doc_id=id(self),
1161
+ new_shape=getattr(img, "shape", None),
1162
+ step_name=step_name,
1163
+ pundo_len=len(self._pundo),
1164
+ predo_len=len(self._predo),
1165
+ )
1166
+ if base is not None:
1167
+ if base.ndim == 2 and img.ndim == 3 and img.shape[2] == 1:
1168
+ img = img[..., 0]
1169
+ if base.ndim == 3 and img.ndim == 2:
1170
+ img = np.repeat(img[..., None], base.shape[2], axis=2)
1171
+ if img.shape[:2] != (h, w):
1172
+ raise ValueError(f"Preview edit shape {img.shape[:2]} != ROI {(h, w)}")
1173
+
1174
+ img = np.ascontiguousarray(img)
1175
+
1176
+ # snapshot current visible preview for local undo
1177
+ self._pundo.append((self._current_preview_copy(), dict(self.metadata), step_name))
1178
+ self._predo.clear()
1179
+
1180
+ self._preview_override = img
1181
+ _debug_log_undo(
1182
+ "_RoiViewDocument.apply_edit.after",
1183
+ roi=(x, y, w, h),
1184
+ preview_shape=getattr(self._preview_override, "shape", None),
1185
+ pundo_len=len(self._pundo),
1186
+ predo_len=len(self._predo),
1187
+ step_name=step_name,
1188
+ )
1189
+
1190
+ if metadata:
1191
+ self.metadata.update(metadata)
1192
+ self.metadata.setdefault("step_name", step_name)
1193
+
1194
+ # 1) notify ROI listeners (e.g. the main window via _on_roi_changed)
1195
+ try:
1196
+ self.changed.emit()
1197
+ except Exception:
1198
+ pass
1199
+
1200
+ # 2) optionally: tell DocManager "ROI preview changed" using base doc + ROI
1201
+ dm = getattr(self, "_doc_manager", None)
1202
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1203
+ try:
1204
+ dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
1205
+ except Exception:
1206
+ pass
1207
+
1208
+
1209
+
1210
+ def _parent(self):
1211
+ return getattr(self, "_parent_doc", None)
1212
+
1213
+ def can_undo(self) -> bool:
1214
+ # Prefer local preview history if present
1215
+ if self._pundo:
1216
+ return True
1217
+ # Otherwise mirror parent’s history
1218
+ p = getattr(self, "_parent_doc", None)
1219
+ if p is not None and hasattr(p, "can_undo"):
1220
+ try:
1221
+ return bool(p.can_undo())
1222
+ except Exception:
1223
+ return False
1224
+ return False
1225
+
1226
+ def can_redo(self) -> bool:
1227
+ if self._predo:
1228
+ return True
1229
+ p = getattr(self, "_parent_doc", None)
1230
+ if p is not None and hasattr(p, "can_redo"):
1231
+ try:
1232
+ return bool(p.can_redo())
1233
+ except Exception:
1234
+ return False
1235
+ return False
1236
+
1237
+ def last_undo_name(self) -> str | None:
1238
+ if self._pundo:
1239
+ return self._pundo[-1][2]
1240
+ p = getattr(self, "_parent_doc", None)
1241
+ if p is not None and hasattr(p, "last_undo_name"):
1242
+ try:
1243
+ return p.last_undo_name()
1244
+ except Exception:
1245
+ return None
1246
+ return None
1247
+
1248
+ def last_redo_name(self) -> str | None:
1249
+ if self._predo:
1250
+ return self._predo[-1][2]
1251
+ p = getattr(self, "_parent_doc", None)
1252
+ if p is not None and hasattr(p, "last_redo_name"):
1253
+ try:
1254
+ return p.last_redo_name()
1255
+ except Exception:
1256
+ return None
1257
+ return None
1258
+
1259
+ def undo(self) -> str | None:
1260
+ # --- Case 1: ROI-local preview history ---
1261
+ if self._pundo:
1262
+ _debug_log_undo(
1263
+ "_RoiViewDocument.undo.local.entry",
1264
+ roi=self._roi,
1265
+ roi_doc_id=id(self),
1266
+ pundo_len=len(self._pundo),
1267
+ predo_len=len(self._predo),
1268
+ )
1269
+ # move current → redo; pop undo → current
1270
+ curr = self._current_preview_copy()
1271
+ self._predo.append((curr, dict(self.metadata), self._pundo[-1][2]))
1272
+
1273
+ prev_img, prev_meta, name = self._pundo.pop()
1274
+ self._preview_override = prev_img
1275
+ self.metadata = dict(prev_meta)
1276
+ _debug_log_undo(
1277
+ "_RoiViewDocument.undo.local.apply",
1278
+ roi=self._roi,
1279
+ new_preview_shape=getattr(prev_img, "shape", None),
1280
+ pundo_len=len(self._pundo),
1281
+ predo_len=len(self._predo),
1282
+ name=name,
1283
+ )
1284
+ try:
1285
+ self.changed.emit()
1286
+ except Exception:
1287
+ pass
1288
+
1289
+ dm = getattr(self, "_doc_manager", None)
1290
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1291
+ try:
1292
+ dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
1293
+ except Exception:
1294
+ pass
1295
+ return name
1296
+
1297
+ # --- Case 2: no ROI-local history → delegate to parent ---
1298
+ parent = getattr(self, "_parent_doc", None)
1299
+ if parent is None or not hasattr(parent, "undo"):
1300
+ return None
1301
+ _debug_log_undo(
1302
+ "_RoiViewDocument.undo.parent.entry",
1303
+ roi=self._roi,
1304
+ roi_doc_id=id(self),
1305
+ parent_id=id(parent),
1306
+ parent_undo_len=len(getattr(parent, "_undo", [])),
1307
+ parent_redo_len=len(getattr(parent, "_redo", [])),
1308
+ )
1309
+
1310
+ name = parent.undo()
1311
+
1312
+ # After parent changes, clear override so we show the new parent slice
1313
+ self._preview_override = None
1314
+
1315
+ try:
1316
+ self.changed.emit()
1317
+ except Exception:
1318
+ pass
1319
+
1320
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
1321
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1322
+ try:
1323
+ dm.previewRepaintRequested.emit(parent, self._roi)
1324
+ except Exception:
1325
+ pass
1326
+ return name
1327
+
1328
+ def redo(self) -> str | None:
1329
+ # --- Case 1: ROI-local preview history ---
1330
+ if self._predo:
1331
+ # move current → undo; pop redo → current
1332
+ curr = self._current_preview_copy()
1333
+ self._pundo.append((curr, dict(self.metadata), self._predo[-1][2]))
1334
+
1335
+ nxt_img, nxt_meta, name = self._predo.pop()
1336
+ self._preview_override = nxt_img
1337
+ self.metadata = dict(nxt_meta)
1338
+
1339
+ try:
1340
+ self.changed.emit()
1341
+ except Exception:
1342
+ pass
1343
+
1344
+ dm = getattr(self, "_doc_manager", None)
1345
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1346
+ try:
1347
+ dm.previewRepaintRequested.emit(self._parent_doc, self._roi)
1348
+ except Exception:
1349
+ pass
1350
+ return name
1351
+
1352
+ # --- Case 2: delegate to parent’s redo ---
1353
+ parent = getattr(self, "_parent_doc", None)
1354
+ if parent is None or not hasattr(parent, "redo"):
1355
+ return None
1356
+
1357
+ name = parent.redo()
1358
+
1359
+ # Parent changed → reset override and repaint
1360
+ self._preview_override = None
1361
+
1362
+ try:
1363
+ self.changed.emit()
1364
+ except Exception:
1365
+ pass
1366
+
1367
+ dm = getattr(self, "_doc_manager", None) or getattr(parent, "_doc_manager", None)
1368
+ if dm is not None and hasattr(dm, "previewRepaintRequested"):
1369
+ try:
1370
+ dm.previewRepaintRequested.emit(parent, self._roi)
1371
+ except Exception:
1372
+ pass
1373
+ return name
1374
+
1375
+
1376
+
1377
+ class LiveViewDocument(QObject):
1378
+ """
1379
+ Drop-in proxy that mirrors an ImageDocument API but always resolves
1380
+ via DocManager + view to the ROI-aware document (if a Preview tab is active).
1381
+ Reads: delegate to current resolved doc.
1382
+ Writes: use DocManager.update_active_document(...) so ROI is pasted back.
1383
+ """
1384
+ changed = pyqtSignal()
1385
+
1386
+ def __init__(self, doc_manager: "DocManager", view, base_doc: "ImageDocument"):
1387
+ super().__init__(parent=base_doc.parent())
1388
+ self._dm = doc_manager
1389
+ self._view = view # ImageSubWindow widget
1390
+ self._base = base_doc # true ImageDocument
1391
+
1392
+ # Bridge base document change signals (ROI wrappers rarely emit)
1393
+ try:
1394
+ base_doc.changed.connect(self.changed.emit)
1395
+ except Exception:
1396
+ pass
1397
+
1398
+ # ---- core resolver ----
1399
+ def _current(self):
1400
+ try:
1401
+ d = self._dm.get_document_for_view(self._view)
1402
+ return d or self._base
1403
+ except Exception:
1404
+ return self._base
1405
+
1406
+ # ---- common API surface (reads) ----
1407
+ @property
1408
+ def image(self):
1409
+ d = self._current()
1410
+ return getattr(d, "image", None)
1411
+
1412
+ @property
1413
+ def metadata(self):
1414
+ d = self._current()
1415
+ return getattr(d, "metadata", {}) or {}
1416
+
1417
+ def display_name(self):
1418
+ d = self._current()
1419
+ if hasattr(d, "display_name"):
1420
+ try:
1421
+ return d.display_name()
1422
+ except Exception:
1423
+ pass
1424
+ return self._base.display_name() if hasattr(self._base, "display_name") else "Untitled"
1425
+
1426
+ # Mask access stays consistent with whichever doc is current
1427
+ def get_active_mask(self):
1428
+ d = self._current()
1429
+ if hasattr(d, "get_active_mask"):
1430
+ try:
1431
+ return d.get_active_mask()
1432
+ except Exception:
1433
+ return None
1434
+ return None
1435
+
1436
+ @property
1437
+ def masks(self):
1438
+ d = self._current()
1439
+ return getattr(d, "masks", {})
1440
+
1441
+ @property
1442
+ def active_mask_id(self):
1443
+ d = self._current()
1444
+ return getattr(d, "active_mask_id", None)
1445
+
1446
+ # ---- writes route through DocManager so ROI is honored ----
1447
+ def apply_edit(self, new_image, metadata=None, step_name="Edit"):
1448
+ #print("[LiveViewDocument] apply_edit called, routing via DocManager")
1449
+ self._dm.update_active_document(new_image, dict(metadata or {}), step_name)
1450
+
1451
+ # ---- history helpers (optional pass-throughs) ----
1452
+ def can_undo(self):
1453
+ d = self._current()
1454
+ return bool(getattr(d, "can_undo", lambda: False)())
1455
+
1456
+ def can_redo(self):
1457
+ d = self._current()
1458
+ return bool(getattr(d, "can_redo", lambda: False)())
1459
+
1460
+ def last_undo_name(self):
1461
+ d = self._current()
1462
+ return getattr(d, "last_undo_name", lambda: None)()
1463
+
1464
+ def last_redo_name(self):
1465
+ d = self._current()
1466
+ return getattr(d, "last_redo_name", lambda: None)()
1467
+
1468
+ def undo(self):
1469
+ d = self._current()
1470
+ _debug_log_undo(
1471
+ "LiveViewDocument.undo.call",
1472
+ live_id=id(self),
1473
+ resolved_type=type(d).__name__ if d is not None else None,
1474
+ resolved_id=id(d) if d is not None else None,
1475
+ is_roi=isinstance(d, _RoiViewDocument),
1476
+ has_undo=getattr(d, "can_undo", lambda: False)(),
1477
+ )
1478
+ return getattr(d, "undo", lambda: None)()
1479
+
1480
+ def redo(self):
1481
+ d = self._current()
1482
+ _debug_log_undo(
1483
+ "LiveViewDocument.redo.call",
1484
+ live_id=id(self),
1485
+ resolved_type=type(d).__name__ if d is not None else None,
1486
+ resolved_id=id(d) if d is not None else None,
1487
+ is_roi=isinstance(d, _RoiViewDocument),
1488
+ has_redo=getattr(d, "can_redo", lambda: False)(),
1489
+ )
1490
+ return getattr(d, "redo", lambda: None)()
1491
+
1492
+
1493
+ # ---- generic fallback so existing attributes keep working ----
1494
+ def __getattr__(self, name):
1495
+ # Prefer the current resolved doc, then base_doc
1496
+ d = object.__getattribute__(self, "_current")()
1497
+ if hasattr(d, name):
1498
+ return getattr(d, name)
1499
+ return getattr(self._base, name)
1500
+
1501
+ def _xisf_meta_to_fits_header(m: dict) -> fits.Header | None:
1502
+ """
1503
+ Best-effort: pull common WCS keys out of XISF FITSKeywords into a fits.Header.
1504
+ Returns None if nothing usable found.
1505
+ """
1506
+ fk = m.get("FITSKeywords", {}) if isinstance(m, dict) else {}
1507
+ if not fk:
1508
+ return None
1509
+
1510
+ want = (
1511
+ "WCSAXES", "CTYPE1", "CTYPE2", "CRPIX1", "CRPIX2",
1512
+ "CRVAL1", "CRVAL2", "CD1_1", "CD1_2", "CD2_1", "CD2_2",
1513
+ "CDELT1", "CDELT2", "PC1_1", "PC1_2", "PC2_1", "PC2_2",
1514
+ "A_ORDER", "B_ORDER"
1515
+ )
1516
+
1517
+ hdr = fits.Header()
1518
+ found = False
1519
+ for k in want:
1520
+ vlist = fk.get(k)
1521
+ if vlist and isinstance(vlist, list) and vlist[0].get("value") is not None:
1522
+ hdr[k] = vlist[0]["value"]
1523
+ found = True
1524
+
1525
+ # also pull SIP coeffs if present
1526
+ for k, vlist in fk.items():
1527
+ if k.startswith(("A_", "B_", "AP_", "BP_")) and vlist and vlist[0].get("value") is not None:
1528
+ try:
1529
+ hdr[k] = float(vlist[0]["value"])
1530
+ found = True
1531
+ except Exception:
1532
+ pass
1533
+
1534
+ return hdr if found else None
1535
+
1536
+ DEBUG_SAVE_DOCUMENT = False
1537
+
1538
+ def debug_dump_metadata_print(meta: dict, context: str = ""):
1539
+ if DEBUG_SAVE_DOCUMENT:
1540
+ print(f"\n===== METADATA DUMP ({context}) =====")
1541
+ if not isinstance(meta, dict):
1542
+ print(" (not a dict) ->", type(meta))
1543
+ print("====================================")
1544
+ return
1545
+
1546
+ keys = sorted(str(k) for k in meta.keys())
1547
+ print(" keys:", ", ".join(keys))
1548
+
1549
+ for key in keys:
1550
+ val = meta[key]
1551
+ if isinstance(val, fits.Header):
1552
+ print(f" {key}: fits.Header with {len(val.cards)} cards")
1553
+ else:
1554
+ print(f" {key}: {val!r} ({type(val).__name__})")
1555
+
1556
+ print("===== END METADATA DUMP ({}) =====".format(context))
1557
+
1558
+ import time
1559
+ _DEBUG_DND_DUP = False
1560
+
1561
+ class DocManager(QObject):
1562
+ documentAdded = pyqtSignal(object) # ImageDocument
1563
+ documentRemoved = pyqtSignal(object) # ImageDocument
1564
+ imageRegionUpdated = pyqtSignal(object, object) # (doc, roi_tuple_or_None)
1565
+ previewRepaintRequested = pyqtSignal(object, object)
1566
+
1567
+ activeBaseChanged = pyqtSignal(object) # emits ImageDocument | None
1568
+
1569
+ def __init__(self, image_manager=None, parent=None):
1570
+ super().__init__(parent)
1571
+ self.image_manager = image_manager
1572
+ self._roi_doc_cache = {}
1573
+ self._docs: list[ImageDocument] = []
1574
+ self._active_doc: ImageDocument | None = None
1575
+ self._mdi: "QMdiArea | None" = None # type: ignore
1576
+ def _on_region_updated(doc, roi):
1577
+ vw = self._active_view_widget()
1578
+ if vw is not None:
1579
+ try:
1580
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1581
+ vw.refresh_from_docman(doc=doc, roi=roi)
1582
+ else:
1583
+ vw._render()
1584
+ except Exception:
1585
+ pass
1586
+
1587
+ self.imageRegionUpdated.connect(_on_region_updated)
1588
+ self._by_uid = {}
1589
+ self._focused_base_doc: ImageDocument | None = None # <— NEW
1590
+
1591
+ def _do_preview_repaint(doc, roi):
1592
+ vw = self._active_view_widget()
1593
+ if vw is not None:
1594
+ try:
1595
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1596
+ vw.refresh_from_docman(doc=doc, roi=roi)
1597
+ else:
1598
+ vw._render()
1599
+ except Exception:
1600
+ pass
1601
+ self.previewRepaintRequested.connect(_do_preview_repaint)
1602
+
1603
+ def get_document_for_view(self, view):
1604
+ """
1605
+ Given an ImageSubWindow widget, return either:
1606
+ - the full base ImageDocument
1607
+ - or a cached ROI-wrapper doc if a Preview/ROI tab is active
1608
+ Works with both old (has_active_preview/current_preview_roi) and
1609
+ new (_active_roi_tuple) view APIs. Falls back to view.document.
1610
+ """
1611
+ # 1) Resolve a base document from the view
1612
+ base = (
1613
+ getattr(view, "base_document", None)
1614
+ or getattr(view, "_base_document", None)
1615
+ or getattr(view, "document", None)
1616
+ )
1617
+ if base is None:
1618
+ return None
1619
+
1620
+ # 2) Try to discover an ROI (support both APIs)
1621
+ roi = None
1622
+ try:
1623
+ if hasattr(view, "has_active_preview") and callable(view.has_active_preview):
1624
+ if view.has_active_preview():
1625
+ # preferred old API
1626
+ try:
1627
+ roi = view.current_preview_roi() # (x,y,w,h)
1628
+ except Exception:
1629
+ roi = None
1630
+ except Exception:
1631
+ pass
1632
+
1633
+ if roi is None:
1634
+ # new API candidate
1635
+ for attr in ("_active_roi_tuple", "current_roi_tuple", "selected_roi", "roi"):
1636
+ try:
1637
+ fn = getattr(view, attr, None)
1638
+ if callable(fn):
1639
+ r = fn()
1640
+ if r and len(r) == 4:
1641
+ roi = r
1642
+ break
1643
+ except Exception:
1644
+ pass
1645
+
1646
+ # 3) If no ROI, return the base doc
1647
+ if not roi:
1648
+ return base
1649
+
1650
+ # 4) Cache and return a lightweight ROI view doc
1651
+ try:
1652
+ x, y, w, h = map(int, roi)
1653
+ key = (id(base), id(view), (x, y, w, h))
1654
+ roi_doc = self._roi_doc_cache.get(key)
1655
+ if roi_doc is None:
1656
+ roi_doc = self._build_roi_document(base, (x, y, w, h))
1657
+ self._roi_doc_cache[key] = roi_doc
1658
+ return roi_doc
1659
+ except Exception:
1660
+ # If anything about ROI construction fails, fall back
1661
+ return base
1662
+
1663
+ def _invalidate_roi_cache(self, parent_doc, roi_tuple):
1664
+ """Drop cached ROI docs that overlap an updated region of parent_doc."""
1665
+ if not roi_tuple:
1666
+ # full-image change -> drop all for this parent
1667
+ dead = [k for k in self._roi_doc_cache.keys() if k[0] == id(parent_doc)]
1668
+ else:
1669
+ px, py, pw, ph = roi_tuple
1670
+ def _overlaps(a, b):
1671
+ ax, ay, aw, ah = a; bx, by, bw, bh = b
1672
+ return not (ax+aw <= bx or bx+bw <= ax or ay+ah <= by or by+bh <= ay)
1673
+ dead = []
1674
+ for (parent_id, _view_id, aroi), _doc in list(self._roi_doc_cache.items()):
1675
+ if parent_id != id(parent_doc):
1676
+ continue
1677
+ if _overlaps(aroi, (px, py, pw, ph)):
1678
+ dead.append((parent_id, _view_id, aroi))
1679
+ for k in dead:
1680
+ self._roi_doc_cache.pop(k, None)
1681
+
1682
+ def get_document_by_uid(self, uid: str):
1683
+ return self._by_uid.get(uid)
1684
+
1685
+
1686
+ def _register_doc(self, doc):
1687
+ import weakref
1688
+ # Only ImageDocument needs the backref; tables can ignore it.
1689
+ if hasattr(doc, "image") or hasattr(doc, "apply_edit"):
1690
+ try:
1691
+ doc._doc_manager = weakref.proxy(self) # avoid cycles
1692
+ except Exception:
1693
+ doc._doc_manager = self # fallback
1694
+ self._docs.append(doc)
1695
+ if hasattr(doc, "uid"):
1696
+ self._by_uid[doc.uid] = doc
1697
+ self.documentAdded.emit(doc)
1698
+
1699
+ def _build_roi_document(self, base_doc, roi):
1700
+ #print("[DocManager] Building ROI view document")
1701
+ doc = _RoiViewDocument(base_doc, roi, name_suffix=" (Preview)")
1702
+ try:
1703
+ import weakref
1704
+ doc._doc_manager = weakref.proxy(self)
1705
+ except Exception:
1706
+ doc._doc_manager = self
1707
+
1708
+ # Repaint the active view on ROI preview changes, but DO NOT invalidate cache.
1709
+ try:
1710
+ #print("[DocManager] Connecting ROI view document change signal")
1711
+ import weakref
1712
+ dm_ref = weakref.ref(self)
1713
+ roi_tuple = tuple(map(int, roi))
1714
+
1715
+ def _on_roi_changed():
1716
+ dm = dm_ref()
1717
+ if dm is None:
1718
+ return
1719
+ vw = dm._active_view_widget()
1720
+ if vw is not None:
1721
+ try:
1722
+ # IMPORTANT: use the *parent* doc here, not the ROI wrapper
1723
+ base = getattr(doc, "_parent_doc", None) or doc
1724
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1725
+ vw.refresh_from_docman(doc=base, roi=roi_tuple)
1726
+ else:
1727
+ vw._render()
1728
+ except Exception:
1729
+ pass
1730
+
1731
+ doc.changed.connect(_on_roi_changed)
1732
+
1733
+ #print("[DocManager] ROI view document change signal connected")
1734
+ except Exception:
1735
+ print("[DocManager] Failed to connect ROI view document change signal")
1736
+ pass
1737
+
1738
+ return doc
1739
+
1740
+ def commit_active_preview_to_parent(self, metadata: dict | None = None, step_name: str = "Edit"):
1741
+ doc = self.get_active_document()
1742
+ if isinstance(doc, _RoiViewDocument):
1743
+ doc.commit_to_parent(None, metadata=metadata or {}, step_name=step_name)
1744
+ # after commit, force an immediate view repaint
1745
+ vw = self._active_view_widget()
1746
+ if vw is not None:
1747
+ try:
1748
+ if hasattr(vw, "refresh_from_docman") and callable(vw.refresh_from_docman):
1749
+ vw.refresh_from_docman(doc=doc._parent_doc, roi=None)
1750
+ else:
1751
+ vw._render()
1752
+ except Exception:
1753
+ pass
1754
+
1755
+ def wrap_document_for_view(self, view, base_doc: ImageDocument) -> LiveViewDocument:
1756
+ """Return a live, ROI-aware proxy for this view."""
1757
+ return LiveViewDocument(self, view, base_doc)
1758
+
1759
+ def open_path(self, path: str):
1760
+ ext = os.path.splitext(path)[1].lower().lstrip('.')
1761
+ norm_ext = _normalize_ext(ext)
1762
+
1763
+ lower_path = path.lower()
1764
+ is_fits = lower_path.endswith((".fit", ".fits", ".fit.gz", ".fits.gz", ".fz"))
1765
+ is_xisf = (norm_ext == "xisf")
1766
+
1767
+ primary_doc = None
1768
+ created_any = False
1769
+
1770
+ # ---------- 1) Try the universal loader first (ALL formats) ----------
1771
+ img = header = bit_depth = is_mono = None
1772
+ meta = None
1773
+ try:
1774
+ # NEW: prefer metadata-aware return
1775
+ out = legacy_load_image(path, return_metadata=True)
1776
+ if out and len(out) == 5:
1777
+ img, header, bit_depth, is_mono, meta = out
1778
+ else:
1779
+ img, header, bit_depth, is_mono = out
1780
+ except TypeError:
1781
+ # legacy_load_image older signature → fall back
1782
+ try:
1783
+ img, header, bit_depth, is_mono = legacy_load_image(path)
1784
+ except Exception as e:
1785
+ print(f"[DocManager] legacy_load_image failed (non-fatal if FITS/XISF): {e}")
1786
+ except Exception as e:
1787
+ print(f"[DocManager] legacy_load_image failed (non-fatal if FITS/XISF): {e}")
1788
+
1789
+ maybe_warn_raw_preview(path, header)
1790
+
1791
+ if img is not None:
1792
+ if meta is None:
1793
+ meta = {
1794
+ "file_path": path,
1795
+ "original_header": header,
1796
+ "bit_depth": bit_depth,
1797
+ "is_mono": is_mono,
1798
+ "original_format": norm_ext,
1799
+ }
1800
+
1801
+ # NEW: attach WCS even for old loader
1802
+ meta = attach_wcs_to_metadata(meta, header)
1803
+
1804
+ _snapshot_header_for_metadata(meta)
1805
+
1806
+ img = _normalize_image_01(img)
1807
+ primary_doc = ImageDocument(img, meta)
1808
+ self._register_doc(primary_doc)
1809
+ created_any = True
1810
+
1811
+
1812
+
1813
+ # ---------- 2) FITS: enumerate HDUs (tables + extra images + ICC) ----------
1814
+ if is_fits:
1815
+ try:
1816
+ with fits.open(path, memmap=True) as hdul:
1817
+ base = os.path.basename(path)
1818
+
1819
+
1820
+ for i, hdu in enumerate(hdul):
1821
+ name_up = (getattr(hdu, "name", "PRIMARY") or "PRIMARY").upper()
1822
+ if primary_doc is not None and (i == 0 or name_up == "PRIMARY"):
1823
+
1824
+ continue
1825
+
1826
+ ext_hdr = hdu.header
1827
+ try:
1828
+ en = str(ext_hdr.get("EXTNAME", "")).strip()
1829
+ ev = ext_hdr.get("EXTVER", None)
1830
+ extname = f"{en}[{int(ev)}]" if (en and isinstance(ev, (int, np.integer))) else (en or "")
1831
+ except Exception:
1832
+ extname = ""
1833
+
1834
+ # --- Tables → TableDocument ---
1835
+ if isinstance(hdu, (fits.BinTableHDU, fits.TableHDU)):
1836
+ key_str = extname or f"HDU{i}"
1837
+ nice = key_str
1838
+ #print(f"[DocManager] HDU {i}: {type(hdu).__name__} '{nice}' → Table")
1839
+
1840
+ # Optional CSV export
1841
+ csv_name = f"{os.path.splitext(path)[0]}_{key_str}.csv".replace(" ", "_")
1842
+ try:
1843
+ _ = _fits_table_to_csv(hdu, csv_name)
1844
+ except Exception as e_csv:
1845
+ print(f"[DocManager] Table CSV export failed ({nice}): {e_csv}")
1846
+ csv_name = None
1847
+
1848
+ # Build in-app table
1849
+ try:
1850
+ rows, headers = _fits_table_to_rows_headers(hdu, max_rows=500000)
1851
+ tmeta = {
1852
+ "file_path": f"{path}::{key_str}",
1853
+ "original_header": ext_hdr,
1854
+ "original_format": "fits",
1855
+ "display_name": f"{base} {key_str} (Table)",
1856
+ "doc_type": "table",
1857
+ "table_csv": csv_name if (csv_name and os.path.exists(csv_name)) else None,
1858
+ }
1859
+ _snapshot_header_for_metadata(tmeta)
1860
+ tdoc = TableDocument(rows, headers, tmeta, parent=self.parent())
1861
+ self._register_doc(tdoc)
1862
+ try: tdoc.changed.emit()
1863
+ except Exception as e:
1864
+ import logging
1865
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1866
+ created_any = True
1867
+ #print(f"[DocManager] Added TableDocument: rows={len(rows)} cols={len(headers)} title='{tdoc.display_name()}'")
1868
+ except Exception as e_tab:
1869
+ print(f"[DocManager] Table HDU {nice} → in-app view failed: {e_tab}")
1870
+ continue # IMPORTANT: don’t treat a table as an image
1871
+
1872
+ # --- Not a table: ICC or image ---
1873
+ if hdu.data is None:
1874
+ #print(f"[DocManager] HDU {i} '{extname or f'HDU{i}'}' has no data — noted as aux")
1875
+ continue
1876
+
1877
+ arr = np.asanyarray(hdu.data)
1878
+ en_up = (extname or "").upper()
1879
+ is_probable_icc = ("ICC" in en_up or "PROFILE" in en_up)
1880
+
1881
+ # ICC ONLY if name suggests ICC/profile AND data is 1-D uint8
1882
+ if arr.ndim == 1 and arr.dtype == np.uint8 and is_probable_icc:
1883
+ try:
1884
+ icc_path = f"{os.path.splitext(path)[0]}_{extname or f'HDU{i}'}_.icc".replace(" ", "_")
1885
+ with open(icc_path, "wb") as f:
1886
+ f.write(arr.tobytes())
1887
+ #print(f"[DocManager] Extracted ICC profile → {icc_path}")
1888
+ created_any = True
1889
+ continue
1890
+ except Exception as e_icc:
1891
+ print(f"[DocManager] ICC export failed: {e_icc} — will try as image")
1892
+
1893
+ # Otherwise: treat as image doc
1894
+ try:
1895
+ if arr.dtype.kind in "ui":
1896
+ a = arr.astype(np.float32, copy=False) # NO normalization
1897
+ # optional: if you want to record original scale:
1898
+ ext_depth = f"{arr.dtype.itemsize*8}-bit {'unsigned' if arr.dtype.kind=='u' else 'signed'}"
1899
+ else:
1900
+ a = arr.astype(np.float32, copy=False) # floats preserved
1901
+ ext_depth = "32-bit floating point" if arr.dtype == np.float32 else "64-bit floating point"
1902
+
1903
+ ext_mono = bool(a.ndim == 2 or (a.ndim == 3 and a.shape[2] == 1))
1904
+ key_str = extname or f"HDU {i}"
1905
+ disp = f"{base} {key_str}"
1906
+
1907
+ aux_meta = {
1908
+ "file_path": f"{path}::{key_str}",
1909
+ "original_header": ext_hdr,
1910
+ "bit_depth": ext_depth,
1911
+ "is_mono": bool(ext_mono),
1912
+ "original_format": "fits",
1913
+ "image_meta": {"derived_from": path, "layer": key_str, "readonly": True},
1914
+ "display_name": disp,
1915
+ }
1916
+
1917
+ # NEW: attach WCS from this HDU header
1918
+ aux_meta = attach_wcs_to_metadata(aux_meta, ext_hdr)
1919
+
1920
+ _snapshot_header_for_metadata(aux_meta)
1921
+ a = _normalize_image_01(a)
1922
+ aux_doc = ImageDocument(a, aux_meta)
1923
+
1924
+ self._register_doc(aux_doc)
1925
+ try: aux_doc.changed.emit()
1926
+ except Exception as e:
1927
+ import logging
1928
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1929
+ created_any = True
1930
+
1931
+ except Exception as e_img:
1932
+ print(f"[DocManager] FITS HDU {i} image build failed: {e_img}")
1933
+ except Exception as _e:
1934
+ print(f"[DocManager] FITS HDU enumeration failed: {_e}")
1935
+
1936
+ # ---------- 3) XISF: create primary if needed, then enumerate extras ----------
1937
+ if is_xisf:
1938
+ try:
1939
+ # helpers
1940
+ def _bit_depth_from_dtype(dt: np.dtype) -> str:
1941
+ dt = np.dtype(dt)
1942
+ if dt == np.float32: return "32-bit floating point"
1943
+ if dt == np.float64: return "64-bit floating point"
1944
+ if dt == np.uint8: return "8-bit"
1945
+ if dt == np.uint16: return "16-bit"
1946
+ if dt == np.uint32: return "32-bit unsigned"
1947
+ return "32-bit floating point"
1948
+
1949
+ def _to_float32_01(arr: np.ndarray) -> np.ndarray:
1950
+ a = np.asarray(arr)
1951
+ if a.dtype == np.float32:
1952
+ return a
1953
+ if a.dtype.kind in "iu":
1954
+ return (a.astype(np.float32) / np.iinfo(a.dtype).max).clip(0.0, 1.0)
1955
+ return a.astype(np.float32, copy=False)
1956
+
1957
+ def _to_float32_preserve(arr: np.ndarray) -> np.ndarray:
1958
+ a = np.asarray(arr)
1959
+ return a if a.dtype == np.float32 else a.astype(np.float32, copy=False)
1960
+
1961
+ xisf = XISFReader(path)
1962
+ metas = xisf.get_images_metadata() or []
1963
+ base = os.path.basename(path)
1964
+
1965
+ # If legacy did NOT create a primary, build image #0 now
1966
+ if primary_doc is None and len(metas) >= 1:
1967
+ try:
1968
+ arr0 = xisf.read_image(0, data_format="channels_last")
1969
+ arr0_f32 = _to_float32_preserve(arr0)
1970
+ arr0_f32 = _normalize_image_01(arr0_f32)
1971
+ bd0 = _bit_depth_from_dtype(metas[0].get("dtype", arr0.dtype))
1972
+ is_mono0 = (arr0_f32.ndim == 2) or (arr0_f32.ndim == 3 and arr0_f32.shape[2] == 1)
1973
+
1974
+ # Friendly label for #0
1975
+ label0 = metas[0].get("id") or "Image[0]"
1976
+ md0 = {
1977
+ "file_path": f"{path}::XISF[0]",
1978
+ "original_header": metas[0], # will be sanitized
1979
+ "bit_depth": bd0,
1980
+ "is_mono": is_mono0,
1981
+ "original_format": "xisf",
1982
+ "image_meta": {"derived_from": path, "layer_index": 0, "readonly": True},
1983
+ "display_name": f"{base} {label0}",
1984
+ }
1985
+ # NEW: attach WCS if possible
1986
+ hdr0 = _xisf_meta_to_fits_header(metas[0])
1987
+ if hdr0 is not None:
1988
+ md0 = attach_wcs_to_metadata(md0, hdr0)
1989
+
1990
+ _snapshot_header_for_metadata(md0)
1991
+ primary_doc = ImageDocument(arr0_f32, md0)
1992
+ self._register_doc(primary_doc)
1993
+ try: primary_doc.changed.emit()
1994
+ except Exception as e:
1995
+ import logging
1996
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
1997
+ created_any = True
1998
+
1999
+ except Exception as e0:
2000
+ print(f"[DocManager] XISF primary (index 0) open failed: {e0}")
2001
+
2002
+ # Add images 1..N-1 as siblings (even if primary came from legacy)
2003
+ for i in range(1, len(metas)):
2004
+ try:
2005
+ m = metas[i]
2006
+ arr = xisf.read_image(i, data_format="channels_last")
2007
+ arr_f32 = _to_float32_preserve(arr)
2008
+ arr_f32 = _normalize_image_01(arr_f32)
2009
+
2010
+ bd = _bit_depth_from_dtype(m.get("dtype", arr.dtype))
2011
+ is_mono_i = (arr_f32.ndim == 2) or (arr_f32.ndim == 3 and arr_f32.shape[2] == 1)
2012
+
2013
+ # Friendly label: prefer id, else EXTNAME/EXTVER in FITSKeywords, else index
2014
+ label = m.get("id") or None
2015
+ if not label:
2016
+ try:
2017
+ fk = m.get("FITSKeywords", {})
2018
+ en = (fk.get("EXTNAME") or [{}])[0].get("value", "")
2019
+ ev = (fk.get("EXTVER") or [{}])[0].get("value", "")
2020
+ if en:
2021
+ label = f"{en}[{ev}]" if ev else en
2022
+ except Exception:
2023
+ pass
2024
+ if not label:
2025
+ label = f"Image[{i}]"
2026
+
2027
+ md = {
2028
+ "file_path": f"{path}::XISF[{i}]",
2029
+ "original_header": m, # snapshot; sanitized below
2030
+ "bit_depth": bd,
2031
+ "is_mono": is_mono_i,
2032
+ "original_format": "xisf",
2033
+ "image_meta": {"derived_from": path, "layer_index": i, "readonly": True},
2034
+ "display_name": f"{base} {label}",
2035
+ }
2036
+ hdri = _xisf_meta_to_fits_header(m)
2037
+ if hdri is not None:
2038
+ md = attach_wcs_to_metadata(md, hdri)
2039
+
2040
+ _snapshot_header_for_metadata(md)
2041
+ sib = ImageDocument(arr_f32, md)
2042
+ self._register_doc(sib)
2043
+ try: sib.changed.emit()
2044
+ except Exception as e:
2045
+ import logging
2046
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
2047
+ created_any = True
2048
+
2049
+ except Exception as _e:
2050
+ print(f"[DocManager] XISF image {i} skipped: {_e}")
2051
+ except Exception as _e:
2052
+ print(f"[DocManager] XISF open/enumeration failed: {_e}")
2053
+
2054
+ # ---------- 4) Return sensible doc or raise ----------
2055
+ if primary_doc is not None:
2056
+ return primary_doc
2057
+ if created_any:
2058
+ return self._docs[-1] # e.g., a table-only FITS or extra XISF image
2059
+
2060
+ raise IOError(f"Could not load: {path}")
2061
+
2062
+ # --- Subwindow / ROI awareness -------------------------------------
2063
+ def _active_subwindow(self):
2064
+ """Return the active QMdiSubWindow (if any)."""
2065
+ if self._mdi is None:
2066
+ return None
2067
+ try:
2068
+ return self._mdi.activeSubWindow()
2069
+ except Exception:
2070
+ return None
2071
+
2072
+ def _active_view_widget(self):
2073
+ """Return the active view widget (ImageSubWindow or TableSubWindow)."""
2074
+ sw = self._active_subwindow()
2075
+ if not sw:
2076
+ return None
2077
+ try:
2078
+ return sw.widget()
2079
+ except Exception:
2080
+ return None
2081
+
2082
+ def _active_preview_roi(self):
2083
+ """
2084
+ Returns (x,y,w,h) if the active view is an ImageSubWindow with a selected Preview tab.
2085
+ Else returns None.
2086
+ """
2087
+ #print("[DocManager] Checking for active preview ROI")
2088
+ vw = self._active_view_widget()
2089
+ if vw and hasattr(vw, "has_active_preview") and vw.has_active_preview():
2090
+ try:
2091
+ return vw.current_preview_roi()
2092
+ except Exception:
2093
+ return None
2094
+ return None
2095
+
2096
+ def get_active_image(self, prefer_preview: bool = True):
2097
+ """
2098
+ Unified read: returns the ndarray a tool should operate on.
2099
+ If a Preview tab is active and prefer_preview=True, return that crop.
2100
+ Otherwise return the full document image.
2101
+ """
2102
+ doc = self.get_active_document()
2103
+ if doc is None or doc.image is None:
2104
+ return None
2105
+ roi = self._active_preview_roi() if prefer_preview else None
2106
+ if roi is None:
2107
+ return doc.image
2108
+ x, y, w, h = roi
2109
+ return doc.image[y:y+h, x:x+w]
2110
+
2111
+
2112
+ # --- Slot -> Document ---
2113
+ def open_from_slot(self, slot_idx: int | None = None) -> "ImageDocument | None":
2114
+ if not self.image_manager:
2115
+ return None
2116
+
2117
+ if slot_idx is None:
2118
+ slot_idx = getattr(self.image_manager, "current_slot", 0)
2119
+
2120
+ img = self.image_manager.get_image_for_slot(slot_idx)
2121
+ if img is None:
2122
+ return None
2123
+
2124
+ meta = {}
2125
+ try:
2126
+ meta = dict(self.image_manager._metadata.get(slot_idx, {}))
2127
+ except Exception:
2128
+ pass
2129
+
2130
+ meta.setdefault("file_path", f"Slot {slot_idx}")
2131
+ meta.setdefault("bit_depth", "32-bit floating point")
2132
+ meta.setdefault("is_mono", (img.ndim == 2))
2133
+ meta.setdefault("original_header", meta.get("original_header")) # whatever SASv2 had
2134
+ meta.setdefault("original_format", "fits")
2135
+
2136
+ _snapshot_header_for_metadata(meta)
2137
+
2138
+ doc = ImageDocument(img, meta)
2139
+ self._register_doc(doc)
2140
+ return doc
2141
+
2142
+ # --- Save ---
2143
+ def _infer_bit_depth_for_format(self, img: np.ndarray, ext: str, current_bit_depth: str | None) -> str:
2144
+ # Previous heuristic fallback (used only if no override provided).
2145
+ if ext in ("png", "jpg"):
2146
+ return "8-bit"
2147
+ if ext in ("fits", "fit"):
2148
+ return "32-bit floating point"
2149
+ if ext == "tif":
2150
+ if current_bit_depth in _ALLOWED_DEPTHS["tif"]:
2151
+ return current_bit_depth
2152
+ return "16-bit" if np.issubdtype(img.dtype, np.floating) else "8-bit"
2153
+ if ext == "xisf":
2154
+ return current_bit_depth if current_bit_depth in _ALLOWED_DEPTHS["xisf"] else "32-bit floating point"
2155
+ return "32-bit floating point"
2156
+
2157
+ def save_document(
2158
+ self,
2159
+ doc: "ImageDocument",
2160
+ path: str,
2161
+ bit_depth: str | None = None,
2162
+ *,
2163
+ bit_depth_override: str | None = None,
2164
+ ):
2165
+ """
2166
+ Save the given ImageDocument to 'path'.
2167
+
2168
+ bit_depth_override:
2169
+ New-style explicit choice from a dialog.
2170
+
2171
+ bit_depth:
2172
+ Legacy positional argument; still honored if override is None.
2173
+ """
2174
+ ext = _normalize_ext(os.path.splitext(path)[1])
2175
+ img = doc.image
2176
+ meta = doc.metadata or {}
2177
+
2178
+ # ── MASSIVE DEBUG: show everything we know coming in ───────────────
2179
+ debug_dump_metadata_print(meta, context="save_document: BEFORE HEADER PICK")
2180
+
2181
+ # --- Decide final bit depth ---------------------------------------
2182
+ requested = bit_depth_override or bit_depth or meta.get("bit_depth")
2183
+
2184
+ if requested:
2185
+ allowed = _ALLOWED_DEPTHS.get(ext, set())
2186
+ if allowed and requested not in allowed:
2187
+ print(f"[save_document] Requested bit depth {requested!r} "
2188
+ f"not in allowed {allowed}, falling back to first.")
2189
+ final_bit_depth = next(iter(allowed))
2190
+ else:
2191
+ final_bit_depth = requested
2192
+ else:
2193
+ final_bit_depth = self._infer_bit_depth_for_format(
2194
+ img, ext, meta.get("bit_depth")
2195
+ )
2196
+
2197
+
2198
+
2199
+ # --- Clip if needed for integer encodes ---------------------------
2200
+ needs_clip = (
2201
+ ext in ("png", "jpg", "jpeg", "tif", "tiff")
2202
+ and final_bit_depth in ("8-bit", "16-bit", "32-bit unsigned")
2203
+ )
2204
+ if needs_clip:
2205
+ print("[save_document] Clipping image to [0,1] for integer encode.")
2206
+ img_to_save = np.clip(img, 0.0, 1.0) if needs_clip else img
2207
+
2208
+ # --- PICK THE HEADER EXPLICITLY -----------------------------------
2209
+ # Priority:
2210
+ # 1) wcs_header
2211
+ # 2) fits_header
2212
+ # 3) original_header
2213
+ # 4) header
2214
+ effective_header = None
2215
+ for key in ("original_header", "fits_header", "wcs_header", "header"):
2216
+ val = meta.get(key)
2217
+ if isinstance(val, fits.Header):
2218
+ effective_header = val
2219
+
2220
+ break
2221
+
2222
+ #if effective_header is None:
2223
+ # print("[save_document] WARNING: No fits.Header in metadata, "
2224
+ # "legacy_save_image will pick a default header.")
2225
+ #else:
2226
+ # # Print first few cards so we can confirm we have the SIP stuff
2227
+ # print("[save_document] effective_header preview (first 25 cards):")
2228
+ # for i, card in enumerate(effective_header.cards):
2229
+ # if i >= 25:
2230
+ # print(" ... (truncated)")
2231
+ # break
2232
+ # print(f" {card.keyword:8s} = {card.value!r}")
2233
+
2234
+ # ── Call the legacy saver ─────────────────────────────────────────
2235
+
2236
+
2237
+ legacy_save_image(
2238
+ img_array=img_to_save,
2239
+ filename=path,
2240
+ original_format=ext,
2241
+ bit_depth=final_bit_depth,
2242
+ original_header=effective_header,
2243
+ is_mono=meta.get("mono", img.ndim == 2),
2244
+ image_meta=meta.get("image_meta"),
2245
+ file_meta=meta.get("file_meta"),
2246
+ wcs_header=meta.get("wcs_header"),
2247
+ )
2248
+
2249
+ # ── Update metadata in memory to match what we just wrote ─────────
2250
+ meta["file_path"] = path
2251
+ meta["original_format"] = ext
2252
+ meta["bit_depth"] = final_bit_depth
2253
+
2254
+ if isinstance(effective_header, fits.Header):
2255
+ meta["original_header"] = effective_header
2256
+
2257
+ # If you have this helper, keep it; if not, you can skip it
2258
+ try:
2259
+ _snapshot_header_for_metadata(meta)
2260
+ except Exception as e:
2261
+ print("[save_document] _snapshot_header_for_metadata error:", e)
2262
+
2263
+ doc.metadata = meta
2264
+
2265
+ # reset dirty flag
2266
+ if hasattr(doc, "dirty"):
2267
+ doc.dirty = False
2268
+
2269
+ if hasattr(doc, "changed"):
2270
+ doc.changed.emit()
2271
+
2272
+ def _current_view_title_for_doc(self, source_doc: ImageDocument) -> str | None:
2273
+ """
2274
+ If the active MDI subwindow is showing 'source_doc' (or its parent/base),
2275
+ return the current view's title (windowTitle), otherwise None.
2276
+ """
2277
+ sw = self._active_subwindow()
2278
+ if sw is None:
2279
+ return None
2280
+
2281
+ try:
2282
+ w = sw.widget()
2283
+ except Exception:
2284
+ w = None
2285
+
2286
+ # Resolve what doc the active view corresponds to (base doc)
2287
+ try:
2288
+ base = (
2289
+ getattr(w, "base_document", None)
2290
+ or getattr(w, "_base_document", None)
2291
+ or getattr(w, "document", None)
2292
+ or getattr(sw, "document", None)
2293
+ )
2294
+ parent = getattr(base, "_parent_doc", None)
2295
+ if isinstance(parent, ImageDocument):
2296
+ base = parent
2297
+ except Exception:
2298
+ base = None
2299
+
2300
+ if base is not source_doc:
2301
+ return None
2302
+
2303
+ # Prefer the actual subwindow title (includes [View N], etc.)
2304
+ try:
2305
+ title = sw.windowTitle()
2306
+ title = title.strip() if isinstance(title, str) else ""
2307
+ return title or None
2308
+ except Exception:
2309
+ return None
2310
+
2311
+
2312
+ def duplicate_document(self, source_doc: ImageDocument, new_name: str | None = None) -> ImageDocument:
2313
+ # DEBUG: log the source doc WCS before we touch anything
2314
+ if _DEBUG_WCS:
2315
+ try:
2316
+ name = source_doc.display_name() if hasattr(source_doc, "display_name") else "<src>"
2317
+ except Exception:
2318
+ name = "<src>"
2319
+
2320
+ _debug_log_wcs_context(" source.metadata", getattr(source_doc, "metadata", {}))
2321
+ if _DEBUG_DND_DUP:
2322
+ try:
2323
+ src_dn = source_doc.display_name() if hasattr(source_doc, "display_name") else None
2324
+ except Exception:
2325
+ src_dn = None
2326
+ print("\n[DNDDBG:DUPLICATE_DOCUMENT]")
2327
+ print(" source_doc:", source_doc, "id:", id(source_doc), "uid:", getattr(source_doc,"uid",None))
2328
+ print(" source_doc.display_name():", src_dn)
2329
+ print(" new_name arg:", new_name)
2330
+ # COPY-ON-WRITE: Share the source image instead of copying immediately.
2331
+ # The duplicate's apply_edit will copy when it first modifies the image.
2332
+ # This saves memory when duplicates are created but not modified.
2333
+ img_ref = source_doc.image # Shared reference, no copy
2334
+
2335
+ meta = dict(source_doc.metadata or {})
2336
+
2337
+ # ✅ Use CURRENT VIEW NAME if this doc is what's active; else fall back to doc display_name()
2338
+ base = self._current_view_title_for_doc(source_doc) or source_doc.display_name()
2339
+
2340
+ dup_title = (new_name or f"{base}_duplicate")
2341
+
2342
+ # 🚫 strip any lingering emojis / link markers
2343
+ dup_title = dup_title.replace("🔗", "").strip()
2344
+
2345
+ meta["display_name"] = dup_title
2346
+ if _DEBUG_DND_DUP:
2347
+ print(" dup_title computed:", dup_title)
2348
+ # Remove anything that makes the view look "linked/preview"
2349
+ imi = dict(meta.get("image_meta") or {})
2350
+ for k in ("readonly", "view_kind", "derived_from", "layer", "layer_index", "linked"):
2351
+ imi.pop(k, None)
2352
+ meta["image_meta"] = imi
2353
+ for k in list(meta.keys()):
2354
+ if k.startswith("_roi_") or k.endswith("_roi") or k == "roi":
2355
+ meta.pop(k, None)
2356
+
2357
+ # NOTE: we intentionally DO NOT remove "roi_wcs_header" or "original_header"
2358
+ # so that a ROI doc keeps its cropped WCS in the duplicate.
2359
+
2360
+ # Safe bit depth / mono flags
2361
+ meta.setdefault("original_format", meta.get("original_format", "fits"))
2362
+ if isinstance(img_ref, np.ndarray):
2363
+ meta["is_mono"] = (img_ref.ndim == 2 or (img_ref.ndim == 3 and img_ref.shape[2] == 1))
2364
+
2365
+ _snapshot_header_for_metadata(meta)
2366
+
2367
+ dup = ImageDocument(img_ref, meta, parent=self.parent())
2368
+ # Mark this duplicate as sharing image data with source
2369
+ dup._cow_source = source_doc
2370
+ self._register_doc(dup)
2371
+ if _DEBUG_DND_DUP:
2372
+ try:
2373
+ dn = dup.display_name() if hasattr(dup, "display_name") else None
2374
+ except Exception:
2375
+ dn = None
2376
+ print(" dup.metadata.display_name:", (dup.metadata or {}).get("display_name"))
2377
+ print(" dup.display_name():", dn)
2378
+ # DEBUG: log the duplicate doc WCS
2379
+ if _DEBUG_WCS:
2380
+ try:
2381
+ dname = dup.display_name()
2382
+ except Exception:
2383
+ dname = "<dup>"
2384
+
2385
+ _debug_log_wcs_context(" duplicate.metadata", dup.metadata)
2386
+
2387
+ return dup
2388
+
2389
+ #def open_array(self, arr, metadata: dict | None = None, title: str | None = None) -> ImageDocument:
2390
+ # import numpy as np
2391
+ ## if arr is None:
2392
+ # raise ValueError("open_array: arr is None")
2393
+ # img = np.asarray(arr)
2394
+ # if img.dtype != np.float32:
2395
+ # img = img.astype(np.float32, copy=False)
2396
+
2397
+ # meta = dict(metadata or {})
2398
+ # meta.setdefault("bit_depth", "32-bit floating point")
2399
+ # meta.setdefault("is_mono", img.ndim == 2)
2400
+ # meta.setdefault("original_header", meta.get("original_header"))
2401
+ # meta.setdefault("original_format", meta.get("original_format", "fits"))
2402
+ # if title:
2403
+ # meta.setdefault("display_name", title)
2404
+
2405
+ # doc = ImageDocument(img, meta, parent=self.parent())
2406
+ # self._docs.append(doc)
2407
+ # self.documentAdded.emit(doc)
2408
+ # return doc
2409
+
2410
+ # convenient aliases used by your tool code
2411
+ def open_array(self, img: np.ndarray, metadata: dict | None = None, title: str | None = None) -> "ImageDocument":
2412
+ meta = dict(metadata or {})
2413
+ if title:
2414
+ meta["display_name"] = title
2415
+ # normalize a few expected fields if missing
2416
+ try:
2417
+ if "is_mono" not in meta and isinstance(img, np.ndarray):
2418
+ meta["is_mono"] = (img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1))
2419
+ except Exception:
2420
+ pass
2421
+ meta.setdefault("bit_depth", meta.get("bit_depth", "32-bit floating point"))
2422
+
2423
+ _snapshot_header_for_metadata(meta)
2424
+
2425
+ doc = ImageDocument(img, meta, parent=self.parent())
2426
+ self._register_doc(doc)
2427
+ return doc
2428
+
2429
+ # (optional alias for old code)
2430
+ open_numpy = open_array
2431
+
2432
+
2433
+ def create_document(self, image, metadata: dict | None = None, name: str | None = None) -> ImageDocument:
2434
+ return self.open_array(image, metadata=metadata, title=name)
2435
+
2436
+ def close_document(self, doc):
2437
+ if doc in self._docs:
2438
+ self._docs.remove(doc)
2439
+ try:
2440
+ if hasattr(doc, "uid"):
2441
+ self._by_uid.pop(doc.uid, None)
2442
+ except Exception:
2443
+ pass
2444
+
2445
+ # Cleanup swap files
2446
+ if hasattr(doc, "close"):
2447
+ try:
2448
+ doc.close()
2449
+ except Exception as e:
2450
+ print(f"[DocManager] Failed to close document {doc}: {e}")
2451
+
2452
+ self.documentRemoved.emit(doc)
2453
+
2454
+ # --- Active-document helpers (NEW) ---------------------------------
2455
+ def all_documents(self):
2456
+ return list(self._docs)
2457
+
2458
+ def _find_main_window(self):
2459
+ from PyQt6.QtWidgets import QMainWindow, QApplication
2460
+ w = self.parent()
2461
+ while w is not None and not isinstance(w, QMainWindow):
2462
+ w = w.parent()
2463
+ if w:
2464
+ return w
2465
+ for tlw in QApplication.topLevelWidgets():
2466
+ if isinstance(tlw, QMainWindow):
2467
+ return tlw
2468
+ return None
2469
+
2470
+ def set_active_document(self, doc: ImageDocument | None):
2471
+ if doc is not None and doc not in self._docs:
2472
+ return
2473
+ # ensure backref for legacy docs
2474
+ if doc is not None and not hasattr(doc, "_doc_manager"):
2475
+ try:
2476
+ import weakref
2477
+ doc._doc_manager = weakref.proxy(self)
2478
+ except Exception:
2479
+ doc._doc_manager = self
2480
+ self._active_doc = doc
2481
+
2482
+ def set_mdi_area(self, mdi):
2483
+ """Call this once from MainWindow after MDI is created."""
2484
+ self._mdi = mdi
2485
+ try:
2486
+ mdi.subWindowActivated.connect(self._on_subwindow_activated)
2487
+ except Exception:
2488
+ pass
2489
+
2490
+ def _base_from_subwindow(self, sw):
2491
+ """Best-effort: unwrap to the base ImageDocument bound to a subwindow."""
2492
+ if sw is None:
2493
+ return None
2494
+ try:
2495
+ w = sw.widget()
2496
+ base = (getattr(w, "base_document", None)
2497
+ or getattr(w, "_base_document", None)
2498
+ or getattr(w, "document", None))
2499
+ # unwrap ROI wrappers if any
2500
+ p = getattr(base, "_parent_doc", None)
2501
+ return p if isinstance(p, ImageDocument) else base
2502
+ except Exception:
2503
+ return None
2504
+
2505
+ def _on_subwindow_activated(self, sw):
2506
+ # existing logic (keep it)
2507
+ doc = None
2508
+ try:
2509
+ if sw is not None:
2510
+ w = sw.widget()
2511
+ doc = getattr(w, "document", None) or getattr(sw, "document", None)
2512
+ except Exception:
2513
+ doc = None
2514
+ self.set_active_document(doc)
2515
+
2516
+ # NEW: compute focused *base* doc and emit change only when different
2517
+ new_base = self._base_from_subwindow(sw)
2518
+ if new_base is not self._focused_base_doc:
2519
+ self._focused_base_doc = new_base
2520
+ try:
2521
+ self.activeBaseChanged.emit(new_base)
2522
+ except Exception:
2523
+ pass
2524
+
2525
+ def get_focused_base_document(self):
2526
+ """
2527
+ Returns the last *activated* subwindow's base ImageDocument (sticky),
2528
+ ignoring hover/preview wrappers.
2529
+ """
2530
+ return self._focused_base_doc
2531
+
2532
+ def get_active_document(self):
2533
+ """
2534
+ Return the active document-like object.
2535
+ If a Preview tab is selected on the active ImageSubWindow, return a cached
2536
+ _RoiViewDocument so tools and the Preview tab share the same instance.
2537
+ Otherwise return the real ImageDocument.
2538
+
2539
+ IMPORTANT: Always check the currently active MDI subwindow first,
2540
+ as that's what the user expects to be the "active" document.
2541
+ """
2542
+ base_doc = None
2543
+
2544
+ # ALWAYS check the MDI active subwindow first - this is the source of truth
2545
+ try:
2546
+ if self._mdi is not None:
2547
+ sw = self._mdi.activeSubWindow()
2548
+ if sw is not None:
2549
+ w = sw.widget()
2550
+ base_doc = getattr(w, "document", None) or getattr(sw, "document", None)
2551
+ if base_doc is not None:
2552
+ self._active_doc = base_doc
2553
+ except Exception:
2554
+ pass
2555
+
2556
+ # Fallback to cached value only if MDI lookup failed
2557
+ if base_doc is None:
2558
+ if self._active_doc is not None and self._active_doc in self._docs:
2559
+ base_doc = self._active_doc
2560
+ else:
2561
+ base_doc = self._docs[-1] if self._docs else None
2562
+
2563
+ # Non-image docs just pass through
2564
+ if base_doc is None or not isinstance(base_doc, ImageDocument) or base_doc.image is None:
2565
+ return base_doc
2566
+
2567
+ # ✅ ROI-aware, CACHED preview doc
2568
+ vw = self._active_view_widget()
2569
+ if vw and hasattr(vw, "has_active_preview") and vw.has_active_preview():
2570
+ try:
2571
+ roi_doc = self.get_document_for_view(vw) # <-- uses _roi_doc_cache
2572
+ if isinstance(roi_doc, _RoiViewDocument):
2573
+ try:
2574
+ name_suffix = f" (Preview {vw.current_preview_name() or ''})"
2575
+ roi_doc.metadata["display_name"] = f"{base_doc.display_name()}{name_suffix}"
2576
+ except Exception:
2577
+ pass
2578
+ return roi_doc
2579
+ except Exception:
2580
+ return base_doc
2581
+
2582
+ return base_doc
2583
+
2584
+
2585
+
2586
+ def update_active_document(
2587
+ self,
2588
+ updated_image,
2589
+ metadata=None,
2590
+ step_name: str = "Edit",
2591
+ doc=None, # 👈 NEW optional parameter
2592
+ ):
2593
+
2594
+ # Prefer explicit doc if given; otherwise fall back to "active"
2595
+ view_doc = doc or self.get_active_document()
2596
+
2597
+ # DEBUG: Trace why LinearFit might fail
2598
+ # print(f"[DocManager] update_active_document target: {view_doc}, type: {type(view_doc).__name__}")
2599
+
2600
+ # NEW: Unwrap proxy objects (_DocProxy / LiveViewDocument)
2601
+ tname = type(view_doc).__name__
2602
+ if "LiveViewDocument" in tname:
2603
+ try:
2604
+ view_doc = view_doc._current()
2605
+ except Exception:
2606
+ pass
2607
+ elif "_DocProxy" in tname:
2608
+ try:
2609
+ view_doc = view_doc._target()
2610
+ except Exception:
2611
+ pass
2612
+
2613
+ if view_doc is None:
2614
+ raise RuntimeError("No active document")
2615
+
2616
+ old_img = getattr(view_doc, "image", None)
2617
+ old_shape = getattr(old_img, "shape", None)
2618
+
2619
+ img = np.asarray(updated_image)
2620
+ if img.dtype != np.float32:
2621
+ img = img.astype(np.float32, copy=False)
2622
+
2623
+ _debug_log_undo(
2624
+ "DocManager.update_active_document.entry",
2625
+ step_name=step_name,
2626
+ view_doc_type=type(view_doc).__name__,
2627
+ view_doc_id=id(view_doc),
2628
+ is_roi=isinstance(view_doc, _RoiViewDocument),
2629
+ old_shape=old_shape,
2630
+ new_shape=getattr(img, "shape", None),
2631
+ )
2632
+
2633
+ # --- Extract operation parameters (if any) from metadata --------
2634
+ md = dict(metadata or {})
2635
+ op_params = md.pop("__op_params__", None)
2636
+
2637
+ # If this is an ROI view doc, keep track of where this happened
2638
+ roi_tuple = None
2639
+ source_kind = "full"
2640
+ if isinstance(view_doc, _RoiViewDocument):
2641
+ roi_tuple = getattr(view_doc, "_roi", None)
2642
+ source_kind = "roi"
2643
+
2644
+ # --- ROI preview branch: only update preview, no parent paste ----
2645
+ if isinstance(view_doc, _RoiViewDocument):
2646
+ # Update ONLY the preview; view repaint is driven by signals
2647
+ view_doc.apply_edit(img, md, step_name)
2648
+
2649
+ # Record operation on the ROI doc itself
2650
+ if hasattr(view_doc, "record_operation"):
2651
+ try:
2652
+ view_doc.record_operation(
2653
+ step_name=step_name,
2654
+ params=op_params,
2655
+ roi=roi_tuple,
2656
+ source=source_kind,
2657
+ )
2658
+ except Exception:
2659
+ pass
2660
+ _debug_log_undo(
2661
+ "DocManager.update_active_document.roi_after",
2662
+ step_name=step_name,
2663
+ view_doc_id=id(view_doc),
2664
+ roi=getattr(view_doc, "_roi", None),
2665
+ pundo_len=len(getattr(view_doc, "_pundo", [])),
2666
+ predo_len=len(getattr(view_doc, "_predo", [])),
2667
+ )
2668
+ return
2669
+
2670
+ # --- Full image branch ------------------------------------------
2671
+ if isinstance(view_doc, ImageDocument):
2672
+ view_doc.apply_edit(img, md, step_name)
2673
+ try:
2674
+ self.imageRegionUpdated.emit(view_doc, None)
2675
+ except Exception:
2676
+ pass
2677
+
2678
+ _debug_log_undo(
2679
+ "DocManager.update_active_document.full_after",
2680
+ step_name=step_name,
2681
+ view_doc_id=id(view_doc),
2682
+ undo_len=len(getattr(view_doc, "_undo", [])),
2683
+ redo_len=len(getattr(view_doc, "_redo", [])),
2684
+ final_shape=getattr(view_doc.image, "shape", None),
2685
+ )
2686
+ # Record operation on the full document
2687
+ if hasattr(view_doc, "record_operation"):
2688
+ try:
2689
+ view_doc.record_operation(
2690
+ step_name=step_name,
2691
+ params=op_params,
2692
+ roi=None,
2693
+ source=source_kind,
2694
+ )
2695
+
2696
+ except Exception:
2697
+ pass
2698
+ else:
2699
+ raise RuntimeError("Active document is not an image")
2700
+
2701
+ def get_active_operation_log(self) -> list[dict]:
2702
+ """
2703
+ Return the operation log for the *currently active* document-like
2704
+ (full image or ROI-preview). Empty list if none.
2705
+ """
2706
+ doc = self.get_active_document()
2707
+ if doc is None:
2708
+ return []
2709
+ get_log = getattr(doc, "get_operation_log", None)
2710
+ if callable(get_log):
2711
+ try:
2712
+ return get_log()
2713
+ except Exception:
2714
+ return []
2715
+ return []
2716
+
2717
+
2718
+
2719
+ # Back-compat/aliases so tools can call any of these:
2720
+ def update_image(self, updated_image, metadata=None, step_name: str = "Edit"):
2721
+ self.update_active_document(updated_image, metadata, step_name)
2722
+
2723
+ def set_image(self, img, metadata=None, step_name: str = "Edit"):
2724
+ self.update_active_document(img, metadata, step_name)
2725
+
2726
+ def apply_edit_to_active(self, img, step_name: str = "Edit", metadata=None):
2727
+ self.update_active_document(img, metadata, step_name)