fix rectangular view for dem files
This commit is contained in:
parent
3a5a3db6ad
commit
46202a2829
@ -29,9 +29,8 @@ from geoelevation.visualizer import MATPLOTLIB_AVAILABLE
|
|||||||
from geoelevation.visualizer import SCIPY_AVAILABLE
|
from geoelevation.visualizer import SCIPY_AVAILABLE
|
||||||
|
|
||||||
# MODIFIED: Corrected import of default cache directory constant from the geoelevation package's public API
|
# 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
|
# WHY: This constant holds the path to the default DEM cache directory
|
||||||
# by geoelevation/__init__.py. The correct name exposed in __init__.py (and sourced from config.py)
|
# as configured in the.config.py module and exposed by __init__.py.
|
||||||
# is GEOELEVATION_DEM_CACHE_DEFAULT. This change aligns the import with the package's defined public API.
|
|
||||||
# HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed
|
# 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
|
# 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.
|
# 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.
|
# 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.
|
# HOW: Added the import statement from the map_viewer.map_utils sub-module.
|
||||||
from geoelevation.map_viewer.map_utils import deg_to_dms_string
|
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:
|
except ImportError:
|
||||||
# This fallback is if the main __init__.py itself has issues exporting the constant,
|
# 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.
|
# which shouldn't happen if __init__.py and config.py are correct.
|
||||||
GEOELEVATION_DEM_CACHE_DEFAULT = "elevation_data_cache_gui_fallback_critical"
|
GEOELEVATION_DEM_CACHE_DEFAULT = "elevation_data_cache_gui_fallback_critical"
|
||||||
# MODIFIED: Define a dummy deg_to_dms_string function if import fails.
|
# MODIFIED: Define dummy deg_to_dms_string and bounds functions if import fails.
|
||||||
# WHY: Avoid NameError if the import fails critically.
|
# WHY: Avoid NameError if the imports fail critically.
|
||||||
# HOW: Define a simple function that returns "Import Error".
|
# HOW: Define simple functions that return default/error values.
|
||||||
def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore
|
def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore
|
||||||
return "Import Error"
|
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(
|
logging.getLogger(__name__).critical(
|
||||||
"elevation_gui.py: CRITICAL - Could not import GEOELEVATION_DEM_CACHE_DEFAULT or deg_to_dms_string from geoelevation package. "
|
f"elevation_gui.py: CRITICAL - Could not import core dependencies from geoelevation package. "
|
||||||
f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. DMS conversion unavailable."
|
f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. Some features unavailable."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -72,232 +105,8 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# --- MULTIPROCESSING TARGET FUNCTIONS ---
|
# --- MULTIPROCESSING TARGET FUNCTIONS (MOVED TO process_targets.py) ---
|
||||||
# (Queste funzioni rimangono identiche alla versione precedente completa che ti ho fornito)
|
# The actual functions are now imported from geoelevation.process_targets
|
||||||
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.")
|
|
||||||
|
|
||||||
|
|
||||||
class ElevationApp:
|
class ElevationApp:
|
||||||
"""Main application class for the GeoElevation Tool GUI."""
|
"""Main application class for the GeoElevation Tool GUI."""
|
||||||
@ -319,10 +128,10 @@ class ElevationApp:
|
|||||||
|
|
||||||
# MODIFIED: Use the correctly imported GEOELEVATION_DEM_CACHE_DEFAULT constant
|
# MODIFIED: Use the correctly imported GEOELEVATION_DEM_CACHE_DEFAULT constant
|
||||||
# WHY: This constant holds the path to the default DEM cache directory
|
# WHY: This constant holds the path to the default DEM cache directory
|
||||||
# as configured in the config.py module and exposed by __init__.py.
|
# as configured in the.config.py module and exposed by __init__.py.
|
||||||
# HOW: Replaced the hardcoded or previously mis-aliased variable name
|
# HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed
|
||||||
# with the correct imported constant. The try...except block ensures
|
# in the import statement itself, as the imported name is already the desired one. The existing try...except
|
||||||
# a fallback is used if the import somehow fails.
|
# block correctly handles the case where this specific constant might not be available from the package.
|
||||||
default_dem_cache = GEOELEVATION_DEM_CACHE_DEFAULT
|
default_dem_cache = GEOELEVATION_DEM_CACHE_DEFAULT
|
||||||
|
|
||||||
if self.elevation_manager is None:
|
if self.elevation_manager is None:
|
||||||
@ -345,7 +154,8 @@ class ElevationApp:
|
|||||||
|
|
||||||
self.last_valid_point_coords: Optional[Tuple[float, float]] = None
|
self.last_valid_point_coords: Optional[Tuple[float, float]] = None
|
||||||
self.last_area_coords: Optional[Tuple[float, float, 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.map_display_scale_factor_var: tk.DoubleVar = tk.DoubleVar(value=0.5)
|
||||||
self.scale_options_map: Dict[str, float] = {
|
self.scale_options_map: Dict[str, float] = {
|
||||||
@ -500,8 +310,7 @@ class ElevationApp:
|
|||||||
# MODIFIED: Configured columns to stretch, important for Entry widgets
|
# MODIFIED: Configured columns to stretch, important for Entry widgets
|
||||||
# WHY: Allows the Entry widgets to fill available horizontal space.
|
# WHY: Allows the Entry widgets to fill available horizontal space.
|
||||||
# HOW: Added columnconfigure calls.
|
# HOW: Added columnconfigure calls.
|
||||||
map_info_frame.columnconfigure(1, weight=1) # Column for Decimal/Elevation entries
|
map_info_frame.columnconfigure(1, weight=1) # Column for Entry widgets
|
||||||
map_info_frame.columnconfigure(3, weight=1) # Column for DMS entries
|
|
||||||
|
|
||||||
# --- Map Info Widgets ---
|
# --- Map Info Widgets ---
|
||||||
# MODIFIED: Replace ttk.Label for displaying values with ttk.Entry (readonly)
|
# 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.
|
# MODIFIED: Clear Map Info fields when a new Get Elevation task starts.
|
||||||
# WHY: Indicate that new information is being fetched.
|
# WHY: Indicate that new information is being fetched.
|
||||||
# HOW: Set StringVar values to "..." or empty.
|
# 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_lon_decimal_var.set("...")
|
||||||
self.map_lat_dms_var.set("...") # Clear DMS fields
|
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
|
||||||
self.map_lon_dms_var.set("...") # Clear DMS fields
|
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
|
||||||
self.map_elevation_var.set("...")
|
self.map_elevation_var.set("...")
|
||||||
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
|
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
|
self.root.update_idletasks() # Force GUI update
|
||||||
|
|
||||||
|
|
||||||
# MODIFIED: Start the elevation retrieval in a separate thread.
|
# Start elevation retrieval in a background thread
|
||||||
# WHY: Prevent the GUI from freezing while waiting for network or disk I/O.
|
|
||||||
# HOW: Create and start a threading.Thread.
|
|
||||||
elev_thread = threading.Thread(
|
elev_thread = threading.Thread(
|
||||||
target=self._perform_background_get_elevation,
|
target=self._perform_background_get_elevation,
|
||||||
args=(lat, lon),
|
args=(lat, lon),
|
||||||
@ -726,9 +533,6 @@ class ElevationApp:
|
|||||||
elev_thread.start()
|
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:
|
def _perform_background_get_elevation(self, latitude: float, longitude: float) -> None:
|
||||||
"""Background task to retrieve elevation."""
|
"""Background task to retrieve elevation."""
|
||||||
result_elevation: Optional[float] = None
|
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_lat_decimal_var.set(f"{original_latitude:.5f}") # Show input coords
|
||||||
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
|
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.
|
# 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_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_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
|
||||||
self.map_elevation_var.set(f"Error: {type(exception_occurred).__name__}")
|
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.
|
# MODIFIED: Clear Map Info fields when a new Download Area task starts.
|
||||||
# WHY: Indicate that the context has shifted to an area task.
|
# WHY: Indicate that the context has shifted to an area task.
|
||||||
# HOW: Set StringVar values to "..." or empty.
|
# 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_lon_decimal_var.set("N/A")
|
||||||
self.map_lat_dms_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")
|
self.map_lon_dms_var.set("N/A") # Point info not relevant for area view
|
||||||
self.map_elevation_var.set("N/A")
|
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.map_area_size_var.set("...") # Show progress for area size info related to map
|
||||||
|
|
||||||
|
|
||||||
self.root.update_idletasks() # Force GUI update
|
self.root.update_idletasks() # Force GUI update
|
||||||
|
|
||||||
|
|
||||||
# Start download in a background thread to keep GUI responsive
|
# Start download in a background thread
|
||||||
# Pass bounds as separate arguments to avoid potential issues with tuple packing/unpacking in target
|
|
||||||
dl_thread = threading.Thread(target=self._perform_background_area_download, args=bounds, daemon=True)
|
dl_thread = threading.Thread(target=self._perform_background_area_download, args=bounds, daemon=True)
|
||||||
dl_thread.start()
|
dl_thread.start()
|
||||||
|
|
||||||
@ -1002,9 +807,35 @@ class ElevationApp:
|
|||||||
browse_img_path = info["browse_image_path"]
|
browse_img_path = info["browse_image_path"]
|
||||||
tile_name = info.get("tile_base_name","?")
|
tile_name = info.get("tile_base_name","?")
|
||||||
window_title = f"Browse: {tile_name.upper()}"
|
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
|
# The process target will load and display the image from the path
|
||||||
args = (browse_img_path, tile_name, window_title)
|
# MODIFIED: Pass the extent to the process target.
|
||||||
self._start_visualization_process(process_target_show_image, args, "2DBrowse")
|
# 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:
|
else:
|
||||||
logger.warning(f"GUI: Browse image not available for ({lat:.5f},{lon:.5f}).")
|
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)
|
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.
|
# The process target will handle data processing (subsampling, smoothing, interpolation) and plotting.
|
||||||
args = (data, plot_title, config_initial_subsample, config_smooth_sigma,
|
args = (data, plot_title, config_initial_subsample, config_smooth_sigma,
|
||||||
config_interpolation_factor, config_plot_grid_points)
|
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:
|
else:
|
||||||
logger.warning(f"GUI: HGT data not available for ({lat:.5f},{lon:.5f}). Cannot show 3D plot.")
|
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)
|
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.")
|
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
|
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}]"
|
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
|
# Pass the filtered list of available info dicts to the process
|
||||||
# The process target will create the composite image from these.
|
# The process target will create the composite image from these.
|
||||||
args = (available_browse_images, title)
|
# MODIFIED: Pass the combined extent to the process target.
|
||||||
self._start_visualization_process(process_target_create_show_area, args, "AreaComposite")
|
# 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:
|
def _start_map_viewer_process(self, base_args_tuple: tuple) -> None:
|
||||||
"""Helper to start the map viewer process."""
|
"""Helper to start the map viewer process."""
|
||||||
if not MAP_VIEWER_SYSTEM_AVAILABLE:
|
if not MAP_VIEWER_SYSTEM_AVAILABLE:
|
||||||
messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root)
|
messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root); return
|
||||||
logger.error("GUI: Map viewer process start failed: MAP_VIEWER_SYSTEM_AVAILABLE is False.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.map_interaction_message_queue:
|
if not self.map_interaction_message_queue:
|
||||||
logger.error("GUI: Cannot start map viewer: interaction queue not initialized.");
|
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)
|
full_args = base_args_tuple + (dem_cache_dir, scale_val)
|
||||||
|
|
||||||
self.map_viewer_process_handle = multiprocessing.Process(
|
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,
|
args=full_args,
|
||||||
daemon=True, # Daemon process will be terminated when the main GUI process exits
|
daemon=True, # Daemon process will be terminated when the main GUI process exits
|
||||||
name="GeoElevationMapViewer"
|
name="GeoElevationMapViewer"
|
||||||
@ -1151,7 +996,7 @@ class ElevationApp:
|
|||||||
self.map_lat_dms_var.set("...") # Indicate loading for 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_lon_dms_var.set("...") # Indicate loading for DMS fields
|
||||||
self.map_elevation_var.set("...")
|
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.
|
# 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 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')}")
|
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()
|
test_root = tk.Tk()
|
||||||
app_test_instance: Optional[ElevationApp] = None
|
app_test_instance: Optional[ElevationApp] = None
|
||||||
|
|||||||
@ -10,7 +10,7 @@ and sizes, finding necessary map tile ranges to cover an area using the
|
|||||||
# Standard library imports
|
# Standard library imports
|
||||||
import logging
|
import logging
|
||||||
import math
|
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
|
# Third-party imports
|
||||||
try:
|
try:
|
||||||
@ -582,3 +582,74 @@ def deg_to_dms_string(degree_value: float, coord_type: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
return dms_string
|
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)
|
||||||
362
geoelevation/process_targets.py
Normal file
362
geoelevation/process_targets.py
Normal 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.
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
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
|
import time # For benchmarking processing time
|
||||||
|
|
||||||
# --- Dependency Checks ---
|
# --- Dependency Checks ---
|
||||||
@ -115,11 +115,20 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
def show_image_matplotlib(
|
def show_image_matplotlib(
|
||||||
image_source: Union[str, "np_typing.ndarray", "PILImage_typing.Image"],
|
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.
|
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.
|
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:
|
if not MATPLOTLIB_AVAILABLE:
|
||||||
logging.error("Cannot display image: Matplotlib is not 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
|
img_display_np: Optional["np_typing.ndarray"] = None
|
||||||
source_type = type(image_source).__name__
|
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:
|
try:
|
||||||
if isinstance(image_source, str):
|
if isinstance(image_source, str):
|
||||||
@ -145,6 +158,9 @@ def show_image_matplotlib(
|
|||||||
return
|
return
|
||||||
elif isinstance(image_source, np.ndarray):
|
elif isinstance(image_source, np.ndarray):
|
||||||
img_display_np = image_source.copy()
|
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
|
elif PIL_AVAILABLE_VIS and isinstance(image_source, Image.Image): # type: ignore
|
||||||
img_display_np = np.array(image_source)
|
img_display_np = np.array(image_source)
|
||||||
else:
|
else:
|
||||||
@ -156,9 +172,26 @@ def show_image_matplotlib(
|
|||||||
return
|
return
|
||||||
|
|
||||||
fig, ax = plt.subplots(figsize=(8, 8))
|
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.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()
|
plt.show()
|
||||||
logging.debug(f"Plot window for '{title}' closed.")
|
logging.debug(f"Plot window for '{title}' closed.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user