feat: Add face encoder settings including GPU support, parallelism, and thumbnail options
Some checks failed
Build Windows Avalonia / build (push) Failing after 1m19s
Build Windows Avalonia / release (push) Has been skipped

This commit is contained in:
MaddoScientisto 2026-05-09 15:46:41 +02:00
commit 25fdb82d2f
9 changed files with 595 additions and 136 deletions

View file

@ -5,6 +5,9 @@
}, },
{ {
"path": "../AIFotoONLUS" "path": "../AIFotoONLUS"
},
{
"path": "../../various/regalamiunsorriso"
} }
], ],
"settings": { "settings": {

View file

@ -1,4 +1,5 @@
using System; using System;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using ImageCatalog_2; using ImageCatalog_2;
using ImageCatalog_2.Services; using ImageCatalog_2.Services;
@ -134,6 +135,61 @@ public class DataModelCharacterizationTests
model.FontSize.ShouldBe(42); model.FontSize.ShouldBe(42);
} }
[TestMethod]
public void FaceExecutableFolder_EnablesGpuToggleWhenBothVariantsExist()
{
using var root = new TemporaryDirectory();
CreateFaceEncoderExecutable(root.Path, "cpu");
CreateFaceEncoderExecutable(root.Path, "gpu");
var model = CreateModel();
model.FaceExecutablePath = root.Path;
model.FaceGpuOptionEnabled.ShouldBeTrue();
model.UseFaceGpu.ShouldBeFalse();
}
[TestMethod]
public void UseFaceGpu_UpdatesUpsampleWhenUsingRecommendedDefault()
{
using var root = new TemporaryDirectory();
CreateFaceEncoderExecutable(root.Path, "cpu");
CreateFaceEncoderExecutable(root.Path, "gpu");
var model = CreateModel();
model.FaceExecutablePath = root.Path;
model.FaceUpsample.ShouldBeTrue();
model.UseFaceGpu = true;
model.FaceUpsample.ShouldBeFalse();
}
[TestMethod]
public void ResolveConfiguredFaceEncoderExecutablePath_UsesFolderLayoutFromPowerShellScript()
{
using var root = new TemporaryDirectory();
var cpuExecutable = CreateFaceEncoderExecutable(root.Path, "cpu");
var gpuExecutable = CreateFaceEncoderExecutable(root.Path, "gpu");
DataModel.ResolveConfiguredFaceEncoderExecutablePath(root.Path, useGpu: false).ShouldBe(cpuExecutable);
DataModel.ResolveConfiguredFaceEncoderExecutablePath(root.Path, useGpu: true).ShouldBe(gpuExecutable);
}
[TestMethod]
public void BuildFaceEncoderOutputPaths_UsesTimestampAndSanitizedFolderName()
{
var timestamp = new DateTime(2026, 5, 9, 14, 30, 45);
var output = DataModel.BuildFaceEncoderOutputPaths(
@"C:\out",
@"C:\images\04 APRILE: gara?",
timestamp);
output.OutputFilePath.ShouldBe(@"C:\out\face_encodings_20260509_143045_04_APRILE_gara.pkl");
output.LogFilePath.ShouldBe(@"C:\out\encoder_log_20260509_143045_04_APRILE_gara.txt");
}
private static DataModel CreateModel( private static DataModel CreateModel(
ISettingsService? settingsService = null, ISettingsService? settingsService = null,
ITestService? testService = null) ITestService? testService = null)
@ -169,4 +225,33 @@ public class DataModelCharacterizationTests
Substitute.For<ILogger<DataModel>>(), Substitute.For<ILogger<DataModel>>(),
versionProvider: null); versionProvider: null);
} }
private static string CreateFaceEncoderExecutable(string rootPath, string variant)
{
var variantDirectory = Path.Combine(rootPath, $"face_encoder_{variant}");
Directory.CreateDirectory(variantDirectory);
var executablePath = Path.Combine(variantDirectory, $"face_encoder_{variant}.exe");
File.WriteAllText(executablePath, "stub");
return executablePath;
}
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);
}
}
}
} }

View file

@ -5,16 +5,16 @@
<ScrollViewer> <ScrollViewer>
<StackPanel Margin="4" Spacing="6"> <StackPanel Margin="4" Spacing="6">
<TextBlock Text="Face Recognition Encoder" FontWeight="Bold" /> <TextBlock Text="Face Recognition Encoder" FontWeight="Bold" />
<TextBlock Text="Esegue il face encoder usando la cartella Destinazione corrente come --images e un file .pkl come --out." <TextBlock Text="Esegue il face encoder usando la cartella Destinazione corrente come --images e genera automaticamente file .pkl e log nella cartella di output scelta."
TextWrapping="Wrap" Opacity="0.8" /> TextWrapping="Wrap" Opacity="0.8" />
<TextBlock Text="Eseguibile" FontWeight="Bold" Margin="0,4,0,0" /> <TextBlock Text="Cartella Face Encoder" FontWeight="Bold" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="face_encoder:" VerticalAlignment="Center" /> <TextBlock Grid.Column="0" Text="Percorso:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceExecutablePathTextBox" Text="{Binding FaceExecutablePath, Mode=TwoWay}" Watermark="C:\\tools\\face_encoder_cpu.exe" /> <TextBox Grid.Column="1" Name="FaceExecutablePathTextBox" Text="{Binding FaceExecutablePath, Mode=TwoWay}" Watermark="C:\tools\Face_Recognition_Windows" />
<Button Grid.Column="2" Name="FaceSelectExecutableButton" Click="SelectFaceExecutable_Click" Width="104" Margin="6,0,0,0"> <Button Grid.Column="2" Name="FaceSelectExecutableButton" Click="SelectFaceExecutable_Click" Width="104" Margin="6,0,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6">
<iconPacks:PackIconMaterial Kind="FileOutline" Width="14" Height="14" /> <iconPacks:PackIconMaterial Kind="FolderOutline" Width="14" Height="14" />
<TextBlock Text="Scegli..." /> <TextBlock Text="Scegli..." />
</StackPanel> </StackPanel>
</Button> </Button>
@ -28,14 +28,24 @@
<StackPanel Orientation="Horizontal" Spacing="12"> <StackPanel Orientation="Horizontal" Spacing="12">
<CheckBox Content="Ricorsivo (--recursive)" IsChecked="{Binding FaceRecursive, Mode=TwoWay}" /> <CheckBox Content="Ricorsivo (--recursive)" IsChecked="{Binding FaceRecursive, Mode=TwoWay}" />
<CheckBox Content="Includi thumbnail (--include-tn)" IsChecked="{Binding FaceIncludeThumbnails, Mode=TwoWay}" />
<CheckBox Content="Upsample (--upsample)" IsChecked="{Binding FaceUpsample, Mode=TwoWay}" />
<CheckBox Content="Usa GPU" <CheckBox Content="Usa GPU"
IsChecked="{Binding UseFaceGpu, Mode=TwoWay}" IsChecked="{Binding UseFaceGpu, Mode=TwoWay}"
IsEnabled="{Binding FaceGpuOptionEnabled}" /> IsEnabled="{Binding FaceGpuOptionEnabled}" />
</StackPanel> </StackPanel>
<TextBlock Text="Se l'eseguibile termina con _cpu o _gpu, il checkbox GPU cambia automaticamente il file usato. Se non c'e il suffisso, viene trattato come CPU." <TextBlock Text="Seleziona la cartella base di Face Recognition Windows: l'app sceglie automaticamente face_encoder_cpu.exe o face_encoder_gpu.exe in base al checkbox GPU."
TextWrapping="Wrap" TextWrapping="Wrap"
Opacity="0.75" /> Opacity="0.75" />
<Grid ColumnDefinitions="Auto,120,Auto,120,*" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Parallelismo:" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" ItemsSource="{Binding FaceParallelismOptions}" SelectedItem="{Binding FaceParallelism, Mode=TwoWay}" />
<TextBlock Grid.Column="2" Text="Min size:" VerticalAlignment="Center" />
<TextBox Grid.Column="3" Text="{Binding FaceMinSize, Mode=TwoWay}" Watermark="35" />
<TextBlock Grid.Column="4" Text="Usa --multicore in CPU e --multiprocess in GPU." VerticalAlignment="Center" Opacity="0.75" />
</Grid>
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" /> <TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceDestinationPathTextBox" Text="{Binding DestinationPath, Mode=OneWay}" IsReadOnly="True" /> <TextBox Grid.Column="1" Name="FaceDestinationPathTextBox" Text="{Binding DestinationPath, Mode=OneWay}" IsReadOnly="True" />
@ -49,11 +59,11 @@
<TextBlock Text="Output encodings" FontWeight="Bold" Margin="0,4,0,0" /> <TextBlock Text="Output encodings" FontWeight="Bold" Margin="0,4,0,0" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="File out (.pkl):" VerticalAlignment="Center" /> <TextBlock Grid.Column="0" Text="Cartella out:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceOutputFolderTextBox" Text="{Binding FaceOutputFolderPath, Mode=TwoWay}" Watermark="C:\\output\\encodings.pkl" /> <TextBox Grid.Column="1" Name="FaceOutputFolderTextBox" Text="{Binding FaceOutputFolderPath, Mode=TwoWay}" Watermark="C:\output\face_encoder" />
<Button Grid.Column="2" Name="FaceSelectOutputButton" Click="SelectFaceOutputFile_Click" Width="104" Margin="6,0,0,0"> <Button Grid.Column="2" Name="FaceSelectOutputButton" Click="SelectFaceOutputFolder_Click" Width="104" Margin="6,0,0,0">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6">
<iconPacks:PackIconMaterial Kind="FileOutline" Width="14" Height="14" /> <iconPacks:PackIconMaterial Kind="FolderOutline" Width="14" Height="14" />
<TextBlock Text="Scegli..." /> <TextBlock Text="Scegli..." />
</StackPanel> </StackPanel>
</Button> </Button>
@ -64,9 +74,22 @@
</StackPanel> </StackPanel>
</Button> </Button>
</Grid> </Grid>
<TextBlock Text="I file vengono creati come face_encodings_yyyyMMdd_HHmmss_nomecartella.pkl e encoder_log_yyyyMMdd_HHmmss_nomecartella.txt."
TextWrapping="Wrap"
Opacity="0.75" />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0"> <StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
<Button Name="FaceRunButton" Content="Esegui Face Encoder" Command="{Binding StartFaceEncoderCommand}" /> <Button Name="FaceRunButton" Command="{Binding StartFaceEncoderCommand}">
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<ProgressBar Width="18"
Height="18"
IsIndeterminate="True"
IsVisible="{Binding IsFaceEncoderRunning}"
ShowProgressText="False"
VerticalAlignment="Center" />
<TextBlock Text="Esegui Face Encoder" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Content="Stop" Command="{Binding StopFaceEncoderCommand}" /> <Button Content="Stop" Command="{Binding StopFaceEncoderCommand}" />
<TextBlock VerticalAlignment="Center" Text="{Binding FaceStatusMessage}" /> <TextBlock VerticalAlignment="Center" Text="{Binding FaceStatusMessage}" />
</StackPanel> </StackPanel>
@ -77,7 +100,10 @@
IsReadOnly="True" IsReadOnly="True"
AcceptsReturn="True" AcceptsReturn="True"
TextWrapping="Wrap" TextWrapping="Wrap"
MinHeight="180" /> FontFamily="Cascadia Mono, Consolas, monospace"
Height="220"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Auto" />
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>

View file

@ -1,7 +1,9 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.Threading;
using System; using System;
using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
@ -9,9 +11,46 @@ namespace ImageCatalog_2.AvaloniaViews;
public partial class FaceAiTabView : Avalonia.Controls.UserControl public partial class FaceAiTabView : Avalonia.Controls.UserControl
{ {
private INotifyPropertyChanged? _faceAiPropertySource;
public FaceAiTabView() public FaceAiTabView()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_faceAiPropertySource is not null)
{
_faceAiPropertySource.PropertyChanged -= OnFaceAiPropertyChanged;
}
_faceAiPropertySource = DataContext as INotifyPropertyChanged;
if (_faceAiPropertySource is not null)
{
_faceAiPropertySource.PropertyChanged += OnFaceAiPropertyChanged;
}
}
private void OnFaceAiPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (!string.Equals(e.PropertyName, nameof(DataModel.FaceCommandOutput), StringComparison.Ordinal))
{
return;
}
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputTextBox");
if (outputBox is null)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
var textLength = outputBox.Text?.Length ?? 0;
outputBox.CaretIndex = textLength;
});
} }
private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e) private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e)
@ -29,19 +68,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return; return;
} }
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = "Seleziona face_encoder_cpu.exe o face_encoder_gpu.exe", Title = "Seleziona la cartella Face Recognition Windows"
FileTypeFilter =
[
new FilePickerFileType("Eseguibile") { Patterns = ["*.exe"] },
new FilePickerFileType("Tutti i file") { Patterns = ["*.*"] }
]
}); });
if (files.Count > 0) if (folders.Count > 0)
{ {
executableBox.Text = files[0].Path.LocalPath; executableBox.Text = folders[0].Path.LocalPath;
if (DataContext is DataModel model) if (DataContext is DataModel model)
{ {
model.FaceExecutablePath = executableBox.Text; model.FaceExecutablePath = executableBox.Text;
@ -49,7 +83,7 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
} }
} }
private async void SelectFaceOutputFile_Click(object? sender, RoutedEventArgs e) private async void SelectFaceOutputFolder_Click(object? sender, RoutedEventArgs e)
{ {
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputFolderTextBox"); var outputBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputFolderTextBox");
if (outputBox is null) if (outputBox is null)
@ -64,21 +98,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return; return;
} }
var files = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = "Seleziona file output encodings (.pkl)", Title = "Seleziona la cartella output per encodings e log"
SuggestedFileName = "encodings.pkl",
DefaultExtension = "pkl",
FileTypeChoices =
[
new FilePickerFileType("Pickle file") { Patterns = ["*.pkl"] }
],
ShowOverwritePrompt = true
}); });
if (files is not null) if (folders.Count > 0)
{ {
outputBox.Text = files.Path.LocalPath; outputBox.Text = folders[0].Path.LocalPath;
if (DataContext is DataModel model) if (DataContext is DataModel model)
{ {
model.FaceOutputFolderPath = outputBox.Text; model.FaceOutputFolderPath = outputBox.Text;
@ -100,6 +127,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return; return;
} }
if (Directory.Exists(path))
{
OpenInExplorer(path);
return;
}
if (File.Exists(path)) if (File.Exists(path))
{ {
OpenInExplorer(path); OpenInExplorer(path);
@ -124,6 +157,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return; return;
} }
if (Directory.Exists(outputPath))
{
OpenInExplorer(outputPath);
return;
}
if (File.Exists(outputPath)) if (File.Exists(outputPath))
{ {
OpenInExplorer(outputPath); OpenInExplorer(outputPath);
@ -148,6 +187,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return; return;
} }
if (Directory.Exists(path))
{
OpenInExplorer(path);
return;
}
var directory = Path.GetDirectoryName(path); var directory = Path.GetDirectoryName(path);
OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory); OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory);
} }

View file

@ -60,8 +60,9 @@ namespace ImageCatalog_2
private Process? _faceEncoderProcess; private Process? _faceEncoderProcess;
private CancellationTokenSource? _faceEncoderWatcherTokenSource; private CancellationTokenSource? _faceEncoderWatcherTokenSource;
private Task? _faceEncoderWatcherTask; private Task? _faceEncoderWatcherTask;
private CancellationTokenSource? _faceEncoderLogWatcherTokenSource;
private Task? _faceEncoderLogWatcherTask;
private bool _hasStartedFaceEncoderInSession; private bool _hasStartedFaceEncoderInSession;
private bool _isUpdatingFaceExecutableSelection;
// ComboBox collections // ComboBox collections
public List<string> AvailableFonts { get; } public List<string> AvailableFonts { get; }
@ -230,6 +231,32 @@ namespace ImageCatalog_2
set => _ai.FaceRecursive = value; set => _ai.FaceRecursive = value;
} }
public bool FaceIncludeThumbnails
{
get => _ai.FaceIncludeThumbnails;
set => _ai.FaceIncludeThumbnails = value;
}
public IReadOnlyList<int> FaceParallelismOptions { get; } = [1, 2, 3, 4, 5];
public int FaceParallelism
{
get => _ai.FaceParallelism;
set => _ai.FaceParallelism = value;
}
public int FaceMinSize
{
get => _ai.FaceMinSize;
set => _ai.FaceMinSize = value;
}
public bool FaceUpsample
{
get => _ai.FaceUpsample;
set => _ai.FaceUpsample = value;
}
public bool FaceGpuOptionEnabled => _ai.FaceGpuOptionEnabled; public bool FaceGpuOptionEnabled => _ai.FaceGpuOptionEnabled;
public bool UseFaceGpu public bool UseFaceGpu
@ -1415,13 +1442,22 @@ namespace ImageCatalog_2
return; return;
} }
var executablePath = NormalizeFilePathArgument(FaceExecutablePath); var executableRootPath = NormalizeFilePathArgument(FaceExecutablePath);
var outputFilePath = NormalizeFilePathArgument(FaceOutputFolderPath); var outputFolderPath = NormalizeDirectoryPathArgument(FaceOutputFolderPath);
var imagesFolder = NormalizeDirectoryPathArgument(DestinationPath); var imagesFolder = NormalizeDirectoryPathArgument(DestinationPath);
var executablePath = ResolveConfiguredFaceEncoderExecutablePath(executableRootPath, UseFaceGpu);
if (string.IsNullOrWhiteSpace(executableRootPath))
{
FaceStatusMessage = "Percorso cartella face encoder non valido.";
return;
}
if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath)) if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath))
{ {
FaceStatusMessage = "Percorso eseguibile non valido."; FaceStatusMessage = UseFaceGpu
? "Impossibile trovare face_encoder_gpu.exe nella cartella selezionata."
: "Impossibile trovare face_encoder_cpu.exe nella cartella selezionata.";
return; return;
} }
@ -1431,38 +1467,33 @@ namespace ImageCatalog_2
return; return;
} }
if (string.IsNullOrWhiteSpace(outputFilePath)) if (string.IsNullOrWhiteSpace(outputFolderPath))
{ {
FaceStatusMessage = "Inserisci il file di output .pkl."; FaceStatusMessage = "Inserisci la cartella di output per encodings e log.";
return;
}
if (!string.Equals(Path.GetExtension(outputFilePath), ".pkl", StringComparison.OrdinalIgnoreCase))
{
FaceStatusMessage = "Il file di output deve avere estensione .pkl.";
return; return;
} }
try try
{ {
var outputDirectory = Path.GetDirectoryName(outputFilePath); Directory.CreateDirectory(outputFolderPath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Unable to create face output directory for file: {OutputFilePath}", outputFilePath); _logger.LogError(ex, "Unable to create face output directory: {OutputFolderPath}", outputFolderPath);
FaceStatusMessage = "Impossibile creare la cartella del file di output."; FaceStatusMessage = "Impossibile creare la cartella di output.";
return; return;
} }
FaceExecutablePath = executablePath; var parallelism = NormalizeFaceParallelism(FaceParallelism);
FaceOutputFolderPath = outputFilePath; var minSize = NormalizeFaceMinSize(FaceMinSize);
var outputFiles = BuildFaceEncoderOutputPaths(outputFolderPath, imagesFolder, DateTime.Now);
FaceExecutablePath = executableRootPath;
FaceOutputFolderPath = outputFolderPath;
FaceCommandOutput = string.Empty; FaceCommandOutput = string.Empty;
FaceStatusMessage = "Esecuzione face encoder in corso..."; FaceStatusMessage = "Esecuzione face encoder in corso...";
var transcriptLines = new StringBuilder();
var outputLines = new StringBuilder(); var outputLines = new StringBuilder();
var errorLines = new StringBuilder(); var errorLines = new StringBuilder();
@ -1475,23 +1506,50 @@ namespace ImageCatalog_2
UseShellExecute = false, UseShellExecute = false,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
RedirectStandardInput = true, RedirectStandardInput = false,
CreateNoWindow = false, CreateNoWindow = false,
}; };
processStartInfo.ArgumentList.Add("--images"); processStartInfo.ArgumentList.Add("--images");
processStartInfo.ArgumentList.Add(imagesFolder); processStartInfo.ArgumentList.Add(imagesFolder);
processStartInfo.ArgumentList.Add("--out"); processStartInfo.ArgumentList.Add("--out");
processStartInfo.ArgumentList.Add(outputFilePath); processStartInfo.ArgumentList.Add(outputFiles.OutputFilePath);
processStartInfo.ArgumentList.Add("--log");
processStartInfo.ArgumentList.Add(outputFiles.LogFilePath);
if (FaceRecursive) if (FaceRecursive)
{ {
processStartInfo.ArgumentList.Add("--recursive"); processStartInfo.ArgumentList.Add("--recursive");
} }
if (FaceIncludeThumbnails)
{
processStartInfo.ArgumentList.Add("--include-tn");
}
processStartInfo.ArgumentList.Add(UseFaceGpu ? "--multiprocess" : "--multicore");
processStartInfo.ArgumentList.Add(parallelism.ToString());
processStartInfo.ArgumentList.Add("--min-size");
processStartInfo.ArgumentList.Add(minSize.ToString());
if (FaceUpsample)
{
processStartInfo.ArgumentList.Add("--upsample");
}
using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true }; using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, args) => AppendFaceProcessOutput(outputLines, args.Data); process.OutputDataReceived += (_, args) => AppendFaceProcessOutput(outputLines, transcriptLines, args.Data, isError: false);
process.ErrorDataReceived += (_, args) => AppendFaceProcessOutput(errorLines, args.Data); process.ErrorDataReceived += (_, args) => AppendFaceProcessOutput(errorLines, transcriptLines, args.Data, isError: true);
process.Exited += (_, _) =>
{
_ = InvokeOnUiThreadAsync(() =>
{
if (!ComputeIsFaceEncoderRunning())
{
IsFaceEncoderRunning = false;
}
});
};
if (!process.Start()) if (!process.Start())
{ {
@ -1503,14 +1561,21 @@ namespace ImageCatalog_2
TrackFaceEncoderProcess(process); TrackFaceEncoderProcess(process);
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = true).ConfigureAwait(false); await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = true).ConfigureAwait(false);
if (UseFaceGpu)
{
StartFaceEncoderLogWatcher(outputFiles.LogFilePath, outputLines, transcriptLines);
}
process.BeginOutputReadLine(); process.BeginOutputReadLine();
process.BeginErrorReadLine(); process.BeginErrorReadLine();
await process.WaitForExitAsync().ConfigureAwait(false); await process.WaitForExitAsync().ConfigureAwait(false);
var summary = BuildFaceEncoderSummary(process.ExitCode, outputLines, errorLines); var summary = BuildFaceEncoderSummary(process.ExitCode, processStartInfo, outputFiles.OutputFilePath, outputFiles.LogFilePath, outputLines, errorLines);
await InvokeOnUiThreadAsync(() => await InvokeOnUiThreadAsync(() =>
{ {
FaceCommandOutput = summary; FaceCommandOutput = string.IsNullOrWhiteSpace(FaceCommandOutput)
? summary
: $"{FaceCommandOutput.TrimEnd()}\n\n{summary}";
FaceStatusMessage = process.ExitCode == 0 FaceStatusMessage = process.ExitCode == 0
? "Face encoder completato." ? "Face encoder completato."
: $"Face encoder terminato con errore (code {process.ExitCode})."; : $"Face encoder terminato con errore (code {process.ExitCode}).";
@ -1518,6 +1583,7 @@ namespace ImageCatalog_2
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine(ex);
_logger.LogError(ex, "Face encoder execution failed."); _logger.LogError(ex, "Face encoder execution failed.");
await InvokeOnUiThreadAsync(() => await InvokeOnUiThreadAsync(() =>
{ {
@ -1527,6 +1593,7 @@ namespace ImageCatalog_2
} }
finally finally
{ {
await StopFaceEncoderLogWatcherAsync().ConfigureAwait(false);
ClearTrackedFaceEncoderProcess(); ClearTrackedFaceEncoderProcess();
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = ComputeIsFaceEncoderRunning()).ConfigureAwait(false); await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = ComputeIsFaceEncoderRunning()).ConfigureAwait(false);
} }
@ -1569,81 +1636,127 @@ namespace ImageCatalog_2
_faceEncoderWatcherTask = WatchFaceEncoderProcessAsync(_faceEncoderWatcherTokenSource.Token); _faceEncoderWatcherTask = WatchFaceEncoderProcessAsync(_faceEncoderWatcherTokenSource.Token);
} }
private void RefreshFaceExecutableCapabilities() private void StartFaceEncoderLogWatcher(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines)
{ {
if (_isUpdatingFaceExecutableSelection) _faceEncoderLogWatcherTokenSource?.Cancel();
_faceEncoderLogWatcherTokenSource?.Dispose();
_faceEncoderLogWatcherTokenSource = new CancellationTokenSource();
_faceEncoderLogWatcherTask = WatchFaceEncoderLogFileAsync(logFilePath, outputLines, transcriptLines, _faceEncoderLogWatcherTokenSource.Token);
}
private async Task StopFaceEncoderLogWatcherAsync()
{
var tokenSource = _faceEncoderLogWatcherTokenSource;
var task = _faceEncoderLogWatcherTask;
_faceEncoderLogWatcherTokenSource = null;
_faceEncoderLogWatcherTask = null;
if (tokenSource is null)
{ {
return; return;
} }
var executableName = Path.GetFileNameWithoutExtension(NormalizeFilePathArgument(_ai.FaceExecutablePath)); try
var supportsGpu = executableName.EndsWith("_cpu", StringComparison.OrdinalIgnoreCase) {
|| executableName.EndsWith("_gpu", StringComparison.OrdinalIgnoreCase); await tokenSource.CancelAsync().ConfigureAwait(false);
var useGpu = supportsGpu && executableName.EndsWith("_gpu", StringComparison.OrdinalIgnoreCase); if (task is not null)
{
await task.ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Expected when shutting down the watcher.
}
finally
{
tokenSource.Dispose();
}
}
_ai.FaceGpuOptionEnabled = supportsGpu; private async Task WatchFaceEncoderLogFileAsync(string logFilePath, StringBuilder outputLines, StringBuilder transcriptLines, CancellationToken token)
_ai.UseFaceGpu = supportsGpu && useGpu; {
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);
AppendFaceProcessOutput(outputLines, transcriptLines, line, isError: false);
}
filePosition = stream.Position;
}
}
catch (OperationCanceledException)
{
throw;
}
catch (IOException)
{
// Retry on the next polling interval while the encoder is still writing.
}
catch (UnauthorizedAccessException)
{
// Retry on the next polling interval if the log file is transiently locked.
}
await Task.Delay(TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false);
}
}
private void RefreshFaceExecutableCapabilities()
{
var executableRoot = NormalizeFilePathArgument(_ai.FaceExecutablePath);
var hasCpu = !string.IsNullOrWhiteSpace(ResolveConfiguredFaceEncoderExecutablePath(executableRoot, useGpu: false));
var hasGpu = !string.IsNullOrWhiteSpace(ResolveConfiguredFaceEncoderExecutablePath(executableRoot, useGpu: true));
_ai.FaceGpuOptionEnabled = hasCpu && hasGpu;
if (hasGpu && !hasCpu)
{
_ai.UseFaceGpu = true;
}
else if (!hasGpu)
{
_ai.UseFaceGpu = false;
}
} }
private void SetUseFaceGpu(bool value) private void SetUseFaceGpu(bool value)
{ {
var currentValue = _ai.UseFaceGpu;
if (!FaceGpuOptionEnabled) if (!FaceGpuOptionEnabled)
{ {
if (_ai.UseFaceGpu)
{
_ai.UseFaceGpu = false;
}
return; return;
} }
if (_ai.UseFaceGpu == value) if (currentValue == value)
{ {
return; return;
} }
_ai.UseFaceGpu = value; _ai.UseFaceGpu = value;
var currentPath = NormalizeFilePathArgument(_ai.FaceExecutablePath); var previousRecommendedUpsample = GetRecommendedFaceUpsample(currentValue);
if (string.IsNullOrWhiteSpace(currentPath)) if (_ai.FaceUpsample == previousRecommendedUpsample)
{ {
return; _ai.FaceUpsample = GetRecommendedFaceUpsample(value);
}
var extension = Path.GetExtension(currentPath);
var fileName = Path.GetFileNameWithoutExtension(currentPath);
var baseName = fileName.EndsWith("_cpu", StringComparison.OrdinalIgnoreCase)
? fileName[..^4]
: fileName.EndsWith("_gpu", StringComparison.OrdinalIgnoreCase)
? fileName[..^4]
: fileName;
if (string.Equals(baseName, fileName, StringComparison.Ordinal))
{
_ai.UseFaceGpu = false;
_ai.FaceGpuOptionEnabled = false;
return;
}
var updatedFileName = $"{baseName}{(value ? "_gpu" : "_cpu")}{extension}";
var directory = Path.GetDirectoryName(currentPath);
var updatedPath = string.IsNullOrWhiteSpace(directory)
? updatedFileName
: Path.Combine(directory, updatedFileName);
if (string.Equals(updatedPath, currentPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
_isUpdatingFaceExecutableSelection = true;
try
{
_ai.FaceExecutablePath = updatedPath;
}
finally
{
_isUpdatingFaceExecutableSelection = false;
} }
} }
@ -1690,7 +1803,7 @@ namespace ImageCatalog_2
private Process? FindConfiguredFaceEncoderProcess() private Process? FindConfiguredFaceEncoderProcess()
{ {
var configuredExecutablePath = NormalizeFilePathArgument(FaceExecutablePath); var configuredExecutablePath = ResolveConfiguredFaceEncoderExecutablePath(FaceExecutablePath, UseFaceGpu);
if (string.IsNullOrWhiteSpace(configuredExecutablePath)) if (string.IsNullOrWhiteSpace(configuredExecutablePath))
{ {
return null; return null;
@ -1803,7 +1916,7 @@ namespace ImageCatalog_2
} }
} }
private static void AppendFaceProcessOutput(StringBuilder builder, string? line) private void AppendFaceProcessOutput(StringBuilder builder, StringBuilder transcriptBuilder, string? line, bool isError)
{ {
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
{ {
@ -1814,12 +1927,162 @@ namespace ImageCatalog_2
{ {
builder.AppendLine(line); 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(() => FaceCommandOutput = transcript);
} }
private static string BuildFaceEncoderSummary(int exitCode, StringBuilder outputLines, StringBuilder errorLines) internal static string? ResolveConfiguredFaceEncoderExecutablePath(string configuredPath, bool useGpu)
{
var variant = useGpu ? "gpu" : "cpu";
foreach (var candidate in EnumerateFaceEncoderExecutableCandidates(configuredPath, variant))
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
internal static (string OutputFilePath, string LogFilePath) BuildFaceEncoderOutputPaths(string outputFolderPath, string imagesFolderPath, DateTime timestamp)
{
var safeRaceName = BuildSafeFaceEncoderRaceName(imagesFolderPath);
var timestampToken = timestamp.ToString("yyyyMMdd_HHmmss");
return (
Path.Combine(outputFolderPath, $"face_encodings_{timestampToken}_{safeRaceName}.pkl"),
Path.Combine(outputFolderPath, $"encoder_log_{timestampToken}_{safeRaceName}.txt"));
}
internal static string BuildSafeFaceEncoderRaceName(string imagesFolderPath)
{
var raceName = new DirectoryInfo(imagesFolderPath).Name;
if (string.IsNullOrWhiteSpace(raceName))
{
return "race";
}
var invalidChars = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(raceName.Length);
var previousWasSeparator = false;
foreach (var currentChar in raceName)
{
if (char.IsWhiteSpace(currentChar) || invalidChars.Contains(currentChar))
{
if (!previousWasSeparator)
{
builder.Append('_');
previousWasSeparator = true;
}
continue;
}
builder.Append(currentChar);
previousWasSeparator = false;
}
return builder.ToString().Trim('_') switch
{
"" => "race",
var sanitized => sanitized
};
}
private static IEnumerable<string> EnumerateFaceEncoderExecutableCandidates(string configuredPath, string variant)
{
var normalizedPath = NormalizeFilePathArgument(configuredPath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
yield break;
}
var executableName = $"face_encoder_{variant}.exe";
if (File.Exists(normalizedPath))
{
var fileDirectory = Path.GetDirectoryName(normalizedPath);
if (!string.IsNullOrWhiteSpace(fileDirectory))
{
yield return Path.Combine(fileDirectory, executableName);
var parentDirectory = Directory.GetParent(fileDirectory)?.FullName;
if (!string.IsNullOrWhiteSpace(parentDirectory))
{
yield return Path.Combine(parentDirectory, $"face_encoder_{variant}", executableName);
}
}
yield return normalizedPath;
yield break;
}
yield return Path.Combine(normalizedPath, executableName);
yield return Path.Combine(normalizedPath, $"face_encoder_{variant}", executableName);
var leafName = Path.GetFileName(normalizedPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
if (leafName.Equals("face_encoder_cpu", StringComparison.OrdinalIgnoreCase)
|| leafName.Equals("face_encoder_gpu", StringComparison.OrdinalIgnoreCase))
{
var parentDirectory = Directory.GetParent(normalizedPath)?.FullName;
if (!string.IsNullOrWhiteSpace(parentDirectory))
{
yield return Path.Combine(parentDirectory, $"face_encoder_{variant}", executableName);
}
}
}
private static int NormalizeFaceParallelism(int value)
{
return value is >= 1 and <= 5 ? value : 3;
}
private static int NormalizeFaceMinSize(int value)
{
return value > 0 ? value : 35;
}
private static bool GetRecommendedFaceUpsample(bool useGpu)
{
return !useGpu;
}
private static string BuildFaceEncoderSummary(
int exitCode,
ProcessStartInfo processStartInfo,
string outputFilePath,
string logFilePath,
StringBuilder outputLines,
StringBuilder errorLines)
{ {
var summary = new StringBuilder(); var summary = new StringBuilder();
summary.AppendLine($"Exit code: {exitCode}"); summary.AppendLine($"Exit code: {exitCode}");
summary.AppendLine($"Command: {processStartInfo.FileName} {string.Join(" ", processStartInfo.ArgumentList)}");
summary.AppendLine($"Output file: {outputFilePath}");
summary.AppendLine($"Log file: {logFilePath}");
lock (outputLines) lock (outputLines)
{ {

View file

@ -286,6 +286,22 @@ namespace ImageCatalog_2.Models
[XmlElement("AI_FaceRecursive")] [XmlElement("AI_FaceRecursive")]
public bool FaceRecursive { get; set; } public bool FaceRecursive { get; set; }
[JsonPropertyName("FaceIncludeThumbnails")]
[XmlElement("AI_FaceIncludeThumbnails")]
public bool FaceIncludeThumbnails { get; set; }
[JsonPropertyName("FaceParallelism")]
[XmlElement("AI_FaceParallelism")]
public int FaceParallelism { get; set; } = 3;
[JsonPropertyName("FaceMinSize")]
[XmlElement("AI_FaceMinSize")]
public int FaceMinSize { get; set; } = 35;
[JsonPropertyName("FaceUpsample")]
[XmlElement("AI_FaceUpsample")]
public bool FaceUpsample { get; set; } = true;
// Race upload settings // Race upload settings
[JsonPropertyName("ApiLogin")] [JsonPropertyName("ApiLogin")]
[XmlElement("RaceUpload_Login")] [XmlElement("RaceUpload_Login")]

View file

@ -72,34 +72,8 @@ static class Program
internal static bool TrySendConsoleInterrupt(int processId) internal static bool TrySendConsoleInterrupt(int processId)
{ {
var hadConsole = GetConsoleWindow() != IntPtr.Zero; _ = processId;
return false;
try
{
if (hadConsole)
{
FreeConsole();
}
if (!AttachConsole(processId))
{
return false;
}
SetConsoleCtrlHandler(null, true);
return GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0);
}
finally
{
FreeConsole();
SetConsoleCtrlHandler(null, false);
if (hadConsole)
{
AllocConsole();
RedirectConsoleOutput();
}
}
} }
#endif #endif

View file

@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MaddoShared.Tests")]

View file

@ -71,6 +71,50 @@ public class AiSettingsViewModel : ViewModelBase
} }
} }
private bool _faceIncludeThumbnails;
public bool FaceIncludeThumbnails
{
get => _faceIncludeThumbnails;
set
{
_faceIncludeThumbnails = value;
NotifyPropertyChanged();
}
}
private int _faceParallelism = 3;
public int FaceParallelism
{
get => _faceParallelism;
set
{
_faceParallelism = value;
NotifyPropertyChanged();
}
}
private int _faceMinSize = 35;
public int FaceMinSize
{
get => _faceMinSize;
set
{
_faceMinSize = value;
NotifyPropertyChanged();
}
}
private bool _faceUpsample = true;
public bool FaceUpsample
{
get => _faceUpsample;
set
{
_faceUpsample = value;
NotifyPropertyChanged();
}
}
private bool _faceGpuOptionEnabled; private bool _faceGpuOptionEnabled;
public bool FaceGpuOptionEnabled public bool FaceGpuOptionEnabled
{ {