# --- START OF FILE map_integration.py --- # map_integration.py """ THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. Manages map integration functionalities, including service interaction, tile fetching/caching, display window management, and overlay updates. Acts as an intermediary between the main application and map-specific modules. """ # Standard library imports import logging import threading import queue # For type hinting import math from typing import Optional, Dict, Any, Tuple # Third-party imports import numpy as np try: from PIL import Image except ImportError: Image = None # Handled in dependent modules, but check here too try: import mercantile except ImportError: mercantile = None try: import pyproj except ImportError: pyproj = None # Local application imports import config from app_state import AppState from utils import put_queue # Map specific modules that this manager orchestrates from map_services import get_map_service, BaseMapService from map_manager import MapTileManager from map_utils import ( get_bounding_box_from_center_size, get_tile_ranges_for_bbox, MapCalculationError ) from map_display import MapDisplayWindow # Forward declaration for type hinting App instance from typing import TYPE_CHECKING if TYPE_CHECKING: from app import App class MapIntegrationManager: """Orchestrates map services, tile management, and display.""" def __init__( self, app_state: AppState, tkinter_queue: queue.Queue, app: 'App', map_x: int, map_y: int, ): """ Initializes the MapIntegrationManager. # ... (docstring args, raises) ... """ self._log_prefix = "[MapIntegrationManager]" logging.info(f"{self._log_prefix} Initializing...") self._app_state: AppState = app_state self._tkinter_queue: queue.Queue = tkinter_queue self._app: 'App' = app # --- Dependency Checks --- if Image is None: raise ImportError("Pillow library not found") if mercantile is None: raise ImportError("mercantile library not found") if pyproj is None: raise ImportError("pyproj library not found") # --- Initialize Attributes --- self._map_service: Optional[BaseMapService] = None self._map_tile_manager: Optional[MapTileManager] = None self._map_display_window: Optional[MapDisplayWindow] = None self._map_initial_display_thread: Optional[threading.Thread] = None self._geod: Optional[pyproj.Geod] = None # Initialize as None first try: # --- Geodetic Calculator Initialization --- # Create the Geod object here self._geod = pyproj.Geod(ellps="WGS84") logging.debug(f"{self._log_prefix} pyproj Geod object initialized (WGS84).") # --- Initialize Other Map Components --- # 1. Get Map Service service_name = getattr(config, 'MAP_SERVICE_PROVIDER', 'osm') api_key = getattr(config, 'MAP_API_KEY', None) self._map_service = get_map_service(service_name, api_key) if not self._map_service: raise ValueError(f"Failed to get map service '{service_name}'.") logging.info(f"{self._log_prefix} Map service '{self._map_service.name}' loaded.") # 2. Create Tile Manager self._map_tile_manager = MapTileManager( map_service=self._map_service, cache_base_dir=getattr(config, 'MAP_CACHE_DIRECTORY', None), enable_online_fetching=getattr(config, 'ENABLE_ONLINE_MAP_FETCHING', None) ) logging.info(f"{self._log_prefix} MapTileManager created.") # 3. Create Map Display Window Manager self._map_display_window = MapDisplayWindow( window_name="Map Overlay", x_pos=map_x, y_pos=map_y ) logging.info(f"{self._log_prefix} MapDisplayWindow created at ({map_x},{map_y}).") # 4. Trigger Initial Map Display in Background Thread logging.debug(f"{self._log_prefix} Starting initial map display thread...") # Set status before starting thread self._app.set_status("Loading initial map...") self._map_initial_display_thread = threading.Thread( target=self._display_initial_map_area_thread, name="InitialMapDisplayThread", daemon=True ) self._map_initial_display_thread.start() except (ImportError, ValueError, pyproj.exceptions.CRSError) as init_err: # Catch pyproj errors too logging.critical(f"{self._log_prefix} Initialization failed: {init_err}") # Ensure components are None if init fails midway self._geod = None self._map_service = None self._map_tile_manager = None self._map_display_window = None raise # Re-raise critical errors except Exception as e: logging.exception(f"{self._log_prefix} Unexpected error during initialization:") self._geod = None self._map_service = None self._map_tile_manager = None self._map_display_window = None raise # Re-raise other unexpected errors # Re-raise other unexpected errors def _display_initial_map_area_thread(self): """ (Runs in background thread) Calculates the initial map area based on default config settings and queues the result for display on the main thread. Moved from App._display_initial_map_area. """ log_prefix = f"{self._log_prefix} InitialMap" # Check dependencies initialized in __init__ if not (self._map_tile_manager and self._map_display_window): # This check might be redundant if __init__ raises exceptions, but keep for safety logging.error(f"{log_prefix} Map components not initialized. Aborting thread.") # Queue None to signal failure to the main thread? put_queue(self._tkinter_queue, ('SHOW_MAP', None), "tkinter", self._app) return # Check shutdown flag early if self._app_state.shutting_down: logging.info(f"{log_prefix} Shutdown detected. Aborting.") return logging.info(f"{log_prefix} Calculating initial map area...") map_image_pil: Optional[Image.Image] = None try: # Use default center/size from config for the initial view bbox = get_bounding_box_from_center_size( config.SAR_CENTER_LAT, config.SAR_CENTER_LON, config.SAR_IMAGE_SIZE_KM ) if bbox is None: raise MapCalculationError("Failed to calculate initial bounding box.") zoom = config.DEFAULT_MAP_ZOOM_LEVEL tile_ranges = get_tile_ranges_for_bbox(bbox, zoom) if tile_ranges is None: raise MapCalculationError("Failed to calculate initial tile ranges.") # --- Check shutdown again before potentially long tile stitching --- if self._app_state.shutting_down: logging.info(f"{log_prefix} Shutdown detected before stitching.") return logging.info(f"{log_prefix} Stitching initial map tiles (Zoom: {zoom}, X: {tile_ranges[0]}, Y: {tile_ranges[1]})...") map_image_pil = self._map_tile_manager.stitch_map_image( zoom, tile_ranges[0], tile_ranges[1] ) # stitch_map_image uses placeholders internally if needed # --- Check shutdown again after stitching --- if self._app_state.shutting_down: logging.info(f"{log_prefix} Shutdown detected after stitching.") # Don't queue result if shutting down return if map_image_pil: logging.info(f"{log_prefix} Initial map area stitched successfully.") else: # This case should be less likely if stitch_map_image uses placeholders logging.error(f"{log_prefix} Failed to stitch initial map area (returned None even with placeholders).") except ImportError as e: # Should be caught by __init__, but handle defensively logging.critical(f"{log_prefix} Missing library during map calculation: {e}") map_image_pil = None # Ensure None is queued on error except MapCalculationError as e: logging.error(f"{log_prefix} Calculation error: {e}") map_image_pil = None # Ensure None is queued on error except Exception as e: logging.exception(f"{log_prefix} Unexpected error calculating initial map:") map_image_pil = None # Ensure None is queued on error finally: # Always queue the result (PIL image or None) for the main thread to handle display # Check shutdown one last time before queueing if not self._app_state.shutting_down: logging.debug(f"{log_prefix} Queueing SHOW_MAP command for main thread.") # The payload is the PIL image (or None) put_queue(self._tkinter_queue, ('SHOW_MAP', map_image_pil), "tkinter", self._app) logging.debug(f"{log_prefix} Initial map display thread finished.") def update_map_overlay(self, sar_normalized_uint8: np.ndarray, geo_info_radians: Dict[str, Any]): """ Calculates the map overlay based on current SAR data. Currently fetches map tiles and draws the SAR bounding box. Queues the result for display on the main thread. Args: sar_normalized_uint8 (np.ndarray): Current normalized SAR image (uint8). Unused in Phase 1. geo_info_radians (Dict[str, Any]): Current SAR georeferencing info (in radians). """ log_prefix = f"{self._log_prefix} Map Update" # --- Prerequisite Checks (Shutdown, Test Mode, Components, GeoInfo) --- if self._app_state.shutting_down: logging.debug(f"{log_prefix} Skipping update: Shutdown detected.") return if self._app_state.test_mode_active: logging.debug(f"{log_prefix} Skipping update: Test Mode active.") return # +++ DETAILED COMPONENT CHECK +++ if not self._map_tile_manager: logging.warning(f"{log_prefix} Skipping update: _map_tile_manager is not available (None or evaluates False).") return if not self._map_display_window: logging.warning(f"{log_prefix} Skipping update: _map_display_window is not available (None or evaluates False).") return if not self._geod: logging.warning(f"{log_prefix} Skipping update: _geod (geodetic calculator) is not available (None or evaluates False).") return # +++ END DETAILED CHECK +++ # Check GeoInfo validity if not geo_info_radians or not geo_info_radians.get("valid", False): logging.warning(f"{log_prefix} Skipping update: Invalid GeoInfo provided.") return # Check libraries (redundant if init succeeded, but safe) if Image is None or mercantile is None or pyproj is None: logging.error(f"{log_prefix} Skipping update: Missing required map libraries.") return # Log start of calculation logging.info(f"{log_prefix} Starting map overlay calculation (Phase 1: BBox)...") # Initialize variables to store map images map_image_with_overlay: Optional[Image.Image] = None stitched_map_image: Optional[Image.Image] = None # Store the base map try: # --- Calculate SAR Footprint Parameters --- # Extract center coordinates (convert back to degrees for utility functions) center_lat_deg = math.degrees(geo_info_radians.get('lat', 0.0)) center_lon_deg = math.degrees(geo_info_radians.get('lon', 0.0)) # Extract scale and dimensions to calculate size scale_x = geo_info_radians.get('scale_x', 0.0) width_px = geo_info_radians.get('width_px', 0) # Calculate size in KM, using default from config as fallback if scale_x > 0 and width_px > 0: size_km = (scale_x * width_px) / 1000.0 else: logging.warning(f"{log_prefix} Using default SAR size for map due to invalid scale/width in GeoInfo.") size_km = config.SAR_IMAGE_SIZE_KM # Get zoom level from config zoom = config.DEFAULT_MAP_ZOOM_LEVEL # --- Fetch and Stitch Base Map --- # 1. Calculate Geographic Bounding Box for fetching tiles logging.debug(f"{log_prefix} Calculating map tile BBox for center ({center_lat_deg:.4f},{center_lon_deg:.4f}), size {size_km*1.2:.1f}km.") fetch_bbox = get_bounding_box_from_center_size(center_lat_deg, center_lon_deg, size_km * 1.2) if fetch_bbox is None: raise MapCalculationError("Tile Bounding Box calculation failed.") # 2. Calculate Tile Ranges needed for the bounding box logging.debug(f"{log_prefix} Calculating tile ranges for zoom {zoom}...") tile_ranges = get_tile_ranges_for_bbox(fetch_bbox, zoom) if tile_ranges is None: raise MapCalculationError("Tile range calculation failed.") # --- Check shutdown flag before potentially long operation --- if self._app_state.shutting_down: logging.info(f"{log_prefix} Shutdown detected before stitching base map.") return # 3. Stitch Background Map Image using MapTileManager logging.info(f"{log_prefix} Stitching base map tiles (Zoom: {zoom}, X: {tile_ranges[0]}, Y: {tile_ranges[1]})...") stitched_map_image = self._map_tile_manager.stitch_map_image(zoom, tile_ranges[0], tile_ranges[1]) # --- Validate Stitched Image and Log --- if stitched_map_image is None: logging.error(f"{log_prefix} MapTileManager.stitch_map_image returned None. Cannot proceed.") map_image_with_overlay = None raise MapCalculationError("Failed to stitch base map image.") else: logging.info(f"{log_prefix} Base map stitched successfully (PIL Size: {stitched_map_image.size}, Mode: {stitched_map_image.mode}).") map_image_with_overlay = stitched_map_image.copy() # --- Check shutdown flag after stitching --- if self._app_state.shutting_down: logging.info(f"{log_prefix} Shutdown detected after stitching base map.") return # --- Calculate and Draw SAR Bounding Box --- # 4. Calculate Geographic Coordinates of SAR Corners logging.debug(f"{log_prefix} Calculating SAR corner geographic coordinates...") sar_corners_deg = self._calculate_sar_corners_geo(geo_info_radians) if sar_corners_deg is None: raise MapCalculationError("SAR corner geographic coordinate calculation failed.") # 5. Convert SAR Corner Geographic Coords to Pixel Coords on Stitched Map logging.debug(f"{log_prefix} Converting SAR corners to map pixel coordinates...") top_left_tile = mercantile.Tile(x=tile_ranges[0][0], y=tile_ranges[1][0], z=zoom) map_display_bounds = mercantile.bounds(top_left_tile) sar_corners_pixels = self._geo_coords_to_map_pixels( coords_deg=sar_corners_deg, map_bounds=map_display_bounds, map_tile_ranges=tile_ranges, zoom=zoom, stitched_map_shape=map_image_with_overlay.size[::-1] ) if sar_corners_pixels is None: raise MapCalculationError("SAR corner to map pixel conversion failed.") # 6. Draw the SAR Bounding Box Polygon on the map image copy logging.debug(f"{log_prefix} Drawing SAR bounding box polygon on map image...") try: map_cv = cv2.cvtColor(np.array(map_image_with_overlay), cv2.COLOR_RGB2BGR) pts = np.array(sar_corners_pixels, np.int32).reshape((-1, 1, 2)) cv2.polylines(map_cv, [pts], isClosed=True, color=(0, 0, 255), thickness=2) map_image_with_overlay = Image.fromarray(cv2.cvtColor(map_cv, cv2.COLOR_BGR2RGB)) logging.info(f"{log_prefix} SAR bounding box drawn successfully on map.") except Exception as draw_err: logging.exception(f"{log_prefix} Error drawing SAR bounding box on map:") map_image_with_overlay = stitched_map_image logging.warning(f"{log_prefix} Proceeding with map display without SAR bounding box due to drawing error.") except MapCalculationError as e: logging.error(f"{log_prefix} Map overlay calculation failed: {e}") map_image_with_overlay = stitched_map_image except Exception as e: logging.exception(f"{log_prefix} Unexpected error during map overlay update:") map_image_with_overlay = stitched_map_image finally: # --- Queue Result for Main Thread Display --- if not self._app_state.shutting_down: payload_type = type(map_image_with_overlay) payload_size = getattr(map_image_with_overlay, 'size', 'N/A') logging.debug(f"{log_prefix} Queueing SHOW_MAP command for updated map overlay. Payload Type: {payload_type}, Size: {payload_size}") put_queue(self._tkinter_queue, ('SHOW_MAP', map_image_with_overlay), "tkinter", self._app) else: logging.debug(f"{log_prefix} Skipping queue put due to shutdown.") def display_map(self, map_image_pil: Optional[Image.Image]): """ Instructs the MapDisplayWindow to show the provided map image. This method is intended to be called from the main thread (e.g., via tkinter_queue). Args: map_image_pil (Optional[Image.Image]): The PIL map image to display, or None for placeholder. """ log_prefix = f"{self._log_prefix} Display" if self._map_display_window: logging.debug(f"{log_prefix} Calling MapDisplayWindow.show_map...") try: self._map_display_window.show_map(map_image_pil) # Update app status only *after* the initial map load attempt completes self._update_app_status_after_map_load(map_image_pil is not None) except Exception as e: logging.exception(f"{log_prefix} Error calling MapDisplayWindow.show_map:") else: logging.warning(f"{log_prefix} Map display window not available. Cannot display map.") def _update_app_status_after_map_load(self, success: bool): """Updates the main application status after the initial map load attempt.""" log_prefix = f"{self._log_prefix} Status Update" try: # Check if the status bar still shows the loading message # Access status bar via self._app reference if hasattr(self._app, 'statusbar') and "Loading initial map" in self._app.statusbar.cget("text"): # Determine the final status based on success and current mode if success: current_mode = self._app.state.test_mode_active # Check current mode status_msg = "Ready (Test Mode)" if current_mode else \ ("Ready (Local Mode)" if config.USE_LOCAL_IMAGES else \ (f"Listening UDP {self._app.local_ip}:{self._app.local_port}" if self._app.udp_socket else "Error: No Socket")) else: status_msg = "Error Loading Map" logging.info(f"{log_prefix} Initial map load finished (Success: {success}). Setting App status to: '{status_msg}'") self._app.set_status(status_msg) # Use App's method for thread safety #else: # Status already updated by something else, do nothing # logging.debug(f"{log_prefix} Skipping status update, map loading message not present.") except Exception as e: logging.warning(f"{log_prefix} Error checking/updating app status after map load: {e}") def shutdown(self): """Cleans up map-related resources, like closing the display window.""" log_prefix = f"{self._log_prefix} Shutdown" logging.info(f"{log_prefix} Shutting down map integration components...") # Destroy Map Display Window if self._map_display_window: logging.debug(f"{log_prefix} Requesting MapDisplayWindow destroy...") try: self._map_display_window.destroy_window() # Handles internal logging except Exception as e: logging.exception(f"{log_prefix} Error destroying MapDisplayWindow:") self._map_display_window = None # Stop initial display thread if still running (less critical as it's daemon) # No explicit stop needed, just rely on shutdown flag check within the thread # Clear tile manager cache? Optional, maybe not needed on normal shutdown. logging.info(f"{log_prefix} Map integration shutdown complete.") # --- END OF FILE map_integration.py ---