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"; }