289 lines
10 KiB
Python
289 lines
10 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
|
|
|
|
# Definisci classi dummy se PIL non c'è, per evitare errori altrove
|
|
class Image:
|
|
pass
|
|
|
|
class ImageDraw:
|
|
pass
|
|
|
|
class ImageFont:
|
|
pass
|
|
|
|
logging.error(
|
|
"Pillow library (PIL) not found. Image processing features will be disabled."
|
|
)
|
|
|
|
# Costanti per disegno (possono essere spostate in config se necessario)
|
|
TILE_TEXT_COLOR = "white"
|
|
TILE_TEXT_BG_COLOR = "black" # Sfondo opaco
|
|
PLACEHOLDER_COLOR = (128, 128, 128) # Grigio per placeholder RGB
|
|
TILE_BORDER_COLOR = "red"
|
|
TILE_BORDER_WIDTH = 2 # Spessore bordo griglia
|
|
|
|
# Carica font (una sola volta)
|
|
DEFAULT_FONT = None
|
|
if PIL_AVAILABLE:
|
|
try:
|
|
# Prova diversi font comuni
|
|
font_paths = [
|
|
"arial.ttf",
|
|
"LiberationSans-Regular.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
]
|
|
for font_path in font_paths:
|
|
try:
|
|
DEFAULT_FONT = ImageFont.truetype(
|
|
font_path, 12
|
|
) # Aumenta dimensione font
|
|
logging.info(f"Loaded font: {font_path}")
|
|
break
|
|
except IOError:
|
|
continue # Prova il prossimo
|
|
if DEFAULT_FONT is None:
|
|
DEFAULT_FONT = ImageFont.load_default()
|
|
logging.warning("Common system fonts not found, using default PIL font.")
|
|
except Exception as e:
|
|
logging.error(f"Error loading font: {e}")
|
|
DEFAULT_FONT = ImageFont.load_default() if PIL_AVAILABLE else None
|
|
|
|
|
|
def add_overlay_info(
|
|
image: Optional[Image.Image], tile_name: str, source_text: str = "NASADEM"
|
|
) -> Optional[Image.Image]:
|
|
"""
|
|
Draws tile name and source information onto a PIL Image object.
|
|
|
|
Args:
|
|
image (Optional[Image.Image]): The PIL Image object to draw on. If None, does nothing.
|
|
tile_name (str): The base name of the tile (e.g., 'n45e007').
|
|
source_text (str): Text indicating the data source.
|
|
|
|
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:
|
|
# Assicurati sia modificabile (RGB o RGBA)
|
|
if image.mode not in ["RGB", "RGBA"]:
|
|
image = image.convert("RGB")
|
|
|
|
draw = ImageDraw.Draw(image)
|
|
text = f"{tile_name.upper()}\nSource: {source_text}"
|
|
font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default()
|
|
|
|
# Calcola dimensioni testo per posizionamento
|
|
try:
|
|
# textbbox è più preciso ma richiede Pillow >= 8.0.0
|
|
text_bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = text_bbox[2] - text_bbox[0]
|
|
text_height = text_bbox[3] - text_bbox[1]
|
|
except AttributeError:
|
|
# Fallback per versioni Pillow più vecchie
|
|
text_width, text_height = draw.textsize(text, font=font)
|
|
|
|
margin = 5
|
|
text_x = image.width - text_width - margin
|
|
text_y = image.height - text_height - margin
|
|
|
|
# Disegna sfondo e testo
|
|
bg_coords = [
|
|
text_x - 2,
|
|
text_y - 2,
|
|
text_x + text_width + 2,
|
|
text_y + text_height + 2,
|
|
]
|
|
draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR)
|
|
draw.text((text_x, text_y), 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 # Restituisci immagine originale in caso di errore
|
|
|
|
|
|
def load_prepare_single_browse(
|
|
image_path: Optional[str], tile_name: str
|
|
) -> Optional[Image.Image]:
|
|
"""
|
|
Loads a browse image from path, adds overlay info.
|
|
|
|
Args:
|
|
image_path (Optional[str]): Path to the browse image file.
|
|
tile_name (str): Base name of the tile (e.g., 'n45e007').
|
|
|
|
Returns:
|
|
Optional[Image.Image]: Prepared PIL Image object or None if loading/processing fails.
|
|
"""
|
|
if not PIL_AVAILABLE or not image_path or not os.path.exists(image_path):
|
|
return None
|
|
|
|
try:
|
|
logging.debug(f"Loading browse image: {image_path}")
|
|
img = Image.open(image_path)
|
|
img_with_overlay = add_overlay_info(img, tile_name)
|
|
return img_with_overlay
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Failed to load or prepare 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 an area.
|
|
|
|
Args:
|
|
tile_info_list (List[Dict]): List of dictionaries from ElevationManager.get_area_tile_info.
|
|
target_tile_size (Optional[Tuple[int, int]]): If provided, resize each tile to this size.
|
|
Otherwise, uses max dimensions found.
|
|
|
|
Returns:
|
|
Optional[Image.Image]: Composite PIL Image object or None if no tiles or error.
|
|
"""
|
|
if not PIL_AVAILABLE or not tile_info_list:
|
|
logging.warning(
|
|
"Cannot create composite image: PIL unavailable or no tile info provided."
|
|
)
|
|
return None
|
|
|
|
logging.info(f"Creating composite image for {len(tile_info_list)} tile area.")
|
|
|
|
# Estrai coordinate min/max per determinare la griglia
|
|
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:
|
|
return None # Lista vuota?
|
|
min_lat, max_lat = min(lats), max(lats)
|
|
min_lon, max_lon = min(lons), max(lons)
|
|
|
|
num_lat_tiles = max_lat - min_lat + 1
|
|
num_lon_tiles = max_lon - min_lon + 1
|
|
|
|
# Carica immagini e determina dimensioni massime (o usa target)
|
|
tile_images: Dict[Tuple[int, int], Optional[Image.Image]] = {}
|
|
max_w, max_h = 0, 0
|
|
loaded_image_count = 0
|
|
|
|
for info in tile_info_list:
|
|
lat, lon = info["latitude_coord"], info["longitude_coord"]
|
|
key = (lat, lon)
|
|
img_path = info.get("browse_image_path")
|
|
img = None
|
|
if img_path and os.path.exists(img_path):
|
|
try:
|
|
img = Image.open(img_path).convert("RGB") # Carica come RGB
|
|
tile_images[key] = img
|
|
if not target_tile_size: # Se non forziamo dimensione, trova max
|
|
max_w = max(max_w, img.width)
|
|
max_h = max(max_h, img.height)
|
|
loaded_image_count += 1
|
|
except Exception as e:
|
|
logging.warning(f"Could not load browse image {img_path}: {e}")
|
|
tile_images[key] = None # Segna come non caricata
|
|
else:
|
|
tile_images[key] = None # Non disponibile
|
|
|
|
if loaded_image_count == 0:
|
|
logging.warning("No browse images available for the specified area.")
|
|
# Potremmo restituire None o un'immagine placeholder unica? Per ora None.
|
|
return None
|
|
|
|
# Imposta dimensione cella: target o massima trovata
|
|
tile_w = target_tile_size[0] if target_tile_size else max_w
|
|
tile_h = target_tile_size[1] if target_tile_size else max_h
|
|
if tile_w <= 0 or tile_h <= 0:
|
|
logging.error(
|
|
"Invalid target tile size or no valid images found to determine size."
|
|
)
|
|
return None # Dimensione non valida
|
|
|
|
# Crea immagine composita
|
|
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} ({num_lon_tiles}x{num_lat_tiles} tiles of {tile_w}x{tile_h})"
|
|
)
|
|
composite_img = Image.new("RGB", (total_width, total_height), PLACEHOLDER_COLOR)
|
|
draw = ImageDraw.Draw(composite_img)
|
|
|
|
# Incolla tile, disegna griglia e testo
|
|
for r, lat_coord in enumerate(
|
|
range(max_lat, min_lat - 1, -1)
|
|
): # Y cresce verso il basso (Nord in alto)
|
|
for c, lon_coord in enumerate(
|
|
range(min_lon, max_lon + 1)
|
|
): # X cresce verso destra (Ovest a sinistra)
|
|
key = (lat_coord, lon_coord)
|
|
img = tile_images.get(key)
|
|
|
|
paste_x = c * tile_w
|
|
paste_y = r * tile_h
|
|
|
|
if img:
|
|
# Ridimensiona se necessario
|
|
if img.width != tile_w or img.height != tile_h:
|
|
img = img.resize((tile_w, tile_h), Image.Resampling.LANCZOS)
|
|
composite_img.paste(img, (paste_x, paste_y))
|
|
|
|
# Disegna bordo
|
|
draw.rectangle(
|
|
[paste_x, paste_y, paste_x + tile_w - 1, paste_y + tile_h - 1],
|
|
outline=TILE_BORDER_COLOR,
|
|
width=TILE_BORDER_WIDTH,
|
|
)
|
|
|
|
# Aggiungi etichetta (riutilizza logica)
|
|
tile_name = f"{'N' if lat_coord >= 0 else 'S'}{abs(lat_coord):02d}{'E' if lon_coord >= 0 else 'W'}{abs(lon_coord):03d}"
|
|
add_overlay_info(
|
|
composite_img.crop(
|
|
(paste_x, paste_y, paste_x + tile_w, paste_y + tile_h)
|
|
), # Disegna su un crop temporaneo? No, direttamente sulla composita
|
|
tile_name,
|
|
source_text="",
|
|
) # Passa empty source? O solo nome tile?
|
|
|
|
# Disegna nome tile direttamente sulla composita
|
|
font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default()
|
|
text = tile_name
|
|
try:
|
|
text_bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_w = text_bbox[2] - text_bbox[0]
|
|
text_h = text_bbox[3] - text_bbox[1]
|
|
except AttributeError:
|
|
text_w, text_h = draw.textsize(text, font=font) # Fallback
|
|
margin = 3
|
|
text_pos_x = paste_x + tile_w - text_w - margin
|
|
text_pos_y = paste_y + tile_h - text_h - margin
|
|
bg_coords = [
|
|
text_pos_x - 1,
|
|
text_pos_y - 1,
|
|
text_pos_x + text_w + 1,
|
|
text_pos_y + text_h + 1,
|
|
]
|
|
draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR)
|
|
draw.text((text_pos_x, text_pos_y), text, fill=TILE_TEXT_COLOR, font=font)
|
|
|
|
logging.info("Composite image created successfully.")
|
|
return composite_img
|