Initial .NET scaffold: Core, Console, WPF projects
Introduced solution structure for AIFotoONLUS migration to .NET. Added Core library with YOLO-based detection/recognition engine using OpenCvSharp, Console batch runner, and WPF demo frontend with MVVM. Implemented model loading, directory processing, progress reporting, and preferences. Added README with build/run instructions.
This commit is contained in:
parent
314761bf9e
commit
769afc08fb
18 changed files with 976 additions and 0 deletions
42
AIFotoONLUS.sln
Normal file
42
AIFotoONLUS.sln
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 18
|
||||||
|
VisualStudioVersion = 18.3.11505.172 d18.3
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIFotoONLUS.Core", "src\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj", "{E115F3D0-A9E5-B632-150F-3AE0FB7AC8A8}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIFotoONLUS.Console", "src\AIFotoONLUS.Console\AIFotoONLUS.Console.csproj", "{272F569E-4277-20A2-5482-A7FF36ECB248}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIFotoONLUS.WPF", "src\AIFotoONLUS.WPF\AIFotoONLUS.WPF.csproj", "{961196B2-0208-3C5E-D664-14EF48F3B2F4}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{E115F3D0-A9E5-B632-150F-3AE0FB7AC8A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E115F3D0-A9E5-B632-150F-3AE0FB7AC8A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E115F3D0-A9E5-B632-150F-3AE0FB7AC8A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E115F3D0-A9E5-B632-150F-3AE0FB7AC8A8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{272F569E-4277-20A2-5482-A7FF36ECB248}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{272F569E-4277-20A2-5482-A7FF36ECB248}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{272F569E-4277-20A2-5482-A7FF36ECB248}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{272F569E-4277-20A2-5482-A7FF36ECB248}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{961196B2-0208-3C5E-D664-14EF48F3B2F4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{961196B2-0208-3C5E-D664-14EF48F3B2F4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{961196B2-0208-3C5E-D664-14EF48F3B2F4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{961196B2-0208-3C5E-D664-14EF48F3B2F4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{E115F3D0-A9E5-B632-150F-3AE0FB7AC8A8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{961196B2-0208-3C5E-D664-14EF48F3B2F4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {D0584B69-5C15-4308-9677-0B13E4D08267}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
14
src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj
Normal file
14
src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="NLog" Version="6.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
84
src/AIFotoONLUS.Console/Program.cs
Normal file
84
src/AIFotoONLUS.Console/Program.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
using AIFotoONLUS.Core;
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.ConsoleApp
|
||||||
|
{
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static int Main(string[] args)
|
||||||
|
{
|
||||||
|
if (args.Length == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Usage: AIFotoONLUS.Console -d <directory> -c <output.csv> [-tn|--textnegative]");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? directory = null;
|
||||||
|
string? csvPath = null;
|
||||||
|
bool textNegative = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var a = args[i];
|
||||||
|
if (a == "-d" || a == "--directory")
|
||||||
|
{
|
||||||
|
if (i + 1 < args.Length) directory = args[++i];
|
||||||
|
}
|
||||||
|
else if (a == "-c" || a == "--csv")
|
||||||
|
{
|
||||||
|
if (i + 1 < args.Length) csvPath = args[++i];
|
||||||
|
}
|
||||||
|
else if (a == "-tn" || a == "--textnegative")
|
||||||
|
{
|
||||||
|
textNegative = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(csvPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine("Missing required arguments.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Directory not found: {directory}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg = new ModelConfiguration
|
||||||
|
{
|
||||||
|
DetectionCfg = Path.Combine("models", "detection.cfg"),
|
||||||
|
DetectionWeights = Path.Combine("models", "detection.weights"),
|
||||||
|
RecognitionCfg = Path.Combine("models", "recognition.cfg"),
|
||||||
|
RecognitionWeights = Path.Combine("models", "recognition.weights"),
|
||||||
|
ConfidenceThreshold = 0.5,
|
||||||
|
NmsThreshold = 0.4
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var engine = new NumberRecognitionEngine(cfg);
|
||||||
|
var results = engine.ProcessDirectory(directory, textNegative).ToList();
|
||||||
|
|
||||||
|
using var sw = new StreamWriter(csvPath, false);
|
||||||
|
sw.WriteLine("filename,text");
|
||||||
|
foreach (var r in results)
|
||||||
|
{
|
||||||
|
sw.WriteLine($"{r.FileName},{r.Text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"Results written to {csvPath}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Error: {ex.Message}");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj
Normal file
13
src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="OpenCvSharp4" Version="4.13.0.20260214" />
|
||||||
|
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260214" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
8
src/AIFotoONLUS.Core/DetectedRegion.cs
Normal file
8
src/AIFotoONLUS.Core/DetectedRegion.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using OpenCvSharp;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.Core
|
||||||
|
{
|
||||||
|
public record DetectedRegion(Rect BoundingBox, float Confidence, int ClassId, double CenterX);
|
||||||
|
public record RecognitionResult(string Text, Rect BoundingBox, double Confidence);
|
||||||
|
public record ImageResult(string FileName, string Text, string FilePath);
|
||||||
|
}
|
||||||
20
src/AIFotoONLUS.Core/ModelConfiguration.cs
Normal file
20
src/AIFotoONLUS.Core/ModelConfiguration.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using OpenCvSharp;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.Core
|
||||||
|
{
|
||||||
|
public class ModelConfiguration
|
||||||
|
{
|
||||||
|
public string DetectionCfg { get; set; } = "models/detection.cfg";
|
||||||
|
public string DetectionWeights { get; set; } = "models/detection.weights";
|
||||||
|
public string RecognitionCfg { get; set; } = "models/recognition.cfg";
|
||||||
|
public string RecognitionWeights { get; set; } = "models/recognition.weights";
|
||||||
|
|
||||||
|
public double ConfidenceThreshold { get; set; } = 0.5;
|
||||||
|
public double NmsThreshold { get; set; } = 0.4;
|
||||||
|
|
||||||
|
public Size DetectionInputSize { get; set; } = new Size(416, 416);
|
||||||
|
public Size RecognitionInputSize { get; set; } = new Size(140, 120);
|
||||||
|
|
||||||
|
public string[] NumberClasses { get; set; } = new[] { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" };
|
||||||
|
}
|
||||||
|
}
|
||||||
369
src/AIFotoONLUS.Core/NumberRecognitionEngine.cs
Normal file
369
src/AIFotoONLUS.Core/NumberRecognitionEngine.cs
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
using OpenCvSharp;
|
||||||
|
using OpenCvSharp.Dnn;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.Core
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// NumberRecognitionEngine: loads Darknet models via OpenCvSharp and
|
||||||
|
/// provides methods to detect text regions and recognize digits.
|
||||||
|
/// </summary>
|
||||||
|
public class NumberRecognitionEngine : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Net _detectionNet;
|
||||||
|
private readonly Net _recognitionNet;
|
||||||
|
private readonly ModelConfiguration _cfg;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public NumberRecognitionEngine(ModelConfiguration cfg)
|
||||||
|
{
|
||||||
|
_cfg = cfg ?? throw new ArgumentNullException(nameof(cfg));
|
||||||
|
|
||||||
|
if (!File.Exists(_cfg.DetectionCfg) || !File.Exists(_cfg.DetectionWeights))
|
||||||
|
throw new FileNotFoundException("Detection model files not found.", _cfg.DetectionCfg);
|
||||||
|
if (!File.Exists(_cfg.RecognitionCfg) || !File.Exists(_cfg.RecognitionWeights))
|
||||||
|
throw new FileNotFoundException("Recognition model files not found.", _cfg.RecognitionCfg);
|
||||||
|
|
||||||
|
_detectionNet = CvDnn.ReadNetFromDarknet(_cfg.DetectionCfg, _cfg.DetectionWeights);
|
||||||
|
_recognitionNet = CvDnn.ReadNetFromDarknet(_cfg.RecognitionCfg, _cfg.RecognitionWeights);
|
||||||
|
|
||||||
|
_detectionNet.SetPreferableBackend(Backend.OPENCV);
|
||||||
|
_detectionNet.SetPreferableTarget(Target.CPU);
|
||||||
|
_recognitionNet.SetPreferableBackend(Backend.OPENCV);
|
||||||
|
_recognitionNet.SetPreferableTarget(Target.CPU);
|
||||||
|
// Let OpenCV use multiple threads internally (use number of logical processors)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Cv2.SetNumThreads(Environment.ProcessorCount);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore if not supported by OpenCvSharp build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed) return;
|
||||||
|
_detectionNet?.Dispose();
|
||||||
|
_recognitionNet?.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string[] GetOutputLayerNames(Net net) => net.GetUnconnectedOutLayersNames();
|
||||||
|
|
||||||
|
public IEnumerable<DetectedRegion> DetectTextRegions(Mat image)
|
||||||
|
{
|
||||||
|
if (image is null) throw new ArgumentNullException(nameof(image));
|
||||||
|
|
||||||
|
return DetectTextRegions(_detectionNet, image);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal variant that accepts a Net instance so it can be used from parallel workers
|
||||||
|
private IEnumerable<DetectedRegion> DetectTextRegions(Net detectionNet, Mat image)
|
||||||
|
{
|
||||||
|
using var blob = CvDnn.BlobFromImage(image, 0.00392, _cfg.DetectionInputSize, new Scalar(0, 0, 0), true, false);
|
||||||
|
detectionNet.SetInput(blob);
|
||||||
|
|
||||||
|
var outNames = GetOutputLayerNames(detectionNet);
|
||||||
|
var outsList = new List<Mat>();
|
||||||
|
detectionNet.Forward(outsList, outNames);
|
||||||
|
Mat[] outs = outsList.ToArray();
|
||||||
|
|
||||||
|
var boxes = new List<Rect>();
|
||||||
|
var confidences = new List<float>();
|
||||||
|
var classIds = new List<int>();
|
||||||
|
var centerXList = new List<double>();
|
||||||
|
|
||||||
|
int imgW = image.Width;
|
||||||
|
int imgH = image.Height;
|
||||||
|
foreach (var outMat in outs)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < outMat.Rows; i++)
|
||||||
|
{
|
||||||
|
float cx = outMat.At<float>(i, 0) * imgW;
|
||||||
|
float cy = outMat.At<float>(i, 1) * imgH;
|
||||||
|
float w = outMat.At<float>(i, 2) * imgW;
|
||||||
|
float h = outMat.At<float>(i, 3) * imgH;
|
||||||
|
|
||||||
|
float maxScore = 0f;
|
||||||
|
int bestClass = -1;
|
||||||
|
for (int c = 5; c < outMat.Cols; c++)
|
||||||
|
{
|
||||||
|
float score = outMat.At<float>(i, c);
|
||||||
|
if (score > maxScore)
|
||||||
|
{
|
||||||
|
maxScore = score;
|
||||||
|
bestClass = c - 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxScore > _cfg.ConfidenceThreshold)
|
||||||
|
{
|
||||||
|
int x = (int)Math.Max(0, Math.Round(cx - w / 2));
|
||||||
|
int y = (int)Math.Max(0, Math.Round(cy - h / 2));
|
||||||
|
var rect = new Rect(x, y, (int)Math.Round(w), (int)Math.Round(h));
|
||||||
|
boxes.Add(rect);
|
||||||
|
confidences.Add(maxScore);
|
||||||
|
classIds.Add(bestClass);
|
||||||
|
centerXList.Add(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boxes.Count == 0) return Enumerable.Empty<DetectedRegion>();
|
||||||
|
|
||||||
|
CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] indices);
|
||||||
|
|
||||||
|
var results = new List<DetectedRegion>();
|
||||||
|
foreach (var idx in indices)
|
||||||
|
{
|
||||||
|
var b = boxes[idx];
|
||||||
|
double centerX = b.X + b.Width / 2.0;
|
||||||
|
results.Add(new DetectedRegion(b, confidences[idx], classIds[idx], centerX));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string RecognizeDigits(Mat croppedImage)
|
||||||
|
{
|
||||||
|
if (croppedImage is null) throw new ArgumentNullException(nameof(croppedImage));
|
||||||
|
|
||||||
|
using var blob = CvDnn.BlobFromImage(croppedImage, 0.00392, _cfg.RecognitionInputSize, new Scalar(0, 0, 0), true, false);
|
||||||
|
_recognitionNet.SetInput(blob);
|
||||||
|
|
||||||
|
var outNames = GetOutputLayerNames(_recognitionNet);
|
||||||
|
var outsList = new List<Mat>();
|
||||||
|
_recognitionNet.Forward(outsList, outNames);
|
||||||
|
Mat[] outs = outsList.ToArray();
|
||||||
|
|
||||||
|
var boxes = new List<Rect>();
|
||||||
|
var confidences = new List<float>();
|
||||||
|
var classIds = new List<int>();
|
||||||
|
var centerXList = new List<double>();
|
||||||
|
int imgW = croppedImage.Width;
|
||||||
|
int imgH = croppedImage.Height;
|
||||||
|
|
||||||
|
foreach (var outMat in outs)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < outMat.Rows; i++)
|
||||||
|
{
|
||||||
|
float cx = outMat.At<float>(i, 0) * imgW;
|
||||||
|
float cy = outMat.At<float>(i, 1) * imgH;
|
||||||
|
float w = outMat.At<float>(i, 2) * imgW;
|
||||||
|
float h = outMat.At<float>(i, 3) * imgH;
|
||||||
|
float maxScore = 0f;
|
||||||
|
int bestClass = -1;
|
||||||
|
for (int c = 5; c < outMat.Cols; c++)
|
||||||
|
{
|
||||||
|
float score = outMat.At<float>(i, c);
|
||||||
|
if (score > maxScore)
|
||||||
|
{
|
||||||
|
maxScore = score;
|
||||||
|
bestClass = c - 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxScore > _cfg.ConfidenceThreshold)
|
||||||
|
{
|
||||||
|
int x = (int)Math.Max(0, Math.Round(cx - w / 2));
|
||||||
|
int y = (int)Math.Max(0, Math.Round(cy - h / 2));
|
||||||
|
boxes.Add(new Rect(x, y, (int)Math.Round(w), (int)Math.Round(h)));
|
||||||
|
confidences.Add(maxScore);
|
||||||
|
classIds.Add(bestClass);
|
||||||
|
centerXList.Add(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classIds.Count == 0) return string.Empty;
|
||||||
|
|
||||||
|
CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] keep);
|
||||||
|
var ordered = keep.Select(i => new { Idx = i, Cx = centerXList[i], ClassId = classIds[i] })
|
||||||
|
.OrderBy(x => x.Cx)
|
||||||
|
.Select(x => _cfg.NumberClasses[x.ClassId]);
|
||||||
|
return string.Concat(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ImageResult ProcessImage(string filePath)
|
||||||
|
{
|
||||||
|
if (!File.Exists(filePath)) throw new FileNotFoundException("Image not found", filePath);
|
||||||
|
using var image = Cv2.ImRead(filePath);
|
||||||
|
var regions = DetectTextRegions(image).ToArray();
|
||||||
|
var texts = new List<string>();
|
||||||
|
foreach (var r in regions)
|
||||||
|
{
|
||||||
|
using var crop = new Mat(image, r.BoundingBox);
|
||||||
|
var txt = RecognizeDigits(crop);
|
||||||
|
if (!string.IsNullOrEmpty(txt)) texts.Add(txt);
|
||||||
|
}
|
||||||
|
return new ImageResult(Path.GetFileName(filePath), string.Join(",", texts), filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ImageResult> ProcessDirectory(string directoryPath, bool skipTextNegative = false)
|
||||||
|
{
|
||||||
|
// Simple wrapper over async implementation
|
||||||
|
return ProcessDirectoryAsync(directoryPath, skipTextNegative).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<ImageResult>> ProcessDirectoryAsync(string directoryPath, bool skipTextNegative = false, bool recursive = false, IProgress<ProcessingStats>? progress = null, IProgress<ImageResult>? resultProgress = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(directoryPath)) throw new DirectoryNotFoundException(directoryPath);
|
||||||
|
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
|
||||||
|
var files = Directory.EnumerateFiles(directoryPath, "*.*", searchOption)
|
||||||
|
.Where(f => f.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var bag = new ConcurrentBag<ImageResult>();
|
||||||
|
|
||||||
|
var dop = Environment.ProcessorCount;
|
||||||
|
|
||||||
|
// Create a ThreadLocal pair of nets to avoid reloading for every file while still avoiding concurrent use of the same Net
|
||||||
|
// Also keep a ConcurrentBag of created nets so we can dispose them safely from this thread
|
||||||
|
var netsBag = new ConcurrentBag<(Net detNet, Net recNet)>();
|
||||||
|
var threadLocalNets = new ThreadLocal<(Net detNet, Net recNet)>(() =>
|
||||||
|
{
|
||||||
|
var det = CvDnn.ReadNetFromDarknet(_cfg.DetectionCfg, _cfg.DetectionWeights);
|
||||||
|
var rec = CvDnn.ReadNetFromDarknet(_cfg.RecognitionCfg, _cfg.RecognitionWeights);
|
||||||
|
det.SetPreferableBackend(Backend.OPENCV);
|
||||||
|
det.SetPreferableTarget(Target.CPU);
|
||||||
|
rec.SetPreferableBackend(Backend.OPENCV);
|
||||||
|
rec.SetPreferableTarget(Target.CPU);
|
||||||
|
netsBag.Add((det, rec));
|
||||||
|
return (det, rec);
|
||||||
|
});
|
||||||
|
|
||||||
|
var total = files.Length;
|
||||||
|
var processed = 0;
|
||||||
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
|
||||||
|
await Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism = dop, CancellationToken = cancellationToken }, f =>
|
||||||
|
{
|
||||||
|
// Parallel will handle cancellation via the provided token; avoid rethrowing OperationCanceledException from workers
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var filename = Path.GetFileName(f);
|
||||||
|
if (skipTextNegative && filename.StartsWith("tn_", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nets = threadLocalNets.Value;
|
||||||
|
using var image = Cv2.ImRead(f);
|
||||||
|
|
||||||
|
var regions = DetectTextRegions(nets.detNet, image).ToArray();
|
||||||
|
var texts = new List<string>();
|
||||||
|
foreach (var r in regions)
|
||||||
|
{
|
||||||
|
using var crop = new Mat(image, r.BoundingBox);
|
||||||
|
var txt = RecognizeDigits(crop, nets.recNet);
|
||||||
|
if (!string.IsNullOrEmpty(txt)) texts.Add(txt);
|
||||||
|
}
|
||||||
|
var imgRes = new ImageResult(filename, string.Join(",", texts), f);
|
||||||
|
bag.Add(imgRes);
|
||||||
|
resultProgress?.Report(imgRes);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// swallow per-file errors and report empty result
|
||||||
|
bag.Add(new ImageResult(filename, string.Empty, f));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
var proc = Interlocked.Increment(ref processed);
|
||||||
|
if (progress != null)
|
||||||
|
{
|
||||||
|
var elapsed = Math.Max(1, sw.ElapsedMilliseconds);
|
||||||
|
var ips = proc * 1000.0 / elapsed;
|
||||||
|
progress.Report(new ProcessingStats(total, proc, ips));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Cancellation requested — exit gracefully and return partial results
|
||||||
|
}
|
||||||
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// dispose created nets
|
||||||
|
while (netsBag.TryTake(out var pair))
|
||||||
|
{
|
||||||
|
try { pair.detNet.Dispose(); } catch { }
|
||||||
|
try { pair.recNet.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
threadLocalNets.Dispose();
|
||||||
|
|
||||||
|
return bag.OrderBy(b => b.FileName).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overload RecognizeDigits that accepts a Net for worker threads
|
||||||
|
private string RecognizeDigits(Mat croppedImage, Net recognitionNet)
|
||||||
|
{
|
||||||
|
if (croppedImage is null) throw new ArgumentNullException(nameof(croppedImage));
|
||||||
|
|
||||||
|
using var blob = CvDnn.BlobFromImage(croppedImage, 0.00392, _cfg.RecognitionInputSize, new Scalar(0, 0, 0), true, false);
|
||||||
|
recognitionNet.SetInput(blob);
|
||||||
|
|
||||||
|
var outNames = GetOutputLayerNames(recognitionNet);
|
||||||
|
var outsList = new List<Mat>();
|
||||||
|
recognitionNet.Forward(outsList, outNames);
|
||||||
|
Mat[] outs = outsList.ToArray();
|
||||||
|
|
||||||
|
var boxes = new List<Rect>();
|
||||||
|
var confidences = new List<float>();
|
||||||
|
var classIds = new List<int>();
|
||||||
|
var centerXList = new List<double>();
|
||||||
|
int imgW = croppedImage.Width;
|
||||||
|
int imgH = croppedImage.Height;
|
||||||
|
|
||||||
|
foreach (var outMat in outs)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < outMat.Rows; i++)
|
||||||
|
{
|
||||||
|
float cx = outMat.At<float>(i, 0) * imgW;
|
||||||
|
float cy = outMat.At<float>(i, 1) * imgH;
|
||||||
|
float w = outMat.At<float>(i, 2) * imgW;
|
||||||
|
float h = outMat.At<float>(i, 3) * imgH;
|
||||||
|
float maxScore = 0f;
|
||||||
|
int bestClass = -1;
|
||||||
|
for (int c = 5; c < outMat.Cols; c++)
|
||||||
|
{
|
||||||
|
float score = outMat.At<float>(i, c);
|
||||||
|
if (score > maxScore)
|
||||||
|
{
|
||||||
|
maxScore = score;
|
||||||
|
bestClass = c - 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (maxScore > _cfg.ConfidenceThreshold)
|
||||||
|
{
|
||||||
|
int x = (int)Math.Max(0, Math.Round(cx - w / 2));
|
||||||
|
int y = (int)Math.Max(0, Math.Round(cy - h / 2));
|
||||||
|
boxes.Add(new Rect(x, y, (int)Math.Round(w), (int)Math.Round(h)));
|
||||||
|
confidences.Add(maxScore);
|
||||||
|
classIds.Add(bestClass);
|
||||||
|
centerXList.Add(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classIds.Count == 0) return string.Empty;
|
||||||
|
|
||||||
|
CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] keep);
|
||||||
|
var ordered = keep.Select(i => new { Idx = i, Cx = centerXList[i], ClassId = classIds[i] })
|
||||||
|
.OrderBy(x => x.Cx)
|
||||||
|
.Select(x => _cfg.NumberClasses[x.ClassId]);
|
||||||
|
return string.Concat(ordered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/AIFotoONLUS.Core/ProcessingStats.cs
Normal file
4
src/AIFotoONLUS.Core/ProcessingStats.cs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
namespace AIFotoONLUS.Core
|
||||||
|
{
|
||||||
|
public record ProcessingStats(int TotalFiles, int ProcessedFiles, double ImagesPerSecond);
|
||||||
|
}
|
||||||
16
src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj
Normal file
16
src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="NLog" Version="6.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
11
src/AIFotoONLUS.WPF/App.xaml
Normal file
11
src/AIFotoONLUS.WPF/App.xaml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<Application x:Class="AIFotoONLUS.WPF.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:converters="clr-namespace:AIFotoONLUS.WPF.Converters"
|
||||||
|
StartupUri="MainWindow.xaml">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<converters:InverseBoolConverter x:Key="InverseBoolConverter" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
8
src/AIFotoONLUS.WPF/App.xaml.cs
Normal file
8
src/AIFotoONLUS.WPF/App.xaml.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.WPF
|
||||||
|
{
|
||||||
|
public partial class App : System.Windows.Application
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/AIFotoONLUS.WPF/Converters/InverseBoolConverter.cs
Normal file
21
src/AIFotoONLUS.WPF/Converters/InverseBoolConverter.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.WPF.Converters
|
||||||
|
{
|
||||||
|
public class InverseBoolConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is bool b) return !b;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is bool b) return !b;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/AIFotoONLUS.WPF/MainWindow.xaml
Normal file
63
src/AIFotoONLUS.WPF/MainWindow.xaml
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<Window x:Class="AIFotoONLUS.WPF.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="AI Foto ONLUS - Demo" Height="460" Width="700">
|
||||||
|
<Grid Margin="10">
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<StackPanel Orientation="Horizontal" Grid.Row="0" Margin="0,0,0,8">
|
||||||
|
<TextBlock VerticalAlignment="Center" Text="Images directory:" Margin="0,0,8,0"/>
|
||||||
|
<TextBox Text="{Binding ImagesDirectory, UpdateSourceTrigger=PropertyChanged}" Width="420" />
|
||||||
|
<Button Content="Browse" Margin="8,0,0,0" Width="75" Command="{Binding BrowseImagesCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Grid.Row="1" Margin="0,0,0,8">
|
||||||
|
<TextBlock VerticalAlignment="Center" Text="Models directory:" Margin="0,0,8,0"/>
|
||||||
|
<TextBox Text="{Binding ModelsDirectory, UpdateSourceTrigger=PropertyChanged}" Width="340" />
|
||||||
|
<Button Content="Browse" Margin="8,0,0,0" Width="75" Command="{Binding BrowseModelsCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Grid.Row="2" Margin="0,0,0,8">
|
||||||
|
<Button Content="Load Models" Width="100" Command="{Binding LoadModelsCommand}"/>
|
||||||
|
<Button Content="Process" Width="100" Margin="8,0,0,0" Command="{Binding ProcessCommand}" IsEnabled="{Binding IsProcessing, Converter={StaticResource InverseBoolConverter}}" />
|
||||||
|
<Button Content="Stop" Width="100" Margin="8,0,0,0" Command="{Binding CancelCommand}" IsEnabled="{Binding IsProcessing}" />
|
||||||
|
<TextBlock Text="{Binding Status}" Margin="12,4,0,0" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="3">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="2*" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="0">
|
||||||
|
<DataGrid AutoGenerateColumns="False" IsReadOnly="True" ItemsSource="{Binding Results}" Height="300">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Filename" Binding="{Binding FileName}" Width="*"/>
|
||||||
|
<DataGridTextColumn Header="Text" Binding="{Binding Text}" Width="2*"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
|
||||||
|
<ProgressBar Minimum="0" Maximum="100" Value="{Binding ProgressValue, Mode=OneWay}" Height="20" Margin="0,8,0,0" />
|
||||||
|
<TextBlock Text="{Binding Status}" Margin="0,6,0,0" />
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,6,0,0">
|
||||||
|
<TextBlock Text="Processed:" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text=" " />
|
||||||
|
<TextBlock Text="{Binding ProcessedFiles}"/>
|
||||||
|
<TextBlock Text=" / " />
|
||||||
|
<TextBlock Text="{Binding TotalFiles}"/>
|
||||||
|
<TextBlock Text=" " />
|
||||||
|
<TextBlock Text="Imgs/sec:" FontWeight="Bold"/>
|
||||||
|
<TextBlock Text=" " />
|
||||||
|
<TextBlock Text="{Binding ImagesPerSecond, StringFormat=N2}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Preview removed as requested -->
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
23
src/AIFotoONLUS.WPF/MainWindow.xaml.cs
Normal file
23
src/AIFotoONLUS.WPF/MainWindow.xaml.cs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
using AIFotoONLUS.WPF.ViewModels;
|
||||||
|
using System;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.WPF
|
||||||
|
{
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private MainViewModel _vm = new();
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
DataContext = _vm;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnClosed(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnClosed(e);
|
||||||
|
_vm.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/AIFotoONLUS.WPF/Preferences.cs
Normal file
34
src/AIFotoONLUS.WPF/Preferences.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.WPF
|
||||||
|
{
|
||||||
|
internal static class Preferences
|
||||||
|
{
|
||||||
|
private static readonly string PrefFile = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AIFotoONLUS", "prefs.txt");
|
||||||
|
|
||||||
|
public static void Save(string imagesDir, string modelsDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(PrefFile);
|
||||||
|
if (!Directory.Exists(dir)) Directory.CreateDirectory(dir!);
|
||||||
|
File.WriteAllLines(PrefFile, new[] { imagesDir ?? string.Empty, modelsDir ?? string.Empty });
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static (string imagesDir, string modelsDir) Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!File.Exists(PrefFile)) return (string.Empty, string.Empty);
|
||||||
|
var lines = File.ReadAllLines(PrefFile);
|
||||||
|
var img = lines.Length > 0 ? lines[0] : string.Empty;
|
||||||
|
var mdl = lines.Length > 1 ? lines[1] : string.Empty;
|
||||||
|
return (img, mdl);
|
||||||
|
}
|
||||||
|
catch { return (string.Empty, string.Empty); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs
Normal file
198
src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
using AIFotoONLUS.Core;
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.WPF.ViewModels
|
||||||
|
{
|
||||||
|
public class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||||
|
{
|
||||||
|
private ModelConfiguration _cfg = new();
|
||||||
|
private NumberRecognitionEngine? _engine;
|
||||||
|
private string _imagesDirectory = string.Empty;
|
||||||
|
private string _modelsDirectory = "models";
|
||||||
|
private string _status = string.Empty;
|
||||||
|
private double _progressValue;
|
||||||
|
private double _imagesPerSecond;
|
||||||
|
private int _totalFiles;
|
||||||
|
private int _processedFiles;
|
||||||
|
private bool _isProcessing;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
public ObservableCollection<ImageResult> Results { get; } = new();
|
||||||
|
|
||||||
|
public string ImagesDirectory
|
||||||
|
{
|
||||||
|
get => _imagesDirectory;
|
||||||
|
set { _imagesDirectory = value; OnPropertyChanged(nameof(ImagesDirectory)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ModelsDirectory
|
||||||
|
{
|
||||||
|
get => _modelsDirectory;
|
||||||
|
set { _modelsDirectory = value; OnPropertyChanged(nameof(ModelsDirectory)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
private set { _status = value; OnPropertyChanged(nameof(Status)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ProgressValue
|
||||||
|
{
|
||||||
|
get => _progressValue;
|
||||||
|
private set { _progressValue = value; OnPropertyChanged(nameof(ProgressValue)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ImagesPerSecond
|
||||||
|
{
|
||||||
|
get => _imagesPerSecond;
|
||||||
|
private set { _imagesPerSecond = value; OnPropertyChanged(nameof(ImagesPerSecond)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public int TotalFiles
|
||||||
|
{
|
||||||
|
get => _totalFiles;
|
||||||
|
private set { _totalFiles = value; OnPropertyChanged(nameof(TotalFiles)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public int ProcessedFiles
|
||||||
|
{
|
||||||
|
get => _processedFiles;
|
||||||
|
private set { _processedFiles = value; OnPropertyChanged(nameof(ProcessedFiles)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsProcessing
|
||||||
|
{
|
||||||
|
get => _isProcessing;
|
||||||
|
private set { _isProcessing = value; OnPropertyChanged(nameof(IsProcessing)); ProcessCommand.RaiseCanExecuteChanged(); CancelCommand.RaiseCanExecuteChanged(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// No image preview required by user — selection/preview removed
|
||||||
|
|
||||||
|
public RelayCommand BrowseImagesCommand { get; }
|
||||||
|
public RelayCommand BrowseModelsCommand { get; }
|
||||||
|
public RelayCommand LoadModelsCommand { get; }
|
||||||
|
public RelayCommand ProcessCommand { get; }
|
||||||
|
public RelayCommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public MainViewModel()
|
||||||
|
{
|
||||||
|
BrowseImagesCommand = new RelayCommand(_ => BrowseFolder(isModel: false));
|
||||||
|
BrowseModelsCommand = new RelayCommand(_ => BrowseFolder(isModel: true));
|
||||||
|
LoadModelsCommand = new RelayCommand(_ => LoadModels());
|
||||||
|
ProcessCommand = new RelayCommand(async _ => await ProcessAsync(), _ => !IsProcessing);
|
||||||
|
CancelCommand = new RelayCommand(_ => Cancel(), _ => IsProcessing);
|
||||||
|
|
||||||
|
// load prefs
|
||||||
|
var prefs = Preferences.Load();
|
||||||
|
if (!string.IsNullOrWhiteSpace(prefs.imagesDir)) ImagesDirectory = prefs.imagesDir;
|
||||||
|
if (!string.IsNullOrWhiteSpace(prefs.modelsDir)) ModelsDirectory = prefs.modelsDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BrowseFolder(bool isModel)
|
||||||
|
{
|
||||||
|
using var dlg = new System.Windows.Forms.FolderBrowserDialog();
|
||||||
|
var result = dlg.ShowDialog();
|
||||||
|
if (result == System.Windows.Forms.DialogResult.OK)
|
||||||
|
{
|
||||||
|
if (isModel) ModelsDirectory = dlg.SelectedPath; else ImagesDirectory = dlg.SelectedPath;
|
||||||
|
Preferences.Save(ImagesDirectory, ModelsDirectory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadModels()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_engine?.Dispose();
|
||||||
|
_cfg = new ModelConfiguration
|
||||||
|
{
|
||||||
|
DetectionCfg = Path.Combine(ModelsDirectory, "detection.cfg"),
|
||||||
|
DetectionWeights = Path.Combine(ModelsDirectory, "detection.weights"),
|
||||||
|
RecognitionCfg = Path.Combine(ModelsDirectory, "recognition.cfg"),
|
||||||
|
RecognitionWeights = Path.Combine(ModelsDirectory, "recognition.weights"),
|
||||||
|
ConfidenceThreshold = 0.5,
|
||||||
|
NmsThreshold = 0.4
|
||||||
|
};
|
||||||
|
|
||||||
|
_engine = new NumberRecognitionEngine(_cfg);
|
||||||
|
Status = "Models loaded";
|
||||||
|
Preferences.Save(ImagesDirectory, ModelsDirectory);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Windows.MessageBox.Show(ex.Message, "Error loading models", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
Status = "Error loading models";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessAsync()
|
||||||
|
{
|
||||||
|
if (_engine == null)
|
||||||
|
{
|
||||||
|
System.Windows.MessageBox.Show("Load models first.", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(ImagesDirectory) || !Directory.Exists(ImagesDirectory))
|
||||||
|
{
|
||||||
|
System.Windows.MessageBox.Show("Select a valid directory.", "Info", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Status = "Processing...";
|
||||||
|
Results.Clear();
|
||||||
|
ProgressValue = 0;
|
||||||
|
ImagesPerSecond = 0;
|
||||||
|
TotalFiles = 0;
|
||||||
|
ProcessedFiles = 0;
|
||||||
|
IsProcessing = true;
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var progress = new Progress<ProcessingStats>(s =>
|
||||||
|
{
|
||||||
|
TotalFiles = s.TotalFiles;
|
||||||
|
ProcessedFiles = s.ProcessedFiles;
|
||||||
|
ImagesPerSecond = s.ImagesPerSecond;
|
||||||
|
ProgressValue = s.TotalFiles > 0 ? (double)s.ProcessedFiles / s.TotalFiles * 100.0 : 0;
|
||||||
|
});
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resultProgress = new Progress<ImageResult>(r => Results.Add(r));
|
||||||
|
await _engine.ProcessDirectoryAsync(ImagesDirectory, recursive: true, progress: progress, resultProgress: resultProgress, cancellationToken: _cts.Token);
|
||||||
|
Status = $"Done ({Results.Count})";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
System.Windows.MessageBox.Show(ex.Message, "Processing error", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
Status = "Error";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsProcessing = false;
|
||||||
|
_cts?.Dispose();
|
||||||
|
_cts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
if (!_isProcessing) return;
|
||||||
|
_cts?.Cancel();
|
||||||
|
Status = "Cancelling...";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_engine?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
private void OnPropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/AIFotoONLUS.WPF/ViewModels/RelayCommand.cs
Normal file
25
src/AIFotoONLUS.WPF/ViewModels/RelayCommand.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using System;
|
||||||
|
using System.Windows.Input;
|
||||||
|
|
||||||
|
namespace AIFotoONLUS.WPF.ViewModels
|
||||||
|
{
|
||||||
|
public class RelayCommand : ICommand
|
||||||
|
{
|
||||||
|
private readonly Action<object?> _execute;
|
||||||
|
private readonly Predicate<object?>? _canExecute;
|
||||||
|
|
||||||
|
public RelayCommand(Action<object?> execute, Predicate<object?>? canExecute = null)
|
||||||
|
{
|
||||||
|
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
|
||||||
|
_canExecute = canExecute;
|
||||||
|
}
|
||||||
|
|
||||||
|
public event EventHandler? CanExecuteChanged;
|
||||||
|
|
||||||
|
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
|
||||||
|
|
||||||
|
public void Execute(object? parameter) => _execute(parameter);
|
||||||
|
|
||||||
|
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/README.md
Normal file
23
src/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# AIFotoONLUS (.NET) — scaffold
|
||||||
|
|
||||||
|
This workspace contains the initial scaffold for migrating the YOLO-based number recognition Python script to .NET 10.
|
||||||
|
|
||||||
|
Projects:
|
||||||
|
- `AIFotoONLUS.Core` — core inference library (OpenCvSharp)
|
||||||
|
- `AIFotoONLUS.Console` — console batch runner
|
||||||
|
- `AIFotoONLUS.WPF` — simple WPF demo frontend
|
||||||
|
|
||||||
|
Quick build & run (Windows):
|
||||||
|
1. Ensure .NET 10 SDK is installed.
|
||||||
|
2. From `src` run:
|
||||||
|
- `dotnet restore`
|
||||||
|
- `dotnet build`
|
||||||
|
3. Copy the `models/` folder (contains detection/recognition .cfg and .weights) to the `src` output folder or run the apps from repository root so `models/` is accessible.
|
||||||
|
|
||||||
|
Console example:
|
||||||
|
dotnet run --project src\AIFotoONLUS.Console -- -d "..\images\onlus" -c result.csv
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Core engine currently loads Darknet models and scaffolds the detection/recognition pipeline.
|
||||||
|
- Parsing of network outputs (YOLO postprocessing) and recognition logic will be implemented next.
|
||||||
|
- WPF app is a minimal demo (code-behind). We'll replace with MVVM as we iterate.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue