setiastrosuitepro 1.6.7__py3-none-any.whl

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

Potentially problematic release.


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

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