setiastrosuitepro 1.6.12__py3-none-any.whl → 1.7.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of setiastrosuitepro might be problematic. Click here for more details.

Files changed (51) hide show
  1. setiastro/images/3dplanet.png +0 -0
  2. setiastro/images/TextureClarity.svg +56 -0
  3. setiastro/images/narrowbandnormalization.png +0 -0
  4. setiastro/images/planetarystacker.png +0 -0
  5. setiastro/saspro/__init__.py +9 -8
  6. setiastro/saspro/__main__.py +326 -285
  7. setiastro/saspro/_generated/build_info.py +2 -2
  8. setiastro/saspro/aberration_ai.py +128 -13
  9. setiastro/saspro/aberration_ai_preset.py +29 -3
  10. setiastro/saspro/astrospike_python.py +45 -3
  11. setiastro/saspro/blink_comparator_pro.py +116 -71
  12. setiastro/saspro/curve_editor_pro.py +72 -22
  13. setiastro/saspro/curves_preset.py +249 -47
  14. setiastro/saspro/doc_manager.py +4 -1
  15. setiastro/saspro/gui/main_window.py +326 -46
  16. setiastro/saspro/gui/mixins/file_mixin.py +41 -18
  17. setiastro/saspro/gui/mixins/menu_mixin.py +9 -0
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +123 -7
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +1429 -0
  22. setiastro/saspro/layers.py +186 -10
  23. setiastro/saspro/layers_dock.py +198 -5
  24. setiastro/saspro/legacy/image_manager.py +10 -4
  25. setiastro/saspro/legacy/numba_utils.py +1 -1
  26. setiastro/saspro/live_stacking.py +24 -4
  27. setiastro/saspro/multiscale_decomp.py +30 -17
  28. setiastro/saspro/narrowband_normalization.py +1618 -0
  29. setiastro/saspro/planetprojection.py +3854 -0
  30. setiastro/saspro/remove_green.py +1 -1
  31. setiastro/saspro/resources.py +8 -0
  32. setiastro/saspro/rgbalign.py +456 -12
  33. setiastro/saspro/save_options.py +45 -13
  34. setiastro/saspro/ser_stack_config.py +102 -0
  35. setiastro/saspro/ser_stacker.py +2327 -0
  36. setiastro/saspro/ser_stacker_dialog.py +1865 -0
  37. setiastro/saspro/ser_tracking.py +228 -0
  38. setiastro/saspro/serviewer.py +1773 -0
  39. setiastro/saspro/sfcc.py +298 -64
  40. setiastro/saspro/shortcuts.py +14 -7
  41. setiastro/saspro/stacking_suite.py +21 -6
  42. setiastro/saspro/stat_stretch.py +179 -31
  43. setiastro/saspro/subwindow.py +38 -5
  44. setiastro/saspro/texture_clarity.py +593 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/METADATA +3 -2
  47. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/RECORD +51 -37
  48. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/LICENSE +0 -0
  51. {setiastrosuitepro-1.6.12.dist-info → setiastrosuitepro-1.7.3.dist-info}/licenses/license.txt +0 -0
@@ -0,0 +1,1429 @@
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, Callable
10
+ from collections import OrderedDict
11
+ import numpy as np
12
+ import time
13
+ from PyQt6 import sip
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
+ # ---- Spec ----
40
+ 0: "MONO",
41
+ 8: "BAYER_RGGB",
42
+ 9: "BAYER_GRBG",
43
+ 10: "BAYER_GBRG",
44
+ 11: "BAYER_BGGR",
45
+ 16: "BAYER_CYYM",
46
+ 17: "BAYER_YCMY",
47
+ 18: "BAYER_YMCY",
48
+ 19: "BAYER_MYYC",
49
+ 100: "RGB",
50
+ 101: "BGR",
51
+
52
+ # ---- Non-standard, but keep for compatibility ----
53
+ 24: "RGB",
54
+ 25: "BGR",
55
+ 26: "RGBA",
56
+ 27: "BGRA",
57
+ 102: "RGBA",
58
+ 103: "BGRA",
59
+ }
60
+
61
+ BAYER_NAMES = {
62
+ "BAYER_RGGB","BAYER_GRBG","BAYER_GBRG","BAYER_BGGR",
63
+ "BAYER_CYYM","BAYER_YCMY","BAYER_YMCY","BAYER_MYYC",
64
+ }
65
+ BAYER_PATTERNS = tuple(sorted(BAYER_NAMES))
66
+
67
+ def _is_rgb(color_name: str) -> bool:
68
+ return color_name in {"RGB", "BGR", "RGBA", "BGRA"}
69
+
70
+ def _normalize_bayer_pattern(p: Optional[str]) -> Optional[str]:
71
+ if not p:
72
+ return None
73
+ p = str(p).strip().upper()
74
+ if p == "AUTO":
75
+ return None
76
+ if p.startswith("BAYER_"):
77
+ if p in BAYER_PATTERNS:
78
+ return p
79
+ return None
80
+ # allow short names like "RGGB"
81
+ p2 = "BAYER_" + p
82
+ if p2 in BAYER_PATTERNS:
83
+ return p2
84
+ return None
85
+
86
+ @dataclass
87
+ class SerMeta:
88
+ path: str
89
+ width: int
90
+ height: int
91
+ frames: int
92
+ pixel_depth: int # bits per sample (8/16 typically)
93
+ color_id: int
94
+ color_name: str
95
+ little_endian: bool
96
+ data_offset: int
97
+ frame_bytes: int
98
+ has_timestamps: bool
99
+
100
+ observer: str = ""
101
+ instrument: str = ""
102
+ telescope: str = ""
103
+
104
+
105
+ def _decode_cstr(b: bytes) -> str:
106
+ try:
107
+ return b.split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
108
+ except Exception:
109
+ return ""
110
+
111
+
112
+ def _bytes_per_sample(pixel_depth_bits: int) -> int:
113
+ return 1 if int(pixel_depth_bits) <= 8 else 2
114
+
115
+
116
+ def _is_bayer(color_name: str) -> bool:
117
+ return color_name in BAYER_NAMES
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
+
133
+ def _rot180(img: np.ndarray) -> np.ndarray:
134
+ # Works for mono (H,W) and RGB(A) (H,W,C)
135
+ return img[::-1, ::-1].copy()
136
+
137
+
138
+ def _roi_evenize_for_bayer(x: int, y: int) -> Tuple[int, int]:
139
+ """Ensure ROI origin is even-even so Bayer phase doesn't flip."""
140
+ if x & 1:
141
+ x -= 1
142
+ if y & 1:
143
+ y -= 1
144
+ return max(0, x), max(0, y)
145
+
146
+
147
+ def _cv2_debayer(mosaic: np.ndarray, pattern: str) -> np.ndarray:
148
+ """
149
+ mosaic: uint8/uint16, shape (H,W)
150
+ returns: RGB uint8/uint16, shape (H,W,3)
151
+ """
152
+ if cv2 is None:
153
+ raise RuntimeError("OpenCV not available for debayer fallback.")
154
+
155
+ code_map = {
156
+ "BAYER_RGGB": cv2.COLOR_BayerRG2RGB,
157
+ "BAYER_BGGR": cv2.COLOR_BayerBG2RGB,
158
+ "BAYER_GBRG": cv2.COLOR_BayerGB2RGB,
159
+ "BAYER_GRBG": cv2.COLOR_BayerGR2RGB,
160
+ }
161
+ code = code_map.get(pattern)
162
+ if code is None:
163
+ raise ValueError(f"Unknown Bayer pattern: {pattern}")
164
+ return cv2.cvtColor(mosaic, code)
165
+
166
+ def _maybe_swap_rb_to_match_cv2(mosaic: np.ndarray, pattern: str, out: np.ndarray) -> np.ndarray:
167
+ """
168
+ Ensure debayer output channel order matches OpenCV's RGB output.
169
+ Some fast debayers return BGR. We detect by comparing against cv2 on a small crop.
170
+ """
171
+ if out is None or out.ndim != 3 or out.shape[2] < 3:
172
+ return out
173
+
174
+ # Compare on a small center crop for speed
175
+ H, W = mosaic.shape[:2]
176
+ cs = min(96, H, W)
177
+ y0 = max(0, (H - cs) // 2)
178
+ x0 = max(0, (W - cs) // 2)
179
+ m = mosaic[y0:y0+cs, x0:x0+cs]
180
+
181
+ ref = _cv2_debayer(m, pattern) # RGB
182
+
183
+ o = out[y0:y0+cs, x0:x0+cs, :3]
184
+ if o.dtype != ref.dtype:
185
+ # compare in float to avoid overflow
186
+ o_f = o.astype(np.float32)
187
+ ref_f = ref.astype(np.float32)
188
+ else:
189
+ o_f = o.astype(np.float32)
190
+ ref_f = ref.astype(np.float32)
191
+
192
+ d_same = float(np.mean(np.abs(o_f - ref_f)))
193
+ d_swap = float(np.mean(np.abs(o_f[..., ::-1] - ref_f)))
194
+
195
+ return out[..., ::-1].copy() if d_swap < d_same else out
196
+
197
+
198
+ def _try_numba_debayer(mosaic: np.ndarray, pattern: str) -> Optional[np.ndarray]:
199
+ """
200
+ Try to use SASpro's fast debayer if available.
201
+ Expected functions (from your memory):
202
+ - debayer_raw_fast / debayer_fits_fast (names may differ in your tree)
203
+ We keep this very defensive; if not found, return None.
204
+ """
205
+ # Try a few likely import locations without hard failing
206
+ candidates = [
207
+ ("setiastro.saspro.imageops.debayer", "debayer_raw_fast"),
208
+ ("setiastro.saspro.imageops.debayer", "debayer_fits_fast"),
209
+ ("setiastro.saspro.imageops.debayer_fast", "debayer_raw_fast"),
210
+ ("setiastro.saspro.imageops.debayer_fast", "debayer_fits_fast"),
211
+ ]
212
+ for mod_name, fn_name in candidates:
213
+ try:
214
+ mod = __import__(mod_name, fromlist=[fn_name])
215
+ fn = getattr(mod, fn_name, None)
216
+ if fn is None:
217
+ continue
218
+
219
+ # Many fast debayers accept a mosaic and a bayer string or enum.
220
+ # We'll try a couple calling conventions.
221
+ try:
222
+ out = fn(mosaic, pattern) # type: ignore
223
+ if out is not None:
224
+ return out
225
+ except Exception:
226
+ pass
227
+
228
+ try:
229
+ out = fn(mosaic) # type: ignore
230
+ if out is not None:
231
+ return out
232
+ except Exception:
233
+ pass
234
+ except Exception:
235
+ continue
236
+ return None
237
+
238
+ def _ser_color_id_from_name(color_name: str) -> int:
239
+ cn = str(color_name).strip().upper()
240
+ rev = {
241
+ "MONO": 0,
242
+ "BAYER_RGGB": 8,
243
+ "BAYER_GRBG": 9,
244
+ "BAYER_GBRG": 10,
245
+ "BAYER_BGGR": 11,
246
+ "RGB": 24,
247
+ "BGR": 25,
248
+ "RGBA": 26,
249
+ "BGRA": 27,
250
+ }
251
+ return rev.get(cn, 0)
252
+
253
+
254
+ ProgressCB = Callable[[int, int], None] # (done, total)
255
+
256
+
257
+ def _make_progress_updater(
258
+ total: int,
259
+ cb: Optional[ProgressCB],
260
+ *,
261
+ every: int = 10,
262
+ min_interval_s: float = 0.10,
263
+ ) -> Callable[[int], None]:
264
+ total_i = max(0, int(total))
265
+ every_i = max(1, int(every)) if every is not None else 10
266
+ last_emit_t = 0.0
267
+ last_emit_done = -1
268
+
269
+ def update(done: int) -> None:
270
+ nonlocal last_emit_t, last_emit_done
271
+ if cb is None:
272
+ return
273
+
274
+ d = int(done)
275
+ if total_i > 0:
276
+ d = max(0, min(total_i, d))
277
+ else:
278
+ d = max(0, d)
279
+
280
+ # always emit start/end
281
+ must_emit = (d == 0) or (d == total_i)
282
+
283
+ # emit strictly every N frames
284
+ if not must_emit and (d % every_i == 0):
285
+ must_emit = True
286
+
287
+ # optional time throttle: ONLY if we've advanced at least `every_i` frames since last emit
288
+ if not must_emit:
289
+ now = time.monotonic()
290
+ if (now - last_emit_t) >= float(min_interval_s) and (d - last_emit_done) >= every_i:
291
+ must_emit = True
292
+
293
+ if not must_emit:
294
+ return
295
+
296
+ # avoid duplicate emits (except start/end)
297
+ if d == last_emit_done and (d != 0 and d != total_i):
298
+ return
299
+
300
+ try:
301
+ cb(d, total_i)
302
+ except Exception:
303
+ pass
304
+
305
+ last_emit_t = time.monotonic()
306
+ last_emit_done = d
307
+
308
+ return update
309
+
310
+
311
+ def export_trimmed_to_ser(
312
+ src: "PlanetaryFrameSource",
313
+ out_path: str,
314
+ start: int,
315
+ end: int,
316
+ *,
317
+ bayer_pattern: Optional[str] = None,
318
+ store_raw_mosaic_if_forced: bool = True,
319
+ progress_cb: Optional[ProgressCB] = None,
320
+ progress_every: int = 10,
321
+ ) -> None:
322
+ """
323
+ Export frames [start..end] (inclusive) to a NEW .ser file.
324
+
325
+ Rules:
326
+ - SER -> SER: raw byte copy + patch header frames (and timestamps).
327
+ - AVI/sequence -> SER:
328
+ - If bayer_pattern is provided (not AUTO) AND store_raw_mosaic_if_forced=True,
329
+ write SER as BAYER_* (color_id 8..11) with 1-channel mosaic frames so the
330
+ output SER can be debayered later.
331
+ - Otherwise write RGB24 SER (color_id 24) 8-bit.
332
+
333
+ NOTE: For raw-mosaic AVI that OpenCV decodes as 3-channel, we take channel 0 as mosaic.
334
+ """
335
+ start = int(start)
336
+ end = int(end)
337
+ if end < start:
338
+ end = start
339
+
340
+ meta = src.meta
341
+ n = int(meta.frames)
342
+ if n <= 0:
343
+ raise ValueError("Source has no frames.")
344
+ if start < 0 or start >= n or end < 0 or end >= n:
345
+ raise ValueError(f"Trim range out of bounds: {start}..{end} (0..{n-1})")
346
+
347
+ out_frames = int(end - start + 1)
348
+
349
+ # progress helper (works for both fast and generic paths)
350
+ progress = _make_progress_updater(out_frames, progress_cb, every=progress_every)
351
+ progress(0)
352
+
353
+ # Normalize pattern
354
+ user_pat = _normalize_bayer_pattern(bayer_pattern) # None means AUTO/invalid
355
+
356
+ # ------------------------------------------------------------
357
+ # FAST PATH: SER -> SER (raw copy)
358
+ # ------------------------------------------------------------
359
+ if isinstance(src, SERReader):
360
+ mm = src._mm
361
+ in_meta = src.meta
362
+
363
+ hdr = bytearray(mm[:SER_HEADER_SIZE])
364
+ struct.pack_into("<I", hdr, SER_SIGNATURE_LEN + 6 * 4, int(out_frames)) # frames field
365
+
366
+ with open(out_path, "wb") as f:
367
+ f.write(hdr)
368
+
369
+ fb = int(in_meta.frame_bytes)
370
+ done = 0
371
+
372
+ for i in range(start, end + 1):
373
+ off = in_meta.data_offset + i * fb
374
+ f.write(mm[off:off + fb])
375
+ done += 1
376
+ progress(done)
377
+
378
+ # timestamps are extra bytes; we keep progress tied to frame count (simple & stable)
379
+ if bool(in_meta.has_timestamps):
380
+ ts_base = in_meta.data_offset + in_meta.frames * fb
381
+ for i in range(start, end + 1):
382
+ off = ts_base + i * 8
383
+ f.write(mm[off:off + 8])
384
+
385
+ progress(out_frames)
386
+ return
387
+
388
+ # ------------------------------------------------------------
389
+ # GENERIC PATH: AVI/sequence -> SER (encode)
390
+ # ------------------------------------------------------------
391
+ w = int(meta.width)
392
+ h = int(meta.height)
393
+ if w <= 0 or h <= 0:
394
+ fr0 = src.get_frame(start, roi=None, debayer=False, to_float01=False, force_rgb=False, bayer_pattern=None)
395
+ h, w = fr0.shape[:2]
396
+
397
+ # Decide output mode
398
+ write_as_bayer = bool(user_pat is not None and store_raw_mosaic_if_forced)
399
+
400
+ # SER header basics
401
+ sig = b"LUCAM-RECORDER"
402
+ sig = sig[:SER_SIGNATURE_LEN].ljust(SER_SIGNATURE_LEN, b"\x00")
403
+
404
+ lu_id = 0
405
+ little_endian = 1
406
+
407
+ # For video sources, we write 8-bit output
408
+ pixel_depth = 8
409
+
410
+ if write_as_bayer:
411
+ color_name = user_pat # e.g. "BAYER_RGGB"
412
+ color_id = _ser_color_id_from_name(color_name) # 8..11
413
+ else:
414
+ color_id = 24 # RGB
415
+
416
+ hdr = bytearray(SER_HEADER_SIZE)
417
+ hdr[:SER_SIGNATURE_LEN] = sig
418
+ struct.pack_into(
419
+ "<7I",
420
+ hdr,
421
+ SER_SIGNATURE_LEN,
422
+ int(lu_id),
423
+ int(color_id),
424
+ int(little_endian),
425
+ int(w),
426
+ int(h),
427
+ int(pixel_depth),
428
+ int(out_frames),
429
+ )
430
+
431
+ with open(out_path, "wb") as f:
432
+ f.write(hdr)
433
+
434
+ done = 0
435
+ for i in range(start, end + 1):
436
+ if write_as_bayer:
437
+ # Get RAW mosaic (no debayer). If AVI frame is packed 3-channel, take channel 0.
438
+ frame = src.get_frame(i, roi=None, debayer=False, to_float01=False, force_rgb=False, bayer_pattern=None)
439
+
440
+ if frame.ndim == 3 and frame.shape[2] >= 3:
441
+ mosaic = frame[..., 0]
442
+ elif frame.ndim == 3 and frame.shape[2] == 1:
443
+ mosaic = frame[..., 0]
444
+ else:
445
+ mosaic = frame # already HxW
446
+
447
+ # Ensure uint8 mosaic
448
+ if mosaic.dtype != np.uint8:
449
+ if mosaic.dtype in (np.float32, np.float64):
450
+ mosaic = np.clip(mosaic, 0.0, 1.0)
451
+ mosaic = (mosaic * 255.0).astype(np.uint8)
452
+ else:
453
+ mosaic_f = mosaic.astype(np.float32)
454
+ if np.issubdtype(mosaic.dtype, np.integer):
455
+ mx = float(np.iinfo(mosaic.dtype).max)
456
+ else:
457
+ mx = 255.0
458
+ mosaic_f = np.clip(mosaic_f / max(1.0, mx), 0.0, 1.0)
459
+ mosaic = (mosaic_f * 255.0).astype(np.uint8)
460
+
461
+ f.write(mosaic.tobytes(order="C"))
462
+
463
+ else:
464
+ # Write RGB SER (debayer/convert handled by source)
465
+ img = src.get_frame(i, roi=None, debayer=True, to_float01=False, force_rgb=True, bayer_pattern=user_pat)
466
+
467
+ if img.ndim == 2:
468
+ img = np.stack([img, img, img], axis=-1)
469
+ if img.shape[2] > 3:
470
+ img = img[..., :3]
471
+
472
+ if img.dtype != np.uint8:
473
+ if img.dtype in (np.float32, np.float64):
474
+ img = np.clip(img, 0.0, 1.0)
475
+ img = (img * 255.0).astype(np.uint8)
476
+ else:
477
+ img_f = img.astype(np.float32)
478
+ if np.issubdtype(img.dtype, np.integer):
479
+ mx = float(np.iinfo(img.dtype).max)
480
+ else:
481
+ mx = 255.0
482
+ img_f = np.clip(img_f / max(1.0, mx), 0.0, 1.0)
483
+ img = (img_f * 255.0).astype(np.uint8)
484
+
485
+ f.write(img.tobytes(order="C"))
486
+
487
+ done += 1
488
+ progress(done)
489
+
490
+ progress(out_frames)
491
+
492
+ class _LRUCache:
493
+ """Tiny LRU cache for decoded frames."""
494
+ def __init__(self, max_items: int = 8):
495
+ self.max_items = int(max_items)
496
+ self._d: "OrderedDict[Tuple, np.ndarray]" = OrderedDict()
497
+
498
+ def get(self, key):
499
+ if key not in self._d:
500
+ return None
501
+ self._d.move_to_end(key)
502
+ return self._d[key]
503
+
504
+ def put(self, key, value: np.ndarray):
505
+ self._d[key] = value
506
+ self._d.move_to_end(key)
507
+ while len(self._d) > self.max_items:
508
+ self._d.popitem(last=False)
509
+
510
+ def clear(self):
511
+ self._d.clear()
512
+
513
+ SASPRO_SER_DEBUG=False
514
+
515
+ def _env_flag(name: str, default: bool = False) -> bool:
516
+ v = os.environ.get(name)
517
+ if v is None:
518
+ return default
519
+ return str(v).strip().lower() in {"1", "true", "yes", "on"}
520
+
521
+ class SERReader:
522
+ """
523
+ Memory-mapped SER reader with:
524
+ - header parsing (common v3 layout)
525
+ - random frame access
526
+ - optional ROI (with Bayer parity protection)
527
+ - optional debayer
528
+ - tiny LRU cache for smooth preview scrubbing
529
+ """
530
+
531
+ def __init__(self, path: str, *, cache_items: int = 10):
532
+ self.path = os.fspath(path)
533
+ self._fh = open(self.path, "rb")
534
+ self._mm = mmap.mmap(self._fh.fileno(), 0, access=mmap.ACCESS_READ)
535
+
536
+ self.meta = self._parse_header(self._mm)
537
+ self.meta.path = self.path
538
+ self._debug = SASPRO_SER_DEBUG
539
+
540
+ self._printed_endian = False
541
+ if self._debug:
542
+ try:
543
+ meta = self.meta
544
+ print(f"[SER] signature={self._mm[:14]!r}")
545
+ print(
546
+ f"[SER] {os.path.basename(self.path)} "
547
+ f"{meta.width}x{meta.height} frames={meta.frames} "
548
+ f"depth={meta.pixel_depth} color_id={meta.color_id} "
549
+ f"color={meta.color_name} little_endian_flag={meta.little_endian} "
550
+ f"frame_bytes={meta.frame_bytes} ts={meta.has_timestamps}"
551
+ )
552
+ except Exception:
553
+ pass
554
+ self._cache = _LRUCache(max_items=cache_items)
555
+ self._fast_debayer_is_bgr: Optional[bool] = None
556
+ self._endian_override: Optional[bool] = None # None=unknown, else True/False for data little-endian
557
+
558
+
559
+ def close(self):
560
+ try:
561
+ self._cache.clear()
562
+ except Exception:
563
+ pass
564
+ try:
565
+ self._mm.close()
566
+ except Exception:
567
+ pass
568
+ try:
569
+ self._fh.close()
570
+ except Exception:
571
+ pass
572
+
573
+ def __enter__(self):
574
+ return self
575
+
576
+ def __exit__(self, exc_type, exc, tb):
577
+ self.close()
578
+
579
+ # ---------------- header parsing ----------------
580
+ @staticmethod
581
+ def _parse_header(mm: mmap.mmap) -> SerMeta:
582
+ if mm.size() < SER_HEADER_SIZE:
583
+ raise ValueError("File too small to be a SER file.")
584
+
585
+ hdr = mm[:SER_HEADER_SIZE]
586
+
587
+ # Signature (informational only; we stay permissive)
588
+ sig = hdr[:SER_SIGNATURE_LEN]
589
+ _ = _decode_cstr(sig)
590
+
591
+ try:
592
+ (lu_id, color_id, little_endian_u32,
593
+ w, h, pixel_depth, frames) = struct.unpack_from("<7I", hdr, SER_SIGNATURE_LEN)
594
+ except Exception as e:
595
+ raise ValueError(f"Failed to parse SER header fields: {e}")
596
+
597
+ little_endian = bool(little_endian_u32)
598
+
599
+ observer = _decode_cstr(hdr[42:82])
600
+ instrument = _decode_cstr(hdr[82:122])
601
+ telescope = _decode_cstr(hdr[122:162])
602
+
603
+ color_name = SER_COLOR.get(int(color_id), f"UNKNOWN({color_id})")
604
+
605
+ data_offset = SER_HEADER_SIZE
606
+ file_size = mm.size()
607
+
608
+ def expected_size(frame_bytes: int, with_ts: bool) -> int:
609
+ base = data_offset + int(frames) * int(frame_bytes)
610
+ return base + (int(frames) * 8 if with_ts else 0)
611
+
612
+ # ------------------------------------------------------------
613
+ # Robust inference:
614
+ # Some writers lie about pixel_depth. So we infer:
615
+ # - channels ∈ {1,3,4}
616
+ # - bytes-per-sample (bps) ∈ {1,2}
617
+ # - timestamps present?
618
+ # by matching file size to expected sizes.
619
+ # ------------------------------------------------------------
620
+ candidates: list[tuple[int, int, int, bool]] = [] # (channels, bps, frame_bytes, has_ts)
621
+
622
+ for ch in (1, 3, 4):
623
+ for bps in (1, 2):
624
+ fb = int(w) * int(h) * ch * bps
625
+ if fb <= 0:
626
+ continue
627
+ if file_size == expected_size(fb, with_ts=False):
628
+ candidates.append((ch, bps, fb, False))
629
+ if file_size == expected_size(fb, with_ts=True):
630
+ candidates.append((ch, bps, fb, True))
631
+
632
+ picked: Optional[tuple[int, int, int, bool]] = None
633
+
634
+ if len(candidates) == 1:
635
+ picked = candidates[0]
636
+ elif len(candidates) > 1:
637
+ # Tie-break using header hints, but don't fully trust them.
638
+
639
+ # Channel hint from color_name
640
+ hinted_ch = 1
641
+ if color_name in {"RGB", "BGR"}:
642
+ hinted_ch = 3
643
+ elif color_name in {"RGBA", "BGRA"}:
644
+ hinted_ch = 4
645
+
646
+ pool = [c for c in candidates if c[0] == hinted_ch] or candidates
647
+
648
+ # bps hint from pixel_depth
649
+ hinted_bps = 1 if int(pixel_depth) <= 8 else 2
650
+ pool2 = [c for c in pool if c[1] == hinted_bps] or pool
651
+
652
+ # If still multiple, prefer:
653
+ # - 1ch if header says MONO/BAYER-ish
654
+ # - else 3ch if RGB-ish
655
+ if len(pool2) > 1:
656
+ if _is_bayer(color_name) or color_name == "MONO":
657
+ pool3 = [c for c in pool2 if c[0] == 1] or pool2
658
+ elif color_name in {"RGB", "BGR"}:
659
+ pool3 = [c for c in pool2 if c[0] == 3] or pool2
660
+ elif color_name in {"RGBA", "BGRA"}:
661
+ pool3 = [c for c in pool2 if c[0] == 4] or pool2
662
+ else:
663
+ pool3 = pool2
664
+ else:
665
+ pool3 = pool2
666
+
667
+ picked = pool3[0]
668
+
669
+ if picked is None:
670
+ # Fall back to header interpretation (best-effort)
671
+ bps = _bytes_per_sample(int(pixel_depth))
672
+ if color_name in {"RGB", "BGR"}:
673
+ channels = 3
674
+ elif color_name in {"RGBA", "BGRA"}:
675
+ channels = 4
676
+ else:
677
+ channels = 1
678
+
679
+ frame_bytes = int(w) * int(h) * channels * int(bps)
680
+ has_ts = (file_size == expected_size(frame_bytes, with_ts=True))
681
+ else:
682
+ channels, bps, frame_bytes, has_ts = picked
683
+
684
+ # If bps contradicts header pixel_depth, coerce pixel_depth to a sane value
685
+ # (If bps==2 but header said 8, we treat it as 16-bit container.)
686
+ if bps == 1:
687
+ # Keep header's pixel_depth if it is <=8, else clamp
688
+ pixel_depth = int(pixel_depth) if int(pixel_depth) <= 8 else 8
689
+ else:
690
+ # If header says 10/12/14/16, keep it; if header says <=8, promote to 16
691
+ if int(pixel_depth) <= 8:
692
+ pixel_depth = 16
693
+ else:
694
+ pixel_depth = int(pixel_depth)
695
+
696
+ # If inferred channels contradict header color_name, adjust color_name for sane downstream behavior
697
+ if channels == 1:
698
+ if color_name in {"RGB", "BGR", "RGBA", "BGRA"}:
699
+ # If header claimed RGB but file is 1ch, safest is treat as Bayer RGGB by default
700
+ color_name = "BAYER_RGGB"
701
+ # If it was UNKNOWN(...) keep it as-is, unless you want to force MONO.
702
+ elif channels == 3:
703
+ if color_name not in {"RGB", "BGR"}:
704
+ color_name = "RGB"
705
+ elif channels == 4:
706
+ if color_name not in {"RGBA", "BGRA"}:
707
+ color_name = "RGBA"
708
+
709
+ return SerMeta(
710
+ path="",
711
+ width=int(w),
712
+ height=int(h),
713
+ frames=int(frames),
714
+ pixel_depth=int(pixel_depth),
715
+ color_id=int(color_id),
716
+ color_name=color_name,
717
+ little_endian=bool(little_endian),
718
+ data_offset=int(data_offset),
719
+ frame_bytes=int(frame_bytes),
720
+ has_timestamps=bool(has_ts),
721
+ observer=observer,
722
+ instrument=instrument,
723
+ telescope=telescope,
724
+ )
725
+
726
+ # ---------------- core access ----------------
727
+
728
+ def frame_offset(self, i: int) -> int:
729
+ i = int(i)
730
+ if i < 0 or i >= self.meta.frames:
731
+ raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
732
+ return self.meta.data_offset + i * self.meta.frame_bytes
733
+
734
+ def get_frame(
735
+ self,
736
+ i: int,
737
+ *,
738
+ roi: Optional[Tuple[int, int, int, int]] = None,
739
+ debayer: bool = True,
740
+ to_float01: bool = False,
741
+ force_rgb: bool = False,
742
+ bayer_pattern: Optional[str] = None,
743
+ ) -> np.ndarray:
744
+ meta = self.meta
745
+
746
+ color_name = meta.color_name
747
+ user_pat = _normalize_bayer_pattern(bayer_pattern)
748
+ active_bayer = user_pat if user_pat is not None else (color_name if _is_bayer(color_name) else None)
749
+
750
+ roi_key = None if roi is None else tuple(int(v) for v in roi)
751
+ key = (int(i), roi_key, bool(debayer), active_bayer, bool(to_float01), bool(force_rgb))
752
+ cached = self._cache.get(key)
753
+ if cached is not None:
754
+ return cached
755
+
756
+ off = self.frame_offset(i)
757
+ buf = self._mm[off:off + meta.frame_bytes]
758
+
759
+ # dtype from inferred/parsed pixel_depth
760
+ bps = _bytes_per_sample(meta.pixel_depth)
761
+ dtype = np.uint8 if bps == 1 else np.uint16
762
+
763
+ # Determine channels stored (from color_name; frame_bytes inference already fixed bps)
764
+ if color_name in {"RGB", "BGR"}:
765
+ ch = 3
766
+ elif color_name in {"RGBA", "BGRA"}:
767
+ ch = 4
768
+ else:
769
+ ch = 1
770
+
771
+ arr = np.frombuffer(buf, dtype=dtype)
772
+ # byteswap if endianness is wrong (SER header endian flag is often unreliable)
773
+ if dtype == np.uint16:
774
+ # Decide once per reader instance; cache in self._endian_override
775
+ if self._endian_override is None:
776
+ # Compare "as-read" vs "byteswapped" on a sample.
777
+ sample = arr[:min(arr.size, 200000)]
778
+ if sample.size >= 2048:
779
+ a = sample
780
+ b = sample.byteswap()
781
+
782
+ # Heuristic #1: low-byte richness (correct endian tends to have richer low byte)
783
+ lo_a = (a & 0x00FF).astype(np.uint8)
784
+ lo_b = (b & 0x00FF).astype(np.uint8)
785
+ ua = int(np.unique(lo_a).size)
786
+ ub = int(np.unique(lo_b).size)
787
+
788
+ # Heuristic #2: "plausible dynamic range" vs declared pixel_depth (if 10/12/14)
789
+ pd = int(getattr(meta, "pixel_depth", 16) or 16)
790
+ # only meaningful if pd is 9..15 (packed in uint16)
791
+ if 8 < pd < 16:
792
+ maxv = (1 << pd) - 1
793
+ # take a high percentile, not max (avoid hot pixels)
794
+ p_a = float(np.percentile(a, 99.9))
795
+ p_b = float(np.percentile(b, 99.9))
796
+ # prefer the interpretation whose 99.9% sits closer to the expected range
797
+ da = abs(p_a - maxv)
798
+ db = abs(p_b - maxv)
799
+ else:
800
+ da = db = 0.0
801
+
802
+ # Decide:
803
+ # - if low-byte richness strongly prefers one, trust it
804
+ # - else if pixel_depth is 10/12/14, use the percentile distance
805
+ # - else fall back to header flag
806
+ if ua >= ub + 32:
807
+ self._endian_override = True # data is little-endian (as-read)
808
+ elif ub >= ua + 32:
809
+ self._endian_override = False # data is big-endian (need swap)
810
+ elif (8 < pd < 16) and (da != db):
811
+ self._endian_override = (da <= db) # True = keep, False = swap
812
+ else:
813
+ self._endian_override = bool(meta.little_endian)
814
+
815
+ else:
816
+ # too small to be confident
817
+ self._endian_override = bool(meta.little_endian)
818
+
819
+ # Apply decision
820
+ if self._endian_override is False:
821
+ arr = arr.byteswap()
822
+
823
+ if self._debug and (not self._printed_endian):
824
+ self._printed_endian = True
825
+ try:
826
+ print(f"[SER] endian_decision: override={self._endian_override} (True=keep, False=swap)")
827
+ except Exception:
828
+ pass
829
+
830
+ # byteswap if big-endian storage (rare, but spec supports it)
831
+ if dtype == np.uint16:
832
+ data_is_little = meta.little_endian
833
+
834
+ if self._endian_override is None and (not meta.little_endian):
835
+ sample = arr[:min(arr.size, 200000)]
836
+ if sample.size >= 1024:
837
+ lo_u = np.bitwise_and(sample, 0xFF).astype(np.uint8)
838
+ lo_s = np.bitwise_and(sample.byteswap(), 0xFF).astype(np.uint8)
839
+
840
+ u_unique = int(np.unique(lo_u).size)
841
+ s_unique = int(np.unique(lo_s).size)
842
+
843
+ if u_unique >= s_unique + 32:
844
+ self._endian_override = True
845
+ elif s_unique >= u_unique + 32:
846
+ self._endian_override = False
847
+ else:
848
+ self._endian_override = data_is_little
849
+ else:
850
+ self._endian_override = data_is_little
851
+
852
+ if self._endian_override is not None:
853
+ data_is_little = bool(self._endian_override)
854
+
855
+ if not data_is_little:
856
+ arr = arr.byteswap()
857
+
858
+ if ch == 1:
859
+ img = arr.reshape(meta.height, meta.width)
860
+ else:
861
+ img = arr.reshape(meta.height, meta.width, ch)
862
+
863
+ # ROI (apply before debayer; for Bayer enforce even-even origin)
864
+ if roi is not None:
865
+ # ✅ roi is specified in DISPLAY coords (post-rot180)
866
+ x, y, w, h = _roi_unrotate_180(roi, meta.width, meta.height)
867
+
868
+ x = max(0, min(meta.width - 1, x))
869
+ y = max(0, min(meta.height - 1, y))
870
+ w = max(1, min(meta.width - x, w))
871
+ h = max(1, min(meta.height - y, h))
872
+
873
+ if (active_bayer is not None) and debayer:
874
+ x, y = _roi_evenize_for_bayer(x, y)
875
+ w = max(1, min(meta.width - x, w))
876
+ h = max(1, min(meta.height - y, h))
877
+
878
+ img = img[y:y + h, x:x + w]
879
+
880
+ # --- SER global orientation fix (rotate 180) ---
881
+ img = _rot180(img)
882
+ # Convert BGR->RGB if needed
883
+ if color_name == "BGR" and img.ndim == 3 and img.shape[2] >= 3:
884
+ img = img[..., ::-1].copy()
885
+
886
+ # Debayer if needed
887
+ user_forced_bayer = (user_pat is not None)
888
+ stored_is_bayer = _is_bayer(color_name)
889
+
890
+ if debayer and (stored_is_bayer or user_forced_bayer):
891
+ pat = active_bayer or user_pat or (color_name if stored_is_bayer else None) or "BAYER_RGGB"
892
+
893
+ if img.ndim == 3 and img.shape[2] >= 3:
894
+ mosaic = img[..., 0] if user_forced_bayer else None
895
+ else:
896
+ mosaic = img if img.ndim == 2 else img[..., 0]
897
+
898
+ if mosaic is not None:
899
+ out = _try_numba_debayer(mosaic, pat)
900
+ if out is None:
901
+ out = _cv2_debayer(mosaic, pat) # RGB
902
+ else:
903
+ out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
904
+ img = out
905
+
906
+ elif stored_is_bayer and (not debayer):
907
+ img = img if img.ndim == 2 else img[..., 0]
908
+
909
+ # Force RGB for mono
910
+ if force_rgb and img.ndim == 2:
911
+ img = np.stack([img, img, img], axis=-1)
912
+
913
+ # ----------------------------
914
+ # Normalize to float01 (BONUS)
915
+ # ----------------------------
916
+ if to_float01:
917
+ if img.dtype == np.uint8:
918
+ img = img.astype(np.float32) / 255.0
919
+ elif img.dtype == np.uint16:
920
+ pd = int(getattr(meta, "pixel_depth", 16) or 16)
921
+ # Many cameras are 10/12/14-bit stored in uint16.
922
+ if 8 < pd < 16:
923
+ denom = float((1 << pd) - 1)
924
+ else:
925
+ denom = 65535.0
926
+ img = img.astype(np.float32) / max(1.0, denom)
927
+ else:
928
+ img = img.astype(np.float32)
929
+ img = np.clip(img, 0.0, 1.0)
930
+
931
+ self._cache.put(key, img)
932
+ return img
933
+
934
+ def get_timestamp_ns(self, i: int) -> Optional[int]:
935
+ """
936
+ If timestamps exist, returns the 64-bit timestamp value for frame i.
937
+ (Interpretation depends on writer; often 100ns ticks or nanoseconds.)
938
+ """
939
+ meta = self.meta
940
+ if not meta.has_timestamps:
941
+ return None
942
+ i = int(i)
943
+ if i < 0 or i >= meta.frames:
944
+ return None
945
+ ts_base = meta.data_offset + meta.frames * meta.frame_bytes
946
+ off = ts_base + i * 8
947
+ b = self._mm[off:off + 8]
948
+ if len(b) != 8:
949
+ return None
950
+ (v,) = struct.unpack("<Q", b)
951
+ return int(v)
952
+
953
+
954
+
955
+ # -----------------------------
956
+ # Common reader interface/meta
957
+ # -----------------------------
958
+
959
+ @dataclass
960
+ class PlanetaryMeta:
961
+ """
962
+ Common metadata shape used by SERViewer / stacker.
963
+ """
964
+ path: str
965
+ width: int
966
+ height: int
967
+ frames: int
968
+ pixel_depth: int # 8/16 typical (AVI usually 8)
969
+ color_name: str # "MONO", "RGB", "BGR", "BAYER_*", etc
970
+ has_timestamps: bool = False
971
+ source_kind: str = "unknown" # "ser" / "avi" / "sequence"
972
+ file_list: Optional[List[str]] = None
973
+
974
+
975
+ class PlanetaryFrameSource:
976
+ """
977
+ Minimal protocol-like base. (Duck-typed by viewer/stacker)
978
+ """
979
+ meta: PlanetaryMeta
980
+ path: str
981
+
982
+ def close(self) -> None:
983
+ raise NotImplementedError
984
+
985
+ def get_frame(
986
+ self,
987
+ i: int,
988
+ *,
989
+ roi: Optional[Tuple[int, int, int, int]] = None,
990
+ debayer: bool = True,
991
+ to_float01: bool = False,
992
+ force_rgb: bool = False,
993
+ bayer_pattern: Optional[str] = None, # ✅ NEW
994
+ ) -> np.ndarray:
995
+ raise NotImplementedError
996
+
997
+
998
+ # -----------------------------
999
+ # AVI reader (OpenCV)
1000
+ # -----------------------------
1001
+
1002
+ class AVIReader(PlanetaryFrameSource):
1003
+ """
1004
+ Frame-accurate random access using cv2.VideoCapture.
1005
+ Notes:
1006
+ - Many codecs only support approximate seeking; good enough for preview/scrub.
1007
+ - Frames come out as BGR uint8 by default.
1008
+ """
1009
+ def __init__(self, path: str, *, cache_items: int = 10):
1010
+ if cv2 is None:
1011
+ raise RuntimeError("OpenCV (cv2) is required to read AVI files.")
1012
+ self.path = os.fspath(path)
1013
+ self._cap = cv2.VideoCapture(self.path)
1014
+ if not self._cap.isOpened():
1015
+ raise ValueError(f"Failed to open video: {self.path}")
1016
+
1017
+ w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
1018
+ h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
1019
+ n = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
1020
+
1021
+ # AVI decoded frames are almost always 8-bit
1022
+ self.meta = PlanetaryMeta(
1023
+ path=self.path,
1024
+ width=w,
1025
+ height=h,
1026
+ frames=max(0, n),
1027
+ pixel_depth=8,
1028
+ color_name="BGR",
1029
+ has_timestamps=False,
1030
+ source_kind="avi",
1031
+ )
1032
+
1033
+ self._cache = _LRUCache(max_items=cache_items)
1034
+
1035
+ def close(self):
1036
+ try:
1037
+ self._cache.clear()
1038
+ except Exception:
1039
+ pass
1040
+ try:
1041
+ if self._cap is not None:
1042
+ self._cap.release()
1043
+ except Exception:
1044
+ pass
1045
+
1046
+ def __enter__(self):
1047
+ return self
1048
+
1049
+ def __exit__(self, exc_type, exc, tb):
1050
+ self.close()
1051
+
1052
+ def _read_raw_frame_bgr(self, i: int) -> np.ndarray:
1053
+ i = int(i)
1054
+ if i < 0 or (self.meta.frames > 0 and i >= self.meta.frames):
1055
+ raise IndexError(f"Frame index {i} out of range")
1056
+
1057
+ # Seek
1058
+ self._cap.set(cv2.CAP_PROP_POS_FRAMES, float(i))
1059
+ ok, frame = self._cap.read()
1060
+ if not ok or frame is None:
1061
+ raise ValueError(f"Failed to read frame {i}")
1062
+
1063
+ # frame is BGR uint8, shape (H,W,3)
1064
+ return frame
1065
+
1066
+ def get_frame(
1067
+ self,
1068
+ i: int,
1069
+ *,
1070
+ roi: Optional[Tuple[int, int, int, int]] = None,
1071
+ debayer: bool = True,
1072
+ to_float01: bool = False,
1073
+ force_rgb: bool = False,
1074
+ bayer_pattern: Optional[str] = None,
1075
+ ) -> np.ndarray:
1076
+
1077
+ roi_key = None if roi is None else tuple(int(v) for v in roi)
1078
+
1079
+ # User pattern:
1080
+ # - None means AUTO (do not force debayer on 3-channel video)
1081
+ # - A real value means: user explicitly wants debayering
1082
+ user_pat = _normalize_bayer_pattern(bayer_pattern) # None == AUTO
1083
+ pat_for_key = user_pat or "AUTO"
1084
+
1085
+ key = ("avi", int(i), roi_key, bool(debayer), pat_for_key, bool(to_float01), bool(force_rgb))
1086
+ cached = self._cache.get(key)
1087
+ if cached is not None:
1088
+ return cached
1089
+
1090
+ frame = self._read_raw_frame_bgr(i) # usually (H,W,3) uint8 BGR
1091
+
1092
+ # ROI first (but if we are going to debayer mosaic, ROI origin must be even-even)
1093
+ if roi is not None:
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
+
1099
+ x = max(0, min(W - 1, x))
1100
+ y = max(0, min(H - 1, y))
1101
+ w = max(1, min(W - x, w))
1102
+ h = max(1, min(H - y, h))
1103
+
1104
+ if debayer and user_pat is not None:
1105
+ x, y = _roi_evenize_for_bayer(x, y)
1106
+ w = max(1, min(W - x, w))
1107
+ h = max(1, min(H - y, h))
1108
+
1109
+ frame = frame[y:y + h, x:x + w]
1110
+
1111
+ frame = _rot180(frame)
1112
+
1113
+ img: np.ndarray
1114
+
1115
+
1116
+ # ---------------------------------------------------------
1117
+ # RAW MOSAIC AVI SUPPORT
1118
+ #
1119
+ # OpenCV often returns 3-channel frames even when the AVI is
1120
+ # conceptually "raw mosaic". In that case, ONLY debayer when
1121
+ # the user explicitly selected a Bayer pattern (not AUTO).
1122
+ # ---------------------------------------------------------
1123
+
1124
+ # True mosaic frame decoded as single-channel
1125
+ is_true_mosaic = (frame.ndim == 2) or (frame.ndim == 3 and frame.shape[2] == 1)
1126
+
1127
+ if debayer and (is_true_mosaic or (user_pat is not None)):
1128
+ # If it's 3-channel but user requested debayer, treat as packed mosaic:
1129
+ # take one channel (they should be identical if it's really mosaic-packed).
1130
+ if frame.ndim == 3 and frame.shape[2] >= 3:
1131
+ mosaic = frame[..., 0] # any channel is fine for packed mosaic
1132
+ else:
1133
+ mosaic = frame if frame.ndim == 2 else frame[..., 0]
1134
+
1135
+ # Choose pattern:
1136
+ # - user_pat is guaranteed not None here if it's forced on 3ch
1137
+ # - if it’s true mosaic and user left AUTO, default RGGB
1138
+ pat = user_pat or "BAYER_RGGB"
1139
+
1140
+ out = _try_numba_debayer(mosaic, pat)
1141
+ if out is None:
1142
+ out = _cv2_debayer(mosaic, pat) # RGB
1143
+ else:
1144
+ out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
1145
+
1146
+ img = out # RGB
1147
+
1148
+ else:
1149
+ # Normal video path: decoded BGR -> RGB
1150
+ if frame.ndim == 3 and frame.shape[2] >= 3:
1151
+ img = frame[..., ::-1].copy()
1152
+ else:
1153
+ # Rare: frame came out mono but debayer is off
1154
+ img = frame if frame.ndim == 2 else frame[..., 0]
1155
+ if force_rgb:
1156
+ img = np.stack([img, img, img], axis=-1)
1157
+
1158
+ # Normalize
1159
+ if to_float01:
1160
+ if img.dtype == np.uint8:
1161
+ img = img.astype(np.float32) / 255.0
1162
+ elif img.dtype == np.uint16:
1163
+ img = img.astype(np.float32) / 65535.0
1164
+ else:
1165
+ img = np.clip(img.astype(np.float32), 0.0, 1.0)
1166
+
1167
+ # Optional force_rgb (mostly relevant if debayer=False and frame is mono)
1168
+ if force_rgb and img.ndim == 2:
1169
+ img = np.stack([img, img, img], axis=-1)
1170
+
1171
+ self._cache.put(key, img)
1172
+ return img
1173
+
1174
+ # -----------------------------
1175
+ # Image-sequence reader
1176
+ # -----------------------------
1177
+
1178
+ def _imread_any(path: str) -> np.ndarray:
1179
+ """
1180
+ Read PNG/JPG/TIF/FITS/etc into numpy.
1181
+ Returns:
1182
+ - grayscale: (H,W)
1183
+ - color: (H,W,3) in RGB when applicable
1184
+ """
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}")
1219
+
1220
+ # Prefer cv2 if available
1221
+ if cv2 is not None:
1222
+ img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
1223
+ if img is not None:
1224
+ # cv2 gives:
1225
+ # - gray: HxW
1226
+ # - color: HxWx3 (BGR)
1227
+ # - sometimes HxWx4 (BGRA)
1228
+ if img.ndim == 3 and img.shape[2] >= 3:
1229
+ img = img[..., :3] # drop alpha if present
1230
+ img = img[..., ::-1].copy() # BGR -> RGB
1231
+ return img
1232
+
1233
+ # PIL fallback
1234
+ if Image is None:
1235
+ raise RuntimeError("Neither OpenCV nor PIL are available to read images.")
1236
+ im = Image.open(p)
1237
+ # Preserve 16-bit if possible; PIL handles many TIFFs.
1238
+ if im.mode in ("I;16", "I;16B", "I"):
1239
+ arr = np.array(im)
1240
+ return arr
1241
+ if im.mode in ("L",):
1242
+ return np.array(im)
1243
+ im = im.convert("RGB")
1244
+ return np.array(im)
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
+
1287
+
1288
+ def _infer_bit_depth(arr: np.ndarray) -> int:
1289
+ if arr.dtype == np.uint16:
1290
+ return 16
1291
+ if arr.dtype == np.uint8:
1292
+ return 8
1293
+ # if float, assume 32 for “depth”
1294
+ if arr.dtype in (np.float32, np.float64):
1295
+ return 32
1296
+ return 8
1297
+
1298
+
1299
+ class ImageSequenceReader(PlanetaryFrameSource):
1300
+ """
1301
+ Reads a list of image files as frames.
1302
+ Supports random access; caches decoded frames for smooth scrubbing.
1303
+ """
1304
+ def __init__(self, files: Sequence[str], *, cache_items: int = 10):
1305
+ flist = [os.fspath(f) for f in files]
1306
+ if not flist:
1307
+ raise ValueError("Empty image sequence.")
1308
+ self.files = flist
1309
+ self.path = flist[0]
1310
+
1311
+ # Probe first frame
1312
+ first = _imread_any(flist[0])
1313
+ h, w = first.shape[:2]
1314
+ depth = _infer_bit_depth(first)
1315
+ if first.ndim == 2:
1316
+ cname = "MONO"
1317
+ else:
1318
+ cname = "RGB"
1319
+
1320
+ self.meta = PlanetaryMeta(
1321
+ path=self.path,
1322
+ width=int(w),
1323
+ height=int(h),
1324
+ frames=len(flist),
1325
+ pixel_depth=int(depth),
1326
+ color_name=cname,
1327
+ has_timestamps=False,
1328
+ source_kind="sequence",
1329
+ file_list=list(flist),
1330
+ )
1331
+
1332
+ self._cache = _LRUCache(max_items=cache_items)
1333
+
1334
+ def close(self):
1335
+ try:
1336
+ self._cache.clear()
1337
+ except Exception:
1338
+ pass
1339
+
1340
+ def __enter__(self):
1341
+ return self
1342
+
1343
+ def __exit__(self, exc_type, exc, tb):
1344
+ self.close()
1345
+
1346
+ def get_frame(
1347
+ self,
1348
+ i: int,
1349
+ *,
1350
+ roi: Optional[Tuple[int, int, int, int]] = None,
1351
+ debayer: bool = True,
1352
+ to_float01: bool = False,
1353
+ force_rgb: bool = False,
1354
+ bayer_pattern: Optional[str] = None,
1355
+ ) -> np.ndarray:
1356
+ _ = debayer, bayer_pattern # unused for sequences (for now)
1357
+ i = int(i)
1358
+ if i < 0 or i >= self.meta.frames:
1359
+ raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
1360
+
1361
+ roi_key = None if roi is None else tuple(int(v) for v in roi)
1362
+ key = ("seq", i, roi_key, bool(to_float01), bool(force_rgb))
1363
+ cached = self._cache.get(key)
1364
+ if cached is not None:
1365
+ return cached
1366
+
1367
+ img = _imread_any(self.files[i])
1368
+
1369
+ # Basic consistency checks (don’t hard fail; some sequences have slight differences)
1370
+ # If sizes differ, we’ll just use whatever comes back for that frame.
1371
+ H, W = img.shape[:2]
1372
+
1373
+ # ROI
1374
+ if roi is not None:
1375
+ x, y, w, h = [int(v) for v in roi]
1376
+ x = max(0, min(W - 1, x))
1377
+ y = max(0, min(H - 1, y))
1378
+ w = max(1, min(W - x, w))
1379
+ h = max(1, min(H - y, h))
1380
+ img = img[y:y + h, x:x + w]
1381
+
1382
+ # Force RGB for mono
1383
+ if force_rgb and img.ndim == 2:
1384
+ img = np.stack([img, img, img], axis=-1)
1385
+
1386
+ # Normalize to float01
1387
+ if to_float01:
1388
+ img = _to_float01_robust(img)
1389
+
1390
+ self._cache.put(key, img)
1391
+ return img
1392
+
1393
+
1394
+ # -----------------------------
1395
+ # Factory
1396
+ # -----------------------------
1397
+
1398
+ def open_planetary_source(
1399
+ path_or_files: Union[str, Sequence[str]],
1400
+ *,
1401
+ cache_items: int = 10,
1402
+ ) -> PlanetaryFrameSource:
1403
+ """
1404
+ Open SER / AVI / image sequence under one API.
1405
+ """
1406
+ # Sequence
1407
+ if not isinstance(path_or_files, (str, os.PathLike)):
1408
+ return ImageSequenceReader(path_or_files, cache_items=cache_items)
1409
+
1410
+ path = os.fspath(path_or_files)
1411
+ ext = os.path.splitext(path)[1].lower()
1412
+
1413
+ if ext == ".ser":
1414
+ r = SERReader(path, cache_items=cache_items)
1415
+ # ---- SER tweak: ensure meta.path is set ----
1416
+ try:
1417
+ r.meta.path = path # type: ignore
1418
+ except Exception:
1419
+ pass
1420
+ return r
1421
+
1422
+ if ext in (".avi", ".mp4", ".mov", ".mkv"):
1423
+ return AVIReader(path, cache_items=cache_items)
1424
+
1425
+ # If user passes a single image, treat it as a 1-frame sequence
1426
+ if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp", ".fit", ".fits"):
1427
+ return ImageSequenceReader([path], cache_items=cache_items)
1428
+
1429
+ raise ValueError(f"Unsupported input: {path}")