先看看官网模型介绍👇
票证检测矫正模型介绍
【读光商用矫正模型开源,快来体验吧】票证检测矫正模型在实际生活中有着广泛的需求,例如信息抽取、图像质量判断、证件扫描、票据审计等领等场景,可以大幅提高工作效率和准确性。本次 读光团队 开源了商用票证检测矫正模型,基于海量的真实数据训练,可以从容应对多种复杂场景的票证检测矫正任务,该模型具有以下优点:
- 支持任意角度、多卡证票据等混贴场景,同时检测输入图像任意角度的多个子图区域;
- 基于海量真实数据训练,效果满足国内常见的卡证票据的检测矫正需求;
- 支持子图区域复印件判断、四方向判断,准确率高达 99%;
- 矫正效果、推理速度远高于 modelScope 同类模型,详见本文测试报告。
模型效果评测
卡证场景
| 评测方式 | det_precison | det_recall | det_f-score | 方向判别 | 复印件判别 | 推理速度(FPS) |
|---|---|---|---|---|---|---|
| 卡证检测矫正模型 | 99.63% | 96.80% | 98.19% | 无此能力 | 无此能力 | 1.83 |
| 读光-票证检测矫正模型 | 99.64% | 99.85% | 99.75% | 99.78% | 99.70% | 14.30 |
备注:1)私有测试集,该测试集包含身份证、银行卡、驾驶证、行驶证等常见的卡证数据共计 1200 张;2)测试的速度均不包含后处理的图像透视变换;测试使用 GPU 型号:Tesla A100-PCIE-80GB
票据场景
| 评测方式 | det_precison | det_recall | det_f-score | 方向判别 | 复印件判别 | 推理速度(FPS) |
|---|---|---|---|---|---|---|
| 卡证检测矫正模型 | 98.90% | 82.43% | 89.92% | 无此能力 | 无此能力 | 1.79 |
| 读光-票证检测矫正模型 | 99.92% | 100.00% | 99.96% | 99.83% | 99.17% | 12.55 |
备注:1)私有测试集,该测试集包含营业执照、增值税发票、机动车销售发票、出生证明等常见的票据和资质数据共计 1200 张;2)测试的速度均不包含后处理的图像透视变换;测试使用 GPU 型号:Tesla A100-PCIE-80GB
模型描述
下图是实现流程:输入图片,基于 Resnet18-FPN 提取特征后,在 1/4 尺寸处通过三条分支分别识别出票证的中心点、偏移量(中心点到4个顶点距离)、中心点偏移量(为了得到精准的中心点),即可解码数出票证区域的四边形框;再用透视变换将票证拉平得到矫正后的票证信息;与此同时,分类分支识别出子图朝向,用于而切割的子图转正。

效果展示
下图是模型效果:

OpenCVSharp实现👇
全部代码如下:
///CardDetector.cs
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
using OpenCvSharp;
using OpenCvSharp.Dnn;
using Size = OpenCvSharp.Size;
namespace CardCorrection
{
public class CardDetector
{
private Net model;
private string[] outputNames;
// 模型参数
private const int InputSize = 768;
private const int OutputSize = 192;
private const float ScoreThreshold = 0.5f;
private const int MaxDetections = 10;
// 预处理参数
private readonly Scalar mean = new Scalar(0.408, 0.447, 0.470);
private readonly Scalar std = new Scalar(0.289, 0.274, 0.278);
public CardDetector(string modelPath)
{
model = CvDnn.ReadNetFromOnnx(modelPath);
outputNames = model.GetUnconnectedOutLayersNames();
Console.WriteLine($"模型加载成功,输出层: {string.Join(", ", outputNames)}");
}
public List<CardDetection> Detect(Mat image)
{
// 预处理
Mat blob = Preprocess(image);
// 推理
model.SetInput(blob);
Mat[] outputs = outputNames.Select(_=>new Mat()).ToArray();
model.Forward(outputs, outputNames);
// 后处理
var detections = Postprocess(outputs, image);
// 清理资源
blob.Dispose();
foreach (var output in outputs)
{
output.Dispose();
}
return detections;
}
private Mat Preprocess(Mat image)
{
// 调整大小并填充
Mat resized = new Mat();
Cv2.Resize(image, resized, new Size(InputSize, InputSize));
// 归一化
Mat normalized = new Mat();
resized.ConvertTo(normalized, MatType.CV_32F, 1.0 / 255.0);
// 减去均值除以标准差
Mat[] channels = new Mat[3];
Cv2.Split(normalized, out channels);
for (int i = 0; i < 3; i++)
{
channels[i] = (channels[i] - mean.Val0) / std.Val0;
}
Cv2.Merge(channels, normalized);
// 创建blob
Mat blob = CvDnn.BlobFromImage(normalized);
// 清理
resized.Dispose();
normalized.Dispose();
foreach (var channel in channels)
{
channel.Dispose();
}
return blob;
}
private List<CardDetection> Postprocess(Mat[] outputs, Mat originalImage)
{
// 解析输出
// outputs[4]: heatmap [1,1,192,192] - 目标热力图
// outputs[0]: angle_cls [1,4,192,192] - 角度分类
// outputs[1]: ftype_cls [1,2,192,192] - 类型分类
// outputs[2]: wh [1,8,192,192] - 边界框坐标
// outputs[3]: reg [1,2,192,192] - 回归偏移
Mat heatmap = outputs[4]; // 热力图
Mat angleCls = outputs[0]; // 角度分类
Mat ftypeCls = outputs[1]; // 类型分类
Mat wh = outputs[2]; // 边界框
Mat reg = outputs[3]; // 回归偏移
// 应用sigmoid激活
Mat heatmapSigmoid = Sigmoid(heatmap);
Mat angleClsSigmoid = Sigmoid(angleCls);
Mat ftypeClsSigmoid = Sigmoid(ftypeCls);
// 找到热力图中的峰值点(局部最大值)
var peaks = FindPeaks(heatmapSigmoid, ScoreThreshold, MaxDetections);
List<CardDetection> detections = new List<CardDetection>();
foreach (var peak in peaks)
{
// 获取该位置的所有信息
var detection = ProcessDetection(peak, heatmapSigmoid, angleClsSigmoid,
ftypeClsSigmoid, wh, reg, originalImage);
if (detection != null)
{
detections.Add(detection);
}
}
// 清理临时矩阵
heatmapSigmoid.Dispose();
angleClsSigmoid.Dispose();
ftypeClsSigmoid.Dispose();
return detections;
}
private List<Peak> FindPeaks(Mat heatmap, float threshold, int maxPeaks)
{
List<Peak> peaks = new List<Peak>();
// 将热力图转换为二维
Mat heatmap2D = heatmap.Reshape(0, new int[] { OutputSize, OutputSize });
// 使用非极大值抑制找到局部最大值
Mat dilated = new Mat();
Cv2.Dilate(heatmap2D, dilated, Mat.Ones(3, 3, MatType.CV_8UC1));
Mat localMaxima = new Mat();
Cv2.Compare(heatmap2D, dilated, localMaxima, CmpType.EQ);
// 收集满足阈值的峰值点
for (int y = 0; y < OutputSize; y++)
{
for (int x = 0; x < OutputSize; x++)
{
if (localMaxima.At<byte>(y, x) != 0 &&
heatmap2D.At<float>(y, x) > threshold)
{
peaks.Add(new Peak
{
X = x,
Y = y,
Score = heatmap2D.At<float>(y, x)
});
}
}
}
// 按分数排序并取前maxPeaks个
peaks = peaks.OrderByDescending(p => p.Score)
.Take(maxPeaks)
.ToList();
heatmap2D.Dispose();
dilated.Dispose();
localMaxima.Dispose();
Console.WriteLine($"找到 {peaks.Count} 个检测点");
return peaks;
}
private CardDetection ProcessDetection(Peak peak, Mat heatmap, Mat angleCls,
Mat ftypeCls, Mat wh, Mat reg, Mat originalImage)
{
try
{
// 1. 应用回归偏移修正中心点
float regX = GetValueAt(reg, 0, 0, peak.Y, peak.X);
float regY = GetValueAt(reg, 0, 1, peak.Y, peak.X);
float centerX = peak.X + regX;
float centerY = peak.Y + regY;
// 2. 获取边界框坐标(相对于中心点的偏移)
float[] bboxOffsets = new float[8];
for (int i = 0; i < 8; i++)
{
bboxOffsets[i] = GetValueAt(wh, 0, i, peak.Y, peak.X);
}
// 3. 计算实际边界框坐标
Point2f[] corners = new Point2f[4];
for (int i = 0; i < 4; i++)
{
float x = centerX - bboxOffsets[i * 2];
float y = centerY - bboxOffsets[i * 2 + 1];
corners[i] = new Point2f(x, y);
}
// 4. 获取分类信息
int angleClass = GetMaxClass(angleCls, peak.Y, peak.X);
int typeClass = GetMaxClass(ftypeCls, peak.Y, peak.X);
// 5. 将坐标从特征图空间转换到原始图像空间
Point2f[] originalCorners = TransformToOriginalImage(corners, originalImage);
// 6. 裁剪并旋转图像
Mat cropped = CropAndRotateCard(originalImage, originalCorners, angleClass);
return new CardDetection
{
Score = peak.Score,
Corners = originalCorners,
AngleClass = angleClass,
TypeClass = typeClass,
Center = new Point2f(centerX * 4, centerY * 4), // 乘以4因为下采样
CroppedImage = cropped
};
}
catch (Exception ex)
{
Console.WriteLine($"处理检测点时出错: {ex.Message}");
return null;
}
}
private Point2f[] TransformToOriginalImage(Point2f[] corners, Mat originalImage)
{
// 从192x192特征图坐标转换到768x768预处理图像坐标
float scaleX = (float)originalImage.Width / InputSize;
float scaleY = (float)originalImage.Height / InputSize;
Point2f[] result = new Point2f[4];
for (int i = 0; i < 4; i++)
{
// 首先乘以4(从192到768),然后缩放到原始图像尺寸
float x = corners[i].X * 4 * scaleX;
float y = corners[i].Y * 4 * scaleY;
// 确保坐标在图像范围内
x = Math.Max(0, Math.Min(originalImage.Width - 1, x));
y = Math.Max(0, Math.Min(originalImage.Height - 1, y));
result[i] = new Point2f(x, y);
}
return result;
}
private Mat CropAndRotateCard(Mat image, Point2f[] corners, int angleClass)
{
if (corners.Length != 4)
return new Mat();
// 计算裁剪后的尺寸
float width = Math.Max(
Distance(corners[0], corners[1]),
Distance(corners[2], corners[3])
);
float height = Math.Max(
Distance(corners[0], corners[3]),
Distance(corners[1], corners[2])
);
// 目标点(矩形)
Point2f[] dstPoints = {
new Point2f(0, 0),
new Point2f(width - 1, 0),
new Point2f(width - 1, height - 1),
new Point2f(0, height - 1)
};
// 透视变换
Mat transform = Cv2.GetPerspectiveTransform(corners, dstPoints);
Mat cropped = new Mat();
Cv2.WarpPerspective(image, cropped, transform, new Size((int)width, (int)height));
// 根据角度分类进行旋转
RotateFlags rotateFlag = GetRotateFlag(angleClass);
if (rotateFlag != RotateFlags.Rotate90Clockwise) // 默认不需要旋转
{
Mat rotated = new Mat();
Cv2.Rotate(cropped, rotated, rotateFlag);
cropped.Dispose();
cropped = rotated;
}
transform.Dispose();
return cropped;
}
// 辅助方法
private float GetValueAt(Mat mat, int batch, int channel, int y, int x)
{
// 直接从4D张量中获取值 [batch, channel, height, width]
return mat.At<float>(batch, channel, y, x);
}
private int GetMaxClass(Mat clsMap, int y, int x)
{
int numClasses = clsMap.Size(1);
float maxScore = -1;
int maxClass = 0;
for (int c = 0; c < numClasses; c++)
{
float score = GetValueAt(clsMap, 0, c, y, x);
if (score > maxScore)
{
maxScore = score;
maxClass = c;
}
}
return maxClass;
}
private Mat Sigmoid(Mat x)
{
Mat result = new Mat();
Cv2.Exp(-x, result);
result = 1.0 / (1 + result);
return result;
}
private float Distance(Point2f p1, Point2f p2)
{
return (float)Math.Sqrt(Math.Pow(p1.X - p2.X, 2) + Math.Pow(p1.Y - p2.Y, 2));
}
private RotateFlags GetRotateFlag(int angleClass)
{
switch (angleClass)
{
case 1: return RotateFlags.Rotate180;
case 2: return RotateFlags.Rotate90Counterclockwise;
case 3: return RotateFlags.Rotate90Clockwise;
default: return RotateFlags.Rotate90Clockwise; // 0度,但OpenCV没有0度旋转标志
}
}
}
// 辅助类
public class Peak
{
public int X { get; set; }
public int Y { get; set; }
public float Score { get; set; }
}
public class CardDetection
{
public float Score { get; set; }
public Point2f[] Corners { get; set; }
public int AngleClass { get; set; }
public int TypeClass { get; set; }
public Point2f Center { get; set; }
public Mat CroppedImage { get; set; }
public Rectangle GetBoundingBox()
{
if (Corners == null || Corners.Length == 0)
return new Rectangle();
float minX = Corners.Min(p => p.X);
float minY = Corners.Min(p => p.Y);
float maxX = Corners.Max(p => p.X);
float maxY = Corners.Max(p => p.Y);
return new Rectangle((int)minX, (int)minY, (int)(maxX - minX), (int)(maxY - minY));
}
}
}
///Visualization.cs
using System;
using System.Collections.Generic;
using System.Linq;
using OpenCvSharp;
namespace CardCorrection
{
public static class Visualization
{
public static void DrawDetections(Mat image, List<CardDetection> detections, string outputPath)
{
Mat result = image.Clone();
Random rnd = new Random();
foreach (var detection in detections)
{
// 随机颜色
Scalar color = new Scalar(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));
// 绘制边界框
Point[] points = detection.Corners.Select(p => new Point((int)p.X, (int)p.Y)).ToArray();
Cv2.Polylines(result, new Point[][] { points }, true, color, 3);
// 绘制中心点
Cv2.Circle(result, new Point((int)detection.Center.X, (int)detection.Center.Y), 8, color, -1);
// 绘制分数和角度信息
string info = $"Score: {detection.Score:F2}, Angle: {detection.AngleClass}";
Cv2.PutText(result, info,
new Point((int)detection.Center.X + 10, (int)detection.Center.Y - 10),
HersheyFonts.HersheySimplex, 0.7, color, 2);
}
Cv2.ImWrite(outputPath, result);
result.Dispose();
Console.WriteLine($"检测结果已保存: {outputPath}");
}
public static void SaveCroppedImages(List<CardDetection> detections, string prefix = "card")
{
for (int i = 0; i < detections.Count; i++)
{
if (!detections[i].CroppedImage.Empty())
{
string path = $"{prefix}_{i:00}.jpg";
Cv2.ImWrite(path, detections[i].CroppedImage);
Console.WriteLine($"裁剪图像已保存: {path} ({detections[i].CroppedImage.Width}x{detections[i].CroppedImage.Height})");
Cv2.ImShow("CroppedImage"+i, detections[i].CroppedImage);
}
}
}
public static void CreateDetectionReport(Mat originalImage, List<CardDetection> detections, string outputPath)
{
if (detections.Count == 0)
{
Console.WriteLine("没有检测到卡片,无法生成报告");
return;
}
// 创建水平拼接的图像
List<Mat> images = new List<Mat> { originalImage.Clone() };
images.AddRange(detections.Select(d => d.CroppedImage));
// 调整所有图像到相同高度
int targetHeight = images.Min(img => img.Height);
List<Mat> resizedImages = new List<Mat>();
int totalWidth = 0;
foreach (var img in images)
{
float aspect = (float)img.Width / img.Height;
int newWidth = (int)(targetHeight * aspect);
Mat resized = new Mat();
Cv2.Resize(img, resized, new Size(newWidth, targetHeight));
resizedImages.Add(resized);
totalWidth += newWidth;
}
// 创建拼接图像
Mat report = new Mat(targetHeight, totalWidth, MatType.CV_8UC3, Scalar.Black);
int xOffset = 0;
for (int i = 0; i < resizedImages.Count; i++)
{
Mat roi = report[new Rect(xOffset, 0, resizedImages[i].Width, resizedImages[i].Height)];
resizedImages[i].CopyTo(roi);
// 添加标题
string title = i == 0 ? "Original" : $"Card {i - 1}";
Cv2.PutText(report, title, new Point(xOffset + 10, 30),
HersheyFonts.HersheySimplex, 1.0, Scalar.White, 2);
xOffset += resizedImages[i].Width;
resizedImages[i].Dispose();
}
Cv2.ImWrite(outputPath, report);
report.Dispose();
Console.WriteLine($"检测报告已保存: {outputPath}");
}
}
}
使用方法:
static void Main6()
{
string imagePath = "D:\\cv_resnet18_card_correction-opencv-dnn-main\\cv_resnet18_card_correction-opencv-dnn-main\\testimgs\\demo.jpg";
string modelPath = "D:\\学习\\OPENCV\\OPENCV-ZXING\\OPENCV-BLOB\\bin\\x64\\Debug\\model\\cv_resnet18_card_correction.onnx";
try
{
Console.WriteLine("卡片检测与校正程序");
Console.WriteLine("==================");
// 1. 加载图像
Console.WriteLine($"加载图像: {imagePath}");
Mat image = Cv2.ImRead(imagePath);
if (image.Empty())
{
Console.WriteLine("无法加载图像");
return;
}
Console.WriteLine($"图像尺寸: {image.Width}x{image.Height}");
// 2. 加载模型
Console.WriteLine("加载检测模型...");
CardDetector detector = new CardDetector(modelPath);
// 3. 执行检测
Console.WriteLine("开始检测...");
List<CardDetection> detections = detector.Detect(image);
// 4. 显示结果
Console.WriteLine($"检测到 {detections.Count} 张卡片");
for (int i = 0; i < detections.Count; i++)
{
var det = detections[i];
Console.WriteLine($"卡片 {i}: 置信度={det.Score:F3}, 角度={det.AngleClass}, 类型={det.TypeClass}");
}
// 5. 可视化结果
Console.WriteLine("生成可视化结果...");
CardCorrection.Visualization.DrawDetections(image, detections, "detection_result.jpg");
CardCorrection.Visualization.SaveCroppedImages(detections, "cropped_card");
CardCorrection.Visualization.CreateDetectionReport(image, detections, "detection_report.jpg");
Console.WriteLine("处理完成!");
// 清理资源
image.Dispose();
foreach (var detection in detections)
{
detection.CroppedImage?.Dispose();
}
}
catch (Exception ex)
{
Console.WriteLine($"错误: {ex.Message}");
Console.WriteLine($"堆栈跟踪: {ex.StackTrace}");
}
}