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.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/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/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/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/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.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/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
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 @@
-
+
+
+
+
diff --git a/imagecatalog/AvaloniaViews/AiTabView.axaml.cs b/imagecatalog/AvaloniaViews/AiTabView.axaml.cs
index f89ded7..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 OpenAiDestinationFolder_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 134621c..dc1aed0 100644
--- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml
+++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml
@@ -1,109 +1,291 @@
-
-
-
-
+ xmlns:converters="clr-namespace:ImageCatalog_2.Converters"
+ x:Class="ImageCatalog_2.AvaloniaViews.FaceAiTabView"
+ x:CompileBindings="False">
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs
index 3057b02..0fbe36b 100644
--- a/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs
+++ b/imagecatalog/AvaloniaViews/FaceAiTabView.axaml.cs
@@ -1,11 +1,17 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
-using Avalonia.Platform.Storage;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Media.Imaging;
using Avalonia.Threading;
+using ImageCatalog_2.Models;
+using ImageCatalog_2.Services;
using System;
using System.ComponentModel;
-using System.Diagnostics;
using System.IO;
+using System.Text;
+using System.Threading.Tasks;
namespace ImageCatalog_2.AvaloniaViews;
@@ -35,12 +41,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;
@@ -53,172 +68,258 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
});
}
- private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e)
+ private async void OpenFaceMatcherPreview_Click(object? sender, RoutedEventArgs e)
{
- var executableBox = this.FindControl("FaceExecutablePathTextBox");
- if (executableBox is null)
+ if (sender is not Button { Tag: FaceMatcherResultItem item })
{
return;
}
- var topLevel = TopLevel.GetTopLevel(this);
- var storageProvider = topLevel?.StorageProvider;
- if (storageProvider is null)
+ 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;
}
- var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ await OpenFaceMatcherPreviewAsync(item);
+ }
+
+ private Window BuildFaceMatcherPreviewDialog(FaceMatcherResultItem item)
+ {
+ var dialog = new Window
{
- Title = "Seleziona la cartella Face Recognition Windows"
+ 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
});
-
- if (folders.Count > 0)
+ header.Children.Add(new TextBlock
{
- executableBox.Text = folders[0].Path.LocalPath;
- if (DataContext is DataModel model)
- {
- model.FaceExecutablePath = executableBox.Text;
- }
- }
- }
-
- private async void SelectFaceOutputFolder_Click(object? sender, RoutedEventArgs e)
- {
- var outputBox = this.FindControl("FaceOutputFolderTextBox");
- if (outputBox is null)
- {
- 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"
+ Text = string.IsNullOrWhiteSpace(item.ScoreDisplay)
+ ? "Score: n/d"
+ : $"Score: {item.ScoreDisplay}%",
+ FontWeight = FontWeight.SemiBold,
+ Opacity = 0.9
});
-
- if (folders.Count > 0)
+ header.Children.Add(new TextBlock
{
- outputBox.Text = folders[0].Path.LocalPath;
- if (DataContext is DataModel model)
+ 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")
+ };
+ 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
{
- model.FaceOutputFolderPath = outputBox.Text;
- }
- }
- }
+ Source = bitmap,
+ Stretch = Stretch.None,
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Top,
+ RenderTransform = new ScaleTransform(1, 1)
+ };
- private void OpenFaceExecutableFolder_Click(object? sender, RoutedEventArgs e)
- {
- var executableBox = this.FindControl("FaceExecutablePathTextBox");
- if (executableBox is null)
+ void UpdateZoom(double delta)
{
- return;
- }
-
- var path = executableBox.Text?.Trim();
- if (string.IsNullOrWhiteSpace(path))
- {
- return;
- }
-
- 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)
- {
- var outputBox = this.FindControl("FaceOutputFolderTextBox");
- if (outputBox is null)
- {
- return;
- }
-
- var outputPath = outputBox.Text?.Trim();
- if (string.IsNullOrWhiteSpace(outputPath))
- {
- return;
- }
-
- 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 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)
- {
- 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 static void OpenInExplorer(string? path)
- {
- if (string.IsNullOrWhiteSpace(path))
- {
- return;
- }
-
- var normalizedPath = path.Trim().Trim('"');
- try
- {
- if (File.Exists(normalizedPath))
+ if (imageControl is null)
{
- Process.Start("explorer.exe", $"/select,\"{normalizedPath}\"");
- }
- else if (Directory.Exists(normalizedPath))
- {
- Process.Start(new ProcessStartInfo { FileName = normalizedPath, UseShellExecute = true });
+ return;
}
+
+ zoomLevel = Math.Clamp(zoomLevel + delta, 0.1, 8.0);
+ imageControl.RenderTransform = new ScaleTransform(zoomLevel, zoomLevel);
+ zoomText.Text = $"{zoomLevel * 100:0}%";
}
- catch
+
+ var toolbar = new StackPanel
{
- // Ignore failures when opening Explorer.
- }
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ Margin = new Avalonia.Thickness(0, 0, 0, 12)
+ };
+
+ 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,
+ Margin = new Avalonia.Thickness(0, 12, 0, 0)
+ };
+ 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 += (_, _) => PathShellService.OpenInExplorer(item.ResolvedImagePath);
+ footer.Children.Add(openFileButton);
+
+ var openFolderButton = new Button { Content = "Apri cartella" };
+ openFolderButton.Click += (_, _) =>
+ {
+ var directory = Path.GetDirectoryName(item.ResolvedImagePath);
+ PathShellService.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;
}
+
}
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/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/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/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..d4a3dc4 100644
--- a/imagecatalog/DataModel.cs
+++ b/imagecatalog/DataModel.cs
@@ -13,13 +13,16 @@ 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;
+using System.Collections.ObjectModel;
namespace ImageCatalog_2
{
@@ -41,6 +44,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 +62,28 @@ 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 int _numberAiGpuRefreshVersion;
+ private volatile bool _numberAiGpuValidationPending;
+
+ 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; }
@@ -105,8 +125,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);
@@ -118,12 +142,18 @@ namespace ImageCatalog_2
// Load available fonts
AvailableFonts = LoadAvailableFonts();
- RefreshNumberAiGpuCapabilities();
+ QueueRefreshNumberAiGpuCapabilities();
RefreshFaceExecutableCapabilities();
}
private async Task StartAiAsync()
{
+ if (!await ConfirmAiCsvOverwriteIfNeededAsync().ConfigureAwait(false))
+ {
+ await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = "OCR annullato.").ConfigureAwait(false);
+ return;
+ }
+
MainToken = new CancellationTokenSource();
try
{
@@ -138,7 +168,7 @@ namespace ImageCatalog_2
_logger.LogError(ex, "AI extraction failed");
if (UseNumberAiGpu)
{
- RefreshNumberAiGpuCapabilities();
+ QueueRefreshNumberAiGpuCapabilities();
}
await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = $"Errore OCR: {ex.GetBaseException().Message}").ConfigureAwait(false);
@@ -236,7 +266,7 @@ namespace ImageCatalog_2
set
{
_ai.ModelsFolderPath = value;
- RefreshNumberAiGpuCapabilities();
+ QueueRefreshNumberAiGpuCapabilities();
}
}
@@ -246,6 +276,8 @@ namespace ImageCatalog_2
set => _ai.CsvOutputPath = value;
}
+ public Func>? ConfirmAiCsvOverwriteAsync { get; set; }
+
public bool UseNumberAiGpu
{
get => _ai.UseNumberAiGpu;
@@ -359,6 +391,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
{
@@ -564,6 +652,11 @@ namespace ImageCatalog_2
return;
}
+ if (string.Equals(e.PropertyName, nameof(PathSettingsViewModel.DestinationPath), StringComparison.Ordinal))
+ {
+ UpdateAiCsvOutputPathForDestination();
+ }
+
NotifyPropertyChanged(e.PropertyName);
}
@@ -576,6 +669,7 @@ namespace ImageCatalog_2
NotifyPropertyChanged(e.PropertyName);
UpdateFaceEncoderCommandStates();
+ UpdateFaceMatcherCommandStates();
}
private void OnRaceUploadPropertyChanged(object? sender, PropertyChangedEventArgs e)
@@ -1528,6 +1622,12 @@ namespace ImageCatalog_2
_stopFaceEncoderCommand?.RaiseCanExecuteChanged();
}
+ private void UpdateFaceMatcherCommandStates()
+ {
+ _startFaceMatcherCommand?.RaiseCanExecuteChanged();
+ _stopFaceMatcherCommand?.RaiseCanExecuteChanged();
+ }
+
private async Task RunFaceEncoderAsync()
{
if (IsFaceEncoderRunning)
@@ -1693,6 +1793,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 +2065,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 +2102,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 +2122,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 +2162,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 +2237,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);
@@ -1832,31 +2299,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)
@@ -1897,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;
@@ -1927,6 +2546,14 @@ namespace ImageCatalog_2
}
}
+ private void TrackFaceMatcherProcess(Process process)
+ {
+ lock (_faceMatcherProcessLock)
+ {
+ _faceMatcherProcess = process;
+ }
+ }
+
private void ClearTrackedFaceEncoderProcess()
{
lock (_faceEncoderProcessLock)
@@ -1941,6 +2568,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 +2601,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 +2648,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 +2693,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 +2761,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 +2841,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 +2983,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 +3057,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 +3178,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/ImageCatalog 2.csproj b/imagecatalog/ImageCatalog 2.csproj
index 734b648..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
@@ -65,16 +70,16 @@
-
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
+
all
@@ -159,4 +164,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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..fd68763 100644
--- a/imagecatalog/Models/SettingsDto.cs
+++ b/imagecatalog/Models/SettingsDto.cs
@@ -314,6 +314,18 @@ 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;
+
+ [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/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/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
diff --git a/imagecatalog/Services/PickerPreferenceService.cs b/imagecatalog/Services/PickerPreferenceService.cs
new file mode 100644
index 0000000..49bdaea
--- /dev/null
+++ b/imagecatalog/Services/PickerPreferenceService.cs
@@ -0,0 +1,122 @@
+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 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";
+ 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();
+ }
+
+ 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);
+ 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();
}