SXXXXXXX_GeoElevation/geoelevation/map_viewer/map_display.py
2025-05-13 16:01:44 +02:00

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)