# image_processor.py import os import math import logging from typing import Optional, List, Dict, Tuple try: from PIL import Image, ImageDraw, ImageFont PIL_AVAILABLE = True except ImportError: PIL_AVAILABLE = False # Define dummy classes if PIL not available to prevent NameErrors elsewhere # if code relies on checking `isinstance(..., Image.Image)` etc. class Image: pass class ImageDraw: pass class ImageFont: pass logging.error( "Pillow library (PIL/Pillow) not found. Image processing features will be disabled." ) # --- Constants --- TILE_TEXT_COLOR = "white" TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)" # Semi-transparent black background PLACEHOLDER_COLOR = (128, 128, 128) # Gray RGB for missing tiles TILE_BORDER_COLOR = "red" TILE_BORDER_WIDTH = 1 # Thinner border # --- Font Loading --- DEFAULT_FONT = None if PIL_AVAILABLE: try: # Try common system font paths (adjust for your OS if needed) font_paths = [ "arial.ttf", # Windows "Arial.ttf", # Some Linux/Mac "LiberationSans-Regular.ttf",# Linux (common alternative) "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux (DejaVu) "/System/Library/Fonts/Helvetica.ttc", # macOS (Helvetica) - TTC needs index "/System/Library/Fonts/Arial.ttf", # macOS (Arial) ] font_size = 12 # Adjust font size if needed font_found = False for font_path in font_paths: try: # Handle TTC files if needed (requires specifying index) if font_path.lower().endswith(".ttc"): # Try index 0, common for regular style DEFAULT_FONT = ImageFont.truetype(font_path, font_size, index=0) else: DEFAULT_FONT = ImageFont.truetype(font_path, font_size) logging.info(f"Loaded font: {font_path} (size {font_size})") font_found = True break # Stop searching once found except IOError: logging.debug(f"Font not found or failed to load: {font_path}") continue # Try the next path except Exception as e_font: # Catch other potential errors like FreeType errors logging.warning(f"Error loading font {font_path}: {e_font}") continue # Fallback to default PIL font if no system font worked if not font_found: DEFAULT_FONT = ImageFont.load_default() logging.warning("Common system fonts not found or failed to load. Using default PIL font.") except Exception as e: # Catch errors during the font search logic itself logging.error(f"Error during font loading process: {e}") DEFAULT_FONT = ImageFont.load_default() if PIL_AVAILABLE else None def add_overlay_info( image: Optional[Image.Image], tile_name: str, source_text: Optional[str] = "NASADEM", corner: str = "bottom-right" # Allow specifying corner ) -> Optional[Image.Image]: """ Draws tile name and optional source information onto a PIL Image object. Args: image (Optional[Image.Image]): The PIL Image object to draw on. If None, returns None. tile_name (str): The base name of the tile (e.g., 'n45e007'). Uppercased automatically. source_text (Optional[str]): Text indicating the data source. If None, only tile name is shown. corner (str): Corner for the text ('bottom-right', 'top-left', etc. - currently only br). Returns: Optional[Image.Image]: The modified image object, or None if input was None or PIL unavailable. """ if not PIL_AVAILABLE or image is None: return None try: # Ensure image is in a mode that supports drawing with transparency (RGBA) if image.mode != "RGBA": # Convert to RGBA to allow semi-transparent background image = image.convert("RGBA") draw = ImageDraw.Draw(image) font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default() # Prepare text content display_text = tile_name.upper() if source_text: display_text += f"\nSource: {source_text}" # Calculate text bounding box using textbbox for better accuracy (requires Pillow >= 8.0) try: # textbbox returns (left, top, right, bottom) relative to the anchor (0,0) text_bbox = draw.textbbox((0, 0), display_text, font=font, spacing=2) # Add line spacing text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] except AttributeError: # Fallback for older Pillow versions using textsize logging.warning("Using legacy textsize, positioning might be less accurate.") # textsize might not handle multiline spacing well text_width, text_height = draw.textsize(display_text, font=font) # Manually add spacing approximation if needed for multiline fallback if "\n" in display_text: line_count = display_text.count("\n") + 1 # Approximate additional height (depends on font) text_height += line_count * 2 # Calculate position based on corner (only bottom-right implemented) margin = 5 if corner == "bottom-right": text_x = image.width - text_width - margin text_y = image.height - text_height - margin # Add other corners (top-left, top-right, bottom-left) here if needed # elif corner == "top-left": # text_x = margin # text_y = margin else: # Default to bottom-right if corner is unrecognized text_x = image.width - text_width - margin text_y = image.height - text_height - margin # Define background rectangle coordinates (slightly larger than text) bg_padding = 2 bg_coords = [ text_x - bg_padding, text_y - bg_padding, text_x + text_width + bg_padding, text_y + text_height + bg_padding, ] # Draw semi-transparent background rectangle draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR) # Draw the text itself # Use anchor='lt' with textbbox calculation for precise placement try: draw.text((text_x, text_y), display_text, fill=TILE_TEXT_COLOR, font=font, spacing=2, anchor="la") # Anchor left, top-of-ascent approx except TypeError: # Older Pillow might not support anchor draw.text((text_x, text_y), display_text, fill=TILE_TEXT_COLOR, font=font) return image except Exception as e: logging.error( f"Error adding overlay to image for {tile_name}: {e}", exc_info=True ) return image # Return original image on error def load_prepare_single_browse( image_path: Optional[str], tile_name: str ) -> Optional[Image.Image]: """ Loads a browse image from path and adds standard overlay information. Args: image_path (Optional[str]): Path to the browse image file. Can be None. tile_name (str): Base name of the tile (e.g., 'n45e007'). Used for overlay. Returns: Optional[Image.Image]: Prepared PIL Image object with overlay, or None if loading/processing fails or path is invalid. """ if not PIL_AVAILABLE: logging.warning("Cannot load/prepare image: Pillow (PIL) is not available.") return None if not image_path: logging.debug("No image path provided for loading.") return None if not os.path.exists(image_path): logging.warning(f"Browse image file not found: {image_path}") return None try: logging.debug(f"Loading browse image: {image_path}") # Use 'with' statement for automatic file closing with Image.open(image_path) as img: # Add overlay information # Convert to RGBA before adding overlay to handle transparency if needed img_prepared = add_overlay_info(img.convert("RGBA"), tile_name) return img_prepared except FileNotFoundError: # This case should be caught by os.path.exists, but handle defensively logging.error(f"File not found error during Image.open: {image_path}") return None except Exception as e: # Catch other PIL errors (e.g., broken image file) logging.error( f"Failed to load or process browse image {image_path}: {e}", exc_info=True ) return None def create_composite_area_image( tile_info_list: List[Dict], target_tile_size: Optional[Tuple[int, int]] = None ) -> Optional[Image.Image]: """ Creates a composite image by mosaicking available browse images for a given area. Draws a grid and labels each tile. Args: tile_info_list (List[Dict]): List of tile information dictionaries, typically from ElevationManager.get_area_tile_info. Each dict should contain 'latitude_coord', 'longitude_coord', 'browse_image_path', and 'tile_base_name'. target_tile_size (Optional[Tuple[int, int]]): If provided (width, height), resizes each tile to this size before pasting. If None, uses the maximum dimensions found among the loaded browse images. Returns: Optional[Image.Image]: A composite PIL Image object, or None if no valid tile info is provided, no browse images can be loaded, or an error occurs. """ if not PIL_AVAILABLE: logging.error("Cannot create composite image: Pillow (PIL) is not available.") return None if not tile_info_list: logging.warning("Cannot create composite image: No tile info provided.") return None logging.info(f"Attempting to create composite image for {len(tile_info_list)} tile locations.") # 1. Determine grid dimensions and range from tile coordinates lats = [info["latitude_coord"] for info in tile_info_list] lons = [info["longitude_coord"] for info in tile_info_list] if not lats or not lons: logging.warning("Tile info list seems empty or lacks coordinates.") return None # Cannot proceed without coordinates min_lat = min(lats) max_lat = max(lats) min_lon = min(lons) max_lon = max(lons) # Calculate number of tiles in each dimension (inclusive) num_lat_tiles = max_lat - min_lat + 1 num_lon_tiles = max_lon - min_lon + 1 # 2. Load available images and determine tile size tile_images: Dict[Tuple[int, int], Optional[Image.Image]] = {} # Store loaded images keyed by (lat, lon) max_w = 0 max_h = 0 loaded_image_count = 0 for info in tile_info_list: lat = info.get("latitude_coord") lon = info.get("longitude_coord") # Ensure coordinates are present if lat is None or lon is None: logging.warning(f"Skipping tile info entry due to missing coordinates: {info}") continue key = (lat, lon) img_path = info.get("browse_image_path") img = None # Reset img for each tile if img_path and os.path.exists(img_path): try: # Load image and convert to RGB (common base) with Image.open(img_path) as loaded_img: img = loaded_img.convert("RGB") tile_images[key] = img # If target size isn't fixed, track maximum dimensions found if target_tile_size is None: max_w = max(max_w, img.width) max_h = max(max_h, img.height) loaded_image_count += 1 logging.debug(f"Successfully loaded browse image for ({lat},{lon}): {img_path}") except Exception as e: logging.warning(f"Could not load or convert browse image {img_path}: {e}") tile_images[key] = None # Mark as failed to load else: # Mark as unavailable if path missing or file doesn't exist tile_images[key] = None if img_path: # Log if path was given but file missing logging.debug(f"Browse image file not found for ({lat},{lon}): {img_path}") else: # Log if path was missing in info logging.debug(f"No browse image path provided for ({lat},{lon}).") # Check if any images were actually loaded if loaded_image_count == 0: logging.warning("No browse images could be loaded for the specified area.") # Return None as no visual content is available return None # 3. Determine final tile dimensions for the composite grid if target_tile_size: tile_w, tile_h = target_tile_size logging.info(f"Using target tile size: {tile_w}x{tile_h}") else: tile_w, tile_h = max_w, max_h logging.info(f"Using maximum detected tile size: {tile_w}x{tile_h}") # Basic validation of determined tile size if tile_w <= 0 or tile_h <= 0: logging.error( f"Invalid calculated tile dimensions ({tile_w}x{tile_h}). Cannot create composite." ) return None # 4. Create the blank composite canvas total_width = num_lon_tiles * tile_w total_height = num_lat_tiles * tile_h logging.info( f"Creating composite canvas: {total_width}x{total_height} " f"({num_lon_tiles} columns x {num_lat_tiles} rows)" ) # Use RGBA to potentially allow transparent elements later if needed composite_img = Image.new("RGBA", (total_width, total_height), PLACEHOLDER_COLOR + (255,)) # Opaque placeholder draw = ImageDraw.Draw(composite_img) font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default() # 5. Iterate through the grid cells and paste images/draw info # Note: Image grid origin (0,0) is top-left. # Latitude decreases downwards (North is up). # Longitude increases rightwards (West is left). for r in range(num_lat_tiles): # Row index (0 to num_lat_tiles-1) lat_coord = max_lat - r # Latitude for this row (North at top) paste_y = r * tile_h # Y-coordinate for top edge of this row for c in range(num_lon_tiles): # Column index (0 to num_lon_tiles-1) lon_coord = min_lon + c # Longitude for this column (West at left) paste_x = c * tile_w # X-coordinate for left edge of this column key = (lat_coord, lon_coord) img = tile_images.get(key) # Get pre-loaded image (or None) # Paste the tile image if available if img: # Resize if necessary to match the determined tile size if img.width != tile_w or img.height != tile_h: try: # Use LANCZOS (a high-quality downsampling filter) img = img.resize((tile_w, tile_h), Image.Resampling.LANCZOS) except Exception as e_resize: logging.warning(f"Failed to resize image for ({lat_coord},{lon_coord}): {e_resize}") # Draw placeholder instead of pasting broken/unresizable image img = None # Reset img to None so placeholder is drawn below # Only paste if img is still valid after potential resize attempt if img: composite_img.paste(img, (paste_x, paste_y)) # else: image was None (missing or failed to load) - placeholder color remains # Draw border around the tile cell border_coords = [ paste_x, paste_y, paste_x + tile_w -1, paste_y + tile_h -1 ] draw.rectangle( border_coords, outline=TILE_BORDER_COLOR, width=TILE_BORDER_WIDTH ) # Draw tile name label in the bottom-right corner of the cell # MODIFIED: Removed the call to add_overlay_info here. # WHY: It was drawing on a temporary crop and the result wasn't used. # The direct drawing logic below achieves adding the tile name. # HOW: Deleted the line `add_overlay_info(...)`. tile_base_name_info = next((info['tile_base_name'] for info in tile_info_list if info.get("latitude_coord") == lat_coord and info.get("longitude_coord") == lon_coord), None) if tile_base_name_info: tile_name_text = tile_base_name_info.upper() else: # Fallback if info not found (shouldn't happen if logic is correct) # Construct name manually as fallback lat_prefix = "N" if lat_coord >= 0 else "S" lon_prefix = "E" if lon_coord >= 0 else "W" tile_name_text = f"{lat_prefix}{abs(lat_coord):02d}{lon_prefix}{abs(lon_coord):03d}" # Calculate text size and position for the tile name within its cell try: t_bbox = draw.textbbox((0, 0), tile_name_text, font=font) t_w = t_bbox[2] - t_bbox[0] t_h = t_bbox[3] - t_bbox[1] except AttributeError: t_w, t_h = draw.textsize(tile_name_text, font=font) # Fallback cell_margin = 3 text_pos_x = paste_x + tile_w - t_w - cell_margin text_pos_y = paste_y + tile_h - t_h - cell_margin # Draw background for text within the cell bg_coords_cell = [ text_pos_x - 1, text_pos_y - 1, text_pos_x + t_w + 1, text_pos_y + t_h + 1, ] draw.rectangle(bg_coords_cell, fill=TILE_TEXT_BG_COLOR) # Draw text within the cell try: draw.text((text_pos_x, text_pos_y), tile_name_text, fill=TILE_TEXT_COLOR, font=font, anchor="la") except TypeError: draw.text((text_pos_x, text_pos_y), tile_name_text, fill=TILE_TEXT_COLOR, font=font) logging.info("Composite image created successfully.") return composite_img