689 lines
40 KiB
Python
689 lines
40 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.
|
|
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.
|
|
"""
|
|
# MODIFIED: Check for necessary libraries and map context at the start.
|
|
# WHY: Ensure conditions for conversion are met.
|
|
# HOW: Added checks.
|
|
if not MERCANTILE_LIB_AVAILABLE_DRAWING or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None:
|
|
logger.warning("Map context incomplete or mercantile missing for geo_to_pixel_on_unscaled_map conversion.")
|
|
return None
|
|
|
|
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("Unscaled map dimensions are zero, cannot convert geo to pixel.")
|
|
return None
|
|
|
|
try:
|
|
# Use mercantile to get Web Mercator coordinates of the unscaled map's corners
|
|
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore
|
|
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
|
|
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
|
|
|
|
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
|
|
logger.warning("Map Mercator extent is zero, cannot convert geo to pixel on unscaled map.")
|
|
return None
|
|
|
|
target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) # type: ignore
|
|
|
|
# Relative position within the *unscaled* map's Mercator extent
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
|
|
|
|
# Convert to pixel coordinates on the *unscaled* image
|
|
pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width))
|
|
pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height))
|
|
|
|
# Clamp to the boundaries of the unscaled image (allow slight overflow for boundary drawing)
|
|
# This clamping logic is more appropriate for drawing lines/points near edges
|
|
# A simple clamp to [0, dim-1] might clip drawing unexpectedly.
|
|
# A padding based on line thickness is better. Let's use a default padding.
|
|
# For point markers, clamping strictly to bounds is often preferred.
|
|
# Let's keep this helper simple and clamp to the image bounds.
|
|
px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width -1))
|
|
py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height -1))
|
|
|
|
return (px_clamped, py_clamped)
|
|
|
|
except Exception as e_geo_to_px_unscaled:
|
|
logger.exception(f"Error during geo_to_pixel_on_unscaled_map conversion for geo ({latitude_deg:.5f},{longitude_deg:.5f}): {e_geo_to_px_unscaled}")
|
|
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 |