setiastrosuitepro 1.7.0__py3-none-any.whl → 1.7.0.post2__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.
@@ -1,3 +1,3 @@
1
1
  # Auto-generated at build time. Do not edit.
2
- BUILD_TIMESTAMP = "2026-01-13T20:26:44Z"
3
- APP_VERSION = "1.7.0"
2
+ BUILD_TIMESTAMP = "2026-01-14T17:12:34Z"
3
+ APP_VERSION = "1.7.0.post2"
@@ -4430,6 +4430,93 @@ class AstroSuiteProMainWindow(
4430
4430
  except Exception:
4431
4431
  pass
4432
4432
 
4433
+ def _swap_rb_active(self):
4434
+ """
4435
+ Swap R and B channels in the active RGB document (undoable).
4436
+ Intended for debayer/channel-order mismatches.
4437
+ """
4438
+ dm = getattr(self, "docman", None)
4439
+ if dm is None:
4440
+ return
4441
+
4442
+ try:
4443
+ doc = dm.get_active_document()
4444
+ except Exception:
4445
+ doc = None
4446
+ if doc is None:
4447
+ return
4448
+
4449
+ img = getattr(doc, "image", None)
4450
+ if img is None:
4451
+ return
4452
+
4453
+ import numpy as np
4454
+ x = np.asarray(img)
4455
+
4456
+ # Must be RGB
4457
+ if not (x.ndim == 3 and x.shape[-1] == 3):
4458
+ try:
4459
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4460
+ except Exception:
4461
+ name = "Active"
4462
+
4463
+ if hasattr(self, "_log"):
4464
+ self._log(f"Swap R/B: '{name}' is not RGB (shape={getattr(x,'shape',None)}).")
4465
+ return
4466
+
4467
+ before_shape = x.shape
4468
+ before_dtype = x.dtype
4469
+
4470
+ # swap channels without changing dtype
4471
+ # (copy is safest so we don't mutate shared views)
4472
+ out = x.copy()
4473
+ out[..., 0], out[..., 2] = x[..., 2], x[..., 0]
4474
+
4475
+ # metadata: preserve existing, but annotate operation
4476
+ try:
4477
+ md = dict(getattr(doc, "metadata", None) or {})
4478
+ except Exception:
4479
+ md = {}
4480
+
4481
+ md["color_model"] = md.get("color_model", "RGB")
4482
+ md["channels"] = 3
4483
+ md["is_mono"] = False
4484
+ md["source"] = (md.get("source") or "Edit")
4485
+
4486
+ # If you track op params for history explorer
4487
+ md["__op_params__"] = {
4488
+ "op": "swap_rb",
4489
+ "from_shape": tuple(before_shape),
4490
+ "to_shape": tuple(out.shape),
4491
+ "dtype": str(before_dtype),
4492
+ }
4493
+
4494
+ try:
4495
+ name = getattr(doc, "display_name", lambda: None)() or getattr(doc, "name", "") or "Active"
4496
+ except Exception:
4497
+ name = "Active"
4498
+
4499
+ try:
4500
+ dm.update_active_document(
4501
+ out,
4502
+ metadata=md,
4503
+ step_name="Swap R ↔ B",
4504
+ doc=doc,
4505
+ )
4506
+
4507
+ if hasattr(self, "_log"):
4508
+ self._log(
4509
+ f"Swap R/B: '{name}' swapped channels "
4510
+ f"(shape={before_shape}, dtype={before_dtype})."
4511
+ )
4512
+
4513
+ except Exception:
4514
+ import traceback
4515
+ try:
4516
+ from PyQt6.QtWidgets import QMessageBox
4517
+ QMessageBox.critical(self, "Swap R/B", traceback.format_exc())
4518
+ except Exception:
4519
+ pass
4433
4520
 
4434
4521
 
4435
4522
  def _on_stackingsuite_relaunch(self, old_dir: str, new_dir: str):
@@ -123,6 +123,7 @@ class MenuMixin:
123
123
  m_edit.addAction(self.act_redo)
124
124
  m_edit.addSeparator()
125
125
  m_edit.addAction(self.act_mono_to_rgb)
126
+ m_edit.addAction(self.act_swap_rb)
126
127
 
127
128
 
128
129
  # Functions
@@ -836,7 +836,9 @@ class ToolbarMixin:
836
836
  self.act_mono_to_rgb = QAction(self.tr("Convert Mono to RGB"), self)
837
837
  self.act_mono_to_rgb.setStatusTip(self.tr("Convert a mono image to RGB by duplicating the channel"))
838
838
  self.act_mono_to_rgb.triggered.connect(self._convert_mono_to_rgb_active)
839
-
839
+ self.act_swap_rb = QAction(self.tr("Swap R and B Channels"), self)
840
+ self.act_swap_rb.setStatusTip(self.tr("Swap red and blue channels on the active RGB image"))
841
+ self.act_swap_rb.triggered.connect(self._swap_rb_active)
840
842
  # Functions
841
843
  self.act_crop = QAction(QIcon(cropicon_path), self.tr("Crop..."), self)
842
844
  self.act_crop.setStatusTip(self.tr("Crop / rotate with handles"))
@@ -31,20 +31,44 @@ SER_HEADER_SIZE = 178
31
31
  SER_SIGNATURE_LEN = 14
32
32
 
33
33
  # Common SER color IDs (seen in the wild)
34
+ # NOTE: Many SER writers use:
35
+ # 0 = MONO
36
+ # 8..11 = Bayer (RGGB/GRBG/GBRG/BGGR)
37
+ # 24..27 = RGB/BGR/RGBA/BGRA
34
38
  SER_COLOR = {
35
- 0: "MONO", # mono
36
- 8: "RGB", # RGB24/RGB48 depending pixel depth
37
- 9: "BGR", # BGR24/BGR48
38
- 10: "RGBA",
39
- 11: "BGRA",
40
- 12: "BAYER_RGGB",
41
- 13: "BAYER_GRBG",
42
- 14: "BAYER_GBRG",
43
- 15: "BAYER_BGGR",
39
+ 0: "MONO",
40
+
41
+ # Bayer (most common in planetary SER)
42
+ 8: "BAYER_RGGB",
43
+ 9: "BAYER_GRBG",
44
+ 10: "BAYER_GBRG",
45
+ 11: "BAYER_BGGR",
46
+
47
+ # Packed color
48
+ 24: "RGB",
49
+ 25: "BGR",
50
+ 26: "RGBA",
51
+ 27: "BGRA",
44
52
  }
45
53
 
46
54
  BAYER_NAMES = {"BAYER_RGGB", "BAYER_GRBG", "BAYER_GBRG", "BAYER_BGGR"}
47
-
55
+ BAYER_PATTERNS = ("BAYER_RGGB", "BAYER_GRBG", "BAYER_GBRG", "BAYER_BGGR")
56
+
57
+ def _normalize_bayer_pattern(p: Optional[str]) -> Optional[str]:
58
+ if not p:
59
+ return None
60
+ p = str(p).strip().upper()
61
+ if p == "AUTO":
62
+ return None
63
+ if p.startswith("BAYER_"):
64
+ if p in BAYER_PATTERNS:
65
+ return p
66
+ return None
67
+ # allow short names like "RGGB"
68
+ p2 = "BAYER_" + p
69
+ if p2 in BAYER_PATTERNS:
70
+ return p2
71
+ return None
48
72
 
49
73
  @dataclass
50
74
  class SerMeta:
@@ -112,6 +136,37 @@ def _cv2_debayer(mosaic: np.ndarray, pattern: str) -> np.ndarray:
112
136
  raise ValueError(f"Unknown Bayer pattern: {pattern}")
113
137
  return cv2.cvtColor(mosaic, code)
114
138
 
139
+ def _maybe_swap_rb_to_match_cv2(mosaic: np.ndarray, pattern: str, out: np.ndarray) -> np.ndarray:
140
+ """
141
+ Ensure debayer output channel order matches OpenCV's RGB output.
142
+ Some fast debayers return BGR. We detect by comparing against cv2 on a small crop.
143
+ """
144
+ if out is None or out.ndim != 3 or out.shape[2] < 3:
145
+ return out
146
+
147
+ # Compare on a small center crop for speed
148
+ H, W = mosaic.shape[:2]
149
+ cs = min(96, H, W)
150
+ y0 = max(0, (H - cs) // 2)
151
+ x0 = max(0, (W - cs) // 2)
152
+ m = mosaic[y0:y0+cs, x0:x0+cs]
153
+
154
+ ref = _cv2_debayer(m, pattern) # RGB
155
+
156
+ o = out[y0:y0+cs, x0:x0+cs, :3]
157
+ if o.dtype != ref.dtype:
158
+ # compare in float to avoid overflow
159
+ o_f = o.astype(np.float32)
160
+ ref_f = ref.astype(np.float32)
161
+ else:
162
+ o_f = o.astype(np.float32)
163
+ ref_f = ref.astype(np.float32)
164
+
165
+ d_same = float(np.mean(np.abs(o_f - ref_f)))
166
+ d_swap = float(np.mean(np.abs(o_f[..., ::-1] - ref_f)))
167
+
168
+ return out[..., ::-1].copy() if d_swap < d_same else out
169
+
115
170
 
116
171
  def _try_numba_debayer(mosaic: np.ndarray, pattern: str) -> Optional[np.ndarray]:
117
172
  """
@@ -194,6 +249,7 @@ class SERReader:
194
249
  self.meta = self._parse_header(self._mm)
195
250
  self.meta.path = self.path
196
251
  self._cache = _LRUCache(max_items=cache_items)
252
+ self._fast_debayer_is_bgr: Optional[bool] = None
197
253
 
198
254
  def close(self):
199
255
  try:
@@ -216,7 +272,6 @@ class SERReader:
216
272
  self.close()
217
273
 
218
274
  # ---------------- header parsing ----------------
219
-
220
275
  @staticmethod
221
276
  def _parse_header(mm: mmap.mmap) -> SerMeta:
222
277
  if mm.size() < SER_HEADER_SIZE:
@@ -228,27 +283,12 @@ class SERReader:
228
283
  sig_txt = _decode_cstr(sig)
229
284
 
230
285
  # Be permissive: many SERs start with LUCAM-RECORDER
231
- if "LUCAM" not in sig_txt.upper():
232
- # still try parsing; some writers differ, but fields often match
233
- pass
286
+ # If not, still try parsing.
287
+ # (Some writers use other signatures but the v3 field layout often matches.)
234
288
 
235
- # Layout (little-endian) commonly:
236
- # 0: 14 bytes signature
237
- # 14: uint32 LuID
238
- # 18: uint32 ColorID
239
- # 22: uint32 LittleEndian (0/1)
240
- # 26: uint32 ImageWidth
241
- # 30: uint32 ImageHeight
242
- # 34: uint32 PixelDepth
243
- # 38: uint32 FrameCount
244
- # 42: char[40] Observer
245
- # 82: char[40] Instrument
246
- # 122: char[40] Telescope
247
- # 162: uint64 DateTime
248
- # 170: uint64 DateTimeUTC
249
289
  try:
250
290
  (lu_id, color_id, little_endian_u32,
251
- w, h, pixel_depth, frames) = struct.unpack_from("<7I", hdr, SER_SIGNATURE_LEN)
291
+ w, h, pixel_depth, frames) = struct.unpack_from("<7I", hdr, SER_SIGNATURE_LEN)
252
292
  except Exception as e:
253
293
  raise ValueError(f"Failed to parse SER header fields: {e}")
254
294
 
@@ -261,26 +301,113 @@ class SERReader:
261
301
  color_name = SER_COLOR.get(int(color_id), f"UNKNOWN({color_id})")
262
302
 
263
303
  bps = _bytes_per_sample(int(pixel_depth))
304
+ data_offset = SER_HEADER_SIZE
305
+ file_size = mm.size()
306
+
307
+ def expected_size(frame_bytes: int, with_ts: bool) -> int:
308
+ base = data_offset + int(frames) * int(frame_bytes)
309
+ return base + (int(frames) * 8 if with_ts else 0)
264
310
 
265
- # channels per pixel:
266
- # - MONO or BAYER: 1 sample per pixel
267
- # - RGB/BGR: 3
268
- # - RGBA/BGRA: 4 (rare in SER)
311
+ # --- candidate interpretations ---
312
+ # Bayer/MONO: 1 sample per pixel
313
+ fb_1 = int(w) * int(h) * 1 * int(bps)
314
+
315
+ # RGB/BGR: 3 samples per pixel
316
+ fb_3 = int(w) * int(h) * 3 * int(bps)
317
+
318
+ # RGBA/BGRA: 4 samples per pixel
319
+ fb_4 = int(w) * int(h) * 4 * int(bps)
320
+
321
+ # Decide initial channels from color_name
269
322
  if color_name in {"RGB", "BGR"}:
270
323
  channels = 3
324
+ frame_bytes = fb_3
271
325
  elif color_name in {"RGBA", "BGRA"}:
272
326
  channels = 4
327
+ frame_bytes = fb_4
273
328
  else:
329
+ # MONO + Bayer variants should land here
274
330
  channels = 1
331
+ frame_bytes = fb_1
275
332
 
276
- frame_bytes = int(w) * int(h) * int(channels) * int(bps)
277
- data_offset = SER_HEADER_SIZE
333
+ # --- sanity check against file size ---
334
+ # If the header mapping is wrong (very common culprit), infer channels by file size.
335
+ # We consider both "no timestamps" and "with timestamps".
336
+ def matches(frame_bytes: int) -> tuple[bool, bool]:
337
+ no_ts = (file_size == expected_size(frame_bytes, with_ts=False))
338
+ yes_ts = (file_size == expected_size(frame_bytes, with_ts=True))
339
+ return no_ts, yes_ts
340
+
341
+ m1_no, m1_ts = matches(fb_1)
342
+ m3_no, m3_ts = matches(fb_3)
343
+ m4_no, m4_ts = matches(fb_4)
344
+
345
+ # Prefer an exact match if one exists.
346
+ # Tie-break: if header says Bayer-ish, prefer 1ch; if header says RGB-ish, prefer 3/4ch.
347
+ picked = None # (channels, frame_bytes, has_ts)
348
+
349
+ # If our current interpretation matches, keep it
350
+ cur_no, cur_ts = matches(frame_bytes)
351
+ if cur_no or cur_ts:
352
+ picked = (channels, frame_bytes, bool(cur_ts))
278
353
 
279
- # timestamps detection
280
- expected_no_ts = data_offset + frames * frame_bytes
281
- expected_with_ts = expected_no_ts + frames * 8
282
- size = mm.size()
283
- has_ts = (size == expected_with_ts)
354
+ else:
355
+ # Try to infer by file size
356
+ # Unique matches:
357
+ candidates = []
358
+ if m1_no: candidates.append((1, fb_1, False))
359
+ if m1_ts: candidates.append((1, fb_1, True))
360
+ if m3_no: candidates.append((3, fb_3, False))
361
+ if m3_ts: candidates.append((3, fb_3, True))
362
+ if m4_no: candidates.append((4, fb_4, False))
363
+ if m4_ts: candidates.append((4, fb_4, True))
364
+
365
+ if len(candidates) == 1:
366
+ picked = candidates[0]
367
+ elif len(candidates) > 1:
368
+ # tie-break using header hint
369
+ if _is_bayer(color_name) or color_name == "MONO":
370
+ # choose first 1ch match
371
+ for c in candidates:
372
+ if c[0] == 1:
373
+ picked = c
374
+ break
375
+ elif color_name in {"RGB", "BGR"}:
376
+ for c in candidates:
377
+ if c[0] == 3:
378
+ picked = c
379
+ break
380
+ elif color_name in {"RGBA", "BGRA"}:
381
+ for c in candidates:
382
+ if c[0] == 4:
383
+ picked = c
384
+ break
385
+ # still ambiguous: just pick the first (rare)
386
+ if picked is None:
387
+ picked = candidates[0]
388
+
389
+ if picked is None:
390
+ # Couldn’t reconcile sizes; fall back to header interpretation and best-effort ts flag
391
+ expected_no_ts = expected_size(frame_bytes, with_ts=False)
392
+ expected_with_ts = expected_size(frame_bytes, with_ts=True)
393
+ has_ts = (file_size == expected_with_ts)
394
+ else:
395
+ channels, frame_bytes, has_ts = picked
396
+
397
+ # If we inferred channels that contradict the header color_name, adjust color_name
398
+ # so the rest of the pipeline (debayer, etc.) behaves sensibly.
399
+ if channels == 1:
400
+ # If header said RGB but file is clearly 1ch, it's almost certainly Bayer.
401
+ # Keep UNKNOWN(...) if we truly don't know the Bayer order.
402
+ if color_name in {"RGB", "BGR", "RGBA", "BGRA"}:
403
+ # safest default: treat as RGGB if we have no better info
404
+ color_name = "BAYER_RGGB"
405
+ elif channels == 3:
406
+ if color_name not in {"RGB", "BGR"}:
407
+ color_name = "RGB"
408
+ elif channels == 4:
409
+ if color_name not in {"RGBA", "BGRA"}:
410
+ color_name = "RGBA"
284
411
 
285
412
  return SerMeta(
286
413
  path="",
@@ -291,7 +418,7 @@ class SERReader:
291
418
  color_id=int(color_id),
292
419
  color_name=color_name,
293
420
  little_endian=little_endian,
294
- data_offset=data_offset,
421
+ data_offset=int(data_offset),
295
422
  frame_bytes=int(frame_bytes),
296
423
  has_timestamps=bool(has_ts),
297
424
  observer=observer,
@@ -299,6 +426,7 @@ class SERReader:
299
426
  telescope=telescope,
300
427
  )
301
428
 
429
+
302
430
  # ---------------- core access ----------------
303
431
 
304
432
  def frame_offset(self, i: int) -> int:
@@ -311,10 +439,11 @@ class SERReader:
311
439
  self,
312
440
  i: int,
313
441
  *,
314
- roi: Optional[Tuple[int, int, int, int]] = None, # x,y,w,h
442
+ roi: Optional[Tuple[int, int, int, int]] = None,
315
443
  debayer: bool = True,
316
444
  to_float01: bool = False,
317
445
  force_rgb: bool = False,
446
+ bayer_pattern: Optional[str] = None, # ✅ NEW
318
447
  ) -> np.ndarray:
319
448
  """
320
449
  Returns:
@@ -324,10 +453,13 @@ class SERReader:
324
453
  roi is applied before debayer (and ROI origin evenized for Bayer).
325
454
  """
326
455
  meta = self.meta
456
+ color_name = meta.color_name
457
+ user_pat = _normalize_bayer_pattern(bayer_pattern)
458
+ active_bayer = user_pat if user_pat is not None else (color_name if _is_bayer(color_name) else None)
327
459
 
328
460
  # Cache key includes ROI + flags
329
461
  roi_key = None if roi is None else tuple(int(v) for v in roi)
330
- key = (int(i), roi_key, bool(debayer), bool(to_float01), bool(force_rgb))
462
+ key = (int(i), roi_key, bool(debayer), active_bayer, bool(to_float01), bool(force_rgb))
331
463
  cached = self._cache.get(key)
332
464
  if cached is not None:
333
465
  return cached
@@ -342,7 +474,6 @@ class SERReader:
342
474
  dtype = np.uint16
343
475
 
344
476
  # Determine channels stored
345
- color_name = meta.color_name
346
477
  if color_name in {"RGB", "BGR"}:
347
478
  ch = 3
348
479
  elif color_name in {"RGBA", "BGRA"}:
@@ -369,7 +500,7 @@ class SERReader:
369
500
  w = max(1, min(meta.width - x, w))
370
501
  h = max(1, min(meta.height - y, h))
371
502
 
372
- if _is_bayer(color_name) and debayer:
503
+ if (active_bayer is not None) and debayer:
373
504
  x, y = _roi_evenize_for_bayer(x, y)
374
505
  w = max(1, min(meta.width - x, w))
375
506
  h = max(1, min(meta.height - y, h))
@@ -384,14 +515,19 @@ class SERReader:
384
515
  if _is_bayer(color_name):
385
516
  if debayer:
386
517
  mosaic = img if img.ndim == 2 else img[..., 0]
387
- out = _try_numba_debayer(mosaic, color_name)
518
+ pat = active_bayer or color_name # active_bayer will usually be set here
519
+
520
+ out = _try_numba_debayer(mosaic, pat)
388
521
  if out is None:
389
- out = _cv2_debayer(mosaic, color_name)
522
+ out = _cv2_debayer(mosaic, pat) # already RGB
523
+ else:
524
+ out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
525
+
390
526
  img = out
391
527
  else:
392
- # keep mosaic as mono
393
528
  img = img if img.ndim == 2 else img[..., 0]
394
529
 
530
+
395
531
  # Force RGB for mono (useful for consistent preview pipeline)
396
532
  if force_rgb and img.ndim == 2:
397
533
  img = np.stack([img, img, img], axis=-1)
@@ -468,6 +604,7 @@ class PlanetaryFrameSource:
468
604
  debayer: bool = True,
469
605
  to_float01: bool = False,
470
606
  force_rgb: bool = False,
607
+ bayer_pattern: Optional[str] = None, # ✅ NEW
471
608
  ) -> np.ndarray:
472
609
  raise NotImplementedError
473
610
 
@@ -548,38 +685,102 @@ class AVIReader(PlanetaryFrameSource):
548
685
  debayer: bool = True,
549
686
  to_float01: bool = False,
550
687
  force_rgb: bool = False,
688
+ bayer_pattern: Optional[str] = None,
551
689
  ) -> np.ndarray:
690
+
552
691
  roi_key = None if roi is None else tuple(int(v) for v in roi)
553
- key = ("avi", int(i), roi_key, bool(to_float01), bool(force_rgb))
692
+
693
+ # User pattern:
694
+ # - None means AUTO (do not force debayer on 3-channel video)
695
+ # - A real value means: user explicitly wants debayering
696
+ user_pat = _normalize_bayer_pattern(bayer_pattern) # None == AUTO
697
+ pat_for_key = user_pat or "AUTO"
698
+
699
+ key = ("avi", int(i), roi_key, bool(debayer), pat_for_key, bool(to_float01), bool(force_rgb))
554
700
  cached = self._cache.get(key)
555
701
  if cached is not None:
556
702
  return cached
557
703
 
558
- bgr = self._read_raw_frame_bgr(i)
704
+ frame = self._read_raw_frame_bgr(i) # usually (H,W,3) uint8 BGR
559
705
 
560
- # ROI
706
+ # ROI first (but if we are going to debayer mosaic, ROI origin must be even-even)
561
707
  if roi is not None:
562
708
  x, y, w, h = [int(v) for v in roi]
563
- H, W = bgr.shape[:2]
709
+ H, W = frame.shape[:2]
564
710
  x = max(0, min(W - 1, x))
565
711
  y = max(0, min(H - 1, y))
566
712
  w = max(1, min(W - x, w))
567
713
  h = max(1, min(H - y, h))
568
- bgr = bgr[y:y + h, x:x + w]
569
714
 
570
- # BGR -> RGB
571
- rgb = bgr[..., ::-1].copy()
715
+ # If user explicitly requests debayering, preserve Bayer phase
716
+ # (even-even origin) exactly like SER
717
+ if debayer and user_pat is not None:
718
+ x, y = _roi_evenize_for_bayer(x, y)
719
+ w = max(1, min(W - x, w))
720
+ h = max(1, min(H - y, h))
721
+
722
+ frame = frame[y:y + h, x:x + w]
723
+
724
+ img: np.ndarray
725
+
726
+ # ---------------------------------------------------------
727
+ # RAW MOSAIC AVI SUPPORT
728
+ #
729
+ # OpenCV often returns 3-channel frames even when the AVI is
730
+ # conceptually "raw mosaic". In that case, ONLY debayer when
731
+ # the user explicitly selected a Bayer pattern (not AUTO).
732
+ # ---------------------------------------------------------
733
+
734
+ # True mosaic frame decoded as single-channel
735
+ is_true_mosaic = (frame.ndim == 2) or (frame.ndim == 3 and frame.shape[2] == 1)
736
+
737
+ if debayer and (is_true_mosaic or (user_pat is not None)):
738
+ # If it's 3-channel but user requested debayer, treat as packed mosaic:
739
+ # take one channel (they should be identical if it's really mosaic-packed).
740
+ if frame.ndim == 3 and frame.shape[2] >= 3:
741
+ mosaic = frame[..., 0] # any channel is fine for packed mosaic
742
+ else:
743
+ mosaic = frame if frame.ndim == 2 else frame[..., 0]
744
+
745
+ # Choose pattern:
746
+ # - user_pat is guaranteed not None here if it's forced on 3ch
747
+ # - if it’s true mosaic and user left AUTO, default RGGB
748
+ pat = user_pat or "BAYER_RGGB"
572
749
 
573
- img: np.ndarray = rgb
750
+ out = _try_numba_debayer(mosaic, pat)
751
+ if out is None:
752
+ out = _cv2_debayer(mosaic, pat) # RGB
753
+ else:
754
+ out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
755
+
756
+ img = out # RGB
574
757
 
575
- # force_rgb no-op (already rgb)
758
+ else:
759
+ # Normal video path: decoded BGR -> RGB
760
+ if frame.ndim == 3 and frame.shape[2] >= 3:
761
+ img = frame[..., ::-1].copy()
762
+ else:
763
+ # Rare: frame came out mono but debayer is off
764
+ img = frame if frame.ndim == 2 else frame[..., 0]
765
+ if force_rgb:
766
+ img = np.stack([img, img, img], axis=-1)
767
+
768
+ # Normalize
576
769
  if to_float01:
577
- img = img.astype(np.float32) / 255.0
770
+ if img.dtype == np.uint8:
771
+ img = img.astype(np.float32) / 255.0
772
+ elif img.dtype == np.uint16:
773
+ img = img.astype(np.float32) / 65535.0
774
+ else:
775
+ img = np.clip(img.astype(np.float32), 0.0, 1.0)
776
+
777
+ # Optional force_rgb (mostly relevant if debayer=False and frame is mono)
778
+ if force_rgb and img.ndim == 2:
779
+ img = np.stack([img, img, img], axis=-1)
578
780
 
579
781
  self._cache.put(key, img)
580
782
  return img
581
783
 
582
-
583
784
  # -----------------------------
584
785
  # Image-sequence reader
585
786
  # -----------------------------
@@ -687,7 +888,9 @@ class ImageSequenceReader(PlanetaryFrameSource):
687
888
  debayer: bool = True,
688
889
  to_float01: bool = False,
689
890
  force_rgb: bool = False,
891
+ bayer_pattern: Optional[str] = None,
690
892
  ) -> np.ndarray:
893
+ _ = debayer, bayer_pattern # unused for sequences (for now)
691
894
  i = int(i)
692
895
  if i < 0 or i >= self.meta.frames:
693
896
  raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
@@ -15,6 +15,7 @@ class SERStackConfig:
15
15
  track_mode: TrackMode = "planetary"
16
16
  surface_anchor: Optional[Tuple[int, int, int, int]] = None
17
17
  keep_percent: float = 20.0
18
+ bayer_pattern: Optional[str] = None
18
19
 
19
20
  # AP / alignment
20
21
  ap_size: int = 64
@@ -38,7 +39,12 @@ class SERStackConfig:
38
39
  self.track_mode = kwargs.pop("track_mode", "planetary")
39
40
  self.surface_anchor = kwargs.pop("surface_anchor", None)
40
41
  self.keep_percent = float(kwargs.pop("keep_percent", 20.0))
41
-
42
+ self.bayer_pattern = kwargs.pop("bayer_pattern", None)
43
+ if isinstance(self.bayer_pattern, str):
44
+ s = self.bayer_pattern.strip().upper()
45
+ self.bayer_pattern = s if s in ("RGGB", "BGGR", "GRBG", "GBRG") else None
46
+ else:
47
+ self.bayer_pattern = None
42
48
  self.ap_size = int(kwargs.pop("ap_size", 64))
43
49
  self.ap_spacing = int(kwargs.pop("ap_spacing", 48))
44
50
  self.ap_min_mean = float(kwargs.pop("ap_min_mean", 0.03))
@@ -18,6 +18,44 @@ from setiastro.saspro.ser_stack_config import SERStackConfig
18
18
  from setiastro.saspro.ser_tracking import PlanetaryTracker, SurfaceTracker, _to_mono01
19
19
  from setiastro.saspro.imageops.serloader import open_planetary_source, PlanetaryFrameSource
20
20
 
21
+ _BAYER_TO_CV2 = {
22
+ "RGGB": cv2.COLOR_BayerRG2RGB,
23
+ "BGGR": cv2.COLOR_BayerBG2RGB,
24
+ "GRBG": cv2.COLOR_BayerGR2RGB,
25
+ "GBRG": cv2.COLOR_BayerGB2RGB,
26
+ }
27
+
28
+ def _cfg_bayer_pattern(cfg) -> str | None:
29
+ # cfg.bayer_pattern might be missing in older saved projects; be defensive
30
+ return getattr(cfg, "bayer_pattern", None)
31
+
32
+
33
+ def _get_frame(src, idx: int, *, roi, debayer: bool, to_float01: bool, force_rgb: bool, bayer_pattern: str | None):
34
+ """
35
+ Drop-in wrapper:
36
+ - passes cfg.bayer_pattern down to sources that support it
37
+ - stays compatible with sources whose get_frame() doesn't accept bayer_pattern yet
38
+ """
39
+ try:
40
+ return src.get_frame(
41
+ int(idx),
42
+ roi=roi,
43
+ debayer=debayer,
44
+ to_float01=to_float01,
45
+ force_rgb=force_rgb,
46
+ bayer_pattern=bayer_pattern,
47
+ )
48
+ except TypeError:
49
+ # Back-compat: older PlanetaryFrameSource implementations
50
+ return src.get_frame(
51
+ int(idx),
52
+ roi=roi,
53
+ debayer=debayer,
54
+ to_float01=to_float01,
55
+ force_rgb=force_rgb,
56
+ )
57
+
58
+
21
59
  @dataclass
22
60
  class AnalyzeResult:
23
61
  frames_total: int
@@ -490,6 +528,7 @@ def _coarse_surface_ref_locked(
490
528
  roi_used=None,
491
529
  debayer: bool,
492
530
  to_rgb: bool,
531
+ bayer_pattern: Optional[str] = None,
493
532
  progress_cb=None,
494
533
  progress_every: int = 25,
495
534
  # tuning:
@@ -553,7 +592,8 @@ def _coarse_surface_ref_locked(
553
592
  # ---------------------------
554
593
  src0, owns0 = _ensure_source(source_obj, cache_items=2)
555
594
  try:
556
- img0 = src0.get_frame(0, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb))
595
+ img0 = _get_frame(src0, 0, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern)
596
+
557
597
  ref0 = _to_mono01(img0).astype(np.float32, copy=False)
558
598
  ref0 = _downN(ref0)
559
599
  ref0p = _bandpass(ref0) if bandpass else (ref0 - float(ref0.mean()))
@@ -644,7 +684,8 @@ def _coarse_surface_ref_locked(
644
684
  try:
645
685
  pred_x, pred_y = float(rx0), float(ry0)
646
686
  for b in boundaries[1:]:
647
- img = srck.get_frame(b, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb))
687
+ img = _get_frame(srck, b, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern)
688
+
648
689
  cur = _to_mono01(img).astype(np.float32, copy=False)
649
690
  cur = _downN(cur)
650
691
  curp = _bandpass(cur) if bandpass else (cur - float(cur.mean()))
@@ -682,15 +723,17 @@ def _coarse_surface_ref_locked(
682
723
  pred_x, pred_y = start_pred.get(b, (float(rx0), float(ry0)))
683
724
  # if boundary already computed above, keep it; start after b
684
725
  i0 = b
726
+ if b in start_pred and b != 0:
727
+ i0 = b + 1 # boundary already solved with r_key
728
+
685
729
  if i0 == 0:
686
- i0 = 1 # frame0 is fixed
730
+ i0 = 1
687
731
  for i in range(i0, e):
688
- # if this is exactly a boundary we already filled, use its pred and continue
689
- if i in start_pred and i != b:
732
+ if i in start_pred:
690
733
  pred_x, pred_y = start_pred[i]
691
734
  continue
692
735
 
693
- img = src.get_frame(i, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb))
736
+ img = _get_frame(src, i, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern)
694
737
  cur = _to_mono01(img).astype(np.float32, copy=False)
695
738
  cur = _downN(cur)
696
739
  curp = _bandpass(cur) if bandpass else (cur - float(cur.mean()))
@@ -720,7 +763,7 @@ def _coarse_surface_ref_locked(
720
763
  try:
721
764
  pred_x, pred_y = float(rx0), float(ry0)
722
765
  for i in range(1, n):
723
- img = src.get_frame(i, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb))
766
+ img = _get_frame(src, i, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern)
724
767
  cur = _to_mono01(img).astype(np.float32, copy=False)
725
768
  cur = _downN(cur)
726
769
  curp = _bandpass(cur) if bandpass else (cur - float(cur.mean()))
@@ -854,6 +897,8 @@ def stack_ser(
854
897
  keep_percent: float = 20.0,
855
898
  track_mode: str = "planetary",
856
899
  surface_anchor=None,
900
+ to_rgb: bool = False, # ✅ add this
901
+ bayer_pattern: Optional[str] = None, # ✅ strongly recommended since dialog passes it
857
902
  analysis: AnalyzeResult | None = None,
858
903
  local_warp: bool = True,
859
904
  max_dim: int = 512,
@@ -866,6 +911,7 @@ def stack_ser(
866
911
  drizzle_pixfrac: float = 0.80,
867
912
  drizzle_kernel: str = "gaussian",
868
913
  drizzle_sigma: float = 0.0,
914
+
869
915
  ) -> tuple[np.ndarray, dict]:
870
916
  source_obj = source
871
917
 
@@ -908,7 +954,7 @@ def stack_ser(
908
954
  ap_size = int(getattr(analysis, "ap_size", 64) or 64)
909
955
 
910
956
  # frame shape for accumulator
911
- first = src0.get_frame(int(keep_idx[0]), roi=roi, debayer=debayer, to_float01=True, force_rgb=False)
957
+ first = _get_frame(src0, int(keep_idx[0]), roi=roi, debayer=debayer, to_float01=True, force_rgb=False, bayer_pattern=bayer_pattern)
912
958
  acc_shape = first.shape # (H,W) or (H,W,3)
913
959
  finally:
914
960
  if owns0:
@@ -986,7 +1032,7 @@ def stack_ser(
986
1032
  wacc = 0.0
987
1033
 
988
1034
  for i in chunk:
989
- img = src.get_frame(int(i), roi=roi, debayer=debayer, to_float01=True, force_rgb=False).astype(np.float32, copy=False)
1035
+ img = _get_frame(src, int(i), roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern).astype(np.float32, copy=False)
990
1036
 
991
1037
  # Global prior (from Analyze)
992
1038
  gdx = float(analysis.dx[int(i)]) if (analysis.dx is not None) else 0.0
@@ -1127,6 +1173,7 @@ def _build_reference(
1127
1173
  to_rgb: bool,
1128
1174
  ref_mode: str,
1129
1175
  ref_count: int,
1176
+ bayer_pattern=None,
1130
1177
  ) -> np.ndarray:
1131
1178
  """
1132
1179
  ref_mode:
@@ -1134,7 +1181,7 @@ def _build_reference(
1134
1181
  - "best_stack": return mean of best ref_count frames
1135
1182
  """
1136
1183
  best_idx = int(order[0])
1137
- f0 = src.get_frame(best_idx, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb))
1184
+ f0 = _get_frame(src, best_idx, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern)
1138
1185
  if ref_mode != "best_stack" or ref_count <= 1:
1139
1186
  return f0.astype(np.float32, copy=False)
1140
1187
 
@@ -1142,7 +1189,7 @@ def _build_reference(
1142
1189
  acc = np.zeros_like(f0, dtype=np.float32)
1143
1190
  for j in range(k):
1144
1191
  idx = int(order[j])
1145
- fr = src.get_frame(idx, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb))
1192
+ fr = _get_frame(src, idx, roi=roi, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb), bayer_pattern=bayer_pattern)
1146
1193
  acc += fr.astype(np.float32, copy=False)
1147
1194
  ref = acc / float(k)
1148
1195
  return np.clip(ref, 0.0, 1.0).astype(np.float32, copy=False)
@@ -1168,6 +1215,7 @@ def analyze_ser(
1168
1215
  smooth_sigma: float = 1.5, # kept for API compat
1169
1216
  thresh_pct: float = 92.0, # kept for API compat
1170
1217
  ref_mode: str = "best_frame", # "best_frame" or "best_stack"
1218
+ bayer_pattern: Optional[str] = None,
1171
1219
  ref_count: int = 5,
1172
1220
  max_dim: int = 512,
1173
1221
  progress_cb=None,
@@ -1187,7 +1235,10 @@ def analyze_ser(
1187
1235
  (B) AP search+refine that follows coarse, with outlier rejection,
1188
1236
  (C) robust median -> final dx/dy/conf
1189
1237
  """
1238
+
1190
1239
  source_obj = _cfg_get_source(cfg)
1240
+ bpat = bayer_pattern or _cfg_bayer_pattern(cfg)
1241
+
1191
1242
  if not source_obj:
1192
1243
  raise ValueError("SERStackConfig.source/ser_path is empty")
1193
1244
 
@@ -1254,12 +1305,13 @@ def analyze_ser(
1254
1305
  src, owns = _ensure_source(source_obj, cache_items=0)
1255
1306
  try:
1256
1307
  for i in chunk.tolist():
1257
- img = src.get_frame(
1258
- int(i),
1308
+ img = _get_frame(
1309
+ src, int(i),
1259
1310
  roi=roi_used,
1260
1311
  debayer=debayer,
1261
1312
  to_float01=True,
1262
1313
  force_rgb=bool(to_rgb),
1314
+ bayer_pattern=bpat,
1263
1315
  )
1264
1316
  m = _downsample_mono01(img, max_dim=max_dim)
1265
1317
 
@@ -1305,9 +1357,12 @@ def analyze_ser(
1305
1357
  try:
1306
1358
  if cfg.track_mode == "surface":
1307
1359
  # Surface ref must be frame 0 in roi_used coords
1308
- ref_img = src_ref.get_frame(
1309
- 0, roi=roi_used, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb)
1360
+ ref_img = _get_frame(
1361
+ src_ref, 0,
1362
+ roi=roi_used, debayer=debayer, to_float01=True, force_rgb=bool(to_rgb),
1363
+ bayer_pattern=bpat,
1310
1364
  ).astype(np.float32, copy=False)
1365
+
1311
1366
  ref_mode = "first_frame"
1312
1367
  ref_count = 1
1313
1368
  else:
@@ -1319,7 +1374,9 @@ def analyze_ser(
1319
1374
  to_rgb=to_rgb,
1320
1375
  ref_mode=ref_mode,
1321
1376
  ref_count=ref_count,
1377
+ bayer_pattern=bpat, # ✅ add this
1322
1378
  ).astype(np.float32, copy=False)
1379
+
1323
1380
  finally:
1324
1381
  if owns_ref:
1325
1382
  try:
@@ -1384,6 +1441,7 @@ def analyze_ser(
1384
1441
  roi_used=roi_used, # ✅ NEW
1385
1442
  debayer=debayer,
1386
1443
  to_rgb=to_rgb,
1444
+ bayer_pattern=bpat,
1387
1445
  progress_cb=progress_cb,
1388
1446
  progress_every=25,
1389
1447
  down=2,
@@ -1426,12 +1484,13 @@ def analyze_ser(
1426
1484
  src, owns = _ensure_source(source_obj, cache_items=0)
1427
1485
  try:
1428
1486
  for i in chunk.tolist():
1429
- img = src.get_frame(
1430
- int(i),
1487
+ img = _get_frame(
1488
+ src, int(i),
1431
1489
  roi=roi_used,
1432
1490
  debayer=debayer,
1433
1491
  to_float01=True,
1434
1492
  force_rgb=bool(to_rgb),
1493
+ bayer_pattern=bpat,
1435
1494
  )
1436
1495
  cur_m = _to_mono01(img).astype(np.float32, copy=False)
1437
1496
 
@@ -1554,12 +1613,13 @@ def analyze_ser(
1554
1613
  src, owns = _ensure_source(source_obj, cache_items=0)
1555
1614
  try:
1556
1615
  for i in chunk.tolist():
1557
- img = src.get_frame(
1558
- int(i),
1616
+ img = _get_frame(
1617
+ src, int(i),
1559
1618
  roi=roi_used,
1560
1619
  debayer=debayer,
1561
1620
  to_float01=True,
1562
1621
  force_rgb=bool(to_rgb),
1622
+ bayer_pattern=bpat,
1563
1623
  )
1564
1624
 
1565
1625
  dx_i, dy_i, cf_i = tracker.shift_to_ref(img, ref_center)
@@ -1647,6 +1707,8 @@ def realign_ser(
1647
1707
  - recompute coarse drift (ref-locked) on roi_track
1648
1708
  - refine via AP search+refine FOLLOWING coarse + outlier rejection
1649
1709
  """
1710
+ bpat = bayer_pattern or _cfg_bayer_pattern(cfg)
1711
+
1650
1712
  if analysis is None:
1651
1713
  raise ValueError("analysis is None")
1652
1714
  if analysis.ref_image is None:
@@ -1748,6 +1810,7 @@ def realign_ser(
1748
1810
  roi_used=roi_used, # ✅ NEW
1749
1811
  debayer=debayer,
1750
1812
  to_rgb=to_rgb,
1813
+ bayer_pattern=bpat,
1751
1814
  progress_cb=progress_cb,
1752
1815
  progress_every=25,
1753
1816
  down=2,
@@ -1776,12 +1839,13 @@ def realign_ser(
1776
1839
  src, owns = _ensure_source(source_obj, cache_items=0)
1777
1840
  try:
1778
1841
  for i in chunk.tolist():
1779
- img = src.get_frame(
1780
- int(i),
1842
+ img = _get_frame(
1843
+ src, int(i),
1781
1844
  roi=roi_used,
1782
1845
  debayer=debayer,
1783
1846
  to_float01=True,
1784
1847
  force_rgb=bool(to_rgb),
1848
+ bayer_pattern=bpat,
1785
1849
  )
1786
1850
  cur_m = _to_mono01(img).astype(np.float32, copy=False)
1787
1851
 
@@ -1902,12 +1966,13 @@ def realign_ser(
1902
1966
  src, owns = _ensure_source(source_obj, cache_items=0)
1903
1967
  try:
1904
1968
  for i in chunk.tolist():
1905
- img = src.get_frame(
1906
- int(i),
1969
+ img = _get_frame(
1970
+ src, int(i),
1907
1971
  roi=roi_used,
1908
1972
  debayer=debayer,
1909
1973
  to_float01=True,
1910
1974
  force_rgb=bool(to_rgb),
1975
+ bayer_pattern=bpat,
1911
1976
  )
1912
1977
 
1913
1978
  dx_i, dy_i, cf_i = tracker.shift_to_ref(img, ref_center)
@@ -252,6 +252,12 @@ class APEditorDialog(QDialog):
252
252
  self.spin_ap_spacing.setRange(8, 256)
253
253
  self.spin_ap_spacing.setSingleStep(8)
254
254
  self.spin_ap_spacing.setValue(int(self._ap_spacing))
255
+ self.spin_ap_min_mean = QDoubleSpinBox(self)
256
+ self.spin_ap_min_mean.setRange(0.0, 1.0)
257
+ self.spin_ap_min_mean.setDecimals(3)
258
+ self.spin_ap_min_mean.setSingleStep(0.005)
259
+ self.spin_ap_min_mean.setValue(float(self._ap_min_mean))
260
+ self.spin_ap_min_mean.setToolTip("Minimum mean intensity (0..1) required for an AP tile to be placed.")
255
261
 
256
262
  ap_row.addWidget(self.lbl_ap)
257
263
  ap_row.addSpacing(6)
@@ -260,6 +266,9 @@ class APEditorDialog(QDialog):
260
266
  ap_row.addSpacing(10)
261
267
  ap_row.addWidget(QLabel("Spacing", self))
262
268
  ap_row.addWidget(self.spin_ap_spacing)
269
+ ap_row.addSpacing(10)
270
+ ap_row.addWidget(QLabel("Min mean", self))
271
+ ap_row.addWidget(self.spin_ap_min_mean)
263
272
  ap_row.addStretch(1)
264
273
 
265
274
  outer.addLayout(ap_row, 0)
@@ -295,6 +304,7 @@ class APEditorDialog(QDialog):
295
304
  self._ap_debounce.setSingleShot(True)
296
305
  self._ap_debounce.setInterval(250) # ms
297
306
  self._ap_debounce.timeout.connect(self._apply_ap_params_and_relayout)
307
+ self.spin_ap_min_mean.valueChanged.connect(self._schedule_ap_relayout)
298
308
 
299
309
  # apply redraw when changed
300
310
  self.spin_ap_size.valueChanged.connect(self._schedule_ap_relayout)
@@ -331,11 +341,13 @@ class APEditorDialog(QDialog):
331
341
  # Commit params
332
342
  self._ap_size = int(self.spin_ap_size.value())
333
343
  self._ap_spacing = int(self.spin_ap_spacing.value())
344
+ self._ap_min_mean = float(self.spin_ap_min_mean.value())
334
345
 
335
346
  # Re-autoplace using the updated params
336
347
  self._do_autoplace()
337
348
 
338
349
 
350
+
339
351
  def showEvent(self, e):
340
352
  super().showEvent(e)
341
353
  if self._fit_pending:
@@ -706,6 +718,7 @@ class _AnalyzeWorker(QThread):
706
718
  self.cfg,
707
719
  debayer=self.debayer,
708
720
  to_rgb=self.to_rgb,
721
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None),
709
722
  ref_mode=self.ref_mode,
710
723
  ref_count=self.ref_count,
711
724
  progress_cb=cb,
@@ -737,19 +750,21 @@ class _StackWorker(QThread):
737
750
  self.cfg.source,
738
751
  roi=self.cfg.roi,
739
752
  debayer=self.debayer,
753
+ to_rgb=self.to_rgb,
754
+ bayer_pattern=getattr(self.cfg, "bayer_pattern", None), # ✅ add this
740
755
  keep_percent=float(getattr(self.cfg, "keep_percent", 20.0)),
741
756
  track_mode=str(getattr(self.cfg, "track_mode", "planetary")),
742
757
  surface_anchor=getattr(self.cfg, "surface_anchor", None),
743
758
  analysis=self.analysis,
744
759
  local_warp=True,
745
760
  progress_cb=cb,
746
-
747
761
  drizzle_scale=float(getattr(self.cfg, "drizzle_scale", 1.0)),
748
762
  drizzle_pixfrac=float(getattr(self.cfg, "drizzle_pixfrac", 0.80)),
749
763
  drizzle_kernel=str(getattr(self.cfg, "drizzle_kernel", "gaussian")),
750
764
  drizzle_sigma=float(getattr(self.cfg, "drizzle_sigma", 0.0)),
751
765
  )
752
766
 
767
+
753
768
  self.finished_ok.emit(out, diag)
754
769
  except Exception as e:
755
770
  msg = f"{e}\n\n{traceback.format_exc()}"
@@ -810,12 +825,14 @@ class SERStackerDialog(QDialog):
810
825
  surface_anchor=None,
811
826
  debayer: bool = True,
812
827
  keep_percent: float = 20.0,
828
+ bayer_pattern: Optional[str] = None,
813
829
  ):
814
830
  super().__init__(parent)
815
831
  self.setWindowTitle("Planetary Stacker - Beta")
816
832
  self.setWindowFlag(Qt.WindowType.Window, True)
817
833
  self.setWindowModality(Qt.WindowModality.NonModal)
818
834
  self.setModal(False)
835
+ self._bayer_pattern = bayer_pattern
819
836
  # ---- Normalize inputs ------------------------------------------------
820
837
  # If caller provided only `source`, treat string-source as ser_path too.
821
838
  if source is None:
@@ -1185,6 +1202,7 @@ class SERStackerDialog(QDialog):
1185
1202
  try:
1186
1203
  self.spin_ap_size.setValue(int(dlg.ap_size()))
1187
1204
  self.spin_ap_spacing.setValue(int(dlg.ap_spacing()))
1205
+ self.spin_ap_min.setValue(float(dlg.ap_min_mean()))
1188
1206
  except Exception:
1189
1207
  pass
1190
1208
 
@@ -1377,6 +1395,7 @@ class SERStackerDialog(QDialog):
1377
1395
  track_mode=self._track_mode_value(),
1378
1396
  surface_anchor=self._surface_anchor,
1379
1397
  keep_percent=float(self.spin_keep.value()),
1398
+ bayer_pattern=self._bayer_pattern,
1380
1399
 
1381
1400
  ap_size=int(self.spin_ap_size.value()),
1382
1401
  ap_spacing=int(self.spin_ap_spacing.value()),
@@ -163,6 +163,9 @@ class SERViewer(QDialog):
163
163
 
164
164
  self.chk_debayer = QCheckBox("Debayer (Bayer SER)", self)
165
165
  self.chk_debayer.setChecked(True)
166
+ self.cmb_bayer = QComboBox(self)
167
+ self.cmb_bayer.addItems(["AUTO", "RGGB", "GRBG", "GBRG", "BGGR"])
168
+ self.cmb_bayer.setCurrentText("AUTO") # ✅ default for raw mosaic AVI
166
169
 
167
170
  self.chk_autostretch = QCheckBox("Autostretch preview (linked)", self)
168
171
  self.chk_autostretch.setChecked(False)
@@ -188,6 +191,7 @@ class SERViewer(QDialog):
188
191
  form.addRow("ROI size", row2)
189
192
 
190
193
  form.addRow("", self.chk_debayer)
194
+ form.addRow("Bayer pattern", self.cmb_bayer)
191
195
  form.addRow("", self.chk_autostretch)
192
196
 
193
197
  # --- Preview tone controls (DISPLAY ONLY) ---
@@ -264,7 +268,9 @@ class SERViewer(QDialog):
264
268
 
265
269
  self.cmb_track.currentIndexChanged.connect(self._on_track_mode_changed)
266
270
  self.btn_stack.clicked.connect(self._open_stacker_clicked)
267
-
271
+ self.cmb_bayer.currentIndexChanged.connect(self._refresh)
272
+ self.chk_debayer.toggled.connect(lambda v: self.cmb_bayer.setEnabled(bool(v)))
273
+ self.cmb_bayer.setEnabled(self.chk_debayer.isChecked())
268
274
  self.resize(1200, 800)
269
275
 
270
276
 
@@ -728,19 +734,28 @@ class SERViewer(QDialog):
728
734
  except Exception:
729
735
  current_doc = None
730
736
 
737
+ debayer = bool(self.chk_debayer.isChecked())
738
+
739
+ # Normalize: "AUTO" means "let the loader decide"
740
+ bp = self.cmb_bayer.currentText().strip().upper()
741
+ if not debayer or bp == "AUTO":
742
+ bp = None
743
+
731
744
  dlg = SERStackerDialog(
732
745
  parent=self,
733
746
  main=main,
734
747
  source_doc=current_doc,
735
- ser_path=ser_path, # optional; OK if None
736
- source=source, # ✅ list[str] for PNG sequences
748
+ ser_path=ser_path,
749
+ source=source,
737
750
  roi=roi,
738
751
  track_mode=self._track_mode_value(),
739
752
  surface_anchor=anchor,
740
- debayer=bool(self.chk_debayer.isChecked()),
753
+ debayer=debayer,
754
+ bayer_pattern=bp, # ✅ THIS IS THE FIX
741
755
  keep_percent=float(self.spin_keep.value()),
742
756
  )
743
757
 
758
+
744
759
  dlg.stackProduced.connect(self._on_stacker_produced)
745
760
  dlg.show()
746
761
  dlg.raise_()
@@ -1119,8 +1134,9 @@ class SERViewer(QDialog):
1119
1134
  self._cur,
1120
1135
  roi=roi,
1121
1136
  debayer=debayer,
1122
- to_float01=True, # float32 [0..1]
1137
+ to_float01=True,
1123
1138
  force_rgb=False,
1139
+ bayer_pattern=self.cmb_bayer.currentText(), # ✅ NEW
1124
1140
  )
1125
1141
  except Exception as e:
1126
1142
  QMessageBox.warning(self, "SER Viewer", f"Frame read failed:\n{e}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: setiastrosuitepro
3
- Version: 1.7.0
3
+ Version: 1.7.0.post2
4
4
  Summary: Seti Astro Suite Pro - Advanced astrophotography toolkit for image calibration, stacking, registration, photometry, and visualization
5
5
  License: GPL-3.0
6
6
  License-File: LICENSE
@@ -180,7 +180,7 @@ setiastro/qml/ResourceMonitor.qml,sha256=k9_qXKAZLi8vj-5BffJTJu_UkRnxunZKn53Hthd
180
180
  setiastro/saspro/__init__.py,sha256=C6Puq5PQr3n0aJ-2i_YAaUhTjPI-pcSzie3_tij3hhw,608
181
181
  setiastro/saspro/__main__.py,sha256=eghNEGSQqHQjNp0BY84T-nhQMPYSnWdIN_eRFvbLj3Q,38487
182
182
  setiastro/saspro/_generated/__init__.py,sha256=HbruQfKNbbVL4kh_t4oVG3UeUieaW8MUaqIcDCmnTvA,197
183
- setiastro/saspro/_generated/build_info.py,sha256=kQnJZOy40C_W1DU5MsPeb6omw-IxFqtehqjwaOcdg3w,111
183
+ setiastro/saspro/_generated/build_info.py,sha256=AzyXhiQpAdluKi6-5HJ5_ryjz9slXCnNho7W4Ff0wB4,117
184
184
  setiastro/saspro/abe.py,sha256=ao9UiOneVvgubI19TCE4gk2FNfW_E3D8rr0D_bmQOwI,59779
185
185
  setiastro/saspro/abe_preset.py,sha256=u9t16yTb9v98tLjhvh496Fsp3Z-dNiBSs5itnAaJwh8,7750
186
186
  setiastro/saspro/aberration_ai.py,sha256=o-l0NQFy8YJXqCUAnp_fT7pjxfYgrCAGircjw3KtMuU,37196
@@ -234,16 +234,16 @@ setiastro/saspro/ghs_preset.py,sha256=Zw3MJH5rEz7nqLdlmRBm3vYXgyopoupyDGAhM-PRXq
234
234
  setiastro/saspro/graxpert.py,sha256=XSLeBhlAY2DkYUw93j2OEOuPLOPfzWYcT5Dz3JhhpW8,22579
235
235
  setiastro/saspro/graxpert_preset.py,sha256=ESds2NPMPfsBHWNBfyYZ1rFpQxZ9gPOtxwz8enHfFfc,10719
236
236
  setiastro/saspro/gui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
237
- setiastro/saspro/gui/main_window.py,sha256=zrDla3zdhpdsGLEf29ZCrqiMiRDbP8VX1_EYs8_vJG8,370052
237
+ setiastro/saspro/gui/main_window.py,sha256=_2BS65Q10AXCtoZXkX4cSi-_XnxMkJtpW35Onil0yM0,372789
238
238
  setiastro/saspro/gui/mixins/__init__.py,sha256=ubdTIri0xoRs6MwDnEaVsAHbMxuPqz0CZcYcue3Mkcc,836
239
239
  setiastro/saspro/gui/mixins/dock_mixin.py,sha256=C6IWKM-uP2ekqO7ozVQQcTnEgAkUL_OaRZbncIdrh8g,23893
240
240
  setiastro/saspro/gui/mixins/file_mixin.py,sha256=ZzKzJhnNKjbi7UQHFhq4Nr2tNLRbB_fQeMHcOj09BU8,17714
241
241
  setiastro/saspro/gui/mixins/geometry_mixin.py,sha256=x-HyLXGFhEs8SJuXT8EF6tS3XJaH8IhAP-ZFJ2BS2Lw,19038
242
242
  setiastro/saspro/gui/mixins/header_mixin.py,sha256=kfZUJviB61c8NBXFB3MVBEcRPX_36ZV8tUZNJkDmMJ0,17253
243
243
  setiastro/saspro/gui/mixins/mask_mixin.py,sha256=hrC5jLWxUZgQiYUXp7nvECo2uiarK7_HKgawG4HGB9w,15711
244
- setiastro/saspro/gui/mixins/menu_mixin.py,sha256=nVuJbNZdVTYgq11nOxxCbbqjN6ugBI1QnPULhU3fdXo,16590
244
+ setiastro/saspro/gui/mixins/menu_mixin.py,sha256=7vDRszE6Q7w9odRzoBxmn_gBQBTuRAwEQcdFDPLzt3I,16636
245
245
  setiastro/saspro/gui/mixins/theme_mixin.py,sha256=td6hYnZn17lXlFxQOXgK7-qfKo6CIJACF8_zrULlEkc,20740
246
- setiastro/saspro/gui/mixins/toolbar_mixin.py,sha256=8n1kkruEaAYSMm_jZUtaxQnyfn4AnJZcapqqTY5qhFU,91938
246
+ setiastro/saspro/gui/mixins/toolbar_mixin.py,sha256=m4qGqiQolMqqipdrKseXc0-eW7GeD0keBkjrrHhz6Ys,92180
247
247
  setiastro/saspro/gui/mixins/update_mixin.py,sha256=rq7J0Pcn_gO8UehCGVtcSvf-MDbmeGf796h0JBGr0sM,16545
248
248
  setiastro/saspro/gui/mixins/view_mixin.py,sha256=EacXxtoAvkectGgLiahf3rHv2AaqKDQUteocVV_P834,17850
249
249
  setiastro/saspro/gui/statistics_dialog.py,sha256=celhcsHcp3lNpox_X0ByAiYE80qFaF-dYyP8StzgrcU,1955
@@ -259,7 +259,7 @@ setiastro/saspro/imageops/__init__.py,sha256=CE9mHOsruHOQ5bYOHr1_fhxd9sdK1W9BpAi
259
259
  setiastro/saspro/imageops/mdi_snap.py,sha256=Xyogrv3N0KRAKcfC65hy_PKG6ZktjmwDBisCQ_cP9pY,10117
260
260
  setiastro/saspro/imageops/narrowband_normalization.py,sha256=q1KdHS7bjRYW-N0Jbszxf0TF38tNNgXKknM80lrzUsA,26793
261
261
  setiastro/saspro/imageops/scnr.py,sha256=jLHxBU4KQ9Mu61Cym5YUq0pwxajJmGrPQRV2pug9LXE,1330
262
- setiastro/saspro/imageops/serloader.py,sha256=EyrvI8IKugP_U3JQI7Cm63qw3iBk52ssUw05pOrrOEk,23576
262
+ setiastro/saspro/imageops/serloader.py,sha256=OBLD0G4UJuHgY946NE7ViYiarPKOKWfWw9uCxBlZYsM,32374
263
263
  setiastro/saspro/imageops/starbasedwhitebalance.py,sha256=urssLdgKD7J_LOuscjbf9zFImnUWoH-sotGvtbxfxTo,6589
264
264
  setiastro/saspro/imageops/stretch.py,sha256=E_Ydh5VxUHrehwivhvVDvF8y89U6SRyjIFUQgJjHWXI,27113
265
265
  setiastro/saspro/isophote.py,sha256=eSzlyBN_tZJSJTKlnBjwHY0zdw0SXPV6BRk_jt9TlVU,53753
@@ -320,11 +320,11 @@ setiastro/saspro/runtime_imports.py,sha256=tgIHH10cdAEbCTOmcs7retQhAww2dEc_3mKrv
320
320
  setiastro/saspro/runtime_torch.py,sha256=IEVqD8jgn6la8t3R9ngZ2egSGMAC_tr1im3R8BnvXTQ,33497
321
321
  setiastro/saspro/save_options.py,sha256=HPMXl5ya7HlCgDle9mPUrthzIFVJWXQGrea_OkcHg6c,2722
322
322
  setiastro/saspro/selective_color.py,sha256=hx-9X4jeso797ifoBmwp9hNagPW4cvNPs9-T0JLHce0,64804
323
- setiastro/saspro/ser_stack_config.py,sha256=nmeowk9I3a4gCFfhddd-etYGIhaS1_5zWG1LOY6c7JE,2864
324
- setiastro/saspro/ser_stacker.py,sha256=JGFLZ0XRsuotd320dkP5sEIQqSA-kafwPxjbbkZF2Rk,82048
325
- setiastro/saspro/ser_stacker_dialog.py,sha256=SkecUJHp3BG1PrNF2XDj5n2PkqQTWs4SCf4sKVLlBh8,54663
323
+ setiastro/saspro/ser_stack_config.py,sha256=s5eaxMB6wuCButMkSyz1-VlOysJZ_vmGC4IA6ZPmiSw,3209
324
+ setiastro/saspro/ser_stacker.py,sha256=I2nHACDtvfeSeDxg6rfWvfMYBZ_7XcAly1XfVEso7bQ,84259
325
+ setiastro/saspro/ser_stacker_dialog.py,sha256=QLrD6-wD4oCyXV6gmtXrZP0jh7hq9WyOeoswqG3zm2A,55743
326
326
  setiastro/saspro/ser_tracking.py,sha256=z5jHJIs0t3kK3p5C330vwz0c6zJ9KXtKIh1FlJ1LljU,7141
327
- setiastro/saspro/serviewer.py,sha256=q8cLpXvoZLJEcDcUtkaqzhQrrLmHXwGtMLV0zMZBlWY,47678
327
+ setiastro/saspro/serviewer.py,sha256=HkRPKq9sTX-NEVGS5bf32VtbcJRXJh7SA4ZokfZ1e_Q,48413
328
328
  setiastro/saspro/sfcc.py,sha256=mKdNfbMxl4fgqyZnuAlkzNNIU3huYOHgqw0FGjtVhUs,89097
329
329
  setiastro/saspro/shortcuts.py,sha256=QvFBXN_S8jqEwaP9m4pJMLVqzBmxo5HrjWhVCV9etQg,138256
330
330
  setiastro/saspro/signature_insert.py,sha256=pWDxUO1Rxm27_fHSo2Y99bdOD2iG9q4AUjGR20x6TiA,70401
@@ -403,9 +403,9 @@ setiastro/saspro/wimi.py,sha256=CNo833Pur9P-A1DUSntlAaQWekf6gzWIvetOMvLDrOw,3146
403
403
  setiastro/saspro/wims.py,sha256=HDfVI3Ckf5OJEJLH8NI36pFc2USZnETpb4UDIvweNX4,27450
404
404
  setiastro/saspro/window_shelf.py,sha256=jQUifB3uJ9tkzXqmscWj8lHQN5E8yleuRc7hDnes4-k,7453
405
405
  setiastro/saspro/xisf.py,sha256=Ah1CXDAohN__ej1Lq7LPU8vGLnDz8fluLQTGE71aUoc,52669
406
- setiastrosuitepro-1.7.0.dist-info/entry_points.txt,sha256=vJfnZaV6Uj3laETakqT8-D-aJZI_nYIicrhSoL0uuko,227
407
- setiastrosuitepro-1.7.0.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
408
- setiastrosuitepro-1.7.0.dist-info/licenses/license.txt,sha256=KCwYZ9VpVwmzjelDq1BzzWqpBvt9nbbapa-woz47hfQ,123930
409
- setiastrosuitepro-1.7.0.dist-info/METADATA,sha256=mPXJiGyemn95E0TPqW0gpRKFSO70MR9EJgceMJVcY30,9834
410
- setiastrosuitepro-1.7.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
411
- setiastrosuitepro-1.7.0.dist-info/RECORD,,
406
+ setiastrosuitepro-1.7.0.post2.dist-info/entry_points.txt,sha256=vJfnZaV6Uj3laETakqT8-D-aJZI_nYIicrhSoL0uuko,227
407
+ setiastrosuitepro-1.7.0.post2.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
408
+ setiastrosuitepro-1.7.0.post2.dist-info/licenses/license.txt,sha256=KCwYZ9VpVwmzjelDq1BzzWqpBvt9nbbapa-woz47hfQ,123930
409
+ setiastrosuitepro-1.7.0.post2.dist-info/METADATA,sha256=ObLW50Nm7d43lX3bqkwNRrRjwv51_0dEY52ebyNFHW0,9840
410
+ setiastrosuitepro-1.7.0.post2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
411
+ setiastrosuitepro-1.7.0.post2.dist-info/RECORD,,