SXXXXXXX_ControlPanel/map_manager.py

582 lines
24 KiB
Python

# 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}':"
)