# --- START OF FILE map_display.py --- # map_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 dedicated OpenCV window for displaying the map overlay. Applies scaling based on AppState factor before displaying in a fixed-size (autosized) window and handles mouse events on the map window. """ # Standard library imports import logging import math import time # Needed for mouse event rate limiting from typing import Optional, TYPE_CHECKING, Tuple # Third-party imports import cv2 import numpy as np # Try to import PIL for type hinting and image conversion try: from PIL import Image ImageType = Image.Image except ImportError: Image = None # type: ignore ImageType = None # type: ignore # Local application imports import config from utils import put_queue # Needed for sending mouse coordinates to the app queue # Type hinting for App reference if TYPE_CHECKING: # Import the specific main application class name from ControlPanel import ControlPanelApp class MapDisplayWindow: """ Manages the OpenCV window used to display the map. Applies scaling based on AppState factor before displaying in a fixed-size window. Handles mouse events within the map window. """ MAX_DISPLAY_WIDTH = config.MAX_MAP_DISPLAY_WIDTH MAX_DISPLAY_HEIGHT = config.MAX_MAP_DISPLAY_HEIGHT def __init__( self, app: "ControlPanelApp", # Accept the main application instance window_name: str = "Map Overlay", x_pos: int = 100, y_pos: int = 100, ): """ Initializes the MapDisplayWindow manager. Args: app (ControlPanelApp): Reference to the main App instance (for AppState and queue access). window_name (str): The name for the OpenCV window. x_pos (int): Initial X position for the window. y_pos (int): Initial Y position for the window. """ self._log_prefix = "[MapDisplay]" if Image is None: logging.critical( f"{self._log_prefix} 'Pillow' library not found. Map display will likely fail." ) self.app = app # Store reference to the main application instance self.window_name = window_name self.x_pos = x_pos self.y_pos = y_pos self.window_initialized = False # --- >>> START OF NEW ATTRIBUTE <<< --- # Flag to track if the mouse callback has been successfully set self.map_mouse_callback_set = False # --- >>> END OF NEW ATTRIBUTE <<< --- # Store the shape of the image actually sent to imshow for coordinate clamping self._last_displayed_shape: Tuple[int, int] = (0, 0) # (height, width) logging.debug( f"{self._log_prefix} Initializing window manager for '{self.window_name}'." ) def show_map(self, map_image_pil: Optional[ImageType]): """ Displays the map image. Converts PIL to BGR, applies scaling based on AppState factor, shows in a fixed-size (autosized) OpenCV window, and ensures the mouse callback is set. Recreates the window if the scaled image size changes. Args: map_image_pil (Optional[ImageType]): The map image (PIL format) to display. If None, shows a placeholder/error image. """ log_prefix = f"{self._log_prefix} ShowMap" # Shortened log prefix # --- 1. Prepare Base Image (Placeholder or Convert PIL) --- map_to_display_bgr = None if map_image_pil is None: logging.warning( f"{log_prefix} Received None image payload. Generating placeholder." ) map_to_display_bgr = self._create_placeholder_image() elif Image is not None and isinstance(map_image_pil, Image.Image): logging.debug( f"{log_prefix} Converting PIL Image payload (Size: {map_image_pil.size}, Mode: {map_image_pil.mode})..." ) map_to_display_bgr = self._convert_pil_to_bgr(map_image_pil) else: logging.error( f"{log_prefix} Received unexpected payload type: {type(map_image_pil)}. Using fallback." ) map_to_display_bgr = self._create_placeholder_image() # Final safety check if conversion/placeholder creation failed if map_to_display_bgr is None: logging.error( f"{log_prefix} map_to_display_bgr is None before scaling. Using minimal fallback." ) map_to_display_bgr = np.full((256, 256, 3), 60, dtype=np.uint8) final_image_to_show = None try: # --- 2. Apply Scaling based on AppState Factor --- scale_factor = self.app.state.map_display_scale_factor img_h, img_w = map_to_display_bgr.shape[:2] logging.debug( f"{log_prefix} Base image size: {img_w}x{img_h}. Scale factor: {scale_factor:.4f}" ) if abs(scale_factor - 1.0) > 1e-6 and img_w > 0 and img_h > 0: target_display_w = max(1, int(round(img_w * scale_factor))) target_display_h = max(1, int(round(img_h * scale_factor))) if target_display_w != img_w or target_display_h != img_h: interpolation = ( cv2.INTER_LINEAR if scale_factor > 1.0 else cv2.INTER_AREA ) logging.debug( f"{log_prefix} Resizing image to {target_display_w}x{target_display_h}..." ) final_image_to_show = cv2.resize( map_to_display_bgr, (target_display_w, target_display_h), interpolation=interpolation, ) else: final_image_to_show = map_to_display_bgr else: final_image_to_show = map_to_display_bgr if final_image_to_show is None: logging.error( f"{log_prefix} Image is None after scaling attempt. Using base image." ) final_image_to_show = map_to_display_bgr # --- 3. Display using OpenCV (WINDOW_AUTOSIZE) --- final_shape = final_image_to_show.shape final_h, final_w = final_shape[:2] logging.debug( f"{log_prefix} Final image shape for display: {final_w}x{final_h}" ) # --- Window Recreation Logic --- if ( self.window_initialized and (final_h, final_w) != self._last_displayed_shape ): logging.info( f"{log_prefix} Image size changed ({self._last_displayed_shape} -> {(final_h, final_w)}). Recreating window..." ) try: cv2.destroyWindow(self.window_name) cv2.waitKey(5) # Brief wait self.window_initialized = False self.map_mouse_callback_set = False # Reset callback flag too logging.debug(f"{log_prefix} Destroyed existing window.") except cv2.error as e: logging.warning( f"{log_prefix} Error destroying window before recreate: {e}" ) self.window_initialized = False self.map_mouse_callback_set = False # --- Window Creation and Callback Setup --- if not self.window_initialized: logging.debug( f"{log_prefix} Ensuring window '{self.window_name}' exists (AUTOSIZE) and is positioned..." ) cv2.namedWindow(self.window_name, cv2.WINDOW_AUTOSIZE) try: cv2.moveWindow(self.window_name, self.x_pos, self.y_pos) self.window_initialized = True logging.info( f"{log_prefix} Window '{self.window_name}' (AUTOSIZE) created/moved to ({self.x_pos}, {self.y_pos})." ) # --- >>> START OF NEW CODE <<< --- # Attempt to set mouse callback immediately after creation/move if not self.map_mouse_callback_set: try: cv2.setMouseCallback( self.window_name, self._map_mouse_callback ) self.map_mouse_callback_set = True logging.info( f"{log_prefix} Mouse callback set for '{self.window_name}'." ) except cv2.error as cb_e: logging.error( f"{log_prefix} Failed to set mouse callback for '{self.window_name}' on creation: {cb_e}" ) # --- >>> END OF NEW CODE <<< --- except cv2.error as move_e: logging.warning( f"{log_prefix} Could not move '{self.window_name}' window after creation: {move_e}." ) self.window_initialized = True # Assume window created # --- >>> START OF NEW CODE <<< --- # Still try to set callback even if move failed if not self.map_mouse_callback_set: try: cv2.setMouseCallback( self.window_name, self._map_mouse_callback ) self.map_mouse_callback_set = True logging.info( f"{log_prefix} Mouse callback set for '{self.window_name}' (after move failed)." ) except cv2.error as cb_e: logging.error( f"{log_prefix} Failed to set mouse callback for '{self.window_name}' after move failed: {cb_e}" ) # --- >>> END OF NEW CODE <<< --- # --- Display Image --- cv2.imshow(self.window_name, final_image_to_show) self._last_displayed_shape = ( final_h, final_w, ) # Store the shape we just displayed logging.debug( f"{log_prefix} Displayed final scaled image in window '{self.window_name}'." ) # --- >>> START OF NEW CODE <<< --- # Ensure callback is set if window already existed but callback wasn't set if self.window_initialized and not self.map_mouse_callback_set: try: cv2.setMouseCallback(self.window_name, self._map_mouse_callback) self.map_mouse_callback_set = True logging.info( f"{log_prefix} Mouse callback set for existing window '{self.window_name}'." ) except cv2.error as cb_e: logging.error( f"{log_prefix} Failed to set mouse callback for existing window '{self.window_name}': {cb_e}" ) # --- >>> END OF NEW CODE <<< --- cv2.waitKey(1) # Allow OpenCV to process events except cv2.error as e: if ( "NULL window" in str(e) or "invalid window" in str(e) or "checkView" in str(e) ): logging.warning( f"{log_prefix} OpenCV window '{self.window_name}' seems closed or invalid." ) self.window_initialized = False self.map_mouse_callback_set = False # Reset flag self._last_displayed_shape = (0, 0) else: logging.exception(f"{log_prefix} OpenCV error during map display: {e}") except Exception as e: logging.exception( f"{log_prefix} Unexpected error displaying map image: {e}" ) # Consider resetting flags on unexpected errors # self.window_initialized = False # self.map_mouse_callback_set = False # self._last_displayed_shape = (0, 0) def _create_placeholder_image(self) -> Optional[np.ndarray]: """Creates a BGR NumPy placeholder image.""" log_prefix = f"{self._log_prefix} Placeholder" if Image is None: logging.error( f"{log_prefix} Pillow not available, using minimal NumPy fallback." ) return np.full((256, 256, 3), 60, dtype=np.uint8) # Minimal fallback try: tile_size = ( self.app.map_integration_manager._map_service.tile_size if hasattr(self.app, "map_integration_manager") and self.app.map_integration_manager and hasattr(self.app.map_integration_manager, "_map_service") else 256 ) placeholder_size = (tile_size, tile_size) placeholder_color_rgb = getattr( config, "OFFLINE_MAP_PLACEHOLDER_COLOR", (200, 200, 200) ) if not ( isinstance(placeholder_color_rgb, tuple) and len(placeholder_color_rgb) == 3 ): placeholder_color_rgb = (200, 200, 200) placeholder_pil = Image.new( "RGB", placeholder_size, color=placeholder_color_rgb ) placeholder_np = np.array(placeholder_pil) return cv2.cvtColor(placeholder_np, cv2.COLOR_RGB2BGR) except Exception as ph_err: logging.exception(f"{log_prefix} Failed to create placeholder image:") return np.full((256, 256, 3), 60, dtype=np.uint8) # Minimal fallback def _convert_pil_to_bgr(self, img_pil: ImageType) -> Optional[np.ndarray]: """Converts a PIL Image to NumPy BGR format.""" log_prefix = f"{self._log_prefix} ConvertPIL" try: img_np = np.array(img_pil) if img_np.ndim == 2: # Grayscale return cv2.cvtColor(img_np, cv2.COLOR_GRAY2BGR) elif img_np.ndim == 3 and img_np.shape[2] == 3: # RGB return cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) elif img_np.ndim == 3 and img_np.shape[2] == 4: # RGBA return cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR) else: raise ValueError( f"Unsupported NumPy shape after PIL conversion: {img_np.shape}" ) except Exception as e: logging.exception(f"{log_prefix} Error converting PIL image to BGR:") return None # --- >>> START OF NEW MOUSE CALLBACK METHOD <<< --- def _map_mouse_callback(self, event, x, y, flags, param): """ OpenCV mouse callback for the Map window. Processes mouse movement, performs rate limiting (using AppState), clamps coordinates to the displayed map size, and puts raw (x, y) onto the application's tkinter_queue with a specific command identifier. """ log_prefix_cb = f"{self._log_prefix} MouseCB" # Process only mouse movement events if event == cv2.EVENT_MOUSEMOVE: # --- Rate Limiting (Using AppState specific timer) --- current_time = time.time() try: last_update_time = self.app.state.last_map_mouse_update_time if (current_time - last_update_time) < 0.05: # 50ms limit (20 Hz) return # Rate limited self.app.state.last_map_mouse_update_time = current_time except AttributeError: logging.warning( f"{log_prefix_cb} App state or 'last_map_mouse_update_time' missing." ) return # Don't proceed without rate limiting if state missing # --- Clamp coordinates and put on Tkinter queue --- try: # Use the stored shape of the *last displayed* image current_display_height, current_display_width = ( self._last_displayed_shape ) if current_display_width <= 0 or current_display_height <= 0: logging.warning( f"{log_prefix_cb} Invalid stored map display dimensions for clamping." ) return x_clamped = max(0, min(x, current_display_width - 1)) y_clamped = max(0, min(y, current_display_height - 1)) # Put coordinates onto the TKINTER queue with a specific command # This allows the main app thread to distinguish map mouse coords # from SAR mouse coords. command = "MAP_MOUSE_COORDS" payload = (x_clamped, y_clamped) logging.debug( f"{log_prefix_cb} Putting command '{command}' payload {payload} onto tkinter_queue." ) put_queue( queue_obj=self.app.tkinter_queue, item=(command, payload), queue_name="tkinter", # Still the tkinter queue app_instance=self.app, ) except AttributeError as ae: logging.warning( f"{log_prefix_cb} App state attributes not available: {ae}" ) except Exception as e: logging.exception(f"{log_prefix_cb} Error in map mouse callback:") # --- >>> END OF NEW MOUSE CALLBACK METHOD <<< --- def destroy_window(self): """Explicitly destroys the managed OpenCV window.""" log_prefix = f"{self._log_prefix} Destroy" logging.info(f"{log_prefix} Attempting to destroy window: '{self.window_name}'") if self.window_initialized or hasattr(self, "window_name"): try: cv2.destroyWindow(self.window_name) self.window_initialized = False self.map_mouse_callback_set = False # Reset flag self._last_displayed_shape = (0, 0) logging.info( f"{log_prefix} Window '{self.window_name}' destroyed successfully." ) except cv2.error as e: logging.warning( f"{log_prefix} Ignoring error destroying window '{self.window_name}' (may already be closed): {e}" ) except Exception as e: logging.exception( f"{log_prefix} Unexpected error destroying window '{self.window_name}': {e}" ) else: logging.debug( f"{log_prefix} Window '{self.window_name}' was not initialized or name unknown." ) # --- END OF FILE map_display.py ---