python-map-manager/map_manager/tile_manager.py
2025-12-02 09:09:22 +01:00

295 lines
16 KiB
Python

"""Map tile manager migrated from original project and adapted.
Manages fetching, caching and stitching tiles using a BaseMapService.
"""
import logging
import os
import time
import threading
from pathlib import Path
from typing import Tuple, Optional, Dict
import io
import shutil
try:
import requests
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:
from PIL import Image, ImageDraw
ImageType = Image.Image # type: ignore
PIL_AVAILABLE_MANAGER = True
except ImportError:
Image = None # type: ignore
ImageDraw = None # type: ignore
ImageType = None # type: ignore
logging.error("MapTileManager: 'Pillow' library not found. Image operations will fail.")
from .services import BaseMapService
logger = logging.getLogger(__name__)
DEFAULT_MAP_TILE_CACHE_ROOT_DIR = "map_tile_cache"
DEFAULT_ENABLE_ONLINE_FETCHING = True
DEFAULT_NETWORK_TIMEOUT_SECONDS = 10
DEFAULT_DOWNLOAD_RETRY_DELAY_SECONDS = 2
DEFAULT_MAX_DOWNLOAD_RETRIES = 2
DEFAULT_PLACEHOLDER_COLOR_RGB = (220, 220, 220)
class MapTileManager:
def __init__(self, map_service: BaseMapService, cache_root_directory: Optional[str] = None, enable_online_tile_fetching: Optional[bool] = None, tile_pixel_size: Optional[int] = None) -> None:
logger.info("Initializing MapTileManager...")
if not REQUESTS_AVAILABLE:
raise ImportError("'requests' library is required by MapTileManager but not found.")
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):
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
if tile_pixel_size is not None:
if not isinstance(tile_pixel_size, int) or tile_pixel_size <= 0:
raise ValueError(f"Invalid provided tile_pixel_size: {tile_pixel_size}.")
self.tile_size: int = tile_pixel_size
else:
self.tile_size: int = self.map_service.tile_size
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
self.is_online_fetching_enabled: bool = enable_online_tile_fetching if enable_online_tile_fetching is not None else DEFAULT_ENABLE_ONLINE_FETCHING
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()
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:
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}")
def _get_tile_cache_file_path(self, z: int, x: int, y: int) -> Path:
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):
logger.debug(f"Checking cache for tile {tile_coordinates_log_str} at {tile_cache_path}")
try:
with self._cache_access_lock:
if tile_cache_path.is_file():
pil_image = Image.open(tile_cache_path) # type: ignore
pil_image.load()
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
return pil_image
else:
return None
except Exception:
logger.exception("Unexpected error accessing cache file")
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):
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 = None
for attempt_num in range(self.download_max_retries + 1):
try:
response = requests.get(tile_download_url, headers=self.http_request_headers, timeout=self.http_request_timeout_seconds, stream=True) # type: ignore
response.raise_for_status()
image_binary_data = response.content
if not image_binary_data:
logger.warning(f"Downloaded empty content for tile {tile_coordinates_log_str}.")
break
try:
pil_image = Image.open(io.BytesIO(image_binary_data)) # type: ignore
pil_image.load()
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
self._save_image_to_cache_file(tile_cache_path, pil_image)
downloaded_pil_image = pil_image
break
except Exception as e_img_proc:
logger.error(f"Failed to process image data for {tile_coordinates_log_str}: {e_img_proc}")
break
except Exception as e_req:
logger.warning(f"Request error for tile {tile_coordinates_log_str}: {e_req}")
if attempt_num < self.download_max_retries:
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:
with self._cache_access_lock:
try:
tile_cache_path.parent.mkdir(parents=True, exist_ok=True)
pil_image.save(tile_cache_path, format='PNG')
logger.debug(f"Saved tile to cache: {tile_cache_path}")
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):
tile_coords_log_str = f"({zoom_level},{tile_x},{tile_y})"
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 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 = 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:
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:
logger.warning(f"Failed to retrieve tile {tile_coords_log_str}. Using placeholder.")
retrieved_image = self._create_placeholder_tile_image(tile_coords_log_str)
return retrieved_image
def stitch_map_image(self, zoom_level: int, x_tile_range: Tuple[int, int], y_tile_range: Tuple[int, int], progress_callback=None):
"""Stitch tiles into a single PIL image.
Optional `progress_callback` is invoked after each tile is processed with
the signature: progress_callback(done:int, total:int, last_tile_duration:float, from_cache:bool, tile_coords:tuple)
Note: the callback may be invoked from the caller's thread (i.e. the
thread where `stitch_map_image` is running). Consumers that update UI
must marshal updates to their UI/main thread.
"""
min_tile_x, max_tile_x = x_tile_range
min_tile_y, max_tile_y = y_tile_range
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
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.")
return None
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
MAX_IMAGE_DIMENSION = 16384
if total_image_width > MAX_IMAGE_DIMENSION or total_image_height > MAX_IMAGE_DIMENSION:
logger.error("Requested stitched image size exceeds maximum allowed dimension.")
return None
try:
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}")
return None
total_tiles = num_tiles_wide * num_tiles_high
tiles_done = 0
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)):
# measure per-tile retrieval time to allow ETA estimation in callers
start_time = time.time()
# check whether tile exists in cache before retrieval (used to report from_cache)
tile_cache_path = self._get_tile_cache_file_path(zoom_level, current_tile_x, current_tile_y)
from_cache_flag = tile_cache_path.is_file()
tile_image_pil = self.get_tile_image(zoom_level, current_tile_x, current_tile_y)
last_tile_duration = time.time() - start_time
if tile_image_pil is None:
logger.critical(f"Critical error: get_tile_image returned None for ({zoom_level},{current_tile_x},{current_tile_y}). Aborting stitch.")
return None
paste_position_x = col_index * single_tile_pixel_size
paste_position_y = row_index * single_tile_pixel_size
try:
if tile_image_pil and tile_image_pil.size != (self.tile_size, self.tile_size):
if PIL_AVAILABLE_MANAGER:
tile_image_pil = tile_image_pil.resize((self.tile_size, self.tile_size), Image.Resampling.LANCZOS) # type: ignore
else:
continue
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}) at ({paste_position_x},{paste_position_y}): {e_paste}")
# update progress
tiles_done += 1
try:
if progress_callback:
# Provide caller with basic timing and whether tile was from cache
progress_callback(tiles_done, total_tiles, last_tile_duration, from_cache_flag, (zoom_level, current_tile_x, current_tile_y))
except Exception:
# progress callbacks must not interrupt stitching
logger.exception('Progress callback raised an exception')
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"):
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
placeholder_color = DEFAULT_PLACEHOLDER_COLOR_RGB
placeholder_img = Image.new("RGB", (tile_pixel_size, tile_pixel_size), color=placeholder_color) # type: ignore
draw = ImageDraw.Draw(placeholder_img) # type: ignore
overlay_text = f"Tile Fail\n{identifier}"
try:
draw.text((10, 10), overlay_text, fill="black") # simple fallback
except Exception:
draw.text((10, 10), overlay_text, fill="black")
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]]):
try:
import mercantile as local_mercantile
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]
top_left_tile_bounds = local_mercantile.bounds(min_x, min_y, zoom)
bottom_right_tile_bounds = local_mercantile.bounds(max_x, max_y, zoom)
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: {e_bounds_calc}")
return None
def clear_entire_service_cache(self) -> None:
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:
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}.")
self._ensure_service_cache_directory_exists()
else:
logger.warning(f"Cache path '{self.service_specific_cache_dir}' is not a directory.")
except Exception as e_clear_unexpected:
logger.exception(f"Unexpected error clearing cache '{self.service_specific_cache_dir}': {e_clear_unexpected}")