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.
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,658 @@
1
+ # pro/wavescalede.py
2
+ from __future__ import annotations
3
+ import math
4
+ import numpy as np
5
+
6
+ from PyQt6.QtCore import Qt, QObject, pyqtSignal, QThread, QTimer
7
+ from PyQt6.QtGui import QImage, QPixmap, QIcon, QWheelEvent
8
+ from PyQt6.QtWidgets import (
9
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QPushButton,
10
+ QSlider, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QScrollArea,
11
+ QMessageBox, QProgressBar, QMainWindow
12
+ )
13
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
14
+
15
+ # Import shared wavelet utilities
16
+ from setiastro.saspro.widgets.wavelet_utils import (
17
+ conv_sep_reflect as _conv_sep_reflect,
18
+ build_spaced_kernel as _build_spaced_kernel,
19
+ atrous_decompose as _atrous_decompose,
20
+ atrous_reconstruct as _atrous_reconstruct,
21
+ rgb_to_lab as _rgb_to_lab,
22
+ lab_to_rgb as _lab_to_rgb,
23
+ gauss_blur as _gauss_blur,
24
+ B3_KERNEL as _B3,
25
+ )
26
+
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+ # Core math (shared)
29
+ # ─────────────────────────────────────────────────────────────────────────────
30
+
31
+ def _resize_mask_nn(mask2d: np.ndarray, target_hw: tuple[int, int]) -> np.ndarray:
32
+ H, W = target_hw
33
+ if mask2d.shape == (H, W):
34
+ return mask2d.astype(np.float32, copy=False)
35
+ yi = (np.linspace(0, mask2d.shape[0] - 1, H)).astype(np.int32)
36
+ xi = (np.linspace(0, mask2d.shape[1] - 1, W)).astype(np.int32)
37
+ return mask2d[yi][:, xi].astype(np.float32, copy=False)
38
+
39
+ # Darkness mask (scales 2–4, negative parts, mean → normalize → gamma → smooth → mild S-curve)
40
+ def _darkness_mask(L: np.ndarray, n_scales: int, base_k: np.ndarray, gamma: float) -> np.ndarray:
41
+ planes = _atrous_decompose(L, n_scales, base_k)
42
+ # mid-scales: 1:4 (0-based → skip 0)
43
+ sel = planes[1:4]
44
+ neg = [np.clip(-p, 0, None) for p in sel]
45
+ if len(neg) == 0:
46
+ m = np.zeros_like(L, dtype=np.float32)
47
+ else:
48
+ combined = np.mean(neg, axis=0).astype(np.float32)
49
+ denom = float(np.max(combined) + 1e-8)
50
+ m = combined / denom
51
+ if gamma != 1.0:
52
+ m = np.power(m, float(gamma), dtype=np.float32)
53
+ m = _gauss_blur(m, sigma=3.0).astype(np.float32)
54
+ # gentle brighten of mids
55
+ m = np.clip(1.5 * m - 0.5 * (m * m), 0.0, 1.0).astype(np.float32)
56
+ return m
57
+
58
+ # Main compute (mono or RGB)
59
+ def compute_wavescale_dse(image: np.ndarray,
60
+ n_scales: int = 6,
61
+ boost_factor: float = 5.0,
62
+ mask_gamma: float = 1.0,
63
+ iterations: int = 2,
64
+ base_kernel: np.ndarray = _B3,
65
+ decay_rate: float = 0.5,
66
+ external_mask: np.ndarray | None = None # ← NEW
67
+ ) -> tuple[np.ndarray, np.ndarray]:
68
+ """
69
+ WaveScale Dark Enhancer.
70
+ Returns (output_image, darkness_mask_used).
71
+ If external_mask is provided (2-D [0..1]), it will be multiplied into the darkness mask.
72
+ """
73
+ arr = np.asarray(image, dtype=np.float32)
74
+
75
+ # normalize external mask now
76
+ ext = None
77
+ if external_mask is not None:
78
+ m = np.asarray(external_mask)
79
+ if m.ndim == 3: # collapse RGB(A)
80
+ m = m.mean(axis=2)
81
+ m = np.clip(m.astype(np.float32), 0.0, 1.0)
82
+ ext = _resize_mask_nn(m, arr.shape[:2])
83
+
84
+ if arr.ndim == 2 or (arr.ndim == 3 and arr.shape[2] == 1):
85
+ L = arr.squeeze().astype(np.float32, copy=True) # [0..1]
86
+ mask = np.zeros_like(L, dtype=np.float32) # define for return
87
+ for it in range(int(iterations)):
88
+ mask = _darkness_mask(L, n_scales, base_kernel, mask_gamma)
89
+ if ext is not None:
90
+ mask = np.clip(mask * ext, 0.0, 1.0) # ← combine here
91
+
92
+ planes = _atrous_decompose(L, n_scales, base_kernel)
93
+ residual = planes.pop()
94
+ for i in range(len(planes)):
95
+ if i == 0:
96
+ continue # skip highest frequency
97
+ decay = decay_rate ** i
98
+ neg = np.clip(-planes[i], 0, None)
99
+ enhancement = neg * mask * (boost_factor - 1.0) * decay
100
+ planes[i] = planes[i] - enhancement
101
+ L = np.clip(_atrous_reconstruct(planes + [residual]), 0.0, 1.0)
102
+
103
+ out = L.astype(np.float32, copy=False)
104
+ return out, mask.astype(np.float32, copy=False)
105
+
106
+ # RGB path
107
+ rgb = np.clip(arr[:, :, :3], 0.0, 1.0).astype(np.float32, copy=False)
108
+ lab = _rgb_to_lab(rgb)
109
+ L = lab[..., 0].astype(np.float32, copy=True)
110
+ mask = np.zeros(L.shape, dtype=np.float32) # define for return
111
+ for it in range(int(iterations)):
112
+ mask = _darkness_mask(np.clip(L / 100.0, 0.0, 1.0), n_scales, base_kernel, mask_gamma)
113
+ if ext is not None:
114
+ mask = np.clip(mask * ext, 0.0, 1.0) # ← combine here
115
+
116
+ planes = _atrous_decompose(L, n_scales, base_kernel)
117
+ residual = planes.pop()
118
+ for i in range(len(planes)):
119
+ if i == 0:
120
+ continue
121
+ decay = decay_rate ** i
122
+ neg = np.clip(-planes[i], 0, None)
123
+ enhancement = neg * mask * (boost_factor - 1.0) * decay
124
+ planes[i] = planes[i] - enhancement
125
+ L = np.clip(_atrous_reconstruct(planes + [residual]), 0.0, 100.0)
126
+
127
+ lab[..., 0] = L
128
+ out_rgb = _lab_to_rgb(lab)
129
+ return out_rgb.astype(np.float32, copy=False), mask.astype(np.float32, copy=False)
130
+
131
+ # ─────────────────────────────────────────────────────────────────────────────
132
+ # Worker
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+ class DSEWorker(QObject):
135
+ progress_update = pyqtSignal(str, int)
136
+ finished = pyqtSignal(np.ndarray, np.ndarray) # (output, mask)
137
+
138
+ def __init__(self, image: np.ndarray, n_scales: int, boost: float, gamma: float,
139
+ base_kernel: np.ndarray, iterations: int,
140
+ external_mask: np.ndarray | None = None):
141
+ super().__init__()
142
+ self.image = image
143
+ self.n_scales = n_scales
144
+ self.boost = boost
145
+ self.gamma = gamma
146
+ self.base_kernel = base_kernel
147
+ self.iterations = iterations
148
+ self.external_mask = external_mask
149
+
150
+ def run(self):
151
+ try:
152
+ self.progress_update.emit(self.tr("Analyzing dark structure…"), 20)
153
+ out, mask = compute_wavescale_dse(
154
+ self.image, self.n_scales, self.boost, self.gamma,
155
+ self.iterations, self.base_kernel,
156
+ external_mask=self.external_mask # ← NEW
157
+ )
158
+ self.progress_update.emit(self.tr("Finalizing…"), 95)
159
+ self.finished.emit(out, mask)
160
+ except Exception as e:
161
+ print("WaveScale DSE error:", e)
162
+ self.finished.emit(None, None)
163
+
164
+ # ─────────────────────────────────────────────────────────────────────────────
165
+ # Small mask window (fixed ~400×400, always shows a zoomed-out mask)
166
+ # ─────────────────────────────────────────────────────────────────────────────
167
+ class _MaskWindow(QDialog):
168
+ def __init__(self, parent=None):
169
+ super().__init__(parent)
170
+ self.setWindowTitle(self.tr("Dark Mask"))
171
+ self.setMinimumSize(300, 300)
172
+ self.resize(400, 400)
173
+ v = QVBoxLayout(self)
174
+ self.lbl = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
175
+ v.addWidget(self.lbl)
176
+
177
+ def set_mask(self, mask: np.ndarray):
178
+ m = np.clip(mask, 0, 1).astype(np.float32)
179
+ m8 = (m * 255.0).astype(np.uint8)
180
+ if m8.ndim == 2:
181
+ h, w = m8.shape
182
+ q = QImage(m8.data, w, h, w, QImage.Format.Format_Grayscale8)
183
+ else:
184
+ h, w, _ = m8.shape
185
+ q = QImage(m8.data, w, h, 3*w, QImage.Format.Format_RGB888)
186
+ pm = QPixmap.fromImage(q)
187
+ box = self.size()
188
+ pm2 = pm.scaled(box, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
189
+ self.lbl.setPixmap(pm2)
190
+
191
+ # ─────────────────────────────────────────────────────────────────────────────
192
+ # Dialog
193
+ # ─────────────────────────────────────────────────────────────────────────────
194
+ class WaveScaleDarkEnhancerDialogPro(QDialog):
195
+ def __init__(self, parent, doc, icon_path: str | None = None):
196
+ super().__init__(parent)
197
+ self.setWindowTitle(self.tr("WaveScale Dark Enhancer"))
198
+ if icon_path:
199
+ try: self.setWindowIcon(QIcon(icon_path))
200
+ except Exception as e:
201
+ import logging
202
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
203
+ self.resize(980, 700)
204
+
205
+ self._doc = doc
206
+ base = getattr(doc, "image", None)
207
+ if base is None:
208
+ raise RuntimeError("Active document has no image.")
209
+
210
+ img = np.asarray(base, dtype=np.float32)
211
+ if img.ndim == 2:
212
+ self._was_mono = True
213
+ self._mono_shape = img.shape
214
+ rgb = np.repeat(img[:, :, None], 3, axis=2)
215
+ elif img.ndim == 3 and img.shape[2] == 1:
216
+ self._was_mono = True
217
+ self._mono_shape = img.shape
218
+ rgb = np.repeat(img, 3, axis=2)
219
+ else:
220
+ self._was_mono = False
221
+ self._mono_shape = None
222
+ rgb = img[:, :, :3]
223
+ if img.dtype.kind in "ui":
224
+ maxv = float(np.nanmax(rgb)) or 1.0
225
+ rgb = rgb / max(1.0, maxv)
226
+ rgb = np.clip(rgb, 0.0, 1.0).astype(np.float32, copy=False)
227
+
228
+ self.original = rgb
229
+ self.preview = rgb.copy()
230
+
231
+ # scene/view
232
+ self.scene = QGraphicsScene(self)
233
+ self.view = QGraphicsView(self.scene)
234
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
235
+ self.pix = QGraphicsPixmapItem()
236
+ self.scene.addItem(self.pix)
237
+ self.scroll = QScrollArea(self); self.scroll.setWidgetResizable(True); self.scroll.setWidget(self.view)
238
+
239
+ # zoom state
240
+ self.zoom_factor = 1.0
241
+ self.zoom_step = 1.25
242
+ self.zoom_min = 0.1
243
+ self.zoom_max = 5.0
244
+
245
+ # controls
246
+ self.grp = QGroupBox(self.tr("Dark Enhancer Controls"))
247
+ form = QFormLayout(self.grp)
248
+
249
+ self.s_scales = QSlider(Qt.Orientation.Horizontal); self.s_scales.setRange(2, 10); self.s_scales.setValue(6)
250
+ self.s_boost = QSlider(Qt.Orientation.Horizontal); self.s_boost.setRange(10, 1000); self.s_boost.setValue(500) # 0.10..10.00
251
+ self.s_gamma = QSlider(Qt.Orientation.Horizontal); self.s_gamma.setRange(10, 1000); self.s_gamma.setValue(100) # 0.10..10.00
252
+ self.s_iters = QSlider(Qt.Orientation.Horizontal); self.s_iters.setRange(1, 10); self.s_iters.setValue(2)
253
+
254
+ form.addRow(self.tr("Number of Scales:"), self.s_scales)
255
+ form.addRow(self.tr("Boost Factor:"), self.s_boost)
256
+ form.addRow(self.tr("Mask Gamma:"), self.s_gamma)
257
+ form.addRow(self.tr("Iterations:"), self.s_iters)
258
+
259
+ row = QHBoxLayout()
260
+ self.btn_preview = QPushButton(self.tr("Preview"))
261
+ self.btn_toggle = QPushButton(self.tr("Show Original")); self.btn_toggle.setCheckable(True)
262
+ row.addWidget(self.btn_preview); row.addWidget(self.btn_toggle)
263
+ form.addRow(row)
264
+
265
+ # progress
266
+ self.prog_grp = QGroupBox(self.tr("Progress"))
267
+ vprog = QVBoxLayout(self.prog_grp)
268
+ self.lbl_step = QLabel(self.tr("Idle"))
269
+ self.bar = QProgressBar(); self.bar.setRange(0, 100); self.bar.setValue(0)
270
+ vprog.addWidget(self.lbl_step); vprog.addWidget(self.bar)
271
+
272
+ # bottom
273
+ bot = QHBoxLayout()
274
+ self.btn_apply = QPushButton(self.tr("Apply to Document")); self.btn_apply.setEnabled(False)
275
+ self.btn_reset = QPushButton(self.tr("Reset"))
276
+ self.btn_close = QPushButton(self.tr("Close"))
277
+ bot.addStretch(1); bot.addWidget(self.btn_apply); bot.addWidget(self.btn_reset); bot.addWidget(self.btn_close)
278
+
279
+ # layout
280
+ main = QVBoxLayout(self)
281
+ main.addWidget(self.scroll)
282
+
283
+ zoom_box = QGroupBox(self.tr("Zoom Controls"))
284
+ zr = QHBoxLayout(zoom_box)
285
+ self.btn_zin = QPushButton(self.tr("Zoom In"))
286
+ self.btn_zout = QPushButton(self.tr("Zoom Out"))
287
+ self.btn_fit = QPushButton(self.tr("Fit to Preview"))
288
+ zr.addWidget(self.btn_zin); zr.addWidget(self.btn_zout); zr.addWidget(self.btn_fit)
289
+ main.addWidget(zoom_box)
290
+
291
+ h = QHBoxLayout()
292
+ h.addWidget(self.grp, 3)
293
+ h.addWidget(self.prog_grp, 1)
294
+ main.addLayout(h)
295
+ main.addLayout(bot)
296
+
297
+ # mask window (show immediately)
298
+ self.mask_win = _MaskWindow(self); self.mask_win.show()
299
+
300
+ # kernel
301
+ self.base_kernel = _B3
302
+
303
+ # connections
304
+ self.btn_preview.clicked.connect(self._start_preview)
305
+ self.btn_apply.clicked.connect(self._apply_to_doc)
306
+ self.btn_close.clicked.connect(self.reject)
307
+ self.btn_reset.clicked.connect(self._reset)
308
+ self.btn_toggle.clicked.connect(self._toggle)
309
+
310
+ self.btn_zin.clicked.connect(self._zoom_in)
311
+ self.btn_zout.clicked.connect(self._zoom_out)
312
+ self.btn_fit.clicked.connect(self._fit_to_preview)
313
+
314
+ # gamma debounce → live mask updates (250ms)
315
+ self._gamma_timer = QTimer(self)
316
+ self._gamma_timer.setSingleShot(True)
317
+ self._gamma_timer.timeout.connect(self._update_mask_only)
318
+ self.s_gamma.valueChanged.connect(lambda _v: self._gamma_timer.start(250))
319
+
320
+ # init preview & initial mask
321
+ self._set_pix(self.preview)
322
+ self._update_mask_only()
323
+
324
+ def _combine_with_doc_mask(self, op_mask: np.ndarray | None) -> np.ndarray | None:
325
+ doc_m = self._get_doc_active_mask_2d()
326
+ if doc_m is None:
327
+ return op_mask
328
+ if op_mask is None:
329
+ return doc_m
330
+ H, W = op_mask.shape[:2]
331
+ if doc_m.shape != (H, W):
332
+ yi = (np.linspace(0, doc_m.shape[0] - 1, H)).astype(np.int32)
333
+ xi = (np.linspace(0, doc_m.shape[1] - 1, W)).astype(np.int32)
334
+ doc_m = doc_m[yi][:, xi]
335
+ return np.clip(op_mask * doc_m, 0.0, 1.0)
336
+
337
+ def _get_doc_active_mask_2d(self) -> np.ndarray | None:
338
+ """
339
+ Return active document mask as 2-D float32 [0..1], resized to current image.
340
+ """
341
+ doc = getattr(self, "_doc", None)
342
+ if doc is None:
343
+ return None
344
+
345
+ mid = getattr(doc, "active_mask_id", None)
346
+ if not mid:
347
+ return None
348
+
349
+ masks = getattr(doc, "masks", {}) or {}
350
+ layer = masks.get(mid)
351
+ if layer is None:
352
+ return None
353
+
354
+ # pick first non-None payload without boolean 'or'
355
+ data = None
356
+ for attr in ("data", "mask", "image", "array"):
357
+ if hasattr(layer, attr):
358
+ val = getattr(layer, attr)
359
+ if val is not None:
360
+ data = val
361
+ break
362
+ if data is None and isinstance(layer, dict):
363
+ for key in ("data", "mask", "image", "array"):
364
+ if key in layer and layer[key] is not None:
365
+ data = layer[key]
366
+ break
367
+ if data is None and isinstance(layer, np.ndarray):
368
+ data = layer
369
+ if data is None:
370
+ return None
371
+
372
+ m = np.asarray(data)
373
+ if m.ndim == 3:
374
+ m = m.mean(axis=2)
375
+
376
+ m = m.astype(np.float32, copy=False)
377
+ # normalize to [0,1] if needed
378
+ mx = float(m.max()) if m.size else 1.0
379
+ if mx > 1.0:
380
+ m /= mx
381
+ m = np.clip(m, 0.0, 1.0)
382
+
383
+ # resize (nearest) to current image size
384
+ H, W = self.original.shape[:2]
385
+ if m.shape != (H, W):
386
+ yi = (np.linspace(0, m.shape[0] - 1, H)).astype(np.int32)
387
+ xi = (np.linspace(0, m.shape[1] - 1, W)).astype(np.int32)
388
+ m = m[yi][:, xi]
389
+
390
+ return m
391
+
392
+
393
+ def _combine_with_doc_mask(self, algo_mask: np.ndarray) -> np.ndarray:
394
+ m_doc = self._get_doc_active_mask_2d()
395
+ if m_doc is None:
396
+ return algo_mask
397
+ return np.clip(algo_mask.astype(np.float32) * m_doc.astype(np.float32), 0.0, 1.0)
398
+
399
+
400
+ # --- preview pixmap ---
401
+ def _set_pix(self, rgb: np.ndarray):
402
+ arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
403
+ h, w, _ = arr.shape
404
+ q = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
405
+ self.pix.setPixmap(QPixmap.fromImage(q))
406
+ self.view.setSceneRect(self.pix.boundingRect())
407
+
408
+ # --- toggle ---
409
+ def _toggle(self):
410
+ if self.btn_toggle.isChecked():
411
+ self.btn_toggle.setText(self.tr("Show Preview"))
412
+ self._set_pix(self.original)
413
+ else:
414
+ self.btn_toggle.setText(self.tr("Show Original"))
415
+ self._set_pix(self.preview)
416
+
417
+ # --- reset ---
418
+ def _reset(self):
419
+ self.s_scales.setValue(6)
420
+ self.s_boost.setValue(500)
421
+ self.s_gamma.setValue(100)
422
+ self.s_iters.setValue(2)
423
+ self.preview = self.original.copy()
424
+ self._set_pix(self.preview)
425
+ self.lbl_step.setText(self.tr("Idle")); self.bar.setValue(0)
426
+ self.btn_apply.setEnabled(False)
427
+ self.btn_toggle.setChecked(False); self.btn_toggle.setText(self.tr("Show Original"))
428
+ self._update_mask_only()
429
+
430
+ # --- zoom + Ctrl+Wheel ---
431
+ def wheelEvent(self, e: QWheelEvent):
432
+ if e.modifiers() & Qt.KeyboardModifier.ControlModifier:
433
+ if e.angleDelta().y() > 0: self._zoom_in()
434
+ else: self._zoom_out()
435
+ e.accept(); return
436
+ super().wheelEvent(e)
437
+
438
+ def _zoom_in(self):
439
+ z = self.zoom_factor * self.zoom_step
440
+ if z <= self.zoom_max:
441
+ self.zoom_factor = z
442
+ self._apply_zoom()
443
+
444
+ def _zoom_out(self):
445
+ z = self.zoom_factor / self.zoom_step
446
+ if z >= self.zoom_min:
447
+ self.zoom_factor = z
448
+ self._apply_zoom()
449
+
450
+ def _fit_to_preview(self):
451
+ if not self.pix.pixmap().isNull():
452
+ self.view.fitInView(self.pix, Qt.AspectRatioMode.KeepAspectRatio)
453
+ self.zoom_factor = 1.0
454
+
455
+ def _apply_zoom(self):
456
+ self.view.resetTransform()
457
+ self.view.scale(self.zoom_factor, self.zoom_factor)
458
+
459
+ # --- live mask (no full recompute) ---
460
+ def _update_mask_only(self):
461
+ mgamma = float(self.s_gamma.value()) / 100.0
462
+ base = self.original
463
+ lab = _rgb_to_lab(base)
464
+ L = lab[..., 0] / 100.0
465
+ algo_mask = _darkness_mask(np.clip(L, 0.0, 1.0),
466
+ int(self.s_scales.value()),
467
+ self.base_kernel, mgamma)
468
+ mask_comb = self._combine_with_doc_mask(algo_mask)
469
+ self.mask_win.setWindowTitle(
470
+ self.tr("Dark Mask (Algo × Active Mask)") if self._get_doc_active_mask_2d() is not None else self.tr("Dark Mask")
471
+ )
472
+ self.mask_win.set_mask(mask_comb)
473
+ # --- threaded preview ---
474
+ def _start_preview(self):
475
+ self.btn_preview.setEnabled(False); self.btn_apply.setEnabled(False)
476
+ n_scales = int(self.s_scales.value())
477
+ boost = float(self.s_boost.value()) / 100.0
478
+ mgamma = float(self.s_gamma.value()) / 100.0
479
+ iters = int(self.s_iters.value())
480
+ docmask = self._get_doc_active_mask_2d()
481
+
482
+ self.thread = QThread(self)
483
+ self.worker = DSEWorker(self.original, n_scales, boost, mgamma,
484
+ self.base_kernel, iters,
485
+ external_mask=docmask)
486
+ self.worker.moveToThread(self.thread)
487
+ self.thread.started.connect(self.worker.run)
488
+ self.worker.progress_update.connect(self._on_progress)
489
+ self.worker.finished.connect(self._on_finished)
490
+ self.worker.finished.connect(self.thread.quit)
491
+ self.worker.finished.connect(self.worker.deleteLater)
492
+ self.thread.finished.connect(self.thread.deleteLater)
493
+ self.thread.start()
494
+
495
+ def _on_progress(self, step: str, pct: int):
496
+ self.lbl_step.setText(step); self.bar.setValue(pct)
497
+
498
+ def _on_finished(self, out: np.ndarray, mask: np.ndarray):
499
+ self.btn_preview.setEnabled(True)
500
+ if out is None:
501
+ QMessageBox.critical(self, self.tr("WaveScale Dark Enhancer"), self.tr("Processing failed."))
502
+ return
503
+
504
+ # Respect the document mask
505
+ doc_m = self._get_doc_active_mask_2d()
506
+ if out.ndim == 2:
507
+ out_rgb = np.repeat(out[:, :, None], 3, axis=2)
508
+ else:
509
+ out_rgb = out
510
+
511
+ if doc_m is not None:
512
+ M3 = np.repeat(doc_m[:, :, None], 3, axis=2).astype(np.float32)
513
+ self.preview = self.original * (1.0 - M3) + out_rgb * M3
514
+ else:
515
+ self.preview = out_rgb
516
+
517
+ # show combined mask (internal darkness mask × doc mask)
518
+ mask = self._combine_with_doc_mask(mask)
519
+
520
+ self._set_pix(self.preview)
521
+ self.mask_win.set_mask(mask)
522
+ self.btn_apply.setEnabled(True)
523
+ self.btn_toggle.setChecked(False); self.btn_toggle.setText(self.tr("Show Original"))
524
+ self.lbl_step.setText(self.tr("Preview ready")); self.bar.setValue(100)
525
+
526
+ # --- apply back to doc ---
527
+ # --- apply back to doc ---
528
+ def _apply_to_doc(self):
529
+ out = self.preview
530
+ if self._was_mono:
531
+ mono = np.mean(out, axis=2, dtype=np.float32)
532
+ if self._mono_shape and len(self._mono_shape) == 3 and self._mono_shape[2] == 1:
533
+ mono = mono[:, :, None]
534
+ out = mono
535
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
536
+ try:
537
+ if hasattr(self._doc, "set_image"):
538
+ self._doc.set_image(out, step_name="WaveScale Dark Enhancer")
539
+ elif hasattr(self._doc, "apply_numpy"):
540
+ self._doc.apply_numpy(out, step_name="WaveScale Dark Enhancer")
541
+ else:
542
+ self._doc.image = out
543
+ except Exception as e:
544
+ QMessageBox.critical(self, self.tr("WaveScale Dark Enhancer"), self.tr("Failed to write to document:\n{0}").format(e))
545
+ return
546
+
547
+ # ── Build preset from current sliders ─────────────────────────
548
+ try:
549
+ preset = {
550
+ "n_scales": int(self.s_scales.value()),
551
+ "boost_factor": float(self.s_boost.value()) / 100.0,
552
+ "mask_gamma": float(self.s_gamma.value()) / 100.0,
553
+ "iterations": int(self.s_iters.value()),
554
+ }
555
+ except Exception:
556
+ preset = {}
557
+
558
+ # ── Register as last_headless_command on the main window ─────
559
+ try:
560
+ main = self.parent()
561
+ if main is not None:
562
+ payload = {
563
+ "command_id": "wavescale_dark_enhance",
564
+ "preset": dict(preset),
565
+ }
566
+ setattr(main, "_last_headless_command", payload)
567
+
568
+ # Optional debug logging similar to other tools
569
+ try:
570
+ if hasattr(main, "_log"):
571
+ main._log(
572
+ "[Replay] Registered WaveScale Dark Enhancer as "
573
+ f"last action (n_scales={preset.get('n_scales')}, "
574
+ f"boost={preset.get('boost_factor')}, "
575
+ f"mask_gamma={preset.get('mask_gamma')}, "
576
+ f"iterations={preset.get('iterations')})"
577
+ )
578
+ except Exception:
579
+ pass
580
+ except Exception:
581
+ # Never let replay wiring break the apply
582
+ pass
583
+
584
+ self.accept()
585
+
586
+
587
+ # ─────────────────────────────────────────────────────────────────────────────
588
+ # Installer helpers
589
+ # ─────────────────────────────────────────────────────────────────────────────
590
+ def install_wavescale_dark_enhancer(main_window: QMainWindow,
591
+ dse_icon_path: str,
592
+ *,
593
+ command_id: str = "wavescale_dark_enhancer",
594
+ menu_name: str = "Pro",
595
+ toolbar_name: str = "Pro Tools"):
596
+ """
597
+ Creates the QAction, hooks it into menu+toolbar, and registers it
598
+ with your ShortcutManager under `command_id`.
599
+ Expects main_window to expose:
600
+ • .docman.current_document() → returns doc with .image
601
+ • ._spawn_subwindow_for(doc) (normal in your app)
602
+ • .shortcut_manager (your ShortcutManager) — optional
603
+ """
604
+ # 1) QAction
605
+ act = getattr(main_window, "act_wavescalede", None)
606
+ if act is None:
607
+ from PyQt6.QtGui import QAction
608
+ act = QAction(QIcon(dse_icon_path), "WaveScale Dark Enhancer", main_window)
609
+ act.setObjectName(command_id)
610
+ act.setProperty("command_id", command_id)
611
+
612
+ def _run_dialog():
613
+ docman = getattr(main_window, "docman", None)
614
+ doc = None
615
+ if docman and hasattr(docman, "current_document"):
616
+ doc = docman.current_document()
617
+ if doc is None or getattr(doc, "image", None) is None:
618
+ from PyQt6.QtCore import QCoreApplication
619
+ QMessageBox.warning(main_window, QCoreApplication.translate("WaveScaleDarkEnhancerDialogPro", "WaveScale Dark Enhancer"), QCoreApplication.translate("WaveScaleDarkEnhancerDialogPro", "No active image."))
620
+ return
621
+ dlg = WaveScaleDarkEnhancerDialogPro(main_window, doc, icon_path=dse_icon_path)
622
+ dlg.exec()
623
+
624
+ act.triggered.connect(_run_dialog)
625
+ setattr(main_window, "act_wavescalede", act)
626
+
627
+ # 2) Menu hookup
628
+ menubar = main_window.menuBar()
629
+ menu = None
630
+ for m in menubar.findChildren(type(menubar)):
631
+ # best-effort: ignore; we’ll just create/find by title
632
+ pass
633
+ menu = None
634
+ for i in range(menubar.actions().__len__()):
635
+ if menubar.actions()[i].text().replace("&", "") == menu_name:
636
+ menu = menubar.actions()[i].menu()
637
+ break
638
+ if menu is None:
639
+ menu = menubar.addMenu(menu_name)
640
+ menu.addAction(act)
641
+
642
+ # 3) Toolbar hookup
643
+ tb = None
644
+ for t in main_window.findChildren(type(main_window.addToolBar("tmp"))):
645
+ # naive scan (we won't rely on this); we'll create if needed
646
+ pass
647
+ tb = getattr(main_window, "_tb_" + toolbar_name.replace(" ", "_").lower(), None)
648
+ if tb is None:
649
+ tb = main_window.addToolBar(toolbar_name)
650
+ setattr(main_window, "_tb_" + toolbar_name.replace(" ", "_").lower(), tb)
651
+ tb.addAction(act)
652
+
653
+ # 4) Register with ShortcutManager (if present)
654
+ sm = getattr(main_window, "shortcut_manager", None)
655
+ if sm and hasattr(sm, "register_action"):
656
+ sm.register_action(command_id, act)
657
+
658
+ return act