From ebb2546d845cabfeee551c415af6caedb439513f Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Tue, 2 Dec 2025 09:50:29 +0100 Subject: [PATCH] fatto refactoring con submodulo map-manager --- .gitignore | 25 +- doc/map_manager.md | 149 ++ geoelevation/__init__.py | 13 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 23544 bytes .../__pycache__/__main__.cpython-313.pyc | Bin 0 -> 8408 bytes .../__pycache__/_version.cpython-313.pyc | Bin 0 -> 3445 bytes .../__pycache__/config.cpython-313.pyc | Bin 0 -> 658 bytes .../__pycache__/elevation_gui.cpython-313.pyc | Bin 0 -> 75130 bytes .../elevation_manager.cpython-313.pyc | Bin 0 -> 29064 bytes .../image_processor.cpython-313.pyc | Bin 0 -> 16462 bytes .../process_targets.cpython-313.pyc | Bin 0 -> 15881 bytes .../__pycache__/visualizer.cpython-313.pyc | Bin 0 -> 21292 bytes geoelevation/elevation_gui.py | 8 +- geoelevation/map_viewer/__init__.py | 61 - geoelevation/map_viewer/geo_map_viewer.py | 1492 ----------------- geoelevation/map_viewer/map_display.py | 630 ------- geoelevation/map_viewer/map_drawing.py | 689 -------- geoelevation/map_viewer/map_manager.py | 775 --------- geoelevation/map_viewer/map_services.py | 250 --- geoelevation/map_viewer/map_utils.py | 1249 -------------- geoelevation/process_targets.py | 133 +- 21 files changed, 250 insertions(+), 5224 deletions(-) create mode 100644 doc/map_manager.md create mode 100644 geoelevation/__pycache__/__init__.cpython-313.pyc create mode 100644 geoelevation/__pycache__/__main__.cpython-313.pyc create mode 100644 geoelevation/__pycache__/_version.cpython-313.pyc create mode 100644 geoelevation/__pycache__/config.cpython-313.pyc create mode 100644 geoelevation/__pycache__/elevation_gui.cpython-313.pyc create mode 100644 geoelevation/__pycache__/elevation_manager.cpython-313.pyc create mode 100644 geoelevation/__pycache__/image_processor.cpython-313.pyc create mode 100644 geoelevation/__pycache__/process_targets.cpython-313.pyc create mode 100644 geoelevation/__pycache__/visualizer.cpython-313.pyc delete mode 100644 geoelevation/map_viewer/__init__.py delete mode 100644 geoelevation/map_viewer/geo_map_viewer.py delete mode 100644 geoelevation/map_viewer/map_display.py delete mode 100644 geoelevation/map_viewer/map_drawing.py delete mode 100644 geoelevation/map_viewer/map_manager.py delete mode 100644 geoelevation/map_viewer/map_services.py delete mode 100644 geoelevation/map_viewer/map_utils.py 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 0000000000000000000000000000000000000000..9bf3344c4343b07920390b350483d247969c0149 GIT binary patch literal 23544 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 literal 0 HcmV?d00001 diff --git a/geoelevation/__pycache__/_version.cpython-313.pyc b/geoelevation/__pycache__/_version.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4dcea2ba888d270d88e9cd8623edad98a62e5555 GIT binary patch literal 3445 zcmZ`+TWk~A89o!=CU#;kxo`;(Cgu`5kSn>Mp(UD!2B}CDZ>h4Cs_skwGdDZQ?g?|| zoZsL7o&W#OfBre_R98C@wBDVDh3`K==$~|Av${M^3Eb1w(;1C+Vl2djsAA6ul8i~AuB)S~r?mtA_0D(n1v>i! zon1HQ3)L#`*}S+YX77lKBcu&tjBY)BIpDFtvtzYVPeLdLzVQ_z>OdmXh{6=3HlO|$ z?5IpSYYQ8(Kq2tG8Bk$h(%LQ^Qwlo3p*7I4{)o;oV5&55CC(n*dfpazfsg=*(r-M& z+pK60zg^{5_mZ;hvdNT!PL;71*yK=k+=gVAjwzjGcowjgKZ5l_9GzhkeG$ln!(go` z2_7)`OK;w3`scENSYM+`U8BapnfB1Vs@5@X6=Oh3e$qIa(qO$gGzYxLSaTPz2Y5dK z{|>0U<}QmBB{ma(3D($e-Fc86G?od1?|YjU@iis39(@hb60(@g5TlSKcyzBO_+4vQ zEmnNg#MedO$^4$;Nis=^NiHf#Fv*IXynwlAK9!DhOPEM0kfcNn4dXVabBH9RfRlr!Nh3Oe58mgv zY)%H4koE_-Ast|>keBE>xwH_&3)yrW$lFvJN;QfTLOv}^oSfyn%gg%8D=VCDc{xf1 zF}AR>(!RVL%VshuIU+4&iM+Dn_j0KOHe66z%zl!)FAv>N&-)OyXczz z=Qx~}FgH4Oc_?&cK5}g=$WPABlq}a+H4iDVYT|fG<}?wvAV>>8j5gvC;CLDsZjTb@ z>*Hbz0+IZC)s4soR26N;i9)6P4ctch2D-%E;K)~&tMZ*a(P%y)wm#INZ#~a&UXV^ z>-2Q%=;%K6loUy(AaTGCh*2|jVv>)HPtHezV{@}yO5)~89zzP~AJyh^PmIH(WfkO1 zfSZR1ln#<{T(oq8qd+*w3`__Nz->uLV!EQPK+i%#NTuNr%oI+V84n4s?@IgbZ1*b; zeF4R;WyE_2ln$k;{7fizO_|Woj8bWaOJU{-EyRk)6loDuED#9At_7xG*Dotbv8nr6 zX!_W%Y~uf8>8pzubxi^1onRz9qF3tkA6#+;kkibtiKBtU9j^U)Bl^ zb$g74B)}=mZGXMuibQn3A`!*2=X59@x;Z1W)6?*p7~&@sw}H@viY1Lj#Ys`a!J(Ov zGJrn!42LGKj7H`sr^opDq3JorCgsxLv#V_96&8ze=}t;sP$~#kY)~0v3yLjCviY21 zOUXDRDGrEpE|tcL9ZNAGhZT#Ik3uIC#V%oifI{WiXEChSKYyiCy{kGZRd7;hY06+x zmPj20jd*F~sbDYDI5Ej>@pepUZXzd?7d@5$7Q zsW$_2#euo?t2f>Z+$;{<{EA)wNfZ#*i|q05?8wu&X4!Hbynpe_i@&+}uI}K&i7zJB zj}Lu&aieZ_ZR{VOy3K<}A1?l4@ln$+#Rrzn#-<0MB71b(j*gsxhxOI5%AM4!^u!^j_IGG`)8H?eSAzhJPJ?TKjeA z_YKc(ZyX=FfBWB+=w#n_ZdB#|dH?->@a603H?H2Is~1*r*Sz%{TW62`_noV52U%<_ zAgek3=<1`9N1v{H{C{Kp-)}oW{6C4_WIi81I({0xI_(@Ew7hD!jR!2Rx*0kS*r4|_ zGkzy&0vw^2GeYlWgkG=+y*H5)w4-}Zj?fNnk4R02-=Q5CBGLt&!VYAz@q8LzBJ@8% z^ju2=&}~~7hS_l=#<|TR54Y;ttQlDKY*yE=IyY;4Fml(dy1?{WFN_Z~th&|iKlUMp JX(9BA_J5B#PYM74 literal 0 HcmV?d00001 diff --git a/geoelevation/__pycache__/config.cpython-313.pyc b/geoelevation/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c0f88e889e51187d66d3cb23fb5c0237d038cf61 GIT binary patch literal 658 zcmY*W!D`z;5M4!ftcZqC8gdD7_EPM$;4dgiRePI?+EK9Flm^4HmDaMTtTnrMqwvE7kef=^1S4HS|0q)IQ1&db@ zd`3@^k0kO9X$&`proYjETi=wG5>-l6mDS-UwmDO5@PQ8Ibmvh%%&3x59u8uAGZ zSTN>!{gim5OD>#&>z&aK>Cv{+enTL7?Y!#_^8Gd4giWcA6TT83u2{6^oYR5ZB`a~? z^9Hoz_Wu;K&g&0Z9oIkaIv?nz=k+M`CEW-(V7o8#gNMWmm%OJ=pE%TaFA06yJ3HFS zYm|l}pj4KHO{HbNLn%vBq03JzNWs35tbxnT7)ELpQFW9EroW>F0JJ`fA#4kF*P34E z){EsXj^$&Z8F17IxVdQ<#@|ZSsLXd!<79^Fx8@x>p4+HNj2Sw-eR797^GB%BT%{Uw S3pJn5(7~7NCweA#pvHe}FvU#( literal 0 HcmV?d00001 diff --git a/geoelevation/__pycache__/elevation_gui.cpython-313.pyc b/geoelevation/__pycache__/elevation_gui.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5f64fb301ea4b52e988a8c827e8eab1485cfebf6 GIT binary patch literal 75130 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 G%>M;Gk2Qb* literal 0 HcmV?d00001 diff --git a/geoelevation/__pycache__/image_processor.cpython-313.pyc b/geoelevation/__pycache__/image_processor.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc3f3cd4ab9a57d8c082d8d845d7615daf0e6de6 GIT binary patch literal 16462 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 Nf2CCOuv7>6{|khI6a)YO literal 0 HcmV?d00001 diff --git a/geoelevation/__pycache__/process_targets.cpython-313.pyc b/geoelevation/__pycache__/process_targets.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..534c2d90cf5dc9aa0bb82f283ee53a30adaaf715 GIT binary patch literal 15881 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 AT>t<8 literal 0 HcmV?d00001 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.")