SXXXXXXX_GeoElevation/geoelevation/elevation_gui.py
2025-05-14 09:31:53 +02:00

1247 lines
75 KiB
Python

# 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: This constant holds the path to the default DEM cache directory
# as configured in the.config.py module and exposed by __init__.py.
# 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
# MODIFIED: Import the deg_to_dms_string utility function from map_utils.py
# WHY: Needed to convert coordinates to DMS format for the GUI display.
# HOW: Added the import statement from the map_viewer.map_utils sub-module.
from geoelevation.map_viewer.map_utils import deg_to_dms_string
# MODIFIED: Import the utility functions for geographic bounds calculation from map_utils.py.
# WHY: Needed to get geographic bounds for 2D plot aspect ratio correction.
# HOW: Added the import statements.
from geoelevation.map_viewer.map_utils import get_hgt_tile_geographic_bounds # For single tile browse
from geoelevation.map_viewer.map_utils import get_combined_geographic_bounds_from_tile_info_list # For area composite
# MODIFIED: Import the multiprocessing target functions from the new module.
# WHY: These functions have been moved to their own module as part of refactoring.
# HOW: Added the import statement.
from geoelevation import process_targets # Import the new module containing targets
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"
# MODIFIED: Define dummy deg_to_dms_string and bounds functions if import fails.
# WHY: Avoid NameError if the imports fail critically.
# HOW: Define simple functions that return default/error values.
def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore
return "Import Error"
# Dummy bounds functions
def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Optional[Tuple[float, float, float, float]]: # type: ignore
logger.error("map_utils bounds functions not available due to import error.")
return None
def get_combined_geographic_bounds_from_tile_info_list(tile_info_list: List[Dict]) -> Optional[Tuple[float, float, float, float]]: # type: ignore
logger.error("map_utils bounds functions not available due to import error.")
return None
# MODIFIED: Define dummy process_targets object if the module import fails.
# WHY: Prevent NameError when trying to access process_targets attributes.
# HOW: Define a simple dummy object.
class process_targets: # type: ignore
@staticmethod
def process_target_show_image(*args: Any, **kwargs: Any) -> None: pass
@staticmethod
def process_target_show_3d(*args: Any, **kwargs: Any) -> None: pass
@staticmethod
def process_target_create_show_area(*args: Any, **kwargs: Any) -> None: pass
@staticmethod
def run_map_viewer_process_target(*args: Any, **kwargs: Any) -> None: pass
logging.getLogger(__name__).critical(
f"elevation_gui.py: CRITICAL - Could not import core dependencies from geoelevation package. "
f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. Some features unavailable."
)
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 (MOVED TO process_targets.py) ---
# The actual functions are now imported from geoelevation.process_targets
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: 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.
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")
# MODIFIED: Increased minsize height to accommodate new info fields.
# WHY: Need more space for decimal and DMS coordinate fields.
# HOW: Changed the minsize tuple.
self.root.minsize(480, 580) # Increased height
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 # MODIFIED: Ensure this attribute is initialized here.
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
# MODIFIED: Declare StringVar instances for the Map Info Entry widgets.
# WHY: Tkinter Entry widgets are typically managed via associated StringVar instances for dynamic updates.
# HOW: Declared new instance attributes of type tk.StringVar.
self.map_lat_decimal_var: tk.StringVar = tk.StringVar(value="N/A")
self.map_lon_decimal_var: tk.StringVar = tk.StringVar(value="N/A")
self.map_lat_dms_var: tk.StringVar = tk.StringVar(value="N/A") # Added DMS variables
self.map_lon_dms_var: tk.StringVar = tk.StringVar(value="N/A") # Added DMS variables
self.map_elevation_var: tk.StringVar = tk.StringVar(value="N/A")
self.map_area_size_var: tk.StringVar = tk.StringVar(value="N/A")
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("<<ComboboxSelected>>", 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))
# MODIFIED: Configured columns to stretch, important for Entry widgets
# WHY: Allows the Entry widgets to fill available horizontal space.
# HOW: Added columnconfigure calls.
map_info_frame.columnconfigure(1, weight=1) # Column for Entry widgets
# --- Map Info Widgets ---
# MODIFIED: Replace ttk.Label for displaying values with ttk.Entry (readonly)
# WHY: To make the displayed information copyable.
# HOW: Changed widget type, configured state and textvariable, adjusted grid row/column.
ttk.Label(map_info_frame, text="Latitude (Dec):").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") # Old Label
self.map_lat_decimal_entry = ttk.Entry(map_info_frame, textvariable=self.map_lat_decimal_var, state="readonly", width=25)
self.map_lat_decimal_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
ttk.Label(map_info_frame, text="Longitude (Dec):").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") # Old Label
self.map_lon_decimal_entry = ttk.Entry(map_info_frame, textvariable=self.map_lon_decimal_var, state="readonly", width=25)
self.map_lon_decimal_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
# MODIFIED: Add new Labels and Entries for DMS format.
# WHY: To display coordinates in DMS format as requested.
# HOW: Added new rows with Labels and Entry widgets.
ttk.Label(map_info_frame, text="Latitude (DMS):").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2)
self.map_lat_dms_entry = ttk.Entry(map_info_frame, textvariable=self.map_lat_dms_var, state="readonly", width=25)
self.map_lat_dms_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
ttk.Label(map_info_frame, text="Longitude (DMS):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=2)
self.map_lon_dms_entry = ttk.Entry(map_info_frame, textvariable=self.map_lon_dms_var, state="readonly", width=25)
self.map_lon_dms_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
ttk.Label(map_info_frame, text="Elevation:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=2)
# self.map_click_elevation_label = ttk.Label(map_info_frame, text="N/A") # Old Label
self.map_elevation_entry = ttk.Entry(map_info_frame, textvariable=self.map_elevation_var, state="readonly", width=25)
self.map_elevation_entry.grid(row=4, 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=5, column=0, sticky=tk.W, padx=5, pady=2)
# MODIFIED: Make Area Size display also an Entry for copyability.
# WHY: Consistency and copyability for all map info fields.
# HOW: Changed widget type, configured state and textvariable.
# self.map_area_size_label = ttk.Label(map_info_frame, text="N/A", wraplength=300, justify=tk.LEFT) # Old Label
self.map_area_size_entry = ttk.Entry(map_info_frame, textvariable=self.map_area_size_var, state="readonly", width=25) # Increased width slightly
self.map_area_size_entry.grid(row=5, 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)")
# MODIFIED: Set initial text for Map Info Entry widgets.
# WHY: Display default "N/A" and ensure text variables are linked correctly.
# HOW: Use set() on the StringVar instances.
self.map_lat_decimal_var.set("N/A")
self.map_lon_decimal_var.set("N/A")
self.map_lat_dms_var.set("N/A") # Set initial value for DMS fields
self.map_lon_dms_var.set("N/A") # Set initial value for DMS fields
self.map_elevation_var.set("N/A")
self.map_area_size_var.set("N/A")
# MODIFIED: Ensure Map Info Entry widgets are in readonly state initially.
# WHY: They should not be editable by the user.
# HOW: Set the 'state' option on the Entry widgets. (Already done in _build_gui_layout, but can double-check here if needed).
# self.map_lat_decimal_entry.config(state="readonly") # Redundant with _build_gui_layout
# ... and so on for other map info entries.
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].") # Allow 90 exactly for validation
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].") # Allow 180 exactly for validation
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)
# MODIFIED: Adjusted validation range to allow 90 and 180 exactly.
# WHY: Standard geographic range includes these boundaries.
# HOW: Changed < 90.0 to <= 90.0 and < 180.0 to <= 180.0.
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 [-90, 90] / [-180, 180].")
# The order check should still be strict for a defined bounding box area.
if min_l >= max_l: raise ValueError("Min Lat must be less than Max Lat.")
# Handle potential longitude wrap-around if min_o > max_o but the range is valid (e.g., crosses antimeridian)
# For simplicity in GUI input, let's assume min_o should generally be <= max_o unless explicitly designing for wrap-around input.
# Let's keep the simple check for now.
if min_o >= max_o: raise ValueError("Min Lon must be less than Max Lon.") # This simplifies downstream logic
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:
# MODIFIED: Clear Map Info fields if point validation fails.
# WHY: Avoids showing stale info if the user enters invalid coords.
# HOW: Call the UI update method with None values.
self._get_elevation_task_complete_ui_update(None, ValueError("Invalid point coordinates"), None, None)
return # Exit if validation fails
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.")
# MODIFIED: Clear Map Info fields when a new Get Elevation task starts.
# WHY: Indicate that new information is being fetched.
# HOW: Set StringVar values to "..." or empty.
self.map_lat_decimal_var.set("...") # Indicate loading
self.map_lon_decimal_var.set("...")
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
self.map_elevation_var.set("...")
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
self.root.update_idletasks() # Force GUI update
# Start elevation retrieval in a background 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()
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.
# MODIFIED: Pass original coords even on error for UI update context.
# WHY: The UI update method needs the original coordinates to populate the entry fields, even if the elevation fetch failed.
# HOW: Pass latitude, longitude as arguments.
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.
# MODIFIED: Updated to populate Map Info Entry widgets with Decimal and DMS coordinates and Elevation.
# WHY: Implement Feature 3 - show coordinates in Entry widgets and in DMS format.
# HOW: Access StringVar instances and set their values. Use deg_to_dms_string for conversion.
def _get_elevation_task_complete_ui_update(
self,
elevation_result: Optional[float],
exception_occurred: Optional[Exception],
original_latitude: Optional[float], # Now Optional as it might be None on validation failure
original_longitude: Optional[float] # Now Optional as it might be None on validation failure
) -> None:
"""Updates GUI elements after a point elevation task completes."""
res_txt = "Result: "
self.last_valid_point_coords = None # Reset valid point state initially
# MODIFIED: Clear/reset Map Info fields if coordinates were not successfully obtained or were invalid.
# WHY: Ensure the Map Info panel reflects the status correctly.
# HOW: Reset StringVars to "N/A".
if original_latitude is None or original_longitude is None:
res_txt += f"Error: {type(exception_occurred).__name__} (Invalid Input)" if exception_occurred else "Input Error."
logger.error(f"GUI: Point elevation task completed with invalid input.")
self.map_lat_decimal_var.set("N/A")
self.map_lon_decimal_var.set("N/A")
self.map_lat_dms_var.set("N/A")
self.map_lon_dms_var.set("N/A")
self.map_elevation_var.set("N/A")
self.map_area_size_var.set("N/A") # Area size doesn't apply to a single point result, but clear it.
elif 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}).")
# MODIFIED: Update Map Info fields with error state.
# WHY: Show the error message in the GUI panel.
# HOW: Set StringVars.
self.map_lat_decimal_var.set(f"{original_latitude:.5f}") # Show input coords
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
# MODIFIED: Convert available coordinates to DMS even if elevation failed, if the coords are valid.
# WHY: Show DMS coordinates if the input was valid, even if data wasn't available.
# HOW: Use deg_to_dms_string.
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
self.map_elevation_var.set(f"Error: {type(exception_occurred).__name__}")
self.map_area_size_var.set("N/A") # Area size doesn't apply
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}).")
# MODIFIED: Update Map Info fields for data unavailable state.
# WHY: Show results in the GUI panel.
# HOW: Set StringVars.
self.map_lat_decimal_var.set(f"{original_latitude:.5f}")
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
self.map_elevation_var.set("Unavailable")
self.map_area_size_var.set("N/A") # Area size doesn't apply
self.last_valid_point_coords = (original_latitude, original_longitude) # Coords were valid, but no data
elif isinstance(elevation_result, float) and math.isnan(elevation_result):
res_txt += "Point on NoData area."
logger.info(f"GUI: Point elevation task completed, NoData for ({original_latitude:.5f},{original_longitude:.5f}).")
# MODIFIED: Update Map Info fields for NoData state.
# WHY: Show results in the GUI panel.
# HOW: Set StringVars.
self.map_lat_decimal_var.set(f"{original_latitude:.5f}")
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
self.map_elevation_var.set("NoData")
self.map_area_size_var.set("N/A") # Area size doesn't apply
self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, result is NoData
else:
res_txt += f"Elevation {elevation_result:.2f}m"
logger.info(
f"GUI: Point elevation task completed, elevation {elevation_result:.2f}m for ({original_latitude:.5f},{original_longitude:.5f})."
)
# MODIFIED: Update Map Info fields for successful elevation retrieval.
# WHY: Show results in the GUI panel.
# HOW: Set StringVars.
self.map_lat_decimal_var.set(f"{original_latitude:.5f}")
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
self.map_elevation_var.set(f"{elevation_result:.2f} m")
self.map_area_size_var.set("N/A") # Area size doesn't apply
self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, got elevation
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:
# MODIFIED: Clear area status and related map info fields if area validation fails.
# WHY: Avoids showing stale info.
# HOW: Update labels and Map Info StringVars.
self.area_download_status_label.config(text="Status: Invalid input.")
self.last_area_coords = None
# Also clear map info fields that are area-related or generic
self.map_area_size_var.set("N/A")
# Optionally clear point-specific map info fields too if they don't apply to area context
# self.map_lat_decimal_var.set("N/A") # Decide if we want to clear point info on area task start
# self.map_lon_decimal_var.set("N/A")
# self.map_lat_dms_var.set("N/A")
# self.map_lon_dms_var.set("N/A")
# self.map_elevation_var.set("N/A")
return # Exit if validation fails
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.")
# MODIFIED: Clear Map Info fields when a new Download Area task starts.
# WHY: Indicate that the context has shifted to an area task.
# HOW: Set StringVar values to "..." or empty.
self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view
self.map_lon_decimal_var.set("N/A")
self.map_lat_dms_var.set("N/A") # Point info not relevant for area view
self.map_lon_dms_var.set("N/A") # Point info not relevant for area view
self.map_elevation_var.set("N/A") # Point info not relevant for area view
self.map_area_size_var.set("...") # Show progress for area size info related to map
self.root.update_idletasks() # Force GUI update
# Start download in a background thread
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()}"
# MODIFIED: Get geographic bounds for the single tile.
# WHY: Needed to pass as extent to Matplotlib for correct aspect ratio.
# HOW: Use get_hgt_tile_geographic_bounds and format as [west, east, south, north].
tile_geo_bounds = None
if info.get("hgt_available"): # Get bounds only if HGT is available (lat/lon coords should be in info)
tile_lat_coord = info.get("latitude_coord")
tile_lon_coord = info.get("longitude_coord")
if tile_lat_coord is not None and tile_lon_coord is not None:
bounds_tuple = get_hgt_tile_geographic_bounds(tile_lat_coord, tile_lon_coord)
if bounds_tuple:
# Format as [left, right, bottom, top] which is [west, east, south, north]
tile_geo_bounds = [bounds_tuple[0], bounds_tuple[2], bounds_tuple[1], bounds_tuple[3]]
logger.debug(f"Passing geographic extent {tile_geo_bounds} for browse image.")
else:
logger.warning("Could not get precise geographic bounds for single tile, aspect ratio might be incorrect.")
else:
logger.warning("Tile coordinates missing in info, cannot get precise geographic bounds for browse image aspect ratio.")
else:
logger.warning("HGT not available for tile, cannot get precise geographic bounds for browse image aspect ratio.")
# Proceed without extent, image will be displayed assuming square pixels.
# The process target will load and display the image from the path
# MODIFIED: Pass the extent to the process target.
# WHY: So the target function can pass it to show_image_matplotlib.
# HOW: Added extent to the args tuple.
args = (browse_img_path, tile_name, window_title, tile_geo_bounds)
self._start_visualization_process(process_targets.process_target_show_image, args, "2DBrowse") # Call imported target
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_targets.process_target_show_3d, args, "3DDEM") # Call imported target
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
# MODIFIED: Calculate combined geographic bounds for all relevant tiles in the area.
# WHY: Needed to pass as extent to Matplotlib for correct aspect ratio of the composite.
# HOW: Use get_combined_geographic_bounds_from_tile_info_list on the full info_list.
combined_geo_bounds = get_combined_geographic_bounds_from_tile_info_list(info_list)
composite_extent = None
if combined_geo_bounds:
# Format as [left, right, bottom, top] which is [west, east, south, north]
composite_extent = [combined_geo_bounds[0], combined_geo_bounds[2], combined_geo_bounds[1], combined_geo_bounds[3]]
logger.debug(f"Passing combined geographic extent {composite_extent} for area composite.")
else:
logger.warning("Could not get combined geographic bounds for area composite, aspect ratio might be incorrect.")
# Proceed without extent.
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.
# MODIFIED: Pass the combined extent to the process target.
# WHY: So the target function can pass it to show_image_matplotlib.
# HOW: Added extent to the args tuple.
args = (available_browse_images, title, composite_extent)
self._start_visualization_process(process_targets.process_target_create_show_area, args, "AreaComposite") # Call imported target
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); 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=process_targets.run_map_viewer_process_target, # Call imported 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}).")
# MODIFIED: Clear Map Info fields when starting a new map view.
# WHY: Indicate that new map information is loading.
# HOW: Set StringVar values.
self.map_lat_decimal_var.set("...") # Indicate loading
self.map_lon_decimal_var.set("...")
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
self.map_elevation_var.set("...")
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
# 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}].")
# MODIFIED: Clear Map Info fields when starting a new map view.
# WHY: Indicate that new map information is loading.
# HOW: Set StringVar values. Point info N/A for area view.
self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view
self.map_lon_decimal_var.set("N/A")
self.map_lat_dms_var.set("N/A") # Point info not relevant for area view
self.map_lon_dms_var.set("N/A") # Point info not relevant for area view
self.map_elevation_var.set("N/A") # Point info not relevant for area view
self.map_area_size_var.set("Loading...")
# 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.
# MODIFIED: Update Entry widgets using StringVar and handle new DMS fields from message.
# WHY: Populate the copyable Entry widgets and show DMS coordinates.
# HOW: Use set() on StringVars, access new keys in the message payload.
if msg_t == "map_info_update":
# Get data from the message payload
lat_val = msg.get("latitude")
lon_val = msg.get("longitude")
lat_dms_str = msg.get("latitude_dms_str", "N/A") # Get DMS strings from message
lon_dms_str = msg.get("longitude_dms_str", "N/A") # Get DMS strings from message
elev_str = msg.get("elevation_str", "N/A")
map_area_size_str = msg.get("map_area_size_str", "N/A")
# Format decimal coordinates for display in Entry
lat_txt_decimal = f"{lat_val:.5f}" if isinstance(lat_val, (int, float)) and math.isfinite(lat_val) else "N/A"
lon_txt_decimal = f"{lon_val:.5f}" if isinstance(lon_val, (int, float)) and math.isfinite(lon_val) else "N/A"
# Update the StringVars linked to the Entry widgets
self.map_lat_decimal_var.set(lat_txt_decimal)
self.map_lon_decimal_var.set(lon_txt_decimal)
self.map_lat_dms_var.set(lat_dms_str) # Set DMS strings directly from message
self.map_lon_dms_var.set(lon_dms_str) # Set DMS strings directly from message
self.map_elevation_var.set(elev_str)
self.map_area_size_var.set(map_area_size_str)
logger.debug(f"GUI: Updated Map Info panel with: Lat={lat_txt_decimal}, Lon={lon_txt_decimal}, LatDMS='{lat_dms_str}', LonDMS='{lon_dms_str}', 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.
# MODIFIED: Update Entry widgets using StringVars for status messages.
# WHY: Show status in copyable Entry widgets.
# HOW: Use set() on StringVars.
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_lat_decimal_var.set("...") # Indicate fetching/processing
self.map_lon_decimal_var.set("...")
self.map_lat_dms_var.set("...") # Indicate fetching/processing for DMS
self.map_lon_dms_var.set("...") # Indicate fetching/processing for DMS
self.map_elevation_var.set(status_message) # Show status as elevation text
self.map_area_size_var.set("...") # Indicate fetching/processing
# 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
# MODIFIED: Update Map Info fields with a communication error state.
# WHY: Provide feedback if there's an error processing queue messages.
# HOW: Set StringVars with error text.
self.map_lat_decimal_var.set("Comm Error")
self.map_lon_decimal_var.set("Comm Error")
self.map_lat_dms_var.set("Comm Error")
self.map_lon_dms_var.set("Comm Error")
self.map_elevation_var.set(f"Comm Error: {type(e).__name__}")
self.map_area_size_var.set("Comm 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()
# MODIFIED: Example of how to use the new DMS conversion function in test/debug.
# WHY: Demonstrate usage of the new function.
# HOW: Added print statements.
test_lat = 45.56789
test_lon = -7.12345
print(f"Test DMS conversion for Lat {test_lat}: {deg_to_dms_string(test_lat, 'lat')}")
print(f"Test DMS conversion for Lon {test_lon}: {deg_to_dms_string(test_lon, 'lon')}")
test_lat_neg = -30.987
test_lon_pos = 150.65
print(f"Test DMS conversion for Lat {test_lat_neg}: {deg_to_dms_string(test_lat_neg, 'lat')}")
print(f"Test DMS conversion for Lon {test_lon_pos}: {deg_to_dms_string(test_lon_pos, 'lon')}")
# MODIFIED: Example of how to use the new combined bounds function in test/debug.
# WHY: Demonstrate usage of the new function.
# HOW: Added test data and print statements.
test_tile_list_info = [
{'latitude_coord': 44, 'longitude_coord': 7, 'tile_base_name': 'n44e007', 'hgt_file_path': 'path/to/n44e007.hgt', 'browse_image_path': 'path/to/n44e007.jpg', 'hgt_available': True, 'browse_available': True},
{'latitude_coord': 45, 'longitude_coord': 7, 'tile_base_name': 'n45e007', 'hgt_file_path': 'path/to/n45e007.hgt', 'browse_image_path': 'path/to/n45e007.jpg', 'hgt_available': True, 'browse_available': True},
{'latitude_coord': 44, 'longitude_coord': 8, 'tile_base_name': 'n44e008', 'hgt_file_path': 'path/to/n44e008.hgt', 'browse_image_path': 'path/to/n44e008.jpg', 'hgt_available': True, 'browse_available': True},
{'latitude_coord': 45, 'longitude_coord': 8, 'tile_base_name': 'n45e008', 'hgt_file_path': 'path/to/n45e008.hgt', 'browse_image_path': 'path/to/n45e008.jpg', 'hgt_available': True, 'browse_available': True},
]
combined_bounds = get_combined_geographic_bounds_from_tile_info_list(test_tile_list_info)
print(f"Test Combined Bounds: {combined_bounds}")
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