SXXXXXXX_ControlPanel/map_integration.py
VALLONGOL f0c49a7934 fix overlay function
add shift sar map
2025-04-14 15:55:12 +02:00

1050 lines
48 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, SAR overlay application (with alpha
blending and user-defined shifting), coordinate conversion, and map saving.
Acts as an intermediary between the main application and map-specific modules.
Implements masked blending for SAR overlay and rapid recomposition for alpha changes.
"""
# Standard library imports
import logging
import threading
import queue
import math
import os
import datetime
from typing import Optional, Dict, Any, Tuple, List
from pathlib import Path
# Third-party imports
import numpy as np
try:
from PIL import Image, ImageDraw, ImageFont
ImageType = Image.Image
except ImportError:
Image = None
ImageDraw = None
ImageFont = None
ImageType = None # type: ignore
try:
import mercantile
except ImportError:
mercantile = None
try:
import pyproj
except ImportError:
pyproj = None
# OpenCV is needed for warping and blending
import cv2
# Local application imports
import config
from app_state import AppState
from utils import put_queue, decimal_to_dms
# Map specific modules
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,
calculate_meters_per_pixel,
)
from map_display import MapDisplayWindow
# Import image processing function needed for overlay
from image_processing import apply_color_palette
# Forward declaration for type hinting App instance
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ControlPanel import ControlPanelApp
class MapIntegrationManager:
"""Orchestrates map services, tile management, and display including SAR overlay."""
DEFAULT_SAVE_DIRECTORY = "saved_map_views"
def __init__(
self,
app_state: AppState,
tkinter_queue: queue.Queue,
app: "ControlPanelApp",
map_x: int,
map_y: int,
):
"""Initializes the MapIntegrationManager."""
self._log_prefix = "[MapIntegrationManager]"
logging.debug(f"{self._log_prefix} Initializing...")
self._app_state: AppState = app_state
self._tkinter_queue: queue.Queue = tkinter_queue
self._app: "ControlPanelApp" = 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")
if ImageDraw is None or ImageFont is None:
logging.warning(f"{self._log_prefix} Pillow ImageDraw/ImageFont not found.")
# 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
self._map_update_lock = threading.Lock()
try:
# Geodetic Calculator
self._geod = pyproj.Geod(ellps="WGS84")
# 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}'.")
# Tile Manager
cache_dir = getattr(config, "MAP_CACHE_DIRECTORY", None)
online_fetch = getattr(config, "ENABLE_ONLINE_MAP_FETCHING", None)
self._map_tile_manager = MapTileManager(
self._map_service, cache_dir, online_fetch
)
# Map Display Window
self._map_display_window = MapDisplayWindow(
self._app, "Map Overlay", map_x, map_y
)
# Initial Map Display 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 Exception as init_err:
logging.critical(
f"{self._log_prefix} Initialization failed: {init_err}", exc_info=True
)
self._geod = None
self._map_service = None
self._map_tile_manager = None
self._map_display_window = None
raise
def _display_initial_map_area_thread(self):
"""(Runs in background thread) Calculates initial map area and queues for display."""
# (Implementation unchanged from previous version)
log_prefix = f"{self._log_prefix} InitialMap"
base_map_image_pil: Optional[ImageType] = None
map_bounds_deg: Optional[Tuple[float, float, float, float]] = None
zoom: Optional[int] = None
if config.SAR_CENTER_LAT == 0.0 and config.SAR_CENTER_LON == 0.0:
self._update_app_state_map_context(None, None, None)
self._queue_map_for_display(None)
return
if not (self._map_tile_manager and self._map_display_window and self._geod):
self._update_app_state_map_context(None, None, None)
self._queue_map_for_display(None)
return
if self._app_state.shutting_down:
return
try:
zoom = config.DEFAULT_MAP_ZOOM_LEVEL
center_lat_deg = config.SAR_CENTER_LAT
center_lon_deg = config.SAR_CENTER_LON
size_km = config.SAR_IMAGE_SIZE_KM
fetch_bbox_deg = get_bounding_box_from_center_size(
center_lat_deg, center_lon_deg, size_km * 1.2
)
if fetch_bbox_deg is None:
raise MapCalculationError("BBox calc failed.")
tile_ranges = get_tile_ranges_for_bbox(fetch_bbox_deg, zoom)
if tile_ranges is None:
raise MapCalculationError("Tile range calc failed.")
if self._app_state.shutting_down:
return
map_bounds_deg = self._get_bounds_for_tile_range(zoom, tile_ranges)
if map_bounds_deg is None:
raise MapCalculationError("Map bounds calc failed.")
base_map_image_pil = self._map_tile_manager.stitch_map_image(
zoom, tile_ranges[0], tile_ranges[1]
)
if self._app_state.shutting_down:
return
if base_map_image_pil:
base_map_image_pil = self._draw_scale_bar(
base_map_image_pil, center_lat_deg, zoom
)
except Exception as e:
logging.exception(f"{log_prefix} Error calculating initial map:")
base_map_image_pil = None
map_bounds_deg = None
zoom = None
finally:
if not self._app_state.shutting_down:
self._update_app_state_map_context(
base_map_image_pil, map_bounds_deg, zoom
)
self._queue_map_for_display(
base_map_image_pil
) # Initial map is also the composed map initially
logging.debug(f"{log_prefix} Initial map display thread finished.")
# --- >>> START OF MODIFIED FUNCTION <<< ---
def update_map_overlay(
self,
sar_normalized_uint8: Optional[np.ndarray],
geo_info_radians: Optional[Dict[str, Any]],
):
"""
Calculates the map overlay based on current state. Fetches/stitches map,
applies SAR shift, processes SAR image, warps/blends overlay using masked alpha,
draws bounding box, adds scale bar, updates AppState (base map, context,
processed SAR, warp info, composed map), and queues the result for display.
Uses a lock to prevent concurrent updates.
"""
log_prefix = f"{self._log_prefix} Map Update"
if not self._map_update_lock.acquire(blocking=False):
logging.debug(f"{log_prefix} Map update already in progress. Skipping.")
return
try:
# Prerequisite Checks
if self._app_state.shutting_down or self._app_state.test_mode_active:
return
if not (self._map_tile_manager and self._map_display_window and self._geod):
return
if not geo_info_radians or not geo_info_radians.get("valid", False):
logging.warning(f"{log_prefix} Skipping: Invalid GeoInfo.")
return
if Image is None or mercantile is None or pyproj is None or cv2 is None:
return
# State Variables
overlay_enabled = self._app_state.map_sar_overlay_enabled
overlay_alpha = self._app_state.map_sar_overlay_alpha
lat_shift_deg = self._app_state.sar_lat_shift_deg
lon_shift_deg = self._app_state.sar_lon_shift_deg
# SAR Data Check (only if overlay enabled)
if overlay_enabled and (
sar_normalized_uint8 is None or sar_normalized_uint8.size == 0
):
logging.warning(
f"{log_prefix} Disabling overlay for this frame: Missing SAR data."
)
overlay_enabled = False
logging.debug(
f"{log_prefix} Starting (Overlay:{overlay_enabled}, Alpha:{overlay_alpha:.2f}, Shift:{lat_shift_deg:.6f},{lon_shift_deg:.6f})..."
)
# Initialize variables for this update cycle
base_map_pil: Optional[ImageType] = None
final_composed_pil: Optional[ImageType] = None
map_bounds_deg: Optional[Tuple[float, float, float, float]] = None
zoom: Optional[int] = None
processed_sar_overlay: Optional[np.ndarray] = (
None # Store processed SAR for AppState
)
sar_warp_matrix: Optional[np.ndarray] = (
None # Store warp matrix for AppState
)
sar_corners_pixels: Optional[List[Tuple[int, int]]] = (
None # Store corners for AppState
)
try:
# Apply Shift to Center Coordinates
center_lat_rad = geo_info_radians.get("lat", 0.0)
center_lon_rad = geo_info_radians.get("lon", 0.0)
shifted_lat_deg = max(
-90.0, min(90.0, math.degrees(center_lat_rad) + lat_shift_deg)
)
shifted_lon_deg = (
(math.degrees(center_lon_rad) + lon_shift_deg + 180) % 360
) - 180
# Calculate Common Parameters (using SHIFTED center)
scale_x = geo_info_radians.get("scale_x", 0.0)
width_px = geo_info_radians.get("width_px", 0)
size_km = (
(scale_x * width_px / 1000.0)
if (scale_x > 0 and width_px > 0)
else config.SAR_IMAGE_SIZE_KM
)
zoom = config.DEFAULT_MAP_ZOOM_LEVEL
# Fetch and Stitch Base Map
fetch_bbox_deg = get_bounding_box_from_center_size(
shifted_lat_deg, shifted_lon_deg, size_km * 1.2
)
if fetch_bbox_deg is None:
raise MapCalculationError("Tile BBox calc failed.")
tile_ranges = get_tile_ranges_for_bbox(fetch_bbox_deg, zoom)
if tile_ranges is None:
raise MapCalculationError("Tile range calc failed.")
if self._app_state.shutting_down:
return
map_bounds_deg = self._get_bounds_for_tile_range(zoom, tile_ranges)
if map_bounds_deg is None:
raise MapCalculationError("Map bounds calc failed.")
base_map_pil = self._map_tile_manager.stitch_map_image(
zoom, tile_ranges[0], tile_ranges[1]
)
if base_map_pil is None:
raise MapCalculationError("Base map stitch failed.")
if self._app_state.shutting_down:
return
# Calculate SAR Footprint Pixels on Map
shifted_geo_info = geo_info_radians.copy()
shifted_geo_info["lat"] = math.radians(shifted_lat_deg)
shifted_geo_info["lon"] = math.radians(shifted_lon_deg)
sar_corners_deg = self._calculate_sar_corners_geo(shifted_geo_info)
if sar_corners_deg is None:
raise MapCalculationError("SAR corner geo calc failed.")
map_shape_yx = base_map_pil.size[::-1]
sar_corners_pixels = self._geo_coords_to_map_pixels(
sar_corners_deg, map_bounds_deg, tile_ranges, zoom, map_shape_yx
)
if sar_corners_pixels is None:
raise MapCalculationError("SAR corner pixel conversion failed.")
# Prepare Base Map Image (NumPy BGR)
map_cv_bgr = cv2.cvtColor(np.array(base_map_pil), cv2.COLOR_RGB2BGR)
map_h, map_w = map_cv_bgr.shape[:2]
display_cv_bgr = map_cv_bgr.copy() # Start with base map
# --- Apply SAR Overlay OR Draw Bounding Box ---
if overlay_enabled:
logging.debug(f"{log_prefix} Processing SAR image for overlay...")
processed_sar_overlay = self._process_sar_for_overlay(
sar_normalized_uint8
) # Store this
if processed_sar_overlay is not None:
logging.debug(
f"{log_prefix} Warping SAR image onto map perspective..."
)
try:
sar_h, sar_w = processed_sar_overlay.shape[:2]
pts_sar = np.float32(
[
[0, 0],
[sar_w - 1, 0],
[sar_w - 1, sar_h - 1],
[0, sar_h - 1],
]
)
pts_map = np.float32(sar_corners_pixels)
sar_warp_matrix = cv2.getPerspectiveTransform(
pts_sar, pts_map
) # Store this
# Warp the processed SAR image
warped_sar_bgr = cv2.warpPerspective(
src=processed_sar_overlay,
M=sar_warp_matrix,
dsize=(map_w, map_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_CONSTANT,
borderValue=(
0,
0,
0,
), # Use black border for mask generation
)
# Create Mask from warped SAR (non-black pixels)
gray_warped = cv2.cvtColor(
warped_sar_bgr, cv2.COLOR_BGR2GRAY
)
_, sar_mask = cv2.threshold(
gray_warped, 1, 255, cv2.THRESH_BINARY
)
# Convert mask to 3 channels for blending compatibility if needed
# sar_mask_3ch = cv2.cvtColor(sar_mask, cv2.COLOR_GRAY2BGR)
# --- MASKED Blending ---
# Convert mask to float (0.0 to 1.0) and multiply by alpha
# Ensure sar_mask is float for multiplication
mask_alpha = (
sar_mask.astype(np.float32) / 255.0
) * overlay_alpha
# Expand mask_alpha to 3 channels to multiply with BGR images
mask_alpha_3ch = cv2.cvtColor(
mask_alpha, cv2.COLOR_GRAY2BGR
)
logging.debug(
f"{log_prefix} Blending map and warped SAR using mask (Alpha: {overlay_alpha:.2f})..."
)
# Blend: map * (1 - mask*alpha) + warped_sar * (mask*alpha)
# Ensure map_cv_bgr is float for multiplication
term1 = map_cv_bgr.astype(np.float32) * (
1.0 - mask_alpha_3ch
)
term2 = warped_sar_bgr.astype(np.float32) * mask_alpha_3ch
blended_float = term1 + term2
# Convert back to uint8
display_cv_bgr = np.clip(blended_float, 0, 255).astype(
np.uint8
)
logging.debug(
f"{log_prefix} Masked alpha blending complete."
)
except cv2.error as warp_err:
logging.exception(
f"{log_prefix} OpenCV error during SAR warp/blend:"
)
overlay_enabled = False
except Exception as warp_generic_err:
logging.exception(
f"{log_prefix} Unexpected error during SAR warp/blend:"
)
overlay_enabled = False
else:
overlay_enabled = False # Fallback if SAR processing failed
# Draw Red Box (If overlay disabled or failed)
if not overlay_enabled:
logging.debug(f"{log_prefix} Drawing SAR bounding box...")
try:
pts = np.array(sar_corners_pixels, np.int32).reshape((-1, 1, 2))
cv2.polylines(
display_cv_bgr,
[pts],
isClosed=True,
color=(0, 0, 255),
thickness=2,
)
except Exception as draw_err:
logging.exception(
f"{log_prefix} Error drawing SAR bounding box:"
)
# Convert Final CV Image back to PIL
final_composed_pil = Image.fromarray(
cv2.cvtColor(display_cv_bgr, cv2.COLOR_BGR2RGB)
)
# Add Scale Bar
final_composed_pil = self._draw_scale_bar(
final_composed_pil, shifted_lat_deg, zoom
)
except MapCalculationError as e:
logging.error(f"{log_prefix} Map overlay calculation failed: {e}")
final_composed_pil = self._get_fallback_map()
except Exception as e:
logging.exception(
f"{log_prefix} Unexpected error during map overlay update:"
)
final_composed_pil = self._get_fallback_map()
finally:
# --- Update AppState and Queue ---
if not self._app_state.shutting_down:
self._update_app_state_map_context(
base_map_pil, map_bounds_deg, zoom
)
# --- >>> START OF NEW CODE: Update SAR overlay cache <<< ---
self._app_state.last_processed_sar_for_overlay = (
processed_sar_overlay.copy()
if processed_sar_overlay is not None
else None
)
self._app_state.last_sar_warp_matrix = (
sar_warp_matrix.copy() if sar_warp_matrix is not None else None
)
self._app_state.last_sar_corners_pixels_map = (
sar_corners_pixels.copy()
if sar_corners_pixels is not None
else None
)
# --- >>> END OF NEW CODE <<< ---
self._queue_map_for_display(
final_composed_pil
) # Queues and updates composed PIL state
finally:
self._map_update_lock.release() # Ensure lock is released
logging.debug(f"{log_prefix} Map update finished.")
def _recompose_map_overlay(self):
"""
Re-applies SAR overlay with current alpha/settings using cached data.
If cached data is insufficient, it logs a warning and exits.
"""
log_prefix = f"{self._log_prefix} RecomposeMap"
if not self._map_update_lock.acquire(blocking=False):
logging.debug(f"{log_prefix} Map update/recomposition already in progress. Skipping.")
return
final_recomposed_pil: Optional[ImageType] = None
try:
if self._app_state.shutting_down: return
logging.debug(f"{log_prefix} Starting map recomposition...")
# --- Get cached data & current state ---
base_map_pil = self._app_state.last_map_image_pil
processed_sar = self._app_state.last_processed_sar_for_overlay
warp_matrix = self._app_state.last_sar_warp_matrix
sar_corner_pixels = self._app_state.last_sar_corners_pixels_map
overlay_enabled = self._app_state.map_sar_overlay_enabled
overlay_alpha = self._app_state.map_sar_overlay_alpha
latitude_deg = math.degrees(self._app_state.current_sar_geo_info.get('lat', 0.0)) if self._app_state.current_sar_geo_info else 0.0
zoom = self._app_state.map_current_zoom
# --- Validate necessary cached data for the CURRENT desired state ---
if base_map_pil is None:
logging.warning(f"{log_prefix} Cannot recompose: Base map image missing.")
# Cannot proceed without base map
return
if overlay_enabled and (processed_sar is None or warp_matrix is None):
# Data needed for overlay is missing, cannot recompose overlay state
logging.error(f"{log_prefix} Cannot recompose overlay: SAR cache data missing (ProcessedSAR:{processed_sar is not None}, WarpMatrix:{warp_matrix is not None}).")
# Trigger full update maybe? Or just log error? Let's just log and exit for now.
return
if not overlay_enabled and sar_corner_pixels is None:
# Data needed for bounding box is missing, cannot recompose box state
logging.error(f"{log_prefix} Cannot recompose bounding box: Corner pixels missing.")
# Fallback to just base map?
final_recomposed_pil = base_map_pil # Use base map without box
# Skip drawing and proceed to scale bar / queuing
# --- Recomposition Steps ---
if final_recomposed_pil is None: # Only if not already fallback to base map
map_cv_bgr = cv2.cvtColor(np.array(base_map_pil), cv2.COLOR_RGB2BGR)
map_h, map_w = map_cv_bgr.shape[:2]
display_cv_bgr = map_cv_bgr.copy()
if overlay_enabled: # We already checked processed_sar and warp_matrix are not None
try:
# Warp, create mask, blend using current alpha
warped_sar_bgr = cv2.warpPerspective(processed_sar, warp_matrix, (map_w, map_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0,0,0))
warped_sar_gray = cv2.cvtColor(warped_sar_bgr, cv2.COLOR_BGR2GRAY)
_, sar_mask = cv2.threshold(warped_sar_gray, 1, 255, cv2.THRESH_BINARY)
inv_alpha = 1.0 - overlay_alpha
sar_weighted = cv2.convertScaleAbs(warped_sar_bgr, alpha=overlay_alpha)
map_weighted = cv2.convertScaleAbs(map_cv_bgr, alpha=inv_alpha)
map_masked_weighted = cv2.bitwise_and(map_weighted, map_weighted, mask=sar_mask)
blended_masked = cv2.add(map_masked_weighted, sar_weighted)
display_cv_bgr = cv2.copyTo(blended_masked, sar_mask, map_cv_bgr)
except Exception as e:
logging.exception(f"{log_prefix} Error during SAR overlay recomposition warp/blend:")
# Fallback to base map if recompose fails
display_cv_bgr = map_cv_bgr # Reset to base map
elif sar_corner_pixels is not None: # Draw bounding box (checked sar_corner_pixels exists)
try:
pts = np.array(sar_corner_pixels, np.int32).reshape((-1, 1, 2))
cv2.polylines(display_cv_bgr, [pts], isClosed=True, color=(0, 0, 255), thickness=2)
except Exception as e:
logging.exception(f"{log_prefix} Error recomposing bounding box:")
# display_cv_bgr remains base map
# Convert back to PIL
final_recomposed_pil = Image.fromarray(cv2.cvtColor(display_cv_bgr, cv2.COLOR_BGR2RGB))
# --- Add Scale Bar (if possible) ---
if final_recomposed_pil and zoom is not None:
final_recomposed_pil = self._draw_scale_bar(final_recomposed_pil, latitude_deg, zoom)
elif final_recomposed_pil:
logging.warning(f"{log_prefix} Cannot draw scale bar during recompose (missing zoom/lat context).")
# --- Update state and Queue ---
self._app_state.last_composed_map_pil = final_recomposed_pil.copy() if final_recomposed_pil else None
self._queue_map_for_display(final_recomposed_pil) # Queue the result
except Exception as e:
logging.exception(f"{log_prefix} Unexpected error during map recomposition:")
# Attempt to queue base map as fallback?
if not self._app_state.shutting_down and self._app_state.last_map_image_pil:
self._queue_map_for_display(self._app_state.last_map_image_pil)
finally:
self._map_update_lock.release() # Release lock
logging.debug(f"{log_prefix} Map recomposition finished.")
def _process_sar_for_overlay(
self, sar_normalized_uint8: np.ndarray
) -> Optional[np.ndarray]:
"""Applies B/C LUT and Palette to SAR image for overlay."""
# (Implementation unchanged)
log_prefix = f"{self._log_prefix} ProcessSAROverlay"
try:
bc_lut = self._app_state.brightness_contrast_lut
palette = self._app_state.sar_palette
if bc_lut is None:
raise ValueError("B/C LUT is None")
processed_sar = cv2.LUT(sar_normalized_uint8, bc_lut)
if palette != "GRAY":
processed_sar = apply_color_palette(processed_sar, palette)
elif processed_sar.ndim == 2:
processed_sar = cv2.cvtColor(processed_sar, cv2.COLOR_GRAY2BGR)
return processed_sar
except Exception as e:
logging.exception(f"{log_prefix} Error:")
return None
def _get_bounds_for_tile_range(
self, zoom: int, tile_ranges: Tuple[Tuple[int, int], Tuple[int, int]]
) -> Optional[Tuple[float, float, float, float]]:
"""Calculate the precise geographic bounds covered by a given range of tiles."""
# (Implementation corrected in previous step)
log_prefix = f"{self._log_prefix} TileBounds"
if mercantile is None:
return None
try:
min_x, max_x = tile_ranges[0]
min_y, max_y = tile_ranges[1]
ul_bound = mercantile.bounds(min_x, min_y, zoom)
lr_bound = mercantile.bounds(max_x, max_y, zoom)
west = ul_bound[0]
east = lr_bound[2]
south = lr_bound[1]
north = ul_bound[3]
return (west, south, east, north)
except Exception as e:
logging.exception(
f"{log_prefix} Error calculating bounds for tile range: {e}"
)
return None
def _update_app_state_map_context(
self,
base_map_pil: Optional[ImageType],
bounds_deg: Optional[Tuple[float, float, float, float]],
zoom: Optional[int],
):
"""Safely updates map context information in AppState."""
# (Implementation unchanged)
log_prefix = f"{self._log_prefix} UpdateMapContext"
try:
self._app_state.last_map_image_pil = (
base_map_pil.copy() if base_map_pil else None
)
self._app_state.map_current_bounds_deg = bounds_deg
self._app_state.map_current_zoom = zoom
# Shape is updated later by display_map
logging.debug(
f"{log_prefix} Updated AppState: Bounds={bounds_deg}, Zoom={zoom}, BasePIL={'Set' if base_map_pil else 'None'}"
)
except Exception as e:
logging.exception(f"{log_prefix} Error updating AppState map context:")
# --- >>> START OF MODIFIED FUNCTION <<< ---
def _queue_map_for_display(self, image_pil: Optional[ImageType]):
"""Puts the final composed map image onto the Tkinter queue AND updates composed state."""
log_prefix = f"{self._log_prefix} QueueMap"
payload_type = type(image_pil).__name__ if image_pil else "None"
# --- Update composed map state ---
try:
self._app_state.last_composed_map_pil = (
image_pil.copy() if image_pil else None
)
logging.debug(
f"{log_prefix} Updated last_composed_map_pil in AppState (Type: {payload_type})."
)
except Exception as e:
logging.exception(
f"{log_prefix} Error updating last_composed_map_pil in AppState:"
)
# --- Queue for display ---
logging.debug(
f"{log_prefix} Queueing SHOW_MAP command (Payload type: {payload_type})"
)
put_queue(self._tkinter_queue, ("SHOW_MAP", image_pil), "tkinter", self._app)
#
def display_map(self, map_image_pil: Optional[ImageType]):
"""Instructs the MapDisplayWindow to show the provided map image and updates app state."""
# (Implementation unchanged)
log_prefix = f"{self._log_prefix} Display"
displayed_shape = None
if self._map_display_window:
try:
self._map_display_window.show_map(map_image_pil)
displayed_shape = self._map_display_window._last_displayed_shape
if (
displayed_shape
and displayed_shape[0] > 0
and displayed_shape[1] > 0
):
self._app_state.map_current_shape_px = displayed_shape
else:
self._app_state.map_current_shape_px = None
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:"
)
self._app_state.map_current_shape_px = None
else:
self._app_state.map_current_shape_px = None
def _update_app_status_after_map_load(self, success: bool):
"""Updates the main application status after the initial map load attempt."""
# (Implementation unchanged)
log_prefix = f"{self._log_prefix} Status Update"
try:
statusbar_ref = getattr(self._app, "statusbar", None)
if statusbar_ref and "Loading initial map" in statusbar_ref.cget("text"):
status_msg = "Error Loading Map"
if success: # Determine correct 'Ready' status
mode = self._app.state.test_mode_active
is_local = config.USE_LOCAL_IMAGES
is_net = not is_local and not mode
if mode:
status_msg = "Ready (Test Mode)"
elif is_local:
status_msg = "Ready (Local Mode)"
elif is_net:
status_msg = (
f"Listening UDP {self._app.local_ip}:{self._app.local_port}"
if self._app.udp_socket
else "Error: No Socket"
)
else:
status_msg = "Ready"
self._app.set_status(status_msg)
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."""
# (Implementation unchanged)
log_prefix = f"{self._log_prefix} Shutdown"
if self._map_display_window:
try:
self._map_display_window.destroy_window()
except Exception as e:
logging.exception(f"{log_prefix} Error destroying MapDisplayWindow:")
self._map_display_window = None
logging.debug(f"{log_prefix} Map integration shutdown complete.")
# --- Geo Calculation Helpers ---
# (_calculate_sar_corners_geo, _geo_coords_to_map_pixels - Unchanged)
def _calculate_sar_corners_geo(
self, geo_info: Dict[str, Any]
) -> Optional[List[Tuple[float, float]]]:
# (Implementation unchanged from previous version)
log_prefix = f"{self._log_prefix} SAR Corners Geo"
if not self._geod:
return None
try:
lat_rad, lon_rad = geo_info["lat"], geo_info["lon"]
orient_rad = geo_info["orientation"]
ref_x, ref_y = geo_info["ref_x"], geo_info["ref_y"]
scale_x, scale_y = geo_info["scale_x"], geo_info["scale_y"]
width, height = geo_info["width_px"], geo_info["height_px"]
calc_orient_rad = -orient_rad
if not (scale_x > 0 and scale_y > 0 and width > 0 and height > 0):
return None
corners_pixel = [
(0 - ref_x, ref_y - 0),
(width - 1 - ref_x, ref_y - 0),
(width - 1 - ref_x, ref_y - (height - 1)),
(0 - ref_x, ref_y - (height - 1)),
]
corners_meters = [(dx * scale_x, dy * scale_y) for dx, dy in corners_pixel]
corners_meters_rotated = []
if abs(calc_orient_rad) > 1e-6:
cos_o, sin_o = math.cos(calc_orient_rad), math.sin(calc_orient_rad)
corners_meters_rotated = [
(dx * cos_o - dy * sin_o, dx * sin_o + dy * cos_o)
for dx, dy in corners_meters
]
else:
corners_meters_rotated = corners_meters
corners_geo_deg = []
lon_deg, lat_deg = math.degrees(lon_rad), math.degrees(lat_rad)
for dx_m, dy_m in corners_meters_rotated:
dist = math.hypot(dx_m, dy_m)
az = math.degrees(math.atan2(dx_m, dy_m))
endlon, endlat, _ = self._geod.fwd(lon_deg, lat_deg, az, dist)
corners_geo_deg.append((endlon, endlat))
return corners_geo_deg if len(corners_geo_deg) == 4 else None
except KeyError as ke:
logging.error(f"{log_prefix} Missing key: {ke}")
return None
except Exception as e:
logging.exception(f"{log_prefix} Error:")
return None
def _geo_coords_to_map_pixels(
self,
coords_deg: List[Tuple[float, float]],
map_bounds: Tuple[float, float, float, float],
map_tile_ranges: Tuple[Tuple[int, int], Tuple[int, int]],
zoom: int,
stitched_map_shape: Tuple[int, int],
) -> Optional[List[Tuple[int, int]]]:
# (Implementation unchanged from previous version)
log_prefix = f"{self._log_prefix} Geo to Pixel"
if mercantile is None:
return None
if (
not stitched_map_shape
or stitched_map_shape[0] <= 0
or stitched_map_shape[1] <= 0
):
return None
pixel_coords = []
map_h, map_w = stitched_map_shape
map_west, map_south, map_east, map_north = map_bounds
try:
map_ul_x, map_ul_y = mercantile.xy(map_west, map_north)
map_lr_x, map_lr_y = mercantile.xy(map_east, map_south)
map_w_merc = map_lr_x - map_ul_x
map_h_merc = map_ul_y - map_lr_y
if map_w_merc <= 0 or map_h_merc <= 0:
return None
for lon, lat in coords_deg:
pt_x, pt_y = mercantile.xy(lon, lat)
rel_x = pt_x - map_ul_x
rel_y = map_ul_y - pt_y
px = int(round((rel_x / map_w_merc) * map_w))
py = int(round((rel_y / map_h_merc) * map_h))
pixel_coords.append(
(max(0, min(px, map_w - 1)), max(0, min(py, map_h - 1)))
)
return pixel_coords
except Exception as e:
logging.exception(f"{log_prefix} Error:")
return None
# --- Pixel to Geo Conversion ---
def get_geo_coords_from_map_pixel(
self, pixel_x: int, pixel_y: int
) -> Optional[Tuple[float, float]]:
"""Converts pixel coordinates from the map display window to geographic coordinates (lat, lon degrees)."""
# (Implementation unchanged from previous version)
log_prefix = f"{self._log_prefix} Pixel to Geo"
map_bounds_deg = self._app_state.map_current_bounds_deg
map_shape_px = self._app_state.map_current_shape_px
if (
map_bounds_deg is None
or map_shape_px is None
or map_shape_px[0] <= 0
or map_shape_px[1] <= 0
or mercantile is None
):
return None
map_h, map_w = map_shape_px
map_west, map_south, map_east, map_north = map_bounds_deg
try:
if not (0 <= pixel_x < map_w and 0 <= pixel_y < map_h):
return None # Outside bounds
map_ul_x, map_ul_y = mercantile.xy(map_west, map_north)
map_lr_x, map_lr_y = mercantile.xy(map_east, map_south)
map_w_merc = map_lr_x - map_ul_x
map_h_merc = map_ul_y - map_lr_y
if map_w_merc <= 0 or map_h_merc <= 0:
return None
rel_x = pixel_x / map_w
rel_y = pixel_y / map_h
pt_x = map_ul_x + (rel_x * map_w_merc)
pt_y = map_ul_y - (rel_y * map_h_merc)
lon_deg, lat_deg = mercantile.lnglat(pt_x, pt_y)
return (lat_deg, lon_deg)
except Exception as e:
logging.exception(f"{log_prefix} Error:")
return None
# --- Map Saving ---
def save_map_view_to_file(
self, directory: Optional[str] = None, filename: Optional[str] = None
) -> bool:
"""Saves the last composed map view (from AppState.last_composed_map_pil) to a file."""
# (Implementation unchanged from previous version)
log_prefix = f"{self._log_prefix} SaveMap"
image_to_save = self._app_state.last_composed_map_pil
if image_to_save is None:
self._app.set_status("Error: No map view to save.")
return False
if Image is None:
self._app.set_status("Error: Pillow library missing.")
return False
try:
save_dir_path: Path
if directory:
save_dir_path = Path(directory)
else:
app_path = (
Path(sys.executable).parent
if getattr(sys, "frozen", False)
else Path(sys.argv[0]).parent
)
save_dir_path = app_path / self.DEFAULT_SAVE_DIRECTORY
save_dir_path.mkdir(parents=True, exist_ok=True)
save_filename = (
(Path(filename).stem + ".png")
if filename
else f"map_view_{datetime.datetime.now():%Y%m%d_%H%M%S}.png"
)
full_save_path = save_dir_path / save_filename
if ImageDraw and ImageFont: # Add timestamp
try:
draw = ImageDraw.Draw(image_to_save)
font = (
ImageFont.truetype("arial.ttf", 14)
if ImageFont
else ImageFont.load_default()
)
text = f"Saved: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}"
draw.text(
(10, image_to_save.height - 20), text, font=font, fill=(0, 0, 0)
)
except Exception as draw_err:
logging.warning(
f"{log_prefix} Could not draw text on saved image: {draw_err}"
)
image_to_save.save(full_save_path, "PNG")
logging.info(f"{log_prefix} Map view saved to: {full_save_path}")
self._app.set_status(f"Map view saved: {save_filename}")
return True
except OSError as e:
logging.error(
f"{log_prefix} OS Error saving map view to '{full_save_path}': {e}"
)
self._app.set_status("Error: Failed to save map view (OS Error).")
return False
except Exception as e:
logging.exception(f"{log_prefix} Unexpected error saving map view:")
self._app.set_status("Error: Failed to save map view (Exception).")
return False
# --- Scale Bar Drawing ---
def _draw_scale_bar(self, image_pil: Optional[ImageType], latitude_deg: float, zoom: int) -> Optional[ImageType]:
"""Draws a simple scale bar onto the map image (PIL). Returns modified image or original on error."""
log_prefix = f"{self._log_prefix} ScaleBar"
if image_pil is None or not (Image and cv2):
return image_pil # Return original if no image or dependencies missing
try:
meters_per_pixel = calculate_meters_per_pixel(latitude_deg, zoom)
if meters_per_pixel is None or meters_per_pixel <= 0:
logging.warning(f"{log_prefix} Invalid meters/pixel ({meters_per_pixel}). Skipping scale bar.")
return image_pil # Cannot calculate scale
img_w, img_h = image_pil.size
# Target bar width relative to image width
target_bar_px = max(50, min(150, img_w // 5)) # Aim for ~1/5 width, 50-150px
# Predefined "nice" distances in kilometers
possible_distances_km = [
0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50,
100, 200, 500, 1000, 2000, 5000
]
best_dist_km = None # Initialize best distance found
min_diff = float("inf") # Initialize minimum difference
# Find the "nicest" distance that results in a bar length close to the target
for dist_km in possible_distances_km:
pixels_for_dist = (dist_km * 1000.0) / meters_per_pixel
# Ensure the calculated bar length is reasonable (e.g., >= 10 pixels)
if pixels_for_dist >= 10:
# --- >>> START OF FIX <<< ---
# Calculate difference *after* ensuring pixels_for_dist is valid
diff = abs(pixels_for_dist - target_bar_px)
# If this difference is smaller than the current minimum, update best distance
if diff < min_diff:
min_diff = diff
best_dist_km = dist_km
# --- >>> END OF FIX <<< ---
# If no suitable distance was found (e.g., very high zoom, small image)
if best_dist_km is None:
logging.warning(f"{log_prefix} Could not find suitable scale bar distance for zoom {zoom} and image width {img_w}. Skipping.")
return image_pil # Return original image
# Calculate final bar length in pixels for the best distance found
scale_distance_km = best_dist_km
scale_distance_meters = scale_distance_km * 1000.0
scale_bar_pixels = int(round(scale_distance_meters / meters_per_pixel))
# Final check if calculated bar is somehow too small (redundant due to pixels>=10 check)
if scale_bar_pixels < 10: return image_pil # Should not happen now
# --- Draw the scale bar using OpenCV ---
map_cv = cv2.cvtColor(np.array(image_pil), cv2.COLOR_RGB2BGR)
# Define bar parameters (position, size, color)
bar_x_start = 15 # X position of the start of the bar
bar_y = map_cv.shape[0] - 25 # Y position (near bottom)
bar_tick_height = 6 # Height of the end ticks
bar_thickness = 2
text_gap = 5 # Gap between bar and text label
text_color = (0, 0, 0) # Black
bar_color = (0, 0, 0) # Black
# Draw main horizontal line
cv2.line(map_cv, (bar_x_start, bar_y), (bar_x_start + scale_bar_pixels, bar_y), bar_color, bar_thickness)
# Draw vertical ticks at ends
cv2.line(map_cv, (bar_x_start, bar_y - bar_tick_height//2), (bar_x_start, bar_y + bar_tick_height//2), bar_color, bar_thickness)
cv2.line(map_cv, (bar_x_start + scale_bar_pixels, bar_y - bar_tick_height//2), (bar_x_start + scale_bar_pixels, bar_y + bar_tick_height//2), bar_color, bar_thickness)
# Prepare label text (e.g., "10 km", "500 m")
if scale_distance_km >= 1:
label = f"{int(scale_distance_km)} km" if scale_distance_km.is_integer() else f"{scale_distance_km:.1f} km"
else:
label = f"{int(scale_distance_meters)} m"
# Add text label above the bar
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.5
font_thickness = 1
(text_w, text_h), _ = cv2.getTextSize(label, font, font_scale, font_thickness)
# Calculate centered X position for the text
text_x = max(5, bar_x_start + (scale_bar_pixels // 2) - (text_w // 2))
# Calculate Y position above the bar
text_y = bar_y - bar_tick_height // 2 - text_gap
# Draw text
cv2.putText(map_cv, label, (text_x, text_y), font, font_scale, text_color, font_thickness, cv2.LINE_AA)
# Convert back to PIL image
image_pil_with_scale = Image.fromarray(cv2.cvtColor(map_cv, cv2.COLOR_BGR2RGB))
logging.debug(f"{log_prefix} Scale bar drawn ({label}, {scale_bar_pixels}px).")
return image_pil_with_scale
except Exception as e:
logging.exception(f"{log_prefix} Error drawing scale bar:")
return image_pil # Return original image on any unexpected error
def _get_fallback_map(self) -> Optional[ImageType]:
"""Returns the last known base map or None."""
# Helper to reduce code duplication in error handling
return self._app_state.last_map_image_pil
# --- END OF FILE map_integration.py ---