setiastrosuitepro 1.6.0__py3-none-any.whl

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

Potentially problematic release.


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

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,1600 @@
1
+ # pro/pixelmath.py
2
+ from __future__ import annotations
3
+ import os
4
+ import re
5
+ import json
6
+ import numpy as np
7
+
8
+ from PyQt6.QtCore import Qt, QTimer, QPointF
9
+ from PyQt6.QtGui import QIcon, QCursor, QImage, QPixmap, QTransform, QActionGroup
10
+ from PyQt6.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QGridLayout, QLabel,
12
+ QPushButton, QPlainTextEdit, QComboBox, QDialogButtonBox, QRadioButton, QApplication, QSplitter,
13
+ QTabWidget, QWidget, QMessageBox, QMenu, QScrollArea, QButtonGroup, QListWidget, QListWidgetItem, QGraphicsView, QGraphicsScene, QGraphicsPixmapItem, QToolButton
14
+ )
15
+
16
+ from setiastro.saspro.autostretch import autostretch
17
+
18
+ # Import shared utilities
19
+ from setiastro.saspro.widgets.image_utils import nearest_resize_2d as _nearest_resize_2d
20
+ from setiastro.saspro.widgets.image_utils import float_to_qimage_rgb8 as _float_to_qimage_rgb8
21
+ from setiastro.saspro.widgets.themed_buttons import themed_toolbtn
22
+
23
+ # ---- Optional accelerators from setiastro.saspro.legacy.numba_utils -------------------------
24
+ try:
25
+ from setiastro.saspro.legacy.numba_utils import fast_mad as _fast_mad
26
+ except Exception:
27
+ _fast_mad = None
28
+
29
+ # _float_to_qimage_rgb8 imported from setiastro.saspro.widgets.image_utils
30
+
31
+
32
+ # =============================================================================
33
+ # PixelImage wrapper (vector ops, indexing, ^ as exponent, ~ as invert)
34
+ # =============================================================================
35
+ class PixelImage:
36
+ """
37
+ Lightweight wrapper to enable intuitive pixel math:
38
+ • Supports per-channel indexing: img[0], img[1], img[2] → (H,W) planes
39
+ • Broadcasts (H,W) ⇄ (H,W,3) for +,-,*,/, power, and comparisons
40
+ • ~img means (1 - img)
41
+ """
42
+ __array_priority__ = 10_000 # ensure numpy uses our dunder ops
43
+
44
+ def __init__(self, array: np.ndarray):
45
+ self.array = np.asarray(array, dtype=np.float32)
46
+
47
+ # ---- channel indexing ----
48
+ def __getitem__(self, ch):
49
+ a = self.array
50
+ if a.ndim < 3:
51
+ raise ValueError("This image has no channel dimension to index.")
52
+ if not (0 <= ch < a.shape[2]):
53
+ raise IndexError(f"Channel index {ch} out of range for shape {a.shape}")
54
+ return PixelImage(a[..., ch])
55
+
56
+ # ---- shape coercion (H,W) ⇄ (H,W,3) ----
57
+ @staticmethod
58
+ def _coerce(a, b):
59
+ a = np.asarray(a, dtype=np.float32)
60
+ b = np.asarray(b, dtype=np.float32)
61
+ if a.ndim == 3 and b.ndim == 2:
62
+ b = np.repeat(b[..., None], a.shape[2], axis=2)
63
+ elif a.ndim == 2 and b.ndim == 3:
64
+ a = np.repeat(a[..., None], b.shape[2], axis=2)
65
+ return a, b
66
+
67
+ # ---- binary arithmetic helpers ----
68
+ def _bin(self, other, op):
69
+ a = self.array
70
+ b = other.array if isinstance(other, PixelImage) else other
71
+ a, b = self._coerce(a, b)
72
+ return PixelImage(op(a, b))
73
+
74
+ # ---- comparisons with coercion (return ndarray masks) ----
75
+ def _cmp(self, other, op):
76
+ a = self.array
77
+ b = other.array if isinstance(other, PixelImage) else other
78
+ a, b = self._coerce(a, b)
79
+ return op(a, b)
80
+
81
+ # ---- arithmetic ----
82
+ __add__ = lambda self, o: self._bin(o, np.add)
83
+ __radd__ = __add__
84
+ __sub__ = lambda self, o: self._bin(o, np.subtract)
85
+ __mul__ = lambda self, o: self._bin(o, np.multiply)
86
+ __rmul__ = __mul__
87
+ __truediv__ = lambda self, o: self._bin(o, np.divide)
88
+
89
+ def __rsub__(self, o):
90
+ a, b = self._coerce(o.array if isinstance(o, PixelImage) else o, self.array)
91
+ return PixelImage(np.subtract(a, b))
92
+
93
+ def __rtruediv__(self, o):
94
+ a, b = self._coerce(o.array if isinstance(o, PixelImage) else o, self.array)
95
+ return PixelImage(np.divide(a, b))
96
+
97
+ # power ** and ^
98
+ def __pow__(self, o):
99
+ a = self.array; b = o.array if isinstance(o, PixelImage) else o
100
+ a, b = self._coerce(a, b)
101
+ return PixelImage(np.power(a, b))
102
+
103
+ def __rpow__(self, o):
104
+ a = o.array if isinstance(o, PixelImage) else o; b = self.array
105
+ a, b = self._coerce(a, b)
106
+ return PixelImage(np.power(a, b))
107
+
108
+ # keep ^ as alias for power for convenience
109
+ def __xor__(self, o):
110
+ return self.__pow__(o)
111
+
112
+ def __rxor__(self, o):
113
+ return self.__rpow__(o)
114
+
115
+ # invert (~img) → 1 - img
116
+ def __invert__(self):
117
+ return PixelImage(1.0 - self.array)
118
+
119
+ # ---- comparisons (return boolean ndarray) ----
120
+ __lt__ = lambda self, o: self._cmp(o, np.less)
121
+ __le__ = lambda self, o: self._cmp(o, np.less_equal)
122
+ __eq__ = lambda self, o: self._cmp(o, np.equal)
123
+ __ne__ = lambda self, o: self._cmp(o, np.not_equal)
124
+ __gt__ = lambda self, o: self._cmp(o, np.greater)
125
+ __ge__ = lambda self, o: self._cmp(o, np.greater_equal)
126
+
127
+ def __repr__(self):
128
+ return f"PixelImage(shape={self.array.shape}, dtype={self.array.dtype})"
129
+
130
+
131
+
132
+ # =============================================================================
133
+ # Helpers
134
+ # =============================================================================
135
+ _ID_RX = re.compile(r'[^0-9a-zA-Z_]+')
136
+ def _sanitize_ident(name: str) -> str:
137
+ s = _ID_RX.sub('_', str(name)).strip('_')
138
+ if not s: s = "view"
139
+ if s[0].isdigit(): s = "_" + s
140
+ return s
141
+
142
+ def _as_rgb(arr: np.ndarray) -> np.ndarray:
143
+ a = np.asarray(arr, dtype=np.float32)
144
+ a = np.clip(a, 0.0, 1.0)
145
+ if a.ndim == 2:
146
+ a = np.repeat(a[..., None], 3, axis=2)
147
+ elif a.ndim == 3 and a.shape[2] == 1:
148
+ a = np.repeat(a, 3, axis=2)
149
+ return a
150
+
151
+ # _nearest_resize_2d imported from setiastro.saspro.widgets.image_utils
152
+
153
+ def _get_doc_active_mask_2d(doc, H: int, W: int) -> np.ndarray | None:
154
+ """
155
+ Returns the active mask as a 2-D float32 array in [0..1], resized to (H,W).
156
+ """
157
+ if doc is None:
158
+ return None
159
+ mid = getattr(doc, "active_mask_id", None)
160
+ if not mid:
161
+ return None
162
+ masks = getattr(doc, "masks", {}) or {}
163
+ layer = masks.get(mid)
164
+ if layer is None:
165
+ return None
166
+
167
+ # Extract data robustly without using `or` on arrays
168
+ data = None
169
+ # object-style
170
+ for attr in ("data", "mask", "image", "array"):
171
+ if hasattr(layer, attr):
172
+ val = getattr(layer, attr)
173
+ if val is not None:
174
+ data = val
175
+ break
176
+ # dict-style
177
+ if data is None and isinstance(layer, dict):
178
+ for key in ("data", "mask", "image", "array"):
179
+ if key in layer and layer[key] is not None:
180
+ data = layer[key]
181
+ break
182
+ # ndarray
183
+ if data is None and isinstance(layer, np.ndarray):
184
+ data = layer
185
+ if data is None:
186
+ return None
187
+
188
+ m = np.asarray(data)
189
+ if m.ndim == 3: # collapse RGB(A) → gray
190
+ m = m.mean(axis=2)
191
+ m = m.astype(np.float32, copy=False)
192
+
193
+ # normalize to [0..1]
194
+ if m.max(initial=0.0) > 1.0:
195
+ m /= float(m.max())
196
+
197
+ m = np.clip(m, 0.0, 1.0)
198
+ return _nearest_resize_2d(m, H, W)
199
+
200
+ def _mask_for_ref(doc, ref_like: np.ndarray) -> np.ndarray | None:
201
+ """
202
+ Returns a mask shaped for `ref_like`:
203
+ - 2-D for mono ref
204
+ - H×W×C (broadcast) for color ref
205
+ """
206
+ ref = np.asarray(ref_like)
207
+ H, W = ref.shape[:2]
208
+ m2d = _get_doc_active_mask_2d(doc, H, W)
209
+ if m2d is None:
210
+ return None
211
+ if ref.ndim == 3:
212
+ return np.repeat(m2d[:, :, None], ref.shape[2], axis=2)
213
+ return m2d
214
+
215
+ def _blend_masked(base: np.ndarray, out: np.ndarray, m: np.ndarray) -> np.ndarray:
216
+ base = _as_rgb(base) # (H,W,3)
217
+ out = _as_rgb(out) # (H,W,3)
218
+ m = np.asarray(m, dtype=np.float32)
219
+ m = np.clip(m, 0.0, 1.0)
220
+
221
+ # Allow 2-D or 3-D masks
222
+ if m.ndim == 2:
223
+ m = m[..., None] # (H,W,1)
224
+ elif m.ndim == 3 and m.shape[2] not in (1, 3):
225
+ raise ValueError("Mask must be 2-D or have 1 or 3 channels.")
226
+
227
+ return np.clip(base * (1.0 - m) + out * m, 0.0, 1.0)
228
+
229
+
230
+ # =============================================================================
231
+ # Headless apply
232
+ # =============================================================================
233
+ def apply_pixel_math_to_doc(parent, doc, preset: dict | None):
234
+ if doc is None or getattr(doc, "image", None) is None:
235
+ raise RuntimeError("Document has no image.")
236
+ expr = (preset or {}).get("expr", "").strip()
237
+ ev = _Evaluator(parent, doc)
238
+ if expr:
239
+ out = ev.eval_single(expr)
240
+ else:
241
+ r = (preset or {}).get("expr_r", "").strip()
242
+ g = (preset or {}).get("expr_g", "").strip()
243
+ b = (preset or {}).get("expr_b", "").strip()
244
+ if not (r or g or b):
245
+ raise RuntimeError("Pixel Math preset empty.")
246
+ out = ev.eval_rgb(r, g, b, default_channels=(0, 1, 2))
247
+
248
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
249
+ if hasattr(doc, "set_image"):
250
+ doc.set_image(out, step_name="Pixel Math")
251
+ elif hasattr(doc, "apply_numpy"):
252
+ doc.apply_numpy(out, step_name="Pixel Math")
253
+ else:
254
+ doc.image = out
255
+
256
+ # =============================================================================
257
+ # Evaluator
258
+ # =============================================================================
259
+ class _Evaluator:
260
+ def __init__(self, parent, doc):
261
+ self.parent = parent
262
+ self.doc = doc
263
+ self._build_namespace()
264
+
265
+ def _build_namespace(self):
266
+ self.ns = {
267
+ "np": np,
268
+ # existing:
269
+ "med": self._med, "mean": self._mean, "min": self._min, "max": self._max,
270
+ "std": self._std, "mad": self._mad, "log": self._log, "iff": self._iff, "mtf": self._mtf,
271
+ # new math helpers:
272
+ "clamp": self._clamp,
273
+ "rescale": self._rescale,
274
+ "gamma": self._gamma,
275
+ "pow_safe": self._pow_safe,
276
+ "absf": self._absf,
277
+ "expf": self._expf,
278
+ "sqrtf": self._sqrtf,
279
+ "arcsin": self._arcsin,
280
+ "sigmoid": self._sigmoid,
281
+ "smoothstep": self._smoothstep,
282
+ "lerp": self._lerp, "mix": self._lerp,
283
+ # stats / normalization:
284
+ "percentile": self._percentile,
285
+ "normalize01": self._normalize01,
286
+ "zscore": self._zscore,
287
+ # channels & color:
288
+ "ch": self._ch,
289
+ "luma": self._luma,
290
+ "compose": self._compose,
291
+ # mask helpers:
292
+ "mask": self._mask_fn,
293
+ "apply_mask": self._apply_mask_fn,
294
+ # optional filters (cv2-backed):
295
+ "boxblur": self._boxblur,
296
+ "gauss": self._gauss,
297
+ "median": self._median,
298
+ "unsharp": self._unsharp,
299
+ # constants:
300
+ "pi": float(np.pi), "e": float(np.e), "EPS": 1e-8,
301
+ }
302
+
303
+ cur = np.asarray(self.doc.image, dtype=np.float32)
304
+ self._img_shape = cur.shape
305
+ self.ns["img"] = PixelImage(_as_rgb(cur))
306
+
307
+ H, W = cur.shape[:2]
308
+ C = 1 if cur.ndim == 2 else cur.shape[2]
309
+ self.ns["H"], self.ns["W"], self.ns["C"] = int(H), int(W), int(C)
310
+ self.ns["shape"] = (int(H), int(W), int(C))
311
+
312
+ # Normalized coordinate grids (2-D, float32)
313
+ xx = np.linspace(0.0, 1.0, W, dtype=np.float32)
314
+ yy = np.linspace(0.0, 1.0, H, dtype=np.float32)
315
+ X, Y = np.meshgrid(xx, yy)
316
+ self.ns["X"] = X
317
+ self.ns["Y"] = Y
318
+
319
+ # map: raw title → ident (existing)
320
+ self.title_map = []
321
+ open_docs = []
322
+ if hasattr(self.parent, "_subwindow_docs"):
323
+ open_docs = list(self.parent._subwindow_docs())
324
+ else:
325
+ open_docs = [(getattr(self.doc, "display_name", lambda: "view")(), self.doc)]
326
+
327
+ used = set(self.ns.keys())
328
+ for raw_title, d in open_docs:
329
+ ident = _sanitize_ident(raw_title or "view")
330
+ base, i = ident, 2
331
+ while ident in used:
332
+ ident = f"{base}_{i}"; i += 1
333
+ used.add(ident)
334
+ arr = getattr(d, "image", None)
335
+ if arr is None:
336
+ continue
337
+ self.ns[ident] = PixelImage(np.asarray(arr, dtype=np.float32)) # keep native 2D/3D
338
+ self.title_map.append((str(raw_title), ident))
339
+
340
+ # -------- expression rewriting: allow raw window titles in user code
341
+ def _rewrite_names(self, expr: str) -> str:
342
+ if not expr: return expr
343
+ out = expr
344
+ for raw, ident in self.title_map:
345
+ # raw title
346
+ pat = re.compile(rf'(?<![\w]){re.escape(raw)}(?![\w])')
347
+ out = pat.sub(ident, out)
348
+ # basename without extension
349
+ base = os.path.splitext(raw)[0]
350
+ if base and base != raw:
351
+ pat2 = re.compile(rf'(?<![\w]){re.escape(base)}(?![\w])')
352
+ out = pat2.sub(ident, out)
353
+ return out
354
+
355
+ # -------- functions
356
+ def _med(self, x):
357
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
358
+ if a.ndim == 2:
359
+ v = np.median(a); out = np.full_like(a, v)
360
+ else:
361
+ v = np.median(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
362
+ return PixelImage(out) if isinstance(x, PixelImage) else out
363
+
364
+ def _mean(self, x):
365
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
366
+ if a.ndim == 2:
367
+ v = np.mean(a); out = np.full_like(a, v)
368
+ else:
369
+ v = np.mean(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
370
+ return PixelImage(out) if isinstance(x, PixelImage) else out
371
+
372
+ def _min(self, x):
373
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
374
+ if a.ndim == 2:
375
+ v = np.min(a); out = np.full_like(a, v)
376
+ else:
377
+ v = np.min(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
378
+ return PixelImage(out) if isinstance(x, PixelImage) else out
379
+
380
+ def _max(self, x):
381
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
382
+ if a.ndim == 2:
383
+ v = np.max(a); out = np.full_like(a, v)
384
+ else:
385
+ v = np.max(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
386
+ return PixelImage(out) if isinstance(x, PixelImage) else out
387
+
388
+ def _std(self, x):
389
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
390
+ if a.ndim == 2:
391
+ v = np.std(a); out = np.full_like(a, v)
392
+ else:
393
+ v = np.std(a, axis=(0, 1)); out = np.tile(v, (*a.shape[:2], 1))
394
+ return PixelImage(out) if isinstance(x, PixelImage) else out
395
+
396
+ def _mad(self, x):
397
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
398
+ if a.ndim == 2:
399
+ if _fast_mad is not None:
400
+ v = float(_fast_mad(a))
401
+ else:
402
+ m = np.median(a); v = np.median(np.abs(a - m))
403
+ out = np.full_like(a, v)
404
+ else:
405
+ out = np.empty_like(a)
406
+ for c in range(a.shape[2]):
407
+ ch = a[..., c]
408
+ if _fast_mad is not None:
409
+ v = float(_fast_mad(ch))
410
+ else:
411
+ m = np.median(ch); v = np.median(np.abs(ch - m))
412
+ out[..., c] = v
413
+ return PixelImage(out) if isinstance(x, PixelImage) else out
414
+
415
+ def _log(self, x):
416
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
417
+ with np.errstate(divide='ignore', invalid='ignore'):
418
+ y = np.log(np.clip(a, 1e-12, None))
419
+ return PixelImage(y) if isinstance(x, PixelImage) else y
420
+
421
+ def _iff(self, cond, a, b):
422
+ c = cond.array if isinstance(cond, PixelImage) else cond
423
+ av = a.array if isinstance(a, PixelImage) else a
424
+ bv = b.array if isinstance(b, PixelImage) else b
425
+ r = np.where(c, av, bv)
426
+ return PixelImage(r) if any(isinstance(z, PixelImage) for z in (cond, a, b)) else r
427
+
428
+ def _mtf(self, x, m):
429
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x)
430
+ with np.errstate(divide='ignore', invalid='ignore'):
431
+ y = ((m - 1.0) * a) / (((2.0 * m - 1.0) * a) - m)
432
+ y = np.nan_to_num(y, nan=0.0, posinf=1.0, neginf=0.0)
433
+ return PixelImage(y) if isinstance(x, PixelImage) else y
434
+
435
+ # ---- math helpers ----
436
+ def _clamp(self, x, lo=0.0, hi=1.0):
437
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
438
+ y = np.clip(a, float(lo), float(hi))
439
+ return PixelImage(y) if isinstance(x, PixelImage) else y
440
+
441
+ def _rescale(self, x, a, b, lo=0.0, hi=1.0):
442
+ a = np.asarray(x.array if isinstance(x, PixelImage) else x, dtype=np.float32)
443
+ src_lo, src_hi = float(a.min()), float(a.max())
444
+ if np.isfinite(a).any():
445
+ src_lo, src_hi = float(a), float(b)
446
+ # avoid div-by-zero
447
+ denom = max(src_hi - src_lo, 1e-12)
448
+ y = (a - src_lo) / denom
449
+ y = y * (hi - lo) + lo
450
+ return PixelImage(y) if isinstance(x, PixelImage) else y
451
+
452
+ def _gamma(self, x, g):
453
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
454
+ y = np.power(np.clip(a, 0.0, 1.0), float(g))
455
+ return PixelImage(y) if isinstance(x, PixelImage) else y
456
+
457
+ def _pow_safe(self, x, p):
458
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
459
+ y = np.power(np.clip(a, 1e-8, None), float(p))
460
+ return PixelImage(y) if isinstance(x, PixelImage) else y
461
+
462
+ def _absf(self, x):
463
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
464
+ y = np.abs(a)
465
+ return PixelImage(y) if isinstance(x, PixelImage) else y
466
+
467
+ def _expf(self, x):
468
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
469
+ y = np.exp(a)
470
+ return PixelImage(y) if isinstance(x, PixelImage) else y
471
+
472
+ def _sqrtf(self, x):
473
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
474
+ y = np.sqrt(np.clip(a, 0.0, None))
475
+ return PixelImage(y) if isinstance(x, PixelImage) else y
476
+
477
+ def _arcsin(self, x):
478
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
479
+ y = np.arcsin(np.clip(a, -1.0, 1.0))
480
+ return PixelImage(y) if isinstance(x, PixelImage) else y
481
+
482
+
483
+ def _sigmoid(self, x, k=10.0, mid=0.5):
484
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
485
+ y = 1.0 / (1.0 + np.exp(-float(k) * (a - float(mid))))
486
+ return PixelImage(y) if isinstance(x, PixelImage) else y
487
+
488
+ def _smoothstep(self, e0, e1, x):
489
+ e0, e1 = float(e0), float(e1)
490
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
491
+ t = np.clip((a - e0) / max(e1 - e0, 1e-12), 0.0, 1.0)
492
+ y = t * t * (3 - 2 * t)
493
+ return PixelImage(y) if isinstance(x, PixelImage) else y
494
+
495
+ def _lerp(self, a, b, t):
496
+ av = a.array if isinstance(a, PixelImage) else np.asarray(a, dtype=np.float32)
497
+ bv = b.array if isinstance(b, PixelImage) else np.asarray(b, dtype=np.float32)
498
+ tv = t.array if isinstance(t, PixelImage) else np.asarray(t, dtype=np.float32)
499
+ y = av * (1.0 - tv) + bv * tv
500
+ return PixelImage(y) if any(isinstance(z, PixelImage) for z in (a, b, t)) else y
501
+
502
+ # ---- stats/normalization ----
503
+ def _percentile(self, x, p):
504
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
505
+ if a.ndim == 2:
506
+ v = np.percentile(a, float(p))
507
+ out = np.full_like(a, v)
508
+ else:
509
+ out = np.empty_like(a)
510
+ for c in range(a.shape[2]):
511
+ v = np.percentile(a[..., c], float(p))
512
+ out[..., c] = v
513
+ return PixelImage(out) if isinstance(x, PixelImage) else out
514
+
515
+ def _normalize01(self, x):
516
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
517
+ if a.ndim == 2:
518
+ lo, hi = float(a.min()), float(a.max())
519
+ out = (a - lo) / max(hi - lo, 1e-12)
520
+ else:
521
+ out = np.empty_like(a)
522
+ for c in range(a.shape[2]):
523
+ ch = a[..., c]
524
+ lo, hi = float(ch.min()), float(ch.max())
525
+ out[..., c] = (ch - lo) / max(hi - lo, 1e-12)
526
+ return PixelImage(np.clip(out, 0.0, 1.0)) if isinstance(x, PixelImage) else np.clip(out, 0.0, 1.0)
527
+
528
+ def _zscore(self, x):
529
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
530
+ if a.ndim == 2:
531
+ m, s = float(a.mean()), float(a.std())
532
+ out = (a - m) / max(s, 1e-12)
533
+ else:
534
+ out = np.empty_like(a)
535
+ for c in range(a.shape[2]):
536
+ ch = a[..., c]
537
+ m, s = float(ch.mean()), float(ch.std())
538
+ out[..., c] = (ch - m) / max(s, 1e-12)
539
+ return PixelImage(out) if isinstance(x, PixelImage) else out
540
+
541
+ # ---- channels & color ----
542
+ def _ch(self, x, i):
543
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
544
+ if a.ndim != 3: raise ValueError("ch(x,i) expects RGB image")
545
+ return a[..., int(i)]
546
+
547
+ def _luma(self, x):
548
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
549
+ if a.ndim == 2:
550
+ return a
551
+ y = 0.2126*a[...,0] + 0.7152*a[...,1] + 0.0722*a[...,2]
552
+ return y
553
+
554
+ def _compose(self, r, g, b):
555
+ R = r.array if isinstance(r, PixelImage) else np.asarray(r, dtype=np.float32)
556
+ G = g.array if isinstance(g, PixelImage) else np.asarray(g, dtype=np.float32)
557
+ B = b.array if isinstance(b, PixelImage) else np.asarray(b, dtype=np.float32)
558
+ if R.ndim != 2 or G.ndim != 2 or B.ndim != 2:
559
+ raise ValueError("compose(r,g,b) expects three 2-D planes")
560
+ return np.stack([R, G, B], axis=2)
561
+
562
+ # ---- mask helpers exposed to the user ----
563
+ def _mask_fn(self):
564
+ ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
565
+ m = _mask_for_ref(self.doc, ref)
566
+ if m is None:
567
+ m = np.zeros(ref.shape[:2], dtype=np.float32)
568
+ if m.ndim == 3:
569
+ m = m[...,0]
570
+ return m
571
+
572
+ def _apply_mask_fn(self, base, out, m):
573
+ basev = base.array if isinstance(base, PixelImage) else np.asarray(base, dtype=np.float32)
574
+ outv = out.array if isinstance(out, PixelImage) else np.asarray(out, dtype=np.float32)
575
+ mv = m.array if isinstance(m, PixelImage) else np.asarray(m, dtype=np.float32)
576
+ return _blend_masked(basev, outv, mv) # _blend_masked now handles 2-D or 3-D
577
+
578
+
579
+ # ---- tiny filters (cv2 optional) ----
580
+ def _apply_per_channel(self, a, fn):
581
+ if a.ndim == 2:
582
+ return fn(a)
583
+ out = np.empty_like(a)
584
+ for c in range(a.shape[2]):
585
+ out[..., c] = fn(a[..., c])
586
+ return out
587
+
588
+ def _boxblur(self, x, k=3):
589
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
590
+ try:
591
+ import cv2
592
+ k = int(max(1, k))
593
+ y = self._apply_per_channel(a, lambda ch: cv2.blur(ch, (k, k)))
594
+ except Exception:
595
+ # naive fallback
596
+ from math import floor
597
+ k = int(max(1, k))
598
+ r = k//2
599
+ y = a.copy()
600
+ # very simple and slow fallback; okay as last resort
601
+ for i in range(a.shape[0]):
602
+ i0, i1 = max(0, i-r), min(a.shape[0], i+r+1)
603
+ for j in range(a.shape[1]):
604
+ j0, j1 = max(0, j-r), min(a.shape[1], j+r+1)
605
+ y[i, j] = a[i0:i1, j0:j1].mean(axis=(0,1))
606
+ return PixelImage(y) if isinstance(x, PixelImage) else y
607
+
608
+ def _gauss(self, x, sigma=1.0):
609
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
610
+ try:
611
+ import cv2
612
+ s = float(sigma)
613
+ k = int(max(1, 2*int(3*s)+1))
614
+ y = self._apply_per_channel(a, lambda ch: cv2.GaussianBlur(ch, (k, k), s))
615
+ except Exception:
616
+ # approximate with box blur passes
617
+ y = self._boxblur(a, k=max(1, int(2*sigma)+1))
618
+ y = y.array if isinstance(y, PixelImage) else y
619
+ y = self._boxblur(PixelImage(y), k=max(1, int(2*sigma)+1))
620
+ y = y.array if isinstance(y, PixelImage) else y
621
+ return PixelImage(y) if isinstance(x, PixelImage) else y
622
+
623
+ def _median(self, x, k=3):
624
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
625
+ try:
626
+ import cv2
627
+ k = int(max(1, k)) | 1 # must be odd
628
+ y = self._apply_per_channel(a, lambda ch: cv2.medianBlur(ch, k))
629
+ except Exception:
630
+ # crude fallback: percentile in local box
631
+ y = self._boxblur(a, k=k) # not truly median, but better than nothing
632
+ y = y.array if isinstance(y, PixelImage) else y
633
+ return PixelImage(y) if isinstance(x, PixelImage) else y
634
+
635
+ def _unsharp(self, x, sigma=1.5, amount=1.0):
636
+ a = x.array if isinstance(x, PixelImage) else np.asarray(x, dtype=np.float32)
637
+ blur = self._gauss(PixelImage(a), sigma)
638
+ blur = blur.array if isinstance(blur, PixelImage) else blur
639
+ y = np.clip(a + float(amount) * (a - blur), 0.0, 1.0)
640
+ return PixelImage(y) if isinstance(x, PixelImage) else y
641
+
642
+
643
+ # -------- core eval
644
+ def _eval_multiline(self, expr: str):
645
+ lines = [ln for ln in (expr or "").splitlines() if ln.strip()]
646
+ if not lines:
647
+ return 0
648
+ scope = dict(self.ns)
649
+ for ln in lines[:-1]:
650
+ exec(ln, {"__builtins__": None}, scope)
651
+ return eval(lines[-1], {"__builtins__": None}, scope)
652
+
653
+ def eval_single(self, expr: str) -> np.ndarray:
654
+ expr = self._rewrite_names(expr)
655
+ r = self._eval_multiline(expr)
656
+ if isinstance(r, PixelImage):
657
+ r = r.array
658
+
659
+ ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
660
+ if np.isscalar(r):
661
+ r = np.full(ref.shape, float(r), dtype=np.float32)
662
+ r = _as_rgb(r.astype(np.float32, copy=False))
663
+
664
+ m = _mask_for_ref(self.doc, ref)
665
+ if m is not None:
666
+ r = _blend_masked(ref, r, m)
667
+ return r
668
+
669
+ def eval_rgb(self, er: str, eg: str, eb: str, default_channels=(0, 1, 2)) -> np.ndarray:
670
+ er, eg, eb = self._rewrite_names(er), self._rewrite_names(eg), self._rewrite_names(eb)
671
+ ref = _as_rgb(np.asarray(self.doc.image, dtype=np.float32))
672
+ H, W, _ = ref.shape
673
+
674
+ def one(e, ci: int):
675
+ if not e:
676
+ return 0
677
+ v = self._eval_multiline(e)
678
+ if isinstance(v, PixelImage):
679
+ v = v.array
680
+
681
+ # Scalars become (H,W) plane later, so handle that below
682
+ if np.isscalar(v):
683
+ return np.full((H, W), float(v), dtype=np.float32)
684
+
685
+ v = np.asarray(v, dtype=np.float32)
686
+
687
+ # NEW: if user returned a color (HxWx3) in per-channel slot, assume the tab's channel
688
+ if v.ndim == 3:
689
+ if v.shape[2] == 1:
690
+ v = v[..., 0]
691
+ else:
692
+ # auto-pick requested channel
693
+ v = v[..., int(ci)]
694
+
695
+ # At this point expect 2-D plane
696
+ if v.ndim != 2:
697
+ raise ValueError("Per-channel mode expects a 2-D result (or an RGB where the tab's channel can be taken).")
698
+ return v
699
+
700
+ R = one(er, default_channels[0])
701
+ G = one(eg, default_channels[1])
702
+ B = one(eb, default_channels[2])
703
+ out = np.stack([R, G, B], axis=2)
704
+
705
+ m = _mask_for_ref(self.doc, ref)
706
+ if m is not None:
707
+ out = _blend_masked(ref, out, m)
708
+ return out
709
+
710
+ class _PreviewView(QGraphicsView):
711
+ """QGraphicsView with left-drag panning and Ctrl+wheel zoom."""
712
+ def __init__(self, parent=None):
713
+ super().__init__(parent)
714
+ self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) # click & drag to pan
715
+ self.setTransformationAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
716
+ self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
717
+ self._zoom = 1.0
718
+ self._min_zoom = 0.05
719
+ self._max_zoom = 20.0
720
+
721
+ def wheelEvent(self, ev):
722
+ # Ctrl + wheel → zoom; otherwise, default scroll behavior
723
+ if ev.modifiers() & Qt.KeyboardModifier.ControlModifier:
724
+ angle = ev.angleDelta().y()
725
+ step = 1.25 if angle > 0 else 1/1.25
726
+ new_zoom = max(self._min_zoom, min(self._zoom * step, self._max_zoom))
727
+ step = new_zoom / self._zoom # clamp-aware step
728
+ self._zoom = new_zoom
729
+ self.scale(step, step)
730
+ ev.accept()
731
+ else:
732
+ super().wheelEvent(ev)
733
+
734
+ # helpers to keep external controls in sync
735
+ def zoom_reset(self):
736
+ self.resetTransform()
737
+ self._zoom = 1.0
738
+
739
+ def zoom_by(self, factor: float):
740
+ new_zoom = max(self._min_zoom, min(self._zoom * float(factor), self._max_zoom))
741
+ factor = new_zoom / self._zoom
742
+ self._zoom = new_zoom
743
+ self.scale(factor, factor)
744
+
745
+
746
+ # =============================================================================
747
+ # Dialog
748
+ # =============================================================================
749
+ class PixelMathDialogPro(QDialog):
750
+ """
751
+ Pixel Math with view-name variables.
752
+ • img → active view
753
+ • one variable per OPEN VIEW using the window title (sanitized).
754
+ • Output: Overwrite active OR Create new view
755
+ """
756
+ def __init__(self, parent, doc, icon: QIcon | None = None):
757
+ super().__init__(parent)
758
+ self.setWindowTitle("Pixel Math")
759
+ if icon:
760
+ try:
761
+ self.setWindowIcon(icon)
762
+ except Exception:
763
+ pass
764
+
765
+ self.doc = doc
766
+ self.ev = _Evaluator(parent, doc)
767
+
768
+ self._load_autostretch_prefs()
769
+
770
+ # ──────────────────────────────────────────────────────────────────────────
771
+ # Root split layout: controls (left, scrollable) | preview (right, flexible)
772
+ # ──────────────────────────────────────────────────────────────────────────
773
+ root = QHBoxLayout(self)
774
+
775
+ # Left column (controls)
776
+ left_scroll = QScrollArea()
777
+ left_scroll.setWidgetResizable(True)
778
+ left_panel = QWidget()
779
+ left_col = QVBoxLayout(left_panel)
780
+ left_col.setContentsMargins(0, 0, 0, 0)
781
+ left_col.setSpacing(8)
782
+ left_scroll.setWidget(left_panel)
783
+
784
+ # Right column (preview)
785
+ right_panel = QWidget()
786
+ right_col = QVBoxLayout(right_panel)
787
+ right_col.setContentsMargins(0, 0, 0, 0)
788
+ right_col.setSpacing(8)
789
+
790
+ # Put them into a splitter so user can drag the boundary
791
+ splitter = QSplitter(Qt.Orientation.Horizontal, self)
792
+ splitter.setChildrenCollapsible(False)
793
+ splitter.addWidget(left_scroll)
794
+ splitter.addWidget(right_panel)
795
+ splitter.setStretchFactor(0, 0) # left: fixed-ish
796
+ splitter.setStretchFactor(1, 1) # right: grows
797
+
798
+ # Give the left side a reasonable minimum so it doesn't disappear
799
+ left_scroll.setMinimumWidth(260)
800
+
801
+ root.addWidget(splitter)
802
+ self._splitter = splitter # optional, if you ever want to tweak later
803
+
804
+
805
+ # ──────────────────────────────────────────────────────────────────────────
806
+ # Variables mapping (raw title → identifier)
807
+ # ──────────────────────────────────────────────────────────────────────────
808
+ vars_grp = QGroupBox("Variables")
809
+ vars_layout = QVBoxLayout(vars_grp)
810
+
811
+ self.vars_list = QListWidget()
812
+ self.vars_list.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
813
+ self.vars_list.setAlternatingRowColors(True)
814
+ self.vars_list.setTextElideMode(Qt.TextElideMode.ElideRight)
815
+
816
+ def _shorten_title(raw_title: str, ident: str, max_chars: int = 48) -> tuple[str, str]:
817
+ """
818
+ Return (display_text, full_text).
819
+ display_text is shortened so the left panel doesn't explode.
820
+ full_text is put into the tooltip.
821
+ """
822
+ base = str(raw_title)
823
+ if len(base) > max_chars:
824
+ head = max_chars // 2 - 1
825
+ tail = max_chars - head - 1
826
+ base = base[:head] + "…" + base[-tail:]
827
+ disp = f"{base} → {ident}"
828
+ full = f"{raw_title} → {ident}"
829
+ return disp, full
830
+
831
+
832
+ # First item = active view
833
+ active_item = QListWidgetItem("img (active)")
834
+ active_item.setData(Qt.ItemDataRole.UserRole, "img") # ← stash the real name
835
+ active_item.setToolTip("img (active)")
836
+ self.vars_list.addItem(active_item)
837
+
838
+ # Other open views
839
+ for raw, ident in self.ev.title_map:
840
+ disp, full = _shorten_title(raw, ident)
841
+ it = QListWidgetItem(disp)
842
+ it.setData(Qt.ItemDataRole.UserRole, ident) # ← stash the ident
843
+ it.setToolTip(full)
844
+ self.vars_list.addItem(it)
845
+
846
+ # Comfortable height; scroll appears as needed
847
+ self.vars_list.setMinimumHeight(120)
848
+ self.vars_list.setMaximumHeight(180)
849
+
850
+ hint = QLabel("Tip: double-click to insert the identifier at the cursor")
851
+ hint.setStyleSheet("color: gray; font-size: 11px;")
852
+
853
+ vars_layout.addWidget(self.vars_list)
854
+ vars_layout.addWidget(hint)
855
+
856
+ def _insert_ident_into_current_editor(item: QListWidgetItem):
857
+ ident = item.data(Qt.ItemDataRole.UserRole) or item.text().split("→", 1)[-1].strip()
858
+ ed = self.ed_single if self.rb_single.isChecked() else (
859
+ self.ed_r if self.tabs.currentIndex()==0 else self.ed_g if self.tabs.currentIndex()==1 else self.ed_b
860
+ )
861
+ ed.setFocus()
862
+ ed.insertPlainText(str(ident))
863
+
864
+ self._on_var_dblclick = _insert_ident_into_current_editor
865
+ try:
866
+ self.vars_list.itemDoubleClicked.connect(self._on_var_dblclick, Qt.ConnectionType.UniqueConnection)
867
+ except TypeError:
868
+ # Already connected; ignore
869
+ pass
870
+
871
+ left_col.addWidget(vars_grp)
872
+
873
+ # ──────────────────────────────────────────────────────────────────────────
874
+ # Output group
875
+ # ──────────────────────────────────────────────────────────────────────────
876
+ out_grp = QGroupBox("Output")
877
+ out_row = QHBoxLayout(out_grp)
878
+ self.rb_out_overwrite = QRadioButton("Overwrite active"); self.rb_out_overwrite.setChecked(True)
879
+ self.rb_out_new = QRadioButton("Create new view")
880
+ out_row.addWidget(self.rb_out_overwrite)
881
+ out_row.addWidget(self.rb_out_new)
882
+ out_row.addStretch(1)
883
+ left_col.addWidget(out_grp)
884
+
885
+ # ──────────────────────────────────────────────────────────────────────────
886
+ # Mode (single expression vs per-channel)
887
+ # ──────────────────────────────────────────────────────────────────────────
888
+ mode_row = QHBoxLayout()
889
+ self.rb_single = QRadioButton("Single Expression"); self.rb_single.setChecked(True)
890
+ self.rb_sep = QRadioButton("Separate (R / G / B)")
891
+ mode_row.addWidget(self.rb_single)
892
+ mode_row.addWidget(self.rb_sep)
893
+ mode_row.addStretch(1)
894
+ left_col.addLayout(mode_row)
895
+
896
+ self.mode_group = QButtonGroup(self)
897
+ self.mode_group.setExclusive(True)
898
+ self.mode_group.addButton(self.rb_single)
899
+ self.mode_group.addButton(self.rb_sep)
900
+
901
+ # Editors
902
+ self.ed_single = QPlainTextEdit()
903
+ self.ed_single.setPlaceholderText("e.g. (img + otherView) / 2")
904
+ left_col.addWidget(self.ed_single)
905
+
906
+ self.tabs = QTabWidget(); self.tabs.setVisible(False)
907
+ self.ed_r, self.ed_g, self.ed_b = QPlainTextEdit(), QPlainTextEdit(), QPlainTextEdit()
908
+ for ed, name in ((self.ed_r, "Red"), (self.ed_g, "Green"), (self.ed_b, "Blue")):
909
+ w = QWidget(); lay = QVBoxLayout(w); lay.addWidget(ed); self.tabs.addTab(w, name)
910
+ left_col.addWidget(self.tabs)
911
+
912
+ self.rb_single.toggled.connect(lambda on: self._mode(on))
913
+
914
+ glossary_btn = QPushButton("Glossary…")
915
+ glossary_btn.clicked.connect(self._open_glossary)
916
+ left_col.addWidget(glossary_btn)
917
+
918
+ # ──────────────────────────────────────────────────────────────────────────
919
+ # Preview (right side)
920
+ # ──────────────────────────────────────────────────────────────────────────
921
+ preview_grp = QGroupBox("Preview")
922
+ pv_lay = QVBoxLayout(preview_grp)
923
+
924
+ # Toolbar
925
+ tb = QHBoxLayout()
926
+ self.btn_preview = QPushButton("Preview")
927
+ self.btn_preview.setToolTip("Compute Pixel Math and show the result here without committing.")
928
+
929
+ # NEW: Auto-stretch toggle with a dropdown menu
930
+ self.btn_autostretch = QToolButton()
931
+ self.btn_autostretch.setText("Auto-stretch")
932
+ self.btn_autostretch.setCheckable(True)
933
+ self.btn_autostretch.setChecked(self._as_enabled)
934
+ self.btn_autostretch.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup)
935
+
936
+ as_menu = QMenu(self)
937
+ act_toggle = as_menu.addAction("Enable Auto-stretch")
938
+ act_toggle.setCheckable(True); act_toggle.setChecked(self._as_enabled)
939
+ as_menu.addSeparator()
940
+
941
+ # Target presets
942
+ tgt_menu = as_menu.addMenu("Target median")
943
+ self._tgt_group = QActionGroup(self); self._tgt_group.setExclusive(True)
944
+ for label, val in (("0.18 (soft)", 0.18), ("0.25 (default)", 0.25), ("0.35 (brighter)", 0.35)):
945
+ a = tgt_menu.addAction(label)
946
+ a.setCheckable(True)
947
+ a.setChecked(abs(self._as_target - val) < 1e-6)
948
+ self._tgt_group.addAction(a)
949
+ a.triggered.connect(lambda _=False, v=val: self._set_as_target(v))
950
+
951
+ # Sigma presets
952
+ sig_menu = as_menu.addMenu("Black-point sigma")
953
+ self._sig_group = QActionGroup(self); self._sig_group.setExclusive(True)
954
+ for label, val in (("σ=2.5", 2.5), ("σ=3 (default)", 3.0), ("σ=4 (deeper black)", 4.0)):
955
+ a = sig_menu.addAction(label)
956
+ a.setCheckable(True)
957
+ a.setChecked(abs(self._as_sigma - val) < 1e-6)
958
+ self._sig_group.addAction(a)
959
+ a.triggered.connect(lambda _=False, v=val: self._set_as_sigma(v))
960
+
961
+ # Linked channels
962
+ act_linked = as_menu.addAction("Linked channels (use luminance)")
963
+ act_linked.setCheckable(True); act_linked.setChecked(self._as_linked)
964
+ as_menu.addSeparator()
965
+
966
+ # Output precision
967
+ act_16 = as_menu.addAction("Use 16-bit stats")
968
+ act_16.setCheckable(True); act_16.setChecked(self._as_16bit)
969
+
970
+ self.btn_autostretch.setMenu(as_menu)
971
+
972
+ # Keep toggle and menu in sync
973
+ def _apply_menu_state():
974
+ self._as_enabled = self.btn_autostretch.isChecked()
975
+ act_toggle.setChecked(self._as_enabled)
976
+ self._save_autostretch_prefs()
977
+ self._rerun_preview_if_any()
978
+
979
+ self.btn_autostretch.toggled.connect(_apply_menu_state)
980
+
981
+ act_toggle.toggled.connect(lambda on: (self.btn_autostretch.setChecked(on),
982
+ self._save_autostretch_prefs(),
983
+ self._rerun_preview_if_any()))
984
+
985
+ act_linked.toggled.connect(lambda on: (setattr(self, "_as_linked", on),
986
+ self._save_autostretch_prefs(),
987
+ self._rerun_preview_if_any()))
988
+
989
+ act_16.toggled.connect( lambda on: (setattr(self, "_as_16bit", on),
990
+ self._save_autostretch_prefs(),
991
+ self._rerun_preview_if_any()))
992
+
993
+ # tiny helpers for radio menus
994
+ def _refresh_target_checks():
995
+ for a in self._tgt_group.actions():
996
+ txt = a.text() # "0.18 (soft)" / "0.25 (default)" / "0.35 (brighter)"
997
+ v = 0.18 if txt.startswith("0.18") else 0.25 if txt.startswith("0.25") else 0.35
998
+ a.setChecked(abs(self._as_target - v) < 1e-6)
999
+
1000
+ def _refresh_sigma_checks():
1001
+ for a in self._sig_group.actions():
1002
+ # texts are "σ=2.5", "σ=3 (default)", "σ=4 (deeper black)"
1003
+ tail = a.text().split("=", 1)[-1].strip().split()[0] # "2.5" / "3" / "4"
1004
+ v = float(tail)
1005
+ a.setChecked(abs(self._as_sigma - v) < 1e-6)
1006
+
1007
+ # API used by the actions above
1008
+ def _set_target(v: float):
1009
+ self._as_target = float(v)
1010
+ self._save_autostretch_prefs()
1011
+ _refresh_target_checks()
1012
+ self._rerun_preview_if_any()
1013
+
1014
+ def _set_sigma(v: float):
1015
+ self._as_sigma = float(v)
1016
+ self._save_autostretch_prefs()
1017
+ _refresh_sigma_checks()
1018
+ self._rerun_preview_if_any()
1019
+
1020
+ self._set_as_target = _set_target
1021
+ self._set_as_sigma = _set_sigma
1022
+
1023
+ # existing zoom buttons
1024
+ self.btn_zoom_in = themed_toolbtn("zoom-in", "Zoom In")
1025
+ self.btn_zoom_out = themed_toolbtn("zoom-out", "Zoom Out")
1026
+ self.btn_zoom_1_1 = themed_toolbtn("zoom-original", "1:1")
1027
+ self.btn_fit = themed_toolbtn("zoom-fit-best", "Fit to Preview")
1028
+
1029
+ tb.addWidget(self.btn_preview)
1030
+ tb.addWidget(self.btn_autostretch) # ← NEW
1031
+ tb.addSpacing(12)
1032
+ tb.addWidget(self.btn_zoom_in); tb.addWidget(self.btn_zoom_out)
1033
+ tb.addWidget(self.btn_zoom_1_1); tb.addWidget(self.btn_fit)
1034
+ tb.addStretch(1)
1035
+ pv_lay.addLayout(tb)
1036
+
1037
+ # Graphics view
1038
+ self.preview_view = _PreviewView() # <-- was QGraphicsView()
1039
+ self.preview_view.setRenderHints(self.preview_view.renderHints())
1040
+ self.preview_scene = QGraphicsScene(self.preview_view)
1041
+ self.preview_view.setScene(self.preview_scene)
1042
+ self._preview_item: QGraphicsPixmapItem | None = None
1043
+ self._preview_zoom = 1.0 # keep if you like; the view tracks its own zoom too
1044
+ pv_lay.addWidget(self.preview_view, 1)
1045
+
1046
+ right_col.addWidget(preview_grp, 1)
1047
+
1048
+ # Wire up preview actions
1049
+ self.btn_preview.clicked.connect(self._do_preview)
1050
+ self.btn_zoom_in.clicked.connect(lambda: self._zoom_by(1.25))
1051
+ self.btn_zoom_out.clicked.connect(lambda: self._zoom_by(1/1.25))
1052
+ self.btn_zoom_1_1.clicked.connect(self._zoom_reset_1_1)
1053
+ self.btn_fit.clicked.connect(self._fit_to_view)
1054
+
1055
+ # ──────────────────────────────────────────────────────────────────────────
1056
+ # Examples (insertable templates)
1057
+ # ──────────────────────────────────────────────────────────────────────────
1058
+ ex_row = QHBoxLayout()
1059
+ ex_row.addWidget(QLabel("Examples:"))
1060
+ self.cb_examples = QComboBox()
1061
+ self.cb_examples.addItem("Insert example…")
1062
+ for title, kind, payload in self._examples_list():
1063
+ self.cb_examples.addItem(title, (kind, payload))
1064
+ self.cb_examples.currentIndexChanged.connect(self._apply_example_from_combo)
1065
+ ex_row.addWidget(self.cb_examples, 1)
1066
+ left_col.addLayout(ex_row)
1067
+
1068
+ # ──────────────────────────────────────────────────────────────────────────
1069
+ # Favorites
1070
+ # ──────────────────────────────────────────────────────────────────────────
1071
+ fav_row = QHBoxLayout()
1072
+ self.cb_fav = QComboBox(); self.cb_fav.addItem("Select a favorite expression")
1073
+ self._load_favorites()
1074
+ self.cb_fav.currentTextChanged.connect(self._pick_favorite)
1075
+
1076
+ b_save = QPushButton("Save as Favorite")
1077
+ b_del = QPushButton("Delete Favorite")
1078
+
1079
+ b_save.clicked.connect(self._save_favorite)
1080
+ b_del.clicked.connect(self._delete_favorite)
1081
+
1082
+ fav_row.addWidget(self.cb_fav, 1)
1083
+ fav_row.addWidget(b_save)
1084
+ fav_row.addWidget(b_del)
1085
+ left_col.addLayout(fav_row)
1086
+
1087
+ def _fav_context_menu(point):
1088
+ if self.cb_fav.currentIndex() <= 0:
1089
+ return
1090
+ menu = QMenu(self)
1091
+ act_del = menu.addAction("Delete this favorite")
1092
+ act = menu.exec(self.cb_fav.mapToGlobal(point))
1093
+ if act == act_del:
1094
+ self._delete_favorite()
1095
+
1096
+ self.cb_fav.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
1097
+ self.cb_fav.customContextMenuRequested.connect(_fav_context_menu)
1098
+
1099
+ # ──────────────────────────────────────────────────────────────────────────
1100
+ # Buttons + Help (left)
1101
+ # ──────────────────────────────────────────────────────────────────────────
1102
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, parent=self)
1103
+ btns.accepted.connect(self._apply)
1104
+ btns.rejected.connect(self.reject)
1105
+ b_help = btns.addButton("Help", QDialogButtonBox.ButtonRole.HelpRole)
1106
+ b_help.clicked.connect(self._help)
1107
+ left_col.addWidget(btns)
1108
+
1109
+ # Output group selection model
1110
+ self.out_group = QButtonGroup(self)
1111
+ self.out_group.setExclusive(True)
1112
+ self.out_group.addButton(self.rb_out_overwrite)
1113
+ self.out_group.addButton(self.rb_out_new)
1114
+
1115
+ # Initialize editor visibility
1116
+ QTimer.singleShot(0, lambda: self._mode(self.rb_single.isChecked()))
1117
+
1118
+ # A little wider to favor the preview
1119
+ self.resize(940, 700)
1120
+ QTimer.singleShot(0, lambda: self._splitter.setSizes([320, max(620, self.width() - 320)]))
1121
+
1122
+ # ─────────────── Auto-stretch prefs ───────────────
1123
+ def _as_settings(self):
1124
+ p = self.parent()
1125
+ return getattr(p, "settings", None)
1126
+
1127
+ def _load_autostretch_prefs(self):
1128
+ s = self._as_settings()
1129
+ self._as_enabled = False
1130
+ self._as_linked = True
1131
+ self._as_target = 0.25
1132
+ self._as_sigma = 3.0
1133
+ self._as_16bit = True
1134
+ if s:
1135
+ self._as_enabled = s.value("pixelmath/preview_autostretch", False, type=bool)
1136
+ self._as_linked = s.value("pixelmath/preview_as_linked", True, type=bool)
1137
+ self._as_target = s.value("pixelmath/preview_as_target", 0.25, type=float)
1138
+ self._as_sigma = s.value("pixelmath/preview_as_sigma", 3.0, type=float)
1139
+ self._as_16bit = s.value("pixelmath/preview_as_16bit", True, type=bool)
1140
+
1141
+ def _save_autostretch_prefs(self):
1142
+ s = self._as_settings()
1143
+ if not s: return
1144
+ s.setValue("pixelmath/preview_autostretch", self._as_enabled)
1145
+ s.setValue("pixelmath/preview_as_linked", self._as_linked)
1146
+ s.setValue("pixelmath/preview_as_target", float(self._as_target))
1147
+ s.setValue("pixelmath/preview_as_sigma", float(self._as_sigma))
1148
+ s.setValue("pixelmath/preview_as_16bit", self._as_16bit)
1149
+
1150
+ def _rerun_preview_if_any(self):
1151
+ # If we already showed something, recompute so user sees the change immediately
1152
+ if self._preview_item is not None:
1153
+ QTimer.singleShot(0, self._do_preview)
1154
+
1155
+
1156
+ # ---------- Preview helpers ------------------------------------------------
1157
+ def _do_preview(self):
1158
+ QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
1159
+ try:
1160
+ if self.rb_single.isChecked():
1161
+ out = self.ev.eval_single(self.ed_single.toPlainText().strip())
1162
+ else:
1163
+ out = self.ev.eval_rgb(
1164
+ self.ed_r.toPlainText().strip(),
1165
+ self.ed_g.toPlainText().strip(),
1166
+ self.ed_b.toPlainText().strip(),
1167
+ default_channels=(0, 1, 2)
1168
+ )
1169
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
1170
+
1171
+ # NEW: optional auto-stretch (preview only)
1172
+ if getattr(self, "_as_enabled", False):
1173
+ out = autostretch(
1174
+ out,
1175
+ target_median=float(getattr(self, "_as_target", 0.25)),
1176
+ linked=bool(getattr(self, "_as_linked", True)),
1177
+ sigma=float(getattr(self, "_as_sigma", 3.0)),
1178
+ use_16bit=bool(getattr(self, "_as_16bit", True)),
1179
+ )
1180
+
1181
+ self._set_preview_image(out)
1182
+ if self._preview_item is not None and abs(self._preview_zoom - 1.0) < 1e-6:
1183
+ self._fit_to_view()
1184
+ except Exception as e:
1185
+ msg = str(e)
1186
+ if "name '" in msg and "' is not defined" in msg:
1187
+ msg += "\n\nTip: use the identifier listed in Variables (or the raw title; it’s auto-mapped)."
1188
+ QMessageBox.critical(self, "Pixel Math Preview", f"Failed:\n{msg}")
1189
+ finally:
1190
+ QApplication.restoreOverrideCursor()
1191
+
1192
+ def _set_preview_image(self, img: np.ndarray):
1193
+ """Render numpy float image into the preview scene, preserving zoom/pan."""
1194
+ qim = _float_to_qimage_rgb8(img)
1195
+ pm = QPixmap.fromImage(qim)
1196
+
1197
+ # --- capture current view state (before we clear/replace) ---
1198
+ view = self.preview_view
1199
+ old_transform = QTransform(view.transform())
1200
+ old_zoom = getattr(view, "_zoom", 1.0)
1201
+
1202
+ old_center_norm = None
1203
+ if self._preview_item is not None:
1204
+ # current center in scene coords → normalize to old image size
1205
+ center_scene = view.mapToScene(view.viewport().rect().center())
1206
+ old_pix = self._preview_item.pixmap()
1207
+ ow, oh = float(old_pix.width()), float(old_pix.height())
1208
+ if ow > 0 and oh > 0:
1209
+ old_center_norm = QPointF(center_scene.x() / ow, center_scene.y() / oh)
1210
+
1211
+ # --- replace scene content ---
1212
+ self.preview_scene.clear()
1213
+ self._preview_item = self.preview_scene.addPixmap(pm)
1214
+ self.preview_scene.setSceneRect(self._preview_item.boundingRect())
1215
+
1216
+ # --- restore transform and center ---
1217
+ # (don’t call zoom_reset / fit here—respect user’s current view)
1218
+ view.setTransform(old_transform)
1219
+ view._zoom = float(old_zoom)
1220
+ self._preview_zoom = float(old_zoom)
1221
+
1222
+ if old_center_norm is not None:
1223
+ nw, nh = float(pm.width()), float(pm.height())
1224
+ new_center = QPointF(old_center_norm.x() * nw, old_center_norm.y() * nh)
1225
+ view.centerOn(new_center)
1226
+
1227
+ self.preview_view.viewport().update()
1228
+
1229
+ def _zoom_by(self, factor: float):
1230
+ if self._preview_item is None:
1231
+ return
1232
+ self.preview_view.zoom_by(float(factor))
1233
+ # mirror into our logical tracker (optional)
1234
+ self._preview_zoom = self.preview_view._zoom
1235
+
1236
+ def _zoom_reset_1_1(self):
1237
+ if self._preview_item is None:
1238
+ return
1239
+ self.preview_view.zoom_reset()
1240
+ self._preview_zoom = 1.0
1241
+
1242
+ def _fit_to_view(self):
1243
+ if self._preview_item is None:
1244
+ return
1245
+ # Fit the item, then record logical zoom as 1.0 (we treat "fit" as baseline)
1246
+ self.preview_view.fitInView(self._preview_item, Qt.AspectRatioMode.KeepAspectRatio)
1247
+ self.preview_view._zoom = 1.0
1248
+ self._preview_zoom = 1.0
1249
+
1250
+ # ---------- examples -------------------------------------------------------
1251
+ def _examples_list(self):
1252
+ a = "img"
1253
+ others = [ident for (_, ident) in self.ev.title_map if ident != a]
1254
+ b = others[0] if others else a
1255
+ c = others[1] if len(others) > 1 else a
1256
+
1257
+ return [
1258
+ # --- existing basics ---
1259
+ ("Average two views", "single", f"({a} + {b}) / 2"),
1260
+ ("Difference (A - B)", "single", f"{a} - {b}"),
1261
+ ("Invert active", "single", f"~{a}"),
1262
+ ("Subtract median (bias remove)", "single", f"{a} - med({a})"),
1263
+ ("Zero-center by mean", "single", f"{a} - mean({a})"),
1264
+ ("Min + Max combine", "single", f"min({a}) + max({a})"),
1265
+ ("Log transform", "single", f"log({a} + 1e-6)"),
1266
+ ("Midtones transform m=0.25", "single", f"mtf({a}, 0.25)"),
1267
+ ("If darker than median → 0 else 1", "single", f"iff({a} < med({a}), 0, 1)"),
1268
+
1269
+ ("Per-channel: swap R↔B", "rgb", (f"{a}[2]", f"{a}[1]", f"{a}[0]")),
1270
+ ("Per-channel: avg A & B", "rgb", (f"({a}[0]+{b}[0])/2", f"({a}[1]+{b}[1])/2", f"({a}[2]+{b}[2])/2")),
1271
+ ("Per-channel: build RGB from A,B,C", "rgb", (f"{a}[0]", f"{b}[1]", f"{c}[2]")),
1272
+
1273
+ # --- new, single-expression tone/normalization ---
1274
+ ("Normalize to 0–1 (per-channel)", "single", f"normalize01({a})"),
1275
+ ("Sigmoid contrast (k=12, mid=0.4)", "single", f"sigmoid({a}, k=12, mid=0.4)"),
1276
+ ("Gamma 0.6 (brighten midtones)", "single", f"gamma({a}, 0.6)"),
1277
+ ("Percentile stretch 0.5–99.5%", "single",
1278
+ f"lo = percentile({a}, 0.5)\nhi = percentile({a}, 99.5)\nclamp(({a} - lo) / (hi - lo), 0, 1)"),
1279
+
1280
+ # --- blending & masking ---
1281
+ ("Blend A→B by horizontal gradient X", "single", f"t = X\nlerp({a}, {b}, t)"),
1282
+ ("Apply active mask to blend A→B", "single", f"m = mask()\napply_mask({a}, {b}, m)"),
1283
+
1284
+ # --- sharpening with mask (multiline) ---
1285
+ ("Masked unsharp (luma-based)", "single",
1286
+ f"base = {a}\nsh = unsharp({a}, sigma=1.2, amount=0.8)\n"
1287
+ f"m = smoothstep(0.10, 0.60, luma({a}))\napply_mask(base, sh, m)"),
1288
+
1289
+ # --- view matching / calibration ---
1290
+ ("Match medians of A to B", "single", f"{a} * (med({b}) / med({a}))"),
1291
+
1292
+ # --- small filters ---
1293
+ ("Gaussian blur σ=2", "single", f"gauss({a}, sigma=2.0)"),
1294
+ ("Median filter k=3", "single", f"median({a}, k=3)"),
1295
+
1296
+ # --- per-channel examples using new helpers ---
1297
+ ("Per-channel: luma to all channels", "rgb", (f"luma({a})", f"luma({a})", f"luma({a})")),
1298
+ ("Per-channel: A’s R, B’s G, C’s B (normed)", "rgb",
1299
+ (f"normalize01({a}[0])", f"normalize01({b}[1])", f"normalize01({c}[2])")),
1300
+ ]
1301
+
1302
+ def _function_glossary(self):
1303
+ # name -> (signature / template, short description)
1304
+ return {
1305
+ "clamp": ("clamp(x, lo=0, hi=1)", "Limit values to [lo..hi]."),
1306
+ "rescale": ("rescale(x, a, b, lo=0, hi=1)", "Map range [a..b] to [lo..hi]."),
1307
+ "gamma": ("gamma(x, g)", "Apply gamma curve."),
1308
+ "pow_safe": ("pow_safe(x, p)", "Power with EPS floor."),
1309
+ "absf": ("absf(x)", "Absolute value."),
1310
+ "expf": ("expf(x)", "Exponential."),
1311
+ "sqrtf": ("sqrtf(x)", "Square root (clamped to ≥0)."),
1312
+ "arcsin": ("arcsin(x)", "Inverse sine (radians), input clipped to [-1,1]."),
1313
+ "sigmoid": ("sigmoid(x, k=10, mid=0.5)", "S-shaped tone curve."),
1314
+ "smoothstep": ("smoothstep(e0, e1, x)", "Cubic smooth ramp."),
1315
+ "lerp/mix": ("lerp(a, b, t)", "Linear blend."),
1316
+ "percentile": ("percentile(x, p)", "Per-channel percentile image."),
1317
+ "normalize01": ("normalize01(x)", "Per-channel [0..1] normalization."),
1318
+ "zscore": ("zscore(x)", "Per-channel (x-mean)/std."),
1319
+ "ch": ("ch(x, i)", "Extract channel i (0/1/2) as 2-D."),
1320
+ "luma": ("luma(x)", "Rec.709 luminance as 2-D."),
1321
+ "compose": ("compose(R, G, B)", "Stack three planes to RGB."),
1322
+ "mask": ("m = mask()", "Active mask (2-D, [0..1])."),
1323
+ "apply_mask": ("apply_mask(base, out, m)", "Blend by mask."),
1324
+ "boxblur": ("boxblur(x, k=3)", "Box blur (cv2 if available)."),
1325
+ "gauss": ("gauss(x, sigma=1.0)", "Gaussian blur."),
1326
+ "median": ("median(x, k=3)", "Median filter (cv2 if avail)."),
1327
+ "unsharp": ("unsharp(x, sigma=1.5, amount=1.0)", "Unsharp mask."),
1328
+ "mtf": ("mtf(x, m)", "Midtones transfer (existing)."),
1329
+ "iff": ("iff(cond, a, b)", "Conditional (existing)."),
1330
+ "X / Y": ("X, Y", "Normalized coordinates in [0..1]."),
1331
+ "H/W/C": ("H, W, C, shape", "Image dimensions."),
1332
+ }
1333
+
1334
+ def _open_glossary(self):
1335
+ dlg = QDialog(self)
1336
+ dlg.setWindowTitle("Pixel Math Glossary")
1337
+ lay = QVBoxLayout(dlg)
1338
+
1339
+ info = QLabel("Double-click to insert a template at the cursor.")
1340
+ info.setStyleSheet("color: gray;")
1341
+ lay.addWidget(info)
1342
+
1343
+ from PyQt6.QtWidgets import QLineEdit, QListWidget, QListWidgetItem, QHBoxLayout, QPushButton
1344
+ search = QLineEdit()
1345
+ search.setPlaceholderText("Search…")
1346
+ lay.addWidget(search)
1347
+
1348
+ lst = QListWidget()
1349
+ lst.setMinimumHeight(220)
1350
+ lay.addWidget(lst, 1)
1351
+
1352
+ # fill
1353
+ gl = self._function_glossary()
1354
+ def _refill():
1355
+ q = search.text().strip().lower()
1356
+ lst.clear()
1357
+ for name, (sig, desc) in gl.items():
1358
+ if not q or q in name.lower() or q in sig.lower() or q in desc.lower():
1359
+ item = QListWidgetItem(f"{sig} — {desc}")
1360
+ item.setData(Qt.ItemDataRole.UserRole, sig)
1361
+ lst.addItem(item)
1362
+ _refill()
1363
+
1364
+ def _insert_current():
1365
+ item = lst.currentItem()
1366
+ if not item: return
1367
+ sig = item.data(Qt.ItemDataRole.UserRole) or ""
1368
+ ed = self.ed_single if self.rb_single.isChecked() else (self.ed_r if self.tabs.currentIndex()==0 else self.ed_g if self.tabs.currentIndex()==1 else self.ed_b)
1369
+ ed.insertPlainText(sig)
1370
+
1371
+ lst.itemDoubleClicked.connect(lambda *_: (_insert_current(), None))
1372
+ search.textChanged.connect(lambda *_: _refill())
1373
+
1374
+ btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
1375
+ insert_btn = QPushButton("Insert")
1376
+ btns.addButton(insert_btn, QDialogButtonBox.ButtonRole.ApplyRole)
1377
+ insert_btn.clicked.connect(_insert_current)
1378
+ btns.rejected.connect(dlg.reject)
1379
+ lay.addWidget(btns)
1380
+
1381
+ dlg.resize(620, 400)
1382
+ dlg.exec()
1383
+
1384
+
1385
+ def _delete_favorite(self):
1386
+ text = self.cb_fav.currentText()
1387
+ if text == "Select a favorite expression":
1388
+ return
1389
+ # Remove from in-memory list
1390
+ try:
1391
+ idx_in_list = self._favs.index(text)
1392
+ except ValueError:
1393
+ return
1394
+
1395
+ self._favs.pop(idx_in_list)
1396
+
1397
+ # Rebuild combo to keep indices clean
1398
+ self.cb_fav.blockSignals(True)
1399
+ self.cb_fav.clear()
1400
+ self.cb_fav.addItem("Select a favorite expression")
1401
+ for f in self._favs:
1402
+ self.cb_fav.addItem(f)
1403
+ self.cb_fav.setCurrentIndex(0)
1404
+ self.cb_fav.blockSignals(False)
1405
+
1406
+ # Persist
1407
+ s = self._settings()
1408
+ if s:
1409
+ s.setValue("pixelmath_favorites", json.dumps(self._favs))
1410
+
1411
+
1412
+ def _apply_example_from_combo(self, idx: int):
1413
+ if idx <= 0: # "Insert example…"
1414
+ return
1415
+ kind, payload = self.cb_examples.currentData()
1416
+ # Switch mode first, then inject text on the next event loop tick to avoid any race with toggled()
1417
+ if kind == "single":
1418
+ self.rb_single.setChecked(True)
1419
+ def set_text():
1420
+ self._mode(True)
1421
+ self.ed_single.setPlainText(str(payload))
1422
+ QTimer.singleShot(0, set_text)
1423
+ else:
1424
+ self.rb_sep.setChecked(True)
1425
+ def set_text_rgb():
1426
+ self._mode(False)
1427
+ r, g, b = payload
1428
+ self.ed_r.setPlainText(r)
1429
+ self.ed_g.setPlainText(g)
1430
+ self.ed_b.setPlainText(b)
1431
+ QTimer.singleShot(0, set_text_rgb)
1432
+ # reset the combo back to the prompt so it can be used repeatedly
1433
+ QTimer.singleShot(0, lambda: self.cb_examples.setCurrentIndex(0))
1434
+
1435
+ # ---------- favorites ------------------------------------------------------
1436
+ def _settings(self):
1437
+ p = self.parent(); return getattr(p, "settings", None)
1438
+
1439
+ def _load_favorites(self):
1440
+ self._favs = []
1441
+ s = self._settings()
1442
+ if s:
1443
+ raw = s.value("pixelmath_favorites", "", type=str) or ""
1444
+ try: self._favs = json.loads(raw) if raw else []
1445
+ except Exception: self._favs = []
1446
+ for f in self._favs: self.cb_fav.addItem(f)
1447
+
1448
+ def _save_favorite(self):
1449
+ if self.rb_single.isChecked():
1450
+ expr = self.ed_single.toPlainText().strip()
1451
+ else:
1452
+ expr = f"[R]{self.ed_r.toPlainText().strip()} | [G]{self.ed_g.toPlainText().strip()} | [B]{self.ed_b.toPlainText().strip()}"
1453
+ if not expr or expr in self._favs: return
1454
+ self._favs.append(expr); self.cb_fav.addItem(expr)
1455
+ s = self._settings()
1456
+ if s: s.setValue("pixelmath_favorites", json.dumps(self._favs))
1457
+
1458
+ def _pick_favorite(self, text):
1459
+ if text == "Select a favorite expression": return
1460
+ if "[R]" in text or "[G]" in text or "[B]" in text:
1461
+ self.rb_sep.setChecked(True); self._mode(False)
1462
+ parts = {}
1463
+ for p in [t.strip() for t in text.split("|") if t.strip()]:
1464
+ parts[p[:3]] = p[3:].strip()
1465
+ self.ed_r.setPlainText(parts.get("[R]", "")); self.ed_g.setPlainText(parts.get("[G]", "")); self.ed_b.setPlainText(parts.get("[B]", ""))
1466
+ else:
1467
+ self.rb_single.setChecked(True); self._mode(True)
1468
+ self.ed_single.setPlainText(text)
1469
+
1470
+ # =============================================================================
1471
+ # New-view delivery helper (used by PixelMathDialogPro)
1472
+ # =============================================================================
1473
+
1474
+ @staticmethod
1475
+ def _deliver_new_view(parent, src_doc, img: np.ndarray, step_name: str = "Pixel Math"):
1476
+ dm = getattr(parent, "doc_manager", None)
1477
+ if dm is None:
1478
+ if hasattr(src_doc, "set_image"):
1479
+ src_doc.set_image(img, step_name=step_name)
1480
+ else:
1481
+ src_doc.image = img
1482
+ return src_doc
1483
+
1484
+ base = src_doc.display_name() if callable(getattr(src_doc, "display_name", None)) else getattr(src_doc, "display_name", "Untitled")
1485
+ base = base if isinstance(base, str) and base else "Untitled"
1486
+ new_title = f"{base} — {step_name}"
1487
+
1488
+ meta = dict(getattr(src_doc, "metadata", {}) or {})
1489
+ meta["step_name"] = step_name
1490
+
1491
+ new_doc = dm.open_array(np.asarray(img, dtype=np.float32), metadata=meta, title=new_title)
1492
+ if hasattr(parent, "_spawn_subwindow_for"):
1493
+ parent._spawn_subwindow_for(new_doc)
1494
+ return new_doc
1495
+
1496
+
1497
+ # ---------- UI helpers -----------------------------------------------------
1498
+ def _mode(self, single_on: bool):
1499
+ self.ed_single.setVisible(single_on)
1500
+ self.tabs.setVisible(not single_on)
1501
+
1502
+ def _help(self):
1503
+ gl = self._function_glossary()
1504
+ lines = [
1505
+ "Operators: + - * / ^(power) ~(invert)",
1506
+ "Comparisons: <, == (use inside iff)",
1507
+ "",
1508
+ "Variables:",
1509
+ " • img (active) and one per open view (by window title, auto-mapped).",
1510
+ " • Coordinates: X, Y in [0..1].",
1511
+ " • Sizes: H, W, C, shape.",
1512
+ "",
1513
+ "Per-channel indexing: view[0], view[1], view[2].",
1514
+ "Multiline: last line is the result.",
1515
+ "Output: Overwrite active or Create new view.",
1516
+ "",
1517
+ "Functions:"
1518
+ ]
1519
+ # Pretty column-ish dump
1520
+ for name, (sig, desc) in gl.items():
1521
+ lines.append(f" {sig}\n {desc}")
1522
+ QMessageBox.information(self, "Pixel Math Help", "\n".join(lines))
1523
+
1524
+ # ---------- Apply ----------------------------------------------------------
1525
+ # ---------- Apply ----------------------------------------------------------
1526
+ def _apply(self):
1527
+ try:
1528
+ # Capture expressions first so we can store them for replay
1529
+ if self.rb_single.isChecked():
1530
+ mode = "single"
1531
+ expr = self.ed_single.toPlainText().strip()
1532
+ expr_r = ""
1533
+ expr_g = ""
1534
+ expr_b = ""
1535
+ out = self.ev.eval_single(expr)
1536
+ else:
1537
+ mode = "rgb"
1538
+ expr = ""
1539
+ expr_r = self.ed_r.toPlainText().strip()
1540
+ expr_g = self.ed_g.toPlainText().strip()
1541
+ expr_b = self.ed_b.toPlainText().strip()
1542
+ out = self.ev.eval_rgb(
1543
+ expr_r,
1544
+ expr_g,
1545
+ expr_b,
1546
+ default_channels=(0, 1, 2)
1547
+ )
1548
+
1549
+ out = np.clip(out, 0.0, 1.0).astype(np.float32, copy=False)
1550
+
1551
+ # Output route
1552
+ if self.rb_out_new.isChecked():
1553
+ self._deliver_new_view(self.parent(), self.doc, out, "Pixel Math")
1554
+ else:
1555
+ if hasattr(self.doc, "set_image"):
1556
+ self.doc.set_image(out, step_name="Pixel Math")
1557
+ elif hasattr(self.doc, "apply_numpy"):
1558
+ self.doc.apply_numpy(out, step_name="Pixel Math")
1559
+ else:
1560
+ self.doc.image = out
1561
+
1562
+ # ── Register as last_headless_command for replay ──────────
1563
+ try:
1564
+ main = self.parent()
1565
+ if main is not None:
1566
+ preset = {
1567
+ "mode": mode,
1568
+ "expr": expr,
1569
+ "expr_r": expr_r,
1570
+ "expr_g": expr_g,
1571
+ "expr_b": expr_b,
1572
+ }
1573
+ payload = {
1574
+ "command_id": "pixel_math",
1575
+ "preset": dict(preset),
1576
+ }
1577
+ setattr(main, "_last_headless_command", payload)
1578
+
1579
+ # optional log
1580
+ try:
1581
+ if hasattr(main, "_log"):
1582
+ if mode == "single" and expr:
1583
+ desc = expr
1584
+ else:
1585
+ desc = f"R:{expr_r} G:{expr_g} B:{expr_b}"
1586
+ main._log(f"[Replay] Registered Pixel Math as last action → {desc}")
1587
+ except Exception:
1588
+ pass
1589
+ except Exception:
1590
+ # don't break apply if replay wiring fails
1591
+ pass
1592
+ # ───────────────────────────────────────────────────────────
1593
+
1594
+ self.accept()
1595
+ except Exception as e:
1596
+ msg = str(e)
1597
+ if "name '" in msg and "' is not defined" in msg:
1598
+ msg += "\n\nTip: use the identifier shown beside Variables (e.g. 'andromeda_png'), "
1599
+ msg += "or just type the raw title; it will be auto-mapped."
1600
+ QMessageBox.critical(self, "Pixel Math", f"Failed:\n{msg}")