# geoelevation/elevation_gui.py """ Provides the main graphical user interface (GUI) for the GeoElevation application. (Resto della docstring come prima) """ # 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 import queue from typing import Optional, Tuple, List, Dict, TYPE_CHECKING, Any # Local application/package imports 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 # MODIFIED: Corrected import of default cache directory constant from the geoelevation package's public API # WHY: The previous import was attempting to use the internal name `DEFAULT_CACHE_DIR` which is not exposed # by geoelevation/__init__.py. The correct name exposed in __init__.py (and sourced from config.py) # is GEOELEVATION_DEM_CACHE_DEFAULT. This change aligns the import with the package's defined public API. # HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed # in the import statement itself, as the imported name is already the desired one. The existing try...except # block correctly handles the case where this specific constant might not be available from the package. try: from geoelevation import GEOELEVATION_DEM_CACHE_DEFAULT except ImportError: # This fallback is if the main __init__.py itself has issues exporting the constant, # which shouldn't happen if __init__.py and config.py are correct. GEOELEVATION_DEM_CACHE_DEFAULT = "elevation_data_cache_gui_fallback_critical" logging.getLogger(__name__).critical( "elevation_gui.py: CRITICAL - Could not import GEOELEVATION_DEM_CACHE_DEFAULT from geoelevation package. " f"Using fallback: {GEOELEVATION_DEM_CACHE_DEFAULT}" ) try: 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 MAP_VIEWER_SYSTEM_AVAILABLE = False if TYPE_CHECKING: import numpy as np_typing logger = logging.getLogger(__name__) # --- MULTIPROCESSING TARGET FUNCTIONS --- # (Queste funzioni rimangono identiche alla versione precedente completa che ti ho fornito) def process_target_show_image(image_path: str, tile_name: str, window_title: str) -> None: try: 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 if not (local_pil_ok and local_mpl_ok): print("PROCESS ERROR (show_image): Pillow/Matplotlib missing in child process.") return prepared_image = local_lpst(image_path, tile_name) if prepared_image: print(f"PROCESS (show_image): Showing '{window_title}'") local_sim(prepared_image, window_title) else: print(f"PROCESS ERROR (show_image): Could not prepare {local_os.path.basename(image_path)}") except Exception as e_proc_img: print(f"PROCESS ERROR in process_target_show_image: {e_proc_img}") import traceback as tb 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: try: 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 if not local_mpl_ok: print("PROCESS ERROR (show_3d): Matplotlib missing in child process.") return 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 missing in child. Disabling 3D plot smoothing/interpolation.") effective_smooth_sigma = None effective_interpolation_factor = 1 if hgt_data is not None: print(f"PROCESS (show_3d): Plotting '{plot_title}' (InitialSub:{initial_subsample}, Smooth:{effective_smooth_sigma}, Interp:{effective_interpolation_factor}x, PlotGridTarget:{plot_grid_points})") local_s3d(hgt_data, plot_title, initial_subsample, effective_smooth_sigma, effective_interpolation_factor, plot_grid_points) else: print("PROCESS ERROR (show_3d): No HGT data array received for plotting.") except Exception as e_proc_3d: print(f"PROCESS ERROR 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: try: 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) else: print("PROCESS ERROR (show_area): Failed to create composite area image.") except Exception as e_proc_area: print(f"PROCESS ERROR 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: child_logger = logging.getLogger("GeoElevationMapViewerChildProcess") # Configure logger if it hasn't been configured in this process yet if not child_logger.handlers: # Use basicConfig as this is a fresh process, ensure it doesn't inherit handlers from root # and send output to sys.stdout/stderr which is captured by the parent process's terminal. # Level should ideally be inherited or passed, but setting INFO here as a default. logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", stream=sys.stdout # Explicitly route to stdout ) child_map_viewer_instance: Optional[Any] = None child_map_system_ok = False try: # Attempt to import components needed in the child process from geoelevation.map_viewer.geo_map_viewer import GeoElevationMapViewer as ChildGeoMapViewer from geoelevation.elevation_manager import ElevationManager as ChildElevationManager import cv2 as child_cv2 # Ensure cv2 is available in the child process 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 process: {e_child_imp_map_corrected}") # MODIFIED: Send an error message back to the GUI queue if critical imports fail in child process. # WHY: The GUI process needs to know that the map process failed to start properly. # HOW: Put a specific error message in the queue before exiting. error_payload = {"type": "map_info_update", "latitude": None, "longitude": None, "elevation_str": f"Fatal Error: {type(e_child_imp_map_corrected).__name__}", "map_area_size_str": "Map System N/A"} try: map_interaction_q.put(error_payload) except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") # Note: No return here yet, the finally block will execute. # Let's add a return right after sending the error. return # Exit if critical libraries are missing # MODIFIED: Added the main operational logic for the map viewer process. # WHY: Centralizes the try/except/finally for the core map viewing loop. # HOW: Wrapped the map viewer initialization and display logic in a try block. try: child_logger.info(f"Initializing for mode '{operation_mode}', display scale: {display_scale_factor:.2f}") # MODIFIED: Pass the correct DEM cache directory path to the child ElevationManager. # WHY: The map viewer process needs its own ElevationManager instance, configured with the same cache path. # HOW: Used the dem_data_cache_dir parameter passed from the GUI. 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: # MODIFIED: Call display_map_for_point with the center coordinates. # WHY: This method contains the logic to stitch the map, draw markers, and send initial info. # HOW: Called the method on the initialized child_map_viewer_instance. child_map_viewer_instance.display_map_for_point(center_latitude, center_longitude) elif operation_mode == "area" and area_bounding_box: # MODIFIED: Call display_map_for_area with the area bounding box. # WHY: This method handles stitching the map for an area and potentially drawing a boundary. # HOW: Called the method on the initialized child_map_viewer_instance. child_map_viewer_instance.display_map_for_area(area_bounding_box) else: child_logger.error(f"Invalid mode ('{operation_mode}') or missing parameters passed to map process.") # MODIFIED: Send an error message to the GUI queue if the mode or parameters are invalid. # WHY: The GUI process needs feedback if the map process received bad instructions. # HOW: Put a specific error message in the queue before exiting. error_payload = {"type": "map_info_update", "latitude": None, "longitude": None, "elevation_str": f"Fatal Error: Invalid Map Args", "map_area_size_str": "Invalid Args"} try: map_interaction_q.put(error_payload) except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") # No need to call shutdown here, as the instance might not be fully initialized return # Exit on invalid parameters child_logger.info("Map display method called. Entering event loop.") # The map display window (managed by MapDisplayWindow) runs its own internal # event loop when cv2.imshow is called periodically, and handles user input via callbacks. # The loop here just needs to keep the process alive and allow the window # to process events via waitKey. We also check if the window is still alive. is_map_active = True while is_map_active: # cv2.waitKey(milliseconds) is crucial for processing window events and mouse callbacks key = child_cv2.waitKey(100) # Check for key press every 100ms (optional, but good practice) # Check if the window still exists and is active. # is_window_alive() uses cv2.getWindowProperty which is thread-safe. if child_map_viewer_instance.map_display_window_controller: if child_map_viewer_instance.map_display_window_controller.is_window_alive(): # Optional: Check for specific keys to close the window (e.g., 'q' or Escape) if key != -1: # A key was pressed child_logger.debug(f"Map window received key press: {key}") if key == ord('q') or key == 27: # 'q' or Escape key child_logger.info("Map window closing due to 'q' or Escape key press.") is_map_active = False # Signal loop to exit else: child_logger.info("Map window reported as not alive by is_window_alive().") is_map_active = False # Signal loop to exit if window closed by user 'X' button else: child_logger.info("map_display_window_controller is None. Assuming window closed or initialization failed.") is_map_active = False # Signal loop to exit child_logger.info("Map window process event loop 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) # MODIFIED: Send a fatal error message back to the GUI queue if an exception occurs in the main loop. # WHY: The GUI process needs to know if the map process crashed unexpectedly. # HOW: Put a specific error message in the queue before exiting. error_payload = {"type": "map_info_update", "latitude": None, "longitude": None, "elevation_str": f"Fatal Error: {type(e_map_proc_child_fatal_final).__name__}", "map_area_size_str": "Fatal Error"} try: map_interaction_q.put(error_payload) except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") finally: # MODIFIED: Ensure shutdown is called regardless of how the process loop exits. # WHY: Cleans up OpenCV resources. # HOW: Moved the shutdown call into the finally block. 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 main GUI application window and components. Args: parent_widget: The root Tkinter window. elevation_manager_instance: Optional existing ElevationManager instance. If None, a new one is created. """ self.root: tk.Tk = parent_widget self.elevation_manager: Optional[ElevationManager] = elevation_manager_instance # MODIFIED: Use the correctly imported GEOELEVATION_DEM_CACHE_DEFAULT constant # WHY: This constant holds the path to the default DEM cache directory # as configured in the config.py module and exposed by __init__.py. # HOW: Replaced the hardcoded or previously mis-aliased variable name # with the correct imported constant. The try...except block ensures # a fallback is used if the import somehow fails. default_dem_cache = GEOELEVATION_DEM_CACHE_DEFAULT if self.elevation_manager is None: try: if not RASTERIO_AVAILABLE: logger.warning("Rasterio library not found. Elevation functions limited.") self.elevation_manager = ElevationManager( tile_directory=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) 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 self.map_display_scale_factor_var: tk.DoubleVar = tk.DoubleVar(value=0.5) self.scale_options_map: Dict[str, float] = { "25% (1:4)": 0.25, "33% (1:3)": 0.33333, "50% (1:2)": 0.5, "75% (3:4)": 0.75, "100% (Original)": 1.0, "150% (3:2)": 1.5, "200% (2:1)": 2.0 } self.map_viewer_process_handle: Optional[multiprocessing.Process] = None self.map_interaction_message_queue: Optional[multiprocessing.Queue] = None # MODIFIED: Store the default DEM cache path to pass to the map viewer process. # WHY: The map process needs this path to initialize its own ElevationManager. # HOW: Assigned the resolved default_dem_cache to a new instance attribute. self._dem_data_cache_dir_for_map_process = default_dem_cache if MAP_VIEWER_SYSTEM_AVAILABLE: try: 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}") self._build_gui_layout() self._apply_initial_widget_states() if self.map_interaction_message_queue: # MODIFIED: Start processing the map queue periodically. # WHY: The GUI needs to receive messages from the map viewer process. # HOW: Scheduled the _process_map_interaction_queue_messages method to run after a delay. self.root.after(100, self._process_map_interaction_queue_messages) # MODIFIED: Handle window closing event gracefully. # WHY: To terminate the separate map process when the main GUI closes. # HOW: Set the WM_DELETE_WINDOW protocol to call _on_application_closing. self.root.protocol("WM_DELETE_WINDOW", self._on_application_closing) def _get_scale_display_text_from_value(self, target_scale_value: float) -> str: closest_text = "" smallest_difference = float('inf') default_display_text = "50% (1:2)" 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 = display_text if closest_text and smallest_difference < 0.01: return closest_text else: 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 def _build_gui_layout(self) -> None: 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) 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) point_actions_subframe.columnconfigure(1, weight=1) point_actions_subframe.columnconfigure(2, weight=1) 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_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) area_download_frame.columnconfigure(3, weight=1) 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_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) 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), values=scale_option_display_texts, state="readonly", width=20 ) 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) # MODIFIED: Changed LabelFrame text from "Map Click Information" to "Map Info". # WHY: The panel will now show information beyond just the click location (e.g., area size). # HOW: Updated the text option in the LabelFrame constructor. map_info_frame = ttk.LabelFrame(main_app_frame, text="Map Info", padding="10") map_info_frame.grid(row=3, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) map_info_frame.columnconfigure(1, weight=1) ttk.Label(map_info_frame, text="Latitude:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2) self.map_click_latitude_label = ttk.Label(map_info_frame, text="N/A") # Keep name for now for less refactoring self.map_click_latitude_label.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_info_frame, text="Longitude:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2) self.map_click_longitude_label = ttk.Label(map_info_frame, text="N/A") # Keep name for now self.map_click_longitude_label.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_info_frame, text="Elevation:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2) self.map_click_elevation_label = ttk.Label(map_info_frame, text="N/A") # Keep name for now self.map_click_elevation_label.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) # MODIFIED: Added a new Label for displaying the map area size. # WHY: To fulfill the requirement to show the size of the currently displayed map patch. # HOW: Created and gridded a new ttk.Label. ttk.Label(map_info_frame, text="Map Area Size:").grid(row=3, column=0, sticky=tk.W, padx=5, pady=2) self.map_area_size_label = ttk.Label(map_info_frame, text="N/A", wraplength=300, justify=tk.LEFT) self.map_area_size_label.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) main_app_frame.columnconfigure(0, weight=1) main_app_frame.rowconfigure(0, weight=0) main_app_frame.rowconfigure(1, weight=0) main_app_frame.rowconfigure(2, weight=0) main_app_frame.rowconfigure(3, weight=0) if not MAP_VIEWER_SYSTEM_AVAILABLE: map_display_options_frame.grid_remove() # MODIFIED: Also hide the renamed "Map Info" frame if map viewer system is unavailable. # WHY: The panel is only relevant when the map viewer is working. # HOW: Called grid_remove on the map_info_frame. map_info_frame.grid_remove() def _apply_initial_widget_states(self) -> None: if self.elevation_manager is None: 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 Init Failed.") self.area_download_status_label.config(text="Status: Elevation Manager Init Failed.") self.show_2d_browse_button.config(state=tk.DISABLED) self.show_3d_dem_button.config(state=tk.DISABLED) self.show_area_composite_button.config(state=tk.DISABLED) point_map_initial_state = tk.DISABLED area_map_initial_state = tk.DISABLED scale_combo_state = tk.DISABLED point_map_text = "Map (Sys N/A)" area_map_text = "Map (Sys N/A)" if MAP_VIEWER_SYSTEM_AVAILABLE: point_map_initial_state = tk.NORMAL # Enabled if system ok, but needs point data # area_map_initial_state remains DISABLED until area data is ready scale_combo_state = "readonly" point_map_text = "View Point on Map" area_map_text = "View Area on Map" self.view_map_for_point_button.config(state=point_map_initial_state, text=point_map_text) self.view_map_for_area_button.config(state=area_map_initial_state, text=area_map_text) self.map_scale_combobox.config(state=scale_combo_state) 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: self.show_3d_dem_button.config(text="DEM (MPL N/A)") def _on_map_display_scale_changed(self, event: Optional[tk.Event] = None) -> None: 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})") # MODIFIED: Add a messagebox to inform the user about the scale change application. # WHY: To clarify that the scale change applies to the *next* map view. # HOW: Display an informational messagebox. messagebox.showinfo( "Map Scale Changed", f"Map display scale set to {selected_display_text}.\n" "This will apply the next time a map view is opened.", parent=self.root ) else: logger.warning(f"Invalid map scale option selected: '{selected_display_text}'") def _set_busy_state(self, is_busy: bool) -> None: """Sets the GUI to a busy or idle state.""" self.is_processing_task = is_busy # MODIFIED: Add check for elevation_manager before configuring buttons. # WHY: Avoids AttributeError if manager init failed. # HOW: Added 'if self.elevation_manager:'. if self.elevation_manager: new_widget_state = tk.DISABLED if is_busy else tk.NORMAL self.get_elevation_button.config(state=new_widget_state) self.download_area_button.config(state=new_widget_state) # MODIFIED: Also disable/enable visualization buttons based on busy state. # WHY: Prevent triggering new tasks while one is in progress. # HOW: Include visualization buttons in the state change. self.show_2d_browse_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords and MATPLOTLIB_AVAILABLE and PIL_AVAILABLE else tk.DISABLED)) self.show_3d_dem_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords and MATPLOTLIB_AVAILABLE else tk.DISABLED)) self.show_area_composite_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_area_coords and MATPLOTLIB_AVAILABLE and PIL_AVAILABLE else tk.DISABLED)) if MAP_VIEWER_SYSTEM_AVAILABLE: # Re-enable map buttons based on busy state and data availability. self.view_map_for_point_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords else tk.DISABLED)) self.view_map_for_area_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_area_coords else tk.DISABLED)) def _validate_point_coordinates(self, lat_s: str, lon_s: str) -> Optional[Tuple[float, float]]: try: if not lat_s: raise ValueError("Latitude empty.") lat_v = float(lat_s.strip()) if not (-90.0 <= lat_v < 90.0): raise ValueError("Latitude out of [-90, 90).") if not lon_s: raise ValueError("Longitude empty.") lon_v = float(lon_s.strip()) if not (-180.0 <= lon_v < 180.0): raise ValueError("Longitude out of [-180, 180).") return lat_v, lon_v except ValueError as e: logger.error(f"Invalid coordinate: {e}"); messagebox.showerror("Input Error", f"Invalid coordinate:\n{e}", parent=self.root); return None def _validate_area_boundary_coordinates(self) -> Optional[Tuple[float, float, float, float]]: try: min_ls,max_ls = self.min_latitude_entry.get().strip(), self.max_latitude_entry.get().strip() min_os,max_os = self.min_longitude_entry.get().strip(), self.max_longitude_entry.get().strip() if not all([min_ls, max_ls, min_os, max_os]): raise ValueError("All bounds must be filled.") min_l,max_l,min_o,max_o = float(min_ls),float(max_ls),float(min_os),float(max_os) if not (-90<=min_l<90 and -90<=max_l<90 and -180<=min_o<180 and -180<=max_o<180): raise ValueError("Coordinates out of valid range.") if min_l >= max_l: raise ValueError("Min Lat >= Max Lat.") if min_o >= max_o: raise ValueError("Min Lon >= Max Lon.") return min_l, min_o, max_l, max_o except ValueError as e: logger.error(f"Invalid area: {e}"); messagebox.showerror("Input Error", f"Invalid area:\n{e}", parent=self.root); return None def _trigger_get_elevation_task(self) -> None: """Starts a background thread to get 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 lat, lon = coords # MODIFIED: Start busy state and update status label immediately on GUI thread. # WHY: Provide immediate visual feedback that processing has started. # HOW: Call _set_busy_state(True) and update label text. self._set_busy_state(True) self.point_result_label.config(text="Result: Requesting elevation... Please wait.") self.root.update_idletasks() # Force GUI update # MODIFIED: Start the elevation retrieval in a separate thread. # WHY: Prevent the GUI from freezing while waiting for network or disk I/O. # HOW: Create and start a threading.Thread. elev_thread = threading.Thread( target=self._perform_background_get_elevation, args=(lat, lon), daemon=True # Thread will exit if main program exits ) elev_thread.start() # MODIFIED: Added a new background task function for point elevation retrieval. # WHY: Encapsulates the potentially blocking operation to run in a separate thread. # HOW: Created this method to call elevation_manager.get_elevation. def _perform_background_get_elevation(self, latitude: float, longitude: float) -> None: """Background task to retrieve elevation.""" result_elevation: Optional[float] = None exception_occurred: Optional[Exception] = None try: # Call the actual elevation retrieval logic (which might involve downloads) # Using the shared manager instance in the main process. if self.elevation_manager: logger.info(f"GUI Thread: Calling elevation_manager.get_elevation for ({latitude:.5f},{longitude:.5f}) in background thread.") # This call will handle its own progress (e.g., the NullHandler logging or the CLI progress dialog if applicable) result_elevation = self.elevation_manager.get_elevation(latitude, longitude) else: raise RuntimeError("ElevationManager is not initialized.") except Exception as e: logger.exception(f"GUI Thread Error: Exception during background get_elevation for ({latitude:.5f},{longitude:.5f}).") exception_occurred = e finally: # MODIFIED: Use root.after to call the UI update method on the main GUI thread. # WHY: Tkinter GUI updates must happen on the main thread. # HOW: Schedule _get_elevation_task_complete_ui_update call. self.root.after( 0, # Schedule to run as soon as the main loop is free self._get_elevation_task_complete_ui_update, # Callback function result_elevation, # Pass the result/error back exception_occurred, latitude, longitude # Pass original coords for context ) # MODIFIED: Added a new UI update function for point elevation task completion. # WHY: Centralizes the logic to update GUI elements after the background task finishes. # HOW: Created this method to update labels and button states. def _get_elevation_task_complete_ui_update( self, elevation_result: Optional[float], exception_occurred: Optional[Exception], original_latitude: float, original_longitude: float ) -> None: """Updates GUI elements after a point elevation task completes.""" res_txt = "Result: " self.last_valid_point_coords = None # Reset valid point state initially if exception_occurred: res_txt += f"Error: {type(exception_occurred).__name__}" messagebox.showerror("Error", f"Error retrieving elevation:\n{exception_occurred}", parent=self.root) logger.error(f"GUI: Point elevation task completed with error for ({original_latitude:.5f},{original_longitude:.5f}).") elif elevation_result is None: res_txt += "Data unavailable." messagebox.showwarning("Info", "Could not retrieve elevation for the point.", parent=self.root) logger.warning(f"GUI: Point elevation task completed, data unavailable for ({original_latitude:.5f},{original_longitude:.5f}).") elif isinstance(elevation_result, float) and math.isnan(elevation_result): res_txt += "Point on NoData area." self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, result is NoData logger.info(f"GUI: Point elevation task completed, NoData for ({original_latitude:.5f},{original_longitude:.5f}).") else: res_txt += f"Elevation {elevation_result:.2f}m" self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, got elevation logger.info(f"GUI: Point elevation task completed, elevation {elevation_result:.2f}m for ({original_latitude:.5f},{original_longitude:.5f}).") self.point_result_label.config(text=res_txt) # MODIFIED: Re-enable point-specific visualization buttons if valid coords were obtained. # WHY: These actions require a valid point location. # HOW: Check if last_valid_point_coords is set and dependency libraries are available. # The _set_busy_state(False) call already handles re-enabling based on last_valid_point_coords, # but let's ensure the state is correctly set before clearing busy. if self.last_valid_point_coords: if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_2d_browse_button.config(state=tk.NORMAL) if MATPLOTLIB_AVAILABLE: self.show_3d_dem_button.config(state=tk.NORMAL) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.NORMAL) else: # Ensure buttons are disabled if no valid point was obtained self.show_2d_browse_button.config(state=tk.DISABLED) self.show_3d_dem_button.config(state=tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.DISABLED) self._set_busy_state(False) # Clear busy state, which also updates button states def _trigger_download_area_task(self) -> None: """Starts a background thread to download tiles for an area.""" if self.is_processing_task or not self.elevation_manager: return bounds = self._validate_area_boundary_coordinates() if not bounds: return self.last_area_coords = bounds # Store validated bounds # MODIFIED: Start busy state and update status label immediately on GUI thread. # WHY: Provide immediate visual feedback that processing has started. # HOW: Call _set_busy_state(True) and update label text. self._set_busy_state(True) self.area_download_status_label.config(text="Status: Starting download task... Please wait.") self.root.update_idletasks() # Force GUI update # Start download in a background thread to keep GUI responsive # Pass bounds as separate arguments to avoid potential issues with tuple packing/unpacking in target dl_thread = threading.Thread(target=self._perform_background_area_download, args=bounds, daemon=True) dl_thread.start() def _perform_background_area_download(self, min_l: float, min_o: float, max_l: float, max_o: float) -> None: """Background task for downloading area tiles.""" status_str, success_bool, processed_count, obtained_count = "Status: Unknown error.", False, 0, 0 try: # Update status label on the GUI thread using root.after self.root.after(0, lambda: self.area_download_status_label.config(text="Status: Downloading...")) if self.elevation_manager: logger.info(f"GUI Thread: Starting background download for area: Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}].") # Call ElevationManager method to perform the download processed_count, obtained_count = self.elevation_manager.download_area(min_l, min_o, max_l, max_o) status_str = f"Status: Complete. Processed {processed_count}, Obtained {obtained_count} HGT." success_bool = True logger.info(f"GUI Thread: Background download complete: {status_str}") else: status_str = "Status: Error - Elevation Manager N/A." logger.error("GUI Thread: Background download failed - Elevation Manager is None.") except Exception as e: logger.exception("GUI Thread Error: Unhandled exception during area download task.") status_str = f"Status: Error: {type(e).__name__}" finally: # Update GUI and re-enable buttons on the GUI thread regardless of success/failure self.root.after(0, self._area_download_task_complete_ui_update, status_str, success_bool, processed_count, obtained_count) def _area_download_task_complete_ui_update(self, msg: str, success: bool, proc: int, obt: int) -> None: """Updates GUI after area download task finishes.""" self.area_download_status_label.config(text=msg) # MODIFIED: Re-enable area visualization buttons if the download was successful. # WHY: These actions require downloaded area data. # HOW: Check success flag and library availability. if success: summary = f"Processed {proc} tile locations.\nSuccessfully obtained {obt} HGT files (downloaded or found in cache)." messagebox.showinfo("Download Complete", summary, parent=self.root) if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: # Re-enable composite image button only if at least one tile was obtained self.show_area_composite_button.config(state=tk.NORMAL if obt > 0 else tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: # Re-enable area map button only if at least one tile was obtained self.view_map_for_area_button.config(state=tk.NORMAL if obt > 0 else tk.DISABLED) else: err_brief = msg.split(":")[-1].strip() if ":" in msg else "An error occurred." messagebox.showerror("Download Error", f"Area download failed:\n{err_brief}\nCheck logs for details.", parent=self.root) # MODIFIED: Ensure area visualization buttons are disabled on failure. # WHY: No data available for visualization. # HOW: Explicitly disable buttons. 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._set_busy_state(False) # Clear busy state, which also updates button states def _start_visualization_process(self, func: callable, args_t: tuple, name_id: str) -> None: """Helper to start a visualization task in a separate process.""" logger.info(f"GUI: Attempting to start visualization process '{name_id}'...") try: # Configure logging for the child process if needed (optional, can also configure in target function) # multiprocessing.log_to_stderr(logging.DEBUG) # Example: uncomment for verbose process logging proc = multiprocessing.Process(target=func, args=args_t, daemon=True, name=name_id) proc.start() logger.info(f"GUI: Started process '{name_id}' (PID: {proc.pid}).") # Note: The GUI main thread does not wait for these visualization processes to finish. except Exception as e: logger.exception(f"GUI Error: Failed to start visualization process '{name_id}'.") messagebox.showerror("Process Error", f"Could not start {name_id}:\n{e}", parent=self.root) def _trigger_2d_browse_display(self) -> None: """Triggers display of the browse image for the last valid point.""" if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): messagebox.showwarning("Dependencies Missing", "Matplotlib or Pillow not available.", parent=self.root); return if not self.last_valid_point_coords or not self.elevation_manager: messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return lat, lon = self.last_valid_point_coords logger.info(f"GUI: Requesting 2D browse image display for ({lat:.5f},{lon:.5f}).") # get_tile_info ensures browse image download is *attempted*, but doesn't guarantee success. info = self.elevation_manager.get_tile_info(lat, lon) # Pass image path and tile name to the target process if info and info.get("browse_available") and info.get("browse_image_path"): browse_img_path = info["browse_image_path"] tile_name = info.get("tile_base_name","?") window_title = f"Browse: {tile_name.upper()}" # The process target will load and display the image from the path args = (browse_img_path, tile_name, window_title) self._start_visualization_process(process_target_show_image, args, "2DBrowse") else: logger.warning(f"GUI: Browse image not available for ({lat:.5f},{lon:.5f}).") messagebox.showinfo("Image Info", "Browse image not available for this tile.", parent=self.root) def _trigger_3d_dem_display(self) -> None: """Triggers display of the 3D DEM plot for the last valid point.""" if not MATPLOTLIB_AVAILABLE: messagebox.showwarning("Dependencies Missing", "Matplotlib not available.", parent=self.root); return # SciPy is optional for advanced features but required by the plotting function implementation # Let's make sure we warn the user if SciPy is needed for full functionality if not SCIPY_AVAILABLE: logger.warning("GUI: SciPy not available. Advanced 3D plot features (smoothing/interpolation) will be disabled.") # Decide if we want to show a warning box here or just rely on the log. # messagebox.showwarning("Dependencies Missing", "SciPy not available. Advanced 3D features disabled.", parent=self.root) if not self.last_valid_point_coords or not self.elevation_manager: messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return lat, lon = self.last_valid_point_coords logger.info(f"GUI: Requesting 3D DEM display for ({lat:.5f},{lon:.5f}).") # get_hgt_data ensures HGT download is *attempted*. data = self.elevation_manager.get_hgt_data(lat, lon) if data is not None: # Retrieve tile info again for tile name, even if HGT data was obtained directly. # get_tile_info won't re-download if HGT is already there. info = self.elevation_manager.get_tile_info(lat, lon) tile_name = info.get("tile_base_name","?") if info else "?" plot_title = f"3D View: Tile {tile_name.upper()}" # Configuration parameters for the 3D plot processing (passed to the target process) # These can be made configurable in the GUI if needed later. config_initial_subsample = 1 # Initial subsampling factor of the raw data config_smooth_sigma = 0.5 # Sigma for Gaussian smoothing (set to None to disable) config_interpolation_factor = 3 # Interpolation factor (e.g., 3x for 3x denser grid) config_plot_grid_points = 300 # Target number of points along each axis for the final plot grid # Pass the raw NumPy array and config to the target process # The process target will handle data processing (subsampling, smoothing, interpolation) and plotting. args = (data, plot_title, config_initial_subsample, config_smooth_sigma, config_interpolation_factor, config_plot_grid_points) self._start_visualization_process(process_target_show_3d, args, "3DDEM") else: logger.warning(f"GUI: HGT data not available for ({lat:.5f},{lon:.5f}). Cannot show 3D plot.") messagebox.showerror("3D Data Error", "Could not retrieve HGT data for this tile.", parent=self.root) def _trigger_area_composite_display(self) -> None: """Triggers display of the composite browse image for the last downloaded area.""" if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): messagebox.showwarning("Dependencies Missing", "Matplotlib or Pillow not available.", parent=self.root); return if not self.last_area_coords or not self.elevation_manager: messagebox.showinfo("Info", "Please download an area first.", parent=self.root); return min_l, min_o, max_l, max_o = self.last_area_coords logger.info(f"GUI: Requesting area composite display for area: Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}]") # get_area_tile_info returns info for tiles that *should* cover the area, # and includes paths to locally available files (doesn't trigger download here). info_list = self.elevation_manager.get_area_tile_info(min_l, min_o, max_l, max_o) # Check if any browse images are potentially available before starting the process available_browse_images = [ info for info in info_list if info.get("browse_available") and info.get("browse_image_path") ] if not available_browse_images: logger.warning("GUI: No browse images found for the downloaded area.") messagebox.showinfo("Area Info", "No browse images were found locally for the specified area.", parent=self.root); return title = f"Area: Lat [{min_l:.1f}-{max_l:.1f}], Lon [{min_o:.1f}-{max_o:.1f}]" # Pass the filtered list of available info dicts to the process # The process target will create the composite image from these. args = (available_browse_images, title) self._start_visualization_process(process_target_create_show_area, args, "AreaComposite") def _start_map_viewer_process(self, base_args_tuple: tuple) -> None: """Helper to start the map viewer process.""" if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root) logger.error("GUI: Map viewer process start failed: MAP_VIEWER_SYSTEM_AVAILABLE is False.") return if not self.map_interaction_message_queue: logger.error("GUI: Cannot start map viewer: interaction queue not initialized."); messagebox.showerror("Internal Error", "Map communication queue not ready.", parent=self.root); return # Check if a map viewer process is already running if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): messagebox.showinfo("Info", "Map viewer is already open.", parent=self.root) logger.info("GUI: Map viewer process already alive.") return logger.info("GUI: Attempting to start map viewer process...") try: # The target function `run_map_viewer_process_target` requires the DEM cache directory path. # This was stored during GUI initialization. dem_cache_dir = self._dem_data_cache_dir_for_map_process # Include the DEM cache directory and display scale in the arguments tuple. # base_args_tuple already contains (map_interaction_q, operation_mode, center_latitude, center_longitude, area_bounding_box). # Add dem_cache_dir and display_scale_factor to the end. scale_val = self.map_display_scale_factor_var.get() full_args = base_args_tuple + (dem_cache_dir, scale_val) self.map_viewer_process_handle = multiprocessing.Process( target=run_map_viewer_process_target, args=full_args, daemon=True, # Daemon process will be terminated when the main GUI process exits name="GeoElevationMapViewer" ) self.map_viewer_process_handle.start() logger.info(f"GUI: Started map viewer process PID: {self.map_viewer_process_handle.pid}, Scale: {scale_val:.3f}.") # Ensure the GUI starts checking the queue for messages from the new process. # The initial `self.root.after` call in __init__ handles this, but doesn't hurt to be sure. # self.root.after(100, self._process_map_interaction_queue_messages) # Already scheduled except Exception as e: logger.exception("GUI Error: Failed to start map viewer process.") messagebox.showerror("Process Error",f"Could not start map viewer:\n{e}",parent=self.root) def _trigger_view_map_for_point(self) -> None: """Triggers the display of a map centered on the last valid point.""" # MODIFIED: Check map system availability first. # WHY: Prevent starting process logic if dependencies are missing. # HOW: Added initial check. if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Map System Unavailable", "Map viewer dependencies are not available.", parent=self.root); return if not self.last_valid_point_coords: messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return lat, lon = self.last_valid_point_coords logger.info(f"GUI: Requesting map view for point ({lat:.5f},{lon:.5f}).") # Prepare base arguments for the map viewer process target function. # It needs the queue, mode, point coords, area bbox (None for point mode), and cache dir (added in _start_map_viewer_process). # The display scale is also added in _start_map_viewer_process. base_args = (self.map_interaction_message_queue, "point", lat, lon, None) # Call the helper to start the process self._start_map_viewer_process(base_args) def _trigger_view_map_for_area(self) -> None: """Triggers the display of a map covering the last downloaded area.""" # MODIFIED: Check map system availability first. # WHY: Prevent starting process logic if dependencies are missing. # HOW: Added initial check. if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Map System Unavailable", "Map viewer dependencies are not available.", parent=self.root); return if not self.last_area_coords: messagebox.showinfo("Info", "Please download an area first.", parent=self.root); return min_l, min_o, max_l, max_o = self.last_area_coords logger.info(f"GUI: Requesting map view for area Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}].") # Prepare base arguments for the map viewer process target function. # It needs the queue, mode, point coords (None for area mode), area bbox, and cache dir (added in _start_map_viewer_process). # The display scale is also added in _start_map_viewer_process. area_bbox_for_process = (min_o, min_l, max_o, max_l) # Pass as (west, south, east, north) as expected by map_utils/geo_map_viewer base_args = (self.map_interaction_message_queue, "area", None, None, area_bbox_for_process) # Call the helper to start the process 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 and updates the GUI.""" if not self.map_interaction_message_queue: logger.debug("GUI: Map interaction queue is not initialized, stopping message processing.") return # Stop scheduling if queue is gone try: # Process all messages currently in the queue without blocking while not self.map_interaction_message_queue.empty(): msg = self.map_interaction_message_queue.get_nowait() msg_t = msg.get("type") # MODIFIED: Handle the new message type 'map_info_update'. # WHY: This message is sent by the map process for initial point info, click info, and errors. # HOW: Check for the type and update all relevant Map Info labels. if msg_t == "map_info_update": lat_val = msg.get("latitude") lon_val = msg.get("longitude") elev_str = msg.get("elevation_str", "N/A") # Default to N/A if key is missing map_area_size_str = msg.get("map_area_size_str", "N/A") # Default to N/A # Format coordinates for display lat_txt = f"{lat_val:.5f}" if isinstance(lat_val, (int, float)) and not math.isnan(lat_val) else "N/A" lon_txt = f"{lon_val:.5f}" if isinstance(lon_val, (int, float)) and not math.isnan(lon_val) else "N/A" self.map_click_latitude_label.config(text=lat_txt) self.map_click_longitude_label.config(text=lon_txt) self.map_click_elevation_label.config(text=elev_str) # MODIFIED: Update the new map area size label. # WHY: Display the size received from the map process. # HOW: Set the text of map_area_size_label. self.map_area_size_label.config(text=map_area_size_str) logger.debug(f"GUI: Updated Map Info panel with: Lat={lat_txt}, Lon={lon_txt}, Elev='{elev_str}', Size='{map_area_size_str}'") # MODIFIED: Handle the new message type 'map_fetching_status'. # WHY: Receive progress/status updates specifically for map tile fetching. # HOW: Update the map info labels with the status message. elif msg_t == "map_fetching_status": status_message = msg.get("status", "Fetching...") # Update all map info labels to show the status, maybe clear coords/elev temporarily self.map_click_latitude_label.config(text="") # Clear coords self.map_click_longitude_label.config(text="") self.map_click_elevation_label.config(text=status_message) # Show status as elevation text self.map_area_size_label.config(text="") # Clear size # elif msg_t == "another_message_type": # # Handle other message types if needed in the future # pass else: # Log any messages with unrecognized types logger.warning(f"GUI: Received unrecognized message type from map queue: {msg_t} (Full message: {msg})") except queue.Empty: # This exception is expected when the queue is empty, just means no messages were ready. pass except Exception as e: # Catch any other unexpected errors while processing messages logger.error(f"GUI Error: Exception during map queue message processing: {e}", exc_info=True) # Optional: Update GUI status to indicate communication error finally: # Schedule this method to run again after a short delay to keep checking the queue. # Only reschedule if the root window still exists. if self.root.winfo_exists(): self.root.after(250, self._process_map_interaction_queue_messages) else: logger.debug("GUI: Root window no longer exists, stopping map queue message processing scheduling.") def _on_application_closing(self) -> None: """Handles cleanup when the main application window is closed.""" logger.info("Main GUI application closing...") # MODIFIED: Terminate the map viewer process if it's running. # WHY: The separate process should not be left running when the main application exits. # HOW: Check if the process handle exists and is alive, then attempt to terminate/kill it. if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): logger.info(f"GUI: Terminating active map viewer process PID: {self.map_viewer_process_handle.pid}...") try: # Use terminate (sends SIGTERM) first for graceful shutdown self.map_viewer_process_handle.terminate() # Wait a bit for the process to exit cleanly self.map_viewer_process_handle.join(timeout=3.0) # Increased timeout slightly # If it's still alive, resort to killing (SIGKILL) if self.map_viewer_process_handle.is_alive(): logger.warning(f"GUI: Map viewer process PID {self.map_viewer_process_handle.pid} did not terminate cleanly. Killing.") self.map_viewer_process_handle.kill() self.map_viewer_process_handle.join(timeout=1.0) # Wait briefly after killing if self.map_viewer_process_handle.is_alive(): logger.error(f"GUI: Map viewer process PID {self.map_viewer_process_handle.pid} is still alive after killing. May be orphaned.") else: logger.info(f"GUI: Map viewer process PID {self.map_viewer_process_handle.pid} has exited.") except Exception as e_terminate: logger.exception(f"GUI Error: Exception during map viewer process termination:") # MODIFIED: Close the multiprocessing queue if it exists. # WHY: Release resources associated with the queue. # HOW: Call close() and potentially join_thread(). if self.map_interaction_message_queue: logger.debug("GUI: Closing map interaction queue.") try: # Closing the queue signals that no more data will be added. self.map_interaction_message_queue.close() # join_thread() waits for the background thread that flushes the queue buffer. # Can sometimes block if the other end is gone or blocked. Use a timeout. # self.map_interaction_message_queue.join_thread(timeout=1.0) # Optional, may hang except Exception as e_queue_close: logger.warning(f"GUI Error: Exception closing map queue: {e_queue_close}") # Finally, destroy the main Tkinter window self.root.destroy() logger.info("Main GUI window destroyed.") # --- Main Execution Block (for direct script testing) --- if __name__ == "__main__": print("Running elevation_gui.py directly for testing purposes...") # Configure logging for the main process when running this script directly logging.basicConfig(level=logging.DEBUG, format="%(asctime)s-%(levelname)s-%(name)s-%(module)s-%(message)s", stream=sys.stdout) logger.info(f"TEST: Rasterio Avail: {RASTERIO_AVAILABLE}, PIL Avail: {PIL_AVAILABLE}, MPL Avail: {MATPLOTLIB_AVAILABLE}, SciPy Avail: {SCIPY_AVAILABLE}, MapViewerSys Avail: {MAP_VIEWER_SYSTEM_AVAILABLE}") # multiprocessing.freeze_support() is essential for creating standalone executables # that use multiprocessing, especially on Windows. It must be called within the # main block of the script that starts the other processes. # MODIFIED: Keep freeze_support() here in the __main__ block as it's the entry point for the GUI when run directly. # WHY: It's a requirement for multiprocessing when the script is frozen (e.g., by PyInstaller). # HOW: Keep the call here. It's also called in geoelevation/__main__.py, which is the # primary entry point for the package as a whole. Calling it here ensures it runs # if *this specific script* is executed directly (python elevation_gui.py), which is # only for testing but good practice. multiprocessing.freeze_support() test_root = tk.Tk() app_test_instance: Optional[ElevationApp] = None try: app_test_instance = ElevationApp(test_root) test_root.mainloop() except Exception as e_test_run: logger.critical(f"Fatal error in direct test run: {e_test_run}", exc_info=True) # Attempt cleanup on fatal error if app_test_instance: try: app_test_instance._on_application_closing() except Exception as e_cleanup: logger.error(f"Error during cleanup after fatal test run error: {e_cleanup}") elif test_root.winfo_exists(): # If app instance wasn't created, just destroy the root if it still exists test_root.destroy() sys.exit(1) # Exit with error code