diff --git a/MaddoShared.Tests/DataModelCharacterizationTests.cs b/MaddoShared.Tests/DataModelCharacterizationTests.cs index 423acb5..0a94ae9 100644 --- a/MaddoShared.Tests/DataModelCharacterizationTests.cs +++ b/MaddoShared.Tests/DataModelCharacterizationTests.cs @@ -137,6 +137,24 @@ public class DataModelCharacterizationTests model.IncludeNumberAiThumbnails.ShouldBeTrue(); } + [TestMethod] + public void NumberAiWorkload_DefaultsToThreeAndClampsToFive() + { + var model = CreateModel(); + model.NumberAiWorkloadLevel.ShouldBe(3); + + string? changed = null; + model.PropertyChanged += (_, args) => changed = args.PropertyName; + + model.NumberAiWorkloadLevel = 99; + + changed.ShouldBe(nameof(DataModel.NumberAiWorkloadLevel)); + model.NumberAiWorkloadLevel.ShouldBe(3); + + model.Ai.NumberAiWorkloadLevel = 5; + model.NumberAiWorkloadLevel.ShouldBe(5); + } + [TestMethod] public void CommandLineOperationRunner_DetectsHeadlessRequest() { diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml b/imagecatalog/AvaloniaViews/AiTabView.axaml index 9436e1f..c731f4e 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml @@ -19,6 +19,19 @@ + + + + + + diff --git a/imagecatalog/CommandLineOperationRunner.cs b/imagecatalog/CommandLineOperationRunner.cs index deed5bd..5aafa69 100644 --- a/imagecatalog/CommandLineOperationRunner.cs +++ b/imagecatalog/CommandLineOperationRunner.cs @@ -117,6 +117,11 @@ internal static class CommandLineOperationRunner { model.IncludeNumberAiThumbnails = options.IncludeThumbnails.Value; } + + if (options.NumberAiWorkloadLevel.HasValue) + { + model.NumberAiWorkloadLevel = options.NumberAiWorkloadLevel.Value; + } } private static string NormalizeOperation(string operation) @@ -172,6 +177,10 @@ internal static class CommandLineOperationRunner case "--no-tn": options.IncludeThumbnails = false; break; + case "--workload": + case "--ai-workload": + options.NumberAiWorkloadLevel = int.Parse(ReadValue(args, ref i, arg)); + break; case "--headless": case "--cli": break; @@ -209,6 +218,10 @@ internal static class CommandLineOperationRunner case "--csv": options.CsvPath = value; break; + case "--workload": + case "--ai-workload": + options.NumberAiWorkloadLevel = int.Parse(value); + break; default: throw new ArgumentException($"Unknown argument: {arg}"); } @@ -226,7 +239,7 @@ internal static class CommandLineOperationRunner private static void WriteUsage() { - Console.WriteLine("Usage: ImageCatalog --config --operation [--models ] [--csv ] [--cpu|--gpu] [--include-thumbnails|--no-thumbnails]"); + Console.WriteLine("Usage: ImageCatalog --config --operation [--models ] [--csv ] [--cpu|--gpu] [--include-thumbnails|--no-thumbnails] [--workload <1-5>]"); } private sealed class CommandLineOptions @@ -238,5 +251,6 @@ internal static class CommandLineOperationRunner public string CsvPath { get; set; } = string.Empty; public bool? UseGpu { get; set; } public bool? IncludeThumbnails { get; set; } + public int? NumberAiWorkloadLevel { get; set; } } } \ No newline at end of file diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index 88e509e..dc12d88 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -131,7 +131,7 @@ namespace ImageCatalog_2 } catch (OperationCanceledException) { - // user cancelled + await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = "OCR annullato.").ConfigureAwait(false); } catch (Exception ex) { @@ -141,6 +141,8 @@ namespace ImageCatalog_2 RefreshNumberAiGpuCapabilities(); } + await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = $"Errore OCR: {ex.GetBaseException().Message}").ConfigureAwait(false); + await ShowErrorMessageAsync("Errore AI", ex.GetBaseException().Message).ConfigureAwait(false); } finally @@ -167,9 +169,10 @@ namespace ImageCatalog_2 { PreviewResults.Clear(); AiProgress = 0; + NumberAiStatsSummary = BuildNumberAiIdleSummary(); }).ConfigureAwait(false); - await _aiExtractionService.RunAsync( + var summary = await _aiExtractionService.RunAsync( new AiExtractionRequest { SearchRoot = searchRoot, @@ -177,6 +180,7 @@ namespace ImageCatalog_2 IncludeThumbnails = IncludeNumberAiThumbnails, ModelsFolderPath = ModelsFolderPath, UseGpu = UseNumberAiGpu, + WorkloadLevel = NumberAiWorkloadLevel, CsvOutputPath = CsvOutputPath }, token, @@ -187,7 +191,17 @@ namespace ImageCatalog_2 PreviewResults.Add(result); } }), - progress => InvokeOnUiThreadAsync(() => AiProgress = progress)).ConfigureAwait(false); + progress => InvokeOnUiThreadAsync(() => + { + AiProgress = progress.PercentComplete; + NumberAiStatsSummary = BuildNumberAiProgressSummary(progress); + })).ConfigureAwait(false); + + await InvokeOnUiThreadAsync(() => + { + AiProgress = summary.TotalFiles > 0 ? 100 : 0; + NumberAiStatsSummary = BuildNumberAiCompletionSummary(summary); + }).ConfigureAwait(false); } /// @@ -250,6 +264,20 @@ namespace ImageCatalog_2 set => _ai.IncludeNumberAiThumbnails = value; } + public IReadOnlyList NumberAiWorkloadOptions { get; } = [1, 2, 3, 4, 5]; + + public int NumberAiWorkloadLevel + { + get => _ai.NumberAiWorkloadLevel; + set => _ai.NumberAiWorkloadLevel = NormalizeNumberAiWorkloadLevel(value); + } + + public string NumberAiStatsSummary + { + get => _ai.NumberAiStatsSummary; + private set => _ai.NumberAiStatsSummary = value; + } + public string FaceExecutablePath { get => _ai.FaceExecutablePath; @@ -419,6 +447,27 @@ namespace ImageCatalog_2 set => _ai.AiProgress = value; } + private string BuildNumberAiIdleSummary() + { + var workerCount = ResolveNumberAiWorkerCount(UseNumberAiGpu, NumberAiWorkloadLevel); + return $"In attesa. Carico {NumberAiWorkloadLevel}/5, {workerCount} worker, 0.00 img/s."; + } + + private static string BuildNumberAiProgressSummary(AiExtractionProgressUpdate progress) + { + return $"{progress.ProcessedFiles}/{progress.TotalFiles} immagini, media {progress.AverageImagesPerSecond:F2} img/s, carico {progress.WorkloadLevel}/5, {progress.WorkerCount} worker."; + } + + private static string BuildNumberAiCompletionSummary(AiExtractionRunSummary summary) + { + if (summary.TotalFiles == 0) + { + return "Nessuna immagine trovata per OCR."; + } + + return $"Completato: {summary.ProcessedFiles}/{summary.TotalFiles} immagini, media finale {summary.AverageImagesPerSecond:F2} img/s, errori {summary.FailedFiles}, carico {summary.WorkloadLevel}/5, {summary.WorkerCount} worker."; + } + private List LoadAvailableFonts() { #if WINDOWS @@ -2167,6 +2216,36 @@ namespace ImageCatalog_2 return value is >= 1 and <= 5 ? value : 3; } + private static int NormalizeNumberAiWorkloadLevel(int value) + { + return value is >= 1 and <= 5 ? value : 3; + } + + private static int ResolveNumberAiWorkerCount(bool useGpu, int workloadLevel) + { + var normalized = NormalizeNumberAiWorkloadLevel(workloadLevel); + var maxWorkers = Math.Max(1, Environment.ProcessorCount); + var requestedWorkers = useGpu + ? normalized switch + { + 1 => 1, + 2 => 2, + 3 => 4, + 4 => 6, + _ => 8 + } + : normalized switch + { + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + _ => 5 + }; + + return Math.Min(requestedWorkers, maxWorkers); + } + private static int NormalizeFaceMinSize(int value) { return value > 0 ? value : 35; diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index f5e7111..e9de47f 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -282,6 +282,10 @@ namespace ImageCatalog_2.Models [XmlElement("AI_IncludiThumbnailNumeri")] public bool IncludeNumberAiThumbnails { get; set; } + [JsonPropertyName("NumberAiWorkloadLevel")] + [XmlElement("AI_LivelloCaricoNumeri")] + public int NumberAiWorkloadLevel { get; set; } = 3; + [JsonPropertyName("FaceExecutablePath")] [XmlElement("AI_FaceExecutablePath")] public string FaceExecutablePath { get; set; } = string.Empty; diff --git a/imagecatalog/Services/AiExtractionService.cs b/imagecatalog/Services/AiExtractionService.cs index 0f1a987..d9a63ae 100644 --- a/imagecatalog/Services/AiExtractionService.cs +++ b/imagecatalog/Services/AiExtractionService.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using AIFotoONLUS.Core; using ImageCatalog_2.Models; @@ -20,11 +21,11 @@ public class AiExtractionService : IAiExtractionService _logger = logger; } - public async Task RunAsync( + public async Task RunAsync( AiExtractionRequest request, CancellationToken token, Func onResult, - Func onProgress) + Func onProgress) { var searchOption = request.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; @@ -39,38 +40,114 @@ public class AiExtractionService : IAiExtractionService var extractedResults = new List(); var modelConfiguration = BuildModelConfiguration(request.ModelsFolderPath, request.UseGpu); - - using var engine = new NumberRecognitionEngine(modelConfiguration, _logger); + var workloadLevel = NormalizeWorkloadLevel(request.WorkloadLevel); + var workerCount = ResolveWorkerCount(request.UseGpu, workloadLevel); + var total = imageFiles.Count; + if (total == 0) + { + var emptySummary = new AiExtractionRunSummary(0, 0, 0, 0, workloadLevel, workerCount); + await onProgress(new AiExtractionProgressUpdate(0, 0, 100, 0, workloadLevel, workerCount)).ConfigureAwait(false); + return emptySummary; + } var processed = 0; - var total = imageFiles.Count; var failed = 0; Exception? firstFailure = null; - - foreach (var file in imageFiles) + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var resultChannel = Channel.CreateUnbounded(new UnboundedChannelOptions { - token.ThrowIfCancellationRequested(); + SingleReader = true, + SingleWriter = false + }); + var fileChannel = Channel.CreateBounded(new BoundedChannelOptions(Math.Max(workerCount * 2, 1)) + { + SingleReader = false, + SingleWriter = true, + FullMode = BoundedChannelFullMode.Wait + }); + var failureLock = new object(); + var logLock = new object(); + var lastLoggedElapsed = TimeSpan.Zero; - var extracted = string.Empty; - - try + var reporterTask = Task.Run(async () => + { + await foreach (var result in resultChannel.Reader.ReadAllAsync(token).ConfigureAwait(false)) { - extracted = engine.ProcessImage(file).Text; + extractedResults.Add(result); + await onResult(result).ConfigureAwait(false); + + var currentProcessed = Interlocked.Increment(ref processed); + var averageImagesPerSecond = CalculateAverageImagesPerSecond(currentProcessed, stopwatch.Elapsed); + var percent = currentProcessed * 100.0 / total; + await onProgress(new AiExtractionProgressUpdate(total, currentProcessed, percent, averageImagesPerSecond, workloadLevel, workerCount)).ConfigureAwait(false); + + var shouldLog = false; + lock (logLock) + { + if (currentProcessed == total || stopwatch.Elapsed - lastLoggedElapsed >= TimeSpan.FromSeconds(2)) + { + lastLoggedElapsed = stopwatch.Elapsed; + shouldLog = true; + } + } + + if (shouldLog) + { + _logger.LogInformation( + "Number AI progress: {Processed}/{Total} ({Percent:F1}%), {ImagesPerSecond:F2} img/s avg, workload {WorkloadLevel} ({WorkerCount} workers)", + currentProcessed, + total, + percent, + averageImagesPerSecond, + workloadLevel, + workerCount); + } } - catch (Exception ex) + }, token); + + var workerTasks = Enumerable.Range(0, workerCount) + .Select(_ => Task.Run(async () => { - failed++; - firstFailure ??= ex; - _logger.LogWarning(ex, "Error processing AI OCR for {File}", file); + using var engine = new NumberRecognitionEngine(modelConfiguration, _logger); + await foreach (var file in fileChannel.Reader.ReadAllAsync(token).ConfigureAwait(false)) + { + var extracted = string.Empty; + + try + { + extracted = engine.ProcessImage(file).Text; + } + catch (Exception ex) + { + lock (failureLock) + { + failed++; + firstFailure ??= ex; + } + + _logger.LogWarning(ex, "Error processing AI OCR for {File}", file); + } + + await resultChannel.Writer.WriteAsync(new AiResultItem { Path = file, Text = extracted }, token).ConfigureAwait(false); + } + }, token)) + .ToArray(); + + try + { + foreach (var file in imageFiles) + { + await fileChannel.Writer.WriteAsync(file, token).ConfigureAwait(false); } - var result = new AiResultItem { Path = file, Text = extracted }; - extractedResults.Add(result); - await onResult(result).ConfigureAwait(false); - - processed++; - var percent = total > 0 ? (processed * 100.0 / total) : 100.0; - await onProgress(percent).ConfigureAwait(false); + fileChannel.Writer.TryComplete(); + await Task.WhenAll(workerTasks).ConfigureAwait(false); + } + finally + { + fileChannel.Writer.TryComplete(); + resultChannel.Writer.TryComplete(); + await reporterTask.ConfigureAwait(false); } if (imageFiles.Count > 0 && failed == imageFiles.Count) @@ -78,6 +155,23 @@ public class AiExtractionService : IAiExtractionService throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details.", firstFailure); } + var summary = new AiExtractionRunSummary( + total, + processed, + failed, + CalculateAverageImagesPerSecond(processed, stopwatch.Elapsed), + workloadLevel, + workerCount); + + _logger.LogInformation( + "Number AI completed: {Processed}/{Total} processed, {Failed} failures, {ImagesPerSecond:F2} img/s avg, workload {WorkloadLevel} ({WorkerCount} workers)", + summary.ProcessedFiles, + summary.TotalFiles, + summary.FailedFiles, + summary.AverageImagesPerSecond, + summary.WorkloadLevel, + summary.WorkerCount); + if (!string.IsNullOrWhiteSpace(request.CsvOutputPath)) { try @@ -102,6 +196,43 @@ public class AiExtractionService : IAiExtractionService _logger.LogError(ex, "Failed to write CSV to {CsvOutputPath}", request.CsvOutputPath); } } + + return summary; + } + + private static double CalculateAverageImagesPerSecond(int processed, TimeSpan elapsed) + { + return elapsed.TotalSeconds > 0 ? processed / elapsed.TotalSeconds : 0; + } + + private static int NormalizeWorkloadLevel(int workloadLevel) + { + return Math.Clamp(workloadLevel, 1, 5); + } + + private static int ResolveWorkerCount(bool useGpu, int workloadLevel) + { + var normalized = NormalizeWorkloadLevel(workloadLevel); + var maxWorkers = Math.Max(1, Environment.ProcessorCount); + var requestedWorkers = useGpu + ? normalized switch + { + 1 => 1, + 2 => 2, + 3 => 4, + 4 => 6, + _ => 8 + } + : normalized switch + { + 1 => 1, + 2 => 2, + 3 => 3, + 4 => 4, + _ => 5 + }; + + return Math.Min(requestedWorkers, maxWorkers); } private static ModelConfiguration BuildModelConfiguration(string modelsFolderPath, bool useGpu) diff --git a/imagecatalog/Services/IAiExtractionService.cs b/imagecatalog/Services/IAiExtractionService.cs index 60de781..74e746c 100644 --- a/imagecatalog/Services/IAiExtractionService.cs +++ b/imagecatalog/Services/IAiExtractionService.cs @@ -12,14 +12,31 @@ public sealed class AiExtractionRequest public bool IncludeThumbnails { get; init; } public required string ModelsFolderPath { get; init; } public bool UseGpu { get; init; } + public int WorkloadLevel { get; init; } = 3; public string CsvOutputPath { get; init; } = string.Empty; } +public sealed record AiExtractionProgressUpdate( + int TotalFiles, + int ProcessedFiles, + double PercentComplete, + double AverageImagesPerSecond, + int WorkloadLevel, + int WorkerCount); + +public sealed record AiExtractionRunSummary( + int TotalFiles, + int ProcessedFiles, + int FailedFiles, + double AverageImagesPerSecond, + int WorkloadLevel, + int WorkerCount); + public interface IAiExtractionService { - Task RunAsync( + Task RunAsync( AiExtractionRequest request, CancellationToken token, Func onResult, - Func onProgress); + Func onProgress); } diff --git a/imagecatalog/ViewModels/AiSettingsViewModel.cs b/imagecatalog/ViewModels/AiSettingsViewModel.cs index 9f0fecb..501cf5e 100644 --- a/imagecatalog/ViewModels/AiSettingsViewModel.cs +++ b/imagecatalog/ViewModels/AiSettingsViewModel.cs @@ -71,6 +71,28 @@ public class AiSettingsViewModel : ViewModelBase } } + private int _numberAiWorkloadLevel = 3; + public int NumberAiWorkloadLevel + { + get => _numberAiWorkloadLevel; + set + { + _numberAiWorkloadLevel = value; + NotifyPropertyChanged(); + } + } + + private string _numberAiStatsSummary = string.Empty; + public string NumberAiStatsSummary + { + get => _numberAiStatsSummary; + set + { + _numberAiStatsSummary = value; + NotifyPropertyChanged(); + } + } + private string _faceExecutablePath = string.Empty; public string FaceExecutablePath {