setiastrosuitepro 1.6.7__py3-none-any.whl → 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +132 -61
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +340 -88
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/live_stacking.py +181 -73
- setiastro/saspro/multiscale_decomp.py +77 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +154 -25
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +853 -401
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +878 -131
- setiastro/saspro/subwindow.py +303 -74
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +128 -80
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +68 -51
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
|
@@ -228,21 +228,116 @@ class WaitForFileWorker(QThread):
|
|
|
228
228
|
fileFound = pyqtSignal(str)
|
|
229
229
|
cancelled = pyqtSignal()
|
|
230
230
|
error = pyqtSignal(str)
|
|
231
|
-
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
glob_pat: str,
|
|
235
|
+
timeout_sec: int = 1800,
|
|
236
|
+
parent=None,
|
|
237
|
+
*,
|
|
238
|
+
poll_ms: int = 200,
|
|
239
|
+
stable_polls: int = 6, # 6 * 200ms = ~1.2s of stability
|
|
240
|
+
stable_timeout_sec: int = 120, # extra time after first detection
|
|
241
|
+
):
|
|
232
242
|
super().__init__(parent)
|
|
233
243
|
self._glob = glob_pat
|
|
234
|
-
self._timeout = timeout_sec
|
|
244
|
+
self._timeout = int(timeout_sec)
|
|
245
|
+
self._poll_ms = int(poll_ms)
|
|
246
|
+
self._stable_polls = int(stable_polls)
|
|
247
|
+
self._stable_timeout = int(stable_timeout_sec)
|
|
235
248
|
self._running = True
|
|
249
|
+
|
|
250
|
+
def stop(self):
|
|
251
|
+
self._running = False
|
|
252
|
+
|
|
253
|
+
def _best_candidate(self, paths: list[str]) -> str | None:
|
|
254
|
+
if not paths:
|
|
255
|
+
return None
|
|
256
|
+
# prefer biggest file; tie-break by newest mtime
|
|
257
|
+
def key(p):
|
|
258
|
+
try:
|
|
259
|
+
st = os.stat(p)
|
|
260
|
+
return (st.st_size, st.st_mtime)
|
|
261
|
+
except Exception:
|
|
262
|
+
return (-1, -1)
|
|
263
|
+
paths.sort(key=key, reverse=True)
|
|
264
|
+
return paths[0]
|
|
265
|
+
|
|
266
|
+
def _is_stable_and_readable(self, path: str) -> bool:
|
|
267
|
+
"""
|
|
268
|
+
Consider stable when size+mtime unchanged for N polls in a row AND file is readable.
|
|
269
|
+
Handles slow writers + Windows "file still locked" issues.
|
|
270
|
+
"""
|
|
271
|
+
stable = 0
|
|
272
|
+
last = None
|
|
273
|
+
|
|
274
|
+
t0 = time.monotonic()
|
|
275
|
+
while self._running and (time.monotonic() - t0) < self._stable_timeout:
|
|
276
|
+
try:
|
|
277
|
+
st = os.stat(path)
|
|
278
|
+
cur = (st.st_size, st.st_mtime)
|
|
279
|
+
if st.st_size <= 0:
|
|
280
|
+
stable = 0
|
|
281
|
+
last = cur
|
|
282
|
+
elif cur == last:
|
|
283
|
+
stable += 1
|
|
284
|
+
else:
|
|
285
|
+
stable = 0
|
|
286
|
+
last = cur
|
|
287
|
+
|
|
288
|
+
if stable >= self._stable_polls:
|
|
289
|
+
# extra “is it readable?” check (important on Windows)
|
|
290
|
+
try:
|
|
291
|
+
with open(path, "rb") as f:
|
|
292
|
+
f.read(64)
|
|
293
|
+
return True
|
|
294
|
+
except PermissionError:
|
|
295
|
+
# still locked by writer, keep waiting
|
|
296
|
+
stable = 0
|
|
297
|
+
except Exception:
|
|
298
|
+
# transient weirdness: keep waiting, don’t declare failure yet
|
|
299
|
+
stable = 0
|
|
300
|
+
|
|
301
|
+
except FileNotFoundError:
|
|
302
|
+
stable = 0
|
|
303
|
+
last = None
|
|
304
|
+
except Exception:
|
|
305
|
+
# don't crash the worker for stat weirdness
|
|
306
|
+
stable = 0
|
|
307
|
+
|
|
308
|
+
time.sleep(self._poll_ms / 1000.0)
|
|
309
|
+
|
|
310
|
+
return False
|
|
311
|
+
|
|
236
312
|
def run(self):
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
313
|
+
t_start = time.monotonic()
|
|
314
|
+
seen_first_candidate_at = None
|
|
315
|
+
|
|
316
|
+
while self._running and (time.monotonic() - t_start) < self._timeout:
|
|
317
|
+
matches = glob.glob(self._glob)
|
|
318
|
+
cand = self._best_candidate(matches)
|
|
319
|
+
|
|
320
|
+
if cand:
|
|
321
|
+
if seen_first_candidate_at is None:
|
|
322
|
+
seen_first_candidate_at = time.monotonic()
|
|
323
|
+
|
|
324
|
+
if self._is_stable_and_readable(cand):
|
|
325
|
+
self.fileFound.emit(cand)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# If we've been seeing candidates for a while but none stabilize,
|
|
329
|
+
# keep looping until global timeout. (This is common on slow disks.)
|
|
330
|
+
|
|
331
|
+
time.sleep(self._poll_ms / 1000.0)
|
|
332
|
+
|
|
333
|
+
if not self._running:
|
|
334
|
+
self.cancelled.emit()
|
|
335
|
+
else:
|
|
336
|
+
extra = ""
|
|
337
|
+
if seen_first_candidate_at is not None:
|
|
338
|
+
extra = " (output appeared but never stabilized)"
|
|
339
|
+
self.error.emit("Output file not found within timeout." + extra)
|
|
340
|
+
|
|
246
341
|
|
|
247
342
|
|
|
248
343
|
# =============================================================================
|
|
@@ -556,6 +651,12 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
556
651
|
QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
|
|
557
652
|
return
|
|
558
653
|
|
|
654
|
+
# ✅ compute base early (we need it for purge + glob)
|
|
655
|
+
base = self._base_name()
|
|
656
|
+
|
|
657
|
+
# ✅ purge any stale outputs for THIS base name (avoids matching old files)
|
|
658
|
+
_purge_cc_io(self.cosmic_root, clear_input=False, clear_output=True, prefix=base)
|
|
659
|
+
|
|
559
660
|
# Build args (SASv2 flags mirrored)
|
|
560
661
|
args = []
|
|
561
662
|
if mode == "sharpen":
|
|
@@ -599,7 +700,7 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
599
700
|
|
|
600
701
|
# Wait for output file
|
|
601
702
|
base = self._base_name()
|
|
602
|
-
out_glob = os.path.join(self.cosmic_root, "output", f"{base}{suffix}
|
|
703
|
+
out_glob = os.path.join(self.cosmic_root, "output", f"{base}*{suffix}*.*")
|
|
603
704
|
self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
|
|
604
705
|
self._wait.cancelled.connect(self._cancel_all)
|
|
605
706
|
self._wait.show()
|
|
@@ -615,19 +716,25 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
615
716
|
|
|
616
717
|
def _read_proc_output(self, proc: QProcess, which="main"):
|
|
617
718
|
out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
|
|
618
|
-
if not self._wait:
|
|
719
|
+
if not self._wait:
|
|
720
|
+
return
|
|
721
|
+
|
|
619
722
|
for line in out.splitlines():
|
|
620
723
|
line = line.strip()
|
|
621
|
-
if not line:
|
|
724
|
+
if not line:
|
|
725
|
+
continue
|
|
726
|
+
|
|
622
727
|
if line.startswith("Progress:"):
|
|
623
728
|
try:
|
|
624
|
-
pct = float(line.split()[1].replace("%",""))
|
|
729
|
+
pct = float(line.split()[1].replace("%", ""))
|
|
625
730
|
self._wait.set_progress(int(pct))
|
|
626
731
|
except Exception:
|
|
627
732
|
pass
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
733
|
+
continue # <- skip echo
|
|
734
|
+
|
|
735
|
+
# non-progress lines: keep showing + printing
|
|
736
|
+
self._wait.append_output(line)
|
|
737
|
+
print(f"[CC] {line}")
|
|
631
738
|
|
|
632
739
|
def _on_proc_finished(self, mode, suffix, code, status):
|
|
633
740
|
if code != 0:
|
|
@@ -58,7 +58,8 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
|
|
|
58
58
|
self._rotating = False
|
|
59
59
|
self._angle0 = 0.0
|
|
60
60
|
self._pivot_scene = QPointF()
|
|
61
|
-
|
|
61
|
+
self._bounds_scene: QRectF | None = None
|
|
62
|
+
self._clamp_eps_deg = 0.25 # treat as "unrotated" if |angle| < eps (deg)
|
|
62
63
|
self._grab_pad = 20 # ← extra hit slop in screen px
|
|
63
64
|
self._edge_pad_px = EDGE_GRAB_PX
|
|
64
65
|
self.setZValue(100) # ← keep above pixmap
|
|
@@ -83,7 +84,26 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
|
|
|
83
84
|
dx = p1.x() - p0.x()
|
|
84
85
|
dy = p1.y() - p0.y()
|
|
85
86
|
return math.hypot(dx, dy)
|
|
86
|
-
|
|
87
|
+
def setBoundsSceneRect(self, r: QRectF | None):
|
|
88
|
+
"""Set the scene-rect bounds we should stay within when unrotated."""
|
|
89
|
+
self._bounds_scene = QRectF(r) if r is not None else None
|
|
90
|
+
|
|
91
|
+
def _is_unrotated(self) -> bool:
|
|
92
|
+
# normalize angle to [-180, 180]
|
|
93
|
+
a = float(self.rotation()) % 360.0
|
|
94
|
+
if a > 180.0:
|
|
95
|
+
a -= 360.0
|
|
96
|
+
return abs(a) < self._clamp_eps_deg
|
|
97
|
+
|
|
98
|
+
def _bounds_local(self) -> QRectF | None:
|
|
99
|
+
"""Bounds rect mapped into the item's local coordinates (only valid when unrotated)."""
|
|
100
|
+
if self._bounds_scene is None:
|
|
101
|
+
return None
|
|
102
|
+
# When unrotated, this is safe and stable.
|
|
103
|
+
tl = self.mapFromScene(self._bounds_scene.topLeft())
|
|
104
|
+
br = self.mapFromScene(self._bounds_scene.bottomRight())
|
|
105
|
+
return QRectF(tl, br).normalized()
|
|
106
|
+
|
|
87
107
|
def _edge_under_cursor(self, scene_pos: QPointF) -> Optional[str]:
|
|
88
108
|
"""
|
|
89
109
|
Return 'l', 'r', 't', or 'b' if the pointer is near an edge (within px-tolerance),
|
|
@@ -218,12 +238,52 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
|
|
|
218
238
|
QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
|
|
219
239
|
):
|
|
220
240
|
self._sync_handles()
|
|
241
|
+
|
|
242
|
+
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
|
243
|
+
if self._bounds_scene is not None and self._is_unrotated():
|
|
244
|
+
new_pos = QPointF(value)
|
|
245
|
+
|
|
246
|
+
# current scene rect of the item (at current pos)
|
|
247
|
+
sr0 = self.mapRectToScene(self.rect()) # QRectF in scene coords
|
|
248
|
+
|
|
249
|
+
# shift it by the delta between proposed pos and current pos
|
|
250
|
+
d = new_pos - self.pos()
|
|
251
|
+
sr = sr0.translated(d)
|
|
252
|
+
|
|
253
|
+
b = self._bounds_scene
|
|
254
|
+
dx = 0.0
|
|
255
|
+
dy = 0.0
|
|
256
|
+
|
|
257
|
+
if sr.left() < b.left():
|
|
258
|
+
dx = b.left() - sr.left()
|
|
259
|
+
elif sr.right() > b.right():
|
|
260
|
+
dx = b.right() - sr.right()
|
|
261
|
+
|
|
262
|
+
if sr.top() < b.top():
|
|
263
|
+
dy = b.top() - sr.top()
|
|
264
|
+
elif sr.bottom() > b.bottom():
|
|
265
|
+
dy = b.bottom() - sr.bottom()
|
|
266
|
+
|
|
267
|
+
if dx != 0.0 or dy != 0.0:
|
|
268
|
+
return new_pos + QPointF(dx, dy)
|
|
269
|
+
|
|
270
|
+
return new_pos
|
|
271
|
+
|
|
221
272
|
return super().itemChange(change, value)
|
|
222
273
|
|
|
223
274
|
def _resize_via_handle(self, scene_pt: QPointF):
|
|
224
275
|
r = self.rect()
|
|
225
276
|
p = self.mapFromScene(scene_pt)
|
|
226
277
|
|
|
278
|
+
# Clamp handle drag to bounds only when unrotated.
|
|
279
|
+
if self._bounds_scene is not None and self._is_unrotated():
|
|
280
|
+
bL = self._bounds_local()
|
|
281
|
+
if bL is not None:
|
|
282
|
+
# NOTE: bL is in the same local coordinate space as r/p.
|
|
283
|
+
px = min(max(p.x(), bL.left()), bL.right())
|
|
284
|
+
py = min(max(p.y(), bL.top()), bL.bottom())
|
|
285
|
+
p = QPointF(px, py)
|
|
286
|
+
|
|
227
287
|
# Corners
|
|
228
288
|
if self._active == "tl": r.setTopLeft(p)
|
|
229
289
|
elif self._active == "tr": r.setTopRight(p)
|
|
@@ -616,6 +676,7 @@ class CropDialogPro(QDialog):
|
|
|
616
676
|
if e.type() == QEvent.Type.MouseMove and self._drawing:
|
|
617
677
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
618
678
|
r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
|
|
679
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
619
680
|
self._draw_live_rect(r)
|
|
620
681
|
|
|
621
682
|
# ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
|
|
@@ -627,9 +688,12 @@ class CropDialogPro(QDialog):
|
|
|
627
688
|
self._drawing = False
|
|
628
689
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
629
690
|
r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
|
|
691
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
630
692
|
self._clear_live_rect()
|
|
693
|
+
|
|
631
694
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
632
695
|
self._rect_item.setZValue(10)
|
|
696
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
633
697
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
634
698
|
self.scene.addItem(self._rect_item)
|
|
635
699
|
|
|
@@ -715,6 +779,7 @@ class CropDialogPro(QDialog):
|
|
|
715
779
|
if self._rect_item is None:
|
|
716
780
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
717
781
|
self._rect_item.setZValue(10)
|
|
782
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
718
783
|
self.scene.addItem(self._rect_item)
|
|
719
784
|
else:
|
|
720
785
|
self._rect_item.setRotation(0.0)
|
|
@@ -758,6 +823,32 @@ class CropDialogPro(QDialog):
|
|
|
758
823
|
if hasattr(self, "_live_rect") and self._live_rect:
|
|
759
824
|
self.scene.removeItem(self._live_rect); self._live_rect = None
|
|
760
825
|
|
|
826
|
+
def _pixmap_scene_rect(self) -> QRectF | None:
|
|
827
|
+
"""Scene rect occupied by the pixmap (image) item."""
|
|
828
|
+
if not self._pix_item:
|
|
829
|
+
return None
|
|
830
|
+
return self._pix_item.mapRectToScene(self._pix_item.boundingRect())
|
|
831
|
+
|
|
832
|
+
def _clamp_rect_to_pixmap(self, r: QRectF) -> QRectF:
|
|
833
|
+
"""Intersect an axis-aligned QRectF with the pixmap scene rect."""
|
|
834
|
+
bounds = self._pixmap_scene_rect()
|
|
835
|
+
if bounds is None:
|
|
836
|
+
return r.normalized()
|
|
837
|
+
rr = r.normalized().intersected(bounds)
|
|
838
|
+
# avoid empty rects (keep at least 1x1 scene unit)
|
|
839
|
+
if rr.isNull() or rr.width() <= 1e-6 or rr.height() <= 1e-6:
|
|
840
|
+
# fallback: clamp to a 1x1 rect at the nearest point inside bounds
|
|
841
|
+
x = min(max(r.center().x(), bounds.left()), bounds.right())
|
|
842
|
+
y = min(max(r.center().y(), bounds.top()), bounds.bottom())
|
|
843
|
+
rr = QRectF(x, y, 1.0, 1.0)
|
|
844
|
+
return rr.normalized()
|
|
845
|
+
|
|
846
|
+
def _bounds_scene_rect(self) -> QRectF | None:
|
|
847
|
+
if not self._pix_item:
|
|
848
|
+
return None
|
|
849
|
+
return self._pix_item.mapRectToScene(self._pix_item.boundingRect())
|
|
850
|
+
|
|
851
|
+
|
|
761
852
|
# ---------- preview toggles ----------
|
|
762
853
|
def _toggle_autostretch(self):
|
|
763
854
|
self._autostretch_on = not self._autostretch_on
|
|
@@ -777,6 +868,7 @@ class CropDialogPro(QDialog):
|
|
|
777
868
|
r, ang, pos = state
|
|
778
869
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
779
870
|
self._rect_item.setZValue(10)
|
|
871
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
780
872
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
781
873
|
self._rect_item.setRotation(ang)
|
|
782
874
|
self._rect_item.setPos(pos)
|
|
@@ -794,6 +886,7 @@ class CropDialogPro(QDialog):
|
|
|
794
886
|
r = QRectF(CropDialogPro._prev_rect)
|
|
795
887
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
796
888
|
self._rect_item.setZValue(10)
|
|
889
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
797
890
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
798
891
|
self._rect_item.setRotation(CropDialogPro._prev_angle)
|
|
799
892
|
self._rect_item.setPos(CropDialogPro._prev_pos)
|
|
@@ -812,6 +905,7 @@ class CropDialogPro(QDialog):
|
|
|
812
905
|
sx, sy = w_img / pm.width(), h_img / pm.height()
|
|
813
906
|
return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
|
|
814
907
|
|
|
908
|
+
|
|
815
909
|
def _apply_one(self):
|
|
816
910
|
if not self._rect_item:
|
|
817
911
|
QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
|
|
@@ -521,32 +521,6 @@ class CurveEditor(QGraphicsView):
|
|
|
521
521
|
if ln is not None:
|
|
522
522
|
ln.setVisible(False)
|
|
523
523
|
|
|
524
|
-
def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
|
|
525
|
-
"""(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
|
|
526
|
-
out = []
|
|
527
|
-
lastx = -1e9
|
|
528
|
-
for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
|
|
529
|
-
x = float(np.clip(x, 0.0, 360.0))
|
|
530
|
-
y = float(np.clip(y, 0.0, 360.0))
|
|
531
|
-
# strictly increasing X
|
|
532
|
-
if x <= lastx:
|
|
533
|
-
x = lastx + 1e-3
|
|
534
|
-
lastx = x
|
|
535
|
-
out.append((x / 360.0, 1.0 - (y / 360.0)))
|
|
536
|
-
# ensure endpoints
|
|
537
|
-
if not any(abs(px - 0.0) < 1e-6 for px, _ in out): out.insert(0, (0.0, 0.0))
|
|
538
|
-
if not any(abs(px - 1.0) < 1e-6 for px, _ in out): out.append((1.0, 1.0))
|
|
539
|
-
# clamp
|
|
540
|
-
return [(float(np.clip(x,0,1)), float(np.clip(y,0,1))) for (x,y) in out]
|
|
541
|
-
|
|
542
|
-
def _collect_points_norm_from_editor(self) -> list[tuple[float,float]]:
|
|
543
|
-
"""Take endpoints+handles from editor => normalized points."""
|
|
544
|
-
pts_scene = []
|
|
545
|
-
for p in (self.editor.end_points + self.editor.control_points):
|
|
546
|
-
pos = p.scenePos()
|
|
547
|
-
pts_scene.append((float(pos.x()), float(pos.y())))
|
|
548
|
-
return self._scene_to_norm_points(pts_scene)
|
|
549
|
-
|
|
550
524
|
|
|
551
525
|
def redistributeHandlesByPivot(self, u: float):
|
|
552
526
|
"""
|
|
@@ -1048,7 +1022,14 @@ class CurvesDialogPro(QDialog):
|
|
|
1048
1022
|
self._cdf = None
|
|
1049
1023
|
self._cdf_bins = 1024
|
|
1050
1024
|
self._cdf_total = 0
|
|
1051
|
-
|
|
1025
|
+
# Debounce: coalesce rapid curve edits into one rebuild
|
|
1026
|
+
self._curve_debounce_ms = 120 # tweak: 80–200ms feels good
|
|
1027
|
+
self._curve_debounce = QTimer(self)
|
|
1028
|
+
self._curve_debounce.setSingleShot(True)
|
|
1029
|
+
self._curve_debounce.timeout.connect(self._rebuild_preview_from_curve_debounced)
|
|
1030
|
+
|
|
1031
|
+
# Optional: generation counter so stale results can't “win”
|
|
1032
|
+
self._curve_gen = 0
|
|
1052
1033
|
self._clip_scale = 1.0 # preview→full multiplier
|
|
1053
1034
|
self._cdf_total_full = 0 # total pixels in full image (H*W)
|
|
1054
1035
|
self._cdf_total_preview = 0 # total pixels in preview (H*W)
|
|
@@ -1220,18 +1201,34 @@ class CurvesDialogPro(QDialog):
|
|
|
1220
1201
|
|
|
1221
1202
|
def _on_editor_curve_changed(self, _lut8=None):
|
|
1222
1203
|
"""
|
|
1223
|
-
Called on every editor redraw/drag. Persist
|
|
1224
|
-
|
|
1204
|
+
Called on every editor redraw/drag. Persist points and refresh overlays.
|
|
1205
|
+
Preview rebuild is DEBOUNCED to avoid spamming.
|
|
1225
1206
|
"""
|
|
1226
1207
|
try:
|
|
1227
1208
|
self._curves_store[self._current_mode_key] = self._editor_points_norm()
|
|
1228
1209
|
except Exception:
|
|
1229
1210
|
pass
|
|
1230
|
-
|
|
1211
|
+
|
|
1212
|
+
# cheap: overlay redraw is fine every move (or you can debounce this too)
|
|
1231
1213
|
self._refresh_overlays()
|
|
1232
|
-
# now build from *all* current curves (including the just-edited one)
|
|
1233
|
-
self._quick_preview()
|
|
1234
1214
|
|
|
1215
|
+
# expensive: debounce the preview rebuild
|
|
1216
|
+
self._curve_gen += 1
|
|
1217
|
+
self._curve_debounce.start(self._curve_debounce_ms)
|
|
1218
|
+
|
|
1219
|
+
def _rebuild_preview_from_curve_debounced(self):
|
|
1220
|
+
"""
|
|
1221
|
+
Runs after the user pauses dragging for _curve_debounce_ms.
|
|
1222
|
+
Only rebuild if we have images loaded.
|
|
1223
|
+
"""
|
|
1224
|
+
if self._preview_orig is None and self._preview_img is None:
|
|
1225
|
+
return
|
|
1226
|
+
# If your preview toggle is off, you may want to skip:
|
|
1227
|
+
if not getattr(self, "btn_preview", None) or not self.btn_preview.isChecked():
|
|
1228
|
+
return
|
|
1229
|
+
|
|
1230
|
+
# Do the real work (what you were doing before)
|
|
1231
|
+
self._quick_preview()
|
|
1235
1232
|
|
|
1236
1233
|
def _active_mode_key(self) -> str:
|
|
1237
1234
|
for b in self.mode_group.buttons():
|
|
@@ -1678,29 +1675,53 @@ class CurvesDialogPro(QDialog):
|
|
|
1678
1675
|
|
|
1679
1676
|
# 1) Put this helper inside CurvesDialogPro (near other helpers)
|
|
1680
1677
|
def _map_label_xy_to_image_ij(self, x: float, y: float):
|
|
1681
|
-
"""
|
|
1678
|
+
"""
|
|
1679
|
+
Map label-local coords (x,y) to _preview_img pixel (ix, iy).
|
|
1680
|
+
Correct even when the pixmap is centered inside a larger label.
|
|
1681
|
+
Returns None if cursor is outside the displayed pixmap area.
|
|
1682
|
+
"""
|
|
1682
1683
|
if self._pix is None:
|
|
1683
1684
|
return None
|
|
1685
|
+
|
|
1684
1686
|
pm_disp = self.label.pixmap()
|
|
1685
1687
|
if pm_disp is None or pm_disp.isNull():
|
|
1686
1688
|
return None
|
|
1687
1689
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
disp_w = pm_disp.width() # size of the *displayed* pixmap on the label
|
|
1690
|
+
# Displayed pixmap size (after zoom)
|
|
1691
|
+
disp_w = pm_disp.width()
|
|
1691
1692
|
disp_h = pm_disp.height()
|
|
1692
|
-
|
|
1693
|
+
|
|
1694
|
+
# Label may be bigger -> pixmap is centered with margins
|
|
1695
|
+
lbl_w = self.label.width()
|
|
1696
|
+
lbl_h = self.label.height()
|
|
1697
|
+
|
|
1698
|
+
off_x = max(0, (lbl_w - disp_w) // 2)
|
|
1699
|
+
off_y = max(0, (lbl_h - disp_h) // 2)
|
|
1700
|
+
|
|
1701
|
+
# Remove margins: label-local -> pixmap-local
|
|
1702
|
+
px = float(x) - float(off_x)
|
|
1703
|
+
py = float(y) - float(off_y)
|
|
1704
|
+
|
|
1705
|
+
if px < 0 or py < 0 or px >= disp_w or py >= disp_h:
|
|
1706
|
+
return None # outside actual image area
|
|
1707
|
+
|
|
1708
|
+
# Now convert displayed pixmap pixel -> source preview pixel
|
|
1709
|
+
src_w = self._pix.width()
|
|
1710
|
+
src_h = self._pix.height()
|
|
1711
|
+
if src_w <= 0 or src_h <= 0:
|
|
1693
1712
|
return None
|
|
1694
1713
|
|
|
1695
1714
|
sx = disp_w / float(src_w)
|
|
1696
1715
|
sy = disp_h / float(src_h)
|
|
1697
1716
|
|
|
1698
|
-
ix = int(
|
|
1699
|
-
iy = int(
|
|
1717
|
+
ix = int(px / sx)
|
|
1718
|
+
iy = int(py / sy)
|
|
1719
|
+
|
|
1700
1720
|
if ix < 0 or iy < 0 or ix >= src_w or iy >= src_h:
|
|
1701
1721
|
return None
|
|
1702
1722
|
return ix, iy
|
|
1703
1723
|
|
|
1724
|
+
|
|
1704
1725
|
def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
|
|
1705
1726
|
"""(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
|
|
1706
1727
|
out = []
|
|
@@ -1748,14 +1769,38 @@ class CurvesDialogPro(QDialog):
|
|
|
1748
1769
|
name, ok = QInputDialog.getText(self, self.tr("Save Curves Preset"), self.tr("Preset name:"))
|
|
1749
1770
|
if not ok or not name.strip():
|
|
1750
1771
|
return
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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))
|
|
1755
1799
|
self._rebuild_presets_menu()
|
|
1756
1800
|
else:
|
|
1757
1801
|
QMessageBox.warning(self, self.tr("Save failed"), self.tr("Could not save preset."))
|
|
1758
1802
|
|
|
1803
|
+
|
|
1759
1804
|
def _rebuild_presets_menu(self):
|
|
1760
1805
|
m = QMenu(self)
|
|
1761
1806
|
# Built-in shapes under K (Brightness)
|
|
@@ -2379,43 +2424,69 @@ class CurvesDialogPro(QDialog):
|
|
|
2379
2424
|
def _apply_preset_dict(self, preset: dict):
|
|
2380
2425
|
preset = preset or {}
|
|
2381
2426
|
|
|
2382
|
-
#
|
|
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)
|
|
2383
2466
|
want = _norm_mode(preset.get("mode"))
|
|
2384
2467
|
for b in self.mode_group.buttons():
|
|
2385
2468
|
if b.text().lower() == want.lower():
|
|
2386
2469
|
b.setChecked(True)
|
|
2387
2470
|
break
|
|
2388
2471
|
|
|
2389
|
-
# 2) get points_norm — if absent, build from shape/amount (built-ins)
|
|
2390
2472
|
ptsN = preset.get("points_norm")
|
|
2391
|
-
shape = preset.get("shape")
|
|
2473
|
+
shape = preset.get("shape")
|
|
2392
2474
|
amount = float(preset.get("amount", 1.0))
|
|
2393
2475
|
|
|
2394
2476
|
if not (isinstance(ptsN, (list, tuple)) and len(ptsN) >= 2):
|
|
2395
2477
|
try:
|
|
2396
|
-
# build from a named shape (built-ins); default to linear
|
|
2397
2478
|
ptsN = _shape_points_norm(str(shape or "linear"), amount)
|
|
2398
2479
|
except Exception:
|
|
2399
|
-
ptsN = [(0.0, 0.0), (1.0, 1.0)]
|
|
2400
|
-
|
|
2401
|
-
# 3) apply handles to the editor (strip exact endpoints)
|
|
2402
|
-
pts_scene = _points_norm_to_scene(ptsN)
|
|
2403
|
-
filt = [(x, y) for (x, y) in pts_scene if 1e-6 < x < 360.0 - 1e-6]
|
|
2480
|
+
ptsN = [(0.0, 0.0), (1.0, 1.0)]
|
|
2404
2481
|
|
|
2405
|
-
|
|
2406
|
-
self.editor.clearSymmetryLine()
|
|
2407
|
-
|
|
2408
|
-
self.editor.setControlHandles(filt)
|
|
2409
|
-
self.editor.updateCurve() # ensure redraw
|
|
2410
|
-
|
|
2411
|
-
# persist into store & refresh
|
|
2482
|
+
self._editor_set_from_norm(ptsN)
|
|
2412
2483
|
self._curves_store[self._current_mode_key] = self._editor_points_norm()
|
|
2413
2484
|
self._refresh_overlays()
|
|
2414
2485
|
self._quick_preview()
|
|
2415
2486
|
|
|
2416
|
-
# 4) status: don’t assume shape exists
|
|
2417
2487
|
shape_tag = f"[{shape}]" if shape else "[custom]"
|
|
2418
|
-
self._set_status(self.tr("Preset: {0} {1}").format(preset.get(
|
|
2488
|
+
self._set_status(self.tr("Preset: {0} {1}").format(preset.get("name", self.tr("(built-in)")), shape_tag))
|
|
2489
|
+
|
|
2419
2490
|
|
|
2420
2491
|
|
|
2421
2492
|
def apply_curves_ops(doc, op: dict):
|