setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0.post2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/saspro/__main__.py +1 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/aberration_ai.py +49 -11
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/backgroundneutral.py +73 -33
- setiastro/saspro/blink_comparator_pro.py +116 -71
- setiastro/saspro/convo.py +9 -6
- setiastro/saspro/curve_editor_pro.py +72 -22
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +178 -11
- setiastro/saspro/gui/main_window.py +305 -66
- setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
- setiastro/saspro/gui/mixins/file_mixin.py +35 -16
- setiastro/saspro/gui/mixins/menu_mixin.py +32 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +135 -11
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +972 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +66 -15
- setiastro/saspro/legacy/numba_utils.py +25 -48
- setiastro/saspro/live_stacking.py +24 -4
- setiastro/saspro/multiscale_decomp.py +30 -17
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +0 -55
- setiastro/saspro/ops/script_editor.py +5 -0
- setiastro/saspro/ops/scripts.py +119 -0
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +4 -0
- setiastro/saspro/ser_stack_config.py +74 -0
- setiastro/saspro/ser_stacker.py +2310 -0
- setiastro/saspro/ser_stacker_dialog.py +1500 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1258 -0
- setiastro/saspro/sfcc.py +602 -214
- setiastro/saspro/shortcuts.py +35 -16
- setiastro/saspro/stacking_suite.py +332 -87
- setiastro/saspro/star_alignment.py +243 -122
- setiastro/saspro/stat_stretch.py +220 -31
- setiastro/saspro/subwindow.py +2 -4
- setiastro/saspro/whitebalance.py +24 -0
- setiastro/saspro/widgets/resource_monitor.py +122 -74
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.post2.dist-info}/licenses/license.txt +0 -0
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
# src/setiastro/saspro/imageops/serloader.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import io
|
|
6
|
+
import mmap
|
|
7
|
+
import struct
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Optional, Tuple, Dict, List, Sequence, Union
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from PIL import Image
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------
|
|
22
|
+
# SER format notes (commonly used by FireCapture / SharpCap / etc.)
|
|
23
|
+
# - Header is 178 bytes (SER v3 style) and begins with ASCII signature
|
|
24
|
+
# typically "LUCAM-RECORDER" padded to 14 bytes.
|
|
25
|
+
# - Most fields are little-endian; header contains an "Endian" flag.
|
|
26
|
+
# - Frame data follows immediately after header, then optional timestamps
|
|
27
|
+
# (8 bytes per frame) at end.
|
|
28
|
+
# ---------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
SER_HEADER_SIZE = 178
|
|
31
|
+
SER_SIGNATURE_LEN = 14
|
|
32
|
+
|
|
33
|
+
# Common SER color IDs (seen in the wild)
|
|
34
|
+
# NOTE: Many SER writers use:
|
|
35
|
+
# 0 = MONO
|
|
36
|
+
# 8..11 = Bayer (RGGB/GRBG/GBRG/BGGR)
|
|
37
|
+
# 24..27 = RGB/BGR/RGBA/BGRA
|
|
38
|
+
SER_COLOR = {
|
|
39
|
+
0: "MONO",
|
|
40
|
+
|
|
41
|
+
# Bayer (most common in planetary SER)
|
|
42
|
+
8: "BAYER_RGGB",
|
|
43
|
+
9: "BAYER_GRBG",
|
|
44
|
+
10: "BAYER_GBRG",
|
|
45
|
+
11: "BAYER_BGGR",
|
|
46
|
+
|
|
47
|
+
# Packed color
|
|
48
|
+
24: "RGB",
|
|
49
|
+
25: "BGR",
|
|
50
|
+
26: "RGBA",
|
|
51
|
+
27: "BGRA",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
BAYER_NAMES = {"BAYER_RGGB", "BAYER_GRBG", "BAYER_GBRG", "BAYER_BGGR"}
|
|
55
|
+
BAYER_PATTERNS = ("BAYER_RGGB", "BAYER_GRBG", "BAYER_GBRG", "BAYER_BGGR")
|
|
56
|
+
|
|
57
|
+
def _normalize_bayer_pattern(p: Optional[str]) -> Optional[str]:
|
|
58
|
+
if not p:
|
|
59
|
+
return None
|
|
60
|
+
p = str(p).strip().upper()
|
|
61
|
+
if p == "AUTO":
|
|
62
|
+
return None
|
|
63
|
+
if p.startswith("BAYER_"):
|
|
64
|
+
if p in BAYER_PATTERNS:
|
|
65
|
+
return p
|
|
66
|
+
return None
|
|
67
|
+
# allow short names like "RGGB"
|
|
68
|
+
p2 = "BAYER_" + p
|
|
69
|
+
if p2 in BAYER_PATTERNS:
|
|
70
|
+
return p2
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class SerMeta:
|
|
75
|
+
path: str
|
|
76
|
+
width: int
|
|
77
|
+
height: int
|
|
78
|
+
frames: int
|
|
79
|
+
pixel_depth: int # bits per sample (8/16 typically)
|
|
80
|
+
color_id: int
|
|
81
|
+
color_name: str
|
|
82
|
+
little_endian: bool
|
|
83
|
+
data_offset: int
|
|
84
|
+
frame_bytes: int
|
|
85
|
+
has_timestamps: bool
|
|
86
|
+
|
|
87
|
+
observer: str = ""
|
|
88
|
+
instrument: str = ""
|
|
89
|
+
telescope: str = ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _decode_cstr(b: bytes) -> str:
|
|
93
|
+
try:
|
|
94
|
+
return b.split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
|
|
95
|
+
except Exception:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bytes_per_sample(pixel_depth_bits: int) -> int:
|
|
100
|
+
return 1 if int(pixel_depth_bits) <= 8 else 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _is_bayer(color_name: str) -> bool:
|
|
104
|
+
return color_name in BAYER_NAMES
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_rgb(color_name: str) -> bool:
|
|
108
|
+
return color_name in {"RGB", "BGR", "RGBA", "BGRA"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _roi_evenize_for_bayer(x: int, y: int) -> Tuple[int, int]:
|
|
112
|
+
"""Ensure ROI origin is even-even so Bayer phase doesn't flip."""
|
|
113
|
+
if x & 1:
|
|
114
|
+
x -= 1
|
|
115
|
+
if y & 1:
|
|
116
|
+
y -= 1
|
|
117
|
+
return max(0, x), max(0, y)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cv2_debayer(mosaic: np.ndarray, pattern: str) -> np.ndarray:
|
|
121
|
+
"""
|
|
122
|
+
mosaic: uint8/uint16, shape (H,W)
|
|
123
|
+
returns: RGB uint8/uint16, shape (H,W,3)
|
|
124
|
+
"""
|
|
125
|
+
if cv2 is None:
|
|
126
|
+
raise RuntimeError("OpenCV not available for debayer fallback.")
|
|
127
|
+
|
|
128
|
+
code_map = {
|
|
129
|
+
"BAYER_RGGB": cv2.COLOR_BayerRG2RGB,
|
|
130
|
+
"BAYER_BGGR": cv2.COLOR_BayerBG2RGB,
|
|
131
|
+
"BAYER_GBRG": cv2.COLOR_BayerGB2RGB,
|
|
132
|
+
"BAYER_GRBG": cv2.COLOR_BayerGR2RGB,
|
|
133
|
+
}
|
|
134
|
+
code = code_map.get(pattern)
|
|
135
|
+
if code is None:
|
|
136
|
+
raise ValueError(f"Unknown Bayer pattern: {pattern}")
|
|
137
|
+
return cv2.cvtColor(mosaic, code)
|
|
138
|
+
|
|
139
|
+
def _maybe_swap_rb_to_match_cv2(mosaic: np.ndarray, pattern: str, out: np.ndarray) -> np.ndarray:
|
|
140
|
+
"""
|
|
141
|
+
Ensure debayer output channel order matches OpenCV's RGB output.
|
|
142
|
+
Some fast debayers return BGR. We detect by comparing against cv2 on a small crop.
|
|
143
|
+
"""
|
|
144
|
+
if out is None or out.ndim != 3 or out.shape[2] < 3:
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
# Compare on a small center crop for speed
|
|
148
|
+
H, W = mosaic.shape[:2]
|
|
149
|
+
cs = min(96, H, W)
|
|
150
|
+
y0 = max(0, (H - cs) // 2)
|
|
151
|
+
x0 = max(0, (W - cs) // 2)
|
|
152
|
+
m = mosaic[y0:y0+cs, x0:x0+cs]
|
|
153
|
+
|
|
154
|
+
ref = _cv2_debayer(m, pattern) # RGB
|
|
155
|
+
|
|
156
|
+
o = out[y0:y0+cs, x0:x0+cs, :3]
|
|
157
|
+
if o.dtype != ref.dtype:
|
|
158
|
+
# compare in float to avoid overflow
|
|
159
|
+
o_f = o.astype(np.float32)
|
|
160
|
+
ref_f = ref.astype(np.float32)
|
|
161
|
+
else:
|
|
162
|
+
o_f = o.astype(np.float32)
|
|
163
|
+
ref_f = ref.astype(np.float32)
|
|
164
|
+
|
|
165
|
+
d_same = float(np.mean(np.abs(o_f - ref_f)))
|
|
166
|
+
d_swap = float(np.mean(np.abs(o_f[..., ::-1] - ref_f)))
|
|
167
|
+
|
|
168
|
+
return out[..., ::-1].copy() if d_swap < d_same else out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _try_numba_debayer(mosaic: np.ndarray, pattern: str) -> Optional[np.ndarray]:
|
|
172
|
+
"""
|
|
173
|
+
Try to use SASpro's fast debayer if available.
|
|
174
|
+
Expected functions (from your memory):
|
|
175
|
+
- debayer_raw_fast / debayer_fits_fast (names may differ in your tree)
|
|
176
|
+
We keep this very defensive; if not found, return None.
|
|
177
|
+
"""
|
|
178
|
+
# Try a few likely import locations without hard failing
|
|
179
|
+
candidates = [
|
|
180
|
+
("setiastro.saspro.imageops.debayer", "debayer_raw_fast"),
|
|
181
|
+
("setiastro.saspro.imageops.debayer", "debayer_fits_fast"),
|
|
182
|
+
("setiastro.saspro.imageops.debayer_fast", "debayer_raw_fast"),
|
|
183
|
+
("setiastro.saspro.imageops.debayer_fast", "debayer_fits_fast"),
|
|
184
|
+
]
|
|
185
|
+
for mod_name, fn_name in candidates:
|
|
186
|
+
try:
|
|
187
|
+
mod = __import__(mod_name, fromlist=[fn_name])
|
|
188
|
+
fn = getattr(mod, fn_name, None)
|
|
189
|
+
if fn is None:
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# Many fast debayers accept a mosaic and a bayer string or enum.
|
|
193
|
+
# We'll try a couple calling conventions.
|
|
194
|
+
try:
|
|
195
|
+
out = fn(mosaic, pattern) # type: ignore
|
|
196
|
+
if out is not None:
|
|
197
|
+
return out
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
out = fn(mosaic) # type: ignore
|
|
203
|
+
if out is not None:
|
|
204
|
+
return out
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
except Exception:
|
|
208
|
+
continue
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _LRUCache:
|
|
213
|
+
"""Tiny LRU cache for decoded frames."""
|
|
214
|
+
def __init__(self, max_items: int = 8):
|
|
215
|
+
self.max_items = int(max_items)
|
|
216
|
+
self._d: "OrderedDict[Tuple, np.ndarray]" = OrderedDict()
|
|
217
|
+
|
|
218
|
+
def get(self, key):
|
|
219
|
+
if key not in self._d:
|
|
220
|
+
return None
|
|
221
|
+
self._d.move_to_end(key)
|
|
222
|
+
return self._d[key]
|
|
223
|
+
|
|
224
|
+
def put(self, key, value: np.ndarray):
|
|
225
|
+
self._d[key] = value
|
|
226
|
+
self._d.move_to_end(key)
|
|
227
|
+
while len(self._d) > self.max_items:
|
|
228
|
+
self._d.popitem(last=False)
|
|
229
|
+
|
|
230
|
+
def clear(self):
|
|
231
|
+
self._d.clear()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class SERReader:
|
|
235
|
+
"""
|
|
236
|
+
Memory-mapped SER reader with:
|
|
237
|
+
- header parsing (common v3 layout)
|
|
238
|
+
- random frame access
|
|
239
|
+
- optional ROI (with Bayer parity protection)
|
|
240
|
+
- optional debayer
|
|
241
|
+
- tiny LRU cache for smooth preview scrubbing
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
def __init__(self, path: str, *, cache_items: int = 10):
|
|
245
|
+
self.path = os.fspath(path)
|
|
246
|
+
self._fh = open(self.path, "rb")
|
|
247
|
+
self._mm = mmap.mmap(self._fh.fileno(), 0, access=mmap.ACCESS_READ)
|
|
248
|
+
|
|
249
|
+
self.meta = self._parse_header(self._mm)
|
|
250
|
+
self.meta.path = self.path
|
|
251
|
+
self._cache = _LRUCache(max_items=cache_items)
|
|
252
|
+
self._fast_debayer_is_bgr: Optional[bool] = None
|
|
253
|
+
|
|
254
|
+
def close(self):
|
|
255
|
+
try:
|
|
256
|
+
self._cache.clear()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
try:
|
|
260
|
+
self._mm.close()
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
try:
|
|
264
|
+
self._fh.close()
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
def __enter__(self):
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(self, exc_type, exc, tb):
|
|
272
|
+
self.close()
|
|
273
|
+
|
|
274
|
+
# ---------------- header parsing ----------------
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _parse_header(mm: mmap.mmap) -> SerMeta:
|
|
277
|
+
if mm.size() < SER_HEADER_SIZE:
|
|
278
|
+
raise ValueError("File too small to be a SER file.")
|
|
279
|
+
|
|
280
|
+
hdr = mm[:SER_HEADER_SIZE]
|
|
281
|
+
|
|
282
|
+
sig = hdr[:SER_SIGNATURE_LEN]
|
|
283
|
+
sig_txt = _decode_cstr(sig)
|
|
284
|
+
|
|
285
|
+
# Be permissive: many SERs start with LUCAM-RECORDER
|
|
286
|
+
# If not, still try parsing.
|
|
287
|
+
# (Some writers use other signatures but the v3 field layout often matches.)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
(lu_id, color_id, little_endian_u32,
|
|
291
|
+
w, h, pixel_depth, frames) = struct.unpack_from("<7I", hdr, SER_SIGNATURE_LEN)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
raise ValueError(f"Failed to parse SER header fields: {e}")
|
|
294
|
+
|
|
295
|
+
little_endian = bool(little_endian_u32)
|
|
296
|
+
|
|
297
|
+
observer = _decode_cstr(hdr[42:82])
|
|
298
|
+
instrument = _decode_cstr(hdr[82:122])
|
|
299
|
+
telescope = _decode_cstr(hdr[122:162])
|
|
300
|
+
|
|
301
|
+
color_name = SER_COLOR.get(int(color_id), f"UNKNOWN({color_id})")
|
|
302
|
+
|
|
303
|
+
bps = _bytes_per_sample(int(pixel_depth))
|
|
304
|
+
data_offset = SER_HEADER_SIZE
|
|
305
|
+
file_size = mm.size()
|
|
306
|
+
|
|
307
|
+
def expected_size(frame_bytes: int, with_ts: bool) -> int:
|
|
308
|
+
base = data_offset + int(frames) * int(frame_bytes)
|
|
309
|
+
return base + (int(frames) * 8 if with_ts else 0)
|
|
310
|
+
|
|
311
|
+
# --- candidate interpretations ---
|
|
312
|
+
# Bayer/MONO: 1 sample per pixel
|
|
313
|
+
fb_1 = int(w) * int(h) * 1 * int(bps)
|
|
314
|
+
|
|
315
|
+
# RGB/BGR: 3 samples per pixel
|
|
316
|
+
fb_3 = int(w) * int(h) * 3 * int(bps)
|
|
317
|
+
|
|
318
|
+
# RGBA/BGRA: 4 samples per pixel
|
|
319
|
+
fb_4 = int(w) * int(h) * 4 * int(bps)
|
|
320
|
+
|
|
321
|
+
# Decide initial channels from color_name
|
|
322
|
+
if color_name in {"RGB", "BGR"}:
|
|
323
|
+
channels = 3
|
|
324
|
+
frame_bytes = fb_3
|
|
325
|
+
elif color_name in {"RGBA", "BGRA"}:
|
|
326
|
+
channels = 4
|
|
327
|
+
frame_bytes = fb_4
|
|
328
|
+
else:
|
|
329
|
+
# MONO + Bayer variants should land here
|
|
330
|
+
channels = 1
|
|
331
|
+
frame_bytes = fb_1
|
|
332
|
+
|
|
333
|
+
# --- sanity check against file size ---
|
|
334
|
+
# If the header mapping is wrong (very common culprit), infer channels by file size.
|
|
335
|
+
# We consider both "no timestamps" and "with timestamps".
|
|
336
|
+
def matches(frame_bytes: int) -> tuple[bool, bool]:
|
|
337
|
+
no_ts = (file_size == expected_size(frame_bytes, with_ts=False))
|
|
338
|
+
yes_ts = (file_size == expected_size(frame_bytes, with_ts=True))
|
|
339
|
+
return no_ts, yes_ts
|
|
340
|
+
|
|
341
|
+
m1_no, m1_ts = matches(fb_1)
|
|
342
|
+
m3_no, m3_ts = matches(fb_3)
|
|
343
|
+
m4_no, m4_ts = matches(fb_4)
|
|
344
|
+
|
|
345
|
+
# Prefer an exact match if one exists.
|
|
346
|
+
# Tie-break: if header says Bayer-ish, prefer 1ch; if header says RGB-ish, prefer 3/4ch.
|
|
347
|
+
picked = None # (channels, frame_bytes, has_ts)
|
|
348
|
+
|
|
349
|
+
# If our current interpretation matches, keep it
|
|
350
|
+
cur_no, cur_ts = matches(frame_bytes)
|
|
351
|
+
if cur_no or cur_ts:
|
|
352
|
+
picked = (channels, frame_bytes, bool(cur_ts))
|
|
353
|
+
|
|
354
|
+
else:
|
|
355
|
+
# Try to infer by file size
|
|
356
|
+
# Unique matches:
|
|
357
|
+
candidates = []
|
|
358
|
+
if m1_no: candidates.append((1, fb_1, False))
|
|
359
|
+
if m1_ts: candidates.append((1, fb_1, True))
|
|
360
|
+
if m3_no: candidates.append((3, fb_3, False))
|
|
361
|
+
if m3_ts: candidates.append((3, fb_3, True))
|
|
362
|
+
if m4_no: candidates.append((4, fb_4, False))
|
|
363
|
+
if m4_ts: candidates.append((4, fb_4, True))
|
|
364
|
+
|
|
365
|
+
if len(candidates) == 1:
|
|
366
|
+
picked = candidates[0]
|
|
367
|
+
elif len(candidates) > 1:
|
|
368
|
+
# tie-break using header hint
|
|
369
|
+
if _is_bayer(color_name) or color_name == "MONO":
|
|
370
|
+
# choose first 1ch match
|
|
371
|
+
for c in candidates:
|
|
372
|
+
if c[0] == 1:
|
|
373
|
+
picked = c
|
|
374
|
+
break
|
|
375
|
+
elif color_name in {"RGB", "BGR"}:
|
|
376
|
+
for c in candidates:
|
|
377
|
+
if c[0] == 3:
|
|
378
|
+
picked = c
|
|
379
|
+
break
|
|
380
|
+
elif color_name in {"RGBA", "BGRA"}:
|
|
381
|
+
for c in candidates:
|
|
382
|
+
if c[0] == 4:
|
|
383
|
+
picked = c
|
|
384
|
+
break
|
|
385
|
+
# still ambiguous: just pick the first (rare)
|
|
386
|
+
if picked is None:
|
|
387
|
+
picked = candidates[0]
|
|
388
|
+
|
|
389
|
+
if picked is None:
|
|
390
|
+
# Couldn’t reconcile sizes; fall back to header interpretation and best-effort ts flag
|
|
391
|
+
expected_no_ts = expected_size(frame_bytes, with_ts=False)
|
|
392
|
+
expected_with_ts = expected_size(frame_bytes, with_ts=True)
|
|
393
|
+
has_ts = (file_size == expected_with_ts)
|
|
394
|
+
else:
|
|
395
|
+
channels, frame_bytes, has_ts = picked
|
|
396
|
+
|
|
397
|
+
# If we inferred channels that contradict the header color_name, adjust color_name
|
|
398
|
+
# so the rest of the pipeline (debayer, etc.) behaves sensibly.
|
|
399
|
+
if channels == 1:
|
|
400
|
+
# If header said RGB but file is clearly 1ch, it's almost certainly Bayer.
|
|
401
|
+
# Keep UNKNOWN(...) if we truly don't know the Bayer order.
|
|
402
|
+
if color_name in {"RGB", "BGR", "RGBA", "BGRA"}:
|
|
403
|
+
# safest default: treat as RGGB if we have no better info
|
|
404
|
+
color_name = "BAYER_RGGB"
|
|
405
|
+
elif channels == 3:
|
|
406
|
+
if color_name not in {"RGB", "BGR"}:
|
|
407
|
+
color_name = "RGB"
|
|
408
|
+
elif channels == 4:
|
|
409
|
+
if color_name not in {"RGBA", "BGRA"}:
|
|
410
|
+
color_name = "RGBA"
|
|
411
|
+
|
|
412
|
+
return SerMeta(
|
|
413
|
+
path="",
|
|
414
|
+
width=int(w),
|
|
415
|
+
height=int(h),
|
|
416
|
+
frames=int(frames),
|
|
417
|
+
pixel_depth=int(pixel_depth),
|
|
418
|
+
color_id=int(color_id),
|
|
419
|
+
color_name=color_name,
|
|
420
|
+
little_endian=little_endian,
|
|
421
|
+
data_offset=int(data_offset),
|
|
422
|
+
frame_bytes=int(frame_bytes),
|
|
423
|
+
has_timestamps=bool(has_ts),
|
|
424
|
+
observer=observer,
|
|
425
|
+
instrument=instrument,
|
|
426
|
+
telescope=telescope,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------- core access ----------------
|
|
431
|
+
|
|
432
|
+
def frame_offset(self, i: int) -> int:
|
|
433
|
+
i = int(i)
|
|
434
|
+
if i < 0 or i >= self.meta.frames:
|
|
435
|
+
raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
|
|
436
|
+
return self.meta.data_offset + i * self.meta.frame_bytes
|
|
437
|
+
|
|
438
|
+
def get_frame(
|
|
439
|
+
self,
|
|
440
|
+
i: int,
|
|
441
|
+
*,
|
|
442
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
443
|
+
debayer: bool = True,
|
|
444
|
+
to_float01: bool = False,
|
|
445
|
+
force_rgb: bool = False,
|
|
446
|
+
bayer_pattern: Optional[str] = None, # ✅ NEW
|
|
447
|
+
) -> np.ndarray:
|
|
448
|
+
"""
|
|
449
|
+
Returns:
|
|
450
|
+
- MONO: (H,W) uint8/uint16 or float32 [0..1]
|
|
451
|
+
- RGB: (H,W,3) uint8/uint16 or float32 [0..1]
|
|
452
|
+
|
|
453
|
+
roi is applied before debayer (and ROI origin evenized for Bayer).
|
|
454
|
+
"""
|
|
455
|
+
meta = self.meta
|
|
456
|
+
color_name = meta.color_name
|
|
457
|
+
user_pat = _normalize_bayer_pattern(bayer_pattern)
|
|
458
|
+
active_bayer = user_pat if user_pat is not None else (color_name if _is_bayer(color_name) else None)
|
|
459
|
+
|
|
460
|
+
# Cache key includes ROI + flags
|
|
461
|
+
roi_key = None if roi is None else tuple(int(v) for v in roi)
|
|
462
|
+
key = (int(i), roi_key, bool(debayer), active_bayer, bool(to_float01), bool(force_rgb))
|
|
463
|
+
cached = self._cache.get(key)
|
|
464
|
+
if cached is not None:
|
|
465
|
+
return cached
|
|
466
|
+
|
|
467
|
+
off = self.frame_offset(i)
|
|
468
|
+
buf = self._mm[off:off + meta.frame_bytes]
|
|
469
|
+
|
|
470
|
+
bps = _bytes_per_sample(meta.pixel_depth)
|
|
471
|
+
if bps == 1:
|
|
472
|
+
dtype = np.uint8
|
|
473
|
+
else:
|
|
474
|
+
dtype = np.uint16
|
|
475
|
+
|
|
476
|
+
# Determine channels stored
|
|
477
|
+
if color_name in {"RGB", "BGR"}:
|
|
478
|
+
ch = 3
|
|
479
|
+
elif color_name in {"RGBA", "BGRA"}:
|
|
480
|
+
ch = 4
|
|
481
|
+
else:
|
|
482
|
+
ch = 1
|
|
483
|
+
|
|
484
|
+
arr = np.frombuffer(buf, dtype=dtype)
|
|
485
|
+
|
|
486
|
+
# byteswap if big-endian storage (rare, but spec supports it)
|
|
487
|
+
if (dtype == np.uint16) and (not meta.little_endian):
|
|
488
|
+
arr = arr.byteswap()
|
|
489
|
+
|
|
490
|
+
if ch == 1:
|
|
491
|
+
img = arr.reshape(meta.height, meta.width)
|
|
492
|
+
else:
|
|
493
|
+
img = arr.reshape(meta.height, meta.width, ch)
|
|
494
|
+
|
|
495
|
+
# ROI (apply before debayer; for Bayer enforce even-even origin)
|
|
496
|
+
if roi is not None:
|
|
497
|
+
x, y, w, h = [int(v) for v in roi]
|
|
498
|
+
x = max(0, min(meta.width - 1, x))
|
|
499
|
+
y = max(0, min(meta.height - 1, y))
|
|
500
|
+
w = max(1, min(meta.width - x, w))
|
|
501
|
+
h = max(1, min(meta.height - y, h))
|
|
502
|
+
|
|
503
|
+
if (active_bayer is not None) and debayer:
|
|
504
|
+
x, y = _roi_evenize_for_bayer(x, y)
|
|
505
|
+
w = max(1, min(meta.width - x, w))
|
|
506
|
+
h = max(1, min(meta.height - y, h))
|
|
507
|
+
|
|
508
|
+
img = img[y:y + h, x:x + w]
|
|
509
|
+
|
|
510
|
+
# Convert BGR->RGB if needed
|
|
511
|
+
if color_name == "BGR" and img.ndim == 3 and img.shape[2] >= 3:
|
|
512
|
+
img = img[..., ::-1].copy()
|
|
513
|
+
|
|
514
|
+
# Debayer if needed
|
|
515
|
+
if _is_bayer(color_name):
|
|
516
|
+
if debayer:
|
|
517
|
+
mosaic = img if img.ndim == 2 else img[..., 0]
|
|
518
|
+
pat = active_bayer or color_name # active_bayer will usually be set here
|
|
519
|
+
|
|
520
|
+
out = _try_numba_debayer(mosaic, pat)
|
|
521
|
+
if out is None:
|
|
522
|
+
out = _cv2_debayer(mosaic, pat) # already RGB
|
|
523
|
+
else:
|
|
524
|
+
out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
|
|
525
|
+
|
|
526
|
+
img = out
|
|
527
|
+
else:
|
|
528
|
+
img = img if img.ndim == 2 else img[..., 0]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# Force RGB for mono (useful for consistent preview pipeline)
|
|
532
|
+
if force_rgb and img.ndim == 2:
|
|
533
|
+
img = np.stack([img, img, img], axis=-1)
|
|
534
|
+
|
|
535
|
+
# Normalize to float01
|
|
536
|
+
if to_float01:
|
|
537
|
+
if img.dtype == np.uint8:
|
|
538
|
+
img = img.astype(np.float32) / 255.0
|
|
539
|
+
elif img.dtype == np.uint16:
|
|
540
|
+
img = img.astype(np.float32) / 65535.0
|
|
541
|
+
else:
|
|
542
|
+
img = img.astype(np.float32)
|
|
543
|
+
img = np.clip(img, 0.0, 1.0)
|
|
544
|
+
|
|
545
|
+
self._cache.put(key, img)
|
|
546
|
+
return img
|
|
547
|
+
|
|
548
|
+
def get_timestamp_ns(self, i: int) -> Optional[int]:
|
|
549
|
+
"""
|
|
550
|
+
If timestamps exist, returns the 64-bit timestamp value for frame i.
|
|
551
|
+
(Interpretation depends on writer; often 100ns ticks or nanoseconds.)
|
|
552
|
+
"""
|
|
553
|
+
meta = self.meta
|
|
554
|
+
if not meta.has_timestamps:
|
|
555
|
+
return None
|
|
556
|
+
i = int(i)
|
|
557
|
+
if i < 0 or i >= meta.frames:
|
|
558
|
+
return None
|
|
559
|
+
ts_base = meta.data_offset + meta.frames * meta.frame_bytes
|
|
560
|
+
off = ts_base + i * 8
|
|
561
|
+
b = self._mm[off:off + 8]
|
|
562
|
+
if len(b) != 8:
|
|
563
|
+
return None
|
|
564
|
+
(v,) = struct.unpack("<Q", b)
|
|
565
|
+
return int(v)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# -----------------------------
|
|
570
|
+
# Common reader interface/meta
|
|
571
|
+
# -----------------------------
|
|
572
|
+
|
|
573
|
+
@dataclass
|
|
574
|
+
class PlanetaryMeta:
|
|
575
|
+
"""
|
|
576
|
+
Common metadata shape used by SERViewer / stacker.
|
|
577
|
+
"""
|
|
578
|
+
path: str
|
|
579
|
+
width: int
|
|
580
|
+
height: int
|
|
581
|
+
frames: int
|
|
582
|
+
pixel_depth: int # 8/16 typical (AVI usually 8)
|
|
583
|
+
color_name: str # "MONO", "RGB", "BGR", "BAYER_*", etc
|
|
584
|
+
has_timestamps: bool = False
|
|
585
|
+
source_kind: str = "unknown" # "ser" / "avi" / "sequence"
|
|
586
|
+
file_list: Optional[List[str]] = None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class PlanetaryFrameSource:
|
|
590
|
+
"""
|
|
591
|
+
Minimal protocol-like base. (Duck-typed by viewer/stacker)
|
|
592
|
+
"""
|
|
593
|
+
meta: PlanetaryMeta
|
|
594
|
+
path: str
|
|
595
|
+
|
|
596
|
+
def close(self) -> None:
|
|
597
|
+
raise NotImplementedError
|
|
598
|
+
|
|
599
|
+
def get_frame(
|
|
600
|
+
self,
|
|
601
|
+
i: int,
|
|
602
|
+
*,
|
|
603
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
604
|
+
debayer: bool = True,
|
|
605
|
+
to_float01: bool = False,
|
|
606
|
+
force_rgb: bool = False,
|
|
607
|
+
bayer_pattern: Optional[str] = None, # ✅ NEW
|
|
608
|
+
) -> np.ndarray:
|
|
609
|
+
raise NotImplementedError
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# -----------------------------
|
|
613
|
+
# AVI reader (OpenCV)
|
|
614
|
+
# -----------------------------
|
|
615
|
+
|
|
616
|
+
class AVIReader(PlanetaryFrameSource):
|
|
617
|
+
"""
|
|
618
|
+
Frame-accurate random access using cv2.VideoCapture.
|
|
619
|
+
Notes:
|
|
620
|
+
- Many codecs only support approximate seeking; good enough for preview/scrub.
|
|
621
|
+
- Frames come out as BGR uint8 by default.
|
|
622
|
+
"""
|
|
623
|
+
def __init__(self, path: str, *, cache_items: int = 10):
|
|
624
|
+
if cv2 is None:
|
|
625
|
+
raise RuntimeError("OpenCV (cv2) is required to read AVI files.")
|
|
626
|
+
self.path = os.fspath(path)
|
|
627
|
+
self._cap = cv2.VideoCapture(self.path)
|
|
628
|
+
if not self._cap.isOpened():
|
|
629
|
+
raise ValueError(f"Failed to open video: {self.path}")
|
|
630
|
+
|
|
631
|
+
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
|
632
|
+
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
|
633
|
+
n = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
|
634
|
+
|
|
635
|
+
# AVI decoded frames are almost always 8-bit
|
|
636
|
+
self.meta = PlanetaryMeta(
|
|
637
|
+
path=self.path,
|
|
638
|
+
width=w,
|
|
639
|
+
height=h,
|
|
640
|
+
frames=max(0, n),
|
|
641
|
+
pixel_depth=8,
|
|
642
|
+
color_name="BGR",
|
|
643
|
+
has_timestamps=False,
|
|
644
|
+
source_kind="avi",
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
self._cache = _LRUCache(max_items=cache_items)
|
|
648
|
+
|
|
649
|
+
def close(self):
|
|
650
|
+
try:
|
|
651
|
+
self._cache.clear()
|
|
652
|
+
except Exception:
|
|
653
|
+
pass
|
|
654
|
+
try:
|
|
655
|
+
if self._cap is not None:
|
|
656
|
+
self._cap.release()
|
|
657
|
+
except Exception:
|
|
658
|
+
pass
|
|
659
|
+
|
|
660
|
+
def __enter__(self):
|
|
661
|
+
return self
|
|
662
|
+
|
|
663
|
+
def __exit__(self, exc_type, exc, tb):
|
|
664
|
+
self.close()
|
|
665
|
+
|
|
666
|
+
def _read_raw_frame_bgr(self, i: int) -> np.ndarray:
|
|
667
|
+
i = int(i)
|
|
668
|
+
if i < 0 or (self.meta.frames > 0 and i >= self.meta.frames):
|
|
669
|
+
raise IndexError(f"Frame index {i} out of range")
|
|
670
|
+
|
|
671
|
+
# Seek
|
|
672
|
+
self._cap.set(cv2.CAP_PROP_POS_FRAMES, float(i))
|
|
673
|
+
ok, frame = self._cap.read()
|
|
674
|
+
if not ok or frame is None:
|
|
675
|
+
raise ValueError(f"Failed to read frame {i}")
|
|
676
|
+
|
|
677
|
+
# frame is BGR uint8, shape (H,W,3)
|
|
678
|
+
return frame
|
|
679
|
+
|
|
680
|
+
def get_frame(
|
|
681
|
+
self,
|
|
682
|
+
i: int,
|
|
683
|
+
*,
|
|
684
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
685
|
+
debayer: bool = True,
|
|
686
|
+
to_float01: bool = False,
|
|
687
|
+
force_rgb: bool = False,
|
|
688
|
+
bayer_pattern: Optional[str] = None,
|
|
689
|
+
) -> np.ndarray:
|
|
690
|
+
|
|
691
|
+
roi_key = None if roi is None else tuple(int(v) for v in roi)
|
|
692
|
+
|
|
693
|
+
# User pattern:
|
|
694
|
+
# - None means AUTO (do not force debayer on 3-channel video)
|
|
695
|
+
# - A real value means: user explicitly wants debayering
|
|
696
|
+
user_pat = _normalize_bayer_pattern(bayer_pattern) # None == AUTO
|
|
697
|
+
pat_for_key = user_pat or "AUTO"
|
|
698
|
+
|
|
699
|
+
key = ("avi", int(i), roi_key, bool(debayer), pat_for_key, bool(to_float01), bool(force_rgb))
|
|
700
|
+
cached = self._cache.get(key)
|
|
701
|
+
if cached is not None:
|
|
702
|
+
return cached
|
|
703
|
+
|
|
704
|
+
frame = self._read_raw_frame_bgr(i) # usually (H,W,3) uint8 BGR
|
|
705
|
+
|
|
706
|
+
# ROI first (but if we are going to debayer mosaic, ROI origin must be even-even)
|
|
707
|
+
if roi is not None:
|
|
708
|
+
x, y, w, h = [int(v) for v in roi]
|
|
709
|
+
H, W = frame.shape[:2]
|
|
710
|
+
x = max(0, min(W - 1, x))
|
|
711
|
+
y = max(0, min(H - 1, y))
|
|
712
|
+
w = max(1, min(W - x, w))
|
|
713
|
+
h = max(1, min(H - y, h))
|
|
714
|
+
|
|
715
|
+
# If user explicitly requests debayering, preserve Bayer phase
|
|
716
|
+
# (even-even origin) exactly like SER
|
|
717
|
+
if debayer and user_pat is not None:
|
|
718
|
+
x, y = _roi_evenize_for_bayer(x, y)
|
|
719
|
+
w = max(1, min(W - x, w))
|
|
720
|
+
h = max(1, min(H - y, h))
|
|
721
|
+
|
|
722
|
+
frame = frame[y:y + h, x:x + w]
|
|
723
|
+
|
|
724
|
+
img: np.ndarray
|
|
725
|
+
|
|
726
|
+
# ---------------------------------------------------------
|
|
727
|
+
# RAW MOSAIC AVI SUPPORT
|
|
728
|
+
#
|
|
729
|
+
# OpenCV often returns 3-channel frames even when the AVI is
|
|
730
|
+
# conceptually "raw mosaic". In that case, ONLY debayer when
|
|
731
|
+
# the user explicitly selected a Bayer pattern (not AUTO).
|
|
732
|
+
# ---------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
# True mosaic frame decoded as single-channel
|
|
735
|
+
is_true_mosaic = (frame.ndim == 2) or (frame.ndim == 3 and frame.shape[2] == 1)
|
|
736
|
+
|
|
737
|
+
if debayer and (is_true_mosaic or (user_pat is not None)):
|
|
738
|
+
# If it's 3-channel but user requested debayer, treat as packed mosaic:
|
|
739
|
+
# take one channel (they should be identical if it's really mosaic-packed).
|
|
740
|
+
if frame.ndim == 3 and frame.shape[2] >= 3:
|
|
741
|
+
mosaic = frame[..., 0] # any channel is fine for packed mosaic
|
|
742
|
+
else:
|
|
743
|
+
mosaic = frame if frame.ndim == 2 else frame[..., 0]
|
|
744
|
+
|
|
745
|
+
# Choose pattern:
|
|
746
|
+
# - user_pat is guaranteed not None here if it's forced on 3ch
|
|
747
|
+
# - if it’s true mosaic and user left AUTO, default RGGB
|
|
748
|
+
pat = user_pat or "BAYER_RGGB"
|
|
749
|
+
|
|
750
|
+
out = _try_numba_debayer(mosaic, pat)
|
|
751
|
+
if out is None:
|
|
752
|
+
out = _cv2_debayer(mosaic, pat) # RGB
|
|
753
|
+
else:
|
|
754
|
+
out = _maybe_swap_rb_to_match_cv2(mosaic, pat, out)
|
|
755
|
+
|
|
756
|
+
img = out # RGB
|
|
757
|
+
|
|
758
|
+
else:
|
|
759
|
+
# Normal video path: decoded BGR -> RGB
|
|
760
|
+
if frame.ndim == 3 and frame.shape[2] >= 3:
|
|
761
|
+
img = frame[..., ::-1].copy()
|
|
762
|
+
else:
|
|
763
|
+
# Rare: frame came out mono but debayer is off
|
|
764
|
+
img = frame if frame.ndim == 2 else frame[..., 0]
|
|
765
|
+
if force_rgb:
|
|
766
|
+
img = np.stack([img, img, img], axis=-1)
|
|
767
|
+
|
|
768
|
+
# Normalize
|
|
769
|
+
if to_float01:
|
|
770
|
+
if img.dtype == np.uint8:
|
|
771
|
+
img = img.astype(np.float32) / 255.0
|
|
772
|
+
elif img.dtype == np.uint16:
|
|
773
|
+
img = img.astype(np.float32) / 65535.0
|
|
774
|
+
else:
|
|
775
|
+
img = np.clip(img.astype(np.float32), 0.0, 1.0)
|
|
776
|
+
|
|
777
|
+
# Optional force_rgb (mostly relevant if debayer=False and frame is mono)
|
|
778
|
+
if force_rgb and img.ndim == 2:
|
|
779
|
+
img = np.stack([img, img, img], axis=-1)
|
|
780
|
+
|
|
781
|
+
self._cache.put(key, img)
|
|
782
|
+
return img
|
|
783
|
+
|
|
784
|
+
# -----------------------------
|
|
785
|
+
# Image-sequence reader
|
|
786
|
+
# -----------------------------
|
|
787
|
+
|
|
788
|
+
def _imread_any(path: str) -> np.ndarray:
|
|
789
|
+
"""
|
|
790
|
+
Read PNG/JPG/TIF/etc into numpy.
|
|
791
|
+
Tries cv2 first (fast), falls back to PIL.
|
|
792
|
+
Returns:
|
|
793
|
+
- grayscale: (H,W) uint8/uint16
|
|
794
|
+
- color: (H,W,3) uint8/uint16 in RGB (we normalize to RGB)
|
|
795
|
+
"""
|
|
796
|
+
p = os.fspath(path)
|
|
797
|
+
|
|
798
|
+
# Prefer cv2 if available
|
|
799
|
+
if cv2 is not None:
|
|
800
|
+
img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
|
801
|
+
if img is not None:
|
|
802
|
+
# cv2 gives:
|
|
803
|
+
# - gray: HxW
|
|
804
|
+
# - color: HxWx3 (BGR)
|
|
805
|
+
# - sometimes HxWx4 (BGRA)
|
|
806
|
+
if img.ndim == 3 and img.shape[2] >= 3:
|
|
807
|
+
img = img[..., :3] # drop alpha if present
|
|
808
|
+
img = img[..., ::-1].copy() # BGR -> RGB
|
|
809
|
+
return img
|
|
810
|
+
|
|
811
|
+
# PIL fallback
|
|
812
|
+
if Image is None:
|
|
813
|
+
raise RuntimeError("Neither OpenCV nor PIL are available to read images.")
|
|
814
|
+
im = Image.open(p)
|
|
815
|
+
# Preserve 16-bit if possible; PIL handles many TIFFs.
|
|
816
|
+
if im.mode in ("I;16", "I;16B", "I"):
|
|
817
|
+
arr = np.array(im)
|
|
818
|
+
return arr
|
|
819
|
+
if im.mode in ("L",):
|
|
820
|
+
return np.array(im)
|
|
821
|
+
im = im.convert("RGB")
|
|
822
|
+
return np.array(im)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _infer_bit_depth(arr: np.ndarray) -> int:
|
|
826
|
+
if arr.dtype == np.uint16:
|
|
827
|
+
return 16
|
|
828
|
+
if arr.dtype == np.uint8:
|
|
829
|
+
return 8
|
|
830
|
+
# if float, assume 32 for “depth”
|
|
831
|
+
if arr.dtype in (np.float32, np.float64):
|
|
832
|
+
return 32
|
|
833
|
+
return 8
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
class ImageSequenceReader(PlanetaryFrameSource):
|
|
837
|
+
"""
|
|
838
|
+
Reads a list of image files as frames.
|
|
839
|
+
Supports random access; caches decoded frames for smooth scrubbing.
|
|
840
|
+
"""
|
|
841
|
+
def __init__(self, files: Sequence[str], *, cache_items: int = 10):
|
|
842
|
+
flist = [os.fspath(f) for f in files]
|
|
843
|
+
if not flist:
|
|
844
|
+
raise ValueError("Empty image sequence.")
|
|
845
|
+
self.files = flist
|
|
846
|
+
self.path = flist[0]
|
|
847
|
+
|
|
848
|
+
# Probe first frame
|
|
849
|
+
first = _imread_any(flist[0])
|
|
850
|
+
h, w = first.shape[:2]
|
|
851
|
+
depth = _infer_bit_depth(first)
|
|
852
|
+
if first.ndim == 2:
|
|
853
|
+
cname = "MONO"
|
|
854
|
+
else:
|
|
855
|
+
cname = "RGB"
|
|
856
|
+
|
|
857
|
+
self.meta = PlanetaryMeta(
|
|
858
|
+
path=self.path,
|
|
859
|
+
width=int(w),
|
|
860
|
+
height=int(h),
|
|
861
|
+
frames=len(flist),
|
|
862
|
+
pixel_depth=int(depth),
|
|
863
|
+
color_name=cname,
|
|
864
|
+
has_timestamps=False,
|
|
865
|
+
source_kind="sequence",
|
|
866
|
+
file_list=list(flist),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
self._cache = _LRUCache(max_items=cache_items)
|
|
870
|
+
|
|
871
|
+
def close(self):
|
|
872
|
+
try:
|
|
873
|
+
self._cache.clear()
|
|
874
|
+
except Exception:
|
|
875
|
+
pass
|
|
876
|
+
|
|
877
|
+
def __enter__(self):
|
|
878
|
+
return self
|
|
879
|
+
|
|
880
|
+
def __exit__(self, exc_type, exc, tb):
|
|
881
|
+
self.close()
|
|
882
|
+
|
|
883
|
+
def get_frame(
|
|
884
|
+
self,
|
|
885
|
+
i: int,
|
|
886
|
+
*,
|
|
887
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
888
|
+
debayer: bool = True,
|
|
889
|
+
to_float01: bool = False,
|
|
890
|
+
force_rgb: bool = False,
|
|
891
|
+
bayer_pattern: Optional[str] = None,
|
|
892
|
+
) -> np.ndarray:
|
|
893
|
+
_ = debayer, bayer_pattern # unused for sequences (for now)
|
|
894
|
+
i = int(i)
|
|
895
|
+
if i < 0 or i >= self.meta.frames:
|
|
896
|
+
raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
|
|
897
|
+
|
|
898
|
+
roi_key = None if roi is None else tuple(int(v) for v in roi)
|
|
899
|
+
key = ("seq", i, roi_key, bool(to_float01), bool(force_rgb))
|
|
900
|
+
cached = self._cache.get(key)
|
|
901
|
+
if cached is not None:
|
|
902
|
+
return cached
|
|
903
|
+
|
|
904
|
+
img = _imread_any(self.files[i])
|
|
905
|
+
|
|
906
|
+
# Basic consistency checks (don’t hard fail; some sequences have slight differences)
|
|
907
|
+
# If sizes differ, we’ll just use whatever comes back for that frame.
|
|
908
|
+
H, W = img.shape[:2]
|
|
909
|
+
|
|
910
|
+
# ROI
|
|
911
|
+
if roi is not None:
|
|
912
|
+
x, y, w, h = [int(v) for v in roi]
|
|
913
|
+
x = max(0, min(W - 1, x))
|
|
914
|
+
y = max(0, min(H - 1, y))
|
|
915
|
+
w = max(1, min(W - x, w))
|
|
916
|
+
h = max(1, min(H - y, h))
|
|
917
|
+
img = img[y:y + h, x:x + w]
|
|
918
|
+
|
|
919
|
+
# Force RGB for mono
|
|
920
|
+
if force_rgb and img.ndim == 2:
|
|
921
|
+
img = np.stack([img, img, img], axis=-1)
|
|
922
|
+
|
|
923
|
+
# Normalize to float01
|
|
924
|
+
if to_float01:
|
|
925
|
+
if img.dtype == np.uint8:
|
|
926
|
+
img = img.astype(np.float32) / 255.0
|
|
927
|
+
elif img.dtype == np.uint16:
|
|
928
|
+
img = img.astype(np.float32) / 65535.0
|
|
929
|
+
else:
|
|
930
|
+
img = img.astype(np.float32)
|
|
931
|
+
img = np.clip(img, 0.0, 1.0)
|
|
932
|
+
|
|
933
|
+
self._cache.put(key, img)
|
|
934
|
+
return img
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# -----------------------------
|
|
938
|
+
# Factory
|
|
939
|
+
# -----------------------------
|
|
940
|
+
|
|
941
|
+
def open_planetary_source(
|
|
942
|
+
path_or_files: Union[str, Sequence[str]],
|
|
943
|
+
*,
|
|
944
|
+
cache_items: int = 10,
|
|
945
|
+
) -> PlanetaryFrameSource:
|
|
946
|
+
"""
|
|
947
|
+
Open SER / AVI / image sequence under one API.
|
|
948
|
+
"""
|
|
949
|
+
# Sequence
|
|
950
|
+
if not isinstance(path_or_files, (str, os.PathLike)):
|
|
951
|
+
return ImageSequenceReader(path_or_files, cache_items=cache_items)
|
|
952
|
+
|
|
953
|
+
path = os.fspath(path_or_files)
|
|
954
|
+
ext = os.path.splitext(path)[1].lower()
|
|
955
|
+
|
|
956
|
+
if ext == ".ser":
|
|
957
|
+
r = SERReader(path, cache_items=cache_items)
|
|
958
|
+
# ---- SER tweak: ensure meta.path is set ----
|
|
959
|
+
try:
|
|
960
|
+
r.meta.path = path # type: ignore
|
|
961
|
+
except Exception:
|
|
962
|
+
pass
|
|
963
|
+
return r
|
|
964
|
+
|
|
965
|
+
if ext in (".avi", ".mp4", ".mov", ".mkv"):
|
|
966
|
+
return AVIReader(path, cache_items=cache_items)
|
|
967
|
+
|
|
968
|
+
# If user passes a single image, treat it as a 1-frame sequence
|
|
969
|
+
if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp"):
|
|
970
|
+
return ImageSequenceReader([path], cache_items=cache_items)
|
|
971
|
+
|
|
972
|
+
raise ValueError(f"Unsupported input: {path}")
|