setiastrosuitepro 1.6.2.post1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (367) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotateclockwise.png +0 -0
  107. setiastro/images/rotatecounterclockwise.png +0 -0
  108. setiastro/images/satellite.png +0 -0
  109. setiastro/images/script.png +0 -0
  110. setiastro/images/selectivecolor.png +0 -0
  111. setiastro/images/simbad.png +0 -0
  112. setiastro/images/slot0.png +0 -0
  113. setiastro/images/slot1.png +0 -0
  114. setiastro/images/slot2.png +0 -0
  115. setiastro/images/slot3.png +0 -0
  116. setiastro/images/slot4.png +0 -0
  117. setiastro/images/slot5.png +0 -0
  118. setiastro/images/slot6.png +0 -0
  119. setiastro/images/slot7.png +0 -0
  120. setiastro/images/slot8.png +0 -0
  121. setiastro/images/slot9.png +0 -0
  122. setiastro/images/spcc.png +0 -0
  123. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  124. setiastro/images/spinner.gif +0 -0
  125. setiastro/images/stacking.png +0 -0
  126. setiastro/images/staradd.png +0 -0
  127. setiastro/images/staralign.png +0 -0
  128. setiastro/images/starnet.png +0 -0
  129. setiastro/images/starregistration.png +0 -0
  130. setiastro/images/starspike.png +0 -0
  131. setiastro/images/starstretch.png +0 -0
  132. setiastro/images/statstretch.png +0 -0
  133. setiastro/images/supernova.png +0 -0
  134. setiastro/images/uhs.png +0 -0
  135. setiastro/images/undoicon.png +0 -0
  136. setiastro/images/upscale.png +0 -0
  137. setiastro/images/viewbundle.png +0 -0
  138. setiastro/images/whitebalance.png +0 -0
  139. setiastro/images/wimi_icon_256x256.png +0 -0
  140. setiastro/images/wimilogo.png +0 -0
  141. setiastro/images/wims.png +0 -0
  142. setiastro/images/wrench_icon.png +0 -0
  143. setiastro/images/xisfliberator.png +0 -0
  144. setiastro/qml/ResourceMonitor.qml +126 -0
  145. setiastro/saspro/__init__.py +20 -0
  146. setiastro/saspro/__main__.py +945 -0
  147. setiastro/saspro/_generated/__init__.py +7 -0
  148. setiastro/saspro/_generated/build_info.py +3 -0
  149. setiastro/saspro/abe.py +1346 -0
  150. setiastro/saspro/abe_preset.py +196 -0
  151. setiastro/saspro/aberration_ai.py +694 -0
  152. setiastro/saspro/aberration_ai_preset.py +224 -0
  153. setiastro/saspro/accel_installer.py +218 -0
  154. setiastro/saspro/accel_workers.py +30 -0
  155. setiastro/saspro/add_stars.py +624 -0
  156. setiastro/saspro/astrobin_exporter.py +1010 -0
  157. setiastro/saspro/astrospike.py +153 -0
  158. setiastro/saspro/astrospike_python.py +1841 -0
  159. setiastro/saspro/autostretch.py +198 -0
  160. setiastro/saspro/backgroundneutral.py +602 -0
  161. setiastro/saspro/batch_convert.py +328 -0
  162. setiastro/saspro/batch_renamer.py +522 -0
  163. setiastro/saspro/blemish_blaster.py +491 -0
  164. setiastro/saspro/blink_comparator_pro.py +2926 -0
  165. setiastro/saspro/bundles.py +61 -0
  166. setiastro/saspro/bundles_dock.py +114 -0
  167. setiastro/saspro/cheat_sheet.py +213 -0
  168. setiastro/saspro/clahe.py +368 -0
  169. setiastro/saspro/comet_stacking.py +1442 -0
  170. setiastro/saspro/common_tr.py +107 -0
  171. setiastro/saspro/config.py +38 -0
  172. setiastro/saspro/config_bootstrap.py +40 -0
  173. setiastro/saspro/config_manager.py +316 -0
  174. setiastro/saspro/continuum_subtract.py +1617 -0
  175. setiastro/saspro/convo.py +1400 -0
  176. setiastro/saspro/convo_preset.py +414 -0
  177. setiastro/saspro/copyastro.py +190 -0
  178. setiastro/saspro/cosmicclarity.py +1589 -0
  179. setiastro/saspro/cosmicclarity_preset.py +407 -0
  180. setiastro/saspro/crop_dialog_pro.py +973 -0
  181. setiastro/saspro/crop_preset.py +189 -0
  182. setiastro/saspro/curve_editor_pro.py +2562 -0
  183. setiastro/saspro/curves_preset.py +375 -0
  184. setiastro/saspro/debayer.py +673 -0
  185. setiastro/saspro/debug_utils.py +29 -0
  186. setiastro/saspro/dnd_mime.py +35 -0
  187. setiastro/saspro/doc_manager.py +2664 -0
  188. setiastro/saspro/exoplanet_detector.py +2166 -0
  189. setiastro/saspro/file_utils.py +284 -0
  190. setiastro/saspro/fitsmodifier.py +748 -0
  191. setiastro/saspro/fix_bom.py +32 -0
  192. setiastro/saspro/free_torch_memory.py +48 -0
  193. setiastro/saspro/frequency_separation.py +1349 -0
  194. setiastro/saspro/function_bundle.py +1596 -0
  195. setiastro/saspro/generate_translations.py +3092 -0
  196. setiastro/saspro/ghs_dialog_pro.py +663 -0
  197. setiastro/saspro/ghs_preset.py +284 -0
  198. setiastro/saspro/graxpert.py +637 -0
  199. setiastro/saspro/graxpert_preset.py +287 -0
  200. setiastro/saspro/gui/__init__.py +0 -0
  201. setiastro/saspro/gui/main_window.py +8810 -0
  202. setiastro/saspro/gui/mixins/__init__.py +33 -0
  203. setiastro/saspro/gui/mixins/dock_mixin.py +362 -0
  204. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  205. setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
  206. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  207. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  208. setiastro/saspro/gui/mixins/menu_mixin.py +389 -0
  209. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  210. setiastro/saspro/gui/mixins/toolbar_mixin.py +1457 -0
  211. setiastro/saspro/gui/mixins/update_mixin.py +309 -0
  212. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  213. setiastro/saspro/gui/statistics_dialog.py +47 -0
  214. setiastro/saspro/halobgon.py +488 -0
  215. setiastro/saspro/header_viewer.py +448 -0
  216. setiastro/saspro/headless_utils.py +88 -0
  217. setiastro/saspro/histogram.py +756 -0
  218. setiastro/saspro/history_explorer.py +941 -0
  219. setiastro/saspro/i18n.py +168 -0
  220. setiastro/saspro/image_combine.py +417 -0
  221. setiastro/saspro/image_peeker_pro.py +1604 -0
  222. setiastro/saspro/imageops/__init__.py +37 -0
  223. setiastro/saspro/imageops/mdi_snap.py +292 -0
  224. setiastro/saspro/imageops/scnr.py +36 -0
  225. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  226. setiastro/saspro/imageops/stretch.py +236 -0
  227. setiastro/saspro/isophote.py +1182 -0
  228. setiastro/saspro/layers.py +208 -0
  229. setiastro/saspro/layers_dock.py +714 -0
  230. setiastro/saspro/lazy_imports.py +193 -0
  231. setiastro/saspro/legacy/__init__.py +2 -0
  232. setiastro/saspro/legacy/image_manager.py +2226 -0
  233. setiastro/saspro/legacy/numba_utils.py +3676 -0
  234. setiastro/saspro/legacy/xisf.py +1071 -0
  235. setiastro/saspro/linear_fit.py +537 -0
  236. setiastro/saspro/live_stacking.py +1841 -0
  237. setiastro/saspro/log_bus.py +5 -0
  238. setiastro/saspro/logging_config.py +460 -0
  239. setiastro/saspro/luminancerecombine.py +309 -0
  240. setiastro/saspro/main_helpers.py +201 -0
  241. setiastro/saspro/mask_creation.py +931 -0
  242. setiastro/saspro/masks_core.py +56 -0
  243. setiastro/saspro/mdi_widgets.py +353 -0
  244. setiastro/saspro/memory_utils.py +666 -0
  245. setiastro/saspro/metadata_patcher.py +75 -0
  246. setiastro/saspro/mfdeconv.py +3831 -0
  247. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  248. setiastro/saspro/mfdeconvcudnn.py +3263 -0
  249. setiastro/saspro/mfdeconvsport.py +2382 -0
  250. setiastro/saspro/minorbodycatalog.py +567 -0
  251. setiastro/saspro/morphology.py +407 -0
  252. setiastro/saspro/multiscale_decomp.py +1293 -0
  253. setiastro/saspro/nbtorgb_stars.py +541 -0
  254. setiastro/saspro/numba_utils.py +3145 -0
  255. setiastro/saspro/numba_warmup.py +141 -0
  256. setiastro/saspro/ops/__init__.py +9 -0
  257. setiastro/saspro/ops/command_help_dialog.py +623 -0
  258. setiastro/saspro/ops/command_runner.py +217 -0
  259. setiastro/saspro/ops/commands.py +1594 -0
  260. setiastro/saspro/ops/script_editor.py +1102 -0
  261. setiastro/saspro/ops/scripts.py +1473 -0
  262. setiastro/saspro/ops/settings.py +637 -0
  263. setiastro/saspro/parallel_utils.py +554 -0
  264. setiastro/saspro/pedestal.py +121 -0
  265. setiastro/saspro/perfect_palette_picker.py +1071 -0
  266. setiastro/saspro/pipeline.py +110 -0
  267. setiastro/saspro/pixelmath.py +1604 -0
  268. setiastro/saspro/plate_solver.py +2445 -0
  269. setiastro/saspro/project_io.py +797 -0
  270. setiastro/saspro/psf_utils.py +136 -0
  271. setiastro/saspro/psf_viewer.py +549 -0
  272. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  273. setiastro/saspro/remove_green.py +331 -0
  274. setiastro/saspro/remove_stars.py +1599 -0
  275. setiastro/saspro/remove_stars_preset.py +404 -0
  276. setiastro/saspro/resources.py +501 -0
  277. setiastro/saspro/rgb_combination.py +208 -0
  278. setiastro/saspro/rgb_extract.py +19 -0
  279. setiastro/saspro/rgbalign.py +723 -0
  280. setiastro/saspro/runtime_imports.py +7 -0
  281. setiastro/saspro/runtime_torch.py +754 -0
  282. setiastro/saspro/save_options.py +73 -0
  283. setiastro/saspro/selective_color.py +1552 -0
  284. setiastro/saspro/sfcc.py +1472 -0
  285. setiastro/saspro/shortcuts.py +3043 -0
  286. setiastro/saspro/signature_insert.py +1102 -0
  287. setiastro/saspro/stacking_suite.py +18470 -0
  288. setiastro/saspro/star_alignment.py +7435 -0
  289. setiastro/saspro/star_alignment_preset.py +329 -0
  290. setiastro/saspro/star_metrics.py +49 -0
  291. setiastro/saspro/star_spikes.py +765 -0
  292. setiastro/saspro/star_stretch.py +507 -0
  293. setiastro/saspro/stat_stretch.py +538 -0
  294. setiastro/saspro/status_log_dock.py +78 -0
  295. setiastro/saspro/subwindow.py +3328 -0
  296. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  297. setiastro/saspro/swap_manager.py +99 -0
  298. setiastro/saspro/torch_backend.py +89 -0
  299. setiastro/saspro/torch_rejection.py +434 -0
  300. setiastro/saspro/translations/all_source_strings.json +3654 -0
  301. setiastro/saspro/translations/ar_translations.py +3865 -0
  302. setiastro/saspro/translations/de_translations.py +3749 -0
  303. setiastro/saspro/translations/es_translations.py +3939 -0
  304. setiastro/saspro/translations/fr_translations.py +3858 -0
  305. setiastro/saspro/translations/hi_translations.py +3571 -0
  306. setiastro/saspro/translations/integrate_translations.py +270 -0
  307. setiastro/saspro/translations/it_translations.py +3678 -0
  308. setiastro/saspro/translations/ja_translations.py +3601 -0
  309. setiastro/saspro/translations/pt_translations.py +3869 -0
  310. setiastro/saspro/translations/ru_translations.py +2848 -0
  311. setiastro/saspro/translations/saspro_ar.qm +0 -0
  312. setiastro/saspro/translations/saspro_ar.ts +255 -0
  313. setiastro/saspro/translations/saspro_de.qm +0 -0
  314. setiastro/saspro/translations/saspro_de.ts +253 -0
  315. setiastro/saspro/translations/saspro_es.qm +0 -0
  316. setiastro/saspro/translations/saspro_es.ts +12520 -0
  317. setiastro/saspro/translations/saspro_fr.qm +0 -0
  318. setiastro/saspro/translations/saspro_fr.ts +12514 -0
  319. setiastro/saspro/translations/saspro_hi.qm +0 -0
  320. setiastro/saspro/translations/saspro_hi.ts +257 -0
  321. setiastro/saspro/translations/saspro_it.qm +0 -0
  322. setiastro/saspro/translations/saspro_it.ts +12520 -0
  323. setiastro/saspro/translations/saspro_ja.qm +0 -0
  324. setiastro/saspro/translations/saspro_ja.ts +257 -0
  325. setiastro/saspro/translations/saspro_pt.qm +0 -0
  326. setiastro/saspro/translations/saspro_pt.ts +257 -0
  327. setiastro/saspro/translations/saspro_ru.qm +0 -0
  328. setiastro/saspro/translations/saspro_ru.ts +237 -0
  329. setiastro/saspro/translations/saspro_sw.qm +0 -0
  330. setiastro/saspro/translations/saspro_sw.ts +257 -0
  331. setiastro/saspro/translations/saspro_uk.qm +0 -0
  332. setiastro/saspro/translations/saspro_uk.ts +10771 -0
  333. setiastro/saspro/translations/saspro_zh.qm +0 -0
  334. setiastro/saspro/translations/saspro_zh.ts +12520 -0
  335. setiastro/saspro/translations/sw_translations.py +3671 -0
  336. setiastro/saspro/translations/uk_translations.py +3700 -0
  337. setiastro/saspro/translations/zh_translations.py +3675 -0
  338. setiastro/saspro/versioning.py +77 -0
  339. setiastro/saspro/view_bundle.py +1558 -0
  340. setiastro/saspro/wavescale_hdr.py +645 -0
  341. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  342. setiastro/saspro/wavescalede.py +680 -0
  343. setiastro/saspro/wavescalede_preset.py +230 -0
  344. setiastro/saspro/wcs_update.py +374 -0
  345. setiastro/saspro/whitebalance.py +492 -0
  346. setiastro/saspro/widgets/__init__.py +48 -0
  347. setiastro/saspro/widgets/common_utilities.py +306 -0
  348. setiastro/saspro/widgets/graphics_views.py +122 -0
  349. setiastro/saspro/widgets/image_utils.py +518 -0
  350. setiastro/saspro/widgets/minigame/game.js +986 -0
  351. setiastro/saspro/widgets/minigame/index.html +53 -0
  352. setiastro/saspro/widgets/minigame/style.css +241 -0
  353. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  354. setiastro/saspro/widgets/resource_monitor.py +237 -0
  355. setiastro/saspro/widgets/spinboxes.py +275 -0
  356. setiastro/saspro/widgets/themed_buttons.py +13 -0
  357. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  358. setiastro/saspro/wimi.py +7996 -0
  359. setiastro/saspro/wims.py +578 -0
  360. setiastro/saspro/window_shelf.py +185 -0
  361. setiastro/saspro/xisf.py +1123 -0
  362. setiastrosuitepro-1.6.2.post1.dist-info/METADATA +278 -0
  363. setiastrosuitepro-1.6.2.post1.dist-info/RECORD +367 -0
  364. setiastrosuitepro-1.6.2.post1.dist-info/WHEEL +4 -0
  365. setiastrosuitepro-1.6.2.post1.dist-info/entry_points.txt +6 -0
  366. setiastrosuitepro-1.6.2.post1.dist-info/licenses/LICENSE +674 -0
  367. setiastrosuitepro-1.6.2.post1.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1346 @@
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
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
495
+
496
+ self._main = parent
497
+ self.doc = document
498
+
499
+ # Connect to active document change signal
500
+ if hasattr(self._main, "currentDocumentChanged"):
501
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
502
+
503
+ self._preview_scale = 1.0
504
+ self._preview_qimg = None
505
+ self._last_preview = None # backing ndarray for QImage lifetime
506
+ self._overlay = None
507
+
508
+
509
+ # image-space polygons: list[list[QPointF]] in ORIGINAL IMAGE COORDS
510
+ self._polygons: list[list[QPointF]] = []
511
+ self._drawing_poly: list[QPointF] | None = None
512
+ self._panning = False
513
+ self._pan_last = None
514
+ self._preview_source_f01 = None
515
+
516
+ # ---------------- Controls ----------------
517
+ self.sp_degree = QSpinBox(); self.sp_degree.setRange(0, 6); self.sp_degree.setValue(2)
518
+ self.sp_samples = QSpinBox(); self.sp_samples.setRange(20, 10000); self.sp_samples.setSingleStep(20); self.sp_samples.setValue(120)
519
+ self.sp_down = QSpinBox(); self.sp_down.setRange(1, 32); self.sp_down.setValue(4)
520
+ self.sp_patch = QSpinBox(); self.sp_patch.setRange(5, 151); self.sp_patch.setSingleStep(2); self.sp_patch.setValue(15)
521
+ self.chk_use_rbf = QCheckBox(self.tr("Enable RBF refinement (after polynomial)")); self.chk_use_rbf.setChecked(True)
522
+ self.sp_rbf = QSpinBox(); self.sp_rbf.setRange(0, 1000); self.sp_rbf.setValue(100) # shown as ×0.01 below
523
+ self.chk_make_bg_doc = QCheckBox(self.tr("Create background document")); self.chk_make_bg_doc.setChecked(False)
524
+ self.chk_preview_bg = QCheckBox(self.tr("Preview background instead of corrected")); self.chk_preview_bg.setChecked(False)
525
+
526
+ # Preview area
527
+ self.preview_label = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
528
+ self.preview_label.setMinimumSize(QSize(480, 360))
529
+ self.preview_label.setScaledContents(False)
530
+ self.preview_scroll = QScrollArea()
531
+ self.preview_scroll.setWidgetResizable(False)
532
+ self.preview_scroll.setWidget(self.preview_label)
533
+ self.preview_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
534
+ self.preview_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
535
+
536
+ # Buttons
537
+ self.btn_preview = QPushButton(self.tr("Preview"))
538
+ self.btn_apply = QPushButton(self.tr("Apply"))
539
+ self.btn_close = QPushButton(self.tr("Close"))
540
+ self.btn_clear = QPushButton(self.tr("Clear Exclusions"))
541
+ self.btn_preview.clicked.connect(self._do_preview)
542
+ self.btn_apply.clicked.connect(self._do_apply)
543
+ self.btn_close.clicked.connect(self.close)
544
+ self.btn_clear.clicked.connect(self._clear_polys)
545
+
546
+ # Layout
547
+ params = QFormLayout()
548
+ params.addRow(self.tr("Polynomial degree:"), self.sp_degree)
549
+ params.addRow(self.tr("# sample points:"), self.sp_samples)
550
+ params.addRow(self.tr("Downsample factor:"), self.sp_down)
551
+ params.addRow(self.tr("Patch size (px):"), self.sp_patch)
552
+
553
+ rbf_box = QGroupBox(self.tr("RBF Refinement"))
554
+ rbf_form = QFormLayout()
555
+ rbf_form.addRow(self.chk_use_rbf)
556
+ rbf_form.addRow(self.tr("Smooth (x0.01):"), self.sp_rbf)
557
+ rbf_box.setLayout(rbf_form)
558
+
559
+ opts = QVBoxLayout()
560
+ opts.addLayout(params)
561
+ opts.addWidget(rbf_box)
562
+ opts.addWidget(self.chk_make_bg_doc)
563
+ opts.addWidget(self.chk_preview_bg)
564
+ row = QHBoxLayout(); row.addWidget(self.btn_preview); row.addWidget(self.btn_apply); row.addStretch(1)
565
+ opts.addLayout(row)
566
+ opts.addWidget(self.btn_clear)
567
+ opts.addStretch(1)
568
+
569
+ # ▼ New status label
570
+ self.status_label = QLabel("Ready")
571
+ self.status_label.setWordWrap(True)
572
+ opts.addWidget(self.status_label)
573
+
574
+ opts.addStretch(1)
575
+
576
+ # ⬇️ New right-side stack: toolbar row ABOVE the preview
577
+ right = QVBoxLayout()
578
+ right.addLayout(self._build_toolbar()) # Zoom In / Out / Fit / Autostretch
579
+ right.addWidget(self.preview_scroll, 1) # Preview below the buttons
580
+
581
+ main = QHBoxLayout(self)
582
+ main.addLayout(opts, 0) # Left controls
583
+ main.addLayout(right, 1) # Right: buttons above preview
584
+
585
+ self._base_pixmap = None # clean, scaled image with no overlays
586
+ self.preview_scroll.viewport().installEventFilter(self)
587
+ self.preview_label.installEventFilter(self)
588
+ self._install_zoom_filters()
589
+ self._populate_initial_preview()
590
+ self.sp_degree.valueChanged.connect(self._degree_changed)
591
+
592
+ QTimer.singleShot(0, self._post_init_fit_and_stretch)
593
+
594
+ def _post_init_fit_and_stretch(self) -> None:
595
+ # Only run if we have an image preview
596
+ if self._preview_qimg is None:
597
+ return
598
+ # Fit to the viewport
599
+ self.fit_to_preview()
600
+ # Turn autostretch ON if it's not already
601
+ if not getattr(self, "_autostretch_on", False):
602
+ self.autostretch_preview()
603
+
604
+ def _set_status(self, text: str) -> None:
605
+ self.status_label.setText(text)
606
+ QApplication.processEvents()
607
+
608
+ def _build_toolbar(self):
609
+ """
610
+ Returns a QHBoxLayout with: Zoom In, Zoom Out, Fit, Autostretch.
611
+ Call: opts.addLayout(self._build_toolbar()) in __init__.
612
+ """
613
+ bar = QHBoxLayout()
614
+
615
+ # QToolButtons with theme icons
616
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
617
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
618
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
619
+ self.btn_autostr = themed_toolbtn("color-picker", "Autostretch") # pick your preferred icon
620
+
621
+ self.btn_zoom_in.clicked.connect(self.zoom_in)
622
+ self.btn_zoom_out.clicked.connect(self.zoom_out)
623
+ self.btn_fit.clicked.connect(self.fit_to_preview)
624
+ self.btn_autostr.clicked.connect(self.autostretch_preview)
625
+
626
+ bar.addWidget(self.btn_zoom_in)
627
+ bar.addWidget(self.btn_zoom_out)
628
+ bar.addWidget(self.btn_fit)
629
+ bar.addStretch(1)
630
+ bar.addWidget(self.btn_autostr)
631
+ return bar
632
+
633
+ # ----- active document change -----
634
+ def _on_active_doc_changed(self, doc):
635
+ """Called when user clicks a different image window."""
636
+ if doc is None or getattr(doc, "image", None) is None:
637
+ return
638
+ self.doc = doc
639
+ self._polygons.clear()
640
+ self._drawing_poly = None
641
+ self._preview_source_f01 = None
642
+ self._populate_initial_preview()
643
+
644
+ # ----- data helpers -----
645
+ def _get_source_float(self) -> np.ndarray | None:
646
+ src = np.asarray(self.doc.image)
647
+ if src is None or src.size == 0:
648
+ return None
649
+ if np.issubdtype(src.dtype, np.integer):
650
+ scale = float(np.iinfo(src.dtype).max)
651
+ return (src.astype(np.float32) / scale).clip(0.0, 1.0)
652
+ # float path: do NOT normalize; just clip to [0,1] like Crop does upstream
653
+ return np.clip(src.astype(np.float32, copy=False), 0.0, 1.0)
654
+
655
+ # ----- preview/applier -----
656
+ def _run_abe(self, excl_mask: np.ndarray | None, progress=None):
657
+ imgf = self._get_source_float()
658
+ if imgf is None:
659
+ return None, None
660
+ deg = int(self.sp_degree.value())
661
+ npts = int(self.sp_samples.value())
662
+ dwn = int(self.sp_down.value())
663
+ patch = int(self.sp_patch.value())
664
+ use_rbf = bool(self.chk_use_rbf.isChecked())
665
+ rbf_smooth = float(self.sp_rbf.value()) * 0.01
666
+
667
+ return abe_run(
668
+ imgf,
669
+ degree=deg, num_samples=npts, downsample=dwn, patch_size=patch,
670
+ use_rbf=use_rbf, rbf_smooth=rbf_smooth,
671
+ exclusion_mask=excl_mask, return_background=True,
672
+ progress_cb=progress # ◀️ forward progress
673
+ )
674
+
675
+ def _degree_changed(self, v: int):
676
+ # Make it clear what 0 means, and default RBF on (can still be unchecked)
677
+ if v == 0:
678
+ self.chk_use_rbf.setChecked(True)
679
+ if hasattr(self, "_set_status"):
680
+ self._set_status("Polynomial disabled (degree 0) → RBF-only.")
681
+ else:
682
+ if hasattr(self, "_set_status"):
683
+ self._set_status("Ready")
684
+
685
+ def _populate_initial_preview(self):
686
+ src = self._get_source_float()
687
+ if src is not None:
688
+ self._set_preview_pixmap(np.clip(src, 0, 1))
689
+
690
+ def _do_preview(self):
691
+ try:
692
+ self._set_status("Building exclusion mask…")
693
+ excl = self._build_exclusion_mask()
694
+
695
+ self._set_status("Running ABE preview…")
696
+ corrected, bg = self._run_abe(excl, progress=self._set_status)
697
+ if corrected is None:
698
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
699
+ self._set_status("Ready")
700
+ return
701
+
702
+ show = bg if self.chk_preview_bg.isChecked() else corrected
703
+
704
+ # ✅ If previewing the corrected image, honor the active mask
705
+ if not self.chk_preview_bg.isChecked():
706
+ srcf = self._get_source_float()
707
+ show = self._blend_with_mask_float(show, srcf)
708
+
709
+ self._set_status("Rendering preview…")
710
+ self._set_preview_pixmap(show)
711
+ self._set_status("Ready")
712
+ except Exception as e:
713
+ self._set_status("Error")
714
+ QMessageBox.warning(self, "Preview failed", str(e))
715
+
716
+ def _do_apply(self):
717
+ try:
718
+ self._set_status("Building exclusion mask…")
719
+ excl = self._build_exclusion_mask()
720
+
721
+ self._set_status("Running ABE (apply)…")
722
+ corrected, bg = self._run_abe(excl, progress=self._set_status)
723
+ if corrected is None:
724
+ QMessageBox.information(self, "No image", "No image is loaded in the active document.")
725
+ self._set_status("Ready")
726
+ return
727
+
728
+ # Preserve mono vs color shape w.r.t. source
729
+ out = corrected
730
+ 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)):
731
+ out = out[..., 0]
732
+
733
+ # ✅ Blend with active mask before committing
734
+ srcf = self._get_source_float()
735
+ out_masked = self._blend_with_mask_float(out, srcf)
736
+
737
+ # Build step name for undo stack
738
+ # Build step name + params for undo stack + Replay
739
+ deg = int(self.sp_degree.value())
740
+ npts = int(self.sp_samples.value())
741
+ dwn = int(self.sp_down.value())
742
+ patch = int(self.sp_patch.value())
743
+ use_rbf = bool(self.chk_use_rbf.isChecked())
744
+ rbf_smooth = float(self.sp_rbf.value()) * 0.01
745
+ make_bg_doc = bool(self.chk_make_bg_doc.isChecked())
746
+
747
+ step_name = (
748
+ f"ABE (deg={deg}, samples={npts}, ds={dwn}, patch={patch}, "
749
+ f"rbf={'on' if use_rbf else 'off'}, s={rbf_smooth:.3f})"
750
+ )
751
+
752
+ # Normalized preset params (same schema as abe_preset.apply_abe_via_preset)
753
+ params = {
754
+ "degree": deg,
755
+ "samples": npts,
756
+ "downsample": dwn,
757
+ "patch": patch,
758
+ "rbf": use_rbf,
759
+ "rbf_smooth": rbf_smooth,
760
+ "make_background_doc": make_bg_doc,
761
+ }
762
+
763
+ # 🔁 Remember this as the last headless-style command for Replay
764
+ mw = self.parent()
765
+ try:
766
+ remember = getattr(mw, "remember_last_headless_command", None)
767
+ if remember is None:
768
+ remember = getattr(mw, "_remember_last_headless_command", None)
769
+ if callable(remember):
770
+ remember("abe", params, description="Automatic Background Extraction")
771
+ try:
772
+ if hasattr(mw, "_log"):
773
+ mw._log(
774
+ f"[Replay] ABE UI apply stored: "
775
+ f"command_id='abe', preset_keys={list(params.keys())}"
776
+ )
777
+ except Exception:
778
+ pass
779
+ except Exception:
780
+ # don’t block the actual ABE apply if remembering fails
781
+ pass
782
+
783
+ # ✅ mask bookkeeping in metadata
784
+ _marr, mid, mname = self._active_mask_layer()
785
+ abe_meta = dict(params)
786
+ abe_meta["exclusion"] = "polygons" if excl is not None else "none"
787
+
788
+ meta = {
789
+ "step_name": "ABE",
790
+ "abe": abe_meta,
791
+ "masked": bool(mid),
792
+ "mask_id": mid,
793
+ "mask_name": mname,
794
+ "mask_blend": "m*out + (1-m)*src",
795
+ }
796
+
797
+ self._set_status("Committing edit…")
798
+ self.doc.apply_edit(
799
+ out_masked.astype(np.float32, copy=False),
800
+ step_name=step_name,
801
+ metadata=meta,
802
+ )
803
+
804
+
805
+ if self.chk_make_bg_doc.isChecked() and bg is not None:
806
+ self._set_status("Creating background document…")
807
+ mw = self.parent()
808
+ dm = getattr(mw, "docman", None)
809
+ if dm is not None:
810
+ base = os.path.splitext(self.doc.display_name())[0]
811
+ meta = {
812
+ "bit_depth": "32-bit floating point",
813
+ "is_mono": (bg.ndim == 2),
814
+ "source": "ABE background",
815
+ "original_header": self.doc.metadata.get("original_header"),
816
+ }
817
+ doc_bg = dm.open_array(bg.astype(np.float32, copy=False), metadata=meta, title=f"{base}_ABE_BG")
818
+ if hasattr(mw, "_spawn_subwindow_for"):
819
+ mw._spawn_subwindow_for(doc_bg)
820
+
821
+ # Preserve the current view's autostretch state: capture before/restore after
822
+ mw = self.parent()
823
+ prev_autostretch = False
824
+ view = None
825
+ try:
826
+ if hasattr(mw, "mdi") and mw.mdi.activeSubWindow():
827
+ view = mw.mdi.activeSubWindow().widget()
828
+ prev_autostretch = bool(getattr(view, "autostretch_enabled", False))
829
+ except Exception:
830
+ prev_autostretch = False
831
+
832
+
833
+ if hasattr(mw, "_log"):
834
+ mw._log(step_name)
835
+
836
+ # Restore autostretch state on the view (recompute display) so the
837
+ # user's display-stretch choice survives the edit.
838
+ try:
839
+ if view is not None and hasattr(view, "set_autostretch") and callable(view.set_autostretch):
840
+ view.set_autostretch(prev_autostretch)
841
+ except Exception:
842
+ pass
843
+
844
+ self._set_status("Done")
845
+ # Dialog stays open so user can apply to other images
846
+ # Refresh to use the now-active document for next operation
847
+ self._refresh_document_from_active()
848
+
849
+ except Exception as e:
850
+ self._set_status("Error")
851
+ QMessageBox.critical(self, "Apply failed", str(e))
852
+
853
+ def _refresh_document_from_active(self):
854
+ """
855
+ Refresh the dialog's document reference to the currently active document.
856
+ This allows reusing the same dialog on different images.
857
+ """
858
+ try:
859
+ main = self.parent()
860
+ if main and hasattr(main, "_active_doc"):
861
+ new_doc = main._active_doc()
862
+ if new_doc is not None and new_doc is not self.doc:
863
+ self.doc = new_doc
864
+ # Reset preview state for new document
865
+ self._preview_source_f01 = None
866
+ self._last_preview = None
867
+ self._preview_qimg = None
868
+ # Clear polygons since they were for old image
869
+ self._clear_polys()
870
+ except Exception:
871
+ pass
872
+
873
+
874
+ # ----- exclusion polygons & mask -----
875
+ def _clear_polys(self):
876
+ self._polygons.clear()
877
+ self._drawing_poly = None
878
+ # ✅ redraw from the clean base
879
+ self._redraw_overlay()
880
+
881
+ def _image_shape(self) -> tuple[int, int]:
882
+ src = np.asarray(self.doc.image)
883
+ if src.ndim == 2:
884
+ return src.shape[0], src.shape[1]
885
+ return src.shape[0], src.shape[1]
886
+
887
+ def _build_exclusion_mask(self) -> np.ndarray | None:
888
+ if not self._polygons:
889
+ return None
890
+ H, W = self._image_shape()
891
+ mask = np.ones((H, W), dtype=np.uint8)
892
+ if cv2 is None:
893
+ # very slow pure-numpy fallback: fill polygon by bounding-box rasterization
894
+ # (expect OpenCV to be available in SASpro)
895
+ for poly in self._polygons:
896
+ pts = np.array([[int(p.x()), int(p.y())] for p in poly], dtype=np.int32)
897
+ minx, maxx = np.clip([pts[:,0].min(), pts[:,0].max()], 0, W-1)
898
+ miny, maxy = np.clip([pts[:,1].min(), pts[:,1].max()], 0, H-1)
899
+ for y in range(miny, maxy+1):
900
+ for x in range(minx, maxx+1):
901
+ # winding test approx omitted -> treat as box (coarse)
902
+ mask[y, x] = 0
903
+ else:
904
+ polys = [np.array([[int(p.x()), int(p.y())] for p in poly], dtype=np.int32) for poly in self._polygons]
905
+ cv2.fillPoly(mask, polys, 0) # 0 = excluded
906
+ return mask.astype(bool)
907
+
908
+ # ----- preview rendering helpers -----
909
+
910
+ def _set_preview_pixmap(self, arr: np.ndarray):
911
+ if arr is None or arr.size == 0:
912
+ self.preview_label.clear(); self._overlay = None; self._preview_source_f01 = None
913
+ return
914
+
915
+ # keep the float source for autostretch toggling (no re-normalization)
916
+ a = _asfloat32(arr)
917
+ self._preview_source_f01 = a # ← no np.clip here
918
+
919
+ # show autostretched or raw; siril_style_autostretch() already clips its result
920
+ src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
921
+ linked=False, use_16bit=True)
922
+ if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
923
+
924
+ if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
925
+ # MONO path — match Crop: use Grayscale8 QImage; keep 3-ch backing for rebuild
926
+ mono = src_to_show if src_to_show.ndim == 2 else src_to_show[..., 0]
927
+ buf8_mono = (mono * 255.0).astype(np.uint8) # ← no np.clip here
928
+ buf8_mono = np.ascontiguousarray(buf8_mono)
929
+ h, w = buf8_mono.shape
930
+
931
+ # for the toggle/rebuild code which expects 3-ch bytes
932
+ self._last_preview = np.ascontiguousarray(np.stack([buf8_mono]*3, axis=-1))
933
+
934
+ qimg = QImage(buf8_mono.data, w, h, w, QImage.Format.Format_Grayscale8)
935
+ else:
936
+ # RGB path
937
+ buf8 = (src_to_show * 255.0).astype(np.uint8) # ← no np.clip here
938
+ buf8 = np.ascontiguousarray(buf8)
939
+ h, w, _ = buf8.shape
940
+ self._last_preview = buf8
941
+ qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
942
+
943
+ self._preview_qimg = qimg
944
+ self._update_preview_scaled()
945
+ self._redraw_overlay()
946
+
947
+
948
+ def _update_preview_scaled(self):
949
+ if self._preview_qimg is None:
950
+ self.preview_label.clear()
951
+ return
952
+
953
+ sw = max(1, int(self._preview_qimg.width() * self._preview_scale))
954
+ sh = max(1, int(self._preview_qimg.height() * self._preview_scale))
955
+
956
+ scaled = self._preview_qimg.scaled(
957
+ sw, sh,
958
+ Qt.AspectRatioMode.KeepAspectRatio,
959
+ Qt.TransformationMode.SmoothTransformation
960
+ )
961
+
962
+ # ✅ store a clean base without overlays
963
+ self._base_pixmap = QPixmap.fromImage(scaled)
964
+ self.preview_label.setPixmap(self._base_pixmap)
965
+ self.preview_label.resize(self._base_pixmap.size())
966
+
967
+ def _redraw_overlay(self):
968
+ pm_base = self._base_pixmap or self.preview_label.pixmap()
969
+ if pm_base is None:
970
+ return
971
+
972
+ # start from a fresh copy of the clean base
973
+ composed = QPixmap(pm_base)
974
+ overlay = QPixmap(pm_base.size())
975
+ overlay.fill(Qt.GlobalColor.transparent)
976
+
977
+ painter = QPainter(overlay)
978
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
979
+
980
+ # map image-space polys to label-space
981
+ img_w = self._preview_qimg.width() if self._preview_qimg else 1
982
+ img_h = self._preview_qimg.height() if self._preview_qimg else 1
983
+ lab_w = self.preview_label.width()
984
+ lab_h = self.preview_label.height()
985
+ sx = lab_w / img_w
986
+ sy = lab_h / img_h
987
+
988
+ # finalized polygons (green, semi-transparent)
989
+ pen = QPen(QColor(0, 255, 0), 2)
990
+ brush = QColor(0, 255, 0, 60)
991
+ painter.setPen(pen)
992
+ painter.setBrush(brush)
993
+ for poly in self._polygons:
994
+ if len(poly) >= 3:
995
+ mapped = [QPointF(p.x() * sx, p.y() * sy) for p in poly]
996
+ painter.drawPolygon(*mapped)
997
+
998
+ # in-progress poly (red dashed)
999
+ if self._drawing_poly and len(self._drawing_poly) >= 2:
1000
+ pen2 = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.DashLine)
1001
+ painter.setPen(pen2)
1002
+ painter.setBrush(Qt.BrushStyle.NoBrush)
1003
+ mapped = [QPointF(p.x() * sx, p.y() * sy) for p in self._drawing_poly]
1004
+ painter.drawPolyline(*mapped)
1005
+
1006
+ painter.end()
1007
+
1008
+ p = QPainter(composed)
1009
+ p.drawPixmap(0, 0, overlay)
1010
+ p.end()
1011
+
1012
+ self.preview_label.setPixmap(composed)
1013
+
1014
+ # ----- zoom/pan + polygon drawing -----
1015
+ def eventFilter(self, obj, ev):
1016
+ # ---- Robust Ctrl+Wheel zoom handling (Qt6-friendly) ----
1017
+ if ev.type() == QEvent.Type.Wheel and (
1018
+ obj is self.preview_label
1019
+ or obj is self.preview_scroll
1020
+ or obj is self.preview_scroll.viewport()
1021
+ or obj is self.preview_scroll.horizontalScrollBar()
1022
+ or obj is self.preview_scroll.verticalScrollBar()
1023
+ ):
1024
+ # always stop the wheel from scrolling
1025
+ ev.accept()
1026
+
1027
+ # Zoom only when Ctrl is held
1028
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1029
+ factor = 1.25 if ev.angleDelta().y() > 0 else 0.8
1030
+
1031
+ # Anchor at the mouse position in the viewport (even if event came from a scrollbar)
1032
+ vp = self.preview_scroll.viewport()
1033
+ anchor_vp = vp.mapFromGlobal(ev.globalPosition().toPoint())
1034
+
1035
+ # Clamp to viewport rect (robust if the event originated on scrollbars)
1036
+ r = vp.rect()
1037
+ if not r.contains(anchor_vp):
1038
+ anchor_vp.setX(max(r.left(), min(r.right(), anchor_vp.x())))
1039
+ anchor_vp.setY(max(r.top(), min(r.bottom(), anchor_vp.y())))
1040
+
1041
+ self._zoom_at(factor, anchor_vp)
1042
+ return True
1043
+
1044
+ # ---- Existing polygon drawing on the label ----
1045
+ if obj is self.preview_label:
1046
+ if ev.type() == QEvent.Type.MouseButtonPress:
1047
+ if ev.buttons() & Qt.MouseButton.RightButton:
1048
+ if self._drawing_poly and len(self._drawing_poly) >= 3:
1049
+ self._polygons.append(self._drawing_poly)
1050
+ self._drawing_poly = None
1051
+ self._redraw_overlay()
1052
+ return True
1053
+ if ev.buttons() & Qt.MouseButton.MiddleButton or (ev.buttons() & Qt.MouseButton.LeftButton and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
1054
+ self._panning = True
1055
+ self._pan_last = ev.position().toPoint()
1056
+ self.preview_label.setCursor(Qt.CursorShape.ClosedHandCursor)
1057
+ return True
1058
+ if ev.buttons() & Qt.MouseButton.LeftButton:
1059
+ img_pt = self._label_to_image_coords(ev.position())
1060
+ if img_pt is not None:
1061
+ if self._drawing_poly is None:
1062
+ self._drawing_poly = [img_pt]
1063
+ else:
1064
+ self._drawing_poly.append(img_pt)
1065
+ self._redraw_overlay()
1066
+ return True
1067
+
1068
+ elif ev.type() == QEvent.Type.MouseMove:
1069
+ if getattr(self, "_panning", False):
1070
+ pos = ev.position().toPoint()
1071
+ delta = pos - (self._pan_last or pos)
1072
+ self._pan_last = pos
1073
+ hsb = self.preview_scroll.horizontalScrollBar()
1074
+ vsb = self.preview_scroll.verticalScrollBar()
1075
+ hsb.setValue(hsb.value() - delta.x())
1076
+ vsb.setValue(vsb.value() - delta.y())
1077
+ return True
1078
+ if self._drawing_poly is not None and (ev.buttons() & Qt.MouseButton.LeftButton):
1079
+ img_pt = self._label_to_image_coords(ev.position())
1080
+ if img_pt is not None:
1081
+ self._drawing_poly.append(img_pt)
1082
+ self._redraw_overlay()
1083
+ return True
1084
+
1085
+ elif ev.type() == QEvent.Type.MouseButtonRelease:
1086
+ # finish panning
1087
+ if getattr(self, "_panning", False):
1088
+ self._panning = False
1089
+ self._pan_last = None
1090
+ self.preview_label.unsetCursor()
1091
+ return True
1092
+
1093
+ # Close polygon on LEFT mouse release
1094
+ if ev.button() == Qt.MouseButton.LeftButton and self._drawing_poly is not None:
1095
+ if len(self._drawing_poly) >= 3:
1096
+ self._polygons.append(self._drawing_poly)
1097
+ self._drawing_poly = None
1098
+ self._redraw_overlay()
1099
+ return True
1100
+
1101
+ return super().eventFilter(obj, ev)
1102
+
1103
+
1104
+
1105
+
1106
+ def _ensure_scale_state(self):
1107
+ # internal guard so _zoom_at can be called even if _scale hasn't been set
1108
+ if not hasattr(self, "_scale"):
1109
+ self._scale = float(self.view.transform().m11()) if not self.view.transform().isIdentity() else 1.0
1110
+
1111
+ def _zoom_at(self, factor: float, anchor_vp) -> None:
1112
+ """
1113
+ Zoom the preview by 'factor', keeping the content point under 'anchor_vp'
1114
+ (a QPoint in viewport coordinates) stationary.
1115
+ """
1116
+ old_scale = float(self._preview_scale)
1117
+ new_scale = max(0.05, min(old_scale * factor, 8.0))
1118
+ if abs(new_scale - old_scale) < 1e-6:
1119
+ return
1120
+ factor = new_scale / old_scale
1121
+
1122
+ # content coordinates (relative to the QLabel) under the cursor BEFORE scaling
1123
+ hsb = self.preview_scroll.horizontalScrollBar()
1124
+ vsb = self.preview_scroll.verticalScrollBar()
1125
+ old_x = hsb.value() + anchor_vp.x()
1126
+ old_y = vsb.value() + anchor_vp.y()
1127
+
1128
+ # apply scale
1129
+ self._preview_scale = new_scale
1130
+ self._update_preview_scaled()
1131
+ self._redraw_overlay()
1132
+
1133
+ # desired scroll so the same content point stays under the cursor
1134
+ new_x = int(old_x * factor - anchor_vp.x())
1135
+ new_y = int(old_y * factor - anchor_vp.y())
1136
+
1137
+ # clamp to valid range
1138
+ hsb.setValue(max(hsb.minimum(), min(new_x, hsb.maximum())))
1139
+ vsb.setValue(max(vsb.minimum(), min(new_y, vsb.maximum())))
1140
+
1141
+
1142
+ def zoom_in(self) -> None:
1143
+ vp = self.preview_scroll.viewport()
1144
+ self._zoom_at(1.25, vp.rect().center())
1145
+
1146
+ def zoom_out(self) -> None:
1147
+ vp = self.preview_scroll.viewport()
1148
+ self._zoom_at(0.8, vp.rect().center())
1149
+
1150
+ def fit_to_preview(self) -> None:
1151
+ """Set scale so the image fits inside the viewport (keeps aspect)."""
1152
+ if self._preview_qimg is None:
1153
+ return
1154
+ vp = self.preview_scroll.viewport()
1155
+ vw, vh = max(1, vp.width()), max(1, vp.height())
1156
+ iw, ih = self._preview_qimg.width(), self._preview_qimg.height()
1157
+ if iw == 0 or ih == 0:
1158
+ return
1159
+ scale = min(vw / iw, vh / ih)
1160
+ self._preview_scale = max(0.05, min(scale, 8.0))
1161
+ self._update_preview_scaled()
1162
+ self._redraw_overlay()
1163
+
1164
+ # center after fit
1165
+ hsb = self.preview_scroll.horizontalScrollBar()
1166
+ vsb = self.preview_scroll.verticalScrollBar()
1167
+ hsb.setValue((hsb.maximum() - hsb.minimum()) // 2)
1168
+ vsb.setValue((vsb.maximum() - vsb.minimum()) // 2)
1169
+
1170
+
1171
+
1172
+ def _label_to_image_coords(self, posf) -> QPointF | None:
1173
+ if self._preview_qimg is None:
1174
+ return None
1175
+ img_w = self._preview_qimg.width(); img_h = self._preview_qimg.height()
1176
+ lab_w = self.preview_label.width(); lab_h = self.preview_label.height()
1177
+ sx = img_w / max(1.0, lab_w); sy = img_h / max(1.0, lab_h)
1178
+ x_img = float(posf.x()) * sx; y_img = float(posf.y()) * sy
1179
+ # clamp to image
1180
+ x_img = max(0.0, min(x_img, img_w - 1.0))
1181
+ y_img = max(0.0, min(y_img, img_h - 1.0))
1182
+ return QPointF(x_img, y_img)
1183
+
1184
+ def _install_zoom_filters(self):
1185
+ """Install event filters so Ctrl+Wheel works even when the cursor is over scrollbars."""
1186
+ self.preview_scroll.installEventFilter(self)
1187
+ self.preview_scroll.viewport().installEventFilter(self)
1188
+ self.preview_scroll.horizontalScrollBar().installEventFilter(self)
1189
+ self.preview_scroll.verticalScrollBar().installEventFilter(self)
1190
+ self.preview_label.installEventFilter(self)
1191
+
1192
+ def _set_preview_from_float(self, arr: np.ndarray):
1193
+ if arr is None or arr.size == 0:
1194
+ return
1195
+ a = _asfloat32(arr)
1196
+ self._preview_source_f01 = a # ← no np.clip
1197
+
1198
+ src_to_show = (hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
1199
+ linked=False, use_16bit=True)
1200
+ if getattr(self, "_autostretch_on", False) else self._preview_source_f01)
1201
+
1202
+ if src_to_show.ndim == 2 or (src_to_show.ndim == 3 and src_to_show.shape[2] == 1):
1203
+ mono = src_to_show if src_to_show.ndim == 2 else src_to_show[..., 0]
1204
+ buf8_mono = (mono * 255.0).astype(np.uint8) # ← no np.clip
1205
+ buf8_mono = np.ascontiguousarray(buf8_mono)
1206
+ self._last_preview = np.ascontiguousarray(np.stack([buf8_mono]*3, axis=-1))
1207
+ h, w = buf8_mono.shape
1208
+ qimg = QImage(buf8_mono.data, w, h, w, QImage.Format.Format_Grayscale8)
1209
+ else:
1210
+ buf8 = (src_to_show * 255.0).astype(np.uint8) # ← no np.clip
1211
+ buf8 = np.ascontiguousarray(buf8)
1212
+ self._last_preview = buf8
1213
+ h, w, _ = buf8.shape
1214
+ qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
1215
+
1216
+ self._preview_qimg = qimg
1217
+ self._update_preview_scaled()
1218
+ self._redraw_overlay()
1219
+
1220
+ # --- mask helpers ---------------------------------------------------
1221
+ def _active_mask_layer(self):
1222
+ """Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
1223
+ mid = getattr(self.doc, "active_mask_id", None)
1224
+ if not mid: return None, None, None
1225
+ layer = getattr(self.doc, "masks", {}).get(mid)
1226
+ if layer is None: return None, None, None
1227
+ m = np.asarray(getattr(layer, "data", None))
1228
+ if m is None or m.size == 0: return None, None, None
1229
+ m = m.astype(np.float32, copy=False)
1230
+ if m.dtype.kind in "ui":
1231
+ m /= float(np.iinfo(m.dtype).max)
1232
+ else:
1233
+ mx = float(m.max()) if m.size else 1.0
1234
+ if mx > 1.0: m /= mx
1235
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
1236
+
1237
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
1238
+ """Nearest-neighbor resize via integer indexing."""
1239
+ mh, mw = mask.shape[:2]
1240
+ th, tw = out_hw
1241
+ if (mh, mw) == (th, tw): return mask
1242
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
1243
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
1244
+ return mask[yi][:, xi]
1245
+
1246
+ def _blend_with_mask_float(self, processed: np.ndarray, src: np.ndarray | None = None) -> np.ndarray:
1247
+ """
1248
+ m*out + (1-m)*src in float [0..1], mono or RGB.
1249
+ If src is None, uses the current document image (float [0..1]).
1250
+ """
1251
+ mask, _mid, _mname = self._active_mask_layer()
1252
+ if mask is None:
1253
+ return processed
1254
+
1255
+ out = processed.astype(np.float32, copy=False)
1256
+ if src is None:
1257
+ src = self._get_source_float()
1258
+ else:
1259
+ src = src.astype(np.float32, copy=False)
1260
+
1261
+ # match HxW
1262
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
1263
+
1264
+ # channel reconcile
1265
+ if out.ndim == 2 and src.ndim == 3:
1266
+ out = out[..., None]
1267
+ if src.ndim == 2 and out.ndim == 3:
1268
+ src = src[..., None]
1269
+
1270
+ if out.ndim == 3 and out.shape[2] == 3 and m.ndim == 2:
1271
+ m = m[..., None]
1272
+
1273
+ blended = (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
1274
+ # squeeze back to mono if we expanded
1275
+ if blended.ndim == 3 and blended.shape[2] == 1:
1276
+ blended = blended[..., 0]
1277
+ return np.clip(blended, 0.0, 1.0)
1278
+
1279
+
1280
+ def autostretch_preview(self, sigma: float = 3.0) -> None:
1281
+ """
1282
+ Toggle Siril-style MAD autostretch on the *preview only* (non-destructive).
1283
+ First press applies; second press restores the original preview.
1284
+ Works from the float [0..1] preview source to avoid double-clipping.
1285
+ """
1286
+ if self._preview_source_f01 is None and self._last_preview is None:
1287
+ return
1288
+
1289
+ # Lazy init toggle state
1290
+ if not hasattr(self, "_autostretch_on"):
1291
+ self._autostretch_on = False
1292
+ if not hasattr(self, "_orig_preview8"):
1293
+ self._orig_preview8 = None
1294
+
1295
+ def _rebuild_from_last():
1296
+ h, w = self._last_preview.shape[:2]
1297
+ ptr = sip.voidptr(self._last_preview.ctypes.data)
1298
+ qimg = QImage(ptr, w, h, self._last_preview.strides[0], QImage.Format.Format_RGB888)
1299
+ self._preview_qimg = qimg
1300
+ self._update_preview_scaled()
1301
+ self._redraw_overlay()
1302
+
1303
+ # Toggle OFF → restore original preview bytes
1304
+ if self._autostretch_on and self._orig_preview8 is not None:
1305
+ self._last_preview = np.ascontiguousarray(self._orig_preview8)
1306
+ _rebuild_from_last()
1307
+ self._autostretch_on = False
1308
+ if hasattr(self, "btn_autostr"):
1309
+ self.btn_autostr.setText("Autostretch")
1310
+ return
1311
+
1312
+ # Toggle ON → cache original and apply stretch from float source
1313
+ if self._last_preview is not None:
1314
+ self._orig_preview8 = np.ascontiguousarray(self._last_preview)
1315
+
1316
+ # Prefer float source (avoids 8-bit clipping); fall back to decoding _last_preview if needed
1317
+ arr = self._preview_source_f01 if self._preview_source_f01 is not None else (self._last_preview.astype(np.float32)/255.0)
1318
+
1319
+ stretched = hard_autostretch(arr, target_median=0.5, sigma=2, linked=False, use_16bit=True)
1320
+
1321
+ buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
1322
+ if buf8.ndim == 2:
1323
+ buf8 = np.stack([buf8] * 3, axis=-1)
1324
+ self._last_preview = np.ascontiguousarray(buf8)
1325
+
1326
+ _rebuild_from_last()
1327
+ self._autostretch_on = True
1328
+ if hasattr(self, "btn_autostr"):
1329
+ self.btn_autostr.setText("Autostretch (On)")
1330
+
1331
+
1332
+ def _apply_autostretch_inplace(self, sigma: float = 3.0):
1333
+ # Apply autostretch directly from current float preview source without toggling state.
1334
+ if self._preview_source_f01 is None:
1335
+ return
1336
+ stretched = hard_autostretch(self._preview_source_f01, target_median=0.5, sigma=2,
1337
+ linked=False, use_16bit=True)
1338
+ buf8 = (np.clip(stretched, 0.0, 1.0) * 255.0).astype(np.uint8)
1339
+ if buf8.ndim == 2:
1340
+ buf8 = np.stack([buf8] * 3, axis=-1)
1341
+ self._last_preview = np.ascontiguousarray(buf8)
1342
+ h, w = buf8.shape[:2]
1343
+ qimg = QImage(buf8.data, w, h, buf8.strides[0], QImage.Format.Format_RGB888)
1344
+ self._preview_qimg = qimg
1345
+ self._update_preview_scaled()
1346
+ self._redraw_overlay()