582 lines
24 KiB
Python
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 controlpanel.map.map_services import BaseMapService # The interface we depend on
|
|
from controlpanel 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}':"
|
|
)
|