SXXXXXXX_GeoElevation/geoelevation/elevation_gui.py
2025-05-13 14:18:48 +02:00

1079 lines
55 KiB
Python

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