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