setiastrosuitepro 1.7.1.post2__py3-none-any.whl → 1.7.3__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.

Files changed (28) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/saspro/__init__.py +9 -8
  3. setiastro/saspro/__main__.py +326 -285
  4. setiastro/saspro/_generated/build_info.py +2 -2
  5. setiastro/saspro/doc_manager.py +4 -1
  6. setiastro/saspro/gui/main_window.py +41 -2
  7. setiastro/saspro/gui/mixins/file_mixin.py +6 -2
  8. setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
  9. setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -1
  10. setiastro/saspro/imageops/serloader.py +101 -17
  11. setiastro/saspro/layers.py +186 -10
  12. setiastro/saspro/layers_dock.py +198 -5
  13. setiastro/saspro/legacy/image_manager.py +10 -4
  14. setiastro/saspro/planetprojection.py +3854 -0
  15. setiastro/saspro/resources.py +2 -0
  16. setiastro/saspro/save_options.py +45 -13
  17. setiastro/saspro/ser_stack_config.py +21 -1
  18. setiastro/saspro/ser_stacker.py +8 -2
  19. setiastro/saspro/ser_stacker_dialog.py +37 -10
  20. setiastro/saspro/ser_tracking.py +57 -35
  21. setiastro/saspro/serviewer.py +164 -16
  22. setiastro/saspro/subwindow.py +36 -1
  23. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +1 -1
  24. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +28 -26
  25. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  26. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  27. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  28. {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -8,19 +8,141 @@ from PyQt6.QtCore import Qt, pyqtSignal, QByteArray, QTimer
8
8
  from PyQt6.QtWidgets import (
9
9
  QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
10
10
  QListWidget, QListWidgetItem, QAbstractItemView, QSlider, QCheckBox,
11
- QPushButton, QFrame, QMessageBox
11
+ QPushButton, QFrame, QMessageBox,QDialog, QFormLayout, QDoubleSpinBox, QDialogButtonBox
12
12
  )
13
13
  from PyQt6.QtGui import QIcon, QDragEnterEvent, QDropEvent, QPixmap, QCursor
14
14
 
15
+ from setiastro.saspro.layers import LayerTransform
15
16
  from setiastro.saspro.dnd_mime import MIME_VIEWSTATE, MIME_MASK
16
17
  from setiastro.saspro.layers import composite_stack, ImageLayer, BLEND_MODES
17
18
 
19
+ class _TransformDialog(QDialog):
20
+ """
21
+ Live-preview transform dialog.
22
+ - Updates the layer transform as the user edits values (debounced).
23
+ - Cancel restores original transform.
24
+ - OK keeps current previewed transform.
25
+ """
26
+ def __init__(self, parent=None, *, t: LayerTransform | None = None,
27
+ apply_cb=None, cancel_cb=None, debounce_ms: int = 200):
28
+ super().__init__(parent)
29
+ self.setWindowTitle("Layer Transform")
30
+ self.setModal(True)
31
+
32
+ self._apply_cb = apply_cb
33
+ self._cancel_cb = cancel_cb
34
+
35
+ self._t0 = t or LayerTransform()
36
+ # snapshot original so Cancel can revert even if layer mutated live
37
+ self._orig = LayerTransform(
38
+ tx=float(self._t0.tx), ty=float(self._t0.ty),
39
+ rot_deg=float(self._t0.rot_deg),
40
+ sx=float(self._t0.sx), sy=float(self._t0.sy),
41
+ pivot_x=self._t0.pivot_x, pivot_y=self._t0.pivot_y,
42
+ )
43
+
44
+ # debounce timer
45
+ self._tmr = QTimer(self)
46
+ self._tmr.setSingleShot(True)
47
+ self._tmr.timeout.connect(self._apply_live)
48
+ self._debounce_ms = int(debounce_ms)
49
+
50
+ lay = QVBoxLayout(self)
51
+ form = QFormLayout()
52
+ lay.addLayout(form)
53
+
54
+ def mk_spin(minv, maxv, step, dec, val):
55
+ s = QDoubleSpinBox(self)
56
+ s.setRange(minv, maxv)
57
+ s.setSingleStep(step)
58
+ s.setDecimals(dec)
59
+ s.setValue(float(val))
60
+ return s
61
+
62
+ self.tx = mk_spin(-100000, 100000, 1.0, 2, self._t0.tx)
63
+ self.ty = mk_spin(-100000, 100000, 1.0, 2, self._t0.ty)
64
+ self.rot = mk_spin(-3600, 3600, 0.1, 3, self._t0.rot_deg)
65
+ self.sx = mk_spin(0.001, 1000.0, 0.01, 4, self._t0.sx)
66
+ self.sy = mk_spin(0.001, 1000.0, 0.01, 4, self._t0.sy)
67
+
68
+ form.addRow("Translate X (px)", self.tx)
69
+ form.addRow("Translate Y (px)", self.ty)
70
+ form.addRow("Rotate (deg)", self.rot)
71
+ form.addRow("Scale X", self.sx)
72
+ form.addRow("Scale Y", self.sy)
73
+
74
+ # Buttons
75
+ btns = QDialogButtonBox(
76
+ QDialogButtonBox.StandardButton.Ok |
77
+ QDialogButtonBox.StandardButton.Cancel,
78
+ parent=self
79
+ )
80
+ lay.addWidget(btns)
81
+
82
+ self.btn_reset = QPushButton("Reset")
83
+ btns.addButton(self.btn_reset, QDialogButtonBox.ButtonRole.ResetRole)
84
+
85
+ btns.accepted.connect(self.accept)
86
+ btns.rejected.connect(self.reject)
87
+ self.btn_reset.clicked.connect(self._reset)
88
+
89
+ # Any value change triggers debounced live preview
90
+ for w in (self.tx, self.ty, self.rot, self.sx, self.sy):
91
+ w.valueChanged.connect(self._schedule_live)
92
+
93
+ # Apply initial once so opening dialog matches actual preview
94
+ self._schedule_live()
95
+
96
+ def _reset(self):
97
+ self.tx.setValue(0.0)
98
+ self.ty.setValue(0.0)
99
+ self.rot.setValue(0.0)
100
+ self.sx.setValue(1.0)
101
+ self.sy.setValue(1.0)
102
+ self._schedule_live()
103
+
104
+ def _schedule_live(self, *_):
105
+ # restart debounce
106
+ self._tmr.start(self._debounce_ms)
107
+
108
+ def _current_transform(self) -> LayerTransform:
109
+ return LayerTransform(
110
+ tx=float(self.tx.value()),
111
+ ty=float(self.ty.value()),
112
+ rot_deg=float(self.rot.value()),
113
+ sx=float(self.sx.value()),
114
+ sy=float(self.sy.value()),
115
+ pivot_x=None,
116
+ pivot_y=None,
117
+ )
118
+
119
+ def _apply_live(self):
120
+ if callable(self._apply_cb):
121
+ self._apply_cb(self._current_transform())
122
+
123
+ def reject(self):
124
+ # Cancel: restore original, then close
125
+ if callable(self._cancel_cb):
126
+ self._cancel_cb(self._orig)
127
+ super().reject()
128
+
129
+ def accept(self):
130
+ # OK: ensure latest values are applied (in case debounce pending)
131
+ try:
132
+ if self._tmr.isActive():
133
+ self._tmr.stop()
134
+ except Exception:
135
+ pass
136
+ self._apply_live()
137
+ super().accept()
138
+
18
139
  # ---------- Small row widget for a layer ----------
19
140
  class _LayerRow(QWidget):
20
141
  changed = pyqtSignal()
21
142
  requestDelete = pyqtSignal()
22
143
  moveUp = pyqtSignal()
23
144
  moveDown = pyqtSignal()
145
+ requestTransform = pyqtSignal()
24
146
 
25
147
  def __init__(self, name: str, mode: str = "Normal", opacity: float = 1.0,
26
148
  visible: bool = True, parent=None, *, is_base: bool = False):
@@ -41,9 +163,10 @@ class _LayerRow(QWidget):
41
163
  self.btn_up = QPushButton("↑"); self.btn_up.setFixedWidth(28)
42
164
  self.btn_dn = QPushButton("↓"); self.btn_dn.setFixedWidth(28)
43
165
  self.btn_x = QPushButton("✕"); self.btn_x.setFixedWidth(28)
44
-
166
+ self.btn_tf = QPushButton("Transform…")
167
+ self.btn_tf.setFixedWidth(92)
45
168
  r1.addWidget(self.chk); r1.addWidget(self.lbl, 1)
46
- r1.addWidget(self.mode); r1.addWidget(QLabel("Opacity")); r1.addWidget(self.sld, 1)
169
+ r1.addWidget(self.mode); r1.addWidget(QLabel("Opacity")); r1.addWidget(self.sld, 1); r1.addWidget(self.btn_tf)
47
170
  r1.addWidget(self.btn_up); r1.addWidget(self.btn_dn); r1.addWidget(self.btn_x)
48
171
 
49
172
  # row 2: mask controls (hidden for base)
@@ -89,7 +212,7 @@ class _LayerRow(QWidget):
89
212
 
90
213
  if self._is_base:
91
214
  # Base row is informational only
92
- for w in (self.chk, self.mode, self.sld, self.btn_up, self.btn_dn, self.btn_x,
215
+ for w in (self.chk, self.mode, self.sld, self.btn_up, self.btn_tf, self.btn_dn, self.btn_x,
93
216
  self.mask_combo, self.mask_invert, self.btn_clear_mask):
94
217
  w.setEnabled(False)
95
218
  self.lbl.setStyleSheet("color: palette(mid);")
@@ -104,6 +227,8 @@ class _LayerRow(QWidget):
104
227
  self.btn_up.clicked.connect(self.moveUp.emit)
105
228
  self.btn_dn.clicked.connect(self.moveDown.emit)
106
229
 
230
+ self.btn_tf.clicked.connect(self.requestTransform.emit)
231
+
107
232
  # Sigmoid controls emit change + only show for Sigmoid mode
108
233
  if self.sig_center is not None:
109
234
  self.sig_center.valueChanged.connect(self._emit)
@@ -116,6 +241,17 @@ class _LayerRow(QWidget):
116
241
  # Initial visibility
117
242
  self._update_extra_controls(self.mode.currentText())
118
243
 
244
+ def setTransformDirty(self, dirty: bool):
245
+ if hasattr(self, "btn_tf") and self.btn_tf is not None:
246
+ self.btn_tf.setText("Transform… *" if dirty else "Transform…")
247
+
248
+
249
+ def _on_transform_clicked(self):
250
+ # Let the dock handle it; row doesn't own the layer object
251
+ self.changed.emit() # (no-op, but keeps pattern)
252
+ # We'll have the dock connect this via a custom signal (see next step).
253
+
254
+
119
255
  def _on_mode_changed(self, _idx: int):
120
256
  # Update which extra controls are visible
121
257
  self._update_extra_controls(self.mode.currentText())
@@ -420,7 +556,12 @@ class LayersDock(QDockWidget):
420
556
  it.setSizeHint(roww.sizeHint())
421
557
  self.list.addItem(it)
422
558
  self.list.setItemWidget(it, roww)
423
-
559
+ t = getattr(lyr, "transform", None)
560
+ dirty = False
561
+ if t is not None:
562
+ dirty = (abs(t.tx) > 1e-6 or abs(t.ty) > 1e-6 or abs(t.rot_deg) > 1e-6 or
563
+ abs(t.sx - 1.0) > 1e-6 or abs(t.sy - 1.0) > 1e-6)
564
+ roww.setTransformDirty(dirty)
424
565
  base_name = getattr(vw, "_effective_title", None)
425
566
  base_name = base_name() if callable(base_name) else "Current View"
426
567
  base_label = f"Base • {base_name}"
@@ -545,6 +686,58 @@ class LayersDock(QDockWidget):
545
686
  roww.requestDelete.connect(lambda: self._delete_row(roww))
546
687
  roww.moveUp.connect(lambda: self._move_row(roww, -1))
547
688
  roww.moveDown.connect(lambda: self._move_row(roww, +1))
689
+ roww.requestTransform.connect(lambda rw=roww: self._edit_transform_for_row(rw))
690
+
691
+ def _edit_transform_for_row(self, roww: _LayerRow):
692
+ vw = self.current_view()
693
+ if not vw:
694
+ return
695
+ idx = self._find_row_index(roww)
696
+ if idx < 0 or idx >= self._layer_count():
697
+ return
698
+
699
+ lyr = vw._layers[idx]
700
+
701
+ # Ensure transform exists
702
+ t = getattr(lyr, "transform", None)
703
+ if t is None:
704
+ t = LayerTransform()
705
+ lyr.transform = t
706
+
707
+ # Helper: apply transform + refresh preview
708
+ def _apply_t(new_t: LayerTransform):
709
+ lyr.transform = new_t
710
+ # update the row star immediately (optional)
711
+ dirty = (abs(new_t.tx) > 1e-6 or abs(new_t.ty) > 1e-6 or abs(new_t.rot_deg) > 1e-6 or
712
+ abs(new_t.sx - 1.0) > 1e-6 or abs(new_t.sy - 1.0) > 1e-6)
713
+ try:
714
+ roww.setTransformDirty(dirty)
715
+ except Exception:
716
+ pass
717
+
718
+ # Live preview refresh
719
+ vw.apply_layer_stack(vw._layers)
720
+
721
+ # Helper: cancel revert
722
+ def _cancel_revert(orig_t: LayerTransform):
723
+ lyr.transform = orig_t
724
+ dirty = (abs(orig_t.tx) > 1e-6 or abs(orig_t.ty) > 1e-6 or abs(orig_t.rot_deg) > 1e-6 or
725
+ abs(orig_t.sx - 1.0) > 1e-6 or abs(orig_t.sy - 1.0) > 1e-6)
726
+ try:
727
+ roww.setTransformDirty(dirty)
728
+ except Exception:
729
+ pass
730
+ vw.apply_layer_stack(vw._layers)
731
+
732
+ dlg = _TransformDialog(
733
+ self,
734
+ t=lyr.transform,
735
+ apply_cb=_apply_t,
736
+ cancel_cb=_cancel_revert,
737
+ debounce_ms=200, # tweak 150–300
738
+ )
739
+ dlg.exec()
740
+
548
741
 
549
742
  def _apply_list_to_view_debounced(self):
550
743
  # restart the timer on every slider tick
@@ -2007,7 +2007,8 @@ def save_image(img_array,
2007
2007
  is_mono=False,
2008
2008
  image_meta=None,
2009
2009
  file_meta=None,
2010
- wcs_header=None): # 🔥 NEW
2010
+ wcs_header=None,
2011
+ jpeg_quality: int | None = None):
2011
2012
  """
2012
2013
  Save an image array to a file in the specified format and bit depth.
2013
2014
  - Robust to mis-ordered positional args (header/bit_depth swap).
@@ -2060,9 +2061,14 @@ def save_image(img_array,
2060
2061
 
2061
2062
  if fmt == "jpg":
2062
2063
  img = Image.fromarray((np.clip(img_array, 0, 1) * 255).astype(np.uint8))
2063
- # You can pass quality=95, subsampling=0 if you want
2064
- img.save(filename)
2065
- print(f"Saved 8-bit JPG image to: {filename}")
2064
+
2065
+ q = 95 if jpeg_quality is None else int(jpeg_quality)
2066
+ q = max(1, min(100, q))
2067
+
2068
+ # subsampling=0 keeps best chroma quality; optimize can reduce size a bit
2069
+ img.save(filename, quality=q, subsampling=0, optimize=True)
2070
+
2071
+ print(f"Saved 8-bit JPG image to: {filename} (quality={q})")
2066
2072
  return
2067
2073
 
2068
2074
  # ---------------------------------------------------------------------