From 7e105e3738c045d02674f7422b8112cbb076289e Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 9 May 2026 17:53:15 +0200 Subject: [PATCH] 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 {