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