From d2206a00cbe97ce29445e2e50ba5f2ae7b97fa3e Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sun, 15 Feb 2026 18:06:03 +0100 Subject: [PATCH] Enhanced logging, diagnostics, and robustness throughout Added NLog-based logging and diagnostics to Console and WPF apps, with programmatic configuration and support for debugger output. Refactored apps to use dependency injection and Microsoft.Extensions.Hosting. Improved output layer extraction and fallback logic in detection/recognition, including objectness-class probability multiplication. Added crop saving for diagnostics. Introduced new CLI options for diagnostics. MainViewModel and MainWindow now use DI and log errors. NumberRecognitionEngine supports logging, crop saving, and robust fallback. Added Python diagnostic script. Improved error handling and argument parsing. --- det.py | 33 +- script.bat | 42 ++- scripts/py_diag.py | 32 ++ .../AIFotoONLUS.Console.csproj | 3 + src/AIFotoONLUS.Console/Program.cs | 121 +++++-- src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj | 1 + src/AIFotoONLUS.Core/ModelConfiguration.cs | 2 + .../NumberRecognitionEngine.cs | 297 ++++++++++++++++-- src/AIFotoONLUS.Core/nlog.config | 9 + src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj | 3 + src/AIFotoONLUS.WPF/App.xaml | 2 +- src/AIFotoONLUS.WPF/App.xaml.cs | 72 ++++- src/AIFotoONLUS.WPF/MainWindow.xaml.cs | 7 +- .../ViewModels/MainViewModel.cs | 27 +- 14 files changed, 572 insertions(+), 79 deletions(-) create mode 100644 scripts/py_diag.py create mode 100644 src/AIFotoONLUS.Core/nlog.config diff --git a/det.py b/det.py index c0ff2da..84fb6d0 100644 --- a/det.py +++ b/det.py @@ -42,7 +42,17 @@ number_classes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] def get_output_layers(net): layer_names = net.getLayerNames() - output_layers = [layer_names[i[0] - 1] for i in net.getUnconnectedOutLayers()] + unconnected = net.getUnconnectedOutLayers() + indices = [] + if isinstance(unconnected, np.ndarray): + indices = unconnected.flatten() + else: + try: + indices = [u[0] if hasattr(u, '__iter__') else u for u in unconnected] + except Exception: + indices = list(unconnected) + + output_layers = [layer_names[int(i) - 1] for i in indices] return output_layers @@ -145,13 +155,15 @@ def recog_number(image): valid_classids = [] valid_centerX = [] - for i in indices: - i = i[0] - box = boxes[i] + if isinstance(i, (list, tuple, np.ndarray)): + idx = int(i[0]) + else: + idx = int(i) + box = boxes[idx] x = box[0] valid_boxes.append(box) - valid_classids.append(class_ids[i]) + valid_classids.append(class_ids[idx]) valid_centerX.append(x) for i in range(0, len(valid_centerX)): @@ -225,20 +237,21 @@ def recog_text(image): text = "" for i in indices: - i = i[0] - box = boxes[i] + if isinstance(i, (list, tuple, np.ndarray)): + idx = int(i[0]) + else: + idx = int(i) + box = boxes[idx] x = box[0] y = box[1] w = box[2] h = box[3] - # if center_Y_list[i] in range(int(image.shape[0] * 0.2), int(image.shape[0] * 0.8)): - plate_img = crop_image(image, round(x), round(y), round(w), round(h)) license_str = recog_number(plate_img) - draw_bounding_box(image, class_ids[i], license_str, round(x), round(y), round(x + w), round(y + h)) + draw_bounding_box(image, class_ids[idx], license_str, round(x), round(y), round(x + w), round(y + h)) print(license_str) diff --git a/script.bat b/script.bat index 1386fe4..294a5ad 100644 --- a/script.bat +++ b/script.bat @@ -1,21 +1,39 @@ @echo off -SET "SCRIPT_PATH=%~1" -SET VENV_DIR="C:\Users\piero\Desktop\AIFotoONLUS\my_venv" +SET "SCRIPT_PATH=%~f1" +SET "VENV_DIR=.\venv" +IF "%SCRIPT_PATH%"=="" ( + echo Usage: %~n0 script.py [args...] + exit /b 1 +) +shift SET "PARAMS=%*" -SET "PARAMS=%PARAMS:*%SCRIPT_PATH%=%" -IF NOT EXIST %VENV_DIR% ( - python -m venv %VENV_DIR% +for %%F in ("%SCRIPT_PATH%") do set "SCRIPT_BASENAME=%%~nxF" + +setlocal enabledelayedexpansion +set "NEWPARAMS=" +for %%A in (%PARAMS%) do ( + if /I not "%%~A"=="%SCRIPT_BASENAME%" ( + set "NEWPARAMS=!NEWPARAMS! %%~A" + ) +) +endlocal & set "PARAMS=%NEWPARAMS%" +for /f "tokens=*" %%A in ("%PARAMS%") do set "PARAMS=%%A" + + +IF NOT EXIST "%VENV_DIR%" ( + py -3.14 -m venv "%VENV_DIR%" ) -call %VENV_DIR%\Scripts\activate.bat +call "%VENV_DIR%\Scripts\activate.bat" -pip install imutils==0.5.4 -pip install numpy==1.20.1 -pip install opencv-python==4.5.1.48 -pip install Pillow==8.1.0 -pip install pytesseract==0.3.7 +python -m pip install --upgrade pip setuptools wheel +pip install --upgrade imutils numpy opencv-python Pillow pytesseract + +echo Running: python "%SCRIPT_PATH%" %PARAMS% +echo SCRIPT_PATH=[%SCRIPT_PATH%] +echo PARAMS=[%PARAMS%] python "%SCRIPT_PATH%" %PARAMS% -call %VENV_DIR%\Scripts\deactivate.bat \ No newline at end of file +call "%VENV_DIR%\Scripts\deactivate.bat" \ No newline at end of file diff --git a/scripts/py_diag.py b/scripts/py_diag.py new file mode 100644 index 0000000..9c0f4ce --- /dev/null +++ b/scripts/py_diag.py @@ -0,0 +1,32 @@ +import cv2 +import numpy as np +import sys + +weights = 'models/detection.weights' +cfg = 'models/detection.cfg' +img = sys.argv[1] + +net = cv2.dnn.readNet(weights, cfg) +blob = cv2.dnn.blobFromImage(cv2.imread(img), 0.00392, (416,416), (0,0,0), True, crop=False) +net.setInput(blob) + +layer_names = net.getLayerNames() +unconnected = net.getUnconnectedOutLayers() +indices = [] +if isinstance(unconnected, np.ndarray): + indices = unconnected.flatten() +else: + try: + indices = [u[0] if hasattr(u, '__iter__') else u for u in unconnected] + except Exception: + indices = list(unconnected) + +output_layers = [layer_names[int(i) - 1] for i in indices] +print('output_layers=', output_layers) + +outs = net.forward(output_layers) +print('outs len:', len(outs)) +for i, om in enumerate(outs): + print(i, 'rows', om.shape[0], 'cols', om.shape[1]) + for r in range(min(3, om.shape[0])): + print(' row', r, om[r,:10]) diff --git a/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj b/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj index c4ac127..61b8b0f 100644 --- a/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj +++ b/src/AIFotoONLUS.Console/AIFotoONLUS.Console.csproj @@ -7,6 +7,9 @@ + + + diff --git a/src/AIFotoONLUS.Console/Program.cs b/src/AIFotoONLUS.Console/Program.cs index 5be5d07..fae0966 100644 --- a/src/AIFotoONLUS.Console/Program.cs +++ b/src/AIFotoONLUS.Console/Program.cs @@ -1,4 +1,12 @@ using AIFotoONLUS.Core; +using OpenCvSharp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NLog; +using NLog.Config; +using NLog.Targets; +using NLog.Extensions.Logging; using System; using System.IO; using System.Linq; @@ -36,31 +44,102 @@ namespace AIFotoONLUS.ConsoleApp } } - if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(csvPath)) + bool diagFirst = args.Contains("--diagfirst"); + var fileIndex = Array.IndexOf(args, "--file"); + + if (fileIndex < 0) { - Console.WriteLine("Missing required arguments."); - return 1; + 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; + } } - if (!Directory.Exists(directory)) + // configure NLog programmatically to write to console + var nlogConfig = new LoggingConfiguration(); + var consoleTarget = new ColoredConsoleTarget("console") { - 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 + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=toString}" }; + nlogConfig.AddTarget(consoleTarget); + nlogConfig.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, consoleTarget); + LogManager.Configuration = nlogConfig; + var host = Host.CreateDefaultBuilder() + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug); + logging.AddNLog(); + }) + .ConfigureServices((ctx, services) => + { + services.AddSingleton(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 + }); + + services.AddTransient(sp => + { + var cfg = sp.GetRequiredService(); + var logger = sp.GetService>(); + return new NumberRecognitionEngine(cfg, logger); + }); + }) + .UseConsoleLifetime() + .Build(); + + using var svcScope = host.Services.CreateScope(); + var services = svcScope.ServiceProvider; try { - using var engine = new NumberRecognitionEngine(cfg); + var cfg = services.GetRequiredService(); + using var engine = services.GetRequiredService(); + var loggerFactory = services.GetService(); + var programLogger = loggerFactory?.CreateLogger("Program"); + // support --file to run a single-file diagnostic via core API + if (fileIndex >= 0 && fileIndex + 1 < args.Length) + { + var singleFile = args[fileIndex + 1]; + programLogger?.LogInformation("Running diagnostic for single file: {file}", singleFile); + var diag = engine.ProcessFileWithDiagnostics(singleFile); + programLogger?.LogInformation("File {file} -> text: {text}", diag.Result.FileName, diag.Result.Text); + foreach (var o in diag.DetectionOutputs) + { + programLogger?.LogDebug("DetectionOutput {name} rows={r} cols={c}", o.Name, o.Rows, o.Cols); + } + return 0; + } + if (diagFirst) + { + var first = Directory.EnumerateFiles(directory, "*.*", SearchOption.TopDirectoryOnly) + .FirstOrDefault(f => f.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase)); + if (first == null) + { + programLogger?.LogWarning("No images found in directory {dir}", directory); + return 1; + } + programLogger?.LogInformation("Diagnostic directory: {dir}", directory); + programLogger?.LogDebug("Directory.Exists: {exists}", Directory.Exists(directory)); + programLogger?.LogDebug("CurrentDirectory: {cwd}", Directory.GetCurrentDirectory()); + programLogger?.LogInformation("Running diagnostic DetectTextRegions on: {file}", first); + var regs = engine.DetectTextRegions(Cv2.ImRead(first)).ToArray(); + programLogger?.LogInformation("Detected regions: {count}", regs.Length); + return 0; + } + var results = engine.ProcessDirectory(directory, textNegative).ToList(); using var sw = new StreamWriter(csvPath, false); @@ -70,15 +149,17 @@ namespace AIFotoONLUS.ConsoleApp sw.WriteLine($"{r.FileName},{r.Text}"); } - Console.WriteLine($"Results written to {csvPath}"); + programLogger?.LogInformation("Results written to {csv}", csvPath); } catch (Exception ex) { - Console.WriteLine($"Error: {ex.Message}"); + var loggerFactory = services.GetService(); + var programLogger = loggerFactory?.CreateLogger("Program"); + programLogger?.LogError(ex, "Error running processing"); 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 index f67ae19..b054056 100644 --- a/src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj +++ b/src/AIFotoONLUS.Core/AIFotoONLUS.Core.csproj @@ -7,6 +7,7 @@ + diff --git a/src/AIFotoONLUS.Core/ModelConfiguration.cs b/src/AIFotoONLUS.Core/ModelConfiguration.cs index dee102a..4c14e7d 100644 --- a/src/AIFotoONLUS.Core/ModelConfiguration.cs +++ b/src/AIFotoONLUS.Core/ModelConfiguration.cs @@ -16,5 +16,7 @@ namespace AIFotoONLUS.Core 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" }; + // When true, recognition crops will be saved to disk for diagnostics. Disabled by default. + public bool EnableCropSaving { get; set; } = false; } } \ No newline at end of file diff --git a/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs b/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs index 03b3e70..1c1a6ca 100644 --- a/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs +++ b/src/AIFotoONLUS.Core/NumberRecognitionEngine.cs @@ -1,6 +1,7 @@ using OpenCvSharp; using OpenCvSharp.Dnn; using System; +using System.Diagnostics; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; @@ -14,15 +15,26 @@ namespace AIFotoONLUS.Core /// NumberRecognitionEngine: loads Darknet models via OpenCvSharp and /// provides methods to detect text regions and recognize digits. /// + using Microsoft.Extensions.Logging; + public class NumberRecognitionEngine : IDisposable { private readonly Net _detectionNet; private readonly Net _recognitionNet; + private readonly object _detectionLock = new(); + private readonly object _recognitionLock = new(); private readonly ModelConfiguration _cfg; + private readonly ILogger? _logger; private bool _disposed; public NumberRecognitionEngine(ModelConfiguration cfg) + : this(cfg, logger: null) { + } + + public NumberRecognitionEngine(ModelConfiguration cfg, ILogger? logger) + { + _logger = logger; _cfg = cfg ?? throw new ArgumentNullException(nameof(cfg)); if (!File.Exists(_cfg.DetectionCfg) || !File.Exists(_cfg.DetectionWeights)) @@ -57,6 +69,12 @@ namespace AIFotoONLUS.Core GC.SuppressFinalize(this); } + private static string SanitizeFileName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) name = name.Replace(c, '_'); + return name; + } + private string[] GetOutputLayerNames(Net net) => net.GetUnconnectedOutLayersNames(); public IEnumerable DetectTextRegions(Mat image) @@ -75,7 +93,42 @@ namespace AIFotoONLUS.Core var outNames = GetOutputLayerNames(detectionNet); var outsList = new List(); detectionNet.Forward(outsList, outNames); + Mat[] outs = outsList.ToArray(); + if (outs.Length == 0) + { + // Try per-output Forward calls as a fallback; use their results for detection + if (outNames != null) + { + var fallback = new List(); + for (int on = 0; on < outNames.Length; on++) + { + try + { + var single = detectionNet.Forward(outNames[on]); + fallback.Add(single); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Fallback Forward failed for {name}", outNames[on]); + } + } + if (fallback.Count > 0) + { + outs = fallback.ToArray(); + } + } + } + + // Diagnostic: dump outs shapes and a sample of values to help debugging + try + { + // diagnostic dumping removed for performance; keep errors only + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error dumping outs"); + } var boxes = new List(); var confidences = new List(); @@ -93,11 +146,14 @@ namespace AIFotoONLUS.Core float w = outMat.At(i, 2) * imgW; float h = outMat.At(i, 3) * imgH; + // YOLO output layout: [cx, cy, w, h, objectness, class1, class2, ...] + float objectness = outMat.At(i, 4); float maxScore = 0f; int bestClass = -1; for (int c = 5; c < outMat.Cols; c++) { - float score = outMat.At(i, c); + float classProb = outMat.At(i, c); + float score = objectness * classProb; // combine objectness and class probability if (score > maxScore) { maxScore = score; @@ -106,20 +162,22 @@ namespace AIFotoONLUS.Core } 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); - } + { + 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(); @@ -132,10 +190,27 @@ namespace AIFotoONLUS.Core return results; } - public string RecognizeDigits(Mat croppedImage) + public string RecognizeDigits(Mat croppedImage, string? context = null) { if (croppedImage is null) throw new ArgumentNullException(nameof(croppedImage)); + // Optionally save crop image for diagnostics when enabled in configuration + if (_cfg.EnableCropSaving) + { + try + { + var cropsDir = Path.Combine("logs", "crops"); + Directory.CreateDirectory(cropsDir); + var fname = $"{(string.IsNullOrEmpty(context) ? "crop" : SanitizeFileName(context))}_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.jpg"; + var full = Path.Combine(cropsDir, fname); + Cv2.ImWrite(full, croppedImage); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed saving crop for diagnostics"); + } + } + using var blob = CvDnn.BlobFromImage(croppedImage, 0.00392, _cfg.RecognitionInputSize, new Scalar(0, 0, 0), true, false); _recognitionNet.SetInput(blob); @@ -144,6 +219,25 @@ namespace AIFotoONLUS.Core _recognitionNet.Forward(outsList, outNames); Mat[] outs = outsList.ToArray(); + // Fallback: try per-output Forward if no mats were returned + if (outs.Length == 0 && outNames != null) + { + var fallback = new List(); + foreach (var n in outNames) + { + try + { + var m = _recognitionNet.Forward(n); + fallback.Add(m); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Recognition fallback forward failed for {name}", n); + } + } + if (fallback.Count > 0) outs = fallback.ToArray(); + } + var boxes = new List(); var confidences = new List(); var classIds = new List(); @@ -159,11 +253,13 @@ namespace AIFotoONLUS.Core float cy = outMat.At(i, 1) * imgH; float w = outMat.At(i, 2) * imgW; float h = outMat.At(i, 3) * imgH; + float objectness = outMat.At(i, 4); float maxScore = 0f; int bestClass = -1; for (int c = 5; c < outMat.Cols; c++) { - float score = outMat.At(i, c); + float classProb = outMat.At(i, c); + float score = objectness * classProb; if (score > maxScore) { maxScore = score; @@ -191,6 +287,49 @@ namespace AIFotoONLUS.Core return string.Concat(ordered); } + public record DetectionOutput(string Name, int Rows, int Cols); + public record DiagnosticResult(ImageResult Result, DetectionOutput[] DetectionOutputs); + + /// + /// Process a single image file and return the recognition result together with + /// detection network forward output shapes for diagnostics. + /// + public DiagnosticResult ProcessFileWithDiagnostics(string filePath) + { + if (!File.Exists(filePath)) throw new FileNotFoundException("Image not found", filePath); + + using var image = Cv2.ImRead(filePath); + + // prepare input blob for detection net + 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); + + // fallback: if no mats produced, try per-name Forward + if (outsList.Count == 0 && outNames != null) + { + foreach (var n in outNames) + { + try + { + var m = _detectionNet.Forward(n); + outsList.Add(m); + } + catch { } + } + } + + var outputs = outsList.Select((m, i) => new DetectionOutput(outNames != null && i < outNames.Length ? outNames[i] : $"out{i}", m.Rows, m.Cols)).ToArray(); + + // run the normal processing to get recognized text + var imgRes = ProcessImage(filePath); + + return new DiagnosticResult(imgRes, outputs); + } + public ImageResult ProcessImage(string filePath) { if (!File.Exists(filePath)) throw new FileNotFoundException("Image not found", filePath); @@ -200,10 +339,16 @@ namespace AIFotoONLUS.Core foreach (var r in regions) { using var crop = new Mat(image, r.BoundingBox); - var txt = RecognizeDigits(crop); + var ctx = $"{Path.GetFileName(filePath)}_{r.BoundingBox.X}_{r.BoundingBox.Y}_{r.BoundingBox.Width}x{r.BoundingBox.Height}"; + var txt = RecognizeDigits(crop, ctx); if (!string.IsNullOrEmpty(txt)) texts.Add(txt); } - return new ImageResult(Path.GetFileName(filePath), string.Join(",", texts), filePath); + var result = new ImageResult(Path.GetFileName(filePath), string.Join(",", texts), filePath); + if (!string.IsNullOrEmpty(result.Text)) + _logger?.LogInformation("Processed image {file} -> {text}", result.FileName, result.Text); + else + _logger?.LogDebug("Processed image {file} -> (no text)", result.FileName); + return result; } public IEnumerable ProcessDirectory(string directoryPath, bool skipTextNegative = false) @@ -223,9 +368,11 @@ namespace AIFotoONLUS.Core var bag = new ConcurrentBag(); var dop = Environment.ProcessorCount; + var total = files.Length; + var processed = 0; + var sw = System.Diagnostics.Stopwatch.StartNew(); - // 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 + // Per-thread nets (each worker gets its own pair) to allow parallel forward calls var netsBag = new ConcurrentBag<(Net detNet, Net recNet)>(); var threadLocalNets = new ThreadLocal<(Net detNet, Net recNet)>(() => { @@ -239,17 +386,12 @@ namespace AIFotoONLUS.Core 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)) @@ -262,19 +404,74 @@ namespace AIFotoONLUS.Core var regions = DetectTextRegions(nets.detNet, image).ToArray(); var texts = new List(); + // minimal logging for performance foreach (var r in regions) { using var crop = new Mat(image, r.BoundingBox); - var txt = RecognizeDigits(crop, nets.recNet); + var ctx = $"{filename}_{r.BoundingBox.X}_{r.BoundingBox.Y}_{r.BoundingBox.Width}x{r.BoundingBox.Height}"; + var txt = RecognizeDigits(crop, nets.recNet, ctx); + // minimal logging for performance + // Fallback: if empty, try a fresh net (diagnostic) + if (string.IsNullOrEmpty(txt)) + { + try + { + using var tempRec = CvDnn.ReadNetFromDarknet(_cfg.RecognitionCfg, _cfg.RecognitionWeights); + tempRec.SetPreferableBackend(Backend.OPENCV); + tempRec.SetPreferableTarget(Target.CPU); + var alt = RecognizeDigits(crop, tempRec, ctx); + if (!string.IsNullOrEmpty(alt)) txt = alt; + } + catch { } + } if (!string.IsNullOrEmpty(txt)) texts.Add(txt); } + + // If no text was recognized with per-thread nets, try one more time using the shared nets under a lock + if (texts.Count == 0) + { + try + { + DetectedRegion[] sharedRegions; + lock (_detectionLock) + { + sharedRegions = DetectTextRegions(image).ToArray(); + } + var sharedTexts = new List(); + foreach (var r2 in sharedRegions) + { + using var crop2 = new Mat(image, r2.BoundingBox); + var ctx2 = $"{filename}_{r2.BoundingBox.X}_{r2.BoundingBox.Y}_{r2.BoundingBox.Width}x{r2.BoundingBox.Height}"; + string txt2; + lock (_recognitionLock) + { + txt2 = RecognizeDigits(crop2, ctx2); + } + if (!string.IsNullOrEmpty(txt2)) + { + sharedTexts.Add(txt2); + } + } + if (sharedTexts.Count > 0) + { + texts = sharedTexts; + } + } + catch + { + // ignore fallback errors + } + } + var imgRes = new ImageResult(filename, string.Join(",", texts), f); + if (!string.IsNullOrEmpty(imgRes.Text)) + _logger?.LogInformation("[{file}] Result: {text}", imgRes.FileName, imgRes.Text); bag.Add(imgRes); resultProgress?.Report(imgRes); } - catch + catch (Exception ex) { - // swallow per-file errors and report empty result + _logger?.LogError(ex, "Error processing image {file}", filename); bag.Add(new ImageResult(filename, string.Empty, f)); } finally @@ -307,10 +504,27 @@ namespace AIFotoONLUS.Core } // Overload RecognizeDigits that accepts a Net for worker threads - private string RecognizeDigits(Mat croppedImage, Net recognitionNet) + private string RecognizeDigits(Mat croppedImage, Net recognitionNet, string? context = null) { if (croppedImage is null) throw new ArgumentNullException(nameof(croppedImage)); + // Optionally save crop image for diagnostics when enabled in configuration + if (_cfg.EnableCropSaving) + { + try + { + var cropsDir = Path.Combine("logs", "crops"); + Directory.CreateDirectory(cropsDir); + var fname = $"{(string.IsNullOrEmpty(context) ? "crop" : SanitizeFileName(context))}_{DateTime.UtcNow:yyyyMMdd_HHmmss_fff}_{Guid.NewGuid():N}.jpg"; + var full = Path.Combine(cropsDir, fname); + Cv2.ImWrite(full, croppedImage); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed saving crop for diagnostics"); + } + } + using var blob = CvDnn.BlobFromImage(croppedImage, 0.00392, _cfg.RecognitionInputSize, new Scalar(0, 0, 0), true, false); recognitionNet.SetInput(blob); @@ -326,6 +540,35 @@ namespace AIFotoONLUS.Core int imgW = croppedImage.Width; int imgH = croppedImage.Height; + // Diagnostic: if no outs, try per-output Forward + if (outs.Length == 0 && outNames != null) + { + var fallback = new List(); + foreach (var n in outNames) + { + try + { + var m = recognitionNet.Forward(n); + fallback.Add(m); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Recognition fallback forward failed for {name}", n); + } + } + if (fallback.Count > 0) outs = fallback.ToArray(); + } + + // Diagnostic: dump outs shapes and a sample of values to help debugging + try + { + // diagnostic dumping removed for performance; keep errors only + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error dumping recognition outs"); + } + foreach (var outMat in outs) { for (int i = 0; i < outMat.Rows; i++) @@ -334,11 +577,13 @@ namespace AIFotoONLUS.Core float cy = outMat.At(i, 1) * imgH; float w = outMat.At(i, 2) * imgW; float h = outMat.At(i, 3) * imgH; + float objectness = outMat.At(i, 4); float maxScore = 0f; int bestClass = -1; for (int c = 5; c < outMat.Cols; c++) { - float score = outMat.At(i, c); + float classProb = outMat.At(i, c); + float score = objectness * classProb; if (score > maxScore) { maxScore = score; diff --git a/src/AIFotoONLUS.Core/nlog.config b/src/AIFotoONLUS.Core/nlog.config new file mode 100644 index 0000000..e5e698c --- /dev/null +++ b/src/AIFotoONLUS.Core/nlog.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj b/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj index fb95110..9b6d1e4 100644 --- a/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj +++ b/src/AIFotoONLUS.WPF/AIFotoONLUS.WPF.csproj @@ -9,6 +9,9 @@ + + + diff --git a/src/AIFotoONLUS.WPF/App.xaml b/src/AIFotoONLUS.WPF/App.xaml index 26dc677..4520257 100644 --- a/src/AIFotoONLUS.WPF/App.xaml +++ b/src/AIFotoONLUS.WPF/App.xaml @@ -2,7 +2,7 @@ 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"> + > diff --git a/src/AIFotoONLUS.WPF/App.xaml.cs b/src/AIFotoONLUS.WPF/App.xaml.cs index 4596187..057639d 100644 --- a/src/AIFotoONLUS.WPF/App.xaml.cs +++ b/src/AIFotoONLUS.WPF/App.xaml.cs @@ -1,8 +1,78 @@ +using System; using System.Windows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NLog; +using NLog.Config; +using NLog.Targets; +using NLog.Extensions.Logging; +using AIFotoONLUS.WPF.ViewModels; namespace AIFotoONLUS.WPF { public partial class App : System.Windows.Application { + private IHost? _host; + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + // configure NLog programmatically to write to console + var nlogConfig = new LoggingConfiguration(); + var consoleTarget = new ColoredConsoleTarget("console") + { + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=toString}" + }; + var debugTarget = new DebuggerTarget("debug") + { + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=toString}" + }; + nlogConfig.AddTarget(consoleTarget); + nlogConfig.AddTarget(debugTarget); + nlogConfig.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, consoleTarget); + nlogConfig.AddRule(NLog.LogLevel.Debug, NLog.LogLevel.Fatal, debugTarget); + LogManager.Configuration = nlogConfig; + // ensure existing loggers pick up the new configuration + LogManager.ReconfigExistingLoggers(); + + _host = Host.CreateDefaultBuilder() + .ConfigureLogging(logging => + { + logging.ClearProviders(); + // write also to the VS Output (Debug) provider + logging.AddDebug(); + logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug); + logging.AddNLog(); + }) + .ConfigureServices((context, services) => + { + services.AddSingleton(); + services.AddSingleton(); + }) + .Build(); + + await _host.StartAsync().ConfigureAwait(false); + + // emit a startup message to verify logging pipeline + var startupLogger = _host.Services.GetRequiredService>(); + startupLogger.LogInformation("Host started and logging configured"); + + var main = _host.Services.GetRequiredService(); + main.Show(); + } + + protected override async void OnExit(ExitEventArgs e) + { + if (_host != null) + { + await _host.StopAsync().ConfigureAwait(false); + _host.Dispose(); + } + // flush NLog to ensure all messages are written before exit + try { LogManager.Flush(); } catch { } + base.OnExit(e); + } } -} \ No newline at end of file +} diff --git a/src/AIFotoONLUS.WPF/MainWindow.xaml.cs b/src/AIFotoONLUS.WPF/MainWindow.xaml.cs index bda106a..35f8c0c 100644 --- a/src/AIFotoONLUS.WPF/MainWindow.xaml.cs +++ b/src/AIFotoONLUS.WPF/MainWindow.xaml.cs @@ -6,10 +6,11 @@ namespace AIFotoONLUS.WPF { public partial class MainWindow : Window { - private MainViewModel _vm = new(); + private readonly MainViewModel _vm; - public MainWindow() + public MainWindow(MainViewModel vm) { + _vm = vm ?? throw new ArgumentNullException(nameof(vm)); InitializeComponent(); DataContext = _vm; } @@ -17,7 +18,7 @@ namespace AIFotoONLUS.WPF protected override void OnClosed(EventArgs e) { base.OnClosed(e); - _vm.Dispose(); + (_vm as IDisposable)?.Dispose(); } } } diff --git a/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs b/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs index c0752f2..88d993d 100644 --- a/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs +++ b/src/AIFotoONLUS.WPF/ViewModels/MainViewModel.cs @@ -1,4 +1,5 @@ using AIFotoONLUS.Core; +using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Collections.ObjectModel; @@ -82,8 +83,14 @@ namespace AIFotoONLUS.WPF.ViewModels public RelayCommand ProcessCommand { get; } public RelayCommand CancelCommand { get; } - public MainViewModel() + private readonly ILogger? _logger; + private readonly ILoggerFactory? _loggerFactory; + + public MainViewModel(ILogger? logger, ILoggerFactory? loggerFactory) { + _logger = logger; + _loggerFactory = loggerFactory; + BrowseImagesCommand = new RelayCommand(_ => BrowseFolder(isModel: false)); BrowseModelsCommand = new RelayCommand(_ => BrowseFolder(isModel: true)); LoadModelsCommand = new RelayCommand(_ => LoadModels()); @@ -91,9 +98,16 @@ namespace AIFotoONLUS.WPF.ViewModels 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; + try + { + var prefs = Preferences.Load(); + if (!string.IsNullOrWhiteSpace(prefs.imagesDir)) ImagesDirectory = prefs.imagesDir; + if (!string.IsNullOrWhiteSpace(prefs.modelsDir)) ModelsDirectory = prefs.modelsDir; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to load preferences"); + } } private void BrowseFolder(bool isModel) @@ -121,13 +135,14 @@ namespace AIFotoONLUS.WPF.ViewModels ConfidenceThreshold = 0.5, NmsThreshold = 0.4 }; - - _engine = new NumberRecognitionEngine(_cfg); + var logger = _loggerFactory?.CreateLogger(); + _engine = new NumberRecognitionEngine(_cfg, logger); Status = "Models loaded"; Preferences.Save(ImagesDirectory, ModelsDirectory); } catch (Exception ex) { + _logger?.LogError(ex, "Error loading models"); System.Windows.MessageBox.Show(ex.Message, "Error loading models", MessageBoxButton.OK, MessageBoxImage.Error); Status = "Error loading models"; }