using Microsoft.Extensions.Options; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Services.TileDownloader; public interface IUavTileQualityGate { UavTileQualityResult Validate(ReadOnlyMemory imageBytes, string? contentType, UavTileMetadata metadata); } public sealed record UavTileQualityResult(bool Accepted, string? Reason, string? Details) { public static UavTileQualityResult Pass() => new(true, null, null); public static UavTileQualityResult Fail(string reason, string? details = null) => new(false, reason, details); } public sealed class UavTileQualityGate : IUavTileQualityGate { private static readonly byte[] JpegMagicBytes = { 0xFF, 0xD8, 0xFF }; private readonly UavQualityConfig _qualityConfig; private readonly MapConfig _mapConfig; private readonly TimeProvider _timeProvider; public UavTileQualityGate( IOptions qualityConfig, IOptions mapConfig, TimeProvider? timeProvider = null) { ArgumentNullException.ThrowIfNull(qualityConfig); ArgumentNullException.ThrowIfNull(mapConfig); _qualityConfig = qualityConfig.Value; _mapConfig = mapConfig.Value; _timeProvider = timeProvider ?? TimeProvider.System; } public UavTileQualityResult Validate(ReadOnlyMemory imageBytes, string? contentType, UavTileMetadata metadata) { ArgumentNullException.ThrowIfNull(metadata); // Rule 1 (Format): content-type AND magic bytes both indicate JPEG. if (!HasJpegContentType(contentType) || !HasJpegMagicBytes(imageBytes.Span)) { return UavTileQualityResult.Fail(UavTileRejectReasons.InvalidFormat); } // Rule 2 (Size band): configurable lower/upper bounds. if (imageBytes.Length < _qualityConfig.MinBytes || imageBytes.Length > _qualityConfig.MaxBytes) { return UavTileQualityResult.Fail(UavTileRejectReasons.SizeOutOfBand); } // Rule 3 (Dimensions): strict equality with MapConfig.TileSizePixels. // ImageSharp.Image.Identify reads only the JPEG header — no full decode cost. ImageInfo? info; try { using var identifyStream = new ReadOnlyMemoryStream(imageBytes); info = Image.Identify(identifyStream); } catch (UnknownImageFormatException) { return UavTileQualityResult.Fail(UavTileRejectReasons.InvalidFormat); } catch (InvalidImageContentException) { return UavTileQualityResult.Fail(UavTileRejectReasons.InvalidFormat); } if (info is null || info.Width != _mapConfig.TileSizePixels || info.Height != _mapConfig.TileSizePixels) { return UavTileQualityResult.Fail(UavTileRejectReasons.WrongDimensions); } // Rule 4 (Captured-at age): forbid future timestamps beyond clock skew and // reject anything older than the configured max age. var now = _timeProvider.GetUtcNow().UtcDateTime; var capturedAt = metadata.CapturedAt.Kind == DateTimeKind.Utc ? metadata.CapturedAt : metadata.CapturedAt.ToUniversalTime(); if (capturedAt > now.AddSeconds(_qualityConfig.CapturedAtFutureSkewSeconds)) { return UavTileQualityResult.Fail(UavTileRejectReasons.CapturedAtFuture); } if (capturedAt < now.AddDays(-_qualityConfig.MaxAgeDays)) { return UavTileQualityResult.Fail(UavTileRejectReasons.CapturedAtTooOld); } // Rule 5 (Blank/uniform): pixel-luminance variance on a downsampled image. // Runs last because it requires the full decode pass. try { var variance = ComputeLuminanceVariance(imageBytes); if (variance < _qualityConfig.MinLuminanceVariance) { return UavTileQualityResult.Fail(UavTileRejectReasons.ImageTooUniform); } } catch (UnknownImageFormatException) { return UavTileQualityResult.Fail(UavTileRejectReasons.InvalidFormat); } catch (InvalidImageContentException) { return UavTileQualityResult.Fail(UavTileRejectReasons.InvalidFormat); } return UavTileQualityResult.Pass(); } private static bool HasJpegContentType(string? contentType) { if (string.IsNullOrWhiteSpace(contentType)) { return false; } // Allow trailing parameters such as "image/jpeg; charset=binary". var separator = contentType.IndexOf(';'); var mediaType = separator >= 0 ? contentType[..separator].Trim() : contentType.Trim(); return string.Equals(mediaType, "image/jpeg", StringComparison.OrdinalIgnoreCase); } private static bool HasJpegMagicBytes(ReadOnlySpan bytes) { if (bytes.Length < JpegMagicBytes.Length) { return false; } for (var i = 0; i < JpegMagicBytes.Length; i++) { if (bytes[i] != JpegMagicBytes[i]) { return false; } } return true; } private double ComputeLuminanceVariance(ReadOnlyMemory imageBytes) { using var stream = new ReadOnlyMemoryStream(imageBytes); using var image = Image.Load(stream); var sampleSize = Math.Clamp(_qualityConfig.LuminanceSampleSize, 4, _mapConfig.TileSizePixels); if (image.Width != sampleSize || image.Height != sampleSize) { image.Mutate(ctx => ctx.Resize(sampleSize, sampleSize)); } double mean = 0.0; double m2 = 0.0; long n = 0; image.ProcessPixelRows(accessor => { for (var y = 0; y < accessor.Height; y++) { var row = accessor.GetRowSpan(y); for (var x = 0; x < row.Length; x++) { n++; double value = row[x].PackedValue; var delta = value - mean; mean += delta / n; m2 += delta * (value - mean); } } }); return n > 1 ? m2 / (n - 1) : 0.0; } } internal sealed class ReadOnlyMemoryStream : Stream { private readonly ReadOnlyMemory _memory; private int _position; public ReadOnlyMemoryStream(ReadOnlyMemory memory) { _memory = memory; } public override bool CanRead => true; public override bool CanSeek => true; public override bool CanWrite => false; public override long Length => _memory.Length; public override long Position { get => _position; set => _position = checked((int)value); } public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) { var span = _memory.Span; var remaining = span.Length - _position; if (remaining <= 0) { return 0; } var toCopy = Math.Min(remaining, count); span.Slice(_position, toCopy).CopyTo(buffer.AsSpan(offset, toCopy)); _position += toCopy; return toCopy; } public override long Seek(long offset, SeekOrigin origin) { _position = origin switch { SeekOrigin.Begin => checked((int)offset), SeekOrigin.Current => checked(_position + (int)offset), SeekOrigin.End => checked(_memory.Length + (int)offset), _ => throw new ArgumentOutOfRangeException(nameof(origin)), }; return _position; } public override void SetLength(long value) => throw new NotSupportedException(); public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); }