Chore: Stop tracking files based on .gitignore update.
Untracked files matching the following rules: - Rule "_build/": 16 files
This commit is contained in:
parent
bcb2bf3fdc
commit
685382c2a9
5
.gitignore
vendored
5
.gitignore
vendored
@ -4,4 +4,7 @@ map_elevation/
|
||||
.jpg
|
||||
.png
|
||||
elevation_cache/
|
||||
__pycache__/
|
||||
__pycache__/
|
||||
_version.py
|
||||
_build/
|
||||
_dist/
|
||||
14
.vscode/launch.json
vendored
Normal file
14
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
518
elevation_gui.py
518
elevation_gui.py
@ -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
45
geoelevation.spec
Normal 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
0
geoelevation/__init__.py
Normal file
81
geoelevation/__main__.py
Normal file
81
geoelevation/__main__.py
Normal 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()
|
||||
454
geoelevation/elevation_gui.py
Normal file
454
geoelevation/elevation_gui.py
Normal 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)
|
||||
418
geoelevation/image_processor.py
Normal file
418
geoelevation/image_processor.py
Normal 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
366
geoelevation/visualizer.py
Normal 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)
|
||||
@ -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
8
tool_config.json
Normal 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
|
||||
}
|
||||
198
visualizer.py
198
visualizer.py
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user