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,1352 @@
1
+ # pro/frequency_separation.py
2
+ from __future__ import annotations
3
+ import os
4
+ import numpy as np
5
+
6
+ # Optional deps used by the processing threads
7
+ try:
8
+ import cv2
9
+ except Exception:
10
+ cv2 = None
11
+
12
+ try:
13
+ import pywt
14
+ except Exception:
15
+ pywt = None
16
+
17
+ from PyQt6.QtCore import (
18
+ Qt, QSize, QPoint, QEvent, QThread, pyqtSignal, QTimer
19
+ )
20
+ from PyQt6.QtWidgets import (
21
+ QWidget, QHBoxLayout, QVBoxLayout, QLabel, QPushButton, QComboBox, QSlider,
22
+ QCheckBox, QScrollArea, QToolButton, QStyle, QFileDialog, QMessageBox
23
+ )
24
+ from PyQt6.QtGui import (
25
+ QPixmap, QImage, QMovie, QCursor, QWheelEvent
26
+ )
27
+
28
+ from .doc_manager import ImageDocument # add this import
29
+ from setiastro.saspro.legacy.image_manager import load_image as legacy_load_image
30
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
31
+
32
+ # ---------------------------- Threads ----------------------------
33
+
34
+ class FrequencySeperationThread(QThread):
35
+ separation_done = pyqtSignal(np.ndarray, np.ndarray)
36
+ error_signal = pyqtSignal(str)
37
+
38
+ def __init__(self, image: np.ndarray, method='Gaussian', radius=10.0, tolerance=50, parent=None):
39
+ super().__init__(parent)
40
+ self.image = image.astype(np.float32, copy=False)
41
+ self.method = method
42
+ self.radius = float(radius)
43
+ self.tolerance = int(tolerance)
44
+ try:
45
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
46
+ except Exception:
47
+ pass # older PyQt6 versions
48
+ def run(self):
49
+ try:
50
+ if self.image.ndim == 3 and self.image.shape[2] == 3:
51
+ if cv2 is None:
52
+ raise RuntimeError("OpenCV (cv2) is required for color frequency separation.")
53
+ bgr = cv2.cvtColor(self.image, cv2.COLOR_RGB2BGR)
54
+ else:
55
+ bgr = self.image.copy()
56
+
57
+ if self.method == 'Gaussian':
58
+ if cv2 is None:
59
+ raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
60
+ low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
61
+ elif self.method == 'Median':
62
+ if cv2 is None:
63
+ raise RuntimeError("OpenCV (cv2) is required for median blur.")
64
+ ksize = max(1, int(self.radius) // 2 * 2 + 1)
65
+ low_bgr = cv2.medianBlur(bgr, ksize)
66
+ elif self.method == 'Bilateral':
67
+ if cv2 is None:
68
+ raise RuntimeError("OpenCV (cv2) is required for bilateral filter.")
69
+ sigma = 50.0 * (self.tolerance / 100.0)
70
+ d = max(1, int(self.radius))
71
+ low_bgr = cv2.bilateralFilter(bgr, d, sigma, sigma)
72
+ else:
73
+ # fallback
74
+ if cv2 is None:
75
+ raise RuntimeError("OpenCV (cv2) is required for Gaussian blur.")
76
+ low_bgr = cv2.GaussianBlur(bgr, (0, 0), self.radius)
77
+
78
+ if low_bgr.ndim == 3 and low_bgr.shape[2] == 3:
79
+ low_rgb = cv2.cvtColor(low_bgr, cv2.COLOR_BGR2RGB)
80
+ else:
81
+ low_rgb = low_bgr
82
+
83
+ high_rgb = self.image - low_rgb # keep signed HF
84
+ self.separation_done.emit(low_rgb.astype(np.float32), high_rgb.astype(np.float32))
85
+ except Exception as e:
86
+ self.error_signal.emit(str(e))
87
+
88
+
89
+ class HFEnhancementThread(QThread):
90
+ enhancement_done = pyqtSignal(np.ndarray)
91
+ error_signal = pyqtSignal(str)
92
+
93
+ def __init__(
94
+ self,
95
+ hf_image: np.ndarray,
96
+ enable_scale=True,
97
+ sharpen_scale=1.0,
98
+ enable_wavelet=True,
99
+ wavelet_level=2,
100
+ wavelet_boost=1.2,
101
+ wavelet_name='db2',
102
+ enable_denoise=False,
103
+ denoise_strength=3.0,
104
+ parent=None
105
+ ):
106
+ super().__init__(parent)
107
+ self.hf_image = hf_image.astype(np.float32, copy=False)
108
+ self.enable_scale = bool(enable_scale)
109
+ self.sharpen_scale = float(sharpen_scale)
110
+ self.enable_wavelet = bool(enable_wavelet)
111
+ self.wavelet_level = int(wavelet_level)
112
+ self.wavelet_boost = float(wavelet_boost)
113
+ self.wavelet_name = str(wavelet_name)
114
+ self.enable_denoise = bool(enable_denoise)
115
+ self.denoise_strength = float(denoise_strength)
116
+
117
+ def run(self):
118
+ try:
119
+ out = self.hf_image.copy()
120
+
121
+ if self.enable_scale:
122
+ out *= self.sharpen_scale
123
+
124
+ if self.enable_wavelet:
125
+ if pywt is None:
126
+ raise RuntimeError("PyWavelets (pywt) is required for wavelet sharpening.")
127
+ out = self._wavelet_sharpen(out, self.wavelet_name, self.wavelet_level, self.wavelet_boost)
128
+
129
+ if self.enable_denoise:
130
+ if cv2 is None:
131
+ raise RuntimeError("OpenCV (cv2) is required for HF denoise.")
132
+ out = self._denoise_hf(out, self.denoise_strength)
133
+
134
+ self.enhancement_done.emit(out.astype(np.float32))
135
+ except Exception as e:
136
+ self.error_signal.emit(str(e))
137
+
138
+ def _wavelet_sharpen(self, img, wavelet='db2', level=2, boost=1.2):
139
+ if img.ndim == 3 and img.shape[2] == 3:
140
+ chs = []
141
+ for c in range(3):
142
+ chs.append(self._wavelet_sharpen_mono(img[..., c], wavelet, level, boost))
143
+ return np.stack(chs, axis=-1)
144
+ else:
145
+ return self._wavelet_sharpen_mono(img, wavelet, level, boost)
146
+
147
+ def _wavelet_sharpen_mono(self, mono, wavelet, level, boost):
148
+ coeffs = pywt.wavedec2(mono, wavelet=wavelet, level=level, mode='periodization')
149
+ new_coeffs = [coeffs[0]]
150
+ for (cH, cV, cD) in coeffs[1:]:
151
+ new_coeffs.append((cH * boost, cV * boost, cD * boost))
152
+ rec = pywt.waverec2(new_coeffs, wavelet=wavelet, mode='periodization')
153
+
154
+ # shape guard
155
+ if rec.shape != mono.shape:
156
+ h, w = mono.shape[:2]
157
+ rec = rec[:h, :w]
158
+ return rec
159
+
160
+ def _denoise_hf(self, hf, strength=3.0):
161
+ # Shift to [0..1], denoise, shift back.
162
+ if hf.ndim == 3 and hf.shape[2] == 3:
163
+ bgr = hf[..., ::-1] # RGB->BGR
164
+ tmp = np.clip(bgr + 0.5, 0, 1)
165
+ u8 = (tmp * 255).astype(np.uint8)
166
+ den = cv2.fastNlMeansDenoisingColored(u8, None, strength, strength, 7, 21)
167
+ f32 = den.astype(np.float32) / 255.0 - 0.5
168
+ return f32[..., ::-1] # back to RGB
169
+ else:
170
+ tmp = np.clip(hf + 0.5, 0, 1)
171
+ u8 = (tmp * 255).astype(np.uint8)
172
+ den = cv2.fastNlMeansDenoising(u8, None, strength, 7, 21)
173
+ return den.astype(np.float32) / 255.0 - 0.5
174
+
175
+
176
+ # ---------------------------- Widget ----------------------------
177
+
178
+ class FrequencySeperationTab(QWidget):
179
+ """
180
+ SASpro version:
181
+ - Side-by-side LF/HF previews with synced panning
182
+ - Ctrl+wheel zoom-at-mouse (no wheel-scroll)
183
+ - Gaussian / Median / Bilateral
184
+ - Optional HF scale, wavelet sharpen, denoise
185
+ - Push LF/HF/Combined to new views via DocManager
186
+ """
187
+ def __init__(self, image_manager=None, doc_manager=None, parent=None, document: ImageDocument | None = None):
188
+ super().__init__(parent)
189
+ self.doc_manager = doc_manager or image_manager
190
+ self.doc: ImageDocument | None = document
191
+
192
+ # state
193
+ self.image: np.ndarray | None = None
194
+ self.low_freq_image: np.ndarray | None = None
195
+ self.high_freq_image: np.ndarray | None = None
196
+ self.original_header = None
197
+ self.is_mono = False
198
+ self.filename = None
199
+
200
+ self.zoom_factor = 1.0
201
+ self._dragging = False
202
+ self._last_pos: QPoint | None = None
203
+ self._sync_guard = False
204
+ self._hf_history: list[np.ndarray] = []
205
+
206
+ # parameters
207
+ self.method = 'Gaussian'
208
+ self.radius = 10.0
209
+ self.tolerance = 50
210
+ self.enable_scale = True
211
+ self.sharpen_scale = 1.0
212
+ self.enable_wavelet = True
213
+ self.wavelet_level = 2
214
+ self.wavelet_boost = 1.2
215
+ self.enable_denoise = False
216
+ self.denoise_strength = 3.0
217
+
218
+ self.proc_thread: FrequencySeperationThread | None = None
219
+ self.hf_thread: HFEnhancementThread | None = None
220
+ self._auto_loaded = False
221
+ self._build_ui()
222
+
223
+ if self.doc is not None and getattr(self.doc, "image", None) is not None:
224
+ # Preload immediately; avoids any focus/MDI ambiguity
225
+ self.set_image_from_doc(np.asarray(self.doc.image),
226
+ getattr(self.doc, "metadata", {}))
227
+ self._auto_loaded = True
228
+
229
+
230
+ # ---------------- UI ----------------
231
+ def _build_ui(self):
232
+ main = QHBoxLayout(self)
233
+ self.setLayout(main)
234
+
235
+ # left controls
236
+ left = QVBoxLayout()
237
+ left_host = QWidget(self); left_host.setLayout(left); left_host.setFixedWidth(280)
238
+
239
+ self.fileLabel = QLabel("", self)
240
+ left.addWidget(self.fileLabel)
241
+
242
+ # Method
243
+ left.addWidget(QLabel(self.tr("Method:"), self))
244
+ self.method_combo = QComboBox(self)
245
+ self.method_combo.addItems(['Gaussian', 'Median', 'Bilateral'])
246
+ self.method_combo.currentTextChanged.connect(self._on_method_changed)
247
+ left.addWidget(self.method_combo)
248
+
249
+ # Radius (0..100 mapped to 0.1..100)
250
+ self.radius_label = QLabel("Radius: 10.00", self); left.addWidget(self.radius_label)
251
+ self.radius_slider = QSlider(Qt.Orientation.Horizontal, self)
252
+ self.radius_slider.setRange(1, 100); self.radius_slider.setValue(50)
253
+ self.radius_slider.valueChanged.connect(self._on_radius_changed)
254
+ left.addWidget(self.radius_slider)
255
+
256
+ # Tolerance (for Bilateral only)
257
+ self.tol_label = QLabel("Tolerance: 50%", self); left.addWidget(self.tol_label)
258
+ self.tol_slider = QSlider(Qt.Orientation.Horizontal, self)
259
+ self.tol_slider.setRange(0, 100); self.tol_slider.setValue(50)
260
+ self.tol_slider.valueChanged.connect(self._on_tol_changed)
261
+ left.addWidget(self.tol_slider)
262
+ self._toggle_tol_enabled(False)
263
+
264
+ # Apply separation
265
+ btn_apply = QPushButton(self.tr("Apply - Split HF & LF"), self)
266
+ btn_apply.clicked.connect(self._apply_separation)
267
+ left.addWidget(btn_apply)
268
+
269
+ left.addWidget(QLabel(self.tr("<b>HF Enhancements</b>"), self))
270
+
271
+ # Sharpen scale
272
+ self.cb_scale = QCheckBox(self.tr("Enable Sharpen Scale"), self)
273
+ self.cb_scale.setChecked(True); left.addWidget(self.cb_scale)
274
+ self.scale_label = QLabel("Sharpen Scale: 1.00", self); left.addWidget(self.scale_label)
275
+ self.scale_slider = QSlider(Qt.Orientation.Horizontal, self)
276
+ self.scale_slider.setRange(10, 300); self.scale_slider.setValue(100)
277
+ self.scale_slider.valueChanged.connect(lambda v: self._update_scale(v))
278
+ left.addWidget(self.scale_slider)
279
+
280
+ # Wavelet
281
+ self.cb_wavelet = QCheckBox(self.tr("Enable Wavelet Sharpening"), self)
282
+ self.cb_wavelet.setChecked(True); left.addWidget(self.cb_wavelet)
283
+ self.wavelet_level_label = QLabel("Wavelet Level: 2", self); left.addWidget(self.wavelet_level_label)
284
+ self.wavelet_level_slider = QSlider(Qt.Orientation.Horizontal, self)
285
+ self.wavelet_level_slider.setRange(1, 5); self.wavelet_level_slider.setValue(2)
286
+ self.wavelet_level_slider.valueChanged.connect(lambda v: self._update_wavelet_level(v))
287
+ left.addWidget(self.wavelet_level_slider)
288
+
289
+ self.wavelet_boost_label = QLabel("Wavelet Boost: 1.20", self); left.addWidget(self.wavelet_boost_label)
290
+ self.wavelet_boost_slider = QSlider(Qt.Orientation.Horizontal, self)
291
+ self.wavelet_boost_slider.setRange(50, 300); self.wavelet_boost_slider.setValue(120)
292
+ self.wavelet_boost_slider.valueChanged.connect(lambda v: self._update_wavelet_boost(v))
293
+ left.addWidget(self.wavelet_boost_slider)
294
+
295
+ # Denoise
296
+ self.cb_denoise = QCheckBox(self.tr("Enable HF Denoise"), self)
297
+ self.cb_denoise.setChecked(False); left.addWidget(self.cb_denoise)
298
+ self.denoise_label = QLabel("Denoise Strength: 3.00", self); left.addWidget(self.denoise_label)
299
+ self.denoise_slider = QSlider(Qt.Orientation.Horizontal, self)
300
+ self.denoise_slider.setRange(0, 50); self.denoise_slider.setValue(30) # 0..5.0 (we'll /10)
301
+ self.denoise_slider.valueChanged.connect(lambda v: self._update_denoise(v))
302
+ left.addWidget(self.denoise_slider)
303
+
304
+ # HF actions row
305
+ row = QHBoxLayout()
306
+ self.btn_apply_hf = QPushButton(self.tr("Apply HF Enhancements"), self)
307
+ self.btn_apply_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton))
308
+ self.btn_apply_hf.clicked.connect(self._apply_hf_enhancements)
309
+ row.addWidget(self.btn_apply_hf)
310
+
311
+ self.btn_undo_hf = QToolButton(self)
312
+ self.btn_undo_hf.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowBack))
313
+ self.btn_undo_hf.setToolTip("Undo last HF enhancement")
314
+ self.btn_undo_hf.setEnabled(False)
315
+ self.btn_undo_hf.clicked.connect(self._undo_hf)
316
+ row.addWidget(self.btn_undo_hf)
317
+ left.addLayout(row)
318
+
319
+ # Push buttons
320
+ push_row = QHBoxLayout()
321
+ self.btn_push_lf = QPushButton(self.tr("Push LF"), self); self.btn_push_lf.clicked.connect(lambda: self._push_array(self.low_freq_image, "LF"))
322
+ self.btn_push_hf = QPushButton(self.tr("Push HF"), self); self.btn_push_hf.clicked.connect(lambda: self._push_array(self._hf_display_for_push(), "HF"))
323
+ push_row.addWidget(self.btn_push_lf); push_row.addWidget(self.btn_push_hf)
324
+ left.addLayout(push_row)
325
+
326
+ #load_row = QHBoxLayout()
327
+ #self.btn_load_hf = QPushButton("Load HF…", self)
328
+ #self.btn_load_hf.clicked.connect(self._load_hf_from_file)
329
+ #load_row.addWidget(self.btn_load_hf)
330
+
331
+ #self.btn_load_lf = QPushButton("Load LF…", self)
332
+ #self.btn_load_lf.clicked.connect(self._load_lf_from_file)
333
+ #load_row.addWidget(self.btn_load_lf)
334
+
335
+ #left.addLayout(load_row)
336
+
337
+ # --- Load from VIEW (active subwindow) ---
338
+ load_row = QHBoxLayout()
339
+ self.btn_load_hf_view = QPushButton("Load HF (View)", self)
340
+ self.btn_load_lf_view = QPushButton("Load LF (View)", self)
341
+ self.btn_load_hf_view.clicked.connect(lambda: self._load_component_from_view("HF"))
342
+ self.btn_load_lf_view.clicked.connect(lambda: self._load_component_from_view("LF"))
343
+ load_row.addWidget(self.btn_load_lf_view)
344
+ load_row.addWidget(self.btn_load_hf_view)
345
+
346
+ left.addLayout(load_row)
347
+
348
+ btn_combine_push = QPushButton(self.tr("Combine HF+LF -> Push"), self)
349
+ btn_combine_push.clicked.connect(self._combine_and_push)
350
+ left.addWidget(btn_combine_push)
351
+
352
+
353
+
354
+ # Spinner
355
+ self.spinnerLabel = QLabel(self); self.spinnerLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
356
+ try:
357
+ # if you have a resource_path util in your project, use it; otherwise show text
358
+ from setiastro.saspro.resources import resource_path # adjust if your helper lives elsewhere
359
+ mov = QMovie(resource_path("spinner.gif"))
360
+ self.spinnerLabel.setMovie(mov)
361
+ self._spinner = mov
362
+ except Exception:
363
+ self.spinnerLabel.setText("Processing…")
364
+ self._spinner = None
365
+ self.spinnerLabel.hide()
366
+ left.addWidget(self.spinnerLabel)
367
+
368
+ main.addWidget(left_host, 0)
369
+
370
+ # right previews
371
+ right = QVBoxLayout()
372
+ top_row = QHBoxLayout()
373
+ top_row.addStretch(1)
374
+
375
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
376
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
377
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
378
+
379
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_at_pair(1.25))
380
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_at_pair(0.8))
381
+ self.btn_fit.clicked.connect(self._fit_to_preview)
382
+
383
+ top_row.addWidget(self.btn_zoom_in)
384
+ top_row.addWidget(self.btn_zoom_out)
385
+ top_row.addWidget(self.btn_fit)
386
+
387
+ right.addLayout(top_row)
388
+
389
+
390
+ # two scroll areas
391
+ self.scrollHF = QScrollArea(self); self.scrollHF.setWidgetResizable(False); self.scrollHF.setAlignment(Qt.AlignmentFlag.AlignCenter)
392
+ self.scrollLF = QScrollArea(self); self.scrollLF.setWidgetResizable(False); self.scrollLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
393
+
394
+ self.labelHF = QLabel("High Frequency", self); self.labelHF.setAlignment(Qt.AlignmentFlag.AlignCenter)
395
+ self.labelLF = QLabel("Low Frequency", self); self.labelLF.setAlignment(Qt.AlignmentFlag.AlignCenter)
396
+
397
+ self.scrollHF.setWidget(self.labelHF)
398
+ self.scrollLF.setWidget(self.labelLF)
399
+
400
+ # install filters to support ctrl+wheel & drag pan, and to suppress wheel scrolling
401
+ for w in (self.labelHF, self.scrollHF, self.scrollHF.viewport(),
402
+
403
+ self.labelLF, self.scrollLF, self.scrollLF.viewport(),
404
+ ):
405
+ w.installEventFilter(self)
406
+
407
+ row_previews = QHBoxLayout()
408
+ row_previews.addWidget(self.scrollHF, 1)
409
+ row_previews.addWidget(self.scrollLF, 1)
410
+ right.addLayout(row_previews, 1)
411
+
412
+ right_host = QWidget(self); right_host.setLayout(right)
413
+ main.addWidget(right_host, 1)
414
+
415
+ def _try_autoload_active(self) -> bool:
416
+ # 1) DocManager paths
417
+ dm = self.doc_manager
418
+ doc = None
419
+ if dm is not None:
420
+ # common names first
421
+ for name in ("active_document", "current_document", "document"):
422
+ doc = getattr(dm, name, None)
423
+ if callable(doc):
424
+ doc = doc()
425
+ if doc is not None:
426
+ break
427
+ # sometimes the active subwindow is exposed
428
+ if doc is None:
429
+ sw = getattr(dm, "active_subwindow", None)
430
+ if sw is not None:
431
+ doc = getattr(sw, "document", None)
432
+
433
+ # 2) Fallback: sniff the main window’s active ImageSubWindow
434
+ if doc is None:
435
+ mw = self._find_main_window()
436
+ if mw is not None:
437
+ try:
438
+ from setiastro.saspro.subwindow import ImageSubWindow
439
+ subs = mw.findChildren(ImageSubWindow)
440
+ pick = None
441
+ for s in subs:
442
+ if s.isActiveWindow() or s.hasFocus():
443
+ pick = s; break
444
+ if pick is None and subs:
445
+ pick = subs[0]
446
+ if pick is not None:
447
+ doc = getattr(pick, "document", None)
448
+ except Exception:
449
+ pass
450
+
451
+ img = getattr(doc, "image", None) if doc is not None else None
452
+ md = getattr(doc, "metadata", {}) if doc is not None else {}
453
+ if img is not None:
454
+ self.set_image_from_doc(img, md)
455
+ return True
456
+ return False
457
+
458
+ def _get_active_document(self, strict: bool = False) -> ImageDocument | None:
459
+ """
460
+ Try to get the currently active ImageDocument from the MDI.
461
+ If strict=True: do NOT fall back to the most-recent DocManager doc.
462
+ """
463
+ # 1) MDI active subwindow
464
+ mw = self._find_main_window()
465
+ try:
466
+ if mw and hasattr(mw, "mdi"):
467
+ sub = mw.mdi.activeSubWindow()
468
+ if sub:
469
+ w = sub.widget()
470
+ doc = getattr(w, "document", None)
471
+ if isinstance(doc, ImageDocument):
472
+ return doc
473
+ # a softer fallback inside MDI only (still 'strict' to MDI)
474
+ subs = getattr(mw.mdi, "subWindowList", lambda: [])()
475
+ if subs:
476
+ # top of stacking order is usually most recent active
477
+ w = subs[0].widget()
478
+ doc = getattr(w, "document", None)
479
+ if isinstance(doc, ImageDocument):
480
+ return doc
481
+ except Exception:
482
+ pass
483
+
484
+ if strict:
485
+ return None # ⬅️ don’t wander to “last-created” when strict
486
+
487
+ # 2) Non-strict fallback: last opened doc in DocManager (as before)
488
+ dm = self.doc_manager
489
+ try:
490
+ docs = getattr(dm, "_docs", None)
491
+ if docs and len(docs) > 0 and isinstance(docs[-1], ImageDocument):
492
+ return docs[-1]
493
+ except Exception:
494
+ pass
495
+ return None
496
+
497
+
498
+ def _use_doc_image_as(self, target: str):
499
+ """
500
+ Load image from *another* open view and assign to HF/LF.
501
+ target: 'HF' or 'LF'
502
+ """
503
+ doc = self._get_active_document()
504
+ if doc is None or doc.image is None:
505
+ QMessageBox.warning(self, "From View", "No active view found with an image.")
506
+ return
507
+
508
+ # If this dialog was opened for an active document, it might be the same doc.
509
+ # That's ok—user can still use its image as HF/LF if they want.
510
+
511
+ ref = self._ref_shape() # shape we want to match (base image or available HF/LF)
512
+ try:
513
+ imgc = self._coerce_to_ref(np.asarray(doc.image), ref)
514
+ except Exception as e:
515
+ QMessageBox.critical(self, "From View", f"Shape/channel mismatch:\n{e}")
516
+ return
517
+
518
+ if self.image is None and self.low_freq_image is None and self.high_freq_image is None:
519
+ # adopt this as the reference image (so future loads coerce to this)
520
+ self.set_image_from_doc(imgc, getattr(doc, "metadata", {}))
521
+
522
+ if target == "HF":
523
+ self.high_freq_image = imgc.astype(np.float32, copy=False)
524
+ self.labelHF.setText(f"HF ← {doc.display_name()}")
525
+ else:
526
+ self.low_freq_image = imgc.astype(np.float32, copy=False)
527
+ self.labelLF.setText(f"LF ← {doc.display_name()}")
528
+
529
+ self._update_previews()
530
+
531
+ def _load_hf_from_view(self):
532
+ self._use_doc_image_as("HF")
533
+
534
+ def _load_lf_from_view(self):
535
+ self._use_doc_image_as("LF")
536
+
537
+ def _collect_open_documents(self) -> list[tuple[str, object]]:
538
+ """
539
+ Returns [(display_name, ImageDocument), ...] for all known open docs.
540
+ Tries DocManager first; falls back to scanning MDI subwindows.
541
+ Active view (if found) is placed first.
542
+ """
543
+ items: list[tuple[str, object]] = []
544
+ active_doc = None
545
+
546
+ # Try to get active from main window
547
+ mw = self._find_main_window()
548
+ if mw and hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
549
+ try:
550
+ active_widget = mw.mdi.activeSubWindow().widget()
551
+ active_doc = getattr(active_widget, "document", None)
552
+ except Exception:
553
+ active_doc = None
554
+
555
+ # Prefer DocManager list
556
+ dm = self.doc_manager
557
+ docs = []
558
+ if dm is not None:
559
+ for attr in ("documents", "all_documents", "_docs"):
560
+ d = getattr(dm, attr, None)
561
+ if d:
562
+ docs = list(d)
563
+ break
564
+
565
+ # If no doc list, scan subwindows
566
+ if not docs and mw is not None:
567
+ try:
568
+ from setiastro.saspro.subwindow import ImageSubWindow
569
+ subs = mw.findChildren(ImageSubWindow)
570
+ for s in subs:
571
+ doc = getattr(s, "document", None)
572
+ if doc:
573
+ docs.append(doc)
574
+ except Exception:
575
+ pass
576
+
577
+ # Build names
578
+ def _name_for(doc):
579
+ name = None
580
+ # ImageDocument has display_name(); metadata may have display_name/file_path
581
+ for cand in ("display_name",):
582
+ if hasattr(doc, cand) and callable(getattr(doc, cand)):
583
+ try:
584
+ name = getattr(doc, cand)()
585
+ except Exception:
586
+ name = None
587
+ if not name:
588
+ md = getattr(doc, "metadata", {}) or {}
589
+ name = md.get("display_name") or md.get("file_path") or "Untitled"
590
+ import os
591
+ if isinstance(name, str):
592
+ name = os.path.basename(name)
593
+ return name
594
+
595
+ # Put active first
596
+ if active_doc and active_doc in docs:
597
+ items.append((f"★ { _name_for(active_doc) } (active)", active_doc))
598
+ docs = [d for d in docs if d is not active_doc]
599
+
600
+ for d in docs:
601
+ items.append((_name_for(d), d))
602
+
603
+ return items
604
+
605
+ def _select_document_via_dropdown(self, which: str | None = None) -> object | None:
606
+ items = self._collect_open_documents()
607
+ if not items:
608
+ QMessageBox.information(self, f"Select View for {which or ''}".strip(),
609
+ "No open views/documents found.")
610
+ return None
611
+ dlg = SelectViewDialog(self, items, which=which)
612
+ if dlg.exec() == QDialog.DialogCode.Accepted:
613
+ return dlg.selected_doc()
614
+ return None
615
+
616
+
617
+ def _image_from_doc(self, doc) -> np.ndarray | None:
618
+ """
619
+ Extract float32 image from an ImageDocument-like object.
620
+ For integer sources, scale into [0..1] using width-based heuristic.
621
+ """
622
+ try:
623
+ img = getattr(doc, "image", None)
624
+ if img is None:
625
+ return None
626
+ arr = np.asarray(img)
627
+ if arr.dtype == np.float32:
628
+ return arr
629
+ if np.issubdtype(arr.dtype, np.floating):
630
+ return arr.astype(np.float32, copy=False)
631
+ # Integer → normalize to [0..1]
632
+ scale = 65535.0 if (arr.dtype.itemsize >= 2) else 255.0
633
+ return (arr.astype(np.float32) / scale)
634
+ except Exception as e:
635
+ QMessageBox.warning(self, "Load from View", f"Could not read image from view:\n{e}")
636
+ return None
637
+
638
+ def _load_component_from_view(self, which: str):
639
+ """
640
+ which ∈ {"HF", "LF"}
641
+ """
642
+ doc = self._select_document_via_dropdown(which)
643
+ if not doc:
644
+ return
645
+ arr = self._image_from_doc(doc)
646
+ if arr is None:
647
+ return
648
+
649
+ # Assign and update preview
650
+ if which.upper() == "HF":
651
+ self.high_freq_image = arr.astype(np.float32, copy=False)
652
+ else:
653
+ self.low_freq_image = arr.astype(np.float32, copy=False)
654
+
655
+ # Warn on dimensional mismatch (combine needs same shape)
656
+ if (self.low_freq_image is not None and self.high_freq_image is not None and
657
+ self.low_freq_image.shape != self.high_freq_image.shape):
658
+ QMessageBox.warning(
659
+ self, "Dimension Mismatch",
660
+ "Loaded image dimensions do not match the other component.\n"
661
+ "You can still view/edit, but Combine requires matching sizes."
662
+ )
663
+
664
+ self._update_previews()
665
+
666
+
667
+ def _ref_shape(self):
668
+ """
669
+ Returns a reference shape to coerce incoming HF/LF to:
670
+ - Prefer the main image's shape
671
+ - Else prefer whichever of LF/HF exists
672
+ - Else None (no constraint yet)
673
+ """
674
+ if isinstance(self.image, np.ndarray):
675
+ return self.image.shape
676
+ if isinstance(self.low_freq_image, np.ndarray):
677
+ return self.low_freq_image.shape
678
+ if isinstance(self.high_freq_image, np.ndarray):
679
+ return self.high_freq_image.shape
680
+ return None
681
+
682
+ def _coerce_to_ref(self, arr: np.ndarray, ref_shape: tuple[int, ...] | None) -> np.ndarray:
683
+ """
684
+ Try to coerce 'arr' to match ref_shape where possible:
685
+ - If ref is HxW and arr is HxW x3 → convert to mono (mean)
686
+ - If ref is HxW x3 and arr is HxW → tile to 3 channels
687
+ - H/W must match; no resize is performed (we error if they differ)
688
+ """
689
+ a = np.asarray(arr, dtype=np.float32)
690
+
691
+ if ref_shape is None:
692
+ return a # nothing to coerce against yet
693
+
694
+ # spatial guard
695
+ if a.ndim == 2:
696
+ ah, aw = a.shape
697
+ ch = 1
698
+ elif a.ndim == 3 and a.shape[2] in (1, 3):
699
+ ah, aw, ch = a.shape[0], a.shape[1], a.shape[2]
700
+ else:
701
+ raise ValueError("Unsupported array shape for HF/LF (expect HxW or HxWx{1,3}).")
702
+
703
+ if len(ref_shape) == 2:
704
+ rh, rw = ref_shape
705
+ rch = 1
706
+ else:
707
+ rh, rw, rch = ref_shape
708
+
709
+ if (ah != rh) or (aw != rw):
710
+ raise ValueError(f"Image dimensions {ah}x{aw} do not match reference {rh}x{rw}.")
711
+
712
+ # channel reconcile
713
+ if rch == 1 and ch == 3:
714
+ # convert RGB→mono (use weighted luma for consistency, or mean if desired. Original was mean)
715
+ a = a.mean(axis=2).astype(np.float32)
716
+ elif rch == 3 and ch == 1:
717
+ # Broadcast mono to 3 channels without copying
718
+ # (H,W,1) -> (H,W,3) via broadcasted view if consumer allows,
719
+ # but usually downstream (like subtraction) handles broadcasting fine.
720
+ # If explicit physical layout is needed, we must check usage.
721
+ # Here: used for subtraction (OK) and preview (OK).
722
+ # We return a view using broadcast_to or striding tricks.
723
+ a = np.broadcast_to(a, (ah, aw, 3))
724
+
725
+ return a
726
+
727
+ def _load_hf_from_file(self):
728
+ path, _ = QFileDialog.getOpenFileName(
729
+ self, "Load High-Frequency Image", "",
730
+ "Images (*.tif *.tiff *.fits *.fit *.png *.xisf);;All Files (*)"
731
+ )
732
+ if not path:
733
+ return
734
+ try:
735
+ img, _, _, _ = legacy_load_image(path)
736
+ if img is None:
737
+ raise RuntimeError("Could not load image.")
738
+ ref = self._ref_shape()
739
+ imgc = self._coerce_to_ref(img, ref)
740
+ self.high_freq_image = imgc.astype(np.float32, copy=False)
741
+ self._update_previews()
742
+ QMessageBox.information(self, "HF Loaded", os.path.basename(path))
743
+ except Exception as e:
744
+ QMessageBox.critical(self, "Load HF", f"Failed to load HF:\n{e}")
745
+
746
+ def _load_lf_from_file(self):
747
+ path, _ = QFileDialog.getOpenFileName(
748
+ self, "Load Low-Frequency Image", "",
749
+ "Images (*.tif *.tiff *.fits *.fit *.png *.xisf);;All Files (*)"
750
+ )
751
+ if not path:
752
+ return
753
+ try:
754
+ img, _, _, _ = legacy_load_image(path)
755
+ if img is None:
756
+ raise RuntimeError("Could not load image.")
757
+ ref = self._ref_shape()
758
+ imgc = self._coerce_to_ref(img, ref)
759
+ self.low_freq_image = imgc.astype(np.float32, copy=False)
760
+ self._update_previews()
761
+ QMessageBox.information(self, "LF Loaded", os.path.basename(path))
762
+ except Exception as e:
763
+ QMessageBox.critical(self, "Load LF", f"Failed to load LF:\n{e}")
764
+
765
+
766
+ # --- NEW: autoload exactly once when the dialog shows ---
767
+ def showEvent(self, e):
768
+ super().showEvent(e)
769
+ if not self._auto_loaded:
770
+ self._auto_loaded = True
771
+ # Strong preference order:
772
+ # (1) self.doc (injected at construction time)
773
+ # (2) active MDI doc (strict — no "last-created" fallback)
774
+ src_doc = self.doc or self._get_active_document(strict=True)
775
+ if src_doc is not None and getattr(src_doc, "image", None) is not None:
776
+ try:
777
+ self.set_image_from_doc(np.asarray(src_doc.image),
778
+ getattr(src_doc, "metadata", {}))
779
+ return
780
+ except Exception:
781
+ pass
782
+
783
+ # --------------- helpers ---------------
784
+ def _toggle_tol_enabled(self, on: bool):
785
+ self.tol_slider.setEnabled(on)
786
+ self.tol_label.setEnabled(on)
787
+
788
+ def _map_slider_to_radius(self, pos: int) -> float:
789
+ if pos <= 10:
790
+ t = pos / 10.0
791
+ return 0.1 + t * (1.0 - 0.1)
792
+ elif pos <= 50:
793
+ t = (pos - 10) / 40.0
794
+ return 1.0 + t * (10.0 - 1.0)
795
+ else:
796
+ t = (pos - 50) / 50.0
797
+ return 10.0 + t * (100.0 - 10.0)
798
+
799
+ def _update_scale(self, v: int):
800
+ self.sharpen_scale = v / 100.0
801
+ self.scale_label.setText(f"Sharpen Scale: {self.sharpen_scale:.2f}")
802
+
803
+ def _update_wavelet_level(self, v: int):
804
+ self.wavelet_level = int(v)
805
+ self.wavelet_level_label.setText(f"Wavelet Level: {self.wavelet_level}")
806
+
807
+ def _update_wavelet_boost(self, v: int):
808
+ self.wavelet_boost = v / 100.0
809
+ self.wavelet_boost_label.setText(f"Wavelet Boost: {self.wavelet_boost:.2f}")
810
+
811
+ def _update_denoise(self, v: int):
812
+ self.denoise_strength = v / 10.0
813
+ self.denoise_label.setText(f"Denoise Strength: {self.denoise_strength:.2f}")
814
+
815
+ # --------------- image I/O hooks ---------------
816
+ def set_image_from_doc(self, image: np.ndarray, metadata: dict | None):
817
+ """Call this from the main app when there’s an active image; or adapt to your ImageManager signal."""
818
+ if image is None:
819
+ return
820
+ self.image = image.astype(np.float32, copy=False)
821
+ md = metadata or {}
822
+ self.filename = md.get("file_path", None)
823
+ self.original_header = md.get("original_header", None)
824
+ self.is_mono = bool(md.get("is_mono", False))
825
+ self.fileLabel.setText(os.path.basename(self.filename) if self.filename else "(from view)")
826
+ # clear outputs
827
+ self.low_freq_image = None
828
+ self.high_freq_image = None
829
+ self._apply_separation()
830
+
831
+ # --------------- controls handlers ---------------
832
+ def _on_method_changed(self, text: str):
833
+ self.method = text
834
+ self._toggle_tol_enabled(self.method == 'Bilateral')
835
+
836
+ def _on_radius_changed(self, v: int):
837
+ self.radius = self._map_slider_to_radius(v)
838
+ self.radius_label.setText(f"Radius: {self.radius:.2f}")
839
+
840
+ def _on_tol_changed(self, v: int):
841
+ self.tolerance = int(v)
842
+ self.tol_label.setText(f"Tolerance: {self.tolerance}%")
843
+
844
+ # --------------- processing ---------------
845
+ def _apply_separation(self):
846
+ if self.image is None:
847
+ QMessageBox.warning(self, "No Image", "Load or select an image first.")
848
+ return
849
+ self._show_spinner(True)
850
+
851
+ if self.proc_thread and self.proc_thread.isRunning():
852
+ self.proc_thread.quit(); self.proc_thread.wait()
853
+
854
+ self.proc_thread = FrequencySeperationThread(
855
+ image=self.image, method=self.method, radius=self.radius, tolerance=self.tolerance
856
+ )
857
+ self.proc_thread.separation_done.connect(self._on_sep_done)
858
+ self.proc_thread.error_signal.connect(self._on_sep_error)
859
+ self.proc_thread.start()
860
+
861
+ def _on_sep_done(self, lf: np.ndarray, hf: np.ndarray):
862
+ self._show_spinner(False)
863
+ self.low_freq_image = lf.astype(np.float32)
864
+ self.high_freq_image = hf.astype(np.float32)
865
+ self._update_previews()
866
+
867
+ def _on_sep_error(self, msg: str):
868
+ self._show_spinner(False)
869
+ QMessageBox.critical(self, "Frequency Separation", msg)
870
+
871
+ def _apply_hf_enhancements(self):
872
+ if self.high_freq_image is None:
873
+ QMessageBox.information(self, "HF", "No HF image to enhance.")
874
+ return
875
+ # history for undo
876
+ self._hf_history.append(self.high_freq_image.copy())
877
+ self.btn_undo_hf.setEnabled(True)
878
+
879
+ self._show_spinner(True)
880
+ if self.hf_thread and self.hf_thread.isRunning():
881
+ self.hf_thread.quit(); self.hf_thread.wait()
882
+
883
+ self.hf_thread = HFEnhancementThread(
884
+ hf_image=self.high_freq_image,
885
+ enable_scale=self.cb_scale.isChecked(),
886
+ sharpen_scale=self.sharpen_scale,
887
+ enable_wavelet=self.cb_wavelet.isChecked(),
888
+ wavelet_level=self.wavelet_level,
889
+ wavelet_boost=self.wavelet_boost,
890
+ enable_denoise=self.cb_denoise.isChecked(),
891
+ denoise_strength=self.denoise_strength
892
+ )
893
+ self.hf_thread.enhancement_done.connect(self._on_hf_done)
894
+ self.hf_thread.error_signal.connect(self._on_hf_error)
895
+ self.hf_thread.start()
896
+
897
+ def _on_hf_done(self, new_hf: np.ndarray):
898
+ self._show_spinner(False)
899
+ self.high_freq_image = new_hf.astype(np.float32)
900
+ self._update_previews()
901
+
902
+ def _on_hf_error(self, msg: str):
903
+ self._show_spinner(False)
904
+ QMessageBox.critical(self, "HF Enhancements", msg)
905
+
906
+ def _undo_hf(self):
907
+ if not self._hf_history:
908
+ return
909
+ self.high_freq_image = self._hf_history.pop()
910
+ self.btn_undo_hf.setEnabled(bool(self._hf_history))
911
+ self._update_previews()
912
+
913
+ # --------------- spinner ---------------
914
+ def _show_spinner(self, on: bool):
915
+ if on:
916
+ self.spinnerLabel.show()
917
+ if self._spinner: self._spinner.start()
918
+ else:
919
+ self.spinnerLabel.hide()
920
+ if self._spinner: self._spinner.stop()
921
+
922
+ # --------------- preview rendering ---------------
923
+ def _numpy_to_qpix(self, arr: np.ndarray) -> QPixmap:
924
+ a = np.clip(arr, 0, 1)
925
+ if a.ndim == 2:
926
+ a = np.stack([a]*3, axis=-1)
927
+ u8 = (a * 255).astype(np.uint8)
928
+ h, w, ch = u8.shape
929
+ qimg = QImage(u8.data, w, h, w*ch, QImage.Format.Format_RGB888)
930
+ return QPixmap.fromImage(qimg.copy())
931
+
932
+ def _update_previews(self):
933
+ # LF
934
+ if self.low_freq_image is not None:
935
+ pm = self._numpy_to_qpix(self.low_freq_image)
936
+ scaled = pm.scaled(pm.size() * self.zoom_factor,
937
+ Qt.AspectRatioMode.KeepAspectRatio,
938
+ Qt.TransformationMode.SmoothTransformation)
939
+ self.labelLF.setPixmap(scaled)
940
+ self.labelLF.resize(scaled.size())
941
+ else:
942
+ self.labelLF.setText("Low Frequency"); self.labelLF.resize(self.labelLF.sizeHint())
943
+
944
+ # HF (offset +0.5 for view)
945
+ if self.high_freq_image is not None:
946
+ disp = np.clip(self.high_freq_image + 0.5, 0, 1)
947
+ pm = self._numpy_to_qpix(disp)
948
+ scaled = pm.scaled(pm.size() * self.zoom_factor,
949
+ Qt.AspectRatioMode.KeepAspectRatio,
950
+ Qt.TransformationMode.SmoothTransformation)
951
+ self.labelHF.setPixmap(scaled)
952
+ self.labelHF.resize(scaled.size())
953
+ else:
954
+ self.labelHF.setText("High Frequency"); self.labelHF.resize(self.labelHF.sizeHint())
955
+
956
+ # center if smaller than viewport
957
+ QTimer.singleShot(0, self._center_if_fit)
958
+
959
+ def _center_if_fit(self):
960
+ for sc, lbl in ((self.scrollHF, self.labelHF), (self.scrollLF, self.labelLF)):
961
+ if lbl.width() <= sc.viewport().width():
962
+ sc.horizontalScrollBar().setValue((sc.horizontalScrollBar().maximum() + sc.horizontalScrollBar().minimum()) // 2)
963
+ if lbl.height() <= sc.viewport().height():
964
+ sc.verticalScrollBar().setValue((sc.verticalScrollBar().maximum() + sc.verticalScrollBar().minimum()) // 2)
965
+
966
+ # --------------- zoom/pan (dual scrollareas) ---------------
967
+ def _zoom_at_pair(self, factor: float, anchor_hf_vp: QPoint | None = None, anchor_lf_vp: QPoint | None = None):
968
+ if self.low_freq_image is None and self.high_freq_image is None:
969
+ return
970
+
971
+ old = self.zoom_factor
972
+ new = max(0.05, min(8.0, old * factor))
973
+ ratio = new / max(1e-6, old)
974
+
975
+ def _center(sc):
976
+ vp = sc.viewport()
977
+ return QPoint(vp.width() // 2, vp.height() // 2)
978
+
979
+ if anchor_hf_vp is None: anchor_hf_vp = _center(self.scrollHF)
980
+ if anchor_lf_vp is None: anchor_lf_vp = _center(self.scrollLF)
981
+
982
+ HFh, HFv = self.scrollHF.horizontalScrollBar(), self.scrollHF.verticalScrollBar()
983
+ LFh, LFv = self.scrollLF.horizontalScrollBar(), self.scrollLF.verticalScrollBar()
984
+ hf_cx = HFh.value() + anchor_hf_vp.x()
985
+ hf_cy = HFv.value() + anchor_hf_vp.y()
986
+ lf_cx = LFh.value() + anchor_lf_vp.x()
987
+ lf_cy = LFv.value() + anchor_lf_vp.y()
988
+
989
+ self.zoom_factor = new
990
+ self._update_previews() # updates label sizes & scrollbar ranges
991
+
992
+ def _restore(sc_area, anchor, cx, cy, lbl):
993
+ hbar, vbar = sc_area.horizontalScrollBar(), sc_area.verticalScrollBar()
994
+ vp = sc_area.viewport()
995
+ if lbl.width() <= vp.width():
996
+ hbar.setValue((hbar.maximum() + hbar.minimum()) // 2)
997
+ else:
998
+ hbar.setValue(int(cx * ratio - anchor.x()))
999
+ if lbl.height() <= vp.height():
1000
+ vbar.setValue((vbar.maximum() + vbar.minimum()) // 2)
1001
+ else:
1002
+ vbar.setValue(int(cy * ratio - anchor.y()))
1003
+
1004
+ _restore(self.scrollHF, anchor_hf_vp, hf_cx, hf_cy, self.labelHF)
1005
+ _restore(self.scrollLF, anchor_lf_vp, lf_cx, lf_cy, self.labelLF)
1006
+
1007
+
1008
+ def _fit_to_preview(self):
1009
+ # Fit width to the *smaller* of the two viewports; use LF size if available, else HF
1010
+ if self.image is None:
1011
+ return
1012
+ base_h, base_w = (self.low_freq_image.shape[:2]
1013
+ if self.low_freq_image is not None else
1014
+ (self.high_freq_image.shape[:2] if self.high_freq_image is not None else (None, None)))
1015
+ if base_w is None:
1016
+ return
1017
+ vpw = min(self.scrollHF.viewport().width(), self.scrollLF.viewport().width())
1018
+ self.zoom_factor = max(0.05, min(8.0, vpw / float(base_w)))
1019
+ self._update_previews()
1020
+
1021
+ # --------------- pushing to new views ---------------
1022
+ def _push_array(self, arr: np.ndarray | None, title: str):
1023
+ if arr is None:
1024
+ QMessageBox.information(self, "Push", f"No {title} image to push.")
1025
+ return
1026
+ mw = self._find_main_window()
1027
+ dm = getattr(mw, "docman", None) or self.doc_manager
1028
+ if not dm:
1029
+ QMessageBox.critical(self, "UI", "DocManager not available.")
1030
+ return
1031
+ try:
1032
+ if hasattr(dm, "open_array"):
1033
+ doc = dm.open_array(arr, metadata={"is_mono": (arr.ndim == 2)}, title=title)
1034
+ elif hasattr(dm, "create_document"):
1035
+ doc = dm.create_document(image=arr, metadata={"is_mono": (arr.ndim == 2)}, name=title)
1036
+ else:
1037
+ raise RuntimeError("DocManager lacks open_array/create_document")
1038
+ if hasattr(mw, "_spawn_subwindow_for"):
1039
+ mw._spawn_subwindow_for(doc)
1040
+ else:
1041
+ from setiastro.saspro.subwindow import ImageSubWindow
1042
+ sw = ImageSubWindow(doc, parent=mw); sw.setWindowTitle(title); sw.show()
1043
+ except Exception as e:
1044
+ QMessageBox.critical(self, "Push", f"Failed to open new view:\n{e}")
1045
+
1046
+ def _push_to_active(self, img: np.ndarray, step_name: str, extra_md: dict | None = None):
1047
+ dm = self.doc_manager
1048
+ if dm is None:
1049
+ # try to discover from main window just in case
1050
+ mw = self.parent() or self.window()
1051
+ dm = getattr(mw, "docman", None)
1052
+ if dm is None:
1053
+ QMessageBox.critical(self, "Error", "DocManager not available; cannot apply to active view.")
1054
+ return
1055
+
1056
+ # build metadata (keep what we know so history/exports are consistent)
1057
+ md = dict(extra_md or {})
1058
+ md.setdefault("original_header", getattr(self, "original_header", None))
1059
+ md.setdefault("is_mono", (img.ndim == 2) or (img.ndim == 3 and img.shape[2] == 1))
1060
+ md.setdefault("bit_depth", "32-bit floating point") # HF/LF math is float32 in this tool
1061
+
1062
+ # try a few common method names so this works with your DocManager
1063
+ try:
1064
+ if hasattr(dm, "update_active_document"):
1065
+ dm.update_active_document(updated_image=img, metadata=md, step_name=step_name)
1066
+ elif hasattr(dm, "update_image"):
1067
+ dm.update_image(updated_image=img, metadata=md, step_name=step_name)
1068
+ elif hasattr(dm, "set_image"):
1069
+ # older API; many builds accept step_name here too
1070
+ dm.set_image(img, md, step_name=step_name)
1071
+ elif hasattr(dm, "apply_edit_to_active"):
1072
+ dm.apply_edit_to_active(img, step_name=step_name, metadata=md)
1073
+ else:
1074
+ raise RuntimeError("DocManager has no known update method")
1075
+ except Exception as e:
1076
+ QMessageBox.critical(self, "Apply Failed", f"Could not apply result to the active view:\n{e}")
1077
+ return
1078
+
1079
+
1080
+ def _hf_display_for_push(self) -> np.ndarray | None:
1081
+ # push the true HF (signed), but clamp for safety into viewable range around 0
1082
+ if self.high_freq_image is None:
1083
+ return None
1084
+ # keep signed HF; app stack supports float32 arrays
1085
+ return self.high_freq_image.astype(np.float32, copy=False)
1086
+
1087
+ def _combine_and_push(self):
1088
+ if self.low_freq_image is None or self.high_freq_image is None:
1089
+ QMessageBox.information(self, "Combine", "LF or HF missing.")
1090
+ return
1091
+
1092
+ combined = np.clip(self.low_freq_image + self.high_freq_image, 0.0, 1.0).astype(np.float32)
1093
+ step_name = "Frequency Separation (Combine HF+LF)"
1094
+
1095
+ # ✅ Blend with active mask (if any)
1096
+ blended, mid, mname, masked = self._blend_with_active_mask(combined)
1097
+
1098
+ # Build metadata
1099
+ md = {
1100
+ "bit_depth": "32-bit floating point",
1101
+ "is_mono": (blended.ndim == 2) or (blended.ndim == 3 and blended.shape[2] == 1),
1102
+ "original_header": getattr(self, "original_header", None),
1103
+ }
1104
+ if masked:
1105
+ md.update({
1106
+ "masked": True,
1107
+ "mask_id": mid,
1108
+ "mask_name": mname,
1109
+ "mask_blend": "m*out + (1-m)*src",
1110
+ })
1111
+
1112
+ # Prefer applying to the injected ImageDocument
1113
+ if isinstance(self.doc, ImageDocument):
1114
+ try:
1115
+ self.doc.apply_edit(blended, metadata=md, step_name=step_name)
1116
+ except Exception as e:
1117
+ QMessageBox.critical(self, "Apply Failed", f"Could not apply to active document:\n{e}")
1118
+ return
1119
+
1120
+ # Fallback: push to active via DocManager (still pre-blended)
1121
+ self._push_to_active(blended, step_name, extra_md=md)
1122
+
1123
+ # --------------- event filter (wheel + drag pan + sync) ---------------
1124
+ def eventFilter(self, obj, ev):
1125
+ # -------- Ctrl+Wheel Zoom (safe) --------
1126
+ if ev.type() == QEvent.Type.Wheel:
1127
+ targets = {self.scrollHF.viewport(), self.labelHF,
1128
+ self.scrollLF.viewport(), self.labelLF}
1129
+ if obj in targets:
1130
+ # Only zoom when Ctrl is held; otherwise let normal scrolling work
1131
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1132
+ try:
1133
+ dy = ev.pixelDelta().y()
1134
+ if dy == 0:
1135
+ dy = ev.angleDelta().y()
1136
+ factor = 1.25 if dy > 0 else 0.8
1137
+
1138
+ # Anchor positions (robust mapping child→viewport)
1139
+ if obj is self.labelHF:
1140
+ anchor_hf = self.labelHF.mapTo(
1141
+ self.scrollHF.viewport(), ev.position().toPoint()
1142
+ )
1143
+ anchor_lf = QPoint(
1144
+ self.scrollLF.viewport().width() // 2,
1145
+ self.scrollLF.viewport().height() // 2
1146
+ )
1147
+ elif obj is self.scrollHF.viewport():
1148
+ anchor_hf = ev.position().toPoint()
1149
+ anchor_lf = QPoint(
1150
+ self.scrollLF.viewport().width() // 2,
1151
+ self.scrollLF.viewport().height() // 2
1152
+ )
1153
+ elif obj is self.labelLF:
1154
+ anchor_lf = self.labelLF.mapTo(
1155
+ self.scrollLF.viewport(), ev.position().toPoint()
1156
+ )
1157
+ anchor_hf = QPoint(
1158
+ self.scrollHF.viewport().width() // 2,
1159
+ self.scrollHF.viewport().height() // 2
1160
+ )
1161
+ else: # obj is self.scrollLF.viewport()
1162
+ anchor_lf = ev.position().toPoint()
1163
+ anchor_hf = QPoint(
1164
+ self.scrollHF.viewport().width() // 2,
1165
+ self.scrollHF.viewport().height() // 2
1166
+ )
1167
+
1168
+ self._zoom_at_pair(factor, anchor_hf, anchor_lf)
1169
+ except Exception:
1170
+ # If anything goes weird (trackpad/gesture edge cases), center-zoom safely
1171
+ self._zoom_at_pair(1.25 if (ev.angleDelta().y() if hasattr(ev, "angleDelta") else 1) > 0 else 0.8)
1172
+ ev.accept()
1173
+ return True
1174
+ # Not Ctrl: let the scroll area do normal scrolling
1175
+ return False
1176
+
1177
+ # -------- Drag-pan inside each viewport (sync the other) --------
1178
+ if obj in (self.scrollHF.viewport(), self.scrollLF.viewport()):
1179
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
1180
+ self._dragging = True
1181
+ self._last_pos = ev.position().toPoint()
1182
+ obj.setCursor(QCursor(Qt.CursorShape.ClosedHandCursor))
1183
+ return True
1184
+ if ev.type() == QEvent.Type.MouseMove and self._dragging:
1185
+ cur = ev.position().toPoint()
1186
+ delta = cur - (self._last_pos or cur)
1187
+ self._last_pos = cur
1188
+ if obj is self.scrollHF.viewport():
1189
+ self._move_scrolls(self.scrollHF, self.scrollLF, delta)
1190
+ else:
1191
+ self._move_scrolls(self.scrollLF, self.scrollHF, delta)
1192
+ return True
1193
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
1194
+ self._dragging = False
1195
+ self._last_pos = None
1196
+ obj.setCursor(QCursor(Qt.CursorShape.ArrowCursor))
1197
+ return True
1198
+
1199
+ return super().eventFilter(obj, ev)
1200
+
1201
+
1202
+ def _move_scrolls(self, src_sc: QScrollArea, dst_sc: QScrollArea, delta):
1203
+ self._sync_guard = True
1204
+ try:
1205
+ sh, sv = src_sc.horizontalScrollBar(), src_sc.verticalScrollBar()
1206
+ dh, dv = dst_sc.horizontalScrollBar(), dst_sc.verticalScrollBar()
1207
+ sh.setValue(sh.value() - delta.x()); sv.setValue(sv.value() - delta.y())
1208
+ dh.setValue(sh.value()); dv.setValue(sv.value())
1209
+ finally:
1210
+ self._sync_guard = False
1211
+
1212
+ # --------------- utilities ---------------
1213
+ def _find_main_window(self):
1214
+ w = self
1215
+ from PyQt6.QtWidgets import QMainWindow, QApplication
1216
+ while w is not None and not isinstance(w, QMainWindow):
1217
+ w = w.parentWidget()
1218
+ if w: return w
1219
+ for tlw in QApplication.topLevelWidgets():
1220
+ if isinstance(tlw, QMainWindow):
1221
+ return tlw
1222
+ return None
1223
+
1224
+
1225
+ # ---- MASK HELPERS -------------------------------------------------
1226
+ def _doc_for_mask(self):
1227
+ """Prefer the dialog-injected doc; else the active MDI doc."""
1228
+ return self.doc or self._get_active_document()
1229
+
1230
+ def _active_mask_array(self):
1231
+ """
1232
+ Return (mask_float01, mask_id, mask_name) or (None, None, None).
1233
+ """
1234
+ doc = self._doc_for_mask()
1235
+ if not doc:
1236
+ return None, None, None
1237
+ mid = getattr(doc, "active_mask_id", None)
1238
+ if not mid:
1239
+ return None, None, None
1240
+ layer = (getattr(doc, "masks", {}) or {}).get(mid)
1241
+ if layer is None:
1242
+ return None, None, None
1243
+
1244
+ import numpy as np
1245
+ m = np.asarray(getattr(layer, "data", None))
1246
+ if m is None or m.size == 0:
1247
+ return None, None, None
1248
+
1249
+ m = m.astype(np.float32, copy=False)
1250
+ if m.dtype.kind in "ui":
1251
+ m /= float(np.iinfo(m.dtype).max)
1252
+ else:
1253
+ mx = float(m.max()) if m.size else 1.0
1254
+ if mx > 1.0:
1255
+ m /= mx
1256
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
1257
+
1258
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
1259
+ """Nearest-neighbor resize via integer indexing."""
1260
+ import numpy as np
1261
+ mh, mw = mask.shape[:2]
1262
+ th, tw = out_hw
1263
+ if (mh, mw) == (th, tw):
1264
+ return mask
1265
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
1266
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
1267
+ return mask[yi][:, xi]
1268
+
1269
+ def _prepare_src_like(self, src, ref):
1270
+ """
1271
+ Convert document source image to float32 [0..1] and reconcile channels to match ref.
1272
+ """
1273
+ import numpy as np
1274
+ s = np.asarray(src)
1275
+ if s.dtype.kind in "ui":
1276
+ # assume 16-bit if >=2 bytes, else 8-bit
1277
+ scale = float(65535.0 if s.dtype.itemsize >= 2 else 255.0)
1278
+ s = s.astype(np.float32) / scale
1279
+ elif np.issubdtype(s.dtype, np.floating):
1280
+ s = s.astype(np.float32, copy=False)
1281
+ mx = float(s.max()) if s.size else 1.0
1282
+ if mx > 5.0:
1283
+ s = s / mx
1284
+
1285
+ # channel reconcile
1286
+ if s.ndim == 2 and ref.ndim == 3:
1287
+ s = np.stack([s]*3, axis=-1)
1288
+ elif s.ndim == 3 and s.shape[2] == 1 and ref.ndim == 3 and ref.shape[2] == 3:
1289
+ s = np.repeat(s, 3, axis=2)
1290
+ elif s.ndim == 3 and ref.ndim == 2:
1291
+ s = s[..., 0]
1292
+
1293
+ return s.astype(np.float32, copy=False)
1294
+
1295
+ def _blend_with_active_mask(self, processed: np.ndarray):
1296
+ """
1297
+ Blend processed result with the *current* document image using active mask.
1298
+ Returns (blended, mask_id, mask_name, masked_bool).
1299
+ If no mask, returns (processed, None, None, False).
1300
+ """
1301
+ mask, mid, mname = self._active_mask_array()
1302
+ if mask is None:
1303
+ return processed, None, None, False
1304
+
1305
+ import numpy as np
1306
+ out = np.asarray(processed, dtype=np.float32, copy=False)
1307
+
1308
+ doc = self._doc_for_mask()
1309
+ src = getattr(doc, "image", None)
1310
+ if src is None:
1311
+ return processed, mid, mname, True
1312
+
1313
+ srcf = self._prepare_src_like(src, out)
1314
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
1315
+ if out.ndim == 3:
1316
+ m = m[..., None]
1317
+
1318
+ blended = (m * out + (1.0 - m) * srcf).astype(np.float32, copy=False)
1319
+ return blended, mid, mname, True
1320
+ # ---- /MASK HELPERS ------------------------------------------------
1321
+
1322
+
1323
+ from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QComboBox
1324
+
1325
+ class SelectViewDialog(QDialog):
1326
+ def __init__(self, parent, items: list[tuple[str, object]],
1327
+ which: str | None = None, title: str | None = None):
1328
+ super().__init__(parent)
1329
+ # Use a nice default title if none provided
1330
+ if title is None:
1331
+ title = f"Select View for {which.upper()}" if which else "Select View"
1332
+ self.setWindowTitle(title)
1333
+
1334
+ self._items = items
1335
+ self.combo = QComboBox(self)
1336
+ for name, _doc in items:
1337
+ self.combo.addItem(name)
1338
+
1339
+ form = QFormLayout(self)
1340
+ if which:
1341
+ form.addRow(QLabel(f"Load into: {which.upper()}"))
1342
+ form.addRow("View:", self.combo)
1343
+
1344
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok |
1345
+ QDialogButtonBox.StandardButton.Cancel, parent=self)
1346
+ btns.accepted.connect(self.accept)
1347
+ btns.rejected.connect(self.reject)
1348
+ form.addRow(btns)
1349
+
1350
+ def selected_doc(self):
1351
+ idx = self.combo.currentIndex()
1352
+ return self._items[idx][1] if 0 <= idx < len(self._items) else None