SXXXXXXX_GeoElevation/geoelevation/map_viewer/geo_map_viewer.py
2025-05-13 15:11:07 +02:00

431 lines
22 KiB
Python

# 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.")