From af74c90ce7e7423ebb0f097c288e27096df039f4 Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 17:29:05 +0200 Subject: [PATCH] feat: Update package references and enhance AI extraction service with CSV output functionality --- .../Catalog.Communication.csproj | 8 +- .../MaddoShared.Benchmarks.csproj | 4 +- .../MaddoShared.ImageSharpTests.csproj | 14 +- .../PickerPreferenceServiceTests.cs | 58 +++++ MaddoShared.Tests/Test1.cs | 47 +++- imagecatalog/AvaloniaMainWindow.axaml.cs | 51 +++- imagecatalog/AvaloniaViews/AiTabView.axaml | 228 ++++++++++-------- imagecatalog/AvaloniaViews/AiTabView.axaml.cs | 2 +- imagecatalog/DataModel.cs | 75 +++++- imagecatalog/ImageCatalog 2.csproj | 18 +- imagecatalog/Services/AiExtractionService.cs | 33 +-- .../Services/PickerPreferenceService.cs | 29 +++ 12 files changed, 407 insertions(+), 160 deletions(-) create mode 100644 MaddoShared.Tests/PickerPreferenceServiceTests.cs diff --git a/Catalog.Communication/Catalog.Communication.csproj b/Catalog.Communication/Catalog.Communication.csproj index cb8dc94..8b3c8df 100644 --- a/Catalog.Communication/Catalog.Communication.csproj +++ b/Catalog.Communication/Catalog.Communication.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj b/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj index b712ff3..56b8684 100644 --- a/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj +++ b/MaddoShared.Benchmarks/MaddoShared.Benchmarks.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj b/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj index 52f36f6..6a7b6fe 100644 --- a/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj +++ b/MaddoShared.ImageSharpTests/MaddoShared.ImageSharpTests.csproj @@ -9,15 +9,15 @@ - - - + + + - - - - + + + + diff --git a/MaddoShared.Tests/PickerPreferenceServiceTests.cs b/MaddoShared.Tests/PickerPreferenceServiceTests.cs new file mode 100644 index 0000000..f0fe3d0 --- /dev/null +++ b/MaddoShared.Tests/PickerPreferenceServiceTests.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using ImageCatalog; +using ImageCatalog_2.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Shouldly; + +namespace MaddoShared.Tests; + +[TestClass] +public class PickerPreferenceServiceTests +{ + [TestMethod] + public void RememberValue_PersistsExactFilePath() + { + using var tempDirectory = new TemporaryDirectory(); + var preferencesFile = Path.Combine(tempDirectory.Path, "userprefs.xml"); + var service = new PickerPreferenceService(new ParametriSetup(preferencesFile)); + var settingsFile = Path.Combine(tempDirectory.Path, "nested", "settings.xml"); + + service.RememberValue(PickerPreferenceKeys.LastSettingsFile, settingsFile); + + service.GetRememberedValue(PickerPreferenceKeys.LastSettingsFile).ShouldBe(settingsFile); + } + + [TestMethod] + public void ForgetValue_RemovesStoredPreference() + { + using var tempDirectory = new TemporaryDirectory(); + var preferencesFile = Path.Combine(tempDirectory.Path, "userprefs.xml"); + var service = new PickerPreferenceService(new ParametriSetup(preferencesFile)); + + service.RememberValue(PickerPreferenceKeys.LastSettingsFile, Path.Combine(tempDirectory.Path, "settings.xml")); + + service.ForgetValue(PickerPreferenceKeys.LastSettingsFile); + + service.GetRememberedValue(PickerPreferenceKeys.LastSettingsFile).ShouldBeNull(); + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + } +} \ No newline at end of file diff --git a/MaddoShared.Tests/Test1.cs b/MaddoShared.Tests/Test1.cs index cd00c92..da8e098 100644 --- a/MaddoShared.Tests/Test1.cs +++ b/MaddoShared.Tests/Test1.cs @@ -1,11 +1,48 @@ -namespace MaddoShared.Tests +using ImageCatalog_2.Services; +using ImageCatalog_2.Models; +using Shouldly; + +namespace MaddoShared.Tests; + +[TestClass] +public sealed class AiExtractionServiceCsvTests { - [TestClass] - public sealed class Test1 + [TestMethod] + public void WriteCsvOutput_UsesLegacyCompatibleHeaderAndFilenameColumn() { - [TestMethod] - public void TestMethod1() + using var tempDir = new TempDirectory(); + var csvPath = Path.Combine(tempDir.Path, "ocr.csv"); + + AiExtractionService.WriteCsvOutput( + csvPath, + [ + new AiResultItem { Path = @"C:\images\IMG_7146.JPG", Text = "43,84,61" }, + new AiResultItem { Path = @"C:\images\IMG_7207.JPG", Text = "a\"b" } + ]); + + var lines = File.ReadAllLines(csvPath); + + lines[0].ShouldBe("filename,text"); + lines[1].ShouldBe("\"IMG_7146.JPG\",\"43,84,61\""); + lines[2].ShouldBe("\"IMG_7207.JPG\",\"a\"\"b\""); + } + + private sealed class TempDirectory : IDisposable + { + public TempDirectory() { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), System.IO.Path.GetRandomFileName()); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } } } } diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs index 08ee289..ec1c454 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml.cs +++ b/imagecatalog/AvaloniaMainWindow.axaml.cs @@ -17,6 +17,7 @@ public partial class AvaloniaMainWindow : Window private readonly DataModel _model; private readonly PickerPreferenceService _pickerPreferenceService; private bool _isDarkTheme; + private bool _startupSettingsRestoreAttempted; public AvaloniaMainWindow(DataModel model) { @@ -26,7 +27,11 @@ public partial class AvaloniaMainWindow : Window _pickerPreferenceService = Program.ServiceProvider.GetRequiredService(); DataContext = _model; - Opened += (_, _) => SyncThemeStateFromCurrentTheme(); + Opened += async (_, _) => + { + SyncThemeStateFromCurrentTheme(); + await TryLoadLastSettingsOnStartupAsync(); + }; Closing += AvaloniaMainWindow_Closing; // Let DataModel marshal callbacks onto Avalonia UI thread. @@ -120,30 +125,38 @@ public partial class AvaloniaMainWindow : Window _model.SaveSettingsRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.SettingsFile); var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Salva impostazioni", DefaultExtension = "xml", - FileTypeChoices = [new FilePickerFileType("Setup") { Patterns = ["*.xml"] }] + FileTypeChoices = [new FilePickerFileType("Setup") { Patterns = ["*.xml"] }], + SuggestedStartLocation = suggestedStartLocation }); if (file is not null) { await _model.SaveSettingsToFileAsync(file.Path.LocalPath); + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.SettingsFile, file.Path.LocalPath); + _pickerPreferenceService.RememberValue(PickerPreferenceKeys.LastSettingsFile, file.Path.LocalPath); } }; _model.LoadSettingsRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.SettingsFile); var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Carica impostazioni", - FileTypeFilter = [new FilePickerFileType("Setup") { Patterns = ["*.xml"] }] + FileTypeFilter = [new FilePickerFileType("Setup") { Patterns = ["*.xml"] }], + SuggestedStartLocation = suggestedStartLocation }); if (files.Count > 0) { await _model.LoadSettingsFromFileAsync(files[0].Path.LocalPath); + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.SettingsFile, files[0].Path.LocalPath); + _pickerPreferenceService.RememberValue(PickerPreferenceKeys.LastSettingsFile, files[0].Path.LocalPath); } }; @@ -165,6 +178,38 @@ public partial class AvaloniaMainWindow : Window private bool _isStoppingFaceEncoderForClose; + private async Task TryLoadLastSettingsOnStartupAsync() + { + if (_startupSettingsRestoreAttempted) + { + return; + } + + _startupSettingsRestoreAttempted = true; + + var lastSettingsFile = _pickerPreferenceService.GetRememberedValue(PickerPreferenceKeys.LastSettingsFile); + if (string.IsNullOrWhiteSpace(lastSettingsFile)) + { + return; + } + + if (!File.Exists(lastSettingsFile)) + { + _pickerPreferenceService.ForgetValue(PickerPreferenceKeys.LastSettingsFile); + return; + } + + try + { + await _model.LoadSettingsFromFileAsync(lastSettingsFile); + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.SettingsFile, lastSettingsFile); + } + catch (Exception ex) + { + await ShowMessageDialogAsync("Impostazioni", $"Impossibile caricare il file impostazioni automatico:\n{ex.GetBaseException().Message}"); + } + } + private async void AvaloniaMainWindow_Closing(object? sender, CancelEventArgs e) { if (_isStoppingFaceEncoderForClose || (!_model.IsFaceEncoderRunning && !_model.IsFaceMatcherRunning)) diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml b/imagecatalog/AvaloniaViews/AiTabView.axaml index c731f4e..0a2d395 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml @@ -3,112 +3,128 @@ xmlns:avaloniaDataGrid="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid" xmlns:iconPacks="https://github.com/MahApps/IconPacks.Avalonia" x:Class="ImageCatalog_2.AvaloniaViews.AiTabView"> - - - - - + + + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs index f89ded7..852cc63 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs @@ -31,7 +31,7 @@ public partial class AiTabView : Avalonia.Controls.UserControl OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? model.CsvOutputPath : directory); } - private void OpenAiDestinationFolder_Click(object? sender, RoutedEventArgs e) + private void OpenAiSourceFolder_Click(object? sender, RoutedEventArgs e) { if (DataContext is DataModel model) { diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index cea5dce..c89cb2c 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -77,6 +77,8 @@ namespace ImageCatalog_2 private Task? _faceMatcherLogWatcherTask; private bool _hasStartedFaceEncoderInSession; private bool _hasStartedFaceMatcherInSession; + private int _numberAiGpuRefreshVersion; + private volatile bool _numberAiGpuValidationPending; private sealed record ParsedFaceMatcherRow(string PhotoId, double? Score, string RawRow, string DebugSummary); @@ -137,7 +139,7 @@ namespace ImageCatalog_2 // Load available fonts AvailableFonts = LoadAvailableFonts(); - RefreshNumberAiGpuCapabilities(); + QueueRefreshNumberAiGpuCapabilities(); RefreshFaceExecutableCapabilities(); } @@ -157,7 +159,7 @@ namespace ImageCatalog_2 _logger.LogError(ex, "AI extraction failed"); if (UseNumberAiGpu) { - RefreshNumberAiGpuCapabilities(); + QueueRefreshNumberAiGpuCapabilities(); } await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = $"Errore OCR: {ex.GetBaseException().Message}").ConfigureAwait(false); @@ -255,7 +257,7 @@ namespace ImageCatalog_2 set { _ai.ModelsFolderPath = value; - RefreshNumberAiGpuCapabilities(); + QueueRefreshNumberAiGpuCapabilities(); } } @@ -2281,31 +2283,86 @@ namespace ImageCatalog_2 } } - private void RefreshNumberAiGpuCapabilities() + private void QueueRefreshNumberAiGpuCapabilities() { if (!TryBuildNumberAiModelConfiguration(out var configuration)) { + _numberAiGpuValidationPending = false; NumberAiGpuOptionEnabled = false; _ai.UseNumberAiGpu = false; return; } - NumberAiGpuOptionEnabled = NumberRecognitionEngine.TryValidateGpuRuntime(configuration, _logger, out _); - if (!NumberAiGpuOptionEnabled) + _numberAiGpuValidationPending = true; + var requestVersion = Interlocked.Increment(ref _numberAiGpuRefreshVersion); + _ = RefreshNumberAiGpuCapabilitiesAsync(configuration, requestVersion); + } + + private async Task RefreshNumberAiGpuCapabilitiesAsync(ModelConfiguration configuration, int requestVersion) + { + try { - _ai.UseNumberAiGpu = false; + var gpuAvailable = await Task.Run(() => + NumberRecognitionEngine.TryValidateGpuRuntime(configuration, _logger, out _)).ConfigureAwait(false); + + if (requestVersion != Volatile.Read(ref _numberAiGpuRefreshVersion)) + { + return; + } + + await InvokeOnUiThreadAsync(() => + { + if (requestVersion != Volatile.Read(ref _numberAiGpuRefreshVersion)) + { + return; + } + + _numberAiGpuValidationPending = false; + NumberAiGpuOptionEnabled = gpuAvailable; + if (!gpuAvailable) + { + _ai.UseNumberAiGpu = false; + } + }).ConfigureAwait(false); + } + catch (Exception ex) + { + if (requestVersion != Volatile.Read(ref _numberAiGpuRefreshVersion)) + { + return; + } + + _logger.LogWarning(ex, "Failed to refresh OCR GPU capabilities."); + + await InvokeOnUiThreadAsync(() => + { + if (requestVersion != Volatile.Read(ref _numberAiGpuRefreshVersion)) + { + return; + } + + _numberAiGpuValidationPending = false; + NumberAiGpuOptionEnabled = false; + _ai.UseNumberAiGpu = false; + }).ConfigureAwait(false); } } private void SetUseNumberAiGpu(bool value) { - if (!NumberAiGpuOptionEnabled) + if (!value) { _ai.UseNumberAiGpu = false; return; } - _ai.UseNumberAiGpu = value; + if (NumberAiGpuOptionEnabled || _numberAiGpuValidationPending) + { + _ai.UseNumberAiGpu = true; + return; + } + + _ai.UseNumberAiGpu = false; } private bool TryBuildNumberAiModelConfiguration(out ModelConfiguration configuration) diff --git a/imagecatalog/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj index 5affa11..7446b3a 100644 --- a/imagecatalog/ImageCatalog 2.csproj +++ b/imagecatalog/ImageCatalog 2.csproj @@ -65,16 +65,16 @@ - - - - - + + + + + - - - - + + + + all diff --git a/imagecatalog/Services/AiExtractionService.cs b/imagecatalog/Services/AiExtractionService.cs index b7ea6cc..685a43f 100644 --- a/imagecatalog/Services/AiExtractionService.cs +++ b/imagecatalog/Services/AiExtractionService.cs @@ -198,20 +198,7 @@ public class AiExtractionService : IAiExtractionService { try { - var dir = Path.GetDirectoryName(request.CsvOutputPath) ?? string.Empty; - if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) - { - Directory.CreateDirectory(dir); - } - - using var sw = new StreamWriter(request.CsvOutputPath, false, Encoding.UTF8); - 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($"\"{csvFileName}\",\"{safeText}\""); - } + WriteCsvOutput(request.CsvOutputPath, extractedResults); } catch (Exception ex) { @@ -222,6 +209,24 @@ public class AiExtractionService : IAiExtractionService return summary; } + internal static void WriteCsvOutput(string csvOutputPath, IEnumerable extractedResults) + { + var dir = Path.GetDirectoryName(csvOutputPath) ?? string.Empty; + if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir)) + { + Directory.CreateDirectory(dir); + } + + using var sw = new StreamWriter(csvOutputPath, false, Encoding.UTF8); + sw.WriteLine("filename,text"); + foreach (var result in extractedResults) + { + var csvFileName = Path.GetFileName(result.Path ?? string.Empty); + var safeText = (result.Text ?? string.Empty).Replace("\"", "\"\""); + sw.WriteLine($"\"{csvFileName}\",\"{safeText}\""); + } + } + private static double CalculateAverageImagesPerSecond(int processed, TimeSpan elapsed) { return elapsed.TotalSeconds > 0 ? processed / elapsed.TotalSeconds : 0; diff --git a/imagecatalog/Services/PickerPreferenceService.cs b/imagecatalog/Services/PickerPreferenceService.cs index 26f7033..49bdaea 100644 --- a/imagecatalog/Services/PickerPreferenceService.cs +++ b/imagecatalog/Services/PickerPreferenceService.cs @@ -13,6 +13,8 @@ public static class PickerPreferenceKeys public const string LogoFile = "Picker.LogoFile.LastPath"; public const string ModelsFolder = "Picker.ModelsFolder.LastPath"; public const string CsvOutput = "Picker.CsvOutput.LastPath"; + public const string SettingsFile = "Picker.SettingsFile.LastPath"; + public const string LastSettingsFile = "Settings.LastFilePath"; public const string FaceExecutableFolder = "Picker.FaceExecutableFolder.LastPath"; public const string FaceOutputFolder = "Picker.FaceOutputFolder.LastPath"; public const string FaceMatcherExecutable = "Picker.FaceMatcherExecutable.LastPath"; @@ -61,6 +63,33 @@ public sealed class PickerPreferenceService _userPreferences.SalvaParametriSetup(); } + public string? GetRememberedValue(string preferenceKey) + { + var value = _userPreferences.LeggiParametroString(preferenceKey); + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().Trim('"'); + } + + public void RememberValue(string preferenceKey, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + _userPreferences.AggiornaParametro(preferenceKey, value.Trim().Trim('"')); + _userPreferences.SalvaParametriSetup(); + } + + public void ForgetValue(string preferenceKey) + { + if (_userPreferences.RimuoviParametro(preferenceKey)) + { + _userPreferences.SalvaParametriSetup(); + } + } + private string? GetPreferredStartDirectory(string preferenceKey, string? currentPath) { var storedPath = _userPreferences.LeggiParametroString(preferenceKey);