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:
parent
769afc08fb
commit
d2206a00cb
14 changed files with 571 additions and 78 deletions
33
det.py
33
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)
|
||||
|
||||
|
|
|
|||
42
script.bat
42
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
|
||||
call "%VENV_DIR%\Scripts\deactivate.bat"
|
||||
32
scripts/py_diag.py
Normal file
32
scripts/py_diag.py
Normal 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])
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
9
src/AIFotoONLUS.Core/nlog.config
Normal file
9
src/AIFotoONLUS.Core/nlog.config
Normal 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>
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue