# geoelevation/elevation_gui.py """ Provides the main graphical user interface (GUI) for the GeoElevation application. Allows users to get elevation for points, download DEM data for areas, and visualize this data in 2D (browse images, area composites) and 3D (DEM tiles). Integrates an optional map viewer functionality to display OpenStreetMap tiles, apply a user-selected display scale, and interact with the map to get elevation data for clicked points. """ # Standard library imports import tkinter as tk from tkinter import ttk from tkinter import messagebox import logging import math import multiprocessing import threading import os import sys # Used in __main__ block for test exit import queue # For GUI communication with map viewer process from typing import Optional, Tuple, List, Dict, TYPE_CHECKING, Any # Third-party imports (handled by individual modules if specific) # Local application/package imports (using absolute paths) from geoelevation.elevation_manager import ElevationManager from geoelevation.elevation_manager import RASTERIO_AVAILABLE from geoelevation.image_processor import load_prepare_single_browse from geoelevation.image_processor import create_composite_area_image from geoelevation.image_processor import PIL_AVAILABLE from geoelevation.visualizer import show_image_matplotlib from geoelevation.visualizer import show_3d_matplotlib from geoelevation.visualizer import MATPLOTLIB_AVAILABLE from geoelevation.visualizer import SCIPY_AVAILABLE from geoelevation import DEFAULT_CACHE_DIR as GEOELEVATION_DEFAULT_DEM_CACHE # Try to import map viewer components; functionality will be disabled if not found try: # We don't instantiate GeoElevationMapViewer directly in this class anymore, # but its availability (and OpenCV's) determines if map features are enabled. # We check for cv2 as a proxy for the map viewer system's core dependency. import cv2 # Attempt to import cv2 MAP_VIEWER_SYSTEM_AVAILABLE = True except ImportError as e_map_import_gui: logging.warning( f"GUI: Map viewer components (e.g. OpenCV) not found, map functionality will be disabled: {e_map_import_gui}" ) cv2 = None # type: ignore # Ensure cv2 is None if import failed MAP_VIEWER_SYSTEM_AVAILABLE = False # Type hinting for NumPy arrays if visualizer or other components use them if TYPE_CHECKING: import numpy as np_typing # Use an alias to avoid conflicts if np is mocked # Configure module-level logger logger = logging.getLogger(__name__) # --- MULTIPROCESSING TARGET FUNCTIONS (Module-Level for Pickling) --- def process_target_show_image( image_path: str, tile_name: str, window_title: str ) -> None: """ Target function for multiprocessing: Loads, prepares, and shows a single image. Imports are local to the process. """ try: # Local imports for the child process from geoelevation.image_processor import load_prepare_single_browse as local_lpst from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok from geoelevation.visualizer import show_image_matplotlib as local_sim from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok import os as local_os # For os.path.basename if not (local_pil_ok and local_mpl_ok): # Using print as logging might not be configured in the child process's console print( "PROCESS ERROR (show_image): Pillow/Matplotlib libraries are missing in the child process." ) return prepared_image = local_lpst(image_path, tile_name) if prepared_image: print(f"PROCESS (show_image): Displaying image '{window_title}'") local_sim(prepared_image, window_title) # This call blocks the child process else: print( f"PROCESS ERROR (show_image): Failed to prepare image {local_os.path.basename(image_path)}" ) except Exception as e_proc_img: print(f"PROCESS ERROR occurred in process_target_show_image: {e_proc_img}") import traceback as tb # Local import for traceback tb.print_exc() def process_target_show_3d( hgt_data: Optional["np_typing.ndarray"], plot_title: str, initial_subsample: int, smooth_sigma: Optional[float], interpolation_factor: int, plot_grid_points: int ) -> None: """ Target function for multiprocessing: Shows 3D plot, optionally smoothed/interpolated. Imports are local to the process. """ try: # Local imports for the child process from geoelevation.visualizer import show_3d_matplotlib as local_s3d from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok from geoelevation.visualizer import SCIPY_AVAILABLE as local_scipy_ok # NumPy is typically imported by the visualizer module if Matplotlib is present if not local_mpl_ok: print("PROCESS ERROR (show_3d): Matplotlib library is missing in the child process.") return # Adjust parameters if SciPy is not available in the child process for advanced features effective_smooth_sigma = smooth_sigma effective_interpolation_factor = interpolation_factor if (interpolation_factor > 1 or smooth_sigma is not None) and not local_scipy_ok: print( "PROCESS WARNING (show_3d): SciPy library missing in child process. " "Disabling 3D plot smoothing/interpolation." ) effective_smooth_sigma = None effective_interpolation_factor = 1 if hgt_data is not None: print( f"PROCESS (show_3d): Generating 3D plot '{plot_title}' (InitialSubsample:{initial_subsample}, " f"SmoothSigma:{effective_smooth_sigma}, InterpolationFactor:{effective_interpolation_factor}x, " f"PlotGridPoints:{plot_grid_points})" ) local_s3d( # Call the actual plotting function hgt_data_array=hgt_data, title=plot_title, initial_subsample=initial_subsample, smooth_sigma=effective_smooth_sigma, interpolation_factor=effective_interpolation_factor, plot_grid_points=plot_grid_points ) # This call blocks the child process else: print("PROCESS ERROR (show_3d): No HGT data array received for plotting.") except Exception as e_proc_3d: print(f"PROCESS ERROR occurred in process_target_show_3d: {e_proc_3d}") import traceback as tb tb.print_exc() def process_target_create_show_area( tile_info_list_data: List[Dict], window_title_str: str ) -> None: """ Target function for multiprocessing: Creates a composite area image and shows it. Imports are local to the process. """ try: # Local imports for the child process from geoelevation.image_processor import create_composite_area_image as local_ccai from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok from geoelevation.visualizer import show_image_matplotlib as local_sim from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok if not (local_pil_ok and local_mpl_ok): print("PROCESS ERROR (show_area): Pillow/Matplotlib libraries are missing in the child process.") return print("PROCESS (show_area): Creating composite area image...") composite_pil_image = local_ccai(tile_info_list_data) if composite_pil_image: print(f"PROCESS (show_area): Displaying composite image '{window_title_str}'") local_sim(composite_pil_image, window_title_str) # This blocks child process else: print("PROCESS ERROR (show_area): Failed to create composite area image.") except Exception as e_proc_area: print(f"PROCESS ERROR occurred in process_target_create_show_area: {e_proc_area}") import traceback as tb tb.print_exc() def run_map_viewer_process_target( map_interaction_q: multiprocessing.Queue, operation_mode: str, center_latitude: Optional[float], center_longitude: Optional[float], area_bounding_box: Optional[Tuple[float, float, float, float]], dem_data_cache_dir: str, display_scale_factor: float ) -> None: """ Target function for multiprocessing: Runs the GeoElevationMapViewer. Initializes ElevationManager and GeoElevationMapViewer in the child process. """ child_logger = logging.getLogger("GeoElevationMapViewerChildProcess") if not child_logger.handlers: stream_handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') stream_handler.setFormatter(formatter) child_logger.addHandler(stream_handler) child_logger.setLevel(logging.INFO) child_logger.propagate = False child_map_viewer_instance: Optional[Any] = None try: from geoelevation.map_viewer.geo_map_viewer import GeoElevationMapViewer as ChildGeoMapViewer from geoelevation.elevation_manager import ElevationManager as ChildElevationManager import cv2 as child_cv2 child_map_system_ok = True except ImportError as e_child_imp_map_corrected: child_logger.error(f"Map viewer components or OpenCV not found in child: {e_child_imp_map_corrected}") child_map_system_ok = False if not child_map_system_ok: return try: child_logger.info( f"Initializing for mode '{operation_mode}', display scale: {display_scale_factor:.2f}" ) local_em = ChildElevationManager(tile_directory=dem_data_cache_dir) child_map_viewer_instance = ChildGeoMapViewer( elevation_manager_instance=local_em, gui_output_communication_queue=map_interaction_q, initial_display_scale=display_scale_factor ) if operation_mode == "point" and center_latitude is not None and center_longitude is not None: child_map_viewer_instance.display_map_for_point(center_latitude, center_longitude) elif operation_mode == "area" and area_bounding_box: child_map_viewer_instance.display_map_for_area(area_bounding_box) else: child_logger.error(f"Invalid mode ('{operation_mode}') or missing parameters.") if hasattr(child_map_viewer_instance, 'shutdown'): child_map_viewer_instance.shutdown() return child_logger.info("Display method called. Map window should be active.") is_map_active = True while is_map_active: # MODIFIED: Access 'map_display_window_controller' # WHY: To match the attribute name used in the GeoElevationMapViewer class. # HOW: Changed from 'map_display_window_handler' to 'map_display_window_controller'. if child_map_viewer_instance.map_display_window_controller: # <<< MODIFICA QUI if child_map_viewer_instance.map_display_window_controller.is_window_alive(): # <<< MODIFICA QUI child_cv2.waitKey(100) else: child_logger.info("Map window reported as not alive by is_window_alive().") is_map_active = False else: child_logger.info("map_display_window_controller is None. Assuming window closed.") is_map_active = False child_logger.info("Map window loop in child process exited.") except Exception as e_map_proc_child_fatal_final: child_logger.error( f"Fatal error in map viewer process target: {e_map_proc_child_fatal_final}", exc_info=True ) finally: if child_map_viewer_instance and hasattr(child_map_viewer_instance, 'shutdown'): child_map_viewer_instance.shutdown() child_logger.info("Terminated child map viewer process.") class ElevationApp: """ Main application class for the GeoElevation Tool GUI. """ def __init__( self, parent_widget: tk.Tk, elevation_manager_instance: Optional[ElevationManager] = None ) -> None: """ Initializes the ElevationApp GUI. """ self.root: tk.Tk = parent_widget self.elevation_manager: Optional[ElevationManager] = elevation_manager_instance if self.elevation_manager is None: try: if not RASTERIO_AVAILABLE: logger.warning("Rasterio library not found. Elevation data functions limited.") self.elevation_manager = ElevationManager( tile_directory=GEOELEVATION_DEFAULT_DEM_CACHE ) except Exception as e_manager_init: logger.critical(f"Failed to initialize ElevationManager: {e_manager_init}", exc_info=True) messagebox.showerror("Init Error", f"Could not start Elevation Manager:\n{e_manager_init}", parent=self.root) self.elevation_manager = None self.root.title("GeoElevation Tool") self.root.minsize(480, 520) # Min height increased for new scale options frame self.last_valid_point_coords: Optional[Tuple[float, float]] = None self.last_area_coords: Optional[Tuple[float, float, float, float]] = None self.is_processing_task: bool = False # Map display scale factor, managed by a Tkinter DoubleVar self.map_display_scale_factor_var: tk.DoubleVar = tk.DoubleVar(value=0.5) # Default to 50% self.scale_options_map: Dict[str, float] = { "25% (1:4)": 0.25, "33% (1:3)": 0.33333, # More precision "50% (1:2)": 0.5, "75% (3:4)": 0.75, "100% (Original Size)": 1.0, "150% (3:2)": 1.5, "200% (2:1)": 2.0 } # Multiprocessing resources for map viewer self.map_viewer_process_handle: Optional[multiprocessing.Process] = None self.map_interaction_message_queue: Optional[multiprocessing.Queue] = None if MAP_VIEWER_SYSTEM_AVAILABLE: try: # Forcing spawn context can sometimes help on macOS/Windows if default causes issues # mp_context = multiprocessing.get_context("spawn") # self.map_interaction_message_queue = mp_context.Queue() self.map_interaction_message_queue = multiprocessing.Queue() except Exception as e_gui_queue: logger.error(f"GUI: Failed to create multiprocessing queue for map viewer: {e_gui_queue}") # If queue creation fails, disable map viewer features that depend on it. # This state needs to be checked before attempting to use the queue. self._build_gui_layout() self._apply_initial_widget_states() # Start polling the map interaction queue if it was successfully created if self.map_interaction_message_queue: self.root.after(100, self._process_map_interaction_queue_messages) self.root.protocol("WM_DELETE_WINDOW", self._on_application_closing) def _get_scale_display_text_from_value(self, target_scale_value: float) -> str: """Finds the display text in scale_options_map closest to target_scale_value.""" closest_text_key = "" smallest_difference = float('inf') default_display_text = "50% (1:2)" # Ensure this is a key in scale_options_map for display_text, scale_val in self.scale_options_map.items(): difference = abs(scale_val - target_scale_value) if difference < smallest_difference: smallest_difference = difference closest_text_key = display_text if closest_text_key and smallest_difference < 0.01: # Threshold for "close enough" return closest_text_key else: # Fallback to the text matching the default scale value (0.5) for text_option, value_option in self.scale_options_map.items(): if abs(value_option - 0.5) < 0.001: return text_option return default_display_text # Should not be reached if "50% (1:2)" is in map def _build_gui_layout(self) -> None: """Creates and arranges all GUI widgets.""" main_app_frame = ttk.Frame(self.root, padding="10") main_app_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) # Allow main_app_frame to expand # --- Point Elevation Section --- point_coords_frame = ttk.LabelFrame(main_app_frame, text="Get Elevation for Point", padding="10") point_coords_frame.grid(row=0, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) point_coords_frame.columnconfigure(1, weight=1) ttk.Label(point_coords_frame, text="Latitude:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) self.latitude_entry = ttk.Entry(point_coords_frame, width=15) self.latitude_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) self.latitude_entry.insert(0, "45.0") ttk.Label(point_coords_frame, text="Longitude:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) self.longitude_entry = ttk.Entry(point_coords_frame, width=15) self.longitude_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) self.longitude_entry.insert(0, "7.0") self.get_elevation_button = ttk.Button( point_coords_frame, text="Get Elevation", command=self._trigger_get_elevation_task ) self.get_elevation_button.grid(row=2, column=0, columnspan=2, pady=5, sticky=(tk.W, tk.E)) self.point_result_label = ttk.Label(point_coords_frame, text="Result: ", wraplength=400, justify=tk.LEFT) self.point_result_label.grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=5) point_actions_subframe = ttk.Frame(point_coords_frame) point_actions_subframe.grid(row=4, column=0, columnspan=2, pady=(5,0), sticky=(tk.W, tk.E)) point_actions_subframe.columnconfigure(0, weight=1) # Equal weight for buttons point_actions_subframe.columnconfigure(1, weight=1) point_actions_subframe.columnconfigure(2, weight=1) # For the third (map) button self.show_2d_browse_button = ttk.Button( point_actions_subframe, text="Browse Image (2D)", command=self._trigger_2d_browse_display ) self.show_2d_browse_button.grid(row=0, column=0, padx=2, sticky=(tk.W,tk.E)) self.show_3d_dem_button = ttk.Button( point_actions_subframe, text="DEM Tile (3D)", command=self._trigger_3d_dem_display ) self.show_3d_dem_button.grid(row=0, column=1, padx=2, sticky=(tk.W,tk.E)) self.view_map_for_point_button = ttk.Button( point_actions_subframe, text="View Point on Map", command=self._trigger_view_map_for_point ) self.view_map_for_point_button.grid(row=0, column=2, padx=2, sticky=(tk.W,tk.E)) # --- Area Download Section --- area_download_frame = ttk.LabelFrame(main_app_frame, text="Pre-Download Tiles for Area", padding="10") area_download_frame.grid(row=1, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) area_download_frame.columnconfigure(1, weight=1) # Entry expand area_download_frame.columnconfigure(3, weight=1) # Entry expand ttk.Label(area_download_frame, text="Min Lat:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) self.min_latitude_entry = ttk.Entry(area_download_frame, width=10) self.min_latitude_entry.grid(row=0, column=1, sticky=(tk.W,tk.E), padx=5, pady=3) self.min_latitude_entry.insert(0, "44.0") ttk.Label(area_download_frame, text="Max Lat:").grid(row=0, column=2, sticky=tk.W, padx=(10,5), pady=3) self.max_latitude_entry = ttk.Entry(area_download_frame, width=10) self.max_latitude_entry.grid(row=0, column=3, sticky=(tk.W,tk.E), padx=5, pady=3) self.max_latitude_entry.insert(0, "45.0") ttk.Label(area_download_frame, text="Min Lon:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) self.min_longitude_entry = ttk.Entry(area_download_frame, width=10) self.min_longitude_entry.grid(row=1, column=1, sticky=(tk.W,tk.E), padx=5, pady=3) self.min_longitude_entry.insert(0, "7.0") ttk.Label(area_download_frame, text="Max Lon:").grid(row=1, column=2, sticky=tk.W, padx=(10,5), pady=3) self.max_longitude_entry = ttk.Entry(area_download_frame, width=10) self.max_longitude_entry.grid(row=1, column=3, sticky=(tk.W,tk.E), padx=5, pady=3) self.max_longitude_entry.insert(0, "8.0") self.download_area_button = ttk.Button( area_download_frame, text="Download Area Tiles", command=self._trigger_download_area_task ) self.download_area_button.grid(row=2, column=0, columnspan=4, pady=10, sticky=(tk.W,tk.E)) self.show_area_composite_button = ttk.Button( area_download_frame, text="Show Area Composite (2D)", command=self._trigger_area_composite_display ) self.show_area_composite_button.grid(row=3, column=0, columnspan=4, pady=5, sticky=(tk.W,tk.E)) self.view_map_for_area_button = ttk.Button( area_download_frame, text="View Area on Map", command=self._trigger_view_map_for_area ) self.view_map_for_area_button.grid(row=4, column=0, columnspan=4, pady=5, sticky=(tk.W,tk.E)) self.area_download_status_label = ttk.Label( area_download_frame, text="Status: Idle", wraplength=400, justify=tk.LEFT ) self.area_download_status_label.grid(row=5, column=0, columnspan=4, sticky=tk.W, pady=5) # --- Map Display Scale Section --- map_display_options_frame = ttk.LabelFrame(main_app_frame, text="Map Display Options", padding="10") map_display_options_frame.grid(row=2, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) map_display_options_frame.columnconfigure(1, weight=1) # Combobox expand ttk.Label(map_display_options_frame, text="Map Display Scale:").grid( row=0, column=0, sticky=tk.W, padx=5, pady=3 ) scale_option_display_texts = list(self.scale_options_map.keys()) initial_combobox_display_text = self._get_scale_display_text_from_value( self.map_display_scale_factor_var.get() ) self.map_scale_combobox = ttk.Combobox( map_display_options_frame, textvariable=tk.StringVar(value=initial_combobox_display_text), # Set initial text values=scale_option_display_texts, state="readonly", # User can only select from the list width=20 # Adjust width as needed ) self.map_scale_combobox.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) self.map_scale_combobox.bind("<>", self._on_map_display_scale_changed) # --- Map Click Info Section --- map_click_info_frame = ttk.LabelFrame(main_app_frame, text="Map Click Information", padding="10") map_click_info_frame.grid(row=3, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) # Now at row 3 map_click_info_frame.columnconfigure(1, weight=1) # Label value expand ttk.Label(map_click_info_frame, text="Clicked Latitude:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2) self.map_click_latitude_label = ttk.Label(map_click_info_frame, text="N/A") self.map_click_latitude_label.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_click_info_frame, text="Clicked Longitude:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2) self.map_click_longitude_label = ttk.Label(map_click_info_frame, text="N/A") self.map_click_longitude_label.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_click_info_frame, text="Elevation at Click:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2) self.map_click_elevation_label = ttk.Label(map_click_info_frame, text="N/A") self.map_click_elevation_label.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) # Configure main_app_frame rows (0-indexed) main_app_frame.columnconfigure(0, weight=1) # Main content column expands main_app_frame.rowconfigure(0, weight=0) # Point Elevation Frame main_app_frame.rowconfigure(1, weight=0) # Area Download Frame main_app_frame.rowconfigure(2, weight=0) # Map Display Options Frame main_app_frame.rowconfigure(3, weight=0) # Map Click Info Frame # Initially hide map-specific sections if map viewer system not available if not MAP_VIEWER_SYSTEM_AVAILABLE: map_display_options_frame.grid_remove() map_click_info_frame.grid_remove() # Button text already updated in _apply_initial_widget_states if system N/A def _apply_initial_widget_states(self) -> None: """Sets initial enabled/disabled states of GUI widgets.""" if self.elevation_manager is None: # Critical failure: disable primary action buttons self.get_elevation_button.config(state=tk.DISABLED) self.download_area_button.config(state=tk.DISABLED) self.point_result_label.config(text="Result: Elevation Manager Failed to Initialize.") self.area_download_status_label.config(text="Status: Elevation Manager Failed.") # Visualization buttons for point - initially disabled, enabled on successful data retrieval self.show_2d_browse_button.config(state=tk.DISABLED) self.show_3d_dem_button.config(state=tk.DISABLED) # Visualization buttons for area - initially disabled, enabled on successful area download self.show_area_composite_button.config(state=tk.DISABLED) # Map viewer related buttons and controls if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.NORMAL) # Can be clicked if point data is later available self.view_map_for_area_button.config(state=tk.DISABLED) # Needs area data first self.map_scale_combobox.config(state="readonly") else: self.view_map_for_point_button.config(state=tk.DISABLED, text="Map (Sys N/A)") self.view_map_for_area_button.config(state=tk.DISABLED, text="Map (Sys N/A)") self.map_scale_combobox.config(state=tk.DISABLED) # Update button text if specific visualization libraries are missing if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): self.show_2d_browse_button.config(text="Browse (Libs N/A)") self.show_area_composite_button.config(text="Composite (Libs N/A)") if not MATPLOTLIB_AVAILABLE: # SciPy is an optional enhancement for 3D, Matplotlib is core self.show_3d_dem_button.config(text="DEM (Matplotlib N/A)") def _on_map_display_scale_changed(self, event: Optional[tk.Event] = None) -> None: """Callback when the map display scale Combobox selection changes.""" selected_display_text = self.map_scale_combobox.get() new_numeric_scale = self.scale_options_map.get(selected_display_text) if new_numeric_scale is not None: self.map_display_scale_factor_var.set(new_numeric_scale) logger.info( f"Map display scale factor set to: {new_numeric_scale:.3f} ({selected_display_text})" ) # TODO (Future): If a map window is currently open, could send a message # via the queue to tell the map viewer process to rescale and redisplay. # This requires the map viewer process to listen for such messages. else: logger.warning(f"Invalid map scale option selected from Combobox: '{selected_display_text}'") def _set_busy_state(self, is_busy: bool) -> None: """Enables/disables main action buttons based on processing state.""" self.is_processing_task = is_busy new_widget_state = tk.DISABLED if is_busy else tk.NORMAL # Only modify state if the elevation manager is available if self.elevation_manager: self.get_elevation_button.config(state=new_widget_state) self.download_area_button.config(state=new_widget_state) def _validate_point_coordinates( self, latitude_str: str, longitude_str: str ) -> Optional[Tuple[float, float]]: """Validates latitude and longitude input strings for a point.""" try: if not latitude_str: raise ValueError("Latitude input cannot be empty.") latitude_val = float(latitude_str.strip()) if not (-90.0 <= latitude_val < 90.0): raise ValueError("Latitude must be in [-90, 90).") if not longitude_str: raise ValueError("Longitude input cannot be empty.") longitude_val = float(longitude_str.strip()) if not (-180.0 <= longitude_val < 180.0): raise ValueError("Longitude must be in [-180, 180).") return latitude_val, longitude_val except ValueError as e_val_pt: logger.error(f"Invalid point coordinate input: {e_val_pt}") messagebox.showerror("Input Error", f"Invalid coordinate:\n{e_val_pt}", parent=self.root) return None def _validate_area_boundary_coordinates(self) -> Optional[Tuple[float, float, float, float]]: """Validates the four bounding box input strings for an area.""" try: min_lat_s = self.min_latitude_entry.get().strip() max_lat_s = self.max_latitude_entry.get().strip() min_lon_s = self.min_longitude_entry.get().strip() max_lon_s = self.max_longitude_entry.get().strip() if not all([min_lat_s, max_lat_s, min_lon_s, max_lon_s]): raise ValueError("All area boundary fields must be filled.") min_lat_v = float(min_lat_s) max_lat_v = float(max_lat_s) min_lon_v = float(min_lon_s) max_lon_v = float(max_lon_s) if not (-90.0 <= min_lat_v < 90.0): raise ValueError("Min Latitude out of range.") if not (-90.0 <= max_lat_v < 90.0): raise ValueError("Max Latitude out of range.") if not (-180.0 <= min_lon_v < 180.0): raise ValueError("Min Longitude out of range.") if not (-180.0 <= max_lon_v < 180.0): raise ValueError("Max Longitude out of range.") if min_lat_v >= max_lat_v: raise ValueError("Min Latitude must be less than Max Latitude.") if min_lon_v >= max_lon_v: raise ValueError("Min Longitude must be less than Max Longitude.") return min_lat_v, min_lon_v, max_lat_v, max_lon_v # Standard order in app except ValueError as e_val_area: logger.error(f"Invalid area boundary input: {e_val_area}") messagebox.showerror("Input Error", f"Invalid area boundary:\n{e_val_area}", parent=self.root) return None def _trigger_get_elevation_task(self) -> None: """Handles the 'Get Elevation' button click to fetch elevation for a point.""" if self.is_processing_task or not self.elevation_manager: return coords = self._validate_point_coordinates( self.latitude_entry.get(), self.longitude_entry.get() ) if not coords: return # Validation failed, user already notified latitude, longitude = coords self._set_busy_state(True) self.point_result_label.config(text="Result: Requesting elevation...") # Disable visualization buttons until task completion self.show_2d_browse_button.config(state=tk.DISABLED) self.show_3d_dem_button.config(state=tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: # Keep map button enabled if system is OK, but needs point self.view_map_for_point_button.config(state=tk.DISABLED) # Disable until point is valid self.last_valid_point_coords = None # Reset self.root.update_idletasks() # Ensure UI update try: elevation_meters = self.elevation_manager.get_elevation(latitude, longitude) display_text = "Result: " if elevation_meters is None: display_text += "Elevation data unavailable (check logs for details)." messagebox.showwarning("Info", "Could not retrieve elevation.", parent=self.root) elif math.isnan(elevation_meters): display_text += "Point is within tile boundaries but on a NoData area." self.last_valid_point_coords = (latitude, longitude) # Tile exists else: display_text += f"Elevation is {elevation_meters:.2f} meters." self.last_valid_point_coords = (latitude, longitude) self.point_result_label.config(text=display_text) # Update states of visualization buttons based on successful data retrieval if self.last_valid_point_coords: if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_2d_browse_button.config(state=tk.NORMAL) if MATPLOTLIB_AVAILABLE: # SciPy is for advanced options within 3D plot self.show_3d_dem_button.config(state=tk.NORMAL) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.NORMAL) except Exception as e_get_elev_task: logger.exception("Error during GUI 'get_elevation' task.") messagebox.showerror("Error", f"Unexpected error occurred:\n{e_get_elev_task}", parent=self.root) self.point_result_label.config(text="Result: Error (see application logs).") finally: self._set_busy_state(False) def _trigger_download_area_task(self) -> None: """Handles the 'Download Area Tiles' button click.""" if self.is_processing_task or not self.elevation_manager: return bounds = self._validate_area_boundary_coordinates() if not bounds: return # Validation failed # Unpack bounds (min_lat, min_lon, max_lat, max_lon) self.last_area_coords = bounds self._set_busy_state(True) self.area_download_status_label.config(text="Status: Starting download process...") self.show_area_composite_button.config(state=tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_area_button.config(state=tk.DISABLED) self.root.update_idletasks() # Run the download in a separate thread download_thread = threading.Thread( target=self._perform_background_area_download, args=bounds, # Pass the tuple directly daemon=True ) download_thread.start() def _perform_background_area_download( self, min_lat: float, min_lon: float, max_lat: float, max_lon: float ) -> None: """Worker function for background area download, executed in a thread.""" final_status_text = "Status: Unknown error during area download." download_succeeded = False num_tiles_processed = 0 num_hgt_obtained = 0 try: # Update GUI from thread using root.after self.root.after( 0, lambda: self.area_download_status_label.config(text="Status: Downloading/Verifying area tiles...") ) if self.elevation_manager: num_tiles_processed, num_hgt_obtained = self.elevation_manager.download_area( min_lat, min_lon, max_lat, max_lon ) final_status_text = ( f"Status: Download complete. Processed {num_tiles_processed} locations. " f"Obtained/Verified {num_hgt_obtained} HGT files." ) download_succeeded = True else: final_status_text = "Status: Error - Elevation Manager instance not available." except Exception as e_bg_download: logger.exception("Error during background area download task.") final_status_text = f"Status: Error - {type(e_bg_download).__name__} (see logs)." finally: # Schedule GUI update back on the main thread self.root.after( 0, self._area_download_task_complete_ui_update, final_status_text, download_succeeded, num_tiles_processed, num_hgt_obtained ) def _area_download_task_complete_ui_update( self, status_message_text: str, success_flag: bool, processed_count: int, obtained_count: int ) -> None: """Updates GUI elements after the area download task is complete.""" self.area_download_status_label.config(text=status_message_text) self._set_busy_state(False) # Release busy state if success_flag: summary_message = ( f"Area data processing complete.\n\n" f"Checked {processed_count} tile locations.\n" f"Successfully found or downloaded {obtained_count} HGT files." ) messagebox.showinfo("Download Complete", summary_message, parent=self.root) # Enable visualization buttons for the area if dependencies are met if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_area_composite_button.config(state=tk.NORMAL) # Enable map view for area if system is OK and some HGT files were relevant if MAP_VIEWER_SYSTEM_AVAILABLE and obtained_count > 0 : self.view_map_for_area_button.config(state=tk.NORMAL) else: # Extract brief error for the popup from the detailed status message brief_error_message = status_message_text.split(":")[-1].strip() messagebox.showerror( "Download Error", f"Area download operation failed: {brief_error_message}\nPlease check application logs for details.", parent=self.root ) # Ensure area-specific buttons remain disabled on failure self.show_area_composite_button.config(state=tk.DISABLED) self.view_map_for_area_button.config(state=tk.DISABLED) def _start_visualization_process( self, target_function: callable, arguments_tuple: tuple, process_id_name: str ) -> None: """Helper method to start a daemonized multiprocessing.Process for visualizations.""" try: viz_process = multiprocessing.Process( target=target_function, args=arguments_tuple, daemon=True, # Ensures process exits when the main application exits name=process_id_name # Helpful for debugging ) viz_process.start() logger.info(f"Started visualization process '{process_id_name}' (PID: {viz_process.pid}).") except Exception as e_viz_proc_start: logger.exception(f"Failed to start '{process_id_name}' process.") messagebox.showerror( "Process Start Error", f"Could not start the {process_id_name} display process:\n{e_viz_proc_start}", parent=self.root ) def _trigger_2d_browse_display(self) -> None: """Triggers display of the 2D browse image for the last valid point.""" if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): messagebox.showwarning("Dependency Missing", "Matplotlib and Pillow are required.", parent=self.root) return if not self.last_valid_point_coords or not self.elevation_manager: messagebox.showinfo("Information", "Get a successful point elevation first.", parent=self.root) return latitude, longitude = self.last_valid_point_coords retrieved_tile_info = self.elevation_manager.get_tile_info(latitude, longitude) if retrieved_tile_info and \ retrieved_tile_info.get("browse_available") and \ retrieved_tile_info.get("browse_image_path"): image_file_path = retrieved_tile_info["browse_image_path"] base_tile_name = retrieved_tile_info.get("tile_base_name", "UnknownTile") window_display_title = f"Browse Image: {base_tile_name.upper()}" process_args = (image_file_path, base_tile_name, window_display_title) self._start_visualization_process(process_target_show_image, process_args, "2DBrowseImageDisplay") else: messagebox.showinfo("Image Info", "Browse image is not available for this location.", parent=self.root) def _trigger_3d_dem_display(self) -> None: """Triggers display of the 3D DEM tile for the last valid point.""" if not MATPLOTLIB_AVAILABLE: # Core requirement for 3D plot messagebox.showwarning("Dependency Missing", "Matplotlib is required for 3D DEM display.", parent=self.root) return if not self.last_valid_point_coords or not self.elevation_manager: messagebox.showinfo("Information", "Get a successful point elevation first.", parent=self.root) return latitude, longitude = self.last_valid_point_coords hgt_numpy_array = self.elevation_manager.get_hgt_data(latitude, longitude) if hgt_numpy_array is not None: retrieved_tile_info = self.elevation_manager.get_tile_info(latitude, longitude) base_tile_name = retrieved_tile_info.get("tile_base_name", "Unknown").upper() if retrieved_tile_info else "Unknown" plot_display_title = f"3D DEM View: Tile {base_tile_name}" # Configuration for 3D plot rendering config_initial_subsample = 1 config_smooth_sigma: Optional[float] = 0.5 # Light smoothing, or None to disable config_interpolation_factor = 3 # e.g., 2, 3, or 4 for smoother surface config_plot_grid_points = 300 # Target points for the final rendered grid # Disable advanced features if SciPy is not available (checked in main thread) if not SCIPY_AVAILABLE: if config_smooth_sigma is not None: config_smooth_sigma = None if config_interpolation_factor > 1: config_interpolation_factor = 1 logger.warning("SciPy not available in main thread. Advanced 3D plot features disabled for child process.") process_args = ( hgt_numpy_array, plot_display_title, config_initial_subsample, config_smooth_sigma, config_interpolation_factor, config_plot_grid_points ) self._start_visualization_process(process_target_show_3d, process_args, "3DDEMTileDisplay") else: messagebox.showerror("3D Data Error", "Could not retrieve HGT data array for 3D plot.", parent=self.root) def _trigger_area_composite_display(self) -> None: """Triggers display of the composite 2D browse image for the last downloaded area.""" if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): messagebox.showwarning("Dependency Missing", "Matplotlib and Pillow are required.", parent=self.root) return if not self.last_area_coords or not self.elevation_manager: messagebox.showinfo("Information", "Download map tiles for an area successfully first.", parent=self.root) return min_lat, min_lon, max_lat, max_lon = self.last_area_coords list_of_tile_info = self.elevation_manager.get_area_tile_info(min_lat, min_lon, max_lat, max_lon) if not list_of_tile_info: messagebox.showinfo("Area Info", "No tile information found for the specified area.", parent=self.root) return window_display_title = f"Area Composite: Lat [{min_lat:.1f}-{max_lat:.1f}], Lon [{min_lon:.1f}-{max_lon:.1f}]" process_args = (list_of_tile_info, window_display_title) self._start_visualization_process(process_target_create_show_area, process_args, "AreaCompositeDisplay") def _start_map_viewer_process(self, base_process_args_tuple: tuple) -> None: """ Helper to start the map viewer process. It retrieves the current display scale factor and appends it to the arguments for the target function. """ if not self.map_interaction_message_queue: logger.error("Cannot start map viewer: interaction message queue is not initialized.") messagebox.showerror("Internal Error", "Map communication system is not ready.", parent=self.root) return try: current_display_scale = self.map_display_scale_factor_var.get() # base_process_args_tuple is (queue, mode, lat, lon, bbox, dem_cache_path) # Append display_scale_factor as the last argument full_args_for_map_process = base_process_args_tuple + (current_display_scale,) self.map_viewer_process_handle = multiprocessing.Process( target=run_map_viewer_process_target, # Module-level target function args=full_args_for_map_process, daemon=True, # Ensures process exits with main app name="GeoElevationMapViewerProcess" ) self.map_viewer_process_handle.start() logger.info( f"Started map viewer process (PID: {self.map_viewer_process_handle.pid}) " f"with display scale: {current_display_scale:.3f}." ) # Ensure queue polling is (re)started if necessary self.root.after(100, self._process_map_interaction_queue_messages) except Exception as e_map_viewer_start: logger.exception("Failed to start the map viewer process.") messagebox.showerror( "Process Start Error", f"Could not start the map viewer process:\n{e_map_viewer_start}", parent=self.root ) def _trigger_view_map_for_point(self) -> None: """Triggers the display of the map centered on the last valid point.""" if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Feature Unavailable", "Map viewer system components are not available.", parent=self.root) return if not self.last_valid_point_coords: messagebox.showinfo("Information", "Please get elevation for a specific point first.", parent=self.root) return if not self.map_interaction_message_queue: # Check if queue was created messagebox.showerror("Internal Error", "Map interaction system is not properly initialized.", parent=self.root) return if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): messagebox.showinfo("Information", "Map window is already open. Please close it first to view a new map.", parent=self.root) return latitude, longitude = self.last_valid_point_coords dem_cache_path = self.elevation_manager.tile_directory if self.elevation_manager else GEOELEVATION_DEFAULT_DEM_CACHE # Arguments for run_map_viewer_process_target (excluding scale factor, added by helper) base_args = ( self.map_interaction_message_queue, "point", # operation_mode latitude, longitude, None, # area_bounding_box (None for point mode) dem_cache_path ) self._start_map_viewer_process(base_args) def _trigger_view_map_for_area(self) -> None: """Triggers the display of the map covering the last downloaded area.""" if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Feature Unavailable", "Map viewer system components are not available.", parent=self.root) return if not self.last_area_coords: # This is set after a successful area download messagebox.showinfo("Information", "Please download map tiles for an area first.", parent=self.root) return if not self.map_interaction_message_queue: messagebox.showerror("Internal Error", "Map interaction system is not properly initialized.", parent=self.root) return if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): messagebox.showinfo("Information", "Map window is already open. Please close it first.", parent=self.root) return min_lat, min_lon, max_lat, max_lon = self.last_area_coords # Bounding box format for mercantile: (west, south, east, north) area_bbox_data = (min_lon, min_lat, max_lon, max_lat) dem_cache_path = self.elevation_manager.tile_directory if self.elevation_manager else GEOELEVATION_DEFAULT_DEM_CACHE base_args = ( self.map_interaction_message_queue, "area", # operation_mode None, # center_latitude (None for area mode) None, # center_longitude (None for area mode) area_bbox_data, dem_cache_path ) self._start_map_viewer_process(base_args) def _process_map_interaction_queue_messages(self) -> None: """Periodically checks the queue for messages from the map viewer process.""" if not self.map_interaction_message_queue: # Queue was not initialized (e.g., MAP_VIEWER_SYSTEM_AVAILABLE was False) return try: while not self.map_interaction_message_queue.empty(): message_data = self.map_interaction_message_queue.get_nowait() message_type = message_data.get("type") if message_type == "map_click_data": lat_val = message_data.get("latitude") lon_val = message_data.get("longitude") elevation_text = message_data.get("elevation_str", "Error fetching") latitude_display_text = f"{lat_val:.5f}" if lat_val is not None else "N/A" longitude_display_text = f"{lon_val:.5f}" if lon_val is not None else "N/A" self.map_click_latitude_label.config(text=latitude_display_text) self.map_click_longitude_label.config(text=longitude_display_text) self.map_click_elevation_label.config(text=elevation_text) # Add handling for other message types from map viewer if needed in the future # else: # logger.warning(f"Received unknown message type from map queue: {message_type}") except queue.Empty: # This is expected when the queue has no new messages pass except Exception as e_gui_queue_proc: logger.error(f"Error processing map interaction queue in GUI: {e_gui_queue_proc}") finally: # Continue polling if the queue exists. A more robust check might involve # also checking if self.map_viewer_process_handle is alive, but messages # could still arrive after the process ends. if self.map_interaction_message_queue: self.root.after(250, self._process_map_interaction_queue_messages) # Poll every 250ms def _on_application_closing(self) -> None: """Handles cleanup when the main application window is closed.""" logger.info("Main GUI application is closing. Performing cleanup...") # Terminate the map viewer process if it's running if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): logger.info("Terminating active map viewer process...") self.map_viewer_process_handle.terminate() # Sends SIGTERM # Wait for a short period for the process to exit self.map_viewer_process_handle.join(timeout=1.5) # Increased timeout slightly if self.map_viewer_process_handle.is_alive(): logger.warning("Map viewer process did not terminate via SIGTERM. Attempting SIGKILL.") self.map_viewer_process_handle.kill() # Force kill self.map_viewer_process_handle.join(timeout=0.5) # Brief wait for kill to take effect # Close and join the multiprocessing queue to release resources if self.map_interaction_message_queue: logger.debug("Closing and joining map interaction message queue.") try: self.map_interaction_message_queue.close() # join_thread waits for the queue's feeder thread to finish self.map_interaction_message_queue.join_thread() except Exception as e_queue_cleanup: # Log errors but don't prevent application exit logger.warning(f"Error during map interaction queue cleanup: {e_queue_cleanup}") self.root.destroy() # Destroy the main Tkinter window # --- Main Execution Block (for direct script testing of this GUI module) --- if __name__ == "__main__": print("Running elevation_gui.py directly for testing purposes...") # Basic logging setup for testing logging.basicConfig( level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(name)s - %(module)s - %(message)s" ) logger.info("--- Direct Test Run: Dependency Status ---") logger.info(f"Rasterio Available: {RASTERIO_AVAILABLE}") logger.info(f"Pillow (PIL) Available: {PIL_AVAILABLE}") logger.info(f"Matplotlib Available: {MATPLOTLIB_AVAILABLE}") logger.info(f"SciPy Available: {SCIPY_AVAILABLE}") logger.info(f"Map Viewer System (OpenCV & Co.) Available: {MAP_VIEWER_SYSTEM_AVAILABLE}") logger.info("-----------------------------------------") # Important for multiprocessing if any child processes are spawned during tests multiprocessing.freeze_support() test_root_tk_window = tk.Tk() gui_app_instance: Optional[ElevationApp] = None try: gui_app_instance = ElevationApp(test_root_tk_window) test_root_tk_window.mainloop() except Exception as e_main_gui_test: logger.critical( f"Fatal error during direct test run of ElevationApp: {e_main_gui_test}", exc_info=True ) try: # Attempt to show a simple Tkinter error dialog if GUI is somewhat functional error_dialog_root = tk.Tk() error_dialog_root.withdraw() # Hide the empty root window messagebox.showerror( "Fatal Test Error", f"Application test run failed critically:\n{e_main_gui_test}", parent=None # No parent if main window might be broken ) error_dialog_root.destroy() except Exception: pass # Ignore if the error dialog itself fails # Attempt cleanup if app instance was created if gui_app_instance and hasattr(gui_app_instance, '_on_application_closing'): gui_app_instance._on_application_closing() # Call cleanup manually else: if test_root_tk_window.winfo_exists(): # Check if window still exists test_root_tk_window.destroy() # Fallback destroy sys.exit(1) # Exit with error status for test run