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,660 @@
1
+ # pro/ghs_dialog_pro.py
2
+ from PyQt6.QtCore import Qt, QEvent, QPointF, QTimer
3
+ from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
4
+ QScrollArea, QComboBox, QSlider, QToolButton, QWidget, QMessageBox)
5
+ from PyQt6.QtGui import QPixmap, QImage, QPen, QColor
6
+ import numpy as np
7
+
8
+
9
+
10
+ # Reuse the engine from curves_editor_pro
11
+ from .curve_editor_pro import (
12
+ CurveEditor, _CurvesWorker, _apply_mode_any, build_curve_lut,
13
+ _float_to_qimage_rgb8, _downsample_for_preview, ImageLabel
14
+ )
15
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
16
+
17
+ class GhsDialogPro(QDialog):
18
+ """
19
+ Hyperbolic Stretch dialog:
20
+ - Left: α/β/γ + LP/HP + channel selector
21
+ - Right: same preview/zoom/pan as CurvesDialogPro
22
+ - Uses CurveEditor for the actual curve, but the points are generated from parameters.
23
+ """
24
+ def __init__(self, parent, document):
25
+ super().__init__(parent)
26
+ self.setWindowTitle(self.tr("Hyperbolic Stretch"))
27
+ self.doc = document
28
+ self._preview_img = None
29
+ self._full_img = None
30
+ self._pix = None
31
+ self._zoom = 0.25
32
+ self._panning = False
33
+ self._pan_start = QPointF()
34
+ self._sym_u = 0.5 # pivot in [0..1]
35
+
36
+ # ---------- layout ----------
37
+ main = QHBoxLayout(self)
38
+
39
+ # Left controls
40
+ left = QVBoxLayout()
41
+ self.editor = CurveEditor(self)
42
+ left.addWidget(self.editor)
43
+
44
+ hint = QLabel(self.tr("Tip: Ctrl+Click (or double-click) the image to set the symmetry pivot"))
45
+ hint.setStyleSheet("color: #888; font-size: 11px;")
46
+ left.addWidget(hint)
47
+ self.editor.setToolTip(self.tr("Ctrl+Click (or double-click) the image to set the symmetry pivot"))
48
+
49
+ # channel selector
50
+ ch_row = QHBoxLayout()
51
+ ch_row.addWidget(QLabel(self.tr("Channel:")))
52
+ self.cmb_ch = QComboBox(self)
53
+ self.cmb_ch.addItems(["K (Brightness)", "R", "G", "B"])
54
+ ch_row.addWidget(self.cmb_ch)
55
+ left.addLayout(ch_row)
56
+
57
+ # α / β / γ
58
+ def _mk_slider_row(name, rng, val):
59
+ row = QHBoxLayout()
60
+ lab = QLabel(name); row.addWidget(lab)
61
+ s = QSlider(Qt.Orientation.Horizontal); s.setRange(*rng); s.setValue(val); row.addWidget(s)
62
+ v = QLabel(f"{val/100:.2f}" if name=="γ" else f"{val/50:.2f}"); row.addWidget(v)
63
+ return row, s, v
64
+
65
+ rowA, self.sA, self.labA = _mk_slider_row("α", (1, 500), 50) # 1.0
66
+ rowB, self.sB, self.labB = _mk_slider_row("β", (1, 500), 50) # 1.0
67
+ rowG, self.sG, self.labG = _mk_slider_row("γ", (1, 500), 100) # 1.0
68
+ left.addLayout(rowA); left.addLayout(rowB); left.addLayout(rowG)
69
+
70
+ # LP / HP (protect)
71
+ rowLP = QHBoxLayout(); rowHP = QHBoxLayout()
72
+ rowLP.addWidget(QLabel("LP")); self.sLP = QSlider(Qt.Orientation.Horizontal); self.sLP.setRange(0,360); rowLP.addWidget(self.sLP); self.labLP = QLabel("0.00"); rowLP.addWidget(self.labLP)
73
+ rowHP.addWidget(QLabel("HP")); self.sHP = QSlider(Qt.Orientation.Horizontal); self.sHP.setRange(0,360); rowHP.addWidget(self.sHP); self.labHP = QLabel("0.00"); rowHP.addWidget(self.labHP)
74
+ left.addLayout(rowLP); left.addLayout(rowHP)
75
+
76
+ # Buttons
77
+ rowb = QHBoxLayout()
78
+ self.btn_apply = QPushButton(self.tr("Apply"))
79
+ self.btn_reset = QToolButton(); self.btn_reset.setText(self.tr("Reset"))
80
+ self.btn_hist = QToolButton(); self.btn_hist.setText(self.tr("Histogram"))
81
+ self.btn_hist.setToolTip(self.tr("Open a Histogram for this image.\n"
82
+ "Ctrl+Click on the histogram to set the GHS pivot."))
83
+ rowb.addWidget(self.btn_apply)
84
+ rowb.addWidget(self.btn_reset)
85
+ rowb.addWidget(self.btn_hist)
86
+ left.addLayout(rowb)
87
+ left.addStretch(1)
88
+
89
+ main.addLayout(left, 0)
90
+
91
+ # --- Right preview panel ---
92
+ right = QVBoxLayout()
93
+ zoombar = QHBoxLayout()
94
+ zoombar.addStretch(1)
95
+
96
+ b_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
97
+ b_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
98
+ b_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
99
+
100
+ zoombar.addWidget(b_out)
101
+ zoombar.addWidget(b_in)
102
+ zoombar.addWidget(b_fit)
103
+
104
+ right.addLayout(zoombar)
105
+ self.scroll = QScrollArea()
106
+ self.scroll.setWidgetResizable(True)
107
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
108
+
109
+ # CREATE LABEL FIRST
110
+ self.label = ImageLabel(self) # <- make sure ImageLabel is imported
111
+ self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
112
+ self.label.mouseMoved.connect(self._on_preview_mouse_moved)
113
+ self.label.installEventFilter(self)
114
+
115
+ self.scroll.setWidget(self.label)
116
+ # INSTALL FILTERS AFTER label exists
117
+ self.scroll.viewport().installEventFilter(self)
118
+
119
+ right.addWidget(self.scroll, 1)
120
+ main.addLayout(right, 1)
121
+
122
+ # ---------- wiring ----------
123
+ self.editor.setPreviewCallback(lambda _lut8: self._quick_preview())
124
+ self.editor.setSymmetryCallback(self._on_symmetry_pick)
125
+
126
+ self.sA.valueChanged.connect(self._rebuild_from_params)
127
+ self.sB.valueChanged.connect(self._rebuild_from_params)
128
+ self.sG.valueChanged.connect(self._rebuild_from_params)
129
+ self.sLP.valueChanged.connect(self._rebuild_from_params)
130
+ self.sHP.valueChanged.connect(self._rebuild_from_params)
131
+ self.cmb_ch.currentTextChanged.connect(self._recolor_curve)
132
+
133
+ self.btn_apply.clicked.connect(self._apply)
134
+ self.btn_reset.clicked.connect(self._reset)
135
+ self._hist_dlg = None # will hold our per-GHS histogram dialog
136
+ self.btn_hist.clicked.connect(self._open_histogram)
137
+
138
+ b_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
139
+ b_in .clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
140
+ b_fit.clicked.connect(self._fit)
141
+
142
+ # seed image data
143
+ self._load_from_doc()
144
+
145
+ # start with Fit to Preview (avoids offset issues)
146
+ QTimer.singleShot(0, self._fit)
147
+
148
+
149
+ # first curve
150
+ self._rebuild_from_params()
151
+
152
+ # ---------- params → handles/curve ----------
153
+ def _open_histogram(self):
154
+ """Open (or raise) a HistogramDialog bound to this document and
155
+ connect its pivot signal to our symmetry pivot."""
156
+ try:
157
+ from .histogram import HistogramDialog
158
+ except Exception as e:
159
+ QMessageBox.warning(self, self.tr("Histogram"),
160
+ self.tr("Could not import histogram module:\n{0}").format(e))
161
+ return
162
+
163
+ # If we already created one and it's still alive, just bring it forward.
164
+ if self._hist_dlg is not None:
165
+ try:
166
+ if self._hist_dlg.isVisible():
167
+ self._hist_dlg.raise_()
168
+ self._hist_dlg.activateWindow()
169
+ return
170
+ except RuntimeError:
171
+ # dialog was destroyed; fall through to recreate
172
+ self._hist_dlg = None
173
+
174
+ dlg = HistogramDialog(self, self.doc)
175
+ self._hist_dlg = dlg
176
+ try:
177
+ dlg.pivotPicked.connect(self._on_hist_pivot)
178
+ except Exception:
179
+ # if signal isn't there for some reason, just ignore
180
+ pass
181
+ dlg.show()
182
+
183
+ def _on_hist_pivot(self, u: float):
184
+ """
185
+ Receive normalized pivot from Histogram (0..1) and update our symmetry
186
+ point & curve.
187
+ """
188
+ u = float(np.clip(u, 0.0, 1.0))
189
+ self._sym_u = u
190
+ # CurveEditor uses 0..360 in X; Y doesn't matter for the vertical line
191
+ self.editor.setSymmetryPoint(u * 360.0, 0.0)
192
+ self._rebuild_from_params()
193
+
194
+
195
+ def _on_symmetry_pick(self, u, v):
196
+ self._sym_u = float(u)
197
+ self._rebuild_from_params()
198
+
199
+ def _rebuild_from_params(self):
200
+ a = self.sA.value()/50.0
201
+ b = self.sB.value()/50.0
202
+ g = self.sG.value()/100.0
203
+ self.labA.setText(f"{a:.2f}")
204
+ self.labB.setText(f"{b:.2f}")
205
+ self.labG.setText(f"{g:.2f}")
206
+
207
+ # number of handles (keep existing count or default to 20)
208
+ N = len(self.editor.control_points) or 20
209
+ if len(self.editor.control_points) == 0:
210
+ for _ in range(N):
211
+ self.editor.addControlPoint(0, 0)
212
+
213
+ SP = float(self._sym_u)
214
+ eps = 1e-6
215
+
216
+ # --- sample around 0.5, then REMAP x to SP (this is the key) ---
217
+ us = np.linspace(0.0, 1.0, N) # even sampling
218
+ left = us <= 0.5
219
+ right = ~left
220
+
221
+ # generalized hyperbolic (two shapes, mirrored at 0.5)
222
+ rawL = us**a / (us**a + b*(1.0-us)**a)
223
+ rawR = us**a / (us**a + (1.0/b)*(1.0-us)**a)
224
+
225
+ midL = (0.5**a) / (0.5**a + b*(0.5)**a)
226
+ midR = (0.5**a) / (0.5**a + (1.0/b)*(0.5)**a)
227
+
228
+ # map domain to pivoted x ("up") and scaled y ("vp")
229
+ up = np.empty_like(us)
230
+ vp = np.empty_like(us)
231
+
232
+ # left half → [0 .. SP]
233
+ up[left] = 2.0 * SP * us[left]
234
+ vp[left] = rawL[left] * (SP / max(midL, eps))
235
+
236
+ # right half → [SP .. 1]
237
+ up[right] = SP + 2.0*(1.0 - SP)*(us[right] - 0.5)
238
+ vp[right] = SP + (rawR[right] - midR) * ((1.0 - SP) / max(1.0 - midR, eps))
239
+
240
+ # LP/HP protection: blend toward identity (vp == up)
241
+ LP = self.sLP.value()/360.0
242
+ HP = self.sHP.value()/360.0
243
+
244
+ if LP > 0:
245
+ m = up <= SP
246
+ vp[m] = (1.0 - LP)*vp[m] + LP*up[m]
247
+ if HP > 0:
248
+ m = up >= SP
249
+ vp[m] = (1.0 - HP)*vp[m] + HP*up[m]
250
+
251
+ self.labLP.setText(f"{LP:.2f}")
252
+ self.labHP.setText(f"{HP:.2f}")
253
+
254
+ # gamma lift
255
+ if abs(g - 1.0) > 1e-6:
256
+ vp = np.clip(vp, 0.0, 1.0) ** (1.0 / g)
257
+
258
+ # keep in range & gently enforce monotonicity to avoid tiny dips
259
+ vp = np.clip(vp, 0.0, 1.0)
260
+ vp = np.maximum.accumulate(vp)
261
+
262
+ # write handles back (x rightward, y inverted for the grid)
263
+ xs = up * 360.0
264
+ ys = (1.0 - vp) * 360.0
265
+ pts = list(zip(xs.astype(float), ys.astype(float)))
266
+
267
+ cps_sorted = sorted(self.editor.control_points, key=lambda p: p.scenePos().x())
268
+ for p, (x, y) in zip(cps_sorted, pts):
269
+ p.setPos(x, y)
270
+
271
+ self._recolor_curve()
272
+ self.editor.updateCurve()
273
+ self._quick_preview()
274
+
275
+
276
+ def _recolor_curve(self):
277
+ color_map = {
278
+ "K (Brightness)": Qt.GlobalColor.white,
279
+ "R": Qt.GlobalColor.red, "G": Qt.GlobalColor.green, "B": Qt.GlobalColor.blue
280
+ }
281
+ ch = self.cmb_ch.currentText()
282
+ if getattr(self.editor, "curve_item", None):
283
+ pen = QPen(color_map[ch]); pen.setWidth(3)
284
+ self.editor.curve_item.setPen(pen)
285
+ self._quick_preview()
286
+
287
+ # ---------- preview/apply (same as CurvesDialogPro) ----------
288
+ def _build_lut01(self):
289
+ fn = getattr(self.editor, "getCurveFunction", None)
290
+ if not fn: return None
291
+ f = fn()
292
+ if f is None: return None
293
+ try:
294
+ return build_curve_lut(f, size=65536)
295
+ except Exception:
296
+ return None
297
+
298
+ def _quick_preview(self):
299
+ if self._preview_img is None:
300
+ return
301
+ lut01 = self._build_lut01()
302
+ if lut01 is None:
303
+ return
304
+ mode = self.cmb_ch.currentText()
305
+ out = _apply_mode_any(self._preview_img, mode, lut01)
306
+ out = self._blend_with_mask(out) # ✅ blend with mask
307
+ self._update_preview_pix(out)
308
+
309
+ def _apply(self):
310
+ if self._full_img is None:
311
+ return
312
+
313
+ luts = self._build_all_active_luts()
314
+
315
+ self.btn_apply.setEnabled(False)
316
+ self._thr = _CurvesWorker(self._full_img, luts, self)
317
+ # ⬇️ use the handler you ALREADY have, which commits + metadata + reset
318
+ self._thr.done.connect(self._on_apply_ready)
319
+ self._thr.finished.connect(lambda: self.btn_apply.setEnabled(True))
320
+ self._thr.start()
321
+
322
+ def _build_all_active_luts(self) -> dict[str, np.ndarray]:
323
+ """
324
+ For GHS we really only have ONE curve at a time – the one in self.editor –
325
+ and we apply it to the currently selected channel.
326
+ The worker wants a dict like {"K": lut} or {"R": lut}.
327
+ """
328
+ lut = self._build_lut01()
329
+ if lut is None:
330
+ return {}
331
+
332
+ ch = self.cmb_ch.currentText()
333
+ # map UI text → worker key
334
+ ui2key = {
335
+ "K (Brightness)": "K",
336
+ "R": "R",
337
+ "G": "G",
338
+ "B": "B",
339
+ }
340
+ key = ui2key.get(ch, "K")
341
+ return {key: lut}
342
+
343
+ def _apply_all_curves_once(self, img: np.ndarray, luts: dict[str, np.ndarray]) -> np.ndarray:
344
+ """
345
+ This is what _CurvesWorker will call.
346
+ We only ever expect 0 or 1 LUT here.
347
+ """
348
+ if not luts:
349
+ return img
350
+
351
+ # pull the single entry
352
+ (key, lut), = luts.items()
353
+
354
+ # map worker key → mode string used by _apply_mode_any
355
+ key2mode = {
356
+ "K": "K (Brightness)",
357
+ "R": "R",
358
+ "G": "G",
359
+ "B": "B",
360
+ }
361
+ mode = key2mode.get(key, "K (Brightness)")
362
+
363
+ out = _apply_mode_any(img, mode, lut)
364
+ return out.astype(np.float32, copy=False)
365
+
366
+ def _on_apply_commit_ready(self, out01: np.ndarray):
367
+ # honor mask, same as preview
368
+ out01 = self._blend_with_mask(out01)
369
+
370
+ # 🔴 safety: if the document currently holds RGB but we got mono back,
371
+ # make it 3-channel so apply_edit doesn’t silently ignore it
372
+ doc_img = np.asarray(self.doc.image)
373
+ if doc_img.ndim == 3 and out01.ndim == 2:
374
+ out01 = np.repeat(out01[..., None], 3, axis=2)
375
+
376
+ # now do the normal commit (history, reload, reset curves, etc.)
377
+ self._commit(out01)
378
+
379
+
380
+ def _on_apply_ready(self, out01: np.ndarray):
381
+ try:
382
+ # honor mask, same as preview
383
+ out_masked = self._blend_with_mask(out01)
384
+
385
+ # 🔹 build a single params dict used by:
386
+ # - metadata["ghs"]
387
+ # - replay_last_action preset
388
+ ghs_params = {
389
+ "alpha": self.sA.value() / 50.0,
390
+ "beta": self.sB.value() / 50.0,
391
+ "gamma": self.sG.value() / 100.0,
392
+ "lp": self.sLP.value() / 360.0,
393
+ "hp": self.sHP.value() / 360.0,
394
+ "pivot": float(self._sym_u),
395
+ "channel": self.cmb_ch.currentText(),
396
+ }
397
+
398
+ _marr, mid, mname = self._active_mask_layer()
399
+ meta = {
400
+ "step_name": "Hyperbolic Stretch",
401
+ "ghs": ghs_params,
402
+ "masked": bool(mid),
403
+ "mask_id": mid,
404
+ "mask_name": mname,
405
+ "mask_blend": "m*out + (1-m)*src",
406
+ }
407
+
408
+ # 🔁 Register this as "last action" for *both* dialog-replay and headless replay
409
+ mw = self.parent()
410
+ # Walk up to the main window
411
+ while mw is not None and not (
412
+ hasattr(mw, "_remember_last_action_from_dialog")
413
+ or hasattr(mw, "_remember_last_headless_command")
414
+ ):
415
+ mw = mw.parent()
416
+
417
+ if mw is not None:
418
+ # Dialog-style (keeps your existing mechanism, if used elsewhere)
419
+ if hasattr(mw, "_remember_last_action_from_dialog"):
420
+ try:
421
+ mw._remember_last_action_from_dialog("ghs", ghs_params)
422
+ except Exception:
423
+ pass
424
+
425
+ # Headless-style (this is what ROI replay uses)
426
+ if hasattr(mw, "_remember_last_headless_command"):
427
+ try:
428
+ mw._remember_last_headless_command(
429
+ "ghs",
430
+ ghs_params,
431
+ description="Hyperbolic Stretch",
432
+ )
433
+ # DEBUG
434
+ try:
435
+ mw._log(
436
+ f"[Replay] GHS stored as headless command: "
437
+ f"preset_keys={list(ghs_params.keys())}"
438
+ )
439
+ except Exception:
440
+ print(
441
+ "[Replay] GHS stored as headless command, "
442
+ "preset_keys=",
443
+ list(ghs_params.keys()),
444
+ )
445
+ except Exception as e:
446
+ print("[Replay] GHS remember_last_headless_command failed:", e)
447
+
448
+
449
+ # Commit result to the document
450
+ self.doc.apply_edit(out_masked.copy(),
451
+ metadata=meta,
452
+ step_name="Hyperbolic Stretch")
453
+
454
+ # 🔄 Refresh buffers from the updated doc
455
+ self._load_from_doc()
456
+
457
+ # 🔄 Reset pivot + curve drawing for the next pass
458
+ self._sym_u = 0.5
459
+ self.editor.clearSymmetryLine()
460
+ self.editor.initCurve()
461
+ self.sA.setValue(50); self.sB.setValue(50); self.sG.setValue(100)
462
+ self.sLP.setValue(0); self.sHP.setValue(0)
463
+ self._rebuild_from_params()
464
+ QTimer.singleShot(0, self._fit)
465
+
466
+ except Exception as e:
467
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
468
+
469
+
470
+ # ---------- image plumbing / zoom/pan ----------
471
+ def _load_from_doc(self):
472
+ img = self.doc.image
473
+ if img is None:
474
+ QMessageBox.information(self, self.tr("No image"), self.tr("Open an image first."))
475
+ return
476
+ arr = np.asarray(img).astype(np.float32)
477
+ if arr.dtype.kind in "ui":
478
+ arr = arr / np.iinfo(img.dtype).max
479
+ self._full_img = arr
480
+ self._preview_img = _downsample_for_preview(arr, 1200)
481
+ self._update_preview_pix(self._preview_img)
482
+
483
+ def _update_preview_pix(self, img01):
484
+ if img01 is None:
485
+ self.label.clear(); self._pix = None; return
486
+ qimg = _float_to_qimage_rgb8(img01)
487
+ pm = QPixmap.fromImage(qimg)
488
+ self._pix = pm
489
+ self._apply_zoom()
490
+
491
+ def _apply_zoom(self):
492
+ if self._pix is None: return
493
+ scaled = self._pix.scaled(self._pix.size()*self._zoom,
494
+ Qt.AspectRatioMode.KeepAspectRatio,
495
+ Qt.TransformationMode.SmoothTransformation)
496
+ self.label.setPixmap(scaled)
497
+ self.label.resize(scaled.size())
498
+
499
+ def _set_zoom(self, z):
500
+ self._zoom = float(max(0.05, min(z, 8.0)))
501
+ self._apply_zoom()
502
+
503
+ def _fit(self):
504
+ if self._pix is None: return
505
+ vp = self.scroll.viewport().size()
506
+ if self._pix.width()==0 or self._pix.height()==0: return
507
+ s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
508
+ self._set_zoom(max(0.05, s))
509
+
510
+ def _k_from_label_point(self, lbl_pt):
511
+ """lbl_pt is in label (pixmap) coordinates."""
512
+ if self._preview_img is None or self.label.pixmap() is None:
513
+ return None
514
+ pix = self.label.pixmap()
515
+ pw, ph = pix.width(), pix.height()
516
+ x, y = int(lbl_pt.x()), int(lbl_pt.y())
517
+ if not (0 <= x < pw and 0 <= y < ph):
518
+ return None
519
+ ih, iw = self._preview_img.shape[:2]
520
+ ix = int(x * iw / pw)
521
+ iy = int(y * ih / ph)
522
+ ix = max(0, min(iw - 1, ix))
523
+ iy = max(0, min(ih - 1, iy))
524
+ px = self._preview_img[iy, ix]
525
+ k = float(np.mean(px)) if self._preview_img.ndim == 3 else float(px)
526
+ return max(0.0, min(1.0, k))
527
+
528
+ # ctrl+wheel zoom + panning + ctrl+click on preview to move pivot
529
+ def eventFilter(self, obj, ev):
530
+ lbl = getattr(self, "label", None)
531
+ if lbl is None:
532
+ return False
533
+ # --- set pivot on DOUBLE-CLICK (or Ctrl+click) anywhere over the image ---
534
+ if (obj is self.label or obj is self.scroll.viewport()):
535
+ # Double-click → set pivot
536
+ if ev.type() == QEvent.Type.MouseButtonDblClick and ev.button() == Qt.MouseButton.LeftButton:
537
+ lbl_pt = (ev.position().toPoint() if obj is self.label
538
+ else self.label.mapFrom(self.scroll.viewport(), ev.position().toPoint()))
539
+ k = self._k_from_label_point(lbl_pt)
540
+ if k is not None:
541
+ self._sym_u = k
542
+ self.editor.setSymmetryPoint(k * 360.0, 0)
543
+ self._rebuild_from_params()
544
+ ev.accept(); return True
545
+
546
+ # Keep Ctrl+single-click support too
547
+ if (ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton
548
+ and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
549
+ lbl_pt = (ev.position().toPoint() if obj is self.label
550
+ else self.label.mapFrom(self.scroll.viewport(), ev.position().toPoint()))
551
+ k = self._k_from_label_point(lbl_pt)
552
+ if k is not None:
553
+ self._sym_u = k
554
+ self.editor.setSymmetryPoint(k * 360.0, 0)
555
+ self._rebuild_from_params()
556
+ ev.accept(); return True
557
+
558
+ # --- existing zoom/pan handling (unchanged) ---
559
+ if obj is self.scroll.viewport():
560
+ if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
561
+ self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
562
+ ev.accept(); return True
563
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
564
+ self._panning = True; self._pan_start = ev.position()
565
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
566
+ ev.accept(); return True
567
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
568
+ d = ev.position() - self._pan_start
569
+ h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
570
+ h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
571
+ self._pan_start = ev.position()
572
+ ev.accept(); return True
573
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
574
+ self._panning = False
575
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
576
+ ev.accept(); return True
577
+
578
+ return super().eventFilter(obj, ev)
579
+
580
+ def _on_preview_mouse_moved(self, x: float, y: float):
581
+ if self._panning or self._preview_img is None or self._pix is None:
582
+ return
583
+ ix = int(x / max(self._zoom, 1e-6))
584
+ iy = int(y / max(self._zoom, 1e-6))
585
+ ix = max(0, min(self._pix.width() - 1, ix))
586
+ iy = max(0, min(self._pix.height() - 1, iy))
587
+
588
+ img = self._preview_img
589
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
590
+ v = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
591
+ v = float(np.clip(v, 0.0, 1.0))
592
+ self.editor.updateValueLines(v, 0.0, 0.0, grayscale=True)
593
+ else:
594
+ r, g, b = img[iy, ix, 0], img[iy, ix, 1], img[iy, ix, 2]
595
+ r = float(np.clip(r, 0.0, 1.0)); g = float(np.clip(g, 0.0, 1.0)); b = float(np.clip(b, 0.0, 1.0))
596
+ self.editor.updateValueLines(r, g, b, grayscale=False)
597
+
598
+ # --- mask helpers ---------------------------------------------------
599
+ def _active_mask_layer(self):
600
+ """Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
601
+ mid = getattr(self.doc, "active_mask_id", None)
602
+ if not mid: return None, None, None
603
+ layer = getattr(self.doc, "masks", {}).get(mid)
604
+ if layer is None: return None, None, None
605
+ m = np.asarray(getattr(layer, "data", None))
606
+ if m is None or m.size == 0: return None, None, None
607
+ m = m.astype(np.float32, copy=False)
608
+ if m.dtype.kind in "ui":
609
+ m /= float(np.iinfo(m.dtype).max)
610
+ else:
611
+ mx = float(m.max()) if m.size else 1.0
612
+ if mx > 1.0: m /= mx
613
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
614
+
615
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
616
+ """Nearest-neighbor resize via integer indexing."""
617
+ mh, mw = mask.shape[:2]
618
+ th, tw = out_hw
619
+ if (mh, mw) == (th, tw): return mask
620
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
621
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
622
+ return mask[yi][:, xi]
623
+
624
+ def _blend_with_mask(self, processed: np.ndarray) -> np.ndarray:
625
+ """
626
+ Blend processed image with original using active mask (if any).
627
+ Chooses original from preview/full buffers to match shape.
628
+ """
629
+ mask, _mid, _mname = self._active_mask_layer()
630
+ if mask is None:
631
+ return processed
632
+
633
+ out = processed.astype(np.float32, copy=False)
634
+
635
+ # choose the matching original buffer (same HxW as 'out')
636
+ if (hasattr(self, "_full_img") and self._full_img is not None
637
+ and out.shape[:2] == self._full_img.shape[:2]):
638
+ src = self._full_img
639
+ else:
640
+ src = self._preview_img
641
+
642
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
643
+ if out.ndim == 3 and out.shape[2] == 3:
644
+ m = m[..., None]
645
+
646
+ # reconcile mono vs RGB
647
+ if src.ndim == 2 and out.ndim == 3:
648
+ src = np.stack([src]*3, axis=-1)
649
+ elif src.ndim == 3 and out.ndim == 2:
650
+ src = src[..., 0]
651
+
652
+ return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
653
+
654
+
655
+ def _reset(self):
656
+ self.sA.setValue(50); self.sB.setValue(50); self.sG.setValue(100)
657
+ self.sLP.setValue(0); self.sHP.setValue(0)
658
+ self._sym_u = 0.5
659
+ self.editor.clearSymmetryLine()
660
+ self._rebuild_from_params()