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
@@ -1,3 +1,3 @@
1
1
  # Auto-generated at build time. Do not edit.
2
- BUILD_TIMESTAMP = "2026-01-15T16:55:53Z"
3
- APP_VERSION = "1.7.1.post2"
2
+ BUILD_TIMESTAMP = "2026-01-20T16:24:15Z"
3
+ APP_VERSION = "1.7.3"
@@ -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
- self.setWindowOpacity(0.0)
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
- dlg = SaveOptionsDialog(self, ext_norm, current_bd)
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
- x, y, w, h = [int(v) for v in roi]
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) uint8/uint16
1167
- - color: (H,W,3) uint8/uint16 in RGB (we normalize to RGB)
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
- if img.dtype == np.uint8:
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}")
@@ -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