setiastrosuitepro 1.6.10__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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +305 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +972 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +74 -0
  34. setiastro/saspro/ser_stacker.py +2310 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1500 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1258 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,972 @@
1
+ # src/setiastro/saspro/imageops/serloader.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import io
6
+ import mmap
7
+ import struct
8
+ from dataclasses import dataclass
9
+ from typing import Optional, Tuple, Dict, List, Sequence, Union
10
+ from collections import OrderedDict
11
+
12
+ import numpy as np
13
+
14
+
15
+ import cv2
16
+
17
+
18
+ from PIL import Image
19
+
20
+
21
+ # ---------------------------------------------------------------------
22
+ # SER format notes (commonly used by FireCapture / SharpCap / etc.)
23
+ # - Header is 178 bytes (SER v3 style) and begins with ASCII signature
24
+ # typically "LUCAM-RECORDER" padded to 14 bytes.
25
+ # - Most fields are little-endian; header contains an "Endian" flag.
26
+ # - Frame data follows immediately after header, then optional timestamps
27
+ # (8 bytes per frame) at end.
28
+ # ---------------------------------------------------------------------
29
+
30
+ SER_HEADER_SIZE = 178
31
+ SER_SIGNATURE_LEN = 14
32
+
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
38
+ SER_COLOR = {
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",
52
+ }
53
+
54
+ BAYER_NAMES = {"BAYER_RGGB", "BAYER_GRBG", "BAYER_GBRG", "BAYER_BGGR"}
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
72
+
73
+ @dataclass
74
+ class SerMeta:
75
+ path: str
76
+ width: int
77
+ height: int
78
+ frames: int
79
+ pixel_depth: int # bits per sample (8/16 typically)
80
+ color_id: int
81
+ color_name: str
82
+ little_endian: bool
83
+ data_offset: int
84
+ frame_bytes: int
85
+ has_timestamps: bool
86
+
87
+ observer: str = ""
88
+ instrument: str = ""
89
+ telescope: str = ""
90
+
91
+
92
+ def _decode_cstr(b: bytes) -> str:
93
+ try:
94
+ return b.split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
95
+ except Exception:
96
+ return ""
97
+
98
+
99
+ def _bytes_per_sample(pixel_depth_bits: int) -> int:
100
+ return 1 if int(pixel_depth_bits) <= 8 else 2
101
+
102
+
103
+ def _is_bayer(color_name: str) -> bool:
104
+ return color_name in BAYER_NAMES
105
+
106
+
107
+ def _is_rgb(color_name: str) -> bool:
108
+ return color_name in {"RGB", "BGR", "RGBA", "BGRA"}
109
+
110
+
111
+ def _roi_evenize_for_bayer(x: int, y: int) -> Tuple[int, int]:
112
+ """Ensure ROI origin is even-even so Bayer phase doesn't flip."""
113
+ if x & 1:
114
+ x -= 1
115
+ if y & 1:
116
+ y -= 1
117
+ return max(0, x), max(0, y)
118
+
119
+
120
+ def _cv2_debayer(mosaic: np.ndarray, pattern: str) -> np.ndarray:
121
+ """
122
+ mosaic: uint8/uint16, shape (H,W)
123
+ returns: RGB uint8/uint16, shape (H,W,3)
124
+ """
125
+ if cv2 is None:
126
+ raise RuntimeError("OpenCV not available for debayer fallback.")
127
+
128
+ code_map = {
129
+ "BAYER_RGGB": cv2.COLOR_BayerRG2RGB,
130
+ "BAYER_BGGR": cv2.COLOR_BayerBG2RGB,
131
+ "BAYER_GBRG": cv2.COLOR_BayerGB2RGB,
132
+ "BAYER_GRBG": cv2.COLOR_BayerGR2RGB,
133
+ }
134
+ code = code_map.get(pattern)
135
+ if code is None:
136
+ raise ValueError(f"Unknown Bayer pattern: {pattern}")
137
+ return cv2.cvtColor(mosaic, code)
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
+
170
+
171
+ def _try_numba_debayer(mosaic: np.ndarray, pattern: str) -> Optional[np.ndarray]:
172
+ """
173
+ Try to use SASpro's fast debayer if available.
174
+ Expected functions (from your memory):
175
+ - debayer_raw_fast / debayer_fits_fast (names may differ in your tree)
176
+ We keep this very defensive; if not found, return None.
177
+ """
178
+ # Try a few likely import locations without hard failing
179
+ candidates = [
180
+ ("setiastro.saspro.imageops.debayer", "debayer_raw_fast"),
181
+ ("setiastro.saspro.imageops.debayer", "debayer_fits_fast"),
182
+ ("setiastro.saspro.imageops.debayer_fast", "debayer_raw_fast"),
183
+ ("setiastro.saspro.imageops.debayer_fast", "debayer_fits_fast"),
184
+ ]
185
+ for mod_name, fn_name in candidates:
186
+ try:
187
+ mod = __import__(mod_name, fromlist=[fn_name])
188
+ fn = getattr(mod, fn_name, None)
189
+ if fn is None:
190
+ continue
191
+
192
+ # Many fast debayers accept a mosaic and a bayer string or enum.
193
+ # We'll try a couple calling conventions.
194
+ try:
195
+ out = fn(mosaic, pattern) # type: ignore
196
+ if out is not None:
197
+ return out
198
+ except Exception:
199
+ pass
200
+
201
+ try:
202
+ out = fn(mosaic) # type: ignore
203
+ if out is not None:
204
+ return out
205
+ except Exception:
206
+ pass
207
+ except Exception:
208
+ continue
209
+ return None
210
+
211
+
212
+ class _LRUCache:
213
+ """Tiny LRU cache for decoded frames."""
214
+ def __init__(self, max_items: int = 8):
215
+ self.max_items = int(max_items)
216
+ self._d: "OrderedDict[Tuple, np.ndarray]" = OrderedDict()
217
+
218
+ def get(self, key):
219
+ if key not in self._d:
220
+ return None
221
+ self._d.move_to_end(key)
222
+ return self._d[key]
223
+
224
+ def put(self, key, value: np.ndarray):
225
+ self._d[key] = value
226
+ self._d.move_to_end(key)
227
+ while len(self._d) > self.max_items:
228
+ self._d.popitem(last=False)
229
+
230
+ def clear(self):
231
+ self._d.clear()
232
+
233
+
234
+ class SERReader:
235
+ """
236
+ Memory-mapped SER reader with:
237
+ - header parsing (common v3 layout)
238
+ - random frame access
239
+ - optional ROI (with Bayer parity protection)
240
+ - optional debayer
241
+ - tiny LRU cache for smooth preview scrubbing
242
+ """
243
+
244
+ def __init__(self, path: str, *, cache_items: int = 10):
245
+ self.path = os.fspath(path)
246
+ self._fh = open(self.path, "rb")
247
+ self._mm = mmap.mmap(self._fh.fileno(), 0, access=mmap.ACCESS_READ)
248
+
249
+ self.meta = self._parse_header(self._mm)
250
+ self.meta.path = self.path
251
+ self._cache = _LRUCache(max_items=cache_items)
252
+ self._fast_debayer_is_bgr: Optional[bool] = None
253
+
254
+ def close(self):
255
+ try:
256
+ self._cache.clear()
257
+ except Exception:
258
+ pass
259
+ try:
260
+ self._mm.close()
261
+ except Exception:
262
+ pass
263
+ try:
264
+ self._fh.close()
265
+ except Exception:
266
+ pass
267
+
268
+ def __enter__(self):
269
+ return self
270
+
271
+ def __exit__(self, exc_type, exc, tb):
272
+ self.close()
273
+
274
+ # ---------------- header parsing ----------------
275
+ @staticmethod
276
+ def _parse_header(mm: mmap.mmap) -> SerMeta:
277
+ if mm.size() < SER_HEADER_SIZE:
278
+ raise ValueError("File too small to be a SER file.")
279
+
280
+ hdr = mm[:SER_HEADER_SIZE]
281
+
282
+ sig = hdr[:SER_SIGNATURE_LEN]
283
+ sig_txt = _decode_cstr(sig)
284
+
285
+ # Be permissive: many SERs start with LUCAM-RECORDER
286
+ # If not, still try parsing.
287
+ # (Some writers use other signatures but the v3 field layout often matches.)
288
+
289
+ try:
290
+ (lu_id, color_id, little_endian_u32,
291
+ w, h, pixel_depth, frames) = struct.unpack_from("<7I", hdr, SER_SIGNATURE_LEN)
292
+ except Exception as e:
293
+ raise ValueError(f"Failed to parse SER header fields: {e}")
294
+
295
+ little_endian = bool(little_endian_u32)
296
+
297
+ observer = _decode_cstr(hdr[42:82])
298
+ instrument = _decode_cstr(hdr[82:122])
299
+ telescope = _decode_cstr(hdr[122:162])
300
+
301
+ color_name = SER_COLOR.get(int(color_id), f"UNKNOWN({color_id})")
302
+
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)
310
+
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
322
+ if color_name in {"RGB", "BGR"}:
323
+ channels = 3
324
+ frame_bytes = fb_3
325
+ elif color_name in {"RGBA", "BGRA"}:
326
+ channels = 4
327
+ frame_bytes = fb_4
328
+ else:
329
+ # MONO + Bayer variants should land here
330
+ channels = 1
331
+ frame_bytes = fb_1
332
+
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))
353
+
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"
411
+
412
+ return SerMeta(
413
+ path="",
414
+ width=int(w),
415
+ height=int(h),
416
+ frames=int(frames),
417
+ pixel_depth=int(pixel_depth),
418
+ color_id=int(color_id),
419
+ color_name=color_name,
420
+ little_endian=little_endian,
421
+ data_offset=int(data_offset),
422
+ frame_bytes=int(frame_bytes),
423
+ has_timestamps=bool(has_ts),
424
+ observer=observer,
425
+ instrument=instrument,
426
+ telescope=telescope,
427
+ )
428
+
429
+
430
+ # ---------------- core access ----------------
431
+
432
+ def frame_offset(self, i: int) -> int:
433
+ i = int(i)
434
+ if i < 0 or i >= self.meta.frames:
435
+ raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
436
+ return self.meta.data_offset + i * self.meta.frame_bytes
437
+
438
+ def get_frame(
439
+ self,
440
+ i: int,
441
+ *,
442
+ roi: Optional[Tuple[int, int, int, int]] = None,
443
+ debayer: bool = True,
444
+ to_float01: bool = False,
445
+ force_rgb: bool = False,
446
+ bayer_pattern: Optional[str] = None, # ✅ NEW
447
+ ) -> np.ndarray:
448
+ """
449
+ Returns:
450
+ - MONO: (H,W) uint8/uint16 or float32 [0..1]
451
+ - RGB: (H,W,3) uint8/uint16 or float32 [0..1]
452
+
453
+ roi is applied before debayer (and ROI origin evenized for Bayer).
454
+ """
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)
459
+
460
+ # Cache key includes ROI + flags
461
+ roi_key = None if roi is None else tuple(int(v) for v in roi)
462
+ key = (int(i), roi_key, bool(debayer), active_bayer, bool(to_float01), bool(force_rgb))
463
+ cached = self._cache.get(key)
464
+ if cached is not None:
465
+ return cached
466
+
467
+ off = self.frame_offset(i)
468
+ buf = self._mm[off:off + meta.frame_bytes]
469
+
470
+ bps = _bytes_per_sample(meta.pixel_depth)
471
+ if bps == 1:
472
+ dtype = np.uint8
473
+ else:
474
+ dtype = np.uint16
475
+
476
+ # Determine channels stored
477
+ if color_name in {"RGB", "BGR"}:
478
+ ch = 3
479
+ elif color_name in {"RGBA", "BGRA"}:
480
+ ch = 4
481
+ else:
482
+ ch = 1
483
+
484
+ arr = np.frombuffer(buf, dtype=dtype)
485
+
486
+ # byteswap if big-endian storage (rare, but spec supports it)
487
+ if (dtype == np.uint16) and (not meta.little_endian):
488
+ arr = arr.byteswap()
489
+
490
+ if ch == 1:
491
+ img = arr.reshape(meta.height, meta.width)
492
+ else:
493
+ img = arr.reshape(meta.height, meta.width, ch)
494
+
495
+ # ROI (apply before debayer; for Bayer enforce even-even origin)
496
+ if roi is not None:
497
+ x, y, w, h = [int(v) for v in roi]
498
+ x = max(0, min(meta.width - 1, x))
499
+ y = max(0, min(meta.height - 1, y))
500
+ w = max(1, min(meta.width - x, w))
501
+ h = max(1, min(meta.height - y, h))
502
+
503
+ if (active_bayer is not None) and debayer:
504
+ x, y = _roi_evenize_for_bayer(x, y)
505
+ w = max(1, min(meta.width - x, w))
506
+ h = max(1, min(meta.height - y, h))
507
+
508
+ img = img[y:y + h, x:x + w]
509
+
510
+ # Convert BGR->RGB if needed
511
+ if color_name == "BGR" and img.ndim == 3 and img.shape[2] >= 3:
512
+ img = img[..., ::-1].copy()
513
+
514
+ # Debayer if needed
515
+ if _is_bayer(color_name):
516
+ if debayer:
517
+ mosaic = img if img.ndim == 2 else img[..., 0]
518
+ pat = active_bayer or color_name # active_bayer will usually be set here
519
+
520
+ out = _try_numba_debayer(mosaic, pat)
521
+ if out is None:
522
+ out = _cv2_debayer(mosaic, pat) # already RGB
523
+ else:
524
+ out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
525
+
526
+ img = out
527
+ else:
528
+ img = img if img.ndim == 2 else img[..., 0]
529
+
530
+
531
+ # Force RGB for mono (useful for consistent preview pipeline)
532
+ if force_rgb and img.ndim == 2:
533
+ img = np.stack([img, img, img], axis=-1)
534
+
535
+ # Normalize to float01
536
+ if to_float01:
537
+ if img.dtype == np.uint8:
538
+ img = img.astype(np.float32) / 255.0
539
+ elif img.dtype == np.uint16:
540
+ img = img.astype(np.float32) / 65535.0
541
+ else:
542
+ img = img.astype(np.float32)
543
+ img = np.clip(img, 0.0, 1.0)
544
+
545
+ self._cache.put(key, img)
546
+ return img
547
+
548
+ def get_timestamp_ns(self, i: int) -> Optional[int]:
549
+ """
550
+ If timestamps exist, returns the 64-bit timestamp value for frame i.
551
+ (Interpretation depends on writer; often 100ns ticks or nanoseconds.)
552
+ """
553
+ meta = self.meta
554
+ if not meta.has_timestamps:
555
+ return None
556
+ i = int(i)
557
+ if i < 0 or i >= meta.frames:
558
+ return None
559
+ ts_base = meta.data_offset + meta.frames * meta.frame_bytes
560
+ off = ts_base + i * 8
561
+ b = self._mm[off:off + 8]
562
+ if len(b) != 8:
563
+ return None
564
+ (v,) = struct.unpack("<Q", b)
565
+ return int(v)
566
+
567
+
568
+
569
+ # -----------------------------
570
+ # Common reader interface/meta
571
+ # -----------------------------
572
+
573
+ @dataclass
574
+ class PlanetaryMeta:
575
+ """
576
+ Common metadata shape used by SERViewer / stacker.
577
+ """
578
+ path: str
579
+ width: int
580
+ height: int
581
+ frames: int
582
+ pixel_depth: int # 8/16 typical (AVI usually 8)
583
+ color_name: str # "MONO", "RGB", "BGR", "BAYER_*", etc
584
+ has_timestamps: bool = False
585
+ source_kind: str = "unknown" # "ser" / "avi" / "sequence"
586
+ file_list: Optional[List[str]] = None
587
+
588
+
589
+ class PlanetaryFrameSource:
590
+ """
591
+ Minimal protocol-like base. (Duck-typed by viewer/stacker)
592
+ """
593
+ meta: PlanetaryMeta
594
+ path: str
595
+
596
+ def close(self) -> None:
597
+ raise NotImplementedError
598
+
599
+ def get_frame(
600
+ self,
601
+ i: int,
602
+ *,
603
+ roi: Optional[Tuple[int, int, int, int]] = None,
604
+ debayer: bool = True,
605
+ to_float01: bool = False,
606
+ force_rgb: bool = False,
607
+ bayer_pattern: Optional[str] = None, # ✅ NEW
608
+ ) -> np.ndarray:
609
+ raise NotImplementedError
610
+
611
+
612
+ # -----------------------------
613
+ # AVI reader (OpenCV)
614
+ # -----------------------------
615
+
616
+ class AVIReader(PlanetaryFrameSource):
617
+ """
618
+ Frame-accurate random access using cv2.VideoCapture.
619
+ Notes:
620
+ - Many codecs only support approximate seeking; good enough for preview/scrub.
621
+ - Frames come out as BGR uint8 by default.
622
+ """
623
+ def __init__(self, path: str, *, cache_items: int = 10):
624
+ if cv2 is None:
625
+ raise RuntimeError("OpenCV (cv2) is required to read AVI files.")
626
+ self.path = os.fspath(path)
627
+ self._cap = cv2.VideoCapture(self.path)
628
+ if not self._cap.isOpened():
629
+ raise ValueError(f"Failed to open video: {self.path}")
630
+
631
+ w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
632
+ h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
633
+ n = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
634
+
635
+ # AVI decoded frames are almost always 8-bit
636
+ self.meta = PlanetaryMeta(
637
+ path=self.path,
638
+ width=w,
639
+ height=h,
640
+ frames=max(0, n),
641
+ pixel_depth=8,
642
+ color_name="BGR",
643
+ has_timestamps=False,
644
+ source_kind="avi",
645
+ )
646
+
647
+ self._cache = _LRUCache(max_items=cache_items)
648
+
649
+ def close(self):
650
+ try:
651
+ self._cache.clear()
652
+ except Exception:
653
+ pass
654
+ try:
655
+ if self._cap is not None:
656
+ self._cap.release()
657
+ except Exception:
658
+ pass
659
+
660
+ def __enter__(self):
661
+ return self
662
+
663
+ def __exit__(self, exc_type, exc, tb):
664
+ self.close()
665
+
666
+ def _read_raw_frame_bgr(self, i: int) -> np.ndarray:
667
+ i = int(i)
668
+ if i < 0 or (self.meta.frames > 0 and i >= self.meta.frames):
669
+ raise IndexError(f"Frame index {i} out of range")
670
+
671
+ # Seek
672
+ self._cap.set(cv2.CAP_PROP_POS_FRAMES, float(i))
673
+ ok, frame = self._cap.read()
674
+ if not ok or frame is None:
675
+ raise ValueError(f"Failed to read frame {i}")
676
+
677
+ # frame is BGR uint8, shape (H,W,3)
678
+ return frame
679
+
680
+ def get_frame(
681
+ self,
682
+ i: int,
683
+ *,
684
+ roi: Optional[Tuple[int, int, int, int]] = None,
685
+ debayer: bool = True,
686
+ to_float01: bool = False,
687
+ force_rgb: bool = False,
688
+ bayer_pattern: Optional[str] = None,
689
+ ) -> np.ndarray:
690
+
691
+ roi_key = None if roi is None else tuple(int(v) for v in roi)
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))
700
+ cached = self._cache.get(key)
701
+ if cached is not None:
702
+ return cached
703
+
704
+ frame = self._read_raw_frame_bgr(i) # usually (H,W,3) uint8 BGR
705
+
706
+ # ROI first (but if we are going to debayer mosaic, ROI origin must be even-even)
707
+ if roi is not None:
708
+ x, y, w, h = [int(v) for v in roi]
709
+ H, W = frame.shape[:2]
710
+ x = max(0, min(W - 1, x))
711
+ y = max(0, min(H - 1, y))
712
+ w = max(1, min(W - x, w))
713
+ h = max(1, min(H - y, h))
714
+
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"
749
+
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
757
+
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
769
+ if to_float01:
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)
780
+
781
+ self._cache.put(key, img)
782
+ return img
783
+
784
+ # -----------------------------
785
+ # Image-sequence reader
786
+ # -----------------------------
787
+
788
+ def _imread_any(path: str) -> np.ndarray:
789
+ """
790
+ Read PNG/JPG/TIF/etc into numpy.
791
+ Tries cv2 first (fast), falls back to PIL.
792
+ Returns:
793
+ - grayscale: (H,W) uint8/uint16
794
+ - color: (H,W,3) uint8/uint16 in RGB (we normalize to RGB)
795
+ """
796
+ p = os.fspath(path)
797
+
798
+ # Prefer cv2 if available
799
+ if cv2 is not None:
800
+ img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
801
+ if img is not None:
802
+ # cv2 gives:
803
+ # - gray: HxW
804
+ # - color: HxWx3 (BGR)
805
+ # - sometimes HxWx4 (BGRA)
806
+ if img.ndim == 3 and img.shape[2] >= 3:
807
+ img = img[..., :3] # drop alpha if present
808
+ img = img[..., ::-1].copy() # BGR -> RGB
809
+ return img
810
+
811
+ # PIL fallback
812
+ if Image is None:
813
+ raise RuntimeError("Neither OpenCV nor PIL are available to read images.")
814
+ im = Image.open(p)
815
+ # Preserve 16-bit if possible; PIL handles many TIFFs.
816
+ if im.mode in ("I;16", "I;16B", "I"):
817
+ arr = np.array(im)
818
+ return arr
819
+ if im.mode in ("L",):
820
+ return np.array(im)
821
+ im = im.convert("RGB")
822
+ return np.array(im)
823
+
824
+
825
+ def _infer_bit_depth(arr: np.ndarray) -> int:
826
+ if arr.dtype == np.uint16:
827
+ return 16
828
+ if arr.dtype == np.uint8:
829
+ return 8
830
+ # if float, assume 32 for “depth”
831
+ if arr.dtype in (np.float32, np.float64):
832
+ return 32
833
+ return 8
834
+
835
+
836
+ class ImageSequenceReader(PlanetaryFrameSource):
837
+ """
838
+ Reads a list of image files as frames.
839
+ Supports random access; caches decoded frames for smooth scrubbing.
840
+ """
841
+ def __init__(self, files: Sequence[str], *, cache_items: int = 10):
842
+ flist = [os.fspath(f) for f in files]
843
+ if not flist:
844
+ raise ValueError("Empty image sequence.")
845
+ self.files = flist
846
+ self.path = flist[0]
847
+
848
+ # Probe first frame
849
+ first = _imread_any(flist[0])
850
+ h, w = first.shape[:2]
851
+ depth = _infer_bit_depth(first)
852
+ if first.ndim == 2:
853
+ cname = "MONO"
854
+ else:
855
+ cname = "RGB"
856
+
857
+ self.meta = PlanetaryMeta(
858
+ path=self.path,
859
+ width=int(w),
860
+ height=int(h),
861
+ frames=len(flist),
862
+ pixel_depth=int(depth),
863
+ color_name=cname,
864
+ has_timestamps=False,
865
+ source_kind="sequence",
866
+ file_list=list(flist),
867
+ )
868
+
869
+ self._cache = _LRUCache(max_items=cache_items)
870
+
871
+ def close(self):
872
+ try:
873
+ self._cache.clear()
874
+ except Exception:
875
+ pass
876
+
877
+ def __enter__(self):
878
+ return self
879
+
880
+ def __exit__(self, exc_type, exc, tb):
881
+ self.close()
882
+
883
+ def get_frame(
884
+ self,
885
+ i: int,
886
+ *,
887
+ roi: Optional[Tuple[int, int, int, int]] = None,
888
+ debayer: bool = True,
889
+ to_float01: bool = False,
890
+ force_rgb: bool = False,
891
+ bayer_pattern: Optional[str] = None,
892
+ ) -> np.ndarray:
893
+ _ = debayer, bayer_pattern # unused for sequences (for now)
894
+ i = int(i)
895
+ if i < 0 or i >= self.meta.frames:
896
+ raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
897
+
898
+ roi_key = None if roi is None else tuple(int(v) for v in roi)
899
+ key = ("seq", i, roi_key, bool(to_float01), bool(force_rgb))
900
+ cached = self._cache.get(key)
901
+ if cached is not None:
902
+ return cached
903
+
904
+ img = _imread_any(self.files[i])
905
+
906
+ # Basic consistency checks (don’t hard fail; some sequences have slight differences)
907
+ # If sizes differ, we’ll just use whatever comes back for that frame.
908
+ H, W = img.shape[:2]
909
+
910
+ # ROI
911
+ if roi is not None:
912
+ x, y, w, h = [int(v) for v in roi]
913
+ x = max(0, min(W - 1, x))
914
+ y = max(0, min(H - 1, y))
915
+ w = max(1, min(W - x, w))
916
+ h = max(1, min(H - y, h))
917
+ img = img[y:y + h, x:x + w]
918
+
919
+ # Force RGB for mono
920
+ if force_rgb and img.ndim == 2:
921
+ img = np.stack([img, img, img], axis=-1)
922
+
923
+ # Normalize to float01
924
+ if to_float01:
925
+ if img.dtype == np.uint8:
926
+ img = img.astype(np.float32) / 255.0
927
+ elif img.dtype == np.uint16:
928
+ img = img.astype(np.float32) / 65535.0
929
+ else:
930
+ img = img.astype(np.float32)
931
+ img = np.clip(img, 0.0, 1.0)
932
+
933
+ self._cache.put(key, img)
934
+ return img
935
+
936
+
937
+ # -----------------------------
938
+ # Factory
939
+ # -----------------------------
940
+
941
+ def open_planetary_source(
942
+ path_or_files: Union[str, Sequence[str]],
943
+ *,
944
+ cache_items: int = 10,
945
+ ) -> PlanetaryFrameSource:
946
+ """
947
+ Open SER / AVI / image sequence under one API.
948
+ """
949
+ # Sequence
950
+ if not isinstance(path_or_files, (str, os.PathLike)):
951
+ return ImageSequenceReader(path_or_files, cache_items=cache_items)
952
+
953
+ path = os.fspath(path_or_files)
954
+ ext = os.path.splitext(path)[1].lower()
955
+
956
+ if ext == ".ser":
957
+ r = SERReader(path, cache_items=cache_items)
958
+ # ---- SER tweak: ensure meta.path is set ----
959
+ try:
960
+ r.meta.path = path # type: ignore
961
+ except Exception:
962
+ pass
963
+ return r
964
+
965
+ if ext in (".avi", ".mp4", ".mov", ".mkv"):
966
+ return AVIReader(path, cache_items=cache_items)
967
+
968
+ # If user passes a single image, treat it as a 1-frame sequence
969
+ if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp"):
970
+ return ImageSequenceReader([path], cache_items=cache_items)
971
+
972
+ raise ValueError(f"Unsupported input: {path}")