setiastrosuitepro 1.6.4__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/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 +108 -40
- 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 +13 -7
- 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 +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 +429 -228
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/menu_mixin.py +27 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +384 -18
- 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 +51 -12
- 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/resources.py +67 -0
- setiastro/saspro/rgbalign.py +4 -0
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/sfcc.py +364 -152
- setiastro/saspro/shortcuts.py +160 -29
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1331 -484
- 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 +743 -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 +84 -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.12.dist-info}/METADATA +2 -1
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/RECORD +115 -82
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.6.12.dist-info}/licenses/license.txt +0 -0
|
@@ -150,45 +150,75 @@ class MetricsPanel(QWidget):
|
|
|
150
150
|
orig_back = entry.get('orig_background', np.nan)
|
|
151
151
|
return idx, fwhm, ecc, orig_back, star_cnt
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
def compute_all_metrics(self, loaded_images) -> bool:
|
|
154
|
+
"""Run SEP over the full list in parallel using threads and cache results.
|
|
155
|
+
Returns True if metrics were computed, False if user declined/canceled.
|
|
156
|
+
"""
|
|
156
157
|
n = len(loaded_images)
|
|
157
158
|
if n == 0:
|
|
158
|
-
# Clear any previous state and bail
|
|
159
159
|
self._orig_images = []
|
|
160
|
-
self.metrics_data = [np.array([])]*4
|
|
160
|
+
self.metrics_data = [np.array([])] * 4
|
|
161
161
|
self.flags = []
|
|
162
|
-
self._threshold_initialized = [False]*4
|
|
163
|
-
return
|
|
162
|
+
self._threshold_initialized = [False] * 4
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def _has_metrics(md):
|
|
166
|
+
try:
|
|
167
|
+
return md is not None and len(md) == 4 and md[0] is not None and len(md[0]) > 0
|
|
168
|
+
except Exception:
|
|
169
|
+
return False
|
|
164
170
|
|
|
165
|
-
# Heads-up dialog (as you already had)
|
|
166
171
|
settings = QSettings()
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
show_warning = settings.value("metrics/showWarning", True, type=bool)
|
|
173
|
+
|
|
174
|
+
if (not show_warning) and (not _has_metrics(getattr(self, "metrics_data", None))):
|
|
175
|
+
settings.setValue("metrics/showWarning", True)
|
|
176
|
+
show_warning = True
|
|
177
|
+
|
|
178
|
+
# ----------------------------
|
|
179
|
+
# 1) Optional warning gate
|
|
180
|
+
# ----------------------------
|
|
181
|
+
if show_warning:
|
|
169
182
|
msg = QMessageBox(self)
|
|
170
183
|
msg.setWindowTitle(self.tr("Heads-up"))
|
|
171
184
|
msg.setText(self.tr(
|
|
172
185
|
"This is going to use ALL your CPU cores and the UI may lock up until it finishes.\n\n"
|
|
173
186
|
"Continue?"
|
|
174
187
|
))
|
|
175
|
-
msg.setStandardButtons(
|
|
176
|
-
|
|
188
|
+
msg.setStandardButtons(
|
|
189
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
190
|
+
)
|
|
177
191
|
cb = QCheckBox(self.tr("Don't show again"), msg)
|
|
178
192
|
msg.setCheckBox(cb)
|
|
179
|
-
|
|
180
|
-
|
|
193
|
+
|
|
194
|
+
clicked = msg.exec()
|
|
195
|
+
clicked_yes = (clicked == QMessageBox.StandardButton.Yes)
|
|
196
|
+
|
|
197
|
+
if not clicked_yes:
|
|
198
|
+
# If they said NO, never allow "Don't show again" to lock them out.
|
|
199
|
+
# Keep the warning enabled so they can opt-in later.
|
|
200
|
+
if cb.isChecked():
|
|
201
|
+
settings.setValue("metrics/showWarning", True)
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# They said YES: now it's safe to honor "Don't show again"
|
|
181
205
|
if cb.isChecked():
|
|
182
206
|
settings.setValue("metrics/showWarning", False)
|
|
183
207
|
|
|
184
|
-
#
|
|
208
|
+
# If show_warning is False, we compute with no prompt.
|
|
209
|
+
|
|
210
|
+
# ----------------------------
|
|
211
|
+
# 2) Allocate result arrays
|
|
212
|
+
# ----------------------------
|
|
185
213
|
m0 = np.full(n, np.nan, dtype=np.float32) # FWHM
|
|
186
214
|
m1 = np.full(n, np.nan, dtype=np.float32) # Eccentricity
|
|
187
215
|
m2 = np.full(n, np.nan, dtype=np.float32) # Background (cached)
|
|
188
216
|
m3 = np.full(n, np.nan, dtype=np.float32) # Star count
|
|
189
217
|
flags = [e.get('flagged', False) for e in loaded_images]
|
|
190
218
|
|
|
191
|
-
#
|
|
219
|
+
# ----------------------------
|
|
220
|
+
# 3) Progress dialog
|
|
221
|
+
# ----------------------------
|
|
192
222
|
prog = QProgressDialog(self.tr("Computing frame metrics…"), self.tr("Cancel"), 0, n, self)
|
|
193
223
|
prog.setWindowModality(Qt.WindowModality.WindowModal)
|
|
194
224
|
prog.setMinimumDuration(0)
|
|
@@ -198,32 +228,43 @@ class MetricsPanel(QWidget):
|
|
|
198
228
|
|
|
199
229
|
workers = min(os.cpu_count() or 1, 60)
|
|
200
230
|
tasks = [(i, loaded_images[i]) for i in range(n)]
|
|
201
|
-
done = 0
|
|
231
|
+
done = 0
|
|
232
|
+
canceled = False
|
|
202
233
|
|
|
203
234
|
try:
|
|
204
235
|
with ThreadPoolExecutor(max_workers=workers) as exe:
|
|
205
236
|
futures = {exe.submit(self._compute_one, t): t[0] for t in tasks}
|
|
206
237
|
for fut in as_completed(futures):
|
|
207
238
|
if prog.wasCanceled():
|
|
239
|
+
canceled = True
|
|
208
240
|
break
|
|
209
241
|
try:
|
|
210
242
|
idx, fwhm, ecc, orig_back, star_cnt = fut.result()
|
|
211
243
|
except Exception:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
244
|
+
idx = futures.get(fut, 0)
|
|
245
|
+
fwhm, ecc, orig_back, star_cnt = np.nan, np.nan, np.nan, 0
|
|
246
|
+
|
|
247
|
+
if 0 <= idx < n:
|
|
248
|
+
m0[idx], m1[idx], m2[idx], m3[idx] = fwhm, ecc, orig_back, float(star_cnt)
|
|
249
|
+
|
|
215
250
|
done += 1
|
|
216
251
|
prog.setValue(done)
|
|
217
252
|
QApplication.processEvents()
|
|
218
253
|
finally:
|
|
219
254
|
prog.close()
|
|
220
255
|
|
|
221
|
-
|
|
256
|
+
if canceled:
|
|
257
|
+
# IMPORTANT: leave caches alone; caller will clear/return
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
# ----------------------------
|
|
261
|
+
# 4) Stash results
|
|
262
|
+
# ----------------------------
|
|
222
263
|
self._orig_images = loaded_images
|
|
223
264
|
self.metrics_data = [m0, m1, m2, m3]
|
|
224
265
|
self.flags = flags
|
|
225
|
-
self._threshold_initialized = [False]*4
|
|
226
|
-
|
|
266
|
+
self._threshold_initialized = [False] * 4
|
|
267
|
+
return True
|
|
227
268
|
|
|
228
269
|
def plot(self, loaded_images, indices=None):
|
|
229
270
|
"""
|
|
@@ -242,7 +283,16 @@ class MetricsPanel(QWidget):
|
|
|
242
283
|
|
|
243
284
|
# compute & cache on first call or new image list
|
|
244
285
|
if self._orig_images is not loaded_images or self.metrics_data is None:
|
|
245
|
-
self.compute_all_metrics(loaded_images)
|
|
286
|
+
ok = self.compute_all_metrics(loaded_images)
|
|
287
|
+
if not ok or self.metrics_data is None:
|
|
288
|
+
# user declined/canceled -> clear plots and exit cleanly
|
|
289
|
+
for pw, scat, line in zip(self.plots, self.scats, self.lines):
|
|
290
|
+
scat.setData(x=[], y=[])
|
|
291
|
+
line.setPos(0)
|
|
292
|
+
pw.getPlotItem().getViewBox().update()
|
|
293
|
+
pw.repaint()
|
|
294
|
+
return
|
|
295
|
+
|
|
246
296
|
|
|
247
297
|
# default to all indices
|
|
248
298
|
if indices is None:
|
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
|
|
@@ -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)
|