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.
- setiastro/images/3dplanet.png +0 -0
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__init__.py +9 -8
- setiastro/saspro/__main__.py +326 -285
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +128 -13
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +4 -1
- setiastro/saspro/gui/main_window.py +326 -46
- setiastro/saspro/gui/mixins/file_mixin.py +41 -18
- setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1429 -0
- setiastro/saspro/layers.py +186 -10
- setiastro/saspro/layers_dock.py +198 -5
- setiastro/saspro/legacy/image_manager.py +10 -4
- setiastro/saspro/legacy/numba_utils.py +1 -1
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/planetprojection.py +3854 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +8 -0
- setiastro/saspro/rgbalign.py +456 -12
- setiastro/saspro/save_options.py +45 -13
- setiastro/saspro/ser_stack_config.py +102 -0
- setiastro/saspro/ser_stacker.py +2327 -0
- setiastro/saspro/ser_stacker_dialog.py +1865 -0
- setiastro/saspro/ser_tracking.py +228 -0
- setiastro/saspro/serviewer.py +1773 -0
- setiastro/saspro/sfcc.py +298 -64
- setiastro/saspro/shortcuts.py +14 -7
- setiastro/saspro/stacking_suite.py +21 -6
- setiastro/saspro/stat_stretch.py +179 -31
- setiastro/saspro/subwindow.py +38 -5
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
setiastro/saspro/layers.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
setiastro/saspro/layers_dock.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
# ---------------------------------------------------------------------
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|