setiastrosuitepro 1.6.5.post3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. setiastro/__init__.py +2 -0
  2. setiastro/data/SASP_data.fits +0 -0
  3. setiastro/data/catalogs/List_of_Galaxies_with_Distances_Gly.csv +488 -0
  4. setiastro/data/catalogs/astrobin_filters.csv +890 -0
  5. setiastro/data/catalogs/astrobin_filters_page1_local.csv +51 -0
  6. setiastro/data/catalogs/cali2.csv +63 -0
  7. setiastro/data/catalogs/cali2color.csv +65 -0
  8. setiastro/data/catalogs/celestial_catalog - original.csv +16471 -0
  9. setiastro/data/catalogs/celestial_catalog.csv +24031 -0
  10. setiastro/data/catalogs/detected_stars.csv +24784 -0
  11. setiastro/data/catalogs/fits_header_data.csv +46 -0
  12. setiastro/data/catalogs/test.csv +8 -0
  13. setiastro/data/catalogs/updated_celestial_catalog.csv +16471 -0
  14. setiastro/images/Astro_Spikes.png +0 -0
  15. setiastro/images/Background_startup.jpg +0 -0
  16. setiastro/images/HRDiagram.png +0 -0
  17. setiastro/images/LExtract.png +0 -0
  18. setiastro/images/LInsert.png +0 -0
  19. setiastro/images/Oxygenation-atm-2.svg.png +0 -0
  20. setiastro/images/RGB080604.png +0 -0
  21. setiastro/images/abeicon.png +0 -0
  22. setiastro/images/aberration.png +0 -0
  23. setiastro/images/andromedatry.png +0 -0
  24. setiastro/images/andromedatry_satellited.png +0 -0
  25. setiastro/images/annotated.png +0 -0
  26. setiastro/images/aperture.png +0 -0
  27. setiastro/images/astrosuite.ico +0 -0
  28. setiastro/images/astrosuite.png +0 -0
  29. setiastro/images/astrosuitepro.icns +0 -0
  30. setiastro/images/astrosuitepro.ico +0 -0
  31. setiastro/images/astrosuitepro.png +0 -0
  32. setiastro/images/background.png +0 -0
  33. setiastro/images/background2.png +0 -0
  34. setiastro/images/benchmark.png +0 -0
  35. setiastro/images/big_moon_stabilizer_timeline.png +0 -0
  36. setiastro/images/big_moon_stabilizer_timeline_clean.png +0 -0
  37. setiastro/images/blaster.png +0 -0
  38. setiastro/images/blink.png +0 -0
  39. setiastro/images/clahe.png +0 -0
  40. setiastro/images/collage.png +0 -0
  41. setiastro/images/colorwheel.png +0 -0
  42. setiastro/images/contsub.png +0 -0
  43. setiastro/images/convo.png +0 -0
  44. setiastro/images/copyslot.png +0 -0
  45. setiastro/images/cosmic.png +0 -0
  46. setiastro/images/cosmicsat.png +0 -0
  47. setiastro/images/crop1.png +0 -0
  48. setiastro/images/cropicon.png +0 -0
  49. setiastro/images/curves.png +0 -0
  50. setiastro/images/cvs.png +0 -0
  51. setiastro/images/debayer.png +0 -0
  52. setiastro/images/denoise_cnn_custom.png +0 -0
  53. setiastro/images/denoise_cnn_graph.png +0 -0
  54. setiastro/images/disk.png +0 -0
  55. setiastro/images/dse.png +0 -0
  56. setiastro/images/exoicon.png +0 -0
  57. setiastro/images/eye.png +0 -0
  58. setiastro/images/fliphorizontal.png +0 -0
  59. setiastro/images/flipvertical.png +0 -0
  60. setiastro/images/font.png +0 -0
  61. setiastro/images/freqsep.png +0 -0
  62. setiastro/images/functionbundle.png +0 -0
  63. setiastro/images/graxpert.png +0 -0
  64. setiastro/images/green.png +0 -0
  65. setiastro/images/gridicon.png +0 -0
  66. setiastro/images/halo.png +0 -0
  67. setiastro/images/hdr.png +0 -0
  68. setiastro/images/histogram.png +0 -0
  69. setiastro/images/hubble.png +0 -0
  70. setiastro/images/imagecombine.png +0 -0
  71. setiastro/images/invert.png +0 -0
  72. setiastro/images/isophote.png +0 -0
  73. setiastro/images/isophote_demo_figure.png +0 -0
  74. setiastro/images/isophote_demo_image.png +0 -0
  75. setiastro/images/isophote_demo_model.png +0 -0
  76. setiastro/images/isophote_demo_residual.png +0 -0
  77. setiastro/images/jwstpupil.png +0 -0
  78. setiastro/images/linearfit.png +0 -0
  79. setiastro/images/livestacking.png +0 -0
  80. setiastro/images/mask.png +0 -0
  81. setiastro/images/maskapply.png +0 -0
  82. setiastro/images/maskcreate.png +0 -0
  83. setiastro/images/maskremove.png +0 -0
  84. setiastro/images/morpho.png +0 -0
  85. setiastro/images/mosaic.png +0 -0
  86. setiastro/images/multiscale_decomp.png +0 -0
  87. setiastro/images/nbtorgb.png +0 -0
  88. setiastro/images/neutral.png +0 -0
  89. setiastro/images/nuke.png +0 -0
  90. setiastro/images/openfile.png +0 -0
  91. setiastro/images/pedestal.png +0 -0
  92. setiastro/images/pen.png +0 -0
  93. setiastro/images/pixelmath.png +0 -0
  94. setiastro/images/platesolve.png +0 -0
  95. setiastro/images/ppp.png +0 -0
  96. setiastro/images/pro.png +0 -0
  97. setiastro/images/project.png +0 -0
  98. setiastro/images/psf.png +0 -0
  99. setiastro/images/redo.png +0 -0
  100. setiastro/images/redoicon.png +0 -0
  101. setiastro/images/rescale.png +0 -0
  102. setiastro/images/rgbalign.png +0 -0
  103. setiastro/images/rgbcombo.png +0 -0
  104. setiastro/images/rgbextract.png +0 -0
  105. setiastro/images/rotate180.png +0 -0
  106. setiastro/images/rotatearbitrary.png +0 -0
  107. setiastro/images/rotateclockwise.png +0 -0
  108. setiastro/images/rotatecounterclockwise.png +0 -0
  109. setiastro/images/satellite.png +0 -0
  110. setiastro/images/script.png +0 -0
  111. setiastro/images/selectivecolor.png +0 -0
  112. setiastro/images/simbad.png +0 -0
  113. setiastro/images/slot0.png +0 -0
  114. setiastro/images/slot1.png +0 -0
  115. setiastro/images/slot2.png +0 -0
  116. setiastro/images/slot3.png +0 -0
  117. setiastro/images/slot4.png +0 -0
  118. setiastro/images/slot5.png +0 -0
  119. setiastro/images/slot6.png +0 -0
  120. setiastro/images/slot7.png +0 -0
  121. setiastro/images/slot8.png +0 -0
  122. setiastro/images/slot9.png +0 -0
  123. setiastro/images/spcc.png +0 -0
  124. setiastro/images/spin_precession_vs_lunar_distance.png +0 -0
  125. setiastro/images/spinner.gif +0 -0
  126. setiastro/images/stacking.png +0 -0
  127. setiastro/images/staradd.png +0 -0
  128. setiastro/images/staralign.png +0 -0
  129. setiastro/images/starnet.png +0 -0
  130. setiastro/images/starregistration.png +0 -0
  131. setiastro/images/starspike.png +0 -0
  132. setiastro/images/starstretch.png +0 -0
  133. setiastro/images/statstretch.png +0 -0
  134. setiastro/images/supernova.png +0 -0
  135. setiastro/images/uhs.png +0 -0
  136. setiastro/images/undoicon.png +0 -0
  137. setiastro/images/upscale.png +0 -0
  138. setiastro/images/viewbundle.png +0 -0
  139. setiastro/images/whitebalance.png +0 -0
  140. setiastro/images/wimi_icon_256x256.png +0 -0
  141. setiastro/images/wimilogo.png +0 -0
  142. setiastro/images/wims.png +0 -0
  143. setiastro/images/wrench_icon.png +0 -0
  144. setiastro/images/xisfliberator.png +0 -0
  145. setiastro/qml/ResourceMonitor.qml +126 -0
  146. setiastro/saspro/__init__.py +20 -0
  147. setiastro/saspro/__main__.py +958 -0
  148. setiastro/saspro/_generated/__init__.py +7 -0
  149. setiastro/saspro/_generated/build_info.py +3 -0
  150. setiastro/saspro/abe.py +1346 -0
  151. setiastro/saspro/abe_preset.py +196 -0
  152. setiastro/saspro/aberration_ai.py +698 -0
  153. setiastro/saspro/aberration_ai_preset.py +224 -0
  154. setiastro/saspro/accel_installer.py +218 -0
  155. setiastro/saspro/accel_workers.py +30 -0
  156. setiastro/saspro/add_stars.py +624 -0
  157. setiastro/saspro/astrobin_exporter.py +1010 -0
  158. setiastro/saspro/astrospike.py +153 -0
  159. setiastro/saspro/astrospike_python.py +1841 -0
  160. setiastro/saspro/autostretch.py +198 -0
  161. setiastro/saspro/backgroundneutral.py +611 -0
  162. setiastro/saspro/batch_convert.py +328 -0
  163. setiastro/saspro/batch_renamer.py +522 -0
  164. setiastro/saspro/blemish_blaster.py +491 -0
  165. setiastro/saspro/blink_comparator_pro.py +3149 -0
  166. setiastro/saspro/bundles.py +61 -0
  167. setiastro/saspro/bundles_dock.py +114 -0
  168. setiastro/saspro/cheat_sheet.py +213 -0
  169. setiastro/saspro/clahe.py +368 -0
  170. setiastro/saspro/comet_stacking.py +1442 -0
  171. setiastro/saspro/common_tr.py +107 -0
  172. setiastro/saspro/config.py +38 -0
  173. setiastro/saspro/config_bootstrap.py +40 -0
  174. setiastro/saspro/config_manager.py +316 -0
  175. setiastro/saspro/continuum_subtract.py +1617 -0
  176. setiastro/saspro/convo.py +1400 -0
  177. setiastro/saspro/convo_preset.py +414 -0
  178. setiastro/saspro/copyastro.py +190 -0
  179. setiastro/saspro/cosmicclarity.py +1589 -0
  180. setiastro/saspro/cosmicclarity_preset.py +407 -0
  181. setiastro/saspro/crop_dialog_pro.py +983 -0
  182. setiastro/saspro/crop_preset.py +189 -0
  183. setiastro/saspro/curve_editor_pro.py +2562 -0
  184. setiastro/saspro/curves_preset.py +375 -0
  185. setiastro/saspro/debayer.py +673 -0
  186. setiastro/saspro/debug_utils.py +29 -0
  187. setiastro/saspro/dnd_mime.py +35 -0
  188. setiastro/saspro/doc_manager.py +2664 -0
  189. setiastro/saspro/exoplanet_detector.py +2166 -0
  190. setiastro/saspro/file_utils.py +284 -0
  191. setiastro/saspro/fitsmodifier.py +748 -0
  192. setiastro/saspro/fix_bom.py +32 -0
  193. setiastro/saspro/free_torch_memory.py +48 -0
  194. setiastro/saspro/frequency_separation.py +1349 -0
  195. setiastro/saspro/function_bundle.py +1596 -0
  196. setiastro/saspro/generate_translations.py +3092 -0
  197. setiastro/saspro/ghs_dialog_pro.py +663 -0
  198. setiastro/saspro/ghs_preset.py +284 -0
  199. setiastro/saspro/graxpert.py +637 -0
  200. setiastro/saspro/graxpert_preset.py +287 -0
  201. setiastro/saspro/gui/__init__.py +0 -0
  202. setiastro/saspro/gui/main_window.py +8792 -0
  203. setiastro/saspro/gui/mixins/__init__.py +33 -0
  204. setiastro/saspro/gui/mixins/dock_mixin.py +375 -0
  205. setiastro/saspro/gui/mixins/file_mixin.py +450 -0
  206. setiastro/saspro/gui/mixins/geometry_mixin.py +503 -0
  207. setiastro/saspro/gui/mixins/header_mixin.py +441 -0
  208. setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
  209. setiastro/saspro/gui/mixins/menu_mixin.py +390 -0
  210. setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
  211. setiastro/saspro/gui/mixins/toolbar_mixin.py +1619 -0
  212. setiastro/saspro/gui/mixins/update_mixin.py +323 -0
  213. setiastro/saspro/gui/mixins/view_mixin.py +435 -0
  214. setiastro/saspro/gui/statistics_dialog.py +47 -0
  215. setiastro/saspro/halobgon.py +488 -0
  216. setiastro/saspro/header_viewer.py +448 -0
  217. setiastro/saspro/headless_utils.py +88 -0
  218. setiastro/saspro/histogram.py +756 -0
  219. setiastro/saspro/history_explorer.py +941 -0
  220. setiastro/saspro/i18n.py +168 -0
  221. setiastro/saspro/image_combine.py +417 -0
  222. setiastro/saspro/image_peeker_pro.py +1604 -0
  223. setiastro/saspro/imageops/__init__.py +37 -0
  224. setiastro/saspro/imageops/mdi_snap.py +292 -0
  225. setiastro/saspro/imageops/scnr.py +36 -0
  226. setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
  227. setiastro/saspro/imageops/stretch.py +236 -0
  228. setiastro/saspro/isophote.py +1182 -0
  229. setiastro/saspro/layers.py +208 -0
  230. setiastro/saspro/layers_dock.py +714 -0
  231. setiastro/saspro/lazy_imports.py +193 -0
  232. setiastro/saspro/legacy/__init__.py +2 -0
  233. setiastro/saspro/legacy/image_manager.py +2360 -0
  234. setiastro/saspro/legacy/numba_utils.py +3676 -0
  235. setiastro/saspro/legacy/xisf.py +1213 -0
  236. setiastro/saspro/linear_fit.py +537 -0
  237. setiastro/saspro/live_stacking.py +1854 -0
  238. setiastro/saspro/log_bus.py +5 -0
  239. setiastro/saspro/logging_config.py +460 -0
  240. setiastro/saspro/luminancerecombine.py +510 -0
  241. setiastro/saspro/main_helpers.py +201 -0
  242. setiastro/saspro/mask_creation.py +1086 -0
  243. setiastro/saspro/masks_core.py +56 -0
  244. setiastro/saspro/mdi_widgets.py +353 -0
  245. setiastro/saspro/memory_utils.py +666 -0
  246. setiastro/saspro/metadata_patcher.py +75 -0
  247. setiastro/saspro/mfdeconv.py +3909 -0
  248. setiastro/saspro/mfdeconv_earlystop.py +71 -0
  249. setiastro/saspro/mfdeconvcudnn.py +3312 -0
  250. setiastro/saspro/mfdeconvsport.py +2459 -0
  251. setiastro/saspro/minorbodycatalog.py +567 -0
  252. setiastro/saspro/morphology.py +407 -0
  253. setiastro/saspro/multiscale_decomp.py +1747 -0
  254. setiastro/saspro/nbtorgb_stars.py +541 -0
  255. setiastro/saspro/numba_utils.py +3145 -0
  256. setiastro/saspro/numba_warmup.py +141 -0
  257. setiastro/saspro/ops/__init__.py +9 -0
  258. setiastro/saspro/ops/command_help_dialog.py +623 -0
  259. setiastro/saspro/ops/command_runner.py +217 -0
  260. setiastro/saspro/ops/commands.py +1594 -0
  261. setiastro/saspro/ops/script_editor.py +1105 -0
  262. setiastro/saspro/ops/scripts.py +1476 -0
  263. setiastro/saspro/ops/settings.py +637 -0
  264. setiastro/saspro/parallel_utils.py +554 -0
  265. setiastro/saspro/pedestal.py +121 -0
  266. setiastro/saspro/perfect_palette_picker.py +1105 -0
  267. setiastro/saspro/pipeline.py +110 -0
  268. setiastro/saspro/pixelmath.py +1604 -0
  269. setiastro/saspro/plate_solver.py +2445 -0
  270. setiastro/saspro/project_io.py +797 -0
  271. setiastro/saspro/psf_utils.py +136 -0
  272. setiastro/saspro/psf_viewer.py +549 -0
  273. setiastro/saspro/pyi_rthook_astroquery.py +95 -0
  274. setiastro/saspro/remove_green.py +331 -0
  275. setiastro/saspro/remove_stars.py +1599 -0
  276. setiastro/saspro/remove_stars_preset.py +446 -0
  277. setiastro/saspro/resources.py +503 -0
  278. setiastro/saspro/rgb_combination.py +208 -0
  279. setiastro/saspro/rgb_extract.py +19 -0
  280. setiastro/saspro/rgbalign.py +723 -0
  281. setiastro/saspro/runtime_imports.py +7 -0
  282. setiastro/saspro/runtime_torch.py +754 -0
  283. setiastro/saspro/save_options.py +73 -0
  284. setiastro/saspro/selective_color.py +1611 -0
  285. setiastro/saspro/sfcc.py +1472 -0
  286. setiastro/saspro/shortcuts.py +3116 -0
  287. setiastro/saspro/signature_insert.py +1102 -0
  288. setiastro/saspro/stacking_suite.py +19066 -0
  289. setiastro/saspro/star_alignment.py +7380 -0
  290. setiastro/saspro/star_alignment_preset.py +329 -0
  291. setiastro/saspro/star_metrics.py +49 -0
  292. setiastro/saspro/star_spikes.py +765 -0
  293. setiastro/saspro/star_stretch.py +507 -0
  294. setiastro/saspro/stat_stretch.py +538 -0
  295. setiastro/saspro/status_log_dock.py +78 -0
  296. setiastro/saspro/subwindow.py +3407 -0
  297. setiastro/saspro/supernovaasteroidhunter.py +1719 -0
  298. setiastro/saspro/swap_manager.py +134 -0
  299. setiastro/saspro/torch_backend.py +89 -0
  300. setiastro/saspro/torch_rejection.py +434 -0
  301. setiastro/saspro/translations/all_source_strings.json +4726 -0
  302. setiastro/saspro/translations/ar_translations.py +4096 -0
  303. setiastro/saspro/translations/de_translations.py +3728 -0
  304. setiastro/saspro/translations/es_translations.py +4169 -0
  305. setiastro/saspro/translations/fr_translations.py +4090 -0
  306. setiastro/saspro/translations/hi_translations.py +3803 -0
  307. setiastro/saspro/translations/integrate_translations.py +271 -0
  308. setiastro/saspro/translations/it_translations.py +4728 -0
  309. setiastro/saspro/translations/ja_translations.py +3834 -0
  310. setiastro/saspro/translations/pt_translations.py +3847 -0
  311. setiastro/saspro/translations/ru_translations.py +3082 -0
  312. setiastro/saspro/translations/saspro_ar.qm +0 -0
  313. setiastro/saspro/translations/saspro_ar.ts +16019 -0
  314. setiastro/saspro/translations/saspro_de.qm +0 -0
  315. setiastro/saspro/translations/saspro_de.ts +14548 -0
  316. setiastro/saspro/translations/saspro_es.qm +0 -0
  317. setiastro/saspro/translations/saspro_es.ts +16202 -0
  318. setiastro/saspro/translations/saspro_fr.qm +0 -0
  319. setiastro/saspro/translations/saspro_fr.ts +15870 -0
  320. setiastro/saspro/translations/saspro_hi.qm +0 -0
  321. setiastro/saspro/translations/saspro_hi.ts +14855 -0
  322. setiastro/saspro/translations/saspro_it.qm +0 -0
  323. setiastro/saspro/translations/saspro_it.ts +19046 -0
  324. setiastro/saspro/translations/saspro_ja.qm +0 -0
  325. setiastro/saspro/translations/saspro_ja.ts +14980 -0
  326. setiastro/saspro/translations/saspro_pt.qm +0 -0
  327. setiastro/saspro/translations/saspro_pt.ts +15024 -0
  328. setiastro/saspro/translations/saspro_ru.qm +0 -0
  329. setiastro/saspro/translations/saspro_ru.ts +11835 -0
  330. setiastro/saspro/translations/saspro_sw.qm +0 -0
  331. setiastro/saspro/translations/saspro_sw.ts +15237 -0
  332. setiastro/saspro/translations/saspro_uk.qm +0 -0
  333. setiastro/saspro/translations/saspro_uk.ts +15248 -0
  334. setiastro/saspro/translations/saspro_zh.qm +0 -0
  335. setiastro/saspro/translations/saspro_zh.ts +15289 -0
  336. setiastro/saspro/translations/sw_translations.py +3897 -0
  337. setiastro/saspro/translations/uk_translations.py +3929 -0
  338. setiastro/saspro/translations/zh_translations.py +3910 -0
  339. setiastro/saspro/versioning.py +77 -0
  340. setiastro/saspro/view_bundle.py +1558 -0
  341. setiastro/saspro/wavescale_hdr.py +645 -0
  342. setiastro/saspro/wavescale_hdr_preset.py +101 -0
  343. setiastro/saspro/wavescalede.py +680 -0
  344. setiastro/saspro/wavescalede_preset.py +230 -0
  345. setiastro/saspro/wcs_update.py +374 -0
  346. setiastro/saspro/whitebalance.py +513 -0
  347. setiastro/saspro/widgets/__init__.py +48 -0
  348. setiastro/saspro/widgets/common_utilities.py +306 -0
  349. setiastro/saspro/widgets/graphics_views.py +122 -0
  350. setiastro/saspro/widgets/image_utils.py +518 -0
  351. setiastro/saspro/widgets/minigame/game.js +991 -0
  352. setiastro/saspro/widgets/minigame/index.html +53 -0
  353. setiastro/saspro/widgets/minigame/style.css +241 -0
  354. setiastro/saspro/widgets/preview_dialogs.py +280 -0
  355. setiastro/saspro/widgets/resource_monitor.py +263 -0
  356. setiastro/saspro/widgets/spinboxes.py +290 -0
  357. setiastro/saspro/widgets/themed_buttons.py +13 -0
  358. setiastro/saspro/widgets/wavelet_utils.py +331 -0
  359. setiastro/saspro/wimi.py +7996 -0
  360. setiastro/saspro/wims.py +578 -0
  361. setiastro/saspro/window_shelf.py +185 -0
  362. setiastro/saspro/xisf.py +1213 -0
  363. setiastrosuitepro-1.6.5.post3.dist-info/METADATA +278 -0
  364. setiastrosuitepro-1.6.5.post3.dist-info/RECORD +368 -0
  365. setiastrosuitepro-1.6.5.post3.dist-info/WHEEL +4 -0
  366. setiastrosuitepro-1.6.5.post3.dist-info/entry_points.txt +6 -0
  367. setiastrosuitepro-1.6.5.post3.dist-info/licenses/LICENSE +674 -0
  368. setiastrosuitepro-1.6.5.post3.dist-info/licenses/license.txt +2580 -0
@@ -0,0 +1,2562 @@
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
+ # Connect to active document change signal
1026
+ if hasattr(self._main, "currentDocumentChanged"):
1027
+ self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
1028
+
1029
+ self._preview_img = None # downsampled float01
1030
+ self._full_img = None # full-res float01
1031
+ self._pix = None
1032
+ self._zoom = 0.25
1033
+ self._panning = False
1034
+ self._pan_start = QPointF()
1035
+ self._did_initial_fit = False
1036
+ self._apply_when_ready = False
1037
+ self._preview_orig = None # downsampled original
1038
+ self._preview_proc = None # downsampled processed (latest)
1039
+ self._show_proc = False # A/B: False=show original, True=show processed
1040
+ self._cdf = None
1041
+ self._cdf_bins = 1024
1042
+ self._cdf_total = 0
1043
+
1044
+ self._clip_scale = 1.0 # preview→full multiplier
1045
+ self._cdf_total_full = 0 # total pixels in full image (H*W)
1046
+ self._cdf_total_preview = 0 # total pixels in preview (H*W)
1047
+
1048
+ # --- UI ---
1049
+ main = QVBoxLayout(self) # ⬅️ root is now vertical
1050
+ top = QHBoxLayout() # ⬅️ holds the two columns
1051
+
1052
+ # Left column: CurveEditor + mode + buttons
1053
+ left = QVBoxLayout()
1054
+ self.editor = CurveEditor(self)
1055
+ left.addWidget(self.editor)
1056
+
1057
+ # mode radio
1058
+ self.mode_group = QButtonGroup(self)
1059
+ self.mode_group.setExclusive(True)
1060
+
1061
+ row1 = QHBoxLayout()
1062
+ for m in ("K (Brightness)", "R", "G", "B"):
1063
+ rb = QRadioButton(m, self)
1064
+ if m == "K (Brightness)":
1065
+ rb.setChecked(True) # default selection
1066
+ self.mode_group.addButton(rb)
1067
+ row1.addWidget(rb)
1068
+
1069
+ row2 = QHBoxLayout()
1070
+ for m in ("L*", "a*", "b*", "Chroma", "Saturation"):
1071
+ rb = QRadioButton(m, self)
1072
+ self.mode_group.addButton(rb)
1073
+ row2.addWidget(rb)
1074
+
1075
+ left.addLayout(row1)
1076
+ left.addLayout(row2)
1077
+
1078
+ # Map UI label → internal key
1079
+ self._mode_key_map = {
1080
+ "K (Brightness)":"K", "R":"R", "G":"G", "B":"B",
1081
+ "L*":"L*", "a*":"a*", "b*":"b*", "Chroma":"Chroma", "Saturation":"Saturation"
1082
+ }
1083
+
1084
+ # each entry holds points in *normalized* space [(x,y) in 0..1 up, endpoints included]
1085
+ self._curves_store = { k: [(0.0,0.0),(1.0,1.0)] for k in self._mode_key_map.values() }
1086
+
1087
+ # remember current mode key
1088
+ self._current_mode_key = "K"
1089
+
1090
+ # when user changes the radio, stash current points and load new
1091
+ for b in self.mode_group.buttons():
1092
+ b.toggled.connect(self._on_mode_toggled)
1093
+
1094
+
1095
+ rowp = QHBoxLayout()
1096
+ self.btn_presets = QToolButton(self)
1097
+ self.btn_presets.setText(self.tr("Presets"))
1098
+ self.btn_presets.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
1099
+ rowp.addWidget(self.btn_presets)
1100
+
1101
+ self.btn_save_preset = QToolButton(self)
1102
+ self.btn_save_preset.setText(self.tr("Save as Preset..."))
1103
+ self.btn_save_preset.clicked.connect(self._save_current_as_preset)
1104
+ rowp.addWidget(self.btn_save_preset)
1105
+ left.addLayout(rowp)
1106
+
1107
+ # status
1108
+ self.lbl_status = QLabel("", self)
1109
+ self.lbl_status.setStyleSheet("color: gray;")
1110
+
1111
+
1112
+ # buttons
1113
+ rowb = QHBoxLayout()
1114
+ self.btn_preview = QToolButton(self)
1115
+ self.btn_preview.setText(self.tr("Toggle Preview"))
1116
+ self.btn_preview.setCheckable(True) # ⬅️ toggle
1117
+ self.btn_apply = QPushButton(self.tr("Apply to Document"))
1118
+ self.btn_reset = QToolButton(); self.btn_reset.setText(self.tr("Reset"))
1119
+ rowb.addWidget(self.btn_preview); rowb.addWidget(self.btn_apply); rowb.addWidget(self.btn_reset)
1120
+ left.addLayout(rowb)
1121
+ left.addStretch(1)
1122
+ top.addLayout(left, 0)
1123
+
1124
+ # Right column: preview w/ zoom/pan
1125
+ right = QVBoxLayout()
1126
+ zoombar = QHBoxLayout()
1127
+ zoombar.addStretch(1)
1128
+
1129
+ self.btn_zoom_out = themed_toolbtn("zoom-out", self.tr("Zoom Out"))
1130
+ self.btn_zoom_in = themed_toolbtn("zoom-in", self.tr("Zoom In"))
1131
+ self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", self.tr("Fit to Preview"))
1132
+
1133
+ zoombar.addWidget(self.btn_zoom_out)
1134
+ zoombar.addWidget(self.btn_zoom_in)
1135
+ zoombar.addWidget(self.btn_zoom_fit)
1136
+
1137
+ right.addLayout(zoombar)
1138
+
1139
+ self.scroll = QScrollArea()
1140
+ self.scroll.setWidgetResizable(True)
1141
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
1142
+ self.scroll.viewport().installEventFilter(self)
1143
+ self.label = ImageLabel(self)
1144
+ self.label.setAlignment(Qt.AlignmentFlag.AlignCenter)
1145
+ self.label.mouseMoved.connect(self._on_preview_mouse_moved)
1146
+ self.label.installEventFilter(self)
1147
+ self.scroll.setWidget(self.label)
1148
+ right.addWidget(self.scroll, 1)
1149
+ top.addLayout(right, 1)
1150
+
1151
+ main.addLayout(top, 1)
1152
+
1153
+ # subtle separator line
1154
+
1155
+ sep = QFrame(self)
1156
+ sep.setFrameShape(QFrame.Shape.HLine)
1157
+ sep.setFrameShadow(QFrame.Shadow.Sunken)
1158
+ main.addWidget(sep)
1159
+
1160
+ # bottom status row
1161
+ status_row = QHBoxLayout()
1162
+ self.lbl_status = QLabel("", self) # ⬅️ re-create here at bottom
1163
+ self.lbl_status.setObjectName("curvesStatus")
1164
+ self.lbl_status.setWordWrap(True)
1165
+ self.lbl_status.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
1166
+ self.lbl_status.setStyleSheet("color: #bbb;") # or keep your theme color
1167
+
1168
+ # keep it from growing tall: ~2 lines max
1169
+ line_h = self.fontMetrics().height()
1170
+ self.lbl_status.setMaximumHeight(int(line_h * 2.2))
1171
+ self.lbl_status.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Maximum)
1172
+
1173
+ status_row.addWidget(self.lbl_status, 1)
1174
+
1175
+ main.addLayout(status_row, 0)
1176
+
1177
+ # wire
1178
+ self.btn_preview.clicked.connect(self._run_preview)
1179
+ self.btn_preview.toggled.connect(self._toggle_preview) # ⬅️ new
1180
+ self.btn_apply.clicked.connect(self._apply)
1181
+ self.btn_reset.clicked.connect(self._reset_curve)
1182
+ self.btn_zoom_out.clicked.connect(lambda: self._set_zoom(self._zoom / 1.25))
1183
+ self.btn_zoom_in.clicked.connect(lambda: self._set_zoom(self._zoom * 1.25))
1184
+ self.btn_zoom_fit.clicked.connect(self._fit)
1185
+
1186
+ # When curve changes, do a quick preview (non-blocking: downsampled in-UI)
1187
+ # You can switch to threaded small preview if images are huge.
1188
+ self.editor.setPreviewCallback(self._on_editor_curve_changed)
1189
+
1190
+ # seed images
1191
+ self._load_from_doc()
1192
+ QTimer.singleShot(0, self._fit_after_load)
1193
+ self.editor.setSymmetryCallback(self._on_symmetry_pick)
1194
+ self.btn_preview.setChecked(True)
1195
+
1196
+ self.main_window = self._find_main_window()
1197
+ self.source_view = None
1198
+ try:
1199
+ # Common cases: parent is a subwindow or has a doc_view
1200
+ if hasattr(self.parent(), "view"):
1201
+ self.source_view = self.parent().view
1202
+ elif hasattr(self.parent(), "doc_view"):
1203
+ self.source_view = self.parent().doc_view
1204
+ except Exception:
1205
+ pass
1206
+
1207
+ if self.main_window is not None:
1208
+ print(f"[Replay] CurvesDialogPro bound to main_window={id(self.main_window)}, "
1209
+ f"source_view={getattr(self.source_view,'view_id',None)}")
1210
+
1211
+ self._rebuild_presets_menu()
1212
+
1213
+ def _on_editor_curve_changed(self, _lut8=None):
1214
+ """
1215
+ Called on every editor redraw/drag. Persist the currently edited curve
1216
+ into the store, refresh overlays, and do a realtime preview.
1217
+ """
1218
+ try:
1219
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
1220
+ except Exception:
1221
+ pass
1222
+ # show the true shapes of other channels too
1223
+ self._refresh_overlays()
1224
+ # now build from *all* current curves (including the just-edited one)
1225
+ self._quick_preview()
1226
+
1227
+
1228
+ def _active_mode_key(self) -> str:
1229
+ for b in self.mode_group.buttons():
1230
+ if b.isChecked():
1231
+ return self._mode_key_map.get(b.text(), "K")
1232
+ return "K"
1233
+
1234
+ def _editor_points_norm(self) -> list[tuple[float,float]]:
1235
+ # uses your existing _collect_points_norm_from_editor()
1236
+ return self._collect_points_norm_from_editor()
1237
+
1238
+ def _editor_set_from_norm(self, ptsN: list[tuple[float,float]]):
1239
+ # convert to scene and strip endpoints
1240
+ pts_scene = _points_norm_to_scene(ptsN)
1241
+ filt = [(x,y) for (x,y) in pts_scene if x > 1e-6 and x < 360-1e-6]
1242
+ self.editor.setControlHandles(filt)
1243
+ self.editor.updateCurve()
1244
+
1245
+ def _on_mode_toggled(self, checked: bool):
1246
+ if not checked:
1247
+ return
1248
+ # 1) save the curve we were editing
1249
+ prev = self._current_mode_key
1250
+ try:
1251
+ self._curves_store[prev] = self._editor_points_norm()
1252
+ except Exception:
1253
+ pass
1254
+
1255
+ # 2) load the newly selected curve
1256
+ key = self._active_mode_key()
1257
+ self._current_mode_key = key
1258
+ self._editor_set_from_norm(self._curves_store.get(key, [(0.0,0.0),(1.0,1.0)]))
1259
+
1260
+ # 3) draw overlays for reference
1261
+ self._refresh_overlays()
1262
+ # 4) refresh preview immediately
1263
+ self._quick_preview()
1264
+
1265
+ def _refresh_overlays(self):
1266
+ # Build overlay polylines in scene coords for all modes except the active one
1267
+ overlays = {}
1268
+ for key, ptsN in self._curves_store.items():
1269
+ if not ptsN:
1270
+ continue
1271
+ pts_scene = _points_norm_to_scene(ptsN)
1272
+ # keep full polyline (including endpoints) to show exact shape
1273
+ overlays[key] = pts_scene
1274
+ self.editor.setOverlayCurves(overlays, self._current_mode_key)
1275
+
1276
+ def _lut01_from_points_norm(self, ptsN: list[tuple[float,float]], size: int = 65536) -> np.ndarray:
1277
+ # ptsN are (x,y) in 0..1 (up). Convert to scene space and build a smooth monotone interpolator.
1278
+ pts_scene = _points_norm_to_scene(ptsN) # [(x:[0..360], y:[0..360 down])]
1279
+ if len(pts_scene) < 2:
1280
+ return np.linspace(0.0, 1.0, size, dtype=np.float32)
1281
+
1282
+ xs = np.array([p[0] for p in pts_scene], dtype=np.float64)
1283
+ ys = np.array([p[1] for p in pts_scene], dtype=np.float64)
1284
+
1285
+ # Ensure strictly increasing X (protect against accidental ties)
1286
+ m = np.diff(xs) <= 0
1287
+ if np.any(m):
1288
+ xs = xs + np.linspace(0, 1e-3, len(xs), dtype=np.float64)
1289
+
1290
+ ys = 360.0 - ys # flip to “up”
1291
+
1292
+ inp = np.linspace(0.0, 360.0, size, dtype=np.float64)
1293
+ try:
1294
+ from scipy.interpolate import PchipInterpolator
1295
+ f = PchipInterpolator(xs, ys, extrapolate=True)
1296
+ out = f(inp)
1297
+ except Exception:
1298
+ # Fallback to linear if SciPy missing or bad control set
1299
+ out = np.interp(inp, xs, ys)
1300
+
1301
+ out = np.clip(out / 360.0, 0.0, 1.0).astype(np.float32)
1302
+ return out
1303
+
1304
+
1305
+ def _build_all_active_luts(self) -> dict[str, np.ndarray]:
1306
+ """
1307
+ Build LUTs for every curve.
1308
+
1309
+ IMPORTANT:
1310
+ - The *active* curve (the one shown in the editor right now) must be built
1311
+ from the editor's actual spline so we DO NOT re-insert (0,0)/(1,1).
1312
+ - All *other* curves (stored in _curves_store) can still be built from
1313
+ their normalized points (those are allowed to have endpoints).
1314
+ """
1315
+ luts: dict[str, np.ndarray] = {}
1316
+ active_key = self._current_mode_key
1317
+
1318
+ # 1) ACTIVE curve → from editor spline (no normalization, no auto endpoints)
1319
+ fn = getattr(self.editor, "getCurveFunction", None)
1320
+ if callable(fn):
1321
+ f = fn()
1322
+ if f is not None:
1323
+ luts[active_key] = build_curve_lut(f, size=65536)
1324
+
1325
+ # 2) OTHER curves → from stored normalized points (old behavior)
1326
+ for key, pts in self._curves_store.items():
1327
+ if key == active_key:
1328
+ continue # already done above
1329
+ # skip exact linear
1330
+ if isinstance(pts, (list, tuple)) and len(pts) == 2 and pts[0] == (0.0, 0.0) and pts[1] == (1.0, 1.0):
1331
+ continue
1332
+ luts[key] = self._lut01_from_points_norm(pts, size=65536)
1333
+
1334
+ return luts
1335
+
1336
+ def _remember_as_last_action(self):
1337
+ """
1338
+ Capture the current curve as a replayable headless command.
1339
+
1340
+ We store it exactly like other tools:
1341
+
1342
+ command_id = "curves"
1343
+ preset = {mode, shape, amount, points_scene, _ops?}
1344
+
1345
+ where `_ops` (if present) is a full, tool-agnostic op dict
1346
+ from export_preview_ops(), used for replay-on-base.
1347
+ """
1348
+ mw = self._find_main_window()
1349
+ if mw is None:
1350
+ print("[Replay] Curves: no main_window; not storing last action.")
1351
+ return
1352
+
1353
+ # 1) mode label
1354
+ btn = self.mode_group.checkedButton() if hasattr(self, "mode_group") else None
1355
+ mode_label = btn.text() if btn is not None else "K (Brightness)"
1356
+ mode_label = _norm_mode(mode_label)
1357
+
1358
+ # 2) collect control handles → scene points
1359
+ if hasattr(self.editor, "getControlHandles"):
1360
+ handles = self.editor.getControlHandles()
1361
+ elif hasattr(self.editor, "controlHandles"):
1362
+ handles = self.editor.controlHandles()
1363
+ else:
1364
+ handles = []
1365
+
1366
+ pts_scene: list[tuple[float, float]] = []
1367
+ for h in handles:
1368
+ try:
1369
+ x = float(h.x()); y = float(h.y())
1370
+ except Exception:
1371
+ try:
1372
+ x = float(h[0]); y = float(h[1])
1373
+ except Exception:
1374
+ continue
1375
+ pts_scene.append((x, y))
1376
+
1377
+ if not pts_scene:
1378
+ pts_scene = [(0.0, 360.0), (360.0, 0.0)]
1379
+
1380
+ pts_scene = _sanitize_scene_points(pts_scene)
1381
+
1382
+ core_preset = {
1383
+ "mode": mode_label,
1384
+ "shape": "custom",
1385
+ "amount": 1.0,
1386
+ "points_scene": pts_scene,
1387
+ }
1388
+
1389
+ # 3) Attach a full op dict for exact replay on base, if possible
1390
+ op = None
1391
+ try:
1392
+ op = self.export_preview_ops()
1393
+ except Exception:
1394
+ op = None
1395
+
1396
+ if op:
1397
+ core_preset["_ops"] = op
1398
+
1399
+ try:
1400
+ # This is the same pattern used by Statistical Stretch etc.
1401
+ mw._remember_last_headless_command("curves", core_preset, description="Curves")
1402
+
1403
+ # Enable/update the replay button for the originating view
1404
+ source_view = getattr(self, "source_view", None)
1405
+ if hasattr(mw, "_update_replay_button"):
1406
+ mw._update_replay_button(source_view)
1407
+
1408
+ print(
1409
+ f"[Replay] Curves: stored last action; "
1410
+ f"has_ops={bool(op)} mode={mode_label}"
1411
+ )
1412
+ except Exception as e:
1413
+ print("Curves: failed to remember last action:", e)
1414
+
1415
+
1416
+
1417
+
1418
+ def _apply_all_curves_once(self, img01: np.ndarray, luts: dict[str, np.ndarray]) -> np.ndarray:
1419
+ # 1) RGB domain — K then per-channel compose
1420
+ out = img01
1421
+ if out.ndim == 2: # mono → treat as K only
1422
+ lutK = luts.get("K")
1423
+ if lutK is not None:
1424
+ out = _np_apply_lut_channel(out, lutK)
1425
+ # nothing else applies meaningfully to mono
1426
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
1427
+
1428
+ # RGB image
1429
+ # compose helper: lut2(lut1(x))
1430
+ def _compose_lut(a: np.ndarray | None, b: np.ndarray | None):
1431
+ if a is None: return b
1432
+ if b is None: return a
1433
+ # fast index compose on [0..1] sampled arrays
1434
+ N = len(a)
1435
+ idx = np.clip((a * (N - 1)).astype(np.int32), 0, N - 1)
1436
+ return b[idx]
1437
+
1438
+ lutK = luts.get("K")
1439
+ lutR = _compose_lut(lutK, luts.get("R"))
1440
+ lutG = _compose_lut(lutK, luts.get("G"))
1441
+ lutB = _compose_lut(lutK, luts.get("B"))
1442
+
1443
+ # If no per-channel, still apply K uniformly
1444
+ if lutR is None and lutG is None and lutB is None and lutK is not None:
1445
+ out = _np_apply_lut_rgb(out, lutK)
1446
+ else:
1447
+ out = out.copy()
1448
+ if lutR is not None: out[...,0] = _np_apply_lut_channel(out[...,0], lutR)
1449
+ elif lutK is not None: out[...,0] = _np_apply_lut_channel(out[...,0], lutK)
1450
+ if lutG is not None: out[...,1] = _np_apply_lut_channel(out[...,1], lutG)
1451
+ elif lutK is not None: out[...,1] = _np_apply_lut_channel(out[...,1], lutK)
1452
+ if lutB is not None: out[...,2] = _np_apply_lut_channel(out[...,2], lutB)
1453
+ elif lutK is not None: out[...,2] = _np_apply_lut_channel(out[...,2], lutK)
1454
+
1455
+ # 2) Lab family
1456
+ need_lab = any(k in luts for k in ("L*","a*","b*","Chroma"))
1457
+ if need_lab:
1458
+ xyz = _np_rgb_to_xyz(out); lab = _np_xyz_to_lab(xyz)
1459
+ if "L*" in luts:
1460
+ L = np.clip(lab[...,0]/100.0, 0.0, 1.0)
1461
+ L = _np_apply_lut_channel(L, luts["L*"]); lab[...,0] = L*100.0
1462
+ if "a*" in luts:
1463
+ a = lab[...,1]; an = np.clip((a+128.0)/255.0, 0.0, 1.0)
1464
+ an = _np_apply_lut_channel(an, luts["a*"]); lab[...,1] = an*255.0 - 128.0
1465
+ if "b*" in luts:
1466
+ b = lab[...,2]; bn = np.clip((b+128.0)/255.0, 0.0, 1.0)
1467
+ bn = _np_apply_lut_channel(bn, luts["b*"]); lab[...,2] = bn*255.0 - 128.0
1468
+ if "Chroma" in luts:
1469
+ a = lab[...,1]; b = lab[...,2]
1470
+ C = np.sqrt(a*a + b*b); Cn = np.clip(C/200.0, 0.0, 1.0)
1471
+ Cn = _np_apply_lut_channel(Cn, luts["Chroma"]); Cnew = Cn*200.0
1472
+ ratio = np.divide(Cnew, C, out=np.ones_like(Cnew), where=(C>0))
1473
+ lab[...,1] = a*ratio; lab[...,2] = b*ratio
1474
+ out = _np_xyz_to_rgb(_np_lab_to_xyz(lab))
1475
+
1476
+ # 3) Saturation (HSV)
1477
+ if "Saturation" in luts:
1478
+ hsv = _np_rgb_to_hsv(out)
1479
+ S = np.clip(hsv[...,1], 0.0, 1.0)
1480
+ hsv[...,1] = _np_apply_lut_channel(S, luts["Saturation"])
1481
+ out = _np_hsv_to_rgb(hsv)
1482
+
1483
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
1484
+
1485
+
1486
+ def _fit_after_load(self, tries: int = 0):
1487
+ """
1488
+ Run Fit-to-Preview once the dialog is visible, the pixmap is ready,
1489
+ and the viewport knows its final size. Retries a few ticks if needed.
1490
+ """
1491
+ if self._did_initial_fit:
1492
+ return
1493
+
1494
+ if not self.isVisible():
1495
+ QTimer.singleShot(0, lambda: self._fit_after_load(tries))
1496
+ return
1497
+
1498
+ # need a pixmap and a live viewport size
1499
+ pm = self.label.pixmap()
1500
+ vp = self.scroll.viewport() if hasattr(self, "scroll") else None
1501
+ have_pm = bool(pm and not pm.isNull())
1502
+ have_sizes = bool(vp and vp.width() > 0 and vp.height() > 0)
1503
+
1504
+ if not (self._pix and have_pm and have_sizes):
1505
+ if tries < 20: # ~ a handful of event-loop turns
1506
+ QTimer.singleShot(15, lambda: self._fit_after_load(tries + 1))
1507
+ return
1508
+
1509
+ # finally do the fit-once
1510
+ self._did_initial_fit = True
1511
+ self._fit()
1512
+
1513
+ def _capture_view(self):
1514
+ """Return (fx, fy, zoom) where f* are fractional center coords in label space."""
1515
+ try:
1516
+ vp = self.scroll.viewport()
1517
+ h = self.scroll.horizontalScrollBar()
1518
+ v = self.scroll.verticalScrollBar()
1519
+ lw = max(1, self.label.width())
1520
+ lh = max(1, self.label.height())
1521
+ cx = h.value() + vp.width() / 2.0
1522
+ cy = v.value() + vp.height() / 2.0
1523
+ fx = float(cx) / float(lw)
1524
+ fy = float(cy) / float(lh)
1525
+ return (fx, fy, float(self._zoom))
1526
+ except Exception:
1527
+ return (0.5, 0.5, float(self._zoom))
1528
+
1529
+ def _restore_view(self, fx: float, fy: float, zoom: float):
1530
+ """Restore zoom and recenter viewport to previous fractional center."""
1531
+ self._set_zoom(zoom) # calls _apply_zoom() internally
1532
+ vp = self.scroll.viewport()
1533
+ h = self.scroll.horizontalScrollBar()
1534
+ v = self.scroll.verticalScrollBar()
1535
+ cx = int(round(fx * max(1, self.label.width())))
1536
+ cy = int(round(fy * max(1, self.label.height())))
1537
+ hx = cx - vp.width() // 2
1538
+ vy = cy - vp.height() // 2
1539
+ # clamp
1540
+ h.setValue(max(h.minimum(), min(h.maximum(), hx)))
1541
+ v.setValue(max(v.minimum(), min(v.maximum(), vy)))
1542
+
1543
+
1544
+ def _build_preview_luma_cdf(self):
1545
+ """Compute a luminance CDF once from the preview image for fast clipping lookups.
1546
+ Also derives a preview→full scaling factor so we can report full-image pixel counts.
1547
+ """
1548
+ img = self._preview_img
1549
+ # defaults / safety
1550
+ bins = int(getattr(self, "_cdf_bins", 1024))
1551
+ self._cdf_bins = bins # remember for consistency
1552
+
1553
+ # reset outputs
1554
+ self._cdf = None
1555
+ self._cdf_total = 0
1556
+ self._cdf_total_preview = 0
1557
+ self._cdf_total_full = 0
1558
+ self._clip_scale = 1.0
1559
+
1560
+ if img is None:
1561
+ return
1562
+
1563
+ # luminance (float32 [0..1])
1564
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
1565
+ luma = img if img.ndim == 2 else img[..., 0]
1566
+ else:
1567
+ luma = (0.2126 * img[..., 0] + 0.7152 * img[..., 1] + 0.0722 * img[..., 2]).astype(np.float32)
1568
+ luma = np.clip(luma, 0.0, 1.0)
1569
+
1570
+ # preview CDF
1571
+ hist, _edges = np.histogram(luma, bins=bins, range=(0.0, 1.0))
1572
+ self._cdf = np.cumsum(hist).astype(np.int64)
1573
+ self._cdf_total_preview = int(luma.size)
1574
+ self._cdf_total = self._cdf_total_preview # backward-compat alias
1575
+
1576
+ # compute full-image pixel count
1577
+ full_pixels = 0
1578
+ if isinstance(getattr(self, "_full_img", None), np.ndarray) and self._full_img.ndim >= 2:
1579
+ Hf, Wf = self._full_img.shape[:2]
1580
+ full_pixels = int(Hf * Wf)
1581
+ if full_pixels <= 0:
1582
+ full_pixels = self._cdf_total_preview # fall back to preview size
1583
+
1584
+ self._cdf_total_full = full_pixels
1585
+ self._clip_scale = (full_pixels / float(self._cdf_total_preview)) if self._cdf_total_preview else 1.0
1586
+
1587
+ def _build_preview_rgb_cdfs(self):
1588
+ """Compute per-channel CDFs (R,G,B) from the preview image for clipping stats."""
1589
+ self._cdf_rgb = None
1590
+ img = self._preview_img
1591
+ if img is None or not (img.ndim == 3 and img.shape[2] >= 3):
1592
+ return
1593
+
1594
+ bins = int(getattr(self, "_cdf_bins", 1024))
1595
+ r = np.clip(img[..., 0].astype(np.float32), 0.0, 1.0)
1596
+ g = np.clip(img[..., 1].astype(np.float32), 0.0, 1.0)
1597
+ b = np.clip(img[..., 2].astype(np.float32), 0.0, 1.0)
1598
+
1599
+ hr, _ = np.histogram(r, bins=bins, range=(0.0, 1.0))
1600
+ hg, _ = np.histogram(g, bins=bins, range=(0.0, 1.0))
1601
+ hb, _ = np.histogram(b, bins=bins, range=(0.0, 1.0))
1602
+
1603
+ self._cdf_rgb = {
1604
+ "r": np.cumsum(hr).astype(np.int64),
1605
+ "g": np.cumsum(hg).astype(np.int64),
1606
+ "b": np.cumsum(hb).astype(np.int64),
1607
+ "total_preview": int(r.size) # same for each channel
1608
+ }
1609
+
1610
+
1611
+ def _on_symmetry_pick(self, u: float, _v: float):
1612
+ self.editor.redistributeHandlesByPivot(u)
1613
+ self._set_status(self.tr("Inflection @ K={0:.3f}").format(u))
1614
+ self._quick_preview()
1615
+
1616
+ def _fit_once(self):
1617
+ if not self._did_initial_fit:
1618
+ self._fit_after_load(0)
1619
+
1620
+ def showEvent(self, ev):
1621
+ super().showEvent(ev)
1622
+ # kick the fit after this show/layout pass
1623
+ QTimer.singleShot(0, self._fit_after_load)
1624
+
1625
+ def _on_preview_mouse_moved(self, x: float, y: float):
1626
+ if self._preview_img is None:
1627
+ return
1628
+
1629
+ mapped = self._map_label_xy_to_image_ij(x, y)
1630
+ if not mapped:
1631
+ # cursor is outside the actual pixmap area
1632
+ self.editor.clearValueLines()
1633
+ self._set_status("")
1634
+ return
1635
+
1636
+ # --- clamp to edges so the last pixel is valid ---
1637
+ img = self._preview_img
1638
+ H, W = img.shape[:2]
1639
+ try:
1640
+ ix, iy = mapped
1641
+ ix = max(0, min(W - 1, int(round(ix))))
1642
+ iy = max(0, min(H - 1, int(round(iy))))
1643
+ except Exception:
1644
+ self.editor.clearValueLines()
1645
+ self._set_status("")
1646
+ return
1647
+ # -------------------------------------------------
1648
+
1649
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
1650
+ v = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
1651
+ v = 0.0 if not np.isfinite(v) else float(np.clip(v, 0.0, 1.0))
1652
+ self.editor.updateValueLines(v, 0.0, 0.0, grayscale=True)
1653
+ self._set_status(self.tr("Cursor ({0}, {1}) K: {2:.3f}").format(ix, iy, v))
1654
+ else:
1655
+ C = img.shape[2]
1656
+ if C >= 3:
1657
+ r, g, b = img[iy, ix, 0], img[iy, ix, 1], img[iy, ix, 2]
1658
+ elif C == 2:
1659
+ r = g = b = img[iy, ix, 0]
1660
+ elif C == 1:
1661
+ r = g = b = img[iy, ix, 0]
1662
+ else:
1663
+ r = g = b = 0.0
1664
+ r = 0.0 if not np.isfinite(r) else float(np.clip(r, 0.0, 1.0))
1665
+ g = 0.0 if not np.isfinite(g) else float(np.clip(g, 0.0, 1.0))
1666
+ b = 0.0 if not np.isfinite(b) else float(np.clip(b, 0.0, 1.0))
1667
+ self.editor.updateValueLines(r, g, b, grayscale=False)
1668
+ self._set_status(self.tr("Cursor ({0}, {1}) R: {2:.3f} G: {3:.3f} B: {4:.3f}").format(ix, iy, r, g, b))
1669
+
1670
+
1671
+ # 1) Put this helper inside CurvesDialogPro (near other helpers)
1672
+ def _map_label_xy_to_image_ij(self, x: float, y: float):
1673
+ """Map label-local coords (x,y) to _preview_img pixel (i,j). Returns (ix, iy) or None."""
1674
+ if self._pix is None:
1675
+ return None
1676
+ pm_disp = self.label.pixmap()
1677
+ if pm_disp is None or pm_disp.isNull():
1678
+ return None
1679
+
1680
+ src_w = self._pix.width() # size of the *source* pixmap (preview image)
1681
+ src_h = self._pix.height()
1682
+ disp_w = pm_disp.width() # size of the *displayed* pixmap on the label
1683
+ disp_h = pm_disp.height()
1684
+ if src_w <= 0 or src_h <= 0 or disp_w <= 0 or disp_h <= 0:
1685
+ return None
1686
+
1687
+ sx = disp_w / float(src_w)
1688
+ sy = disp_h / float(src_h)
1689
+
1690
+ ix = int(x / sx)
1691
+ iy = int(y / sy)
1692
+ if ix < 0 or iy < 0 or ix >= src_w or iy >= src_h:
1693
+ return None
1694
+ return ix, iy
1695
+
1696
+ def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
1697
+ """(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
1698
+ out = []
1699
+ lastx = -1e9
1700
+ for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
1701
+ x = float(np.clip(x, 0.0, 360.0))
1702
+ y = float(np.clip(y, 0.0, 360.0))
1703
+ # strictly increasing X
1704
+ if x <= lastx:
1705
+ x = lastx + 1e-3
1706
+ lastx = x
1707
+ out.append((x / 360.0, 1.0 - (y / 360.0)))
1708
+ # ensure endpoints
1709
+ if not any(abs(px - 0.0) < 1e-6 for px, _ in out): out.insert(0, (0.0, 0.0))
1710
+ if not any(abs(px - 1.0) < 1e-6 for px, _ in out): out.append((1.0, 1.0))
1711
+ # clamp
1712
+ return [(float(np.clip(x,0,1)), float(np.clip(y,0,1))) for (x,y) in out]
1713
+
1714
+ def _collect_points_norm_from_editor(self) -> list[tuple[float,float]]:
1715
+ """
1716
+ Take endpoints+handles from editor => normalized points.
1717
+ NOTE: we do NOT force-add (0,0) and (1,1) here, because that breaks
1718
+ manual black/white endpoints. Presets can still add them later.
1719
+ """
1720
+ pts_scene: list[tuple[float, float]] = []
1721
+ for p in (self.editor.end_points + self.editor.control_points):
1722
+ pos = p.scenePos()
1723
+ pts_scene.append((float(pos.x()), float(pos.y())))
1724
+
1725
+ # convert WITHOUT forced endpoints
1726
+ out: list[tuple[float,float]] = []
1727
+ lastx = -1e9
1728
+ for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
1729
+ x = float(np.clip(x, 0.0, 360.0))
1730
+ y = float(np.clip(y, 0.0, 360.0))
1731
+ if x <= lastx:
1732
+ x = lastx + 1e-3
1733
+ lastx = x
1734
+ out.append((x / 360.0, 1.0 - (y / 360.0)))
1735
+ # no auto (0,0)/(1,1) here
1736
+ return [(float(np.clip(x, 0, 1)), float(np.clip(y, 0, 1))) for (x, y) in out]
1737
+
1738
+ def _save_current_as_preset(self):
1739
+ # get name
1740
+ name, ok = QInputDialog.getText(self, self.tr("Save Curves Preset"), self.tr("Preset name:"))
1741
+ if not ok or not name.strip():
1742
+ return
1743
+ pts_norm = self._collect_points_norm_from_editor()
1744
+ mode = self._current_mode()
1745
+ if save_custom_preset(name.strip(), mode, pts_norm):
1746
+ self._set_status(self.tr("Saved preset “{0}”.").format(name.strip()))
1747
+ self._rebuild_presets_menu()
1748
+ else:
1749
+ QMessageBox.warning(self, self.tr("Save failed"), self.tr("Could not save preset."))
1750
+
1751
+ def _rebuild_presets_menu(self):
1752
+ m = QMenu(self)
1753
+ # Built-in shapes under K (Brightness)
1754
+ builtins = [
1755
+ ("Linear", {"mode": "K (Brightness)", "shape": "linear"}),
1756
+ ("S-Curve (mild)", {"mode": "K (Brightness)", "shape": "s_mild", "amount": 1.0}),
1757
+ ("S-Curve (medium)", {"mode": "K (Brightness)", "shape": "s_med", "amount": 1.0}),
1758
+ ("S-Curve (strong)", {"mode": "K (Brightness)", "shape": "s_strong","amount": 1.0}),
1759
+ ("Lift Shadows", {"mode": "K (Brightness)", "shape": "lift_shadows", "amount": 1.0}),
1760
+ ("Crush Shadows", {"mode": "K (Brightness)", "shape": "crush_shadows","amount": 1.0}),
1761
+ ("Fade Blacks", {"mode": "K (Brightness)", "shape": "fade_blacks", "amount": 1.0}),
1762
+ ("Rolloff Highlights", {"mode": "K (Brightness)", "shape": "rolloff_highlights","amount": 1.0}),
1763
+ ("Flatten", {"mode": "K (Brightness)", "shape": "flatten", "amount": 1.0}),
1764
+ ]
1765
+ if builtins:
1766
+ mb = m.addMenu(self.tr("Built-ins"))
1767
+ for label, preset in builtins:
1768
+ act = mb.addAction(label)
1769
+ act.triggered.connect(lambda _=False, p=preset: self._apply_preset_dict(p))
1770
+
1771
+ # Custom presets (from QSettings)
1772
+ customs = list_custom_presets()
1773
+ if customs:
1774
+ mc = m.addMenu(self.tr("Custom"))
1775
+ for p in sorted(customs, key=lambda d: d.get("name","").lower()):
1776
+ act = mc.addAction(p.get("name","(unnamed)"))
1777
+ act.triggered.connect(lambda _=False, pp=p: self._apply_preset_dict(pp))
1778
+ mc.addSeparator()
1779
+ act_manage = mc.addAction(self.tr("Manage…"))
1780
+ act_manage.triggered.connect(self._open_manage_customs_dialog) # optional (see below)
1781
+ else:
1782
+ m.addAction(self.tr("(No custom presets yet)")).setEnabled(False)
1783
+
1784
+ self.btn_presets.setMenu(m)
1785
+
1786
+ def _open_manage_customs_dialog(self):
1787
+ # optional: quick-and-dirty remover
1788
+ customs = list_custom_presets()
1789
+ if not customs:
1790
+ QMessageBox.information(self, self.tr("Manage Presets"), self.tr("No custom presets."))
1791
+ return
1792
+ names = [p.get("name","") for p in customs]
1793
+ name, ok = QInputDialog.getItem(self, self.tr("Delete Preset"), self.tr("Choose preset to delete:"), names, 0, False)
1794
+ if ok and name:
1795
+ from setiastro.saspro.curves_preset import delete_custom_preset
1796
+ if delete_custom_preset(name):
1797
+ self._rebuild_presets_menu()
1798
+
1799
+
1800
+ # ----- active document change -----
1801
+ def _on_active_doc_changed(self, doc):
1802
+ """Called when user clicks a different image window."""
1803
+ if doc is None or getattr(doc, "image", None) is None:
1804
+ return
1805
+ self.doc = doc
1806
+ self._load_from_doc()
1807
+ QTimer.singleShot(0, self._fit_after_load)
1808
+
1809
+ # ----- data -----
1810
+ def _load_from_doc(self):
1811
+ img = self.doc.image
1812
+ if img is None:
1813
+ QMessageBox.information(self, self.tr("No image"), self.tr("Open an image first."))
1814
+ return
1815
+ arr = np.asarray(img)
1816
+ # normalize to float01 gently
1817
+ if arr.dtype.kind in "ui":
1818
+ arr = arr.astype(np.float32) / np.iinfo(arr.dtype).max
1819
+ elif arr.dtype.kind == "f":
1820
+ mx = float(arr.max()) if arr.size else 1.0
1821
+ arr = (arr / (mx if mx > 1.0 else 1.0)).astype(np.float32)
1822
+ else:
1823
+ arr = arr.astype(np.float32)
1824
+ self._full_img = arr
1825
+ self._preview_img = _downsample_for_preview(arr, 1200)
1826
+ self._preview_orig = self._preview_img.copy()
1827
+ self._preview_proc = None
1828
+
1829
+ self._show_proc = True # ⬅️ start with preview ON
1830
+ self._quick_preview() # ⬅️ build first processed DS frame
1831
+ self._update_preview_pix( # ⬅️ show processed immediately
1832
+ self._preview_proc if self._preview_proc is not None else self._preview_orig,
1833
+ preserve_view=False
1834
+ )
1835
+ self._build_preview_luma_cdf()
1836
+ self._build_preview_rgb_cdfs()
1837
+
1838
+ # ----- building LUT from editor -----
1839
+ def _build_lut01(self) -> np.ndarray | None:
1840
+ get_fn = getattr(self.editor, "getCurveFunction", None)
1841
+ if not get_fn:
1842
+ return None
1843
+ curve_func = get_fn()
1844
+ if curve_func is None:
1845
+ return None
1846
+ # this is your old good helper from the file you pasted
1847
+ return build_curve_lut(curve_func, size=65536)
1848
+
1849
+
1850
+ def _toggle_preview(self, on: bool):
1851
+ self._show_proc = bool(on)
1852
+ # Ensure we have a processed frame ready
1853
+ if self._preview_proc is None:
1854
+ self._quick_preview()
1855
+ # Pick which buffer to show (both are downsampled)
1856
+ img = self._preview_proc if (self._show_proc and self._preview_proc is not None) else self._preview_orig
1857
+ self._update_preview_pix(img)
1858
+ self._set_status(self.tr("Preview ON") if self._show_proc else self.tr("Preview OFF"))
1859
+
1860
+
1861
+ # ----- quick (in-UI) preview on downsample -----
1862
+ def _quick_preview(self):
1863
+ if self._preview_img is None:
1864
+ return
1865
+ luts = self._build_all_active_luts()
1866
+ proc = self._apply_all_curves_once(self._preview_img, luts)
1867
+ proc = self._blend_with_mask(proc)
1868
+ self._preview_proc = proc
1869
+ if self._show_proc:
1870
+ self._update_preview_pix(self._preview_proc)
1871
+ try:
1872
+ bt, wt = self.editor.current_black_white_thresholds()
1873
+
1874
+ if self._preview_img is not None and self._preview_img.ndim == 3 and self._preview_img.shape[2] >= 3:
1875
+ # Color image → only per-channel stats
1876
+ rgb = self._clip_counts_rgb_from_thresholds(bt, wt)
1877
+ def _fmt(pair):
1878
+ cnt_b, cnt_w, fb, fw = pair
1879
+ return self.tr("Bk {0:,} ({1:.2f}%) Wt {2:,} ({3:.2f}%)").format(cnt_b, fb*100, cnt_w, fw*100)
1880
+ self._set_status(
1881
+ self.tr("Clipping — R: {0} G: {1} B: {2}").format(_fmt(rgb['r']), _fmt(rgb['g']), _fmt(rgb['b']))
1882
+ )
1883
+ else:
1884
+ # Grayscale/mono → K summary (unchanged behavior)
1885
+ below, above, f_below, f_above = self._clip_counts_from_thresholds(bt, wt)
1886
+ self._set_status(
1887
+ self.tr("Clipping — Bk {0:,} ({1:.2f}%) Wt {2:,} ({3:.2f}%)").format(below, f_below*100, above, f_above*100)
1888
+ )
1889
+ except Exception:
1890
+ pass
1891
+
1892
+
1893
+ # ----- threaded full-res preview (also used for Apply path if needed) -----
1894
+ def _run_preview(self):
1895
+ if self._full_img is None:
1896
+ return
1897
+ luts = self._build_all_active_luts()
1898
+ self.btn_apply.setEnabled(False)
1899
+ self._thr = _CurvesWorker(self._full_img, luts, self)
1900
+ self._thr.done.connect(self._on_preview_ready)
1901
+ self._thr.finished.connect(lambda: self.btn_apply.setEnabled(True))
1902
+ self._thr.start()
1903
+
1904
+ def _on_preview_ready(self, out01: np.ndarray):
1905
+ # NOTE: do not push full-res into the label
1906
+ out_masked = self._blend_with_mask(out01)
1907
+ self._last_preview = out_masked # cache for Apply
1908
+ self._set_status(self.tr("Full-res ready (not shown)."))
1909
+
1910
+ def _clip_counts_from_thresholds(self, black_t: float | None, white_t: float | None):
1911
+ """
1912
+ Return tuple: (below_count_full, above_count_full, below_frac, above_frac)
1913
+ using the precomputed *preview* luma CDF, scaled to full-image counts.
1914
+ """
1915
+ if self._cdf is None or getattr(self, "_cdf_total_preview", 0) <= 0:
1916
+ return 0, 0, 0.0, 0.0
1917
+
1918
+ bins = int(getattr(self, "_cdf_bins", 1024))
1919
+
1920
+ # blacks: values strictly < black_t
1921
+ if black_t is None:
1922
+ below_preview = 0
1923
+ else:
1924
+ i = int(np.floor(np.clip(float(black_t), 0.0, 1.0) * (bins - 1)))
1925
+ i = max(0, min(bins - 1, i))
1926
+ below_preview = int(self._cdf[i])
1927
+
1928
+ # whites: values strictly > white_t
1929
+ if white_t is None:
1930
+ above_preview = 0
1931
+ else:
1932
+ j = int(np.floor(np.clip(float(white_t), 0.0, 1.0) * (bins - 1)))
1933
+ j = max(0, min(bins - 1, j))
1934
+ above_preview = int(self._cdf_total_preview - self._cdf[j])
1935
+
1936
+ # scale preview counts to full-image counts
1937
+ scale = float(getattr(self, "_clip_scale", 1.0))
1938
+ total_full = int(getattr(self, "_cdf_total_full", self._cdf_total_preview)) or 1
1939
+ below_full = int(round(below_preview * scale))
1940
+ above_full = int(round(above_preview * scale))
1941
+
1942
+ # clamp to valid range
1943
+ below_full = max(0, min(below_full, total_full))
1944
+ above_full = max(0, min(above_full, total_full))
1945
+
1946
+ # fractions against full-image total
1947
+ f_below = below_full / float(total_full)
1948
+ f_above = above_full / float(total_full)
1949
+
1950
+ return below_full, above_full, f_below, f_above
1951
+
1952
+ def _clip_counts_rgb_from_thresholds(self, black_t: float | None, white_t: float | None):
1953
+ """
1954
+ Returns dict:
1955
+ {
1956
+ 'r': (below_full, above_full, frac_below, frac_above),
1957
+ 'g': (...),
1958
+ 'b': (...)
1959
+ }
1960
+ using the precomputed preview RGB CDFs scaled to full-image counts.
1961
+ """
1962
+ out = {"r": (0,0,0.0,0.0), "g": (0,0,0.0,0.0), "b": (0,0,0.0,0.0)}
1963
+ if getattr(self, "_cdf_rgb", None) is None:
1964
+ return out
1965
+
1966
+ bins = int(getattr(self, "_cdf_bins", 1024))
1967
+ scale = float(getattr(self, "_clip_scale", 1.0))
1968
+ total_full = int(getattr(self, "_cdf_total_full", self._cdf_rgb["total_preview"])) or 1
1969
+ total_prev = int(self._cdf_rgb["total_preview"]) or 1
1970
+
1971
+ def _bin_idx(t):
1972
+ i = int(np.floor(np.clip(float(t), 0.0, 1.0) * (bins - 1)))
1973
+ return max(0, min(bins - 1, i))
1974
+
1975
+ for ch in ("r", "g", "b"):
1976
+ cdf = self._cdf_rgb[ch]
1977
+ # blacks
1978
+ if black_t is None:
1979
+ below_prev = 0
1980
+ else:
1981
+ i = _bin_idx(black_t)
1982
+ below_prev = int(cdf[i])
1983
+ # whites
1984
+ if white_t is None:
1985
+ above_prev = 0
1986
+ else:
1987
+ j = _bin_idx(white_t)
1988
+ above_prev = int(total_prev - cdf[j])
1989
+
1990
+ below_full = int(round(below_prev * scale))
1991
+ above_full = int(round(above_prev * scale))
1992
+
1993
+ below_full = max(0, min(below_full, total_full))
1994
+ above_full = max(0, min(above_full, total_full))
1995
+
1996
+ out[ch] = (
1997
+ below_full,
1998
+ above_full,
1999
+ below_full / float(total_full),
2000
+ above_full / float(total_full),
2001
+ )
2002
+ return out
2003
+
2004
+ def export_preview_ops(self) -> dict:
2005
+ """
2006
+ Produce a deterministic, tool-agnostic op dict for Curves
2007
+ that can be replayed on the full image later.
2008
+ """
2009
+ # Make sure the store has the latest edit from the active editor
2010
+ try:
2011
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
2012
+ except Exception:
2013
+ pass
2014
+
2015
+ # Only include modes that differ from linear
2016
+ def _is_linear(pts):
2017
+ return isinstance(pts, (list,tuple)) and len(pts)==2 and pts[0]==(0.0,0.0) and pts[1]==(1.0,1.0)
2018
+
2019
+ modes = {}
2020
+ for k, pts in self._curves_store.items():
2021
+ if not pts or _is_linear(pts):
2022
+ continue
2023
+ modes[k] = [(float(x), float(y)) for (x,y) in pts]
2024
+
2025
+ op = {
2026
+ "version": 1,
2027
+ "tool": "curves",
2028
+ "modes": modes,
2029
+ "active": self._current_mode_key,
2030
+ "lut_size": 65536,
2031
+ "mask": {
2032
+ "id": getattr(self.doc, "active_mask_id", None),
2033
+ "blend": "m*out+(1-m)*src",
2034
+ },
2035
+ }
2036
+ return op
2037
+
2038
+
2039
+ # ----- apply to document -----
2040
+ def _apply(self):
2041
+ if not hasattr(self, "_last_preview"):
2042
+ luts = self._build_all_active_luts()
2043
+ out01 = self._apply_all_curves_once(self._full_img, luts)
2044
+ out01 = self._blend_with_mask(out01)
2045
+ self._last_preview = out01
2046
+ self._commit(self._last_preview)
2047
+
2048
+ def _commit(self, out01: np.ndarray):
2049
+ try:
2050
+ _marr, mid, mname = self._active_mask_layer()
2051
+ meta = {
2052
+ "step_name": "Curves",
2053
+ "curves": {"mode": self._current_mode()},
2054
+ "masked": bool(mid),
2055
+ "mask_id": mid,
2056
+ "mask_name": mname,
2057
+ "mask_blend": "m*out + (1-m)*src",
2058
+ }
2059
+
2060
+ # 1) Apply to the document (updates the active view)
2061
+ self.doc.apply_edit(out01.copy(), metadata=meta, step_name="Curves")
2062
+
2063
+ try:
2064
+ self._remember_as_last_action()
2065
+ except Exception:
2066
+ pass
2067
+
2068
+ # 2) Pull the NEW image back into the curves dialog
2069
+ # (clear cached previews so we truly reload from the document)
2070
+ self.__dict__.pop("_last_preview", None)
2071
+ self._full_img = None
2072
+ self._preview_img = None
2073
+ self._load_from_doc() # refresh preview from updated doc
2074
+
2075
+ # 3) Reset the curve drawing so user can keep tweaking from scratch
2076
+ # --- after reloading the image from the document ---
2077
+ if hasattr(self.editor, "clearSymmetryLine"):
2078
+ self.editor.clearSymmetryLine()
2079
+ self.editor.initCurve()
2080
+
2081
+ # Clear ALL curves, not just current
2082
+ for k in list(self._curves_store.keys()):
2083
+ self._curves_store[k] = [(0.0, 0.0), (1.0, 1.0)]
2084
+
2085
+ self._refresh_overlays()
2086
+ self._quick_preview()
2087
+ self._set_status(self.tr("Applied. Image reloaded. All curves reset — keep tweaking."))
2088
+
2089
+
2090
+
2091
+ except Exception as e:
2092
+ QMessageBox.critical(self, self.tr("Apply failed"), str(e))
2093
+
2094
+
2095
+ # ----- helpers -----
2096
+ def _current_mode(self) -> str:
2097
+ for b in self.mode_group.buttons():
2098
+ if b.isChecked():
2099
+ return b.text()
2100
+ return "K (Brightness)"
2101
+
2102
+ def _set_status(self, s: str):
2103
+ self.lbl_status.setText(s)
2104
+
2105
+ # preview label drawing
2106
+ def _update_preview_pix(self, img01: np.ndarray | None, preserve_view: bool = True):
2107
+ if img01 is None:
2108
+ self.label.clear(); self._pix = None; return
2109
+
2110
+ state = self._capture_view() if preserve_view else None
2111
+
2112
+ qimg = _float_to_qimage_rgb8(img01)
2113
+ pm = QPixmap.fromImage(qimg)
2114
+ self._pix = pm
2115
+
2116
+ if preserve_view and state is not None:
2117
+ fx, fy, zoom = state
2118
+ # Avoid any auto-fit when we explicitly preserve view
2119
+ self._restore_view(fx, fy, zoom)
2120
+ else:
2121
+ self._apply_zoom()
2122
+ if not self._did_initial_fit:
2123
+ QTimer.singleShot(0, self._fit_once)
2124
+
2125
+
2126
+ # --- mask helpers ---------------------------------------------------
2127
+ def _active_mask_layer(self):
2128
+ """Return (mask_float01, mask_id, mask_name) or (None, None, None)."""
2129
+ mid = getattr(self.doc, "active_mask_id", None)
2130
+ if not mid: return None, None, None
2131
+ layer = getattr(self.doc, "masks", {}).get(mid)
2132
+ if layer is None: return None, None, None
2133
+ m = np.asarray(getattr(layer, "data", None))
2134
+ if m is None or m.size == 0: return None, None, None
2135
+ m = m.astype(np.float32, copy=False)
2136
+ if m.dtype.kind in "ui":
2137
+ m /= float(np.iinfo(m.dtype).max)
2138
+ else:
2139
+ mx = float(m.max()) if m.size else 1.0
2140
+ if mx > 1.0: m /= mx
2141
+ return np.clip(m, 0.0, 1.0), mid, getattr(layer, "name", "Mask")
2142
+
2143
+ def _resample_mask_if_needed(self, mask: np.ndarray, out_hw: tuple[int,int]) -> np.ndarray:
2144
+ """Nearest-neighbor resize via integer indexing."""
2145
+ mh, mw = mask.shape[:2]
2146
+ th, tw = out_hw
2147
+ if (mh, mw) == (th, tw): return mask
2148
+ yi = np.linspace(0, mh - 1, th).astype(np.int32)
2149
+ xi = np.linspace(0, mw - 1, tw).astype(np.int32)
2150
+ return mask[yi][:, xi]
2151
+
2152
+ def _blend_with_mask(self, processed: np.ndarray) -> np.ndarray:
2153
+ """
2154
+ Blend processed image with original using active mask (if any).
2155
+ Chooses original from preview/full buffers to match shape.
2156
+ """
2157
+ mask, _mid, _mname = self._active_mask_layer()
2158
+ if mask is None:
2159
+ return processed
2160
+
2161
+ out = processed.astype(np.float32, copy=False)
2162
+ # pick matching original
2163
+ if (hasattr(self, "_full_img") and self._full_img is not None
2164
+ and out.shape[:2] == self._full_img.shape[:2]):
2165
+ src = self._full_img
2166
+ else:
2167
+ src = self._preview_img
2168
+
2169
+ m = self._resample_mask_if_needed(mask, out.shape[:2])
2170
+ if out.ndim == 3 and out.shape[2] == 3:
2171
+ m = m[..., None]
2172
+
2173
+ # shape/channel reconcile
2174
+ if src.ndim == 2 and out.ndim == 3:
2175
+ src = np.stack([src]*3, axis=-1)
2176
+ elif src.ndim == 3 and out.ndim == 2:
2177
+ src = src[..., 0]
2178
+
2179
+ return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
2180
+
2181
+
2182
+ # zoom/pan
2183
+ def _apply_zoom(self):
2184
+ if self._pix is None:
2185
+ return
2186
+ scaled = self._pix.scaled(self._pix.size()*self._zoom,
2187
+ Qt.AspectRatioMode.KeepAspectRatio,
2188
+ Qt.TransformationMode.SmoothTransformation)
2189
+ self.label.setPixmap(scaled)
2190
+ self.label.resize(scaled.size())
2191
+
2192
+ def _set_zoom(self, z: float):
2193
+ self._zoom = float(max(0.05, min(z, 8.0)))
2194
+ self._apply_zoom()
2195
+
2196
+ def _fit(self):
2197
+ if self._pix is None: return
2198
+ vp = self.scroll.viewport().size()
2199
+ if self._pix.width()==0 or self._pix.height()==0: return
2200
+ s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
2201
+ self._set_zoom(max(0.05, s))
2202
+
2203
+ # event filter: ctrl+wheel zoom + panning (like Star Stretch)
2204
+ def eventFilter(self, obj, ev):
2205
+ if obj is self.scroll.viewport():
2206
+ # Ctrl+wheel zoom / panning (your existing code) ...
2207
+ if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
2208
+ self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
2209
+ ev.accept(); return True
2210
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
2211
+ self._panning = True; self._pan_start = ev.position()
2212
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
2213
+ ev.accept(); return True
2214
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
2215
+ d = ev.position() - self._pan_start
2216
+ h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
2217
+ h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
2218
+ self._pan_start = ev.position()
2219
+ ev.accept(); return True
2220
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
2221
+ self._panning = False
2222
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
2223
+ ev.accept(); return True
2224
+
2225
+ # NEW: if just moving the mouse (not panning), forward to label coords
2226
+ if ev.type() == QEvent.Type.MouseMove and not self._panning:
2227
+ # map viewport point → label-local point
2228
+ lp = self.label.mapFrom(self.scroll.viewport(), QPoint(int(ev.position().x()), int(ev.position().y())))
2229
+ if 0 <= lp.x() < self.label.width() and 0 <= lp.y() < self.label.height():
2230
+ self._on_preview_mouse_moved(lp.x(), lp.y())
2231
+ else:
2232
+ self.editor.clearValueLines()
2233
+ self._set_status("")
2234
+ return False # don't consume
2235
+
2236
+ if ev.type() == QEvent.Type.MouseButtonDblClick and ev.button() == Qt.MouseButton.LeftButton:
2237
+ if self._preview_img is None or self._pix is None:
2238
+ return False
2239
+ pos = self.label.mapFrom(self.scroll.viewport(), ev.pos())
2240
+ ix = int(pos.x() / max(self._zoom, 1e-6))
2241
+ iy = int(pos.y() / max(self._zoom, 1e-6))
2242
+ ix = max(0, min(self._pix.width() - 1, ix))
2243
+ iy = max(0, min(self._pix.height() - 1, iy))
2244
+
2245
+ img = self._preview_img
2246
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
2247
+ k = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
2248
+ else:
2249
+ k = float(np.mean(img[iy, ix, :3]))
2250
+ k = float(np.clip(k, 0.0, 1.0))
2251
+
2252
+ # show the yellow bar + redistribute
2253
+ self.editor.setSymmetryPoint(k * 360.0, 0.0)
2254
+ self._on_symmetry_pick(k, k)
2255
+ ev.accept()
2256
+ return True
2257
+
2258
+ # existing label Leave handler
2259
+ if obj is self.label and ev.type() == QEvent.Type.Leave:
2260
+ self.editor.clearValueLines()
2261
+ self._set_status("")
2262
+ return False
2263
+
2264
+ # existing double-click handler: just swap in the same mapper
2265
+ if obj is self.label and ev.type() == QEvent.Type.MouseButtonDblClick:
2266
+ if ev.button() != Qt.MouseButton.LeftButton:
2267
+ return False
2268
+ pos = ev.position()
2269
+ mapped = self._map_label_xy_to_image_ij(pos.x(), pos.y())
2270
+ if not mapped or self._preview_img is None:
2271
+ return False
2272
+ ix, iy = mapped
2273
+ img = self._preview_img
2274
+ # mono or RGB-average
2275
+ if img.ndim == 2 or (img.ndim == 3 and img.shape[2] == 1):
2276
+ v = float(img[iy, ix] if img.ndim == 2 else img[iy, ix, 0])
2277
+ else:
2278
+ r, g, b = float(img[iy, ix, 0]), float(img[iy, ix, 1]), float(img[iy, ix, 2])
2279
+ v = (r + g + b) / 3.0
2280
+ if np.isnan(v):
2281
+ return True
2282
+ v = float(np.clip(v, 0.0, 1.0))
2283
+ x = max(0.001, min(359.999, v * 360.0))
2284
+
2285
+ # place on current curve
2286
+ y = None
2287
+ try:
2288
+ f = self.editor.getCurveFunction()
2289
+ if f is not None:
2290
+ y = float(f(x))
2291
+ except Exception:
2292
+ pass
2293
+ if y is None:
2294
+ y = 360.0 - x
2295
+
2296
+ # avoid x-collisions
2297
+ xs = [p.scenePos().x() for p in (self.editor.end_points + self.editor.control_points)]
2298
+ if any(abs(x - ex) < 1e-3 for ex in xs):
2299
+ step = 0.002
2300
+ for k in range(1, 2000):
2301
+ for cand in (x + k*step, x - k*step):
2302
+ if 0.001 < cand < 359.999 and all(abs(cand - ex) >= 1e-3 for ex in xs):
2303
+ x = cand; break
2304
+ else:
2305
+ continue
2306
+ break
2307
+
2308
+ self.editor.addControlPoint(x, y)
2309
+ self._set_status(self.tr("Added point at x={0:.3f}").format(v))
2310
+ ev.accept()
2311
+ return True
2312
+
2313
+ return super().eventFilter(obj, ev)
2314
+
2315
+
2316
+ def _reset_curve(self):
2317
+ # 1) reset editor drawing to linear
2318
+ self.editor.initCurve()
2319
+ # 2) mark *every* stored curve linear
2320
+ for k in list(self._curves_store.keys()):
2321
+ self._curves_store[k] = [(0.0, 0.0), (1.0, 1.0)]
2322
+ # 3) refresh overlays & preview
2323
+ self._refresh_overlays()
2324
+ self._quick_preview()
2325
+ self._set_status(self.tr("All curves reset."))
2326
+
2327
+ def _find_main_window(self):
2328
+ p = self.parent()
2329
+ while p is not None and not hasattr(p, "docman"):
2330
+ p = p.parent()
2331
+ return p
2332
+
2333
+ def _apply_preset_dict(self, preset: dict):
2334
+ preset = preset or {}
2335
+
2336
+ # 1) set mode radio
2337
+ want = _norm_mode(preset.get("mode"))
2338
+ for b in self.mode_group.buttons():
2339
+ if b.text().lower() == want.lower():
2340
+ b.setChecked(True)
2341
+ break
2342
+
2343
+ # 2) get points_norm — if absent, build from shape/amount (built-ins)
2344
+ ptsN = preset.get("points_norm")
2345
+ shape = preset.get("shape") # may be None for custom presets
2346
+ amount = float(preset.get("amount", 1.0))
2347
+
2348
+ if not (isinstance(ptsN, (list, tuple)) and len(ptsN) >= 2):
2349
+ try:
2350
+ # build from a named shape (built-ins); default to linear
2351
+ ptsN = _shape_points_norm(str(shape or "linear"), amount)
2352
+ except Exception:
2353
+ ptsN = [(0.0, 0.0), (1.0, 1.0)] # safe fallback
2354
+
2355
+ # 3) apply handles to the editor (strip exact endpoints)
2356
+ pts_scene = _points_norm_to_scene(ptsN)
2357
+ filt = [(x, y) for (x, y) in pts_scene if 1e-6 < x < 360.0 - 1e-6]
2358
+
2359
+ if hasattr(self.editor, "clearSymmetryLine"):
2360
+ self.editor.clearSymmetryLine()
2361
+
2362
+ self.editor.setControlHandles(filt)
2363
+ self.editor.updateCurve() # ensure redraw
2364
+
2365
+ # persist into store & refresh
2366
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
2367
+ self._refresh_overlays()
2368
+ self._quick_preview()
2369
+
2370
+ # 4) status: don’t assume shape exists
2371
+ shape_tag = f"[{shape}]" if shape else "[custom]"
2372
+ self._set_status(self.tr("Preset: {0} {1}").format(preset.get('name', self.tr('(built-in)')), shape_tag))
2373
+
2374
+
2375
+ def apply_curves_ops(doc, op: dict):
2376
+ """
2377
+ Rebuild LUTs from normalized points and apply to doc.image (full-res).
2378
+ Uses the same math as the dialog path, but headless.
2379
+ """
2380
+ try:
2381
+ if op.get("tool") != "curves":
2382
+ return False
2383
+
2384
+ # safety defaults
2385
+ lut_size = int(op.get("lut_size", 65536))
2386
+ modes = dict(op.get("modes", {}))
2387
+ if not modes:
2388
+ return True # nothing to do (all linear)
2389
+
2390
+ # Build LUTs exactly like the dialog does (_lut01_from_points_norm)
2391
+ def _lut01_from_ptsN(ptsN, size=65536):
2392
+ # local import: reuse your existing helper if you prefer
2393
+ pts_scene = _points_norm_to_scene(ptsN)
2394
+ if len(pts_scene) < 2:
2395
+ return np.linspace(0.0, 1.0, size, dtype=np.float32)
2396
+ xs = np.array([p[0] for p in pts_scene], dtype=np.float64)
2397
+ ys = np.array([p[1] for p in pts_scene], dtype=np.float64)
2398
+ if np.any(np.diff(xs) <= 0):
2399
+ xs = xs + np.linspace(0, 1e-3, len(xs), dtype=np.float64)
2400
+ ys = 360.0 - ys
2401
+ inp = np.linspace(0.0, 360.0, size, dtype=np.float64)
2402
+ try:
2403
+ from scipy.interpolate import PchipInterpolator
2404
+ f = PchipInterpolator(xs, ys, extrapolate=True)
2405
+ out = f(inp)
2406
+ except Exception:
2407
+ out = np.interp(inp, xs, ys)
2408
+ out = np.clip(out / 360.0, 0.0, 1.0).astype(np.float32)
2409
+ return out
2410
+
2411
+ luts = {k: _lut01_from_ptsN(pts, lut_size) for k, pts in modes.items()}
2412
+
2413
+ # Pull full-res, normalize to float01 (same as dialog)
2414
+ img = np.asarray(doc.image)
2415
+ if img.dtype.kind in "ui":
2416
+ img01 = img.astype(np.float32) / np.iinfo(img.dtype).max
2417
+ elif img.dtype.kind == "f":
2418
+ mx = float(img.max()) if img.size else 1.0
2419
+ img01 = (img / (mx if mx > 1.0 else 1.0)).astype(np.float32)
2420
+ else:
2421
+ img01 = img.astype(np.float32)
2422
+
2423
+ # Apply using the same engine as the dialog
2424
+ # (reuse CurvesDialogPro._apply_all_curves_once logic via a tiny local copy)
2425
+ out01 = CurvesDialogPro._apply_all_curves_once(None, img01, luts) # call as unbound
2426
+
2427
+ # Blend with active mask if any
2428
+ # Reuse the dialog helper via a tiny shim:
2429
+ dlg_like = CurvesDialogPro.__new__(CurvesDialogPro) # no init
2430
+ dlg_like.doc = doc
2431
+ dlg_like._full_img = img01
2432
+ out01 = CurvesDialogPro._blend_with_mask(dlg_like, out01)
2433
+
2434
+ # Commit to doc history
2435
+ meta = {
2436
+ "step_name": "Curves (Replay)",
2437
+ "curves": {"modes": list(modes.keys()), "lut_size": lut_size},
2438
+ "masked": bool(op.get("mask", {}).get("id")),
2439
+ "mask_id": op.get("mask", {}).get("id"),
2440
+ }
2441
+ doc.apply_edit(out01.copy(), metadata=meta, step_name="Curves (Replay)")
2442
+ return True
2443
+ except Exception as e:
2444
+ print("apply_curves_ops failed:", e)
2445
+ return False
2446
+
2447
+
2448
+ def _apply_mode_any(img01: np.ndarray, mode: str, lut01: np.ndarray) -> np.ndarray:
2449
+ """
2450
+ img01: float32 [0..1], mono(H,W) or RGB(H,W,3)
2451
+ mode: "K (Brightness)" | "R" | "G" | "B" | "L*" | "a*" | "b*" | "Chroma" | "Saturation"
2452
+ lut01: float32 [0..1] LUT
2453
+ """
2454
+ if img01.ndim == 2 or (img01.ndim == 3 and img01.shape[2] == 1):
2455
+ ch = img01 if img01.ndim == 2 else img01[...,0]
2456
+ # mono – just apply
2457
+ if _HAS_NUMBA:
2458
+ out = ch.copy()
2459
+ _nb_apply_lut_mono_inplace(out, lut01)
2460
+ else:
2461
+ out = _np_apply_lut_channel(ch, lut01)
2462
+ return out
2463
+
2464
+ # RGB:
2465
+ m = mode.lower()
2466
+ if m == "k (brightness)":
2467
+ if _HAS_NUMBA:
2468
+ out = img01.copy()
2469
+ _nb_apply_lut_color_inplace(out, lut01)
2470
+ return out
2471
+ return _np_apply_lut_rgb(img01, lut01)
2472
+
2473
+ if m in ("r","g","b"):
2474
+ out = img01.copy()
2475
+ idx = {"r":0, "g":1, "b":2}[m]
2476
+ if _HAS_NUMBA:
2477
+ _nb_apply_lut_mono_inplace(out[..., idx], lut01)
2478
+ else:
2479
+ out[..., idx] = _np_apply_lut_channel(out[..., idx], lut01)
2480
+ return out
2481
+
2482
+ # L*, a*, b*, Chroma => Lab trip
2483
+ if m in ("l*", "a*", "b*", "chroma"):
2484
+ if _HAS_NUMBA:
2485
+ xyz = rgb_to_xyz_numba(img01)
2486
+ lab = xyz_to_lab_numba(xyz)
2487
+ else:
2488
+ xyz = _np_rgb_to_xyz(img01)
2489
+ lab = _np_xyz_to_lab(xyz)
2490
+
2491
+ if m == "l*":
2492
+ L = lab[...,0] / 100.0
2493
+ L = np.clip(L, 0.0, 1.0)
2494
+ if _HAS_NUMBA:
2495
+ _nb_apply_lut_mono_inplace(L, lut01)
2496
+ else:
2497
+ L = _np_apply_lut_channel(L, lut01)
2498
+ lab[...,0] = L * 100.0
2499
+
2500
+ elif m == "a*":
2501
+ a = lab[...,1]
2502
+ a_norm = np.clip((a + 128.0)/255.0, 0.0, 1.0)
2503
+ if _HAS_NUMBA:
2504
+ _nb_apply_lut_mono_inplace(a_norm, lut01)
2505
+ else:
2506
+ a_norm = _np_apply_lut_channel(a_norm, lut01)
2507
+ lab[...,1] = a_norm*255.0 - 128.0
2508
+
2509
+ elif m == "b*":
2510
+ b = lab[...,2]
2511
+ b_norm = np.clip((b + 128.0)/255.0, 0.0, 1.0)
2512
+ if _HAS_NUMBA:
2513
+ _nb_apply_lut_mono_inplace(b_norm, lut01)
2514
+ else:
2515
+ b_norm = _np_apply_lut_channel(b_norm, lut01)
2516
+ lab[...,2] = b_norm*255.0 - 128.0
2517
+
2518
+ else: # chroma
2519
+ a = lab[...,1]; b = lab[...,2]
2520
+ C = np.sqrt(a*a + b*b)
2521
+ C_norm = np.clip(C / 200.0, 0.0, 1.0)
2522
+ if _HAS_NUMBA:
2523
+ _nb_apply_lut_mono_inplace(C_norm, lut01)
2524
+ else:
2525
+ C_norm = _np_apply_lut_channel(C_norm, lut01)
2526
+ C_new = C_norm * 200.0
2527
+ ratio = np.divide(C_new, C, out=np.zeros_like(C_new), where=(C>0))
2528
+ lab[...,1] = a * ratio
2529
+ lab[...,2] = b * ratio
2530
+
2531
+ if _HAS_NUMBA:
2532
+ xyz2 = lab_to_xyz_numba(lab)
2533
+ out = xyz_to_rgb_numba(xyz2)
2534
+ else:
2535
+ xyz2 = _np_lab_to_xyz(lab)
2536
+ out = _np_xyz_to_rgb(xyz2)
2537
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
2538
+
2539
+ # Saturation => HSV trip
2540
+ if m == "saturation":
2541
+ if _HAS_NUMBA:
2542
+ hsv = rgb_to_hsv_numba(img01)
2543
+ else:
2544
+ hsv = _np_rgb_to_hsv(img01)
2545
+ S = np.clip(hsv[...,1], 0.0, 1.0)
2546
+ if _HAS_NUMBA:
2547
+ _nb_apply_lut_mono_inplace(S, lut01)
2548
+ else:
2549
+ S = _np_apply_lut_channel(S, lut01)
2550
+ hsv[...,1] = np.clip(S, 0.0, 1.0)
2551
+ if _HAS_NUMBA:
2552
+ out = hsv_to_rgb_numba(hsv)
2553
+ else:
2554
+ out = _np_hsv_to_rgb(hsv)
2555
+ return np.clip(out, 0.0, 1.0).astype(np.float32)
2556
+
2557
+ # Unknown ⇒ fallback to brightness
2558
+ if _HAS_NUMBA:
2559
+ out = img01.copy()
2560
+ _nb_apply_lut_color_inplace(out, lut01)
2561
+ return out
2562
+ return _np_apply_lut_rgb(img01, lut01)