setiastrosuitepro 1.6.0__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.
- setiastro/__init__.py +2 -0
- setiastro/saspro/__init__.py +20 -0
- setiastro/saspro/__main__.py +784 -0
- setiastro/saspro/_generated/__init__.py +7 -0
- setiastro/saspro/_generated/build_info.py +2 -0
- setiastro/saspro/abe.py +1295 -0
- setiastro/saspro/abe_preset.py +196 -0
- setiastro/saspro/aberration_ai.py +694 -0
- setiastro/saspro/aberration_ai_preset.py +224 -0
- setiastro/saspro/accel_installer.py +218 -0
- setiastro/saspro/accel_workers.py +30 -0
- setiastro/saspro/add_stars.py +621 -0
- setiastro/saspro/astrobin_exporter.py +1007 -0
- setiastro/saspro/astrospike.py +153 -0
- setiastro/saspro/astrospike_python.py +1839 -0
- setiastro/saspro/autostretch.py +196 -0
- setiastro/saspro/backgroundneutral.py +560 -0
- setiastro/saspro/batch_convert.py +325 -0
- setiastro/saspro/batch_renamer.py +519 -0
- setiastro/saspro/blemish_blaster.py +488 -0
- setiastro/saspro/blink_comparator_pro.py +2923 -0
- setiastro/saspro/bundles.py +61 -0
- setiastro/saspro/bundles_dock.py +114 -0
- setiastro/saspro/cheat_sheet.py +168 -0
- setiastro/saspro/clahe.py +342 -0
- setiastro/saspro/comet_stacking.py +1377 -0
- setiastro/saspro/config.py +38 -0
- setiastro/saspro/config_bootstrap.py +40 -0
- setiastro/saspro/config_manager.py +316 -0
- setiastro/saspro/continuum_subtract.py +1617 -0
- setiastro/saspro/convo.py +1397 -0
- setiastro/saspro/convo_preset.py +414 -0
- setiastro/saspro/copyastro.py +187 -0
- setiastro/saspro/cosmicclarity.py +1564 -0
- setiastro/saspro/cosmicclarity_preset.py +407 -0
- setiastro/saspro/crop_dialog_pro.py +948 -0
- setiastro/saspro/crop_preset.py +189 -0
- setiastro/saspro/curve_editor_pro.py +2544 -0
- setiastro/saspro/curves_preset.py +375 -0
- setiastro/saspro/debayer.py +670 -0
- setiastro/saspro/debug_utils.py +29 -0
- setiastro/saspro/dnd_mime.py +35 -0
- setiastro/saspro/doc_manager.py +2634 -0
- setiastro/saspro/exoplanet_detector.py +2166 -0
- setiastro/saspro/file_utils.py +284 -0
- setiastro/saspro/fitsmodifier.py +744 -0
- setiastro/saspro/free_torch_memory.py +48 -0
- setiastro/saspro/frequency_separation.py +1343 -0
- setiastro/saspro/function_bundle.py +1594 -0
- setiastro/saspro/ghs_dialog_pro.py +660 -0
- setiastro/saspro/ghs_preset.py +284 -0
- setiastro/saspro/graxpert.py +634 -0
- setiastro/saspro/graxpert_preset.py +287 -0
- setiastro/saspro/gui/__init__.py +0 -0
- setiastro/saspro/gui/main_window.py +8494 -0
- setiastro/saspro/gui/mixins/__init__.py +33 -0
- setiastro/saspro/gui/mixins/dock_mixin.py +263 -0
- setiastro/saspro/gui/mixins/file_mixin.py +445 -0
- setiastro/saspro/gui/mixins/geometry_mixin.py +403 -0
- setiastro/saspro/gui/mixins/header_mixin.py +441 -0
- setiastro/saspro/gui/mixins/mask_mixin.py +421 -0
- setiastro/saspro/gui/mixins/menu_mixin.py +361 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +367 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +1324 -0
- setiastro/saspro/gui/mixins/update_mixin.py +309 -0
- setiastro/saspro/gui/mixins/view_mixin.py +435 -0
- setiastro/saspro/halobgon.py +462 -0
- setiastro/saspro/header_viewer.py +445 -0
- setiastro/saspro/headless_utils.py +88 -0
- setiastro/saspro/histogram.py +753 -0
- setiastro/saspro/history_explorer.py +939 -0
- setiastro/saspro/image_combine.py +414 -0
- setiastro/saspro/image_peeker_pro.py +1596 -0
- setiastro/saspro/imageops/__init__.py +37 -0
- setiastro/saspro/imageops/mdi_snap.py +292 -0
- setiastro/saspro/imageops/scnr.py +36 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +210 -0
- setiastro/saspro/imageops/stretch.py +244 -0
- setiastro/saspro/isophote.py +1179 -0
- setiastro/saspro/layers.py +208 -0
- setiastro/saspro/layers_dock.py +714 -0
- setiastro/saspro/lazy_imports.py +193 -0
- setiastro/saspro/legacy/__init__.py +2 -0
- setiastro/saspro/legacy/image_manager.py +2226 -0
- setiastro/saspro/legacy/numba_utils.py +3659 -0
- setiastro/saspro/legacy/xisf.py +1071 -0
- setiastro/saspro/linear_fit.py +534 -0
- setiastro/saspro/live_stacking.py +1830 -0
- setiastro/saspro/log_bus.py +5 -0
- setiastro/saspro/logging_config.py +460 -0
- setiastro/saspro/luminancerecombine.py +309 -0
- setiastro/saspro/main_helpers.py +201 -0
- setiastro/saspro/mask_creation.py +928 -0
- setiastro/saspro/masks_core.py +56 -0
- setiastro/saspro/mdi_widgets.py +353 -0
- setiastro/saspro/memory_utils.py +666 -0
- setiastro/saspro/metadata_patcher.py +75 -0
- setiastro/saspro/mfdeconv.py +3826 -0
- setiastro/saspro/mfdeconv_earlystop.py +71 -0
- setiastro/saspro/mfdeconvcudnn.py +3263 -0
- setiastro/saspro/mfdeconvsport.py +2382 -0
- setiastro/saspro/minorbodycatalog.py +567 -0
- setiastro/saspro/morphology.py +382 -0
- setiastro/saspro/multiscale_decomp.py +1290 -0
- setiastro/saspro/nbtorgb_stars.py +531 -0
- setiastro/saspro/numba_utils.py +3044 -0
- setiastro/saspro/numba_warmup.py +141 -0
- setiastro/saspro/ops/__init__.py +9 -0
- setiastro/saspro/ops/command_help_dialog.py +623 -0
- setiastro/saspro/ops/command_runner.py +217 -0
- setiastro/saspro/ops/commands.py +1594 -0
- setiastro/saspro/ops/script_editor.py +1102 -0
- setiastro/saspro/ops/scripts.py +1413 -0
- setiastro/saspro/ops/settings.py +560 -0
- setiastro/saspro/parallel_utils.py +554 -0
- setiastro/saspro/pedestal.py +121 -0
- setiastro/saspro/perfect_palette_picker.py +1053 -0
- setiastro/saspro/pipeline.py +110 -0
- setiastro/saspro/pixelmath.py +1600 -0
- setiastro/saspro/plate_solver.py +2435 -0
- setiastro/saspro/project_io.py +797 -0
- setiastro/saspro/psf_utils.py +136 -0
- setiastro/saspro/psf_viewer.py +549 -0
- setiastro/saspro/pyi_rthook_astroquery.py +95 -0
- setiastro/saspro/remove_green.py +314 -0
- setiastro/saspro/remove_stars.py +1625 -0
- setiastro/saspro/remove_stars_preset.py +404 -0
- setiastro/saspro/resources.py +472 -0
- setiastro/saspro/rgb_combination.py +207 -0
- setiastro/saspro/rgb_extract.py +19 -0
- setiastro/saspro/rgbalign.py +723 -0
- setiastro/saspro/runtime_imports.py +7 -0
- setiastro/saspro/runtime_torch.py +754 -0
- setiastro/saspro/save_options.py +72 -0
- setiastro/saspro/selective_color.py +1552 -0
- setiastro/saspro/sfcc.py +1425 -0
- setiastro/saspro/shortcuts.py +2807 -0
- setiastro/saspro/signature_insert.py +1099 -0
- setiastro/saspro/stacking_suite.py +17712 -0
- setiastro/saspro/star_alignment.py +7420 -0
- setiastro/saspro/star_alignment_preset.py +329 -0
- setiastro/saspro/star_metrics.py +49 -0
- setiastro/saspro/star_spikes.py +681 -0
- setiastro/saspro/star_stretch.py +470 -0
- setiastro/saspro/stat_stretch.py +502 -0
- setiastro/saspro/status_log_dock.py +78 -0
- setiastro/saspro/subwindow.py +3267 -0
- setiastro/saspro/supernovaasteroidhunter.py +1712 -0
- setiastro/saspro/swap_manager.py +99 -0
- setiastro/saspro/torch_backend.py +89 -0
- setiastro/saspro/torch_rejection.py +434 -0
- setiastro/saspro/view_bundle.py +1555 -0
- setiastro/saspro/wavescale_hdr.py +624 -0
- setiastro/saspro/wavescale_hdr_preset.py +100 -0
- setiastro/saspro/wavescalede.py +657 -0
- setiastro/saspro/wavescalede_preset.py +228 -0
- setiastro/saspro/wcs_update.py +374 -0
- setiastro/saspro/whitebalance.py +456 -0
- setiastro/saspro/widgets/__init__.py +48 -0
- setiastro/saspro/widgets/common_utilities.py +305 -0
- setiastro/saspro/widgets/graphics_views.py +122 -0
- setiastro/saspro/widgets/image_utils.py +518 -0
- setiastro/saspro/widgets/preview_dialogs.py +280 -0
- setiastro/saspro/widgets/spinboxes.py +275 -0
- setiastro/saspro/widgets/themed_buttons.py +13 -0
- setiastro/saspro/widgets/wavelet_utils.py +299 -0
- setiastro/saspro/window_shelf.py +185 -0
- setiastro/saspro/xisf.py +1123 -0
- setiastrosuitepro-1.6.0.dist-info/METADATA +266 -0
- setiastrosuitepro-1.6.0.dist-info/RECORD +174 -0
- setiastrosuitepro-1.6.0.dist-info/WHEEL +4 -0
- setiastrosuitepro-1.6.0.dist-info/entry_points.txt +6 -0
- setiastrosuitepro-1.6.0.dist-info/licenses/LICENSE +674 -0
- setiastrosuitepro-1.6.0.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(f"Inflection @ K={u:.3f}")
|
|
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("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("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("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("Toggle Preview")
|
|
1107
|
+
self.btn_preview.setCheckable(True) # ⬅️ toggle
|
|
1108
|
+
self.btn_apply = QPushButton("Apply to Document")
|
|
1109
|
+
self.btn_reset = QToolButton(); self.btn_reset.setText("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", "Zoom Out")
|
|
1121
|
+
self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
|
|
1122
|
+
self.btn_zoom_fit = themed_toolbtn("zoom-fit-best", "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(f"Inflection @ K={u:.3f}")
|
|
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(f"Cursor ({ix}, {iy}) K: {v:.3f}")
|
|
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(f"Cursor ({ix}, {iy}) R: {r:.3f} G: {g:.3f} B: {b:.3f}")
|
|
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, "Save Curves Preset", "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(f"Saved preset “{name.strip()}”.")
|
|
1738
|
+
self._rebuild_presets_menu()
|
|
1739
|
+
else:
|
|
1740
|
+
QMessageBox.warning(self, "Save failed", "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("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("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("Manage…")
|
|
1771
|
+
act_manage.triggered.connect(self._open_manage_customs_dialog) # optional (see below)
|
|
1772
|
+
else:
|
|
1773
|
+
m.addAction("(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, "Manage Presets", "No custom presets.")
|
|
1782
|
+
return
|
|
1783
|
+
names = [p.get("name","") for p in customs]
|
|
1784
|
+
name, ok = QInputDialog.getItem(self, "Delete Preset", "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, "No image", "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("Preview ON" if self._show_proc else "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 f"Bk {cnt_b:,} ({fb*100:.2f}%) Wt {cnt_w:,} ({fw*100:.2f}%)"
|
|
1862
|
+
self._set_status(
|
|
1863
|
+
f"Clipping — R: {_fmt(rgb['r'])} G: {_fmt(rgb['g'])} B: {_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
|
+
f"Clipping — Bk {below:,} ({f_below*100:.2f}%) Wt {above:,} ({f_above*100:.2f}%)"
|
|
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("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("Applied. Image reloaded. All curves reset — keep tweaking.")
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
except Exception as e:
|
|
2074
|
+
QMessageBox.critical(self, "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(f"Added point at x={v:.3f}")
|
|
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("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(f"Preset: {preset.get('name', '(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)
|