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