SXXXXXXX_ControlPanel/map_display.py
VALLONGOL f0c49a7934 fix overlay function
add shift sar map
2025-04-14 15:55:12 +02:00

441 lines
19 KiB
Python

# --- START OF FILE map_display.py ---
# map_display.py
"""
THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED.
Manages the dedicated OpenCV window for displaying the map overlay.
Applies scaling based on AppState factor before displaying in a fixed-size
(autosized) window and handles mouse events on the map window.
"""
# Standard library imports
import logging
import math
import time # Needed for mouse event rate limiting
from typing import Optional, TYPE_CHECKING, Tuple
# Third-party imports
import cv2
import numpy as np
# Try to import PIL for type hinting and image conversion
try:
from PIL import Image
ImageType = Image.Image
except ImportError:
Image = None # type: ignore
ImageType = None # type: ignore
# Local application imports
import config
from utils import put_queue # Needed for sending mouse coordinates to the app queue
# Type hinting for App reference
if TYPE_CHECKING:
# Import the specific main application class name
from ControlPanel import ControlPanelApp
class MapDisplayWindow:
"""
Manages the OpenCV window used to display the map.
Applies scaling based on AppState factor before displaying in a fixed-size window.
Handles mouse events within the map window.
"""
MAX_DISPLAY_WIDTH = config.MAX_MAP_DISPLAY_WIDTH
MAX_DISPLAY_HEIGHT = config.MAX_MAP_DISPLAY_HEIGHT
def __init__(
self,
app: "ControlPanelApp", # Accept the main application instance
window_name: str = "Map Overlay",
x_pos: int = 100,
y_pos: int = 100,
):
"""
Initializes the MapDisplayWindow manager.
Args:
app (ControlPanelApp): Reference to the main App instance (for AppState and queue 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:
logging.critical(
f"{self._log_prefix} 'Pillow' library not found. Map display will likely fail."
)
self.app = app # Store reference to the main application instance
self.window_name = window_name
self.x_pos = x_pos
self.y_pos = y_pos
self.window_initialized = False
# --- >>> START OF NEW ATTRIBUTE <<< ---
# Flag to track if the mouse callback has been successfully set
self.map_mouse_callback_set = False
# --- >>> END OF NEW ATTRIBUTE <<< ---
# Store the shape of the image actually sent to imshow for coordinate clamping
self._last_displayed_shape: Tuple[int, int] = (0, 0) # (height, width)
logging.debug(
f"{self._log_prefix} Initializing window manager for '{self.window_name}'."
)
def show_map(self, map_image_pil: Optional[ImageType]):
"""
Displays the map image. Converts PIL to BGR, applies scaling based on
AppState factor, shows in a fixed-size (autosized) OpenCV window,
and ensures the mouse callback is set. Recreates the window if the
scaled image size changes.
Args:
map_image_pil (Optional[ImageType]): The map image (PIL format) to display.
If None, shows a placeholder/error image.
"""
log_prefix = f"{self._log_prefix} ShowMap" # Shortened log prefix
# --- 1. Prepare Base Image (Placeholder or Convert PIL) ---
map_to_display_bgr = None
if map_image_pil is None:
logging.warning(
f"{log_prefix} Received None image payload. Generating placeholder."
)
map_to_display_bgr = self._create_placeholder_image()
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})..."
)
map_to_display_bgr = self._convert_pil_to_bgr(map_image_pil)
else:
logging.error(
f"{log_prefix} Received unexpected payload type: {type(map_image_pil)}. Using fallback."
)
map_to_display_bgr = self._create_placeholder_image()
# Final safety check if conversion/placeholder creation failed
if map_to_display_bgr is None:
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
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: {scale_factor:.4f}"
)
if abs(scale_factor - 1.0) > 1e-6 and img_w > 0 and img_h > 0:
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:
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}..."
)
final_image_to_show = cv2.resize(
map_to_display_bgr,
(target_display_w, target_display_h),
interpolation=interpolation,
)
else:
final_image_to_show = map_to_display_bgr
else:
final_image_to_show = map_to_display_bgr
if final_image_to_show is None:
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: {final_w}x{final_h}"
)
# --- Window Recreation Logic ---
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_h, final_w)}). Recreating window..."
)
try:
cv2.destroyWindow(self.window_name)
cv2.waitKey(5) # Brief wait
self.window_initialized = False
self.map_mouse_callback_set = False # Reset callback flag too
logging.debug(f"{log_prefix} Destroyed existing window.")
except cv2.error as e:
logging.warning(
f"{log_prefix} Error destroying window before recreate: {e}"
)
self.window_initialized = False
self.map_mouse_callback_set = False
# --- Window Creation and Callback Setup ---
if not self.window_initialized:
logging.debug(
f"{log_prefix} Ensuring window '{self.window_name}' exists (AUTOSIZE) and is positioned..."
)
cv2.namedWindow(self.window_name, cv2.WINDOW_AUTOSIZE)
try:
cv2.moveWindow(self.window_name, self.x_pos, self.y_pos)
self.window_initialized = True
logging.info(
f"{log_prefix} Window '{self.window_name}' (AUTOSIZE) created/moved to ({self.x_pos}, {self.y_pos})."
)
# --- >>> START OF NEW CODE <<< ---
# Attempt to set mouse callback immediately after creation/move
if not self.map_mouse_callback_set:
try:
cv2.setMouseCallback(
self.window_name, self._map_mouse_callback
)
self.map_mouse_callback_set = True
logging.info(
f"{log_prefix} Mouse callback set for '{self.window_name}'."
)
except cv2.error as cb_e:
logging.error(
f"{log_prefix} Failed to set mouse callback for '{self.window_name}' on creation: {cb_e}"
)
# --- >>> END OF NEW CODE <<< ---
except cv2.error as move_e:
logging.warning(
f"{log_prefix} Could not move '{self.window_name}' window after creation: {move_e}."
)
self.window_initialized = True # Assume window created
# --- >>> START OF NEW CODE <<< ---
# Still try to set callback even if move failed
if not self.map_mouse_callback_set:
try:
cv2.setMouseCallback(
self.window_name, self._map_mouse_callback
)
self.map_mouse_callback_set = True
logging.info(
f"{log_prefix} Mouse callback set for '{self.window_name}' (after move failed)."
)
except cv2.error as cb_e:
logging.error(
f"{log_prefix} Failed to set mouse callback for '{self.window_name}' after move failed: {cb_e}"
)
# --- >>> END OF NEW CODE <<< ---
# --- Display Image ---
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}'."
)
# --- >>> START OF NEW CODE <<< ---
# Ensure callback is set if window already existed but callback wasn't set
if self.window_initialized and not self.map_mouse_callback_set:
try:
cv2.setMouseCallback(self.window_name, self._map_mouse_callback)
self.map_mouse_callback_set = True
logging.info(
f"{log_prefix} Mouse callback set for existing window '{self.window_name}'."
)
except cv2.error as cb_e:
logging.error(
f"{log_prefix} Failed to set mouse callback for existing window '{self.window_name}': {cb_e}"
)
# --- >>> END OF NEW CODE <<< ---
cv2.waitKey(1) # Allow OpenCV to process events
except cv2.error as e:
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
self.map_mouse_callback_set = False # Reset flag
self._last_displayed_shape = (0, 0)
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 flags on unexpected errors
# self.window_initialized = False
# self.map_mouse_callback_set = False
# self._last_displayed_shape = (0, 0)
def _create_placeholder_image(self) -> Optional[np.ndarray]:
"""Creates a BGR NumPy placeholder image."""
log_prefix = f"{self._log_prefix} Placeholder"
if Image is None:
logging.error(
f"{log_prefix} Pillow not available, using minimal NumPy fallback."
)
return np.full((256, 256, 3), 60, dtype=np.uint8) # Minimal fallback
try:
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)
return cv2.cvtColor(placeholder_np, cv2.COLOR_RGB2BGR)
except Exception as ph_err:
logging.exception(f"{log_prefix} Failed to create placeholder image:")
return np.full((256, 256, 3), 60, dtype=np.uint8) # Minimal fallback
def _convert_pil_to_bgr(self, img_pil: ImageType) -> Optional[np.ndarray]:
"""Converts a PIL Image to NumPy BGR format."""
log_prefix = f"{self._log_prefix} ConvertPIL"
try:
img_np = np.array(img_pil)
if img_np.ndim == 2: # Grayscale
return cv2.cvtColor(img_np, cv2.COLOR_GRAY2BGR)
elif img_np.ndim == 3 and img_np.shape[2] == 3: # RGB
return cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
elif img_np.ndim == 3 and img_np.shape[2] == 4: # RGBA
return cv2.cvtColor(img_np, cv2.COLOR_RGBA2BGR)
else:
raise ValueError(
f"Unsupported NumPy shape after PIL conversion: {img_np.shape}"
)
except Exception as e:
logging.exception(f"{log_prefix} Error converting PIL image to BGR:")
return None
# --- >>> START OF NEW MOUSE CALLBACK METHOD <<< ---
def _map_mouse_callback(self, event, x, y, flags, param):
"""
OpenCV mouse callback for the Map window. Processes mouse movement,
performs rate limiting (using AppState), clamps coordinates to the
displayed map size, and puts raw (x, y) onto the application's
tkinter_queue with a specific command identifier.
"""
log_prefix_cb = f"{self._log_prefix} MouseCB"
# Process only mouse movement events
if event == cv2.EVENT_MOUSEMOVE:
# --- Rate Limiting (Using AppState specific timer) ---
current_time = time.time()
try:
last_update_time = self.app.state.last_map_mouse_update_time
if (current_time - last_update_time) < 0.05: # 50ms limit (20 Hz)
return # Rate limited
self.app.state.last_map_mouse_update_time = current_time
except AttributeError:
logging.warning(
f"{log_prefix_cb} App state or 'last_map_mouse_update_time' missing."
)
return # Don't proceed without rate limiting if state missing
# --- Clamp coordinates and put on Tkinter queue ---
try:
# Use the stored shape of the *last displayed* image
current_display_height, current_display_width = (
self._last_displayed_shape
)
if current_display_width <= 0 or current_display_height <= 0:
logging.warning(
f"{log_prefix_cb} Invalid stored map display dimensions for clamping."
)
return
x_clamped = max(0, min(x, current_display_width - 1))
y_clamped = max(0, min(y, current_display_height - 1))
# Put coordinates onto the TKINTER queue with a specific command
# This allows the main app thread to distinguish map mouse coords
# from SAR mouse coords.
command = "MAP_MOUSE_COORDS"
payload = (x_clamped, y_clamped)
logging.debug(
f"{log_prefix_cb} Putting command '{command}' payload {payload} onto tkinter_queue."
)
put_queue(
queue_obj=self.app.tkinter_queue,
item=(command, payload),
queue_name="tkinter", # Still the tkinter queue
app_instance=self.app,
)
except AttributeError as ae:
logging.warning(
f"{log_prefix_cb} App state attributes not available: {ae}"
)
except Exception as e:
logging.exception(f"{log_prefix_cb} Error in map mouse callback:")
# --- >>> END OF NEW MOUSE CALLBACK METHOD <<< ---
def destroy_window(self):
"""Explicitly destroys the managed OpenCV window."""
log_prefix = f"{self._log_prefix} Destroy"
logging.info(f"{log_prefix} Attempting to destroy window: '{self.window_name}'")
if self.window_initialized or hasattr(self, "window_name"):
try:
cv2.destroyWindow(self.window_name)
self.window_initialized = False
self.map_mouse_callback_set = False # Reset flag
self._last_displayed_shape = (0, 0)
logging.info(
f"{log_prefix} Window '{self.window_name}' destroyed successfully."
)
except cv2.error as e:
logging.warning(
f"{log_prefix} Ignoring error destroying window '{self.window_name}' (may already be closed): {e}"
)
except Exception as e:
logging.exception(
f"{log_prefix} Unexpected error destroying window '{self.window_name}': {e}"
)
else:
logging.debug(
f"{log_prefix} Window '{self.window_name}' was not initialized or name unknown."
)
# --- END OF FILE map_display.py ---