SXXXXXXX_FlightMonitor/flightmonitor/map/map_drawing.py
2025-05-16 12:23:48 +02:00

765 lines
45 KiB
Python

# geoelevation/map_viewer/map_drawing.py
"""
Utility functions for drawing overlays on map images (PIL).
Handles conversions between geographic coordinates and pixel coordinates
on a stitched map image, and draws markers, boundaries, and labels.
"""
# Standard library imports
import logging
import math
from typing import Optional, Tuple, List, Dict, Any
# Third-party imports
try:
# MODIFIED: Ensure ImageFont is imported correctly from PIL.
# WHY: The ImageFont module is needed directly for font loading, not as a submodule of ImageDraw.
# HOW: Changed the import statement to include ImageFont at the top level.
from PIL import Image, ImageDraw, ImageFont # Import ImageFont for text size/position
PIL_LIB_AVAILABLE_DRAWING = True
except ImportError:
Image = None # type: ignore
ImageDraw = None # type: ignore # Define as None if import fails
# MODIFIED: Define dummy ImageFont as None if import fails.
# WHY: Ensures the ImageFont variable exists and is None if the import failed,
# allowing subsequent checks (e.g., `if ImageFont is None`) to work correctly.
# HOW: Added ImageFont = None.
ImageFont = None # type: ignore # Define as None if import fails
PIL_LIB_AVAILABLE_DRAWING = False
logging.error("MapDrawing: Pillow (PIL) library not found. Drawing operations will fail.")
try:
import cv2 # OpenCV for drawing markers (optional but used)
import numpy as np # Needed by cv2, also for calculations
CV2_NUMPY_LIBS_AVAILABLE_DRAWING = True
except ImportError:
cv2 = None # type: ignore
np = None # type: ignore
CV2_NUMPY_LIBS_AVAILABLE_DRAWING = False
logging.warning("MapDrawing: OpenCV or NumPy not found. Some drawing operations (markers) will be disabled.")
try:
import mercantile # For Web Mercator tile calculations and coordinate conversions
MERCANTILE_LIB_AVAILABLE_DRAWING = True
except ImportError:
mercantile = None # type: ignore
MERCANTILE_LIB_AVAILABLE_DRAWING = False
logging.error("MapDrawing: 'mercantile' library not found. Coordinate conversions will fail.")
# Local application/package imports
# Import constants and potentially shared font from image_processor for consistent style
try:
# MODIFIED: Import DEFAULT_FONT from image_processor directly.
# WHY: The font loading logic in image_processor is preferred.
# HOW: Imported DEFAULT_FONT.
from geoelevation.image_processor import TILE_TEXT_COLOR, TILE_TEXT_BG_COLOR, DEFAULT_FONT
# MODIFIED: Use the imported DEFAULT_FONT directly as the initial font source for labels.
# WHY: This font is loaded based on system availability in image_processor.
# HOW: Assigned DEFAULT_FONT to _DEFAULT_FONT_FOR_LABELS.
_DEFAULT_FONT_FOR_LABELS = DEFAULT_FONT # Use the font loaded in image_processor
# Reusing constants defined in geo_map_viewer for drawing styles
# MODIFIED: Import constants directly from geo_map_viewer.
# WHY: Use the defined constants for consistency.
# HOW: Added import.
from .geo_map_viewer import DEM_BOUNDARY_COLOR, DEM_BOUNDARY_THICKNESS_PX
from .geo_map_viewer import AREA_BOUNDARY_COLOR, AREA_BOUNDARY_THICKNESS_PX
from .geo_map_viewer import DEM_TILE_LABEL_BASE_FONT_SIZE, DEM_TILE_LABEL_BASE_ZOOM # For font scaling
except ImportError:
# MODIFIED: Set _DEFAULT_FONT_FOR_LABELS to None if image_processor import fails.
# WHY: This variable should be None if the preferred font loading method failed.
# HOW: Set the variable to None.
_DEFAULT_FONT_FOR_LABELS = None
# Fallback constants if geo_map_viewer or image_processor constants are not available
DEM_BOUNDARY_COLOR = "red"
DEM_BOUNDARY_THICKNESS_PX = 3
AREA_BOUNDARY_COLOR = "blue"
AREA_BOUNDARY_THICKNESS_PX = 2
TILE_TEXT_COLOR = "white"
TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)"
DEM_TILE_LABEL_BASE_FONT_SIZE = 12
DEM_TILE_LABEL_BASE_ZOOM = 10
logging.warning("MapDrawing: Could not import style constants or default font from image_processor/geo_map_viewer. Using fallbacks.")
# Import necessary map_utils functions
from .map_utils import get_hgt_tile_geographic_bounds # Needed for DEM bounds for drawing
from .map_utils import MapCalculationError # Re-raise calculation errors
# Module-level logger
logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.map_drawing'
# --- Helper for Geo to Pixel Conversion on Unscaled Stitched Image ---
def _geo_to_pixel_on_unscaled_map(
latitude_deg: float,
longitude_deg: float,
current_map_geo_bounds: Optional[Tuple[float, float, float, float]], # (west, south, east, north)
current_stitched_map_pixel_shape: Optional[Tuple[int, int]] # (height, width)
) -> Optional[Tuple[int, int]]:
"""
Converts geographic coordinates to pixel coordinates on the UN SCALED stitched PIL map image.
Args:
latitude_deg: Latitude in degrees.
longitude_deg: Longitude in degrees.
current_map_geo_bounds: The geographic bounds of the unscaled stitched map image.
(west_lon, south_lat, east_lon, north_lat)
current_stitched_map_pixel_shape: The pixel shape (height, width) of the unscaled image.
Returns:
A tuple (pixel_x, pixel_y) on the unscaled image, or None if conversion fails
or map context is incomplete or mercantile is not available.
"""
# Controlli iniziali per dipendenze e dati di input validi
if not MERCANTILE_LIB_AVAILABLE_DRAWING or mercantile is None:
logger.error("_geo_to_pixel_on_unscaled_map: Mercantile library is not available.")
return None
if current_map_geo_bounds is None:
logger.warning("_geo_to_pixel_on_unscaled_map: current_map_geo_bounds is None.")
return None
if current_stitched_map_pixel_shape is None:
logger.warning("_geo_to_pixel_on_unscaled_map: current_stitched_map_pixel_shape is None.")
return None
# Log degli input per debug
logger.debug(f"_geo_to_pixel_on_unscaled_map: INPUT -> lat={latitude_deg:.4f}, lon={longitude_deg:.4f}")
logger.debug(f"_geo_to_pixel_on_unscaled_map: CONTEXT -> map_bounds (W,S,E,N)={current_map_geo_bounds}, map_shape (H,W)={current_stitched_map_pixel_shape}")
unscaled_height, unscaled_width = current_stitched_map_pixel_shape
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds
if unscaled_width <= 0 or unscaled_height <= 0:
logger.warning(f"_geo_to_pixel_on_unscaled_map: Unscaled map dimensions are zero or invalid ({unscaled_width}x{unscaled_height}). Cannot convert.")
return None
# Validazione dei limiti geografici (opzionale ma buona pratica)
if not (-180.0 <= map_west_lon <= 180.0 and \
-180.0 <= map_east_lon <= 180.0 and \
-90.0 <= map_south_lat <= 90.0 and \
-90.0 <= map_north_lat <= 90.0 and \
map_south_lat < map_north_lat): # map_west_lon può essere > map_east_lon se attraversa l'antimeridiano
logger.warning(f"_geo_to_pixel_on_unscaled_map: Invalid current_map_geo_bounds: {current_map_geo_bounds}")
# Potrebbe non essere un errore fatale se mercantile.xy lo gestisce, ma è un avviso.
# Validazione delle coordinate di input
if not (-90.0 <= latitude_deg <= 90.0 and -180.0 <= longitude_deg <= 180.0):
logger.warning(f"_geo_to_pixel_on_unscaled_map: Input geo coordinates out of typical range: lat={latitude_deg}, lon={longitude_deg}")
# Comunque, mercantile.xy potrebbe gestire longitudini fuori da [-180, 180] normalizzandole.
try:
# mercantile.xy(lon, lat) -> (merc_x, merc_y)
# mercantile.ul(tile) -> (merc_x, merc_y) dell'angolo superiore sinistro della tile
# mercantile.bounds(tile) -> (west, south, east, north) della tile
# Coordinate Mercator degli angoli dell'immagine stitchata
# Angolo Superiore Sinistro (North-West)
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat)
# Angolo Inferiore Destro (South-East)
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat)
logger.debug(f"_geo_to_pixel_on_unscaled_map: Map Mercator UL(NW)=({map_ul_merc_x:.2f}, {map_ul_merc_y:.2f}), LR(SE)=({map_lr_merc_x:.2f}, {map_lr_merc_y:.2f})")
# Larghezza e altezza totali della mappa in coordinate Mercator
# mercantile.xy produce x crescenti verso Est e y crescenti verso Nord.
total_map_width_merc = map_lr_merc_x - map_ul_merc_x # east_merc - west_merc
total_map_height_merc = map_ul_merc_y - map_lr_merc_y # north_merc - south_merc (positivo)
# Gestione attraversamento antimeridiano per la larghezza Mercator
if map_west_lon > map_east_lon: # Es. West=170, East=-170 (cioè 190)
# L'approccio con mercantile.xy() su lon normalizzate dovrebbe già gestire questo.
# Se map_lr_merc_x < map_ul_merc_x, significa che l'asse x Mercator ha wrappato.
# La circonferenza del mondo in unità Mercator a zoom 0 è la dimensione di una tile (256) * 2^0 = 256
# No, è legata alla proiezione. mercantile.xy normalizza la longitudine.
# La larghezza in coordinate Mercator della mappa dovrebbe essere sempre positiva se i bound sono corretti.
# Mercantile usa longitudini normalizzate, quindi map_lr_merc_x dovrebbe essere > map_ul_merc_x
# se east_lon è a est di west_lon (anche attraverso l'antimeridiano).
# Esempio: mercantile.xy(170, 0) vs mercantile.xy(-170,0) --> mercantile.xy(190,0)
# Se mercantile.xy normalizza lon a [-180, 180], allora:
# mercantile.xy(170,0)[0] = 0.9722... * WORLD_SIZE_AT_ZOOM_0
# mercantile.xy(-170,0)[0] = -0.944... * WORLD_SIZE_AT_ZOOM_0
# In questo caso, se map_west_lon = 170 e map_east_lon = -170 (cioè 190),
# map_ul_merc_x (per 170) > map_lr_merc_x (per -170, che è 190).
# Quindi total_map_width_merc sarebbe negativo. Prendiamo l'assoluto.
pass # mercantile.xy dovrebbe gestire la normalizzazione, total_map_width_merc dovrebbe essere calcolato correttamente.
# Se map_west_lon > map_east_lon, mercantile.tiles() gestisce la copertura delle tile.
# Per _get_bounds_for_tile_range, otteniamo i bound corretti.
# Qui usiamo direttamente i bound geografici dell'immagine stitchata.
total_map_width_merc = abs(total_map_width_merc) # Assicuriamoci sia positivo
total_map_height_merc = abs(total_map_height_merc)
logger.debug(f"_geo_to_pixel_on_unscaled_map: Total Map Mercator Width={total_map_width_merc:.2f}, Height={total_map_height_merc:.2f}")
if total_map_width_merc <= 1e-6 or total_map_height_merc <= 1e-6: # Usa una piccola tolleranza per il float
logger.warning(f"_geo_to_pixel_on_unscaled_map: Map Mercator extent is zero or near-zero. Cannot convert.")
return None
# Coordinate Mercator del punto geografico target
target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg)
logger.debug(f"_geo_to_pixel_on_unscaled_map: Target Geo (lon={longitude_deg:.4f}, lat={latitude_deg:.4f}) -> Mercator (x={target_merc_x:.2f}, y={target_merc_y:.2f})")
# Calcola la posizione relativa del punto target all'interno dell'estensione Mercator della mappa
# X: (target_merc_x - angolo_sinistro_merc_x) / larghezza_totale_merc
# L'angolo sinistro in Mercator X è map_ul_merc_x (corrispondente a map_west_lon)
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc
# Y: (angolo_superiore_merc_y - target_merc_y) / altezza_totale_merc
# L'angolo superiore in Mercator Y è map_ul_merc_y (corrispondente a map_north_lat)
# Poiché pixel Y cresce verso il basso e Mercator Y cresce verso nord, questa sottrazione è corretta.
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc
logger.debug(f"_geo_to_pixel_on_unscaled_map: Relative Mercator Position in Map Frame: X={relative_merc_x_in_map:.6f}, Y={relative_merc_y_in_map:.6f}")
# Converti la posizione relativa in coordinate pixel sull'immagine non scalata
pixel_x_on_unscaled = relative_merc_x_in_map * unscaled_width
pixel_y_on_unscaled = relative_merc_y_in_map * unscaled_height
logger.debug(f"_geo_to_pixel_on_unscaled_map: Calculated Raw Pixel (float): ({pixel_x_on_unscaled:.2f}, {pixel_y_on_unscaled:.2f}) for image WxH ({unscaled_width},{unscaled_height})")
# Arrotonda ai pixel interi più vicini
rounded_pixel_x = int(round(pixel_x_on_unscaled))
rounded_pixel_y = int(round(pixel_y_on_unscaled))
# Clampa le coordinate pixel ai limiti dell'immagine
# Questo assicura che se un punto è geograficamente fuori dall'immagine,
# venga comunque disegnato sul bordo più vicino, invece di causare un errore
# o essere disegnato in una posizione completamente sbagliata se le coordinate relative fossero <0 o >1.
px_clamped = max(0, min(rounded_pixel_x, unscaled_width - 1))
py_clamped = max(0, min(rounded_pixel_y, unscaled_height - 1))
if rounded_pixel_x != px_clamped or rounded_pixel_y != py_clamped:
logger.debug(f"_geo_to_pixel_on_unscaled_map: Pixel coords were outside image bounds. Raw=({rounded_pixel_x},{rounded_pixel_y}), Clamped=({px_clamped},{py_clamped})")
logger.debug(f"_geo_to_pixel_on_unscaled_map: OUTPUT Clamped Pixel -> ({px_clamped}, {py_clamped})")
return (px_clamped, py_clamped)
except Exception as e_geo_to_px_unscaled:
logger.error(f"Error during _geo_to_pixel_on_unscaled_map conversion for geo (lat={latitude_deg:.5f}, lon={longitude_deg:.5f}): {e_geo_to_px_unscaled}", exc_info=True)
return None
# --- Drawing Functions (formerly methods of GeoElevationMapViewer) ---
def draw_point_marker(
pil_image_to_draw_on: Image.Image,
latitude_deg: float,
longitude_deg: float,
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
current_stitched_map_pixel_shape: Optional[Tuple[int, int]]
) -> Image.Image:
"""
Draws a point marker at specified geographic coordinates on the provided PIL Image.
Requires OpenCV and NumPy for drawing.
"""
# MODIFIED: Pass map context explicitly. Check for PIL_LIB_AVAILABLE_DRAWING.
# WHY: The function is now standalone and needs context. Ensure PIL is available.
if not PIL_LIB_AVAILABLE_DRAWING or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None:
logger.warning("Cannot draw point marker: PIL image or map context missing.")
return pil_image_to_draw_on # Return original image if drawing not possible
# Use the helper method to convert geo to pixel on the UN SCALED map image.
pixel_coords_on_unscaled = _geo_to_pixel_on_unscaled_map(
latitude_deg, longitude_deg,
current_map_geo_bounds, current_stitched_map_pixel_shape # Pass context to helper
)
if pixel_coords_on_unscaled:
px_clamped, py_clamped = pixel_coords_on_unscaled
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_NUMPY_LIBS_AVAILABLE_DRAWING and cv2 and np:
try:
# Convert PIL image to OpenCV format (BGR) for drawing
# Ensure image is in a mode OpenCV can handle (BGR)
# Converting here ensures drawing is possible if the input image was, e.g., L mode
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
except Exception as e_draw_click_cv:
logger.exception(f"Error drawing point 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 point marker using OpenCV.")
return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here
else:
logger.warning(f"Geo-to-pixel conversion failed for ({latitude_deg:.5f},{longitude_deg:.5f}), cannot draw point marker.")
return pil_image_to_draw_on # Return original image
def draw_area_bounding_box(
pil_image_to_draw_on: Image.Image,
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
current_stitched_map_pixel_shape: Optional[Tuple[int, int]],
color: str = "blue", # Allow specifying color
thickness: int = 2 # Allow specifying thickness
) -> Image.Image:
"""
Draws an area bounding box on the provided PIL Image.
Requires PIL and ImageDraw.
"""
# MODIFIED: Pass map context explicitly. Check for PIL_LIB_AVAILABLE_DRAWING and ImageDraw.
# WHY: The function is now standalone and needs context. Ensure PIL/ImageDraw are available.
# MODIFIED: Use predefined DEM boundary style constants.
# WHY: Centralize styling.
# HOW: Pass DEM_BOUNDARY_COLOR and DEM_BOUNDARY_THICKNESS_PX to draw_area_bounding_box.
if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None:
logger.warning("Cannot draw area BBox: PIL image, ImageDraw, or map context missing.")
return pil_image_to_draw_on # Return original image if drawing not possible
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:
# Convert all corners to pixel coordinates on the *unscaled* image, suitable for drawing lines.
# Recalculate pixel coords relative to the unscaled map using mercantile for line drawing accuracy,
# and clamp with padding.
unscaled_height, unscaled_width = current_stitched_map_pixel_shape
map_west_lon_stitched, map_south_lat_stitched, map_east_lon_stitched, map_north_lat_stitched = current_map_geo_bounds
map_ul_merc_x_stitched, map_ul_merc_y_stitched = mercantile.xy(map_west_lon_stitched, map_north_lat_stitched) # type: ignore
map_lr_merc_x_stitched, map_lr_merc_y_stitched = mercantile.xy(map_east_lon_stitched, map_south_lat_stitched) # type: ignore
total_map_width_merc = abs(map_lr_merc_x_stitched - map_ul_merc_x_stitched)
total_map_height_merc = abs(map_ul_merc_y_stitched - map_lr_merc_y_stitched)
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
raise ValueError("Map Mercator extent is zero for drawing lines.")
import mercantile as local_mercantile # Use mercantile directly here
if local_mercantile is None: raise ImportError("mercantile not available locally.")
for lon, lat in corners_geo:
target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x_stitched) / total_map_width_merc if total_map_width_merc > 0 else 0.0
relative_merc_y_in_map = (map_ul_merc_y_stitched - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width))
pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height))
# Clamp with padding for line drawing - allow coordinates slightly outside image bounds
px_clamped_for_line = max(-thickness, min(pixel_x_on_unscaled_raw, unscaled_width + thickness))
py_clamped_for_line = max(-thickness, min(pixel_y_on_unscaled_raw, unscaled_height + thickness))
pixel_corners.append((px_clamped_for_line, py_clamped_for_line))
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. (Already checked at function start, but defensive)
# 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)
# Converting here ensures drawing is possible if the input image was, e.g., L mode
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
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
try:
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
def draw_dem_tile_boundary(
pil_image_to_draw_on: Image.Image,
dem_tile_geo_bbox: Tuple[float, float, float, float],
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
current_stitched_map_pixel_shape: Optional[Tuple[int, int]]
) -> Image.Image:
"""
Draws a boundary box for a single DEM tile on the provided PIL Image.
Requires PIL and ImageDraw. Uses predefined DEM boundary style.
"""
# MODIFIED: Pass map context explicitly. Check for PIL_LIB_AVAILABLE_DRAWING and ImageDraw.
# WHY: The function is now standalone and needs context. Ensure PIL/ImageDraw are available.
# MODIFIED: Use predefined DEM boundary style constants.
# WHY: Centralize styling.
# HOW: Pass DEM_BOUNDARY_COLOR and DEM_BOUNDARY_THICKNESS_PX to draw_area_bounding_box.
if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None:
logger.warning("Cannot draw DEM tile boundary: PIL image, ImageDraw, or map context missing.")
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 draw_area_bounding_box(
pil_image_to_draw_on,
dem_tile_geo_bbox,
current_map_geo_bounds,
current_stitched_map_pixel_shape,
color=DEM_BOUNDARY_COLOR,
thickness=DEM_BOUNDARY_THICKNESS_PX
)
def draw_dem_tile_boundaries_with_labels(
pil_image_to_draw_on: Image.Image,
dem_tiles_info_list: List[Dict],
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
current_map_render_zoom: Optional[int], # Needed for font scaling
current_stitched_map_pixel_shape: Optional[Tuple[int, int]]
) -> Image.Image:
"""
Draws boundaries and names for a list of DEM tiles on the provided PIL Image.
Draws only for tiles marked as available HGT.
Requires PIL, ImageDraw, and map_utils.get_hgt_tile_geographic_bounds.
"""
# MODIFIED: Pass map context explicitly. Check for necessary libraries and context.
# WHY: The function is now standalone and needs context. Ensure dependencies are available.
if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_map_render_zoom is None or current_stitched_map_pixel_shape is None:
logger.warning("Cannot draw multiple DEM tile boundaries/labels: PIL image, ImageDraw, or map context missing.")
return pil_image_to_draw_on
if not dem_tiles_info_list:
logger.debug("No DEM tile info provided for drawing multiple boundaries.")
return pil_image_to_draw_on # Nothing to draw
logger.debug(f"Drawing {len(dem_tiles_info_list)} DEM tile boundaries and labels.")
# Ensure image is in a mode that supports drawing (RGB or RGBA)
# Converting here ensures drawing is possible if the input image was, e.g., L mode
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA")
draw = ImageDraw.Draw(pil_image_to_draw_on)
# Attempt to use a font loaded in image_processor for consistency (_DEFAULT_FONT_FOR_LABELS)
# If that failed (e.g., image_processor not imported or font load failed there),
# fallback to default PIL font if ImageFont module is available.
font_to_use = _DEFAULT_FONT_FOR_LABELS
# MODIFIED: Corrected the fallback logic for loading the default PIL font.
# WHY: The previous attempt `ImageDraw.ImageFont.load_default()` caused an AttributeError.
# The correct way is to use `ImageFont.load_default()` if the `ImageFont` module
# was successfully imported at the top of the file.
# HOW: Changed `ImageDraw.ImageFont.load_default()` to `ImageFont.load_default()` and added a check
# `if ImageFont is not None` to ensure the module is available before calling its method.
if font_to_use is None: # If the preferred font from image_processor is not available
if PIL_LIB_AVAILABLE_DRAWING and ImageFont is not None: # Check if PIL and ImageFont module are available
try:
font_to_use = ImageFont.load_default() # type: ignore # Use the correct way to load default font
logger.debug("Using default PIL font for DEM tile labels (fallback).")
except Exception as e_default_font:
logger.warning(f"Could not load default PIL font: {e_default_font}. Cannot draw text labels.")
font_to_use = None # Ensure font_to_use is None if loading default also fails
else:
logger.debug("Pillow (PIL) or ImageFont module not available, skipping text label drawing.")
# font_to_use remains None, text drawing logic will be skipped below.
# MODIFIED: Calculate font size based on current map zoom.
# WHY: To make labels more readable at different zoom levels.
# HOW: Use a simple linear scaling based on a base zoom and base font size.
current_map_zoom = current_map_render_zoom # Use the zoom level the map was rendered at
if current_map_zoom is None: # Should not be None based on function signature and checks, but defensive
logger.warning("Current map zoom is None, cannot scale font. Using base size.")
current_map_zoom = DEM_TILE_LABEL_BASE_ZOOM # Default to base zoom for size calc
# Simple linear scaling: size increases by 1 for each zoom level above base zoom
# Ensure minimum sensible font size (e.g., 6)
scaled_font_size = max(6, DEM_TILE_LABEL_BASE_FONT_SIZE + (current_map_zoom - DEM_TILE_LABEL_BASE_ZOOM))
logger.debug(f"Calculated label font size {scaled_font_size} for zoom {current_map_zoom}.")
# Update the font instance with the calculated size (if using truetype font)
# If load_default is used, resizing is often not possible or behaves differently.
# Let's re-load the font with the scaled size if it's a truetype font.
# This requires knowing the path of the original font used by image_processor, which is tricky.
# Alternative: Store the font path and size calculation logic from image_processor here.
# Or, maybe simpler, if using load_default fallback, just use the default size.
# Let's assume if _DEFAULT_FONT_FOR_LABELS is not None and has the 'font' attribute, it's a truetype font we can resize.
# Also need to check if ImageFont module is available before using ImageFont.truetype.
if font_to_use and hasattr(font_to_use, 'font') and ImageFont is not None: # Check if it looks like a truetype font object and ImageFont module is available
try:
# Get the original font object's path and index from Pillow's internal structure
original_font_path = font_to_use.font.path # type: ignore
font_index = font_to_use.font.index # type: ignore # Handle TTC files
# MODIFIED: Call ImageFont.truetype correctly.
# WHY: Ensure the call uses the correctly imported module.
# HOW: Used ImageFont.truetype instead of ImageDraw.ImageFont.truetype.
font_to_use = ImageFont.truetype(original_font_path, scaled_font_size, index=font_index) # type: ignore
logger.debug(f"Resized truetype font to {scaled_font_size}.")
except Exception as e_resize_font:
logger.warning(f"Could not resize truetype font: {e_resize_font}. Using original size.")
# Keep the font_to_use as it was (original size)
for tile_info in dem_tiles_info_list:
# Draw only if HGT data is available for this tile
if not tile_info.get("hgt_available"):
logger.debug(f"Skipping drawing boundary/label for tile {tile_info.get('tile_base_name', '?')}: HGT not available.")
continue # Skip this tile if no HGT
lat_coord = tile_info.get("latitude_coord")
lon_coord = tile_info.get("longitude_coord")
tile_base_name = tile_info.get("tile_base_name")
if lat_coord is None or lon_coord is None or tile_base_name is None:
logger.warning(f"Skipping drawing for invalid tile info entry: {tile_info}")
continue
try:
# Get the precise geographic bounds for this HGT tile
tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord)
if not tile_geo_bbox:
logger.warning(f"Could not get geographic bounds for tile ({lat_coord},{lon_coord}), skipping drawing.")
continue # Skip tile if bounds calculation fails
west, south, east, north = tile_geo_bbox
# Corners of this specific tile's bbox in geographic degrees
tile_corners_geo = [(west, north), (east, north), (east, south), (west, south)]
tile_pixel_corners_on_unscaled: List[Tuple[int,int]] = []
# Convert tile corners to unscaled pixel coordinates, suitable for drawing lines.
# Recalculate pixel coords relative to the unscaled map using mercantile for line drawing accuracy,
# and clamp with padding.
if current_map_geo_bounds is not None and current_stitched_map_pixel_shape is not None:
unscaled_height, unscaled_width = current_stitched_map_pixel_shape
map_west_lon_stitched, map_south_lat_stitched, map_east_lon_stitched, map_north_lat_stitched = current_map_geo_bounds
map_ul_merc_x_stitched, map_ul_merc_y_stitched = mercantile.xy(map_west_lon_stitched, map_north_lat_stitched) # type: ignore
map_lr_merc_x_stitched, map_lr_merc_y_stitched = mercantile.xy(map_east_lon_stitched, map_south_lat_stitched) # type: ignore
total_map_width_merc = abs(map_lr_merc_x_stitched - map_ul_merc_x_stitched)
total_map_height_merc = abs(map_ul_merc_y_stitched - map_lr_merc_y_stitched)
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
logger.warning("Map Mercator extent is zero for drawing tile boundaries.")
continue # Skip this tile if map extent is zero
import mercantile as local_mercantile # Use mercantile directly here
if local_mercantile is None: raise ImportError("mercantile not available locally.")
for lon, lat in tile_corners_geo:
target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x_stitched) / total_map_width_merc if total_map_width_merc > 0 else 0.0
relative_merc_y_in_map = (map_ul_merc_y_stitched - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width))
pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height))
# Clamp with padding for line drawing - allow coordinates slightly outside image bounds
px_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_x_on_unscaled_raw, unscaled_width + DEM_BOUNDARY_THICKNESS_PX))
py_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_y_on_unscaled_raw, unscaled_height + DEM_BOUNDARY_THICKNESS_PX))
tile_pixel_corners_on_unscaled.append((px_clamped_for_line, py_clamped_for_line))
else:
logger.warning(f"Unscaled map dimensions or geo bounds not available, cannot clamp pixel corners for tile ({lat_coord},{lon_coord}).")
continue # Skip this tile if map context is missing
if len(tile_pixel_corners_on_unscaled) == 4:
# Draw the tile boundary (red)
draw.line([tile_pixel_corners_on_unscaled[0], tile_pixel_corners_on_unscaled[1]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Top
draw.line([tile_pixel_corners_on_unscaled[1], tile_pixel_corners_on_unscaled[2]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Right
draw.line([tile_pixel_corners_on_unscaled[2], tile_pixel_corners_on_unscaled[3]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Bottom
draw.line([tile_pixel_corners_on_unscaled[3], tile_pixel_corners_on_unscaled[0]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Left
# --- Draw Tile Name Label ---
label_text = tile_base_name.upper()
# Find pixel position for label - bottom-right corner area of the tile's pixel box.
# Get the precise unscaled pixel coords for the SE corner using the helper (which clamps to edge)
se_pixel_on_unscaled = _geo_to_pixel_on_unscaled_map(
south, east,
current_map_geo_bounds, # Pass context to helper
current_stitched_map_pixel_shape # Pass context to helper
)
label_margin = 3 # Small margin from the border
# Draw text only if a font is available and position is calculable
# MODIFIED: Check if font_to_use is not None before attempting to use it.
# WHY: The font might not have been loaded due to missing libraries or errors.
# HOW: Added the check.
if font_to_use is not None and se_pixel_on_unscaled and current_stitched_map_pixel_shape is not None:
try:
unscaled_height, unscaled_width = current_stitched_map_pixel_shape
se_px, se_py = se_pixel_on_unscaled
# Calculate text size using textbbox (requires Pillow >= 8.0)
# Use the bottom-right corner pixel as the anchor point for calculation (not drawing)
try:
# textbbox relative to (0,0)
# MODIFIED: Use font_to_use variable directly instead of ImageFont.truetype.
# WHY: font_to_use already holds the appropriate font object (potentially scaled).
# HOW: Changed font parameter in textbbox and text calls.
text_bbox = draw.textbbox((0,0), label_text, font=font_to_use) # type: ignore
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
# Target bottom-right corner for text drawing relative to the unscaled image pixel
target_text_br_x = se_px - label_margin
target_text_br_y = se_py - label_margin
# Top-left corner for drawing the text based on target bottom-right and text size
label_text_x = target_text_br_x - text_width
label_text_y = target_text_br_y - text_height
# Clamp text position to be within the visible area of the unscaled map image
label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1))
label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1))
# Draw background rectangle for the text
bg_padding = 1 # Small padding around text background
bg_coords = [
label_text_x - bg_padding,
label_text_y - bg_padding,
label_text_x + text_width + bg_padding,
label_text_y + text_height + bg_padding,
]
draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR)
# Draw the text itself
draw.text((label_text_x, label_text_y), label_text, fill=TILE_TEXT_COLOR, font=font_to_use) # type: ignore
except AttributeError:
# Fallback for older Pillow versions using textsize
logger.warning("Pillow textbbox not available (Pillow < 8.0). Using textsize fallback for labels.")
# MODIFIED: Use font_to_use variable directly.
# WHY: Ensure the correct font object is used.
# HOW: Changed font parameter.
text_width, text_height = draw.textsize(label_text, font=font_to_use) # type: ignore
# Rough position calculation based on textsize
if current_stitched_map_pixel_shape and se_pixel_on_unscaled:
unscaled_height, unscaled_width = current_stitched_map_pixel_shape
se_px, se_py = se_pixel_on_unscaled
label_text_x = se_px - text_width - label_margin
label_text_y = se_py - text_height - label_margin
# Clamp text position to be within the visible area
label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1))
label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1))
# Draw background
bg_padding = 1
bg_coords = [label_text_x - bg_padding, label_text_y - bg_padding,
label_text_x + text_width + bg_padding, label_text_y + text_height + bg_padding]
draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR)
# Draw text using font fallback
# MODIFIED: Use font_to_use variable directly.
# WHY: Ensure the correct font object is used.
# HOW: Changed font parameter.
draw.text((label_text_x, label_text_y), label_text, fill=TILE_TEXT_COLOR, font=font_to_use) # type: ignore
else:
logger.warning(f"Could not get SE pixel coords for tile ({lat_coord},{lon_coord}) label positioning (textsize fallback).")
except Exception as e_draw_label:
logger.warning(f"Error drawing label '{label_text}' for tile ({lat_coord},{lon_coord}): {e_draw_label}")
else:
# This log message is now accurate as the outer if font_to_use check handles the case where no font was loaded.
logger.debug(f"No font available, skipping drawing label '{label_text}' for tile ({lat_coord},{lon_coord}).")
except ValueError as ve: # Catch explicit ValueErrors raised for conversion/drawing issues for a single tile
logger.warning(f"Value error during drawing for tile ({lat_coord},{lon_coord}): {ve}. Skipping this tile.")
pass # Skip this tile and continue with the next
except Exception as e_draw_tile:
logger.exception(f"Unexpected error drawing boundary/label for tile ({lat_coord},{lon_coord}): {e_draw_tile}. Skipping this tile.")
pass # Skip this tile and continue with the next
# Return the image with all drawn boundaries and labels
return pil_image_to_draw_on
def draw_user_click_marker(
pil_image_to_draw_on: Image.Image,
last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]],
current_display_scale_factor: float,
current_stitched_map_pixel_shape: Optional[Tuple[int, int]]
) -> Optional[Image.Image]:
"""
Draws a marker at the last user-clicked pixel location on the (unscaled) PIL map image.
Requires OpenCV and NumPy for drawing.
"""
# MODIFIED: Pass map context explicitly. Check for necessary libraries and context.
# WHY: The function is now standalone and needs context. Ensure dependencies are available.
if not PIL_LIB_AVAILABLE_DRAWING or pil_image_to_draw_on is None or not CV2_NUMPY_LIBS_AVAILABLE_DRAWING or cv2 is None or np is None or current_stitched_map_pixel_shape is None or last_user_click_pixel_coords_on_displayed_image is None:
logger.debug("Conditions not met for drawing user click marker (no click, no image, no context, or libraries missing).")
return pil_image_to_draw_on # Return original image if drawing not possible
# Unscale the click coordinates from displayed (scaled) image to original stitched image coordinates
clicked_px_scaled, clicked_py_scaled = last_user_click_pixel_coords_on_displayed_image
# Get the shape of the original unscaled stitched image
unscaled_height, unscaled_width = current_stitched_map_pixel_shape
# Calculate the unscaled pixel coordinates corresponding to the clicked scaled pixel
unscaled_target_px = int(round(clicked_px_scaled / current_display_scale_factor))
unscaled_target_py = int(round(clicked_py_scaled / 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. (Already checked at start, but defensive).
# 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)
# Converting here ensures drawing is possible if the input image was, e.g., L mode
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