Chore: Stop tracking files based on .gitignore update.

Untracked files matching the following rules:
- Rule "_build/": 16 files
This commit is contained in:
VALLONGOL 2025-05-06 09:24:45 +02:00
parent bcb2bf3fdc
commit 685382c2a9
15 changed files with 1390 additions and 1005 deletions

5
.gitignore vendored
View File

@ -4,4 +4,7 @@ map_elevation/
.jpg
.png
elevation_cache/
__pycache__/
__pycache__/
_version.py
_build/
_dist/

14
.vscode/launch.json vendored Normal file
View File

@ -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"
}
]
}

View File

@ -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)

45
geoelevation.spec Normal file
View File

@ -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')

0
geoelevation/__init__.py Normal file
View File

81
geoelevation/__main__.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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

366
geoelevation/visualizer.py Normal file
View File

@ -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)

View File

@ -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

8
tool_config.json Normal file
View File

@ -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
}

View File

@ -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)