diff --git a/SatelliteProvider/Configs/DirectoriesConfig.cs b/SatelliteProvider/Configs/DirectoriesConfig.cs new file mode 100644 index 0000000..57cd824 --- /dev/null +++ b/SatelliteProvider/Configs/DirectoriesConfig.cs @@ -0,0 +1,15 @@ +namespace SatelliteProvider.Configs; + +public class DirectoriesConfig +{ + public string? ApiResourcesDirectory { get; set; } = null!; + + public string VideosDirectory { get; set; } = null!; + public string LabelsDirectory { get; set; } = null!; + public string ImagesDirectory { get; set; } = null!; + public string ResultsDirectory { get; set; } = null!; + public string ThumbnailsDirectory { get; set; } = null!; + + public string GpsSatDirectory { get; set; } = null!; + public string GpsRouteDirectory { get; set; } = null!; +} \ No newline at end of file diff --git a/SatelliteProvider/Configs/MapConfig.cs b/SatelliteProvider/Configs/MapConfig.cs new file mode 100644 index 0000000..0d86d37 --- /dev/null +++ b/SatelliteProvider/Configs/MapConfig.cs @@ -0,0 +1,7 @@ +namespace SatelliteProvider.Configs; + +public class MapConfig +{ + public string Service { get; set; } = null!; + public string ApiKey { get; set; } = null!; +} \ No newline at end of file diff --git a/SatelliteProvider/DTO/Direction.cs b/SatelliteProvider/DTO/Direction.cs new file mode 100644 index 0000000..333d54d --- /dev/null +++ b/SatelliteProvider/DTO/Direction.cs @@ -0,0 +1,17 @@ +namespace SatelliteProvider.DTO; + +public class Direction +{ + public double Distance { get; set; } + public double Azimuth { get; set; } + + public Direction() { } + + public Direction(double distance, double azimuth) + { + Distance = distance; + Azimuth = azimuth; + } + + public override string ToString() => $"{Distance:F2}, {Azimuth:F1} deg"; +} diff --git a/SatelliteProvider/DTO/GeoPoint.cs b/SatelliteProvider/DTO/GeoPoint.cs new file mode 100644 index 0000000..0f5d16e --- /dev/null +++ b/SatelliteProvider/DTO/GeoPoint.cs @@ -0,0 +1,32 @@ +namespace SatelliteProvider; + +public class GeoPoint +{ + const double PRECISION_TOLERANCE = 0.00005; + public double Lat { get; } + public double Lon { get; } + + public GeoPoint() { } + + public GeoPoint(double lat, double lon) + { + Lat = lat; + Lon = lon; + } + + public override string ToString() => $"{Lat:F4}, {Lon:F4}"; + + public override bool Equals(object? obj) + { + if (obj is not GeoPoint point) return false; + return ReferenceEquals(this, obj) || Equals(point); + } + + private bool Equals(GeoPoint point) => + Math.Abs(Lat - point.Lat) < PRECISION_TOLERANCE && Math.Abs(Lon - point.Lon) < PRECISION_TOLERANCE; + + public override int GetHashCode() => HashCode.Combine(Lat, Lon); + + public static bool operator ==(GeoPoint left, GeoPoint right) => Equals(left, right); + public static bool operator !=(GeoPoint left, GeoPoint right) => !Equals(left, right); +} \ No newline at end of file diff --git a/SatelliteProvider/DTO/SatTile.cs b/SatelliteProvider/DTO/SatTile.cs new file mode 100644 index 0000000..31f7aa2 --- /dev/null +++ b/SatelliteProvider/DTO/SatTile.cs @@ -0,0 +1,28 @@ +namespace SatelliteProvider.DTO; + +public class SatTile +{ + public int X { get; } + public int Y { get; } + public GeoPoint LeftTop { get; } + public GeoPoint BottomRight { get; } + public string Url { get; set; } + + + public SatTile(int x, int y, int zoom, string url) + { + X = x; + Y = y; + Url = url; + + LeftTop = GeoUtils.TileToWorldPos(x, y, zoom); + BottomRight = GeoUtils.TileToWorldPos(x + 1, y + 1, zoom); + } + + public string FileName => $"tile_lt_{LeftTop.Lat:F6}_{LeftTop.Lon:F6}_br_{BottomRight.Lat:F6}_{BottomRight.Lon:F6}.jpg"; + + public override string ToString() + { + return $"Tile[X={X}, Y={Y}, TL=({LeftTop.Lat:F6}, {LeftTop.Lon:F6}), BR=({BottomRight.Lat:F6}, {BottomRight.Lon:F6})]"; + } +} \ No newline at end of file diff --git a/SatelliteProvider/GeoUtils.cs b/SatelliteProvider/GeoUtils.cs new file mode 100644 index 0000000..4c927dc --- /dev/null +++ b/SatelliteProvider/GeoUtils.cs @@ -0,0 +1,86 @@ +using SatelliteProvider.DTO; + +namespace SatelliteProvider; + +public static class GeoUtils +{ + private const double EARTH_RADIUS = 6378137; + + public static (int x, int y) WorldToTilePos(double lat, double lon, int zoom) + { + var latRad = lat * Math.PI / 180.0; + var n = Math.Pow(2.0, zoom); + var xTile = (int)Math.Floor((lon + 180.0) / 360.0 * n); + var yTile = (int)Math.Floor((1.0 - Math.Log(Math.Tan(latRad) + 1.0 / Math.Cos(latRad)) / Math.PI) / 2.0 * n); + return (xTile, yTile); + } + + public static double ToRadians(double degrees) => degrees * Math.PI / 180.0; + public static double ToDegrees(double radians) => radians * 180.0 / Math.PI; + + public static Direction DirectionTo(this GeoPoint p1, GeoPoint p2) + { + var lat1Rad = ToRadians(p1.Lat); + var lat2Rad = ToRadians(p2.Lat); + var dLon = ToRadians(p2.Lon - p1.Lon); + var dLat = ToRadians(p2.Lat - p1.Lat); + + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1Rad) * Math.Cos(lat2Rad) * + Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + var c = 2 * Math.Asin(Math.Sqrt(a)); + var distance = EARTH_RADIUS * c; + + var y = Math.Sin(dLon) * Math.Cos(lat2Rad); + var x = Math.Cos(lat1Rad) * Math.Sin(lat2Rad) - + Math.Sin(lat1Rad) * Math.Cos(lat2Rad) * Math.Cos(dLon); + var azimuthRadians = Math.Atan2(y, x); + var azimuth = (ToDegrees(azimuthRadians) + 360) % 360; + + return new Direction + { + Distance = distance, + Azimuth = azimuth + }; + } + + public static GeoPoint GoDirection(this GeoPoint startPoint, Direction direction) + { + var angularDistance = direction.Distance / EARTH_RADIUS; + var azimuthRadians = ToRadians(direction.Azimuth); + var startLatRad = ToRadians(startPoint.Lat); + var startLonRad = ToRadians(startPoint.Lon); + + var destLatRad = Math.Asin(Math.Sin(startLatRad) * Math.Cos(angularDistance) + + Math.Cos(startLatRad) * Math.Sin(angularDistance) * Math.Cos(azimuthRadians)); + + var destLonRad = startLonRad + Math.Atan2(Math.Sin(azimuthRadians) * Math.Sin(angularDistance) * Math.Cos(startLatRad), + Math.Cos(angularDistance) - Math.Sin(startLatRad) * Math.Sin(destLatRad)); + + return new GeoPoint(ToDegrees(destLatRad), ToDegrees(destLonRad)); + } + + public static GeoPoint TileToWorldPos(int x, int y, int zoom) + { + var n = Math.Pow(2.0, zoom); + var lonDeg = x / n * 360.0 - 180.0; + var latRad = Math.Atan(Math.Sinh(Math.PI * (1.0 - 2.0 * y / n))); + var latDeg = latRad * 180.0 / Math.PI; + return new GeoPoint(latDeg, lonDeg); + } + + public static (double minLat, double maxLat, double minLon, double maxLon) GetBoundingBox(GeoPoint centerGeoPoint, double radiusM) + { + var latRad = centerGeoPoint.Lat * Math.PI / 180.0; + + var latDiff = (radiusM / EARTH_RADIUS) * (180.0 / Math.PI); + var minLat = Math.Max(centerGeoPoint.Lat - latDiff, -90.0); + var maxLat = Math.Min(centerGeoPoint.Lat + latDiff, 90.0); + + var lonDiff = (radiusM / (EARTH_RADIUS * Math.Cos(latRad))) * (180.0 / Math.PI); + var minLon = Math.Max(centerGeoPoint.Lon - lonDiff, -180.0); + var maxLon = Math.Min(centerGeoPoint.Lon + lonDiff, 180.0); + + return (minLat, maxLat, minLon, maxLon); + } +} \ No newline at end of file diff --git a/SatelliteProvider/SatelliteDownloader.cs b/SatelliteProvider/SatelliteDownloader.cs new file mode 100644 index 0000000..c98000d --- /dev/null +++ b/SatelliteProvider/SatelliteDownloader.cs @@ -0,0 +1,107 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using SatelliteProvider.Configs; +using SatelliteProvider.DTO; +using SixLabors.ImageSharp; + +namespace SatelliteProvider; + +public interface ISatelliteDownloader +{ + Task GetTiles(GeoPoint geoPoint, double radiusM, int zoomLevel, CancellationToken token = default); +} + +public class SatelliteDownloader(ILogger logger, IOptions mapConfig, IHttpClientFactory httpClientFactory) + : ISatelliteDownloader +{ + private const string TILE_URL_TEMPLATE = "https://mt{0}.google.com/vt/lyrs=s&x={1}&y={2}&z={3}&token={4}"; + private const int NUM_SERVERS = 4; + private readonly string _apiKey = mapConfig.Value.ApiKey; + + private readonly string _satDirectory = Path.Combine(Directory.GetCurrentDirectory(), "maps"); + + + private record SessionResponse(string Session); + + private async Task GetSessionToken() + { + var url = $"https://tile.googleapis.com/v1/createSession?key={_apiKey}"; + using var httpClient = httpClientFactory.CreateClient(); + try + { + var str = JsonConvert.SerializeObject(new { mapType = "satellite" }); + var response = await httpClient.PostAsync(url, new StringContent(str)); + response.EnsureSuccessStatusCode(); + var sessionResponse = await response.Content.ReadFromJsonAsync(); + return sessionResponse?.Session; + } + catch (Exception e) + { + logger.LogError(e, e.Message); + throw; + } + } + + public async Task GetTiles(GeoPoint centerGeoPoint, double radiusM, int zoomLevel, CancellationToken token = default) + { + var (latMin, latMax, lonMin, lonMax) = GeoUtils.GetBoundingBox(centerGeoPoint, radiusM); + + var (xMin, yMin) = GeoUtils.WorldToTilePos(latMax, lonMin, zoomLevel); // Top-left corner + var (xMax, yMax) = GeoUtils.WorldToTilePos(latMin, lonMax, zoomLevel); // Bottom-right corner + + var tilesToDownload = new ConcurrentQueue(); + var server = 0; + var sessionToken = await GetSessionToken(); + + for (var y = yMin; y <= yMax + 1; y++) + for (var x = xMin; x <= xMax + 1; x++) + { + token.ThrowIfCancellationRequested(); + var url = string.Format(TILE_URL_TEMPLATE, server, x, y, zoomLevel, sessionToken); + + tilesToDownload.Enqueue(new SatTile(x, y, zoomLevel, url)); + server = (server + 1) % NUM_SERVERS; + } + + var downloadTasks = new List(); + int downloadedCount = 0; + + + for (int i = 0; i < NUM_SERVERS; i++) + { + downloadTasks.Add(Task.Run(async () => + { + using var httpClient = httpClientFactory.CreateClient(); + + while (tilesToDownload.TryDequeue(out var tileInfo)) + { + if (token.IsCancellationRequested) break; + try + { + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36"); + var response = await httpClient.GetAsync(tileInfo.Url, token); + response.EnsureSuccessStatusCode(); + var tileData = await response.Content.ReadAsByteArrayAsync(token); + using var tileImage = Image.Load(tileData); + await tileImage.SaveAsync(Path.Combine(_satDirectory, tileInfo.FileName), token); + if (tileData.Length > 0) + { + Interlocked.Increment(ref downloadedCount); + } + } + catch (HttpRequestException requestException) + { + logger.LogError(requestException, $"Fail to download tile! Url: {tileInfo.Url}. {requestException.Message}"); + } + catch (Exception e) + { + logger.LogError(e, $"Fail to download tile! {e.Message}"); + } + } + }, token)); + } + + await Task.WhenAll(downloadTasks); + } +} \ No newline at end of file diff --git a/SatelliteProvider/SatelliteProvider.csproj b/SatelliteProvider/SatelliteProvider.csproj index e006786..899a851 100644 --- a/SatelliteProvider/SatelliteProvider.csproj +++ b/SatelliteProvider/SatelliteProvider.csproj @@ -8,6 +8,8 @@ + +