# --- START OF FILE ControlPanel.py - PART 1 --- # 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, 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 # Required for network setup from typing import Optional, Tuple, Any, Dict, TYPE_CHECKING import datetime # --- Third-party imports --- import tkinter as tk from tkinter import ttk from tkinter import colorchooser import numpy as np import cv2 # Keep for potential utility/fallback use import screeninfo try: from PIL import Image, ImageTk # ImageTk might be needed for icon except ImportError: Image = None # Define as None if Pillow not installed 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: # Fallback basic configuration if logging_config fails 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 --- # Use UIPanel alias to avoid name clash with the main class if named ControlPanel from ui import ControlPanel as UIPanel, StatusBar, create_main_window from display import DisplayManager from utils import ( put_queue, clear_queue, decimal_to_dms, generate_sar_kml, launch_google_earth, cleanup_old_kml_files, ) 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, # Needed by MapIntegrationManager ) # --- Map related imports (Conditional) --- map_libs_found = True try: # Core map libs needed by manager/utils import mercantile import pyproj # Pillow already checked above if Image is None: raise ImportError("Pillow library failed to import earlier.") except ImportError as map_lib_err: logging.warning( f"[App Init] Failed to import core map library ({map_lib_err}). " "Map functionality disabled." ) map_libs_found = False # Define placeholders for type hinting if core libs failed 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 ( get_bounding_box_from_center_size, get_tile_ranges_for_bbox, MapCalculationError, ) from map_display import MapDisplayWindow # Import the integration manager from map_integration import MapIntegrationManager MAP_MODULES_LOADED = True except ImportError as map_import_err: logging.warning( f"[App Init] Failed to import specific map modules ({map_import_err}). " "Map functionality disabled." ) MAP_MODULES_LOADED = False # Define placeholders if specific modules failed BaseMapService = None MapTileManager = None MapDisplayWindow = None MapIntegrationManager = None MapCalculationError = Exception else: MAP_MODULES_LOADED = False # Type checking block for App class reference in managers if TYPE_CHECKING: # This avoids circular imports at runtime but helps type checkers pass # --- Main Application Class --- # Renamed from App to ControlPanelApp to avoid clash with ui.ControlPanel class ControlPanelApp: """ Main application class. Manages UI, display, processing, network, state, and orchestrates various managers (Test Mode, Image Pipeline, Map Integration). """ # --- Methods DEFINED BEFORE __init__ to be available for bindings --- # --- Status Update Method --- def set_status(self, message: str): """ Safely updates the main status message prefix in the status bar. Uses after_idle for thread safety. """ log_prefix = "[App Set Status]" # Check state exists and flag before proceeding if not hasattr(self, "state") or self.state.shutting_down: return new_status_prefix = f"Status: {message}" # Use INFO level for user-visible status changes logging.info(f"{log_prefix} Request to set status message prefix: '{message}'") def _update_status_text_on_main_thread(): """Internal function to update status text, runs in main GUI thread.""" # Check state again inside the scheduled function if not hasattr(self, "state") or self.state.shutting_down: return try: # Check if statusbar exists and is valid Tkinter widget statusbar_valid = ( hasattr(self, "statusbar") and isinstance(self.statusbar, tk.Widget) and self.statusbar.winfo_exists() ) if not statusbar_valid: logging.warning( f"{log_prefix} Statusbar widget not available, " "cannot update status." ) return # Get current text and parse existing parts (keep info after '|') current_text: str = self.statusbar.cget("text") parts = current_text.split("|") suffix = "" # Rebuild suffix from parts after the first one if len(parts) > 1: suffix_parts = [p.strip() for p in parts[1:] if p.strip()] if suffix_parts: suffix = " | " + " | ".join(suffix_parts) # Combine new prefix and existing suffix final_text = f"{new_status_prefix}{suffix}" logging.debug( f"{log_prefix} Updating status bar text to: '{final_text}'" ) # Call StatusBar's method to update self.statusbar.set_status_text(final_text) except tk.TclError as e: # Log TclError (widget destroyed) if not shutting down if not self.state.shutting_down: logging.warning(f"{log_prefix} TclError setting status text: {e}") except Exception as e: # Log other unexpected errors logging.exception(f"{log_prefix} Error updating status bar text:") # Schedule the update on the main GUI thread try: root_valid = ( hasattr(self, "root") and self.root and self.root.winfo_exists() ) if root_valid: # Use after_idle to ensure it runs when Tkinter is idle self.root.after_idle(_update_status_text_on_main_thread) # else: Don't log if root is None or destroyed, expected during shutdown except Exception as e: # Log error only if not shutting down if not hasattr(self, "state") or not self.state.shutting_down: logging.warning( f"{log_prefix} Error scheduling status update via after_idle: {e}" ) # --- LUT Generation Methods --- def update_brightness_contrast_lut(self): """Recalculates the SAR B/C LUT based on AppState and stores it back in AppState.""" log_prefix = "[App Update SAR LUT]" logging.debug(f"{log_prefix} Updating SAR Brightness/Contrast LUT...") # Check if state is initialized if not hasattr(self, "state"): logging.error(f"{log_prefix} AppState not ready for LUT update.") return try: # Ensure contrast has a minimum positive value contrast_val = max(0.01, self.state.sar_contrast) brightness_val = self.state.sar_brightness except AttributeError: logging.error( f"{log_prefix} Error accessing state for SAR LUT parameters " f"(AttributeError)." ) return except Exception as e: logging.error( f"{log_prefix} Unexpected error accessing state for SAR LUT params: {e}" ) return # Calculate the LUT using numpy vectorized operations try: lut_values = np.arange(256, dtype=np.float32) adjusted_values = (lut_values * contrast_val) + brightness_val # Clip and round values to uint8 range lut = np.clip(np.round(adjusted_values), 0, 255).astype(np.uint8) except Exception as e: logging.exception(f"{log_prefix} Error calculating SAR B/C LUT:") # Create identity LUT as fallback identity_lut = np.arange(256, dtype=np.uint8) self.state.brightness_contrast_lut = identity_lut logging.error( f"{log_prefix} Using identity SAR LUT as fallback " "due to calculation error." ) return # Store the calculated LUT back into AppState self.state.brightness_contrast_lut = lut logging.debug(f"{log_prefix} SAR B/C LUT updated successfully in AppState.") def update_mfd_lut(self): """Recalculates the MFD LUT based on AppState parameters and stores it back in AppState.""" log_prefix = "[MFD LUT Update]" logging.debug(f"{log_prefix} Recalculating MFD Color LUT...") if not hasattr(self, "state"): logging.error(f"{log_prefix} AppState not ready for MFD LUT update.") return try: # Read parameters from AppState safely mfd_params = self.state.mfd_params raw_map_intensity_factor = mfd_params["raw_map_intensity"] / 255.0 pixel_to_category = mfd_params["pixel_to_category"] categories = mfd_params["categories"] except AttributeError: logging.error( f"{log_prefix} Error accessing mfd_params state (AttributeError)." ) return except KeyError as ke: logging.error(f"{log_prefix} Missing key in AppState mfd_params: {ke}") return except Exception as e: logging.error( f"{log_prefix} Unexpected error accessing state for MFD LUT params: {e}" ) return # Initialize a new LUT array (256 entries, 3 channels BGR) with zeros new_lut = np.zeros((256, 3), dtype=np.uint8) try: # Iterate through all possible pixel index values (0-255) for index_value in range(256): category_name = pixel_to_category.get(index_value) if category_name: # Categorized Pixels (0-31 typically) cat_data = categories[category_name] base_bgr = cat_data["color"] intensity_factor = cat_data["intensity"] / 255.0 # Calculate final color components final_b = float(base_bgr[0]) * intensity_factor final_g = float(base_bgr[1]) * intensity_factor final_r = float(base_bgr[2]) * intensity_factor # Assign clamped values to LUT new_lut[index_value, 0] = np.clip(int(round(final_b)), 0, 255) new_lut[index_value, 1] = np.clip(int(round(final_g)), 0, 255) new_lut[index_value, 2] = np.clip(int(round(final_r)), 0, 255) elif 32 <= index_value <= 255: # Raw Map Pixels (32-255 typically) # Remap index range [32, 255] to intensity [0, 255] raw_intensity = (float(index_value) - 32.0) * (255.0 / 223.0) # Apply raw map intensity factor final_gray_float = raw_intensity * raw_map_intensity_factor # Clip and convert to integer final_gray_int = int(round(np.clip(final_gray_float, 0, 255))) # Assign gray value to all BGR channels new_lut[index_value, :] = final_gray_int else: # Handle cases where index is < 32 but not found if category_name is None: logging.warning( f"{log_prefix} Index {index_value} has no assigned category. " "Defaulting to black." ) # new_lut[index_value, :] is already [0, 0, 0] by initialization # Store the completed LUT back into AppState self.state.mfd_lut = new_lut logging.debug( f"{log_prefix} MFD LUT update complete and stored in AppState." ) except KeyError as ke: logging.error( f"{log_prefix} Missing category key '{ke}' during MFD LUT generation." ) self._apply_fallback_mfd_lut() # Apply fallback except Exception as e: logging.critical( f"{log_prefix} CRITICAL error during MFD LUT generation:", exc_info=True ) self._apply_fallback_mfd_lut() # Apply fallback def _apply_fallback_mfd_lut(self): """Applies a simple grayscale ramp as a fallback MFD LUT in case of errors.""" log_prefix = "[MFD LUT Update]" logging.error( f"{log_prefix} Applying fallback grayscale MFD LUT due to previous errors." ) if hasattr(self, "state"): try: gray_ramp = np.arange(256, dtype=np.uint8) # Use OpenCV to efficiently convert grayscale ramp to BGR LUT format fallback_lut = cv2.cvtColor( gray_ramp[:, np.newaxis], cv2.COLOR_GRAY2BGR )[ :, 0, : ] # Reshape result correctly self.state.mfd_lut = fallback_lut except Exception as fallback_e: logging.critical( f"{log_prefix} Failed even to create fallback MFD LUT: {fallback_e}" ) # Ensure state LUT is a valid array even if fallback fails self.state.mfd_lut = np.zeros((256, 3), dtype=np.uint8) # --- UI Callback Methods --- def update_image_mode(self): # UI Callback """Handles switching between Test and Normal Mode based on UI checkbox.""" log_prefix = "[App Mode Switch]" if not hasattr(self, "state") or not hasattr(self, "test_mode_manager"): logging.error( f"{log_prefix} State or TestModeManager not initialized. " "Cannot switch mode." ) return if self.state.shutting_down: return # Ignore if shutting down try: is_test_req = False # Safely access control panel and its variable control_panel_ref = getattr(self, "control_panel", None) test_var_ref = ( getattr(control_panel_ref, "test_image_var", None) if control_panel_ref else None ) if test_var_ref and isinstance(test_var_ref, tk.Variable): is_test_req = test_var_ref.get() == 1 else: logging.warning( f"{log_prefix} test_image_var not found or invalid in control_panel." ) # Fallback to current state if UI var is missing is_test_req = self.state.test_mode_active # Perform switch only if requested mode differs from current state if is_test_req != self.state.test_mode_active: logging.info( f"{log_prefix} Request to change Test Mode state to: {is_test_req}" ) self.state.test_mode_active = is_test_req # Update state flag first if self.state.test_mode_active: if self.test_mode_manager.activate(): # Attempt activation self.activate_test_mode_ui_actions() else: # Manager activation failed logging.error( f"{log_prefix} TestModeManager activation failed. " "Reverting UI and state." ) self._revert_test_mode_ui() # Revert checkbox and state flag else: # Deactivating test mode self.test_mode_manager.deactivate() # Stop timers self.deactivate_test_mode_ui_actions() # Reset statistics whenever the mode successfully changes self.state.reset_statistics() self.update_status() # Update the status bar display else: logging.debug( f"{log_prefix} Requested mode ({is_test_req}) is the same as current mode. " "No change." ) except tk.TclError as e: logging.warning( f"{log_prefix} UI error accessing checkbox state (TclError): {e}" ) except AttributeError as ae: logging.error( f"{log_prefix} Missing attribute during mode update " f"(manager init issue?): {ae}" ) except Exception as e: logging.exception(f"{log_prefix} Unexpected error during mode update:") def update_sar_size(self, event=None): # UI Callback """ Callback for SAR size combobox change. Updates state and triggers processing. Allows changing size even when map overlay is active. """ log_prefix = "[App CB SAR Size]" if not hasattr(self, "state") or self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown or state not ready. Ignoring.") return try: # Safely access control panel and combobox control_panel_ref = getattr(self, "control_panel", None) combo_ref = ( getattr(control_panel_ref, "sar_size_combo", None) if control_panel_ref else None ) if not combo_ref: logging.warning(f"{log_prefix} SAR size combobox not found.") return selected_size_str = combo_ref.get() logging.debug( f"{log_prefix} SAR display size selected: '{selected_size_str}'" ) factor = 1 # Default factor if selected_size_str and ":" in selected_size_str: try: factor_str = selected_size_str.split(":")[1] factor = int(factor_str) if factor <= 0: raise ValueError("Factor must be positive") except (IndexError, ValueError) as parse_err: logging.warning( f"{log_prefix} Invalid SAR size factor: '{selected_size_str}'. " f"Error: {parse_err}. Using factor 1." ) factor = 1 # Attempt to reset UI combobox to a valid value try: current_factor = 1 if self.state.sar_display_width > 0: current_factor = ( config.SAR_WIDTH // self.state.sar_display_width ) current_size_str = f"1:{current_factor}" # Ensure the reset value is actually in the list if current_size_str not in config.SAR_SIZE_FACTORS: current_size_str = config.DEFAULT_SAR_SIZE combo_ref.set(current_size_str) except Exception as reset_e: logging.warning( f"{log_prefix} Failed to reset SAR size combobox UI: {reset_e}" ) # Calculate new dimensions, ensuring minimum 1x1 new_width = max(1, config.SAR_WIDTH // factor) new_height = max(1, config.SAR_HEIGHT // factor) # Update state and trigger processing only if size actually changed if ( new_width != self.state.sar_display_width or new_height != self.state.sar_display_height ): logging.info( f"{log_prefix} Requesting SAR display size update to " f"{new_width}x{new_height} (Factor 1:{factor})" ) self.state.update_sar_display_size(new_width, new_height) self._trigger_sar_update() # Trigger SAR reprocessing else: logging.debug( f"{log_prefix} Selected size {new_width}x{new_height} is the same as current." ) except tk.TclError as e: if not self.state.shutting_down: logging.warning( f"{log_prefix} TclError accessing SAR size combobox: {e}" ) except Exception as e: if not self.state.shutting_down: logging.exception(f"{log_prefix} Error processing SAR size update:") def update_contrast(self, value_str: str): # UI Callback """Callback for SAR contrast slider. Updates state, LUT, and triggers display update.""" log_prefix = "[App CB SAR Contrast]" 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 ValueError: logging.warning( f"{log_prefix} Invalid contrast value received: {value_str}" ) except Exception as e: logging.exception(f"{log_prefix} Error updating contrast: {e}") def update_brightness(self, value_str: str): # UI Callback """Callback for SAR brightness slider. Updates state, LUT, and triggers display update.""" log_prefix = "[App CB SAR Brightness]" if self.state.shutting_down: return try: # Slider value might be float, convert carefully brightness = int(float(value_str)) self.state.update_sar_parameters(brightness=brightness) self.update_brightness_contrast_lut() self._trigger_sar_update() except ValueError: logging.warning( f"{log_prefix} Invalid brightness value received: {value_str}" ) except Exception as e: logging.exception(f"{log_prefix} Error updating brightness: {e}") def update_sar_palette(self, event=None): # UI Callback """Callback for SAR palette combobox. Updates state and triggers display update.""" log_prefix = "[App CB SAR Palette]" if self.state.shutting_down: return try: # Safely access control panel and combobox control_panel_ref = getattr(self, "control_panel", None) combo_ref = ( getattr(control_panel_ref, "palette_combo", None) if control_panel_ref else None ) if not combo_ref: logging.warning(f"{log_prefix} Palette combobox not found.") return palette = combo_ref.get() logging.debug(f"{log_prefix} Palette changed to '{palette}'") if palette in config.COLOR_PALETTES: self.state.update_sar_parameters(palette=palette) self._trigger_sar_update() else: logging.warning( f"{log_prefix} Unknown palette selected: '{palette}'. Ignoring." ) # Reset UI combobox to the actual current state value combo_ref.set(self.state.sar_palette) except Exception as e: logging.exception(f"{log_prefix} Error updating SAR palette: {e}") def update_mfd_category_intensity( self, category_name: str, intensity_value: int ): # UI Callback """Callback for MFD category intensity slider. Updates state, LUT, and triggers display.""" log_prefix = "[App CB MFD Param Intensity]" if self.state.shutting_down: return logging.debug( f"{log_prefix} Category='{category_name}', Intensity={intensity_value}" ) try: # Value is already int from IntVar, clip just in case intensity = np.clip(intensity_value, 0, 255) if category_name in self.state.mfd_params["categories"]: self.state.mfd_params["categories"][category_name][ "intensity" ] = intensity logging.debug( f"{log_prefix} Intensity for '{category_name}' set to {intensity} in AppState." ) self.update_mfd_lut() self._trigger_mfd_update() else: logging.warning( f"{log_prefix} Unknown MFD category received: '{category_name}'" ) except KeyError as ke: logging.error( f"{log_prefix} KeyError accessing MFD params for '{category_name}': {ke}" ) except Exception as e: logging.exception( f"{log_prefix} Error updating MFD intensity for '{category_name}': {e}" ) def choose_mfd_category_color(self, category_name: str): # UI Callback """Callback for MFD category color button. Opens chooser, updates state, LUT, UI, and triggers display.""" log_prefix = "[App CB MFD Param Color]" if self.state.shutting_down: return logging.debug( f"{log_prefix} Color chooser requested for Category='{category_name}'" ) if category_name not in self.state.mfd_params["categories"]: logging.warning( f"{log_prefix} Cannot choose color for unknown MFD category: '{category_name}'" ) return try: initial_bgr = self.state.mfd_params["categories"][category_name]["color"] # Format BGR to HEX for color chooser initial color initial_hex = ( f"#{initial_bgr[2]:02x}{initial_bgr[1]:02x}{initial_bgr[0]:02x}" ) logging.debug( f"{log_prefix} Opening color chooser (initial BGR: {initial_bgr}, initial HEX: {initial_hex})" ) # Open the Tkinter color chooser dialog color_code = colorchooser.askcolor( title=f"Select Color for {category_name}", initialcolor=initial_hex ) # Check if a color was selected (color_code is None if cancelled) if color_code and color_code[0]: rgb = color_code[0] # Returns tuple (R, G, B) as floats or ints # Convert selected RGB to BGR tuple, ensuring ints and clamping new_bgr = tuple( np.clip(int(c), 0, 255) for c in (rgb[2], rgb[1], rgb[0]) ) logging.info( f"{log_prefix} New color chosen for '{category_name}': RGB={rgb}, BGR={new_bgr}" ) # Update state self.state.mfd_params["categories"][category_name]["color"] = new_bgr # Recalculate LUT self.update_mfd_lut() # Schedule UI update for color preview label control_panel_ref = getattr(self, "control_panel", None) if ( self.root and self.root.winfo_exists() and control_panel_ref and hasattr(control_panel_ref, "update_mfd_color_display") ): self.root.after_idle( control_panel_ref.update_mfd_color_display, category_name, new_bgr, ) # Trigger MFD display update self._trigger_mfd_update() else: logging.debug(f"{log_prefix} Color selection cancelled.") except KeyError as ke: logging.error( f"{log_prefix} KeyError accessing MFD params for '{category_name}': {ke}" ) except Exception as e: logging.exception( f"{log_prefix} Error during color selection for '{category_name}': {e}" ) def update_mfd_raw_map_intensity(self, intensity_value: int): # UI Callback """Callback for Raw Map intensity slider. Updates state, LUT, and triggers display.""" log_prefix = "[App CB MFD Param RawMap]" if self.state.shutting_down: return logging.debug( f"{log_prefix} Raw Map intensity slider changed -> {intensity_value}" ) try: # Value is already int from IntVar, clip just in case intensity = np.clip(intensity_value, 0, 255) self.state.mfd_params["raw_map_intensity"] = intensity logging.info( f"{log_prefix} Raw Map intensity set to {intensity} in AppState." ) self.update_mfd_lut() self._trigger_mfd_update() except KeyError as ke: logging.error( f"{log_prefix} KeyError accessing MFD params for raw_map_intensity: {ke}" ) except Exception as e: logging.exception(f"{log_prefix} Error updating raw map intensity: {e}") def update_map_size(self, event=None): # UI Callback for Map Size """Callback for Map size combobox change. Updates state and triggers map redraw.""" log_prefix = "[App CB Map Size]" if not hasattr(self, "state") or self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown or state not ready. Ignoring.") return try: # Safely access control panel and combobox control_panel_ref = getattr(self, "control_panel", None) combo_ref = ( getattr(control_panel_ref, "map_size_combo", None) if control_panel_ref else None ) if not combo_ref: logging.warning( f"{log_prefix} Map size combobox not found in control panel." ) return selected_size_str = combo_ref.get() logging.debug( f"{log_prefix} Map display size selected: '{selected_size_str}'" ) # Update the scale factor in AppState using its dedicated method self.state.update_map_scale_factor(selected_size_str) # Trigger a map redraw using the last known map image # The map display logic will use the newly set scale factor self.trigger_map_redraw() # Use helper function except tk.TclError as e: if not self.state.shutting_down: logging.warning( f"{log_prefix} TclError accessing Map size combobox: {e}" ) except Exception as e: if not self.state.shutting_down: logging.exception(f"{log_prefix} Error processing Map size update: {e}") # --- START OF FILE ControlPanel.py - PART 2 --- # --- >>> START OF NEW CODE (Callbacks and Helper) <<< --- # --- NEW UI Callback Methods --- def toggle_sar_overlay(self): # UI Callback for SAR Overlay Checkbox """Handles SAR overlay checkbox state change.""" log_prefix = "[App CB SAR Overlay Toggle]" if not hasattr(self, "state") or self.state.shutting_down: return try: # Safely access control panel and checkbox variable control_panel_ref = getattr(self, "control_panel", None) overlay_var_ref = ( getattr(control_panel_ref, "sar_overlay_var", None) if control_panel_ref else None ) if not overlay_var_ref or not isinstance(overlay_var_ref, tk.Variable): logging.warning( f"{log_prefix} SAR overlay checkbox var not found or invalid." ) return new_enabled_state = overlay_var_ref.get() # Get boolean value logging.debug( f"{log_prefix} SAR overlay checkbox toggled to: {new_enabled_state}" ) # Update the state self.state.update_map_overlay_params(enabled=new_enabled_state) # Trigger a map content redraw (if map manager exists) self.trigger_map_redraw() # Call helper except tk.TclError as e: if not self.state.shutting_down: logging.warning( f"{log_prefix} TclError accessing SAR overlay checkbox: {e}" ) except Exception as e: if not self.state.shutting_down: logging.exception( f"{log_prefix} Error processing SAR overlay toggle: {e}" ) def update_sar_overlay_alpha( self, value_str: str ): # UI Callback for SAR Overlay Alpha Slider """Handles SAR overlay alpha slider value change.""" log_prefix = "[App CB SAR Overlay Alpha]" if not hasattr(self, "state") or self.state.shutting_down: return try: # Slider passes value as string, convert to float new_alpha = float(value_str) logging.debug( f"{log_prefix} SAR overlay alpha slider changed to: {new_alpha:.3f}" ) # Update the state (method clamps value 0.0-1.0) self.state.update_map_overlay_params(alpha=new_alpha) # Trigger a map content redraw (if map manager exists) self.trigger_map_redraw() # Call helper except ValueError: logging.warning( f"{log_prefix} Invalid alpha value received from slider: {value_str}" ) except Exception as e: if not self.state.shutting_down: logging.exception( f"{log_prefix} Error processing SAR overlay alpha update: {e}" ) def toggle_sar_recording(self): # UI Callback for Record SAR Checkbox """Handles SAR recording checkbox state change.""" log_prefix = "[App CB SAR Record Toggle]" if not hasattr(self, "state") or self.state.shutting_down: return try: # Safely access control panel and checkbox variable control_panel_ref = getattr(self, "control_panel", None) record_var_ref = ( getattr(control_panel_ref, "record_sar_var", None) if control_panel_ref else None ) if not record_var_ref or not isinstance(record_var_ref, tk.Variable): logging.warning( f"{log_prefix} Record SAR checkbox var not found or invalid." ) return new_enabled_state = record_var_ref.get() # Get boolean value logging.debug( f"{log_prefix} Record SAR checkbox toggled to: {new_enabled_state}" ) # Update the state using the dedicated method in AppState self.state.update_sar_recording_enabled(enabled=new_enabled_state) # No immediate action needed here, recording happens in receiver/recorder except tk.TclError as e: if not self.state.shutting_down: logging.warning( f"{log_prefix} TclError accessing Record SAR checkbox: {e}" ) except Exception as e: if not self.state.shutting_down: logging.exception( f"{log_prefix} Error processing Record SAR toggle: {e}" ) # --- Helper to trigger map redraw --- def trigger_map_redraw(self): """Requests a map redraw by putting a command on the Tkinter queue.""" log_prefix = "[App Trigger Map Redraw]" if self.state.shutting_down: return # Check if map is actually enabled and manager exists map_manager_exists = ( hasattr(self, "map_integration_manager") and self.map_integration_manager is not None ) if config.ENABLE_MAP_OVERLAY and map_manager_exists: logging.debug( f"{log_prefix} Queueing REDRAW_MAP command due to overlay parameter change." ) # Use the existing REDRAW_MAP command. The map manager's display logic # should inherently use the latest state (including alpha/enabled flags). put_queue( queue_obj=self.tkinter_queue, item=("REDRAW_MAP", None), # Command, payload is ignored for redraw queue_name="tkinter", app_instance=self, # Pass self (ControlPanelApp instance) for context ) else: logging.debug( f"{log_prefix} Map overlay not active, skipping redraw trigger." ) # --- >>> END OF NEW CODE (Callbacks and Helper) <<< --- # --- Initialization --- def __init__(self, root: tk.Tk): """Initializes the main application components and state.""" log_prefix = "[App Init]" logging.debug(f"{log_prefix} Starting application initialization...") self.root = root self.root.title("Control Panel") # --- Set Icon --- try: # Define path relative to script location script_dir = os.path.dirname(__file__) icon_filename = "ControlPanel.ico" icon_path = os.path.join(script_dir, icon_filename) if os.path.exists(icon_path): # iconbitmap is generally preferred for .ico on Windows self.root.iconbitmap(default=icon_path) logging.info(f"{log_prefix} Application icon set from: {icon_path}") else: logging.warning( f"{log_prefix} Icon file not found: {icon_path}. Using default icon." ) except tk.TclError as icon_err: logging.warning( f"{log_prefix} Failed to set application icon (TclError): {icon_err}" ) except Exception as icon_e: logging.exception( f"{log_prefix} Unexpected error setting application icon:" ) self.root.minsize(config.TKINTER_MIN_WIDTH, config.TKINTER_MIN_HEIGHT) # --- Central State Initialization --- self.state = AppState() logging.debug(f"{log_prefix} AppState instance created.") # --- Data Queues --- self.sar_queue: queue.Queue = queue.Queue(maxsize=config.DEFAULT_SAR_QUEUE) self.mouse_queue: queue.Queue = queue.Queue(maxsize=config.DEFAULT_MOUSE_QUEUE) self.tkinter_queue: queue.Queue = queue.Queue( maxsize=config.DEFAULT_TKINTER_QUEUE ) self.mfd_queue: queue.Queue = queue.Queue(maxsize=config.DEFAULT_MFD_QUEUE) logging.debug(f"{log_prefix} Data queues initialized.") # --- Screen Info & 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 MapDisplayWindow: map_max_w = 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}" ) # Position main window logging.debug( f"{log_prefix} Initial Window positions: Tk({self.tkinter_x},{self.tkinter_y}), " f"MFD({self.mfd_x},{self.mfd_y}), SAR({self.sar_x},{self.sar_y}), " f"MapEst({map_x},{map_y})" ) # --- Initialize Sub-systems --- # 1. UI Components self.statusbar = StatusBar(self.root) # Instantiate UI Frame, passing self (ControlPanelApp instance) self.control_panel = UIPanel(self.root, self) logging.debug(f"{log_prefix} UI components created.") # 2. LUTs self.update_brightness_contrast_lut() self.update_mfd_lut() logging.debug(f"{log_prefix} Initial LUTs generated.") # 3. Display Manager self.display_manager = DisplayManager( app=self, sar_queue=self.sar_queue, mouse_queue=self.mouse_queue, sar_x=self.sar_x, sar_y=self.sar_y, mfd_x=self.mfd_x, mfd_y=self.mfd_y, initial_sar_width=self.state.sar_display_width, initial_sar_height=self.state.sar_display_height, ) logging.debug(f"{log_prefix} DisplayManager created.") try: self.display_manager.initialize_display_windows() except Exception as e: self.set_status("Error: Display Init Failed") logging.critical( f"{log_prefix} Display window initialization failed: {e}", exc_info=True ) # 4. Image Processing Pipeline self.image_pipeline = ImagePipeline( app_state=self.state, sar_queue=self.sar_queue, mfd_queue=self.mfd_queue, app=self, # Pass app instance ) logging.debug(f"{log_prefix} ImagePipeline created.") # 5. Test Mode Manager self.test_mode_manager = TestModeManager( app_state=self.state, root=self.root, sar_queue=self.sar_queue, mfd_queue=self.mfd_queue, app=self, # Pass app instance ) logging.debug(f"{log_prefix} TestModeManager created.") # 6. Map Integration Manager self.map_integration_manager: Optional[MapIntegrationManager] = None if config.ENABLE_MAP_OVERLAY: if MAP_MODULES_LOADED and MapIntegrationManager is not None: logging.info(f"{log_prefix} Initializing MapIntegrationManager...") try: self.map_integration_manager = MapIntegrationManager( app_state=self.state, tkinter_queue=self.tkinter_queue, app=self, # Pass app instance map_x=map_x, map_y=map_y, ) logging.info( f"{log_prefix} MapIntegrationManager initialized successfully." ) except Exception as map_mgr_e: logging.exception( f"{log_prefix} Failed to initialize MapIntegrationManager:" ) self.map_integration_manager = None self.set_status("Error: Map Init Failed") else: logging.error( f"{log_prefix} Map Overlay enabled but required modules/manager " "failed to load." ) self.set_status("Error: Map Modules Missing") else: logging.debug(f"{log_prefix} Map Overlay is disabled in configuration.") # --- >>> START OF NEW CODE (Recorder Init) <<< --- # 7. Image Recorder (Initialize after AppState) self.image_recorder = None # Initialize as None try: # Attempt to import and initialize only if needed later from image_recorder import ( ImageRecorder, ) # Import here or globally if preferred self.image_recorder = ImageRecorder(app_state=self.state) logging.info(f"{log_prefix} ImageRecorder initialized.") except ImportError: logging.warning( f"{log_prefix} image_recorder.py not found or ImageRecorder class missing. " "SAR recording disabled." ) except Exception as rec_init_e: logging.exception(f"{log_prefix} Failed to initialize ImageRecorder:") self.image_recorder = None # Ensure it's None on error # --- >>> END OF NEW CODE (Recorder Init) <<< --- # 8. Set initial UI state labels self._update_initial_ui_labels() # 9. Network Setup self.local_ip: str = config.DEFAULT_SER_IP self.local_port: int = config.DEFAULT_SER_PORT self.udp_socket: Optional[socket.socket] = None self.udp_receiver: Optional[UdpReceiver] = None self.udp_thread: Optional[threading.Thread] = None if not config.USE_LOCAL_IMAGES: self._setup_network_receiver() else: logging.info( f"{log_prefix} Network receiver disabled (USE_LOCAL_IMAGES=True)." ) # 10. Initial Image Load Thread self._start_initial_image_loader() # 11. Start Queue Processors self.process_sar_queue() self.process_mfd_queue() self.process_mouse_queue() self.process_tkinter_queue() logging.debug(f"{log_prefix} Queue processors scheduled.") # 12. Start Periodic Status Updates self.schedule_periodic_updates() logging.debug(f"{log_prefix} Periodic updates scheduled.") # 13. Set initial image mode based on checkbox/config self.update_image_mode() logging.debug(f"{log_prefix} Initial image mode set.") logging.info(f"{log_prefix} Application initialization sequence complete.") # --- Initialization Helper Methods --- def _get_screen_dimensions(self) -> Tuple[int, int]: """Gets primary screen dimensions.""" log_prefix = "[App Init]" try: monitors = screeninfo.get_monitors() if not monitors: raise screeninfo.ScreenInfoError("No monitors detected.") # Assuming primary monitor is the first one screen = monitors[0] logging.debug( f"{log_prefix} Detected Screen Dimensions: {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 self.state.update_sar_display_size(initial_w, initial_h) # Update state logging.info( f"{log_prefix} Map active, forcing SAR size to 1:{forced_factor} " f"({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 # Position below MFD window + padding y = config.INITIAL_MFD_HEIGHT + 40 # 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 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 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 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 to start network receiver on " f"{self.local_ip}:{self.local_port}" ) self.udp_socket = create_udp_socket(self.local_ip, self.local_port) if self.udp_socket: try: # Pass the image_recorder instance if it was created successfully recorder_instance = getattr(self, "image_recorder", None) self.udp_receiver = UdpReceiver( app=self, # Pass self (ControlPanelApp) 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, # Pass recorder instance ) logging.info(f"{log_prefix} UdpReceiver instance created.") self.udp_thread = threading.Thread( target=self.udp_receiver.receive_udp_data, name="UDPReceiverThread", daemon=True, ) self.udp_thread.start() logging.info(f"{log_prefix} UDP Receiver thread started.") 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") # Clean up socket if receiver init failed close_udp_socket(self.udp_socket) self.udp_socket = None else: logging.error(f"{log_prefix} UDP socket creation failed.") 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: logging.debug(f"{log_prefix} Skipping initial image loading.") # 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_labels(self): """Sets the initial text for UI info labels based on default AppState.""" log_prefix = "[App Init]" logging.debug(f"{log_prefix} Setting initial UI info labels...") # Safely access control_panel instance control_panel_ref = getattr(self, "control_panel", None) if not control_panel_ref: logging.warning(f"{log_prefix} Control panel not ready for initial labels.") return try: # SAR Center Label default_geo = self.state.current_sar_geo_info center_txt = "Image Ref: Lat=N/A, Lon=N/A" if default_geo and default_geo.get("valid", False): try: lat_s = decimal_to_dms(math.degrees(default_geo["lat"]), True) lon_s = decimal_to_dms(math.degrees(default_geo["lon"]), False) center_txt = f"Image Ref: Lat={lat_s}, Lon={lon_s}" except Exception as format_err: logging.error( f"{log_prefix} Error formatting initial geo label: {format_err}" ) center_txt = "Image Ref: Format Error" if hasattr(control_panel_ref, "sar_center_label"): control_panel_ref.sar_center_label.config(text=center_txt) # SAR Orientation Label if hasattr(control_panel_ref, "set_sar_orientation"): control_panel_ref.set_sar_orientation("N/A") # SAR Size Km Label if hasattr(control_panel_ref, "set_sar_size_km"): control_panel_ref.set_sar_size_km("N/A") # Mouse Coordinates Label if hasattr(control_panel_ref, "set_mouse_coordinates"): control_panel_ref.set_mouse_coordinates("N/A", "N/A") # Statistics Labels initial_stats = self.state.get_statistics() drop_txt = ( f"Drop(Q): S={initial_stats['dropped_sar_q']}," f"M={initial_stats['dropped_mfd_q']}," f"Tk={initial_stats['dropped_tk_q']}," f"Mo={initial_stats['dropped_mouse_q']}" ) incmpl_txt = ( f"Incmpl(RX): S={initial_stats['incomplete_sar_rx']}," f"M={initial_stats['incomplete_mfd_rx']}" ) if hasattr(control_panel_ref, "dropped_label"): control_panel_ref.dropped_label.config(text=drop_txt) if hasattr(control_panel_ref, "incomplete_label"): control_panel_ref.incomplete_label.config(text=incmpl_txt) logging.debug(f"{log_prefix} Initial UI state labels set.") except tk.TclError as e: # Ignore Tcl errors if window is destroyed during init logging.warning( f"{log_prefix} Error setting initial UI labels (TclError): {e}" ) except Exception as e: logging.exception( f"{log_prefix} Unexpected error setting initial UI labels:" ) # --- Network Data Handlers --- def handle_new_sar_data( self, normalized_image_uint8: np.ndarray, geo_info_radians: Dict[str, Any] ): """Safely handles new SAR data received from the network receiver.""" log_prefix = "[App CB SAR]" geo_valid = geo_info_radians.get("valid", False) logging.debug( f"{log_prefix} Handling new SAR data (Shape: {normalized_image_uint8.shape}, " f"Geo Valid: {geo_valid})..." ) if self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown detected. Ignoring.") return # Update state first self.state.set_sar_data(normalized_image_uint8, geo_info_radians) logging.debug(f"{log_prefix} SAR data/GeoInfo updated in AppState.") # Schedule main thread processing if self.root and self.root.winfo_exists(): logging.debug( f"{log_prefix} Scheduling _process_sar_update_on_main_thread." ) self.root.after_idle(self._process_sar_update_on_main_thread) else: logging.warning( f"{log_prefix} Cannot schedule SAR update: Root window gone." ) def handle_new_mfd_data(self, image_indices: np.ndarray): """Safely handles new MFD index data received from the network receiver.""" log_prefix = "[App CB MFD]" logging.debug( f"{log_prefix} Handling new MFD index data (Shape: {image_indices.shape})..." ) if self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown detected. Ignoring.") return # Update state first self.state.set_mfd_indices(image_indices) logging.debug(f"{log_prefix} MFD indices updated in AppState.") # Schedule main thread processing if self.root and self.root.winfo_exists(): logging.debug( f"{log_prefix} Scheduling _process_mfd_update_on_main_thread." ) self.root.after_idle(self._process_mfd_update_on_main_thread) else: logging.warning( f"{log_prefix} Cannot schedule MFD update: Root window gone." ) # --- Main Thread Processing Triggers --- def _process_sar_update_on_main_thread(self): """ Processes SAR updates scheduled to run on the main GUI thread. Updates UI labels, triggers image pipeline, triggers map update, and handles KML generation/cleanup. """ log_prefix = "[App MainThread SAR Update]" if self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown detected. Skipping.") return logging.debug(f"{log_prefix} Processing scheduled SAR update...") # 1. Update UI Labels self._update_sar_ui_labels() # 2. Trigger Image Pipeline for Display logging.debug(f"{log_prefix} Calling image_pipeline.process_sar_for_display...") try: if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_sar_for_display() else: logging.error(f"{log_prefix} ImagePipeline not available.") except Exception as e: logging.exception(f"{log_prefix} Error calling ImagePipeline for SAR:") # 3. Trigger Map Update geo_info = self.state.current_sar_geo_info is_geo_valid = geo_info and geo_info.get("valid", False) map_manager_active = ( hasattr(self, "map_integration_manager") and self.map_integration_manager is not None ) if map_manager_active and is_geo_valid: logging.debug( f"{log_prefix} Calling map_integration_manager.update_map_overlay..." ) try: # Pass current normalized SAR data and geo info # Recorder uses raw data, overlay uses normalized+processed data self.map_integration_manager.update_map_overlay( self.state.current_sar_normalized, geo_info ) except Exception as e: logging.exception(f"{log_prefix} Error calling map manager update:") elif config.ENABLE_MAP_OVERLAY: # Log reason for skipping only if map enabled if not map_manager_active: logging.debug( f"{log_prefix} Skipping map update: MapIntegrationManager not available." ) elif not is_geo_valid: logging.debug(f"{log_prefix} Skipping map update: GeoInfo not valid.") # 4. KML Generation and Cleanup Logic (if geo valid) if is_geo_valid and config.ENABLE_KML_GENERATION: self._handle_kml_generation(geo_info) # Use helper method # 5. Update FPS Statistics for SAR self._update_fps_stats("sar") logging.debug(f"{log_prefix} Finished processing SAR update.") def _handle_kml_generation(self, geo_info): """Handles KML generation, cleanup, and optional launch.""" kml_log_prefix = "[App KML]" logging.debug(f"{kml_log_prefix} KML generation enabled.") try: kml_dir = config.KML_OUTPUT_DIRECTORY os.makedirs(kml_dir, exist_ok=True) timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") kml_filename = f"sar_footprint_{timestamp}.kml" kml_output_path = os.path.join(kml_dir, kml_filename) logging.debug( f"{kml_log_prefix} Calling generate_sar_kml for path: {kml_output_path}" ) # Generate KML using utility function kml_success = generate_sar_kml(geo_info, kml_output_path) if kml_success: logging.info( f"{kml_log_prefix} KML file generated successfully: {kml_output_path}" ) # --- Call KML Cleanup --- logging.debug(f"{kml_log_prefix} Calling KML cleanup utility...") try: # Use utility function for cleanup cleanup_old_kml_files( config.KML_OUTPUT_DIRECTORY, config.MAX_KML_FILES ) except Exception as cleanup_e: logging.exception( f"{kml_log_prefix} Error during KML cleanup call:" ) # --- End Cleanup Call --- # --- Optional Launch --- if config.AUTO_LAUNCH_GOOGLE_EARTH: logging.debug(f"{kml_log_prefix} Auto-launch Google Earth enabled.") launch_google_earth(kml_output_path) # Use utility function else: logging.debug( f"{kml_log_prefix} Auto-launch Google Earth disabled." ) else: logging.error(f"{kml_log_prefix} KML file generation failed.") except ImportError as ie: # Log error if required libraries for KML/Utils are missing logging.error( f"{kml_log_prefix} Cannot generate/cleanup KML due to missing library: {ie}" ) except Exception as e: logging.exception( f"{kml_log_prefix} Error during KML generation/launch/cleanup process:" ) def _process_mfd_update_on_main_thread(self): """Processes MFD updates scheduled to run on the main GUI thread.""" log_prefix = "[App MainThread MFD Update]" if self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown detected. Skipping.") return logging.debug(f"{log_prefix} Processing scheduled MFD update...") # 1. Trigger Image Processing Pipeline logging.debug(f"{log_prefix} Calling image_pipeline.process_mfd_for_display...") try: if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_mfd_for_display() else: logging.error(f"{log_prefix} ImagePipeline not available.") except Exception as e: logging.exception(f"{log_prefix} Error calling ImagePipeline for MFD:") # 2. Update FPS Statistics for MFD self._update_fps_stats("mfd") logging.debug(f"{log_prefix} Finished processing MFD update.") def _update_sar_ui_labels(self): """Helper method to update SAR related UI labels from AppState.""" log_prefix = "[App MainThread SAR Update]" # Safely access control_panel instance control_panel_ref = getattr(self, "control_panel", None) if not control_panel_ref or not control_panel_ref.winfo_exists(): return # Skip if UI not ready geo_info = self.state.current_sar_geo_info center_txt = "Image Ref: N/A" orient_txt = "N/A" size_txt = "N/A" is_valid_geo = geo_info and geo_info.get("valid", False) if is_valid_geo: try: lat_d = math.degrees(geo_info["lat"]) lon_d = math.degrees(geo_info["lon"]) orient_d = math.degrees(geo_info["orientation"]) # Format using utility function lat_s = decimal_to_dms(lat_d, is_latitude=True) lon_s = decimal_to_dms(lon_d, is_latitude=False) orient_txt = f"{orient_d:.2f}°" center_txt = f"Image Ref: Lat={lat_s}, Lon={lon_s}" # Calculate size in Km scale_x = geo_info.get("scale_x", 0.0) scale_y = geo_info.get("scale_y", 0.0) width_px = geo_info.get("width_px", 0) height_px = geo_info.get("height_px", 0) if scale_x > 0 and width_px > 0 and scale_y > 0 and height_px > 0: width_km = (scale_x * width_px) / 1000.0 height_km = (scale_y * height_px) / 1000.0 size_txt = f"W: {width_km:.1f} km, H: {height_km:.1f} km" else: size_txt = "Invalid Scale/Dims" except KeyError as ke: logging.error( f"{log_prefix} Missing key '{ke}' in geo_info for UI update." ) center_txt = "Ref: Data Error" orient_txt = "Data Error" size_txt = "Data Error" is_valid_geo = False except Exception as e: logging.error(f"{log_prefix} Error formatting geo info for UI: {e}") center_txt = "Ref: Format Error" orient_txt = "Format Error" size_txt = "Format Error" is_valid_geo = False # Safely update UI elements, checking existence first try: if hasattr(control_panel_ref, "sar_center_label"): control_panel_ref.sar_center_label.config(text=center_txt) if hasattr(control_panel_ref, "set_sar_orientation"): control_panel_ref.set_sar_orientation(orient_txt) if hasattr(control_panel_ref, "set_sar_size_km"): control_panel_ref.set_sar_size_km(size_txt) # Clear mouse coords if geo becomes invalid if not is_valid_geo and hasattr(control_panel_ref, "set_mouse_coordinates"): control_panel_ref.set_mouse_coordinates("N/A", "N/A") except tk.TclError as ui_err: if not self.state.shutting_down: logging.warning( f"{log_prefix} Error updating SAR UI labels (TclError): {ui_err}" ) except Exception as gen_err: logging.exception(f"{log_prefix} Unexpected error updating SAR UI labels:") def _update_fps_stats(self, img_type: str): """Helper function to update FPS counters in AppState.""" now = time.time() log_prefix = "[App FPS Update]" try: if img_type == "sar": self.state.sar_frame_count += 1 elapsed = now - self.state.sar_update_time # Update FPS only after a certain interval if elapsed >= config.LOG_UPDATE_INTERVAL: self.state.sar_fps = self.state.sar_frame_count / elapsed self.state.sar_update_time = now self.state.sar_frame_count = 0 logging.debug( f"{log_prefix} SAR FPS calculated: {self.state.sar_fps:.2f}" ) elif img_type == "mfd": self.state.mfd_frame_count += 1 elapsed = now - self.state.mfd_start_time if elapsed >= config.LOG_UPDATE_INTERVAL: self.state.mfd_fps = self.state.mfd_frame_count / elapsed self.state.mfd_start_time = now self.state.mfd_frame_count = 0 logging.debug( f"{log_prefix} MFD FPS calculated: {self.state.mfd_fps:.2f}" ) except Exception as e: logging.warning( f"{log_prefix} Error updating FPS stats for '{img_type}': {e}" ) # --- Trigger Methods --- def _trigger_sar_update(self): """Triggers a SAR image update processing via ImagePipeline if not in test mode.""" log_prefix = "[App Trigger SAR]" if self.state.shutting_down: return if not self.state.test_mode_active: logging.debug(f"{log_prefix} Triggering SAR update via ImagePipeline.") try: if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_sar_for_display() else: logging.error(f"{log_prefix} 'image_pipeline' not found.") except Exception as e: logging.exception( f"{log_prefix} Error calling image_pipeline.process_sar_for_display:" ) else: logging.debug( f"{log_prefix} SAR update trigger skipped (Test Mode active)." ) def _trigger_mfd_update(self): """Triggers an MFD image update processing via ImagePipeline if not in test mode.""" log_prefix = "[App Trigger MFD]" if self.state.shutting_down: return if not self.state.test_mode_active: logging.debug(f"{log_prefix} Triggering MFD update via ImagePipeline.") try: if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_mfd_for_display() else: logging.error(f"{log_prefix} 'image_pipeline' not found.") except Exception as e: logging.exception( f"{log_prefix} Error calling image_pipeline.process_mfd_for_display:" ) else: logging.debug( f"{log_prefix} MFD update trigger skipped (Test Mode active)." ) # --- Periodic Update Scheduling --- def schedule_periodic_updates(self): """Schedules the regular update of the status bar information.""" log_prefix = "[App Status Scheduler]" if self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown detected. Stopping periodic updates.") return try: # Call the status update logic self.update_status() except Exception as e: logging.error( f"{log_prefix} Error during periodic status update execution: {e}" ) # Schedule the next call interval_ms = max(100, int(config.LOG_UPDATE_INTERVAL * 1000)) try: if self.root and self.root.winfo_exists(): self.root.after(interval_ms, self.schedule_periodic_updates) except Exception as e: # Log error only if not shutting down if not self.state.shutting_down: logging.warning(f"{log_prefix} Error rescheduling periodic update: {e}") # --- Initial Image Loading --- 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: logging.debug(f"{log_prefix} Shutdown detected. Aborting.") return logging.info(f"{log_prefix} Initial image loading thread started.") # Schedule status update on main thread if self.root and self.root.winfo_exists(): self.root.after_idle(self.set_status, "Loading initial images...") try: # Ensure test images are generated if test mode enabled if config.ENABLE_TEST_MODE or self.state.test_mode_active: if hasattr(self, "test_mode_manager") and self.test_mode_manager: logging.debug(f"{log_prefix} Ensuring test images are generated...") self.test_mode_manager._ensure_test_images() else: logging.error( f"{log_prefix} TestModeManager not available for test image generation." ) # Load local images if configured if config.USE_LOCAL_IMAGES: logging.debug(f"{log_prefix} Loading local MFD image...") self._load_local_mfd_image() logging.debug(f"{log_prefix} Loading local SAR image...") self._load_local_sar_image() # Schedule setting initial display after loading completes if self.root and self.root.winfo_exists(): logging.debug( f"{log_prefix} Scheduling _set_initial_display_from_loaded_data." ) 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 to random data if file not found or loading fails default_indices = np.random.randint( 0, 256, (config.MFD_HEIGHT, config.MFD_WIDTH), dtype=np.uint8 ) loaded_indices = None try: mfd_path = getattr(config, "MFD_IMAGE_PATH", "") # Safely get path if mfd_path and os.path.exists(mfd_path): # Actual loading logic would go here if supported logging.warning( f"{log_prefix} Local MFD loading from file NYI. Using random data." ) loaded_indices = default_indices # Placeholder else: logging.warning( f"{log_prefix} Local MFD image file not found or path not set: " f"{mfd_path}. Using random data." ) loaded_indices = default_indices self.state.local_mfd_image_data_indices = loaded_indices 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 to zeros if loading fails default_raw_data = np.zeros( (config.SAR_HEIGHT, config.SAR_WIDTH), dtype=config.SAR_DATA_TYPE ) loaded_raw_data = None try: # Use the load_image utility from image_processing loaded_raw_data = load_image( config.SAR_IMAGE_PATH, config.SAR_DATA_TYPE ) # Logs internally if loaded_raw_data is None or loaded_raw_data.size == 0: logging.error( f"{log_prefix} Failed to load local SAR raw data. Using zeros." ) loaded_raw_data = default_raw_data else: logging.info( f"{log_prefix} Loaded local SAR raw data " f"(shape: {loaded_raw_data.shape})." ) self.state.local_sar_image_data_raw = loaded_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 the initial display based on loaded data and mode.""" log_prefix = "[App Init Display]" if self.state.shutting_down: logging.debug(f"{log_prefix} Shutdown detected. Skipping.") return is_test = self.state.test_mode_active is_local = config.USE_LOCAL_IMAGES if not is_test and is_local: # Local Image Mode logging.info(f"{log_prefix} Setting initial display from local images.") # Set MFD display 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() else: logging.warning(f"{log_prefix} Local MFD data not loaded.") # Set SAR display if self.state.local_sar_image_data_raw is not None: # Handles normalization, state update, UI reset, pipeline call self.set_initial_sar_image(self.state.local_sar_image_data_raw) else: logging.warning( f"{log_prefix} Local SAR data not loaded. Displaying black." ) self.set_initial_sar_image(None) # Display black elif is_test: # Test Mode logging.info( f"{log_prefix} Test mode active. Display handled by TestModeManager timers." ) # Initial display is triggered by TestModeManager.activate() starting timers else: # Network Mode logging.info( f"{log_prefix} Network mode active. Displaying initial placeholders." ) self._show_network_placeholders() # Set Final Initial Status (only if map isn't loading) map_manager_active = ( hasattr(self, "map_integration_manager") and self.map_integration_manager is not None ) map_thread_running = False if map_manager_active: map_thread_attr = getattr( self.map_integration_manager, "_map_initial_display_thread", None ) if map_thread_attr and isinstance(map_thread_attr, threading.Thread): map_thread_running = map_thread_attr.is_alive() map_is_loading = map_manager_active and map_thread_running if not map_is_loading: final_status = "" if is_test: final_status = "Ready (Test Mode)" elif is_local: final_status = "Ready (Local Mode)" else: # Network mode if self.udp_socket: final_status = f"Listening UDP {self.local_ip}:{self.local_port}" else: final_status = "Error: No Socket" self.set_status(final_status) logging.debug( f"{log_prefix} Set final initial status (map not loading): '{final_status}'" ) else: logging.debug( f"{log_prefix} Initial map display is still loading. Status update deferred." ) def set_initial_sar_image(self, raw_image_data: Optional[np.ndarray]): """Processes provided raw SAR data (or None for black image), updates AppState, resets UI, triggers display.""" log_prefix = "[App Init SAR Image]" if self.state.shutting_down: return logging.debug(f"{log_prefix} Processing initial raw SAR image...") normalized: Optional[np.ndarray] = None if raw_image_data is not None and raw_image_data.size > 0: try: # Use utility function for normalization normalized = normalize_image(raw_image_data, target_type=np.uint8) except Exception as e: logging.exception( f"{log_prefix} Error during initial SAR normalization:" ) else: logging.warning(f"{log_prefix} Provided raw SAR data is invalid or empty.") # Update state with normalized image or fallback if normalized is not None: self.state.current_sar_normalized = normalized logging.debug( f"{log_prefix} Stored normalized initial SAR image in AppState." ) else: logging.warning( f"{log_prefix} Using black image fallback for initial SAR display." ) # Ensure state has a valid array, fill with 0 if needed 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) # Mark geo invalid for local/black image and reset UI labels self.state.current_sar_geo_info["valid"] = False self._reset_ui_geo_info() # Trigger display via pipeline logging.debug( f"{log_prefix} Triggering display of initial SAR image via ImagePipeline." ) if hasattr(self, "image_pipeline") and self.image_pipeline: self.image_pipeline.process_sar_for_display() else: logging.error(f"{log_prefix} ImagePipeline not available for initial SAR.") logging.info(f"{log_prefix} Initial SAR image processed and queued.") # --- Mode Switching UI Actions --- def activate_test_mode_ui_actions(self): """Handles UI and state changes needed when activating test mode.""" log_prefix = "[App Test Activate]" logging.info( f"{log_prefix} Performing UI/State actions for Test Mode activation." ) self.set_status("Activating Test Mode...") # Reset geo display as test mode doesn't use real geo data self._reset_ui_geo_info() # Clear display queues to avoid showing old network/local images clear_queue(self.mfd_queue) clear_queue(self.sar_queue) logging.debug(f"{log_prefix} Display queues cleared.") # Set final status self.set_status("Ready (Test Mode)") logging.info(f"{log_prefix} Test Mode UI/State actions complete.") def deactivate_test_mode_ui_actions(self): """Handles UI and state changes needed when deactivating test mode.""" log_prefix = "[App Test Deactivate]" logging.info( f"{log_prefix} Performing UI/State actions for Test Mode deactivation -> Normal Mode." ) self.set_status("Activating Normal Mode...") # Clear queues to remove any lingering test images clear_queue(self.mfd_queue) clear_queue(self.sar_queue) logging.debug(f"{log_prefix} Display queues cleared.") # Reset geo display self._reset_ui_geo_info() if config.USE_LOCAL_IMAGES: # Local Image Mode Restoration logging.info( f"{log_prefix} Restoring display from local images stored in AppState." ) # Restore MFD 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() else: logging.warning(f"{log_prefix} No local MFD data to restore.") # Restore SAR if self.state.local_sar_image_data_raw is not None: self.set_initial_sar_image(self.state.local_sar_image_data_raw) else: logging.warning( f"{log_prefix} No local SAR data to restore. Displaying black." ) self.set_initial_sar_image(None) self.set_status("Ready (Local Mode)") else: # Network Mode Restoration logging.info( f"{log_prefix} Switched to Network mode. Displaying placeholders." ) self._show_network_placeholders() # Set status based on socket state socket_ok = self.udp_socket is not None and self.udp_socket.fileno() != -1 status = ( f"Listening UDP {self.local_ip}:{self.local_port}" if socket_ok else "Error: No UDP Socket" ) self.set_status(status) logging.info(f"{log_prefix} Normal Mode UI/State actions complete.") def _reset_ui_geo_info(self): """Schedules UI reset for geo-related labels on the main thread.""" log_prefix = "[App UI Reset]" # Safely access control_panel instance control_panel_ref = getattr(self, "control_panel", None) if self.root and self.root.winfo_exists() and control_panel_ref: # Schedule individual label updates via after_idle if hasattr(control_panel_ref, "set_sar_orientation"): self.root.after_idle( lambda: control_panel_ref.set_sar_orientation("N/A") ) if hasattr(control_panel_ref, "set_mouse_coordinates"): self.root.after_idle( lambda: control_panel_ref.set_mouse_coordinates("N/A", "N/A") ) if hasattr(control_panel_ref, "sar_center_label"): self.root.after_idle( lambda: control_panel_ref.sar_center_label.config( text="Image Ref: Lat=N/A, Lon=N/A" ) ) if hasattr(control_panel_ref, "set_sar_size_km"): self.root.after_idle(lambda: control_panel_ref.set_sar_size_km("N/A")) logging.debug(f"{log_prefix} Geo UI label reset scheduled.") 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 and state due to activation failure." ) # Safely access control panel and variable control_panel_ref = getattr(self, "control_panel", None) test_var_ref = ( getattr(control_panel_ref, "test_image_var", None) if control_panel_ref else None ) if self.root and self.root.winfo_exists() and test_var_ref: try: # Schedule the uncheck operation on the main thread self.root.after_idle(test_var_ref.set, 0) except Exception as e: logging.warning( f"{log_prefix} Failed to schedule uncheck of test mode checkbox: {e}" ) # Reset state flag regardless of UI success if hasattr(self, "state"): self.state.test_mode_active = False logging.debug(f"{log_prefix} Test mode state flag reverted to False.") def _show_network_placeholders(self): """Queues placeholder images for MFD and SAR displays.""" log_prefix = "[App Placeholders]" logging.debug(f"{log_prefix} Queueing network placeholder images.") try: # Create dark gray placeholder for MFD ph_mfd = np.full( (config.INITIAL_MFD_HEIGHT, config.INITIAL_MFD_WIDTH, 3), 30, # Dark gray dtype=np.uint8, ) # Create medium gray placeholder for SAR (using current display size) ph_sar = np.full( (self.state.sar_display_height, self.state.sar_display_width, 3), 60, # Medium gray dtype=np.uint8, ) # Put placeholders onto respective display queues 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 placeholder images:" ) # --- Mouse Coordinate Handling --- def process_mouse_queue(self): """Processes raw mouse coords from queue, calculates geo coords, queues result.""" log_prefix = "[App GeoCalc]" if self.state.shutting_down: return raw_coords = None try: # Non-blocking get from queue raw_coords = self.mouse_queue.get(block=False) self.mouse_queue.task_done() except queue.Empty: pass # Normal case, queue is empty except Exception as e: logging.exception(f"{log_prefix} Error getting from mouse queue:") pass # Continue processing loop if ( raw_coords is not None and 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: str = "N/A" lon_s: str = "N/A" # Check if all required geo info is present and valid 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 "lat" in geo and "lon" in geo and "ref_x" in geo and "ref_y" in geo and "orientation" in geo ) if is_geo_valid_for_calc: logging.debug( f"{log_prefix} Processing mouse coords: Display({x_disp},{y_disp}) " "with valid GeoInfo." ) try: # Extract necessary values orig_w = geo["width_px"] orig_h = geo["height_px"] scale_x = geo["scale_x"] scale_y = geo["scale_y"] ref_x = geo["ref_x"] ref_y = geo["ref_y"] ref_lat_rad = geo["lat"] ref_lon_rad = geo["lon"] original_orient_rad = geo.get("orientation", 0.0) # Use original orientation for inverse rotation calculation angle_for_inverse_rotation_rad = original_orient_rad # Normalize display coordinates (0.0 to 1.0) nx_disp = x_disp / disp_w ny_disp = y_disp / disp_h nx_orig_norm, ny_orig_norm = nx_disp, ny_disp # Apply Inverse Rotation if needed if abs(angle_for_inverse_rotation_rad) > 1e-4: logging.debug( f"{log_prefix} Applying inverse rotation " f"(angle: {math.degrees(angle_for_inverse_rotation_rad):.2f} deg)..." ) arad_inv = angle_for_inverse_rotation_rad cosa = math.cos(arad_inv) sina = math.sin(arad_inv) # Rotate around center (0.5, 0.5) of normalized coords cx, cy = 0.5, 0.5 tx = nx_disp - cx ty = ny_disp - cy rtx = tx * cosa - ty * sina rty = tx * sina + ty * cosa nx_orig_norm = rtx + cx ny_orig_norm = rty + cy logging.debug( f"{log_prefix} Inverse rotation applied. " f"Norm coords: ({nx_orig_norm:.4f}, {ny_orig_norm:.4f})" ) # Convert normalized, un-rotated coords 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)) logging.debug( f"{log_prefix} Calculated original pixel coords: ({orig_x:.2f}, {orig_y:.2f})" ) # Geodetic Calculation (simplified approach) # Pixel offset from reference point pixel_delta_x = orig_x - ref_x pixel_delta_y = ref_y - orig_y # y inverted in pixel space # Convert pixel offset to meter offset meters_delta_x = pixel_delta_x * scale_x # Easting offset meters_delta_y = pixel_delta_y * scale_y # Northing offset logging.debug( f"{log_prefix} Offset (meters): dX={meters_delta_x:.1f} (E), " f"dY={meters_delta_y:.1f} (N)" ) # Approximate conversion from meters to degrees M_PER_DLAT = 111132.954 # Approx meters per degree latitude M_PER_DLON_EQ = ( 111319.488 # Approx meters per degree longitude at equator ) # Adjust longitude conversion based on latitude m_per_dlon = max(abs(M_PER_DLON_EQ * math.cos(ref_lat_rad)), 1e-3) # Calculate degree offsets lat_offset_deg = meters_delta_y / M_PER_DLAT lon_offset_deg = meters_delta_x / m_per_dlon logging.debug( f"{log_prefix} Offset (degrees): dLat={lat_offset_deg:.6f}, " f"dLon={lon_offset_deg:.6f}" ) # Calculate final coordinates final_lat_deg = math.degrees(ref_lat_rad) + lat_offset_deg final_lon_deg = math.degrees(ref_lon_rad) + lon_offset_deg logging.debug( f"{log_prefix} Final coords (dec deg): Lat={final_lat_deg:.6f}, " f"Lon={final_lon_deg:.6f}" ) # Format Output to DMS string if valid 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 = decimal_to_dms(final_lat_deg, is_latitude=True) lon_s = decimal_to_dms(final_lon_deg, is_latitude=False) # Check if DMS conversion itself failed dms_failed = ( "Error" in lat_s or "Invalid" in lat_s or "Error" in lon_s or "Invalid" in lon_s ) if dms_failed: logging.warning(f"{log_prefix} DMS conversion failed.") lat_s, lon_s = "Error DMS", "Error DMS" else: logging.warning( f"{log_prefix} Calculated coordinates out of range." ) 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) result_payload = (lat_s, lon_s) self.put_mouse_coordinates_queue(result_payload) # Reschedule processor if not self.state.shutting_down: self._reschedule_queue_processor(self.process_mouse_queue, delay=50) def put_mouse_coordinates_queue(self, coords_tuple: Tuple[str, str]): """Puts processed mouse coords tuple (DMS strings or status) onto Tkinter queue.""" log_prefix = "[App Mouse Queue Put]" if self.state.shutting_down: return command = "MOUSE_COORDS" payload = coords_tuple logging.debug( f"{log_prefix} Putting command '{command}' with payload {payload} onto tkinter_queue." ) put_queue( self.tkinter_queue, (command, payload), queue_name="tkinter", app_instance=self, # Pass app instance for context ) # --- Queue Processors --- def process_sar_queue(self): """Gets processed SAR image from queue and displays it.""" log_prefix = "[App QProc SAR]" if self.state.shutting_down: return image_to_display = None try: image_to_display = self.sar_queue.get(block=False) self.sar_queue.task_done() except queue.Empty: pass # Normal case except Exception as e: logging.exception(f"{log_prefix} Error getting from SAR display queue:") if image_to_display is not None: logging.debug(f"{log_prefix} Dequeued SAR image. Calling DisplayManager...") if hasattr(self, "display_manager") and self.display_manager: try: self.display_manager.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.") # Reschedule processor 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]" if self.state.shutting_down: return image_to_display = None try: image_to_display = self.mfd_queue.get(block=False) self.mfd_queue.task_done() except queue.Empty: pass # Normal case except Exception as e: logging.exception(f"{log_prefix} Error getting from MFD display queue:") if image_to_display is not None: logging.debug(f"{log_prefix} Dequeued MFD image. Calling DisplayManager...") if hasattr(self, "display_manager") and self.display_manager: try: self.display_manager.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.") # Reschedule processor 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, map redraw) from queue to update UI.""" log_prefix = "[App QProc Tkinter]" if self.state.shutting_down: return item = None try: # Non-blocking get from queue item = self.tkinter_queue.get(block=False) self.tkinter_queue.task_done() except queue.Empty: pass # Normal case except Exception as e: logging.exception(f"{log_prefix} Error getting from Tkinter queue:") item = None # Ensure item is None on error if item is not None: try: # Expect items to be (command, payload) tuples if isinstance(item, tuple) and len(item) == 2: command, payload = item logging.debug( f"{log_prefix} Dequeued Command:'{command}', " f"Payload Type:{type(payload)}" ) if command == "MOUSE_COORDS": self._handle_mouse_coords_update(payload) elif command == "SHOW_MAP": # Store last map PIL image in AppState before display if Image is not None and isinstance(payload, Image.Image): self.state.last_map_image_pil = payload.copy() logging.debug( f"{log_prefix} Stored last map image (PIL) in AppState." ) elif payload is None: # Clear state if None payload (e.g., initial placeholder) self.state.last_map_image_pil = None logging.debug( f"{log_prefix} Cleared last map image in AppState " "(payload was None)." ) # Delegate display to handler self._handle_show_map_update(payload) elif command == "REDRAW_MAP": logging.debug(f"{log_prefix} Handling REDRAW_MAP command.") # Trigger display using the last known PIL image. # MapIntegrationManager.display_map will handle applying current settings. if self.state.last_map_image_pil: logging.debug( f"{log_prefix} Re-displaying using last stored map image as base." ) self._handle_show_map_update(self.state.last_map_image_pil) else: # If no image is cached, try triggering a full update logging.warning( f"{log_prefix} REDRAW_MAP requested but no previous map image found. " "Attempting full map update based on current SAR data." ) self._trigger_map_update_from_sar() else: # Unknown command logging.warning( f"{log_prefix} Unknown command received: {command}" ) else: # Unexpected item type logging.warning( f"{log_prefix} Dequeued unexpected item type: {type(item)}" ) except Exception as e: logging.exception( f"{log_prefix} Error processing dequeued Tkinter item:" ) # Reschedule next check if not self.state.shutting_down: self._reschedule_queue_processor(self.process_tkinter_queue, delay=100) def _trigger_map_update_from_sar(self): """Helper to trigger a full map update based on current SAR data.""" log_prefix = "[App Trigger Map Update]" map_manager_exists = ( hasattr(self, "map_integration_manager") and self.map_integration_manager is not None ) # Check prerequisites if ( self.state.shutting_down or not config.ENABLE_MAP_OVERLAY or not map_manager_exists ): logging.debug(f"{log_prefix} Skipping map update trigger.") return geo_info = self.state.current_sar_geo_info sar_data = self.state.current_sar_normalized # Use normalized for overlay is_geo_valid = geo_info and geo_info.get("valid", False) is_sar_valid = sar_data is not None and sar_data.size > 0 if is_geo_valid and is_sar_valid: logging.debug( f"{log_prefix} Triggering full map update using current SAR data..." ) try: # Directly call update_map_overlay. Assumes this process_tkinter_queue # runs on the main thread, so direct call is safe. self.map_integration_manager.update_map_overlay(sar_data, geo_info) except Exception as e: logging.exception(f"{log_prefix} Error during triggered map update:") else: logging.warning( f"{log_prefix} Cannot trigger map update: Geo valid={is_geo_valid}, " f"SAR valid={is_sar_valid}." ) # Optionally queue a command to show a placeholder map put_queue( self.tkinter_queue, ("SHOW_MAP", None), # Command, None payload for placeholder "tkinter", self, ) def _handle_mouse_coords_update(self, payload: Optional[Tuple[str, str]]): """Updates the mouse coordinates UI label.""" log_prefix = "[App QProc Tkinter]" lat_s, lon_s = ("N/A", "N/A") # Default values if payload is not None: if isinstance(payload, tuple) and len(payload) == 2: lat_s, lon_s = payload else: logging.warning( f"{log_prefix} Invalid payload for MOUSE_COORDS: {type(payload)}" ) lat_s, lon_s = ("Error", "Error") try: # Safely access control panel and method control_panel_ref = getattr(self, "control_panel", None) if control_panel_ref and hasattr( control_panel_ref, "set_mouse_coordinates" ): control_panel_ref.set_mouse_coordinates(lat_s, lon_s) except tk.TclError as e: if not self.state.shutting_down: logging.warning( f"{log_prefix} Error updating mouse coords UI (TclError): {e}" ) except Exception as e: logging.warning(f"{log_prefix} Error updating mouse coords UI: {e}") def _handle_show_map_update(self, payload: Optional[Image.Image]): """Handles the SHOW_MAP/REDRAW_MAP command by delegating display to MapIntegrationManager.""" log_prefix = "[App QProc Tkinter]" logging.debug(f"{log_prefix} Processing SHOW_MAP/REDRAW_MAP...") if hasattr(self, "map_integration_manager") and self.map_integration_manager: try: # Delegate display call to the map manager self.map_integration_manager.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 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 # Use MFD FPS for display queues? calculated_delay = 1000 / (target_fps * 1.5) if target_fps > 0 else 20 delay = max(10, int(calculated_delay)) else: delay = 100 # Default delay for other queues (e.g., 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 labels periodically.""" log_prefix = "[App Status Update]" if not hasattr(self, "state") or self.state.shutting_down: return # Check if map is still loading initially to avoid overwriting status map_loading = False try: statusbar_ref = getattr(self, "statusbar", None) if ( statusbar_ref and statusbar_ref.winfo_exists() and "Loading initial map" in statusbar_ref.cget("text") ): map_loading = True logging.debug( f"{log_prefix} Skipping status update while initial map loads." ) return except Exception: pass # Ignore errors checking status bar text logging.debug(f"{log_prefix} Updating status bar and statistics labels...") stats = self.state.get_statistics() try: # --- Format Status String Components --- mode = ( "Test" if self.state.test_mode_active else ("Local" if config.USE_LOCAL_IMAGES else "Network") ) map_active = ( hasattr(self, "map_integration_manager") and self.map_integration_manager is not None ) map_stat = " MapOn" if config.ENABLE_MAP_OVERLAY and map_active else "" mfd_fps_str = f"MFD:{self.state.mfd_fps:.1f}fps" sar_fps_str = ( f"SAR:{self.state.sar_fps:.1f}fps" if self.state.sar_fps > 0 else "SAR:N/A" ) status_prefix = f"Status: {mode}{map_stat}" status_info = f"{mfd_fps_str} | {sar_fps_str}" full_status = f"{status_prefix} | {status_info}" # --- Format Statistics Strings --- drop_txt = ( f"Drop(Q): S={stats['dropped_sar_q']},M={stats['dropped_mfd_q']}," f"Tk={stats['dropped_tk_q']},Mo={stats['dropped_mouse_q']}" ) incmpl_txt = f"Incmpl(RX): S={stats['incomplete_sar_rx']},M={stats['incomplete_mfd_rx']}" logging.debug(f"{log_prefix} Formatted status strings.") # --- Schedule UI updates via after_idle --- if self.root and self.root.winfo_exists(): # Update status bar statusbar_ref = getattr(self, "statusbar", None) if statusbar_ref and statusbar_ref.winfo_exists(): self.root.after_idle(statusbar_ref.set_status_text, full_status) # Update statistics labels in control panel control_panel_ref = getattr(self, "control_panel", None) if control_panel_ref: # Update dropped label dropped_label_ref = getattr( control_panel_ref, "dropped_label", None ) if dropped_label_ref and dropped_label_ref.winfo_exists(): self.root.after_idle( dropped_label_ref.config, {"text": drop_txt} ) # Update incomplete label incomplete_label_ref = getattr( control_panel_ref, "incomplete_label", None ) if incomplete_label_ref and incomplete_label_ref.winfo_exists(): self.root.after_idle( incomplete_label_ref.config, {"text": incmpl_txt} ) except tk.TclError as e: if not self.state.shutting_down: logging.warning(f"{log_prefix} TclError updating status UI: {e}") except Exception as e: if not self.state.shutting_down: logging.exception(f"{log_prefix} Error formatting/updating status UI:") # --- Cleanup --- def close_app(self): """Performs graceful shutdown of the application and its components.""" log_prefix = "[App Shutdown]" if hasattr(self, "state") and self.state.shutting_down: logging.warning(f"{log_prefix} Close already initiated.") return if not hasattr(self, "state"): # Log critical error and exit if state missing during shutdown attempt logging.error(f"{log_prefix} Cannot shutdown: AppState not found.") sys.exit(1) logging.info(f"{log_prefix} Starting shutdown sequence...") self.state.shutting_down = True try: # Attempt final status update self.set_status("Closing...") except Exception: pass # Ignore errors during final status update # Stop test mode timers logging.debug(f"{log_prefix} Stopping TestModeManager timers...") if hasattr(self, "test_mode_manager") and self.test_mode_manager: self.test_mode_manager.stop_timers() # Shutdown map integration logging.debug(f"{log_prefix} Shutting down MapIntegrationManager...") if hasattr(self, "map_integration_manager") and self.map_integration_manager: self.map_integration_manager.shutdown() logging.debug(f"{log_prefix} Signalling periodic updates/processors to stop.") # Close network resources logging.debug(f"{log_prefix} Closing UDP socket...") if self.udp_socket: close_udp_socket(self.udp_socket) self.udp_socket = None # Wait for receiver thread if self.udp_thread and self.udp_thread.is_alive(): logging.debug(f"{log_prefix} Waiting for UDP receiver thread...") self.udp_thread.join(timeout=0.5) # Wait briefly if self.udp_thread.is_alive(): logging.warning( f"{log_prefix} UDP receiver thread did not exit cleanly." ) else: logging.debug(f"{log_prefix} UDP receiver thread exited.") # Shutdown worker pool worker_pool = None if hasattr(self, "udp_receiver") and self.udp_receiver: worker_pool = getattr(self.udp_receiver, "executor", None) if worker_pool: logging.info(f"{log_prefix} Shutting down worker pool...") try: # Cancel pending futures and don't wait for running ones worker_pool.shutdown(wait=False, cancel_futures=True) except Exception as e: logging.exception( f"{log_prefix} Exception during worker_pool shutdown: {e}" ) # Destroy display windows logging.debug(f"{log_prefix} Destroying display windows via DisplayManager...") if hasattr(self, "display_manager"): self.display_manager.destroy_windows() # Small waitKey for OpenCV window handling try: logging.debug(f"{log_prefix} Final cv2.waitKey(5)...") cv2.waitKey(5) except Exception as e: logging.warning(f"{log_prefix} Error during final cv2.waitKey: {e}") # Destroy Tkinter window logging.info(f"{log_prefix} Requesting Tkinter root window destruction...") try: if self.root and self.root.winfo_exists(): self.root.destroy() logging.info(f"{log_prefix} Tkinter root window destroyed.") except Exception as e: logging.exception(f"{log_prefix} Error destroying Tkinter window:") logging.info("-----------------------------------------") logging.info(f"{log_prefix} Application close sequence finished.") logging.info("-----------------------------------------") sys.exit(0) # Ensure clean exit # --- Main Execution Block --- if __name__ == "__main__": main_log_prefix = "[App Main]" root = None # Use a distinct name for the application instance variable app_instance = None try: # Check map dependencies early if config.ENABLE_MAP_OVERLAY and not MAP_MODULES_LOADED: logging.critical( f"{main_log_prefix} Map Overlay enabled but modules failed to load. " "Cannot start." ) sys.exit(1) logging.debug(f"{main_log_prefix} Creating main Tkinter window...") # Create window (icon setting is handled inside ControlPanelApp init) root = create_main_window( title="Control Panel", min_width=config.TKINTER_MIN_WIDTH, min_height=config.TKINTER_MIN_HEIGHT, x_pos=10, # Initial position y_pos=10, ) logging.debug(f"{main_log_prefix} Main Tkinter window created.") logging.debug(f"{main_log_prefix} Initializing App class (ControlPanelApp)...") # Instantiate the main application class app_instance = ControlPanelApp(root) logging.debug(f"{main_log_prefix} App class initialized.") # Set close protocol to call the instance's close method root.protocol("WM_DELETE_WINDOW", app_instance.close_app) logging.debug(f"{main_log_prefix} WM_DELETE_WINDOW protocol set.") logging.info(f"{main_log_prefix} Starting Tkinter main event loop...") root.mainloop() logging.info(f"{main_log_prefix} Tkinter main event loop finished.") except SystemExit as exit_e: # Handle clean exit vs error exit if exit_e.code == 0: logging.info(f"{main_log_prefix} Application exited normally.") else: logging.warning( f"{main_log_prefix} Application exited with error code {exit_e.code}." ) except Exception as e: # Log critical unhandled exceptions logging.critical( f"{main_log_prefix} UNHANDLED EXCEPTION OCCURRED:", exc_info=True ) # Attempt emergency cleanup if app was partially initialized if ( app_instance and hasattr(app_instance, "state") and not app_instance.state.shutting_down ): logging.error(f"{main_log_prefix} Attempting emergency cleanup...") try: app_instance.close_app() except SystemExit: pass # Ignore SystemExit during cleanup except Exception as cleanup_e: logging.exception( f"{main_log_prefix} Error during emergency cleanup: {cleanup_e}" ) sys.exit(1) # Exit with error code after logging finally: # Final cleanup actions regardless of success or failure logging.info(f"{main_log_prefix} Application finally block reached.") logging.debug( f"{main_log_prefix} Final check: Destroying any remaining OpenCV windows..." ) try: cv2.destroyAllWindows() except Exception as cv_err: logging.warning( f"{main_log_prefix} Exception during final cv2.destroyAllWindows(): {cv_err}" ) logging.info("================ Application End ================") logging.shutdown() # Ensure logging resources are released