setiastrosuitepro 1.6.4__py3-none-any.whl → 1.7.1.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.

Potentially problematic release.


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

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