SXXXXXXX_ControlPanel/VideoReceiverSFP/core/sfp_module.py
2026-01-19 14:37:12 +01:00

1429 lines
65 KiB
Python

"""VideoReceiverSFP: SFP receiver module (simulated and network modes).
This module implements a simple L0-like adapter that can run in simulated
mode by reading images from a local directory and publishing frames to
registered callbacks, or in network mode by listening for SFP UDP packets.
"""
from typing import Callable, Dict, Any, Optional
from collections import deque
import pathlib
import threading
import time
import os
import logging
import io
try:
import numpy as np
except Exception: # pragma: no cover - numpy is bundled via requirements, but guard anyway
np = None # type: ignore
try:
from PIL import Image
except Exception:
Image = None
# Optional network transport
try:
from .sfp_transport import SfpTransport
from .sfp_structures import SFPHeader
except Exception:
SfpTransport = None
SFPHeader = None
# Try to import the authoritative image structures from controlpanel
try:
from controlpanel.core.sfp_structures import ImageLeaderData
except Exception:
ImageLeaderData = None
# Use local image processing helpers (module must be self-contained)
try:
from .image_processing import normalize_image, apply_color_palette, apply_brightness_contrast, resize_image
except Exception:
normalize_image = None
apply_color_palette = None
apply_brightness_contrast = None
resize_image = None
try:
from .mfd_palette import build_mfd_lut, apply_mfd_lut
except Exception:
build_mfd_lut = None # type: ignore
apply_mfd_lut = None # type: ignore
try:
from .dump_manager import DumpManager
except Exception:
DumpManager = None
try:
from .payload_saver import save_bin_payload, save_unknown_sample
except Exception:
save_bin_payload = None
save_unknown_sample = None
try:
from .payload_dispatcher import dispatch_frame
except Exception:
dispatch_frame = None
# --- Image Type Constants ---
# Based on actual payload analysis: TYPE=1 with PALTYPE=1 is MFD, TYPE=2 is SAR
IMAGE_TYPE_UNKNOWN = 0
IMAGE_TYPE_MFD = 1
IMAGE_TYPE_SAR = 2
def get_image_type_name(type_id: int) -> str:
"""Return a short name for image type ids used in payloads."""
try:
if int(type_id) == IMAGE_TYPE_MFD:
return 'MFD'
if int(type_id) == IMAGE_TYPE_SAR:
return 'SAR'
except Exception:
pass
return 'UNKNOWN'
class SfpConnectorModule:
"""Minimal, self-contained SFP connector for development and testing.
- When initialized with `sim_dir` it will stream images from that folder
at the configured `fps`.
- When initialized with `host`/`port` it will listen for SFP UDP and
dispatch completed payloads to callbacks.
"""
def __init__(self, name: str = "VideoReceiverSFP") -> None:
self.module_name = name
self.is_running = False
self._thread: Optional[threading.Thread] = None
self._stop_event = threading.Event()
self._callbacks = []
self._last_frame = None
self._frame_lock = threading.Lock()
self._sim_dir: Optional[str] = None
self._fps = 5
self._frame_idx = 0
self._host = None
self._port = None
self._transport: Optional[object] = None
# normalization method: None, 'cp' (use controlpanel.normalize_image), or 'autocontrast'
self._normalize_method: Optional[str] = None
self._image_type_filter: Optional[int] = None
self._mfd_lut = None
# saving flags and queues
self._save_png = False
self._save_bin = False
# video recording flags (disabled by default)
self._record_mfd_video = False
self._record_sar_video = False
# runtime tracking of active video writers
self._mfd_video_active = False
self._sar_video_active = False
# desired video fps when starting writers (default 20)
self._video_fps = 20
# default SAR recording fps (respect arrival timing, lower default)
self._sar_video_fps = 1
try:
from .config import DUMP_KEEP_COUNT, DUMPS_DIR
self._dump_keep = int(DUMP_KEEP_COUNT)
self._dumps_dir = str(DUMPS_DIR)
except Exception:
self._dump_keep = 100
self._dumps_dir = os.path.join(os.getcwd(), 'dumps')
try:
if DumpManager is not None:
self._dump_manager = DumpManager(self._dumps_dir, self._dump_keep)
else:
self._dump_manager = None
except Exception:
self._dump_manager = None
self._saved_pngs = deque()
self._saved_bins = deque()
self._mfd_callbacks = []
self._sar_callbacks = []
self._sar_metadata_callbacks = []
self._sar_brightness = 0
self._sar_contrast = 0
self._sar_autocontrast = False
self._saved_sar_pngs = deque()
self._sar_save_png = False
self._first_frame_saved = False
self._first_frame_path: Optional[str] = None
self._frames_received = 0
def _loop(self) -> None:
interval = 1.0 / max(1, self._fps)
while not self._stop_event.is_set():
frame = self._load_next_frame()
if frame is not None:
with self._frame_lock:
self._last_frame = frame
self._frames_received += 1
try:
if self._frames_received % 10 == 0:
logging.getLogger().info("VideoReceiverSFP: simulated frames received=%d", self._frames_received)
except Exception:
pass
for cb in list(self._callbacks):
try:
cb(frame)
except Exception:
pass
time.sleep(interval)
@staticmethod
def _looks_like_encoded_image(payload: bytes) -> bool:
try:
from .image_helpers import looks_like_encoded_image as helper
return bool(helper(payload))
except Exception:
if not payload:
return False
if payload.startswith(b"\x89PNG\r\n\x1a\n"):
return True
if payload.startswith(b"\xff\xd8\xff"):
return True
if payload.startswith(b"BM"):
return True
return False
def _load_next_frame(self):
"""Load next frame from `self._sim_dir` for simulation mode.
Returns a PIL.Image (RGB) or None if no frame available.
"""
try:
if not self._sim_dir:
return None
# Cache file list
if not hasattr(self, '_sim_files') or self._sim_files is None:
p = pathlib.Path(self._sim_dir)
if not p.exists() or not p.is_dir():
return None
files = []
for ext in ('*.png', '*.jpg', '*.jpeg', '*.bmp', '*.tif', '*.tiff'):
files.extend(sorted(p.glob(ext)))
self._sim_files = [str(x) for x in files]
if not self._sim_files:
return None
if not self._sim_files:
return None
idx = int(self._frame_idx) % len(self._sim_files)
path = self._sim_files[idx]
try:
if Image is not None:
img = Image.open(path).convert('RGB')
else:
# Fallback: return raw bytes
with open(path, 'rb') as fh:
return fh.read()
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to open simulated frame %s', path)
# Skip this file
self._frame_idx = (self._frame_idx + 1) % max(1, len(self._sim_files))
return None
self._frame_idx = (self._frame_idx + 1) % max(1, len(self._sim_files))
return img
except Exception:
logging.getLogger().exception('VideoReceiverSFP: _load_next_frame encountered an error')
return None
def _ensure_mfd_lut(self):
if self._mfd_lut is not None:
return
# First try to construct the exact LUT used by ControlPanel (parity)
try:
from controlpanel.app_state import AppState
state = AppState()
params = state.mfd_params
raw_map_factor = params.get("raw_map_intensity", 128) / 255.0
pixel_map = params.get("pixel_to_category", {})
categories = params.get("categories", {})
new_lut = None
try:
import numpy as _np
new_lut = _np.zeros((256, 3), dtype=_np.uint8)
for idx in range(256):
cat_name = pixel_map.get(idx)
if cat_name:
cat_data = categories[cat_name]
bgr = cat_data["color"]
intensity_factor = cat_data["intensity"] / 255.0
new_lut[idx, 0] = _np.clip(int(round(float(bgr[0]) * intensity_factor)), 0, 255)
new_lut[idx, 1] = _np.clip(int(round(float(bgr[1]) * intensity_factor)), 0, 255)
new_lut[idx, 2] = _np.clip(int(round(float(bgr[2]) * intensity_factor)), 0, 255)
elif 32 <= idx <= 255:
raw_intensity = (float(idx) - 32.0) * (255.0 / 223.0)
final_gray = int(round(_np.clip(raw_intensity * raw_map_factor, 0, 255)))
new_lut[idx, :] = final_gray
except Exception:
new_lut = None
if new_lut is not None:
self._mfd_lut = new_lut
logging.getLogger().info("VideoReceiverSFP: built ControlPanel-compatible MFD LUT for colorization")
return
except Exception:
# fall through to other build methods
pass
# Fallback: use local build_mfd_lut if available
if build_mfd_lut is None:
return
try:
self._mfd_lut = build_mfd_lut()
logging.getLogger().info("VideoReceiverSFP: built fallback MFD LUT for colorization")
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to build MFD LUT")
def _extract_index_array(self, width: int, height: int, row_stride: int, pixel_data: bytes):
try:
from .image_helpers import extract_index_array
return extract_index_array(width, height, row_stride, pixel_data)
except Exception:
if np is None:
return None
total_bytes = row_stride * height
if len(pixel_data) < total_bytes:
logging.getLogger().warning(
"VideoReceiverSFP: pixel_data shorter than expected (got=%d need=%d)",
len(pixel_data),
total_bytes,
)
return None
try:
arr = np.frombuffer(pixel_data[:total_bytes], dtype=np.uint8)
row_pixels = width
if row_stride == row_pixels:
arr = arr.reshape((height, width))
else:
arr = arr.reshape((height, row_stride))[:, :width]
return arr.copy()
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to reshape MFD pixel data")
return None
def _build_grayscale_frame(self, width, height, row_stride, row_pixels, pixel_data):
try:
from .image_helpers import build_grayscale_frame
return build_grayscale_frame(width, height, row_stride, row_pixels, pixel_data, normalize_method=self._normalize_method)
except Exception:
if Image is None:
return None
try:
if row_stride == row_pixels:
img = Image.frombytes("L", (width, height), pixel_data)
else:
rows = []
for r in range(height):
start = r * row_stride
rows.append(pixel_data[start:start + row_pixels])
img = Image.frombytes("L", (width, height), b"".join(rows))
normalized_img = None
if self._normalize_method == 'cp' and normalize_image is not None:
try:
if np is not None:
arr = np.frombuffer(img.tobytes(), dtype=np.uint8).reshape((height, width))
norm = normalize_image(arr, target_type=np.uint8)
if norm is not None:
normalized_img = Image.fromarray(norm, mode='L')
except Exception:
logging.getLogger().exception("VideoReceiverSFP: controlpanel normalize_image failed")
elif self._normalize_method == 'autocontrast':
try:
from PIL import ImageOps
normalized_img = ImageOps.autocontrast(img)
except Exception:
normalized_img = None
if normalized_img is not None:
frame = normalized_img.convert("RGB")
try:
dumps_dir = os.path.join(os.getcwd(), "dumps")
os.makedirs(dumps_dir, exist_ok=True)
dbg_path = os.path.join(dumps_dir, "VideoReceiverSFP_last_normalized.png")
frame.save(dbg_path)
logging.getLogger().info("VideoReceiverSFP: saved normalized debug frame to %s", dbg_path)
except Exception:
pass
return frame
return img.convert("RGB")
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to build grayscale frame")
return None
def _decode_leader_payload(self, leader, payload: bytes):
if ImageLeaderData is None:
return None
header = leader.HEADER_DATA
image_type_id = int(header.TYPE)
if self._image_type_filter is not None and image_type_id != self._image_type_filter:
logging.getLogger().debug(
"VideoReceiverSFP: skipping image_type=%s due to filter",
get_image_type_name(image_type_id),
)
return None
width = int(header.DX)
height = int(header.DY)
stride = int(header.STRIDE)
bpp = int(header.BPP)
comp = int(header.COMP)
pal_type = int(getattr(header, "PALTYPE", 0))
# `header.BPP` in the ControlPanel format is bytes-per-pixel (1 or 2)
# in most cases. Fall back to treating it as bits-per-pixel only if
# it looks larger than 2.
if bpp in (1, 2):
bytes_per_pixel = int(bpp)
else:
if bpp <= 8:
bytes_per_pixel = 1
elif bpp % 8 == 0:
bytes_per_pixel = bpp // 8
else:
bytes_per_pixel = max(1, (bpp + 7) // 8)
row_pixels = width * bytes_per_pixel
row_stride = stride if stride and stride > 0 else row_pixels
# Calculate pixel data offset using ImageLeaderData layout when available
if ImageLeaderData is not None:
try:
# Mirror ControlPanel's calculation to remain compatible with payload layout
pixel_offset = (
leader.HEADER_TAG.size()
+ leader.HEADER_DATA.size()
+ leader.GEO_TAG.size()
+ leader.GEO_DATA.size()
+ leader.RESERVED_TAG.size()
+ ImageLeaderData.get_reserved_data_size()
+ leader.CM_TAG.size()
+ ImageLeaderData.get_colour_map_size()
+ leader.PIXEL_TAG.size()
)
except Exception:
pixel_offset = ImageLeaderData.size()
else:
pixel_offset = 0
# Interpret header.STRIDE as number of pixels per row. Compute stride in bytes.
row_stride_pixels = stride if stride and stride > 0 else width
row_stride_bytes = row_stride_pixels * bytes_per_pixel
total_bytes = row_stride_bytes * height
# We'll reference the original payload buffer and use numpy views with
# proper `offset` and `strides` to match ControlPanel's reconstruction.
pixel_data_offset = pixel_offset
have_bytes = len(payload) - pixel_data_offset
if have_bytes < total_bytes:
logging.getLogger().warning(
"VideoReceiverSFP: truncated pixel payload (have=%d expected=%d)",
have_bytes,
total_bytes,
)
return None
if image_type_id == IMAGE_TYPE_MFD and pal_type == 1 and bytes_per_pixel == 1:
# Extract raw indices using the same approach as ControlPanel:
# create a NumPy view directly on the payload buffer with the
# correct offset and strides (bytes).
try:
# Use the original payload (bytes/bytearray) as the buffer
mview = None
if np is not None:
mview = np.ndarray(
shape=(height, width),
dtype=np.uint8,
buffer=payload,
offset=pixel_data_offset,
strides=(row_stride_pixels * 1, 1),
)
if mview is None:
return None
indices = mview.copy()
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to construct MFD index view")
return None
# Attempt to produce a colorized RGB image using the MFD LUT,
# then enhance visibility (scale non-zero pixels and autocontrast)
rgb = None
try:
self._ensure_mfd_lut()
if self._mfd_lut is not None and apply_mfd_lut is not None:
rgb = apply_mfd_lut(indices, self._mfd_lut)
except Exception:
rgb = None
if rgb is not None:
try:
import numpy as _np
from PIL import ImageOps
rgb_arr = _np.asarray(rgb, dtype=_np.uint8)
mask = _np.any(rgb_arr != 0, axis=2)
pct_nonzero = float(_np.count_nonzero(mask)) / float(mask.size) if mask.size else 0.0
max_val = int(rgb_arr.max()) if rgb_arr.size else 0
if pct_nonzero < 0.10 or max_val < 220:
if _np.count_nonzero(mask) > 0 and max_val > 0:
scale = 255.0 / float(max_val)
rgb_arr = _np.clip(rgb_arr.astype(_np.float32) * scale, 0, 255).astype(_np.uint8)
try:
pil_tmp = Image.fromarray(rgb_arr, mode='RGB')
pil_tmp = ImageOps.autocontrast(pil_tmp)
rgb_arr = _np.array(pil_tmp)
except Exception:
pass
if Image is not None:
return Image.fromarray(rgb_arr, mode='RGB')
return rgb_arr
except Exception:
if Image is not None:
return Image.fromarray(rgb, mode='RGB')
return rgb
# Fallback: try again to map indices -> RGB, or return grayscale RGB
try:
self._ensure_mfd_lut()
if self._mfd_lut is not None and apply_mfd_lut is not None:
rgb2 = apply_mfd_lut(indices, self._mfd_lut)
if rgb2 is not None:
try:
import numpy as _np
from PIL import ImageOps
rgb_arr = _np.asarray(rgb2, dtype=_np.uint8)
max_val = int(rgb_arr.max()) if rgb_arr.size else 0
if _np.count_nonzero(_np.any(rgb_arr != 0, axis=2)) > 0 and max_val > 0:
scale = 255.0 / float(max_val)
rgb_arr = _np.clip(rgb_arr.astype(_np.float32) * scale, 0, 255).astype(_np.uint8)
try:
pil_tmp = Image.fromarray(rgb_arr, mode='RGB')
pil_tmp = ImageOps.autocontrast(pil_tmp)
rgb_arr = _np.array(pil_tmp)
except Exception:
pass
return Image.fromarray(rgb_arr, mode='RGB')
except Exception:
pass
except Exception:
pass
if Image is not None:
return Image.fromarray(indices, mode='L').convert("RGB")
else:
return indices
# Handle uncompressed grayscale frames. Support both 8-bit and 16-bit SAR images.
if comp == 0:
if bytes_per_pixel == 1:
# For 8-bit grayscale, build frame using bytes-per-row stride
frame = self._build_grayscale_frame(width, height, row_stride_bytes, row_pixels, payload[pixel_data_offset:pixel_data_offset + total_bytes])
if frame is not None:
logging.getLogger().info(
"VideoReceiverSFP: decoded %s frame %dx%d bytes_per_pixel=%d stride=%d",
get_image_type_name(image_type_id),
width,
height,
bytes_per_pixel,
row_stride_bytes,
)
return frame
# 16-bit per pixel handling (common for SAR images)
if bytes_per_pixel == 2:
try:
import numpy as _np
# Create a NumPy view on the original payload with proper offset
# and strides (bytes-per-row, bytes-per-pixel) to exactly mirror
# ControlPanel's reconstruction.
if _np is None:
return None
raw_view = _np.ndarray(
shape=(height, width),
dtype=_np.uint16,
buffer=payload,
offset=pixel_data_offset,
strides=(row_stride_bytes, 2),
)
arr = raw_view.copy()
# Normalize to uint8 for display using local normalize_image if available
norm = None
try:
if normalize_image is not None:
norm = normalize_image(arr, target_type=_np.uint8)
except Exception:
logging.getLogger().exception("VideoReceiverSFP: normalization of uint16 SAR failed")
if norm is None:
try:
norm = (_np.right_shift(arr, 8)).astype(_np.uint8)
except Exception:
logging.getLogger().exception("VideoReceiverSFP: fallback uint16->uint8 conversion failed")
return None
try:
from PIL import Image as _PILImage
pil_img = _PILImage.fromarray(norm, mode='L').convert('RGB')
logging.getLogger().info(
"VideoReceiverSFP: decoded %s frame %dx%d bytes_per_pixel=%d stride=%d (uint16->uint8)",
get_image_type_name(image_type_id),
width,
height,
bytes_per_pixel,
row_stride_bytes,
)
return pil_img
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to build PIL image from normalized SAR data")
return norm
except Exception:
logging.getLogger().exception("VideoReceiverSFP: error decoding uint16 SAR")
return None
logging.getLogger().debug(
"VideoReceiverSFP: unsupported comp=%s bpp=%s bytes_per_pixel=%s for type=%s",
comp,
bpp,
bytes_per_pixel,
get_image_type_name(image_type_id),
)
return None
def _network_payload_handler(self, completed_payload: bytearray):
return self._network_payload_handler_with_flow(completed_payload, forced_flow=None)
def _network_payload_handler_mfd(self, completed_payload: bytearray):
return self._network_payload_handler_with_flow(completed_payload, forced_flow=ord('M'))
def _network_payload_handler_sar(self, completed_payload: bytearray):
return self._network_payload_handler_with_flow(completed_payload, forced_flow=ord('S'))
def _network_payload_handler_with_flow(self, completed_payload: bytearray, forced_flow: Optional[int] = None):
payload = bytes(completed_payload)
frame = None
logging.getLogger().debug("VideoReceiverSFP: network payload received size=%d bytes", len(payload))
leader = None
if ImageLeaderData is not None and not self._looks_like_encoded_image(payload):
leader_size = ImageLeaderData.size()
if len(payload) >= leader_size:
try:
leader = ImageLeaderData.from_buffer_copy(payload[:leader_size])
except Exception:
leader = None
# If we parsed a leader, save a debug dump of header fields and raw payload
if leader is not None:
import time, os, pathlib
dbgdir = os.path.join(os.getcwd(), 'dumps')
os.makedirs(dbgdir, exist_ok=True)
ts = time.strftime('%Y%m%d_%H%M%S')
# Decide whether to save this payload based on configured flags
try:
from .config import SAVE_RAW_MFD, SAVE_RAW_SAR, SAVE_RAW_UNKNOWN
except Exception:
SAVE_RAW_MFD = False
SAVE_RAW_SAR = False
SAVE_RAW_UNKNOWN = False
# Inspect leader to choose flag
try:
hd = leader.HEADER_DATA
ity = int(getattr(hd, 'TYPE', 0))
except Exception:
hd = None
ity = 0
# If transport indicated flow (M or S), prefer that as authoritative
if forced_flow is not None:
if forced_flow == ord('S'):
try:
if hd is not None:
hd.TYPE = int(IMAGE_TYPE_SAR)
ity = IMAGE_TYPE_SAR
except Exception:
pass
elif forced_flow == ord('M'):
try:
if hd is not None:
hd.TYPE = int(IMAGE_TYPE_MFD)
ity = IMAGE_TYPE_MFD
except Exception:
pass
save_this_bin = (
(ity == IMAGE_TYPE_MFD and SAVE_RAW_MFD)
or (ity == IMAGE_TYPE_SAR and SAVE_RAW_SAR)
or (ity == 0 and SAVE_RAW_UNKNOWN)
)
binpath = None
if save_this_bin or getattr(self, '_save_bin', False):
try:
if save_bin_payload is not None:
binpath = save_bin_payload(self, payload, ity, dbgdir, ts, force_save=(save_this_bin or getattr(self, '_save_bin', False)))
else:
# fallback to previous inline behavior
if getattr(self, '_dump_manager', None) is not None:
binpath = self._dump_manager.save_bin(payload, category=('mfd' if ity==IMAGE_TYPE_MFD else ('sar' if ity==IMAGE_TYPE_SAR else 'unknown')))
else:
binpath = os.path.join(dbgdir, f'VideoReceiverSFP_payload_{ts}.bin')
with open(binpath, 'wb') as fh:
fh.write(payload)
self._saved_bins.append(binpath)
while len(self._saved_bins) > self._dump_keep:
old = self._saved_bins.popleft()
try:
pathlib.Path(old).unlink()
except Exception:
pass
except Exception:
logging.getLogger().exception('VideoReceiverSFP: error saving payload bin')
# Log leader fields
try:
logging.getLogger().debug(
'VideoReceiverSFP: Parsed ImageLeader TYPE=%s DX=%s DY=%s BPP=%s STRIDE=%s PALTYPE=%s COMP=%s FCOUNTER=%s saved=%s',
int(hd.TYPE) if hd is not None else -1,
int(hd.DX) if hd is not None else -1,
int(hd.DY) if hd is not None else -1,
int(hd.BPP) if hd is not None else -1,
int(hd.STRIDE) if hd is not None else -1,
int(getattr(hd,'PALTYPE',0)) if hd is not None else 0,
int(hd.COMP) if hd is not None else -1,
int(hd.FCOUNTER) if hd is not None else -1,
binpath,
)
except Exception:
pass
# Build formatted metadata string (if controlpanel helper available)
try:
metadata_str = None
try:
from controlpanel.utils.utils import format_ctypes_structure
metadata_str = format_ctypes_structure(leader)
except Exception:
metadata_str = None
# Only store/notify SAR metadata when leader indicates a SAR image.
try:
ity_local = int(getattr(hd, 'TYPE', 0)) if hd is not None else 0
except Exception:
ity_local = 0
if ity_local == IMAGE_TYPE_SAR:
self._last_sar_metadata_str = metadata_str
try:
self._notify_sar_metadata(metadata_str)
except Exception:
pass
except Exception:
self._last_sar_metadata_str = None
# Optionally write a preview (PNG for MFD, BMP for SAR/UNKNOWN)
try:
from .config import SAVE_BMP_MFD, SAVE_BMP_SAR, SAVE_BMP_UNKNOWN
except Exception:
SAVE_BMP_MFD = SAVE_BMP_SAR = SAVE_BMP_UNKNOWN = False
try:
ity = int(getattr(hd, 'TYPE', 0)) if hd is not None else 0
except Exception:
ity = 0
want_preview = (
(ity == IMAGE_TYPE_MFD and SAVE_BMP_MFD)
or (ity == IMAGE_TYPE_SAR and SAVE_BMP_SAR)
or (ity == 0 and SAVE_BMP_UNKNOWN)
)
if want_preview:
try:
try:
from .payload_preview import save_payload_preview
save_payload_preview(self, leader, payload, ity, dbgdir, ts)
except Exception:
# If import/call failed, fall back to existing inline behavior
logging.getLogger().exception('VideoReceiverSFP: payload_preview helper failed')
except Exception:
logging.getLogger().exception('VideoReceiverSFP: error creating payload preview')
if leader is not None:
# Heuristic and diagnostics: some senders set HEADER_DATA.TYPE==0 or
# provide inconsistent DY/STRIDE. Try to infer SAR images from the
# payload size and stride; add extra logging to help debug cases
# where frames are classified as Unknown (0).
try:
hd = leader.HEADER_DATA
hdr_type = int(getattr(hd, 'TYPE', 0))
width = int(getattr(hd, 'DX', 0))
stride = int(getattr(hd, 'STRIDE', 0))
bpp = int(getattr(hd, 'BPP', 8))
if bpp <= 8:
bytes_per_pixel = 1
elif bpp % 8 == 0:
bytes_per_pixel = bpp // 8
else:
bytes_per_pixel = max(1, (bpp + 7) // 8)
row_pixels = width * bytes_per_pixel
row_stride = stride if stride and stride > 0 else row_pixels
try:
pixel_offset = (
leader.HEADER_TAG.size()
+ leader.HEADER_DATA.size()
+ leader.GEO_TAG.size()
+ leader.GEO_DATA.size()
+ leader.RESERVED_TAG.size()
+ ImageLeaderData.get_reserved_data_size()
+ leader.CM_TAG.size()
+ ImageLeaderData.get_colour_map_size()
+ leader.PIXEL_TAG.size()
)
except Exception:
pixel_offset = ImageLeaderData.size()
total_bytes = len(payload) - pixel_offset
# If TYPE is unknown, attempt to infer SAR by payload geometry
if hdr_type == 0 and total_bytes > 0 and row_stride > 0 and (total_bytes % row_stride) == 0:
inferred_h = total_bytes // row_stride
# If we see a common SAR width and inferred height in plausible range,
# assume SAR (TYPE=2). This handles senders that omit TYPE.
if width in (2048,) and 1800 <= inferred_h <= 2100:
try:
hd.DY = int(inferred_h)
hd.TYPE = int(2)
logging.getLogger().warning(
"VideoReceiverSFP: inferred SAR for payload - set TYPE=2 DX=%s DY=%s stride=%s",
width,
inferred_h,
row_stride,
)
except Exception:
pass
# If still unknown, emit a detailed debug message to aid investigation
if int(getattr(hd, 'TYPE', 0)) == 0:
try:
logging.getLogger().warning(
"VideoReceiverSFP: leader TYPE==0 parsed DX=%s DY=%s BPP=%s STRIDE=%s payload_bytes=%d",
width,
int(getattr(hd, 'DY', -1)),
bpp,
row_stride,
len(payload),
)
# save small header+payload sample if debug bin saving is enabled
if getattr(self, '_save_bin', False):
try:
if save_unknown_sample is not None:
save_unknown_sample(self, payload, dbgdir, ts)
else:
if getattr(self, '_dump_manager', None) is not None:
path = self._dump_manager.save_bin(payload, category='unknown')
if path:
logging.getLogger().info('VideoReceiverSFP: saved unknown-type payload to %s', path)
else:
import time, os, pathlib
dbgdir = os.path.join(os.getcwd(), 'dumps')
os.makedirs(dbgdir, exist_ok=True)
ts = time.strftime('%Y%m%d_%H%M%S')
try:
path = os.path.join(dbgdir, f'VideoReceiverSFP_unknown_TYPE_{ts}.bin')
with open(path, 'wb') as fh:
fh.write(payload)
self._saved_bins.append(path)
while len(self._saved_bins) > self._dump_keep:
old = self._saved_bins.popleft()
try:
pathlib.Path(old).unlink()
except Exception:
pass
logging.getLogger().info('VideoReceiverSFP: saved unknown-type payload to %s', path)
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to save unknown-type payload')
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to save unknown-type payload')
except Exception:
logging.getLogger().exception('VideoReceiverSFP: diagnostic logging for TYPE==0 failed')
except Exception:
logging.getLogger().exception("VideoReceiverSFP: error while attempting to infer image type from leader")
frame = self._decode_leader_payload(leader, payload)
if frame is None and Image is not None:
try:
img = Image.open(io.BytesIO(payload))
frame = img.convert("RGB")
logging.getLogger().debug("VideoReceiverSFP: payload decoded by PIL as standalone image")
except Exception:
frame = None
if frame is None:
frame = payload
# Save the first decoded frame to disk so user can verify output
if (not getattr(self, "_first_frame_saved", False)) and Image is not None and hasattr(frame, "save"):
try:
dumps_dir = os.path.join(os.getcwd(), "dumps")
os.makedirs(dumps_dir, exist_ok=True)
path = os.path.join(dumps_dir, "VideoReceiverSFP_first_frame.png")
frame.save(path)
self._first_frame_saved = True
self._first_frame_path = path
try:
logging.getLogger().info("VideoReceiverSFP: saved first decoded frame to %s", path)
except Exception:
pass
except Exception:
try:
logging.getLogger().exception("VideoReceiverSFP: failed to save first decoded frame")
except Exception:
pass
# Additionally save an enhanced preview image to help Windows thumbnail/preview
try:
from PIL import ImageOps
import numpy as _np
try:
arr = _np.array(frame.convert('RGB'))
nonzero = _np.count_nonzero(arr)
pct_nonzero = float(nonzero) / arr.size if arr.size > 0 else 0.0
except Exception:
arr = None
pct_nonzero = 0.0
enhanced = None
# If image is very sparse (few non-zero pixels) scale non-zero values to full range
if arr is not None and pct_nonzero < 0.05:
try:
mask = _np.any(arr != 0, axis=2)
if _np.any(mask):
maxv = int(arr[mask].max())
if maxv > 0:
scale = 255.0 / float(maxv)
arr2 = _np.clip(arr.astype(_np.float32) * scale, 0, 255).astype(_np.uint8)
enhanced = Image.fromarray(arr2, mode='RGB')
except Exception:
enhanced = None
# Fallback to PIL autocontrast if not produced above
if enhanced is None:
try:
enhanced = ImageOps.autocontrast(frame)
except Exception:
enhanced = None
if enhanced is not None:
try:
preview_path = os.path.join(dumps_dir, "VideoReceiverSFP_first_frame_preview.png")
enhanced.save(preview_path)
logging.getLogger().info("VideoReceiverSFP: saved enhanced preview to %s", preview_path)
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to save enhanced preview")
except Exception:
# Do not break the main flow if preview creation fails
pass
with self._frame_lock:
self._last_frame = frame
self._frames_received += 1
try:
if self._frames_received % 5 == 0:
logging.getLogger().debug("VideoReceiverSFP: total frames_received=%d", self._frames_received)
except Exception:
pass
# Determine image type (if leader available) to route to specialized callbacks
image_type_id = None
try:
if leader is not None:
image_type_id = int(leader.HEADER_DATA.TYPE)
except Exception:
image_type_id = None
# If this is a SAR image, apply SAR-specific transforms and notify SAR callbacks
if image_type_id == IMAGE_TYPE_SAR:
try:
if Image is not None and hasattr(frame, 'copy'):
sar_img = frame.copy()
# Apply autocontrast first if requested
if getattr(self, '_sar_autocontrast', False):
try:
from PIL import ImageOps
sar_img = ImageOps.autocontrast(sar_img)
except Exception:
pass
# Apply brightness/contrast
try:
from PIL import ImageEnhance
bf = max(0.0, 1.0 + (float(self._sar_brightness) / 100.0))
cf = max(0.0, 1.0 + (float(self._sar_contrast) / 100.0))
if bf != 1.0:
sar_img = ImageEnhance.Brightness(sar_img).enhance(bf)
if cf != 1.0:
sar_img = ImageEnhance.Contrast(sar_img).enhance(cf)
except Exception:
pass
# Optionally save SAR PNGs separately
if getattr(self, '_sar_save_png', False):
try:
# Use DumpManager when available
if getattr(self, '_dump_manager', None) is not None:
saved = self._dump_manager.save_preview_from_pil(sar_img, category='sar', fmt='png')
if saved:
logging.getLogger().info("VideoReceiverSFP: saved sar png %s", saved)
else:
ts = time.strftime('%Y%m%d_%H%M%S') + (f"_{int(time.time() * 1000) % 1000:03d}")
dumps_dir = getattr(self, '_dumps_dir', os.path.join(os.getcwd(), 'dumps'))
os.makedirs(dumps_dir, exist_ok=True)
pngpath = os.path.join(dumps_dir, f'VideoReceiverSFP_sar_{ts}.png')
sar_img.save(pngpath)
self._saved_sar_pngs.append(pngpath)
while len(self._saved_sar_pngs) > self._dump_keep:
old = self._saved_sar_pngs.popleft()
try:
pathlib.Path(old).unlink()
except Exception:
pass
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed saving sar png frame")
# Optionally record SAR video (start writer on first valid frame)
try:
if getattr(self, '_record_sar_video', False) and getattr(self, '_dump_manager', None) is not None:
if not getattr(self, '_sar_video_active', False):
try:
w, h = None, None
if Image is not None and hasattr(sar_img, 'size'):
w, h = sar_img.size
else:
try:
import numpy as _np
arr = _np.asarray(sar_img)
if arr is not None and arr.ndim >= 2:
h, w = arr.shape[0], arr.shape[1]
except Exception:
pass
if w and h:
sar_fps = getattr(self, '_sar_video_fps', getattr(self, '_video_fps', 20))
started = self._dump_manager.start_video_record('sar', w, h, fps=sar_fps)
if started:
self._sar_video_active = True
try:
logging.getLogger().info("VideoReceiverSFP: started SAR video writer (%dx%d @ %s FPS)", w, h, sar_fps)
except Exception:
pass
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to start sar video writer")
# write frame if active
if getattr(self, '_sar_video_active', False):
try:
self._dump_manager.write_video_frame(sar_img, 'sar')
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed writing sar frame to video")
except Exception:
pass
# Deliver to SAR callbacks
for scb in list(self._sar_callbacks):
try:
scb(sar_img)
except Exception:
pass
except Exception:
logging.getLogger().exception("VideoReceiverSFP: error processing SAR image for SAR callbacks")
# If this is an MFD image, notify MFD callbacks
if image_type_id == IMAGE_TYPE_MFD:
try:
# If requested, initialize/start MFD video writer on first frame
try:
if getattr(self, '_record_mfd_video', False) and getattr(self, '_dump_manager', None) is not None:
if not getattr(self, '_mfd_video_active', False):
try:
w, h = None, None
if Image is not None and hasattr(frame, 'size'):
w, h = frame.size
else:
try:
import numpy as _np
arr = _np.asarray(frame)
if arr is not None and arr.ndim >= 2:
h, w = arr.shape[0], arr.shape[1]
except Exception:
pass
if w and h:
started = self._dump_manager.start_video_record('mfd', w, h, fps=getattr(self, '_video_fps', 20))
if started:
self._mfd_video_active = True
try:
logging.getLogger().info("VideoReceiverSFP: started MFD video writer (%dx%d @ %s FPS)", w, h, getattr(self, '_video_fps', 20))
except Exception:
pass
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to start mfd video writer')
# write frame if active
if getattr(self, '_mfd_video_active', False):
try:
self._dump_manager.write_video_frame(frame, 'mfd')
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed writing mfd frame to video')
# debug: if no MFD callbacks are registered, log that UI may not be receiving frames
try:
if not self._mfd_callbacks:
logging.getLogger().debug('VideoReceiverSFP: mfd frames are being recorded but no MFD callbacks are registered')
except Exception:
pass
except Exception:
pass
for mcb in list(self._mfd_callbacks):
try:
mcb(frame)
except Exception:
pass
except Exception:
pass
# Fallback: always notify generic callbacks (backwards compatibility)
for cb in list(self._callbacks):
try:
# If saving of PNGs enabled and frame is an Image, save timestamped copy
if getattr(self, '_save_png', False) and Image is not None and hasattr(frame, 'save'):
try:
# Use DumpManager when available
if getattr(self, '_dump_manager', None) is not None:
saved = self._dump_manager.save_preview_from_pil(frame, category='frame', fmt='png')
if saved:
logging.getLogger().info("VideoReceiverSFP: saved frame to %s", saved)
else:
ts = time.strftime('%Y%m%d_%H%M%S') + (f"_{int(time.time() * 1000) % 1000:03d}")
dumps_dir = getattr(self, '_dumps_dir', os.path.join(os.getcwd(), 'dumps'))
os.makedirs(dumps_dir, exist_ok=True)
pngpath = os.path.join(dumps_dir, f'VideoReceiverSFP_frame_{ts}.png')
frame.save(pngpath)
self._saved_pngs.append(pngpath)
while len(self._saved_pngs) > self._dump_keep:
old = self._saved_pngs.popleft()
try:
pathlib.Path(old).unlink()
except Exception:
pass
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed saving png frame")
cb(frame)
except Exception:
pass
# Methods to control saving flags from external UI
def set_save_png(self, enabled: bool) -> None:
self._save_png = bool(enabled)
logging.getLogger().info("VideoReceiverSFP: save_png set to %s", self._save_png)
def set_save_bin(self, enabled: bool) -> None:
self._save_bin = bool(enabled)
logging.getLogger().info("VideoReceiverSFP: save_bin set to %s", self._save_bin)
def initialize(self, config: Dict[str, Any]) -> bool:
"""Initialize the module with a config dict.
Supported keys:
- sim_dir, fps, host, port, normalize, image_type_filter
- record_mfd_video, record_sar_video, video_fps (overrides)
"""
try:
self._sim_dir = config.get("sim_dir")
self._fps = int(config.get("fps", self._fps))
self._host = config.get("host")
self._port = config.get("port")
self._normalize_method = config.get("normalize", None)
image_type_str = config.get("image_type_filter")
if image_type_str:
image_type_str = str(image_type_str).upper()
if image_type_str == 'MFD':
self._image_type_filter = IMAGE_TYPE_MFD
logging.info("VideoReceiverSFP: Filtering for MFD images only.")
elif image_type_str == 'SAR':
self._image_type_filter = IMAGE_TYPE_SAR
logging.info("VideoReceiverSFP: Filtering for SAR images only.")
# Prefer controlpanel.app config when present, otherwise local module config
try:
import controlpanel.config as appcfg
from . import config as localcfg
if hasattr(appcfg, 'RECORD_MFD_VIDEO'):
self._record_mfd_video = bool(getattr(appcfg, 'RECORD_MFD_VIDEO'))
else:
self._record_mfd_video = bool(getattr(localcfg, 'RECORD_MFD_VIDEO', self._record_mfd_video))
if hasattr(appcfg, 'RECORD_SAR_VIDEO'):
self._record_sar_video = bool(getattr(appcfg, 'RECORD_SAR_VIDEO'))
else:
self._record_sar_video = bool(getattr(localcfg, 'RECORD_SAR_VIDEO', self._record_sar_video))
if hasattr(appcfg, 'VIDEO_FPS'):
self._video_fps = int(getattr(appcfg, 'VIDEO_FPS'))
else:
self._video_fps = int(getattr(localcfg, 'VIDEO_FPS', self._video_fps))
except Exception:
try:
from .config import RECORD_MFD_VIDEO, RECORD_SAR_VIDEO, VIDEO_FPS
self._record_mfd_video = bool(RECORD_MFD_VIDEO)
self._record_sar_video = bool(RECORD_SAR_VIDEO)
self._video_fps = int(VIDEO_FPS)
except Exception:
pass
# Allow direct overrides from orchestrator-provided config dict
if 'record_mfd_video' in config:
self._record_mfd_video = bool(config.get('record_mfd_video'))
if 'record_sar_video' in config:
self._record_sar_video = bool(config.get('record_sar_video'))
if 'video_fps' in config:
self._video_fps = int(config.get('video_fps'))
return True
except Exception:
logging.getLogger().exception('VideoReceiverSFP: initialize failed')
return False
def set_record_mfd_video(self, enabled: bool, fps: Optional[int] = None) -> None:
try:
self._record_mfd_video = bool(enabled)
if fps is not None:
self._video_fps = int(fps)
logging.getLogger().info("VideoReceiverSFP: record_mfd_video set to %s (fps=%s)", self._record_mfd_video, self._video_fps)
# if disabling, ensure writer closed
if not self._record_mfd_video and getattr(self, '_dump_manager', None) is not None:
try:
self._dump_manager.stop_video_record('mfd')
except Exception:
pass
self._mfd_video_active = False
except Exception:
pass
def set_record_sar_video(self, enabled: bool, fps: Optional[int] = None) -> None:
try:
self._record_sar_video = bool(enabled)
if fps is not None:
self._sar_video_fps = int(fps)
logging.getLogger().info("VideoReceiverSFP: record_sar_video set to %s (fps=%s)", self._record_sar_video, getattr(self, '_sar_video_fps', getattr(self, '_video_fps', 20)))
if not self._record_sar_video and getattr(self, '_dump_manager', None) is not None:
try:
self._dump_manager.stop_video_record('sar')
except Exception:
pass
self._sar_video_active = False
except Exception:
pass
# SAR-specific parameter setters
def set_sar_brightness(self, value: int) -> None:
try:
self._sar_brightness = int(value)
logging.getLogger().info("VideoReceiverSFP: sar_brightness set to %s", self._sar_brightness)
except Exception:
pass
def set_sar_contrast(self, value: int) -> None:
try:
self._sar_contrast = int(value)
logging.getLogger().info("VideoReceiverSFP: sar_contrast set to %s", self._sar_contrast)
except Exception:
pass
def set_sar_autocontrast(self, enabled: bool) -> None:
try:
self._sar_autocontrast = bool(enabled)
logging.getLogger().info("VideoReceiverSFP: sar_autocontrast set to %s", self._sar_autocontrast)
except Exception:
pass
def set_sar_save_png(self, enabled: bool) -> None:
try:
self._sar_save_png = bool(enabled)
logging.getLogger().info("VideoReceiverSFP: sar_save_png set to %s", self._sar_save_png)
except Exception:
pass
# --- API: last saved file paths -------------------------------------
def get_last_saved_mfd(self) -> Optional[str]:
try:
if getattr(self, '_dump_manager', None) is not None:
return self._dump_manager.get_last_saved('mfd')
# fallback: check module-level saved lists
if getattr(self, '_saved_pngs', None):
return self._saved_pngs[-1] if len(self._saved_pngs) else None
except Exception:
pass
return None
def get_last_saved_sar(self) -> Optional[str]:
try:
if getattr(self, '_dump_manager', None) is not None:
return self._dump_manager.get_last_saved('sar')
if getattr(self, '_saved_sar_pngs', None):
return self._saved_sar_pngs[-1] if len(self._saved_sar_pngs) else None
except Exception:
pass
return None
def get_last_video_path(self, category: Optional[str] = None) -> Optional[str]:
try:
if getattr(self, '_dump_manager', None) is not None:
return self._dump_manager.get_last_video_path(category)
except Exception:
pass
return None
def update_mfd_lut(self, lut) -> None:
"""Receive an MFD LUT (numpy array or similar) from the UI and store it.
The module will use this LUT when colorizing MFD frames. This is
intentionally lightweight: callers (the viewer) are expected to
construct a proper 256x3 uint8 LUT. We simply store and log it.
"""
try:
self._mfd_lut = lut
logging.getLogger().info("VideoReceiverSFP: MFD LUT updated from UI")
except Exception:
logging.getLogger().exception("VideoReceiverSFP: failed to update MFD LUT from UI")
# Callback registration helpers
def register_callback(self, cb: Callable[[Any], None]) -> None:
try:
if cb not in self._callbacks:
self._callbacks.append(cb)
# Immediately deliver last frame if available
try:
if self._last_frame is not None:
cb(self._last_frame)
except Exception:
pass
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to register generic callback')
def register_mfd_callback(self, cb: Callable[[Any], None]) -> None:
try:
if cb not in self._mfd_callbacks:
self._mfd_callbacks.append(cb)
# deliver last frame immediately when present
try:
if self._last_frame is not None:
cb(self._last_frame)
except Exception:
pass
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to register mfd callback')
def register_sar_callback(self, cb: Callable[[Any], None]) -> None:
try:
if cb not in self._sar_callbacks:
self._sar_callbacks.append(cb)
try:
if self._last_frame is not None:
cb(self._last_frame)
except Exception:
pass
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to register sar callback')
def register_sar_metadata_callback(self, cb: Callable[[str], None]) -> None:
try:
if cb not in self._sar_metadata_callbacks:
self._sar_metadata_callbacks.append(cb)
try:
if getattr(self, '_last_sar_metadata_str', None) is not None:
cb(self._last_sar_metadata_str)
except Exception:
pass
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to register sar metadata callback')
def _notify_sar_metadata(self, metadata_str: Optional[str]) -> None:
for cb in list(self._sar_metadata_callbacks):
try:
cb(metadata_str)
except Exception:
pass
def get_status(self) -> Dict[str, Any]:
return {
"is_running": bool(self.is_running),
"sim_dir": self._sim_dir,
"fps": self._fps,
"host": self._host,
"port": self._port,
}
def start_session(self) -> bool:
try:
if self.is_running:
return True
# If host/port provided -> network mode using SfpTransport
if self._host and self._port and SfpTransport is not None:
handlers = {
ord('M'): self._network_payload_handler_mfd,
ord('S'): self._network_payload_handler_sar,
}
# general fallback handler for other flows
handlers[0] = self._network_payload_handler
try:
self._transport = SfpTransport(self._host, int(self._port), handlers)
started = self._transport.start()
if not started:
logging.getLogger().error('VideoReceiverSFP: failed to start SfpTransport')
self._transport = None
else:
logging.getLogger().info('VideoReceiverSFP: SfpTransport started on %s:%s', self._host, self._port)
except Exception:
logging.getLogger().exception('VideoReceiverSFP: exception while creating SfpTransport')
# If sim_dir configured -> start simulation loop thread
if self._sim_dir:
self._stop_event.clear()
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
logging.getLogger().info('VideoReceiverSFP: simulation thread started')
self.is_running = True
return True
except Exception:
logging.getLogger().exception('VideoReceiverSFP: failed to start session')
return False
def stop_session(self) -> None:
try:
self._stop_event.set()
if getattr(self, '_thread', None) is not None and self._thread.is_alive():
try:
self._thread.join(timeout=1.0)
except Exception:
pass
if getattr(self, '_transport', None) is not None:
try:
self._transport.shutdown()
except Exception:
pass
self._transport = None
self.is_running = False
except Exception:
logging.getLogger().exception('VideoReceiverSFP: error stopping session')