SXXXXXXX_ControlPanel/map_integration.py
2025-04-08 07:53:55 +02:00

437 lines
21 KiB
Python

# --- 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 ---