# 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