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,1611 @@
1
+ from __future__ import annotations
2
+ import numpy as np
3
+
4
+ try:
5
+ import cv2
6
+ except Exception:
7
+ cv2 = None
8
+
9
+ from PyQt6.QtCore import Qt, QTimer
10
+ from PyQt6.QtGui import QImage, QPixmap, QIcon, QGuiApplication
11
+ from PyQt6.QtWidgets import (
12
+ QDialog, QWidget, QLabel, QPushButton, QComboBox, QCheckBox, QSlider, QGroupBox,
13
+ QVBoxLayout, QHBoxLayout, QGridLayout, QMessageBox, QSpinBox, QDoubleSpinBox,
14
+ QFileDialog, QScrollArea, QFrame, QTabWidget, QSplitter
15
+ )
16
+ from PyQt6.QtWidgets import QSizePolicy
17
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
18
+
19
+ # ---------------------------------------------------------------------
20
+ # Small helpers
21
+ # ---------------------------------------------------------------------
22
+
23
+ def _to_uint8_rgb(img01: np.ndarray) -> np.ndarray:
24
+ a = np.clip(img01, 0.0, 1.0)
25
+ if a.ndim == 2:
26
+ a = np.repeat(a[..., None], 3, axis=2)
27
+ return (a * 255.0 + 0.5).astype(np.uint8)
28
+
29
+ def _to_pixmap(img01: np.ndarray) -> QPixmap:
30
+ a = _to_uint8_rgb(img01)
31
+ h, w, _ = a.shape
32
+ qimg = QImage(a.data, w, h, a.strides[0], QImage.Format.Format_RGB888)
33
+ return QPixmap.fromImage(qimg)
34
+
35
+ def _rgb_to_hsv01(img01: np.ndarray) -> np.ndarray:
36
+ if cv2 is None:
37
+ raise RuntimeError("OpenCV (cv2) is required for Selective Color.")
38
+ # expects 8-bit for best speed
39
+ u8 = _to_uint8_rgb(img01)
40
+ hsv = cv2.cvtColor(u8, cv2.COLOR_RGB2HSV) # H in [0,180], S,V in [0,255]
41
+ out = np.empty_like(hsv, dtype=np.float32)
42
+ out[...,0] = hsv[...,0].astype(np.float32) / 180.0 # 0..1
43
+ out[...,1] = hsv[...,1].astype(np.float32) / 255.0
44
+ out[...,2] = hsv[...,2].astype(np.float32) / 255.0
45
+ return out
46
+
47
+ def _luminance01(img01: np.ndarray) -> np.ndarray:
48
+ if img01.ndim == 2:
49
+ return np.clip(img01, 0.0, 1.0).astype(np.float32)
50
+ r, g, b = img01[...,0], img01[...,1], img01[...,2]
51
+ return (0.2989*r + 0.5870*g + 0.1140*b).astype(np.float32)
52
+
53
+ def _softstep(x, edge0, edge1):
54
+ # smoothstep
55
+ t = np.clip((x - edge0) / max(edge1 - edge0, 1e-6), 0.0, 1.0)
56
+ return t * t * (3 - 2*t)
57
+
58
+ # ---------------------------------------------------------------------
59
+ # Mask generation
60
+ # ---------------------------------------------------------------------
61
+
62
+ _PRESETS = {
63
+ "Red": [(340, 360), (0, 15)],
64
+ "Orange": [(15, 40)],
65
+ "Yellow": [(40, 70)],
66
+ "Green": [(70, 170)],
67
+ "Cyan": [(170, 200)],
68
+ "Blue": [(200, 270)],
69
+ "Magenta": [(270, 340)],
70
+ }
71
+
72
+ def _hue_band(Hdeg: np.ndarray, lo: float, hi: float, smooth_deg: float) -> np.ndarray:
73
+ """
74
+ Soft band on the hue circle (degrees 0..360), but with *local* feathering:
75
+ - core band is the forward arc lo → hi
76
+ - smooth_deg only adds a ramp *right after hi* and *right before lo*
77
+ - never balloons into the whole hue wheel
78
+ """
79
+ H = Hdeg.astype(np.float32)
80
+
81
+ lo = float(lo) % 360.0
82
+ hi = float(hi) % 360.0
83
+
84
+ # length of the forward arc
85
+ L = (hi - lo) % 360.0
86
+ if L <= 1e-6:
87
+ return np.zeros_like(H, dtype=np.float32)
88
+
89
+ s = float(max(smooth_deg, 0.0))
90
+
91
+ # forward distance from lo → hue (always 0..360)
92
+ fwd = (H - lo) % 360.0
93
+ # backward distance from hue → lo (always 0..360)
94
+ bwd = (lo - H) % 360.0
95
+
96
+ # start with zeros
97
+ band = np.zeros_like(H, dtype=np.float32)
98
+
99
+ # 1) core: strictly inside the band
100
+ inside = (fwd <= L)
101
+ band[inside] = 1.0
102
+
103
+ if s > 1e-6:
104
+ # 2) upper feather: just after hi
105
+ upper = (fwd > L) & (fwd < L + s)
106
+ band[upper] = np.maximum(
107
+ band[upper],
108
+ 1.0 - (fwd[upper] - L) / s
109
+ )
110
+
111
+ # 3) lower feather: just before lo (going backwards)
112
+ lower = (bwd > 0) & (bwd < s)
113
+ band[lower] = np.maximum(
114
+ band[lower],
115
+ 1.0 - bwd[lower] / s
116
+ )
117
+
118
+ return np.clip(band, 0.0, 1.0).astype(np.float32)
119
+
120
+
121
+
122
+ def _hue_mask(img01: np.ndarray,
123
+ ranges_deg: list[tuple[float,float]],
124
+ min_chroma: float,
125
+ min_light: float,
126
+ max_light: float,
127
+ smooth_deg: float,
128
+ invert_range: bool = False) -> np.ndarray:
129
+ """
130
+ Return mask in 0..1 for the UNION of hue bands in ranges_deg (degrees).
131
+ Handles wrap-around without recursion. If invert_range=True, selects the
132
+ COMPLEMENT of the union on the hue circle (before chroma/light gating).
133
+ """
134
+ hsv = _rgb_to_hsv01(img01) # H in [0..1)
135
+ Hdeg = (np.mod(hsv[..., 0] * 360.0, 360.0)).astype(np.float32)
136
+ S = hsv[..., 1].astype(np.float32)
137
+ V = hsv[..., 2].astype(np.float32)
138
+
139
+ m = np.zeros_like(Hdeg, dtype=np.float32)
140
+ for lo, hi in ranges_deg:
141
+ m = np.maximum(m, _hue_band(Hdeg, lo, hi, smooth_deg))
142
+
143
+ # Invert selection on the hue circle if requested
144
+ if invert_range:
145
+ m = 1.0 - m
146
+
147
+ # chroma/light gating
148
+ if min_chroma > 0:
149
+ chroma = (S * V).astype(np.float32)
150
+ m *= _softstep(chroma, float(min_chroma)*0.7, float(min_chroma))
151
+ if min_light > 0:
152
+ m *= (V >= float(min_light)).astype(np.float32)
153
+ if max_light < 1:
154
+ m *= (V <= float(max_light)).astype(np.float32)
155
+
156
+ return np.clip(m, 0.0, 1.0)
157
+
158
+
159
+
160
+
161
+ def _weight_shadows_highlights(mask: np.ndarray,
162
+ img01: np.ndarray,
163
+ shadows: float,
164
+ highlights: float,
165
+ balance: float) -> np.ndarray:
166
+ """
167
+ New behavior:
168
+ - `shadows` in [0..1]: pixels BELOW this luminance get faded OUT.
169
+ - `highlights` in [0..1]: pixels ABOVE this luminance get faded OUT.
170
+ - `balance` just tweaks feather width (optional).
171
+ """
172
+ L = _luminance01(img01).astype(np.float32)
173
+ w = np.ones_like(L, dtype=np.float32)
174
+
175
+ # feather size ~ 8% of range, you can tune this
176
+ feather = 0.08 + 0.12 * balance # 0.08..0.2
177
+
178
+ # 1) shadow gate: fade OUT below `shadows`
179
+ if shadows > 1e-3:
180
+ s0 = max(0.0, shadows - feather)
181
+ s1 = min(1.0, shadows + 1e-6)
182
+ # below s0 → 0, above s1 → 1
183
+ w *= _softstep(L, s0, s1)
184
+
185
+ # 2) highlight gate: fade OUT above `highlights`
186
+ if highlights < 0.999:
187
+ h0 = max(0.0, highlights - 1e-6)
188
+ h1 = min(1.0, highlights + feather)
189
+ # below h0 → 1, above h1 → 0
190
+ w *= (1.0 - _softstep(L, h0, h1))
191
+
192
+ # apply to mask
193
+ return np.clip(mask * w, 0.0, 1.0)
194
+
195
+
196
+ # ---------------------------------------------------------------------
197
+ # Color adjustments
198
+ # ---------------------------------------------------------------------
199
+
200
+ def _apply_selective_adjustments(img01: np.ndarray,
201
+ mask01: np.ndarray,
202
+ cyan: float, magenta: float, yellow: float,
203
+ r: float, g: float, b: float,
204
+ lum: float, chroma: float, sat: float, con: float,
205
+ intensity: float,
206
+ use_chroma_mode: bool) -> np.ndarray:
207
+
208
+ """
209
+ CMY/RGB sliders in [-1..+1] range (we’ll clamp).
210
+ L/S/C also in [-1..+1].
211
+ """
212
+ a = img01.astype(np.float32, copy=True)
213
+ m = np.clip(mask01.astype(np.float32) * float(intensity), 0.0, 1.0)
214
+
215
+ # RGB base
216
+ if a.ndim == 2:
217
+ a = np.repeat(a[..., None], 3, axis=2)
218
+
219
+ R = a[...,0]; G = a[...,1]; B = a[...,2]
220
+
221
+ # CMY = reduce the complementary primary
222
+ # Positive Cyan -> reduce Red; negative Cyan -> increase Red.
223
+ R = np.clip(R + (-cyan) * m, 0.0, 1.0)
224
+ G = np.clip(G + (-magenta) * m, 0.0, 1.0)
225
+ B = np.clip(B + (-yellow) * m, 0.0, 1.0)
226
+
227
+ # Primary boosts
228
+ R = np.clip(R + r * m, 0.0, 1.0)
229
+ G = np.clip(G + g * m, 0.0, 1.0)
230
+ B = np.clip(B + b * m, 0.0, 1.0)
231
+
232
+ out = np.stack([R,G,B], axis=-1)
233
+
234
+ # L / Chroma-or-Sat / Contrast
235
+ if any(abs(x) > 1e-6 for x in (lum, chroma, sat, con)):
236
+ if abs(lum) > 0:
237
+ out = np.clip(out + lum * m[..., None], 0.0, 1.0)
238
+
239
+ if abs(con) > 0:
240
+ out = np.clip((out - 0.5) * (1.0 + con * m[..., None]) + 0.5, 0.0, 1.0)
241
+
242
+ if use_chroma_mode:
243
+ if abs(chroma) > 0:
244
+ out = _apply_chroma_boost(out, m, chroma)
245
+ else:
246
+ if abs(sat) > 0:
247
+ hsv = _rgb_to_hsv01(out)
248
+ hsv[..., 1] = np.clip(hsv[..., 1] * (1.0 + sat * m), 0.0, 1.0)
249
+ # HSV->RGB using cv2 (expects 8-bit)
250
+ hv = (hsv[..., 0] * 180.0).astype(np.uint8)
251
+ sv = (hsv[..., 1] * 255.0).astype(np.uint8)
252
+ vv = (hsv[..., 2] * 255.0).astype(np.uint8)
253
+ hsv8 = np.stack([hv, sv, vv], axis=-1)
254
+ rgb8 = cv2.cvtColor(hsv8, cv2.COLOR_HSV2RGB)
255
+ out = rgb8.astype(np.float32) / 255.0
256
+
257
+ return np.clip(out, 0.0, 1.0)
258
+
259
+ def _apply_chroma_boost(rgb01: np.ndarray, m01: np.ndarray, chroma: float) -> np.ndarray:
260
+ """
261
+ L-preserving chroma change:
262
+ rgb' = Y + (rgb - Y) * (1 + chroma * m)
263
+ where Y is luminance and m is the 0..1 mask (with intensity applied upstream).
264
+ Positive chroma -> more colorfulness; negative -> less.
265
+ """
266
+ rgb = _ensure_rgb01(rgb01).astype(np.float32)
267
+ m = np.clip(m01.astype(np.float32), 0.0, 1.0)[..., None]
268
+ Y = _luminance01(rgb)[..., None] # HxWx1
269
+ d = rgb - Y # chroma direction
270
+ k = (1.0 + float(chroma) * m) # scale per-pixel with mask
271
+ out = Y + d * k
272
+ return np.clip(out, 0.0, 1.0)
273
+
274
+
275
+ def _ensure_rgb01(img: np.ndarray) -> np.ndarray:
276
+ """Return an RGB float image in [0,1]."""
277
+ a = np.clip(img.astype(np.float32), 0.0, 1.0)
278
+ if a.ndim == 2:
279
+ a = np.repeat(a[..., None], 3, axis=2)
280
+ return a
281
+
282
+ class HueWheel(QWidget):
283
+ """
284
+ A compact HSV hue wheel with two draggable handles for start/end (degrees 0..360).
285
+ Emits rangeChanged(start_deg, end_deg) when either handle moves.
286
+ """
287
+ from PyQt6.QtCore import pyqtSignal
288
+ rangeChanged = pyqtSignal(int, int)
289
+
290
+ def __init__(self, start_deg=65, end_deg=158, parent=None):
291
+ super().__init__(parent)
292
+ self.setMinimumSize(160, 160)
293
+ self._start = int(start_deg) % 360
294
+ self._end = int(end_deg) % 360
295
+ self._dragging = None # "start" | "end" | None
296
+ self._ring_img = None
297
+ self._picked = None # degrees or None
298
+
299
+ # --- public API
300
+ def setRange(self, start_deg: int, end_deg: int, notify=True):
301
+ s = int(start_deg) % 360
302
+ e = int(end_deg) % 360
303
+ if s == self._start and e == self._end:
304
+ return
305
+ self._start, self._end = s, e
306
+ self.update()
307
+ if notify:
308
+ self.rangeChanged.emit(self._start, self._end)
309
+
310
+ def range(self):
311
+ return self._start, self._end
312
+
313
+ def setPickedHue(self, deg: float | int | None):
314
+ """Show a small marker on the wheel at the sampled hue (degrees)."""
315
+ if deg is None:
316
+ self._picked = None
317
+ else:
318
+ self._picked = int(deg) % 360
319
+ self.update()
320
+
321
+ # --- util
322
+ @staticmethod
323
+ def _ang_from_pos(cx, cy, x, y):
324
+ import math
325
+ a = math.degrees(math.atan2(y - cy, x - cx))
326
+ a = (a + 360.0) % 360.0
327
+ return a
328
+
329
+ def _ensure_ring(self, side):
330
+ # cache a color wheel image to paint fast
331
+ if self._ring_img is not None and self._ring_img.width() == side and self._ring_img.height() == side:
332
+ return
333
+ import math
334
+ side = int(side)
335
+ img = np.zeros((side, side, 3), np.uint8)
336
+ cx = cy = side // 2
337
+ r = int(side*0.48)
338
+ rr2 = r*r
339
+ for y in range(side):
340
+ dy = y - cy
341
+ for x in range(side):
342
+ dx = x - cx
343
+ d2 = dx*dx + dy*dy
344
+ if rr2 - r*12 <= d2 <= rr2: # thin ring
345
+ ang = self._ang_from_pos(cx, cy, x, y)
346
+ hsv = np.array([ang/2, 255, 255], np.uint8) # H 0..180 in OpenCV
347
+ rgb = cv2.cvtColor(hsv[None,None,:], cv2.COLOR_HSV2RGB)[0,0]
348
+ img[y, x] = rgb
349
+ h, w, _ = img.shape
350
+ self._ring_img = QImage(img.data, w, h, img.strides[0], QImage.Format.Format_RGB888).copy()
351
+
352
+ # --- events
353
+ def paintEvent(self, ev):
354
+ from PyQt6.QtGui import QPainter, QPen, QBrush, QColor
355
+ p = QPainter(self)
356
+ side = min(self.width(), self.height())
357
+ self._ensure_ring(side)
358
+
359
+ # center ring
360
+ x0 = (self.width() - side)//2
361
+ y0 = (self.height() - side)//2
362
+ p.drawImage(x0, y0, self._ring_img)
363
+
364
+ # draw handles & arc
365
+ cx = x0 + side//2
366
+ cy = y0 + side//2
367
+ r = int(side*0.48)
368
+
369
+ def pt(ang_deg):
370
+ import math
371
+ th = math.radians(ang_deg)
372
+ return int(cx + r*math.cos(th)), int(cy + r*math.sin(th))
373
+
374
+ # --- RANGE ARC (match mask logic) ---
375
+ # Mask defines band as positive arc from start -> end with L = (end - start) % 360.
376
+ s, e = int(self._start) % 360, int(self._end) % 360
377
+ L = (e - s) % 360 # arc length in degrees (0..359)
378
+ steps = 60
379
+ if L > 0:
380
+ p.setPen(QPen(QColor(255, 255, 255, 140), 4))
381
+ px, py = pt(s)
382
+ for k in range(1, steps + 1):
383
+ a = (s + (L * k) / steps) % 360 # move forward along the positive arc
384
+ qx, qy = pt(a)
385
+ p.drawLine(px, py, qx, qy)
386
+ px, py = qx, qy
387
+
388
+ # handles
389
+ p.setBrush(QBrush(QColor(255,255,255)))
390
+ p.setPen(QPen(QColor(0,0,0), 1))
391
+ for ang in (self._start, self._end):
392
+ xh, yh = pt(ang)
393
+ p.drawEllipse(xh-5, yh-5, 10, 10)
394
+
395
+ # sampled hue marker
396
+ if self._picked is not None:
397
+ import math
398
+ th = math.radians(self._picked)
399
+ px = int(cx + r*math.cos(th)); py = int(cy + r*math.sin(th))
400
+ p.setBrush(QBrush(QColor(0, 0, 0)))
401
+ p.setPen(QPen(QColor(255, 255, 255), 2))
402
+ p.drawEllipse(px-6, py-6, 12, 12)
403
+
404
+
405
+ def mousePressEvent(self, ev):
406
+ x, y = ev.position().x(), ev.position().y()
407
+ side = min(self.width(), self.height())
408
+ x0 = (self.width() - side)//2
409
+ y0 = (self.height() - side)//2
410
+ cx = x0 + side//2
411
+ cy = y0 + side//2
412
+ a = self._ang_from_pos(cx, cy, x, y)
413
+ # pick the nearest handle
414
+ def d(a0, a1):
415
+ dd = abs((a0 - a1 + 180) % 360 - 180)
416
+ return dd
417
+ if d(a, self._start) <= d(a, self._end):
418
+ self._dragging = "start"
419
+ self._start = int(a)
420
+ else:
421
+ self._dragging = "end"
422
+ self._end = int(a)
423
+ self.update()
424
+ self.rangeChanged.emit(self._start, self._end)
425
+
426
+ def mouseMoveEvent(self, ev):
427
+ if not self._dragging:
428
+ return
429
+ x, y = ev.position().x(), ev.position().y()
430
+ side = min(self.width(), self.height())
431
+ x0 = (self.width() - side)//2
432
+ y0 = (self.height() - side)//2
433
+ cx = x0 + side//2
434
+ cy = y0 + side//2
435
+ a = int(self._ang_from_pos(cx, cy, x, y)) % 360
436
+ if self._dragging == "start":
437
+ self._start = a
438
+ else:
439
+ self._end = a
440
+ self.update()
441
+ self.rangeChanged.emit(self._start, self._end)
442
+
443
+ def mouseReleaseEvent(self, ev):
444
+ self._dragging = None
445
+
446
+
447
+ # ---------------------------------------------------------------------
448
+ # UI
449
+ # ---------------------------------------------------------------------
450
+
451
+ class SelectiveColorCorrection(QDialog):
452
+ """
453
+ v1.0 — live preview, mask overlay, presets + custom hue range,
454
+ CMY/RGB + L/S/C sliders. Loads active document's image.
455
+ """
456
+ def __init__(self, doc_manager=None, document=None, parent=None, window_icon: QIcon | None = None):
457
+ super().__init__(parent)
458
+ self.setWindowTitle(self.tr("Selective Color Correction"))
459
+ if window_icon:
460
+ self.setWindowIcon(window_icon)
461
+
462
+ self.docman = doc_manager
463
+ self.document = document
464
+ if self.document is None or getattr(self.document, "image", None) is None:
465
+ QMessageBox.information(self, "No image", "Open an image first.")
466
+ self.close(); return
467
+
468
+ self.img = np.clip(self.document.image.astype(np.float32), 0.0, 1.0)
469
+ self.preview_img = self.img.copy()
470
+ self._syncing_hue = False
471
+ self._imported_mask_full = None # full-res mask (H x W) float32 0..1
472
+ self._imported_mask_name = None # nice label to show in UI
473
+ self._use_imported_mask = False # checkbox state mirror
474
+ self._mask_delay_ms = 200
475
+ self._adj_delay_ms = 200
476
+ self._build_ui()
477
+ self._mask_delay_ms = 200 # 0.2s idle before recomputing mask
478
+ self._mask_timer = QTimer(self)
479
+ self._mask_timer.setSingleShot(True)
480
+ self._mask_timer.timeout.connect(self._recompute_mask_and_preview)
481
+ self._adj_delay_ms = 200
482
+ self._adj_timer = QTimer(self)
483
+ self._adj_timer.setSingleShot(True)
484
+ self._adj_timer.timeout.connect(self._update_preview_pixmap)
485
+ self.dd_preset.setCurrentText("Red")
486
+ self._setting_preset = False
487
+ self._recompute_mask_and_preview()
488
+ self._panning = False
489
+ self._pan_start_pos = None # QPointF in label coords
490
+ self._pan_start_scroll = (0, 0) # (hval, vval)
491
+ self._pan_deadzone = 1
492
+ self._pan_start_pos_vp = None
493
+
494
+ # ------------- UI -------------
495
+ def _build_ui(self):
496
+ # --- Root layout -------------------------------------------------------
497
+ root = QHBoxLayout(self)
498
+ root.setContentsMargins(8, 8, 8, 8)
499
+ root.setSpacing(10)
500
+
501
+ splitter = QSplitter(Qt.Orientation.Horizontal)
502
+ splitter.setChildrenCollapsible(False)
503
+ splitter.setHandleWidth(6)
504
+ root.addWidget(splitter)
505
+
506
+ # ======================================================================
507
+ # LEFT PANE (header → "small preview" toggle → scroller → live toggle → buttons)
508
+ # ======================================================================
509
+ left_widget = QWidget()
510
+ left_widget.setMinimumWidth(360)
511
+ left_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
512
+ splitter.addWidget(left_widget)
513
+
514
+ left_outer = QVBoxLayout(left_widget)
515
+ left_outer.setContentsMargins(0, 0, 0, 0)
516
+ left_outer.setSpacing(8)
517
+
518
+ # Header (target view label)
519
+ try:
520
+ disp = getattr(self.document, "display_name", lambda: "Image")()
521
+ except Exception:
522
+ disp = "Image"
523
+ self.lbl_target = QLabel(f"Target View: <b>{disp}</b>")
524
+ left_outer.addWidget(self.lbl_target)
525
+
526
+ # Small/fast preview toggle
527
+ self.cb_small_preview = QCheckBox("Small-sized Preview (fast)")
528
+ self.cb_small_preview.setChecked(True)
529
+ self.cb_small_preview.toggled.connect(self._recompute_mask_and_preview)
530
+ left_outer.addWidget(self.cb_small_preview)
531
+
532
+ # ---------- SCROLLABLE CONTROLS (placed inside a QScrollArea) ----------
533
+ controls_container = QWidget()
534
+ left = QVBoxLayout(controls_container)
535
+ left.setContentsMargins(0, 0, 0, 0)
536
+ left.setSpacing(8)
537
+
538
+ # ===== Mask group
539
+ gb_mask = QGroupBox(self.tr("Mask"))
540
+ gl = QGridLayout(gb_mask)
541
+ gl.setContentsMargins(8, 8, 8, 8)
542
+ gl.setHorizontalSpacing(10)
543
+ gl.setVerticalSpacing(8)
544
+
545
+ # Row 0: Preset
546
+ gl.addWidget(QLabel("Preset:"), 0, 0)
547
+ self.dd_preset = QComboBox()
548
+ self.dd_preset.addItems(["Custom"] + list(_PRESETS.keys()))
549
+ self.dd_preset.currentTextChanged.connect(self._on_preset_change)
550
+ gl.addWidget(self.dd_preset, 0, 1, 1, 4)
551
+
552
+ # Hue wheel
553
+ self.hue_wheel = HueWheel(start_deg=65, end_deg=158)
554
+ self.hue_wheel.setMinimumSize(130, 130)
555
+ self.hue_wheel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
556
+ gl.addWidget(self.hue_wheel, 1, 0, 7, 2)
557
+
558
+ # Wheel -> sliders/spins (so dragging wheel updates UI and mask)
559
+ def _wheel_to_sliders(s: int, e: int):
560
+ # If user is dragging the wheel, we’re now custom
561
+ if not self._setting_preset and self.dd_preset.currentText() != "Custom":
562
+ self.dd_preset.blockSignals(True)
563
+ self.dd_preset.setCurrentText("Custom")
564
+ self.dd_preset.blockSignals(False)
565
+
566
+ # Update BOTH sliders and spins, without ping-pong
567
+ self._syncing_hue = True
568
+ try:
569
+ s = int(s) % 360
570
+ e = int(e) % 360
571
+
572
+ for w, val in (
573
+ (self.sl_h1, s), (self.sp_h1, s),
574
+ (self.sl_h2, e), (self.sp_h2, e),
575
+ ):
576
+ w.blockSignals(True)
577
+ w.setValue(val)
578
+ w.blockSignals(False)
579
+ finally:
580
+ self._syncing_hue = False
581
+
582
+ self._schedule_mask()
583
+
584
+ self.hue_wheel.rangeChanged.connect(_wheel_to_sliders)
585
+
586
+
587
+
588
+ # Helper: integer slider + spin (0..360)
589
+ def _deg_pair(grid: QGridLayout, label: str, row: int):
590
+ grid.addWidget(QLabel(label), row, 2)
591
+ sld = QSlider(Qt.Orientation.Horizontal)
592
+ sld.setRange(0, 360); sld.setSingleStep(1); sld.setPageStep(10)
593
+ spn = QSpinBox(); spn.setRange(0, 360)
594
+ sld.valueChanged.connect(spn.setValue)
595
+ spn.valueChanged.connect(sld.setValue)
596
+ grid.addWidget(sld, row, 3, 1, 3)
597
+ grid.addWidget(spn, row, 6, 1, 1)
598
+ return sld, spn
599
+
600
+ # Rows 1–2: Hue Start/End
601
+ self.sl_h1, self.sp_h1 = _deg_pair(gl, "Hue start (°):", 1)
602
+ self.sl_h2, self.sp_h2 = _deg_pair(gl, "Hue end (°):", 2)
603
+ self.sp_h1.setValue(65); self.sp_h2.setValue(158)
604
+
605
+ # Row 3: chroma + lightness
606
+ gl.addWidget(QLabel("Min chroma:"), 3, 2)
607
+ self.ds_minC = QDoubleSpinBox(); self.ds_minC.setRange(0,1); self.ds_minC.setSingleStep(0.05); self.ds_minC.setValue(0.05)
608
+
609
+ self.ds_minC.valueChanged.connect(self._recompute_mask_and_preview)
610
+ gl.addWidget(self.ds_minC, 3, 3)
611
+
612
+ gl.addWidget(QLabel("Lightness min/max:"), 3, 4)
613
+ self.ds_minL = QDoubleSpinBox(); self.ds_minL.setRange(0,1); self.ds_minL.setSingleStep(0.05); self.ds_minL.setValue(0.0)
614
+ self.ds_maxL = QDoubleSpinBox(); self.ds_maxL.setRange(0,1); self.ds_maxL.setSingleStep(0.05); self.ds_maxL.setValue(1.0)
615
+ self.ds_minL.valueChanged.connect(self._recompute_mask_and_preview)
616
+ self.ds_maxL.valueChanged.connect(self._recompute_mask_and_preview)
617
+ gl.addWidget(self.ds_minL, 3, 5)
618
+ gl.addWidget(QLabel("to"), 3, 6)
619
+ gl.addWidget(self.ds_maxL, 3, 7)
620
+
621
+ # Row 4: smoothness + invert
622
+ gl.addWidget(QLabel("Smoothness (deg):"), 4, 2)
623
+ self.ds_smooth = QDoubleSpinBox(); self.ds_smooth.setRange(0,60); self.ds_smooth.setSingleStep(1.0); self.ds_smooth.setValue(10.0)
624
+ self.ds_smooth.valueChanged.connect(self._recompute_mask_and_preview)
625
+ gl.addWidget(self.ds_smooth, 4, 3)
626
+
627
+ self.cb_invert = QCheckBox("Invert hue range")
628
+ self.cb_invert.setChecked(False)
629
+ self.cb_invert.toggled.connect(self._recompute_mask_and_preview)
630
+ gl.addWidget(self.cb_invert, 4, 4, 1, 3)
631
+
632
+ # Row 5: shadows/highlights + intensity
633
+ gl.addWidget(QLabel("Shadows:"), 5, 2)
634
+ self.ds_sh = QDoubleSpinBox(); self.ds_sh.setRange(0,1); self.ds_sh.setSingleStep(0.05); self.ds_sh.setValue(0.0)
635
+ self.ds_sh.valueChanged.connect(self._recompute_mask_and_preview)
636
+ gl.addWidget(self.ds_sh, 5, 3)
637
+
638
+ gl.addWidget(QLabel("Highlights:"), 5, 4)
639
+ self.ds_hi = QDoubleSpinBox(); self.ds_hi.setRange(0,1); self.ds_hi.setSingleStep(0.05); self.ds_hi.setValue(1.0)
640
+ self.ds_hi.valueChanged.connect(self._recompute_mask_and_preview)
641
+ gl.addWidget(self.ds_hi, 5, 5)
642
+
643
+ self.ds_bal = QDoubleSpinBox(); self.ds_bal.setRange(0,1); self.ds_bal.setSingleStep(0.05); self.ds_bal.setValue(0.5)
644
+ self.ds_bal.valueChanged.connect(self._recompute_mask_and_preview)
645
+ self.ds_bal.setVisible(False) # used in math, hidden in UI
646
+
647
+ gl.addWidget(QLabel("Intensity:"), 5, 6)
648
+ self.ds_int = QDoubleSpinBox(); self.ds_int.setRange(0, 2.0); self.ds_int.setSingleStep(0.05); self.ds_int.setValue(1.0)
649
+ self.ds_int.valueChanged.connect(self._recompute_mask_and_preview)
650
+ gl.addWidget(self.ds_int, 5, 7)
651
+
652
+ # Row 6: blur + overlay
653
+ gl.addWidget(QLabel("Edge blur (px):"), 6, 2)
654
+ self.sb_blur = QSpinBox(); self.sb_blur.setRange(0, 150); self.sb_blur.setValue(0)
655
+ self.sb_blur.valueChanged.connect(self._recompute_mask_and_preview)
656
+ gl.addWidget(self.sb_blur, 6, 3)
657
+
658
+ self.cb_show_mask = QCheckBox("Show mask overlay")
659
+ self.cb_show_mask.setChecked(False)
660
+ self.cb_show_mask.toggled.connect(self._update_preview_pixmap)
661
+ gl.addWidget(self.cb_show_mask, 6, 4, 1, 2)
662
+
663
+ # Row 7: imported mask
664
+ self.cb_use_imported = QCheckBox("Use imported mask")
665
+ self.cb_use_imported.setChecked(False)
666
+ self.cb_use_imported.toggled.connect(self._on_use_imported_mask_toggled)
667
+ gl.addWidget(self.cb_use_imported, 7, 2, 1, 2)
668
+
669
+ self.btn_import_mask = QPushButton("Pick mask from view…")
670
+ self.btn_import_mask.clicked.connect(self._import_mask_from_view)
671
+ gl.addWidget(self.btn_import_mask, 7, 4, 1, 2)
672
+
673
+ self.lbl_imported_mask = QLabel("No imported mask")
674
+ gl.addWidget(self.lbl_imported_mask, 7, 6, 1, 2)
675
+
676
+ # Column sizing
677
+ gl.setColumnStretch(0, 0)
678
+ gl.setColumnStretch(1, 0)
679
+ for c in (2,3,4,5,6,7):
680
+ gl.setColumnStretch(c, 1)
681
+
682
+ left.addWidget(gb_mask)
683
+
684
+ # ===== Adjustments
685
+ # CMY
686
+ gb_cmy = QGroupBox(self.tr("Complementary colors (CMY)"))
687
+ glc = QGridLayout(gb_cmy)
688
+ self.sl_c, self.ds_c = self._slider_pair(glc, "Cyan:", 0)
689
+ self.sl_m, self.ds_m = self._slider_pair(glc, "Magenta:", 1)
690
+ self.sl_y, self.ds_y = self._slider_pair(glc, "Yellow:", 2)
691
+ left.addWidget(gb_cmy)
692
+
693
+ # RGB
694
+ gb_rgb = QGroupBox(self.tr("RGB Colors"))
695
+ glr = QGridLayout(gb_rgb)
696
+ self.sl_r, self.ds_r = self._slider_pair(glr, "Red:", 0)
697
+ self.sl_g, self.ds_g = self._slider_pair(glr, "Green:", 1)
698
+ self.sl_b, self.ds_b = self._slider_pair(glr, "Blue:", 2)
699
+ left.addWidget(gb_rgb)
700
+
701
+ # LSC
702
+ gb_lsc = QGroupBox(self.tr("Luminance, Chroma/Saturation, Contrast"))
703
+ gll = QGridLayout(gb_lsc)
704
+ self.sl_l, self.ds_l = self._slider_pair(gll, "Luminance:", 0)
705
+ self.sl_chroma, self.ds_chroma = self._slider_pair(gll, "Chroma (L-preserving):", 1)
706
+ self.sl_s, self.ds_s = self._slider_pair(gll, "Saturation (HSV S):", 2)
707
+ self.sl_c2, self.ds_c2 = self._slider_pair(gll, "Contrast:", 3)
708
+ gll.addWidget(QLabel("Color boost mode:"), 4, 0)
709
+ self.dd_color_mode = QComboBox()
710
+ self.dd_color_mode.addItems(["Chroma (L-preserving)", "Saturation (HSV S)"])
711
+ self.dd_color_mode.setCurrentIndex(0)
712
+ self.dd_color_mode.currentIndexChanged.connect(self._update_color_mode_enabled)
713
+ gll.addWidget(self.dd_color_mode, 4, 1, 1, 2)
714
+ left.addWidget(gb_lsc)
715
+
716
+ # Wrap controls in a scroller (horizontal scroll allowed if needed)
717
+ left_scroll = QScrollArea()
718
+ left_scroll.setWidget(controls_container)
719
+ left_scroll.setWidgetResizable(False)
720
+ left_scroll.setFrameShape(QFrame.Shape.NoFrame)
721
+ left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
722
+ left_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
723
+ left_outer.addWidget(left_scroll, 1)
724
+
725
+ # Live toggle (non-scroll)
726
+ self.cb_live = QCheckBox("Preview changed image")
727
+ self.cb_live.setChecked(True)
728
+ self.cb_live.toggled.connect(self._update_preview_pixmap)
729
+ left_outer.addWidget(self.cb_live)
730
+
731
+ # Buttons row (non-scroll)
732
+ row = QHBoxLayout()
733
+ self.btn_apply = QPushButton("Apply")
734
+ self.btn_apply.clicked.connect(self._apply_to_document)
735
+ self.btn_push = QPushButton("Apply as New Document")
736
+ self.btn_push.clicked.connect(self._apply_as_new_doc)
737
+ self.btn_export_mask = QPushButton("Export Mask")
738
+ self.btn_export_mask.clicked.connect(self._export_mask_doc)
739
+ self.btn_reset = QPushButton("↺ Reset")
740
+ self.btn_reset.clicked.connect(self._reset_controls)
741
+ row.addWidget(self.btn_apply)
742
+ row.addWidget(self.btn_push)
743
+ row.addWidget(self.btn_export_mask)
744
+ row.addWidget(self.btn_reset)
745
+ left_outer.addLayout(row)
746
+
747
+ # ======================================================================
748
+ # RIGHT PANE (zoom toolbar + preview scroller + picked hue readout)
749
+ # ======================================================================
750
+ right_widget = QWidget()
751
+ right_widget.setMinimumWidth(420)
752
+ right_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
753
+ splitter.addWidget(right_widget)
754
+
755
+ right = QVBoxLayout(right_widget)
756
+ right.setContentsMargins(0, 0, 0, 0)
757
+ right.setSpacing(8)
758
+
759
+ # Zoom toolbar (themed)
760
+ zoom_row = QHBoxLayout()
761
+
762
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
763
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
764
+ self.btn_zoom_1 = themed_toolbtn("zoom-original", "1:1")
765
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit")
766
+
767
+ zoom_row.addWidget(self.btn_zoom_out)
768
+ zoom_row.addWidget(self.btn_zoom_in)
769
+ zoom_row.addWidget(self.btn_zoom_1)
770
+ zoom_row.addWidget(self.btn_fit)
771
+ zoom_row.addStretch(1)
772
+ right.addLayout(zoom_row)
773
+
774
+ self.lbl_help = QLabel(
775
+ "🖱️ <b>Click</b>: show hue &nbsp;•&nbsp; "
776
+ "<b>Shift + Click</b>: select that color &nbsp;•&nbsp; "
777
+ "<b>Ctrl + Click & Drag</b>: pan &nbsp;•&nbsp; "
778
+ "<b>Ctrl + Wheel</b>: zoom"
779
+ )
780
+
781
+ self.lbl_help.setWordWrap(True)
782
+ self.lbl_help.setTextFormat(Qt.TextFormat.RichText)
783
+ self.lbl_help.setStyleSheet("color: #888; font-size: 11px;")
784
+ right.addWidget(self.lbl_help)
785
+
786
+ # Preview scroller
787
+ self.scroll = QScrollArea()
788
+ self.scroll.setWidgetResizable(False)
789
+ self.lbl_preview = QLabel()
790
+ self.lbl_preview.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
791
+ self.lbl_preview.setMinimumSize(10, 10)
792
+ self.scroll.setWidget(self.lbl_preview)
793
+ right.addWidget(self.scroll, 1)
794
+
795
+ vp = self.scroll.viewport()
796
+ vp.setMouseTracking(True)
797
+ vp.installEventFilter(self)
798
+
799
+ self.lbl_preview.setToolTip(
800
+ "Click to sample hue.\n"
801
+ "Ctrl + Click & Drag to pan.\n"
802
+ "Ctrl + Mouse Wheel to zoom."
803
+ )
804
+ self.btn_zoom_in.setToolTip("Zoom in (centers view)")
805
+ self.btn_zoom_out.setToolTip("Zoom out (centers view)")
806
+ self.btn_zoom_1.setToolTip("Reset zoom to 1:1")
807
+
808
+ # Hue readout
809
+ self.lbl_hue_readout = QLabel("Picked hue: —")
810
+ right.addWidget(self.lbl_hue_readout)
811
+
812
+ # Splitter stretch: make preview greedy
813
+ splitter.setStretchFactor(0, 0) # left
814
+ splitter.setStretchFactor(1, 1) # right
815
+ splitter.setSizes([420, 900])
816
+
817
+ # Clamp dialog height and add size grip
818
+ self.setSizeGripEnabled(True)
819
+ try:
820
+ g = QGuiApplication.primaryScreen().availableGeometry()
821
+ max_h = int(g.height() * 0.9)
822
+ self.resize(1080, min(680, max_h))
823
+ self.setMaximumHeight(max_h)
824
+ except Exception:
825
+ self.resize(1080, 680)
826
+
827
+ # ---- Wiring that depends on built widgets ----------------------------
828
+ self._update_color_mode_enabled()
829
+ for w in (self.ds_c, self.ds_m, self.ds_y, self.ds_r, self.ds_g, self.ds_b, self.ds_l, self.ds_s, self.ds_c2, self.ds_int):
830
+ w.valueChanged.connect(self._schedule_adjustments)
831
+
832
+ def _sliders_to_wheel(_=None):
833
+ if getattr(self, "_syncing_hue", False):
834
+ return
835
+
836
+ if not self._setting_preset and self.dd_preset.currentText() != "Custom":
837
+ self.dd_preset.blockSignals(True)
838
+ self.dd_preset.setCurrentText("Custom")
839
+ self.dd_preset.blockSignals(False)
840
+
841
+ s = int(self.sp_h1.value())
842
+ e = int(self.sp_h2.value())
843
+ self.hue_wheel.setRange(s, e, notify=False)
844
+ self._schedule_mask()
845
+
846
+
847
+ self.sp_h1.valueChanged.connect(_sliders_to_wheel)
848
+ self.sp_h2.valueChanged.connect(_sliders_to_wheel)
849
+ self.sl_h1.valueChanged.connect(_sliders_to_wheel)
850
+ self.sl_h2.valueChanged.connect(_sliders_to_wheel)
851
+
852
+ # Zoom behavior
853
+ self._zoom = 1.0
854
+
855
+
856
+ self.btn_zoom_in.clicked.connect(lambda: self._apply_zoom(self._zoom * 1.25, None))
857
+ self.btn_zoom_out.clicked.connect(lambda: self._apply_zoom(self._zoom / 1.25, None))
858
+ self.btn_zoom_1.clicked.connect(lambda: self._apply_zoom(1.0, None))
859
+
860
+ # Ctrl+wheel: zoom around mouse position (label coords)
861
+ def _wheel_event(ev):
862
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
863
+ factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
864
+ self._apply_zoom(self._zoom * factor, anchor_label_pos=ev.position())
865
+ ev.accept()
866
+ return
867
+ QLabel.wheelEvent(self.lbl_preview, ev)
868
+
869
+ self.lbl_preview.wheelEvent = _wheel_event
870
+
871
+ # Preview interactions
872
+ self.lbl_preview.setMouseTracking(True)
873
+ self.lbl_preview.installEventFilter(self)
874
+
875
+ # First paint
876
+ self._update_preview_pixmap()
877
+
878
+ # --- Zoom helpers ----------------------------------------------------
879
+ def _current_scroll(self):
880
+ hbar = self.scroll.horizontalScrollBar()
881
+ vbar = self.scroll.verticalScrollBar()
882
+ return hbar.value(), vbar.value(), hbar.maximum(), vbar.maximum()
883
+
884
+ def _set_scroll(self, x, y):
885
+ hbar = self.scroll.horizontalScrollBar()
886
+ vbar = self.scroll.verticalScrollBar()
887
+ hbar.setValue(int(max(0, min(x, hbar.maximum()))))
888
+ vbar.setValue(int(max(0, min(y, vbar.maximum()))))
889
+
890
+ def _apply_zoom(self, new_zoom: float, anchor_label_pos=None):
891
+ """
892
+ new_zoom: float
893
+ anchor_label_pos: QPointF in *label (content)* coords to keep fixed on screen.
894
+ If None, use viewport center.
895
+ """
896
+ old_zoom = getattr(self, "_zoom", 1.0)
897
+ new_zoom = max(0.05, min(16.0, float(new_zoom)))
898
+ if abs(new_zoom - old_zoom) < 1e-6:
899
+ return
900
+
901
+ # Figure out the anchor (content coords)
902
+ if anchor_label_pos is None:
903
+ # viewport center → content coords
904
+ sx, sy, _, _ = self._current_scroll()
905
+ vp = self.scroll.viewport().rect()
906
+ cx = (sx + vp.width() / 2.0) / max(old_zoom, 1e-9)
907
+ cy = (sy + vp.height() / 2.0) / max(old_zoom, 1e-9)
908
+ else:
909
+ cx = float(anchor_label_pos.x()) / max(1.0, 1.0) # label coords already in content space
910
+ cy = float(anchor_label_pos.y()) / max(1.0, 1.0)
911
+
912
+ # Where is that content point on the viewport *before* zoom?
913
+ sx, sy, _, _ = self._current_scroll()
914
+ vp = self.scroll.viewport().rect()
915
+ pvx = cx * old_zoom - sx # pixel pos in viewport
916
+ pvy = cy * old_zoom - sy
917
+
918
+ # Apply zoom and repaint
919
+ self._zoom = new_zoom
920
+ self._update_preview_pixmap()
921
+
922
+ # Set scroll so that the same content point stays at the same viewport pixel
923
+ nx = cx * new_zoom - pvx
924
+ ny = cy * new_zoom - pvy
925
+ self._set_scroll(nx, ny)
926
+
927
+ def _fit_to_preview(self):
928
+ if not hasattr(self, "_base_pm") or self._base_pm is None:
929
+ return
930
+ vp = self.scroll.viewport().size()
931
+ pm = self._base_pm.size()
932
+ if pm.width() <= 0 or pm.height() <= 0:
933
+ return
934
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
935
+ self._apply_zoom(k, anchor_label_pos=None)
936
+
937
+ # --- Pan helpers -----------------------------------------------------
938
+ def _begin_pan(self, pos_label):
939
+ self._panning = True
940
+ self._pan_start_pos = pos_label
941
+ hbar = self.scroll.horizontalScrollBar()
942
+ vbar = self.scroll.verticalScrollBar()
943
+ self._pan_start_scroll = (hbar.value(), vbar.value())
944
+ try:
945
+ self.lbl_preview.setCursor(Qt.CursorShape.ClosedHandCursor)
946
+ except Exception:
947
+ pass
948
+
949
+ def _update_pan(self, pos_label):
950
+ if not self._panning or self._pan_start_pos is None:
951
+ return
952
+ dx = pos_label.x() - self._pan_start_pos.x() # label pixels
953
+ dy = pos_label.y() - self._pan_start_pos.y()
954
+ sx0, sy0 = self._pan_start_scroll
955
+ # invert to move content with the mouse
956
+ self._set_scroll(sx0 - dx, sy0 - dy)
957
+
958
+ def _end_pan(self):
959
+ self._panning = False
960
+ self._pan_start_pos = None
961
+ try:
962
+ self.lbl_preview.setCursor(Qt.CursorShape.ArrowCursor)
963
+ except Exception:
964
+ pass
965
+
966
+
967
+ def _update_color_mode_enabled(self):
968
+ use_chroma = (self.dd_color_mode.currentIndex() == 0)
969
+ # enable Chroma controls when chroma mode; disable Sat controls, and vice versa
970
+ self.ds_chroma.setEnabled(use_chroma); self.sl_chroma.setEnabled(use_chroma)
971
+ self.ds_s.setEnabled(not use_chroma); self.sl_s.setEnabled(not use_chroma)
972
+ # refresh preview
973
+ self._schedule_adjustments()
974
+
975
+
976
+ def _set_pair(self, sld: QSlider, box: QDoubleSpinBox, value: float):
977
+ # block both sides to avoid ping-pong and callbacks
978
+ sld.blockSignals(True); box.blockSignals(True)
979
+ sld.setValue(int(round(value * 100))) # because slider units are *100
980
+ box.setValue(float(value))
981
+ sld.blockSignals(False); box.blockSignals(False)
982
+
983
+
984
+ def _reset_controls(self):
985
+ """Reset all UI controls to defaults and rebuild mask/preview on current self.img."""
986
+ # pause timers while resetting
987
+ self._mask_timer.stop()
988
+ self._adj_timer.stop()
989
+
990
+ # --- Preset: make 'Red' the default and let _on_preset_change drive the wheel/sliders ---
991
+ # IMPORTANT: do NOT overwrite with 'Custom' afterwards.
992
+ self._setting_preset = True
993
+ try:
994
+ # This emits currentTextChanged -> _on_preset_change(), which:
995
+ # - sets the hue_wheel to the preset range (notify=False)
996
+ # - sets sp_h1/sp_h2 to the preset lo/hi
997
+ # - calls _recompute_mask_and_preview()
998
+ self.dd_preset.setCurrentText("Red")
999
+ finally:
1000
+ self._setting_preset = False
1001
+
1002
+ # --- Mask gating defaults (won't change the preset/wheel) ---
1003
+ def setv(w, val):
1004
+ w.blockSignals(True)
1005
+ if isinstance(w, (QDoubleSpinBox, QSpinBox)):
1006
+ w.setValue(val)
1007
+ elif isinstance(w, QCheckBox):
1008
+ w.setChecked(bool(val))
1009
+ elif isinstance(w, QComboBox):
1010
+ idx = w.findText(val)
1011
+ if idx >= 0:
1012
+ w.setCurrentIndex(idx)
1013
+ elif isinstance(w, QSlider):
1014
+ w.setValue(int(val))
1015
+ w.blockSignals(False)
1016
+
1017
+ setv(self.ds_minC, 0.05)
1018
+ setv(self.ds_minL, 0.0)
1019
+ setv(self.ds_maxL, 1.0)
1020
+ setv(self.ds_smooth, 10.0)
1021
+ setv(self.cb_invert, False)
1022
+
1023
+ # Shadows/Highlights/Balance
1024
+ setv(self.ds_sh, 0.0)
1025
+ setv(self.ds_hi, 1.0)
1026
+ setv(self.ds_bal, 0.5)
1027
+
1028
+ # Blur / overlays / preview
1029
+ setv(self.sb_blur, 0)
1030
+ setv(self.cb_show_mask, False)
1031
+ # keep user’s small/large preview choice & zoom as-is
1032
+
1033
+ # CMY/RGB/LSC back to 0, intensity to 1.0
1034
+ self._set_pair(self.sl_c, self.ds_c, 0.0)
1035
+ self._set_pair(self.sl_m, self.ds_m, 0.0)
1036
+ self._set_pair(self.sl_y, self.ds_y, 0.0)
1037
+ self._set_pair(self.sl_r, self.ds_r, 0.0)
1038
+ self._set_pair(self.sl_g, self.ds_g, 0.0)
1039
+ self._set_pair(self.sl_b, self.ds_b, 0.0)
1040
+ self._set_pair(self.sl_l, self.ds_l, 0.0)
1041
+ self._set_pair(self.sl_s, self.ds_s, 0.0)
1042
+ self._set_pair(self.sl_c2, self.ds_c2, 0.0)
1043
+
1044
+ self._set_pair(self.sl_chroma, self.ds_chroma, 0.0)
1045
+ # default to Chroma mode
1046
+ self.dd_color_mode.blockSignals(True)
1047
+ self.dd_color_mode.setCurrentIndex(0)
1048
+ self.dd_color_mode.blockSignals(False)
1049
+ self._update_color_mode_enabled()
1050
+
1051
+ self.ds_int.blockSignals(True)
1052
+ self.ds_int.setValue(1.0)
1053
+ self.ds_int.blockSignals(False)
1054
+
1055
+ # Clear any sampled hue marker on the wheel
1056
+ self.hue_wheel.setPickedHue(None)
1057
+
1058
+ # Rebuild preview (preset handler already recomputed the mask, but this is safe)
1059
+ self._recompute_mask_and_preview()
1060
+
1061
+
1062
+ def _schedule_adjustments(self, *_, delay_ms: int | None = None):
1063
+ if delay_ms is None:
1064
+ delay_ms = getattr(self, "_adj_delay_ms", 200)
1065
+
1066
+ if not hasattr(self, "_adj_timer"):
1067
+ return
1068
+
1069
+ ms = max(1, int(delay_ms)) # never allow 0/negative
1070
+ self._adj_timer.stop()
1071
+ self._adj_timer.start(ms)
1072
+
1073
+ def _schedule_mask(self, *_, delay_ms: int | None = None):
1074
+ if delay_ms is None:
1075
+ delay_ms = getattr(self, "_mask_delay_ms", 200)
1076
+
1077
+ if not hasattr(self, "_mask_timer"):
1078
+ return
1079
+
1080
+ ms = max(1, int(delay_ms))
1081
+ self._mask_timer.stop()
1082
+ self._mask_timer.start(ms)
1083
+
1084
+
1085
+ def _sample_hue_deg_from_base(self, x: int, y: int) -> float | None:
1086
+ """Return hue in degrees at (x,y) in _last_base (float RGB in [0,1])."""
1087
+ base = getattr(self, "_last_base", None)
1088
+ if base is None:
1089
+ return None
1090
+ h, w = base.shape[:2]
1091
+ if not (0 <= x < w and 0 <= y < h):
1092
+ return None
1093
+ pix = base[y:y+1, x:x+1, :] if base.ndim == 3 else np.repeat(base[y:y+1, x:x+1][...,None], 3, axis=2)
1094
+ hsv = _rgb_to_hsv01(pix) # 1x1x3, H in [0,1]
1095
+ return float(hsv[0,0,0] * 360.0)
1096
+
1097
+ def _map_label_point_to_image_xy(self, ev_pos):
1098
+ """Map a click on the *label* to base image (x,y), accounting for zoom."""
1099
+ base = getattr(self, "_last_base", None)
1100
+ if base is None:
1101
+ return None
1102
+ bh, bw = base.shape[:2]
1103
+ # ev_pos is in the label's local coordinates
1104
+ x = int(round(ev_pos.x() / max(self._zoom, 1e-6)))
1105
+ y = int(round(ev_pos.y() / max(self._zoom, 1e-6)))
1106
+ if x < 0 or y < 0 or x >= bw or y >= bh:
1107
+ return None
1108
+ return (x, y)
1109
+
1110
+
1111
+ def eventFilter(self, obj, ev):
1112
+ from PyQt6.QtCore import QEvent, Qt
1113
+
1114
+ # Helper: get event position in *viewport* coords regardless of target
1115
+ def _pos_in_viewport(o, e):
1116
+ if o is self.scroll.viewport():
1117
+ return e.position() # already viewport coords (QPointF)
1118
+ # map label-local → viewport
1119
+ return self.lbl_preview.mapTo(self.scroll.viewport(), e.position().toPoint())
1120
+
1121
+ # --- PANNING (Ctrl + LMB) on viewport *or* label ---
1122
+ if obj in (self.scroll.viewport(), self.lbl_preview):
1123
+ if ev.type() == QEvent.Type.MouseButtonPress:
1124
+ if (ev.button() == Qt.MouseButton.LeftButton and
1125
+ ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
1126
+ self._panning = True
1127
+ self._pan_start_pos_vp = _pos_in_viewport(obj, ev)
1128
+ hbar = self.scroll.horizontalScrollBar()
1129
+ vbar = self.scroll.verticalScrollBar()
1130
+ self._pan_start_scroll = (hbar.value(), vbar.value())
1131
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
1132
+ return True
1133
+
1134
+ elif ev.type() == QEvent.Type.MouseMove and self._panning:
1135
+ cur = _pos_in_viewport(obj, ev)
1136
+ dx = cur.x() - self._pan_start_pos_vp.x()
1137
+ dy = cur.y() - self._pan_start_pos_vp.y()
1138
+ if abs(dx) > self._pan_deadzone or abs(dy) > self._pan_deadzone:
1139
+ hbar = self.scroll.horizontalScrollBar()
1140
+ vbar = self.scroll.verticalScrollBar()
1141
+ hbar.setValue(int(self._pan_start_scroll[0] - dx))
1142
+ vbar.setValue(int(self._pan_start_scroll[1] - dy))
1143
+ return True
1144
+
1145
+ elif ev.type() in (QEvent.Type.MouseButtonRelease, QEvent.Type.Leave):
1146
+ if self._panning:
1147
+ self._panning = False
1148
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
1149
+ return True
1150
+
1151
+ # --- Hue pick (plain click) on the label only ---
1152
+ if obj is self.lbl_preview and ev.type() == QEvent.Type.MouseButtonPress:
1153
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1154
+ return True # Ctrl is for panning; let pan branch handle it
1155
+ pt = self._map_label_point_to_image_xy(ev.position())
1156
+ if pt is not None:
1157
+ x, y = pt
1158
+ hue = self._sample_hue_deg_from_base(x, y)
1159
+ if hue is not None:
1160
+ self.hue_wheel.setPickedHue(hue)
1161
+ self.lbl_hue_readout.setText(f"Picked hue: {hue:.1f}°")
1162
+ if ev.modifiers() & Qt.KeyboardModifier.ShiftModifier:
1163
+ half = 15
1164
+ self.hue_wheel.setRange(int((hue-half) % 360), int((hue+half) % 360))
1165
+ return True
1166
+
1167
+ return super().eventFilter(obj, ev)
1168
+
1169
+ def _slider_row(self, grid: QGridLayout, name: str, row: int) -> QDoubleSpinBox:
1170
+ grid.addWidget(QLabel(name), row, 0)
1171
+ s = QDoubleSpinBox()
1172
+ s.setRange(-1.0, 1.0); s.setSingleStep(0.05); s.setDecimals(2); s.setValue(0.0)
1173
+ s.valueChanged.connect(self._recompute_mask_and_preview)
1174
+ grid.addWidget(s, row, 1)
1175
+ return s
1176
+
1177
+ def _slider_pair(self, grid: QGridLayout, name: str, row: int, minv=-1.0, maxv=1.0, step=0.05):
1178
+ import math
1179
+
1180
+ def _to_slider(v: float) -> int:
1181
+ # Symmetric rounding away from zero at half-steps; no banker’s rounding.
1182
+ s = abs(v) * 100.0
1183
+ s = math.floor(s + 0.5)
1184
+ return int(-s if v < 0 else s)
1185
+
1186
+ def _to_spin(v_int: int) -> float:
1187
+ return float(v_int) / 100.0
1188
+
1189
+ grid.addWidget(QLabel(name), row, 0)
1190
+
1191
+ sld = QSlider(Qt.Orientation.Horizontal)
1192
+ sld.setRange(int(minv*100), int(maxv*100)) # e.g., -100..100
1193
+ sld.setSingleStep(int(step*100)) # e.g., 5
1194
+ sld.setPageStep(int(5*step*100)) # e.g., 25
1195
+ sld.setValue(0)
1196
+
1197
+ box = QDoubleSpinBox()
1198
+ box.setRange(minv, maxv)
1199
+ box.setSingleStep(step)
1200
+ box.setDecimals(2)
1201
+ box.setValue(0.0)
1202
+ box.setKeyboardTracking(False) # only fire on committed changes
1203
+
1204
+ # Two-way binding without ping-pong
1205
+ def _sld_to_box(v_int: int):
1206
+ box.blockSignals(True)
1207
+ box.setValue(_to_spin(v_int))
1208
+ box.blockSignals(False)
1209
+
1210
+ def _box_to_sld(v_float: float):
1211
+ sld.blockSignals(True)
1212
+ sld.setValue(_to_slider(v_float))
1213
+ sld.blockSignals(False)
1214
+
1215
+ sld.valueChanged.connect(_sld_to_box)
1216
+ box.valueChanged.connect(_box_to_sld)
1217
+
1218
+ # Debounced preview updates (adjustments don’t rebuild mask)
1219
+ sld.valueChanged.connect(self._schedule_adjustments)
1220
+ box.valueChanged.connect(self._schedule_adjustments)
1221
+ # Nice UX: force one final refresh on release
1222
+ sld.sliderReleased.connect(self._update_preview_pixmap)
1223
+ box.editingFinished.connect(self._update_preview_pixmap)
1224
+
1225
+ grid.addWidget(sld, row, 1)
1226
+ grid.addWidget(box, row, 2)
1227
+ return sld, box
1228
+
1229
+
1230
+ # ------------- Logic -------------
1231
+ def _on_preset_change(self, txt: str):
1232
+ self._setting_preset = True
1233
+ try:
1234
+ if txt != "Custom":
1235
+ intervals = _PRESETS.get(txt, [])
1236
+ if intervals:
1237
+ lo, hi = (intervals[0][0], intervals[-1][1]) if len(intervals) > 1 else intervals[0]
1238
+
1239
+ # --- NEW: keep wheel + sliders + spins all in sync ---
1240
+ self._syncing_hue = True
1241
+ try:
1242
+ # update wheel silently
1243
+ self.hue_wheel.setRange(int(lo), int(hi), notify=False)
1244
+
1245
+ # update both sliders and spins (no ping-pong)
1246
+ for w, val in (
1247
+ (self.sl_h1, int(lo)), (self.sp_h1, int(lo)),
1248
+ (self.sl_h2, int(hi)), (self.sp_h2, int(hi)),
1249
+ ):
1250
+ w.blockSignals(True)
1251
+ w.setValue(val)
1252
+ w.blockSignals(False)
1253
+
1254
+ self.hue_wheel.update() # ensure repaint
1255
+ finally:
1256
+ self._syncing_hue = False
1257
+
1258
+ self._recompute_mask_and_preview()
1259
+ finally:
1260
+ self._setting_preset = False
1261
+
1262
+ def _downsample(self, img, max_dim=1024):
1263
+ h, w = img.shape[:2]
1264
+ s = max(h, w)
1265
+ if s <= max_dim: return img
1266
+ k = max_dim / float(s)
1267
+ if cv2 is None:
1268
+ return cv2.resize(img, (int(w*k), int(h*k))) if False else img[::int(1/k), ::int(1/k)]
1269
+ return cv2.resize(img, (int(w*k), int(h*k)), interpolation=cv2.INTER_AREA)
1270
+
1271
+ def _recompute_mask_and_preview(self):
1272
+ if self.img is None:
1273
+ return
1274
+
1275
+ base = self._downsample(self.img, 1200) if self.cb_small_preview.isChecked() else self.img
1276
+ self._last_base = base
1277
+
1278
+ # if user wants imported mask and we have one → use it
1279
+ if self._use_imported_mask and self._imported_mask_full is not None:
1280
+ imp = self._imported_mask_full
1281
+ bh, bw = base.shape[:2]
1282
+ mh, mw = imp.shape[:2]
1283
+ if (mh, mw) != (bh, bw):
1284
+ if cv2 is not None:
1285
+ mask = cv2.resize(imp, (bw, bh), interpolation=cv2.INTER_LINEAR)
1286
+ else:
1287
+ yy = (np.linspace(0, mh - 1, bh)).astype(int)
1288
+ xx = (np.linspace(0, mw - 1, bw)).astype(int)
1289
+ mask = imp[yy[:, None], xx[None, :]]
1290
+ else:
1291
+ mask = imp
1292
+ mask = np.clip(mask.astype(np.float32), 0.0, 1.0)
1293
+ else:
1294
+ # your original hue-based build
1295
+ preset = self.dd_preset.currentText()
1296
+ if preset == "Custom":
1297
+ ranges = [(float(self.sp_h1.value()), float(self.sp_h2.value()))]
1298
+ else:
1299
+ ranges = _PRESETS[preset]
1300
+
1301
+ mask = _hue_mask(
1302
+ base,
1303
+ ranges_deg=ranges,
1304
+ min_chroma=float(self.ds_minC.value()),
1305
+ min_light=float(self.ds_minL.value()),
1306
+ max_light=float(self.ds_maxL.value()),
1307
+ smooth_deg=float(self.ds_smooth.value()),
1308
+ invert_range=self.cb_invert.isChecked(),
1309
+ )
1310
+
1311
+ mask = _weight_shadows_highlights(
1312
+ mask, base,
1313
+ shadows=float(self.ds_sh.value()),
1314
+ highlights=float(self.ds_hi.value()),
1315
+ balance=float(self.ds_bal.value()),
1316
+ )
1317
+
1318
+ k = int(self.sb_blur.value())
1319
+ if k > 0 and cv2 is not None:
1320
+ mask = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), float(k))
1321
+
1322
+ self._mask = np.clip(mask, 0.0, 1.0)
1323
+ self._update_preview_pixmap()
1324
+
1325
+ def _on_use_imported_mask_toggled(self, on: bool):
1326
+ self._use_imported_mask = bool(on)
1327
+ # if we don't have an imported mask yet, turn it off again
1328
+ if self._use_imported_mask and self._imported_mask_full is None:
1329
+ self._use_imported_mask = False
1330
+ self.cb_use_imported.setChecked(False)
1331
+ QMessageBox.information(self, "No imported mask", "Pick a mask view first.")
1332
+ return
1333
+
1334
+ # just rebuild preview with the external mask
1335
+ self._recompute_mask_and_preview()
1336
+
1337
+ def _import_mask_from_view(self):
1338
+ if self.docman is None:
1339
+ QMessageBox.information(self, "No document manager", "Cannot import without a document manager.")
1340
+ return
1341
+
1342
+ # get ALL docs user currently has open (renamed, FITS layers, XISF layers, duplicates, etc.)
1343
+ docs = self.docman.all_documents() or []
1344
+ # only image docs
1345
+ img_docs = [d for d in docs if hasattr(d, "image") and d.image is not None]
1346
+
1347
+ if not img_docs:
1348
+ QMessageBox.information(self, "No views", "There are no image views to import a mask from.")
1349
+ return
1350
+
1351
+ # build names as the user sees them
1352
+ items = []
1353
+ for d in img_docs:
1354
+ try:
1355
+ nm = d.display_name()
1356
+ except Exception:
1357
+ nm = "Untitled"
1358
+ items.append(nm)
1359
+
1360
+ from PyQt6.QtWidgets import QInputDialog
1361
+ choice, ok = QInputDialog.getItem(
1362
+ self,
1363
+ "Pick mask view",
1364
+ "Open image views:",
1365
+ items,
1366
+ 0,
1367
+ False
1368
+ )
1369
+ if not ok:
1370
+ return
1371
+
1372
+ # find selected document
1373
+ sel_doc = None
1374
+ for d, nm in zip(img_docs, items):
1375
+ if nm == choice:
1376
+ sel_doc = d
1377
+ break
1378
+
1379
+ if sel_doc is None or getattr(sel_doc, "image", None) is None:
1380
+ QMessageBox.warning(self, "Import failed", "Selected view has no image.")
1381
+ return
1382
+
1383
+ mask_img = np.clip(sel_doc.image.astype(np.float32), 0.0, 1.0)
1384
+
1385
+ # if it's RGB, take channel 0 — that’s how your exported mask would look (3 equal channels)
1386
+ if mask_img.ndim == 3:
1387
+ mask_img = mask_img[..., 0]
1388
+
1389
+ # resize to current image size if needed
1390
+ dst_h, dst_w = self.img.shape[:2]
1391
+ src_h, src_w = mask_img.shape[:2]
1392
+ if (src_h, src_w) != (dst_h, dst_w):
1393
+ if cv2 is not None:
1394
+ mask_full = cv2.resize(mask_img, (dst_w, dst_h), interpolation=cv2.INTER_LINEAR)
1395
+ else:
1396
+ yy = (np.linspace(0, src_h - 1, dst_h)).astype(int)
1397
+ xx = (np.linspace(0, src_w - 1, dst_w)).astype(int)
1398
+ mask_full = mask_img[yy[:, None], xx[None, :]]
1399
+ else:
1400
+ mask_full = mask_img
1401
+
1402
+ mask_full = np.clip(mask_full.astype(np.float32), 0.0, 1.0)
1403
+
1404
+ # store
1405
+ self._imported_mask_full = mask_full
1406
+ self._imported_mask_name = choice
1407
+ self.lbl_imported_mask.setText(f"Imported: {choice}")
1408
+
1409
+ # auto-enable
1410
+ self.cb_use_imported.setChecked(True)
1411
+ self._use_imported_mask = True
1412
+
1413
+ # refresh preview
1414
+ self._recompute_mask_and_preview()
1415
+
1416
+
1417
+ def _overlay_mask(self, base: np.ndarray, mask: np.ndarray) -> np.ndarray:
1418
+ base = _ensure_rgb01(base)
1419
+ # mask is HxW -> expand to HxWx1 for broadcasting
1420
+ alpha = np.clip(mask.astype(np.float32), 0.0, 1.0)[..., None] * 0.6
1421
+ # red overlay
1422
+ overlay = base.copy()
1423
+ overlay[..., 0] = np.clip(base[..., 0]*(1 - alpha[..., 0]) + 1.0*alpha[..., 0], 0.0, 1.0)
1424
+ overlay[..., 1] = np.clip(base[..., 1]*(1 - alpha[..., 0]) + 0.0*alpha[..., 0], 0.0, 1.0)
1425
+ overlay[..., 2] = np.clip(base[..., 2]*(1 - alpha[..., 0]) + 0.0*alpha[..., 0], 0.0, 1.0)
1426
+ return overlay
1427
+
1428
+ def _update_preview_pixmap(self):
1429
+ if not hasattr(self, "_last_base"):
1430
+ self._recompute_mask_and_preview(); return
1431
+
1432
+ base = self._last_base
1433
+ mask = getattr(self, "_mask", np.zeros(base.shape[:2], np.float32))
1434
+
1435
+ if self.cb_live.isChecked():
1436
+ out = _apply_selective_adjustments(
1437
+ base, mask,
1438
+ cyan=float(self.ds_c.value()),
1439
+ magenta=float(self.ds_m.value()),
1440
+ yellow=float(self.ds_y.value()),
1441
+ r=float(self.ds_r.value()),
1442
+ g=float(self.ds_g.value()),
1443
+ b=float(self.ds_b.value()),
1444
+ lum=float(self.ds_l.value()),
1445
+ chroma=float(self.ds_chroma.value()),
1446
+ sat=float(self.ds_s.value()),
1447
+ con=float(self.ds_c2.value()),
1448
+ intensity=float(self.ds_int.value()),
1449
+ use_chroma_mode=(self.dd_color_mode.currentIndex() == 0),
1450
+ )
1451
+
1452
+ out = _ensure_rgb01(out)
1453
+ else:
1454
+ out = _ensure_rgb01(base)
1455
+
1456
+ if self.cb_show_mask.isChecked():
1457
+ # fade overlay by intensity too
1458
+ mask_vis = mask * float(self.ds_int.value())
1459
+ show = self._overlay_mask(out, mask_vis)
1460
+ else:
1461
+ show = out
1462
+
1463
+ pm = _to_pixmap(show)
1464
+ h, w = show.shape[:2]
1465
+ zw, zh = max(1, int(round(w * self._zoom))), max(1, int(round(h * self._zoom)))
1466
+ pm_scaled = pm.scaled(zw, zh, Qt.AspectRatioMode.IgnoreAspectRatio,
1467
+ Qt.TransformationMode.SmoothTransformation)
1468
+ self.lbl_preview.setPixmap(pm_scaled)
1469
+ self.lbl_preview.resize(zw, zh)
1470
+
1471
+
1472
+ def resizeEvent(self, ev):
1473
+ super().resizeEvent(ev)
1474
+ QTimer.singleShot(0, self._update_preview_pixmap)
1475
+
1476
+ # ------------- Apply -------------
1477
+ def _apply_fullres(self) -> np.ndarray:
1478
+ base = self.img
1479
+
1480
+ if self._use_imported_mask and self._imported_mask_full is not None:
1481
+ mask = np.clip(self._imported_mask_full.astype(np.float32), 0.0, 1.0)
1482
+ else:
1483
+ mask = self._build_mask(base)
1484
+
1485
+ out = _apply_selective_adjustments(
1486
+ base, mask,
1487
+ cyan=float(self.ds_c.value()),
1488
+ magenta=float(self.ds_m.value()),
1489
+ yellow=float(self.ds_y.value()),
1490
+ r=float(self.ds_r.value()),
1491
+ g=float(self.ds_g.value()),
1492
+ b=float(self.ds_b.value()),
1493
+ lum=float(self.ds_l.value()),
1494
+ chroma=float(self.ds_chroma.value()),
1495
+ sat=float(self.ds_s.value()),
1496
+ con=float(self.ds_c2.value()),
1497
+ intensity=float(self.ds_int.value()),
1498
+ use_chroma_mode=(self.dd_color_mode.currentIndex() == 0),
1499
+ )
1500
+
1501
+ return out
1502
+
1503
+ def _export_mask_doc(self):
1504
+ if self.docman is None:
1505
+ QMessageBox.information(self, "No document manager", "Cannot export mask without a document manager.")
1506
+ return
1507
+
1508
+ base = self.img
1509
+ if base is None:
1510
+ QMessageBox.information(self, "No image", "Open an image first.")
1511
+ return
1512
+
1513
+ mask = self._build_mask(base) # H x W, float32, 0..1
1514
+ mask_rgb = np.repeat(mask[..., None], 3, axis=2).astype(np.float32)
1515
+
1516
+ name = getattr(self.document, "display_name", lambda: "Image")()
1517
+ title = f"{name} [SelectiveColor MASK]"
1518
+ try:
1519
+ self.docman.open_array(mask_rgb, title=title)
1520
+ except Exception as e:
1521
+ QMessageBox.warning(self, "Export failed", str(e))
1522
+
1523
+
1524
+ def _build_mask(self, base: np.ndarray) -> np.ndarray:
1525
+ """
1526
+ Build the full-res mask using the *current UI settings*.
1527
+ This is exactly what your old _apply_fullres did, just pulled out.
1528
+ """
1529
+ preset = self.dd_preset.currentText()
1530
+ ranges = (
1531
+ [(float(self.sp_h1.value()), float(self.sp_h2.value()))]
1532
+ if preset == "Custom"
1533
+ else _PRESETS[preset]
1534
+ )
1535
+
1536
+ # 1) hue / chroma / light / smooth / invert
1537
+ mask = _hue_mask(
1538
+ base,
1539
+ ranges_deg=ranges,
1540
+ min_chroma=float(self.ds_minC.value()),
1541
+ min_light=float(self.ds_minL.value()),
1542
+ max_light=float(self.ds_maxL.value()),
1543
+ smooth_deg=float(self.ds_smooth.value()),
1544
+ invert_range=self.cb_invert.isChecked(),
1545
+ )
1546
+
1547
+ # 2) shadows / highlights weighting
1548
+ mask = _weight_shadows_highlights(
1549
+ mask, base,
1550
+ shadows=float(self.ds_sh.value()),
1551
+ highlights=float(self.ds_hi.value()),
1552
+ balance=float(self.ds_bal.value()),
1553
+ )
1554
+
1555
+ # 3) optional blur
1556
+ k = int(self.sb_blur.value())
1557
+ if k > 0 and cv2 is not None:
1558
+ mask = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), float(k))
1559
+
1560
+ return np.clip(mask, 0.0, 1.0).astype(np.float32)
1561
+
1562
+
1563
+
1564
+ def _apply_to_document(self):
1565
+ try:
1566
+ result = self._apply_fullres()
1567
+ except Exception as e:
1568
+ QMessageBox.warning(self, "Error", str(e)); return
1569
+
1570
+ # write back to the same document (preferred)
1571
+ try:
1572
+ if hasattr(self.document, "set_image"):
1573
+ self.document.set_image(result)
1574
+ except Exception:
1575
+ # fallback: if set_image fails, at least open it as a new view (but keep dialog open)
1576
+ name = getattr(self.document, "display_name", lambda: "Image")()
1577
+ if hasattr(self.docman, "open_array"):
1578
+ self.docman.open_array(result, title=f"{name} [SelectiveColor]")
1579
+
1580
+ # make the processed image the new working base for further tweaks
1581
+ self.img = np.clip(result.astype(np.float32), 0.0, 1.0)
1582
+ self._last_base = None # force rebuild from current self.img
1583
+ self._reset_controls() # reset knobs; dialog remains open
1584
+
1585
+
1586
+ def _apply_as_new_doc(self):
1587
+ try:
1588
+ result = self._apply_fullres()
1589
+ except Exception as e:
1590
+ QMessageBox.warning(self, "Error", str(e)); return
1591
+
1592
+ name = getattr(self.document, "display_name", lambda: "Image")()
1593
+ new_doc = None
1594
+ if hasattr(self.docman, "open_array"):
1595
+ new_doc = self.docman.open_array(result, title=f"{name} [SelectiveColor]")
1596
+
1597
+ # continue editing the new doc if we got a handle; otherwise just keep editing current
1598
+ if new_doc is not None:
1599
+ self.document = new_doc
1600
+ # refresh label
1601
+ try:
1602
+ disp = getattr(self.document, "display_name", lambda: "Image")()
1603
+ except Exception:
1604
+ disp = "Image"
1605
+ self.lbl_target.setText(f"Target View: <b>{disp}</b>")
1606
+
1607
+ # new working base is the processed pixels either way
1608
+ self.img = np.clip(result.astype(np.float32), 0.0, 1.0)
1609
+ self._last_base = None
1610
+ self._reset_controls()
1611
+