# geoelevation/map_viewer/map_display.py """ Manages the dedicated OpenCV window for displaying map tiles. This module handles the creation and updating of an OpenCV window, displaying map images, scaling them if necessary (though current default is autosize), and capturing mouse events within the map window. It converts pixel coordinates from clicks into geographic coordinates and vice-versa, relying on the 'mercantile' library for these Web Mercator projection calculations. """ # Standard library imports import logging from typing import Optional, Tuple, Any # 'Any' for app_facade type hint # Third-party imports try: import cv2 # OpenCV for windowing and drawing import numpy as np CV2_NUMPY_AVAILABLE_DISPLAY = True except ImportError: cv2 = None # type: ignore np = None # type: ignore CV2_NUMPY_AVAILABLE_DISPLAY = False logging.error("MapDisplay: OpenCV or NumPy not found. Map display cannot function.") try: from PIL import Image ImageType = Image.Image # type: ignore PIL_AVAILABLE_DISPLAY = True except ImportError: Image = None # type: ignore ImageType = None # type: ignore PIL_AVAILABLE_DISPLAY = False logging.warning("MapDisplay: Pillow (PIL) not found. Image conversion from PIL might fail.") try: import mercantile # For Web Mercator tile calculations and coordinate conversions MERCANTILE_AVAILABLE_DISPLAY = True except ImportError: mercantile = None # type: ignore MERCANTILE_AVAILABLE_DISPLAY = False logging.error("MapDisplay: 'mercantile' library not found. Coordinate conversions will fail.") # Module-level logger logger = logging.getLogger(__name__) # Default window properties (can be overridden or extended) DEFAULT_WINDOW_X_POS = 150 DEFAULT_WINDOW_Y_POS = 150 class MapDisplayWindow: """ Manages an OpenCV window for displaying map images and handling mouse interactions. """ def __init__( self, app_facade: Any, # Typically an instance of GeoElevationMapViewer window_name: str = "GeoElevation Map View", initial_x_pos: int = DEFAULT_WINDOW_X_POS, initial_y_pos: int = DEFAULT_WINDOW_Y_POS ) -> None: """ Initializes the MapDisplayWindow manager. Args: app_facade: An object that has a 'handle_map_mouse_click(x, y)' method. This is called when the map is clicked. window_name: The name for the OpenCV window. initial_x_pos: Initial X screen position for the window. initial_y_pos: Initial Y screen position for the window. """ logger.info(f"Initializing MapDisplayWindow: '{window_name}'") if not CV2_NUMPY_AVAILABLE_DISPLAY: raise ImportError("OpenCV and/or NumPy are not available for MapDisplayWindow.") self.app_facade_handler: Any = app_facade self.cv_window_name: str = window_name self.window_initial_x: int = initial_x_pos self.window_initial_y: int = initial_y_pos self.is_window_initialized: bool = False self.is_mouse_callback_set: bool = False # Stores the shape (height, width) of the last image successfully displayed self._last_displayed_image_shape: Tuple[int, int] = (0, 0) def show_map(self, map_pil_image: Optional[ImageType]) -> None: """ Displays the provided map image (PIL format) in the OpenCV window. The window is autosized to the image content. Handles window creation and recreation if the image size changes. Args: map_pil_image: The map image (PIL.Image) to display. If None, a placeholder image is shown. """ bgr_image_to_display: Optional[np.ndarray] = None # type: ignore if map_pil_image is None: logger.warning("Received None PIL image. Generating a placeholder.") bgr_image_to_display = self._create_placeholder_bgr_numpy() elif PIL_AVAILABLE_DISPLAY and isinstance(map_pil_image, Image.Image): # type: ignore logger.debug(f"Converting PIL Image (Size: {map_pil_image.size}, Mode: {map_pil_image.mode}) to BGR.") bgr_image_to_display = self._convert_pil_image_to_bgr_numpy(map_pil_image) else: logger.error(f"Received unexpected image type: {type(map_pil_image)}. Using placeholder.") bgr_image_to_display = self._create_placeholder_bgr_numpy() if bgr_image_to_display is None: logger.error("Failed to obtain a BGR image for display. Using minimal fallback.") # Create a minimal black square as an ultimate fallback bgr_image_to_display = np.zeros((256, 256, 3), dtype=np.uint8) # type: ignore current_image_height, current_image_width = bgr_image_to_display.shape[:2] logger.debug(f"Final image shape for display: {current_image_width}x{current_image_height}") # Recreate window if initialized and image size has changed (due to WINDOW_AUTOSIZE) if self.is_window_initialized and \ (current_image_height, current_image_width) != self._last_displayed_image_shape: logger.info( f"Image size changed ({self._last_displayed_image_shape} -> " f"{(current_image_height, current_image_width)}). Recreating window." ) try: cv2.destroyWindow(self.cv_window_name) # type: ignore cv2.waitKey(5) # Brief pause to allow window manager to process destroy self.is_window_initialized = False self.is_mouse_callback_set = False # Callback needs to be reset on new window except cv2.error as e_destroy: # type: ignore logger.warning(f"Error destroying window before recreation: {e_destroy}") # Force flags to false to ensure recreation attempt self.is_window_initialized = False self.is_mouse_callback_set = False # Ensure window exists and mouse callback is set if not self.is_window_initialized: logger.debug(f"Creating/moving OpenCV window: '{self.cv_window_name}'") cv2.namedWindow(self.cv_window_name, cv2.WINDOW_AUTOSIZE) # type: ignore try: cv2.moveWindow(self.cv_window_name, self.window_initial_x, self.window_initial_y) # type: ignore except cv2.error as e_move: # type: ignore logger.warning(f"Could not move window '{self.cv_window_name}': {e_move}") self.is_window_initialized = True logger.info(f"Window '{self.cv_window_name}' (AUTOSIZE) ready.") if self.is_window_initialized and not self.is_mouse_callback_set: try: cv2.setMouseCallback(self.cv_window_name, self._opencv_mouse_callback, param=self.app_facade_handler) # type: ignore self.is_mouse_callback_set = True logger.info(f"Mouse callback successfully set for '{self.cv_window_name}'.") except cv2.error as e_callback: # type: ignore logger.error(f"Failed to set mouse callback for '{self.cv_window_name}': {e_callback}") # Display the image try: cv2.imshow(self.cv_window_name, bgr_image_to_display) # type: ignore self._last_displayed_image_shape = (current_image_height, current_image_width) # cv2.waitKey(1) is crucial for OpenCV to process GUI events and display image # The main event loop for this window will be handled by the parent process/thread # that calls this show_map in its own loop (e.g., the map viewer process target). except cv2.error as e_imshow: # type: ignore # Handle cases where the window might have been closed externally if "NULL window" in str(e_imshow).lower() or \ "invalid window" in str(e_imshow).lower() or \ "checkView" in str(e_imshow).lower(): # Common OpenCV error strings logger.warning(f"OpenCV window '{self.cv_window_name}' seems closed or invalid during imshow.") self.is_window_initialized = False self.is_mouse_callback_set = False self._last_displayed_image_shape = (0, 0) else: logger.exception(f"OpenCV error during map display: {e_imshow}") except Exception as e_disp: logger.exception(f"Unexpected error displaying map image: {e_disp}") def _opencv_mouse_callback(self, event: int, x: int, y: int, flags: int, param_app_facade: Any) -> None: """ Internal OpenCV mouse callback. Clamps coordinates and calls the app_facade's handler. 'param_app_facade' is the GeoElevationMapViewer instance. """ if event == cv2.EVENT_LBUTTONDOWN: # type: ignore current_height, current_width = self._last_displayed_image_shape if current_width <= 0 or current_height <= 0: logger.warning("Mouse click on map with no valid displayed image dimensions.") return # Cannot process click without valid image dimensions # Clamp coordinates to the bounds of the currently displayed image x_clamped = max(0, min(x, current_width - 1)) y_clamped = max(0, min(y, current_height - 1)) logger.debug( f"Map Window Left Click (OpenCV): Original ({x},{y}), Clamped ({x_clamped},{y_clamped})" ) if param_app_facade and hasattr(param_app_facade, 'handle_map_mouse_click'): # Call the method on the GeoElevationMapViewer instance param_app_facade.handle_map_mouse_click(x_clamped, y_clamped) else: logger.error( "app_facade not correctly passed to OpenCV mouse callback or lacks 'handle_map_mouse_click'." ) # --- Coordinate Conversion Utilities --- # These methods operate on the context of the *currently displayed map*. def pixel_to_geo_on_current_map( self, pixel_x: int, pixel_y: int, current_map_bounds_deg: Tuple[float, float, float, float], # west, south, east, north current_map_pixel_shape: Tuple[int, int], # height, width current_map_zoom: int # Zoom level of the current map ) -> Optional[Tuple[float, float]]: # Returns (latitude, longitude) """ Converts pixel coordinates (from mouse click) on the currently displayed map to geographic WGS84 coordinates (latitude, longitude). """ if not MERCANTILE_AVAILABLE_DISPLAY: logger.error("mercantile library not available for pixel_to_geo conversion.") return None if not (current_map_bounds_deg and current_map_pixel_shape and current_map_zoom is not None): logger.warning("Cannot convert pixel to geo: Current map context is incomplete.") return None map_height, map_width = current_map_pixel_shape map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_bounds_deg if map_width <= 0 or map_height <= 0: logger.error("Cannot convert pixel to geo: Invalid map dimensions.") return None try: # Get Web Mercator coordinates of the map's top-left and bottom-right corners # mercantile.xy expects (longitude, latitude) map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore # Total width and height of the map in Web Mercator units total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) # Y decreases downwards in pixel, increases upwards in Mercator if total_map_width_merc <= 0 or total_map_height_merc <= 0: logger.error("Cannot convert pixel to geo: Invalid Mercator dimensions for map.") return None # Calculate relative position of the click within the map (0.0 to 1.0) relative_x_on_map = pixel_x / map_width relative_y_on_map = pixel_y / map_height # Calculate the Web Mercator coordinate of the clicked point # For Y, Mercator Y increases northwards, pixel Y increases downwards. # map_ul_merc_y is the "top" (northernmost) Mercator Y. clicked_point_merc_x = map_ul_merc_x + (relative_x_on_map * total_map_width_merc) clicked_point_merc_y = map_ul_merc_y - (relative_y_on_map * total_map_height_merc) # Convert the clicked Web Mercator coordinate back to geographic (lon, lat) # mercantile.lnglat expects (merc_x, merc_y) clicked_lon, clicked_lat = mercantile.lnglat(clicked_point_merc_x, clicked_point_merc_y) # type: ignore return (clicked_lat, clicked_lon) except Exception as e_conv: logger.exception(f"Error during pixel_to_geo conversion: {e_conv}") return None def geo_to_pixel_on_current_map( self, latitude: float, longitude: float, current_map_bounds_deg: Tuple[float, float, float, float], # west, south, east, north current_map_pixel_shape: Tuple[int, int], # height, width current_map_zoom: int # Zoom level of the current map ) -> Optional[Tuple[int, int]]: # Returns (pixel_x, pixel_y) """ Converts geographic WGS84 coordinates (latitude, longitude) to pixel coordinates (x, y) on the currently displayed map. """ if not MERCANTILE_AVAILABLE_DISPLAY: logger.error("mercantile library not available for geo_to_pixel conversion.") return None if not (current_map_bounds_deg and current_map_pixel_shape and current_map_zoom is not None): logger.warning("Cannot convert geo to pixel: Current map context is incomplete.") return None map_height, map_width = current_map_pixel_shape map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_bounds_deg if map_width <= 0 or map_height <= 0: logger.error("Cannot convert geo to pixel: Invalid map dimensions.") return None try: map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) if total_map_width_merc <= 0 or total_map_height_merc <= 0: logger.error("Cannot convert geo to pixel: Invalid Mercator dimensions for map.") return None # Convert the target geographic point to Web Mercator coordinates target_merc_x, target_merc_y = mercantile.xy(longitude, latitude) # type: ignore # Calculate relative position of the target point within the map's Mercator bounds # (relative_merc_x from 0 to 1 if target_merc_x is within map_ul_merc_x and map_lr_merc_x) relative_merc_x = (target_merc_x - map_ul_merc_x) / total_map_width_merc # For Y, map_ul_merc_y is "higher" (more North) relative_merc_y = (map_ul_merc_y - target_merc_y) / total_map_height_merc # Convert relative Mercator positions to pixel coordinates pixel_x = int(round(relative_merc_x * map_width)) pixel_y = int(round(relative_merc_y * map_height)) # Clamp to image boundaries (though usually points of interest should be within) pixel_x_clamped = max(0, min(pixel_x, map_width - 1)) pixel_y_clamped = max(0, min(pixel_y, map_height - 1)) return (pixel_x_clamped, pixel_y_clamped) except Exception as e_conv_geo: logger.exception(f"Error during geo_to_pixel conversion: {e_conv_geo}") return None def _create_placeholder_bgr_numpy(self) -> np.ndarray: # type: ignore """Creates a simple BGR NumPy array as a placeholder image.""" # Default size and color if other components fail placeholder_height = 256 placeholder_width = 256 # Light grey color in BGR order placeholder_bgr_color = (200, 200, 200) if np: # type: ignore return np.full((placeholder_height, placeholder_width, 3), placeholder_bgr_color, dtype=np.uint8) # type: ignore else: # Should not happen if CV2_NUMPY_AVAILABLE_DISPLAY is true return None # type: ignore def _convert_pil_image_to_bgr_numpy(self, pil_img: ImageType) -> Optional[np.ndarray]: # type: ignore """Converts a PIL Image object to a NumPy BGR array for OpenCV.""" if not (PIL_AVAILABLE_DISPLAY and CV2_NUMPY_AVAILABLE_DISPLAY and pil_img): return None try: numpy_image = np.array(pil_img) # type: ignore if numpy_image.ndim == 2: # Grayscale image return cv2.cvtColor(numpy_image, cv2.COLOR_GRAY2BGR) # type: ignore elif numpy_image.ndim == 3: if numpy_image.shape[2] == 3: # RGB image return cv2.cvtColor(numpy_image, cv2.COLOR_RGB2BGR) # type: ignore elif numpy_image.shape[2] == 4: # RGBA image return cv2.cvtColor(numpy_image, cv2.COLOR_RGBA2BGR) # type: ignore logger.warning(f"Unsupported NumPy image shape after PIL conversion: {numpy_image.shape}") return None except Exception as e_conv_pil: logger.exception(f"Error converting PIL image to BGR NumPy array: {e_conv_pil}") return None def is_window_alive(self) -> bool: """ Checks if the OpenCV window associated with this instance is likely still open and initialized. """ if not self.is_window_initialized or not CV2_NUMPY_AVAILABLE_DISPLAY: return False try: # cv2.getWindowProperty returns >= 0 if property can be read (window exists) # and specifically >= 1.0 if WND_PROP_VISIBLE is true. # If window doesn't exist, it returns -1.0. window_visibility_property = cv2.getWindowProperty(self.cv_window_name, cv2.WND_PROP_VISIBLE) # type: ignore if window_visibility_property >= 1.0: # Window exists and is visible return True else: # Window might be closed, hidden, or property not available logger.debug( f"Window '{self.cv_window_name}' not reported as visible (prop_val={window_visibility_property}). Assuming closed/invalid." ) self.is_window_initialized = False # Update state if not visible self.is_mouse_callback_set = False return False except cv2.error: # type: ignore # OpenCV error likely means the window no longer exists logger.debug(f"OpenCV error accessing window property for '{self.cv_window_name}'. Assuming closed.") self.is_window_initialized = False self.is_mouse_callback_set = False return False except Exception: # Catch any other unexpected errors logger.exception(f"Unexpected error checking if window '{self.cv_window_name}' is alive.") self.is_window_initialized = False self.is_mouse_callback_set = False return False def destroy_window(self) -> None: """Explicitly destroys the managed OpenCV window.""" logger.info(f"Attempting to destroy OpenCV window: '{self.cv_window_name}'") if self.is_window_initialized and CV2_NUMPY_AVAILABLE_DISPLAY: try: cv2.destroyWindow(self.cv_window_name) # type: ignore cv2.waitKey(5) # Allow OpenCV to process destroy event logger.info(f"Window '{self.cv_window_name}' destroyed successfully.") except cv2.error as e_destroy_cv: # type: ignore logger.warning( f"Ignoring OpenCV error during destroyWindow '{self.cv_window_name}' (may already be closed): {e_destroy_cv}" ) except Exception as e_destroy_generic: logger.exception( f"Unexpected error destroying window '{self.cv_window_name}': {e_destroy_generic}" ) else: logger.debug( f"Window '{self.cv_window_name}' was not marked as initialized or OpenCV not available. No explicit destroy." ) # Always reset flags after attempting destroy self.is_window_initialized = False self.is_mouse_callback_set = False self._last_displayed_image_shape = (0, 0)