diff --git a/ControlPanel.py b/ControlPanel.py index 868e44e..c2977a3 100644 --- a/ControlPanel.py +++ b/ControlPanel.py @@ -38,6 +38,7 @@ import screeninfo # --- PIL Import and Type Definition --- try: from PIL import Image, ImageTk + ImageType = Image.Image # type: ignore except ImportError: ImageType = Any # Fallback type hint @@ -53,6 +54,7 @@ import config # --- Logging Setup --- try: from logging_config import setup_logging + setup_logging() except ImportError: print("ERROR: logging_config.py not found. Using basic logging.") @@ -78,7 +80,7 @@ from utils import ( generate_lookat_and_point_kml, _simplekml_available, _pyproj_available, - format_ctypes_structure + format_ctypes_structure, ) from network import create_udp_socket, close_udp_socket from receiver import UdpReceiver @@ -93,6 +95,7 @@ map_libs_found = True try: import mercantile import pyproj + if Image is None and ImageType is not Any: raise ImportError("Pillow failed import") except ImportError as map_lib_err: @@ -113,6 +116,7 @@ if map_libs_found: from map_utils import MapCalculationError from map_display import MapDisplayWindow from map_integration import MapIntegrationManager + MAP_MODULES_LOADED = True except ImportError as map_import_err: logging.warning( @@ -144,12 +148,12 @@ class ControlPanelApp: self.root.title("Control Panel") try: # Determine script directory safely - if getattr(sys, 'frozen', False): # Running as compiled executable - script_dir = os.path.dirname(sys.executable) - elif "__file__" in locals() or "__file__" in globals(): # Running as script - script_dir = os.path.dirname(os.path.abspath(__file__)) - else: # Fallback (interactive mode?) - script_dir = os.getcwd() + if getattr(sys, "frozen", False): # Running as compiled executable + script_dir = os.path.dirname(sys.executable) + elif "__file__" in locals() or "__file__" in globals(): # Running as script + script_dir = os.path.dirname(os.path.abspath(__file__)) + else: # Fallback (interactive mode?) + script_dir = os.getcwd() icon_path = os.path.join(script_dir, "ControlPanel.ico") if os.path.exists(icon_path): @@ -176,7 +180,7 @@ class ControlPanelApp: # Store original/expanded widths for dynamic resizing self.original_window_width = config.TKINTER_MIN_WIDTH - self.metadata_panel_width = 300 # Adjusted width example + self.metadata_panel_width = 300 # Adjusted width example self.expanded_window_width = ( self.original_window_width + self.metadata_panel_width + 10 ) @@ -208,7 +212,7 @@ class ControlPanelApp: # Initialize ControlPanel UI (UIPanel class from ui.py) self.control_panel = UIPanel(self.container_frame, self) # Grid the control panel into the container's first column - self.control_panel.grid(row=0, column=0, sticky='nsew') + self.control_panel.grid(row=0, column=0, sticky="nsew") # --- Create Metadata Frame Structure (as attribute of self) --- # Create metadata frame as child of the container_frame @@ -226,10 +230,10 @@ class ControlPanelApp: self.metadata_display_text = tk.Text( self.metadata_text_frame, wrap=tk.NONE, - state='disabled', + state="disabled", height=8, yscrollcommand=self.metadata_scrollbar.set, - font=("Courier New", 8) + font=("Courier New", 8), ) # Configure scrollbar self.metadata_scrollbar.config(command=self.metadata_display_text.yview) @@ -237,9 +241,7 @@ class ControlPanelApp: self.metadata_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.metadata_display_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Set initial placeholder text using the method on self - self.set_metadata_display( - "Enable 'Show SAR Metadata' checkbox to view data..." - ) + self.set_metadata_display("Enable 'Show SAR Metadata' checkbox to view data...") # NOTE: metadata_frame is CREATED but NOT GRIDDED here initially logging.debug( f"{log_prefix} Metadata Display frame structure created but not gridded." @@ -248,20 +250,15 @@ class ControlPanelApp: # --- Initialize Sub-systems --- # Calculate positions for external windows self.mfd_x, self.mfd_y = self._calculate_mfd_position() - self.sar_x, self.sar_y = self._calculate_sar_position( - screen_w, initial_sar_w - ) + self.sar_x, self.sar_y = self._calculate_sar_position(screen_w, initial_sar_w) map_max_w = ( - config.MAX_MAP_DISPLAY_WIDTH if not MapDisplayWindow + config.MAX_MAP_DISPLAY_WIDTH + if not MapDisplayWindow else getattr( - MapDisplayWindow, - "MAX_DISPLAY_WIDTH", - config.MAX_MAP_DISPLAY_WIDTH + MapDisplayWindow, "MAX_DISPLAY_WIDTH", config.MAX_MAP_DISPLAY_WIDTH ) ) - map_x, map_y = self._calculate_map_position( - screen_w, initial_sar_w, map_max_w - ) + map_x, map_y = self._calculate_map_position(screen_w, initial_sar_w, map_max_w) # Initialize Display Manager self.display_manager = DisplayManager( @@ -272,8 +269,8 @@ class ControlPanelApp: self.sar_y, self.mfd_x, self.mfd_y, - initial_sar_w, # Correct variable name - initial_sar_h, # Correct variable name + initial_sar_w, # Correct variable name + initial_sar_h, # Correct variable name ) try: self.display_manager.initialize_display_windows() @@ -362,21 +359,23 @@ class ControlPanelApp: return try: statusbar = getattr(self, "statusbar", None) - if (statusbar - and isinstance(statusbar, tk.Widget) - and statusbar.winfo_exists()): + if ( + statusbar + and isinstance(statusbar, tk.Widget) + and statusbar.winfo_exists() + ): current_text: str = statusbar.cget("text") # Preserve info after the first '|' - parts = current_text.split("|", 1) # Split only once + parts = current_text.split("|", 1) # Split only once suffix = "" if len(parts) > 1: - suffix = f" | {parts[1].strip()}" # Reconstruct suffix + suffix = f" | {parts[1].strip()}" # Reconstruct suffix final_text = f"{new_status_prefix}{suffix}" # Call the specific method of the StatusBar class if available - if hasattr(statusbar, 'set_status_text'): - statusbar.set_status_text(final_text) - else: # Fallback - statusbar.config(text=final_text) + if hasattr(statusbar, "set_status_text"): + statusbar.set_status_text(final_text) + else: # Fallback + statusbar.config(text=final_text) except Exception as e: logging.exception(f"{log_prefix} Error updating status bar text:") @@ -462,9 +461,9 @@ class ControlPanelApp: # Create a single channel grayscale ramp gray_ramp = np.arange(256, dtype=np.uint8)[:, np.newaxis] # Convert grayscale ramp to 3-channel BGR - self.state.mfd_lut = cv2.cvtColor( - gray_ramp, cv2.COLOR_GRAY2BGR - )[:, 0, :] # Remove added dimension + self.state.mfd_lut = cv2.cvtColor(gray_ramp, cv2.COLOR_GRAY2BGR)[ + :, 0, : + ] # Remove added dimension except Exception as fb_e: logging.critical(f"[MFD LUT Update] Fallback LUT failed: {fb_e}") # Ultimate fallback: all black @@ -474,21 +473,25 @@ class ControlPanelApp: def update_image_mode(self): """Callback for the Test Image checkbox.""" log_prefix = "[App Mode Switch]" - if (not hasattr(self, "state") - or not hasattr(self, "test_mode_manager") - or self.state.shutting_down): + if ( + not hasattr(self, "state") + or not hasattr(self, "test_mode_manager") + or self.state.shutting_down + ): return try: cp = getattr(self, "control_panel", None) var = getattr(cp, "test_image_var", None) if cp else None # Determine the requested state from the checkbox variable - is_test_req = var.get() == 1 if ( - var and isinstance(var, tk.Variable) - ) else self.state.test_mode_active + is_test_req = ( + var.get() == 1 + if (var and isinstance(var, tk.Variable)) + else self.state.test_mode_active + ) # Only act if the state is actually changing if is_test_req != self.state.test_mode_active: - self.state.test_mode_active = is_test_req # Update state first + self.state.test_mode_active = is_test_req # Update state first if is_test_req: # Activate test mode manager and related UI actions if self.test_mode_manager.activate(): @@ -523,9 +526,9 @@ class ControlPanelApp: if size_str and ":" in size_str: try: factor_val = int(size_str.split(":")[1]) - factor = max(1, factor_val) # Ensure factor is at least 1 + factor = max(1, factor_val) # Ensure factor is at least 1 except (ValueError, IndexError): - factor = 1 # Default to 1 on parsing error + factor = 1 # Default to 1 on parsing error # Calculate new dimensions w = max(1, config.SAR_WIDTH // factor) h = max(1, config.SAR_HEIGHT // factor) @@ -552,7 +555,9 @@ class ControlPanelApp: # Trigger reprocessing/redisplay of SAR image self._trigger_sar_update() except ValueError: - logging.warning(f"[App CB SAR Contrast] Invalid contrast value: {value_str}") + logging.warning( + f"[App CB SAR Contrast] Invalid contrast value: {value_str}" + ) except Exception as e: logging.exception(f"[App CB SAR Contrast] Error updating contrast: {e}") @@ -570,7 +575,9 @@ class ControlPanelApp: # Trigger reprocessing/redisplay of SAR image self._trigger_sar_update() except ValueError: - logging.warning(f"[App CB SAR Brightness] Invalid brightness value: {value_str}") + logging.warning( + f"[App CB SAR Brightness] Invalid brightness value: {value_str}" + ) except Exception as e: logging.exception(f"[App CB SAR Brightness] Error updating brightness: {e}") @@ -609,13 +616,17 @@ class ControlPanelApp: # Clamp value to valid range 0-255 clamped_value = np.clip(intensity_value, 0, 255) # Update the intensity in the MFD parameters dictionary - self.state.mfd_params["categories"][category_name]["intensity"] = clamped_value + self.state.mfd_params["categories"][category_name][ + "intensity" + ] = clamped_value # Recalculate the MFD LUT based on the change self.update_mfd_lut() # Trigger reprocessing/redisplay of MFD image self._trigger_mfd_update() else: - logging.warning(f"[App CB MFD Intensity] Unknown category: {category_name}") + logging.warning( + f"[App CB MFD Intensity] Unknown category: {category_name}" + ) except Exception as e: logging.exception( f"[App CB MFD Intensity] Error updating intensity for '{category_name}': {e}" @@ -633,7 +644,9 @@ class ControlPanelApp: return try: initial_bgr = self.state.mfd_params["categories"][category_name]["color"] - initial_hex = f"#{initial_bgr[2]:02x}{initial_bgr[1]:02x}{initial_bgr[0]:02x}" + initial_hex = ( + f"#{initial_bgr[2]:02x}{initial_bgr[1]:02x}{initial_bgr[0]:02x}" + ) color_code = colorchooser.askcolor( title=f"Select Color for {category_name}", initialcolor=initial_hex ) @@ -641,14 +654,20 @@ class ControlPanelApp: if color_code and color_code[0]: rgb = color_code[0] # Convert RGB to BGR tuple, clamping values - new_bgr = tuple(np.clip(int(c), 0, 255) for c in (rgb[2], rgb[1], rgb[0])) + new_bgr = tuple( + np.clip(int(c), 0, 255) for c in (rgb[2], rgb[1], rgb[0]) + ) # Update state self.state.mfd_params["categories"][category_name]["color"] = new_bgr self.update_mfd_lut() # Schedule UI update for the color preview cp = getattr(self, "control_panel", None) - if (self.root and self.root.winfo_exists() and cp - and hasattr(cp, "update_mfd_color_display")): + if ( + self.root + and self.root.winfo_exists() + and cp + and hasattr(cp, "update_mfd_color_display") + ): self.root.after_idle( cp.update_mfd_color_display, category_name, new_bgr ) @@ -719,9 +738,7 @@ class ControlPanelApp: # Trigger a map redraw using recomposition (faster) self.trigger_map_redraw(full_update=False) except Exception as e: - logging.exception( - f"{log_prefix} Error handling alpha slider release: {e}" - ) + logging.exception(f"{log_prefix} Error handling alpha slider release: {e}") def toggle_sar_recording(self): """Callback for the Record SAR checkbox.""" @@ -790,7 +807,7 @@ class ControlPanelApp: ) if file_path: save_dir = os.path.dirname(file_path) - save_fn = Path(file_path).stem # Filename without extension + save_fn = Path(file_path).stem # Filename without extension mgr.save_map_view_to_file(directory=save_dir, filename=save_fn) else: self.set_status("Save map view cancelled.") @@ -823,42 +840,52 @@ class ControlPanelApp: coords_text = cp.map_mouse_coords_var.get() source_desc = "Map Mouse" else: - logging.warning(f"{log_prefix} Unknown coordinate source: {coord_source}") + logging.warning( + f"{log_prefix} Unknown coordinate source: {coord_source}" + ) return - if (not coords_text or "N/A" in coords_text - or "Error" in coords_text or "Invalid" in coords_text): - self.set_status(f"Error: No valid coordinates for {source_desc}.") - return + if ( + not coords_text + or "N/A" in coords_text + or "Error" in coords_text + or "Invalid" in coords_text + ): + self.set_status(f"Error: No valid coordinates for {source_desc}.") + return # Parse the "Lat=..., Lon=..." string lon_sep = ", Lon=" if lon_sep in coords_text: - parts = coords_text.split(lon_sep, 1) - lat_dms_str = None - if 'Lat=' in parts[0]: - lat_dms_str = parts[0].split('=', 1)[1].strip() - lon_dms_str = parts[1].strip() - if lat_dms_str and lon_dms_str: - lat_deg = dms_string_to_decimal(lat_dms_str, is_latitude=True) - lon_deg = dms_string_to_decimal(lon_dms_str, is_latitude=False) - else: - raise ValueError("Could not extract Lat/Lon parts") + parts = coords_text.split(lon_sep, 1) + lat_dms_str = None + if "Lat=" in parts[0]: + lat_dms_str = parts[0].split("=", 1)[1].strip() + lon_dms_str = parts[1].strip() + if lat_dms_str and lon_dms_str: + lat_deg = dms_string_to_decimal(lat_dms_str, is_latitude=True) + lon_deg = dms_string_to_decimal(lon_dms_str, is_latitude=False) + else: + raise ValueError("Could not extract Lat/Lon parts") else: - raise ValueError("Separator ', Lon=' not found") + raise ValueError("Separator ', Lon=' not found") if lat_deg is None or lon_deg is None: - self.set_status(f"Error: Cannot parse coordinates for {source_desc}.") - return + self.set_status(f"Error: Cannot parse coordinates for {source_desc}.") + return # Call utility function open_google_maps(lat_deg, lon_deg) except ValueError as ve: - logging.error(f"{log_prefix} Parsing error for {source_desc}: {ve} (Text: '{coords_text}')") - self.set_status(f"Error parsing coords for {source_desc}.") + logging.error( + f"{log_prefix} Parsing error for {source_desc}: {ve} (Text: '{coords_text}')" + ) + self.set_status(f"Error parsing coords for {source_desc}.") except Exception as e: - logging.exception(f"{log_prefix} Error opening Google Maps for {source_desc}:") - self.set_status(f"Error opening map for {source_desc}.") + logging.exception( + f"{log_prefix} Error opening Google Maps for {source_desc}:" + ) + self.set_status(f"Error opening map for {source_desc}.") def go_to_google_earth(self, coord_source: str): """Callback for 'GE' buttons; opens Google Earth with LookAt/Point KML.""" @@ -872,8 +899,8 @@ class ControlPanelApp: control_panel_ref = getattr(self, "control_panel", None) if not control_panel_ref: - logging.error(f"{log_prefix} Control Panel UI reference not found.") - return + logging.error(f"{log_prefix} Control Panel UI reference not found.") + return coords_text: Optional[str] = None source_desc: str = "Unknown" @@ -896,34 +923,40 @@ class ControlPanelApp: source_desc = "Map Mouse" placemark_name = "Mouse on Map" else: - logging.warning(f"{log_prefix} Unknown coordinate source: {coord_source}") + logging.warning( + f"{log_prefix} Unknown coordinate source: {coord_source}" + ) return # Validate coordinates text - if (not coords_text or "N/A" in coords_text - or "Error" in coords_text or "Invalid" in coords_text): - self.set_status(f"Error: No valid coordinates for {source_desc}.") - return + if ( + not coords_text + or "N/A" in coords_text + or "Error" in coords_text + or "Invalid" in coords_text + ): + self.set_status(f"Error: No valid coordinates for {source_desc}.") + return # Parse the DMS string to get decimal degrees lon_sep = ", Lon=" if lon_sep in coords_text: - parts = coords_text.split(lon_sep, 1) - lat_dms_str = None - if 'Lat=' in parts[0]: - lat_dms_str = parts[0].split('=', 1)[1].strip() - lon_dms_str = parts[1].strip() - if lat_dms_str and lon_dms_str: - lat_deg = dms_string_to_decimal(lat_dms_str, is_latitude=True) - lon_deg = dms_string_to_decimal(lon_dms_str, is_latitude=False) - else: - raise ValueError("Could not split Lat/Lon parts") + parts = coords_text.split(lon_sep, 1) + lat_dms_str = None + if "Lat=" in parts[0]: + lat_dms_str = parts[0].split("=", 1)[1].strip() + lon_dms_str = parts[1].strip() + if lat_dms_str and lon_dms_str: + lat_deg = dms_string_to_decimal(lat_dms_str, is_latitude=True) + lon_deg = dms_string_to_decimal(lon_dms_str, is_latitude=False) + else: + raise ValueError("Could not split Lat/Lon parts") else: - raise ValueError("Separator ', Lon=' not found") + raise ValueError("Separator ', Lon=' not found") if lat_deg is None or lon_deg is None: - self.set_status(f"Error: Cannot parse coordinates for {source_desc}.") - return + self.set_status(f"Error: Cannot parse coordinates for {source_desc}.") + return # Generate Temporary KML logging.debug( @@ -933,7 +966,7 @@ class ControlPanelApp: latitude_deg=lat_deg, longitude_deg=lon_deg, placemark_name=placemark_name, - placemark_desc=f"Source: {source_desc}\nCoords: {coords_text}" + placemark_desc=f"Source: {source_desc}\nCoords: {coords_text}", ) # Launch Google Earth if KML generated @@ -947,15 +980,15 @@ class ControlPanelApp: self.set_status("Error: Failed to create KML.") except ValueError as ve: - logging.error( - f"{log_prefix} Parsing error for {source_desc}: {ve} (Text: '{coords_text}')" - ) - self.set_status(f"Error parsing coords for {source_desc}.") + logging.error( + f"{log_prefix} Parsing error for {source_desc}: {ve} (Text: '{coords_text}')" + ) + self.set_status(f"Error parsing coords for {source_desc}.") except Exception as e: - logging.exception( - f"{log_prefix} Error preparing/launching GE for {source_desc}:" - ) - self.set_status(f"Error launching GE for {source_desc}.") + logging.exception( + f"{log_prefix} Error preparing/launching GE for {source_desc}:" + ) + self.set_status(f"Error launching GE for {source_desc}.") def go_to_all_gearth(self): """Callback for 'GE All' button; opens Google Earth with multiple points.""" @@ -969,8 +1002,8 @@ class ControlPanelApp: control_panel_ref = getattr(self, "control_panel", None) if not control_panel_ref: - logging.error(f"{log_prefix} Control Panel UI reference not found.") - return + logging.error(f"{log_prefix} Control Panel UI reference not found.") + return points_to_plot: List[Tuple[float, float, str, Optional[str]]] = [] @@ -978,7 +1011,7 @@ class ControlPanelApp: source_map = { "SAR Center": ("SAR Center", control_panel_ref.sar_center_coords_var), "SAR Mouse": ("Mouse on SAR", control_panel_ref.mouse_coords_var), - "Map Mouse": ("Mouse on Map", control_panel_ref.map_mouse_coords_var) + "Map Mouse": ("Mouse on Map", control_panel_ref.map_mouse_coords_var), } # Iterate through sources, parse coordinates, add valid points @@ -988,33 +1021,50 @@ class ControlPanelApp: logging.debug( f"{log_prefix} Processing {internal_name} (KML: {kml_name}) - Text: '{coords_text}'" ) - if (coords_text and "N/A" not in coords_text - and "Error" not in coords_text and "Invalid" not in coords_text): + if ( + coords_text + and "N/A" not in coords_text + and "Error" not in coords_text + and "Invalid" not in coords_text + ): try: lon_sep = ", Lon=" if lon_sep in coords_text: - parts = coords_text.split(lon_sep, 1) - lat_dms_str = parts[0].split('=', 1)[1].strip() if 'Lat=' in parts[0] else None - lon_dms_str = parts[1].strip() - if lat_dms_str and lon_dms_str: - lat_deg = dms_string_to_decimal(lat_dms_str, is_latitude=True) - lon_deg = dms_string_to_decimal(lon_dms_str, is_latitude=False) - else: - raise ValueError("Could not split Lat/Lon parts") + parts = coords_text.split(lon_sep, 1) + lat_dms_str = ( + parts[0].split("=", 1)[1].strip() + if "Lat=" in parts[0] + else None + ) + lon_dms_str = parts[1].strip() + if lat_dms_str and lon_dms_str: + lat_deg = dms_string_to_decimal( + lat_dms_str, is_latitude=True + ) + lon_deg = dms_string_to_decimal( + lon_dms_str, is_latitude=False + ) + else: + raise ValueError("Could not split Lat/Lon parts") else: - raise ValueError("Separator ', Lon=' not found") + raise ValueError("Separator ', Lon=' not found") if lat_deg is not None and lon_deg is not None: points_to_plot.append( - (lat_deg, lon_deg, kml_name, f"Source: {internal_name}\nCoords: {coords_text}") + ( + lat_deg, + lon_deg, + kml_name, + f"Source: {internal_name}\nCoords: {coords_text}", + ) ) logging.debug( f"{log_prefix} Added valid point: {kml_name} ({lat_deg:.6f}, {lon_deg:.6f})" ) else: - logging.warning( - f"{log_prefix} Could not parse coords for {internal_name} from '{coords_text}'" - ) + logging.warning( + f"{log_prefix} Could not parse coords for {internal_name} from '{coords_text}'" + ) except ValueError as ve: logging.error( f"{log_prefix} Parsing error for {internal_name}: {ve} (Text: '{coords_text}')" @@ -1024,9 +1074,9 @@ class ControlPanelApp: f"{log_prefix} Error parsing coords for {internal_name}: {parse_err}" ) else: - logging.warning( - f"{log_prefix} Skipping invalid/unavailable coords for {internal_name}." - ) + logging.warning( + f"{log_prefix} Skipping invalid/unavailable coords for {internal_name}." + ) # Check if any valid points were found if not points_to_plot: @@ -1051,10 +1101,10 @@ class ControlPanelApp: logging.error(f"{log_prefix} Failed to generate multi-point KML file.") self.set_status("Error: Failed to create KML.") except Exception as e: - logging.exception( - f"{log_prefix} Error preparing/launching GE for multiple points:" - ) - self.set_status("Error launching Google Earth.") + logging.exception( + f"{log_prefix} Error preparing/launching GE for multiple points:" + ) + self.set_status("Error launching Google Earth.") def toggle_sar_metadata_display(self): """Callback for 'Show SAR Metadata' checkbox. Shows/Hides the metadata panel using grid.""" @@ -1092,7 +1142,7 @@ class ControlPanelApp: # 1. Grid the metadata frame into the container (column 1) logging.debug(f"{log_prefix} Gridding metadata frame...") metadata_frame.grid( - row=0, column=1, sticky='nsew', padx=(5, 5), pady=(0,0) + row=0, column=1, sticky="nsew", padx=(5, 5), pady=(0, 0) ) # 2. Configure column weight (give equal weight) container.columnconfigure(1, weight=1) @@ -1131,7 +1181,6 @@ class ControlPanelApp: except Exception as e: logging.exception(f"{log_prefix} Error toggling metadata display: {e}") - # --- Initialization Helper Methods --- def _get_screen_dimensions(self) -> Tuple[int, int]: """Gets primary screen dimensions using screeninfo.""" @@ -1141,13 +1190,19 @@ class ControlPanelApp: if not monitors: raise screeninfo.ScreenInfoError("No monitors detected.") screen = monitors[0] - logging.debug(f"{log_prefix} Detected Screen: {screen.width}x{screen.height}") + logging.debug( + f"{log_prefix} Detected Screen: {screen.width}x{screen.height}" + ) return screen.width, screen.height except Exception as e: - logging.warning(f"{log_prefix} Screen info error: {e}. Using default 1920x1080.") + logging.warning( + f"{log_prefix} Screen info error: {e}. Using default 1920x1080." + ) return 1920, 1080 - def _calculate_initial_sar_size(self, desired_factor_if_map: int = 4) -> Tuple[int, int]: + def _calculate_initial_sar_size( + self, desired_factor_if_map: int = 4 + ) -> Tuple[int, int]: """Calculates initial SAR display size based on config and map state.""" log_prefix = "[App Init]" initial_w = self.state.sar_display_width @@ -1158,8 +1213,10 @@ class ControlPanelApp: initial_w = config.SAR_WIDTH // forced_factor initial_h = config.SAR_HEIGHT // forced_factor # Update state immediately if map forces a different initial size - if (initial_w != self.state.sar_display_width - or initial_h != self.state.sar_display_height): + if ( + initial_w != self.state.sar_display_width + or initial_h != self.state.sar_display_height + ): self.state.update_sar_display_size(initial_w, initial_h) logging.info( f"{log_prefix} Map active, using SAR size 1:{forced_factor} ({initial_w}x{initial_h})." @@ -1173,7 +1230,7 @@ class ControlPanelApp: def _calculate_tkinter_position(self, screen_h: int) -> Tuple[int, int]: """Calculates the initial X, Y position for the Tkinter control panel window.""" x = 10 - y = config.INITIAL_MFD_HEIGHT + 40 # Position below MFD placeholder area + y = config.INITIAL_MFD_HEIGHT + 40 # Position below MFD placeholder area # Adjust if it goes off screen bottom if y + config.TKINTER_MIN_HEIGHT > screen_h: y = max(10, screen_h - config.TKINTER_MIN_HEIGHT - 10) @@ -1181,8 +1238,8 @@ class ControlPanelApp: def _calculate_mfd_position(self) -> Tuple[int, int]: """Calculates the initial X, Y position for the MFD display window.""" - x = self.tkinter_x # Align with Tkinter window's left edge - y = 10 # Near top + x = self.tkinter_x # Align with Tkinter window's left edge + y = 10 # Near top return x, y def _calculate_sar_position( @@ -1191,7 +1248,7 @@ class ControlPanelApp: """Calculates the initial X, Y position for the SAR display window.""" # Position right of ORIGINAL Tkinter width x = self.tkinter_x + self.original_window_width + 20 - y = 10 # Align with top + y = 10 # Align with top # Adjust if it goes off screen right if x + initial_sar_w > screen_w: x = max(10, screen_w - initial_sar_w - 10) @@ -1203,7 +1260,7 @@ class ControlPanelApp: """Calculates the initial X, Y position for the Map display window.""" # Position right of SAR window x = self.sar_x + current_sar_w + 20 - y = 10 # Align with top + y = 10 # Align with top # Adjust if it goes off screen right if x + max_map_width > screen_w: x = max(10, screen_w - max_map_width - 10) @@ -1236,7 +1293,7 @@ class ControlPanelApp: except Exception as receiver_init_e: logging.critical( f"{log_prefix} Failed to initialize UdpReceiver: {receiver_init_e}", - exc_info=True + exc_info=True, ) self.set_status("Error: Receiver Init Failed") close_udp_socket(self.udp_socket) @@ -1332,8 +1389,12 @@ class ControlPanelApp: ) try: # Placeholder: Implement actual loading from config.MFD_IMAGE_PATH - mfd_path = getattr(config, "MFD_IMAGE_PATH", "local_mfd_indices.png") # Example - logging.warning(f"{log_prefix} Local MFD image loading NYI ({mfd_path}). Using random.") + mfd_path = getattr( + config, "MFD_IMAGE_PATH", "local_mfd_indices.png" + ) # Example + logging.warning( + f"{log_prefix} Local MFD image loading NYI ({mfd_path}). Using random." + ) # loaded_indices = load_image(mfd_path, np.uint8) # Hypothetical load # self.state.local_mfd_image_data_indices = loaded_indices if loaded_indices ... else default_indices self.state.local_mfd_image_data_indices = default_indices @@ -1356,12 +1417,12 @@ class ControlPanelApp: loaded_raw_data = load_image(sar_path, config.SAR_DATA_TYPE) # Check if loading was successful and data is not empty if loaded_raw_data is not None and loaded_raw_data.size > 0: - self.state.local_sar_image_data_raw = loaded_raw_data + self.state.local_sar_image_data_raw = loaded_raw_data else: - logging.warning( - f"{log_prefix} Failed to load local SAR raw data or empty image. Using zeros." - ) - self.state.local_sar_image_data_raw = default_raw_data + logging.warning( + f"{log_prefix} Failed to load local SAR raw data or empty image. Using zeros." + ) + self.state.local_sar_image_data_raw = default_raw_data except Exception as e: logging.exception(f"{log_prefix} Error loading local SAR raw data:") self.state.local_sar_image_data_raw = default_raw_data @@ -1380,7 +1441,9 @@ class ControlPanelApp: # Process MFD if data available if self.state.local_mfd_image_data_indices is not None: if hasattr(self, "image_pipeline") and self.image_pipeline: - self.state.current_mfd_indices = self.state.local_mfd_image_data_indices.copy() + self.state.current_mfd_indices = ( + self.state.local_mfd_image_data_indices.copy() + ) self.image_pipeline.process_mfd_for_display() # Process SAR if data available if self.state.local_sar_image_data_raw is not None: @@ -1391,7 +1454,7 @@ class ControlPanelApp: elif is_test: # Test mode display is handled by TestModeManager activation pass - else: # Network mode (no local/test images loaded yet) + else: # Network mode (no local/test images loaded yet) # Show placeholders while waiting for network data self._show_network_placeholders() @@ -1401,7 +1464,7 @@ class ControlPanelApp: if mgr: thread_attr = getattr(mgr, "_map_initial_display_thread", None) if thread_attr and isinstance(thread_attr, threading.Thread): - map_loading = thread_attr.is_alive() + map_loading = thread_attr.is_alive() # Update status only if map is done loading (or no map) if not map_loading: @@ -1414,7 +1477,8 @@ class ControlPanelApp: # Network mode status status = ( f"Listening UDP {self.local_ip}:{self.local_port}" - if self.udp_socket else "Error: No Socket" + if self.udp_socket + else "Error: No Socket" ) self.set_status(status) @@ -1478,25 +1542,28 @@ class ControlPanelApp: self._reset_ui_geo_info() # Restore display based on whether we are in Local or Network mode - if config.USE_LOCAL_IMAGES: # Local Mode Restore + if config.USE_LOCAL_IMAGES: # Local Mode Restore # Display local MFD if available if self.state.local_mfd_image_data_indices is not None: - self.state.current_mfd_indices = self.state.local_mfd_image_data_indices.copy() + self.state.current_mfd_indices = ( + self.state.local_mfd_image_data_indices.copy() + ) if hasattr(self, "image_pipeline"): self.image_pipeline.process_mfd_for_display() # Display local SAR if available if self.state.local_sar_image_data_raw is not None: self.set_initial_sar_image(self.state.local_sar_image_data_raw) else: - self.set_initial_sar_image(None) # Show black if missing + self.set_initial_sar_image(None) # Show black if missing self.set_status("Ready (Local Mode)") - else: # Network Mode Restore + else: # Network Mode Restore # Show placeholders until network data arrives self._show_network_placeholders() # Set status based on socket state status = ( f"Listening UDP {self.local_ip}:{self.local_port}" - if self.udp_socket else "Error: No UDP Socket" + if self.udp_socket + else "Error: No UDP Socket" ) self.set_status(status) @@ -1535,12 +1602,14 @@ class ControlPanelApp: # Create MFD placeholder (dark gray) ph_mfd = np.full( (config.INITIAL_MFD_HEIGHT, config.INITIAL_MFD_WIDTH, 3), - 30, dtype=np.uint8 + 30, + dtype=np.uint8, ) # Create SAR placeholder (lighter gray, uses current display size) ph_sar = np.full( (self.state.sar_display_height, self.state.sar_display_width, 3), - 60, dtype=np.uint8 + 60, + dtype=np.uint8, ) # Put placeholders onto respective display queues put_queue(self.mfd_queue, ph_mfd, "mfd", self) @@ -1571,9 +1640,9 @@ class ControlPanelApp: # Simple redraw requested (e.g., alpha, toggle, marker change) # Check if data required for fast recomposition is available can_recompose = ( - self.state.last_processed_sar_for_overlay is not None and - self.state.last_sar_warp_matrix is not None and - self.state.last_map_image_pil is not None + self.state.last_processed_sar_for_overlay is not None + and self.state.last_sar_warp_matrix is not None + and self.state.last_map_image_pil is not None ) if can_recompose: @@ -1634,7 +1703,9 @@ class ControlPanelApp: # Check if libraries needed for KML were loaded if not _simplekml_available or not _pyproj_available: # Log this only once maybe? Or check flag? For now, log each time. - logging.warning("[App KML] Skipping KML generation: simplekml or pyproj missing.") + logging.warning( + "[App KML] Skipping KML generation: simplekml or pyproj missing." + ) return try: kml_dir = config.KML_OUTPUT_DIRECTORY @@ -1647,12 +1718,10 @@ class ControlPanelApp: success = generate_sar_kml(geo_info, fp) if success: # Call utility function to clean up old KML files - cleanup_old_kml_files( - config.KML_OUTPUT_DIRECTORY, config.MAX_KML_FILES - ) + cleanup_old_kml_files(config.KML_OUTPUT_DIRECTORY, config.MAX_KML_FILES) # Optionally launch Google Earth if config.AUTO_LAUNCH_GOOGLE_EARTH: - launch_google_earth(fp) # Use utility function + launch_google_earth(fp) # Use utility function # else: error logged by generate_sar_kml except Exception as e: logging.exception(f"[App KML] Error during KML handling: {e}") @@ -1688,7 +1757,7 @@ class ControlPanelApp: # Format using DMS utility lat_s = decimal_to_dms(lat_d, True) lon_s = decimal_to_dms(lon_d, False) - orient_s = f"{orient_d:.2f}°" # Format orientation + orient_s = f"{orient_d:.2f}°" # Format orientation # Calculate size in km if possible scale_x = geo.get("scale_x", 0.0) width_px = geo.get("width_px", 0) @@ -1701,7 +1770,7 @@ class ControlPanelApp: except Exception as e: logging.warning(f"[App UI Update] Error formatting SAR geo labels: {e}") lat_s, lon_s, orient_s, size_s = "Error", "Error", "Error", "Error" - is_valid = False # Mark as invalid if formatting fails + is_valid = False # Mark as invalid if formatting fails # Update UI widgets safely using control panel methods try: @@ -1718,7 +1787,7 @@ class ControlPanelApp: cp.set_mouse_coordinates("N/A", "N/A") cp.set_map_mouse_coordinates("N/A", "N/A") except Exception: - pass # Ignore errors if UI closed + pass # Ignore errors if UI closed def _update_fps_stats(self, img_type: str): """Updates FPS counters in AppState based on LOG_UPDATE_INTERVAL.""" @@ -1743,9 +1812,9 @@ class ControlPanelApp: self.state.mfd_start_time = now self.state.mfd_frame_count = 0 except ZeroDivisionError: - pass # Ignore if elapsed time is zero + pass # Ignore if elapsed time is zero except Exception: - pass # Ignore other potential calculation errors + pass # Ignore other potential calculation errors # --- Trigger Methods --- def _trigger_sar_update(self): @@ -1779,7 +1848,9 @@ class ControlPanelApp: # Call manager method to update map and overlay mgr.update_map_overlay(sar, geo) except Exception as e: - logging.exception(f"[App Trigger Map Update] Error calling manager: {e}") + logging.exception( + f"[App Trigger Map Update] Error calling manager: {e}" + ) # --- Periodic Update Scheduling --- def schedule_periodic_updates(self): @@ -1810,7 +1881,7 @@ class ControlPanelApp: image_to_display = self.sar_queue.get(block=False) self.sar_queue.task_done() except queue.Empty: - pass # Normal case if queue is empty + pass # Normal case if queue is empty except Exception as e: logging.exception(f"{log_prefix} Error getting from SAR display queue:") @@ -1871,10 +1942,10 @@ class ControlPanelApp: item = self.tkinter_queue.get(block=False) self.tkinter_queue.task_done() except queue.Empty: - pass # Queue is empty, do nothing this cycle + pass # Queue is empty, do nothing this cycle except Exception as e: logging.exception(f"{log_prefix} Error getting from queue:") - item = None # Ensure item is None if error occurs + item = None # Ensure item is None if error occurs # Process item if one was retrieved if item is not None: @@ -1920,9 +1991,11 @@ class ControlPanelApp: if not cp: return # Unpack payload or use default "N/A" - lat_s, lon_s = payload if ( - payload and isinstance(payload, tuple) and len(payload) == 2 - ) else ("N/A", "N/A") + lat_s, lon_s = ( + payload + if (payload and isinstance(payload, tuple) and len(payload) == 2) + else ("N/A", "N/A") + ) try: # Call method on UI object to update the text cp.set_mouse_coordinates(lat_s, lon_s) @@ -1937,16 +2010,18 @@ class ControlPanelApp: # Convert map pixel coords to geo coords using MapIntegrationManager mgr = getattr(self, "map_integration_manager", None) if mgr: - geo_coords = mgr.get_geo_coords_from_map_pixel( - payload[0], payload[1] - ) + geo_coords = mgr.get_geo_coords_from_map_pixel(payload[0], payload[1]) # If conversion successful, format to DMS if geo_coords: lat_s_calc = decimal_to_dms(geo_coords[0], True) lon_s_calc = decimal_to_dms(geo_coords[1], False) # Check for formatting errors - if ("Error" not in lat_s_calc and "Invalid" not in lat_s_calc and - "Error" not in lon_s_calc and "Invalid" not in lon_s_calc): + if ( + "Error" not in lat_s_calc + and "Invalid" not in lat_s_calc + and "Error" not in lon_s_calc + and "Invalid" not in lon_s_calc + ): lat_s, lon_s = lat_s_calc, lon_s_calc else: # Use error string if DMS formatting failed @@ -1961,7 +2036,7 @@ class ControlPanelApp: try: cp.set_map_mouse_coordinates("N/A", "N/A") except Exception: - pass # Ignore errors if UI closed + pass # Ignore errors if UI closed def _handle_show_map_update(self, payload: Optional[ImageType]): """Handles the SHOW_MAP command by delegating display to MapIntegrationManager.""" @@ -2039,32 +2114,32 @@ class ControlPanelApp: text_widget = getattr(self, "metadata_display_text", None) if text_widget and isinstance(metadata_string, str): - # Cache the last received string in state - self.state.last_sar_metadata_str = metadata_string - # Update UI only if the display is currently enabled - if self.state.display_sar_metadata: - logging.debug(f"{log_prefix} Updating metadata display widget.") - self.set_metadata_display(metadata_string) # Use method on self - else: - # Store it, but don't update UI if checkbox is off - logging.debug( - f"{log_prefix} Metadata received but display is disabled. Cached." - ) + # Cache the last received string in state + self.state.last_sar_metadata_str = metadata_string + # Update UI only if the display is currently enabled + if self.state.display_sar_metadata: + logging.debug(f"{log_prefix} Updating metadata display widget.") + self.set_metadata_display(metadata_string) # Use method on self + else: + # Store it, but don't update UI if checkbox is off + logging.debug( + f"{log_prefix} Metadata received but display is disabled. Cached." + ) elif text_widget and metadata_string is None: - # Handle case where None might be sent (e.g., error during formatting) - self.state.last_sar_metadata_str = None - if self.state.display_sar_metadata: - self.set_metadata_display("") + # Handle case where None might be sent (e.g., error during formatting) + self.state.last_sar_metadata_str = None + if self.state.display_sar_metadata: + self.set_metadata_display("") elif not text_widget: - # This shouldn't happen if init is correct - logging.warning( - f"{log_prefix} Metadata text widget not available to display metadata." - ) + # This shouldn't happen if init is correct + logging.warning( + f"{log_prefix} Metadata text widget not available to display metadata." + ) def _reschedule_queue_processor(self, processor_func, delay: Optional[int] = None): """Helper method to reschedule a queue processor function using root.after.""" if self.state.shutting_down: - return # Don't reschedule if shutting down + return # Don't reschedule if shutting down if delay is None: # Determine default delay based on processor type @@ -2072,10 +2147,10 @@ class ControlPanelApp: # Target slightly faster than FPS for image display queues target_fps = config.MFD_FPS calculated_delay = 1000 / (target_fps * 1.5) if target_fps > 0 else 20 - delay = max(10, int(calculated_delay)) # Minimum delay 10ms + delay = max(10, int(calculated_delay)) # Minimum delay 10ms else: # Default delay for other queues (tkinter, mouse) - delay = 50 # Use slightly faster delay for UI responsiveness + delay = 50 # Use slightly faster delay for UI responsiveness try: # Schedule only if root window exists @@ -2101,7 +2176,7 @@ class ControlPanelApp: raw_coords = self.mouse_queue.get(block=False) self.mouse_queue.task_done() except queue.Empty: - pass # Nothing to process + pass # Nothing to process except Exception as e: logging.exception(f"{log_prefix} Error getting from mouse queue:") @@ -2115,10 +2190,17 @@ class ControlPanelApp: # Check if geo info is valid for calculation is_geo_valid_for_calc = ( - geo and geo.get("valid") and disp_w > 0 and disp_h > 0 and - geo.get("width_px", 0) > 0 and geo.get("height_px", 0) > 0 and - geo.get("scale_x", 0.0) > 0 and geo.get("scale_y", 0.0) > 0 and - all(k in geo for k in ["lat", "lon", "ref_x", "ref_y", "orientation"]) + geo + and geo.get("valid") + and disp_w > 0 + and disp_h > 0 + and geo.get("width_px", 0) > 0 + and geo.get("height_px", 0) > 0 + and geo.get("scale_x", 0.0) > 0 + and geo.get("scale_y", 0.0) > 0 + and all( + k in geo for k in ["lat", "lon", "ref_x", "ref_y", "orientation"] + ) ) if is_geo_valid_for_calc: @@ -2142,7 +2224,7 @@ class ControlPanelApp: # Calculate distance from reference pixel pixel_delta_x: float = orig_x - ref_x - pixel_delta_y: float = ref_y - orig_y # Y increases downwards + pixel_delta_y: float = ref_y - orig_y # Y increases downwards # Convert pixel delta to meters meters_delta_x: float = pixel_delta_x * scale_x @@ -2162,14 +2244,22 @@ class ControlPanelApp: final_lon_deg: float = math.degrees(ref_lon_rad) + lon_offset_deg # Validate calculated coordinates and format to DMS - lat_valid = math.isfinite(final_lat_deg) and abs(final_lat_deg) <= 90.0 - lon_valid = math.isfinite(final_lon_deg) and abs(final_lon_deg) <= 180.0 + lat_valid = ( + math.isfinite(final_lat_deg) and abs(final_lat_deg) <= 90.0 + ) + lon_valid = ( + math.isfinite(final_lon_deg) and abs(final_lon_deg) <= 180.0 + ) if lat_valid and lon_valid: lat_s_calc = decimal_to_dms(final_lat_deg, True) lon_s_calc = decimal_to_dms(final_lon_deg, False) # Check for formatting errors - if ("Error" not in lat_s_calc and "Invalid" not in lat_s_calc and - "Error" not in lon_s_calc and "Invalid" not in lon_s_calc): + if ( + "Error" not in lat_s_calc + and "Invalid" not in lat_s_calc + and "Error" not in lon_s_calc + and "Invalid" not in lon_s_calc + ): lat_s, lon_s = lat_s_calc, lon_s_calc else: lat_s, lon_s = "Error DMS", "Error DMS" @@ -2188,7 +2278,7 @@ class ControlPanelApp: # Reschedule processor if not self.state.shutting_down: - self._reschedule_queue_processor(self.process_mouse_queue, delay=100) + self._reschedule_queue_processor(self.process_mouse_queue, delay=100) def put_mouse_coordinates_queue( self, command_payload_tuple: Tuple[str, Tuple[str, str]] @@ -2212,17 +2302,17 @@ class ControlPanelApp: # Access the widget stored as an attribute of the app instance text_widget = getattr(self, "metadata_display_text", None) if not text_widget: - logging.warning( - "[App UI Update] set_metadata_display called but text widget not found on app." - ) - return + logging.warning( + "[App UI Update] set_metadata_display called but text widget not found on app." + ) + return try: # Ensure the widget still exists before configuring if text_widget.winfo_exists(): - text_widget.config(state='normal') # Enable editing - text_widget.delete('1.0', tk.END) # Clear existing content - text_widget.insert('1.0', text) # Insert new text - text_widget.config(state='disabled')# Disable editing + text_widget.config(state="normal") # Enable editing + text_widget.delete("1.0", tk.END) # Clear existing content + text_widget.insert("1.0", text) # Insert new text + text_widget.config(state="disabled") # Disable editing except Exception as e: logging.warning(f"[App UI Update] Error setting metadata display text: {e}") @@ -2237,23 +2327,33 @@ class ControlPanelApp: if sb and sb.winfo_exists() and "Loading" in sb.cget("text"): return except Exception: - pass # Ignore errors checking status bar text + pass # Ignore errors checking status bar text stats = self.state.get_statistics() try: # Determine current mode - mode = "Test" if self.state.test_mode_active else ( - "Local" if config.USE_LOCAL_IMAGES else "Network" + mode = ( + "Test" + if self.state.test_mode_active + else ("Local" if config.USE_LOCAL_IMAGES else "Network") ) # Check if map is active - map_on = " MapOn" if ( - config.ENABLE_MAP_OVERLAY and - hasattr(self, "map_integration_manager") and - self.map_integration_manager - ) else "" + map_on = ( + " MapOn" + if ( + config.ENABLE_MAP_OVERLAY + and hasattr(self, "map_integration_manager") + and self.map_integration_manager + ) + else "" + ) # Format FPS strings 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" + sar_fps = ( + f"SAR:{self.state.sar_fps:.1f}fps" + if self.state.sar_fps > 0 + else "SAR:N/A" + ) # Construct status prefix status_prefix = f"Status: {mode}{map_on} | {mfd_fps} | {sar_fps}" @@ -2273,7 +2373,7 @@ class ControlPanelApp: if self.root and self.root.winfo_exists(): # Update status bar if sb and sb.winfo_exists(): - final_status = status_prefix # Keep status bar cleaner + final_status = status_prefix # Keep status bar cleaner self.root.after_idle(sb.set_status_text, final_status) # Update statistics display in the control panel cp = getattr(self, "control_panel", None) @@ -2281,8 +2381,8 @@ class ControlPanelApp: self.root.after_idle(cp.set_statistics_display, drop, incmpl) except Exception as e: - # Log warning but don't crash the app for status update errors - logging.warning(f"[App Status Update] Error during status update: {e}") + # Log warning but don't crash the app for status update errors + logging.warning(f"[App Status Update] Error during status update: {e}") # --- Cleanup --- def close_app(self): @@ -2317,13 +2417,15 @@ class ControlPanelApp: # Join receiver thread if self.udp_thread and self.udp_thread.is_alive(): logging.debug("[App Shutdown] Joining UDP receiver thread...") - self.udp_thread.join(timeout=0.5) # Short timeout + self.udp_thread.join(timeout=0.5) # Short timeout if self.udp_thread.is_alive(): logging.warning("[App Shutdown] UDP thread did not join cleanly.") # Shutdown worker pool - pool = getattr( - self.udp_receiver, "executor", None - ) if hasattr(self, "udp_receiver") else None + pool = ( + getattr(self.udp_receiver, "executor", None) + if hasattr(self, "udp_receiver") + else None + ) if pool: logging.debug("[App Shutdown] Shutting down ThreadPoolExecutor...") pool.shutdown(wait=False, cancel_futures=True) @@ -2340,8 +2442,8 @@ class ControlPanelApp: # Tkinter cleanup try: if self.root and self.root.winfo_exists(): - logging.debug("[App Shutdown] Destroying Tkinter root window...") - self.root.destroy() + logging.debug("[App Shutdown] Destroying Tkinter root window...") + self.root.destroy() except Exception as e: logging.exception(f"[App Shutdown] Error destroying Tkinter window: {e}") @@ -2367,10 +2469,11 @@ if __name__ == "__main__": "Control Panel", config.TKINTER_MIN_WIDTH, config.TKINTER_MIN_HEIGHT, - 10, 10 # Initial position, App constructor calculates final + 10, + 10, # Initial position, App constructor calculates final ) # Instantiate the main application class - app_instance = ControlPanelApp(root) # Pass root window + app_instance = ControlPanelApp(root) # Pass root window # Set the window close protocol handler root.protocol("WM_DELETE_WINDOW", app_instance.close_app) # Start the Tkinter main event loop @@ -2381,29 +2484,32 @@ if __name__ == "__main__": exit_code = exit_e.code if isinstance(exit_e.code, int) else 1 log_level = logging.INFO if exit_code == 0 else logging.WARNING # Log the exit code without re-raising SystemExit - logging.log(log_level, f"[App Main] Application exited via sys.exit({exit_code}).") + logging.log( + log_level, f"[App Main] Application exited via sys.exit({exit_code})." + ) except ImportError as imp_err: - # Handle critical import errors during startup - logging.critical( - f"[App Main] CRITICAL IMPORT ERROR: {imp_err}. Application cannot start.", - exc_info=True - ) - print( - f"\nCRITICAL ERROR: Missing required library - {imp_err}\n" - "Please install the necessary libraries (check logs/readme) and try again.\n" - ) - sys.exit(1) + # Handle critical import errors during startup + logging.critical( + f"[App Main] CRITICAL IMPORT ERROR: {imp_err}. Application cannot start.", + exc_info=True, + ) + print( + f"\nCRITICAL ERROR: Missing required library - {imp_err}\n" + "Please install the necessary libraries (check logs/readme) and try again.\n" + ) + sys.exit(1) except Exception as e: # Catch any other unhandled exceptions during startup or main loop logging.critical( - "[App Main] UNHANDLED EXCEPTION during startup or main loop:", - exc_info=True + "[App Main] UNHANDLED EXCEPTION during startup or main loop:", exc_info=True + ) + print( + "\nFATAL ERROR: An unhandled exception occurred. Check logs for details.\n" ) - print("\nFATAL ERROR: An unhandled exception occurred. Check logs for details.\n") sys.exit(1) finally: # Ensure logging is shut down properly on any exit path logging.info("=== App End ===") logging.shutdown() -# --- END OF FILE ControlPanel.py --- \ No newline at end of file +# --- END OF FILE ControlPanel.py --- diff --git a/ui.py b/ui.py index fdc0547..5e88a22 100644 --- a/ui.py +++ b/ui.py @@ -10,7 +10,8 @@ Defines the user interface components for the Control Panel application, including the main control panel area, status bar, map parameters, info display with Google Maps/Earth links, and helper functions for window creation. The main ControlPanel frame is designed to be placed within a container managed -by the main application. +by the main application. The metadata display components are now created and +managed directly by the main application (ControlPanelApp). """ # Standard library imports @@ -20,7 +21,8 @@ from typing import TYPE_CHECKING, Dict, Tuple, Optional # Third-party imports import tkinter as tk from tkinter import ttk -# Import ScrolledText only if needed within this module (currently not) + +# Removed ScrolledText import as it's no longer created here # from tkinter.scrolledtext import ScrolledText # Local application imports @@ -32,12 +34,13 @@ if TYPE_CHECKING: from ControlPanel import ControlPanelApp -class ControlPanel(ttk.Frame): # This is the main panel holding user controls +class ControlPanel(ttk.Frame): # This is the main panel holding user controls """ Main control panel frame containing SAR, MFD, Map parameter widgets, information displays, statistics labels, and interaction buttons. This frame is typically placed in the main application window's container. """ + def __init__(self, parent: tk.Widget, app: "ControlPanelApp", *args, **kwargs): """Initializes the ControlPanel frame.""" log_prefix = "[UI Setup]" @@ -57,19 +60,17 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.sar_lon_shift_var = tk.StringVar( value=f"{self.app.state.sar_lon_shift_deg:.6f}" ) - self.dropped_stats_var = tk.StringVar( - value="Drop (Q): S=0, M=0, Tk=0, Mo=0" - ) - self.incomplete_stats_var = tk.StringVar( - value="Incmpl (RX): S=0, M=0" - ) + self.dropped_stats_var = tk.StringVar(value="Drop (Q): S=0, M=0, Tk=0, Mo=0") + self.incomplete_stats_var = tk.StringVar(value="Incmpl (RX): S=0, M=0") + # Checkbox variable for metadata toggle (still needed here) + self.show_meta_var = tk.BooleanVar(value=self.app.state.display_sar_metadata) # --- References to UI widgets --- self.mfd_color_labels: Dict[str, tk.Label] = {} - # References to metadata widgets are REMOVED from here + # References to metadata widgets are REMOVED from this class # --- Initialize UI structure --- - self.init_ui() # Call the UI building method + self.init_ui() # Call the UI building method logging.debug(f"{log_prefix} ControlPanel frame initialization complete.") @@ -85,7 +86,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.sar_params_frame = ttk.Labelframe(self, text="SAR Parameters", padding=5) self.sar_params_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=(5, 2)) - sar_row = 0 # Row counter for SAR grid + sar_row = 0 # Row counter for SAR grid # Test Image Checkbox self.test_image_var = tk.IntVar(value=1 if config.ENABLE_TEST_MODE else 0) @@ -100,9 +101,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls ) # Record SAR Checkbox - self.record_sar_var = tk.BooleanVar( - value=config.DEFAULT_SAR_RECORDING_ENABLED - ) + self.record_sar_var = tk.BooleanVar(value=config.DEFAULT_SAR_RECORDING_ENABLED) self.record_sar_check = ttk.Checkbutton( self.sar_params_frame, text="Record SAR", @@ -126,7 +125,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls state="readonly", width=6, ) - try: # Set initial value based on current state + try: # Set initial value based on current state factor = 1 if self.app.state.sar_display_width > 0: factor = max(1, config.SAR_WIDTH // self.app.state.sar_display_width) @@ -135,7 +134,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.sar_size_combo.set(sz_str) else: self.sar_size_combo.set(config.DEFAULT_SAR_SIZE) - except Exception: # Fallback to default if error + except Exception: # Fallback to default if error self.sar_size_combo.set(config.DEFAULT_SAR_SIZE) self.sar_size_combo.grid( row=sar_row, column=1, sticky=tk.EW, padx=(0, 10), pady=1 @@ -144,16 +143,14 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # SAR Palette Combobox self.palette_label = ttk.Label(self.sar_params_frame, text="Palette:") - self.palette_label.grid( - row=sar_row, column=2, sticky=tk.W, padx=(0, 2), pady=1 - ) + self.palette_label.grid(row=sar_row, column=2, sticky=tk.W, padx=(0, 2), pady=1) self.palette_combo = ttk.Combobox( self.sar_params_frame, values=config.COLOR_PALETTES, state="readonly", width=8, ) - self.palette_combo.set(self.app.state.sar_palette) # Set from state + self.palette_combo.set(self.app.state.sar_palette) # Set from state self.palette_combo.grid( row=sar_row, column=3, sticky=tk.EW, padx=(0, 5), pady=1 ) @@ -171,7 +168,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls orient=tk.HORIZONTAL, from_=0.1, to=3.0, - value=self.app.state.sar_contrast, # Set from state + value=self.app.state.sar_contrast, # Set from state command=self.app.update_contrast, ) self.contrast_scale.grid( @@ -188,42 +185,48 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls orient=tk.HORIZONTAL, from_=-100, to=100, - value=self.app.state.sar_brightness, # Set from state + value=self.app.state.sar_brightness, # Set from state command=self.app.update_brightness, ) self.brightness_scale.grid( row=sar_row, column=3, sticky=tk.EW, padx=(0, 5), pady=1 ) - # SAR Metadata Checkbox + # SAR Metadata Checkbox (Still created here as it belongs logically with SAR params) sar_row += 1 - self.show_meta_var = tk.BooleanVar(value=self.app.state.display_sar_metadata) + # self.show_meta_var is already created in __init__ self.show_meta_check = ttk.Checkbutton( self.sar_params_frame, text="Show SAR Metadata", variable=self.show_meta_var, - command=self.app.toggle_sar_metadata_display # Link to app callback + command=self.app.toggle_sar_metadata_display, # Link to app callback ) self.show_meta_check.grid( row=sar_row, column=0, columnspan=4, sticky=tk.W, padx=5, pady=(5, 2) ) # Configure SAR frame column weights - self.sar_params_frame.columnconfigure(1, weight=1) # Allow sliders to expand + self.sar_params_frame.columnconfigure(1, weight=1) # Allow sliders to expand self.sar_params_frame.columnconfigure(3, weight=1) # --- 2. MFD Parameters Frame --- self.mfd_params_frame = ttk.Labelframe(self, text="MFD Parameters", padding=5) self.mfd_params_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=2) - mfd_categories_ordered = [ # Order for display - "Occlusion", "Cat A", "Cat B", "Cat C", "Cat C1", "Cat C2", "Cat C3", + mfd_categories_ordered = [ + "Occlusion", + "Cat A", + "Cat B", + "Cat C", + "Cat C1", + "Cat C2", + "Cat C3", ] num_categories = len(mfd_categories_ordered) for index, name in enumerate(mfd_categories_ordered): - row = index // 2 # Two categories per row - col_offset = 0 if (index % 2 == 0) else 4 # Offset for second column + row = index // 2 + col_offset = 0 if (index % 2 == 0) else 4 # Category Label cat_label = ttk.Label(self.mfd_params_frame, text=f"{name}:") @@ -233,12 +236,12 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # Intensity Slider Variable and Widget intensity_var = tk.IntVar(value=config.DEFAULT_MFD_INTENSITY) - try: # Set initial value from state + try: intensity_var.set( self.app.state.mfd_params["categories"][name]["intensity"] ) except Exception: - pass # Ignore if state not ready or key missing + pass intensity_scale = ttk.Scale( self.mfd_params_frame, orient=tk.HORIZONTAL, @@ -246,7 +249,6 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls from_=0, to=255, variable=intensity_var, - # Use lambda to pass name and var correctly to callback command=lambda v, n=name, var=intensity_var: ( self.app.update_mfd_category_intensity(n, var.get()) ), @@ -268,41 +270,39 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # Color Preview Label color_label = tk.Label( - self.mfd_params_frame, - text="", - width=3, - relief=tk.SUNKEN, - borderwidth=1 + self.mfd_params_frame, text="", width=3, relief=tk.SUNKEN, borderwidth=1 ) - try: # Set initial color from state + try: bgr = self.app.state.mfd_params["categories"][name]["color"] hex_color = f"#{bgr[2]:02x}{bgr[1]:02x}{bgr[0]:02x}" color_label.config(background=hex_color) except Exception: - color_label.config(background="grey") # Fallback + color_label.config(background="grey") color_label.grid( row=row, column=3 + col_offset, sticky=tk.W, padx=(1, 5), pady=1 ) - self.mfd_color_labels[name] = color_label # Store label reference + self.mfd_color_labels[name] = color_label # Raw Map Intensity Slider last_cat_row = (num_categories - 1) // 2 - # Determine position based on number of categories raw_map_col_offset = 4 if (num_categories % 2 != 0) else 0 raw_map_row = last_cat_row if (num_categories % 2 != 0) else last_cat_row + 1 raw_map_label = ttk.Label(self.mfd_params_frame, text="Raw Map:") raw_map_label.grid( - row=raw_map_row, column=0 + raw_map_col_offset, sticky=tk.W, padx=(5, 1), pady=1 + row=raw_map_row, + column=0 + raw_map_col_offset, + sticky=tk.W, + padx=(5, 1), + pady=1, ) raw_map_intensity_var = tk.IntVar(value=config.DEFAULT_MFD_RAW_MAP_INTENSITY) - try: # Set initial value from state - raw_map_intensity_var.set(self.app.state.mfd_params["raw_map_intensity"]) + try: + raw_map_intensity_var.set(self.app.state.mfd_params["raw_map_intensity"]) except Exception: - pass - # Store var reference if needed elsewhere - self.mfd_raw_map_intensity_var = raw_map_intensity_var + pass + self.mfd_raw_map_intensity_var = raw_map_intensity_var # Keep reference raw_map_scale = ttk.Scale( self.mfd_params_frame, @@ -318,20 +318,20 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls raw_map_scale.grid( row=raw_map_row, column=1 + raw_map_col_offset, - columnspan=3, # Span 3 cols after label + columnspan=3, sticky=tk.EW, padx=(1, 5), pady=1, ) # Configure MFD frame column weights - self.mfd_params_frame.columnconfigure(1, weight=1) # Allow sliders to expand - self.mfd_params_frame.columnconfigure(5, weight=1) # Allow sliders on right + self.mfd_params_frame.columnconfigure(1, weight=1) + self.mfd_params_frame.columnconfigure(5, weight=1) # --- 3. Map Parameters Frame --- self.map_params_frame = ttk.Labelframe(self, text="Map Parameters", padding=5) self.map_params_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=2) - map_row = 0 # Row counter for Map grid + map_row = 0 # Row counter for Map grid # Map Size Combobox self.map_size_label = ttk.Label(self.map_params_frame, text="Map Display Size:") @@ -342,9 +342,9 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.map_params_frame, values=config.MAP_SIZE_FACTORS, state="readonly", - width=6 + width=6, ) - self.map_size_combo.set(config.DEFAULT_MAP_SIZE) # Set default initially + self.map_size_combo.set(config.DEFAULT_MAP_SIZE) self.map_size_combo.grid( row=map_row, column=1, sticky=tk.EW, padx=(2, 10), pady=1 ) @@ -354,7 +354,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.save_map_button = ttk.Button( self.map_params_frame, text="Save Map View", - command=self.app.save_current_map_view + command=self.app.save_current_map_view, ) self.save_map_button.grid( row=map_row, column=2, columnspan=4, sticky=tk.E, padx=5, pady=1 @@ -380,9 +380,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # SAR Overlay Alpha Slider self.alpha_label = ttk.Label(self.map_params_frame, text="SAR Overlay Alpha:") - self.alpha_label.grid( - row=map_row, column=0, sticky=tk.W, padx=(5, 2), pady=1 - ) + self.alpha_label.grid(row=map_row, column=0, sticky=tk.W, padx=(5, 2), pady=1) self.sar_overlay_alpha_var = tk.DoubleVar( value=self.app.state.map_sar_overlay_alpha ) @@ -393,7 +391,6 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls to=1.0, variable=self.sar_overlay_alpha_var, ) - # Trigger update only on release for performance self.alpha_scale.bind("", self.app.on_alpha_slider_release) self.alpha_scale.grid( row=map_row, column=1, columnspan=5, sticky=tk.EW, padx=(0, 5), pady=1 @@ -403,51 +400,42 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # SAR Shift Inputs and Apply Button shift_label = ttk.Label(self.map_params_frame, text="SAR Shift (deg):") - shift_label.grid( - row=map_row, column=0, sticky=tk.W, padx=(5, 2), pady=1 - ) - + shift_label.grid(row=map_row, column=0, sticky=tk.W, padx=(5, 2), pady=1) lat_label = ttk.Label(self.map_params_frame, text="Lat:") lat_label.grid(row=map_row, column=1, sticky=tk.W, padx=(0, 0), pady=1) self.lat_shift_entry = ttk.Entry( - self.map_params_frame, - textvariable=self.sar_lat_shift_var, - width=10 + self.map_params_frame, textvariable=self.sar_lat_shift_var, width=10 ) self.lat_shift_entry.grid( row=map_row, column=2, sticky=tk.W, padx=(0, 5), pady=1 ) - lon_label = ttk.Label(self.map_params_frame, text="Lon:") lon_label.grid(row=map_row, column=3, sticky=tk.W, padx=(5, 0), pady=1) self.lon_shift_entry = ttk.Entry( - self.map_params_frame, - textvariable=self.sar_lon_shift_var, - width=10 + self.map_params_frame, textvariable=self.sar_lon_shift_var, width=10 ) self.lon_shift_entry.grid( row=map_row, column=4, sticky=tk.W, padx=(0, 5), pady=1 ) - self.apply_shift_button = ttk.Button( self.map_params_frame, text="Apply Shift", - command=self.app.apply_sar_overlay_shift + command=self.app.apply_sar_overlay_shift, ) self.apply_shift_button.grid( row=map_row, column=5, sticky=tk.E, padx=(5, 5), pady=1 ) # Configure Map frame column weights - self.map_params_frame.columnconfigure(2, weight=1) # Allow Lat entry expand - self.map_params_frame.columnconfigure(4, weight=1) # Allow Lon entry expand + self.map_params_frame.columnconfigure(2, weight=1) + self.map_params_frame.columnconfigure(4, weight=1) # --- 4. Info Display Frame --- self.info_display_frame = ttk.Labelframe(self, text="Info Display", padding=5) self.info_display_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=2) - info_row = 0 # Row counter for Info grid - button_width = 3 # Standard width for Go/GE buttons + info_row = 0 # Row counter for Info grid + button_width = 3 # Standard width for Go/GE buttons # --- Row 0: SAR Center Coords --- ref_label = ttk.Label(self.info_display_frame, text="SAR Center:") @@ -456,7 +444,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, textvariable=self.sar_center_coords_var, state="readonly", - width=35 + width=35, ) self.sar_center_entry.grid( row=info_row, column=1, columnspan=3, sticky=tk.EW, padx=(0, 2), pady=1 @@ -465,7 +453,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, text="Go", width=button_width, - command=lambda: self.app.go_to_google_maps("sar_center") + command=lambda: self.app.go_to_google_maps("sar_center"), ) self.ref_gmaps_button.grid( row=info_row, column=4, sticky=tk.E, padx=(0, 1), pady=1 @@ -474,7 +462,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, text="GE", width=button_width, - command=lambda: self.app.go_to_google_earth("sar_center") + command=lambda: self.app.go_to_google_earth("sar_center"), ) self.ref_gearth_button.grid( row=info_row, column=5, sticky=tk.E, padx=(0, 5), pady=1 @@ -488,20 +476,18 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, textvariable=self.sar_orientation_var, state="readonly", - width=15 + width=15, ) self.sar_orientation_entry.grid( row=info_row, column=1, sticky=tk.EW, padx=(0, 5), pady=1 ) size_label = ttk.Label(self.info_display_frame, text="Image Size:") - size_label.grid( - row=info_row, column=2, sticky=tk.W, padx=(10, 2), pady=1 - ) + size_label.grid(row=info_row, column=2, sticky=tk.W, padx=(10, 2), pady=1) self.sar_size_entry = ttk.Entry( self.info_display_frame, textvariable=self.sar_size_km_var, state="readonly", - width=25 + width=25, ) self.sar_size_entry.grid( row=info_row, column=3, columnspan=3, sticky=tk.EW, padx=(0, 5), pady=1 @@ -515,7 +501,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, textvariable=self.mouse_coords_var, state="readonly", - width=35 + width=35, ) self.mouse_latlon_entry.grid( row=info_row, column=1, columnspan=3, sticky=tk.EW, padx=(0, 2), pady=1 @@ -524,7 +510,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, text="Go", width=button_width, - command=lambda: self.app.go_to_google_maps("sar_mouse") + command=lambda: self.app.go_to_google_maps("sar_mouse"), ) self.sar_mouse_gmaps_button.grid( row=info_row, column=4, sticky=tk.E, padx=(0, 1), pady=1 @@ -533,7 +519,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, text="GE", width=button_width, - command=lambda: self.app.go_to_google_earth("sar_mouse") + command=lambda: self.app.go_to_google_earth("sar_mouse"), ) self.sar_mouse_gearth_button.grid( row=info_row, column=5, sticky=tk.E, padx=(0, 5), pady=1 @@ -547,7 +533,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, textvariable=self.map_mouse_coords_var, state="readonly", - width=35 + width=35, ) self.map_mouse_latlon_entry.grid( row=info_row, column=1, columnspan=3, sticky=tk.EW, padx=(0, 2), pady=1 @@ -556,7 +542,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, text="Go", width=button_width, - command=lambda: self.app.go_to_google_maps("map_mouse") + command=lambda: self.app.go_to_google_maps("map_mouse"), ) self.map_mouse_gmaps_button.grid( row=info_row, column=4, sticky=tk.E, padx=(0, 1), pady=1 @@ -565,7 +551,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, text="GE", width=button_width, - command=lambda: self.app.go_to_google_earth("map_mouse") + command=lambda: self.app.go_to_google_earth("map_mouse"), ) self.map_mouse_gearth_button.grid( row=info_row, column=5, sticky=tk.E, padx=(0, 5), pady=1 @@ -574,14 +560,12 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # --- Row 4: Drop & Incomplete Stats --- dropped_label_text = ttk.Label(self.info_display_frame, text="Stats Drop:") - dropped_label_text.grid( - row=info_row, column=0, sticky=tk.W, padx=5, pady=1 - ) + dropped_label_text.grid(row=info_row, column=0, sticky=tk.W, padx=5, pady=1) self.dropped_entry = ttk.Entry( self.info_display_frame, textvariable=self.dropped_stats_var, state="readonly", - width=30 + width=30, ) self.dropped_entry.grid( row=info_row, column=1, sticky=tk.EW, padx=(0, 5), pady=1 @@ -594,7 +578,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls self.info_display_frame, textvariable=self.incomplete_stats_var, state="readonly", - width=15 + width=15, ) self.incomplete_entry.grid( row=info_row, column=3, columnspan=3, sticky=tk.EW, padx=(0, 5), pady=1 @@ -603,33 +587,25 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls # --- Row 5: "GE All" Button --- self.ge_all_button = ttk.Button( - self.info_display_frame, - text="GE All", - command=self.app.go_to_all_gearth # Link to app callback + self.info_display_frame, text="GE All", command=self.app.go_to_all_gearth ) self.ge_all_button.grid( - row=info_row, - column=0, # Start column 0 - columnspan=6, # Span all 6 columns - sticky=tk.EW, # Expand horizontally - padx=5, - pady=(5, 5) # Padding top and bottom + row=info_row, column=0, columnspan=6, sticky=tk.EW, padx=5, pady=(5, 5) ) # Configure column weights for Info Display frame - self.info_display_frame.columnconfigure(1, weight=1) # Entry column - self.info_display_frame.columnconfigure(3, weight=1) # Entry column - self.info_display_frame.columnconfigure(4, weight=0) # Button Go - self.info_display_frame.columnconfigure(5, weight=0) # Button GE + self.info_display_frame.columnconfigure(1, weight=1) # Entry column + self.info_display_frame.columnconfigure(3, weight=1) # Entry column + self.info_display_frame.columnconfigure(4, weight=0) # Button Go + self.info_display_frame.columnconfigure(5, weight=0) # Button GE logging.debug(f"{log_prefix} Info Display frame created.") # --- 5. Metadata Display Frame (Creation REMOVED from here) --- - # The structure is now created in ControlPanelApp.__init__ + # This is now created and managed in ControlPanelApp # --- End of init_ui --- logging.debug(f"{log_prefix} init_ui widget creation complete.") - # --- UI Update Methods --- def set_sar_center_coords(self, latitude_str: str, longitude_str: str): """Updates the SAR Center coordinates display.""" @@ -698,6 +674,7 @@ class ControlPanel(ttk.Frame): # This is the main panel holding user controls f"[UI Update] Error updating MFD color for {category_name}: {e}" ) + # --- StatusBar Class --- class StatusBar(ttk.Label): """Represents the status bar at the bottom of the main window.""" @@ -709,10 +686,10 @@ class StatusBar(ttk.Label): parent, text=config.INITIAL_STATUS_MESSAGE, relief=tk.SUNKEN, - anchor=tk.W, # Anchor text to the West (left) - padding=(5, 2), # Add some internal padding + anchor=tk.W, # Anchor text to the West (left) + padding=(5, 2), # Add some internal padding *args, - **kwargs + **kwargs, ) # Packed by the main application layout manager @@ -726,6 +703,7 @@ class StatusBar(ttk.Label): # Ignore errors during status update, especially during shutdown pass + # --- Window Creation Helper --- def create_main_window( title: str, min_width: int, min_height: int, x_pos: int, y_pos: int @@ -740,9 +718,10 @@ def create_main_window( # Set minimum size constraint root.minsize(min_width, min_height) # Set initial position (may be overridden by OS window manager) + # This is less critical now as the app constructor sets the final position root.geometry(f"+{x_pos}+{y_pos}") logging.debug( - f"{log_prefix} Main Tkinter root window created (requested pos: {x_pos},{y_pos})." + f"{log_prefix} Main Tkinter root window created (initial requested pos: {x_pos},{y_pos})." ) return root except Exception as e: @@ -752,4 +731,5 @@ def create_main_window( # Re-raise the exception to halt execution if window creation fails raise -# --- END OF FILE ui.py --- \ No newline at end of file + +# --- END OF FILE ui.py --- diff --git a/utils.py b/utils.py index 61f7281..a1e0202 100644 --- a/utils.py +++ b/utils.py @@ -25,9 +25,9 @@ from pathlib import Path import webbrowser import urllib.parse import re # For DMS parsing -from typing import Optional, Tuple, List # Import Optional and Tuple for type hints +from typing import Optional, Tuple, List, Any, Dict # Added List, Any, Dict import tempfile -import ctypes +import ctypes # Needed for format_ctypes_structure # Import KML and GEO libraries, handling ImportError @@ -39,7 +39,8 @@ except ImportError: simplekml = None _simplekml_available = False logging.warning( - "[Utils KML] Library 'simplekml' not found. KML generation disabled. (pip install simplekml)" + "[Utils KML] Library 'simplekml' not found. KML generation disabled. " + "(pip install simplekml)" ) try: import pyproj @@ -48,57 +49,74 @@ try: except ImportError: pyproj = None _pyproj_available = False + # Pyproj is needed for accurate geo calculations, log warning if missing logging.warning( - "[Utils KML] Library 'pyproj' not found. KML generation requires it. (pip install pyproj)" + "[Utils Geo] Library 'pyproj' not found. " + "Some geometric calculations (KML corners, BBox) may be less accurate. " + "(pip install pyproj)" ) # --- Queue Management Functions --- - - def put_queue(queue_obj, item, queue_name="Unknown", app_instance=None): """ Safely puts an item into a queue using non-blocking put. Increments specific drop counters in the AppState object if the queue is full. Discards item if the application is shutting down. + + Args: + queue_obj (queue.Queue): The queue instance. + item (Any): The item to put into the queue. + queue_name (str): Name of the queue (for logging/stats). + app_instance (Optional[ControlPanelApp]): Reference to the main app instance + (for shutdown check and drop count). """ log_prefix = "[Utils Queue Put]" - # Check shutdown flag via app_instance + # Check shutdown flag via app_instance's state if ( app_instance and hasattr(app_instance, "state") and app_instance.state.shutting_down ): - # logging.debug(...) # Reduce verbosity + # Silently discard if shutting down (or log debug -1 if needed) + # logging.log(logging.DEBUG - 1, f"{log_prefix} Discarding item for queue '{queue_name}': Shutdown.") return try: + # Put item without blocking queue_obj.put(item, block=False) - # logging.debug(...) # Reduce verbosity + # Log successful put at very low debug level if needed + # logging.log(logging.DEBUG - 1, f"{log_prefix} Item put onto queue '{queue_name}'.") except queue.Full: - # logging.debug(...) # Reduce verbosity - # Increment the counter via the AppState instance + # Log queue full warning (consider reducing level if too noisy) + logging.warning(f"{log_prefix} Queue '{queue_name}' is full. Dropping item.") + # Increment the specific drop counter via the AppState instance if app_instance and hasattr(app_instance, "state"): try: app_instance.state.increment_dropped_count(queue_name) except Exception as e: + # Log error only if incrementing fails (shouldn't happen) logging.error( f"{log_prefix} Failed to increment drop count for '{queue_name}': {e}" ) else: + # Log error if app instance/state is not available to increment count logging.error( - f"{log_prefix} Cannot increment drop count for '{queue_name}': App instance or state missing." + f"{log_prefix} Cannot increment drop count for '{queue_name}': " + "App instance or state missing." ) except Exception as e: + # Log any other unexpected errors during queue put logging.exception( f"{log_prefix} Unexpected error putting item onto queue '{queue_name}': {e}" ) -def clear_queue(q): +def clear_queue(q: queue.Queue): """Removes all items currently in the specified queue.""" log_prefix = "[Utils Queue Clear]" try: - q_size_before = q.qsize() + q_size_before = q.qsize() # Get approximate size before clearing + # Ensure thread safety during clear operation with q.mutex: q.queue.clear() logging.debug( @@ -109,57 +127,82 @@ def clear_queue(q): # --- Coordinate Formatting --- - - def decimal_to_dms(decimal_degrees: float, is_latitude: bool) -> str: """ Converts decimal degrees to a formatted DMS (Degrees, Minutes, Seconds) string. Returns "N/A", "Invalid Lat/Lon", or "Error DMS" on error. + + Args: + decimal_degrees (float): Coordinate value in decimal degrees. + is_latitude (bool): True if latitude, False if longitude. + + Returns: + str: Formatted DMS string or an error indicator. """ log_prefix = "[Utils DMS Conv]" coord_type = "Latitude" if is_latitude else "Longitude" - # logging.debug(...) # Reduce verbosity try: + # Input validation if ( not isinstance(decimal_degrees, (int, float)) or math.isnan(decimal_degrees) or math.isinf(decimal_degrees) ): logging.warning( - f"{log_prefix} Invalid input type or value ({decimal_degrees}) for {coord_type}." + f"{log_prefix} Invalid input type or value ({decimal_degrees}) " + f"for {coord_type}." ) return "N/A" - # Determine direction and padding + # Range check and direction determination if is_latitude: - if abs(decimal_degrees) > 90.000001: + if not (-90.0 <= decimal_degrees <= 90.0): + logging.warning( + f"{log_prefix} Latitude {decimal_degrees} out of range." + ) return "Invalid Lat" direction = "N" if decimal_degrees >= 0 else "S" - deg_pad = 2 + deg_pad = 2 # Padding for degrees (e.g., 01°, 89°) else: # Longitude - if abs(decimal_degrees) > 180.000001: + if not (-180.0 <= decimal_degrees <= 180.0): + logging.warning( + f"{log_prefix} Longitude {decimal_degrees} out of range." + ) return "Invalid Lon" direction = "E" if decimal_degrees >= 0 else "W" - deg_pad = 3 + deg_pad = 3 # Padding for degrees (e.g., 001°, 179°) + # Calculations dd_abs = abs(decimal_degrees) degrees = math.floor(dd_abs) minutes_dec = (dd_abs - degrees) * 60.0 minutes = math.floor(minutes_dec) seconds = (minutes_dec - minutes) * 60.0 - # Handle float inaccuracies near 60 + + # Handle floating point precision near 60.0 for seconds/minutes if abs(seconds - 60.0) < 1e-9: seconds = 0.0 minutes += 1 if minutes == 60: minutes = 0 degrees += 1 + # Recalculate absolute degrees if degrees incremented past range limit + # (e.g., 89° 59' 59.999" N should become 90° 00' 00.00" N) + if is_latitude and degrees > 90: + degrees = 90 + minutes = 0 + seconds = 0.0 + if not is_latitude and degrees > 180: + degrees = 180 + minutes = 0 + seconds = 0.0 + # Format the final string dms_string = ( f"{degrees:0{deg_pad}d}° {minutes:02d}' {seconds:05.2f}\" {direction}" ) - # logging.debug(...) # Reduce verbosity + # logging.debug(f"{log_prefix} Converted {decimal_degrees:.6f} -> {dms_string}") return dms_string except Exception as e: logging.exception( @@ -168,7 +211,6 @@ def decimal_to_dms(decimal_degrees: float, is_latitude: bool) -> str: return "Error DMS" -# --- >>> START OF NEW FUNCTION <<< --- def dms_string_to_decimal(dms_str: str, is_latitude: bool) -> Optional[float]: """ Converts a DMS string (e.g., "46° 09' 50.24\" N", "009° 23' 14.11\" E") @@ -186,9 +228,13 @@ def dms_string_to_decimal(dms_str: str, is_latitude: bool) -> Optional[float]: logging.warning(f"{log_prefix} Invalid input string: {dms_str}") return None - # Regex to capture degrees, minutes, seconds, and direction (flexible with spaces) + # Regex to capture degrees, minutes, seconds, and direction + # Allows for variable spacing and ° ' " or ” symbols. pattern = re.compile( - r"^\s*(\d{1,3})\s*[°]\s*(\d{1,2})\s*[']\s*([\d.]+)\s*[\"”]\s*([NSEWnsew])\s*$", # Accept " or ” + r"^\s*(\d{1,3})\s*[°]\s*" # Degrees (1-3 digits) + ° symbol + r"(\d{1,2})\s*[']\s*" # Minutes (1-2 digits) + ' symbol + r"([\d.]+)\s*[\"”]\s*" # Seconds (digits, dot) + " or ” symbol + r"([NSEWnsew])\s*$", # Direction (N,S,E,W case-insensitive) re.IGNORECASE, ) match = pattern.match(dms_str.strip()) @@ -198,24 +244,27 @@ def dms_string_to_decimal(dms_str: str, is_latitude: bool) -> Optional[float]: return None try: + # Extract components as floats degrees = float(match.group(1)) minutes = float(match.group(2)) seconds = float(match.group(3)) direction = match.group(4).upper() - # Validate components + # Validate component ranges if not (0 <= minutes < 60 and 0 <= seconds < 60): logging.error( - f"{log_prefix} Invalid minutes or seconds in DMS: '{dms_str}'" + f"{log_prefix} Invalid minutes ({minutes}) or seconds ({seconds}) " + f"in DMS: '{dms_str}'" ) return None - # Validate direction + # Validate direction based on coordinate type valid_dirs = ("N", "S") if is_latitude else ("E", "W") if direction not in valid_dirs: - type_str = "latitude" if is_latitude else "longitude" + coord_type = "latitude" if is_latitude else "longitude" logging.error( - f"{log_prefix} Invalid direction '{direction}' for {type_str}: '{dms_str}'" + f"{log_prefix} Invalid direction '{direction}' for {coord_type}: " + f"'{dms_str}'" ) return None @@ -226,15 +275,15 @@ def dms_string_to_decimal(dms_str: str, is_latitude: bool) -> Optional[float]: if direction in ("S", "W"): decimal_degrees *= -1.0 - # Final range check + # Final range check for the calculated decimal value if is_latitude and not (-90.0 <= decimal_degrees <= 90.0): logging.warning( - f"{log_prefix} Calculated latitude {decimal_degrees:.6f} out of range." + f"{log_prefix} Calculated latitude {decimal_degrees:.7f} out of range." ) return None # Treat out of range as error if not is_latitude and not (-180.0 <= decimal_degrees <= 180.0): logging.warning( - f"{log_prefix} Calculated longitude {decimal_degrees:.6f} out of range." + f"{log_prefix} Calculated longitude {decimal_degrees:.7f} out of range." ) return None @@ -242,6 +291,7 @@ def dms_string_to_decimal(dms_str: str, is_latitude: bool) -> Optional[float]: return decimal_degrees except ValueError as ve: + # Handle errors during float conversion logging.error( f"{log_prefix} Error converting components to float: {ve} in '{dms_str}'" ) @@ -253,7 +303,135 @@ def dms_string_to_decimal(dms_str: str, is_latitude: bool) -> Optional[float]: return None -# --- >>> END OF NEW FUNCTION <<< --- +# --- Metadata Formatting Function --- +def format_ctypes_structure(structure: ctypes.Structure, indent_level: int = 0) -> str: + """ + Generates a formatted string representation of a ctypes Structure, + handling nested structures, arrays, and basic types recursively. + + Args: + structure: The ctypes.Structure instance to format. + indent_level: The current indentation level for nested structures. + + Returns: + A multi-line string representing the structure's content. + """ + indent = " " * indent_level # Define indentation string + result = "" + + # Check if it's actually a structure with _fields_ attribute + if not hasattr(structure, "_fields_"): + # Fallback for non-structures or basic types passed accidentally + return f"{indent}Value: {repr(structure)}\n" + + # Iterate through the fields defined in the structure + for field_name, field_type in structure._fields_: + try: + # Get the value of the current field + value = getattr(structure, field_name) + formatted_value = "" + + # --- Handle Nested Structures --- + if hasattr(field_type, "_fields_"): + # If the field type itself is another Structure + result += f"{indent}{field_name} ({field_type.__name__}):\n" + # Recursively call formatting for the nested structure + result += format_ctypes_structure(value, indent_level + 1) + + # --- Handle Arrays --- + elif issubclass(field_type, ctypes.Array): + array_len = getattr(field_type, "_length_", "N/A") + elem_type = getattr(field_type, "_type_", "N/A") + elem_name = getattr(elem_type, "__name__", "?") + + # Special handling for byte arrays (show hex preview) + if issubclass(elem_type, (ctypes.c_byte, ctypes.c_ubyte)): + try: + # Attempt conversion to bytes for hex preview + byte_value = bytes(value[:16]) # Limit preview length + preview = byte_value.hex().upper() + if len(value) > 16: + preview += "..." + formatted_value = f"<{elem_name}[{array_len}] Data: {preview}>" + except: # Fallback if conversion to bytes fails + formatted_value = f"<{elem_name}[{array_len}] (Preview N/A)>" + + # Handle simple numeric arrays (show first few elements) + elif isinstance(value, (list, tuple)): + preview = str(list(value[:8])) # Limit preview length + if len(value) > 8: + # Indicate truncation + preview = preview[:-1] + ", ...]" + formatted_value = f"<{elem_name}[{array_len}] Data: {preview}>" + else: + # For other array types, just show type and size + formatted_value = f"<{elem_name}[{array_len}]>" + + result += f"{indent}{field_name}: {formatted_value}\n" + + # --- Handle Basic Types --- + else: + # --- Special Formatting for Known Fields --- + # GeoData floats (convert radians to degrees for readability) + if field_name in ( + "LATITUDE", + "LONGITUDE", + "ORIENTATION", + "POI_LATITUDE", + "POI_LONGITUDE", + "POI_ORIENTATION", + ) and isinstance(value, float): + if math.isfinite(value): + deg_value = math.degrees(value) + prec = 2 if "ORIENTATION" in field_name else 6 + formatted_value = f"{value:.7f} rad ({deg_value:.{prec}f} deg)" + else: + formatted_value = f"{value} rad (Non-finite)" + # SFP Header special fields (show hex and char if printable) + elif field_name in ( + "SFP_MARKER", + "SFP_PT_SPARE", + "SFP_TAG", + "SFP_SRC", + "SFP_TID", + "SFP_FLAGS", + "SFP_WIN", + "SFP_ERR", + "SFP_ERR_INFO", + "SFP_RECTYPE", + "SFP_RECSPARE", + "SFP_PLDAP", + "SFP_PLEXT", + ) and isinstance(value, int): + formatted_value = f"0x{value:02X} ({value})" + elif field_name in ("SFP_DIRECTION", "SFP_FLOW") and isinstance( + value, int + ): + char_repr = chr(value) if 32 <= value <= 126 else "?" + formatted_value = f"0x{value:02X} ('{char_repr}')" + # Handle byte strings (show decoded + hex preview) + elif isinstance(value, bytes): + try: + decoded = value.decode("ascii", errors="replace") + hex_preview = value.hex().upper()[:32] # Limit hex preview + suffix = "..." if len(value) > 16 else "" + formatted_value = f"'{decoded}' (hex: {hex_preview}{suffix})" + except: + formatted_value = repr(value) # Fallback if not decodable + # Default representation for other types + else: + formatted_value = repr(value) + + result += f"{indent}{field_name}: {formatted_value}\n" + + except AttributeError: + # Handle cases where getattr might fail (should be rare) + result += f"{indent}{field_name}: \n" + except Exception as e: + # Catch any other formatting errors for a specific field + result += f"{indent}{field_name}: \n" + + return result # --- Google Maps Launcher --- @@ -276,207 +454,232 @@ def open_google_maps(latitude_deg: float, longitude_deg: float): logging.error(f"{log_prefix} Invalid longitude: {longitude_deg}.") return try: - # Construct URL (search API usually shows a pin) + # Construct URL using the search API for better pinning query_str = f"{latitude_deg:.7f},{longitude_deg:.7f}" - gmaps_url = f"https://www.google.com/maps/search/?api=1&query={query_str}" + # URL encode the query string + encoded_query = urllib.parse.quote(query_str) + gmaps_url = f"https://www.google.com/maps/search/?api=1&query={encoded_query}" + logging.info(f"{log_prefix} Opening Google Maps URL: {gmaps_url}") - opened_ok = webbrowser.open_new_tab(gmaps_url) # Open in new tab + # Open in a new tab if possible + opened_ok = webbrowser.open_new_tab(gmaps_url) if not opened_ok: logging.warning(f"{log_prefix} webbrowser.open_new_tab returned False.") + # Fallback attempt if new tab fails + webbrowser.open(gmaps_url) except Exception as e: logging.exception(f"{log_prefix} Failed to open Google Maps URL:") # --- KML Generation and Management --- -def _calculate_geo_corners_for_kml(geo_info_radians): +def _calculate_geo_corners_for_kml( + geo_info_radians: Dict[str, Any], +) -> Optional[List[Tuple[float, float]]]: """Internal helper to calculate geographic corners (degrees) from geo_info (radians). Requires pyproj.""" - # (Implementation unchanged from previous version) + # Requires pyproj library if not _pyproj_available: + logging.error("[Utils KML Calc] pyproj library needed for corner calculation.") return None + log_prefix = "[Utils KML Calc]" try: + # Define the geodetic calculator (WGS84 ellipsoid) geod = pyproj.Geod(ellps="WGS84") + + # Extract necessary info from the dictionary center_lat_rad = geo_info_radians["lat"] center_lon_rad = geo_info_radians["lon"] + # Orientation: Use the *negative* for forward projection from North + # (Angle typically defines rotation FROM North axis) orient_rad = geo_info_radians["orientation"] - calc_orient_rad = -orient_rad + calc_orient_rad = -orient_rad # Angle used for projection + ref_x = geo_info_radians["ref_x"] ref_y = geo_info_radians["ref_y"] scale_x = geo_info_radians["scale_x"] scale_y = geo_info_radians["scale_y"] width = geo_info_radians["width_px"] height = geo_info_radians["height_px"] + + # Validate inputs if not (scale_x > 0 and scale_y > 0 and width > 0 and height > 0): + logging.error(f"{log_prefix} Invalid scale or dimensions in geo_info.") return None - corners_pixel = [ - (0 - ref_x, ref_y - 0), - (width - 1 - ref_x, ref_y - 0), - (width - 1 - ref_x, ref_y - (height - 1)), - (0 - ref_x, ref_y - (height - 1)), + + # Define corner pixel coordinates relative to reference pixel (ref_x, ref_y) + # Top-Left (0,0), Top-Right (w-1, 0), Bottom-Right (w-1, h-1), Bottom-Left (0, h-1) + # Note: Pixel Y increases downwards + corners_pixel_delta = [ + (0 - ref_x, ref_y - 0), # Top-Left Delta + (width - 1 - ref_x, ref_y - 0), # Top-Right Delta + (width - 1 - ref_x, ref_y - (height - 1)), # Bottom-Right Delta + (0 - ref_x, ref_y - (height - 1)), # Bottom-Left Delta ] - corners_meters = [(dx * scale_x, dy * scale_y) for dx, dy in corners_pixel] + + # Convert pixel deltas to meter deltas (unrotated) + corners_meters = [ + (dx * scale_x, dy * scale_y) for dx, dy in corners_pixel_delta + ] + + # Rotate meter deltas based on orientation if significant corners_meters_rotated = corners_meters - if abs(calc_orient_rad) > 1e-6: - cos_o, sin_o = math.cos(calc_orient_rad), math.sin(calc_orient_rad) + if abs(calc_orient_rad) > 1e-6: # Only rotate if angle is non-zero + cos_o = math.cos(calc_orient_rad) + sin_o = math.sin(calc_orient_rad) corners_meters_rotated = [ (dx * cos_o - dy * sin_o, dx * sin_o + dy * cos_o) for dx, dy in corners_meters ] + + # Calculate geographic coordinates for each corner using forward projection corners_geo_deg = [] - center_lon_deg, center_lat_deg = math.degrees(center_lon_rad), math.degrees( - center_lat_rad - ) + center_lon_deg = math.degrees(center_lon_rad) + center_lat_deg = math.degrees(center_lat_rad) + for dx_m, dy_m in corners_meters_rotated: - dist = math.hypot(dx_m, dy_m) - az = math.degrees(math.atan2(dx_m, dy_m)) - lon, lat, _ = geod.fwd(center_lon_deg, center_lat_deg, az, dist) - corners_geo_deg.append((lon, lat)) - return corners_geo_deg if len(corners_geo_deg) == 4 else None + # Calculate distance and azimuth from center to corner + distance = math.hypot(dx_m, dy_m) + # Azimuth: angle from North clockwise (atan2 gives angle from East CCW) + azimuth = math.degrees(math.atan2(dx_m, dy_m)) % 360.0 # Ensure 0-360 range + + # Use pyproj geod.fwd for accurate projection + lon, lat, _ = geod.fwd(center_lon_deg, center_lat_deg, azimuth, distance) + corners_geo_deg.append((lon, lat)) # Store (longitude, latitude) + + # Ensure we have exactly 4 corners + if len(corners_geo_deg) == 4: + # logging.debug(f"{log_prefix} Calculated corners (Lon, Lat): {corners_geo_deg}") + return corners_geo_deg + else: + logging.error(f"{log_prefix} Incorrect number of corners calculated.") + return None + except KeyError as ke: - logging.error(f"{log_prefix} Missing key in geo_info_radians: {ke}") + logging.error(f"{log_prefix} Missing required key in geo_info_radians: {ke}") return None except Exception as e: logging.exception(f"{log_prefix} Error calculating geographic corners for KML:") return None -def generate_sar_kml(geo_info_radians, output_path) -> bool: - """Generates a KML file representing the SAR footprint area.""" - # (Implementation unchanged from previous version) +def generate_sar_kml(geo_info_radians: Dict[str, Any], output_path: str) -> bool: + """ + Generates a KML file representing the SAR footprint area as a polygon. + + Args: + geo_info_radians (Dict[str, Any]): Dictionary with SAR georeferencing. + output_path (str): The full path where the KML file should be saved. + + Returns: + bool: True if KML generation and saving were successful, False otherwise. + """ log_prefix = "[Utils KML Gen]" + # Check for required libraries if not _simplekml_available or not _pyproj_available: + logging.error(f"{log_prefix} Cannot generate KML: simplekml or pyproj missing.") return False + # Check for valid input geo_info if not geo_info_radians or not geo_info_radians.get("valid", False): + logging.warning(f"{log_prefix} Cannot generate KML: Invalid geo_info provided.") return False + try: + # Calculate geographic corner coordinates corners_deg = _calculate_geo_corners_for_kml(geo_info_radians) if corners_deg is None: - return False - center_lon = math.degrees(geo_info_radians["lon"]) - center_lat = math.degrees(geo_info_radians["lat"]) - orientation = math.degrees(geo_info_radians["orientation"]) - width_km = ( - geo_info_radians.get("scale_x", 1) * geo_info_radians.get("width_px", 1) - ) / 1000.0 - height_km = ( - geo_info_radians.get("scale_y", 1) * geo_info_radians.get("height_px", 1) - ) / 1000.0 - view_alt = max(width_km, height_km) * 2000 + logging.error(f"{log_prefix} Failed to calculate KML corners.") + return False # Error already logged by helper + + # Extract center and orientation for LookAt view + center_lon_deg = math.degrees(geo_info_radians["lon"]) + center_lat_deg = math.degrees(geo_info_radians["lat"]) + orientation_deg = math.degrees(geo_info_radians.get("orientation", 0.0)) + + # Estimate a reasonable viewing altitude based on image size + try: + width_km = ( + geo_info_radians.get("scale_x", 1.0) + * geo_info_radians.get("width_px", 1000) + ) / 1000.0 + height_km = ( + geo_info_radians.get("scale_y", 1.0) + * geo_info_radians.get("height_px", 1000) + ) / 1000.0 + # Simple heuristic for altitude (e.g., 2x the larger dimension in km * 1000) + view_altitude_m = max(width_km, height_km) * 2000.0 + # Clamp altitude to a reasonable range + view_altitude_m = max(1000.0, min(view_altitude_m, 500000.0)) + except: + view_altitude_m = 10000.0 # Default altitude on error + + # Create KML object kml = simplekml.Kml(name=f"SAR Image {datetime.datetime.now():%Y%m%d_%H%M%S}") - kml.document.lookat.longitude = center_lon - kml.document.lookat.latitude = center_lat - kml.document.lookat.range = view_alt - kml.document.lookat.tilt = 45 - kml.document.lookat.heading = orientation - outer_boundary = [(lon, lat, 0) for lon, lat in corners_deg] - pol = kml.newpolygon(name="SAR Footprint", outerboundaryis=outer_boundary) - pol.style.linestyle.color = simplekml.Color.red - pol.style.linestyle.width = 2 - pol.style.polystyle.color = simplekml.Color.changealphaint( - 100, simplekml.Color.red + + # Set LookAt view + kml.document.lookat.longitude = center_lon_deg + kml.document.lookat.latitude = center_lat_deg + kml.document.lookat.range = view_altitude_m + kml.document.lookat.tilt = 45.0 # Default tilt + kml.document.lookat.heading = orientation_deg % 360.0 # Ensure 0-360 heading + kml.document.lookat.altitudemode = simplekml.AltitudeMode.relativetoground + + # Create the polygon footprint + # KML uses (longitude, latitude, altitude) tuples + # Close the polygon by repeating the first point at the end + outer_boundary = [(lon, lat, 0) for lon, lat in corners_deg] + [ + (corners_deg[0][0], corners_deg[0][1], 0) + ] + + polygon = kml.newpolygon(name="SAR Footprint", outerboundaryis=outer_boundary) + + # Style the polygon (red outline, semi-transparent red fill) + polygon.style.linestyle.color = simplekml.Color.red + polygon.style.linestyle.width = 2 + polygon.style.polystyle.color = simplekml.Color.changealphaint( + 100, simplekml.Color.red # ~40% opacity red fill ) + polygon.style.polystyle.outline = 1 # Ensure outline is drawn + + # Save the KML file kml.save(output_path) logging.debug(f"{log_prefix} KML file saved successfully: {output_path}") return True + except Exception as e: logging.exception(f"{log_prefix} Error generating or saving KML file:") return False -def launch_google_earth(kml_path): - """Attempts to open a KML file using the system's default application.""" - # (Implementation unchanged from previous version) - log_prefix = "[Utils Launch GE]" - if not os.path.exists(kml_path): - logging.error(f"{log_prefix} KML file not found: {kml_path}") - return - logging.info(f"{log_prefix} Attempting launch for: {kml_path}") - try: - if sys.platform == "win32": - os.startfile(kml_path) - elif sys.platform == "darwin": - subprocess.run(["open", kml_path], check=True) - else: # Linux/Unix - cmd = shutil.which("google-earth-pro") or shutil.which("xdg-open") - if cmd: - ( - subprocess.Popen([cmd, kml_path]) - if cmd.endswith("pro") - else subprocess.run([cmd, kml_path], check=True) - ) - else: - raise FileNotFoundError("Neither google-earth-pro nor xdg-open found.") - except Exception as e: - logging.exception(f"{log_prefix} Error launching Google Earth:") - - -def cleanup_old_kml_files(kml_directory: str, max_files_to_keep: int): - """Removes the oldest KML files from the directory if count exceeds limit.""" - # (Implementation unchanged from previous version) - log_prefix = "[Utils KML Cleanup]" - if max_files_to_keep <= 0: - return - try: - kml_dir_path = Path(kml_directory) - if not kml_dir_path.is_dir(): - return - kml_files = [] - for item in kml_dir_path.glob("*.kml"): - if item.is_file(): - try: - kml_files.append((item, item.stat().st_mtime)) - except Exception as stat_e: - logging.warning(f"{log_prefix} Stat error '{item.name}': {stat_e}") - current_count = len(kml_files) - if current_count <= max_files_to_keep: - return - kml_files.sort(key=lambda x: x[1]) - num_to_delete = current_count - max_files_to_keep - deleted_count = 0 - for file_path, _ in kml_files[:num_to_delete]: - try: - file_path.unlink() - deleted_count += 1 - except Exception as delete_e: - logging.error( - f"{log_prefix} Delete error '{file_path.name}': {delete_e}" - ) - logging.debug(f"{log_prefix} Deleted {deleted_count}/{num_to_delete} files.") - except Exception as e: - logging.exception(f"{log_prefix} Error during KML cleanup:") - - def generate_lookat_and_point_kml( latitude_deg: float, longitude_deg: float, - altitude_m: float = 10000.0, # Default view altitude - tilt: float = 45.0, # Default camera tilt - heading: float = 0.0, # Default heading (North) - placemark_name: str = "Selected Location", # Name for the point - placemark_desc: Optional[str] = None, # Optional description + altitude_m: float = 10000.0, + tilt: float = 45.0, + heading: float = 0.0, + placemark_name: str = "Selected Location", + placemark_desc: Optional[str] = None, ) -> Optional[str]: """ - Generates a temporary KML file containing a LookAt element pointing to - the specified coordinates AND a Placemark (point) at those coordinates. + Generates a temporary KML file containing a LookAt element and a Placemark point. Args: latitude_deg (float): Target latitude in decimal degrees. longitude_deg (float): Target longitude in decimal degrees. - altitude_m (float): Viewing altitude in meters above ground for LookAt. - tilt (float): Camera tilt angle for LookAt (0=nadir, 90=horizon). - heading (float): Camera heading/azimuth for LookAt (0=North, 90=East). - placemark_name (str): Name for the placemark displayed in Google Earth. + altitude_m (float): Viewing altitude in meters for LookAt. + tilt (float): Camera tilt angle for LookAt. + heading (float): Camera heading/azimuth for LookAt. + placemark_name (str): Name for the placemark. placemark_desc (Optional[str]): Optional description for the placemark. Returns: - Optional[str]: The path to the temporary KML file created, or None on error. + Optional[str]: Path to the temporary KML file, or None on error. """ - log_prefix = "[Utils KML LookAtPoint]" # Updated log prefix + log_prefix = "[Utils KML LookAtPoint]" if not _simplekml_available: - logging.error(f"{log_prefix} Cannot generate KML: simplekml library missing.") + logging.error(f"{log_prefix} Cannot generate KML: simplekml missing.") return None - # Validate coordinates (same validation as before) + # Validate coordinates if not ( isinstance(latitude_deg, (int, float)) and math.isfinite(latitude_deg) @@ -493,34 +696,25 @@ def generate_lookat_and_point_kml( return None try: - # Create a KML object - kml = simplekml.Kml() + kml = simplekml.Kml(name=placemark_name) # Use name for KML document too - # --- Set the LookAt parameters (same as before) --- + # Set LookAt parameters kml.document.lookat.longitude = longitude_deg kml.document.lookat.latitude = latitude_deg - kml.document.lookat.range = altitude_m + kml.document.lookat.range = max(100.0, altitude_m) # Min range 100m kml.document.lookat.tilt = tilt - kml.document.lookat.heading = heading + kml.document.lookat.heading = heading % 360.0 kml.document.lookat.altitudemode = simplekml.AltitudeMode.relativetoground - # --- Add the Placemark (New part) --- - # Create a point Placemark at the specified coordinates + # Add the Placemark point point = kml.newpoint(name=placemark_name) - point.coords = [ - (longitude_deg, latitude_deg) - ] # simplekml uses (lon, lat) order - - # Add description if provided + # simplekml uses (lon, lat, optional_alt) order for coordinates + point.coords = [(longitude_deg, latitude_deg)] if placemark_desc: point.description = placemark_desc - # Optionally, you could add custom styling here: - # point.style.iconstyle.icon.href = 'http://maps.google.com/mapfiles/kml/paddle/red-stars.png' - # point.style.iconstyle.scale = 1.5 - # point.style.labelstyle.scale = 1.1 - - # Create a temporary file to save the KML + # Create a temporary file (delete=False so GE can open it) + # Consider using a subfolder in the main app temp/cache dir? with tempfile.NamedTemporaryFile( mode="w", suffix=".kml", delete=False, encoding="utf-8" ) as temp_kml: @@ -531,7 +725,7 @@ def generate_lookat_and_point_kml( temp_kml.write(kml.kml()) # Write KML content logging.debug(f"{log_prefix} LookAt+Point KML file created successfully.") - return temp_kml_path # Return the path + return temp_kml_path except Exception as e: logging.exception(f"{log_prefix} Error generating LookAt+Point KML file:") @@ -547,15 +741,14 @@ def generate_multi_point_kml( Args: points_data (List[Tuple[float, float, str, Optional[str]]]): - A list where each element is a tuple: - (latitude_deg, longitude_deg, placemark_name, optional_placemark_desc). + List of (latitude_deg, longitude_deg, placemark_name, optional_desc). Returns: - Optional[str]: The path to the temporary KML file created, or None on error. + Optional[str]: Path to the temporary KML file, or None on error. """ log_prefix = "[Utils KML MultiPoint]" if not _simplekml_available: - logging.error(f"{log_prefix} Cannot generate KML: simplekml library missing.") + logging.error(f"{log_prefix} Cannot generate KML: simplekml missing.") return None if not points_data: logging.warning(f"{log_prefix} No points provided to generate KML.") @@ -564,32 +757,26 @@ def generate_multi_point_kml( try: kml = simplekml.Kml(name="Multiple Locations") - # --- Determine LookAt View --- - # Simple approach: LookAt the first point + # --- Determine LookAt View (Look at first point) --- first_lat, first_lon, _, _ = points_data[0] - # More complex: Calculate average coordinates or bounding box for LookAt - # Let's stick to the first point for simplicity now. kml.document.lookat.longitude = first_lon kml.document.lookat.latitude = first_lat - kml.document.lookat.range = 20000 # Adjust altitude for multiple points + kml.document.lookat.range = 20000 # Default range for multiple points kml.document.lookat.tilt = 30 kml.document.lookat.heading = 0 kml.document.lookat.altitudemode = simplekml.AltitudeMode.relativetoground - # --- Define some basic styles (optional) --- - # You can define styles and reuse them + # --- Define styles --- style_sar_center = simplekml.Style() style_sar_center.iconstyle.icon.href = ( "http://maps.google.com/mapfiles/kml/paddle/red-stars.png" ) style_sar_center.iconstyle.scale = 1.1 - style_mouse_on_sar = simplekml.Style() style_mouse_on_sar.iconstyle.icon.href = ( "http://maps.google.com/mapfiles/kml/paddle/blu-circle.png" ) style_mouse_on_sar.iconstyle.scale = 1.1 - style_mouse_on_map = simplekml.Style() style_mouse_on_map.iconstyle.icon.href = ( "http://maps.google.com/mapfiles/kml/paddle/grn-diamond.png" @@ -604,14 +791,14 @@ def generate_multi_point_kml( if desc: point.description = desc - # Apply style based on name (example) + # Apply style based on name if name == "SAR Center": point.style = style_sar_center elif name == "Mouse on SAR": point.style = style_mouse_on_sar elif name == "Mouse on Map": point.style = style_mouse_on_map - # else: leave default style if name doesn't match + # else: default style # --- Save to Temporary File --- with tempfile.NamedTemporaryFile( @@ -621,7 +808,7 @@ def generate_multi_point_kml( logging.debug( f"{log_prefix} Saving temporary Multi-Point KML to: {temp_kml_path}" ) - temp_kml.write(kml.kml()) # Write KML content + temp_kml.write(kml.kml()) logging.debug(f"{log_prefix} Multi-Point KML file created successfully.") return temp_kml_path @@ -631,105 +818,111 @@ def generate_multi_point_kml( return None -def format_ctypes_structure(structure: ctypes.Structure, indent_level: int = 0) -> str: +def launch_google_earth(kml_path: Optional[str]): """ - Generates a formatted string representation of a ctypes Structure, - handling nested structures and basic types. + Attempts to open a KML file using the system's default application + or google-earth-pro directly. Args: - structure: The ctypes.Structure instance to format. - indent_level: The current indentation level for nested structures. - - Returns: - A multi-line string representing the structure's content. + kml_path (Optional[str]): The path to the KML file to launch. """ - indent = " " * indent_level - result = "" - - # Check if it's actually a structure with _fields_ - if not hasattr(structure, "_fields_"): - return f"{indent}Value: {repr(structure)}\n" # Fallback for non-structures passed accidentally - - for field_name, field_type in structure._fields_: - try: - value = getattr(structure, field_name) - formatted_value = "" - - # Check for nested structures - if hasattr( - field_type, "_fields_" - ): # Check if field_type itself is a Structure class - result += f"{indent}{field_name} ({field_type.__name__}):\n" - result += format_ctypes_structure( - value, indent_level + 1 - ) # Recursive call - # Check for arrays - elif issubclass(field_type, ctypes.Array): - # Show array type, length and maybe first few elements (hex) - array_len = getattr(field_type, "_length_", "N/A") - elem_type = getattr(field_type, "_type_", "N/A") - elem_name = getattr(elem_type, "__name__", "Unknown") - # Convert simple byte arrays to hex string for preview - if issubclass( - elem_type, (ctypes.c_byte, ctypes.c_ubyte) - ) and isinstance(value, (bytes, bytearray)): - preview = ( - bytes(value[:16]).hex().upper() - ) # Show first 16 bytes as hex - if len(value) > 16: - preview += "..." - formatted_value = f"<{elem_name}[{array_len}] Data: {preview}>" - elif isinstance(value, list) or isinstance( - value, tuple - ): # Handle simple numeric arrays - preview = str(list(value[:8])) # Show first 8 elements - if len(value) > 8: - preview = preview[:-1] + ", ...]" - formatted_value = f"<{elem_name}[{array_len}] Data: {preview}>" + log_prefix = "[Utils Launch GE]" + if not kml_path or not os.path.exists(kml_path): + logging.error( + f"{log_prefix} Cannot launch: KML file path invalid or not found: {kml_path}" + ) + return + logging.info(f"{log_prefix} Attempting launch for KML: {kml_path}") + try: + if sys.platform == "win32": + # Use os.startfile on Windows + os.startfile(kml_path) + elif sys.platform == "darwin": + # Use 'open' command on macOS + subprocess.run(["open", kml_path], check=True) + else: + # Use 'google-earth-pro' or 'xdg-open' on Linux/Unix + cmd = shutil.which("google-earth-pro") or shutil.which("xdg-open") + if cmd: + logging.debug(f"{log_prefix} Using command: {cmd}") + if cmd.endswith("pro"): + # Popen for GE Pro to run in background potentially + subprocess.Popen([cmd, kml_path]) else: - formatted_value = ( - f"<{elem_name}[{array_len}]>" # Just show type/size - ) - result += f"{indent}{field_name}: {formatted_value}\n" - # Basic types + # run for xdg-open, wait for it? + subprocess.run([cmd, kml_path], check=True) else: - # Add special formatting for known fields if needed - if field_name == "LATITUDE" and isinstance(value, float): - formatted_value = f"{value:.7f} rad ({math.degrees(value):.6f} deg)" - elif field_name == "LONGITUDE" and isinstance(value, float): - formatted_value = f"{value:.7f} rad ({math.degrees(value):.6f} deg)" - elif field_name == "ORIENTATION" and isinstance(value, float): - # Handle potential non-finite values from struct - if math.isfinite(value): - formatted_value = ( - f"{value:.7f} rad ({math.degrees(value):.2f} deg)" - ) - else: - formatted_value = f"{value} rad (Invalid)" - elif field_name == "SFP_MARKER" and isinstance(value, int): - formatted_value = f"0x{value:02X}" - elif field_name == "SFP_DIRECTION" and isinstance(value, int): - dir_char = chr(value) if 32 <= value <= 126 else "?" - formatted_value = f"0x{value:02X} ('{dir_char}')" - elif field_name == "SFP_FLOW" and isinstance(value, int): - flow_char = chr(value) if 32 <= value <= 126 else "?" - formatted_value = f"0x{value:02X} ('{flow_char}')" - elif isinstance(value, bytes): - try: - formatted_value = f"'{value.decode('ascii', errors='replace')}' (bytes: {value.hex().upper()[:32]}{'...' if len(value)>16 else ''})" - except: # Catch potential errors if value is not bytes-like - formatted_value = repr(value) - else: - formatted_value = repr(value) # Default representation + # Log error if no suitable command found + err_msg = "Neither google-earth-pro nor xdg-open found in PATH." + logging.error(f"{log_prefix} {err_msg}") + raise FileNotFoundError(err_msg) + except Exception as e: + logging.exception(f"{log_prefix} Error launching KML handler:") - result += f"{indent}{field_name}: {formatted_value}\n" - except AttributeError: - result += f"{indent}{field_name}: \n" - except Exception as e: - result += f"{indent}{field_name}: \n" +def cleanup_old_kml_files(kml_directory: str, max_files_to_keep: int): + """Removes the oldest KML files from the directory if count exceeds limit.""" + log_prefix = "[Utils KML Cleanup]" + # Skip if cleanup is disabled or directory invalid + if max_files_to_keep <= 0: + return + try: + kml_dir_path = Path(kml_directory) + if not kml_dir_path.is_dir(): + logging.warning( + f"{log_prefix} KML directory not found: '{kml_directory}'. Cannot cleanup." + ) + return - return result + # Get list of KML files with modification times + kml_files = [] + for item in kml_dir_path.glob("*.kml"): + if item.is_file(): + try: + # Get modification time + mtime = item.stat().st_mtime + kml_files.append((item, mtime)) + except Exception as stat_e: + # Log error getting stats but continue with others + logging.warning( + f"{log_prefix} Could not stat file '{item.name}': {stat_e}" + ) + + current_count = len(kml_files) + # Only proceed if count exceeds the limit + if current_count <= max_files_to_keep: + return + + # Sort files by modification time (oldest first) + kml_files.sort(key=lambda x: x[1]) + + # Determine number of files to delete + num_to_delete = current_count - max_files_to_keep + # Get the list of files to delete + files_to_delete = kml_files[:num_to_delete] + + logging.info( + f"{log_prefix} Found {current_count} KML files. " + f"Attempting to delete oldest {num_to_delete}..." + ) + deleted_count = 0 + # Iterate and delete oldest files + for file_path, _ in files_to_delete: + try: + file_path.unlink() # Delete the file + logging.debug(f"{log_prefix} Deleted old KML: {file_path.name}") + deleted_count += 1 + except Exception as delete_e: + # Log error deleting specific file but continue + logging.error( + f"{log_prefix} Failed to delete file '{file_path.name}': {delete_e}" + ) + logging.info( + f"{log_prefix} Cleanup finished. Deleted {deleted_count}/{num_to_delete} files." + ) + + except Exception as e: + logging.exception(f"{log_prefix} Error during KML cleanup process:") # --- END OF FILE utils.py ---