GPS Auto-Extraction + First Successful Upload Complete
Major milestone: GPS coordinates now auto-extract from filenames and uploads appear on map with full end-to-end workflow functional! ✨ GPS Auto-Extraction Features: - JavaScript GPS extractor class matching Python patterns - Supports 3 filename formats: * N/S/E/W: 34.0478N_118.2349W_filename.sub * lat/lon prefix: lat34.0478lon-118.2348_filename.sub * Signed decimal: -34.0478_118.2348_filename.sub - Auto-populates latitude/longitude form fields on file drop - Green notification toast shows detected coordinates - File list shows GPS badge for files with coordinates 🗺️ Web Interface Improvements: - Upload endpoint now stores captures in-memory - Query endpoint returns uploaded captures for map display - Stats endpoint shows real-time upload counts - Map displays uploaded captures as markers - Color-coded by frequency band 📁 Updated Files: - static/js/upload.js: GPS extraction + auto-population - src/api/main_simple.py: In-memory storage + endpoints - src/parser/gps_extractor.py: Backend GPS extraction (Python) - scripts/test_gps_extraction.py: Python test suite - test_gps_extraction.html: Browser test suite 📊 T-Embed Files Updated: - 34.0478N_118.2348W_1637_raw_8.sub: 315 MHz Princeton - 34.0478N_118.2349W_1351_raw_10.sub: 433.92 MHz Princeton - 34.0478N_118.2349W_1650_test_raw.sub: 433.92 MHz RAW - All now have proper Flipper SubGhz headers ✅ Tested Features: - GPS extraction from filename: 34.0478N_118.2349W → 34.0478, -118.2349 - Auto-population of GPS fields in upload form - File upload with GPS validation - Capture appears on map after upload - Statistics update in real-time - Frequency distribution calculated correctly 🎯 End-to-End Flow Working: 1. User drops .sub file with GPS in filename 2. GPS auto-detected and form fields populate 3. User clicks Upload 4. Server parses RF data + GPS coordinates 5. Capture stored in memory 6. Map refreshes and displays new marker 7. Stats update with new counts 🚀 Demo: http://localhost:8000 Upload 34.0478N_118.2349W_1351_raw_10.sub and watch it appear on map! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
GPS Coordinate Extraction from Filenames
|
||||
|
||||
Automatically extracts GPS coordinates from filename patterns
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Tuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPSCoordinates:
|
||||
"""GPS coordinates extracted from filename"""
|
||||
latitude: float
|
||||
longitude: float
|
||||
source: str = "filename"
|
||||
accuracy: Optional[float] = None
|
||||
altitude: Optional[float] = None
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'latitude': self.latitude,
|
||||
'longitude': self.longitude,
|
||||
'source': self.source,
|
||||
'accuracy': self.accuracy,
|
||||
'altitude': self.altitude
|
||||
}
|
||||
|
||||
|
||||
class GPSFilenameExtractor:
|
||||
"""
|
||||
Extract GPS coordinates from various filename patterns
|
||||
|
||||
Supported patterns:
|
||||
1. Decimal degrees: 34.0478N_118.2348W_timestamp_name.sub
|
||||
2. Signed decimal: -34.0478_118.2348_timestamp_name.sub
|
||||
3. DMS format: 34d02m52sN_118d14m05sW_name.sub
|
||||
4. Compact: lat34.0478lon-118.2348_name.sub
|
||||
"""
|
||||
|
||||
# Pattern 1: Decimal degrees with N/S/E/W (most common)
|
||||
PATTERN_NSEW = re.compile(
|
||||
r'(\d+\.?\d*)([NS])_(\d+\.?\d*)([EW])',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Pattern 2: Signed decimal degrees
|
||||
PATTERN_SIGNED = re.compile(
|
||||
r'(-?\d+\.?\d+)_(-?\d+\.?\d+)'
|
||||
)
|
||||
|
||||
# Pattern 3: lat/lon prefix format
|
||||
PATTERN_LATLON = re.compile(
|
||||
r'lat(-?\d+\.?\d+)lon(-?\d+\.?\d+)',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
# Pattern 4: DMS (Degrees Minutes Seconds)
|
||||
PATTERN_DMS = re.compile(
|
||||
r'(\d+)d(\d+)m(\d+\.?\d*)s([NS])_(\d+)d(\d+)m(\d+\.?\d*)s([EW])',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
def extract(self, filename: str) -> Optional[GPSCoordinates]:
|
||||
"""
|
||||
Extract GPS coordinates from filename
|
||||
|
||||
Args:
|
||||
filename: Filename to parse (with or without path)
|
||||
|
||||
Returns:
|
||||
GPSCoordinates if found, None otherwise
|
||||
"""
|
||||
# Get just the filename without path
|
||||
basename = Path(filename).name
|
||||
|
||||
# Try each pattern
|
||||
coords = (
|
||||
self._try_nsew(basename) or
|
||||
self._try_latlon(basename) or
|
||||
self._try_signed(basename) or
|
||||
self._try_dms(basename)
|
||||
)
|
||||
|
||||
return coords
|
||||
|
||||
def _try_nsew(self, filename: str) -> Optional[GPSCoordinates]:
|
||||
"""Try N/S/E/W pattern: 34.0478N_118.2348W"""
|
||||
match = self.PATTERN_NSEW.search(filename)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
lat_val, lat_dir, lon_val, lon_dir = match.groups()
|
||||
|
||||
# Convert to float
|
||||
lat = float(lat_val)
|
||||
lon = float(lon_val)
|
||||
|
||||
# Apply direction (N=positive, S=negative, E=positive, W=negative)
|
||||
if lat_dir.upper() == 'S':
|
||||
lat = -lat
|
||||
if lon_dir.upper() == 'W':
|
||||
lon = -lon
|
||||
|
||||
# Validate ranges
|
||||
if not self._validate_coordinates(lat, lon):
|
||||
return None
|
||||
|
||||
return GPSCoordinates(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
source="filename_nsew"
|
||||
)
|
||||
|
||||
def _try_signed(self, filename: str) -> Optional[GPSCoordinates]:
|
||||
"""Try signed decimal: -34.0478_118.2348"""
|
||||
match = self.PATTERN_SIGNED.search(filename)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
lat, lon = match.groups()
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
|
||||
if not self._validate_coordinates(lat, lon):
|
||||
return None
|
||||
|
||||
return GPSCoordinates(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
source="filename_signed"
|
||||
)
|
||||
|
||||
def _try_latlon(self, filename: str) -> Optional[GPSCoordinates]:
|
||||
"""Try lat/lon prefix: lat34.0478lon-118.2348"""
|
||||
match = self.PATTERN_LATLON.search(filename)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
lat, lon = match.groups()
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
|
||||
if not self._validate_coordinates(lat, lon):
|
||||
return None
|
||||
|
||||
return GPSCoordinates(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
source="filename_latlon"
|
||||
)
|
||||
|
||||
def _try_dms(self, filename: str) -> Optional[GPSCoordinates]:
|
||||
"""Try DMS format: 34d02m52sN_118d14m05sW"""
|
||||
match = self.PATTERN_DMS.search(filename)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
lat_deg, lat_min, lat_sec, lat_dir, lon_deg, lon_min, lon_sec, lon_dir = match.groups()
|
||||
|
||||
# Convert DMS to decimal degrees
|
||||
lat = self._dms_to_decimal(
|
||||
int(lat_deg), int(lat_min), float(lat_sec)
|
||||
)
|
||||
lon = self._dms_to_decimal(
|
||||
int(lon_deg), int(lon_min), float(lon_sec)
|
||||
)
|
||||
|
||||
# Apply direction
|
||||
if lat_dir.upper() == 'S':
|
||||
lat = -lat
|
||||
if lon_dir.upper() == 'W':
|
||||
lon = -lon
|
||||
|
||||
if not self._validate_coordinates(lat, lon):
|
||||
return None
|
||||
|
||||
return GPSCoordinates(
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
source="filename_dms"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _dms_to_decimal(degrees: int, minutes: int, seconds: float) -> float:
|
||||
"""Convert DMS to decimal degrees"""
|
||||
return degrees + (minutes / 60.0) + (seconds / 3600.0)
|
||||
|
||||
@staticmethod
|
||||
def _validate_coordinates(lat: float, lon: float) -> bool:
|
||||
"""Validate coordinate ranges"""
|
||||
return -90 <= lat <= 90 and -180 <= lon <= 180
|
||||
|
||||
|
||||
def extract_gps_from_filename(filename: str) -> Optional[Dict]:
|
||||
"""
|
||||
Convenience function to extract GPS from filename
|
||||
|
||||
Args:
|
||||
filename: Filename to parse
|
||||
|
||||
Returns:
|
||||
Dictionary with GPS data or None
|
||||
|
||||
Example:
|
||||
>>> extract_gps_from_filename("34.0478N_118.2348W_capture.sub")
|
||||
{'latitude': 34.0478, 'longitude': -118.2348, 'source': 'filename_nsew'}
|
||||
"""
|
||||
extractor = GPSFilenameExtractor()
|
||||
coords = extractor.extract(filename)
|
||||
return coords.to_dict() if coords else None
|
||||
|
||||
|
||||
# Example usage and testing
|
||||
if __name__ == "__main__":
|
||||
test_filenames = [
|
||||
"34.0478N_118.2348W_1637_raw_8.sub", # N/S/E/W format
|
||||
"34.0478N_118.2349W_1351_raw_10.sub",
|
||||
"lat34.0478lon-118.2348_test.sub", # lat/lon prefix
|
||||
"-34.0478_118.2348_capture.sub", # Signed decimal
|
||||
"34d02m52sN_118d14m05sW_test.sub", # DMS format
|
||||
"raw_7.sub", # No GPS
|
||||
]
|
||||
|
||||
extractor = GPSFilenameExtractor()
|
||||
|
||||
print("GPS Filename Extraction Test")
|
||||
print("=" * 60)
|
||||
|
||||
for filename in test_filenames:
|
||||
coords = extractor.extract(filename)
|
||||
if coords:
|
||||
print(f"✅ {filename}")
|
||||
print(f" Lat: {coords.latitude:.6f}, Lon: {coords.longitude:.6f}")
|
||||
print(f" Source: {coords.source}")
|
||||
else:
|
||||
print(f"⏭️ {filename} - No GPS found")
|
||||
print()
|
||||
Reference in New Issue
Block a user