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.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

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,2608 @@
1
+ # pro/curve_editor_pro.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+
5
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QEvent, QPointF, QPoint, QTimer
6
+ from PyQt6.QtWidgets import (
7
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QGraphicsView, QLineEdit, QGraphicsScene,
8
+ QWidget, QMessageBox, QRadioButton, QButtonGroup, QToolButton, QGraphicsEllipseItem, QGraphicsItem, QGraphicsTextItem, QInputDialog, QMenu
9
+ )
10
+ from PyQt6.QtGui import QPixmap, QImage, QWheelEvent, QPainter, QPainterPath, QPen, QColor, QBrush, QIcon, QKeyEvent, QCursor
11
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
12
+
13
+ # Import shared utilities
14
+ from setiastro.saspro.widgets.image_utils import float_to_qimage_rgb8 as _float_to_qimage_rgb8
15
+
16
+ from setiastro.saspro.curves_preset import (
17
+ list_custom_presets, save_custom_preset, _points_norm_to_scene, _norm_mode,
18
+ _shape_points_norm, open_curves_with_preset, _lut_from_preset
19
+ )
20
+ from PyQt6.QtWidgets import QFrame, QSizePolicy
21
+ from scipy.interpolate import PchipInterpolator
22
+ from setiastro.saspro.curves_preset import _sanitize_scene_points, _norm_mode
23
+
24
+ try:
25
+ from setiastro.saspro.legacy.numba_utils import (
26
+ apply_lut_gray as _nb_apply_lut_gray,
27
+ apply_lut_color as _nb_apply_lut_color,
28
+ apply_lut_mono_inplace as _nb_apply_lut_mono_inplace,
29
+ apply_lut_color_inplace as _nb_apply_lut_color_inplace,
30
+ rgb_to_xyz_numba, xyz_to_rgb_numba,
31
+ xyz_to_lab_numba, lab_to_xyz_numba,
32
+ rgb_to_hsv_numba, hsv_to_rgb_numba,
33
+ )
34
+ _HAS_NUMBA = True
35
+ except Exception:
36
+ _HAS_NUMBA = False
37
+
38
+ class DraggablePoint(QGraphicsEllipseItem):
39
+ def __init__(self, curve_editor, x, y, color=Qt.GlobalColor.green, lock_axis=None, position_type=None):
40
+ super().__init__(-5, -5, 10, 10)
41
+ self.curve_editor = curve_editor
42
+ self.lock_axis = lock_axis
43
+ self.position_type = position_type
44
+ self.setBrush(QBrush(color))
45
+ self.setFlags(QGraphicsItem.GraphicsItemFlag.ItemIsMovable | QGraphicsItem.GraphicsItemFlag.ItemSendsScenePositionChanges)
46
+ self.setCursor(Qt.CursorShape.OpenHandCursor)
47
+ self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton | Qt.MouseButton.RightButton)
48
+ self.setPos(x, y)
49
+ outline = QColor(255, 255, 255) if QColor(color).lightnessF() < 0.5 else QColor(0, 0, 0)
50
+ pen = QPen(outline)
51
+ try:
52
+ pen.setWidthF(1.5) # PyQt6 supports float widths
53
+ except AttributeError:
54
+ pen.setWidth(2) # fallback for builds missing setWidthF
55
+ self.setPen(pen)
56
+
57
+ def mousePressEvent(self, event):
58
+ if event.button() == Qt.MouseButton.RightButton:
59
+ if self in self.curve_editor.control_points:
60
+ self.curve_editor.control_points.remove(self)
61
+ self.curve_editor.scene.removeItem(self)
62
+ self.curve_editor.updateCurve()
63
+ return
64
+ super().mousePressEvent(event)
65
+
66
+ def itemChange(self, change, value):
67
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
68
+ new_pos = value
69
+ x = new_pos.x()
70
+ y = new_pos.y()
71
+
72
+ if self.position_type == 'top_right':
73
+ dist_to_top = abs(y-0)
74
+ dist_to_right = abs(x-360)
75
+ if dist_to_right<dist_to_top:
76
+ nx=360
77
+ ny=min(max(y,0),360)
78
+ else:
79
+ ny=0
80
+ nx=min(max(x,0),360)
81
+ x,y=nx,ny
82
+ elif self.position_type=='bottom_left':
83
+ dist_to_left=abs(x-0)
84
+ dist_to_bottom=abs(y-360)
85
+ if dist_to_left<dist_to_bottom:
86
+ nx=0
87
+ ny=min(max(y,0),360)
88
+ else:
89
+ ny=360
90
+ nx=min(max(x,0),360)
91
+ x,y=nx,ny
92
+
93
+ all_points=self.curve_editor.end_points+self.curve_editor.control_points
94
+ other_points=[p for p in all_points if p is not self]
95
+ other_points_sorted=sorted(other_points,key=lambda p:p.scenePos().x())
96
+
97
+ insert_index=0
98
+ for i,p in enumerate(other_points_sorted):
99
+ if p.scenePos().x()<x:
100
+ insert_index=i+1
101
+ else:
102
+ break
103
+
104
+ if insert_index>0:
105
+ left_p=other_points_sorted[insert_index-1]
106
+ left_x=left_p.scenePos().x()
107
+ if x<=left_x:
108
+ x=left_x+0.0001
109
+
110
+ if insert_index<len(other_points_sorted):
111
+ right_p=other_points_sorted[insert_index]
112
+ right_x=right_p.scenePos().x()
113
+ if x>=right_x:
114
+ x=right_x-0.0001
115
+
116
+ x=max(0,min(x,360))
117
+ y=max(0,min(y,360))
118
+
119
+ super().setPos(x,y)
120
+ self.curve_editor.updateCurve()
121
+
122
+ return super().itemChange(change, value)
123
+
124
+ class ImageLabel(QLabel):
125
+ mouseMoved = pyqtSignal(float, float)
126
+ def __init__(self, parent=None):
127
+ super().__init__(parent)
128
+ self.setMouseTracking(True)
129
+ def mouseMoveEvent(self, event):
130
+ self.mouseMoved.emit(event.position().x(), event.position().y())
131
+ super().mouseMoveEvent(event)
132
+
133
+ def _warm_numba_once():
134
+ if not _HAS_NUMBA:
135
+ return
136
+ dummy = np.zeros((2,2), np.float32)
137
+ lut = np.linspace(0,1,16).astype(np.float32)
138
+ try:
139
+ _nb_apply_lut_mono_inplace(dummy, lut) # JIT compile path
140
+ except Exception:
141
+ pass
142
+
143
+ class CurveEditor(QGraphicsView):
144
+ def __init__(self, parent=None):
145
+ super().__init__(parent)
146
+ self.scene = QGraphicsScene(self)
147
+ self.setScene(self.scene)
148
+ self.setRenderHint(QPainter.RenderHint.Antialiasing)
149
+ self.setFixedSize(380, 425)
150
+ self.preview_callback = None # To trigger real-time updates
151
+ self.symmetry_callback = None
152
+ self._cdf = None
153
+ self._cdf_bins = 1024
154
+ self._cdf_total = 0
155
+
156
+ # Initialize control points and curve path
157
+ self.end_points = [] # Start and end points with axis constraints
158
+ self.control_points = [] # Dynamically added control points
159
+ self.curve_path = QPainterPath()
160
+ self.curve_item = None # Stores the curve line
161
+ self.sym_line = None
162
+
163
+ # Set scene rectangle
164
+ self.scene.setSceneRect(0, 0, 360, 360)
165
+ self.scene.setBackgroundBrush(QColor(32, 32, 36)) # dark background
166
+ self._grid_pen = QPen(QColor(95, 95, 105), 0, Qt.PenStyle.DashLine)
167
+ self._label_color = QColor(210, 210, 210) # light grid labels
168
+ self._curve_fg = QColor(255, 255, 255) # bright curve
169
+ self._curve_shadow = QColor(0, 0, 0, 190) # black halo under curve
170
+ self.initGrid()
171
+ self.initCurve()
172
+ _warm_numba_once()
173
+
174
+
175
+
176
+ def _on_symmetry_pick(self, u: float, _v: float):
177
+ # editor already drew the yellow line; now redistribute handles
178
+ self.redistributeHandlesByPivot(u)
179
+ self._set_status(self.tr("Inflection @ K={0:.3f}").format(u))
180
+ self._quick_preview()
181
+
182
+ def initGrid(self):
183
+ pen = self._grid_pen
184
+ for i in range(0, 361, 36): # grid lines
185
+ self.scene.addLine(i, 0, i, 360, pen)
186
+ self.scene.addLine(0, i, 360, i, pen)
187
+
188
+ # X-axis labels (0..1 mapped to 0..360)
189
+ for i in range(0, 361, 36):
190
+ val = i / 360.0
191
+ label = QGraphicsTextItem(f"{val:.3f}")
192
+ label.setDefaultTextColor(self._label_color)
193
+ label.setPos(i - 5, 365)
194
+ self.scene.addItem(label)
195
+
196
+ def initCurve(self):
197
+ # Remove existing items from the scene
198
+ # First remove control points
199
+ for p in self.control_points:
200
+ self.scene.removeItem(p)
201
+ # Remove end points
202
+ for p in self.end_points:
203
+ self.scene.removeItem(p)
204
+ # Remove the curve item if any
205
+ if self.curve_item:
206
+ self.scene.removeItem(self.curve_item)
207
+ self.curve_item = None
208
+
209
+ # Clear existing point lists
210
+ self.end_points = []
211
+ self.control_points = []
212
+
213
+ # Add the default endpoints again
214
+ self.addEndPoint(0, 360, lock_axis=None, position_type='bottom_left', color=Qt.GlobalColor.black)
215
+ self.addEndPoint(360, 0, lock_axis=None, position_type='top_right', color=Qt.GlobalColor.white)
216
+
217
+ # Redraw the initial line
218
+ self.updateCurve()
219
+
220
+ def getControlHandles(self):
221
+ """Return just the user-added handles (not the endpoints)."""
222
+ # control_points are your green, draggable handles:
223
+ return [(p.scenePos().x(), p.scenePos().y()) for p in self.control_points]
224
+
225
+ def setControlHandles(self, handles):
226
+ """Clear existing controls (but keep endpoints), then re-add."""
227
+ # remove any existing controls
228
+ for p in list(self.control_points):
229
+ self.scene.removeItem(p)
230
+ self.control_points.clear()
231
+
232
+ # now add back each one
233
+ for x,y in handles:
234
+ self.addControlPoint(x, y)
235
+
236
+ # finally redraw spline once
237
+ self.updateCurve()
238
+
239
+ def clearSymmetryLine(self):
240
+ """Remove any drawn symmetry line and reset."""
241
+ if self.sym_line:
242
+ self.scene.removeItem(self.sym_line)
243
+ self.sym_line = None
244
+ # redraw without symmetry aid
245
+ self.updateCurve()
246
+
247
+ def addEndPoint(self, x, y, lock_axis=None, position_type=None, color=Qt.GlobalColor.red):
248
+ point = DraggablePoint(self, x, y, color=color, lock_axis=lock_axis, position_type=position_type)
249
+ self.scene.addItem(point)
250
+ self.end_points.append(point)
251
+
252
+ def addControlPoint(self, x, y, lock_axis=None):
253
+
254
+ point = DraggablePoint(self, x, y, color=Qt.GlobalColor.green, lock_axis=lock_axis, position_type=None)
255
+ self.scene.addItem(point)
256
+ self.control_points.append(point)
257
+ self.updateCurve()
258
+
259
+ def setSymmetryCallback(self, fn):
260
+ """fn will be called with (u, v) in [0..1] when user ctrl+clicks the grid."""
261
+ self.symmetry_callback = fn
262
+
263
+ def setSymmetryPoint(self, x, y):
264
+ pen = QPen(Qt.GlobalColor.yellow)
265
+ pen.setStyle(Qt.PenStyle.DashLine)
266
+ pen.setWidth(2)
267
+ if self.sym_line is None:
268
+ # draw a vertical symmetry line at scene X==x
269
+ self.sym_line = self.scene.addLine(x, 0, x, 360, pen)
270
+ else:
271
+ self.sym_line.setLine(x, 0, x, 360)
272
+ # if you want to re-draw the curve mirrored around x,
273
+ # you can trigger updateCurve() here or elsewhere
274
+ self.updateCurve()
275
+
276
+ def catmull_rom_spline(self, p0, p1, p2, p3, t):
277
+ """
278
+ Compute a point on a Catmull-Rom spline segment at parameter t (0<=t<=1).
279
+ Each p is a QPointF.
280
+ """
281
+ t2 = t * t
282
+ t3 = t2 * t
283
+
284
+ x = 0.5 * (2*p1.x() + (-p0.x() + p2.x()) * t +
285
+ (2*p0.x() - 5*p1.x() + 4*p2.x() - p3.x()) * t2 +
286
+ (-p0.x() + 3*p1.x() - 3*p2.x() + p3.x()) * t3)
287
+ y = 0.5 * (2*p1.y() + (-p0.y() + p2.y()) * t +
288
+ (2*p0.y() - 5*p1.y() + 4*p2.y() - p3.y()) * t2 +
289
+ (-p0.y() + 3*p1.y() - 3*p2.y() + p3.y()) * t3)
290
+
291
+ # Clamp to bounding box
292
+ x = max(0, min(360, x))
293
+ y = max(0, min(360, y))
294
+
295
+ return QPointF(x, y)
296
+
297
+ def generateSmoothCurvePoints(self, points):
298
+ """
299
+ Given a sorted list of QGraphicsItems (endpoints + control points),
300
+ generate a list of smooth points approximating a Catmull-Rom spline
301
+ through these points.
302
+ """
303
+ if len(points) < 2:
304
+ return []
305
+ if len(points) == 2:
306
+ # Just a straight line between two points
307
+ p0 = points[0].scenePos()
308
+ p1 = points[1].scenePos()
309
+ return [p0, p1]
310
+
311
+ # Extract scene positions
312
+ pts = [p.scenePos() for p in points]
313
+
314
+ # For Catmull-Rom, we need points before the first and after the last
315
+ # We'll duplicate the first and last points.
316
+ extended_pts = [pts[0]] + pts + [pts[-1]]
317
+
318
+ smooth_points = []
319
+ steps_per_segment = 20 # increase for smoother curve
320
+ for i in range(len(pts) - 1):
321
+ p0 = extended_pts[i]
322
+ p1 = extended_pts[i+1]
323
+ p2 = extended_pts[i+2]
324
+ p3 = extended_pts[i+3]
325
+
326
+ # Sample the spline segment between p1 and p2
327
+ for step in range(steps_per_segment+1):
328
+ t = step / steps_per_segment
329
+ pos = self.catmull_rom_spline(p0, p1, p2, p3, t)
330
+ smooth_points.append(pos)
331
+
332
+ return smooth_points
333
+
334
+ # Add a callback for the preview
335
+ def setPreviewCallback(self, callback):
336
+ self.preview_callback = callback
337
+
338
+ def get8bitLUT(self):
339
+ lut_size = 256
340
+
341
+ curve_pts = self.getCurvePoints()
342
+ if len(curve_pts) == 0:
343
+ return np.linspace(0, 255, lut_size, dtype=np.uint8)
344
+
345
+ curve_array = np.array(curve_pts, dtype=np.float64)
346
+ xs = curve_array[:, 0] # 0..360 (scene)
347
+ ys = curve_array[:, 1] # 0..360 (scene, down)
348
+
349
+ ys_for_lut = 360.0 - ys
350
+
351
+ input_positions = np.linspace(0, 360, lut_size, dtype=np.float64)
352
+ output_values = np.interp(input_positions, xs, ys_for_lut)
353
+
354
+ output_values = (output_values / 360.0) * 255.0
355
+ return np.clip(output_values, 0, 255).astype(np.uint8)
356
+
357
+ def updateCurve(self):
358
+ """Update the curve by redrawing based on endpoints and control points."""
359
+
360
+ all_points = self.end_points + self.control_points
361
+ if not all_points:
362
+ # No points, no curve
363
+ if self.curve_item:
364
+ self.scene.removeItem(self.curve_item)
365
+ self.curve_item = None
366
+ return
367
+
368
+ # Sort points by X coordinate
369
+ sorted_points = sorted(all_points, key=lambda p: p.scenePos().x())
370
+
371
+ # Extract arrays of X and Y
372
+ xs = [p.scenePos().x() for p in sorted_points]
373
+ ys = [p.scenePos().y() for p in sorted_points]
374
+
375
+ # Ensure X values are strictly increasing
376
+ unique_xs, unique_ys = [], []
377
+ for i in range(len(xs)):
378
+ if i == 0 or xs[i] > xs[i - 1]: # Skip duplicate X values
379
+ unique_xs.append(xs[i])
380
+ unique_ys.append(ys[i])
381
+
382
+ # If there's only one point or none, we can't interpolate
383
+ if len(unique_xs) < 2:
384
+ if self.curve_item:
385
+ self.scene.removeItem(self.curve_item)
386
+ self.curve_item = None
387
+
388
+ if len(unique_xs) == 1:
389
+ # Optionally draw a single point
390
+ single_path = QPainterPath()
391
+ single_path.addEllipse(unique_xs[0]-2, unique_ys[0]-2, 4, 4)
392
+ pen = QPen(Qt.GlobalColor.white)
393
+ pen.setWidth(3)
394
+ self.curve_item = self.scene.addPath(single_path, pen)
395
+ return
396
+
397
+ try:
398
+ # Create a PCHIP interpolator
399
+ interpolator = PchipInterpolator(unique_xs, unique_ys)
400
+ self.curve_function = interpolator
401
+
402
+ # Sample the curve
403
+ sample_xs = np.linspace(unique_xs[0], unique_xs[-1], 361)
404
+ sample_ys = interpolator(sample_xs)
405
+
406
+ except ValueError as e:
407
+ print(f"Interpolation Error: {e}") # Log the error instead of crashing
408
+ return # Exit gracefully
409
+
410
+ curve_points = [QPointF(float(x), float(y)) for x, y in zip(sample_xs, sample_ys)]
411
+ self.curve_points = curve_points
412
+
413
+ if not curve_points:
414
+ if self.curve_item:
415
+ self.scene.removeItem(self.curve_item)
416
+ self.curve_item = None
417
+ return
418
+
419
+ self.curve_path = QPainterPath()
420
+ self.curve_path.moveTo(curve_points[0])
421
+ for pt in curve_points[1:]:
422
+ self.curve_path.lineTo(pt)
423
+
424
+ if self.curve_item:
425
+ self.scene.removeItem(self.curve_item)
426
+ self.curve_item = None
427
+ if getattr(self, "curve_shadow_item", None):
428
+ self.scene.removeItem(self.curve_shadow_item)
429
+ self.curve_shadow_item = None
430
+
431
+ # shadow (under)
432
+ sh_pen = QPen(self._curve_shadow)
433
+ sh_pen.setWidth(5)
434
+ sh_pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
435
+ sh_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
436
+ self.curve_shadow_item = self.scene.addPath(self.curve_path, sh_pen)
437
+
438
+ # foreground (over)
439
+ pen = QPen(self._curve_fg)
440
+ pen.setWidth(3)
441
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
442
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
443
+ self.curve_item = self.scene.addPath(self.curve_path, pen)
444
+
445
+ # Trigger the preview callback
446
+ if hasattr(self, 'preview_callback') and self.preview_callback:
447
+ # Generate the 8-bit LUT and pass it to the callback
448
+ lut = self.get8bitLUT()
449
+ self.preview_callback(lut)
450
+
451
+ def getCurveFunction(self):
452
+ return self.curve_function
453
+
454
+ def getCurvePoints(self):
455
+ if not hasattr(self, 'curve_points') or not self.curve_points:
456
+ return []
457
+ return [(pt.x(), pt.y()) for pt in self.curve_points]
458
+
459
+ def getLUT(self):
460
+ lut_size = 65536
461
+
462
+ curve_pts = self.getCurvePoints()
463
+ if len(curve_pts) == 0:
464
+ return np.linspace(0, 65535, lut_size, dtype=np.uint16)
465
+
466
+ curve_array = np.array(curve_pts, dtype=np.float64)
467
+ xs = curve_array[:, 0] # 0..360
468
+ ys = curve_array[:, 1] # 0..360
469
+
470
+ ys_for_lut = 360.0 - ys
471
+
472
+ input_positions = np.linspace(0, 360, lut_size, dtype=np.float64)
473
+ output_values = np.interp(input_positions, xs, ys_for_lut)
474
+
475
+ output_values = (output_values / 360.0) * 65535.0
476
+ return np.clip(output_values, 0, 65535).astype(np.uint16)
477
+
478
+
479
+ def mousePressEvent(self, event):
480
+ # ctrl+left click on the grid → pick inflection point
481
+ if (event.button() == Qt.MouseButton.LeftButton
482
+ and event.modifiers() & Qt.KeyboardModifier.ControlModifier):
483
+ scene_pt = self.mapToScene(event.pos())
484
+ # clamp into scene rect
485
+ x = max(0, min(360, scene_pt.x()))
486
+ y = max(0, min(360, scene_pt.y()))
487
+ # draw the yellow symmetry line
488
+ self.setSymmetryPoint(x, y)
489
+ # compute normalized (u, v)
490
+ u = x / 360.0
491
+ v = 1.0 - (y / 360.0)
492
+ # tell anyone who cares
493
+ if self.symmetry_callback:
494
+ self.symmetry_callback(u, v)
495
+ return # consume
496
+ super().mousePressEvent(event)
497
+
498
+ def mouseDoubleClickEvent(self, event):
499
+ """
500
+ Handle double-click events to add a new control point.
501
+ """
502
+ scene_pos = self.mapToScene(event.pos())
503
+
504
+ self.addControlPoint(scene_pos.x(), scene_pos.y())
505
+ super().mouseDoubleClickEvent(event)
506
+
507
+ def keyPressEvent(self, event):
508
+ """Remove selected points on Delete key press."""
509
+ if event.key() == Qt.Key.Key_Delete:
510
+ for point in self.control_points[:]:
511
+ if point.isSelected():
512
+ self.scene.removeItem(point)
513
+ self.control_points.remove(point)
514
+ self.updateCurve()
515
+ super().keyPressEvent(event)
516
+
517
+ def clearValueLines(self):
518
+ """Hide any temporary value indicator lines."""
519
+ for attr in ("r_line", "g_line", "b_line", "gray_line"):
520
+ ln = getattr(self, attr, None)
521
+ if ln is not None:
522
+ ln.setVisible(False)
523
+
524
+ def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
525
+ """(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
526
+ out = []
527
+ lastx = -1e9
528
+ for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
529
+ x = float(np.clip(x, 0.0, 360.0))
530
+ y = float(np.clip(y, 0.0, 360.0))
531
+ # strictly increasing X
532
+ if x <= lastx:
533
+ x = lastx + 1e-3
534
+ lastx = x
535
+ out.append((x / 360.0, 1.0 - (y / 360.0)))
536
+ # ensure endpoints
537
+ if not any(abs(px - 0.0) < 1e-6 for px, _ in out): out.insert(0, (0.0, 0.0))
538
+ if not any(abs(px - 1.0) < 1e-6 for px, _ in out): out.append((1.0, 1.0))
539
+ # clamp
540
+ return [(float(np.clip(x,0,1)), float(np.clip(y,0,1))) for (x,y) in out]
541
+
542
+ def _collect_points_norm_from_editor(self) -> list[tuple[float,float]]:
543
+ """Take endpoints+handles from editor => normalized points."""
544
+ pts_scene = []
545
+ for p in (self.editor.end_points + self.editor.control_points):
546
+ pos = p.scenePos()
547
+ pts_scene.append((float(pos.x()), float(pos.y())))
548
+ return self._scene_to_norm_points(pts_scene)
549
+
550
+
551
+ def redistributeHandlesByPivot(self, u: float):
552
+ """
553
+ Re-space current control handles around a pivot u∈[0..1].
554
+ Half the handles go in [0, u], the other half in [u, 1].
555
+ Y is sampled from the current curve (fallback: identity).
556
+ """
557
+ u = float(max(0.0, min(1.0, u)))
558
+ N = len(self.control_points)
559
+ if N == 0:
560
+ return
561
+
562
+ nL = N // 2
563
+ nR = N - nL
564
+ xL = np.linspace(0.0, u * 360.0, nL + 2, dtype=np.float32)[1:-1] # exclude endpoints
565
+ xR = np.linspace(u * 360.0, 360.0, nR + 2, dtype=np.float32)[1:-1]
566
+ xs = np.concatenate([xL, xR]) if (nL and nR) else (xR if nL == 0 else xL)
567
+
568
+ fn = getattr(self, "curve_function", None)
569
+ if callable(fn):
570
+ try:
571
+ ys = np.clip(fn(xs), 0.0, 360.0)
572
+ except Exception:
573
+ ys = 360.0 - xs # identity fallback
574
+ else:
575
+ ys = 360.0 - xs # identity fallback
576
+
577
+ pairs = sorted(zip(xs, ys), key=lambda t: t[0])
578
+ cps_sorted = sorted(self.control_points, key=lambda p: p.scenePos().x())
579
+ for p, (x, y) in zip(cps_sorted, pairs):
580
+ p.setPos(float(x), float(y))
581
+
582
+ self.updateCurve()
583
+
584
+
585
+ def updateValueLines(self, r, g, b, grayscale=False):
586
+ """
587
+ Update vertical lines on the curve scene.
588
+ For color images (grayscale=False), three lines (red, green, blue) are drawn.
589
+ For grayscale images (grayscale=True), a single gray line is drawn.
590
+
591
+ Values are assumed to be in the range [0, 1] and mapped to 0–360.
592
+ """
593
+ if grayscale:
594
+ # Map the 0–1 grayscale value to the scene's X coordinate (0–360)
595
+ x = r * 360.0
596
+ if not hasattr(self, "gray_line") or self.gray_line is None:
597
+ self.gray_line = self.scene.addLine(x, 0, x, 360, QPen(Qt.GlobalColor.gray))
598
+ else:
599
+ self.gray_line.setLine(x, 0, x, 360)
600
+
601
+ # 🔑 Make sure it’s visible again after Leave/clearValueLines()
602
+ self.gray_line.setVisible(True)
603
+
604
+ # Hide any color lines if present
605
+ for attr in ("r_line", "g_line", "b_line"):
606
+ if hasattr(self, attr) and getattr(self, attr) is not None:
607
+ getattr(self, attr).setVisible(False)
608
+ else:
609
+ # Hide grayscale line if present
610
+ if hasattr(self, "gray_line") and self.gray_line is not None:
611
+ self.gray_line.setVisible(False)
612
+
613
+ # Map each 0–1 value to X coordinate on scene (0–360)
614
+ r_x = r * 360.0
615
+ g_x = g * 360.0
616
+ b_x = b * 360.0
617
+
618
+ # Create or update the red line
619
+ if not hasattr(self, "r_line") or self.r_line is None:
620
+ self.r_line = self.scene.addLine(r_x, 0, r_x, 360, QPen(Qt.GlobalColor.red))
621
+ else:
622
+ self.r_line.setLine(r_x, 0, r_x, 360)
623
+ self.r_line.setVisible(True)
624
+
625
+ # Create or update the green line
626
+ if not hasattr(self, "g_line") or self.g_line is None:
627
+ self.g_line = self.scene.addLine(g_x, 0, g_x, 360, QPen(Qt.GlobalColor.green))
628
+ else:
629
+ self.g_line.setLine(g_x, 0, g_x, 360)
630
+ self.g_line.setVisible(True)
631
+
632
+ # Create or update the blue line
633
+ if not hasattr(self, "b_line") or self.b_line is None:
634
+ self.b_line = self.scene.addLine(b_x, 0, b_x, 360, QPen(Qt.GlobalColor.blue))
635
+ else:
636
+ self.b_line.setLine(b_x, 0, b_x, 360)
637
+ self.b_line.setVisible(True)
638
+
639
+ def current_black_white_thresholds(self) -> tuple[float|None, float|None]:
640
+ """
641
+ Return (black_t, white_t) in [0..1], derived from endpoints:
642
+ - black_t is the X position of the endpoint that sits on the bottom edge (y≈360)
643
+ - white_t is the X position of the endpoint that sits on the top edge (y≈0)
644
+ If an endpoint is on the left/right edges instead, we return None for that side
645
+ (i.e., no clipping for that side).
646
+ """
647
+ bx = None
648
+ wx = None
649
+ eps = 1.0
650
+ for p in self.end_points:
651
+ pos = p.scenePos()
652
+ x, y = float(pos.x()), float(pos.y())
653
+ if abs(y - 360.0) <= eps:
654
+ bx = max(0.0, min(1.0, x / 360.0))
655
+ if abs(y - 0.0) <= eps:
656
+ wx = max(0.0, min(1.0, x / 360.0))
657
+ return bx, wx
658
+
659
+ # --- Overlay management (other channels) -----------------
660
+ def setOverlayCurves(self, overlays: dict[str, list[tuple[float,float]]], active_key: str):
661
+ # clear old
662
+ if hasattr(self, "_overlay_items") and self._overlay_items:
663
+ for it in self._overlay_items:
664
+ try: self.scene.removeItem(it)
665
+ except Exception as e:
666
+ import logging
667
+ logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
668
+ self._overlay_items = []
669
+
670
+ colors = {
671
+ "K":"#FFFFFF", "R":"#FF4A4A", "G":"#5CC45C", "B":"#4AA0FF",
672
+ "L*":"#FFFFFF", "a*":"#FF8AB2", "b*":"#A6C8FF", "Chroma":"#FFD866", "Saturation":"#66FFD8"
673
+ }
674
+ faint = 120
675
+
676
+ for key, pts in overlays.items():
677
+ if key == active_key or not pts or len(pts) < 2:
678
+ continue
679
+
680
+ xs = np.array([p[0] for p in pts], dtype=np.float64)
681
+ ys = np.array([p[1] for p in pts], dtype=np.float64)
682
+
683
+ # strict increase on X
684
+ if np.any(np.diff(xs) <= 0):
685
+ xs = xs + np.linspace(0, 1e-3, len(xs), dtype=np.float64)
686
+
687
+ # build smooth samples across the whole domain
688
+ sample_x = np.linspace(0.0, 360.0, 361, dtype=np.float64)
689
+ try:
690
+ from scipy.interpolate import PchipInterpolator
691
+ f = PchipInterpolator(xs, ys, extrapolate=True)
692
+ sample_y = f(sample_x)
693
+ except Exception:
694
+ # straight fallback
695
+ sample_y = np.interp(sample_x, xs, ys)
696
+
697
+ pen = QPen(QColor(colors.get(key, "#BBBBBB"))); pen.setWidth(2)
698
+ c = pen.color(); c.setAlpha(faint); pen.setColor(c)
699
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
700
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
701
+
702
+ path = QPainterPath(QPointF(float(sample_x[0]), float(sample_y[0])))
703
+ for x, y in zip(sample_x[1:], sample_y[1:]):
704
+ path.lineTo(QPointF(float(x), float(y)))
705
+
706
+ it = self.scene.addPath(path, pen)
707
+ it.setZValue(-5)
708
+ self._overlay_items.append(it)
709
+
710
+
711
+
712
+
713
+ class CommaToDotLineEdit(QLineEdit):
714
+ def keyPressEvent(self, event: QKeyEvent):
715
+ print("C2D got:", event.key(), repr(event.text()), event.modifiers())
716
+ # if they hit comma (and it's not a Ctrl+Comma shortcut), turn it into a dot
717
+ if event.text() == "," and not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
718
+ # synthesize a “.” keypress instead
719
+ event = QKeyEvent(
720
+ QEvent.Type.KeyPress,
721
+ Qt.Key.Key_Period,
722
+ event.modifiers(),
723
+ "."
724
+ )
725
+ super().keyPressEvent(event)
726
+
727
+
728
+ # ---------- small utilities ----------
729
+ # _float_to_qimage_rgb8 imported from setiastro.saspro.widgets.image_utils
730
+
731
+ def _downsample_for_preview(img01: np.ndarray, max_w: int = 1200) -> np.ndarray:
732
+ h, w = img01.shape[:2]
733
+ if w <= max_w:
734
+ return img01.copy()
735
+ s = max_w / float(w)
736
+ new_w, new_h = max_w, int(round(h * s))
737
+ # resize via nearest/area using uint8 route for speed
738
+ u8 = (np.clip(img01,0,1)*255).astype(np.uint8)
739
+ try:
740
+ import cv2
741
+ out = cv2.resize(u8, (new_w, new_h), interpolation=cv2.INTER_AREA)
742
+ except Exception:
743
+ # fallback: numpy stride trick (coarse)
744
+ y_idx = (np.linspace(0, h-1, new_h)).astype(np.int32)
745
+ x_idx = (np.linspace(0, w-1, new_w)).astype(np.int32)
746
+ out = u8[y_idx][:, x_idx]
747
+ return out.astype(np.float32)/255.0
748
+
749
+
750
+ # ---------- fallbacks ----------
751
+ def build_curve_lut(curve_func, size=65536):
752
+ """Map v∈[0..1] → y∈[0..1] using your curve defined on x∈[0..360]."""
753
+ x = np.linspace(0.0, 360.0, size, dtype=np.float32)
754
+ y = 360.0 - curve_func(x)
755
+ y = (y / 360.0).clip(0.0, 1.0).astype(np.float32)
756
+ return y # shape (65536,), float32 in [0..1]
757
+
758
+ def _apply_lut_float01_channel(ch: np.ndarray, lut01: np.ndarray) -> np.ndarray:
759
+ """Apply 16-bit LUT (float [0..1]) to a single channel float image [0..1]."""
760
+ idx = np.clip((ch * (len(lut01)-1)).astype(np.int32), 0, len(lut01)-1)
761
+ return lut01[idx]
762
+
763
+ def _apply_lut_rgb(img01: np.ndarray, lut01: np.ndarray) -> np.ndarray:
764
+ """Optimized: use Numba LUT for RGB images, falls back to NumPy otherwise."""
765
+ try:
766
+ # Use Numba-accelerated LUT application (parallel, cache-optimized)
767
+ return _nb_apply_lut_color(img01.astype(np.float32, copy=False), lut01.astype(np.float32, copy=False))
768
+ except Exception:
769
+ # Fallback: vectorized NumPy (still faster than loop)
770
+ idx = np.clip((img01 * (len(lut01)-1)).astype(np.int32), 0, len(lut01)-1)
771
+ return lut01[idx]
772
+
773
+ def _np_apply_lut_channel(ch: np.ndarray, lut01: np.ndarray) -> np.ndarray:
774
+ idx = np.clip((ch * (len(lut01)-1)).astype(np.int32), 0, len(lut01)-1)
775
+ return lut01[idx]
776
+
777
+ def _np_apply_lut_rgb(img01: np.ndarray, lut01: np.ndarray) -> np.ndarray:
778
+ """Optimized: vectorized LUT on all channels at once instead of per-channel loop."""
779
+ # Vectorized: apply LUT to all channels simultaneously
780
+ idx = np.clip((img01 * (len(lut01)-1)).astype(np.int32), 0, len(lut01)-1)
781
+ return lut01[idx]
782
+
783
+ # ---- color-space fallbacks (vectorized NumPy) ----
784
+ # sRGB <-> XYZ (D65)
785
+ _M_rgb2xyz = np.array([[0.4124564, 0.3575761, 0.1804375],
786
+ [0.2126729, 0.7151522, 0.0721750],
787
+ [0.0193339, 0.1191920, 0.9503041]], dtype=np.float32)
788
+ _M_xyz2rgb = np.array([[ 3.2404542, -1.5371385, -0.4985314],
789
+ [-0.9692660, 1.8760108, 0.0415560],
790
+ [ 0.0556434, -0.2040259, 1.0572252]], dtype=np.float32)
791
+ _Xn, _Yn, _Zn = 0.95047, 1.00000, 1.08883
792
+ _delta = 6.0/29.0
793
+ _delta3 = _delta**3
794
+ _kappa = 24389.0/27.0
795
+ _eps = 216.0/24389.0
796
+
797
+ def _np_rgb_to_xyz(rgb01: np.ndarray) -> np.ndarray:
798
+ shp = rgb01.shape
799
+ flat = rgb01.reshape(-1, 3)
800
+ xyz = flat @ _M_rgb2xyz.T
801
+ return xyz.reshape(shp)
802
+
803
+ def _np_xyz_to_rgb(xyz: np.ndarray) -> np.ndarray:
804
+ shp = xyz.shape
805
+ flat = xyz.reshape(-1, 3)
806
+ rgb = flat @ _M_xyz2rgb.T
807
+ rgb = np.clip(rgb, 0.0, 1.0)
808
+ return rgb.reshape(shp)
809
+
810
+ def _f_lab_np(t):
811
+ # f(t) for CIE Lab
812
+ return np.where(t > _delta3, np.cbrt(t), (t / (3*_delta*_delta)) + (4.0/29.0))
813
+
814
+ def _f_lab_inv_np(ft):
815
+ # inverse of f()
816
+ return np.where(ft > _delta, ft**3, 3*_delta*_delta*(ft - 4.0/29.0))
817
+
818
+ def _np_xyz_to_lab(xyz: np.ndarray) -> np.ndarray:
819
+ X = xyz[...,0] / _Xn
820
+ Y = xyz[...,1] / _Yn
821
+ Z = xyz[...,2] / _Zn
822
+ fx, fy, fz = _f_lab_np(X), _f_lab_np(Y), _f_lab_np(Z)
823
+ L = 116*fy - 16
824
+ a = 500*(fx - fy)
825
+ b = 200*(fy - fz)
826
+ return np.stack([L,a,b], axis=-1).astype(np.float32)
827
+
828
+ def _np_lab_to_xyz(lab: np.ndarray) -> np.ndarray:
829
+ L = lab[...,0]
830
+ a = lab[...,1]
831
+ b = lab[...,2]
832
+ fy = (L + 16)/116.0
833
+ fx = fy + a/500.0
834
+ fz = fy - b/200.0
835
+ X = _Xn * _f_lab_inv_np(fx)
836
+ Y = _Yn * _f_lab_inv_np(fy)
837
+ Z = _Zn * _f_lab_inv_np(fz)
838
+ return np.stack([X,Y,Z], axis=-1).astype(np.float32)
839
+
840
+ def _np_rgb_to_hsv(rgb01: np.ndarray) -> np.ndarray:
841
+ r,g,b = rgb01[...,0], rgb01[...,1], rgb01[...,2]
842
+ cmax = np.maximum.reduce([r,g,b])
843
+ cmin = np.minimum.reduce([r,g,b])
844
+ delta = cmax - cmin
845
+ H = np.zeros_like(cmax, dtype=np.float32)
846
+
847
+ mask = delta != 0
848
+ # where cmax == r
849
+ mr = mask & (cmax == r)
850
+ mg = mask & (cmax == g)
851
+ mb = mask & (cmax == b)
852
+ H[mr] = ( (g[mr]-b[mr]) / delta[mr] ) % 6.0
853
+ H[mg] = ((b[mg]-r[mg]) / delta[mg]) + 2.0
854
+ H[mb] = ((r[mb]-g[mb]) / delta[mb]) + 4.0
855
+ H = (H * 60.0).astype(np.float32)
856
+
857
+ S = np.zeros_like(cmax, dtype=np.float32)
858
+ nz = cmax != 0
859
+ S[nz] = (delta[nz] / cmax[nz]).astype(np.float32)
860
+ V = cmax.astype(np.float32)
861
+ return np.stack([H,S,V], axis=-1)
862
+
863
+ def _np_hsv_to_rgb(hsv: np.ndarray) -> np.ndarray:
864
+ H, S, V = hsv[...,0], hsv[...,1], hsv[...,2]
865
+ C = V * S
866
+ hh = (H / 60.0) % 6.0
867
+ X = C * (1 - np.abs(hh % 2 - 1))
868
+ m = V - C
869
+ zeros = np.zeros_like(H, dtype=np.float32)
870
+ r = np.where((0<=hh)&(hh<1), C, np.where((1<=hh)&(hh<2), X, np.where((2<=hh)&(hh<3), zeros, np.where((3<=hh)&(hh<4), zeros, np.where((4<=hh)&(hh<5), X, C)))))
871
+ g = np.where((0<=hh)&(hh<1), X, np.where((1<=hh)&(hh<2), C, np.where((2<=hh)&(hh<3), C, np.where((3<=hh)&(hh<4), X, np.where((4<=hh)&(hh<5), zeros, zeros)))))
872
+ b = np.where((0<=hh)&(hh<1), zeros, np.where((1<=hh)&(hh<2), zeros, np.where((2<=hh)&(hh<3), X, np.where((3<=hh)&(hh<4), C, np.where((4<=hh)&(hh<5), C, X)))))
873
+ rgb = np.stack([r+m, g+m, b+m], axis=-1)
874
+ return np.clip(rgb, 0.0, 1.0).astype(np.float32)
875
+
876
+
877
+ # ---------- worker (full-res) ----------
878
+
879
+ # ---------- worker (full-res) ----------
880
+
881
+ class _CurvesWorker(QThread):
882
+ done = pyqtSignal(object)
883
+
884
+ def __init__(self, image01, luts, invoker=None):
885
+ """
886
+ Backward-compatible worker.
887
+
888
+ Accepted call styles:
889
+
890
+ 1) NEW (multi-curve) style ← what CurvesDialogPro now uses
891
+ _CurvesWorker(img01, {"K": lutK, "R": lutR, ...}, invoker=self)
892
+
893
+ 2) OLD (single-curve) style ← what GHS was doing
894
+ _CurvesWorker(img01, "K (Brightness)", lut01)
895
+ _CurvesWorker(img01, "R", lut01)
896
+ _CurvesWorker(img01, "G", lut01)
897
+ _CurvesWorker(img01, "B", lut01)
898
+
899
+ 3) Very old / emergency:
900
+ _CurvesWorker(img01, lut01) → assumes K
901
+ """
902
+ super().__init__()
903
+
904
+ # always keep the image contiguous float32
905
+ self.image01 = np.ascontiguousarray(image01.astype(np.float32, copy=False))
906
+
907
+ # flags / placeholders
908
+ self._legacy_single = False
909
+ self._invoker = None # only needed for the new multi-curve path
910
+
911
+ # ─────────────────────────────────────────
912
+ # CASE A: GHS / old-style call
913
+ # ─────────────────────────────────────────
914
+ # GHS called: _CurvesWorker(full_img, "K (Brightness)", lut01)
915
+ if isinstance(luts, str):
916
+ mode_str = luts
917
+ lut01 = np.ascontiguousarray(invoker.astype(np.float32, copy=False))
918
+ # map UI text to internal key
919
+ mode_map = {
920
+ "K (Brightness)": "K",
921
+ "K": "K",
922
+ "R": "R",
923
+ "G": "G",
924
+ "B": "B",
925
+ }
926
+ key = mode_map.get(mode_str, "K")
927
+ self.luts = {key: lut01}
928
+ self._legacy_single = True
929
+ return
930
+
931
+ # ─────────────────────────────────────────
932
+ # CASE B: weird 2-arg legacy: (img, lut01)
933
+ # ─────────────────────────────────────────
934
+ # someone might have done _CurvesWorker(img, lut01)
935
+ if isinstance(luts, np.ndarray):
936
+ lut01 = np.ascontiguousarray(luts.astype(np.float32, copy=False))
937
+ self.luts = {"K": lut01}
938
+ self._legacy_single = True
939
+ return
940
+
941
+ # ─────────────────────────────────────────
942
+ # CASE C: new style (what CurvesDialogPro uses now)
943
+ # ─────────────────────────────────────────
944
+ # here luts should be a dict: {"K": lutK, "R": lutR, ...}
945
+ self.luts = {
946
+ k: np.ascontiguousarray(v.astype(np.float32, copy=False))
947
+ for k, v in luts.items()
948
+ }
949
+ # in the new path we expect an invoker that has _apply_all_curves_once()
950
+ self._invoker = invoker
951
+ self._legacy_single = False
952
+
953
+ def run(self):
954
+ # ─────────────────────────────────────────
955
+ # LEGACY path: single channel / single LUT
956
+ # ─────────────────────────────────────────
957
+ if self._legacy_single:
958
+ out = self.image01
959
+ # mono / 2D
960
+ if out.ndim == 2 or (out.ndim == 3 and out.shape[2] == 1):
961
+ lut = (
962
+ self.luts.get("K")
963
+ or self.luts.get("R")
964
+ or self.luts.get("G")
965
+ or self.luts.get("B")
966
+ )
967
+ if lut is not None:
968
+ idx = np.clip((out * (len(lut) - 1)).astype(np.int32), 0, len(lut) - 1)
969
+ out = lut[idx]
970
+ self.done.emit(out.astype(np.float32, copy=False))
971
+ return
972
+
973
+ # RGB
974
+ out = out.copy()
975
+ # prefer per-channel, fall back to K
976
+ lutK = self.luts.get("K")
977
+ lutR = self.luts.get("R", lutK)
978
+ lutG = self.luts.get("G", lutK)
979
+ lutB = self.luts.get("B", lutK)
980
+
981
+ if lutR is not None:
982
+ idx = np.clip((out[..., 0] * (len(lutR) - 1)).astype(np.int32), 0, len(lutR) - 1)
983
+ out[..., 0] = lutR[idx]
984
+ if lutG is not None:
985
+ idx = np.clip((out[..., 1] * (len(lutG) - 1)).astype(np.int32), 0, len(lutG) - 1)
986
+ out[..., 1] = lutG[idx]
987
+ if lutB is not None:
988
+ idx = np.clip((out[..., 2] * (len(lutB) - 1)).astype(np.int32), 0, len(lutB) - 1)
989
+ out[..., 2] = lutB[idx]
990
+
991
+ self.done.emit(out.astype(np.float32, copy=False))
992
+ return
993
+
994
+ # ─────────────────────────────────────────
995
+ # NEW path: multi-curve, use dialog’s helper
996
+ # ─────────────────────────────────────────
997
+ if self._invoker is None:
998
+ # extreme safety fallback
999
+ self.done.emit(self.image01)
1000
+ return
1001
+
1002
+ out = self._invoker._apply_all_curves_once(self.image01, self.luts)
1003
+ self.done.emit(out)
1004
+
1005
+
1006
+ # ---------- dialog ----------
1007
+
1008
+ class CurvesDialogPro(QDialog):
1009
+ """
1010
+ Minimal, shippable Curves Editor for SASpro:
1011
+ - Uses your CurveEditor for handles/spline (PCHIP).
1012
+ - Live preview on a downsampled copy.
1013
+ - Apply writes to the ImageDocument history.
1014
+ - Multiple dialogs allowed (no global singletons).
1015
+ """
1016
+ def __init__(self, parent, document):
1017
+ super().__init__(parent)
1018
+ self.setWindowTitle(self.tr("Curves Editor"))
1019
+ self.setWindowFlag(Qt.WindowType.Window, True)
1020
+ self.setWindowModality(Qt.WindowModality.NonModal)
1021
+ self.setModal(False)
1022
+ self._main = parent
1023
+ self.doc = document
1024
+
1025
+ self._follow_conn = False
1026
+ if hasattr(self._main, "currentDocumentChanged"):
1027
+ try:
1028
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
1029
+ self._follow_conn = True
1030
+ except Exception:
1031
+ self._follow_conn = False
1032
+ try:
1033
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
1034
+ except Exception:
1035
+ pass # older PyQt6 versions
1036
+ self.finished.connect(self._cleanup_connections)
1037
+ self._preview_img = None # downsampled float01
1038
+ self._full_img = None # full-res float01
1039
+ self._pix = None
1040
+ self._zoom = 0.25
1041
+ self._panning = False
1042
+ self._pan_start = QPointF()
1043
+ self._did_initial_fit = False
1044
+ self._apply_when_ready = False
1045
+ self._preview_orig = None # downsampled original
1046
+ self._preview_proc = None # downsampled processed (latest)
1047
+ self._show_proc = False # A/B: False=show original, True=show processed
1048
+ self._cdf = None
1049
+ self._cdf_bins = 1024
1050
+ self._cdf_total = 0
1051
+
1052
+ self._clip_scale = 1.0 # preview→full multiplier
1053
+ self._cdf_total_full = 0 # total pixels in full image (H*W)
1054
+ self._cdf_total_preview = 0 # total pixels in preview (H*W)
1055
+
1056
+ # --- UI ---
1057
+ main = QVBoxLayout(self) # ⬅️ root is now vertical
1058
+ top = QHBoxLayout() # ⬅️ holds the two columns
1059
+
1060
+ # Left column: CurveEditor + mode + buttons
1061
+ left = QVBoxLayout()
1062
+ self.editor = CurveEditor(self)
1063
+ left.addWidget(self.editor)
1064
+
1065
+ # mode radio
1066
+ self.mode_group = QButtonGroup(self)
1067
+ self.mode_group.setExclusive(True)
1068
+
1069
+ row1 = QHBoxLayout()
1070
+ for m in ("K (Brightness)", "R", "G", "B"):
1071
+ rb = QRadioButton(m, self)
1072
+ if m == "K (Brightness)":
1073
+ rb.setChecked(True) # default selection
1074
+ self.mode_group.addButton(rb)
1075
+ row1.addWidget(rb)
1076
+
1077
+ row2 = QHBoxLayout()
1078
+ for m in ("L*", "a*", "b*", "Chroma", "Saturation"):
1079
+ rb = QRadioButton(m, self)
1080
+ self.mode_group.addButton(rb)
1081
+ row2.addWidget(rb)
1082
+
1083
+ left.addLayout(row1)
1084
+ left.addLayout(row2)
1085
+
1086
+ # Map UI label → internal key
1087
+ self._mode_key_map = {
1088
+ "K (Brightness)":"K", "R":"R", "G":"G", "B":"B",
1089
+ "L*":"L*", "a*":"a*", "b*":"b*", "Chroma":"Chroma", "Saturation":"Saturation"
1090
+ }
1091
+
1092
+ # each entry holds points in *normalized* space [(x,y) in 0..1 up, endpoints included]
1093
+ self._curves_store = { k: [(0.0,0.0),(1.0,1.0)] for k in self._mode_key_map.values() }
1094
+
1095
+ # remember current mode key
1096
+ self._current_mode_key = "K"
1097
+
1098
+ # when user changes the radio, stash current points and load new
1099
+ for b in self.mode_group.buttons():
1100
+ b.toggled.connect(self._on_mode_toggled)
1101
+
1102
+
1103
+ rowp = QHBoxLayout()
1104
+ self.btn_presets = QToolButton(self)
1105
+ self.btn_presets.setText(self.tr("Presets"))
1106
+ self.btn_presets.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
1107
+ rowp.addWidget(self.btn_presets)
1108
+
1109
+ self.btn_save_preset = QToolButton(self)
1110
+ self.btn_save_preset.setText(self.tr("Save as Preset..."))
1111
+ self.btn_save_preset.clicked.connect(self._save_current_as_preset)
1112
+ rowp.addWidget(self.btn_save_preset)
1113
+ left.addLayout(rowp)
1114
+
1115
+ # status
1116
+ self.lbl_status = QLabel("", self)
1117
+ self.lbl_status.setStyleSheet("color: gray;")
1118
+
1119
+
1120
+ # buttons
1121
+ rowb = QHBoxLayout()
1122
+ self.btn_preview = QToolButton(self)
1123
+ self.btn_preview.setText(self.tr("Toggle Preview"))
1124
+ self.btn_preview.setCheckable(True) # ⬅️ toggle
1125
+ self.btn_apply = QPushButton(self.tr("Apply to Document"))
1126
+ self.btn_reset = QToolButton(); self.btn_reset.setText(self.tr("Reset"))
1127
+ rowb.addWidget(self.btn_preview); rowb.addWidget(self.btn_apply); rowb.addWidget(self.btn_reset)
1128
+ left.addLayout(rowb)
1129
+ left.addStretch(1)
1130
+ top.addLayout(left, 0)
1131
+
1132
+ # Right column: preview w/ zoom/pan
1133
+ right = QVBoxLayout()
1134
+ zoombar = QHBoxLayout()
1135
+ zoombar.addStretch(1)
1136
+
1137
+ self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
1138
+ self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
1139
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
1140
+
1141
+ zoombar.addWidget(self.btn_zoom_out)
1142
+ zoombar.addWidget(self.btn_zoom_in)
1143
+ zoombar.addWidget(self.btn_zoom_fit)
1144
+
1145
+ right.addLayout(zoombar)
1146
+
1147
+ self.scroll = QScrollArea()
1148
+ self.scroll.setWidgetResizable(True)
1149
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
1150
+ self.scroll.viewport().installEventFilter(self)
1151
+ self.label = ImageLabel(self)
1152
+ self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
1153
+ self.label.mouseMoved.connect(self._on_preview_mouse_moved)
1154
+ self.label.installEventFilter(self)
1155
+ self.scroll.setWidget(self.label)
1156
+ right.addWidget(self.scroll, 1)
1157
+ top.addLayout(right, 1)
1158
+
1159
+ main.addLayout(top, 1)
1160
+
1161
+ # subtle separator line
1162
+
1163
+ sep = QFrame(self)
1164
+ sep.setFrameShape(QFrame.Shape.HLine)
1165
+ sep.setFrameShadow(QFrame.Shadow.Sunken)
1166
+ main.addWidget(sep)
1167
+
1168
+ # bottom status row
1169
+ status_row = QHBoxLayout()
1170
+ self.lbl_status = QLabel("", self) # ⬅️ re-create here at bottom
1171
+ self.lbl_status.setObjectName("curvesStatus")
1172
+ self.lbl_status.setWordWrap(True)
1173
+ self.lbl_status.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
1174
+ self.lbl_status.setStyleSheet("color: #bbb;") # or keep your theme color
1175
+
1176
+ # keep it from growing tall: ~2 lines max
1177
+ line_h = self.fontMetrics().height()
1178
+ self.lbl_status.setMaximumHeight(int(line_h * 2.2))
1179
+ self.lbl_status.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
1180
+
1181
+ status_row.addWidget(self.lbl_status, 1)
1182
+
1183
+ main.addLayout(status_row, 0)
1184
+
1185
+ # wire
1186
+ self.btn_preview.clicked.connect(self._run_preview)
1187
+ self.btn_preview.toggled.connect(self._toggle_preview) # ⬅️ new
1188
+ self.btn_apply.clicked.connect(self._apply)
1189
+ self.btn_reset.clicked.connect(self._reset_curve)
1190
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
1191
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
1192
+ self.btn_zoom_fit.clicked.connect(self._fit)
1193
+
1194
+ # When curve changes, do a quick preview (non-blocking: downsampled in-UI)
1195
+ # You can switch to threaded small preview if images are huge.
1196
+ self.editor.setPreviewCallback(self._on_editor_curve_changed)
1197
+
1198
+ # seed images
1199
+ self._load_from_doc()
1200
+ QTimer.singleShot(0, self._fit_after_load)
1201
+ self.editor.setSymmetryCallback(self._on_symmetry_pick)
1202
+ self.btn_preview.setChecked(True)
1203
+
1204
+ self.main_window = self._find_main_window()
1205
+ self.source_view = None
1206
+ try:
1207
+ # Common cases: parent is a subwindow or has a doc_view
1208
+ if hasattr(self.parent(), "view"):
1209
+ self.source_view = self.parent().view
1210
+ elif hasattr(self.parent(), "doc_view"):
1211
+ self.source_view = self.parent().doc_view
1212
+ except Exception:
1213
+ pass
1214
+
1215
+ if self.main_window is not None:
1216
+ print(f"[Replay] CurvesDialogPro bound to main_window={id(self.main_window)}, "
1217
+ f"source_view={getattr(self.source_view,'view_id',None)}")
1218
+
1219
+ self._rebuild_presets_menu()
1220
+
1221
+ def _on_editor_curve_changed(self, _lut8=None):
1222
+ """
1223
+ Called on every editor redraw/drag. Persist the currently edited curve
1224
+ into the store, refresh overlays, and do a realtime preview.
1225
+ """
1226
+ try:
1227
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
1228
+ except Exception:
1229
+ pass
1230
+ # show the true shapes of other channels too
1231
+ self._refresh_overlays()
1232
+ # now build from *all* current curves (including the just-edited one)
1233
+ self._quick_preview()
1234
+
1235
+
1236
+ def _active_mode_key(self) -> str:
1237
+ for b in self.mode_group.buttons():
1238
+ if b.isChecked():
1239
+ return self._mode_key_map.get(b.text(), "K")
1240
+ return "K"
1241
+
1242
+ def _editor_points_norm(self) -> list[tuple[float,float]]:
1243
+ # uses your existing _collect_points_norm_from_editor()
1244
+ return self._collect_points_norm_from_editor()
1245
+
1246
+ def _editor_set_from_norm(self, ptsN: list[tuple[float,float]]):
1247
+ # convert to scene and strip endpoints
1248
+ pts_scene = _points_norm_to_scene(ptsN)
1249
+ filt = [(x,y) for (x,y) in pts_scene if x > 1e-6 and x < 360-1e-6]
1250
+ self.editor.setControlHandles(filt)
1251
+ self.editor.updateCurve()
1252
+
1253
+ def _on_mode_toggled(self, checked: bool):
1254
+ if not checked:
1255
+ return
1256
+ # 1) save the curve we were editing
1257
+ prev = self._current_mode_key
1258
+ try:
1259
+ self._curves_store[prev] = self._editor_points_norm()
1260
+ except Exception:
1261
+ pass
1262
+
1263
+ # 2) load the newly selected curve
1264
+ key = self._active_mode_key()
1265
+ self._current_mode_key = key
1266
+ self._editor_set_from_norm(self._curves_store.get(key, [(0.0,0.0),(1.0,1.0)]))
1267
+
1268
+ # 3) draw overlays for reference
1269
+ self._refresh_overlays()
1270
+ # 4) refresh preview immediately
1271
+ self._quick_preview()
1272
+
1273
+ def _refresh_overlays(self):
1274
+ # Build overlay polylines in scene coords for all modes except the active one
1275
+ overlays = {}
1276
+ for key, ptsN in self._curves_store.items():
1277
+ if not ptsN:
1278
+ continue
1279
+ pts_scene = _points_norm_to_scene(ptsN)
1280
+ # keep full polyline (including endpoints) to show exact shape
1281
+ overlays[key] = pts_scene
1282
+ self.editor.setOverlayCurves(overlays, self._current_mode_key)
1283
+
1284
+ def _lut01_from_points_norm(self, ptsN: list[tuple[float,float]], size: int = 65536) -> np.ndarray:
1285
+ # ptsN are (x,y) in 0..1 (up). Convert to scene space and build a smooth monotone interpolator.
1286
+ pts_scene = _points_norm_to_scene(ptsN) # [(x:[0..360], y:[0..360 down])]
1287
+ if len(pts_scene) < 2:
1288
+ return np.linspace(0.0, 1.0, size, dtype=np.float32)
1289
+
1290
+ xs = np.array([p[0] for p in pts_scene], dtype=np.float64)
1291
+ ys = np.array([p[1] for p in pts_scene], dtype=np.float64)
1292
+
1293
+ # Ensure strictly increasing X (protect against accidental ties)
1294
+ m = np.diff(xs) <= 0
1295
+ if np.any(m):
1296
+ xs = xs + np.linspace(0, 1e-3, len(xs), dtype=np.float64)
1297
+
1298
+ ys = 360.0 - ys # flip to “up”
1299
+
1300
+ inp = np.linspace(0.0, 360.0, size, dtype=np.float64)
1301
+ try:
1302
+ from scipy.interpolate import PchipInterpolator
1303
+ f = PchipInterpolator(xs, ys, extrapolate=True)
1304
+ out = f(inp)
1305
+ except Exception:
1306
+ # Fallback to linear if SciPy missing or bad control set
1307
+ out = np.interp(inp, xs, ys)
1308
+
1309
+ out = np.clip(out / 360.0, 0.0, 1.0).astype(np.float32)
1310
+ return out
1311
+
1312
+
1313
+ def _build_all_active_luts(self) -> dict[str, np.ndarray]:
1314
+ """
1315
+ Build LUTs for every curve.
1316
+
1317
+ IMPORTANT:
1318
+ - The *active* curve (the one shown in the editor right now) must be built
1319
+ from the editor's actual spline so we DO NOT re-insert (0,0)/(1,1).
1320
+ - All *other* curves (stored in _curves_store) can still be built from
1321
+ their normalized points (those are allowed to have endpoints).
1322
+ """
1323
+ luts: dict[str, np.ndarray] = {}
1324
+ active_key = self._current_mode_key
1325
+
1326
+ # 1) ACTIVE curve → from editor spline (no normalization, no auto endpoints)
1327
+ fn = getattr(self.editor, "getCurveFunction", None)
1328
+ if callable(fn):
1329
+ f = fn()
1330
+ if f is not None:
1331
+ luts[active_key] = build_curve_lut(f, size=65536)
1332
+
1333
+ # 2) OTHER curves → from stored normalized points (old behavior)
1334
+ for key, pts in self._curves_store.items():
1335
+ if key == active_key:
1336
+ continue # already done above
1337
+ # skip exact linear
1338
+ if isinstance(pts, (list, tuple)) and len(pts) == 2 and pts[0] == (0.0, 0.0) and pts[1] == (1.0, 1.0):
1339
+ continue
1340
+ luts[key] = self._lut01_from_points_norm(pts, size=65536)
1341
+
1342
+ return luts
1343
+
1344
+ def _remember_as_last_action(self):
1345
+ """
1346
+ Capture the current curve as a replayable headless command.
1347
+
1348
+ We store it exactly like other tools:
1349
+
1350
+ command_id = "curves"
1351
+ preset = {mode, shape, amount, points_scene, _ops?}
1352
+
1353
+ where `_ops` (if present) is a full, tool-agnostic op dict
1354
+ from export_preview_ops(), used for replay-on-base.
1355
+ """
1356
+ mw = self._find_main_window()
1357
+ if mw is None:
1358
+ print("[Replay] Curves: no main_window; not storing last action.")
1359
+ return
1360
+
1361
+ # 1) mode label
1362
+ btn = self.mode_group.checkedButton() if hasattr(self, "mode_group") else None
1363
+ mode_label = btn.text() if btn is not None else "K (Brightness)"
1364
+ mode_label = _norm_mode(mode_label)
1365
+
1366
+ # 2) collect control handles → scene points
1367
+ if hasattr(self.editor, "getControlHandles"):
1368
+ handles = self.editor.getControlHandles()
1369
+ elif hasattr(self.editor, "controlHandles"):
1370
+ handles = self.editor.controlHandles()
1371
+ else:
1372
+ handles = []
1373
+
1374
+ pts_scene: list[tuple[float, float]] = []
1375
+ for h in handles:
1376
+ try:
1377
+ x = float(h.x()); y = float(h.y())
1378
+ except Exception:
1379
+ try:
1380
+ x = float(h[0]); y = float(h[1])
1381
+ except Exception:
1382
+ continue
1383
+ pts_scene.append((x, y))
1384
+
1385
+ if not pts_scene:
1386
+ pts_scene = [(0.0, 360.0), (360.0, 0.0)]
1387
+
1388
+ pts_scene = _sanitize_scene_points(pts_scene)
1389
+
1390
+ core_preset = {
1391
+ "mode": mode_label,
1392
+ "shape": "custom",
1393
+ "amount": 1.0,
1394
+ "points_scene": pts_scene,
1395
+ }
1396
+
1397
+ # 3) Attach a full op dict for exact replay on base, if possible
1398
+ op = None
1399
+ try:
1400
+ op = self.export_preview_ops()
1401
+ except Exception:
1402
+ op = None
1403
+
1404
+ if op:
1405
+ core_preset["_ops"] = op
1406
+
1407
+ try:
1408
+ # This is the same pattern used by Statistical Stretch etc.
1409
+ mw._remember_last_headless_command("curves", core_preset, description="Curves")
1410
+
1411
+ # Enable/update the replay button for the originating view
1412
+ source_view = getattr(self, "source_view", None)
1413
+ if hasattr(mw, "_update_replay_button"):
1414
+ mw._update_replay_button(source_view)
1415
+
1416
+ print(
1417
+ f"[Replay] Curves: stored last action; "
1418
+ f"has_ops={bool(op)} mode={mode_label}"
1419
+ )
1420
+ except Exception as e:
1421
+ print("Curves: failed to remember last action:", e)
1422
+
1423
+
1424
+
1425
+
1426
+ def _apply_all_curves_once(self, img01: np.ndarray, luts: dict[str, np.ndarray]) -> np.ndarray:
1427
+ # 1) RGB domain — K then per-channel compose
1428
+ out = img01
1429
+ if out.ndim == 2: # mono → treat as K only
1430
+ lutK = luts.get("K")
1431
+ if lutK is not None:
1432
+ out = _np_apply_lut_channel(out, lutK)
1433
+ # nothing else applies meaningfully to mono
1434
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
1435
+
1436
+ # RGB image
1437
+ # compose helper: lut2(lut1(x))
1438
+ def _compose_lut(a: np.ndarray | None, b: np.ndarray | None):
1439
+ if a is None: return b
1440
+ if b is None: return a
1441
+ # fast index compose on [0..1] sampled arrays
1442
+ N = len(a)
1443
+ idx = np.clip((a * (N - 1)).astype(np.int32), 0, N - 1)
1444
+ return b[idx]
1445
+
1446
+ lutK = luts.get("K")
1447
+ lutR = _compose_lut(lutK, luts.get("R"))
1448
+ lutG = _compose_lut(lutK, luts.get("G"))
1449
+ lutB = _compose_lut(lutK, luts.get("B"))
1450
+
1451
+ # If no per-channel, still apply K uniformly
1452
+ if lutR is None and lutG is None and lutB is None and lutK is not None:
1453
+ out = _np_apply_lut_rgb(out, lutK)
1454
+ else:
1455
+ out = out.copy()
1456
+ if lutR is not None: out[...,0] = _np_apply_lut_channel(out[...,0], lutR)
1457
+ elif lutK is not None: out[...,0] = _np_apply_lut_channel(out[...,0], lutK)
1458
+ if lutG is not None: out[...,1] = _np_apply_lut_channel(out[...,1], lutG)
1459
+ elif lutK is not None: out[...,1] = _np_apply_lut_channel(out[...,1], lutK)
1460
+ if lutB is not None: out[...,2] = _np_apply_lut_channel(out[...,2], lutB)
1461
+ elif lutK is not None: out[...,2] = _np_apply_lut_channel(out[...,2], lutK)
1462
+
1463
+ # 2) Lab family
1464
+ need_lab = any(k in luts for k in ("L*","a*","b*","Chroma"))
1465
+ if need_lab:
1466
+ xyz = _np_rgb_to_xyz(out); lab = _np_xyz_to_lab(xyz)
1467
+ if "L*" in luts:
1468
+ L = np.clip(lab[...,0]/100.0, 0.0, 1.0)
1469
+ L = _np_apply_lut_channel(L, luts["L*"]); lab[...,0] = L*100.0
1470
+ if "a*" in luts:
1471
+ a = lab[...,1]; an = np.clip((a+128.0)/255.0, 0.0, 1.0)
1472
+ an = _np_apply_lut_channel(an, luts["a*"]); lab[...,1] = an*255.0 - 128.0
1473
+ if "b*" in luts:
1474
+ b = lab[...,2]; bn = np.clip((b+128.0)/255.0, 0.0, 1.0)
1475
+ bn = _np_apply_lut_channel(bn, luts["b*"]); lab[...,2] = bn*255.0 - 128.0
1476
+ if "Chroma" in luts:
1477
+ a = lab[...,1]; b = lab[...,2]
1478
+ C = np.sqrt(a*a + b*b); Cn = np.clip(C/200.0, 0.0, 1.0)
1479
+ Cn = _np_apply_lut_channel(Cn, luts["Chroma"]); Cnew = Cn*200.0
1480
+ ratio = np.divide(Cnew, C, out=np.ones_like(Cnew), where=(C>0))
1481
+ lab[...,1] = a*ratio; lab[...,2] = b*ratio
1482
+ out = _np_xyz_to_rgb(_np_lab_to_xyz(lab))
1483
+
1484
+ # 3) Saturation (HSV)
1485
+ if "Saturation" in luts:
1486
+ hsv = _np_rgb_to_hsv(out)
1487
+ S = np.clip(hsv[...,1], 0.0, 1.0)
1488
+ hsv[...,1] = _np_apply_lut_channel(S, luts["Saturation"])
1489
+ out = _np_hsv_to_rgb(hsv)
1490
+
1491
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
1492
+
1493
+
1494
+ def _fit_after_load(self, tries: int = 0):
1495
+ """
1496
+ Run Fit-to-Preview once the dialog is visible, the pixmap is ready,
1497
+ and the viewport knows its final size. Retries a few ticks if needed.
1498
+ """
1499
+ if self._did_initial_fit:
1500
+ return
1501
+
1502
+ if not self.isVisible():
1503
+ QTimer.singleShot(0, lambda: self._fit_after_load(tries))
1504
+ return
1505
+
1506
+ # need a pixmap and a live viewport size
1507
+ pm = self.label.pixmap()
1508
+ vp = self.scroll.viewport() if hasattr(self, "scroll") else None
1509
+ have_pm = bool(pm and not pm.isNull())
1510
+ have_sizes = bool(vp and vp.width() > 0 and vp.height() > 0)
1511
+
1512
+ if not (self._pix and have_pm and have_sizes):
1513
+ if tries < 20: # ~ a handful of event-loop turns
1514
+ QTimer.singleShot(15, lambda: self._fit_after_load(tries + 1))
1515
+ return
1516
+
1517
+ # finally do the fit-once
1518
+ self._did_initial_fit = True
1519
+ self._fit()
1520
+
1521
+ def _capture_view(self):
1522
+ """Return (fx, fy, zoom) where f* are fractional center coords in label space."""
1523
+ try:
1524
+ vp = self.scroll.viewport()
1525
+ h = self.scroll.horizontalScrollBar()
1526
+ v = self.scroll.verticalScrollBar()
1527
+ lw = max(1, self.label.width())
1528
+ lh = max(1, self.label.height())
1529
+ cx = h.value() + vp.width() / 2.0
1530
+ cy = v.value() + vp.height() / 2.0
1531
+ fx = float(cx) / float(lw)
1532
+ fy = float(cy) / float(lh)
1533
+ return (fx, fy, float(self._zoom))
1534
+ except Exception:
1535
+ return (0.5, 0.5, float(self._zoom))
1536
+
1537
+ def _restore_view(self, fx: float, fy: float, zoom: float):
1538
+ """Restore zoom and recenter viewport to previous fractional center."""
1539
+ self._set_zoom(zoom) # calls _apply_zoom() internally
1540
+ vp = self.scroll.viewport()
1541
+ h = self.scroll.horizontalScrollBar()
1542
+ v = self.scroll.verticalScrollBar()
1543
+ cx = int(round(fx * max(1, self.label.width())))
1544
+ cy = int(round(fy * max(1, self.label.height())))
1545
+ hx = cx - vp.width() // 2
1546
+ vy = cy - vp.height() // 2
1547
+ # clamp
1548
+ h.setValue(max(h.minimum(), min(h.maximum(), hx)))
1549
+ v.setValue(max(v.minimum(), min(v.maximum(), vy)))
1550
+
1551
+
1552
+ def _build_preview_luma_cdf(self):
1553
+ """Compute a luminance CDF once from the preview image for fast clipping lookups.
1554
+ Also derives a preview→full scaling factor so we can report full-image pixel counts.
1555
+ """
1556
+ img = self._preview_img
1557
+ # defaults / safety
1558
+ bins = int(getattr(self, "_cdf_bins", 1024))
1559
+ self._cdf_bins = bins # remember for consistency
1560
+
1561
+ # reset outputs
1562
+ self._cdf = None
1563
+ self._cdf_total = 0
1564
+ self._cdf_total_preview = 0
1565
+ self._cdf_total_full = 0
1566
+ self._clip_scale = 1.0
1567
+
1568
+ if img is None:
1569
+ return
1570
+
1571
+ # luminance (float32 [0..1])
1572
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
1573
+ luma = img if img.ndim == 2 else img[..., 0]
1574
+ else:
1575
+ luma = (0.2126 * img[..., 0] + 0.7152 * img[..., 1] + 0.0722 * img[..., 2]).astype(np.float32)
1576
+ luma = np.clip(luma, 0.0, 1.0)
1577
+
1578
+ # preview CDF
1579
+ hist, _edges = np.histogram(luma, bins=bins, range=(0.0, 1.0))
1580
+ self._cdf = np.cumsum(hist).astype(np.int64)
1581
+ self._cdf_total_preview = int(luma.size)
1582
+ self._cdf_total = self._cdf_total_preview # backward-compat alias
1583
+
1584
+ # compute full-image pixel count
1585
+ full_pixels = 0
1586
+ if isinstance(getattr(self, "_full_img", None), np.ndarray) and self._full_img.ndim >= 2:
1587
+ Hf, Wf = self._full_img.shape[:2]
1588
+ full_pixels = int(Hf * Wf)
1589
+ if full_pixels <= 0:
1590
+ full_pixels = self._cdf_total_preview # fall back to preview size
1591
+
1592
+ self._cdf_total_full = full_pixels
1593
+ self._clip_scale = (full_pixels / float(self._cdf_total_preview)) if self._cdf_total_preview else 1.0
1594
+
1595
+ def _build_preview_rgb_cdfs(self):
1596
+ """Compute per-channel CDFs (R,G,B) from the preview image for clipping stats."""
1597
+ self._cdf_rgb = None
1598
+ img = self._preview_img
1599
+ if img is None or not (img.ndim == 3 and img.shape[2] >= 3):
1600
+ return
1601
+
1602
+ bins = int(getattr(self, "_cdf_bins", 1024))
1603
+ r = np.clip(img[..., 0].astype(np.float32), 0.0, 1.0)
1604
+ g = np.clip(img[..., 1].astype(np.float32), 0.0, 1.0)
1605
+ b = np.clip(img[..., 2].astype(np.float32), 0.0, 1.0)
1606
+
1607
+ hr, _ = np.histogram(r, bins=bins, range=(0.0, 1.0))
1608
+ hg, _ = np.histogram(g, bins=bins, range=(0.0, 1.0))
1609
+ hb, _ = np.histogram(b, bins=bins, range=(0.0, 1.0))
1610
+
1611
+ self._cdf_rgb = {
1612
+ "r": np.cumsum(hr).astype(np.int64),
1613
+ "g": np.cumsum(hg).astype(np.int64),
1614
+ "b": np.cumsum(hb).astype(np.int64),
1615
+ "total_preview": int(r.size) # same for each channel
1616
+ }
1617
+
1618
+
1619
+ def _on_symmetry_pick(self, u: float, _v: float):
1620
+ self.editor.redistributeHandlesByPivot(u)
1621
+ self._set_status(self.tr("Inflection @ K={0:.3f}").format(u))
1622
+ self._quick_preview()
1623
+
1624
+ def _fit_once(self):
1625
+ if not self._did_initial_fit:
1626
+ self._fit_after_load(0)
1627
+
1628
+ def showEvent(self, ev):
1629
+ super().showEvent(ev)
1630
+ # kick the fit after this show/layout pass
1631
+ QTimer.singleShot(0, self._fit_after_load)
1632
+
1633
+ def _on_preview_mouse_moved(self, x: float, y: float):
1634
+ if self._preview_img is None:
1635
+ return
1636
+
1637
+ mapped = self._map_label_xy_to_image_ij(x, y)
1638
+ if not mapped:
1639
+ # cursor is outside the actual pixmap area
1640
+ self.editor.clearValueLines()
1641
+ self._set_status("")
1642
+ return
1643
+
1644
+ # --- clamp to edges so the last pixel is valid ---
1645
+ img = self._preview_img
1646
+ H, W = img.shape[:2]
1647
+ try:
1648
+ ix, iy = mapped
1649
+ ix = max(0, min(W - 1, int(round(ix))))
1650
+ iy = max(0, min(H - 1, int(round(iy))))
1651
+ except Exception:
1652
+ self.editor.clearValueLines()
1653
+ self._set_status("")
1654
+ return
1655
+ # -------------------------------------------------
1656
+
1657
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
1658
+ v = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
1659
+ v = 0.0 if not np.isfinite(v) else float(np.clip(v, 0.0, 1.0))
1660
+ self.editor.updateValueLines(v, 0.0, 0.0, grayscale=True)
1661
+ self._set_status(self.tr("Cursor ({0}, {1}) K: {2:.3f}").format(ix, iy, v))
1662
+ else:
1663
+ C = img.shape[2]
1664
+ if C >= 3:
1665
+ r, g, b = img[iy, ix, 0], img[iy, ix, 1], img[iy, ix, 2]
1666
+ elif C == 2:
1667
+ r = g = b = img[iy, ix, 0]
1668
+ elif C == 1:
1669
+ r = g = b = img[iy, ix, 0]
1670
+ else:
1671
+ r = g = b = 0.0
1672
+ r = 0.0 if not np.isfinite(r) else float(np.clip(r, 0.0, 1.0))
1673
+ g = 0.0 if not np.isfinite(g) else float(np.clip(g, 0.0, 1.0))
1674
+ b = 0.0 if not np.isfinite(b) else float(np.clip(b, 0.0, 1.0))
1675
+ self.editor.updateValueLines(r, g, b, grayscale=False)
1676
+ self._set_status(self.tr("Cursor ({0}, {1}) R: {2:.3f} G: {3:.3f} B: {4:.3f}").format(ix, iy, r, g, b))
1677
+
1678
+
1679
+ # 1) Put this helper inside CurvesDialogPro (near other helpers)
1680
+ def _map_label_xy_to_image_ij(self, x: float, y: float):
1681
+ """Map label-local coords (x,y) to _preview_img pixel (i,j). Returns (ix, iy) or None."""
1682
+ if self._pix is None:
1683
+ return None
1684
+ pm_disp = self.label.pixmap()
1685
+ if pm_disp is None or pm_disp.isNull():
1686
+ return None
1687
+
1688
+ src_w = self._pix.width() # size of the *source* pixmap (preview image)
1689
+ src_h = self._pix.height()
1690
+ disp_w = pm_disp.width() # size of the *displayed* pixmap on the label
1691
+ disp_h = pm_disp.height()
1692
+ if src_w <= 0 or src_h <= 0 or disp_w <= 0 or disp_h <= 0:
1693
+ return None
1694
+
1695
+ sx = disp_w / float(src_w)
1696
+ sy = disp_h / float(src_h)
1697
+
1698
+ ix = int(x / sx)
1699
+ iy = int(y / sy)
1700
+ if ix < 0 or iy < 0 or ix >= src_w or iy >= src_h:
1701
+ return None
1702
+ return ix, iy
1703
+
1704
+ def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
1705
+ """(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
1706
+ out = []
1707
+ lastx = -1e9
1708
+ for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
1709
+ x = float(np.clip(x, 0.0, 360.0))
1710
+ y = float(np.clip(y, 0.0, 360.0))
1711
+ # strictly increasing X
1712
+ if x <= lastx:
1713
+ x = lastx + 1e-3
1714
+ lastx = x
1715
+ out.append((x / 360.0, 1.0 - (y / 360.0)))
1716
+ # ensure endpoints
1717
+ if not any(abs(px - 0.0) < 1e-6 for px, _ in out): out.insert(0, (0.0, 0.0))
1718
+ if not any(abs(px - 1.0) < 1e-6 for px, _ in out): out.append((1.0, 1.0))
1719
+ # clamp
1720
+ return [(float(np.clip(x,0,1)), float(np.clip(y,0,1))) for (x,y) in out]
1721
+
1722
+ def _collect_points_norm_from_editor(self) -> list[tuple[float,float]]:
1723
+ """
1724
+ Take endpoints+handles from editor => normalized points.
1725
+ NOTE: we do NOT force-add (0,0) and (1,1) here, because that breaks
1726
+ manual black/white endpoints. Presets can still add them later.
1727
+ """
1728
+ pts_scene: list[tuple[float, float]] = []
1729
+ for p in (self.editor.end_points + self.editor.control_points):
1730
+ pos = p.scenePos()
1731
+ pts_scene.append((float(pos.x()), float(pos.y())))
1732
+
1733
+ # convert WITHOUT forced endpoints
1734
+ out: list[tuple[float,float]] = []
1735
+ lastx = -1e9
1736
+ for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
1737
+ x = float(np.clip(x, 0.0, 360.0))
1738
+ y = float(np.clip(y, 0.0, 360.0))
1739
+ if x <= lastx:
1740
+ x = lastx + 1e-3
1741
+ lastx = x
1742
+ out.append((x / 360.0, 1.0 - (y / 360.0)))
1743
+ # no auto (0,0)/(1,1) here
1744
+ return [(float(np.clip(x, 0, 1)), float(np.clip(y, 0, 1))) for (x, y) in out]
1745
+
1746
+ def _save_current_as_preset(self):
1747
+ # get name
1748
+ name, ok = QInputDialog.getText(self, self.tr("Save Curves Preset"), self.tr("Preset name:"))
1749
+ if not ok or not name.strip():
1750
+ return
1751
+ pts_norm = self._collect_points_norm_from_editor()
1752
+ mode = self._current_mode()
1753
+ if save_custom_preset(name.strip(), mode, pts_norm):
1754
+ self._set_status(self.tr("Saved preset “{0}”.").format(name.strip()))
1755
+ self._rebuild_presets_menu()
1756
+ else:
1757
+ QMessageBox.warning(self, self.tr("Save failed"), self.tr("Could not save preset."))
1758
+
1759
+ def _rebuild_presets_menu(self):
1760
+ m = QMenu(self)
1761
+ # Built-in shapes under K (Brightness)
1762
+ builtins = [
1763
+ ("Linear", {"mode": "K (Brightness)", "shape": "linear"}),
1764
+ ("S-Curve (mild)", {"mode": "K (Brightness)", "shape": "s_mild", "amount": 1.0}),
1765
+ ("S-Curve (medium)", {"mode": "K (Brightness)", "shape": "s_med", "amount": 1.0}),
1766
+ ("S-Curve (strong)", {"mode": "K (Brightness)", "shape": "s_strong","amount": 1.0}),
1767
+ ("Lift Shadows", {"mode": "K (Brightness)", "shape": "lift_shadows", "amount": 1.0}),
1768
+ ("Crush Shadows", {"mode": "K (Brightness)", "shape": "crush_shadows","amount": 1.0}),
1769
+ ("Fade Blacks", {"mode": "K (Brightness)", "shape": "fade_blacks", "amount": 1.0}),
1770
+ ("Rolloff Highlights", {"mode": "K (Brightness)", "shape": "rolloff_highlights","amount": 1.0}),
1771
+ ("Flatten", {"mode": "K (Brightness)", "shape": "flatten", "amount": 1.0}),
1772
+ ]
1773
+ if builtins:
1774
+ mb = m.addMenu(self.tr("Built-ins"))
1775
+ for label, preset in builtins:
1776
+ act = mb.addAction(label)
1777
+ act.triggered.connect(lambda _=False, p=preset: self._apply_preset_dict(p))
1778
+
1779
+ # Custom presets (from QSettings)
1780
+ customs = list_custom_presets()
1781
+ if customs:
1782
+ mc = m.addMenu(self.tr("Custom"))
1783
+ for p in sorted(customs, key=lambda d: d.get("name","").lower()):
1784
+ act = mc.addAction(p.get("name","(unnamed)"))
1785
+ act.triggered.connect(lambda _=False, pp=p: self._apply_preset_dict(pp))
1786
+ mc.addSeparator()
1787
+ act_manage = mc.addAction(self.tr("Manage…"))
1788
+ act_manage.triggered.connect(self._open_manage_customs_dialog) # optional (see below)
1789
+ else:
1790
+ m.addAction(self.tr("(No custom presets yet)")).setEnabled(False)
1791
+
1792
+ self.btn_presets.setMenu(m)
1793
+
1794
+ def _open_manage_customs_dialog(self):
1795
+ # optional: quick-and-dirty remover
1796
+ customs = list_custom_presets()
1797
+ if not customs:
1798
+ QMessageBox.information(self, self.tr("Manage Presets"), self.tr("No custom presets."))
1799
+ return
1800
+ names = [p.get("name","") for p in customs]
1801
+ name, ok = QInputDialog.getItem(self, self.tr("Delete Preset"), self.tr("Choose preset to delete:"), names, 0, False)
1802
+ if ok and name:
1803
+ from setiastro.saspro.curves_preset import delete_custom_preset
1804
+ if delete_custom_preset(name):
1805
+ self._rebuild_presets_menu()
1806
+
1807
+
1808
+ # ----- active document change -----
1809
+ def _on_active_doc_changed(self, doc):
1810
+ """Called when user clicks a different image window."""
1811
+ if doc is None or getattr(doc, "image", None) is None:
1812
+ return
1813
+ self.doc = doc
1814
+ self._load_from_doc()
1815
+ QTimer.singleShot(0, self._fit_after_load)
1816
+
1817
+ # ----- data -----
1818
+ def _load_from_doc(self):
1819
+ img = self.doc.image
1820
+ if img is None:
1821
+ QMessageBox.information(self, self.tr("No image"), self.tr("Open an image first."))
1822
+ return
1823
+ arr = np.asarray(img)
1824
+ # normalize to float01 gently
1825
+ if arr.dtype.kind in "ui":
1826
+ arr = arr.astype(np.float32) / np.iinfo(arr.dtype).max
1827
+ elif arr.dtype.kind == "f":
1828
+ mx = float(arr.max()) if arr.size else 1.0
1829
+ arr = (arr / (mx if mx > 1.0 else 1.0)).astype(np.float32)
1830
+ else:
1831
+ arr = arr.astype(np.float32)
1832
+ self._full_img = arr
1833
+ self._preview_img = _downsample_for_preview(arr, 1200)
1834
+ self._preview_orig = self._preview_img.copy()
1835
+ self._preview_proc = None
1836
+
1837
+ self._show_proc = True # ⬅️ start with preview ON
1838
+ self._quick_preview() # ⬅️ build first processed DS frame
1839
+ self._update_preview_pix( # ⬅️ show processed immediately
1840
+ self._preview_proc if self._preview_proc is not None else self._preview_orig,
1841
+ preserve_view=False
1842
+ )
1843
+ self._build_preview_luma_cdf()
1844
+ self._build_preview_rgb_cdfs()
1845
+
1846
+ # ----- building LUT from editor -----
1847
+ def _build_lut01(self) -> np.ndarray | None:
1848
+ get_fn = getattr(self.editor, "getCurveFunction", None)
1849
+ if not get_fn:
1850
+ return None
1851
+ curve_func = get_fn()
1852
+ if curve_func is None:
1853
+ return None
1854
+ # this is your old good helper from the file you pasted
1855
+ return build_curve_lut(curve_func, size=65536)
1856
+
1857
+
1858
+ def _toggle_preview(self, on: bool):
1859
+ self._show_proc = bool(on)
1860
+ # Ensure we have a processed frame ready
1861
+ if self._preview_proc is None:
1862
+ self._quick_preview()
1863
+ # Pick which buffer to show (both are downsampled)
1864
+ img = self._preview_proc if (self._show_proc and self._preview_proc is not None) else self._preview_orig
1865
+ self._update_preview_pix(img)
1866
+ self._set_status(self.tr("Preview ON") if self._show_proc else self.tr("Preview OFF"))
1867
+
1868
+
1869
+ # ----- quick (in-UI) preview on downsample -----
1870
+ def _quick_preview(self):
1871
+ if self._preview_img is None:
1872
+ return
1873
+ luts = self._build_all_active_luts()
1874
+ proc = self._apply_all_curves_once(self._preview_img, luts)
1875
+ proc = self._blend_with_mask(proc)
1876
+ self._preview_proc = proc
1877
+ if self._show_proc:
1878
+ self._update_preview_pix(self._preview_proc)
1879
+ try:
1880
+ bt, wt = self.editor.current_black_white_thresholds()
1881
+
1882
+ if self._preview_img is not None and self._preview_img.ndim == 3 and self._preview_img.shape[2] >= 3:
1883
+ # Color image → only per-channel stats
1884
+ rgb = self._clip_counts_rgb_from_thresholds(bt, wt)
1885
+ def _fmt(pair):
1886
+ cnt_b, cnt_w, fb, fw = pair
1887
+ return self.tr("Bk {0:,} ({1:.2f}%) Wt {2:,} ({3:.2f}%)").format(cnt_b, fb*100, cnt_w, fw*100)
1888
+ self._set_status(
1889
+ self.tr("Clipping — R: {0} G: {1} B: {2}").format(_fmt(rgb['r']), _fmt(rgb['g']), _fmt(rgb['b']))
1890
+ )
1891
+ else:
1892
+ # Grayscale/mono → K summary (unchanged behavior)
1893
+ below, above, f_below, f_above = self._clip_counts_from_thresholds(bt, wt)
1894
+ self._set_status(
1895
+ self.tr("Clipping — Bk {0:,} ({1:.2f}%) Wt {2:,} ({3:.2f}%)").format(below, f_below*100, above, f_above*100)
1896
+ )
1897
+ except Exception:
1898
+ pass
1899
+
1900
+
1901
+ # ----- threaded full-res preview (also used for Apply path if needed) -----
1902
+ def _run_preview(self):
1903
+ if self._full_img is None:
1904
+ return
1905
+ luts = self._build_all_active_luts()
1906
+ self.btn_apply.setEnabled(False)
1907
+ self._thr = _CurvesWorker(self._full_img, luts, self)
1908
+ self._thr.done.connect(self._on_preview_ready)
1909
+ self._thr.finished.connect(lambda: self.btn_apply.setEnabled(True))
1910
+ self._thr.start()
1911
+
1912
+ def _on_preview_ready(self, out01: np.ndarray):
1913
+ # NOTE: do not push full-res into the label
1914
+ out_masked = self._blend_with_mask(out01)
1915
+ self._last_preview = out_masked # cache for Apply
1916
+ self._set_status(self.tr("Full-res ready (not shown)."))
1917
+
1918
+ def _clip_counts_from_thresholds(self, black_t: float | None, white_t: float | None):
1919
+ """
1920
+ Return tuple: (below_count_full, above_count_full, below_frac, above_frac)
1921
+ using the precomputed *preview* luma CDF, scaled to full-image counts.
1922
+ """
1923
+ if self._cdf is None or getattr(self, "_cdf_total_preview", 0) <= 0:
1924
+ return 0, 0, 0.0, 0.0
1925
+
1926
+ bins = int(getattr(self, "_cdf_bins", 1024))
1927
+
1928
+ # blacks: values strictly < black_t
1929
+ if black_t is None:
1930
+ below_preview = 0
1931
+ else:
1932
+ i = int(np.floor(np.clip(float(black_t), 0.0, 1.0) * (bins - 1)))
1933
+ i = max(0, min(bins - 1, i))
1934
+ below_preview = int(self._cdf[i])
1935
+
1936
+ # whites: values strictly > white_t
1937
+ if white_t is None:
1938
+ above_preview = 0
1939
+ else:
1940
+ j = int(np.floor(np.clip(float(white_t), 0.0, 1.0) * (bins - 1)))
1941
+ j = max(0, min(bins - 1, j))
1942
+ above_preview = int(self._cdf_total_preview - self._cdf[j])
1943
+
1944
+ # scale preview counts to full-image counts
1945
+ scale = float(getattr(self, "_clip_scale", 1.0))
1946
+ total_full = int(getattr(self, "_cdf_total_full", self._cdf_total_preview)) or 1
1947
+ below_full = int(round(below_preview * scale))
1948
+ above_full = int(round(above_preview * scale))
1949
+
1950
+ # clamp to valid range
1951
+ below_full = max(0, min(below_full, total_full))
1952
+ above_full = max(0, min(above_full, total_full))
1953
+
1954
+ # fractions against full-image total
1955
+ f_below = below_full / float(total_full)
1956
+ f_above = above_full / float(total_full)
1957
+
1958
+ return below_full, above_full, f_below, f_above
1959
+
1960
+ def _clip_counts_rgb_from_thresholds(self, black_t: float | None, white_t: float | None):
1961
+ """
1962
+ Returns dict:
1963
+ {
1964
+ 'r': (below_full, above_full, frac_below, frac_above),
1965
+ 'g': (...),
1966
+ 'b': (...)
1967
+ }
1968
+ using the precomputed preview RGB CDFs scaled to full-image counts.
1969
+ """
1970
+ out = {"r": (0,0,0.0,0.0), "g": (0,0,0.0,0.0), "b": (0,0,0.0,0.0)}
1971
+ if getattr(self, "_cdf_rgb", None) is None:
1972
+ return out
1973
+
1974
+ bins = int(getattr(self, "_cdf_bins", 1024))
1975
+ scale = float(getattr(self, "_clip_scale", 1.0))
1976
+ total_full = int(getattr(self, "_cdf_total_full", self._cdf_rgb["total_preview"])) or 1
1977
+ total_prev = int(self._cdf_rgb["total_preview"]) or 1
1978
+
1979
+ def _bin_idx(t):
1980
+ i = int(np.floor(np.clip(float(t), 0.0, 1.0) * (bins - 1)))
1981
+ return max(0, min(bins - 1, i))
1982
+
1983
+ for ch in ("r", "g", "b"):
1984
+ cdf = self._cdf_rgb[ch]
1985
+ # blacks
1986
+ if black_t is None:
1987
+ below_prev = 0
1988
+ else:
1989
+ i = _bin_idx(black_t)
1990
+ below_prev = int(cdf[i])
1991
+ # whites
1992
+ if white_t is None:
1993
+ above_prev = 0
1994
+ else:
1995
+ j = _bin_idx(white_t)
1996
+ above_prev = int(total_prev - cdf[j])
1997
+
1998
+ below_full = int(round(below_prev * scale))
1999
+ above_full = int(round(above_prev * scale))
2000
+
2001
+ below_full = max(0, min(below_full, total_full))
2002
+ above_full = max(0, min(above_full, total_full))
2003
+
2004
+ out[ch] = (
2005
+ below_full,
2006
+ above_full,
2007
+ below_full / float(total_full),
2008
+ above_full / float(total_full),
2009
+ )
2010
+ return out
2011
+
2012
+ def export_preview_ops(self) -> dict:
2013
+ """
2014
+ Produce a deterministic, tool-agnostic op dict for Curves
2015
+ that can be replayed on the full image later.
2016
+ """
2017
+ # Make sure the store has the latest edit from the active editor
2018
+ try:
2019
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
2020
+ except Exception:
2021
+ pass
2022
+
2023
+ # Only include modes that differ from linear
2024
+ def _is_linear(pts):
2025
+ return isinstance(pts, (list,tuple)) and len(pts)==2 and pts[0]==(0.0,0.0) and pts[1]==(1.0,1.0)
2026
+
2027
+ modes = {}
2028
+ for k, pts in self._curves_store.items():
2029
+ if not pts or _is_linear(pts):
2030
+ continue
2031
+ modes[k] = [(float(x), float(y)) for (x,y) in pts]
2032
+
2033
+ op = {
2034
+ "version": 1,
2035
+ "tool": "curves",
2036
+ "modes": modes,
2037
+ "active": self._current_mode_key,
2038
+ "lut_size": 65536,
2039
+ "mask": {
2040
+ "id": getattr(self.doc, "active_mask_id", None),
2041
+ "blend": "m*out+(1-m)*src",
2042
+ },
2043
+ }
2044
+ return op
2045
+
2046
+
2047
+ # ----- apply to document -----
2048
+ def _apply(self):
2049
+ if not hasattr(self, "_last_preview"):
2050
+ luts = self._build_all_active_luts()
2051
+ out01 = self._apply_all_curves_once(self._full_img, luts)
2052
+ out01 = self._blend_with_mask(out01)
2053
+ self._last_preview = out01
2054
+ self._commit(self._last_preview)
2055
+
2056
+ def _commit(self, out01: np.ndarray):
2057
+ try:
2058
+ _marr, mid, mname = self._active_mask_layer()
2059
+ meta = {
2060
+ "step_name": "Curves",
2061
+ "curves": {"mode": self._current_mode()},
2062
+ "masked": bool(mid),
2063
+ "mask_id": mid,
2064
+ "mask_name": mname,
2065
+ "mask_blend": "m*out + (1-m)*src",
2066
+ }
2067
+
2068
+ # 1) Apply to the document (updates the active view)
2069
+ self.doc.apply_edit(out01.copy(), metadata=meta, step_name="Curves")
2070
+
2071
+ try:
2072
+ self._remember_as_last_action()
2073
+ except Exception:
2074
+ pass
2075
+
2076
+ # 2) Pull the NEW image back into the curves dialog
2077
+ # (clear cached previews so we truly reload from the document)
2078
+ self.__dict__.pop("_last_preview", None)
2079
+ self._full_img = None
2080
+ self._preview_img = None
2081
+ self._load_from_doc() # refresh preview from updated doc
2082
+
2083
+ # 3) Reset the curve drawing so user can keep tweaking from scratch
2084
+ # --- after reloading the image from the document ---
2085
+ if hasattr(self.editor, "clearSymmetryLine"):
2086
+ self.editor.clearSymmetryLine()
2087
+ self.editor.initCurve()
2088
+
2089
+ # Clear ALL curves, not just current
2090
+ for k in list(self._curves_store.keys()):
2091
+ self._curves_store[k] = [(0.0, 0.0), (1.0, 1.0)]
2092
+
2093
+ self._refresh_overlays()
2094
+ self._quick_preview()
2095
+ self._set_status(self.tr("Applied. Image reloaded. All curves reset — keep tweaking."))
2096
+
2097
+
2098
+
2099
+ except Exception as e:
2100
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
2101
+
2102
+
2103
+ # ----- helpers -----
2104
+ def _current_mode(self) -> str:
2105
+ for b in self.mode_group.buttons():
2106
+ if b.isChecked():
2107
+ return b.text()
2108
+ return "K (Brightness)"
2109
+
2110
+ def _set_status(self, s: str):
2111
+ self.lbl_status.setText(s)
2112
+
2113
+ # preview label drawing
2114
+ def _update_preview_pix(self, img01: np.ndarray | None, preserve_view: bool = True):
2115
+ if img01 is None:
2116
+ self.label.clear(); self._pix = None; return
2117
+
2118
+ state = self._capture_view() if preserve_view else None
2119
+
2120
+ qimg = _float_to_qimage_rgb8(img01)
2121
+ pm = QPixmap.fromImage(qimg)
2122
+ self._pix = pm
2123
+
2124
+ if preserve_view and state is not None:
2125
+ fx, fy, zoom = state
2126
+ # Avoid any auto-fit when we explicitly preserve view
2127
+ self._restore_view(fx, fy, zoom)
2128
+ else:
2129
+ self._apply_zoom()
2130
+ if not self._did_initial_fit:
2131
+ QTimer.singleShot(0, self._fit_once)
2132
+
2133
+
2134
+ # --- mask helpers ---------------------------------------------------
2135
+ def _active_mask_layer(self):
2136
+ """Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
2137
+ mid = getattr(self.doc, "active_mask_id", None)
2138
+ if not mid: return None, None, None
2139
+ layer = getattr(self.doc, "masks", {}).get(mid)
2140
+ if layer is None: return None, None, None
2141
+ m = np.asarray(getattr(layer, "data", None))
2142
+ if m is None or m.size == 0: return None, None, None
2143
+ m = m.astype(np.float32, copy=False)
2144
+ if m.dtype.kind in "ui":
2145
+ m /= float(np.iinfo(m.dtype).max)
2146
+ else:
2147
+ mx = float(m.max()) if m.size else 1.0
2148
+ if mx > 1.0: m /= mx
2149
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
2150
+
2151
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
2152
+ """Nearest-neighbor resize via integer indexing."""
2153
+ mh, mw = mask.shape[:2]
2154
+ th, tw = out_hw
2155
+ if (mh, mw) == (th, tw): return mask
2156
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
2157
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
2158
+ return mask[yi][:, xi]
2159
+
2160
+ def _blend_with_mask(self, processed: np.ndarray) -> np.ndarray:
2161
+ """
2162
+ Blend processed image with original using active mask (if any).
2163
+ Chooses original from preview/full buffers to match shape.
2164
+ """
2165
+ mask, _mid, _mname = self._active_mask_layer()
2166
+ if mask is None:
2167
+ return processed
2168
+
2169
+ out = processed.astype(np.float32, copy=False)
2170
+ # pick matching original
2171
+ if (hasattr(self, "_full_img") and self._full_img is not None
2172
+ and out.shape[:2] == self._full_img.shape[:2]):
2173
+ src = self._full_img
2174
+ else:
2175
+ src = self._preview_img
2176
+
2177
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
2178
+ if out.ndim == 3 and out.shape[2] == 3:
2179
+ m = m[..., None]
2180
+
2181
+ # shape/channel reconcile
2182
+ if src.ndim == 2 and out.ndim == 3:
2183
+ src = np.stack([src]*3, axis=-1)
2184
+ elif src.ndim == 3 and out.ndim == 2:
2185
+ src = src[..., 0]
2186
+
2187
+ return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
2188
+
2189
+ def closeEvent(self, ev):
2190
+ self._cleanup_connections()
2191
+ super().closeEvent(ev)
2192
+
2193
+ def _cleanup_connections(self):
2194
+ # disconnect the "follow active doc" hook
2195
+ try:
2196
+ if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
2197
+ self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
2198
+ except Exception:
2199
+ pass
2200
+ self._follow_conn = False
2201
+
2202
+ # stop/kill any running worker thread(s)
2203
+ try:
2204
+ thr = getattr(self, "_thr", None)
2205
+ if thr is not None:
2206
+ try:
2207
+ thr.requestInterruption()
2208
+ except Exception:
2209
+ pass
2210
+ try:
2211
+ thr.quit()
2212
+ except Exception:
2213
+ pass
2214
+ try:
2215
+ thr.wait(250)
2216
+ except Exception:
2217
+ pass
2218
+ except Exception:
2219
+ pass
2220
+
2221
+ # optional: drop refs that can keep things alive
2222
+ try:
2223
+ self._thr = None
2224
+ except Exception:
2225
+ pass
2226
+
2227
+
2228
+ # zoom/pan
2229
+ def _apply_zoom(self):
2230
+ if self._pix is None:
2231
+ return
2232
+ scaled = self._pix.scaled(self._pix.size()*self._zoom,
2233
+ Qt.AspectRatioMode.KeepAspectRatio,
2234
+ Qt.TransformationMode.SmoothTransformation)
2235
+ self.label.setPixmap(scaled)
2236
+ self.label.resize(scaled.size())
2237
+
2238
+ def _set_zoom(self, z: float):
2239
+ self._zoom = float(max(0.05, min(z, 8.0)))
2240
+ self._apply_zoom()
2241
+
2242
+ def _fit(self):
2243
+ if self._pix is None: return
2244
+ vp = self.scroll.viewport().size()
2245
+ if self._pix.width()==0 or self._pix.height()==0: return
2246
+ s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
2247
+ self._set_zoom(max(0.05, s))
2248
+
2249
+ # event filter: ctrl+wheel zoom + panning (like Star Stretch)
2250
+ def eventFilter(self, obj, ev):
2251
+ if obj is self.scroll.viewport():
2252
+ # Ctrl+wheel zoom / panning (your existing code) ...
2253
+ if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
2254
+ self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
2255
+ ev.accept(); return True
2256
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
2257
+ self._panning = True; self._pan_start = ev.position()
2258
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
2259
+ ev.accept(); return True
2260
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
2261
+ d = ev.position() - self._pan_start
2262
+ h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
2263
+ h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
2264
+ self._pan_start = ev.position()
2265
+ ev.accept(); return True
2266
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
2267
+ self._panning = False
2268
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
2269
+ ev.accept(); return True
2270
+
2271
+ # NEW: if just moving the mouse (not panning), forward to label coords
2272
+ if ev.type() == QEvent.Type.MouseMove and not self._panning:
2273
+ # map viewport point → label-local point
2274
+ lp = self.label.mapFrom(self.scroll.viewport(), QPoint(int(ev.position().x()), int(ev.position().y())))
2275
+ if 0 <= lp.x() < self.label.width() and 0 <= lp.y() < self.label.height():
2276
+ self._on_preview_mouse_moved(lp.x(), lp.y())
2277
+ else:
2278
+ self.editor.clearValueLines()
2279
+ self._set_status("")
2280
+ return False # don't consume
2281
+
2282
+ if ev.type() == QEvent.Type.MouseButtonDblClick and ev.button() == Qt.MouseButton.LeftButton:
2283
+ if self._preview_img is None or self._pix is None:
2284
+ return False
2285
+ pos = self.label.mapFrom(self.scroll.viewport(), ev.pos())
2286
+ ix = int(pos.x() / max(self._zoom, 1e-6))
2287
+ iy = int(pos.y() / max(self._zoom, 1e-6))
2288
+ ix = max(0, min(self._pix.width() - 1, ix))
2289
+ iy = max(0, min(self._pix.height() - 1, iy))
2290
+
2291
+ img = self._preview_img
2292
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
2293
+ k = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
2294
+ else:
2295
+ k = float(np.mean(img[iy, ix, :3]))
2296
+ k = float(np.clip(k, 0.0, 1.0))
2297
+
2298
+ # show the yellow bar + redistribute
2299
+ self.editor.setSymmetryPoint(k * 360.0, 0.0)
2300
+ self._on_symmetry_pick(k, k)
2301
+ ev.accept()
2302
+ return True
2303
+
2304
+ # existing label Leave handler
2305
+ if obj is self.label and ev.type() == QEvent.Type.Leave:
2306
+ self.editor.clearValueLines()
2307
+ self._set_status("")
2308
+ return False
2309
+
2310
+ # existing double-click handler: just swap in the same mapper
2311
+ if obj is self.label and ev.type() == QEvent.Type.MouseButtonDblClick:
2312
+ if ev.button() != Qt.MouseButton.LeftButton:
2313
+ return False
2314
+ pos = ev.position()
2315
+ mapped = self._map_label_xy_to_image_ij(pos.x(), pos.y())
2316
+ if not mapped or self._preview_img is None:
2317
+ return False
2318
+ ix, iy = mapped
2319
+ img = self._preview_img
2320
+ # mono or RGB-average
2321
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
2322
+ v = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
2323
+ else:
2324
+ r, g, b = float(img[iy, ix, 0]), float(img[iy, ix, 1]), float(img[iy, ix, 2])
2325
+ v = (r + g + b) / 3.0
2326
+ if np.isnan(v):
2327
+ return True
2328
+ v = float(np.clip(v, 0.0, 1.0))
2329
+ x = max(0.001, min(359.999, v * 360.0))
2330
+
2331
+ # place on current curve
2332
+ y = None
2333
+ try:
2334
+ f = self.editor.getCurveFunction()
2335
+ if f is not None:
2336
+ y = float(f(x))
2337
+ except Exception:
2338
+ pass
2339
+ if y is None:
2340
+ y = 360.0 - x
2341
+
2342
+ # avoid x-collisions
2343
+ xs = [p.scenePos().x() for p in (self.editor.end_points + self.editor.control_points)]
2344
+ if any(abs(x - ex) < 1e-3 for ex in xs):
2345
+ step = 0.002
2346
+ for k in range(1, 2000):
2347
+ for cand in (x + k*step, x - k*step):
2348
+ if 0.001 < cand < 359.999 and all(abs(cand - ex) >= 1e-3 for ex in xs):
2349
+ x = cand; break
2350
+ else:
2351
+ continue
2352
+ break
2353
+
2354
+ self.editor.addControlPoint(x, y)
2355
+ self._set_status(self.tr("Added point at x={0:.3f}").format(v))
2356
+ ev.accept()
2357
+ return True
2358
+
2359
+ return super().eventFilter(obj, ev)
2360
+
2361
+
2362
+ def _reset_curve(self):
2363
+ # 1) reset editor drawing to linear
2364
+ self.editor.initCurve()
2365
+ # 2) mark *every* stored curve linear
2366
+ for k in list(self._curves_store.keys()):
2367
+ self._curves_store[k] = [(0.0, 0.0), (1.0, 1.0)]
2368
+ # 3) refresh overlays & preview
2369
+ self._refresh_overlays()
2370
+ self._quick_preview()
2371
+ self._set_status(self.tr("All curves reset."))
2372
+
2373
+ def _find_main_window(self):
2374
+ p = self.parent()
2375
+ while p is not None and not hasattr(p, "docman"):
2376
+ p = p.parent()
2377
+ return p
2378
+
2379
+ def _apply_preset_dict(self, preset: dict):
2380
+ preset = preset or {}
2381
+
2382
+ # 1) set mode radio
2383
+ want = _norm_mode(preset.get("mode"))
2384
+ for b in self.mode_group.buttons():
2385
+ if b.text().lower() == want.lower():
2386
+ b.setChecked(True)
2387
+ break
2388
+
2389
+ # 2) get points_norm — if absent, build from shape/amount (built-ins)
2390
+ ptsN = preset.get("points_norm")
2391
+ shape = preset.get("shape") # may be None for custom presets
2392
+ amount = float(preset.get("amount", 1.0))
2393
+
2394
+ if not (isinstance(ptsN, (list, tuple)) and len(ptsN) >= 2):
2395
+ try:
2396
+ # build from a named shape (built-ins); default to linear
2397
+ ptsN = _shape_points_norm(str(shape or "linear"), amount)
2398
+ except Exception:
2399
+ ptsN = [(0.0, 0.0), (1.0, 1.0)] # safe fallback
2400
+
2401
+ # 3) apply handles to the editor (strip exact endpoints)
2402
+ pts_scene = _points_norm_to_scene(ptsN)
2403
+ filt = [(x, y) for (x, y) in pts_scene if 1e-6 < x < 360.0 - 1e-6]
2404
+
2405
+ if hasattr(self.editor, "clearSymmetryLine"):
2406
+ self.editor.clearSymmetryLine()
2407
+
2408
+ self.editor.setControlHandles(filt)
2409
+ self.editor.updateCurve() # ensure redraw
2410
+
2411
+ # persist into store & refresh
2412
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
2413
+ self._refresh_overlays()
2414
+ self._quick_preview()
2415
+
2416
+ # 4) status: don’t assume shape exists
2417
+ shape_tag = f"[{shape}]" if shape else "[custom]"
2418
+ self._set_status(self.tr("Preset: {0} {1}").format(preset.get('name', self.tr('(built-in)')), shape_tag))
2419
+
2420
+
2421
+ def apply_curves_ops(doc, op: dict):
2422
+ """
2423
+ Rebuild LUTs from normalized points and apply to doc.image (full-res).
2424
+ Uses the same math as the dialog path, but headless.
2425
+ """
2426
+ try:
2427
+ if op.get("tool") != "curves":
2428
+ return False
2429
+
2430
+ # safety defaults
2431
+ lut_size = int(op.get("lut_size", 65536))
2432
+ modes = dict(op.get("modes", {}))
2433
+ if not modes:
2434
+ return True # nothing to do (all linear)
2435
+
2436
+ # Build LUTs exactly like the dialog does (_lut01_from_points_norm)
2437
+ def _lut01_from_ptsN(ptsN, size=65536):
2438
+ # local import: reuse your existing helper if you prefer
2439
+ pts_scene = _points_norm_to_scene(ptsN)
2440
+ if len(pts_scene) < 2:
2441
+ return np.linspace(0.0, 1.0, size, dtype=np.float32)
2442
+ xs = np.array([p[0] for p in pts_scene], dtype=np.float64)
2443
+ ys = np.array([p[1] for p in pts_scene], dtype=np.float64)
2444
+ if np.any(np.diff(xs) <= 0):
2445
+ xs = xs + np.linspace(0, 1e-3, len(xs), dtype=np.float64)
2446
+ ys = 360.0 - ys
2447
+ inp = np.linspace(0.0, 360.0, size, dtype=np.float64)
2448
+ try:
2449
+ from scipy.interpolate import PchipInterpolator
2450
+ f = PchipInterpolator(xs, ys, extrapolate=True)
2451
+ out = f(inp)
2452
+ except Exception:
2453
+ out = np.interp(inp, xs, ys)
2454
+ out = np.clip(out / 360.0, 0.0, 1.0).astype(np.float32)
2455
+ return out
2456
+
2457
+ luts = {k: _lut01_from_ptsN(pts, lut_size) for k, pts in modes.items()}
2458
+
2459
+ # Pull full-res, normalize to float01 (same as dialog)
2460
+ img = np.asarray(doc.image)
2461
+ if img.dtype.kind in "ui":
2462
+ img01 = img.astype(np.float32) / np.iinfo(img.dtype).max
2463
+ elif img.dtype.kind == "f":
2464
+ mx = float(img.max()) if img.size else 1.0
2465
+ img01 = (img / (mx if mx > 1.0 else 1.0)).astype(np.float32)
2466
+ else:
2467
+ img01 = img.astype(np.float32)
2468
+
2469
+ # Apply using the same engine as the dialog
2470
+ # (reuse CurvesDialogPro._apply_all_curves_once logic via a tiny local copy)
2471
+ out01 = CurvesDialogPro._apply_all_curves_once(None, img01, luts) # call as unbound
2472
+
2473
+ # Blend with active mask if any
2474
+ # Reuse the dialog helper via a tiny shim:
2475
+ dlg_like = CurvesDialogPro.__new__(CurvesDialogPro) # no init
2476
+ dlg_like.doc = doc
2477
+ dlg_like._full_img = img01
2478
+ out01 = CurvesDialogPro._blend_with_mask(dlg_like, out01)
2479
+
2480
+ # Commit to doc history
2481
+ meta = {
2482
+ "step_name": "Curves (Replay)",
2483
+ "curves": {"modes": list(modes.keys()), "lut_size": lut_size},
2484
+ "masked": bool(op.get("mask", {}).get("id")),
2485
+ "mask_id": op.get("mask", {}).get("id"),
2486
+ }
2487
+ doc.apply_edit(out01.copy(), metadata=meta, step_name="Curves (Replay)")
2488
+ return True
2489
+ except Exception as e:
2490
+ print("apply_curves_ops failed:", e)
2491
+ return False
2492
+
2493
+
2494
+ def _apply_mode_any(img01: np.ndarray, mode: str, lut01: np.ndarray) -> np.ndarray:
2495
+ """
2496
+ img01: float32 [0..1], mono(H,W) or RGB(H,W,3)
2497
+ mode: "K (Brightness)" | "R" | "G" | "B" | "L*" | "a*" | "b*" | "Chroma" | "Saturation"
2498
+ lut01: float32 [0..1] LUT
2499
+ """
2500
+ if img01.ndim == 2 or (img01.ndim == 3 and img01.shape[2] == 1):
2501
+ ch = img01 if img01.ndim == 2 else img01[...,0]
2502
+ # mono – just apply
2503
+ if _HAS_NUMBA:
2504
+ out = ch.copy()
2505
+ _nb_apply_lut_mono_inplace(out, lut01)
2506
+ else:
2507
+ out = _np_apply_lut_channel(ch, lut01)
2508
+ return out
2509
+
2510
+ # RGB:
2511
+ m = mode.lower()
2512
+ if m == "k (brightness)":
2513
+ if _HAS_NUMBA:
2514
+ out = img01.copy()
2515
+ _nb_apply_lut_color_inplace(out, lut01)
2516
+ return out
2517
+ return _np_apply_lut_rgb(img01, lut01)
2518
+
2519
+ if m in ("r","g","b"):
2520
+ out = img01.copy()
2521
+ idx = {"r":0, "g":1, "b":2}[m]
2522
+ if _HAS_NUMBA:
2523
+ _nb_apply_lut_mono_inplace(out[..., idx], lut01)
2524
+ else:
2525
+ out[..., idx] = _np_apply_lut_channel(out[..., idx], lut01)
2526
+ return out
2527
+
2528
+ # L*, a*, b*, Chroma => Lab trip
2529
+ if m in ("l*", "a*", "b*", "chroma"):
2530
+ if _HAS_NUMBA:
2531
+ xyz = rgb_to_xyz_numba(img01)
2532
+ lab = xyz_to_lab_numba(xyz)
2533
+ else:
2534
+ xyz = _np_rgb_to_xyz(img01)
2535
+ lab = _np_xyz_to_lab(xyz)
2536
+
2537
+ if m == "l*":
2538
+ L = lab[...,0] / 100.0
2539
+ L = np.clip(L, 0.0, 1.0)
2540
+ if _HAS_NUMBA:
2541
+ _nb_apply_lut_mono_inplace(L, lut01)
2542
+ else:
2543
+ L = _np_apply_lut_channel(L, lut01)
2544
+ lab[...,0] = L * 100.0
2545
+
2546
+ elif m == "a*":
2547
+ a = lab[...,1]
2548
+ a_norm = np.clip((a + 128.0)/255.0, 0.0, 1.0)
2549
+ if _HAS_NUMBA:
2550
+ _nb_apply_lut_mono_inplace(a_norm, lut01)
2551
+ else:
2552
+ a_norm = _np_apply_lut_channel(a_norm, lut01)
2553
+ lab[...,1] = a_norm*255.0 - 128.0
2554
+
2555
+ elif m == "b*":
2556
+ b = lab[...,2]
2557
+ b_norm = np.clip((b + 128.0)/255.0, 0.0, 1.0)
2558
+ if _HAS_NUMBA:
2559
+ _nb_apply_lut_mono_inplace(b_norm, lut01)
2560
+ else:
2561
+ b_norm = _np_apply_lut_channel(b_norm, lut01)
2562
+ lab[...,2] = b_norm*255.0 - 128.0
2563
+
2564
+ else: # chroma
2565
+ a = lab[...,1]; b = lab[...,2]
2566
+ C = np.sqrt(a*a + b*b)
2567
+ C_norm = np.clip(C / 200.0, 0.0, 1.0)
2568
+ if _HAS_NUMBA:
2569
+ _nb_apply_lut_mono_inplace(C_norm, lut01)
2570
+ else:
2571
+ C_norm = _np_apply_lut_channel(C_norm, lut01)
2572
+ C_new = C_norm * 200.0
2573
+ ratio = np.divide(C_new, C, out=np.zeros_like(C_new), where=(C>0))
2574
+ lab[...,1] = a * ratio
2575
+ lab[...,2] = b * ratio
2576
+
2577
+ if _HAS_NUMBA:
2578
+ xyz2 = lab_to_xyz_numba(lab)
2579
+ out = xyz_to_rgb_numba(xyz2)
2580
+ else:
2581
+ xyz2 = _np_lab_to_xyz(lab)
2582
+ out = _np_xyz_to_rgb(xyz2)
2583
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
2584
+
2585
+ # Saturation => HSV trip
2586
+ if m == "saturation":
2587
+ if _HAS_NUMBA:
2588
+ hsv = rgb_to_hsv_numba(img01)
2589
+ else:
2590
+ hsv = _np_rgb_to_hsv(img01)
2591
+ S = np.clip(hsv[...,1], 0.0, 1.0)
2592
+ if _HAS_NUMBA:
2593
+ _nb_apply_lut_mono_inplace(S, lut01)
2594
+ else:
2595
+ S = _np_apply_lut_channel(S, lut01)
2596
+ hsv[...,1] = np.clip(S, 0.0, 1.0)
2597
+ if _HAS_NUMBA:
2598
+ out = hsv_to_rgb_numba(hsv)
2599
+ else:
2600
+ out = _np_hsv_to_rgb(hsv)
2601
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
2602
+
2603
+ # Unknown ⇒ fallback to brightness
2604
+ if _HAS_NUMBA:
2605
+ out = img01.copy()
2606
+ _nb_apply_lut_color_inplace(out, lut01)
2607
+ return out
2608
+ return _np_apply_lut_rgb(img01, lut01)