From 46202a2829d63269af98da167fcebb4a5b94f066 Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 14 May 2025 09:31:53 +0200 Subject: [PATCH] fix rectangular view for dem files --- geoelevation/elevation_gui.py | 389 +++++++++------------------ geoelevation/map_viewer/map_utils.py | 75 +++++- geoelevation/process_targets.py | 362 +++++++++++++++++++++++++ geoelevation/visualizer.py | 43 ++- 4 files changed, 596 insertions(+), 273 deletions(-) create mode 100644 geoelevation/process_targets.py diff --git a/geoelevation/elevation_gui.py b/geoelevation/elevation_gui.py index 5a9de77..98c9f63 100644 --- a/geoelevation/elevation_gui.py +++ b/geoelevation/elevation_gui.py @@ -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 diff --git a/geoelevation/map_viewer/map_utils.py b/geoelevation/map_viewer/map_utils.py index 5e638ea..e6b9013 100644 --- a/geoelevation/map_viewer/map_utils.py +++ b/geoelevation/map_viewer/map_utils.py @@ -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: @@ -581,4 +581,75 @@ def deg_to_dms_string(degree_value: float, coord_type: str) -> str: dms_string += f" {suffix}" - return dms_string \ No newline at end of file + 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) \ No newline at end of file diff --git a/geoelevation/process_targets.py b/geoelevation/process_targets.py new file mode 100644 index 0000000..cb0b78f --- /dev/null +++ b/geoelevation/process_targets.py @@ -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. \ No newline at end of file diff --git a/geoelevation/visualizer.py b/geoelevation/visualizer.py index 4c75c4c..644bb7f 100644 --- a/geoelevation/visualizer.py +++ b/geoelevation/visualizer.py @@ -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: