1154 lines
69 KiB
Python
1154 lines
69 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
|
|
import sys # Import sys for logging stream
|
|
from typing import Optional, Tuple, Dict, Any, List
|
|
|
|
# Third-party imports
|
|
try:
|
|
from PIL import Image, ImageDraw # Import ImageDraw for drawing operations
|
|
ImageType = Image.Image # type: ignore
|
|
PIL_IMAGE_LIB_AVAILABLE = True
|
|
except ImportError:
|
|
Image = None # type: ignore
|
|
ImageDraw = None # type: ignore # Define as None if import fails
|
|
# MODIFIED: Added ImageType definition for type hinting even if PIL is missing.
|
|
# WHY: Allows static analysis tools to understand the intended type even if the library isn't installed.
|
|
# HOW: Defined ImageType = Any inside the except block.
|
|
ImageType = Any # type: ignore # Define ImageType as Any if PIL is not available
|
|
# 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 windowing and drawing
|
|
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.")
|
|
|
|
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.")
|
|
|
|
|
|
# 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
|
|
# MODIFIED: Import the new utility functions for geographic size and HGT tile bounds.
|
|
# WHY: Needed for calculating displayed map area size and getting DEM tile bounds.
|
|
# HOW: Added imports from map_utils.
|
|
from .map_utils import calculate_geographic_bbox_size_km
|
|
from .map_utils import get_hgt_tile_geographic_bounds
|
|
# MODIFIED: Import the new utility function to calculate zoom level for geographic size.
|
|
# WHY: This is the core function needed to determine the appropriate zoom for the map point view.
|
|
# HOW: Added import from map_utils.
|
|
from .map_utils import calculate_zoom_level_for_geographic_size
|
|
from .map_utils import PYPROJ_AVAILABLE
|
|
|
|
|
|
# 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 # This default might become less relevant for point views
|
|
|
|
# MODIFIED: Define constants for drawing the DEM tile boundary.
|
|
# WHY: Improves code clarity and makes colors/thickness easily adjustable.
|
|
# HOW: Added constants for DEM boundary color and thickness.
|
|
DEM_BOUNDARY_COLOR = "red"
|
|
DEM_BOUNDARY_THICKNESS_PX = 3 # Pixel thickness on the unscaled map image
|
|
|
|
# MODIFIED: Define target pixel dimensions for the stitched map image in the point view.
|
|
# WHY: This is the desired output size that determines the calculated zoom level.
|
|
# HOW: Added a constant.
|
|
TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW = 1024 # Target width and height in pixels
|
|
|
|
|
|
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...")
|
|
# MODIFIED: Added a check for critical dependencies at init.
|
|
# WHY: Ensure the class can function before proceeding.
|
|
# HOW: Raise ImportError if dependencies are missing.
|
|
if not CV2_NUMPY_LIBS_AVAILABLE:
|
|
critical_msg = "OpenCV and/or NumPy are not available for GeoElevationMapViewer operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# PIL and mercantile are also critical for map viewer logic
|
|
if not PIL_IMAGE_LIB_AVAILABLE:
|
|
critical_msg = "Pillow (PIL) library is not available for GeoElevationMapViewer operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# MODIFIED: Added check for ImageDraw availability, as it's needed for drawing.
|
|
# WHY: Drawing shapes/text on PIL images requires ImageDraw.
|
|
# HOW: Added explicit check.
|
|
if ImageDraw is None: # type: ignore
|
|
critical_msg = "Pillow's ImageDraw module is not available. GeoElevationMapViewer drawing operations will fail."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
if not MERCANTILE_LIB_AVAILABLE_DISPLAY:
|
|
critical_msg = "'mercantile' library is not available for GeoElevationMapViewer operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# pyproj is needed for size calculations, but might be optional depending on usage.
|
|
# If calculate_geographic_bbox_size_km fails, the size might be reported as N/A,
|
|
# which is graceful degradation. Let's not make pyproj a hard dependency for init.
|
|
|
|
|
|
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]] = (0, 0) # H, W
|
|
|
|
self._last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]] = None
|
|
# MODIFIED: Added attribute to store the DEM tile bbox if a map view was initiated for a point with DEM data.
|
|
# WHY: Needed to redraw the DEM boundary after user clicks.
|
|
# HOW: Added a new instance attribute.
|
|
self._dem_tile_geo_bbox_for_current_map: Optional[Tuple[float, float, float, float]] = 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:
|
|
# Local import of map_display within the process target to avoid import issues
|
|
# in the main GUI process where Tkinter is running.
|
|
from .map_display import MapDisplayWindow
|
|
|
|
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.")
|
|
|
|
# MODIFIED: Use the map service's tile size when initializing MapTileManager.
|
|
# WHY: Ensure MapTileManager uses the correct tile size for the chosen service.
|
|
# HOW: Passed map_service.tile_size to MapTileManager constructor.
|
|
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,
|
|
tile_pixel_size=self.map_service_provider.tile_size # Pass tile size
|
|
)
|
|
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 # This parameter is now effectively ignored for point view sizing
|
|
) -> None:
|
|
"""
|
|
Displays a map centered around a point, ideally covering the relevant DEM tile,
|
|
draws a marker at the point, and sends initial info back to the GUI.
|
|
Applies the current display scale. The zoom level is calculated to fit the DEM tile.
|
|
"""
|
|
if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.elevation_manager or not self.map_service_provider: # Added check for map_service_provider
|
|
logger.error("Map or Elevation components not ready for display_map_for_point.")
|
|
# MODIFIED: Send error info to GUI queue if components aren't ready.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Put an error message into the queue.
|
|
error_payload = {"type": "map_info_update", "latitude": center_latitude, "longitude": center_longitude,
|
|
"elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"}
|
|
try: self.gui_com_queue.put(error_payload)
|
|
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue: {e_put_err}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder
|
|
return
|
|
|
|
logger.info(
|
|
f"Requesting map display for point: ({center_latitude:.5f}, {center_longitude:.5f}), "
|
|
f"Target Pixel Size: {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}x{TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}, "
|
|
f"CurrentDisplayScale: {self.current_display_scale_factor:.2f}"
|
|
)
|
|
|
|
dem_tile_info = None
|
|
dem_tile_geo_bbox: Optional[Tuple[float, float, float, float]] = None
|
|
map_fetch_geo_bbox: Optional[Tuple[float, float, float, float]] = None # BBox to fetch tiles for
|
|
|
|
# MODIFIED: Reset the stored DEM tile bbox for the current map view.
|
|
# WHY: This is a new map view, so the previous DEM bbox info is irrelevant.
|
|
# HOW: Set _dem_tile_geo_bbox_for_current_map to None.
|
|
self._dem_tile_geo_bbox_for_current_map = None
|
|
|
|
# MODIFIED: Initialize map_tile_xy_ranges to None before the try block.
|
|
# WHY: To ensure the variable is defined even if an exception occurs before its assignment.
|
|
# HOW: Added the initialization here.
|
|
map_tile_xy_ranges = None
|
|
|
|
try:
|
|
# MODIFIED: 1. Get DEM tile info and its geographic bounds.
|
|
# WHY: To size the map view appropriately and potentially draw the DEM boundary.
|
|
# HOW: Use self.elevation_manager.get_tile_info and the new get_hgt_tile_geographic_bounds.
|
|
dem_tile_info = self.elevation_manager.get_tile_info(center_latitude, center_longitude)
|
|
if dem_tile_info and dem_tile_info.get("hgt_available"):
|
|
lat_coord = dem_tile_info["latitude_coord"]
|
|
lon_coord = dem_tile_info["longitude_coord"]
|
|
dem_tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord)
|
|
# MODIFIED: Store the calculated DEM tile bbox if found.
|
|
# WHY: Needed later for redrawing the boundary after clicks.
|
|
# HOW: Assign dem_tile_geo_bbox to _dem_tile_geo_bbox_for_current_map.
|
|
self._dem_tile_geo_bbox_for_current_map = dem_tile_geo_bbox
|
|
logger.debug(f"Identified DEM tile bounds for ({center_latitude:.5f},{center_longitude:.5f}): {dem_tile_geo_bbox}")
|
|
else:
|
|
logger.warning(f"No HGT tile information or HGT not available for ({center_latitude:.5f},{center_longitude:.5f}). Cannot size map precisely to DEM tile. Using default area.")
|
|
# Fallback: if no DEM tile, use a default map area size centered on the point.
|
|
# This is the old behavior, but the zoom calculation will still apply to this area.
|
|
map_area_km_fetch = DEFAULT_MAP_VIEW_AREA_SIZE_KM * 1.2 # Use area size definition
|
|
map_fetch_geo_bbox = get_bounding_box_from_center_size(
|
|
center_latitude, center_longitude, map_area_km_fetch
|
|
)
|
|
if not map_fetch_geo_bbox:
|
|
raise MapCalculationError("Fallback BBox calculation failed.")
|
|
|
|
|
|
# MODIFIED: 2. Determine the map fetch bounding box based on the DEM tile or fallback.
|
|
# WHY: Ensure the map covers the relevant DEM tile area or a reasonable default.
|
|
# HOW: If DEM tile bounds are known, create map_fetch_bbox from them with a buffer.
|
|
if dem_tile_geo_bbox:
|
|
# Expand DEM tile bounds slightly for map fetching (e.g., 0.1 degrees buffer)
|
|
buffer_deg = 0.1
|
|
w_dem, s_dem, e_dem, n_dem = dem_tile_geo_bbox
|
|
# Apply buffer and clamp to valid WGS84 range
|
|
map_fetch_west = max(-180.0, w_dem - buffer_deg)
|
|
map_fetch_south = max(-90.0, s_dem - buffer_deg)
|
|
map_fetch_east = min(180.0, e_dem + buffer_deg)
|
|
map_fetch_north = min(90.0, n_dem + buffer_deg)
|
|
map_fetch_geo_bbox = (map_fetch_west, map_fetch_south, map_fetch_east, map_fetch_north)
|
|
logger.debug(f"Map fetch BBox (DEM+buffer): {map_fetch_geo_bbox}")
|
|
# If dem_tile_geo_bbox was None, map_fetch_geo_bbox was already set by the fallback logic above.
|
|
|
|
|
|
if not map_fetch_geo_bbox:
|
|
raise MapCalculationError("Final map fetch BBox could not be determined.")
|
|
|
|
|
|
# MODIFIED: 3. Calculate the appropriate zoom level to fit the map_fetch_geo_bbox into the target pixel size.
|
|
# WHY: To prevent creating excessively large map images like 28160x40192 px.
|
|
# HOW: Calculate geographic height of map_fetch_geo_bbox and use calculate_zoom_level_for_geographic_size.
|
|
calculated_zoom = None
|
|
zoom_calculation_successful = False
|
|
map_area_size_km = None # Added variable to store size for logging
|
|
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
map_area_size_km = calculate_geographic_bbox_size_km(map_fetch_geo_bbox)
|
|
if map_area_size_km:
|
|
width_km, height_km = map_area_size_km
|
|
map_bbox_height_meters = height_km * 1000.0
|
|
# Use the center latitude of the fetch box for zoom calculation accuracy
|
|
center_lat_fetch_bbox = (map_fetch_geo_bbox[1] + map_fetch_geo_bbox[3]) / 2.0
|
|
|
|
calculated_zoom = calculate_zoom_level_for_geographic_size(
|
|
center_lat_fetch_bbox,
|
|
map_bbox_height_meters,
|
|
TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height
|
|
self.map_service_provider.tile_size # Tile size from the map service
|
|
)
|
|
if calculated_zoom is not None:
|
|
logger.info(f"Calculated zoom level {calculated_zoom} to fit BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.")
|
|
zoom_calculation_successful = True
|
|
else:
|
|
logger.warning("Could not calculate appropriate zoom level. Falling back to default zoom.")
|
|
|
|
else:
|
|
logger.warning("Could not calculate geographic size of fetch BBox. Falling back to default zoom.")
|
|
else:
|
|
logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.")
|
|
|
|
|
|
# MODIFIED: Determine the final zoom level to use.
|
|
# WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback.
|
|
# HOW: Check zoom_calculation_successful.
|
|
zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL
|
|
logger.debug(f"Using zoom level {zoom_to_use} for tile fetching and stitching.")
|
|
|
|
|
|
# map_tile_xy_ranges assignment is here - line 346 originally
|
|
map_tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bbox, zoom_to_use)
|
|
|
|
|
|
if not map_tile_xy_ranges:
|
|
# This might happen if the BBox is very small or outside standard tile limits, mercantile.tiles might be empty.
|
|
logger.warning(f"No map tile ranges found for fetch BBox {map_fetch_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send initial info to GUI even if map fails, with error status.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Tiles N/A", "Map Tiles N/A")
|
|
return # Exit after showing placeholder/sending error
|
|
|
|
|
|
# MODIFIED: Pass the chosen zoom_to_use to stitch_map_image.
|
|
stitched_pil = self.map_tile_fetch_manager.stitch_map_image(
|
|
zoom_to_use, map_tile_xy_ranges[0], map_tile_xy_ranges[1]
|
|
)
|
|
if not stitched_pil:
|
|
logger.error("Failed to stitch map image.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send initial info to GUI even if stitch fails.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Stitch Failed", "Map Stitch Failed")
|
|
return # Exit after showing placeholder/sending error
|
|
|
|
|
|
self._current_stitched_map_pil = stitched_pil
|
|
# MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles.
|
|
# WHY: Needed for pixel-to-geo conversions and calculating the displayed area size.
|
|
# HOW: Get bounds from map_tile_fetch_manager after stitching.
|
|
# MODIFIED: Pass the zoom level *actually used* for stitching (zoom_to_use) to get_bounds_for_tile_range.
|
|
# WHY: The bounds calculated must correspond to the tiles that were actually stitched.
|
|
# HOW: Replaced `effective_zoom` with `zoom_to_use`.
|
|
self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range(
|
|
zoom_to_use, map_tile_xy_ranges
|
|
)
|
|
# MODIFIED: Store the zoom level *actually used* for stitching (zoom_to_use).
|
|
# WHY: Consistency in context.
|
|
# HOW: Assigned `zoom_to_use` to _current_map_render_zoom.
|
|
self._current_map_render_zoom = zoom_to_use
|
|
self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width)
|
|
|
|
# MODIFIED: 5. Draw DEM tile boundary if DEM data is available and the map was stitched successfully.
|
|
# WHY: To indicate the area for which DEM data is available.
|
|
# HOW: Check if _dem_tile_geo_bbox_for_current_map is available and call the new drawing function.
|
|
map_image_to_display = stitched_pil.copy()
|
|
# Only draw DEM boundary if we have its bbox stored (meaning HGT was available for the initial point)
|
|
# The DEM boundary drawing logic in _draw_dem_tile_boundary_on_map handles conversion to pixels
|
|
# on the *stitched image*.
|
|
if self._dem_tile_geo_bbox_for_current_map:
|
|
logger.debug("Drawing DEM tile boundary on initial map display.")
|
|
map_image_to_display = self._draw_dem_tile_boundary_on_map(map_image_to_display, self._dem_tile_geo_bbox_for_current_map)
|
|
|
|
|
|
# MODIFIED: 6. Draw the initial point marker on the prepared map image.
|
|
# WHY: Requirement to show the initial point on the map.
|
|
# HOW: Call _draw_point_marker_on_map with the initial coordinates.
|
|
map_image_to_display = self._draw_point_marker_on_map(
|
|
map_image_to_display, center_latitude, center_longitude
|
|
)
|
|
|
|
# Display the final prepared image (scaling is handled by MapDisplayWindow.show_map)
|
|
self.map_display_window_controller.show_map(map_image_to_display)
|
|
|
|
# MODIFIED: 7. Get elevation for the initial point and send info to GUI.
|
|
# WHY: Requirement to show initial point info in the GUI panel.
|
|
# HOW: Fetch elevation and call _send_initial_point_info_to_gui.
|
|
# Re-fetch elevation to be consistent with what's shown in the GUI panel,
|
|
# although it was already fetched in elevation_gui.
|
|
initial_elev = self.elevation_manager.get_elevation(center_latitude, center_longitude)
|
|
initial_elev_str = "Unavailable" if initial_elev is None else ("NoData" if math.isnan(initial_elev) else f"{initial_elev:.2f} m")
|
|
logger.info(f"Initial elevation at point ({center_latitude:.5f},{center_longitude:.5f}) is: {initial_elev_str}")
|
|
|
|
# Calculate and send map area size
|
|
map_area_size_str = "N/A"
|
|
if self._current_map_geo_bounds_deg:
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
|
|
if size_km:
|
|
width_km, height_km = size_km
|
|
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H"
|
|
else:
|
|
map_area_size_str = "Size Calc Failed"
|
|
logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.")
|
|
else:
|
|
map_area_size_str = "PyProj N/A (Size Unknown)"
|
|
logger.warning("PyProj not available, cannot calculate map area size.")
|
|
|
|
|
|
self._send_initial_point_info_to_gui(
|
|
center_latitude, center_longitude, initial_elev_str, map_area_size_str
|
|
)
|
|
|
|
|
|
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)
|
|
# MODIFIED: Send error info to GUI queue on calculation failure.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Map Calc Error: {e_calc_map_pt}", "Map Calc Error")
|
|
except Exception as e_disp_map_pt_fatal:
|
|
logger.exception(f"Unexpected error displaying map for point: {e_disp_map_pt_fatal}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue on unexpected fatal error.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Fatal Map Error: {type(e_disp_map_pt_fatal).__name__}", "Fatal Error")
|
|
|
|
|
|
def display_map_for_area(
|
|
self,
|
|
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
|
|
target_map_zoom: Optional[int] = None # This parameter is now effectively ignored for area view sizing
|
|
) -> None:
|
|
"""
|
|
Displays a map for a geographic area, applying the current display scale.
|
|
Calculates the zoom level dynamically to fit the requested area into a target pixel size.
|
|
"""
|
|
if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.map_service_provider: # Added check for map_service_provider
|
|
logger.error("Map components not ready for display_map_for_area.")
|
|
# MODIFIED: Send error info to GUI queue if components aren't ready.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Put an error message into the queue.
|
|
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
|
|
"elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"}
|
|
try: self.gui_com_queue.put(error_payload)
|
|
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue: {e_put_err}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder
|
|
return
|
|
|
|
# MODIFIED: Remove the effective_zoom calculation that defaulted to DEFAULT_MAP_DISPLAY_ZOOM_LEVEL.
|
|
# WHY: The goal is to calculate the zoom dynamically based on the area size, not use a fixed default.
|
|
# 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}"
|
|
# )
|
|
logger.info(
|
|
f"Requesting map display for area: BBox {area_geo_bbox}, "
|
|
f"Target Pixel Size: {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}x{TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}, "
|
|
f"CurrentDisplayScale: {self.current_display_scale_factor:.2f}"
|
|
)
|
|
|
|
|
|
# MODIFIED: Clear the stored DEM tile bbox as this is an area view.
|
|
# WHY: The DEM boundary is specific to the initial point view.
|
|
# HOW: Set _dem_tile_geo_bbox_for_current_map to None.
|
|
self._dem_tile_geo_bbox_for_current_map = None
|
|
|
|
calculated_zoom: Optional[int] = None
|
|
zoom_calculation_successful = False
|
|
map_area_size_km: Optional[Tuple[float, float]] = None
|
|
|
|
# MODIFIED: Initialize map_tile_xy_ranges to None before the try block.
|
|
# WHY: To ensure the variable is defined even if an exception occurs before its assignment.
|
|
# HOW: Added the initialization here.
|
|
map_tile_xy_ranges = None
|
|
|
|
try:
|
|
# MODIFIED: Calculate the geographic size of the requested area bounding box.
|
|
# WHY: Needed to determine the appropriate zoom level to fit this area into a target pixel size.
|
|
# HOW: Call map_utils.calculate_geographic_bbox_size_km.
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
map_area_size_km = calculate_geographic_bbox_size_km(area_geo_bbox)
|
|
if map_area_size_km:
|
|
width_km, height_km = map_area_size_km
|
|
logger.debug(f"Calculated geographic size of requested area: {width_km:.2f}km W x {height_km:.2f}km H")
|
|
|
|
# MODIFIED: Calculate the appropriate zoom level to fit the area into the target pixel size.
|
|
# WHY: To prevent creating excessively large map images for large geographic areas.
|
|
# HOW: Use calculate_zoom_level_for_geographic_size based on the area's height.
|
|
map_bbox_height_meters = height_km * 1000.0
|
|
# Use the center latitude of the requested area BBox for zoom calculation accuracy
|
|
center_lat_area_bbox = (area_geo_bbox[1] + area_geo_bbox[3]) / 2.0
|
|
|
|
calculated_zoom = calculate_zoom_level_for_geographic_size(
|
|
center_lat_area_bbox,
|
|
map_bbox_height_meters,
|
|
TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height (reuse constant from point view)
|
|
self.map_service_provider.tile_size # Tile size from the map service
|
|
)
|
|
if calculated_zoom is not None:
|
|
logger.info(f"Calculated zoom level {calculated_zoom} to fit Area BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.")
|
|
zoom_calculation_successful = True
|
|
else:
|
|
logger.warning("Could not calculate appropriate zoom level for area. Falling back to default zoom.")
|
|
|
|
else:
|
|
logger.warning("Could not calculate geographic size of requested area BBox. Falling back to default zoom.")
|
|
else:
|
|
logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.")
|
|
|
|
|
|
# MODIFIED: Determine the final zoom level to use.
|
|
# WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback.
|
|
# HOW: Check zoom_calculation_successful.
|
|
zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL
|
|
logger.debug(f"Using zoom level {zoom_to_use} for tile fetching and stitching for area.")
|
|
|
|
# map_tile_xy_ranges assignment is here - corresponds to line 346 in point view
|
|
map_tile_xy_ranges = get_tile_ranges_for_bbox(area_geo_bbox, zoom_to_use)
|
|
|
|
if not map_tile_xy_ranges:
|
|
logger.warning(f"No map tile ranges found for area BBox {area_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue even if map fails.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
self._send_initial_point_info_to_gui(None, None, "Map Tiles N/A", "Map Tiles N/A")
|
|
return # Exit after showing placeholder/sending error
|
|
|
|
|
|
# MODIFIED: Pass the chosen zoom_to_use to stitch_map_image.
|
|
stitched_pil = self.map_tile_fetch_manager.stitch_map_image(
|
|
zoom_to_use, 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)
|
|
# MODIFIED: Send error info to GUI queue even if stitch fails.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
self._send_initial_point_info_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed")
|
|
return # Exit after showing placeholder/sending error
|
|
|
|
self._current_stitched_map_pil = stitched_pil
|
|
# MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles for the area view.
|
|
# WHY: Needed for pixel-to-geo conversions and calculating the displayed area size.
|
|
# HOW: Get bounds from map_tile_fetch_manager after stitching.
|
|
# MODIFIED: Pass the zoom level *actually used* for stitching (zoom_to_use) to get_bounds_for_tile_range.
|
|
# WHY: The bounds calculated must correspond to the tiles that were actually stitched.
|
|
# HOW: Replaced `effective_zoom` with `zoom_to_use`.
|
|
self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range(
|
|
zoom_to_use, map_tile_xy_ranges
|
|
)
|
|
# MODIFIED: Store the zoom level *actually used* for stitching (zoom_to_use).
|
|
# WHY: Consistency in context.
|
|
# HOW: Assigned `zoom_to_use` to _current_map_render_zoom.
|
|
self._current_map_render_zoom = zoom_to_use
|
|
self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width)
|
|
|
|
# MODIFIED: Draw the *requested* area bounding box on the map image.
|
|
# WHY: To visualize the specific area the user requested to view on the map.
|
|
# HOW: Call _draw_area_bounding_box_on_map with the input area_geo_bbox.
|
|
map_with_bbox_outline = self._draw_area_bounding_box_on_map(
|
|
stitched_pil.copy(), area_geo_bbox # Draw the *requested* area BBox
|
|
)
|
|
self.map_display_window_controller.show_map(map_with_bbox_outline)
|
|
self._last_user_click_pixel_coords_on_displayed_image = None
|
|
|
|
# MODIFIED: Calculate and send map area size for area view.
|
|
# WHY: The GUI needs the size of the displayed area.
|
|
# HOW: Calculate size and send message (using N/A for point info in this case).
|
|
map_area_size_str = "N/A"
|
|
if self._current_map_geo_bounds_deg:
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
# Calculate size of the *stitched* area's bounds (which might be slightly larger than requested)
|
|
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
|
|
if size_km:
|
|
width_km, height_km = size_km
|
|
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H"
|
|
else:
|
|
map_area_size_str = "Size Calc Failed"
|
|
logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.")
|
|
else:
|
|
map_area_size_str = "PyProj N/A (Size Unknown)"
|
|
logger.warning("PyProj not available, cannot calculate map area size.")
|
|
|
|
|
|
# Send info for area view (point info is N/A)
|
|
self._send_initial_point_info_to_gui(None, None, "N/A (Area View)", map_area_size_str)
|
|
|
|
|
|
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)
|
|
# MODIFIED: Send error info to GUI queue on calculation failure for area view.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
self._send_initial_point_info_to_gui(None, None, f"Map Calc Error: {e_calc_map_area}", "Map Calc Error")
|
|
except Exception as e_disp_map_area_fatal:
|
|
logger.exception(f"Unexpected error displaying map for area: {e_disp_map_area_fatal}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue on unexpected fatal error for area view.
|
|
# WHY: GUI should update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
self._send_initial_point_info_to_gui(None, None, f"Fatal Map Error: {type(e_disp_map_area_fatal).__name__}", "Fatal Error")
|
|
|
|
|
|
# MODIFIED: Added a dedicated helper function to send initial point/map info to the GUI.
|
|
# WHY: Centralizes the logic for sending the first message after map display.
|
|
# HOW: Created a new method that formats and puts the data into the queue.
|
|
def _send_initial_point_info_to_gui(
|
|
self,
|
|
latitude: Optional[float],
|
|
longitude: Optional[float],
|
|
elevation_str: str,
|
|
map_area_size_str: str
|
|
) -> None:
|
|
"""Sends initial point/map info (coords, elevation, map size) to the GUI queue."""
|
|
payload_to_gui = {
|
|
"type": "map_info_update", # Use a distinct type for initial/map state updates
|
|
"latitude": latitude,
|
|
"longitude": longitude,
|
|
"elevation_str": elevation_str,
|
|
"map_area_size_str": map_area_size_str
|
|
}
|
|
try:
|
|
self.gui_com_queue.put(payload_to_gui)
|
|
logger.debug(f"Sent map_info_update to GUI queue: {payload_to_gui}")
|
|
except Exception as e_queue_initial_info:
|
|
logger.exception(f"Error putting initial map info onto GUI queue: {e_queue_initial_info}")
|
|
|
|
|
|
def _can_perform_drawing_operations(self) -> bool:
|
|
"""Helper to check if necessary map context and libraries exist for drawing."""
|
|
# MODIFIED: Added check for ImageDraw which is needed for drawing shapes/lines on PIL images.
|
|
# WHY: ImageDraw is explicitly required for drawing bounding boxes/markers.
|
|
# HOW: Included ImageDraw in the check.
|
|
# MODIFIED: Include check for CV2/NumPy availability as well, as some drawing methods use OpenCV.
|
|
# WHY: Ensure all necessary libraries are present for drawing.
|
|
# HOW: Added check.
|
|
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
|
|
PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None and # Check PIL and ImageDraw
|
|
CV2_NUMPY_LIBS_AVAILABLE # Check CV2 and NumPy
|
|
)
|
|
|
|
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."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
|
|
logger.warning("Cannot draw point marker: drawing context/libs/controller or original image not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
# MODIFIED: Use internal mercantile logic to convert geo to pixel on the UN SCALED map image.
|
|
# WHY: More direct than going through the MapDisplayWindow's method which is designed for the *scaled* displayed image.
|
|
# HOW: Reimplemented the core conversion logic from map_display.py here using the stored unscaled image context.
|
|
if self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape:
|
|
# Reuse the logic from map_display.py but apply it to the unscaled image dimensions
|
|
# (height, width) of the UN SCALED stitched map
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg
|
|
map_native_zoom = self._current_map_render_zoom # Not strictly needed for this conversion, but part of context
|
|
|
|
try:
|
|
import mercantile as local_mercantile # Use mercantile directly here
|
|
# MODIFIED: Check mercantile availability locally before use.
|
|
# WHY: Safety, although should be available due to class init check.
|
|
# HOW: Added check.
|
|
if local_mercantile is None: raise ImportError("mercantile not available locally.")
|
|
|
|
map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore
|
|
map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
|
|
# Corrected potential typo and ensured positive value
|
|
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
|
|
|
|
|
|
# Handle zero dimensions in Mercator space (e.g., invalid geo bounds)
|
|
if total_map_width_merc == 0 or total_map_height_merc == 0:
|
|
logger.warning("Map Mercator extent is zero, cannot draw point marker.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
|
|
target_merc_x, target_merc_y = local_mercantile.xy(longitude_deg, latitude_deg) # type: ignore
|
|
|
|
# Relative position of the target geo point within the *unscaled* map's Mercator extent
|
|
# Need to handle potential division by zero if map width/height is zero (e.g., invalid bounds)
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0
|
|
|
|
|
|
# Convert these relative positions to pixel coordinates on the *unscaled* image
|
|
pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width))
|
|
pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height))
|
|
|
|
|
|
# Clamp to the boundaries of the unscaled image
|
|
px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width - 1))
|
|
py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height - 1))
|
|
|
|
logger.debug(f"Drawing point marker at unscaled pixel ({px_clamped},{py_clamped}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})")
|
|
|
|
# MODIFIED: Check for CV2 and NumPy availability before using them.
|
|
# WHY: Ensure dependencies are present for drawing with OpenCV.
|
|
# HOW: Added check.
|
|
if cv2 and np:
|
|
# Convert PIL image to OpenCV format (BGR) for drawing
|
|
# Ensure image is in a mode OpenCV can handle (BGR)
|
|
if pil_image_to_draw_on.mode != 'RGB':
|
|
# Convert to RGB first if not already, then to BGR
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore
|
|
else:
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore
|
|
|
|
# Draw a cross marker at the calculated unscaled pixel coordinates
|
|
# Note: Marker color (0,0,255) is BGR for red
|
|
cv2.drawMarker(map_cv_bgr, (px_clamped, py_clamped), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore
|
|
# Convert back to PIL format (RGB)
|
|
return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore
|
|
else:
|
|
logger.warning("CV2 or NumPy not available, cannot draw point marker using OpenCV.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
|
|
except Exception as e_geo_to_px:
|
|
logger.exception(f"Error during geo_to_pixel conversion for point marker: {e_geo_to_px}")
|
|
return pil_image_to_draw_on # Return original image on error
|
|
else:
|
|
logger.warning("Current map context incomplete, cannot draw point marker.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
|
|
def _draw_area_bounding_box_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType,
|
|
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
|
|
color: str = "blue", # Allow specifying color
|
|
thickness: int = 2 # Allow specifying thickness
|
|
) -> ImageType:
|
|
"""Draws an area bounding box on the (unscaled) stitched PIL map image."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
|
|
logger.warning("Cannot draw area BBox: drawing context/libs/controller or original image not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
# MODIFIED: Use internal mercantile logic to convert geo to pixel on the UN SCALED map image.
|
|
# WHY: More direct and consistent with point marker drawing.
|
|
# HOW: Reimplemented the core conversion logic from map_display.py here using the stored unscaled image context.
|
|
if self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape:
|
|
# (height, width) of the UN SCALED stitched map
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg
|
|
map_native_zoom = self._current_map_render_zoom # Not strictly needed for this conversion
|
|
|
|
west, south, east, north = area_geo_bbox
|
|
# Corners of the box in geographic degrees
|
|
corners_geo = [(west, north), (east, north), (east, south), (west, south)]
|
|
pixel_corners: List[Tuple[int,int]] = []
|
|
|
|
try:
|
|
import mercantile as local_mercantile # Use mercantile directly here
|
|
# MODIFIED: Check mercantile availability locally before use.
|
|
# WHY: Safety.
|
|
# HOW: Added check.
|
|
if local_mercantile is None: raise ImportError("mercantile not available locally.")
|
|
|
|
map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore
|
|
map_lr_merc_x, map_lr_merc_y = local_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.warning("Map Mercator extent is zero, cannot draw BBox.")
|
|
return pil_image_to_draw_on
|
|
|
|
for lon, lat in corners_geo:
|
|
target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore
|
|
|
|
# Handle relative position calculation, ensuring bounds are respected
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0
|
|
|
|
pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width))
|
|
pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height))
|
|
|
|
# Clamping is less strict for drawing lines that might slightly exceed image bounds
|
|
# to show boundaries clearly, but avoid drawing far off-screen.
|
|
# Let's clamp to a reasonable area around the image.
|
|
# MODIFIED: Clamping to be slightly outside the image boundaries to avoid clipping the drawn box edges.
|
|
# WHY: Standard practice for drawing boundaries.
|
|
# HOW: Use thickness value in max/min calculation.
|
|
px_clamped = max(-thickness, min(pixel_x_on_unscaled, unscaled_width + thickness))
|
|
py_clamped = max(-thickness, min(pixel_y_on_unscaled, unscaled_height + thickness))
|
|
|
|
|
|
pixel_corners.append((px_clamped, py_clamped))
|
|
|
|
except Exception as e_geo_to_px_bbox:
|
|
logger.exception(f"Error during geo_to_pixel conversion for BBox drawing: {e_geo_to_px_bbox}")
|
|
return pil_image_to_draw_on # Return original image on error
|
|
|
|
# MODIFIED: Check ImageDraw availability before using it.
|
|
# WHY: Ensure dependency is present.
|
|
# HOW: Added check.
|
|
if len(pixel_corners) == 4 and ImageDraw is not None:
|
|
logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}")
|
|
# Ensure image is in a mode that supports drawing (RGB or RGBA)
|
|
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
|
|
# Convert to RGBA to support drawing with transparency if needed in the future,
|
|
# otherwise RGB is usually sufficient for solid colors.
|
|
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") # Prefer RGBA for drawing
|
|
draw = ImageDraw.Draw(pil_image_to_draw_on)
|
|
|
|
# Draw lines connecting the corner points
|
|
# Draw a closed polygon outline
|
|
# MODIFIED: Use draw.line to draw segments, provides more control and handles points order better.
|
|
# WHY: Ensures the rectangle is drawn correctly even if points are not in perfect order for polygon outline.
|
|
# HOW: Changed from draw.polygon with outline to four calls to draw.line.
|
|
try:
|
|
# Draw lines explicitly connecting the corners in sequence
|
|
draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) # Top edge
|
|
draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) # Right edge
|
|
draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) # Bottom edge
|
|
draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) # Left edge
|
|
except Exception as e_draw_lines:
|
|
logger.exception(f"Error drawing BBox lines: {e_draw_lines}")
|
|
|
|
|
|
return pil_image_to_draw_on
|
|
else:
|
|
logger.warning("Not enough pixel corners calculated for BBox, or ImageDraw missing.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
else:
|
|
logger.warning("Current map context incomplete, cannot draw area BBox.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
|
|
# MODIFIED: Added a specific drawing function for the DEM tile boundary.
|
|
# WHY: Separates the logic and allows specific styling (e.g., red color, different thickness).
|
|
# HOW: Calls _draw_area_bounding_box_on_map with predefined style.
|
|
def _draw_dem_tile_boundary_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType,
|
|
dem_tile_geo_bbox: Tuple[float, float, float, float]
|
|
) -> ImageType:
|
|
"""Draws a boundary box for the DEM tile on the map image."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
|
|
logger.warning("Cannot draw DEM tile boundary: drawing context/libs/controller or original image not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
logger.debug(f"Drawing DEM tile boundary on map for bbox: {dem_tile_geo_bbox}")
|
|
# Use the generic area drawing function with specific style
|
|
return self._draw_area_bounding_box_on_map(
|
|
pil_image_to_draw_on,
|
|
dem_tile_geo_bbox,
|
|
color=DEM_BOUNDARY_COLOR,
|
|
thickness=DEM_BOUNDARY_THICKNESS_PX
|
|
)
|
|
|
|
|
|
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."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
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() or \
|
|
self._current_stitched_map_pil is None: # Ensure original stitched image exists
|
|
logger.debug("Conditions not met for drawing user click marker (no click, no image, no context, or no original stitched image).")
|
|
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
|
|
|
|
# Get the shape of the original unscaled stitched image
|
|
if not self._current_stitched_map_pixel_shape:
|
|
logger.warning("Cannot accurately unscale click for marker: unscaled map shape unknown.")
|
|
return pil_image_to_draw_on
|
|
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
|
|
# Calculate the unscaled pixel coordinates corresponding to the clicked scaled pixel
|
|
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))
|
|
|
|
# Clamp to unscaled image dimensions
|
|
unscaled_target_px = max(0, min(unscaled_target_px, unscaled_width - 1))
|
|
unscaled_target_py = max(0, min(unscaled_target_py, unscaled_height - 1))
|
|
|
|
# MODIFIED: Check for CV2 and NumPy availability before using them.
|
|
# WHY: Ensure dependencies are present for drawing with OpenCV.
|
|
# HOW: Added check.
|
|
if cv2 and np:
|
|
try:
|
|
logger.debug(f"Drawing user click marker at unscaled pixel ({unscaled_target_px},{unscaled_target_py})")
|
|
# Convert PIL image to OpenCV format (BGR) for drawing
|
|
# Ensure image is in a mode OpenCV can handle (BGR)
|
|
if pil_image_to_draw_on.mode != 'RGB':
|
|
# Convert to RGB first if not already, then to BGR
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore
|
|
else:
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore
|
|
|
|
# Draw a cross marker at the calculated unscaled pixel coordinates
|
|
# Note: Marker color (0,0,255) is BGR for red
|
|
cv2.drawMarker(map_cv_bgr, (unscaled_target_px,unscaled_target_py), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore
|
|
# Convert back to PIL format (RGB)
|
|
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 original image on error
|
|
else:
|
|
logger.warning("CV2 or NumPy not available, cannot draw user click marker.")
|
|
return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here
|
|
|
|
|
|
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})"
|
|
)
|
|
# Store the pixel coordinates of the click on the *displayed* (scalata) image.
|
|
self._last_user_click_pixel_coords_on_displayed_image = (pixel_x_on_displayed_img, pixel_y_on_displayed_img)
|
|
|
|
# MODIFIED: Check if map context is ready before proceeding with conversion and elevation fetch.
|
|
# WHY: Avoids errors if the map wasn't fully loaded or context is missing.
|
|
# HOW: Added explicit check. Also check Mercantile availability which is critical for pixel-geo conversion.
|
|
if not self._can_perform_drawing_operations() or not self._current_map_geo_bounds_deg or \
|
|
self._current_map_render_zoom is None or not self._current_stitched_map_pixel_shape or \
|
|
not MERCANTILE_LIB_AVAILABLE_DISPLAY: # Check Mercantile specifically
|
|
logger.warning("Cannot process map click: map context, Mercantile, or display controller not fully loaded.")
|
|
# MODIFIED: Send partial error info to GUI queue on click if context is missing.
|
|
# WHY: GUI should update click info even if elevation cannot be fetched.
|
|
# HOW: Send a payload with error status for elevation/size.
|
|
error_payload = {
|
|
"type": "map_info_update", # Use the same type for click updates
|
|
"latitude": None, "longitude": None,
|
|
"elevation_str": "Click Error: Context N/A", "map_area_size_str": "Click Error: Context N/A"
|
|
}
|
|
try: self.gui_com_queue.put(error_payload)
|
|
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue on click: {e_put_err}")
|
|
return # Exit after sending error
|
|
|
|
|
|
# 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
|
|
|
|
# Use the MapDisplayWindow's method to convert click pixel coords to geo coords.
|
|
# This method uses the stored unscaled map context and the scaled displayed shape.
|
|
geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
|
|
pixel_x_on_displayed_img, pixel_y_on_displayed_img,
|
|
# These arguments are required by MapDisplayWindow.pixel_to_geo_on_current_map
|
|
# and are stored in the GeoElevationMapViewer instance after a map is displayed.
|
|
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
|
|
map_area_size_str = "N/A" # Default
|
|
|
|
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}")
|
|
# MODIFIED: Fetch elevation for the clicked point using the child process's ElevationManager.
|
|
# WHY: ElevationManager is available and configured in this process.
|
|
# HOW: Call self.elevation_manager.get_elevation.
|
|
try:
|
|
if self.elevation_manager: # Ensure manager is not None
|
|
elev_val = self.elevation_manager.get_elevation(lat_val, lon_val)
|
|
if elev_val is None: elev_display_str = "Unavailable"
|
|
elif isinstance(elev_val, float) and math.isnan(elev_val): elev_display_str = "NoData"
|
|
else: elev_display_str = f"{elev_val:.2f} m"
|
|
else:
|
|
elev_display_str = "Elev Manager N/A"
|
|
logger.warning("ElevationManager is None in map process, cannot get elevation for click.")
|
|
|
|
logger.info(f"Elevation at clicked geo point: {elev_display_str}")
|
|
except Exception as e_get_elev_click:
|
|
logger.error(f"Error getting elevation for clicked point: {e_get_elev_click}")
|
|
elev_display_str = f"Error: {type(e_get_elev_click).__name__}"
|
|
|
|
# Calculate map area size string (same as for initial display)
|
|
if self._current_map_geo_bounds_deg:
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
|
|
if size_km:
|
|
width_km, height_km = size_km
|
|
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H"
|
|
else:
|
|
map_area_size_str = "Size Calc Failed"
|
|
logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.")
|
|
else:
|
|
map_area_size_str = "PyProj N/A (Size Unknown)"
|
|
logger.warning("PyProj not available, cannot calculate map area size.")
|
|
|
|
|
|
else:
|
|
logger.warning(f"Could not convert pixel click to geo coordinates.")
|
|
elev_display_str = "Error: Click conversion failed"
|
|
map_area_size_str = "Error: Click conversion failed"
|
|
|
|
# MODIFIED: Send click data + map area size info using the new update message type.
|
|
# WHY: Consistent message format for initial info and clicks.
|
|
# HOW: Use "type": "map_info_update" and include all relevant fields.
|
|
try:
|
|
payload_to_gui = {
|
|
"type": "map_info_update",
|
|
"latitude": lat_val, # Send coordinates even if conversion failed, might be None
|
|
"longitude": lon_val,
|
|
"elevation_str": elev_display_str,
|
|
"map_area_size_str": map_area_size_str
|
|
}
|
|
self.gui_com_queue.put(payload_to_gui)
|
|
logger.debug(f"Sent map_info_update (click) 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, potentially draw DEM boundary, draw new click marker, then show.
|
|
# MapDisplayWindow.show_map() will apply the current_display_scale_factor.
|
|
# Need to re-apply DEM boundary drawing if it was originally drawn for this map.
|
|
# We need to use the *original* stitched image as the base for redrawing, not the one currently displayed
|
|
# by OpenCV which might have older markers.
|
|
# MODIFIED: Check if original stitched image is available and drawing is possible before redrawing.
|
|
# WHY: Avoids errors if the original image is None.
|
|
# HOW: Added check.
|
|
if self._current_stitched_map_pil and self.map_display_window_controller and self._can_perform_drawing_operations():
|
|
# Start with a fresh copy of the original stitched image
|
|
map_copy_for_drawing = self._current_stitched_map_pil.copy()
|
|
|
|
# MODIFIED: Re-draw DEM boundary on map image copy before adding click marker.
|
|
# WHY: The original stitched image does NOT have the boundary drawn. It must be redrawn for each update
|
|
# (initial display, subsequent clicks) on a fresh copy.
|
|
# HOW: Check if _dem_tile_geo_bbox_for_current_map is stored (meaning this map view was for a point with DEM data)
|
|
# and draw the boundary if so.
|
|
if self._dem_tile_geo_bbox_for_current_map:
|
|
logger.debug("Redrawing DEM tile boundary on map image copy before adding click marker.")
|
|
map_copy_for_drawing = self._draw_dem_tile_boundary_on_map(map_copy_for_drawing, self._dem_tile_geo_bbox_for_current_map)
|
|
|
|
|
|
# Draw the latest user click marker on top of the map copy (which might now have the DEM boundary)
|
|
# _draw_user_click_marker_on_map needs the image to draw on, and uses the stored _last_user_click_pixel_coords_on_displayed_image
|
|
map_with_latest_click_marker = self._draw_user_click_marker_on_map(map_copy_for_drawing)
|
|
|
|
# Display the final prepared image (scaling happens inside MapDisplayWindow.show_map)
|
|
if map_with_latest_click_marker:
|
|
self.map_display_window_controller.show_map(map_with_latest_click_marker)
|
|
else:
|
|
# Fallback if drawing the marker failed, show the map potentially with the DEM boundary but no click marker
|
|
logger.warning("Failed to draw user click marker. Showing map without marker.")
|
|
self.map_display_window_controller.show_map(map_copy_for_drawing)
|
|
else:
|
|
logger.warning("Cannot redraw map after click: conditions not met.")
|
|
|
|
|
|
|
|
def shutdown(self) -> None:
|
|
"""Cleans up resources, particularly the map display window controller."""
|
|
logger.info("Shutting down GeoElevationMapViewer and its display window controller.")
|
|
# MODIFIED: Reset stored map context on shutdown.
|
|
# WHY: Ensure a clean state if the map viewer process is restarted.
|
|
# HOW: Reset attributes to None.
|
|
self._current_stitched_map_pil = None
|
|
self._current_map_geo_bounds_deg = None
|
|
self._current_map_render_zoom = None
|
|
self._current_stitched_map_pixel_shape = (0, 0) # Reset to default tuple
|
|
self._last_user_click_pixel_coords_on_displayed_image = None
|
|
self._dem_tile_geo_bbox_for_current_map = None
|
|
|
|
if self.map_display_window_controller:
|
|
self.map_display_window_controller.destroy_window()
|
|
self.map_display_window_controller = None # Clear reference
|
|
|
|
logger.info("GeoElevationMapViewer shutdown procedure complete.") |