diff --git a/AIFotoONLUS.sln b/AIFotoONLUS.sln
new file mode 100644
index 0000000..e9c6332
--- /dev/null
+++ b/AIFotoONLUS.sln
@@ -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
diff --git a/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj b/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj
new file mode 100644
index 0000000..c4ac127
--- /dev/null
+++ b/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj
@@ -0,0 +1,14 @@
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AIFotoONLUS.Console/Program.cs b/src/AIFotoONLUS.Console/Program.cs
new file mode 100644
index 0000000..5be5d07
--- /dev/null
+++ b/src/AIFotoONLUS.Console/Program.cs
@@ -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 -c [-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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj b/src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj
new file mode 100644
index 0000000..f67ae19
--- /dev/null
+++ b/src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj
@@ -0,0 +1,13 @@
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AIFotoONLUS.Core/DetectedRegion.cs b/src/AIFotoONLUS.Core/DetectedRegion.cs
new file mode 100644
index 0000000..38fb2b7
--- /dev/null
+++ b/src/AIFotoONLUS.Core/DetectedRegion.cs
@@ -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);
+}
\ No newline at end of file
diff --git a/src/AIFotoONLUS.Core/ModelConfiguration.cs b/src/AIFotoONLUS.Core/ModelConfiguration.cs
new file mode 100644
index 0000000..dee102a
--- /dev/null
+++ b/src/AIFotoONLUS.Core/ModelConfiguration.cs
@@ -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" };
+ }
+}
\ No newline at end of file
diff --git a/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs b/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs
new file mode 100644
index 0000000..03b3e70
--- /dev/null
+++ b/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs
@@ -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
+{
+ ///
+ /// NumberRecognitionEngine: loads Darknet models via OpenCvSharp and
+ /// provides methods to detect text regions and recognize digits.
+ ///
+ 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 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 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();
+ detectionNet.Forward(outsList, outNames);
+ Mat[] outs = outsList.ToArray();
+
+ var boxes = new List();
+ var confidences = new List();
+ var classIds = new List();
+ var centerXList = new List();
+
+ 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(i, 0) * imgW;
+ float cy = outMat.At(i, 1) * imgH;
+ float w = outMat.At(i, 2) * imgW;
+ float h = outMat.At(i, 3) * imgH;
+
+ float maxScore = 0f;
+ int bestClass = -1;
+ for (int c = 5; c < outMat.Cols; c++)
+ {
+ float score = outMat.At(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();
+
+ CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] indices);
+
+ var results = new List();
+ 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();
+ _recognitionNet.Forward(outsList, outNames);
+ Mat[] outs = outsList.ToArray();
+
+ var boxes = new List();
+ var confidences = new List();
+ var classIds = new List();
+ var centerXList = new List();
+ 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(i, 0) * imgW;
+ float cy = outMat.At(i, 1) * imgH;
+ float w = outMat.At(i, 2) * imgW;
+ float h = outMat.At(i, 3) * imgH;
+ float maxScore = 0f;
+ int bestClass = -1;
+ for (int c = 5; c < outMat.Cols; c++)
+ {
+ float score = outMat.At(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();
+ 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 ProcessDirectory(string directoryPath, bool skipTextNegative = false)
+ {
+ // Simple wrapper over async implementation
+ return ProcessDirectoryAsync(directoryPath, skipTextNegative).GetAwaiter().GetResult();
+ }
+
+ public async Task> ProcessDirectoryAsync(string directoryPath, bool skipTextNegative = false, bool recursive = false, IProgress? progress = null, IProgress? 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();
+
+ 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();
+ 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();
+ recognitionNet.Forward(outsList, outNames);
+ Mat[] outs = outsList.ToArray();
+
+ var boxes = new List();
+ var confidences = new List();
+ var classIds = new List();
+ var centerXList = new List();
+ 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(i, 0) * imgW;
+ float cy = outMat.At(i, 1) * imgH;
+ float w = outMat.At(i, 2) * imgW;
+ float h = outMat.At(i, 3) * imgH;
+ float maxScore = 0f;
+ int bestClass = -1;
+ for (int c = 5; c < outMat.Cols; c++)
+ {
+ float score = outMat.At(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);
+ }
+ }
+}
diff --git a/src/AIFotoONLUS.Core/ProcessingStats.cs b/src/AIFotoONLUS.Core/ProcessingStats.cs
new file mode 100644
index 0000000..fe5aba3
--- /dev/null
+++ b/src/AIFotoONLUS.Core/ProcessingStats.cs
@@ -0,0 +1,4 @@
+namespace AIFotoONLUS.Core
+{
+ public record ProcessingStats(int TotalFiles, int ProcessedFiles, double ImagesPerSecond);
+}
diff --git a/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj b/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj
new file mode 100644
index 0000000..fb95110
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj
@@ -0,0 +1,16 @@
+
+
+ WinExe
+ net10.0-windows
+ true
+ true
+ enable
+ enable
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AIFotoONLUS.WPF/App.xaml b/src/AIFotoONLUS.WPF/App.xaml
new file mode 100644
index 0000000..26dc677
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/App.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AIFotoONLUS.WPF/App.xaml.cs b/src/AIFotoONLUS.WPF/App.xaml.cs
new file mode 100644
index 0000000..4596187
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/App.xaml.cs
@@ -0,0 +1,8 @@
+using System.Windows;
+
+namespace AIFotoONLUS.WPF
+{
+ public partial class App : System.Windows.Application
+ {
+ }
+}
\ No newline at end of file
diff --git a/src/AIFotoONLUS.WPF/Converters/InverseBoolConverter.cs b/src/AIFotoONLUS.WPF/Converters/InverseBoolConverter.cs
new file mode 100644
index 0000000..19617fe
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/Converters/InverseBoolConverter.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/AIFotoONLUS.WPF/MainWindow.xaml b/src/AIFotoONLUS.WPF/MainWindow.xaml
new file mode 100644
index 0000000..cb823eb
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/MainWindow.xaml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AIFotoONLUS.WPF/MainWindow.xaml.cs b/src/AIFotoONLUS.WPF/MainWindow.xaml.cs
new file mode 100644
index 0000000..bda106a
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/MainWindow.xaml.cs
@@ -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();
+ }
+ }
+}
diff --git a/src/AIFotoONLUS.WPF/Preferences.cs b/src/AIFotoONLUS.WPF/Preferences.cs
new file mode 100644
index 0000000..64135a8
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/Preferences.cs
@@ -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); }
+ }
+ }
+}
diff --git a/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs b/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..c0752f2
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs
@@ -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 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(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(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));
+ }
+}
diff --git a/src/AIFotoONLUS.WPF/ViewModels/RelayCommand.cs b/src/AIFotoONLUS.WPF/ViewModels/RelayCommand.cs
new file mode 100644
index 0000000..1a5feaa
--- /dev/null
+++ b/src/AIFotoONLUS.WPF/ViewModels/RelayCommand.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Windows.Input;
+
+namespace AIFotoONLUS.WPF.ViewModels
+{
+ public class RelayCommand : ICommand
+ {
+ private readonly Action