421 lines
21 KiB
Python
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) |