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,1379 @@
1
+ # pro/abe.py — SASpro Automatic Background Extraction (ABE)
2
+ # -----------------------------------------------------------------------------
3
+ # This module migrates the SASv2 ABE functionality into SASpro with:
4
+ # • Polynomial background model (degree 1–6)
5
+ # • Optional RBF refinement stage (multiquadric) with smoothing
6
+ # • Smart sample-point generation (borders, corners, quartiles) with
7
+ # gradient-descent-to-dim-spot and bright-region avoidance
8
+ # • User-drawn exclusion polygons directly on the preview (image-space)
9
+ # • Non‑destructive preview, commit with undo, optional background doc
10
+ # • Mono and RGB float workflows (expects [0..1] float domain internally)
11
+ # -----------------------------------------------------------------------------
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import numpy as np
16
+
17
+ try:
18
+ import cv2
19
+ except Exception: # pragma: no cover
20
+ cv2 = None
21
+
22
+ from PyQt6.QtCore import Qt, QSize, QEvent, QPointF, QTimer
23
+ from PyQt6.QtWidgets import (
24
+ QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QLabel, QSpinBox,
25
+ QCheckBox, QPushButton, QScrollArea, QWidget, QMessageBox, QComboBox,
26
+ QGroupBox, QApplication
27
+ )
28
+ from PyQt6.QtGui import QImage, QPixmap, QPainter, QColor, QPen
29
+ from PyQt6 import sip
30
+
31
+ from scipy.interpolate import Rbf
32
+
33
+ from .doc_manager import ImageDocument
34
+ from setiastro.saspro.legacy.numba_utils import build_poly_terms, evaluate_polynomial
35
+ from .autostretch import autostretch as hard_autostretch
36
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
37
+
38
+ # =============================================================================
39
+ # Headless ABE Core (poly + RBF)
40
+ # =============================================================================
41
+
42
+ def _downsample_area(img: np.ndarray, scale: int) -> np.ndarray:
43
+ if scale <= 1:
44
+ return img
45
+ if cv2 is None:
46
+ return img[::scale, ::scale] if img.ndim == 2 else img[::scale, ::scale, :]
47
+ h, w = img.shape[:2]
48
+ return cv2.resize(img, (max(1, w // scale), max(1, h // scale)), interpolation=cv2.INTER_AREA)
49
+
50
+
51
+ def _upscale_bg(bg_small: np.ndarray, out_shape: tuple[int, int]) -> np.ndarray:
52
+ oh, ow = out_shape
53
+ if cv2 is None:
54
+ ys = (np.linspace(0, bg_small.shape[0] - 1, oh)).astype(int)
55
+ xs = (np.linspace(0, bg_small.shape[1] - 1, ow)).astype(int)
56
+ if bg_small.ndim == 2:
57
+ return bg_small[ys][:, xs]
58
+ return np.stack([bg_small[..., c][ys][:, xs] for c in range(bg_small.shape[2])], axis=-1)
59
+ if bg_small.ndim == 2:
60
+ return cv2.resize(bg_small, (ow, oh), interpolation=cv2.INTER_LANCZOS4).astype(np.float32)
61
+ return np.stack(
62
+ [cv2.resize(bg_small[..., c], (ow, oh), interpolation=cv2.INTER_LANCZOS4) for c in range(bg_small.shape[2])],
63
+ axis=-1
64
+ ).astype(np.float32)
65
+
66
+
67
+ def _fit_poly_on_small(small: np.ndarray, points: np.ndarray, degree: int, patch_size: int = 15) -> np.ndarray:
68
+ H, W = small.shape[:2]
69
+ half = patch_size // 2
70
+ pts = np.asarray(points, dtype=np.int32)
71
+ xs = np.clip(pts[:, 0], 0, W - 1)
72
+ ys = np.clip(pts[:, 1], 0, H - 1)
73
+
74
+ A = build_poly_terms(xs.astype(np.float32), ys.astype(np.float32), degree).astype(np.float32)
75
+
76
+ if small.ndim == 3 and small.shape[2] == 3:
77
+ bg_small = np.zeros_like(small, dtype=np.float32)
78
+
79
+ # Batch collect samples: (num_samples, 3)
80
+ # We need N samples. z will be list of (3,) arrays
81
+
82
+ # Pre-allocate Z: (N, 3)
83
+ Z = np.zeros((len(xs), 3), dtype=np.float32)
84
+
85
+ for k, (x, y) in enumerate(zip(xs, ys)):
86
+ x0, x1 = max(0, x - half), min(W, x + half + 1)
87
+ y0, y1 = max(0, y - half), min(H, y + half + 1)
88
+ # Efficiently compute median for all channels in this patch
89
+ patch = small[y0:y1, x0:x1, :]
90
+ Z[k] = np.median(patch, axis=(0, 1))
91
+
92
+ # Solve once: A is (N, terms), Z is (N, 3) -> coeffs is (terms, 3)
93
+ coeffs_all, *_ = np.linalg.lstsq(A, Z, rcond=None)
94
+
95
+ # Evaluate per channel
96
+ for c in range(3):
97
+ # coeffs_all[:, c] gives the terms for channel c
98
+ bg_small[..., c] = evaluate_polynomial(H, W, coeffs_all[:, c].astype(np.float32), degree)
99
+
100
+ return bg_small
101
+ else:
102
+ z = []
103
+ for x, y in zip(xs, ys):
104
+ x0, x1 = max(0, x - half), min(W, x + half + 1)
105
+ y0, y1 = max(0, y - half), min(H, y + half + 1)
106
+ z.append(np.median(small[y0:y1, x0:x1]))
107
+ z = np.asarray(z, dtype=np.float32)
108
+ coeffs, *_ = np.linalg.lstsq(A, z, rcond=None)
109
+ return evaluate_polynomial(H, W, coeffs.astype(np.float32), degree)
110
+
111
+
112
+ def _divide_into_quartiles(image: np.ndarray):
113
+ h, w = image.shape[:2]
114
+ hh, ww = h // 2, w // 2
115
+ return {
116
+ "top_left": (slice(0, hh), slice(0, ww), (0, 0)),
117
+ "top_right": (slice(0, hh), slice(ww, w), (ww, 0)),
118
+ "bottom_left": (slice(hh, h), slice(0, ww), (0, hh)),
119
+ "bottom_right": (slice(hh, h), slice(ww, w), (ww, hh)),
120
+ }
121
+
122
+
123
+ def _exclude_bright_regions(gray: np.ndarray, exclusion_fraction: float = 0.5) -> np.ndarray:
124
+ flat = gray.ravel()
125
+ thresh = np.percentile(flat, 100 * (1 - exclusion_fraction))
126
+ return (gray < thresh)
127
+
128
+
129
+ def _to_luminance(img: np.ndarray) -> np.ndarray:
130
+ if img.ndim == 2:
131
+ return img
132
+ return np.dot(img[..., :3], [0.2989, 0.5870, 0.1140]).astype(np.float32)
133
+
134
+
135
+ def _gradient_descent_to_dim_spot(image: np.ndarray, x: int, y: int, max_iter: int = 500, patch_size: int = 15) -> tuple[int, int]:
136
+ half = patch_size // 2
137
+ lum = _to_luminance(image)
138
+ H, W = lum.shape
139
+
140
+ def patch_median(px: int, py: int) -> float:
141
+ x0, x1 = max(0, px - half), min(W, px + half + 1)
142
+ y0, y1 = max(0, py - half), min(H, py + half + 1)
143
+ return float(np.median(lum[y0:y1, x0:x1]))
144
+
145
+ cx, cy = int(np.clip(x, 0, W - 1)), int(np.clip(y, 0, H - 1))
146
+ for _ in range(max_iter):
147
+ cur = patch_median(cx, cy)
148
+ xs = range(max(0, cx - 1), min(W, cx + 2))
149
+ ys = range(max(0, cy - 1), min(H, cy + 2))
150
+ best = (cx, cy); best_val = cur
151
+ for nx in xs:
152
+ for ny in ys:
153
+ if nx == cx and ny == cy:
154
+ continue
155
+ val = patch_median(nx, ny)
156
+ if val < best_val:
157
+ best_val = val; best = (nx, ny)
158
+ if best == (cx, cy):
159
+ break
160
+ cx, cy = best
161
+ return cx, cy
162
+
163
+
164
+ def _generate_sample_points(image: np.ndarray, num_points: int = 100, exclusion_mask: np.ndarray | None = None, patch_size: int = 15) -> np.ndarray:
165
+ H, W = image.shape[:2]
166
+ pts: list[tuple[int, int]] = []
167
+ border = 10
168
+
169
+ def allowed(x: int, y: int) -> bool:
170
+ if exclusion_mask is None:
171
+ return True
172
+ return bool(exclusion_mask[min(max(0, y), H-1), min(max(0, x), W-1)])
173
+
174
+ # corners
175
+ corners = [(border, border), (W - border - 1, border), (border, H - border - 1), (W - border - 1, H - border - 1)]
176
+ for x, y in corners:
177
+ if not allowed(x, y):
178
+ continue
179
+ nx, ny = _gradient_descent_to_dim_spot(image, x, y, patch_size=patch_size)
180
+ if allowed(nx, ny):
181
+ pts.append((nx, ny))
182
+
183
+ # borders
184
+ xs = np.linspace(border, W - border - 1, 5, dtype=int)
185
+ ys = np.linspace(border, H - border - 1, 5, dtype=int)
186
+ for x in xs:
187
+ if allowed(x, border):
188
+ nx, ny = _gradient_descent_to_dim_spot(image, x, border, patch_size=patch_size)
189
+ if allowed(nx, ny):
190
+ pts.append((nx, ny))
191
+ if allowed(x, H - border - 1):
192
+ nx, ny = _gradient_descent_to_dim_spot(image, x, H - border - 1, patch_size=patch_size)
193
+ if allowed(nx, ny):
194
+ pts.append((nx, ny))
195
+ for y in ys:
196
+ if allowed(border, y):
197
+ nx, ny = _gradient_descent_to_dim_spot(image, border, y, patch_size=patch_size)
198
+ if allowed(nx, ny):
199
+ pts.append((nx, ny))
200
+ if allowed(W - border - 1, y):
201
+ nx, ny = _gradient_descent_to_dim_spot(image, W - border - 1, y, patch_size=patch_size)
202
+ if allowed(nx, ny):
203
+ pts.append((nx, ny))
204
+
205
+ # quartiles with bright-region avoidance and descent
206
+ quarts = _divide_into_quartiles(image)
207
+ for _, (yslc, xslc, (x0, y0)) in quarts.items():
208
+ sub = image[yslc, xslc]
209
+ gray = _to_luminance(sub)
210
+ bright_mask = _exclude_bright_regions(gray, exclusion_fraction=0.5)
211
+ if exclusion_mask is not None:
212
+ bright_mask &= exclusion_mask[yslc, xslc]
213
+ elig = np.argwhere(bright_mask)
214
+ if elig.size == 0:
215
+ continue
216
+ k = min(len(elig), max(1, num_points // 4))
217
+ sel = elig[np.random.choice(len(elig), k, replace=False)]
218
+ for (yy, xx) in sel:
219
+ gx, gy = x0 + int(xx), y0 + int(yy)
220
+ nx, ny = _gradient_descent_to_dim_spot(image, gx, gy, patch_size=patch_size)
221
+ if allowed(nx, ny):
222
+ pts.append((nx, ny))
223
+
224
+ if len(pts) == 0:
225
+ # fallback grid
226
+ grid = int(np.sqrt(max(9, num_points)))
227
+ xs = np.linspace(border, W - border - 1, grid, dtype=int)
228
+ ys = np.linspace(border, H - border - 1, grid, dtype=int)
229
+ pts = [(x, y) for y in ys for x in xs if allowed(x, y)]
230
+ return np.array(pts, dtype=np.int32)
231
+
232
+
233
+ def _fit_rbf_on_small(small: np.ndarray, points: np.ndarray, smooth: float = 0.1, patch_size: int = 15) -> np.ndarray:
234
+ """Match SASv2 exactly: float64 for RBF inputs, multiquadric, epsilon=1.0."""
235
+ H, W = small.shape[:2]
236
+ half = patch_size // 2
237
+ pts = np.asarray(points, dtype=np.int32)
238
+ xs = np.clip(pts[:, 0], 0, W - 1).astype(np.int64)
239
+ ys = np.clip(pts[:, 1], 0, H - 1).astype(np.int64)
240
+
241
+ # Evaluate on a float64 meshgrid (same as SASv2)
242
+ grid_x, grid_y = np.meshgrid(
243
+ np.arange(W, dtype=np.float64),
244
+ np.arange(H, dtype=np.float64),
245
+ )
246
+
247
+ def _median_patch(arr, x, y):
248
+ x0, x1 = max(0, x - half), min(W, x + half + 1)
249
+ y0, y1 = max(0, y - half), min(H, y + half + 1)
250
+ return float(np.median(arr[y0:y1, x0:x1]))
251
+
252
+ if small.ndim == 3 and small.shape[2] == 3:
253
+ bg_small = np.zeros((H, W, 3), dtype=np.float32)
254
+ for c in range(3):
255
+ z = np.array([_median_patch(small[..., c], int(x), int(y)) for x, y in zip(xs, ys)], dtype=np.float64)
256
+ rbf = Rbf(xs.astype(np.float64), ys.astype(np.float64), z,
257
+ function='multiquadric', smooth=float(smooth), epsilon=1.0)
258
+ bg_small[..., c] = rbf(grid_x, grid_y).astype(np.float32)
259
+ return bg_small
260
+ else:
261
+ z = np.array([_median_patch(small, int(x), int(y)) for x, y in zip(xs, ys)], dtype=np.float64)
262
+ rbf = Rbf(xs.astype(np.float64), ys.astype(np.float64), z,
263
+ function='multiquadric', smooth=float(smooth), epsilon=1.0)
264
+ return rbf(grid_x, grid_y).astype(np.float32)
265
+
266
+ def _legacy_stretch_unlinked(image: np.ndarray):
267
+ """
268
+ SASv2 stretch domain used for modeling: per-channel min shift + unlinked rational
269
+ stretch to target median=0.25. Returns (stretched_rgb, state_dict).
270
+ """
271
+ was_single = False
272
+ img = image
273
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
274
+ was_single = True
275
+ img = np.stack([img[..., 0] if img.ndim == 3 else img] * 3, axis=-1)
276
+
277
+ img = img.astype(np.float32, copy=True)
278
+ target_median = 0.25
279
+
280
+ ch_mins: list[float] = []
281
+ ch_meds: list[float] = []
282
+ out = img.copy()
283
+
284
+ for c in range(3):
285
+ m0 = float(np.min(out[..., c]))
286
+ ch_mins.append(m0)
287
+ out[..., c] -= m0
288
+ med = float(np.median(out[..., c]))
289
+ ch_meds.append(med)
290
+ if med != 0.0:
291
+ num = (med - 1.0) * target_median * out[..., c]
292
+ den = (med * (target_median + out[..., c] - 1.0) - target_median * out[..., c])
293
+ den = np.where(den == 0.0, 1e-6, den)
294
+ out[..., c] = num / den
295
+
296
+ out = np.clip(out, 0.0, 1.0)
297
+ return out, {"mins": ch_mins, "meds": ch_meds, "was_single": was_single}
298
+
299
+
300
+ def _legacy_unstretch_unlinked(image: np.ndarray, state: dict):
301
+ """
302
+ Inverse of the SASv2 stretch above. Accepts mono or RGB; returns same ndim
303
+ as input, except if original was single-channel it returns mono.
304
+ """
305
+ mins = state["mins"]; meds = state["meds"]; was_single = state["was_single"]
306
+ img = image.astype(np.float32, copy=True)
307
+
308
+ # Work as RGB internally
309
+ if img.ndim == 2:
310
+ img = np.stack([img] * 3, axis=-1)
311
+ if img.ndim == 3 and img.shape[2] == 1:
312
+ img = np.repeat(img, 3, axis=2)
313
+
314
+ for c in range(3):
315
+ ch_med = float(np.median(img[..., c]))
316
+ orig_med = float(meds[c])
317
+ if ch_med != 0.0 and orig_med != 0.0:
318
+ num = (ch_med - 1.0) * orig_med * img[..., c]
319
+ den = (ch_med * (orig_med + img[..., c] - 1.0) - orig_med * img[..., c])
320
+ den = np.where(den == 0.0, 1e-6, den)
321
+ img[..., c] = num / den
322
+ img[..., c] += float(mins[c])
323
+
324
+ img = np.clip(img, 0.0, 1.0)
325
+ if was_single:
326
+ # original was mono → return mono
327
+ return img[..., 0]
328
+ return img
329
+
330
+
331
+ def abe_run(
332
+ image: np.ndarray,
333
+ degree: int = 2, # 0..6 (0 = skip polynomial)
334
+ num_samples: int = 100,
335
+ downsample: int = 4,
336
+ patch_size: int = 15,
337
+ use_rbf: bool = True,
338
+ rbf_smooth: float = 0.1, # numeric; UI can map 10 -> 0.10, 100 -> 1.0, etc.
339
+ exclusion_mask: np.ndarray | None = None,
340
+ return_background: bool = True,
341
+ progress_cb=None,
342
+ legacy_prestretch: bool = True, # <-- SASv2 parity switch
343
+ ) -> tuple[np.ndarray, np.ndarray] | np.ndarray:
344
+ """Two-stage ABE (poly + optional RBF) with SASv2-compatible pre/post stretch."""
345
+ if image is None:
346
+ raise ValueError("ABE: image is None")
347
+
348
+ img_src = np.asarray(image).astype(np.float32, copy=False)
349
+ mono = (img_src.ndim == 2) or (img_src.ndim == 3 and img_src.shape[2] == 1)
350
+
351
+ # Work in RGB internally (even for mono) so pre/post stretch matches SASv2 behavior
352
+ img_rgb = img_src if (img_src.ndim == 3 and img_src.shape[2] == 3) else np.stack(
353
+ [img_src.squeeze()] * 3, axis=-1
354
+ )
355
+
356
+ # --- SASv2 modeling domain (optional) ---------------------------------
357
+ stretch_state = None
358
+ if legacy_prestretch:
359
+ img_rgb, stretch_state = _legacy_stretch_unlinked(img_rgb)
360
+
361
+ # IMPORTANT: compute original median ONCE in the modeling domain
362
+ orig_med = float(np.median(img_rgb))
363
+
364
+ # downsample & mask (for fitting only)
365
+ if progress_cb: progress_cb("Downsampling image…")
366
+ small = _downsample_area(img_rgb, downsample)
367
+ mask_small = None
368
+ if exclusion_mask is not None:
369
+ if progress_cb: progress_cb("Downsampling exclusion mask…")
370
+ mask_small = _downsample_area(exclusion_mask.astype(np.float32), downsample) >= 0.5
371
+
372
+ # ---------- Polynomial stage (skip when degree == 0) ----------
373
+ if degree <= 0:
374
+ if progress_cb: progress_cb("Degree 0: skipping polynomial stage…")
375
+ after_poly = img_rgb.copy() # nothing removed yet
376
+ total_bg = np.zeros_like(img_rgb, dtype=np.float32)
377
+ else:
378
+ if progress_cb: progress_cb("Sampling points (poly stage)…")
379
+ pts = _generate_sample_points(small, num_points=num_samples,
380
+ exclusion_mask=mask_small, patch_size=patch_size)
381
+
382
+ if progress_cb: progress_cb(f"Fitting polynomial (degree {degree})…")
383
+ bg_poly_small = _fit_poly_on_small(small, pts, degree=degree, patch_size=patch_size)
384
+
385
+ if progress_cb: progress_cb("Upscaling polynomial background…")
386
+ bg_poly = _upscale_bg(bg_poly_small, img_rgb.shape[:2])
387
+
388
+ if progress_cb: progress_cb("Subtracting polynomial background & re-centering…")
389
+ after_poly = img_rgb - bg_poly
390
+ med_after = float(np.median(after_poly))
391
+ after_poly = np.clip(after_poly + (orig_med - med_after), 0.0, 1.0)
392
+
393
+ total_bg = bg_poly.astype(np.float32, copy=False)
394
+
395
+ # ---------- RBF refinement --------------------------------------------
396
+ if use_rbf:
397
+ if progress_cb: progress_cb("Downsampling for RBF stage…")
398
+ small_rbf = _downsample_area(after_poly, downsample)
399
+
400
+ if progress_cb: progress_cb("Sampling points (RBF stage)…")
401
+ pts_rbf = _generate_sample_points(small_rbf, num_points=num_samples,
402
+ exclusion_mask=mask_small, patch_size=patch_size)
403
+
404
+ if progress_cb: progress_cb(f"Fitting RBF (smooth={rbf_smooth:.3f})…")
405
+ bg_rbf_small = _fit_rbf_on_small(small_rbf, pts_rbf, smooth=rbf_smooth, patch_size=patch_size)
406
+
407
+ if progress_cb: progress_cb("Upscaling RBF background…")
408
+ bg_rbf = _upscale_bg(bg_rbf_small, img_rgb.shape[:2])
409
+
410
+ if progress_cb: progress_cb("Combining backgrounds & finalizing…")
411
+ total_bg = (total_bg + bg_rbf).astype(np.float32)
412
+ corrected = img_rgb - total_bg
413
+ med2 = float(np.median(corrected))
414
+ corrected = np.clip(corrected + (orig_med - med2), 0.0, 1.0)
415
+ else:
416
+ if progress_cb: progress_cb("Finalizing…")
417
+ corrected = after_poly
418
+
419
+ # --- Undo SASv2 modeling domain if used -------------------------------
420
+ if legacy_prestretch and stretch_state is not None:
421
+ if progress_cb: progress_cb("Unstretching to source domain…")
422
+ corrected = _legacy_unstretch_unlinked(corrected, stretch_state)
423
+ total_bg = _legacy_unstretch_unlinked(total_bg, stretch_state)
424
+
425
+ # Make sure types are float32
426
+ corrected = corrected.astype(np.float32, copy=False)
427
+ total_bg = total_bg.astype(np.float32, copy=False)
428
+
429
+ # If original was mono, squeeze to 2D
430
+ if mono:
431
+ if corrected.ndim == 3:
432
+ corrected = corrected[..., 0]
433
+ if total_bg.ndim == 3:
434
+ total_bg = total_bg[..., 0]
435
+ else:
436
+ # We stayed in RGB all along; if the source was mono, return mono
437
+ if mono:
438
+ corrected = corrected[..., 0]
439
+ total_bg = total_bg[..., 0]
440
+
441
+ if progress_cb: progress_cb("Ready")
442
+ if return_background:
443
+ return corrected.astype(np.float32, copy=False), total_bg.astype(np.float32, copy=False)
444
+ return corrected.astype(np.float32, copy=False)
445
+
446
+
447
+
448
+ def siril_style_autostretch(image: np.ndarray, sigma: float = 3.0) -> np.ndarray:
449
+ def stretch_channel(c):
450
+ med = np.median(c); mad = np.median(np.abs(c - med))
451
+ mad_std = mad * 1.4826
452
+ mn, mx = float(c.min()), float(c.max())
453
+ bp = max(mn, med - sigma * mad_std)
454
+ wp = min(mx, med + 0.5*sigma * mad_std)
455
+ if wp - bp <= 1e-8:
456
+ return np.zeros_like(c, dtype=np.float32)
457
+ out = (c - bp) / (wp - bp)
458
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
459
+
460
+ if image.ndim == 2:
461
+ return stretch_channel(image.astype(np.float32, copy=False))
462
+ if image.ndim == 3 and image.shape[2] == 3:
463
+ return np.stack([stretch_channel(image[..., i].astype(np.float32, copy=False))
464
+ for i in range(3)], axis=-1)
465
+ raise ValueError("Unsupported image format for autostretch.")
466
+
467
+
468
+
469
+
470
+
471
+ # =============================================================================
472
+ # UI Dialog
473
+ # =============================================================================
474
+
475
+ def _asfloat32(x: np.ndarray) -> np.ndarray:
476
+ a = np.asarray(x) # zero-copy view when possible
477
+ return a if a.dtype == np.float32 else a.astype(np.float32, copy=False)
478
+
479
+ class ABEDialog(QDialog):
480
+ """
481
+ Non-destructive preview with polygon exclusions and optional RBF stage.
482
+ Apply commits to the document image with undo. Optionally spawns a
483
+ background document containing the extracted gradient.
484
+ """
485
+ def __init__(self, parent, document: ImageDocument):
486
+ super().__init__(parent)
487
+ self.setWindowTitle(self.tr("Automatic Background Extraction (ABE)"))
488
+
489
+ # IMPORTANT: avoid “attached modal sheet” behavior on some Linux WMs
490
+ self.setWindowFlag(Qt.WindowType.Window, True)
491
+ # Non-modal: allow user to switch between images while dialog is open
492
+ self.setWindowModality(Qt.WindowModality.NonModal)
493
+ self.setModal(False)
494
+ try:
495
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
496
+ except Exception:
497
+ pass # older PyQt6 versions
498
+
499
+ self._main = parent
500
+ self.doc = document
501
+
502
+ self._connected_current_doc_changed = False
503
+ if hasattr(self._main, "currentDocumentChanged"):
504
+ try:
505
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
506
+ self._connected_current_doc_changed = True
507
+ except Exception:
508
+ self._connected_current_doc_changed = False
509
+
510
+ self._preview_scale = 1.0
511
+ self._preview_qimg = None
512
+ self._last_preview = None # backing ndarray for QImage lifetime
513
+ self._overlay = None
514
+
515
+
516
+ # image-space polygons: list[list[QPointF]] in ORIGINAL IMAGE COORDS
517
+ self._polygons: list[list[QPointF]] = []
518
+ self._drawing_poly: list[QPointF] | None = None
519
+ self._panning = False
520
+ self._pan_last = None
521
+ self._preview_source_f01 = None
522
+
523
+ # ---------------- Controls ----------------
524
+ self.sp_degree = QSpinBox(); self.sp_degree.setRange(0, 6); self.sp_degree.setValue(2)
525
+ self.sp_samples = QSpinBox(); self.sp_samples.setRange(20, 10000); self.sp_samples.setSingleStep(20); self.sp_samples.setValue(120)
526
+ self.sp_down = QSpinBox(); self.sp_down.setRange(1, 32); self.sp_down.setValue(4)
527
+ self.sp_patch = QSpinBox(); self.sp_patch.setRange(5, 151); self.sp_patch.setSingleStep(2); self.sp_patch.setValue(15)
528
+ self.chk_use_rbf = QCheckBox(self.tr("Enable RBF refinement (after polynomial)")); self.chk_use_rbf.setChecked(True)
529
+ self.sp_rbf = QSpinBox(); self.sp_rbf.setRange(0, 1000); self.sp_rbf.setValue(100) # shown as ×0.01 below
530
+ self.chk_make_bg_doc = QCheckBox(self.tr("Create background document")); self.chk_make_bg_doc.setChecked(False)
531
+ self.chk_preview_bg = QCheckBox(self.tr("Preview background instead of corrected")); self.chk_preview_bg.setChecked(False)
532
+
533
+ # Preview area
534
+ self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
535
+ self.preview_label.setMinimumSize(QSize(480, 360))
536
+ self.preview_label.setScaledContents(False)
537
+ self.preview_scroll = QScrollArea()
538
+ self.preview_scroll.setWidgetResizable(False)
539
+ self.preview_scroll.setWidget(self.preview_label)
540
+ self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
541
+ self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
542
+
543
+ # Buttons
544
+ self.btn_preview = QPushButton(self.tr("Preview"))
545
+ self.btn_apply = QPushButton(self.tr("Apply"))
546
+ self.btn_close = QPushButton(self.tr("Close"))
547
+ self.btn_clear = QPushButton(self.tr("Clear Exclusions"))
548
+ self.btn_preview.clicked.connect(self._do_preview)
549
+ self.btn_apply.clicked.connect(self._do_apply)
550
+ self.btn_close.clicked.connect(self.close)
551
+ self.btn_clear.clicked.connect(self._clear_polys)
552
+
553
+ # Layout
554
+ params = QFormLayout()
555
+ params.addRow(self.tr("Polynomial degree:"), self.sp_degree)
556
+ params.addRow(self.tr("# sample points:"), self.sp_samples)
557
+ params.addRow(self.tr("Downsample factor:"), self.sp_down)
558
+ params.addRow(self.tr("Patch size (px):"), self.sp_patch)
559
+
560
+ rbf_box = QGroupBox(self.tr("RBF Refinement"))
561
+ rbf_form = QFormLayout()
562
+ rbf_form.addRow(self.chk_use_rbf)
563
+ rbf_form.addRow(self.tr("Smooth (x0.01):"), self.sp_rbf)
564
+ rbf_box.setLayout(rbf_form)
565
+
566
+ opts = QVBoxLayout()
567
+ opts.addLayout(params)
568
+ opts.addWidget(rbf_box)
569
+ opts.addWidget(self.chk_make_bg_doc)
570
+ opts.addWidget(self.chk_preview_bg)
571
+ row = QHBoxLayout(); row.addWidget(self.btn_preview); row.addWidget(self.btn_apply); row.addStretch(1)
572
+ opts.addLayout(row)
573
+ opts.addWidget(self.btn_clear)
574
+ opts.addStretch(1)
575
+
576
+ # ▼ New status label
577
+ self.status_label = QLabel("Ready")
578
+ self.status_label.setWordWrap(True)
579
+ opts.addWidget(self.status_label)
580
+
581
+ opts.addStretch(1)
582
+
583
+ # ⬇️ New right-side stack: toolbar row ABOVE the preview
584
+ right = QVBoxLayout()
585
+ right.addLayout(self._build_toolbar()) # Zoom In / Out / Fit / Autostretch
586
+ right.addWidget(self.preview_scroll, 1) # Preview below the buttons
587
+
588
+ main = QHBoxLayout(self)
589
+ main.addLayout(opts, 0) # Left controls
590
+ main.addLayout(right, 1) # Right: buttons above preview
591
+
592
+ self._base_pixmap = None # clean, scaled image with no overlays
593
+ self.preview_scroll.viewport().installEventFilter(self)
594
+ self.preview_label.installEventFilter(self)
595
+ self._install_zoom_filters()
596
+ self._populate_initial_preview()
597
+ self.sp_degree.valueChanged.connect(self._degree_changed)
598
+
599
+ QTimer.singleShot(0, self._post_init_fit_and_stretch)
600
+
601
+ def _post_init_fit_and_stretch(self) -> None:
602
+ # Only run if we have an image preview
603
+ if self._preview_qimg is None:
604
+ return
605
+ # Fit to the viewport
606
+ self.fit_to_preview()
607
+ # Turn autostretch ON if it's not already
608
+ if not getattr(self, "_autostretch_on", False):
609
+ self.autostretch_preview()
610
+
611
+ def _set_status(self, text: str) -> None:
612
+ self.status_label.setText(text)
613
+ QApplication.processEvents()
614
+
615
+ def _build_toolbar(self):
616
+ """
617
+ Returns a QHBoxLayout with: Zoom In, Zoom Out, Fit, Autostretch.
618
+ Call: opts.addLayout(self._build_toolbar()) in __init__.
619
+ """
620
+ bar = QHBoxLayout()
621
+
622
+ # QToolButtons with theme icons
623
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
624
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
625
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
626
+ self.btn_autostr = themed_toolbtn("color-picker", "Autostretch") # pick your preferred icon
627
+
628
+ self.btn_zoom_in.clicked.connect(self.zoom_in)
629
+ self.btn_zoom_out.clicked.connect(self.zoom_out)
630
+ self.btn_fit.clicked.connect(self.fit_to_preview)
631
+ self.btn_autostr.clicked.connect(self.autostretch_preview)
632
+
633
+ bar.addWidget(self.btn_zoom_in)
634
+ bar.addWidget(self.btn_zoom_out)
635
+ bar.addWidget(self.btn_fit)
636
+ bar.addStretch(1)
637
+ bar.addWidget(self.btn_autostr)
638
+ return bar
639
+
640
+ # ----- active document change -----
641
+ def _on_active_doc_changed(self, doc):
642
+ """Called when user clicks a different image window."""
643
+ if doc is None or getattr(doc, "image", None) is None:
644
+ return
645
+ self.doc = doc
646
+ self._polygons.clear()
647
+ self._drawing_poly = None
648
+ self._preview_source_f01 = None
649
+ self._populate_initial_preview()
650
+
651
+ # ----- data helpers -----
652
+ def _get_source_float(self) -> np.ndarray | None:
653
+ src = np.asarray(self.doc.image)
654
+ if src is None or src.size == 0:
655
+ return None
656
+ if np.issubdtype(src.dtype, np.integer):
657
+ scale = float(np.iinfo(src.dtype).max)
658
+ return (src.astype(np.float32) / scale).clip(0.0, 1.0)
659
+ # float path: do NOT normalize; just clip to [0,1] like Crop does upstream
660
+ return np.clip(src.astype(np.float32, copy=False), 0.0, 1.0)
661
+
662
+ # ----- preview/applier -----
663
+ def _run_abe(self, excl_mask: np.ndarray | None, progress=None):
664
+ imgf = self._get_source_float()
665
+ if imgf is None:
666
+ return None, None
667
+ deg = int(self.sp_degree.value())
668
+ npts = int(self.sp_samples.value())
669
+ dwn = int(self.sp_down.value())
670
+ patch = int(self.sp_patch.value())
671
+ use_rbf = bool(self.chk_use_rbf.isChecked())
672
+ rbf_smooth = float(self.sp_rbf.value()) * 0.01
673
+
674
+ return abe_run(
675
+ imgf,
676
+ degree=deg, num_samples=npts, downsample=dwn, patch_size=patch,
677
+ use_rbf=use_rbf, rbf_smooth=rbf_smooth,
678
+ exclusion_mask=excl_mask, return_background=True,
679
+ progress_cb=progress # ◀️ forward progress
680
+ )
681
+
682
+ def _degree_changed(self, v: int):
683
+ # Make it clear what 0 means, and default RBF on (can still be unchecked)
684
+ if v == 0:
685
+ self.chk_use_rbf.setChecked(True)
686
+ if hasattr(self, "_set_status"):
687
+ self._set_status("Polynomial disabled (degree 0) → RBF-only.")
688
+ else:
689
+ if hasattr(self, "_set_status"):
690
+ self._set_status("Ready")
691
+
692
+ def _populate_initial_preview(self):
693
+ src = self._get_source_float()
694
+ if src is not None:
695
+ self._set_preview_pixmap(np.clip(src, 0, 1))
696
+
697
+ def _do_preview(self):
698
+ try:
699
+ self._set_status("Building exclusion mask…")
700
+ excl = self._build_exclusion_mask()
701
+
702
+ self._set_status("Running ABE preview…")
703
+ corrected, bg = self._run_abe(excl, progress=self._set_status)
704
+ if corrected is None:
705
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
706
+ self._set_status("Ready")
707
+ return
708
+
709
+ show = bg if self.chk_preview_bg.isChecked() else corrected
710
+
711
+ # ✅ If previewing the corrected image, honor the active mask
712
+ if not self.chk_preview_bg.isChecked():
713
+ srcf = self._get_source_float()
714
+ show = self._blend_with_mask_float(show, srcf)
715
+
716
+ self._set_status("Rendering preview…")
717
+ self._set_preview_pixmap(show)
718
+ self._set_status("Ready")
719
+ except Exception as e:
720
+ self._set_status("Error")
721
+ QMessageBox.warning(self, "Preview failed", str(e))
722
+
723
+ def _do_apply(self):
724
+ try:
725
+ self._set_status("Building exclusion mask…")
726
+ excl = self._build_exclusion_mask()
727
+
728
+ self._set_status("Running ABE (apply)…")
729
+ corrected, bg = self._run_abe(excl, progress=self._set_status)
730
+ if corrected is None:
731
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
732
+ self._set_status("Ready")
733
+ return
734
+
735
+ # Preserve mono vs color shape w.r.t. source
736
+ out = corrected
737
+ if out.ndim == 3 and out.shape[2] == 3 and (self.doc.image.ndim == 2 or (self.doc.image.ndim == 3 and self.doc.image.shape[2] == 1)):
738
+ out = out[..., 0]
739
+
740
+ # ✅ Blend with active mask before committing
741
+ srcf = self._get_source_float()
742
+ out_masked = self._blend_with_mask_float(out, srcf)
743
+
744
+ # Build step name for undo stack
745
+ # Build step name + params for undo stack + Replay
746
+ deg = int(self.sp_degree.value())
747
+ npts = int(self.sp_samples.value())
748
+ dwn = int(self.sp_down.value())
749
+ patch = int(self.sp_patch.value())
750
+ use_rbf = bool(self.chk_use_rbf.isChecked())
751
+ rbf_smooth = float(self.sp_rbf.value()) * 0.01
752
+ make_bg_doc = bool(self.chk_make_bg_doc.isChecked())
753
+
754
+ step_name = (
755
+ f"ABE (deg={deg}, samples={npts}, ds={dwn}, patch={patch}, "
756
+ f"rbf={'on' if use_rbf else 'off'}, s={rbf_smooth:.3f})"
757
+ )
758
+
759
+ # Normalized preset params (same schema as abe_preset.apply_abe_via_preset)
760
+ params = {
761
+ "degree": deg,
762
+ "samples": npts,
763
+ "downsample": dwn,
764
+ "patch": patch,
765
+ "rbf": use_rbf,
766
+ "rbf_smooth": rbf_smooth,
767
+ "make_background_doc": make_bg_doc,
768
+ }
769
+
770
+ # 🔁 Remember this as the last headless-style command for Replay
771
+ mw = self.parent()
772
+ try:
773
+ remember = getattr(mw, "remember_last_headless_command", None)
774
+ if remember is None:
775
+ remember = getattr(mw, "_remember_last_headless_command", None)
776
+ if callable(remember):
777
+ remember("abe", params, description="Automatic Background Extraction")
778
+ try:
779
+ if hasattr(mw, "_log"):
780
+ mw._log(
781
+ f"[Replay] ABE UI apply stored: "
782
+ f"command_id='abe', preset_keys={list(params.keys())}"
783
+ )
784
+ except Exception:
785
+ pass
786
+ except Exception:
787
+ # don’t block the actual ABE apply if remembering fails
788
+ pass
789
+
790
+ # ✅ mask bookkeeping in metadata
791
+ _marr, mid, mname = self._active_mask_layer()
792
+ abe_meta = dict(params)
793
+ abe_meta["exclusion"] = "polygons" if excl is not None else "none"
794
+
795
+ meta = {
796
+ "step_name": "ABE",
797
+ "abe": abe_meta,
798
+ "masked": bool(mid),
799
+ "mask_id": mid,
800
+ "mask_name": mname,
801
+ "mask_blend": "m*out + (1-m)*src",
802
+ }
803
+
804
+ self._set_status("Committing edit…")
805
+ self.doc.apply_edit(
806
+ out_masked.astype(np.float32, copy=False),
807
+ step_name=step_name,
808
+ metadata=meta,
809
+ )
810
+
811
+
812
+ if self.chk_make_bg_doc.isChecked() and bg is not None:
813
+ self._set_status("Creating background document…")
814
+ mw = self.parent()
815
+ dm = getattr(mw, "docman", None)
816
+ if dm is not None:
817
+ base = os.path.splitext(self.doc.display_name())[0]
818
+ meta = {
819
+ "bit_depth": "32-bit floating point",
820
+ "is_mono": (bg.ndim == 2),
821
+ "source": "ABE background",
822
+ "original_header": self.doc.metadata.get("original_header"),
823
+ }
824
+ doc_bg = dm.open_array(bg.astype(np.float32, copy=False), metadata=meta, title=f"{base}_ABE_BG")
825
+ if hasattr(mw, "_spawn_subwindow_for"):
826
+ mw._spawn_subwindow_for(doc_bg)
827
+
828
+ # Preserve the current view's autostretch state: capture before/restore after
829
+ mw = self.parent()
830
+ prev_autostretch = False
831
+ view = None
832
+ try:
833
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
834
+ view = mw.mdi.activeSubWindow().widget()
835
+ prev_autostretch = bool(getattr(view, "autostretch_enabled", False))
836
+ except Exception:
837
+ prev_autostretch = False
838
+
839
+
840
+ if hasattr(mw, "_log"):
841
+ mw._log(step_name)
842
+
843
+ # Restore autostretch state on the view (recompute display) so the
844
+ # user's display-stretch choice survives the edit.
845
+ try:
846
+ if view is not None and hasattr(view, "set_autostretch") and callable(view.set_autostretch):
847
+ view.set_autostretch(prev_autostretch)
848
+ except Exception:
849
+ pass
850
+
851
+ self._set_status("Done")
852
+ # Dialog stays open so user can apply to other images
853
+ # Refresh to use the now-active document for next operation
854
+ self.close()
855
+ return
856
+
857
+ except Exception as e:
858
+ self._set_status("Error")
859
+ QMessageBox.critical(self, "Apply failed", str(e))
860
+
861
+ def closeEvent(self, ev):
862
+ # 1) Disconnect active-doc tracking (Fabio hook)
863
+ try:
864
+ if self._connected_current_doc_changed and hasattr(self._main, "currentDocumentChanged"):
865
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
866
+ except Exception:
867
+ pass
868
+ self._connected_current_doc_changed = False
869
+
870
+ # 2) Stop any background preview worker/thread if you have one
871
+ # (names may differ in your file; keep what matches your implementation)
872
+ try:
873
+ if getattr(self, "_worker", None) is not None:
874
+ try:
875
+ self._worker.requestInterruption()
876
+ except Exception:
877
+ pass
878
+ if getattr(self, "_thread", None) is not None:
879
+ self._thread.quit()
880
+ self._thread.wait(500)
881
+ except Exception:
882
+ pass
883
+
884
+ super().closeEvent(ev)
885
+
886
+ def _refresh_document_from_active(self):
887
+ """
888
+ Refresh the dialog's document reference to the currently active document.
889
+ This allows reusing the same dialog on different images.
890
+ """
891
+ try:
892
+ main = self.parent()
893
+ if main and hasattr(main, "_active_doc"):
894
+ new_doc = main._active_doc()
895
+ if new_doc is not None and new_doc is not self.doc:
896
+ self.doc = new_doc
897
+ # Reset preview state for new document
898
+ self._preview_source_f01 = None
899
+ self._last_preview = None
900
+ self._preview_qimg = None
901
+ # Clear polygons since they were for old image
902
+ self._clear_polys()
903
+ except Exception:
904
+ pass
905
+
906
+
907
+ # ----- exclusion polygons & mask -----
908
+ def _clear_polys(self):
909
+ self._polygons.clear()
910
+ self._drawing_poly = None
911
+ # ✅ redraw from the clean base
912
+ self._redraw_overlay()
913
+
914
+ def _image_shape(self) -> tuple[int, int]:
915
+ src = np.asarray(self.doc.image)
916
+ if src.ndim == 2:
917
+ return src.shape[0], src.shape[1]
918
+ return src.shape[0], src.shape[1]
919
+
920
+ def _build_exclusion_mask(self) -> np.ndarray | None:
921
+ if not self._polygons:
922
+ return None
923
+ H, W = self._image_shape()
924
+ mask = np.ones((H, W), dtype=np.uint8)
925
+ if cv2 is None:
926
+ # very slow pure-numpy fallback: fill polygon by bounding-box rasterization
927
+ # (expect OpenCV to be available in SASpro)
928
+ for poly in self._polygons:
929
+ pts = np.array([[int(p.x()), int(p.y())] for p in poly], dtype=np.int32)
930
+ minx, maxx = np.clip([pts[:,0].min(), pts[:,0].max()], 0, W-1)
931
+ miny, maxy = np.clip([pts[:,1].min(), pts[:,1].max()], 0, H-1)
932
+ for y in range(miny, maxy+1):
933
+ for x in range(minx, maxx+1):
934
+ # winding test approx omitted -> treat as box (coarse)
935
+ mask[y, x] = 0
936
+ else:
937
+ polys = [np.array([[int(p.x()), int(p.y())] for p in poly], dtype=np.int32) for poly in self._polygons]
938
+ cv2.fillPoly(mask, polys, 0) # 0 = excluded
939
+ return mask.astype(bool)
940
+
941
+ # ----- preview rendering helpers -----
942
+
943
+ def _set_preview_pixmap(self, arr: np.ndarray):
944
+ if arr is None or arr.size == 0:
945
+ self.preview_label.clear(); self._overlay = None; self._preview_source_f01 = None
946
+ return
947
+
948
+ # keep the float source for autostretch toggling (no re-normalization)
949
+ a = _asfloat32(arr)
950
+ self._preview_source_f01 = a # ← no np.clip here
951
+
952
+ # show autostretched or raw; siril_style_autostretch() already clips its result
953
+ src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
954
+ linked=False, use_16bit=True)
955
+ if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
956
+
957
+ if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
958
+ # MONO path — match Crop: use Grayscale8 QImage; keep 3-ch backing for rebuild
959
+ mono = src_to_show if src_to_show.ndim == 2 else src_to_show[..., 0]
960
+ buf8_mono = (mono * 255.0).astype(np.uint8) # ← no np.clip here
961
+ buf8_mono = np.ascontiguousarray(buf8_mono)
962
+ h, w = buf8_mono.shape
963
+
964
+ # for the toggle/rebuild code which expects 3-ch bytes
965
+ self._last_preview = np.ascontiguousarray(np.stack([buf8_mono]*3, axis=-1))
966
+
967
+ qimg = QImage(buf8_mono.data, w, h, w, QImage.Format.Format_Grayscale8)
968
+ else:
969
+ # RGB path
970
+ buf8 = (src_to_show * 255.0).astype(np.uint8) # ← no np.clip here
971
+ buf8 = np.ascontiguousarray(buf8)
972
+ h, w, _ = buf8.shape
973
+ self._last_preview = buf8
974
+ qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
975
+
976
+ self._preview_qimg = qimg
977
+ self._update_preview_scaled()
978
+ self._redraw_overlay()
979
+
980
+
981
+ def _update_preview_scaled(self):
982
+ if self._preview_qimg is None:
983
+ self.preview_label.clear()
984
+ return
985
+
986
+ sw = max(1, int(self._preview_qimg.width() * self._preview_scale))
987
+ sh = max(1, int(self._preview_qimg.height() * self._preview_scale))
988
+
989
+ scaled = self._preview_qimg.scaled(
990
+ sw, sh,
991
+ Qt.AspectRatioMode.KeepAspectRatio,
992
+ Qt.TransformationMode.SmoothTransformation
993
+ )
994
+
995
+ # ✅ store a clean base without overlays
996
+ self._base_pixmap = QPixmap.fromImage(scaled)
997
+ self.preview_label.setPixmap(self._base_pixmap)
998
+ self.preview_label.resize(self._base_pixmap.size())
999
+
1000
+ def _redraw_overlay(self):
1001
+ pm_base = self._base_pixmap or self.preview_label.pixmap()
1002
+ if pm_base is None:
1003
+ return
1004
+
1005
+ # start from a fresh copy of the clean base
1006
+ composed = QPixmap(pm_base)
1007
+ overlay = QPixmap(pm_base.size())
1008
+ overlay.fill(Qt.GlobalColor.transparent)
1009
+
1010
+ painter = QPainter(overlay)
1011
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
1012
+
1013
+ # map image-space polys to label-space
1014
+ img_w = self._preview_qimg.width() if self._preview_qimg else 1
1015
+ img_h = self._preview_qimg.height() if self._preview_qimg else 1
1016
+ lab_w = self.preview_label.width()
1017
+ lab_h = self.preview_label.height()
1018
+ sx = lab_w / img_w
1019
+ sy = lab_h / img_h
1020
+
1021
+ # finalized polygons (green, semi-transparent)
1022
+ pen = QPen(QColor(0, 255, 0), 2)
1023
+ brush = QColor(0, 255, 0, 60)
1024
+ painter.setPen(pen)
1025
+ painter.setBrush(brush)
1026
+ for poly in self._polygons:
1027
+ if len(poly) >= 3:
1028
+ mapped = [QPointF(p.x() * sx, p.y() * sy) for p in poly]
1029
+ painter.drawPolygon(*mapped)
1030
+
1031
+ # in-progress poly (red dashed)
1032
+ if self._drawing_poly and len(self._drawing_poly) >= 2:
1033
+ pen2 = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
1034
+ painter.setPen(pen2)
1035
+ painter.setBrush(Qt.BrushStyle.NoBrush)
1036
+ mapped = [QPointF(p.x() * sx, p.y() * sy) for p in self._drawing_poly]
1037
+ painter.drawPolyline(*mapped)
1038
+
1039
+ painter.end()
1040
+
1041
+ p = QPainter(composed)
1042
+ p.drawPixmap(0, 0, overlay)
1043
+ p.end()
1044
+
1045
+ self.preview_label.setPixmap(composed)
1046
+
1047
+ # ----- zoom/pan + polygon drawing -----
1048
+ def eventFilter(self, obj, ev):
1049
+ # ---- Robust Ctrl+Wheel zoom handling (Qt6-friendly) ----
1050
+ if ev.type() == QEvent.Type.Wheel and (
1051
+ obj is self.preview_label
1052
+ or obj is self.preview_scroll
1053
+ or obj is self.preview_scroll.viewport()
1054
+ or obj is self.preview_scroll.horizontalScrollBar()
1055
+ or obj is self.preview_scroll.verticalScrollBar()
1056
+ ):
1057
+ # always stop the wheel from scrolling
1058
+ ev.accept()
1059
+
1060
+ # Zoom only when Ctrl is held
1061
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1062
+ factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
1063
+
1064
+ # Anchor at the mouse position in the viewport (even if event came from a scrollbar)
1065
+ vp = self.preview_scroll.viewport()
1066
+ anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
1067
+
1068
+ # Clamp to viewport rect (robust if the event originated on scrollbars)
1069
+ r = vp.rect()
1070
+ if not r.contains(anchor_vp):
1071
+ anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
1072
+ anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
1073
+
1074
+ self._zoom_at(factor, anchor_vp)
1075
+ return True
1076
+
1077
+ # ---- Existing polygon drawing on the label ----
1078
+ if obj is self.preview_label:
1079
+ if ev.type() == QEvent.Type.MouseButtonPress:
1080
+ if ev.buttons() & Qt.MouseButton.RightButton:
1081
+ if self._drawing_poly and len(self._drawing_poly) >= 3:
1082
+ self._polygons.append(self._drawing_poly)
1083
+ self._drawing_poly = None
1084
+ self._redraw_overlay()
1085
+ return True
1086
+ if ev.buttons() & Qt.MouseButton.MiddleButton or (ev.buttons() & Qt.MouseButton.LeftButton and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
1087
+ self._panning = True
1088
+ self._pan_last = ev.position().toPoint()
1089
+ self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
1090
+ return True
1091
+ if ev.buttons() & Qt.MouseButton.LeftButton:
1092
+ img_pt = self._label_to_image_coords(ev.position())
1093
+ if img_pt is not None:
1094
+ if self._drawing_poly is None:
1095
+ self._drawing_poly = [img_pt]
1096
+ else:
1097
+ self._drawing_poly.append(img_pt)
1098
+ self._redraw_overlay()
1099
+ return True
1100
+
1101
+ elif ev.type() == QEvent.Type.MouseMove:
1102
+ if getattr(self, "_panning", False):
1103
+ pos = ev.position().toPoint()
1104
+ delta = pos - (self._pan_last or pos)
1105
+ self._pan_last = pos
1106
+ hsb = self.preview_scroll.horizontalScrollBar()
1107
+ vsb = self.preview_scroll.verticalScrollBar()
1108
+ hsb.setValue(hsb.value() - delta.x())
1109
+ vsb.setValue(vsb.value() - delta.y())
1110
+ return True
1111
+ if self._drawing_poly is not None and (ev.buttons() & Qt.MouseButton.LeftButton):
1112
+ img_pt = self._label_to_image_coords(ev.position())
1113
+ if img_pt is not None:
1114
+ self._drawing_poly.append(img_pt)
1115
+ self._redraw_overlay()
1116
+ return True
1117
+
1118
+ elif ev.type() == QEvent.Type.MouseButtonRelease:
1119
+ # finish panning
1120
+ if getattr(self, "_panning", False):
1121
+ self._panning = False
1122
+ self._pan_last = None
1123
+ self.preview_label.unsetCursor()
1124
+ return True
1125
+
1126
+ # Close polygon on LEFT mouse release
1127
+ if ev.button() == Qt.MouseButton.LeftButton and self._drawing_poly is not None:
1128
+ if len(self._drawing_poly) >= 3:
1129
+ self._polygons.append(self._drawing_poly)
1130
+ self._drawing_poly = None
1131
+ self._redraw_overlay()
1132
+ return True
1133
+
1134
+ return super().eventFilter(obj, ev)
1135
+
1136
+
1137
+
1138
+
1139
+ def _ensure_scale_state(self):
1140
+ # internal guard so _zoom_at can be called even if _scale hasn't been set
1141
+ if not hasattr(self, "_scale"):
1142
+ self._scale = float(self.view.transform().m11()) if not self.view.transform().isIdentity() else 1.0
1143
+
1144
+ def _zoom_at(self, factor: float, anchor_vp) -> None:
1145
+ """
1146
+ Zoom the preview by 'factor', keeping the content point under 'anchor_vp'
1147
+ (a QPoint in viewport coordinates) stationary.
1148
+ """
1149
+ old_scale = float(self._preview_scale)
1150
+ new_scale = max(0.05, min(old_scale * factor, 8.0))
1151
+ if abs(new_scale - old_scale) < 1e-6:
1152
+ return
1153
+ factor = new_scale / old_scale
1154
+
1155
+ # content coordinates (relative to the QLabel) under the cursor BEFORE scaling
1156
+ hsb = self.preview_scroll.horizontalScrollBar()
1157
+ vsb = self.preview_scroll.verticalScrollBar()
1158
+ old_x = hsb.value() + anchor_vp.x()
1159
+ old_y = vsb.value() + anchor_vp.y()
1160
+
1161
+ # apply scale
1162
+ self._preview_scale = new_scale
1163
+ self._update_preview_scaled()
1164
+ self._redraw_overlay()
1165
+
1166
+ # desired scroll so the same content point stays under the cursor
1167
+ new_x = int(old_x * factor - anchor_vp.x())
1168
+ new_y = int(old_y * factor - anchor_vp.y())
1169
+
1170
+ # clamp to valid range
1171
+ hsb.setValue(max(hsb.minimum(), min(new_x, hsb.maximum())))
1172
+ vsb.setValue(max(vsb.minimum(), min(new_y, vsb.maximum())))
1173
+
1174
+
1175
+ def zoom_in(self) -> None:
1176
+ vp = self.preview_scroll.viewport()
1177
+ self._zoom_at(1.25, vp.rect().center())
1178
+
1179
+ def zoom_out(self) -> None:
1180
+ vp = self.preview_scroll.viewport()
1181
+ self._zoom_at(0.8, vp.rect().center())
1182
+
1183
+ def fit_to_preview(self) -> None:
1184
+ """Set scale so the image fits inside the viewport (keeps aspect)."""
1185
+ if self._preview_qimg is None:
1186
+ return
1187
+ vp = self.preview_scroll.viewport()
1188
+ vw, vh = max(1, vp.width()), max(1, vp.height())
1189
+ iw, ih = self._preview_qimg.width(), self._preview_qimg.height()
1190
+ if iw == 0 or ih == 0:
1191
+ return
1192
+ scale = min(vw / iw, vh / ih)
1193
+ self._preview_scale = max(0.05, min(scale, 8.0))
1194
+ self._update_preview_scaled()
1195
+ self._redraw_overlay()
1196
+
1197
+ # center after fit
1198
+ hsb = self.preview_scroll.horizontalScrollBar()
1199
+ vsb = self.preview_scroll.verticalScrollBar()
1200
+ hsb.setValue((hsb.maximum() - hsb.minimum()) // 2)
1201
+ vsb.setValue((vsb.maximum() - vsb.minimum()) // 2)
1202
+
1203
+
1204
+
1205
+ def _label_to_image_coords(self, posf) -> QPointF | None:
1206
+ if self._preview_qimg is None:
1207
+ return None
1208
+ img_w = self._preview_qimg.width(); img_h = self._preview_qimg.height()
1209
+ lab_w = self.preview_label.width(); lab_h = self.preview_label.height()
1210
+ sx = img_w / max(1.0, lab_w); sy = img_h / max(1.0, lab_h)
1211
+ x_img = float(posf.x()) * sx; y_img = float(posf.y()) * sy
1212
+ # clamp to image
1213
+ x_img = max(0.0, min(x_img, img_w - 1.0))
1214
+ y_img = max(0.0, min(y_img, img_h - 1.0))
1215
+ return QPointF(x_img, y_img)
1216
+
1217
+ def _install_zoom_filters(self):
1218
+ """Install event filters so Ctrl+Wheel works even when the cursor is over scrollbars."""
1219
+ self.preview_scroll.installEventFilter(self)
1220
+ self.preview_scroll.viewport().installEventFilter(self)
1221
+ self.preview_scroll.horizontalScrollBar().installEventFilter(self)
1222
+ self.preview_scroll.verticalScrollBar().installEventFilter(self)
1223
+ self.preview_label.installEventFilter(self)
1224
+
1225
+ def _set_preview_from_float(self, arr: np.ndarray):
1226
+ if arr is None or arr.size == 0:
1227
+ return
1228
+ a = _asfloat32(arr)
1229
+ self._preview_source_f01 = a # ← no np.clip
1230
+
1231
+ src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
1232
+ linked=False, use_16bit=True)
1233
+ if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
1234
+
1235
+ if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
1236
+ mono = src_to_show if src_to_show.ndim == 2 else src_to_show[..., 0]
1237
+ buf8_mono = (mono * 255.0).astype(np.uint8) # ← no np.clip
1238
+ buf8_mono = np.ascontiguousarray(buf8_mono)
1239
+ self._last_preview = np.ascontiguousarray(np.stack([buf8_mono]*3, axis=-1))
1240
+ h, w = buf8_mono.shape
1241
+ qimg = QImage(buf8_mono.data, w, h, w, QImage.Format.Format_Grayscale8)
1242
+ else:
1243
+ buf8 = (src_to_show * 255.0).astype(np.uint8) # ← no np.clip
1244
+ buf8 = np.ascontiguousarray(buf8)
1245
+ self._last_preview = buf8
1246
+ h, w, _ = buf8.shape
1247
+ qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
1248
+
1249
+ self._preview_qimg = qimg
1250
+ self._update_preview_scaled()
1251
+ self._redraw_overlay()
1252
+
1253
+ # --- mask helpers ---------------------------------------------------
1254
+ def _active_mask_layer(self):
1255
+ """Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
1256
+ mid = getattr(self.doc, "active_mask_id", None)
1257
+ if not mid: return None, None, None
1258
+ layer = getattr(self.doc, "masks", {}).get(mid)
1259
+ if layer is None: return None, None, None
1260
+ m = np.asarray(getattr(layer, "data", None))
1261
+ if m is None or m.size == 0: return None, None, None
1262
+ m = m.astype(np.float32, copy=False)
1263
+ if m.dtype.kind in "ui":
1264
+ m /= float(np.iinfo(m.dtype).max)
1265
+ else:
1266
+ mx = float(m.max()) if m.size else 1.0
1267
+ if mx > 1.0: m /= mx
1268
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
1269
+
1270
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
1271
+ """Nearest-neighbor resize via integer indexing."""
1272
+ mh, mw = mask.shape[:2]
1273
+ th, tw = out_hw
1274
+ if (mh, mw) == (th, tw): return mask
1275
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
1276
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
1277
+ return mask[yi][:, xi]
1278
+
1279
+ def _blend_with_mask_float(self, processed: np.ndarray, src: np.ndarray | None = None) -> np.ndarray:
1280
+ """
1281
+ m*out + (1-m)*src in float [0..1], mono or RGB.
1282
+ If src is None, uses the current document image (float [0..1]).
1283
+ """
1284
+ mask, _mid, _mname = self._active_mask_layer()
1285
+ if mask is None:
1286
+ return processed
1287
+
1288
+ out = processed.astype(np.float32, copy=False)
1289
+ if src is None:
1290
+ src = self._get_source_float()
1291
+ else:
1292
+ src = src.astype(np.float32, copy=False)
1293
+
1294
+ # match HxW
1295
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
1296
+
1297
+ # channel reconcile
1298
+ if out.ndim == 2 and src.ndim == 3:
1299
+ out = out[..., None]
1300
+ if src.ndim == 2 and out.ndim == 3:
1301
+ src = src[..., None]
1302
+
1303
+ if out.ndim == 3 and out.shape[2] == 3 and m.ndim == 2:
1304
+ m = m[..., None]
1305
+
1306
+ blended = (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
1307
+ # squeeze back to mono if we expanded
1308
+ if blended.ndim == 3 and blended.shape[2] == 1:
1309
+ blended = blended[..., 0]
1310
+ return np.clip(blended, 0.0, 1.0)
1311
+
1312
+
1313
+ def autostretch_preview(self, sigma: float = 3.0) -> None:
1314
+ """
1315
+ Toggle Siril-style MAD autostretch on the *preview only* (non-destructive).
1316
+ First press applies; second press restores the original preview.
1317
+ Works from the float [0..1] preview source to avoid double-clipping.
1318
+ """
1319
+ if self._preview_source_f01 is None and self._last_preview is None:
1320
+ return
1321
+
1322
+ # Lazy init toggle state
1323
+ if not hasattr(self, "_autostretch_on"):
1324
+ self._autostretch_on = False
1325
+ if not hasattr(self, "_orig_preview8"):
1326
+ self._orig_preview8 = None
1327
+
1328
+ def _rebuild_from_last():
1329
+ h, w = self._last_preview.shape[:2]
1330
+ ptr = sip.voidptr(self._last_preview.ctypes.data)
1331
+ qimg = QImage(ptr, w, h, self._last_preview.strides[0], QImage.Format.Format_RGB888)
1332
+ self._preview_qimg = qimg
1333
+ self._update_preview_scaled()
1334
+ self._redraw_overlay()
1335
+
1336
+ # Toggle OFF → restore original preview bytes
1337
+ if self._autostretch_on and self._orig_preview8 is not None:
1338
+ self._last_preview = np.ascontiguousarray(self._orig_preview8)
1339
+ _rebuild_from_last()
1340
+ self._autostretch_on = False
1341
+ if hasattr(self, "btn_autostr"):
1342
+ self.btn_autostr.setText("Autostretch")
1343
+ return
1344
+
1345
+ # Toggle ON → cache original and apply stretch from float source
1346
+ if self._last_preview is not None:
1347
+ self._orig_preview8 = np.ascontiguousarray(self._last_preview)
1348
+
1349
+ # Prefer float source (avoids 8-bit clipping); fall back to decoding _last_preview if needed
1350
+ arr = self._preview_source_f01 if self._preview_source_f01 is not None else (self._last_preview.astype(np.float32)/255.0)
1351
+
1352
+ stretched = hard_autostretch(arr, target_median=0.5, sigma=2, linked=False, use_16bit=True)
1353
+
1354
+ buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
1355
+ if buf8.ndim == 2:
1356
+ buf8 = np.stack([buf8] * 3, axis=-1)
1357
+ self._last_preview = np.ascontiguousarray(buf8)
1358
+
1359
+ _rebuild_from_last()
1360
+ self._autostretch_on = True
1361
+ if hasattr(self, "btn_autostr"):
1362
+ self.btn_autostr.setText("Autostretch (On)")
1363
+
1364
+
1365
+ def _apply_autostretch_inplace(self, sigma: float = 3.0):
1366
+ # Apply autostretch directly from current float preview source without toggling state.
1367
+ if self._preview_source_f01 is None:
1368
+ return
1369
+ stretched = hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
1370
+ linked=False, use_16bit=True)
1371
+ buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
1372
+ if buf8.ndim == 2:
1373
+ buf8 = np.stack([buf8] * 3, axis=-1)
1374
+ self._last_preview = np.ascontiguousarray(buf8)
1375
+ h, w = buf8.shape[:2]
1376
+ qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
1377
+ self._preview_qimg = qimg
1378
+ self._update_preview_scaled()
1379
+ self._redraw_overlay()