setiastrosuitepro 1.6.4__py3-none-any.whl → 1.6.10__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.
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +19 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +35 -7
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +4 -1
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +67 -4
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +393 -204
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +356 -12
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +5 -1
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +180 -79
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +51 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +5 -2
- setiastro/saspro/ops/scripts.py +3 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/resources.py +67 -0
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/sfcc.py +60 -2
- setiastro/saspro/shortcuts.py +142 -23
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1017 -400
- setiastro/saspro/star_alignment.py +4 -1
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +702 -128
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +60 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +109 -59
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +112 -80
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/clahe.py
CHANGED
|
@@ -122,7 +122,10 @@ class CLAHEDialogPro(QDialog):
|
|
|
122
122
|
except Exception as e:
|
|
123
123
|
import logging
|
|
124
124
|
logging.debug(f"Exception suppressed: {type(e).__name__}: {e}")
|
|
125
|
-
|
|
125
|
+
try:
|
|
126
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
127
|
+
except Exception:
|
|
128
|
+
pass # older PyQt6 versions
|
|
126
129
|
self.doc = doc
|
|
127
130
|
self.orig = np.clip(np.asarray(doc.image, dtype=np.float32), 0.0, 1.0)
|
|
128
131
|
disp = self.orig
|
|
@@ -105,7 +105,10 @@ class ContinuumSubtractTab(QWidget):
|
|
|
105
105
|
self.processing_thread = None
|
|
106
106
|
self.original_header = None
|
|
107
107
|
self._clickable_images = {}
|
|
108
|
-
|
|
108
|
+
try:
|
|
109
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
110
|
+
except Exception:
|
|
111
|
+
pass # older PyQt6 versions
|
|
109
112
|
|
|
110
113
|
def initUI(self):
|
|
111
114
|
self.spinnerLabel = QLabel("") # starts empty
|
setiastro/saspro/convo.py
CHANGED
|
@@ -151,7 +151,10 @@ class ConvoDeconvoDialog(QDialog):
|
|
|
151
151
|
# Only follow global active-doc changes if we *weren't* given a doc
|
|
152
152
|
if hasattr(self._main, "currentDocumentChanged") and self._doc_override is None:
|
|
153
153
|
self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
154
|
-
|
|
154
|
+
try:
|
|
155
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
156
|
+
except Exception:
|
|
157
|
+
pass # older PyQt6 versions
|
|
155
158
|
self.setWindowTitle(self.tr("Convolution / Deconvolution"))
|
|
156
159
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
157
160
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
@@ -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
|
# =============================================================================
|
|
@@ -274,6 +369,10 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
274
369
|
self.setWindowFlag(Qt.WindowType.Window, True)
|
|
275
370
|
self.setWindowModality(Qt.WindowModality.NonModal)
|
|
276
371
|
self.setModal(False)
|
|
372
|
+
try:
|
|
373
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass # older PyQt6 versions
|
|
277
376
|
if icon:
|
|
278
377
|
try: self.setWindowIcon(icon)
|
|
279
378
|
except Exception as e:
|
|
@@ -552,6 +651,12 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
552
651
|
QMessageBox.critical(self, "Cosmic Clarity", f"Executable not found:\n{exe_path}")
|
|
553
652
|
return
|
|
554
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
|
+
|
|
555
660
|
# Build args (SASv2 flags mirrored)
|
|
556
661
|
args = []
|
|
557
662
|
if mode == "sharpen":
|
|
@@ -595,7 +700,7 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
595
700
|
|
|
596
701
|
# Wait for output file
|
|
597
702
|
base = self._base_name()
|
|
598
|
-
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}*.*")
|
|
599
704
|
self._wait = WaitDialog(f"Cosmic Clarity – {mode.title()}", self)
|
|
600
705
|
self._wait.cancelled.connect(self._cancel_all)
|
|
601
706
|
self._wait.show()
|
|
@@ -611,19 +716,25 @@ class CosmicClarityDialogPro(QDialog):
|
|
|
611
716
|
|
|
612
717
|
def _read_proc_output(self, proc: QProcess, which="main"):
|
|
613
718
|
out = proc.readAllStandardOutput().data().decode("utf-8", errors="replace")
|
|
614
|
-
if not self._wait:
|
|
719
|
+
if not self._wait:
|
|
720
|
+
return
|
|
721
|
+
|
|
615
722
|
for line in out.splitlines():
|
|
616
723
|
line = line.strip()
|
|
617
|
-
if not line:
|
|
724
|
+
if not line:
|
|
725
|
+
continue
|
|
726
|
+
|
|
618
727
|
if line.startswith("Progress:"):
|
|
619
728
|
try:
|
|
620
|
-
pct = float(line.split()[1].replace("%",""))
|
|
729
|
+
pct = float(line.split()[1].replace("%", ""))
|
|
621
730
|
self._wait.set_progress(int(pct))
|
|
622
731
|
except Exception:
|
|
623
732
|
pass
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
733
|
+
continue # <- skip echo
|
|
734
|
+
|
|
735
|
+
# non-progress lines: keep showing + printing
|
|
736
|
+
self._wait.append_output(line)
|
|
737
|
+
print(f"[CC] {line}")
|
|
627
738
|
|
|
628
739
|
def _on_proc_finished(self, mode, suffix, code, status):
|
|
629
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)
|
|
@@ -277,10 +337,19 @@ class CropDialogPro(QDialog):
|
|
|
277
337
|
self._main = parent
|
|
278
338
|
self.doc = document
|
|
279
339
|
|
|
280
|
-
|
|
340
|
+
self._follow_conn = False
|
|
281
341
|
if hasattr(self._main, "currentDocumentChanged"):
|
|
282
|
-
|
|
342
|
+
try:
|
|
343
|
+
self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
344
|
+
self._follow_conn = True
|
|
345
|
+
except Exception:
|
|
346
|
+
self._follow_conn = False
|
|
283
347
|
|
|
348
|
+
self.finished.connect(self._cleanup_connections)
|
|
349
|
+
try:
|
|
350
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
351
|
+
except Exception:
|
|
352
|
+
pass # older PyQt6 versions
|
|
284
353
|
self._rect_item: Optional[ResizableRotatableRectItem] = None
|
|
285
354
|
self._pix_item: Optional[QGraphicsPixmapItem] = None
|
|
286
355
|
self._drawing = False
|
|
@@ -415,7 +484,7 @@ class CropDialogPro(QDialog):
|
|
|
415
484
|
self.btn_prev.clicked.connect(self._load_previous)
|
|
416
485
|
self.btn_apply.clicked.connect(self._apply_one)
|
|
417
486
|
self.btn_batch.clicked.connect(self._apply_batch)
|
|
418
|
-
self.btn_close.clicked.connect(self.
|
|
487
|
+
self.btn_close.clicked.connect(self.close)
|
|
419
488
|
|
|
420
489
|
# seed image
|
|
421
490
|
self._load_from_doc()
|
|
@@ -607,6 +676,7 @@ class CropDialogPro(QDialog):
|
|
|
607
676
|
if e.type() == QEvent.Type.MouseMove and self._drawing:
|
|
608
677
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
609
678
|
r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
|
|
679
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
610
680
|
self._draw_live_rect(r)
|
|
611
681
|
|
|
612
682
|
# ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
|
|
@@ -618,9 +688,12 @@ class CropDialogPro(QDialog):
|
|
|
618
688
|
self._drawing = False
|
|
619
689
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
620
690
|
r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
|
|
691
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
621
692
|
self._clear_live_rect()
|
|
693
|
+
|
|
622
694
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
623
695
|
self._rect_item.setZValue(10)
|
|
696
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
624
697
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
625
698
|
self.scene.addItem(self._rect_item)
|
|
626
699
|
|
|
@@ -706,6 +779,7 @@ class CropDialogPro(QDialog):
|
|
|
706
779
|
if self._rect_item is None:
|
|
707
780
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
708
781
|
self._rect_item.setZValue(10)
|
|
782
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
709
783
|
self.scene.addItem(self._rect_item)
|
|
710
784
|
else:
|
|
711
785
|
self._rect_item.setRotation(0.0)
|
|
@@ -749,6 +823,32 @@ class CropDialogPro(QDialog):
|
|
|
749
823
|
if hasattr(self, "_live_rect") and self._live_rect:
|
|
750
824
|
self.scene.removeItem(self._live_rect); self._live_rect = None
|
|
751
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
|
+
|
|
752
852
|
# ---------- preview toggles ----------
|
|
753
853
|
def _toggle_autostretch(self):
|
|
754
854
|
self._autostretch_on = not self._autostretch_on
|
|
@@ -768,6 +868,7 @@ class CropDialogPro(QDialog):
|
|
|
768
868
|
r, ang, pos = state
|
|
769
869
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
770
870
|
self._rect_item.setZValue(10)
|
|
871
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
771
872
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
772
873
|
self._rect_item.setRotation(ang)
|
|
773
874
|
self._rect_item.setPos(pos)
|
|
@@ -785,6 +886,7 @@ class CropDialogPro(QDialog):
|
|
|
785
886
|
r = QRectF(CropDialogPro._prev_rect)
|
|
786
887
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
787
888
|
self._rect_item.setZValue(10)
|
|
889
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
788
890
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
789
891
|
self._rect_item.setRotation(CropDialogPro._prev_angle)
|
|
790
892
|
self._rect_item.setPos(CropDialogPro._prev_pos)
|
|
@@ -803,6 +905,7 @@ class CropDialogPro(QDialog):
|
|
|
803
905
|
sx, sy = w_img / pm.width(), h_img / pm.height()
|
|
804
906
|
return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
|
|
805
907
|
|
|
908
|
+
|
|
806
909
|
def _apply_one(self):
|
|
807
910
|
if not self._rect_item:
|
|
808
911
|
QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
|
|
@@ -865,7 +968,7 @@ class CropDialogPro(QDialog):
|
|
|
865
968
|
self.doc.apply_edit(out.copy(), metadata={**new_meta, "step_name": "Crop"}, step_name="Crop")
|
|
866
969
|
self._maybe_notify_wcs_update(new_meta)
|
|
867
970
|
self.crop_applied.emit(out)
|
|
868
|
-
self.
|
|
971
|
+
self.close()
|
|
869
972
|
except Exception as e:
|
|
870
973
|
QMessageBox.critical(self, self.tr("Apply failed"), str(e))
|
|
871
974
|
|
|
@@ -951,7 +1054,7 @@ class CropDialogPro(QDialog):
|
|
|
951
1054
|
QMessageBox.information(self, self.tr("Batch Crop"), self.tr("Applied crop to all open images. Any Astrometric Solutions has been updated."))
|
|
952
1055
|
if last_cropped is not None:
|
|
953
1056
|
self.crop_applied.emit(last_cropped)
|
|
954
|
-
self.
|
|
1057
|
+
self.close()
|
|
955
1058
|
|
|
956
1059
|
def _maybe_notify_wcs_update(self, meta: dict, batch_note: str | None = None):
|
|
957
1060
|
dbg = (meta or {}).get("__wcs_debug__")
|
|
@@ -981,3 +1084,16 @@ class CropDialogPro(QDialog):
|
|
|
981
1084
|
except Exception:
|
|
982
1085
|
# Be quiet if formatting fails
|
|
983
1086
|
pass
|
|
1087
|
+
|
|
1088
|
+
def _cleanup_connections(self):
|
|
1089
|
+
try:
|
|
1090
|
+
if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
|
|
1091
|
+
self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
|
|
1092
|
+
except Exception:
|
|
1093
|
+
pass
|
|
1094
|
+
self._follow_conn = False
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def closeEvent(self, ev):
|
|
1098
|
+
self._cleanup_connections()
|
|
1099
|
+
super().closeEvent(ev)
|
|
@@ -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
|
"""
|
|
@@ -1022,10 +996,18 @@ class CurvesDialogPro(QDialog):
|
|
|
1022
996
|
self._main = parent
|
|
1023
997
|
self.doc = document
|
|
1024
998
|
|
|
1025
|
-
|
|
999
|
+
self._follow_conn = False
|
|
1026
1000
|
if hasattr(self._main, "currentDocumentChanged"):
|
|
1027
|
-
|
|
1028
|
-
|
|
1001
|
+
try:
|
|
1002
|
+
self._main.currentDocumentChanged.connect(self._on_active_doc_changed)
|
|
1003
|
+
self._follow_conn = True
|
|
1004
|
+
except Exception:
|
|
1005
|
+
self._follow_conn = False
|
|
1006
|
+
try:
|
|
1007
|
+
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
|
|
1008
|
+
except Exception:
|
|
1009
|
+
pass # older PyQt6 versions
|
|
1010
|
+
self.finished.connect(self._cleanup_connections)
|
|
1029
1011
|
self._preview_img = None # downsampled float01
|
|
1030
1012
|
self._full_img = None # full-res float01
|
|
1031
1013
|
self._pix = None
|
|
@@ -1040,7 +1022,14 @@ class CurvesDialogPro(QDialog):
|
|
|
1040
1022
|
self._cdf = None
|
|
1041
1023
|
self._cdf_bins = 1024
|
|
1042
1024
|
self._cdf_total = 0
|
|
1043
|
-
|
|
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
|
|
1044
1033
|
self._clip_scale = 1.0 # preview→full multiplier
|
|
1045
1034
|
self._cdf_total_full = 0 # total pixels in full image (H*W)
|
|
1046
1035
|
self._cdf_total_preview = 0 # total pixels in preview (H*W)
|
|
@@ -1212,18 +1201,34 @@ class CurvesDialogPro(QDialog):
|
|
|
1212
1201
|
|
|
1213
1202
|
def _on_editor_curve_changed(self, _lut8=None):
|
|
1214
1203
|
"""
|
|
1215
|
-
Called on every editor redraw/drag. Persist
|
|
1216
|
-
|
|
1204
|
+
Called on every editor redraw/drag. Persist points and refresh overlays.
|
|
1205
|
+
Preview rebuild is DEBOUNCED to avoid spamming.
|
|
1217
1206
|
"""
|
|
1218
1207
|
try:
|
|
1219
1208
|
self._curves_store[self._current_mode_key] = self._editor_points_norm()
|
|
1220
1209
|
except Exception:
|
|
1221
1210
|
pass
|
|
1222
|
-
|
|
1211
|
+
|
|
1212
|
+
# cheap: overlay redraw is fine every move (or you can debounce this too)
|
|
1223
1213
|
self._refresh_overlays()
|
|
1224
|
-
# now build from *all* current curves (including the just-edited one)
|
|
1225
|
-
self._quick_preview()
|
|
1226
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()
|
|
1227
1232
|
|
|
1228
1233
|
def _active_mode_key(self) -> str:
|
|
1229
1234
|
for b in self.mode_group.buttons():
|
|
@@ -1670,29 +1675,53 @@ class CurvesDialogPro(QDialog):
|
|
|
1670
1675
|
|
|
1671
1676
|
# 1) Put this helper inside CurvesDialogPro (near other helpers)
|
|
1672
1677
|
def _map_label_xy_to_image_ij(self, x: float, y: float):
|
|
1673
|
-
"""
|
|
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
|
+
"""
|
|
1674
1683
|
if self._pix is None:
|
|
1675
1684
|
return None
|
|
1685
|
+
|
|
1676
1686
|
pm_disp = self.label.pixmap()
|
|
1677
1687
|
if pm_disp is None or pm_disp.isNull():
|
|
1678
1688
|
return None
|
|
1679
1689
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
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()
|
|
1683
1692
|
disp_h = pm_disp.height()
|
|
1684
|
-
|
|
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:
|
|
1685
1712
|
return None
|
|
1686
1713
|
|
|
1687
1714
|
sx = disp_w / float(src_w)
|
|
1688
1715
|
sy = disp_h / float(src_h)
|
|
1689
1716
|
|
|
1690
|
-
ix = int(
|
|
1691
|
-
iy = int(
|
|
1717
|
+
ix = int(px / sx)
|
|
1718
|
+
iy = int(py / sy)
|
|
1719
|
+
|
|
1692
1720
|
if ix < 0 or iy < 0 or ix >= src_w or iy >= src_h:
|
|
1693
1721
|
return None
|
|
1694
1722
|
return ix, iy
|
|
1695
1723
|
|
|
1724
|
+
|
|
1696
1725
|
def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
|
|
1697
1726
|
"""(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
|
|
1698
1727
|
out = []
|
|
@@ -2178,6 +2207,44 @@ class CurvesDialogPro(QDialog):
|
|
|
2178
2207
|
|
|
2179
2208
|
return (m * out + (1.0 - m) * src).astype(np.float32, copy=False)
|
|
2180
2209
|
|
|
2210
|
+
def closeEvent(self, ev):
|
|
2211
|
+
self._cleanup_connections()
|
|
2212
|
+
super().closeEvent(ev)
|
|
2213
|
+
|
|
2214
|
+
def _cleanup_connections(self):
|
|
2215
|
+
# disconnect the "follow active doc" hook
|
|
2216
|
+
try:
|
|
2217
|
+
if self._follow_conn and hasattr(self._main, "currentDocumentChanged"):
|
|
2218
|
+
self._main.currentDocumentChanged.disconnect(self._on_active_doc_changed)
|
|
2219
|
+
except Exception:
|
|
2220
|
+
pass
|
|
2221
|
+
self._follow_conn = False
|
|
2222
|
+
|
|
2223
|
+
# stop/kill any running worker thread(s)
|
|
2224
|
+
try:
|
|
2225
|
+
thr = getattr(self, "_thr", None)
|
|
2226
|
+
if thr is not None:
|
|
2227
|
+
try:
|
|
2228
|
+
thr.requestInterruption()
|
|
2229
|
+
except Exception:
|
|
2230
|
+
pass
|
|
2231
|
+
try:
|
|
2232
|
+
thr.quit()
|
|
2233
|
+
except Exception:
|
|
2234
|
+
pass
|
|
2235
|
+
try:
|
|
2236
|
+
thr.wait(250)
|
|
2237
|
+
except Exception:
|
|
2238
|
+
pass
|
|
2239
|
+
except Exception:
|
|
2240
|
+
pass
|
|
2241
|
+
|
|
2242
|
+
# optional: drop refs that can keep things alive
|
|
2243
|
+
try:
|
|
2244
|
+
self._thr = None
|
|
2245
|
+
except Exception:
|
|
2246
|
+
pass
|
|
2247
|
+
|
|
2181
2248
|
|
|
2182
2249
|
# zoom/pan
|
|
2183
2250
|
def _apply_zoom(self):
|