setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.post2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of setiastrosuitepro might be problematic. Click here for more details.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -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/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -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 +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- 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 +706 -264
- 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 +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- 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 +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -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 +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- 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 +81 -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 +10 -2
- setiastro/saspro/ops/scripts.py +122 -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/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- 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.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.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
|
# =============================================================================
|
|
@@ -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)
|