620 lines
36 KiB
Python
620 lines
36 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
|
|
import sys # Import sys for logging stream
|
|
from typing import Optional, Tuple, Any # 'Any' for app_facade type hint
|
|
|
|
# Third-party imports
|
|
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
|
|
# Logging might not be set up if this is imported very early by a child process
|
|
# So, direct print or rely on higher-level logger configuration.
|
|
print("ERROR: MapDisplay - Pillow (PIL) library not found. Image conversion from PIL might fail.")
|
|
|
|
|
|
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
|
|
print("ERROR: MapDisplay - OpenCV or NumPy not found. Drawing and image operations will 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}'")
|
|
# MODIFIED: Added a check for critical dependencies at init.
|
|
# WHY: Ensure the class can function before proceeding.
|
|
# HOW: Raise ImportError if dependencies are missing.
|
|
if not CV2_NUMPY_LIBS_AVAILABLE_DISPLAY:
|
|
critical_msg = "OpenCV and/or NumPy are not available for MapDisplayWindow operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# PIL is needed for image conversion, but not strictly for windowing itself,
|
|
# though show_map will likely fail without it for non-numpy inputs.
|
|
# mercantile is needed for pixel-geo conversions, not for windowing.
|
|
# We'll check those where they are strictly needed.
|
|
|
|
|
|
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()
|
|
# MODIFIED: Added more explicit check for PIL availability and instance type.
|
|
# WHY: Ensure PIL is available before attempting conversion from a PIL object.
|
|
# HOW: Included PIL_LIB_AVAILABLE_DISPLAY in the check.
|
|
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:
|
|
# This else branch handles cases where input is not None, not a PIL Image, or PIL is not available.
|
|
logger.error(
|
|
f"Received unexpected image type for display: {type(map_pil_image_input)}. Or Pillow is missing. 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.")
|
|
# MODIFIED: Create a minimal black image using NumPy for robustness.
|
|
# WHY: Ensure a displayable image is created even if placeholder creation fails.
|
|
# HOW: Use np.zeros.
|
|
if np: # Ensure np is available
|
|
bgr_image_unscaled = np.zeros((100, 100, 3), dtype=np.uint8) # type: ignore
|
|
else:
|
|
logger.critical("NumPy not available, cannot even create fallback black image for imshow.")
|
|
return # Cannot proceed without NumPy
|
|
|
|
# --- 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
|
|
# MODIFIED: Added check that app_facade_handler is not None before accessing its attribute.
|
|
# WHY: Avoids AttributeError if facade is unexpectedly None.
|
|
# HOW: Check 'if self.app_facade_handler and hasattr(...)'.
|
|
if self.app_facade_handler and hasattr(self.app_facade_handler, 'current_display_scale_factor'):
|
|
# MODIFIED: Added try-except around float conversion of scale factor.
|
|
# WHY: Defend against non-numeric scale factor values.
|
|
# HOW: Use a try-except block.
|
|
try:
|
|
display_scale = float(self.app_facade_handler.current_display_scale_factor)
|
|
except (ValueError, TypeError) as e_scale_conv:
|
|
logger.warning(f"Could not convert scale factor from facade to float: {self.app_facade_handler.current_display_scale_factor}. Using 1.0. Error: {e_scale_conv}")
|
|
display_scale = 1.0
|
|
|
|
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}"
|
|
)
|
|
|
|
# Only resize if scale is not 1.0 (with tolerance) and image dimensions are valid
|
|
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} using {interpolation_method}.")
|
|
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.
|
|
# Only recreate if window is initialized and its size changed from the last displayed size.
|
|
# We also add a check if the window exists.
|
|
window_exists = False
|
|
try:
|
|
# Check if the window property can be retrieved without error
|
|
if CV2_NUMPY_LIBS_AVAILABLE_DISPLAY and cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_AUTOSIZE) >= 0: # type: ignore # Any property check works
|
|
window_exists = True
|
|
except cv2.error: # type: ignore
|
|
window_exists = False # Error means window is gone
|
|
|
|
if self.is_opencv_window_initialized and window_exists 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
|
|
self._last_displayed_scaled_image_shape = (0, 0) # Reset stored size
|
|
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
|
|
self._last_displayed_scaled_image_shape = (0, 0)
|
|
|
|
# Ensure OpenCV window exists and mouse callback is properly set
|
|
# Create window if not initialized or if it was destroyed unexpectedly
|
|
if not self.is_opencv_window_initialized or not window_exists:
|
|
logger.debug(f"Creating/moving OpenCV window: '{self.opencv_window_name}' (AUTOSIZE)")
|
|
try:
|
|
cv2.namedWindow(self.opencv_window_name, cv2.WINDOW_AUTOSIZE) # type: ignore
|
|
# Try moving the window. This might fail on some systems or if window creation is delayed.
|
|
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.")
|
|
except Exception as e_window_create:
|
|
logger.error(f"Failed to create OpenCV window '{self.opencv_window_name}': {e_window_create}")
|
|
self.is_opencv_window_initialized = False # Mark as not initialized
|
|
self.is_opencv_mouse_callback_set = False
|
|
self._last_displayed_scaled_image_shape = (0, 0)
|
|
# Cannot proceed to imshow or set mouse callback if window creation failed.
|
|
return
|
|
|
|
# Set mouse callback if the window is initialized and callback hasn't been set
|
|
if self.is_opencv_window_initialized and not self.is_opencv_mouse_callback_set:
|
|
# MODIFIED: Added check that app_facade_handler is not None before setting callback param.
|
|
# WHY: Avoids passing None as param, although OpenCV might handle it, it's safer.
|
|
# HOW: Check 'if self.app_facade_handler:'.
|
|
if self.app_facade_handler:
|
|
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}")
|
|
self.is_opencv_mouse_callback_set = False # Mark as failed to set
|
|
else:
|
|
logger.warning("App facade is None, cannot set mouse callback parameter.")
|
|
self.is_opencv_mouse_callback_set = False
|
|
|
|
|
|
# Display the final (scaled) image if the window is initialized
|
|
if self.is_opencv_window_initialized:
|
|
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
|
|
# Catch specific OpenCV errors that indicate the window is gone
|
|
error_str = str(e_cv_imshow).lower()
|
|
if "null window" in error_str or "invalid window" in error_str or "checkview" in error_str:
|
|
logger.warning(f"OpenCV window '{self.opencv_window_name}' seems closed or invalid during imshow operation.")
|
|
# Reset state flags as the window is gone
|
|
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}")
|
|
else:
|
|
logger.error("Cannot display image: OpenCV window is not initialized.")
|
|
|
|
|
|
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.
|
|
This callback runs in the OpenCV internal thread.
|
|
"""
|
|
# MODIFIED: Added check for left mouse button down event.
|
|
# WHY: Only process clicks, ignore other mouse events like move etc.
|
|
# HOW: Check event_type.
|
|
if event_type == cv2.EVENT_LBUTTONDOWN: # type: ignore # Check for left mouse button down event
|
|
logger.debug(f"OpenCV Mouse Event: LBUTTONDOWN at raw pixel ({x_coord},{y_coord})")
|
|
|
|
# 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
|
|
# This is important because the click coordinates can sometimes be slightly outside the window bounds,
|
|
# or the image size might momentarily not match the window size.
|
|
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 ({current_displayed_width}x{current_displayed_height}): "
|
|
f"({x_coord_clamped},{y_coord_clamped})"
|
|
)
|
|
|
|
# The app_facade_param should be the GeoElevationMapViewer instance.
|
|
# We call its handler method, passing the clamped pixel coordinates on the *displayed* image.
|
|
if app_facade_param and hasattr(app_facade_param, 'handle_map_mouse_click'):
|
|
try:
|
|
# Call the designated handler method on the GeoElevationMapViewer instance
|
|
# Pass the clamped pixel coordinates on the SCALED, DISPLAYED image
|
|
app_facade_param.handle_map_mouse_click(x_coord_clamped, y_coord_clamped)
|
|
logger.debug("Called facade's handle_map_mouse_click.")
|
|
except Exception as e_handle_click:
|
|
logger.exception(f"Error executing handle_map_mouse_click on app facade: {e_handle_click}")
|
|
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).
|
|
|
|
This method is called by GeoElevationMapViewer.handle_map_mouse_click.
|
|
It uses the stored context of the original, unscaled map (`current_map_geo_bounds`, `current_map_native_zoom`)
|
|
and the shape of the image *actually displayed* (`displayed_map_pixel_shape`) to perform the conversion.
|
|
"""
|
|
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):
|
|
# This warning indicates the context needed for conversion wasn't properly stored/passed.
|
|
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
|
|
|
|
# Basic validation of dimensions
|
|
if displayed_width <= 0 or displayed_height <= 0:
|
|
logger.error("Cannot convert pixel to geo: Invalid displayed map dimensions.")
|
|
return None
|
|
|
|
try:
|
|
# Use mercantile to get Web Mercator coordinates of the unscaled map's 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)
|
|
|
|
# Handle zero dimensions in Mercator space (e.g., invalid geo bounds)
|
|
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)
|
|
# Ensure we don't divide by zero if dimensions are unexpectedly zero
|
|
relative_x_on_displayed_map = pixel_x_on_displayed / displayed_width if displayed_width > 0 else 0.0
|
|
relative_y_on_displayed_map = pixel_y_on_displayed / displayed_height if displayed_height > 0 else 0.0
|
|
|
|
# Use these relative positions to find the corresponding Web Mercator coordinate
|
|
# within the *unscaled* map's Mercator extent.
|
|
# Y Mercator increases towards North, pixel Y increases downwards. So use map_ul_merc_y - ...
|
|
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)
|
|
|
|
# Convert Mercator coordinates back to geographic (longitude, latitude)
|
|
clicked_lon, clicked_lat = mercantile.lnglat(clicked_point_merc_x, clicked_point_merc_y) # type: ignore
|
|
# Return as (latitude, longitude) tuple
|
|
return (clicked_lat, clicked_lon)
|
|
except Exception as e_px_to_geo:
|
|
logger.exception(f"Error during pixel_to_geo conversion for pixel ({pixel_x_on_displayed},{pixel_y_on_displayed}): {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.
|
|
|
|
This method might be called by the app_facade (GeoElevationMapViewer)
|
|
to determine where to draw a marker on the *displayed* image, although
|
|
the current drawing implementation in GeoElevationMapViewer draws on the
|
|
*unscaled* image and relies on its own direct geo-to-pixel logic for the unscaled image.
|
|
This method is kept here for completeness and potential future use if
|
|
drawing logic were moved to this class or needed scaled coordinates.
|
|
"""
|
|
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):
|
|
# This warning indicates the context needed for conversion wasn't properly stored/passed.
|
|
logger.warning("Cannot convert geo to pixel: 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
|
|
|
|
# Basic validation of dimensions
|
|
if displayed_width <= 0 or displayed_height <= 0:
|
|
logger.error("Cannot convert geo to pixel: Invalid displayed map dimensions.")
|
|
return None
|
|
|
|
try:
|
|
# Use mercantile to get Web Mercator coordinates of the unscaled map's 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)
|
|
|
|
# Handle zero dimensions in Mercator space (e.g., invalid geo bounds)
|
|
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
|
|
|
|
|
|
# Get Web Mercator coordinates of the target geographic point
|
|
target_merc_x, target_merc_y = mercantile.xy(longitude_deg, latitude_deg) # type: ignore
|
|
|
|
# Calculate relative position of the target geo point within the *unscaled* map's Mercator extent.
|
|
# Ensure we don't divide by zero.
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
|
|
|
|
# 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 for geo ({latitude_deg:.5f},{longitude_deg:.5f}): {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
|
|
# MODIFIED: Added check for NumPy availability before creation.
|
|
# WHY: Defend against scenarios where NumPy is None despite initial check (unlikely but safe).
|
|
# HOW: Check 'if np:'.
|
|
if np: # type: ignore
|
|
try:
|
|
return np.full((placeholder_h, placeholder_w, 3), bgr_light_grey, dtype=np.uint8) # type: ignore
|
|
except Exception as e_np_full:
|
|
logger.exception(f"Error creating NumPy full array for placeholder: {e_np_full}. Using zeros fallback.")
|
|
# Fallback to zeros array if full() fails
|
|
return np.zeros((100, 100, 3), dtype=np.uint8) # type: ignore # Minimal array
|
|
|
|
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.
|
|
logger.critical("NumPy became unavailable unexpectedly during placeholder creation.")
|
|
# Cannot create a NumPy array, return None which might cause further errors in imshow.
|
|
# This indicates a severe issue.
|
|
return None # type: ignore
|
|
|
|
|
|
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.
|
|
Handles different PIL modes (RGB, RGBA, L/Grayscale).
|
|
"""
|
|
# MODIFIED: Added check for PIL and CV2/NumPy availability.
|
|
# WHY: Ensure dependencies are present before attempting conversion.
|
|
# HOW: Added checks.
|
|
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:
|
|
# Convert PIL image to NumPy array. This retains the number of channels.
|
|
numpy_image_array = np.array(pil_image) # type: ignore
|
|
|
|
# Convert based on the number of channels (shape[2]) or dimension (ndim)
|
|
if numpy_image_array.ndim == 2: # Grayscale or L mode PIL image
|
|
logger.debug("Converting grayscale/L PIL image to BGR NumPy array.")
|
|
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
|
|
logger.debug("Converting RGB PIL image to BGR NumPy array.")
|
|
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)
|
|
logger.debug("Converting RGBA PIL image to BGR NumPy array (stripping Alpha).")
|
|
return cv2.cvtColor(numpy_image_array, cv2.COLOR_RGBA2BGR) # type: ignore
|
|
else:
|
|
logger.warning(
|
|
f"Unsupported NumPy image shape after PIL conversion ({numpy_image_array.shape}). Cannot convert to BGR."
|
|
)
|
|
return None
|
|
else: # Unexpected number of dimensions
|
|
logger.warning(
|
|
f"Unexpected NumPy image dimensions ({numpy_image_array.ndim}) after PIL conversion. 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."""
|
|
# MODIFIED: Added check for CV2/NumPy availability.
|
|
# WHY: Prevent errors if dependencies are gone.
|
|
# HOW: Added initial check.
|
|
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.
|
|
# Check for any property to see if the window handle is still valid.
|
|
# getWindowProperty returns -1 if the window does not exist.
|
|
window_property_value = cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_AUTOSIZE) # type: ignore
|
|
|
|
# A value of -1.0 indicates the window does not exist.
|
|
if window_property_value >= 0.0: # Window exists
|
|
logger.debug(f"Window '{self.opencv_window_name}' property check >= 0.0 ({window_property_value}). Assuming alive.")
|
|
# We can also check for visibility specifically if needed:
|
|
# visibility = cv2.getWindowProperty(self.opencv_window_name, cv2.WND_PROP_VISIBLE)
|
|
# if visibility >= 1.0: return True else False
|
|
return True # Window exists and is likely alive
|
|
else: # Window likely closed or an issue occurred (property < 0)
|
|
logger.debug(
|
|
f"Window '{self.opencv_window_name}' property check < 0.0 ({window_property_value}). "
|
|
"Assuming it's closed or invalid for interaction."
|
|
)
|
|
self.is_opencv_window_initialized = False # Update internal state
|
|
self.is_opencv_mouse_callback_set = False
|
|
self._last_displayed_scaled_image_shape = (0, 0)
|
|
return False
|
|
except cv2.error: # type: ignore
|
|
# OpenCV error (e.g., window name invalid/destroyed).
|
|
# This happens if the window was destroyed by user action or other means.
|
|
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
|
|
self._last_displayed_scaled_image_shape = (0, 0)
|
|
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}'")
|
|
# MODIFIED: Added check for CV2/NumPy availability before destroying.
|
|
# WHY: Prevent errors if dependencies are gone.
|
|
# HOW: Added initial check.
|
|
if self.is_opencv_window_initialized and CV2_NUMPY_LIBS_AVAILABLE_DISPLAY:
|
|
try:
|
|
cv2.destroyWindow(self.opencv_window_name) # type: ignore
|
|
# It is important to call cv2.waitKey() after destroyWindow to process the event queue
|
|
# and ensure the window is actually closed by the OS. A small delay helps.
|
|
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 regardless of outcome.
|
|
self.is_opencv_window_initialized = False
|
|
self.is_opencv_mouse_callback_set = False
|
|
self._last_displayed_scaled_image_shape = (0, 0) |