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 {