setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (132) hide show
  1. setiastro/images/TextureClarity.svg +56 -0
  2. setiastro/images/abeicon.svg +16 -0
  3. setiastro/images/acv_icon.png +0 -0
  4. setiastro/images/colorwheel.svg +97 -0
  5. setiastro/images/cosmic.svg +40 -0
  6. setiastro/images/cosmicsat.svg +24 -0
  7. setiastro/images/first_quarter.png +0 -0
  8. setiastro/images/full_moon.png +0 -0
  9. setiastro/images/graxpert.svg +19 -0
  10. setiastro/images/last_quarter.png +0 -0
  11. setiastro/images/linearfit.svg +32 -0
  12. setiastro/images/narrowbandnormalization.png +0 -0
  13. setiastro/images/new_moon.png +0 -0
  14. setiastro/images/pixelmath.svg +42 -0
  15. setiastro/images/planetarystacker.png +0 -0
  16. setiastro/images/waning_crescent_1.png +0 -0
  17. setiastro/images/waning_crescent_2.png +0 -0
  18. setiastro/images/waning_crescent_3.png +0 -0
  19. setiastro/images/waning_crescent_4.png +0 -0
  20. setiastro/images/waning_crescent_5.png +0 -0
  21. setiastro/images/waning_gibbous_1.png +0 -0
  22. setiastro/images/waning_gibbous_2.png +0 -0
  23. setiastro/images/waning_gibbous_3.png +0 -0
  24. setiastro/images/waning_gibbous_4.png +0 -0
  25. setiastro/images/waning_gibbous_5.png +0 -0
  26. setiastro/images/waxing_crescent_1.png +0 -0
  27. setiastro/images/waxing_crescent_2.png +0 -0
  28. setiastro/images/waxing_crescent_3.png +0 -0
  29. setiastro/images/waxing_crescent_4.png +0 -0
  30. setiastro/images/waxing_crescent_5.png +0 -0
  31. setiastro/images/waxing_gibbous_1.png +0 -0
  32. setiastro/images/waxing_gibbous_2.png +0 -0
  33. setiastro/images/waxing_gibbous_3.png +0 -0
  34. setiastro/images/waxing_gibbous_4.png +0 -0
  35. setiastro/images/waxing_gibbous_5.png +0 -0
  36. setiastro/qml/ResourceMonitor.qml +84 -82
  37. setiastro/saspro/__main__.py +20 -1
  38. setiastro/saspro/_generated/build_info.py +2 -2
  39. setiastro/saspro/abe.py +37 -4
  40. setiastro/saspro/aberration_ai.py +364 -33
  41. setiastro/saspro/aberration_ai_preset.py +29 -3
  42. setiastro/saspro/acv_exporter.py +379 -0
  43. setiastro/saspro/add_stars.py +33 -6
  44. setiastro/saspro/astrospike_python.py +45 -3
  45. setiastro/saspro/backgroundneutral.py +108 -40
  46. setiastro/saspro/blemish_blaster.py +4 -1
  47. setiastro/saspro/blink_comparator_pro.py +150 -55
  48. setiastro/saspro/clahe.py +4 -1
  49. setiastro/saspro/continuum_subtract.py +4 -1
  50. setiastro/saspro/convo.py +13 -7
  51. setiastro/saspro/cosmicclarity.py +129 -18
  52. setiastro/saspro/crop_dialog_pro.py +123 -7
  53. setiastro/saspro/curve_editor_pro.py +181 -64
  54. setiastro/saspro/curves_preset.py +249 -47
  55. setiastro/saspro/doc_manager.py +245 -15
  56. setiastro/saspro/exoplanet_detector.py +120 -28
  57. setiastro/saspro/frequency_separation.py +1158 -204
  58. setiastro/saspro/ghs_dialog_pro.py +81 -16
  59. setiastro/saspro/graxpert.py +1 -0
  60. setiastro/saspro/gui/main_window.py +706 -264
  61. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  62. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  63. setiastro/saspro/gui/mixins/menu_mixin.py +35 -1
  64. setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
  65. setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
  66. setiastro/saspro/gui/mixins/update_mixin.py +138 -36
  67. setiastro/saspro/gui/mixins/view_mixin.py +42 -0
  68. setiastro/saspro/halobgon.py +4 -0
  69. setiastro/saspro/histogram.py +184 -8
  70. setiastro/saspro/image_combine.py +4 -0
  71. setiastro/saspro/image_peeker_pro.py +4 -0
  72. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  73. setiastro/saspro/imageops/serloader.py +1345 -0
  74. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  75. setiastro/saspro/imageops/stretch.py +582 -62
  76. setiastro/saspro/isophote.py +4 -0
  77. setiastro/saspro/layers.py +13 -9
  78. setiastro/saspro/layers_dock.py +183 -3
  79. setiastro/saspro/legacy/image_manager.py +154 -20
  80. setiastro/saspro/legacy/numba_utils.py +68 -48
  81. setiastro/saspro/legacy/xisf.py +240 -98
  82. setiastro/saspro/live_stacking.py +203 -82
  83. setiastro/saspro/luminancerecombine.py +228 -27
  84. setiastro/saspro/mask_creation.py +174 -15
  85. setiastro/saspro/mfdeconv.py +113 -35
  86. setiastro/saspro/mfdeconvcudnn.py +119 -70
  87. setiastro/saspro/mfdeconvsport.py +112 -35
  88. setiastro/saspro/morphology.py +4 -0
  89. setiastro/saspro/multiscale_decomp.py +81 -29
  90. setiastro/saspro/narrowband_normalization.py +1618 -0
  91. setiastro/saspro/numba_utils.py +72 -57
  92. setiastro/saspro/ops/commands.py +18 -18
  93. setiastro/saspro/ops/script_editor.py +10 -2
  94. setiastro/saspro/ops/scripts.py +122 -0
  95. setiastro/saspro/perfect_palette_picker.py +37 -3
  96. setiastro/saspro/plate_solver.py +84 -49
  97. setiastro/saspro/psf_viewer.py +119 -37
  98. setiastro/saspro/remove_green.py +1 -1
  99. setiastro/saspro/resources.py +73 -0
  100. setiastro/saspro/rgbalign.py +460 -12
  101. setiastro/saspro/selective_color.py +4 -1
  102. setiastro/saspro/ser_stack_config.py +82 -0
  103. setiastro/saspro/ser_stacker.py +2321 -0
  104. setiastro/saspro/ser_stacker_dialog.py +1838 -0
  105. setiastro/saspro/ser_tracking.py +206 -0
  106. setiastro/saspro/serviewer.py +1625 -0
  107. setiastro/saspro/sfcc.py +662 -216
  108. setiastro/saspro/shortcuts.py +171 -33
  109. setiastro/saspro/signature_insert.py +692 -33
  110. setiastro/saspro/stacking_suite.py +1347 -485
  111. setiastro/saspro/star_alignment.py +247 -123
  112. setiastro/saspro/star_spikes.py +4 -0
  113. setiastro/saspro/star_stretch.py +38 -3
  114. setiastro/saspro/stat_stretch.py +892 -129
  115. setiastro/saspro/subwindow.py +787 -363
  116. setiastro/saspro/supernovaasteroidhunter.py +1 -1
  117. setiastro/saspro/texture_clarity.py +593 -0
  118. setiastro/saspro/wavescale_hdr.py +4 -1
  119. setiastro/saspro/wavescalede.py +4 -1
  120. setiastro/saspro/whitebalance.py +84 -12
  121. setiastro/saspro/widgets/common_utilities.py +28 -21
  122. setiastro/saspro/widgets/resource_monitor.py +209 -111
  123. setiastro/saspro/widgets/spinboxes.py +10 -13
  124. setiastro/saspro/wimi.py +27 -656
  125. setiastro/saspro/wims.py +13 -3
  126. setiastro/saspro/xisf.py +101 -11
  127. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
  128. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
  129. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
  130. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
  131. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
  132. {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/license.txt +0 -0
@@ -24,7 +24,7 @@ from setiastro.saspro.legacy.image_manager import load_image, save_image
24
24
  from setiastro.saspro.legacy.numba_utils import bulk_cosmetic_correction_numba
25
25
  from setiastro.saspro.imageops.stretch import stretch_mono_image, stretch_color_image
26
26
  from setiastro.saspro.star_alignment import PolyGradientRemoval
27
- from pro import minorbodycatalog as mbc
27
+ from setiastro.saspro import minorbodycatalog as mbc
28
28
  from setiastro.saspro.plate_solver import PlateSolverDialog as PlateSolver
29
29
  from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
30
30
 
@@ -0,0 +1,593 @@
1
+ # pro/texture_clarity.py
2
+ from __future__ import annotations
3
+ import numpy as np
4
+ import os
5
+
6
+ from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QPointF, QEvent
7
+ from PyQt6.QtWidgets import (
8
+ QDialog, QVBoxLayout, QLabel, QSlider, QHBoxLayout,
9
+ QPushButton, QMessageBox, QCheckBox, QScrollArea, QWidget
10
+ )
11
+ from PyQt6.QtGui import QPixmap, QImage, QMovie
12
+
13
+ try:
14
+ import cv2
15
+ except Exception:
16
+ cv2 = None
17
+
18
+ # ---------- utils ----------
19
+ from setiastro.saspro.widgets.image_utils import (
20
+ to_float01 as _to_float01,
21
+ extract_mask_from_document as _active_mask_array_from_doc
22
+ )
23
+
24
+ def _as_qimage_rgb8(float01: np.ndarray) -> QImage:
25
+ f = np.asarray(float01, dtype=np.float32)
26
+
27
+ # Ensure 3-channel RGB for preview
28
+ if f.ndim == 2:
29
+ f = np.stack([f]*3, axis=-1)
30
+ elif f.ndim == 3 and f.shape[2] == 1:
31
+ f = np.repeat(f, 3, axis=2)
32
+
33
+ # [0,1] -> uint8 and force C-contiguous
34
+ buf8 = (np.clip(f, 0.0, 1.0) * 255.0).astype(np.uint8, copy=False)
35
+ buf8 = np.ascontiguousarray(buf8)
36
+ h, w, _ = buf8.shape
37
+ bpl = int(buf8.strides[0])
38
+
39
+ # Detach
40
+ data = buf8.tobytes()
41
+ qimg = QImage(data, w, h, bpl, QImage.Format.Format_RGB888)
42
+ return qimg.copy()
43
+
44
+ def _ensure_rgb(arr: np.ndarray) -> np.ndarray | None:
45
+ a = _to_float01(arr)
46
+ if a is None: return None
47
+ if a.ndim == 2: return a
48
+ if a.ndim == 3 and a.shape[2] == 1: return a
49
+ if a.ndim == 3 and a.shape[2] >= 3:
50
+ return a[..., :3].astype(np.float32, copy=False)
51
+ return None
52
+
53
+ def _midtone_mask(image: np.ndarray) -> np.ndarray:
54
+ """
55
+ Generate a midtone mask where midtones (0.5) are 1.0 and shadows/highlights are 0.0.
56
+ """
57
+ return np.clip(1.0 - 4.0 * (image - 0.5)**2, 0.0, 1.0)
58
+
59
+ def _apply_texture(image: np.ndarray, amount: float, radius: float) -> np.ndarray:
60
+ """
61
+ TEXTURE: Enhances 'Texture' frequency band (Difference of Gaussians).
62
+ - Method: DoG (Band-pass). Isolate frequencies between Radius and 2*Radius.
63
+ """
64
+ if abs(amount) < 0.001: return image
65
+
66
+ # Ensure input is valid float32 and contiguous
67
+ img = np.ascontiguousarray(image, dtype=np.float32)
68
+ if np.any(np.isnan(img)):
69
+ img = np.nan_to_num(img)
70
+
71
+ sigma1 = radius
72
+ sigma2 = radius * 2.0
73
+
74
+ ksize1 = int(2 * round(3 * sigma1) + 1); ksize1 += 1 if ksize1 % 2 == 0 else 0
75
+ ksize2 = int(2 * round(3 * sigma2) + 1); ksize2 += 1 if ksize2 % 2 == 0 else 0
76
+
77
+ if cv2 is not None:
78
+ try:
79
+ b1 = cv2.GaussianBlur(img, (ksize1, ksize1), sigma1)
80
+ b2 = cv2.GaussianBlur(img, (ksize2, ksize2), sigma2)
81
+ except Exception:
82
+ # Fallback if CV2 fails
83
+ return image
84
+ else:
85
+ return image
86
+
87
+ texture_band = b1 - b2
88
+ boost = 2.0 * amount
89
+ enhanced = img + texture_band * boost
90
+ return np.clip(enhanced, 0.0, 1.0)
91
+
92
+ def _apply_clarity(image: np.ndarray, amount: float, radius: float) -> np.ndarray:
93
+ """
94
+ CLARITY: Local Contrast with Edge Preservation (Bilateral).
95
+ - Method: Original + Amount * (Original - Bilateral_Base).
96
+ - Optimization: Uses Downscale-Process-Upscale for large radii.
97
+ This allows effective large-radius filtering without using massive kernels that crash OpenCV.
98
+ - Safety: Kernel diameter 'd' is kept small relative to the processed image.
99
+ """
100
+ if abs(amount) < 0.001: return image
101
+
102
+ # Target Sigma Space
103
+ sigma_space_target = radius * 10.0
104
+ sigma_color = 0.1
105
+
106
+ img_f32 = np.ascontiguousarray(image, dtype=np.float32)
107
+ if np.any(np.isnan(img_f32)):
108
+ img_f32 = np.nan_to_num(img_f32)
109
+
110
+ base = img_f32
111
+
112
+ if cv2 is not None:
113
+ try:
114
+ # Multi-scale Logic:
115
+ # If sigma_space is large (e.g. > 10.0), downscale the image.
116
+ # This makes the "pixels" larger, so a small kernel covers more area.
117
+
118
+ scale = 1.0
119
+ if sigma_space_target > 10.0:
120
+ # Calculate scale factor
121
+ # We want the effective sigma on the downscaled image to be manageable, say ~5-10.
122
+ # scaled_sigma = sigma_target * scale
123
+ # scale = 5.0 / sigma_target
124
+ scale = 5.0 / sigma_space_target
125
+ scale = max(0.1, min(scale, 1.0)) # Limit minimum scale to 10%
126
+
127
+ # If downscaling is significant
128
+ if scale < 0.95:
129
+ h, w = img_f32.shape[:2]
130
+ small_w = int(w * scale)
131
+ small_h = int(h * scale)
132
+
133
+ # Resize down
134
+ small_img = cv2.resize(img_f32, (small_w, small_h), interpolation=cv2.INTER_AREA)
135
+
136
+ # Adjust sigma for the small scale
137
+ sigma_small = sigma_space_target * scale
138
+
139
+ # A safe 'd' for the small image.
140
+ # Since we successfully shrunk the problem, d=9 is now effectively d=9/scale in original pixels.
141
+ # e.g with scale 0.2, d=9 covers 45 original pixels.
142
+ d_safe = 9
143
+
144
+ small_base = cv2.bilateralFilter(small_img, d=d_safe, sigmaColor=sigma_color, sigmaSpace=sigma_small)
145
+
146
+ # Resize up (using linear/cubic to smooth)
147
+ base = cv2.resize(small_base, (w, h), interpolation=cv2.INTER_LINEAR)
148
+ else:
149
+ # Standard processing for small radii
150
+ d_safe = 9
151
+ base = cv2.bilateralFilter(img_f32, d=d_safe, sigmaColor=sigma_color, sigmaSpace=sigma_space_target)
152
+
153
+ except Exception as e:
154
+ print(f"Bilateral Filter failed: {e}")
155
+ try:
156
+ base = cv2.GaussianBlur(img_f32, (0, 0), sigma_space_target)
157
+ except:
158
+ return image
159
+ else:
160
+ return image
161
+
162
+ detail = img_f32 - base
163
+ mask = _midtone_mask(img_f32)
164
+ enhanced = img_f32 + amount * detail * mask
165
+
166
+ return np.clip(enhanced, 0.0, 1.0)
167
+
168
+ def _compute_texture_clarity(image: np.ndarray, texture_amt: float, texture_rad: float, clarity_amt: float, clarity_rad: float) -> np.ndarray:
169
+ # 1. Texture (DoG Band)
170
+ out = _apply_texture(image, texture_amt, texture_rad)
171
+
172
+ # 2. Clarity (Bilateral Base)
173
+ out = _apply_clarity(out, clarity_amt, clarity_rad)
174
+
175
+ return out
176
+
177
+ # ---------- headless core ----------
178
+ def texture_clarity_headless(
179
+ doc,
180
+ texture_amount: float = 0.0,
181
+ texture_radius: float = 1.0,
182
+ clarity_amount: float = 0.0,
183
+ clarity_radius: float = 1.0,
184
+ ):
185
+ if doc is None or getattr(doc, "image", None) is None:
186
+ return
187
+
188
+ src = np.asarray(doc.image)
189
+ f_src = _to_float01(src)
190
+ if f_src is None:
191
+ return
192
+
193
+ is_rgb = (f_src.ndim == 3 and f_src.shape[2] >= 3)
194
+
195
+ if is_rgb:
196
+ R, G, B = f_src[..., 0], f_src[..., 1], f_src[..., 2]
197
+ L = 0.2126 * R + 0.7152 * G + 0.0722 * B
198
+
199
+ L_new = _compute_texture_clarity(L, texture_amount, texture_radius, clarity_amount, clarity_radius)
200
+
201
+ eps = 1e-7
202
+ ratio = L_new / (L + eps)
203
+ out = f_src[..., :3] * ratio[..., None]
204
+ out = np.clip(out, 0.0, 1.0)
205
+ else:
206
+ if f_src.ndim == 3: f_src = f_src.squeeze()
207
+ out = _compute_texture_clarity(f_src, texture_amount, texture_radius, clarity_amount, clarity_radius)
208
+ if src.ndim == 3: out = out[..., None]
209
+
210
+ # mask-aware blend
211
+ m = _active_mask_array_from_doc(doc)
212
+ if m is not None:
213
+ h, w = out.shape[:2]
214
+ if m.shape != (h, w):
215
+ if cv2 is not None:
216
+ m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
217
+ else:
218
+ yi = (np.linspace(0, m.shape[0]-1, h)).astype(np.int32)
219
+ xi = (np.linspace(0, m.shape[1]-1, w)).astype(np.int32)
220
+ m = m[yi][:, xi]
221
+
222
+ if out.ndim == 3 and m.ndim == 2:
223
+ m = np.repeat(m[:, :, None], out.shape[2], axis=2)
224
+
225
+ src_f = _to_float01(src)
226
+ out = np.clip(src_f * (1.0 - m) + out * m, 0.0, 1.0)
227
+
228
+ meta = {
229
+ "step_name": "Texture and Clarity",
230
+ "texture_clarity": {
231
+ "texture_amount": texture_amount,
232
+ "texture_radius": texture_radius,
233
+ "clarity_amount": clarity_amount,
234
+ "clarity_radius": clarity_radius
235
+ }
236
+ }
237
+ doc.apply_edit(out.astype(np.float32, copy=False), metadata=meta, step_name="Texture and Clarity")
238
+
239
+
240
+ # ---------- Worker ----------
241
+
242
+ class TextureClarityWorker(QThread):
243
+ preview_ready = pyqtSignal(object) # np.ndarray [0..1]
244
+
245
+ def __init__(self, image: np.ndarray, params: dict, mask01: np.ndarray | None = None):
246
+ super().__init__()
247
+ self.image = image
248
+ self.params = params
249
+ self.mask01 = mask01 # (H,W) float [0..1] or None
250
+
251
+ def run(self):
252
+ src = _to_float01(self.image)
253
+ if src is None:
254
+ return
255
+
256
+ texture_amt = float(self.params.get("t_amt", 0.0))
257
+ texture_rad = float(self.params.get("t_rad", 1.0))
258
+ clarity_amt = float(self.params.get("c_amt", 0.0))
259
+ clarity_rad = float(self.params.get("c_rad", 1.0))
260
+
261
+ is_rgb = (src.ndim == 3 and src.shape[2] >= 3)
262
+
263
+ if is_rgb:
264
+ R, G, B = src[..., 0], src[..., 1], src[..., 2]
265
+ L = 0.2126 * R + 0.7152 * G + 0.0722 * B
266
+ L_new = _compute_texture_clarity(L, texture_amt, texture_rad, clarity_amt, clarity_rad)
267
+
268
+ eps = 1e-7
269
+ ratio = L_new / (L + eps)
270
+ out = src[..., :3] * ratio[..., None]
271
+ else:
272
+ s = src.squeeze() if src.ndim == 3 else src
273
+ out = _compute_texture_clarity(s, texture_amt, texture_rad, clarity_amt, clarity_rad)
274
+ if src.ndim == 3: # preserve HxWx1 if that’s what caller had
275
+ out = out[..., None]
276
+
277
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
278
+
279
+ # ✅ mask-aware blend (same behavior as headless)
280
+ m = self.mask01
281
+ if m is not None:
282
+ h, w = out.shape[:2]
283
+ if m.shape != (h, w):
284
+ if cv2 is not None:
285
+ m = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
286
+ else:
287
+ yi = (np.linspace(0, m.shape[0] - 1, h)).astype(np.int32)
288
+ xi = (np.linspace(0, m.shape[1] - 1, w)).astype(np.int32)
289
+ m = m[yi][:, xi]
290
+
291
+ # expand mask to channels
292
+ if out.ndim == 3 and m.ndim == 2:
293
+ m = m[:, :, None]
294
+
295
+ # make src match out shape for blending
296
+ src_f = src
297
+ if out.ndim == 2 and src_f.ndim == 3:
298
+ src_f = src_f.squeeze()
299
+ if out.ndim == 3 and src_f.ndim == 2:
300
+ src_f = np.repeat(src_f[:, :, None], out.shape[2], axis=2)
301
+ if out.ndim == 3 and src_f.ndim == 3 and src_f.shape[2] > out.shape[2]:
302
+ src_f = src_f[..., :out.shape[2]]
303
+
304
+ out = np.clip(src_f * (1.0 - m) + out * m, 0.0, 1.0).astype(np.float32, copy=False)
305
+
306
+ self.preview_ready.emit(out)
307
+
308
+
309
+ # ---------- Dialog ----------
310
+
311
+ class TextureClarityDialog(QDialog):
312
+ def __init__(self, main, doc, parent=None):
313
+ super().__init__(parent)
314
+ self.main = main
315
+ self.doc = doc
316
+ self.setWindowTitle("Texture and Clarity")
317
+ self.setWindowFlag(Qt.WindowType.Window, True)
318
+ self.setWindowModality(Qt.WindowModality.NonModal)
319
+ self.setModal(False)
320
+ self._preview = None
321
+ self._pix = None
322
+ self._zoom = 0.25
323
+ self._panning = False
324
+ self._pan_start = QPointF()
325
+
326
+ # Watch for active document changes
327
+ self._connected_doc_change = False
328
+ if hasattr(self.main, "currentDocumentChanged"):
329
+ try:
330
+ self.main.currentDocumentChanged.connect(self._on_active_doc_changed)
331
+ self._connected_doc_change = True
332
+ except Exception:
333
+ pass
334
+
335
+ # Debounce timer for preview
336
+ self._preview_timer = QTimer(self)
337
+ self._preview_timer.setSingleShot(True)
338
+ self._preview_timer.setInterval(150) # 150ms debounce
339
+ self._preview_timer.timeout.connect(self._trigger_preview)
340
+
341
+ self._build_ui()
342
+ # Initial preview
343
+ self._trigger_preview()
344
+
345
+ def _build_ui(self):
346
+ container = QHBoxLayout(self)
347
+
348
+ # Left Column: Controls
349
+ left_widget = QWidget()
350
+ left_widget.setMinimumWidth(350)
351
+ left = QVBoxLayout(left_widget)
352
+
353
+ # Texture
354
+ left.addWidget(QLabel("Texture"))
355
+ self.sl_tex_amt = QSlider(Qt.Orientation.Horizontal)
356
+ self.sl_tex_amt.setRange(-100, 100); self.sl_tex_amt.setValue(0)
357
+ self.lbl_tex_amt = QLabel("Amount: 0.00")
358
+ self.sl_tex_amt.valueChanged.connect(lambda v: self._on_param_change(self.lbl_tex_amt, f"Amount: {v/100.0:.2f}"))
359
+
360
+ self.sl_tex_rad = QSlider(Qt.Orientation.Horizontal)
361
+ self.sl_tex_rad.setRange(1, 20); self.sl_tex_rad.setValue(10)
362
+ self.lbl_tex_rad = QLabel("Radius: 1.0")
363
+ self.sl_tex_rad.valueChanged.connect(lambda v: self._on_param_change(self.lbl_tex_rad, f"Radius: {v/10.0:.1f}"))
364
+
365
+ left.addWidget(self.lbl_tex_amt); left.addWidget(self.sl_tex_amt)
366
+ left.addWidget(self.lbl_tex_rad); left.addWidget(self.sl_tex_rad)
367
+
368
+ left.addSpacing(20)
369
+
370
+ # Clarity
371
+ left.addWidget(QLabel("Clarity"))
372
+ self.sl_clar_amt = QSlider(Qt.Orientation.Horizontal)
373
+ self.sl_clar_amt.setRange(-100, 100); self.sl_clar_amt.setValue(0)
374
+ self.lbl_clar_amt = QLabel("Amount: 0.00")
375
+ self.sl_clar_amt.valueChanged.connect(lambda v: self._on_param_change(self.lbl_clar_amt, f"Amount: {v/100.0:.2f}"))
376
+
377
+ self.sl_clar_rad = QSlider(Qt.Orientation.Horizontal)
378
+ self.sl_clar_rad.setRange(1, 100); self.sl_clar_rad.setValue(30)
379
+ self.lbl_clar_rad = QLabel("Radius: 3.0")
380
+ self.sl_clar_rad.valueChanged.connect(lambda v: self._on_param_change(self.lbl_clar_rad, f"Radius: {v/10.0:.1f}"))
381
+
382
+ left.addWidget(self.lbl_clar_amt); left.addWidget(self.sl_clar_amt)
383
+ left.addWidget(self.lbl_clar_rad); left.addWidget(self.sl_clar_rad)
384
+
385
+ left.addSpacing(10)
386
+
387
+ # Toggle for Real-time Preview (Requested: below sliders)
388
+ self.chk_realtime = QCheckBox("Real-time Preview")
389
+ self.chk_realtime.setChecked(True)
390
+ self.chk_realtime.toggled.connect(self._trigger_preview)
391
+ left.addWidget(self.chk_realtime)
392
+
393
+ left.addStretch(1)
394
+
395
+ # Buttons
396
+ row = QHBoxLayout()
397
+ self.btn_apply = QPushButton("Apply"); self.btn_apply.clicked.connect(self._apply)
398
+
399
+ # Reset Button (Requested)
400
+ self.btn_reset = QPushButton("Reset"); self.btn_reset.clicked.connect(self._reset_sliders)
401
+
402
+ btn_cancel= QPushButton("Cancel"); btn_cancel.clicked.connect(self.close)
403
+
404
+ row.addWidget(self.btn_apply)
405
+ row.addWidget(self.btn_reset)
406
+ row.addWidget(btn_cancel)
407
+ left.addLayout(row)
408
+
409
+ container.addWidget(left_widget, 0) # stretch 0
410
+
411
+ # Right Column: Preview
412
+ right = QVBoxLayout()
413
+
414
+ # Zoom controls
415
+ zoombar = QHBoxLayout()
416
+ b_out = QPushButton("Zoom -"); b_out.clicked.connect(self._zoom_out)
417
+ b_in = QPushButton("Zoom +"); b_in.clicked.connect(self._zoom_in)
418
+ b_fit = QPushButton("Fit"); b_fit.clicked.connect(self._fit)
419
+
420
+ zoombar.addWidget(b_out); zoombar.addWidget(b_in); zoombar.addWidget(b_fit)
421
+ right.addLayout(zoombar)
422
+
423
+ self.scroll = QScrollArea()
424
+ self.scroll.setWidgetResizable(True)
425
+ self.scroll.setAlignment(Qt.AlignmentFlag.AlignCenter)
426
+ self.scroll.viewport().installEventFilter(self)
427
+
428
+ self.preview_lbl = QLabel(alignment=Qt.AlignmentFlag.AlignCenter)
429
+ self.scroll.setWidget(self.preview_lbl)
430
+
431
+ right.addWidget(self.scroll, 1) # stretch 1 (expands)
432
+
433
+ container.addLayout(right, 1)
434
+ self.resize(1000, 600) # Increased width slightly for wider controls
435
+
436
+ def _on_param_change(self, lbl, text):
437
+ lbl.setText(text)
438
+ if self.chk_realtime.isChecked():
439
+ self._preview_timer.start()
440
+
441
+ def _reset_sliders(self):
442
+ # Block signals to avoid 4 separate preview triggers
443
+ self.sl_tex_amt.blockSignals(True)
444
+ self.sl_tex_rad.blockSignals(True)
445
+ self.sl_clar_amt.blockSignals(True)
446
+ self.sl_clar_rad.blockSignals(True)
447
+
448
+ self.sl_tex_amt.setValue(0)
449
+ self.sl_tex_rad.setValue(10)
450
+ self.sl_clar_amt.setValue(0)
451
+ self.sl_clar_rad.setValue(30)
452
+
453
+ self.sl_tex_amt.blockSignals(False)
454
+ self.sl_tex_rad.blockSignals(False)
455
+ self.sl_clar_amt.blockSignals(False)
456
+ self.sl_clar_rad.blockSignals(False)
457
+
458
+ # Update labels manually
459
+ self.lbl_tex_amt.setText("Amount: 0.00")
460
+ self.lbl_tex_rad.setText("Radius: 1.0")
461
+ self.lbl_clar_amt.setText("Amount: 0.00")
462
+ self.lbl_clar_rad.setText("Radius: 3.0")
463
+
464
+ # Trigger one preview update
465
+ if self.chk_realtime.isChecked():
466
+ self._preview_timer.start()
467
+
468
+ def _trigger_preview(self):
469
+ if self.doc is None or getattr(self.doc, "image", None) is None:
470
+ return
471
+
472
+ # Preview Toggle: If unchecked, show Original Image (Before)
473
+ if not self.chk_realtime.isChecked():
474
+ # Show original
475
+ qimg = _as_qimage_rgb8(_to_float01(np.asarray(self.doc.image)))
476
+ self._pix = QPixmap.fromImage(qimg)
477
+ self._apply_zoom()
478
+ return
479
+
480
+ params = {
481
+ "t_amt": self.sl_tex_amt.value() / 100.0,
482
+ "t_rad": self.sl_tex_rad.value() / 10.0,
483
+ "c_amt": self.sl_clar_amt.value() / 100.0,
484
+ "c_rad": self.sl_clar_rad.value() / 10.0
485
+ }
486
+
487
+ # Kill old worker if running
488
+ if hasattr(self, "_worker") and self._worker.isRunning():
489
+ self._worker.terminate()
490
+ self._worker.wait()
491
+
492
+ # ✅ grab active mask at trigger time
493
+ mask01 = _active_mask_array_from_doc(self.doc)
494
+
495
+ self._worker = TextureClarityWorker(self.doc.image, params, mask01=mask01)
496
+ self._worker.preview_ready.connect(self._on_preview_ready)
497
+ self._worker.start()
498
+
499
+ def _on_preview_ready(self, out_img):
500
+ self._preview = out_img
501
+ qimg = _as_qimage_rgb8(out_img)
502
+ self._pix = QPixmap.fromImage(qimg)
503
+ self._apply_zoom()
504
+
505
+ def _apply(self):
506
+ if self.doc is None: return
507
+ t_amt = self.sl_tex_amt.value() / 100.0
508
+ t_rad = self.sl_tex_rad.value() / 10.0
509
+ c_amt = self.sl_clar_amt.value() / 100.0
510
+ c_rad = self.sl_clar_rad.value() / 10.0
511
+
512
+ texture_clarity_headless(
513
+ self.doc,
514
+ texture_amount=t_amt,
515
+ texture_radius=t_rad,
516
+ clarity_amount=c_amt,
517
+ clarity_radius=c_rad
518
+ )
519
+ self.close()
520
+
521
+ def _on_active_doc_changed(self, doc):
522
+ if doc is None or getattr(doc, "image", None) is None:
523
+ return
524
+ if doc is not self.doc:
525
+ self.doc = doc
526
+ self.setWindowTitle(f"Texture and Clarity - {doc.display_name() if hasattr(doc,'display_name') else 'Image'}")
527
+ # Reset preview
528
+ self._trigger_preview()
529
+
530
+ # --- Zoom / Pan ---
531
+ def _apply_zoom(self):
532
+ if self._pix is None: return
533
+ scaled = self._pix.scaled(
534
+ self._pix.size() * self._zoom,
535
+ Qt.AspectRatioMode.KeepAspectRatio,
536
+ Qt.TransformationMode.SmoothTransformation
537
+ )
538
+ self.preview_lbl.setPixmap(scaled)
539
+ self.preview_lbl.resize(scaled.size())
540
+
541
+ def _zoom_in(self): self._set_zoom(self._zoom * 1.25)
542
+ def _zoom_out(self): self._set_zoom(self._zoom / 1.25)
543
+ def _set_zoom(self, z):
544
+ self._zoom = max(0.05, min(z, 5.0))
545
+ self._apply_zoom()
546
+ def _fit(self):
547
+ if self._pix is None: return
548
+ vp = self.scroll.viewport().size()
549
+ s = min(vp.width()/self._pix.width(), vp.height()/self._pix.height())
550
+ self._set_zoom(max(0.05, s))
551
+
552
+ def eventFilter(self, obj, ev):
553
+ if obj is self.scroll.viewport():
554
+ if ev.type() == QEvent.Type.Wheel and (ev.modifiers() & Qt.KeyboardModifier.ControlModifier):
555
+ self._set_zoom(self._zoom * (1.25 if ev.angleDelta().y() > 0 else 0.8))
556
+ ev.accept(); return True
557
+ if ev.type() == QEvent.Type.MouseButtonPress and ev.button() == Qt.MouseButton.LeftButton:
558
+ self._panning = True; self._pan_start = ev.position()
559
+ self.scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor)
560
+ ev.accept(); return True
561
+ if ev.type() == QEvent.Type.MouseMove and self._panning:
562
+ d = ev.position() - self._pan_start
563
+ h = self.scroll.horizontalScrollBar(); v = self.scroll.verticalScrollBar()
564
+ h.setValue(h.value() - int(d.x())); v.setValue(v.value() - int(d.y()))
565
+ self._pan_start = ev.position()
566
+ ev.accept(); return True
567
+ if ev.type() == QEvent.Type.MouseButtonRelease and ev.button() == Qt.MouseButton.LeftButton:
568
+ self._panning = False
569
+ self.scroll.viewport().setCursor(Qt.CursorShape.ArrowCursor)
570
+ ev.accept(); return True
571
+ return super().eventFilter(obj, ev)
572
+
573
+ def closeEvent(self, ev):
574
+ if self._connected_doc_change and hasattr(self.main, "currentDocumentChanged"):
575
+ try:
576
+ self.main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
577
+ except Exception:
578
+ pass
579
+ super().closeEvent(ev)
580
+
581
+ def open_texture_clarity_dialog(main, doc=None, preset: dict | None = None):
582
+ if doc is None:
583
+ doc = getattr(main, "_active_doc", None)
584
+ if callable(doc):
585
+ doc = doc()
586
+
587
+ if doc is None or getattr(doc, "image", None) is None:
588
+ QMessageBox.information(main, "Texture & Clarity", "Open an image first.")
589
+ return
590
+
591
+ dlg = TextureClarityDialog(main, doc, parent=main)
592
+ # If preset handling needed, add here (set sliders)
593
+ dlg.show()
@@ -202,7 +202,10 @@ class WaveScaleHDRDialogPro(QDialog):
202
202
  self.setWindowFlag(Qt.WindowType.Window, True)
203
203
  self.setWindowModality(Qt.WindowModality.NonModal)
204
204
  self.setModal(False)
205
-
205
+ try:
206
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
207
+ except Exception:
208
+ pass # older PyQt6 versions
206
209
  self._doc = doc
207
210
  base = getattr(doc, "image", None)
208
211
  if base is None:
@@ -204,7 +204,10 @@ class WaveScaleDarkEnhancerDialogPro(QDialog):
204
204
  self.setWindowFlag(Qt.WindowType.Window, True)
205
205
  self.setWindowModality(Qt.WindowModality.NonModal)
206
206
  self.setModal(False)
207
-
207
+ try:
208
+ self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
209
+ except Exception:
210
+ pass # older PyQt6 versions
208
211
  self._doc = doc
209
212
  base = getattr(doc, "image", None)
210
213
  if base is None: