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.
Files changed (51) hide show
  1. setiastro/images/colorwheel.svg +97 -0
  2. setiastro/images/narrowbandnormalization.png +0 -0
  3. setiastro/images/planetarystacker.png +0 -0
  4. setiastro/saspro/__main__.py +1 -1
  5. setiastro/saspro/_generated/build_info.py +2 -2
  6. setiastro/saspro/aberration_ai.py +49 -11
  7. setiastro/saspro/aberration_ai_preset.py +29 -3
  8. setiastro/saspro/backgroundneutral.py +73 -33
  9. setiastro/saspro/blink_comparator_pro.py +116 -71
  10. setiastro/saspro/convo.py +9 -6
  11. setiastro/saspro/curve_editor_pro.py +72 -22
  12. setiastro/saspro/curves_preset.py +249 -47
  13. setiastro/saspro/doc_manager.py +178 -11
  14. setiastro/saspro/gui/main_window.py +218 -66
  15. setiastro/saspro/gui/mixins/dock_mixin.py +245 -24
  16. setiastro/saspro/gui/mixins/file_mixin.py +35 -16
  17. setiastro/saspro/gui/mixins/menu_mixin.py +31 -1
  18. setiastro/saspro/gui/mixins/toolbar_mixin.py +132 -10
  19. setiastro/saspro/histogram.py +179 -7
  20. setiastro/saspro/imageops/narrowband_normalization.py +816 -0
  21. setiastro/saspro/imageops/serloader.py +769 -0
  22. setiastro/saspro/imageops/starbasedwhitebalance.py +23 -52
  23. setiastro/saspro/imageops/stretch.py +66 -15
  24. setiastro/saspro/legacy/numba_utils.py +25 -48
  25. setiastro/saspro/live_stacking.py +24 -4
  26. setiastro/saspro/multiscale_decomp.py +30 -17
  27. setiastro/saspro/narrowband_normalization.py +1618 -0
  28. setiastro/saspro/numba_utils.py +0 -55
  29. setiastro/saspro/ops/script_editor.py +5 -0
  30. setiastro/saspro/ops/scripts.py +119 -0
  31. setiastro/saspro/remove_green.py +1 -1
  32. setiastro/saspro/resources.py +4 -0
  33. setiastro/saspro/ser_stack_config.py +68 -0
  34. setiastro/saspro/ser_stacker.py +2245 -0
  35. setiastro/saspro/ser_stacker_dialog.py +1481 -0
  36. setiastro/saspro/ser_tracking.py +206 -0
  37. setiastro/saspro/serviewer.py +1242 -0
  38. setiastro/saspro/sfcc.py +602 -214
  39. setiastro/saspro/shortcuts.py +35 -16
  40. setiastro/saspro/stacking_suite.py +332 -87
  41. setiastro/saspro/star_alignment.py +243 -122
  42. setiastro/saspro/stat_stretch.py +220 -31
  43. setiastro/saspro/subwindow.py +2 -4
  44. setiastro/saspro/whitebalance.py +24 -0
  45. setiastro/saspro/widgets/resource_monitor.py +122 -74
  46. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/METADATA +2 -2
  47. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/RECORD +51 -40
  48. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/WHEEL +0 -0
  49. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/entry_points.txt +0 -0
  50. {setiastrosuitepro-1.6.10.dist-info → setiastrosuitepro-1.7.0.dist-info}/licenses/LICENSE +0 -0
  51. {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}")