fix rectangular view for dem files

This commit is contained in:
VALLONGOL 2025-05-14 09:31:53 +02:00
parent 3a5a3db6ad
commit 46202a2829
4 changed files with 596 additions and 273 deletions

View File

@ -29,9 +29,8 @@ from geoelevation.visualizer import MATPLOTLIB_AVAILABLE
from geoelevation.visualizer import SCIPY_AVAILABLE
# MODIFIED: Corrected import of default cache directory constant from the geoelevation package's public API
# WHY: The previous import was attempting to use the internal name `DEFAULT_CACHE_DIR` which is not exposed
# by geoelevation/__init__.py. The correct name exposed in __init__.py (and sourced from config.py)
# is GEOELEVATION_DEM_CACHE_DEFAULT. This change aligns the import with the package's defined public API.
# WHY: This constant holds the path to the default DEM cache directory
# as configured in the.config.py module and exposed by __init__.py.
# HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed
# in the import statement itself, as the imported name is already the desired one. The existing try...except
# block correctly handles the case where this specific constant might not be available from the package.
@ -41,19 +40,53 @@ try:
# WHY: Needed to convert coordinates to DMS format for the GUI display.
# HOW: Added the import statement from the map_viewer.map_utils sub-module.
from geoelevation.map_viewer.map_utils import deg_to_dms_string
# MODIFIED: Import the utility functions for geographic bounds calculation from map_utils.py.
# WHY: Needed to get geographic bounds for 2D plot aspect ratio correction.
# HOW: Added the import statements.
from geoelevation.map_viewer.map_utils import get_hgt_tile_geographic_bounds # For single tile browse
from geoelevation.map_viewer.map_utils import get_combined_geographic_bounds_from_tile_info_list # For area composite
# MODIFIED: Import the multiprocessing target functions from the new module.
# WHY: These functions have been moved to their own module as part of refactoring.
# HOW: Added the import statement.
from geoelevation import process_targets # Import the new module containing targets
except ImportError:
# This fallback is if the main __init__.py itself has issues exporting the constant,
# which shouldn't happen if __init__.py and config.py are correct.
GEOELEVATION_DEM_CACHE_DEFAULT = "elevation_data_cache_gui_fallback_critical"
# MODIFIED: Define a dummy deg_to_dms_string function if import fails.
# WHY: Avoid NameError if the import fails critically.
# HOW: Define a simple function that returns "Import Error".
# MODIFIED: Define dummy deg_to_dms_string and bounds functions if import fails.
# WHY: Avoid NameError if the imports fail critically.
# HOW: Define simple functions that return default/error values.
def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore
return "Import Error"
# Dummy bounds functions
def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Optional[Tuple[float, float, float, float]]: # type: ignore
logger.error("map_utils bounds functions not available due to import error.")
return None
def get_combined_geographic_bounds_from_tile_info_list(tile_info_list: List[Dict]) -> Optional[Tuple[float, float, float, float]]: # type: ignore
logger.error("map_utils bounds functions not available due to import error.")
return None
# MODIFIED: Define dummy process_targets object if the module import fails.
# WHY: Prevent NameError when trying to access process_targets attributes.
# HOW: Define a simple dummy object.
class process_targets: # type: ignore
@staticmethod
def process_target_show_image(*args: Any, **kwargs: Any) -> None: pass
@staticmethod
def process_target_show_3d(*args: Any, **kwargs: Any) -> None: pass
@staticmethod
def process_target_create_show_area(*args: Any, **kwargs: Any) -> None: pass
@staticmethod
def run_map_viewer_process_target(*args: Any, **kwargs: Any) -> None: pass
logging.getLogger(__name__).critical(
"elevation_gui.py: CRITICAL - Could not import GEOELEVATION_DEM_CACHE_DEFAULT or deg_to_dms_string from geoelevation package. "
f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. DMS conversion unavailable."
f"elevation_gui.py: CRITICAL - Could not import core dependencies from geoelevation package. "
f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. Some features unavailable."
)
@ -72,232 +105,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# --- MULTIPROCESSING TARGET FUNCTIONS ---
# (Queste funzioni rimangono identiche alla versione precedente completa che ti ho fornito)
def process_target_show_image(image_path: str, tile_name: str, window_title: str) -> None:
try:
from geoelevation.image_processor import load_prepare_single_browse as local_lpst
from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok
from geoelevation.visualizer import show_image_matplotlib as local_sim
from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok
import os as local_os
if not (local_pil_ok and local_mpl_ok):
print("PROCESS ERROR (show_image): Pillow/Matplotlib missing in child process.")
return
prepared_image = local_lpst(image_path, tile_name)
if prepared_image:
print(f"PROCESS (show_image): Showing '{window_title}'")
local_sim(prepared_image, window_title)
else:
print(f"PROCESS ERROR (show_image): Could not prepare {local_os.path.basename(image_path)}")
except Exception as e_proc_img:
print(f"PROCESS ERROR in process_target_show_image: {e_proc_img}")
import traceback as tb
tb.print_exc()
def process_target_show_3d(
hgt_data: Optional["np_typing.ndarray"],
plot_title: str,
initial_subsample: int,
smooth_sigma: Optional[float],
interpolation_factor: int,
plot_grid_points: int
) -> None:
try:
from geoelevation.visualizer import show_3d_matplotlib as local_s3d
from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok
from geoelevation.visualizer import SCIPY_AVAILABLE as local_scipy_ok
if not local_mpl_ok:
print("PROCESS ERROR (show_3d): Matplotlib missing in child process.")
return
effective_smooth_sigma = smooth_sigma
effective_interpolation_factor = interpolation_factor
if (interpolation_factor > 1 or smooth_sigma is not None) and not local_scipy_ok:
print("PROCESS WARNING (show_3d): SciPy missing in child. Disabling 3D plot smoothing/interpolation.")
effective_smooth_sigma = None
effective_interpolation_factor = 1
if hgt_data is not None:
print(f"PROCESS (show_3d): Plotting '{plot_title}' (InitialSub:{initial_subsample}, Smooth:{effective_smooth_sigma}, Interp:{effective_interpolation_factor}x, PlotGridTarget:{plot_grid_points})")
local_s3d(hgt_data, plot_title, initial_subsample, effective_smooth_sigma, effective_interpolation_factor, plot_grid_points)
else:
print("PROCESS ERROR (show_3d): No HGT data array received for plotting.")
except Exception as e_proc_3d:
print(f"PROCESS ERROR in process_target_show_3d: {e_proc_3d}")
import traceback as tb
tb.print_exc()
def process_target_create_show_area(tile_info_list_data: List[Dict], window_title_str: str) -> None:
try:
from geoelevation.image_processor import create_composite_area_image as local_ccai
from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok
from geoelevation.visualizer import show_image_matplotlib as local_sim
from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok
if not (local_pil_ok and local_mpl_ok):
print("PROCESS ERROR (show_area): Pillow/Matplotlib libraries are missing in the child process.")
return
print("PROCESS (show_area): Creating composite area image...")
composite_pil_image = local_ccai(tile_info_list_data)
if composite_pil_image:
print(f"PROCESS (show_area): Displaying composite image '{window_title_str}'")
local_sim(composite_pil_image, window_title_str)
else:
print("PROCESS ERROR (show_area): Failed to create composite area image.")
except Exception as e_proc_area:
print(f"PROCESS ERROR in process_target_create_show_area: {e_proc_area}")
import traceback as tb
tb.print_exc()
def run_map_viewer_process_target(
map_interaction_q: multiprocessing.Queue,
operation_mode: str,
center_latitude: Optional[float],
center_longitude: Optional[float],
area_bounding_box: Optional[Tuple[float, float, float, float]],
dem_data_cache_dir: str,
display_scale_factor: float
) -> None:
child_logger = logging.getLogger("GeoElevationMapViewerChildProcess")
# Configure logger if it hasn't been configured in this process yet
if not child_logger.hasHandlers():
# Use basicConfig as this is a fresh process, ensure it doesn't inherit handlers from root
# and send output to sys.stdout/stderr which is captured by the parent process's terminal.
# Level should ideally be inherited or passed, but setting INFO here as a default.
# Also ensure the geoelevation logger in this process has a handler for its messages to be seen.
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stdout # Explicitly route to stdout
)
# Ensure geoelevation logger sends messages if its level is below root default (e.g. INFO)
geoelevation_proc_logger = logging.getLogger("geoelevation")
if not geoelevation_proc_logger.hasHandlers():
geoelevation_proc_logger.addHandler(logging.StreamHandler(sys.stdout)) # Add a stream handler
child_map_viewer_instance: Optional[Any] = None
child_map_system_ok = False
try:
# Attempt to import components needed in the child process
from geoelevation.map_viewer.geo_map_viewer import GeoElevationMapViewer as ChildGeoMapViewer
from geoelevation.elevation_manager import ElevationManager as ChildElevationManager
import cv2 as child_cv2 # Ensure cv2 is available in the child process
child_map_system_ok = True
except ImportError as e_child_imp_map_corrected:
child_logger.error(f"Map viewer components or OpenCV not found in child process: {e_child_imp_map_corrected}")
# MODIFIED: Send an error message back to the GUI queue if critical imports fail in child process.
# WHY: The GUI process needs to know that the map process failed to start properly.
# HOW: Put a specific error message in the queue before exiting.
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields
"elevation_str": f"Fatal Error: {type(e_child_imp_map_corrected).__name__}",
"map_area_size_str": "Map System N/A"}
try: map_interaction_q.put(error_payload)
except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
# Note: No return here yet, the finally block will execute.
# Let's add a return right after sending the error.
return # Exit if critical libraries are missing
# MODIFIED: Added the main operational logic for the map viewer process.
# WHY: Centralizes the try/except/finally for the core map viewing loop.
# HOW: Wrapped the map viewer initialization and display logic in a try block.
try:
child_logger.info(f"Initializing for mode '{operation_mode}', display scale: {display_scale_factor:.2f}")
# MODIFIED: Pass the correct DEM cache directory path to the child ElevationManager.
# WHY: The map viewer process needs its own ElevationManager instance, configured with the same cache path.
# HOW: Used the dem_data_cache_dir parameter passed from the GUI.
local_em = ChildElevationManager(tile_directory=dem_data_cache_dir)
child_map_viewer_instance = ChildGeoMapViewer(
elevation_manager_instance=local_em,
gui_output_communication_queue=map_interaction_q,
initial_display_scale=display_scale_factor
)
if operation_mode == "point" and center_latitude is not None and center_longitude is not None:
# MODIFIED: Call display_map_for_point with the center coordinates.
# WHY: This method contains the logic to stitch the map, draw markers, and send initial info.
# HOW: Called the method on the initialized child_map_viewer_instance.
child_map_viewer_instance.display_map_for_point(center_latitude, center_longitude)
elif operation_mode == "area" and area_bounding_box:
# MODIFIED: Call display_map_for_area with the area bounding box.
# WHY: This method handles stitching the map for an area and potentially drawing a boundary.
# HOW: Called the method on the initialized child_map_viewer_instance.
child_map_viewer_instance.display_map_for_area(area_bounding_box)
else:
child_logger.error(f"Invalid mode ('{operation_mode}') or missing parameters passed to map process.")
# MODIFIED: Send an error message to the GUI queue if the mode or parameters are invalid.
# WHY: The GUI process needs feedback if the map process received bad instructions.
# HOW: Put a specific error message in the queue before exiting.
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields
"elevation_str": f"Fatal Error: Invalid Map Args",
"map_area_size_str": "Invalid Args"}
try: map_interaction_q.put(error_payload)
except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
# No need to call shutdown here, as the instance might not be fully initialized
return # Exit on invalid parameters
child_logger.info("Map display method called. Entering event loop.")
# The map display window (managed by MapDisplayWindow) runs its own internal
# event loop when cv2.imshow is called periodically, and handles user input via callbacks.
# The loop here just needs to keep the process alive and allow the window
# to process events via waitKey. We also check if the window is still alive.
is_map_active = True
while is_map_active:
# cv2.waitKey(milliseconds) is crucial for processing window events and mouse callbacks
# A short delay (e.g., 100ms) prevents the loop from consuming 100% CPU.
key = child_cv2.waitKey(100) # Check for key press every 100ms (optional, but good practice)
# Check if the window still exists and is active.
# is_window_alive() uses cv2.getWindowProperty which is thread-safe.
if child_map_viewer_instance.map_display_window_controller:
if child_map_viewer_instance.map_display_window_controller.is_window_alive():
# Optional: Check for specific keys to close the window (e.g., 'q' or Escape)
if key != -1: # A key was pressed
child_logger.debug(f"Map window received key press: {key}")
# Convert key code to character if it's printable for logging
try:
char_key = chr(key & 0xFF) # Mask to get ASCII value
logger.debug(f"Key code {key} corresponds to character '{char_key}'.")
if char_key in ('q', 'Q'): # 'q' or 'Q' key
child_logger.info("Map window closing due to 'q'/'Q' key press.")
is_map_active = False # Signal loop to exit
except ValueError: # Non-printable key
pass # Just ignore non-printable keys
if key == 27: # Escape key
child_logger.info("Map window closing due to Escape key press.")
is_map_active = False # Signal loop to exit
else:
child_logger.info("Map window reported as not alive by is_window_alive().")
is_map_active = False # Signal loop to exit if window closed by user 'X' button
else:
child_logger.info("map_display_window_controller is None. Assuming window closed or initialization failed.")
is_map_active = False # Signal loop to exit
child_logger.info("Map window process event loop exited.")
except Exception as e_map_proc_child_fatal_final:
child_logger.error(f"Fatal error in map viewer process target: {e_map_proc_child_fatal_final}", exc_info=True)
# MODIFIED: Send a fatal error message back to the GUI queue if an exception occurs in the main loop.
# WHY: The GUI process needs to know if the map process crashed unexpectedly.
# HOW: Put a specific error message in the queue before exiting.
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields
"elevation_str": f"Fatal Error: {type(e_map_proc_child_fatal_final).__name__}",
"map_area_size_str": "Fatal Error"}
try: map_interaction_q.put(error_payload)
except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
finally:
# MODIFIED: Ensure shutdown is called regardless of how the process loop exits.
# WHY: Cleans up OpenCV resources.
# HOW: Moved the shutdown call into the finally block.
if child_map_viewer_instance and hasattr(child_map_viewer_instance, 'shutdown'):
child_map_viewer_instance.shutdown()
child_logger.info("Terminated child map viewer process.")
# --- MULTIPROCESSING TARGET FUNCTIONS (MOVED TO process_targets.py) ---
# The actual functions are now imported from geoelevation.process_targets
class ElevationApp:
"""Main application class for the GeoElevation Tool GUI."""
@ -319,10 +128,10 @@ class ElevationApp:
# MODIFIED: Use the correctly imported GEOELEVATION_DEM_CACHE_DEFAULT constant
# WHY: This constant holds the path to the default DEM cache directory
# as configured in the config.py module and exposed by __init__.py.
# HOW: Replaced the hardcoded or previously mis-aliased variable name
# with the correct imported constant. The try...except block ensures
# a fallback is used if the import somehow fails.
# as configured in the.config.py module and exposed by __init__.py.
# HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed
# in the import statement itself, as the imported name is already the desired one. The existing try...except
# block correctly handles the case where this specific constant might not be available from the package.
default_dem_cache = GEOELEVATION_DEM_CACHE_DEFAULT
if self.elevation_manager is None:
@ -345,7 +154,8 @@ class ElevationApp:
self.last_valid_point_coords: Optional[Tuple[float, float]] = None
self.last_area_coords: Optional[Tuple[float, float, float, float]] = None
self.is_processing_task: bool = False
self.is_processing_task: bool = False # MODIFIED: Ensure this attribute is initialized here.
self.map_display_scale_factor_var: tk.DoubleVar = tk.DoubleVar(value=0.5)
self.scale_options_map: Dict[str, float] = {
@ -500,8 +310,7 @@ class ElevationApp:
# MODIFIED: Configured columns to stretch, important for Entry widgets
# WHY: Allows the Entry widgets to fill available horizontal space.
# HOW: Added columnconfigure calls.
map_info_frame.columnconfigure(1, weight=1) # Column for Decimal/Elevation entries
map_info_frame.columnconfigure(3, weight=1) # Column for DMS entries
map_info_frame.columnconfigure(1, weight=1) # Column for Entry widgets
# --- Map Info Widgets ---
# MODIFIED: Replace ttk.Label for displaying values with ttk.Entry (readonly)
@ -704,10 +513,10 @@ class ElevationApp:
# MODIFIED: Clear Map Info fields when a new Get Elevation task starts.
# WHY: Indicate that new information is being fetched.
# HOW: Set StringVar values to "..." or empty.
self.map_lat_decimal_var.set("...")
self.map_lat_decimal_var.set("...") # Indicate loading
self.map_lon_decimal_var.set("...")
self.map_lat_dms_var.set("...") # Clear DMS fields
self.map_lon_dms_var.set("...") # Clear DMS fields
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
self.map_elevation_var.set("...")
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
@ -715,9 +524,7 @@ class ElevationApp:
self.root.update_idletasks() # Force GUI update
# MODIFIED: Start the elevation retrieval in a separate thread.
# WHY: Prevent the GUI from freezing while waiting for network or disk I/O.
# HOW: Create and start a threading.Thread.
# Start elevation retrieval in a background thread
elev_thread = threading.Thread(
target=self._perform_background_get_elevation,
args=(lat, lon),
@ -726,9 +533,6 @@ class ElevationApp:
elev_thread.start()
# MODIFIED: Added a new background task function for point elevation retrieval.
# WHY: Encapsulates the potentially blocking operation to run in a separate thread.
# HOW: Created this method to call elevation_manager.get_elevation.
def _perform_background_get_elevation(self, latitude: float, longitude: float) -> None:
"""Background task to retrieve elevation."""
result_elevation: Optional[float] = None
@ -801,6 +605,8 @@ class ElevationApp:
self.map_lat_decimal_var.set(f"{original_latitude:.5f}") # Show input coords
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
# MODIFIED: Convert available coordinates to DMS even if elevation failed, if the coords are valid.
# WHY: Show DMS coordinates if the input was valid, even if data wasn't available.
# HOW: Use deg_to_dms_string.
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
self.map_elevation_var.set(f"Error: {type(exception_occurred).__name__}")
@ -904,19 +710,18 @@ class ElevationApp:
# MODIFIED: Clear Map Info fields when a new Download Area task starts.
# WHY: Indicate that the context has shifted to an area task.
# HOW: Set StringVar values to "..." or empty.
self.map_lat_decimal_var.set("N/A") # Point info doesn't apply
self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view
self.map_lon_decimal_var.set("N/A")
self.map_lat_dms_var.set("N/A")
self.map_lon_dms_var.set("N/A")
self.map_elevation_var.set("N/A")
self.map_lat_dms_var.set("N/A") # Point info not relevant for area view
self.map_lon_dms_var.set("N/A") # Point info not relevant for area view
self.map_elevation_var.set("N/A") # Point info not relevant for area view
self.map_area_size_var.set("...") # Show progress for area size info related to map
self.root.update_idletasks() # Force GUI update
# Start download in a background thread to keep GUI responsive
# Pass bounds as separate arguments to avoid potential issues with tuple packing/unpacking in target
# Start download in a background thread
dl_thread = threading.Thread(target=self._perform_background_area_download, args=bounds, daemon=True)
dl_thread.start()
@ -1002,9 +807,35 @@ class ElevationApp:
browse_img_path = info["browse_image_path"]
tile_name = info.get("tile_base_name","?")
window_title = f"Browse: {tile_name.upper()}"
# MODIFIED: Get geographic bounds for the single tile.
# WHY: Needed to pass as extent to Matplotlib for correct aspect ratio.
# HOW: Use get_hgt_tile_geographic_bounds and format as [west, east, south, north].
tile_geo_bounds = None
if info.get("hgt_available"): # Get bounds only if HGT is available (lat/lon coords should be in info)
tile_lat_coord = info.get("latitude_coord")
tile_lon_coord = info.get("longitude_coord")
if tile_lat_coord is not None and tile_lon_coord is not None:
bounds_tuple = get_hgt_tile_geographic_bounds(tile_lat_coord, tile_lon_coord)
if bounds_tuple:
# Format as [left, right, bottom, top] which is [west, east, south, north]
tile_geo_bounds = [bounds_tuple[0], bounds_tuple[2], bounds_tuple[1], bounds_tuple[3]]
logger.debug(f"Passing geographic extent {tile_geo_bounds} for browse image.")
else:
logger.warning("Could not get precise geographic bounds for single tile, aspect ratio might be incorrect.")
else:
logger.warning("Tile coordinates missing in info, cannot get precise geographic bounds for browse image aspect ratio.")
else:
logger.warning("HGT not available for tile, cannot get precise geographic bounds for browse image aspect ratio.")
# Proceed without extent, image will be displayed assuming square pixels.
# The process target will load and display the image from the path
args = (browse_img_path, tile_name, window_title)
self._start_visualization_process(process_target_show_image, args, "2DBrowse")
# MODIFIED: Pass the extent to the process target.
# WHY: So the target function can pass it to show_image_matplotlib.
# HOW: Added extent to the args tuple.
args = (browse_img_path, tile_name, window_title, tile_geo_bounds)
self._start_visualization_process(process_targets.process_target_show_image, args, "2DBrowse") # Call imported target
else:
logger.warning(f"GUI: Browse image not available for ({lat:.5f},{lon:.5f}).")
messagebox.showinfo("Image Info", "Browse image not available for this tile.", parent=self.root)
@ -1048,7 +879,7 @@ class ElevationApp:
# The process target will handle data processing (subsampling, smoothing, interpolation) and plotting.
args = (data, plot_title, config_initial_subsample, config_smooth_sigma,
config_interpolation_factor, config_plot_grid_points)
self._start_visualization_process(process_target_show_3d, args, "3DDEM")
self._start_visualization_process(process_targets.process_target_show_3d, args, "3DDEM") # Call imported target
else:
logger.warning(f"GUI: HGT data not available for ({lat:.5f},{lon:.5f}). Cannot show 3D plot.")
messagebox.showerror("3D Data Error", "Could not retrieve HGT data for this tile.", parent=self.root)
@ -1076,19 +907,33 @@ class ElevationApp:
logger.warning("GUI: No browse images found for the downloaded area.")
messagebox.showinfo("Area Info", "No browse images were found locally for the specified area.", parent=self.root); return
# MODIFIED: Calculate combined geographic bounds for all relevant tiles in the area.
# WHY: Needed to pass as extent to Matplotlib for correct aspect ratio of the composite.
# HOW: Use get_combined_geographic_bounds_from_tile_info_list on the full info_list.
combined_geo_bounds = get_combined_geographic_bounds_from_tile_info_list(info_list)
composite_extent = None
if combined_geo_bounds:
# Format as [left, right, bottom, top] which is [west, east, south, north]
composite_extent = [combined_geo_bounds[0], combined_geo_bounds[2], combined_geo_bounds[1], combined_geo_bounds[3]]
logger.debug(f"Passing combined geographic extent {composite_extent} for area composite.")
else:
logger.warning("Could not get combined geographic bounds for area composite, aspect ratio might be incorrect.")
# Proceed without extent.
title = f"Area: Lat [{min_l:.1f}-{max_l:.1f}], Lon [{min_o:.1f}-{max_o:.1f}]"
# Pass the filtered list of available info dicts to the process
# The process target will create the composite image from these.
args = (available_browse_images, title)
self._start_visualization_process(process_target_create_show_area, args, "AreaComposite")
# MODIFIED: Pass the combined extent to the process target.
# WHY: So the target function can pass it to show_image_matplotlib.
# HOW: Added extent to the args tuple.
args = (available_browse_images, title, composite_extent)
self._start_visualization_process(process_targets.process_target_create_show_area, args, "AreaComposite") # Call imported target
def _start_map_viewer_process(self, base_args_tuple: tuple) -> None:
"""Helper to start the map viewer process."""
if not MAP_VIEWER_SYSTEM_AVAILABLE:
messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root)
logger.error("GUI: Map viewer process start failed: MAP_VIEWER_SYSTEM_AVAILABLE is False.")
return
messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root); return
if not self.map_interaction_message_queue:
logger.error("GUI: Cannot start map viewer: interaction queue not initialized.");
@ -1113,7 +958,7 @@ class ElevationApp:
full_args = base_args_tuple + (dem_cache_dir, scale_val)
self.map_viewer_process_handle = multiprocessing.Process(
target=run_map_viewer_process_target,
target=process_targets.run_map_viewer_process_target, # Call imported target
args=full_args,
daemon=True, # Daemon process will be terminated when the main GUI process exits
name="GeoElevationMapViewer"
@ -1151,7 +996,7 @@ class ElevationApp:
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
self.map_elevation_var.set("...")
self.map_area_size_var.set("Loading...")
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
# Prepare base arguments for the map viewer process target function.
@ -1370,6 +1215,18 @@ if __name__ == "__main__":
print(f"Test DMS conversion for Lat {test_lat_neg}: {deg_to_dms_string(test_lat_neg, 'lat')}")
print(f"Test DMS conversion for Lon {test_lon_pos}: {deg_to_dms_string(test_lon_pos, 'lon')}")
# MODIFIED: Example of how to use the new combined bounds function in test/debug.
# WHY: Demonstrate usage of the new function.
# HOW: Added test data and print statements.
test_tile_list_info = [
{'latitude_coord': 44, 'longitude_coord': 7, 'tile_base_name': 'n44e007', 'hgt_file_path': 'path/to/n44e007.hgt', 'browse_image_path': 'path/to/n44e007.jpg', 'hgt_available': True, 'browse_available': True},
{'latitude_coord': 45, 'longitude_coord': 7, 'tile_base_name': 'n45e007', 'hgt_file_path': 'path/to/n45e007.hgt', 'browse_image_path': 'path/to/n45e007.jpg', 'hgt_available': True, 'browse_available': True},
{'latitude_coord': 44, 'longitude_coord': 8, 'tile_base_name': 'n44e008', 'hgt_file_path': 'path/to/n44e008.hgt', 'browse_image_path': 'path/to/n44e008.jpg', 'hgt_available': True, 'browse_available': True},
{'latitude_coord': 45, 'longitude_coord': 8, 'tile_base_name': 'n45e008', 'hgt_file_path': 'path/to/n45e008.hgt', 'browse_image_path': 'path/to/n45e008.jpg', 'hgt_available': True, 'browse_available': True},
]
combined_bounds = get_combined_geographic_bounds_from_tile_info_list(test_tile_list_info)
print(f"Test Combined Bounds: {combined_bounds}")
test_root = tk.Tk()
app_test_instance: Optional[ElevationApp] = None

View File

@ -10,7 +10,7 @@ and sizes, finding necessary map tile ranges to cover an area using the
# Standard library imports
import logging
import math
from typing import Tuple, Optional, List, Set # Ensure all used types are imported
from typing import Tuple, Optional, List, Set, Dict # Ensure all used types are imported
# Third-party imports
try:
@ -582,3 +582,74 @@ def deg_to_dms_string(degree_value: float, coord_type: str) -> str:
return dms_string
# MODIFIED: Added new utility function to calculate the combined geographic bounds from a list of tile infos.
# WHY: Needed for calculating the overall geographic extent of a group of DEM tiles for map display aspect ratio.
# HOW: Iterate through the list, get bounds for each tile, and find the min/max lat/lon.
def get_combined_geographic_bounds_from_tile_info_list(tile_info_list: List[Dict]) -> Optional[Tuple[float, float, float, float]]:
"""
Calculates the minimum bounding box that encompasses all tiles in the provided list.
Args:
tile_info_list: A list of tile information dictionaries (e.g., from ElevationManager.get_area_tile_info).
Each dict must contain 'latitude_coord' and 'longitude_coord'.
Returns:
A tuple (west_lon, south_lat, east_lon, north_lat) encompassing all tiles,
or None if the list is empty or invalid info is found.
"""
if not tile_info_list:
logger.warning("Tile info list is empty, cannot calculate combined geographic bounds.")
return None
min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined = 180.0, 90.0, -180.0, -90.0
initialized = False
for tile_info in tile_info_list:
lat_coord = tile_info.get("latitude_coord")
lon_coord = tile_info.get("longitude_longitude") # Typo in original code, should be "longitude_coord"
# MODIFIED: Corrected typo in key name.
# WHY: To correctly access the longitude coordinate from the dictionary.
# HOW: Changed "longitude_longitude" to "longitude_coord".
lon_coord = tile_info.get("longitude_coord")
if lat_coord is None or lon_coord is None:
logger.warning(f"Skipping tile info entry due to missing coordinates: {tile_info}")
continue # Skip this entry if coordinates are missing
try:
# Get the precise geographic bounds for this HGT tile
tile_bounds = get_hgt_tile_geographic_bounds(lat_coord, lon_coord)
if not initialized:
min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined = tile_bounds
initialized = True
else:
min_lon_combined = min(min_lon_combined, tile_bounds[0])
min_lat_combined = min(min_lat_combined, tile_bounds[1])
max_lon_combined = max(max_lon_combined, tile_bounds[2])
max_lat_combined = max(max_lat_combined, tile_bounds[3])
except Exception as e_get_tile_bounds:
logger.warning(f"Error getting geographic bounds for tile ({lat_coord},{lon_coord}): {e_get_tile_bounds}. Skipping this tile.")
continue # Skip tile with invalid bounds
if not initialized:
logger.warning("No valid tile coordinates found in the list to calculate combined bounds.")
return None # No valid tiles processed
# Final validation of combined bounds (e.g., if it spans the whole globe)
# The bounds from HGT tiles are 1x1 degree, so combining shouldn't create invalid wrap-around issues easily,
# but defensive check.
if min_lat_combined > max_lat_combined or min_lon_combined > max_lon_combined:
# This might indicate an issue if the area spans the antimeridian widely and min/max logic breaks.
# For HGT tiles, this should be fine, but if input list had weird coords, this could happen.
# Returning None might be safer if the bbox is clearly invalid.
logger.warning(f"Calculated invalid combined bounds: W={min_lon_combined}, S={min_lat_combined}, E={max_lon_combined}, N={max_lat_combined}. Returning None.")
return None
logger.debug(f"Calculated combined geographic bounds: ({min_lon_combined:.6f}, {min_lat_combined:.6f}, {max_lon_combined:.6f}, {max_lat_combined:.6f})")
return (min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined)

View File

@ -0,0 +1,362 @@
# geoelevation/process_targets.py
"""
Contains multiprocessing target functions executed in separate processes.
These functions perform visualization tasks (2D/3D plots, map viewer).
"""
# Standard library imports needed at the top level for process entry points
import multiprocessing
import sys
import logging
from typing import Optional, Tuple, List, Dict, Any, TYPE_CHECKING # Needed for type hinting
# Local application/package imports that the target functions will use.
# Imports needed *within* the target functions should be handled carefully
# (e.g., within try blocks or using local imports) as the child process
# might have a different import context or dependencies.
# Configure logging for the process target module itself.
# Note: The run_map_viewer_process_target explicitly configures logging,
# but other process targets might not, so a basic config here is safer.
# Using NullHandler by default, similar to how libraries are configured,
# letting the *actual process entry point* decide on the final handlers.
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
logger.addHandler(logging.NullHandler())
# The run_map_viewer_process_target function will configure a StreamHandler.
# Define the multiprocessing target functions
def process_target_show_image(image_path: str, tile_name: str, window_title: str, extent: Optional[List[float]] = None) -> None:
"""
Multiprocessing target function to load and display a 2D image.
Expected to be run in a separate process.
"""
try:
# Local imports needed by this target function
from geoelevation.image_processor import load_prepare_single_browse as local_lpst
from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok
from geoelevation.visualizer import show_image_matplotlib as local_sim
from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok
import os as local_os
# Basic check for critical libraries within the child process
if not (local_pil_ok and local_mpl_ok):
# Use print for crucial messages in process targets, as logging might not be fully set up yet.
print("PROCESS ERROR (show_image): Pillow/Matplotlib missing in child process. Cannot display image.")
return
# Load and prepare the image (adds overlay)
prepared_image = local_lpst(image_path, tile_name)
if prepared_image:
# Display the image using Matplotlib
print(f"PROCESS (show_image): Showing '{window_title}' (Extent: {'Yes' if extent is not None else 'No'})...")
local_sim(prepared_image, window_title, extent=extent) # Pass the extent
# Explicitly close matplotlib figures to free memory in the child process
# Not strictly necessary as process exits, but good practice.
try:
import matplotlib.pyplot as local_plt # Import pyplot locally for closing
local_plt.close('all')
# print("PROCESS (show_image): Closed Matplotlib figures.")
except Exception:
pass # Ignore errors if matplotlib closing fails
else:
print(f"PROCESS ERROR (show_image): Could not prepare image from {local_os.path.basename(image_path)}")
except Exception as e_proc_img:
# Log/print unexpected errors with traceback
print(f"PROCESS ERROR in process_target_show_image: {e_proc_img}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# The process will exit when this function returns.
def process_target_show_3d(
hgt_data: Optional["np_typing.ndarray"],
plot_title: str,
initial_subsample: int,
smooth_sigma: Optional[float],
interpolation_factor: int,
plot_grid_points: int
) -> None:
"""
Multiprocessing target function to display a 3D elevation plot.
Expected to be run in a separate process.
"""
try:
# Local imports needed by this target function
from geoelevation.visualizer import show_3d_matplotlib as local_s3d
from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok
from geoelevation.visualizer import SCIPY_AVAILABLE as local_scipy_ok
import numpy as local_np # Import numpy locally if not already available
# Basic check for critical libraries
if not local_mpl_ok:
print("PROCESS ERROR (show_3d): Matplotlib missing in child process. Cannot display 3D plot.")
return
effective_smooth_sigma = smooth_sigma
effective_interpolation_factor = interpolation_factor
# Check SciPy availability for advanced features
if (interpolation_factor > 1 or smooth_sigma is not None) and not local_scipy_ok:
print("PROCESS WARNING (show_3d): SciPy missing in child. Disabling 3D plot smoothing/interpolation features.")
effective_smooth_sigma = None
effective_interpolation_factor = 1
if hgt_data is not None and isinstance(hgt_data, local_np.ndarray): # Ensure data is a numpy array
# Perform the 3D visualization
print(f"PROCESS (show_3d): Plotting '{plot_title}' (InitialSub:{initial_subsample}, Smooth:{effective_smooth_sigma}, Interp:{effective_interpolation_factor}x, PlotGridTarget:{plot_grid_points})...")
local_s3d(
hgt_data,
plot_title,
initial_subsample,
effective_smooth_sigma,
effective_interpolation_factor,
plot_grid_points
)
# Explicitly close matplotlib figures
try:
import matplotlib.pyplot as local_plt # Import pyplot locally for closing
local_plt.close('all')
# print("PROCESS (show_3d): Closed Matplotlib figures.")
except Exception:
pass # Ignore errors if matplotlib closing fails
else:
print("PROCESS ERROR (show_3d): No valid HGT data array received for plotting.")
except Exception as e_proc_3d:
# Log/print unexpected errors with traceback
print(f"PROCESS ERROR in process_target_show_3d: {e_proc_3d}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# The process will exit when this function returns.
def process_target_create_show_area(tile_info_list_data: List[Dict], window_title_str: str, extent: Optional[List[float]] = None) -> None:
"""
Multiprocessing target function to create a composite image for an area
and display it. Expected to be run in a separate process.
"""
try:
# Local imports needed by this target function
from geoelevation.image_processor import create_composite_area_image as local_ccai
from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok
from geoelevation.visualizer import show_image_matplotlib as local_sim
from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok
# Basic check for critical libraries
if not (local_pil_ok and local_mpl_ok):
print("PROCESS ERROR (show_area): Pillow/Matplotlib libraries are missing in the child process. Cannot create/display composite.")
return
print("PROCESS (show_area): Creating composite area image...")
composite_pil_image = local_ccai(tile_info_list_data)
if composite_pil_image:
# Display the composite image
print(f"PROCESS (show_area): Displaying composite image '{window_title_str}' (Extent: {'Yes' if extent is not None else 'No'})...")
local_sim(composite_pil_image, window_title_str, extent=extent) # Pass the extent
# Explicitly close matplotlib figures
try:
import matplotlib.pyplot as local_plt # Import pyplot locally for closing
local_plt.close('all')
# print("PROCESS (show_area): Closed Matplotlib figures.")
except Exception:
pass # Ignore errors if matplotlib closing fails
else:
print("PROCESS ERROR (show_area): Failed to create composite area image.")
except Exception as e_proc_area:
# Log/print unexpected errors with traceback
print(f"PROCESS ERROR in process_target_create_show_area: {e_proc_area}", file=sys.stderr)
import traceback
traceback.print_exc(file=sys.stderr)
# The process will exit when this function returns.
def run_map_viewer_process_target(
map_interaction_q: multiprocessing.Queue,
operation_mode: str,
center_latitude: Optional[float],
center_longitude: Optional[float], # Corrected type hint, Optional[Optional[float]] is redundant
area_bounding_box: Optional[Tuple[float, float, float, float]],
dem_data_cache_dir: str,
display_scale_factor: float
) -> None:
"""
Multiprocessing target function to run the interactive map viewer.
Expected to be run in a separate process.
"""
# --- Initial Process Setup and Logging ---
# Configure logging for the child process explicitly.
# This ensures its logs are visible and don't interfere with a parent's logging setup.
child_logger = logging.getLogger("GeoElevationMapViewerChildProcess")
# Check if handlers already exist (e.g., from basicConfig in __main__.py).
# If not, set up basic stream handling.
if not child_logger.hasHandlers():
# Use basicConfig if no handlers exist in this process's logger or its ancestors.
# Using basicConfig might affect the root logger if no other configuration exists globally in this process.
# A safer approach if deeper control is needed might be to manually add handlers.
# For a dedicated process target, basicConfig is usually fine.
logging.basicConfig(
level=logging.INFO, # Default level for this process, can be adjusted
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
stream=sys.stdout # Send output to stdout, which is captured by parent process's console.
)
# Ensure the 'geoelevation' package logger in this process has a handler
# if its level is below the root default.
geoelevation_proc_logger = logging.getLogger("geoelevation")
if not geoelevation_proc_logger.hasHandlers():
geoelevation_proc_logger.addHandler(logging.StreamHandler(sys.stdout)) # Add a stream handler
child_logger.info("Map viewer process started.")
# --- Import Critical Libraries for Map Viewer Process ---
child_map_viewer_instance: Optional[Any] = None
critical_libs_available = False
try:
# Local imports needed by this target function
from geoelevation.map_viewer.geo_map_viewer import GeoElevationMapViewer as ChildGeoMapViewer
from geoelevation.elevation_manager import ElevationManager as ChildElevationManager
import cv2 as child_cv2 # Ensure cv2 is available in the child process
# Also check other libraries that geo_map_viewer depends on and checks internally
# If GeoElevationMapViewer import succeeds, it means PIL and Mercantile were likely available,
# as GeoElevationMapViewer's __init__ raises ImportError if they're missing.
# We still need cv2 and numpy for the loop itself.
import numpy as child_np # Ensure numpy is available
critical_libs_available = True
except ImportError as e_child_imp_map:
child_logger.critical(f"CRITICAL: Map viewer components or essential libraries not found in child process: {e_child_imp_map}", exc_info=True)
# Send an error message back to the GUI queue if critical imports fail in child process.
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"latitude_dms_str": "Error", "longitude_dms_str": "Error",
"elevation_str": f"Fatal Error: {type(e_child_imp_map).__name__} (Import)",
"map_area_size_str": "Map System N/A"}
try: map_interaction_q.put(error_payload)
except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
return # Exit if critical libraries are missing
# --- Initialize Map Viewer and Run Main Loop ---
try:
child_logger.info(f"Initializing GeoElevationMapViewer instance for mode '{operation_mode}', display scale: {display_scale_factor:.2f}...")
# Initialize ElevationManager and GeoElevationMapViewer within the child process
# Each process needs its own instance, but they share the filesystem cache.
local_em = ChildElevationManager(tile_directory=dem_data_cache_dir)
child_map_viewer_instance = ChildGeoMapViewer(
elevation_manager_instance=local_em,
gui_output_communication_queue=map_interaction_q,
initial_display_scale=display_scale_factor
)
child_logger.info("Child GeoElevationMapViewer instance initialized.")
# Call the appropriate display method based on the requested operation mode
if operation_mode == "point" and center_latitude is not None and center_longitude is not None:
child_logger.info(f"Calling display_map_for_point for ({center_latitude:.5f},{center_longitude:.5f}).")
child_map_viewer_instance.display_map_for_point(center_latitude, center_longitude)
elif operation_mode == "area" and area_bounding_box:
child_logger.info(f"Calling display_map_for_area for BBox {area_bounding_box}.")
child_map_viewer_instance.display_map_for_area(area_bounding_box)
else:
child_logger.error(f"Invalid operation mode ('{operation_mode}') or missing parameters passed to map process target.")
# Send error message to GUI
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"latitude_dms_str": "Error", "longitude_dms_str": "Error",
"elevation_str": f"Fatal Error: Invalid Map Args",
"map_area_size_str": "Invalid Args"}
try: map_interaction_q.put(error_payload)
except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue: {e_put_err}")
# No need to call shutdown here, as instance might not be fully initialized.
return # Exit on invalid parameters
child_logger.info("Initial map display call complete. Entering OpenCV event loop.")
# Main loop to keep the OpenCV window alive and process events.
# cv2.waitKey() is essential for processing window events and mouse callbacks.
# A non-zero argument (e.g., 100ms) makes it wait for a key press for that duration,
# but more importantly, it allows OpenCV to process the window's message queue.
# Without regular calls to waitKey, window updates and callbacks won't happen.
is_map_active = True
while is_map_active:
# Pass a short delay to yield CPU time
key = child_cv2.waitKey(100) # Process events every 100ms
# Check if the map window is still alive.
# The MapDisplayWindow instance holds the OpenCV window name and can check its property.
if child_map_viewer_instance.map_display_window_controller:
if child_map_viewer_instance.map_display_window_controller.is_window_alive():
# Check for specific key presses (like 'q' or Escape) to allow closing the window via keyboard
if key != -1: # A key was pressed
# Convert key code if needed and check for close commands
try:
# Check for 'q' or 'Q' keys (example)
if chr(key & 0xFF) in ('q', 'Q'):
child_logger.info("Map window closing due to 'q'/'Q' key press.")
is_map_active = False # Signal loop to exit
# Check for Escape key (common window close shortcut)
if key == 27:
child_logger.info("Map window closing due to Escape key press.")
is_map_active = False # Signal loop to exit
except ValueError: # Ignore non-printable keys
pass # Do nothing for non-printable keys
else:
# is_window_alive() returned False, meaning the window was closed (e.g., by user clicking the 'X').
child_logger.info("Map window detected as closed.")
is_map_active = False # Signal loop to exit
else:
# The controller instance is None, indicating an issue or shutdown signal.
child_logger.info("MapDisplayWindow controller is None. Exiting map process loop.")
is_map_active = False # Signal loop to exit
child_logger.info("OpenCV event loop finished. Map viewer process target returning.")
except Exception as e_map_proc_fatal:
# Catch any unhandled exceptions during the map viewer's operation loop.
child_logger.critical(f"FATAL: Unhandled exception in map viewer process: {e_map_proc_fatal}", exc_info=True)
# Send a fatal error message back to the GUI.
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"latitude_dms_str": "Error", "longitude_dms_str": "Error",
"elevation_str": f"Fatal Error: {type(e_map_proc_fatal).__name__}",
"map_area_size_str": "Fatal Error"}
try: map_interaction_q.put(error_payload)
except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
finally:
# Ensure cleanup is performed regardless of how the function exits (success, planned loop exit, exception).
child_logger.info("Map viewer process target finally block reached. Performing cleanup.")
if child_map_viewer_instance and hasattr(child_map_viewer_instance, 'shutdown'):
child_map_viewer_instance.shutdown() # Call the instance's shutdown method
child_logger.info("Child map viewer instance shutdown complete.")
# cv2.destroyAllWindows() is often called here in simple examples, but destroying the specific window
# via the instance's shutdown method is usually better practice if multiple OpenCV windows might exist.
# Let's add a safety destroyAll just in case, although the instance shutdown should handle it.
try:
if 'child_cv2' in locals() and child_cv2 is not None:
child_cv2.destroyAllWindows()
child_logger.debug("Called cv2.destroyAllWindows() in child process.")
except Exception as e_destroy_all:
child_logger.debug(f"Error calling cv2.destroyAllWindows() in child process: {e_destroy_all}")
child_logger.info("Map viewer process target finished.")
# The process automatically terminates when this function returns.

View File

@ -2,7 +2,7 @@
import logging
import os
from typing import Optional, Union, TYPE_CHECKING
from typing import Optional, Union, TYPE_CHECKING, List # Import List for extent type hint
import time # For benchmarking processing time
# --- Dependency Checks ---
@ -115,11 +115,20 @@ if TYPE_CHECKING:
def show_image_matplotlib(
image_source: Union[str, "np_typing.ndarray", "PILImage_typing.Image"],
title: str = "Image Preview"
title: str = "Image Preview",
extent: Optional[List[float]] = None # MODIFIED: Added optional extent parameter
):
"""
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.
Optionally applies a geographic extent for correct aspect ratio display.
Args:
image_source (Union[str, np.ndarray, PIL.Image.Image]): The image data or path.
title (str): The title for the plot window.
extent (Optional[List[float]]): A list [left, right, bottom, top] in geographic
coordinates. If provided, Matplotlib will use this
for plot limits and aspect ratio.
"""
if not MATPLOTLIB_AVAILABLE:
logging.error("Cannot display image: Matplotlib is not available.")
@ -127,7 +136,11 @@ def show_image_matplotlib(
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}")
# MODIFIED: Added log info about whether extent is provided.
# WHY: Useful for debugging.
# HOW: Check if extent is None in the log message.
logging.info(f"Attempting to display image '{title}' from source type: {source_type}. Extent provided: {'Yes' if extent is not None else 'No'}")
try:
if isinstance(image_source, str):
@ -145,6 +158,9 @@ def show_image_matplotlib(
return
elif isinstance(image_source, np.ndarray):
img_display_np = image_source.copy()
# MODIFIED: Added check for PIL_AVAILABLE_VIS before isinstance check against Image.Image.
# WHY: Avoids NameError if Image is the dummy class.
# HOW: Added the boolean check.
elif PIL_AVAILABLE_VIS and isinstance(image_source, Image.Image): # type: ignore
img_display_np = np.array(image_source)
else:
@ -156,9 +172,26 @@ def show_image_matplotlib(
return
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(img_display_np)
# MODIFIED: Pass the extent parameter to imshow.
# WHY: Allows Matplotlib to display the image with the correct geographic aspect ratio.
# HOW: Added extent=extent argument.
ax.imshow(img_display_np, extent=extent)
ax.set_title(title)
ax.axis('off')
# MODIFIED: Only turn off axis if extent is NOT provided.
# WHY: If extent is provided, the axes represent geographic coordinates and should usually be visible.
# HOW: Added conditional check.
if extent is None:
ax.axis('off') # Turn off axes for simple image display
else:
ax.set_xlabel("Longitude") # Label axes with geographic meaning
ax.set_ylabel("Latitude")
ax.set_aspect('auto', adjustable='box') # Let Matplotlib adjust aspect based on extent and figure size
# It might be better to use 'equal' if we strictly want geographic square pixels,
# but 'auto' often gives a better fit within the figure while respecting the extent.
# Let's stick with default 'auto' when extent is provided, which implicitly
# respects the aspect ratio defined by the extent if adjustable='box'.
plt.show()
logging.debug(f"Plot window for '{title}' closed.")
except Exception as e: