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

@ -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)
{