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.
This commit is contained in:
MaddoScientisto 2026-02-15 18:06:03 +01:00
commit d2206a00cb
14 changed files with 571 additions and 78 deletions

33
det.py
View file

@ -42,7 +42,17 @@ number_classes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
def get_output_layers(net): def get_output_layers(net):
layer_names = net.getLayerNames() 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 return output_layers
@ -145,13 +155,15 @@ def recog_number(image):
valid_classids = [] valid_classids = []
valid_centerX = [] valid_centerX = []
for i in indices: for i in indices:
i = i[0] if isinstance(i, (list, tuple, np.ndarray)):
box = boxes[i] idx = int(i[0])
else:
idx = int(i)
box = boxes[idx]
x = box[0] x = box[0]
valid_boxes.append(box) valid_boxes.append(box)
valid_classids.append(class_ids[i]) valid_classids.append(class_ids[idx])
valid_centerX.append(x) valid_centerX.append(x)
for i in range(0, len(valid_centerX)): for i in range(0, len(valid_centerX)):
@ -225,20 +237,21 @@ def recog_text(image):
text = "" text = ""
for i in indices: for i in indices:
i = i[0] if isinstance(i, (list, tuple, np.ndarray)):
box = boxes[i] idx = int(i[0])
else:
idx = int(i)
box = boxes[idx]
x = box[0] x = box[0]
y = box[1] y = box[1]
w = box[2] w = box[2]
h = box[3] 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)) plate_img = crop_image(image, round(x), round(y), round(w), round(h))
license_str = recog_number(plate_img) 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) print(license_str)

View file

@ -1,21 +1,39 @@
@echo off @echo off
SET "SCRIPT_PATH=%~1" SET "SCRIPT_PATH=%~f1"
SET VENV_DIR="C:\Users\piero\Desktop\AIFotoONLUS\my_venv" SET "VENV_DIR=.\venv"
IF "%SCRIPT_PATH%"=="" (
echo Usage: %~n0 script.py [args...]
exit /b 1
)
shift
SET "PARAMS=%*" SET "PARAMS=%*"
SET "PARAMS=%PARAMS:*%SCRIPT_PATH%=%"
IF NOT EXIST %VENV_DIR% ( for %%F in ("%SCRIPT_PATH%") do set "SCRIPT_BASENAME=%%~nxF"
python -m venv %VENV_DIR%
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 python -m pip install --upgrade pip setuptools wheel
pip install numpy==1.20.1 pip install --upgrade imutils numpy opencv-python Pillow pytesseract
pip install opencv-python==4.5.1.48
pip install Pillow==8.1.0 echo Running: python "%SCRIPT_PATH%" %PARAMS%
pip install pytesseract==0.3.7 echo SCRIPT_PATH=[%SCRIPT_PATH%]
echo PARAMS=[%PARAMS%]
python "%SCRIPT_PATH%" %PARAMS% python "%SCRIPT_PATH%" %PARAMS%
call %VENV_DIR%\Scripts\deactivate.bat call "%VENV_DIR%\Scripts\deactivate.bat"

32
scripts/py_diag.py Normal file
View file

@ -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])

View file

@ -7,6 +7,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NLog" Version="6.1.0" /> <PackageReference Include="NLog" Version="6.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="6.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" /> <ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" />

View file

@ -1,4 +1,12 @@
using AIFotoONLUS.Core; 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;
using System.IO; using System.IO;
using System.Linq; 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."); if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(csvPath))
return 1; {
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}"); Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=toString}"
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
}; };
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<NumberRecognitionEngine>(sp =>
{
var cfg = sp.GetRequiredService<ModelConfiguration>();
var logger = sp.GetService<ILogger<NumberRecognitionEngine>>();
return new NumberRecognitionEngine(cfg, logger);
});
})
.UseConsoleLifetime()
.Build();
using var svcScope = host.Services.CreateScope();
var services = svcScope.ServiceProvider;
try try
{ {
using var engine = new NumberRecognitionEngine(cfg); var cfg = services.GetRequiredService<ModelConfiguration>();
using var engine = services.GetRequiredService<NumberRecognitionEngine>();
var loggerFactory = services.GetService<ILoggerFactory>();
var programLogger = loggerFactory?.CreateLogger("Program");
// support --file <path> 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(); var results = engine.ProcessDirectory(directory, textNegative).ToList();
using var sw = new StreamWriter(csvPath, false); using var sw = new StreamWriter(csvPath, false);
@ -70,15 +149,17 @@ namespace AIFotoONLUS.ConsoleApp
sw.WriteLine($"{r.FileName},{r.Text}"); sw.WriteLine($"{r.FileName},{r.Text}");
} }
Console.WriteLine($"Results written to {csvPath}"); programLogger?.LogInformation("Results written to {csv}", csvPath);
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Error: {ex.Message}"); var loggerFactory = services.GetService<ILoggerFactory>();
var programLogger = loggerFactory?.CreateLogger("Program");
programLogger?.LogError(ex, "Error running processing");
return 2; return 2;
} }
return 0; return 0;
} }
} }
} }

View file

@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="OpenCvSharp4" Version="4.13.0.20260214" /> <PackageReference Include="OpenCvSharp4" Version="4.13.0.20260214" />
<PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260214" /> <PackageReference Include="OpenCvSharp4.runtime.win" Version="4.13.0.20260214" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
</ItemGroup> </ItemGroup>

View file

@ -16,5 +16,7 @@ namespace AIFotoONLUS.Core
public Size RecognitionInputSize { get; set; } = new Size(140, 120); 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" }; 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;
} }
} }

View file

@ -1,6 +1,7 @@
using OpenCvSharp; using OpenCvSharp;
using OpenCvSharp.Dnn; using OpenCvSharp.Dnn;
using System; using System;
using System.Diagnostics;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
@ -14,15 +15,26 @@ namespace AIFotoONLUS.Core
/// NumberRecognitionEngine: loads Darknet models via OpenCvSharp and /// NumberRecognitionEngine: loads Darknet models via OpenCvSharp and
/// provides methods to detect text regions and recognize digits. /// provides methods to detect text regions and recognize digits.
/// </summary> /// </summary>
using Microsoft.Extensions.Logging;
public class NumberRecognitionEngine : IDisposable public class NumberRecognitionEngine : IDisposable
{ {
private readonly Net _detectionNet; private readonly Net _detectionNet;
private readonly Net _recognitionNet; private readonly Net _recognitionNet;
private readonly object _detectionLock = new();
private readonly object _recognitionLock = new();
private readonly ModelConfiguration _cfg; private readonly ModelConfiguration _cfg;
private readonly ILogger? _logger;
private bool _disposed; private bool _disposed;
public NumberRecognitionEngine(ModelConfiguration cfg) public NumberRecognitionEngine(ModelConfiguration cfg)
: this(cfg, logger: null)
{ {
}
public NumberRecognitionEngine(ModelConfiguration cfg, ILogger? logger)
{
_logger = logger;
_cfg = cfg ?? throw new ArgumentNullException(nameof(cfg)); _cfg = cfg ?? throw new ArgumentNullException(nameof(cfg));
if (!File.Exists(_cfg.DetectionCfg) || !File.Exists(_cfg.DetectionWeights)) if (!File.Exists(_cfg.DetectionCfg) || !File.Exists(_cfg.DetectionWeights))
@ -57,6 +69,12 @@ namespace AIFotoONLUS.Core
GC.SuppressFinalize(this); 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(); private string[] GetOutputLayerNames(Net net) => net.GetUnconnectedOutLayersNames();
public IEnumerable<DetectedRegion> DetectTextRegions(Mat image) public IEnumerable<DetectedRegion> DetectTextRegions(Mat image)
@ -75,7 +93,42 @@ namespace AIFotoONLUS.Core
var outNames = GetOutputLayerNames(detectionNet); var outNames = GetOutputLayerNames(detectionNet);
var outsList = new List<Mat>(); var outsList = new List<Mat>();
detectionNet.Forward(outsList, outNames); detectionNet.Forward(outsList, outNames);
Mat[] outs = outsList.ToArray(); 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<Mat>();
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<Rect>(); var boxes = new List<Rect>();
var confidences = new List<float>(); var confidences = new List<float>();
@ -93,11 +146,14 @@ namespace AIFotoONLUS.Core
float w = outMat.At<float>(i, 2) * imgW; float w = outMat.At<float>(i, 2) * imgW;
float h = outMat.At<float>(i, 3) * imgH; float h = outMat.At<float>(i, 3) * imgH;
// YOLO output layout: [cx, cy, w, h, objectness, class1, class2, ...]
float objectness = outMat.At<float>(i, 4);
float maxScore = 0f; float maxScore = 0f;
int bestClass = -1; int bestClass = -1;
for (int c = 5; c < outMat.Cols; c++) for (int c = 5; c < outMat.Cols; c++)
{ {
float score = outMat.At<float>(i, c); float classProb = outMat.At<float>(i, c);
float score = objectness * classProb; // combine objectness and class probability
if (score > maxScore) if (score > maxScore)
{ {
maxScore = score; maxScore = score;
@ -106,20 +162,22 @@ namespace AIFotoONLUS.Core
} }
if (maxScore > _cfg.ConfidenceThreshold) if (maxScore > _cfg.ConfidenceThreshold)
{ {
int x = (int)Math.Max(0, Math.Round(cx - w / 2)); int x = (int)Math.Max(0, Math.Round(cx - w / 2));
int y = (int)Math.Max(0, Math.Round(cy - h / 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)); var rect = new Rect(x, y, (int)Math.Round(w), (int)Math.Round(h));
boxes.Add(rect); boxes.Add(rect);
confidences.Add(maxScore); confidences.Add(maxScore);
classIds.Add(bestClass); classIds.Add(bestClass);
centerXList.Add(cx); centerXList.Add(cx);
} }
} }
} }
if (boxes.Count == 0) return Enumerable.Empty<DetectedRegion>(); if (boxes.Count == 0) return Enumerable.Empty<DetectedRegion>();
CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] indices); CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] indices);
var results = new List<DetectedRegion>(); var results = new List<DetectedRegion>();
@ -132,10 +190,27 @@ namespace AIFotoONLUS.Core
return results; return results;
} }
public string RecognizeDigits(Mat croppedImage) public string RecognizeDigits(Mat croppedImage, string? context = null)
{ {
if (croppedImage is null) throw new ArgumentNullException(nameof(croppedImage)); 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); using var blob = CvDnn.BlobFromImage(croppedImage, 0.00392, _cfg.RecognitionInputSize, new Scalar(0, 0, 0), true, false);
_recognitionNet.SetInput(blob); _recognitionNet.SetInput(blob);
@ -144,6 +219,25 @@ namespace AIFotoONLUS.Core
_recognitionNet.Forward(outsList, outNames); _recognitionNet.Forward(outsList, outNames);
Mat[] outs = outsList.ToArray(); Mat[] outs = outsList.ToArray();
// Fallback: try per-output Forward if no mats were returned
if (outs.Length == 0 && outNames != null)
{
var fallback = new List<Mat>();
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<Rect>(); var boxes = new List<Rect>();
var confidences = new List<float>(); var confidences = new List<float>();
var classIds = new List<int>(); var classIds = new List<int>();
@ -159,11 +253,13 @@ namespace AIFotoONLUS.Core
float cy = outMat.At<float>(i, 1) * imgH; float cy = outMat.At<float>(i, 1) * imgH;
float w = outMat.At<float>(i, 2) * imgW; float w = outMat.At<float>(i, 2) * imgW;
float h = outMat.At<float>(i, 3) * imgH; float h = outMat.At<float>(i, 3) * imgH;
float objectness = outMat.At<float>(i, 4);
float maxScore = 0f; float maxScore = 0f;
int bestClass = -1; int bestClass = -1;
for (int c = 5; c < outMat.Cols; c++) for (int c = 5; c < outMat.Cols; c++)
{ {
float score = outMat.At<float>(i, c); float classProb = outMat.At<float>(i, c);
float score = objectness * classProb;
if (score > maxScore) if (score > maxScore)
{ {
maxScore = score; maxScore = score;
@ -191,6 +287,49 @@ namespace AIFotoONLUS.Core
return string.Concat(ordered); return string.Concat(ordered);
} }
public record DetectionOutput(string Name, int Rows, int Cols);
public record DiagnosticResult(ImageResult Result, DetectionOutput[] DetectionOutputs);
/// <summary>
/// Process a single image file and return the recognition result together with
/// detection network forward output shapes for diagnostics.
/// </summary>
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<Mat>();
_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) public ImageResult ProcessImage(string filePath)
{ {
if (!File.Exists(filePath)) throw new FileNotFoundException("Image not found", filePath); if (!File.Exists(filePath)) throw new FileNotFoundException("Image not found", filePath);
@ -200,10 +339,16 @@ namespace AIFotoONLUS.Core
foreach (var r in regions) foreach (var r in regions)
{ {
using var crop = new Mat(image, r.BoundingBox); 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); 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<ImageResult> ProcessDirectory(string directoryPath, bool skipTextNegative = false) public IEnumerable<ImageResult> ProcessDirectory(string directoryPath, bool skipTextNegative = false)
@ -223,9 +368,11 @@ namespace AIFotoONLUS.Core
var bag = new ConcurrentBag<ImageResult>(); var bag = new ConcurrentBag<ImageResult>();
var dop = Environment.ProcessorCount; 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 // Per-thread nets (each worker gets its own pair) to allow parallel forward calls
// Also keep a ConcurrentBag of created nets so we can dispose them safely from this thread
var netsBag = new ConcurrentBag<(Net detNet, Net recNet)>(); var netsBag = new ConcurrentBag<(Net detNet, Net recNet)>();
var threadLocalNets = new ThreadLocal<(Net detNet, Net recNet)>(() => var threadLocalNets = new ThreadLocal<(Net detNet, Net recNet)>(() =>
{ {
@ -239,17 +386,12 @@ namespace AIFotoONLUS.Core
return (det, rec); return (det, rec);
}); });
var total = files.Length;
var processed = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();
await Task.Run(() => await Task.Run(() =>
{ {
try try
{ {
Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism = dop, CancellationToken = cancellationToken }, f => 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(); cancellationToken.ThrowIfCancellationRequested();
var filename = Path.GetFileName(f); var filename = Path.GetFileName(f);
if (skipTextNegative && filename.StartsWith("tn_", StringComparison.OrdinalIgnoreCase)) if (skipTextNegative && filename.StartsWith("tn_", StringComparison.OrdinalIgnoreCase))
@ -262,19 +404,74 @@ namespace AIFotoONLUS.Core
var regions = DetectTextRegions(nets.detNet, image).ToArray(); var regions = DetectTextRegions(nets.detNet, image).ToArray();
var texts = new List<string>(); var texts = new List<string>();
// minimal logging for performance
foreach (var r in regions) foreach (var r in regions)
{ {
using var crop = new Mat(image, r.BoundingBox); 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 (!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<string>();
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); 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); bag.Add(imgRes);
resultProgress?.Report(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)); bag.Add(new ImageResult(filename, string.Empty, f));
} }
finally finally
@ -307,10 +504,27 @@ namespace AIFotoONLUS.Core
} }
// Overload RecognizeDigits that accepts a Net for worker threads // 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)); 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); using var blob = CvDnn.BlobFromImage(croppedImage, 0.00392, _cfg.RecognitionInputSize, new Scalar(0, 0, 0), true, false);
recognitionNet.SetInput(blob); recognitionNet.SetInput(blob);
@ -326,6 +540,35 @@ namespace AIFotoONLUS.Core
int imgW = croppedImage.Width; int imgW = croppedImage.Width;
int imgH = croppedImage.Height; int imgH = croppedImage.Height;
// Diagnostic: if no outs, try per-output Forward
if (outs.Length == 0 && outNames != null)
{
var fallback = new List<Mat>();
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) foreach (var outMat in outs)
{ {
for (int i = 0; i < outMat.Rows; i++) for (int i = 0; i < outMat.Rows; i++)
@ -334,11 +577,13 @@ namespace AIFotoONLUS.Core
float cy = outMat.At<float>(i, 1) * imgH; float cy = outMat.At<float>(i, 1) * imgH;
float w = outMat.At<float>(i, 2) * imgW; float w = outMat.At<float>(i, 2) * imgW;
float h = outMat.At<float>(i, 3) * imgH; float h = outMat.At<float>(i, 3) * imgH;
float objectness = outMat.At<float>(i, 4);
float maxScore = 0f; float maxScore = 0f;
int bestClass = -1; int bestClass = -1;
for (int c = 5; c < outMat.Cols; c++) for (int c = 5; c < outMat.Cols; c++)
{ {
float score = outMat.At<float>(i, c); float classProb = outMat.At<float>(i, c);
float score = objectness * classProb;
if (score > maxScore) if (score > maxScore)
{ {
maxScore = score; maxScore = score;

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<targets>
<target xsi:type="Console" name="console" layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=toString}" />
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="console" />
</rules>
</nlog>

View file

@ -9,6 +9,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="NLog" Version="6.1.0" /> <PackageReference Include="NLog" Version="6.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="6.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" /> <ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" />

View file

@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:AIFotoONLUS.WPF.Converters" xmlns:converters="clr-namespace:AIFotoONLUS.WPF.Converters"
StartupUri="MainWindow.xaml"> >
<Application.Resources> <Application.Resources>
<ResourceDictionary> <ResourceDictionary>
<converters:InverseBoolConverter x:Key="InverseBoolConverter" /> <converters:InverseBoolConverter x:Key="InverseBoolConverter" />

View file

@ -1,8 +1,78 @@
using System;
using System.Windows; 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 namespace AIFotoONLUS.WPF
{ {
public partial class App : System.Windows.Application 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<MainViewModel>();
services.AddSingleton<MainWindow>();
})
.Build();
await _host.StartAsync().ConfigureAwait(false);
// emit a startup message to verify logging pipeline
var startupLogger = _host.Services.GetRequiredService<ILogger<App>>();
startupLogger.LogInformation("Host started and logging configured");
var main = _host.Services.GetRequiredService<MainWindow>();
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);
}
} }
} }

View file

@ -6,10 +6,11 @@ namespace AIFotoONLUS.WPF
{ {
public partial class MainWindow : Window 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(); InitializeComponent();
DataContext = _vm; DataContext = _vm;
} }
@ -17,7 +18,7 @@ namespace AIFotoONLUS.WPF
protected override void OnClosed(EventArgs e) protected override void OnClosed(EventArgs e)
{ {
base.OnClosed(e); base.OnClosed(e);
_vm.Dispose(); (_vm as IDisposable)?.Dispose();
} }
} }
} }

View file

@ -1,4 +1,5 @@
using AIFotoONLUS.Core; using AIFotoONLUS.Core;
using Microsoft.Extensions.Logging;
using System; using System;
using System.Threading; using System.Threading;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@ -82,8 +83,14 @@ namespace AIFotoONLUS.WPF.ViewModels
public RelayCommand ProcessCommand { get; } public RelayCommand ProcessCommand { get; }
public RelayCommand CancelCommand { get; } public RelayCommand CancelCommand { get; }
public MainViewModel() private readonly ILogger<MainViewModel>? _logger;
private readonly ILoggerFactory? _loggerFactory;
public MainViewModel(ILogger<MainViewModel>? logger, ILoggerFactory? loggerFactory)
{ {
_logger = logger;
_loggerFactory = loggerFactory;
BrowseImagesCommand = new RelayCommand(_ => BrowseFolder(isModel: false)); BrowseImagesCommand = new RelayCommand(_ => BrowseFolder(isModel: false));
BrowseModelsCommand = new RelayCommand(_ => BrowseFolder(isModel: true)); BrowseModelsCommand = new RelayCommand(_ => BrowseFolder(isModel: true));
LoadModelsCommand = new RelayCommand(_ => LoadModels()); LoadModelsCommand = new RelayCommand(_ => LoadModels());
@ -91,9 +98,16 @@ namespace AIFotoONLUS.WPF.ViewModels
CancelCommand = new RelayCommand(_ => Cancel(), _ => IsProcessing); CancelCommand = new RelayCommand(_ => Cancel(), _ => IsProcessing);
// load prefs // load prefs
var prefs = Preferences.Load(); try
if (!string.IsNullOrWhiteSpace(prefs.imagesDir)) ImagesDirectory = prefs.imagesDir; {
if (!string.IsNullOrWhiteSpace(prefs.modelsDir)) ModelsDirectory = prefs.modelsDir; 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) private void BrowseFolder(bool isModel)
@ -121,13 +135,14 @@ namespace AIFotoONLUS.WPF.ViewModels
ConfidenceThreshold = 0.5, ConfidenceThreshold = 0.5,
NmsThreshold = 0.4 NmsThreshold = 0.4
}; };
var logger = _loggerFactory?.CreateLogger<NumberRecognitionEngine>();
_engine = new NumberRecognitionEngine(_cfg); _engine = new NumberRecognitionEngine(_cfg, logger);
Status = "Models loaded"; Status = "Models loaded";
Preferences.Save(ImagesDirectory, ModelsDirectory); Preferences.Save(ImagesDirectory, ModelsDirectory);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger?.LogError(ex, "Error loading models");
System.Windows.MessageBox.Show(ex.Message, "Error loading models", MessageBoxButton.OK, MessageBoxImage.Error); System.Windows.MessageBox.Show(ex.Message, "Error loading models", MessageBoxButton.OK, MessageBoxImage.Error);
Status = "Error loading models"; Status = "Error loading models";
} }