# 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. It applies a display scale factor (provided by its app_facade) to the incoming map image before rendering. It also captures mouse events within the map window and performs conversions between pixel coordinates (on the displayed, scaled image) and geographic coordinates, relying on the 'mercantile' library for Web Mercator projections. """ # 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_LIBS_AVAILABLE_DISPLAY = True except ImportError: cv2 = None # type: ignore np = None # type: ignore CV2_NUMPY_LIBS_AVAILABLE_DISPLAY = False # Logging might not be set up if this is imported very early by a child process print("ERROR: MapDisplay - OpenCV or NumPy not found. Map display cannot function.") try: from PIL import Image ImageType = Image.Image # type: ignore PIL_LIB_AVAILABLE_DISPLAY = True except ImportError: Image = None # type: ignore ImageType = None # type: ignore PIL_LIB_AVAILABLE_DISPLAY = False print("WARNING: MapDisplay - Pillow (PIL) not found. Image conversion from PIL might fail.") try: import mercantile # For Web Mercator tile calculations and coordinate conversions MERCANTILE_LIB_AVAILABLE_DISPLAY = True except ImportError: mercantile = None # type: ignore MERCANTILE_LIB_AVAILABLE_DISPLAY = False print("ERROR: MapDisplay - 'mercantile' library not found. Coordinate conversions will fail.") # Module-level logger logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.map_display' # Default window properties (can be overridden or extended if needed) DEFAULT_CV_WINDOW_X_POSITION = 150 DEFAULT_CV_WINDOW_Y_POSITION = 150 class MapDisplayWindow: """ Manages an OpenCV window for displaying map images and handling mouse interactions. The displayed image is scaled according to a factor provided by the app_facade. """ def __init__( self, app_facade: Any, # Instance of GeoElevationMapViewer (or similar providing scale and click handler) window_name_str: str = "GeoElevation - Interactive Map", initial_screen_x_pos: int = DEFAULT_CV_WINDOW_X_POSITION, initial_screen_y_pos: int = DEFAULT_CV_WINDOW_Y_POSITION ) -> None: """ Initializes the MapDisplayWindow manager. Args: app_facade: An object that has a 'handle_map_mouse_click(x, y)' method and an attribute 'current_display_scale_factor'. window_name_str: The name for the OpenCV window. initial_screen_x_pos: Initial X screen position for the window. initial_screen_y_pos: Initial Y screen position for the window. """ logger.info(f"Initializing MapDisplayWindow with name: '{window_name_str}'") if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: # This error should ideally prevent the application from reaching this point # if map functionality is critical and relies on this module. raise ImportError("OpenCV and/or NumPy are not available for MapDisplayWindow operation.") self.app_facade_handler: Any = app_facade # Facade to access scale and report clicks self.opencv_window_name: str = window_name_str self.window_start_x_position: int = initial_screen_x_pos self.window_start_y_position: int = initial_screen_y_pos self.is_opencv_window_initialized: bool = False self.is_opencv_mouse_callback_set: bool = False # Stores the shape (height, width) of the *scaled image actually displayed* by imshow self._last_displayed_scaled_image_shape: Tuple[int, int] = (0, 0) def show_map(self, map_pil_image_input: Optional[ImageType]) -> None: """ Displays the provided map image (PIL format) in the OpenCV window. The image is first converted to BGR, then scaled using the factor from `app_facade.current_display_scale_factor`, and then displayed. The OpenCV window autosizes to the scaled image. Args: map_pil_image_input: The map image (PIL.Image) to display. If None, a placeholder image is shown. """ if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: logger.error("Cannot show map: OpenCV/NumPy not available.") return bgr_image_unscaled: Optional[np.ndarray] # type: ignore if map_pil_image_input is None: logger.warning("Received None PIL image for display. Generating a placeholder.") bgr_image_unscaled = self._create_placeholder_bgr_numpy_array() elif PIL_LIB_AVAILABLE_DISPLAY and isinstance(map_pil_image_input, Image.Image): # type: ignore logger.debug( f"Converting input PIL Image (Size: {map_pil_image_input.size}, Mode: {map_pil_image_input.mode}) to BGR." ) bgr_image_unscaled = self._convert_pil_image_to_bgr_numpy_array(map_pil_image_input) else: logger.error( f"Received unexpected image type for display: {type(map_pil_image_input)}. Using placeholder." ) bgr_image_unscaled = self._create_placeholder_bgr_numpy_array() if bgr_image_unscaled is None: # Fallback if conversion or placeholder failed logger.error("Failed to obtain a BGR image (unscaled) for display. Using minimal black square.") bgr_image_unscaled = np.zeros((256, 256, 3), dtype=np.uint8) # type: ignore # --- Apply Display Scaling --- scaled_bgr_image_for_display: np.ndarray = bgr_image_unscaled # type: ignore try: display_scale = 1.0 # Default scale if not found on facade if hasattr(self.app_facade_handler, 'current_display_scale_factor'): display_scale = float(self.app_facade_handler.current_display_scale_factor) if display_scale <= 0: # Prevent invalid scale logger.warning(f"Invalid scale factor {display_scale} from facade. Using 1.0.") display_scale = 1.0 else: logger.warning("Display scale factor not found on app_facade. Defaulting to 1.0.") unscaled_h, unscaled_w = bgr_image_unscaled.shape[:2] logger.debug( f"Unscaled BGR image size: {unscaled_w}x{unscaled_h}. Applying display scale: {display_scale:.3f}" ) if abs(display_scale - 1.0) > 1e-6 and unscaled_w > 0 and unscaled_h > 0: target_w = max(1, int(round(unscaled_w * display_scale))) target_h = max(1, int(round(unscaled_h * display_scale))) interpolation_method = cv2.INTER_LINEAR if display_scale > 1.0 else cv2.INTER_AREA # type: ignore logger.debug(f"Resizing image from {unscaled_w}x{unscaled_h} to {target_w}x{target_h}") scaled_bgr_image_for_display = cv2.resize( # type: ignore bgr_image_unscaled, (target_w, target_h), interpolation=interpolation_method ) # else: no scaling needed, scaled_bgr_image_for_display remains bgr_image_unscaled except Exception as e_scaling_img: logger.exception(f"Error during image scaling: {e_scaling_img}. Displaying unscaled image.") scaled_bgr_image_for_display = bgr_image_unscaled # Fallback to unscaled # --- End Display Scaling --- current_disp_h, current_disp_w = scaled_bgr_image_for_display.shape[:2] logger.debug(f"Final scaled image shape for display: {current_disp_w}x{current_disp_h}") # Recreate OpenCV window if its initialized state suggests it's needed, # or if the size of the (scaled) image to be displayed has changed. if self.is_opencv_window_initialized and \ (current_disp_h, current_disp_w) != self._last_displayed_scaled_image_shape: logger.info( f"Scaled image size changed ({self._last_displayed_scaled_image_shape} -> " f"{(current_disp_h, current_disp_w)}). Recreating OpenCV window." ) try: cv2.destroyWindow(self.opencv_window_name) # type: ignore cv2.waitKey(5) # Allow OS to process window destruction self.is_opencv_window_initialized = False self.is_opencv_mouse_callback_set = False # Callback must be reset except cv2.error as e_cv_destroy: # type: ignore logger.warning(f"Error destroying OpenCV window before recreation: {e_cv_destroy}") self.is_opencv_window_initialized = False # Force recreation attempt self.is_opencv_mouse_callback_set = False # Ensure OpenCV window exists and mouse callback is properly set if not self.is_opencv_window_initialized: logger.debug(f"Creating/moving OpenCV window: '{self.opencv_window_name}' (AUTOSIZE)") cv2.namedWindow(self.opencv_window_name, cv2.WINDOW_AUTOSIZE) # type: ignore try: cv2.moveWindow(self.opencv_window_name, self.window_start_x_position, self.window_start_y_position) # type: ignore except cv2.error as e_cv_move: # type: ignore logger.warning(f"Could not move OpenCV window '{self.opencv_window_name}': {e_cv_move}") self.is_opencv_window_initialized = True # Assume created even if move failed logger.info(f"OpenCV window '{self.opencv_window_name}' (AUTOSIZE) is ready.") if self.is_opencv_window_initialized and not self.is_opencv_mouse_callback_set: try: cv2.setMouseCallback(self.opencv_window_name, self._opencv_mouse_callback, param=self.app_facade_handler) # type: ignore self.is_opencv_mouse_callback_set = True logger.info(f"Mouse callback successfully set for '{self.opencv_window_name}'.") except cv2.error as e_cv_callback: # type: ignore logger.error(f"Failed to set mouse callback for '{self.opencv_window_name}': {e_cv_callback}") # Display the final (scaled) image try: cv2.imshow(self.opencv_window_name, scaled_bgr_image_for_display) # type: ignore # Store the shape of the image that was actually displayed (scaled) self._last_displayed_scaled_image_shape = (current_disp_h, current_disp_w) # cv2.waitKey(1) is important for OpenCV to process GUI events. # The main event loop for this window is expected to be handled by the # calling process (e.g., the run_map_viewer_process_target loop). except cv2.error as e_cv_imshow: # type: ignore if "NULL window" in str(e_cv_imshow).lower() or \ "invalid window" in str(e_cv_imshow).lower() or \ "checkView" in str(e_cv_imshow).lower(): logger.warning(f"OpenCV window '{self.opencv_window_name}' seems closed or invalid during imshow operation.") self.is_opencv_window_initialized = False self.is_opencv_mouse_callback_set = False self._last_displayed_scaled_image_shape = (0, 0) else: # Re-raise other OpenCV errors if not related to window state logger.exception(f"OpenCV error during map display (imshow): {e_cv_imshow}") except Exception as e_disp_final: logger.exception(f"Unexpected error displaying final map image: {e_disp_final}") def _opencv_mouse_callback(self, event_type: int, x_coord: int, y_coord: int, flags: int, app_facade_param: Any) -> None: """ Internal OpenCV mouse callback function. Invoked by OpenCV when a mouse event occurs in the managed window. Clamps coordinates and calls the app_facade's handler method. 'app_facade_param' is expected to be the GeoElevationMapViewer instance. """ if event_type == cv2.EVENT_LBUTTONDOWN: # type: ignore # Check for left mouse button down event # Get the dimensions of the image currently displayed (which is the scaled image) current_displayed_height, current_displayed_width = self._last_displayed_scaled_image_shape if current_displayed_width <= 0 or current_displayed_height <= 0: logger.warning("Mouse click on map, but no valid displayed image dimensions are stored.") return # Cannot process click without knowing the displayed image size # Clamp clicked x, y coordinates to be within the bounds of the displayed (scaled) image x_coord_clamped = max(0, min(x_coord, current_displayed_width - 1)) y_coord_clamped = max(0, min(y_coord, current_displayed_height - 1)) logger.debug( f"Map Window Left Click (OpenCV raw): ({x_coord},{y_coord}), " f"Clamped to displayed image: ({x_coord_clamped},{y_coord_clamped})" ) if app_facade_param and hasattr(app_facade_param, 'handle_map_mouse_click'): # Call the designated handler method on the GeoElevationMapViewer instance app_facade_param.handle_map_mouse_click(x_coord_clamped, y_coord_clamped) else: logger.error( "app_facade_param not correctly passed to OpenCV mouse callback, or it lacks " "the 'handle_map_mouse_click' method." ) def pixel_to_geo_on_current_map( self, pixel_x_on_displayed: int, # X coordinate on the (potentially scaled) displayed image pixel_y_on_displayed: int, # Y coordinate on the (potentially scaled) displayed image current_map_geo_bounds: Tuple[float, float, float, float], # west, south, east, north of UN SCALED map displayed_map_pixel_shape: Tuple[int, int], # height, width of SCALED image shown current_map_native_zoom: int # Zoom level of the UN SCALED map data ) -> Optional[Tuple[float, float]]: # Returns (latitude, longitude) """ Converts pixel coordinates from the (potentially scaled) displayed map image to geographic WGS84 coordinates (latitude, longitude). """ if not MERCANTILE_LIB_AVAILABLE_DISPLAY: logger.error("mercantile library not available for pixel_to_geo conversion.") return None if not (current_map_geo_bounds and displayed_map_pixel_shape and current_map_native_zoom is not None): logger.warning("Cannot convert pixel to geo: Current map context for conversion is incomplete.") return None # Dimensions of the image *as it is displayed* (after scaling) displayed_height, displayed_width = displayed_map_pixel_shape # Geographic bounds of the *original, unscaled* map tile data map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds if displayed_width <= 0 or displayed_height <= 0: logger.error("Cannot convert pixel to geo: Invalid displayed map dimensions.") return None try: # Get Web Mercator coordinates of the unscaled map's top-left and bottom-right corners 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 pixel to geo: Invalid Mercator dimensions for map bounds.") return None # Calculate relative position of the click within the *displayed (scaled)* map (0.0 to 1.0) relative_x_on_displayed_map = pixel_x_on_displayed / displayed_width relative_y_on_displayed_map = pixel_y_on_displayed / displayed_height # Use these relative positions to find the corresponding Web Mercator coordinate # within the *unscaled* map's Mercator extent. clicked_point_merc_x = map_ul_merc_x + (relative_x_on_displayed_map * total_map_width_merc) clicked_point_merc_y = map_ul_merc_y - (relative_y_on_displayed_map * total_map_height_merc) 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_px_to_geo: logger.exception(f"Error during pixel_to_geo conversion: {e_px_to_geo}") return None def geo_to_pixel_on_current_map( self, latitude_deg: float, longitude_deg: float, current_map_geo_bounds: Tuple[float, float, float, float], # west, south, east, north of UN SCALED map displayed_map_pixel_shape: Tuple[int, int], # height, width of SCALED image shown current_map_native_zoom: int # Zoom level of the UN SCALED map data ) -> Optional[Tuple[int, int]]: # Returns (pixel_x, pixel_y) on the SCALED image """ Converts geographic WGS84 coordinates to pixel coordinates (x, y) on the currently displayed (potentially scaled) map image. """ if not MERCANTILE_LIB_AVAILABLE_DISPLAY: logger.error("mercantile library not available for geo_to_pixel conversion.") return None if not (current_map_geo_bounds and displayed_map_pixel_shape and current_map_native_zoom is not None): logger.warning("Cannot convert geo to pixel: Current map context for conversion is incomplete.") return None displayed_height, displayed_width = displayed_map_pixel_shape map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds if displayed_width <= 0 or displayed_height <= 0: logger.error("Cannot convert geo to pixel: Invalid displayed 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 bounds.") return None target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) # type: ignore # Relative position of the target geo point within the *unscaled* map's Mercator extent relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc # Convert these relative positions to pixel coordinates on the *displayed (scaled)* image pixel_x_on_displayed = int(round(relative_merc_x_in_map * displayed_width)) pixel_y_on_displayed = int(round(relative_merc_y_in_map * displayed_height)) # Clamp to the boundaries of the displayed (scaled) image px_clamped = max(0, min(pixel_x_on_displayed, displayed_width - 1)) py_clamped = max(0, min(pixel_y_on_displayed, displayed_height - 1)) return (px_clamped, py_clamped) except Exception as e_geo_to_px: logger.exception(f"Error during geo_to_pixel conversion: {e_geo_to_px}") return None def _create_placeholder_bgr_numpy_array(self) -> np.ndarray: # type: ignore """Creates a simple BGR NumPy array to use as a placeholder image.""" placeholder_h = 256 # Default placeholder dimensions placeholder_w = 256 bgr_light_grey = (220, 220, 220) # BGR color for light grey if np: # type: ignore return np.full((placeholder_h, placeholder_w, 3), bgr_light_grey, dtype=np.uint8) # type: ignore else: # Fallback if NumPy somehow became None (should not happen if CV2_NUMPY_AVAILABLE is true) # This case is highly unlikely if __init__ guard passed. # Creating a manual list of lists if np is None is too complex for a simple placeholder. # Returning a pre-made small array or raising error might be better. # For now, this indicates a severe issue. logger.critical("NumPy became unavailable unexpectedly during placeholder creation.") # Return a minimal black image to avoid crashing imshow if possible return [[[0,0,0]]] * 10 # type: ignore # Minimal 10x1 black image (highly not ideal) def _convert_pil_image_to_bgr_numpy_array(self, pil_image: ImageType) -> Optional[np.ndarray]: # type: ignore """Converts a PIL Image object to a NumPy BGR array for OpenCV display.""" if not (PIL_LIB_AVAILABLE_DISPLAY and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY and pil_image): logger.error("Cannot convert PIL to BGR: Pillow, OpenCV/NumPy missing, or input image is None.") return None try: numpy_image_array = np.array(pil_image) # type: ignore if numpy_image_array.ndim == 2: # Grayscale image return cv2.cvtColor(numpy_image_array, cv2.COLOR_GRAY2BGR) # type: ignore elif numpy_image_array.ndim == 3: if numpy_image_array.shape[2] == 3: # RGB image return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGB2BGR) # type: ignore elif numpy_image_array.shape[2] == 4: # RGBA image (alpha channel will be stripped) return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGBA2BGR) # type: ignore logger.warning( f"Unsupported NumPy image shape after PIL conversion: {numpy_image_array.shape}. Cannot convert to BGR." ) return None except Exception as e_conv_pil_bgr: logger.exception(f"Error converting PIL image to BGR NumPy array: {e_conv_pil_bgr}") return None def is_window_alive(self) -> bool: """Checks if the OpenCV window is likely still open and initialized.""" if not self.is_opencv_window_initialized or not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: return False # Not initialized or OpenCV gone try: # WND_PROP_VISIBLE returns >= 1.0 if window is visible, 0.0 if hidden/occluded, # and < 0 (typically -1.0) if window does not exist. window_visibility = cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_VISIBLE) # type: ignore if window_visibility >= 1.0: # Window exists and is visible return True else: # Window likely closed or an issue occurred logger.debug( f"Window '{self.opencv_window_name}' not reported as visible (prop_val={window_visibility}). " "Assuming it's closed or invalid for interaction." ) self.is_opencv_window_initialized = False # Update internal state self.is_opencv_mouse_callback_set = False return False except cv2.error: # type: ignore # OpenCV error (e.g., window name invalid/destroyed) logger.debug(f"OpenCV error when checking property for window '{self.opencv_window_name}'. Assuming closed.") self.is_opencv_window_initialized = False self.is_opencv_mouse_callback_set = False return False except Exception as e_unexpected_alive_check: logger.exception(f"Unexpected error checking if window '{self.opencv_window_name}' is alive: {e_unexpected_alive_check}") self.is_opencv_window_initialized = False # Assume not alive on any other error self.is_opencv_mouse_callback_set = False return False def destroy_window(self) -> None: """Explicitly destroys the managed OpenCV window and resets state flags.""" logger.info(f"Attempting to destroy OpenCV window: '{self.opencv_window_name}'") if self.is_opencv_window_initialized and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: try: cv2.destroyWindow(self.opencv_window_name) # type: ignore cv2.waitKey(5) # Give OpenCV a moment to process the destruction logger.info(f"Window '{self.opencv_window_name}' explicitly destroyed.") except cv2.error as e_cv_destroy_final: # type: ignore logger.warning( f"Ignoring OpenCV error during explicit destroyWindow '{self.opencv_window_name}' " f"(window might have been already closed by user): {e_cv_destroy_final}" ) except Exception as e_destroy_final_generic: logger.exception( f"Unexpected error during explicit destroy window '{self.opencv_window_name}': {e_destroy_final_generic}" ) else: logger.debug( f"Window '{self.opencv_window_name}' was not marked as initialized or OpenCV is not available. " "No explicit destroy action taken." ) # Always reset flags after attempting destroy to ensure clean state self.is_opencv_window_initialized = False self.is_opencv_mouse_callback_set = False self._last_displayed_scaled_image_shape = (0, 0)