From c261557a29acc68f7d0fb3062dce137a69f55969 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sat, 9 May 2026 20:27:44 +0200 Subject: [PATCH 1/7] feat: Add Face Matcher functionality and related settings - Implemented FilePathToBitmapConverter for image loading. - Enhanced DataModel with commands and properties for Face Matcher. - Created FaceMatcherResultItem model to store results. - Updated SettingsDto to include Face Matcher paths and tolerance. - Introduced PickerPreferenceService for managing folder paths. - Expanded AiSettingsViewModel to manage Face Matcher settings and results. --- imagecatalog/AvaloniaMainWindow.axaml.cs | 39 +- .../AvaloniaViews/FaceAiTabView.axaml | 405 +++++-- .../AvaloniaViews/FaceAiTabView.axaml.cs | 544 +++++++-- .../Converters/FilePathToBitmapConverter.cs | 31 + imagecatalog/DataModel.cs | 1005 +++++++++++++++++ imagecatalog/Models/FaceMatcherResultItem.cs | 24 + imagecatalog/Models/SettingsDto.cs | 8 + imagecatalog/Program.cs | 1 + .../Services/PickerPreferenceService.cs | 93 ++ .../ViewModels/AiSettingsViewModel.cs | 101 ++ 10 files changed, 2050 insertions(+), 201 deletions(-) create mode 100644 imagecatalog/Converters/FilePathToBitmapConverter.cs create mode 100644 imagecatalog/Models/FaceMatcherResultItem.cs create mode 100644 imagecatalog/Services/PickerPreferenceService.cs diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs index c9ca861..08ee289 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml.cs +++ b/imagecatalog/AvaloniaMainWindow.axaml.cs @@ -5,6 +5,8 @@ using Avalonia.Layout; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; +using ImageCatalog_2.Services; +using Microsoft.Extensions.DependencyInjection; using System.ComponentModel; using System.IO; @@ -13,6 +15,7 @@ namespace ImageCatalog_2; public partial class AvaloniaMainWindow : Window { private readonly DataModel _model; + private readonly PickerPreferenceService _pickerPreferenceService; private bool _isDarkTheme; public AvaloniaMainWindow(DataModel model) @@ -20,6 +23,7 @@ public partial class AvaloniaMainWindow : Window InitializeComponent(); _model = model; + _pickerPreferenceService = Program.ServiceProvider.GetRequiredService(); DataContext = _model; Opened += (_, _) => SyncThemeStateFromCurrentTheme(); @@ -30,35 +34,43 @@ public partial class AvaloniaMainWindow : Window _model.SelectSourceFolderRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.SourceFolder, _model.SourcePath); var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { - Title = "Seleziona cartella sorgente" + Title = "Seleziona cartella sorgente", + SuggestedStartLocation = suggestedStartLocation }); if (folders.Count > 0) { _model.SourcePath = folders[0].Path.LocalPath + Path.DirectorySeparatorChar; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.SourceFolder, _model.SourcePath); } }; _model.SelectDestinationFolderRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.DestinationFolder, _model.DestinationPath); var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { - Title = "Seleziona cartella destinazione" + Title = "Seleziona cartella destinazione", + SuggestedStartLocation = suggestedStartLocation }); if (folders.Count > 0) { _model.DestinationPath = folders[0].Path.LocalPath + Path.DirectorySeparatorChar; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.DestinationFolder, _model.DestinationPath); } }; _model.SelectLogoFileRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.LogoFile, _model.LogoFile); var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = "Seleziona logo", + SuggestedStartLocation = suggestedStartLocation, FileTypeFilter = [ new FilePickerFileType("Immagini") { Patterns = ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif"] } @@ -68,34 +80,41 @@ public partial class AvaloniaMainWindow : Window if (files.Count > 0) { _model.LogoFile = files[0].Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.LogoFile, _model.LogoFile); } }; _model.SelectModelsFolderRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.ModelsFolder, _model.ModelsFolderPath); var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { - Title = "Seleziona cartella modelli" + Title = "Seleziona cartella modelli", + SuggestedStartLocation = suggestedStartLocation }); if (folders.Count > 0) { _model.ModelsFolderPath = folders[0].Path.LocalPath + Path.DirectorySeparatorChar; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.ModelsFolder, _model.ModelsFolderPath); } }; _model.SelectCsvOutputRequested += async (_, _) => { + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(StorageProvider, PickerPreferenceKeys.CsvOutput, _model.CsvOutputPath); var file = await StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = "Salva CSV", DefaultExtension = "csv", - FileTypeChoices = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }] + FileTypeChoices = [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], + SuggestedStartLocation = suggestedStartLocation }); if (file is not null) { _model.CsvOutputPath = file.Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.CsvOutput, _model.CsvOutputPath); } }; @@ -148,7 +167,7 @@ public partial class AvaloniaMainWindow : Window private async void AvaloniaMainWindow_Closing(object? sender, CancelEventArgs e) { - if (_isStoppingFaceEncoderForClose || !_model.IsFaceEncoderRunning) + if (_isStoppingFaceEncoderForClose || (!_model.IsFaceEncoderRunning && !_model.IsFaceMatcherRunning)) { return; } @@ -158,7 +177,15 @@ public partial class AvaloniaMainWindow : Window try { - await _model.StopFaceEncoderAsync("Arresto face encoder in chiusura...", waitForExit: true); + if (_model.IsFaceMatcherRunning) + { + await _model.StopFaceMatcherAsync("Arresto face matcher in chiusura...", waitForExit: true); + } + + if (_model.IsFaceEncoderRunning) + { + await _model.StopFaceEncoderAsync("Arresto face encoder in chiusura...", waitForExit: true); + } } finally { diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml index 134621c..858a4e7 100644 --- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml +++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml @@ -1,109 +1,326 @@ - - - - + + + - - - - - + + + + + + + + - - + + + + + + + + + + + + + + - - - - - - - - + + + + + - - - - - - - + + + + + - - - - + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + - - - + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs index 3057b02..e4edbcc 100644 --- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs @@ -1,20 +1,32 @@ using Avalonia.Controls; using Avalonia.Interactivity; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Imaging; using Avalonia.Platform.Storage; using Avalonia.Threading; +using ImageCatalog_2.Models; +using ImageCatalog_2.Services; +using Microsoft.Extensions.DependencyInjection; using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.IO; +using System.Text; +using System.Threading.Tasks; namespace ImageCatalog_2.AvaloniaViews; public partial class FaceAiTabView : Avalonia.Controls.UserControl { private INotifyPropertyChanged? _faceAiPropertySource; + private readonly PickerPreferenceService _pickerPreferenceService; public FaceAiTabView() { + _pickerPreferenceService = Program.ServiceProvider.GetRequiredService(); InitializeComponent(); DataContextChanged += OnDataContextChanged; } @@ -35,12 +47,21 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl private void OnFaceAiPropertyChanged(object? sender, PropertyChangedEventArgs e) { - if (!string.Equals(e.PropertyName, nameof(DataModel.FaceCommandOutput), StringComparison.Ordinal)) + if (string.Equals(e.PropertyName, nameof(DataModel.FaceCommandOutput), StringComparison.Ordinal)) { + ScrollOutputTextBoxToEnd("FaceOutputTextBox"); return; } - var outputBox = this.FindControl("FaceOutputTextBox"); + if (string.Equals(e.PropertyName, nameof(DataModel.FaceMatcherCommandOutput), StringComparison.Ordinal)) + { + ScrollOutputTextBoxToEnd("FaceMatcherOutputTextBox"); + } + } + + private void ScrollOutputTextBoxToEnd(string controlName) + { + var outputBox = this.FindControl(controlName); if (outputBox is null) { return; @@ -55,129 +76,121 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e) { - var executableBox = this.FindControl("FaceExecutablePathTextBox"); - if (executableBox is null) + var currentPath = DataContext is DataModel currentModel ? currentModel.FaceExecutablePath : null; + var folders = await OpenFolderPickerAsync("Seleziona la cartella Face Recognition Windows", PickerPreferenceKeys.FaceExecutableFolder, currentPath); + if (folders.Count > 0 && DataContext is DataModel model) { - return; - } - - var topLevel = TopLevel.GetTopLevel(this); - var storageProvider = topLevel?.StorageProvider; - if (storageProvider is null) - { - return; - } - - var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - Title = "Seleziona la cartella Face Recognition Windows" - }); - - if (folders.Count > 0) - { - executableBox.Text = folders[0].Path.LocalPath; - if (DataContext is DataModel model) - { - model.FaceExecutablePath = executableBox.Text; - } + model.FaceExecutablePath = folders[0].Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceExecutableFolder, model.FaceExecutablePath); } } private async void SelectFaceOutputFolder_Click(object? sender, RoutedEventArgs e) { - var outputBox = this.FindControl("FaceOutputFolderTextBox"); - if (outputBox is null) + var currentPath = DataContext is DataModel currentModel ? currentModel.FaceOutputFolderPath : null; + var folders = await OpenFolderPickerAsync("Seleziona la cartella output per encodings e log", PickerPreferenceKeys.FaceOutputFolder, currentPath); + if (folders.Count > 0 && DataContext is DataModel model) { - return; - } - - var topLevel = TopLevel.GetTopLevel(this); - var storageProvider = topLevel?.StorageProvider; - if (storageProvider is null) - { - return; - } - - var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - Title = "Seleziona la cartella output per encodings e log" - }); - - if (folders.Count > 0) - { - outputBox.Text = folders[0].Path.LocalPath; - if (DataContext is DataModel model) - { - model.FaceOutputFolderPath = outputBox.Text; - } + model.FaceOutputFolderPath = folders[0].Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceOutputFolder, model.FaceOutputFolderPath); } } - private void OpenFaceExecutableFolder_Click(object? sender, RoutedEventArgs e) + private async void SelectFaceMatcherExecutable_Click(object? sender, RoutedEventArgs e) { - var executableBox = this.FindControl("FaceExecutablePathTextBox"); - if (executableBox is null) - { - return; - } + var files = await OpenFilePickerAsync( + "Seleziona face_matcher.exe", + [new FilePickerFileType("Eseguibile") { Patterns = ["*.exe"] }], + PickerPreferenceKeys.FaceMatcherExecutable, + DataContext is DataModel currentModel ? currentModel.FaceMatcherExecutablePath : null); - var path = executableBox.Text?.Trim(); - if (string.IsNullOrWhiteSpace(path)) + if (files.Count > 0 && DataContext is DataModel model) { - return; + model.FaceMatcherExecutablePath = files[0].Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherExecutable, model.FaceMatcherExecutablePath); } - - if (Directory.Exists(path)) - { - OpenInExplorer(path); - return; - } - - if (File.Exists(path)) - { - OpenInExplorer(path); - return; - } - - var directory = Path.GetDirectoryName(path); - OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory); } - private void OpenFaceOutputFolder_Click(object? sender, RoutedEventArgs e) + private async void SelectFaceMatcherImage_Click(object? sender, RoutedEventArgs e) { - var outputBox = this.FindControl("FaceOutputFolderTextBox"); - if (outputBox is null) - { - return; - } + var files = await OpenFilePickerAsync( + "Seleziona immagine per il match", + [new FilePickerFileType("Immagini") { Patterns = ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"] }], + PickerPreferenceKeys.FaceMatcherImage, + DataContext is DataModel currentModel ? currentModel.FaceMatcherSelectedImagePath : null); - var outputPath = outputBox.Text?.Trim(); - if (string.IsNullOrWhiteSpace(outputPath)) + if (files.Count > 0 && DataContext is DataModel model) { - return; + model.FaceMatcherSelectedImagePath = files[0].Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherImage, model.FaceMatcherSelectedImagePath); } - - if (Directory.Exists(outputPath)) - { - OpenInExplorer(outputPath); - return; - } - - if (File.Exists(outputPath)) - { - OpenInExplorer(outputPath); - return; - } - - var directory = Path.GetDirectoryName(outputPath); - OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? outputPath : directory); } + private async void SelectFaceMatcherEncodings_Click(object? sender, RoutedEventArgs e) + { + var files = await OpenFilePickerAsync( + "Seleziona file encodings .pkl", + [new FilePickerFileType("Encodings") { Patterns = ["*.pkl"] }], + PickerPreferenceKeys.FaceMatcherEncodings, + DataContext is DataModel currentModel ? currentModel.FaceMatcherEncodingsPath : null); + + if (files.Count > 0 && DataContext is DataModel model) + { + model.FaceMatcherEncodingsPath = files[0].Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherEncodings, model.FaceMatcherEncodingsPath); + } + } + + private async void SelectFaceMatcherOutput_Click(object? sender, RoutedEventArgs e) + { + var file = await SaveFilePickerAsync( + "Seleziona output CSV del matcher", + "csv", + [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], + PickerPreferenceKeys.FaceMatcherOutput, + DataContext is DataModel currentModel ? currentModel.FaceMatcherOutputPath : null); + + if (file is not null && DataContext is DataModel model) + { + model.FaceMatcherOutputPath = file.Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherOutput, model.FaceMatcherOutputPath); + } + } + + private async void SelectFaceMatcherLog_Click(object? sender, RoutedEventArgs e) + { + var file = await SaveFilePickerAsync( + "Seleziona log TXT del matcher", + "txt", + [new FilePickerFileType("Log") { Patterns = ["*.txt", "*.log"] }], + PickerPreferenceKeys.FaceMatcherLog, + DataContext is DataModel currentModel ? currentModel.FaceMatcherLogPath : null); + + if (file is not null && DataContext is DataModel model) + { + model.FaceMatcherLogPath = file.Path.LocalPath; + _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherLog, model.FaceMatcherLogPath); + } + } + + private void OpenFaceExecutableFolder_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceExecutablePathTextBox"); + + private void OpenFaceOutputFolder_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceOutputFolderTextBox"); + + private void OpenFaceMatcherExecutable_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherExecutablePathTextBox"); + + private void OpenFaceMatcherImage_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherImagePathTextBox"); + + private void OpenFaceMatcherEncodings_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherEncodingsPathTextBox"); + + private void OpenFaceMatcherOutput_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherOutputPathTextBox"); + + private void OpenFaceMatcherLog_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherLogPathTextBox"); + private void OpenFaceDestinationFolder_Click(object? sender, RoutedEventArgs e) { - var destBox = this.FindControl("FaceDestinationPathTextBox"); - string? path = destBox?.Text?.Trim(); - if (string.IsNullOrWhiteSpace(path) && DataContext is DataModel model) + string? path = null; + if (DataContext is DataModel model) { path = (model.DestinationPath ?? string.Empty).Trim(); } @@ -197,6 +210,335 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory); } + private async void OpenFaceMatcherPreview_Click(object? sender, RoutedEventArgs e) + { + if (sender is not Button { Tag: FaceMatcherResultItem item }) + { + return; + } + + await OpenFaceMatcherPreviewAsync(item); + } + + private async Task OpenFaceMatcherPreviewAsync(FaceMatcherResultItem item) + { + var owner = TopLevel.GetTopLevel(this) as Window; + var dialog = BuildFaceMatcherPreviewDialog(item); + if (owner is not null) + { + await dialog.ShowDialog(owner); + return; + } + + dialog.Show(); + } + + private async void FaceMatcherResults_DoubleTapped(object? sender, TappedEventArgs e) + { + if (sender is not Avalonia.Controls.DataGrid { SelectedItem: FaceMatcherResultItem item }) + { + return; + } + + await OpenFaceMatcherPreviewAsync(item); + } + + private Window BuildFaceMatcherPreviewDialog(FaceMatcherResultItem item) + { + var dialog = new Window + { + Title = $"Preview match: {item.PhotoId}", + Width = 1180, + Height = 900, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + Bitmap? bitmap = null; + var dimensionText = "n/d"; + if (!string.IsNullOrWhiteSpace(item.ResolvedImagePath) && File.Exists(item.ResolvedImagePath)) + { + try + { + bitmap = new Bitmap(item.ResolvedImagePath); + dimensionText = $"{bitmap.PixelSize.Width} x {bitmap.PixelSize.Height}px"; + } + catch + { + bitmap = null; + } + } + + var fileInfo = !string.IsNullOrWhiteSpace(item.ResolvedImagePath) && File.Exists(item.ResolvedImagePath) + ? new FileInfo(item.ResolvedImagePath) + : null; + + var debugBuilder = new StringBuilder(); + debugBuilder.AppendLine($"File matcher: {item.PhotoId}"); + debugBuilder.AppendLine($"Score: {item.ScoreDisplay}"); + debugBuilder.AppendLine($"Path risolto: {item.ResolvedImagePath}"); + debugBuilder.AppendLine($"Candidati trovati in destinazione: {item.CandidateCount}"); + debugBuilder.AppendLine($"Dimensioni immagine: {dimensionText}"); + if (fileInfo is not null) + { + debugBuilder.AppendLine($"Dimensione file: {fileInfo.Length / 1024.0:F1} KB"); + debugBuilder.AppendLine($"Ultima modifica: {fileInfo.LastWriteTime:yyyy-MM-dd HH:mm:ss}"); + } + + debugBuilder.AppendLine($"Immagine ricerca: {item.SearchImagePath}"); + debugBuilder.AppendLine($"CSV risultati: {item.CsvPath}"); + debugBuilder.AppendLine($"Log matcher: {item.LogPath}"); + if (!string.IsNullOrWhiteSpace(item.DebugSummary)) + { + debugBuilder.AppendLine($"Dettagli riga: {item.DebugSummary}"); + } + + if (!string.IsNullOrWhiteSpace(item.RawRow)) + { + debugBuilder.AppendLine($"Raw CSV: {item.RawRow}"); + } + + var layout = new Grid + { + Margin = new Avalonia.Thickness(16), + RowDefinitions = new RowDefinitions("Auto,*,Auto") + }; + + var header = new StackPanel { Spacing = 6 }; + header.Children.Add(new TextBlock + { + Text = item.PhotoId, + FontWeight = FontWeight.Bold, + FontSize = 18 + }); + header.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(item.ScoreDisplay) + ? "Score: n/d" + : $"Score: {item.ScoreDisplay}%", + FontWeight = FontWeight.SemiBold, + Opacity = 0.9 + }); + header.Children.Add(new TextBlock + { + Text = string.IsNullOrWhiteSpace(item.ResolvedImagePath) + ? "Nessun file immagine risolto nella cartella Destinazione." + : item.ResolvedImagePath, + TextWrapping = TextWrapping.Wrap, + Opacity = 0.8 + }); + layout.Children.Add(header); + + var contentGrid = new Grid + { + Margin = new Avalonia.Thickness(0, 12, 0, 12), + RowDefinitions = new RowDefinitions("Auto,*,Auto"), + RowSpacing = 12 + }; + Grid.SetRow(contentGrid, 1); + + var zoomLevel = 1.0; + var zoomText = new TextBlock + { + Text = "100%", + VerticalAlignment = VerticalAlignment.Center, + MinWidth = 52, + TextAlignment = TextAlignment.Center + }; + + var imageControl = bitmap is null + ? null + : new Image + { + Source = bitmap, + Stretch = Stretch.None, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top, + RenderTransform = new ScaleTransform(1, 1) + }; + + void UpdateZoom(double delta) + { + if (imageControl is null) + { + return; + } + + zoomLevel = Math.Clamp(zoomLevel + delta, 0.1, 8.0); + imageControl.RenderTransform = new ScaleTransform(zoomLevel, zoomLevel); + zoomText.Text = $"{zoomLevel * 100:0}%"; + } + + var toolbar = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8 + }; + + var zoomOutButton = new Button { Content = "Zoom -", MinWidth = 80, IsEnabled = imageControl is not null }; + zoomOutButton.Click += (_, _) => UpdateZoom(-0.1); + toolbar.Children.Add(zoomOutButton); + + var zoomInButton = new Button { Content = "Zoom +", MinWidth = 80, IsEnabled = imageControl is not null }; + zoomInButton.Click += (_, _) => UpdateZoom(0.1); + toolbar.Children.Add(zoomInButton); + + var resetZoomButton = new Button { Content = "100%", MinWidth = 72, IsEnabled = imageControl is not null }; + resetZoomButton.Click += (_, _) => + { + if (imageControl is null) + { + return; + } + + zoomLevel = 1.0; + imageControl.RenderTransform = new ScaleTransform(1, 1); + zoomText.Text = "100%"; + }; + toolbar.Children.Add(resetZoomButton); + toolbar.Children.Add(zoomText); + contentGrid.Children.Add(toolbar); + + var previewBorder = new Border + { + BorderBrush = Brushes.Gray, + BorderThickness = new Avalonia.Thickness(1), + Padding = new Avalonia.Thickness(8), + Child = new ScrollViewer + { + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, + Content = bitmap is null + ? new TextBlock + { + Text = "Anteprima non disponibile", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + } + : imageControl + } + }; + Grid.SetRow(previewBorder, 1); + contentGrid.Children.Add(previewBorder); + + var debugBox = new TextBox + { + Text = debugBuilder.ToString(), + IsReadOnly = true, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + FontFamily = new FontFamily("Cascadia Mono, Consolas, monospace"), + MinHeight = 180 + }; + Grid.SetRow(debugBox, 2); + contentGrid.Children.Add(debugBox); + + layout.Children.Add(contentGrid); + + var footer = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8 + }; + Grid.SetRow(footer, 2); + + var openFileButton = new Button { Content = "Apri file" }; + openFileButton.Click += (_, _) => OpenInExplorer(item.ResolvedImagePath); + footer.Children.Add(openFileButton); + + var openFolderButton = new Button { Content = "Apri cartella" }; + openFolderButton.Click += (_, _) => + { + var directory = Path.GetDirectoryName(item.ResolvedImagePath); + OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? item.ResolvedImagePath : directory); + }; + footer.Children.Add(openFolderButton); + + var closeButton = new Button { Content = "Chiudi", MinWidth = 88 }; + closeButton.Click += (_, _) => dialog.Close(); + footer.Children.Add(closeButton); + + layout.Children.Add(footer); + dialog.Content = layout; + return dialog; + } + + private void OpenFromTextBox(string textBoxName) + { + var textBox = this.FindControl(textBoxName); + var path = textBox?.Text?.Trim(); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + if (Directory.Exists(path) || File.Exists(path)) + { + OpenInExplorer(path); + return; + } + + var directory = Path.GetDirectoryName(path); + OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory); + } + + private async Task> OpenFolderPickerAsync(string title, string preferenceKey, string? currentPath) + { + var topLevel = TopLevel.GetTopLevel(this); + var storageProvider = topLevel?.StorageProvider; + if (storageProvider is null) + { + return Array.Empty(); + } + + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, preferenceKey, currentPath); + + return await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = title, + SuggestedStartLocation = suggestedStartLocation + }); + } + + private async Task> OpenFilePickerAsync(string title, IReadOnlyList fileTypes, string preferenceKey, string? currentPath) + { + var topLevel = TopLevel.GetTopLevel(this); + var storageProvider = topLevel?.StorageProvider; + if (storageProvider is null) + { + return Array.Empty(); + } + + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, preferenceKey, currentPath); + + return await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title, + FileTypeFilter = fileTypes, + SuggestedStartLocation = suggestedStartLocation + }); + } + + private async Task SaveFilePickerAsync(string title, string defaultExtension, IReadOnlyList fileTypes, string preferenceKey, string? currentPath) + { + var topLevel = TopLevel.GetTopLevel(this); + var storageProvider = topLevel?.StorageProvider; + if (storageProvider is null) + { + return null; + } + + var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, preferenceKey, currentPath); + + return await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = title, + DefaultExtension = defaultExtension, + FileTypeChoices = fileTypes, + SuggestedStartLocation = suggestedStartLocation + }); + } + private static void OpenInExplorer(string? path) { if (string.IsNullOrWhiteSpace(path)) diff --git a/imagecatalog/Converters/FilePathToBitmapConverter.cs b/imagecatalog/Converters/FilePathToBitmapConverter.cs new file mode 100644 index 0000000..614897d --- /dev/null +++ b/imagecatalog/Converters/FilePathToBitmapConverter.cs @@ -0,0 +1,31 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media.Imaging; + +namespace ImageCatalog_2.Converters; + +public sealed class FilePathToBitmapConverter : IValueConverter +{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string path || string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + return new Bitmap(path); + } + catch + { + return null; + } + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index 66ce8d4..cea5dce 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -13,10 +13,12 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; +using System.Globalization; using System.Threading; using System.Threading.Tasks; using System.Windows.Input; using AIFotoONLUS.Core; +using System.Text.RegularExpressions; using AutoMapper; using MaddoShared; using Microsoft.Extensions.Logging; @@ -41,6 +43,8 @@ namespace ImageCatalog_2 public ICommand StartAiCommand { get; } public ICommand StartFaceEncoderCommand { get; } public ICommand StopFaceEncoderCommand { get; } + public ICommand StartFaceMatcherCommand { get; } + public ICommand StopFaceMatcherCommand { get; } private readonly ITestService _service; private readonly ILogger _logger; @@ -57,13 +61,24 @@ namespace ImageCatalog_2 private readonly IMapper _mapper; private readonly AsyncCommand _startFaceEncoderCommand; private readonly AsyncCommand _stopFaceEncoderCommand; + private readonly AsyncCommand _startFaceMatcherCommand; + private readonly AsyncCommand _stopFaceMatcherCommand; private readonly object _faceEncoderProcessLock = new(); + private readonly object _faceMatcherProcessLock = new(); private Process? _faceEncoderProcess; + private Process? _faceMatcherProcess; private CancellationTokenSource? _faceEncoderWatcherTokenSource; private Task? _faceEncoderWatcherTask; private CancellationTokenSource? _faceEncoderLogWatcherTokenSource; private Task? _faceEncoderLogWatcherTask; + private CancellationTokenSource? _faceMatcherWatcherTokenSource; + private Task? _faceMatcherWatcherTask; + private CancellationTokenSource? _faceMatcherLogWatcherTokenSource; + private Task? _faceMatcherLogWatcherTask; private bool _hasStartedFaceEncoderInSession; + private bool _hasStartedFaceMatcherInSession; + + private sealed record ParsedFaceMatcherRow(string PhotoId, double? Score, string RawRow, string DebugSummary); // ComboBox collections public List AvailableFonts { get; } @@ -105,8 +120,12 @@ namespace ImageCatalog_2 StartAiCommand = new AsyncCommand(StartAiAsync); _startFaceEncoderCommand = new AsyncCommand(RunFaceEncoderAsync, CanRunFaceEncoder); _stopFaceEncoderCommand = new AsyncCommand(() => StopFaceEncoderAsync("Arresto richiesto dall'utente."), CanStopFaceEncoder); + _startFaceMatcherCommand = new AsyncCommand(RunFaceMatcherAsync, CanRunFaceMatcher); + _stopFaceMatcherCommand = new AsyncCommand(() => StopFaceMatcherAsync("Arresto richiesto dall'utente."), CanStopFaceMatcher); StartFaceEncoderCommand = _startFaceEncoderCommand; StopFaceEncoderCommand = _stopFaceEncoderCommand; + StartFaceMatcherCommand = _startFaceMatcherCommand; + StopFaceMatcherCommand = _stopFaceMatcherCommand; SelectSourceFolderCommand = new RelayCommand(SelectSourceFolder); SelectDestinationFolderCommand = new RelayCommand(SelectDestinationFolder); @@ -359,6 +378,62 @@ namespace ImageCatalog_2 private set => _ai.FaceCommandOutput = value; } + public string FaceMatcherExecutablePath + { + get => _ai.FaceMatcherExecutablePath; + set => _ai.FaceMatcherExecutablePath = value ?? string.Empty; + } + + public string FaceMatcherEncodingsPath + { + get => _ai.FaceMatcherEncodingsPath; + set => _ai.FaceMatcherEncodingsPath = value ?? string.Empty; + } + + public string FaceMatcherOutputPath + { + get => _ai.FaceMatcherOutputPath; + set => _ai.FaceMatcherOutputPath = value ?? string.Empty; + } + + public string FaceMatcherLogPath + { + get => _ai.FaceMatcherLogPath; + set => _ai.FaceMatcherLogPath = value ?? string.Empty; + } + + public double FaceMatcherTolerance + { + get => _ai.FaceMatcherTolerance; + set => _ai.FaceMatcherTolerance = NormalizeFaceMatcherTolerance(value); + } + + public string FaceMatcherSelectedImagePath + { + get => _ai.FaceMatcherSelectedImagePath; + set => _ai.FaceMatcherSelectedImagePath = value ?? string.Empty; + } + + public bool IsFaceMatcherRunning + { + get => _ai.IsFaceMatcherRunning; + private set => _ai.IsFaceMatcherRunning = value; + } + + public string FaceMatcherStatusMessage + { + get => _ai.FaceMatcherStatusMessage; + private set => _ai.FaceMatcherStatusMessage = value; + } + + public string FaceMatcherCommandOutput + { + get => _ai.FaceMatcherCommandOutput; + private set => _ai.FaceMatcherCommandOutput = value; + } + + public System.Collections.ObjectModel.ObservableCollection FaceMatcherResults => _ai.FaceMatcherResults; + // Race upload settings public string ApiLogin { @@ -576,6 +651,7 @@ namespace ImageCatalog_2 NotifyPropertyChanged(e.PropertyName); UpdateFaceEncoderCommandStates(); + UpdateFaceMatcherCommandStates(); } private void OnRaceUploadPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -1528,6 +1604,12 @@ namespace ImageCatalog_2 _stopFaceEncoderCommand?.RaiseCanExecuteChanged(); } + private void UpdateFaceMatcherCommandStates() + { + _startFaceMatcherCommand?.RaiseCanExecuteChanged(); + _stopFaceMatcherCommand?.RaiseCanExecuteChanged(); + } + private async Task RunFaceEncoderAsync() { if (IsFaceEncoderRunning) @@ -1693,6 +1775,252 @@ namespace ImageCatalog_2 } } + private bool CanRunFaceMatcher() + { + return !IsFaceMatcherRunning; + } + + private bool CanStopFaceMatcher() + { + return IsFaceMatcherRunning; + } + + private async Task RunFaceMatcherAsync() + { + if (IsFaceMatcherRunning) + { + FaceMatcherStatusMessage = "Face matcher gia in esecuzione."; + return; + } + + var executablePath = ResolveConfiguredFaceMatcherExecutablePath(NormalizeFilePathArgument(FaceMatcherExecutablePath), NormalizeFilePathArgument(FaceExecutablePath)); + if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath)) + { + FaceMatcherStatusMessage = "Percorso face_matcher.exe non valido."; + return; + } + + var searchImagePath = NormalizeFilePathArgument(FaceMatcherSelectedImagePath); + if (string.IsNullOrWhiteSpace(searchImagePath) || !File.Exists(searchImagePath)) + { + FaceMatcherStatusMessage = "Seleziona un'immagine valida per il match."; + return; + } + + var encodingsPath = ResolveConfiguredFaceMatcherEncodingsPath(NormalizeFilePathArgument(FaceMatcherEncodingsPath), NormalizeDirectoryPathArgument(FaceOutputFolderPath)); + if (string.IsNullOrWhiteSpace(encodingsPath) || !File.Exists(encodingsPath)) + { + FaceMatcherStatusMessage = "File encodings .pkl non trovato."; + return; + } + + var fallbackOutputRoot = ResolveFaceMatcherFallbackOutputRoot(NormalizeFilePathArgument(FaceMatcherOutputPath), NormalizeFilePathArgument(FaceMatcherLogPath), NormalizeDirectoryPathArgument(FaceOutputFolderPath), executablePath); + var outputPaths = ResolveFaceMatcherOutputPaths(FaceMatcherOutputPath, FaceMatcherLogPath, fallbackOutputRoot, searchImagePath, DateTime.Now); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(outputPaths.CsvPath) ?? fallbackOutputRoot); + Directory.CreateDirectory(Path.GetDirectoryName(outputPaths.LogPath) ?? fallbackOutputRoot); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to create face matcher output directory."); + FaceMatcherStatusMessage = "Impossibile creare cartelle output/log del matcher."; + return; + } + + var tolerance = NormalizeFaceMatcherTolerance(FaceMatcherTolerance); + FaceMatcherExecutablePath = executablePath; + FaceMatcherEncodingsPath = encodingsPath; + FaceMatcherOutputPath = outputPaths.CsvPath; + FaceMatcherLogPath = outputPaths.LogPath; + + await InvokeOnUiThreadAsync(() => + { + FaceMatcherResults.Clear(); + FaceMatcherCommandOutput = string.Empty; + FaceMatcherStatusMessage = "Esecuzione face matcher in corso..."; + }).ConfigureAwait(false); + + var transcriptLines = new StringBuilder(); + var outputLines = new StringBuilder(); + var errorLines = new StringBuilder(); + + try + { + var processStartInfo = new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory, + UseShellExecute = false, + RedirectStandardOutput = false, + RedirectStandardError = false, + RedirectStandardInput = false, + CreateNoWindow = true, + }; + + processStartInfo.Environment["PYTHONUTF8"] = "1"; + processStartInfo.Environment["PYTHONIOENCODING"] = "utf-8"; + + processStartInfo.ArgumentList.Add("--image"); + processStartInfo.ArgumentList.Add(searchImagePath); + processStartInfo.ArgumentList.Add("--encodings"); + processStartInfo.ArgumentList.Add(encodingsPath); + processStartInfo.ArgumentList.Add("--out"); + processStartInfo.ArgumentList.Add(outputPaths.CsvPath); + processStartInfo.ArgumentList.Add("--log"); + processStartInfo.ArgumentList.Add(outputPaths.LogPath); + processStartInfo.ArgumentList.Add("--tolerance"); + processStartInfo.ArgumentList.Add(tolerance.ToString("0.##", CultureInfo.InvariantCulture)); + + using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true }; + process.Exited += (_, _) => + { + _ = InvokeOnUiThreadAsync(() => + { + if (!ComputeIsFaceMatcherRunning()) + { + IsFaceMatcherRunning = false; + } + }); + }; + + if (!process.Start()) + { + throw new InvalidOperationException("Avvio face matcher fallito."); + } + + _hasStartedFaceMatcherInSession = true; + EnsureFaceMatcherWatcherStarted(); + TrackFaceMatcherProcess(process); + await InvokeOnUiThreadAsync(() => IsFaceMatcherRunning = true).ConfigureAwait(false); + + StartFaceMatcherLogWatcher(outputPaths.LogPath, outputLines, transcriptLines); + await process.WaitForExitAsync().ConfigureAwait(false); + + var isNoFacesRun = process.ExitCode == 1 && await LogIndicatesNoFacesAsync(outputPaths.LogPath).ConfigureAwait(false); + if (process.ExitCode == 0 || isNoFacesRun) + { + var parsedRows = await ParseFaceMatcherCsvAsync(outputPaths.CsvPath, outputPaths.LogPath).ConfigureAwait(false); + var resolvedPaths = ResolveDestinationImagesByFileName(DestinationPath, parsedRows.Select(row => row.PhotoId)); + await InvokeOnUiThreadAsync(() => + { + FaceMatcherResults.Clear(); + foreach (var row in parsedRows) + { + resolvedPaths.TryGetValue(row.PhotoId, out var candidates); + candidates ??= []; + + FaceMatcherResults.Add(new FaceMatcherResultItem + { + PhotoId = row.PhotoId, + Score = row.Score, + ResolvedImagePath = candidates.FirstOrDefault() ?? string.Empty, + CandidateCount = candidates.Count, + RawRow = row.RawRow, + DebugSummary = row.DebugSummary, + SearchImagePath = searchImagePath, + CsvPath = outputPaths.CsvPath, + LogPath = outputPaths.LogPath + }); + } + }).ConfigureAwait(false); + } + + var summary = BuildFaceMatcherSummary(process.ExitCode, processStartInfo, outputPaths.CsvPath, outputPaths.LogPath, outputLines, errorLines, FaceMatcherResults.Count); + await InvokeOnUiThreadAsync(() => + { + FaceMatcherCommandOutput = string.IsNullOrWhiteSpace(FaceMatcherCommandOutput) + ? summary + : $"{FaceMatcherCommandOutput.TrimEnd()}\n\n{summary}"; + FaceMatcherStatusMessage = process.ExitCode switch + { + 0 when FaceMatcherResults.Count > 0 => $"Face matcher completato: {FaceMatcherResults.Count} match.", + 0 => "Face matcher completato senza match.", + 1 when isNoFacesRun => "Face matcher completato: nessun volto rilevato nell'immagine di ricerca.", + _ => $"Face matcher terminato con errore (code {process.ExitCode})." + }; + }).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine(ex); + _logger.LogError(ex, "Face matcher execution failed."); + await InvokeOnUiThreadAsync(() => + { + FaceMatcherCommandOutput = ex.ToString(); + FaceMatcherStatusMessage = "Errore durante esecuzione face matcher."; + }).ConfigureAwait(false); + } + finally + { + await StopFaceMatcherLogWatcherAsync().ConfigureAwait(false); + ClearTrackedFaceMatcherProcess(); + await InvokeOnUiThreadAsync(() => IsFaceMatcherRunning = ComputeIsFaceMatcherRunning()).ConfigureAwait(false); + } + } + + public async Task StopFaceMatcherAsync(string reason, bool waitForExit = true) + { + var trackedProcess = GetTrackedFaceMatcherProcess(); + Process? process = null; + + if (trackedProcess is not null) + { + process = trackedProcess; + } + else if (_hasStartedFaceMatcherInSession) + { + process = FindConfiguredFaceMatcherProcess(); + } + + if (process is null) + { + await StopFaceMatcherLogWatcherAsync().ConfigureAwait(false); + await InvokeOnUiThreadAsync(() => + { + IsFaceMatcherRunning = false; + FaceMatcherStatusMessage = "Face matcher non in esecuzione."; + }).ConfigureAwait(false); + return; + } + + using (process) + { + var gracefulStopRequested = TryRequestFaceMatcherStop(process); + var exited = !IsProcessAlive(process); + + if (!exited && waitForExit) + { + exited = await WaitForProcessExitAsync(process, TimeSpan.FromSeconds(5)).ConfigureAwait(false); + } + + if (!exited) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to terminate face matcher process {ProcessId}", process.Id); + } + } + + await StopFaceMatcherLogWatcherAsync().ConfigureAwait(false); + ClearTrackedFaceMatcherProcess(); + await InvokeOnUiThreadAsync(() => + { + IsFaceMatcherRunning = !exited && IsProcessAlive(process); + FaceMatcherStatusMessage = exited + ? "Face matcher arrestato." + : gracefulStopRequested + ? "Segnale di arresto inviato al face matcher." + : "Arresto forzato del face matcher richiesto."; + }).ConfigureAwait(false); + } + } + private async Task WatchFaceEncoderProcessAsync(CancellationToken token) { using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); @@ -1719,6 +2047,32 @@ namespace ImageCatalog_2 } } + private async Task WatchFaceMatcherProcessAsync(CancellationToken token) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + if (!_hasStartedFaceMatcherInSession) + { + continue; + } + + var isRunning = ComputeIsFaceMatcherRunning(); + if (isRunning != IsFaceMatcherRunning) + { + await InvokeOnUiThreadAsync(() => IsFaceMatcherRunning = isRunning).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // App shutdown. + } + } + private void EnsureFaceEncoderWatcherStarted() { if (_faceEncoderWatcherTask is not null) @@ -1730,6 +2084,17 @@ namespace ImageCatalog_2 _faceEncoderWatcherTask = WatchFaceEncoderProcessAsync(_faceEncoderWatcherTokenSource.Token); } + private void EnsureFaceMatcherWatcherStarted() + { + if (_faceMatcherWatcherTask is not null) + { + return; + } + + _faceMatcherWatcherTokenSource = new CancellationTokenSource(); + _faceMatcherWatcherTask = WatchFaceMatcherProcessAsync(_faceMatcherWatcherTokenSource.Token); + } + private void StartFaceEncoderLogWatcher(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines) { _faceEncoderLogWatcherTokenSource?.Cancel(); @@ -1739,6 +2104,15 @@ namespace ImageCatalog_2 _faceEncoderLogWatcherTask = WatchFaceEncoderLogFileAsync(logFilePath, outputLines, transcriptLines, _faceEncoderLogWatcherTokenSource.Token); } + private void StartFaceMatcherLogWatcher(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines) + { + _faceMatcherLogWatcherTokenSource?.Cancel(); + _faceMatcherLogWatcherTokenSource?.Dispose(); + + _faceMatcherLogWatcherTokenSource = new CancellationTokenSource(); + _faceMatcherLogWatcherTask = WatchFaceMatcherLogFileAsync(logFilePath, outputLines, transcriptLines, _faceMatcherLogWatcherTokenSource.Token); + } + private async Task StopFaceEncoderLogWatcherAsync() { var tokenSource = _faceEncoderLogWatcherTokenSource; @@ -1770,6 +2144,37 @@ namespace ImageCatalog_2 } } + private async Task StopFaceMatcherLogWatcherAsync() + { + var tokenSource = _faceMatcherLogWatcherTokenSource; + var task = _faceMatcherLogWatcherTask; + + _faceMatcherLogWatcherTokenSource = null; + _faceMatcherLogWatcherTask = null; + + if (tokenSource is null) + { + return; + } + + try + { + await tokenSource.CancelAsync().ConfigureAwait(false); + if (task is not null) + { + await task.ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Expected when shutting down the watcher. + } + finally + { + tokenSource.Dispose(); + } + } + private async Task WatchFaceEncoderLogFileAsync(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines, CancellationToken token) { long filePosition = 0; @@ -1814,6 +2219,50 @@ namespace ImageCatalog_2 } } + private async Task WatchFaceMatcherLogFileAsync(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines, CancellationToken token) + { + long filePosition = 0; + + while (!token.IsCancellationRequested) + { + try + { + if (File.Exists(logFilePath)) + { + using var stream = new FileStream(logFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (filePosition > stream.Length) + { + filePosition = 0; + } + + stream.Seek(filePosition, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + var line = await reader.ReadLineAsync(token).ConfigureAwait(false); + AppendFaceMatcherProcessOutput(outputLines, transcriptLines, line, isError: false); + } + + filePosition = stream.Position; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (IOException) + { + // Retry while the matcher is still writing. + } + catch (UnauthorizedAccessException) + { + // Retry if the file is transiently locked. + } + + await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false); + } + } + private void RefreshFaceExecutableCapabilities() { var executableRoot = NormalizeFilePathArgument(_ai.FaceExecutablePath); @@ -1927,6 +2376,14 @@ namespace ImageCatalog_2 } } + private void TrackFaceMatcherProcess(Process process) + { + lock (_faceMatcherProcessLock) + { + _faceMatcherProcess = process; + } + } + private void ClearTrackedFaceEncoderProcess() { lock (_faceEncoderProcessLock) @@ -1941,6 +2398,20 @@ namespace ImageCatalog_2 } } + private void ClearTrackedFaceMatcherProcess() + { + lock (_faceMatcherProcessLock) + { + if (_faceMatcherProcess is not null && _faceMatcherProcess.HasExited) + { + _faceMatcherProcess = null; + return; + } + + _faceMatcherProcess = null; + } + } + private Process? GetTrackedFaceEncoderProcess() { lock (_faceEncoderProcessLock) @@ -1960,6 +2431,25 @@ namespace ImageCatalog_2 } } + private Process? GetTrackedFaceMatcherProcess() + { + lock (_faceMatcherProcessLock) + { + if (_faceMatcherProcess is null) + { + return null; + } + + if (_faceMatcherProcess.HasExited) + { + _faceMatcherProcess = null; + return null; + } + + return _faceMatcherProcess; + } + } + private Process? FindConfiguredFaceEncoderProcess() { var configuredExecutablePath = ResolveConfiguredFaceEncoderExecutablePath(FaceExecutablePath, UseFaceGpu); @@ -1988,6 +2478,34 @@ namespace ImageCatalog_2 return null; } + private Process? FindConfiguredFaceMatcherProcess() + { + var configuredExecutablePath = ResolveConfiguredFaceMatcherExecutablePath(FaceMatcherExecutablePath, FaceExecutablePath); + if (string.IsNullOrWhiteSpace(configuredExecutablePath)) + { + return null; + } + + var processName = Path.GetFileNameWithoutExtension(configuredExecutablePath); + foreach (var process in Process.GetProcessesByName(processName)) + { + if (!IsProcessAlive(process)) + { + process.Dispose(); + continue; + } + + if (IsMatchingProcessPath(process, configuredExecutablePath)) + { + return process; + } + + process.Dispose(); + } + + return null; + } + private bool ComputeIsFaceEncoderRunning() { var trackedProcess = GetTrackedFaceEncoderProcess(); @@ -2005,6 +2523,23 @@ namespace ImageCatalog_2 return discoveredProcess is not null; } + private bool ComputeIsFaceMatcherRunning() + { + var trackedProcess = GetTrackedFaceMatcherProcess(); + if (trackedProcess is not null) + { + return true; + } + + if (!_hasStartedFaceMatcherInSession) + { + return false; + } + + using var discoveredProcess = FindConfiguredFaceMatcherProcess(); + return discoveredProcess is not null; + } + private static bool IsProcessAlive(Process process) { try @@ -2056,6 +2591,31 @@ namespace ImageCatalog_2 } } + private bool TryRequestFaceMatcherStop(Process process) + { + if (!IsProcessAlive(process)) + { + return true; + } + +#if WINDOWS + if (Program.TrySendConsoleInterrupt(process.Id)) + { + return true; + } +#endif + + try + { + return process.CloseMainWindow(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Unable to request graceful stop for face matcher process {ProcessId}", process.Id); + return false; + } + } + private static async Task WaitForProcessExitAsync(Process process, TimeSpan timeout) { if (!IsProcessAlive(process)) @@ -2111,6 +2671,88 @@ namespace ImageCatalog_2 _ = InvokeOnUiThreadAsync(() => FaceCommandOutput = transcript); } + private void AppendFaceMatcherProcessOutput(StringBuilder builder, StringBuilder transcriptBuilder, string? line, bool isError) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + + lock (builder) + { + builder.AppendLine(line); + } + + if (isError) + { + Console.Error.WriteLine(line); + } + else + { + Console.WriteLine(line); + } + + string transcript; + lock (transcriptBuilder) + { + if (isError) + { + transcriptBuilder.Append("[stderr] "); + } + + transcriptBuilder.AppendLine(line); + transcript = transcriptBuilder.ToString(); + } + + _ = InvokeOnUiThreadAsync(() => FaceMatcherCommandOutput = transcript); + } + + internal static string? ResolveConfiguredFaceMatcherExecutablePath(string configuredPath, string fallbackEncoderPath) + { + foreach (var candidate in EnumerateFaceMatcherExecutableCandidates(configuredPath, fallbackEncoderPath)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + + return null; + } + + internal static string? ResolveConfiguredFaceMatcherEncodingsPath(string configuredPath, string fallbackOutputFolderPath) + { + var normalizedPath = NormalizeFilePathArgument(configuredPath); + if (File.Exists(normalizedPath)) + { + return normalizedPath; + } + + if (Directory.Exists(normalizedPath)) + { + var fromDirectory = new DirectoryInfo(normalizedPath) + .EnumerateFiles("*.pkl", SearchOption.TopDirectoryOnly) + .OrderByDescending(file => file.LastWriteTimeUtc) + .FirstOrDefault(); + if (fromDirectory is not null) + { + return fromDirectory.FullName; + } + } + + var fallbackOutputFolder = NormalizeDirectoryPathArgument(fallbackOutputFolderPath); + if (Directory.Exists(fallbackOutputFolder)) + { + var latest = new DirectoryInfo(fallbackOutputFolder) + .EnumerateFiles("*.pkl", SearchOption.TopDirectoryOnly) + .OrderByDescending(file => file.LastWriteTimeUtc) + .FirstOrDefault(); + return latest?.FullName; + } + + return null; + } + internal static string? ResolveConfiguredFaceEncoderExecutablePath(string configuredPath, bool useGpu) { var variant = useGpu ? "gpu" : "cpu"; @@ -2171,6 +2813,37 @@ namespace ImageCatalog_2 }; } + internal static (string CsvPath, string LogPath) ResolveFaceMatcherOutputPaths(string configuredCsvPath, string configuredLogPath, string fallbackRootPath, string imagePath, DateTime timestamp) + { + var baseName = BuildSafeFaceMatcherImageName(imagePath); + var timestampToken = timestamp.ToString("yyyyMMdd_HHmmss"); + var csvPath = ResolveFaceMatcherOutputFilePath(configuredCsvPath, fallbackRootPath, $"result_{timestampToken}_{baseName}.csv"); + var logPath = ResolveFaceMatcherOutputFilePath(configuredLogPath, fallbackRootPath, $"matcher_log_{timestampToken}_{baseName}.txt"); + return (csvPath, logPath); + } + + internal static string BuildSafeFaceMatcherImageName(string imagePath) + { + var fileName = Path.GetFileNameWithoutExtension(imagePath); + if (string.IsNullOrWhiteSpace(fileName)) + { + return "image"; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(fileName.Length); + foreach (var currentChar in fileName) + { + builder.Append(invalidChars.Contains(currentChar) || char.IsWhiteSpace(currentChar) ? '_' : currentChar); + } + + return builder.ToString().Trim('_') switch + { + "" => "image", + var sanitized => sanitized + }; + } + private static IEnumerable EnumerateFaceEncoderExecutableCandidates(string configuredPath, string variant) { var normalizedPath = NormalizeFilePathArgument(configuredPath); @@ -2214,11 +2887,50 @@ namespace ImageCatalog_2 } } + private static IEnumerable EnumerateFaceMatcherExecutableCandidates(string configuredPath, string fallbackEncoderPath) + { + var normalizedPath = NormalizeFilePathArgument(configuredPath); + if (!string.IsNullOrWhiteSpace(normalizedPath)) + { + if (File.Exists(normalizedPath)) + { + yield return normalizedPath; + } + + yield return Path.Combine(normalizedPath, "face_matcher.exe"); + } + + var fallbackPath = NormalizeFilePathArgument(fallbackEncoderPath); + if (!string.IsNullOrWhiteSpace(fallbackPath)) + { + if (File.Exists(fallbackPath)) + { + var fileDirectory = Path.GetDirectoryName(fallbackPath); + if (!string.IsNullOrWhiteSpace(fileDirectory)) + { + yield return Path.Combine(fileDirectory, "face_matcher.exe"); + } + } + + yield return Path.Combine(fallbackPath, "face_matcher.exe"); + } + } + private static int NormalizeFaceParallelism(int value) { return value is >= 1 and <= 5 ? value : 3; } + private static double NormalizeFaceMatcherTolerance(double value) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + return 0.5; + } + + return Math.Clamp(Math.Round(value, 2), 0.35, 0.75); + } + private static int NormalizeNumberAiWorkloadLevel(int value) { return value is >= 1 and <= 5 ? value : 3; @@ -2296,6 +3008,299 @@ namespace ImageCatalog_2 return summary.ToString(); } + private static string BuildFaceMatcherSummary( + int exitCode, + ProcessStartInfo processStartInfo, + string csvPath, + string logFilePath, + StringBuilder outputLines, + StringBuilder errorLines, + int matchCount) + { + var summary = new StringBuilder(); + summary.AppendLine($"Exit code: {exitCode}"); + summary.AppendLine($"Command: {processStartInfo.FileName} {string.Join(" ", processStartInfo.ArgumentList)}"); + summary.AppendLine($"Result CSV: {csvPath}"); + summary.AppendLine($"Log file: {logFilePath}"); + summary.AppendLine($"Match count: {matchCount}"); + + lock (outputLines) + { + if (outputLines.Length > 0) + { + summary.AppendLine(); + summary.AppendLine("STDOUT:"); + summary.Append(outputLines); + } + } + + lock (errorLines) + { + if (errorLines.Length > 0) + { + summary.AppendLine(); + summary.AppendLine("STDERR:"); + summary.Append(errorLines); + } + } + + return summary.ToString(); + } + + private static string ResolveFaceMatcherFallbackOutputRoot(string configuredCsvPath, string configuredLogPath, string faceOutputFolderPath, string executablePath) + { + foreach (var candidate in new[] { NormalizeFilePathArgument(configuredCsvPath), NormalizeFilePathArgument(configuredLogPath), NormalizeDirectoryPathArgument(faceOutputFolderPath) }) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + continue; + } + + if (Directory.Exists(candidate)) + { + return candidate; + } + + var directory = Path.GetDirectoryName(candidate); + if (!string.IsNullOrWhiteSpace(directory)) + { + return directory; + } + } + + return Path.Combine(Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory, "output"); + } + + private static string ResolveFaceMatcherOutputFilePath(string configuredPath, string fallbackRootPath, string defaultFileName) + { + var normalized = NormalizeFilePathArgument(configuredPath); + if (string.IsNullOrWhiteSpace(normalized)) + { + return Path.Combine(fallbackRootPath, defaultFileName); + } + + if (Directory.Exists(normalized) || string.IsNullOrWhiteSpace(Path.GetExtension(normalized))) + { + return Path.Combine(normalized, defaultFileName); + } + + return normalized; + } + + private async Task LogIndicatesNoFacesAsync(string logPath) + { + try + { + if (!File.Exists(logPath)) + { + return false; + } + + var content = await File.ReadAllTextAsync(logPath).ConfigureAwait(false); + return content.Contains("nessun volt", StringComparison.OrdinalIgnoreCase) + || content.Contains("no face", StringComparison.OrdinalIgnoreCase) + || content.Contains("0 faces", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + + private static async Task> ParseFaceMatcherCsvAsync(string csvPath, string logPath) + { + var parsedRows = new List(); + if (!File.Exists(csvPath)) + { + return parsedRows; + } + + var logScores = await ParseFaceMatcherScoresFromLogAsync(logPath).ConfigureAwait(false); + + var lines = await File.ReadAllLinesAsync(csvPath).ConfigureAwait(false); + var meaningfulLines = lines.Where(line => !string.IsNullOrWhiteSpace(line)).ToArray(); + if (meaningfulLines.Length == 0) + { + return parsedRows; + } + + string[]? headers = null; + var firstCells = ParseCsvLine(meaningfulLines[0]); + var hasHeader = firstCells.Any(cell => cell.Contains("file", StringComparison.OrdinalIgnoreCase) + || cell.Contains("image", StringComparison.OrdinalIgnoreCase) + || cell.Contains("score", StringComparison.OrdinalIgnoreCase) + || cell.Contains("distance", StringComparison.OrdinalIgnoreCase) + || cell.Contains("confidence", StringComparison.OrdinalIgnoreCase)); + + var startIndex = 0; + if (hasHeader) + { + headers = firstCells; + startIndex = 1; + } + + for (var index = startIndex; index < meaningfulLines.Length; index++) + { + var rawLine = meaningfulLines[index].Trim(); + var cells = ParseCsvLine(rawLine); + if (cells.Length == 0 || string.IsNullOrWhiteSpace(cells[0])) + { + continue; + } + + var photoId = Path.GetFileName(cells[0]); + double? score = null; + for (var cellIndex = 1; cellIndex < cells.Length; cellIndex++) + { + if (double.TryParse(cells[cellIndex], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedScore)) + { + score = parsedScore; + break; + } + } + + if (!score.HasValue && logScores.TryGetValue(photoId, out var logScore)) + { + score = logScore; + } + + var debugParts = new List(); + for (var cellIndex = 1; cellIndex < cells.Length; cellIndex++) + { + var cellValue = cells[cellIndex]; + if (string.IsNullOrWhiteSpace(cellValue)) + { + continue; + } + + var header = headers is not null && cellIndex < headers.Length + ? headers[cellIndex] + : $"col{cellIndex + 1}"; + debugParts.Add($"{header}: {cellValue}"); + } + + if (score.HasValue) + { + debugParts.Add($"score: {score.Value.ToString("0.###", CultureInfo.InvariantCulture)}"); + } + + parsedRows.Add(new ParsedFaceMatcherRow(photoId, score, rawLine, string.Join(" | ", debugParts))); + } + + return parsedRows; + } + + private static async Task> ParseFaceMatcherScoresFromLogAsync(string logPath) + { + var scores = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!File.Exists(logPath)) + { + return scores; + } + + var lines = await File.ReadAllLinesAsync(logPath).ConfigureAwait(false); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var match = Regex.Match( + line, + @"in\s+(?.+?)\s+-\s+\[Somiglianza:\s*(?[0-9]+(?:[\.,][0-9]+)?)%\]", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + if (!match.Success) + { + continue; + } + + var pathValue = match.Groups["path"].Value.Trim(); + var photoId = Path.GetFileName(pathValue); + if (string.IsNullOrWhiteSpace(photoId)) + { + continue; + } + + var scoreText = match.Groups["score"].Value.Replace(',', '.'); + if (!double.TryParse(scoreText, NumberStyles.Float, CultureInfo.InvariantCulture, out var score)) + { + continue; + } + + if (!scores.TryGetValue(photoId, out var existingScore) || score > existingScore) + { + scores[photoId] = score; + } + } + + return scores; + } + + private static string[] ParseCsvLine(string line) + { + var values = new List(); + var current = new StringBuilder(); + var insideQuotes = false; + + foreach (var currentChar in line) + { + if (currentChar == '"') + { + insideQuotes = !insideQuotes; + continue; + } + + if (currentChar == ',' && !insideQuotes) + { + values.Add(current.ToString().Trim()); + current.Clear(); + continue; + } + + current.Append(currentChar); + } + + values.Add(current.ToString().Trim()); + return values.ToArray(); + } + + private static Dictionary> ResolveDestinationImagesByFileName(string destinationRoot, IEnumerable fileNames) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var normalizedRoot = NormalizeDirectoryPathArgument(destinationRoot); + if (string.IsNullOrWhiteSpace(normalizedRoot) || !Directory.Exists(normalizedRoot)) + { + return result; + } + + var requestedNames = new HashSet(fileNames.Where(name => !string.IsNullOrWhiteSpace(name)).Select(name => Path.GetFileName(name)), StringComparer.OrdinalIgnoreCase); + if (requestedNames.Count == 0) + { + return result; + } + + foreach (var path in Directory.EnumerateFiles(normalizedRoot, "*", SearchOption.AllDirectories)) + { + var fileName = Path.GetFileName(path); + if (!requestedNames.Contains(fileName)) + { + continue; + } + + if (!result.TryGetValue(fileName, out var list)) + { + list = new List(); + result[fileName] = list; + } + + list.Add(path); + } + + return result; + } + private static string NormalizeDirectoryPathArgument(string value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/imagecatalog/Models/FaceMatcherResultItem.cs b/imagecatalog/Models/FaceMatcherResultItem.cs new file mode 100644 index 0000000..15914e0 --- /dev/null +++ b/imagecatalog/Models/FaceMatcherResultItem.cs @@ -0,0 +1,24 @@ +namespace ImageCatalog_2.Models; + +public sealed class FaceMatcherResultItem +{ + public string PhotoId { get; init; } = string.Empty; + + public double? Score { get; init; } + + public string ScoreDisplay => Score.HasValue ? Score.Value.ToString("0.###") : string.Empty; + + public string ResolvedImagePath { get; init; } = string.Empty; + + public int CandidateCount { get; init; } + + public string RawRow { get; init; } = string.Empty; + + public string DebugSummary { get; init; } = string.Empty; + + public string SearchImagePath { get; init; } = string.Empty; + + public string CsvPath { get; init; } = string.Empty; + + public string LogPath { get; init; } = string.Empty; +} diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index e9de47f..00c1783 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -314,6 +314,14 @@ namespace ImageCatalog_2.Models [XmlElement("AI_FaceUpsample")] public bool FaceUpsample { get; set; } = true; + [JsonPropertyName("FaceMatcherExecutablePath")] + [XmlElement("AI_FaceMatcherExecutablePath")] + public string FaceMatcherExecutablePath { get; set; } = string.Empty; + + [JsonPropertyName("FaceMatcherTolerance")] + [XmlElement("AI_FaceMatcherTolerance")] + public double FaceMatcherTolerance { get; set; } = 0.5; + // Race upload settings [JsonPropertyName("ApiLogin")] [XmlElement("RaceUpload_Login")] diff --git a/imagecatalog/Program.cs b/imagecatalog/Program.cs index 2a350bd..798a03d 100644 --- a/imagecatalog/Program.cs +++ b/imagecatalog/Program.cs @@ -154,6 +154,7 @@ static class Program var userPrefsPath = System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ImageCatalog", "userprefs.xml"); services.AddSingleton(new ParametriSetup(userPrefsPath)); + services.AddSingleton(); services.AddSingleton(); services.AddCatalogCommunication(options => diff --git a/imagecatalog/Services/PickerPreferenceService.cs b/imagecatalog/Services/PickerPreferenceService.cs new file mode 100644 index 0000000..26f7033 --- /dev/null +++ b/imagecatalog/Services/PickerPreferenceService.cs @@ -0,0 +1,93 @@ +using Avalonia.Platform.Storage; +using ImageCatalog; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace ImageCatalog_2.Services; + +public static class PickerPreferenceKeys +{ + public const string SourceFolder = "Picker.SourceFolder.LastPath"; + public const string DestinationFolder = "Picker.DestinationFolder.LastPath"; + public const string LogoFile = "Picker.LogoFile.LastPath"; + public const string ModelsFolder = "Picker.ModelsFolder.LastPath"; + public const string CsvOutput = "Picker.CsvOutput.LastPath"; + public const string FaceExecutableFolder = "Picker.FaceExecutableFolder.LastPath"; + public const string FaceOutputFolder = "Picker.FaceOutputFolder.LastPath"; + public const string FaceMatcherExecutable = "Picker.FaceMatcherExecutable.LastPath"; + public const string FaceMatcherImage = "Picker.FaceMatcherImage.LastPath"; + public const string FaceMatcherEncodings = "Picker.FaceMatcherEncodings.LastPath"; + public const string FaceMatcherOutput = "Picker.FaceMatcherOutput.LastPath"; + public const string FaceMatcherLog = "Picker.FaceMatcherLog.LastPath"; +} + +public sealed class PickerPreferenceService +{ + private readonly ParametriSetup _userPreferences; + + public PickerPreferenceService(ParametriSetup userPreferences) + { + _userPreferences = userPreferences; + } + + public async Task TryGetStartFolderAsync(IStorageProvider storageProvider, string preferenceKey, string? currentPath = null) + { + var startPath = GetPreferredStartDirectory(preferenceKey, currentPath); + if (string.IsNullOrWhiteSpace(startPath)) + { + return null; + } + + try + { + return await storageProvider.TryGetFolderFromPathAsync(new Uri(startPath)).ConfigureAwait(true); + } + catch + { + return null; + } + } + + public void RememberPath(string preferenceKey, string? selectedPath) + { + var directory = TryGetExistingDirectory(selectedPath); + if (string.IsNullOrWhiteSpace(directory)) + { + return; + } + + _userPreferences.AggiornaParametro(preferenceKey, directory); + _userPreferences.SalvaParametriSetup(); + } + + private string? GetPreferredStartDirectory(string preferenceKey, string? currentPath) + { + var storedPath = _userPreferences.LeggiParametroString(preferenceKey); + return TryGetExistingDirectory(storedPath) ?? TryGetExistingDirectory(currentPath); + } + + private static string? TryGetExistingDirectory(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var normalizedPath = path.Trim().Trim('"'); + if (Directory.Exists(normalizedPath)) + { + return normalizedPath; + } + + if (File.Exists(normalizedPath)) + { + return Path.GetDirectoryName(normalizedPath); + } + + var containingDirectory = Path.GetDirectoryName(normalizedPath); + return !string.IsNullOrWhiteSpace(containingDirectory) && Directory.Exists(containingDirectory) + ? containingDirectory + : null; + } +} \ No newline at end of file diff --git a/imagecatalog/ViewModels/AiSettingsViewModel.cs b/imagecatalog/ViewModels/AiSettingsViewModel.cs index 501cf5e..e38213c 100644 --- a/imagecatalog/ViewModels/AiSettingsViewModel.cs +++ b/imagecatalog/ViewModels/AiSettingsViewModel.cs @@ -225,6 +225,105 @@ public class AiSettingsViewModel : ViewModelBase } } + private string _faceMatcherExecutablePath = string.Empty; + public string FaceMatcherExecutablePath + { + get => _faceMatcherExecutablePath; + set + { + _faceMatcherExecutablePath = value; + NotifyPropertyChanged(); + } + } + + private string _faceMatcherEncodingsPath = string.Empty; + public string FaceMatcherEncodingsPath + { + get => _faceMatcherEncodingsPath; + set + { + _faceMatcherEncodingsPath = value; + NotifyPropertyChanged(); + } + } + + private string _faceMatcherOutputPath = string.Empty; + public string FaceMatcherOutputPath + { + get => _faceMatcherOutputPath; + set + { + _faceMatcherOutputPath = value; + NotifyPropertyChanged(); + } + } + + private string _faceMatcherLogPath = string.Empty; + public string FaceMatcherLogPath + { + get => _faceMatcherLogPath; + set + { + _faceMatcherLogPath = value; + NotifyPropertyChanged(); + } + } + + private double _faceMatcherTolerance = 0.5; + public double FaceMatcherTolerance + { + get => _faceMatcherTolerance; + set + { + _faceMatcherTolerance = value; + NotifyPropertyChanged(); + } + } + + private string _faceMatcherSelectedImagePath = string.Empty; + public string FaceMatcherSelectedImagePath + { + get => _faceMatcherSelectedImagePath; + set + { + _faceMatcherSelectedImagePath = value; + NotifyPropertyChanged(); + } + } + + private bool _isFaceMatcherRunning; + public bool IsFaceMatcherRunning + { + get => _isFaceMatcherRunning; + set + { + _isFaceMatcherRunning = value; + NotifyPropertyChanged(); + } + } + + private string _faceMatcherStatusMessage = string.Empty; + public string FaceMatcherStatusMessage + { + get => _faceMatcherStatusMessage; + set + { + _faceMatcherStatusMessage = value; + NotifyPropertyChanged(); + } + } + + private string _faceMatcherCommandOutput = string.Empty; + public string FaceMatcherCommandOutput + { + get => _faceMatcherCommandOutput; + set + { + _faceMatcherCommandOutput = value; + NotifyPropertyChanged(); + } + } + private double _aiProgress; public double AiProgress { @@ -237,4 +336,6 @@ public class AiSettingsViewModel : ViewModelBase } public ObservableCollection PreviewResults { get; } = new(); + + public ObservableCollection FaceMatcherResults { get; } = new(); } From 5511817896bbeb6e070d3162f8af28fab2fb9e48 Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 10:49:41 +0200 Subject: [PATCH 2/7] feat: Update .gitignore and project file to include TestArtifacts and local CUDA libraries for publish --- .gitignore | 3 ++- Catalog.code-workspace | 3 ++- imagecatalog/ImageCatalog 2.csproj | 9 +++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7e95a94..7c86da2 100644 --- a/.gitignore +++ b/.gitignore @@ -256,4 +256,5 @@ paket-files/ .idea/ *.sln.iml .vscode/settings.json -tmp/** \ No newline at end of file +tmp/** +TestArtifacts/** \ No newline at end of file diff --git a/Catalog.code-workspace b/Catalog.code-workspace index 15ec3a0..93418a7 100644 --- a/Catalog.code-workspace +++ b/Catalog.code-workspace @@ -11,6 +11,7 @@ } ], "settings": { - "commentTranslate.hover.enabled": false + "commentTranslate.hover.enabled": false, + "github.copilot.chat.otel.dbSpanExporter.enabled": true } } \ No newline at end of file diff --git a/imagecatalog/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj index 734b648..5affa11 100644 --- a/imagecatalog/ImageCatalog 2.csproj +++ b/imagecatalog/ImageCatalog 2.csproj @@ -159,4 +159,13 @@ + + + + + + + + + \ No newline at end of file From 55e8f0face816c0947377dc5b756d0419a7ed441 Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 11:21:30 +0200 Subject: [PATCH 3/7] Local build fix --- MaddoShared.Tests/MaddoShared.Tests.csproj | 2 +- MaddoShared/MaddoShared.csproj | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/MaddoShared.Tests/MaddoShared.Tests.csproj b/MaddoShared.Tests/MaddoShared.Tests.csproj index d8936a9..2c8d370 100644 --- a/MaddoShared.Tests/MaddoShared.Tests.csproj +++ b/MaddoShared.Tests/MaddoShared.Tests.csproj @@ -17,7 +17,7 @@ - + diff --git a/MaddoShared/MaddoShared.csproj b/MaddoShared/MaddoShared.csproj index b18b80a..7e3998c 100644 --- a/MaddoShared/MaddoShared.csproj +++ b/MaddoShared/MaddoShared.csproj @@ -2,6 +2,8 @@ net10.0 Library + enable + enable false x64 From af74c90ce7e7423ebb0f097c288e27096df039f4 Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 17:29:05 +0200 Subject: [PATCH 4/7] 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); From 6e05869b044003abd6f2657dfe036cb1b44b49c1 Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 18:33:54 +0200 Subject: [PATCH 5/7] feat: Add GPU support options and enhance UI for Face Encoder and Matcher functionalities --- imagecatalog/AvaloniaApp.axaml | 2 +- imagecatalog/AvaloniaMainWindow.axaml | 3 +- imagecatalog/AvaloniaMainWindow.axaml.cs | 5 + imagecatalog/AvaloniaViews/AiTabView.axaml | 3 +- .../AvaloniaViews/FaceAiTabView.axaml | 236 ++++++++++-------- .../AvaloniaViews/FaceAiTabView.axaml.cs | 9 +- .../AvaloniaViews/RaceUploadTabView.axaml | 6 +- imagecatalog/ImageCatalog 2.csproj | 13 +- imagecatalog/Models/SettingsDto.cs | 4 + 9 files changed, 163 insertions(+), 118 deletions(-) diff --git a/imagecatalog/AvaloniaApp.axaml b/imagecatalog/AvaloniaApp.axaml index 97bd721..fc57b97 100644 --- a/imagecatalog/AvaloniaApp.axaml +++ b/imagecatalog/AvaloniaApp.axaml @@ -100,7 +100,7 @@ - + diff --git a/imagecatalog/AvaloniaMainWindow.axaml b/imagecatalog/AvaloniaMainWindow.axaml index ba21b00..c01f37f 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml +++ b/imagecatalog/AvaloniaMainWindow.axaml @@ -5,6 +5,7 @@ xmlns:views="clr-namespace:ImageCatalog_2.AvaloniaViews" xmlns:iconPacks="https://github.com/MahApps/IconPacks.Avalonia" x:Class="ImageCatalog_2.AvaloniaMainWindow" + x:CompileBindings="False" mc:Ignorable="d" Title="Image Catalog - Avalonia" Height="540" Width="800"> @@ -106,7 +107,7 @@ - + + + + + + + + + + + + + + + + - - - - - - - - - - + + + + + - - - - - - - + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + @@ -240,9 +262,10 @@ - - - diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs index e4edbcc..e8b1c95 100644 --- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs @@ -331,8 +331,7 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl var contentGrid = new Grid { Margin = new Avalonia.Thickness(0, 12, 0, 12), - RowDefinitions = new RowDefinitions("Auto,*,Auto"), - RowSpacing = 12 + RowDefinitions = new RowDefinitions("Auto,*,Auto") }; Grid.SetRow(contentGrid, 1); @@ -371,7 +370,8 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl var toolbar = new StackPanel { Orientation = Orientation.Horizontal, - Spacing = 8 + Spacing = 8, + Margin = new Avalonia.Thickness(0, 0, 0, 12) }; var zoomOutButton = new Button { Content = "Zoom -", MinWidth = 80, IsEnabled = imageControl is not null }; @@ -427,7 +427,8 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, FontFamily = new FontFamily("Cascadia Mono, Consolas, monospace"), - MinHeight = 180 + MinHeight = 180, + Margin = new Avalonia.Thickness(0, 12, 0, 0) }; Grid.SetRow(debugBox, 2); contentGrid.Children.Add(debugBox); diff --git a/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml b/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml index cc3b507..1c9a6a8 100644 --- a/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml +++ b/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml @@ -7,7 +7,7 @@ - + @@ -16,7 +16,7 @@ - + @@ -49,7 +49,7 @@ - + diff --git a/imagecatalog/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj index 7446b3a..11eda12 100644 --- a/imagecatalog/ImageCatalog 2.csproj +++ b/imagecatalog/ImageCatalog 2.csproj @@ -3,6 +3,7 @@ enable enable False + false ImageCatalog default @@ -46,12 +47,16 @@ embedded + + + + SettingsSingleFileGenerator @@ -71,10 +76,10 @@ - - - - + + + + all diff --git a/imagecatalog/Models/SettingsDto.cs b/imagecatalog/Models/SettingsDto.cs index 00c1783..fd68763 100644 --- a/imagecatalog/Models/SettingsDto.cs +++ b/imagecatalog/Models/SettingsDto.cs @@ -314,6 +314,10 @@ namespace ImageCatalog_2.Models [XmlElement("AI_FaceUpsample")] public bool FaceUpsample { get; set; } = true; + [JsonPropertyName("UseFaceGpu")] + [XmlElement("AI_UsaGpuFace")] + public bool UseFaceGpu { get; set; } + [JsonPropertyName("FaceMatcherExecutablePath")] [XmlElement("AI_FaceMatcherExecutablePath")] public string FaceMatcherExecutablePath { get; set; } = string.Empty; From f3ac1ea9206bf711269ad1f5fb964d29279f7f49 Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 18:45:51 +0200 Subject: [PATCH 6/7] feat: Implement AI CSV overwrite confirmation and update CSV output path based on destination --- .../DataModelCharacterizationTests.cs | 39 ++++++ imagecatalog/AvaloniaMainWindow.axaml.cs | 62 ++++++++++ imagecatalog/DataModel.cs | 113 ++++++++++++++++++ 3 files changed, 214 insertions(+) diff --git a/MaddoShared.Tests/DataModelCharacterizationTests.cs b/MaddoShared.Tests/DataModelCharacterizationTests.cs index 0a94ae9..ac09cb8 100644 --- a/MaddoShared.Tests/DataModelCharacterizationTests.cs +++ b/MaddoShared.Tests/DataModelCharacterizationTests.cs @@ -96,6 +96,45 @@ public class DataModelCharacterizationTests model.DestinationPath.ShouldBe($"C:{System.IO.Path.DirectorySeparatorChar}output{System.IO.Path.DirectorySeparatorChar}"); } + [TestMethod] + public void DestinationPathChange_UpdatesAiCsvFileNameToMatchFinalFolderSegment() + { + var model = CreateModel(); + model.CsvOutputPath = @"K:\various\catalogtest\aioutput\test2.csv"; + + model.DestinationPath = @"K:\various\catalogtest\Dest\03.KM_8_A\"; + + model.CsvOutputPath.ShouldBe(@"K:\various\catalogtest\aioutput\03.KM_8_A.csv"); + } + + [TestMethod] + public async Task ConfirmAiCsvOverwriteIfNeededAsync_CanCancelWhenCsvAlreadyExists() + { + using var tempDirectory = new TemporaryDirectory(); + var csvPath = Path.Combine(tempDirectory.Path, "existing.csv"); + File.WriteAllText(csvPath, "existing"); + + var model = CreateModel(); + model.CsvOutputPath = csvPath; + + string? requestedTitle = null; + string? requestedMessage = null; + model.ConfirmAiCsvOverwriteAsync = (title, message) => + { + requestedTitle = title; + requestedMessage = message; + return Task.FromResult(false); + }; + + var shouldContinue = await model.ConfirmAiCsvOverwriteIfNeededAsync(); + + shouldContinue.ShouldBeFalse(); + requestedTitle.ShouldBe("File CSV gia esistente"); + requestedMessage.ShouldNotBeNull(); + requestedMessage.ShouldContain("Vuoi sovrascriverlo?"); + requestedMessage.ShouldContain(csvPath); + } + [TestMethod] public void AiChildChange_RaisesDataModelPropertyChanged() { diff --git a/imagecatalog/AvaloniaMainWindow.axaml.cs b/imagecatalog/AvaloniaMainWindow.axaml.cs index d996708..6740d6f 100644 --- a/imagecatalog/AvaloniaMainWindow.axaml.cs +++ b/imagecatalog/AvaloniaMainWindow.axaml.cs @@ -41,6 +41,7 @@ public partial class AvaloniaMainWindow : Window // Let DataModel marshal callbacks onto Avalonia UI thread. _model.UiInvoker = action => Dispatcher.UIThread.Invoke(action); + _model.ConfirmAiCsvOverwriteAsync = ShowConfirmationDialogAsync; _model.SelectSourceFolderRequested += async (_, _) => { @@ -283,6 +284,25 @@ public partial class AvaloniaMainWindow : Window await dialog.ShowDialog(this); } + private async Task ShowConfirmationDialogAsync(string title, string message) + { + var dialog = new Window + { + Title = title, + Width = 520, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + SizeToContent = SizeToContent.Height + }; + + dialog.Content = BuildConfirmationDialogContent( + message, + () => dialog.Close(true), + () => dialog.Close(false)); + + return await dialog.ShowDialog(this); + } + private static Control BuildMessageDialogContent(string message, Action closeDialog) { var layout = new StackPanel @@ -310,4 +330,46 @@ public partial class AvaloniaMainWindow : Window layout.Children.Add(closeButton); return layout; } + + private static Control BuildConfirmationDialogContent(string message, Action confirmDialog, Action cancelDialog) + { + var layout = new StackPanel + { + Margin = new Thickness(16), + Spacing = 12 + }; + + layout.Children.Add(new TextBlock + { + Text = message, + TextWrapping = Avalonia.Media.TextWrapping.Wrap, + MaxWidth = 460 + }); + + var buttons = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8 + }; + + var cancelButton = new Button + { + Content = "Annulla", + MinWidth = 96 + }; + cancelButton.Click += (_, _) => cancelDialog(); + + var confirmButton = new Button + { + Content = "Sovrascrivi", + MinWidth = 96 + }; + confirmButton.Click += (_, _) => confirmDialog(); + + buttons.Children.Add(cancelButton); + buttons.Children.Add(confirmButton); + layout.Children.Add(buttons); + return layout; + } } diff --git a/imagecatalog/DataModel.cs b/imagecatalog/DataModel.cs index c89cb2c..d4a3dc4 100644 --- a/imagecatalog/DataModel.cs +++ b/imagecatalog/DataModel.cs @@ -22,6 +22,7 @@ using System.Text.RegularExpressions; using AutoMapper; using MaddoShared; using Microsoft.Extensions.Logging; +using System.Collections.ObjectModel; namespace ImageCatalog_2 { @@ -82,6 +83,8 @@ namespace ImageCatalog_2 private sealed record ParsedFaceMatcherRow(string PhotoId, double? Score, string RawRow, string DebugSummary); + private const string AiCsvOverwriteDialogTitle = "File CSV gia esistente"; + // ComboBox collections public List AvailableFonts { get; } public List VerticalPositions { get; } = new() { "Alto", "Centro", "Basso" }; @@ -145,6 +148,12 @@ namespace ImageCatalog_2 private async Task StartAiAsync() { + if (!await ConfirmAiCsvOverwriteIfNeededAsync().ConfigureAwait(false)) + { + await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = "OCR annullato.").ConfigureAwait(false); + return; + } + MainToken = new CancellationTokenSource(); try { @@ -267,6 +276,8 @@ namespace ImageCatalog_2 set => _ai.CsvOutputPath = value; } + public Func>? ConfirmAiCsvOverwriteAsync { get; set; } + public bool UseNumberAiGpu { get => _ai.UseNumberAiGpu; @@ -641,6 +652,11 @@ namespace ImageCatalog_2 return; } + if (string.Equals(e.PropertyName, nameof(PathSettingsViewModel.DestinationPath), StringComparison.Ordinal)) + { + UpdateAiCsvOutputPathForDestination(); + } + NotifyPropertyChanged(e.PropertyName); } @@ -2403,6 +2419,103 @@ namespace ImageCatalog_2 }); } + internal async Task ConfirmAiCsvOverwriteIfNeededAsync() + { + var csvOutputPath = NormalizeFilePathArgument(CsvOutputPath); + if (string.IsNullOrWhiteSpace(csvOutputPath) || !File.Exists(csvOutputPath)) + { + return true; + } + + var confirmOverwrite = ConfirmAiCsvOverwriteAsync; + if (confirmOverwrite is null) + { + return true; + } + + var message = $"Il file CSV esiste gia:\n{csvOutputPath}\n\nVuoi sovrascriverlo? Se scegli Annulla l'operazione OCR non verra avviata."; + return await confirmOverwrite(AiCsvOverwriteDialogTitle, message).ConfigureAwait(false); + } + + internal void UpdateAiCsvOutputPathForDestination() + { + var updatedPath = BuildAiCsvOutputPathForDestination(CsvOutputPath, DestinationPath); + if (string.Equals(updatedPath, CsvOutputPath, StringComparison.Ordinal)) + { + return; + } + + CsvOutputPath = updatedPath; + } + + internal static string BuildAiCsvOutputPathForDestination(string currentCsvOutputPath, string destinationPath) + { + var normalizedCsvPath = NormalizeFilePathArgument(currentCsvOutputPath); + if (string.IsNullOrWhiteSpace(normalizedCsvPath)) + { + return currentCsvOutputPath; + } + + var directory = Path.GetDirectoryName(normalizedCsvPath); + var destinationFolderName = GetDestinationFolderName(destinationPath); + if (string.IsNullOrWhiteSpace(destinationFolderName)) + { + return normalizedCsvPath; + } + + var extension = Path.GetExtension(normalizedCsvPath); + if (string.IsNullOrWhiteSpace(extension)) + { + extension = ".csv"; + } + + var safeFileName = SanitizeFileName(destinationFolderName) + extension; + return string.IsNullOrWhiteSpace(directory) + ? safeFileName + : Path.Combine(directory, safeFileName); + } + + private static string GetDestinationFolderName(string destinationPath) + { + var normalizedDestinationPath = NormalizeDirectoryPathArgument(destinationPath); + if (string.IsNullOrWhiteSpace(normalizedDestinationPath)) + { + return string.Empty; + } + + var trimmedPath = normalizedDestinationPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.IsNullOrWhiteSpace(trimmedPath)) + { + return string.Empty; + } + + var rootPath = Path.GetPathRoot(trimmedPath)?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (!string.IsNullOrWhiteSpace(rootPath) + && string.Equals(trimmedPath, rootPath, StringComparison.OrdinalIgnoreCase)) + { + return string.Empty; + } + + return Path.GetFileName(trimmedPath); + } + + private static string SanitizeFileName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var invalidFileNameChars = Path.GetInvalidFileNameChars(); + var builder = new StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(Array.IndexOf(invalidFileNameChars, character) >= 0 ? '_' : character); + } + + return builder.ToString(); + } + private void SetUseFaceGpu(bool value) { var currentValue = _ai.UseFaceGpu; From 398cfa310e8e82c40c0647a5bab8c8bdf5fa2d9f Mon Sep 17 00:00:00 2001 From: Maddo Date: Sun, 24 May 2026 19:07:17 +0200 Subject: [PATCH 7/7] Refactor path handling in UI components - Removed redundant folder and file opening methods from AiTabView and FaceAiTabView. - Introduced PathPickerField control to streamline path selection and opening functionality across multiple views. - Updated FaceAiTabView and GeneralTabView to utilize PathPickerField for source and destination path selection. - Created PathShellService to encapsulate logic for opening paths in the file explorer. - Simplified XAML structure by replacing manual grid definitions with PathPickerField components. - Removed unused namespaces and cleaned up code for better readability and maintainability. --- imagecatalog/AvaloniaViews/AiTabView.axaml | 73 ++--- imagecatalog/AvaloniaViews/AiTabView.axaml.cs | 56 ---- .../AvaloniaViews/FaceAiTabView.axaml | 201 +++++------- .../AvaloniaViews/FaceAiTabView.axaml.cs | 246 +-------------- .../AvaloniaViews/GeneralTabView.axaml | 47 +-- .../AvaloniaViews/GeneralTabView.axaml.cs | 46 --- imagecatalog/Controls/PathPickerField.axaml | 34 +++ .../Controls/PathPickerField.axaml.cs | 285 ++++++++++++++++++ .../Controls/PathPickerSelectionMode.cs | 8 + imagecatalog/Services/PathShellService.cs | 50 +++ 10 files changed, 484 insertions(+), 562 deletions(-) create mode 100644 imagecatalog/Controls/PathPickerField.axaml create mode 100644 imagecatalog/Controls/PathPickerField.axaml.cs create mode 100644 imagecatalog/Controls/PathPickerSelectionMode.cs create mode 100644 imagecatalog/Services/PathShellService.cs diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml b/imagecatalog/AvaloniaViews/AiTabView.axaml index 7c703df..5026497 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml @@ -1,6 +1,7 @@ @@ -31,25 +32,13 @@ FontWeight="SemiBold" /> - - - - - - + - - + @@ -108,22 +89,12 @@ - - - - - - + diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs index 852cc63..f56e327 100644 --- a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs @@ -1,8 +1,4 @@ using Avalonia.Controls; -using Avalonia.Interactivity; -using System.Diagnostics; -using System.IO; - namespace ImageCatalog_2.AvaloniaViews; public partial class AiTabView : Avalonia.Controls.UserControl @@ -11,56 +7,4 @@ public partial class AiTabView : Avalonia.Controls.UserControl { InitializeComponent(); } - - private void OpenModelsFolder_Click(object? sender, RoutedEventArgs e) - { - if (DataContext is DataModel model) - { - OpenInExplorer(model.ModelsFolderPath); - } - } - - private void OpenCsvOutputFolder_Click(object? sender, RoutedEventArgs e) - { - if (DataContext is not DataModel model) - { - return; - } - - var directory = Path.GetDirectoryName(model.CsvOutputPath); - OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? model.CsvOutputPath : directory); - } - - private void OpenAiSourceFolder_Click(object? sender, RoutedEventArgs e) - { - if (DataContext is DataModel model) - { - OpenInExplorer(model.DestinationPath); - } - } - - private static void OpenInExplorer(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return; - } - - var normalizedPath = path.Trim().Trim('"'); - try - { - if (File.Exists(normalizedPath)) - { - Process.Start("explorer.exe", $"/select,\"{normalizedPath}\""); - } - else if (Directory.Exists(normalizedPath)) - { - Process.Start(new ProcessStartInfo { FileName = normalizedPath, UseShellExecute = true }); - } - } - catch - { - // Ignore failures when opening Explorer. - } - } } diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml index 55e74f1..dc1aed0 100644 --- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml +++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml @@ -1,6 +1,7 @@ - - - - - + - - - - - - + @@ -116,22 +104,12 @@ TextWrapping="Wrap" Opacity="0.75" /> - - - - - - + @@ -146,40 +124,24 @@ TextWrapping="Wrap" Opacity="0.8" /> - - - - - - + - - - - - - + @@ -193,68 +155,43 @@ - - - - - + - - - - - - + - - - - - - + - - - - - - + diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs index e8b1c95..0fbe36b 100644 --- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs @@ -4,15 +4,11 @@ using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Media.Imaging; -using Avalonia.Platform.Storage; using Avalonia.Threading; using ImageCatalog_2.Models; using ImageCatalog_2.Services; -using Microsoft.Extensions.DependencyInjection; using System; -using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.IO; using System.Text; using System.Threading.Tasks; @@ -22,11 +18,9 @@ namespace ImageCatalog_2.AvaloniaViews; public partial class FaceAiTabView : Avalonia.Controls.UserControl { private INotifyPropertyChanged? _faceAiPropertySource; - private readonly PickerPreferenceService _pickerPreferenceService; public FaceAiTabView() { - _pickerPreferenceService = Program.ServiceProvider.GetRequiredService(); InitializeComponent(); DataContextChanged += OnDataContextChanged; } @@ -74,142 +68,6 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl }); } - private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e) - { - var currentPath = DataContext is DataModel currentModel ? currentModel.FaceExecutablePath : null; - var folders = await OpenFolderPickerAsync("Seleziona la cartella Face Recognition Windows", PickerPreferenceKeys.FaceExecutableFolder, currentPath); - if (folders.Count > 0 && DataContext is DataModel model) - { - model.FaceExecutablePath = folders[0].Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceExecutableFolder, model.FaceExecutablePath); - } - } - - private async void SelectFaceOutputFolder_Click(object? sender, RoutedEventArgs e) - { - var currentPath = DataContext is DataModel currentModel ? currentModel.FaceOutputFolderPath : null; - var folders = await OpenFolderPickerAsync("Seleziona la cartella output per encodings e log", PickerPreferenceKeys.FaceOutputFolder, currentPath); - if (folders.Count > 0 && DataContext is DataModel model) - { - model.FaceOutputFolderPath = folders[0].Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceOutputFolder, model.FaceOutputFolderPath); - } - } - - private async void SelectFaceMatcherExecutable_Click(object? sender, RoutedEventArgs e) - { - var files = await OpenFilePickerAsync( - "Seleziona face_matcher.exe", - [new FilePickerFileType("Eseguibile") { Patterns = ["*.exe"] }], - PickerPreferenceKeys.FaceMatcherExecutable, - DataContext is DataModel currentModel ? currentModel.FaceMatcherExecutablePath : null); - - if (files.Count > 0 && DataContext is DataModel model) - { - model.FaceMatcherExecutablePath = files[0].Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherExecutable, model.FaceMatcherExecutablePath); - } - } - - private async void SelectFaceMatcherImage_Click(object? sender, RoutedEventArgs e) - { - var files = await OpenFilePickerAsync( - "Seleziona immagine per il match", - [new FilePickerFileType("Immagini") { Patterns = ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.gif", "*.webp"] }], - PickerPreferenceKeys.FaceMatcherImage, - DataContext is DataModel currentModel ? currentModel.FaceMatcherSelectedImagePath : null); - - if (files.Count > 0 && DataContext is DataModel model) - { - model.FaceMatcherSelectedImagePath = files[0].Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherImage, model.FaceMatcherSelectedImagePath); - } - } - - private async void SelectFaceMatcherEncodings_Click(object? sender, RoutedEventArgs e) - { - var files = await OpenFilePickerAsync( - "Seleziona file encodings .pkl", - [new FilePickerFileType("Encodings") { Patterns = ["*.pkl"] }], - PickerPreferenceKeys.FaceMatcherEncodings, - DataContext is DataModel currentModel ? currentModel.FaceMatcherEncodingsPath : null); - - if (files.Count > 0 && DataContext is DataModel model) - { - model.FaceMatcherEncodingsPath = files[0].Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherEncodings, model.FaceMatcherEncodingsPath); - } - } - - private async void SelectFaceMatcherOutput_Click(object? sender, RoutedEventArgs e) - { - var file = await SaveFilePickerAsync( - "Seleziona output CSV del matcher", - "csv", - [new FilePickerFileType("CSV") { Patterns = ["*.csv"] }], - PickerPreferenceKeys.FaceMatcherOutput, - DataContext is DataModel currentModel ? currentModel.FaceMatcherOutputPath : null); - - if (file is not null && DataContext is DataModel model) - { - model.FaceMatcherOutputPath = file.Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherOutput, model.FaceMatcherOutputPath); - } - } - - private async void SelectFaceMatcherLog_Click(object? sender, RoutedEventArgs e) - { - var file = await SaveFilePickerAsync( - "Seleziona log TXT del matcher", - "txt", - [new FilePickerFileType("Log") { Patterns = ["*.txt", "*.log"] }], - PickerPreferenceKeys.FaceMatcherLog, - DataContext is DataModel currentModel ? currentModel.FaceMatcherLogPath : null); - - if (file is not null && DataContext is DataModel model) - { - model.FaceMatcherLogPath = file.Path.LocalPath; - _pickerPreferenceService.RememberPath(PickerPreferenceKeys.FaceMatcherLog, model.FaceMatcherLogPath); - } - } - - private void OpenFaceExecutableFolder_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceExecutablePathTextBox"); - - private void OpenFaceOutputFolder_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceOutputFolderTextBox"); - - private void OpenFaceMatcherExecutable_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherExecutablePathTextBox"); - - private void OpenFaceMatcherImage_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherImagePathTextBox"); - - private void OpenFaceMatcherEncodings_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherEncodingsPathTextBox"); - - private void OpenFaceMatcherOutput_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherOutputPathTextBox"); - - private void OpenFaceMatcherLog_Click(object? sender, RoutedEventArgs e) => OpenFromTextBox("FaceMatcherLogPathTextBox"); - - private void OpenFaceDestinationFolder_Click(object? sender, RoutedEventArgs e) - { - string? path = null; - if (DataContext is DataModel model) - { - path = (model.DestinationPath ?? string.Empty).Trim(); - } - - if (string.IsNullOrWhiteSpace(path)) - { - return; - } - - if (Directory.Exists(path)) - { - OpenInExplorer(path); - return; - } - - var directory = Path.GetDirectoryName(path); - OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory); - } - private async void OpenFaceMatcherPreview_Click(object? sender, RoutedEventArgs e) { if (sender is not Button { Tag: FaceMatcherResultItem item }) @@ -444,14 +302,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl Grid.SetRow(footer, 2); var openFileButton = new Button { Content = "Apri file" }; - openFileButton.Click += (_, _) => OpenInExplorer(item.ResolvedImagePath); + openFileButton.Click += (_, _) => PathShellService.OpenInExplorer(item.ResolvedImagePath); footer.Children.Add(openFileButton); var openFolderButton = new Button { Content = "Apri cartella" }; openFolderButton.Click += (_, _) => { var directory = Path.GetDirectoryName(item.ResolvedImagePath); - OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? item.ResolvedImagePath : directory); + PathShellService.OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? item.ResolvedImagePath : directory); }; footer.Children.Add(openFolderButton); @@ -464,104 +322,4 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl return dialog; } - private void OpenFromTextBox(string textBoxName) - { - var textBox = this.FindControl(textBoxName); - var path = textBox?.Text?.Trim(); - if (string.IsNullOrWhiteSpace(path)) - { - return; - } - - if (Directory.Exists(path) || File.Exists(path)) - { - OpenInExplorer(path); - return; - } - - var directory = Path.GetDirectoryName(path); - OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory); - } - - private async Task> OpenFolderPickerAsync(string title, string preferenceKey, string? currentPath) - { - var topLevel = TopLevel.GetTopLevel(this); - var storageProvider = topLevel?.StorageProvider; - if (storageProvider is null) - { - return Array.Empty(); - } - - var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, preferenceKey, currentPath); - - return await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions - { - Title = title, - SuggestedStartLocation = suggestedStartLocation - }); - } - - private async Task> OpenFilePickerAsync(string title, IReadOnlyList fileTypes, string preferenceKey, string? currentPath) - { - var topLevel = TopLevel.GetTopLevel(this); - var storageProvider = topLevel?.StorageProvider; - if (storageProvider is null) - { - return Array.Empty(); - } - - var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, preferenceKey, currentPath); - - return await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions - { - Title = title, - FileTypeFilter = fileTypes, - SuggestedStartLocation = suggestedStartLocation - }); - } - - private async Task SaveFilePickerAsync(string title, string defaultExtension, IReadOnlyList fileTypes, string preferenceKey, string? currentPath) - { - var topLevel = TopLevel.GetTopLevel(this); - var storageProvider = topLevel?.StorageProvider; - if (storageProvider is null) - { - return null; - } - - var suggestedStartLocation = await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, preferenceKey, currentPath); - - return await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions - { - Title = title, - DefaultExtension = defaultExtension, - FileTypeChoices = fileTypes, - SuggestedStartLocation = suggestedStartLocation - }); - } - - private static void OpenInExplorer(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return; - } - - var normalizedPath = path.Trim().Trim('"'); - try - { - if (File.Exists(normalizedPath)) - { - Process.Start("explorer.exe", $"/select,\"{normalizedPath}\""); - } - else if (Directory.Exists(normalizedPath)) - { - Process.Start(new ProcessStartInfo { FileName = normalizedPath, UseShellExecute = true }); - } - } - catch - { - // Ignore failures when opening Explorer. - } - } } diff --git a/imagecatalog/AvaloniaViews/GeneralTabView.axaml b/imagecatalog/AvaloniaViews/GeneralTabView.axaml index 82979eb..ed0dd98 100644 --- a/imagecatalog/AvaloniaViews/GeneralTabView.axaml +++ b/imagecatalog/AvaloniaViews/GeneralTabView.axaml @@ -1,43 +1,24 @@ - - - - - - - - - - - - + + diff --git a/imagecatalog/AvaloniaViews/GeneralTabView.axaml.cs b/imagecatalog/AvaloniaViews/GeneralTabView.axaml.cs index cdcbec9..9670c2a 100644 --- a/imagecatalog/AvaloniaViews/GeneralTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/GeneralTabView.axaml.cs @@ -1,9 +1,4 @@ using Avalonia.Controls; -using Avalonia.Interactivity; -using System; -using System.Diagnostics; -using System.IO; - namespace ImageCatalog_2.AvaloniaViews; public partial class GeneralTabView : Avalonia.Controls.UserControl @@ -12,45 +7,4 @@ public partial class GeneralTabView : Avalonia.Controls.UserControl { InitializeComponent(); } - - private void OpenSourceFolder_Click(object? sender, RoutedEventArgs e) - { - if (DataContext is DataModel model) - { - OpenInExplorer(model.SourcePath); - } - } - - private void OpenDestinationFolder_Click(object? sender, RoutedEventArgs e) - { - if (DataContext is DataModel model) - { - OpenInExplorer(model.DestinationPath); - } - } - - private static void OpenInExplorer(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return; - } - - var normalizedPath = path.Trim().Trim('"'); - try - { - if (File.Exists(normalizedPath)) - { - Process.Start("explorer.exe", $"/select,\"{normalizedPath}\""); - } - else if (Directory.Exists(normalizedPath)) - { - Process.Start(new ProcessStartInfo { FileName = normalizedPath, UseShellExecute = true }); - } - } - catch - { - // Ignore failures when opening Explorer. - } - } } diff --git a/imagecatalog/Controls/PathPickerField.axaml b/imagecatalog/Controls/PathPickerField.axaml new file mode 100644 index 0000000..dfa6948 --- /dev/null +++ b/imagecatalog/Controls/PathPickerField.axaml @@ -0,0 +1,34 @@ + + + + + + + + \ No newline at end of file diff --git a/imagecatalog/Controls/PathPickerField.axaml.cs b/imagecatalog/Controls/PathPickerField.axaml.cs new file mode 100644 index 0000000..6ec2538 --- /dev/null +++ b/imagecatalog/Controls/PathPickerField.axaml.cs @@ -0,0 +1,285 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Platform.Storage; +using ImageCatalog_2.Services; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace ImageCatalog_2.Controls; + +public partial class PathPickerField : UserControl +{ + private readonly PickerPreferenceService _pickerPreferenceService; + + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label), string.Empty); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), string.Empty, defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty WatermarkProperty = + AvaloniaProperty.Register(nameof(Watermark), string.Empty); + + public static readonly StyledProperty PreferenceKeyProperty = + AvaloniaProperty.Register(nameof(PreferenceKey), string.Empty); + + public static readonly StyledProperty PickerTitleProperty = + AvaloniaProperty.Register(nameof(PickerTitle), string.Empty); + + public static readonly StyledProperty FileTypeNameProperty = + AvaloniaProperty.Register(nameof(FileTypeName), string.Empty); + + public static readonly StyledProperty FilePatternsProperty = + AvaloniaProperty.Register(nameof(FilePatterns), string.Empty); + + public static readonly StyledProperty DefaultExtensionProperty = + AvaloniaProperty.Register(nameof(DefaultExtension), string.Empty); + + public static readonly StyledProperty PickerModeProperty = + AvaloniaProperty.Register(nameof(PickerMode), PathPickerSelectionMode.Folder); + + public static readonly StyledProperty IsTextReadOnlyProperty = + AvaloniaProperty.Register(nameof(IsTextReadOnly), false); + + public static readonly StyledProperty ShowPickerButtonProperty = + AvaloniaProperty.Register(nameof(ShowPickerButton), true); + + public static readonly StyledProperty AppendDirectorySeparatorProperty = + AvaloniaProperty.Register(nameof(AppendDirectorySeparator), false); + + public PathPickerField() + { + _pickerPreferenceService = Program.ServiceProvider.GetRequiredService(); + InitializeComponent(); + } + + public string Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string Watermark + { + get => GetValue(WatermarkProperty); + set => SetValue(WatermarkProperty, value); + } + + public string PreferenceKey + { + get => GetValue(PreferenceKeyProperty); + set => SetValue(PreferenceKeyProperty, value); + } + + public string PickerTitle + { + get => GetValue(PickerTitleProperty); + set => SetValue(PickerTitleProperty, value); + } + + public string FileTypeName + { + get => GetValue(FileTypeNameProperty); + set => SetValue(FileTypeNameProperty, value); + } + + public string FilePatterns + { + get => GetValue(FilePatternsProperty); + set => SetValue(FilePatternsProperty, value); + } + + public string DefaultExtension + { + get => GetValue(DefaultExtensionProperty); + set => SetValue(DefaultExtensionProperty, value); + } + + public PathPickerSelectionMode PickerMode + { + get => GetValue(PickerModeProperty); + set => SetValue(PickerModeProperty, value); + } + + public bool IsTextReadOnly + { + get => GetValue(IsTextReadOnlyProperty); + set => SetValue(IsTextReadOnlyProperty, value); + } + + public bool ShowPickerButton + { + get => GetValue(ShowPickerButtonProperty); + set => SetValue(ShowPickerButtonProperty, value); + } + + public bool AppendDirectorySeparator + { + get => GetValue(AppendDirectorySeparatorProperty); + set => SetValue(AppendDirectorySeparatorProperty, value); + } + + private async void PickPath_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var topLevel = TopLevel.GetTopLevel(this); + var storageProvider = topLevel?.StorageProvider; + if (storageProvider is null) + { + return; + } + + var selectedPath = await PickPathAsync(storageProvider); + if (string.IsNullOrWhiteSpace(selectedPath)) + { + return; + } + + Text = selectedPath; + + if (!string.IsNullOrWhiteSpace(PreferenceKey)) + { + _pickerPreferenceService.RememberPath(PreferenceKey, selectedPath); + } + } + + private void OpenPath_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + PathShellService.OpenInExplorer(Text); + } + + private async Task PickPathAsync(IStorageProvider storageProvider) + { + var suggestedStartLocation = await TryGetSuggestedStartLocationAsync(storageProvider); + var pickerTitle = ResolvePickerTitle(); + + switch (PickerMode) + { + case PathPickerSelectionMode.Folder: + { + var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = pickerTitle, + SuggestedStartLocation = suggestedStartLocation + }); + + return folders.Count == 0 + ? null + : NormalizeSelectedPath(folders[0].Path.LocalPath); + } + case PathPickerSelectionMode.OpenFile: + { + var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = pickerTitle, + SuggestedStartLocation = suggestedStartLocation, + FileTypeFilter = BuildFileTypes() + }); + + return files.Count == 0 + ? null + : files[0].Path.LocalPath; + } + case PathPickerSelectionMode.SaveFile: + { + var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = pickerTitle, + SuggestedStartLocation = suggestedStartLocation, + DefaultExtension = NormalizeDefaultExtension(DefaultExtension), + FileTypeChoices = BuildFileTypes() + }); + + return file?.Path.LocalPath; + } + default: + return null; + } + } + + private async Task TryGetSuggestedStartLocationAsync(IStorageProvider storageProvider) + { + if (string.IsNullOrWhiteSpace(PreferenceKey)) + { + return null; + } + + return await _pickerPreferenceService.TryGetStartFolderAsync(storageProvider, PreferenceKey, Text); + } + + private IReadOnlyList BuildFileTypes() + { + var patterns = (FilePatterns ?? string.Empty) + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (patterns.Length == 0) + { + return Array.Empty(); + } + + var fileTypeName = string.IsNullOrWhiteSpace(FileTypeName) + ? "File" + : FileTypeName.Trim(); + + return + [ + new FilePickerFileType(fileTypeName) + { + Patterns = patterns.ToList() + } + ]; + } + + private string ResolvePickerTitle() + { + if (!string.IsNullOrWhiteSpace(PickerTitle)) + { + return PickerTitle; + } + + var cleanedLabel = string.IsNullOrWhiteSpace(Label) + ? "percorso" + : Label.Trim().TrimEnd(':'); + + return PickerMode == PathPickerSelectionMode.SaveFile + ? $"Salva {cleanedLabel.ToLowerInvariant()}" + : $"Seleziona {cleanedLabel.ToLowerInvariant()}"; + } + + private string NormalizeSelectedPath(string selectedPath) + { + if (PickerMode != PathPickerSelectionMode.Folder || !AppendDirectorySeparator) + { + return selectedPath; + } + + if (string.IsNullOrWhiteSpace(selectedPath)) + { + return string.Empty; + } + + return selectedPath.EndsWith(Path.DirectorySeparatorChar) + || selectedPath.EndsWith(Path.AltDirectorySeparatorChar) + ? selectedPath + : selectedPath + Path.DirectorySeparatorChar; + } + + private static string NormalizeDefaultExtension(string extension) + { + if (string.IsNullOrWhiteSpace(extension)) + { + return string.Empty; + } + + return extension.Trim().TrimStart('.'); + } +} \ No newline at end of file diff --git a/imagecatalog/Controls/PathPickerSelectionMode.cs b/imagecatalog/Controls/PathPickerSelectionMode.cs new file mode 100644 index 0000000..27d456e --- /dev/null +++ b/imagecatalog/Controls/PathPickerSelectionMode.cs @@ -0,0 +1,8 @@ +namespace ImageCatalog_2.Controls; + +public enum PathPickerSelectionMode +{ + Folder, + OpenFile, + SaveFile +} \ No newline at end of file diff --git a/imagecatalog/Services/PathShellService.cs b/imagecatalog/Services/PathShellService.cs new file mode 100644 index 0000000..fcba122 --- /dev/null +++ b/imagecatalog/Services/PathShellService.cs @@ -0,0 +1,50 @@ +using System.Diagnostics; +using System.IO; + +namespace ImageCatalog_2.Services; + +public static class PathShellService +{ + public static void OpenInExplorer(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var normalizedPath = path.Trim().Trim('"'); + + try + { + if (File.Exists(normalizedPath)) + { + Process.Start("explorer.exe", $"/select,\"{normalizedPath}\""); + return; + } + + if (Directory.Exists(normalizedPath)) + { + Process.Start(new ProcessStartInfo + { + FileName = normalizedPath, + UseShellExecute = true + }); + return; + } + + var containingDirectory = Path.GetDirectoryName(normalizedPath); + if (!string.IsNullOrWhiteSpace(containingDirectory) && Directory.Exists(containingDirectory)) + { + Process.Start(new ProcessStartInfo + { + FileName = containingDirectory, + UseShellExecute = true + }); + } + } + catch + { + // Ignore failures when opening Explorer. + } + } +} \ No newline at end of file