setiastrosuitepro 1.6.2__py3-none-any.whl → 1.6.12__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/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/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/rotatearbitrary.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 +237 -21
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/backgroundneutral.py +114 -37
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +548 -275
- 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 +134 -8
- setiastro/saspro/curve_editor_pro.py +109 -42
- setiastro/saspro/doc_manager.py +246 -16
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/function_bundle.py +16 -16
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +519 -289
- setiastro/saspro/gui/mixins/dock_mixin.py +276 -42
- setiastro/saspro/gui/mixins/geometry_mixin.py +105 -5
- setiastro/saspro/gui/mixins/menu_mixin.py +28 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +416 -27
- 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/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 +67 -47
- 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 +748 -255
- 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_stars_preset.py +55 -13
- setiastro/saspro/resources.py +97 -11
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +83 -21
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +253 -49
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1610 -574
- setiastro/saspro/star_alignment.py +522 -453
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +743 -128
- setiastro/saspro/status_log_dock.py +1 -1
- setiastro/saspro/subwindow.py +786 -360
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/swap_manager.py +77 -42
- setiastro/saspro/translations/all_source_strings.json +1588 -516
- setiastro/saspro/translations/ar_translations.py +915 -684
- setiastro/saspro/translations/de_translations.py +442 -463
- setiastro/saspro/translations/es_translations.py +277 -47
- setiastro/saspro/translations/fr_translations.py +279 -47
- setiastro/saspro/translations/hi_translations.py +253 -21
- setiastro/saspro/translations/integrate_translations.py +3 -2
- setiastro/saspro/translations/it_translations.py +1211 -161
- setiastro/saspro/translations/ja_translations.py +3340 -3107
- setiastro/saspro/translations/pt_translations.py +3315 -3337
- setiastro/saspro/translations/ru_translations.py +351 -117
- setiastro/saspro/translations/saspro_ar.qm +0 -0
- setiastro/saspro/translations/saspro_ar.ts +15902 -138
- setiastro/saspro/translations/saspro_de.qm +0 -0
- setiastro/saspro/translations/saspro_de.ts +14428 -133
- setiastro/saspro/translations/saspro_es.qm +0 -0
- setiastro/saspro/translations/saspro_es.ts +11503 -7821
- setiastro/saspro/translations/saspro_fr.qm +0 -0
- setiastro/saspro/translations/saspro_fr.ts +11168 -7812
- setiastro/saspro/translations/saspro_hi.qm +0 -0
- setiastro/saspro/translations/saspro_hi.ts +14733 -135
- setiastro/saspro/translations/saspro_it.qm +0 -0
- setiastro/saspro/translations/saspro_it.ts +14347 -7821
- setiastro/saspro/translations/saspro_ja.qm +0 -0
- setiastro/saspro/translations/saspro_ja.ts +14860 -137
- setiastro/saspro/translations/saspro_pt.qm +0 -0
- setiastro/saspro/translations/saspro_pt.ts +14904 -137
- setiastro/saspro/translations/saspro_ru.qm +0 -0
- setiastro/saspro/translations/saspro_ru.ts +11766 -168
- setiastro/saspro/translations/saspro_sw.qm +0 -0
- setiastro/saspro/translations/saspro_sw.ts +15115 -135
- setiastro/saspro/translations/saspro_uk.qm +0 -0
- setiastro/saspro/translations/saspro_uk.ts +11206 -6729
- setiastro/saspro/translations/saspro_zh.qm +0 -0
- setiastro/saspro/translations/saspro_zh.ts +10581 -7812
- setiastro/saspro/translations/sw_translations.py +282 -56
- setiastro/saspro/translations/uk_translations.py +264 -35
- setiastro/saspro/translations/zh_translations.py +282 -47
- setiastro/saspro/view_bundle.py +17 -17
- 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/minigame/game.js +11 -6
- setiastro/saspro/widgets/resource_monitor.py +133 -57
- setiastro/saspro/widgets/spinboxes.py +28 -13
- setiastro/saspro/wimi.py +92 -721
- setiastro/saspro/wims.py +46 -36
- setiastro/saspro/window_shelf.py +2 -2
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/METADATA +8 -7
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +162 -128
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.2.dist-info → setiastrosuitepro-1.6.12.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)
|
|
@@ -1115,13 +1118,14 @@ class ConvoDeconvoDialog(QDialog):
|
|
|
1115
1118
|
if img is None:
|
|
1116
1119
|
QMessageBox.warning(self, "No Image", "Please select an image before estimating PSF.")
|
|
1117
1120
|
return
|
|
1121
|
+
|
|
1118
1122
|
img_gray = img.mean(axis=2).astype(np.float32) if (img.ndim == 3) else img.astype(np.float32)
|
|
1119
1123
|
|
|
1120
|
-
sigma
|
|
1121
|
-
minarea
|
|
1122
|
-
sat
|
|
1123
|
-
maxstars= self.sep_maxstars_spin.value
|
|
1124
|
-
half_w
|
|
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()) # ✅
|
|
1125
1129
|
|
|
1126
1130
|
try:
|
|
1127
1131
|
psf_kernel = estimate_psf_from_image(
|
|
@@ -1133,11 +1137,13 @@ class ConvoDeconvoDialog(QDialog):
|
|
|
1133
1137
|
stamp_half_width=half_w
|
|
1134
1138
|
)
|
|
1135
1139
|
except RuntimeError as e:
|
|
1136
|
-
QMessageBox.critical(self, "PSF Error", str(e))
|
|
1140
|
+
QMessageBox.critical(self, "PSF Error", str(e))
|
|
1141
|
+
return
|
|
1137
1142
|
|
|
1138
1143
|
self._last_stellar_psf = psf_kernel
|
|
1139
1144
|
self._show_stellar_psf_preview(psf_kernel)
|
|
1140
1145
|
|
|
1146
|
+
|
|
1141
1147
|
def _show_stellar_psf_preview(self, psf_kernel: np.ndarray):
|
|
1142
1148
|
h, w = psf_kernel.shape
|
|
1143
1149
|
img8 = ((psf_kernel / max(psf_kernel.max(), 1e-12)) * 255.0).astype(np.uint8)
|
|
@@ -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
|
|
@@ -302,7 +371,17 @@ class CropDialogPro(QDialog):
|
|
|
302
371
|
row.addStretch(1)
|
|
303
372
|
row.addWidget(QLabel(self.tr("Aspect Ratio:")))
|
|
304
373
|
self.cmb_ar = QComboBox()
|
|
305
|
-
self.cmb_ar.addItems([
|
|
374
|
+
self.cmb_ar.addItems([
|
|
375
|
+
self.tr("Free"), self.tr("Original"),
|
|
376
|
+
"1:1",
|
|
377
|
+
"3:2", "2:3",
|
|
378
|
+
"4:3", "3:4",
|
|
379
|
+
"4:5", "5:4",
|
|
380
|
+
"16:9", "9:16",
|
|
381
|
+
"21:9", "9:21",
|
|
382
|
+
"2:1", "1:2",
|
|
383
|
+
"3:5", "5:3",
|
|
384
|
+
])
|
|
306
385
|
row.addWidget(self.cmb_ar)
|
|
307
386
|
row.addStretch(1)
|
|
308
387
|
main.addLayout(row)
|
|
@@ -405,7 +484,7 @@ class CropDialogPro(QDialog):
|
|
|
405
484
|
self.btn_prev.clicked.connect(self._load_previous)
|
|
406
485
|
self.btn_apply.clicked.connect(self._apply_one)
|
|
407
486
|
self.btn_batch.clicked.connect(self._apply_batch)
|
|
408
|
-
self.btn_close.clicked.connect(self.
|
|
487
|
+
self.btn_close.clicked.connect(self.close)
|
|
409
488
|
|
|
410
489
|
# seed image
|
|
411
490
|
self._load_from_doc()
|
|
@@ -597,6 +676,7 @@ class CropDialogPro(QDialog):
|
|
|
597
676
|
if e.type() == QEvent.Type.MouseMove and self._drawing:
|
|
598
677
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
599
678
|
r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
|
|
679
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
600
680
|
self._draw_live_rect(r)
|
|
601
681
|
|
|
602
682
|
# ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
|
|
@@ -608,9 +688,12 @@ class CropDialogPro(QDialog):
|
|
|
608
688
|
self._drawing = False
|
|
609
689
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
610
690
|
r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
|
|
691
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
611
692
|
self._clear_live_rect()
|
|
693
|
+
|
|
612
694
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
613
695
|
self._rect_item.setZValue(10)
|
|
696
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
614
697
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
615
698
|
self.scene.addItem(self._rect_item)
|
|
616
699
|
|
|
@@ -696,6 +779,7 @@ class CropDialogPro(QDialog):
|
|
|
696
779
|
if self._rect_item is None:
|
|
697
780
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
698
781
|
self._rect_item.setZValue(10)
|
|
782
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
699
783
|
self.scene.addItem(self._rect_item)
|
|
700
784
|
else:
|
|
701
785
|
self._rect_item.setRotation(0.0)
|
|
@@ -739,6 +823,32 @@ class CropDialogPro(QDialog):
|
|
|
739
823
|
if hasattr(self, "_live_rect") and self._live_rect:
|
|
740
824
|
self.scene.removeItem(self._live_rect); self._live_rect = None
|
|
741
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
|
+
|
|
742
852
|
# ---------- preview toggles ----------
|
|
743
853
|
def _toggle_autostretch(self):
|
|
744
854
|
self._autostretch_on = not self._autostretch_on
|
|
@@ -758,6 +868,7 @@ class CropDialogPro(QDialog):
|
|
|
758
868
|
r, ang, pos = state
|
|
759
869
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
760
870
|
self._rect_item.setZValue(10)
|
|
871
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
761
872
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
762
873
|
self._rect_item.setRotation(ang)
|
|
763
874
|
self._rect_item.setPos(pos)
|
|
@@ -775,6 +886,7 @@ class CropDialogPro(QDialog):
|
|
|
775
886
|
r = QRectF(CropDialogPro._prev_rect)
|
|
776
887
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
777
888
|
self._rect_item.setZValue(10)
|
|
889
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
778
890
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
779
891
|
self._rect_item.setRotation(CropDialogPro._prev_angle)
|
|
780
892
|
self._rect_item.setPos(CropDialogPro._prev_pos)
|
|
@@ -793,6 +905,7 @@ class CropDialogPro(QDialog):
|
|
|
793
905
|
sx, sy = w_img / pm.width(), h_img / pm.height()
|
|
794
906
|
return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
|
|
795
907
|
|
|
908
|
+
|
|
796
909
|
def _apply_one(self):
|
|
797
910
|
if not self._rect_item:
|
|
798
911
|
QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
|
|
@@ -855,7 +968,7 @@ class CropDialogPro(QDialog):
|
|
|
855
968
|
self.doc.apply_edit(out.copy(), metadata={**new_meta, "step_name": "Crop"}, step_name="Crop")
|
|
856
969
|
self._maybe_notify_wcs_update(new_meta)
|
|
857
970
|
self.crop_applied.emit(out)
|
|
858
|
-
self.
|
|
971
|
+
self.close()
|
|
859
972
|
except Exception as e:
|
|
860
973
|
QMessageBox.critical(self, self.tr("Apply failed"), str(e))
|
|
861
974
|
|
|
@@ -941,7 +1054,7 @@ class CropDialogPro(QDialog):
|
|
|
941
1054
|
QMessageBox.information(self, self.tr("Batch Crop"), self.tr("Applied crop to all open images. Any Astrometric Solutions has been updated."))
|
|
942
1055
|
if last_cropped is not None:
|
|
943
1056
|
self.crop_applied.emit(last_cropped)
|
|
944
|
-
self.
|
|
1057
|
+
self.close()
|
|
945
1058
|
|
|
946
1059
|
def _maybe_notify_wcs_update(self, meta: dict, batch_note: str | None = None):
|
|
947
1060
|
dbg = (meta or {}).get("__wcs_debug__")
|
|
@@ -971,3 +1084,16 @@ class CropDialogPro(QDialog):
|
|
|
971
1084
|
except Exception:
|
|
972
1085
|
# Be quiet if formatting fails
|
|
973
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)
|