setiastrosuitepro 1.6.7__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/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/add_stars.py +29 -5
- setiastro/saspro/blink_comparator_pro.py +74 -24
- setiastro/saspro/cosmicclarity.py +125 -18
- setiastro/saspro/crop_dialog_pro.py +96 -2
- setiastro/saspro/curve_editor_pro.py +60 -39
- setiastro/saspro/frequency_separation.py +1159 -208
- setiastro/saspro/gui/main_window.py +131 -31
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/update_mixin.py +121 -33
- setiastro/saspro/imageops/stretch.py +531 -62
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/numba_utils.py +43 -0
- setiastro/saspro/live_stacking.py +158 -70
- setiastro/saspro/multiscale_decomp.py +47 -12
- setiastro/saspro/numba_utils.py +72 -2
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/shortcuts.py +122 -12
- setiastro/saspro/signature_insert.py +688 -33
- setiastro/saspro/stacking_suite.py +523 -316
- setiastro/saspro/stat_stretch.py +688 -130
- setiastro/saspro/subwindow.py +302 -71
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +7 -7
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/RECORD +37 -31
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.7.dist-info → setiastrosuitepro-1.6.10.dist-info}/licenses/license.txt +0 -0
|
@@ -58,7 +58,8 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
|
|
|
58
58
|
self._rotating = False
|
|
59
59
|
self._angle0 = 0.0
|
|
60
60
|
self._pivot_scene = QPointF()
|
|
61
|
-
|
|
61
|
+
self._bounds_scene: QRectF | None = None
|
|
62
|
+
self._clamp_eps_deg = 0.25 # treat as "unrotated" if |angle| < eps (deg)
|
|
62
63
|
self._grab_pad = 20 # ← extra hit slop in screen px
|
|
63
64
|
self._edge_pad_px = EDGE_GRAB_PX
|
|
64
65
|
self.setZValue(100) # ← keep above pixmap
|
|
@@ -83,7 +84,26 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
|
|
|
83
84
|
dx = p1.x() - p0.x()
|
|
84
85
|
dy = p1.y() - p0.y()
|
|
85
86
|
return math.hypot(dx, dy)
|
|
86
|
-
|
|
87
|
+
def setBoundsSceneRect(self, r: QRectF | None):
|
|
88
|
+
"""Set the scene-rect bounds we should stay within when unrotated."""
|
|
89
|
+
self._bounds_scene = QRectF(r) if r is not None else None
|
|
90
|
+
|
|
91
|
+
def _is_unrotated(self) -> bool:
|
|
92
|
+
# normalize angle to [-180, 180]
|
|
93
|
+
a = float(self.rotation()) % 360.0
|
|
94
|
+
if a > 180.0:
|
|
95
|
+
a -= 360.0
|
|
96
|
+
return abs(a) < self._clamp_eps_deg
|
|
97
|
+
|
|
98
|
+
def _bounds_local(self) -> QRectF | None:
|
|
99
|
+
"""Bounds rect mapped into the item's local coordinates (only valid when unrotated)."""
|
|
100
|
+
if self._bounds_scene is None:
|
|
101
|
+
return None
|
|
102
|
+
# When unrotated, this is safe and stable.
|
|
103
|
+
tl = self.mapFromScene(self._bounds_scene.topLeft())
|
|
104
|
+
br = self.mapFromScene(self._bounds_scene.bottomRight())
|
|
105
|
+
return QRectF(tl, br).normalized()
|
|
106
|
+
|
|
87
107
|
def _edge_under_cursor(self, scene_pos: QPointF) -> Optional[str]:
|
|
88
108
|
"""
|
|
89
109
|
Return 'l', 'r', 't', or 'b' if the pointer is near an edge (within px-tolerance),
|
|
@@ -218,12 +238,52 @@ class ResizableRotatableRectItem(QGraphicsRectItem):
|
|
|
218
238
|
QGraphicsItem.GraphicsItemChange.ItemTransformHasChanged,
|
|
219
239
|
):
|
|
220
240
|
self._sync_handles()
|
|
241
|
+
|
|
242
|
+
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
|
|
243
|
+
if self._bounds_scene is not None and self._is_unrotated():
|
|
244
|
+
new_pos = QPointF(value)
|
|
245
|
+
|
|
246
|
+
# current scene rect of the item (at current pos)
|
|
247
|
+
sr0 = self.mapRectToScene(self.rect()) # QRectF in scene coords
|
|
248
|
+
|
|
249
|
+
# shift it by the delta between proposed pos and current pos
|
|
250
|
+
d = new_pos - self.pos()
|
|
251
|
+
sr = sr0.translated(d)
|
|
252
|
+
|
|
253
|
+
b = self._bounds_scene
|
|
254
|
+
dx = 0.0
|
|
255
|
+
dy = 0.0
|
|
256
|
+
|
|
257
|
+
if sr.left() < b.left():
|
|
258
|
+
dx = b.left() - sr.left()
|
|
259
|
+
elif sr.right() > b.right():
|
|
260
|
+
dx = b.right() - sr.right()
|
|
261
|
+
|
|
262
|
+
if sr.top() < b.top():
|
|
263
|
+
dy = b.top() - sr.top()
|
|
264
|
+
elif sr.bottom() > b.bottom():
|
|
265
|
+
dy = b.bottom() - sr.bottom()
|
|
266
|
+
|
|
267
|
+
if dx != 0.0 or dy != 0.0:
|
|
268
|
+
return new_pos + QPointF(dx, dy)
|
|
269
|
+
|
|
270
|
+
return new_pos
|
|
271
|
+
|
|
221
272
|
return super().itemChange(change, value)
|
|
222
273
|
|
|
223
274
|
def _resize_via_handle(self, scene_pt: QPointF):
|
|
224
275
|
r = self.rect()
|
|
225
276
|
p = self.mapFromScene(scene_pt)
|
|
226
277
|
|
|
278
|
+
# Clamp handle drag to bounds only when unrotated.
|
|
279
|
+
if self._bounds_scene is not None and self._is_unrotated():
|
|
280
|
+
bL = self._bounds_local()
|
|
281
|
+
if bL is not None:
|
|
282
|
+
# NOTE: bL is in the same local coordinate space as r/p.
|
|
283
|
+
px = min(max(p.x(), bL.left()), bL.right())
|
|
284
|
+
py = min(max(p.y(), bL.top()), bL.bottom())
|
|
285
|
+
p = QPointF(px, py)
|
|
286
|
+
|
|
227
287
|
# Corners
|
|
228
288
|
if self._active == "tl": r.setTopLeft(p)
|
|
229
289
|
elif self._active == "tr": r.setTopRight(p)
|
|
@@ -616,6 +676,7 @@ class CropDialogPro(QDialog):
|
|
|
616
676
|
if e.type() == QEvent.Type.MouseMove and self._drawing:
|
|
617
677
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
618
678
|
r = self._apply_ar_to_rect(r, live=True, scene_pt=scene_pt)
|
|
679
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
619
680
|
self._draw_live_rect(r)
|
|
620
681
|
|
|
621
682
|
# ⬇️ live dims from the temporary rect (axis-aligned TL,TR,BR,BL)
|
|
@@ -627,9 +688,12 @@ class CropDialogPro(QDialog):
|
|
|
627
688
|
self._drawing = False
|
|
628
689
|
r = QRectF(self._origin, scene_pt).normalized()
|
|
629
690
|
r = self._apply_ar_to_rect(r, live=False, scene_pt=scene_pt)
|
|
691
|
+
r = self._clamp_rect_to_pixmap(r)
|
|
630
692
|
self._clear_live_rect()
|
|
693
|
+
|
|
631
694
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
632
695
|
self._rect_item.setZValue(10)
|
|
696
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
633
697
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
634
698
|
self.scene.addItem(self._rect_item)
|
|
635
699
|
|
|
@@ -715,6 +779,7 @@ class CropDialogPro(QDialog):
|
|
|
715
779
|
if self._rect_item is None:
|
|
716
780
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
717
781
|
self._rect_item.setZValue(10)
|
|
782
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
718
783
|
self.scene.addItem(self._rect_item)
|
|
719
784
|
else:
|
|
720
785
|
self._rect_item.setRotation(0.0)
|
|
@@ -758,6 +823,32 @@ class CropDialogPro(QDialog):
|
|
|
758
823
|
if hasattr(self, "_live_rect") and self._live_rect:
|
|
759
824
|
self.scene.removeItem(self._live_rect); self._live_rect = None
|
|
760
825
|
|
|
826
|
+
def _pixmap_scene_rect(self) -> QRectF | None:
|
|
827
|
+
"""Scene rect occupied by the pixmap (image) item."""
|
|
828
|
+
if not self._pix_item:
|
|
829
|
+
return None
|
|
830
|
+
return self._pix_item.mapRectToScene(self._pix_item.boundingRect())
|
|
831
|
+
|
|
832
|
+
def _clamp_rect_to_pixmap(self, r: QRectF) -> QRectF:
|
|
833
|
+
"""Intersect an axis-aligned QRectF with the pixmap scene rect."""
|
|
834
|
+
bounds = self._pixmap_scene_rect()
|
|
835
|
+
if bounds is None:
|
|
836
|
+
return r.normalized()
|
|
837
|
+
rr = r.normalized().intersected(bounds)
|
|
838
|
+
# avoid empty rects (keep at least 1x1 scene unit)
|
|
839
|
+
if rr.isNull() or rr.width() <= 1e-6 or rr.height() <= 1e-6:
|
|
840
|
+
# fallback: clamp to a 1x1 rect at the nearest point inside bounds
|
|
841
|
+
x = min(max(r.center().x(), bounds.left()), bounds.right())
|
|
842
|
+
y = min(max(r.center().y(), bounds.top()), bounds.bottom())
|
|
843
|
+
rr = QRectF(x, y, 1.0, 1.0)
|
|
844
|
+
return rr.normalized()
|
|
845
|
+
|
|
846
|
+
def _bounds_scene_rect(self) -> QRectF | None:
|
|
847
|
+
if not self._pix_item:
|
|
848
|
+
return None
|
|
849
|
+
return self._pix_item.mapRectToScene(self._pix_item.boundingRect())
|
|
850
|
+
|
|
851
|
+
|
|
761
852
|
# ---------- preview toggles ----------
|
|
762
853
|
def _toggle_autostretch(self):
|
|
763
854
|
self._autostretch_on = not self._autostretch_on
|
|
@@ -777,6 +868,7 @@ class CropDialogPro(QDialog):
|
|
|
777
868
|
r, ang, pos = state
|
|
778
869
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
779
870
|
self._rect_item.setZValue(10)
|
|
871
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
780
872
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
781
873
|
self._rect_item.setRotation(ang)
|
|
782
874
|
self._rect_item.setPos(pos)
|
|
@@ -794,6 +886,7 @@ class CropDialogPro(QDialog):
|
|
|
794
886
|
r = QRectF(CropDialogPro._prev_rect)
|
|
795
887
|
self._rect_item = ResizableRotatableRectItem(r)
|
|
796
888
|
self._rect_item.setZValue(10)
|
|
889
|
+
self._rect_item.setBoundsSceneRect(self._bounds_scene_rect())
|
|
797
890
|
self._rect_item.setFixedAspectRatio(self._current_ar_value())
|
|
798
891
|
self._rect_item.setRotation(CropDialogPro._prev_angle)
|
|
799
892
|
self._rect_item.setPos(CropDialogPro._prev_pos)
|
|
@@ -812,6 +905,7 @@ class CropDialogPro(QDialog):
|
|
|
812
905
|
sx, sy = w_img / pm.width(), h_img / pm.height()
|
|
813
906
|
return np.array([pt_scene.x() * sx, pt_scene.y() * sy], dtype=np.float32)
|
|
814
907
|
|
|
908
|
+
|
|
815
909
|
def _apply_one(self):
|
|
816
910
|
if not self._rect_item:
|
|
817
911
|
QMessageBox.warning(self, self.tr("No Selection"), self.tr("Draw & finalize a crop first."))
|
|
@@ -521,32 +521,6 @@ class CurveEditor(QGraphicsView):
|
|
|
521
521
|
if ln is not None:
|
|
522
522
|
ln.setVisible(False)
|
|
523
523
|
|
|
524
|
-
def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
|
|
525
|
-
"""(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
|
|
526
|
-
out = []
|
|
527
|
-
lastx = -1e9
|
|
528
|
-
for (x, y) in sorted(pts_scene, key=lambda t: t[0]):
|
|
529
|
-
x = float(np.clip(x, 0.0, 360.0))
|
|
530
|
-
y = float(np.clip(y, 0.0, 360.0))
|
|
531
|
-
# strictly increasing X
|
|
532
|
-
if x <= lastx:
|
|
533
|
-
x = lastx + 1e-3
|
|
534
|
-
lastx = x
|
|
535
|
-
out.append((x / 360.0, 1.0 - (y / 360.0)))
|
|
536
|
-
# ensure endpoints
|
|
537
|
-
if not any(abs(px - 0.0) < 1e-6 for px, _ in out): out.insert(0, (0.0, 0.0))
|
|
538
|
-
if not any(abs(px - 1.0) < 1e-6 for px, _ in out): out.append((1.0, 1.0))
|
|
539
|
-
# clamp
|
|
540
|
-
return [(float(np.clip(x,0,1)), float(np.clip(y,0,1))) for (x,y) in out]
|
|
541
|
-
|
|
542
|
-
def _collect_points_norm_from_editor(self) -> list[tuple[float,float]]:
|
|
543
|
-
"""Take endpoints+handles from editor => normalized points."""
|
|
544
|
-
pts_scene = []
|
|
545
|
-
for p in (self.editor.end_points + self.editor.control_points):
|
|
546
|
-
pos = p.scenePos()
|
|
547
|
-
pts_scene.append((float(pos.x()), float(pos.y())))
|
|
548
|
-
return self._scene_to_norm_points(pts_scene)
|
|
549
|
-
|
|
550
524
|
|
|
551
525
|
def redistributeHandlesByPivot(self, u: float):
|
|
552
526
|
"""
|
|
@@ -1048,7 +1022,14 @@ class CurvesDialogPro(QDialog):
|
|
|
1048
1022
|
self._cdf = None
|
|
1049
1023
|
self._cdf_bins = 1024
|
|
1050
1024
|
self._cdf_total = 0
|
|
1051
|
-
|
|
1025
|
+
# Debounce: coalesce rapid curve edits into one rebuild
|
|
1026
|
+
self._curve_debounce_ms = 120 # tweak: 80–200ms feels good
|
|
1027
|
+
self._curve_debounce = QTimer(self)
|
|
1028
|
+
self._curve_debounce.setSingleShot(True)
|
|
1029
|
+
self._curve_debounce.timeout.connect(self._rebuild_preview_from_curve_debounced)
|
|
1030
|
+
|
|
1031
|
+
# Optional: generation counter so stale results can't “win”
|
|
1032
|
+
self._curve_gen = 0
|
|
1052
1033
|
self._clip_scale = 1.0 # preview→full multiplier
|
|
1053
1034
|
self._cdf_total_full = 0 # total pixels in full image (H*W)
|
|
1054
1035
|
self._cdf_total_preview = 0 # total pixels in preview (H*W)
|
|
@@ -1220,18 +1201,34 @@ class CurvesDialogPro(QDialog):
|
|
|
1220
1201
|
|
|
1221
1202
|
def _on_editor_curve_changed(self, _lut8=None):
|
|
1222
1203
|
"""
|
|
1223
|
-
Called on every editor redraw/drag. Persist
|
|
1224
|
-
|
|
1204
|
+
Called on every editor redraw/drag. Persist points and refresh overlays.
|
|
1205
|
+
Preview rebuild is DEBOUNCED to avoid spamming.
|
|
1225
1206
|
"""
|
|
1226
1207
|
try:
|
|
1227
1208
|
self._curves_store[self._current_mode_key] = self._editor_points_norm()
|
|
1228
1209
|
except Exception:
|
|
1229
1210
|
pass
|
|
1230
|
-
|
|
1211
|
+
|
|
1212
|
+
# cheap: overlay redraw is fine every move (or you can debounce this too)
|
|
1231
1213
|
self._refresh_overlays()
|
|
1232
|
-
# now build from *all* current curves (including the just-edited one)
|
|
1233
|
-
self._quick_preview()
|
|
1234
1214
|
|
|
1215
|
+
# expensive: debounce the preview rebuild
|
|
1216
|
+
self._curve_gen += 1
|
|
1217
|
+
self._curve_debounce.start(self._curve_debounce_ms)
|
|
1218
|
+
|
|
1219
|
+
def _rebuild_preview_from_curve_debounced(self):
|
|
1220
|
+
"""
|
|
1221
|
+
Runs after the user pauses dragging for _curve_debounce_ms.
|
|
1222
|
+
Only rebuild if we have images loaded.
|
|
1223
|
+
"""
|
|
1224
|
+
if self._preview_orig is None and self._preview_img is None:
|
|
1225
|
+
return
|
|
1226
|
+
# If your preview toggle is off, you may want to skip:
|
|
1227
|
+
if not getattr(self, "btn_preview", None) or not self.btn_preview.isChecked():
|
|
1228
|
+
return
|
|
1229
|
+
|
|
1230
|
+
# Do the real work (what you were doing before)
|
|
1231
|
+
self._quick_preview()
|
|
1235
1232
|
|
|
1236
1233
|
def _active_mode_key(self) -> str:
|
|
1237
1234
|
for b in self.mode_group.buttons():
|
|
@@ -1678,29 +1675,53 @@ class CurvesDialogPro(QDialog):
|
|
|
1678
1675
|
|
|
1679
1676
|
# 1) Put this helper inside CurvesDialogPro (near other helpers)
|
|
1680
1677
|
def _map_label_xy_to_image_ij(self, x: float, y: float):
|
|
1681
|
-
"""
|
|
1678
|
+
"""
|
|
1679
|
+
Map label-local coords (x,y) to _preview_img pixel (ix, iy).
|
|
1680
|
+
Correct even when the pixmap is centered inside a larger label.
|
|
1681
|
+
Returns None if cursor is outside the displayed pixmap area.
|
|
1682
|
+
"""
|
|
1682
1683
|
if self._pix is None:
|
|
1683
1684
|
return None
|
|
1685
|
+
|
|
1684
1686
|
pm_disp = self.label.pixmap()
|
|
1685
1687
|
if pm_disp is None or pm_disp.isNull():
|
|
1686
1688
|
return None
|
|
1687
1689
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
disp_w = pm_disp.width() # size of the *displayed* pixmap on the label
|
|
1690
|
+
# Displayed pixmap size (after zoom)
|
|
1691
|
+
disp_w = pm_disp.width()
|
|
1691
1692
|
disp_h = pm_disp.height()
|
|
1692
|
-
|
|
1693
|
+
|
|
1694
|
+
# Label may be bigger -> pixmap is centered with margins
|
|
1695
|
+
lbl_w = self.label.width()
|
|
1696
|
+
lbl_h = self.label.height()
|
|
1697
|
+
|
|
1698
|
+
off_x = max(0, (lbl_w - disp_w) // 2)
|
|
1699
|
+
off_y = max(0, (lbl_h - disp_h) // 2)
|
|
1700
|
+
|
|
1701
|
+
# Remove margins: label-local -> pixmap-local
|
|
1702
|
+
px = float(x) - float(off_x)
|
|
1703
|
+
py = float(y) - float(off_y)
|
|
1704
|
+
|
|
1705
|
+
if px < 0 or py < 0 or px >= disp_w or py >= disp_h:
|
|
1706
|
+
return None # outside actual image area
|
|
1707
|
+
|
|
1708
|
+
# Now convert displayed pixmap pixel -> source preview pixel
|
|
1709
|
+
src_w = self._pix.width()
|
|
1710
|
+
src_h = self._pix.height()
|
|
1711
|
+
if src_w <= 0 or src_h <= 0:
|
|
1693
1712
|
return None
|
|
1694
1713
|
|
|
1695
1714
|
sx = disp_w / float(src_w)
|
|
1696
1715
|
sy = disp_h / float(src_h)
|
|
1697
1716
|
|
|
1698
|
-
ix = int(
|
|
1699
|
-
iy = int(
|
|
1717
|
+
ix = int(px / sx)
|
|
1718
|
+
iy = int(py / sy)
|
|
1719
|
+
|
|
1700
1720
|
if ix < 0 or iy < 0 or ix >= src_w or iy >= src_h:
|
|
1701
1721
|
return None
|
|
1702
1722
|
return ix, iy
|
|
1703
1723
|
|
|
1724
|
+
|
|
1704
1725
|
def _scene_to_norm_points(self, pts_scene: list[tuple[float,float]]) -> list[tuple[float,float]]:
|
|
1705
1726
|
"""(x:[0..360], y:[0..360] down) → (x,y in [0..1] up). Ensures endpoints present & strictly increasing x."""
|
|
1706
1727
|
out = []
|