develop #1

Open
maddo wants to merge 126 commits from develop into master
10 changed files with 790 additions and 219 deletions
Showing only changes of commit 988a3d94e1 - Show all commits

feat: Implement face encoder functionality with GPU support and recursive option

MaddoScientisto 2026-05-09 12:09:05 +02:00

View file

@ -14,15 +14,15 @@ env:
PROJECT_PATH: imagecatalog/ImageCatalog 2.csproj
PUBLISH_DIR: artifacts/publish/win-x64
ARTIFACT_NAME: imagecatalog-windows-avalonia
NUGET_SOURCE_NAME: Nuget-GitLab-AIFotoONLUS
NUGET_SOURCE_URL: https://gitlab.com/api/v4/projects/79509532/packages/nuget/index.json
NUGET_SOURCE_NAME: Nuget-Forgejo-AIFotoONLUS
NUGET_SOURCE_URL: ${{ vars.AIFOTOONLUS_NUGET_SOURCE_URL || format('{0}/api/packages/{1}/nuget/index.json', github.server_url, vars.AIFOTOONLUS_PACKAGE_OWNER || github.repository_owner) }}
jobs:
build:
runs-on: docker
env:
NUGET_USERNAME: ${{ secrets.NUGET_USERNAME }}
NUGET_PASSWORD: ${{ secrets.NUGET_PASSWORD }}
FORGEJO_PACKAGE_USERNAME: ${{ secrets.FORGEJO_PACKAGE_USERNAME }}
FORGEJO_PACKAGE_TOKEN: ${{ secrets.FORGEJO_PACKAGE_TOKEN }}
steps:
- name: Checkout
@ -36,12 +36,12 @@ jobs:
- name: Validate NuGet secrets
run: |
set -eu
if [ -z "${NUGET_USERNAME}" ]; then
echo "secrets.NUGET_USERNAME is required"
if [ -z "${FORGEJO_PACKAGE_USERNAME}" ]; then
echo "secrets.FORGEJO_PACKAGE_USERNAME is required"
exit 1
fi
if [ -z "${NUGET_PASSWORD}" ]; then
echo "secrets.NUGET_PASSWORD is required"
if [ -z "${FORGEJO_PACKAGE_TOKEN}" ]; then
echo "secrets.FORGEJO_PACKAGE_TOKEN is required"
exit 1
fi
@ -53,8 +53,8 @@ jobs:
dotnet nuget update source "${{ env.NUGET_SOURCE_NAME }}" \
--source "${{ env.NUGET_SOURCE_URL }}" \
--username "${NUGET_USERNAME}" \
--password "${NUGET_PASSWORD}" \
--username "${FORGEJO_PACKAGE_USERNAME}" \
--password "${FORGEJO_PACKAGE_TOKEN}" \
--store-password-in-clear-text \
--configfile "${temp_config}"

13
Catalog.code-workspace Normal file
View file

@ -0,0 +1,13 @@
{
"folders": [
{
"path": "."
},
{
"path": "../AIFotoONLUS"
}
],
"settings": {
"commentTranslate.hover.enabled": false
}
}

View file

@ -1,25 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Repository-level NuGet.Config to control which sources are queried for which
packages. This prevents the GitLab feed from being queried for all package
packages. This prevents the Forgejo feed from being queried for all package
IDs (which causes 401 errors for packages hosted on nuget.org).
Usage:
- Keep this file in the repository root so `dotnet restore` picks it up by default.
- CI still needs to add the `GitLab` source credentials at runtime (we do this
- CI still needs to add the `Forgejo` source credentials at runtime (we do this
from the pipeline using `dotnet nuget add source ...`), but the mapping below
ensures only the listed package ID patterns are requested from the GitLab feed.
ensures only the listed package ID patterns are requested from the Forgejo feed.
-->
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="Nuget-GitLab-AIFotoONLUS" value="https://gitlab.com/api/v4/projects/79509532/packages/nuget/index.json" />
<add key="Nuget-Forgejo-AIFotoONLUS" value="https://forgejo.maddoscientisto.net/api/packages/maddo/nuget/index.json" />
</packageSources>
<!-- Map private package IDs to the GitLab source; everything else uses nuget.org -->
<!-- Map private package IDs to the Forgejo source; everything else uses nuget.org -->
<packageSourceMapping>
<packageSource key="Nuget-GitLab-AIFotoONLUS">
<!-- Add patterns for your private packages hosted in GitLab -->
<packageSource key="Nuget-Forgejo-AIFotoONLUS">
<!-- Add patterns for your private packages hosted in Forgejo -->
<package pattern="AIFotoONLUS.*" />
<package pattern="AIFotoONLUS.Core" />
</packageSource>

View file

@ -3,6 +3,7 @@ using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using System.ComponentModel;
using System.IO;
namespace ImageCatalog_2;
@ -20,6 +21,7 @@ public partial class AvaloniaMainWindow : Window
DataContext = _model;
Opened += (_, _) => SyncThemeStateFromCurrentTheme();
Closing += AvaloniaMainWindow_Closing;
// Let DataModel marshal callbacks onto Avalonia UI thread.
_model.UiInvoker = action => Dispatcher.UIThread.Invoke(action);
@ -135,6 +137,29 @@ public partial class AvaloniaMainWindow : Window
};
}
private bool _isStoppingFaceEncoderForClose;
private async void AvaloniaMainWindow_Closing(object? sender, CancelEventArgs e)
{
if (_isStoppingFaceEncoderForClose || !_model.IsFaceEncoderRunning)
{
return;
}
e.Cancel = true;
_isStoppingFaceEncoderForClose = true;
try
{
await _model.StopFaceEncoderAsync("Arresto face encoder in chiusura...", waitForExit: true);
}
finally
{
_isStoppingFaceEncoderForClose = false;
Close();
}
}
private void ToggleTheme_Click(object? sender, RoutedEventArgs e)
{
_isDarkTheme = !_isDarkTheme;

View file

@ -5,13 +5,13 @@
<ScrollViewer>
<StackPanel Margin="4" Spacing="6">
<TextBlock Text="Face Recognition Encoder" FontWeight="Bold" />
<TextBlock Text="Esegue face_encoder.exe 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 un file .pkl come --out."
TextWrapping="Wrap" Opacity="0.8" />
<TextBlock Text="Eseguibile" 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.exe" />
<TextBox Grid.Column="1" Name="FaceExecutablePathTextBox" Text="{Binding FaceExecutablePath, Mode=TwoWay}" Watermark="C:\\tools\\face_encoder_cpu.exe" />
<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" />
@ -26,6 +26,16 @@
</Button>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="12">
<CheckBox Content="Ricorsivo (--recursive)" IsChecked="{Binding FaceRecursive, 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."
TextWrapping="Wrap"
Opacity="0.75" />
<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" />
@ -56,12 +66,14 @@
</Grid>
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
<Button Name="FaceRunButton" Content="Esegui Face Encoder" Click="RunFaceEncoder_Click" />
<TextBlock Name="FaceStatusTextBlock" VerticalAlignment="Center" />
<Button Name="FaceRunButton" Content="Esegui Face Encoder" Command="{Binding StartFaceEncoderCommand}" />
<Button Content="Stop" Command="{Binding StopFaceEncoderCommand}" />
<TextBlock VerticalAlignment="Center" Text="{Binding FaceStatusMessage}" />
</StackPanel>
<TextBlock Text="Output comando" FontWeight="Bold" Margin="0,6,0,0" />
<TextBox Name="FaceOutputTextBox"
Text="{Binding FaceCommandOutput}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"

View file

@ -1,25 +1,17 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace ImageCatalog_2.AvaloniaViews;
public partial class FaceAiTabView : Avalonia.Controls.UserControl
{
private readonly ILogger<FaceAiTabView> _logger;
public FaceAiTabView()
{
InitializeComponent();
_logger = Program.ServiceProvider.GetService(typeof(ILogger<FaceAiTabView>)) as ILogger<FaceAiTabView>
?? NullLogger<FaceAiTabView>.Instance;
}
private async void SelectFaceExecutable_Click(object? sender, RoutedEventArgs e)
@ -39,7 +31,7 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Seleziona face_encoder.exe",
Title = "Seleziona face_encoder_cpu.exe o face_encoder_gpu.exe",
FileTypeFilter =
[
new FilePickerFileType("Eseguibile") { Patterns = ["*.exe"] },
@ -160,167 +152,6 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? path : directory);
}
private async void RunFaceEncoder_Click(object? sender, RoutedEventArgs e)
{
var executableBox = this.FindControl<Avalonia.Controls.TextBox>("FaceExecutablePathTextBox");
var outputFolderBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputFolderTextBox");
var outputLogBox = this.FindControl<Avalonia.Controls.TextBox>("FaceOutputTextBox");
var statusBlock = this.FindControl<TextBlock>("FaceStatusTextBlock");
var runButton = this.FindControl<Avalonia.Controls.Button>("FaceRunButton");
if (executableBox is null || outputFolderBox is null || outputLogBox is null || statusBlock is null || runButton is null)
{
return;
}
if (DataContext is not DataModel model)
{
statusBlock.Text = "DataContext non valido.";
return;
}
var executablePath = executableBox.Text?.Trim().Trim('"') ?? string.Empty;
var outputFilePath = outputFolderBox.Text?.Trim().Trim('"') ?? string.Empty;
var imagesFolder = (model.DestinationPath ?? string.Empty).Trim().Trim('"');
model.FaceExecutablePath = executablePath;
model.FaceOutputFolderPath = outputFilePath;
if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath))
{
statusBlock.Text = "Percorso eseguibile non valido.";
return;
}
if (string.IsNullOrWhiteSpace(imagesFolder) || !Directory.Exists(imagesFolder))
{
statusBlock.Text = "Cartella Destinazione non valida.";
return;
}
if (string.IsNullOrWhiteSpace(outputFilePath))
{
statusBlock.Text = "Inserisci il file di output .pkl.";
return;
}
if (!string.Equals(Path.GetExtension(outputFilePath), ".pkl", StringComparison.OrdinalIgnoreCase))
{
statusBlock.Text = "Il file di output deve avere estensione .pkl.";
return;
}
try
{
var outputDirectory = Path.GetDirectoryName(outputFilePath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to create face output directory for file: {OutputFilePath}", outputFilePath);
statusBlock.Text = "Impossibile creare la cartella del file di output.";
return;
}
runButton.IsEnabled = false;
statusBlock.Text = "Esecuzione face encoder in corso...";
outputLogBox.Text = string.Empty;
var outputLines = new StringBuilder();
var errorLines = new StringBuilder();
try
{
var imagesFolderArg = NormalizeDirectoryPathArgument(imagesFolder);
var outputFileArg = NormalizeFilePathArgument(outputFilePath);
var processStartInfo = new ProcessStartInfo
{
FileName = executablePath,
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
processStartInfo.ArgumentList.Add("--images");
processStartInfo.ArgumentList.Add(imagesFolderArg);
processStartInfo.ArgumentList.Add("--out");
processStartInfo.ArgumentList.Add(outputFileArg);
using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, args) =>
{
if (string.IsNullOrWhiteSpace(args.Data))
{
return;
}
lock (outputLines)
{
outputLines.AppendLine(args.Data);
}
};
process.ErrorDataReceived += (_, args) =>
{
if (string.IsNullOrWhiteSpace(args.Data))
{
return;
}
lock (errorLines)
{
errorLines.AppendLine(args.Data);
}
};
if (!process.Start())
{
throw new InvalidOperationException("Avvio face_encoder.exe fallito.");
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync().ConfigureAwait(true);
var summary = new StringBuilder();
summary.AppendLine($"Exit code: {process.ExitCode}");
if (outputLines.Length > 0)
{
summary.AppendLine();
summary.AppendLine("STDOUT:");
summary.Append(outputLines);
}
if (errorLines.Length > 0)
{
summary.AppendLine();
summary.AppendLine("STDERR:");
summary.Append(errorLines);
}
outputLogBox.Text = summary.ToString();
statusBlock.Text = process.ExitCode == 0
? "Face encoder completato."
: $"Face encoder terminato con errore (code {process.ExitCode}).";
}
catch (Exception ex)
{
_logger.LogError(ex, "Face encoder execution failed.");
outputLogBox.Text = ex.ToString();
statusBlock.Text = "Errore durante esecuzione face encoder.";
}
finally
{
runButton.IsEnabled = true;
}
}
private static void OpenInExplorer(string? path)
{
if (string.IsNullOrWhiteSpace(path))
@ -345,31 +176,4 @@ public partial class FaceAiTabView : Avalonia.Controls.UserControl
// Ignore failures when opening Explorer.
}
}
private static string NormalizeDirectoryPathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Trim('"');
var root = Path.GetPathRoot(normalized);
if (!string.IsNullOrEmpty(root) && normalized.Length > root.Length)
{
normalized = normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return normalized;
}
private static string NormalizeFilePathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Trim('"');
}
}

View file

@ -9,6 +9,7 @@ using System.Diagnostics;
#if WINDOWS
using System.Drawing.Text;
#endif
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
@ -40,6 +41,8 @@ namespace ImageCatalog_2
public ICommand SelectModelsFolderCommand { get; }
public ICommand SelectCsvOutputCommand { get; }
public ICommand StartAiCommand { get; }
public ICommand StartFaceEncoderCommand { get; }
public ICommand StopFaceEncoderCommand { get; }
private readonly ITestService _service;
private readonly ILogger<DataModel> _logger;
@ -54,6 +57,14 @@ namespace ImageCatalog_2
private readonly VisualSettingsViewModel _visual;
private readonly PicSettings _picSettings;
private readonly IMapper _mapper;
private readonly AsyncCommand _startFaceEncoderCommand;
private readonly AsyncCommand _stopFaceEncoderCommand;
private readonly object _faceEncoderProcessLock = new();
private Process? _faceEncoderProcess;
private CancellationTokenSource? _faceEncoderWatcherTokenSource;
private Task? _faceEncoderWatcherTask;
private bool _hasStartedFaceEncoderInSession;
private bool _isUpdatingFaceExecutableSelection;
// ComboBox collections
public List<string> AvailableFonts { get; }
@ -93,6 +104,10 @@ namespace ImageCatalog_2
SelectModelsFolderCommand = new RelayCommand(SelectModelsFolder);
SelectCsvOutputCommand = new RelayCommand(SelectCsvOutput);
StartAiCommand = new AsyncCommand(StartAiAsync);
_startFaceEncoderCommand = new AsyncCommand(RunFaceEncoderAsync, CanRunFaceEncoder);
_stopFaceEncoderCommand = new AsyncCommand(() => StopFaceEncoderAsync("Arresto richiesto dall'utente."), CanStopFaceEncoder);
StartFaceEncoderCommand = _startFaceEncoderCommand;
StopFaceEncoderCommand = _stopFaceEncoderCommand;
SelectSourceFolderCommand = new RelayCommand(SelectSourceFolder);
SelectDestinationFolderCommand = new RelayCommand(SelectDestinationFolder);
@ -104,6 +119,7 @@ namespace ImageCatalog_2
// Load available fonts
AvailableFonts = LoadAvailableFonts();
RefreshFaceExecutableCapabilities();
}
private async Task StartAiAsync()
@ -192,7 +208,18 @@ namespace ImageCatalog_2
public string FaceExecutablePath
{
get => _ai.FaceExecutablePath;
set => _ai.FaceExecutablePath = value;
set
{
var normalizedValue = value ?? string.Empty;
if (string.Equals(_ai.FaceExecutablePath, normalizedValue, StringComparison.Ordinal))
{
RefreshFaceExecutableCapabilities();
return;
}
_ai.FaceExecutablePath = normalizedValue;
RefreshFaceExecutableCapabilities();
}
}
public string FaceOutputFolderPath
@ -201,6 +228,38 @@ namespace ImageCatalog_2
set => _ai.FaceOutputFolderPath = value;
}
public bool FaceRecursive
{
get => _ai.FaceRecursive;
set => _ai.FaceRecursive = value;
}
public bool FaceGpuOptionEnabled => _ai.FaceGpuOptionEnabled;
public bool UseFaceGpu
{
get => _ai.UseFaceGpu;
set => SetUseFaceGpu(value);
}
public bool IsFaceEncoderRunning
{
get => _ai.IsFaceEncoderRunning;
private set => _ai.IsFaceEncoderRunning = value;
}
public string FaceStatusMessage
{
get => _ai.FaceStatusMessage;
private set => _ai.FaceStatusMessage = value;
}
public string FaceCommandOutput
{
get => _ai.FaceCommandOutput;
private set => _ai.FaceCommandOutput = value;
}
// Race upload settings
public string ApiLogin
{
@ -393,6 +452,7 @@ namespace ImageCatalog_2
}
NotifyPropertyChanged(e.PropertyName);
UpdateFaceEncoderCommandStates();
}
private void OnRaceUploadPropertyChanged(object? sender, PropertyChangedEventArgs e)
@ -1272,6 +1332,549 @@ namespace ImageCatalog_2
}
}
public async Task StopFaceEncoderAsync(string reason, bool waitForExit = true)
{
var trackedProcess = GetTrackedFaceEncoderProcess();
var ownsProcess = false;
var process = trackedProcess;
if (process is null)
{
process = FindConfiguredFaceEncoderProcess();
ownsProcess = process is not null;
}
if (process is null)
{
await InvokeOnUiThreadAsync(() =>
{
IsFaceEncoderRunning = false;
FaceStatusMessage = "Face encoder non in esecuzione.";
}).ConfigureAwait(false);
return;
}
try
{
await InvokeOnUiThreadAsync(() => FaceStatusMessage = reason).ConfigureAwait(false);
var gracefulStopRequested = TryRequestFaceEncoderStop(process);
if (waitForExit)
{
var exited = await WaitForProcessExitAsync(process, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
if (!exited)
{
try
{
process.Kill(entireProcessTree: true);
exited = await WaitForProcessExitAsync(process, TimeSpan.FromSeconds(5)).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to terminate face encoder process {ProcessId}", process.Id);
}
}
await InvokeOnUiThreadAsync(() =>
{
IsFaceEncoderRunning = !exited && IsProcessAlive(process);
FaceStatusMessage = exited
? "Face encoder arrestato."
: gracefulStopRequested
? "Segnale di arresto inviato al face encoder."
: "Arresto forzato del face encoder richiesto.";
}).ConfigureAwait(false);
}
}
finally
{
if (ownsProcess)
{
process.Dispose();
}
}
}
private bool CanRunFaceEncoder()
{
return !IsFaceEncoderRunning;
}
private bool CanStopFaceEncoder()
{
return IsFaceEncoderRunning;
}
private void UpdateFaceEncoderCommandStates()
{
_startFaceEncoderCommand?.RaiseCanExecuteChanged();
_stopFaceEncoderCommand?.RaiseCanExecuteChanged();
}
private async Task RunFaceEncoderAsync()
{
if (IsFaceEncoderRunning)
{
FaceStatusMessage = "Face encoder gia in esecuzione.";
return;
}
var executablePath = NormalizeFilePathArgument(FaceExecutablePath);
var outputFilePath = NormalizeFilePathArgument(FaceOutputFolderPath);
var imagesFolder = NormalizeDirectoryPathArgument(DestinationPath);
if (string.IsNullOrWhiteSpace(executablePath) || !File.Exists(executablePath))
{
FaceStatusMessage = "Percorso eseguibile non valido.";
return;
}
if (string.IsNullOrWhiteSpace(imagesFolder) || !Directory.Exists(imagesFolder))
{
FaceStatusMessage = "Cartella Destinazione non valida.";
return;
}
if (string.IsNullOrWhiteSpace(outputFilePath))
{
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.";
return;
}
try
{
var outputDirectory = Path.GetDirectoryName(outputFilePath);
if (!string.IsNullOrWhiteSpace(outputDirectory))
{
Directory.CreateDirectory(outputDirectory);
}
}
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.";
return;
}
FaceExecutablePath = executablePath;
FaceOutputFolderPath = outputFilePath;
FaceCommandOutput = string.Empty;
FaceStatusMessage = "Esecuzione face encoder in corso...";
var outputLines = new StringBuilder();
var errorLines = new StringBuilder();
try
{
var processStartInfo = new ProcessStartInfo
{
FileName = executablePath,
WorkingDirectory = Path.GetDirectoryName(executablePath) ?? Environment.CurrentDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
CreateNoWindow = false,
};
processStartInfo.ArgumentList.Add("--images");
processStartInfo.ArgumentList.Add(imagesFolder);
processStartInfo.ArgumentList.Add("--out");
processStartInfo.ArgumentList.Add(outputFilePath);
if (FaceRecursive)
{
processStartInfo.ArgumentList.Add("--recursive");
}
using var process = new Process { StartInfo = processStartInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, args) => AppendFaceProcessOutput(outputLines, args.Data);
process.ErrorDataReceived += (_, args) => AppendFaceProcessOutput(errorLines, args.Data);
if (!process.Start())
{
throw new InvalidOperationException("Avvio face encoder fallito.");
}
_hasStartedFaceEncoderInSession = true;
EnsureFaceEncoderWatcherStarted();
TrackFaceEncoderProcess(process);
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = true).ConfigureAwait(false);
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync().ConfigureAwait(false);
var summary = BuildFaceEncoderSummary(process.ExitCode, outputLines, errorLines);
await InvokeOnUiThreadAsync(() =>
{
FaceCommandOutput = summary;
FaceStatusMessage = process.ExitCode == 0
? "Face encoder completato."
: $"Face encoder terminato con errore (code {process.ExitCode}).";
}).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Face encoder execution failed.");
await InvokeOnUiThreadAsync(() =>
{
FaceCommandOutput = ex.ToString();
FaceStatusMessage = "Errore durante esecuzione face encoder.";
}).ConfigureAwait(false);
}
finally
{
ClearTrackedFaceEncoderProcess();
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = ComputeIsFaceEncoderRunning()).ConfigureAwait(false);
}
}
private async Task WatchFaceEncoderProcessAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
try
{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
{
if (!_hasStartedFaceEncoderInSession)
{
continue;
}
var isRunning = ComputeIsFaceEncoderRunning();
if (isRunning != IsFaceEncoderRunning)
{
await InvokeOnUiThreadAsync(() => IsFaceEncoderRunning = isRunning).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// App shutdown.
}
}
private void EnsureFaceEncoderWatcherStarted()
{
if (_faceEncoderWatcherTask is not null)
{
return;
}
_faceEncoderWatcherTokenSource = new CancellationTokenSource();
_faceEncoderWatcherTask = WatchFaceEncoderProcessAsync(_faceEncoderWatcherTokenSource.Token);
}
private void RefreshFaceExecutableCapabilities()
{
if (_isUpdatingFaceExecutableSelection)
{
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);
_ai.FaceGpuOptionEnabled = supportsGpu;
_ai.UseFaceGpu = supportsGpu && useGpu;
}
private void SetUseFaceGpu(bool value)
{
if (!FaceGpuOptionEnabled)
{
if (_ai.UseFaceGpu)
{
_ai.UseFaceGpu = false;
}
return;
}
if (_ai.UseFaceGpu == value)
{
return;
}
_ai.UseFaceGpu = value;
var currentPath = NormalizeFilePathArgument(_ai.FaceExecutablePath);
if (string.IsNullOrWhiteSpace(currentPath))
{
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;
}
}
private void TrackFaceEncoderProcess(Process process)
{
lock (_faceEncoderProcessLock)
{
_faceEncoderProcess = process;
}
}
private void ClearTrackedFaceEncoderProcess()
{
lock (_faceEncoderProcessLock)
{
if (_faceEncoderProcess is not null && _faceEncoderProcess.HasExited)
{
_faceEncoderProcess = null;
return;
}
_faceEncoderProcess = null;
}
}
private Process? GetTrackedFaceEncoderProcess()
{
lock (_faceEncoderProcessLock)
{
if (_faceEncoderProcess is null)
{
return null;
}
if (_faceEncoderProcess.HasExited)
{
_faceEncoderProcess = null;
return null;
}
return _faceEncoderProcess;
}
}
private Process? FindConfiguredFaceEncoderProcess()
{
var configuredExecutablePath = NormalizeFilePathArgument(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();
if (trackedProcess is not null)
{
return true;
}
if (!_hasStartedFaceEncoderInSession)
{
return false;
}
using var discoveredProcess = FindConfiguredFaceEncoderProcess();
return discoveredProcess is not null;
}
private static bool IsProcessAlive(Process process)
{
try
{
return !process.HasExited;
}
catch
{
return false;
}
}
private static bool IsMatchingProcessPath(Process process, string configuredExecutablePath)
{
try
{
var processPath = process.MainModule?.FileName;
return !string.IsNullOrWhiteSpace(processPath)
&& string.Equals(Path.GetFullPath(processPath), Path.GetFullPath(configuredExecutablePath), StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private bool TryRequestFaceEncoderStop(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 encoder process {ProcessId}", process.Id);
return false;
}
}
private static async Task<bool> WaitForProcessExitAsync(Process process, TimeSpan timeout)
{
if (!IsProcessAlive(process))
{
return true;
}
using var cancellationTokenSource = new CancellationTokenSource(timeout);
try
{
await process.WaitForExitAsync(cancellationTokenSource.Token).ConfigureAwait(false);
return true;
}
catch (OperationCanceledException)
{
return !IsProcessAlive(process);
}
}
private static void AppendFaceProcessOutput(StringBuilder builder, string? line)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
lock (builder)
{
builder.AppendLine(line);
}
}
private static string BuildFaceEncoderSummary(int exitCode, StringBuilder outputLines, StringBuilder errorLines)
{
var summary = new StringBuilder();
summary.AppendLine($"Exit code: {exitCode}");
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 NormalizeDirectoryPathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
var normalized = value.Trim().Trim('"');
var root = Path.GetPathRoot(normalized);
if (!string.IsNullOrEmpty(root) && normalized.Length > root.Length)
{
normalized = normalized.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
return normalized;
}
private static string NormalizeFilePathArgument(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return string.Empty;
}
return value.Trim().Trim('"');
}
// Note: These commands will trigger events that the View will handle to show dialogs
// since dialogs require UI context
public event EventHandler? SelectSourceFolderRequested;

View file

@ -282,6 +282,10 @@ namespace ImageCatalog_2.Models
[XmlElement("AI_FaceOutputFolderPath")]
public string FaceOutputFolderPath { get; set; } = string.Empty;
[JsonPropertyName("FaceRecursive")]
[XmlElement("AI_FaceRecursive")]
public bool FaceRecursive { get; set; }
// Race upload settings
[JsonPropertyName("ApiLogin")]
[XmlElement("RaceUpload_Login")]

View file

@ -21,6 +21,9 @@ static class Program
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AllocConsole();
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool FreeConsole();
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);
@ -36,6 +39,12 @@ static class Program
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(int dwProcessId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate? handlerRoutine, bool add);
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateFile(
string lpFileName,
@ -48,6 +57,9 @@ static class Program
private const uint GENERIC_WRITE = 0x40000000;
private const uint OPEN_EXISTING = 3;
private const uint CTRL_C_EVENT = 0;
private delegate bool ConsoleCtrlDelegate(uint ctrlType);
private static void RedirectConsoleOutput()
{
@ -58,6 +70,38 @@ static class Program
Console.SetOut(standardOutput);
Console.SetError(standardOutput);
}
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();
}
}
}
#endif
public static IServiceProvider ServiceProvider { get; private set; } = default!;

View file

@ -60,6 +60,72 @@ public class AiSettingsViewModel : ViewModelBase
}
}
private bool _faceRecursive;
public bool FaceRecursive
{
get => _faceRecursive;
set
{
_faceRecursive = value;
NotifyPropertyChanged();
}
}
private bool _faceGpuOptionEnabled;
public bool FaceGpuOptionEnabled
{
get => _faceGpuOptionEnabled;
set
{
_faceGpuOptionEnabled = value;
NotifyPropertyChanged();
}
}
private bool _useFaceGpu;
public bool UseFaceGpu
{
get => _useFaceGpu;
set
{
_useFaceGpu = value;
NotifyPropertyChanged();
}
}
private bool _isFaceEncoderRunning;
public bool IsFaceEncoderRunning
{
get => _isFaceEncoderRunning;
set
{
_isFaceEncoderRunning = value;
NotifyPropertyChanged();
}
}
private string _faceStatusMessage = string.Empty;
public string FaceStatusMessage
{
get => _faceStatusMessage;
set
{
_faceStatusMessage = value;
NotifyPropertyChanged();
}
}
private string _faceCommandOutput = string.Empty;
public string FaceCommandOutput
{
get => _faceCommandOutput;
set
{
_faceCommandOutput = value;
NotifyPropertyChanged();
}
}
private double _aiProgress;
public double AiProgress
{