SXXXXXXX_GeoElevation/geoelevation/map_viewer/map_display.py
2025-05-13 14:18:48 +02:00

421 lines
21 KiB
Python

# 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, scaling them if necessary (though current default is autosize),
and capturing mouse events within the map window. It converts pixel coordinates
from clicks into geographic coordinates and vice-versa, relying on the 'mercantile'
library for these Web Mercator projection calculations.
"""
# Standard library imports
import logging
from typing import Optional, Tuple, Any # 'Any' for app_facade type hint
# Third-party imports
try:
import cv2 # OpenCV for windowing and drawing
import numpy as np
CV2_NUMPY_AVAILABLE_DISPLAY = True
except ImportError:
cv2 = None # type: ignore
np = None # type: ignore
CV2_NUMPY_AVAILABLE_DISPLAY = False
logging.error("MapDisplay: OpenCV or NumPy not found. Map display cannot function.")
try:
from PIL import Image
ImageType = Image.Image # type: ignore
PIL_AVAILABLE_DISPLAY = True
except ImportError:
Image = None # type: ignore
ImageType = None # type: ignore
PIL_AVAILABLE_DISPLAY = False
logging.warning("MapDisplay: Pillow (PIL) not found. Image conversion from PIL might fail.")
try:
import mercantile # For Web Mercator tile calculations and coordinate conversions
MERCANTILE_AVAILABLE_DISPLAY = True
except ImportError:
mercantile = None # type: ignore
MERCANTILE_AVAILABLE_DISPLAY = False
logging.error("MapDisplay: 'mercantile' library not found. Coordinate conversions will fail.")
# Module-level logger
logger = logging.getLogger(__name__)
# Default window properties (can be overridden or extended)
DEFAULT_WINDOW_X_POS = 150
DEFAULT_WINDOW_Y_POS = 150
class MapDisplayWindow:
"""
Manages an OpenCV window for displaying map images and handling mouse interactions.
"""
def __init__(
self,
app_facade: Any, # Typically an instance of GeoElevationMapViewer
window_name: str = "GeoElevation Map View",
initial_x_pos: int = DEFAULT_WINDOW_X_POS,
initial_y_pos: int = DEFAULT_WINDOW_Y_POS
) -> None:
"""
Initializes the MapDisplayWindow manager.
Args:
app_facade: An object that has a 'handle_map_mouse_click(x, y)' method.
This is called when the map is clicked.
window_name: The name for the OpenCV window.
initial_x_pos: Initial X screen position for the window.
initial_y_pos: Initial Y screen position for the window.
"""
logger.info(f"Initializing MapDisplayWindow: '{window_name}'")
if not CV2_NUMPY_AVAILABLE_DISPLAY:
raise ImportError("OpenCV and/or NumPy are not available for MapDisplayWindow.")
self.app_facade_handler: Any = app_facade
self.cv_window_name: str = window_name
self.window_initial_x: int = initial_x_pos
self.window_initial_y: int = initial_y_pos
self.is_window_initialized: bool = False
self.is_mouse_callback_set: bool = False
# Stores the shape (height, width) of the last image successfully displayed
self._last_displayed_image_shape: Tuple[int, int] = (0, 0)
def show_map(self, map_pil_image: Optional[ImageType]) -> None:
"""
Displays the provided map image (PIL format) in the OpenCV window.
The window is autosized to the image content. Handles window creation
and recreation if the image size changes.
Args:
map_pil_image: The map image (PIL.Image) to display.
If None, a placeholder image is shown.
"""
bgr_image_to_display: Optional[np.ndarray] = None # type: ignore
if map_pil_image is None:
logger.warning("Received None PIL image. Generating a placeholder.")
bgr_image_to_display = self._create_placeholder_bgr_numpy()
elif PIL_AVAILABLE_DISPLAY and isinstance(map_pil_image, Image.Image): # type: ignore
logger.debug(f"Converting PIL Image (Size: {map_pil_image.size}, Mode: {map_pil_image.mode}) to BGR.")
bgr_image_to_display = self._convert_pil_image_to_bgr_numpy(map_pil_image)
else:
logger.error(f"Received unexpected image type: {type(map_pil_image)}. Using placeholder.")
bgr_image_to_display = self._create_placeholder_bgr_numpy()
if bgr_image_to_display is None:
logger.error("Failed to obtain a BGR image for display. Using minimal fallback.")
# Create a minimal black square as an ultimate fallback
bgr_image_to_display = np.zeros((256, 256, 3), dtype=np.uint8) # type: ignore
current_image_height, current_image_width = bgr_image_to_display.shape[:2]
logger.debug(f"Final image shape for display: {current_image_width}x{current_image_height}")
# Recreate window if initialized and image size has changed (due to WINDOW_AUTOSIZE)
if self.is_window_initialized and \
(current_image_height, current_image_width) != self._last_displayed_image_shape:
logger.info(
f"Image size changed ({self._last_displayed_image_shape} -> "
f"{(current_image_height, current_image_width)}). Recreating window."
)
try:
cv2.destroyWindow(self.cv_window_name) # type: ignore
cv2.waitKey(5) # Brief pause to allow window manager to process destroy
self.is_window_initialized = False
self.is_mouse_callback_set = False # Callback needs to be reset on new window
except cv2.error as e_destroy: # type: ignore
logger.warning(f"Error destroying window before recreation: {e_destroy}")
# Force flags to false to ensure recreation attempt
self.is_window_initialized = False
self.is_mouse_callback_set = False
# Ensure window exists and mouse callback is set
if not self.is_window_initialized:
logger.debug(f"Creating/moving OpenCV window: '{self.cv_window_name}'")
cv2.namedWindow(self.cv_window_name, cv2.WINDOW_AUTOSIZE) # type: ignore
try:
cv2.moveWindow(self.cv_window_name, self.window_initial_x, self.window_initial_y) # type: ignore
except cv2.error as e_move: # type: ignore
logger.warning(f"Could not move window '{self.cv_window_name}': {e_move}")
self.is_window_initialized = True
logger.info(f"Window '{self.cv_window_name}' (AUTOSIZE) ready.")
if self.is_window_initialized and not self.is_mouse_callback_set:
try:
cv2.setMouseCallback(self.cv_window_name, self._opencv_mouse_callback, param=self.app_facade_handler) # type: ignore
self.is_mouse_callback_set = True
logger.info(f"Mouse callback successfully set for '{self.cv_window_name}'.")
except cv2.error as e_callback: # type: ignore
logger.error(f"Failed to set mouse callback for '{self.cv_window_name}': {e_callback}")
# Display the image
try:
cv2.imshow(self.cv_window_name, bgr_image_to_display) # type: ignore
self._last_displayed_image_shape = (current_image_height, current_image_width)
# cv2.waitKey(1) is crucial for OpenCV to process GUI events and display image
# The main event loop for this window will be handled by the parent process/thread
# that calls this show_map in its own loop (e.g., the map viewer process target).
except cv2.error as e_imshow: # type: ignore
# Handle cases where the window might have been closed externally
if "NULL window" in str(e_imshow).lower() or \
"invalid window" in str(e_imshow).lower() or \
"checkView" in str(e_imshow).lower(): # Common OpenCV error strings
logger.warning(f"OpenCV window '{self.cv_window_name}' seems closed or invalid during imshow.")
self.is_window_initialized = False
self.is_mouse_callback_set = False
self._last_displayed_image_shape = (0, 0)
else:
logger.exception(f"OpenCV error during map display: {e_imshow}")
except Exception as e_disp:
logger.exception(f"Unexpected error displaying map image: {e_disp}")
def _opencv_mouse_callback(self, event: int, x: int, y: int, flags: int, param_app_facade: Any) -> None:
"""
Internal OpenCV mouse callback. Clamps coordinates and calls the
app_facade's handler.
'param_app_facade' is the GeoElevationMapViewer instance.
"""
if event == cv2.EVENT_LBUTTONDOWN: # type: ignore
current_height, current_width = self._last_displayed_image_shape
if current_width <= 0 or current_height <= 0:
logger.warning("Mouse click on map with no valid displayed image dimensions.")
return # Cannot process click without valid image dimensions
# Clamp coordinates to the bounds of the currently displayed image
x_clamped = max(0, min(x, current_width - 1))
y_clamped = max(0, min(y, current_height - 1))
logger.debug(
f"Map Window Left Click (OpenCV): Original ({x},{y}), Clamped ({x_clamped},{y_clamped})"
)
if param_app_facade and hasattr(param_app_facade, 'handle_map_mouse_click'):
# Call the method on the GeoElevationMapViewer instance
param_app_facade.handle_map_mouse_click(x_clamped, y_clamped)
else:
logger.error(
"app_facade not correctly passed to OpenCV mouse callback or lacks 'handle_map_mouse_click'."
)
# --- Coordinate Conversion Utilities ---
# These methods operate on the context of the *currently displayed map*.
def pixel_to_geo_on_current_map(
self,
pixel_x: int,
pixel_y: int,
current_map_bounds_deg: Tuple[float, float, float, float], # west, south, east, north
current_map_pixel_shape: Tuple[int, int], # height, width
current_map_zoom: int # Zoom level of the current map
) -> Optional[Tuple[float, float]]: # Returns (latitude, longitude)
"""
Converts pixel coordinates (from mouse click) on the currently displayed map
to geographic WGS84 coordinates (latitude, longitude).
"""
if not MERCANTILE_AVAILABLE_DISPLAY:
logger.error("mercantile library not available for pixel_to_geo conversion.")
return None
if not (current_map_bounds_deg and current_map_pixel_shape and current_map_zoom is not None):
logger.warning("Cannot convert pixel to geo: Current map context is incomplete.")
return None
map_height, map_width = current_map_pixel_shape
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_bounds_deg
if map_width <= 0 or map_height <= 0:
logger.error("Cannot convert pixel to geo: Invalid map dimensions.")
return None
try:
# Get Web Mercator coordinates of the map's top-left and bottom-right corners
# mercantile.xy expects (longitude, latitude)
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 width and height of the map in Web Mercator units
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) # Y decreases downwards in pixel, increases upwards in Mercator
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
logger.error("Cannot convert pixel to geo: Invalid Mercator dimensions for map.")
return None
# Calculate relative position of the click within the map (0.0 to 1.0)
relative_x_on_map = pixel_x / map_width
relative_y_on_map = pixel_y / map_height
# Calculate the Web Mercator coordinate of the clicked point
# For Y, Mercator Y increases northwards, pixel Y increases downwards.
# map_ul_merc_y is the "top" (northernmost) Mercator Y.
clicked_point_merc_x = map_ul_merc_x + (relative_x_on_map * total_map_width_merc)
clicked_point_merc_y = map_ul_merc_y - (relative_y_on_map * total_map_height_merc)
# Convert the clicked Web Mercator coordinate back to geographic (lon, lat)
# mercantile.lnglat expects (merc_x, merc_y)
clicked_lon, clicked_lat = mercantile.lnglat(clicked_point_merc_x, clicked_point_merc_y) # type: ignore
return (clicked_lat, clicked_lon)
except Exception as e_conv:
logger.exception(f"Error during pixel_to_geo conversion: {e_conv}")
return None
def geo_to_pixel_on_current_map(
self,
latitude: float,
longitude: float,
current_map_bounds_deg: Tuple[float, float, float, float], # west, south, east, north
current_map_pixel_shape: Tuple[int, int], # height, width
current_map_zoom: int # Zoom level of the current map
) -> Optional[Tuple[int, int]]: # Returns (pixel_x, pixel_y)
"""
Converts geographic WGS84 coordinates (latitude, longitude) to pixel
coordinates (x, y) on the currently displayed map.
"""
if not MERCANTILE_AVAILABLE_DISPLAY:
logger.error("mercantile library not available for geo_to_pixel conversion.")
return None
if not (current_map_bounds_deg and current_map_pixel_shape and current_map_zoom is not None):
logger.warning("Cannot convert geo to pixel: Current map context is incomplete.")
return None
map_height, map_width = current_map_pixel_shape
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_bounds_deg
if map_width <= 0 or map_height <= 0:
logger.error("Cannot convert geo to pixel: Invalid map dimensions.")
return None
try:
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.error("Cannot convert geo to pixel: Invalid Mercator dimensions for map.")
return None
# Convert the target geographic point to Web Mercator coordinates
target_merc_x, target_merc_y = mercantile.xy(longitude, latitude) # type: ignore
# Calculate relative position of the target point within the map's Mercator bounds
# (relative_merc_x from 0 to 1 if target_merc_x is within map_ul_merc_x and map_lr_merc_x)
relative_merc_x = (target_merc_x - map_ul_merc_x) / total_map_width_merc
# For Y, map_ul_merc_y is "higher" (more North)
relative_merc_y = (map_ul_merc_y - target_merc_y) / total_map_height_merc
# Convert relative Mercator positions to pixel coordinates
pixel_x = int(round(relative_merc_x * map_width))
pixel_y = int(round(relative_merc_y * map_height))
# Clamp to image boundaries (though usually points of interest should be within)
pixel_x_clamped = max(0, min(pixel_x, map_width - 1))
pixel_y_clamped = max(0, min(pixel_y, map_height - 1))
return (pixel_x_clamped, pixel_y_clamped)
except Exception as e_conv_geo:
logger.exception(f"Error during geo_to_pixel conversion: {e_conv_geo}")
return None
def _create_placeholder_bgr_numpy(self) -> np.ndarray: # type: ignore
"""Creates a simple BGR NumPy array as a placeholder image."""
# Default size and color if other components fail
placeholder_height = 256
placeholder_width = 256
# Light grey color in BGR order
placeholder_bgr_color = (200, 200, 200)
if np: # type: ignore
return np.full((placeholder_height, placeholder_width, 3), placeholder_bgr_color, dtype=np.uint8) # type: ignore
else: # Should not happen if CV2_NUMPY_AVAILABLE_DISPLAY is true
return None # type: ignore
def _convert_pil_image_to_bgr_numpy(self, pil_img: ImageType) -> Optional[np.ndarray]: # type: ignore
"""Converts a PIL Image object to a NumPy BGR array for OpenCV."""
if not (PIL_AVAILABLE_DISPLAY and CV2_NUMPY_AVAILABLE_DISPLAY and pil_img):
return None
try:
numpy_image = np.array(pil_img) # type: ignore
if numpy_image.ndim == 2: # Grayscale image
return cv2.cvtColor(numpy_image, cv2.COLOR_GRAY2BGR) # type: ignore
elif numpy_image.ndim == 3:
if numpy_image.shape[2] == 3: # RGB image
return cv2.cvtColor(numpy_image, cv2.COLOR_RGB2BGR) # type: ignore
elif numpy_image.shape[2] == 4: # RGBA image
return cv2.cvtColor(numpy_image, cv2.COLOR_RGBA2BGR) # type: ignore
logger.warning(f"Unsupported NumPy image shape after PIL conversion: {numpy_image.shape}")
return None
except Exception as e_conv_pil:
logger.exception(f"Error converting PIL image to BGR NumPy array: {e_conv_pil}")
return None
def is_window_alive(self) -> bool:
"""
Checks if the OpenCV window associated with this instance is likely still
open and initialized.
"""
if not self.is_window_initialized or not CV2_NUMPY_AVAILABLE_DISPLAY:
return False
try:
# cv2.getWindowProperty returns >= 0 if property can be read (window exists)
# and specifically >= 1.0 if WND_PROP_VISIBLE is true.
# If window doesn't exist, it returns -1.0.
window_visibility_property = cv2.getWindowProperty(self.cv_window_name, cv2.WND_PROP_VISIBLE) # type: ignore
if window_visibility_property >= 1.0: # Window exists and is visible
return True
else: # Window might be closed, hidden, or property not available
logger.debug(
f"Window '{self.cv_window_name}' not reported as visible (prop_val={window_visibility_property}). Assuming closed/invalid."
)
self.is_window_initialized = False # Update state if not visible
self.is_mouse_callback_set = False
return False
except cv2.error: # type: ignore
# OpenCV error likely means the window no longer exists
logger.debug(f"OpenCV error accessing window property for '{self.cv_window_name}'. Assuming closed.")
self.is_window_initialized = False
self.is_mouse_callback_set = False
return False
except Exception: # Catch any other unexpected errors
logger.exception(f"Unexpected error checking if window '{self.cv_window_name}' is alive.")
self.is_window_initialized = False
self.is_mouse_callback_set = False
return False
def destroy_window(self) -> None:
"""Explicitly destroys the managed OpenCV window."""
logger.info(f"Attempting to destroy OpenCV window: '{self.cv_window_name}'")
if self.is_window_initialized and CV2_NUMPY_AVAILABLE_DISPLAY:
try:
cv2.destroyWindow(self.cv_window_name) # type: ignore
cv2.waitKey(5) # Allow OpenCV to process destroy event
logger.info(f"Window '{self.cv_window_name}' destroyed successfully.")
except cv2.error as e_destroy_cv: # type: ignore
logger.warning(
f"Ignoring OpenCV error during destroyWindow '{self.cv_window_name}' (may already be closed): {e_destroy_cv}"
)
except Exception as e_destroy_generic:
logger.exception(
f"Unexpected error destroying window '{self.cv_window_name}': {e_destroy_generic}"
)
else:
logger.debug(
f"Window '{self.cv_window_name}' was not marked as initialized or OpenCV not available. No explicit destroy."
)
# Always reset flags after attempting destroy
self.is_window_initialized = False
self.is_mouse_callback_set = False
self._last_displayed_image_shape = (0, 0)