setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -93,13 +93,34 @@ class MetricsPanel(QWidget):
93
93
 
94
94
  @staticmethod
95
95
  def _compute_one(i_entry):
96
+ """
97
+ Compute (FWHM, eccentricity, star_count) using SEP on a *2x downsampled*
98
+ mono float32 frame.
99
+
100
+ - Downsample is fixed at 2x (linear), using AREA.
101
+ - FWHM is converted back to full-res pixel units by multiplying by 2.
102
+ Optionally multiply by sqrt(2) if you want to compensate for the
103
+ AREA downsample's effective smoothing (see fwhm_factor below).
104
+ - Eccentricity is scale-invariant.
105
+ - Star count should be closer to full-res if we also scale minarea
106
+ from 16 -> 4 (area scales by 1/4).
107
+ """
108
+
109
+ import cv2
110
+ import sep
111
+
96
112
  idx, entry = i_entry
97
- img = entry['image_data']
113
+ img = entry["image_data"]
98
114
 
99
- # normalize to float32 mono [0..1] exactly like live
100
115
  data = np.asarray(img)
116
+ h0, w0 = data.shape[:2]
117
+
118
+ # ----------------------------
119
+ # 1) Normalize to float32 mono [0..1]
120
+ # ----------------------------
101
121
  if data.ndim == 3:
102
122
  data = data.mean(axis=2)
123
+
103
124
  if data.dtype == np.uint8:
104
125
  data = data.astype(np.float32) / 255.0
105
126
  elif data.dtype == np.uint16:
@@ -107,35 +128,63 @@ class MetricsPanel(QWidget):
107
128
  else:
108
129
  data = data.astype(np.float32, copy=False)
109
130
 
131
+ # Guard: SEP expects finite values
132
+ if not np.isfinite(data).all():
133
+ data = np.nan_to_num(data, nan=0.0, posinf=1.0, neginf=0.0).astype(np.float32, copy=False)
134
+
135
+ # ----------------------------
136
+ # 2) Fixed 2x downsample (linear /2)
137
+ # ----------------------------
138
+ # Use integer decimation by resize to preserve speed and consistency.
139
+ new_w = max(16, w0 // 2)
140
+ new_h = max(16, h0 // 2)
141
+ ds = cv2.resize(data, (new_w, new_h), interpolation=cv2.INTER_AREA)
142
+
143
+ # ----------------------------
144
+ # 3) SEP pipeline (same as before, but minarea scaled)
145
+ # ----------------------------
110
146
  try:
111
- # --- match old Blink’s SEP pipeline ---
112
- bkg = sep.Background(data)
147
+ bkg = sep.Background(ds)
113
148
  back = bkg.back()
114
149
  try:
115
150
  gr = float(bkg.globalrms)
116
151
  except Exception:
117
- # some SEP builds only expose per-cell rms map
118
152
  gr = float(np.median(np.asarray(bkg.rms(), dtype=np.float32)))
119
153
 
154
+ # minarea: 16 at full-res ~= 4 at 2x downsample (area /4)
155
+ minarea = 4
156
+
120
157
  cat = sep.extract(
121
- data - back,
158
+ ds - back,
122
159
  thresh=7.0,
123
160
  err=gr,
124
- minarea=16,
161
+ minarea=minarea,
125
162
  clean=True,
126
163
  deblend_nthresh=32,
127
164
  )
128
165
 
129
166
  if len(cat) > 0:
130
167
  # FWHM via geometric-mean sigma (old Blink)
131
- sig = np.sqrt(cat['a'] * cat['b']).astype(np.float32, copy=False)
132
- fwhm = float(np.nanmedian(2.3548 * sig))
133
-
134
- # TRUE eccentricity: e = sqrt(1 - (b/a)^2) (old Blink)
135
- # guard against divide-by-zero and NaNs
136
- a = np.maximum(cat['a'].astype(np.float32, copy=False), 1e-12)
137
- b = np.clip(cat['b'].astype(np.float32, copy=False), 0.0, None)
138
- q = np.clip(b / a, 0.0, 1.0) # b/a
168
+ sig = np.sqrt(cat["a"] * cat["b"]).astype(np.float32, copy=False)
169
+ fwhm_ds = float(np.nanmedian(2.3548 * sig))
170
+
171
+ # ----------------------------
172
+ # 4) Convert FWHM back to full-res
173
+ # ----------------------------
174
+ # Pure geometric reconversion: *2
175
+ # If you want the "noise reduction" compensation you mentioned:
176
+ # multiply by sqrt(2) instead of 2, or 2*sqrt(2) depending on intent.
177
+ #
178
+ # Most consistent with "true full-res pixels" is factor = 2.
179
+ # If you insist on smoothing-compensation, set factor = 2*np.sqrt(2)
180
+ # (because you still have to undo scale, and then add smoothing term).
181
+ fwhm_factor = 2.0 # change to (2.0 * np.sqrt(2.0)) if you really want it
182
+ fwhm = fwhm_ds * fwhm_factor
183
+
184
+ # TRUE eccentricity
185
+ a = np.maximum(cat["a"].astype(np.float32, copy=False), 1e-12)
186
+ b = np.clip(cat["b"].astype(np.float32, copy=False), 0.0, None)
187
+ q = np.clip(b / a, 0.0, 1.0)
139
188
  e_true = np.sqrt(np.maximum(0.0, 1.0 - q * q))
140
189
  ecc = float(np.nanmedian(e_true))
141
190
 
@@ -144,16 +193,26 @@ class MetricsPanel(QWidget):
144
193
  fwhm, ecc, star_cnt = np.nan, np.nan, 0
145
194
 
146
195
  except Exception:
147
- # same sentinel behavior as before
148
196
  fwhm, ecc, star_cnt = 10.0, 1.0, 0
149
197
 
150
- orig_back = entry.get('orig_background', np.nan)
198
+ orig_back = entry.get("orig_background", np.nan)
151
199
  return idx, fwhm, ecc, orig_back, star_cnt
152
200
 
201
+
202
+
153
203
  def compute_all_metrics(self, loaded_images) -> bool:
154
- """Run SEP over the full list in parallel using threads and cache results.
155
- Returns True if metrics were computed, False if user declined/canceled.
156
204
  """
205
+ Run SEP over the full list in parallel using threads and cache results.
206
+ Uses *downsampled* SEP for speed + lower RAM.
207
+ Returns True if metrics were computed, False if user canceled.
208
+ """
209
+ from concurrent.futures import ThreadPoolExecutor, as_completed
210
+ import os
211
+ import numpy as np
212
+ import psutil
213
+ from PyQt6.QtCore import Qt
214
+ from PyQt6.QtWidgets import QProgressDialog, QApplication
215
+
157
216
  n = len(loaded_images)
158
217
  if n == 0:
159
218
  self._orig_images = []
@@ -162,62 +221,17 @@ class MetricsPanel(QWidget):
162
221
  self._threshold_initialized = [False] * 4
163
222
  return True
164
223
 
165
- def _has_metrics(md):
166
- try:
167
- return md is not None and len(md) == 4 and md[0] is not None and len(md[0]) > 0
168
- except Exception:
169
- return False
170
-
171
- settings = QSettings()
172
- show_warning = settings.value("metrics/showWarning", True, type=bool)
173
-
174
- if (not show_warning) and (not _has_metrics(getattr(self, "metrics_data", None))):
175
- settings.setValue("metrics/showWarning", True)
176
- show_warning = True
177
-
178
- # ----------------------------
179
- # 1) Optional warning gate
180
- # ----------------------------
181
- if show_warning:
182
- msg = QMessageBox(self)
183
- msg.setWindowTitle(self.tr("Heads-up"))
184
- msg.setText(self.tr(
185
- "This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
186
- "Continue?"
187
- ))
188
- msg.setStandardButtons(
189
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
190
- )
191
- cb = QCheckBox(self.tr("Don't show again"), msg)
192
- msg.setCheckBox(cb)
193
-
194
- clicked = msg.exec()
195
- clicked_yes = (clicked == QMessageBox.StandardButton.Yes)
196
-
197
- if not clicked_yes:
198
- # If they said NO, never allow "Don't show again" to lock them out.
199
- # Keep the warning enabled so they can opt-in later.
200
- if cb.isChecked():
201
- settings.setValue("metrics/showWarning", True)
202
- return False
203
-
204
- # They said YES: now it's safe to honor "Don't show again"
205
- if cb.isChecked():
206
- settings.setValue("metrics/showWarning", False)
207
-
208
- # If show_warning is False, we compute with no prompt.
209
-
210
224
  # ----------------------------
211
- # 2) Allocate result arrays
225
+ # 1) Allocate result arrays
212
226
  # ----------------------------
213
- m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
227
+ m0 = np.full(n, np.nan, dtype=np.float32) # FWHM (full-res px units)
214
228
  m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
215
229
  m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
216
230
  m3 = np.full(n, np.nan, dtype=np.float32) # Star count
217
- flags = [e.get('flagged', False) for e in loaded_images]
231
+ flags = [e.get("flagged", False) for e in loaded_images]
218
232
 
219
233
  # ----------------------------
220
- # 3) Progress dialog
234
+ # 2) Progress dialog (Cancel)
221
235
  # ----------------------------
222
236
  prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
223
237
  prog.setWindowModality(Qt.WindowModality.WindowModal)
@@ -226,7 +240,34 @@ class MetricsPanel(QWidget):
226
240
  prog.show()
227
241
  QApplication.processEvents()
228
242
 
229
- workers = min(os.cpu_count() or 1, 60)
243
+ cpu = os.cpu_count() or 1
244
+
245
+ # ----------------------------
246
+ # 3) Worker sizing by RAM (downsample-aware)
247
+ # ----------------------------
248
+ # Estimate using the same max_dim as _compute_one (default 1024).
249
+ # Use first frame to estimate scale.
250
+ max_dim = int(loaded_images[0].get("metrics_max_dim", 1024))
251
+ h0, w0 = loaded_images[0]["image_data"].shape[:2]
252
+ scale = 1.0
253
+ if max(h0, w0) > max_dim:
254
+ scale = max_dim / float(max(h0, w0))
255
+
256
+ hd = max(16, int(round(h0 * scale)))
257
+ wd = max(16, int(round(w0 * scale)))
258
+
259
+ # float32 mono downsample buffer
260
+ bytes_per = hd * wd * 4
261
+
262
+ # SEP allocates extra maps; budget ~3x to be safe.
263
+ budget_per_worker = int(bytes_per * 3.0)
264
+
265
+ avail = psutil.virtual_memory().available
266
+ max_by_mem = max(1, int(avail / max(budget_per_worker, 1)))
267
+
268
+ # Don’t exceed CPU, and don’t go crazy high even if RAM is huge
269
+ workers = max(1, min(cpu, max_by_mem, 24))
270
+
230
271
  tasks = [(i, loaded_images[i]) for i in range(n)]
231
272
  done = 0
232
273
  canceled = False
@@ -238,6 +279,7 @@ class MetricsPanel(QWidget):
238
279
  if prog.wasCanceled():
239
280
  canceled = True
240
281
  break
282
+
241
283
  try:
242
284
  idx, fwhm, ecc, orig_back, star_cnt = fut.result()
243
285
  except Exception:
@@ -245,7 +287,10 @@ class MetricsPanel(QWidget):
245
287
  fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
246
288
 
247
289
  if 0 <= idx < n:
248
- m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
290
+ m0[idx] = fwhm
291
+ m1[idx] = ecc
292
+ m2[idx] = orig_back
293
+ m3[idx] = float(star_cnt)
249
294
 
250
295
  done += 1
251
296
  prog.setValue(done)
@@ -254,7 +299,7 @@ class MetricsPanel(QWidget):
254
299
  prog.close()
255
300
 
256
301
  if canceled:
257
- # IMPORTANT: leave caches alone; caller will clear/return
302
+ # IMPORTANT: leave caches alone; caller handles clear/return
258
303
  return False
259
304
 
260
305
  # ----------------------------
setiastro/saspro/convo.py CHANGED
@@ -1118,13 +1118,14 @@ class ConvoDeconvoDialog(QDialog):
1118
1118
  if img is None:
1119
1119
  QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
1120
1120
  return
1121
+
1121
1122
  img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
1122
1123
 
1123
- sigma = self.sep_threshold_slider.value()
1124
- minarea = self.sep_minarea_spin.value
1125
- sat = self.sep_sat_slider.value()
1126
- maxstars= self.sep_maxstars_spin.value
1127
- half_w = self.sep_stamp_spin.value
1124
+ sigma = float(self.sep_threshold_slider.value())
1125
+ minarea = int(self.sep_minarea_spin.value()) # ✅
1126
+ sat = float(self.sep_sat_slider.value())
1127
+ maxstars = int(self.sep_maxstars_spin.value()) # ✅
1128
+ half_w = int(self.sep_stamp_spin.value()) # ✅
1128
1129
 
1129
1130
  try:
1130
1131
  psf_kernel = estimate_psf_from_image(
@@ -1136,11 +1137,13 @@ class ConvoDeconvoDialog(QDialog):
1136
1137
  stamp_half_width=half_w
1137
1138
  )
1138
1139
  except RuntimeError as e:
1139
- QMessageBox.critical(self, "PSF Error", str(e)); return
1140
+ QMessageBox.critical(self, "PSF Error", str(e))
1141
+ return
1140
1142
 
1141
1143
  self._last_stellar_psf = psf_kernel
1142
1144
  self._show_stellar_psf_preview(psf_kernel)
1143
1145
 
1146
+
1144
1147
  def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
1145
1148
  h, w = psf_kernel.shape
1146
1149
  img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
@@ -1769,14 +1769,38 @@ class CurvesDialogPro(QDialog):
1769
1769
  name, ok = QInputDialog.getText(self, self.tr("Save Curves Preset"), self.tr("Preset name:"))
1770
1770
  if not ok or not name.strip():
1771
1771
  return
1772
- pts_norm = self._collect_points_norm_from_editor()
1773
- mode = self._current_mode()
1774
- if save_custom_preset(name.strip(), mode, pts_norm):
1775
- self._set_status(self.tr("Saved preset “{0}”.").format(name.strip()))
1772
+ name = name.strip()
1773
+
1774
+ # 0) flush active editor -> store (CRITICAL)
1775
+ try:
1776
+ self._curves_store[self._current_mode_key] = self._editor_points_norm()
1777
+ except Exception:
1778
+ pass
1779
+
1780
+ # 1) build a full multi-curve payload
1781
+ modes = {}
1782
+ for k, pts in self._curves_store.items():
1783
+ if not isinstance(pts, (list, tuple)) or len(pts) < 2:
1784
+ continue
1785
+ # ensure floats (QSettings/JSON safety)
1786
+ modes[k] = [(float(x), float(y)) for (x, y) in pts]
1787
+
1788
+ preset = {
1789
+ "name": name,
1790
+ "version": 2,
1791
+ "kind": "curves_multi",
1792
+ "active": self._current_mode_key, # "K", "R", ...
1793
+ "modes": modes,
1794
+ }
1795
+
1796
+ # 2) save via your existing persistence
1797
+ if save_custom_preset(name, preset): # <-- change signature (see section 3)
1798
+ self._set_status(self.tr("Saved preset “{0}”.").format(name))
1776
1799
  self._rebuild_presets_menu()
1777
1800
  else:
1778
1801
  QMessageBox.warning(self, self.tr("Save failed"), self.tr("Could not save preset."))
1779
1802
 
1803
+
1780
1804
  def _rebuild_presets_menu(self):
1781
1805
  m = QMenu(self)
1782
1806
  # Built-in shapes under K (Brightness)
@@ -2400,43 +2424,69 @@ class CurvesDialogPro(QDialog):
2400
2424
  def _apply_preset_dict(self, preset: dict):
2401
2425
  preset = preset or {}
2402
2426
 
2403
- # 1) set mode radio
2427
+ # -------- MULTI-CURVE (v2) --------
2428
+ if preset.get("kind") == "curves_multi" or ("modes" in preset and isinstance(preset.get("modes"), dict)):
2429
+ modes = preset.get("modes", {}) or {}
2430
+
2431
+ # 0) load all curves into store (fill missing keys with linear)
2432
+ for k in self._curves_store.keys():
2433
+ pts = modes.get(k)
2434
+ if isinstance(pts, (list, tuple)) and len(pts) >= 2:
2435
+ self._curves_store[k] = [(float(x), float(y)) for (x, y) in pts]
2436
+ else:
2437
+ self._curves_store[k] = [(0.0, 0.0), (1.0, 1.0)]
2438
+
2439
+ # 1) choose active key (default to K)
2440
+ active = str(preset.get("active") or "K")
2441
+ if active not in self._curves_store:
2442
+ active = "K"
2443
+ self._current_mode_key = active
2444
+
2445
+ # 2) set radio button that corresponds to active key
2446
+ # map internal key -> radio label
2447
+ key_to_label = {v: k for (k, v) in self._mode_key_map.items()} # "K"->"K (Brightness)"
2448
+ want_label = key_to_label.get(active, "K (Brightness)")
2449
+ for b in self.mode_group.buttons():
2450
+ if b.text() == want_label:
2451
+ b.setChecked(True)
2452
+ break
2453
+
2454
+ # 3) push active curve into editor
2455
+ self._editor_set_from_norm(self._curves_store[active])
2456
+
2457
+ # 4) refresh overlays + preview
2458
+ self._refresh_overlays()
2459
+ self._quick_preview()
2460
+
2461
+ self._set_status(self.tr("Preset: {0} [multi]").format(preset.get("name", self.tr("(built-in)"))))
2462
+ return
2463
+
2464
+ # -------- LEGACY SINGLE-CURVE --------
2465
+ # your existing single-curve behavior (slightly adjusted: store it too)
2404
2466
  want = _norm_mode(preset.get("mode"))
2405
2467
  for b in self.mode_group.buttons():
2406
2468
  if b.text().lower() == want.lower():
2407
2469
  b.setChecked(True)
2408
2470
  break
2409
2471
 
2410
- # 2) get points_norm — if absent, build from shape/amount (built-ins)
2411
2472
  ptsN = preset.get("points_norm")
2412
- shape = preset.get("shape") # may be None for custom presets
2473
+ shape = preset.get("shape")
2413
2474
  amount = float(preset.get("amount", 1.0))
2414
2475
 
2415
2476
  if not (isinstance(ptsN, (list, tuple)) and len(ptsN) >= 2):
2416
2477
  try:
2417
- # build from a named shape (built-ins); default to linear
2418
2478
  ptsN = _shape_points_norm(str(shape or "linear"), amount)
2419
2479
  except Exception:
2420
- ptsN = [(0.0, 0.0), (1.0, 1.0)] # safe fallback
2421
-
2422
- # 3) apply handles to the editor (strip exact endpoints)
2423
- pts_scene = _points_norm_to_scene(ptsN)
2424
- filt = [(x, y) for (x, y) in pts_scene if 1e-6 < x < 360.0 - 1e-6]
2425
-
2426
- if hasattr(self.editor, "clearSymmetryLine"):
2427
- self.editor.clearSymmetryLine()
2480
+ ptsN = [(0.0, 0.0), (1.0, 1.0)]
2428
2481
 
2429
- self.editor.setControlHandles(filt)
2430
- self.editor.updateCurve() # ensure redraw
2431
-
2432
- # persist into store & refresh
2482
+ self._editor_set_from_norm(ptsN)
2433
2483
  self._curves_store[self._current_mode_key] = self._editor_points_norm()
2434
2484
  self._refresh_overlays()
2435
2485
  self._quick_preview()
2436
2486
 
2437
- # 4) status: don’t assume shape exists
2438
2487
  shape_tag = f"[{shape}]" if shape else "[custom]"
2439
- self._set_status(self.tr("Preset: {0} {1}").format(preset.get('name', self.tr('(built-in)')), shape_tag))
2488
+ self._set_status(self.tr("Preset: {0} {1}").format(preset.get("name", self.tr("(built-in)")), shape_tag))
2489
+
2440
2490
 
2441
2491
 
2442
2492
  def apply_curves_ops(doc, op: dict):