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