Add AI/OCR extraction feature with UI and CSV export

Integrates optional AI/OCR (AIFotoONLUS.Core) support to extract numbers from images after processing. Adds new "AI" tab in the UI for enabling extraction, selecting models folder, specifying CSV output, and previewing results. Results can be exported to CSV. Uses reflection for AI library invocation, with fallback simulation if unavailable. Persists new AI settings. Updates related NuGet packages and adds theme resources.
This commit is contained in:
MaddoScientisto 2026-02-16 18:32:04 +01:00
commit 6a5173a20d
8 changed files with 392 additions and 7 deletions

View file

@ -31,6 +31,8 @@ namespace ImageCatalog_2
public ICommand LoadSettingsCommand { get; }
public ICommand SelectColorCommand { get; }
public ICommand SelectTransparentColorCommand { get; }
public ICommand SelectModelsFolderCommand { get; }
public ICommand SelectCsvOutputCommand { get; }
private readonly ITestService _service;
private readonly ILogger<DataModel> _logger;
@ -61,6 +63,8 @@ namespace ImageCatalog_2
AsyncTestCommand = new AsyncCommand(TestAsync);
AsyncCancelOperationCommand = new AsyncCommand(CancelOperation);
ProcessImagesCommand = new AsyncCommand(ProcessImages);
SelectModelsFolderCommand = new RelayCommand(SelectModelsFolder);
SelectCsvOutputCommand = new RelayCommand(SelectCsvOutput);
SelectSourceFolderCommand = new RelayCommand(SelectSourceFolder);
SelectDestinationFolderCommand = new RelayCommand(SelectDestinationFolder);
@ -74,6 +78,176 @@ namespace ImageCatalog_2
AvailableFonts = LoadAvailableFonts();
}
private async Task RunAiExtractionAsync(CancellationToken token)
{
// Simple stub: scan source folder for supported images and either call AIFotoONLUS.Core
// or simulate results. Write CSV output and populate PreviewResults.
if (string.IsNullOrWhiteSpace(SourcePath) || !System.IO.Directory.Exists(SourcePath))
{
_logger.LogWarning("Source path invalid for AI extraction: {SourcePath}", SourcePath);
return;
}
var imageFiles = System.IO.Directory.EnumerateFiles(SourcePath, "*.*", System.IO.SearchOption.TopDirectoryOnly)
.Where(f => f.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)
|| f.EndsWith(".gif", StringComparison.OrdinalIgnoreCase))
.ToList();
if (imageFiles.Count == 0)
{
_logger.LogInformation("No image files found for AI extraction in {SourcePath}", SourcePath);
return;
}
// Clear preview
await InvokeOnUiThreadAsync(() => { PreviewResults.Clear(); });
// Try to locate AIFotoONLUS.Core types via reflection to avoid hard reference at compile time
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)
{
// Create instance assuming parameterless ctor
aiProcessor = Activator.CreateInstance(aiProcessorType);
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "AIFotoONLUS.Core not available or failed to load via reflection");
}
var results = new List<AiResult>();
foreach (var file in imageFiles)
{
token.ThrowIfCancellationRequested();
string extracted = string.Empty;
if (aiProcessorType is not null && aiProcessor is not null)
{
try
{
// Preferred method name: ExtractNumbersFromImage(string imagePath)
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;
}
else
{
// No expected method found, fallback to simulated result
extracted = SimulateExtraction(file);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error invoking AI processor for {File}", file);
extracted = SimulateExtraction(file);
}
}
else
{
// Simulate extraction when library not available
extracted = SimulateExtraction(file);
}
var res = new AiResult { Path = file, Text = extracted };
results.Add(res);
await InvokeOnUiThreadAsync(() => PreviewResults.Add(res));
}
// Write CSV if requested
if (!string.IsNullOrWhiteSpace(CsvOutputPath))
{
try
{
var dir = System.IO.Path.GetDirectoryName(CsvOutputPath) ?? string.Empty;
if (!string.IsNullOrWhiteSpace(dir) && !System.IO.Directory.Exists(dir))
{
System.IO.Directory.CreateDirectory(dir);
}
using var sw = new System.IO.StreamWriter(CsvOutputPath, false, System.Text.Encoding.UTF8);
sw.WriteLine("Path,Text");
foreach (var r in results)
{
var safeText = (r.Text ?? string.Empty).Replace("\"", "\"\"");
sw.WriteLine($"\"{r.Path}\",\"{safeText}\"");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write CSV to {CsvOutputPath}", CsvOutputPath);
}
}
}
private string SimulateExtraction(string file)
{
// Cheap heuristic: return filename digits
var name = System.IO.Path.GetFileNameWithoutExtension(file);
var digits = new string(name.Where(char.IsDigit).ToArray());
if (string.IsNullOrEmpty(digits)) return "";
return digits;
}
private Task InvokeOnUiThreadAsync(Action action)
{
// Use SynchronizationContext via Task to ensure UI thread update
return Task.Run(() =>
{
System.Windows.Application.Current?.Dispatcher.Invoke(action);
});
}
// AI properties
private bool _extractNumbers;
public bool ExtractNumbers
{
get => _extractNumbers;
set { _extractNumbers = value; NotifyPropertyChanged(); }
}
private string _modelsFolderPath = string.Empty;
public string ModelsFolderPath
{
get => _modelsFolderPath;
set { _modelsFolderPath = value; NotifyPropertyChanged(); }
}
private string _csvOutputPath = string.Empty;
public string CsvOutputPath
{
get => _csvOutputPath;
set { _csvOutputPath = value; NotifyPropertyChanged(); }
}
// Preview results for DataGrid
private System.Collections.ObjectModel.ObservableCollection<AiResult> _previewResults = new();
public System.Collections.ObjectModel.ObservableCollection<AiResult> PreviewResults => _previewResults;
public class AiResult
{
public string Path { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
}
private List<string> LoadAvailableFonts()
{
var fonts = new List<string>();
@ -991,6 +1165,23 @@ namespace ImageCatalog_2
_results,
OnImageProcessed,
token);
// AI integration stub: if ExtractNumbers is enabled, simulate or invoke OCR processing
if (ExtractNumbers)
{
try
{
await RunAiExtractionAsync(token);
}
catch (OperationCanceledException)
{
_logger.LogInformation("AI extraction canceled");
}
catch (Exception ex)
{
_logger.LogError(ex, "AI extraction failed");
}
}
// Compute final averages and show only averages (do not show raw seconds)
var finalProcessed = System.Threading.Volatile.Read(ref _processedAtomic);
@ -1161,6 +1352,8 @@ namespace ImageCatalog_2
public event EventHandler SelectSourceFolderRequested;
public event EventHandler SelectDestinationFolderRequested;
public event EventHandler SelectLogoFileRequested;
public event EventHandler SelectModelsFolderRequested;
public event EventHandler SelectCsvOutputRequested;
public event EventHandler<string> SaveSettingsRequested;
public event EventHandler<string> LoadSettingsRequested;
public event EventHandler SelectColorRequested;
@ -1183,6 +1376,16 @@ namespace ImageCatalog_2
SelectLogoFileRequested?.Invoke(this, EventArgs.Empty);
}
private void SelectModelsFolder(object parameter)
{
SelectModelsFolderRequested?.Invoke(this, EventArgs.Empty);
}
private void SelectCsvOutput(object parameter)
{
SelectCsvOutputRequested?.Invoke(this, EventArgs.Empty);
}
private void SaveSettings(object parameter)
{
SaveSettingsRequested?.Invoke(this, null);

View file

@ -41,6 +41,7 @@
<ProjectReference Include="..\MaddoShared\MaddoShared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AIFotoONLUS.Core" Version="0.1.1" />
<PackageReference Include="AutoMapper" Version="16.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />

View file

@ -4,7 +4,33 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="Image Catalog - WPF" Height="490" Width="800">
Title="Image Catalog - WPF" Height="490" Width="800"
Background="{DynamicResource WindowBackgroundBrush}" Foreground="{DynamicResource ControlForegroundBrush}">
<Window.Resources>
<ResourceDictionary>
<!-- Light theme resources -->
<ResourceDictionary x:Key="LightTheme">
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="White" />
<SolidColorBrush x:Key="ControlBackgroundBrush" Color="White" />
<SolidColorBrush x:Key="ControlForegroundBrush" Color="Black" />
<SolidColorBrush x:Key="BorderBrush" Color="#DDD" />
<SolidColorBrush x:Key="AccentBrush" Color="#0078D7" />
<SolidColorBrush x:Key="DataGridBackgroundBrush" Color="White" />
<SolidColorBrush x:Key="DataGridForegroundBrush" Color="Black" />
</ResourceDictionary>
<!-- Dark theme resources -->
<ResourceDictionary x:Key="DarkTheme">
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#1E1E1E" />
<SolidColorBrush x:Key="ControlBackgroundBrush" Color="#252526" />
<SolidColorBrush x:Key="ControlForegroundBrush" Color="#E6E6E6" />
<SolidColorBrush x:Key="BorderBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="AccentBrush" Color="#0A84FF" />
<SolidColorBrush x:Key="DataGridBackgroundBrush" Color="#252526" />
<SolidColorBrush x:Key="DataGridForegroundBrush" Color="#E6E6E6" />
</ResourceDictionary>
</ResourceDictionary>
</Window.Resources>
<Grid Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="3*" />
@ -210,6 +236,51 @@
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem Header="AI">
<ScrollViewer>
<StackPanel Margin="8">
<TextBlock Text="AI / OCR" FontWeight="Bold" />
<CheckBox Content="Estrai numeri dalle immagini" IsChecked="{Binding ExtractNumbers}" Margin="0,8,0,0" />
<TextBlock Text="Modelli" FontWeight="Bold" Margin="0,12,0,0" />
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Cartella modelli:" VerticalAlignment="Center" Margin="0,0,8,0" Grid.Column="0" />
<TextBox Text="{Binding ModelsFolderPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="1" VerticalAlignment="Center" />
<Button Content="Scegli..." Width="88" Margin="8,0,0,0" Command="{Binding SelectModelsFolderCommand}" Grid.Column="2" />
<Button Content="Apri" Width="56" Margin="8,0,0,0" Click="OpenModelsFolder_Click" Grid.Column="3" />
</Grid>
<TextBlock Text="Output CSV" FontWeight="Bold" Margin="0,12,0,0" />
<Grid Margin="0,6,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Text="Percorso CSV:" VerticalAlignment="Center" Margin="0,0,8,0" Grid.Column="0" />
<TextBox Text="{Binding CsvOutputPath, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" Grid.Column="1" VerticalAlignment="Center" />
<Button Content="Scegli..." Width="88" Margin="8,0,0,0" Command="{Binding SelectCsvOutputCommand}" Grid.Column="2" />
<Button Content="Apri" Width="56" Margin="8,0,0,0" Click="OpenCsvOutputFolder_Click" Grid.Column="3" />
</Grid>
<TextBlock Text="Anteprima risultati" FontWeight="Bold" Margin="0,12,0,0" />
<DataGrid ItemsSource="{Binding PreviewResults}" IsReadOnly="True" AutoGenerateColumns="False" Height="200" Margin="0,6,0,0">
<DataGrid.Columns>
<DataGridTextColumn Header="Path" Binding="{Binding Path}" Width="*" />
<DataGridTextColumn Header="Text" Binding="{Binding Text}" Width="2*" />
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl>
<!-- Right: Controls and live info -->

View file

@ -16,6 +16,8 @@ namespace ImageCatalog_2
InitializeComponent();
_model = model;
DataContext = _model;
// Apply theme based on user preference or system setting (default to light)
ApplyTheme(isDark: false);
// Subscribe to DataModel events that require UI dialogs
_model.SelectSourceFolderRequested += Model_SelectSourceFolderRequested;
_model.SelectDestinationFolderRequested += Model_SelectDestinationFolderRequested;
@ -24,11 +26,95 @@ namespace ImageCatalog_2
_model.LoadSettingsRequested += Model_LoadSettingsRequested;
_model.SelectColorRequested += Model_SelectColorRequested;
_model.SelectTransparentColorRequested += Model_SelectTransparentColorRequested;
_model.SelectModelsFolderRequested += Model_SelectModelsFolderRequested;
_model.SelectCsvOutputRequested += Model_SelectCsvOutputRequested;
// Watch for logo changes to update preview
_model.PropertyChanged += Model_PropertyChanged;
}
private void ApplyTheme(bool isDark)
{
try
{
var rd = isDark ? (ResourceDictionary)Resources["DarkTheme"] : (ResourceDictionary)Resources["LightTheme"];
foreach (var key in rd.Keys)
{
Resources[key] = rd[key];
}
}
catch
{
// ignore theme failures
}
}
private void Model_SelectModelsFolderRequested(object? sender, EventArgs e)
{
var dlg = new System.Windows.Forms.FolderBrowserDialog();
var starting = string.IsNullOrWhiteSpace(_model.ModelsFolderPath) ? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments) : _model.ModelsFolderPath;
dlg.SelectedPath = starting;
if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
_model.ModelsFolderPath = dlg.SelectedPath + Path.DirectorySeparatorChar;
}
}
private void OpenModelsFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var path = _model.ModelsFolderPath;
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (File.Exists(path))
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
if (Directory.Exists(path))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path, UseShellExecute = true });
return;
}
}
catch { }
}
private void Model_SelectCsvOutputRequested(object? sender, EventArgs e)
{
var dlg = new Microsoft.Win32.SaveFileDialog();
dlg.Filter = "CSV file (*.csv)|*.csv|All files (*.*)|*.*";
if (!string.IsNullOrWhiteSpace(_model.CsvOutputPath)) dlg.FileName = _model.CsvOutputPath;
var result = dlg.ShowDialog(this);
if (result == true)
{
_model.CsvOutputPath = dlg.FileName;
}
}
private void OpenCsvOutputFolder_Click(object sender, RoutedEventArgs e)
{
try
{
var path = _model.CsvOutputPath;
if (string.IsNullOrWhiteSpace(path)) return;
path = path.Trim().Trim('"');
if (File.Exists(path))
{
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
return;
}
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir))
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = dir, UseShellExecute = true });
return;
}
}
catch { }
}
private void Model_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e is null || string.IsNullOrWhiteSpace(e.PropertyName)) return;

View file

@ -245,5 +245,18 @@ namespace ImageCatalog_2.Models
[JsonPropertyName("RaceStartDate")]
[XmlElement("DataPartenza")]
public DateTime RaceStartDate { get; set; } = DateTime.Now;
// AI / OCR settings
[JsonPropertyName("ExtractNumbers")]
[XmlElement("AI_EstraiNumeri")]
public bool ExtractNumbers { get; set; }
[JsonPropertyName("ModelsFolderPath")]
[XmlElement("AI_CartellaModelli")]
public string ModelsFolderPath { get; set; }
[JsonPropertyName("CsvOutputPath")]
[XmlElement("AI_PercorsoCsv")]
public string CsvOutputPath { get; set; }
}
}

View file

@ -3,4 +3,15 @@
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings>
<Setting Name="ExtractNumbers" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
<Setting Name="ModelsFolderPath" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
<Setting Name="CsvOutputPath" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
</Settings>
</SettingsFile>