431 lines
22 KiB
Python
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.") |