SXXXXXXX_GeoElevation/geoelevation/map_viewer/map_display.py
2025-05-13 15:11:07 +02:00

461 lines
26 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. It applies a display scale factor (provided by its
app_facade) to the incoming map image before rendering. It also captures
mouse events within the map window and performs conversions between pixel
coordinates (on the displayed, scaled image) and geographic coordinates,
relying on the 'mercantile' library for Web Mercator projections.
"""
# 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_LIBS_AVAILABLE_DISPLAY = True
except ImportError:
cv2 = None # type: ignore
np = None # type: ignore
CV2_NUMPY_LIBS_AVAILABLE_DISPLAY = False
# Logging might not be set up if this is imported very early by a child process
print("ERROR: MapDisplay - OpenCV or NumPy not found. Map display cannot function.")
try:
from PIL import Image
ImageType = Image.Image # type: ignore
PIL_LIB_AVAILABLE_DISPLAY = True
except ImportError:
Image = None # type: ignore
ImageType = None # type: ignore
PIL_LIB_AVAILABLE_DISPLAY = False
print("WARNING: MapDisplay - Pillow (PIL) not found. Image conversion from PIL might fail.")
try:
import mercantile # For Web Mercator tile calculations and coordinate conversions
MERCANTILE_LIB_AVAILABLE_DISPLAY = True
except ImportError:
mercantile = None # type: ignore
MERCANTILE_LIB_AVAILABLE_DISPLAY = False
print("ERROR: MapDisplay - 'mercantile' library not found. Coordinate conversions will fail.")
# Module-level logger
logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.map_display'
# Default window properties (can be overridden or extended if needed)
DEFAULT_CV_WINDOW_X_POSITION = 150
DEFAULT_CV_WINDOW_Y_POSITION = 150
class MapDisplayWindow:
"""
Manages an OpenCV window for displaying map images and handling mouse interactions.
The displayed image is scaled according to a factor provided by the app_facade.
"""
def __init__(
self,
app_facade: Any, # Instance of GeoElevationMapViewer (or similar providing scale and click handler)
window_name_str: str = "GeoElevation - Interactive Map",
initial_screen_x_pos: int = DEFAULT_CV_WINDOW_X_POSITION,
initial_screen_y_pos: int = DEFAULT_CV_WINDOW_Y_POSITION
) -> None:
"""
Initializes the MapDisplayWindow manager.
Args:
app_facade: An object that has a 'handle_map_mouse_click(x, y)' method
and an attribute 'current_display_scale_factor'.
window_name_str: The name for the OpenCV window.
initial_screen_x_pos: Initial X screen position for the window.
initial_screen_y_pos: Initial Y screen position for the window.
"""
logger.info(f"Initializing MapDisplayWindow with name: '{window_name_str}'")
if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY:
# This error should ideally prevent the application from reaching this point
# if map functionality is critical and relies on this module.
raise ImportError("OpenCV and/or NumPy are not available for MapDisplayWindow operation.")
self.app_facade_handler: Any = app_facade # Facade to access scale and report clicks
self.opencv_window_name: str = window_name_str
self.window_start_x_position: int = initial_screen_x_pos
self.window_start_y_position: int = initial_screen_y_pos
self.is_opencv_window_initialized: bool = False
self.is_opencv_mouse_callback_set: bool = False
# Stores the shape (height, width) of the *scaled image actually displayed* by imshow
self._last_displayed_scaled_image_shape: Tuple[int, int] = (0, 0)
def show_map(self, map_pil_image_input: Optional[ImageType]) -> None:
"""
Displays the provided map image (PIL format) in the OpenCV window.
The image is first converted to BGR, then scaled using the factor
from `app_facade.current_display_scale_factor`, and then displayed.
The OpenCV window autosizes to the scaled image.
Args:
map_pil_image_input: The map image (PIL.Image) to display.
If None, a placeholder image is shown.
"""
if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY:
logger.error("Cannot show map: OpenCV/NumPy not available.")
return
bgr_image_unscaled: Optional[np.ndarray] # type: ignore
if map_pil_image_input is None:
logger.warning("Received None PIL image for display. Generating a placeholder.")
bgr_image_unscaled = self._create_placeholder_bgr_numpy_array()
elif PIL_LIB_AVAILABLE_DISPLAY and isinstance(map_pil_image_input, Image.Image): # type: ignore
logger.debug(
f"Converting input PIL Image (Size: {map_pil_image_input.size}, Mode: {map_pil_image_input.mode}) to BGR."
)
bgr_image_unscaled = self._convert_pil_image_to_bgr_numpy_array(map_pil_image_input)
else:
logger.error(
f"Received unexpected image type for display: {type(map_pil_image_input)}. Using placeholder."
)
bgr_image_unscaled = self._create_placeholder_bgr_numpy_array()
if bgr_image_unscaled is None: # Fallback if conversion or placeholder failed
logger.error("Failed to obtain a BGR image (unscaled) for display. Using minimal black square.")
bgr_image_unscaled = np.zeros((256, 256, 3), dtype=np.uint8) # type: ignore
# --- Apply Display Scaling ---
scaled_bgr_image_for_display: np.ndarray = bgr_image_unscaled # type: ignore
try:
display_scale = 1.0 # Default scale if not found on facade
if hasattr(self.app_facade_handler, 'current_display_scale_factor'):
display_scale = float(self.app_facade_handler.current_display_scale_factor)
if display_scale <= 0: # Prevent invalid scale
logger.warning(f"Invalid scale factor {display_scale} from facade. Using 1.0.")
display_scale = 1.0
else:
logger.warning("Display scale factor not found on app_facade. Defaulting to 1.0.")
unscaled_h, unscaled_w = bgr_image_unscaled.shape[:2]
logger.debug(
f"Unscaled BGR image size: {unscaled_w}x{unscaled_h}. Applying display scale: {display_scale:.3f}"
)
if abs(display_scale - 1.0) > 1e-6 and unscaled_w > 0 and unscaled_h > 0:
target_w = max(1, int(round(unscaled_w * display_scale)))
target_h = max(1, int(round(unscaled_h * display_scale)))
interpolation_method = cv2.INTER_LINEAR if display_scale > 1.0 else cv2.INTER_AREA # type: ignore
logger.debug(f"Resizing image from {unscaled_w}x{unscaled_h} to {target_w}x{target_h}")
scaled_bgr_image_for_display = cv2.resize( # type: ignore
bgr_image_unscaled, (target_w, target_h), interpolation=interpolation_method
)
# else: no scaling needed, scaled_bgr_image_for_display remains bgr_image_unscaled
except Exception as e_scaling_img:
logger.exception(f"Error during image scaling: {e_scaling_img}. Displaying unscaled image.")
scaled_bgr_image_for_display = bgr_image_unscaled # Fallback to unscaled
# --- End Display Scaling ---
current_disp_h, current_disp_w = scaled_bgr_image_for_display.shape[:2]
logger.debug(f"Final scaled image shape for display: {current_disp_w}x{current_disp_h}")
# Recreate OpenCV window if its initialized state suggests it's needed,
# or if the size of the (scaled) image to be displayed has changed.
if self.is_opencv_window_initialized and \
(current_disp_h, current_disp_w) != self._last_displayed_scaled_image_shape:
logger.info(
f"Scaled image size changed ({self._last_displayed_scaled_image_shape} -> "
f"{(current_disp_h, current_disp_w)}). Recreating OpenCV window."
)
try:
cv2.destroyWindow(self.opencv_window_name) # type: ignore
cv2.waitKey(5) # Allow OS to process window destruction
self.is_opencv_window_initialized = False
self.is_opencv_mouse_callback_set = False # Callback must be reset
except cv2.error as e_cv_destroy: # type: ignore
logger.warning(f"Error destroying OpenCV window before recreation: {e_cv_destroy}")
self.is_opencv_window_initialized = False # Force recreation attempt
self.is_opencv_mouse_callback_set = False
# Ensure OpenCV window exists and mouse callback is properly set
if not self.is_opencv_window_initialized:
logger.debug(f"Creating/moving OpenCV window: '{self.opencv_window_name}' (AUTOSIZE)")
cv2.namedWindow(self.opencv_window_name, cv2.WINDOW_AUTOSIZE) # type: ignore
try:
cv2.moveWindow(self.opencv_window_name, self.window_start_x_position, self.window_start_y_position) # type: ignore
except cv2.error as e_cv_move: # type: ignore
logger.warning(f"Could not move OpenCV window '{self.opencv_window_name}': {e_cv_move}")
self.is_opencv_window_initialized = True # Assume created even if move failed
logger.info(f"OpenCV window '{self.opencv_window_name}' (AUTOSIZE) is ready.")
if self.is_opencv_window_initialized and not self.is_opencv_mouse_callback_set:
try:
cv2.setMouseCallback(self.opencv_window_name, self._opencv_mouse_callback, param=self.app_facade_handler) # type: ignore
self.is_opencv_mouse_callback_set = True
logger.info(f"Mouse callback successfully set for '{self.opencv_window_name}'.")
except cv2.error as e_cv_callback: # type: ignore
logger.error(f"Failed to set mouse callback for '{self.opencv_window_name}': {e_cv_callback}")
# Display the final (scaled) image
try:
cv2.imshow(self.opencv_window_name, scaled_bgr_image_for_display) # type: ignore
# Store the shape of the image that was actually displayed (scaled)
self._last_displayed_scaled_image_shape = (current_disp_h, current_disp_w)
# cv2.waitKey(1) is important for OpenCV to process GUI events.
# The main event loop for this window is expected to be handled by the
# calling process (e.g., the run_map_viewer_process_target loop).
except cv2.error as e_cv_imshow: # type: ignore
if "NULL window" in str(e_cv_imshow).lower() or \
"invalid window" in str(e_cv_imshow).lower() or \
"checkView" in str(e_cv_imshow).lower():
logger.warning(f"OpenCV window '{self.opencv_window_name}' seems closed or invalid during imshow operation.")
self.is_opencv_window_initialized = False
self.is_opencv_mouse_callback_set = False
self._last_displayed_scaled_image_shape = (0, 0)
else: # Re-raise other OpenCV errors if not related to window state
logger.exception(f"OpenCV error during map display (imshow): {e_cv_imshow}")
except Exception as e_disp_final:
logger.exception(f"Unexpected error displaying final map image: {e_disp_final}")
def _opencv_mouse_callback(self, event_type: int, x_coord: int, y_coord: int, flags: int, app_facade_param: Any) -> None:
"""
Internal OpenCV mouse callback function.
Invoked by OpenCV when a mouse event occurs in the managed window.
Clamps coordinates and calls the app_facade's handler method.
'app_facade_param' is expected to be the GeoElevationMapViewer instance.
"""
if event_type == cv2.EVENT_LBUTTONDOWN: # type: ignore # Check for left mouse button down event
# Get the dimensions of the image currently displayed (which is the scaled image)
current_displayed_height, current_displayed_width = self._last_displayed_scaled_image_shape
if current_displayed_width <= 0 or current_displayed_height <= 0:
logger.warning("Mouse click on map, but no valid displayed image dimensions are stored.")
return # Cannot process click without knowing the displayed image size
# Clamp clicked x, y coordinates to be within the bounds of the displayed (scaled) image
x_coord_clamped = max(0, min(x_coord, current_displayed_width - 1))
y_coord_clamped = max(0, min(y_coord, current_displayed_height - 1))
logger.debug(
f"Map Window Left Click (OpenCV raw): ({x_coord},{y_coord}), "
f"Clamped to displayed image: ({x_coord_clamped},{y_coord_clamped})"
)
if app_facade_param and hasattr(app_facade_param, 'handle_map_mouse_click'):
# Call the designated handler method on the GeoElevationMapViewer instance
app_facade_param.handle_map_mouse_click(x_coord_clamped, y_coord_clamped)
else:
logger.error(
"app_facade_param not correctly passed to OpenCV mouse callback, or it lacks "
"the 'handle_map_mouse_click' method."
)
def pixel_to_geo_on_current_map(
self,
pixel_x_on_displayed: int, # X coordinate on the (potentially scaled) displayed image
pixel_y_on_displayed: int, # Y coordinate on the (potentially scaled) displayed image
current_map_geo_bounds: Tuple[float, float, float, float], # west, south, east, north of UN SCALED map
displayed_map_pixel_shape: Tuple[int, int], # height, width of SCALED image shown
current_map_native_zoom: int # Zoom level of the UN SCALED map data
) -> Optional[Tuple[float, float]]: # Returns (latitude, longitude)
"""
Converts pixel coordinates from the (potentially scaled) displayed map image
to geographic WGS84 coordinates (latitude, longitude).
"""
if not MERCANTILE_LIB_AVAILABLE_DISPLAY:
logger.error("mercantile library not available for pixel_to_geo conversion.")
return None
if not (current_map_geo_bounds and displayed_map_pixel_shape and current_map_native_zoom is not None):
logger.warning("Cannot convert pixel to geo: Current map context for conversion is incomplete.")
return None
# Dimensions of the image *as it is displayed* (after scaling)
displayed_height, displayed_width = displayed_map_pixel_shape
# Geographic bounds of the *original, unscaled* map tile data
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds
if displayed_width <= 0 or displayed_height <= 0:
logger.error("Cannot convert pixel to geo: Invalid displayed map dimensions.")
return None
try:
# Get Web Mercator coordinates of the unscaled map's top-left and bottom-right corners
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 pixel to geo: Invalid Mercator dimensions for map bounds.")
return None
# Calculate relative position of the click within the *displayed (scaled)* map (0.0 to 1.0)
relative_x_on_displayed_map = pixel_x_on_displayed / displayed_width
relative_y_on_displayed_map = pixel_y_on_displayed / displayed_height
# Use these relative positions to find the corresponding Web Mercator coordinate
# within the *unscaled* map's Mercator extent.
clicked_point_merc_x = map_ul_merc_x + (relative_x_on_displayed_map * total_map_width_merc)
clicked_point_merc_y = map_ul_merc_y - (relative_y_on_displayed_map * total_map_height_merc)
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_px_to_geo:
logger.exception(f"Error during pixel_to_geo conversion: {e_px_to_geo}")
return None
def geo_to_pixel_on_current_map(
self,
latitude_deg: float,
longitude_deg: float,
current_map_geo_bounds: Tuple[float, float, float, float], # west, south, east, north of UN SCALED map
displayed_map_pixel_shape: Tuple[int, int], # height, width of SCALED image shown
current_map_native_zoom: int # Zoom level of the UN SCALED map data
) -> Optional[Tuple[int, int]]: # Returns (pixel_x, pixel_y) on the SCALED image
"""
Converts geographic WGS84 coordinates to pixel coordinates (x, y)
on the currently displayed (potentially scaled) map image.
"""
if not MERCANTILE_LIB_AVAILABLE_DISPLAY:
logger.error("mercantile library not available for geo_to_pixel conversion.")
return None
if not (current_map_geo_bounds and displayed_map_pixel_shape and current_map_native_zoom is not None):
logger.warning("Cannot convert geo to pixel: Current map context for conversion is incomplete.")
return None
displayed_height, displayed_width = displayed_map_pixel_shape
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds
if displayed_width <= 0 or displayed_height <= 0:
logger.error("Cannot convert geo to pixel: Invalid displayed 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 bounds.")
return None
target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) # type: ignore
# Relative position of the target geo point within the *unscaled* map's Mercator extent
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc
# Convert these relative positions to pixel coordinates on the *displayed (scaled)* image
pixel_x_on_displayed = int(round(relative_merc_x_in_map * displayed_width))
pixel_y_on_displayed = int(round(relative_merc_y_in_map * displayed_height))
# Clamp to the boundaries of the displayed (scaled) image
px_clamped = max(0, min(pixel_x_on_displayed, displayed_width - 1))
py_clamped = max(0, min(pixel_y_on_displayed, displayed_height - 1))
return (px_clamped, py_clamped)
except Exception as e_geo_to_px:
logger.exception(f"Error during geo_to_pixel conversion: {e_geo_to_px}")
return None
def _create_placeholder_bgr_numpy_array(self) -> np.ndarray: # type: ignore
"""Creates a simple BGR NumPy array to use as a placeholder image."""
placeholder_h = 256 # Default placeholder dimensions
placeholder_w = 256
bgr_light_grey = (220, 220, 220) # BGR color for light grey
if np: # type: ignore
return np.full((placeholder_h, placeholder_w, 3), bgr_light_grey, dtype=np.uint8) # type: ignore
else: # Fallback if NumPy somehow became None (should not happen if CV2_NUMPY_AVAILABLE is true)
# This case is highly unlikely if __init__ guard passed.
# Creating a manual list of lists if np is None is too complex for a simple placeholder.
# Returning a pre-made small array or raising error might be better.
# For now, this indicates a severe issue.
logger.critical("NumPy became unavailable unexpectedly during placeholder creation.")
# Return a minimal black image to avoid crashing imshow if possible
return [[[0,0,0]]] * 10 # type: ignore # Minimal 10x1 black image (highly not ideal)
def _convert_pil_image_to_bgr_numpy_array(self, pil_image: ImageType) -> Optional[np.ndarray]: # type: ignore
"""Converts a PIL Image object to a NumPy BGR array for OpenCV display."""
if not (PIL_LIB_AVAILABLE_DISPLAY and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY and pil_image):
logger.error("Cannot convert PIL to BGR: Pillow, OpenCV/NumPy missing, or input image is None.")
return None
try:
numpy_image_array = np.array(pil_image) # type: ignore
if numpy_image_array.ndim == 2: # Grayscale image
return cv2.cvtColor(numpy_image_array, cv2.COLOR_GRAY2BGR) # type: ignore
elif numpy_image_array.ndim == 3:
if numpy_image_array.shape[2] == 3: # RGB image
return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGB2BGR) # type: ignore
elif numpy_image_array.shape[2] == 4: # RGBA image (alpha channel will be stripped)
return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGBA2BGR) # type: ignore
logger.warning(
f"Unsupported NumPy image shape after PIL conversion: {numpy_image_array.shape}. Cannot convert to BGR."
)
return None
except Exception as e_conv_pil_bgr:
logger.exception(f"Error converting PIL image to BGR NumPy array: {e_conv_pil_bgr}")
return None
def is_window_alive(self) -> bool:
"""Checks if the OpenCV window is likely still open and initialized."""
if not self.is_opencv_window_initialized or not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY:
return False # Not initialized or OpenCV gone
try:
# WND_PROP_VISIBLE returns >= 1.0 if window is visible, 0.0 if hidden/occluded,
# and < 0 (typically -1.0) if window does not exist.
window_visibility = cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_VISIBLE) # type: ignore
if window_visibility >= 1.0: # Window exists and is visible
return True
else: # Window likely closed or an issue occurred
logger.debug(
f"Window '{self.opencv_window_name}' not reported as visible (prop_val={window_visibility}). "
"Assuming it's closed or invalid for interaction."
)
self.is_opencv_window_initialized = False # Update internal state
self.is_opencv_mouse_callback_set = False
return False
except cv2.error: # type: ignore
# OpenCV error (e.g., window name invalid/destroyed)
logger.debug(f"OpenCV error when checking property for window '{self.opencv_window_name}'. Assuming closed.")
self.is_opencv_window_initialized = False
self.is_opencv_mouse_callback_set = False
return False
except Exception as e_unexpected_alive_check:
logger.exception(f"Unexpected error checking if window '{self.opencv_window_name}' is alive: {e_unexpected_alive_check}")
self.is_opencv_window_initialized = False # Assume not alive on any other error
self.is_opencv_mouse_callback_set = False
return False
def destroy_window(self) -> None:
"""Explicitly destroys the managed OpenCV window and resets state flags."""
logger.info(f"Attempting to destroy OpenCV window: '{self.opencv_window_name}'")
if self.is_opencv_window_initialized and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY:
try:
cv2.destroyWindow(self.opencv_window_name) # type: ignore
cv2.waitKey(5) # Give OpenCV a moment to process the destruction
logger.info(f"Window '{self.opencv_window_name}' explicitly destroyed.")
except cv2.error as e_cv_destroy_final: # type: ignore
logger.warning(
f"Ignoring OpenCV error during explicit destroyWindow '{self.opencv_window_name}' "
f"(window might have been already closed by user): {e_cv_destroy_final}"
)
except Exception as e_destroy_final_generic:
logger.exception(
f"Unexpected error during explicit destroy window '{self.opencv_window_name}': {e_destroy_final_generic}"
)
else:
logger.debug(
f"Window '{self.opencv_window_name}' was not marked as initialized or OpenCV is not available. "
"No explicit destroy action taken."
)
# Always reset flags after attempting destroy to ensure clean state
self.is_opencv_window_initialized = False
self.is_opencv_mouse_callback_set = False
self._last_displayed_scaled_image_shape = (0, 0)