# --- START OF FILE ControlPanel.py --- # ControlPanel.py (Previously app.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. Main application module for the Control Panel application. Orchestrates UI, display, network reception, image processing pipeline, test mode management, map integration, image recording, and state management. Initializes all sub-modules and manages the main application lifecycle. """ # --- Standard library imports --- import threading import time import queue import os import logging import math import sys import socket from typing import Optional, Tuple, Any, Dict, TYPE_CHECKING, Union import datetime import tkinter.filedialog as fd # For save file dialog from pathlib import Path # For map saving # --- Third-party imports --- import tkinter as tk from tkinter import ttk from tkinter import colorchooser import numpy as np import cv2 import screeninfo # --- PIL Import and Type Definition --- try: from PIL import Image, ImageTk ImageType = Image.Image # type: ignore except ImportError: ImageType = Any # Fallback type hint Image = None ImageTk = None logging.critical( "[App Init] Pillow library not found. Map/Image functionality will fail." ) # --- Configuration Import --- import config # --- Logging Setup --- try: from logging_config import setup_logging setup_logging() except ImportError: print("ERROR: logging_config.py not found. Using basic logging.") logging.basicConfig( level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s", ) # --- Application Modules Import --- from ui import ControlPanel as UIPanel, StatusBar, create_main_window from display import DisplayManager from utils import ( put_queue, clear_queue, decimal_to_dms, dms_string_to_decimal, # Added dms_string_to_decimal generate_sar_kml, launch_google_earth, cleanup_old_kml_files, open_google_maps, ) from network import create_udp_socket, close_udp_socket from receiver import UdpReceiver from app_state import AppState from test_mode_manager import TestModeManager from image_pipeline import ImagePipeline from image_processing import load_image, normalize_image, apply_color_palette from image_recorder import ImageRecorder # --- Map related imports (Conditional) --- map_libs_found = True try: import mercantile import pyproj if Image is None and ImageType is not Any: raise ImportError("Pillow failed import") except ImportError as map_lib_err: logging.warning( f"[App Init] Core map lib import failed ({map_lib_err}). Map disabled." ) map_libs_found = False BaseMapService = None MapTileManager = None MapDisplayWindow = None MapIntegrationManager = None MapCalculationError = Exception if map_libs_found: try: from map_services import get_map_service, BaseMapService from map_manager import MapTileManager from map_utils import MapCalculationError # Only import needed exception? from map_display import MapDisplayWindow from map_integration import MapIntegrationManager MAP_MODULES_LOADED = True except ImportError as map_import_err: logging.warning( f"[App Init] Specific map module import failed ({map_import_err}). Map disabled." ) MAP_MODULES_LOADED = False BaseMapService = None MapTileManager = None MapDisplayWindow = None MapIntegrationManager = None MapCalculationError = Exception else: MAP_MODULES_LOADED = False # --- Main Application Class --- class ControlPanelApp: """ Main application class. Manages UI, display, processing, network, state, and orchestrates various managers. """ # --- Status Update Method --- def set_status(self, message: str): """Safely updates the main status message prefix in the status bar via after_idle.""" log_prefix = "[App Set Status]" if not hasattr(self, "state") or self.state.shutting_down: return new_status_prefix = f"Status: {message}" # logging.info(...) # Reduce verbosity def _update(): if not hasattr(self, "state") or self.state.shutting_down: return try: statusbar = getattr(self, "statusbar", None) if ( statusbar and isinstance(statusbar, tk.Widget) and statusbar.winfo_exists() ): current_text: str = statusbar.cget("text") parts = current_text.split("|") suffix = "" if len(parts) > 1: suffix_parts = [p.strip() for p in parts[1:] if p.strip()] suffix = ( " | " + " | ".join(suffix_parts) if suffix_parts else "" ) final_text = f"{new_status_prefix}{suffix}" statusbar.set_status_text(final_text) except Exception as e: logging.exception(f"{log_prefix} Error updating status bar text:") try: if hasattr(self, "root") and self.root and self.root.winfo_exists(): self.root.after_idle(_update) except Exception as e: logging.warning(f"{log_prefix} Error scheduling status update: {e}") # --- LUT Generation Methods --- def update_brightness_contrast_lut(self): """Recalculates the SAR B/C LUT based on AppState and stores it back.""" log_prefix = "[App Update SAR LUT]" if not hasattr(self, "state"): return try: contrast = max(0.01, self.state.sar_contrast) brightness = self.state.sar_brightness lut_values = np.arange(256, dtype=np.float32) adjusted = (lut_values * contrast) + brightness self.state.brightness_contrast_lut = np.clip( np.round(adjusted), 0, 255 ).astype(np.uint8) except Exception as e: logging.exception(f"{log_prefix} Error calculating SAR B/C LUT:") self.state.brightness_contrast_lut = np.arange(256, dtype=np.uint8) def update_mfd_lut(self): """Recalculates the MFD LUT based on AppState parameters and stores it back.""" log_prefix = "[MFD LUT Update]" if not hasattr(self, "state"): return try: params = self.state.mfd_params raw_map_factor = params["raw_map_intensity"] / 255.0 pixel_map = params["pixel_to_category"] categories = params["categories"] new_lut = np.zeros((256, 3), dtype=np.uint8) for idx in range(256): cat_name = pixel_map.get(idx) if cat_name: cat_data = categories[cat_name] bgr = cat_data["color"] intensity = cat_data["intensity"] / 255.0 new_lut[idx, 0] = np.clip( int(round(float(bgr[0]) * intensity)), 0, 255 ) new_lut[idx, 1] = np.clip( int(round(float(bgr[1]) * intensity)), 0, 255 ) new_lut[idx, 2] = np.clip( int(round(float(bgr[2]) * intensity)), 0, 255 ) elif 32 <= idx <= 255: raw_intensity = (float(idx) - 32.0) * (255.0 / 223.0) final_gray = int( round(np.clip(raw_intensity * raw_map_factor, 0, 255)) ) new_lut[idx, :] = final_gray self.state.mfd_lut = new_lut except Exception as e: logging.critical(f"{log_prefix} CRITICAL MFD LUT error:", exc_info=True) self._apply_fallback_mfd_lut() def _apply_fallback_mfd_lut(self): """Applies a simple grayscale ramp as a fallback MFD LUT.""" if hasattr(self, "state"): try: self.state.mfd_lut = cv2.cvtColor( np.arange(256, dtype=np.uint8)[:, np.newaxis], cv2.COLOR_GRAY2BGR )[:, 0, :] except Exception as fb_e: logging.critical(f"[MFD LUT Update] Fallback LUT failed: {fb_e}") self.state.mfd_lut = np.zeros((256, 3), dtype=np.uint8) # --- Existing UI Callbacks (Mostly Unchanged Logic, adapted to new UI/State) --- def update_image_mode(self): log_prefix = "[App Mode Switch]" if ( not hasattr(self, "state") or not hasattr(self, "test_mode_manager") or self.state.shutting_down ): return try: cp = getattr(self, "control_panel", None) var = getattr(cp, "test_image_var", None) if cp else None is_test_req = ( var.get() == 1 if (var and isinstance(var, tk.Variable)) else self.state.test_mode_active ) if is_test_req != self.state.test_mode_active: self.state.test_mode_active = is_test_req if is_test_req: if self.test_mode_manager.activate(): self.activate_test_mode_ui_actions() else: self._revert_test_mode_ui() else: self.test_mode_manager.deactivate() self.deactivate_test_mode_ui_actions() self.state.reset_statistics() self.update_status() except Exception as e: logging.exception(f"{log_prefix} Error:") def update_sar_size(self, event=None): log_prefix = "[App CB SAR Size]" if not hasattr(self, "state") or self.state.shutting_down: return try: cp = getattr(self, "control_panel", None) combo = getattr(cp, "sar_size_combo", None) if cp else None if not combo: return size_str = combo.get() factor = 1 if size_str and ":" in size_str: try: factor = int(size_str.split(":")[1]) factor = max(1, factor) except: factor = 1 w = max(1, config.SAR_WIDTH // factor) h = max(1, config.SAR_HEIGHT // factor) if w != self.state.sar_display_width or h != self.state.sar_display_height: self.state.update_sar_display_size(w, h) self._trigger_sar_update() except Exception as e: logging.exception(f"{log_prefix} Error:") def update_contrast(self, value_str: str): if self.state.shutting_down: return try: contrast = float(value_str) self.state.update_sar_parameters(contrast=contrast) self.update_brightness_contrast_lut() self._trigger_sar_update() except Exception as e: logging.exception(f"[App CB SAR Contrast] Error: {e}") def update_brightness(self, value_str: str): if self.state.shutting_down: return try: brightness = int(float(value_str)) self.state.update_sar_parameters(brightness=brightness) self.update_brightness_contrast_lut() self._trigger_sar_update() except Exception as e: logging.exception(f"[App CB SAR Brightness] Error: {e}") def update_sar_palette(self, event=None): if self.state.shutting_down: return try: cp = getattr(self, "control_panel", None) combo = getattr(cp, "palette_combo", None) if cp else None if not combo: return palette = combo.get() if palette in config.COLOR_PALETTES: self.state.update_sar_parameters(palette=palette) self._trigger_sar_update() else: combo.set(self.state.sar_palette) except Exception as e: logging.exception(f"[App CB SAR Palette] Error: {e}") def update_mfd_category_intensity(self, category_name: str, intensity_value: int): if self.state.shutting_down: return try: if category_name in self.state.mfd_params["categories"]: self.state.mfd_params["categories"][category_name]["intensity"] = ( np.clip(intensity_value, 0, 255) ) self.update_mfd_lut() self._trigger_mfd_update() except Exception as e: logging.exception( f"[App CB MFD Intensity] Error for '{category_name}': {e}" ) def choose_mfd_category_color(self, category_name: str): if self.state.shutting_down: return if category_name not in self.state.mfd_params["categories"]: return try: initial_bgr = self.state.mfd_params["categories"][category_name]["color"] initial_hex = ( f"#{initial_bgr[2]:02x}{initial_bgr[1]:02x}{initial_bgr[0]:02x}" ) color_code = colorchooser.askcolor( title=f"Select Color for {category_name}", initialcolor=initial_hex ) if color_code and color_code[0]: rgb = color_code[0] new_bgr = tuple( np.clip(int(c), 0, 255) for c in (rgb[2], rgb[1], rgb[0]) ) self.state.mfd_params["categories"][category_name]["color"] = new_bgr self.update_mfd_lut() cp = getattr(self, "control_panel", None) if ( self.root and self.root.winfo_exists() and cp and hasattr(cp, "update_mfd_color_display") ): self.root.after_idle( cp.update_mfd_color_display, category_name, new_bgr ) self._trigger_mfd_update() except Exception as e: logging.exception(f"[App CB MFD Color] Error for '{category_name}': {e}") def update_mfd_raw_map_intensity(self, intensity_value: int): if self.state.shutting_down: return try: self.state.mfd_params["raw_map_intensity"] = np.clip( intensity_value, 0, 255 ) self.update_mfd_lut() self._trigger_mfd_update() except Exception as e: logging.exception(f"[App CB MFD RawMap] Error: {e}") def update_map_size(self, event=None): if not hasattr(self, "state") or self.state.shutting_down: return try: cp = getattr(self, "control_panel", None) combo = getattr(cp, "map_size_combo", None) if cp else None if not combo: return self.state.update_map_scale_factor(combo.get()) self.trigger_map_redraw() # Redraw immediately with new scale factor except Exception as e: logging.exception(f"[App CB Map Size] Error: {e}") def toggle_sar_overlay(self): if not hasattr(self, "state") or self.state.shutting_down: return try: cp = getattr(self, "control_panel", None) var = getattr(cp, "sar_overlay_var", None) if cp else None if not var or not isinstance(var, tk.Variable): return self.state.update_map_overlay_params(enabled=var.get()) self.trigger_map_redraw() # Redraw immediately except Exception as e: logging.exception(f"[App CB Overlay Toggle] Error: {e}") def update_sar_overlay_alpha( self, value_str: str ): # Only for immediate feedback if needed if self.state.shutting_down: return try: float(value_str) # Just validate except ValueError: logging.warning(f"[App CB SAR Alpha] Invalid alpha value: {value_str}") def on_alpha_slider_release(self, event=None): """Handles ButtonRelease on alpha slider: updates state and triggers map recomposition.""" log_prefix = "[App CB SAR Alpha Release]" if not hasattr(self, "state") or self.state.shutting_down: return try: cp = getattr(self, "control_panel", None) var = getattr(cp, "sar_overlay_alpha_var", None) if cp else None if not var or not isinstance(var, tk.Variable): return final_alpha = var.get() logging.info(f"{log_prefix} Alpha value set to: {final_alpha:.3f}") self.state.update_map_overlay_params(alpha=final_alpha) # Trigger a recomposition, not a full update self.trigger_map_redraw(full_update=False) except Exception as e: logging.exception(f"{log_prefix} Error: {e}") def toggle_sar_recording(self): if not hasattr(self, "state") or self.state.shutting_down: return try: cp = getattr(self, "control_panel", None) var = getattr(cp, "record_sar_var", None) if cp else None if not var or not isinstance(var, tk.Variable): return self.state.update_sar_recording_enabled(enabled=var.get()) except Exception as e: logging.exception(f"[App CB Record Toggle] Error: {e}") # --- NEW UI Callbacks --- def apply_sar_overlay_shift(self): """Reads shift values from UI, validates, updates AppState, triggers full map update.""" log_prefix = "[App CB Apply Shift]" if not hasattr(self, "state") or self.state.shutting_down: return cp = getattr(self, "control_panel", None) if not cp: return lat_str = cp.sar_lat_shift_var.get() lon_str = cp.sar_lon_shift_var.get() try: lat_shift = float(lat_str) lon_shift = float(lon_str) # Add more validation if needed (e.g., range) self.state.update_sar_overlay_shift(lat_shift, lon_shift) self.trigger_map_redraw(full_update=True) # Full update needed for shift self.set_status("Applied SAR overlay shift.") except ValueError as ve: logging.error(f"{log_prefix} Invalid shift value: {ve}") self.set_status(f"Error: Invalid shift - {ve}") except Exception as e: logging.exception(f"{log_prefix} Error: {e}") self.set_status("Error applying shift.") def save_current_map_view(self): """Opens save dialog and calls MapIntegrationManager to save the current map view.""" log_prefix = "[App CB Save Map]" if not hasattr(self, "state") or self.state.shutting_down: return mgr = getattr(self, "map_integration_manager", None) if not mgr: self.set_status("Error: Map components not ready.") return if self.state.last_composed_map_pil is None: self.set_status("Error: No map view to save.") return try: ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") init_fn = f"map_view_{ts}.png" file_path = fd.asksaveasfilename( title="Save Map View As", initialfile=init_fn, defaultextension=".png", filetypes=[("PNG files", "*.png"), ("All files", "*.*")], ) if file_path: save_dir = os.path.dirname(file_path) save_fn = Path(file_path).stem mgr.save_map_view_to_file( directory=save_dir, filename=save_fn ) # Status updated by manager else: self.set_status("Save map view cancelled.") except Exception as e: logging.exception(f"{log_prefix} Error: {e}") self.set_status("Error saving map view.") def go_to_google_maps(self, coord_source: str): """Opens Google Maps centered on coordinates from the specified UI source.""" log_prefix = "[App CB Go Gmaps]" if not hasattr(self, "state") or self.state.shutting_down: return cp = getattr(self, "control_panel", None) if not cp: return coords_text: Optional[str] = None source_desc = coord_source # Use ID as description default lat_deg: Optional[float] = None lon_deg: Optional[float] = None try: if coord_source == "sar_center": coords_text = cp.sar_center_coords_var.get() source_desc = "SAR Center" elif coord_source == "sar_mouse": coords_text = cp.mouse_coords_var.get() source_desc = "SAR Mouse" elif coord_source == "map_mouse": coords_text = cp.map_mouse_coords_var.get() source_desc = "Map Mouse" else: return # Unknown source if ( not coords_text or "N/A" in coords_text or "Error" in coords_text or "Invalid" in coords_text ): self.set_status(f"Error: No valid coordinates for {source_desc}.") return # Parse the combined string "Lat=..., Lon=..." using the DMS parser lon_sep = ", Lon=" if lon_sep in coords_text: parts = coords_text.split(lon_sep, 1) lat_dms_str = ( parts[0].split("=", 1)[1].strip() if "Lat=" in parts[0] else None ) lon_dms_str = parts[1].strip() if lat_dms_str and lon_dms_str: lat_deg = dms_string_to_decimal( lat_dms_str, is_latitude=True ) # Use new util lon_deg = dms_string_to_decimal( lon_dms_str, is_latitude=False ) # Use new util else: raise ValueError("Could not split Lat/Lon parts") else: raise ValueError("Separator ', Lon=' not found") if lat_deg is None or lon_deg is None: self.set_status(f"Error: Cannot parse coordinates for {source_desc}.") return open_google_maps(lat_deg, lon_deg) # Call util function except Exception as e: logging.exception(f"{log_prefix} Error for {source_desc}: {e}") self.set_status(f"Error opening map for {source_desc}.") # --- Initialization Helper Methods --- def _get_screen_dimensions(self) -> Tuple[int, int]: """Gets primary screen dimensions using screeninfo.""" log_prefix = "[App Init]" try: monitors = screeninfo.get_monitors() if not monitors: raise screeninfo.ScreenInfoError("No monitors detected.") screen = monitors[0] # Assume primary is the first logging.debug(f"{log_prefix} Detected Screen: {screen.width}x{screen.height}") return screen.width, screen.height except Exception as e: logging.warning(f"{log_prefix} Screen info error: {e}. Using default 1920x1080.") return 1920, 1080 def _calculate_initial_sar_size(self, desired_factor_if_map: int = 4) -> Tuple[int, int]: """Calculates initial SAR display size based on config and map state.""" log_prefix = "[App Init]" initial_w = self.state.sar_display_width; initial_h = self.state.sar_display_height map_enabled_and_loaded = config.ENABLE_MAP_OVERLAY and MAP_MODULES_LOADED if map_enabled_and_loaded: forced_factor = max(1, desired_factor_if_map) initial_w = config.SAR_WIDTH // forced_factor initial_h = config.SAR_HEIGHT // forced_factor # Update state immediately if map forces a different initial size if initial_w != self.state.sar_display_width or initial_h != self.state.sar_display_height: self.state.update_sar_display_size(initial_w, initial_h) logging.info(f"{log_prefix} Map active, using SAR size 1:{forced_factor} ({initial_w}x{initial_h}).") else: logging.debug(f"{log_prefix} Using initial SAR size from state: {initial_w}x{initial_h}.") return initial_w, initial_h def _calculate_tkinter_position(self, screen_h: int) -> Tuple[int, int]: """Calculates the initial X, Y position for the Tkinter control panel window.""" x = 10; y = config.INITIAL_MFD_HEIGHT + 40 # Position below MFD placeholder # Adjust if it goes off screen if y + config.TKINTER_MIN_HEIGHT > screen_h: y = max(10, screen_h - config.TKINTER_MIN_HEIGHT - 10) return x, y def _calculate_mfd_position(self) -> Tuple[int, int]: """Calculates the initial X, Y position for the MFD display window.""" # Align with Tkinter window's left edge, near the top x = self.tkinter_x # Assumes tkinter_x is calculated first y = 10 return x, y def _calculate_sar_position(self, screen_w: int, initial_sar_w: int) -> Tuple[int, int]: """Calculates the initial X, Y position for the SAR display window.""" # Position to the right of Tkinter window + padding x = self.tkinter_x + config.TKINTER_MIN_WIDTH + 20 # Assumes tkinter_x calculated y = 10 # Align with top # Adjust if it goes off screen if x + initial_sar_w > screen_w: x = max(10, screen_w - initial_sar_w - 10) return x, y def _calculate_map_position(self, screen_w: int, current_sar_w: int, max_map_width: int) -> Tuple[int, int]: """Calculates the initial X, Y position for the Map display window.""" # Position to the right of SAR window + padding x = self.sar_x + current_sar_w + 20 # Assumes sar_x calculated y = 10 # Align with top # Adjust if it goes off screen if x + max_map_width > screen_w: x = max(10, screen_w - max_map_width - 10) return x, y def _setup_network_receiver(self): """Creates and starts the UDP socket and receiver thread.""" log_prefix = "[App Init Network]" logging.info(f"{log_prefix} Attempting network receiver on {self.local_ip}:{self.local_port}") self.udp_socket = create_udp_socket(self.local_ip, self.local_port) if self.udp_socket: try: recorder_instance = getattr(self, "image_recorder", None) self.udp_receiver = UdpReceiver( app=self, udp_socket=self.udp_socket, set_new_sar_image_callback=self.handle_new_sar_data, set_new_mfd_indices_image_callback=self.handle_new_mfd_data, image_recorder=recorder_instance, ) self.udp_thread = threading.Thread(target=self.udp_receiver.receive_udp_data, name="UDPReceiverThread", daemon=True) self.udp_thread.start() self.set_status(f"Listening UDP {self.local_ip}:{self.local_port}") except Exception as receiver_init_e: logging.critical(f"{log_prefix} Failed to initialize UdpReceiver: {receiver_init_e}", exc_info=True) self.set_status("Error: Receiver Init Failed"); close_udp_socket(self.udp_socket); self.udp_socket = None else: self.set_status("Error: UDP Socket Failed") def _start_initial_image_loader(self): """Starts a background thread to load local/test images if needed.""" log_prefix = "[App Init]" should_load = config.USE_LOCAL_IMAGES or config.ENABLE_TEST_MODE if should_load: logging.debug(f"{log_prefix} Starting initial image loading thread...") image_loading_thread = threading.Thread(target=self.load_initial_images, name="ImageLoaderThread", daemon=True) image_loading_thread.start() else: # If not loading, schedule initial display setup immediately if self.root and self.root.winfo_exists(): self.root.after_idle(self._set_initial_display_from_loaded_data) def _update_initial_ui_display(self): """Sets the initial text for UI info Entry widgets based on default AppState.""" # This version is already updated in the previous response part log_prefix = "[App Init]" logging.debug(f"{log_prefix} Setting initial UI info display...") control_panel_ref = getattr(self, "control_panel", None) if not control_panel_ref: return try: control_panel_ref.set_sar_center_coords("N/A", "N/A") control_panel_ref.set_sar_orientation("N/A") control_panel_ref.set_sar_size_km("N/A") control_panel_ref.set_mouse_coordinates("N/A", "N/A") control_panel_ref.set_map_mouse_coordinates("N/A", "N/A") initial_stats = self.state.get_statistics() drop_txt = f"Drop(Q): S={initial_stats['dropped_sar_q']},M={initial_stats['dropped_mfd_q']},Tk={initial_stats['dropped_tk_q']},Mo={initial_stats['dropped_mouse_q']}" incmpl_txt = f"Incmpl(RX): S={initial_stats['incomplete_sar_rx']},M={initial_stats['incomplete_mfd_rx']}" control_panel_ref.set_statistics_display(drop_txt, incmpl_txt) logging.debug(f"{log_prefix} Initial UI info display set.") except tk.TclError as e: logging.warning(f"{log_prefix} Error setting initial UI display (TclError): {e}") except Exception as e: logging.exception(f"{log_prefix} Unexpected error setting initial UI display:") def load_initial_images(self): """(Runs in background thread) Loads initial local/test images into AppState.""" log_prefix = "[App Image Loader]" if self.state.shutting_down: return if self.root and self.root.winfo_exists(): self.root.after_idle(self.set_status, "Loading initial images...") try: if config.ENABLE_TEST_MODE or self.state.test_mode_active: if hasattr(self, "test_mode_manager") and self.test_mode_manager: self.test_mode_manager._ensure_test_images() if config.USE_LOCAL_IMAGES: self._load_local_mfd_image(); self._load_local_sar_image() if self.root and self.root.winfo_exists(): self.root.after_idle(self._set_initial_display_from_loaded_data) except Exception as e: logging.exception(f"{log_prefix} Error during initial image loading:") if self.root and self.root.winfo_exists(): self.root.after_idle(self.set_status, "Error Loading Images") finally: logging.info(f"{log_prefix} Initial image loading thread finished.") def _load_local_mfd_image(self): """Loads local MFD image data (indices) into AppState.""" log_prefix = "[App Image Loader]"; default_indices = np.random.randint(0, 256, (config.MFD_HEIGHT, config.MFD_WIDTH), dtype=np.uint8) try: self.state.local_mfd_image_data_indices = default_indices # Placeholder: Actual loading NYI except Exception as e: logging.exception(f"{log_prefix} Error loading local MFD image:"); self.state.local_mfd_image_data_indices = default_indices def _load_local_sar_image(self): """Loads local SAR image data (raw) into AppState.""" log_prefix = "[App Image Loader]"; default_raw_data = np.zeros((config.SAR_HEIGHT, config.SAR_WIDTH), dtype=config.SAR_DATA_TYPE) try: loaded_raw_data = load_image(config.SAR_IMAGE_PATH, config.SAR_DATA_TYPE); self.state.local_sar_image_data_raw = loaded_raw_data if loaded_raw_data is not None and loaded_raw_data.size > 0 else default_raw_data except Exception as e: logging.exception(f"{log_prefix} Error loading local SAR raw data:"); self.state.local_sar_image_data_raw = default_raw_data def _set_initial_display_from_loaded_data(self): """(Runs in main thread) Sets initial display based on loaded data/mode.""" log_prefix = "[App Init Display]" if self.state.shutting_down: return is_test = self.state.test_mode_active; is_local = config.USE_LOCAL_IMAGES if not is_test and is_local: if self.state.local_mfd_image_data_indices is not None: if hasattr(self, "image_pipeline") and self.image_pipeline: self.state.current_mfd_indices = self.state.local_mfd_image_data_indices.copy(); self.image_pipeline.process_mfd_for_display() if self.state.local_sar_image_data_raw is not None: self.set_initial_sar_image(self.state.local_sar_image_data_raw) else: self.set_initial_sar_image(None) # Show black elif is_test: pass # Handled by TestModeManager else: self._show_network_placeholders() # Set final status (check if map still loading) map_loading = False; mgr = getattr(self, "map_integration_manager", None) if mgr: thread_attr = getattr(mgr, "_map_initial_display_thread", None) if mgr and thread_attr and isinstance(thread_attr, threading.Thread): map_loading = thread_attr.is_alive() if not map_loading: status = ""; if is_test: status="Ready (Test Mode)" elif is_local: status="Ready (Local Mode)" else: status=f"Listening UDP {self.local_ip}:{self.local_port}" if self.udp_socket else "Error: No Socket" self.set_status(status) def set_initial_sar_image(self, raw_image_data: Optional[np.ndarray]): """Processes raw SAR data (or None), updates state, resets UI, triggers display.""" log_prefix = "[App Init SAR Image]"; normalized = None if self.state.shutting_down: return if raw_image_data is not None and raw_image_data.size > 0: try: normalized = normalize_image(raw_image_data, target_type=np.uint8) except Exception as e: logging.exception(f"{log_prefix} Error normalizing initial SAR:") if normalized is None: # Use black image if normalization failed or no data if self.state.current_sar_normalized is None: self.state.current_sar_normalized = np.zeros((config.SAR_HEIGHT,config.SAR_WIDTH), dtype=np.uint8) self.state.current_sar_normalized.fill(0) else: self.state.current_sar_normalized = normalized self.state.current_sar_geo_info["valid"] = False; self._reset_ui_geo_info() if hasattr(self,"image_pipeline") and self.image_pipeline: self.image_pipeline.process_sar_for_display() # --- Mode Switching UI Actions --- def activate_test_mode_ui_actions(self): log_prefix = "[App Test Activate]"; self.set_status("Activating Test Mode..."); self._reset_ui_geo_info() clear_queue(self.mfd_queue); clear_queue(self.sar_queue); self.set_status("Ready (Test Mode)") def deactivate_test_mode_ui_actions(self): log_prefix = "[App Test Deactivate]"; self.set_status("Activating Normal Mode..."); clear_queue(self.mfd_queue); clear_queue(self.sar_queue); self._reset_ui_geo_info() if config.USE_LOCAL_IMAGES: # Local Mode Restore if self.state.local_mfd_image_data_indices is not None: self.state.current_mfd_indices = self.state.local_mfd_image_data_indices.copy(); self.image_pipeline.process_mfd_for_display() if self.state.local_sar_image_data_raw is not None: self.set_initial_sar_image(self.state.local_sar_image_data_raw) else: self.set_initial_sar_image(None) self.set_status("Ready (Local Mode)") else: # Network Mode Restore self._show_network_placeholders() status = f"Listening UDP {self.local_ip}:{self.local_port}" if self.udp_socket else "Error: No UDP Socket" self.set_status(status) def _reset_ui_geo_info(self): """Schedules UI reset for geo-related Entry widgets on the main thread.""" log_prefix = "[App UI Reset]"; cp = getattr(self, "control_panel", None) if self.root and self.root.winfo_exists() and cp: self.root.after_idle(lambda: cp.set_sar_orientation("N/A")) self.root.after_idle(lambda: cp.set_mouse_coordinates("N/A", "N/A")) self.root.after_idle(lambda: cp.set_map_mouse_coordinates("N/A", "N/A")) self.root.after_idle(lambda: cp.set_sar_center_coords("N/A", "N/A")) self.root.after_idle(lambda: cp.set_sar_size_km("N/A")) def _revert_test_mode_ui(self): """Tries to uncheck the test mode checkbox in the UI and resets the state flag.""" log_prefix = "[App Mode Switch]"; logging.warning(f"{log_prefix} Reverting Test Mode UI/state...") cp = getattr(self, "control_panel", None); var = getattr(cp, "test_image_var", None) if cp else None if self.root and self.root.winfo_exists() and var: try: self.root.after_idle(var.set, 0) except Exception as e: logging.warning(f"{log_prefix} Failed to schedule uncheck: {e}") if hasattr(self, "state"): self.state.test_mode_active = False def _show_network_placeholders(self): """Queues placeholder images for MFD and SAR displays.""" log_prefix = "[App Placeholders]" try: ph_mfd = np.full((config.INITIAL_MFD_HEIGHT, config.INITIAL_MFD_WIDTH, 3), 30, dtype=np.uint8) ph_sar = np.full((self.state.sar_display_height, self.state.sar_display_width, 3), 60, dtype=np.uint8) put_queue(self.mfd_queue, ph_mfd, "mfd", self); put_queue(self.sar_queue, ph_sar, "sar", self) except Exception as e: logging.exception(f"{log_prefix} Error creating/queueing placeholders:") # --- Trigger map redraw --- def trigger_map_redraw(self, full_update: bool = False): """ Requests a map redraw. Uses cached data for simple redraws (alpha/toggle) or triggers a full update if needed or requested. """ log_prefix = "[App Trigger Map Redraw]" if self.state.shutting_down: return mgr = getattr(self, "map_integration_manager", None) if not (config.ENABLE_MAP_OVERLAY and mgr): logging.debug(f"{log_prefix} Map disabled or manager not available.") return if full_update: # Requested full update (e.g., after shift change) logging.debug(f"{log_prefix} Triggering full map update (explicit request)...") self._trigger_map_update_from_sar() else: # Simple redraw requested (e.g., alpha/toggle change) # Check if we have the necessary cached data for recomposition can_recompose = ( self.state.last_processed_sar_for_overlay is not None and self.state.last_sar_warp_matrix is not None and self.state.last_map_image_pil is not None # Need base map too # Corner pixels are needed only if drawing the box as fallback in recompose ) if can_recompose: # Data exists, queue the simple redraw command logging.debug(f"{log_prefix} Queueing simple REDRAW_MAP command (cache valid)...") put_queue(self.tkinter_queue, ("REDRAW_MAP", None), "tkinter", self) else: # Data for recomposition is missing, force a full update instead logging.warning(f"{log_prefix} Recomposition cache invalid/missing. Triggering full map update instead of simple redraw...") self._trigger_map_update_from_sar() # --- Initialization --- def __init__(self, root: tk.Tk): """Initializes the main application components and state.""" log_prefix = "[App Init]" logging.debug(f"{log_prefix} Starting initialization...") self.root = root self.root.title("Control Panel") try: # Set Icon script_dir = ( os.path.dirname(__file__) if "__file__" in locals() else os.getcwd() ) icon_path = os.path.join(script_dir, "ControlPanel.ico") if os.path.exists(icon_path): self.root.iconbitmap(default=icon_path) except Exception as icon_e: logging.warning(f"{log_prefix} Icon error: {icon_e}") self.root.minsize(config.TKINTER_MIN_WIDTH, config.TKINTER_MIN_HEIGHT) self.state = AppState() self.sar_queue = queue.Queue(config.DEFAULT_SAR_QUEUE) self.mouse_queue = queue.Queue(config.DEFAULT_MOUSE_QUEUE) self.tkinter_queue = queue.Queue(config.DEFAULT_TKINTER_QUEUE) self.mfd_queue = queue.Queue(config.DEFAULT_MFD_QUEUE) # Window Placement screen_w, screen_h = self._get_screen_dimensions() initial_sar_w, initial_sar_h = self._calculate_initial_sar_size() self.tkinter_x, self.tkinter_y = self._calculate_tkinter_position(screen_h) self.mfd_x, self.mfd_y = self._calculate_mfd_position() self.sar_x, self.sar_y = self._calculate_sar_position(screen_w, initial_sar_w) map_max_w = ( config.MAX_MAP_DISPLAY_WIDTH if not MapDisplayWindow else getattr( MapDisplayWindow, "MAX_DISPLAY_WIDTH", config.MAX_MAP_DISPLAY_WIDTH ) ) map_x, map_y = self._calculate_map_position(screen_w, initial_sar_w, map_max_w) self.root.geometry(f"+{self.tkinter_x}+{self.tkinter_y}") # Initialize Sub-systems self.statusbar = StatusBar(self.root) self.control_panel = UIPanel(self.root, self) self.update_brightness_contrast_lut() self.update_mfd_lut() self.display_manager = DisplayManager( self, self.sar_queue, self.mouse_queue, self.sar_x, self.sar_y, self.mfd_x, self.mfd_y, self.state.sar_display_width, self.state.sar_display_height, ) try: self.display_manager.initialize_display_windows() except Exception as e: self.set_status("Error: Display Init Failed") logging.critical(f"{log_prefix} Display init failed: {e}", exc_info=True) self.image_pipeline = ImagePipeline( self.state, self.sar_queue, self.mfd_queue, self ) self.test_mode_manager = TestModeManager( self.state, self.root, self.sar_queue, self.mfd_queue, self ) self.map_integration_manager = None if config.ENABLE_MAP_OVERLAY: # Map Manager Init if MAP_MODULES_LOADED and MapIntegrationManager: try: self.map_integration_manager = MapIntegrationManager( self.state, self.tkinter_queue, self, map_x, map_y ) except Exception as map_e: logging.exception( f"{log_prefix} MapIntegrationManager init failed:" ) self.map_integration_manager = None self.set_status("Error: Map Init Failed") else: self.set_status("Error: Map Modules Missing") self.image_recorder = None # Recorder Init if ImageRecorder: try: self.image_recorder = ImageRecorder(self.state) except Exception as rec_e: logging.exception(f"{log_prefix} ImageRecorder init failed:") self.image_recorder = None self._update_initial_ui_display() # Network Setup self.local_ip = config.DEFAULT_SER_IP self.local_port = config.DEFAULT_SER_PORT self.udp_socket = None self.udp_receiver = None self.udp_thread = None if not config.USE_LOCAL_IMAGES: self._setup_network_receiver() else: self._start_initial_image_loader() # Load local images if network disabled # Start loops/timers self.process_sar_queue() self.process_mfd_queue() self.process_mouse_queue() self.process_tkinter_queue() self.schedule_periodic_updates() self.update_image_mode() logging.info(f"{log_prefix} Application initialization complete.") # --- Initialization Helper Methods --- # (_get_screen_dimensions, _calculate_initial_sar_size, _calculate_tkinter_position) # (_calculate_mfd_position, _calculate_sar_position, _calculate_map_position) # (_setup_network_receiver, _start_initial_image_loader, _update_initial_ui_display) # (_load_local_mfd_image, _load_local_sar_image, _set_initial_display_from_loaded_data) # (set_initial_sar_image, activate_test_mode_ui_actions, deactivate_test_mode_ui_actions) # (_reset_ui_geo_info, _revert_test_mode_ui, _show_network_placeholders) # -> Queste funzioni rimangono come nelle risposte precedenti. # --- Network Data Handlers --- def handle_new_sar_data( self, normalized_image_uint8: np.ndarray, geo_info_radians: Dict[str, Any] ): if self.state.shutting_down: return self.state.set_sar_data(normalized_image_uint8, geo_info_radians) if self.root and self.root.winfo_exists(): self.root.after_idle(self._process_sar_update_on_main_thread) def handle_new_mfd_data(self, image_indices: np.ndarray): if self.state.shutting_down: return self.state.set_mfd_indices(image_indices) if self.root and self.root.winfo_exists(): self.root.after_idle(self._process_mfd_update_on_main_thread) # --- Main Thread Processing --- def _process_sar_update_on_main_thread(self): """Processes SAR updates: UI labels, image pipeline, map update, KML, FPS.""" if self.state.shutting_down: return self._update_sar_ui_labels() if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_sar_for_display() self._trigger_map_update_from_sar() # Potentially triggers full map update geo_info = self.state.current_sar_geo_info if geo_info and geo_info.get("valid") and config.ENABLE_KML_GENERATION: self._handle_kml_generation(geo_info) self._update_fps_stats("sar") def _handle_kml_generation(self, geo_info): """Handles KML generation, cleanup, and optional launch.""" # (Implementation unchanged) try: kml_dir = config.KML_OUTPUT_DIRECTORY os.makedirs(kml_dir, exist_ok=True) ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") fn = f"sar_footprint_{ts}.kml" fp = os.path.join(kml_dir, fn) success = generate_sar_kml(geo_info, fp) if success: cleanup_old_kml_files(config.KML_OUTPUT_DIRECTORY, config.MAX_KML_FILES) if config.AUTO_LAUNCH_GOOGLE_EARTH: launch_google_earth(fp) except Exception as e: logging.exception("[App KML] Error: {e}") def _process_mfd_update_on_main_thread(self): """Processes MFD updates: image pipeline, FPS.""" if self.state.shutting_down: return if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_mfd_for_display() self._update_fps_stats("mfd") # --- UI Display Update Helpers --- def _update_sar_ui_labels(self): """Updates SAR related UI Entry widgets from AppState.""" # (Implementation updated in previous step) cp = getattr(self, "control_panel", None) if not cp or not cp.winfo_exists(): return geo = self.state.current_sar_geo_info lat_s, lon_s, orient_s, size_s = "N/A", "N/A", "N/A", "N/A" is_valid = geo and geo.get("valid") if is_valid: try: lat_d, lon_d = math.degrees(geo["lat"]), math.degrees(geo["lon"]) orient_d = math.degrees(geo.get("orientation", 0.0)) lat_s, lon_s = decimal_to_dms(lat_d, True), decimal_to_dms(lon_d, False) orient_s = f"{orient_d:.2f}°" scale_x, w = geo.get("scale_x", 0.0), geo.get("width_px", 0) scale_y, h = geo.get("scale_y", 0.0), geo.get("height_px", 0) if scale_x > 0 and w > 0 and scale_y > 0 and h > 0: size_s = f"W: {(scale_x*w)/1000.0:.1f} km, H: {(scale_y*h)/1000.0:.1f} km" except Exception: is_valid = False try: cp.set_sar_center_coords(lat_s, lon_s) cp.set_sar_orientation(orient_s) cp.set_sar_size_km(size_s) except Exception as e: logging.exception(f"[App UI Update] Error: {e}") if not is_valid: cp.set_mouse_coordinates("N/A", "N/A") cp.set_map_mouse_coordinates("N/A", "N/A") def _update_fps_stats(self, img_type: str): """Updates FPS counters in AppState.""" # (Implementation unchanged) now = time.time() try: if img_type == "sar": self.state.sar_frame_count += 1 el = now - self.state.sar_update_time if el >= config.LOG_UPDATE_INTERVAL: self.state.sar_fps = self.state.sar_frame_count / el self.state.sar_update_time = now self.state.sar_frame_count = 0 elif img_type == "mfd": self.state.mfd_frame_count += 1 el = now - self.state.mfd_start_time if el >= config.LOG_UPDATE_INTERVAL: self.state.mfd_fps = self.state.mfd_frame_count / el self.state.mfd_start_time = now self.state.mfd_frame_count = 0 except Exception: pass # Ignore FPS calculation errors # --- Trigger Methods --- def _trigger_sar_update(self): """Triggers SAR reprocessing if not in test mode.""" if self.state.shutting_down or self.state.test_mode_active: return if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_sar_for_display() def _trigger_mfd_update(self): """Triggers MFD reprocessing if not in test mode.""" if self.state.shutting_down or self.state.test_mode_active: return if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_mfd_for_display() def _trigger_map_update_from_sar(self): """Triggers a full map update based on current SAR data.""" mgr = getattr(self, "map_integration_manager", None) if self.state.shutting_down or not config.ENABLE_MAP_OVERLAY or not mgr: return geo = self.state.current_sar_geo_info sar = self.state.current_sar_normalized if geo and geo.get("valid") and sar is not None and sar.size > 0: try: mgr.update_map_overlay(sar, geo) except Exception as e: logging.exception(f"[App Trigger Map Update] Error: {e}") # --- Periodic Update Scheduling --- def schedule_periodic_updates(self): """Schedules the regular update of the status bar.""" if self.state.shutting_down: return self.update_status() interval_ms = max(100, int(config.LOG_UPDATE_INTERVAL * 1000)) if self.root and self.root.winfo_exists(): self.root.after(interval_ms, self.schedule_periodic_updates) # --- Queue Processors --- def process_sar_queue(self): if self.state.shutting_down: return img = None try: img = self.sar_queue.get(block=False) self.sar_queue.task_done() except queue.Empty: pass if ( img is not None and hasattr(self, "display_manager") and self.display_manager ): self.display_manager.show_sar_image(img) if not self.state.shutting_down: self._reschedule_queue_processor(self.process_sar_queue) def process_mfd_queue(self): if self.state.shutting_down: return img = None try: img = self.mfd_queue.get(block=False) self.mfd_queue.task_done() except queue.Empty: pass if ( img is not None and hasattr(self, "display_manager") and self.display_manager ): self.display_manager.show_mfd_image(img) if not self.state.shutting_down: self._reschedule_queue_processor(self.process_mfd_queue) # --- Tkinter Queue Processor --- def process_tkinter_queue(self): """Processes commands (mouse coords, map updates) from queue for UI/State.""" if self.state.shutting_down: return item: Optional[Tuple[str, Any]] = None try: item = self.tkinter_queue.get(block=False) self.tkinter_queue.task_done() except queue.Empty: pass except Exception as e: logging.exception("[App QProc Tkinter] Error getting from queue:") if item is not None: try: if isinstance(item, tuple) and len(item) == 2: command, payload = item # logging.debug(...) # Reduce verbosity if command == "MOUSE_COORDS": self._handle_sar_mouse_coords_update(payload) elif command == "MAP_MOUSE_COORDS": self._handle_map_mouse_coords_update(payload) elif command == "SHOW_MAP": self._handle_show_map_update(payload) elif ( command == "REDRAW_MAP" ): # Handle simple redraw (e.g., alpha change) mgr = getattr(self, "map_integration_manager", None) if mgr: mgr._recompose_map_overlay() # Call the recomposition method else: logging.warning( "[App QProc Tkinter] REDRAW_MAP ignored: Map manager not available." ) else: logging.warning( f"[App QProc Tkinter] Unknown command: {command}" ) else: logging.warning( f"[App QProc Tkinter] Invalid item type: {type(item)}" ) except Exception as e: logging.exception("[App QProc Tkinter] Error processing item:") if not self.state.shutting_down: self._reschedule_queue_processor(self.process_tkinter_queue, delay=100) def _handle_sar_mouse_coords_update(self, payload: Optional[Tuple[str, str]]): """Updates the SAR mouse coordinates UI Entry widget.""" cp = getattr(self, "control_panel", None) if not cp: return lat_s, lon_s = ( payload if (payload and isinstance(payload, tuple) and len(payload) == 2) else ("N/A", "N/A") ) try: cp.set_mouse_coordinates(lat_s, lon_s) except Exception as e: logging.warning(f"[App UI Update] Error updating SAR mouse coords: {e}") def _handle_map_mouse_coords_update(self, payload: Optional[Tuple[int, int]]): """Handles MAP_MOUSE_COORDS: converts pixels to geo and updates UI.""" lat_s, lon_s = ("N/A", "N/A") cp = getattr(self, "control_panel", None) if cp and payload and isinstance(payload, tuple) and len(payload) == 2: mgr = getattr(self, "map_integration_manager", None) if mgr: geo_coords = mgr.get_geo_coords_from_map_pixel(payload[0], payload[1]) if geo_coords: lat_s, lon_s = decimal_to_dms(geo_coords[0], True), decimal_to_dms( geo_coords[1], False ) try: cp.set_map_mouse_coordinates(lat_s, lon_s) except Exception as e: logging.warning(f"[App UI Update] Error updating map mouse coords: {e}") def _handle_show_map_update(self, payload: Optional[ImageType]): """Delegates map display to MapIntegrationManager.""" mgr = getattr(self, "map_integration_manager", None) if mgr: try: mgr.display_map(payload) except Exception as e: logging.exception("[App QProc Tkinter] Error calling display_map:") def _reschedule_queue_processor(self, processor_func, delay: Optional[int] = None): """Helper to reschedule queue processors.""" if delay is None: delay = ( max(10, int(1000 / (config.MFD_FPS * 1.5))) if processor_func in [self.process_sar_queue, self.process_mfd_queue] else 100 ) try: if self.root and self.root.winfo_exists(): self.root.after(delay, processor_func) except Exception: pass # Ignore errors during shutdown # --- Mouse Coordinate Handling (SAR) --- def process_mouse_queue(self): """Processes raw SAR mouse coords from queue, calculates geo coords, queues result.""" log_prefix = "[App GeoCalc]" if self.state.shutting_down: return raw_coords = None try: raw_coords = self.mouse_queue.get(block=False); self.mouse_queue.task_done() except queue.Empty: pass except Exception as e: logging.exception(f"{log_prefix} Error getting from mouse queue:") if isinstance(raw_coords, tuple) and len(raw_coords) == 2: x_disp, y_disp = raw_coords geo = self.state.current_sar_geo_info; disp_w = self.state.sar_display_width; disp_h = self.state.sar_display_height lat_s, lon_s = "N/A", "N/A" # Check if geo info is valid for calculation is_geo_valid_for_calc = (geo and geo.get("valid") and disp_w > 0 and disp_h > 0 and geo.get("width_px", 0) > 0 and geo.get("height_px", 0) > 0 and geo.get("scale_x", 0.0) > 0 and geo.get("scale_y", 0.0) > 0 and all(k in geo for k in ["lat", "lon", "ref_x", "ref_y", "orientation"])) if is_geo_valid_for_calc: try: # Extract values orig_w, orig_h = geo["width_px"], geo["height_px"]; scale_x, scale_y = geo["scale_x"], geo["scale_y"] ref_x, ref_y = geo["ref_x"], geo["ref_y"]; ref_lat_rad, ref_lon_rad = geo["lat"], geo["lon"] angle_rad = geo.get("orientation", 0.0) # Use original orientation # Normalize display coords nx_disp, ny_disp = x_disp / disp_w, y_disp / disp_h # Apply Inverse Rotation if needed nx_orig_norm, ny_orig_norm = nx_disp, ny_disp if abs(angle_rad) > 1e-4: # If rotated significantly arad_inv = angle_rad; cosa, sina = math.cos(arad_inv), math.sin(arad_inv); cx, cy = 0.5, 0.5 tx, ty = nx_disp - cx, ny_disp - cy; rtx, rty = tx * cosa - ty * sina, tx * sina + ty * cosa nx_orig_norm, ny_orig_norm = rtx + cx, rty + cy # Convert normalized back to original pixel space orig_x = max(0.0, min(nx_orig_norm * orig_w, orig_w - 1.0)) orig_y = max(0.0, min(ny_orig_norm * orig_h, orig_h - 1.0)) # Geodetic Calculation (Simplified) pixel_delta_x, pixel_delta_y = orig_x - ref_x, ref_y - orig_y meters_delta_x, meters_delta_y = pixel_delta_x * scale_x, pixel_delta_y * scale_y M_PER_DLAT, M_PER_DLON_EQ = 111132.954, 111319.488 m_per_dlon = max(abs(M_PER_DLON_EQ * math.cos(ref_lat_rad)), 1e-3) lat_offset_deg, lon_offset_deg = meters_delta_y / M_PER_DLAT, meters_delta_x / m_per_dlon final_lat_deg, final_lon_deg = math.degrees(ref_lat_rad) + lat_offset_deg, math.degrees(ref_lon_rad) + lon_offset_deg # Validate and format lat_valid = (math.isfinite(final_lat_deg) and abs(final_lat_deg) <= 90.0) lon_valid = (math.isfinite(final_lon_deg) and abs(final_lon_deg) <= 180.0) if lat_valid and lon_valid: lat_s, lon_s = decimal_to_dms(final_lat_deg, True), decimal_to_dms(final_lon_deg, False) if "Error" in lat_s or "Invalid" in lat_s or "Error" in lon_s or "Invalid" in lon_s: lat_s, lon_s = "Error DMS", "Error DMS" else: lat_s, lon_s = "Invalid Calc", "Invalid Calc" except KeyError as ke: logging.error(f"{log_prefix} Missing key in geo_info: {ke}"); lat_s, lon_s = "Error Key", "Error Key" except Exception as calc_e: logging.exception(f"{log_prefix} Geo calculation error:"); lat_s, lon_s = "Calc Error", "Calc Error" # Queue Result (even if "N/A" or error strings) using the updated method self.put_mouse_coordinates_queue(("MOUSE_COORDS", (lat_s, lon_s))) # Reschedule processor if not self.state.shutting_down: self._reschedule_queue_processor(self.process_mouse_queue, delay=50) # Use specific delay def put_mouse_coordinates_queue(self, command_payload_tuple: Tuple[str, Tuple[str, str]]): """Puts processed mouse coords tuple (command, (lat_str, lon_str)) onto Tkinter queue.""" log_prefix = "[App Mouse Queue Put]" if self.state.shutting_down: return command, payload = command_payload_tuple logging.debug(f"{log_prefix} Putting command '{command}' with payload {payload} onto tkinter_queue.") # Use the utility function, passing self as app_instance put_queue( queue_obj=self.tkinter_queue, item=(command, payload), queue_name="tkinter", app_instance=self ) # --- Queue Processors --- def process_sar_queue(self): """Gets processed SAR image from queue and displays it.""" log_prefix = "[App QProc SAR]"; image_to_display = None if self.state.shutting_down: return try: image_to_display = self.sar_queue.get(block=False); self.sar_queue.task_done() except queue.Empty: pass except Exception as e: logging.exception(f"{log_prefix} Error getting from SAR display queue:") if image_to_display is not None: display_mgr = getattr(self, "display_manager", None) if display_mgr: try: display_mgr.show_sar_image(image_to_display) except Exception as display_e: logging.exception(f"{log_prefix} Error calling DisplayManager.show_sar_image:") else: logging.error(f"{log_prefix} DisplayManager not available.") if not self.state.shutting_down: self._reschedule_queue_processor(self.process_sar_queue) def process_mfd_queue(self): """Gets processed MFD image from queue and displays it.""" log_prefix = "[App QProc MFD]"; image_to_display = None if self.state.shutting_down: return try: image_to_display = self.mfd_queue.get(block=False); self.mfd_queue.task_done() except queue.Empty: pass except Exception as e: logging.exception(f"{log_prefix} Error getting from MFD display queue:") if image_to_display is not None: display_mgr = getattr(self, "display_manager", None) if display_mgr: try: display_mgr.show_mfd_image(image_to_display) except Exception as display_e: logging.exception(f"{log_prefix} Error calling DisplayManager.show_mfd_image:") else: logging.error(f"{log_prefix} DisplayManager not available.") if not self.state.shutting_down: self._reschedule_queue_processor(self.process_mfd_queue) def process_tkinter_queue(self): """Processes commands (mouse coords, map updates) from queue for UI/State.""" log_prefix = "[App QProc Tkinter]" if self.state.shutting_down: return item: Optional[Tuple[str, Any]] = None try: item = self.tkinter_queue.get(block=False); self.tkinter_queue.task_done() except queue.Empty: pass except Exception as e: logging.exception(f"{log_prefix} Error getting from queue:"); item = None if item is not None: try: if isinstance(item, tuple) and len(item) == 2: command, payload = item # logging.debug(...) # Reduce verbosity if command == "MOUSE_COORDS": self._handle_sar_mouse_coords_update(payload) elif command == "MAP_MOUSE_COORDS": self._handle_map_mouse_coords_update(payload) elif command == "SHOW_MAP": self._handle_show_map_update(payload) elif command == "REDRAW_MAP": # Handle simple redraw (e.g., alpha change) mgr = getattr(self, "map_integration_manager", None) if mgr: mgr._recompose_map_overlay() # Call the new recomposition method else: logging.warning(f"{log_prefix} REDRAW_MAP ignored: Map manager unavailable.") else: logging.warning(f"{log_prefix} Unknown command: {command}") else: logging.warning(f"{log_prefix} Invalid item type: {type(item)}") except Exception as e: logging.exception(f"{log_prefix} Error processing item:") if not self.state.shutting_down: self._reschedule_queue_processor(self.process_tkinter_queue, delay=100) # --- >>> Handle Map Mouse Coords - Moved here from Part 3 <<< --- def _handle_map_mouse_coords_update(self, payload: Optional[Tuple[int, int]]): """Handles MAP_MOUSE_COORDS command: converts pixels to geo and updates UI.""" log_prefix = "[App QProc Tkinter]" lat_s, lon_s = ("N/A", "N/A"); cp = getattr(self, "control_panel", None); if cp and payload and isinstance(payload, tuple) and len(payload)==2: mgr = getattr(self, "map_integration_manager", None) if mgr: geo_coords = mgr.get_geo_coords_from_map_pixel(payload[0], payload[1]) if geo_coords: lat_s, lon_s = decimal_to_dms(geo_coords[0],True), decimal_to_dms(geo_coords[1],False) if "Error" in lat_s or "Invalid" in lat_s or "Error" in lon_s or "Invalid" in lon_s: lat_s, lon_s = "Error DMS", "Error DMS" try: cp.set_map_mouse_coordinates(lat_s, lon_s) except Exception as e: logging.warning(f"[App UI Update] Error updating map mouse coords: {e}") elif cp: # If payload invalid, still clear UI try: cp.set_map_mouse_coordinates("N/A", "N/A") except Exception: pass # --- >>> Handle SAR Mouse Coords - Moved here from Part 3 <<< --- def _handle_sar_mouse_coords_update(self, payload: Optional[Tuple[str, str]]): """Updates the SAR mouse coordinates UI Entry widget.""" log_prefix = "[App QProc Tkinter]" cp = getattr(self, "control_panel", None); if not cp: return lat_s, lon_s = payload if (payload and isinstance(payload, tuple) and len(payload)==2) else ("N/A", "N/A") try: cp.set_mouse_coordinates(lat_s, lon_s) except Exception as e: logging.warning(f"{log_prefix} Error updating SAR mouse coords UI: {e}") # --- >>> Handle Show Map Update - Moved here from Part 3 <<< --- def _handle_show_map_update(self, payload: Optional[ImageType]): """Handles the SHOW_MAP/REDRAW_MAP command by delegating display to MapIntegrationManager.""" log_prefix = "[App QProc Tkinter]" # logging.debug(...) # Reduce verbosity mgr = getattr(self, "map_integration_manager", None) if mgr: try: mgr.display_map(payload) except Exception as e: logging.exception(f"{log_prefix} Error calling map_integration_manager.display_map:") else: logging.warning(f"{log_prefix} Received map display command but MapIntegrationManager not active.") def _reschedule_queue_processor(self, processor_func, delay: Optional[int] = None): """Helper method to reschedule a queue processor function using root.after.""" if self.state.shutting_down: return # Don't reschedule if shutting down if delay is None: # Determine default delay based on processor type if processor_func in [self.process_sar_queue, self.process_mfd_queue]: target_fps = config.MFD_FPS calculated_delay = 1000 / (target_fps * 1.5) if target_fps > 0 else 20 # Target ~1.5x display rate delay = max(10, int(calculated_delay)) # Minimum delay 10ms else: delay = 100 # Default delay for other queues (tkinter, mouse) try: # Schedule only if root window exists if self.root and self.root.winfo_exists(): self.root.after(delay, processor_func) except Exception as e: # Log error only if not shutting down if not self.state.shutting_down: logging.warning(f"[App Rescheduler] Error rescheduling {processor_func.__name__}: {e}") # --- Status Update --- def update_status(self): """Updates status bar text and statistics display.""" if not hasattr(self, "state") or self.state.shutting_down: return try: # Skip if map loading sb = getattr(self, "statusbar", None) if sb and sb.winfo_exists() and "Loading" in sb.cget("text"): return except Exception: pass stats = self.state.get_statistics() try: mode = ( "Test" if self.state.test_mode_active else ("Local" if config.USE_LOCAL_IMAGES else "Network") ) map_on = ( " MapOn" if config.ENABLE_MAP_OVERLAY and hasattr(self, "map_integration_manager") and self.map_integration_manager else "" ) mfd_fps = f"MFD:{self.state.mfd_fps:.1f}fps" sar_fps = ( f"SAR:{self.state.sar_fps:.1f}fps" if self.state.sar_fps > 0 else "SAR:N/A" ) status = f"Status: {mode}{map_on} | {mfd_fps} | {sar_fps}" drop = f"Drop(Q): S={stats['dropped_sar_q']},M={stats['dropped_mfd_q']},Tk={stats['dropped_tk_q']},Mo={stats['dropped_mouse_q']}" incmpl = f"Incmpl(RX): S={stats['incomplete_sar_rx']},M={stats['incomplete_mfd_rx']}" if self.root and self.root.winfo_exists(): if sb and sb.winfo_exists(): self.root.after_idle(sb.set_status_text, status) cp = getattr(self, "control_panel", None) if cp and cp.winfo_exists(): self.root.after_idle(cp.set_statistics_display, drop, incmpl) except Exception: pass # Ignore status update errors # --- Cleanup --- def close_app(self): """Performs graceful shutdown.""" if hasattr(self, "state") and self.state.shutting_down: return if not hasattr(self, "state"): sys.exit(1) logging.info("[App Shutdown] Starting shutdown sequence...") self.state.shutting_down = True try: self.set_status("Closing...") except Exception: pass # Shutdown components in reverse order of dependency (roughly) if hasattr(self, "test_mode_manager"): self.test_mode_manager.stop_timers() if hasattr(self, "map_integration_manager"): self.map_integration_manager.shutdown() if hasattr(self, "image_recorder"): self.image_recorder.shutdown() if self.udp_socket: close_udp_socket(self.udp_socket) self.udp_socket = None if self.udp_thread and self.udp_thread.is_alive(): self.udp_thread.join(timeout=0.5) pool = ( getattr(self.udp_receiver, "executor", None) if hasattr(self, "udp_receiver") else None ) if pool: pool.shutdown(wait=False, cancel_futures=True) if hasattr(self, "display_manager"): self.display_manager.destroy_windows() try: cv2.waitKey(5) except Exception: pass try: if self.root and self.root.winfo_exists(): self.root.destroy() except Exception as e: logging.exception(f"[App Shutdown] Error destroying Tkinter window: {e}") logging.info("[App Shutdown] Application close sequence finished.") sys.exit(0) # --- Main Execution Block --- if __name__ == "__main__": # (Implementation unchanged) root = None app_instance = None try: if config.ENABLE_MAP_OVERLAY and not MAP_MODULES_LOADED: logging.critical("[App Main] Map enabled but modules failed. Cannot start.") sys.exit(1) root = create_main_window( "Control Panel", config.TKINTER_MIN_WIDTH, config.TKINTER_MIN_HEIGHT, 10, 10 ) app_instance = ControlPanelApp(root) root.protocol("WM_DELETE_WINDOW", app_instance.close_app) root.mainloop() except SystemExit as exit_e: pass # Already logged by close_app or startup except Exception as e: logging.critical("[App Main] UNHANDLED EXCEPTION:", exc_info=True) sys.exit(1) finally: logging.info("=== App End ===") logging.shutdown() # --- END OF FILE ControlPanel.py ---