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):
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)

View file

@ -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
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>
<ItemGroup>
<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>
<ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" />

View file

@ -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,6 +44,11 @@ namespace AIFotoONLUS.ConsoleApp
}
}
bool diagFirst = args.Contains("--diagfirst");
var fileIndex = Array.IndexOf(args, "--file");
if (fileIndex < 0)
{
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(csvPath))
{
Console.WriteLine("Missing required arguments.");
@ -47,8 +60,28 @@ namespace AIFotoONLUS.ConsoleApp
Console.WriteLine($"Directory not found: {directory}");
return 1;
}
}
var cfg = new ModelConfiguration
// 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}"
};
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"),
@ -56,11 +89,57 @@ namespace AIFotoONLUS.ConsoleApp
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
{
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();
using var sw = new StreamWriter(csvPath, false);
@ -70,11 +149,13 @@ 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<ILoggerFactory>();
var programLogger = loggerFactory?.CreateLogger("Program");
programLogger?.LogError(ex, "Error running processing");
return 2;
}

View file

@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="OpenCvSharp4" 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>

View file

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

View file

@ -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.
/// </summary>
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<DetectedRegion> DetectTextRegions(Mat image)
@ -75,7 +93,42 @@ namespace AIFotoONLUS.Core
var outNames = GetOutputLayerNames(detectionNet);
var outsList = new List<Mat>();
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<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 confidences = new List<float>();
@ -93,11 +146,14 @@ namespace AIFotoONLUS.Core
float w = outMat.At<float>(i, 2) * imgW;
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;
int bestClass = -1;
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)
{
maxScore = score;
@ -120,6 +176,8 @@ namespace AIFotoONLUS.Core
if (boxes.Count == 0) return Enumerable.Empty<DetectedRegion>();
CvDnn.NMSBoxes(boxes, confidences, (float)_cfg.ConfidenceThreshold, (float)_cfg.NmsThreshold, out int[] indices);
var results = new List<DetectedRegion>();
@ -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<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 confidences = new List<float>();
var classIds = new List<int>();
@ -159,11 +253,13 @@ namespace AIFotoONLUS.Core
float cy = outMat.At<float>(i, 1) * imgH;
float w = outMat.At<float>(i, 2) * imgW;
float h = outMat.At<float>(i, 3) * imgH;
float objectness = outMat.At<float>(i, 4);
float maxScore = 0f;
int bestClass = -1;
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)
{
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);
/// <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)
{
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<ImageResult> ProcessDirectory(string directoryPath, bool skipTextNegative = false)
@ -223,9 +368,11 @@ namespace AIFotoONLUS.Core
var bag = new ConcurrentBag<ImageResult>();
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<string>();
// 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);
}
var imgRes = new ImageResult(filename, string.Join(",", texts), f);
bag.Add(imgRes);
resultProgress?.Report(imgRes);
// 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
{
// swallow per-file errors and report empty result
// 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 (Exception ex)
{
_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<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)
{
for (int i = 0; i < outMat.Rows; i++)
@ -334,11 +577,13 @@ namespace AIFotoONLUS.Core
float cy = outMat.At<float>(i, 1) * imgH;
float w = outMat.At<float>(i, 2) * imgW;
float h = outMat.At<float>(i, 3) * imgH;
float objectness = outMat.At<float>(i, 4);
float maxScore = 0f;
int bestClass = -1;
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)
{
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>
<ItemGroup>
<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>
<ProjectReference Include="..\AIFotoONLUS.Core\AIFotoONLUS.Core.csproj" />

View file

@ -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">
>
<Application.Resources>
<ResourceDictionary>
<converters:InverseBoolConverter x:Key="InverseBoolConverter" />

View file

@ -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<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
{
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();
}
}
}

View file

@ -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<MainViewModel>? _logger;
private readonly ILoggerFactory? _loggerFactory;
public MainViewModel(ILogger<MainViewModel>? logger, ILoggerFactory? loggerFactory)
{
_logger = logger;
_loggerFactory = loggerFactory;
BrowseImagesCommand = new RelayCommand(_ => BrowseFolder(isModel: false));
BrowseModelsCommand = new RelayCommand(_ => BrowseFolder(isModel: true));
LoadModelsCommand = new RelayCommand(_ => LoadModels());
@ -91,10 +98,17 @@ namespace AIFotoONLUS.WPF.ViewModels
CancelCommand = new RelayCommand(_ => Cancel(), _ => IsProcessing);
// load prefs
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<NumberRecognitionEngine>();
_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";
}