SXXXXXXX_ControlPanel/app.py

2412 lines
125 KiB
Python

# 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
# --- Third-party imports ---
import tkinter as tk
from tkinter import ttk
from tkinter import colorchooser
import numpy as np
import screeninfo
# Conditional map imports are handled further down
# --- Configuration Import ---
import config
# --- Logging Setup ---
# Import and call the setup function from the dedicated module
try:
from logging_config import setup_logging
# Configure logging as early as possible
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 ---
from ui import ControlPanel, StatusBar, create_main_window
# image_processing functions are used by other modules, App uses ImagePipeline now
# from image_processing import ...
from display import DisplayManager
from utils import put_queue, clear_queue, decimal_to_dms
from network import create_udp_socket, close_udp_socket
from receiver import UdpReceiver
from app_state import AppState # Centralized state
from test_mode_manager import TestModeManager # Manages test mode logic
from image_pipeline import ImagePipeline # Manages normal image processing
# --- Map related imports (Conditional) ---
# Check if map modules are present before attempting specific imports
map_libs_found = True
try:
import mercantile
import pyproj
from PIL import Image # Needed by map modules usually
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 # type: ignore
MapTileManager = None # type: ignore
MapDisplayWindow = None # type: ignore
MapIntegrationManager = None # type: ignore
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 # type: ignore
MapTileManager = None # type: ignore
MapDisplayWindow = None # type: ignore
MapIntegrationManager = None # type: ignore
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 # No direct import needed here as other modules import App
# --- Main Application Class ---
class App:
"""
Main application class. Manages UI, display, processing, network, state,
and orchestrates various managers (Test Mode, Image Pipeline, Map Integration).
"""
# --- Class Attributes ---
# Timers and offsets previously here are now managed by TestModeManager
# Map components previously here are now managed by MapIntegrationManager
# --- 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
# Use hasattr for robustness during init/shutdown
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
if not (hasattr(self, "statusbar") and isinstance(self.statusbar, tk.Widget) and self.statusbar.winfo_exists()):
# Use WARNING if statusbar is gone unexpectedly
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:
if hasattr(self, 'root') and self.root and self.root.winfo_exists():
# 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:
# Read parameters from AppState
# Use max(0.01, ...) for contrast to avoid zero or negative values
contrast_val = max(0.01, self.state.sar_contrast)
brightness_val = self.state.sar_brightness
except AttributeError:
# This case should be covered by the hasattr check above, but keep for safety
logging.error(f"{log_prefix} Error accessing state for SAR LUT parameters (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:
# Create an array representing pixel values 0-255
lut_values = np.arange(256, dtype=np.float32) # Use float for calculation accuracy
# Apply contrast (multiplication) and brightness (addition)
adjusted_values = (lut_values * contrast_val) + brightness_val
# Clip the results to the valid 0-255 range and convert to uint8
lut = np.clip(np.round(adjusted_values), 0, 255).astype(np.uint8)
except Exception as e:
# Log calculation errors and set a default identity LUT
logging.exception(f"{log_prefix} Error calculating SAR B/C LUT:")
# Create identity LUT as fallback
identity_lut = np.arange(256, dtype=np.uint8)
# Store fallback LUT in state
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...")
# Check if state is initialized
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):
# Find the category associated with this pixel index
category_name = pixel_to_category.get(index_value)
if category_name:
# --- Handle Categorized Pixels (0-31 typically) ---
cat_data = categories[category_name] # Get category data (color, intensity)
base_bgr = cat_data["color"] # Base BGR color tuple
intensity_factor = cat_data["intensity"] / 255.0 # Intensity slider value (0-1)
# Calculate final color components applying intensity
final_b = float(base_bgr[0]) * intensity_factor
final_g = float(base_bgr[1]) * intensity_factor
final_r = float(base_bgr[2]) * intensity_factor
# Clip and convert to integer for the LUT entry
new_lut[index_value, 0] = np.clip(int(round(final_b)), 0, 255) # Blue
new_lut[index_value, 1] = np.clip(int(round(final_g)), 0, 255) # Green
new_lut[index_value, 2] = np.clip(int(round(final_r)), 0, 255) # Red
elif 32 <= index_value <= 255:
# --- Handle Raw Map Pixels (32-255 typically) ---
# Map index value (32-255) to a raw intensity (0-255)
raw_intensity = (float(index_value) - 32.0) * (255.0 / 223.0)
# Apply the raw map intensity slider 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 the gray value to all BGR channels
new_lut[index_value, :] = final_gray_int
else:
# Handle cases where index is < 32 but not found in pixel_to_category (e.g., Reserved range)
# This case might indicate an issue in config, but handle gracefully.
# We default these to black (as per initial new_lut value)
if category_name is None:
# Log unexpected unmapped indices
logging.warning(f"{log_prefix} Index {index_value} has no assigned category. Defaulting to black.")
# new_lut[index_value, :] is already [0, 0, 0]
# Store the completed LUT back into AppState
self.state.mfd_lut = new_lut
logging.info(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 if structure error occurs
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 on critical errors
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:
# Create a simple grayscale ramp (0-255)
gray_ramp = np.arange(256,dtype=np.uint8)
# Convert to BGR format for the LUT
fallback_lut = cv2.cvtColor(gray_ramp[:, np.newaxis], cv2.COLOR_GRAY2BGR)[:, 0, :]
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 at least *something* to avoid None errors later
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]"
# Check essential components exist
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 get checkbox state
if hasattr(self.control_panel, "test_image_var") and isinstance(self.control_panel.test_image_var, tk.Variable):
is_test_req = self.control_panel.test_image_var.get() == 1
else:
logging.warning(f"{log_prefix} test_image_var not found or invalid in control_panel.")
# Fallback: assume current state to avoid unintended switch
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}")
# Update the state flag first
self.state.test_mode_active = is_test_req
# Call appropriate activation/deactivation sequences
if self.state.test_mode_active:
# Attempt to activate the manager
if self.test_mode_manager.activate():
# If manager activated successfully, perform UI/State actions
self.activate_test_mode_ui_actions()
else:
# If manager activation failed (e.g., missing test data)
logging.error(f"{log_prefix} TestModeManager activation failed. Reverting UI and state.")
# Try to revert the UI checkbox and state flag
self._revert_test_mode_ui()
else:
# Deactivate the manager (stops timers)
self.test_mode_manager.deactivate()
# Perform UI/State actions for returning to normal mode
self.deactivate_test_mode_ui_actions()
# Reset statistics whenever the mode successfully changes
self.state.reset_statistics()
# Update the status bar display
self.update_status()
else:
# Log if no change is needed
logging.debug(f"{log_prefix} Requested mode ({is_test_req}) is the same as current mode. No change.")
except tk.TclError as e:
# Handle Tkinter errors (e.g., widget destroyed)
logging.warning(f"{log_prefix} UI error accessing checkbox state (TclError): {e}")
except AttributeError as ae:
# Handle potential errors if managers aren't fully initialized
logging.error(f"{log_prefix} Missing attribute during mode update (likely manager init issue): {ae}")
except Exception as e:
# Log any other unexpected errors during the mode switch process
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."""
log_prefix = "[App CB SAR Size]"
if self.state.shutting_down:
return # Ignore if shutting down
# If map overlay is enabled, SAR size is fixed, prevent UI change
# Check map manager existence as a proxy for map being active
map_active = hasattr(self, 'map_integration_manager') and self.map_integration_manager is not None
if config.ENABLE_MAP_OVERLAY and map_active:
logging.debug(f"{log_prefix} Ignoring SAR size change request (Map Overlay active).")
# Try to force the UI combobox back to the map-enforced value
try:
# Calculate the fixed factor based on current state (set during init if map on)
forced_factor = config.SAR_WIDTH // self.state.sar_display_width if self.state.sar_display_width > 0 else 1
forced_size_str = f"1:{forced_factor}"
if hasattr(self.control_panel, 'sar_size_combo'):
# Only set if the current value differs to avoid unnecessary events
if self.control_panel.sar_size_combo.get() != forced_size_str:
self.control_panel.sar_size_combo.set(forced_size_str)
except Exception as e:
logging.warning(f"{log_prefix} Failed to reset SAR size combobox UI for map mode: {e}")
return # Exit without processing size change
# Proceed with size change if map overlay is not active
try:
selected_size_str = self.control_panel.sar_size_combo.get()
logging.debug(f"{log_prefix} SAR display size selected: '{selected_size_str}'")
# Parse the factor from the string "1:N"
factor = 1 # Default factor
if selected_size_str != "1:1":
# Split by ':' and take the second part, convert to int
factor = int(selected_size_str.split(":")[1])
# Calculate new dimensions based on factor
new_width = max(1, config.SAR_WIDTH // factor)
new_height = max(1, config.SAR_HEIGHT // factor)
# Update the display size in AppState
self.state.update_sar_display_size(new_width, new_height)
# Trigger a SAR image update to reflect the new size
self._trigger_sar_update() # Calls ImagePipeline if not in test mode
except (ValueError, IndexError, TypeError) as e:
# Handle errors parsing the combobox value
logging.warning(f"{log_prefix} Invalid SAR size format: '{selected_size_str}'. Error: {e}. Resetting UI.")
# Reset UI to current state value as fallback
try:
current_factor = config.SAR_WIDTH // self.state.sar_display_width if self.state.sar_display_width > 0 else 1
current_size_str = f"1:{current_factor}"
if hasattr(self.control_panel, 'sar_size_combo'):
self.control_panel.sar_size_combo.set(current_size_str)
except Exception as reset_e:
logging.warning(f"{log_prefix} Failed to reset SAR size combobox UI after error: {reset_e}")
except Exception as e:
# Log other unexpected errors
logging.exception(f"{log_prefix} Error processing SAR size update: {e}")
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 # Ignore if shutting down
try:
# Convert slider value string to float
contrast = float(value_str)
# Update the contrast value in AppState
self.state.update_sar_parameters(contrast=contrast)
# Recalculate the SAR LUT based on the new contrast
self.update_brightness_contrast_lut()
# Trigger a display update to show the effect
self._trigger_sar_update() # Calls ImagePipeline if not in test mode
except ValueError:
# Log error if slider value is not a valid float
logging.warning(f"{log_prefix} Invalid contrast value received from slider: {value_str}")
except Exception as e:
# Log other unexpected errors
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 # Ignore if shutting down
try:
# Convert slider value string to integer (can be float then int)
brightness = int(float(value_str))
# Update the brightness value in AppState
self.state.update_sar_parameters(brightness=brightness)
# Recalculate the SAR LUT based on the new brightness
self.update_brightness_contrast_lut()
# Trigger a display update to show the effect
self._trigger_sar_update() # Calls ImagePipeline if not in test mode
except ValueError:
# Log error if slider value is not a valid number
logging.warning(f"{log_prefix} Invalid brightness value received from slider: {value_str}")
except Exception as e:
# Log other unexpected errors
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 # Ignore if shutting down
try:
# Get the selected palette name from the combobox
palette = self.control_panel.palette_combo.get()
logging.debug(f"{log_prefix} Palette changed to '{palette}'")
# Validate if the selected palette is known/supported
if palette in config.COLOR_PALETTES:
# Update the palette name in AppState
self.state.update_sar_parameters(palette=palette)
# Trigger a display update to apply the new palette
self._trigger_sar_update() # Calls ImagePipeline if not in test mode
else:
# Log warning and reset UI if palette is unknown
logging.warning(f"{log_prefix} Unknown palette selected: '{palette}'. Ignoring change.")
# Reset combobox to the current value stored in state
self.control_panel.palette_combo.set(self.state.sar_palette)
except Exception as e:
# Log other unexpected errors
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 # Ignore if shutting down
logging.debug(f"{log_prefix} Category='{category_name}', Intensity={intensity_value}")
try:
# Ensure intensity is within the valid range 0-255
intensity = np.clip(intensity_value, 0, 255) # Value is already int fromIntVar
# Check if category exists in state before updating
if category_name in self.state.mfd_params["categories"]:
# Update the intensity for the specific category in AppState
self.state.mfd_params["categories"][category_name]["intensity"] = intensity
logging.info(f"{log_prefix} Intensity for '{category_name}' set to {intensity} in AppState.")
# Recalculate the MFD LUT to reflect the change
self.update_mfd_lut()
# Trigger an MFD display update
self._trigger_mfd_update() # Calls ImagePipeline if not in test mode
else:
# Log warning if the category name is not found
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:
# Log other unexpected errors
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 # Ignore if shutting down
logging.debug(f"{log_prefix} Color chooser requested for Category='{category_name}'")
# Validate 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:
# Get the current color (BGR tuple) from AppState to set initial color
initial_bgr = self.state.mfd_params["categories"][category_name]["color"]
# Convert BGR to HEX string for the color chooser
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
# Returns a tuple: ((R, G, B), "#RRGGBB") or (None, None) if cancelled
color_code = colorchooser.askcolor(
title=f"Select Color for {category_name}", initialcolor=initial_hex
)
# Check if a color was selected (result is not None and first element is not None)
if color_code and color_code[0]:
# Extract the chosen RGB tuple (float 0-255 or int 0-255 depending on Tk version)
rgb = color_code[0]
# Convert RGB tuple to integer BGR tuple, clipping values
new_bgr = tuple(np.clip(int(c),0,255) for c in (rgb[2],rgb[1],rgb[0])) # B=rgb[2], G=rgb[1], R=rgb[0]
logging.info(f"{log_prefix} New color chosen for '{category_name}': RGB={rgb}, BGR={new_bgr}")
# Update the color in AppState
self.state.mfd_params["categories"][category_name]["color"] = new_bgr
# Recalculate the MFD LUT
self.update_mfd_lut()
# Update the color preview label in the UI (schedule on main thread)
if self.root and self.root.winfo_exists():
# Ensure control panel and method exist before scheduling
if hasattr(self.control_panel, 'update_mfd_color_display'):
self.root.after_idle(self.control_panel.update_mfd_color_display, category_name, new_bgr)
else:
logging.warning(f"{log_prefix} control_panel.update_mfd_color_display method not found.")
# Trigger an MFD display update
self._trigger_mfd_update() # Calls ImagePipeline if not in test mode
else:
# Log if the user cancelled the color chooser
logging.debug(f"{log_prefix} Color selection cancelled by user.")
except KeyError as ke:
logging.error(f"{log_prefix} KeyError accessing MFD params for '{category_name}': {ke}")
except Exception as e:
# Log other unexpected errors during color selection process
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 # Ignore if shutting down
logging.debug(f"{log_prefix} Raw Map intensity slider changed -> {intensity_value}")
try:
# Ensure intensity is within the valid range 0-255
intensity = np.clip(intensity_value, 0, 255) # Value is already int fromIntVar
# Update the raw map intensity value in AppState
self.state.mfd_params["raw_map_intensity"] = intensity
logging.info(f"{log_prefix} Raw Map intensity set to {intensity} in AppState.")
# Recalculate the MFD LUT
self.update_mfd_lut()
# Trigger an MFD display update
self._trigger_mfd_update() # Calls ImagePipeline if not in test mode
except KeyError as ke:
logging.error(f"{log_prefix} KeyError accessing MFD params for raw_map_intensity: {ke}")
except Exception as e:
# Log other unexpected errors
logging.exception(f"{log_prefix} Error updating raw map intensity: {e}")
# --- Initialization ---
def __init__(self, root: tk.Tk):
"""
Initializes the main application components and state.
Args:
root (tk.Tk): The main Tkinter window instance.
"""
log_prefix = "[App Init]"
logging.debug(f"{log_prefix} Starting application initialization...")
self.root = root
self.root.title("Control Panel")
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()
# --- Calculate Initial Window Positions and Sizes ---
# Calculate SAR display size first, as it might depend on map state
initial_sar_w, initial_sar_h = self._calculate_initial_sar_size(desired_factor_if_map=5) # Updates state if map active
# Calculate Tkinter and MFD positions
self.tkinter_x, self.tkinter_y = self._calculate_tkinter_position(screen_h)
self.mfd_x, self.mfd_y = self._calculate_mfd_position()
# Calculate SAR position using the initial size (potentially adjusted by map)
self.sar_x, self.sar_y = self._calculate_sar_position(screen_w, initial_sar_w)
# Calculate potential map position (used if manager is created)
map_x, map_y = self._calculate_map_position(
screen_w,
initial_sar_w,
# Pass the max map size used in MapDisplayWindow
max_map_width=MapDisplayWindow.MAX_DISPLAY_WIDTH
)
# Set Tkinter window position
self.root.geometry(f"+{self.tkinter_x}+{self.tkinter_y}")
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}), MapEst({map_x},{map_y})"
)
# --- Initialize Sub-systems ---
# 1. UI Components (need 'self' for callbacks)
self.statusbar = StatusBar(self.root)
self.control_panel = ControlPanel(self.root, self) # Pass App instance
logging.debug(f"{log_prefix} UI components created.")
# 2. LUTs (read initial state, store back in state)
self.update_brightness_contrast_lut()
self.update_mfd_lut()
logging.debug(f"{log_prefix} Initial LUTs generated.")
# 3. Display Manager (handles MFD/SAR OpenCV windows)
self.display_manager = DisplayManager(
app=self, # Pass self for AppState access
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, # Use current state (potentially map-adjusted)
initial_sar_height=self.state.sar_display_height # Use current state
)
logging.debug(f"{log_prefix} DisplayManager created.")
# Initialize display windows immediately (shows placeholders)
try:
self.display_manager.initialize_display_windows()
except Exception as e:
self.set_status("Error: Display Init Failed") # Use self.set_status
logging.critical(f"{log_prefix} Display window initialization failed: {e}", exc_info=True)
# 4. Image Processing Pipeline (handles normal mode image processing)
self.image_pipeline = ImagePipeline(
app_state=self.state,
sar_queue=self.sar_queue,
mfd_queue=self.mfd_queue,
app=self, # Pass self for put_queue context
)
logging.debug(f"{log_prefix} ImagePipeline created.")
# 5. Test Mode Manager (handles test mode logic)
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 self for put_queue context
)
logging.debug(f"{log_prefix} TestModeManager created.")
# 6. Map Integration Manager (conditional initialization)
self.map_integration_manager: Optional[MapIntegrationManager] = None
if config.ENABLE_MAP_OVERLAY:
# Check if necessary libraries and manager class were loaded
if MAP_MODULES_LOADED and MapIntegrationManager is not None:
logging.info(f"{log_prefix} Map Overlay enabled. Initializing MapIntegrationManager...")
try:
# Create the manager instance
self.map_integration_manager = MapIntegrationManager(
app_state=self.state,
tkinter_queue=self.tkinter_queue,
app=self,
map_x=map_x, # Pass pre-calculated position
map_y=map_y
)
logging.info(f"{log_prefix} MapIntegrationManager initialized successfully.")
except Exception as map_mgr_e:
# Log errors during map manager initialization
logging.exception(f"{log_prefix} Failed to initialize MapIntegrationManager:")
self.map_integration_manager = None # Ensure manager is None on error
self.set_status("Error: Map Init Failed") # Update status bar
else:
# Log error if map is enabled but components are missing
logging.error(f"{log_prefix} Map Overlay enabled but required modules/manager failed to load.")
self.set_status("Error: Map Modules Missing")
else:
# Log if map overlay is disabled in config
logging.debug(f"{log_prefix} Map Overlay is disabled in configuration.")
# 7. Set initial UI state labels AFTER all components potentially needed exist
self._update_initial_ui_labels()
# 8. 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 # Define attribute type
self.udp_receiver: Optional[UdpReceiver] = None # Define attribute type
self.udp_thread: Optional[threading.Thread] = None # Define attribute type
# Setup receiver only if not using local images
if not config.USE_LOCAL_IMAGES:
self._setup_network_receiver() # Calls set_status internally on success/failure
else:
logging.info(f"{log_prefix} Network receiver disabled (USE_LOCAL_IMAGES=True).")
# Status will be set later by image loader completion
# 9. Initial Image Load Thread (runs in background)
self._start_initial_image_loader() # Calls _set_initial_display_from_loaded_data upon completion
# 10. Start Queue Processors (run periodically on main thread)
self.process_sar_queue()
self.process_mfd_queue()
self.process_mouse_queue()
self.process_tkinter_queue()
logging.debug(f"{log_prefix} Queue processors scheduled.")
# 11. Start Periodic Status Updates (runs periodically on main thread)
self.schedule_periodic_updates()
logging.debug(f"{log_prefix} Periodic updates scheduled.")
# 12. Set initial image mode based on config (calls TestModeManager activate/deactivate)
self.update_image_mode()
logging.debug(f"{log_prefix} Initial image mode set.")
# Final status is set by _set_initial_display_from_loaded_data or map manager after loading
logging.info(f"{log_prefix} Application initialization sequence complete.")
# --- Initialization Helper Methods ---
def _get_screen_dimensions(self) -> Tuple[int, int]:
"""Gets primary screen dimensions using screeninfo, returning defaults on error."""
log_prefix = "[App Init]" # Part of initialization
try:
# Get list of monitors
monitors = screeninfo.get_monitors()
if not monitors:
# Raise specific error if no monitors found
raise screeninfo.ScreenInfoError("No monitors detected by screeninfo.")
# Use the first monitor as primary
screen = monitors[0]
screen_w: int = screen.width
screen_h: int = screen.height
logging.debug(f"{log_prefix} Detected Screen Dimensions: {screen_w}x{screen_h}")
return screen_w, screen_h
except Exception as e:
# Log warning and return default values on any error
logging.warning(f"{log_prefix} Screen info error: {e}. Using default dimensions 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.
Allows specifying the desired reduction factor if the map is active.
Updates AppState if map overlay forces a different size.
Args:
desired_factor_if_map (int): The reduction factor (e.g., 4 for 1:4, 5 for 1:5)
to use if the map overlay is active. Defaults to 4.
Returns:
Tuple[int, int]: (initial_width, initial_height) to use for window positioning.
"""
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:
# Use the specified factor for SAR size when map is active
forced_factor = max(1, desired_factor_if_map) # Ensure factor is at least 1
initial_w = config.SAR_WIDTH // forced_factor
initial_h = config.SAR_HEIGHT // forced_factor
# Update the AppState with the map-enforced size
self.state.update_sar_display_size(initial_w, initial_h)
logging.info(
f"{log_prefix} Map overlay active, forcing SAR display size to 1:{forced_factor} "
f"({initial_w}x{initial_h})."
)
else:
logging.debug(f"{log_prefix} Using initial SAR display 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."""
# Position near top-left, leaving space for MFD above if needed
x = 10
# Place below the default MFD window height + some padding
y = config.INITIAL_MFD_HEIGHT + 40
# Check if calculated Y position pushes window off-screen
if y + config.TKINTER_MIN_HEIGHT > screen_h:
# If off-screen, adjust Y upwards, ensuring some padding from top/bottom
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 X with Tkinter window, place near the top
x = self.tkinter_x # Use the already calculated 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.
Args:
screen_w (int): Width of the screen.
initial_sar_w (int): Calculated initial width of the SAR window.
Returns:
Tuple[int, int]: (x_pos, y_pos) for the SAR window.
"""
# Place SAR window to the right of the Tkinter window + padding
x = self.tkinter_x + config.TKINTER_MIN_WIDTH + 20
# Align top with MFD window
y = 10
# Check if calculated X position pushes window off-screen
if x + initial_sar_w > screen_w:
# If off-screen, adjust X leftwards, ensuring padding
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 = 512 # Add max width argument
) -> Tuple[int, int]:
"""
Calculates the initial X, Y position for the Map display window.
Args:
screen_w (int): Width of the screen.
current_sar_w (int): Current width of the SAR window (potentially resized).
max_map_width (int): The maximum expected width of the map window for bounds check.
Returns:
Tuple[int, int]: (x_pos, y_pos) for the Map window.
"""
# Place Map window to the right of the SAR window + padding
x = self.sar_x + current_sar_w + 20
# Align top with SAR/MFD window
y = 10
# Check if calculated X position pushes window off-screen, using max_map_width
if x + max_map_width > screen_w:
# If off-screen, adjust X leftwards, ensuring padding
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 {self.local_ip}:{self.local_port}")
# Create the UDP socket using the network utility function
self.udp_socket = create_udp_socket(self.local_ip, self.local_port)
if self.udp_socket:
# If socket created successfully, initialize the receiver
try:
self.udp_receiver = UdpReceiver(
app=self, # Pass App instance for state/config access
udp_socket=self.udp_socket,
set_new_sar_image_callback=self.handle_new_sar_data, # Pass SAR handler method
set_new_mfd_indices_image_callback=self.handle_new_mfd_data, # Pass MFD handler method
)
logging.info(f"{log_prefix} UdpReceiver instance created.")
# Start the receiver loop in a separate daemon thread
self.udp_thread = threading.Thread(
target=self.udp_receiver.receive_udp_data,
name="UDPReceiverThread",
daemon=True # Allows app to exit even if this thread hangs (though it checks shutdown flag)
)
self.udp_thread.start()
logging.info(f"{log_prefix} UDP Receiver thread started.")
# Set status to indicate listening only after successful setup
self.set_status(f"Listening UDP {self.local_ip}:{self.local_port}")
except Exception as receiver_init_e:
# Log critical error if UdpReceiver initialization fails
logging.critical(f"{log_prefix} Failed to initialize UdpReceiver: {receiver_init_e}", exc_info=True)
self.set_status("Error: Receiver Init Failed")
# Close the socket if receiver init failed
if self.udp_socket:
close_udp_socket(self.udp_socket)
self.udp_socket = None
else:
# Log error and set status if socket creation failed
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 into AppState if needed."""
log_prefix = "[App Init]"
# Determine if loading is needed based on config flags
# Load if using local images OR if test mode is enabled by default
should_load = config.USE_LOCAL_IMAGES or config.ENABLE_TEST_MODE
if should_load:
logging.debug(f"{log_prefix} Starting initial image loading thread...")
# Create and start the thread
image_loading_thread = threading.Thread(
target=self.load_initial_images, # Target function in App
name="ImageLoaderThread",
daemon=True # Allow app to exit even if this thread hangs
)
image_loading_thread.start()
else:
logging.debug(f"{log_prefix} Skipping initial image loading (USE_LOCAL_IMAGES=False, ENABLE_TEST_MODE=False).")
# If not loading images, set initial display immediately (placeholders or network wait)
# Need to ensure status is set correctly if not loading anything.
# Call the display setup directly, skipping the thread load.
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...")
if not hasattr(self, 'control_panel') or not self.control_panel:
logging.warning(f"{log_prefix} Control panel not ready, cannot set initial labels.")
return
try:
# --- Set SAR Center Label ---
# Use default geo info from state for initial display
default_geo = self.state.current_sar_geo_info
center_txt = "Image Ref: Lat=N/A, Lon=N/A" # Default text
# Only format if initial state claims validity (unlikely, but possible)
if default_geo and default_geo.get('valid', False):
try:
# Convert default radians back to degrees for display formatting
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 KeyError as ke:
logging.error(f"{log_prefix} Missing key '{ke}' in initial default geo info.")
center_txt = "Image Ref: Data Error"
except Exception as format_err:
logging.error(f"{log_prefix} Error formatting initial geo label: {format_err}")
center_txt = "Image Ref: Format Error"
# Safely update the label widget, checking existence
if hasattr(self.control_panel, 'sar_center_label'):
self.control_panel.sar_center_label.config(text=center_txt)
# --- Set SAR Orientation Label ---
# Use the dedicated method on ControlPanel if available
if hasattr(self.control_panel, 'set_sar_orientation'):
self.control_panel.set_sar_orientation("N/A") # Initial value
# --- Set Mouse Coordinates Label ---
# Use the dedicated method on ControlPanel if available
if hasattr(self.control_panel, 'set_mouse_coordinates'):
self.control_panel.set_mouse_coordinates("N/A", "N/A") # Initial value
# --- Set Statistics Labels ---
# Set initial text for drop/incomplete labels
initial_stats = self.state.get_statistics() # Get initial zeroed stats
drop_txt = (f"Drop(Q): S={initial_stats['dropped_sar_q']},M={initial_stats['dropped_mfd_q']}, "
f"Tk={initial_stats['dropped_tk_q']},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(self.control_panel, 'dropped_label'):
self.control_panel.dropped_label.config(text=drop_txt)
if hasattr(self.control_panel, 'incomplete_label'):
self.control_panel.incomplete_label.config(text=incmpl_txt)
logging.debug(f"{log_prefix} Initial UI state labels set.")
except tk.TclError as e:
# Catch potential errors if UI elements are destroyed prematurely
logging.warning(f"{log_prefix} Error setting initial UI labels (TclError): {e}")
except Exception as e:
# Catch other unexpected errors
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.
Updates AppState and schedules main thread processing.
This method is called by the UdpReceiver instance.
"""
log_prefix = "[App CB SAR]" # Callback prefix
logging.debug(f"{log_prefix} Handling new SAR data (Shape: {normalized_image_uint8.shape}, Geo Valid: {geo_info_radians.get('valid', False)})...")
# Check shutdown flag before processing
if self.state.shutting_down:
logging.debug(f"{log_prefix} Shutdown detected. Ignoring new SAR data.")
return
# Update the shared application state with the received data
# Assume receiver already made copies if necessary before calling back
self.state.set_sar_data(normalized_image_uint8, geo_info_radians)
logging.debug(f"{log_prefix} SAR data and GeoInfo updated in AppState.")
# Schedule the main thread processing logic using after_idle
# This ensures UI updates and processing happen safely on the GUI thread
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:
# Log warning if root window is gone, cannot schedule update
logging.warning(f"{log_prefix} Cannot schedule SAR update: Root window destroyed or not available.")
def handle_new_mfd_data(self, image_indices: np.ndarray):
"""
Safely handles new MFD index data received from the network receiver.
Updates AppState and schedules main thread processing.
This method is called by the UdpReceiver instance.
"""
log_prefix = "[App CB MFD]" # Callback prefix
logging.debug(f"{log_prefix} Handling new MFD index data (Shape: {image_indices.shape})...")
# Check shutdown flag
if self.state.shutting_down:
logging.debug(f"{log_prefix} Shutdown detected. Ignoring new MFD data.")
return
# Update the shared application state with the received indices
# Assume receiver already made a copy if necessary
self.state.set_mfd_indices(image_indices)
logging.debug(f"{log_prefix} MFD indices updated in AppState.")
# Schedule the main thread processing logic using after_idle
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 destroyed or not available.")
# --- 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, and triggers map update.
"""
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 based on the latest state
self._update_sar_ui_labels() # Reads from self.state
# 2. Trigger Image Processing Pipeline (for SAR display queue)
# The pipeline checks the test_mode flag internally
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 (if map manager exists and geo is valid)
geo_info = self.state.current_sar_geo_info # Get current info
# Check if map manager was initialized successfully
map_manager_active = hasattr(self, 'map_integration_manager') and self.map_integration_manager is not None
if map_manager_active and geo_info and geo_info.get("valid", False):
logging.debug(f"{log_prefix} Calling map_integration_manager.update_map_overlay...")
try:
# Pass current normalized image and geo info from state to the manager
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 is supposed to be on
if not map_manager_active:
logging.debug(f"{log_prefix} Skipping map update: MapIntegrationManager not available.")
elif not geo_info or not geo_info.get("valid", False):
logging.debug(f"{log_prefix} Skipping map update: GeoInfo not valid.")
# 4. Update FPS Statistics for SAR
self._update_fps_stats("sar") # Updates self.state counters
logging.debug(f"{log_prefix} Finished processing SAR update.")
def _process_mfd_update_on_main_thread(self):
"""
Processes MFD updates scheduled to run on the main GUI thread.
Triggers image pipeline and updates FPS stats.
"""
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 for MFD display
# The pipeline checks the test_mode flag internally
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") # Updates self.state counters
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 (runs in main thread)."""
log_prefix = "[App MainThread SAR Update]" # Part of the main thread update cycle
# Check if control panel exists and is valid
if not hasattr(self, 'control_panel') or not self.control_panel or not self.control_panel.winfo_exists():
# logging.debug(f"{log_prefix} Control panel not available for UI label update.") # Can be noisy
return
geo_info = self.state.current_sar_geo_info
center_txt = "Image Ref: N/A" # Default text
orient_txt = "N/A" # Default text
is_valid_geo = geo_info and geo_info.get("valid", False)
if is_valid_geo:
try:
# Convert radians back to degrees for display formatting
lat_d = math.degrees(geo_info["lat"])
lon_d = math.degrees(geo_info["lon"])
orient_d = math.degrees(geo_info["orientation"])
# Format using the utility function
lat_s = decimal_to_dms(lat_d, is_latitude=True)
lon_s = decimal_to_dms(lon_d, is_latitude=False)
# Format orientation string
orient_txt = f"{orient_d:.2f}°" # Show degrees with 2 decimal places
# Format center string
center_txt = f"Image Ref: Lat={lat_s}, Lon={lon_s}"
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
# Formatta la stringa (es. W: 10.5 km, H: 9.8 km)
size_txt = f"W: {width_km:.1f} km, H: {height_km:.1f} km"
else:
logging.warning(f"{log_prefix} Cannot calculate SAR size in km due to invalid scale/dimensions in GeoInfo.")
size_txt = "Invalid Scale/Dims"
except KeyError as ke:
# Log error if expected keys are missing in geo_info
logging.error(f"{log_prefix} Missing key '{ke}' in geo_info for UI update.")
center_txt = "Ref: Data Error"
orient_txt = "Data Error"
is_valid_geo = False # Mark as invalid if data is incomplete
except Exception as e:
# Log other formatting errors
logging.error(f"{log_prefix} Error formatting geo info for UI: {e}")
center_txt = "Ref: Format Error"
orient_txt = "Format Error"
is_valid_geo = False # Mark as invalid on formatting error
# --- Safely update UI elements ---
try:
# Update Center Label
if hasattr(self.control_panel, 'sar_center_label'):
self.control_panel.sar_center_label.config(text=center_txt)
# Update Orientation Label (using ControlPanel method)
if hasattr(self.control_panel, 'set_sar_orientation'):
self.control_panel.set_sar_orientation(orient_txt)
if hasattr(self.control_panel, 'set_sar_size_km'):
self.control_panel.set_sar_size_km(size_txt)
# Reset mouse coordinates display if geo becomes invalid
if not is_valid_geo and hasattr(self.control_panel, 'set_mouse_coordinates'):
self.control_panel.set_mouse_coordinates("N/A", "N/A")
# Log success at debug level
# logging.debug(f"{log_prefix} SAR UI labels updated.") # Can be noisy
except tk.TclError as ui_err:
# Catch errors if UI widgets are destroyed prematurely (e.g., during shutdown)
if not self.state.shutting_down:
logging.warning(f"{log_prefix} Error updating SAR UI labels (TclError): {ui_err}")
except Exception as gen_err:
# Catch other unexpected errors during UI update
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 based on frame processing."""
now = time.time() # Get current time
log_prefix = "[App FPS Update]"
try:
if img_type == "sar":
# Increment SAR frame count in state
self.state.sar_frame_count += 1
# Check if enough time has passed to calculate FPS
elapsed = now - self.state.sar_update_time
if elapsed >= config.LOG_UPDATE_INTERVAL:
# Calculate FPS
self.state.sar_fps = self.state.sar_frame_count / elapsed
# Reset timer and counter for next interval
self.state.sar_update_time = now
self.state.sar_frame_count = 0
# Log calculated FPS at debug level
logging.debug(f"{log_prefix} SAR FPS calculated: {self.state.sar_fps:.2f}")
elif img_type == "mfd":
# Increment MFD frame count in state
self.state.mfd_frame_count += 1
# Check if enough time has passed
elapsed = now - self.state.mfd_start_time
if elapsed >= config.LOG_UPDATE_INTERVAL:
# Calculate FPS
self.state.mfd_fps = self.state.mfd_frame_count / elapsed
# Reset timer and counter
self.state.mfd_start_time = now
self.state.mfd_frame_count = 0
# Log calculated FPS at debug level
logging.debug(f"{log_prefix} MFD FPS calculated: {self.state.mfd_fps:.2f}")
except Exception as e:
# Prevent FPS calculation errors from stopping the application
logging.warning(f"{log_prefix} Error updating FPS stats for '{img_type}': {e}")
# --- Trigger Methods ---
# These methods are typically called by UI callbacks after parameters change.
# They ensure the correct processing pipeline (or test mode update) is triggered.
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 # Ignore if shutting down
# Check if test mode is active. Test mode updates are handled by TestModeManager's timer.
if not self.state.test_mode_active:
# If not in test mode, trigger the normal image processing pipeline.
logging.debug(f"{log_prefix} Triggering SAR update processing via ImagePipeline.")
try:
# Ensure image_pipeline exists before calling
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. Cannot trigger SAR update.")
except Exception as e:
# Log exceptions during the pipeline call
logging.exception(f"{log_prefix} Error calling image_pipeline.process_sar_for_display:")
else:
# Log that the trigger is skipped because test mode is active.
logging.debug(f"{log_prefix} SAR update trigger skipped (Test Mode active, handled by TestModeManager timer).")
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 # Ignore if shutting down
# Check if test mode is active. Test mode updates are handled by TestModeManager's timer.
if not self.state.test_mode_active:
# If not in test mode, trigger the normal image processing pipeline.
logging.debug(f"{log_prefix} Triggering MFD update processing via ImagePipeline.")
try:
# Ensure image_pipeline exists before calling
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. Cannot trigger MFD update.")
except Exception as e:
# Log exceptions during the pipeline call
logging.exception(f"{log_prefix} Error calling image_pipeline.process_mfd_for_display:")
else:
# Log that the trigger is skipped because test mode is active.
logging.debug(f"{log_prefix} MFD update trigger skipped (Test Mode active, handled by TestModeManager timer).")
# --- Periodic Update Scheduling ---
def schedule_periodic_updates(self):
"""Schedules the regular update of the status bar information."""
log_prefix = "[App Status Scheduler]"
# Stop scheduling if application is shutting down
if self.state.shutting_down:
logging.debug(f"{log_prefix} Shutdown detected. Stopping periodic updates.")
return
# Call the update function first to update status immediately
try:
self.update_status()
except Exception as e:
# Log error during update but continue scheduling
logging.error(f"{log_prefix} Error during periodic status update execution: {e}")
# Calculate interval in milliseconds from config (seconds)
# Ensure a minimum delay to prevent overly frequent updates
interval_ms = max(100, int(config.LOG_UPDATE_INTERVAL * 1000))
# Schedule the next call using root.after, checking root existence
try:
if self.root and self.root.winfo_exists():
self.root.after(interval_ms, self.schedule_periodic_updates)
# else: Don't log warning if root is gone, expected during shutdown
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 images into AppState
and ensures test images are generated if needed. Calls UI setup upon completion.
"""
log_prefix = "[App Image Loader]"
if self.state.shutting_down:
logging.debug(f"{log_prefix} Shutdown detected. Aborting image loading thread.")
return
logging.info(f"{log_prefix} Initial image loading thread started.")
# Schedule initial status update on main thread (thread-safe via after_idle)
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 is enabled initially
# The manager handles the check if data already exists.
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 via TestModeManager...")
self.test_mode_manager._ensure_test_images() # Call manager's internal method
else:
logging.error(f"{log_prefix} TestModeManager not available to generate test images.")
# Load local images if configured
if config.USE_LOCAL_IMAGES:
logging.debug(f"{log_prefix} Loading local MFD image...")
self._load_local_mfd_image() # Loads into self.state.local_mfd_image_data_indices
logging.debug(f"{log_prefix} Loading local SAR image...")
self._load_local_sar_image() # Loads into self.state.local_sar_image_data_raw
# Schedule the final display setup on the main thread after loading is complete
if self.root and self.root.winfo_exists():
logging.debug(f"{log_prefix} Scheduling _set_initial_display_from_loaded_data on main thread.")
self.root.after_idle(self._set_initial_display_from_loaded_data)
except Exception as e:
# Log any exceptions during the loading process
logging.exception(f"{log_prefix} Error during initial image loading:")
# Schedule error status update on main thread
if self.root and self.root.winfo_exists():
self.root.after_idle(self.set_status, "Error Loading Images")
finally:
# Log thread completion
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 indices if loading fails
default_indices = np.random.randint(0, 256, (config.MFD_HEIGHT, config.MFD_WIDTH), dtype=np.uint8)
loaded_indices = None
try:
mfd_path = config.MFD_IMAGE_PATH
if os.path.exists(mfd_path):
logging.warning(f"{log_prefix} Local MFD loading from file is NYI. Using random data.")
# Placeholder for actual loading logic (e.g., np.load)
# Example: loaded_indices = np.load(mfd_path).astype(np.uint8)
loaded_indices = default_indices # Use default for now
logging.debug(f"{log_prefix} Using placeholder random MFD indices.")
else:
logging.warning(f"{log_prefix} Local MFD image file not found: {mfd_path}. Using random data.")
loaded_indices = default_indices
# Store the result (loaded or default) in AppState
self.state.local_mfd_image_data_indices = loaded_indices
except Exception as e:
# Log error and ensure state has the default value
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 module
# This handles file existence check, loading, and type conversion
loaded_raw_data = load_image(config.SAR_IMAGE_PATH, config.SAR_DATA_TYPE) # load_image logs internally
if loaded_raw_data is None or loaded_raw_data.size == 0:
# load_image returns placeholder on error, check if it's different from default
logging.error(f"{log_prefix} Failed to load local SAR raw data from {config.SAR_IMAGE_PATH}. Using zeros.")
loaded_raw_data = default_raw_data # Ensure zeros on failure
else:
logging.info(f"{log_prefix} Loaded local SAR raw data into AppState (shape: {loaded_raw_data.shape}).")
# Store the result (loaded or default) in AppState
self.state.local_sar_image_data_raw = loaded_raw_data
except Exception as e:
# Log error and ensure state has the default value
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 image data
(if any) and the current application mode (Test, Local, Network).
Also sets the final initial status message if the map isn't loading.
"""
log_prefix = "[App Init Display]"
if self.state.shutting_down:
logging.debug(f"{log_prefix} Shutdown detected. Skipping initial display setup.")
return
is_test = self.state.test_mode_active
is_local = config.USE_LOCAL_IMAGES
# Determine initial display content based on mode
if not is_test and is_local:
# --- Local Image Mode ---
logging.info(f"{log_prefix} Setting initial display based on loaded local images.")
# Display MFD (if loaded)
if self.state.local_mfd_image_data_indices is not None:
# Update current state from the loaded local data
self.state.current_mfd_indices = self.state.local_mfd_image_data_indices.copy()
# Process the restored state via the pipeline
self.image_pipeline.process_mfd_for_display()
else:
logging.warning(f"{log_prefix} Local MFD data not loaded. MFD display will be blank/stale.")
# Display SAR (if loaded)
if self.state.local_sar_image_data_raw is not None:
# This method handles normalization, state update, UI reset, and pipeline call
self.set_initial_sar_image(self.state.local_sar_image_data_raw)
else:
# Handle case where SAR load failed but local mode is active
logging.warning(f"{log_prefix} Local SAR data not loaded. Displaying black SAR image.")
# Ensure normalized state buffer exists and is black
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 as invalid and reset UI
self.state.current_sar_geo_info["valid"] = False
self._reset_ui_geo_info()
# Process the black image via the pipeline
self.image_pipeline.process_sar_for_display()
elif is_test:
# --- Test Mode ---
logging.info(f"{log_prefix} Test mode active. Display handled by TestModeManager timers.")
# Activation of test mode (in update_image_mode) starts the timers.
else:
# --- Network Mode ---
logging.info(f"{log_prefix} Network mode active. Displaying initial placeholders.")
# Show placeholder images (these bypass the pipeline for simplicity)
self._show_network_placeholders()
# --- Set Final Initial Status ---
# Check if the map manager is active and if its initial loading thread is still running
map_manager_active = hasattr(self, 'map_integration_manager') and self.map_integration_manager is not None
map_thread_running = False
if map_manager_active:
# Check existence of thread attribute before accessing is_alive
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
# Set the final status message *only if* the map isn't currently loading
# If map is loading, its completion callback will set the status.
if not map_is_loading:
# Determine status based on mode
final_status = "Ready (Test Mode)" if is_test else \
("Ready (Local Mode)" if is_local else \
(f"Listening UDP {self.local_ip}:{self.local_port}" if self.udp_socket else "Error: No Socket"))
self.set_status(final_status)
logging.debug(f"{log_prefix} Set final initial status (map not loading): '{final_status}'")
else:
# Status 'Loading initial map...' was likely set by MapIntegrationManager init
logging.debug(f"{log_prefix} Initial map display is still loading. Status will be updated upon completion.")
def set_initial_sar_image(self, raw_image_data: np.ndarray):
"""
Processes provided raw SAR data (typically loaded locally), updates AppState,
resets UI Geo info, and triggers display via the ImagePipeline.
"""
log_prefix = "[App Init SAR Image]"
if self.state.shutting_down: return # Ignore if shutting down
logging.debug(f"{log_prefix} Processing initial raw SAR image...")
normalized: Optional[np.ndarray] = None
# Validate input raw data
if raw_image_data is not None and raw_image_data.size > 0:
try:
# Normalize the raw data to uint8 using the utility function
normalized = normalize_image(raw_image_data, target_type=np.uint8) # Logs internally
if normalized is None:
# Log error if normalization itself failed
logging.error(f"{log_prefix} Normalization of raw SAR data failed.")
except Exception as e:
# Log any unexpected errors during normalization
logging.exception(f"{log_prefix} Error during initial SAR normalization:")
else:
# Log error if raw data is invalid
logging.error(f"{log_prefix} Provided raw SAR data is invalid or empty.")
# Update AppState with the normalized image (or a black image on failure)
if normalized is not None:
self.state.current_sar_normalized = normalized
logging.debug(f"{log_prefix} Stored normalized initial SAR image in AppState.")
else:
# Ensure the state buffer exists before filling with zeros
logging.warning(f"{log_prefix} Using black image fallback for initial SAR display.")
if self.state.current_sar_normalized is None:
# Create buffer if it doesn't exist
self.state.current_sar_normalized = np.zeros((config.SAR_HEIGHT, config.SAR_WIDTH), dtype=np.uint8)
# Fill existing buffer with zeros
self.state.current_sar_normalized.fill(0)
# Mark geo info as invalid for locally loaded images and reset UI display
self.state.current_sar_geo_info["valid"] = False
self._reset_ui_geo_info() # Schedule UI label reset
# Trigger the image pipeline to process and display the normalized (or black) image
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() # Use the pipeline
else:
logging.error(f"{log_prefix} ImagePipeline not available to display initial SAR.")
logging.info(f"{log_prefix} Initial SAR image processed and queued.")
# --- Mode Switching UI Actions ---
# These handle UI/State changes NOT directly related to manager timers/data generation
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...") # Initial status
# Reset geo info display in UI as test mode doesn't use real geo
self._reset_ui_geo_info()
# Clear display queues immediately before test images start arriving
clear_queue(self.mfd_queue)
clear_queue(self.sar_queue)
logging.debug(f"{log_prefix} MFD and SAR display queues cleared.")
# Set final status after manager activation and queue clearing
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...") # Initial status
# Test timers are stopped by TestModeManager.deactivate() before this is called
# Clear display queues of any lingering test images
clear_queue(self.mfd_queue)
clear_queue(self.sar_queue)
logging.debug(f"{log_prefix} MFD and SAR display queues cleared.")
# Reset geo info display in UI (will be updated by real data if network mode)
self._reset_ui_geo_info()
# --- Restore display based on configuration (Local or Network) ---
if config.USE_LOCAL_IMAGES:
# --- Local Image Mode Restoration ---
logging.info(f"{log_prefix} Restoring display from local images stored in AppState.")
# MFD restore
if self.state.local_mfd_image_data_indices is not None:
# Update current display state from the loaded local backup
self.state.current_mfd_indices = self.state.local_mfd_image_data_indices.copy()
# Process the restored state via the pipeline
self.image_pipeline.process_mfd_for_display()
else:
logging.warning(f"{log_prefix} No local MFD data in AppState to restore.")
# Optionally queue a black MFD image or clear display? For now, do nothing.
# SAR restore
if self.state.local_sar_image_data_raw is not None:
# This method handles processing, state update, UI reset, and pipeline call
self.set_initial_sar_image(self.state.local_sar_image_data_raw)
else:
# Handle case where SAR load failed but local mode is active
logging.warning(f"{log_prefix} No local SAR data in AppState to restore. Displaying black.")
# Ensure normalized state is black, mark geo invalid, reset UI, and process black image
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)
self.state.current_sar_geo_info["valid"] = False
self._reset_ui_geo_info()
self.image_pipeline.process_sar_for_display()
# Set status for local mode
self.set_status("Ready (Local Mode)")
else:
# --- Network Mode Restoration ---
logging.info(f"{log_prefix} Switched to Network mode. Displaying placeholders.")
# Queue placeholder images (these bypass the pipeline)
self._show_network_placeholders()
# Determine status based on current 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)
# Geo info will be updated when valid data arrives via network handler.
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]"
# Check if root window exists before scheduling
if self.root and self.root.winfo_exists():
# Schedule updates using after_idle for thread safety
# Check if control_panel and specific methods/widgets exist before calling config/set
if hasattr(self.control_panel, 'set_sar_orientation'):
self.root.after_idle(lambda: self.control_panel.set_sar_orientation("N/A"))
if hasattr(self.control_panel, 'set_mouse_coordinates'):
self.root.after_idle(lambda: self.control_panel.set_mouse_coordinates("N/A", "N/A"))
if hasattr(self.control_panel, 'sar_center_label'):
self.root.after_idle(lambda: self.control_panel.sar_center_label.config(text="Image Ref: Lat=N/A, Lon=N/A"))
logging.debug(f"{log_prefix} Geo UI label reset scheduled.")
# else: # Don't log if root gone, expected during shutdown
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]" # Part of mode switching logic
logging.warning(f"{log_prefix} Reverting Test Mode UI and state due to activation failure.")
# Schedule UI update on main thread if possible
if self.root and self.root.winfo_exists() and hasattr(self.control_panel, "test_image_var"):
try:
# Use after_idle to ensure UI update happens when Tkinter is ready
self.root.after_idle(self.control_panel.test_image_var.set, 0) # Set checkbox to unchecked
logging.debug(f"{log_prefix} Scheduled UI checkbox uncheck.")
except Exception as e:
logging.warning(f"{log_prefix} Failed to schedule uncheck of test mode checkbox: {e}")
# Correct 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 when in Network mode without data."""
log_prefix = "[App Placeholders]"
logging.debug(f"{log_prefix} Queueing network placeholder images.")
try:
# Create MFD placeholder (dark gray)
ph_mfd = np.full((config.INITIAL_MFD_HEIGHT, config.INITIAL_MFD_WIDTH, 3), 30, dtype=np.uint8)
# Create SAR placeholder (lighter gray) - Use current display size from state
ph_sar = np.full((self.state.sar_display_height, self.state.sar_display_width, 3), 60, dtype=np.uint8)
# Queue the placeholders directly, bypassing the processing pipeline
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 or queueing placeholder images:")
# --- Mouse Coordinate Handling ---
def process_mouse_queue(self):
"""
Processes raw mouse coords from queue, calculates geo coords (using AppState),
and queues the result string tuple onto the Tkinter queue. Runs periodically.
"""
log_prefix = "[App GeoCalc]" # Calculation prefix
if self.state.shutting_down: return # Stop processing on shutdown
raw_coords = None
try:
# Get raw (x, y) display coordinates non-blockingly
raw_coords = self.mouse_queue.get(block=False)
# Mark task done *after* getting item successfully
self.mouse_queue.task_done()
except queue.Empty:
pass # Normal case, queue is empty
except Exception as e:
# Log error getting from queue
logging.exception(f"{log_prefix} Error getting from mouse queue:")
# Don't reschedule immediately on error? Or reschedule anyway? Reschedule for now.
pass
# else: # Process if coords were retrieved
if raw_coords is not None and isinstance(raw_coords, tuple) and len(raw_coords) == 2:
x_disp, y_disp = raw_coords
# Proceed with calculation
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" # Default result string
lon_s: str = "N/A" # Default result string
# --- Check if geo info is valid for calculation ---
# Requires valid geo dict, positive display and original dimensions, positive scale factors
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
# Check essential lat/lon keys exist
"lat" in geo and "lon" in geo and "ref_x" in geo and "ref_y" 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 from state ---
orig_w = geo["width_px"]
orig_h = geo["height_px"]
scale_x = geo["scale_x"] # meters per pixel
scale_y = geo["scale_y"] # meters per pixel
ref_x = geo["ref_x"] # pixel coord of ref lat/lon
ref_y = geo["ref_y"] # pixel coord of ref lat/lon
ref_lat_rad = geo["lat"] # radians
ref_lon_rad = geo["lon"] # radians
orient_rad = geo.get("orientation", 0.0) # radians (default to 0 if missing)
# --- Coordinate Transformation ---
# 1. Normalize display coordinates to [0, 1] range
# Avoid division by zero checked by is_geo_valid_for_calc
nx_disp = x_disp / disp_w
ny_disp = y_disp / disp_h
# 2. Apply Inverse Rotation if needed
nx_orig_norm, ny_orig_norm = nx_disp, ny_disp # Start with normalized display coords
if abs(orient_rad) > 1e-4: # Check if rotation is significant
logging.debug(f"{log_prefix} Applying inverse rotation (angle: {math.degrees(orient_rad):.2f} deg)...")
# Inverse rotation angle
arad_inv = -orient_rad
cosa = math.cos(arad_inv)
sina = math.sin(arad_inv)
# Rotation center (normalized coordinates: 0.5, 0.5)
cx, cy = 0.5, 0.5
# Translate coordinates relative to center
tx = nx_disp - cx
ty = ny_disp - cy
# Perform 2D rotation
rtx = tx * cosa - ty * sina
rty = tx * sina + ty * cosa
# Translate back and store result
nx_orig_norm = rtx + cx
ny_orig_norm = rty + cy
logging.debug(f"{log_prefix} Inverse rotation applied. Norm coords: ({nx_orig_norm:.4f}, {ny_orig_norm:.4f})")
# 3. Convert normalized original coordinates back to original pixel coordinates
# Clamp result to valid pixel range [0, width/height - 1]
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 ---
# 4. Calculate pixel offset from reference point
# Note y-axis inversion: display Y increases downwards, geo latitude increases upwards
pixel_delta_x = orig_x - ref_x
pixel_delta_y = ref_y - orig_y # Positive delta_y means pointing North from ref
# 5. Convert pixel offset to meter offset using scale factors
meters_delta_x = pixel_delta_x * scale_x # Positive delta_x = East
meters_delta_y = pixel_delta_y * scale_y # Positive delta_y = North
logging.debug(f"{log_prefix} Calculated offset from ref (pixels): dX={pixel_delta_x:.1f}, dY={pixel_delta_y:.1f}")
logging.debug(f"{log_prefix} Calculated offset from ref (meters): dX={meters_delta_x:.1f} (E), dY={meters_delta_y:.1f} (N)")
# 6. Convert meter offsets to degree offsets (approximate using simple sphere/ellipse model)
# Constants for WGS84 approximation
M_PER_DLAT = 111132.954 # Approx meters per degree latitude (constant)
M_PER_DLON_EQ = 111319.488 # Approx meters per degree longitude at equator
# Adjust meters per degree longitude based on reference latitude
# Avoid division by zero near poles (cos(pi/2)=0)
m_per_dlon = max(abs(M_PER_DLON_EQ * math.cos(ref_lat_rad)), 1e-3) # Use small non-zero minimum
# 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} Calculated offset (degrees): dLat={lat_offset_deg:.6f}, dLon={lon_offset_deg:.6f}")
# 7. Calculate final coordinates by adding offsets to reference 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} Calculated final coords (dec deg): Lat={final_lat_deg:.6f}, Lon={final_lon_deg:.6f}")
# --- Format Output ---
# 8. Validate calculated coordinates and format to DMS string
# Check for NaN/inf and reasonable lat/lon ranges
if (math.isfinite(final_lat_deg) and math.isfinite(final_lon_deg) and
abs(final_lat_deg) <= 90.0 and abs(final_lon_deg) <= 180.0):
# Use utility function for DMS conversion
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 returned an error string
if "Error" in lat_s or "Invalid" in lat_s or "Error" in lon_s or "Invalid" in lon_s:
logging.warning(f"{log_prefix} DMS conversion failed for valid decimal degrees.")
lat_s, lon_s = "Error DMS", "Error DMS"
else:
# Log warning if calculated coordinates are out of valid range
logging.warning(f"{log_prefix} Calculated coordinates out of valid range: Lat={final_lat_deg}, Lon={final_lon_deg}")
lat_s, lon_s = "Invalid Calc", "Invalid Calc"
except KeyError as ke:
# Log error if required keys are missing in state
logging.error(f"{log_prefix} Missing key in geo_info state during calculation: {ke}")
lat_s, lon_s = "Error Key", "Error Key"
except Exception as calc_e:
# Log any other unexpected calculation errors
logging.exception(f"{log_prefix} Geo calculation error:")
lat_s, lon_s = "Calc Error", "Calc Error"
# else: Geo info invalid, keep default "N/A" strings
# --- Queue Result ---
# Create the payload tuple (lat_s, lon_s)
result_payload = (lat_s, lon_s)
# Queue the result (tuple of strings) for the Tkinter thread
self.put_mouse_coordinates_queue(result_payload)
# Reschedule the processor to run again after a short delay
if not self.state.shutting_down:
self._reschedule_queue_processor(self.process_mouse_queue, delay=50) # Check every 50ms
def put_mouse_coordinates_queue(self, coords_tuple: Tuple[str, str]):
"""Puts processed mouse coords tuple onto Tkinter queue using command structure."""
log_prefix = "[App Mouse Queue Put]" # Specific prefix for this action
if self.state.shutting_down:
return # Don't queue if shutting down
# Structure: command, payload
command = 'MOUSE_COORDS'
payload = coords_tuple # Payload is the tuple (lat_s, lon_s)
logging.debug(f"{log_prefix} Putting command '{command}' with payload {payload} onto tkinter_queue.")
# Use the put_queue utility for safe queueing with drop counting
put_queue(
self.tkinter_queue,
(command, payload), # Send command and payload as a tuple
queue_name="tkinter", # Identify queue name for logging/stats
app_instance=self # Pass app instance for context
)
# --- Queue Processors ---
def process_sar_queue(self):
"""Gets processed SAR image from queue and displays it using DisplayManager."""
log_prefix = "[App QProc SAR]"
if self.state.shutting_down: return # Stop processing on shutdown
image_to_display = None
try:
# Get item non-blockingly from the queue
image_to_display = self.sar_queue.get(block=False)
# Mark task as done immediately after getting
self.sar_queue.task_done()
except queue.Empty:
pass # Normal case, queue is empty
except Exception as e:
# Log error getting from queue
logging.exception(f"{log_prefix} Error getting from SAR display queue:")
# Don't process item if get failed
# Process item if successfully retrieved
if image_to_display is not None:
logging.debug(f"{log_prefix} Dequeued SAR image (Shape: {getattr(image_to_display, 'shape', 'N/A')}). Calling DisplayManager...")
# Check if display manager exists
if hasattr(self, 'display_manager') and self.display_manager:
try:
# Call DisplayManager to show the image
self.display_manager.show_sar_image(image_to_display)
except Exception as display_e:
# Log errors during display call
logging.exception(f"{log_prefix} Error calling DisplayManager.show_sar_image:")
else:
logging.error(f"{log_prefix} DisplayManager not available to show SAR image.")
# else: Item was None (error during get or queue empty)
# Reschedule next check always
if not self.state.shutting_down:
self._reschedule_queue_processor(self.process_sar_queue) # Use default delay
def process_mfd_queue(self):
"""Gets processed MFD image from queue and displays it using DisplayManager."""
log_prefix = "[App QProc MFD]"
if self.state.shutting_down: return # Stop processing on shutdown
image_to_display = None
try:
# Get item non-blockingly
image_to_display = self.mfd_queue.get(block=False)
# Mark task done
self.mfd_queue.task_done()
except queue.Empty:
pass # Normal case
except Exception as e:
# Log error getting from queue
logging.exception(f"{log_prefix} Error getting from MFD display queue:")
# Process if item retrieved
if image_to_display is not None:
logging.debug(f"{log_prefix} Dequeued MFD image (Shape: {getattr(image_to_display, 'shape', 'N/A')}). Calling DisplayManager...")
# Check if display manager exists
if hasattr(self, 'display_manager') and self.display_manager:
try:
# Call DisplayManager to show the image
self.display_manager.show_mfd_image(image_to_display)
except Exception as display_e:
# Log errors during display call
logging.exception(f"{log_prefix} Error calling DisplayManager.show_mfd_image:")
else:
logging.error(f"{log_prefix} DisplayManager not available to show MFD image.")
# Reschedule next check always
if not self.state.shutting_down:
self._reschedule_queue_processor(self.process_mfd_queue) # Use default delay
def process_tkinter_queue(self):
"""Processes commands (mouse coords, map updates) from queue to update UI."""
log_prefix = "[App QProc Tkinter]"
if self.state.shutting_down: return # Stop processing on shutdown
item = None
try:
# Get item non-blockingly
item = self.tkinter_queue.get(block=False)
# Mark task done *after* getting item successfully
self.tkinter_queue.task_done()
except queue.Empty:
pass # Normal case
except Exception as e:
# Log error getting from queue
logging.exception(f"{log_prefix} Error getting from Tkinter queue:")
# Don't process item if get failed
# Process item if successfully retrieved
if item is not None:
try:
# Check if item is the expected command tuple structure
if isinstance(item, tuple) and len(item) == 2:
command, payload = item
logging.debug(f"{log_prefix} Dequeued Command:'{command}', Payload Type:{type(payload)}")
# Handle different commands
if command == 'MOUSE_COORDS':
# Update mouse coordinate display in UI
self._handle_mouse_coords_update(payload)
elif command == 'SHOW_MAP':
# Update the map display window (delegated to manager)
self._handle_show_map_update(payload)
else:
# Log warning for unknown commands
logging.warning(f"{log_prefix} Unknown command received: {command}")
# Handle legacy None for mouse coordinates (can be removed if put_mouse_coordinates_queue always sends tuple)
# elif item is None:
# self._handle_mouse_coords_update(None)
else:
# Log warning for unexpected item types
logging.warning(f"{log_prefix} Dequeued unexpected item type: {type(item)}")
except Exception as e:
# Log error during processing of the dequeued item
logging.exception(f"{log_prefix} Error processing dequeued Tkinter item:")
# Task already marked done, avoid potential infinite loops on bad data.
# Reschedule next check always
if not self.state.shutting_down:
# Check Tkinter queue less often than image queues
self._reschedule_queue_processor(self.process_tkinter_queue, delay=100)
def _handle_mouse_coords_update(self, payload: Optional[Tuple[str, str]]):
"""Updates the mouse coordinates UI label."""
log_prefix = "[App QProc Tkinter]" # Part of Tkinter queue processing
lat_s, lon_s = ("N/A", "N/A") # Default values
# Check if payload is the expected tuple of strings
if payload is not None:
if isinstance(payload, tuple) and len(payload) == 2:
lat_s, lon_s = payload # Unpack the strings
else:
# Log error if payload is not None or the expected tuple format
logging.warning(f"{log_prefix} Invalid payload type/format for MOUSE_COORDS: {type(payload)}")
lat_s, lon_s = ("Error", "Error") # Indicate error in UI
# Safely update the UI label
try:
# Check if control panel and the specific method exist
if hasattr(self,'control_panel') and hasattr(self.control_panel, 'set_mouse_coordinates'):
# Call the method on the ControlPanel instance
self.control_panel.set_mouse_coordinates(lat_s, lon_s)
# else: # Don't log if UI elements not ready, expected during init/shutdown
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:
# Log other unexpected errors during UI update
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 command by delegating display to the MapIntegrationManager.
Args:
payload (Optional[Image.Image]): The PIL Image object (or None) to display.
"""
log_prefix = "[App QProc Tkinter]" # Part of Tkinter queue processing
logging.debug(f"{log_prefix} Processing SHOW_MAP command...")
# Check if the map manager is active
if hasattr(self, 'map_integration_manager') and self.map_integration_manager:
try:
# Delegate the display task to the manager's method
self.map_integration_manager.display_map(payload)
except Exception as e:
# Log errors calling the manager's method
logging.exception(f"{log_prefix} Error calling map_integration_manager.display_map:")
else:
# Log warning if command received but map manager is not active
logging.warning(f"{log_prefix} Received SHOW_MAP command but MapIntegrationManager is not active.")
def _reschedule_queue_processor(self, processor_func, delay: Optional[int] = None):
"""Helper method to reschedule a queue processor function using root.after."""
# Calculate default delay if none provided
if delay is None:
# Default delay based on target MFD FPS for image queues (run slightly faster than FPS)
if processor_func in [self.process_sar_queue, self.process_mfd_queue]:
target_fps = config.MFD_FPS
# Calculate delay in ms, ensure minimum delay (e.g., 10ms)
delay = max(10, int(1000 / (target_fps * 1.5))) if target_fps > 0 else 20 # Default ~20ms if FPS is 0/invalid
else:
# Default delay for other queues (like tkinter, mouse) - check less often
delay = 100 # ms
# Schedule the next call using root.after, checking root existence
try:
if self.root and self.root.winfo_exists():
self.root.after(delay, processor_func)
# else: Don't warn if root is gone, expected during shutdown
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]"
# Check shutdown flag and state initialization
if not hasattr(self, 'state') or self.state.shutting_down:
return
# Avoid updating status if initial map load is in progress (status set by map manager)
map_loading = False
try:
# Check if status bar indicates map loading
if hasattr(self,'statusbar') and self.statusbar.winfo_exists() and "Loading initial map" in self.statusbar.cget("text"):
map_loading = True
logging.debug(f"{log_prefix} Skipping status update while initial map loads.")
return
except Exception as status_check_e:
# Ignore errors checking status bar text
logging.debug(f"{log_prefix} Error checking status bar for map loading message: {status_check_e}")
logging.debug(f"{log_prefix} Updating status bar and statistics labels...")
# Get latest statistics dictionary from AppState (thread-safe read)
stats = self.state.get_statistics()
try:
# --- Format Status String Components ---
# Mode
mode = "Test" if self.state.test_mode_active else ("Local" if config.USE_LOCAL_IMAGES else "Network")
# Map Status
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 ""
# FPS (use values directly from state)
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"
# Mouse Coordinates (get current text from UI label)
mouse_txt = "Mouse: N/A" # Default
if hasattr(self.control_panel,'mouse_latlon_label') and self.control_panel.mouse_latlon_label.winfo_exists():
try:
# Get text directly, no need for full 'Mouse: ...' prefix if label holds it
mouse_label_text = self.control_panel.mouse_latlon_label.cget("text")
# Extract coords part if label format is consistent
if mouse_label_text.startswith("Mouse"):
mouse_txt = mouse_label_text # Keep full label text
else: # Assume label only contains coords
mouse_txt = f"Mouse: {mouse_label_text}"
except Exception:
mouse_txt="Mouse: UI Error" # Fallback on error getting label text
# Assemble final status string (first part of status bar)
status_prefix = f"Status: {mode}{map_stat}" # Use the prefix set by set_status
status_info = f"{mfd_fps} | {sar_fps} | {mouse_txt}"
full_status = f"{status_prefix} | {status_info}" # Combine prefix and 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']},"
f"M={stats['incomplete_mfd_rx']}")
logging.debug(f"{log_prefix} Formatted status strings. Status: '{full_status}', Drop: '{drop_txt}', Incmpl: '{incmpl_txt}'")
# --- Schedule UI updates on main thread using after_idle ---
if self.root and self.root.winfo_exists():
# Update Status Bar
if hasattr(self,'statusbar') and self.statusbar.winfo_exists():
# Update the full text using status bar's method
self.root.after_idle(self.statusbar.set_status_text, full_status)
# Update Dropped Label
if hasattr(self.control_panel,'dropped_label') and self.control_panel.dropped_label.winfo_exists():
self.root.after_idle(self.control_panel.dropped_label.config, {"text": drop_txt})
# Update Incomplete Label
if hasattr(self.control_panel,'incomplete_label') and self.control_panel.incomplete_label.winfo_exists():
self.root.after_idle(self.control_panel.incomplete_label.config, {"text": incmpl_txt})
except tk.TclError as e:
# Log TclErrors (widget likely destroyed during update) only if not shutting down
if not self.state.shutting_down:
logging.warning(f"{log_prefix} TclError updating status UI: {e}")
except Exception as e:
# Log other unexpected errors during formatting/scheduling
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]"
# Prevent double execution if called multiple times
if hasattr(self, 'state') and self.state.shutting_down:
logging.warning(f"{log_prefix} Close sequence already initiated. Ignoring request.")
return
# Ensure state exists before setting flag
if not hasattr(self, 'state'):
logging.error(f"{log_prefix} Cannot initiate shutdown: AppState not found.")
# Attempt basic exit?
sys.exit(1)
logging.info(f"{log_prefix} Close application requested. Starting shutdown sequence...")
# 1. Set Shutdown Flag in Central State
logging.debug(f"{log_prefix} Setting shutdown flag to True in AppState.")
self.state.shutting_down = True
# Attempt to update status bar (might fail if UI closing)
try:
# Use set_status for thread safety if called from non-main thread
self.set_status("Closing...")
except Exception:
pass # Ignore errors setting status during shutdown
# 2. Stop Test Mode Timers (via manager)
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()
else:
logging.debug(f"{log_prefix} TestModeManager not found or already cleaned up.")
# 3. Stop Map Integration (if active)
logging.debug(f"{log_prefix} Shutting down MapIntegrationManager...")
if hasattr(self, 'map_integration_manager') and self.map_integration_manager:
self.map_integration_manager.shutdown() # Closes map window
else:
logging.debug(f"{log_prefix} MapIntegrationManager not found or not active.")
# 4. Stop Periodic UI Updates & Queue Processors
# These check the self.state.shutting_down flag internally, no explicit stop needed,
# but log the intent.
logging.debug(f"{log_prefix} Signalling periodic status updates and queue processors to stop.")
# 5. Close UDP Socket
logging.debug(f"{log_prefix} Closing UDP socket...")
if self.udp_socket:
close_udp_socket(self.udp_socket) # Use utility function
self.udp_socket = None # Clear reference
logging.debug(f"{log_prefix} UDP socket closed.")
else:
logging.debug(f"{log_prefix} UDP socket was not open or already closed.")
# 6. Wait briefly for UDP receiver thread to exit (it checks shutdown flag)
if self.udp_thread and self.udp_thread.is_alive():
logging.debug(f"{log_prefix} Waiting up to 0.5s for UDP receiver thread...")
self.udp_thread.join(timeout=0.5)
if self.udp_thread.is_alive():
logging.warning(f"{log_prefix} UDP receiver thread did not exit cleanly after 0.5s.")
else:
logging.debug(f"{log_prefix} UDP receiver thread exited.")
elif self.udp_thread:
logging.debug(f"{log_prefix} UDP receiver thread reference exists but thread already finished.")
else:
logging.debug(f"{log_prefix} No UDP receiver thread instance found.")
# 7. Shutdown Executor Pool from Receiver (if receiver exists)
worker_pool = None
if hasattr(self, 'udp_receiver') and self.udp_receiver:
# Access executor attribute safely
worker_pool = getattr(self.udp_receiver, 'executor', None)
if worker_pool:
logging.info(f"{log_prefix} Shutting down worker pool (non-blocking)...")
try:
# Shutdown gracefully, don't wait indefinitely, don't cancel running tasks
worker_pool.shutdown(wait=False, cancel_futures=False)
logging.debug(f"{log_prefix} Worker pool shutdown initiated.")
# Note: Tasks within the pool should ideally check the shutting_down flag too.
except Exception as e:
logging.exception(f"{log_prefix} Exception during worker_pool shutdown: {e}")
else:
logging.debug(f"{log_prefix} Worker pool not found or not initialized in UdpReceiver.")
# 8. Destroy Display Windows (MFD, SAR) via DisplayManager
logging.debug(f"{log_prefix} Destroying MFD/SAR display windows via DisplayManager...")
if hasattr(self, 'display_manager'):
self.display_manager.destroy_windows() # Handles internal logging and checks
else:
logging.debug(f"{log_prefix} DisplayManager not found or already cleaned up.")
# 9. Brief wait for OpenCV events processing (belt-and-braces)
try:
logging.debug(f"{log_prefix} Calling cv2.waitKey(5) for final OpenCV cleanup...")
cv2.waitKey(5)
except Exception as e:
logging.warning(f"{log_prefix} Error during final cv2.waitKey: {e}")
# 10. Destroy Tkinter Root Window (last step)
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 tk.TclError as e:
# Expected if window closed manually before app shutdown logic completed
logging.warning(f"{log_prefix} Error destroying Tkinter window (likely closed already): {e}")
except Exception as e:
# Log other unexpected errors during root window destruction
logging.exception(f"{log_prefix} Unexpected error destroying Tkinter window:")
# --- Final Log Messages ---
logging.info("-----------------------------------------")
logging.info(f"{log_prefix} Application close sequence finished.")
logging.info("-----------------------------------------")
# Exit the application cleanly
# Use sys.exit(0) for normal exit
sys.exit(0)
# --- Main Execution Block ---
if __name__ == "__main__":
main_log_prefix = "[App Main]"
root = None # Tkinter root window instance
app = None # App instance
try:
# --- Pre-Initialization Checks ---
# Check Map Module Dependencies *before* creating Tkinter window if map enabled
if config.ENABLE_MAP_OVERLAY and not MAP_MODULES_LOADED:
# Log critical error and exit if map enabled but components missing
logging.critical(
f"{main_log_prefix} Map Overlay is enabled in config "
"but required map modules failed to load. Cannot start."
)
sys.exit(1) # Exit with error code
# --- Application Setup ---
logging.debug(f"{main_log_prefix} Creating main Tkinter window...")
# Create the root window using the UI utility function
root = create_main_window(
title="Control Panel v1.1", # Updated version maybe?
min_width=config.TKINTER_MIN_WIDTH,
min_height=config.TKINTER_MIN_HEIGHT,
x_pos=10, # Initial position, App.__init__ recalculates and sets geometry
y_pos=10
)
logging.debug(f"{main_log_prefix} Main Tkinter window created.")
logging.debug(f"{main_log_prefix} Initializing App class...")
# Initialize the main application logic, passing the root window
app = App(root)
logging.debug(f"{main_log_prefix} App class initialized.")
# Set the close button behaviour (calls app.close_app for graceful shutdown)
root.protocol("WM_DELETE_WINDOW", app.close_app)
logging.debug(f"{main_log_prefix} WM_DELETE_WINDOW protocol set to call app.close_app.")
# --- Start Event Loop ---
logging.info(f"{main_log_prefix} Starting Tkinter main event loop (root.mainloop())...")
# This blocks until the root window is destroyed
root.mainloop()
# Code here is reached only after mainloop ends (typically via root.destroy() in close_app)
logging.info(f"{main_log_prefix} Tkinter main event loop finished.")
except SystemExit as exit_e:
# Catch sys.exit() calls for clean shutdown messages
if exit_e.code == 0:
# Normal exit initiated by close_app
logging.info(f"{main_log_prefix} Application exited normally (sys.exit code 0).")
else:
# Exit due to error (e.g., failed pre-check)
logging.warning(f"{main_log_prefix} Application exited with error code {exit_e.code}.")
except Exception as e:
# Catch any other unhandled exceptions during setup or mainloop run
logging.critical(f"{main_log_prefix} UNHANDLED EXCEPTION OCCURRED:", exc_info=True)
# Attempt emergency cleanup only if app instance exists and wasn't already shutting down
if app and hasattr(app,'state') and not app.state.shutting_down:
logging.error(f"{main_log_prefix} Attempting emergency cleanup due to unhandled exception...")
try:
# Call close directly, don't rely on mainloop anymore
app.close_app()
except SystemExit:
# close_app initiated the exit, this is expected here
pass
except Exception as cleanup_e:
# Log any error during the emergency cleanup attempt
logging.exception(f"{main_log_prefix} Error during emergency cleanup: {cleanup_e}")
# Exit with error code after attempting cleanup or if app never initialized
sys.exit(1)
finally:
# This block executes after try/except, even after sys.exit() is called
logging.info(f"{main_log_prefix} Application finally block reached.")
# Final OpenCV cleanup attempt (belt-and-braces, may not be necessary if managers clean up)
logging.debug(f"{main_log_prefix} Final check: Destroying any remaining OpenCV windows...")
try:
# This might destroy windows not managed by DisplayManager/MapIntegrationManager if any exist
cv2.destroyAllWindows()
except Exception as cv_err:
# Warning is sufficient here, main cleanup happened in close_app/managers
logging.warning(f"{main_log_prefix} Exception during final cv2.destroyAllWindows(): {cv_err}")
logging.info("================ Application End ================")
# Ensure all log messages are flushed before exit
logging.shutdown()