mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-04-22 13:06:38 +00:00
fix: post-audit — runtime bugs, functional gaps, docs, hardening
Phase A — Runtime bugs: - SSE: add push_event() method to SSEEventStreamer (was missing, masked by mocks) - MAVLink: satellites_visible=10 (was 0, triggers ArduPilot failsafe) - MAVLink: horiz_accuracy=sqrt(P[0,0]+P[1,1]) per spec (was sqrt(avg)) - MAVLink: MEDIUM confidence → fix_type=3 per solution.md (was 2) Phase B — Functional gaps: - handle_user_fix() injects operator GPS into ESKF with noise=500m - app.py uses create_vo_backend() factory (was hardcoded SequentialVO) - ESKF: Mahalanobis gating on satellite updates (rejects outliers >5σ) - ESKF: public accessors (position, quaternion, covariance, last_timestamp) - Processor: no more private ESKF field access Phase C — Documentation: - README: correct API endpoints, CLI command, 40+ env vars documented - Dockerfile: ENV prefixes match pydantic-settings (DB_, SATELLITE_, MAVLINK_) - tech_stack.md marked ARCHIVED (contradicts solution.md) Phase D — Hardening: - JWT auth middleware (AUTH_ENABLED=false default, verify_token on /flights) - TLS config env vars (AUTH_SSL_CERTFILE, AUTH_SSL_KEYFILE) - SHA-256 tile manifest verification in SatelliteDataManager - AuthConfig, ESKFSettings, MAVLinkConfig, SatelliteConfig in config.py Also: conftest.py shared fixtures, download_tiles.py, convert_to_trt.py scripts, config wiring into app.py lifespan, config-driven ESKF, calculate_precise_angle fix. Tests: 196 passed / 8 skipped. Ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,19 +23,17 @@ Scenarios:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Ensure src/ is on the path when running from the repo root
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
from gps_denied.core.benchmark import AccuracyBenchmark, SyntheticTrajectory, SyntheticTrajectoryConfig
|
||||
from gps_denied.schemas import GPSPoint
|
||||
|
||||
|
||||
ORIGIN = GPSPoint(lat=49.0, lon=32.0)
|
||||
|
||||
_SEP = "─" * 60
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""TensorRT engine conversion script.
|
||||
|
||||
Converts ONNX models to TensorRT FP16 .engine files for Jetson deployment.
|
||||
Wraps trtexec CLI (available on Jetson with JetPack installed).
|
||||
|
||||
Usage:
|
||||
# Convert a single model
|
||||
python scripts/convert_to_trt.py --onnx weights/litesam.onnx --output /opt/engines/litesam.engine
|
||||
|
||||
# Convert all known models from weights/ to engines/
|
||||
python scripts/convert_to_trt.py --all --onnx-dir weights/ --engine-dir /opt/engines/
|
||||
|
||||
# Dry run (check trtexec availability)
|
||||
python scripts/convert_to_trt.py --check
|
||||
|
||||
Requires:
|
||||
- NVIDIA TensorRT (trtexec in PATH) — available on Jetson with JetPack
|
||||
- NOT available on dev/CI machines — script exits cleanly with a message
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Known models and their expected ONNX filenames
|
||||
_MODELS = {
|
||||
"superpoint": "superpoint.onnx",
|
||||
"lightglue": "lightglue.onnx",
|
||||
"xfeat": "xfeat.onnx",
|
||||
"dinov2": "dinov2.onnx",
|
||||
"litesam": "litesam.onnx",
|
||||
}
|
||||
|
||||
|
||||
def find_trtexec() -> str | None:
|
||||
"""Find trtexec binary in PATH or common Jetson locations."""
|
||||
path = shutil.which("trtexec")
|
||||
if path:
|
||||
return path
|
||||
# Common Jetson paths
|
||||
for candidate in [
|
||||
"/usr/src/tensorrt/bin/trtexec",
|
||||
"/usr/local/cuda/bin/trtexec",
|
||||
]:
|
||||
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def convert_onnx_to_engine(
|
||||
onnx_path: str,
|
||||
engine_path: str,
|
||||
fp16: bool = True,
|
||||
workspace_mb: int = 1024,
|
||||
trtexec_path: str | None = None,
|
||||
) -> bool:
|
||||
"""Run trtexec to convert ONNX → TensorRT engine.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
trtexec = trtexec_path or find_trtexec()
|
||||
if not trtexec:
|
||||
print("ERROR: trtexec not found. Install TensorRT or run on Jetson with JetPack.")
|
||||
return False
|
||||
|
||||
if not os.path.isfile(onnx_path):
|
||||
print(f"ERROR: ONNX file not found: {onnx_path}")
|
||||
return False
|
||||
|
||||
os.makedirs(os.path.dirname(engine_path) or ".", exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
trtexec,
|
||||
f"--onnx={onnx_path}",
|
||||
f"--saveEngine={engine_path}",
|
||||
f"--workspace={workspace_mb}",
|
||||
]
|
||||
if fp16:
|
||||
cmd.append("--fp16")
|
||||
|
||||
print(f" Converting: {onnx_path} → {engine_path}")
|
||||
print(f" Command: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
||||
if result.returncode == 0:
|
||||
size_mb = os.path.getsize(engine_path) / (1024 * 1024)
|
||||
print(f" OK: {engine_path} ({size_mb:.1f} MB)")
|
||||
return True
|
||||
else:
|
||||
print(f" FAIL (exit {result.returncode})")
|
||||
if result.stderr:
|
||||
print(f" stderr: {result.stderr[:500]}")
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" FAIL: trtexec timed out (>600s)")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
print(f" FAIL: trtexec not found at {trtexec}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Convert ONNX models to TensorRT FP16 engines")
|
||||
parser.add_argument("--onnx", help="Input ONNX model path")
|
||||
parser.add_argument("--output", help="Output .engine path")
|
||||
parser.add_argument("--all", action="store_true", help="Convert all known models")
|
||||
parser.add_argument("--onnx-dir", default="weights", help="Directory with ONNX files")
|
||||
parser.add_argument("--engine-dir", default="/opt/engines", help="Output engine directory")
|
||||
parser.add_argument("--no-fp16", action="store_true", help="Disable FP16 (use FP32)")
|
||||
parser.add_argument("--workspace", type=int, default=1024, help="TRT workspace (MB)")
|
||||
parser.add_argument("--check", action="store_true", help="Check trtexec availability only")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check mode
|
||||
if args.check:
|
||||
trtexec = find_trtexec()
|
||||
if trtexec:
|
||||
print(f"trtexec found: {trtexec}")
|
||||
return 0
|
||||
else:
|
||||
print("trtexec not found. Not on Jetson or TensorRT not installed.")
|
||||
return 1
|
||||
|
||||
fp16 = not args.no_fp16
|
||||
|
||||
# Single model conversion
|
||||
if args.onnx and args.output:
|
||||
ok = convert_onnx_to_engine(args.onnx, args.output, fp16=fp16, workspace_mb=args.workspace)
|
||||
return 0 if ok else 1
|
||||
|
||||
# Batch conversion
|
||||
if args.all:
|
||||
trtexec = find_trtexec()
|
||||
if not trtexec:
|
||||
print("trtexec not found. Conversion requires Jetson with JetPack installed.")
|
||||
return 1
|
||||
|
||||
print(f"Converting all known models from {args.onnx_dir}/ → {args.engine_dir}/")
|
||||
success = 0
|
||||
fail = 0
|
||||
for model_name, onnx_file in _MODELS.items():
|
||||
onnx_path = os.path.join(args.onnx_dir, onnx_file)
|
||||
engine_path = os.path.join(args.engine_dir, f"{model_name}.engine")
|
||||
if not os.path.isfile(onnx_path):
|
||||
print(f" SKIP {model_name}: {onnx_path} not found")
|
||||
continue
|
||||
ok = convert_onnx_to_engine(
|
||||
onnx_path, engine_path,
|
||||
fp16=fp16, workspace_mb=args.workspace, trtexec_path=trtexec,
|
||||
)
|
||||
if ok:
|
||||
success += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
print(f"\nDone: {success} converted, {fail} failed")
|
||||
return 1 if fail > 0 else 0
|
||||
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Satellite tile downloader for GPS-denied navigation.
|
||||
|
||||
Downloads Web Mercator satellite tiles for a given bounding box and stores
|
||||
them in the standard z/x/y.png directory layout expected by SatelliteDataManager.
|
||||
|
||||
Usage:
|
||||
# Dry run (count tiles only)
|
||||
python scripts/download_tiles.py --lat-min 48.5 --lat-max 49.5 --lon-min 31.5 --lon-max 32.5 --dry-run
|
||||
|
||||
# Download from OpenStreetMap (default, no API key)
|
||||
python scripts/download_tiles.py --lat-min 48.5 --lat-max 49.5 --lon-min 31.5 --lon-max 32.5
|
||||
|
||||
# Custom zoom and output dir
|
||||
python scripts/download_tiles.py --lat-min 49.0 --lat-max 49.1 --lon-min 32.0 --lon-max 32.1 \
|
||||
--zoom 18 --output .satellite_tiles
|
||||
|
||||
# Google Maps provider (requires API key)
|
||||
python scripts/download_tiles.py --lat-min 49.0 --lat-max 49.1 --lon-min 32.0 --lon-max 32.1 \
|
||||
--provider google --api-key YOUR_KEY
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
||||
|
||||
|
||||
from gps_denied.schemas.satellite import TileCoords
|
||||
from gps_denied.utils.mercator import latlon_to_tile
|
||||
|
||||
# Tile provider URL templates
|
||||
_PROVIDERS = {
|
||||
"osm": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
"google": (
|
||||
"https://maps.googleapis.com/maps/api/staticmap"
|
||||
"?center={lat},{lon}&zoom={z}&size=256x256&maptype=satellite&key={api_key}"
|
||||
),
|
||||
}
|
||||
|
||||
_USER_AGENT = "GPS-Denied-Onboard/1.0 (tile prefetch)"
|
||||
|
||||
|
||||
def compute_tile_range(
|
||||
lat_min: float, lat_max: float, lon_min: float, lon_max: float, zoom: int
|
||||
) -> list[TileCoords]:
|
||||
"""Compute all tile coordinates within a lat/lon bounding box."""
|
||||
nw = latlon_to_tile(lat_max, lon_min, zoom) # NW corner
|
||||
se = latlon_to_tile(lat_min, lon_max, zoom) # SE corner
|
||||
tiles: list[TileCoords] = []
|
||||
for x in range(nw.x, se.x + 1):
|
||||
for y in range(nw.y, se.y + 1):
|
||||
tiles.append(TileCoords(x=x, y=y, zoom=zoom))
|
||||
return tiles
|
||||
|
||||
|
||||
async def download_tiles(
|
||||
tiles: list[TileCoords],
|
||||
output_dir: str,
|
||||
provider: str = "osm",
|
||||
api_key: str = "",
|
||||
max_concurrent: int = 4,
|
||||
delay_s: float = 0.1,
|
||||
) -> tuple[int, int]:
|
||||
"""Download tiles concurrently. Returns (success_count, error_count)."""
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
print("ERROR: httpx required. Install: pip install httpx")
|
||||
return 0, len(tiles)
|
||||
|
||||
url_template = _PROVIDERS.get(provider, _PROVIDERS["osm"])
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
success = 0
|
||||
errors = 0
|
||||
|
||||
async def fetch_one(client: httpx.AsyncClient, tc: TileCoords) -> bool:
|
||||
nonlocal success, errors
|
||||
out_path = os.path.join(output_dir, str(tc.zoom), str(tc.x), f"{tc.y}.png")
|
||||
if os.path.isfile(out_path):
|
||||
success += 1
|
||||
return True
|
||||
|
||||
if provider == "google":
|
||||
from gps_denied.utils.mercator import tile_to_latlon
|
||||
center = tile_to_latlon(tc.x + 0.5, tc.y + 0.5, tc.zoom)
|
||||
url = url_template.format(
|
||||
z=tc.zoom, x=tc.x, y=tc.y,
|
||||
lat=center.lat, lon=center.lon, api_key=api_key,
|
||||
)
|
||||
else:
|
||||
url = url_template.format(z=tc.zoom, x=tc.x, y=tc.y)
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
success += 1
|
||||
await asyncio.sleep(delay_s)
|
||||
return True
|
||||
except Exception as exc:
|
||||
errors += 1
|
||||
print(f" FAIL {tc.zoom}/{tc.x}/{tc.y}: {exc}")
|
||||
return False
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30.0,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
tasks = [fetch_one(client, tc) for tc in tiles]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
return success, errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Download satellite tiles for GPS-denied navigation")
|
||||
parser.add_argument("--lat-min", type=float, required=True)
|
||||
parser.add_argument("--lat-max", type=float, required=True)
|
||||
parser.add_argument("--lon-min", type=float, required=True)
|
||||
parser.add_argument("--lon-max", type=float, required=True)
|
||||
parser.add_argument("--zoom", type=int, default=18)
|
||||
parser.add_argument("--output", default=".satellite_tiles", help="Output directory")
|
||||
parser.add_argument("--provider", choices=["osm", "google"], default="osm")
|
||||
parser.add_argument("--api-key", default="", help="API key (for google provider)")
|
||||
parser.add_argument("--max-concurrent", type=int, default=4)
|
||||
parser.add_argument("--dry-run", action="store_true", help="Only count tiles, don't download")
|
||||
args = parser.parse_args()
|
||||
|
||||
tiles = compute_tile_range(args.lat_min, args.lat_max, args.lon_min, args.lon_max, args.zoom)
|
||||
|
||||
# Estimate size: ~30KB per tile
|
||||
est_mb = len(tiles) * 30 / 1024
|
||||
print(f"Tiles: {len(tiles)} at zoom {args.zoom}")
|
||||
print(f"Bounding box: ({args.lat_min}, {args.lon_min}) → ({args.lat_max}, {args.lon_max})")
|
||||
print(f"Estimated size: ~{est_mb:.1f} MB")
|
||||
print(f"Output: {args.output}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\n--dry-run: no tiles downloaded.")
|
||||
return 0
|
||||
|
||||
t0 = time.time()
|
||||
ok, err = asyncio.run(download_tiles(
|
||||
tiles, args.output,
|
||||
provider=args.provider, api_key=args.api_key,
|
||||
max_concurrent=args.max_concurrent,
|
||||
))
|
||||
elapsed = time.time() - t0
|
||||
|
||||
print(f"\nDone in {elapsed:.1f}s: {ok} downloaded, {err} errors")
|
||||
return 1 if err > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user