setiastrosuitepro 1.6.7__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 (394) 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/acv_icon.png +0 -0
  24. setiastro/images/andromedatry.png +0 -0
  25. setiastro/images/andromedatry_satellited.png +0 -0
  26. setiastro/images/annotated.png +0 -0
  27. setiastro/images/aperture.png +0 -0
  28. setiastro/images/astrosuite.ico +0 -0
  29. setiastro/images/astrosuite.png +0 -0
  30. setiastro/images/astrosuitepro.icns +0 -0
  31. setiastro/images/astrosuitepro.ico +0 -0
  32. setiastro/images/astrosuitepro.png +0 -0
  33. setiastro/images/background.png +0 -0
  34. setiastro/images/background2.png +0 -0
  35. setiastro/images/benchmark.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  37. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  38. setiastro/images/blaster.png +0 -0
  39. setiastro/images/blink.png +0 -0
  40. setiastro/images/clahe.png +0 -0
  41. setiastro/images/collage.png +0 -0
  42. setiastro/images/colorwheel.png +0 -0
  43. setiastro/images/contsub.png +0 -0
  44. setiastro/images/convo.png +0 -0
  45. setiastro/images/copyslot.png +0 -0
  46. setiastro/images/cosmic.png +0 -0
  47. setiastro/images/cosmicsat.png +0 -0
  48. setiastro/images/crop1.png +0 -0
  49. setiastro/images/cropicon.png +0 -0
  50. setiastro/images/curves.png +0 -0
  51. setiastro/images/cvs.png +0 -0
  52. setiastro/images/debayer.png +0 -0
  53. setiastro/images/denoise_cnn_custom.png +0 -0
  54. setiastro/images/denoise_cnn_graph.png +0 -0
  55. setiastro/images/disk.png +0 -0
  56. setiastro/images/dse.png +0 -0
  57. setiastro/images/exoicon.png +0 -0
  58. setiastro/images/eye.png +0 -0
  59. setiastro/images/first_quarter.png +0 -0
  60. setiastro/images/fliphorizontal.png +0 -0
  61. setiastro/images/flipvertical.png +0 -0
  62. setiastro/images/font.png +0 -0
  63. setiastro/images/freqsep.png +0 -0
  64. setiastro/images/full_moon.png +0 -0
  65. setiastro/images/functionbundle.png +0 -0
  66. setiastro/images/graxpert.png +0 -0
  67. setiastro/images/green.png +0 -0
  68. setiastro/images/gridicon.png +0 -0
  69. setiastro/images/halo.png +0 -0
  70. setiastro/images/hdr.png +0 -0
  71. setiastro/images/histogram.png +0 -0
  72. setiastro/images/hubble.png +0 -0
  73. setiastro/images/imagecombine.png +0 -0
  74. setiastro/images/invert.png +0 -0
  75. setiastro/images/isophote.png +0 -0
  76. setiastro/images/isophote_demo_figure.png +0 -0
  77. setiastro/images/isophote_demo_image.png +0 -0
  78. setiastro/images/isophote_demo_model.png +0 -0
  79. setiastro/images/isophote_demo_residual.png +0 -0
  80. setiastro/images/jwstpupil.png +0 -0
  81. setiastro/images/last_quarter.png +0 -0
  82. setiastro/images/linearfit.png +0 -0
  83. setiastro/images/livestacking.png +0 -0
  84. setiastro/images/mask.png +0 -0
  85. setiastro/images/maskapply.png +0 -0
  86. setiastro/images/maskcreate.png +0 -0
  87. setiastro/images/maskremove.png +0 -0
  88. setiastro/images/morpho.png +0 -0
  89. setiastro/images/mosaic.png +0 -0
  90. setiastro/images/multiscale_decomp.png +0 -0
  91. setiastro/images/nbtorgb.png +0 -0
  92. setiastro/images/neutral.png +0 -0
  93. setiastro/images/new_moon.png +0 -0
  94. setiastro/images/nuke.png +0 -0
  95. setiastro/images/openfile.png +0 -0
  96. setiastro/images/pedestal.png +0 -0
  97. setiastro/images/pen.png +0 -0
  98. setiastro/images/pixelmath.png +0 -0
  99. setiastro/images/platesolve.png +0 -0
  100. setiastro/images/ppp.png +0 -0
  101. setiastro/images/pro.png +0 -0
  102. setiastro/images/project.png +0 -0
  103. setiastro/images/psf.png +0 -0
  104. setiastro/images/redo.png +0 -0
  105. setiastro/images/redoicon.png +0 -0
  106. setiastro/images/rescale.png +0 -0
  107. setiastro/images/rgbalign.png +0 -0
  108. setiastro/images/rgbcombo.png +0 -0
  109. setiastro/images/rgbextract.png +0 -0
  110. setiastro/images/rotate180.png +0 -0
  111. setiastro/images/rotatearbitrary.png +0 -0
  112. setiastro/images/rotateclockwise.png +0 -0
  113. setiastro/images/rotatecounterclockwise.png +0 -0
  114. setiastro/images/satellite.png +0 -0
  115. setiastro/images/script.png +0 -0
  116. setiastro/images/selectivecolor.png +0 -0
  117. setiastro/images/simbad.png +0 -0
  118. setiastro/images/slot0.png +0 -0
  119. setiastro/images/slot1.png +0 -0
  120. setiastro/images/slot2.png +0 -0
  121. setiastro/images/slot3.png +0 -0
  122. setiastro/images/slot4.png +0 -0
  123. setiastro/images/slot5.png +0 -0
  124. setiastro/images/slot6.png +0 -0
  125. setiastro/images/slot7.png +0 -0
  126. setiastro/images/slot8.png +0 -0
  127. setiastro/images/slot9.png +0 -0
  128. setiastro/images/spcc.png +0 -0
  129. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  130. setiastro/images/spinner.gif +0 -0
  131. setiastro/images/stacking.png +0 -0
  132. setiastro/images/staradd.png +0 -0
  133. setiastro/images/staralign.png +0 -0
  134. setiastro/images/starnet.png +0 -0
  135. setiastro/images/starregistration.png +0 -0
  136. setiastro/images/starspike.png +0 -0
  137. setiastro/images/starstretch.png +0 -0
  138. setiastro/images/statstretch.png +0 -0
  139. setiastro/images/supernova.png +0 -0
  140. setiastro/images/uhs.png +0 -0
  141. setiastro/images/undoicon.png +0 -0
  142. setiastro/images/upscale.png +0 -0
  143. setiastro/images/viewbundle.png +0 -0
  144. setiastro/images/waning_crescent_1.png +0 -0
  145. setiastro/images/waning_crescent_2.png +0 -0
  146. setiastro/images/waning_crescent_3.png +0 -0
  147. setiastro/images/waning_crescent_4.png +0 -0
  148. setiastro/images/waning_crescent_5.png +0 -0
  149. setiastro/images/waning_gibbous_1.png +0 -0
  150. setiastro/images/waning_gibbous_2.png +0 -0
  151. setiastro/images/waning_gibbous_3.png +0 -0
  152. setiastro/images/waning_gibbous_4.png +0 -0
  153. setiastro/images/waning_gibbous_5.png +0 -0
  154. setiastro/images/waxing_crescent_1.png +0 -0
  155. setiastro/images/waxing_crescent_2.png +0 -0
  156. setiastro/images/waxing_crescent_3.png +0 -0
  157. setiastro/images/waxing_crescent_4.png +0 -0
  158. setiastro/images/waxing_crescent_5.png +0 -0
  159. setiastro/images/waxing_gibbous_1.png +0 -0
  160. setiastro/images/waxing_gibbous_2.png +0 -0
  161. setiastro/images/waxing_gibbous_3.png +0 -0
  162. setiastro/images/waxing_gibbous_4.png +0 -0
  163. setiastro/images/waxing_gibbous_5.png +0 -0
  164. setiastro/images/whitebalance.png +0 -0
  165. setiastro/images/wimi_icon_256x256.png +0 -0
  166. setiastro/images/wimilogo.png +0 -0
  167. setiastro/images/wims.png +0 -0
  168. setiastro/images/wrench_icon.png +0 -0
  169. setiastro/images/xisfliberator.png +0 -0
  170. setiastro/qml/ResourceMonitor.qml +128 -0
  171. setiastro/saspro/__init__.py +20 -0
  172. setiastro/saspro/__main__.py +964 -0
  173. setiastro/saspro/_generated/__init__.py +7 -0
  174. setiastro/saspro/_generated/build_info.py +3 -0
  175. setiastro/saspro/abe.py +1379 -0
  176. setiastro/saspro/abe_preset.py +196 -0
  177. setiastro/saspro/aberration_ai.py +910 -0
  178. setiastro/saspro/aberration_ai_preset.py +224 -0
  179. setiastro/saspro/accel_installer.py +218 -0
  180. setiastro/saspro/accel_workers.py +30 -0
  181. setiastro/saspro/acv_exporter.py +379 -0
  182. setiastro/saspro/add_stars.py +627 -0
  183. setiastro/saspro/astrobin_exporter.py +1010 -0
  184. setiastro/saspro/astrospike.py +153 -0
  185. setiastro/saspro/astrospike_python.py +1841 -0
  186. setiastro/saspro/autostretch.py +198 -0
  187. setiastro/saspro/backgroundneutral.py +639 -0
  188. setiastro/saspro/batch_convert.py +328 -0
  189. setiastro/saspro/batch_renamer.py +522 -0
  190. setiastro/saspro/blemish_blaster.py +494 -0
  191. setiastro/saspro/blink_comparator_pro.py +3149 -0
  192. setiastro/saspro/bundles.py +61 -0
  193. setiastro/saspro/bundles_dock.py +114 -0
  194. setiastro/saspro/cheat_sheet.py +213 -0
  195. setiastro/saspro/clahe.py +371 -0
  196. setiastro/saspro/comet_stacking.py +1442 -0
  197. setiastro/saspro/common_tr.py +107 -0
  198. setiastro/saspro/config.py +38 -0
  199. setiastro/saspro/config_bootstrap.py +40 -0
  200. setiastro/saspro/config_manager.py +316 -0
  201. setiastro/saspro/continuum_subtract.py +1620 -0
  202. setiastro/saspro/convo.py +1403 -0
  203. setiastro/saspro/convo_preset.py +414 -0
  204. setiastro/saspro/copyastro.py +190 -0
  205. setiastro/saspro/cosmicclarity.py +1593 -0
  206. setiastro/saspro/cosmicclarity_preset.py +407 -0
  207. setiastro/saspro/crop_dialog_pro.py +1005 -0
  208. setiastro/saspro/crop_preset.py +189 -0
  209. setiastro/saspro/curve_editor_pro.py +2608 -0
  210. setiastro/saspro/curves_preset.py +375 -0
  211. setiastro/saspro/debayer.py +673 -0
  212. setiastro/saspro/debug_utils.py +29 -0
  213. setiastro/saspro/dnd_mime.py +35 -0
  214. setiastro/saspro/doc_manager.py +2727 -0
  215. setiastro/saspro/exoplanet_detector.py +2258 -0
  216. setiastro/saspro/file_utils.py +284 -0
  217. setiastro/saspro/fitsmodifier.py +748 -0
  218. setiastro/saspro/fix_bom.py +32 -0
  219. setiastro/saspro/free_torch_memory.py +48 -0
  220. setiastro/saspro/frequency_separation.py +1352 -0
  221. setiastro/saspro/function_bundle.py +1596 -0
  222. setiastro/saspro/generate_translations.py +3092 -0
  223. setiastro/saspro/ghs_dialog_pro.py +728 -0
  224. setiastro/saspro/ghs_preset.py +284 -0
  225. setiastro/saspro/graxpert.py +638 -0
  226. setiastro/saspro/graxpert_preset.py +287 -0
  227. setiastro/saspro/gui/__init__.py +0 -0
  228. setiastro/saspro/gui/main_window.py +8928 -0
  229. setiastro/saspro/gui/mixins/__init__.py +33 -0
  230. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  231. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  232. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  233. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  234. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  235. setiastro/saspro/gui/mixins/menu_mixin.py +391 -0
  236. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  237. setiastro/saspro/gui/mixins/toolbar_mixin.py +1824 -0
  238. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  239. setiastro/saspro/gui/mixins/view_mixin.py +477 -0
  240. setiastro/saspro/gui/statistics_dialog.py +47 -0
  241. setiastro/saspro/halobgon.py +492 -0
  242. setiastro/saspro/header_viewer.py +448 -0
  243. setiastro/saspro/headless_utils.py +88 -0
  244. setiastro/saspro/histogram.py +760 -0
  245. setiastro/saspro/history_explorer.py +941 -0
  246. setiastro/saspro/i18n.py +168 -0
  247. setiastro/saspro/image_combine.py +421 -0
  248. setiastro/saspro/image_peeker_pro.py +1608 -0
  249. setiastro/saspro/imageops/__init__.py +37 -0
  250. setiastro/saspro/imageops/mdi_snap.py +292 -0
  251. setiastro/saspro/imageops/scnr.py +36 -0
  252. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  253. setiastro/saspro/imageops/stretch.py +236 -0
  254. setiastro/saspro/isophote.py +1186 -0
  255. setiastro/saspro/layers.py +208 -0
  256. setiastro/saspro/layers_dock.py +714 -0
  257. setiastro/saspro/lazy_imports.py +193 -0
  258. setiastro/saspro/legacy/__init__.py +2 -0
  259. setiastro/saspro/legacy/image_manager.py +2360 -0
  260. setiastro/saspro/legacy/numba_utils.py +3676 -0
  261. setiastro/saspro/legacy/xisf.py +1213 -0
  262. setiastro/saspro/linear_fit.py +537 -0
  263. setiastro/saspro/live_stacking.py +1854 -0
  264. setiastro/saspro/log_bus.py +5 -0
  265. setiastro/saspro/logging_config.py +460 -0
  266. setiastro/saspro/luminancerecombine.py +510 -0
  267. setiastro/saspro/main_helpers.py +201 -0
  268. setiastro/saspro/mask_creation.py +1090 -0
  269. setiastro/saspro/masks_core.py +56 -0
  270. setiastro/saspro/mdi_widgets.py +353 -0
  271. setiastro/saspro/memory_utils.py +666 -0
  272. setiastro/saspro/metadata_patcher.py +75 -0
  273. setiastro/saspro/mfdeconv.py +3909 -0
  274. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  275. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  276. setiastro/saspro/mfdeconvsport.py +2459 -0
  277. setiastro/saspro/minorbodycatalog.py +567 -0
  278. setiastro/saspro/morphology.py +411 -0
  279. setiastro/saspro/multiscale_decomp.py +1751 -0
  280. setiastro/saspro/nbtorgb_stars.py +541 -0
  281. setiastro/saspro/numba_utils.py +3145 -0
  282. setiastro/saspro/numba_warmup.py +141 -0
  283. setiastro/saspro/ops/__init__.py +9 -0
  284. setiastro/saspro/ops/command_help_dialog.py +623 -0
  285. setiastro/saspro/ops/command_runner.py +217 -0
  286. setiastro/saspro/ops/commands.py +1594 -0
  287. setiastro/saspro/ops/script_editor.py +1105 -0
  288. setiastro/saspro/ops/scripts.py +1476 -0
  289. setiastro/saspro/ops/settings.py +637 -0
  290. setiastro/saspro/parallel_utils.py +554 -0
  291. setiastro/saspro/pedestal.py +121 -0
  292. setiastro/saspro/perfect_palette_picker.py +1105 -0
  293. setiastro/saspro/pipeline.py +110 -0
  294. setiastro/saspro/pixelmath.py +1604 -0
  295. setiastro/saspro/plate_solver.py +2480 -0
  296. setiastro/saspro/project_io.py +797 -0
  297. setiastro/saspro/psf_utils.py +136 -0
  298. setiastro/saspro/psf_viewer.py +631 -0
  299. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  300. setiastro/saspro/remove_green.py +331 -0
  301. setiastro/saspro/remove_stars.py +1599 -0
  302. setiastro/saspro/remove_stars_preset.py +446 -0
  303. setiastro/saspro/resources.py +570 -0
  304. setiastro/saspro/rgb_combination.py +208 -0
  305. setiastro/saspro/rgb_extract.py +19 -0
  306. setiastro/saspro/rgbalign.py +727 -0
  307. setiastro/saspro/runtime_imports.py +7 -0
  308. setiastro/saspro/runtime_torch.py +754 -0
  309. setiastro/saspro/save_options.py +73 -0
  310. setiastro/saspro/selective_color.py +1614 -0
  311. setiastro/saspro/sfcc.py +1530 -0
  312. setiastro/saspro/shortcuts.py +3125 -0
  313. setiastro/saspro/signature_insert.py +1106 -0
  314. setiastro/saspro/stacking_suite.py +19069 -0
  315. setiastro/saspro/star_alignment.py +7383 -0
  316. setiastro/saspro/star_alignment_preset.py +329 -0
  317. setiastro/saspro/star_metrics.py +49 -0
  318. setiastro/saspro/star_spikes.py +769 -0
  319. setiastro/saspro/star_stretch.py +542 -0
  320. setiastro/saspro/stat_stretch.py +554 -0
  321. setiastro/saspro/status_log_dock.py +78 -0
  322. setiastro/saspro/subwindow.py +3523 -0
  323. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  324. setiastro/saspro/swap_manager.py +134 -0
  325. setiastro/saspro/torch_backend.py +89 -0
  326. setiastro/saspro/torch_rejection.py +434 -0
  327. setiastro/saspro/translations/all_source_strings.json +4726 -0
  328. setiastro/saspro/translations/ar_translations.py +4096 -0
  329. setiastro/saspro/translations/de_translations.py +3728 -0
  330. setiastro/saspro/translations/es_translations.py +4169 -0
  331. setiastro/saspro/translations/fr_translations.py +4090 -0
  332. setiastro/saspro/translations/hi_translations.py +3803 -0
  333. setiastro/saspro/translations/integrate_translations.py +271 -0
  334. setiastro/saspro/translations/it_translations.py +4728 -0
  335. setiastro/saspro/translations/ja_translations.py +3834 -0
  336. setiastro/saspro/translations/pt_translations.py +3847 -0
  337. setiastro/saspro/translations/ru_translations.py +3082 -0
  338. setiastro/saspro/translations/saspro_ar.qm +0 -0
  339. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  340. setiastro/saspro/translations/saspro_de.qm +0 -0
  341. setiastro/saspro/translations/saspro_de.ts +14548 -0
  342. setiastro/saspro/translations/saspro_es.qm +0 -0
  343. setiastro/saspro/translations/saspro_es.ts +16202 -0
  344. setiastro/saspro/translations/saspro_fr.qm +0 -0
  345. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  346. setiastro/saspro/translations/saspro_hi.qm +0 -0
  347. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  348. setiastro/saspro/translations/saspro_it.qm +0 -0
  349. setiastro/saspro/translations/saspro_it.ts +19046 -0
  350. setiastro/saspro/translations/saspro_ja.qm +0 -0
  351. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  352. setiastro/saspro/translations/saspro_pt.qm +0 -0
  353. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  354. setiastro/saspro/translations/saspro_ru.qm +0 -0
  355. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  356. setiastro/saspro/translations/saspro_sw.qm +0 -0
  357. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  358. setiastro/saspro/translations/saspro_uk.qm +0 -0
  359. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  360. setiastro/saspro/translations/saspro_zh.qm +0 -0
  361. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  362. setiastro/saspro/translations/sw_translations.py +3897 -0
  363. setiastro/saspro/translations/uk_translations.py +3929 -0
  364. setiastro/saspro/translations/zh_translations.py +3910 -0
  365. setiastro/saspro/versioning.py +77 -0
  366. setiastro/saspro/view_bundle.py +1558 -0
  367. setiastro/saspro/wavescale_hdr.py +648 -0
  368. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  369. setiastro/saspro/wavescalede.py +683 -0
  370. setiastro/saspro/wavescalede_preset.py +230 -0
  371. setiastro/saspro/wcs_update.py +374 -0
  372. setiastro/saspro/whitebalance.py +540 -0
  373. setiastro/saspro/widgets/__init__.py +48 -0
  374. setiastro/saspro/widgets/common_utilities.py +306 -0
  375. setiastro/saspro/widgets/graphics_views.py +122 -0
  376. setiastro/saspro/widgets/image_utils.py +518 -0
  377. setiastro/saspro/widgets/minigame/game.js +991 -0
  378. setiastro/saspro/widgets/minigame/index.html +53 -0
  379. setiastro/saspro/widgets/minigame/style.css +241 -0
  380. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  381. setiastro/saspro/widgets/resource_monitor.py +313 -0
  382. setiastro/saspro/widgets/spinboxes.py +290 -0
  383. setiastro/saspro/widgets/themed_buttons.py +13 -0
  384. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  385. setiastro/saspro/wimi.py +7367 -0
  386. setiastro/saspro/wims.py +588 -0
  387. setiastro/saspro/window_shelf.py +185 -0
  388. setiastro/saspro/xisf.py +1213 -0
  389. setiastrosuitepro-1.6.7.dist-info/METADATA +279 -0
  390. setiastrosuitepro-1.6.7.dist-info/RECORD +394 -0
  391. setiastrosuitepro-1.6.7.dist-info/WHEEL +4 -0
  392. setiastrosuitepro-1.6.7.dist-info/entry_points.txt +6 -0
  393. setiastrosuitepro-1.6.7.dist-info/licenses/LICENSE +674 -0
  394. setiastrosuitepro-1.6.7.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,1614 @@
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
+ try:
462
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
463
+ except Exception:
464
+ pass # older PyQt6 versions
465
+ self.docman = doc_manager
466
+ self.document = document
467
+ if self.document is None or getattr(self.document, "image", None) is None:
468
+ QMessageBox.information(self, "No image", "Open an image first.")
469
+ self.close(); return
470
+
471
+ self.img = np.clip(self.document.image.astype(np.float32), 0.0, 1.0)
472
+ self.preview_img = self.img.copy()
473
+ self._syncing_hue = False
474
+ self._imported_mask_full = None # full-res mask (H x W) float32 0..1
475
+ self._imported_mask_name = None # nice label to show in UI
476
+ self._use_imported_mask = False # checkbox state mirror
477
+ self._mask_delay_ms = 200
478
+ self._adj_delay_ms = 200
479
+ self._build_ui()
480
+ self._mask_delay_ms = 200 # 0.2s idle before recomputing mask
481
+ self._mask_timer = QTimer(self)
482
+ self._mask_timer.setSingleShot(True)
483
+ self._mask_timer.timeout.connect(self._recompute_mask_and_preview)
484
+ self._adj_delay_ms = 200
485
+ self._adj_timer = QTimer(self)
486
+ self._adj_timer.setSingleShot(True)
487
+ self._adj_timer.timeout.connect(self._update_preview_pixmap)
488
+ self.dd_preset.setCurrentText("Red")
489
+ self._setting_preset = False
490
+ self._recompute_mask_and_preview()
491
+ self._panning = False
492
+ self._pan_start_pos = None # QPointF in label coords
493
+ self._pan_start_scroll = (0, 0) # (hval, vval)
494
+ self._pan_deadzone = 1
495
+ self._pan_start_pos_vp = None
496
+
497
+ # ------------- UI -------------
498
+ def _build_ui(self):
499
+ # --- Root layout -------------------------------------------------------
500
+ root = QHBoxLayout(self)
501
+ root.setContentsMargins(8, 8, 8, 8)
502
+ root.setSpacing(10)
503
+
504
+ splitter = QSplitter(Qt.Orientation.Horizontal)
505
+ splitter.setChildrenCollapsible(False)
506
+ splitter.setHandleWidth(6)
507
+ root.addWidget(splitter)
508
+
509
+ # ======================================================================
510
+ # LEFT PANE (header → "small preview" toggle → scroller → live toggle → buttons)
511
+ # ======================================================================
512
+ left_widget = QWidget()
513
+ left_widget.setMinimumWidth(360)
514
+ left_widget.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
515
+ splitter.addWidget(left_widget)
516
+
517
+ left_outer = QVBoxLayout(left_widget)
518
+ left_outer.setContentsMargins(0, 0, 0, 0)
519
+ left_outer.setSpacing(8)
520
+
521
+ # Header (target view label)
522
+ try:
523
+ disp = getattr(self.document, "display_name", lambda: "Image")()
524
+ except Exception:
525
+ disp = "Image"
526
+ self.lbl_target = QLabel(f"Target View: <b>{disp}</b>")
527
+ left_outer.addWidget(self.lbl_target)
528
+
529
+ # Small/fast preview toggle
530
+ self.cb_small_preview = QCheckBox("Small-sized Preview (fast)")
531
+ self.cb_small_preview.setChecked(True)
532
+ self.cb_small_preview.toggled.connect(self._recompute_mask_and_preview)
533
+ left_outer.addWidget(self.cb_small_preview)
534
+
535
+ # ---------- SCROLLABLE CONTROLS (placed inside a QScrollArea) ----------
536
+ controls_container = QWidget()
537
+ left = QVBoxLayout(controls_container)
538
+ left.setContentsMargins(0, 0, 0, 0)
539
+ left.setSpacing(8)
540
+
541
+ # ===== Mask group
542
+ gb_mask = QGroupBox(self.tr("Mask"))
543
+ gl = QGridLayout(gb_mask)
544
+ gl.setContentsMargins(8, 8, 8, 8)
545
+ gl.setHorizontalSpacing(10)
546
+ gl.setVerticalSpacing(8)
547
+
548
+ # Row 0: Preset
549
+ gl.addWidget(QLabel("Preset:"), 0, 0)
550
+ self.dd_preset = QComboBox()
551
+ self.dd_preset.addItems(["Custom"] + list(_PRESETS.keys()))
552
+ self.dd_preset.currentTextChanged.connect(self._on_preset_change)
553
+ gl.addWidget(self.dd_preset, 0, 1, 1, 4)
554
+
555
+ # Hue wheel
556
+ self.hue_wheel = HueWheel(start_deg=65, end_deg=158)
557
+ self.hue_wheel.setMinimumSize(130, 130)
558
+ self.hue_wheel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
559
+ gl.addWidget(self.hue_wheel, 1, 0, 7, 2)
560
+
561
+ # Wheel -> sliders/spins (so dragging wheel updates UI and mask)
562
+ def _wheel_to_sliders(s: int, e: int):
563
+ # If user is dragging the wheel, we’re now custom
564
+ if not self._setting_preset and self.dd_preset.currentText() != "Custom":
565
+ self.dd_preset.blockSignals(True)
566
+ self.dd_preset.setCurrentText("Custom")
567
+ self.dd_preset.blockSignals(False)
568
+
569
+ # Update BOTH sliders and spins, without ping-pong
570
+ self._syncing_hue = True
571
+ try:
572
+ s = int(s) % 360
573
+ e = int(e) % 360
574
+
575
+ for w, val in (
576
+ (self.sl_h1, s), (self.sp_h1, s),
577
+ (self.sl_h2, e), (self.sp_h2, e),
578
+ ):
579
+ w.blockSignals(True)
580
+ w.setValue(val)
581
+ w.blockSignals(False)
582
+ finally:
583
+ self._syncing_hue = False
584
+
585
+ self._schedule_mask()
586
+
587
+ self.hue_wheel.rangeChanged.connect(_wheel_to_sliders)
588
+
589
+
590
+
591
+ # Helper: integer slider + spin (0..360)
592
+ def _deg_pair(grid: QGridLayout, label: str, row: int):
593
+ grid.addWidget(QLabel(label), row, 2)
594
+ sld = QSlider(Qt.Orientation.Horizontal)
595
+ sld.setRange(0, 360); sld.setSingleStep(1); sld.setPageStep(10)
596
+ spn = QSpinBox(); spn.setRange(0, 360)
597
+ sld.valueChanged.connect(spn.setValue)
598
+ spn.valueChanged.connect(sld.setValue)
599
+ grid.addWidget(sld, row, 3, 1, 3)
600
+ grid.addWidget(spn, row, 6, 1, 1)
601
+ return sld, spn
602
+
603
+ # Rows 1–2: Hue Start/End
604
+ self.sl_h1, self.sp_h1 = _deg_pair(gl, "Hue start (°):", 1)
605
+ self.sl_h2, self.sp_h2 = _deg_pair(gl, "Hue end (°):", 2)
606
+ self.sp_h1.setValue(65); self.sp_h2.setValue(158)
607
+
608
+ # Row 3: chroma + lightness
609
+ gl.addWidget(QLabel("Min chroma:"), 3, 2)
610
+ self.ds_minC = QDoubleSpinBox(); self.ds_minC.setRange(0,1); self.ds_minC.setSingleStep(0.05); self.ds_minC.setValue(0.05)
611
+
612
+ self.ds_minC.valueChanged.connect(self._recompute_mask_and_preview)
613
+ gl.addWidget(self.ds_minC, 3, 3)
614
+
615
+ gl.addWidget(QLabel("Lightness min/max:"), 3, 4)
616
+ self.ds_minL = QDoubleSpinBox(); self.ds_minL.setRange(0,1); self.ds_minL.setSingleStep(0.05); self.ds_minL.setValue(0.0)
617
+ self.ds_maxL = QDoubleSpinBox(); self.ds_maxL.setRange(0,1); self.ds_maxL.setSingleStep(0.05); self.ds_maxL.setValue(1.0)
618
+ self.ds_minL.valueChanged.connect(self._recompute_mask_and_preview)
619
+ self.ds_maxL.valueChanged.connect(self._recompute_mask_and_preview)
620
+ gl.addWidget(self.ds_minL, 3, 5)
621
+ gl.addWidget(QLabel("to"), 3, 6)
622
+ gl.addWidget(self.ds_maxL, 3, 7)
623
+
624
+ # Row 4: smoothness + invert
625
+ gl.addWidget(QLabel("Smoothness (deg):"), 4, 2)
626
+ self.ds_smooth = QDoubleSpinBox(); self.ds_smooth.setRange(0,60); self.ds_smooth.setSingleStep(1.0); self.ds_smooth.setValue(10.0)
627
+ self.ds_smooth.valueChanged.connect(self._recompute_mask_and_preview)
628
+ gl.addWidget(self.ds_smooth, 4, 3)
629
+
630
+ self.cb_invert = QCheckBox("Invert hue range")
631
+ self.cb_invert.setChecked(False)
632
+ self.cb_invert.toggled.connect(self._recompute_mask_and_preview)
633
+ gl.addWidget(self.cb_invert, 4, 4, 1, 3)
634
+
635
+ # Row 5: shadows/highlights + intensity
636
+ gl.addWidget(QLabel("Shadows:"), 5, 2)
637
+ self.ds_sh = QDoubleSpinBox(); self.ds_sh.setRange(0,1); self.ds_sh.setSingleStep(0.05); self.ds_sh.setValue(0.0)
638
+ self.ds_sh.valueChanged.connect(self._recompute_mask_and_preview)
639
+ gl.addWidget(self.ds_sh, 5, 3)
640
+
641
+ gl.addWidget(QLabel("Highlights:"), 5, 4)
642
+ self.ds_hi = QDoubleSpinBox(); self.ds_hi.setRange(0,1); self.ds_hi.setSingleStep(0.05); self.ds_hi.setValue(1.0)
643
+ self.ds_hi.valueChanged.connect(self._recompute_mask_and_preview)
644
+ gl.addWidget(self.ds_hi, 5, 5)
645
+
646
+ self.ds_bal = QDoubleSpinBox(); self.ds_bal.setRange(0,1); self.ds_bal.setSingleStep(0.05); self.ds_bal.setValue(0.5)
647
+ self.ds_bal.valueChanged.connect(self._recompute_mask_and_preview)
648
+ self.ds_bal.setVisible(False) # used in math, hidden in UI
649
+
650
+ gl.addWidget(QLabel("Intensity:"), 5, 6)
651
+ self.ds_int = QDoubleSpinBox(); self.ds_int.setRange(0, 2.0); self.ds_int.setSingleStep(0.05); self.ds_int.setValue(1.0)
652
+ self.ds_int.valueChanged.connect(self._recompute_mask_and_preview)
653
+ gl.addWidget(self.ds_int, 5, 7)
654
+
655
+ # Row 6: blur + overlay
656
+ gl.addWidget(QLabel("Edge blur (px):"), 6, 2)
657
+ self.sb_blur = QSpinBox(); self.sb_blur.setRange(0, 150); self.sb_blur.setValue(0)
658
+ self.sb_blur.valueChanged.connect(self._recompute_mask_and_preview)
659
+ gl.addWidget(self.sb_blur, 6, 3)
660
+
661
+ self.cb_show_mask = QCheckBox("Show mask overlay")
662
+ self.cb_show_mask.setChecked(False)
663
+ self.cb_show_mask.toggled.connect(self._update_preview_pixmap)
664
+ gl.addWidget(self.cb_show_mask, 6, 4, 1, 2)
665
+
666
+ # Row 7: imported mask
667
+ self.cb_use_imported = QCheckBox("Use imported mask")
668
+ self.cb_use_imported.setChecked(False)
669
+ self.cb_use_imported.toggled.connect(self._on_use_imported_mask_toggled)
670
+ gl.addWidget(self.cb_use_imported, 7, 2, 1, 2)
671
+
672
+ self.btn_import_mask = QPushButton("Pick mask from view…")
673
+ self.btn_import_mask.clicked.connect(self._import_mask_from_view)
674
+ gl.addWidget(self.btn_import_mask, 7, 4, 1, 2)
675
+
676
+ self.lbl_imported_mask = QLabel("No imported mask")
677
+ gl.addWidget(self.lbl_imported_mask, 7, 6, 1, 2)
678
+
679
+ # Column sizing
680
+ gl.setColumnStretch(0, 0)
681
+ gl.setColumnStretch(1, 0)
682
+ for c in (2,3,4,5,6,7):
683
+ gl.setColumnStretch(c, 1)
684
+
685
+ left.addWidget(gb_mask)
686
+
687
+ # ===== Adjustments
688
+ # CMY
689
+ gb_cmy = QGroupBox(self.tr("Complementary colors (CMY)"))
690
+ glc = QGridLayout(gb_cmy)
691
+ self.sl_c, self.ds_c = self._slider_pair(glc, "Cyan:", 0)
692
+ self.sl_m, self.ds_m = self._slider_pair(glc, "Magenta:", 1)
693
+ self.sl_y, self.ds_y = self._slider_pair(glc, "Yellow:", 2)
694
+ left.addWidget(gb_cmy)
695
+
696
+ # RGB
697
+ gb_rgb = QGroupBox(self.tr("RGB Colors"))
698
+ glr = QGridLayout(gb_rgb)
699
+ self.sl_r, self.ds_r = self._slider_pair(glr, "Red:", 0)
700
+ self.sl_g, self.ds_g = self._slider_pair(glr, "Green:", 1)
701
+ self.sl_b, self.ds_b = self._slider_pair(glr, "Blue:", 2)
702
+ left.addWidget(gb_rgb)
703
+
704
+ # LSC
705
+ gb_lsc = QGroupBox(self.tr("Luminance, Chroma/Saturation, Contrast"))
706
+ gll = QGridLayout(gb_lsc)
707
+ self.sl_l, self.ds_l = self._slider_pair(gll, "Luminance:", 0)
708
+ self.sl_chroma, self.ds_chroma = self._slider_pair(gll, "Chroma (L-preserving):", 1)
709
+ self.sl_s, self.ds_s = self._slider_pair(gll, "Saturation (HSV S):", 2)
710
+ self.sl_c2, self.ds_c2 = self._slider_pair(gll, "Contrast:", 3)
711
+ gll.addWidget(QLabel("Color boost mode:"), 4, 0)
712
+ self.dd_color_mode = QComboBox()
713
+ self.dd_color_mode.addItems(["Chroma (L-preserving)", "Saturation (HSV S)"])
714
+ self.dd_color_mode.setCurrentIndex(0)
715
+ self.dd_color_mode.currentIndexChanged.connect(self._update_color_mode_enabled)
716
+ gll.addWidget(self.dd_color_mode, 4, 1, 1, 2)
717
+ left.addWidget(gb_lsc)
718
+
719
+ # Wrap controls in a scroller (horizontal scroll allowed if needed)
720
+ left_scroll = QScrollArea()
721
+ left_scroll.setWidget(controls_container)
722
+ left_scroll.setWidgetResizable(False)
723
+ left_scroll.setFrameShape(QFrame.Shape.NoFrame)
724
+ left_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
725
+ left_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
726
+ left_outer.addWidget(left_scroll, 1)
727
+
728
+ # Live toggle (non-scroll)
729
+ self.cb_live = QCheckBox("Preview changed image")
730
+ self.cb_live.setChecked(True)
731
+ self.cb_live.toggled.connect(self._update_preview_pixmap)
732
+ left_outer.addWidget(self.cb_live)
733
+
734
+ # Buttons row (non-scroll)
735
+ row = QHBoxLayout()
736
+ self.btn_apply = QPushButton("Apply")
737
+ self.btn_apply.clicked.connect(self._apply_to_document)
738
+ self.btn_push = QPushButton("Apply as New Document")
739
+ self.btn_push.clicked.connect(self._apply_as_new_doc)
740
+ self.btn_export_mask = QPushButton("Export Mask")
741
+ self.btn_export_mask.clicked.connect(self._export_mask_doc)
742
+ self.btn_reset = QPushButton("↺ Reset")
743
+ self.btn_reset.clicked.connect(self._reset_controls)
744
+ row.addWidget(self.btn_apply)
745
+ row.addWidget(self.btn_push)
746
+ row.addWidget(self.btn_export_mask)
747
+ row.addWidget(self.btn_reset)
748
+ left_outer.addLayout(row)
749
+
750
+ # ======================================================================
751
+ # RIGHT PANE (zoom toolbar + preview scroller + picked hue readout)
752
+ # ======================================================================
753
+ right_widget = QWidget()
754
+ right_widget.setMinimumWidth(420)
755
+ right_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
756
+ splitter.addWidget(right_widget)
757
+
758
+ right = QVBoxLayout(right_widget)
759
+ right.setContentsMargins(0, 0, 0, 0)
760
+ right.setSpacing(8)
761
+
762
+ # Zoom toolbar (themed)
763
+ zoom_row = QHBoxLayout()
764
+
765
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
766
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
767
+ self.btn_zoom_1 = themed_toolbtn("zoom-original", "1:1")
768
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit")
769
+
770
+ zoom_row.addWidget(self.btn_zoom_out)
771
+ zoom_row.addWidget(self.btn_zoom_in)
772
+ zoom_row.addWidget(self.btn_zoom_1)
773
+ zoom_row.addWidget(self.btn_fit)
774
+ zoom_row.addStretch(1)
775
+ right.addLayout(zoom_row)
776
+
777
+ self.lbl_help = QLabel(
778
+ "🖱️ <b>Click</b>: show hue &nbsp;•&nbsp; "
779
+ "<b>Shift + Click</b>: select that color &nbsp;•&nbsp; "
780
+ "<b>Ctrl + Click & Drag</b>: pan &nbsp;•&nbsp; "
781
+ "<b>Ctrl + Wheel</b>: zoom"
782
+ )
783
+
784
+ self.lbl_help.setWordWrap(True)
785
+ self.lbl_help.setTextFormat(Qt.TextFormat.RichText)
786
+ self.lbl_help.setStyleSheet("color: #888; font-size: 11px;")
787
+ right.addWidget(self.lbl_help)
788
+
789
+ # Preview scroller
790
+ self.scroll = QScrollArea()
791
+ self.scroll.setWidgetResizable(False)
792
+ self.lbl_preview = QLabel()
793
+ self.lbl_preview.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
794
+ self.lbl_preview.setMinimumSize(10, 10)
795
+ self.scroll.setWidget(self.lbl_preview)
796
+ right.addWidget(self.scroll, 1)
797
+
798
+ vp = self.scroll.viewport()
799
+ vp.setMouseTracking(True)
800
+ vp.installEventFilter(self)
801
+
802
+ self.lbl_preview.setToolTip(
803
+ "Click to sample hue.\n"
804
+ "Ctrl + Click & Drag to pan.\n"
805
+ "Ctrl + Mouse Wheel to zoom."
806
+ )
807
+ self.btn_zoom_in.setToolTip("Zoom in (centers view)")
808
+ self.btn_zoom_out.setToolTip("Zoom out (centers view)")
809
+ self.btn_zoom_1.setToolTip("Reset zoom to 1:1")
810
+
811
+ # Hue readout
812
+ self.lbl_hue_readout = QLabel("Picked hue: —")
813
+ right.addWidget(self.lbl_hue_readout)
814
+
815
+ # Splitter stretch: make preview greedy
816
+ splitter.setStretchFactor(0, 0) # left
817
+ splitter.setStretchFactor(1, 1) # right
818
+ splitter.setSizes([420, 900])
819
+
820
+ # Clamp dialog height and add size grip
821
+ self.setSizeGripEnabled(True)
822
+ try:
823
+ g = QGuiApplication.primaryScreen().availableGeometry()
824
+ max_h = int(g.height() * 0.9)
825
+ self.resize(1080, min(680, max_h))
826
+ self.setMaximumHeight(max_h)
827
+ except Exception:
828
+ self.resize(1080, 680)
829
+
830
+ # ---- Wiring that depends on built widgets ----------------------------
831
+ self._update_color_mode_enabled()
832
+ 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):
833
+ w.valueChanged.connect(self._schedule_adjustments)
834
+
835
+ def _sliders_to_wheel(_=None):
836
+ if getattr(self, "_syncing_hue", False):
837
+ return
838
+
839
+ if not self._setting_preset and self.dd_preset.currentText() != "Custom":
840
+ self.dd_preset.blockSignals(True)
841
+ self.dd_preset.setCurrentText("Custom")
842
+ self.dd_preset.blockSignals(False)
843
+
844
+ s = int(self.sp_h1.value())
845
+ e = int(self.sp_h2.value())
846
+ self.hue_wheel.setRange(s, e, notify=False)
847
+ self._schedule_mask()
848
+
849
+
850
+ self.sp_h1.valueChanged.connect(_sliders_to_wheel)
851
+ self.sp_h2.valueChanged.connect(_sliders_to_wheel)
852
+ self.sl_h1.valueChanged.connect(_sliders_to_wheel)
853
+ self.sl_h2.valueChanged.connect(_sliders_to_wheel)
854
+
855
+ # Zoom behavior
856
+ self._zoom = 1.0
857
+
858
+
859
+ self.btn_zoom_in.clicked.connect(lambda: self._apply_zoom(self._zoom * 1.25, None))
860
+ self.btn_zoom_out.clicked.connect(lambda: self._apply_zoom(self._zoom / 1.25, None))
861
+ self.btn_zoom_1.clicked.connect(lambda: self._apply_zoom(1.0, None))
862
+
863
+ # Ctrl+wheel: zoom around mouse position (label coords)
864
+ def _wheel_event(ev):
865
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
866
+ factor = 1.25 if ev.angleDelta().y() > 0 else 1/1.25
867
+ self._apply_zoom(self._zoom * factor, anchor_label_pos=ev.position())
868
+ ev.accept()
869
+ return
870
+ QLabel.wheelEvent(self.lbl_preview, ev)
871
+
872
+ self.lbl_preview.wheelEvent = _wheel_event
873
+
874
+ # Preview interactions
875
+ self.lbl_preview.setMouseTracking(True)
876
+ self.lbl_preview.installEventFilter(self)
877
+
878
+ # First paint
879
+ self._update_preview_pixmap()
880
+
881
+ # --- Zoom helpers ----------------------------------------------------
882
+ def _current_scroll(self):
883
+ hbar = self.scroll.horizontalScrollBar()
884
+ vbar = self.scroll.verticalScrollBar()
885
+ return hbar.value(), vbar.value(), hbar.maximum(), vbar.maximum()
886
+
887
+ def _set_scroll(self, x, y):
888
+ hbar = self.scroll.horizontalScrollBar()
889
+ vbar = self.scroll.verticalScrollBar()
890
+ hbar.setValue(int(max(0, min(x, hbar.maximum()))))
891
+ vbar.setValue(int(max(0, min(y, vbar.maximum()))))
892
+
893
+ def _apply_zoom(self, new_zoom: float, anchor_label_pos=None):
894
+ """
895
+ new_zoom: float
896
+ anchor_label_pos: QPointF in *label (content)* coords to keep fixed on screen.
897
+ If None, use viewport center.
898
+ """
899
+ old_zoom = getattr(self, "_zoom", 1.0)
900
+ new_zoom = max(0.05, min(16.0, float(new_zoom)))
901
+ if abs(new_zoom - old_zoom) < 1e-6:
902
+ return
903
+
904
+ # Figure out the anchor (content coords)
905
+ if anchor_label_pos is None:
906
+ # viewport center → content coords
907
+ sx, sy, _, _ = self._current_scroll()
908
+ vp = self.scroll.viewport().rect()
909
+ cx = (sx + vp.width() / 2.0) / max(old_zoom, 1e-9)
910
+ cy = (sy + vp.height() / 2.0) / max(old_zoom, 1e-9)
911
+ else:
912
+ cx = float(anchor_label_pos.x()) / max(1.0, 1.0) # label coords already in content space
913
+ cy = float(anchor_label_pos.y()) / max(1.0, 1.0)
914
+
915
+ # Where is that content point on the viewport *before* zoom?
916
+ sx, sy, _, _ = self._current_scroll()
917
+ vp = self.scroll.viewport().rect()
918
+ pvx = cx * old_zoom - sx # pixel pos in viewport
919
+ pvy = cy * old_zoom - sy
920
+
921
+ # Apply zoom and repaint
922
+ self._zoom = new_zoom
923
+ self._update_preview_pixmap()
924
+
925
+ # Set scroll so that the same content point stays at the same viewport pixel
926
+ nx = cx * new_zoom - pvx
927
+ ny = cy * new_zoom - pvy
928
+ self._set_scroll(nx, ny)
929
+
930
+ def _fit_to_preview(self):
931
+ if not hasattr(self, "_base_pm") or self._base_pm is None:
932
+ return
933
+ vp = self.scroll.viewport().size()
934
+ pm = self._base_pm.size()
935
+ if pm.width() <= 0 or pm.height() <= 0:
936
+ return
937
+ k = min(vp.width() / pm.width(), vp.height() / pm.height())
938
+ self._apply_zoom(k, anchor_label_pos=None)
939
+
940
+ # --- Pan helpers -----------------------------------------------------
941
+ def _begin_pan(self, pos_label):
942
+ self._panning = True
943
+ self._pan_start_pos = pos_label
944
+ hbar = self.scroll.horizontalScrollBar()
945
+ vbar = self.scroll.verticalScrollBar()
946
+ self._pan_start_scroll = (hbar.value(), vbar.value())
947
+ try:
948
+ self.lbl_preview.setCursor(Qt.CursorShape.ClosedHandCursor)
949
+ except Exception:
950
+ pass
951
+
952
+ def _update_pan(self, pos_label):
953
+ if not self._panning or self._pan_start_pos is None:
954
+ return
955
+ dx = pos_label.x() - self._pan_start_pos.x() # label pixels
956
+ dy = pos_label.y() - self._pan_start_pos.y()
957
+ sx0, sy0 = self._pan_start_scroll
958
+ # invert to move content with the mouse
959
+ self._set_scroll(sx0 - dx, sy0 - dy)
960
+
961
+ def _end_pan(self):
962
+ self._panning = False
963
+ self._pan_start_pos = None
964
+ try:
965
+ self.lbl_preview.setCursor(Qt.CursorShape.ArrowCursor)
966
+ except Exception:
967
+ pass
968
+
969
+
970
+ def _update_color_mode_enabled(self):
971
+ use_chroma = (self.dd_color_mode.currentIndex() == 0)
972
+ # enable Chroma controls when chroma mode; disable Sat controls, and vice versa
973
+ self.ds_chroma.setEnabled(use_chroma); self.sl_chroma.setEnabled(use_chroma)
974
+ self.ds_s.setEnabled(not use_chroma); self.sl_s.setEnabled(not use_chroma)
975
+ # refresh preview
976
+ self._schedule_adjustments()
977
+
978
+
979
+ def _set_pair(self, sld: QSlider, box: QDoubleSpinBox, value: float):
980
+ # block both sides to avoid ping-pong and callbacks
981
+ sld.blockSignals(True); box.blockSignals(True)
982
+ sld.setValue(int(round(value * 100))) # because slider units are *100
983
+ box.setValue(float(value))
984
+ sld.blockSignals(False); box.blockSignals(False)
985
+
986
+
987
+ def _reset_controls(self):
988
+ """Reset all UI controls to defaults and rebuild mask/preview on current self.img."""
989
+ # pause timers while resetting
990
+ self._mask_timer.stop()
991
+ self._adj_timer.stop()
992
+
993
+ # --- Preset: make 'Red' the default and let _on_preset_change drive the wheel/sliders ---
994
+ # IMPORTANT: do NOT overwrite with 'Custom' afterwards.
995
+ self._setting_preset = True
996
+ try:
997
+ # This emits currentTextChanged -> _on_preset_change(), which:
998
+ # - sets the hue_wheel to the preset range (notify=False)
999
+ # - sets sp_h1/sp_h2 to the preset lo/hi
1000
+ # - calls _recompute_mask_and_preview()
1001
+ self.dd_preset.setCurrentText("Red")
1002
+ finally:
1003
+ self._setting_preset = False
1004
+
1005
+ # --- Mask gating defaults (won't change the preset/wheel) ---
1006
+ def setv(w, val):
1007
+ w.blockSignals(True)
1008
+ if isinstance(w, (QDoubleSpinBox, QSpinBox)):
1009
+ w.setValue(val)
1010
+ elif isinstance(w, QCheckBox):
1011
+ w.setChecked(bool(val))
1012
+ elif isinstance(w, QComboBox):
1013
+ idx = w.findText(val)
1014
+ if idx >= 0:
1015
+ w.setCurrentIndex(idx)
1016
+ elif isinstance(w, QSlider):
1017
+ w.setValue(int(val))
1018
+ w.blockSignals(False)
1019
+
1020
+ setv(self.ds_minC, 0.05)
1021
+ setv(self.ds_minL, 0.0)
1022
+ setv(self.ds_maxL, 1.0)
1023
+ setv(self.ds_smooth, 10.0)
1024
+ setv(self.cb_invert, False)
1025
+
1026
+ # Shadows/Highlights/Balance
1027
+ setv(self.ds_sh, 0.0)
1028
+ setv(self.ds_hi, 1.0)
1029
+ setv(self.ds_bal, 0.5)
1030
+
1031
+ # Blur / overlays / preview
1032
+ setv(self.sb_blur, 0)
1033
+ setv(self.cb_show_mask, False)
1034
+ # keep user’s small/large preview choice & zoom as-is
1035
+
1036
+ # CMY/RGB/LSC back to 0, intensity to 1.0
1037
+ self._set_pair(self.sl_c, self.ds_c, 0.0)
1038
+ self._set_pair(self.sl_m, self.ds_m, 0.0)
1039
+ self._set_pair(self.sl_y, self.ds_y, 0.0)
1040
+ self._set_pair(self.sl_r, self.ds_r, 0.0)
1041
+ self._set_pair(self.sl_g, self.ds_g, 0.0)
1042
+ self._set_pair(self.sl_b, self.ds_b, 0.0)
1043
+ self._set_pair(self.sl_l, self.ds_l, 0.0)
1044
+ self._set_pair(self.sl_s, self.ds_s, 0.0)
1045
+ self._set_pair(self.sl_c2, self.ds_c2, 0.0)
1046
+
1047
+ self._set_pair(self.sl_chroma, self.ds_chroma, 0.0)
1048
+ # default to Chroma mode
1049
+ self.dd_color_mode.blockSignals(True)
1050
+ self.dd_color_mode.setCurrentIndex(0)
1051
+ self.dd_color_mode.blockSignals(False)
1052
+ self._update_color_mode_enabled()
1053
+
1054
+ self.ds_int.blockSignals(True)
1055
+ self.ds_int.setValue(1.0)
1056
+ self.ds_int.blockSignals(False)
1057
+
1058
+ # Clear any sampled hue marker on the wheel
1059
+ self.hue_wheel.setPickedHue(None)
1060
+
1061
+ # Rebuild preview (preset handler already recomputed the mask, but this is safe)
1062
+ self._recompute_mask_and_preview()
1063
+
1064
+
1065
+ def _schedule_adjustments(self, *_, delay_ms: int | None = None):
1066
+ if delay_ms is None:
1067
+ delay_ms = getattr(self, "_adj_delay_ms", 200)
1068
+
1069
+ if not hasattr(self, "_adj_timer"):
1070
+ return
1071
+
1072
+ ms = max(1, int(delay_ms)) # never allow 0/negative
1073
+ self._adj_timer.stop()
1074
+ self._adj_timer.start(ms)
1075
+
1076
+ def _schedule_mask(self, *_, delay_ms: int | None = None):
1077
+ if delay_ms is None:
1078
+ delay_ms = getattr(self, "_mask_delay_ms", 200)
1079
+
1080
+ if not hasattr(self, "_mask_timer"):
1081
+ return
1082
+
1083
+ ms = max(1, int(delay_ms))
1084
+ self._mask_timer.stop()
1085
+ self._mask_timer.start(ms)
1086
+
1087
+
1088
+ def _sample_hue_deg_from_base(self, x: int, y: int) -> float | None:
1089
+ """Return hue in degrees at (x,y) in _last_base (float RGB in [0,1])."""
1090
+ base = getattr(self, "_last_base", None)
1091
+ if base is None:
1092
+ return None
1093
+ h, w = base.shape[:2]
1094
+ if not (0 <= x < w and 0 <= y < h):
1095
+ return None
1096
+ 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)
1097
+ hsv = _rgb_to_hsv01(pix) # 1x1x3, H in [0,1]
1098
+ return float(hsv[0,0,0] * 360.0)
1099
+
1100
+ def _map_label_point_to_image_xy(self, ev_pos):
1101
+ """Map a click on the *label* to base image (x,y), accounting for zoom."""
1102
+ base = getattr(self, "_last_base", None)
1103
+ if base is None:
1104
+ return None
1105
+ bh, bw = base.shape[:2]
1106
+ # ev_pos is in the label's local coordinates
1107
+ x = int(round(ev_pos.x() / max(self._zoom, 1e-6)))
1108
+ y = int(round(ev_pos.y() / max(self._zoom, 1e-6)))
1109
+ if x < 0 or y < 0 or x >= bw or y >= bh:
1110
+ return None
1111
+ return (x, y)
1112
+
1113
+
1114
+ def eventFilter(self, obj, ev):
1115
+ from PyQt6.QtCore import QEvent, Qt
1116
+
1117
+ # Helper: get event position in *viewport* coords regardless of target
1118
+ def _pos_in_viewport(o, e):
1119
+ if o is self.scroll.viewport():
1120
+ return e.position() # already viewport coords (QPointF)
1121
+ # map label-local → viewport
1122
+ return self.lbl_preview.mapTo(self.scroll.viewport(), e.position().toPoint())
1123
+
1124
+ # --- PANNING (Ctrl + LMB) on viewport *or* label ---
1125
+ if obj in (self.scroll.viewport(), self.lbl_preview):
1126
+ if ev.type() == QEvent.Type.MouseButtonPress:
1127
+ if (ev.button() == Qt.MouseButton.LeftButton and
1128
+ ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
1129
+ self._panning = True
1130
+ self._pan_start_pos_vp = _pos_in_viewport(obj, ev)
1131
+ hbar = self.scroll.horizontalScrollBar()
1132
+ vbar = self.scroll.verticalScrollBar()
1133
+ self._pan_start_scroll = (hbar.value(), vbar.value())
1134
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
1135
+ return True
1136
+
1137
+ elif ev.type() == QEvent.Type.MouseMove and self._panning:
1138
+ cur = _pos_in_viewport(obj, ev)
1139
+ dx = cur.x() - self._pan_start_pos_vp.x()
1140
+ dy = cur.y() - self._pan_start_pos_vp.y()
1141
+ if abs(dx) > self._pan_deadzone or abs(dy) > self._pan_deadzone:
1142
+ hbar = self.scroll.horizontalScrollBar()
1143
+ vbar = self.scroll.verticalScrollBar()
1144
+ hbar.setValue(int(self._pan_start_scroll[0] - dx))
1145
+ vbar.setValue(int(self._pan_start_scroll[1] - dy))
1146
+ return True
1147
+
1148
+ elif ev.type() in (QEvent.Type.MouseButtonRelease, QEvent.Type.Leave):
1149
+ if self._panning:
1150
+ self._panning = False
1151
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
1152
+ return True
1153
+
1154
+ # --- Hue pick (plain click) on the label only ---
1155
+ if obj is self.lbl_preview and ev.type() == QEvent.Type.MouseButtonPress:
1156
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
1157
+ return True # Ctrl is for panning; let pan branch handle it
1158
+ pt = self._map_label_point_to_image_xy(ev.position())
1159
+ if pt is not None:
1160
+ x, y = pt
1161
+ hue = self._sample_hue_deg_from_base(x, y)
1162
+ if hue is not None:
1163
+ self.hue_wheel.setPickedHue(hue)
1164
+ self.lbl_hue_readout.setText(f"Picked hue: {hue:.1f}°")
1165
+ if ev.modifiers() & Qt.KeyboardModifier.ShiftModifier:
1166
+ half = 15
1167
+ self.hue_wheel.setRange(int((hue-half) % 360), int((hue+half) % 360))
1168
+ return True
1169
+
1170
+ return super().eventFilter(obj, ev)
1171
+
1172
+ def _slider_row(self, grid: QGridLayout, name: str, row: int) -> QDoubleSpinBox:
1173
+ grid.addWidget(QLabel(name), row, 0)
1174
+ s = QDoubleSpinBox()
1175
+ s.setRange(-1.0, 1.0); s.setSingleStep(0.05); s.setDecimals(2); s.setValue(0.0)
1176
+ s.valueChanged.connect(self._recompute_mask_and_preview)
1177
+ grid.addWidget(s, row, 1)
1178
+ return s
1179
+
1180
+ def _slider_pair(self, grid: QGridLayout, name: str, row: int, minv=-1.0, maxv=1.0, step=0.05):
1181
+ import math
1182
+
1183
+ def _to_slider(v: float) -> int:
1184
+ # Symmetric rounding away from zero at half-steps; no banker’s rounding.
1185
+ s = abs(v) * 100.0
1186
+ s = math.floor(s + 0.5)
1187
+ return int(-s if v < 0 else s)
1188
+
1189
+ def _to_spin(v_int: int) -> float:
1190
+ return float(v_int) / 100.0
1191
+
1192
+ grid.addWidget(QLabel(name), row, 0)
1193
+
1194
+ sld = QSlider(Qt.Orientation.Horizontal)
1195
+ sld.setRange(int(minv*100), int(maxv*100)) # e.g., -100..100
1196
+ sld.setSingleStep(int(step*100)) # e.g., 5
1197
+ sld.setPageStep(int(5*step*100)) # e.g., 25
1198
+ sld.setValue(0)
1199
+
1200
+ box = QDoubleSpinBox()
1201
+ box.setRange(minv, maxv)
1202
+ box.setSingleStep(step)
1203
+ box.setDecimals(2)
1204
+ box.setValue(0.0)
1205
+ box.setKeyboardTracking(False) # only fire on committed changes
1206
+
1207
+ # Two-way binding without ping-pong
1208
+ def _sld_to_box(v_int: int):
1209
+ box.blockSignals(True)
1210
+ box.setValue(_to_spin(v_int))
1211
+ box.blockSignals(False)
1212
+
1213
+ def _box_to_sld(v_float: float):
1214
+ sld.blockSignals(True)
1215
+ sld.setValue(_to_slider(v_float))
1216
+ sld.blockSignals(False)
1217
+
1218
+ sld.valueChanged.connect(_sld_to_box)
1219
+ box.valueChanged.connect(_box_to_sld)
1220
+
1221
+ # Debounced preview updates (adjustments don’t rebuild mask)
1222
+ sld.valueChanged.connect(self._schedule_adjustments)
1223
+ box.valueChanged.connect(self._schedule_adjustments)
1224
+ # Nice UX: force one final refresh on release
1225
+ sld.sliderReleased.connect(self._update_preview_pixmap)
1226
+ box.editingFinished.connect(self._update_preview_pixmap)
1227
+
1228
+ grid.addWidget(sld, row, 1)
1229
+ grid.addWidget(box, row, 2)
1230
+ return sld, box
1231
+
1232
+
1233
+ # ------------- Logic -------------
1234
+ def _on_preset_change(self, txt: str):
1235
+ self._setting_preset = True
1236
+ try:
1237
+ if txt != "Custom":
1238
+ intervals = _PRESETS.get(txt, [])
1239
+ if intervals:
1240
+ lo, hi = (intervals[0][0], intervals[-1][1]) if len(intervals) > 1 else intervals[0]
1241
+
1242
+ # --- NEW: keep wheel + sliders + spins all in sync ---
1243
+ self._syncing_hue = True
1244
+ try:
1245
+ # update wheel silently
1246
+ self.hue_wheel.setRange(int(lo), int(hi), notify=False)
1247
+
1248
+ # update both sliders and spins (no ping-pong)
1249
+ for w, val in (
1250
+ (self.sl_h1, int(lo)), (self.sp_h1, int(lo)),
1251
+ (self.sl_h2, int(hi)), (self.sp_h2, int(hi)),
1252
+ ):
1253
+ w.blockSignals(True)
1254
+ w.setValue(val)
1255
+ w.blockSignals(False)
1256
+
1257
+ self.hue_wheel.update() # ensure repaint
1258
+ finally:
1259
+ self._syncing_hue = False
1260
+
1261
+ self._recompute_mask_and_preview()
1262
+ finally:
1263
+ self._setting_preset = False
1264
+
1265
+ def _downsample(self, img, max_dim=1024):
1266
+ h, w = img.shape[:2]
1267
+ s = max(h, w)
1268
+ if s <= max_dim: return img
1269
+ k = max_dim / float(s)
1270
+ if cv2 is None:
1271
+ return cv2.resize(img, (int(w*k), int(h*k))) if False else img[::int(1/k), ::int(1/k)]
1272
+ return cv2.resize(img, (int(w*k), int(h*k)), interpolation=cv2.INTER_AREA)
1273
+
1274
+ def _recompute_mask_and_preview(self):
1275
+ if self.img is None:
1276
+ return
1277
+
1278
+ base = self._downsample(self.img, 1200) if self.cb_small_preview.isChecked() else self.img
1279
+ self._last_base = base
1280
+
1281
+ # if user wants imported mask and we have one → use it
1282
+ if self._use_imported_mask and self._imported_mask_full is not None:
1283
+ imp = self._imported_mask_full
1284
+ bh, bw = base.shape[:2]
1285
+ mh, mw = imp.shape[:2]
1286
+ if (mh, mw) != (bh, bw):
1287
+ if cv2 is not None:
1288
+ mask = cv2.resize(imp, (bw, bh), interpolation=cv2.INTER_LINEAR)
1289
+ else:
1290
+ yy = (np.linspace(0, mh - 1, bh)).astype(int)
1291
+ xx = (np.linspace(0, mw - 1, bw)).astype(int)
1292
+ mask = imp[yy[:, None], xx[None, :]]
1293
+ else:
1294
+ mask = imp
1295
+ mask = np.clip(mask.astype(np.float32), 0.0, 1.0)
1296
+ else:
1297
+ # your original hue-based build
1298
+ preset = self.dd_preset.currentText()
1299
+ if preset == "Custom":
1300
+ ranges = [(float(self.sp_h1.value()), float(self.sp_h2.value()))]
1301
+ else:
1302
+ ranges = _PRESETS[preset]
1303
+
1304
+ mask = _hue_mask(
1305
+ base,
1306
+ ranges_deg=ranges,
1307
+ min_chroma=float(self.ds_minC.value()),
1308
+ min_light=float(self.ds_minL.value()),
1309
+ max_light=float(self.ds_maxL.value()),
1310
+ smooth_deg=float(self.ds_smooth.value()),
1311
+ invert_range=self.cb_invert.isChecked(),
1312
+ )
1313
+
1314
+ mask = _weight_shadows_highlights(
1315
+ mask, base,
1316
+ shadows=float(self.ds_sh.value()),
1317
+ highlights=float(self.ds_hi.value()),
1318
+ balance=float(self.ds_bal.value()),
1319
+ )
1320
+
1321
+ k = int(self.sb_blur.value())
1322
+ if k > 0 and cv2 is not None:
1323
+ mask = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), float(k))
1324
+
1325
+ self._mask = np.clip(mask, 0.0, 1.0)
1326
+ self._update_preview_pixmap()
1327
+
1328
+ def _on_use_imported_mask_toggled(self, on: bool):
1329
+ self._use_imported_mask = bool(on)
1330
+ # if we don't have an imported mask yet, turn it off again
1331
+ if self._use_imported_mask and self._imported_mask_full is None:
1332
+ self._use_imported_mask = False
1333
+ self.cb_use_imported.setChecked(False)
1334
+ QMessageBox.information(self, "No imported mask", "Pick a mask view first.")
1335
+ return
1336
+
1337
+ # just rebuild preview with the external mask
1338
+ self._recompute_mask_and_preview()
1339
+
1340
+ def _import_mask_from_view(self):
1341
+ if self.docman is None:
1342
+ QMessageBox.information(self, "No document manager", "Cannot import without a document manager.")
1343
+ return
1344
+
1345
+ # get ALL docs user currently has open (renamed, FITS layers, XISF layers, duplicates, etc.)
1346
+ docs = self.docman.all_documents() or []
1347
+ # only image docs
1348
+ img_docs = [d for d in docs if hasattr(d, "image") and d.image is not None]
1349
+
1350
+ if not img_docs:
1351
+ QMessageBox.information(self, "No views", "There are no image views to import a mask from.")
1352
+ return
1353
+
1354
+ # build names as the user sees them
1355
+ items = []
1356
+ for d in img_docs:
1357
+ try:
1358
+ nm = d.display_name()
1359
+ except Exception:
1360
+ nm = "Untitled"
1361
+ items.append(nm)
1362
+
1363
+ from PyQt6.QtWidgets import QInputDialog
1364
+ choice, ok = QInputDialog.getItem(
1365
+ self,
1366
+ "Pick mask view",
1367
+ "Open image views:",
1368
+ items,
1369
+ 0,
1370
+ False
1371
+ )
1372
+ if not ok:
1373
+ return
1374
+
1375
+ # find selected document
1376
+ sel_doc = None
1377
+ for d, nm in zip(img_docs, items):
1378
+ if nm == choice:
1379
+ sel_doc = d
1380
+ break
1381
+
1382
+ if sel_doc is None or getattr(sel_doc, "image", None) is None:
1383
+ QMessageBox.warning(self, "Import failed", "Selected view has no image.")
1384
+ return
1385
+
1386
+ mask_img = np.clip(sel_doc.image.astype(np.float32), 0.0, 1.0)
1387
+
1388
+ # if it's RGB, take channel 0 — that’s how your exported mask would look (3 equal channels)
1389
+ if mask_img.ndim == 3:
1390
+ mask_img = mask_img[..., 0]
1391
+
1392
+ # resize to current image size if needed
1393
+ dst_h, dst_w = self.img.shape[:2]
1394
+ src_h, src_w = mask_img.shape[:2]
1395
+ if (src_h, src_w) != (dst_h, dst_w):
1396
+ if cv2 is not None:
1397
+ mask_full = cv2.resize(mask_img, (dst_w, dst_h), interpolation=cv2.INTER_LINEAR)
1398
+ else:
1399
+ yy = (np.linspace(0, src_h - 1, dst_h)).astype(int)
1400
+ xx = (np.linspace(0, src_w - 1, dst_w)).astype(int)
1401
+ mask_full = mask_img[yy[:, None], xx[None, :]]
1402
+ else:
1403
+ mask_full = mask_img
1404
+
1405
+ mask_full = np.clip(mask_full.astype(np.float32), 0.0, 1.0)
1406
+
1407
+ # store
1408
+ self._imported_mask_full = mask_full
1409
+ self._imported_mask_name = choice
1410
+ self.lbl_imported_mask.setText(f"Imported: {choice}")
1411
+
1412
+ # auto-enable
1413
+ self.cb_use_imported.setChecked(True)
1414
+ self._use_imported_mask = True
1415
+
1416
+ # refresh preview
1417
+ self._recompute_mask_and_preview()
1418
+
1419
+
1420
+ def _overlay_mask(self, base: np.ndarray, mask: np.ndarray) -> np.ndarray:
1421
+ base = _ensure_rgb01(base)
1422
+ # mask is HxW -> expand to HxWx1 for broadcasting
1423
+ alpha = np.clip(mask.astype(np.float32), 0.0, 1.0)[..., None] * 0.6
1424
+ # red overlay
1425
+ overlay = base.copy()
1426
+ overlay[..., 0] = np.clip(base[..., 0]*(1 - alpha[..., 0]) + 1.0*alpha[..., 0], 0.0, 1.0)
1427
+ overlay[..., 1] = np.clip(base[..., 1]*(1 - alpha[..., 0]) + 0.0*alpha[..., 0], 0.0, 1.0)
1428
+ overlay[..., 2] = np.clip(base[..., 2]*(1 - alpha[..., 0]) + 0.0*alpha[..., 0], 0.0, 1.0)
1429
+ return overlay
1430
+
1431
+ def _update_preview_pixmap(self):
1432
+ if not hasattr(self, "_last_base"):
1433
+ self._recompute_mask_and_preview(); return
1434
+
1435
+ base = self._last_base
1436
+ mask = getattr(self, "_mask", np.zeros(base.shape[:2], np.float32))
1437
+
1438
+ if self.cb_live.isChecked():
1439
+ out = _apply_selective_adjustments(
1440
+ base, mask,
1441
+ cyan=float(self.ds_c.value()),
1442
+ magenta=float(self.ds_m.value()),
1443
+ yellow=float(self.ds_y.value()),
1444
+ r=float(self.ds_r.value()),
1445
+ g=float(self.ds_g.value()),
1446
+ b=float(self.ds_b.value()),
1447
+ lum=float(self.ds_l.value()),
1448
+ chroma=float(self.ds_chroma.value()),
1449
+ sat=float(self.ds_s.value()),
1450
+ con=float(self.ds_c2.value()),
1451
+ intensity=float(self.ds_int.value()),
1452
+ use_chroma_mode=(self.dd_color_mode.currentIndex() == 0),
1453
+ )
1454
+
1455
+ out = _ensure_rgb01(out)
1456
+ else:
1457
+ out = _ensure_rgb01(base)
1458
+
1459
+ if self.cb_show_mask.isChecked():
1460
+ # fade overlay by intensity too
1461
+ mask_vis = mask * float(self.ds_int.value())
1462
+ show = self._overlay_mask(out, mask_vis)
1463
+ else:
1464
+ show = out
1465
+
1466
+ pm = _to_pixmap(show)
1467
+ h, w = show.shape[:2]
1468
+ zw, zh = max(1, int(round(w * self._zoom))), max(1, int(round(h * self._zoom)))
1469
+ pm_scaled = pm.scaled(zw, zh, Qt.AspectRatioMode.IgnoreAspectRatio,
1470
+ Qt.TransformationMode.SmoothTransformation)
1471
+ self.lbl_preview.setPixmap(pm_scaled)
1472
+ self.lbl_preview.resize(zw, zh)
1473
+
1474
+
1475
+ def resizeEvent(self, ev):
1476
+ super().resizeEvent(ev)
1477
+ QTimer.singleShot(0, self._update_preview_pixmap)
1478
+
1479
+ # ------------- Apply -------------
1480
+ def _apply_fullres(self) -> np.ndarray:
1481
+ base = self.img
1482
+
1483
+ if self._use_imported_mask and self._imported_mask_full is not None:
1484
+ mask = np.clip(self._imported_mask_full.astype(np.float32), 0.0, 1.0)
1485
+ else:
1486
+ mask = self._build_mask(base)
1487
+
1488
+ out = _apply_selective_adjustments(
1489
+ base, mask,
1490
+ cyan=float(self.ds_c.value()),
1491
+ magenta=float(self.ds_m.value()),
1492
+ yellow=float(self.ds_y.value()),
1493
+ r=float(self.ds_r.value()),
1494
+ g=float(self.ds_g.value()),
1495
+ b=float(self.ds_b.value()),
1496
+ lum=float(self.ds_l.value()),
1497
+ chroma=float(self.ds_chroma.value()),
1498
+ sat=float(self.ds_s.value()),
1499
+ con=float(self.ds_c2.value()),
1500
+ intensity=float(self.ds_int.value()),
1501
+ use_chroma_mode=(self.dd_color_mode.currentIndex() == 0),
1502
+ )
1503
+
1504
+ return out
1505
+
1506
+ def _export_mask_doc(self):
1507
+ if self.docman is None:
1508
+ QMessageBox.information(self, "No document manager", "Cannot export mask without a document manager.")
1509
+ return
1510
+
1511
+ base = self.img
1512
+ if base is None:
1513
+ QMessageBox.information(self, "No image", "Open an image first.")
1514
+ return
1515
+
1516
+ mask = self._build_mask(base) # H x W, float32, 0..1
1517
+ mask_rgb = np.repeat(mask[..., None], 3, axis=2).astype(np.float32)
1518
+
1519
+ name = getattr(self.document, "display_name", lambda: "Image")()
1520
+ title = f"{name} [SelectiveColor MASK]"
1521
+ try:
1522
+ self.docman.open_array(mask_rgb, title=title)
1523
+ except Exception as e:
1524
+ QMessageBox.warning(self, "Export failed", str(e))
1525
+
1526
+
1527
+ def _build_mask(self, base: np.ndarray) -> np.ndarray:
1528
+ """
1529
+ Build the full-res mask using the *current UI settings*.
1530
+ This is exactly what your old _apply_fullres did, just pulled out.
1531
+ """
1532
+ preset = self.dd_preset.currentText()
1533
+ ranges = (
1534
+ [(float(self.sp_h1.value()), float(self.sp_h2.value()))]
1535
+ if preset == "Custom"
1536
+ else _PRESETS[preset]
1537
+ )
1538
+
1539
+ # 1) hue / chroma / light / smooth / invert
1540
+ mask = _hue_mask(
1541
+ base,
1542
+ ranges_deg=ranges,
1543
+ min_chroma=float(self.ds_minC.value()),
1544
+ min_light=float(self.ds_minL.value()),
1545
+ max_light=float(self.ds_maxL.value()),
1546
+ smooth_deg=float(self.ds_smooth.value()),
1547
+ invert_range=self.cb_invert.isChecked(),
1548
+ )
1549
+
1550
+ # 2) shadows / highlights weighting
1551
+ mask = _weight_shadows_highlights(
1552
+ mask, base,
1553
+ shadows=float(self.ds_sh.value()),
1554
+ highlights=float(self.ds_hi.value()),
1555
+ balance=float(self.ds_bal.value()),
1556
+ )
1557
+
1558
+ # 3) optional blur
1559
+ k = int(self.sb_blur.value())
1560
+ if k > 0 and cv2 is not None:
1561
+ mask = cv2.GaussianBlur(mask.astype(np.float32), (0, 0), float(k))
1562
+
1563
+ return np.clip(mask, 0.0, 1.0).astype(np.float32)
1564
+
1565
+
1566
+
1567
+ def _apply_to_document(self):
1568
+ try:
1569
+ result = self._apply_fullres()
1570
+ except Exception as e:
1571
+ QMessageBox.warning(self, "Error", str(e)); return
1572
+
1573
+ # write back to the same document (preferred)
1574
+ try:
1575
+ if hasattr(self.document, "set_image"):
1576
+ self.document.set_image(result)
1577
+ except Exception:
1578
+ # fallback: if set_image fails, at least open it as a new view (but keep dialog open)
1579
+ name = getattr(self.document, "display_name", lambda: "Image")()
1580
+ if hasattr(self.docman, "open_array"):
1581
+ self.docman.open_array(result, title=f"{name} [SelectiveColor]")
1582
+
1583
+ # make the processed image the new working base for further tweaks
1584
+ self.img = np.clip(result.astype(np.float32), 0.0, 1.0)
1585
+ self._last_base = None # force rebuild from current self.img
1586
+ self._reset_controls() # reset knobs; dialog remains open
1587
+
1588
+
1589
+ def _apply_as_new_doc(self):
1590
+ try:
1591
+ result = self._apply_fullres()
1592
+ except Exception as e:
1593
+ QMessageBox.warning(self, "Error", str(e)); return
1594
+
1595
+ name = getattr(self.document, "display_name", lambda: "Image")()
1596
+ new_doc = None
1597
+ if hasattr(self.docman, "open_array"):
1598
+ new_doc = self.docman.open_array(result, title=f"{name} [SelectiveColor]")
1599
+
1600
+ # continue editing the new doc if we got a handle; otherwise just keep editing current
1601
+ if new_doc is not None:
1602
+ self.document = new_doc
1603
+ # refresh label
1604
+ try:
1605
+ disp = getattr(self.document, "display_name", lambda: "Image")()
1606
+ except Exception:
1607
+ disp = "Image"
1608
+ self.lbl_target.setText(f"Target View: <b>{disp}</b>")
1609
+
1610
+ # new working base is the processed pixels either way
1611
+ self.img = np.clip(result.astype(np.float32), 0.0, 1.0)
1612
+ self._last_base = None
1613
+ self._reset_controls()
1614
+