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
{