SXXXXXXX_ControlPanel/display.py
2025-04-15 14:06:44 +02:00

518 lines
23 KiB
Python

# display.py
"""
THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
Manages the display of MFD and SAR images using OpenCV windows.
Handles window creation, positioning, image updates (called from main thread),
mouse event callbacks for the SAR window, and window destruction during shutdown.
Uses standardized logging prefixes and levels.
"""
# Standard library imports
import logging
import queue
import time
from typing import Optional # For type hinting
# Third-party imports
import cv2
import numpy as np
# Local application imports
import config # For default sizes and other constants
from utils import put_queue
class DisplayManager:
"""
Manages MFD and SAR OpenCV display windows and related interactions.
Requires image update calls (show_mfd_image, show_sar_image) to be made
from the main GUI thread. Logs operations with the [DisplayMgr] prefix.
"""
# --- MODIFIED FUNCTION: __init__ ---
def __init__(
self,
app,
sar_queue,
mouse_queue,
sar_x,
sar_y,
mfd_x,
mfd_y,
initial_sar_width: Optional[int] = None, # <<< ADDED Argument
initial_sar_height: Optional[int] = None, # <<< ADDED Argument
):
"""
Initializes the DisplayManager.
Args:
app (App): Reference to the main application instance.
sar_queue (queue.Queue): Queue for receiving processed SAR images.
mouse_queue (queue.Queue): Queue for sending raw mouse coordinates.
sar_x (int): Initial X position for the SAR window.
sar_y (int): Initial Y position for the SAR window.
mfd_x (int): Initial X position for the MFD window.
mfd_y (int): Initial Y position for the MFD window.
initial_sar_width (Optional[int]): Initial width for SAR placeholder/window. Defaults to config.
initial_sar_height (Optional[int]): Initial height for SAR placeholder/window. Defaults to config.
"""
# Define log prefix for this class instance
self.log_prefix = "[DisplayMgr]"
logging.debug(f"{self.log_prefix} Initializing DisplayManager...")
self.app = app # Reference to main application
self.sar_queue = sar_queue
self.mouse_queue = mouse_queue
self.sar_x = sar_x
self.sar_y = sar_y
self.mfd_x = mfd_x
self.mfd_y = mfd_y
self.sar_window_name = "SAR"
self.mfd_window_name = "MFD"
self.sar_window_initialized = False
self.mfd_window_initialized = False
self.sar_mouse_callback_set = False
# Store initial dimensions from arguments or config for placeholders
# Use passed values if provided, otherwise fall back to config defaults
self.initial_mfd_width = (
config.INITIAL_MFD_WIDTH
) # MFD size not changed by map overlay (yet)
self.initial_mfd_height = config.INITIAL_MFD_HEIGHT
self.initial_sar_width = (
initial_sar_width
if initial_sar_width is not None
else config.INITIAL_SAR_WIDTH
)
self.initial_sar_height = (
initial_sar_height
if initial_sar_height is not None
else config.INITIAL_SAR_HEIGHT
)
logging.debug(
f"{self.log_prefix} Using Initial Sizes - MFD: {self.initial_mfd_width}x{self.initial_mfd_height}, SAR: {self.initial_sar_width}x{self.initial_sar_height}"
)
logging.debug(f"{self.log_prefix} DisplayManager initialization complete.")
# --- END MODIFIED FUNCTION: __init__ ---
# --- MODIFIED FUNCTION: initialize_display_windows ---
def initialize_display_windows(self):
"""
Creates and displays initial placeholder windows for MFD and SAR.
Forces OpenCV windows to appear at startup with default background.
MUST be called from the main GUI thread.
Uses the initial dimensions stored during __init__.
"""
# Use INFO level for significant initialization step
logging.info(
f"{self.log_prefix} Initializing MFD and SAR display windows with placeholders..."
)
try:
# MFD Placeholder - Uses self.initial_mfd_... attributes
logging.debug(
f"{self.log_prefix} Creating placeholder MFD image ({self.initial_mfd_height}x{self.initial_mfd_width})."
)
placeholder_mfd = np.zeros(
(self.initial_mfd_height, self.initial_mfd_width, 3), dtype=np.uint8
)
placeholder_mfd[:, :] = [30, 30, 30] # Dark Gray BGR
logging.debug(f"{self.log_prefix} Attempting to show placeholder MFD...")
# show_mfd_image logs internally
self.show_mfd_image(placeholder_mfd)
# Log success after the call if window became initialized
if self.mfd_window_initialized:
logging.info(
f"{self.log_prefix} MFD window '{self.mfd_window_name}' initialized with placeholder."
)
else:
logging.warning(
f"{self.log_prefix} MFD window initialization attempt finished, but window not marked as initialized."
)
except Exception as e:
# Use ERROR for failure during initialization
logging.error(
f"{self.log_prefix} Failed to initialize MFD window with placeholder: {e}",
exc_info=True,
)
try:
# SAR Placeholder - Uses self.initial_sar_... attributes
logging.debug(
f"{self.log_prefix} Creating placeholder SAR image ({self.initial_sar_height}x{self.initial_sar_width})."
)
# Ensure placeholder is BGR (3 channels) as show_sar_image might receive colorized images later
placeholder_sar = np.zeros(
(self.initial_sar_height, self.initial_sar_width, 3), dtype=np.uint8
)
placeholder_sar[:, :] = [60, 60, 60] # Lighter Gray BGR
logging.debug(f"{self.log_prefix} Attempting to show placeholder SAR...")
# show_sar_image logs internally
self.show_sar_image(placeholder_sar)
# Log success after the call
if self.sar_window_initialized:
logging.info(
f"{self.log_prefix} SAR window '{self.sar_window_name}' initialized with placeholder."
)
else:
logging.warning(
f"{self.log_prefix} SAR window initialization attempt finished, but window not marked as initialized."
)
except Exception as e:
# Use ERROR for failure during initialization
logging.error(
f"{self.log_prefix} Failed to initialize SAR window with placeholder: {e}",
exc_info=True,
)
# --- END MODIFIED FUNCTION: initialize_display_windows ---
def show_mfd_image(self, image):
"""
Displays the MFD image in its OpenCV window. Creates/moves window on first call.
MUST be called from the main GUI thread. Includes cv2.waitKey(1).
Args:
image (numpy.ndarray): The MFD image to display (BGR format).
"""
# Use DEBUG for start of display function
logging.debug(
f"{self.log_prefix} Received request to show MFD image (Shape: {getattr(image, 'shape', 'N/A')})."
)
# Validate input - Use ERROR for invalid input causing failure
if image is None or not isinstance(image, np.ndarray) or image.size == 0:
logging.error(
f"{self.log_prefix} show_mfd_image received invalid image data. Aborting display."
)
return
if image.ndim != 3 or image.shape[2] != 3:
logging.error(
f"{self.log_prefix} show_mfd_image received non-BGR image (ndim={image.ndim}, channels={image.shape[2] if image.ndim==3 else 'N/A'}). Aborting display."
)
return
try:
window_name = self.mfd_window_name
# Create and move window only once
if not self.mfd_window_initialized:
# DEBUG for first-time setup
logging.debug(
f"{self.log_prefix} First call for '{window_name}'. Creating and positioning window."
)
cv2.imshow(window_name, image)
try:
cv2.moveWindow(window_name, self.mfd_x, self.mfd_y)
self.mfd_window_initialized = (
True # Set flag AFTER successful operations
)
# INFO log for successful window creation/move
logging.info(
f"{self.log_prefix} '{window_name}' window created and moved to ({self.mfd_x}, {self.mfd_y})."
)
cv2.waitKey(1) # Allow window to draw/position
except cv2.error as move_e:
# WARNING if move fails but window might still exist
logging.warning(
f"{self.log_prefix} Could not move '{window_name}' window on initialization: {move_e}. Window might appear elsewhere."
)
# Set flag true anyway, assuming imshow worked.
self.mfd_window_initialized = True
else:
# Just update the image - DEBUG for subsequent updates
logging.debug(
f"{self.log_prefix} Updating existing MFD window '{window_name}'."
)
cv2.imshow(window_name, image)
# Essential waitKey - DEBUG for calling waitKey
# logging.debug(f"{self.log_prefix} Calling waitKey(1) for MFD window.") # Less verbose
cv2.waitKey(1)
except cv2.error as e:
# Handle cases where window might have been closed manually
# Use WARNING as it's recoverable
if "NULL window" in str(e) or "invalid window" in str(e):
logging.warning(
f"{self.log_prefix} OpenCV window '{window_name}' seems closed. Will re-initialize on next valid image."
)
self.mfd_window_initialized = False # Reset flag
else:
# Keep EXCEPTION for other OpenCV errors
logging.exception(
f"{self.log_prefix} OpenCV error displaying MFD image: {e}"
)
except Exception as e:
# Keep EXCEPTION for unexpected errors
logging.exception(
f"{self.log_prefix} Unexpected error displaying MFD image: {e}"
)
def show_sar_image(self, image):
"""
Displays the SAR image in its OpenCV window. Handles first-time setup
(creation, move, mouse callback) and subsequent updates.
MUST be called from the main GUI thread. Includes cv2.waitKey(1).
Args:
image (numpy.ndarray): The SAR image (grayscale or BGR colorized).
"""
# DEBUG for start of display function
logging.debug(
f"{self.log_prefix} Received request to show SAR image (Shape: {getattr(image, 'shape', 'N/A')})."
)
# Validate input - ERROR for invalid input
if image is None or not isinstance(image, np.ndarray) or image.size == 0:
logging.error(
f"{self.log_prefix} show_sar_image received invalid image data. Aborting display."
)
return
if image.shape[0] == 0 or image.shape[1] == 0:
logging.error(
f"{self.log_prefix} show_sar_image received image with invalid dimensions: {image.shape}. Aborting."
)
return
# Basic check for supported types (grayscale or BGR)
if not (image.ndim == 2 or (image.ndim == 3 and image.shape[2] == 3)):
logging.error(
f"{self.log_prefix} show_sar_image received unsupported image format (ndim={image.ndim}, channels={image.shape[2] if image.ndim==3 else 'N/A'}). Expected Grayscale or BGR. Aborting."
)
return
try:
window_name = self.sar_window_name
# Create, move window, and set callback only once
if not self.sar_window_initialized:
# DEBUG for first-time setup
logging.debug(
f"{self.log_prefix} First call for '{window_name}'. Creating, positioning window, and setting callback."
)
cv2.imshow(window_name, image)
try:
cv2.moveWindow(window_name, self.sar_x, self.sar_y)
self.sar_window_initialized = True # Mark initialized
# INFO for successful window creation/move
logging.info(
f"{self.log_prefix} '{window_name}' window created and moved to ({self.sar_x}, {self.sar_y})."
)
# Set mouse callback immediately after successful initialization
if not self.sar_mouse_callback_set:
cv2.setMouseCallback(window_name, self.sar_mouse_callback)
self.sar_mouse_callback_set = True
# INFO for successful callback setup
logging.info(
f"{self.log_prefix} Mouse callback set for '{window_name}'."
)
cv2.waitKey(1) # Allow window to draw/position
except cv2.error as move_e:
# WARNING if move fails
logging.warning(
f"{self.log_prefix} Could not move '{window_name}' window on initialization: {move_e}."
)
self.sar_window_initialized = True # Assume imshow worked
# Still try setting callback even if move failed
if not self.sar_mouse_callback_set:
try:
cv2.setMouseCallback(window_name, self.sar_mouse_callback)
self.sar_mouse_callback_set = True
logging.info(
f"{self.log_prefix} Mouse callback set for '{window_name}' (after move failed)."
)
except cv2.error as cb_e:
# ERROR if callback setting fails
logging.error(
f"{self.log_prefix} Failed to set mouse callback for '{window_name}': {cb_e}"
)
else:
# Just update the image - DEBUG for subsequent updates
logging.debug(
f"{self.log_prefix} Updating existing SAR window '{window_name}'."
)
cv2.imshow(window_name, image)
# Essential waitKey - DEBUG for calling waitKey
# logging.debug(f"{self.log_prefix} Calling waitKey(1) for SAR window.") # Less verbose
cv2.waitKey(1)
except cv2.error as e:
# WARNING for recoverable closed window error
if "NULL window" in str(e) or "invalid window" in str(e):
logging.warning(
f"{self.log_prefix} OpenCV window '{self.sar_window_name}' seems closed. Will re-initialize on next valid image."
)
self.sar_window_initialized = False
self.sar_mouse_callback_set = False # Reset callback flag too
else:
# Keep EXCEPTION for other OpenCV errors
logging.exception(
f"{self.log_prefix} OpenCV error displaying SAR image: {e}"
)
except Exception as e:
# Keep EXCEPTION for unexpected errors
logging.exception(
f"{self.log_prefix} Unexpected error displaying SAR image: {e}"
)
# Method inside DisplayManager class in display.py
def sar_mouse_callback(self, event, x, y, flags, param):
"""
OpenCV mouse callback for the SAR window. Processes left mouse clicks,
clamps coordinates, and puts raw (x, y) onto the application's mouse_queue.
Updates only on click, removing the need for rate limiting.
"""
# --- MODIFIED: Process only left button down events ---
if event == cv2.EVENT_LBUTTONDOWN:
log_prefix_cb = f"{self.log_prefix} MouseClick"
logging.debug(
f"{log_prefix_cb} SAR Window Left Click detected at ({x}, {y})"
)
try:
current_display_width = self.app.state.sar_display_width
current_display_height = self.app.state.sar_display_height
if current_display_width <= 0 or current_display_height <= 0:
return
x_clamped = max(0, min(x, current_display_width - 1))
y_clamped = max(0, min(y, current_display_height - 1))
# --- Action 1: Queue raw coords for geo calculation (original queue) ---
logging.debug(
f"{log_prefix_cb} Putting clamped coords ({x_clamped}, {y_clamped}) onto mouse_queue for geo calc."
)
put_queue(
self.app.mouse_queue,
(x_clamped, y_clamped),
queue_name="mouse",
app_instance=self.app,
)
# --- >>> START OF NEW ACTION <<< ---
# --- Action 2: Queue pixel coords to update marker state (tkinter queue) ---
click_command = "SAR_CLICK_UPDATE"
click_payload = (x_clamped, y_clamped)
logging.debug(
f"{log_prefix_cb} Putting command '{click_command}' payload {click_payload} onto tkinter_queue for marker state."
)
put_queue(
queue_obj=self.app.tkinter_queue,
item=(click_command, click_payload),
queue_name="tkinter",
app_instance=self.app,
)
# --- >>> END OF NEW ACTION <<< ---
except Exception as e:
logging.exception(f"{log_prefix_cb} Error in SAR mouse callback: {e}")
def update_sar_position(self, x, y):
"""Updates the stored position and attempts to move the SAR window."""
# DEBUG for position update request
logging.debug(f"{self.log_prefix} Updating SAR window position to ({x}, {y}).")
self.sar_x = x
self.sar_y = y
if self.sar_window_initialized:
try:
cv2.moveWindow(self.sar_window_name, self.sar_x, self.sar_y)
# INFO for successful move
logging.info(
f"{self.log_prefix} Moved SAR window '{self.sar_window_name}' to ({x}, {y})"
)
except cv2.error as e:
# WARNING if move fails
logging.warning(
f"{self.log_prefix} Could not move SAR window '{self.sar_window_name}': {e}"
)
else:
logging.debug(
f"{self.log_prefix} SAR window not initialized, cannot move yet."
)
def update_mfd_position(self, x, y):
"""Updates the stored position and attempts to move the MFD window."""
# DEBUG for position update request
logging.debug(f"{self.log_prefix} Updating MFD window position to ({x}, {y}).")
self.mfd_x = x
self.mfd_y = y
if self.mfd_window_initialized:
try:
cv2.moveWindow(self.mfd_window_name, self.mfd_x, self.mfd_y)
# INFO for successful move
logging.info(
f"{self.log_prefix} Moved MFD window '{self.mfd_window_name}' to ({x}, {y})"
)
except cv2.error as e:
# WARNING if move fails
logging.warning(
f"{self.log_prefix} Could not move MFD window '{self.mfd_window_name}': {e}"
)
else:
logging.debug(
f"{self.log_prefix} MFD window not initialized, cannot move yet."
)
def destroy_windows(self):
"""Explicitly destroys the managed OpenCV windows during application shutdown."""
# INFO for start of significant cleanup action
logging.info(
f"{self.log_prefix} Attempting to destroy managed OpenCV windows (MFD, SAR)..."
)
destroyed_count = 0
# Attempt to destroy SAR window
# Check name existence as fallback if init flag is somehow false
if self.sar_window_initialized or hasattr(self, "sar_window_name"):
try:
# DEBUG for specific window destruction
logging.debug(
f"{self.log_prefix} Destroying OpenCV window: '{self.sar_window_name}'"
)
cv2.destroyWindow(self.sar_window_name)
destroyed_count += 1
self.sar_window_initialized = False # Reset flags
self.sar_mouse_callback_set = False
except cv2.error as e:
# WARNING for known error if window already closed
logging.warning(
f"{self.log_prefix} Ignoring OpenCV error destroying window '{self.sar_window_name}' (may already be closed): {e}"
)
except Exception as e:
# Keep EXCEPTION for unexpected errors
logging.exception(
f"{self.log_prefix} Unexpected error destroying window '{self.sar_window_name}': {e}"
)
# Attempt to destroy MFD window
if self.mfd_window_initialized or hasattr(self, "mfd_window_name"):
try:
# DEBUG for specific window destruction
logging.debug(
f"{self.log_prefix} Destroying OpenCV window: '{self.mfd_window_name}'"
)
cv2.destroyWindow(self.mfd_window_name)
destroyed_count += 1
self.mfd_window_initialized = False # Reset flag
except cv2.error as e:
# WARNING for known error
logging.warning(
f"{self.log_prefix} Ignoring OpenCV error destroying window '{self.mfd_window_name}' (may already be closed): {e}"
)
except Exception as e:
# Keep EXCEPTION for unexpected errors
logging.exception(
f"{self.log_prefix} Unexpected error destroying window '{self.mfd_window_name}': {e}"
)
# INFO for completion of cleanup action
logging.info(
f"{self.log_prefix} Finished destroying managed OpenCV windows (attempted {destroyed_count})."
)