SXXXXXXX_ControlPanel/utils.py
VALLONGOL cd66dab8db add go to google map and google earth button
add "x" red for pointing mouse on sar and map images
2025-04-15 12:25:00 +02:00

606 lines
23 KiB
Python

# --- START OF FILE utils.py ---
# utils.py
"""
THIS SOFTWARE IS PROVIDED “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED.
Provides utility functions used throughout the application, including
safe queue management (put, clear), coordinate formatting (decimal degrees to DMS
and DMS string to decimal), KML generation and management, and opening web maps.
Uses standardized logging prefixes. Drop counts are managed within AppState.
"""
# Standard library imports
import queue
import logging
import math
import os
import datetime
import sys
import subprocess
import shutil
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
import tempfile
# Import KML and GEO libraries, handling ImportError
try:
import simplekml
_simplekml_available = True
except ImportError:
simplekml = None
_simplekml_available = False
logging.warning(
"[Utils KML] Library 'simplekml' not found. KML generation disabled. (pip install simplekml)"
)
try:
import pyproj
_pyproj_available = True
except ImportError:
pyproj = None
_pyproj_available = False
logging.warning(
"[Utils KML] Library 'pyproj' not found. KML generation requires it. (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.
"""
log_prefix = "[Utils Queue Put]"
# Check shutdown flag via app_instance
if (
app_instance
and hasattr(app_instance, "state")
and app_instance.state.shutting_down
):
# logging.debug(...) # Reduce verbosity
return
try:
queue_obj.put(item, block=False)
# logging.debug(...) # Reduce verbosity
except queue.Full:
# logging.debug(...) # Reduce verbosity
# Increment the 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:
logging.error(
f"{log_prefix} Failed to increment drop count for '{queue_name}': {e}"
)
else:
logging.error(
f"{log_prefix} Cannot increment drop count for '{queue_name}': App instance or state missing."
)
except Exception as e:
logging.exception(
f"{log_prefix} Unexpected error putting item onto queue '{queue_name}': {e}"
)
def clear_queue(q):
"""Removes all items currently in the specified queue."""
log_prefix = "[Utils Queue Clear]"
try:
q_size_before = q.qsize()
with q.mutex:
q.queue.clear()
logging.debug(
f"{log_prefix} Cleared queue (approx size before: {q_size_before})."
)
except Exception as e:
logging.exception(f"{log_prefix} Error clearing queue: {e}")
# --- 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.
"""
log_prefix = "[Utils DMS Conv]"
coord_type = "Latitude" if is_latitude else "Longitude"
# logging.debug(...) # Reduce verbosity
try:
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}."
)
return "N/A"
# Determine direction and padding
if is_latitude:
if abs(decimal_degrees) > 90.000001:
return "Invalid Lat"
direction = "N" if decimal_degrees >= 0 else "S"
deg_pad = 2
else: # Longitude
if abs(decimal_degrees) > 180.000001:
return "Invalid Lon"
direction = "E" if decimal_degrees >= 0 else "W"
deg_pad = 3
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
if abs(seconds - 60.0) < 1e-9:
seconds = 0.0
minutes += 1
if minutes == 60:
minutes = 0
degrees += 1
dms_string = (
f"{degrees:0{deg_pad}d}° {minutes:02d}' {seconds:05.2f}\" {direction}"
)
# logging.debug(...) # Reduce verbosity
return dms_string
except Exception as e:
logging.exception(
f"{log_prefix} Error converting {decimal_degrees} to DMS: {e}"
)
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")
to decimal degrees.
Args:
dms_str (str): The DMS string to parse.
is_latitude (bool): True if the string represents latitude, False for longitude.
Returns:
Optional[float]: The coordinate in decimal degrees, or None on parsing error.
"""
log_prefix = "[Utils DMS Parse]"
if not dms_str or not isinstance(dms_str, str):
logging.warning(f"{log_prefix} Invalid input string: {dms_str}")
return None
# Regex to capture degrees, minutes, seconds, and direction (flexible with spaces)
pattern = re.compile(
r"^\s*(\d{1,3})\s*[°]\s*(\d{1,2})\s*[']\s*([\d.]+)\s*[\"”]\s*([NSEWnsew])\s*$", # Accept " or ”
re.IGNORECASE,
)
match = pattern.match(dms_str.strip())
if not match:
logging.error(f"{log_prefix} Could not parse DMS string format: '{dms_str}'")
return None
try:
degrees = float(match.group(1))
minutes = float(match.group(2))
seconds = float(match.group(3))
direction = match.group(4).upper()
# Validate components
if not (0 <= minutes < 60 and 0 <= seconds < 60):
logging.error(
f"{log_prefix} Invalid minutes or seconds in DMS: '{dms_str}'"
)
return None
# Validate direction
valid_dirs = ("N", "S") if is_latitude else ("E", "W")
if direction not in valid_dirs:
type_str = "latitude" if is_latitude else "longitude"
logging.error(
f"{log_prefix} Invalid direction '{direction}' for {type_str}: '{dms_str}'"
)
return None
# Calculate decimal degrees
decimal_degrees = degrees + (minutes / 60.0) + (seconds / 3600.0)
# Apply sign based on direction
if direction in ("S", "W"):
decimal_degrees *= -1.0
# Final range check
if is_latitude and not (-90.0 <= decimal_degrees <= 90.0):
logging.warning(
f"{log_prefix} Calculated latitude {decimal_degrees:.6f} 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."
)
return None
logging.debug(f"{log_prefix} Parsed '{dms_str}' -> {decimal_degrees:.7f}")
return decimal_degrees
except ValueError as ve:
logging.error(
f"{log_prefix} Error converting components to float: {ve} in '{dms_str}'"
)
return None
except Exception as e:
logging.exception(
f"{log_prefix} Unexpected error parsing DMS string '{dms_str}':"
)
return None
# --- >>> END OF NEW FUNCTION <<< ---
# --- Google Maps Launcher ---
def open_google_maps(latitude_deg: float, longitude_deg: float):
"""Opens the default web browser to Google Maps centered on the specified coordinates."""
log_prefix = "[Utils Gmaps]"
# Validate coordinates
if not (
isinstance(latitude_deg, (int, float))
and math.isfinite(latitude_deg)
and -90 <= latitude_deg <= 90
):
logging.error(f"{log_prefix} Invalid latitude: {latitude_deg}.")
return
if not (
isinstance(longitude_deg, (int, float))
and math.isfinite(longitude_deg)
and -180 <= longitude_deg <= 180
):
logging.error(f"{log_prefix} Invalid longitude: {longitude_deg}.")
return
try:
# Construct URL (search API usually shows a pin)
query_str = f"{latitude_deg:.7f},{longitude_deg:.7f}"
gmaps_url = f"https://www.google.com/maps/search/?api=1&query={query_str}"
logging.info(f"{log_prefix} Opening Google Maps URL: {gmaps_url}")
opened_ok = webbrowser.open_new_tab(gmaps_url) # Open in new tab
if not opened_ok:
logging.warning(f"{log_prefix} webbrowser.open_new_tab returned False.")
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):
"""Internal helper to calculate geographic corners (degrees) from geo_info (radians). Requires pyproj."""
# (Implementation unchanged from previous version)
if not _pyproj_available:
return None
log_prefix = "[Utils KML Calc]"
try:
geod = pyproj.Geod(ellps="WGS84")
center_lat_rad = geo_info_radians["lat"]
center_lon_rad = geo_info_radians["lon"]
orient_rad = geo_info_radians["orientation"]
calc_orient_rad = -orient_rad
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"]
if not (scale_x > 0 and scale_y > 0 and width > 0 and height > 0):
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)),
]
corners_meters = [(dx * scale_x, dy * scale_y) for dx, dy in corners_pixel]
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)
corners_meters_rotated = [
(dx * cos_o - dy * sin_o, dx * sin_o + dy * cos_o)
for dx, dy in corners_meters
]
corners_geo_deg = []
center_lon_deg, center_lat_deg = math.degrees(center_lon_rad), 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
except KeyError as ke:
logging.error(f"{log_prefix} Missing 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)
log_prefix = "[Utils KML Gen]"
if not _simplekml_available or not _pyproj_available:
return False
if not geo_info_radians or not geo_info_radians.get("valid", False):
return False
try:
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
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
)
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
) -> Optional[str]:
"""
Generates a temporary KML file containing a LookAt element pointing to
the specified coordinates AND a Placemark (point) at those coordinates.
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.
placemark_desc (Optional[str]): Optional description for the placemark.
Returns:
Optional[str]: The path to the temporary KML file created, or None on error.
"""
log_prefix = "[Utils KML LookAtPoint]" # Updated log prefix
if not _simplekml_available:
logging.error(f"{log_prefix} Cannot generate KML: simplekml library missing.")
return None
# Validate coordinates (same validation as before)
if not (isinstance(latitude_deg, (int, float)) and math.isfinite(latitude_deg) and -90 <= latitude_deg <= 90):
logging.error(f"{log_prefix} Invalid latitude provided: {latitude_deg}.")
return None
if not (isinstance(longitude_deg, (int, float)) and math.isfinite(longitude_deg) and -180 <= longitude_deg <= 180):
logging.error(f"{log_prefix} Invalid longitude provided: {longitude_deg}.")
return None
try:
# Create a KML object
kml = simplekml.Kml()
# --- Set the LookAt parameters (same as before) ---
kml.document.lookat.longitude = longitude_deg
kml.document.lookat.latitude = latitude_deg
kml.document.lookat.range = altitude_m
kml.document.lookat.tilt = tilt
kml.document.lookat.heading = heading
kml.document.lookat.altitudemode = simplekml.AltitudeMode.relativetoground
# --- Add the Placemark (New part) ---
# Create a point Placemark at the specified coordinates
point = kml.newpoint(name=placemark_name)
point.coords = [(longitude_deg, latitude_deg)] # simplekml uses (lon, lat) order
# Add description if provided
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
with tempfile.NamedTemporaryFile(mode='w', suffix='.kml', delete=False, encoding='utf-8') as temp_kml:
temp_kml_path = temp_kml.name
logging.debug(f"{log_prefix} Saving temporary LookAt+Point KML to: {temp_kml_path}")
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
except Exception as e:
logging.exception(f"{log_prefix} Error generating LookAt+Point KML file:")
return None
def generate_multi_point_kml(points_data: List[Tuple[float, float, str, Optional[str]]]) -> Optional[str]:
"""
Generates a temporary KML file containing multiple Placemarks (points)
and a LookAt view potentially encompassing them.
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).
Returns:
Optional[str]: The path to the temporary KML file created, or None on error.
"""
log_prefix = "[Utils KML MultiPoint]"
if not _simplekml_available:
logging.error(f"{log_prefix} Cannot generate KML: simplekml library missing.")
return None
if not points_data:
logging.warning(f"{log_prefix} No points provided to generate KML.")
return None
try:
kml = simplekml.Kml(name="Multiple Locations")
# --- Determine LookAt View ---
# Simple approach: LookAt the 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.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
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'
style_mouse_on_map.iconstyle.scale = 1.1
# --- Add Placemarks ---
logging.debug(f"{log_prefix} Adding {len(points_data)} placemarks to KML.")
for lat, lon, name, desc in points_data:
point = kml.newpoint(name=name)
point.coords = [(lon, lat)] # (lon, lat) order
if desc:
point.description = desc
# Apply style based on name (example)
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
# --- Save to Temporary File ---
with tempfile.NamedTemporaryFile(mode='w', suffix='.kml', delete=False, encoding='utf-8') as temp_kml:
temp_kml_path = temp_kml.name
logging.debug(f"{log_prefix} Saving temporary Multi-Point KML to: {temp_kml_path}")
temp_kml.write(kml.kml()) # Write KML content
logging.debug(f"{log_prefix} Multi-Point KML file created successfully.")
return temp_kml_path
except Exception as e:
logging.exception(f"{log_prefix} Error generating Multi-Point KML file:")
return None
# --- END OF FILE utils.py ---