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,602 @@
1
+ # pro/backgroundneutral.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt, QPointF, QRectF, QEvent, QTimer
6
+ from PyQt6.QtGui import QImage, QPixmap, QPen, QColor, QIcon, QPainter
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QLabel, QGraphicsView, QGraphicsScene,
9
+ QHBoxLayout, QPushButton, QMessageBox, QGraphicsRectItem
10
+ )
11
+
12
+ # Reuse existing helpers + autostretch
13
+ from setiastro.saspro.imageops.stretch import stretch_color_image
14
+ # Shared utilities
15
+ from setiastro.saspro.widgets.image_utils import extract_mask_from_document as _active_mask_array_from_doc
16
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
17
+
18
+
19
+
20
+ # ----------------------------
21
+ # Core neutralization function
22
+ # ----------------------------
23
+ def background_neutralize_rgb(img: np.ndarray, rect_xywh: tuple[int, int, int, int]) -> np.ndarray:
24
+ """
25
+ Apply Background Neutralization to an RGB float32 image in [0,1],
26
+ using an image-space rectangle (x, y, w, h) as the sample region.
27
+ Returns a new float32 array in [0,1].
28
+ """
29
+ if img.ndim != 3 or img.shape[2] != 3:
30
+ raise ValueError("Background Neutralization requires a 3-channel RGB image.")
31
+
32
+ h, w, _ = img.shape
33
+ x, y, rw, rh = rect_xywh
34
+ x = max(0, min(int(x), w - 1))
35
+ y = max(0, min(int(y), h - 1))
36
+ rw = max(1, min(int(rw), w - x))
37
+ rh = max(1, min(int(rh), h - y))
38
+
39
+ sample = img[y:y+rh, x:x+rw, :]
40
+ medians = np.median(sample, axis=(0, 1)).astype(np.float32) # (3,)
41
+ avg_med = float(np.mean(medians))
42
+
43
+ out = img.copy()
44
+ eps = 1e-8
45
+
46
+ # Vectorized neutralization
47
+ # diff shape: (3,) -> (1, 1, 3)
48
+ diffs = (medians - avg_med).reshape(1, 1, 3)
49
+
50
+ # denom shape: (1, 1, 3)
51
+ denoms = 1.0 - diffs
52
+
53
+ # Avoid div-by-zero (vectorized)
54
+ # logic: if abs(denom) < eps, set to eps (sign matched)
55
+ # We can do this efficiently:
56
+ small_mask = np.abs(denoms) < eps
57
+ denoms[small_mask] = np.where(denoms[small_mask] >= 0, eps, -eps)
58
+
59
+ # Apply formula: (pixel - diff) / denom
60
+ out = (out - diffs) / denoms
61
+ out = np.clip(out, 0.0, 1.0)
62
+
63
+ return out.astype(np.float32, copy=False)
64
+
65
+
66
+ # ------------------------------------
67
+ # Auto background finder (SASv2 logic)
68
+ # ------------------------------------
69
+ def _find_best_patch_center(lum: np.ndarray) -> tuple[int, int]:
70
+ """Port of your downhill-walk tile search (works on a luminance plane)."""
71
+ h, w = lum.shape
72
+ th, tw = h // 10, w // 10
73
+
74
+ # Optimized: compute 10x10 tile medians using strided views where possible
75
+ # This avoids repeated slicing and is cache-friendlier
76
+ meds = np.zeros((10, 10), dtype=np.float32)
77
+
78
+ # For tiles that fit evenly, use reshape + median (faster than loop)
79
+ crop_h, crop_w = th * 10, tw * 10
80
+ if crop_h <= h and crop_w <= w:
81
+ lum_crop = lum[:crop_h, :crop_w]
82
+ # Reshape to (10, th, 10, tw) and compute medians
83
+ tiles = lum_crop.reshape(10, th, 10, tw).transpose(0, 2, 1, 3).reshape(10, 10, -1)
84
+ meds = np.median(tiles, axis=2).astype(np.float32)
85
+
86
+ # Handle edge tiles if image doesn't divide evenly
87
+ if h > crop_h or w > crop_w:
88
+ # Bottom row edge
89
+ if h > crop_h:
90
+ for j in range(10):
91
+ x0, x1 = j * tw, (j + 1) * tw if j < 9 else w
92
+ meds[9, j] = np.median(lum[9*th:h, x0:x1])
93
+ # Right column edge
94
+ if w > crop_w:
95
+ for i in range(10):
96
+ y0, y1 = i * th, (i + 1) * th if i < 9 else h
97
+ meds[i, 9] = np.median(lum[y0:y1, 9*tw:w])
98
+ else:
99
+ # Fallback for very small images
100
+ for i in range(10):
101
+ for j in range(10):
102
+ y0, x0 = i * th, j * tw
103
+ y1 = (i + 1) * th if i < 9 else h
104
+ x1 = (j + 1) * tw if j < 9 else w
105
+ meds[i, j] = np.median(lum[y0:y1, x0:x1])
106
+
107
+ idxs = np.argsort(meds.flatten())[:2]
108
+
109
+ finals = []
110
+ for idx in idxs:
111
+ ti, tj = divmod(int(idx), 10)
112
+ y0, x0 = ti * th, tj * tw
113
+ y1 = (ti + 1) * th if ti < 9 else h
114
+ x1 = (tj + 1) * tw if tj < 9 else w
115
+ for _ in range(200):
116
+ y = np.random.randint(y0, y1)
117
+ x = np.random.randint(x0, x1)
118
+ while True:
119
+ mv, mpos = lum[y, x], (y, x)
120
+ for dy in (-1, 0, 1):
121
+ for dx in (-1, 0, 1):
122
+ if dy == 0 and dx == 0:
123
+ continue
124
+ ny, nx = y + dy, x + dx
125
+ if 0 <= ny < h and 0 <= nx < w and lum[ny, nx] < mv:
126
+ mv, mpos = lum[ny, nx], (ny, nx)
127
+ if mpos == (y, x):
128
+ break
129
+ y, x = mpos
130
+ finals.append((y, x))
131
+
132
+ best_val = np.inf
133
+ best_pt = (h // 2, w // 2)
134
+ for (y, x) in finals:
135
+ y0 = max(0, y - 25); y1 = min(h, y + 25)
136
+ x0 = max(0, x - 25); x1 = min(w, x + 25)
137
+ m = np.median(lum[y0:y1, x0:x1])
138
+ if m < best_val:
139
+ best_val, best_pt = m, (y, x)
140
+ return best_pt
141
+
142
+
143
+ def auto_rect_50x50(img_rgb: np.ndarray) -> tuple[int, int, int, int]:
144
+ """
145
+ Find a robust 50×50 background rectangle (≥100 px margins) in image space.
146
+ Returns (x, y, w, h).
147
+ """
148
+ h, w, ch = img_rgb.shape
149
+ if ch != 3:
150
+ raise ValueError("Auto background finder expects a 3-channel RGB image.")
151
+ lum = img_rgb.mean(axis=2).astype(np.float32)
152
+
153
+ cy, cx = _find_best_patch_center(lum)
154
+
155
+ margin = 100
156
+ half = 25
157
+ min_cx, max_cx = margin + half, w - (margin + half)
158
+ min_cy, max_cy = margin + half, h - (margin + half)
159
+ cx = int(np.clip(cx, min_cx, max_cx))
160
+ cy = int(np.clip(cy, min_cy, max_cy))
161
+
162
+ # refine by ±half
163
+ best_val = np.inf
164
+ ty, tx = cy, cx
165
+ for dy in (-half, 0, +half):
166
+ for dx in (-half, 0, +half):
167
+ y = int(np.clip(cy + dy, min_cy, max_cy))
168
+ x = int(np.clip(cx + dx, min_cx, max_cx))
169
+ y0, y1 = y - half, y + half
170
+ x0, x1 = x - half, x + half
171
+ m = np.median(lum[y0:y1, x0:x1])
172
+ if m < best_val:
173
+ best_val, ty, tx = m, y, x
174
+
175
+ return (tx - half, ty - half, 50, 50)
176
+
177
+
178
+ # --------------------------------
179
+ # Headless apply (doc + preset in)
180
+ # --------------------------------
181
+ def apply_background_neutral_to_doc(doc, preset: dict | None = None):
182
+ """
183
+ Headless entrypoint (used by DnD shortcuts).
184
+ Preset schema:
185
+ {
186
+ "mode": "auto" | "rect",
187
+ # rect in normalized coords if mode == "rect"
188
+ "rect_norm": [x0, y0, w, h] # each in 0..1
189
+ }
190
+ Defaults to {"mode": "auto"}.
191
+ """
192
+ import numpy as np
193
+
194
+ if preset is None:
195
+ preset = {}
196
+ mode = (preset.get("mode") or "auto").lower()
197
+
198
+ base = np.asarray(doc.image).astype(np.float32, copy=False)
199
+ if base.size == 0:
200
+ raise ValueError("Empty image.")
201
+
202
+ # Defensive normalization (should already be [0,1] in SASpro)
203
+ maxv = float(np.nanmax(base))
204
+ if maxv > 1.0 and np.isfinite(maxv):
205
+ base = base / maxv
206
+
207
+ if base.ndim != 3 or base.shape[2] != 3:
208
+ raise ValueError("Background Neutralization currently supports RGB images.")
209
+
210
+ if mode == "rect":
211
+ rn = preset.get("rect_norm")
212
+ if not rn or len(rn) != 4:
213
+ raise ValueError("rect mode requires rect_norm=[x,y,w,h] in normalized coords.")
214
+ H, W, _ = base.shape
215
+ x = int(np.clip(rn[0], 0, 1) * W)
216
+ y = int(np.clip(rn[1], 0, 1) * H)
217
+ w = int(np.clip(rn[2], 0, 1) * W)
218
+ h = int(np.clip(rn[3], 0, 1) * H)
219
+ rect = (x, y, max(w, 1), max(h, 1))
220
+ else:
221
+ rect = auto_rect_50x50(base)
222
+
223
+ out = background_neutralize_rgb(base, rect)
224
+
225
+ # Destination-mask blend (mask lives on the destination doc)
226
+ m = _active_mask_array_from_doc(doc)
227
+ if m is not None:
228
+ if out.ndim == 3:
229
+ m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32, copy=False)
230
+ else:
231
+ m3 = m.astype(np.float32, copy=False)
232
+ base_for_blend = np.asarray(doc.image).astype(np.float32, copy=False)
233
+ bmax = float(np.nanmax(base_for_blend))
234
+ if bmax > 1.0 and np.isfinite(bmax):
235
+ base_for_blend /= bmax
236
+ out = base_for_blend * (1.0 - m3) + out * m3
237
+
238
+ doc.apply_edit(
239
+ out.astype(np.float32, copy=False),
240
+ metadata={"step_name": "Background Neutralization", "preset": preset},
241
+ step_name="Background Neutralization",
242
+ )
243
+
244
+
245
+ # -------------------------
246
+ # Interactive BN dialog UI
247
+ # -------------------------
248
+ class BackgroundNeutralizationDialog(QDialog):
249
+ def __init__(self, parent, doc, icon: QIcon | None = None):
250
+ super().__init__(parent)
251
+ self._main = parent
252
+ self.doc = doc
253
+
254
+ # Connect to active document change signal
255
+ if hasattr(self._main, "currentDocumentChanged"):
256
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
257
+
258
+ if icon:
259
+ self.setWindowIcon(icon)
260
+ self.setWindowTitle(self.tr("Background Neutralization"))
261
+ self.resize(900, 600)
262
+
263
+ self.setWindowFlag(Qt.WindowType.Window, True)
264
+ # Non-modal: allow user to switch between images while dialog is open
265
+ self.setWindowModality(Qt.WindowModality.NonModal)
266
+ self.setModal(False)
267
+ #self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
268
+
269
+ self.auto_stretch = False
270
+ self.zoom_factor = 1.0
271
+ self._user_zoomed = False
272
+
273
+ # --- scene / view ---
274
+ self.scene = QGraphicsScene(self)
275
+ self.graphics_view = QGraphicsView(self)
276
+ self.graphics_view.setScene(self.scene)
277
+ self.graphics_view.setRenderHints(
278
+ QPainter.RenderHint.Antialiasing |
279
+ QPainter.RenderHint.SmoothPixmapTransform
280
+ )
281
+ self.graphics_view.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
282
+ self.graphics_view.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorViewCenter)
283
+
284
+ # --- main layout ---
285
+ layout = QVBoxLayout(self)
286
+ instruction = QLabel("Draw a sample box or click ‘Find Background’ to auto-select.")
287
+ layout.addWidget(instruction)
288
+ layout.addWidget(self.graphics_view, 1)
289
+
290
+ # Buttons row
291
+ btn_row = QHBoxLayout()
292
+ self.btn_apply = QPushButton(self.tr("Apply Neutralization"))
293
+ self.btn_cancel = QPushButton(self.tr("Cancel"))
294
+ self.btn_toggle_stretch = QPushButton(self.tr("Enable Auto-Stretch"))
295
+ self.btn_find_bg = QPushButton(self.tr("Find Background"))
296
+ btn_row.addWidget(self.btn_apply)
297
+ btn_row.addWidget(self.btn_cancel)
298
+ btn_row.addWidget(self.btn_toggle_stretch)
299
+ btn_row.addWidget(self.btn_find_bg)
300
+ layout.addLayout(btn_row)
301
+
302
+ # Zoom row
303
+ # Zoom row (standardized themed toolbuttons)
304
+ zoom_row = QHBoxLayout()
305
+
306
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
307
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to View")
308
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
309
+
310
+ zoom_row.addWidget(self.btn_zoom_out)
311
+ zoom_row.addWidget(self.btn_fit)
312
+ zoom_row.addWidget(self.btn_zoom_in)
313
+ zoom_row.addStretch(1) # optional: keeps them left-aligned
314
+
315
+ layout.addLayout(zoom_row)
316
+
317
+ # Events
318
+ self.btn_apply.clicked.connect(self._on_apply)
319
+ self.btn_cancel.clicked.connect(self.reject)
320
+ self.btn_toggle_stretch.clicked.connect(self._toggle_auto_stretch)
321
+ self.btn_find_bg.clicked.connect(self._on_find_background)
322
+ self.btn_zoom_out.clicked.connect(self.zoom_out)
323
+ self.btn_fit.clicked.connect(self.fit_to_view)
324
+ self.btn_zoom_in.clicked.connect(self.zoom_in)
325
+
326
+ self.graphics_view.viewport().installEventFilter(self)
327
+ self.origin_scene = QPointF()
328
+ self.current_rect_scene = QRectF()
329
+ self.selection_item: QGraphicsRectItem | None = None
330
+ self.drawing = False
331
+
332
+ self._load_image()
333
+
334
+ # ---- active document change ------------------------------------
335
+ def _on_active_doc_changed(self, doc):
336
+ """Called when user clicks a different image window."""
337
+ if doc is None or getattr(doc, "image", None) is None:
338
+ return
339
+ self.doc = doc
340
+ self.selection_item = None
341
+ self._load_image()
342
+
343
+ # ---------- image display ----------
344
+ def _doc_image_normalized(self) -> np.ndarray:
345
+ import numpy as np
346
+ img = np.asarray(self.doc.image).astype(np.float32, copy=False)
347
+ if img.size == 0:
348
+ return img
349
+ m = float(np.nanmax(img))
350
+ if m > 1.0 and np.isfinite(m):
351
+ img = img / m
352
+ return img
353
+
354
+ def _load_image(self):
355
+ self.scene.clear()
356
+ self.selection_item = None
357
+
358
+ img = self._doc_image_normalized()
359
+ if img is None or img.size == 0:
360
+ QMessageBox.warning(self, "No Image", "Open an image first.")
361
+ self.reject()
362
+ return
363
+
364
+ disp = img.copy()
365
+ if self.auto_stretch and disp.ndim == 3 and disp.shape[2] == 3:
366
+ disp = stretch_color_image(disp, 0.25, linked=False, normalize=False)
367
+
368
+ # Build QImage/QPixmap
369
+ if disp.ndim == 2:
370
+ h, w = disp.shape
371
+ qimg = QImage((disp * 255).astype(np.uint8).tobytes(), w, h, w, QImage.Format.Format_Grayscale8)
372
+ else:
373
+ h, w, _ = disp.shape
374
+ qimg = QImage((disp * 255).astype(np.uint8).tobytes(), w, h, 3 * w, QImage.Format.Format_RGB888)
375
+
376
+ pix = QPixmap.fromImage(qimg)
377
+
378
+ # Add to scene; force scene rect to native image pixels and place at (0,0)
379
+ self.scene.clear()
380
+ self.selection_item = None
381
+ self.pixmap_item = self.scene.addPixmap(pix)
382
+ self.pixmap_item.setPos(0, 0)
383
+ self.scene.setSceneRect(0, 0, pix.width(), pix.height())
384
+
385
+ # Reset and fit (this sets initial view, later showEvent/resizeEvent will refit)
386
+ self.graphics_view.resetTransform()
387
+ self.graphics_view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
388
+ self.zoom_factor = 1.0
389
+ self._user_zoomed = False
390
+
391
+ def _toggle_auto_stretch(self):
392
+ self.auto_stretch = not self.auto_stretch
393
+ self.btn_toggle_stretch.setText("Disable Auto-Stretch" if self.auto_stretch else "Enable Auto-Stretch")
394
+ self._load_image()
395
+
396
+ # ---------- zoom ----------
397
+ def eventFilter(self, source, event):
398
+ if source is self.graphics_view.viewport():
399
+ et = event.type()
400
+ if et == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton:
401
+ self.drawing = True
402
+ self.origin_scene = self.graphics_view.mapToScene(event.pos())
403
+ if self.selection_item:
404
+ self.scene.removeItem(self.selection_item)
405
+ self.selection_item = None
406
+ elif et == QEvent.Type.MouseMove and self.drawing:
407
+ cur = self.graphics_view.mapToScene(event.pos())
408
+ self.current_rect_scene = QRectF(self.origin_scene, cur).normalized()
409
+ if self.selection_item:
410
+ self.scene.removeItem(self.selection_item)
411
+ pen = QPen(QColor(0, 255, 0), 2, Qt.PenStyle.DashLine)
412
+ self.selection_item = self.scene.addRect(self.current_rect_scene, pen)
413
+ elif et == QEvent.Type.MouseButtonRelease and event.button() == Qt.MouseButton.LeftButton and self.drawing:
414
+ self.drawing = False
415
+ cur = self.graphics_view.mapToScene(event.pos())
416
+ self.current_rect_scene = QRectF(self.origin_scene, cur).normalized()
417
+ if self.selection_item:
418
+ self.scene.removeItem(self.selection_item)
419
+ if self.current_rect_scene.width() < 10 or self.current_rect_scene.height() < 10:
420
+ QMessageBox.warning(self, "Selection Too Small", "Please draw a larger selection box.")
421
+ self.selection_item = None
422
+ self.current_rect_scene = QRectF()
423
+ else:
424
+ pen = QPen(QColor(255, 0, 0), 2, Qt.PenStyle.SolidLine)
425
+ self.selection_item = self.scene.addRect(self.current_rect_scene, pen)
426
+ return super().eventFilter(source, event)
427
+
428
+ def _on_find_background(self):
429
+ img = self._doc_image_normalized()
430
+ if img.ndim != 3 or img.shape[2] != 3:
431
+ QMessageBox.warning(self, "Not RGB", "Background Neutralization supports RGB images.")
432
+ return
433
+
434
+ x, y, w, h = auto_rect_50x50(img)
435
+
436
+ if self.selection_item:
437
+ self.scene.removeItem(self.selection_item)
438
+
439
+ pen = QPen(QColor(255, 215, 0), 2) # gold
440
+ rect_scene = QRectF(float(x), float(y), float(w), float(h)) # scene == image pixels now
441
+ self.selection_item = self.scene.addRect(rect_scene, pen)
442
+ self.current_rect_scene = rect_scene
443
+
444
+ def _scene_rect_to_image_rect(self) -> tuple[int, int, int, int]:
445
+ if not self.current_rect_scene or self.current_rect_scene.isNull():
446
+ raise ValueError("No selection rectangle defined.")
447
+
448
+ # Scene == image pixels (because we setSceneRect to pixmap bounds)
449
+ bounds = self.pixmap_item.boundingRect()
450
+ W = int(bounds.width())
451
+ H = int(bounds.height())
452
+
453
+ x = int(max(0.0, min(bounds.width(), self.current_rect_scene.left())))
454
+ y = int(max(0.0, min(bounds.height(), self.current_rect_scene.top())))
455
+ w = int(max(1.0, min(bounds.width() - x, self.current_rect_scene.width())))
456
+ h = int(max(1.0, min(bounds.height() - y, self.current_rect_scene.height())))
457
+ return (x, y, w, h)
458
+
459
+ def _on_apply(self):
460
+ try:
461
+ rect = self._scene_rect_to_image_rect()
462
+ except Exception as e:
463
+ QMessageBox.warning(self, "No Selection", str(e))
464
+ return
465
+
466
+ img = self._doc_image_normalized()
467
+ if img.ndim != 3 or img.shape[2] != 3:
468
+ QMessageBox.warning(self, "Not RGB", "Background Neutralization supports RGB images.")
469
+ return
470
+
471
+ out = background_neutralize_rgb(img, rect)
472
+
473
+ # Destination-mask blend
474
+ m = _active_mask_array_from_doc(self.doc)
475
+ if m is not None:
476
+ if out.ndim == 3:
477
+ m3 = np.repeat(m[..., None], 3, axis=2).astype(np.float32, copy=False)
478
+ else:
479
+ m3 = m.astype(np.float32, copy=False)
480
+ base_for_blend = self._doc_image_normalized()
481
+ out = base_for_blend * (1.0 - m3) + out * m3
482
+
483
+ # ---------- Build preset for Replay Last ----------
484
+ preset = None
485
+ try:
486
+ H, W = img.shape[:2]
487
+ x, y, w, h = rect
488
+ if W > 0 and H > 0:
489
+ rect_norm = [
490
+ float(x) / float(W),
491
+ float(y) / float(H),
492
+ float(w) / float(W),
493
+ float(h) / float(H),
494
+ ]
495
+ else:
496
+ rect_norm = [0.0, 0.0, 1.0, 1.0]
497
+
498
+ preset = {"mode": "rect", "rect_norm": rect_norm}
499
+
500
+ # Walk up parent chain until we find the main window that carries
501
+ # _last_headless_command
502
+ main = self.parent()
503
+ while main is not None and not hasattr(main, "_last_headless_command"):
504
+ main = main.parent()
505
+
506
+ if main is not None:
507
+ try:
508
+ main._last_headless_command = {
509
+ "command_id": "background_neutral",
510
+ "preset": preset,
511
+ }
512
+ if hasattr(main, "_log"):
513
+ main._log(
514
+ "[Replay] Recorded background_neutral "
515
+ f"(mode=rect, rect_norm={rect_norm})"
516
+ )
517
+ except Exception:
518
+ pass
519
+ except Exception:
520
+ # Fallback: at least record mode
521
+ if preset is None:
522
+ preset = {"mode": "rect"}
523
+
524
+ # ---------- Apply edit (include preset in metadata) ----------
525
+ meta = {
526
+ "step_name": "Background Neutralization",
527
+ "rect": rect,
528
+ }
529
+ if preset is not None:
530
+ meta["preset"] = preset
531
+
532
+ self.doc.apply_edit(
533
+ out.astype(np.float32, copy=False),
534
+ metadata=meta,
535
+ step_name="Background Neutralization",
536
+ )
537
+ # Dialog stays open so user can apply to other images
538
+ # Refresh to use the now-active document for next operation
539
+ self._refresh_document_from_active()
540
+
541
+ def _refresh_document_from_active(self):
542
+ """
543
+ Refresh the dialog's document reference to the currently active document.
544
+ This allows reusing the same dialog on different images.
545
+ """
546
+ try:
547
+ main = self.parent()
548
+ if main and hasattr(main, "_active_doc"):
549
+ new_doc = main._active_doc()
550
+ if new_doc is not None and new_doc is not self.doc:
551
+ self.doc = new_doc
552
+ # Refresh the preview image
553
+ self._load_preview()
554
+ except Exception:
555
+ pass
556
+
557
+ def _zoom(self, factor: float):
558
+ self._user_zoomed = True
559
+ cur = self.graphics_view.transform().m11()
560
+ new_scale = cur * factor
561
+ if new_scale < 0.01 or new_scale > 100.0:
562
+ return
563
+ self.graphics_view.scale(factor, factor)
564
+
565
+ def zoom_in(self):
566
+ self._zoom(1.25)
567
+
568
+ def zoom_out(self):
569
+ self._zoom(0.8)
570
+
571
+ def fit_to_view(self):
572
+ self._user_zoomed = False
573
+ self.graphics_view.resetTransform()
574
+ # Fit the pixmap bounds (not a default huge scene)
575
+ if hasattr(self, "pixmap_item") and self.pixmap_item is not None:
576
+ self.graphics_view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio)
577
+
578
+ def showEvent(self, e):
579
+ super().showEvent(e)
580
+ # fit after the widget is actually visible
581
+ QTimer.singleShot(0, self.fit_to_view)
582
+
583
+ def resizeEvent(self, e):
584
+ super().resizeEvent(e)
585
+ # keep it fitted while the user hasn't manually zoomed
586
+ if not self._user_zoomed:
587
+ self.fit_to_view()
588
+
589
+ from setiastro.saspro.headless_utils import normalize_headless_main, unwrap_docproxy
590
+
591
+ def run_background_neutral_via_preset(main, preset=None, target_doc=None):
592
+ from PyQt6.QtWidgets import QMessageBox
593
+ from setiastro.saspro.backgroundneutral import apply_background_neutral_to_doc
594
+
595
+ p = dict(preset or {})
596
+ main, doc, _dm = normalize_headless_main(main, target_doc)
597
+
598
+ if doc is None or getattr(doc, "image", None) is None:
599
+ QMessageBox.warning(main or None, "Background Neutralization", "Load an image first.")
600
+ return
601
+
602
+ apply_background_neutral_to_doc(doc, p)