# 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)