setiastrosuitepro 1.6.12__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 (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  # pro/layers.py
2
2
  from __future__ import annotations
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from typing import Optional, List
5
5
  import numpy as np
6
6
 
@@ -20,6 +20,17 @@ BLEND_MODES = [
20
20
  "Sigmoid",
21
21
  ]
22
22
 
23
+
24
+ @dataclass
25
+ class LayerTransform:
26
+ tx: float = 0.0
27
+ ty: float = 0.0
28
+ rot_deg: float = 0.0
29
+ sx: float = 1.0
30
+ sy: float = 1.0
31
+ pivot_x: float | None = None
32
+ pivot_y: float | None = None
33
+
23
34
  @dataclass
24
35
  class ImageLayer:
25
36
  name: str
@@ -33,7 +44,8 @@ class ImageLayer:
33
44
  mask_invert: bool = False
34
45
  mask_feather: float = 0.0
35
46
  mask_use_luma: bool = False
36
-
47
+ transform: LayerTransform = field(default_factory=LayerTransform)
48
+ selected: bool = False # optional, or store selected layer elsewhere
37
49
  sigmoid_center: float = 0.5
38
50
  sigmoid_strength: float = 10.0
39
51
 
@@ -63,6 +75,127 @@ def _resize_like(src: np.ndarray, tgt_shape_hw: tuple[int, int]) -> np.ndarray:
63
75
  xi = (np.linspace(0, Ws - 1, Wt)).astype(np.int32)
64
76
  return src[yi][:, xi, ...] if src.ndim == 3 else src[yi][:, xi]
65
77
 
78
+ def _affine_matrix_from_transform(t: LayerTransform, H: int, W: int) -> np.ndarray:
79
+ """
80
+ Returns 3x3 matrix mapping source -> destination in pixel coords.
81
+ We will invert it when sampling (dest -> src).
82
+ """
83
+ tx, ty = float(t.tx), float(t.ty)
84
+ sx, sy = float(t.sx), float(t.sy)
85
+ th = np.deg2rad(float(t.rot_deg))
86
+
87
+ # Default pivot: image center in pixel coords
88
+ px = (W - 1) * 0.5 if t.pivot_x is None else float(t.pivot_x)
89
+ py = (H - 1) * 0.5 if t.pivot_y is None else float(t.pivot_y)
90
+
91
+ c = float(np.cos(th))
92
+ s = float(np.sin(th))
93
+
94
+ # Build: T(tx,ty) * T(pivot) * R * S * T(-pivot)
95
+ T1 = np.array([[1, 0, -px],
96
+ [0, 1, -py],
97
+ [0, 0, 1]], dtype=np.float32)
98
+
99
+ S = np.array([[sx, 0, 0],
100
+ [0, sy, 0],
101
+ [0, 0, 1]], dtype=np.float32)
102
+
103
+ R = np.array([[ c, -s, 0],
104
+ [ s, c, 0],
105
+ [ 0, 0, 1]], dtype=np.float32)
106
+
107
+ T2 = np.array([[1, 0, px],
108
+ [0, 1, py],
109
+ [0, 0, 1]], dtype=np.float32)
110
+
111
+ Tt = np.array([[1, 0, tx],
112
+ [0, 1, ty],
113
+ [0, 0, 1]], dtype=np.float32)
114
+
115
+ M = (Tt @ T2 @ R @ S @ T1).astype(np.float32, copy=False)
116
+ return M
117
+
118
+
119
+ def _warp_affine_nearest(src: np.ndarray, out_hw: tuple[int, int], M_src_to_dst: np.ndarray,
120
+ *, fill: float = 0.0) -> np.ndarray:
121
+ """
122
+ Nearest-neighbor warp without deps.
123
+ src: (Hs,Ws) or (Hs,Ws,C)
124
+ out: (Ht,Wt) or (Ht,Wt,C)
125
+ M_src_to_dst: 3x3 matrix mapping src -> dst.
126
+ We sample by inverting: src = inv(M) * dst.
127
+ """
128
+ Ht, Wt = int(out_hw[0]), int(out_hw[1])
129
+ Hs, Ws = int(src.shape[0]), int(src.shape[1])
130
+
131
+ if src.ndim == 2:
132
+ C = None
133
+ else:
134
+ C = int(src.shape[2])
135
+
136
+ # Compute inverse mapping
137
+ try:
138
+ Minv = np.linalg.inv(M_src_to_dst).astype(np.float32, copy=False)
139
+ except np.linalg.LinAlgError:
140
+ # Singular matrix (e.g. sx=0). Return fill.
141
+ if C is None:
142
+ return np.full((Ht, Wt), fill, dtype=src.dtype)
143
+ return np.full((Ht, Wt, C), fill, dtype=src.dtype)
144
+
145
+ # Build destination grid
146
+ yy, xx = np.mgrid[0:Ht, 0:Wt]
147
+ ones = np.ones_like(xx, dtype=np.float32)
148
+
149
+ # (3, Ht*Wt)
150
+ dst = np.stack([xx.astype(np.float32), yy.astype(np.float32), ones], axis=0).reshape(3, -1)
151
+ src_xyw = (Minv @ dst)
152
+
153
+ xs = np.rint(src_xyw[0]).astype(np.int32)
154
+ ys = np.rint(src_xyw[1]).astype(np.int32)
155
+
156
+ valid = (xs >= 0) & (xs < Ws) & (ys >= 0) & (ys < Hs)
157
+
158
+ if C is None:
159
+ out = np.full((Ht * Wt,), fill, dtype=src.dtype)
160
+ out[valid] = src[ys[valid], xs[valid]]
161
+ return out.reshape(Ht, Wt)
162
+
163
+ out = np.full((Ht * Wt, C), fill, dtype=src.dtype)
164
+ out[valid] = src[ys[valid], xs[valid], :]
165
+ return out.reshape(Ht, Wt, C)
166
+
167
+
168
+ def _apply_transform_to_layer_image(img01_3c: np.ndarray, t: LayerTransform, H: int, W: int) -> np.ndarray:
169
+ """
170
+ img01_3c is float32 [0..1], shape (Hs,Ws,3).
171
+ Returns float32 [0..1], shape (H,W,3) transformed into base canvas size.
172
+ """
173
+ M = _affine_matrix_from_transform(t, img01_3c.shape[0], img01_3c.shape[1])
174
+
175
+ # IMPORTANT:
176
+ # Your transform parameters (tx,ty,pivot) are in *layer pixel space*.
177
+ # We want to composite in base space (H,W).
178
+ #
179
+ # Since you already resize layers to (H,W) before compositing, easiest is:
180
+ # 1) resize to (H,W)
181
+ # 2) build transform using H,W
182
+ # 3) warp in that same canvas
183
+ #
184
+ # So caller should pass already-resized (H,W,3) and we rebuild M with H,W.
185
+ M = _affine_matrix_from_transform(t, H, W)
186
+ warped = _warp_affine_nearest(img01_3c, (H, W), M, fill=0.0).astype(np.float32, copy=False)
187
+ return np.clip(warped, 0.0, 1.0)
188
+
189
+ def _apply_transform_to_mask(mask01: np.ndarray, t: LayerTransform, H: int, W: int) -> np.ndarray:
190
+ m = np.clip(mask01.astype(np.float32, copy=False), 0.0, 1.0)
191
+ if m.ndim != 2:
192
+ m = m[..., 0] if (m.ndim == 3 and m.shape[2] == 1) else m
193
+ m = _resize_like(m, (H, W))
194
+ M = _affine_matrix_from_transform(t, H, W)
195
+ warped = _warp_affine_nearest(m, (H, W), M, fill=0.0).astype(np.float32, copy=False)
196
+ return np.clip(warped, 0.0, 1.0)
197
+
198
+
66
199
  def _luminance01(img: np.ndarray) -> np.ndarray:
67
200
  a = _float01(img)
68
201
  a = _ensure_3c(a)
@@ -172,41 +305,84 @@ def _apply_mode(base: np.ndarray, src: np.ndarray, layer: ImageLayer) -> np.ndar
172
305
 
173
306
 
174
307
  def composite_stack(base_img: np.ndarray, layers: List[ImageLayer]) -> np.ndarray:
308
+ """
309
+ Composite a base image with a stack of ImageLayer objects.
310
+
311
+ Notes:
312
+ - Works in float32 [0..1] internally.
313
+ - Ensures 3-channel output.
314
+ - Applies per-layer transform (translate/rotate/scale about pivot) in canvas space
315
+ AFTER resizing the layer to the base canvas (H,W).
316
+ - Applies the SAME transform to the layer's mask (if present) so alpha lines up.
317
+ """
175
318
  if base_img is None:
176
319
  return None
177
- out = _float01(base_img)
178
- out = _ensure_3c(out)
179
320
 
180
- H, W = out.shape[0], out.shape[1]
321
+ out = _ensure_3c(_float01(base_img))
322
+ H, W = int(out.shape[0]), int(out.shape[1])
181
323
 
182
324
  # iterate bottom → top so the top-most layer renders last
183
325
  for L in reversed(layers or []):
184
- if not L.visible:
326
+ if not getattr(L, "visible", True):
185
327
  continue
328
+
329
+ # ----- fetch source pixels -----
186
330
  src = getattr(L, "pixels", None)
187
331
  if src is None:
188
332
  src_doc = getattr(L, "src_doc", None)
189
333
  src = getattr(src_doc, "image", None) if src_doc is not None else None
190
334
  if src is None:
191
335
  continue
336
+
337
+ # ----- normalize to float01 + 3 channels + canvas size -----
192
338
  s = _ensure_3c(_float01(src))
193
339
  s = _resize_like(s, (H, W))
194
340
 
341
+ # ----- apply layer transform in canvas space -----
342
+ t = getattr(L, "transform", None)
343
+ if t is not None:
344
+ try:
345
+ s = _apply_transform_to_layer_image(s, t, H, W)
346
+ except Exception:
347
+ # If transform fails for any reason, fall back to untransformed
348
+ pass
349
+
350
+ # ----- validate blend mode -----
195
351
  if getattr(L, "mode", None) not in BLEND_MODES:
196
352
  L.mode = "Normal"
197
353
 
354
+ # ----- blend result (still full-strength; opacity/mask applied after) -----
198
355
  blended = _apply_mode(out, s, L)
199
356
 
200
- alpha = float(L.opacity if 0.0 <= L.opacity <= 1.0 else 1.0)
201
- if L.mask_doc is not None:
202
- m = _mask_from_doc(L.mask_doc, use_luma=bool(L.mask_use_luma))
357
+ # ----- compute alpha -----
358
+ try:
359
+ alpha = float(getattr(L, "opacity", 1.0))
360
+ except Exception:
361
+ alpha = 1.0
362
+ if not (0.0 <= alpha <= 1.0):
363
+ alpha = 1.0
364
+
365
+ # ----- optional mask (transformed to match the layer) -----
366
+ if getattr(L, "mask_doc", None) is not None:
367
+ m = _mask_from_doc(L.mask_doc, use_luma=bool(getattr(L, "mask_use_luma", False)))
203
368
  if m is not None:
204
369
  m = _resize_like(m, (H, W))
205
- if L.mask_invert:
370
+
371
+ # Apply SAME transform to mask so it stays registered with the layer pixels
372
+ if t is not None:
373
+ try:
374
+ m = _apply_transform_to_mask(m, t, H, W)
375
+ except Exception:
376
+ pass
377
+
378
+ if getattr(L, "mask_invert", False):
206
379
  m = 1.0 - m
380
+
207
381
  alpha_map = np.clip(alpha * m, 0.0, 1.0)[..., None]
208
382
  out = out * (1.0 - alpha_map) + blended * alpha_map
209
383
  continue
384
+
385
+ # ----- no mask: uniform opacity -----
210
386
  out = out * (1.0 - alpha) + blended * alpha
211
387
 
212
388
  return out
@@ -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
  # ---------------------------------------------------------------------
@@ -1,4 +1,4 @@
1
- #legacy.numba_utils.py
1
+ #src.setiastro.saspro.legacy.numba_utils.py
2
2
  import numpy as np
3
3
  from numba import njit, prange
4
4
  from numba.typed import List
@@ -1328,7 +1328,23 @@ class LiveStackWindow(QDialog):
1328
1328
  QApplication.processEvents()
1329
1329
  finally:
1330
1330
  self._poll_busy = False
1331
-
1331
+
1332
+ def _match_master_to_image(self, master: np.ndarray, img: np.ndarray) -> np.ndarray:
1333
+ """
1334
+ Coerce master (dark/flat) to match img dimensionality.
1335
+ - If img is RGB (H,W,3) and master is mono (H,W), expand to (H,W,1).
1336
+ - If img is mono (H,W) and master is RGB (H,W,3), collapse to mono via mean.
1337
+ """
1338
+ if master is None:
1339
+ return None
1340
+
1341
+ if img.ndim == 3 and master.ndim == 2:
1342
+ return master[..., None] # (H,W,1) broadcasts to (H,W,3)
1343
+ if img.ndim == 2 and master.ndim == 3:
1344
+ return master.mean(axis=2) # (H,W)
1345
+ return master
1346
+
1347
+
1332
1348
  def process_frame(self, path):
1333
1349
  if self._should_stop():
1334
1350
  return
@@ -1422,12 +1438,16 @@ class LiveStackWindow(QDialog):
1422
1438
 
1423
1439
  # ——— 2b) CALIBRATION (once) ————————————————————————
1424
1440
  if self.master_dark is not None:
1425
- img = img.astype(np.float32) - self.master_dark
1441
+ md = self._match_master_to_image(self.master_dark, img).astype(np.float32, copy=False)
1442
+ img = img.astype(np.float32, copy=False) - md
1426
1443
  # prefer per-filter flat if we’re in mono→color and have one
1427
1444
  if mono_key and mono_key in self.master_flats:
1428
- img = apply_flat_division_numba(img, self.master_flats[mono_key])
1445
+ mf = self._match_master_to_image(self.master_flats[mono_key], img).astype(np.float32, copy=False)
1446
+ img = apply_flat_division_numba(img, mf)
1429
1447
  elif self.master_flat is not None:
1430
- img = apply_flat_division_numba(img, self.master_flat)
1448
+ mf = self._match_master_to_image(self.master_flat, img).astype(np.float32, copy=False)
1449
+ img = apply_flat_division_numba(img, mf)
1450
+
1431
1451
 
1432
1452
  if self._should_stop():
1433
1453
  return