# geoelevation/map_viewer/geo_map_viewer.py """ Orchestrates map display functionalities for the GeoElevation application. This module initializes and manages map services, tile fetching/caching, and the map display window. It handles requests to show maps centered on specific points or covering defined areas, applying a specified display scale. It also processes user interactions (mouse clicks) on the map, converting pixel coordinates to geographic coordinates, fetching elevation for those points using the core ElevationManager, and sending this information back to the main GUI via a queue. """ # Standard library imports import logging import math import queue # For type hinting, actual queue object is passed in from multiprocessing from typing import Optional, Tuple, Dict, Any, List # Third-party imports try: from PIL import Image ImageType = Image.Image # type: ignore PIL_IMAGE_LIB_AVAILABLE = True except ImportError: Image = None # type: ignore ImageType = None # type: ignore PIL_IMAGE_LIB_AVAILABLE = False # This logger might not be configured yet if this is the first import in the process # So, direct print or rely on higher-level logger configuration. print("ERROR: GeoMapViewer - Pillow (PIL) library not found. Image operations will fail.") try: import cv2 # OpenCV for drawing operations import numpy as np CV2_NUMPY_LIBS_AVAILABLE = True except ImportError: cv2 = None # type: ignore np = None # type: ignore CV2_NUMPY_LIBS_AVAILABLE = False print("ERROR: GeoMapViewer - OpenCV or NumPy not found. Drawing and image operations will fail.") # Local application/package imports # Imports from other modules within the 'map_viewer' subpackage from .map_services import BaseMapService from .map_services import OpenStreetMapService # Default service if none specified from .map_manager import MapTileManager from .map_utils import get_bounding_box_from_center_size from .map_utils import get_tile_ranges_for_bbox from .map_utils import MapCalculationError # from .map_utils import calculate_meters_per_pixel # Uncomment if scale bar is drawn here # Imports from the parent 'geoelevation' package from geoelevation.elevation_manager import ElevationManager # Module-level logger. Will be configured by the calling process (run_map_viewer_process_target) # or use root logger if not specifically configured. logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.geo_map_viewer' # Default configuration values specific to the map viewer's operation DEFAULT_MAP_TILE_CACHE_DIRECTORY = "map_tile_cache_ge" DEFAULT_MAP_DISPLAY_ZOOM_LEVEL = 15 DEFAULT_MAP_VIEW_AREA_SIZE_KM = 5.0 class GeoElevationMapViewer: """ Manages the display of maps and user interaction for GeoElevation. This class is intended to be instantiated and run in a separate process. """ def __init__( self, elevation_manager_instance: ElevationManager, gui_output_communication_queue: queue.Queue, # For sending data back to GUI initial_display_scale: float = 1.0 # Scale factor for the map image ) -> None: """ Initializes the GeoElevationMapViewer. Args: elevation_manager_instance: Instance of ElevationManager for fetching elevations. gui_output_communication_queue: Queue to send interaction data to the main GUI. initial_display_scale: Initial scaling factor for the map display. """ logger.info("Initializing GeoElevationMapViewer instance...") if not (CV2_NUMPY_LIBS_AVAILABLE and PIL_IMAGE_LIB_AVAILABLE): error_msg = "Pillow, OpenCV, or NumPy not available. GeoElevationMapViewer cannot function." logger.critical(error_msg) raise ImportError(error_msg) self.elevation_manager: ElevationManager = elevation_manager_instance self.gui_com_queue: queue.Queue = gui_output_communication_queue # MODIFIED: Store the initial_display_scale. # WHY: This scale factor will be used by MapDisplayWindow to scale the map image. # HOW: Assigned to self.current_display_scale_factor. self.current_display_scale_factor: float = initial_display_scale logger.info(f"Initial map display scale factor set to: {self.current_display_scale_factor:.3f}") self.map_service_provider: Optional[BaseMapService] = None self.map_tile_fetch_manager: Optional[MapTileManager] = None # Changed attribute name to map_display_window_controller for consistency self.map_display_window_controller: Optional['MapDisplayWindow'] = None self._current_stitched_map_pil: Optional[ImageType] = None self._current_map_geo_bounds_deg: Optional[Tuple[float, float, float, float]] = None self._current_map_render_zoom: Optional[int] = None self._current_stitched_map_pixel_shape: Optional[Tuple[int, int]] = None # H, W self._last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]] = None self._initialize_map_viewer_components() logger.info("GeoElevationMapViewer instance initialization complete.") def _initialize_map_viewer_components(self) -> None: """Initializes internal map service, tile manager, and display window controller.""" logger.debug("Initializing internal map viewer components...") try: from .map_display import MapDisplayWindow # Local import self.map_service_provider = OpenStreetMapService() if not self.map_service_provider: raise ValueError("Failed to initialize OpenStreetMapService.") logger.info(f"Map service provider '{self.map_service_provider.name}' initialized.") self.map_tile_fetch_manager = MapTileManager( map_service=self.map_service_provider, cache_root_directory=DEFAULT_MAP_TILE_CACHE_DIRECTORY, enable_online_tile_fetching=True ) logger.info("MapTileManager initialized.") # MapDisplayWindow will use 'self' (this GeoElevationMapViewer instance) # as its 'app_facade' to make callbacks and access shared state like scale factor. # MODIFIED: Corrected the keyword argument name for the window name. # WHY: The MapDisplayWindow.__init__ method expects 'window_name_str', not 'window_name'. # HOW: Changed 'window_name' to 'window_name_str' in the constructor call. self.map_display_window_controller = MapDisplayWindow( app_facade=self, # This instance provides context (like scale) and handles callbacks window_name_str="GeoElevation - Interactive Map" ) logger.info("MapDisplayWindow controller initialized.") except ImportError as e_imp_map_comp: logger.critical(f"Failed to import a required map component: {e_imp_map_comp}", exc_info=True) raise except Exception as e_init_map_comp: logger.critical(f"Failed to initialize map components: {e_init_map_comp}", exc_info=True) raise def display_map_for_point( self, center_latitude: float, center_longitude: float, target_map_zoom: Optional[int] = None ) -> None: """Displays a map centered on a point, applying the current display scale.""" if not self.map_tile_fetch_manager or not self.map_display_window_controller: logger.error("Map components not ready for display_map_for_point.") return effective_zoom = target_map_zoom if target_map_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL logger.info( f"Requesting map display for point: ({center_latitude:.5f}, {center_longitude:.5f}), " f"Zoom: {effective_zoom}, CurrentDisplayScale: {self.current_display_scale_factor:.2f}" ) try: map_area_km_fetch = DEFAULT_MAP_VIEW_AREA_SIZE_KM * 1.2 # Fetch slightly larger tile_fetch_geo_bbox = get_bounding_box_from_center_size( center_latitude, center_longitude, map_area_km_fetch ) if not tile_fetch_geo_bbox: raise MapCalculationError("BBox calculation for point display failed.") map_tile_xy_ranges = get_tile_ranges_for_bbox(tile_fetch_geo_bbox, effective_zoom) if not map_tile_xy_ranges: raise MapCalculationError("Tile range calculation for point display failed.") stitched_pil = self.map_tile_fetch_manager.stitch_map_image( effective_zoom, map_tile_xy_ranges[0], map_tile_xy_ranges[1] ) if not stitched_pil: logger.error("Failed to stitch map for point display.") self.map_display_window_controller.show_map(None) return self._current_stitched_map_pil = stitched_pil self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range( effective_zoom, map_tile_xy_ranges ) self._current_map_render_zoom = effective_zoom self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width) map_with_marker = self._draw_point_marker_on_map( stitched_pil.copy(), center_latitude, center_longitude ) # MapDisplayWindow.show_map will use self.current_display_scale_factor via app_facade self.map_display_window_controller.show_map(map_with_marker) self._last_user_click_pixel_coords_on_displayed_image = None except MapCalculationError as e_calc_map_pt: logger.error(f"Map calculation error for point display: {e_calc_map_pt}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) except Exception as e_disp_map_pt_fatal: logger.exception(f"Unexpected error displaying map for point: {e_disp_map_pt_fatal}") def display_map_for_area( self, area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north target_map_zoom: Optional[int] = None ) -> None: """Displays a map for a geographic area, applying the current display scale.""" if not self.map_tile_fetch_manager or not self.map_display_window_controller: logger.error("Map components not ready for display_map_for_area.") return effective_zoom = target_map_zoom if target_map_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL logger.info( f"Requesting map display for area: BBox {area_geo_bbox}, " f"Zoom: {effective_zoom}, CurrentDisplayScale: {self.current_display_scale_factor:.2f}" ) try: map_tile_xy_ranges = get_tile_ranges_for_bbox(area_geo_bbox, effective_zoom) if not map_tile_xy_ranges: raise MapCalculationError("Tile range calculation for area display failed.") stitched_pil = self.map_tile_fetch_manager.stitch_map_image( effective_zoom, map_tile_xy_ranges[0], map_tile_xy_ranges[1] ) if not stitched_pil: logger.error("Failed to stitch map image for area display.") self.map_display_window_controller.show_map(None) return self._current_stitched_map_pil = stitched_pil self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range( effective_zoom, map_tile_xy_ranges ) self._current_map_render_zoom = effective_zoom self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width) map_with_bbox_outline = self._draw_area_bounding_box_on_map( stitched_pil.copy(), area_geo_bbox ) self.map_display_window_controller.show_map(map_with_bbox_outline) self._last_user_click_pixel_coords_on_displayed_image = None except MapCalculationError as e_calc_map_area: logger.error(f"Map calculation error for area display: {e_calc_map_area}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) except Exception as e_disp_map_area_fatal: logger.exception(f"Unexpected error displaying map for area: {e_disp_map_area_fatal}") def _can_perform_drawing_operations(self) -> bool: """Helper to check if necessary map context and libraries exist for drawing.""" return bool( self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape and self.map_display_window_controller and CV2_NUMPY_LIBS_AVAILABLE and PIL_IMAGE_LIB_AVAILABLE ) def _draw_point_marker_on_map( self, pil_image_to_draw_on: ImageType, latitude_deg: float, longitude_deg: float ) -> ImageType: """Draws a point marker on the (unscaled) stitched PIL map image.""" if not self._can_perform_drawing_operations() or not self.map_display_window_controller: logger.warning("Cannot draw point marker: drawing context/libs/controller not ready.") return pil_image_to_draw_on # geo_to_pixel_on_current_map needs the UN SCALED map's context pixel_coords_on_unscaled_map = self.map_display_window_controller.geo_to_pixel_on_current_map( latitude_deg, longitude_deg, self._current_map_geo_bounds_deg, # type: ignore self._current_stitched_map_pixel_shape, # type: ignore self._current_map_render_zoom # type: ignore ) if pixel_coords_on_unscaled_map and cv2 and np: px, py = pixel_coords_on_unscaled_map logger.debug(f"Drawing point marker at unscaled pixel ({px},{py}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})") map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore cv2.drawMarker(map_cv_bgr, (px, py), (0,0,255), cv2.MARKER_CROSS, markerSize=20, thickness=2) # Red cross type: ignore return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore logger.warning("Failed to convert geo to pixel for point marker, or CV2/NumPy missing.") return pil_image_to_draw_on def _draw_area_bounding_box_on_map( self, pil_image_to_draw_on: ImageType, area_geo_bbox: Tuple[float, float, float, float] ) -> ImageType: """Draws an area bounding box on the (unscaled) stitched PIL map image.""" if not self._can_perform_drawing_operations() or not self.map_display_window_controller: logger.warning("Cannot draw area BBox: drawing context/libs/controller not ready.") return pil_image_to_draw_on west, south, east, north = area_geo_bbox corners_geo = [(west, north), (east, north), (east, south), (west, south)] pixel_corners: List[Tuple[int,int]] = [] for lon, lat in corners_geo: px_c = self.map_display_window_controller.geo_to_pixel_on_current_map( lat, lon, self._current_map_geo_bounds_deg, # type: ignore self._current_stitched_map_pixel_shape, # type: ignore self._current_map_render_zoom # type: ignore ) if px_c: pixel_corners.append(px_c) if len(pixel_corners) == 4 and cv2 and np: logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}") map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore cv_pts = np.array(pixel_corners, np.int32).reshape((-1,1,2)) # type: ignore cv2.polylines(map_cv_bgr, [cv_pts], isClosed=True, color=(255,0,0), thickness=2) # Blue rectangle type: ignore return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore logger.warning("Failed to convert all corners for area BBox, or CV2/NumPy missing.") return pil_image_to_draw_on def _draw_user_click_marker_on_map( self, pil_image_to_draw_on: ImageType ) -> Optional[ImageType]: """Draws a marker at the last user-clicked pixel location on the (unscaled) PIL map image.""" if not self._last_user_click_pixel_coords_on_displayed_image or \ pil_image_to_draw_on is None or \ not self._can_perform_drawing_operations(): logger.debug("Conditions not met for drawing user click marker (no click, no image, or no context).") return pil_image_to_draw_on # Unscale the click coordinates from displayed (scaled) image to original stitched image coordinates clicked_px_scaled, clicked_py_scaled = self._last_user_click_pixel_coords_on_displayed_image unscaled_target_px = int(round(clicked_px_scaled / self.current_display_scale_factor)) unscaled_target_py = int(round(clicked_py_scaled / self.current_display_scale_factor)) if self._current_stitched_map_pixel_shape: # Clamp to unscaled image dimensions h_unscaled_map, w_unscaled_map = self._current_stitched_map_pixel_shape unscaled_target_px = max(0, min(unscaled_target_px, w_unscaled_map - 1)) unscaled_target_py = max(0, min(unscaled_target_py, h_unscaled_map - 1)) else: logger.warning("Cannot accurately unscale click for marker: unscaled map shape unknown.") return pil_image_to_draw_on if cv2 and np: try: logger.debug(f"Drawing user click marker at unscaled pixel ({unscaled_target_px},{unscaled_target_py})") map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore cv2.drawMarker(map_cv_bgr, (unscaled_target_px,unscaled_target_py), (0,0,255), cv2.MARKER_CROSS, 15, 2) # type: ignore return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore except Exception as e_draw_click_cv: logger.exception(f"Error drawing user click marker with OpenCV: {e_draw_click_cv}") return pil_image_to_draw_on return pil_image_to_draw_on def handle_map_mouse_click(self, pixel_x_on_displayed_img: int, pixel_y_on_displayed_img: int) -> None: """ Handles a mouse click from MapDisplayWindow (coords are on the SCALED, displayed image). Converts pixel to geo, fetches elevation, sends data to GUI, and redraws map with click marker. """ logger.debug( f"Map mouse click (on scaled img) received at pixel ({pixel_x_on_displayed_img}, {pixel_y_on_displayed_img})" ) self._last_user_click_pixel_coords_on_displayed_image = (pixel_x_on_displayed_img, pixel_y_on_displayed_img) if not self._can_perform_drawing_operations() or not self.map_display_window_controller: logger.warning("Cannot process map click: map context or display controller not fully loaded.") return # Convert pixel on SCALED displayed image to geographic coordinates. # MapDisplayWindow's pixel_to_geo method needs the *displayed* (scaled) image shape for this. displayed_img_shape = self.map_display_window_controller._last_displayed_scaled_image_shape geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map( pixel_x_on_displayed_img, pixel_y_on_displayed_img, self._current_map_geo_bounds_deg, # type: ignore # Geo bounds of the unscaled map displayed_img_shape, # Pixel shape of the displayed (scaled) map self._current_map_render_zoom # type: ignore # Zoom level of the unscaled map ) elev_val: Optional[float] = None elev_display_str = "N/A" lat_val: Optional[float] = None lon_val: Optional[float] = None if geo_coords: lat_val, lon_val = geo_coords logger.info(f"Map click (on scaled) converted to Geo: Lat={lat_val:.5f}, Lon={lon_val:.5f}") elev_val = self.elevation_manager.get_elevation(lat_val, lon_val) if elev_val is None: elev_display_str = "Unavailable" elif math.isnan(elev_val): elev_display_str = "NoData" else: elev_display_str = f"{elev_val:.2f} m" logger.info(f"Elevation at clicked geo point: {elev_display_str}") else: logger.warning(f"Could not convert pixel click to geo coordinates.") elev_display_str = "Error: Click conversion failed" try: payload_to_gui = { "type": "map_click_data", "latitude": lat_val, "longitude": lon_val, "elevation_str": elev_display_str, "elevation_val": elev_val } self.gui_com_queue.put(payload_to_gui) logger.debug(f"Sent map_click_data to GUI queue: {payload_to_gui}") except Exception as e_queue_map_click: logger.exception(f"Error putting click data onto GUI queue: {e_queue_map_click}") # Redraw map: get original stitched map, draw new click marker, then show. # MapDisplayWindow.show_map() will apply the current_display_scale_factor. if self._current_stitched_map_pil and self.map_display_window_controller: map_copy_for_marker = self._current_stitched_map_pil.copy() map_with_latest_click_marker = self._draw_user_click_marker_on_map(map_copy_for_marker) if map_with_latest_click_marker: self.map_display_window_controller.show_map(map_with_latest_click_marker) else: # Fallback if drawing failed self.map_display_window_controller.show_map(map_copy_for_marker) def shutdown(self) -> None: """Cleans up resources, particularly the map display window controller.""" logger.info("Shutting down GeoElevationMapViewer and its display window controller.") if self.map_display_window_controller: self.map_display_window_controller.destroy_window() logger.info("GeoElevationMapViewer shutdown procedure complete.")