setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1400 @@
1
+ # pro/convo.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import math
6
+ import numpy as np
7
+ from typing import Optional, Tuple
8
+ from functools import lru_cache
9
+ from concurrent.futures import ThreadPoolExecutor
10
+
11
+ # ── SciPy / scikit-image
12
+ from scipy.signal import fftconvolve
13
+ from scipy.ndimage import laplace
14
+ from numpy.fft import fft2, ifft2, ifftshift
15
+
16
+ from skimage.restoration import denoise_tv_chambolle, denoise_bilateral
17
+ from skimage.color import rgb2lab, lab2rgb
18
+ from skimage.util import img_as_float32
19
+ from skimage.transform import warp, AffineTransform
20
+
21
+ # ── Qt
22
+ from PyQt6.QtCore import Qt, pyqtSignal
23
+ from PyQt6.QtGui import QDoubleValidator, QImage, QPainter, QPen, QColor, QIcon, QPixmap
24
+ from PyQt6.QtWidgets import (
25
+ QApplication, QMessageBox,
26
+ QDialog, QHBoxLayout, QVBoxLayout, QFrame, QLabel, QSlider, QLineEdit,
27
+ QFormLayout, QTabWidget, QComboBox, QCheckBox, QPushButton, QToolButton,
28
+ QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QFileDialog, QWidget,
29
+ QSpinBox
30
+ )
31
+ import cv2
32
+ # Optional FITS export
33
+ from astropy.io import fits
34
+
35
+ import sep # PSF estimator
36
+
37
+ # Import centralized widgets
38
+ from setiastro.saspro.widgets.spinboxes import CustomSpinBox
39
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
40
+
41
+
42
+ # --- GraphicsView with Shift+Click LS center + optional scene ctor -----------
43
+ class InteractiveGraphicsView(QGraphicsView):
44
+ def __init__(self, scene: QGraphicsScene | None = None, parent=None):
45
+ super().__init__(parent)
46
+ if scene is not None:
47
+ self.setScene(scene)
48
+ self.ls_center: Optional[Tuple[float, float]] = None
49
+ self.cross_items = []
50
+
51
+ def mousePressEvent(self, event):
52
+ if (event.modifiers() & Qt.KeyboardModifier.ShiftModifier) and event.button() == Qt.MouseButton.LeftButton:
53
+ scene_pt = self.mapToScene(event.position().toPoint())
54
+ x, y = scene_pt.x(), scene_pt.y()
55
+ self.ls_center = (x, y)
56
+ self._draw_crosshair_at(x, y)
57
+ return
58
+ super().mousePressEvent(event)
59
+
60
+ def _draw_crosshair_at(self, x: float, y: float):
61
+ for item in self.cross_items:
62
+ self.scene().removeItem(item)
63
+ self.cross_items.clear()
64
+ size = 10
65
+ pen = QPen(QColor(255, 0, 0), 2)
66
+ hline = self.scene().addLine(x - size, y, x + size, y, pen)
67
+ vline = self.scene().addLine(x, y - size, x, y + size, pen)
68
+ self.cross_items.extend([hline, vline])
69
+
70
+
71
+ class FloatSliderWithEdit(QWidget):
72
+ """
73
+ Integer slider + float line edit, mapped by fixed step; emits valueChanged(float)
74
+ """
75
+ valueChanged = pyqtSignal(float)
76
+
77
+ def __init__(self, *, minimum: float, maximum: float, step: float, initial: float, suffix: str = "", parent=None):
78
+ super().__init__(parent)
79
+ self._min = minimum
80
+ self._max = maximum
81
+ self._step = step
82
+ self._suffix = suffix
83
+ self._factor = 1.0 / step
84
+ self._int_min = int(round(minimum * self._factor))
85
+ self._int_max = int(round(maximum * self._factor))
86
+
87
+ layout = QHBoxLayout(self)
88
+ layout.setContentsMargins(0, 0, 0, 0)
89
+
90
+ self.slider = QSlider(Qt.Orientation.Horizontal, self)
91
+ self.slider.setRange(self._int_min, self._int_max)
92
+ layout.addWidget(self.slider, stretch=1)
93
+
94
+ self.edit = QLineEdit(self)
95
+ self.edit.setFixedWidth(60)
96
+ validator = QDoubleValidator(minimum, maximum, int(abs(np.log10(step))), self)
97
+ validator.setNotation(QDoubleValidator.Notation.StandardNotation)
98
+ self.edit.setValidator(validator)
99
+ layout.addWidget(self.edit)
100
+
101
+ self.setValue(initial)
102
+ self.slider.valueChanged.connect(self._on_slider_changed)
103
+ self.edit.editingFinished.connect(self._on_edit_finished)
104
+
105
+ def _on_slider_changed(self, int_val: int):
106
+ f = int_val / self._factor
107
+ f = min(max(f, self._min), self._max)
108
+ text = f"{f:.{max(0, int(-np.log10(self._step)))}f}{self._suffix}"
109
+ self.edit.blockSignals(True)
110
+ self.edit.setText(text)
111
+ self.edit.blockSignals(False)
112
+ self.valueChanged.emit(f)
113
+
114
+ def _on_edit_finished(self):
115
+ txt = self.edit.text().rstrip(self._suffix)
116
+ try:
117
+ f = float(txt)
118
+ except ValueError:
119
+ f = self.slider.value() / self._factor
120
+ f = min(max(f, self._min), self._max)
121
+ int_val = int(round(f * self._factor))
122
+ self.slider.blockSignals(True)
123
+ self.slider.setValue(int_val)
124
+ self.slider.blockSignals(False)
125
+
126
+ def value(self) -> float:
127
+ return self.slider.value() / self._factor
128
+
129
+ def setValue(self, f: float):
130
+ f = min(max(f, self._min), self._max)
131
+ int_val = int(round(f * self._factor))
132
+ self.slider.blockSignals(True)
133
+ self.slider.setValue(int_val)
134
+ self.slider.blockSignals(False)
135
+ s = f"{(int_val / self._factor):.{max(0, int(-np.log10(self._step)))}f}{self._suffix}"
136
+ self.edit.setText(s)
137
+ self.valueChanged.emit(int_val / self._factor)
138
+
139
+
140
+ # ============= Convo/Deconvo dialog (DocManager-powered) =====================
141
+ class ConvoDeconvoDialog(QDialog):
142
+ """
143
+ SASpro version: takes a DocManager, no ImageManager dependency.
144
+ """
145
+ def __init__(self, doc_manager, parent=None, doc=None):
146
+ super().__init__(parent)
147
+ self.doc_manager = doc_manager
148
+ self._main = parent # keep a ref to the main window (has _active_doc + signal)
149
+ self._doc_override = doc # ← explicit doc (ROI or full) from the MDI
150
+
151
+ # Only follow global active-doc changes if we *weren't* given a doc
152
+ if hasattr(self._main, "currentDocumentChanged") and self._doc_override is None:
153
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
154
+
155
+ self.setWindowTitle(self.tr("Convolution / Deconvolution"))
156
+ self.setWindowFlag(Qt.WindowType.Window, True)
157
+ self.setWindowModality(Qt.WindowModality.NonModal)
158
+ self.setModal(False)
159
+ self.resize(1000, 650)
160
+ self._use_custom_psf = False
161
+ self._custom_psf: Optional[np.ndarray] = None
162
+ self._last_stellar_psf: Optional[np.ndarray] = None
163
+ self._original_image: Optional[np.ndarray] = None
164
+ self._preview_result: Optional[np.ndarray] = None
165
+ self._auto_fit = False
166
+ self._load_original_on_show = True
167
+
168
+ # ── Layout: left controls / right preview
169
+ main_layout = QHBoxLayout(self)
170
+ # Left
171
+ left_panel = QFrame(); left_panel.setFrameShape(QFrame.Shape.StyledPanel); left_panel.setFixedWidth(350)
172
+ left_layout = QVBoxLayout(left_panel); main_layout.addWidget(left_panel)
173
+ # Right
174
+ preview_panel = QFrame(); preview_layout = QVBoxLayout(preview_panel); main_layout.addWidget(preview_panel, stretch=1)
175
+
176
+ # Tabs
177
+ self.tabs = QTabWidget(); left_layout.addWidget(self.tabs)
178
+ self.deconv_param_stack: dict[str, QWidget] = {}
179
+ self._build_convolution_tab()
180
+ self._build_deconvolution_tab()
181
+ self._build_psf_estimator_tab()
182
+ self._build_tv_denoise_tab()
183
+
184
+ # PSF preview chip
185
+ self.conv_psf_label = QLabel(); self.conv_psf_label.setFixedSize(64, 64)
186
+ self.conv_psf_label.setStyleSheet("border: 1px solid #888;")
187
+ left_layout.addWidget(self.conv_psf_label, alignment=Qt.AlignmentFlag.AlignHCenter)
188
+
189
+ # Strength
190
+ self.strength_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=1.0, suffix="")
191
+ srow = QHBoxLayout(); srow.addWidget(QLabel("Strength:")); srow.addWidget(self.strength_slider)
192
+ left_layout.addLayout(srow)
193
+
194
+ # Buttons
195
+ row1 = QHBoxLayout()
196
+ self.preview_btn = QPushButton(self.tr("Preview"))
197
+ self.undo_btn = QPushButton(self.tr("Undo"))
198
+ self.close_btn = QPushButton(self.tr("Close"))
199
+ row1.addWidget(self.preview_btn); row1.addWidget(self.undo_btn)
200
+ left_layout.addLayout(row1)
201
+
202
+ row2 = QHBoxLayout()
203
+ self.push_btn = QPushButton(self.tr("Push"))
204
+ row2.addWidget(self.push_btn); row2.addWidget(self.close_btn)
205
+ left_layout.addLayout(row2)
206
+
207
+ left_layout.addStretch()
208
+ self.rl_status_label = QLabel(""); self.rl_status_label.setStyleSheet("color:#fff;background:#333;padding:4px;")
209
+ self.rl_status_label.setFixedHeight(24)
210
+ left_layout.addWidget(self.rl_status_label)
211
+
212
+ # Zoom & Preview
213
+ zrow = QHBoxLayout(); zrow.addStretch()
214
+ self.zoom_in_btn = QToolButton(); self.zoom_in_btn.setIcon(QIcon.fromTheme("zoom-in")); self.zoom_in_btn.setToolTip("Zoom In")
215
+ self.zoom_out_btn= QToolButton(); self.zoom_out_btn.setIcon(QIcon.fromTheme("zoom-out")); self.zoom_out_btn.setToolTip("Zoom Out")
216
+ self.fit_btn = QToolButton(); self.fit_btn.setIcon(QIcon.fromTheme("zoom-fit-best")); self.fit_btn.setToolTip("Fit to Preview")
217
+ zrow.addWidget(self.zoom_in_btn); zrow.addWidget(self.zoom_out_btn); zrow.addWidget(self.fit_btn)
218
+ preview_layout.addLayout(zrow)
219
+
220
+ self.scene = QGraphicsScene()
221
+ self.view = InteractiveGraphicsView(self.scene)
222
+ self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
223
+ self.view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
224
+ self.pixmap_item = QGraphicsPixmapItem(); self.scene.addItem(self.pixmap_item)
225
+ preview_layout.addWidget(self.view)
226
+
227
+ # Signals
228
+ self.preview_btn.clicked.connect(self._on_preview)
229
+ self.undo_btn.clicked.connect(self._on_undo)
230
+ self.push_btn.clicked.connect(self._on_push_to_doc)
231
+ self.close_btn.clicked.connect(self.close)
232
+
233
+ self.zoom_in_btn.clicked.connect(self.zoom_in)
234
+ self.zoom_out_btn.clicked.connect(self.zoom_out)
235
+ self.fit_btn.clicked.connect(self._on_fit_clicked)
236
+
237
+ self.tabs.currentChanged.connect(self._update_psf_preview)
238
+ self.deconv_algo_combo.currentTextChanged.connect(self._update_psf_preview)
239
+
240
+ self.sep_run_button.clicked.connect(self._on_run_sep)
241
+ self.sep_use_button.clicked.connect(self._on_use_stellar_psf)
242
+ self.sep_save_button.clicked.connect(self._on_save_stellar_psf)
243
+
244
+ for s in (self.conv_radius_slider, self.conv_shape_slider, self.conv_aspect_slider, self.conv_rotation_slider):
245
+ s.valueChanged.connect(self._update_psf_preview)
246
+ for s in (self.rl_psf_radius_slider, self.rl_psf_shape_slider, self.rl_psf_aspect_slider, self.rl_psf_rotation_slider):
247
+ s.valueChanged.connect(self._update_psf_preview)
248
+
249
+ self._update_psf_preview()
250
+
251
+ def _active_doc(self):
252
+ # 1) If we were given a specific doc (ROI or full), always use that.
253
+ if getattr(self, "_doc_override", None) is not None:
254
+ return self._doc_override
255
+
256
+ # 2) Otherwise fall back to the MDI's notion of active
257
+ if self._main is not None and hasattr(self._main, "_active_doc") and callable(self._main._active_doc):
258
+ try:
259
+ return self._main._active_doc()
260
+ except Exception:
261
+ pass
262
+
263
+ # 3) Last resort: DocManager's active doc
264
+ if hasattr(self.doc_manager, "get_active_document"):
265
+ return self.doc_manager.get_active_document()
266
+
267
+ return None
268
+
269
+
270
+ def _on_active_doc_changed(self, doc):
271
+ # If this dialog is bound to a specific doc (ROI/full), ignore global changes
272
+ if getattr(self, "_doc_override", None) is not None:
273
+ return
274
+
275
+ img = getattr(doc, "image", None)
276
+ self._preview_result = None
277
+ self._original_image = img.copy() if isinstance(img, np.ndarray) else None
278
+ if self._original_image is not None:
279
+ self._auto_fit = True
280
+ self._display_in_view(self._original_image)
281
+
282
+
283
+ # ---------------- DocManager IO helpers ----------------
284
+ def _get_active_image_and_meta(self) -> tuple[Optional[np.ndarray], dict]:
285
+ doc = self._active_doc()
286
+ if doc is None or getattr(doc, "image", None) is None:
287
+ return None, {}
288
+ return doc.image, (getattr(doc, "metadata", {}) or {})
289
+
290
+ # ---------------- Qt life-cycle ----------------
291
+ def showEvent(self, ev):
292
+ super().showEvent(ev)
293
+ self._preview_result = None
294
+ if self._load_original_on_show:
295
+ img, _ = self._get_active_image_and_meta()
296
+ if img is not None:
297
+ self._original_image = img.copy()
298
+ self._auto_fit = True
299
+ self._display_in_view(img)
300
+ self._load_original_on_show = False
301
+ self.conv_psf_label.clear()
302
+ self.sep_psf_preview.clear() if hasattr(self, "sep_psf_preview") else None
303
+ self._update_psf_preview()
304
+
305
+ def closeEvent(self, ev):
306
+ # Clear state so next open starts fresh
307
+ if hasattr(self.view, "ls_center"):
308
+ self.view.ls_center = None
309
+ self._original_image = None
310
+ self._preview_result = None
311
+ self._last_stellar_psf = None
312
+ self._custom_psf = None
313
+ self._use_custom_psf = False
314
+ self.conv_psf_label.clear() if hasattr(self, "conv_psf_label") else None
315
+ self.sep_psf_preview.clear() if hasattr(self, "sep_psf_preview") else None
316
+ self.rl_status_label.setText("") if hasattr(self, "rl_status_label") else None
317
+ self.custom_psf_bar.setVisible(False) if hasattr(self, "custom_psf_bar") else None
318
+ super().closeEvent(ev)
319
+
320
+ # ---------------- Build tabs ----------------
321
+ def _build_convolution_tab(self):
322
+ conv_tab = QWidget()
323
+ layout = QVBoxLayout(conv_tab)
324
+ form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
325
+
326
+ self.conv_radius_slider = FloatSliderWithEdit(minimum=0.1, maximum=200.0, step=0.1, initial=5.0, suffix=" px")
327
+ form.addRow("Radius:", self.conv_radius_slider)
328
+
329
+ self.conv_shape_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=2.0, suffix="σ")
330
+ form.addRow("Kurtosis (σ):", self.conv_shape_slider)
331
+
332
+ self.conv_aspect_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=1.0, suffix="")
333
+ form.addRow("Aspect Ratio:", self.conv_aspect_slider)
334
+
335
+ self.conv_rotation_slider = FloatSliderWithEdit(minimum=0.0, maximum=360.0, step=1.0, initial=0.0, suffix="°")
336
+ form.addRow("Rotation:", self.conv_rotation_slider)
337
+
338
+ layout.addLayout(form); layout.addStretch()
339
+ self.tabs.addTab(conv_tab, self.tr("Convolution"))
340
+
341
+ def _build_deconvolution_tab(self):
342
+ deconv_tab = QWidget()
343
+ outer_layout = QVBoxLayout(deconv_tab)
344
+
345
+ # Algo row
346
+ algo_layout = QHBoxLayout()
347
+ algo_layout.addWidget(QLabel("Algorithm:"))
348
+ self.deconv_algo_combo = QComboBox()
349
+ self.deconv_algo_combo.addItems(["Richardson-Lucy", "Wiener", "Larson-Sekanina", "Van Cittert"])
350
+ self.deconv_algo_combo.currentTextChanged.connect(self._on_deconv_algo_changed)
351
+ algo_layout.addWidget(self.deconv_algo_combo); algo_layout.addStretch()
352
+ outer_layout.addLayout(algo_layout)
353
+
354
+ # PSF sliders (shared for RL/Wiener)
355
+ self.psf_param_group = QWidget()
356
+ psf_group_layout = QFormLayout(self.psf_param_group)
357
+ psf_group_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
358
+
359
+ self.rl_psf_radius_slider = FloatSliderWithEdit(minimum=0.1, maximum=100.0, step=0.1, initial=3.0, suffix=" px")
360
+ psf_group_layout.addRow("PSF Radius:", self.rl_psf_radius_slider)
361
+ self.rl_psf_shape_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=2.0, suffix="σ")
362
+ psf_group_layout.addRow("PSF Kurtosis (σ):", self.rl_psf_shape_slider)
363
+ self.rl_psf_aspect_slider = FloatSliderWithEdit(minimum=0.1, maximum=10.0, step=0.1, initial=1.0, suffix="")
364
+ psf_group_layout.addRow("PSF Aspect Ratio:", self.rl_psf_aspect_slider)
365
+ self.rl_psf_rotation_slider = FloatSliderWithEdit(minimum=0.0, maximum=360.0, step=1.0, initial=0.0, suffix="°")
366
+ psf_group_layout.addRow("PSF Rotation:", self.rl_psf_rotation_slider)
367
+ outer_layout.addWidget(self.psf_param_group)
368
+ self.psf_param_group.setVisible(self.deconv_algo_combo.currentText() in ("Richardson-Lucy", "Wiener"))
369
+
370
+ # “Using Stellar PSF” bar
371
+ self.custom_psf_bar = QWidget()
372
+ bar_layout = QHBoxLayout(self.custom_psf_bar); bar_layout.setContentsMargins(0, 0, 0, 0); bar_layout.setSpacing(4)
373
+ self.rl_custom_label = QLabel("Using Stellar PSF")
374
+ self.rl_custom_label.setStyleSheet("color:#fff;background-color:#007acc;padding:2px;")
375
+ self.rl_custom_label.setVisible(False)
376
+ self.rl_disable_custom_btn = QPushButton("Disable Stellar PSF")
377
+ self.rl_disable_custom_btn.setToolTip("Revert to PSF sliders")
378
+ self.rl_disable_custom_btn.setVisible(False)
379
+ self.rl_disable_custom_btn.clicked.connect(self._clear_custom_psf_flag)
380
+ bar_layout.addWidget(self.rl_custom_label); bar_layout.addWidget(self.rl_disable_custom_btn); bar_layout.addStretch()
381
+ outer_layout.addWidget(self.custom_psf_bar)
382
+ self.custom_psf_bar.setVisible(False)
383
+
384
+ # Stacked parameter panels
385
+ self.deconv_param_stack.clear()
386
+ self.deconv_stack_container = QWidget(); self.deconv_stack_layout = QVBoxLayout(self.deconv_stack_container)
387
+
388
+ # RL
389
+ rl_widget = QWidget()
390
+ rl_form = QFormLayout(rl_widget); rl_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
391
+ self.rl_iterations_slider = FloatSliderWithEdit(minimum=1.0, maximum=100.0, step=1.0, initial=30.0, suffix="")
392
+ rl_form.addRow("Iterations:", self.rl_iterations_slider)
393
+ self.rl_reg_combo = QComboBox(); self.rl_reg_combo.addItems(["None (Plain R–L)", "Tikhonov (L2)", "Total Variation (TV)"])
394
+ rl_form.addRow("Regularization:", self.rl_reg_combo)
395
+ self.rl_clip_checkbox = QCheckBox("Enable de‐ring"); self.rl_clip_checkbox.setChecked(True)
396
+ rl_form.addRow("", self.rl_clip_checkbox)
397
+ self.rl_luminance_only_checkbox = QCheckBox("Deconvolve L* Only"); self.rl_luminance_only_checkbox.setChecked(True)
398
+ self.rl_luminance_only_checkbox.setToolTip("If checked and the image is color, RL runs only on the L* channel.")
399
+ rl_form.addRow("", self.rl_luminance_only_checkbox)
400
+ rl_widget.setLayout(rl_form)
401
+ self.deconv_param_stack["Richardson-Lucy"] = rl_widget
402
+
403
+ # Wiener
404
+ wiener_widget = QWidget(); wiener_layout = QVBoxLayout(wiener_widget)
405
+ wiener_form = QFormLayout(); wiener_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
406
+ self.wiener_nsr_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.001, initial=0.01, suffix="")
407
+ wiener_form.addRow("Noise/Signal (λ):", self.wiener_nsr_slider)
408
+ self.wiener_reg_combo = QComboBox(); self.wiener_reg_combo.addItems(["None (Classical Wiener)", "Tikhonov (L2)"])
409
+ wiener_form.addRow("Regularization:", self.wiener_reg_combo)
410
+ self.wiener_luminance_only_checkbox = QCheckBox("Deconvolve L* Only"); self.wiener_luminance_only_checkbox.setChecked(True)
411
+ self.wiener_luminance_only_checkbox.setToolTip("If checked and the image is color, Wiener runs only on the L* channel.")
412
+ wiener_form.addRow("", self.wiener_luminance_only_checkbox)
413
+ self.wiener_dering_checkbox = QCheckBox("Enable de-ring"); self.wiener_dering_checkbox.setChecked(True)
414
+ self.wiener_dering_checkbox.setToolTip("Applies a single bilateral pass after Wiener deconvolution")
415
+ wiener_form.addRow("", self.wiener_dering_checkbox)
416
+ wiener_layout.addLayout(wiener_form)
417
+ self.deconv_param_stack["Wiener"] = wiener_widget
418
+
419
+ # Larson–Sekanina
420
+ ls_widget = QWidget(); ls_form = QFormLayout(ls_widget); ls_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
421
+ self.ls_radial_slider = FloatSliderWithEdit(minimum=0.0, maximum=50.0, step=0.1, initial=0.0, suffix=" px")
422
+ self.ls_angular_slider = FloatSliderWithEdit(minimum=0.1, maximum=360.0, step=0.1, initial=1.0, suffix="°")
423
+ self.ls_operator_combo = QComboBox(); self.ls_operator_combo.addItems(["Divide", "Subtract"])
424
+ self.ls_blend_combo = QComboBox(); self.ls_blend_combo.addItems(["SoftLight", "Screen"])
425
+ ls_form.addRow("Radial Step (px):", self.ls_radial_slider)
426
+ ls_form.addRow("Angular Step (°):", self.ls_angular_slider)
427
+ ls_form.addRow("LS Operator:", self.ls_operator_combo)
428
+ ls_form.addRow("Blend Mode:", self.ls_blend_combo)
429
+ self.ls_operator_combo.currentTextChanged.connect(self._on_ls_operator_changed)
430
+ self.deconv_param_stack["Larson-Sekanina"] = ls_widget
431
+
432
+ # Van Cittert
433
+ vc_widget = QWidget(); vc_form = QFormLayout(vc_widget); vc_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
434
+ self.vc_iterations_slider = FloatSliderWithEdit(minimum=1, maximum=1000, step=1, initial=10, suffix="")
435
+ self.vc_relax_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=0.0, suffix="")
436
+ vc_form.addRow("Iterations:", self.vc_iterations_slider)
437
+ vc_form.addRow("Relaxation (0–1):", self.vc_relax_slider)
438
+ self.deconv_param_stack["Van Cittert"] = vc_widget
439
+
440
+ # Add all panels (hidden initially)
441
+ for widget in self.deconv_param_stack.values():
442
+ widget.setVisible(False)
443
+ self.deconv_stack_layout.addWidget(widget)
444
+
445
+ first_algo = self.deconv_algo_combo.currentText()
446
+ if first_algo in self.deconv_param_stack:
447
+ self.deconv_param_stack[first_algo].setVisible(True)
448
+
449
+ outer_layout.addWidget(self.deconv_stack_container)
450
+ outer_layout.addStretch()
451
+ self.tabs.addTab(deconv_tab, self.tr("Deconvolution"))
452
+
453
+ # Clear “custom PSF” if sliders change
454
+ for s in (self.rl_psf_radius_slider, self.rl_psf_shape_slider, self.rl_psf_aspect_slider, self.rl_psf_rotation_slider):
455
+ s.valueChanged.connect(self._clear_custom_psf_flag)
456
+
457
+ def _build_psf_estimator_tab(self):
458
+ psf_tab = QWidget(); layout = QVBoxLayout(psf_tab)
459
+
460
+ h_image = QHBoxLayout()
461
+ h_image.addWidget(QLabel("Image for PSF Estimate:"))
462
+ self.sep_image_label = QLabel("(Current Active Image)")
463
+ h_image.addWidget(self.sep_image_label); layout.addLayout(h_image)
464
+
465
+ form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
466
+ self.sep_threshold_slider = FloatSliderWithEdit(minimum=1.0, maximum=5.0, step=0.1, initial=2.5, suffix=" σ")
467
+ form.addRow("Detection σ:", self.sep_threshold_slider)
468
+ self.sep_minarea_spin = CustomSpinBox(minimum=1, maximum=100, initial=5, step=1)
469
+ form.addRow("Min Area (px²):", self.sep_minarea_spin)
470
+ self.sep_sat_slider = FloatSliderWithEdit(minimum=1000, maximum=100000, step=500, initial=50000, suffix=" ADU")
471
+ form.addRow("Saturation Cutoff:", self.sep_sat_slider)
472
+ self.sep_maxstars_spin = CustomSpinBox(minimum=1, maximum=500, initial=50, step=1)
473
+ form.addRow("Max Stars:", self.sep_maxstars_spin)
474
+ self.sep_stamp_spin = CustomSpinBox(minimum=5, maximum=50, initial=15, step=1)
475
+ form.addRow("Half‐Width (px):", self.sep_stamp_spin)
476
+ layout.addLayout(form)
477
+
478
+ h_buttons = QHBoxLayout()
479
+ self.sep_run_button = QPushButton("Run SEP Extraction")
480
+ self.sep_save_button = QPushButton("Save PSF…")
481
+ self.sep_use_button = QPushButton("Use as Current PSF")
482
+ h_buttons.addWidget(self.sep_run_button); h_buttons.addWidget(self.sep_save_button); h_buttons.addWidget(self.sep_use_button)
483
+ layout.addLayout(h_buttons)
484
+
485
+ self.psf_estimate_title = QLabel("Estimated PSF (64×64):")
486
+ layout.addWidget(self.psf_estimate_title, alignment=Qt.AlignmentFlag.AlignLeft)
487
+ self.sep_psf_preview = QLabel(); self.sep_psf_preview.setFixedSize(64, 64)
488
+ self.sep_psf_preview.setStyleSheet("border: 1px solid #888;")
489
+ layout.addWidget(self.sep_psf_preview, alignment=Qt.AlignmentFlag.AlignHCenter)
490
+
491
+ layout.addStretch()
492
+ self.tabs.addTab(psf_tab, self.tr("PSF Estimator"))
493
+
494
+ def _build_tv_denoise_tab(self):
495
+ tvd_tab = QWidget(); layout = QVBoxLayout(tvd_tab)
496
+ form = QFormLayout(); form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
497
+ self.tv_weight_slider = FloatSliderWithEdit(minimum=0.0, maximum=1.0, step=0.01, initial=0.1, suffix="")
498
+ form.addRow("TV Weight:", self.tv_weight_slider)
499
+ self.tv_iter_slider = FloatSliderWithEdit(minimum=1, maximum=100, step=1, initial=10, suffix="")
500
+ form.addRow("Max Iterations:", self.tv_iter_slider)
501
+ self.tv_multichannel_checkbox = QCheckBox("Multi‐channel"); self.tv_multichannel_checkbox.setChecked(True)
502
+ self.tv_multichannel_checkbox.setToolTip("If checked and the image is color, run TV on all channels jointly")
503
+ form.addRow("", self.tv_multichannel_checkbox)
504
+ layout.addLayout(form); layout.addStretch()
505
+ self.tabs.addTab(tvd_tab, self.tr("TV Denoise"))
506
+
507
+ # ---------------- UI reactions ----------------
508
+ def _on_deconv_algo_changed(self, selected: str):
509
+ for w in self.deconv_param_stack.values():
510
+ w.setVisible(False)
511
+ if selected in self.deconv_param_stack:
512
+ self.deconv_param_stack[selected].setVisible(True)
513
+
514
+ # Show/hide PSF sliders & bar
515
+ on_psf_algo = selected in ("Richardson-Lucy", "Wiener")
516
+ self.psf_param_group.setVisible(on_psf_algo)
517
+ self.custom_psf_bar.setVisible(on_psf_algo and self._use_custom_psf and (self._custom_psf is not None))
518
+
519
+ def _on_ls_operator_changed(self, op_text: str):
520
+ self.ls_blend_combo.setCurrentText("SoftLight" if op_text == "Divide" else "Screen")
521
+
522
+ def _make_psf_pixmap(self, radius, kurtosis, aspect, rotation_deg) -> QPixmap:
523
+ psf = make_elliptical_gaussian_psf(radius, kurtosis, aspect, rotation_deg)
524
+ h, w = psf.shape
525
+ img8 = ((psf / psf.max()) * 255.0).astype(np.uint8) if psf.max() > 0 else psf.astype(np.uint8)
526
+ qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
527
+ scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
528
+ final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
529
+ p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
530
+ return final
531
+
532
+ def _make_stellar_psf_pixmap(self, psf_kernel: np.ndarray) -> QPixmap:
533
+ h, w = psf_kernel.shape
534
+ img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
535
+ qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
536
+ scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
537
+ final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
538
+ p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
539
+ return final
540
+
541
+ def _update_psf_preview(self):
542
+ current_tab = self.tabs.tabText(self.tabs.currentIndex())
543
+ algo = getattr(self, "deconv_algo_combo", None)
544
+ algo_text = algo.currentText() if algo is not None else ""
545
+
546
+ if current_tab == "Convolution":
547
+ r, k, a, rot = (self.conv_radius_slider.value(), self.conv_shape_slider.value(),
548
+ self.conv_aspect_slider.value(), self.conv_rotation_slider.value())
549
+ self.conv_psf_label.setPixmap(self._make_psf_pixmap(r, k, a, rot))
550
+ elif current_tab == "Deconvolution" and algo_text in ("Richardson-Lucy", "Wiener"):
551
+ if self._use_custom_psf and (self._custom_psf is not None):
552
+ self.conv_psf_label.setPixmap(self._make_stellar_psf_pixmap(self._custom_psf))
553
+ else:
554
+ r, k, a, rot = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
555
+ self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
556
+ self.conv_psf_label.setPixmap(self._make_psf_pixmap(r, k, a, rot))
557
+ else:
558
+ self.conv_psf_label.clear()
559
+
560
+ # ---------------- Mask helper (from active document) ----------------
561
+ def _active_mask_array_from_active_doc(self) -> np.ndarray | None:
562
+ """
563
+ Read the active mask from the active document:
564
+ doc.active_mask_id -> doc.masks[mid].data
565
+ Return a 2-D float32 mask in [0..1], or None.
566
+ """
567
+ try:
568
+ doc = self._active_doc()
569
+ if doc is None:
570
+ return None
571
+ mid = getattr(doc, "active_mask_id", None)
572
+ if not mid:
573
+ return None
574
+ masks = getattr(doc, "masks", {}) or {}
575
+ layer = masks.get(mid)
576
+ data = getattr(layer, "data", None) if layer is not None else None
577
+ if data is None:
578
+ return None
579
+
580
+ m = np.asarray(data)
581
+ # If RGB(A) mask, convert to gray
582
+ if m.ndim == 3:
583
+ if cv2 is not None:
584
+ m = cv2.cvtColor(m, cv2.COLOR_BGR2GRAY)
585
+ else:
586
+ m = m.mean(axis=2)
587
+
588
+ m = m.astype(np.float32, copy=False)
589
+ if m.max() > 1.0:
590
+ m /= 255.0
591
+ return np.clip(m, 0.0, 1.0)
592
+ except Exception:
593
+ return None
594
+
595
+
596
+ def _resize_mask_nearest(self, mask2d: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray:
597
+ """Resize 2-D mask to (H, W) using nearest neighbor."""
598
+ H, W = target_hw
599
+ if mask2d.shape == (H, W):
600
+ return mask2d
601
+ if cv2 is not None:
602
+ return cv2.resize(mask2d, (W, H), interpolation=cv2.INTER_NEAREST).astype(np.float32, copy=False)
603
+ # NumPy fallback NN
604
+ yi = (np.linspace(0, mask2d.shape[0] - 1, H)).astype(np.int32)
605
+ xi = (np.linspace(0, mask2d.shape[1] - 1, W)).astype(np.int32)
606
+ return mask2d[yi][:, xi].astype(np.float32, copy=False)
607
+
608
+
609
+ def _get_active_mask_from_doc(self, target_shape) -> np.ndarray | None:
610
+ """
611
+ Return mask resized to `target_shape`; broadcast to channels if needed.
612
+ """
613
+ m = self._active_mask_array_from_active_doc()
614
+ if m is None:
615
+ return None
616
+
617
+ H, W = target_shape[:2]
618
+ m = self._resize_mask_nearest(m, (H, W))
619
+
620
+ # If the processed image is RGB, expand mask to 3 channels
621
+ if len(target_shape) == 3 and m.ndim == 2:
622
+ m = np.repeat(m[:, :, None], target_shape[2], axis=2)
623
+
624
+ return np.clip(m.astype(np.float32, copy=False), 0.0, 1.0)
625
+
626
+ # ---------------- Core actions ----------------
627
+ def _on_preview(self):
628
+ doc = self._active_doc()
629
+ if hasattr(self.doc_manager, "set_active_document"):
630
+ self.doc_manager.set_active_document(doc)
631
+ img, _ = self._get_active_image_and_meta()
632
+ if img is None:
633
+ self._show_message("No active image to process.")
634
+ return
635
+
636
+ if self._original_image is None:
637
+ self._original_image = img.copy()
638
+
639
+ current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
640
+
641
+ if current_tab_name == "Convolution":
642
+ radius = self.conv_radius_slider.value()
643
+ kurtosis= self.conv_shape_slider.value()
644
+ aspect = self.conv_aspect_slider.value()
645
+ rotation= self.conv_rotation_slider.value()
646
+ psf_kernel = make_elliptical_gaussian_psf(radius, kurtosis, aspect, rotation).astype(np.float32)
647
+ processed = self._convolve_color(img, psf_kernel)
648
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
649
+
650
+ elif current_tab_name == "Deconvolution":
651
+ algo = self.deconv_algo_combo.currentText()
652
+ if algo == "Richardson-Lucy":
653
+ iters = int(round(self.rl_iterations_slider.value()))
654
+ reg_type = self.rl_reg_combo.currentText()
655
+ pr, ps, pa, pt = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
656
+ self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
657
+ psf_kernel = make_elliptical_gaussian_psf(pr, ps, pa, pt).astype(np.float32)
658
+ clip_flag = self.rl_clip_checkbox.isChecked()
659
+
660
+ if self.rl_luminance_only_checkbox.isChecked() and img.ndim == 3 and img.shape[2] == 3:
661
+ lab = rgb2lab(img.astype(np.float32))
662
+ L = (lab[:, :, 0] / 100.0).astype(np.float32)
663
+ deconv_L = self._richardson_lucy_color(L, psf_kernel, iterations=iters, reg_type=reg_type, clip_flag=clip_flag)
664
+ lab[:, :, 0] = np.clip(deconv_L * 100.0, 0.0, 100.0)
665
+ rgb_deconv = lab2rgb(lab.astype(np.float32))
666
+ processed = np.clip(rgb_deconv.astype(np.float32), 0.0, 1.0)
667
+ else:
668
+ processed = self._richardson_lucy_color(img.astype(np.float32), psf_kernel, iters, reg_type, clip_flag)
669
+
670
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
671
+
672
+ elif algo == "Wiener":
673
+ if self._use_custom_psf and (self._custom_psf is not None):
674
+ small_psf = self._custom_psf.astype(np.float32)
675
+ else:
676
+ pr, ps, pa, pt = (self.rl_psf_radius_slider.value(), self.rl_psf_shape_slider.value(),
677
+ self.rl_psf_aspect_slider.value(), self.rl_psf_rotation_slider.value())
678
+ small_psf = make_elliptical_gaussian_psf(pr, ps, pa, pt).astype(np.float32)
679
+
680
+ nsr = self.wiener_nsr_slider.value()
681
+ reg_type = "Wiener" if self.wiener_reg_combo.currentText() == "None (Classical Wiener)" else "Tikhonov"
682
+ do_dering = self.wiener_dering_checkbox.isChecked()
683
+
684
+ if self.wiener_luminance_only_checkbox.isChecked() and img.ndim == 3 and img.shape[2] == 3:
685
+ lab = rgb2lab(img.astype(np.float32))
686
+ L = (lab[:, :, 0] / 100.0).astype(np.float32)
687
+ deconv_L = self._wiener_deconv_with_kernel(L, small_psf, nsr, reg_type, do_dering)
688
+ lab[:, :, 0] = np.clip(deconv_L * 100.0, 0.0, 100.0)
689
+ rgb_deconv = lab2rgb(lab.astype(np.float32))
690
+ processed = np.clip(rgb_deconv.astype(np.float32), 0.0, 1.0)
691
+ else:
692
+ processed = self._wiener_deconv_with_kernel(img, small_psf, nsr, reg_type, do_dering)
693
+ processed = np.clip(processed, 0.0, 1.0)
694
+
695
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
696
+
697
+ elif algo == "Larson-Sekanina":
698
+ if not hasattr(self.view, "ls_center") or self.view.ls_center is None:
699
+ QMessageBox.information(self, "Hold Shift + Click",
700
+ "To choose a Larson–Sekanina center, hold Shift and click on the preview.")
701
+ return
702
+
703
+ center = self.view.ls_center
704
+ rstep = self.ls_radial_slider.value()
705
+ astep = self.ls_angular_slider.value()
706
+ operator = self.ls_operator_combo.currentText()
707
+ blend_mode = self.ls_blend_combo.currentText()
708
+
709
+ B = larson_sekanina(image=img, center=center, radial_step=rstep, angular_step_deg=astep, operator=operator)
710
+ A = img
711
+ if A.ndim == 3 and A.shape[2] == 3:
712
+ B_rgb, A_rgb = np.repeat(B[:, :, None], 3, axis=2), A
713
+ else:
714
+ B_rgb, A_rgb = B[..., None], A[..., None]
715
+ C = (A_rgb + B_rgb - (A_rgb * B_rgb)) if blend_mode == "Screen" else ((1 - 2 * B_rgb) * (A_rgb**2) + 2 * B_rgb * A_rgb)
716
+ processed = np.clip(C, 0.0, 1.0)
717
+ processed = processed[..., 0] if img.ndim == 2 else processed
718
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
719
+
720
+ elif algo == "Van Cittert":
721
+ iters2 = self.vc_iterations_slider.value()
722
+ relax = self.vc_relax_slider.value()
723
+ if img.ndim == 3 and img.shape[2] == 3:
724
+ chans = [van_cittert_deconv(img[:, :, c], iters2, relax) for c in range(3)]
725
+ processed = np.stack(chans, axis=2)
726
+ else:
727
+ processed = van_cittert_deconv(img, iters2, relax)
728
+ processed = np.clip(processed, 0.0, 1.0)
729
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
730
+
731
+ else:
732
+ self._show_message("Unknown deconvolution algorithm")
733
+ return
734
+
735
+ elif current_tab_name == "TV Denoise":
736
+ weight = self.tv_weight_slider.value()
737
+ max_iter = int(self.tv_iter_slider.value())
738
+ multichannel = self.tv_multichannel_checkbox.isChecked()
739
+
740
+ if img.ndim == 3 and multichannel:
741
+ processed = denoise_tv_chambolle(img.astype(np.float32), weight=weight, max_num_iter=max_iter, channel_axis=-1).astype(np.float32)
742
+ else:
743
+ if img.ndim == 3 and img.shape[2] == 3:
744
+ channels_out = [
745
+ denoise_tv_chambolle(img[:, :, c].astype(np.float32), weight=weight, max_num_iter=max_iter, channel_axis=None).astype(np.float32)
746
+ for c in range(3)
747
+ ]
748
+ processed = np.stack(channels_out, axis=2)
749
+ else:
750
+ gray = img.astype(np.float32) if img.ndim == 2 else img
751
+ processed = denoise_tv_chambolle(gray, weight=weight, max_num_iter=max_iter, channel_axis=None).astype(np.float32)
752
+
753
+ processed = np.clip(processed, 0.0, 1.0)
754
+ processed = processed * self.strength_slider.value() + (1 - self.strength_slider.value()) * img
755
+
756
+ else:
757
+ self._show_message("Unknown tab")
758
+ return
759
+
760
+ # Masked blend if an active mask exists
761
+ mask = self._get_active_mask_from_doc(processed.shape)
762
+ if mask is not None:
763
+ if processed.ndim == 3 and mask.ndim == 2:
764
+ mask = mask[..., None]
765
+ final_result = np.clip(processed * mask + self._original_image * (1.0 - mask), 0.0, 1.0)
766
+ else:
767
+ final_result = processed
768
+
769
+ self._preview_result = final_result
770
+ self._display_in_view(final_result)
771
+
772
+ def _on_undo(self):
773
+ if self._original_image is not None:
774
+ self._preview_result = None
775
+ self._display_in_view(self._original_image)
776
+ else:
777
+ self._show_message("Nothing to undo.")
778
+
779
+ def _build_replay_preset(self) -> dict | None:
780
+ """
781
+ Capture the current UI state as a preset-style dict so Replay Last Action
782
+ can re-run the same Convo/Deconvo/TV operation on another document.
783
+ Matches the schema used by ConvoPresetDialog.result_dict().
784
+ """
785
+ current_tab_name = self.tabs.tabText(self.tabs.currentIndex())
786
+ strength = float(self.strength_slider.value())
787
+
788
+ # ── Convolution tab ─────────────────────────────────────────────
789
+ if current_tab_name == "Convolution":
790
+ return {
791
+ "op": "convolution",
792
+ "radius": float(self.conv_radius_slider.value()),
793
+ "kurtosis": float(self.conv_shape_slider.value()),
794
+ "aspect": float(self.conv_aspect_slider.value()),
795
+ "rotation": float(self.conv_rotation_slider.value()),
796
+ "strength": strength,
797
+ }
798
+
799
+ # ── Deconvolution tab ───────────────────────────────────────────
800
+ if current_tab_name == "Deconvolution":
801
+ algo = self.deconv_algo_combo.currentText()
802
+ p: dict[str, object] = {
803
+ "op": "deconvolution",
804
+ "algo": algo,
805
+ # RL/Wiener PSF params
806
+ "psf_radius": float(self.rl_psf_radius_slider.value()),
807
+ "psf_kurtosis": float(self.rl_psf_shape_slider.value()),
808
+ "psf_aspect": float(self.rl_psf_aspect_slider.value()),
809
+ "psf_rotation": float(self.rl_psf_rotation_slider.value()),
810
+ # RL options
811
+ "rl_iter": float(self.rl_iterations_slider.value()),
812
+ "rl_reg": self.rl_reg_combo.currentText(),
813
+ "rl_dering": bool(self.rl_clip_checkbox.isChecked()),
814
+ "luminance_only": bool(self.rl_luminance_only_checkbox.isChecked()),
815
+ # Wiener options
816
+ "wiener_nsr": float(self.wiener_nsr_slider.value()),
817
+ "wiener_reg": self.wiener_reg_combo.currentText(),
818
+ "wiener_dering": bool(self.wiener_dering_checkbox.isChecked()),
819
+ # Larson–Sekanina options
820
+ "ls_rstep": float(self.ls_radial_slider.value()),
821
+ "ls_astep": float(self.ls_angular_slider.value()),
822
+ "ls_operator": self.ls_operator_combo.currentText(),
823
+ "ls_blend": self.ls_blend_combo.currentText(),
824
+ # Van Cittert options
825
+ "vc_iter": float(self.vc_iterations_slider.value()),
826
+ "vc_relax": float(self.vc_relax_slider.value()),
827
+ # Global blend strength
828
+ "strength": strength,
829
+ }
830
+
831
+ # If user actually picked an LS center, preserve it for replay.
832
+ # Interactive view stores (x,y). apply_convo_via_preset expects [cx, cy].
833
+ if hasattr(self.view, "ls_center") and self.view.ls_center is not None:
834
+ cx, cy = self.view.ls_center # (x, y)
835
+ p["center"] = [float(cx), float(cy)]
836
+
837
+ return p
838
+
839
+ # ── TV Denoise tab ──────────────────────────────────────────────
840
+ if current_tab_name == "TV Denoise":
841
+ return {
842
+ "op": "tv",
843
+ "tv_weight": float(self.tv_weight_slider.value()),
844
+ "tv_iter": int(round(float(self.tv_iter_slider.value()))),
845
+ "tv_multichannel": bool(self.tv_multichannel_checkbox.isChecked()),
846
+ "strength": strength,
847
+ }
848
+
849
+ return None
850
+
851
+
852
+ def _on_push_to_doc(self):
853
+ doc = self._active_doc()
854
+ if doc is None:
855
+ QMessageBox.warning(self, "No Document", "No active document to push into.")
856
+ return
857
+
858
+ if self._preview_result is None:
859
+ QMessageBox.warning(self, "No Preview", "No preview to push. Click Preview first.")
860
+ return
861
+
862
+ # Grab current metadata from this specific doc
863
+ _, meta = self._get_active_image_and_meta()
864
+ new_meta = dict(meta)
865
+ new_meta["source"] = "ConvoDeconvo"
866
+
867
+ try:
868
+ if hasattr(doc, "apply_edit"):
869
+ # ⭐ Preferred: update this exact Document (ROI or full) so all views update
870
+ doc.apply_edit(
871
+ self._preview_result.copy(),
872
+ metadata=new_meta,
873
+ step_name="Convo/Deconvo",
874
+ )
875
+ else:
876
+ # Fallback for older paths: go through DocManager active-doc API
877
+ if hasattr(self.doc_manager, "set_active_document"):
878
+ self.doc_manager.set_active_document(doc)
879
+ self.doc_manager.update_active_document(
880
+ self._preview_result.copy(),
881
+ metadata=new_meta,
882
+ step_name="Convo/Deconvo",
883
+ )
884
+ except Exception as e:
885
+ QMessageBox.critical(self, "Push failed", str(e))
886
+ return
887
+
888
+ # Make the pushed image the new baseline so you can iterate
889
+ img_after, _ = self._get_active_image_and_meta()
890
+ if img_after is not None:
891
+ self._original_image = img_after.copy()
892
+ self._preview_result = None
893
+ self._display_in_view(self._original_image)
894
+
895
+ # 🔴 Replay wiring (unchanged, just moved under try/except)
896
+ try:
897
+ if self._main is not None:
898
+ preset = self._build_replay_preset()
899
+ if preset:
900
+ self._main._last_headless_command = {
901
+ "cid": "convo",
902
+ "preset": preset,
903
+ }
904
+ if hasattr(self._main, "_log"):
905
+ op = preset.get("op", "convolution")
906
+ self._main._log(f"Replay: stored Convo/Deconvo ({op}) from dialog.")
907
+ except Exception:
908
+ # Replay wiring should never break the actual push
909
+ pass
910
+
911
+ QMessageBox.information(self, "Pushed", "Result committed to the active document.")
912
+
913
+
914
+
915
+ # ---------------- Utils ----------------
916
+ def _show_message(self, text: str):
917
+ self.scene.clear()
918
+ self.pixmap_item = QGraphicsPixmapItem()
919
+ self.scene.addItem(self.pixmap_item)
920
+ self.view.resetTransform()
921
+ temp_label = QLabel(text); temp_label.setStyleSheet("color: white; background-color: #222;"); temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
922
+ pixmap = temp_label.grab()
923
+ self.pixmap_item.setPixmap(pixmap)
924
+ self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
925
+
926
+ def _convolve_color(self, image: np.ndarray, psf_kernel: np.ndarray) -> np.ndarray:
927
+ """
928
+ Convolve image with psf_kernel using reflect padding so we don't get
929
+ dark borders from zero–padding. Returns same H×W (and channels) as input.
930
+ """
931
+ if image is None or psf_kernel is None:
932
+ return image
933
+
934
+ img = image.astype(np.float32, copy=False)
935
+ kh, kw = psf_kernel.shape
936
+ pad_y = kh // 2
937
+ pad_x = kw // 2
938
+
939
+ def _conv_single_channel(im2d: np.ndarray) -> np.ndarray:
940
+ if pad_y or pad_x:
941
+ padded = np.pad(
942
+ im2d,
943
+ ((pad_y, pad_y), (pad_x, pad_x)),
944
+ mode="reflect"
945
+ )
946
+ else:
947
+ padded = im2d
948
+
949
+ conv_full = fftconvolve(padded, psf_kernel, mode="same")
950
+
951
+ if pad_y or pad_x:
952
+ conv = conv_full[pad_y:-pad_y or None, pad_x:-pad_x or None]
953
+ else:
954
+ conv = conv_full
955
+
956
+ return conv.astype(np.float32)
957
+
958
+ if img.ndim == 2:
959
+ out = _conv_single_channel(img)
960
+ elif img.ndim == 3 and img.shape[2] == 3:
961
+ chans = [_conv_single_channel(img[:, :, c]) for c in range(3)]
962
+ out = np.stack(chans, axis=2)
963
+ else:
964
+ # Unknown layout; just return a copy to be safe
965
+ return img.copy()
966
+
967
+ # PSF is normalized, but clamp just in case of numeric noise
968
+ return np.clip(out, 0.0, 1.0)
969
+
970
+
971
+ def _richardson_lucy_color(self, image: np.ndarray, psf_kernel: np.ndarray, iterations: int,
972
+ reg_type: str = "None (Plain R–L)", clip_flag: bool = True) -> np.ndarray:
973
+ iters = int(round(iterations))
974
+ psf = psf_kernel.astype(np.float32)
975
+
976
+ def _deconv_2d_parallel(gray: np.ndarray) -> np.ndarray:
977
+ H, W = gray.shape
978
+ psf_h, psf_w = psf.shape
979
+ half_psf = max(psf_h, psf_w) // 2
980
+ extra = 15
981
+ pad = half_psf + extra
982
+ overlap = pad
983
+
984
+ n_cores = min((os.cpu_count() or 1), H)
985
+ tile_h = math.ceil(H / n_cores)
986
+ tile_ranges = []
987
+ for i in range(n_cores):
988
+ y0 = i * tile_h; y1 = min((i + 1) * tile_h, H)
989
+ if y0 >= H: break
990
+ tile_ranges.append((y0, y1))
991
+
992
+ accum_image = np.zeros((H, W), dtype=np.float32)
993
+ accum_weight = np.zeros((H, W), dtype=np.float32)
994
+
995
+ def _build_vertical_ramp(L: int, ov: int) -> np.ndarray:
996
+ w = np.ones(L, dtype=np.float32)
997
+ if ov <= 0: return w
998
+ if 2 * ov >= L:
999
+ for i in range(L):
1000
+ w[i] = 1.0 - abs((i - (L - 1) / 2) / ((L - 1) / 2))
1001
+ return w
1002
+ for i in range(ov):
1003
+ w[i] = (i + 1) / float(ov)
1004
+ w[L - 1 - i] = (i + 1) / float(ov)
1005
+ return w
1006
+
1007
+ tile_inputs = []
1008
+ for idx, (y0, y1) in enumerate(tile_ranges):
1009
+ y0_ext = max(0, y0 - overlap); y1_ext = min(H, y1 + overlap)
1010
+ core_tile = gray[y0_ext:y1_ext, :]
1011
+ padded = np.pad(core_tile, ((pad, pad), (pad, pad)), mode="reflect")
1012
+ L_ext = y1_ext - y0_ext
1013
+ tile_inputs.append((idx, padded, psf, iters, clip_flag, pad, reg_type, y0_ext, y1_ext, L_ext))
1014
+
1015
+ results = [None] * len(tile_inputs)
1016
+ max_workers = min(len(tile_inputs), os.cpu_count() or 1)
1017
+ if max_workers < 1:
1018
+ max_workers = 1
1019
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
1020
+ for tile_index, deconv_ext in executor.map(_rl_tile_process_reg, tile_inputs):
1021
+ results[tile_index] = deconv_ext
1022
+
1023
+ for idx, (y0, y1) in enumerate(tile_ranges):
1024
+ (_, _, _, _, _, _, _, y0_ext, y1_ext, L_ext) = tile_inputs[idx]
1025
+ deconv_ext = results[idx]
1026
+ w = _build_vertical_ramp(L_ext, overlap)
1027
+ w2d = np.broadcast_to(w[:, None], (L_ext, W)).astype(np.float32)
1028
+ accum_image[y0_ext:y1_ext, :] += deconv_ext * w2d
1029
+ accum_weight[y0_ext:y1_ext, :] += w2d
1030
+
1031
+ final_deconv = np.zeros_like(accum_image, dtype=np.float32)
1032
+ nz = accum_weight > 0
1033
+ final_deconv[nz] = accum_image[nz] / accum_weight[nz]
1034
+ return final_deconv
1035
+
1036
+ if image.ndim == 2:
1037
+ self.rl_status_label.setText(f"Running RL for {iters} iterations"); QApplication.processEvents()
1038
+ deconv = _deconv_2d_parallel(image.astype(np.float32))
1039
+ self.rl_status_label.setText(""); QApplication.processEvents()
1040
+ return np.clip(deconv, 0.0, 1.0)
1041
+ elif image.ndim == 3 and image.shape[2] == 3:
1042
+ outs = []
1043
+ for c in range(3):
1044
+ self.rl_status_label.setText(f"Running RL on ch {c+1} for {iters} iterations"); QApplication.processEvents()
1045
+ outs.append(np.clip(_deconv_2d_parallel(image[:, :, c].astype(np.float32)), 0.0, 1.0))
1046
+ self.rl_status_label.setText(""); QApplication.processEvents()
1047
+ return np.stack(outs, axis=2)
1048
+ else:
1049
+ return image.copy()
1050
+
1051
+ def _wiener_deconv_with_kernel(self, image: np.ndarray, small_psf: np.ndarray, nsr: float,
1052
+ reg_type: str, do_dering: bool) -> np.ndarray:
1053
+ def _deconv_gray(im2d: np.ndarray, do_dering_flag: bool) -> np.ndarray:
1054
+ H, W = im2d.shape
1055
+ psf_h, psf_w = small_psf.shape
1056
+ Hpsf = np.zeros((H, W), dtype=np.float32)
1057
+ cy, cx = H // 2, W // 2
1058
+ y0 = cy - psf_h // 2; x0 = cx - psf_w // 2
1059
+ Hpsf[y0:y0+psf_h, x0:x0+psf_w] = small_psf
1060
+ H_f = fft2(ifftshift(Hpsf)); H_f_conj = np.conj(H_f); mag2 = np.abs(H_f) ** 2
1061
+ K = nsr * nsr if reg_type == "Tikhonov" else nsr
1062
+ Wf = H_f_conj / (mag2 + K)
1063
+ deconv = np.real(ifft2(Wf * fft2(im2d))).astype(np.float32)
1064
+ if do_dering_flag:
1065
+ deconv = denoise_bilateral(deconv, sigma_color=0.08, sigma_spatial=1)
1066
+ return deconv.clip(0.0, 1.0)
1067
+
1068
+ if image.ndim == 2:
1069
+ return _deconv_gray(image.astype(np.float32), do_dering)
1070
+ elif image.ndim == 3 and image.shape[2] == 3:
1071
+ return np.stack([_deconv_gray(image[:, :, c].astype(np.float32), do_dering) for c in range(3)], axis=2)
1072
+ else:
1073
+ return image.copy()
1074
+
1075
+ def _display_in_view(self, array: np.ndarray):
1076
+ arr = array.copy()
1077
+ if arr.dtype in (np.float32, np.float64):
1078
+ arr = np.clip(arr, 0.0, 1.0); arr8 = (arr * 255).astype(np.uint8)
1079
+ elif arr.dtype == np.uint16:
1080
+ arr8 = (np.clip(arr, 0, 65535) // 257).astype(np.uint8)
1081
+ elif arr.dtype == np.uint8:
1082
+ arr8 = arr
1083
+ else:
1084
+ mn, mx = arr.min(), arr.max()
1085
+ arr8 = ((arr - mn) / (mx - mn) * 255).astype(np.uint8) if mx > mn else np.zeros_like(arr, dtype=np.uint8)
1086
+
1087
+ h, w = arr8.shape[:2]
1088
+ if arr8.ndim == 2:
1089
+ fmt = QImage.Format.Format_Grayscale8; bytespp = w
1090
+ else:
1091
+ fmt = QImage.Format.Format_RGB888; bytespp = 3 * w
1092
+
1093
+ qimg = QImage(arr8.data, w, h, bytespp, fmt)
1094
+ self.pixmap_item.setPixmap(QPixmap.fromImage(qimg))
1095
+ self.scene.setSceneRect(0, 0, w, h)
1096
+
1097
+ if self._auto_fit:
1098
+ self.view.resetTransform()
1099
+ self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio)
1100
+ self._auto_fit = False
1101
+
1102
+ def zoom_in(self): self.view.scale(1.2, 1.2)
1103
+ def zoom_out(self): self.view.scale(1/1.2, 1/1.2)
1104
+
1105
+ def _on_fit_clicked(self):
1106
+ self._auto_fit = True
1107
+ if self._preview_result is not None:
1108
+ self._display_in_view(self._preview_result)
1109
+ elif self._original_image is not None:
1110
+ self._display_in_view(self._original_image)
1111
+
1112
+ # ---------------- SEP PSF estimator ----------------
1113
+ def _on_run_sep(self):
1114
+ img, _ = self._get_active_image_and_meta()
1115
+ if img is None:
1116
+ QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
1117
+ return
1118
+ img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
1119
+
1120
+ sigma = self.sep_threshold_slider.value()
1121
+ minarea = self.sep_minarea_spin.value
1122
+ sat = self.sep_sat_slider.value()
1123
+ maxstars= self.sep_maxstars_spin.value
1124
+ half_w = self.sep_stamp_spin.value
1125
+
1126
+ try:
1127
+ psf_kernel = estimate_psf_from_image(
1128
+ image_array=img_gray,
1129
+ threshold_sigma=sigma,
1130
+ min_area=minarea,
1131
+ saturation_limit=sat,
1132
+ max_stars=maxstars,
1133
+ stamp_half_width=half_w
1134
+ )
1135
+ except RuntimeError as e:
1136
+ QMessageBox.critical(self, "PSF Error", str(e)); return
1137
+
1138
+ self._last_stellar_psf = psf_kernel
1139
+ self._show_stellar_psf_preview(psf_kernel)
1140
+
1141
+ def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
1142
+ h, w = psf_kernel.shape
1143
+ img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
1144
+ qimg = QImage(img8.data, w, h, w, QImage.Format.Format_Grayscale8)
1145
+ scaled = QPixmap.fromImage(qimg).scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
1146
+ final = QPixmap(64, 64); final.fill(Qt.GlobalColor.transparent)
1147
+ p = QPainter(final); p.drawPixmap((64 - scaled.width()) // 2, (64 - scaled.height()) // 2, scaled); p.end()
1148
+ self.sep_psf_preview.setPixmap(final)
1149
+
1150
+ def _on_use_stellar_psf(self):
1151
+ if self._last_stellar_psf is None:
1152
+ QMessageBox.warning(self, "No PSF", "Run SEP extraction first.")
1153
+ return
1154
+ self._custom_psf = self._last_stellar_psf.copy()
1155
+ self._use_custom_psf = True
1156
+ self.conv_psf_label.setPixmap(self._make_stellar_psf_pixmap(self._custom_psf))
1157
+ self.deconv_algo_combo.setCurrentText("Richardson-Lucy")
1158
+ self.rl_custom_label.setVisible(True)
1159
+ self.rl_disable_custom_btn.setVisible(True)
1160
+ self.custom_psf_bar.setVisible(True)
1161
+ QMessageBox.information(self, "PSF Selected", "Stellar PSF is now active for Richardson–Lucy.")
1162
+
1163
+ def _clear_custom_psf_flag(self, _=None):
1164
+ if self._use_custom_psf:
1165
+ self._use_custom_psf = False
1166
+ self._custom_psf = None
1167
+ self.rl_custom_label.setVisible(False)
1168
+ self.rl_disable_custom_btn.setVisible(False)
1169
+ self.custom_psf_bar.setVisible(False)
1170
+
1171
+ def _on_save_stellar_psf(self):
1172
+ if self._last_stellar_psf is None:
1173
+ QMessageBox.warning(self, "No PSF", "Run SEP extraction before saving.")
1174
+ return
1175
+
1176
+ path, _ = QFileDialog.getSaveFileName(
1177
+ self,
1178
+ "Save PSF as...",
1179
+ "",
1180
+ "TIFF (*.tif);;FITS (*.fits)"
1181
+ )
1182
+ if not path:
1183
+ return
1184
+
1185
+ ext = path.lower().split('.')[-1]
1186
+
1187
+ if ext == 'fits':
1188
+ fits.PrimaryHDU(self._last_stellar_psf.astype(np.float32)).writeto(path, overwrite=True)
1189
+
1190
+ elif ext in ('tif', 'tiff'):
1191
+ import tifffile
1192
+ tifffile.imwrite(path, self._last_stellar_psf.astype(np.float32))
1193
+
1194
+ else:
1195
+ QMessageBox.warning(self, "Invalid Extension", "Please choose .fits or .tif.")
1196
+ return
1197
+
1198
+ QMessageBox.information(self, "Saved", f"PSF saved to:\n{path}")
1199
+
1200
+
1201
+
1202
+ # ─────────────────────────────────────────────────────────────────────────────
1203
+ def estimate_psf_from_image(image_array: np.ndarray,
1204
+ threshold_sigma: float,
1205
+ min_area: int,
1206
+ saturation_limit: float,
1207
+ max_stars: int,
1208
+ stamp_half_width: int) -> np.ndarray:
1209
+ data = image_array.astype(np.float32)
1210
+ bkg = sep.Background(data)
1211
+ bkg_sub = data - bkg.back()
1212
+ sources = sep.extract(bkg_sub, thresh=threshold_sigma, err=bkg.globalrms, minarea=min_area)
1213
+ if len(sources) == 0:
1214
+ raise RuntimeError(f"No sources found with SEP threshold = {threshold_sigma:.1f} σ.")
1215
+
1216
+ valid_sources = [s for s in sources if s['peak'] < saturation_limit]
1217
+ if len(valid_sources) == 0:
1218
+ raise RuntimeError(f"All detected sources exceed saturation limit {int(saturation_limit)}.")
1219
+
1220
+ valid_sources.sort(key=lambda s: s['peak'], reverse=True)
1221
+ selected = valid_sources[:max_stars]
1222
+
1223
+ w = stamp_half_width
1224
+ ksize = 2*w + 1
1225
+ psf_sum = np.zeros((ksize, ksize), dtype=np.float32)
1226
+ count = 0
1227
+
1228
+ H, W = data.shape[:2]
1229
+ for src in selected:
1230
+ xi = int(round(src['x'])); yi = int(round(src['y']))
1231
+ y0, y1 = yi - w, yi + w + 1
1232
+ x0, x1 = xi - w, xi + w + 1
1233
+ if y0 < 0 or x0 < 0 or y1 > H or x1 > W:
1234
+ continue
1235
+ stamp = bkg_sub[y0:y1, x0:x1].astype(np.float32)
1236
+ total_flux = float(np.sum(stamp))
1237
+ if total_flux <= 0:
1238
+ continue
1239
+ psf_sum += (stamp / total_flux)
1240
+ count += 1
1241
+
1242
+ if count == 0:
1243
+ raise RuntimeError("No valid postage stamps extracted (all were off-edge or zero).")
1244
+
1245
+ psf_kernel = (psf_sum / count).astype(np.float32)
1246
+ total = float(psf_kernel.sum())
1247
+ if total > 0:
1248
+ psf_kernel /= total
1249
+ else:
1250
+ psf_kernel[:] = 0; psf_kernel[w, w] = 1.0
1251
+ return psf_kernel
1252
+
1253
+
1254
+ # ─────────────────────────────────────────────────────────────────────────────
1255
+ @lru_cache(maxsize=64)
1256
+ def make_elliptical_gaussian_psf(radius: float, kurtosis: float, aspect: float, rotation_deg: float) -> np.ndarray:
1257
+ """Generate elliptical Gaussian PSF kernel. Results are cached."""
1258
+ sigma_x = radius
1259
+ sigma_y = radius / max(aspect, 1e-8)
1260
+
1261
+ size = int(np.ceil(6 * sigma_x))
1262
+ size = size + 1 if size % 2 == 0 else size
1263
+ half = size // 2
1264
+
1265
+ xs = np.linspace(-half, half, size)
1266
+ ys = np.linspace(-half, half, size)
1267
+ xv, yv = np.meshgrid(xs, ys)
1268
+
1269
+ theta = np.deg2rad(rotation_deg)
1270
+ cos_t, sin_t = np.cos(theta), np.sin(theta)
1271
+ x_rot = cos_t * xv + sin_t * yv
1272
+ y_rot = -sin_t * xv + cos_t * yv
1273
+
1274
+ beta = kurtosis
1275
+ squared_sum = (x_rot / max(sigma_x, 1e-8))**2 + (y_rot / max(sigma_y, 1e-8))**2
1276
+ psf = np.exp(-(squared_sum ** beta))
1277
+ total = psf.sum()
1278
+ return (psf / total).astype(np.float32) if total != 0 else np.zeros_like(psf, dtype=np.float32)
1279
+
1280
+
1281
+ def _rl_tile_process_reg(tile_and_meta: Tuple[int, np.ndarray]) -> Tuple[int, np.ndarray]:
1282
+ (tile_index, padded_tile, psf, num_iter, clip_flag, pad, reg_type, y0_ext, y1_ext, L_ext) = tile_and_meta
1283
+ alpha_L2 = 0.01
1284
+ alpha_tv = 0.01
1285
+ f = np.clip(padded_tile.astype(np.float32), 1e-8, None)
1286
+ psf_flipped = psf[::-1, ::-1]
1287
+
1288
+ for _ in range(num_iter):
1289
+ estimate_blurred = fftconvolve(f, psf, mode="same")
1290
+ ratio = padded_tile / (estimate_blurred + 1e-8)
1291
+ correction = fftconvolve(ratio, psf_flipped, mode="same")
1292
+ f = f * correction
1293
+ if reg_type == "Tikhonov (L2)":
1294
+ f = f - alpha_L2 * laplace(f)
1295
+ elif reg_type == "Total Variation (TV)":
1296
+ f = denoise_tv_chambolle(f, weight=alpha_tv, channel_axis=None).astype(np.float32)
1297
+ f = np.clip(f, 0.0, 1.0)
1298
+
1299
+ if clip_flag:
1300
+ f = denoise_bilateral(f, sigma_color=0.08, sigma_spatial=1).astype(np.float32)
1301
+
1302
+ full_h, full_w = f.shape
1303
+ Wcore = full_w - 2 * pad
1304
+ deconv_core = f[pad: pad + L_ext, pad: pad + Wcore].astype(np.float32)
1305
+ return (tile_index, deconv_core)
1306
+
1307
+
1308
+ # ─────────────────────────────────────────────────────────────────────────────
1309
+ def van_cittert_deconv(image: np.ndarray, iterations: int, relaxation: float) -> np.ndarray:
1310
+ sigma = 3.0
1311
+ size = int(np.ceil(6 * sigma)); size = size + 1 if size % 2 == 0 else size
1312
+ xs = np.linspace(-size//2, size//2, size)
1313
+ kernel_1d = np.exp(-(xs**2) / (2*sigma**2)); kernel_1d = kernel_1d / kernel_1d.sum()
1314
+ psf = np.outer(kernel_1d, kernel_1d).astype(np.float32)
1315
+
1316
+ f = image.copy().astype(np.float32)
1317
+ for _ in range(iterations):
1318
+ conv = fftconvolve(f, psf, mode="same")
1319
+ f = f + relaxation * (image.astype(np.float32) - conv)
1320
+ return np.clip(f, 0.0, 1.0)
1321
+
1322
+
1323
+ def rotate_about_center(image: np.ndarray, angle_deg: float, center: Tuple[float, float]) -> np.ndarray:
1324
+ img_f = img_as_float32(image)
1325
+ H, W = img_f.shape[:2]
1326
+ y0, x0 = center
1327
+ theta = np.deg2rad(angle_deg)
1328
+ cos_t, sin_t = np.cos(theta), np.sin(theta)
1329
+ tx = x0 - ( x0 * cos_t - y0 * sin_t )
1330
+ ty = y0 - ( x0 * sin_t + y0 * cos_t )
1331
+ M3 = np.array([[ cos_t, -sin_t, tx ],
1332
+ [ sin_t, cos_t, ty ],
1333
+ [ 0.0 , 0.0 , 1.0 ]], dtype=np.float32)
1334
+ tform = AffineTransform(matrix=np.linalg.inv(M3))
1335
+ rotated = warp(img_f, inverse_map=tform, order=1, mode='constant', cval=0.0, preserve_range=True)
1336
+ return rotated.astype(np.float32)
1337
+
1338
+
1339
+ def _bilinear_interpolate_gray(gray: np.ndarray, y_coords: np.ndarray, x_coords: np.ndarray, cval: float = 0.0) -> np.ndarray:
1340
+ H, W = gray.shape
1341
+ x0 = np.floor(x_coords).astype(int); x1 = x0 + 1
1342
+ y0 = np.floor(y_coords).astype(int); y1 = y0 + 1
1343
+ dx = x_coords - x0; dy = y_coords - y0
1344
+ x0c = np.clip(x0, 0, W - 1); x1c = np.clip(x1, 0, W - 1)
1345
+ y0c = np.clip(y0, 0, H - 1); y1c = np.clip(y1, 0, H - 1)
1346
+ Ia = gray[y0c, x0c]; Ib = gray[y0c, x1c]; Ic = gray[y1c, x0c]; Id = gray[y1c, x1c]
1347
+ wa = (1 - dx) * (1 - dy); wb = dx * (1 - dy); wc = (1 - dx) * dy; wd = dx * dy
1348
+ interp = (Ia * wa) + (Ib * wb) + (Ic * wc) + (Id * wd)
1349
+ oob = (x_coords < 0) | (x_coords >= W) | (y_coords < 0) | (y_coords >= H)
1350
+ interp[oob] = cval
1351
+ return interp.astype(np.float32)
1352
+
1353
+
1354
+ def larson_sekanina(image: np.ndarray, center: Tuple[float, float], radial_step: Optional[float],
1355
+ angular_step_deg: float, operator: str = "Divide") -> np.ndarray:
1356
+ if image.dtype != np.float32:
1357
+ raise ValueError("larson_sekanina: input must be float32 in [0..1]")
1358
+ if image.ndim == 3 and image.shape[2] == 3:
1359
+ from skimage.color import rgb2gray
1360
+ gray = rgb2gray(image)
1361
+ else:
1362
+ gray = image
1363
+
1364
+ H, W = gray.shape
1365
+ y0, x0 = center
1366
+ dtheta = (angular_step_deg / 180.0) * np.pi
1367
+
1368
+ ys = np.arange(H, dtype=np.float32)[:, None]
1369
+ xs = np.arange(W, dtype=np.float32)[None, :]
1370
+ dy = np.broadcast_to(ys - y0, (H, W))
1371
+ dx = np.broadcast_to(xs - x0, (H, W))
1372
+ r = np.sqrt(dx*dx + dy*dy)
1373
+ theta = np.arctan2(dy, dx); theta[theta < 0] += 2*np.pi
1374
+
1375
+ r2 = r if (radial_step is None or radial_step <= 0) else (r + radial_step)
1376
+ theta2 = (theta + dtheta) % (2*np.pi)
1377
+
1378
+ x2 = x0 + r2 * np.cos(theta2)
1379
+ y2 = y0 + r2 * np.sin(theta2)
1380
+
1381
+ J = _bilinear_interpolate_gray(gray, y2.ravel(), x2.ravel(), cval=0.0).reshape(H, W)
1382
+
1383
+ if operator == "Divide":
1384
+ eps = 1e-6
1385
+ med = np.median(J) if np.median(J) > 0 else 1e-6
1386
+ B = np.clip(gray * (med / (J + eps)), 0.0, 1.0)
1387
+ else:
1388
+ diff = gray - J
1389
+ B = np.clip(diff, 0.0, None)
1390
+ maxv = B.max()
1391
+ B = (B / maxv) if maxv > 0 else np.zeros_like(B)
1392
+
1393
+ return B.astype(np.float32)
1394
+
1395
+
1396
+ # Optional helper to open like SFCC:
1397
+ def open_convo_deconvo(doc_manager, parent=None, doc=None) -> ConvoDeconvoDialog:
1398
+ dlg = ConvoDeconvoDialog(doc_manager=doc_manager, parent=parent, doc=doc)
1399
+ dlg.show()
1400
+ return dlg