mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-04-22 09:06:37 +00:00
134 lines
5.3 KiB
Python
134 lines
5.3 KiB
Python
import numpy as np
|
|
import math
|
|
import logging
|
|
from typing import Tuple, Optional, Any, Dict
|
|
from pydantic import BaseModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- Data Models ---
|
|
|
|
class GPSPoint(BaseModel):
|
|
lat: float
|
|
lon: float
|
|
altitude_m: Optional[float] = 0.0
|
|
|
|
class Sim3Transform(BaseModel):
|
|
translation: np.ndarray
|
|
rotation: np.ndarray
|
|
scale: float
|
|
|
|
model_config = {"arbitrary_types_allowed": True}
|
|
|
|
class ObjectGPSResponse(BaseModel):
|
|
gps: GPSPoint
|
|
accuracy_meters: float
|
|
|
|
class OriginNotSetError(Exception):
|
|
pass
|
|
|
|
# --- Implementation ---
|
|
|
|
class CoordinateTransformer:
|
|
"""
|
|
F14 (also referenced as F13 in some architectural diagrams): Coordinate Transformer.
|
|
Maps precise pixel object coordinates to Ray-Cloud geospatial intersections.
|
|
Handles transformations between 2D pixels, 3D local maps, 3D global ENU, and GPS.
|
|
"""
|
|
def __init__(self, camera_model: Any):
|
|
self.camera_model = camera_model
|
|
self.origins: Dict[str, GPSPoint] = {}
|
|
self.conversion_factors: Dict[str, Tuple[float, float]] = {}
|
|
|
|
def _compute_meters_per_degree(self, latitude: float) -> Tuple[float, float]:
|
|
lat_rad = math.radians(latitude)
|
|
meters_per_degree_lat = 111319.5
|
|
meters_per_degree_lon = 111319.5 * math.cos(lat_rad)
|
|
return (meters_per_degree_lon, meters_per_degree_lat)
|
|
|
|
def set_enu_origin(self, flight_id: str, origin_gps: GPSPoint) -> None:
|
|
"""Sets the global [Lat, Lon, Alt] origin for ENU conversions per flight."""
|
|
self.origins[flight_id] = origin_gps
|
|
self.conversion_factors[flight_id] = self._compute_meters_per_degree(origin_gps.lat)
|
|
logger.info(f"Coordinate Transformer ENU origin set for flight {flight_id}.")
|
|
|
|
def get_enu_origin(self, flight_id: str) -> GPSPoint:
|
|
if flight_id not in self.origins:
|
|
raise OriginNotSetError(f"Origin not set for flight {flight_id}")
|
|
return self.origins[flight_id]
|
|
|
|
def gps_to_enu(self, flight_id: str, gps: GPSPoint) -> Tuple[float, float, float]:
|
|
"""Converts global GPS Geodetic coordinates to local Metric ENU."""
|
|
origin = self.get_enu_origin(flight_id)
|
|
factors = self.conversion_factors[flight_id]
|
|
delta_lat = gps.lat - origin.lat
|
|
delta_lon = gps.lon - origin.lon
|
|
east = delta_lon * factors[0]
|
|
north = delta_lat * factors[1]
|
|
return (east, north, 0.0)
|
|
|
|
def enu_to_gps(self, flight_id: str, enu: Tuple[float, float, float]) -> GPSPoint:
|
|
"""Converts local metric ENU coordinates to global GPS Geodetic coordinates."""
|
|
origin = self.get_enu_origin(flight_id)
|
|
factors = self.conversion_factors[flight_id]
|
|
east, north, up = enu
|
|
delta_lon = east / factors[0]
|
|
delta_lat = north / factors[1]
|
|
alt = (origin.altitude_m or 0.0) + up
|
|
return GPSPoint(lat=origin.lat + delta_lat, lon=origin.lon + delta_lon, altitude_m=alt)
|
|
|
|
def _ray_cloud_intersection(self, ray_origin: np.ndarray, ray_dir: np.ndarray, point_cloud: np.ndarray, max_dist: float = 2.0) -> Optional[np.ndarray]:
|
|
"""
|
|
Finds the 3D point in the local point cloud that intersects with (or is closest to) the ray.
|
|
"""
|
|
if point_cloud is None or len(point_cloud) == 0:
|
|
return None
|
|
|
|
# Vectors from the ray origin to all points in the cloud
|
|
w = point_cloud - ray_origin
|
|
|
|
# Projection scalar of w onto the ray direction
|
|
proj = np.dot(w, ray_dir)
|
|
|
|
# We only care about points that are in front of the camera (positive projection)
|
|
valid_idx = proj > 0
|
|
if not np.any(valid_idx):
|
|
return None
|
|
|
|
w_valid = w[valid_idx]
|
|
proj_valid = proj[valid_idx]
|
|
pc_valid = point_cloud[valid_idx]
|
|
|
|
# Perpendicular distance squared from valid points to the ray (Pythagorean theorem)
|
|
w_sq_norm = np.sum(w_valid**2, axis=1)
|
|
dist_sq = w_sq_norm - (proj_valid**2)
|
|
|
|
min_idx = np.argmin(dist_sq)
|
|
min_dist = math.sqrt(max(0.0, dist_sq[min_idx]))
|
|
|
|
if min_dist > max_dist:
|
|
logger.warning(f"No point cloud feature found near the object ray (closest was {min_dist:.2f}m away).")
|
|
return None
|
|
|
|
# Return the actual 3D feature point from the map
|
|
return pc_valid[min_idx]
|
|
|
|
def pixel_to_gps(self, flight_id: str, u: float, v: float, local_pose_matrix: np.ndarray, local_point_cloud: np.ndarray, sim3: Sim3Transform) -> Optional[ObjectGPSResponse]:
|
|
"""
|
|
Executes the Ray-Cloud intersection algorithm to geolocate an object in an image.
|
|
Decouples external DEM errors to meet AC-2 and AC-10.
|
|
"""
|
|
d_cam = self.camera_model.pixel_to_ray(u, v)
|
|
|
|
R_local = local_pose_matrix[:3, :3]
|
|
T_local = local_pose_matrix[:3, 3]
|
|
ray_dir_local = R_local @ d_cam
|
|
ray_dir_local = ray_dir_local / np.linalg.norm(ray_dir_local)
|
|
|
|
p_local = self._ray_cloud_intersection(T_local, ray_dir_local, local_point_cloud)
|
|
if p_local is None: return None
|
|
|
|
p_metric = sim3.scale * (sim3.rotation @ p_local) + sim3.translation
|
|
gps_coord = self.enu_to_gps(flight_id, (p_metric[0], p_metric[1], p_metric[2]))
|
|
|
|
return ObjectGPSResponse(gps=gps_coord, accuracy_meters=(5.0 * sim3.scale)) |