# 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)