418 lines
18 KiB
Python
418 lines
18 KiB
Python
# 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 if "latitude_coord" in info]
|
|
lons = [info["longitude_coord"] for info in tile_info_list if "longitude_coord" in info]
|
|
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 |