336 lines
15 KiB
Python
336 lines
15 KiB
Python
# --- START OF FILE map_display.py ---
|
|
|
|
# map_display.py
|
|
"""
|
|
Manages the dedicated OpenCV window for displaying the map overlay.
|
|
Applies scaling based on AppState factor before displaying in a fixed-size window.
|
|
"""
|
|
|
|
# Standard library imports
|
|
import logging
|
|
import math # Added for math functions if needed
|
|
from typing import Optional, TYPE_CHECKING # Added Optional
|
|
|
|
# Third-party imports
|
|
import cv2
|
|
import numpy as np
|
|
|
|
try:
|
|
from PIL import Image
|
|
except ImportError:
|
|
Image = None
|
|
|
|
# Local application imports
|
|
import config
|
|
|
|
# Type hinting for App reference
|
|
if TYPE_CHECKING:
|
|
from app import App
|
|
|
|
|
|
class MapDisplayWindow:
|
|
"""
|
|
Manages the OpenCV window used to display the map.
|
|
Applies scaling based on AppState factor before displaying in a fixed-size window.
|
|
"""
|
|
|
|
# MAX_DISPLAY_WIDTH/HEIGHT are less relevant now for window flags,
|
|
# but can be used for initial sizing or sanity checks.
|
|
MAX_DISPLAY_WIDTH = config.MAX_MAP_DISPLAY_WIDTH
|
|
MAX_DISPLAY_HEIGHT = config.MAX_MAP_DISPLAY_HEIGHT
|
|
|
|
def __init__(
|
|
self,
|
|
app: "App", # <<< ACCEPT APP INSTANCE
|
|
window_name: str = "Map Overlay",
|
|
x_pos: int = 100,
|
|
y_pos: int = 100,
|
|
):
|
|
"""
|
|
Initializes the MapDisplayWindow manager.
|
|
|
|
Args:
|
|
app (App): Reference to the main App instance (for AppState access).
|
|
window_name (str): The name for the OpenCV window.
|
|
x_pos (int): Initial X position for the window.
|
|
y_pos (int): Initial Y position for the window.
|
|
"""
|
|
self._log_prefix = "[MapDisplay]"
|
|
if Image is None:
|
|
# Log critical if Pillow missing, as it's needed for conversion
|
|
logging.critical(
|
|
f"{self._log_prefix} 'Pillow' library not found. Map display will likely fail."
|
|
)
|
|
|
|
self.app = app # <<< STORE APP INSTANCE
|
|
self.window_name = window_name
|
|
self.x_pos = x_pos
|
|
self.y_pos = y_pos
|
|
self.window_initialized = False
|
|
# Store the shape of the image actually sent to imshow
|
|
self._last_displayed_shape: Tuple[int, int] = (0, 0)
|
|
logging.debug(
|
|
f"{self._log_prefix} Initializing window manager for '{self.window_name}'."
|
|
)
|
|
|
|
def show_map(self, map_image_pil: Optional[Image.Image]):
|
|
"""
|
|
Displays the map image. Converts PIL to BGR, applies scaling based on
|
|
AppState factor, and shows in a fixed-size (autosized) OpenCV window.
|
|
Recreates the window if the scaled image size changes.
|
|
|
|
Args:
|
|
map_image_pil (Optional[Image.Image]): The map image (PIL format) to display.
|
|
If None, shows a placeholder/error image.
|
|
"""
|
|
log_prefix = f"{self._log_prefix} ShowMap (Scaled)"
|
|
|
|
# --- 1. Prepare Base Image (Placeholder or Convert PIL) ---
|
|
map_to_display_bgr = None # Start with None
|
|
if map_image_pil is None:
|
|
logging.warning(
|
|
f"{log_prefix} Received None image payload. Generating placeholder."
|
|
)
|
|
if Image is None:
|
|
# Cannot create PIL placeholder if Pillow not loaded
|
|
map_to_display_bgr = np.full((256, 256, 3), 60, dtype=np.uint8)
|
|
logging.error(
|
|
f"{log_prefix} Pillow not available, using minimal fallback placeholder."
|
|
)
|
|
else:
|
|
try:
|
|
# Try using map service tile size if available, else default
|
|
tile_size = (
|
|
self.app.map_integration_manager._map_service.tile_size
|
|
if hasattr(self.app, "map_integration_manager")
|
|
and self.app.map_integration_manager
|
|
and hasattr(self.app.map_integration_manager, "_map_service")
|
|
else 256
|
|
)
|
|
placeholder_size = (tile_size, tile_size)
|
|
placeholder_color_rgb = getattr(
|
|
config, "OFFLINE_MAP_PLACEHOLDER_COLOR", (200, 200, 200)
|
|
)
|
|
if not (
|
|
isinstance(placeholder_color_rgb, tuple)
|
|
and len(placeholder_color_rgb) == 3
|
|
):
|
|
placeholder_color_rgb = (200, 200, 200)
|
|
placeholder_pil = Image.new(
|
|
"RGB", placeholder_size, color=placeholder_color_rgb
|
|
)
|
|
placeholder_np = np.array(placeholder_pil)
|
|
map_to_display_bgr = cv2.cvtColor(placeholder_np, cv2.COLOR_RGB2BGR)
|
|
except Exception as ph_err:
|
|
logging.exception(
|
|
f"{log_prefix} Failed to create placeholder image:"
|
|
)
|
|
map_to_display_bgr = np.full(
|
|
(256, 256, 3), 60, dtype=np.uint8
|
|
) # Minimal fallback
|
|
|
|
elif Image is not None and isinstance(map_image_pil, Image.Image):
|
|
logging.debug(
|
|
f"{log_prefix} Converting PIL Image payload (Size: {map_image_pil.size}, Mode: {map_image_pil.mode})..."
|
|
)
|
|
try:
|
|
map_image_np = np.array(map_image_pil)
|
|
if map_image_np.ndim == 2:
|
|
map_to_display_bgr = cv2.cvtColor(map_image_np, cv2.COLOR_GRAY2BGR)
|
|
elif map_image_np.ndim == 3 and map_image_np.shape[2] == 3:
|
|
map_to_display_bgr = cv2.cvtColor(map_image_np, cv2.COLOR_RGB2BGR)
|
|
elif map_image_np.ndim == 3 and map_image_np.shape[2] == 4:
|
|
map_to_display_bgr = cv2.cvtColor(map_image_np, cv2.COLOR_RGBA2BGR)
|
|
else:
|
|
raise ValueError(
|
|
f"Unsupported NumPy shape after PIL conversion: {map_image_np.shape}"
|
|
)
|
|
logging.debug(
|
|
f"{log_prefix} Converted PIL to NumPy BGR (Shape: {map_to_display_bgr.shape})."
|
|
)
|
|
except Exception as e:
|
|
logging.exception(f"{log_prefix} Error converting received PIL image:")
|
|
map_to_display_bgr = np.full(
|
|
(256, 256, 3), 60, dtype=np.uint8
|
|
) # Fallback
|
|
else:
|
|
logging.error(
|
|
f"{log_prefix} Received unexpected payload type: {type(map_image_pil)}. Using fallback."
|
|
)
|
|
map_to_display_bgr = np.full((256, 256, 3), 60, dtype=np.uint8) # Fallback
|
|
|
|
if map_to_display_bgr is None: # Final safety net
|
|
logging.error(
|
|
f"{log_prefix} map_to_display_bgr is None before scaling. Using minimal fallback."
|
|
)
|
|
map_to_display_bgr = np.full((256, 256, 3), 60, dtype=np.uint8)
|
|
|
|
final_image_to_show = None # Initialize variable for final scaled image
|
|
try:
|
|
# --- 2. Apply Scaling based on AppState Factor ---
|
|
scale_factor = self.app.state.map_display_scale_factor
|
|
img_h, img_w = map_to_display_bgr.shape[:2]
|
|
logging.debug(
|
|
f"{log_prefix} Base image size: {img_w}x{img_h}. Scale factor from state: {scale_factor:.4f}"
|
|
)
|
|
|
|
if (
|
|
abs(scale_factor - 1.0) > 1e-6 and img_w > 0 and img_h > 0
|
|
): # Apply scaling if not 1.0
|
|
# Calculate new dimensions, ensure they are at least 1x1
|
|
target_display_w = max(1, int(round(img_w * scale_factor)))
|
|
target_display_h = max(1, int(round(img_h * scale_factor)))
|
|
|
|
if target_display_w != img_w or target_display_h != img_h:
|
|
# Choose interpolation based on scaling direction
|
|
interpolation = (
|
|
cv2.INTER_LINEAR if scale_factor > 1.0 else cv2.INTER_AREA
|
|
)
|
|
logging.debug(
|
|
f"{log_prefix} Resizing image to {target_display_w}x{target_display_h} using interpolation {interpolation}..."
|
|
)
|
|
final_image_to_show = cv2.resize(
|
|
map_to_display_bgr,
|
|
(target_display_w, target_display_h),
|
|
interpolation=interpolation,
|
|
)
|
|
else:
|
|
logging.debug(
|
|
f"{log_prefix} Scaled dimensions are same as original. Skipping resize."
|
|
)
|
|
final_image_to_show = (
|
|
map_to_display_bgr # Use original if calculated size is same
|
|
)
|
|
else:
|
|
logging.debug(
|
|
f"{log_prefix} No scaling needed (factor is ~1.0 or invalid base size)."
|
|
)
|
|
final_image_to_show = (
|
|
map_to_display_bgr # Use the original (or placeholder)
|
|
)
|
|
|
|
if final_image_to_show is None: # Safety check after scaling logic
|
|
logging.error(
|
|
f"{log_prefix} Image is None after scaling attempt. Using base image."
|
|
)
|
|
final_image_to_show = map_to_display_bgr
|
|
|
|
# --- 3. Display using OpenCV (WINDOW_AUTOSIZE) ---
|
|
final_shape = final_image_to_show.shape
|
|
final_h, final_w = final_shape[:2]
|
|
logging.debug(
|
|
f"{log_prefix} Final image shape for display (scaled): {final_w}x{final_h}"
|
|
)
|
|
|
|
# --- Window Recreation Logic ---
|
|
# Check if window needs recreation due to size change
|
|
if (
|
|
self.window_initialized
|
|
and (final_h, final_w) != self._last_displayed_shape
|
|
):
|
|
logging.info(
|
|
f"{log_prefix} Image size changed ({self._last_displayed_shape} -> {final_shape[:2]}). Recreating window '{self.window_name}'..."
|
|
)
|
|
try:
|
|
cv2.destroyWindow(self.window_name)
|
|
# Wait briefly after destroy? Sometimes helps window manager catch up.
|
|
cv2.waitKey(5)
|
|
self.window_initialized = False # Force recreation below
|
|
logging.debug(f"{log_prefix} Destroyed existing window.")
|
|
except cv2.error as e:
|
|
# Log error but still mark as not initialized to attempt recreation
|
|
logging.warning(
|
|
f"{log_prefix} Error destroying window before recreate: {e}"
|
|
)
|
|
self.window_initialized = False
|
|
|
|
# Create window if it doesn't exist (will autosize to image)
|
|
# Use namedWindow only to allow moving it. AUTOSIZE is default behavior for imshow if window doesn't exist.
|
|
if not self.window_initialized:
|
|
logging.debug(
|
|
f"{log_prefix} Ensuring window '{self.window_name}' exists (AUTOSIZE) and is positioned..."
|
|
)
|
|
# Create with AUTOSIZE (default if not specified, but explicit is fine)
|
|
cv2.namedWindow(self.window_name, cv2.WINDOW_AUTOSIZE)
|
|
try:
|
|
# Move window immediately after creation
|
|
cv2.moveWindow(self.window_name, self.x_pos, self.y_pos)
|
|
self.window_initialized = (
|
|
True # Mark initialized after successful creation/move attempt
|
|
)
|
|
logging.info(
|
|
f"{log_prefix} Window '{self.window_name}' (AUTOSIZE) created/moved to ({self.x_pos}, {self.y_pos})."
|
|
)
|
|
except cv2.error as move_e:
|
|
logging.warning(
|
|
f"{log_prefix} Could not move '{self.window_name}' window after creation: {move_e}."
|
|
)
|
|
# Assume window exists even if move failed
|
|
self.window_initialized = True
|
|
|
|
# Display the FINAL scaled image (window will adjust size automatically)
|
|
cv2.imshow(self.window_name, final_image_to_show)
|
|
self._last_displayed_shape = (
|
|
final_h,
|
|
final_w,
|
|
) # Store the shape we just displayed
|
|
logging.debug(
|
|
f"{log_prefix} Displayed final scaled image in window '{self.window_name}'."
|
|
)
|
|
|
|
# Keep waitKey(1) to allow OpenCV to process events
|
|
cv2.waitKey(1)
|
|
|
|
except cv2.error as e:
|
|
# Handle errors, including potential closed window
|
|
if (
|
|
"NULL window" in str(e)
|
|
or "invalid window" in str(e)
|
|
or "checkView" in str(e)
|
|
):
|
|
logging.warning(
|
|
f"{log_prefix} OpenCV window '{self.window_name}' seems closed or invalid."
|
|
)
|
|
self.window_initialized = False # Reset flag
|
|
self._last_displayed_shape = (0, 0) # Reset shape tracking
|
|
else:
|
|
logging.exception(f"{log_prefix} OpenCV error during map display: {e}")
|
|
except Exception as e:
|
|
logging.exception(
|
|
f"{log_prefix} Unexpected error displaying map image: {e}"
|
|
)
|
|
# Consider resetting initialized flag on unexpected errors too?
|
|
# self.window_initialized = False
|
|
# self._last_displayed_shape = (0,0)
|
|
|
|
def destroy_window(self):
|
|
"""Explicitly destroys the managed OpenCV window."""
|
|
logging.info(
|
|
f"{self._log_prefix} Attempting to destroy window: '{self.window_name}'"
|
|
)
|
|
# Check name existence as fallback if init flag is false but window might exist
|
|
if self.window_initialized or hasattr(self, "window_name"):
|
|
try:
|
|
cv2.destroyWindow(self.window_name)
|
|
self.window_initialized = False
|
|
self._last_displayed_shape = (0, 0) # Reset shape
|
|
logging.info(
|
|
f"{self._log_prefix} Window '{self.window_name}' destroyed successfully."
|
|
)
|
|
except cv2.error as e:
|
|
# Warning is sufficient as window might be gone already
|
|
logging.warning(
|
|
f"{self._log_prefix} Ignoring error destroying window '{self.window_name}' (may already be closed): {e}"
|
|
)
|
|
except Exception as e:
|
|
logging.exception(
|
|
f"{self._log_prefix} Unexpected error destroying window '{self.window_name}': {e}"
|
|
)
|
|
else:
|
|
logging.debug(
|
|
f"{self._log_prefix} Window '{self.window_name}' was not initialized or name unknown."
|
|
)
|
|
|
|
|
|
# --- END OF FILE map_display.py ---
|