using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Net.Http.Json; using Azaion.Common.DTO; using Azaion.Common.DTO.Config; using Azaion.Common.Extensions; using Azaion.CommonSecurity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace Azaion.Common.Services; public interface ISatelliteDownloader { Task GetTiles(double latitude, double longitude, double radiusM, int zoomLevel, CancellationToken token = default); } public class SatelliteDownloader( ILogger logger, IOptions mapConfig, IOptions directoriesConfig, IHttpClientFactory httpClientFactory) : ISatelliteDownloader { private const int INPUT_TILE_SIZE = 256; 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 const int CROP_WIDTH = 1024; private const int CROP_HEIGHT = 1024; private const int STEP_X = 300; private const int STEP_Y = 300; private const int OUTPUT_TILE_SIZE = 512; private readonly string _apiKey = mapConfig.Value.ApiKey; private readonly string _satDirectory = Path.Combine(SecurityConstants.EXTERNAL_GPS_DENIED_FOLDER, directoriesConfig.Value.GpsSatDirectory); public async Task GetTiles(double centerLat, double centerLon, double radiusM, int zoomLevel, CancellationToken token = default) { //empty Satellite directory if (Directory.Exists(_satDirectory)) Directory.Delete(_satDirectory, true); Directory.CreateDirectory(_satDirectory); var downloadTilesResult = await DownloadTiles(centerLat, centerLon, radiusM, zoomLevel, token); var image = await ComposeTiles(downloadTilesResult.Tiles, token); if (image != null) await SplitToTiles(image, downloadTilesResult, token); } private async Task SplitToTiles(Image image, DownloadTilesResult bounds, CancellationToken token = default) { // Calculate all crop parameters beforehand var cropTasks = new List(); var latRange = bounds.LatMax - bounds.LatMin; // [cite: 13] var lonRange = bounds.LonMax - bounds.LonMin; // [cite: 13] var degreesPerPixelLat = latRange / image.Height; // [cite: 13] var degreesPerPixelLon = lonRange / image.Width; // [cite: 14] int tempRowIndex = 0; for (int top = 0; top <= image.Height - CROP_HEIGHT; top += STEP_Y) // [cite: 15] { int tempColIndex = 0; for (int left = 0; left <= image.Width - CROP_WIDTH; left += STEP_X) // [cite: 16] { // Capture loop variables for the closure int currentTop = top; int currentLeft = left; int rowIndex = tempRowIndex; int colIndex = tempColIndex; cropTasks.Add(() => { token.ThrowIfCancellationRequested(); var cropBox = new Rectangle(currentLeft, currentTop, CROP_WIDTH, CROP_HEIGHT); using var croppedImage = image.Clone(ctx => ctx.Crop(cropBox)); var cropTlLat = bounds.LatMax - (currentTop * degreesPerPixelLat); var cropTlLon = bounds.LonMin + (currentLeft * degreesPerPixelLon); var cropBrLat = cropTlLat - (CROP_HEIGHT * degreesPerPixelLat); var cropBrLon = cropTlLon + (CROP_WIDTH * degreesPerPixelLon); var outputFilename = Path.Combine(_satDirectory, $"map_{rowIndex:D4}_{colIndex:D4}_tl_{cropTlLat:F6}_{cropTlLon:F6}_br_{cropBrLat:F6}_{cropBrLon:F6}.tif" ); using var resizedImage = croppedImage.Clone(ctx => ctx.Resize(OUTPUT_TILE_SIZE, OUTPUT_TILE_SIZE, KnownResamplers.Lanczos3)); resizedImage.SaveAsTiffAsync(outputFilename, token).GetAwaiter().GetResult(); // Use synchronous saving or manage async Tasks properly in parallel context }); tempColIndex++; } tempRowIndex++; } // Execute tasks in parallel await Task.Run(() => Parallel.ForEach(cropTasks, action => action()), token); } private async Task SplitToTiles_OLD(Image image, DownloadTilesResult bounds, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(image); ArgumentNullException.ThrowIfNull(bounds); if (bounds.LatMax <= bounds.LatMin || bounds.LonMax <= bounds.LonMin || image.Width <= 0 || image.Height <= 0) throw new ArgumentException("Invalid coordinate bounds (LatMax <= LatMin or LonMax <= LonMin) or image dimensions (Width/Height <= 0)."); var latRange = bounds.LatMax - bounds.LatMin; var lonRange = bounds.LonMax - bounds.LonMin; var degreesPerPixelLat = latRange / image.Height; var degreesPerPixelLon = lonRange / image.Width; var rowIndex = 0; for (int top = 0; top <= image.Height - CROP_HEIGHT; top += STEP_Y) { token.ThrowIfCancellationRequested(); int colIndex = 0; for (int left = 0; left <= image.Width - CROP_WIDTH; left += STEP_X) { token.ThrowIfCancellationRequested(); var cropBox = new Rectangle(left, top, CROP_WIDTH, CROP_HEIGHT); using (var croppedImage = image.Clone(ctx => ctx.Crop(cropBox))) { var cropTlLat = bounds.LatMax - (top * degreesPerPixelLat); var cropTlLon = bounds.LonMin + (left * degreesPerPixelLon); var cropBrLat = cropTlLat - (CROP_HEIGHT * degreesPerPixelLat); var cropBrLon = cropTlLon + (CROP_WIDTH * degreesPerPixelLon); var outputFilename = Path.Combine(_satDirectory, $"map_{rowIndex:D4}_{colIndex:D4}_tl_{cropTlLat:F6}_{cropTlLon:F6}_br_{cropBrLat:F6}_{cropBrLon:F6}.tif" ); using (var resizedImage = croppedImage.Clone(ctx => ctx.Resize(OUTPUT_TILE_SIZE, OUTPUT_TILE_SIZE, KnownResamplers.Lanczos3))) await resizedImage.SaveAsTiffAsync(outputFilename, token); } colIndex++; } rowIndex++; } } private async Task?> ComposeTiles(ConcurrentDictionary<(int x, int y), byte[]> downloadedTiles, CancellationToken token = default) { if (downloadedTiles.IsEmpty) return null; var xMin = downloadedTiles.Min(t => t.Key.x); var xMax = downloadedTiles.Max(t => t.Key.x); var yMin = downloadedTiles.Min(t => t.Key.y); var yMax = downloadedTiles.Max(t => t.Key.y); var totalWidth = (xMax - xMin + 1) * INPUT_TILE_SIZE; var totalHeight = (yMax - yMin + 1) * INPUT_TILE_SIZE; if (totalWidth <= 0 || totalHeight <= 0) return null; var largeImage = new Image(totalWidth, totalHeight); largeImage.Mutate(ctx => { for (var y = yMin; y <= yMax; y++) { for (var x = xMin; x <= xMax; x++) { if (!downloadedTiles.TryGetValue((x, y), out var tileData)) continue; try { using var tileImage = Image.Load(tileData); var offsetX = (x - xMin) * INPUT_TILE_SIZE; var offsetY = (y - yMin) * INPUT_TILE_SIZE; ctx.DrawImage(tileImage, new Point(offsetX, offsetY), 1f); } catch (Exception) { Console.WriteLine($"Error while loading tile: {tileData}"); } if (token.IsCancellationRequested) return; } } }); // await largeImage.SaveAsync(Path.Combine(_satDirectory, "full_map.tif"), // new TiffEncoder { Compression = TiffCompression.Deflate }, token); return largeImage; } 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; } } private async Task DownloadTiles(double centerLat, double centerLon, double radiusM, int zoomLevel, CancellationToken token = default) { var (latMin, latMax, lonMin, lonMax) = GeoUtils.GetBoundingBox(centerLat, centerLon, 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 downloadedTiles = new ConcurrentDictionary<(int x, int y), byte[]>(); 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); if (tileData?.Length > 0) { downloadedTiles.TryAdd((tileInfo.X, tileInfo.Y), tileData); 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); return new DownloadTilesResult { Tiles = downloadedTiles, LatMin = latMin, LatMax = latMax, LonMin = lonMin, LonMax = lonMax }; } }