Compare commits
4 commits
7e105e3738
...
f57dc1edba
| Author | SHA1 | Date | |
|---|---|---|---|
| f57dc1edba | |||
| 48d6af13da | |||
| 88c193549f | |||
| 4230300518 |
10 changed files with 377 additions and 27 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -256,3 +256,4 @@ paket-files/
|
||||||
.idea/
|
.idea/
|
||||||
*.sln.iml
|
*.sln.iml
|
||||||
.vscode/settings.json
|
.vscode/settings.json
|
||||||
|
tmp/**
|
||||||
|
|
@ -137,6 +137,24 @@ public class DataModelCharacterizationTests
|
||||||
model.IncludeNumberAiThumbnails.ShouldBeTrue();
|
model.IncludeNumberAiThumbnails.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void NumberAiWorkload_DefaultsToThreeAndClampsToFive()
|
||||||
|
{
|
||||||
|
var model = CreateModel();
|
||||||
|
model.NumberAiWorkloadLevel.ShouldBe(3);
|
||||||
|
|
||||||
|
string? changed = null;
|
||||||
|
model.PropertyChanged += (_, args) => changed = args.PropertyName;
|
||||||
|
|
||||||
|
model.NumberAiWorkloadLevel = 99;
|
||||||
|
|
||||||
|
changed.ShouldBe(nameof(DataModel.NumberAiWorkloadLevel));
|
||||||
|
model.NumberAiWorkloadLevel.ShouldBe(3);
|
||||||
|
|
||||||
|
model.Ai.NumberAiWorkloadLevel = 5;
|
||||||
|
model.NumberAiWorkloadLevel.ShouldBe(5);
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void CommandLineOperationRunner_DetectsHeadlessRequest()
|
public void CommandLineOperationRunner_DetectsHeadlessRequest()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@
|
||||||
<CheckBox Content="Includi thumbnail" IsChecked="{Binding IncludeNumberAiThumbnails, Mode=TwoWay}" />
|
<CheckBox Content="Includi thumbnail" IsChecked="{Binding IncludeNumberAiThumbnails, Mode=TwoWay}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Margin="0,8,0,0" ColumnDefinitions="Auto,Auto,*" ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Column="0" Text="Carico OCR:" VerticalAlignment="Center" />
|
||||||
|
<ComboBox Grid.Column="1"
|
||||||
|
Width="84"
|
||||||
|
ItemsSource="{Binding NumberAiWorkloadOptions}"
|
||||||
|
SelectedItem="{Binding NumberAiWorkloadLevel, Mode=TwoWay}" />
|
||||||
|
<TextBlock Grid.Column="2"
|
||||||
|
Text="{Binding NumberAiStatsSummary}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
FontWeight="SemiBold" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Grid Margin="0,8,0,0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="6">
|
<Grid Margin="0,8,0,0" ColumnDefinitions="Auto,*,Auto" ColumnSpacing="6">
|
||||||
<TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" />
|
<TextBlock Grid.Column="0" Text="Sorgente:" VerticalAlignment="Center" />
|
||||||
<TextBox Grid.Column="1" Text="{Binding DestinationPath, Mode=OneWay}" IsReadOnly="True" VerticalAlignment="Center" />
|
<TextBox Grid.Column="1" Text="{Binding DestinationPath, Mode=OneWay}" IsReadOnly="True" VerticalAlignment="Center" />
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,11 @@ internal static class CommandLineOperationRunner
|
||||||
{
|
{
|
||||||
model.IncludeNumberAiThumbnails = options.IncludeThumbnails.Value;
|
model.IncludeNumberAiThumbnails = options.IncludeThumbnails.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.NumberAiWorkloadLevel.HasValue)
|
||||||
|
{
|
||||||
|
model.NumberAiWorkloadLevel = options.NumberAiWorkloadLevel.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string NormalizeOperation(string operation)
|
private static string NormalizeOperation(string operation)
|
||||||
|
|
@ -172,6 +177,10 @@ internal static class CommandLineOperationRunner
|
||||||
case "--no-tn":
|
case "--no-tn":
|
||||||
options.IncludeThumbnails = false;
|
options.IncludeThumbnails = false;
|
||||||
break;
|
break;
|
||||||
|
case "--workload":
|
||||||
|
case "--ai-workload":
|
||||||
|
options.NumberAiWorkloadLevel = int.Parse(ReadValue(args, ref i, arg));
|
||||||
|
break;
|
||||||
case "--headless":
|
case "--headless":
|
||||||
case "--cli":
|
case "--cli":
|
||||||
break;
|
break;
|
||||||
|
|
@ -209,6 +218,10 @@ internal static class CommandLineOperationRunner
|
||||||
case "--csv":
|
case "--csv":
|
||||||
options.CsvPath = value;
|
options.CsvPath = value;
|
||||||
break;
|
break;
|
||||||
|
case "--workload":
|
||||||
|
case "--ai-workload":
|
||||||
|
options.NumberAiWorkloadLevel = int.Parse(value);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentException($"Unknown argument: {arg}");
|
throw new ArgumentException($"Unknown argument: {arg}");
|
||||||
}
|
}
|
||||||
|
|
@ -226,7 +239,7 @@ internal static class CommandLineOperationRunner
|
||||||
|
|
||||||
private static void WriteUsage()
|
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] [--include-thumbnails|--no-thumbnails]");
|
Console.WriteLine("Usage: ImageCatalog --config <settings.xml> --operation <image-processing|number-ai|face-ai|race-upload> [--models <folder>] [--csv <path>] [--cpu|--gpu] [--include-thumbnails|--no-thumbnails] [--workload <1-5>]");
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class CommandLineOptions
|
private sealed class CommandLineOptions
|
||||||
|
|
@ -238,5 +251,6 @@ internal static class CommandLineOperationRunner
|
||||||
public string CsvPath { get; set; } = string.Empty;
|
public string CsvPath { get; set; } = string.Empty;
|
||||||
public bool? UseGpu { get; set; }
|
public bool? UseGpu { get; set; }
|
||||||
public bool? IncludeThumbnails { get; set; }
|
public bool? IncludeThumbnails { get; set; }
|
||||||
|
public int? NumberAiWorkloadLevel { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +131,7 @@ namespace ImageCatalog_2
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// user cancelled
|
await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = "OCR annullato.").ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -141,6 +141,8 @@ namespace ImageCatalog_2
|
||||||
RefreshNumberAiGpuCapabilities();
|
RefreshNumberAiGpuCapabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await InvokeOnUiThreadAsync(() => NumberAiStatsSummary = $"Errore OCR: {ex.GetBaseException().Message}").ConfigureAwait(false);
|
||||||
|
|
||||||
await ShowErrorMessageAsync("Errore AI", ex.GetBaseException().Message).ConfigureAwait(false);
|
await ShowErrorMessageAsync("Errore AI", ex.GetBaseException().Message).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|
@ -167,9 +169,10 @@ namespace ImageCatalog_2
|
||||||
{
|
{
|
||||||
PreviewResults.Clear();
|
PreviewResults.Clear();
|
||||||
AiProgress = 0;
|
AiProgress = 0;
|
||||||
|
NumberAiStatsSummary = BuildNumberAiIdleSummary();
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
await _aiExtractionService.RunAsync(
|
var summary = await _aiExtractionService.RunAsync(
|
||||||
new AiExtractionRequest
|
new AiExtractionRequest
|
||||||
{
|
{
|
||||||
SearchRoot = searchRoot,
|
SearchRoot = searchRoot,
|
||||||
|
|
@ -177,6 +180,7 @@ namespace ImageCatalog_2
|
||||||
IncludeThumbnails = IncludeNumberAiThumbnails,
|
IncludeThumbnails = IncludeNumberAiThumbnails,
|
||||||
ModelsFolderPath = ModelsFolderPath,
|
ModelsFolderPath = ModelsFolderPath,
|
||||||
UseGpu = UseNumberAiGpu,
|
UseGpu = UseNumberAiGpu,
|
||||||
|
WorkloadLevel = NumberAiWorkloadLevel,
|
||||||
CsvOutputPath = CsvOutputPath
|
CsvOutputPath = CsvOutputPath
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
|
|
@ -187,7 +191,17 @@ namespace ImageCatalog_2
|
||||||
PreviewResults.Add(result);
|
PreviewResults.Add(result);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
progress => InvokeOnUiThreadAsync(() => AiProgress = progress)).ConfigureAwait(false);
|
progress => InvokeOnUiThreadAsync(() =>
|
||||||
|
{
|
||||||
|
AiProgress = progress.PercentComplete;
|
||||||
|
NumberAiStatsSummary = BuildNumberAiProgressSummary(progress);
|
||||||
|
})).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await InvokeOnUiThreadAsync(() =>
|
||||||
|
{
|
||||||
|
AiProgress = summary.TotalFiles > 0 ? 100 : 0;
|
||||||
|
NumberAiStatsSummary = BuildNumberAiCompletionSummary(summary);
|
||||||
|
}).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -250,6 +264,20 @@ namespace ImageCatalog_2
|
||||||
set => _ai.IncludeNumberAiThumbnails = value;
|
set => _ai.IncludeNumberAiThumbnails = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<int> NumberAiWorkloadOptions { get; } = [1, 2, 3, 4, 5];
|
||||||
|
|
||||||
|
public int NumberAiWorkloadLevel
|
||||||
|
{
|
||||||
|
get => _ai.NumberAiWorkloadLevel;
|
||||||
|
set => _ai.NumberAiWorkloadLevel = NormalizeNumberAiWorkloadLevel(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string NumberAiStatsSummary
|
||||||
|
{
|
||||||
|
get => _ai.NumberAiStatsSummary;
|
||||||
|
private set => _ai.NumberAiStatsSummary = value;
|
||||||
|
}
|
||||||
|
|
||||||
public string FaceExecutablePath
|
public string FaceExecutablePath
|
||||||
{
|
{
|
||||||
get => _ai.FaceExecutablePath;
|
get => _ai.FaceExecutablePath;
|
||||||
|
|
@ -419,6 +447,30 @@ namespace ImageCatalog_2
|
||||||
set => _ai.AiProgress = value;
|
set => _ai.AiProgress = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string BuildNumberAiIdleSummary()
|
||||||
|
{
|
||||||
|
var workerCount = ResolveNumberAiWorkerCount(UseNumberAiGpu, NumberAiWorkloadLevel);
|
||||||
|
var unit = UseNumberAiGpu ? "batch" : "worker";
|
||||||
|
return $"In attesa. Carico {NumberAiWorkloadLevel}/5, {workerCount} {unit}, 0.00 img/s.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildNumberAiProgressSummary(AiExtractionProgressUpdate progress)
|
||||||
|
{
|
||||||
|
var unit = progress.UseGpu ? "batch" : "worker";
|
||||||
|
return $"{progress.ProcessedFiles}/{progress.TotalFiles} immagini, media {progress.AverageImagesPerSecond:F2} img/s, carico {progress.WorkloadLevel}/5, {progress.WorkerCount} {unit}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildNumberAiCompletionSummary(AiExtractionRunSummary summary)
|
||||||
|
{
|
||||||
|
if (summary.TotalFiles == 0)
|
||||||
|
{
|
||||||
|
return "Nessuna immagine trovata per OCR.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var unit = summary.UseGpu ? "batch" : "worker";
|
||||||
|
return $"Completato: {summary.ProcessedFiles}/{summary.TotalFiles} immagini, media finale {summary.AverageImagesPerSecond:F2} img/s, errori {summary.FailedFiles}, carico {summary.WorkloadLevel}/5, {summary.WorkerCount} {unit}.";
|
||||||
|
}
|
||||||
|
|
||||||
private List<string> LoadAvailableFonts()
|
private List<string> LoadAvailableFonts()
|
||||||
{
|
{
|
||||||
#if WINDOWS
|
#if WINDOWS
|
||||||
|
|
@ -2167,6 +2219,36 @@ namespace ImageCatalog_2
|
||||||
return value is >= 1 and <= 5 ? value : 3;
|
return value is >= 1 and <= 5 ? value : 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int NormalizeNumberAiWorkloadLevel(int value)
|
||||||
|
{
|
||||||
|
return value is >= 1 and <= 5 ? value : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveNumberAiWorkerCount(bool useGpu, int workloadLevel)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeNumberAiWorkloadLevel(workloadLevel);
|
||||||
|
var maxWorkers = Math.Max(1, Environment.ProcessorCount);
|
||||||
|
var requestedWorkers = useGpu
|
||||||
|
? normalized switch
|
||||||
|
{
|
||||||
|
1 => 4,
|
||||||
|
2 => 8,
|
||||||
|
3 => 16,
|
||||||
|
4 => 24,
|
||||||
|
_ => 32
|
||||||
|
}
|
||||||
|
: normalized switch
|
||||||
|
{
|
||||||
|
1 => 1,
|
||||||
|
2 => 2,
|
||||||
|
3 => 3,
|
||||||
|
4 => 4,
|
||||||
|
_ => 5
|
||||||
|
};
|
||||||
|
|
||||||
|
return useGpu ? requestedWorkers : Math.Min(requestedWorkers, maxWorkers);
|
||||||
|
}
|
||||||
|
|
||||||
private static int NormalizeFaceMinSize(int value)
|
private static int NormalizeFaceMinSize(int value)
|
||||||
{
|
{
|
||||||
return value > 0 ? value : 35;
|
return value > 0 ? value : 35;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@
|
||||||
<!-- Skip MinVer execution during local builds to avoid environment/runtime-specific failures. -->
|
<!-- Skip MinVer execution during local builds to avoid environment/runtime-specific failures. -->
|
||||||
<MinVerSkip>true</MinVerSkip>
|
<MinVerSkip>true</MinVerSkip>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="$([MSBuild]::IsOsPlatform('Windows')) and '$(UseLocalAIFotoONLUS)' == 'true'">
|
||||||
|
<LocalAIFotoOutputDir>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\AIFotoONLUS\src\AIFotoONLUS.Core\bin\$(Configuration)\net10.0'))</LocalAIFotoOutputDir>
|
||||||
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
|
||||||
<DebugType>embedded</DebugType>
|
<DebugType>embedded</DebugType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
@ -147,4 +150,13 @@
|
||||||
<Copy SourceFiles="$(_CandidateFound)" DestinationFiles="$(_DestExe)" SkipUnchangedFiles="false" Condition="'$(_CandidateFound)' != ''" />
|
<Copy SourceFiles="$(_CandidateFound)" DestinationFiles="$(_DestExe)" SkipUnchangedFiles="false" Condition="'$(_CandidateFound)' != ''" />
|
||||||
<Delete Files="$(_CandidateFound)" Condition="'$(_CandidateFound)' != ''" />
|
<Delete Files="$(_CandidateFound)" Condition="'$(_CandidateFound)' != ''" />
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="CopyLocalCudaInferenceLibraries" AfterTargets="Build" Condition="$([MSBuild]::IsOsPlatform('Windows')) and '$(UseLocalAIFotoONLUS)' == 'true'">
|
||||||
|
<ItemGroup>
|
||||||
|
<LocalCudaInferenceLibrary Include="$(LocalAIFotoOutputDir)\cublasLt64_11.dll" Condition="Exists('$(LocalAIFotoOutputDir)\cublasLt64_11.dll')" />
|
||||||
|
<LocalCudaInferenceLibrary Include="$(LocalAIFotoOutputDir)\cudnn_cnn_infer64_8.dll" Condition="Exists('$(LocalAIFotoOutputDir)\cudnn_cnn_infer64_8.dll')" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Copy SourceFiles="@(LocalCudaInferenceLibrary)" DestinationFolder="$(TargetDir)" SkipUnchangedFiles="true" Condition="'@(LocalCudaInferenceLibrary)' != ''" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -282,6 +282,10 @@ namespace ImageCatalog_2.Models
|
||||||
[XmlElement("AI_IncludiThumbnailNumeri")]
|
[XmlElement("AI_IncludiThumbnailNumeri")]
|
||||||
public bool IncludeNumberAiThumbnails { get; set; }
|
public bool IncludeNumberAiThumbnails { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("NumberAiWorkloadLevel")]
|
||||||
|
[XmlElement("AI_LivelloCaricoNumeri")]
|
||||||
|
public int NumberAiWorkloadLevel { get; set; } = 3;
|
||||||
|
|
||||||
[JsonPropertyName("FaceExecutablePath")]
|
[JsonPropertyName("FaceExecutablePath")]
|
||||||
[XmlElement("AI_FaceExecutablePath")]
|
[XmlElement("AI_FaceExecutablePath")]
|
||||||
public string FaceExecutablePath { get; set; } = string.Empty;
|
public string FaceExecutablePath { get; set; } = string.Empty;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AIFotoONLUS.Core;
|
using AIFotoONLUS.Core;
|
||||||
using ImageCatalog_2.Models;
|
using ImageCatalog_2.Models;
|
||||||
|
|
@ -20,11 +21,11 @@ public class AiExtractionService : IAiExtractionService
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(
|
public async Task<AiExtractionRunSummary> RunAsync(
|
||||||
AiExtractionRequest request,
|
AiExtractionRequest request,
|
||||||
CancellationToken token,
|
CancellationToken token,
|
||||||
Func<AiResultItem, Task> onResult,
|
Func<AiResultItem, Task> onResult,
|
||||||
Func<double, Task> onProgress)
|
Func<AiExtractionProgressUpdate, Task> onProgress)
|
||||||
{
|
{
|
||||||
var searchOption = request.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
var searchOption = request.Recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||||
|
|
||||||
|
|
@ -39,38 +40,134 @@ public class AiExtractionService : IAiExtractionService
|
||||||
|
|
||||||
var extractedResults = new List<AiResultItem>();
|
var extractedResults = new List<AiResultItem>();
|
||||||
var modelConfiguration = BuildModelConfiguration(request.ModelsFolderPath, request.UseGpu);
|
var modelConfiguration = BuildModelConfiguration(request.ModelsFolderPath, request.UseGpu);
|
||||||
|
var workloadLevel = NormalizeWorkloadLevel(request.WorkloadLevel);
|
||||||
using var engine = new NumberRecognitionEngine(modelConfiguration, _logger);
|
var workerCount = ResolveWorkerCount(request.UseGpu, workloadLevel);
|
||||||
|
var total = imageFiles.Count;
|
||||||
|
if (total == 0)
|
||||||
|
{
|
||||||
|
var emptySummary = new AiExtractionRunSummary(0, 0, 0, 0, workloadLevel, workerCount, request.UseGpu);
|
||||||
|
await onProgress(new AiExtractionProgressUpdate(0, 0, 100, 0, workloadLevel, workerCount, request.UseGpu)).ConfigureAwait(false);
|
||||||
|
return emptySummary;
|
||||||
|
}
|
||||||
|
|
||||||
var processed = 0;
|
var processed = 0;
|
||||||
var total = imageFiles.Count;
|
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
Exception? firstFailure = null;
|
Exception? firstFailure = null;
|
||||||
|
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||||
foreach (var file in imageFiles)
|
var resultChannel = Channel.CreateUnbounded<AiResultItem>(new UnboundedChannelOptions
|
||||||
{
|
{
|
||||||
token.ThrowIfCancellationRequested();
|
SingleReader = true,
|
||||||
|
SingleWriter = false
|
||||||
|
});
|
||||||
|
var fileChannel = Channel.CreateBounded<string>(new BoundedChannelOptions(Math.Max(workerCount * 2, 1))
|
||||||
|
{
|
||||||
|
SingleReader = false,
|
||||||
|
SingleWriter = true,
|
||||||
|
FullMode = BoundedChannelFullMode.Wait
|
||||||
|
});
|
||||||
|
var failureLock = new object();
|
||||||
|
var logLock = new object();
|
||||||
|
var lastLoggedElapsed = TimeSpan.Zero;
|
||||||
|
|
||||||
var extracted = string.Empty;
|
var reporterTask = Task.Run(async () =>
|
||||||
|
{
|
||||||
try
|
await foreach (var result in resultChannel.Reader.ReadAllAsync(token).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
extracted = engine.ProcessImage(file).Text;
|
extractedResults.Add(result);
|
||||||
|
await onResult(result).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var currentProcessed = Interlocked.Increment(ref processed);
|
||||||
|
var averageImagesPerSecond = CalculateAverageImagesPerSecond(currentProcessed, stopwatch.Elapsed);
|
||||||
|
var percent = currentProcessed * 100.0 / total;
|
||||||
|
await onProgress(new AiExtractionProgressUpdate(total, currentProcessed, percent, averageImagesPerSecond, workloadLevel, workerCount, request.UseGpu)).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var shouldLog = false;
|
||||||
|
lock (logLock)
|
||||||
|
{
|
||||||
|
if (currentProcessed == total || stopwatch.Elapsed - lastLoggedElapsed >= TimeSpan.FromSeconds(2))
|
||||||
|
{
|
||||||
|
lastLoggedElapsed = stopwatch.Elapsed;
|
||||||
|
shouldLog = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldLog)
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Number AI progress: {Processed}/{Total} ({Percent:F1}%), {ImagesPerSecond:F2} img/s avg, workload {WorkloadLevel} ({WorkerCount} {ExecutionUnit})",
|
||||||
|
currentProcessed,
|
||||||
|
total,
|
||||||
|
percent,
|
||||||
|
averageImagesPerSecond,
|
||||||
|
workloadLevel,
|
||||||
|
workerCount,
|
||||||
|
request.UseGpu ? "batch" : "workers");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
}, token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (request.UseGpu)
|
||||||
{
|
{
|
||||||
failed++;
|
using var engine = new NumberRecognitionEngine(modelConfiguration, _logger);
|
||||||
firstFailure ??= ex;
|
var resultProgress = new SynchronousProgress<ImageResult>(result =>
|
||||||
_logger.LogWarning(ex, "Error processing AI OCR for {File}", file);
|
{
|
||||||
|
resultChannel.Writer.TryWrite(new AiResultItem { Path = result.FilePath, Text = result.Text });
|
||||||
|
});
|
||||||
|
|
||||||
|
await engine.ProcessFilesAsync(
|
||||||
|
imageFiles,
|
||||||
|
skipTextNegative: false,
|
||||||
|
maxDegreeOfParallelism: workerCount,
|
||||||
|
progress: null,
|
||||||
|
resultProgress: resultProgress,
|
||||||
|
cancellationToken: token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var workerTasks = Enumerable.Range(0, workerCount)
|
||||||
|
.Select(_ => Task.Run(async () =>
|
||||||
|
{
|
||||||
|
using var engine = new NumberRecognitionEngine(modelConfiguration, _logger);
|
||||||
|
await foreach (var file in fileChannel.Reader.ReadAllAsync(token).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var extracted = string.Empty;
|
||||||
|
|
||||||
var result = new AiResultItem { Path = file, Text = extracted };
|
try
|
||||||
extractedResults.Add(result);
|
{
|
||||||
await onResult(result).ConfigureAwait(false);
|
extracted = engine.ProcessImage(file).Text;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lock (failureLock)
|
||||||
|
{
|
||||||
|
failed++;
|
||||||
|
firstFailure ??= ex;
|
||||||
|
}
|
||||||
|
|
||||||
processed++;
|
_logger.LogWarning(ex, "Error processing AI OCR for {File}", file);
|
||||||
var percent = total > 0 ? (processed * 100.0 / total) : 100.0;
|
}
|
||||||
await onProgress(percent).ConfigureAwait(false);
|
|
||||||
|
await resultChannel.Writer.WriteAsync(new AiResultItem { Path = file, Text = extracted }, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}, token))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var file in imageFiles)
|
||||||
|
{
|
||||||
|
await fileChannel.Writer.WriteAsync(file, token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileChannel.Writer.TryComplete();
|
||||||
|
await Task.WhenAll(workerTasks).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
fileChannel.Writer.TryComplete();
|
||||||
|
resultChannel.Writer.TryComplete();
|
||||||
|
await reporterTask.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageFiles.Count > 0 && failed == imageFiles.Count)
|
if (imageFiles.Count > 0 && failed == imageFiles.Count)
|
||||||
|
|
@ -78,6 +175,25 @@ public class AiExtractionService : IAiExtractionService
|
||||||
throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details.", firstFailure);
|
throw new InvalidOperationException($"AI OCR failed for all {imageFiles.Count} image(s). See previous log entries for details.", firstFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var summary = new AiExtractionRunSummary(
|
||||||
|
total,
|
||||||
|
processed,
|
||||||
|
failed,
|
||||||
|
CalculateAverageImagesPerSecond(processed, stopwatch.Elapsed),
|
||||||
|
workloadLevel,
|
||||||
|
workerCount,
|
||||||
|
request.UseGpu);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Number AI completed: {Processed}/{Total} processed, {Failed} failures, {ImagesPerSecond:F2} img/s avg, workload {WorkloadLevel} ({WorkerCount} {ExecutionUnit})",
|
||||||
|
summary.ProcessedFiles,
|
||||||
|
summary.TotalFiles,
|
||||||
|
summary.FailedFiles,
|
||||||
|
summary.AverageImagesPerSecond,
|
||||||
|
summary.WorkloadLevel,
|
||||||
|
summary.WorkerCount,
|
||||||
|
request.UseGpu ? "batch" : "workers");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.CsvOutputPath))
|
if (!string.IsNullOrWhiteSpace(request.CsvOutputPath))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -102,6 +218,55 @@ public class AiExtractionService : IAiExtractionService
|
||||||
_logger.LogError(ex, "Failed to write CSV to {CsvOutputPath}", request.CsvOutputPath);
|
_logger.LogError(ex, "Failed to write CSV to {CsvOutputPath}", request.CsvOutputPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double CalculateAverageImagesPerSecond(int processed, TimeSpan elapsed)
|
||||||
|
{
|
||||||
|
return elapsed.TotalSeconds > 0 ? processed / elapsed.TotalSeconds : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeWorkloadLevel(int workloadLevel)
|
||||||
|
{
|
||||||
|
return Math.Clamp(workloadLevel, 1, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ResolveWorkerCount(bool useGpu, int workloadLevel)
|
||||||
|
{
|
||||||
|
var normalized = NormalizeWorkloadLevel(workloadLevel);
|
||||||
|
var maxWorkers = Math.Max(1, Environment.ProcessorCount);
|
||||||
|
var requestedWorkers = useGpu
|
||||||
|
? normalized switch
|
||||||
|
{
|
||||||
|
1 => 4,
|
||||||
|
2 => 8,
|
||||||
|
3 => 16,
|
||||||
|
4 => 24,
|
||||||
|
_ => 32
|
||||||
|
}
|
||||||
|
: normalized switch
|
||||||
|
{
|
||||||
|
1 => 1,
|
||||||
|
2 => 2,
|
||||||
|
3 => 3,
|
||||||
|
4 => 4,
|
||||||
|
_ => 5
|
||||||
|
};
|
||||||
|
|
||||||
|
return useGpu ? requestedWorkers : Math.Min(requestedWorkers, maxWorkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SynchronousProgress<T> : IProgress<T>
|
||||||
|
{
|
||||||
|
private readonly Action<T> _handler;
|
||||||
|
|
||||||
|
public SynchronousProgress(Action<T> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Report(T value) => _handler(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ModelConfiguration BuildModelConfiguration(string modelsFolderPath, bool useGpu)
|
private static ModelConfiguration BuildModelConfiguration(string modelsFolderPath, bool useGpu)
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,33 @@ public sealed class AiExtractionRequest
|
||||||
public bool IncludeThumbnails { get; init; }
|
public bool IncludeThumbnails { get; init; }
|
||||||
public required string ModelsFolderPath { get; init; }
|
public required string ModelsFolderPath { get; init; }
|
||||||
public bool UseGpu { get; init; }
|
public bool UseGpu { get; init; }
|
||||||
|
public int WorkloadLevel { get; init; } = 3;
|
||||||
public string CsvOutputPath { get; init; } = string.Empty;
|
public string CsvOutputPath { get; init; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record AiExtractionProgressUpdate(
|
||||||
|
int TotalFiles,
|
||||||
|
int ProcessedFiles,
|
||||||
|
double PercentComplete,
|
||||||
|
double AverageImagesPerSecond,
|
||||||
|
int WorkloadLevel,
|
||||||
|
int WorkerCount,
|
||||||
|
bool UseGpu);
|
||||||
|
|
||||||
|
public sealed record AiExtractionRunSummary(
|
||||||
|
int TotalFiles,
|
||||||
|
int ProcessedFiles,
|
||||||
|
int FailedFiles,
|
||||||
|
double AverageImagesPerSecond,
|
||||||
|
int WorkloadLevel,
|
||||||
|
int WorkerCount,
|
||||||
|
bool UseGpu);
|
||||||
|
|
||||||
public interface IAiExtractionService
|
public interface IAiExtractionService
|
||||||
{
|
{
|
||||||
Task RunAsync(
|
Task<AiExtractionRunSummary> RunAsync(
|
||||||
AiExtractionRequest request,
|
AiExtractionRequest request,
|
||||||
CancellationToken token,
|
CancellationToken token,
|
||||||
Func<AiResultItem, Task> onResult,
|
Func<AiResultItem, Task> onResult,
|
||||||
Func<double, Task> onProgress);
|
Func<AiExtractionProgressUpdate, Task> onProgress);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,28 @@ public class AiSettingsViewModel : ViewModelBase
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int _numberAiWorkloadLevel = 3;
|
||||||
|
public int NumberAiWorkloadLevel
|
||||||
|
{
|
||||||
|
get => _numberAiWorkloadLevel;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_numberAiWorkloadLevel = value;
|
||||||
|
NotifyPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string _numberAiStatsSummary = string.Empty;
|
||||||
|
public string NumberAiStatsSummary
|
||||||
|
{
|
||||||
|
get => _numberAiStatsSummary;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
_numberAiStatsSummary = value;
|
||||||
|
NotifyPropertyChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private string _faceExecutablePath = string.Empty;
|
private string _faceExecutablePath = string.Empty;
|
||||||
public string FaceExecutablePath
|
public string FaceExecutablePath
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue