feat: Add AI extraction service and related view models
- Introduced `IAiExtractionService` and its implementation `AiExtractionService` for processing images and extracting text. - Created `AiResultItem` model to hold results from AI extraction. - Added `ImageProcessingCoordinator` to manage image processing tasks and provide progress updates. - Implemented view models for AI settings, path settings, processing state, race upload settings, and visual settings to support UI binding. - Updated `Program.cs` to register new services and dependencies. - Modified project file to skip MinVer execution during local builds.
This commit is contained in:
parent
bdf503c627
commit
3c722a66df
16 changed files with 1462 additions and 628 deletions
132
imagecatalog/Services/AiExtractionService.cs
Normal file
132
imagecatalog/Services/AiExtractionService.cs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ImageCatalog_2.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ImageCatalog_2.Services;
|
||||
|
||||
public class AiExtractionService : IAiExtractionService
|
||||
{
|
||||
private readonly ILogger<AiExtractionService> _logger;
|
||||
|
||||
public AiExtractionService(ILogger<AiExtractionService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAsync(
|
||||
AiExtractionRequest request,
|
||||
CancellationToken token,
|
||||
Func<AiResultItem, Task> onResult,
|
||||
Func<double, Task> onProgress)
|
||||
{
|
||||
var searchOption = request.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||
|
||||
var imageFiles = Directory.EnumerateFiles(request.SearchRoot, "*.*", searchOption)
|
||||
.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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var extractedResults = new List<AiResultItem>();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
var processed = 0;
|
||||
var total = imageFiles.Count;
|
||||
|
||||
foreach (var file in imageFiles)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error invoking AI processor 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 (!string.IsNullOrWhiteSpace(request.CsvOutputPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(request.CsvOutputPath) ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(dir) && !Directory.Exists(dir))
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
}
|
||||
|
||||
using var sw = new StreamWriter(request.CsvOutputPath, false, Encoding.UTF8);
|
||||
sw.WriteLine("Path,Text");
|
||||
foreach (var r in extractedResults)
|
||||
{
|
||||
var safeText = (r.Text ?? string.Empty).Replace("\"", "\"\"");
|
||||
sw.WriteLine($"\"{r.Path}\",\"{safeText}\"");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to write CSV to {CsvOutputPath}", request.CsvOutputPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
imagecatalog/Services/IAiExtractionService.cs
Normal file
22
imagecatalog/Services/IAiExtractionService.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ImageCatalog_2.Models;
|
||||
|
||||
namespace ImageCatalog_2.Services;
|
||||
|
||||
public sealed class AiExtractionRequest
|
||||
{
|
||||
public required string SearchRoot { get; init; }
|
||||
public required bool Recursive { get; init; }
|
||||
public string CsvOutputPath { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public interface IAiExtractionService
|
||||
{
|
||||
Task RunAsync(
|
||||
AiExtractionRequest request,
|
||||
CancellationToken token,
|
||||
Func<AiResultItem, Task> onResult,
|
||||
Func<double, Task> onProgress);
|
||||
}
|
||||
28
imagecatalog/Services/IImageProcessingCoordinator.cs
Normal file
28
imagecatalog/Services/IImageProcessingCoordinator.cs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MaddoShared;
|
||||
|
||||
namespace ImageCatalog_2.Services
|
||||
{
|
||||
public readonly record struct ImageProcessedUpdate(string Status, int Total, int Processed);
|
||||
|
||||
public sealed class ImageProcessingRunRequest
|
||||
{
|
||||
public required ImageCreationService.Options Options { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ImageProcessingRunResult
|
||||
{
|
||||
public required string FinalSpeedCounter { get; init; }
|
||||
}
|
||||
|
||||
public interface IImageProcessingCoordinator
|
||||
{
|
||||
Task<ImageProcessingRunResult> RunAsync(
|
||||
ImageProcessingRunRequest request,
|
||||
CancellationToken token,
|
||||
Action<ImageProcessedUpdate> onImageProcessed,
|
||||
Action<string> onSpeedUpdated);
|
||||
}
|
||||
}
|
||||
127
imagecatalog/Services/ImageProcessingCoordinator.cs
Normal file
127
imagecatalog/Services/ImageProcessingCoordinator.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MaddoShared;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ImageCatalog_2.Services
|
||||
{
|
||||
public class ImageProcessingCoordinator : IImageProcessingCoordinator
|
||||
{
|
||||
private readonly ImageCreationService _imageCreationService;
|
||||
private readonly ILogger<ImageProcessingCoordinator> _logger;
|
||||
|
||||
[CLSCompliant(false)]
|
||||
public ImageProcessingCoordinator(
|
||||
ImageCreationService imageCreationService,
|
||||
ILogger<ImageProcessingCoordinator> logger)
|
||||
{
|
||||
_imageCreationService = imageCreationService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ImageProcessingRunResult> RunAsync(
|
||||
ImageProcessingRunRequest request,
|
||||
CancellationToken token,
|
||||
Action<ImageProcessedUpdate> onImageProcessed,
|
||||
Action<string> onSpeedUpdated)
|
||||
{
|
||||
var results = new ConcurrentBag<string>();
|
||||
var recentDiffs = new Queue<int>();
|
||||
const int recentWindowSize = 5;
|
||||
|
||||
int currentAmount = 0;
|
||||
int previousAmount = 0;
|
||||
int processedAtomic = 0;
|
||||
|
||||
var speedWatch = Stopwatch.StartNew();
|
||||
using var speedTimer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
previousAmount = currentAmount;
|
||||
currentAmount = Volatile.Read(ref processedAtomic);
|
||||
int diff = currentAmount - previousAmount;
|
||||
if (diff < 0)
|
||||
{
|
||||
diff = 0;
|
||||
}
|
||||
|
||||
lock (recentDiffs)
|
||||
{
|
||||
recentDiffs.Enqueue(diff);
|
||||
if (recentDiffs.Count > recentWindowSize)
|
||||
{
|
||||
recentDiffs.Dequeue();
|
||||
}
|
||||
}
|
||||
|
||||
double avgRecent;
|
||||
lock (recentDiffs)
|
||||
{
|
||||
avgRecent = recentDiffs.Count == 0 ? 0.0 : recentDiffs.Average();
|
||||
}
|
||||
|
||||
double overall = 0.0;
|
||||
if (speedWatch.Elapsed.TotalSeconds >= 1)
|
||||
{
|
||||
var elapsedSeconds = speedWatch.Elapsed.TotalSeconds;
|
||||
var total = Volatile.Read(ref processedAtomic);
|
||||
overall = elapsedSeconds > 0 ? total / elapsedSeconds : 0.0;
|
||||
}
|
||||
|
||||
var recentPerMin = avgRecent * 60.0;
|
||||
var elapsed = speedWatch.Elapsed;
|
||||
int hours = (int)elapsed.TotalHours;
|
||||
int minutes = elapsed.Minutes;
|
||||
int seconds = elapsed.Seconds;
|
||||
var elapsedStr = $"{hours}h {minutes}m {seconds}s";
|
||||
|
||||
var speedText = $"{avgRecent:0.00} f/s (media: {overall:0.00} f/s) - {elapsedStr}{Environment.NewLine}media: {recentPerMin:0.00} f/m";
|
||||
onSpeedUpdated(speedText);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to update speed counter");
|
||||
}
|
||||
}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
|
||||
EventHandler<Tuple<string, int>> onImageProcessedInternal = (_, args) =>
|
||||
{
|
||||
var processed = Interlocked.Increment(ref processedAtomic);
|
||||
onImageProcessed(new ImageProcessedUpdate(args.Item1, args.Item2, processed));
|
||||
};
|
||||
|
||||
await _imageCreationService.CreaCatalogoParallel(
|
||||
request.Options,
|
||||
results,
|
||||
onImageProcessedInternal,
|
||||
token).ConfigureAwait(false);
|
||||
|
||||
speedWatch.Stop();
|
||||
|
||||
var finalProcessed = Volatile.Read(ref processedAtomic);
|
||||
double overallAvg = 0.0;
|
||||
double overallPerMin = 0.0;
|
||||
if (speedWatch.Elapsed.TotalSeconds > 0.0)
|
||||
{
|
||||
overallAvg = finalProcessed / speedWatch.Elapsed.TotalSeconds;
|
||||
overallPerMin = overallAvg * 60.0;
|
||||
}
|
||||
|
||||
var finalElapsed = speedWatch.Elapsed;
|
||||
int finalHours = (int)finalElapsed.TotalHours;
|
||||
int finalMinutes = finalElapsed.Minutes;
|
||||
int finalSeconds = finalElapsed.Seconds;
|
||||
|
||||
return new ImageProcessingRunResult
|
||||
{
|
||||
FinalSpeedCounter = $"{finalHours}h {finalMinutes}m {finalSeconds}s{Environment.NewLine}media: {overallAvg:0.00} f/s{Environment.NewLine}media: {overallPerMin:0.00} f/m"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue