# --- START OF FILE map_manager.py --- # map_manager.py """ Manages the retrieval, caching, and stitching of map tiles from a selected map service. Handles interaction with the map service provider interface, local disk caching, and providing map tile images to the application. """ # Standard library imports import logging import os import time import threading from pathlib import Path # For easier path manipulation from typing import Tuple, Optional, List, Dict # <<< AGGIUNTO QUESTO IMPORT import io # To handle byte stream from requests as image import shutil # For cache clearing # Third-party imports try: import requests # For downloading tiles except ImportError: requests = None # Handle optional dependency try: from PIL import Image # For handling image data (opening, saving, stitching) except ImportError: Image = None # Handle optional dependency # Local application imports from map_services import BaseMapService # The interface we depend on import config # For cache directory, online fetching flag, etc. class MapTileManager: """ Manages fetching, caching, and assembling map tiles. Uses a specific map service provider (implementing BaseMapService) to get tile URLs and handles local caching to support offline use and reduce network requests. Requires 'requests' and 'Pillow' libraries. """ # Default values (can be overridden by config) DEFAULT_CACHE_DIR = "map_cache" DEFAULT_ENABLE_ONLINE = True DEFAULT_TIMEOUT_SECONDS = 5 # Timeout for network requests DEFAULT_RETRY_DELAY_SECONDS = 1 # Delay before retrying a failed download DEFAULT_MAX_RETRIES = 2 # Number of retries for failed downloads def __init__( self, map_service: BaseMapService, cache_base_dir: Optional[str] = None, enable_online_fetching: Optional[bool] = None, ): # <<< 'Optional' ora è definito """ Initializes the MapTileManager. Args: map_service (BaseMapService): An instance of a map service provider (e.g., OpenStreetMapService). cache_base_dir (Optional[str]): The root directory for caching tiles. If None, uses config.MAP_CACHE_DIRECTORY or DEFAULT_CACHE_DIR. enable_online_fetching (Optional[bool]): Whether to download tiles if not in cache. If None, uses config.ENABLE_ONLINE_MAP_FETCHING or DEFAULT_ENABLE_ONLINE. Raises: TypeError: If map_service is not a valid BaseMapService instance. ImportError: If 'requests' or 'Pillow' libraries are not installed. """ self._log_prefix = "[MapTileManager]" logging.debug(f"{self._log_prefix} Initializing...") # --- Dependency Check --- if requests is None: logging.critical( f"{self._log_prefix} 'requests' library is required but not found. Please install it (`pip install requests`)." ) raise ImportError("'requests' library not found.") if Image is None: logging.critical( f"{self._log_prefix} 'Pillow' library is required but not found. Please install it (`pip install Pillow`)." ) raise ImportError("'Pillow' library not found.") # --- Service Validation --- if not isinstance(map_service, BaseMapService): logging.critical( f"{self._log_prefix} Invalid map_service provided. Must be an instance of BaseMapService." ) raise TypeError("map_service must be an instance of BaseMapService") self.map_service = map_service self.service_name = map_service.name # Get name for cache sub-directory # --- Configuration --- # Determine cache directory if cache_base_dir is None: cache_base_dir = getattr( config, "MAP_CACHE_DIRECTORY", self.DEFAULT_CACHE_DIR ) # Create specific cache path for this service self.cache_dir = Path(cache_base_dir) / self.service_name logging.debug(f"{self._log_prefix} Cache directory set to: {self.cache_dir}") # Determine online fetching status if enable_online_fetching is None: self.enable_online = getattr( config, "ENABLE_ONLINE_MAP_FETCHING", self.DEFAULT_ENABLE_ONLINE ) else: self.enable_online = enable_online_fetching logging.debug( f"{self._log_prefix} Online fetching enabled: {self.enable_online}" ) # Network request parameters self.user_agent = "SAR_ControlPanel/1.0 (Python Requests; +http://example.com/bot)" # Be a good citizen self.request_headers = {"User-Agent": self.user_agent} self.request_timeout = self.DEFAULT_TIMEOUT_SECONDS self.max_retries = self.DEFAULT_MAX_RETRIES self.retry_delay = self.DEFAULT_RETRY_DELAY_SECONDS # Ensure the cache directory exists (create if necessary) self._ensure_cache_dir_exists() # Lock for thread safety during cache access/modification self._cache_lock = threading.Lock() logging.debug( f"{self._log_prefix} Initialized for service '{self.service_name}' with cache at '{self.cache_dir}'. Online: {self.enable_online}" ) def _ensure_cache_dir_exists(self): """Creates the cache directory structure if it doesn't exist.""" try: # Use parents=True to create intermediate directories if needed # exist_ok=True prevents error if directory already exists self.cache_dir.mkdir(parents=True, exist_ok=True) logging.debug( f"{self._log_prefix} Cache directory check/creation successful: {self.cache_dir}" ) except OSError as e: # Log error if creation fails, application might need to handle this. logging.error( f"{self._log_prefix} Failed to create cache directory '{self.cache_dir}': {e}" ) except Exception as e: logging.exception( f"{self._log_prefix} Unexpected error ensuring cache directory exists:" ) def _get_tile_cache_path(self, z: int, x: int, y: int) -> Path: """ Constructs the full path for a specific tile within the cache. Uses a structure like /cache_base/service_name/zoom/x/y.png Args: z (int): Zoom level. x (int): Tile X coordinate. y (int): Tile Y coordinate. Returns: Path: The full Path object for the tile file. """ # Creates paths like: .../map_cache/osm/12/1234/5678.png return self.cache_dir / str(z) / str(x) / f"{y}.png" def _load_from_cache( self, cache_path: Path, tile_coords_str: str ) -> Optional[Image.Image]: # <<< 'Optional' ora è definito """Attempts to load a tile image from the cache file (thread-safe read).""" logging.debug( f"{self._log_prefix} Checking cache for tile {tile_coords_str} at {cache_path}" ) try: # Use lock for thread-safe file access check/read with self._cache_lock: if cache_path.is_file(): logging.debug( f"{self._log_prefix} Cache hit for tile {tile_coords_str}. Loading from disk." ) # Open the image file img = Image.open(cache_path) # Crucial: Load data immediately to release file lock sooner. img.load() # Convert to RGB for consistency if not already if img.mode != "RGB": logging.debug( f"{self._log_prefix} Converting cached image {tile_coords_str} from mode {img.mode} to RGB." ) img = img.convert("RGB") logging.debug( f"{self._log_prefix} Tile {tile_coords_str} loaded successfully from cache." ) return img else: logging.debug( f"{self._log_prefix} Cache miss for tile {tile_coords_str}." ) return None except IOError as e: logging.error( f"{self._log_prefix} Error reading cached tile {cache_path}: {e}. Treating as cache miss." ) return None except Exception as e: logging.exception( f"{self._log_prefix} Unexpected error accessing cache file {cache_path}:" ) return None def _download_and_cache_tile( self, z: int, x: int, y: int, cache_path: Path, tile_coords_str: str ) -> Optional[Image.Image]: # <<< 'Optional' ora è definito """Attempts to download a tile, process it, and save it to cache.""" if not self.enable_online: logging.debug( f"{self._log_prefix} Online fetching disabled. Cannot download tile {tile_coords_str}." ) return None # Get URL from the map service tile_url = self.map_service.get_tile_url(z, x, y) if not tile_url: logging.error( f"{self._log_prefix} Failed to get URL for tile {tile_coords_str} from service '{self.service_name}'." ) return None logging.debug( f"{self._log_prefix} Downloading tile {tile_coords_str} from: {tile_url}" ) # Log download attempt as INFO downloaded_image = None for attempt in range(self.max_retries + 1): try: # Perform the network request response = requests.get( tile_url, headers=self.request_headers, timeout=self.request_timeout, stream=True, ) response.raise_for_status() # Raise HTTPError for bad responses (4xx, 5xx) # Check content type (optional but recommended) content_type = response.headers.get("content-type", "").lower() if "image" not in content_type: logging.warning( f"{self._log_prefix} Downloaded content for {tile_coords_str} may not be an image (Content-Type: {content_type}). Processing anyway." ) # Read content into memory image_data = response.content if not image_data: logging.warning( f"{self._log_prefix} Downloaded empty content for {tile_coords_str}. Skipping." ) break # Stop retrying if content is empty # --- Process and Cache Downloaded Image --- try: img = Image.open(io.BytesIO(image_data)) img.load() # Load data # Convert to RGB for consistency if img.mode != "RGB": logging.debug( f"{self._log_prefix} Converting downloaded image {tile_coords_str} from mode {img.mode} to RGB." ) img = img.convert("RGB") logging.debug( f"{self._log_prefix} Tile {tile_coords_str} downloaded successfully (Attempt {attempt + 1})." ) # Save to cache (thread-safe) self._save_tile_to_cache( cache_path, img ) # Pass Pillow image directly downloaded_image = img break # Success, exit retry loop except (IOError, Image.UnidentifiedImageError) as img_err: logging.error( f"{self._log_prefix} Failed to process downloaded image data for {tile_coords_str} from {tile_url}: {img_err}" ) break # Don't retry if data is corrupt except Exception as proc_err: logging.exception( f"{self._log_prefix} Unexpected error processing downloaded image {tile_coords_str}:" ) break # Don't retry on unexpected processing errors except requests.exceptions.Timeout: logging.warning( f"{self._log_prefix} Timeout downloading tile {tile_coords_str} (Attempt {attempt + 1}/{self.max_retries + 1})." ) except requests.exceptions.RequestException as req_err: status_code = getattr(req_err.response, "status_code", "N/A") logging.warning( f"{self._log_prefix} Error downloading tile {tile_coords_str} (Status: {status_code}, Attempt {attempt + 1}/{self.max_retries + 1}): {req_err}" ) # Decide if retry makes sense based on status code (e.g., retry on 5xx, not on 404) if status_code == 404: logging.error( f"{self._log_prefix} Tile {tile_coords_str} not found (404). Not retrying." ) break # No point retrying a 404 except Exception as dl_err: logging.exception( f"{self._log_prefix} Unexpected error downloading tile {tile_coords_str} (Attempt {attempt + 1}):" ) # Break on unexpected errors during download itself # Wait before retrying if not the last attempt if attempt < self.max_retries: logging.debug( f"{self._log_prefix} Waiting {self.retry_delay}s before retrying download for {tile_coords_str}." ) time.sleep(self.retry_delay) if downloaded_image is None: logging.error( f"{self._log_prefix} Failed to download tile {tile_coords_str} after {self.max_retries + 1} attempts." ) return downloaded_image def get_tile_image( self, z: int, x: int, y: int, force_refresh: bool = False ) -> Optional[Image.Image]: # <<< 'Optional' ora è definito """ Retrieves a map tile image, using cache first, then attempting download if enabled. Args: z (int): Zoom level. x (int): Tile X coordinate. y (int): Tile Y coordinate. force_refresh (bool): If True, ignore cache and force download (if online). Defaults to False. Returns: Optional[Image.Image]: A PIL Image object of the tile, or None if retrieval failed. """ tile_coords_str = f"({z},{x},{y})" # For logging logging.debug(f"{self._log_prefix} Requesting tile image for {tile_coords_str}") cache_path = self._get_tile_cache_path(z, x, y) image = None # --- 1. Check Cache --- if not force_refresh: image = self._load_from_cache(cache_path, tile_coords_str) # --- 2. Attempt Download (if cache miss/force refresh) --- if image is None: image = self._download_and_cache_tile(z, x, y, cache_path, tile_coords_str) # --- 3. Handle Failure --- if image is None: logging.warning( f"{self._log_prefix} Failed to retrieve tile {tile_coords_str}. Returning placeholder." ) # Return a placeholder tile instead of None image = self.get_placeholder_tile() if image is None: # If even placeholder fails logging.error( f"{self._log_prefix} Could not even create a placeholder tile." ) return image def _save_tile_to_cache(self, cache_path: Path, image: Image.Image): """ Saves the downloaded tile image (Pillow object) to the cache directory (thread-safe). Args: cache_path (Path): The full path where the tile should be saved. image (Image.Image): The PIL Image object (already loaded and converted). """ # Use lock for thread-safe directory creation and file writing with self._cache_lock: try: # Ensure the specific subdirectory for the tile exists (e.g., .../zoom/x/) cache_path.parent.mkdir(parents=True, exist_ok=True) # Save the image using Pillow (infers format from extension) image.save(cache_path) logging.debug(f"{self._log_prefix} Saved tile to cache: {cache_path}") except IOError as e: logging.error( f"{self._log_prefix} Error saving tile to cache {cache_path}: {e}" ) except Exception as e: logging.exception( f"{self._log_prefix} Unexpected error saving tile to cache {cache_path}:" ) def stitch_map_image( self, z: int, x_range: Tuple[int, int], y_range: Tuple[int, int] ) -> Optional[Image.Image]: # <<< 'Optional' ora è definito """ Retrieves and stitches together multiple tiles to form a larger map image. Args: z (int): The zoom level for all tiles. x_range (Tuple[int, int]): The inclusive start and end X tile coordinates (min_x, max_x). y_range (Tuple[int, int]): The inclusive start and end Y tile coordinates (min_y, max_y). Returns: Optional[Image.Image]: A single PIL Image object containing the stitched map. Placeholders are used for missing tiles. Returns None only on major errors. """ log_prefix_stitch = f"{self._log_prefix}[Stitch]" logging.debug( f"{log_prefix_stitch} Request to stitch map for zoom {z}, X range {x_range}, Y range {y_range}" ) min_x, max_x = x_range min_y, max_y = y_range if not (min_x <= max_x and min_y <= max_y): logging.error( f"{log_prefix_stitch} Invalid tile ranges provided: X={x_range}, Y={y_range}" ) return None num_tiles_x = (max_x - min_x) + 1 num_tiles_y = (max_y - min_y) + 1 tile_size = self.map_service.tile_size if tile_size <= 0: logging.error( f"{log_prefix_stitch} Invalid tile size ({tile_size}) reported by map service. Cannot stitch." ) return None # Calculate dimensions of the final stitched image total_width = num_tiles_x * tile_size total_height = num_tiles_y * tile_size logging.debug( f"{log_prefix_stitch} Final image dimensions: {total_width}x{total_height} ({num_tiles_x}x{num_tiles_y} tiles)" ) # Create a new blank image to paste tiles onto try: stitched_image = Image.new("RGB", (total_width, total_height)) except Exception as e: logging.exception( f"{log_prefix_stitch} Failed to create blank image for stitching:" ) return None # Loop through tiles, get image (or placeholder), and paste for j, y in enumerate(range(min_y, max_y + 1)): # Iterate Y (rows) first for i, x in enumerate( range(min_x, max_x + 1) ): # Iterate X (columns) second # Get the individual tile image (will return placeholder on failure) tile_img = self.get_tile_image( z, x, y ) # Handles caching/download/placeholder if tile_img is None: # This should ideally not happen if get_placeholder_tile works logging.critical( f"{log_prefix_stitch} Failed to retrieve tile OR placeholder for ({z},{x},{y}). Aborting stitch." ) return None # Abort if even placeholder fails # Calculate paste position paste_x = i * tile_size paste_y = j * tile_size logging.debug( f"{log_prefix_stitch} Pasting tile ({z},{x},{y}) at pixel position ({paste_x},{paste_y})" ) # Paste the tile onto the main image try: stitched_image.paste(tile_img, (paste_x, paste_y)) except Exception as e: logging.exception( f"{log_prefix_stitch} Error pasting tile ({z},{x},{y}) at ({paste_x},{paste_y}):" ) # Continue trying to paste other tiles, but log the error pass # The tile will be missing / area blank logging.debug( f"{log_prefix_stitch} Map stitching complete for zoom {z}, X={x_range}, Y={y_range}." ) return stitched_image def get_placeholder_tile( self, ) -> Optional[Image.Image]: # <<< 'Optional' ora è definito """ Returns a placeholder tile image (e.g., grey square) if Pillow is available. Returns: Optional[Image.Image]: The placeholder image or None if Image module not loaded. """ if Image is None: logging.warning( f"{self._log_prefix} Cannot create placeholder tile: Pillow library not available." ) return None try: size = self.map_service.tile_size color = getattr( config, "OFFLINE_MAP_PLACEHOLDER_COLOR", (200, 200, 200) ) # Light grey default RGB # Ensure color is RGB tuple for Pillow if isinstance(color, tuple) and len(color) == 3: rgb_color = (int(color[0]), int(color[1]), int(color[2])) else: logging.warning( f"{self._log_prefix} Invalid placeholder color {color} in config. Using default grey." ) rgb_color = (200, 200, 200) return Image.new("RGB", (size, size), color=rgb_color) except Exception as e: logging.exception(f"{self._log_prefix} Error creating placeholder tile:") return None def clear_cache( self, zoom_level: Optional[int] = None ): # <<< 'Optional' ora è definito """ Deletes cached tiles for the current service. Args: zoom_level (Optional[int]): If specified, only clear tiles for this zoom level. If None, clear the entire cache for the service. """ log_prefix_clear = f"{self._log_prefix}[CacheClear]" target_dir = self.cache_dir if zoom_level is not None: target_dir = target_dir / str(zoom_level) clear_desc = f"zoom level {zoom_level}" else: clear_desc = "entire service cache" logging.debug( f"{log_prefix_clear} Attempting to clear {clear_desc} at {target_dir}" ) if not target_dir.exists(): logging.warning( f"{log_prefix_clear} Cache directory/level '{target_dir}' does not exist. Nothing to clear." ) return # Use lock to prevent conflicts during deletion with self._cache_lock: try: if target_dir.is_dir(): shutil.rmtree(target_dir) logging.debug( f"{log_prefix_clear} Successfully cleared {clear_desc} cache at {target_dir}." ) # Recreate the base cache dir if we deleted the whole service cache # Only if the base cache dir itself was the target_dir if zoom_level is None and target_dir == self.cache_dir: self._ensure_cache_dir_exists() else: logging.warning( f"{log_prefix_clear} Target path '{target_dir}' is not a directory. Cannot clear." ) except OSError as e: logging.error( f"{log_prefix_clear} Error clearing cache at '{target_dir}': {e}" ) except Exception as e: logging.exception( f"{log_prefix_clear} Unexpected error clearing cache '{target_dir}':" ) # --- END OF FILE map_manager.py ---