From 769afc08fbeb43d9f898faae973b7b8f7c754b55 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sun, 15 Feb 2026 15:16:56 +0100 Subject: [PATCH] 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. --- AIFotoONLUS.sln | 42 ++ .../AIFotoONLUS.Console.csproj | 14 + src/AIFotoONLUS.Console/Program.cs | 84 ++++ src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj | 13 + src/AIFotoONLUS.Core/DetectedRegion.cs | 8 + src/AIFotoONLUS.Core/ModelConfiguration.cs | 20 + .../NumberRecognitionEngine.cs | 369 ++++++++++++++++++ src/AIFotoONLUS.Core/ProcessingStats.cs | 4 + src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj | 16 + src/AIFotoONLUS.WPF/App.xaml | 11 + src/AIFotoONLUS.WPF/App.xaml.cs | 8 + .../Converters/InverseBoolConverter.cs | 21 + src/AIFotoONLUS.WPF/MainWindow.xaml | 63 +++ src/AIFotoONLUS.WPF/MainWindow.xaml.cs | 23 ++ src/AIFotoONLUS.WPF/Preferences.cs | 34 ++ .../ViewModels/MainViewModel.cs | 198 ++++++++++ .../ViewModels/RelayCommand.cs | 25 ++ src/README.md | 23 ++ 18 files changed, 976 insertions(+) create mode 100644 AIFotoONLUS.sln create mode 100644 src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj create mode 100644 src/AIFotoONLUS.Console/Program.cs create mode 100644 src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj create mode 100644 src/AIFotoONLUS.Core/DetectedRegion.cs create mode 100644 src/AIFotoONLUS.Core/ModelConfiguration.cs create mode 100644 src/AIFotoONLUS.Core/NumberRecognitionEngine.cs create mode 100644 src/AIFotoONLUS.Core/ProcessingStats.cs create mode 100644 src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj create mode 100644 src/AIFotoONLUS.WPF/App.xaml create mode 100644 src/AIFotoONLUS.WPF/App.xaml.cs create mode 100644 src/AIFotoONLUS.WPF/Converters/InverseBoolConverter.cs create mode 100644 src/AIFotoONLUS.WPF/MainWindow.xaml create mode 100644 src/AIFotoONLUS.WPF/MainWindow.xaml.cs create mode 100644 src/AIFotoONLUS.WPF/Preferences.cs create mode 100644 src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs create mode 100644 src/AIFotoONLUS.WPF/ViewModels/RelayCommand.cs create mode 100644 src/README.md 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 @@ + + + + + + + + + + + +