diff --git a/.gitignore b/.gitignore index 855e362..2bed5b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,19 @@ .svn -map_elevation/ +map_elevation/* .hgt .jpg .png -elevation_cache/ -__pycache__/ -_build/ -_dist/ -_req_packages/ -map_tile_cache/ -map_tile_cache_ge/ -geoelevation_dem_cache/ -build/ \ No newline at end of file +elevation_cache/* +__pycache__/* +_build/* +_dist/* +_req_packages/* +map_tile_cache/* +map_tile_cache_ge/* +geoelevation_dem_cache/* +build/* +elevation_data_cache_gui_fallback_critical/* +elevation_data_cache/* +debug_map_cache/* +test_cache_dir/* +.pytest_cache/* \ No newline at end of file diff --git a/doc/map_manager.md b/doc/map_manager.md new file mode 100644 index 0000000..3ec8ac4 --- /dev/null +++ b/doc/map_manager.md @@ -0,0 +1,149 @@ +Ecco il piano operativo completo per il refactoring e la creazione del modulo `python-map-manager`. Questo documento è strutturato per essere inserito direttamente nella documentazione tecnica del progetto. + +--- + +# Piano Operativo: Refactoring e Creazione Modulo `python-map-manager` + +## 1. Obiettivo del Refactoring + +L'obiettivo primario è disaccoppiare la logica di gestione, recupero e visualizzazione delle mappe geografiche (attualmente integrata nell'applicazione `geoelevation`) per creare un componente software autonomo, riutilizzabile e manutenibile separatamente. + +Questo nuovo componente sarà gestito come **Git Submodule** e denominato **`python-map-manager`**. + +### Obiettivi Specifici +1. **Indipendenza**: Il modulo non deve avere dipendenze dalla logica di business dell'applicazione ospite (es. non deve conoscere `ElevationManager`). +2. **Modularità**: Separazione netta tra logica di elaborazione (`Engine`) e logica di visualizzazione (`Visualizer`). +3. **Testabilità**: Inclusione di un tool di debug integrato (`debug_tool.py`) per lo sviluppo e il test isolato delle funzionalità. +4. **Interfaccia Chiara**: Esposizione di API semplici per richiedere immagini di mappe basate su aree, punti o raggi. + +--- + +## 2. Architettura del Nuovo Modulo + +Il modulo `python-map-manager` esporrà due componenti principali: + +### A. `MapEngine` (Logica Backend) +È il cervello del modulo. Non ha interfaccia grafica. +* **Responsabilità**: + * Gestione dei provider di mappe (es. OpenStreetMap). + * Gestione della cache su disco (download, salvataggio, recupero). + * Calcoli matematici (conversioni coordinate Geo <-> Pixel, calcolo Bounding Box). + * Stitching (unione) delle tile per formare un'unica immagine PIL. +* **Funzionalità Chiave**: + * `get_image_for_area(bbox, max_size)`: Restituisce un'immagine ottimizzata per coprire un'area. + * `get_image_for_point(lat, lon, zoom, size)`: Restituisce un'immagine centrata su un punto. + +### B. `MapVisualizer` (Interfaccia Frontend - Opzionale) +È il componente di visualizzazione interattiva (basato su OpenCV). +* **Responsabilità**: + * Apertura e gestione della finestra grafica. + * Gestione dell'input utente (Mouse, Zoom, Pan). + * Rendering dell'immagine fornita dall'`Engine`. +* **Disaccoppiamento**: + * Invece di chiamare funzioni esterne, il Visualizer emette **Eventi** (tramite callback) quando l'utente interagisce (es. `on_map_click`, `on_area_selected`). L'applicazione ospite si sottoscrive a questi eventi. + +--- + +## 3. Struttura del Repository `python-map-manager` + +```text +python-map-manager/ +├── map_manager/ # Package Python principale +│ ├── __init__.py # Espone MapEngine e MapVisualizer +│ ├── engine.py # Classe MapEngine (Facade logica) +│ ├── visualizer.py # Classe MapVisualizer (Gestione Window/OpenCV) +│ ├── tile_manager.py # Gestione download e cache (ex map_manager.py) +│ ├── services.py # Definizioni Provider Mappe (ex map_services.py) +│ ├── utils.py # Calcoli geografici puri (ex map_utils.py) +│ └── drawing.py # Funzioni di disegno su PIL (ex map_drawing.py) +├── debug_tool.py # Tool CLI/GUI per testare il modulo isolatamente +├── requirements.txt # Dipendenze (requests, Pillow, opencv-python, mercantile, pyproj) +└── README.md # Documentazione API +``` + +--- + +## 4. Fasi di Implementazione + +### Fase 1: Setup dell'Ambiente e Migrazione File "Puri" +In questa fase si crea la struttura base e si migrano le librerie di utilità che non richiedono refactoring logico. + +1. Creare la cartella `python-map-manager` e inizializzare git. +2. Creare la struttura cartelle `map_manager/`. +3. **Migrazione Diretta**: + * Copiare `geoelevation/map_viewer/map_services.py` -> `map_manager/services.py`. + * Copiare `geoelevation/map_viewer/map_utils.py` -> `map_manager/utils.py`. + * Copiare `geoelevation/map_viewer/map_drawing.py` -> `map_manager/drawing.py`. + * Copiare `geoelevation/map_viewer/map_manager.py` -> `map_manager/tile_manager.py` (Rinominato per chiarezza). +4. **Normalizzazione Import**: Aggiornare gli import interni ai file copiati per puntare ai nuovi percorsi relativi (es. `from .services import ...` invece di `from .map_services import ...`). + +### Fase 2: Implementazione di `MapEngine` (`engine.py`) +In questa fase si astrae la logica di calcolo e recupero immagini. + +1. Creare `map_manager/engine.py`. +2. Definire la classe `MapEngine`. +3. Implementare il metodo `__init__` per configurare il `MapTileManager` e la cache. +4. Implementare `get_image_for_area`: + * Deve accettare coordinate `(min_lat, min_lon, max_lat, max_lon)`. + * Deve usare `utils.py` per calcolare lo zoom ottimale in base alle dimensioni pixel richieste. + * Deve chiamare `tile_manager.stitch_map_image`. +5. Implementare `get_image_for_point`: + * Accetta centro e livello di zoom. + * Calcola il Bounding Box necessario usando `utils.py`. + * Richiede lo stitching. + +### Fase 3: Implementazione di `MapVisualizer` (`visualizer.py`) +In questa fase si crea il gestore della finestra, rimuovendo ogni logica di business specifica di `geoelevation`. + +1. Creare `map_manager/visualizer.py`. +2. Definire la classe `MapVisualizer` che accetta un'istanza di `MapEngine`. +3. Estrarre la logica OpenCV da `geo_map_viewer.py` e `map_display.py`. +4. Implementare il loop di gestione eventi mouse (`cv2.setMouseCallback`). +5. **Refactoring Eventi**: + * Definire una proprietà `callback_on_click` (funzione che accetta lat, lon). + * Quando avviene un click, usare `engine` o `utils` per convertire Pixel -> Lat/Lon. + * Invocare `self.callback_on_click(lat, lon)` invece di chiamare `elevation_manager`. + +### Fase 4: Creazione del `debug_tool.py` +Uno strumento essenziale per garantire che il modulo funzioni "out of the box". + +1. Creare `debug_tool.py` nella root del repository. +2. Lo script deve: + * Istanziare `MapEngine` (con una cache temporanea o di debug). + * Istanziare `MapVisualizer`. + * Definire una funzione dummy: `def on_click(lat, lon): print(f"Clicked: {lat}, {lon}")`. + * Collegare la funzione al visualizzatore. + * Avviare la mappa su coordinate di default (es. Roma). +3. Questo tool servirà per verificare lo zoom, il pan e la correttezza del download delle tile. + +### Fase 5: Integrazione nell'Applicazione Principale +Una volta che il submodule è stabile e pushato sul repository remoto. + +1. In `geoelevation`, rimuovere la cartella `map_viewer` esistente. +2. Aggiungere il submodule: + ```bash + git submodule add -b master external/python-map-manager + ``` +3. Configurare i path in `geoelevation/__init__.py` (o file di setup path dedicato) per includere il submodule. +4. Modificare `geoelevation/process_targets.py` (o dove risiede il processo mappa): + * Importare `MapEngine` e `MapVisualizer` dal submodule. + * Nel processo dedicato alla mappa, definire la funzione di callback reale che interroga `ElevationManager`. + * Passare questa callback al `MapVisualizer`. + +--- + +## 5. Specifiche Tecniche e Standard + +* **PEP8**: Tutto il codice deve seguire rigorosamente lo standard PEP8. +* **Type Hinting**: Ogni funzione deve avere le annotazioni di tipo (`-> Optional[Image.Image]`, ecc.). +* **Docstrings**: Ogni classe e metodo pubblico deve avere docstring in Inglese. +* **Dipendenze**: + * Non usare `try-except ImportError` per nascondere dipendenze mancanti all'interno del modulo. Se `MapVisualizer` richiede OpenCV, l'import deve fallire esplicitamente se manca, o essere gestito a livello di `__init__.py` per esporre le funzionalità disponibili. + * Il file `requirements.txt` deve elencare le versioni minime testate. + +## 6. Risultato Atteso + +Al termine di questo processo, avremo: +1. Un repository `python-map-manager` autonomo. +2. La possibilità di sviluppare e migliorare la gestione mappe lanciando solo `python debug_tool.py`. +3. L'applicazione `geoelevation` più leggera, che delega tutta la complessità cartografica al modulo esterno, mantenendo solo la logica di "cosa fare quando l'utente clicca un punto" (ovvero chiedere l'elevazione). \ No newline at end of file diff --git a/geoelevation/__init__.py b/geoelevation/__init__.py index a3bbbf5..3a2925f 100644 --- a/geoelevation/__init__.py +++ b/geoelevation/__init__.py @@ -38,6 +38,19 @@ if not library_logger.hasHandlers(): # --- Import Core Components and Configuration --- # These imports make key classes and constants available and are used by # the public API functions defined in this __init__.py. +## Ensure external `python-map-manager` package (workspace submodule) is importable +# This helps top-level modules (e.g. elevation_gui) import `map_manager` early. +try: + _pkg_root = os.path.dirname(__file__) + _candidate = os.path.normpath(os.path.join(_pkg_root, "..", "external", "python-map-manager")) + if os.path.isdir(_candidate) and _candidate not in sys.path: + # Prepend so it takes precedence over other installations + sys.path.insert(0, _candidate) + library_logger.debug(f"Added external python-map-manager to sys.path: {_candidate}") +except Exception: + # Non-fatal: if path manipulation fails, imports will raise as before and fallback handles it. + pass + try: from .elevation_manager import ElevationManager from .elevation_manager import RASTERIO_AVAILABLE # Critical dependency check diff --git a/geoelevation/__pycache__/__init__.cpython-313.pyc b/geoelevation/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9bf3344 Binary files /dev/null and b/geoelevation/__pycache__/__init__.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/__main__.cpython-313.pyc b/geoelevation/__pycache__/__main__.cpython-313.pyc new file mode 100644 index 0000000..a7e9970 Binary files /dev/null and b/geoelevation/__pycache__/__main__.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/_version.cpython-313.pyc b/geoelevation/__pycache__/_version.cpython-313.pyc new file mode 100644 index 0000000..4dcea2b Binary files /dev/null and b/geoelevation/__pycache__/_version.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/config.cpython-313.pyc b/geoelevation/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..c0f88e8 Binary files /dev/null and b/geoelevation/__pycache__/config.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/elevation_gui.cpython-313.pyc b/geoelevation/__pycache__/elevation_gui.cpython-313.pyc new file mode 100644 index 0000000..5f64fb3 Binary files /dev/null and b/geoelevation/__pycache__/elevation_gui.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/elevation_manager.cpython-313.pyc b/geoelevation/__pycache__/elevation_manager.cpython-313.pyc new file mode 100644 index 0000000..a13624d Binary files /dev/null and b/geoelevation/__pycache__/elevation_manager.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/image_processor.cpython-313.pyc b/geoelevation/__pycache__/image_processor.cpython-313.pyc new file mode 100644 index 0000000..fc3f3cd Binary files /dev/null and b/geoelevation/__pycache__/image_processor.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/process_targets.cpython-313.pyc b/geoelevation/__pycache__/process_targets.cpython-313.pyc new file mode 100644 index 0000000..534c2d9 Binary files /dev/null and b/geoelevation/__pycache__/process_targets.cpython-313.pyc differ diff --git a/geoelevation/__pycache__/visualizer.cpython-313.pyc b/geoelevation/__pycache__/visualizer.cpython-313.pyc new file mode 100644 index 0000000..219a77b Binary files /dev/null and b/geoelevation/__pycache__/visualizer.cpython-313.pyc differ diff --git a/geoelevation/elevation_gui.py b/geoelevation/elevation_gui.py index 52b31ba..8fb34be 100644 --- a/geoelevation/elevation_gui.py +++ b/geoelevation/elevation_gui.py @@ -49,12 +49,12 @@ try: # MODIFIED: Import the deg_to_dms_string utility function from map_utils.py # 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. + from map_manager.utils import deg_to_dms_string + # MODIFIED: Import the utility functions for geographic bounds calculation from map_manager.utils. # 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 + from map_manager.utils import get_hgt_tile_geographic_bounds # For single tile browse + from map_manager.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. diff --git a/geoelevation/map_viewer/__init__.py b/geoelevation/map_viewer/__init__.py deleted file mode 100644 index 9d824e4..0000000 --- a/geoelevation/map_viewer/__init__.py +++ /dev/null @@ -1,61 +0,0 @@ -# geoelevation/map_viewer/__init__.py -""" -GeoElevation Map Viewer Subpackage. - -This package provides the components necessary for displaying and interacting -with tiled web maps (e.g., OpenStreetMap) within the main GeoElevation application. -It is designed to run in a separate process to keep the main GUI responsive. - -Key components include: - - GeoElevationMapViewer: Orchestrates the map display, data fetching for tiles, - user interaction (mouse clicks), and communication with - the main application GUI. - - MapDisplayWindow: Manages the OpenCV window used for rendering the map and - capturing mouse events. - - MapTileManager: Handles the logic for retrieving map tiles from a specified - map service, including caching and stitching tiles together. - - MapServices: Defines an interface (BaseMapService) and concrete implementations - (e.g., OpenStreetMapService) for different map tile providers. - - MapUtils: Contains utility functions for common geographic and map-tile - related calculations (e.g., bounding boxes, tile ranges). -""" - -# To make the main orchestrator class easily importable from this subpackage, -# e.g., `from geoelevation.map_viewer import GeoElevationMapViewer` -# We perform a guarded import here. If critical dependencies for GeoElevationMapViewer -# (like OpenCV, Pillow, which are checked in geo_map_viewer.py itself) are missing, -# this import might fail. The main application (elevation_gui.py) has its own -# checks for MAP_VIEWER_SYSTEM_AVAILABLE to handle this gracefully. - -try: - from .geo_map_viewer import GeoElevationMapViewer - # You could also expose other key classes or factory functions if desired: - # from .map_services import OpenStreetMapService, get_map_service_instance - # from .map_utils import get_bounding_box_from_center_size - - # Define what `from geoelevation.map_viewer import *` will import. - # It's generally good practice to be explicit. - __all__ = [ - "GeoElevationMapViewer", - # "OpenStreetMapService", - # "get_map_service_instance", - # "get_bounding_box_from_center_size", - # Add other names here if you want them to be part of the public API - # of this sub-package when imported with '*'. - ] - -except ImportError as e_map_viewer_init_import: - # This might happen if, for example, OpenCV is not installed, and thus - # geo_map_viewer.py (or one of its imports like map_display.py) fails to load. - # The main application GUI (elevation_gui.py) has more robust checks. - import logging - # Use the name of this __init__.py file for the logger - logger_init = logging.getLogger(__name__) # Will be 'geoelevation.map_viewer' - logger_init.warning( - f"Could not import GeoElevationMapViewer or other components from the " - f"map_viewer subpackage, possibly due to missing dependencies (e.g., OpenCV, Pillow): {e_map_viewer_init_import}. " - f"Map functionality might be limited if this subpackage is used directly." - ) - # If the core component fails to import, __all__ should reflect that - # nothing (or very little) is available. - __all__ = [] \ No newline at end of file diff --git a/geoelevation/map_viewer/geo_map_viewer.py b/geoelevation/map_viewer/geo_map_viewer.py deleted file mode 100644 index fb90c76..0000000 --- a/geoelevation/map_viewer/geo_map_viewer.py +++ /dev/null @@ -1,1492 +0,0 @@ -# geoelevation/map_viewer/geo_map_viewer.py -""" -Orchestrates map display functionalities for the GeoElevation application. - -This module initializes and manages map services, tile fetching/caching, -and the map display window. It handles requests to show maps centered on -specific points or covering defined areas, applying a specified display scale. -It also processes user interactions (mouse clicks) on the map, converting -pixel coordinates to geographic coordinates, fetching elevation for those points -using the core ElevationManager, and sending this information back to the -main GUI via a queue. -""" - -# Standard library imports -import logging -import math -import queue # For type hinting, actual queue object is passed in from multiprocessing -import sys # Import sys for logging stream -from typing import Optional, Tuple, Dict, Any, List - -# Third-party imports -try: - from PIL import Image, ImageDraw # Import ImageDraw for drawing operations - ImageType = Image.Image # type: ignore - PIL_IMAGE_LIB_AVAILABLE = True -except ImportError: - Image = None # type: ignore - ImageDraw = None # type: ignore # Define as None if import fails - ImageType = Any # type: ignore # Define ImageType as Any if PIL is not available - # This logger might not be configured yet if this is the first import in the process - # So, direct print or rely on higher-level logger configuration. - print("ERROR: GeoMapViewer - Pillow (PIL) library not found. Image operations will fail.") - - -try: - import cv2 # OpenCV for windowing and drawing - import numpy as np - CV2_NUMPY_LIBS_AVAILABLE = True -except ImportError: - cv2 = None # type: ignore - np = None # type: ignore - CV2_NUMPY_LIBS_AVAILABLE = False - print("ERROR: GeoMapViewer - OpenCV or NumPy not found. Drawing and image operations will fail.") - -try: - import mercantile # For Web Mercator tile calculations and coordinate conversions - MERCANTILE_LIB_AVAILABLE_DISPLAY = True -except ImportError: - mercantile = None # type: ignore - MERCANTILE_LIB_AVAILABLE_DISPLAY = False - print("ERROR: MapDisplay - 'mercantile' library not found. Coordinate conversions will fail.") - - -# Local application/package imports -# Imports from other modules within the 'map_viewer' subpackage -from .map_services import BaseMapService -from .map_services import OpenStreetMapService # Default service if none specified -from .map_manager import MapTileManager -from .map_utils import get_bounding_box_from_center_size -from .map_utils import get_tile_ranges_for_bbox -from .map_utils import MapCalculationError -# MODIFIED: Import the new utility functions for geographic size and HGT tile bounds. -# WHY: Needed for calculating displayed map area size and getting DEM tile bounds. -# HOW: Added imports from map_utils. -from .map_utils import calculate_geographic_bbox_size_km -from .map_utils import get_hgt_tile_geographic_bounds -# MODIFIED: Import the new utility function to calculate zoom level for geographic size. -# WHY: This is the core function needed to determine the appropriate zoom for the map point view. -# HOW: Added import from map_utils. -from .map_utils import calculate_zoom_level_for_geographic_size -# MODIFIED: Import the utility function to calculate bbox from pixel size and zoom. -# WHY: Needed for interactive zoom implementation. -# HOW: Added import from map_utils. -from .map_utils import calculate_geographic_bbox_from_pixel_size_and_zoom -from .map_utils import PYPROJ_AVAILABLE -# MODIFIED: Import the map_utils module explicitly. -# WHY: Required to call map_utils.deg_to_dms_string. -# HOW: Added this import line. -from . import map_utils -# MODIFIED: Import drawing functions from the new map_drawing module. -# WHY: Drawing logic has been moved to a separate module. -# HOW: Added import for drawing functions. -from . import map_drawing # Import the module containing drawing functions - - -# Imports from the parent 'geoelevation' package -from geoelevation.elevation_manager import ElevationManager - -# Module-level logger. Will be configured by the calling process (run_map_viewer_process_target) -# or use root logger if not specifically configured. -logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.geo_map_viewer' - -# Default configuration values specific to the map viewer's operation -DEFAULT_MAP_TILE_CACHE_DIRECTORY = "map_tile_cache_ge" -DEFAULT_MAP_DISPLAY_ZOOM_LEVEL = 15 -DEFAULT_MAP_VIEW_AREA_SIZE_KM = 5.0 # This default might become less relevant for point views - -# MODIFIED: Define constants for drawing the DEM tile boundary. -# WHY: Improves code clarity and makes colors/thickness easily adjustable. -# HOW: Added constants for DEM boundary color and thickness. -DEM_BOUNDARY_COLOR = "red" -DEM_BOUNDARY_THICKNESS_PX = 3 # Pixel thickness on the unscaled map image -# MODIFIED: Define constants for drawing the Requested Area boundary. -# WHY: Improves code clarity and makes colors/thickness easily adjustable. Distinct from DEM color. -# HOW: Added constants for Area boundary color and thickness. -AREA_BOUNDARY_COLOR = "blue" -AREA_BOUNDARY_THICKNESS_PX = 2 - - -# MODIFIED: Define target pixel dimensions for the stitched map image in the point view. -# WHY: This is the desired output size that determines the calculated zoom level. -# HOW: Added a constant. -TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW = 1024 # Target width and height in pixels - -# MODIFIED: Define text drawing parameters for DEM tile labels. -# WHY: Centralize style for labels. -# HOW: Added constants for color, background color, font size. Reusing constants from image_processor for consistency. -try: - # Attempt to import constants from image_processor for consistency - from geoelevation.image_processor import TILE_TEXT_COLOR, TILE_TEXT_BG_COLOR, DEFAULT_FONT - # These constants will be used directly by map_drawing functions -except ImportError: - # Fallback constants if image_processor constants are not available - # map_drawing needs to handle these fallbacks internally if it can't import them. - pass # No need to define fallbacks here, map_drawing handles it. - - -# MODIFIED: Base font size and zoom level for DEM tile label scaling. -# WHY: Used by map_drawing for font size calculation. -# HOW: Added constants. -DEM_TILE_LABEL_BASE_FONT_SIZE = 12 # px -DEM_TILE_LABEL_BASE_ZOOM = 10 # At zoom 10, font size will be BASE_FONT_SIZE - - -class GeoElevationMapViewer: - """ - Manages the display of maps and user interaction for GeoElevation. - This class is intended to be instantiated and run in a separate process. - """ - def __init__( - self, - elevation_manager_instance: ElevationManager, - gui_output_communication_queue: queue.Queue, # For sending data back to GUI - initial_display_scale: float = 1.0, # Scale factor for the map image - # MODIFIED: Add parameters for initial view definition. - # WHY: The class needs to know how to load the first map view. - # HOW: Added new parameters to the constructor. - initial_operation_mode: str = "point", # "point" or "area" - initial_point_coords: Optional[Tuple[float, float]] = None, - initial_area_bbox: Optional[Tuple[float, float, float, float]] = None - ) -> None: - """ - Initializes the GeoElevationMapViewer. - - Args: - elevation_manager_instance: Instance of ElevationManager for fetching elevations. - gui_output_communication_queue: Queue to send interaction data to the main GUI. - initial_display_scale: Initial scaling factor for the map display. - initial_operation_mode (str): "point" or "area". Defines the type of the initial view. - initial_point_coords (Optional[Tuple[float, float]]): (lat, lon) for point view. - initial_area_bbox (Optional[Tuple[float, float, float, float]]): (west, south, east, north) for area view. - """ - logger.info("Initializing GeoElevationMapViewer instance...") - # MODIFIED: Check for critical dependencies at init. - # WHY: Ensure the class can function before proceeding. - # HOW: Raise ImportError if dependencies are missing. - if not CV2_NUMPY_LIBS_AVAILABLE: - critical_msg = "OpenCV and/or NumPy are not available for GeoElevationMapViewer operation." - logger.critical(critical_msg) - raise ImportError(critical_msg) - # PIL and mercantile are also critical for map viewer logic - if not PIL_IMAGE_LIB_AVAILABLE: - critical_msg = "Pillow (PIL) library is not available for GeoElevationMapViewer operation." - logger.critical(critical_msg) - raise ImportError(critical_msg) - # MODIFIED: Added check for ImageDraw availability, as it's needed for drawing. - # WHY: Drawing shapes/text on PIL images requires ImageDraw. - # HOW: Added explicit check. - if ImageDraw is None: # type: ignore - critical_msg = "Pillow's ImageDraw module is not available. GeoElevationMapViewer drawing operations will fail." - logger.critical(critical_msg) - raise ImportError(critical_msg) - if not MERCANTILE_LIB_AVAILABLE_DISPLAY: - critical_msg = "'mercantile' library is not available for GeoElevationMapViewer operation." - logger.critical(critical_msg) - raise ImportError(critical_msg) - # pyproj is needed for size calculations, but might be optional depending on usage. - # If calculate_geographic_bbox_size_km fails, the size might be reported as N/A, - # which is graceful degradation. Let's not make pyproj a hard dependency for init. - - - self.elevation_manager: ElevationManager = elevation_manager_instance - self.gui_com_queue: queue.Queue = gui_output_communication_queue - - # MODIFIED: Store the initial_display_scale. - # WHY: This scale factor will be used by MapDisplayWindow to scale the map image. - # HOW: Assigned to self.current_display_scale_factor. - self.current_display_scale_factor: float = initial_display_scale - logger.info(f"Initial map display scale factor set to: {self.current_display_scale_factor:.3f}") - - self.map_service_provider: Optional[BaseMapService] = None - self.map_tile_fetch_manager: Optional[MapTileManager] = None - # Changed attribute name to map_display_window_controller for consistency - self.map_display_window_controller: Optional['MapDisplayWindow'] = None - - # --- Current Map View State --- - self._current_stitched_map_pil: Optional[ImageType] = None # The base, unscaled stitched image - self._current_map_geo_bounds_deg: Optional[Tuple[float, float, float, float]] = None # Bounds of the _current_stitched_map_pil - self._current_map_render_zoom: Optional[int] = None # Zoom level _current_stitched_map_pil was rendered at - self._current_stitched_map_pixel_shape: Optional[Tuple[int, int]] = (0, 0) # Shape (H, W) of _current_stitched_map_pil - - # --- Interactive State --- - self._last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]] = None # Pixel coords on the SCALED, displayed image - - # --- Initial View State (for Reset) --- - # MODIFIED: Added attributes to store the initial map view parameters for reset functionality. - # WHY: Need to remember the original parameters passed to re-load the initial view. - # HOW: Added new instance attributes. - self._initial_operation_mode: str = initial_operation_mode - self._initial_point_coords: Optional[Tuple[float, float]] = initial_point_coords - self._initial_area_bbox: Optional[Tuple[float, float, float, float]] = initial_area_bbox - - - # --- View Specific State (for Drawing Overlays) --- - # MODIFIED: Added attributes to store info for POINT view drawing on clicks. - # WHY: Needed to redraw the single DEM tile boundary on clicks for point view. - # HOW: Added new instance attributes. - self._dem_tile_geo_bbox_for_current_point_view: Optional[Tuple[float, float, float, float]] = None - - # MODIFIED: Added attributes to store info for AREA view drawing on clicks. - # WHY: Needed to redraw the requested area boundary (blue) and all DEM tile boundaries/labels (red) on clicks for area view. - # HOW: Added new instance attributes. - self._current_requested_area_geo_bbox: Optional[Tuple[float, float, float, float]] = None # The original bbox from GUI - self._dem_tiles_info_for_current_map_area_view: List[Dict] = [] # Store list of tile info dicts for DEMs in area view - - - self._initialize_map_viewer_components() - - # MODIFIED: Load and display the initial map view based on the provided parameters. - # WHY: The class is responsible for setting up its initial display state. - # HOW: Call the new internal method _load_and_display_initial_view. - self._load_and_display_initial_view() - - logger.info("GeoElevationMapViewer instance initialization complete.") - - # MODIFIED: Added the missing _can_perform_drawing_operations method. - # WHY: The _trigger_map_redraw_with_overlays method calls this non-existent method, - # leading to an AttributeError. This method checks if the necessary libraries - # and basic context are available to attempt drawing operations. - # HOW: Defined the method to check for PIL (Image, ImageDraw) and Mercantile library availability. - def _can_perform_drawing_operations(self) -> bool: - """Checks if conditions are met to perform drawing operations (libraries).""" - # Check for essential drawing libraries/modules - # PIL (Image, ImageDraw) is needed for creating/manipulating images and drawing shapes/text. - # Mercantile is needed for converting geographic coordinates to pixel coordinates for drawing. - # OpenCV/NumPy are needed specifically for drawing point markers, but boundary/label drawing - # uses PIL/ImageDraw. The individual drawing functions check for CV2/NumPy internally if needed. - # This method checks for the *minimum* set of libraries needed for the overlay logic - # in _trigger_map_redraw_with_overlays to proceed and call the drawing functions. - if not (PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None and MERCANTILE_LIB_AVAILABLE_DISPLAY): - # Log a warning if fundamental drawing libraries are missing - # This warning might be logged once during initial check. - # Avoid excessive logging from this check if it's called often. - logger.debug("Drawing capability check failed: Essential libraries (PIL, ImageDraw, Mercantile) are not fully available.") - return False - - # If all required libraries are present, drawing is *potentially* possible. - # The redraw logic will also check if there's an image and context to draw on. - return True - - def _initialize_map_viewer_components(self) -> None: - """Initializes internal map service, tile manager, and display window controller.""" - logger.debug("Initializing internal map viewer components...") - try: - # Local import of map_display within the process target to avoid import issues - # in the main GUI process where Tkinter is running. - from .map_display import MapDisplayWindow - - self.map_service_provider = OpenStreetMapService() - if not self.map_service_provider: - raise ValueError("Failed to initialize OpenStreetMapService.") - logger.info(f"Map service provider '{self.map_service_provider.name}' initialized.") - - # MODIFIED: Use the map service's tile size when initializing MapTileManager. - # WHY: Ensure MapTileManager uses the correct tile size for the chosen service. - # HOW: Passed map_service.tile_size to MapTileManager constructor. - self.map_tile_fetch_manager = MapTileManager( - map_service=self.map_service_provider, - cache_root_directory=DEFAULT_MAP_TILE_CACHE_DIRECTORY, - enable_online_tile_fetching=True, - tile_pixel_size=self.map_service_provider.tile_size # Pass tile size - ) - logger.info("MapTileManager initialized.") - - # MapDisplayWindow will use 'self' (this GeoElevationMapViewer instance) - # as its 'app_facade' to make callbacks and access shared state like scale factor. - # MODIFIED: Corrected the keyword argument name for the window name. - # WHY: The MapDisplayWindow.__init__ method expects 'window_name_str', not 'window_name'. - # HOW: Changed 'window_name' to 'window_name_str' in the constructor call. - self.map_display_window_controller = MapDisplayWindow( - app_facade=self, # This instance provides context (like scale) and handles callbacks - window_name_str="GeoElevation - Interactive Map" - ) - logger.info("MapDisplayWindow controller initialized.") - - except ImportError as e_imp_map_comp: - logger.critical(f"Failed to import a required map component: {e_imp_map_comp}", exc_info=True) - raise - except Exception as e_init_map_comp: - logger.critical(f"Failed to initialize map components: {e_init_map_comp}", exc_info=True) - raise - - - # MODIFIED: New internal method to load and display the initial map view. - # WHY: Encapsulates the logic previously at the start of display_map_for_point/area - # and called by run_map_viewer_process_target. Handles setting up the first view state. - # HOW: Created this method, moved the core logic into it, and it uses the initial_ parameters - # stored in __init__. - def _load_and_display_initial_view(self) -> None: - """ - Loads and displays the initial map view based on the mode and parameters - passed during the GeoElevationMapViewer initialization. - Sets up the initial view state and triggers the first redraw. - """ - logger.info(f"Loading initial map view for mode '{self._initial_operation_mode}'...") - - # --- Clear State from Potential Previous Views (should be clear by init, but defensive) --- - # MODIFIED: Use the helper method to clear state. - self._update_current_map_state(None, None, None) # Clear current map state - self._dem_tile_geo_bbox_for_current_point_view = None - self._current_requested_area_geo_bbox = None - self._dem_tiles_info_for_current_map_area_view = [] - self._last_user_click_pixel_coords_on_displayed_image = None # No click marker initially - - - map_fetch_geo_bbox: Optional[Tuple[float, float, float, float]] = None - zoom_to_use: Optional[int] = None - dem_tiles_info_for_drawing: List[Dict] = [] # List of DEM tile infos to draw boundaries/labels for - - - try: - if self._initial_operation_mode == "point" and self._initial_point_coords is not None: - center_latitude, center_longitude = self._initial_point_coords - logger.info(f"Preparing initial POINT view for ({center_latitude:.5f}, {center_longitude:.5f}).") - - # Determine map fetch bbox (based on DEM tile or fallback) and calculate initial zoom - dem_tile_info = self.elevation_manager.get_tile_info(center_latitude, center_longitude) - if dem_tile_info and dem_tile_info.get("hgt_available"): - lat_coord = dem_tile_info["latitude_coord"] - lon_coord = dem_tile_info["longitude_coord"] - dem_tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord) - self._dem_tile_geo_bbox_for_current_point_view = dem_tile_geo_bbox # Store for redraw - logger.debug(f"Identified DEM tile bounds for initial point view: {dem_tile_geo_bbox}") - - # Use DEM tile bounds (with buffer) for map fetch bbox - if dem_tile_geo_bbox: - buffer_deg = 0.1 - w_dem, s_dem, e_dem, n_dem = dem_tile_geo_bbox - map_fetch_west = max(-180.0, w_dem - buffer_deg) - map_fetch_south = max(-90.0, s_dem - buffer_deg) - map_fetch_east = min(180.0, e_dem + buffer_deg) - map_fetch_north = min(90.0, n_dem + buffer_deg) - map_fetch_geo_bbox = (map_fetch_west, map_fetch_south, map_fetch_east, map_fetch_north) - logger.debug(f"Map fetch BBox (DEM+buffer) for initial point view: {map_fetch_geo_bbox}") - else: - logger.warning("No HGT tile information or HGT not available for initial point. Cannot size map precisely to DEM tile. Using default area.") - # Fallback: if no DEM tile, use a default map area size centered on the point. - map_area_km_fetch = DEFAULT_MAP_VIEW_AREA_SIZE_KM * 1.2 - map_fetch_geo_bbox = get_bounding_box_from_center_size(center_latitude, center_longitude, map_area_km_fetch) - if not map_fetch_geo_bbox: - raise MapCalculationError("Fallback BBox calculation failed for initial point view.") - - if not map_fetch_geo_bbox: - raise MapCalculationError("Final map fetch BBox could not be determined for initial point view.") - - # Calculate appropriate zoom to fit the map_fetch_geo_bbox into target pixel size - calculated_zoom = None - if PYPROJ_AVAILABLE: # type: ignore - map_area_size_km = calculate_geographic_bbox_size_km(map_fetch_geo_bbox) - if map_area_size_km: - width_km, height_km = map_area_size_km - map_bbox_height_meters = height_km * 1000.0 - center_lat_fetch_bbox = (map_fetch_geo_bbox[1] + map_fetch_geo_bbox[3]) / 2.0 - calculated_zoom = calculate_zoom_level_for_geographic_size( - center_lat_fetch_bbox, - map_bbox_height_meters, - TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, - self.map_service_provider.tile_size - ) - if calculated_zoom is not None: - logger.info(f"Calculated zoom level {calculated_zoom} to fit BBox height ({map_bbox_height_meters:.2f}m) for initial point view.") - else: - logger.warning("Could not calculate appropriate zoom level for initial point view. Falling back to default zoom.") - else: - logger.warning("Could not calculate geographic size of fetch BBox for initial point view. Falling back to default zoom.") - else: - logger.warning("Pyproj not available. Cannot calculate geographic size for zoom calculation for initial point view. Falling back to default zoom.") - - # Determine the final zoom level to use - zoom_to_use = calculated_zoom if calculated_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL - # Clamp zoom to service max zoom - if self.map_service_provider and zoom_to_use > self.map_service_provider.max_zoom: - logger.warning(f"Calculated zoom {zoom_to_use} exceeds service max zoom {self.map_service_provider.max_zoom} for initial point view. Clamping.") - zoom_to_use = self.map_service_provider.max_zoom - logger.debug(f"Using final zoom level {zoom_to_use} for tile fetching for initial point view.") - - - elif self._initial_operation_mode == "area" and self._initial_area_bbox is not None: - area_geo_bbox = self._initial_area_bbox - logger.info(f"Preparing initial AREA view for BBox {area_geo_bbox}.") - self._current_requested_area_geo_bbox = area_geo_bbox # Store requested area bbox - - # Determine the full geographic extent of all relevant DEM tiles in the *requested* area - logger.debug("Getting DEM tile info for the REQUESTED area for initial view...") - all_relevant_dem_tiles_info = self.elevation_manager.get_area_tile_info( - area_geo_bbox[1], area_geo_bbox[0], area_geo_bbox[3], area_geo_bbox[2] - ) - dem_tiles_info_in_requested_area = [info for info in all_relevant_dem_tiles_info if info.get("hgt_available")] - logger.info(f"Found {len(dem_tiles_info_in_requested_area)} DEM tiles with HGT data in the REQUESTED area for initial view.") - - if not dem_tiles_info_in_requested_area: - logger.warning("No DEM tiles with HGT data found in the requested area for initial view. Cannot display relevant DEM context.") - # Decide fallback: Display requested area with map tiles, or show placeholder? - # Let's show a placeholder map indicating no DEM data found. - logger.warning(f"No DEM tiles with HGT data found in the requested area {area_geo_bbox} for initial view. Showing placeholder.") - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send info to GUI with status - self._send_map_info_update_to_gui(None, None, "No DEM Data in Area", "Area: No DEM Data") # DMS handled in send function - # No DEM tiles to draw, list remains empty. - return # Exit if no DEM tiles found - - # Store the list of relevant DEM tiles (with HGT data) for this view - self._dem_tiles_info_for_current_map_area_view = dem_tiles_info_in_requested_area - - # Calculate the combined geographic bounding box of ALL these relevant DEM tiles - combined_dem_geo_bbox = map_utils.get_combined_geographic_bounds_from_tile_info_list(dem_tiles_info_in_requested_area) - - if not combined_dem_geo_bbox: - logger.error("Failed to calculate combined geographic bounds for DEM tiles for initial area view. Cannot display map.") - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send error info to GUI - self._send_map_info_update_to_gui(None, None, "DEM Bounds Calc Error", "Map Error") # DMS handled in send function - # Reset state variables related to the stitched map - self._current_map_geo_bounds_deg = None - self._current_map_render_zoom = None - self._current_stitched_map_pil = None - self._current_stitched_map_pixel_shape = (0, 0) - return # Exit if combined bounds calculation fails - - - map_fetch_geo_bbox = combined_dem_geo_bbox # Fetch map tiles for the combined DEM area - - # Calculate appropriate zoom to fit the COMBINED DEM BBox into target pixel size - calculated_zoom = None - if PYPROJ_AVAILABLE: # type: ignore - map_area_size_km = calculate_geographic_bbox_size_km(combined_dem_geo_bbox) - if map_area_size_km: - width_km, height_km = map_area_size_km - map_bbox_height_meters = height_km * 1000.0 - center_lat_combined_bbox = (combined_dem_geo_bbox[1] + combined_dem_geo_bbox[3]) / 2.0 - calculated_zoom = calculate_zoom_level_for_geographic_size( - center_lat_combined_bbox, - map_bbox_height_meters, - TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, - self.map_service_provider.tile_size - ) - if calculated_zoom is not None: - logger.info(f"Calculated zoom level {calculated_zoom} to fit COMBINED DEM BBox height ({map_bbox_height_meters:.2f}m) for initial area view.") - else: - logger.warning("Could not calculate appropriate zoom level for combined DEM area for initial view. Falling back to default zoom.") - else: - logger.warning("Could not calculate geographic size of combined DEM BBox for initial view. Falling back to default zoom.") - else: - logger.warning("Pyproj not available. Cannot calculate geographic size for zoom calculation for initial view. Falling back to default zoom.") - - # Determine the final zoom level to use - zoom_to_use = calculated_zoom if calculated_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL - # Clamp zoom to service max zoom - if self.map_service_provider and zoom_to_use > self.map_service_provider.max_zoom: - logger.warning(f"Calculated zoom {zoom_to_use} exceeds service max zoom {self.map_service_provider.max_zoom} for initial area view. Clamping.") - zoom_to_use = self.map_service_provider.max_zoom - logger.debug(f"Using final zoom level {zoom_to_use} for tile fetching for initial area view.") - - - else: # Invalid initial mode or parameters - logger.error(f"Invalid initial operation mode ('{self._initial_operation_mode}') or missing parameters passed to GeoElevationMapViewer.") - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send error info to GUI - self._send_map_info_update_to_gui(None, None, "Fatal Error: Invalid Init Args", "Map System N/A") # DMS handled in send function - return # Exit on invalid parameters - - - # --- Fetch and Stitch Map Tiles for the Determined BBox and Zoom --- - if map_fetch_geo_bbox is None or zoom_to_use is None or self.map_tile_fetch_manager is None: - logger.error("Map fetch bbox, zoom, or tile manager is None after initial view setup. Cannot fetch/stitch.") - self._update_current_map_state(None, None, None) # Update state to reflect no map - self._send_map_info_update_to_gui(None, None, "Fetch Setup Error", "Map Error") # DMS handled in send function - return - - - map_tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bbox, zoom_to_use) - - if not map_tile_xy_ranges: - logger.warning(f"No map tile ranges found for fetch BBox {map_fetch_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.") - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send info to GUI even if map fails, with error status. - self._send_map_info_update_to_gui(None, None, "Map Tiles N/A", "Map Tiles N/A") # DMS handled in send function - return # Exit after showing placeholder/sending error - - - stitched_pil = self.map_tile_fetch_manager.stitch_map_image( - zoom_to_use, map_tile_xy_ranges[0], map_tile_xy_ranges[1] - ) - - if not stitched_pil: - logger.error("Failed to stitch map image for initial view.") - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send initial info to GUI even if stitch fails. - self._send_map_info_update_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed") # DMS handled in send function - return # Exit after showing placeholder/sending error - - - # --- Update Current Map State --- - # Use the helper method to update current map state. - # Pass the actual bounds covered by the newly stitched tiles. - actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range( - zoom_to_use, map_tile_xy_ranges # Pass the zoom and ranges used for stitching - ) - self._update_current_map_state(stitched_pil, actual_stitched_bounds, zoom_to_use) - - - # --- Send Initial Info to GUI --- - # Calculate and send map area size of the *stitched* area - map_area_size_str = "N/A" - if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore # Check not None and PyProj - size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - # Indicate if this is the size of the DEM area shown (if initial mode was area) - if self._initial_operation_mode == "area": - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (DEM Area Shown)" - else: # Point view or fallback - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (Map Area Shown)" - - else: - map_area_size_str = "Size Calc Failed" - logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.") - elif self._current_map_geo_bounds_deg: # Bounds exist but PyProj missing - map_area_size_str = "PyProj N/A (Size Unknown)" - logger.warning("Pyproj not available, cannot calculate map area size.") - - - # Send initial info to GUI (point info is handled by _send_initial_point_info_to_gui based on whether _initial_point_coords is set) - initial_point_lat = self._initial_point_coords[0] if self._initial_point_coords else None - initial_point_lon = self._initial_point_coords[1] if self._initial_point_coords else None - self._send_map_info_update_to_gui(initial_point_lat, initial_point_lon, "N/A (Initial Load)", map_area_size_str) # Set initial elevation status - - - # --- Trigger Initial Redraw --- - # Redrawing is now handled by _trigger_map_redraw_with_overlays after state update. - self._trigger_map_redraw_with_overlays() - - - except MapCalculationError as e_calc_initial: - logger.error(f"Map calculation error during initial view load: {e_calc_initial}") - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send error info to GUI - self._send_map_info_update_to_gui(None, None, f"Map Calc Error: {e_calc_initial}", "Map Error") # DMS handled in send function - except Exception as e_initial_fatal: - logger.critical(f"FATAL: Unexpected error loading initial map view: {e_initial_fatal}", exc_info=True) - self._update_current_map_state(None, None, None) # Update state to reflect no map - # Send error info to GUI - self._send_map_info_update_to_gui(None, None, f"Fatal Map Error: {type(e_initial_fatal).__name__}", "Fatal Error") # DMS handled in send function - - - def _update_current_map_state( - self, - stitched_image: Optional[ImageType], - geo_bounds: Optional[Tuple[float, float, float, float]], - zoom_level: Optional[int] - ) -> None: - """Updates the current map view state attributes and displays the new map.""" - self._current_stitched_map_pil = stitched_image - self._current_map_geo_bounds_deg = geo_bounds - self._current_map_render_zoom = zoom_level - # MODIFIED: Update pixel shape only if image is not None. - # WHY: Avoids AttributeError if stitched_image is None. - # HOW: Added conditional check. - if stitched_image is not None: - self._current_stitched_map_pixel_shape = (stitched_image.height, stitched_image.width) - else: - self._current_stitched_map_pixel_shape = (0, 0) # Reset if no image - - - # Always clear the last click position when the base map image changes - # MODIFIED: Also clear the last click marker on any map state update (e.g., zoom, recenter). - # WHY: The pixel coordinates of the marker are only valid for the specific image they were drawn on. - # Clearing prevents drawing the marker in the wrong place on a new map view. - # HOW: Set _last_user_click_pixel_coords_on_displayed_image to None here. - self._last_user_click_pixel_coords_on_displayed_image = None - - # Display the new map image (draw overlays and click marker will happen later in redraw logic if needed) - # If stitched_image is None, show_map will display a placeholder. - if self.map_display_window_controller: - # Pass the *base* stitched image (overlays drawn later) or None if stitch failed - self.map_display_window_controller.show_map(stitched_image) - else: - logger.error("MapDisplayWindow controller is None, cannot show updated map.") - - - # MODIFIED: This method remains within GeoElevationMapViewer. It checks state before triggering drawing. - # It calls drawing functions from the map_drawing module. - def _trigger_map_redraw_with_overlays(self) -> None: - """ - Triggers a redraw of the current map view, reapplying persistent overlays - and the last user click marker. Called after map state changes or clicks. - """ - logger.debug("Triggering map redraw with overlays...") - # Check if we have a base image to draw on and drawing is possible - # MODIFIED: Check _can_perform_drawing_operations which includes PIL/ImageDraw/CV2/Mercantile checks. - # WHY: Ensure all dependencies and map context are ready for drawing. - # HOW: Replaced individual checks with a single call. - # MODIFIED: Adjusted condition to first check if drawing is possible, THEN if there's an image. - # WHY: The _can_perform_drawing_operations check is lighter than the image check. - # HOW: Changed the order of the condition. - if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None: - logger.warning("Cannot redraw overlays: drawing libraries/context missing or no base map image.") - # If there was a previous map displayed, ensure the placeholder is shown. - if self.map_display_window_controller: self.map_display_window_controller.show_map(None) - return # Nothing to draw on - - # Start with a fresh copy of the base stitched image - map_copy_for_drawing = self._current_stitched_map_pil.copy() - - # --- Redraw Persistent Overlays based on View Type --- - if self._current_requested_area_geo_bbox: # This is an AREA view - logger.debug("Redrawing overlays for Area View.") - # Redraw the requested area boundary (blue) - # MODIFIED: Call imported drawing function, pass map context. - map_copy_for_drawing = map_drawing.draw_area_bounding_box( - map_copy_for_drawing, - self._current_requested_area_geo_bbox, - self._current_map_geo_bounds_deg, # Pass context - self._current_stitched_map_pixel_shape # Pass context - # color and thickness are default in draw_area_bounding_box - ) - # Redraw the DEM tile boundaries and labels (red) for all relevant tiles in the area view - if self._dem_tiles_info_for_current_map_area_view: - # MODIFIED: Call imported drawing function, pass map context. - map_copy_for_drawing = map_drawing.draw_dem_tile_boundaries_with_labels( - map_copy_for_drawing, - self._dem_tiles_info_for_current_map_area_view, - self._current_map_geo_bounds_deg, # Pass context - self._current_map_render_zoom, # Pass context - self._current_stitched_map_pixel_shape # Pass context - ) - - elif self._dem_tile_geo_bbox_for_current_point_view: # This is a POINT view (and DEM was available) - logger.debug("Redrawing overlays for Point View.") - # Redraw the single DEM tile boundary (red) - # MODIFIED: Call imported drawing function, pass map context. - map_copy_for_drawing = map_drawing.draw_dem_tile_boundary( - map_copy_for_drawing, - self._dem_tile_geo_bbox_for_current_point_view, - self._current_map_geo_bounds_deg, # Pass context - self._current_stitched_map_pixel_shape # Pass context - ) - - else: - # Neither area bbox nor single DEM tile bbox is stored. No persistent overlays to redraw. - logger.debug("No persistent overlays (Area box or DEM boundary) stored for redrawing.") - pass # map_copy_for_drawing is just the base stitched image - - - # --- Redraw User Click Marker --- - # The draw_user_click_marker function itself checks if a click position is stored. - # MODIFIED: Call imported drawing function, pass map context. - map_with_latest_click_marker = map_drawing.draw_user_click_marker( - map_copy_for_drawing, - self._last_user_click_pixel_coords_on_displayed_image, - self.current_display_scale_factor, # Pass current scale - self._current_stitched_map_pixel_shape # Pass context - ) - - - # --- Display the Final Image --- - # show_map handles the final scaling before displaying - if map_with_latest_click_marker and self.map_display_window_controller: - self.map_display_window_controller.show_map(map_with_latest_click_marker) - logger.debug("Map redraw complete.") - else: - logger.warning("Final image for redraw is None or MapDisplayWindow not available.") - # If final image is None but base was not, show the base image without marker. - if self._current_stitched_map_pil and self.map_display_window_controller: - logger.warning("Failed to draw click marker. Showing base map with persistent overlays.") - self.map_display_window_controller.show_map(map_copy_for_drawing) # Show the image with only persistent overlays - elif self.map_display_window_controller: - # If base image was None too, ensure placeholder is shown. - self.map_display_window_controller.show_map(None) - - - def _calculate_bbox_for_zoom_level( - self, - center_latitude: float, - center_longitude: float, - target_zoom: int - ) -> Optional[Tuple[float, float, float, float]]: - """ - Calculates a geographic bounding box centered on a point at a given zoom level - designed to fit within a target pixel size (e.g., the scaled window size). - """ - if self.map_display_window_controller is None or self.map_service_provider is None: - logger.error("MapDisplayWindow or MapService is None, cannot calculate bbox for zoom.") - return None - - # Use the current dimensions of the *scaled, displayed* window as the target pixel size. - # This ensures that zooming keeps the map approximately filling the window. - # The MapDisplayWindow keeps track of the shape of the image it actually displays after scaling. - displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape - - if displayed_w <= 0 or displayed_h <= 0: - logger.warning("Displayed map dimensions are zero or invalid, cannot calculate zoom bbox accurately. Using fallback target pixel size.") - # Fallback target pixel size if displayed dimensions are zero (e.g., before first map is shown) - # Scale target pixel dim by current display scale. - target_px_w = int(TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW * self.current_display_scale_factor) # Use default target scaled by current scale - target_px_h = int(TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW * self.current_display_scale_factor) - # Ensure min sensible size - target_px_w = max(256, target_px_w) - target_px_h = max(256, target_px_h) - - else: - target_px_w, target_px_h = displayed_w, displayed_h # Use current displayed size - - - # Calculate the geographic bbox needed for the new zoom level, centered at the click point - # MODIFIED: Call the imported calculate_geographic_bbox_from_pixel_size_and_zoom. - # WHY: This logic has been moved to map_utils. - # HOW: Call the function using the imported module name. - calculated_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom( - center_latitude, - center_longitude, - target_px_w, - target_px_h, - target_zoom, - self.map_service_provider.tile_size # Use the tile size of the current service - ) - - return calculated_bbox - - - # MODIFIED: New method to reset the map view to the initial state. - # WHY: Implement reset functionality. - # HOW: Recalls either display_map_for_point or display_map_for_area based on initial parameters. - def _reset_to_initial_view(self) -> None: - """Resets the map view to the state it was in when first displayed.""" - logger.info("Resetting map view to initial state...") - - # Check which type of view was initially requested and re-trigger it. - # This requires storing the initial operation mode and its parameters. - # Let's add attributes for initial operation mode and parameters. - - # *** Need to add _initial_operation_mode, _initial_point_coords, _initial_area_bbox attributes in __init__. *** - # *** Set them in display_map_for_point/area. *** - - # Assuming these attributes are now stored: - if hasattr(self, '_initial_operation_mode') and self._initial_operation_mode == "point" and hasattr(self, '_initial_point_coords') and self._initial_point_coords is not None: - logger.debug("Restoring initial POINT view state by re-triggering display_map_for_point.") - lat, lon = self._initial_point_coords - # Clear current map state *before* calling the display function, to ensure it's treated as a new display request. - # MODIFIED: Use the helper method to clear state. - self._update_current_map_state(None, None, None) - # Clear view-specific states as they will be set by display_map_for_point logic - self._dem_tile_geo_bbox_for_current_point_view = None - self._current_requested_area_geo_bbox = None # Ensure this is cleared for point view - self._dem_tiles_info_for_current_map_area_view = [] # Ensure this is cleared for point view - - # Call the logic to load the initial point view. - # This logic is now in _load_and_display_initial_view, which uses the _initial_ parameters. - # Since we are *resetting* based on stored _initial_ parameters, we just need to call the loading logic. - # The _load_and_display_initial_view method itself uses self._initial_operation_mode etc. - # So, we don't need to pass lat, lon here, just call the loader. - # However, the current _load_and_display_initial_view reads from _initial_*. - # The simplest is to re-call _load_and_and_display_initial_view. - - # Let's re-trigger the logic that loads the initial view based on the _initial_* attributes. - self._load_and_display_initial_view() - # The loader handles setting all _current_... and _initial_... again, sending info, and first redraw. - - - elif hasattr(self, '_initial_operation_mode') and self._initial_operation_mode == "area" and hasattr(self, '_initial_area_bbox') and self._initial_area_bbox is not None: - logger.debug("Restoring initial AREA view state by re-triggering display_map_for_area.") - bbox = self._initial_area_bbox - # Clear current map state *before* calling the display function - # MODIFIED: Use the helper method to clear state. - self._update_current_map_state(None, None, None) - # Clear view-specific states as they will be set by display_map_for_area logic - self._dem_tile_geo_bbox_for_current_point_view = None # Ensure this is cleared for area view - # _current_requested_area_geo_bbox and _dem_tiles_info_for_current_map_area_view will be set by display_map_for_area logic - - # Call the logic to load the initial area view. - # Same as point view, just call _load_and_display_initial_view. - self._load_and_display_initial_view() - # The loader handles everything else. - - - else: - logger.error("Initial view parameters were not stored or are incomplete. Cannot perform reset.") - # Send info to GUI - # MODIFIED: Include DMS fields with error state. - self._send_map_info_update_to_gui(None, None, "Reset Error: No Init Params", "Map Error") # Added DMS handled in send function - # Ensure placeholder is shown - if self.map_display_window_controller: self.map_display_window_controller.show_map(None) - # Clear current state - self._update_current_map_state(None, None, None) - # Clear view specific states - self._dem_tile_geo_bbox_for_current_point_view = None - self._current_requested_area_geo_bbox = None - self._dem_tiles_info_for_current_map_area_view = [] - - - # MODIFIED: Added a dedicated helper function to send map info updates to the GUI. - # WHY: Centralizes the logic for sending information like click coordinates, elevation, and map area size. - # Called by handle_map_click_event and _load_and_display_initial_view. - # HOW: Created a new method that formats the payload and puts it into the queue. - # MODIFIED: Updated to include DMS strings in the payload sent to the GUI. - # WHY: The GUI now expects to receive DMS strings directly from the map process for click updates. - # HOW: Calculate DMS strings using map_utils.deg_to_dms_string and add them to the payload dictionary. - def _send_map_info_update_to_gui( - self, - latitude: Optional[float], - longitude: Optional[float], - elevation_str: str, - map_area_size_str: str - ) -> None: - """Sends map info (coords, elevation, map size) to the GUI queue.""" - # MODIFIED: Calculate DMS strings for latitude and longitude if available. - # WHY: To send DMS format back to the GUI for display. - # HOW: Use map_utils.deg_to_dms_string. Handle None coords. - lat_dms_str = "N/A" - lon_dms_str = "N/A" - if latitude is not None and math.isfinite(latitude): - # MODIFIED: Use the imported deg_to_dms_string function. - # WHY: Perform the conversion here before sending to GUI. - # HOW: Call the function. - lat_dms_str = map_utils.deg_to_dms_string(latitude, 'lat') - if longitude is not None and math.isfinite(longitude): - # MODIFIED: Use the imported deg_to_dms_string function. - # WHY: Perform the conversion here before sending to GUI. - # HOW: Call the function. - lon_dms_str = map_utils.deg_to_dms_string(longitude, 'lon') - - - payload_to_gui = { - "type": "map_info_update", # Use a distinct type for initial/map state updates - "latitude": latitude, # Send float latitude - "longitude": longitude, # Send float longitude - "latitude_dms_str": lat_dms_str, # Send DMS latitude string - "longitude_dms_str": lon_dms_str, # Send DMS longitude string - "elevation_str": elevation_str, - "map_area_size_str": map_area_size_str - } - try: - self.gui_com_queue.put(payload_to_gui) - logger.debug(f"Sent map_info_update to GUI queue: {payload_to_gui}") - except Exception as e_queue_info: - logger.exception(f"Error putting map info onto GUI queue: {e_queue_info}") - - - # MODIFIED: Added a new helper function to send map fetching status updates to the GUI. - # WHY: To provide feedback to the user in the GUI while the map viewer is downloading/stitching tiles. - # HOW: Created a new method that formats a status message payload and puts it into the queue. - def _send_map_fetching_status_to_gui(self, status_message: str) -> None: - """Sends a map fetching status message to the GUI queue.""" - payload_to_gui = { - "type": "map_fetching_status", # Use a distinct type for fetching status updates - "status": status_message - } - try: - self.gui_com_queue.put(payload_to_gui) - logger.debug(f"Sent map_fetching_status to GUI queue: {payload_to_gui}") - except Exception as e_queue_status: - logger.exception(f"Error putting map fetching status onto GUI queue: {e_queue_status}") - - - def shutdown(self) -> None: - """Cleans up resources, particularly the map display window controller.""" - logger.info("Shutting down GeoElevationMapViewer and its display window controller.") - # MODIFIED: Reset stored map context on shutdown. - # WHY: Ensure a clean state if the map viewer process is restarted. - # HOW: Reset attributes to None. - self._current_stitched_map_pil = None - self._current_map_geo_bounds_deg = None - self._current_map_render_zoom = None - self._current_stitched_map_pixel_shape = (0, 0) # Reset to default tuple - self._last_user_click_pixel_coords_on_displayed_image = None - # MODIFIED: Clear view specific state attributes on shutdown. - # WHY: Clean state. - # HOW: Reset attributes. - self._dem_tile_geo_bbox_for_current_point_view = None - self._current_requested_area_geo_bbox = None - self._dem_tiles_info_for_current_map_area_view = [] - - # MODIFIED: Clear initial view state attributes on shutdown. - # WHY: Clean state for reset functionality. - # HOW: Reset attributes. - if hasattr(self, '_initial_operation_mode'): del self._initial_operation_mode - if hasattr(self, '_initial_point_coords'): del self._initial_point_coords - if hasattr(self, '_initial_area_bbox'): del self._initial_area_bbox - - - if self.map_display_window_controller: - self.map_display_window_controller.destroy_window() - self.map_display_window_controller = None # Clear reference - - logger.info("GeoElevationMapViewer shutdown procedure complete.") - - # MODIFIED: Added the missing method handle_map_click_event. - # WHY: The MapDisplayWindow calls this method when a mouse click occurs. - # It was defined in a previous version's plan but not fully implemented/included. - # This is where the core logic for processing clicks, getting elevation, - # and updating the GUI and map view happens. - # HOW: Defined the method to receive event type, pixel coordinates, and flags, - # perform pixel-to_geo conversion, get elevation, update GUI queue, and trigger redraw. - # MODIFIED: Added logic to handle Right Click for zoom out. - # WHY: To add the requested zoom out functionality. - # HOW: Added an `elif is_right_click:` block to process Right Click events, - # decreasing the zoom level and triggering a map reload centered on the click point. - # MODIFIED: Added logic to handle Ctrl + Left Click for panning (recenter without zoom change). - # WHY: To implement the requested panning functionality. - # HOW: Added an `elif is_ctrl_held:` block within the `if is_left_click:` block to process Ctrl + Left Click events. - # This block uses logic similar to zoom in/out but keeps the current zoom level. - def handle_map_click_event(self, event_type: int, x_pixel: int, y_pixel: int, flags: int) -> None: - """ - Handles mouse click events received from the MapDisplayWindow. - Converts pixel coordinates to geographic, retrieves elevation (Left Click), - recensers/zooms (Shift+Left Click, Right Click), pans (Ctrl+Left Click), - sends info to GUI, and triggers map redraw to show click marker. - - Args: - event_type: OpenCV mouse event type (e.g., cv2.EVENT_LBUTTONDOWN). - x_pixel: X coordinate of the click on the *scaled* displayed image. - y_pixel: Y coordinate of the click on the *scaled* displayed image. - flags: OpenCV mouse event flags (e.g., indicates modifier keys like Shift, Ctrl). - """ - # MODIFIED: Added check for CV2/NumPy availability to avoid NameError if flags are checked later. - # WHY: The 'flags' value is an integer, but checking for specific flags like cv2.EVENT_FLAG_SHIFTKEY - # requires cv2 to be available. - # HOW: Added an initial check. - if not CV2_NUMPY_LIBS_AVAILABLE: - logger.error("Cannot handle map click event: OpenCV/NumPy not available.") - # Send an error message to GUI? Maybe too verbose. - return - - logger.debug(f"Handling map click event type {event_type} at scaled pixel ({x_pixel},{y_pixel}) with flags {flags}.") - - # --- Determine Action Based on Event Type and Flags --- - is_left_click = event_type == cv2.EVENT_LBUTTONDOWN # type: ignore - is_right_click = event_type == cv2.EVENT_RBUTTONDOWN # type: ignore - is_shift_held = (flags & cv2.EVENT_FLAG_SHIFTKEY) != 0 # type: ignore # Check if Shift flag is set - # MODIFIED: Added check for Ctrl flag. - # WHY: To detect Ctrl + Click events. - # HOW: Used the bitwise AND operator with cv2.EVENT_FLAG_CTRLKEY. - is_ctrl_held = (flags & cv2.EVENT_FLAG_CTRLKEY) != 0 # type: ignore # Check if Ctrl flag is set - - - # Process Left Clicks (Standard for Elevation, Shift for Zoom In/Recenter, Ctrl for Pan/Recenter) - if is_left_click: - if is_shift_held: - logger.info(f"Shift + Left Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to recenter and zoom IN.") - # --- Recenter and Zoom IN --- - # The core logic for recentering and zooming (increase zoom level) is already here. - # We will reuse this logic for the zoom-in part. - - if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.map_tile_fetch_manager is None or self.map_service_provider is None: - logger.warning("Cannot recenter/zoom IN: Missing current map context or map controller/manager/service.") - self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Context N/A", "Map Error") - return - - # 1. Convert clicked pixel on *scaled* image to geographic coordinates (to use as new center). - displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape - if displayed_w <= 0 or displayed_h <= 0: - logger.error("Cannot recenter/zoom IN: Invalid displayed map dimensions.") - self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Display Dims N/A", "Map Error") - return - - clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map( - x_pixel, y_pixel, - self._current_map_geo_bounds_deg, - (displayed_h, displayed_w), - self._current_map_render_zoom - ) - - if clicked_geo_coords is None: - logger.warning(f"Zoom In failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).") - self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Geo Conv Error", "Map Error") - return - - clicked_lat, clicked_lon = clicked_geo_coords - logger.debug(f"Shift+Left Clicked Geo Coords (Recenter/Zoom In): ({clicked_lat:.5f}, {clicked_lon:.5f}).") - - - # 2. Determine new zoom level: Increase by 1. - new_zoom_level = self._current_map_render_zoom + 1 - # Clamp zoom to service max zoom - if self.map_service_provider and new_zoom_level > self.map_service_provider.max_zoom: - new_zoom_level = self.map_service_provider.max_zoom - logger.warning(f"Shift+Left Click zoom IN clamped to service max zoom: {new_zoom_level}.") - - # Ensure minimum zoom level (should not be needed when zooming in, but defensive) - if new_zoom_level < 0: new_zoom_level = 0 - - - logger.info(f"Attempting to recenter map on ({clicked_lat:.5f},{clicked_lon:.5f}) at new zoom level {new_zoom_level} (Zoom In).") - - # 3. Calculate the geographic bounding box for the new view, centered on the click point. - new_map_fetch_geo_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom( - clicked_lat, clicked_lon, - displayed_w, displayed_h, # Target pixel dimensions (use the current displayed size) - new_zoom_level, - self.map_service_provider.tile_size - ) - - - if new_map_fetch_geo_bbox is None: - logger.warning("Zoom In failed: Could not calculate new map fetch BBox.") - self._send_map_info_update_to_gui(None, None, "Zoom In Failed: BBox Calc Error", "Map Error") - return - - # 4. Fetch and Stitch the new map area. - self._send_map_fetching_status_to_gui(f"Fetching map (Zoom In) zoom {new_zoom_level}...") - - try: - map_tile_xy_ranges = map_utils.get_tile_ranges_for_bbox(new_map_fetch_geo_bbox, new_zoom_level) - - if not map_tile_xy_ranges: - logger.warning("Zoom In failed: No map tile ranges found for the new BBox/zoom.") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, "Zoom In Failed: No Map Tiles", "Map Tiles N/A") - return - - stitched_pil = self.map_tile_fetch_manager.stitch_map_image( - new_zoom_level, map_tile_xy_ranges[0], map_tile_xy_ranges[1] - ) - - if not stitched_pil: - logger.error("Zoom In failed: Failed to stitch new map image.") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Stitch Failed", "Map Stitch Failed") - return - - # 5. Update current map state and trigger redraw. - actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range( - new_zoom_level, map_tile_xy_ranges - ) - - # Update state with the newly fetched/stitched map (this clears the old click marker) - self._update_current_map_state(stitched_pil, actual_stitched_bounds, new_zoom_level) - - # Calculate and send the map area size of the NEW stitched area. - map_area_size_str = "N/A" - if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore - size_km = map_utils.calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (Zoomed View)" - map_area_size_str += f" Z[{new_zoom_level}]" # Add zoom level info - else: - map_area_size_str = "Size Calc Failed" - elif self._current_map_geo_bounds_deg: - map_area_size_str = "PyProj N/A (Size Unknown)" - - # Send the updated map info (centered coords, elevation N/A for now, new area size) - self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "N/A (Zoom In)", map_area_size_str) # Use helper - - # Trigger redraw to show the new map (click marker is cleared by state update) - self._trigger_map_redraw_with_overlays() # This adds the click marker automatically if set (but it's None here) - - except Exception as e_zoom_in_fetch: - logger.exception(f"Unexpected error during zoom IN fetch/stitch: {e_zoom_in_fetch}") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, f"Zoom In Error: {type(e_zoom_in_fetch).__name__}", "Map Error") - - # MODIFIED: Added block for Ctrl + Left Click for Pan/Recenter. - # WHY: To implement the panning functionality without changing zoom. - # HOW: Checked for `is_ctrl_held`. Copied and adapted logic from zoom handlers, keeping the current zoom level. - elif is_ctrl_held: - logger.info(f"Ctrl + Left Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to recenter (Pan).") - # --- Recenter (Pan) --- - # Logic is similar to Zoom In/Out, but keeps the *current* zoom level. - - if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.map_tile_fetch_manager is None or self.map_service_provider is None: - logger.warning("Cannot recenter (Pan): Missing current map context or map controller/manager/service.") - self._send_map_info_update_to_gui(None, None, "Pan Failed: Context N/A", "Map Error") - return - - # 1. Convert clicked pixel on *scaled* image to geographic coordinates (to use as new center). - displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape - if displayed_w <= 0 or displayed_h <= 0: - logger.error("Cannot recenter (Pan): Invalid displayed map dimensions.") - self._send_map_info_update_to_gui(None, None, "Pan Failed: Display Dims N/A", "Map Error") - return - - clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map( - x_pixel, y_pixel, - self._current_map_geo_bounds_deg, - (displayed_h, displayed_w), - self._current_map_render_zoom - ) - - if clicked_geo_coords is None: - logger.warning(f"Pan failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).") - self._send_map_info_update_to_gui(None, None, "Pan Failed: Geo Conv Error", "Map Error") - return - - clicked_lat, clicked_lon = clicked_geo_coords - logger.debug(f"Ctrl+Left Clicked Geo Coords (Pan): ({clicked_lat:.5f}, {clicked_lon:.5f}).") - - - # 2. Determine new zoom level: Keep the current zoom level. - target_zoom_level = self._current_map_render_zoom - - logger.info(f"Attempting to recenter map on ({clicked_lat:.5f},{clicked_lon:.5f}) at current zoom level {target_zoom_level} (Pan).") - - - # 3. Calculate the geographic bounding box for the new view, centered on the click point. - # Use the *current* zoom level. Target pixel dimensions are the current displayed size. - new_map_fetch_geo_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom( - clicked_lat, clicked_lon, - displayed_w, displayed_h, # Target pixel dimensions (use the current displayed size) - target_zoom_level, - self.map_service_provider.tile_size - ) - - if new_map_fetch_geo_bbox is None: - logger.warning("Pan failed: Could not calculate new map fetch BBox.") - self._send_map_info_update_to_gui(None, None, "Pan Failed: BBox Calc Error", "Map Error") - return - - # 4. Fetch and Stitch the new map area. - self._send_map_fetching_status_to_gui(f"Panning map zoom {target_zoom_level}...") - - try: - map_tile_xy_ranges = map_utils.get_tile_ranges_for_bbox(new_map_fetch_geo_bbox, target_zoom_level) - - if not map_tile_xy_ranges: - logger.warning("Pan failed: No map tile ranges found for the new BBox/zoom.") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, "Pan Failed: No Map Tiles", "Map Tiles N/A") - return - - stitched_pil = self.map_tile_fetch_manager.stitch_map_image( - target_zoom_level, map_tile_xy_ranges[0], map_tile_xy_ranges[1] - ) - - if not stitched_pil: - logger.error("Pan failed: Failed to stitch new map image.") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, "Pan Failed: Stitch Failed", "Map Stitch Failed") - return - - # 5. Update current map state and trigger redraw. - actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range( - target_zoom_level, map_tile_xy_ranges - ) - - # Update state with the newly fetched/stitched map (this clears the old click marker) - self._update_current_map_state(stitched_pil, actual_stitched_bounds, target_zoom_level) - - # Calculate and send the map area size of the NEW stitched area. - map_area_size_str = "N/A" - if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore - size_km = map_utils.calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (Pan View)" - map_area_size_str += f" Z[{target_zoom_level}]" # Add zoom level info - else: - map_area_size_str = "Size Calc Failed" - elif self._current_map_geo_bounds_deg: - map_area_size_str = "PyProj N/A (Size Unknown)" - - # Send the updated map info (centered coords, elevation N/A for now, new area size) - self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "N/A (Pan)", map_area_size_str) - - # Trigger redraw to show the new map (click marker is cleared by state update) - self._trigger_map_redraw_with_overlays() # This adds the click marker automatically if set (but it's None here) - - except Exception as e_pan_fetch: - logger.exception(f"Unexpected error during pan fetch/stitch: {e_pan_fetch}") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, f"Pan Error: {type(e_pan_fetch).__name__}", "Map Error") - - - else: # Standard Left Click (no modifiers: Shift or Ctrl) - logger.info(f"Standard Left Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to get elevation.") - # --- Process Standard Left Click (Get Elevation) --- - # The core logic for getting elevation is already here. - - if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.elevation_manager is None: - logger.warning("Cannot get elevation for click: Missing current map context or elevation manager.") - self._send_map_info_update_to_gui(None, None, "Elev Failed: Context N/A", "Map Error") - return - - # 1. Convert clicked pixel on *scaled* image to geographic coordinates. - displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape - if displayed_w <= 0 or displayed_h <= 0: - logger.error("Cannot get elevation for click: Invalid displayed map dimensions.") - self._send_map_info_update_to_gui(None, None, "Elev Failed: Display Dims N/A", "Map Error") - return - - clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map( - x_pixel, y_pixel, - self._current_map_geo_bounds_deg, - (displayed_h, displayed_w), - self._current_map_render_zoom - ) - - if clicked_geo_coords is None: - logger.warning(f"Elevation lookup failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).") - self._send_map_info_update_to_gui(None, None, "Elev Failed: Geo Conv Error", "Map Error") - return - - clicked_lat, clicked_lon = clicked_geo_coords - logger.info(f"Clicked Geo Coords: ({clicked_lat:.5f}, {clicked_lon:.5f}). Requesting elevation...") - - - # 2. Get Elevation for the clicked point using the ElevationManager instance. - self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "Fetching Elevation...", "Map Area Shown") - - try: - elevation_value = self.elevation_manager.get_elevation(clicked_lat, clicked_lon) - - # 3. Update GUI with elevation result. - if elevation_value is None: - elevation_str_for_gui = "Unavailable" - logger.warning(f"Elevation data unavailable for clicked point ({clicked_lat:.5f},{clicked_lon:.5f}).") - elif isinstance(elevation_value, float) and math.isnan(elevation_value): - elevation_str_for_gui = "NoData" - logger.info(f"Clicked point ({clicked_lat:.5f},{clicked_lon:.5f}) is on a NoData area.") - else: - elevation_str_for_gui = f"{elevation_value:.2f} m" - logger.info(f"Elevation found for clicked point ({clicked_lat:.5f},{clicked_lon:.5f}): {elevation_str_for_gui}") - - # Send the updated point info (coords and elevation) to the GUI. - # Map area size doesn't change on a click, keep showing the size of the currently displayed map patch. - map_area_size_str_for_gui = "N/A" - if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore - size_km = map_utils.calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - map_area_size_str_for_gui = f"{width_km:.2f} km W x {height_km:.2f} km H (Map Area Shown)" - # MODIFIED: Add zoom level to area size string for more info. - # WHY: Provides helpful context in the GUI. - # HOW: Appended " Z[#]" to the string. - if self._current_map_render_zoom is not None: - map_area_size_str_for_gui += f" Z[{self._current_map_render_zoom}]" - - elif self._current_requested_area_geo_bbox: # If it was an area view but size calc failed - map_area_size_str_for_gui = "Size Calc Failed (Area View)" - else: - map_area_size_str_for_gui = "Size Calc Failed" # For point view - elif self._current_map_geo_bounds_deg: - map_area_size_str_for_gui = "PyProj N/A (Size Unknown)" - - - self._send_map_info_update_to_gui(clicked_lat, clicked_lon, elevation_str_for_gui, map_area_size_str_for_gui) - - # 4. Store the clicked pixel coordinates and trigger map redraw to show the marker. - self._last_user_click_pixel_coords_on_displayed_image = (x_pixel, y_pixel) - self._trigger_map_redraw_with_overlays() # Redraw with the new marker - - except Exception as e_elev_lookup: - logger.exception(f"Unexpected error during elevation lookup for click ({clicked_lat:.5f},{clicked_lon:.5f}): {e_elev_lookup}") - self._send_map_info_update_to_gui(clicked_lat, clicked_lon, f"Elev Error: {type(e_elev_lookup).__name__}", "Map Area Shown") - # Clear the last click marker on error - self._last_user_click_pixel_coords_on_displayed_image = None - self._trigger_map_redraw_with_overlays() # Redraw without marker - - - # --- Process Right Clicks --- - elif is_right_click: - logger.info(f"Right Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to recenter and zoom OUT.") - # --- Recenter and Zoom OUT --- - # Logic is very similar to Shift+Left Click (Zoom In), but decreasing the zoom level. - - if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.map_tile_fetch_manager is None or self.map_service_provider is None: - logger.warning("Cannot recenter/zoom OUT: Missing current map context or map controller/manager/service.") - self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Context N/A", "Map Error") - return - - # 1. Convert clicked pixel on *scaled* image to geographic coordinates (to use as new center). - displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape - if displayed_w <= 0 or displayed_h <= 0: - logger.error("Cannot recenter/zoom OUT: Invalid displayed map dimensions.") - self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Display Dims N/A", "Map Error") - return - - clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map( - x_pixel, y_pixel, - self._current_map_geo_bounds_deg, - (displayed_h, displayed_w), - self._current_map_render_zoom - ) - - if clicked_geo_coords is None: - logger.warning(f"Zoom Out failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).") - self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Geo Conv Error", "Map Error") - return - - clicked_lat, clicked_lon = clicked_geo_coords - logger.debug(f"Right Clicked Geo Coords (Recenter/Zoom Out): ({clicked_lat:.5f}, {clicked_lon:.5f}).") - - - # 2. Determine new zoom level: Decrease by 1. - new_zoom_level = self._current_map_render_zoom - 1 - # Clamp zoom to minimum (zoom 0) - if new_zoom_level < 0: - new_zoom_level = 0 - logger.warning(f"Right Click zoom OUT clamped to minimum zoom: {new_zoom_level}.") - - - logger.info(f"Attempting to recenter map on ({clicked_lat:.5f},{clicked_lon:.5f}) at new zoom level {new_zoom_level} (Zoom Out).") - - - # 3. Calculate the geographic bounding box for the new view, centered on the click point. - new_map_fetch_geo_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom( - clicked_lat, clicked_lon, - displayed_w, displayed_h, # Target pixel dimensions (use the current displayed size) - new_zoom_level, - self.map_service_provider.tile_size - ) - - if new_map_fetch_geo_bbox is None: - logger.warning("Zoom Out failed: Could not calculate new map fetch BBox.") - self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: BBox Calc Error", "Map Error") - return - - # 4. Fetch and Stitch the new map area. - self._send_map_fetching_status_to_gui(f"Fetching map (Zoom Out) zoom {new_zoom_level}...") - - try: - map_tile_xy_ranges = map_utils.get_tile_ranges_for_bbox(new_map_fetch_geo_bbox, new_zoom_level) - - if not map_tile_xy_ranges: - logger.warning("Zoom Out failed: No map tile ranges found for the new BBox/zoom.") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: No Map Tiles", "Map Tiles N/A") - return - - stitched_pil = self.map_tile_fetch_manager.stitch_map_image( - new_zoom_level, map_tile_xy_ranges[0], map_tile_xy_ranges[1] - ) - - if not stitched_pil: - logger.error("Zoom Out failed: Failed to stitch new map image.") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Stitch Failed", "Map Stitch Failed") - return - - # 5. Update current map state and trigger redraw. - actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range( - new_zoom_level, map_tile_xy_ranges - ) - - # Update state with the newly fetched/stitched map (this clears the old click marker) - self._update_current_map_state(stitched_pil, actual_stitched_bounds, new_zoom_level) - - # Calculate and send the map area size of the NEW stitched area. - map_area_size_str = "N/A" - if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore - size_km = map_utils.calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (Zoomed View)" - map_area_size_str += f" Z[{new_zoom_level}]" # Add zoom level info - else: - map_area_size_str = "Size Calc Failed" - elif self._current_map_geo_bounds_deg: - map_area_size_str = "PyProj N/A (Size Unknown)" - - # Send the updated map info (centered coords, elevation N/A for now, new area size) - self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "N/A (Zoom Out)", map_area_size_str) - - # Trigger redraw to show the new map (click marker is cleared by state update) - self._trigger_map_redraw_with_overlays() # This adds the click marker automatically if set (but it's None here) - - except Exception as e_zoom_out_fetch: - logger.exception(f"Unexpected error during zoom OUT fetch/stitch: {e_zoom_out_fetch}") - self._update_current_map_state(None, None, None) - self._send_map_info_update_to_gui(None, None, f"Zoom Out Error: {type(e_zoom_out_fetch).__name__}", "Map Error") - - else: - # Handle other click types if needed in the future (e.g., Middle click) - logger.debug(f"Ignoring unhandled click event type: {event_type}.") - pass # Do nothing for unhandled event types - - - # MODIFIED: Added a dedicated helper function to send map info updates to the GUI. - # WHY: Centralizes the logic for sending information like click coordinates, elevation, and map area size. - # Called by handle_map_click_event and _load_and_display_initial_view. - # HOW: Created a new method that formats the payload and puts it into the queue. - # MODIFIED: Updated to include DMS strings in the payload sent to the GUI. - # WHY: The GUI now expects to receive DMS strings directly from the map process for click updates. - # HOW: Calculate DMS strings using map_utils.deg_to_dms_string and add them to the payload dictionary. - def _send_map_info_update_to_gui( - self, - latitude: Optional[float], - longitude: Optional[float], - elevation_str: str, - map_area_size_str: str - ) -> None: - """Sends map info (coords, elevation, map size) to the GUI queue.""" - # MODIFIED: Calculate DMS strings for latitude and longitude if available. - # WHY: To send DMS format back to the GUI for display. - # HOW: Use map_utils.deg_to_dms_string. Handle None coords. - lat_dms_str = "N/A" - lon_dms_str = "N/A" - if latitude is not None and math.isfinite(latitude): - # MODIFIED: Use the imported deg_to_dms_string function. - # WHY: Perform the conversion here before sending to GUI. - # HOW: Call the function. - lat_dms_str = map_utils.deg_to_dms_string(latitude, 'lat') - if longitude is not None and math.isfinite(longitude): - # MODIFIED: Use the imported deg_to_dms_string function. - # WHY: Perform the conversion here before sending to GUI. - # HOW: Call the function. - lon_dms_str = map_utils.deg_to_dms_string(longitude, 'lon') - - - payload_to_gui = { - "type": "map_info_update", # Use a distinct type for initial/map state updates - "latitude": latitude, # Send float latitude - "longitude": longitude, # Send float longitude - "latitude_dms_str": lat_dms_str, # Send DMS latitude string - "longitude_dms_str": lon_dms_str, # Send DMS longitude string - "elevation_str": elevation_str, - "map_area_size_str": map_area_size_str - } - try: - self.gui_com_queue.put(payload_to_gui) - logger.debug(f"Sent map_info_update to GUI queue: {payload_to_gui}") - except Exception as e_queue_info: - logger.exception(f"Error putting map info onto GUI queue: {e_queue_info}") - - - # MODIFIED: Added a new helper function to send map fetching status updates to the GUI. - # WHY: To provide feedback to the user in the GUI while the map viewer is downloading/stitching tiles. - # HOW: Created a new method that formats a status message payload and puts it into the queue. - def _send_map_fetching_status_to_gui(self, status_message: str) -> None: - """Sends a map fetching status message to the GUI queue.""" - payload_to_gui = { - "type": "map_fetching_status", # Use a distinct type for fetching status updates - "status": status_message - } - try: - self.gui_com_queue.put(payload_to_gui) - logger.debug(f"Sent map_fetching_status to GUI queue: {payload_to_gui}") - except Exception as e_queue_status: - logger.exception(f"Error putting map fetching status onto GUI queue: {e_queue_status}") - - - def shutdown(self) -> None: - """Cleans up resources, particularly the map display window controller.""" - logger.info("Shutting down GeoElevationMapViewer and its display window controller.") - # MODIFIED: Reset stored map context on shutdown. - # WHY: Ensure a clean state if the map viewer process is restarted. - # HOW: Reset attributes to None. - self._current_stitched_map_pil = None - self._current_map_geo_bounds_deg = None - self._current_map_render_zoom = None - self._current_stitched_map_pixel_shape = (0, 0) # Reset to default tuple - self._last_user_click_pixel_coords_on_displayed_image = None - # MODIFIED: Clear view specific state attributes on shutdown. - # WHY: Clean state. - # HOW: Reset attributes. - self._dem_tile_geo_bbox_for_current_point_view = None - self._current_requested_area_geo_bbox = None - self._dem_tiles_info_for_current_map_area_view = [] - - # MODIFIED: Clear initial view state attributes on shutdown. - # WHY: Clean state for reset functionality. - # HOW: Reset attributes. - if hasattr(self, '_initial_operation_mode'): del self._initial_operation_mode - if hasattr(self, '_initial_point_coords'): del self._initial_point_coords - if hasattr(self, '_initial_area_bbox'): del self._initial_area_bbox - - - if self.map_display_window_controller: - self.map_display_window_controller.destroy_window() - self.map_display_window_controller = None # Clear reference - - logger.info("GeoElevationMapViewer shutdown procedure complete.") \ No newline at end of file diff --git a/geoelevation/map_viewer/map_display.py b/geoelevation/map_viewer/map_display.py deleted file mode 100644 index 0ffbca7..0000000 --- a/geoelevation/map_viewer/map_display.py +++ /dev/null @@ -1,630 +0,0 @@ -# geoelevation/map_viewer/map_display.py -""" -Manages the dedicated OpenCV window for displaying map tiles. - -This module handles the creation and updating of an OpenCV window, -displaying map images. It applies a display scale factor (provided by its -app_facade) to the incoming map image before rendering. It also captures -mouse events within the map window and performs conversions between pixel -coordinates (on the displayed, scaled image) and geographic coordinates, -relying on the 'mercantile' library for Web Mercator projections. -""" - -# Standard library imports -import logging -import sys # Import sys for logging stream -from typing import Optional, Tuple, Any # 'Any' for app_facade type hint - -# Third-party imports -try: - from PIL import Image - ImageType = Image.Image # type: ignore - PIL_LIB_AVAILABLE_DISPLAY = True -except ImportError: - Image = None # type: ignore - ImageType = None # type: ignore - PIL_LIB_AVAILABLE_DISPLAY = False - # Logging might not be set up if this is imported very early by a child process - # So, direct print or rely on higher-level logger configuration. - print("ERROR: MapDisplay - Pillow (PIL) library not found. Image conversion from PIL might fail.") - - -try: - import cv2 # OpenCV for windowing and drawing - import numpy as np - CV2_NUMPY_LIBS_AVAILABLE_DISPLAY = True -except ImportError: - cv2 = None # type: ignore - np = None # type: ignore - CV2_NUMPY_LIBS_AVAILABLE_DISPLAY = False - print("ERROR: MapDisplay - OpenCV or NumPy not found. Drawing and image operations will fail.") - -try: - import mercantile # For Web Mercator tile calculations and coordinate conversions - MERCANTILE_LIB_AVAILABLE_DISPLAY = True -except ImportError: - mercantile = None # type: ignore - MERCANTILE_LIB_AVAILABLE_DISPLAY = False - print("ERROR: MapDisplay - 'mercantile' library not found. Coordinate conversions will fail.") - - -# Module-level logger -logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.map_display' - -# Default window properties (can be overridden or extended if needed) -DEFAULT_CV_WINDOW_X_POSITION = 150 -DEFAULT_CV_WINDOW_Y_POSITION = 150 - - -class MapDisplayWindow: - """ - Manages an OpenCV window for displaying map images and handling mouse interactions. - The displayed image is scaled according to a factor provided by its - app_facade. - """ - def __init__( - self, - app_facade: Any, # Instance of GeoElevationMapViewer (or similar providing scale and click handler) - window_name_str: str = "GeoElevation - Interactive Map", - initial_screen_x_pos: int = DEFAULT_CV_WINDOW_X_POSITION, - initial_screen_y_pos: int = DEFAULT_CV_WINDOW_Y_POSITION - ) -> None: - """ - Initializes the MapDisplayWindow manager. - - Args: - app_facade: An object that has a 'handle_map_click_event(event_type, x, y, flags)' method - and an attribute 'current_display_scale_factor'. - window_name_str: The name for the OpenCV window. - initial_screen_x_pos: Initial X screen position for the window. - initial_screen_y_pos: Initial Y screen position for the window. - """ - logger.info(f"Initializing MapDisplayWindow with name: '{window_name_str}'") - # MODIFIED: Added a check for critical dependencies at init. - # WHY: Ensure the class can function before proceeding. - # HOW: Raise ImportError if dependencies are missing. - if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: - critical_msg = "OpenCV and/or NumPy are not available for MapDisplayWindow operation." - logger.critical(critical_msg) - raise ImportError(critical_msg) - # PIL is needed for image conversion, but not strictly for windowing itself, - # though show_map will likely fail without it for non-numpy inputs. - # mercantile is needed for pixel-geo conversions, not for windowing. - # We'll check those where they are strictly needed. - - - self.app_facade_handler: Any = app_facade # Facade to access scale and report clicks - self.opencv_window_name: str = window_name_str - self.window_start_x_position: int = initial_screen_x_pos - self.window_start_y_position: int = initial_screen_y_pos - - self.is_opencv_window_initialized: bool = False - self.is_opencv_mouse_callback_set: bool = False - # Stores the shape (height, width) of the *scaled image actually displayed* by imshow - self._last_displayed_scaled_image_shape: Tuple[int, int] = (0, 0) - - - def show_map(self, map_pil_image_input: Optional[ImageType]) -> None: - """ - Displays the provided map image (PIL format) in the OpenCV window. - The image is first converted to BGR, then scaled using the factor - from `app_facade.current_display_scale_factor`, and then displayed. - The OpenCV window autosizes to the scaled image. - - Args: - map_pil_image_input: The map image (PIL.Image) to display. - If None, a placeholder image is shown. - """ - if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: - logger.error("Cannot show map: OpenCV/NumPy not available.") - return - - bgr_image_unscaled: Optional[np.ndarray] # type: ignore - - if map_pil_image_input is None: - logger.warning("Received None PIL image for display. Generating a placeholder.") - bgr_image_unscaled = self._create_placeholder_bgr_numpy_array() - # MODIFIED: Added more explicit check for PIL availability and instance type. - # WHY: Ensure PIL is available before attempting conversion from a PIL object. - # HOW: Included PIL_LIB_AVAILABLE_DISPLAY in the check. - elif PIL_LIB_AVAILABLE_DISPLAY and isinstance(map_pil_image_input, Image.Image): # type: ignore - logger.debug( - f"Converting input PIL Image (Size: {map_pil_image_input.size}, Mode: {map_pil_image_input.mode}) to BGR." - ) - bgr_image_unscaled = self._convert_pil_image_to_bgr_numpy_array(map_pil_image_input) - else: - # This else branch handles cases where input is not None, not a PIL Image, or PIL is not available. - logger.error( - f"Received unexpected image type for display: {type(map_pil_image_input)}. Or Pillow is missing. Using placeholder." - ) - bgr_image_unscaled = self._create_placeholder_bgr_numpy_array() - - if bgr_image_unscaled is None: # Fallback if conversion or placeholder failed - logger.error("Failed to obtain a BGR image (unscaled) for display. Using minimal black square.") - # MODIFIED: Create a minimal black image using NumPy for robustness. - # WHY: Ensure a displayable image is created even if placeholder creation fails. - # HOW: Use np.zeros. - if np: # Ensure np is available - bgr_image_unscaled = np.zeros((100, 100, 3), dtype=np.uint8) # type: ignore - else: - logger.critical("NumPy not available, cannot even create fallback black image for imshow.") - return # Cannot proceed without NumPy - - # --- Apply Display Scaling --- - scaled_bgr_image_for_display: np.ndarray = bgr_image_unscaled # type: ignore - try: - display_scale = 1.0 # Default scale if not found on facade - # MODIFIED: Added check that app_facade_handler is not None before accessing its attribute. - # WHY: Avoids AttributeError if facade is unexpectedly None. - # HOW: Check 'if self.app_facade_handler and hasattr(...)'. - if self.app_facade_handler and hasattr(self.app_facade_handler, 'current_display_scale_factor'): - # MODIFIED: Added try-except around float conversion of scale factor. - # WHY: Defend against non-numeric scale factor values. - # HOW: Use a try-except block. - try: - display_scale = float(self.app_facade_handler.current_display_scale_factor) - except (ValueError, TypeError) as e_scale_conv: - logger.warning(f"Could not convert scale factor from facade to float: {self.app_facade_handler.current_display_scale_factor}. Using 1.0. Error: {e_scale_conv}") - display_scale = 1.0 - - if display_scale <= 0: # Prevent invalid scale - logger.warning(f"Invalid scale factor {display_scale} from facade. Using 1.0.") - display_scale = 1.0 - else: - logger.warning("Display scale factor not found on app_facade. Defaulting to 1.0.") - - unscaled_h, unscaled_w = bgr_image_unscaled.shape[:2] - logger.debug( - f"Unscaled BGR image size: {unscaled_w}x{unscaled_h}. Applying display scale: {display_scale:.3f}" - ) - - # Only resize if scale is not 1.0 (with tolerance) and image dimensions are valid - if abs(display_scale - 1.0) > 1e-6 and unscaled_w > 0 and unscaled_h > 0: - target_w = max(1, int(round(unscaled_w * display_scale))) - target_h = max(1, int(round(unscaled_h * display_scale))) - - interpolation_method = cv2.INTER_LINEAR if display_scale > 1.0 else cv2.INTER_AREA # type: ignore - logger.debug(f"Resizing image from {unscaled_w}x{unscaled_h} to {target_w}x{target_h} using {interpolation_method}.") - scaled_bgr_image_for_display = cv2.resize( # type: ignore - bgr_image_unscaled, (target_w, target_h), interpolation=interpolation_method - ) - # else: no scaling needed, scaled_bgr_image_for_display remains bgr_image_unscaled - - except Exception as e_scaling_img: - logger.exception(f"Error during image scaling: {e_scaling_img}. Displaying unscaled image.") - scaled_bgr_image_for_display = bgr_image_unscaled # Fallback to unscaled - # --- End Display Scaling --- - - current_disp_h, current_disp_w = scaled_bgr_image_for_display.shape[:2] - logger.debug(f"Final scaled image shape for display: {current_disp_w}x{current_disp_h}") - - # Recreate OpenCV window if its initialized state suggests it's needed, - # or if the size of the (scaled) image to be displayed has changed. - # Only recreate if window is initialized and its size changed from the last displayed size. - # We also add a check if the window exists. - window_exists = False - try: - # Check if the window property can be retrieved without error - if CV2_NUMPY_LIBS_AVAILABLE_DISPLAY and cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_AUTOSIZE) >= 0: # type: ignore # Any property check works - window_exists = True - except cv2.error: # type: ignore - window_exists = False # Error means window is gone - - if self.is_opencv_window_initialized and window_exists and \ - (current_disp_h, current_disp_w) != self._last_displayed_scaled_image_shape: - logger.info( - f"Scaled image size changed ({self._last_displayed_scaled_image_shape} -> " - f"{(current_disp_h, current_disp_w)}). Recreating OpenCV window." - ) - try: - cv2.destroyWindow(self.opencv_window_name) # type: ignore - cv2.waitKey(5) # Allow OS to process window destruction - self.is_opencv_window_initialized = False - self.is_opencv_mouse_callback_set = False # Callback must be reset - self._last_displayed_scaled_image_shape = (0, 0) # Reset stored size - except cv2.error as e_cv_destroy: # type: ignore - logger.warning(f"Error destroying OpenCV window before recreation: {e_cv_destroy}") - self.is_opencv_window_initialized = False # Force recreation attempt - self.is_opencv_mouse_callback_set = False - self._last_displayed_scaled_image_shape = (0, 0) - - # Ensure OpenCV window exists and mouse callback is properly set - # Create window if not initialized or if it was destroyed unexpectedly - if not self.is_opencv_window_initialized or not window_exists: - logger.debug(f"Creating/moving OpenCV window: '{self.opencv_window_name}' (AUTOSIZE)") - try: - cv2.namedWindow(self.opencv_window_name, cv2.WINDOW_AUTOSIZE) # type: ignore - # Try moving the window. This might fail on some systems or if window creation is delayed. - try: - cv2.moveWindow(self.opencv_window_name, self.window_start_x_position, self.window_start_y_position) # type: ignore - except cv2.error as e_cv_move: # type: ignore - logger.warning(f"Could not move OpenCV window '{self.opencv_window_name}': {e_cv_move}") - self.is_opencv_window_initialized = True # Assume created even if move failed - logger.info(f"OpenCV window '{self.opencv_window_name}' (AUTOSIZE) is ready.") - except Exception as e_window_create: - logger.error(f"Failed to create OpenCV window '{self.opencv_window_name}': {e_window_create}") - self.is_opencv_window_initialized = False # Mark as not initialized - self.is_opencv_mouse_callback_set = False - self._last_displayed_scaled_image_shape = (0, 0) - # Cannot proceed to imshow or set mouse callback if window creation failed. - return - - # Set mouse callback if the window is initialized and callback hasn't been set - if self.is_opencv_window_initialized and not self.is_opencv_mouse_callback_set: - # MODIFIED: Added check that app_facade_handler is not None before setting callback param. - # WHY: Avoids passing None as param, although OpenCV might handle it, it's safer. - # HOW: Check 'if self.app_facade_handler:'. - if self.app_facade_handler: - try: - # MODIFIED: Set the mouse callback to _opencv_mouse_callback, passing the facade as param. - # WHY: This callback will receive all mouse events (including right click) and flags. - # HOW: Changed the function name and ensured the facade is passed as param. - cv2.setMouseCallback(self.opencv_window_name, self._opencv_mouse_callback, param=self.app_facade_handler) # type: ignore - self.is_opencv_mouse_callback_set = True - logger.info(f"Mouse callback successfully set for '{self.opencv_window_name}'.") - except cv2.error as e_cv_callback: # type: ignore - logger.error(f"Failed to set mouse callback for '{self.opencv_window_name}': {e_cv_callback}") - self.is_opencv_mouse_callback_set = False # Mark as failed to set - else: - logger.warning("App facade is None, cannot set mouse callback parameter.") - self.is_opencv_mouse_callback_set = False - - - # Display the final (scaled) image if the window is initialized - if self.is_opencv_window_initialized: - try: - cv2.imshow(self.opencv_window_name, scaled_bgr_image_for_display) # type: ignore - # Store the shape of the image that was actually displayed (scaled) - self._last_displayed_scaled_image_shape = (current_disp_h, current_disp_w) - # cv2.waitKey(1) is important for OpenCV to process GUI events. - # The main event loop for this window is expected to be handled by the - # calling process (e.g., the run_map_viewer_process_target loop). - except cv2.error as e_cv_imshow: # type: ignore - # Catch specific OpenCV errors that indicate the window is gone - error_str = str(e_cv_imshow).lower() - if "null window" in error_str or "invalid window" in error_str or "checkview" in error_str: - logger.warning(f"OpenCV window '{self.opencv_window_name}' seems closed or invalid during imshow operation.") - # Reset state flags as the window is gone - self.is_opencv_window_initialized = False - self.is_opencv_mouse_callback_set = False - self._last_displayed_scaled_image_shape = (0, 0) - else: # Re-raise other OpenCV errors if not related to window state - logger.exception(f"OpenCV error during map display (imshow): {e_cv_imshow}") - except Exception as e_disp_final: - logger.exception(f"Unexpected error displaying final map image: {e_disp_final}") - else: - logger.error("Cannot display image: OpenCV window is not initialized.") - - - # MODIFIED: Modified mouse callback signature to receive event_type and flags. - # WHY: To detect different mouse events (left/right click) and modifier key states (Shift). - # HOW: Added event_type and flags parameters. - def _opencv_mouse_callback(self, event_type: int, x_coord: int, y_coord: int, flags: int, app_facade_param: Any) -> None: - """ - Internal OpenCV mouse callback function. - Invoked by OpenCV when a mouse event occurs in the managed window. - Clamps coordinates and calls the app_facade's handler method with all event details. - 'app_facade_param' is expected to be the GeoElevationMapViewer instance. - This callback runs in the OpenCV internal thread. - """ - # MODIFIED: Process event only if it's a button press event (left, right, middle). - # WHY: Ignore mouse movements and other non-click events for simplicity. - # HOW: Check event_type against button press constants. - if event_type in [cv2.EVENT_LBUTTONDOWN, cv2.EVENT_RBUTTONDOWN, cv2.EVENT_MBUTTONDOWN]: # type: ignore - logger.debug(f"OpenCV Mouse Event: {event_type} at raw pixel ({x_coord},{y_coord}), flags: {flags}") - - # Get the dimensions of the image currently displayed (which is the scaled image) - current_displayed_height, current_displayed_width = self._last_displayed_scaled_image_shape - - if current_displayed_width <= 0 or current_displayed_height <= 0: - logger.warning("Mouse click on map, but no valid displayed image dimensions are stored.") - return # Cannot process click without knowing the displayed image size - - # Clamp clicked x, y coordinates to be within the bounds of the displayed (scaled) image - # This is important because the click coordinates can sometimes be slightly outside the window bounds, - # or the image size might momentarily not match the window size. - x_coord_clamped = max(0, min(x_coord, current_displayed_width - 1)) - y_coord_clamped = max(0, min(y_coord, current_displayed_height - 1)) - - logger.debug( - f"Map Window Click (OpenCV raw): ({x_coord},{y_coord}), " - f"Clamped to displayed image ({current_displayed_width}x{current_displayed_height}): " - f"({x_coord_clamped},{y_coord_clamped})" - ) - - # The app_facade_param should be the GeoElevationMapViewer instance. - # We call its handler method, passing the clamped pixel coordinates on the *displayed* image - # along with the event type and flags. - if app_facade_param and hasattr(app_facade_param, 'handle_map_click_event'): - try: - # MODIFIED: Call the designated handler method on the GeoElevationMapViewer instance, - # passing all relevant event details. - # WHY: GeoElevationMapViewer needs event type and flags to handle different actions (left click, right click, etc.). - # HOW: Changed method name and arguments passed. - app_facade_param.handle_map_click_event(event_type, x_coord_clamped, y_coord_clamped, flags) - logger.debug("Called facade's handle_map_click_event.") - except Exception as e_handle_click: - logger.exception(f"Error executing handle_map_click_event on app facade: {e_handle_click}") - else: - logger.error( - "app_facade_param not correctly passed to OpenCV mouse callback, or it lacks " - "the 'handle_map_click_event' method." - ) - - def pixel_to_geo_on_current_map( - self, - pixel_x_on_displayed: int, # X coordinate on the (potentially scaled) displayed image - pixel_y_on_displayed: int, # Y coordinate on the (potentially scaled) displayed image - current_map_geo_bounds: Tuple[float, float, float, float], # west, south, east, north of UN SCALED map - displayed_map_pixel_shape: Tuple[int, int], # height, width of SCALED image shown - current_map_native_zoom: int # Zoom level of the UN SCALED map data - ) -> Optional[Tuple[float, float]]: # Returns (latitude, longitude) - """ - Converts pixel coordinates from the (potentially scaled) displayed map image - to geographic WGS84 coordinates (latitude, longitude). - - This method is called by GeoElevationMapViewer.handle_map_mouse_click. - It uses the stored context of the original, unscaled map (`current_map_geo_bounds`, `current_map_native_zoom`) - and the shape of the image *actually displayed* (`displayed_map_pixel_shape`) to perform the conversion. - """ - if not MERCANTILE_LIB_AVAILABLE_DISPLAY: - logger.error("mercantile library not available for pixel_to_geo conversion.") - return None - if not (current_map_geo_bounds and displayed_map_pixel_shape and current_map_native_zoom is not None): - # This warning indicates the context needed for conversion wasn't properly stored/passed. - logger.warning("Cannot convert pixel to geo: Current map context for conversion is incomplete.") - return None - - # Dimensions of the image *as it is displayed* (after scaling) - displayed_height, displayed_width = displayed_map_pixel_shape - # Geographic bounds of the *original, unscaled* map tile data - map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds - - # Basic validation of dimensions - if displayed_width <= 0 or displayed_height <= 0: - logger.error("Cannot convert pixel to geo: Invalid displayed map dimensions.") - return None - - try: - # Use mercantile to get Web Mercator coordinates of the unscaled map's corners - map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore - map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore - - total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) - total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) - - # Handle zero dimensions in Mercator space (e.g., invalid geo bounds) - if total_map_width_merc <= 0 or total_map_height_merc <= 0: - logger.error("Cannot convert pixel to geo: Invalid Mercator dimensions for map bounds.") - return None - - # Calculate relative position of the click within the *displayed (scaled)* map (0.0 to 1.0) - # Ensure we don't divide by zero if dimensions are unexpectedly zero - relative_x_on_displayed_map = pixel_x_on_displayed / displayed_width if displayed_width > 0 else 0.0 - relative_y_on_displayed_map = pixel_y_on_displayed / displayed_height if displayed_height > 0 else 0.0 - - # Use these relative positions to find the corresponding Web Mercator coordinate - # within the *unscaled* map's Mercator extent. - # Y Mercator increases towards North, pixel Y increases downwards. So use map_ul_merc_y - ... - clicked_point_merc_x = map_ul_merc_x + (relative_x_on_displayed_map * total_map_width_merc) - clicked_point_merc_y = map_ul_merc_y - (relative_y_on_displayed_map * total_map_height_merc) - - # Convert Mercator coordinates back to geographic (longitude, latitude) - clicked_lon, clicked_lat = mercantile.lnglat(clicked_point_merc_x, clicked_point_merc_y) # type: ignore - # Return as (latitude, longitude) tuple - return (clicked_lat, clicked_lon) - except Exception as e_px_to_geo: - logger.exception(f"Error during pixel_to_geo conversion for pixel ({pixel_x_on_displayed},{pixel_y_on_displayed}): {e_px_to_geo}") - return None - - def geo_to_pixel_on_current_map( - self, - latitude_deg: float, - longitude_deg: float, - current_map_geo_bounds: Tuple[float, float, float, float], # west, south, east, north of UN SCALED map - displayed_map_pixel_shape: Tuple[int, int], # height, width of SCALED image shown - current_map_native_zoom: int # Zoom level of the UN SCALED map data - ) -> Optional[Tuple[int, int]]: # Returns (pixel_x, pixel_y) on the SCALED image - """ - Converts geographic WGS84 coordinates to pixel coordinates (x, y) - on the currently displayed (potentially scaled) map image. - - This method might be called by the app_facade (GeoElevationMapViewer) - to determine where to draw a marker on the *displayed* image, although - the current drawing implementation in GeoElevationMapViewer draws on the - *unscaled* image and relies on its own direct geo-to-pixel logic for the unscaled image. - This method is kept here for completeness and potential future use if - drawing logic were moved to this class or needed scaled coordinates. - """ - if not MERCANTILE_LIB_AVAILABLE_DISPLAY: - logger.error("mercantile library not available for geo_to_pixel conversion.") - return None - if not (current_map_geo_bounds and displayed_map_pixel_shape and current_map_native_zoom is not None): - # This warning indicates the context needed for conversion wasn't properly stored/passed. - logger.warning("Cannot convert geo to pixel: Current map context for conversion is incomplete.") - return None - - # Dimensions of the image *as it is displayed* (after scaling) - displayed_height, displayed_width = displayed_map_pixel_shape - # Geographic bounds of the *original, unscaled* map tile data - map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds - - # Basic validation of dimensions - if displayed_width <= 0 or displayed_height <= 0: - logger.error("Cannot convert geo to pixel: Invalid displayed map dimensions.") - return None - - try: - # Use mercantile to get Web Mercator coordinates of the unscaled map's corners - map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore - map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore - - total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) - total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) - - # Handle zero dimensions in Mercator space (e.g., invalid geo bounds) - if total_map_width_merc <= 0 or total_map_height_merc <= 0: - logger.error("Cannot convert geo to pixel: Invalid Mercator dimensions for map bounds.") - return None - - - # Get Web Mercator coordinates of the target geographic point - target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) # type: ignore - - # Calculate relative position of the target geo point within the *unscaled* map's Mercator extent. - # Ensure we don't divide by zero. - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 - relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards - - # Convert these relative positions to pixel coordinates on the *displayed (scaled)* image - pixel_x_on_displayed = int(round(relative_merc_x_in_map * displayed_width)) - pixel_y_on_displayed = int(round(relative_merc_y_in_map * displayed_height)) - - # Clamp to the boundaries of the displayed (scaled) image - px_clamped = max(0, min(pixel_x_on_displayed, displayed_width - 1)) - py_clamped = max(0, min(pixel_y_on_displayed, displayed_height - 1)) - return (px_clamped, py_clamped) - except Exception as e_geo_to_px: - logger.exception(f"Error during geo_to_pixel conversion for geo ({latitude_deg:.5f},{longitude_deg:.5f}): {e_geo_to_px}") - return None - - def _create_placeholder_bgr_numpy_array(self) -> np.ndarray: # type: ignore - """Creates a simple BGR NumPy array to use as a placeholder image.""" - placeholder_h = 256 # Default placeholder dimensions - placeholder_w = 256 - bgr_light_grey = (220, 220, 220) # BGR color for light grey - # MODIFIED: Added check for NumPy availability before creation. - # WHY: Defend against scenarios where NumPy is None despite initial check (unlikely but safe). - # HOW: Check 'if np:'. - if np: # type: ignore - try: - return np.full((placeholder_h, placeholder_w, 3), bgr_light_grey, dtype=np.uint8) # type: ignore - except Exception as e_np_full: - logger.exception(f"Error creating NumPy full array for placeholder: {e_np_full}. Using zeros fallback.") - # Fallback to zeros array if full() fails - return np.zeros((100, 100, 3), dtype=np.uint8) # type: ignore # Minimal array - - else: # Fallback if NumPy somehow became None (should not happen if CV2_NUMPY_AVAILABLE is true) - # This case is highly unlikely if __init__ guard passed. - logger.critical("NumPy became unavailable unexpectedly during placeholder creation.") - # Cannot create a NumPy array, return None which might cause further errors in imshow. - # This indicates a severe issue. - return None # type: ignore - - - def _convert_pil_image_to_bgr_numpy_array(self, pil_image: ImageType) -> Optional[np.ndarray]: # type: ignore - """ - Converts a PIL Image object to a NumPy BGR array for OpenCV display. - Handles different PIL modes (RGB, RGBA, L/Grayscale). - """ - # MODIFIED: Added check for PIL and CV2/NumPy availability. - # WHY: Ensure dependencies are present before attempting conversion. - # HOW: Added checks. - if not (PIL_LIB_AVAILABLE_DISPLAY and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY and pil_image): - logger.error("Cannot convert PIL to BGR: Pillow, OpenCV/NumPy missing, or input image is None.") - return None - try: - # Convert PIL image to NumPy array. This retains the number of channels. - numpy_image_array = np.array(pil_image) # type: ignore - - # Convert based on the number of channels (shape[2]) or dimension (ndim) - if numpy_image_array.ndim == 2: # Grayscale or L mode PIL image - logger.debug("Converting grayscale/L PIL image to BGR NumPy array.") - return cv2.cvtColor(numpy_image_array, cv2.COLOR_GRAY2BGR) # type: ignore - elif numpy_image_array.ndim == 3: - if numpy_image_array.shape[2] == 3: # RGB image - logger.debug("Converting RGB PIL image to BGR NumPy array.") - return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGB2BGR) # type: ignore - elif numpy_image_array.shape[2] == 4: # RGBA image (alpha channel will be stripped) - logger.debug("Converting RGBA PIL image to BGR NumPy array (stripping Alpha).") - return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGBA2BGR) # type: ignore - else: - logger.warning( - f"Unsupported NumPy image shape after PIL conversion ({numpy_image_array.shape}). Cannot convert to BGR." - ) - return None - else: # Unexpected number of dimensions - logger.warning( - f"Unexpected NumPy image dimensions ({numpy_image_array.ndim}) after PIL conversion. Cannot convert to BGR." - ) - return None - - except Exception as e_conv_pil_bgr: - logger.exception(f"Error converting PIL image to BGR NumPy array: {e_conv_pil_bgr}") - return None - - def is_window_alive(self) -> bool: - """Checks if the OpenCV window is likely still open and initialized.""" - # MODIFIED: Added check for CV2/NumPy availability. - # WHY: Prevent errors if dependencies are gone. - # HOW: Added initial check. - if not self.is_opencv_window_initialized or not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: - return False # Not initialized or OpenCV gone - try: - # WND_PROP_VISIBLE returns >= 1.0 if window is visible, 0.0 if hidden/occluded, - # and < 0 (typically -1.0) if window does not exist. - # Check for any property to see if the window handle is still valid. - # getWindowProperty returns -1 if the window does not exist. - window_property_value = cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_AUTOSIZE) # type: ignore - - # A value of -1.0 indicates the window does not exist. - if window_property_value >= 0.0: # Window exists - logger.debug(f"Window '{self.opencv_window_name}' property check >= 0.0 ({window_property_value}). Assuming alive.") - # We can also check for visibility specifically if needed: - # visibility = cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_VISIBLE) - # if visibility >= 1.0: return True else False - return True # Window exists and is likely alive - else: # Window likely closed or an issue occurred (property < 0) - logger.debug( - f"Window '{self.opencv_window_name}' property check < 0.0 ({window_property_value}). " - "Assuming it's closed or invalid for interaction." - ) - self.is_opencv_window_initialized = False # Update internal state - self.is_opencv_mouse_callback_set = False - self._last_displayed_scaled_image_shape = (0, 0) - return False - except cv2.error: # type: ignore - # OpenCV error (e.g., window name invalid/destroyed). - # This happens if the window was destroyed by user action or other means. - logger.debug(f"OpenCV error when checking property for window '{self.opencv_window_name}'. Assuming closed.") - self.is_opencv_window_initialized = False - self.is_opencv_mouse_callback_set = False - self._last_displayed_scaled_image_shape = (0, 0) - return False - except Exception as e_unexpected_alive_check: - logger.exception(f"Unexpected error checking if window '{self.opencv_window_name}' is alive: {e_unexpected_alive_check}") - self.is_opencv_window_initialized = False # Assume not alive on any other error - self.is_opencv_mouse_callback_set = False - return False - - def destroy_window(self) -> None: - """Explicitly destroys the managed OpenCV window and resets state flags.""" - logger.info(f"Attempting to destroy OpenCV window: '{self.opencv_window_name}'") - # MODIFIED: Added check for CV2/NumPy availability before destroying. - # WHY: Prevent errors if dependencies are gone. - # HOW: Added initial check. - if self.is_opencv_window_initialized and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY: - try: - cv2.destroyWindow(self.opencv_window_name) # type: ignore - # It is important to call cv2.waitKey() after destroyWindow to process the event queue - # and ensure the window is actually closed by the OS. A small delay helps. - cv2.waitKey(5) # Give OpenCV a moment to process the destruction - logger.info(f"Window '{self.opencv_window_name}' explicitly destroyed.") - except cv2.error as e_cv_destroy_final: # type: ignore - logger.warning( - f"Ignoring OpenCV error during explicit destroyWindow '{self.opencv_window_name}' " - f"(window might have been already closed by user): {e_cv_destroy_final}" - ) - except Exception as e_destroy_final_generic: - logger.exception( - f"Unexpected error during explicit destroy window '{self.opencv_window_name}': {e_destroy_final_generic}" - ) - else: - logger.debug( - f"Window '{self.opencv_window_name}' was not marked as initialized or OpenCV is not available. " - "No explicit destroy action taken." - ) - # Always reset flags after attempting destroy to ensure clean state regardless of outcome. - self.is_opencv_window_initialized = False - self.is_opencv_mouse_callback_set = False - self._last_displayed_scaled_image_shape = (0, 0) \ No newline at end of file diff --git a/geoelevation/map_viewer/map_drawing.py b/geoelevation/map_viewer/map_drawing.py deleted file mode 100644 index 996bc53..0000000 --- a/geoelevation/map_viewer/map_drawing.py +++ /dev/null @@ -1,689 +0,0 @@ -# geoelevation/map_viewer/map_drawing.py -""" -Utility functions for drawing overlays on map images (PIL). - -Handles conversions between geographic coordinates and pixel coordinates -on a stitched map image, and draws markers, boundaries, and labels. -""" - -# Standard library imports -import logging -import math -from typing import Optional, Tuple, List, Dict, Any - -# Third-party imports -try: - # MODIFIED: Ensure ImageFont is imported correctly from PIL. - # WHY: The ImageFont module is needed directly for font loading, not as a submodule of ImageDraw. - # HOW: Changed the import statement to include ImageFont at the top level. - from PIL import Image, ImageDraw, ImageFont # Import ImageFont for text size/position - PIL_LIB_AVAILABLE_DRAWING = True -except ImportError: - Image = None # type: ignore - ImageDraw = None # type: ignore # Define as None if import fails - # MODIFIED: Define dummy ImageFont as None if import fails. - # WHY: Ensures the ImageFont variable exists and is None if the import failed, - # allowing subsequent checks (e.g., `if ImageFont is None`) to work correctly. - # HOW: Added ImageFont = None. - ImageFont = None # type: ignore # Define as None if import fails - PIL_LIB_AVAILABLE_DRAWING = False - logging.error("MapDrawing: Pillow (PIL) library not found. Drawing operations will fail.") - -try: - import cv2 # OpenCV for drawing markers (optional but used) - import numpy as np # Needed by cv2, also for calculations - CV2_NUMPY_LIBS_AVAILABLE_DRAWING = True -except ImportError: - cv2 = None # type: ignore - np = None # type: ignore - CV2_NUMPY_LIBS_AVAILABLE_DRAWING = False - logging.warning("MapDrawing: OpenCV or NumPy not found. Some drawing operations (markers) will be disabled.") - -try: - import mercantile # For Web Mercator tile calculations and coordinate conversions - MERCANTILE_LIB_AVAILABLE_DRAWING = True -except ImportError: - mercantile = None # type: ignore - MERCANTILE_LIB_AVAILABLE_DRAWING = False - logging.error("MapDrawing: 'mercantile' library not found. Coordinate conversions will fail.") - -# Local application/package imports -# Import constants and potentially shared font from image_processor for consistent style -try: - # MODIFIED: Import DEFAULT_FONT from image_processor directly. - # WHY: The font loading logic in image_processor is preferred. - # HOW: Imported DEFAULT_FONT. - from geoelevation.image_processor import TILE_TEXT_COLOR, TILE_TEXT_BG_COLOR, DEFAULT_FONT - # MODIFIED: Use the imported DEFAULT_FONT directly as the initial font source for labels. - # WHY: This font is loaded based on system availability in image_processor. - # HOW: Assigned DEFAULT_FONT to _DEFAULT_FONT_FOR_LABELS. - _DEFAULT_FONT_FOR_LABELS = DEFAULT_FONT # Use the font loaded in image_processor - - # Reusing constants defined in geo_map_viewer for drawing styles - # MODIFIED: Import constants directly from geo_map_viewer. - # WHY: Use the defined constants for consistency. - # HOW: Added import. - from .geo_map_viewer import DEM_BOUNDARY_COLOR, DEM_BOUNDARY_THICKNESS_PX - from .geo_map_viewer import AREA_BOUNDARY_COLOR, AREA_BOUNDARY_THICKNESS_PX - from .geo_map_viewer import DEM_TILE_LABEL_BASE_FONT_SIZE, DEM_TILE_LABEL_BASE_ZOOM # For font scaling - -except ImportError: - # MODIFIED: Set _DEFAULT_FONT_FOR_LABELS to None if image_processor import fails. - # WHY: This variable should be None if the preferred font loading method failed. - # HOW: Set the variable to None. - _DEFAULT_FONT_FOR_LABELS = None - # Fallback constants if geo_map_viewer or image_processor constants are not available - DEM_BOUNDARY_COLOR = "red" - DEM_BOUNDARY_THICKNESS_PX = 3 - AREA_BOUNDARY_COLOR = "blue" - AREA_BOUNDARY_THICKNESS_PX = 2 - TILE_TEXT_COLOR = "white" - TILE_TEXT_BG_COLOR = "rgba(0, 0, 0, 150)" - DEM_TILE_LABEL_BASE_FONT_SIZE = 12 - DEM_TILE_LABEL_BASE_ZOOM = 10 - logging.warning("MapDrawing: Could not import style constants or default font from image_processor/geo_map_viewer. Using fallbacks.") - -# Import necessary map_utils functions -from .map_utils import get_hgt_tile_geographic_bounds # Needed for DEM bounds for drawing -from .map_utils import MapCalculationError # Re-raise calculation errors - - -# Module-level logger -logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.map_drawing' - - -# --- Helper for Geo to Pixel Conversion on Unscaled Stitched Image --- - -def _geo_to_pixel_on_unscaled_map( - latitude_deg: float, - longitude_deg: float, - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], # west, south, east, north - current_stitched_map_pixel_shape: Optional[Tuple[int, int]] # height, width -) -> Optional[Tuple[int, int]]: - """ - Converts geographic coordinates to pixel coordinates on the UN SCALED stitched PIL map image. - - Args: - latitude_deg: Latitude in degrees. - longitude_deg: Longitude in degrees. - current_map_geo_bounds: The geographic bounds of the unscaled stitched map image. - current_stitched_map_pixel_shape: The pixel shape (height, width) of the unscaled image. - - Returns: - A tuple (pixel_x, pixel_y) on the unscaled image, or None if conversion fails - or map context is incomplete. - """ - # MODIFIED: Check for necessary libraries and map context at the start. - # WHY: Ensure conditions for conversion are met. - # HOW: Added checks. - if not MERCANTILE_LIB_AVAILABLE_DRAWING or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None: - logger.warning("Map context incomplete or mercantile missing for geo_to_pixel_on_unscaled_map conversion.") - return None - - unscaled_height, unscaled_width = current_stitched_map_pixel_shape - map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds - - if unscaled_width <= 0 or unscaled_height <= 0: - logger.warning("Unscaled map dimensions are zero, cannot convert geo to pixel.") - return None - - try: - # Use mercantile to get Web Mercator coordinates of the unscaled map's corners - map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore - map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore - - total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) - total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) - - if total_map_width_merc <= 0 or total_map_height_merc <= 0: - logger.warning("Map Mercator extent is zero, cannot convert geo to pixel on unscaled map.") - return None - - target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) # type: ignore - - # Relative position within the *unscaled* map's Mercator extent - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 - relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards - - # Convert to pixel coordinates on the *unscaled* image - pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width)) - pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height)) - - # Clamp to the boundaries of the unscaled image (allow slight overflow for boundary drawing) - # This clamping logic is more appropriate for drawing lines/points near edges - # A simple clamp to [0, dim-1] might clip drawing unexpectedly. - # A padding based on line thickness is better. Let's use a default padding. - # For point markers, clamping strictly to bounds is often preferred. - # Let's keep this helper simple and clamp to the image bounds. - px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width -1)) - py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height -1)) - - return (px_clamped, py_clamped) - - except Exception as e_geo_to_px_unscaled: - logger.exception(f"Error during geo_to_pixel_on_unscaled_map conversion for geo ({latitude_deg:.5f},{longitude_deg:.5f}): {e_geo_to_px_unscaled}") - return None - - -# --- Drawing Functions (formerly methods of GeoElevationMapViewer) --- - -def draw_point_marker( - pil_image_to_draw_on: Image.Image, - latitude_deg: float, - longitude_deg: float, - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], - current_stitched_map_pixel_shape: Optional[Tuple[int, int]] -) -> Image.Image: - """ - Draws a point marker at specified geographic coordinates on the provided PIL Image. - Requires OpenCV and NumPy for drawing. - """ - # MODIFIED: Pass map context explicitly. Check for PIL_LIB_AVAILABLE_DRAWING. - # WHY: The function is now standalone and needs context. Ensure PIL is available. - if not PIL_LIB_AVAILABLE_DRAWING or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None: - logger.warning("Cannot draw point marker: PIL image or map context missing.") - return pil_image_to_draw_on # Return original image if drawing not possible - - # Use the helper method to convert geo to pixel on the UN SCALED map image. - pixel_coords_on_unscaled = _geo_to_pixel_on_unscaled_map( - latitude_deg, longitude_deg, - current_map_geo_bounds, current_stitched_map_pixel_shape # Pass context to helper - ) - - if pixel_coords_on_unscaled: - px_clamped, py_clamped = pixel_coords_on_unscaled - - logger.debug(f"Drawing point marker at unscaled pixel ({px_clamped},{py_clamped}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})") - - # MODIFIED: Check for CV2 and NumPy availability before using them. - # WHY: Ensure dependencies are present for drawing with OpenCV. - # HOW: Added check. - if CV2_NUMPY_LIBS_AVAILABLE_DRAWING and cv2 and np: - try: - # Convert PIL image to OpenCV format (BGR) for drawing - # Ensure image is in a mode OpenCV can handle (BGR) - # Converting here ensures drawing is possible if the input image was, e.g., L mode - if pil_image_to_draw_on.mode != 'RGB': - # Convert to RGB first if not already, then to BGR - map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore - else: - map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore - - - # Draw a cross marker at the calculated unscaled pixel coordinates - # Note: Marker color (0,0,255) is BGR for red - cv2.drawMarker(map_cv_bgr, (px_clamped, py_clamped), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore - # Convert back to PIL format (RGB) - return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore - except Exception as e_draw_click_cv: - logger.exception(f"Error drawing point marker with OpenCV: {e_draw_click_cv}") - return pil_image_to_draw_on # Return original image on error - else: - logger.warning("CV2 or NumPy not available, cannot draw point marker using OpenCV.") - return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here - else: - logger.warning(f"Geo-to-pixel conversion failed for ({latitude_deg:.5f},{longitude_deg:.5f}), cannot draw point marker.") - return pil_image_to_draw_on # Return original image - - -def draw_area_bounding_box( - pil_image_to_draw_on: Image.Image, - area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], - current_stitched_map_pixel_shape: Optional[Tuple[int, int]], - color: str = "blue", # Allow specifying color - thickness: int = 2 # Allow specifying thickness -) -> Image.Image: - """ - Draws an area bounding box on the provided PIL Image. - Requires PIL and ImageDraw. - """ - # MODIFIED: Pass map context explicitly. Check for PIL_LIB_AVAILABLE_DRAWING and ImageDraw. - # WHY: The function is now standalone and needs context. Ensure PIL/ImageDraw are available. - # MODIFIED: Use predefined DEM boundary style constants. - # WHY: Centralize styling. - # HOW: Pass DEM_BOUNDARY_COLOR and DEM_BOUNDARY_THICKNESS_PX to draw_area_bounding_box. - if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None: - logger.warning("Cannot draw area BBox: PIL image, ImageDraw, or map context missing.") - return pil_image_to_draw_on # Return original image if drawing not possible - - - west, south, east, north = area_geo_bbox - # Corners of the box in geographic degrees - corners_geo = [(west, north), (east, north), (east, south), (west, south)] - pixel_corners: List[Tuple[int,int]] = [] - - try: - # Convert all corners to pixel coordinates on the *unscaled* image, suitable for drawing lines. - # Recalculate pixel coords relative to the unscaled map using mercantile for line drawing accuracy, - # and clamp with padding. - unscaled_height, unscaled_width = current_stitched_map_pixel_shape - map_west_lon_stitched, map_south_lat_stitched, map_east_lon_stitched, map_north_lat_stitched = current_map_geo_bounds - - map_ul_merc_x_stitched, map_ul_merc_y_stitched = mercantile.xy(map_west_lon_stitched, map_north_lat_stitched) # type: ignore - map_lr_merc_x_stitched, map_lr_merc_y_stitched = mercantile.xy(map_east_lon_stitched, map_south_lat_stitched) # type: ignore - - total_map_width_merc = abs(map_lr_merc_x_stitched - map_ul_merc_x_stitched) - total_map_height_merc = abs(map_ul_merc_y_stitched - map_lr_merc_y_stitched) - - if total_map_width_merc <= 0 or total_map_height_merc <= 0: - raise ValueError("Map Mercator extent is zero for drawing lines.") - - import mercantile as local_mercantile # Use mercantile directly here - if local_mercantile is None: raise ImportError("mercantile not available locally.") - - - for lon, lat in corners_geo: - target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore - - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x_stitched) / total_map_width_merc if total_map_width_merc > 0 else 0.0 - relative_merc_y_in_map = (map_ul_merc_y_stitched - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards - - pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width)) - pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height)) - - # Clamp with padding for line drawing - allow coordinates slightly outside image bounds - px_clamped_for_line = max(-thickness, min(pixel_x_on_unscaled_raw, unscaled_width + thickness)) - py_clamped_for_line = max(-thickness, min(pixel_y_on_unscaled_raw, unscaled_height + thickness)) - - pixel_corners.append((px_clamped_for_line, py_clamped_for_line)) - - except Exception as e_geo_to_px_bbox: - logger.exception(f"Error during geo_to_pixel conversion for BBox drawing: {e_geo_to_px_bbox}") - return pil_image_to_draw_on # Return original image on error - - # MODIFIED: Check ImageDraw availability before using it. - # WHY: Ensure dependency is present. (Already checked at function start, but defensive) - # HOW: Added check. - if len(pixel_corners) == 4 and ImageDraw is not None: - logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}") - # Ensure image is in a mode that supports drawing (RGB or RGBA) - # Converting here ensures drawing is possible if the input image was, e.g., L mode - if pil_image_to_draw_on.mode not in ("RGB", "RGBA"): - pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") # Prefer RGBA for drawing - draw = ImageDraw.Draw(pil_image_to_draw_on) - - # Draw lines connecting the corner points - try: - draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) # Top edge - draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) # Right edge - draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) # Bottom edge - draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) # Left edge - except Exception as e_draw_lines: - logger.exception(f"Error drawing BBox lines: {e_draw_lines}") - - return pil_image_to_draw_on - else: - logger.warning("Not enough pixel corners calculated for BBox, or ImageDraw missing.") - return pil_image_to_draw_on # Return original image - - -def draw_dem_tile_boundary( - pil_image_to_draw_on: Image.Image, - dem_tile_geo_bbox: Tuple[float, float, float, float], - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], - current_stitched_map_pixel_shape: Optional[Tuple[int, int]] -) -> Image.Image: - """ - Draws a boundary box for a single DEM tile on the provided PIL Image. - Requires PIL and ImageDraw. Uses predefined DEM boundary style. - """ - # MODIFIED: Pass map context explicitly. Check for PIL_LIB_AVAILABLE_DRAWING and ImageDraw. - # WHY: The function is now standalone and needs context. Ensure PIL/ImageDraw are available. - # MODIFIED: Use predefined DEM boundary style constants. - # WHY: Centralize styling. - # HOW: Pass DEM_BOUNDARY_COLOR and DEM_BOUNDARY_THICKNESS_PX to draw_area_bounding_box. - if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_stitched_map_pixel_shape is None: - logger.warning("Cannot draw DEM tile boundary: PIL image, ImageDraw, or map context missing.") - return pil_image_to_draw_on - - logger.debug(f"Drawing DEM tile boundary on map for bbox: {dem_tile_geo_bbox}") - # Use the generic area drawing function with specific style - return draw_area_bounding_box( - pil_image_to_draw_on, - dem_tile_geo_bbox, - current_map_geo_bounds, - current_stitched_map_pixel_shape, - color=DEM_BOUNDARY_COLOR, - thickness=DEM_BOUNDARY_THICKNESS_PX - ) - - -def draw_dem_tile_boundaries_with_labels( - pil_image_to_draw_on: Image.Image, - dem_tiles_info_list: List[Dict], - current_map_geo_bounds: Optional[Tuple[float, float, float, float]], - current_map_render_zoom: Optional[int], # Needed for font scaling - current_stitched_map_pixel_shape: Optional[Tuple[int, int]] -) -> Image.Image: - """ - Draws boundaries and names for a list of DEM tiles on the provided PIL Image. - Draws only for tiles marked as available HGT. - Requires PIL, ImageDraw, and map_utils.get_hgt_tile_geographic_bounds. - """ - # MODIFIED: Pass map context explicitly. Check for necessary libraries and context. - # WHY: The function is now standalone and needs context. Ensure dependencies are available. - if not PIL_LIB_AVAILABLE_DRAWING or ImageDraw is None or pil_image_to_draw_on is None or current_map_geo_bounds is None or current_map_render_zoom is None or current_stitched_map_pixel_shape is None: - logger.warning("Cannot draw multiple DEM tile boundaries/labels: PIL image, ImageDraw, or map context missing.") - return pil_image_to_draw_on - - if not dem_tiles_info_list: - logger.debug("No DEM tile info provided for drawing multiple boundaries.") - return pil_image_to_draw_on # Nothing to draw - - logger.debug(f"Drawing {len(dem_tiles_info_list)} DEM tile boundaries and labels.") - - # Ensure image is in a mode that supports drawing (RGB or RGBA) - # Converting here ensures drawing is possible if the input image was, e.g., L mode - if pil_image_to_draw_on.mode not in ("RGB", "RGBA"): - pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") - draw = ImageDraw.Draw(pil_image_to_draw_on) - - # Attempt to use a font loaded in image_processor for consistency (_DEFAULT_FONT_FOR_LABELS) - # If that failed (e.g., image_processor not imported or font load failed there), - # fallback to default PIL font if ImageFont module is available. - font_to_use = _DEFAULT_FONT_FOR_LABELS - # MODIFIED: Corrected the fallback logic for loading the default PIL font. - # WHY: The previous attempt `ImageDraw.ImageFont.load_default()` caused an AttributeError. - # The correct way is to use `ImageFont.load_default()` if the `ImageFont` module - # was successfully imported at the top of the file. - # HOW: Changed `ImageDraw.ImageFont.load_default()` to `ImageFont.load_default()` and added a check - # `if ImageFont is not None` to ensure the module is available before calling its method. - if font_to_use is None: # If the preferred font from image_processor is not available - if PIL_LIB_AVAILABLE_DRAWING and ImageFont is not None: # Check if PIL and ImageFont module are available - try: - font_to_use = ImageFont.load_default() # type: ignore # Use the correct way to load default font - logger.debug("Using default PIL font for DEM tile labels (fallback).") - except Exception as e_default_font: - logger.warning(f"Could not load default PIL font: {e_default_font}. Cannot draw text labels.") - font_to_use = None # Ensure font_to_use is None if loading default also fails - else: - logger.debug("Pillow (PIL) or ImageFont module not available, skipping text label drawing.") - # font_to_use remains None, text drawing logic will be skipped below. - - - # MODIFIED: Calculate font size based on current map zoom. - # WHY: To make labels more readable at different zoom levels. - # HOW: Use a simple linear scaling based on a base zoom and base font size. - current_map_zoom = current_map_render_zoom # Use the zoom level the map was rendered at - if current_map_zoom is None: # Should not be None based on function signature and checks, but defensive - logger.warning("Current map zoom is None, cannot scale font. Using base size.") - current_map_zoom = DEM_TILE_LABEL_BASE_ZOOM # Default to base zoom for size calc - - # Simple linear scaling: size increases by 1 for each zoom level above base zoom - # Ensure minimum sensible font size (e.g., 6) - scaled_font_size = max(6, DEM_TILE_LABEL_BASE_FONT_SIZE + (current_map_zoom - DEM_TILE_LABEL_BASE_ZOOM)) - logger.debug(f"Calculated label font size {scaled_font_size} for zoom {current_map_zoom}.") - - # Update the font instance with the calculated size (if using truetype font) - # If load_default is used, resizing is often not possible or behaves differently. - # Let's re-load the font with the scaled size if it's a truetype font. - # This requires knowing the path of the original font used by image_processor, which is tricky. - # Alternative: Store the font path and size calculation logic from image_processor here. - # Or, maybe simpler, if using load_default fallback, just use the default size. - # Let's assume if _DEFAULT_FONT_FOR_LABELS is not None and has the 'font' attribute, it's a truetype font we can resize. - # Also need to check if ImageFont module is available before using ImageFont.truetype. - if font_to_use and hasattr(font_to_use, 'font') and ImageFont is not None: # Check if it looks like a truetype font object and ImageFont module is available - try: - # Get the original font object's path and index from Pillow's internal structure - original_font_path = font_to_use.font.path # type: ignore - font_index = font_to_use.font.index # type: ignore # Handle TTC files - # MODIFIED: Call ImageFont.truetype correctly. - # WHY: Ensure the call uses the correctly imported module. - # HOW: Used ImageFont.truetype instead of ImageDraw.ImageFont.truetype. - font_to_use = ImageFont.truetype(original_font_path, scaled_font_size, index=font_index) # type: ignore - logger.debug(f"Resized truetype font to {scaled_font_size}.") - except Exception as e_resize_font: - logger.warning(f"Could not resize truetype font: {e_resize_font}. Using original size.") - # Keep the font_to_use as it was (original size) - - - for tile_info in dem_tiles_info_list: - # Draw only if HGT data is available for this tile - if not tile_info.get("hgt_available"): - logger.debug(f"Skipping drawing boundary/label for tile {tile_info.get('tile_base_name', '?')}: HGT not available.") - continue # Skip this tile if no HGT - - lat_coord = tile_info.get("latitude_coord") - lon_coord = tile_info.get("longitude_coord") - tile_base_name = tile_info.get("tile_base_name") - - if lat_coord is None or lon_coord is None or tile_base_name is None: - logger.warning(f"Skipping drawing for invalid tile info entry: {tile_info}") - continue - - try: - # Get the precise geographic bounds for this HGT tile - tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord) - - if not tile_geo_bbox: - logger.warning(f"Could not get geographic bounds for tile ({lat_coord},{lon_coord}), skipping drawing.") - continue # Skip tile if bounds calculation fails - - west, south, east, north = tile_geo_bbox - - # Corners of this specific tile's bbox in geographic degrees - tile_corners_geo = [(west, north), (east, north), (east, south), (west, south)] - tile_pixel_corners_on_unscaled: List[Tuple[int,int]] = [] - - # Convert tile corners to unscaled pixel coordinates, suitable for drawing lines. - # Recalculate pixel coords relative to the unscaled map using mercantile for line drawing accuracy, - # and clamp with padding. - if current_map_geo_bounds is not None and current_stitched_map_pixel_shape is not None: - unscaled_height, unscaled_width = current_stitched_map_pixel_shape - map_west_lon_stitched, map_south_lat_stitched, map_east_lon_stitched, map_north_lat_stitched = current_map_geo_bounds - - map_ul_merc_x_stitched, map_ul_merc_y_stitched = mercantile.xy(map_west_lon_stitched, map_north_lat_stitched) # type: ignore - map_lr_merc_x_stitched, map_lr_merc_y_stitched = mercantile.xy(map_east_lon_stitched, map_south_lat_stitched) # type: ignore - - total_map_width_merc = abs(map_lr_merc_x_stitched - map_ul_merc_x_stitched) - total_map_height_merc = abs(map_ul_merc_y_stitched - map_lr_merc_y_stitched) - - if total_map_width_merc <= 0 or total_map_height_merc <= 0: - logger.warning("Map Mercator extent is zero for drawing tile boundaries.") - continue # Skip this tile if map extent is zero - - - import mercantile as local_mercantile # Use mercantile directly here - if local_mercantile is None: raise ImportError("mercantile not available locally.") - - - for lon, lat in tile_corners_geo: - target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore - - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x_stitched) / total_map_width_merc if total_map_width_merc > 0 else 0.0 - relative_merc_y_in_map = (map_ul_merc_y_stitched - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards - - pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width)) - pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height)) - - # Clamp with padding for line drawing - allow coordinates slightly outside image bounds - px_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_x_on_unscaled_raw, unscaled_width + DEM_BOUNDARY_THICKNESS_PX)) - py_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_y_on_unscaled_raw, unscaled_height + DEM_BOUNDARY_THICKNESS_PX)) - - tile_pixel_corners_on_unscaled.append((px_clamped_for_line, py_clamped_for_line)) - - else: - logger.warning(f"Unscaled map dimensions or geo bounds not available, cannot clamp pixel corners for tile ({lat_coord},{lon_coord}).") - continue # Skip this tile if map context is missing - - - if len(tile_pixel_corners_on_unscaled) == 4: - # Draw the tile boundary (red) - draw.line([tile_pixel_corners_on_unscaled[0], tile_pixel_corners_on_unscaled[1]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Top - draw.line([tile_pixel_corners_on_unscaled[1], tile_pixel_corners_on_unscaled[2]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Right - draw.line([tile_pixel_corners_on_unscaled[2], tile_pixel_corners_on_unscaled[3]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Bottom - draw.line([tile_pixel_corners_on_unscaled[3], tile_pixel_corners_on_unscaled[0]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Left - - # --- Draw Tile Name Label --- - label_text = tile_base_name.upper() - # Find pixel position for label - bottom-right corner area of the tile's pixel box. - # Get the precise unscaled pixel coords for the SE corner using the helper (which clamps to edge) - se_pixel_on_unscaled = _geo_to_pixel_on_unscaled_map( - south, east, - current_map_geo_bounds, # Pass context to helper - current_stitched_map_pixel_shape # Pass context to helper - ) - - label_margin = 3 # Small margin from the border - - - # Draw text only if a font is available and position is calculable - # MODIFIED: Check if font_to_use is not None before attempting to use it. - # WHY: The font might not have been loaded due to missing libraries or errors. - # HOW: Added the check. - if font_to_use is not None and se_pixel_on_unscaled and current_stitched_map_pixel_shape is not None: - try: - unscaled_height, unscaled_width = current_stitched_map_pixel_shape - se_px, se_py = se_pixel_on_unscaled - - # Calculate text size using textbbox (requires Pillow >= 8.0) - # Use the bottom-right corner pixel as the anchor point for calculation (not drawing) - try: - # textbbox relative to (0,0) - # MODIFIED: Use font_to_use variable directly instead of ImageFont.truetype. - # WHY: font_to_use already holds the appropriate font object (potentially scaled). - # HOW: Changed font parameter in textbbox and text calls. - text_bbox = draw.textbbox((0,0), label_text, font=font_to_use) # type: ignore - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - - # Target bottom-right corner for text drawing relative to the unscaled image pixel - target_text_br_x = se_px - label_margin - target_text_br_y = se_py - label_margin - - # Top-left corner for drawing the text based on target bottom-right and text size - label_text_x = target_text_br_x - text_width - label_text_y = target_text_br_y - text_height - - # Clamp text position to be within the visible area of the unscaled map image - label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1)) - label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1)) - - - # Draw background rectangle for the text - bg_padding = 1 # Small padding around text background - bg_coords = [ - label_text_x - bg_padding, - label_text_y - bg_padding, - label_text_x + text_width + bg_padding, - label_text_y + text_height + bg_padding, - ] - draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR) - - # Draw the text itself - draw.text((label_text_x, label_text_y), label_text, fill=TILE_TEXT_COLOR, font=font_to_use) # type: ignore - - - except AttributeError: - # Fallback for older Pillow versions using textsize - logger.warning("Pillow textbbox not available (Pillow < 8.0). Using textsize fallback for labels.") - # MODIFIED: Use font_to_use variable directly. - # WHY: Ensure the correct font object is used. - # HOW: Changed font parameter. - text_width, text_height = draw.textsize(label_text, font=font_to_use) # type: ignore - # Rough position calculation based on textsize - if current_stitched_map_pixel_shape and se_pixel_on_unscaled: - unscaled_height, unscaled_width = current_stitched_map_pixel_shape - se_px, se_py = se_pixel_on_unscaled - label_text_x = se_px - text_width - label_margin - label_text_y = se_py - text_height - label_margin - - # Clamp text position to be within the visible area - label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1)) - label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1)) - - # Draw background - bg_padding = 1 - bg_coords = [label_text_x - bg_padding, label_text_y - bg_padding, - label_text_x + text_width + bg_padding, label_text_y + text_height + bg_padding] - draw.rectangle(bg_coords, fill=TILE_TEXT_BG_COLOR) - - # Draw text using font fallback - # MODIFIED: Use font_to_use variable directly. - # WHY: Ensure the correct font object is used. - # HOW: Changed font parameter. - draw.text((label_text_x, label_text_y), label_text, fill=TILE_TEXT_COLOR, font=font_to_use) # type: ignore - else: - logger.warning(f"Could not get SE pixel coords for tile ({lat_coord},{lon_coord}) label positioning (textsize fallback).") - - - except Exception as e_draw_label: - logger.warning(f"Error drawing label '{label_text}' for tile ({lat_coord},{lon_coord}): {e_draw_label}") - else: - # This log message is now accurate as the outer if font_to_use check handles the case where no font was loaded. - logger.debug(f"No font available, skipping drawing label '{label_text}' for tile ({lat_coord},{lon_coord}).") - - - except ValueError as ve: # Catch explicit ValueErrors raised for conversion/drawing issues for a single tile - logger.warning(f"Value error during drawing for tile ({lat_coord},{lon_coord}): {ve}. Skipping this tile.") - pass # Skip this tile and continue with the next - - - except Exception as e_draw_tile: - logger.exception(f"Unexpected error drawing boundary/label for tile ({lat_coord},{lon_coord}): {e_draw_tile}. Skipping this tile.") - pass # Skip this tile and continue with the next - - - # Return the image with all drawn boundaries and labels - return pil_image_to_draw_on - - -def draw_user_click_marker( - pil_image_to_draw_on: Image.Image, - last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]], - current_display_scale_factor: float, - current_stitched_map_pixel_shape: Optional[Tuple[int, int]] -) -> Optional[Image.Image]: - """ - Draws a marker at the last user-clicked pixel location on the (unscaled) PIL map image. - Requires OpenCV and NumPy for drawing. - """ - # MODIFIED: Pass map context explicitly. Check for necessary libraries and context. - # WHY: The function is now standalone and needs context. Ensure dependencies are available. - if not PIL_LIB_AVAILABLE_DRAWING or pil_image_to_draw_on is None or not CV2_NUMPY_LIBS_AVAILABLE_DRAWING or cv2 is None or np is None or current_stitched_map_pixel_shape is None or last_user_click_pixel_coords_on_displayed_image is None: - logger.debug("Conditions not met for drawing user click marker (no click, no image, no context, or libraries missing).") - return pil_image_to_draw_on # Return original image if drawing not possible - - - # Unscale the click coordinates from displayed (scaled) image to original stitched image coordinates - clicked_px_scaled, clicked_py_scaled = last_user_click_pixel_coords_on_displayed_image - - # Get the shape of the original unscaled stitched image - unscaled_height, unscaled_width = current_stitched_map_pixel_shape - - # Calculate the unscaled pixel coordinates corresponding to the clicked scaled pixel - unscaled_target_px = int(round(clicked_px_scaled / current_display_scale_factor)) - unscaled_target_py = int(round(clicked_py_scaled / current_display_scale_factor)) - - # Clamp to unscaled image dimensions - unscaled_target_px = max(0, min(unscaled_target_px, unscaled_width - 1)) - unscaled_target_py = max(0, min(unscaled_target_py, unscaled_height - 1)) - - # MODIFIED: Check for CV2 and NumPy availability before using them. - # WHY: Ensure dependencies are present for drawing with OpenCV. (Already checked at start, but defensive). - # HOW: Added check. - if cv2 and np: - try: - logger.debug(f"Drawing user click marker at unscaled pixel ({unscaled_target_px},{unscaled_target_py})") - # Convert PIL image to OpenCV format (BGR) for drawing - # Ensure image is in a mode OpenCV can handle (BGR) - # Converting here ensures drawing is possible if the input image was, e.g., L mode - if pil_image_to_draw_on.mode != 'RGB': - # Convert to RGB first if not already, then to BGR - map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore - else: - map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore - - - # Draw a cross marker at the calculated unscaled pixel coordinates - # Note: Marker color (0,0,255) is BGR for red - cv2.drawMarker(map_cv_bgr, (unscaled_target_px,unscaled_target_py), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore - # Convert back to PIL format (RGB) - return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore - except Exception as e_draw_click_cv: - logger.exception(f"Error drawing user click marker with OpenCV: {e_draw_click_cv}") - return pil_image_to_draw_on # Return original image on error - else: - logger.warning("CV2 or NumPy not available, cannot draw user click marker.") - return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here \ No newline at end of file diff --git a/geoelevation/map_viewer/map_manager.py b/geoelevation/map_viewer/map_manager.py deleted file mode 100644 index 918e9db..0000000 --- a/geoelevation/map_viewer/map_manager.py +++ /dev/null @@ -1,775 +0,0 @@ -# geoelevation/map_viewer/map_manager.py -""" -Manages the retrieval, caching, and stitching of map tiles from a selected map service. - -This module interacts with a map service provider (implementing BaseMapService), -handles local disk caching of tiles to support offline use and reduce network -requests, and assembles individual tiles into a complete map image. -""" - -# Standard library imports -import logging -import os -import time -import threading -from pathlib import Path # For robust path manipulation -from typing import Tuple, Optional, List, Dict # Ensure these are available -import io # To handle byte stream from requests as an image -import shutil # For cache clearing operations - -# Third-party imports -try: - import requests # For downloading map tiles - REQUESTS_AVAILABLE = True -except ImportError: - requests = None # type: ignore - REQUESTS_AVAILABLE = False - logging.error("MapTileManager: 'requests' library not found. Online tile fetching will fail.") - -try: - # MODIFIED: Import ImageDraw along with Image from PIL. - # WHY: ImageDraw is required for drawing on placeholder images and potentially other image manipulation tasks. - # HOW: Added ImageDraw to the import list from PIL. - from PIL import Image, ImageDraw - ImageType = Image.Image # type: ignore - PIL_AVAILABLE_MANAGER = True -except ImportError: - Image = None # type: ignore - ImageDraw = None # type: ignore # Define as None if import fails - ImageType = None # type: ignore - logging.error("MapTileManager: 'Pillow' library not found. Image operations will fail.") - - -# Local application/package imports -# Assumes map_services is in the same subpackage 'map_viewer' -from .map_services import BaseMapService - -# Module-level logger -logger = logging.getLogger(__name__) - -# Default values for the manager if not provided or configured -DEFAULT_MAP_TILE_CACHE_ROOT_DIR = "map_tile_cache" # Root for all service caches -DEFAULT_ENABLE_ONLINE_FETCHING = True -DEFAULT_NETWORK_TIMEOUT_SECONDS = 10 # Increased timeout slightly -DEFAULT_DOWNLOAD_RETRY_DELAY_SECONDS = 2 -DEFAULT_MAX_DOWNLOAD_RETRIES = 2 -DEFAULT_PLACEHOLDER_COLOR_RGB = (220, 220, 220) # Light grey placeholder - - -class MapTileManager: - """ - Manages fetching, caching, and assembling map tiles for a given map service. - Requires 'requests' and 'Pillow' libraries to be installed. - """ - - def __init__( - self, - map_service: BaseMapService, - cache_root_directory: Optional[str] = None, - enable_online_tile_fetching: Optional[bool] = None, - # MODIFIED: Added tile_pixel_size parameter to the constructor. - # WHY: To allow the caller (GeoElevationMapViewer) to explicitly specify the tile size - # based on the selected map service configuration. - # HOW: Added the parameter with an Optional[int] type hint and default None. - tile_pixel_size: Optional[int] = None - ) -> None: - """ - Initializes the MapTileManager. - - Args: - map_service_instance: An instance of a map service provider - (e.g., OpenStreetMapService). - cache_root_directory: The root directory for caching tiles from all services. - If None, uses DEFAULT_MAP_TILE_CACHE_ROOT_DIR. - A subdirectory for the specific service will be created. - enable_online_tile_fetching: Whether to download tiles if not found in cache. - If None, uses DEFAULT_ENABLE_ONLINE_FETCHING. - tile_pixel_size: The pixel dimension (width/height) of map tiles for this manager. - If None, the size is taken from the map_service instance. - Raises: - TypeError: If map_service_instance is not a valid BaseMapService instance. - ImportError: If 'requests' or 'Pillow' libraries are not installed. - ValueError: If a tile_pixel_size is provided but invalid. - """ - logger.info("Initializing MapTileManager...") - - if not REQUESTS_AVAILABLE: - raise ImportError("'requests' library is required by MapTileManager but not found.") - # MODIFIED: Check for ImageDraw availability as well if Pillow is expected. - # WHY: Drawing on placeholders requires ImageDraw. - # HOW: Added ImageDraw check. - if not (PIL_AVAILABLE_MANAGER and ImageDraw is not None): - raise ImportError("'Pillow' library (or its drawing module ImageDraw) is required by MapTileManager but not found.") - - - if not isinstance(map_service, BaseMapService): - logger.critical("Invalid map_service_instance provided. Must be an instance of BaseMapService.") - raise TypeError("map_service_instance must be an instance of BaseMapService.") - - self.map_service: BaseMapService = map_service - self.service_identifier_name: str = self.map_service.name - - # MODIFIED: Set the tile_size attribute using the provided parameter or the service's size. - # WHY: The manager needs to know the pixel dimensions of the tiles it handles for stitching and placeholder creation. - # HOW: Check if tile_pixel_size was provided; if so, validate and use it. Otherwise, use the size from the map_service instance. - if tile_pixel_size is not None: - if not isinstance(tile_pixel_size, int) or tile_pixel_size <= 0: - logger.error(f"Invalid provided tile_pixel_size: {tile_pixel_size}. Must be a positive integer.") - # Fallback to service size or raise error? Let's raise for clarity if a bad value is explicitly passed. - raise ValueError(f"Invalid provided tile_pixel_size: {tile_pixel_size}. Must be a positive integer.") - self.tile_size: int = tile_pixel_size - logger.info(f"Map tile size explicitly set to {self.tile_size}px.") - else: - # Use the size from the provided map service instance - self.tile_size: int = self.map_service.tile_size - logger.info(f"Map tile size inherited from service '{self.map_service.name}': {self.tile_size}px.") - - # Determine cache directory path - effective_cache_root_dir = cache_root_directory if cache_root_directory is not None \ - else DEFAULT_MAP_TILE_CACHE_ROOT_DIR - self.service_specific_cache_dir: Path = Path(effective_cache_root_dir) / self.service_identifier_name - logger.info(f"Service-specific cache directory set to: {self.service_specific_cache_dir}") - - # Determine online fetching status - self.is_online_fetching_enabled: bool = enable_online_tile_fetching \ - if enable_online_tile_fetching is not None else DEFAULT_ENABLE_ONLINE_FETCHING - logger.info(f"Online tile fetching enabled: {self.is_online_fetching_enabled}") - - # Network request parameters - self.http_user_agent: str = "GeoElevationMapViewer/0.1 (Python Requests)" - self.http_request_headers: Dict[str, str] = {"User-Agent": self.http_user_agent} - self.http_request_timeout_seconds: int = DEFAULT_NETWORK_TIMEOUT_SECONDS - self.download_max_retries: int = DEFAULT_MAX_DOWNLOAD_RETRIES - self.download_retry_delay_seconds: int = DEFAULT_DOWNLOAD_RETRY_DELAY_SECONDS - - self._ensure_service_cache_directory_exists() - self._cache_access_lock = threading.Lock() # For thread-safe cache operations - - logger.info( - f"MapTileManager initialized for service '{self.service_identifier_name}'. Online: {self.is_online_fetching_enabled}" - ) - - def _ensure_service_cache_directory_exists(self) -> None: - """Creates the service-specific cache directory if it doesn't exist.""" - try: - self.service_specific_cache_dir.mkdir(parents=True, exist_ok=True) - logger.debug(f"Cache directory verified/created: {self.service_specific_cache_dir}") - except OSError as e_mkdir: - logger.error( - f"Failed to create cache directory '{self.service_specific_cache_dir}': {e_mkdir}" - ) - except Exception as e_unexpected_mkdir: - logger.exception( - f"Unexpected error ensuring cache directory exists: {e_unexpected_mkdir}" - ) - - def _get_tile_cache_file_path(self, z: int, x: int, y: int) -> Path: - """ - Constructs the full local file path for a specific map tile. - The structure is: ///.png - """ - return self.service_specific_cache_dir / str(z) / str(x) / f"{y}.png" - - def _load_tile_from_cache( - self, tile_cache_path: Path, tile_coordinates_log_str: str - ) -> Optional[ImageType]: - """Attempts to load a tile image from a cache file (thread-safe read).""" - logger.debug(f"Checking cache for tile {tile_coordinates_log_str} at {tile_cache_path}") - try: - with self._cache_access_lock: # Protect file system access - if tile_cache_path.is_file(): - logger.info(f"Cache hit for tile {tile_coordinates_log_str}. Loading from disk.") - pil_image = Image.open(tile_cache_path) # type: ignore - pil_image.load() # Load image data into memory to release file lock sooner - - # Ensure consistency by converting to RGB if needed - # MODIFIED: Ensure consistency by converting to RGB or RGBA depending on service need (currently RGB). - # WHY: Consistent format is important for image processing and display. - # HOW: Convert to RGB. - if pil_image.mode != "RGB": - logger.debug( - f"Converting cached image {tile_coordinates_log_str} from mode {pil_image.mode} to RGB." - ) - pil_image = pil_image.convert("RGB") - return pil_image - else: - logger.debug(f"Cache miss for tile {tile_coordinates_log_str}.") - return None - except IOError as e_io_cache: - logger.error( - f"IOError reading cached tile {tile_cache_path}: {e_io_cache}. Treating as cache miss." - ) - return None - except Exception as e_cache_unexpected: - logger.exception( - f"Unexpected error accessing cache file {tile_cache_path}: {e_cache_unexpected}" - ) - return None - - def _download_and_save_tile_to_cache( - self, - zoom_level: int, - tile_x: int, - tile_y: int, - tile_cache_path: Path, - tile_coordinates_log_str: str - ) -> Optional[ImageType]: - """Attempts to download a tile, process it, and save it to the cache.""" - if not self.is_online_fetching_enabled: - logger.debug(f"Online fetching disabled. Cannot download tile {tile_coordinates_log_str}.") - return None - - tile_download_url = self.map_service.get_tile_url(zoom_level, tile_x, tile_y) - if not tile_download_url: - logger.error(f"Failed to get URL for tile {tile_coordinates_log_str} from service.") - return None - - logger.info(f"Downloading tile {tile_coordinates_log_str} from: {tile_download_url}") - downloaded_pil_image: Optional[ImageType] = None - - for attempt_num in range(self.download_max_retries + 1): - try: - response = requests.get( # type: ignore - tile_download_url, - headers=self.http_request_headers, - timeout=self.http_request_timeout_seconds, - stream=True # Efficient for binary content - ) - response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx) - - image_binary_data = response.content - if not image_binary_data: - logger.warning(f"Downloaded empty content for tile {tile_coordinates_log_str}.") - break # Stop retrying if server sends empty response - - # Process downloaded image data and save to cache - try: - pil_image = Image.open(io.BytesIO(image_binary_data)) # type: ignore - pil_image.load() - # MODIFIED: Convert downloaded image to RGB mode before saving/returning. - # WHY: Consistency in image format within the manager. - # HOW: Added .convert("RGB"). - if pil_image.mode != "RGB": - pil_image = pil_image.convert("RGB") - - # Optional: Resize downloaded tile if its size doesn't match self.tile_size - # This would be needed if the service URL returns tiles of different sizes, - # which is uncommon for standard XYZ services, but could happen. - # For standard services, the service.tile_size should be correct. - # if pil_image.size != (self.tile_size, self.tile_size): - # logger.warning(f"Downloaded tile size {pil_image.size} doesn't match expected {self.tile_size}. Resizing.") - # pil_image = pil_image.resize((self.tile_size, self.tile_size), Image.Resampling.LANCZOS) - - - logger.debug(f"Tile {tile_coordinates_log_str} downloaded (Attempt {attempt_num + 1}).") - self._save_image_to_cache_file(tile_cache_path, pil_image) - downloaded_pil_image = pil_image - break # Success, exit retry loop - - except (IOError, Image.UnidentifiedImageError) as e_img_proc: # type: ignore - logger.error( - f"Failed to process image data for {tile_coordinates_log_str}: {e_img_proc}" - ) - break # Don't retry if image data is corrupt - except Exception as e_proc_unexpected: - logger.exception( - f"Unexpected error processing downloaded image {tile_coordinates_log_str}: {e_proc_unexpected}" - ) - break - - except requests.exceptions.Timeout: # type: ignore - logger.warning( - f"Timeout downloading tile {tile_coordinates_log_str} (Attempt {attempt_num + 1})." - ) - except requests.exceptions.RequestException as e_req: # type: ignore - status = getattr(e_req.response, "status_code", "N/A") - logger.warning( - f"Request error for tile {tile_coordinates_log_str} (Status: {status}, Attempt {attempt_num + 1}): {e_req}" - ) - if status == 404: break # No point retrying a 404 Not Found - except Exception as e_dl_unexpected: - logger.exception( - f"Unexpected error downloading tile {tile_coordinates_log_str} (Attempt {attempt_num + 1}): {e_dl_unexpected}" - ) - break # Stop on other unexpected download errors - - if attempt_num < self.download_max_retries: - logger.debug(f"Waiting {self.download_retry_delay_seconds}s before retrying for {tile_coordinates_log_str}.") - time.sleep(self.download_retry_delay_seconds) - - if downloaded_pil_image is None: - logger.error(f"Failed to download tile {tile_coordinates_log_str} after all retries.") - return downloaded_pil_image - - - def _save_image_to_cache_file(self, tile_cache_path: Path, pil_image: ImageType) -> None: - """Saves a PIL Image object to a file in the cache (thread-safe write).""" - with self._cache_access_lock: # Protect file system access - try: - # Ensure parent directory for the tile file exists - tile_cache_path.parent.mkdir(parents=True, exist_ok=True) - # Use 'png' format explicitly as it's lossless and common for map tiles - pil_image.save(tile_cache_path, format='PNG') # MODIFIED: Explicitly save as PNG. WHY: Standard format. - logger.debug(f"Saved tile to cache: {tile_cache_path}") - except IOError as e_io_save: - logger.error(f"IOError saving tile to cache {tile_cache_path}: {e_io_save}") - except Exception as e_save_unexpected: - logger.exception( - f"Unexpected error saving tile to cache {tile_cache_path}: {e_save_unexpected}" - ) - - def get_tile_image( - self, - zoom_level: int, - tile_x: int, - tile_y: int, - force_online_refresh: bool = False - ) -> Optional[ImageType]: - """ - Retrieves a map tile image, using local cache first. - If not cached or refresh is forced, attempts to download (if enabled). - Returns a placeholder image if the tile cannot be retrieved. - - Args: - zoom_level: The zoom level of the tile. - tile_x: The X coordinate of the tile. - tile_y: The Y coordinate of the tile. - force_online_refresh: If True, bypasses cache and attempts download. - - Returns: - A PIL Image object of the tile, or a placeholder image on failure. - Returns None only if placeholder creation itself fails critically. - """ - tile_coords_log_str = f"({zoom_level},{tile_x},{tile_y})" - logger.debug(f"Requesting tile image for {tile_coords_log_str}") - - # MODIFIED: Check if the zoom level is valid for the map service. - # WHY: Avoid requesting tiles for invalid zoom levels from the service or cache. - # HOW: Added a check using self.map_service.is_zoom_level_valid. - if not self.map_service.is_zoom_level_valid(zoom_level): - logger.error(f"Invalid zoom level {zoom_level} for map service '{self.service_identifier_name}'. Cannot get tile.") - # Return a placeholder for invalid zoom levels - return self._create_placeholder_tile_image(f"Invalid Zoom {zoom_level}") - - - tile_cache_file = self._get_tile_cache_file_path(zoom_level, tile_x, tile_y) - retrieved_image: Optional[ImageType] = None - - if not force_online_refresh: - retrieved_image = self._load_tile_from_cache(tile_cache_file, tile_coords_log_str) - - if retrieved_image is None: # Cache miss or force_refresh - retrieved_image = self._download_and_save_tile_to_cache( - zoom_level, tile_x, tile_y, tile_cache_file, tile_coords_log_str - ) - - if retrieved_image is None: # All attempts failed - logger.warning(f"Failed to retrieve tile {tile_coords_log_str}. Using placeholder.") - # MODIFIED: Pass tile coordinates to placeholder for debugging/visual info. - # WHY: Helps identify which specific tile failed when looking at the stitched map. - # HOW: Pass a string identifier to the placeholder creation function. - retrieved_image = self._create_placeholder_tile_image(tile_coords_log_str) - if retrieved_image is None: # Should be rare if Pillow is working - logger.critical("Failed to create even a placeholder tile. Returning None.") - - return retrieved_image - - - def stitch_map_image( - self, - zoom_level: int, - x_tile_range: Tuple[int, int], # (min_x, max_x) - y_tile_range: Tuple[int, int] # (min_y, max_y) - ) -> Optional[ImageType]: - """ - Retrieves and stitches multiple map tiles to form a larger composite map image. - - Args: - zoom_level: The zoom level for all tiles. - x_tile_range: Inclusive start and end X tile coordinates (min_x, max_x). - y_tile_range: Inclusive start and end Y tile coordinates (min_y, max_y). - - Returns: - A PIL Image object of the stitched map, or None if a critical error occurs. - Missing individual tiles are replaced by placeholders. - """ - logger.info( - f"Request to stitch map: Zoom {zoom_level}, X-Range {x_tile_range}, Y-Range {y_tile_range}" - ) - - min_tile_x, max_tile_x = x_tile_range - min_tile_y, max_tile_y = y_tile_range - - # Basic validation of ranges - if not (min_tile_x <= max_tile_x and min_tile_y <= max_tile_y): - logger.error(f"Invalid tile ranges for stitching: X={x_tile_range}, Y={y_tile_range}") - return None - - # MODIFIED: Use the tile_size attribute of the manager. - # WHY: Consistency. The manager's size should be used, not necessarily the service's size again here. - # HOW: Changed self.map_service.tile_size to self.tile_size. - single_tile_pixel_size = self.tile_size - - if single_tile_pixel_size <= 0: - logger.error(f"Invalid tile size ({single_tile_pixel_size}) stored in manager. Cannot stitch.") - # MODIFIED: Return placeholder instead of None on invalid tile size. - # WHY: Provide a visual indication that stitching failed due to config, rather than a blank window. - # HOW: Create and return a large placeholder image. - try: - # Ensure Pillow/ImageDraw are available for placeholder creation - if PIL_AVAILABLE_MANAGER and ImageDraw is not None: # type: ignore - # Create a large placeholder image (e.g., 3x3 tiles size) - placeholder_img = Image.new("RGB", (256 * 3, 256 * 3), DEFAULT_PLACEHOLDER_COLOR_RGB) # Use a fixed reasonable size for error image - draw = ImageDraw.Draw(placeholder_img) # type: ignore - # Add error text - error_text = f"Stitch Failed\nInvalid Tile Size:\n{single_tile_pixel_size}" - # This simple text drawing assumes basic PIL text capabilities - try: - # Try drawing with a font loaded in image_processor - from geoelevation.image_processor import DEFAULT_FONT # Assume font loading logic in image_processor - font_to_use = DEFAULT_FONT # type: ignore # Use font loaded in image_processor - if font_to_use: - # Calculate text size and position using the font - # Note: textbbox requires Pillow >= 8.0 - try: - text_bbox = draw.textbbox((0,0), error_text, font=font_to_use, spacing=2) # type: ignore - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - text_pos = ((placeholder_img.width - text_width) // 2, (placeholder_img.height - text_height) // 2) - draw.text(text_pos, error_text, fill="black", font=font_to_use, spacing=2, anchor="lt") # type: ignore # Draw text using font - except AttributeError: # Fallback for textbbox if Pillow < 8.0 - text_width, text_height = draw.textsize(error_text, font=font_to_use) # type: ignore - text_pos = ((placeholder_img.width - text_width) // 2, (placeholder_img.height - text_height) // 2) - draw.text(text_pos, error_text, fill="black", font=font_to_use) # type: ignore # Draw text using font fallback - except Exception as e_font_draw: - logger.warning(f"Error drawing text with font on placeholder: {e_font_draw}. Falling back to simple draw.") - draw.text((10, 10), error_text, fill="black") # type: ignore # Simple draw if font drawing fails - else: - draw.text((10, 10), error_text, fill="black") # type: ignore # Simple draw if no font was loaded - except Exception as e_draw: - logger.warning(f"Error drawing text on placeholder: {e_draw}. Falling back to simple draw.") - draw.text((10, 10), error_text, fill="black") # type: ignore # Final fallback - - return placeholder_img - else: - logger.error("Pillow or ImageDraw not available to create placeholder image.") - return None # Cannot create placeholder without PIL - - except Exception as e_placeholder_fail: - logger.exception(f"Failed to create large placeholder for stitching error: {e_placeholder_fail}") - return None # Return None if placeholder creation fails - - num_tiles_wide = (max_tile_x - min_tile_x) + 1 - num_tiles_high = (max_tile_y - min_tile_y) + 1 - - - total_image_width = num_tiles_wide * single_tile_pixel_size - total_image_height = num_tiles_high * single_tile_pixel_size - logger.debug( - f"Stitched image dimensions: {total_image_width}x{total_image_height} " - f"({num_tiles_wide}x{num_tiles_high} tiles of {single_tile_pixel_size}px)" - ) - - # Handle potential excessively large image size request - MAX_IMAGE_DIMENSION = 16384 # Arbitrary limit to prevent crashes with massive requests - if total_image_width > MAX_IMAGE_DIMENSION or total_image_height > MAX_IMAGE_DIMENSION: - logger.error( - f"Requested stitched image size ({total_image_width}x{total_image_height}) " - f"exceeds maximum allowed dimension ({MAX_IMAGE_DIMENSION}). Aborting stitch." - ) - # Return placeholder for excessive size request - try: - if PIL_AVAILABLE_MANAGER and ImageDraw is not None: # type: ignore - placeholder_img = Image.new("RGB", (256 * 3, 256 * 3), (255, 100, 100)) # Reddish placeholder - draw = ImageDraw.Draw(placeholder_img) # type: ignore - error_text = f"Stitch Failed\nImage too large:\n{total_image_width}x{total_image_height}px" - try: - from geoelevation.image_processor import DEFAULT_FONT - font_to_use = DEFAULT_FONT # type: ignore - if font_to_use: - try: - text_bbox = draw.textbbox((0,0), error_text, font=font_to_use, spacing=2) # type: ignore - text_w = text_bbox[2] - text_bbox[0] - text_h = text_bbox[3] - text_bbox[1] - text_pos = ((placeholder_img.width - text_w) // 2, (placeholder_img.height - text_h) // 2) - draw.text(text_pos, error_text, fill="black", font=font_to_use, spacing=2, anchor="lt") # type: ignore - except AttributeError: - text_w, text_h = draw.textsize(error_text, font=font_to_use) # type: ignore - text_pos = ((placeholder_img.width - text_w) // 2, (placeholder_img.height - text_h) // 2) - draw.text(text_pos, error_text, fill="black", font=font_to_use) # type: ignore - except Exception as e_font_draw: - logger.warning(f"Error drawing text with font on placeholder: {e_font_draw}. Falling back to simple draw.") - draw.text((10, 10), error_text, fill="black") # type: ignore - else: - draw.text((10, 10), error_text, fill="black") # type: ignore - except Exception as e_draw: - logger.warning(f"Error drawing text on placeholder: {e_draw}. Falling back to simple draw.") - draw.text((10, 10), error_text, fill="black") # type: ignore # Final fallback - - return placeholder_img - else: - logger.error("Pillow or ImageDraw not available to create placeholder image.") - return None # Cannot create placeholder without PIL - - except Exception as e_placeholder_fail: - logger.exception(f"Failed to create large placeholder for size error: {e_placeholder_fail}") - return None # Return None if placeholder fails - - - try: - # Create a new blank RGB image to paste tiles onto - # MODIFIED: Ensure PIL_AVAILABLE_MANAGER is true before creating Image.new. - # WHY: Avoids NameError if PIL import failed. - # HOW: Added check. - if PIL_AVAILABLE_MANAGER: - stitched_map_image = Image.new("RGB", (total_image_width, total_image_height)) # type: ignore - else: - raise ImportError("Pillow not available to create new image.") - - except Exception as e_create_blank: - logger.exception(f"Failed to create blank image for stitching: {e_create_blank}. Dimensions: {total_image_width}x{total_image_height}") - # Return placeholder if blank image creation fails (e.g., out of memory) - try: - if PIL_AVAILABLE_MANAGER and ImageDraw is not None: # type: ignore - placeholder_img = Image.new("RGB", (256 * 3, 256 * 3), (255, 100, 100)) # Reddish placeholder - draw = ImageDraw.Draw(placeholder_img) # type: ignore - error_text = f"Stitch Failed\nCannot create image:\n{e_create_blank}" - try: - from geoelevation.image_processor import DEFAULT_FONT - font_to_use = DEFAULT_FONT # type: ignore - if font_to_use: - try: - text_bbox = draw.textbbox((0,0), error_text, font=font_to_use, spacing=2) # type: ignore - text_w = text_bbox[2] - text_bbox[0] - text_h = text_bbox[3] - text_bbox[1] - text_pos = ((placeholder_img.width - text_w) // 2, (placeholder_img.height - text_h) // 2) - draw.text(text_pos, error_text, fill="black", font=font_to_use, spacing=2, anchor="lt") # type: ignore - except AttributeError: - text_w, text_h = draw.textsize(error_text, font=font_to_use) # type: ignore - text_pos = ((placeholder_img.width - text_w) // 2, (placeholder_img.height - text_h) // 2) - draw.text(text_pos, error_text, fill="black", font=font_to_use) # type: ignore - except Exception as e_font_draw: - logger.warning(f"Error drawing text with font on placeholder: {e_font_draw}. Falling back to simple draw.") - draw.text((10, 10), error_text, fill="black") # type: ignore - else: - draw.text((10, 10), error_text, fill="black") # type: ignore - except Exception as e_draw: - logger.warning(f"Error drawing text on placeholder: {e_draw}. Falling back to simple draw.") - draw.text((10, 10), error_text, fill="black") # type: ignore # Final fallback - - return placeholder_img - else: - logger.error("Pillow or ImageDraw not available to create placeholder image.") - return None # Cannot create placeholder without PIL - - except Exception as e_placeholder_fail: - logger.exception(f"Failed to create large placeholder for memory error: {e_placeholder_fail}") - return None # Return None if placeholder fails - - - # Iterate through the required tile coordinates, fetch, and paste - for row_index, current_tile_y in enumerate(range(min_tile_y, max_tile_y + 1)): - for col_index, current_tile_x in enumerate(range(min_tile_x, max_tile_x + 1)): - tile_image_pil = self.get_tile_image(zoom_level, current_tile_x, current_tile_y) - - if tile_image_pil is None: - # This implies even placeholder creation failed, which is critical. - logger.critical( - f"Critical error: get_tile_image returned None for ({zoom_level},{current_tile_x},{current_tile_y}). Aborting stitch." - ) - return None # Abort stitching on critical tile failure - - # Calculate top-left pixel position to paste this tile - paste_position_x = col_index * single_tile_pixel_size - paste_position_y = row_index * single_tile_pixel_size - logger.debug( - f"Pasting tile ({zoom_level},{current_tile_x},{current_tile_y}) at " - f"pixel position ({paste_position_x},{paste_position_y})" - ) - try: - # Ensure the tile image is the correct size before pasting - # MODIFIED: Check if tile_image_pil is valid before checking its size. - # WHY: Avoids AttributeError if tile_image_pil is None (shouldn't happen if get_tile_image handles None, but defensive). - # HOW: Added `if tile_image_pil and tile_image_pil.size...`. - if tile_image_pil and tile_image_pil.size != (self.tile_size, self.tile_size): - # This might happen if the downloaded tile or placeholder was the wrong size. - # Resize it to match the expected tile size for stitching consistency. - logger.warning(f"Tile image size {tile_image_pil.size} doesn't match expected {self.tile_size}. Resizing for stitch.") - # MODIFIED: Check PIL_AVAILABLE_MANAGER before resizing. - # WHY: Resize requires PIL. - # HOW: Added check. - if PIL_AVAILABLE_MANAGER: - tile_image_pil = tile_image_pil.resize((self.tile_size, self.tile_size), Image.Resampling.LANCZOS) # type: ignore - else: - logger.error("Pillow not available, cannot resize tile for stitch.") - # Decide fallback: skip pasting this tile or use placeholder? - # Leaving it blank might be okay, or replace with a placeholder of correct size. - # Let's just leave it blank (skip paste) if resize fails due to missing lib. - continue # Skip pasting this tile - - - # MODIFIED: Check if tile_image_pil is still valid before pasting. - # WHY: It might have become None if resize failed due to missing PIL. - # HOW: Added `if tile_image_pil:`. - if tile_image_pil: - stitched_map_image.paste(tile_image_pil, (paste_position_x, paste_position_y)) # type: ignore - except Exception as e_paste: - logger.exception( - f"Error pasting tile ({zoom_level},{current_tile_x},{current_tile_y}) " - f"at ({paste_position_x},{paste_position_y}): {e_paste}" - ) - # Continue, leaving that part of the map blank or with placeholder color - - logger.info(f"Map stitching complete for zoom {zoom_level}, X={x_tile_range}, Y={y_tile_range}.") - return stitched_map_image - - - def _create_placeholder_tile_image(self, identifier: str = "N/A") -> Optional[ImageType]: - """ - Creates and returns a placeholder tile image (e.g., a grey square). - Includes optional text identifier on the placeholder. - """ - # MODIFIED: Added check for ImageDraw availability. - # WHY: Drawing on placeholders requires ImageDraw. - # HOW: Added check. - if not (PIL_AVAILABLE_MANAGER and ImageDraw is not None): - logger.warning("Cannot create placeholder tile: Pillow or ImageDraw library not available.") - return None - try: - tile_pixel_size = self.tile_size # Use the manager's stored tile size - # Ensure placeholder_color is a valid RGB tuple - placeholder_color = DEFAULT_PLACEHOLDER_COLOR_RGB - # No need to re-validate color if it's a fixed constant, but defensive check - # if not (isinstance(placeholder_color, tuple) and len(placeholder_color) == 3 and - # all(isinstance(c, int) and 0 <= c <= 255 for c in placeholder_color)): - # logger.warning(f"Invalid placeholder color '{placeholder_color}'. Using default grey.") - # placeholder_color = (220, 220, 220) - - placeholder_img = Image.new("RGB", (tile_pixel_size, tile_pixel_size), color=placeholder_color) # type: ignore - draw = ImageDraw.Draw(placeholder_img) # type: ignore - - # Add text overlay indicating failure and identifier - overlay_text = f"Tile Fail\n{identifier}" - - try: - # Attempt to use a font loaded in image_processor for consistency - from geoelevation.image_processor import DEFAULT_FONT # Assume font loading logic exists - font_to_use = DEFAULT_FONT # type: ignore # Use the shared loaded font - - # Calculate text position for centering or top-left - # Using textbbox for accurate size calculation (requires Pillow >= 8.0) - try: - # textbbox returns (left, top, right, bottom) relative to the anchor (0,0) - text_bbox = draw.textbbox((0,0), overlay_text, font=font_to_use, spacing=2) # type: ignore - text_width = text_bbox[2] - text_bbox[0] - text_height = text_bbox[3] - text_bbox[1] - - # Center the text (approx) - text_x = (tile_pixel_size - text_width) // 2 - text_y = (tile_pixel_size - text_height) // 2 - - # Draw text with the loaded font, anchored at the top-left of the text bbox - draw.text((text_x, text_y), overlay_text, fill="black", font=font_to_use, spacing=2, anchor="lt") # type: ignore - - except AttributeError: # Fallback for textbbox if Pillow < 8.0 - logger.warning("Pillow textbbox not available (Pillow < 8.0). Using textsize fallback.") - # textsize might not handle multiline spacing well - text_width, text_height = draw.textsize(overlay_text, font=font_to_use) # type: ignore - # Add approximated height for multiline if needed - if "\n" in overlay_text: - line_count = overlay_text.count("\n") + 1 - text_height += line_count * 2 # Rough approximation - - # Center text based on textsize (less accurate for multiline) - text_x = (tile_pixel_size - text_width) // 2 - text_y = (tile_pixel_size - text_height) // 2 - - draw.text((text_x, text_y), overlay_text, fill="black", font=font_to_use) # type: ignore # Draw text using font fallback - - except Exception as e_font_draw: - logger.warning(f"Error drawing text with font on placeholder: {e_font_draw}. Falling back to simple draw.") - # Fallback to simple draw if font drawing fails - draw.text((10, 10), overlay_text, fill="black") # type: ignore # Simple draw near top-left - - except Exception as e_draw: - logger.warning(f"Error drawing text on placeholder: {e_draw}. Falling back to simple draw.") - draw.text((10, 10), overlay_text, fill="black") # type: ignore # Final fallback - - return placeholder_img - - except Exception as e_placeholder: - logger.exception(f"Error creating placeholder tile image: {e_placeholder}") - return None - - def _get_bounds_for_tile_range( - self, - zoom: int, - tile_ranges: Tuple[Tuple[int, int], Tuple[int, int]] # ((min_x, max_x), (min_y, max_y)) - ) -> Optional[Tuple[float, float, float, float]]: # (west, south, east, north) - """ - Calculates the precise geographic bounds covered by a given range of tiles. - This method might be better placed in map_utils if mercantile is available there, - or kept here if MapTileManager is the primary user of mercantile for this. - Requires 'mercantile' library. - """ - # Check if mercantile is available (it should be if MapTileManager initialized without error) - try: - import mercantile as local_mercantile # Local import for this method - # MODIFIED: Check if mercantile is actually available after import attempt. - # WHY: Defend against scenarios where the import succeeds but mercantile is None. - # HOW: Add explicit check. - if local_mercantile is None: - raise ImportError("mercantile is None after import.") - except ImportError: - logger.error("mercantile library not found, cannot calculate bounds for tile range.") - return None - - try: - min_x, max_x = tile_ranges[0] - min_y, max_y = tile_ranges[1] - - # Get bounds of the top-left tile and bottom-right tile - # mercantile.bounds(x, y, z) returns (west, south, east, north) - top_left_tile_bounds = local_mercantile.bounds(min_x, min_y, zoom) - bottom_right_tile_bounds = local_mercantile.bounds(max_x, max_y, zoom) - - # The overall bounding box is: - # West longitude from the top-left tile - # South latitude from the bottom-right tile - # East longitude from the bottom-right tile - # North latitude from the top-left tile - overall_west_lon = top_left_tile_bounds.west - overall_south_lat = bottom_right_tile_bounds.south - overall_east_lon = bottom_right_tile_bounds.east - overall_north_lat = top_left_tile_bounds.north - - return (overall_west_lon, overall_south_lat, overall_east_lon, overall_north_lat) - except Exception as e_bounds_calc: - logger.exception( - f"Error calculating geographic bounds for tile range {tile_ranges} at zoom {zoom}: {e_bounds_calc}" - ) - return None - - - def clear_entire_service_cache(self) -> None: - """Deletes all cached tiles for the current map service.""" - logger.info(f"Attempting to clear entire cache for service '{self.service_identifier_name}' at {self.service_specific_cache_dir}") - if not self.service_specific_cache_dir.exists(): - logger.warning(f"Cache directory '{self.service_specific_cache_dir}' does not exist. Nothing to clear.") - return - - with self._cache_access_lock: # Ensure exclusive access during deletion - try: - if self.service_specific_cache_dir.is_dir(): - shutil.rmtree(self.service_specific_cache_dir) - logger.info(f"Successfully cleared cache at {self.service_specific_cache_dir}.") - # Recreate the base directory for this service after clearing - self._ensure_service_cache_directory_exists() - else: - logger.warning(f"Cache path '{self.service_specific_cache_dir}' is not a directory.") - except OSError as e_os_clear: - logger.error(f"OS Error clearing cache at '{self.service_specific_cache_dir}': {e_os_clear}") - except Exception as e_clear_unexpected: - logger.exception( - f"Unexpected error clearing cache '{self.service_specific_cache_dir}': {e_clear_unexpected}" - ) \ No newline at end of file diff --git a/geoelevation/map_viewer/map_services.py b/geoelevation/map_viewer/map_services.py deleted file mode 100644 index 4ba17d6..0000000 --- a/geoelevation/map_viewer/map_services.py +++ /dev/null @@ -1,250 +0,0 @@ -# geoelevation/map_viewer/map_services.py -""" -Defines an abstract base class for map tile services and provides concrete -implementations for specific map providers (e.g., OpenStreetMap). - -This allows the application to interact with different map sources through a -common, well-defined interface, facilitating extensibility to other services. -""" - -# Standard library imports -import abc # Abstract Base Classes -import logging -from urllib.parse import urlparse # For basic URL validation -from typing import Optional, Tuple, Dict, Any - -# Module-level logger -logger = logging.getLogger(__name__) - - -class BaseMapService(abc.ABC): - """ - Abstract Base Class for map tile service providers. - - Subclasses must implement the 'name', 'attribution', and 'get_tile_url' - properties and methods. - """ - - DEFAULT_TILE_PIXEL_SIZE: int = 256 - DEFAULT_MAX_ZOOM_LEVEL: int = 19 # Common for many services like OSM - - def __init__( - self, - service_api_key: Optional[str] = None, - tile_pixel_dim: int = DEFAULT_TILE_PIXEL_SIZE, - max_supported_zoom: int = DEFAULT_MAX_ZOOM_LEVEL - ) -> None: - """ - Initializes the BaseMapService. - - Args: - service_api_key: API key required by the service, if any. - tile_pixel_dim: The pixel dimension (width/height) of map tiles. - max_supported_zoom: The maximum zoom level supported by this service. - """ - # Use class name of the concrete subclass for logging - self._service_log_prefix = f"[{self.__class__.__name__}]" - logger.debug(f"{self._service_log_prefix} Initializing base map service.") - - self.api_key: Optional[str] = service_api_key - self.tile_size: int = tile_pixel_dim - self.max_zoom: int = max_supported_zoom - - # Validate provided tile_size and max_zoom - if not (isinstance(self.tile_size, int) and self.tile_size > 0): - logger.warning( - f"{self._service_log_prefix} Invalid tile_size '{self.tile_size}'. " - f"Using default: {self.DEFAULT_TILE_PIXEL_SIZE}px." - ) - self.tile_size = self.DEFAULT_TILE_PIXEL_SIZE - - # Practical limits for Web Mercator zoom levels - if not (isinstance(self.max_zoom, int) and 0 <= self.max_zoom <= 25): - logger.warning( - f"{self._service_log_prefix} Invalid max_zoom '{self.max_zoom}'. " - f"Using default: {self.DEFAULT_MAX_ZOOM_LEVEL}." - ) - self.max_zoom = self.DEFAULT_MAX_ZOOM_LEVEL - - @property - @abc.abstractmethod - def name(self) -> str: - """ - Returns the unique, short name of the map service (e.g., 'osm'). - This is used for identification and potentially for cache directory naming. - """ - pass - - @property - @abc.abstractmethod - def attribution(self) -> str: - """ - Returns the required attribution text for the map service. - This text should be displayed whenever map tiles from this service are shown. - """ - pass - - @abc.abstractmethod - def get_tile_url(self, z: int, x: int, y: int) -> Optional[str]: - """ - Generates the full URL for a specific map tile based on its ZXY coordinates. - - Args: - z: The zoom level of the tile. - x: The X coordinate of the tile. - y: The Y coordinate of the tile. - - Returns: - The fully formed URL string for the tile, or None if the zoom level - is invalid for this service or if URL construction fails. - """ - pass - - def is_zoom_level_valid(self, zoom_level: int) -> bool: - """ - Checks if the requested zoom level is within the valid range for this service. - - Args: - zoom_level: The zoom level to validate. - - Returns: - True if the zoom level is valid, False otherwise. - """ - is_valid = 0 <= zoom_level <= self.max_zoom - if not is_valid: - logger.warning( - f"{self._service_log_prefix} Requested zoom level {zoom_level} is outside " - f"the valid range [0, {self.max_zoom}] for this service." - ) - return is_valid - - def _is_generated_url_structurally_valid(self, url_string: str) -> bool: - """Performs a basic structural validation of a generated URL string.""" - if not url_string: # Check for empty string - logger.error(f"{self._service_log_prefix} Generated URL is empty.") - return False - try: - parsed_url = urlparse(url_string) - # A valid URL typically has a scheme (http/https) and a netloc (domain name). - has_scheme_and_netloc = bool(parsed_url.scheme and parsed_url.netloc) - if not has_scheme_and_netloc: - logger.error(f"{self._service_log_prefix} Generated URL '{url_string}' appears malformed (missing scheme or netloc).") - return has_scheme_and_netloc - except Exception as e_url_parse: # Catch potential errors from urlparse itself - logger.error( - f"{self._service_log_prefix} Error parsing generated URL '{url_string}': {e_url_parse}" - ) - return False - - def __repr__(self) -> str: - """Provides a concise and informative string representation of the service object.""" - return ( - f"<{self.__class__.__name__}(Name: '{self.name}', MaxZoom: {self.max_zoom}, TileSize: {self.tile_size})>" - ) - - -class OpenStreetMapService(BaseMapService): - """ - Concrete implementation for fetching map tiles from OpenStreetMap (OSM). - This service does not require an API key. - """ - - SERVICE_IDENTIFIER_NAME: str = "osm" - SERVICE_ATTRIBUTION_TEXT: str = \ - "© OpenStreetMap contributors (openstreetmap.org/copyright)" - # Standard URL template for OSM tiles. Subdomains (a,b,c) can be used for load balancing. - TILE_URL_TEMPLATE: str = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" - # OSM standard tile servers typically support up to zoom level 19. - OSM_MAX_ZOOM_LEVEL: int = 19 - # Optional: cycle through subdomains for better load distribution - SUBDOMAINS: Tuple[str, ...] = ('a', 'b', 'c') - _subdomain_index: int = 0 # Class variable for simple round-robin - - def __init__(self) -> None: - """Initializes the OpenStreetMap tile service.""" - super().__init__( - service_api_key=None, # OSM does not require an API key - max_supported_zoom=self.OSM_MAX_ZOOM_LEVEL - ) - logger.info(f"{self._service_log_prefix} OpenStreetMap service instance ready.") - - @property - def name(self) -> str: - """Returns the unique name for the OpenStreetMap service.""" - return self.SERVICE_IDENTIFIER_NAME - - @property - def attribution(self) -> str: - """Returns the required attribution text for OpenStreetMap.""" - return self.SERVICE_ATTRIBUTION_TEXT - - def get_tile_url(self, z: int, x: int, y: int) -> Optional[str]: - """ - Generates the tile URL for an OpenStreetMap tile. - - Args: - z: The zoom level. - x: The tile X coordinate. - y: The tile Y coordinate. - - Returns: - The tile URL string, or None if the zoom level is invalid. - """ - if not self.is_zoom_level_valid(z): - # Warning logged by is_zoom_level_valid - return None - - # Simple round-robin for subdomains - subdomain = self.SUBDOMAINS[OpenStreetMapService._subdomain_index % len(self.SUBDOMAINS)] - OpenStreetMapService._subdomain_index += 1 - - try: - # Format the URL using the class template and selected subdomain - tile_url = self.TILE_URL_TEMPLATE.format(s=subdomain, z=z, x=x, y=y) - - if not self._is_generated_url_structurally_valid(tile_url): - # Error logged by _is_generated_url_structurally_valid - return None - - logger.debug(f"{self._service_log_prefix} Generated URL for ({z},{x},{y}): {tile_url}") - return tile_url - except Exception as e_url_format: # Catch potential errors during .format() - logger.error( - f"{self._service_log_prefix} Error formatting tile URL for ({z},{x},{y}): {e_url_format}" - ) - return None - - -# --- Factory Function to Get Map Service Instances --- - -def get_map_service_instance( - service_name_key: str, - api_key_value: Optional[str] = None -) -> Optional[BaseMapService]: - """ - Factory function to create and return an instance of a specific map service. - - Args: - service_name_key: The unique string identifier for the desired service (e.g., 'osm'). - api_key_value: The API key, if required by the selected service. - - Returns: - An instance of a BaseMapService subclass, or None if the service_name_key - is unknown or if a required API key is missing. - """ - log_prefix_factory = "[MapServiceFactory]" - normalized_service_name = service_name_key.lower().strip() - logger.debug(f"{log_prefix_factory} Requesting map service instance for '{normalized_service_name}'.") - - if normalized_service_name == OpenStreetMapService.SERVICE_IDENTIFIER_NAME: - return OpenStreetMapService() - # Example for a future service requiring an API key: - # elif normalized_service_name == "some_other_service_key": - # if api_key_value: - # return SomeOtherMapService(api_key=api_key_value) - # else: - # logger.error(f"{log_prefix_factory} API key is required for '{normalized_service_name}' but was not provided.") - # return None - else: - logger.error(f"{log_prefix_factory} Unknown map service name specified: '{service_name_key}'.") - return None \ No newline at end of file diff --git a/geoelevation/map_viewer/map_utils.py b/geoelevation/map_viewer/map_utils.py deleted file mode 100644 index 4355f10..0000000 --- a/geoelevation/map_viewer/map_utils.py +++ /dev/null @@ -1,1249 +0,0 @@ -# geoelevation/map_viewer/map_utils.py -""" -Provides utility functions for map-related calculations. - -Includes functions for determining geographic bounding boxes from center points -and sizes, finding necessary map tile ranges to cover an area using the -'mercantile' library, and calculating ground resolution (meters per pixel). -""" - -# Standard library imports -import logging -import math -from typing import Tuple, Optional, List, Set, Dict # Ensure all used types are imported - -# Third-party imports -try: - import pyproj # For geodetic calculations (e.g., bounding box from center) - PYPROJ_AVAILABLE = True -except ImportError: - pyproj = None # type: ignore - PYPROJ_AVAILABLE = False - logging.warning("MapUtils: 'pyproj' library not found. Some geodetic calculations will fail.") - -try: - import mercantile # For tile calculations (Web Mercator) - MERCANTILE_AVAILABLE_UTILS = True -except ImportError: - mercantile = None # type: ignore - MERCANTILE_AVAILABLE_UTILS = False - logging.warning("MapUtils: 'mercantile' library not found. Tile calculations will fail.") - -# Module-level logger -logger = logging.getLogger(__name__) - - -class MapCalculationError(Exception): - """Custom exception for errors occurring during map-related calculations.""" - pass - - -def get_bounding_box_from_center_size( - center_latitude_deg: float, - center_longitude_deg: float, - area_size_km: float -) -> Optional[Tuple[float, float, float, float]]: # (west_lon, south_lat, east_lon, north_lat) - """ - Calculates a geographic bounding box (WGS84 decimal degrees) given a center - point and a size (width/height of a square area). - - Args: - center_latitude_deg: Latitude of the center point in degrees. - center_longitude_deg: Longitude of the center point in degrees. - area_size_km: The side length of the desired square area in kilometers. - - Returns: - A tuple (west_lon, south_lat, east_lon, north_lat) in degrees, - or None if an error occurs or pyproj is unavailable. - - Raises: - ImportError: Propagated if pyproj is required but not installed. - (Note: function now returns None instead of raising directly for this) - """ - if not PYPROJ_AVAILABLE: - logger.error( - "'pyproj' library is required for bounding box calculation from center/size but is not found." - ) - return None # Return None instead of raising ImportError here to allow graceful degradation - - if not (isinstance(area_size_km, (int, float)) and area_size_km > 0): - logger.error(f"Invalid area_size_km: {area_size_km}. Must be a positive number.") - return None - if not (-90.0 <= center_latitude_deg <= 90.0): - logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}. Must be in [-90, 90].") - return None - if not (-180.0 <= center_longitude_deg <= 180.0): - logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}. Must be in [-180, 180].") - return None - - - logger.debug( - f"Calculating bounding box for center ({center_latitude_deg:.6f}, {center_longitude_deg:.6f}), " - f"size {area_size_km} km." - ) - - try: - # Initialize a geodetic calculator using the WGS84 ellipsoid - geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore - - half_side_length_meters = (area_size_km / 2.0) * 1000.0 - - # Calculate points by projecting from the center along cardinal directions - # Azimuths: 0=North, 90=East, 180=South, 270=West - # geod.fwd returns (end_longitude, end_latitude, back_azimuth) - _, north_boundary_lat, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=0.0, dist=half_side_length_meters - ) - _, south_boundary_lat, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=180.0, dist=half_side_length_meters - ) - east_boundary_lon, _, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=90.0, dist=half_side_length_meters - ) - west_boundary_lon, _, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=270.0, dist=half_side_length_meters - ) - - # Handle potential latitude clamping at poles - # pyproj should handle this correctly, but a sanity check can be useful - north_boundary_lat = min(90.0, max(-90.0, north_boundary_lat)) - south_boundary_lat = min(90.0, max(-90.0, south_boundary_lat)) - - # Handle longitude wrapping if the area is extremely large (unlikely for typical use) - # pyproj fwd/inv handles dateline crossing correctly for points. - # If west_lon > east_lon after projection, it implies dateline crossing. - # For bounding box, we typically want west < east unless it spans more than 180 deg. - # This simple assignment should be fine for areas not spanning the dateline/poles widely. - - logger.debug( - f"Calculated BBox: W={west_boundary_lon:.6f}, S={south_boundary_lat:.6f}, " - f"E={east_boundary_lon:.6f}, N={north_boundary_lat:.6f}" - ) - return (west_boundary_lon, south_boundary_lat, east_boundary_lon, north_boundary_lat) - - except Exception as e_bbox_calc: - logger.exception(f"Error calculating bounding box from center/size: {e_bbox_calc}") - return None - - -def get_tile_ranges_for_bbox( - bounding_box_deg: Tuple[float, float, float, float], # (west, south, east, north) - zoom_level: int -) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]: # ((min_x, max_x), (min_y, max_y)) - """ - Calculates the X and Y tile ranges (min_x, max_x, min_y, max_y) - required to cover a given geographic bounding box at a specific zoom level. - - Args: - bounding_box_deg: A tuple (west_lon, south_lat, east_lon, north_lat) in degrees. - zoom_level: The Web Mercator map zoom level. - - Returns: - A tuple containing ((min_x, max_x), (min_y, max_y)) tile coordinates, - or None if an error occurs or mercantile is unavailable. - """ - if not MERCANTILE_AVAILABLE_UTILS: - logger.error("'mercantile' library is required for tile range calculation but is not found.") - return None - - west_lon, south_lat, east_lon, north_lat = bounding_box_deg - logger.debug( - f"Calculating tile ranges for zoom {zoom_level} and BBox W={west_lon:.4f}, " - f"S={south_lat:.4f}, E={east_lon:.4f}, N={north_lat:.4f}" - ) - - try: - # mercantile.tiles expects (west, south, east, north) and zoom as integer - # It returns a generator of Tile(x, y, z) objects. - tiles_in_bbox_generator = mercantile.tiles( # type: ignore - west_lon, south_lat, east_lon, north_lat, zooms=[zoom_level] # Pass zoom as a list - ) - - list_of_tiles = list(tiles_in_bbox_generator) - - if not list_of_tiles: - # If the bbox is very small or outside standard tile limits, mercantile.tiles might be empty. - # As a fallback, find the tile containing the center of the bbox. - logger.warning( - f"No tiles found by mercantile.tiles for BBox at zoom {zoom_level}. " - "Using fallback: tile at BBox center." - ) - # Ensure coordinates are within valid WGS84 bounds before calculating center - clamped_west_lon = max(-180.0, min(180.0, west_lon)) - clamped_east_lon = max(-180.0, min(180.0, east_lon)) - clamped_south_lat = max(-90.0, min(90.0, south_lat)) - clamped_north_lat = max(-90.0, min(90.0, north_lat)) - - center_lon = (clamped_west_lon + clamped_east_lon) / 2.0 - center_lat = (clamped_south_lat + clamped_north_lat) / 2.0 - - # Clamp center_lat to avoid mercantile issues near poles if bbox extends beyond valid range - center_lat = max(-85.0, min(85.0, center_lat)) # Mercantile limits - approx. latitude of tile row 0 or max - # Ensure center_lon wraps correctly if bbox spans the antimeridian - if clamped_west_lon > clamped_east_lon: # Bbox crosses antimeridian - center_lon = (clamped_west_lon + clamped_east_lon + 360) / 2.0 - if center_lon > 180: center_lon -= 360 - - - # mercantile.tile(lon, lat, zoom) - center_point_tile = mercantile.tile(center_lon, center_lat, zoom_level) # type: ignore - - min_tile_x = center_point_tile.x - max_tile_x = center_point_tile.x - min_tile_y = center_point_tile.y - max_tile_y = center_point_tile.y - num_tiles_found = 1 - else: - # Extract all x and y coordinates from the list of tiles - x_coordinates = [tile.x for tile in list_of_tiles] - y_coordinates = [tile.y for tile in list_of_tiles] - - min_tile_x = min(x_coordinates) - max_tile_x = max(x_coordinates) - min_tile_y = min(y_coordinates) - max_tile_y = max(y_coordinates) - num_tiles_found = len(list_of_tiles) - - logger.debug( - f"Calculated tile ranges for zoom {zoom_level}: " - f"X=[{min_tile_x}, {max_tile_x}], Y=[{min_tile_y}, {max_tile_y}] " - f"({num_tiles_found} tiles)" - ) - return ((min_tile_x, max_tile_x), (min_tile_y, max_tile_y)) - - except Exception as e_tile_range_calc: - logger.exception(f"Error calculating tile ranges: {e_tile_range_calc}") - return None - - -def calculate_meters_per_pixel( - latitude_degrees: float, - zoom_level: int, - tile_pixel_size: int = 256 -) -> Optional[float]: - """ - Calculates the approximate ground resolution (meters per pixel) at a given - latitude and zoom level for the Web Mercator projection. - - Args: - latitude_degrees: Latitude in degrees. - zoom_level: Map zoom level. - tile_pixel_size: The size of one side of a map tile in pixels (usually 256). - - Returns: - Approximate meters per pixel, or None if calculation fails or inputs are invalid. - """ - try: - if not (-90.0 <= latitude_degrees <= 90.0): - logger.warning(f"Invalid latitude for m/px calc: {latitude_degrees}") - return None - # Zoom levels for Web Mercator are typically 0-22, some services go higher. - if not (0 <= zoom_level <= 25): # Practical upper limit - logger.warning(f"Invalid zoom level for m/px calc: {zoom_level}") - return None - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.warning(f"Invalid tile_pixel_size for m/px calc: {tile_pixel_size}") - return None - - - # Earth's equatorial circumference in meters (WGS84) - EARTH_CIRCUMFERENCE_METERS = 40075016.686 - - latitude_radians = math.radians(latitude_degrees) - - # Formula: Resolution = (Circumference * cos(latitude_rad)) / (tile_size * 2^zoom) - resolution_m_px = (EARTH_CIRCUMFERENCE_METERS * math.cos(latitude_radians)) / \ - (tile_pixel_size * (2**zoom_level)) - - # Avoid returning non-finite values if cos(latitude) is near zero at poles - if not math.isfinite(resolution_m_px) or resolution_m_px <= 0: - logger.warning(f"Calculated non-finite or non-positive m/px ({resolution_m_px}) at Lat {latitude_degrees}. Returning None.") - return None - - logger.debug( - f"Calculated meters/pixel at lat {latitude_degrees:.4f}, zoom {zoom_level}, " - f"tile_size {tile_pixel_size}px: {resolution_m_px:.4f} m/px" - ) - return resolution_m_px - - except Exception as e_mpp_calc: - logger.exception(f"Error calculating meters per pixel: {e_mpp_calc}") - return None - -# MODIFIED: Added function to calculate geographic size of a bounding box. -# WHY: Needed to report the displayed map area size in the GUI. -# HOW: Implemented logic using pyproj to calculate distances for width and height. -def calculate_geographic_bbox_size_km( - bounding_box_deg: Tuple[float, float, float, float] # (west, south, east, north) -) -> Optional[Tuple[float, float]]: # Returns (approx_width_km, approx_height_km) - """ - Calculates the approximate geographic width and height of a bounding box in kilometers. - Uses pyproj if available. Width is calculated along the center latitude, height along center longitude. - - Args: - bounding_box_deg: A tuple (west_lon, south_lat, east_lon, north_lat) in degrees. - - Returns: - A tuple (approx_width_km, approx_height_km), or None if calculation fails or pyproj is unavailable. - """ - if not PYPROJ_AVAILABLE: - logger.error( - "'pyproj' library is required for geographic size calculation but is not found." - ) - return None - - west_lon, south_lat, east_lon, north_lat = bounding_box_deg - - # Basic validation - if not (-90.0 <= south_lat <= north_lat <= 90.0): - logger.warning(f"Invalid latitude range for size calculation: {south_lat}, {north_lat}") - # Try clamping and continue, or return None? Let's clamp for robustness. - south_lat = max(-90.0, south_lat) - north_lat = min(90.0, north_lat) - if south_lat >= north_lat: # After clamping, check if still invalid - logger.error(f"Invalid latitude range after clamping: {south_lat}, {north_lat}. Cannot calculate size.") - return None - - - try: - geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore - - # Calculate approximate width along the center latitude - # The 'inv' method from pyproj.Geod is suitable for this, it handles the antimeridian. - center_lat = (south_lat + north_lat) / 2.0 - # Clamp center lat to avoid issues near poles for geod.inv - center_lat = max(-89.9, min(89.9, center_lat)) - - # geod.inv returns (forward_azimuth, backward_azimuth, distance) - _, _, width_meters = geodetic_calculator.inv(west_lon, center_lat, east_lon, center_lat) - - - # Calculate approximate height along the center longitude - # This is simpler, distance between south_lat and north_lat at center_lon - # Need to handle potential longitude wrap around - use inv carefully - # The straight line distance calculation below should be generally fine for height. - # Using the average longitude for height calculation line might not be strictly necessary, - # distance between two points at the same longitude is simple geodetic distance. - # However, using inv is more general and handles the ellipsoid correctly. - center_lon_for_height = (west_lon + east_lon) / 2.0 # Use the average longitude for height calculation line - # Handle antimeridian crossing for height calculation by adjusting center_lon_for_height if needed. - # If the width calculation crossed the antimeridian (west_lon > east_lon originally), the average might be misleading. - # A simpler approach for height is just calculating distance between (center_lon, south) and (center_lon, north). - # Let's reuse the original logic using the average longitude, ensuring it's within range. - center_lon_for_height = max(-180.0, min(180.0, center_lon_for_height)) # Ensure within standard range - - - _, _, height_meters = geodetic_calculator.inv(center_lon_for_height, south_lat, center_lon_for_height, north_lat) - - approx_width_km = abs(width_meters) / 1000.0 # Ensure positive distance - approx_height_km = abs(height_meters) / 1000.0 - - # Add a sanity check: if width or height are zero, something is wrong (e.g., points are identical) - if approx_width_km <= 0 or approx_height_km <= 0: - logger.warning(f"Calculated non-positive width or height for BBox {bounding_box_deg}. Result: ({approx_width_km:.2f}, {approx_height_km:.2f}). Returning None.") - return None - - - logger.debug( - f"Calculated BBox size for {bounding_box_deg}: " - f"Approx. {approx_width_km:.2f}km W x {approx_height_km:.2f}km H" - ) - return (approx_width_km, approx_height_km) - - except Exception as e_size_calc: - logger.exception(f"Error calculating geographic bounding box size: {e_size_calc}") - return None - -# MODIFIED: Added function to calculate the geographic bounds of an HGT tile from its integer coordinates. -# WHY: Needed to get the exact bounds of the DEM tile to determine the map fetch area and potentially draw the boundary. -# HOW: Based on HGT tile naming conventions (e.g., N45E007 covers 7E-8E, 45N-46N), the integer coordinates are the southwest corner. -def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Tuple[float, float, float, float]: - """ - Calculates the precise geographic bounding box (W, S, E, N) for an HGT tile - based on its integer latitude and longitude coordinates. - - Assumes standard HGT 1x1 degree tile coverage where lat_coord is the - southern boundary latitude and lon_coord is the western boundary longitude. - E.g., tile N45E007 (lat_coord=45, lon_coord=7) covers 7E-8E, 45N-46N. - - Args: - lat_coord: The integer latitude coordinate of the tile (e.g., 45 for N45). - lon_coord: The integer longitude coordinate of the tile (e.g., 7 for E007). - - Returns: - A tuple (west_lon, south_lat, east_lon, north_lat) in degrees. - """ - west_lon = float(lon_coord) - south_lat = float(lat_coord) - east_lon = float(lon_coord + 1) - north_lat = float(lat_coord + 1) - - # Clamping to strict WGS84 bounds for sanity, though tile coords should respect this - west_lon = max(-180.0, min(180.0, west_lon)) - south_lat = max(-90.0, min(90.0, south_lat)) - east_lon = max(-180.0, min(180.0, east_lon)) - north_lat = max(-90.0, min(90.0, north_lat)) - - # Ensure west < east and south < north for valid bbox representation, handling wrap-around is complex - # For 1x1 degree tiles not crossing antimeridian or poles widely, this is fine. - # A more robust check might be needed for edge cases near antimeridian, but for 1x1 tiles this is usually okay. - if west_lon > east_lon: - # This case ideally shouldn't happen for 1x1 degree tiles unless lon_coord is 179 and it wraps, - # but let's handle defensively if coords are unusual. Could swap or adjust. - # For HGT tile boundaries, it's usually [lon, lon+1] so this shouldn't be an issue. - logger.warning(f"Calculated west > east for HGT tile bounds ({lat_coord},{lon_coord}): ({west_lon}, {east_lon}).") - # Assuming it's a simple swap needed - # west_lon, east_lon = east_lon, west_lon # This might not be correct if it actually wraps the globe - pass # Let's stick to the direct calculation based on HGT convention - - logger.debug(f"Calculated HGT tile bounds for ({lat_coord},{lon_coord}): ({west_lon:.6f}, {south_lat:.6f}, {east_lon:.6f}, {north_lat:.6f})") - return (west_lon, south_lat, east_lon, north_lat) - - -# MODIFIED: Added function to calculate required zoom level for a target geographic size to fit in a target pixel size. -# WHY: To determine the appropriate zoom level for displaying the 1x1 degree DEM tile area within a manageable pixel size. -# HOW: Used the inverse of the calculate_meters_per_pixel formula to solve for the zoom level. -def calculate_zoom_level_for_geographic_size( - latitude_degrees: float, - geographic_height_meters: float, # The height of the area you want to fit in pixels - target_pixel_height: int, # The desired pixel height for that geographic area - tile_pixel_size: int = 256 -) -> Optional[int]: - """ - Calculates the approximate Web Mercator zoom level required for a given - geographic height (in meters at a specific latitude) to span a target pixel height. - - This is useful for determining the zoom needed to fit a known geographic area - (like a DEM tile's height) into a certain number of pixels on a map composed of tiles. - - Args: - latitude_degrees: The latitude at which the geographic_height_meters is measured. - geographic_height_meters: The actual height of the geographic area in meters at that latitude. - target_pixel_height: The desired height in pixels for that geographic area on the map. - tile_pixel_size: The size of one side of a map tile in pixels (usually 256). - - Returns: - The approximate integer zoom level, or None if calculation fails or inputs are invalid. - """ - if not (-90.0 <= latitude_degrees <= 90.0): - logger.warning(f"Invalid latitude for zoom calculation: {latitude_degrees}") - return None - if not (isinstance(geographic_height_meters, (int, float)) and geographic_height_meters > 0): - logger.warning(f"Invalid geographic_height_meters for zoom calculation: {geographic_height_meters}. Must be positive.") - return None - if not (isinstance(target_pixel_height, int) and target_pixel_height > 0): - logger.warning(f"Invalid target_pixel_height for zoom calculation: {target_pixel_height}. Must be positive integer.") - return None - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.warning(f"Invalid tile_pixel_size for zoom calculation: {tile_pixel_size}. Must be positive integer.") - return None - - try: - # Earth's equatorial circumference in meters (WGS84) - EARTH_CIRCUMFERENCE_METERS = 40075016.686 - - # Calculate the required meters per pixel (resolution) to fit the geographic height into the target pixel height - required_resolution_m_px = geographic_height_meters / target_pixel_height - - # Avoid division by zero or non-finite results - if required_resolution_m_px <= 0 or not math.isfinite(required_resolution_m_px): - logger.warning(f"Calculated non-positive or non-finite required resolution ({required_resolution_m_px} m/px). Cannot calculate zoom.") - return None - - - # Use the inverse of the resolution formula: Resolution = (Circumference * cos(latitude_rad)) / (tile_size * 2^z) - # Rearranging for 2^z: 2^z = (Circumference * cos(latitude_rad)) / (tile_size * Resolution) - # Solving for z: z = log2( (Circumference * cos(latitude_rad)) / (tile_size * Resolution) ) - - latitude_radians = math.radians(latitude_degrees) - cos_lat = math.cos(latitude_radians) - - # Avoid division by zero or log of zero/negative if cos_lat is near zero (at poles) - # The latitude validation should prevent hitting exactly 90/-90, but check for very small values. - if abs(cos_lat) < 1e-9: # Handle near poles - logger.warning(f"Latitude {latitude_degrees} is too close to a pole for reliable zoom calculation.") - # Return a very low zoom level as a fallback? Or None? - # Given the context (mapping a DEM tile), this likely won't happen as DEMs stop at 60 deg. - return None # Return None for latitudes very close to poles - - term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * required_resolution_m_px) - - # Ensure the argument for log2 is positive - if term_for_log <= 0: - logger.warning(f"Calculated non-positive term for log2 ({term_for_log}) during zoom calculation. Cannot calculate zoom.") - return None - - # Calculate the precise zoom level (can be fractional) - precise_zoom = math.log2(term_for_log) - - # We need an integer zoom level for tile fetching. Rounding to the nearest integer is common. - # Floor might get fewer tiles than needed, ceil might get more. Rounding is a good balance. - integer_zoom = int(round(precise_zoom)) - - # Clamp the calculated zoom level to a reasonable range (e.g., 0 to 20) - # A zoom level too high (e.g., > 22) might not be supported by map services. - # A level too low (negative) indicates an issue or a request for an impossibly large area. - # We should probably cap it to the map service's max zoom as well, but that info isn't available here. - # Let's clamp it to a general reasonable range. - clamped_zoom = max(0, min(integer_zoom, 20)) # Max zoom of 20 is usually safe for OSM - - logger.debug( - f"Calculated zoom for {geographic_height_meters:.2f}m at Lat {latitude_degrees:.4f} " - f"to fit in {target_pixel_height}px: Precise Zoom {precise_zoom:.2f}, Clamped Integer Zoom {clamped_zoom}" - ) - - return clamped_zoom - except Exception as e_zoom_calc: - logger.exception(f"Error calculating zoom level: {e_zoom_calc}") - return None - -# MODIFIED: Added utility function to convert decimal degrees to DMS string format. -# WHY: Needed for displaying coordinates in a user-friendly, copyable format in the GUI. -# HOW: Implemented standard conversion logic. -def deg_to_dms_string(degree_value: float, coord_type: str) -> str: - """ - Converts a decimal degree coordinate to a Degrees, Minutes, Seconds (DMS) string. - - Args: - degree_value: The coordinate value in decimal degrees (float). - coord_type: 'lat' for latitude (determines N/S suffix), 'lon' for longitude (E/W suffix). - - Returns: - A formatted DMS string (e.g., "45° 30' 15.23'' N"). Returns "N/A" for non-finite or invalid inputs. - """ - if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): - # Handle None, NaN, Inf, etc. - return "N/A" - - # Clamp to valid ranges for sanity, although conversion works outside. - # Note: DMS representation is strictly defined for lat [-90, 90] and lon [-180, 180] - if coord_type.lower() == 'lat': - if not -90.0 <= degree_value <= 90.0: - logger.warning(f"Latitude {degree_value} is outside valid range [-90, 90] for standard DMS.") - # Still convert, but maybe add a note or handle separately if needed. - # For now, just convert the clamped value. - degree_value = max(-90.0, min(90.0, degree_value)) - elif coord_type.lower() == 'lon': - # Longitude wrapping - DMS usually represents within [-180, 180]. - # Python's % operator handles negative numbers differently than some other languages, - # (a % b) has the same sign as b. So ((value + 180) % 360 - 180) ensures the result - # is in (-180, 180]. If the input is exactly 180, it becomes 180. - degree_value = ((degree_value + 180) % 360) - 180 - # Check if exactly -180.0 and adjust to 180.0 if needed for conventional representation - # if degree_value == -180.0: - # degree_value = 180.0 - - else: - logger.warning(f"Unknown coordinate type '{coord_type}' for DMS conversion.") - return "N/A (Invalid Type)" - - - is_negative = degree_value < 0 - abs_degree = abs(degree_value) - - degrees = int(abs_degree) - minutes_decimal = (abs_degree - degrees) * 60 - minutes = int(minutes_decimal) - seconds = (minutes_decimal - minutes) * 60 - - # Determine suffix (North/South, East/West) - suffix = "" - if coord_type.lower() == 'lat': - suffix = "N" if not is_negative else "S" - elif coord_type.lower() == 'lon': - # Longitude can be 0 or 180, technically not negative/positive in terms of E/W. - # Let's use the sign logic which is correct for the suffix E/W convention. - suffix = "E" if not is_negative else "W" - # Special case for exactly 0 or 180, maybe no suffix or specific suffix? - # Standard practice is usually to use E for 0 and 180 if positive/negative sign is ignored. - if degree_value == 0.0: suffix = "" # No suffix for 0? Or E? Let's use "" - elif abs(degree_value) == 180.0: suffix = "" # No suffix for 180? Or W? Let's use "" - - # Format the string - # Use a consistent number of decimal places for seconds, e.g., 2 - # Handle potential edge case where seconds round up to 60 - if seconds >= 59.995: # Check if seconds are very close to 60 due to float precision - seconds = 0.0 - minutes += 1 - if minutes >= 60: - minutes = 0 - degrees += 1 - # Degree could potentially roll over if it was like 89 deg 59 min 59.99 sec - # This simple logic assumes it won't cross 90 or 180 significantly with typical inputs - # A more robust implementation might need to handle full degree rollover, but unlikely for standard inputs. - # For now, just increment degree if minutes rolled over from 59 to 60. - - - dms_string = f"{degrees}° {minutes}' {seconds:.2f}''" - - # Add suffix only if it's meaningful (not empty) - if suffix: - dms_string += f" {suffix}" - - - 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") - # 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 tile_bounds: # Ensure bounds were calculated successfully - 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]) - else: - logger.warning(f"Could not get geographic bounds for tile ({lat_coord},{lon_coord}), skipping.") - - - 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. - # A more robust check might be needed for edge cases near antimeridian, but for 1x1 tiles this is usually fine. - # For simplicity, assume min_lat < max_lat and min_lon < max_lon unless it crosses the antimeridian. - # The get_hgt_tile_geographic_bounds clamps, so min/max comparison should work generally. - # If the combined area crosses the antimeridian, min_lon_combined will be > max_lon_combined. - # We might need to represent this differently if we need a bbox spanning the antimeridian, - # but for simple min/max extent, the current logic is okay for non-global areas. - # Let's just check for egregious errors like south > north. - if min_lat_combined > max_lat_combined: - logger.warning(f"Calculated invalid combined latitude range: S={min_lat_combined}, N={max_lat_combined}. Returning None.") - return None - # Longitude range check is tricky with antimeridian. Assuming for typical areas the min/max works. - - - 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) - - -# MODIFIED: Added new utility function to calculate a geographic bounding box -# given a center point, desired pixel dimensions, and zoom level. -# WHY: Necessary for interactive zoom to determine the map area to fetch and display -# while maintaining a relatively constant pixel size for the map window. -# HOW: Uses the inverse of the meters per pixel calculation to find the geographic -# width and height corresponding to the desired pixel dimensions at the given -# latitude and zoom. Then uses pyproj to find the geographic coordinates -# of the corners of a bounding box centered on the input point with these -# calculated geographic width/height. -def calculate_geographic_bbox_from_pixel_size_and_zoom( - center_latitude_deg: float, - center_longitude_deg: float, - target_pixel_width: int, - target_pixel_height: int, - zoom_level: int, - tile_pixel_size: int = 256 -) -> Optional[Tuple[float, float, float, float]]: # (west_lon, south_lat, east_lon, north_lat) - """ - Calculates the geographic bounding box centered on a point that corresponds - to a specific pixel size at a given zoom level and latitude. - - Args: - center_latitude_deg: Latitude of the center point in degrees. - center_longitude_deg: Longitude of the center point in degrees. - target_pixel_width: Desired pixel width for the bounding box. - target_pixel_height: Desired pixel height for the bounding box. - zoom_level: Map zoom level. - tile_pixel_size: The size of one side of a map tile in pixels. - - Returns: - A tuple (west_lon, south_lat, east_lon, north_lat) in degrees, - or None if calculation fails or dependencies are unavailable. - """ - if not PYPROJ_AVAILABLE: - logger.error( - "'pyproj' library is required for bbox calculation but is not found." - ) - return None - if not MERCANTILE_AVAILABLE_UTILS: - logger.error( - "'mercantile' library is required for bbox calculation but is not found." - ) - return None - - if not (-90.0 <= center_latitude_deg <= 90.0): - logger.error(f"Invalid center_latitude_deg for bbox calc: {center_latitude_deg}") - return None - if not (-180.0 <= center_longitude_deg <= 180.0): - logger.error(f"Invalid center_longitude_deg for bbox calc: {center_longitude_deg}") - return None - if not (isinstance(target_pixel_width, int) and target_pixel_width > 0 and - isinstance(target_pixel_height, int) and target_pixel_height > 0): - logger.error(f"Invalid target pixel dimensions ({target_pixel_width}x{target_pixel_height}) for bbox calc.") - return None - if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): # Clamp to practical max zoom - logger.warning(f"Invalid zoom level for bbox calc: {zoom_level}. Clamping to [0, 20].") - zoom_level = max(0, min(zoom_level, 20)) # Clamp - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.error(f"Invalid tile_pixel_size for bbox calc: {tile_pixel_size}") - return None - - - logger.debug( - f"Calculating geographic bbox for center ({center_latitude_deg:.6f}, {center_longitude_deg:.6f}) " - f"at zoom {zoom_level} for {target_pixel_width}x{target_pixel_height}px." - ) - - try: - # Calculate the ground resolution (meters per pixel) at the center latitude for the given zoom - resolution_m_px = calculate_meters_per_pixel(center_latitude_deg, zoom_level, tile_pixel_size) - - if resolution_m_px is None or resolution_m_px <= 0 or not math.isfinite(resolution_m_px): - logger.error("Could not calculate meters per pixel for bbox calculation.") - return None - - # Calculate the geographic width and height in meters corresponding to the target pixel dimensions - geographic_width_meters = target_pixel_width * resolution_m_px - geographic_height_meters = target_pixel_height * resolution_m_px - - # Now, use pyproj to find the geographic coordinates of the corners of a bounding box - # centered on the input point with these geographic dimensions. - geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore - - # Calculate points by projecting from the center along cardinal directions - half_width_meters = geographic_width_meters / 2.0 - half_height_meters = geographic_height_meters / 2.0 - - # geod.fwd returns (end_longitude, end_latitude, back_azimuth) - _, north_boundary_lat, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=0.0, dist=half_height_meters - ) - _, south_boundary_lat, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=180.0, dist=half_height_meters - ) - east_boundary_lon, _, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=90.0, dist=half_width_meters - ) - west_boundary_lon, _, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=270.0, dist=half_width_meters - ) - - # Clamp latitude boundaries to WGS84 range - north_boundary_lat = min(90.0, max(-90.0, north_boundary_lat)) - south_boundary_lat = min(90.0, max(-90.0, south_boundary_lat)) - - # Longitude wrapping should be handled by geod.fwd/inv, but ensure the order is correct for a bbox - # If west_lon > east_lon after projection, it implies crossing the antimeridian. - # The min/max approach might not correctly represent a bbox spanning the antimeridian. - # For simplicity, if west > east, we assume it's a normal bbox and swap, - # which is okay for areas not crossing the antimeridian widely. - # A more robust solution for antimeridian spanning areas would involve mercantile's bbox handling. - # Let's stick to the simpler approach assuming areas don't span the antimeridian widely. - - - logger.debug( - f"Calculated BBox for {target_pixel_width}x{target_pixel_height}px at zoom {zoom_level}: " - f"W={west_boundary_lon:.6f}, S={south_boundary_lat:.6f}, " - f"E={east_boundary_lon:.6f}, N={north_boundary_lat:.6f}" - ) - - # Check for invalid bbox (e.g. zero size) - if west_boundary_lon == east_boundary_lon or south_boundary_lat == north_boundary_lat: - logger.warning("Calculated zero-size geographic bbox. Returning None.") - return None - - return (west_boundary_lon, south_boundary_lat, east_boundary_lon, north_boundary_lat) - - except Exception as e_bbox_calc: - logger.exception(f"Error calculating geographic bbox from pixel size and zoom: {e_bbox_calc}") - return None - -# MODIFIED: Added utility function to convert decimal degrees to DMS string format. -# WHY: Needed for displaying coordinates in a user-friendly, copyable format in the GUI. -# HOW: Implemented standard conversion logic. -def deg_to_dms_string(degree_value: float, coord_type: str) -> str: - """ - Converts a decimal degree coordinate to a Degrees, Minutes, Seconds (DMS) string. - - Args: - degree_value: The coordinate value in decimal degrees (float). - coord_type: 'lat' for latitude (determines N/S suffix), 'lon' for longitude (E/W suffix). - - Returns: - A formatted DMS string (e.g., "45° 30' 15.23'' N"). Returns "N/A" for non-finite or invalid inputs. - """ - if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): - # Handle None, NaN, Inf, etc. - return "N/A" - - # Clamp to valid ranges for sanity, although conversion works outside. - # Note: DMS representation is strictly defined for lat [-90, 90] and lon [-180, 180] - if coord_type.lower() == 'lat': - if not -90.0 <= degree_value <= 90.0: - logger.warning(f"Latitude {degree_value} is outside valid range [-90, 90] for standard DMS.") - # Still convert, but maybe add a note or handle separately if needed. - # For now, just convert the clamped value. - degree_value = max(-90.0, min(90.0, degree_value)) - elif coord_type.lower() == 'lon': - # Longitude wrapping - DMS usually represents within [-180, 180]. - # Python's % operator handles negative numbers differently than some other languages, - # (a % b) has the same sign as b. So ((value + 180) % 360 - 180) ensures the result - # is in (-180, 180]. If the input is exactly 180, it becomes 180. - degree_value = ((degree_value + 180) % 360) - 180 - # Check if exactly -180.0 and adjust to 180.0 if needed for conventional representation - # if degree_value == -180.0: - # degree_value = 180.0 - - else: - logger.warning(f"Unknown coordinate type '{coord_type}' for DMS conversion.") - return "N/A (Invalid Type)" - - - is_negative = degree_value < 0 - abs_degree = abs(degree_value) - - degrees = int(abs_degree) - minutes_decimal = (abs_degree - degrees) * 60 - minutes = int(minutes_decimal) - seconds = (minutes_decimal - minutes) * 60 - - # Determine suffix (North/South, East/West) - suffix = "" - if coord_type.lower() == 'lat': - suffix = "N" if not is_negative else "S" - elif coord_type.lower() == 'lon': - # Longitude can be 0 or 180, technically not negative/positive in terms of E/W. - # Let's use the sign logic which is correct for the suffix E/W convention. - suffix = "E" if not is_negative else "W" - # Special case for exactly 0 or 180, maybe no suffix or specific suffix? - # Standard practice is usually to use E for 0 and 180 if positive/negative sign is ignored. - if degree_value == 0.0: suffix = "" # No suffix for 0? Or E? Let's use "" - elif abs(degree_value) == 180.0: suffix = "" # No suffix for 180? Or W? Let's use "" - - # Format the string - # Use a consistent number of decimal places for seconds, e.g., 2 - # Handle potential edge case where seconds round up to 60 - if seconds >= 59.995: # Check if seconds are very close to 60 due to float precision - seconds = 0.0 - minutes += 1 - if minutes >= 60: - minutes = 0 - degrees += 1 - # Degree could potentially roll over if it was like 89 deg 59 min 59.99 sec - # This simple logic assumes it won't cross 90 or 180 significantly with typical inputs - # A more robust implementation might need to handle full degree rollover, but unlikely for standard inputs. - # For now, just increment degree if minutes rolled over from 59 to 60. - - - dms_string = f"{degrees}° {minutes}' {seconds:.2f}''" - - # Add suffix only if it's meaningful (not empty) - if suffix: - dms_string += f" {suffix}" - - - 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") - # 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 tile_bounds: # Ensure bounds were calculated successfully - 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. - # A more robust check might be needed for edge cases near antimeridian, but for 1x1 tiles this is usually okay. - # For simplicity, assume min_lat < max_lat and min_lon < max_lon unless it crosses the antimeridian. - # The get_hgt_tile_geographic_bounds clamps, so min/max comparison should work generally. - # If the combined area crosses the antimeridian, min_lon_combined will be > max_lon_combined. - # We might need to represent this differently if we need a bbox spanning the antimeridian, - # but for simple min/max extent, the current logic is okay for non-global areas. - # Let's just check for egregious errors like south > north. - if min_lat_combined > max_lat_combined: - logger.warning(f"Calculated invalid combined latitude range: S={min_lat_combined}, N={max_lat_combined}. Returning None.") - return None - # Longitude range check is tricky with antimeridian. Assuming for typical areas the min/max works. - - - 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) - - -# MODIFIED: Added new utility function to calculate a geographic bounding box -# given a center point, desired pixel dimensions, and zoom level. -# WHY: Necessary for interactive zoom to determine the map area to fetch and display -# while maintaining a relatively constant pixel size for the map window. -# HOW: Uses the inverse of the meters per pixel calculation to find the geographic -# width and height corresponding to the desired pixel dimensions at the given -# latitude and zoom. Then uses pyproj to find the geographic coordinates -# of the corners of a bounding box centered on the input point with these -# calculated geographic width/height. -def calculate_geographic_bbox_from_pixel_size_and_zoom( - center_latitude_deg: float, - center_longitude_deg: float, - target_pixel_width: int, - target_pixel_height: int, - zoom_level: int, - tile_pixel_size: int = 256 -) -> Optional[Tuple[float, float, float, float]]: # (west_lon, south_lat, east_lon, north_lat) - """ - Calculates the geographic bounding box centered on a point that corresponds - to a specific pixel size at a given zoom level and latitude. - - Args: - center_latitude_deg: Latitude of the center point in degrees. - center_longitude_deg: Longitude of the center point in degrees. - target_pixel_width: Desired pixel width for the bounding box. - target_pixel_height: Desired pixel height for the bounding box. - zoom_level: Map zoom level. - tile_pixel_size: The size of one side of a map tile in pixels. - - Returns: - A tuple (west_lon, south_lat, east_lon, north_lat) in degrees, - or None if calculation fails or dependencies are unavailable. - """ - if not PYPROJ_AVAILABLE: - logger.error( - "'pyproj' library is required for bbox calculation but is not found." - ) - return None - if not MERCANTILE_AVAILABLE_UTILS: - logger.error( - "'mercantile' library is required for bbox calculation but is not found." - ) - return None - - if not (-90.0 <= center_latitude_deg <= 90.0): - logger.error(f"Invalid center_latitude_deg for bbox calc: {center_latitude_deg}") - return None - if not (-180.0 <= center_longitude_deg <= 180.0): - logger.error(f"Invalid center_longitude_deg for bbox calc: {center_longitude_deg}") - return None - if not (isinstance(target_pixel_width, int) and target_pixel_width > 0 and - isinstance(target_pixel_height, int) and target_pixel_height > 0): - logger.error(f"Invalid target pixel dimensions ({target_pixel_width}x{target_pixel_height}) for bbox calc.") - return None - if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25): # Clamp to practical max zoom - logger.warning(f"Invalid zoom level for bbox calc: {zoom_level}. Clamping to [0, 20].") - zoom_level = max(0, min(zoom_level, 20)) # Clamp - if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0): - logger.error(f"Invalid tile_pixel_size for bbox calc: {tile_pixel_size}") - return None - - - logger.debug( - f"Calculating geographic bbox for center ({center_latitude_deg:.6f}, {center_longitude_deg:.6f}) " - f"at zoom {zoom_level} for {target_pixel_width}x{target_pixel_height}px." - ) - - try: - # Calculate the ground resolution (meters per pixel) at the center latitude for the given zoom - resolution_m_px = calculate_meters_per_pixel(center_latitude_deg, zoom_level, tile_pixel_size) - - if resolution_m_px is None or resolution_m_px <= 0 or not math.isfinite(resolution_m_px): - logger.error("Could not calculate meters per pixel for bbox calculation.") - return None - - # Calculate the geographic width and height in meters corresponding to the target pixel dimensions - geographic_width_meters = target_pixel_width * resolution_m_px - geographic_height_meters = target_pixel_height * resolution_m_px - - # Now, use pyproj to find the geographic coordinates of the corners of a bounding box - # centered on the input point with these geographic dimensions. - geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore - - # Calculate points by projecting from the center along cardinal directions - half_width_meters = geographic_width_meters / 2.0 - half_height_meters = geographic_height_meters / 2.0 - - # geod.fwd returns (end_longitude, end_latitude, back_azimuth) - _, north_boundary_lat, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=0.0, dist=half_height_meters - ) - _, south_boundary_lat, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=180.0, dist=half_height_meters - ) - east_boundary_lon, _, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=90.0, dist=half_width_meters - ) - west_boundary_lon, _, _ = geodetic_calculator.fwd( - lons=center_longitude_deg, lats=center_latitude_deg, az=270.0, dist=half_width_meters - ) - - # Clamp latitude boundaries to WGS84 range - north_boundary_lat = min(90.0, max(-90.0, north_boundary_lat)) - south_boundary_lat = min(90.0, max(-90.0, south_boundary_lat)) - - # Longitude wrapping should be handled by geod.fwd/inv, but ensure the order is correct for a bbox - # If west_lon > east_lon after projection, it implies crossing the antimeridian. - # The min/max approach might not correctly represent a bbox spanning the antimeridian. - # For simplicity, if west > east, we assume it's a normal bbox and swap, - # which is okay for areas not crossing the antimeridian widely. - # A more robust solution for antimeridian spanning areas would involve mercantile's bbox handling. - # Let's stick to the simpler approach assuming areas don't span the antimeridian widely. - - - logger.debug( - f"Calculated BBox for {target_pixel_width}x{target_pixel_height}px at zoom {zoom_level}: " - f"W={west_boundary_lon:.6f}, S={south_boundary_lat:.6f}, " - f"E={east_boundary_lon:.6f}, N={north_boundary_lat:.6f}" - ) - - # Check for invalid bbox (e.g. zero size) - if west_boundary_lon == east_boundary_lon or south_boundary_lat == north_boundary_lat: - logger.warning("Calculated zero-size geographic bbox. Returning None.") - return None - - return (west_boundary_lon, south_boundary_lat, east_boundary_lon, north_boundary_lat) # Return as (west, south, east, north) - - - except Exception as e_bbox_calc: - logger.exception(f"Error calculating geographic bbox from pixel size and zoom: {e_bbox_calc}") - return None - -# MODIFIED: Added utility function to convert decimal degrees to DMS string format. -# WHY: Needed for displaying coordinates in a user-friendly, copyable format in the GUI. -# HOW: Implemented standard conversion logic. -def deg_to_dms_string(degree_value: float, coord_type: str) -> str: - """ - Converts a decimal degree coordinate to a Degrees, Minutes, Seconds (DMS) string. - - Args: - degree_value: The coordinate value in decimal degrees (float). - coord_type: 'lat' for latitude (determines N/S suffix), 'lon' for longitude (E/W suffix). - - Returns: - A formatted DMS string (e.g., "45° 30' 15.23'' N"). Returns "N/A" for non-finite or invalid inputs. - """ - if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value): - # Handle None, NaN, Inf, etc. - return "N/A" - - # Clamp to valid ranges for sanity, although conversion works outside. - # Note: DMS representation is strictly defined for lat [-90, 90] and lon [-180, 180] - if coord_type.lower() == 'lat': - if not -90.0 <= degree_value <= 90.0: - logger.warning(f"Latitude {degree_value} is outside valid range [-90, 90] for standard DMS.") - # Still convert, but maybe add a note or handle separately if needed. - # For now, just convert the clamped value. - degree_value = max(-90.0, min(90.0, degree_value)) - elif coord_type.lower() == 'lon': - # Longitude wrapping - DMS usually represents within [-180, 180]. - # Python's % operator handles negative numbers differently than some other languages, - # (a % b) has the same sign as b. So ((value + 180) % 360 - 180) ensures the result - # is in (-180, 180]. If the input is exactly 180, it becomes 180. - degree_value = ((degree_value + 180) % 360) - 180 - # Check if exactly -180.0 and adjust to 180.0 if needed for conventional representation - # if degree_value == -180.0: - # degree_value = 180.0 - - else: - logger.warning(f"Unknown coordinate type '{coord_type}' for DMS conversion.") - return "N/A (Invalid Type)" - - - is_negative = degree_value < 0 - abs_degree = abs(degree_value) - - degrees = int(abs_degree) - minutes_decimal = (abs_degree - degrees) * 60 - minutes = int(minutes_decimal) - seconds = (minutes_decimal - minutes) * 60 - - # Determine suffix (North/South, East/West) - suffix = "" - if coord_type.lower() == 'lat': - suffix = "N" if not is_negative else "S" - elif coord_type.lower() == 'lon': - # Longitude can be 0 or 180, technically not negative/positive in terms of E/W. - # Let's use the sign logic which is correct for the suffix E/W convention. - suffix = "E" if not is_negative else "W" - # Special case for exactly 0 or 180, maybe no suffix or specific suffix? - # Standard practice is usually to use E for 0 and 180 if positive/negative sign is ignored. - if degree_value == 0.0: suffix = "" # No suffix for 0? Or E? Let's use "" - elif abs(degree_value) == 180.0: suffix = "" # No suffix for 180? Or W? Let's use "" - - # Format the string - # Use a consistent number of decimal places for seconds, e.g., 2 - # Handle potential edge case where seconds round up to 60 - if seconds >= 59.995: # Check if seconds are very close to 60 due to float precision - seconds = 0.0 - minutes += 1 - if minutes >= 60: - minutes = 0 - degrees += 1 - # Degree could potentially roll over if it was like 89 deg 59 min 59.99 sec - # This simple logic assumes it won't cross 90 or 180 significantly with typical inputs - # A more robust implementation might need to handle full degree rollover, but unlikely for standard inputs. - # For now, just increment degree if minutes rolled over from 59 to 60. - - - dms_string = f"{degrees}° {minutes}' {seconds:.2f}''" - - # Add suffix only if it's meaningful (not empty) - if suffix: - dms_string += f" {suffix}" - - - 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") - # 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 tile_bounds: # Ensure bounds were calculated successfully - 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. - # A more robust check might be needed for edge cases near antimeridian, but for 1x1 tiles this is usually okay. - # For simplicity, assume min_lat < max_lat and min_lon < max_lon unless it crosses the antimeridian. - # The get_hgt_tile_geographic_bounds clamps, so min/max comparison should work generally. - # If the combined area crosses the antimeridian, min_lon_combined will be > max_lon_combined. - # We might need to represent this differently if we need a bbox spanning the antimeridian, - # but for simple min/max extent, the current logic is okay for non-global areas. - # Let's just check for egregious errors like south > north. - if min_lat_combined > max_lat_combined: - logger.warning(f"Calculated invalid combined latitude range: S={min_lat_combined}, N={max_lat_combined}. Returning None.") - return None - # Longitude range check is tricky with antimeridian. Assuming for typical areas the min/max works. - - - 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 index d6979bd..adc3f94 100644 --- a/geoelevation/process_targets.py +++ b/geoelevation/process_targets.py @@ -226,55 +226,62 @@ def run_map_viewer_process_target( 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 + # Local imports: use the external map_manager package for map rendering and engine + from map_manager.engine import MapEngine as ChildMapEngine + from map_manager.visualizer import MapVisualizer as ChildMapVisualizer 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 + import cv2 as child_cv2 # Optional, used by MapVisualizer if available + import numpy as child_np 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 + return # --- 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. + # Initialize ElevationManager and the map engine/visualizer from external package local_em = ChildElevationManager(tile_directory=dem_data_cache_dir) + # Initialize map engine and visualizer + engine = ChildMapEngine(service_name='osm', cache_dir=dem_data_cache_dir, enable_online=True) + visual = ChildMapVisualizer(engine) - # MODIFIED: Pass initial view parameters to the GeoElevationMapViewer constructor. - # WHY: The class now loads the initial view internally based on these parameters. - # HOW: Added initial_operation_mode, initial_point_coords, initial_area_bbox arguments. - # The constructor will handle which parameters are relevant based on initial_operation_mode. - child_map_viewer_instance = ChildGeoMapViewer( - elevation_manager_instance=local_em, - gui_output_communication_queue=map_interaction_q, - initial_display_scale=display_scale_factor, - initial_operation_mode=operation_mode, - initial_point_coords=(center_latitude, center_longitude) if operation_mode == "point" else None, - initial_area_bbox=area_bounding_box if operation_mode == "area" else None - ) - child_logger.info("Child GeoElevationMapViewer instance initialized.") + child_logger.info("Child MapEngine and MapVisualizer instances initialized.") - - # MODIFIED: REMOVE the old block that called display_map_for_point/area. - # WHY: The initial map view loading is now handled by the GeoElevationMapViewer constructor. - # HOW: Deleted the following if/elif/else block: + # Show map depending on requested operation mode + if operation_mode == 'point' and center_latitude is not None and center_longitude is not None: + child_logger.info(f"Showing point {center_latitude},{center_longitude} via MapVisualizer.show_point") + try: + visual.show_point(center_latitude, center_longitude) + except Exception as e_vis: + child_logger.error(f"Error while showing point via MapVisualizer: {e_vis}") + elif operation_mode == 'area' and area_bounding_box: + child_logger.info(f"Creating area image for bbox {area_bounding_box} via MapEngine.get_image_for_area") + try: + img = engine.get_image_for_area(area_bounding_box, zoom=None, max_size=800) + if img: + visual.show_pil_image(img) + else: + child_logger.error("MapEngine returned no image for area request") + except Exception as e_area: + child_logger.error(f"Error while creating/showing area image: {e_area}") + else: + child_logger.error(f"Invalid operation mode ('{operation_mode}') or missing parameters passed to map process target.") + 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 before exit: {e_put_err}") + return # 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) @@ -294,45 +301,43 @@ def run_map_viewer_process_target( 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. + # Use OpenCV window visibility checks rather than relying on an application-specific + # `map_display_window_controller` attribute which may not exist with the external visualizer. is_map_active = True + window_name = 'Map' if 'child_cv2' in locals() and child_cv2 is not None else None while is_map_active: - # Pass a short delay to yield CPU time - key = child_cv2.waitKey(100) # Process events every 100ms + # Pass a short delay to yield CPU time and let OpenCV process window events + key = child_cv2.waitKey(100) if window_name else -1 - # 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 - child_logger.debug(f"Map window received key press: {key}") - # Convert key code to character if it's printable for logging - 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 + try: + # If there's an OpenCV window, check its visibility property. If the window is closed + # by the user, getWindowProperty returns a value < 1. Use that as the signal to exit. + if window_name: + try: + vis = child_cv2.getWindowProperty(window_name, child_cv2.WND_PROP_VISIBLE) + if vis < 1: + child_logger.info("Map window detected as closed (OpenCV).") + break + except Exception: + # If getWindowProperty is unsupported for some reason, fall back to key checks. + pass + # Check for specific key presses (like 'q' or Escape) to allow closing via keyboard + if key != -1: + child_logger.debug(f"Map window received key press: {key}") + try: + if chr(key & 0xFF) in ('q', 'Q'): + child_logger.info("Map window closing due to 'q'/'Q' key press.") + break + if key == 27: + child_logger.info("Map window closing due to Escape key press.") + break + except Exception: + pass - 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 + except Exception as e_loop_check: + child_logger.debug(f"Error during OpenCV loop checks: {e_loop_check}") + break child_logger.info("OpenCV event loop finished. Map viewer process target returning.")