setiastrosuitepro 1.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

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