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,16 +5,16 @@
<ScrollViewer>
<StackPanel Margin="4" Spacing="6">
<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" />
<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">
<TextBlock Grid.Column="0" Text="face_encoder:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceExecutablePathTextBox" Text="{Binding FaceExecutablePath, Mode=TwoWay}" Watermark="C:\\tools\\face_encoder_cpu.exe" />
<TextBlock Grid.Column="0" Text="Percorso:" VerticalAlignment="Center" />
<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">
<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..." />
</StackPanel>
</Button>
@ -28,14 +28,24 @@
<StackPanel Orientation="Horizontal" Spacing="12">
<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"
IsChecked="{Binding UseFaceGpu, Mode=TwoWay}"
IsEnabled="{Binding FaceGpuOptionEnabled}" />
</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"
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">
<TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" />
<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" />
<Grid ColumnDefinitions="Auto,*,Auto,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="File out (.pkl):" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceOutputFolderTextBox" Text="{Binding FaceOutputFolderPath, Mode=TwoWay}" Watermark="C:\\output\\encodings.pkl" />
<Button Grid.Column="2" Name="FaceSelectOutputButton" Click="SelectFaceOutputFile_Click" Width="104" Margin="6,0,0,0">
<TextBlock Grid.Column="0" Text="Cartella out:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Name="FaceOutputFolderTextBox" Text="{Binding FaceOutputFolderPath, Mode=TwoWay}" Watermark="C:\output\face_encoder" />
<Button Grid.Column="2" Name="FaceSelectOutputButton" Click="SelectFaceOutputFolder_Click" Width="104" Margin="6,0,0,0">
<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..." />
</StackPanel>
</Button>
@ -64,9 +74,22 @@
</StackPanel>
</Button>
</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">
<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}" />
<TextBlock VerticalAlignment="Center" Text="{Binding FaceStatusMessage}" />
</StackPanel>
@ -77,7 +100,10 @@
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="180" />
FontFamily="Cascadia Mono, Consolas, monospace"
Height="220"
ScrollViewer.VerticalScrollBarVisibility="Auto"
ScrollViewer.HorizontalScrollBarVisibility="Auto" />
</StackPanel>
</ScrollViewer>
</UserControl>

View file

@ -1,7 +1,9 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
@ -9,9 +11,46 @@ namespace ImageCatalog_2.AvaloniaViews;
public partial class FaceAiTabView : Avalonia.Controls.UserControl
{
private INotifyPropertyChanged? _faceAiPropertySource;
public FaceAiTabView()
{
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)
@ -29,19 +68,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
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",
FileTypeFilter =
[
new FilePickerFileType("Eseguibile") { Patterns = ["*.exe"] },
new FilePickerFileType("Tutti i file") { Patterns = ["*.*"] }
]
Title = "Seleziona la cartella Face Recognition Windows"
});
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)
{
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");
if (outputBox is null)
@ -64,21 +98,14 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
var files = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
var folders = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Seleziona file output encodings (.pkl)",
SuggestedFileName = "encodings.pkl",
DefaultExtension = "pkl",
FileTypeChoices =
[
new FilePickerFileType("Pickle file") { Patterns = ["*.pkl"] }
],
ShowOverwritePrompt = true
Title = "Seleziona la cartella output per encodings e log"
});
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)
{
model.FaceOutputFolderPath = outputBox.Text;
@ -100,6 +127,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
if (Directory.Exists(path))
{
OpenInExplorer(path);
return;
}
if (File.Exists(path))
{
OpenInExplorer(path);
@ -124,6 +157,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
if (Directory.Exists(outputPath))
{
OpenInExplorer(outputPath);
return;
}
if (File.Exists(outputPath))
{
OpenInExplorer(outputPath);
@ -148,6 +187,12 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
return;
}
if (Directory.Exists(path))
{
OpenInExplorer(path);
return;
}
var directory = Path.GetDirectoryName(path);
OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory);
}

View file

@ -60,8 +60,9 @@ namespace ImageCatalog_2
private Process? _faceEncoderProcess;
private CancellationTokenSource? _faceEncoderWatcherTokenSource;
private Task? _faceEncoderWatcherTask;
private CancellationTokenSource? _faceEncoderLogWatcherTokenSource;
private Task? _faceEncoderLogWatcherTask;
private bool _hasStartedFaceEncoderInSession;
private bool _isUpdatingFaceExecutableSelection;
// ComboBox collections
public List<string> AvailableFonts { get; }
@ -230,6 +231,32 @@ namespace ImageCatalog_2
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 UseFaceGpu
@ -1415,13 +1442,22 @@ namespace ImageCatalog_2
return;
}
var executablePath = NormalizeFilePathArgument(FaceExecutablePath);
var outputFilePath = NormalizeFilePathArgument(FaceOutputFolderPath);
var executableRootPath = NormalizeFilePathArgument(FaceExecutablePath);
var outputFolderPath = NormalizeDirectoryPathArgument(FaceOutputFolderPath);
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))
{
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;
}
@ -1431,38 +1467,33 @@ namespace ImageCatalog_2
return;
}
if (string.IsNullOrWhiteSpace(outputFilePath))
if (string.IsNullOrWhiteSpace(outputFolderPath))
{
FaceStatusMessage = "Inserisci il file di output .pkl.";
return;
}
if (!string.Equals(Path.GetExtension(outputFilePath), ".pkl", StringComparison.OrdinalIgnoreCase))
{
FaceStatusMessage = "Il file di output deve avere estensione .pkl.";
FaceStatusMessage = "Inserisci la cartella di output per encodings e log.";
return;
}
try
{
var outputDirectory = Path.GetDirectoryName(outputFilePath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
Directory.CreateDirectory(outputFolderPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to create face output directory for file: {OutputFilePath}", outputFilePath);
FaceStatusMessage = "Impossibile creare la cartella del file di output.";
_logger.LogError(ex, "Unable to create face output directory: {OutputFolderPath}", outputFolderPath);
FaceStatusMessage = "Impossibile creare la cartella di output.";
return;
}
FaceExecutablePath = executablePath;
FaceOutputFolderPath = outputFilePath;
var parallelism = NormalizeFaceParallelism(FaceParallelism);
var minSize = NormalizeFaceMinSize(FaceMinSize);
var outputFiles = BuildFaceEncoderOutputPaths(outputFolderPath, imagesFolder, DateTime.Now);
FaceExecutablePath = executableRootPath;
FaceOutputFolderPath = outputFolderPath;
FaceCommandOutput = string.Empty;
FaceStatusMessage = "Esecuzione face encoder in corso...";
var transcriptLines = new StringBuilder();
var outputLines = new StringBuilder();
var errorLines = new StringBuilder();
@ -1475,23 +1506,50 @@ namespace ImageCatalog_2
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
RedirectStandardInput = false,
CreateNoWindow = false,
};
processStartInfo.ArgumentList.Add("--images");
processStartInfo.ArgumentList.Add(imagesFolder);
processStartInfo.ArgumentList.Add("--out");
processStartInfo.ArgumentList.Add(outputFilePath);
processStartInfo.ArgumentList.Add(outputFiles.OutputFilePath);
processStartInfo.ArgumentList.Add("--log");
processStartInfo.ArgumentList.Add(outputFiles.LogFilePath);
if (FaceRecursive)
{
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 };
process.OutputDataReceived += (_, args) => AppendFaceProcessOutput(outputLines, args.Data);
process.ErrorDataReceived += (_, args) => AppendFaceProcessOutput(errorLines, args.Data);
process.OutputDataReceived += (_, args) => AppendFaceProcessOutput(outputLines, transcriptLines, args.Data, isError: false);
process.ErrorDataReceived += (_, args) => AppendFaceProcessOutput(errorLines, transcriptLines, args.Data, isError: true);
process.Exited += (_, _) =>
{
_ = InvokeOnUiThreadAsync(() =>
{
if (!ComputeIsFaceEncoderRunning())
{
IsFaceEncoderRunning = false;
}
});
};
if (!process.Start())
{
@ -1503,14 +1561,21 @@ namespace ImageCatalog_2
TrackFaceEncoderProcess(process);
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = true).ConfigureAwait(false);
if (UseFaceGpu)
{
StartFaceEncoderLogWatcher(outputFiles.LogFilePath, outputLines, transcriptLines);
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
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(() =>
{
FaceCommandOutput = summary;
FaceCommandOutput = string.IsNullOrWhiteSpace(FaceCommandOutput)
? summary
: $"{FaceCommandOutput.TrimEnd()}\n\n{summary}";
FaceStatusMessage = process.ExitCode == 0
? "Face encoder completato."
: $"Face encoder terminato con errore (code {process.ExitCode}).";
@ -1518,6 +1583,7 @@ namespace ImageCatalog_2
}
catch (Exception ex)
{
Console.Error.WriteLine(ex);
_logger.LogError(ex, "Face encoder execution failed.");
await InvokeOnUiThreadAsync(() =>
{
@ -1527,6 +1593,7 @@ namespace ImageCatalog_2
}
finally
{
await StopFaceEncoderLogWatcherAsync().ConfigureAwait(false);
ClearTrackedFaceEncoderProcess();
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = ComputeIsFaceEncoderRunning()).ConfigureAwait(false);
}
@ -1569,81 +1636,127 @@ namespace ImageCatalog_2
_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;
}
var executableName = Path.GetFileNameWithoutExtension(NormalizeFilePathArgument(_ai.FaceExecutablePath));
var supportsGpu = executableName.EndsWith("_cpu", StringComparison.OrdinalIgnoreCase)
|| executableName.EndsWith("_gpu", StringComparison.OrdinalIgnoreCase);
var useGpu = supportsGpu && executableName.EndsWith("_gpu", StringComparison.OrdinalIgnoreCase);
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();
}
}
_ai.FaceGpuOptionEnabled = supportsGpu;
_ai.UseFaceGpu = supportsGpu && useGpu;
private async Task WatchFaceEncoderLogFileAsync(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);
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)
{
var currentValue = _ai.UseFaceGpu;
if (!FaceGpuOptionEnabled)
{
if (_ai.UseFaceGpu)
{
_ai.UseFaceGpu = false;
}
return;
}
if (_ai.UseFaceGpu == value)
if (currentValue == value)
{
return;
}
_ai.UseFaceGpu = value;
var currentPath = NormalizeFilePathArgument(_ai.FaceExecutablePath);
if (string.IsNullOrWhiteSpace(currentPath))
var previousRecommendedUpsample = GetRecommendedFaceUpsample(currentValue);
if (_ai.FaceUpsample == previousRecommendedUpsample)
{
return;
}
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;
_ai.FaceUpsample = GetRecommendedFaceUpsample(value);
}
}
@ -1690,7 +1803,7 @@ namespace ImageCatalog_2
private Process? FindConfiguredFaceEncoderProcess()
{
var configuredExecutablePath = NormalizeFilePathArgument(FaceExecutablePath);
var configuredExecutablePath = ResolveConfiguredFaceEncoderExecutablePath(FaceExecutablePath, UseFaceGpu);
if (string.IsNullOrWhiteSpace(configuredExecutablePath))
{
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))
{
@ -1814,12 +1927,162 @@ namespace ImageCatalog_2
{
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();
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)
{

View file

@ -286,6 +286,22 @@ namespace ImageCatalog_2.Models
[XmlElement("AI_FaceRecursive")]
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
[JsonPropertyName("ApiLogin")]
[XmlElement("RaceUpload_Login")]

View file

@ -72,34 +72,8 @@ static class Program
internal static bool TrySendConsoleInterrupt(int processId)
{
var hadConsole = GetConsoleWindow() != IntPtr.Zero;
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();
}
}
_ = processId;
return false;
}
#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;
public bool FaceGpuOptionEnabled
{