From cb41c42bb5149164b6660310654b4c90ca1938dc Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 9 May 2026 17:27:05 +0200 Subject: [PATCH 1/2] AI Pettorali --- .../DataModelCharacterizationTests.cs | 21 ++ imagecatalog/AvaloniaViews/AiTabView.axaml | 12 + imagecatalog/AvaloniaViews/AiTabView.axaml.cs | 8 + imagecatalog/CommandLineOperationRunner.cs | 228 ++++++++++++++++++ imagecatalog/DataModel.cs | 32 ++- imagecatalog/ImageCatalog 2.csproj | 5 +- imagecatalog/Models/SettingsDto.cs | 4 + imagecatalog/Program.cs | 22 +- imagecatalog/Services/AiExtractionService.cs | 91 ++++--- imagecatalog/Services/IAiExtractionService.cs | 2 + .../ViewModels/AiSettingsViewModel.cs | 11 + 11 files changed, 380 insertions(+), 56 deletions(-) create mode 100644 imagecatalog/CommandLineOperationRunner.cs diff --git a/MaddoShared.Tests/DataModelCharacterizationTests.cs b/MaddoShared.Tests/DataModelCharacterizationTests.cs index cba6ce0..1a8955d 100644 --- a/MaddoShared.Tests/DataModelCharacterizationTests.cs +++ b/MaddoShared.Tests/DataModelCharacterizationTests.cs @@ -109,6 +109,27 @@ public class DataModelCharacterizationTests model.ModelsFolderPath.ShouldBe("K:/models"); } + [TestMethod] + public void NumberAiGpuChildChange_RaisesDataModelPropertyChanged() + { + var model = CreateModel(); + string? changed = null; + model.PropertyChanged += (_, args) => changed = args.PropertyName; + + model.Ai.UseNumberAiGpu = true; + + changed.ShouldBe(nameof(DataModel.UseNumberAiGpu)); + model.UseNumberAiGpu.ShouldBeTrue(); + } + + [TestMethod] + public void CommandLineOperationRunner_DetectsHeadlessRequest() + { + CommandLineOperationRunner.IsHeadlessRequest(["--config", "settings.xml", "--operation", "number-ai"]).ShouldBeTrue(); + CommandLineOperationRunner.IsHeadlessRequest(["--config=settings.xml", "--operation=number-ai"]).ShouldBeTrue(); + CommandLineOperationRunner.IsHeadlessRequest([]).ShouldBeFalse(); + } + [TestMethod] public void RaceUploadChildChange_RaisesDataModelPropertyChanged() { diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml b/imagecatalog/AvaloniaViews/AiTabView.axaml index 09abb0b..34d62cd 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml @@ -13,6 +13,18 @@ + + + + + + + diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs index 1873691..f89ded7 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs @@ -31,6 +31,14 @@ public partial class AiTabView : Avalonia.Controls.UserControl OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? model.CsvOutputPath : directory); } + private void OpenAiDestinationFolder_Click(object? sender, RoutedEventArgs e) + { + if (DataContext is DataModel model) + { + OpenInExplorer(model.DestinationPath); + } + } + private static void OpenInExplorer(string? path) { if (string.IsNullOrWhiteSpace(path)) diff --git a/imagecatalog/CommandLineOperationRunner.cs b/imagecatalog/CommandLineOperationRunner.cs new file mode 100644 index 0000000..0765a22 --- /dev/null +++ b/imagecatalog/CommandLineOperationRunner.cs @@ -0,0 +1,228 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ImageCatalog_2; + +internal static class CommandLineOperationRunner +{ + private static readonly HashSet HeadlessMarkers = new(StringComparer.OrdinalIgnoreCase) + { + "--operation", + "--op", + "--config", + "--headless", + "--cli" + }; + + public static bool IsHeadlessRequest(string[]? args) + { + return args?.Any(arg => HeadlessMarkers.Contains(arg) || arg.StartsWith("--operation=", StringComparison.OrdinalIgnoreCase) || arg.StartsWith("--config=", StringComparison.OrdinalIgnoreCase)) == true; + } + + public static async Task RunAsync(IServiceProvider services, string[] args) + { + var logger = services.GetRequiredService().CreateLogger("CommandLine"); + + try + { + var options = Parse(args); + if (options.ShowHelp) + { + WriteUsage(); + return 0; + } + + if (string.IsNullOrWhiteSpace(options.ConfigPath)) + { + throw new ArgumentException("Missing required --config argument."); + } + + if (string.IsNullOrWhiteSpace(options.Operation)) + { + throw new ArgumentException("Missing required --operation argument."); + } + + if (!File.Exists(options.ConfigPath)) + { + throw new FileNotFoundException("Configuration file not found.", options.ConfigPath); + } + + var model = services.GetRequiredService(); + await model.LoadSettingsFromFileAsync(options.ConfigPath).ConfigureAwait(false); + ApplyOverrides(model, options); + + using var cancellationTokenSource = new CancellationTokenSource(); + Console.CancelKeyPress += (_, eventArgs) => + { + eventArgs.Cancel = true; + cancellationTokenSource.Cancel(); + }; + + logger.LogInformation("Running ImageCatalog operation {Operation} with config {ConfigPath}", options.Operation, options.ConfigPath); + + switch (NormalizeOperation(options.Operation)) + { + case "image-processing": + await model.ProcessImages().ConfigureAwait(false); + break; + case "number-ai": + await model.RunNumberAiAsync(cancellationTokenSource.Token).ConfigureAwait(false); + break; + case "face-ai": + await model.RunFaceAiAsync(cancellationTokenSource.Token).ConfigureAwait(false); + break; + case "race-upload": + throw new NotSupportedException("race-upload is not available in headless mode yet because the upload workflow still lives in the Avalonia view layer."); + default: + throw new ArgumentException($"Unknown operation: {options.Operation}"); + } + + logger.LogInformation("ImageCatalog operation {Operation} completed", options.Operation); + return 0; + } + catch (OperationCanceledException) + { + logger.LogWarning("Command-line operation canceled."); + return 130; + } + catch (Exception ex) + { + logger.LogError(ex, "Command-line operation failed."); + return 1; + } + } + + private static void ApplyOverrides(DataModel model, CommandLineOptions options) + { + if (!string.IsNullOrWhiteSpace(options.ModelsPath)) + { + model.ModelsFolderPath = options.ModelsPath; + } + + if (!string.IsNullOrWhiteSpace(options.CsvPath)) + { + model.CsvOutputPath = options.CsvPath; + } + + if (options.UseGpu.HasValue) + { + model.UseNumberAiGpu = options.UseGpu.Value; + if (model.FaceGpuOptionEnabled || !options.UseGpu.Value) + { + model.UseFaceGpu = options.UseGpu.Value; + } + } + } + + private static string NormalizeOperation(string operation) + { + return operation.Trim().ToLowerInvariant() switch + { + "images" or "image" or "process-images" or "image-processing" => "image-processing", + "ai" or "ocr" or "number" or "number-ai" => "number-ai", + "face" or "face-ai" => "face-ai", + "race" or "race-upload" => "race-upload", + var normalized => normalized + }; + } + + private static CommandLineOptions Parse(string[] args) + { + var options = new CommandLineOptions(); + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + switch (arg.ToLowerInvariant()) + { + case "--help": + case "-h": + case "/?": + options.ShowHelp = true; + break; + case "--operation": + case "--op": + options.Operation = ReadValue(args, ref i, arg); + break; + case "--config": + options.ConfigPath = ReadValue(args, ref i, arg); + break; + case "--models": + options.ModelsPath = ReadValue(args, ref i, arg); + break; + case "--csv": + options.CsvPath = ReadValue(args, ref i, arg); + break; + case "--gpu": + options.UseGpu = true; + break; + case "--cpu": + options.UseGpu = false; + break; + case "--headless": + case "--cli": + break; + default: + ApplyInlineArgument(options, arg); + break; + } + } + + return options; + } + + private static void ApplyInlineArgument(CommandLineOptions options, string arg) + { + var separatorIndex = arg.IndexOf('='); + if (separatorIndex < 0) + { + throw new ArgumentException($"Unknown argument: {arg}"); + } + + var name = arg[..separatorIndex].ToLowerInvariant(); + var value = arg[(separatorIndex + 1)..]; + switch (name) + { + case "--operation": + case "--op": + options.Operation = value; + break; + case "--config": + options.ConfigPath = value; + break; + case "--models": + options.ModelsPath = value; + break; + case "--csv": + options.CsvPath = value; + break; + default: + throw new ArgumentException($"Unknown argument: {arg}"); + } + } + + private static string ReadValue(string[] args, ref int index, string name) + { + if (index + 1 >= args.Length) + { + throw new ArgumentException($"Missing value for {name}."); + } + + return args[++index]; + } + + private static void WriteUsage() + { + Console.WriteLine("Usage: ImageCatalog --config --operation [--models ] [--csv ] [--cpu|--gpu]"); + } + + private sealed class CommandLineOptions + { + public bool ShowHelp { get; set; } + public string Operation { get; set; } = string.Empty; + public string ConfigPath { get; set; } = string.Empty; + public string ModelsPath { get; set; } = string.Empty; + public string CsvPath { get; set; } = string.Empty; + public bool? UseGpu { get; set; } + } +} \ No newline at end of file diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index 25e7041..95bef1b 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -137,12 +137,17 @@ namespace ImageCatalog_2 } } - private async Task RunAiExtractionCoreAsync(CancellationToken token, bool useDestination = false, bool recursive = false) + private async Task RunAiExtractionCoreAsync(CancellationToken token, bool useDestination = false, bool recursive = false, bool failOnInvalidPath = false) { var searchRoot = useDestination ? DestinationPath : SourcePath; if (string.IsNullOrWhiteSpace(searchRoot) || !System.IO.Directory.Exists(searchRoot)) { _logger.LogWarning("AI extraction path invalid: {Path}", searchRoot); + if (failOnInvalidPath) + { + throw new DirectoryNotFoundException($"AI extraction path invalid: {searchRoot}"); + } + return; } @@ -157,6 +162,8 @@ namespace ImageCatalog_2 { SearchRoot = searchRoot, Recursive = recursive, + ModelsFolderPath = ModelsFolderPath, + UseGpu = UseNumberAiGpu, CsvOutputPath = CsvOutputPath }, token, @@ -202,6 +209,12 @@ namespace ImageCatalog_2 set => _ai.CsvOutputPath = value; } + public bool UseNumberAiGpu + { + get => _ai.UseNumberAiGpu; + set => _ai.UseNumberAiGpu = value; + } + public string FaceExecutablePath { get => _ai.FaceExecutablePath; @@ -1223,7 +1236,7 @@ namespace ImageCatalog_2 Debug.WriteLine("Yep c"); } - private async Task ProcessImages() + public async Task ProcessImages() { _logger.LogInformation("Avvio elaborazione..."); UiEnabled = false; @@ -1289,12 +1302,12 @@ namespace ImageCatalog_2 }, speed => { SpeedCounter = speed; }).ConfigureAwait(false); - // AI integration stub: if ExtractNumbers is enabled, simulate or invoke OCR processing + // AI integration: OCR runs over processed output so it matches the face AI input folder. if (ExtractNumbers) { try { - await RunAiExtractionCoreAsync(token); + await RunAiExtractionCoreAsync(token, useDestination: true, recursive: true); } catch (OperationCanceledException) { @@ -1327,6 +1340,17 @@ namespace ImageCatalog_2 UiEnabled = true; } + public async Task RunNumberAiAsync(CancellationToken token) + { + await RunAiExtractionCoreAsync(token, useDestination: true, recursive: true, failOnInvalidPath: true).ConfigureAwait(false); + } + + public async Task RunFaceAiAsync(CancellationToken token) + { + using var registration = token.Register(() => _ = StopFaceEncoderAsync("Arresto face encoder richiesto dalla CLI.", waitForExit: true)); + await RunFaceEncoderAsync().ConfigureAwait(false); + } + private async Task CancelOperation() { try diff --git a/imagecatalog/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj index 2749766..7c33f76 100644 --- a/imagecatalog/ImageCatalog 2.csproj +++ b/imagecatalog/ImageCatalog 2.csproj @@ -7,6 +7,8 @@ ImageCatalog default false + true + false @@ -56,9 +58,10 @@ + - + diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index 4891981..200beb9 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -274,6 +274,10 @@ namespace ImageCatalog_2.Models [XmlElement("AI_PercorsoCsv")] public string CsvOutputPath { get; set; } + [JsonPropertyName("UseNumberAiGpu")] + [XmlElement("AI_UsaGpuNumeri")] + public bool UseNumberAiGpu { get; set; } + [JsonPropertyName("FaceExecutablePath")] [XmlElement("AI_FaceExecutablePath")] public string FaceExecutablePath { get; set; } = string.Empty; diff --git a/imagecatalog/Program.cs b/imagecatalog/Program.cs index fb38a24..2a350bd 100644 --- a/imagecatalog/Program.cs +++ b/imagecatalog/Program.cs @@ -38,6 +38,8 @@ static class Program [DllImport("kernel32.dll", SetLastError = true)] static extern bool AttachConsole(int dwProcessId); + private const int ATTACH_PARENT_PROCESS = -1; + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); @@ -85,10 +87,18 @@ static class Program .LogToTrace(); [STAThread] - static void Main(string[] args) + static int Main(string[] args) { #if WINDOWS - AllocConsole(); + if (CommandLineOperationRunner.IsHeadlessRequest(args)) + { + AttachConsole(ATTACH_PARENT_PROCESS); + } + else + { + AllocConsole(); + } + RedirectConsoleOutput(); #endif @@ -97,7 +107,15 @@ static class Program ServiceProvider = serviceCollection.BuildServiceProvider(); + if (CommandLineOperationRunner.IsHeadlessRequest(args)) + { + return CommandLineOperationRunner.RunAsync(ServiceProvider, args ?? Array.Empty()) + .GetAwaiter() + .GetResult(); + } + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args ?? Array.Empty()); + return 0; } private static void ConfigureServices(ServiceCollection services) diff --git a/imagecatalog/Services/AiExtractionService.cs b/imagecatalog/Services/AiExtractionService.cs index 2051581..a96254a 100644 --- a/imagecatalog/Services/AiExtractionService.cs +++ b/imagecatalog/Services/AiExtractionService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using AIFotoONLUS.Core; using ImageCatalog_2.Models; using Microsoft.Extensions.Logging; @@ -35,36 +36,14 @@ public class AiExtractionService : IAiExtractionService || f.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) .ToList(); - if (imageFiles.Count == 0) - { - return; - } - var extractedResults = new List(); + var modelConfiguration = BuildModelConfiguration(request.ModelsFolderPath, request.UseGpu); - Type? aiProcessorType = null; - object? aiProcessor = null; - - try - { - var assembly = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => a.GetName().Name?.Equals("AIFotoONLUS.Core", StringComparison.OrdinalIgnoreCase) == true); - if (assembly != null) - { - aiProcessorType = assembly.GetType("AIFotoONLUS.Core.AiProcessor"); - if (aiProcessorType != null) - { - aiProcessor = Activator.CreateInstance(aiProcessorType); - } - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "AIFotoONLUS.Core not available or failed to load via reflection"); - } + using var engine = new NumberRecognitionEngine(modelConfiguration, _logger); var processed = 0; var total = imageFiles.Count; + var failed = 0; foreach (var file in imageFiles) { @@ -72,39 +51,30 @@ public class AiExtractionService : IAiExtractionService var extracted = string.Empty; - if (aiProcessorType is not null && aiProcessor is not null) + try { - try - { - var method = aiProcessorType.GetMethod("ExtractNumbersFromImage") - ?? aiProcessorType.GetMethod("ExtractTextFromImage"); - if (method is not null) - { - var value = method.Invoke(aiProcessor, new object[] { file }); - if (value != null) - { - extracted = value.ToString() ?? string.Empty; - } - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error invoking AI processor for {File}", file); - } + extracted = engine.ProcessImage(file).Text; + } + catch (Exception ex) + { + failed++; + _logger.LogWarning(ex, "Error processing AI OCR for {File}", file); } - if (!string.IsNullOrWhiteSpace(extracted)) - { - var result = new AiResultItem { Path = file, Text = extracted }; - extractedResults.Add(result); - await onResult(result).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); } + if (imageFiles.Count > 0 && failed == imageFiles.Count) + { + throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details."); + } + if (!string.IsNullOrWhiteSpace(request.CsvOutputPath)) { try @@ -129,4 +99,27 @@ public class AiExtractionService : IAiExtractionService } } } + + private static ModelConfiguration BuildModelConfiguration(string modelsFolderPath, bool useGpu) + { + if (string.IsNullOrWhiteSpace(modelsFolderPath)) + { + throw new InvalidOperationException("AI models folder is not configured."); + } + + var modelsRoot = Path.GetFullPath(modelsFolderPath.Trim().Trim('"')); + if (!Directory.Exists(modelsRoot)) + { + throw new DirectoryNotFoundException($"AI models folder not found: {modelsRoot}"); + } + + return new ModelConfiguration + { + DetectionCfg = Path.Combine(modelsRoot, "detection.cfg"), + DetectionWeights = Path.Combine(modelsRoot, "detection.weights"), + RecognitionCfg = Path.Combine(modelsRoot, "recognition.cfg"), + RecognitionWeights = Path.Combine(modelsRoot, "recognition.weights"), + UseGpu = useGpu + }; + } } diff --git a/imagecatalog/Services/IAiExtractionService.cs b/imagecatalog/Services/IAiExtractionService.cs index 3028946..bcf4c2e 100644 --- a/imagecatalog/Services/IAiExtractionService.cs +++ b/imagecatalog/Services/IAiExtractionService.cs @@ -9,6 +9,8 @@ public sealed class AiExtractionRequest { public required string SearchRoot { get; init; } public required bool Recursive { get; init; } + public required string ModelsFolderPath { get; init; } + public bool UseGpu { get; init; } public string CsvOutputPath { get; init; } = string.Empty; } diff --git a/imagecatalog/ViewModels/AiSettingsViewModel.cs b/imagecatalog/ViewModels/AiSettingsViewModel.cs index 461758f..e56874e 100644 --- a/imagecatalog/ViewModels/AiSettingsViewModel.cs +++ b/imagecatalog/ViewModels/AiSettingsViewModel.cs @@ -38,6 +38,17 @@ public class AiSettingsViewModel : ViewModelBase } } + private bool _useNumberAiGpu; + public bool UseNumberAiGpu + { + get => _useNumberAiGpu; + set + { + _useNumberAiGpu = value; + NotifyPropertyChanged(); + } + } + private string _faceExecutablePath = string.Empty; public string FaceExecutablePath { From 7e105e3738c045d02674f7422b8112cbb076289e Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 9 May 2026 17:53:15 +0200 Subject: [PATCH 2/2] feat: Add support for thumbnail inclusion in AI processing and enhance UI bindings --- .../DataModelCharacterizationTests.cs | 15 +++ imagecatalog/AvaloniaMainWindow.axaml.cs | 51 ++++++++ imagecatalog/AvaloniaViews/AiTabView.axaml | 22 +++- imagecatalog/CommandLineOperationRunner.cs | 16 ++- imagecatalog/DataModel.cs | 123 +++++++++++++++--- imagecatalog/Models/SettingsDto.cs | 4 + imagecatalog/Services/AiExtractionService.cs | 8 +- imagecatalog/Services/IAiExtractionService.cs | 1 + .../ViewModels/AiSettingsViewModel.cs | 22 ++++ 9 files changed, 235 insertions(+), 27 deletions(-) diff --git a/MaddoShared.Tests/DataModelCharacterizationTests.cs b/MaddoShared.Tests/DataModelCharacterizationTests.cs index 1a8955d..423acb5 100644 --- a/MaddoShared.Tests/DataModelCharacterizationTests.cs +++ b/MaddoShared.Tests/DataModelCharacterizationTests.cs @@ -122,6 +122,21 @@ public class DataModelCharacterizationTests model.UseNumberAiGpu.ShouldBeTrue(); } + [TestMethod] + public void NumberAiThumbnails_DefaultsOffAndRaisesDataModelPropertyChanged() + { + var model = CreateModel(); + model.IncludeNumberAiThumbnails.ShouldBeFalse(); + + string? changed = null; + model.PropertyChanged += (_, args) => changed = args.PropertyName; + + model.Ai.IncludeNumberAiThumbnails = true; + + changed.ShouldBe(nameof(DataModel.IncludeNumberAiThumbnails)); + model.IncludeNumberAiThumbnails.ShouldBeTrue(); + } + [TestMethod] public void CommandLineOperationRunner_DetectsHeadlessRequest() { diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs index 8ebe952..c9ca861 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml.cs +++ b/imagecatalog/AvaloniaMainWindow.axaml.cs @@ -1,5 +1,7 @@ +using Avalonia; using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; @@ -135,6 +137,11 @@ public partial class AvaloniaMainWindow : Window { // Color is set by typing hex directly in the TextBox. }; + + _model.ShowMessageRequested += async (_, args) => + { + await ShowMessageDialogAsync(args.Item1, args.Item2); + }; } private bool _isStoppingFaceEncoderForClose; @@ -182,4 +189,48 @@ public partial class AvaloniaMainWindow : Window { _ = this.FindControl("ThemeToggleButton"); } + + private async Task ShowMessageDialogAsync(string title, string message) + { + var dialog = new Window + { + Title = title, + Width = 480, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + SizeToContent = SizeToContent.Height + }; + + dialog.Content = BuildMessageDialogContent(message, () => dialog.Close()); + + await dialog.ShowDialog(this); + } + + private static Control BuildMessageDialogContent(string message, Action closeDialog) + { + var layout = new StackPanel + { + Margin = new Thickness(16), + Spacing = 12 + }; + + layout.Children.Add(new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + MaxWidth = 420 + }); + + var closeButton = new Button + { + Content = "OK", + MinWidth = 88, + HorizontalAlignment = HorizontalAlignment.Right + }; + + closeButton.Click += (_, _) => closeDialog(); + + layout.Children.Add(closeButton); + return layout; + } } diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml b/imagecatalog/AvaloniaViews/AiTabView.axaml index 34d62cd..9436e1f 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml @@ -12,8 +12,12 @@ - - + + + + @@ -47,8 +51,18 @@ - + diff --git a/imagecatalog/CommandLineOperationRunner.cs b/imagecatalog/CommandLineOperationRunner.cs index 0765a22..deed5bd 100644 --- a/imagecatalog/CommandLineOperationRunner.cs +++ b/imagecatalog/CommandLineOperationRunner.cs @@ -112,6 +112,11 @@ internal static class CommandLineOperationRunner model.UseFaceGpu = options.UseGpu.Value; } } + + if (options.IncludeThumbnails.HasValue) + { + model.IncludeNumberAiThumbnails = options.IncludeThumbnails.Value; + } } private static string NormalizeOperation(string operation) @@ -159,6 +164,14 @@ internal static class CommandLineOperationRunner case "--cpu": options.UseGpu = false; break; + case "--include-thumbnails": + case "--include-tn": + options.IncludeThumbnails = true; + break; + case "--no-thumbnails": + case "--no-tn": + options.IncludeThumbnails = false; + break; case "--headless": case "--cli": break; @@ -213,7 +226,7 @@ internal static class CommandLineOperationRunner private static void WriteUsage() { - Console.WriteLine("Usage: ImageCatalog --config --operation [--models ] [--csv ] [--cpu|--gpu]"); + Console.WriteLine("Usage: ImageCatalog --config --operation [--models ] [--csv ] [--cpu|--gpu] [--include-thumbnails|--no-thumbnails]"); } private sealed class CommandLineOptions @@ -224,5 +237,6 @@ internal static class CommandLineOperationRunner public string ModelsPath { get; set; } = string.Empty; public string CsvPath { get; set; } = string.Empty; public bool? UseGpu { get; set; } + public bool? IncludeThumbnails { get; set; } } } \ No newline at end of file diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index 95bef1b..88e509e 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -16,6 +16,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; +using AIFotoONLUS.Core; using AutoMapper; using MaddoShared; using Microsoft.Extensions.Logging; @@ -117,6 +118,7 @@ namespace ImageCatalog_2 // Load available fonts AvailableFonts = LoadAvailableFonts(); + RefreshNumberAiGpuCapabilities(); RefreshFaceExecutableCapabilities(); } @@ -131,6 +133,16 @@ namespace ImageCatalog_2 { // user cancelled } + catch (Exception ex) + { + _logger.LogError(ex, "AI extraction failed"); + if (UseNumberAiGpu) + { + RefreshNumberAiGpuCapabilities(); + } + + await ShowErrorMessageAsync("Errore AI", ex.GetBaseException().Message).ConfigureAwait(false); + } finally { MainToken = null; @@ -162,12 +174,19 @@ namespace ImageCatalog_2 { SearchRoot = searchRoot, Recursive = recursive, + IncludeThumbnails = IncludeNumberAiThumbnails, ModelsFolderPath = ModelsFolderPath, UseGpu = UseNumberAiGpu, CsvOutputPath = CsvOutputPath }, token, - result => InvokeOnUiThreadAsync(() => PreviewResults.Add(result)), + result => InvokeOnUiThreadAsync(() => + { + if (!string.IsNullOrWhiteSpace(result.Text)) + { + PreviewResults.Add(result); + } + }), progress => InvokeOnUiThreadAsync(() => AiProgress = progress)).ConfigureAwait(false); } @@ -200,7 +219,11 @@ namespace ImageCatalog_2 public string ModelsFolderPath { get => _ai.ModelsFolderPath; - set => _ai.ModelsFolderPath = value; + set + { + _ai.ModelsFolderPath = value; + RefreshNumberAiGpuCapabilities(); + } } public string CsvOutputPath @@ -212,7 +235,19 @@ namespace ImageCatalog_2 public bool UseNumberAiGpu { get => _ai.UseNumberAiGpu; - set => _ai.UseNumberAiGpu = value; + set => SetUseNumberAiGpu(value); + } + + public bool NumberAiGpuOptionEnabled + { + get => _ai.NumberAiGpuOptionEnabled; + private set => _ai.NumberAiGpuOptionEnabled = value; + } + + public bool IncludeNumberAiThumbnails + { + get => _ai.IncludeNumberAiThumbnails; + set => _ai.IncludeNumberAiThumbnails = value; } public string FaceExecutablePath @@ -1302,23 +1337,6 @@ namespace ImageCatalog_2 }, speed => { SpeedCounter = speed; }).ConfigureAwait(false); - // AI integration: OCR runs over processed output so it matches the face AI input folder. - if (ExtractNumbers) - { - try - { - await RunAiExtractionCoreAsync(token, useDestination: true, recursive: true); - } - catch (OperationCanceledException) - { - _logger.LogInformation("AI extraction canceled"); - } - catch (Exception ex) - { - _logger.LogError(ex, "AI extraction failed"); - } - } - SpeedCounter = runResult.FinalSpeedCounter; } catch (OperationCanceledException) @@ -1762,6 +1780,71 @@ namespace ImageCatalog_2 } } + private void RefreshNumberAiGpuCapabilities() + { + if (!TryBuildNumberAiModelConfiguration(out var configuration)) + { + NumberAiGpuOptionEnabled = false; + _ai.UseNumberAiGpu = false; + return; + } + + NumberAiGpuOptionEnabled = NumberRecognitionEngine.TryValidateGpuRuntime(configuration, _logger, out _); + if (!NumberAiGpuOptionEnabled) + { + _ai.UseNumberAiGpu = false; + } + } + + private void SetUseNumberAiGpu(bool value) + { + if (!NumberAiGpuOptionEnabled) + { + _ai.UseNumberAiGpu = false; + return; + } + + _ai.UseNumberAiGpu = value; + } + + private bool TryBuildNumberAiModelConfiguration(out ModelConfiguration configuration) + { + configuration = null!; + + if (string.IsNullOrWhiteSpace(ModelsFolderPath)) + { + return false; + } + + var modelsRoot = Path.GetFullPath(ModelsFolderPath.Trim().Trim('"')); + if (!Directory.Exists(modelsRoot)) + { + return false; + } + + configuration = new ModelConfiguration + { + DetectionCfg = Path.Combine(modelsRoot, "detection.cfg"), + DetectionWeights = Path.Combine(modelsRoot, "detection.weights"), + RecognitionCfg = Path.Combine(modelsRoot, "recognition.cfg"), + RecognitionWeights = Path.Combine(modelsRoot, "recognition.weights"), + UseGpu = true + }; + + return File.Exists(configuration.DetectionCfg) + && File.Exists(configuration.DetectionWeights) + && File.Exists(configuration.RecognitionCfg) + && File.Exists(configuration.RecognitionWeights); + } + + private Task ShowErrorMessageAsync(string title, string message) + { + return InvokeOnUiThreadAsync(() => + { + ShowMessageRequested?.Invoke(this, Tuple.Create(title, message, 0)); + }); + } + private void SetUseFaceGpu(bool value) { var currentValue = _ai.UseFaceGpu; diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index 200beb9..f5e7111 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -278,6 +278,10 @@ namespace ImageCatalog_2.Models [XmlElement("AI_UsaGpuNumeri")] public bool UseNumberAiGpu { get; set; } + [JsonPropertyName("IncludeNumberAiThumbnails")] + [XmlElement("AI_IncludiThumbnailNumeri")] + public bool IncludeNumberAiThumbnails { get; set; } + [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 a96254a..0f1a987 100644 --- a/imagecatalog/Services/AiExtractionService.cs +++ b/imagecatalog/Services/AiExtractionService.cs @@ -34,6 +34,7 @@ public class AiExtractionService : IAiExtractionService || f.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)) + .Where(f => request.IncludeThumbnails || !Path.GetFileName(f).StartsWith("tn_", StringComparison.OrdinalIgnoreCase)) .ToList(); var extractedResults = new List(); @@ -44,6 +45,7 @@ public class AiExtractionService : IAiExtractionService var processed = 0; var total = imageFiles.Count; var failed = 0; + Exception? firstFailure = null; foreach (var file in imageFiles) { @@ -58,6 +60,7 @@ public class AiExtractionService : IAiExtractionService catch (Exception ex) { failed++; + firstFailure ??= ex; _logger.LogWarning(ex, "Error processing AI OCR for {File}", file); } @@ -72,7 +75,7 @@ public class AiExtractionService : IAiExtractionService if (imageFiles.Count > 0 && failed == imageFiles.Count) { - throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details."); + throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details.", firstFailure); } if (!string.IsNullOrWhiteSpace(request.CsvOutputPath)) @@ -89,8 +92,9 @@ public class AiExtractionService : IAiExtractionService sw.WriteLine("Path,Text"); foreach (var r in extractedResults) { + var csvFileName = Path.GetFileName(r.Path ?? string.Empty); var safeText = (r.Text ?? string.Empty).Replace("\"", "\"\""); - sw.WriteLine($"\"{r.Path}\",\"{safeText}\""); + sw.WriteLine($"\"{csvFileName}\",\"{safeText}\""); } } catch (Exception ex) diff --git a/imagecatalog/Services/IAiExtractionService.cs b/imagecatalog/Services/IAiExtractionService.cs index bcf4c2e..60de781 100644 --- a/imagecatalog/Services/IAiExtractionService.cs +++ b/imagecatalog/Services/IAiExtractionService.cs @@ -9,6 +9,7 @@ public sealed class AiExtractionRequest { public required string SearchRoot { get; init; } public required bool Recursive { get; init; } + public bool IncludeThumbnails { get; init; } public required string ModelsFolderPath { get; init; } public bool UseGpu { get; init; } public string CsvOutputPath { get; init; } = string.Empty; diff --git a/imagecatalog/ViewModels/AiSettingsViewModel.cs b/imagecatalog/ViewModels/AiSettingsViewModel.cs index e56874e..9f0fecb 100644 --- a/imagecatalog/ViewModels/AiSettingsViewModel.cs +++ b/imagecatalog/ViewModels/AiSettingsViewModel.cs @@ -49,6 +49,28 @@ public class AiSettingsViewModel : ViewModelBase } } + private bool _numberAiGpuOptionEnabled; + public bool NumberAiGpuOptionEnabled + { + get => _numberAiGpuOptionEnabled; + set + { + _numberAiGpuOptionEnabled = value; + NotifyPropertyChanged(); + } + } + + private bool _includeNumberAiThumbnails; + public bool IncludeNumberAiThumbnails + { + get => _includeNumberAiThumbnails; + set + { + _includeNumberAiThumbnails = value; + NotifyPropertyChanged(); + } + } + private string _faceExecutablePath = string.Empty; public string FaceExecutablePath {