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.
- setiastro/images/TextureClarity.svg +56 -0
- setiastro/images/abeicon.svg +16 -0
- setiastro/images/acv_icon.png +0 -0
- setiastro/images/colorwheel.svg +97 -0
- setiastro/images/cosmic.svg +40 -0
- setiastro/images/cosmicsat.svg +24 -0
- setiastro/images/first_quarter.png +0 -0
- setiastro/images/full_moon.png +0 -0
- setiastro/images/graxpert.svg +19 -0
- setiastro/images/last_quarter.png +0 -0
- setiastro/images/linearfit.svg +32 -0
- setiastro/images/narrowbandnormalization.png +0 -0
- setiastro/images/new_moon.png +0 -0
- setiastro/images/pixelmath.svg +42 -0
- setiastro/images/planetarystacker.png +0 -0
- setiastro/images/waning_crescent_1.png +0 -0
- setiastro/images/waning_crescent_2.png +0 -0
- setiastro/images/waning_crescent_3.png +0 -0
- setiastro/images/waning_crescent_4.png +0 -0
- setiastro/images/waning_crescent_5.png +0 -0
- setiastro/images/waning_gibbous_1.png +0 -0
- setiastro/images/waning_gibbous_2.png +0 -0
- setiastro/images/waning_gibbous_3.png +0 -0
- setiastro/images/waning_gibbous_4.png +0 -0
- setiastro/images/waning_gibbous_5.png +0 -0
- setiastro/images/waxing_crescent_1.png +0 -0
- setiastro/images/waxing_crescent_2.png +0 -0
- setiastro/images/waxing_crescent_3.png +0 -0
- setiastro/images/waxing_crescent_4.png +0 -0
- setiastro/images/waxing_crescent_5.png +0 -0
- setiastro/images/waxing_gibbous_1.png +0 -0
- setiastro/images/waxing_gibbous_2.png +0 -0
- setiastro/images/waxing_gibbous_3.png +0 -0
- setiastro/images/waxing_gibbous_4.png +0 -0
- setiastro/images/waxing_gibbous_5.png +0 -0
- setiastro/qml/ResourceMonitor.qml +84 -82
- setiastro/saspro/__main__.py +20 -1
- setiastro/saspro/_generated/build_info.py +2 -2
- setiastro/saspro/abe.py +37 -4
- setiastro/saspro/aberration_ai.py +364 -33
- setiastro/saspro/aberration_ai_preset.py +29 -3
- setiastro/saspro/acv_exporter.py +379 -0
- setiastro/saspro/add_stars.py +33 -6
- setiastro/saspro/astrospike_python.py +45 -3
- setiastro/saspro/backgroundneutral.py +108 -40
- setiastro/saspro/blemish_blaster.py +4 -1
- setiastro/saspro/blink_comparator_pro.py +150 -55
- setiastro/saspro/clahe.py +4 -1
- setiastro/saspro/continuum_subtract.py +4 -1
- setiastro/saspro/convo.py +13 -7
- setiastro/saspro/cosmicclarity.py +129 -18
- setiastro/saspro/crop_dialog_pro.py +123 -7
- setiastro/saspro/curve_editor_pro.py +181 -64
- setiastro/saspro/curves_preset.py +249 -47
- setiastro/saspro/doc_manager.py +245 -15
- setiastro/saspro/exoplanet_detector.py +120 -28
- setiastro/saspro/frequency_separation.py +1158 -204
- setiastro/saspro/ghs_dialog_pro.py +81 -16
- setiastro/saspro/graxpert.py +1 -0
- setiastro/saspro/gui/main_window.py +706 -264
- 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 +35 -1
- setiastro/saspro/gui/mixins/theme_mixin.py +160 -14
- setiastro/saspro/gui/mixins/toolbar_mixin.py +499 -24
- setiastro/saspro/gui/mixins/update_mixin.py +138 -36
- setiastro/saspro/gui/mixins/view_mixin.py +42 -0
- setiastro/saspro/halobgon.py +4 -0
- setiastro/saspro/histogram.py +184 -8
- setiastro/saspro/image_combine.py +4 -0
- setiastro/saspro/image_peeker_pro.py +4 -0
- setiastro/saspro/imageops/narrowband_normalization.py +816 -0
- setiastro/saspro/imageops/serloader.py +1345 -0
- setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
- setiastro/saspro/imageops/stretch.py +582 -62
- setiastro/saspro/isophote.py +4 -0
- setiastro/saspro/layers.py +13 -9
- setiastro/saspro/layers_dock.py +183 -3
- setiastro/saspro/legacy/image_manager.py +154 -20
- setiastro/saspro/legacy/numba_utils.py +68 -48
- setiastro/saspro/legacy/xisf.py +240 -98
- setiastro/saspro/live_stacking.py +203 -82
- setiastro/saspro/luminancerecombine.py +228 -27
- setiastro/saspro/mask_creation.py +174 -15
- setiastro/saspro/mfdeconv.py +113 -35
- setiastro/saspro/mfdeconvcudnn.py +119 -70
- setiastro/saspro/mfdeconvsport.py +112 -35
- setiastro/saspro/morphology.py +4 -0
- setiastro/saspro/multiscale_decomp.py +81 -29
- setiastro/saspro/narrowband_normalization.py +1618 -0
- setiastro/saspro/numba_utils.py +72 -57
- setiastro/saspro/ops/commands.py +18 -18
- setiastro/saspro/ops/script_editor.py +10 -2
- setiastro/saspro/ops/scripts.py +122 -0
- setiastro/saspro/perfect_palette_picker.py +37 -3
- setiastro/saspro/plate_solver.py +84 -49
- setiastro/saspro/psf_viewer.py +119 -37
- setiastro/saspro/remove_green.py +1 -1
- setiastro/saspro/resources.py +73 -0
- setiastro/saspro/rgbalign.py +460 -12
- setiastro/saspro/selective_color.py +4 -1
- setiastro/saspro/ser_stack_config.py +82 -0
- setiastro/saspro/ser_stacker.py +2321 -0
- setiastro/saspro/ser_stacker_dialog.py +1838 -0
- setiastro/saspro/ser_tracking.py +206 -0
- setiastro/saspro/serviewer.py +1625 -0
- setiastro/saspro/sfcc.py +662 -216
- setiastro/saspro/shortcuts.py +171 -33
- setiastro/saspro/signature_insert.py +692 -33
- setiastro/saspro/stacking_suite.py +1347 -485
- setiastro/saspro/star_alignment.py +247 -123
- setiastro/saspro/star_spikes.py +4 -0
- setiastro/saspro/star_stretch.py +38 -3
- setiastro/saspro/stat_stretch.py +892 -129
- setiastro/saspro/subwindow.py +787 -363
- setiastro/saspro/supernovaasteroidhunter.py +1 -1
- setiastro/saspro/texture_clarity.py +593 -0
- setiastro/saspro/wavescale_hdr.py +4 -1
- setiastro/saspro/wavescalede.py +4 -1
- setiastro/saspro/whitebalance.py +84 -12
- setiastro/saspro/widgets/common_utilities.py +28 -21
- setiastro/saspro/widgets/resource_monitor.py +209 -111
- setiastro/saspro/widgets/spinboxes.py +10 -13
- setiastro/saspro/wimi.py +27 -656
- setiastro/saspro/wims.py +13 -3
- setiastro/saspro/xisf.py +101 -11
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/METADATA +4 -2
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/RECORD +132 -87
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/WHEEL +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/entry_points.txt +0 -0
- {setiastrosuitepro-1.6.4.dist-info → setiastrosuitepro-1.7.1.post2.dist-info}/licenses/LICENSE +0 -0
- {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}")
|