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.
- setiastro/images/3dplanet.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/doc_manager.py +4 -1
- setiastro/saspro/gui/main_window.py +41 -2
- setiastro/saspro/gui/mixins/file_mixin.py +6 -2
- setiastro/saspro/gui/mixins/menu_mixin.py +1 -0
- setiastro/saspro/gui/mixins/toolbar_mixin.py +8 -1
- setiastro/saspro/imageops/serloader.py +101 -17
- setiastro/saspro/layers.py +186 -10
- setiastro/saspro/layers_dock.py +198 -5
- setiastro/saspro/legacy/image_manager.py +10 -4
- setiastro/saspro/planetprojection.py +3854 -0
- setiastro/saspro/resources.py +2 -0
- setiastro/saspro/save_options.py +45 -13
- setiastro/saspro/ser_stack_config.py +21 -1
- setiastro/saspro/ser_stacker.py +8 -2
- setiastro/saspro/ser_stacker_dialog.py +37 -10
- setiastro/saspro/ser_tracking.py +57 -35
- setiastro/saspro/serviewer.py +164 -16
- setiastro/saspro/subwindow.py +36 -1
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +1 -1
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +28 -26
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.7.1.post2.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Auto-generated at build time. Do not edit.
|
|
2
|
-
BUILD_TIMESTAMP = "2026-01-
|
|
3
|
-
APP_VERSION = "1.7.
|
|
2
|
+
BUILD_TIMESTAMP = "2026-01-20T16:24:15Z"
|
|
3
|
+
APP_VERSION = "1.7.3"
|
setiastro/saspro/doc_manager.py
CHANGED
|
@@ -2276,6 +2276,7 @@ class DocManager(QObject):
|
|
|
2276
2276
|
bit_depth: str | None = None,
|
|
2277
2277
|
*,
|
|
2278
2278
|
bit_depth_override: str | None = None,
|
|
2279
|
+
jpeg_quality: int | None = None, # <-- NEW
|
|
2279
2280
|
):
|
|
2280
2281
|
"""
|
|
2281
2282
|
Save the given ImageDocument to 'path'.
|
|
@@ -2289,7 +2290,8 @@ class DocManager(QObject):
|
|
|
2289
2290
|
ext = _normalize_ext(os.path.splitext(path)[1])
|
|
2290
2291
|
img = doc.image
|
|
2291
2292
|
meta = doc.metadata or {}
|
|
2292
|
-
|
|
2293
|
+
if jpeg_quality is not None:
|
|
2294
|
+
meta["jpeg_quality"] = int(jpeg_quality)
|
|
2293
2295
|
# ── MASSIVE DEBUG: show everything we know coming in ───────────────
|
|
2294
2296
|
debug_dump_metadata_print(meta, context="save_document: BEFORE HEADER PICK")
|
|
2295
2297
|
|
|
@@ -2359,6 +2361,7 @@ class DocManager(QObject):
|
|
|
2359
2361
|
image_meta=meta.get("image_meta"),
|
|
2360
2362
|
file_meta=meta.get("file_meta"),
|
|
2361
2363
|
wcs_header=meta.get("wcs_header"),
|
|
2364
|
+
jpeg_quality=jpeg_quality,
|
|
2362
2365
|
)
|
|
2363
2366
|
|
|
2364
2367
|
# ── Update metadata in memory to match what we just wrote ─────────
|
|
@@ -196,7 +196,7 @@ from setiastro.saspro.resources import (
|
|
|
196
196
|
colorwheel_path, font_path, csv_icon_path, spinner_path, wims_path, narrowbandnormalization_path,
|
|
197
197
|
wimi_path, linearfit_path, debayer_path, aberration_path, acv_icon_path,
|
|
198
198
|
functionbundles_path, viewbundles_path, selectivecolor_path, rgbalign_path, planetarystacker_path,
|
|
199
|
-
background_path, script_icon_path
|
|
199
|
+
background_path, script_icon_path, planetprojection_path,
|
|
200
200
|
)
|
|
201
201
|
|
|
202
202
|
import faulthandler
|
|
@@ -435,7 +435,23 @@ class AstroSuiteProMainWindow(
|
|
|
435
435
|
version: str = "dev", build_timestamp: str = "dev"):
|
|
436
436
|
super().__init__(parent)
|
|
437
437
|
# Prevent white flash: start strictly transparent and force dark bg
|
|
438
|
-
|
|
438
|
+
from PyQt6.QtGui import QGuiApplication
|
|
439
|
+
|
|
440
|
+
def _is_wayland() -> bool:
|
|
441
|
+
try:
|
|
442
|
+
plat = (QGuiApplication.platformName() or "").lower()
|
|
443
|
+
if "wayland" in plat:
|
|
444
|
+
return True
|
|
445
|
+
except Exception:
|
|
446
|
+
pass
|
|
447
|
+
# fallback env checks
|
|
448
|
+
return bool(os.environ.get("WAYLAND_DISPLAY")) and not bool(os.environ.get("DISPLAY"))
|
|
449
|
+
|
|
450
|
+
# Prevent white flash: start strictly transparent and force dark bg
|
|
451
|
+
if not _is_wayland():
|
|
452
|
+
self.setWindowOpacity(0.0)
|
|
453
|
+
self.setStyleSheet("QMainWindow { background-color: #0F0F19; }")
|
|
454
|
+
|
|
439
455
|
self.setStyleSheet("QMainWindow { background-color: #0F0F19; }")
|
|
440
456
|
#self._stall = UiStallDetector(self, interval_ms=50, threshold_ms=250)
|
|
441
457
|
#self._stall.start()
|
|
@@ -4280,6 +4296,29 @@ class AstroSuiteProMainWindow(
|
|
|
4280
4296
|
dlg.setWindowIcon(QIcon(planetarystacker_path))
|
|
4281
4297
|
dlg.show()
|
|
4282
4298
|
|
|
4299
|
+
def _open_planet_projection(self):
|
|
4300
|
+
from setiastro.saspro.planetprojection import PlanetProjectionDialog
|
|
4301
|
+
sw = self.mdi.activeSubWindow()
|
|
4302
|
+
if not sw:
|
|
4303
|
+
QMessageBox.information(self, "No image", "Open an image first.")
|
|
4304
|
+
return
|
|
4305
|
+
|
|
4306
|
+
view = sw.widget()
|
|
4307
|
+
doc = self.doc_manager.get_document_for_view(view)
|
|
4308
|
+
|
|
4309
|
+
dlg = PlanetProjectionDialog(self, doc)
|
|
4310
|
+
try:
|
|
4311
|
+
# dlg.setWindowIcon(QIcon(planetprojection_path))
|
|
4312
|
+
pass
|
|
4313
|
+
except Exception:
|
|
4314
|
+
pass
|
|
4315
|
+
dlg.resize(980, 720)
|
|
4316
|
+
dlg.setWindowFlag(Qt.WindowType.Window, True)
|
|
4317
|
+
dlg.setWindowIcon(QIcon(planetprojection_path))
|
|
4318
|
+
dlg.show()
|
|
4319
|
+
self._log("Functions: opened Planet Projection.")
|
|
4320
|
+
|
|
4321
|
+
|
|
4283
4322
|
def _open_stacking_suite(self):
|
|
4284
4323
|
# Reuse if we already have one
|
|
4285
4324
|
dlg = getattr(self, "_stacking_suite", None)
|
|
@@ -216,14 +216,18 @@ class FileMixin:
|
|
|
216
216
|
# --- Bit depth selection ----------------------------------------
|
|
217
217
|
from setiastro.saspro.save_options import SaveOptionsDialog
|
|
218
218
|
current_bd = doc.metadata.get("bit_depth")
|
|
219
|
-
|
|
219
|
+
current_jq = (doc.metadata or {}).get("jpeg_quality", None)
|
|
220
|
+
|
|
221
|
+
dlg = SaveOptionsDialog(self, ext_norm, current_bd, current_jpeg_quality=current_jq)
|
|
220
222
|
if dlg.exec() != dlg.DialogCode.Accepted:
|
|
221
223
|
return
|
|
224
|
+
|
|
222
225
|
chosen_bd = dlg.selected_bit_depth()
|
|
226
|
+
chosen_jq = dlg.selected_jpeg_quality()
|
|
223
227
|
|
|
224
228
|
# --- Save & remember folder ----------------------------------------
|
|
225
229
|
try:
|
|
226
|
-
self.docman.save_document(doc, path, bit_depth_override=chosen_bd)
|
|
230
|
+
self.docman.save_document(doc, path, bit_depth_override=chosen_bd, jpeg_quality=chosen_jq)
|
|
227
231
|
self._log(f"Saved: {path} ({chosen_bd})")
|
|
228
232
|
self.settings.setValue("paths/last_save_dir", os.path.dirname(path))
|
|
229
233
|
except Exception as e:
|
|
@@ -207,6 +207,7 @@ class MenuMixin:
|
|
|
207
207
|
m_star.addAction(self.act_isophote)
|
|
208
208
|
m_star.addAction(self.act_live_stacking)
|
|
209
209
|
m_star.addAction(self.act_mosaic_master)
|
|
210
|
+
m_star.addAction(self.act_planet_projection)
|
|
210
211
|
m_star.addAction(self.act_planetary_stacker)
|
|
211
212
|
m_star.addAction(self.act_plate_solve)
|
|
212
213
|
m_star.addAction(self.act_psf_viewer)
|
|
@@ -36,7 +36,7 @@ from setiastro.saspro.resources import (
|
|
|
36
36
|
nbtorgb_path, freqsep_path, multiscale_decomp_path, contsub_path, halo_path, cosmic_path,
|
|
37
37
|
satellite_path, imagecombine_path, wims_path, wimi_path, linearfit_path,
|
|
38
38
|
debayer_path, aberration_path, functionbundles_path, viewbundles_path, planetarystacker_path,
|
|
39
|
-
selectivecolor_path, rgbalign_path,
|
|
39
|
+
selectivecolor_path, rgbalign_path, planetprojection_path,
|
|
40
40
|
)
|
|
41
41
|
|
|
42
42
|
# Import shortcuts module
|
|
@@ -307,6 +307,7 @@ class ToolbarMixin:
|
|
|
307
307
|
tb_star.addAction(self.act_stacking_suite)
|
|
308
308
|
tb_star.addAction(self.act_live_stacking)
|
|
309
309
|
tb_star.addAction(self.act_planetary_stacker)
|
|
310
|
+
tb_star.addAction(self.act_planet_projection)
|
|
310
311
|
tb_star.addAction(self.act_plate_solve)
|
|
311
312
|
tb_star.addAction(self.act_star_align)
|
|
312
313
|
tb_star.addAction(self.act_star_register)
|
|
@@ -1195,6 +1196,11 @@ class ToolbarMixin:
|
|
|
1195
1196
|
self.act_planetary_stacker.setStatusTip(self.tr("Stack SER videos (planetary/solar/lunar)"))
|
|
1196
1197
|
self.act_planetary_stacker.triggered.connect(self._open_planetary_stacker)
|
|
1197
1198
|
|
|
1199
|
+
self.act_planet_projection = QAction(QIcon(planetprojection_path), self.tr("Planetary Projection..."), self)
|
|
1200
|
+
self.act_planet_projection.setIconVisibleInMenu(True)
|
|
1201
|
+
self.act_planet_projection.setStatusTip(self.tr("View your planets with stereographic projection"))
|
|
1202
|
+
self.act_planet_projection.triggered.connect(self._open_planet_projection)
|
|
1203
|
+
|
|
1198
1204
|
self.act_plate_solve = QAction(QIcon(platesolve_path), self.tr("Plate Solver..."), self)
|
|
1199
1205
|
self.act_plate_solve.setIconVisibleInMenu(True)
|
|
1200
1206
|
self.act_plate_solve.setStatusTip(self.tr("Solve WCS/SIP for the active image or a file"))
|
|
@@ -1439,6 +1445,7 @@ class ToolbarMixin:
|
|
|
1439
1445
|
reg("image_peeker", self.act_image_peeker)
|
|
1440
1446
|
reg("live_stacking", self.act_live_stacking)
|
|
1441
1447
|
reg("stacking_suite", self.act_stacking_suite)
|
|
1448
|
+
reg("planet_projection", self.act_planet_projection)
|
|
1442
1449
|
reg("supernova_hunter", self.act_supernova_hunter)
|
|
1443
1450
|
reg("star_spikes", self.act_star_spikes)
|
|
1444
1451
|
reg("astrospike", self.act_astrospike)
|
|
@@ -10,6 +10,7 @@ from typing import Optional, Tuple, Dict, List, Sequence, Union, Callable
|
|
|
10
10
|
from collections import OrderedDict
|
|
11
11
|
import numpy as np
|
|
12
12
|
import time
|
|
13
|
+
from PyQt6 import sip
|
|
13
14
|
|
|
14
15
|
import cv2
|
|
15
16
|
|
|
@@ -115,6 +116,20 @@ def _bytes_per_sample(pixel_depth_bits: int) -> int:
|
|
|
115
116
|
def _is_bayer(color_name: str) -> bool:
|
|
116
117
|
return color_name in BAYER_NAMES
|
|
117
118
|
|
|
119
|
+
def _roi_unrotate_180(roi: Tuple[int, int, int, int], W: int, H: int) -> Tuple[int, int, int, int]:
|
|
120
|
+
"""
|
|
121
|
+
Convert an ROI specified in *post-rot180/display coords* into *raw/pre-rot coords*.
|
|
122
|
+
"""
|
|
123
|
+
x, y, w, h = [int(v) for v in roi]
|
|
124
|
+
x2 = x + w
|
|
125
|
+
y2 = y + h
|
|
126
|
+
|
|
127
|
+
x_raw = int(W - x2)
|
|
128
|
+
y_raw = int(H - y2)
|
|
129
|
+
|
|
130
|
+
return (x_raw, y_raw, int(w), int(h))
|
|
131
|
+
|
|
132
|
+
|
|
118
133
|
def _rot180(img: np.ndarray) -> np.ndarray:
|
|
119
134
|
# Works for mono (H,W) and RGB(A) (H,W,C)
|
|
120
135
|
return img[::-1, ::-1].copy()
|
|
@@ -847,7 +862,9 @@ class SERReader:
|
|
|
847
862
|
|
|
848
863
|
# ROI (apply before debayer; for Bayer enforce even-even origin)
|
|
849
864
|
if roi is not None:
|
|
850
|
-
|
|
865
|
+
# ✅ roi is specified in DISPLAY coords (post-rot180)
|
|
866
|
+
x, y, w, h = _roi_unrotate_180(roi, meta.width, meta.height)
|
|
867
|
+
|
|
851
868
|
x = max(0, min(meta.width - 1, x))
|
|
852
869
|
y = max(0, min(meta.height - 1, y))
|
|
853
870
|
w = max(1, min(meta.width - x, w))
|
|
@@ -862,7 +879,6 @@ class SERReader:
|
|
|
862
879
|
|
|
863
880
|
# --- SER global orientation fix (rotate 180) ---
|
|
864
881
|
img = _rot180(img)
|
|
865
|
-
|
|
866
882
|
# Convert BGR->RGB if needed
|
|
867
883
|
if color_name == "BGR" and img.ndim == 3 and img.shape[2] >= 3:
|
|
868
884
|
img = img[..., ::-1].copy()
|
|
@@ -1075,15 +1091,16 @@ class AVIReader(PlanetaryFrameSource):
|
|
|
1075
1091
|
|
|
1076
1092
|
# ROI first (but if we are going to debayer mosaic, ROI origin must be even-even)
|
|
1077
1093
|
if roi is not None:
|
|
1078
|
-
x, y, w, h = [int(v) for v in roi]
|
|
1079
1094
|
H, W = frame.shape[:2]
|
|
1095
|
+
|
|
1096
|
+
# ✅ roi is specified in DISPLAY coords (post-rot180)
|
|
1097
|
+
x, y, w, h = _roi_unrotate_180(roi, W, H)
|
|
1098
|
+
|
|
1080
1099
|
x = max(0, min(W - 1, x))
|
|
1081
1100
|
y = max(0, min(H - 1, y))
|
|
1082
1101
|
w = max(1, min(W - x, w))
|
|
1083
1102
|
h = max(1, min(H - y, h))
|
|
1084
1103
|
|
|
1085
|
-
# If user explicitly requests debayering, preserve Bayer phase
|
|
1086
|
-
# (even-even origin) exactly like SER
|
|
1087
1104
|
if debayer and user_pat is not None:
|
|
1088
1105
|
x, y = _roi_evenize_for_bayer(x, y)
|
|
1089
1106
|
w = max(1, min(W - x, w))
|
|
@@ -1160,13 +1177,45 @@ class AVIReader(PlanetaryFrameSource):
|
|
|
1160
1177
|
|
|
1161
1178
|
def _imread_any(path: str) -> np.ndarray:
|
|
1162
1179
|
"""
|
|
1163
|
-
Read PNG/JPG/TIF/etc into numpy.
|
|
1164
|
-
Tries cv2 first (fast), falls back to PIL.
|
|
1180
|
+
Read PNG/JPG/TIF/FITS/etc into numpy.
|
|
1165
1181
|
Returns:
|
|
1166
|
-
- grayscale: (H,W)
|
|
1167
|
-
- color: (H,W,3)
|
|
1182
|
+
- grayscale: (H,W)
|
|
1183
|
+
- color: (H,W,3) in RGB when applicable
|
|
1168
1184
|
"""
|
|
1169
1185
|
p = os.fspath(path)
|
|
1186
|
+
ext = os.path.splitext(p)[1].lower()
|
|
1187
|
+
|
|
1188
|
+
# ---- FITS / FIT ----
|
|
1189
|
+
if ext in (".fit", ".fits"):
|
|
1190
|
+
try:
|
|
1191
|
+
from astropy.io import fits
|
|
1192
|
+
|
|
1193
|
+
data = fits.getdata(p, memmap=False)
|
|
1194
|
+
if data is None:
|
|
1195
|
+
raise ValueError("Empty FITS data.")
|
|
1196
|
+
|
|
1197
|
+
arr = np.asarray(data)
|
|
1198
|
+
|
|
1199
|
+
# Common shapes:
|
|
1200
|
+
# (H,W) -> mono
|
|
1201
|
+
# (C,H,W) -> convert to (H,W,C)
|
|
1202
|
+
# (H,W,C) -> already fine
|
|
1203
|
+
if arr.ndim == 3:
|
|
1204
|
+
# If first axis is small (1/3/4), assume CHW
|
|
1205
|
+
if arr.shape[0] in (1, 3, 4) and arr.shape[1] > 8 and arr.shape[2] > 8:
|
|
1206
|
+
arr = np.moveaxis(arr, 0, -1) # CHW -> HWC
|
|
1207
|
+
|
|
1208
|
+
# If now HWC and has alpha, drop it
|
|
1209
|
+
if arr.shape[-1] >= 4:
|
|
1210
|
+
arr = arr[..., :3]
|
|
1211
|
+
|
|
1212
|
+
# If single channel, squeeze
|
|
1213
|
+
if arr.shape[-1] == 1:
|
|
1214
|
+
arr = arr[..., 0]
|
|
1215
|
+
|
|
1216
|
+
return arr
|
|
1217
|
+
except Exception as e:
|
|
1218
|
+
raise RuntimeError(f"Failed to read FITS: {p}\n{e}")
|
|
1170
1219
|
|
|
1171
1220
|
# Prefer cv2 if available
|
|
1172
1221
|
if cv2 is not None:
|
|
@@ -1194,6 +1243,47 @@ def _imread_any(path: str) -> np.ndarray:
|
|
|
1194
1243
|
im = im.convert("RGB")
|
|
1195
1244
|
return np.array(im)
|
|
1196
1245
|
|
|
1246
|
+
def _to_float01_robust(img: np.ndarray) -> np.ndarray:
|
|
1247
|
+
"""
|
|
1248
|
+
Robust float01 conversion for preview:
|
|
1249
|
+
- uint8/uint16/int types scale by dtype max
|
|
1250
|
+
- float types:
|
|
1251
|
+
* if already ~[0,1], keep
|
|
1252
|
+
* else percentile-scale (0.1..99.9) to [0,1]
|
|
1253
|
+
Works for mono or RGB arrays.
|
|
1254
|
+
"""
|
|
1255
|
+
a = np.asarray(img)
|
|
1256
|
+
|
|
1257
|
+
if a.dtype == np.uint8:
|
|
1258
|
+
return a.astype(np.float32) / 255.0
|
|
1259
|
+
if a.dtype == np.uint16:
|
|
1260
|
+
return a.astype(np.float32) / 65535.0
|
|
1261
|
+
if np.issubdtype(a.dtype, np.integer):
|
|
1262
|
+
info = np.iinfo(a.dtype)
|
|
1263
|
+
denom = float(info.max) if info.max > 0 else 1.0
|
|
1264
|
+
return (a.astype(np.float32) / denom).clip(0.0, 1.0)
|
|
1265
|
+
|
|
1266
|
+
# float path
|
|
1267
|
+
f = a.astype(np.float32, copy=False)
|
|
1268
|
+
|
|
1269
|
+
# If it already looks like [0,1], don’t mess with it
|
|
1270
|
+
mn = float(np.nanmin(f))
|
|
1271
|
+
mx = float(np.nanmax(f))
|
|
1272
|
+
if (mn >= -1e-3) and (mx <= 1.0 + 1e-3):
|
|
1273
|
+
return np.clip(f, 0.0, 1.0)
|
|
1274
|
+
|
|
1275
|
+
# Percentile scale (planetary-friendly)
|
|
1276
|
+
lo = float(np.nanpercentile(f, 0.1))
|
|
1277
|
+
hi = float(np.nanpercentile(f, 99.9))
|
|
1278
|
+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo + 1e-12:
|
|
1279
|
+
# fallback to min/max
|
|
1280
|
+
lo, hi = mn, mx
|
|
1281
|
+
if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo + 1e-12:
|
|
1282
|
+
return np.zeros_like(f, dtype=np.float32)
|
|
1283
|
+
|
|
1284
|
+
out = (f - lo) / (hi - lo)
|
|
1285
|
+
return np.clip(out, 0.0, 1.0)
|
|
1286
|
+
|
|
1197
1287
|
|
|
1198
1288
|
def _infer_bit_depth(arr: np.ndarray) -> int:
|
|
1199
1289
|
if arr.dtype == np.uint16:
|
|
@@ -1295,13 +1385,7 @@ class ImageSequenceReader(PlanetaryFrameSource):
|
|
|
1295
1385
|
|
|
1296
1386
|
# Normalize to float01
|
|
1297
1387
|
if to_float01:
|
|
1298
|
-
|
|
1299
|
-
img = img.astype(np.float32) / 255.0
|
|
1300
|
-
elif img.dtype == np.uint16:
|
|
1301
|
-
img = img.astype(np.float32) / 65535.0
|
|
1302
|
-
else:
|
|
1303
|
-
img = img.astype(np.float32)
|
|
1304
|
-
img = np.clip(img, 0.0, 1.0)
|
|
1388
|
+
img = _to_float01_robust(img)
|
|
1305
1389
|
|
|
1306
1390
|
self._cache.put(key, img)
|
|
1307
1391
|
return img
|
|
@@ -1339,7 +1423,7 @@ def open_planetary_source(
|
|
|
1339
1423
|
return AVIReader(path, cache_items=cache_items)
|
|
1340
1424
|
|
|
1341
1425
|
# If user passes a single image, treat it as a 1-frame sequence
|
|
1342
|
-
if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp"):
|
|
1426
|
+
if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp", ".fit", ".fits"):
|
|
1343
1427
|
return ImageSequenceReader([path], cache_items=cache_items)
|
|
1344
1428
|
|
|
1345
1429
|
raise ValueError(f"Unsupported input: {path}")
|
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
|