diff --git a/.gitignore b/.gitignore index 28322af..23d9bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ map_elevation/ .jpg .png elevation_cache/ -__pycache__/ \ No newline at end of file +__pycache__/ +_version.py +_build/ +_dist/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6e38950 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Module", + "type": "debugpy", + "request": "launch", + "module": "geoelevation" + } + ] +} \ No newline at end of file diff --git a/Manual.md b/doc/English-Manual.md similarity index 100% rename from Manual.md rename to doc/English-Manual.md diff --git a/Manuale.md b/doc/Italian-Manual.md similarity index 100% rename from Manuale.md rename to doc/Italian-Manual.md diff --git a/elevation_gui.py b/elevation_gui.py deleted file mode 100644 index 7b4e85b..0000000 --- a/elevation_gui.py +++ /dev/null @@ -1,518 +0,0 @@ -# elevation_gui.py - -import tkinter as tk -from tkinter import ttk, messagebox -import logging -import math -import multiprocessing -import threading -import os -from typing import Optional, Tuple, List, Dict # Aggiungi List, Dict - -# Importa classi e funzioni dai moduli separati -from elevation_manager import ElevationManager, RASTERIO_AVAILABLE -from image_processor import ( - load_prepare_single_browse, - create_composite_area_image, - PIL_AVAILABLE, -) -from visualizer import show_image_matplotlib, show_3d_matplotlib, MATPLOTLIB_AVAILABLE - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) -DEFAULT_CACHE_DIR = "elevation_cache" - -# === FUNZIONI TARGET PER MULTIPROCESSING (spostate fuori dalla classe) === - - -def process_target_show_image(image_path: str, tile_name: str, window_title: str): - """Target function for multiprocessing: Loads, prepares, and shows single image.""" - try: - # Importa qui se necessario (o assicurati siano globali e pickle-safe) - from image_processor import load_prepare_single_browse, PIL_AVAILABLE - from visualizer import show_image_matplotlib, MATPLOTLIB_AVAILABLE - import os # Importa di nuovo se serve qui - - if not PIL_AVAILABLE or not MATPLOTLIB_AVAILABLE: - print( - "PROCESS ERROR: Required libraries (PIL/Matplotlib) not available in process." - ) - return - - prepared_image = load_prepare_single_browse(image_path, tile_name) - if prepared_image: - print( - f"PROCESS: Showing image '{window_title}'" - ) # Usa print per output da processo - show_image_matplotlib(prepared_image, window_title) - else: - print( - f"PROCESS ERROR: Could not prepare image {os.path.basename(image_path)}" - ) - except Exception as e: - # Loggare da qui potrebbe non funzionare come atteso, usa print - print(f"PROCESS ERROR in process_target_show_image: {e}") - import traceback - - traceback.print_exc() # Stampa traceback completo nel processo figlio - - -def process_target_show_3d( - hgt_data: Optional["np.ndarray"], plot_title: str, subsample: int -): - """Target function for multiprocessing: Shows 3D plot.""" - try: - # Importa qui se necessario - from visualizer import show_3d_matplotlib, MATPLOTLIB_AVAILABLE - import numpy as np # Assicurati sia importato nel processo - - if not MATPLOTLIB_AVAILABLE: - print("PROCESS ERROR: Matplotlib not available in process.") - return - - if hgt_data is not None: - print(f"PROCESS: Showing 3D plot '{plot_title}'") - # Passa i dati numpy direttamente - show_3d_matplotlib(hgt_data, plot_title, subsample=subsample) - else: - print("PROCESS ERROR: No HGT data received for 3D plot.") - - except Exception as e: - print(f"PROCESS ERROR in process_target_show_3d: {e}") - import traceback - - traceback.print_exc() - - -def process_target_create_show_area(tile_info_list: List[Dict], window_title: str): - """Target function for multiprocessing: Creates composite image and shows it.""" - try: - # Importa qui se necessario - from image_processor import create_composite_area_image, PIL_AVAILABLE - from visualizer import show_image_matplotlib, MATPLOTLIB_AVAILABLE - - if not PIL_AVAILABLE or not MATPLOTLIB_AVAILABLE: - print( - "PROCESS ERROR: Required libraries (PIL/Matplotlib) not available in process." - ) - return - - print("PROCESS: Creating composite image...") - composite_image = create_composite_area_image(tile_info_list) - - if composite_image: - print(f"PROCESS: Showing composite image '{window_title}'") - show_image_matplotlib(composite_image, window_title) - else: - print( - "PROCESS ERROR: Failed to create composite image or no browse images found." - ) - - except Exception as e: - print(f"PROCESS ERROR in process_target_create_show_area: {e}") - import traceback - - traceback.print_exc() - - -# === FINE FUNZIONI TARGET === - - -class ElevationApp: - # ... (init e metodi _set_busy, _validate_* come prima) ... - def __init__(self, parent_widget: tk.Tk, elevation_manager: ElevationManager): - # ... (identico a prima) ... - self.root = parent_widget - self.manager = elevation_manager - self.root.title("Elevation Tool") - self.root.minsize(450, 350) - self.last_valid_point_coords: Optional[Tuple[float, float]] = None - self.last_area_coords: Optional[Tuple[float, float, float, float]] = None - self.is_processing: bool = False - main_frame = ttk.Frame(self.root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - self.root.columnconfigure(0, weight=1) - self.root.rowconfigure(0, weight=1) - point_frame = ttk.LabelFrame( - main_frame, text="Get Elevation for Point", padding="10" - ) - point_frame.grid(row=0, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) - point_frame.columnconfigure(1, weight=1) - ttk.Label(point_frame, text="Latitude:").grid( - row=0, column=0, sticky=tk.W, padx=5, pady=3 - ) - self.lat_entry = ttk.Entry(point_frame, width=15) - self.lat_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) - self.lat_entry.insert(0, "45.0") - ttk.Label(point_frame, text="Longitude:").grid( - row=1, column=0, sticky=tk.W, padx=5, pady=3 - ) - self.lon_entry = ttk.Entry(point_frame, width=15) - self.lon_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) - self.lon_entry.insert(0, "7.0") - self.get_elevation_button = ttk.Button( - point_frame, text="Get Elevation", command=self.run_get_elevation - ) - self.get_elevation_button.grid( - row=2, column=0, columnspan=2, pady=5, sticky=(tk.W, tk.E) - ) - self.result_label = ttk.Label( - point_frame, text="Result: ", wraplength=400, justify=tk.LEFT - ) - self.result_label.grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=5) - action_frame = ttk.Frame(point_frame) - action_frame.grid( - row=4, column=0, columnspan=2, pady=(5, 0), sticky=tk.W + tk.E - ) - action_frame.columnconfigure(0, weight=1) - action_frame.columnconfigure(1, weight=1) - self.show_2d_button = ttk.Button( - action_frame, - text="Show Browse Image (2D)", - command=self.trigger_2d_display, - state=tk.DISABLED, - ) - self.show_2d_button.grid(row=0, column=0, padx=2, sticky=tk.W + tk.E) - if not MATPLOTLIB_AVAILABLE or not PIL_AVAILABLE: - self.show_2d_button.config( - state=tk.DISABLED, text="Show Browse (Matplotlib/PIL N/A)" - ) - self.show_3d_button = ttk.Button( - action_frame, - text="Show DEM Tile (3D)", - command=self.trigger_3d_display, - state=tk.DISABLED, - ) - self.show_3d_button.grid(row=0, column=1, padx=2, sticky=tk.W + tk.E) - if not MATPLOTLIB_AVAILABLE: - self.show_3d_button.config( - state=tk.DISABLED, text="Show DEM Tile (Matplotlib N/A)" - ) - area_frame = ttk.LabelFrame( - main_frame, text="Pre-Download Tiles for Area", padding="10" - ) - area_frame.grid(row=1, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) - area_frame.columnconfigure(1, weight=1) - area_frame.columnconfigure(3, weight=1) - ttk.Label(area_frame, text="Min Lat:").grid( - row=0, column=0, sticky=tk.W, padx=5, pady=3 - ) - self.min_lat_entry = ttk.Entry(area_frame, width=10) - self.min_lat_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) - self.min_lat_entry.insert(0, "44.0") - ttk.Label(area_frame, text="Max Lat:").grid( - row=0, column=2, sticky=tk.W, padx=(10, 5), pady=3 - ) - self.max_lat_entry = ttk.Entry(area_frame, width=10) - self.max_lat_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=5, pady=3) - self.max_lat_entry.insert(0, "45.0") - ttk.Label(area_frame, text="Min Lon:").grid( - row=1, column=0, sticky=tk.W, padx=5, pady=3 - ) - self.min_lon_entry = ttk.Entry(area_frame, width=10) - self.min_lon_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) - self.min_lon_entry.insert(0, "7.0") - ttk.Label(area_frame, text="Max Lon:").grid( - row=1, column=2, sticky=tk.W, padx=(10, 5), pady=3 - ) - self.max_lon_entry = ttk.Entry(area_frame, width=10) - self.max_lon_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=5, pady=3) - self.max_lon_entry.insert(0, "8.0") - self.download_area_button = ttk.Button( - area_frame, text="Download Area Tiles", command=self.run_download_area - ) - self.download_area_button.grid( - row=2, column=0, columnspan=4, pady=10, sticky=(tk.W, tk.E) - ) - self.show_area_button = ttk.Button( - area_frame, - text="Show Area Browse Images (2D)", - command=self.trigger_area_display, - state=tk.DISABLED, - ) - self.show_area_button.grid( - row=3, column=0, columnspan=4, pady=5, sticky=tk.W + tk.E - ) - if not MATPLOTLIB_AVAILABLE or not PIL_AVAILABLE: - self.show_area_button.config( - state=tk.DISABLED, text="Show Area (Matplotlib/PIL N/A)" - ) - self.download_status_label = ttk.Label( - area_frame, text="Status: Idle", wraplength=400, justify=tk.LEFT - ) - self.download_status_label.grid( - row=4, column=0, columnspan=4, sticky=tk.W, pady=5 - ) - main_frame.columnconfigure(0, weight=1) - main_frame.rowconfigure(0, weight=0) - main_frame.rowconfigure(1, weight=0) - - def _set_busy(self, busy: bool): - self.is_processing = busy - state = tk.DISABLED if busy else tk.NORMAL - self.get_elevation_button.config(state=state) - self.download_area_button.config(state=state) - - def _validate_coordinates( - self, lat_str: str, lon_str: str - ) -> Optional[Tuple[float, float]]: - try: - if not lat_str: - raise ValueError("Latitude empty.") - lat = float(lat_str) - if not (-90 <= lat < 90): - raise ValueError("Latitude out of range [-90, 90).") - if not lon_str: - raise ValueError("Longitude empty.") - lon = float(lon_str) - if not (-180 <= lon < 180): - raise ValueError("Longitude out of range [-180, 180).") - return lat, lon - except ValueError as e: - logging.error(f"Invalid coordinate input: {e}") - messagebox.showerror( - "Input Error", f"Invalid coordinate input:\n{e}", parent=self.root - ) - return None - - def _validate_area_bounds(self) -> Optional[Tuple[float, float, float, float]]: - try: - min_lat = float(self.min_lat_entry.get()) - max_lat = float(self.max_lat_entry.get()) - min_lon = float(self.min_lon_entry.get()) - max_lon = float(self.max_lon_entry.get()) - if not ( - -90 <= min_lat < 90 - and -90 <= max_lat < 90 - and -180 <= min_lon < 180 - and -180 <= max_lon < 180 - ): - raise ValueError("Coordinates out of valid range.") - if min_lat >= max_lat: - raise ValueError("Min Lat >= Max Lat.") - if min_lon >= max_lon: - raise ValueError("Min Lon >= Max Lon.") - return min_lat, min_lon, max_lat, max_lon - except ValueError as e: - logging.error(f"Invalid area input: {e}") - messagebox.showerror( - "Input Error", f"Invalid area input:\n{e}", parent=self.root - ) - return None - - def run_get_elevation(self): - if self.is_processing: - return - coords = self._validate_coordinates(self.lat_entry.get(), self.lon_entry.get()) - if not coords: - return - latitude, longitude = coords - self._set_busy(True) - self.result_label.config(text="Result: Requesting elevation...") - self.show_2d_button.config(state=tk.DISABLED) - self.show_3d_button.config(state=tk.DISABLED) - self.last_valid_point_coords = None - self.root.update_idletasks() - try: - elevation = self.manager.get_elevation(latitude, longitude) - if elevation is None: - result_text = "Result: Elevation data unavailable." - messagebox.showwarning( - "Elevation Info", - "Could not retrieve elevation data.", - parent=self.root, - ) - elif math.isnan(elevation): - result_text = "Result: Point is on a nodata area." - self.last_valid_point_coords = (latitude, longitude) - else: - result_text = f"Result: Elevation is {elevation:.2f} meters" - self.last_valid_point_coords = (latitude, longitude) - self.result_label.config(text=result_text) - if self.last_valid_point_coords: - if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: - self.show_2d_button.config(state=tk.NORMAL) - if MATPLOTLIB_AVAILABLE: - self.show_3d_button.config(state=tk.NORMAL) - except Exception as e: - logging.exception("Error during get_elevation.") - messagebox.showerror("Error", f"Unexpected error:\n{e}", parent=self.root) - self.result_label.config(text="Result: Error.") - finally: - self._set_busy(False) - - def run_download_area(self): - if self.is_processing: - return - bounds = self._validate_area_bounds() - if not bounds: - return - min_lat, min_lon, max_lat, max_lon = bounds - self.last_area_coords = bounds - self._set_busy(True) - self.download_status_label.config(text="Status: Starting download...") - self.show_area_button.config(state=tk.DISABLED) - self.root.update_idletasks() - thread = threading.Thread( - target=self._perform_area_download_task, - args=(min_lat, min_lon, max_lat, max_lon), - daemon=True, - ) - thread.start() - - def _perform_area_download_task(self, min_lat, min_lon, max_lat, max_lon): - final_status = "Status: Unknown error." - success = False - processed, obtained = 0, 0 - try: - self.root.after( - 0, - lambda: self.download_status_label.config( - text="Status: Downloading/Checking tiles..." - ), - ) - processed, obtained = self.manager.download_area( - min_lat, min_lon, max_lat, max_lon - ) - final_status = ( - f"Status: Complete. Processed {processed}, Obtained HGT {obtained}." - ) - success = True - except Exception as e: - logging.exception("Error during background area download.") - final_status = f"Status: Error: {type(e).__name__}" - success = False - finally: - self.root.after( - 0, - self._area_download_complete_ui, - final_status, - success, - processed, - obtained, - ) - - def _area_download_complete_ui(self, status_message, success, processed, obtained): - self.download_status_label.config(text=status_message) - self._set_busy(False) - if success: - summary = f"Processed {processed} tiles.\nObtained {obtained} HGT files." - messagebox.showinfo("Download Complete", summary, parent=self.root) - if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: - self.show_area_button.config(state=tk.NORMAL) - else: - brief_error = status_message.split(":")[-1].strip() - messagebox.showerror( - "Download Error", - f"Area download failed: {brief_error}\nCheck logs.", - parent=self.root, - ) - self.show_area_button.config(state=tk.DISABLED) - - # === Metodi Trigger usano le funzioni target esterne === - - def trigger_2d_display(self): - if not MATPLOTLIB_AVAILABLE or not PIL_AVAILABLE: - return - if not self.last_valid_point_coords: - logging.warning("Show 2D clicked, no valid point.") - return - lat, lon = self.last_valid_point_coords - logging.info(f"Requesting browse image data for 2D display ({lat},{lon})") - tile_info = self.manager.get_tile_info(lat, lon) - if tile_info and tile_info.get("browse_available"): - image_path = tile_info["browse_image_path"] - tile_name = tile_info["tile_base_name"] - window_title = f"Browse: {tile_name.upper()}" - # Passa argomenti alla funzione target esterna - process = multiprocessing.Process( - target=process_target_show_image, - args=(image_path, tile_name, window_title), - daemon=True, - ) - process.start() - else: - logging.warning(f"Browse image not found for ({lat},{lon}).") - messagebox.showinfo( - "Image Info", "Browse image not available.", parent=self.root - ) - - def trigger_3d_display(self): - if not MATPLOTLIB_AVAILABLE: - return - if not self.last_valid_point_coords: - logging.warning("Show 3D clicked, no valid point.") - return - lat, lon = self.last_valid_point_coords - logging.info(f"Requesting HGT data for 3D display ({lat},{lon})") - hgt_data = self.manager.get_hgt_data(lat, lon) - if hgt_data is not None: - tile_info = self.manager.get_tile_info(lat, lon) - tile_name = tile_info["tile_base_name"].upper() if tile_info else "Unknown" - plot_title = f"3D View: Tile {tile_name}" - # Passa argomenti alla funzione target esterna - process = multiprocessing.Process( - target=process_target_show_3d, - args=(hgt_data, plot_title), - kwargs={"subsample": 10}, - daemon=True, - ) - process.start() - else: - messagebox.showerror( - "3D Data Error", "Could not retrieve HGT data.", parent=self.root - ) - - def trigger_area_display(self): - if not MATPLOTLIB_AVAILABLE or not PIL_AVAILABLE: - return - if not self.last_area_coords: - logging.warning("Show Area clicked, no area.") - messagebox.showinfo( - "Area Info", "Please download area first.", parent=self.root - ) - return - min_lat, min_lon, max_lat, max_lon = self.last_area_coords - logging.info(f"Requesting data for composite image display for area...") - tile_info_list = self.manager.get_area_tile_info( - min_lat, min_lon, max_lat, max_lon - ) - if not tile_info_list: - messagebox.showinfo("Area Info", "No tile info found.", parent=self.root) - return - window_title = f"Area Overview: Lat [{min_lat:.1f}-{max_lat:.1f}], Lon [{min_lon:.1f}-{max_lon:.1f}]" - # Passa argomenti alla funzione target esterna - process = multiprocessing.Process( - target=process_target_create_show_area, - args=(tile_info_list, window_title), - daemon=True, - ) - process.start() - - # === RIMOSSI: Metodi _create_and_show_area_image, _show_image_process_target === - # Ora sono funzioni a livello di modulo - - -# --- Main Execution Block --- -if __name__ == "__main__": - multiprocessing.freeze_support() # Importante per Windows - # ... (controlli dipendenze e avvio app come prima) ... - if not RASTERIO_AVAILABLE: - print("ERROR: Rasterio library is required...") - if not PIL_AVAILABLE: - print("WARNING: Pillow library (PIL) not found...") - if not MATPLOTLIB_AVAILABLE: - print("WARNING: Matplotlib not found...") - try: - manager = ElevationManager(tile_directory=DEFAULT_CACHE_DIR) - except Exception as e: - logging.critical(f"Failed init Manager: {e}", exc_info=True) - exit(1) - try: - root_window = tk.Tk() - app = ElevationApp(root_window, manager) - root_window.mainloop() - except Exception as e: - logging.critical(f"App Error: {e}", exc_info=True) - exit(1) diff --git a/geoelevation.spec b/geoelevation.spec new file mode 100644 index 0000000..ee79d2c --- /dev/null +++ b/geoelevation.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- + +block_cipher = None + +import os +a = Analysis(scripts=['geoelevation\\__main__.py'], + pathex=['geoelevation'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=None, + noarchive=False) + +pyz = PYZ(a.pure, a.zipped_data, cipher=None) + +exe = EXE(pyz, + a.scripts, + [], # Binaries/Datas usually handled by Analysis/COLLECT + exclude_binaries=True, # Let COLLECT handle binaries in one-dir + name='GeoElevation', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, # Use UPX based on config + runtime_tmpdir=None, + console=True, # Set console based on GUI checkbox + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None) + +coll = COLLECT(exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, # Match UPX setting + upx_exclude=[], + name='GeoElevation') diff --git a/geoelevation/__init__.py b/geoelevation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geoelevation/__main__.py b/geoelevation/__main__.py new file mode 100644 index 0000000..c80b2c8 --- /dev/null +++ b/geoelevation/__main__.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +Main entry point script for the GeoElevation application. +Works for both `python -m geoelevation` and as PyInstaller entry point. +Uses absolute imports. +""" + +import sys +import os +import traceback +import multiprocessing +import tkinter as tk + +# --- Import the necessary modules using ABSOLUTE paths --- +try: + # MODIFIED: Changed relative import to absolute import. + # WHY: To make this script work consistently as a direct entry point + # (for PyInstaller) and when run via `python -m`. Assumes the + # 'geoelevation' package is findable in sys.path. + # HOW: Changed 'from .elevation_gui import ElevationApp' to + # 'from geoelevation.elevation_gui import ElevationApp'. + from geoelevation.elevation_gui import ElevationApp + # Import other components if needed for setup (using absolute paths) + # from geoelevation.config import DEFAULT_CACHE_DIR # Example if you had config + +except ImportError as e: + # Error message adjusted slightly for absolute import context + print(f"ERROR: Could not import required modules using absolute paths (e.g., 'geoelevation.elevation_gui').") + print(f" Ensure the 'geoelevation' package is correctly structured and accessible.") + print(f"ImportError: {e}") + print(f"Current sys.path: {sys.path}") + # Try to determine expected base path for context + try: + script_path = os.path.dirname(os.path.abspath(__file__)) + parent_path = os.path.dirname(script_path) + print(f" Expected package 'geoelevation' potentially under: {parent_path}") + except NameError: + pass # __file__ might not be defined in some rare cases + traceback.print_exc() + sys.exit(1) +except Exception as e: + print(f"ERROR: An unexpected error occurred during initial imports: {e}") + traceback.print_exc() + sys.exit(1) + + +def main(): + """ + Initializes and runs the ElevationApp GUI application. + """ + root_window = tk.Tk() + try: + # Instantiate ElevationApp, passing the root window + app = ElevationApp(parent_widget=root_window) + # Start the Tkinter event loop + root_window.mainloop() + except Exception as e: + print(f"FATAL ERROR: An unexpected error occurred during application execution: {e}") + traceback.print_exc() + try: + error_root = tk.Tk() + error_root.withdraw() + from tkinter import messagebox + messagebox.showerror( + "Fatal Error", + f"Application failed to run:\n{e}\n\nSee console for details.", + parent=error_root + ) + error_root.destroy() + except Exception: + pass + sys.exit(1) + +# --- Main Execution Guard --- +if __name__ == "__main__": + # !!! IMPORTANT for PyInstaller and multiprocessing !!! + # Must be called in the main entry script for frozen executables. + multiprocessing.freeze_support() + + # print(f"Running GeoElevation via __main__.py...") + main() \ No newline at end of file diff --git a/geoelevation/elevation_gui.py b/geoelevation/elevation_gui.py new file mode 100644 index 0000000..bb43053 --- /dev/null +++ b/geoelevation/elevation_gui.py @@ -0,0 +1,454 @@ +# elevation_gui.py + +import tkinter as tk +from tkinter import ttk, messagebox +import logging +import math +import multiprocessing +import threading +import os +from typing import Optional, Tuple, List, Dict, TYPE_CHECKING + +# Use ABSOLUTE imports (assuming 'geoelevation' is the top-level package name) +from geoelevation.elevation_manager import ElevationManager, RASTERIO_AVAILABLE +from geoelevation.image_processor import ( + load_prepare_single_browse, + create_composite_area_image, + PIL_AVAILABLE, +) +from geoelevation.visualizer import ( + show_image_matplotlib, + show_3d_matplotlib, + MATPLOTLIB_AVAILABLE, + SCIPY_AVAILABLE # Import check for SciPy (needed for smoothing/interpolation) +) + +# Type hint for numpy array without direct import if possible +if TYPE_CHECKING: + import numpy as np_typing + +# Setup logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) +DEFAULT_CACHE_DIR = "elevation_cache" + + +# === MULTIPROCESSING TARGET FUNCTIONS === + +def process_target_show_image(image_path: str, tile_name: str, window_title: str): + """Target function for multiprocessing: Loads, prepares, and shows single image.""" + try: + from geoelevation.image_processor import load_prepare_single_browse, PIL_AVAILABLE + from geoelevation.visualizer import show_image_matplotlib, MATPLOTLIB_AVAILABLE + import os + if not PIL_AVAILABLE or not MATPLOTLIB_AVAILABLE: + print("PROCESS ERROR (show_image): Pillow/Matplotlib not available in child.") + return + prepared_image = load_prepare_single_browse(image_path, tile_name) + if prepared_image: + print(f"PROCESS (show_image): Showing '{window_title}'") + show_image_matplotlib(prepared_image, window_title) + else: + print(f"PROCESS ERROR (show_image): Could not prepare {os.path.basename(image_path)}") + except Exception as e: + print(f"PROCESS ERROR in process_target_show_image: {e}") + import traceback + traceback.print_exc() + +def process_target_show_3d( + hgt_data: Optional["np_typing.ndarray"], + plot_title: str, + initial_subsample: int, + smooth_sigma: Optional[float] = None, + interpolation_factor: int = 1, + plot_grid_points: int = 500 +): + """Target function for multiprocessing: Shows 3D plot, optionally smoothed/interpolated.""" + try: + from geoelevation.visualizer import show_3d_matplotlib, MATPLOTLIB_AVAILABLE, SCIPY_AVAILABLE + import numpy as np_child # Use alias if main module has dummy np + + if not MATPLOTLIB_AVAILABLE: + print("PROCESS ERROR (show_3d): Matplotlib not available in child.") + return + if (interpolation_factor > 1 or smooth_sigma is not None) and not SCIPY_AVAILABLE: + print(f"PROCESS WARNING (show_3d): SciPy not available in child. Disabling smoothing/interpolation.") + smooth_sigma = None + interpolation_factor = 1 + + if hgt_data is not None: + print(f"PROCESS (show_3d): Plotting '{plot_title}' (InitialSub:{initial_subsample}, Smooth:{smooth_sigma}, Interp:{interpolation_factor}x, PlotGridTarget:{plot_grid_points})") + show_3d_matplotlib( + hgt_data, + plot_title, + initial_subsample=initial_subsample, + smooth_sigma=smooth_sigma, + interpolation_factor=interpolation_factor, + plot_grid_points=plot_grid_points + ) + else: + print("PROCESS ERROR (show_3d): No HGT data received.") + except Exception as e: + print(f"PROCESS ERROR in process_target_show_3d: {e}") + import traceback + traceback.print_exc() + +def process_target_create_show_area(tile_info_list: List[Dict], window_title: str): + """Target function for multiprocessing: Creates composite image and shows it.""" + try: + from geoelevation.image_processor import create_composite_area_image, PIL_AVAILABLE + from geoelevation.visualizer import show_image_matplotlib, MATPLOTLIB_AVAILABLE + if not PIL_AVAILABLE or not MATPLOTLIB_AVAILABLE: + print("PROCESS ERROR (show_area): Pillow/Matplotlib not available in child.") + return + print("PROCESS (show_area): Creating composite image...") + composite_image = create_composite_area_image(tile_info_list) + if composite_image: + print(f"PROCESS (show_area): Showing '{window_title}'") + show_image_matplotlib(composite_image, window_title) + else: + print("PROCESS ERROR (show_area): Failed to create composite image.") + except Exception as e: + print(f"PROCESS ERROR in process_target_create_show_area: {e}") + import traceback + traceback.print_exc() + +# === END MULTIPROCESSING TARGET FUNCTIONS === + +# --- Import Version Info FOR THE WRAPPER ITSELF --- +try: + # Use absolute import based on package name + from geoelevation import _version as wrapper_version + WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})" + WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}" +except ImportError: + # This might happen if you run the wrapper directly from source + # without generating its _version.py first (if you use that approach for the wrapper itself) + WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)" + WRAPPER_BUILD_INFO = "Wrapper build time unknown" +# --- End Import Version Info --- + +# --- Constants for Version Generation --- +DEFAULT_VERSION = "0.0.0+unknown" +DEFAULT_COMMIT = "Unknown" +DEFAULT_BRANCH = "Unknown" +# --- End Constants --- + +class ElevationApp: + """Main application class for the Elevation Tool GUI.""" + def __init__(self, parent_widget: tk.Tk, elevation_manager: Optional[ElevationManager] = None): + self.root = parent_widget + if elevation_manager is None: + try: + if not RASTERIO_AVAILABLE: + logging.warning("Rasterio not available. Elevation functions limited.") + self.manager = ElevationManager(tile_directory=DEFAULT_CACHE_DIR) + except Exception as e: + logging.critical(f"Failed to initialize ElevationManager: {e}", exc_info=True) + messagebox.showerror("Init Error", f"Could not start Elevation Manager:\n{e}", parent=self.root) + self.manager = None + else: + self.manager = elevation_manager + + self.root.title(f"Elevation Tool - {WRAPPER_APP_VERSION_STRING}") + self.root.minsize(450, 350) + self.last_valid_point_coords: Optional[Tuple[float, float]] = None + self.last_area_coords: Optional[Tuple[float, float, float, float]] = None + self.is_processing: bool = False + + main_frame = ttk.Frame(self.root, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + + point_frame = ttk.LabelFrame(main_frame, text="Get Elevation for Point", padding="10") + point_frame.grid(row=0, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) + point_frame.columnconfigure(1, weight=1) + ttk.Label(point_frame, text="Latitude:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) + self.lat_entry = ttk.Entry(point_frame, width=15) + self.lat_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) + self.lat_entry.insert(0, "45.0") + ttk.Label(point_frame, text="Longitude:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) + self.lon_entry = ttk.Entry(point_frame, width=15) + self.lon_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) + self.lon_entry.insert(0, "7.0") + self.get_elevation_button = ttk.Button(point_frame, text="Get Elevation", command=self.run_get_elevation) + self.get_elevation_button.grid(row=2, column=0, columnspan=2, pady=5, sticky=(tk.W, tk.E)) + self.result_label = ttk.Label(point_frame, text="Result: ", wraplength=400, justify=tk.LEFT) + self.result_label.grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=5) + action_frame = ttk.Frame(point_frame) + action_frame.grid(row=4, column=0, columnspan=2, pady=(5, 0), sticky=(tk.W, tk.E)) + action_frame.columnconfigure(0, weight=1) + action_frame.columnconfigure(1, weight=1) + self.show_2d_button = ttk.Button(action_frame, text="Show Browse Image (2D)", command=self.trigger_2d_display, state=tk.DISABLED) + self.show_2d_button.grid(row=0, column=0, padx=2, sticky=(tk.W, tk.E)) + if not MATPLOTLIB_AVAILABLE or not PIL_AVAILABLE: + self.show_2d_button.config(state=tk.DISABLED, text="Show Browse (Libs N/A)") + self.show_3d_button = ttk.Button(action_frame, text="Show DEM Tile (3D)", command=self.trigger_3d_display, state=tk.DISABLED) + self.show_3d_button.grid(row=0, column=1, padx=2, sticky=(tk.W, tk.E)) + if not MATPLOTLIB_AVAILABLE: + self.show_3d_button.config(state=tk.DISABLED, text="Show DEM Tile (Matplotlib N/A)") + + area_frame = ttk.LabelFrame(main_frame, text="Pre-Download Tiles for Area", padding="10") + area_frame.grid(row=1, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) + area_frame.columnconfigure(1, weight=1) + area_frame.columnconfigure(3, weight=1) + ttk.Label(area_frame, text="Min Lat:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) + self.min_lat_entry = ttk.Entry(area_frame, width=10) + self.min_lat_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) + self.min_lat_entry.insert(0, "44.0") + ttk.Label(area_frame, text="Max Lat:").grid(row=0, column=2, sticky=tk.W, padx=(10, 5), pady=3) + self.max_lat_entry = ttk.Entry(area_frame, width=10) + self.max_lat_entry.grid(row=0, column=3, sticky=(tk.W, tk.E), padx=5, pady=3) + self.max_lat_entry.insert(0, "45.0") + ttk.Label(area_frame, text="Min Lon:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) + self.min_lon_entry = ttk.Entry(area_frame, width=10) + self.min_lon_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) + self.min_lon_entry.insert(0, "7.0") + ttk.Label(area_frame, text="Max Lon:").grid(row=1, column=2, sticky=tk.W, padx=(10, 5), pady=3) + self.max_lon_entry = ttk.Entry(area_frame, width=10) + self.max_lon_entry.grid(row=1, column=3, sticky=(tk.W, tk.E), padx=5, pady=3) + self.max_lon_entry.insert(0, "8.0") + self.download_area_button = ttk.Button(area_frame, text="Download Area Tiles", command=self.run_download_area) + self.download_area_button.grid(row=2, column=0, columnspan=4, pady=10, sticky=(tk.W, tk.E)) + self.show_area_button = ttk.Button(area_frame, text="Show Area Browse Images (2D)", command=self.trigger_area_display, state=tk.DISABLED) + self.show_area_button.grid(row=3, column=0, columnspan=4, pady=5, sticky=(tk.W, tk.E)) + if not MATPLOTLIB_AVAILABLE or not PIL_AVAILABLE: + self.show_area_button.config(state=tk.DISABLED, text="Show Area (Libs N/A)") + self.download_status_label = ttk.Label(area_frame, text="Status: Idle", wraplength=400, justify=tk.LEFT) + self.download_status_label.grid(row=4, column=0, columnspan=4, sticky=tk.W, pady=5) + + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(0, weight=0) + main_frame.rowconfigure(1, weight=0) + + if self.manager is None: + self.get_elevation_button.config(state=tk.DISABLED) + self.download_area_button.config(state=tk.DISABLED) + self.result_label.config(text="Result: Manager Init Failed.") + self.download_status_label.config(text="Status: Manager Failed.") + + def _set_busy(self, busy: bool): + self.is_processing = busy + state = tk.DISABLED if busy else tk.NORMAL + if self.manager is not None: + self.get_elevation_button.config(state=state) + self.download_area_button.config(state=state) + + def _validate_coordinates(self, lat_str: str, lon_str: str) -> Optional[Tuple[float, float]]: + try: + if not lat_str: raise ValueError("Latitude empty.") + lat = float(lat_str.strip()) + if not (-90 <= lat < 90): raise ValueError("Latitude out of range [-90, 90).") + if not lon_str: raise ValueError("Longitude empty.") + lon = float(lon_str.strip()) + if not (-180 <= lon < 180): raise ValueError("Longitude out of range [-180, 180).") + return lat, lon + except ValueError as e: + logging.error(f"Invalid coordinate: {e}") + messagebox.showerror("Input Error", f"Invalid coordinate:\n{e}", parent=self.root) + return None + + def _validate_area_bounds(self) -> Optional[Tuple[float, float, float, float]]: + try: + min_l_s, max_l_s = self.min_lat_entry.get().strip(), self.max_lat_entry.get().strip() + min_o_s, max_o_s = self.min_lon_entry.get().strip(), self.max_lon_entry.get().strip() + if not all([min_l_s, max_l_s, min_o_s, max_o_s]): raise ValueError("All bounds must be filled.") + min_l, max_l = float(min_l_s), float(max_l_s) + min_o, max_o = float(min_o_s), float(max_o_s) + if not (-90<=min_l<90 and -90<=max_l<90 and -180<=min_o<180 and -180<=max_o<180): + raise ValueError("Coordinates out of valid range.") + if min_l >= max_l: raise ValueError("Min Lat >= Max Lat.") + if min_o >= max_o: raise ValueError("Min Lon >= Max Lon.") + return min_l, min_o, max_l, max_o + except ValueError as e: + logging.error(f"Invalid area: {e}") + messagebox.showerror("Input Error", f"Invalid area:\n{e}", parent=self.root) + return None + + def run_get_elevation(self): + if self.is_processing or self.manager is None: return + coords = self._validate_coordinates(self.lat_entry.get(), self.lon_entry.get()) + if not coords: return + lat, lon = coords + self._set_busy(True) + self.result_label.config(text="Result: Requesting...") + self.show_2d_button.config(state=tk.DISABLED) + self.show_3d_button.config(state=tk.DISABLED) + self.last_valid_point_coords = None + self.root.update_idletasks() + try: + elevation = self.manager.get_elevation(lat, lon) + if elevation is None: + res_text = "Result: Elevation data unavailable." + messagebox.showwarning("Info", "Could not retrieve elevation.", parent=self.root) + elif math.isnan(elevation): + res_text = "Result: Point on NoData area." + self.last_valid_point_coords = (lat, lon) + else: + res_text = f"Result: Elevation {elevation:.2f}m" + self.last_valid_point_coords = (lat, lon) + self.result_label.config(text=res_text) + if self.last_valid_point_coords: + if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_2d_button.config(state=tk.NORMAL) + if MATPLOTLIB_AVAILABLE: self.show_3d_button.config(state=tk.NORMAL) + except Exception as e: + logging.exception("GUI Error: get_elevation") + messagebox.showerror("Error", f"Unexpected error:\n{e}", parent=self.root) + self.result_label.config(text="Result: Error.") + finally: + self._set_busy(False) + + def run_download_area(self): + if self.is_processing or self.manager is None: return + bounds = self._validate_area_bounds() + if not bounds: return + min_lat, min_lon, max_lat, max_lon = bounds + self.last_area_coords = bounds + self._set_busy(True) + self.download_status_label.config(text="Status: Starting...") + self.show_area_button.config(state=tk.DISABLED) + self.root.update_idletasks() + thread = threading.Thread(target=self._perform_area_download_task, args=bounds, daemon=True) + thread.start() + + def _perform_area_download_task(self, min_lat, min_lon, max_lat, max_lon): + status, success, p_count, o_count = "Status: Unknown error.", False, 0, 0 + try: + self.root.after(0, lambda: self.download_status_label.config(text="Status: Downloading...")) + if self.manager: + p_count, o_count = self.manager.download_area(min_lat, min_lon, max_lat, max_lon) + status = f"Status: Complete. Processed {p_count}, Obtained {o_count} HGT." + success = True + else: + status = "Status: Error - Manager N/A." + except Exception as e: + logging.exception("GUI Error: area download task") + status = f"Status: Error: {type(e).__name__}" + finally: + self.root.after(0, self._area_download_complete_ui, status, success, p_count, o_count) + + def _area_download_complete_ui(self, status_msg, success, processed, obtained): + self.download_status_label.config(text=status_msg) + self._set_busy(False) + if success: + summary = f"Processed {processed} tiles.\nObtained {obtained} HGT files." + messagebox.showinfo("Download Complete", summary, parent=self.root) + if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_area_button.config(state=tk.NORMAL) + else: + err = status_msg.split(":")[-1].strip() + messagebox.showerror("Download Error", f"Area download failed: {err}\nCheck logs.", parent=self.root) + self.show_area_button.config(state=tk.DISABLED) + + def trigger_2d_display(self): + if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): + messagebox.showwarning("Deps Error", "Matplotlib/PIL N/A.", parent=self.root) + return + if not self.last_valid_point_coords: + messagebox.showinfo("Info", "Get elevation first.", parent=self.root) + return + if self.manager is None: return + lat, lon = self.last_valid_point_coords + tile_info = self.manager.get_tile_info(lat, lon) + if tile_info and tile_info.get("browse_available") and tile_info.get("browse_image_path"): + img_path = tile_info["browse_image_path"] + t_name = tile_info.get("tile_base_name", "Unknown") + win_title = f"Browse: {t_name.upper()}" + try: + proc = multiprocessing.Process(target=process_target_show_image, args=(img_path, t_name, win_title), daemon=True) + proc.start() + except Exception as e: + logging.exception("GUI Error: start 2D process") + messagebox.showerror("Process Error", f"Could not start 2D display:\n{e}", parent=self.root) + else: + messagebox.showinfo("Image Info", "Browse image N/A.", parent=self.root) + + def trigger_3d_display(self): + if not MATPLOTLIB_AVAILABLE: + messagebox.showwarning("Deps Error", "Matplotlib N/A.", parent=self.root) + return + if not self.last_valid_point_coords: + messagebox.showinfo("Info", "Get elevation first.", parent=self.root) + return + if self.manager is None: return + lat, lon = self.last_valid_point_coords + hgt_data = self.manager.get_hgt_data(lat, lon) + if hgt_data is not None: + tile_info = self.manager.get_tile_info(lat, lon) + t_name = tile_info.get("tile_base_name", "Unknown").upper() if tile_info else "Unknown" + p_title = f"3D View: Tile {t_name}" + + # --- Configuration for Plotting --- + initial_subsample_val = 1 + smooth_sigma_val: Optional[float] = 0.5 # Light smoothing or None + interpolation_factor_val: int = 2 # 2, 3, or 4 typically + plot_grid_points_val: int = 150 # Target plot points (e.g., 300x300) + + if not SCIPY_AVAILABLE: + if smooth_sigma_val is not None: + logging.warning("SciPy N/A. Disabling 3D smoothing.") + smooth_sigma_val = None + if interpolation_factor_val > 1: + logging.warning("SciPy N/A. Disabling 3D interpolation.") + interpolation_factor_val = 1 + # --- End Configuration --- + try: + proc_kwargs = { + "initial_subsample": initial_subsample_val, + "smooth_sigma": smooth_sigma_val, + "interpolation_factor": interpolation_factor_val, + "plot_grid_points": plot_grid_points_val + } + proc = multiprocessing.Process(target=process_target_show_3d, args=(hgt_data, p_title), kwargs=proc_kwargs, daemon=True) + proc.start() + logging.info(f"Started 3D display process {proc.pid} (InitialSub:{initial_subsample_val}, Smooth:{smooth_sigma_val}, Interp:{interpolation_factor_val}x, PlotGrid:{plot_grid_points_val})") + except Exception as e: + logging.exception("GUI Error: start 3D process") + messagebox.showerror("Process Error", f"Could not start 3D display:\n{e}", parent=self.root) + else: + messagebox.showerror("3D Data Error", "Could not retrieve HGT data.\nCheck logs.", parent=self.root) + + def trigger_area_display(self): + if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): + messagebox.showwarning("Deps Error", "Matplotlib/PIL N/A.", parent=self.root) + return + if not self.last_area_coords: + messagebox.showinfo("Info", "Download area first.", parent=self.root) + return + if self.manager is None: return + min_lat, min_lon, max_lat, max_lon = self.last_area_coords + tile_info_list = self.manager.get_area_tile_info(min_lat, min_lon, max_lat, max_lon) + if not tile_info_list: + messagebox.showinfo("Area Info", "No tile info found.", parent=self.root) + return + win_title = f"Area: Lat [{min_lat:.1f}-{max_lat:.1f}], Lon [{min_lon:.1f}-{max_lon:.1f}]" + try: + proc = multiprocessing.Process(target=process_target_create_show_area, args=(tile_info_list, win_title), daemon=True) + proc.start() + except Exception as e: + logging.exception("GUI Error: start area display process") + messagebox.showerror("Process Error", f"Could not start area display:\n{e}", parent=self.root) + +# --- Main Execution Block (for direct script testing) --- +if __name__ == "__main__": + print("Running elevation_gui.py directly for testing...") + if not RASTERIO_AVAILABLE: print("WARNING (Test): Rasterio N/A.") + if not PIL_AVAILABLE: print("WARNING (Test): Pillow N/A.") + if not MATPLOTLIB_AVAILABLE: print("WARNING (Test): Matplotlib N/A.") + if SCIPY_AVAILABLE: print("INFO (Test): SciPy available.") + else: print("WARNING (Test): SciPy N/A (no smooth/interp).") + + root = tk.Tk() + try: + app = ElevationApp(root) + root.mainloop() + except Exception as e_main: + logging.critical(f"Error in direct run of elevation_gui: {e_main}", exc_info=True) + try: + err_root = tk.Tk() + err_root.withdraw() + messagebox.showerror("Fatal Error (Test Run)", f"App failed:\n{e_main}") + err_root.destroy() + except Exception: pass + exit(1) \ No newline at end of file diff --git a/elevation_manager.py b/geoelevation/elevation_manager.py similarity index 100% rename from elevation_manager.py rename to geoelevation/elevation_manager.py diff --git a/geoelevation/image_processor.py b/geoelevation/image_processor.py new file mode 100644 index 0000000..c224849 --- /dev/null +++ b/geoelevation/image_processor.py @@ -0,0 +1,418 @@ +# image_processor.py + +import os +import math +import logging +from typing import Optional, List, Dict, Tuple + +try: + from PIL import Image, ImageDraw, ImageFont + PIL_AVAILABLE = True +except ImportError: + PIL_AVAILABLE = False + # Define dummy classes if PIL not available to prevent NameErrors elsewhere + # if code relies on checking `isinstance(..., Image.Image)` etc. + class Image: pass + class ImageDraw: pass + class ImageFont: pass + logging.error( + "Pillow library (PIL/Pillow) not found. Image processing features will be disabled." + ) + +# --- Constants --- +TILE_TEXT_COLOR = "white" +TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)" # Semi-transparent black background +PLACEHOLDER_COLOR = (128, 128, 128) # Gray RGB for missing tiles +TILE_BORDER_COLOR = "red" +TILE_BORDER_WIDTH = 1 # Thinner border + +# --- Font Loading --- +DEFAULT_FONT = None +if PIL_AVAILABLE: + try: + # Try common system font paths (adjust for your OS if needed) + font_paths = [ + "arial.ttf", # Windows + "Arial.ttf", # Some Linux/Mac + "LiberationSans-Regular.ttf",# Linux (common alternative) + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux (DejaVu) + "/System/Library/Fonts/Helvetica.ttc", # macOS (Helvetica) - TTC needs index + "/System/Library/Fonts/Arial.ttf", # macOS (Arial) + ] + font_size = 12 # Adjust font size if needed + font_found = False + for font_path in font_paths: + try: + # Handle TTC files if needed (requires specifying index) + if font_path.lower().endswith(".ttc"): + # Try index 0, common for regular style + DEFAULT_FONT = ImageFont.truetype(font_path, font_size, index=0) + else: + DEFAULT_FONT = ImageFont.truetype(font_path, font_size) + logging.info(f"Loaded font: {font_path} (size {font_size})") + font_found = True + break # Stop searching once found + except IOError: + logging.debug(f"Font not found or failed to load: {font_path}") + continue # Try the next path + except Exception as e_font: + # Catch other potential errors like FreeType errors + logging.warning(f"Error loading font {font_path}: {e_font}") + continue + + # Fallback to default PIL font if no system font worked + if not font_found: + DEFAULT_FONT = ImageFont.load_default() + logging.warning("Common system fonts not found or failed to load. Using default PIL font.") + except Exception as e: + # Catch errors during the font search logic itself + logging.error(f"Error during font loading process: {e}") + DEFAULT_FONT = ImageFont.load_default() if PIL_AVAILABLE else None + + +def add_overlay_info( + image: Optional[Image.Image], + tile_name: str, + source_text: Optional[str] = "NASADEM", + corner: str = "bottom-right" # Allow specifying corner +) -> Optional[Image.Image]: + """ + Draws tile name and optional source information onto a PIL Image object. + + Args: + image (Optional[Image.Image]): The PIL Image object to draw on. If None, returns None. + tile_name (str): The base name of the tile (e.g., 'n45e007'). Uppercased automatically. + source_text (Optional[str]): Text indicating the data source. If None, only tile name is shown. + corner (str): Corner for the text ('bottom-right', 'top-left', etc. - currently only br). + + Returns: + Optional[Image.Image]: The modified image object, or None if input was None or PIL unavailable. + """ + if not PIL_AVAILABLE or image is None: + return None + + try: + # Ensure image is in a mode that supports drawing with transparency (RGBA) + if image.mode != "RGBA": + # Convert to RGBA to allow semi-transparent background + image = image.convert("RGBA") + + draw = ImageDraw.Draw(image) + font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default() + + # Prepare text content + display_text = tile_name.upper() + if source_text: + display_text += f"\nSource: {source_text}" + + # Calculate text bounding box using textbbox for better accuracy (requires Pillow >= 8.0) + try: + # textbbox returns (left, top, right, bottom) relative to the anchor (0,0) + text_bbox = draw.textbbox((0, 0), display_text, font=font, spacing=2) # Add line spacing + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + except AttributeError: + # Fallback for older Pillow versions using textsize + logging.warning("Using legacy textsize, positioning might be less accurate.") + # textsize might not handle multiline spacing well + text_width, text_height = draw.textsize(display_text, font=font) + # Manually add spacing approximation if needed for multiline fallback + if "\n" in display_text: + line_count = display_text.count("\n") + 1 + # Approximate additional height (depends on font) + text_height += line_count * 2 + + # Calculate position based on corner (only bottom-right implemented) + margin = 5 + if corner == "bottom-right": + text_x = image.width - text_width - margin + text_y = image.height - text_height - margin + # Add other corners (top-left, top-right, bottom-left) here if needed + # elif corner == "top-left": + # text_x = margin + # text_y = margin + else: # Default to bottom-right if corner is unrecognized + text_x = image.width - text_width - margin + text_y = image.height - text_height - margin + + + # Define background rectangle coordinates (slightly larger than text) + bg_padding = 2 + bg_coords = [ + text_x - bg_padding, + text_y - bg_padding, + text_x + text_width + bg_padding, + text_y + text_height + bg_padding, + ] + # Draw semi-transparent background rectangle + draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR) + + # Draw the text itself + # Use anchor='lt' with textbbox calculation for precise placement + try: + draw.text((text_x, text_y), display_text, fill=TILE_TEXT_COLOR, font=font, spacing=2, anchor="la") # Anchor left, top-of-ascent approx + except TypeError: # Older Pillow might not support anchor + draw.text((text_x, text_y), display_text, fill=TILE_TEXT_COLOR, font=font) + + + return image + + except Exception as e: + logging.error( + f"Error adding overlay to image for {tile_name}: {e}", exc_info=True + ) + return image # Return original image on error + + +def load_prepare_single_browse( + image_path: Optional[str], tile_name: str +) -> Optional[Image.Image]: + """ + Loads a browse image from path and adds standard overlay information. + + Args: + image_path (Optional[str]): Path to the browse image file. Can be None. + tile_name (str): Base name of the tile (e.g., 'n45e007'). Used for overlay. + + Returns: + Optional[Image.Image]: Prepared PIL Image object with overlay, + or None if loading/processing fails or path is invalid. + """ + if not PIL_AVAILABLE: + logging.warning("Cannot load/prepare image: Pillow (PIL) is not available.") + return None + if not image_path: + logging.debug("No image path provided for loading.") + return None + if not os.path.exists(image_path): + logging.warning(f"Browse image file not found: {image_path}") + return None + + try: + logging.debug(f"Loading browse image: {image_path}") + # Use 'with' statement for automatic file closing + with Image.open(image_path) as img: + # Add overlay information + # Convert to RGBA before adding overlay to handle transparency if needed + img_prepared = add_overlay_info(img.convert("RGBA"), tile_name) + return img_prepared + except FileNotFoundError: + # This case should be caught by os.path.exists, but handle defensively + logging.error(f"File not found error during Image.open: {image_path}") + return None + except Exception as e: + # Catch other PIL errors (e.g., broken image file) + logging.error( + f"Failed to load or process browse image {image_path}: {e}", exc_info=True + ) + return None + + +def create_composite_area_image( + tile_info_list: List[Dict], target_tile_size: Optional[Tuple[int, int]] = None +) -> Optional[Image.Image]: + """ + Creates a composite image by mosaicking available browse images for a given area. + Draws a grid and labels each tile. + + Args: + tile_info_list (List[Dict]): List of tile information dictionaries, typically + from ElevationManager.get_area_tile_info. Each dict + should contain 'latitude_coord', 'longitude_coord', + 'browse_image_path', and 'tile_base_name'. + target_tile_size (Optional[Tuple[int, int]]): If provided (width, height), + resizes each tile to this size before pasting. + If None, uses the maximum dimensions found + among the loaded browse images. + + Returns: + Optional[Image.Image]: A composite PIL Image object, or None if no valid tile info + is provided, no browse images can be loaded, or an error occurs. + """ + if not PIL_AVAILABLE: + logging.error("Cannot create composite image: Pillow (PIL) is not available.") + return None + if not tile_info_list: + logging.warning("Cannot create composite image: No tile info provided.") + return None + + logging.info(f"Attempting to create composite image for {len(tile_info_list)} tile locations.") + + # 1. Determine grid dimensions and range from tile coordinates + lats = [info["latitude_coord"] for info in tile_info_list] + lons = [info["longitude_coord"] for info in tile_info_list] + if not lats or not lons: + logging.warning("Tile info list seems empty or lacks coordinates.") + return None # Cannot proceed without coordinates + + min_lat = min(lats) + max_lat = max(lats) + min_lon = min(lons) + max_lon = max(lons) + + # Calculate number of tiles in each dimension (inclusive) + num_lat_tiles = max_lat - min_lat + 1 + num_lon_tiles = max_lon - min_lon + 1 + + # 2. Load available images and determine tile size + tile_images: Dict[Tuple[int, int], Optional[Image.Image]] = {} # Store loaded images keyed by (lat, lon) + max_w = 0 + max_h = 0 + loaded_image_count = 0 + + for info in tile_info_list: + lat = info.get("latitude_coord") + lon = info.get("longitude_coord") + # Ensure coordinates are present + if lat is None or lon is None: + logging.warning(f"Skipping tile info entry due to missing coordinates: {info}") + continue + + key = (lat, lon) + img_path = info.get("browse_image_path") + img = None # Reset img for each tile + + if img_path and os.path.exists(img_path): + try: + # Load image and convert to RGB (common base) + with Image.open(img_path) as loaded_img: + img = loaded_img.convert("RGB") + tile_images[key] = img + # If target size isn't fixed, track maximum dimensions found + if target_tile_size is None: + max_w = max(max_w, img.width) + max_h = max(max_h, img.height) + loaded_image_count += 1 + logging.debug(f"Successfully loaded browse image for ({lat},{lon}): {img_path}") + except Exception as e: + logging.warning(f"Could not load or convert browse image {img_path}: {e}") + tile_images[key] = None # Mark as failed to load + else: + # Mark as unavailable if path missing or file doesn't exist + tile_images[key] = None + if img_path: # Log if path was given but file missing + logging.debug(f"Browse image file not found for ({lat},{lon}): {img_path}") + else: # Log if path was missing in info + logging.debug(f"No browse image path provided for ({lat},{lon}).") + + + # Check if any images were actually loaded + if loaded_image_count == 0: + logging.warning("No browse images could be loaded for the specified area.") + # Return None as no visual content is available + return None + + # 3. Determine final tile dimensions for the composite grid + if target_tile_size: + tile_w, tile_h = target_tile_size + logging.info(f"Using target tile size: {tile_w}x{tile_h}") + else: + tile_w, tile_h = max_w, max_h + logging.info(f"Using maximum detected tile size: {tile_w}x{tile_h}") + + # Basic validation of determined tile size + if tile_w <= 0 or tile_h <= 0: + logging.error( + f"Invalid calculated tile dimensions ({tile_w}x{tile_h}). Cannot create composite." + ) + return None + + # 4. Create the blank composite canvas + total_width = num_lon_tiles * tile_w + total_height = num_lat_tiles * tile_h + logging.info( + f"Creating composite canvas: {total_width}x{total_height} " + f"({num_lon_tiles} columns x {num_lat_tiles} rows)" + ) + # Use RGBA to potentially allow transparent elements later if needed + composite_img = Image.new("RGBA", (total_width, total_height), PLACEHOLDER_COLOR + (255,)) # Opaque placeholder + draw = ImageDraw.Draw(composite_img) + font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default() + + # 5. Iterate through the grid cells and paste images/draw info + # Note: Image grid origin (0,0) is top-left. + # Latitude decreases downwards (North is up). + # Longitude increases rightwards (West is left). + for r in range(num_lat_tiles): # Row index (0 to num_lat_tiles-1) + lat_coord = max_lat - r # Latitude for this row (North at top) + paste_y = r * tile_h # Y-coordinate for top edge of this row + + for c in range(num_lon_tiles): # Column index (0 to num_lon_tiles-1) + lon_coord = min_lon + c # Longitude for this column (West at left) + paste_x = c * tile_w # X-coordinate for left edge of this column + + key = (lat_coord, lon_coord) + img = tile_images.get(key) # Get pre-loaded image (or None) + + # Paste the tile image if available + if img: + # Resize if necessary to match the determined tile size + if img.width != tile_w or img.height != tile_h: + try: + # Use LANCZOS (a high-quality downsampling filter) + img = img.resize((tile_w, tile_h), Image.Resampling.LANCZOS) + except Exception as e_resize: + logging.warning(f"Failed to resize image for ({lat_coord},{lon_coord}): {e_resize}") + # Draw placeholder instead of pasting broken/unresizable image + img = None # Reset img to None so placeholder is drawn below + + # Only paste if img is still valid after potential resize attempt + if img: + composite_img.paste(img, (paste_x, paste_y)) + # else: image was None (missing or failed to load) - placeholder color remains + + # Draw border around the tile cell + border_coords = [ + paste_x, paste_y, paste_x + tile_w -1, paste_y + tile_h -1 + ] + draw.rectangle( + border_coords, + outline=TILE_BORDER_COLOR, + width=TILE_BORDER_WIDTH + ) + + # Draw tile name label in the bottom-right corner of the cell + # MODIFIED: Removed the call to add_overlay_info here. + # WHY: It was drawing on a temporary crop and the result wasn't used. + # The direct drawing logic below achieves adding the tile name. + # HOW: Deleted the line `add_overlay_info(...)`. + tile_base_name_info = next((info['tile_base_name'] for info in tile_info_list if info.get("latitude_coord") == lat_coord and info.get("longitude_coord") == lon_coord), None) + if tile_base_name_info: + tile_name_text = tile_base_name_info.upper() + else: # Fallback if info not found (shouldn't happen if logic is correct) + # Construct name manually as fallback + lat_prefix = "N" if lat_coord >= 0 else "S" + lon_prefix = "E" if lon_coord >= 0 else "W" + tile_name_text = f"{lat_prefix}{abs(lat_coord):02d}{lon_prefix}{abs(lon_coord):03d}" + + + # Calculate text size and position for the tile name within its cell + try: + t_bbox = draw.textbbox((0, 0), tile_name_text, font=font) + t_w = t_bbox[2] - t_bbox[0] + t_h = t_bbox[3] - t_bbox[1] + except AttributeError: + t_w, t_h = draw.textsize(tile_name_text, font=font) # Fallback + + cell_margin = 3 + text_pos_x = paste_x + tile_w - t_w - cell_margin + text_pos_y = paste_y + tile_h - t_h - cell_margin + + # Draw background for text within the cell + bg_coords_cell = [ + text_pos_x - 1, + text_pos_y - 1, + text_pos_x + t_w + 1, + text_pos_y + t_h + 1, + ] + draw.rectangle(bg_coords_cell, fill=TILE_TEXT_BG_COLOR) + + # Draw text within the cell + try: + draw.text((text_pos_x, text_pos_y), tile_name_text, fill=TILE_TEXT_COLOR, font=font, anchor="la") + except TypeError: + draw.text((text_pos_x, text_pos_y), tile_name_text, fill=TILE_TEXT_COLOR, font=font) + + + logging.info("Composite image created successfully.") + return composite_img \ No newline at end of file diff --git a/geoelevation/visualizer.py b/geoelevation/visualizer.py new file mode 100644 index 0000000..4c75c4c --- /dev/null +++ b/geoelevation/visualizer.py @@ -0,0 +1,366 @@ +# visualizer.py + +import logging +import os +from typing import Optional, Union, TYPE_CHECKING +import time # For benchmarking processing time + +# --- Dependency Checks --- +try: + import matplotlib.pyplot as plt + from mpl_toolkits.mplot3d import Axes3D # Required for 3D projection + import numpy as np # Required by Matplotlib & for data handling + MATPLOTLIB_AVAILABLE = True +except ImportError: + MATPLOTLIB_AVAILABLE = False + # Define dummy/fallback classes and functions if Matplotlib is missing + class plt: # type: ignore + @staticmethod + def figure(*args, **kwargs): return plt + def add_subplot(self, *args, **kwargs): return plt + def plot_surface(self, *args, **kwargs): return plt + def set_xlabel(self, *args, **kwargs): pass + def set_ylabel(self, *args, **kwargs): pass + def set_zlabel(self, *args, **kwargs): pass + def set_title(self, *args, **kwargs): pass + def set_zlim(self, *args, **kwargs): pass + def colorbar(self, *args, **kwargs): pass + @staticmethod + def show(*args, **kwargs): + logging.warning("Matplotlib not available, cannot show plot.") + @staticmethod + def subplots(*args, **kwargs): return plt, plt # Dummy fig, ax + def imshow(self, *args, **kwargs): pass + def axis(self, *args, **kwargs): pass + def tight_layout(self, *args, **kwargs): pass + + + class Axes3D: pass # Dummy class + class np: # Minimal numpy dummy # type: ignore + ndarray = type(None) + @staticmethod + def array(*args, **kwargs): return None + @staticmethod + def arange(*args, **kwargs): return [] + @staticmethod + def meshgrid(*args, **kwargs): return [], [] + @staticmethod + def linspace(*args, **kwargs): return [] + @staticmethod + def nanmin(*args, **kwargs): return 0 + @staticmethod + def nanmax(*args, **kwargs): return 0 + @staticmethod + def nanmean(*args, **kwargs): return 0 + @staticmethod + def nan_to_num(*args, **kwargs): return np.array([]) + @staticmethod + def isnan(*args, **kwargs): return False + @staticmethod + def issubdtype(*args, **kwargs): return False + @staticmethod + def any(*args, **kwargs): return False + @staticmethod + def sum(*args, **kwargs): return 0 + @staticmethod + def isfinite(*args, **kwargs): return True + floating = float + float64 = float + nan = float('nan') + + logging.warning( + "Matplotlib or NumPy not found. " + "Visualization features (2D/3D plots) will be disabled." + ) + +try: + import scipy.ndimage # For gaussian_filter + from scipy.interpolate import RectBivariateSpline # For smooth 2D interpolation + SCIPY_AVAILABLE = True +except ImportError: + SCIPY_AVAILABLE = False + # Dummy class for RectBivariateSpline if SciPy missing + class RectBivariateSpline: # type: ignore + def __init__(self, *args, **kwargs): pass + def __call__(self, *args, **kwargs): return np.array([[0.0]]) + # Dummy module for scipy.ndimage + class scipy_ndimage_dummy: # type: ignore + @staticmethod + def gaussian_filter(*args, **kwargs): return args[0] # Return input array + scipy = type('SciPyDummy', (), {'ndimage': scipy_ndimage_dummy})() # type: ignore + + logging.warning( + "SciPy library not found. " + "Advanced smoothing/interpolation for 3D plots will be disabled." + ) + +# Check for Pillow (PIL) needed for loading images from paths or PIL objects +try: + from PIL import Image + PIL_AVAILABLE_VIS = True +except ImportError: + PIL_AVAILABLE_VIS = False + class Image: # Dummy class # type: ignore + Image = type(None) + @staticmethod + def open(*args, **kwargs): raise ImportError("Pillow not available") + +# Use TYPE_CHECKING to hint dependencies without runtime import errors +if TYPE_CHECKING: + import numpy as np_typing # Use an alias to avoid conflict with dummy np + from PIL import Image as PILImage_typing + + +# === Visualization Functions === + +def show_image_matplotlib( + image_source: Union[str, "np_typing.ndarray", "PILImage_typing.Image"], + title: str = "Image Preview" +): + """ + Displays an image in a separate Matplotlib window with interactive zoom/pan. + Supports loading from a file path (str), a NumPy array, or a PIL Image object. + """ + if not MATPLOTLIB_AVAILABLE: + logging.error("Cannot display image: Matplotlib is not available.") + return + + img_display_np: Optional["np_typing.ndarray"] = None + source_type = type(image_source).__name__ + logging.info(f"Attempting to display image '{title}' from source type: {source_type}") + + try: + if isinstance(image_source, str): + if not PIL_AVAILABLE_VIS: + logging.error("Cannot display image from path: Pillow (PIL) is required.") + return + if not os.path.exists(image_source): + logging.error(f"Image file not found: {image_source}") + return + try: + with Image.open(image_source) as img_pil: + img_display_np = np.array(img_pil) + except Exception as e_load: + logging.error(f"Failed to load image from path {image_source}: {e_load}", exc_info=True) + return + elif isinstance(image_source, np.ndarray): + img_display_np = image_source.copy() + elif PIL_AVAILABLE_VIS and isinstance(image_source, Image.Image): # type: ignore + img_display_np = np.array(image_source) + else: + logging.error(f"Unsupported image source type for Matplotlib: {type(image_source)}") + return + + if img_display_np is None: + logging.error("Failed to get image data as NumPy array.") + return + + fig, ax = plt.subplots(figsize=(8, 8)) + ax.imshow(img_display_np) + ax.set_title(title) + ax.axis('off') + plt.show() + logging.debug(f"Plot window for '{title}' closed.") + except Exception as e: + logging.error(f"Error displaying image '{title}' with Matplotlib: {e}", exc_info=True) + + +def show_3d_matplotlib( + hgt_array: Optional["np_typing.ndarray"], + title: str = "3D Elevation View", + initial_subsample: int = 1, + smooth_sigma: Optional[float] = None, + interpolation_factor: int = 1, + plot_grid_points: int = 500 +): + """ + Displays elevation data as a 3D surface plot. + Optionally smooths, interpolates to a dense grid, and then plots a + subsampled version of this dense grid to maintain performance. + """ + # --- Input and Dependency Checks --- + if not MATPLOTLIB_AVAILABLE: + logging.error("Cannot display 3D plot: Matplotlib is not available.") + return + if hgt_array is None: + logging.error("Cannot display 3D plot: Input data array is None.") + return + if not isinstance(hgt_array, np.ndarray) or hgt_array.ndim != 2 or hgt_array.size == 0: + logging.error(f"Invalid input data for 3D plot (shape/type).") + return + if not isinstance(initial_subsample, int) or initial_subsample < 1: + logging.warning(f"Invalid initial_subsample ({initial_subsample}). Using 1.") + initial_subsample = 1 + if smooth_sigma is not None and (not isinstance(smooth_sigma, (int, float)) or smooth_sigma <= 0): + logging.warning(f"Invalid smooth_sigma ({smooth_sigma}). Disabling smoothing.") + smooth_sigma = None + if not isinstance(interpolation_factor, int) or interpolation_factor < 1: + logging.warning(f"Invalid interpolation_factor ({interpolation_factor}). Using 1.") + interpolation_factor = 1 + if not isinstance(plot_grid_points, int) or plot_grid_points < 30: # Min for a reasonable plot + logging.warning(f"Invalid plot_grid_points ({plot_grid_points}). Setting to 200.") + plot_grid_points = 200 + + # Disable advanced features if SciPy is missing + if (interpolation_factor > 1 or smooth_sigma is not None) and not SCIPY_AVAILABLE: + logging.warning("SciPy not available. Disabling smoothing and/or interpolation.") + smooth_sigma = None + interpolation_factor = 1 + + processing_start_time = time.time() + try: + plot_title_parts = [title] # Build title dynamically + + # --- 1. Initial Subsampling (of raw data) --- + data_to_process = hgt_array[::initial_subsample, ::initial_subsample].copy() + if initial_subsample > 1: + plot_title_parts.append(f"RawSub{initial_subsample}x") + logging.info(f"Initial subsampling by {initial_subsample}. Data shape: {data_to_process.shape}") + + # --- 2. Handle NoData (Convert to float, mark NaNs) --- + if not np.issubdtype(data_to_process.dtype, np.floating): + data_to_process = data_to_process.astype(np.float64) # Use float64 for precision + common_nodata_value = -32768.0 # Ensure float for comparison with float array + nodata_mask_initial = (data_to_process == common_nodata_value) + if np.any(nodata_mask_initial): + data_to_process[nodata_mask_initial] = np.nan # Use NaN internally + logging.debug(f"Marked {np.sum(nodata_mask_initial)} NoData points as NaN.") + + # --- 3. Gaussian Smoothing (Optional, before interpolation) --- + if smooth_sigma is not None and SCIPY_AVAILABLE: + logging.info(f"Applying Gaussian smoothing (sigma={smooth_sigma})...") + try: + # gaussian_filter handles NaNs by effectively giving them zero weight + data_to_process = scipy.ndimage.gaussian_filter( + data_to_process, sigma=smooth_sigma, mode='nearest' + ) + plot_title_parts.append(f"Smooth σ{smooth_sigma:.1f}") + except Exception as e_smooth: + logging.error(f"Gaussian smoothing failed: {e_smooth}", exc_info=True) + plot_title_parts.append("(SmoothFail)") + + # --- 4. Interpolation (if requested) --- + rows_proc, cols_proc = data_to_process.shape + x_proc_coords = np.arange(cols_proc) # Original X indices of processed data + y_proc_coords = np.arange(rows_proc) # Original Y indices of processed data + + # These will hold the grid and values for the final plot_surface call + X_for_plot, Y_for_plot, Z_for_plot = None, None, None + + if interpolation_factor > 1 and SCIPY_AVAILABLE: + plot_title_parts.append(f"Interp{interpolation_factor}x") + logging.info(f"Performing spline interpolation (factor={interpolation_factor}). Input shape: {data_to_process.shape}") + + # Define the DENSE grid for spline evaluation + x_dense_eval_coords = np.linspace(x_proc_coords.min(), x_proc_coords.max(), cols_proc * interpolation_factor) + y_dense_eval_coords = np.linspace(y_proc_coords.min(), y_proc_coords.max(), rows_proc * interpolation_factor) + + # Prepare data for spline fitting (RectBivariateSpline doesn't like NaNs) + data_for_spline_fit = data_to_process.copy() + nan_in_data_for_spline = np.isnan(data_for_spline_fit) + if np.any(nan_in_data_for_spline): + # Fill NaNs, e.g., with mean of valid data or 0 if all are NaN + fill_value = np.nanmean(data_for_spline_fit) + if np.isnan(fill_value): fill_value = 0.0 # Fallback if all data was NaN + data_for_spline_fit[nan_in_data_for_spline] = fill_value + logging.debug(f"Filled {np.sum(nan_in_data_for_spline)} NaNs with {fill_value:.2f} for spline fitting.") + + try: + # Create spline interpolator (kx=3, ky=3 for bicubic) + spline = RectBivariateSpline(y_proc_coords, x_proc_coords, data_for_spline_fit, kx=3, ky=3, s=0) + # Evaluate spline on the DENSE grid + Z_dense_interpolated = spline(y_dense_eval_coords, x_dense_eval_coords) + logging.info(f"Interpolation complete. Dense grid shape: {Z_dense_interpolated.shape}") + + # Subsample this DENSE interpolated grid for PLOTTING + # Calculate stride to approximate `plot_grid_points` along each axis + plot_stride_y = max(1, int(Z_dense_interpolated.shape[0] / plot_grid_points)) + plot_stride_x = max(1, int(Z_dense_interpolated.shape[1] / plot_grid_points)) + logging.info(f"Subsampling dense interpolated grid for plotting with Y-stride:{plot_stride_y}, X-stride:{plot_stride_x}") + + # Select coordinates and Z values for the final plot grid + final_y_coords_for_plot = y_dense_eval_coords[::plot_stride_y] + final_x_coords_for_plot = x_dense_eval_coords[::plot_stride_x] + X_for_plot, Y_for_plot = np.meshgrid(final_x_coords_for_plot, final_y_coords_for_plot) + Z_for_plot = Z_dense_interpolated[::plot_stride_y, ::plot_stride_x] + + except Exception as e_interp: + logging.error(f"Spline interpolation or subsequent subsampling failed: {e_interp}", exc_info=True) + plot_title_parts.append("(InterpFail)") + # Fallback: plot the processed (maybe smoothed) data, subsampled to plot_grid_points + plot_stride_y = max(1, int(rows_proc / plot_grid_points)) + plot_stride_x = max(1, int(cols_proc / plot_grid_points)) + X_for_plot, Y_for_plot = np.meshgrid(x_proc_coords[::plot_stride_x], y_proc_coords[::plot_stride_y]) + Z_for_plot = data_to_process[::plot_stride_y, ::plot_stride_x] + else: + # No interpolation: plot the processed data, subsampled to achieve plot_grid_points + logging.info("Skipping interpolation. Subsampling processed data for plotting.") + plot_stride_y = max(1, int(rows_proc / plot_grid_points)) + plot_stride_x = max(1, int(cols_proc / plot_grid_points)) + X_for_plot, Y_for_plot = np.meshgrid(x_proc_coords[::plot_stride_x], y_proc_coords[::plot_stride_y]) + Z_for_plot = data_to_process[::plot_stride_y, ::plot_stride_x] + + # Construct final plot title + final_plot_title = " ".join(plot_title_parts) + # Display actual plot grid size (Y, X for shape) + final_plot_title += f" (PlotGrid {Z_for_plot.shape[0]}x{Z_for_plot.shape[1]})" + + processing_end_time = time.time() + logging.info(f"Data processing for 3D plot took {processing_end_time - processing_start_time:.2f} seconds.") + + # --- 5. Plotting the Result --- + logging.info(f"Generating Matplotlib 3D plot. Final plot grid size: {Z_for_plot.shape}") + fig = plt.figure(figsize=(10, 8)) # Slightly larger figure + ax = fig.add_subplot(111, projection='3d') + + # Determine Z limits from the final data to be plotted (Z_for_plot) + # Handle potential NaNs that might persist or be introduced + z_min, z_max = np.nanmin(Z_for_plot), np.nanmax(Z_for_plot) + if np.isnan(z_min) or not np.isfinite(z_min): z_min = 0.0 + if np.isnan(z_max) or not np.isfinite(z_max): z_max = z_min + 100.0 # Fallback range if max is also bad + if z_min >= z_max : z_max = z_min + 100.0 # Ensure z_max > z_min + + + # Create the 3D surface plot + # rstride/cstride=1 because X_for_plot/Y_for_plot/Z_for_plot are already at the desired plot density + surf = ax.plot_surface( + X_for_plot, Y_for_plot, Z_for_plot, + rstride=1, cstride=1, # Plot all points from the prepared grid + cmap='terrain', # Standard colormap for terrain + linewidth=0.1, # Thin lines for dense meshes can look good + antialiased=False, # For smoother rendering of facets + shade=False, # Apply shading for better 3D perception + vmin=z_min, # Set color limits based on data range + vmax=z_max + ) + + # --- Customize Plot Appearance --- + ax.set_xlabel("X Index (Scaled/Processed)") + ax.set_ylabel("Y Index (Scaled/Processed)") + ax.set_zlabel("Elevation (m)") + ax.set_title(final_plot_title, fontsize=10) # Use a slightly smaller font for the potentially long title + + # Set Z-axis limits with some padding + z_range = z_max - z_min + padding = z_range * 0.1 if z_range > 0 else 10.0 # Ensure some padding even if range is zero + ax_z_min = z_min - padding + ax_z_max = z_max + padding + ax.set_zlim(ax_z_min, ax_z_max) + + # Add a color bar + fig.colorbar(surf, shrink=0.5, aspect=10, label="Elevation (m)", pad=0.1) # Add padding to colorbar + + # Improve layout to prevent labels from overlapping + try: + fig.tight_layout() + except Exception: + logging.warning("fig.tight_layout() failed, plot might have overlapping elements.") + + + plotting_end_time = time.time() + logging.info(f"Plotting setup complete. Total time: {plotting_end_time - processing_start_time:.2f}s. Showing plot...") + plt.show() # BLOCKING CALL + + except Exception as e: + # Catch any unexpected errors during the entire process + logging.error(f"Critical error in show_3d_matplotlib ('{title}'): {e}", exc_info=True) \ No newline at end of file diff --git a/image_processor.py b/image_processor.py deleted file mode 100644 index dbcfc6e..0000000 --- a/image_processor.py +++ /dev/null @@ -1,288 +0,0 @@ -# image_processor.py - -import os -import math -import logging -from typing import Optional, List, Dict, Tuple - -try: - from PIL import Image, ImageDraw, ImageFont - - PIL_AVAILABLE = True -except ImportError: - PIL_AVAILABLE = False - - # Definisci classi dummy se PIL non c'è, per evitare errori altrove - class Image: - pass - - class ImageDraw: - pass - - class ImageFont: - pass - - logging.error( - "Pillow library (PIL) not found. Image processing features will be disabled." - ) - -# Costanti per disegno (possono essere spostate in config se necessario) -TILE_TEXT_COLOR = "white" -TILE_TEXT_BG_COLOR = "black" # Sfondo opaco -PLACEHOLDER_COLOR = (128, 128, 128) # Grigio per placeholder RGB -TILE_BORDER_COLOR = "red" -TILE_BORDER_WIDTH = 2 # Spessore bordo griglia - -# Carica font (una sola volta) -DEFAULT_FONT = None -if PIL_AVAILABLE: - try: - # Prova diversi font comuni - font_paths = [ - "arial.ttf", - "LiberationSans-Regular.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - ] - for font_path in font_paths: - try: - DEFAULT_FONT = ImageFont.truetype( - font_path, 12 - ) # Aumenta dimensione font - logging.info(f"Loaded font: {font_path}") - break - except IOError: - continue # Prova il prossimo - if DEFAULT_FONT is None: - DEFAULT_FONT = ImageFont.load_default() - logging.warning("Common system fonts not found, using default PIL font.") - except Exception as e: - logging.error(f"Error loading font: {e}") - DEFAULT_FONT = ImageFont.load_default() if PIL_AVAILABLE else None - - -def add_overlay_info( - image: Optional[Image.Image], tile_name: str, source_text: str = "NASADEM" -) -> Optional[Image.Image]: - """ - Draws tile name and source information onto a PIL Image object. - - Args: - image (Optional[Image.Image]): The PIL Image object to draw on. If None, does nothing. - tile_name (str): The base name of the tile (e.g., 'n45e007'). - source_text (str): Text indicating the data source. - - Returns: - Optional[Image.Image]: The modified image object, or None if input was None or PIL unavailable. - """ - if not PIL_AVAILABLE or image is None: - return None - - try: - # Assicurati sia modificabile (RGB o RGBA) - if image.mode not in ["RGB", "RGBA"]: - image = image.convert("RGB") - - draw = ImageDraw.Draw(image) - text = f"{tile_name.upper()}\nSource: {source_text}" - font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default() - - # Calcola dimensioni testo per posizionamento - try: - # textbbox è più preciso ma richiede Pillow >= 8.0.0 - text_bbox = draw.textbbox((0, 0), text, font=font) - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - except AttributeError: - # Fallback per versioni Pillow più vecchie - text_width, text_height = draw.textsize(text, font=font) - - margin = 5 - text_x = image.width - text_width - margin - text_y = image.height - text_height - margin - - # Disegna sfondo e testo - bg_coords = [ - text_x - 2, - text_y - 2, - text_x + text_width + 2, - text_y + text_height + 2, - ] - draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR) - draw.text((text_x, text_y), text, fill=TILE_TEXT_COLOR, font=font) - - return image - - except Exception as e: - logging.error( - f"Error adding overlay to image for {tile_name}: {e}", exc_info=True - ) - return image # Restituisci immagine originale in caso di errore - - -def load_prepare_single_browse( - image_path: Optional[str], tile_name: str -) -> Optional[Image.Image]: - """ - Loads a browse image from path, adds overlay info. - - Args: - image_path (Optional[str]): Path to the browse image file. - tile_name (str): Base name of the tile (e.g., 'n45e007'). - - Returns: - Optional[Image.Image]: Prepared PIL Image object or None if loading/processing fails. - """ - if not PIL_AVAILABLE or not image_path or not os.path.exists(image_path): - return None - - try: - logging.debug(f"Loading browse image: {image_path}") - img = Image.open(image_path) - img_with_overlay = add_overlay_info(img, tile_name) - return img_with_overlay - except Exception as e: - logging.error( - f"Failed to load or prepare browse image {image_path}: {e}", exc_info=True - ) - return None - - -def create_composite_area_image( - tile_info_list: List[Dict], target_tile_size: Optional[Tuple[int, int]] = None -) -> Optional[Image.Image]: - """ - Creates a composite image by mosaicking available browse images for an area. - - Args: - tile_info_list (List[Dict]): List of dictionaries from ElevationManager.get_area_tile_info. - target_tile_size (Optional[Tuple[int, int]]): If provided, resize each tile to this size. - Otherwise, uses max dimensions found. - - Returns: - Optional[Image.Image]: Composite PIL Image object or None if no tiles or error. - """ - if not PIL_AVAILABLE or not tile_info_list: - logging.warning( - "Cannot create composite image: PIL unavailable or no tile info provided." - ) - return None - - logging.info(f"Creating composite image for {len(tile_info_list)} tile area.") - - # Estrai coordinate min/max per determinare la griglia - lats = [info["latitude_coord"] for info in tile_info_list] - lons = [info["longitude_coord"] for info in tile_info_list] - if not lats or not lons: - return None # Lista vuota? - min_lat, max_lat = min(lats), max(lats) - min_lon, max_lon = min(lons), max(lons) - - num_lat_tiles = max_lat - min_lat + 1 - num_lon_tiles = max_lon - min_lon + 1 - - # Carica immagini e determina dimensioni massime (o usa target) - tile_images: Dict[Tuple[int, int], Optional[Image.Image]] = {} - max_w, max_h = 0, 0 - loaded_image_count = 0 - - for info in tile_info_list: - lat, lon = info["latitude_coord"], info["longitude_coord"] - key = (lat, lon) - img_path = info.get("browse_image_path") - img = None - if img_path and os.path.exists(img_path): - try: - img = Image.open(img_path).convert("RGB") # Carica come RGB - tile_images[key] = img - if not target_tile_size: # Se non forziamo dimensione, trova max - max_w = max(max_w, img.width) - max_h = max(max_h, img.height) - loaded_image_count += 1 - except Exception as e: - logging.warning(f"Could not load browse image {img_path}: {e}") - tile_images[key] = None # Segna come non caricata - else: - tile_images[key] = None # Non disponibile - - if loaded_image_count == 0: - logging.warning("No browse images available for the specified area.") - # Potremmo restituire None o un'immagine placeholder unica? Per ora None. - return None - - # Imposta dimensione cella: target o massima trovata - tile_w = target_tile_size[0] if target_tile_size else max_w - tile_h = target_tile_size[1] if target_tile_size else max_h - if tile_w <= 0 or tile_h <= 0: - logging.error( - "Invalid target tile size or no valid images found to determine size." - ) - return None # Dimensione non valida - - # Crea immagine composita - total_width = num_lon_tiles * tile_w - total_height = num_lat_tiles * tile_h - logging.info( - f"Creating composite canvas: {total_width}x{total_height} ({num_lon_tiles}x{num_lat_tiles} tiles of {tile_w}x{tile_h})" - ) - composite_img = Image.new("RGB", (total_width, total_height), PLACEHOLDER_COLOR) - draw = ImageDraw.Draw(composite_img) - - # Incolla tile, disegna griglia e testo - for r, lat_coord in enumerate( - range(max_lat, min_lat - 1, -1) - ): # Y cresce verso il basso (Nord in alto) - for c, lon_coord in enumerate( - range(min_lon, max_lon + 1) - ): # X cresce verso destra (Ovest a sinistra) - key = (lat_coord, lon_coord) - img = tile_images.get(key) - - paste_x = c * tile_w - paste_y = r * tile_h - - if img: - # Ridimensiona se necessario - if img.width != tile_w or img.height != tile_h: - img = img.resize((tile_w, tile_h), Image.Resampling.LANCZOS) - composite_img.paste(img, (paste_x, paste_y)) - - # Disegna bordo - draw.rectangle( - [paste_x, paste_y, paste_x + tile_w - 1, paste_y + tile_h - 1], - outline=TILE_BORDER_COLOR, - width=TILE_BORDER_WIDTH, - ) - - # Aggiungi etichetta (riutilizza logica) - tile_name = f"{'N' if lat_coord >= 0 else 'S'}{abs(lat_coord):02d}{'E' if lon_coord >= 0 else 'W'}{abs(lon_coord):03d}" - add_overlay_info( - composite_img.crop( - (paste_x, paste_y, paste_x + tile_w, paste_y + tile_h) - ), # Disegna su un crop temporaneo? No, direttamente sulla composita - tile_name, - source_text="", - ) # Passa empty source? O solo nome tile? - - # Disegna nome tile direttamente sulla composita - font = DEFAULT_FONT if DEFAULT_FONT else ImageFont.load_default() - text = tile_name - try: - text_bbox = draw.textbbox((0, 0), text, font=font) - text_w = text_bbox[2] - text_bbox[0] - text_h = text_bbox[3] - text_bbox[1] - except AttributeError: - text_w, text_h = draw.textsize(text, font=font) # Fallback - margin = 3 - text_pos_x = paste_x + tile_w - text_w - margin - text_pos_y = paste_y + tile_h - text_h - margin - bg_coords = [ - text_pos_x - 1, - text_pos_y - 1, - text_pos_x + text_w + 1, - text_pos_y + text_h + 1, - ] - draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR) - draw.text((text_pos_x, text_pos_y), text, fill=TILE_TEXT_COLOR, font=font) - - logging.info("Composite image created successfully.") - return composite_img diff --git a/tool_config.json b/tool_config.json new file mode 100644 index 0000000..a9aec9f --- /dev/null +++ b/tool_config.json @@ -0,0 +1,8 @@ +{ + "display_name": "PyInstaller GUI Wrapper", + "description": "Creates executables from Python projects using PyInstaller with a GUI.", + "command": ["python", "-m", "pyinstallerguiwrapper"], + "version": "1.1", + "parameters": [], + "has_gui": true +} \ No newline at end of file diff --git a/visualizer.py b/visualizer.py deleted file mode 100644 index 2b353ff..0000000 --- a/visualizer.py +++ /dev/null @@ -1,198 +0,0 @@ -# visualizer.py - -import logging -import os -from typing import Optional, Union - -# Rimuovi import cv2 se non serve più ad altro -# try: -# import cv2 -# import numpy as np # OpenCV richiede numpy -# OPENCV_AVAILABLE = True -# except ImportError: -# OPENCV_AVAILABLE = False -# class np: pass -# logging.warning("OpenCV (cv2) or NumPy not found. OpenCV visualization disabled.") - -try: - import matplotlib.pyplot as plt - from mpl_toolkits.mplot3d import Axes3D # Per 3D - import numpy as np # Matplotlib richiede numpy - - MATPLOTLIB_AVAILABLE = True -except ImportError: - MATPLOTLIB_AVAILABLE = False - - class np: - pass - - class plt: # Dummy class - def figure(self, figsize): - return self - - def add_subplot(self, *args, **kwargs): - return self - - def plot_surface(self, *args, **kwargs): - return self - - def set_xlabel(self, *args): - pass - - def set_ylabel(self, *args): - pass - - def set_zlabel(self, *args): - pass - - def set_title(self, *args): - pass - - def set_zlim(self, *args): - pass - - def colorbar(self, *args, **kwargs): - pass - - def show(self): - pass - - def subplots(self): - return self, self # Dummy fig, ax - - def imshow(self, *args): - pass - - def axis(self, *args): - pass - - logging.warning("Matplotlib not found. 2D/3D plot visualization will be disabled.") - -try: - from PIL import Image - - PIL_AVAILABLE_VIS = True -except ImportError: - PIL_AVAILABLE_VIS = False - - class Image: - pass # Dummy - - -# === RIMOSSA: show_image_cv2 === - - -# === NUOVA FUNZIONE: show_image_matplotlib === -def show_image_matplotlib( - image_source: Union[str, np.ndarray, Image.Image], title: str = "Image Preview" -): - """ - Displays an image (from path, NumPy array, or PIL Image) in a separate - Matplotlib window with interactive zoom/pan. - Requires Matplotlib and NumPy. PIL is needed if input is PIL Image or path. - """ - if not MATPLOTLIB_AVAILABLE: - logging.error("Cannot display image: Matplotlib not available.") - return - if not PIL_AVAILABLE_VIS and isinstance(image_source, (str, Image.Image)): - logging.error( - "Cannot display image: Pillow (PIL) not available for loading/conversion." - ) - return - - img_display_np = None - try: - if isinstance(image_source, str): # Path - if not os.path.exists(image_source): - logging.error( - f"Matplotlib display: Image path not found: {image_source}" - ) - return - with Image.open(image_source) as img_pil: - # Converti in NumPy array (Matplotlib preferisce NumPy) - img_display_np = np.array(img_pil) - elif isinstance(image_source, np.ndarray): # NumPy array - img_display_np = ( - image_source.copy() - ) # Usa direttamente (o copia per sicurezza) - elif PIL_AVAILABLE_VIS and isinstance(image_source, Image.Image): # PIL Image - img_display_np = np.array(image_source) - else: - logging.error( - f"Matplotlib display: Unsupported image source type: {type(image_source)}" - ) - return - - if img_display_np is None: - logging.error("Failed to load or convert image to NumPy array.") - return - - # Crea figura e asse - fig, ax = plt.subplots(figsize=(8, 8)) # Puoi aggiustare figsize - ax.imshow(img_display_np) - - # Abbellimenti opzionali - ax.set_title(title) - ax.axis("off") # Nasconde assi numerici e tick - - # Mostra la finestra interattiva (BLOCCANTE) - # La GUI chiamante deve gestire l'esecuzione in un thread. - plt.show() - - except Exception as e: - logging.error( - f"Error displaying image with Matplotlib ('{title}'): {e}", exc_info=True - ) - - -# === Funzione show_3d_matplotlib rimane invariata === -def show_3d_matplotlib( - hgt_array: Optional[np.ndarray], - title: str = "3D Elevation View", - subsample: int = 5, -): - """Displays HGT data as a 3D surface plot using Matplotlib.""" - if not MATPLOTLIB_AVAILABLE: - logging.error("Cannot display 3D plot: Matplotlib not available.") - return - if hgt_array is None or hgt_array.size == 0: - logging.error("Cannot display 3D view: Input data array is None or empty.") - return - - try: - # ... (implementazione 3D come prima) ... - logging.info( - f"Generating 3D plot for data shape: {hgt_array.shape} with subsampling={subsample}" - ) - rows, cols = hgt_array.shape - x = np.arange(0, cols, subsample) - y = np.arange(0, rows, subsample) - X, Y = np.meshgrid(x, y) - Z = hgt_array[::subsample, ::subsample].astype(float) - common_nodata = -32768 - Z[Z == common_nodata] = np.nan - fig = plt.figure(figsize=(10, 7)) - ax = fig.add_subplot(111, projection="3d") - z_min, z_max = np.nanmin(Z), np.nanmax(Z) - if np.isnan(z_min) or np.isnan(z_max): - z_min, z_max = 0, 100 # Fallback - surf = ax.plot_surface( - X, - Y, - Z, - cmap="terrain", - linewidth=0, - antialiased=False, - vmin=z_min, - vmax=z_max, - ) - ax.set_xlabel("Pixel Col Index (Subsampled)") - ax.set_ylabel("Pixel Row Index (Subsampled)") - ax.set_zlabel("Elevation (m)") - ax.set_title(title) - ax.set_zlim(z_min - (z_max - z_min) * 0.1, z_max + (z_max - z_min) * 0.1) - fig.colorbar(surf, shrink=0.6, aspect=10, label="Elevation (m)") - plt.show() # BLOCCANTE - - except Exception as e: - logging.error(f"Error generating 3D plot ('{title}'): {e}", exc_info=True)