# --- 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): # ... (controllo evento LBUTTONDOWN e clamping come prima) ... log_prefix_cb = f"{self._log_prefix} MouseClick" if event == cv2.EVENT_LBUTTONDOWN: logging.debug( f"{log_prefix_cb} Map Window Left Click detected at ({x}, {y})" ) try: current_display_height, current_display_width = ( self._last_displayed_shape ) 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 pixel coords for geo calculation (original command) --- geo_command = "MAP_MOUSE_COORDS" geo_payload = (x_clamped, y_clamped) logging.debug( f"{log_prefix_cb} Putting command '{geo_command}' payload {geo_payload} onto tkinter_queue for geo calc." ) put_queue( queue_obj=self.app.tkinter_queue, item=(geo_command, geo_payload), queue_name="tkinter", app_instance=self.app, ) # --- >>> START OF NEW ACTION <<< --- # --- Action 2: Queue pixel coords to update marker state --- click_command = "MAP_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 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 ---