AI Pettorali

This commit is contained in:
MaddoScientisto 2026-05-09 17:27:05 +02:00
commit cb41c42bb5
11 changed files with 379 additions and 55 deletions

View file

@ -109,6 +109,27 @@ public class DataModelCharacterizationTests
model.ModelsFolderPath.ShouldBe("K:/models");
}
[TestMethod]
public void NumberAiGpuChildChange_RaisesDataModelPropertyChanged()
{
var model = CreateModel();
string? changed = null;
model.PropertyChanged += (_, args) => changed = args.PropertyName;
model.Ai.UseNumberAiGpu = true;
changed.ShouldBe(nameof(DataModel.UseNumberAiGpu));
model.UseNumberAiGpu.ShouldBeTrue();
}
[TestMethod]
public void CommandLineOperationRunner_DetectsHeadlessRequest()
{
CommandLineOperationRunner.IsHeadlessRequest(["--config", "settings.xml", "--operation", "number-ai"]).ShouldBeTrue();
CommandLineOperationRunner.IsHeadlessRequest(["--config=settings.xml", "--operation=number-ai"]).ShouldBeTrue();
CommandLineOperationRunner.IsHeadlessRequest([]).ShouldBeFalse();
}
[TestMethod]
public void RaceUploadChildChange_RaisesDataModelPropertyChanged()
{

View file

@ -13,6 +13,18 @@
<StackPanel Margin="4">
<TextBlock Text="AI / OCR" FontWeight="Bold" />
<CheckBox Content="Estrai numeri dalle immagini" IsChecked="{Binding ExtractNumbers}" Margin="0,6,0,0" />
<CheckBox Content="Usa GPU" IsChecked="{Binding UseNumberAiGpu, Mode=TwoWay}" Margin="0,2,0,0" />
<Grid Margin="0,8,0,0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Text="{Binding DestinationPath, Mode=OneWay}" IsReadOnly="True" VerticalAlignment="Center" />
<Button Grid.Column="2" Width="72" Click="OpenAiDestinationFolder_Click">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" Spacing="6">
<iconPacks:PackIconMaterial Kind="Folder" Width="14" Height="14" />
<TextBlock Text="Apri" />
</StackPanel>
</Button>
</Grid>
<TextBlock Text="Modelli" FontWeight="Bold" Margin="0,8,0,0" />
<Grid Margin="0,4,0,0" ColumnDefinitions="Auto,*,Auto,Auto">

View file

@ -31,6 +31,14 @@ public partial class AiTabView : Avalonia.Controls.UserControl
OpenInExplorer(string.IsNullOrWhiteSpace(directory) ? model.CsvOutputPath : directory);
}
private void OpenAiDestinationFolder_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is DataModel model)
{
OpenInExplorer(model.DestinationPath);
}
}
private static void OpenInExplorer(string? path)
{
if (string.IsNullOrWhiteSpace(path))

View file

@ -0,0 +1,228 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace ImageCatalog_2;
internal static class CommandLineOperationRunner
{
private static readonly HashSet<string> HeadlessMarkers = new(StringComparer.OrdinalIgnoreCase)
{
"--operation",
"--op",
"--config",
"--headless",
"--cli"
};
public static bool IsHeadlessRequest(string[]? args)
{
return args?.Any(arg => HeadlessMarkers.Contains(arg) || arg.StartsWith("--operation=", StringComparison.OrdinalIgnoreCase) || arg.StartsWith("--config=", StringComparison.OrdinalIgnoreCase)) == true;
}
public static async Task<int> RunAsync(IServiceProvider services, string[] args)
{
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("CommandLine");
try
{
var options = Parse(args);
if (options.ShowHelp)
{
WriteUsage();
return 0;
}
if (string.IsNullOrWhiteSpace(options.ConfigPath))
{
throw new ArgumentException("Missing required --config <path> argument.");
}
if (string.IsNullOrWhiteSpace(options.Operation))
{
throw new ArgumentException("Missing required --operation <image-processing|number-ai|face-ai|race-upload> argument.");
}
if (!File.Exists(options.ConfigPath))
{
throw new FileNotFoundException("Configuration file not found.", options.ConfigPath);
}
var model = services.GetRequiredService<DataModel>();
await model.LoadSettingsFromFileAsync(options.ConfigPath).ConfigureAwait(false);
ApplyOverrides(model, options);
using var cancellationTokenSource = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
cancellationTokenSource.Cancel();
};
logger.LogInformation("Running ImageCatalog operation {Operation} with config {ConfigPath}", options.Operation, options.ConfigPath);
switch (NormalizeOperation(options.Operation))
{
case "image-processing":
await model.ProcessImages().ConfigureAwait(false);
break;
case "number-ai":
await model.RunNumberAiAsync(cancellationTokenSource.Token).ConfigureAwait(false);
break;
case "face-ai":
await model.RunFaceAiAsync(cancellationTokenSource.Token).ConfigureAwait(false);
break;
case "race-upload":
throw new NotSupportedException("race-upload is not available in headless mode yet because the upload workflow still lives in the Avalonia view layer.");
default:
throw new ArgumentException($"Unknown operation: {options.Operation}");
}
logger.LogInformation("ImageCatalog operation {Operation} completed", options.Operation);
return 0;
}
catch (OperationCanceledException)
{
logger.LogWarning("Command-line operation canceled.");
return 130;
}
catch (Exception ex)
{
logger.LogError(ex, "Command-line operation failed.");
return 1;
}
}
private static void ApplyOverrides(DataModel model, CommandLineOptions options)
{
if (!string.IsNullOrWhiteSpace(options.ModelsPath))
{
model.ModelsFolderPath = options.ModelsPath;
}
if (!string.IsNullOrWhiteSpace(options.CsvPath))
{
model.CsvOutputPath = options.CsvPath;
}
if (options.UseGpu.HasValue)
{
model.UseNumberAiGpu = options.UseGpu.Value;
if (model.FaceGpuOptionEnabled || !options.UseGpu.Value)
{
model.UseFaceGpu = options.UseGpu.Value;
}
}
}
private static string NormalizeOperation(string operation)
{
return operation.Trim().ToLowerInvariant() switch
{
"images" or "image" or "process-images" or "image-processing" => "image-processing",
"ai" or "ocr" or "number" or "number-ai" => "number-ai",
"face" or "face-ai" => "face-ai",
"race" or "race-upload" => "race-upload",
var normalized => normalized
};
}
private static CommandLineOptions Parse(string[] args)
{
var options = new CommandLineOptions();
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
switch (arg.ToLowerInvariant())
{
case "--help":
case "-h":
case "/?":
options.ShowHelp = true;
break;
case "--operation":
case "--op":
options.Operation = ReadValue(args, ref i, arg);
break;
case "--config":
options.ConfigPath = ReadValue(args, ref i, arg);
break;
case "--models":
options.ModelsPath = ReadValue(args, ref i, arg);
break;
case "--csv":
options.CsvPath = ReadValue(args, ref i, arg);
break;
case "--gpu":
options.UseGpu = true;
break;
case "--cpu":
options.UseGpu = false;
break;
case "--headless":
case "--cli":
break;
default:
ApplyInlineArgument(options, arg);
break;
}
}
return options;
}
private static void ApplyInlineArgument(CommandLineOptions options, string arg)
{
var separatorIndex = arg.IndexOf('=');
if (separatorIndex < 0)
{
throw new ArgumentException($"Unknown argument: {arg}");
}
var name = arg[..separatorIndex].ToLowerInvariant();
var value = arg[(separatorIndex + 1)..];
switch (name)
{
case "--operation":
case "--op":
options.Operation = value;
break;
case "--config":
options.ConfigPath = value;
break;
case "--models":
options.ModelsPath = value;
break;
case "--csv":
options.CsvPath = value;
break;
default:
throw new ArgumentException($"Unknown argument: {arg}");
}
}
private static string ReadValue(string[] args, ref int index, string name)
{
if (index + 1 >= args.Length)
{
throw new ArgumentException($"Missing value for {name}.");
}
return args[++index];
}
private static void WriteUsage()
{
Console.WriteLine("Usage: ImageCatalog --config <settings.xml> --operation <image-processing|number-ai|face-ai|race-upload> [--models <folder>] [--csv <path>] [--cpu|--gpu]");
}
private sealed class CommandLineOptions
{
public bool ShowHelp { get; set; }
public string Operation { get; set; } = string.Empty;
public string ConfigPath { get; set; } = string.Empty;
public string ModelsPath { get; set; } = string.Empty;
public string CsvPath { get; set; } = string.Empty;
public bool? UseGpu { get; set; }
}
}

View file

@ -137,12 +137,17 @@ namespace ImageCatalog_2
}
}
private async Task RunAiExtractionCoreAsync(CancellationToken token, bool useDestination = false, bool recursive = false)
private async Task RunAiExtractionCoreAsync(CancellationToken token, bool useDestination = false, bool recursive = false, bool failOnInvalidPath = false)
{
var searchRoot = useDestination ? DestinationPath : SourcePath;
if (string.IsNullOrWhiteSpace(searchRoot) || !System.IO.Directory.Exists(searchRoot))
{
_logger.LogWarning("AI extraction path invalid: {Path}", searchRoot);
if (failOnInvalidPath)
{
throw new DirectoryNotFoundException($"AI extraction path invalid: {searchRoot}");
}
return;
}
@ -157,6 +162,8 @@ namespace ImageCatalog_2
{
SearchRoot = searchRoot,
Recursive = recursive,
ModelsFolderPath = ModelsFolderPath,
UseGpu = UseNumberAiGpu,
CsvOutputPath = CsvOutputPath
},
token,
@ -202,6 +209,12 @@ namespace ImageCatalog_2
set => _ai.CsvOutputPath = value;
}
public bool UseNumberAiGpu
{
get => _ai.UseNumberAiGpu;
set => _ai.UseNumberAiGpu = value;
}
public string FaceExecutablePath
{
get => _ai.FaceExecutablePath;
@ -1223,7 +1236,7 @@ namespace ImageCatalog_2
Debug.WriteLine("Yep c");
}
private async Task ProcessImages()
public async Task ProcessImages()
{
_logger.LogInformation("Avvio elaborazione...");
UiEnabled = false;
@ -1289,12 +1302,12 @@ namespace ImageCatalog_2
},
speed => { SpeedCounter = speed; }).ConfigureAwait(false);
// AI integration stub: if ExtractNumbers is enabled, simulate or invoke OCR processing
// AI integration: OCR runs over processed output so it matches the face AI input folder.
if (ExtractNumbers)
{
try
{
await RunAiExtractionCoreAsync(token);
await RunAiExtractionCoreAsync(token, useDestination: true, recursive: true);
}
catch (OperationCanceledException)
{
@ -1327,6 +1340,17 @@ namespace ImageCatalog_2
UiEnabled = true;
}
public async Task RunNumberAiAsync(CancellationToken token)
{
await RunAiExtractionCoreAsync(token, useDestination: true, recursive: true, failOnInvalidPath: true).ConfigureAwait(false);
}
public async Task RunFaceAiAsync(CancellationToken token)
{
using var registration = token.Register(() => _ = StopFaceEncoderAsync("Arresto face encoder richiesto dalla CLI.", waitForExit: true));
await RunFaceEncoderAsync().ConfigureAwait(false);
}
private async Task CancelOperation()
{
try

View file

@ -7,6 +7,8 @@
<AssemblyName>ImageCatalog</AssemblyName>
<LangVersion>default</LangVersion>
<AvaloniaWindowsCrossPublish Condition="'$(AvaloniaWindowsCrossPublish)' == ''">false</AvaloniaWindowsCrossPublish>
<UseLocalAIFotoONLUS Condition="'$(UseLocalAIFotoONLUS)' == '' and Exists('..\..\AIFotoONLUS\src\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj') and '$(GITHUB_ACTIONS)' != 'true' and '$(CI)' != 'true'">true</UseLocalAIFotoONLUS>
<UseLocalAIFotoONLUS Condition="'$(UseLocalAIFotoONLUS)' == ''">false</UseLocalAIFotoONLUS>
</PropertyGroup>
<!-- Windows: net10.0-windows TFM auto-defines WINDOWS preprocessor symbol -->
<PropertyGroup Condition="$([MSBuild]::IsOsPlatform('Windows'))">
@ -56,9 +58,10 @@
<ItemGroup>
<ProjectReference Include="..\Catalog.Communication\Catalog.Communication.csproj" />
<ProjectReference Include="..\MaddoShared\MaddoShared.csproj" />
<ProjectReference Include="..\..\AIFotoONLUS\src\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" Condition="'$(UseLocalAIFotoONLUS)' == 'true'" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AIFotoONLUS.Core" Version="0.1.1" />
<PackageReference Include="AIFotoONLUS.Core" Version="0.1.2" Condition="'$(UseLocalAIFotoONLUS)' != 'true'" />
<PackageReference Include="AutoMapper" Version="16.1.0" />
<PackageReference Include="IconPacks.Avalonia" Version="1.3.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />

View file

@ -274,6 +274,10 @@ namespace ImageCatalog_2.Models
[XmlElement("AI_PercorsoCsv")]
public string CsvOutputPath { get; set; }
[JsonPropertyName("UseNumberAiGpu")]
[XmlElement("AI_UsaGpuNumeri")]
public bool UseNumberAiGpu { get; set; }
[JsonPropertyName("FaceExecutablePath")]
[XmlElement("AI_FaceExecutablePath")]
public string FaceExecutablePath { get; set; } = string.Empty;

View file

@ -38,6 +38,8 @@ static class Program
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(int dwProcessId);
private const int ATTACH_PARENT_PROCESS = -1;
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
@ -85,10 +87,18 @@ static class Program
.LogToTrace();
[STAThread]
static void Main(string[] args)
static int Main(string[] args)
{
#if WINDOWS
if (CommandLineOperationRunner.IsHeadlessRequest(args))
{
AttachConsole(ATTACH_PARENT_PROCESS);
}
else
{
AllocConsole();
}
RedirectConsoleOutput();
#endif
@ -97,7 +107,15 @@ static class Program
ServiceProvider = serviceCollection.BuildServiceProvider();
if (CommandLineOperationRunner.IsHeadlessRequest(args))
{
return CommandLineOperationRunner.RunAsync(ServiceProvider, args ?? Array.Empty<string>())
.GetAwaiter()
.GetResult();
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args ?? Array.Empty<string>());
return 0;
}
private static void ConfigureServices(ServiceCollection services)

View file

@ -5,6 +5,7 @@ using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AIFotoONLUS.Core;
using ImageCatalog_2.Models;
using Microsoft.Extensions.Logging;
@ -35,36 +36,14 @@ public class AiExtractionService : IAiExtractionService
|| f.EndsWith(".gif", StringComparison.OrdinalIgnoreCase))
.ToList();
if (imageFiles.Count == 0)
{
return;
}
var extractedResults = new List<AiResultItem>();
var modelConfiguration = BuildModelConfiguration(request.ModelsFolderPath, request.UseGpu);
Type? aiProcessorType = null;
object? aiProcessor = null;
try
{
var assembly = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name?.Equals("AIFotoONLUS.Core", StringComparison.OrdinalIgnoreCase) == true);
if (assembly != null)
{
aiProcessorType = assembly.GetType("AIFotoONLUS.Core.AiProcessor");
if (aiProcessorType != null)
{
aiProcessor = Activator.CreateInstance(aiProcessorType);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "AIFotoONLUS.Core not available or failed to load via reflection");
}
using var engine = new NumberRecognitionEngine(modelConfiguration, _logger);
var processed = 0;
var total = imageFiles.Count;
var failed = 0;
foreach (var file in imageFiles)
{
@ -72,39 +51,30 @@ public class AiExtractionService : IAiExtractionService
var extracted = string.Empty;
if (aiProcessorType is not null && aiProcessor is not null)
{
try
{
var method = aiProcessorType.GetMethod("ExtractNumbersFromImage")
?? aiProcessorType.GetMethod("ExtractTextFromImage");
if (method is not null)
{
var value = method.Invoke(aiProcessor, new object[] { file });
if (value != null)
{
extracted = value.ToString() ?? string.Empty;
}
}
extracted = engine.ProcessImage(file).Text;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error invoking AI processor for {File}", file);
}
failed++;
_logger.LogWarning(ex, "Error processing AI OCR for {File}", file);
}
if (!string.IsNullOrWhiteSpace(extracted))
{
var result = new AiResultItem { Path = file, Text = extracted };
extractedResults.Add(result);
await onResult(result).ConfigureAwait(false);
}
processed++;
var percent = total > 0 ? (processed * 100.0 / total) : 100.0;
await onProgress(percent).ConfigureAwait(false);
}
if (imageFiles.Count > 0 && failed == imageFiles.Count)
{
throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details.");
}
if (!string.IsNullOrWhiteSpace(request.CsvOutputPath))
{
try
@ -129,4 +99,27 @@ public class AiExtractionService : IAiExtractionService
}
}
}
private static ModelConfiguration BuildModelConfiguration(string modelsFolderPath, bool useGpu)
{
if (string.IsNullOrWhiteSpace(modelsFolderPath))
{
throw new InvalidOperationException("AI models folder is not configured.");
}
var modelsRoot = Path.GetFullPath(modelsFolderPath.Trim().Trim('"'));
if (!Directory.Exists(modelsRoot))
{
throw new DirectoryNotFoundException($"AI models folder not found: {modelsRoot}");
}
return new ModelConfiguration
{
DetectionCfg = Path.Combine(modelsRoot, "detection.cfg"),
DetectionWeights = Path.Combine(modelsRoot, "detection.weights"),
RecognitionCfg = Path.Combine(modelsRoot, "recognition.cfg"),
RecognitionWeights = Path.Combine(modelsRoot, "recognition.weights"),
UseGpu = useGpu
};
}
}

View file

@ -9,6 +9,8 @@ public sealed class AiExtractionRequest
{
public required string SearchRoot { get; init; }
public required bool Recursive { get; init; }
public required string ModelsFolderPath { get; init; }
public bool UseGpu { get; init; }
public string CsvOutputPath { get; init; } = string.Empty;
}

View file

@ -38,6 +38,17 @@ public class AiSettingsViewModel : ViewModelBase
}
}
private bool _useNumberAiGpu;
public bool UseNumberAiGpu
{
get => _useNumberAiGpu;
set
{
_useNumberAiGpu = value;
NotifyPropertyChanged();
}
}
private string _faceExecutablePath = string.Empty;
public string FaceExecutablePath
{