using System.Collections.Concurrent; using System.Drawing; using System.Drawing.Drawing2D; using System.Drawing.Imaging; using System.IO; using Azaion.Annotator.Extensions; using Azaion.Common.Database; using Azaion.Common.DTO; using Azaion.Common.DTO.Config; using Azaion.Common.DTO.Queue; using Azaion.Common.Extensions; using LinqToDB; using LinqToDB.Data; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Color = System.Drawing.Color; using ParallelOptions = Azaion.Annotator.Extensions.ParallelOptions; using Size = System.Windows.Size; namespace Azaion.Common.Services; public delegate void ThumbnailsUpdatedEventHandler(double thumbnailsPercentage); public class GalleryService( IOptions directoriesConfig, IOptions thumbnailConfig, IOptions annotationConfig, ILogger logger, IDbFactory dbFactory) : IGalleryService { private readonly DirectoriesConfig _dirConfig = directoriesConfig.Value; private readonly ThumbnailConfig _thumbnailConfig = thumbnailConfig.Value; private readonly AnnotationConfig _annotationConfig = annotationConfig.Value; public event ThumbnailsUpdatedEventHandler? ThumbnailsUpdate; private readonly SemaphoreSlim _updateLock = new(1); public double ProcessedThumbnailsPercentage { get; set; } private DirectoryInfo? _thumbnailsDirectory; private DirectoryInfo ThumbnailsDirectory { get { if (_thumbnailsDirectory != null) return _thumbnailsDirectory; var dir = new DirectoryInfo(_dirConfig.ThumbnailsDirectory); if (!dir.Exists) Directory.CreateDirectory(_dirConfig.ThumbnailsDirectory); _thumbnailsDirectory = new DirectoryInfo(_dirConfig.ThumbnailsDirectory); return _thumbnailsDirectory; } } public async Task ClearThumbnails(CancellationToken cancellationToken = default) { foreach(var file in new DirectoryInfo(_dirConfig.ThumbnailsDirectory).GetFiles()) file.Delete(); await dbFactory.RunWrite(async db => { await db.Detections.DeleteAsync(x => true, token: cancellationToken); await db.Annotations.DeleteAsync(x => true, token: cancellationToken); }); } public async Task RefreshThumbnails() { await _updateLock.WaitAsync(); var existingAnnotations = new ConcurrentDictionary(await dbFactory.Run(async db => await db.Annotations.ToDictionaryAsync(x => x.Name))); var missedAnnotations = new ConcurrentDictionary(); try { var prefixLen = Constants.THUMBNAIL_PREFIX.Length; var thumbnails = ThumbnailsDirectory.GetFiles() .Select(x => Path.GetFileNameWithoutExtension(x.Name)[..^prefixLen]) .GroupBy(x => x) .Select(gr => gr.Key) .ToHashSet(); var files = new DirectoryInfo(_dirConfig.ImagesDirectory).GetFiles(); var imagesCount = files.Length; await ParallelExt.ForEachAsync(files, async (file, cancellationToken) => { var fName = file.Name.ToFName(); try { var labelName = Path.Combine(_dirConfig.LabelsDirectory, $"{fName}.txt"); if (!File.Exists(labelName)) { File.Delete(file.FullName); logger.LogInformation($"No labels found for image {file.FullName}! Image deleted!"); await dbFactory.DeleteAnnotations([fName], cancellationToken); return; } //Read labels file only when it needed if (existingAnnotations.ContainsKey(fName) && thumbnails.Contains(fName)) return; var detections = (await YoloLabel.ReadFromFile(labelName, cancellationToken)).Select(x => new Detection(fName, x)).ToList(); //get names and time var fileName = Path.GetFileNameWithoutExtension(file.Name); var strings = fileName.Split("_"); var timeStr = strings.LastOrDefault(); string originalMediaName; TimeSpan time; //For some reason, TimeSpan.ParseExact doesn't work on every platform. if (!string.IsNullOrEmpty(timeStr) && timeStr.Length == 6 && int.TryParse(timeStr[..1], out var hours) && int.TryParse(timeStr[1..3], out var minutes) && int.TryParse(timeStr[3..5], out var seconds) && int.TryParse(timeStr[5..], out var milliseconds)) { time = new TimeSpan(0, hours, minutes, seconds, milliseconds * 100); originalMediaName = fileName[..^7]; } else { originalMediaName = fileName; time = TimeSpan.FromSeconds(0); } var annotation = new Annotation { Time = time, OriginalMediaName = originalMediaName, Name = fName, ImageExtension = Path.GetExtension(file.Name), Detections = detections, CreatedDate = File.GetCreationTimeUtc(file.FullName), Source = SourceEnum.Manual, CreatedRole = RoleEnum.Validator, CreatedEmail = Constants.ADMIN_EMAIL, AnnotationStatus = AnnotationStatus.Validated }; //Remove duplicates if (!existingAnnotations.ContainsKey(fName)) { if (missedAnnotations.ContainsKey(fName)) logger.LogInformation($"{fName} is already exists! Duplicate!"); else missedAnnotations.TryAdd(fName, annotation); } if (!thumbnails.Contains(fName)) await CreateThumbnail(annotation, cancellationToken: cancellationToken); } catch (Exception e) { logger.LogError(e, $"Failed to generate thumbnail for {file.Name}! Error: {e.Message}"); } }, new ParallelOptions { ProgressFn = async num => { logger.LogInformation($"Processed {num} item by Thread {Environment.CurrentManagedThreadId}"); ProcessedThumbnailsPercentage = imagesCount == 0 ? 0 : Math.Min(100, num * 100 / (double)imagesCount); ThumbnailsUpdate?.Invoke(ProcessedThumbnailsPercentage); await Task.CompletedTask; }, CpuUtilPercent = 100, ProgressUpdateInterval = 200 }); } catch (Exception e) { logger.LogError(e, $"Failed to refresh thumbnails! Error: {e.Message}"); } finally { var copyOptions = new BulkCopyOptions { MaxBatchSize = 50 }; //Db could be updated during the long files scraping existingAnnotations = new ConcurrentDictionary(await dbFactory.Run(async db => await db.Annotations.ToDictionaryAsync(x => x.Name))); var insertedDuplicates = missedAnnotations.Where(x => existingAnnotations.ContainsKey(x.Key)).ToList(); var annotationsToInsert = missedAnnotations .Where(a => !existingAnnotations.ContainsKey(a.Key)) .Select(x => x.Value) .ToList(); await dbFactory.RunWrite(async db => { await db.BulkCopyAsync(copyOptions, annotationsToInsert); await db.BulkCopyAsync(copyOptions, annotationsToInsert.SelectMany(x => x.Detections)); }); _updateLock.Release(); } } public async Task CreateThumbnail(Annotation annotation, Image? originalImage = null, CancellationToken cancellationToken = default) { try { var width = (int)_thumbnailConfig.Size.Width; var height = (int)_thumbnailConfig.Size.Height; originalImage ??= Image.FromStream(new MemoryStream(await File.ReadAllBytesAsync(annotation.ImagePath, cancellationToken))); var bitmap = new Bitmap(width, height); using var g = Graphics.FromImage(bitmap); g.CompositingQuality = CompositingQuality.HighSpeed; g.SmoothingMode = SmoothingMode.HighSpeed; g.InterpolationMode = InterpolationMode.Default; var size = new Size(originalImage.Width, originalImage.Height); var thumbWhRatio = width / (float)height; var border = _thumbnailConfig.Border; var frameX = 0.0; var frameY = 0.0; var frameHeight = size.Height; var frameWidth = size.Width; var labels = annotation.Detections .Select(x => new CanvasLabel(x, size)) .ToList(); if (annotation.Detections.Any()) { var labelsMinX = labels.Min(x => x.X); var labelsMaxX = labels.Max(x => x.X + x.Width); var labelsMinY = labels.Min(x => x.Y); var labelsMaxY = labels.Max(x => x.Y + x.Height); var labelsHeight = labelsMaxY - labelsMinY + 2 * border; var labelsWidth = labelsMaxX - labelsMinX + 2 * border; if (labelsWidth / labelsHeight > thumbWhRatio) { frameWidth = labelsWidth; frameHeight = Math.Min(labelsWidth / thumbWhRatio, size.Height); frameX = Math.Max(0, labelsMinX - border); frameY = Math.Max(0, 0.5 * (labelsMinY + labelsMaxY - frameHeight) - border); } else { frameHeight = labelsHeight; frameWidth = Math.Min(labelsHeight * thumbWhRatio, size.Width); frameY = Math.Max(0, labelsMinY - border); frameX = Math.Max(0, 0.5 * (labelsMinX + labelsMaxX - frameWidth) - border); } } var scale = frameHeight / height; g.DrawImage(originalImage, new Rectangle(0, 0, width, height), new RectangleF((float)frameX, (float)frameY, (float)frameWidth, (float)frameHeight), GraphicsUnit.Pixel); foreach (var label in labels) { var color = _annotationConfig.DetectionClassesDict[label.ClassNumber].Color; var brush = new SolidBrush(Color.FromArgb(color.A, color.R, color.G, color.B)); g.DrawRectangle(new Pen(brush, width: 3), (float)((label.X - frameX) / scale), (float)((label.Y - frameY) / scale), (float)(label.Width / scale), (float)(label.Height / scale)); } bitmap.Save(annotation.ThumbPath, ImageFormat.Jpeg); } catch (Exception e) { logger.LogError(e, e.Message); } } public async Task CreateAnnotatedImage(Annotation annotation, Image? originalImage = null, CancellationToken token = default) { originalImage ??= Image.FromStream(new MemoryStream(await File.ReadAllBytesAsync(annotation.ImagePath, token))); using var g = Graphics.FromImage(originalImage); foreach (var detection in annotation.Detections) { var detClass = _annotationConfig.DetectionClassesDict[detection.ClassNumber]; var color = detClass.Color; var brush = new SolidBrush(Color.FromArgb(color.A, color.R, color.G, color.B)); var det = new CanvasLabel(detection, new Size(originalImage.Width, originalImage.Height)); g.DrawRectangle(new Pen(brush, width: 3), (float)det.X, (float)det.Y, (float)det.Width, (float)det.Height); var label = detection.Confidence >= 0.995 ? detClass.UIName : $"{detClass.UIName}: {detection.Confidence * 100:F0}%"; g.DrawTextBox(label, new PointF((float)(det.X + det.Width / 2.0), (float)(det.Y - 24)), brush, Brushes.Black); } var imagePath = Path.Combine(_dirConfig.ResultsDirectory, $"{annotation.Name}{Constants.RESULT_PREFIX}.jpg"); if (File.Exists(imagePath)) ResilienceExt.WithRetry(() => File.Delete(imagePath)); originalImage.Save(imagePath, ImageFormat.Jpeg); } } public interface IGalleryService { event ThumbnailsUpdatedEventHandler? ThumbnailsUpdate; Task CreateThumbnail(Annotation annotation, Image? originalImage = null, CancellationToken cancellationToken = default); Task RefreshThumbnails(); Task ClearThumbnails(CancellationToken cancellationToken = default); Task CreateAnnotatedImage(Annotation annotation, Image? originalImage = null, CancellationToken token = default); }