setiastrosuitepro 1.6.5.post3__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 (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,663 @@
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.setWindowFlag(Qt.WindowType.Window, True)
28
+ self.setWindowModality(Qt.WindowModality.NonModal)
29
+ self.setModal(False)
30
+ self.doc = document
31
+ self._preview_img = None
32
+ self._full_img = None
33
+ self._pix = None
34
+ self._zoom = 0.25
35
+ self._panning = False
36
+ self._pan_start = QPointF()
37
+ self._sym_u = 0.5 # pivot in [0..1]
38
+
39
+ # ---------- layout ----------
40
+ main = QHBoxLayout(self)
41
+
42
+ # Left controls
43
+ left = QVBoxLayout()
44
+ self.editor = CurveEditor(self)
45
+ left.addWidget(self.editor)
46
+
47
+ hint = QLabel(self.tr("Tip: Ctrl+Click (or double-click) the image to set the symmetry pivot"))
48
+ hint.setStyleSheet("color: #888; font-size: 11px;")
49
+ left.addWidget(hint)
50
+ self.editor.setToolTip(self.tr("Ctrl+Click (or double-click) the image to set the symmetry pivot"))
51
+
52
+ # channel selector
53
+ ch_row = QHBoxLayout()
54
+ ch_row.addWidget(QLabel(self.tr("Channel:")))
55
+ self.cmb_ch = QComboBox(self)
56
+ self.cmb_ch.addItems(["K (Brightness)", "R", "G", "B"])
57
+ ch_row.addWidget(self.cmb_ch)
58
+ left.addLayout(ch_row)
59
+
60
+ # α / β / γ
61
+ def _mk_slider_row(name, rng, val):
62
+ row = QHBoxLayout()
63
+ lab = QLabel(name); row.addWidget(lab)
64
+ s = QSlider(Qt.Orientation.Horizontal); s.setRange(*rng); s.setValue(val); row.addWidget(s)
65
+ v = QLabel(f"{val/100:.2f}" if name=="γ" else f"{val/50:.2f}"); row.addWidget(v)
66
+ return row, s, v
67
+
68
+ rowA, self.sA, self.labA = _mk_slider_row("α", (1, 500), 50) # 1.0
69
+ rowB, self.sB, self.labB = _mk_slider_row("β", (1, 500), 50) # 1.0
70
+ rowG, self.sG, self.labG = _mk_slider_row("γ", (1, 500), 100) # 1.0
71
+ left.addLayout(rowA); left.addLayout(rowB); left.addLayout(rowG)
72
+
73
+ # LP / HP (protect)
74
+ rowLP = QHBoxLayout(); rowHP = QHBoxLayout()
75
+ 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)
76
+ 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)
77
+ left.addLayout(rowLP); left.addLayout(rowHP)
78
+
79
+ # Buttons
80
+ rowb = QHBoxLayout()
81
+ self.btn_apply = QPushButton(self.tr("Apply"))
82
+ self.btn_reset = QToolButton(); self.btn_reset.setText(self.tr("Reset"))
83
+ self.btn_hist = QToolButton(); self.btn_hist.setText(self.tr("Histogram"))
84
+ self.btn_hist.setToolTip(self.tr("Open a Histogram for this image.\n"
85
+ "Ctrl+Click on the histogram to set the GHS pivot."))
86
+ rowb.addWidget(self.btn_apply)
87
+ rowb.addWidget(self.btn_reset)
88
+ rowb.addWidget(self.btn_hist)
89
+ left.addLayout(rowb)
90
+ left.addStretch(1)
91
+
92
+ main.addLayout(left, 0)
93
+
94
+ # --- Right preview panel ---
95
+ right = QVBoxLayout()
96
+ zoombar = QHBoxLayout()
97
+ zoombar.addStretch(1)
98
+
99
+ b_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
100
+ b_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
101
+ b_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
102
+
103
+ zoombar.addWidget(b_out)
104
+ zoombar.addWidget(b_in)
105
+ zoombar.addWidget(b_fit)
106
+
107
+ right.addLayout(zoombar)
108
+ self.scroll = QScrollArea()
109
+ self.scroll.setWidgetResizable(True)
110
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
111
+
112
+ # CREATE LABEL FIRST
113
+ self.label = ImageLabel(self) # <- make sure ImageLabel is imported
114
+ self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
115
+ self.label.mouseMoved.connect(self._on_preview_mouse_moved)
116
+ self.label.installEventFilter(self)
117
+
118
+ self.scroll.setWidget(self.label)
119
+ # INSTALL FILTERS AFTER label exists
120
+ self.scroll.viewport().installEventFilter(self)
121
+
122
+ right.addWidget(self.scroll, 1)
123
+ main.addLayout(right, 1)
124
+
125
+ # ---------- wiring ----------
126
+ self.editor.setPreviewCallback(lambda _lut8: self._quick_preview())
127
+ self.editor.setSymmetryCallback(self._on_symmetry_pick)
128
+
129
+ self.sA.valueChanged.connect(self._rebuild_from_params)
130
+ self.sB.valueChanged.connect(self._rebuild_from_params)
131
+ self.sG.valueChanged.connect(self._rebuild_from_params)
132
+ self.sLP.valueChanged.connect(self._rebuild_from_params)
133
+ self.sHP.valueChanged.connect(self._rebuild_from_params)
134
+ self.cmb_ch.currentTextChanged.connect(self._recolor_curve)
135
+
136
+ self.btn_apply.clicked.connect(self._apply)
137
+ self.btn_reset.clicked.connect(self._reset)
138
+ self._hist_dlg = None # will hold our per-GHS histogram dialog
139
+ self.btn_hist.clicked.connect(self._open_histogram)
140
+
141
+ b_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
142
+ b_in .clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
143
+ b_fit.clicked.connect(self._fit)
144
+
145
+ # seed image data
146
+ self._load_from_doc()
147
+
148
+ # start with Fit to Preview (avoids offset issues)
149
+ QTimer.singleShot(0, self._fit)
150
+
151
+
152
+ # first curve
153
+ self._rebuild_from_params()
154
+
155
+ # ---------- params → handles/curve ----------
156
+ def _open_histogram(self):
157
+ """Open (or raise) a HistogramDialog bound to this document and
158
+ connect its pivot signal to our symmetry pivot."""
159
+ try:
160
+ from .histogram import HistogramDialog
161
+ except Exception as e:
162
+ QMessageBox.warning(self, self.tr("Histogram"),
163
+ self.tr("Could not import histogram module:\n{0}").format(e))
164
+ return
165
+
166
+ # If we already created one and it's still alive, just bring it forward.
167
+ if self._hist_dlg is not None:
168
+ try:
169
+ if self._hist_dlg.isVisible():
170
+ self._hist_dlg.raise_()
171
+ self._hist_dlg.activateWindow()
172
+ return
173
+ except RuntimeError:
174
+ # dialog was destroyed; fall through to recreate
175
+ self._hist_dlg = None
176
+
177
+ dlg = HistogramDialog(self, self.doc)
178
+ self._hist_dlg = dlg
179
+ try:
180
+ dlg.pivotPicked.connect(self._on_hist_pivot)
181
+ except Exception:
182
+ # if signal isn't there for some reason, just ignore
183
+ pass
184
+ dlg.show()
185
+
186
+ def _on_hist_pivot(self, u: float):
187
+ """
188
+ Receive normalized pivot from Histogram (0..1) and update our symmetry
189
+ point & curve.
190
+ """
191
+ u = float(np.clip(u, 0.0, 1.0))
192
+ self._sym_u = u
193
+ # CurveEditor uses 0..360 in X; Y doesn't matter for the vertical line
194
+ self.editor.setSymmetryPoint(u * 360.0, 0.0)
195
+ self._rebuild_from_params()
196
+
197
+
198
+ def _on_symmetry_pick(self, u, v):
199
+ self._sym_u = float(u)
200
+ self._rebuild_from_params()
201
+
202
+ def _rebuild_from_params(self):
203
+ a = self.sA.value()/50.0
204
+ b = self.sB.value()/50.0
205
+ g = self.sG.value()/100.0
206
+ self.labA.setText(f"{a:.2f}")
207
+ self.labB.setText(f"{b:.2f}")
208
+ self.labG.setText(f"{g:.2f}")
209
+
210
+ # number of handles (keep existing count or default to 20)
211
+ N = len(self.editor.control_points) or 20
212
+ if len(self.editor.control_points) == 0:
213
+ for _ in range(N):
214
+ self.editor.addControlPoint(0, 0)
215
+
216
+ SP = float(self._sym_u)
217
+ eps = 1e-6
218
+
219
+ # --- sample around 0.5, then REMAP x to SP (this is the key) ---
220
+ us = np.linspace(0.0, 1.0, N) # even sampling
221
+ left = us <= 0.5
222
+ right = ~left
223
+
224
+ # generalized hyperbolic (two shapes, mirrored at 0.5)
225
+ rawL = us**a / (us**a + b*(1.0-us)**a)
226
+ rawR = us**a / (us**a + (1.0/b)*(1.0-us)**a)
227
+
228
+ midL = (0.5**a) / (0.5**a + b*(0.5)**a)
229
+ midR = (0.5**a) / (0.5**a + (1.0/b)*(0.5)**a)
230
+
231
+ # map domain to pivoted x ("up") and scaled y ("vp")
232
+ up = np.empty_like(us)
233
+ vp = np.empty_like(us)
234
+
235
+ # left half → [0 .. SP]
236
+ up[left] = 2.0 * SP * us[left]
237
+ vp[left] = rawL[left] * (SP / max(midL, eps))
238
+
239
+ # right half → [SP .. 1]
240
+ up[right] = SP + 2.0*(1.0 - SP)*(us[right] - 0.5)
241
+ vp[right] = SP + (rawR[right] - midR) * ((1.0 - SP) / max(1.0 - midR, eps))
242
+
243
+ # LP/HP protection: blend toward identity (vp == up)
244
+ LP = self.sLP.value()/360.0
245
+ HP = self.sHP.value()/360.0
246
+
247
+ if LP > 0:
248
+ m = up <= SP
249
+ vp[m] = (1.0 - LP)*vp[m] + LP*up[m]
250
+ if HP > 0:
251
+ m = up >= SP
252
+ vp[m] = (1.0 - HP)*vp[m] + HP*up[m]
253
+
254
+ self.labLP.setText(f"{LP:.2f}")
255
+ self.labHP.setText(f"{HP:.2f}")
256
+
257
+ # gamma lift
258
+ if abs(g - 1.0) > 1e-6:
259
+ vp = np.clip(vp, 0.0, 1.0) ** (1.0 / g)
260
+
261
+ # keep in range & gently enforce monotonicity to avoid tiny dips
262
+ vp = np.clip(vp, 0.0, 1.0)
263
+ vp = np.maximum.accumulate(vp)
264
+
265
+ # write handles back (x rightward, y inverted for the grid)
266
+ xs = up * 360.0
267
+ ys = (1.0 - vp) * 360.0
268
+ pts = list(zip(xs.astype(float), ys.astype(float)))
269
+
270
+ cps_sorted = sorted(self.editor.control_points, key=lambda p: p.scenePos().x())
271
+ for p, (x, y) in zip(cps_sorted, pts):
272
+ p.setPos(x, y)
273
+
274
+ self._recolor_curve()
275
+ self.editor.updateCurve()
276
+ self._quick_preview()
277
+
278
+
279
+ def _recolor_curve(self):
280
+ color_map = {
281
+ "K (Brightness)": Qt.GlobalColor.white,
282
+ "R": Qt.GlobalColor.red, "G": Qt.GlobalColor.green, "B": Qt.GlobalColor.blue
283
+ }
284
+ ch = self.cmb_ch.currentText()
285
+ if getattr(self.editor, "curve_item", None):
286
+ pen = QPen(color_map[ch]); pen.setWidth(3)
287
+ self.editor.curve_item.setPen(pen)
288
+ self._quick_preview()
289
+
290
+ # ---------- preview/apply (same as CurvesDialogPro) ----------
291
+ def _build_lut01(self):
292
+ fn = getattr(self.editor, "getCurveFunction", None)
293
+ if not fn: return None
294
+ f = fn()
295
+ if f is None: return None
296
+ try:
297
+ return build_curve_lut(f, size=65536)
298
+ except Exception:
299
+ return None
300
+
301
+ def _quick_preview(self):
302
+ if self._preview_img is None:
303
+ return
304
+ lut01 = self._build_lut01()
305
+ if lut01 is None:
306
+ return
307
+ mode = self.cmb_ch.currentText()
308
+ out = _apply_mode_any(self._preview_img, mode, lut01)
309
+ out = self._blend_with_mask(out) # ✅ blend with mask
310
+ self._update_preview_pix(out)
311
+
312
+ def _apply(self):
313
+ if self._full_img is None:
314
+ return
315
+
316
+ luts = self._build_all_active_luts()
317
+
318
+ self.btn_apply.setEnabled(False)
319
+ self._thr = _CurvesWorker(self._full_img, luts, self)
320
+ # ⬇️ use the handler you ALREADY have, which commits + metadata + reset
321
+ self._thr.done.connect(self._on_apply_ready)
322
+ self._thr.finished.connect(lambda: self.btn_apply.setEnabled(True))
323
+ self._thr.start()
324
+
325
+ def _build_all_active_luts(self) -> dict[str, np.ndarray]:
326
+ """
327
+ For GHS we really only have ONE curve at a time – the one in self.editor –
328
+ and we apply it to the currently selected channel.
329
+ The worker wants a dict like {"K": lut} or {"R": lut}.
330
+ """
331
+ lut = self._build_lut01()
332
+ if lut is None:
333
+ return {}
334
+
335
+ ch = self.cmb_ch.currentText()
336
+ # map UI text → worker key
337
+ ui2key = {
338
+ "K (Brightness)": "K",
339
+ "R": "R",
340
+ "G": "G",
341
+ "B": "B",
342
+ }
343
+ key = ui2key.get(ch, "K")
344
+ return {key: lut}
345
+
346
+ def _apply_all_curves_once(self, img: np.ndarray, luts: dict[str, np.ndarray]) -> np.ndarray:
347
+ """
348
+ This is what _CurvesWorker will call.
349
+ We only ever expect 0 or 1 LUT here.
350
+ """
351
+ if not luts:
352
+ return img
353
+
354
+ # pull the single entry
355
+ (key, lut), = luts.items()
356
+
357
+ # map worker key → mode string used by _apply_mode_any
358
+ key2mode = {
359
+ "K": "K (Brightness)",
360
+ "R": "R",
361
+ "G": "G",
362
+ "B": "B",
363
+ }
364
+ mode = key2mode.get(key, "K (Brightness)")
365
+
366
+ out = _apply_mode_any(img, mode, lut)
367
+ return out.astype(np.float32, copy=False)
368
+
369
+ def _on_apply_commit_ready(self, out01: np.ndarray):
370
+ # honor mask, same as preview
371
+ out01 = self._blend_with_mask(out01)
372
+
373
+ # 🔴 safety: if the document currently holds RGB but we got mono back,
374
+ # make it 3-channel so apply_edit doesn’t silently ignore it
375
+ doc_img = np.asarray(self.doc.image)
376
+ if doc_img.ndim == 3 and out01.ndim == 2:
377
+ out01 = np.repeat(out01[..., None], 3, axis=2)
378
+
379
+ # now do the normal commit (history, reload, reset curves, etc.)
380
+ self._commit(out01)
381
+
382
+
383
+ def _on_apply_ready(self, out01: np.ndarray):
384
+ try:
385
+ # honor mask, same as preview
386
+ out_masked = self._blend_with_mask(out01)
387
+
388
+ # 🔹 build a single params dict used by:
389
+ # - metadata["ghs"]
390
+ # - replay_last_action preset
391
+ ghs_params = {
392
+ "alpha": self.sA.value() / 50.0,
393
+ "beta": self.sB.value() / 50.0,
394
+ "gamma": self.sG.value() / 100.0,
395
+ "lp": self.sLP.value() / 360.0,
396
+ "hp": self.sHP.value() / 360.0,
397
+ "pivot": float(self._sym_u),
398
+ "channel": self.cmb_ch.currentText(),
399
+ }
400
+
401
+ _marr, mid, mname = self._active_mask_layer()
402
+ meta = {
403
+ "step_name": "Hyperbolic Stretch",
404
+ "ghs": ghs_params,
405
+ "masked": bool(mid),
406
+ "mask_id": mid,
407
+ "mask_name": mname,
408
+ "mask_blend": "m*out + (1-m)*src",
409
+ }
410
+
411
+ # 🔁 Register this as "last action" for *both* dialog-replay and headless replay
412
+ mw = self.parent()
413
+ # Walk up to the main window
414
+ while mw is not None and not (
415
+ hasattr(mw, "_remember_last_action_from_dialog")
416
+ or hasattr(mw, "_remember_last_headless_command")
417
+ ):
418
+ mw = mw.parent()
419
+
420
+ if mw is not None:
421
+ # Dialog-style (keeps your existing mechanism, if used elsewhere)
422
+ if hasattr(mw, "_remember_last_action_from_dialog"):
423
+ try:
424
+ mw._remember_last_action_from_dialog("ghs", ghs_params)
425
+ except Exception:
426
+ pass
427
+
428
+ # Headless-style (this is what ROI replay uses)
429
+ if hasattr(mw, "_remember_last_headless_command"):
430
+ try:
431
+ mw._remember_last_headless_command(
432
+ "ghs",
433
+ ghs_params,
434
+ description="Hyperbolic Stretch",
435
+ )
436
+ # DEBUG
437
+ try:
438
+ mw._log(
439
+ f"[Replay] GHS stored as headless command: "
440
+ f"preset_keys={list(ghs_params.keys())}"
441
+ )
442
+ except Exception:
443
+ print(
444
+ "[Replay] GHS stored as headless command, "
445
+ "preset_keys=",
446
+ list(ghs_params.keys()),
447
+ )
448
+ except Exception as e:
449
+ print("[Replay] GHS remember_last_headless_command failed:", e)
450
+
451
+
452
+ # Commit result to the document
453
+ self.doc.apply_edit(out_masked.copy(),
454
+ metadata=meta,
455
+ step_name="Hyperbolic Stretch")
456
+
457
+ # 🔄 Refresh buffers from the updated doc
458
+ self._load_from_doc()
459
+
460
+ # 🔄 Reset pivot + curve drawing for the next pass
461
+ self._sym_u = 0.5
462
+ self.editor.clearSymmetryLine()
463
+ self.editor.initCurve()
464
+ self.sA.setValue(50); self.sB.setValue(50); self.sG.setValue(100)
465
+ self.sLP.setValue(0); self.sHP.setValue(0)
466
+ self._rebuild_from_params()
467
+ QTimer.singleShot(0, self._fit)
468
+
469
+ except Exception as e:
470
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
471
+
472
+
473
+ # ---------- image plumbing / zoom/pan ----------
474
+ def _load_from_doc(self):
475
+ img = self.doc.image
476
+ if img is None:
477
+ QMessageBox.information(self, self.tr("No image"), self.tr("Open an image first."))
478
+ return
479
+ arr = np.asarray(img).astype(np.float32)
480
+ if arr.dtype.kind in "ui":
481
+ arr = arr / np.iinfo(img.dtype).max
482
+ self._full_img = arr
483
+ self._preview_img = _downsample_for_preview(arr, 1200)
484
+ self._update_preview_pix(self._preview_img)
485
+
486
+ def _update_preview_pix(self, img01):
487
+ if img01 is None:
488
+ self.label.clear(); self._pix = None; return
489
+ qimg = _float_to_qimage_rgb8(img01)
490
+ pm = QPixmap.fromImage(qimg)
491
+ self._pix = pm
492
+ self._apply_zoom()
493
+
494
+ def _apply_zoom(self):
495
+ if self._pix is None: return
496
+ scaled = self._pix.scaled(self._pix.size()*self._zoom,
497
+ Qt.AspectRatioMode.KeepAspectRatio,
498
+ Qt.TransformationMode.SmoothTransformation)
499
+ self.label.setPixmap(scaled)
500
+ self.label.resize(scaled.size())
501
+
502
+ def _set_zoom(self, z):
503
+ self._zoom = float(max(0.05, min(z, 8.0)))
504
+ self._apply_zoom()
505
+
506
+ def _fit(self):
507
+ if self._pix is None: return
508
+ vp = self.scroll.viewport().size()
509
+ if self._pix.width()==0 or self._pix.height()==0: return
510
+ s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
511
+ self._set_zoom(max(0.05, s))
512
+
513
+ def _k_from_label_point(self, lbl_pt):
514
+ """lbl_pt is in label (pixmap) coordinates."""
515
+ if self._preview_img is None or self.label.pixmap() is None:
516
+ return None
517
+ pix = self.label.pixmap()
518
+ pw, ph = pix.width(), pix.height()
519
+ x, y = int(lbl_pt.x()), int(lbl_pt.y())
520
+ if not (0 <= x < pw and 0 <= y < ph):
521
+ return None
522
+ ih, iw = self._preview_img.shape[:2]
523
+ ix = int(x * iw / pw)
524
+ iy = int(y * ih / ph)
525
+ ix = max(0, min(iw - 1, ix))
526
+ iy = max(0, min(ih - 1, iy))
527
+ px = self._preview_img[iy, ix]
528
+ k = float(np.mean(px)) if self._preview_img.ndim == 3 else float(px)
529
+ return max(0.0, min(1.0, k))
530
+
531
+ # ctrl+wheel zoom + panning + ctrl+click on preview to move pivot
532
+ def eventFilter(self, obj, ev):
533
+ lbl = getattr(self, "label", None)
534
+ if lbl is None:
535
+ return False
536
+ # --- set pivot on DOUBLE-CLICK (or Ctrl+click) anywhere over the image ---
537
+ if (obj is self.label or obj is self.scroll.viewport()):
538
+ # Double-click → set pivot
539
+ if ev.type() == QEvent.Type.MouseButtonDblClick and ev.button() == Qt.MouseButton.LeftButton:
540
+ lbl_pt = (ev.position().toPoint() if obj is self.label
541
+ else self.label.mapFrom(self.scroll.viewport(), ev.position().toPoint()))
542
+ k = self._k_from_label_point(lbl_pt)
543
+ if k is not None:
544
+ self._sym_u = k
545
+ self.editor.setSymmetryPoint(k * 360.0, 0)
546
+ self._rebuild_from_params()
547
+ ev.accept(); return True
548
+
549
+ # Keep Ctrl+single-click support too
550
+ if (ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton
551
+ and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier)):
552
+ lbl_pt = (ev.position().toPoint() if obj is self.label
553
+ else self.label.mapFrom(self.scroll.viewport(), ev.position().toPoint()))
554
+ k = self._k_from_label_point(lbl_pt)
555
+ if k is not None:
556
+ self._sym_u = k
557
+ self.editor.setSymmetryPoint(k * 360.0, 0)
558
+ self._rebuild_from_params()
559
+ ev.accept(); return True
560
+
561
+ # --- existing zoom/pan handling (unchanged) ---
562
+ if obj is self.scroll.viewport():
563
+ if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
564
+ self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
565
+ ev.accept(); return True
566
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
567
+ self._panning = True; self._pan_start = ev.position()
568
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
569
+ ev.accept(); return True
570
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
571
+ d = ev.position() - self._pan_start
572
+ h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
573
+ h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
574
+ self._pan_start = ev.position()
575
+ ev.accept(); return True
576
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
577
+ self._panning = False
578
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
579
+ ev.accept(); return True
580
+
581
+ return super().eventFilter(obj, ev)
582
+
583
+ def _on_preview_mouse_moved(self, x: float, y: float):
584
+ if self._panning or self._preview_img is None or self._pix is None:
585
+ return
586
+ ix = int(x / max(self._zoom, 1e-6))
587
+ iy = int(y / max(self._zoom, 1e-6))
588
+ ix = max(0, min(self._pix.width() - 1, ix))
589
+ iy = max(0, min(self._pix.height() - 1, iy))
590
+
591
+ img = self._preview_img
592
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
593
+ v = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
594
+ v = float(np.clip(v, 0.0, 1.0))
595
+ self.editor.updateValueLines(v, 0.0, 0.0, grayscale=True)
596
+ else:
597
+ r, g, b = img[iy, ix, 0], img[iy, ix, 1], img[iy, ix, 2]
598
+ 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))
599
+ self.editor.updateValueLines(r, g, b, grayscale=False)
600
+
601
+ # --- mask helpers ---------------------------------------------------
602
+ def _active_mask_layer(self):
603
+ """Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
604
+ mid = getattr(self.doc, "active_mask_id", None)
605
+ if not mid: return None, None, None
606
+ layer = getattr(self.doc, "masks", {}).get(mid)
607
+ if layer is None: return None, None, None
608
+ m = np.asarray(getattr(layer, "data", None))
609
+ if m is None or m.size == 0: return None, None, None
610
+ m = m.astype(np.float32, copy=False)
611
+ if m.dtype.kind in "ui":
612
+ m /= float(np.iinfo(m.dtype).max)
613
+ else:
614
+ mx = float(m.max()) if m.size else 1.0
615
+ if mx > 1.0: m /= mx
616
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
617
+
618
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
619
+ """Nearest-neighbor resize via integer indexing."""
620
+ mh, mw = mask.shape[:2]
621
+ th, tw = out_hw
622
+ if (mh, mw) == (th, tw): return mask
623
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
624
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
625
+ return mask[yi][:, xi]
626
+
627
+ def _blend_with_mask(self, processed: np.ndarray) -> np.ndarray:
628
+ """
629
+ Blend processed image with original using active mask (if any).
630
+ Chooses original from preview/full buffers to match shape.
631
+ """
632
+ mask, _mid, _mname = self._active_mask_layer()
633
+ if mask is None:
634
+ return processed
635
+
636
+ out = processed.astype(np.float32, copy=False)
637
+
638
+ # choose the matching original buffer (same HxW as 'out')
639
+ if (hasattr(self, "_full_img") and self._full_img is not None
640
+ and out.shape[:2] == self._full_img.shape[:2]):
641
+ src = self._full_img
642
+ else:
643
+ src = self._preview_img
644
+
645
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
646
+ if out.ndim == 3 and out.shape[2] == 3:
647
+ m = m[..., None]
648
+
649
+ # reconcile mono vs RGB
650
+ if src.ndim == 2 and out.ndim == 3:
651
+ src = np.stack([src]*3, axis=-1)
652
+ elif src.ndim == 3 and out.ndim == 2:
653
+ src = src[..., 0]
654
+
655
+ return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
656
+
657
+
658
+ def _reset(self):
659
+ self.sA.setValue(50); self.sB.setValue(50); self.sG.setValue(100)
660
+ self.sLP.setValue(0); self.sHP.setValue(0)
661
+ self._sym_u = 0.5
662
+ self.editor.clearSymmetryLine()
663
+ self._rebuild_from_params()