sistemata visualizzazione sar e start acquisitzione mfd
This commit is contained in:
parent
ff64d81a38
commit
f4b190fa48
200
VideoReceiverSFP/core/payload_dispatcher.py
Normal file
200
VideoReceiverSFP/core/payload_dispatcher.py
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"""Dispatch logic for decoded frames (MFD/SAR/generic).
|
||||||
|
|
||||||
|
Provides `dispatch_frame(module, frame, leader)` which encapsulates
|
||||||
|
recording, saving previews, and invoking registered callbacks.
|
||||||
|
"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps, ImageEnhance
|
||||||
|
except Exception:
|
||||||
|
Image = None
|
||||||
|
ImageOps = None
|
||||||
|
ImageEnhance = None
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_frame(module: Any, frame: Any, leader: Optional[Any]) -> None:
|
||||||
|
"""Handle a decoded frame: start/ write video, save previews, and call callbacks.
|
||||||
|
|
||||||
|
`module` is the `SfpConnectorModule` instance.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Determine image type
|
||||||
|
image_type_id = None
|
||||||
|
try:
|
||||||
|
if leader is not None:
|
||||||
|
image_type_id = int(leader.HEADER_DATA.TYPE)
|
||||||
|
except Exception:
|
||||||
|
image_type_id = None
|
||||||
|
|
||||||
|
# SAR path
|
||||||
|
if image_type_id == getattr(module, 'IMAGE_TYPE_SAR', 2):
|
||||||
|
try:
|
||||||
|
if Image is not None and hasattr(frame, 'copy'):
|
||||||
|
sar_img = frame.copy()
|
||||||
|
if getattr(module, '_sar_autocontrast', False) and ImageOps is not None:
|
||||||
|
try:
|
||||||
|
sar_img = ImageOps.autocontrast(sar_img)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
bf = max(0.0, 1.0 + (float(getattr(module, '_sar_brightness', 0)) / 100.0))
|
||||||
|
cf = max(0.0, 1.0 + (float(getattr(module, '_sar_contrast', 0)) / 100.0))
|
||||||
|
if bf != 1.0 and ImageEnhance is not None:
|
||||||
|
sar_img = ImageEnhance.Brightness(sar_img).enhance(bf)
|
||||||
|
if cf != 1.0 and ImageEnhance is not None:
|
||||||
|
sar_img = ImageEnhance.Contrast(sar_img).enhance(cf)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# save sar png
|
||||||
|
if getattr(module, '_sar_save_png', False):
|
||||||
|
try:
|
||||||
|
if getattr(module, '_dump_manager', None) is not None:
|
||||||
|
saved = module._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(module, '_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)
|
||||||
|
module._saved_sar_pngs.append(pngpath)
|
||||||
|
while len(module._saved_sar_pngs) > module._dump_keep:
|
||||||
|
old = module._saved_sar_pngs.popleft()
|
||||||
|
try:
|
||||||
|
pathlib.Path(old).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception("VideoReceiverSFP: failed saving sar png frame")
|
||||||
|
|
||||||
|
# sar video
|
||||||
|
try:
|
||||||
|
if getattr(module, '_record_sar_video', False) and getattr(module, '_dump_manager', None) is not None:
|
||||||
|
if not getattr(module, '_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:
|
||||||
|
started = module._dump_manager.start_video_record('sar', w, h, fps=getattr(module, '_video_fps', 20))
|
||||||
|
if started:
|
||||||
|
module._sar_video_active = True
|
||||||
|
try:
|
||||||
|
logging.getLogger().info("VideoReceiverSFP: started SAR video writer (%dx%d @ %s FPS)", w, h, getattr(module, '_video_fps', 20))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception("VideoReceiverSFP: failed to start sar video writer")
|
||||||
|
if getattr(module, '_sar_video_active', False):
|
||||||
|
try:
|
||||||
|
module._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 callbacks
|
||||||
|
for scb in list(getattr(module, '_sar_callbacks', [])):
|
||||||
|
try:
|
||||||
|
scb(sar_img)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception("VideoReceiverSFP: error processing SAR image for SAR callbacks")
|
||||||
|
|
||||||
|
# MFD path
|
||||||
|
if image_type_id == getattr(module, 'IMAGE_TYPE_MFD', 1):
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if getattr(module, '_record_mfd_video', False) and getattr(module, '_dump_manager', None) is not None:
|
||||||
|
if not getattr(module, '_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 = module._dump_manager.start_video_record('mfd', w, h, fps=getattr(module, '_video_fps', 20))
|
||||||
|
if started:
|
||||||
|
module._mfd_video_active = True
|
||||||
|
try:
|
||||||
|
logging.getLogger().info("VideoReceiverSFP: started MFD video writer (%dx%d @ %s FPS)", w, h, getattr(module, '_video_fps', 20))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception('VideoReceiverSFP: failed to start mfd video writer')
|
||||||
|
if getattr(module, '_mfd_video_active', False):
|
||||||
|
try:
|
||||||
|
module._dump_manager.write_video_frame(frame, 'mfd')
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception('VideoReceiverSFP: failed writing mfd frame to video')
|
||||||
|
try:
|
||||||
|
if not getattr(module, '_mfd_callbacks', False):
|
||||||
|
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(getattr(module, '_mfd_callbacks', [])):
|
||||||
|
try:
|
||||||
|
mcb(frame)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# generic callbacks and saving
|
||||||
|
for cb in list(getattr(module, '_callbacks', [])):
|
||||||
|
try:
|
||||||
|
if getattr(module, '_save_png', False) and Image is not None and hasattr(frame, 'save'):
|
||||||
|
try:
|
||||||
|
if getattr(module, '_dump_manager', None) is not None:
|
||||||
|
saved = module._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(module, '_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)
|
||||||
|
module._saved_pngs.append(pngpath)
|
||||||
|
while len(module._saved_pngs) > module._dump_keep:
|
||||||
|
old = module._saved_pngs.popleft()
|
||||||
|
try:
|
||||||
|
pathlib.Path(old).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception('VideoReceiverSFP: failed to save preview png')
|
||||||
|
try:
|
||||||
|
cb(frame)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger().exception('VideoReceiverSFP: dispatch_frame unexpected error')
|
||||||
@ -113,10 +113,16 @@ def save_payload_preview(module, leader, payload, ity, dbgdir, ts):
|
|||||||
base_name = 'mfd' if ity == 1 else ('sar' if ity == 2 else 'unknown')
|
base_name = 'mfd' if ity == 1 else ('sar' if ity == 2 else 'unknown')
|
||||||
fmt = 'bmp'
|
fmt = 'bmp'
|
||||||
if getattr(module, '_dump_manager', None) is not None:
|
if getattr(module, '_dump_manager', None) is not None:
|
||||||
saved = module._dump_manager.save_preview_from_array(norm, category=base_name, fmt=fmt)
|
try:
|
||||||
if saved:
|
# Convert numpy array to PIL Image and use existing DumpManager API
|
||||||
logging.getLogger().info('VideoReceiverSFP: saved payload preview %s', saved)
|
from PIL import Image as _PILImage
|
||||||
return
|
pil_img = _PILImage.fromarray(norm, mode='L')
|
||||||
|
saved = module._dump_manager.save_preview_from_pil(pil_img, category=base_name, fmt=fmt)
|
||||||
|
if saved:
|
||||||
|
logging.getLogger().info('VideoReceiverSFP: saved payload preview %s', saved)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception('save_payload_preview: DumpManager save_preview_from_pil failed for array input')
|
||||||
else:
|
else:
|
||||||
from PIL import Image as _PILImage
|
from PIL import Image as _PILImage
|
||||||
ts_name = ts + (f"_{int(__import__('time').time() * 1000) % 1000:03d}")
|
ts_name = ts + (f"_{int(__import__('time').time() * 1000) % 1000:03d}")
|
||||||
|
|||||||
@ -4,6 +4,7 @@ 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
|
mode by reading images from a local directory and publishing frames to
|
||||||
registered callbacks, or in network mode by listening for SFP UDP packets.
|
registered callbacks, or in network mode by listening for SFP UDP packets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Callable, Dict, Any, Optional
|
from typing import Callable, Dict, Any, Optional
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import pathlib
|
import pathlib
|
||||||
@ -54,24 +55,34 @@ try:
|
|||||||
from .dump_manager import DumpManager
|
from .dump_manager import DumpManager
|
||||||
except Exception:
|
except Exception:
|
||||||
DumpManager = None
|
DumpManager = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .payload_saver import save_bin_payload, save_unknown_sample
|
from .payload_saver import save_bin_payload, save_unknown_sample
|
||||||
except Exception:
|
except Exception:
|
||||||
save_bin_payload = None
|
save_bin_payload = None
|
||||||
save_unknown_sample = None
|
save_unknown_sample = None
|
||||||
|
try:
|
||||||
|
from .payload_dispatcher import dispatch_frame
|
||||||
|
except Exception:
|
||||||
|
dispatch_frame = None
|
||||||
|
|
||||||
# --- Image Type Constants ---
|
# --- Image Type Constants ---
|
||||||
# Based on actual payload analysis: TYPE=1 with PALTYPE=1 is MFD, TYPE=2 is SAR
|
# Based on actual payload analysis: TYPE=1 with PALTYPE=1 is MFD, TYPE=2 is SAR
|
||||||
IMAGE_TYPE_UNKNOWN = 0
|
IMAGE_TYPE_UNKNOWN = 0
|
||||||
IMAGE_TYPE_MFD = 1 # MFD images have TYPE=1 and typically PALTYPE=1 (indexed palette)
|
IMAGE_TYPE_MFD = 1
|
||||||
IMAGE_TYPE_SAR = 2 # SAR images have TYPE=2
|
IMAGE_TYPE_SAR = 2
|
||||||
|
|
||||||
def get_image_type_name(type_id):
|
|
||||||
"""Helper function to get the name of the image type."""
|
def get_image_type_name(type_id: int) -> str:
|
||||||
return {
|
"""Return a short name for image type ids used in payloads."""
|
||||||
IMAGE_TYPE_SAR: "SAR",
|
try:
|
||||||
IMAGE_TYPE_MFD: "MFD",
|
if int(type_id) == IMAGE_TYPE_MFD:
|
||||||
}.get(type_id, f"Unknown ({type_id})")
|
return 'MFD'
|
||||||
|
if int(type_id) == IMAGE_TYPE_SAR:
|
||||||
|
return 'SAR'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 'UNKNOWN'
|
||||||
|
|
||||||
|
|
||||||
class SfpConnectorModule:
|
class SfpConnectorModule:
|
||||||
@ -83,7 +94,7 @@ class SfpConnectorModule:
|
|||||||
dispatch completed payloads to callbacks.
|
dispatch completed payloads to callbacks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, name: str = "VideoReceiverSFP"):
|
def __init__(self, name: str = "VideoReceiverSFP") -> None:
|
||||||
self.module_name = name
|
self.module_name = name
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
@ -102,7 +113,6 @@ class SfpConnectorModule:
|
|||||||
self._image_type_filter: Optional[int] = None
|
self._image_type_filter: Optional[int] = None
|
||||||
self._mfd_lut = None
|
self._mfd_lut = None
|
||||||
# saving flags and queues
|
# saving flags and queues
|
||||||
# Saving of received payloads is disabled by default; use config flags
|
|
||||||
self._save_png = False
|
self._save_png = False
|
||||||
self._save_bin = False
|
self._save_bin = False
|
||||||
# video recording flags (disabled by default)
|
# video recording flags (disabled by default)
|
||||||
@ -121,7 +131,6 @@ class SfpConnectorModule:
|
|||||||
self._dump_keep = 100
|
self._dump_keep = 100
|
||||||
self._dumps_dir = os.path.join(os.getcwd(), 'dumps')
|
self._dumps_dir = os.path.join(os.getcwd(), 'dumps')
|
||||||
|
|
||||||
# centralized dump manager for saving/rotation
|
|
||||||
try:
|
try:
|
||||||
if DumpManager is not None:
|
if DumpManager is not None:
|
||||||
self._dump_manager = DumpManager(self._dumps_dir, self._dump_keep)
|
self._dump_manager = DumpManager(self._dumps_dir, self._dump_keep)
|
||||||
@ -129,377 +138,22 @@ class SfpConnectorModule:
|
|||||||
self._dump_manager = None
|
self._dump_manager = None
|
||||||
except Exception:
|
except Exception:
|
||||||
self._dump_manager = None
|
self._dump_manager = None
|
||||||
# deques to track saved files for rotation
|
|
||||||
self._saved_pngs = deque()
|
self._saved_pngs = deque()
|
||||||
self._saved_bins = deque()
|
self._saved_bins = deque()
|
||||||
|
|
||||||
# dedicated callback lists for MFD and SAR viewers
|
|
||||||
self._mfd_callbacks = []
|
self._mfd_callbacks = []
|
||||||
self._sar_callbacks = []
|
self._sar_callbacks = []
|
||||||
# callbacks for SAR metadata display (receives a formatted string)
|
|
||||||
self._sar_metadata_callbacks = []
|
self._sar_metadata_callbacks = []
|
||||||
|
|
||||||
# SAR-specific display controls
|
|
||||||
self._sar_brightness = 0
|
self._sar_brightness = 0
|
||||||
self._sar_contrast = 0
|
self._sar_contrast = 0
|
||||||
self._sar_autocontrast = False
|
self._sar_autocontrast = False
|
||||||
self._saved_sar_pngs = deque()
|
self._saved_sar_pngs = deque()
|
||||||
self._sar_save_png = False
|
self._sar_save_png = False
|
||||||
|
|
||||||
# track whether we've saved the first decoded frame to disk
|
|
||||||
self._first_frame_saved = False
|
self._first_frame_saved = False
|
||||||
self._first_frame_path: Optional[str] = None
|
self._first_frame_path: Optional[str] = None
|
||||||
|
|
||||||
# internal counters for logging
|
|
||||||
self._frames_received = 0
|
self._frames_received = 0
|
||||||
|
|
||||||
def initialize(self, config: Dict[str, Any]) -> bool:
|
def _loop(self) -> None:
|
||||||
"""Initialize the module.
|
|
||||||
|
|
||||||
Config keys:
|
|
||||||
- sim_dir: optional directory with sample images for simulated mode
|
|
||||||
- fps: frames per second (default 5)
|
|
||||||
- host: optional host to bind for network mode
|
|
||||||
- port: optional UDP port for network mode
|
|
||||||
"""
|
|
||||||
self._sim_dir = config.get("sim_dir")
|
|
||||||
self._fps = int(config.get("fps", 5))
|
|
||||||
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.")
|
|
||||||
else:
|
|
||||||
logging.warning("VideoReceiverSFP: Unknown image_type_filter=%s", image_type_str)
|
|
||||||
try:
|
|
||||||
logging.getLogger().debug("VideoReceiverSFP.initialize: sim_dir=%s fps=%s host=%s port=%s", self._sim_dir, self._fps, self._host, self._port)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Read default recording flags from local module config if present
|
|
||||||
try:
|
|
||||||
# Try to prefer centralized app config only if it explicitly defines the keys.
|
|
||||||
# Otherwise fall back to the local VideoReceiverSFP/core/config.py defaults.
|
|
||||||
try:
|
|
||||||
import controlpanel.config as appcfg
|
|
||||||
from . import config as localcfg
|
|
||||||
# If controlpanel.config explicitly defines the attributes, use them;
|
|
||||||
# otherwise use the local module defaults.
|
|
||||||
if hasattr(appcfg, 'RECORD_MFD_VIDEO'):
|
|
||||||
try:
|
|
||||||
self._record_mfd_video = bool(getattr(appcfg, 'RECORD_MFD_VIDEO'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
self._record_mfd_video = bool(getattr(localcfg, 'RECORD_MFD_VIDEO', self._record_mfd_video))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if hasattr(appcfg, 'RECORD_SAR_VIDEO'):
|
|
||||||
try:
|
|
||||||
self._record_sar_video = bool(getattr(appcfg, 'RECORD_SAR_VIDEO'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
self._record_sar_video = bool(getattr(localcfg, 'RECORD_SAR_VIDEO', self._record_sar_video))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if hasattr(appcfg, 'VIDEO_FPS'):
|
|
||||||
try:
|
|
||||||
self._video_fps = int(getattr(appcfg, 'VIDEO_FPS'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
self._video_fps = int(getattr(localcfg, 'VIDEO_FPS', self._video_fps))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
# controlpanel.config not available; use local module config
|
|
||||||
from .config import RECORD_MFD_VIDEO, RECORD_SAR_VIDEO, VIDEO_FPS
|
|
||||||
try:
|
|
||||||
self._record_mfd_video = bool(RECORD_MFD_VIDEO)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self._record_sar_video = bool(RECORD_SAR_VIDEO)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
self._video_fps = int(VIDEO_FPS)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Allow orchestrator to override recording flags/fps via the config dict
|
|
||||||
try:
|
|
||||||
# Keys expected: 'record_mfd_video', 'record_sar_video', 'video_fps'
|
|
||||||
if 'record_mfd_video' in config:
|
|
||||||
try:
|
|
||||||
self._record_mfd_video = bool(config.get('record_mfd_video'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if 'record_sar_video' in config:
|
|
||||||
try:
|
|
||||||
self._record_sar_video = bool(config.get('record_sar_video'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if 'video_fps' in config:
|
|
||||||
try:
|
|
||||||
self._video_fps = int(config.get('video_fps'))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
|
|
||||||
def start_session(self) -> None:
|
|
||||||
if self.is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
# If network config provided and transport available, start transport
|
|
||||||
if self._host and self._port and SfpTransport is not None:
|
|
||||||
try:
|
|
||||||
# Register distinct handlers per SFP flow so the transport
|
|
||||||
# can call the correct one and we can rely on the flow id
|
|
||||||
# (ord('M') for MFD, ord('S') for SAR) instead of only
|
|
||||||
# trusting the embedded ImageLeader.TYPE which may be 0.
|
|
||||||
handlers = {
|
|
||||||
ord("M"): self._network_payload_handler_mfd,
|
|
||||||
ord("S"): self._network_payload_handler_sar,
|
|
||||||
}
|
|
||||||
self._transport = SfpTransport(self._host, int(self._port), handlers)
|
|
||||||
started = self._transport.start()
|
|
||||||
if started:
|
|
||||||
try:
|
|
||||||
logging.getLogger().info("VideoReceiverSFP: started SfpTransport on %s:%s", self._host, self._port)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self.is_running = True
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to start SfpTransport; falling back to sim mode")
|
|
||||||
|
|
||||||
# fallback to simulated folder stream
|
|
||||||
self._stop_event.clear()
|
|
||||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
||||||
self._thread.start()
|
|
||||||
self.is_running = True
|
|
||||||
|
|
||||||
def stop_session(self) -> None:
|
|
||||||
if not self.is_running:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Stop transport if active
|
|
||||||
if getattr(self, "_transport", None) is not None:
|
|
||||||
try:
|
|
||||||
self._transport.shutdown()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._transport = None
|
|
||||||
|
|
||||||
# Ensure any active video recordings are closed
|
|
||||||
try:
|
|
||||||
if getattr(self, '_dump_manager', None) is not None:
|
|
||||||
try:
|
|
||||||
logging.getLogger().info("VideoReceiverSFP: requesting stop of MFD video writer")
|
|
||||||
self._dump_manager.stop_video_record('mfd')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
logging.getLogger().info("VideoReceiverSFP: requesting stop of SAR video writer")
|
|
||||||
self._dump_manager.stop_video_record('sar')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._stop_event.set()
|
|
||||||
if self._thread is not None:
|
|
||||||
self._thread.join(timeout=2.0)
|
|
||||||
# mark writers inactive locally
|
|
||||||
self._mfd_video_active = False
|
|
||||||
self._sar_video_active = False
|
|
||||||
self.is_running = False
|
|
||||||
|
|
||||||
def register_callback(self, cb: Callable[[Any], None]) -> None:
|
|
||||||
if cb not in self._callbacks:
|
|
||||||
self._callbacks.append(cb)
|
|
||||||
try:
|
|
||||||
logging.getLogger().debug("VideoReceiverSFP: registered callback %s", getattr(cb, '__name__', str(cb)))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# if we already have a last frame, deliver it immediately so UI can show initial image
|
|
||||||
try:
|
|
||||||
if self._last_frame is not None:
|
|
||||||
try:
|
|
||||||
cb(self._last_frame)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def register_mfd_callback(self, cb: Callable[[Any], None]) -> None:
|
|
||||||
if cb not in self._mfd_callbacks:
|
|
||||||
self._mfd_callbacks.append(cb)
|
|
||||||
# push last frame immediately if available (helps initial UI refresh)
|
|
||||||
try:
|
|
||||||
if self._last_frame is not None:
|
|
||||||
try:
|
|
||||||
cb(self._last_frame)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def register_sar_callback(self, cb: Callable[[Any], None]) -> None:
|
|
||||||
if cb not in self._sar_callbacks:
|
|
||||||
self._sar_callbacks.append(cb)
|
|
||||||
try:
|
|
||||||
if self._last_frame is not None:
|
|
||||||
try:
|
|
||||||
cb(self._last_frame)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def register_sar_metadata_callback(self, cb: Callable[[str], None]) -> None:
|
|
||||||
"""Register a callback that receives a formatted metadata string for the last SAR image."""
|
|
||||||
if cb not in self._sar_metadata_callbacks:
|
|
||||||
self._sar_metadata_callbacks.append(cb)
|
|
||||||
|
|
||||||
def _notify_sar_metadata(self, metadata_str: Optional[str]) -> None:
|
|
||||||
if not metadata_str:
|
|
||||||
return
|
|
||||||
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": self.is_running,
|
|
||||||
"sim_dir": self._sim_dir,
|
|
||||||
"fps": self._fps,
|
|
||||||
"host": self._host,
|
|
||||||
"port": self._port,
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_data_stream(self) -> Any:
|
|
||||||
with self._frame_lock:
|
|
||||||
return self._last_frame
|
|
||||||
|
|
||||||
def update_mfd_lut(self, new_lut) -> None:
|
|
||||||
"""Update the MFD LUT used for MFD image decoding.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
new_lut: 256x3 RGB LUT array (uint8)
|
|
||||||
"""
|
|
||||||
self._mfd_lut = new_lut
|
|
||||||
logging.info("VideoReceiverSFP: MFD LUT updated")
|
|
||||||
# Optionally re-decode last frame if it was MFD type
|
|
||||||
# (For simplicity, next frame will use new LUT)
|
|
||||||
|
|
||||||
def save_last_frame(self, path: str) -> bool:
|
|
||||||
frame = self.get_data_stream()
|
|
||||||
if frame is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
if Image is not None and hasattr(frame, "save"):
|
|
||||||
frame.save(path)
|
|
||||||
else:
|
|
||||||
with open(path, "wb") as fh:
|
|
||||||
fh.write(frame)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _list_sim_files(self):
|
|
||||||
if not self._sim_dir or not os.path.isdir(self._sim_dir):
|
|
||||||
return []
|
|
||||||
files = [f for f in sorted(os.listdir(self._sim_dir)) if not f.startswith(".")]
|
|
||||||
return [os.path.join(self._sim_dir, f) for f in files]
|
|
||||||
|
|
||||||
def _load_next_frame(self):
|
|
||||||
files = self._list_sim_files()
|
|
||||||
if not files:
|
|
||||||
return None
|
|
||||||
path = files[self._frame_idx % len(files)]
|
|
||||||
self._frame_idx += 1
|
|
||||||
try:
|
|
||||||
if Image is not None:
|
|
||||||
# Open without forced conversion so we can detect grayscale/index images
|
|
||||||
img = Image.open(path)
|
|
||||||
try:
|
|
||||||
mode = getattr(img, 'mode', None)
|
|
||||||
# If the file is single-channel (likely an MFD index image), attempt to colorize
|
|
||||||
if mode in ('L', 'P'):
|
|
||||||
try:
|
|
||||||
import numpy as _np
|
|
||||||
inds = _np.array(img.convert('L'))
|
|
||||||
self._ensure_mfd_lut()
|
|
||||||
if self._mfd_lut is not None and apply_mfd_lut is not None:
|
|
||||||
rgb = apply_mfd_lut(inds, self._mfd_lut)
|
|
||||||
if rgb is not None and Image is not None:
|
|
||||||
try:
|
|
||||||
frame = Image.fromarray(rgb.astype('uint8'), mode='RGB')
|
|
||||||
dumps_dir = os.path.join(os.getcwd(), 'dumps')
|
|
||||||
os.makedirs(dumps_dir, exist_ok=True)
|
|
||||||
dbg_path = os.path.join(dumps_dir, 'VideoReceiverSFP_colorized_debug.png')
|
|
||||||
frame.save(dbg_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return frame
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Some dumps are stored as RGB but actually contain index images (identical channels / low cardinality)
|
|
||||||
if mode == 'RGB':
|
|
||||||
try:
|
|
||||||
import numpy as _np
|
|
||||||
larr = _np.array(img.convert('L'))
|
|
||||||
unique_vals = _np.unique(larr)
|
|
||||||
# If unique index count is small (<=32) treat as indices and colorize
|
|
||||||
if unique_vals.size <= 32:
|
|
||||||
self._ensure_mfd_lut()
|
|
||||||
if self._mfd_lut is not None and apply_mfd_lut is not None:
|
|
||||||
rgb = apply_mfd_lut(larr, self._mfd_lut)
|
|
||||||
if rgb is not None and Image is not None:
|
|
||||||
try:
|
|
||||||
frame = Image.fromarray(rgb.astype('uint8'), mode='RGB')
|
|
||||||
dumps_dir = os.path.join(os.getcwd(), 'dumps')
|
|
||||||
os.makedirs(dumps_dir, exist_ok=True)
|
|
||||||
dbg_path = os.path.join(dumps_dir, 'VideoReceiverSFP_colorized_debug.png')
|
|
||||||
frame.save(dbg_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return frame
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Default: return an RGB-converted copy for display
|
|
||||||
return img.convert("RGB")
|
|
||||||
except Exception:
|
|
||||||
return img.convert("RGB")
|
|
||||||
else:
|
|
||||||
with open(path, "rb") as fh:
|
|
||||||
return fh.read()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _loop(self):
|
|
||||||
interval = 1.0 / max(1, self._fps)
|
interval = 1.0 / max(1, self._fps)
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
frame = self._load_next_frame()
|
frame = self._load_next_frame()
|
||||||
@ -1437,6 +1091,70 @@ class SfpConnectorModule:
|
|||||||
self._save_bin = bool(enabled)
|
self._save_bin = bool(enabled)
|
||||||
logging.getLogger().info("VideoReceiverSFP: save_bin set to %s", self._save_bin)
|
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:
|
def set_record_mfd_video(self, enabled: bool, fps: Optional[int] = None) -> None:
|
||||||
try:
|
try:
|
||||||
self._record_mfd_video = bool(enabled)
|
self._record_mfd_video = bool(enabled)
|
||||||
@ -1496,3 +1214,125 @@ class SfpConnectorModule:
|
|||||||
logging.getLogger().info("VideoReceiverSFP: sar_save_png set to %s", self._sar_save_png)
|
logging.getLogger().info("VideoReceiverSFP: sar_save_png set to %s", self._sar_save_png)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|||||||
@ -67,15 +67,18 @@ class SfpSarViewer:
|
|||||||
save_cb = ttk.Checkbutton(ctrl_frame, text="Save .png", variable=self._save_png_var, command=self._on_save_png_toggled)
|
save_cb = ttk.Checkbutton(ctrl_frame, text="Save .png", variable=self._save_png_var, command=self._on_save_png_toggled)
|
||||||
save_cb.grid(row=6, column=0, sticky="w", pady=(2,2))
|
save_cb.grid(row=6, column=0, sticky="w", pady=(2,2))
|
||||||
|
|
||||||
# Status / FPS
|
|
||||||
self._fps_label = ttk.Label(ctrl_frame, text="FPS: 0.00")
|
|
||||||
self._fps_label.grid(row=6, column=0, sticky="w", pady=(8,0))
|
|
||||||
|
|
||||||
ctrl_frame.columnconfigure(0, weight=1)
|
ctrl_frame.columnconfigure(0, weight=1)
|
||||||
main.rowconfigure(0, weight=1)
|
main.rowconfigure(0, weight=1)
|
||||||
|
main.rowconfigure(1, weight=0)
|
||||||
main.columnconfigure(0, weight=3)
|
main.columnconfigure(0, weight=3)
|
||||||
main.columnconfigure(1, weight=1)
|
main.columnconfigure(1, weight=1)
|
||||||
|
|
||||||
|
# Bottom: Status bar (FPS)
|
||||||
|
status_frame = ttk.Frame(main, relief="sunken")
|
||||||
|
status_frame.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0))
|
||||||
|
self._fps_label = ttk.Label(status_frame, text="FPS: 0.00")
|
||||||
|
self._fps_label.pack(side="left", padx=5)
|
||||||
|
|
||||||
# runtime state
|
# runtime state
|
||||||
self._photo = None
|
self._photo = None
|
||||||
self._pending_frame = None
|
self._pending_frame = None
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user