SXXXXXXX_GeoElevation/elevation_gui.py
2025-04-14 12:17:43 +02:00

465 lines
24 KiB
Python

# elevation_gui.py
import tkinter as tk
from tkinter import ttk, messagebox, font as tkfont
import logging
import math
import threading
import os # Necessario per path.basename
from typing import Optional, Tuple
# === NUOVI IMPORT ===
from PIL import Image, ImageTk, ImageDraw, ImageFont
# Import the manager class
from elevation_manager import ElevationManager
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Default directory for tiles
DEFAULT_TILE_DIR = "map_elevation"
# Costanti per la visualizzazione
CANVAS_BG_COLOR = "gray80"
TILE_BORDER_COLOR = "red"
TILE_TEXT_COLOR = "white"
TILE_TEXT_BG_COLOR = "black" # Sfondo semi-trasparente per il testo? Più complesso.
PLACEHOLDER_COLOR = "gray50"
class ElevationApp:
"""
Tkinter GUI application to get elevation data and pre-download DEM tiles for areas.
Displays browse images for downloaded tiles.
"""
def __init__(self, parent_widget: tk.Tk, elevation_manager: ElevationManager):
self.root = parent_widget
self.elevation_manager = elevation_manager
self.root.title("Elevation Finder & Downloader")
self.root.minsize(450, 650) # Aumenta altezza minima per canvas
# Memorizza le coordinate dell'ultima area richiesta per il display
self.last_area_coords: Optional[Tuple[float, float, float, float]] = None
# Memorizza riferimento all'oggetto PhotoImage per evitare garbage collection
self.current_photo_image: Optional[ImageTk.PhotoImage] = None
self.tile_images_cache = {} # Cache per le immagini PIL caricate
# --- Main Frame ---
main_frame = ttk.Frame(self.root, padding="10")
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(0, weight=1) # Permetti al main_frame di espandersi
# --- Sezioni Input e Download (come prima) ---
point_frame = ttk.LabelFrame(main_frame, text="Get Elevation for Point", padding="10")
point_frame.grid(row=0, column=0, padx=5, pady=5, sticky=(tk.W, tk.E))
point_frame.columnconfigure(1, weight=1)
# ... (widget Lat, Lon, Button, Result Label come prima) ...
ttk.Label(point_frame, text="Latitude:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
self.lat_entry = ttk.Entry(point_frame, width=15)
self.lat_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3)
self.lat_entry.insert(0, "45.0")
ttk.Label(point_frame, text="Longitude:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
self.lon_entry = ttk.Entry(point_frame, width=15)
self.lon_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3)
self.lon_entry.insert(0, "7.0")
self.get_elevation_button = ttk.Button(
point_frame, text="Get Elevation", command=self.calculate_single_elevation
)
self.get_elevation_button.grid(row=2, column=0, columnspan=2, pady=10, sticky=(tk.W, tk.E))
self.result_label = ttk.Label(point_frame, text="Result: ", wraplength=400, justify=tk.LEFT)
self.result_label.grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=5)
area_frame = ttk.LabelFrame(main_frame, text="Pre-Download Tiles for Area", padding="10")
area_frame.grid(row=1, column=0, padx=5, pady=5, sticky=(tk.W, tk.E))
area_frame.columnconfigure(1, weight=1)
area_frame.columnconfigure(3, weight=1)
# ... (widget Min/Max Lat/Lon, Button, Status Label come prima) ...
ttk.Label(area_frame, text="Min Lat:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3)
self.min_lat_entry = ttk.Entry(area_frame, width=10)
self.min_lat_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3)
self.min_lat_entry.insert(0, "44.0")
ttk.Label(area_frame, text="Max Lat:").grid(row=0, column=2, sticky=tk.W, padx=(10, 5), pady=3)
self.max_lat_entry = ttk.Entry(area_frame, width=10)
self.max_lat_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=5, pady=3)
self.max_lat_entry.insert(0, "46.0")
ttk.Label(area_frame, text="Min Lon:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3)
self.min_lon_entry = ttk.Entry(area_frame, width=10)
self.min_lon_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3)
self.min_lon_entry.insert(0, "6.0")
ttk.Label(area_frame, text="Max Lon:").grid(row=1, column=2, sticky=tk.W, padx=(10, 5), pady=3)
self.max_lon_entry = ttk.Entry(area_frame, width=10)
self.max_lon_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=5, pady=3)
self.max_lon_entry.insert(0, "8.0")
self.download_area_button = ttk.Button(
area_frame, text="Download Area Tiles", command=self.start_area_download_thread
)
self.download_area_button.grid(row=2, column=0, columnspan=4, pady=10, sticky=(tk.W, tk.E))
self.download_status_label = ttk.Label(area_frame, text="Status: Idle", wraplength=400, justify=tk.LEFT)
self.download_status_label.grid(row=3, column=0, columnspan=4, sticky=tk.W, pady=5)
# --- NUOVA Sezione: Visualizzazione Immagine ---
image_frame = ttk.LabelFrame(main_frame, text="Browse Image Preview", padding="10")
# sticky='nsew' permette al frame di espandersi con la finestra
image_frame.grid(row=2, column=0, padx=5, pady=10, sticky=(tk.W, tk.E, tk.N, tk.S))
# Configura il frame immagine per espandersi
image_frame.columnconfigure(0, weight=1)
image_frame.rowconfigure(0, weight=1)
# Crea il Canvas per l'immagine
# Aggiungi delle scrollbar se l'immagine è grande? Per ora no.
self.image_canvas = tk.Canvas(image_frame, bg=CANVAS_BG_COLOR, width=400, height=300) # Dimensioni iniziali
self.image_canvas.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
# Configura la riga/colonna del main_frame per dare spazio al canvas
main_frame.rowconfigure(2, weight=1)
main_frame.columnconfigure(0, weight=1)
# Prova a caricare un font per le etichette
try:
self.label_font = ImageFont.truetype("arial.ttf", 10) # Prova font comune
except IOError:
self.label_font = ImageFont.load_default() # Fallback
logging.warning("Arial font not found, using default PIL font.")
def _clear_canvas(self):
"""Pulisce il canvas e resetta l'immagine corrente."""
self.image_canvas.delete("all")
self.current_photo_image = None
self.tile_images_cache.clear() # Svuota cache immagini
def _validate_coordinates(self, lat_str: str, lon_str: str) -> Tuple[float, float]:
"""Helper to validate single point coordinates."""
try:
if not lat_str: raise ValueError("Latitude cannot be empty.")
lat = float(lat_str)
if not (-90 <= lat < 90): raise ValueError("Latitude must be between -90 and < 90.")
if not lon_str: raise ValueError("Longitude cannot be empty.")
lon = float(lon_str)
if not (-180 <= lon < 180): raise ValueError("Longitude must be between -180 and < 180.")
return lat, lon
except ValueError as e:
raise ValueError(f"Invalid input: {e}") from e
def calculate_single_elevation(self) -> None:
"""Handles 'Get Elevation' button click. Also displays browse image."""
self._clear_canvas() # Pulisci canvas precedente
self.result_label.config(text="Result: Processing...")
self.get_elevation_button.config(state=tk.DISABLED)
self.root.update_idletasks()
try:
latitude, longitude = self._validate_coordinates(
self.lat_entry.get(), self.lon_entry.get()
)
logging.info(f"GUI: Requesting elevation for Lat: {latitude}, Lon: {longitude}")
elevation_value = self.elevation_manager.get_elevation(latitude, longitude)
logging.info(f"GUI: Elevation manager returned: {elevation_value}")
if elevation_value is None:
result_text = "Result: Could not retrieve elevation (Tile unavailable or processing error)."
# Non mostrare immagine se i dati non ci sono
elif math.isnan(elevation_value):
result_text = "Result: Point is on a nodata area within the tile."
# Mostra comunque l'immagine browse se esiste
self.display_single_browse_image(latitude, longitude)
else:
result_text = f"Result: Elevation is {elevation_value:.2f} meters"
# Mostra l'immagine browse associata
self.display_single_browse_image(latitude, longitude)
self.result_label.config(text=result_text)
except ValueError as ve:
logging.warning(f"Input validation error: {ve}")
messagebox.showerror("Input Error", str(ve), parent=self.root)
self.result_label.config(text="Result: Invalid input.")
except Exception as e:
logging.exception("An unexpected error occurred during single point elevation retrieval.")
messagebox.showerror("Error", f"An unexpected error occurred: {e}", parent=self.root)
self.result_label.config(text="Result: Error during processing.")
finally:
self.get_elevation_button.config(state=tk.NORMAL)
def display_single_browse_image(self, lat: float, lon: float):
"""Carica e visualizza l'immagine browse per un singolo tile."""
self._clear_canvas()
logging.info(f"Attempting to display browse image for ({lat},{lon})")
browse_path = self.elevation_manager.get_browse_image_path(lat, lon)
if browse_path and os.path.exists(browse_path):
try:
# Apri immagine con Pillow
img = Image.open(browse_path).convert("RGBA") # Converti in RGBA per disegno alpha?
# Disegna info sul tile sull'immagine
draw = ImageDraw.Draw(img)
lat_coord, lon_coord = self.elevation_manager._get_tile_base_coordinates(lat, lon)
tile_name = self.elevation_manager._get_nasadem_tile_base_name(lat_coord, lon_coord)
text = f"{tile_name}\nSource: NASADEM HGT"
# Calcola posizione testo (basso a destra)
text_bbox = draw.textbbox((0, 0), text, font=self.label_font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
text_x = img.width - text_width - 10 # Margine 10px
text_y = img.height - text_height - 10
# Disegna rettangolo di sfondo (opzionale)
# draw.rectangle([text_x-2, text_y-2, text_x+text_width+2, text_y+text_height+2], fill=(0,0,0,128)) # Sfondo nero semi-trasp.
draw.rectangle([text_x-2, text_y-2, text_x+text_width+2, text_y+text_height+2], fill=TILE_TEXT_BG_COLOR) # Sfondo nero opaco
draw.text((text_x, text_y), text, fill=TILE_TEXT_COLOR, font=self.label_font)
# Ridimensiona immagine per adattarla al canvas mantenendo l'aspect ratio
img.thumbnail((self.image_canvas.winfo_width(), self.image_canvas.winfo_height()))
# Converti per Tkinter e visualizza
self.current_photo_image = ImageTk.PhotoImage(img)
self.image_canvas.create_image(0, 0, anchor=tk.NW, image=self.current_photo_image)
logging.info(f"Displayed browse image: {browse_path}")
except Exception as e:
logging.error(f"Error processing or displaying browse image {browse_path}: {e}", exc_info=True)
self.image_canvas.create_text(10, 10, anchor=tk.NW, text="Error loading image.", fill="red")
else:
logging.warning(f"Browse image not found locally for tile containing ({lat},{lon}).")
self.image_canvas.create_text(10, 10, anchor=tk.NW, text="Browse image not available.", fill="orange")
def _validate_area_bounds(self) -> Tuple[float, float, float, float]:
"""Helper to validate area download bounds."""
try:
min_lat = float(self.min_lat_entry.get())
max_lat = float(self.max_lat_entry.get())
min_lon = float(self.min_lon_entry.get())
max_lon = float(self.max_lon_entry.get())
if not (-90 <= min_lat < 90 and -90 <= max_lat < 90 and
-180 <= min_lon < 180 and -180 <= max_lon < 180):
raise ValueError("Coordinates out of valid range.")
if min_lat >= max_lat: raise ValueError("Min latitude must be less than Max latitude.")
if min_lon >= max_lon: raise ValueError("Min longitude must be less than Max longitude.")
return min_lat, min_lon, max_lat, max_lon
except ValueError as e:
raise ValueError(f"Invalid area input: {e}") from e
def start_area_download_thread(self):
"""Starts the area download process in a separate thread."""
self._clear_canvas() # Pulisci immagine precedente
try:
min_lat, min_lon, max_lat, max_lon = self._validate_area_bounds()
# Memorizza le coordinate per usarle dopo il download per visualizzare l'area
self.last_area_coords = (min_lat, min_lon, max_lat, max_lon)
self.download_area_button.config(state=tk.DISABLED)
self.download_status_label.config(text="Status: Starting download...")
self.root.update_idletasks()
download_thread = threading.Thread(
target=self._perform_area_download,
args=(min_lat, min_lon, max_lat, max_lon),
daemon=True
)
download_thread.start()
except ValueError as ve:
logging.warning(f"Area input validation error: {ve}")
messagebox.showerror("Input Error", str(ve), parent=self.root)
self.download_status_label.config(text="Status: Invalid input.")
self.last_area_coords = None # Resetta coords se input errato
except Exception as e:
logging.exception("Error initiating area download.")
messagebox.showerror("Error", f"Could not start download process: {e}", parent=self.root)
self.download_status_label.config(text="Status: Error starting download.")
self.download_area_button.config(state=tk.NORMAL)
self.last_area_coords = None
def _perform_area_download(self, min_lat, min_lon, max_lat, max_lon):
"""Download logic executed in the background thread."""
final_status = "Status: Unknown error."
success = False
processed = 0
obtained = 0
try:
logging.info(f"Thread: Calling download_area for Lat [{min_lat}, {max_lat}], Lon [{min_lon}, {max_lon}]")
self.root.after(0, lambda: self.download_status_label.config(
text="Status: Downloading/Extracting tiles... (See console log)"
))
processed, obtained = self.elevation_manager.download_area(min_lat, min_lon, max_lat, max_lon)
logging.info(f"Thread: Download complete. Processed: {processed}, Obtained HGT: {obtained}")
final_status = f"Status: Download complete. Processed {processed}, Obtained HGT {obtained}."
success = True # Assume successo se non ci sono eccezioni
except Exception as e:
logging.exception("Error during background area download execution.")
final_status = f"Status: Error during download: {type(e).__name__}"
success = False
# Aggiorna UI e visualizza immagine area (se successo)
self.root.after(0, self._update_download_ui_after_completion, final_status, success, processed, obtained)
def _update_download_ui_after_completion(self, status_message, download_success, processed, obtained):
"""Updates GUI after download thread finishes and triggers area image display."""
self.download_status_label.config(text=status_message)
self.download_area_button.config(state=tk.NORMAL)
# Mostra messaggio riassuntivo
if not download_success:
brief_error = status_message.split(":")[-1].strip()
messagebox.showerror("Download Error", f"Download process finished with error: {brief_error}\nCheck logs.", parent=self.root)
else:
summary = f"Processed {processed} tiles.\nObtained {obtained} HGT tiles."
messagebox.showinfo("Download Complete", summary, parent=self.root)
# Se il download è andato bene e avevamo coordinate valide, mostra l'immagine dell'area
if self.last_area_coords:
self.display_area_browse_images(*self.last_area_coords)
# Resetta le coordinate area dopo l'uso
# self.last_area_coords = None # Forse non resettare? Utile per vedere cosa si è chiesto
def display_area_browse_images(self, min_lat, min_lon, max_lat, max_lon):
"""Carica, cuce e visualizza le immagini browse per l'area scaricata."""
self._clear_canvas()
logging.info(f"Attempting to display browse images for area [{min_lat},{min_lon},{max_lat},{max_lon}]")
try:
# Calcola la griglia di tile necessaria
start_lat = math.floor(min_lat)
end_lat = math.floor(max_lat)
start_lon = math.floor(min_lon)
end_lon = math.floor(max_lon)
# Determina dimensioni griglia
num_lat_tiles = end_lat - start_lat + 1
num_lon_tiles = end_lon - start_lon + 1
if num_lat_tiles <= 0 or num_lon_tiles <= 0:
logging.warning("No tiles in the specified area range.")
self.image_canvas.create_text(10, 10, anchor=tk.NW, text="No tiles in selected area.", fill="orange")
return
# Carica le immagini (o crea placeholder se mancano)
tile_images = {}
max_tile_w, max_tile_h = 0, 0 # Per determinare dimensione cella griglia
for r, lat_coord in enumerate(range(end_lat, start_lat - 1, -1)): # Itera da Nord a Sud per visualizzazione mappa
for c, lon_coord in enumerate(range(start_lon, end_lon + 1)): # Itera da Ovest a Est
img_path = self.elevation_manager._get_local_browse_path(lat_coord, lon_coord) # Usa metodo interno per path
img = None
tile_key = (lat_coord, lon_coord)
if img_path and os.path.exists(img_path):
try:
# Usa cache per non ricaricare da disco se già fatto
if tile_key in self.tile_images_cache:
img = self.tile_images_cache[tile_key]
else:
img = Image.open(img_path).convert("RGB") # Converti in RGB per coerenza
self.tile_images_cache[tile_key] = img
max_tile_w = max(max_tile_w, img.width)
max_tile_h = max(max_tile_h, img.height)
except Exception as e:
logging.warning(f"Could not load browse image {img_path}: {e}")
else:
logging.debug(f"Browse image not found for tile ({lat_coord},{lon_coord}), using placeholder.")
# Memorizza immagine o None se non trovata/caricata
tile_images[tile_key] = img
if max_tile_w == 0 or max_tile_h == 0:
# Prova a stimare una dimensione se nessuna immagine è stata caricata
# Potremmo basarci sulla dimensione attesa (es. SRTM browse sono spesso ~1200x1200?)
# O più semplicemente, usa una dimensione fissa per il placeholder
max_tile_w, max_tile_h = 100, 100 # Dimensione placeholder
logging.warning("No browse images loaded, using default placeholder size.")
# Crea immagine composita
total_width = num_lon_tiles * max_tile_w
total_height = num_lat_tiles * max_tile_h
composite_img = Image.new('RGB', (total_width, total_height), PLACEHOLDER_COLOR) # Sfondo grigio
draw = ImageDraw.Draw(composite_img)
# Incolla i tile e disegna griglia/testo
for r, lat_coord in enumerate(range(end_lat, start_lat - 1, -1)): # Da Nord a Sud
for c, lon_coord in enumerate(range(start_lon, end_lon + 1)): # Da Ovest a Est
tile_key = (lat_coord, lon_coord)
img = tile_images.get(tile_key)
# Calcola posizione top-left del tile nella griglia composita
paste_x = c * max_tile_w
paste_y = r * max_tile_h
if img:
# Ridimensiona se necessario per adattarsi alla cella (se le img hanno dim diverse)
if img.width != max_tile_w or img.height != max_tile_h:
img = img.resize((max_tile_w, max_tile_h), Image.Resampling.LANCZOS) # Usa resampling di qualità
composite_img.paste(img, (paste_x, paste_y))
# Disegna bordo rosso
draw.rectangle(
[paste_x, paste_y, paste_x + max_tile_w -1, paste_y + max_tile_h -1],
outline=TILE_BORDER_COLOR, width=1
)
# Disegna nome tile in basso a destra della cella
tile_name = self.elevation_manager._get_nasadem_tile_base_name(lat_coord, lon_coord)
text = f"{tile_name}"
text_bbox = draw.textbbox((0, 0), text, font=self.label_font)
text_w = text_bbox[2] - text_bbox[0]
text_h = text_bbox[3] - text_bbox[1]
text_pos_x = paste_x + max_tile_w - text_w - 5 # Margine 5px
text_pos_y = paste_y + max_tile_h - text_h - 5
# Rettangolo sfondo testo
draw.rectangle([text_pos_x-1, text_pos_y-1, text_pos_x+text_w+1, text_pos_y+text_h+1], fill=TILE_TEXT_BG_COLOR)
draw.text((text_pos_x, text_pos_y), text, fill=TILE_TEXT_COLOR, font=self.label_font)
# Ridimensiona l'immagine composita per adattarla al canvas
composite_img.thumbnail((self.image_canvas.winfo_width(), self.image_canvas.winfo_height()), Image.Resampling.LANCZOS)
# Converti per Tkinter e visualizza
self.current_photo_image = ImageTk.PhotoImage(composite_img)
self.image_canvas.create_image(0, 0, anchor=tk.NW, image=self.current_photo_image)
logging.info(f"Displayed composite browse image for area.")
except Exception as e:
logging.error(f"Error creating or displaying composite browse image: {e}", exc_info=True)
self.image_canvas.create_text(10, 10, anchor=tk.NW, text="Error displaying area image.", fill="red")
# --- Main Execution Block (invariato) ---
if __name__ == "__main__":
try:
manager = ElevationManager(tile_directory=DEFAULT_TILE_DIR)
except Exception as e:
# ... (gestione errore init manager come prima) ...
logging.critical(f"Failed to initialize ElevationManager: {e}", exc_info=True)
try:
temp_root = tk.Tk(); temp_root.withdraw()
messagebox.showerror("Initialization Error", f"Failed to initialize Elevation Manager: {e}\nCheck config/permissions.", parent=None)
temp_root.destroy()
except tk.TclError: print(f"CRITICAL: Failed to initialize Elevation Manager: {e}")
exit(1)
try:
root_window = tk.Tk()
app = ElevationApp(root_window, manager)
root_window.mainloop()
except Exception as e:
# ... (gestione errore app come prima) ...
logging.critical(f"An error occurred running the application: {e}", exc_info=True)
try:
temp_root = tk.Tk(); temp_root.withdraw()
messagebox.showerror("Application Error", f"A critical error occurred: {e}\nCheck logs.", parent=None)
temp_root.destroy()
except tk.TclError: print(f"CRITICAL: Application failed unexpectedly: {e}")
exit(1)