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,624 @@
1
+ # pro/wavescale_hdr.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt, QObject, pyqtSignal, QThread, QTimer, QSettings
6
+ from PyQt6.QtGui import QImage, QPixmap, QIcon
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QLabel, QPushButton,
9
+ QSlider, QGraphicsScene, QGraphicsPixmapItem, QScrollArea,
10
+ QMessageBox, QProgressBar
11
+ )
12
+
13
+ # Import centralized widget
14
+ from setiastro.saspro.widgets.graphics_views import ZoomableGraphicsView
15
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
16
+
17
+ # Import shared wavelet utilities
18
+ from setiastro.saspro.widgets.wavelet_utils import (
19
+ conv_sep_reflect as _conv_sep_reflect,
20
+ build_spaced_kernel as _build_spaced_kernel,
21
+ atrous_decompose as _atrous_decompose,
22
+ atrous_reconstruct as _atrous_reconstruct,
23
+ rgb_to_lab as _rgb_to_lab,
24
+ lab_to_rgb as _lab_to_rgb,
25
+ B3_KERNEL as _B3,
26
+ )
27
+
28
+ # ──────────────────────────────────────────────────────────────────────────────
29
+ # Core math (shared by dialog + headless apply)
30
+ # ──────────────────────────────────────────────────────────────────────────────
31
+
32
+ def _mask_from_L(L: np.ndarray, gamma: float) -> np.ndarray:
33
+ m = np.clip(L / 100.0, 0.0, 1.0).astype(np.float32)
34
+ if gamma != 1.0:
35
+ m = np.power(m, gamma, dtype=np.float32)
36
+ return m
37
+
38
+ def _apply_dim_curve(rgb: np.ndarray, gamma: float) -> np.ndarray:
39
+ return np.power(np.clip(rgb, 0.0, 1.0), gamma, dtype=np.float32)
40
+
41
+ def compute_wavescale_hdr(rgb_image: np.ndarray,
42
+ n_scales: int = 5,
43
+ compression_factor: float = 1.5,
44
+ mask_gamma: float = 1.0,
45
+ base_kernel: np.ndarray = _B3,
46
+ decay_rate: float = 0.5) -> tuple[np.ndarray, np.ndarray]:
47
+ """
48
+ Returns (transformed_rgb, luminance_mask). transformed_rgb is already
49
+ reconstructed from modified L and gamma-dimmed.
50
+ """
51
+ lab = _rgb_to_lab(rgb_image)
52
+ L0 = lab[..., 0].astype(np.float32, copy=True)
53
+ scales = _atrous_decompose(L0, n_scales, base_kernel)
54
+
55
+ mask = _mask_from_L(L0, mask_gamma)
56
+ planes, residual = scales[:-1], scales[-1]
57
+
58
+ for i, wp in enumerate(planes):
59
+ decay = decay_rate ** i
60
+ scale = (1.0 + (compression_factor - 1.0) * mask * decay) * 2.0
61
+ planes[i] = wp * scale
62
+
63
+ Lr = _atrous_reconstruct(planes + [residual])
64
+
65
+ # midtones alignment
66
+ med0 = float(np.median(L0))
67
+ med1 = float(np.median(Lr)) or 1.0
68
+ Lr = np.clip(Lr * (med0 / med1), 0.0, 100.0)
69
+
70
+ lab[..., 0] = Lr
71
+ rgb = _lab_to_rgb(lab)
72
+
73
+ # gentle dimming curve to tame highlights
74
+ rgb = _apply_dim_curve(rgb, gamma=1.0 + n_scales * 0.2)
75
+ return rgb, mask
76
+
77
+ def compute_wavescale_hdr(rgb_image: np.ndarray,
78
+ n_scales: int = 5,
79
+ compression_factor: float = 1.5,
80
+ mask_gamma: float = 1.0,
81
+ base_kernel: np.ndarray = _B3,
82
+ decay_rate: float = 0.5,
83
+ dim_gamma: float | None = None) -> tuple[np.ndarray, np.ndarray]:
84
+ """
85
+ Returns (transformed_rgb, luminance_mask).
86
+ If dim_gamma is None, uses auto gamma = 1.0 + 0.2 * n_scales.
87
+ """
88
+ lab = _rgb_to_lab(rgb_image)
89
+ L0 = lab[..., 0].astype(np.float32, copy=True)
90
+ scales = _atrous_decompose(L0, n_scales, base_kernel)
91
+
92
+ mask = _mask_from_L(L0, mask_gamma)
93
+ planes, residual = scales[:-1], scales[-1]
94
+
95
+ for i, wp in enumerate(planes):
96
+ decay = decay_rate ** i
97
+ scale = (1.0 + (compression_factor - 1.0) * mask * decay) * 2.0
98
+ planes[i] = wp * scale
99
+
100
+ Lr = _atrous_reconstruct(planes + [residual])
101
+
102
+ # midtones alignment
103
+ med0 = float(np.median(L0))
104
+ med1 = float(np.median(Lr)) or 1.0
105
+ Lr = np.clip(Lr * (med0 / med1), 0.0, 100.0)
106
+
107
+ lab[..., 0] = Lr
108
+ rgb = _lab_to_rgb(lab)
109
+
110
+ # dimming curve
111
+ g = (1.0 + n_scales * 0.2) if dim_gamma is None else float(dim_gamma)
112
+ rgb = _apply_dim_curve(rgb, gamma=g)
113
+ return rgb, mask
114
+
115
+
116
+ # ──────────────────────────────────────────────────────────────────────────────
117
+ # Worker (QObject in its own QThread) for the dialog
118
+ # ──────────────────────────────────────────────────────────────────────────────
119
+
120
+ class HDRWorker(QObject):
121
+ progress_update = pyqtSignal(str, int) # (step, percent)
122
+ finished = pyqtSignal(np.ndarray, np.ndarray) # (transformed_rgb, mask)
123
+
124
+ def __init__(self, rgb_image: np.ndarray, n_scales: int, compression_factor: float,
125
+ mask_gamma: float, base_kernel: np.ndarray):
126
+ super().__init__()
127
+ self.rgb_image = rgb_image
128
+ self.n_scales = n_scales
129
+ self.compression_factor = compression_factor
130
+ self.mask_gamma = mask_gamma
131
+ self.base_kernel = base_kernel
132
+
133
+ def run(self):
134
+ try:
135
+ self.progress_update.emit(self.tr("Converting to Lab color space…"), 10)
136
+ # progress checkpoints inline here are cosmetic
137
+ self.progress_update.emit(self.tr("Decomposing luminance with starlet…"), 20)
138
+ # full compute
139
+ transformed, mask = compute_wavescale_hdr(
140
+ self.rgb_image, self.n_scales, self.compression_factor, self.mask_gamma, self.base_kernel
141
+ )
142
+ self.progress_update.emit(self.tr("Finalizing…"), 95)
143
+ self.finished.emit(transformed, mask)
144
+ except Exception as e:
145
+ print("WaveScale HDR error:", e)
146
+ self.finished.emit(None, None)
147
+
148
+ # ──────────────────────────────────────────────────────────────────────────────
149
+ # Simple mask window
150
+ # ──────────────────────────────────────────────────────────────────────────────
151
+
152
+ class MaskDisplayWindow(QDialog):
153
+ def __init__(self, parent=None):
154
+ super().__init__(parent)
155
+ self.setWindowTitle(self.tr("HDR Mask (L-based)"))
156
+ self.lbl = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
157
+ self.lbl.setFixedSize(400, 400) # keep it small
158
+ lay = QVBoxLayout(self)
159
+ lay.addWidget(self.lbl)
160
+
161
+ def update_mask(self, mask: np.ndarray):
162
+ if mask is None:
163
+ return
164
+ m = np.clip(mask, 0, 1).astype(np.float32)
165
+ m8 = (m * 255.0).astype(np.uint8)
166
+ if m8.ndim == 2:
167
+ h, w = m8.shape
168
+ rgb = np.repeat(m8[..., None], 3, axis=2)
169
+ else:
170
+ h, w, _ = m8.shape
171
+ rgb = m8
172
+ qimg = QImage(rgb.data, w, h, 3*w, QImage.Format.Format_RGB888)
173
+ pix = QPixmap.fromImage(qimg).scaled(
174
+ self.lbl.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
175
+ )
176
+ self.lbl.setPixmap(pix)
177
+
178
+ # ──────────────────────────────────────────────────────────────────────────────
179
+ # Dialog
180
+ # ──────────────────────────────────────────────────────────────────────────────
181
+
182
+ class WaveScaleHDRDialogPro(QDialog):
183
+ applied_preset = pyqtSignal(object, dict)
184
+
185
+ def __init__(self, parent, doc, icon_path: str | None = None, *, headless: bool=False, bypass_guard: bool=False):
186
+ super().__init__(parent)
187
+ self.setWindowTitle(self.tr("WaveScale HDR"))
188
+ self._headless = bool(headless)
189
+ self._bypass_guard = bool(bypass_guard)
190
+ if self._headless:
191
+ # Don’t show any windows; we’ll still exec() to run the event loop.
192
+ try: self.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
193
+ except Exception as e:
194
+ import logging
195
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
196
+ if icon_path:
197
+ try: self.setWindowIcon(QIcon(icon_path))
198
+ except Exception as e:
199
+ import logging
200
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
201
+ self.resize(980, 700)
202
+
203
+ self._doc = doc
204
+ base = getattr(doc, "image", None)
205
+ if base is None:
206
+ raise RuntimeError("Active document has no image.")
207
+
208
+ # normalize to float32 [0..1] RGB for processing/preview
209
+ img = np.asarray(base, dtype=np.float32)
210
+ if img.ndim == 2:
211
+ img_rgb = np.repeat(img[:, :, None], 3, axis=2)
212
+ self._was_mono = True
213
+ self._mono_shape = img.shape
214
+ elif img.ndim == 3 and img.shape[2] == 1:
215
+ img_rgb = np.repeat(img, 3, axis=2)
216
+ self._was_mono = True
217
+ self._mono_shape = img.shape
218
+ else:
219
+ img_rgb = img[:, :, :3]
220
+ self._was_mono = False
221
+ self._mono_shape = None
222
+
223
+ if img.dtype.kind in "ui":
224
+ maxv = float(np.nanmax(img_rgb)) or 1.0
225
+ img_rgb = img_rgb / max(1.0, maxv)
226
+ img_rgb = np.clip(img_rgb, 0.0, 1.0).astype(np.float32, copy=False)
227
+
228
+ self.original_rgb = img_rgb
229
+ self.preview_rgb = img_rgb.copy()
230
+
231
+ # scene/view (⚠️ use ZoomableGraphicsView)
232
+ self.scene = QGraphicsScene(self)
233
+ self.view = ZoomableGraphicsView(self.scene, self)
234
+ self.view.setAlignment(Qt.AlignmentFlag.AlignCenter)
235
+ self.pix = QGraphicsPixmapItem()
236
+ self.scene.addItem(self.pix)
237
+
238
+ # optional: keep your scroll area wrapper
239
+ self.scroll = QScrollArea(self)
240
+ self.scroll.setWidgetResizable(True)
241
+ self.scroll.setWidget(self.view)
242
+
243
+ # controls (add zoom row)
244
+ self.grp = QGroupBox(self.tr("HDR Controls"))
245
+ form = QFormLayout(self.grp)
246
+
247
+ self.s_scales = QSlider(Qt.Orientation.Horizontal); self.s_scales.setRange(2, 10); self.s_scales.setValue(5)
248
+ self.s_comp = QSlider(Qt.Orientation.Horizontal); self.s_comp.setRange(10, 500); self.s_comp.setValue(150)
249
+ self.s_gamma = QSlider(Qt.Orientation.Horizontal); self.s_gamma.setRange(10, 1000); self.s_gamma.setValue(500)
250
+
251
+ form.addRow(self.tr("Number of Scales:"), self.s_scales)
252
+ form.addRow(self.tr("Coarse Compression:"), self.s_comp)
253
+ form.addRow(self.tr("Mask Gamma:"), self.s_gamma)
254
+
255
+ row = QHBoxLayout()
256
+ self.btn_preview = QPushButton(self.tr("Preview"))
257
+ self.btn_toggle = QPushButton(self.tr("Show Original")); self.btn_toggle.setCheckable(True)
258
+ row.addWidget(self.btn_preview); row.addWidget(self.btn_toggle)
259
+ form.addRow(row)
260
+
261
+ # ↓ NEW: zoom controls
262
+ zoom_row = QHBoxLayout()
263
+ self.btn_zoom_in = QPushButton(self.tr("Zoom In"))
264
+ self.btn_zoom_out = QPushButton(self.tr("Zoom Out"))
265
+ self.btn_fit = QPushButton(self.tr("Fit to Preview"))
266
+ zoom_row.addWidget(self.btn_zoom_in)
267
+ zoom_row.addWidget(self.btn_zoom_out)
268
+ zoom_row.addWidget(self.btn_fit)
269
+ form.addRow(zoom_row)
270
+
271
+ # progress group (unchanged)
272
+ self.prog_grp = QGroupBox(self.tr("Processing Progress"))
273
+ vprog = QVBoxLayout(self.prog_grp)
274
+ self.lbl_step = QLabel(self.tr("Idle"))
275
+ self.bar = QProgressBar(); self.bar.setRange(0, 100); self.bar.setValue(0)
276
+ vprog.addWidget(self.lbl_step); vprog.addWidget(self.bar)
277
+
278
+ # bottom buttons (unchanged)
279
+ bot = QHBoxLayout()
280
+ self.btn_apply = QPushButton(self.tr("Apply to Document")); self.btn_apply.setEnabled(False)
281
+ self.btn_reset = QPushButton(self.tr("Reset"))
282
+ self.btn_close = QPushButton(self.tr("Close"))
283
+ bot.addStretch(1); bot.addWidget(self.btn_apply); bot.addWidget(self.btn_reset); bot.addWidget(self.btn_close)
284
+
285
+ # layout (unchanged)
286
+ main = QVBoxLayout(self)
287
+ main.addWidget(self.scroll)
288
+ h = QHBoxLayout()
289
+ h.addWidget(self.grp, 3)
290
+ h.addWidget(self.prog_grp, 1)
291
+ main.addLayout(h)
292
+ main.addLayout(bot)
293
+
294
+ # mask window
295
+ self.mask_win = MaskDisplayWindow(self)
296
+ if not self._headless:
297
+ self.mask_win.show()
298
+
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_zoom_in.clicked.connect(self.view.zoom_in)
311
+ self.btn_zoom_out.clicked.connect(self.view.zoom_out)
312
+ self.btn_fit.clicked.connect(lambda: self.view.fit_item(self.pix))
313
+
314
+ # ── Mask shown immediately ───────────────────────────────────────────
315
+ # Precompute L from original and push initial mask to the small window
316
+ self._lab_original = _rgb_to_lab(self.original_rgb)
317
+ self._L_original = self._lab_original[..., 0].astype(np.float32, copy=True)
318
+ self._mask_timer = QTimer(self)
319
+ self._mask_timer.setSingleShot(True)
320
+ self._mask_timer.timeout.connect(self._update_mask_from_gamma)
321
+ self.s_gamma.valueChanged.connect(self._schedule_mask_refresh)
322
+
323
+ # show initial mask right away
324
+ self._update_mask_from_gamma()
325
+
326
+ # initial pix
327
+ self._set_pix(self.preview_rgb)
328
+
329
+ def apply_preset(self, p: dict):
330
+ # sliders are integer; map floats to their scales
331
+ ns = int(p.get("n_scales", 5))
332
+ comp = float(p.get("compression_factor", 1.5))
333
+ mg = float(p.get("mask_gamma", 5.0)) # dialog default is 5.0 (slider 500)
334
+ # clamp safely
335
+ ns = max(2, min(10, ns))
336
+ comp_i = int(max(10, min(500, round(comp*100)))) # 1.0..5.0 -> 100..500
337
+ mg_i = int(max(10, min(1000, round(mg*100)))) # 0.1..10.0 -> 10..1000
338
+ self.s_scales.setValue(ns)
339
+ self.s_comp.setValue(comp_i)
340
+ self.s_gamma.setValue(mg_i)
341
+ # refresh mask preview (even if window is hidden)
342
+ self._update_mask_from_gamma()
343
+
344
+ def _headless_guard_active(self) -> bool:
345
+ """Only guard true concurrent *headless* runs; ignore stale locks."""
346
+ # If we are not launching headless, never block the interactive UI.
347
+ if not self._headless:
348
+ return False
349
+
350
+ # Parent flags
351
+ p = self.parent()
352
+ if p and (getattr(p, "_wavescale_guard", False) or getattr(p, "_wavescale_headless_running", False)):
353
+ return True
354
+
355
+ # Settings lock with TTL
356
+ try:
357
+ s = QSettings()
358
+ in_prog = bool(s.value("wavescale/headless_in_progress", False))
359
+ started = float(s.value("wavescale/headless_started_at", 0.0))
360
+ except Exception:
361
+ in_prog, started = False, 0.0
362
+
363
+ if not in_prog:
364
+ return False
365
+
366
+ # consider anything older than 5 minutes stale
367
+ import time
368
+ if (time.time() - started) > 5 * 60:
369
+ try:
370
+ s.remove("wavescale/headless_in_progress")
371
+ s.remove("wavescale/headless_started_at")
372
+ except Exception:
373
+ pass
374
+ return False
375
+
376
+ return True
377
+
378
+ def showEvent(self, e):
379
+ super().showEvent(e)
380
+ if not self._bypass_guard and self._headless_guard_active():
381
+ # Soft warning instead of rejecting the dialog
382
+ try:
383
+ QMessageBox.information(
384
+ self, self.tr("WaveScale HDR"),
385
+ self.tr("A headless HDR run appears to be in progress. "
386
+ "This window will remain open; you can still preview safely.")
387
+ )
388
+ except Exception:
389
+ pass
390
+
391
+ def exec(self) -> int:
392
+ if not self._bypass_guard and self._headless_guard_active():
393
+ return 0
394
+ return super().exec()
395
+
396
+ def _get_doc_active_mask_2d(self) -> np.ndarray | None:
397
+ """
398
+ Return the document's active mask as a 2-D float32 in [0..1],
399
+ resized to the current image size. If none, return None.
400
+ """
401
+ doc = getattr(self, "_doc", None)
402
+ if doc is None:
403
+ return None
404
+
405
+ mid = getattr(doc, "active_mask_id", None)
406
+ if not mid:
407
+ return None
408
+
409
+ masks = getattr(doc, "masks", {}) or {}
410
+ layer = masks.get(mid)
411
+ if layer is None:
412
+ return None
413
+
414
+ # Safely pick the first non-None payload without using boolean 'or'
415
+ data = None
416
+ # object with attributes
417
+ for attr in ("data", "mask", "image", "array"):
418
+ if hasattr(layer, attr):
419
+ val = getattr(layer, attr)
420
+ if val is not None:
421
+ data = val
422
+ break
423
+ # plain ndarray?
424
+ if data is None and isinstance(layer, np.ndarray):
425
+ data = layer
426
+ # dict-like layer?
427
+ if data is None and isinstance(layer, dict):
428
+ for key in ("data", "mask", "image", "array"):
429
+ if key in layer and layer[key] is not None:
430
+ data = layer[key]
431
+ break
432
+
433
+ if data is None:
434
+ return None
435
+
436
+ m = np.asarray(data)
437
+
438
+ # collapse RGB/alpha to gray if needed
439
+ if m.ndim == 3:
440
+ m = m.mean(axis=2)
441
+
442
+ m = m.astype(np.float32, copy=False)
443
+ # normalize to [0,1] if it looks like 0..255 or 0..65535
444
+ if m.dtype.kind in "ui":
445
+ m /= float(np.iinfo(m.dtype).max)
446
+ else:
447
+ mx = float(m.max()) if m.size else 1.0
448
+ if mx > 1.0:
449
+ m /= mx
450
+ m = np.clip(m, 0.0, 1.0)
451
+
452
+ # resize to current image size (nearest)
453
+ H, W = self.original_rgb.shape[:2]
454
+ if m.shape != (H, W):
455
+ yi = (np.linspace(0, m.shape[0] - 1, H)).astype(np.int32)
456
+ xi = (np.linspace(0, m.shape[1] - 1, W)).astype(np.int32)
457
+ m = m[yi][:, xi]
458
+
459
+ return m
460
+
461
+
462
+ def _combine_with_doc_mask(self, hdr_mask: np.ndarray) -> np.ndarray:
463
+ """
464
+ Multiply the HDR luminance mask by the document active mask (if any).
465
+ Shapes are matched to image size.
466
+ """
467
+ m_doc = self._get_doc_active_mask_2d()
468
+ if m_doc is None:
469
+ return hdr_mask
470
+ # both are already (H, W) float32 in [0..1]
471
+ return np.clip(hdr_mask * m_doc, 0.0, 1.0)
472
+
473
+
474
+ def _set_pix(self, rgb: np.ndarray):
475
+ arr = (np.clip(rgb, 0, 1) * 255).astype(np.uint8)
476
+ h, w, _ = arr.shape
477
+ q = QImage(arr.data, w, h, 3*w, QImage.Format.Format_RGB888)
478
+ self.pix.setPixmap(QPixmap.fromImage(q))
479
+ self.view.setSceneRect(self.pix.boundingRect())
480
+
481
+ def _toggle(self):
482
+ if self.btn_toggle.isChecked():
483
+ self.btn_toggle.setText(self.tr("Show Preview"))
484
+ self._set_pix(self.original_rgb)
485
+ else:
486
+ self.btn_toggle.setText(self.tr("Show Original"))
487
+ self._set_pix(self.preview_rgb)
488
+
489
+ def _reset(self):
490
+ self.s_scales.setValue(5)
491
+ self.s_comp.setValue(150)
492
+ self.s_gamma.setValue(500)
493
+ self.preview_rgb = self.original_rgb.copy()
494
+ self._set_pix(self.preview_rgb)
495
+ self.lbl_step.setText(self.tr("Idle")); self.bar.setValue(0)
496
+ self.btn_apply.setEnabled(False)
497
+ self.btn_toggle.setChecked(False); self.btn_toggle.setText(self.tr("Show Original"))
498
+
499
+ def _start_preview(self):
500
+ self.btn_preview.setEnabled(False); self.btn_apply.setEnabled(False)
501
+ n_scales = int(self.s_scales.value())
502
+ comp = float(self.s_comp.value()) / 100.0
503
+ mgamma = float(self.s_gamma.value()) / 100.0
504
+
505
+ self.thread = QThread(self)
506
+ self.worker = HDRWorker(self.original_rgb, n_scales, comp, mgamma, self.base_kernel)
507
+ self.worker.moveToThread(self.thread)
508
+ self.thread.started.connect(self.worker.run)
509
+ self.worker.progress_update.connect(self._on_progress)
510
+ self.worker.finished.connect(self._on_finished)
511
+ self.worker.finished.connect(self.thread.quit)
512
+ self.worker.finished.connect(self.worker.deleteLater)
513
+ self.thread.finished.connect(self.thread.deleteLater)
514
+ self.thread.start()
515
+
516
+ def _on_progress(self, step: str, pct: int):
517
+ self.lbl_step.setText(step); self.bar.setValue(pct)
518
+
519
+ def _on_finished(self, transformed_rgb: np.ndarray, mask: np.ndarray):
520
+ self.btn_preview.setEnabled(True)
521
+ if transformed_rgb is None:
522
+ QMessageBox.critical(self, self.tr("WaveScale HDR"), self.tr("Processing failed."))
523
+ return
524
+
525
+ # ← NEW: combine HDR's luminance mask with the doc's active mask (if present)
526
+ mask_comb = self._combine_with_doc_mask(mask)
527
+
528
+ # blend preview: original*(1-mask) + transformed*mask
529
+ m3 = np.repeat(mask_comb[..., None], 3, axis=2)
530
+ self.preview_rgb = self.original_rgb * (1.0 - m3) + transformed_rgb * m3
531
+ self._set_pix(self.preview_rgb)
532
+
533
+ # show the *combined* mask in the little window
534
+ self.mask_win.setWindowTitle(
535
+ self.tr("HDR Mask (L × Active Mask)") if self._get_doc_active_mask_2d() is not None else self.tr("HDR Mask (L-based)")
536
+ )
537
+ self.mask_win.update_mask(mask_comb)
538
+
539
+ self.btn_apply.setEnabled(True)
540
+ self.btn_toggle.setChecked(False); self.btn_toggle.setText(self.tr("Show Original"))
541
+ self.lbl_step.setText(self.tr("Preview ready")); self.bar.setValue(100)
542
+ # Headless: apply immediately (exactly like clicking "Apply to Document")
543
+ if self._headless:
544
+ QTimer.singleShot(0, self._apply_to_doc)
545
+
546
+ def _apply_to_doc(self):
547
+ out = self.preview_rgb
548
+ if self._was_mono:
549
+ # collapse back to mono (keep original shape: 2D or H×W×1)
550
+ mono = np.mean(out, axis=2, dtype=np.float32)
551
+ if self._mono_shape and len(self._mono_shape) == 3 and self._mono_shape[2] == 1:
552
+ mono = mono[:, :, None]
553
+ out = mono
554
+
555
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
556
+ try:
557
+ if hasattr(self._doc, "set_image"):
558
+ self._doc.set_image(out, step_name="WaveScale HDR")
559
+ elif hasattr(self._doc, "apply_numpy"):
560
+ self._doc.apply_numpy(out, step_name="WaveScale HDR")
561
+ else:
562
+ self._doc.image = out
563
+ except Exception as e:
564
+ QMessageBox.critical(self, self.tr("WaveScale HDR"), self.tr("Failed to write to document:\n{0}").format(e))
565
+ return
566
+
567
+ # ── Build preset from current sliders ─────────────────────────
568
+ try:
569
+ preset = {
570
+ "n_scales": int(self.s_scales.value()),
571
+ "compression_factor": float(self.s_comp.value()) / 100.0,
572
+ "mask_gamma": float(self.s_gamma.value()) / 100.0,
573
+ }
574
+ except Exception:
575
+ preset = {}
576
+
577
+ # ── Register as last_headless_command on the main window ─────
578
+ try:
579
+ main = self.parent()
580
+ if main is not None:
581
+ payload = {
582
+ "command_id": "wavescale_hdr",
583
+ "preset": dict(preset),
584
+ }
585
+ setattr(main, "_last_headless_command", payload)
586
+
587
+ # Optional debug log (mirrors other tools)
588
+ try:
589
+ if hasattr(main, "_log"):
590
+ ns = int(preset.get("n_scales", 5))
591
+ comp = float(preset.get("compression_factor", 1.5))
592
+ mg = float(preset.get("mask_gamma", 5.0))
593
+ main._log(
594
+ f"[Replay] Registered WaveScale HDR as last action "
595
+ f"(n_scales={ns}, compression={comp:.2f}, mask_gamma={mg:.2f})"
596
+ )
597
+ except Exception:
598
+ pass
599
+ except Exception:
600
+ # never let replay wiring break the apply
601
+ pass
602
+
603
+ # ── (optional) keep emitting signal if you want it elsewhere ──
604
+ try:
605
+ self.applied_preset.emit(self._doc, preset)
606
+ except Exception:
607
+ pass
608
+
609
+ self.accept()
610
+
611
+
612
+
613
+ def _schedule_mask_refresh(self, _value):
614
+ # debounce to ~0.25s
615
+ self._mask_timer.start(250)
616
+
617
+ def _update_mask_from_gamma(self):
618
+ gamma = float(self.s_gamma.value()) / 100.0
619
+ hdr_mask = _mask_from_L(self._L_original, gamma=gamma)
620
+ mask_comb = self._combine_with_doc_mask(hdr_mask)
621
+ self.mask_win.setWindowTitle(
622
+ self.tr("HDR Mask (L × Active Mask)") if self._get_doc_active_mask_2d() is not None else self.tr("HDR Mask (L-based)")
623
+ )
624
+ self.mask_win.update_mask(mask_comb)