setiastrosuitepro 1.6.10__py3-none-any.whl → 1.7.0__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 +218 -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 +31 -1
- setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
- setiastro/saspro/histogram.py +179 -7
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +769 -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 +68 -0
- setiastro/saspro/ser_stacker.py +2245 -0
- setiastro/saspro/ser_stacker_dialog.py +1481 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1242 -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.dist-info}/METADATA +2 -2
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
- {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/license.txt +0 -0
|
@@ -0,0 +1,769 @@
|
|
|
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
|
+
SER_COLOR = {
|
|
35
|
+
0: "MONO", # mono
|
|
36
|
+
8: "RGB", # RGB24/RGB48 depending pixel depth
|
|
37
|
+
9: "BGR", # BGR24/BGR48
|
|
38
|
+
10: "RGBA",
|
|
39
|
+
11: "BGRA",
|
|
40
|
+
12: "BAYER_RGGB",
|
|
41
|
+
13: "BAYER_GRBG",
|
|
42
|
+
14: "BAYER_GBRG",
|
|
43
|
+
15: "BAYER_BGGR",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
BAYER_NAMES = {"BAYER_RGGB", "BAYER_GRBG", "BAYER_GBRG", "BAYER_BGGR"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SerMeta:
|
|
51
|
+
path: str
|
|
52
|
+
width: int
|
|
53
|
+
height: int
|
|
54
|
+
frames: int
|
|
55
|
+
pixel_depth: int # bits per sample (8/16 typically)
|
|
56
|
+
color_id: int
|
|
57
|
+
color_name: str
|
|
58
|
+
little_endian: bool
|
|
59
|
+
data_offset: int
|
|
60
|
+
frame_bytes: int
|
|
61
|
+
has_timestamps: bool
|
|
62
|
+
|
|
63
|
+
observer: str = ""
|
|
64
|
+
instrument: str = ""
|
|
65
|
+
telescope: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _decode_cstr(b: bytes) -> str:
|
|
69
|
+
try:
|
|
70
|
+
return b.split(b"\x00", 1)[0].decode("utf-8", errors="ignore").strip()
|
|
71
|
+
except Exception:
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _bytes_per_sample(pixel_depth_bits: int) -> int:
|
|
76
|
+
return 1 if int(pixel_depth_bits) <= 8 else 2
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_bayer(color_name: str) -> bool:
|
|
80
|
+
return color_name in BAYER_NAMES
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_rgb(color_name: str) -> bool:
|
|
84
|
+
return color_name in {"RGB", "BGR", "RGBA", "BGRA"}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _roi_evenize_for_bayer(x: int, y: int) -> Tuple[int, int]:
|
|
88
|
+
"""Ensure ROI origin is even-even so Bayer phase doesn't flip."""
|
|
89
|
+
if x & 1:
|
|
90
|
+
x -= 1
|
|
91
|
+
if y & 1:
|
|
92
|
+
y -= 1
|
|
93
|
+
return max(0, x), max(0, y)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cv2_debayer(mosaic: np.ndarray, pattern: str) -> np.ndarray:
|
|
97
|
+
"""
|
|
98
|
+
mosaic: uint8/uint16, shape (H,W)
|
|
99
|
+
returns: RGB uint8/uint16, shape (H,W,3)
|
|
100
|
+
"""
|
|
101
|
+
if cv2 is None:
|
|
102
|
+
raise RuntimeError("OpenCV not available for debayer fallback.")
|
|
103
|
+
|
|
104
|
+
code_map = {
|
|
105
|
+
"BAYER_RGGB": cv2.COLOR_BayerRG2RGB,
|
|
106
|
+
"BAYER_BGGR": cv2.COLOR_BayerBG2RGB,
|
|
107
|
+
"BAYER_GBRG": cv2.COLOR_BayerGB2RGB,
|
|
108
|
+
"BAYER_GRBG": cv2.COLOR_BayerGR2RGB,
|
|
109
|
+
}
|
|
110
|
+
code = code_map.get(pattern)
|
|
111
|
+
if code is None:
|
|
112
|
+
raise ValueError(f"Unknown Bayer pattern: {pattern}")
|
|
113
|
+
return cv2.cvtColor(mosaic, code)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _try_numba_debayer(mosaic: np.ndarray, pattern: str) -> Optional[np.ndarray]:
|
|
117
|
+
"""
|
|
118
|
+
Try to use SASpro's fast debayer if available.
|
|
119
|
+
Expected functions (from your memory):
|
|
120
|
+
- debayer_raw_fast / debayer_fits_fast (names may differ in your tree)
|
|
121
|
+
We keep this very defensive; if not found, return None.
|
|
122
|
+
"""
|
|
123
|
+
# Try a few likely import locations without hard failing
|
|
124
|
+
candidates = [
|
|
125
|
+
("setiastro.saspro.imageops.debayer", "debayer_raw_fast"),
|
|
126
|
+
("setiastro.saspro.imageops.debayer", "debayer_fits_fast"),
|
|
127
|
+
("setiastro.saspro.imageops.debayer_fast", "debayer_raw_fast"),
|
|
128
|
+
("setiastro.saspro.imageops.debayer_fast", "debayer_fits_fast"),
|
|
129
|
+
]
|
|
130
|
+
for mod_name, fn_name in candidates:
|
|
131
|
+
try:
|
|
132
|
+
mod = __import__(mod_name, fromlist=[fn_name])
|
|
133
|
+
fn = getattr(mod, fn_name, None)
|
|
134
|
+
if fn is None:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Many fast debayers accept a mosaic and a bayer string or enum.
|
|
138
|
+
# We'll try a couple calling conventions.
|
|
139
|
+
try:
|
|
140
|
+
out = fn(mosaic, pattern) # type: ignore
|
|
141
|
+
if out is not None:
|
|
142
|
+
return out
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
out = fn(mosaic) # type: ignore
|
|
148
|
+
if out is not None:
|
|
149
|
+
return out
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
except Exception:
|
|
153
|
+
continue
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _LRUCache:
|
|
158
|
+
"""Tiny LRU cache for decoded frames."""
|
|
159
|
+
def __init__(self, max_items: int = 8):
|
|
160
|
+
self.max_items = int(max_items)
|
|
161
|
+
self._d: "OrderedDict[Tuple, np.ndarray]" = OrderedDict()
|
|
162
|
+
|
|
163
|
+
def get(self, key):
|
|
164
|
+
if key not in self._d:
|
|
165
|
+
return None
|
|
166
|
+
self._d.move_to_end(key)
|
|
167
|
+
return self._d[key]
|
|
168
|
+
|
|
169
|
+
def put(self, key, value: np.ndarray):
|
|
170
|
+
self._d[key] = value
|
|
171
|
+
self._d.move_to_end(key)
|
|
172
|
+
while len(self._d) > self.max_items:
|
|
173
|
+
self._d.popitem(last=False)
|
|
174
|
+
|
|
175
|
+
def clear(self):
|
|
176
|
+
self._d.clear()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class SERReader:
|
|
180
|
+
"""
|
|
181
|
+
Memory-mapped SER reader with:
|
|
182
|
+
- header parsing (common v3 layout)
|
|
183
|
+
- random frame access
|
|
184
|
+
- optional ROI (with Bayer parity protection)
|
|
185
|
+
- optional debayer
|
|
186
|
+
- tiny LRU cache for smooth preview scrubbing
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, path: str, *, cache_items: int = 10):
|
|
190
|
+
self.path = os.fspath(path)
|
|
191
|
+
self._fh = open(self.path, "rb")
|
|
192
|
+
self._mm = mmap.mmap(self._fh.fileno(), 0, access=mmap.ACCESS_READ)
|
|
193
|
+
|
|
194
|
+
self.meta = self._parse_header(self._mm)
|
|
195
|
+
self.meta.path = self.path
|
|
196
|
+
self._cache = _LRUCache(max_items=cache_items)
|
|
197
|
+
|
|
198
|
+
def close(self):
|
|
199
|
+
try:
|
|
200
|
+
self._cache.clear()
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
try:
|
|
204
|
+
self._mm.close()
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
try:
|
|
208
|
+
self._fh.close()
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
def __enter__(self):
|
|
213
|
+
return self
|
|
214
|
+
|
|
215
|
+
def __exit__(self, exc_type, exc, tb):
|
|
216
|
+
self.close()
|
|
217
|
+
|
|
218
|
+
# ---------------- header parsing ----------------
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _parse_header(mm: mmap.mmap) -> SerMeta:
|
|
222
|
+
if mm.size() < SER_HEADER_SIZE:
|
|
223
|
+
raise ValueError("File too small to be a SER file.")
|
|
224
|
+
|
|
225
|
+
hdr = mm[:SER_HEADER_SIZE]
|
|
226
|
+
|
|
227
|
+
sig = hdr[:SER_SIGNATURE_LEN]
|
|
228
|
+
sig_txt = _decode_cstr(sig)
|
|
229
|
+
|
|
230
|
+
# Be permissive: many SERs start with LUCAM-RECORDER
|
|
231
|
+
if "LUCAM" not in sig_txt.upper():
|
|
232
|
+
# still try parsing; some writers differ, but fields often match
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
# Layout (little-endian) commonly:
|
|
236
|
+
# 0: 14 bytes signature
|
|
237
|
+
# 14: uint32 LuID
|
|
238
|
+
# 18: uint32 ColorID
|
|
239
|
+
# 22: uint32 LittleEndian (0/1)
|
|
240
|
+
# 26: uint32 ImageWidth
|
|
241
|
+
# 30: uint32 ImageHeight
|
|
242
|
+
# 34: uint32 PixelDepth
|
|
243
|
+
# 38: uint32 FrameCount
|
|
244
|
+
# 42: char[40] Observer
|
|
245
|
+
# 82: char[40] Instrument
|
|
246
|
+
# 122: char[40] Telescope
|
|
247
|
+
# 162: uint64 DateTime
|
|
248
|
+
# 170: uint64 DateTimeUTC
|
|
249
|
+
try:
|
|
250
|
+
(lu_id, color_id, little_endian_u32,
|
|
251
|
+
w, h, pixel_depth, frames) = struct.unpack_from("<7I", hdr, SER_SIGNATURE_LEN)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
raise ValueError(f"Failed to parse SER header fields: {e}")
|
|
254
|
+
|
|
255
|
+
little_endian = bool(little_endian_u32)
|
|
256
|
+
|
|
257
|
+
observer = _decode_cstr(hdr[42:82])
|
|
258
|
+
instrument = _decode_cstr(hdr[82:122])
|
|
259
|
+
telescope = _decode_cstr(hdr[122:162])
|
|
260
|
+
|
|
261
|
+
color_name = SER_COLOR.get(int(color_id), f"UNKNOWN({color_id})")
|
|
262
|
+
|
|
263
|
+
bps = _bytes_per_sample(int(pixel_depth))
|
|
264
|
+
|
|
265
|
+
# channels per pixel:
|
|
266
|
+
# - MONO or BAYER: 1 sample per pixel
|
|
267
|
+
# - RGB/BGR: 3
|
|
268
|
+
# - RGBA/BGRA: 4 (rare in SER)
|
|
269
|
+
if color_name in {"RGB", "BGR"}:
|
|
270
|
+
channels = 3
|
|
271
|
+
elif color_name in {"RGBA", "BGRA"}:
|
|
272
|
+
channels = 4
|
|
273
|
+
else:
|
|
274
|
+
channels = 1
|
|
275
|
+
|
|
276
|
+
frame_bytes = int(w) * int(h) * int(channels) * int(bps)
|
|
277
|
+
data_offset = SER_HEADER_SIZE
|
|
278
|
+
|
|
279
|
+
# timestamps detection
|
|
280
|
+
expected_no_ts = data_offset + frames * frame_bytes
|
|
281
|
+
expected_with_ts = expected_no_ts + frames * 8
|
|
282
|
+
size = mm.size()
|
|
283
|
+
has_ts = (size == expected_with_ts)
|
|
284
|
+
|
|
285
|
+
return SerMeta(
|
|
286
|
+
path="",
|
|
287
|
+
width=int(w),
|
|
288
|
+
height=int(h),
|
|
289
|
+
frames=int(frames),
|
|
290
|
+
pixel_depth=int(pixel_depth),
|
|
291
|
+
color_id=int(color_id),
|
|
292
|
+
color_name=color_name,
|
|
293
|
+
little_endian=little_endian,
|
|
294
|
+
data_offset=data_offset,
|
|
295
|
+
frame_bytes=int(frame_bytes),
|
|
296
|
+
has_timestamps=bool(has_ts),
|
|
297
|
+
observer=observer,
|
|
298
|
+
instrument=instrument,
|
|
299
|
+
telescope=telescope,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# ---------------- core access ----------------
|
|
303
|
+
|
|
304
|
+
def frame_offset(self, i: int) -> int:
|
|
305
|
+
i = int(i)
|
|
306
|
+
if i < 0 or i >= self.meta.frames:
|
|
307
|
+
raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
|
|
308
|
+
return self.meta.data_offset + i * self.meta.frame_bytes
|
|
309
|
+
|
|
310
|
+
def get_frame(
|
|
311
|
+
self,
|
|
312
|
+
i: int,
|
|
313
|
+
*,
|
|
314
|
+
roi: Optional[Tuple[int, int, int, int]] = None, # x,y,w,h
|
|
315
|
+
debayer: bool = True,
|
|
316
|
+
to_float01: bool = False,
|
|
317
|
+
force_rgb: bool = False,
|
|
318
|
+
) -> np.ndarray:
|
|
319
|
+
"""
|
|
320
|
+
Returns:
|
|
321
|
+
- MONO: (H,W) uint8/uint16 or float32 [0..1]
|
|
322
|
+
- RGB: (H,W,3) uint8/uint16 or float32 [0..1]
|
|
323
|
+
|
|
324
|
+
roi is applied before debayer (and ROI origin evenized for Bayer).
|
|
325
|
+
"""
|
|
326
|
+
meta = self.meta
|
|
327
|
+
|
|
328
|
+
# Cache key includes ROI + flags
|
|
329
|
+
roi_key = None if roi is None else tuple(int(v) for v in roi)
|
|
330
|
+
key = (int(i), roi_key, bool(debayer), bool(to_float01), bool(force_rgb))
|
|
331
|
+
cached = self._cache.get(key)
|
|
332
|
+
if cached is not None:
|
|
333
|
+
return cached
|
|
334
|
+
|
|
335
|
+
off = self.frame_offset(i)
|
|
336
|
+
buf = self._mm[off:off + meta.frame_bytes]
|
|
337
|
+
|
|
338
|
+
bps = _bytes_per_sample(meta.pixel_depth)
|
|
339
|
+
if bps == 1:
|
|
340
|
+
dtype = np.uint8
|
|
341
|
+
else:
|
|
342
|
+
dtype = np.uint16
|
|
343
|
+
|
|
344
|
+
# Determine channels stored
|
|
345
|
+
color_name = meta.color_name
|
|
346
|
+
if color_name in {"RGB", "BGR"}:
|
|
347
|
+
ch = 3
|
|
348
|
+
elif color_name in {"RGBA", "BGRA"}:
|
|
349
|
+
ch = 4
|
|
350
|
+
else:
|
|
351
|
+
ch = 1
|
|
352
|
+
|
|
353
|
+
arr = np.frombuffer(buf, dtype=dtype)
|
|
354
|
+
|
|
355
|
+
# byteswap if big-endian storage (rare, but spec supports it)
|
|
356
|
+
if (dtype == np.uint16) and (not meta.little_endian):
|
|
357
|
+
arr = arr.byteswap()
|
|
358
|
+
|
|
359
|
+
if ch == 1:
|
|
360
|
+
img = arr.reshape(meta.height, meta.width)
|
|
361
|
+
else:
|
|
362
|
+
img = arr.reshape(meta.height, meta.width, ch)
|
|
363
|
+
|
|
364
|
+
# ROI (apply before debayer; for Bayer enforce even-even origin)
|
|
365
|
+
if roi is not None:
|
|
366
|
+
x, y, w, h = [int(v) for v in roi]
|
|
367
|
+
x = max(0, min(meta.width - 1, x))
|
|
368
|
+
y = max(0, min(meta.height - 1, y))
|
|
369
|
+
w = max(1, min(meta.width - x, w))
|
|
370
|
+
h = max(1, min(meta.height - y, h))
|
|
371
|
+
|
|
372
|
+
if _is_bayer(color_name) and debayer:
|
|
373
|
+
x, y = _roi_evenize_for_bayer(x, y)
|
|
374
|
+
w = max(1, min(meta.width - x, w))
|
|
375
|
+
h = max(1, min(meta.height - y, h))
|
|
376
|
+
|
|
377
|
+
img = img[y:y + h, x:x + w]
|
|
378
|
+
|
|
379
|
+
# Convert BGR->RGB if needed
|
|
380
|
+
if color_name == "BGR" and img.ndim == 3 and img.shape[2] >= 3:
|
|
381
|
+
img = img[..., ::-1].copy()
|
|
382
|
+
|
|
383
|
+
# Debayer if needed
|
|
384
|
+
if _is_bayer(color_name):
|
|
385
|
+
if debayer:
|
|
386
|
+
mosaic = img if img.ndim == 2 else img[..., 0]
|
|
387
|
+
out = _try_numba_debayer(mosaic, color_name)
|
|
388
|
+
if out is None:
|
|
389
|
+
out = _cv2_debayer(mosaic, color_name)
|
|
390
|
+
img = out
|
|
391
|
+
else:
|
|
392
|
+
# keep mosaic as mono
|
|
393
|
+
img = img if img.ndim == 2 else img[..., 0]
|
|
394
|
+
|
|
395
|
+
# Force RGB for mono (useful for consistent preview pipeline)
|
|
396
|
+
if force_rgb and img.ndim == 2:
|
|
397
|
+
img = np.stack([img, img, img], axis=-1)
|
|
398
|
+
|
|
399
|
+
# Normalize to float01
|
|
400
|
+
if to_float01:
|
|
401
|
+
if img.dtype == np.uint8:
|
|
402
|
+
img = img.astype(np.float32) / 255.0
|
|
403
|
+
elif img.dtype == np.uint16:
|
|
404
|
+
img = img.astype(np.float32) / 65535.0
|
|
405
|
+
else:
|
|
406
|
+
img = img.astype(np.float32)
|
|
407
|
+
img = np.clip(img, 0.0, 1.0)
|
|
408
|
+
|
|
409
|
+
self._cache.put(key, img)
|
|
410
|
+
return img
|
|
411
|
+
|
|
412
|
+
def get_timestamp_ns(self, i: int) -> Optional[int]:
|
|
413
|
+
"""
|
|
414
|
+
If timestamps exist, returns the 64-bit timestamp value for frame i.
|
|
415
|
+
(Interpretation depends on writer; often 100ns ticks or nanoseconds.)
|
|
416
|
+
"""
|
|
417
|
+
meta = self.meta
|
|
418
|
+
if not meta.has_timestamps:
|
|
419
|
+
return None
|
|
420
|
+
i = int(i)
|
|
421
|
+
if i < 0 or i >= meta.frames:
|
|
422
|
+
return None
|
|
423
|
+
ts_base = meta.data_offset + meta.frames * meta.frame_bytes
|
|
424
|
+
off = ts_base + i * 8
|
|
425
|
+
b = self._mm[off:off + 8]
|
|
426
|
+
if len(b) != 8:
|
|
427
|
+
return None
|
|
428
|
+
(v,) = struct.unpack("<Q", b)
|
|
429
|
+
return int(v)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# -----------------------------
|
|
434
|
+
# Common reader interface/meta
|
|
435
|
+
# -----------------------------
|
|
436
|
+
|
|
437
|
+
@dataclass
|
|
438
|
+
class PlanetaryMeta:
|
|
439
|
+
"""
|
|
440
|
+
Common metadata shape used by SERViewer / stacker.
|
|
441
|
+
"""
|
|
442
|
+
path: str
|
|
443
|
+
width: int
|
|
444
|
+
height: int
|
|
445
|
+
frames: int
|
|
446
|
+
pixel_depth: int # 8/16 typical (AVI usually 8)
|
|
447
|
+
color_name: str # "MONO", "RGB", "BGR", "BAYER_*", etc
|
|
448
|
+
has_timestamps: bool = False
|
|
449
|
+
source_kind: str = "unknown" # "ser" / "avi" / "sequence"
|
|
450
|
+
file_list: Optional[List[str]] = None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class PlanetaryFrameSource:
|
|
454
|
+
"""
|
|
455
|
+
Minimal protocol-like base. (Duck-typed by viewer/stacker)
|
|
456
|
+
"""
|
|
457
|
+
meta: PlanetaryMeta
|
|
458
|
+
path: str
|
|
459
|
+
|
|
460
|
+
def close(self) -> None:
|
|
461
|
+
raise NotImplementedError
|
|
462
|
+
|
|
463
|
+
def get_frame(
|
|
464
|
+
self,
|
|
465
|
+
i: int,
|
|
466
|
+
*,
|
|
467
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
468
|
+
debayer: bool = True,
|
|
469
|
+
to_float01: bool = False,
|
|
470
|
+
force_rgb: bool = False,
|
|
471
|
+
) -> np.ndarray:
|
|
472
|
+
raise NotImplementedError
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# -----------------------------
|
|
476
|
+
# AVI reader (OpenCV)
|
|
477
|
+
# -----------------------------
|
|
478
|
+
|
|
479
|
+
class AVIReader(PlanetaryFrameSource):
|
|
480
|
+
"""
|
|
481
|
+
Frame-accurate random access using cv2.VideoCapture.
|
|
482
|
+
Notes:
|
|
483
|
+
- Many codecs only support approximate seeking; good enough for preview/scrub.
|
|
484
|
+
- Frames come out as BGR uint8 by default.
|
|
485
|
+
"""
|
|
486
|
+
def __init__(self, path: str, *, cache_items: int = 10):
|
|
487
|
+
if cv2 is None:
|
|
488
|
+
raise RuntimeError("OpenCV (cv2) is required to read AVI files.")
|
|
489
|
+
self.path = os.fspath(path)
|
|
490
|
+
self._cap = cv2.VideoCapture(self.path)
|
|
491
|
+
if not self._cap.isOpened():
|
|
492
|
+
raise ValueError(f"Failed to open video: {self.path}")
|
|
493
|
+
|
|
494
|
+
w = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
|
495
|
+
h = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
|
496
|
+
n = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
|
497
|
+
|
|
498
|
+
# AVI decoded frames are almost always 8-bit
|
|
499
|
+
self.meta = PlanetaryMeta(
|
|
500
|
+
path=self.path,
|
|
501
|
+
width=w,
|
|
502
|
+
height=h,
|
|
503
|
+
frames=max(0, n),
|
|
504
|
+
pixel_depth=8,
|
|
505
|
+
color_name="BGR",
|
|
506
|
+
has_timestamps=False,
|
|
507
|
+
source_kind="avi",
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
self._cache = _LRUCache(max_items=cache_items)
|
|
511
|
+
|
|
512
|
+
def close(self):
|
|
513
|
+
try:
|
|
514
|
+
self._cache.clear()
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
try:
|
|
518
|
+
if self._cap is not None:
|
|
519
|
+
self._cap.release()
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
def __enter__(self):
|
|
524
|
+
return self
|
|
525
|
+
|
|
526
|
+
def __exit__(self, exc_type, exc, tb):
|
|
527
|
+
self.close()
|
|
528
|
+
|
|
529
|
+
def _read_raw_frame_bgr(self, i: int) -> np.ndarray:
|
|
530
|
+
i = int(i)
|
|
531
|
+
if i < 0 or (self.meta.frames > 0 and i >= self.meta.frames):
|
|
532
|
+
raise IndexError(f"Frame index {i} out of range")
|
|
533
|
+
|
|
534
|
+
# Seek
|
|
535
|
+
self._cap.set(cv2.CAP_PROP_POS_FRAMES, float(i))
|
|
536
|
+
ok, frame = self._cap.read()
|
|
537
|
+
if not ok or frame is None:
|
|
538
|
+
raise ValueError(f"Failed to read frame {i}")
|
|
539
|
+
|
|
540
|
+
# frame is BGR uint8, shape (H,W,3)
|
|
541
|
+
return frame
|
|
542
|
+
|
|
543
|
+
def get_frame(
|
|
544
|
+
self,
|
|
545
|
+
i: int,
|
|
546
|
+
*,
|
|
547
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
548
|
+
debayer: bool = True,
|
|
549
|
+
to_float01: bool = False,
|
|
550
|
+
force_rgb: bool = False,
|
|
551
|
+
) -> np.ndarray:
|
|
552
|
+
roi_key = None if roi is None else tuple(int(v) for v in roi)
|
|
553
|
+
key = ("avi", int(i), roi_key, bool(to_float01), bool(force_rgb))
|
|
554
|
+
cached = self._cache.get(key)
|
|
555
|
+
if cached is not None:
|
|
556
|
+
return cached
|
|
557
|
+
|
|
558
|
+
bgr = self._read_raw_frame_bgr(i)
|
|
559
|
+
|
|
560
|
+
# ROI
|
|
561
|
+
if roi is not None:
|
|
562
|
+
x, y, w, h = [int(v) for v in roi]
|
|
563
|
+
H, W = bgr.shape[:2]
|
|
564
|
+
x = max(0, min(W - 1, x))
|
|
565
|
+
y = max(0, min(H - 1, y))
|
|
566
|
+
w = max(1, min(W - x, w))
|
|
567
|
+
h = max(1, min(H - y, h))
|
|
568
|
+
bgr = bgr[y:y + h, x:x + w]
|
|
569
|
+
|
|
570
|
+
# BGR -> RGB
|
|
571
|
+
rgb = bgr[..., ::-1].copy()
|
|
572
|
+
|
|
573
|
+
img: np.ndarray = rgb
|
|
574
|
+
|
|
575
|
+
# force_rgb no-op (already rgb)
|
|
576
|
+
if to_float01:
|
|
577
|
+
img = img.astype(np.float32) / 255.0
|
|
578
|
+
|
|
579
|
+
self._cache.put(key, img)
|
|
580
|
+
return img
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# -----------------------------
|
|
584
|
+
# Image-sequence reader
|
|
585
|
+
# -----------------------------
|
|
586
|
+
|
|
587
|
+
def _imread_any(path: str) -> np.ndarray:
|
|
588
|
+
"""
|
|
589
|
+
Read PNG/JPG/TIF/etc into numpy.
|
|
590
|
+
Tries cv2 first (fast), falls back to PIL.
|
|
591
|
+
Returns:
|
|
592
|
+
- grayscale: (H,W) uint8/uint16
|
|
593
|
+
- color: (H,W,3) uint8/uint16 in RGB (we normalize to RGB)
|
|
594
|
+
"""
|
|
595
|
+
p = os.fspath(path)
|
|
596
|
+
|
|
597
|
+
# Prefer cv2 if available
|
|
598
|
+
if cv2 is not None:
|
|
599
|
+
img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
|
600
|
+
if img is not None:
|
|
601
|
+
# cv2 gives:
|
|
602
|
+
# - gray: HxW
|
|
603
|
+
# - color: HxWx3 (BGR)
|
|
604
|
+
# - sometimes HxWx4 (BGRA)
|
|
605
|
+
if img.ndim == 3 and img.shape[2] >= 3:
|
|
606
|
+
img = img[..., :3] # drop alpha if present
|
|
607
|
+
img = img[..., ::-1].copy() # BGR -> RGB
|
|
608
|
+
return img
|
|
609
|
+
|
|
610
|
+
# PIL fallback
|
|
611
|
+
if Image is None:
|
|
612
|
+
raise RuntimeError("Neither OpenCV nor PIL are available to read images.")
|
|
613
|
+
im = Image.open(p)
|
|
614
|
+
# Preserve 16-bit if possible; PIL handles many TIFFs.
|
|
615
|
+
if im.mode in ("I;16", "I;16B", "I"):
|
|
616
|
+
arr = np.array(im)
|
|
617
|
+
return arr
|
|
618
|
+
if im.mode in ("L",):
|
|
619
|
+
return np.array(im)
|
|
620
|
+
im = im.convert("RGB")
|
|
621
|
+
return np.array(im)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _infer_bit_depth(arr: np.ndarray) -> int:
|
|
625
|
+
if arr.dtype == np.uint16:
|
|
626
|
+
return 16
|
|
627
|
+
if arr.dtype == np.uint8:
|
|
628
|
+
return 8
|
|
629
|
+
# if float, assume 32 for “depth”
|
|
630
|
+
if arr.dtype in (np.float32, np.float64):
|
|
631
|
+
return 32
|
|
632
|
+
return 8
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
class ImageSequenceReader(PlanetaryFrameSource):
|
|
636
|
+
"""
|
|
637
|
+
Reads a list of image files as frames.
|
|
638
|
+
Supports random access; caches decoded frames for smooth scrubbing.
|
|
639
|
+
"""
|
|
640
|
+
def __init__(self, files: Sequence[str], *, cache_items: int = 10):
|
|
641
|
+
flist = [os.fspath(f) for f in files]
|
|
642
|
+
if not flist:
|
|
643
|
+
raise ValueError("Empty image sequence.")
|
|
644
|
+
self.files = flist
|
|
645
|
+
self.path = flist[0]
|
|
646
|
+
|
|
647
|
+
# Probe first frame
|
|
648
|
+
first = _imread_any(flist[0])
|
|
649
|
+
h, w = first.shape[:2]
|
|
650
|
+
depth = _infer_bit_depth(first)
|
|
651
|
+
if first.ndim == 2:
|
|
652
|
+
cname = "MONO"
|
|
653
|
+
else:
|
|
654
|
+
cname = "RGB"
|
|
655
|
+
|
|
656
|
+
self.meta = PlanetaryMeta(
|
|
657
|
+
path=self.path,
|
|
658
|
+
width=int(w),
|
|
659
|
+
height=int(h),
|
|
660
|
+
frames=len(flist),
|
|
661
|
+
pixel_depth=int(depth),
|
|
662
|
+
color_name=cname,
|
|
663
|
+
has_timestamps=False,
|
|
664
|
+
source_kind="sequence",
|
|
665
|
+
file_list=list(flist),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
self._cache = _LRUCache(max_items=cache_items)
|
|
669
|
+
|
|
670
|
+
def close(self):
|
|
671
|
+
try:
|
|
672
|
+
self._cache.clear()
|
|
673
|
+
except Exception:
|
|
674
|
+
pass
|
|
675
|
+
|
|
676
|
+
def __enter__(self):
|
|
677
|
+
return self
|
|
678
|
+
|
|
679
|
+
def __exit__(self, exc_type, exc, tb):
|
|
680
|
+
self.close()
|
|
681
|
+
|
|
682
|
+
def get_frame(
|
|
683
|
+
self,
|
|
684
|
+
i: int,
|
|
685
|
+
*,
|
|
686
|
+
roi: Optional[Tuple[int, int, int, int]] = None,
|
|
687
|
+
debayer: bool = True,
|
|
688
|
+
to_float01: bool = False,
|
|
689
|
+
force_rgb: bool = False,
|
|
690
|
+
) -> np.ndarray:
|
|
691
|
+
i = int(i)
|
|
692
|
+
if i < 0 or i >= self.meta.frames:
|
|
693
|
+
raise IndexError(f"Frame index {i} out of range (0..{self.meta.frames-1})")
|
|
694
|
+
|
|
695
|
+
roi_key = None if roi is None else tuple(int(v) for v in roi)
|
|
696
|
+
key = ("seq", i, roi_key, bool(to_float01), bool(force_rgb))
|
|
697
|
+
cached = self._cache.get(key)
|
|
698
|
+
if cached is not None:
|
|
699
|
+
return cached
|
|
700
|
+
|
|
701
|
+
img = _imread_any(self.files[i])
|
|
702
|
+
|
|
703
|
+
# Basic consistency checks (don’t hard fail; some sequences have slight differences)
|
|
704
|
+
# If sizes differ, we’ll just use whatever comes back for that frame.
|
|
705
|
+
H, W = img.shape[:2]
|
|
706
|
+
|
|
707
|
+
# ROI
|
|
708
|
+
if roi is not None:
|
|
709
|
+
x, y, w, h = [int(v) for v in roi]
|
|
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
|
+
img = img[y:y + h, x:x + w]
|
|
715
|
+
|
|
716
|
+
# Force RGB for mono
|
|
717
|
+
if force_rgb and img.ndim == 2:
|
|
718
|
+
img = np.stack([img, img, img], axis=-1)
|
|
719
|
+
|
|
720
|
+
# Normalize to float01
|
|
721
|
+
if to_float01:
|
|
722
|
+
if img.dtype == np.uint8:
|
|
723
|
+
img = img.astype(np.float32) / 255.0
|
|
724
|
+
elif img.dtype == np.uint16:
|
|
725
|
+
img = img.astype(np.float32) / 65535.0
|
|
726
|
+
else:
|
|
727
|
+
img = img.astype(np.float32)
|
|
728
|
+
img = np.clip(img, 0.0, 1.0)
|
|
729
|
+
|
|
730
|
+
self._cache.put(key, img)
|
|
731
|
+
return img
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
# -----------------------------
|
|
735
|
+
# Factory
|
|
736
|
+
# -----------------------------
|
|
737
|
+
|
|
738
|
+
def open_planetary_source(
|
|
739
|
+
path_or_files: Union[str, Sequence[str]],
|
|
740
|
+
*,
|
|
741
|
+
cache_items: int = 10,
|
|
742
|
+
) -> PlanetaryFrameSource:
|
|
743
|
+
"""
|
|
744
|
+
Open SER / AVI / image sequence under one API.
|
|
745
|
+
"""
|
|
746
|
+
# Sequence
|
|
747
|
+
if not isinstance(path_or_files, (str, os.PathLike)):
|
|
748
|
+
return ImageSequenceReader(path_or_files, cache_items=cache_items)
|
|
749
|
+
|
|
750
|
+
path = os.fspath(path_or_files)
|
|
751
|
+
ext = os.path.splitext(path)[1].lower()
|
|
752
|
+
|
|
753
|
+
if ext == ".ser":
|
|
754
|
+
r = SERReader(path, cache_items=cache_items)
|
|
755
|
+
# ---- SER tweak: ensure meta.path is set ----
|
|
756
|
+
try:
|
|
757
|
+
r.meta.path = path # type: ignore
|
|
758
|
+
except Exception:
|
|
759
|
+
pass
|
|
760
|
+
return r
|
|
761
|
+
|
|
762
|
+
if ext in (".avi", ".mp4", ".mov", ".mkv"):
|
|
763
|
+
return AVIReader(path, cache_items=cache_items)
|
|
764
|
+
|
|
765
|
+
# If user passes a single image, treat it as a 1-frame sequence
|
|
766
|
+
if ext in (".png", ".tif", ".tiff", ".jpg", ".jpeg", ".bmp", ".webp"):
|
|
767
|
+
return ImageSequenceReader([path], cache_items=cache_items)
|
|
768
|
+
|
|
769
|
+
raise ValueError(f"Unsupported input: {path}")
|