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