C#使用OpenCV进行答题卡识别

安装

需要安装两个依赖:

  • OpenCvSharp4
  • OpenCvSharp4.runtime.win

安装

1
2
3
Install-Package OpenCvSharp4 -Version 4.8.0.20230708
Install-Package OpenCvSharp4.runtime.win -Version 4.8.0.20230708
Install-Package OpenCvSharp4.Extensions -Version 4.8.0.20230708

依赖扩展

  • OpenCvSharp4.Extensions

其中

OpenCvSharp4.Extensions 主要是一些辅助的工具 比如Mat和Bitmap的互转。

添加引用

1
2
3
using OpenCvSharp;
using Point = OpenCvSharp.Point;
using Rect = OpenCvSharp.Rect;

识别步骤

image-20220819155853340

常用操作

Mat和Bitmap互转

1
2
3
4
//Bitmap转Mat
Mat mat = OpenCvSharp.Extensions.BitmapConverter.ToMat(image);
//Mat转Bitmap
Bitmap bitmap = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(img8);

读取图片

1
2
3
4
5
6
private void readImg()
{
Mat img1 = new Mat("D:\\Pic\\0.jpg", ImreadModes.Color);
Cv2.ImShow("win1", img1);
Cv2.WaitKey(0);
}

保存

1
2
Mat img1 = new Mat("D:\\Pic\\0.jpg", ImreadModes.Color);
img1.ImWrite("D:\\Pic\\2.jpg");

查看效果

方式1

本地保存图片

1
Cv2.ImWrite("D:\\Pic\\3.jpg", img3);

方式2

窗口打开图片

1
2
3
4
5
private void showImg(Mat img)
{
Cv2.ImShow("win1", img);
Cv2.WaitKey(0);
}

图片模式转换

1
2
Mat img10 = new Mat();
Cv2.CvtColor(img7, img10, ColorConversionCodes.GRAY2RGB);

复制

1
2
Mat img2 = new Mat();
img1.CopyTo(img2);

图片拼接

type表示了矩阵中元素的类型以及矩阵的通道个数,它是一系列的预定义的常量,其命名规则为CV_(位数)+(数据类型)+(通道数),由type()返回,但是返回值是int型,不是OpenCV预定义的宏(CV_8UC1, CV_64FC1…),也就是说你用type函数得到的只是一个int型的数值,比如CV_8UC1返回的值是0,而不是CV_8UC1。

img

数据类型

  • U(unsigned integer)表示的是无符号整数,

  • S(signed integer)是有符号整数,

  • F(float)是浮点数

方式1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/// <summary>
/// Mat拼接
/// </summary>
/// <param name="matList"></param>
/// <returns></returns>
public static Mat jointMat(List<Mat> matList)
{
if (matList.Count == 0)
{
return new Mat();
}
int rows = 0;
int cols = 0;

for (int j = 0; j < matList.Count; j++)
{
Mat img_temp = matList[j];
rows += img_temp.Rows;
cols = Math.Max(cols, img_temp.Cols);
}
Mat result = new Mat(rows, cols, matList[0].Type(), new Scalar(255, 255, 255));

int tempRows = 0;
foreach (Mat itemMat in matList)
{
Mat roi = result[new Rect(0, tempRows, itemMat.Cols, itemMat.Rows)];
itemMat.CopyTo(roi);
tempRows += itemMat.Rows;
}
return result;
}

调用

1
2
3
4
5
List<Mat> mats = new List<Mat>();
mats.Add(new Mat("D:\\Pic\\0.jpg", ImreadModes.Color));
mats.Add(new Mat("D:\\Pic\\1.jpg", ImreadModes.Color));
var result = CvCommonUtils.jointMat(mats);
result.ImWrite("D:\\Pic\\2.jpg");

注意

不同色彩模式的图片不能正常合并,和目标图片的色彩模式也要保持一致,这里使用matList[0].Type()设置目标图的模式。

默认背景是纯黑色,这里new Scalar(255, 255, 255)使图片默认为纯白色。

方式2(不推荐)

使用VConcat()HConcat()拼接则要求待拼接图像有相同的宽度或高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/// <summary>
/// Mat拼接
/// </summary>
/// <param name="matList"></param>
/// <returns></returns>
public static Mat jointMat2(List<Mat> matList)
{
int rows = 0;
int cols = 0;

foreach (Mat itemMat in matList)
{
cols = Math.Max(cols, itemMat.Cols);
}
List<Mat> matListNew = new List<Mat>();
foreach (Mat itemMat in matList)
{
if (itemMat.Cols == cols)
{
matListNew.Add(itemMat);
rows += itemMat.Rows;
}
else
{
int rowsNew = cols * itemMat.Rows / itemMat.Cols;
Mat resultMat = new Mat();
Cv2.Resize(itemMat, resultMat, new Size(cols, rowsNew));
matListNew.Add(resultMat);
rows += resultMat.Rows;
}
}
Mat result = new Mat(rows, cols, MatType.CV_8UC3, new Scalar(255, 255, 255));

Cv2.VConcat(matListNew, result);
return result;
}

调用方式

1
2
3
4
5
List<Mat> mats = new List<Mat>();
mats.Add(new Mat("D:\\Pic\\0.jpg", ImreadModes.Color));
mats.Add(new Mat("D:\\Pic\\1.jpg", ImreadModes.Color));
var result = CvCommonUtils.jointMat2(mats);
result.ImWrite("D:\\Pic\\2.jpg");

灰度

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 灰度
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat gray(Mat source)
{
Mat resultMat = new Mat();
Cv2.CvtColor(source, resultMat, ColorConversionCodes.BGR2GRAY);
return resultMat;
}

二值化

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 二值化
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat binary(Mat source)
{
Mat resultMat = new Mat();
Cv2.Threshold(source, resultMat, 200, 255, ThresholdTypes.Binary);
return resultMat;
}

说明

1
2
3
4
5
6
7
Cv2.Threshold(
source, // 输入图像
resultMat, // 输出图像
200, // 阈值
255, // 最大值
ThresholdTypes.Binary // 阈值类型
);

灰度值小于阈值的都设置为0,大于或等于这个阈值的像素将被赋予 maxValue

腐蚀与膨胀

腐蚀与膨胀都是针对白色区域的

  • 腐蚀 白色变少 黑色变多
  • 膨胀 白色变多 黑色减少

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/// <summary>
/// 膨胀
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat dilation(Mat source)
{
Mat resultMat = new Mat(source.Rows, source.Cols, source.Type());
Mat element = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3));
Cv2.Dilate(source, resultMat, element);
return resultMat;
}

/// <summary>
/// 腐蚀
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat eroding(Mat source)
{
Mat resultMat = new Mat(source.Rows, source.Cols, source.Type());
Mat element = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3));
Cv2.Erode(source, resultMat, element);
return resultMat;
}

高斯模糊

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 高斯模糊
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat gaussianBlur(Mat source)
{
Mat resultMat = new Mat();
Cv2.GaussianBlur(source, resultMat, new OpenCvSharp.Size(11, 11), 4, 4);
return resultMat;
}

缩放

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 图片缩放
/// </summary>
/// <param name="source"></param>
/// <param name="width"></param>
/// <param name="height"></param>
/// <returns></returns>
public static Mat resize(Mat source, int width, int height)
{
Mat resultMat = new Mat();
Cv2.Resize(source, resultMat, new Size(width, height));
return resultMat;
}

旋转

其中方式1和方式2都一样,都只能旋转90的倍数。

方式3可以旋转任意角度,但是如果是长方形就会部分无法显示。

所以

  • 旋转90的倍数推荐方式1
  • 旋转其他角度推荐方式3

方式1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static Mat rotate90Counter(Mat source)
{
Mat resultMat = new Mat();
Cv2.Rotate(source, resultMat, RotateFlags.Rotate90Counterclockwise);
return resultMat;
}

public static Mat rotate90(Mat source)
{
Mat resultMat = new Mat();
Cv2.Rotate(source, resultMat, RotateFlags.Rotate90Clockwise);
return resultMat;
}

public static Mat rotate180(Mat source)
{
Mat resultMat = new Mat();
Cv2.Rotate(source, resultMat, RotateFlags.Rotate180);
return resultMat;
}

其中方向

1
2
3
4
5
6
public enum RotateFlags
{
Rotate90Clockwise,//顺时针90
Rotate180,//180
Rotate90Counterclockwise//逆时针90
}

方式2

逆时针90

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 旋转
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat rotate90Counter(Mat source)
{
Mat resultMat = new Mat();
Mat tempMat = new Mat();
Cv2.Transpose(source, tempMat);
Cv2.Flip(tempMat, resultMat, FlipMode.X);
return resultMat;
}

顺时针90

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 旋转
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat rotate90(Mat source)
{
Mat resultMat = new Mat();
Mat tempMat = new Mat();
Cv2.Transpose(source, tempMat);
Cv2.Flip(tempMat, resultMat, FlipMode.Y);
return resultMat;
}

旋转180

1
2
3
4
5
6
7
8
9
10
11
/// <summary>
/// 旋转
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat rotate180(Mat source)
{
Mat resultMat = new Mat();
Cv2.Flip(source, resultMat, FlipMode.XY);
return resultMat;
}

总结一下:

  • 需逆时针90°旋转时Transpose(src,tmp) + Flip(tmp,dst,0)
  • 需顺时针90°旋转时Transpose(src,tmp) + Flip(tmp,dst,1)

  • 需180°旋转时Flip(src,dst,-1)

Transpose()简单来说,就相当于数学中的转置,在矩阵中,转置就是把行与列相互调换位置;

相当于将图像逆时针旋转90度,然后再关于x轴对称

枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public enum FlipMode
{
//
// 摘要:
// means flipping around x-axis
X = 0,
//
// 摘要:
// means flipping around y-axis
Y = 1,
//
// 摘要:
// means flipping around both axises
XY = -1
}

方式3

旋转任意角度

这种方式如果是长方向旋转90度会导致黑边和遮挡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// 旋转
/// </summary>
/// <param name="source"></param>
/// <param name="angle">角度</param>
/// <returns></returns>
public static Mat rotate(Mat source, double angle = 90)
{
Mat resultMat = new Mat();
Point center = new Point(source.Cols / 2, source.Rows / 2); //旋转中心
double scale = 1.0; //缩放系数
Mat rotMat = Cv2.GetRotationMatrix2D(center, angle, scale);
Cv2.WarpAffine(source, resultMat, rotMat, source.Size());

return resultMat;
}

透视变形

获取黑块顶点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
using OpenCvSharp;

using System;

namespace card_scanner.util
{
public class CvContoursUtils
{
/// <summary>
/// 获取四个顶点
/// </summary>
/// <param name="img"></param>
/// <returns></returns>
public static Point[] getAllPoints(Mat img)
{
Point[] potArr = new Point[4];
for (int i = 0; i < 4; i++)
{
potArr[i] = new Point(-1, -1);
}
// 距离四个角的距离
int[] spaceArr = new int[] { -1, -1, -1, -1 };
int cols = img.Cols;
int rows = img.Rows;
int x1 = cols / 3;
int x2 = cols * 2 / 3;
int y1 = rows / 3;
int y2 = rows * 2 / 3;
for (int x = 0; x < cols; x++)
{
for (int y = 0; y < rows; y++)
{
if (x > x1 && x < x2 && y > y1 && y < y2)
{
continue;
}

Vec3b color = img.Get<Vec3b>(y, x);

if (color != null && color.Item0 == 0)
{
if (spaceArr[0] == -1)
{
potArr[0].X = x;
potArr[0].Y = y;
potArr[1].X = x;
potArr[1].Y = y;
potArr[2].X = x;
potArr[2].Y = y;
potArr[3].X = x;
potArr[3].Y = y;
spaceArr[0] = getSpace(0, 0, x, y);
spaceArr[1] = getSpace(cols, 0, x, y);
spaceArr[2] = getSpace(cols, rows, x, y);
spaceArr[3] = getSpace(0, rows, x, y);
}
else
{
int s0 = getSpace(0, 0, x, y);
int s1 = getSpace(cols, 0, x, y);
int s2 = getSpace(cols, rows, x, y);
int s3 = getSpace(0, rows, x, y);
if (s0 < spaceArr[0])
{
spaceArr[0] = s0;
potArr[0].X = x;
potArr[0].Y = y;
}
if (s1 < spaceArr[1])
{
spaceArr[1] = s1;
potArr[1].X = x;
potArr[1].Y = y;
}
if (s2 < spaceArr[2])
{
spaceArr[2] = s2;
potArr[2].X = x;
potArr[2].Y = y;
}
if (s3 < spaceArr[3])
{
spaceArr[3] = s3;
potArr[3].X = x;
potArr[3].Y = y;
}
}
}
}
}
return potArr;
}

/// <summary>
/// 计算两点之间的距离
/// </summary>
/// <param name="x1"></param>
/// <param name="y1"></param>
/// <param name="x2"></param>
/// <param name="y2"></param>
/// <returns></returns>
private static int getSpace(int x1, int y1, int x2, int y2)
{
int xspace = Math.Abs(x1 - x2);
int yspace = Math.Abs(y1 - y2);
return (int)Math.Sqrt(Math.Pow(xspace, 2) + Math.Pow(yspace, 2));
}
}
}

透视变形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using OpenCvSharp;

using System.Collections.Generic;

namespace Z.OpenCV
{
public class CvPerspectiveUtils
{
/// <summary>
/// 透视变换/顶点变换
/// </summary>
/// <param name="src"></param>
/// <param name="points"></param>
/// <returns></returns>
public static Mat WarpPerspective(Mat src, Point[] points)
{
//设置原图变换顶点
List<Point2f> affinePoints0 = new List<Point2f>()
{
points[0],
points[1],
points[2],
points[3]
};
//设置目标图像变换顶点
List<Point2f> affinePoints1 = new List<Point2f>()
{
new Point(0, 0),
new Point(src.Width, 0),
new Point(src.Width, src.Height),
new Point(0, src.Height)
};
//计算变换矩阵
Mat trans = Cv2.GetPerspectiveTransform(affinePoints0, affinePoints1);
//矩阵仿射变换
Mat dst = new Mat();
Cv2.WarpPerspective(
src,
dst,
trans,
new Size() { Height = src.Rows, Width = src.Cols }
);
return dst;
}

/// <summary>
/// 透视变换
/// </summary>
/// <param name="src"></param>
/// <param name="points"></param>
/// <returns></returns>
public static Mat WarpPerspective2(Mat src, Point[] points)
{
//设置原图变换顶点
List<Point2f> affinePoints0 = new List<Point2f>()
{
points[0],
points[1],
points[2],
points[3]
};
//设置目标图像变换顶点
List<Point2f> affinePoints1 = new List<Point2f>()
{
new Point(0, 0),
new Point(src.Width, 0),
new Point(src.Width, src.Height),
new Point(0, src.Height)
};
//计算变换矩阵
Mat trans = Cv2.GetAffineTransform(affinePoints0, affinePoints1);

//矩阵仿射变换
Mat dst = new Mat();
Cv2.WarpAffine(
src,
dst,
trans,
new Size() { Height = src.Rows, Width = src.Cols }
);
return dst;
}
}
}

调用

1
2
3
4
//透视变形
var points = CvContoursUtils.getAllPoints(img5);
Mat img6 = CvPerspectiveUtils.warpPerspective(img5, points);
Cv2.ImWrite("D:\\Pic\\6_透视变形.jpg", img6);

剪裁

1
2
3
4
// 截取左上角四分之一区域
OpenCvSharp.Rect rect = new OpenCvSharp.Rect(0, 0, img2.Cols / 2, img2.Rows / 2);
Mat img4 = new Mat(img3, rect);
Cv2.ImWrite("D:\\Pic\\4.png", img4);

文件名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ZPathUtil
{
private static int temp = 100;

public static string GetPathJpeg(string basePath)
{
temp += 1;
if (temp > 1000)
{
temp = 101;
}

string filename = DateTime.Now.ToString("yyyy_MM_dd_HH_mm_ss_ffff") + "_" + temp + ".jpg";
string pathAll = Path.Combine(basePath, filename);
return pathAll;
}
}

是否涂卡

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
///  <summary>
/// 区域是否涂卡
/// </summary>
/// <param name="source"></param>
/// <param name="rect"></param>
/// <param name="max"></param>
/// <returns></returns>
public static bool IsSmearCard
(
Mat source,
Rect rect,
double max = 0.25
)
{
Mat matTemp = new Mat(source, rect);
int count = Cv2.CountNonZero(matTemp);
int total = matTemp.Cols * matTemp.Rows;
double rate = 1.0f * (total - count) / total;
return rate > max;
}

注意传入的原图一定要二值化。

识别中可能需要采取不同的策略。

比如单选题可以取填涂占比最高的项,并且该项的填涂率要达到20%以上。

这里获取某区域的填涂率(从0到1):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// <summary>
/// 获取黑色块的占比
/// </summary>
/// <param name="source"></param>
/// <param name="rect"></param>
/// <returns></returns>
public static double GetSmearRate(Mat source, Rect rect)
{
Mat matTemp = new Mat(source, rect);
int count = Cv2.CountNonZero(matTemp);
int total = matTemp.Cols * matTemp.Rows;
double rate = 1.0f * (total - count) / total;
return double.Parse(rate.ToString("0.00"));
}

绘制边框

1
2
3
4
5
Mat img10 = new Mat();
Cv2.CvtColor(img7, img10, ColorConversionCodes.GRAY2RGB);
Cv2.Rectangle(img10, posModel.cantronqrcode, new Scalar(0, 0, 255), 1);
Cv2.Rectangle(img10, posModel.pageRects[0], new Scalar(0, 0, 255), 1);
Cv2.ImWrite("D:\\Pic\\10_边框.png", img10);

注意

黑白图片转为彩色

查找轮廓

实现框选用户选择的选项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 轮廓识别,使用最外轮廓发抽取轮廓RETR_EXTERNAL,轮廓识别方法为CHAIN_APPROX_SIMPLE
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Point[][] findContours(Mat source)
{
Point[][] contours;
HierarchyIndex[] hierarchy;

Cv2.FindContours(
source,
out contours,
out hierarchy,
RetrievalModes.List,
ContourApproximationModes.ApproxSimple
);
return contours;
}

调用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int rows = mat4.Rows;
int cols = mat4.Cols;
int space = mat4.Rows * 5 / 100;
//获取定位块
OpenCvSharp.Point[][] pointAll = CvContoursUtils.findContours(mat4);
List<OpenCvSharp.Point[]> rectVec2 = new List<OpenCvSharp.Point[]>();
for (int i = 0; i < pointAll.Length; i++)
{
OpenCvSharp.Point[] pointArr = pointAll[i];
Rect rect2 = Cv2.BoundingRect(pointArr);

if (rect2.Width > 10 && rect2.Height > 10 && rect2.Width < 60 && rect2.Height < 60 && (rect2.Left < space || rect2.Top < space || (cols - rect2.Right) < space || (rows - rect2.Bottom) < space))
{
rectVec2.Add(pointArr);
}
}

Mat img7 = new Mat(mat4.Rows, mat4.Cols, MatType.CV_8UC3, new Scalar(255, 255, 255));
Cv2.DrawContours(img7, rectVec2, -1, new Scalar(0, 0, 255), 1);
Cv2.ImWrite("D:\\Pic\\5_边框.jpg", img7);

获取面积

1
2
3
4
5
6
7
8
9
//获取涂写区域
Point[][] pointAll = CvContoursUtils.findContours(img6);
List<Point[]> rectVec2 = new List<Point[]>();
for (int i = 0; i < pointAll.Length; i++)
{
Point[] pointArr = pointAll[i];
double image_area = Cv2.ContourArea(pointArr);
Console.WriteLine(image_area);
}

其中Cv2.CountNonZero(matTemp)是获取非0的像素点个素数,所以在二值化的图片中,用户涂的区域都是0,我们只需要获取涂的百分比就能判断用户是否涂卡。

获取答题卡涂的选项

其中每个选项的坐标区域是在制作答题卡的时候,后台要保存的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
int[][][] ques_select = new int[][][] {
new int[][]{
new int[] { 67,6,109,30},
new int[] { 134,6,169,30},
new int[] { 199,6,237,30},
},
new int[][]{
new int[] { 67,50,109,72},
new int[] { 134,50,169,72},
new int[] { 199,50,237,72},
},
new int[][]{
new int[] { 67,92,109,114},
new int[] { 134,92,169,114},
new int[] { 199,92,237,114},
},
new int[][]{
new int[] { 67,132,109,154},
new int[] { 134,132, 169,154},
new int[] { 199,132, 237,154},
},
new int[][]{
new int[] { 67,176,109,198},
new int[] { 134,176, 169,198},
new int[] { 199,176, 237,198},
},
};
string[] opts = new string[] { "A", "B", "C" };
for (int i = 0; i < ques_select.Length; i++)
{
int[][] ques = ques_select[i];
for (int j = 0; j < ques.Length; j++)
{
int[] opt = ques[j];
int width = opt[2] - opt[0];
int height = opt[3] - opt[1];
Mat matTemp = new Mat(img6, new Rect(opt[0], opt[1], width, height));
int count = Cv2.CountNonZero(matTemp);
int total = width * height;
double rate = 1.0f * (total - count) / total;
if (rate > 0.6)
{
Console.WriteLine("题号:" + (i + 1));
Console.WriteLine("选项:" + opts[j]);
Console.WriteLine("rate:" + rate);
}
}
}

页码识别

页面我们可以转换为二进制然后进行黑块渲染,识别的时候后在转成数字即可。这里是页码从1开始,所以要减1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// 获取页码数据
/// </summary>
/// <param name="pageMat"></param>
/// <returns></returns>
private int getPageNum(Mat pageMat)
{
string pagestr = "";
var cantronpage = posModel.cantronpage;
foreach (var page in cantronpage)
{
if (CvCommonUtils.isSmearCard(pageMat, page))
{
pagestr += "1";
}
else
{
pagestr += "0";
}
}

return Convert.ToInt32(pagestr, 2)-1;
}

Mat转Base64

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static string MatToBase64(Mat mat, bool addPrefix = true)
{
using (MemoryStream ms = new MemoryStream())
{
using (Bitmap bitmap = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(mat))
{
bitmap.Save(ms, ImageFormat.Jpeg);
}
if (addPrefix)
{
const string prefix = "data:image/jpeg;base64,";
return prefix + Convert.ToBase64String(ms.ToArray());
}
return Convert.ToBase64String(ms.ToArray());
}
}

绘制

绘制文字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public static void WriteTxt
(
Mat img,
string txt,
Rect rect
)
{
double fontScale = 0.6;
Scalar color = Scalar.Red;
HersheyFonts fontFace = HersheyFonts.HersheySimplex;
int thickness = 1;
Size textSize = Cv2.GetTextSize(
txt,
fontFace,
fontScale,
thickness,
out int baseline
);
Point textOrg = new Point(rect.X, rect.Y + textSize.Height);
// 绘制文本
Cv2.PutText(
img,
txt,
textOrg,
fontFace,
fontScale,
color,
thickness
);
}

位置

其中textOrg是文字的左下角的坐标。

字体

OpenCV 不能自定义字体样式,但是提供了几种预定义的字体样式,通过 HersheyFonts 枚举进行选择。

以下是一些常用的字体类型:

  • HersheySimplex:简单的无衬线字体。
  • HersheyPlain:更细的字体。
  • HersheyDuplex:双线条字体。
  • HersheyTriplex:三线条字体。
  • HersheyComplex:复杂的字体。
  • HersheyComplexSmall:小型复杂字体。
  • HersheyScriptSimplex:手写风格字体。
  • HersheyScriptComplex:复杂手写风格字体。

字体粗细和大小

  • 字体粗细:通过 thickness 参数设置。增加值会使文本变粗。
  • 字体大小:通过 fontScale 参数设置。增大 fontScale 值会使字体变大。

绘制矩形

1
2
3
4
5
6
7
8
9
10
11
12
13
Mat img7 = new Mat();
Cv2.CvtColor(
sourceMat,
img7,
ColorConversionCodes.GRAY2RGB
);

Cv2.Rectangle(
img7,
rectItem,
Scalar.Red,
1
);

绘制轮廓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int rows = mat4.Rows;
int cols = mat4.Cols;
int space = mat4.Rows * 5 / 100;
//获取定位块
OpenCvSharp.Point[][] pointAll = CvContoursUtils.findContours(mat4);
List<OpenCvSharp.Point[]> rectVec2 = new List<OpenCvSharp.Point[]>();
for (int i = 0; i < pointAll.Length; i++)
{
OpenCvSharp.Point[] pointArr = pointAll[i];
Rect rect2 = Cv2.BoundingRect(pointArr);

if (rect2.Width > 10 && rect2.Height > 10 && rect2.Width < 60 && rect2.Height < 60 && (rect2.Left < space || rect2.Top < space || (cols - rect2.Right) < space || (rows - rect2.Bottom) < space))
{
rectVec2.Add(pointArr);
}
}

Mat img7 = new Mat(mat4.Rows, mat4.Cols, MatType.CV_8UC3, new Scalar(255, 255, 255));
Cv2.DrawContours(img7, rectVec2, -1, new Scalar(0, 0, 255), 1);
Cv2.ImWrite("D:\\Pic\\5_边框.jpg", img7);

工具类

基本操作

CvCommonUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
using OpenCvSharp;

using System;
using System.Collections.Generic;

namespace Z.OpenCV
{
public class CvCommonUtils
{
/// <summary>
/// Mat拼接
/// </summary>
/// <param name="matList"></param>
/// <returns></returns>
public static Mat jointMat(List<Mat> matList)
{
if (matList.Count == 0)
{
return new Mat();
}
int rows = 0;
int cols = 0;

for (int j = 0; j < matList.Count; j++)
{
Mat img_temp = matList[j];
rows += img_temp.Rows;
cols = Math.Max(cols, img_temp.Cols);
}
Mat result = new Mat(rows, cols, matList[0].Type(), new Scalar(255, 255, 255));

int tempRows = 0;
foreach (Mat itemMat in matList)
{
Mat roi = result[new Rect(0, tempRows, itemMat.Cols, itemMat.Rows)];
itemMat.CopyTo(roi);
tempRows += itemMat.Rows;
}
return result;
}

/// <summary>
/// Mat拼接
/// </summary>
/// <param name="matList"></param>
/// <returns></returns>
public static Mat jointMat2(List<Mat> matList)
{
int rows = 0;
int cols = 0;

foreach (Mat itemMat in matList)
{
cols = Math.Max(cols, itemMat.Cols);
}
List<Mat> matListNew = new List<Mat>();
foreach (Mat itemMat in matList)
{
if (itemMat.Cols == cols)
{
matListNew.Add(itemMat);
rows += itemMat.Rows;
}
else
{
int rowsNew = cols * itemMat.Rows / itemMat.Cols;
Mat resultMat = new Mat();
Cv2.Resize(itemMat, resultMat, new Size(cols, rowsNew));
matListNew.Add(resultMat);
rows += resultMat.Rows;
}
}
Mat result = new Mat(rows, cols, MatType.CV_8UC3, new Scalar(255, 255, 255));

Cv2.VConcat(matListNew, result);
return result;
}

/// <summary>
/// 图片缩放
/// </summary>
/// <param name="source"></param>
/// <param name="width"></param>
/// <param name="height"></param>
/// <returns></returns>
public static Mat resize(Mat source, int width, int height)
{
Mat resultMat = new Mat();
Cv2.Resize(source, resultMat, new Size(width, height));
return resultMat;
}

/// <summary>
/// 灰度
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat gray(Mat source)
{
Mat resultMat = new Mat();
Cv2.CvtColor(source, resultMat, ColorConversionCodes.BGR2GRAY);
return resultMat;
}

/// <summary>
/// 二值化
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat binary(Mat source)
{
Mat resultMat = new Mat();
Cv2.Threshold(source, resultMat, 200, 255, ThresholdTypes.Binary);
return resultMat;
}

/// <summary>
/// 膨胀
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat dilation(Mat source)
{
Mat resultMat = new Mat(source.Rows, source.Cols, source.Type());
Mat element = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3));
Cv2.Dilate(source, resultMat, element);
return resultMat;
}

/// <summary>
/// 腐蚀
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat eroding(Mat source)
{
Mat resultMat = new Mat(source.Rows, source.Cols, source.Type());
Mat element = Cv2.GetStructuringElement(MorphShapes.Rect, new Size(3, 3));
Cv2.Erode(source, resultMat, element);
return resultMat;
}

/// <summary>
/// 高斯模糊
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat gaussianBlur(Mat source)
{
Mat resultMat = new Mat();
Cv2.GaussianBlur(source, resultMat, new OpenCvSharp.Size(11, 11), 4, 4);
return resultMat;
}

/// <summary>
/// 反转
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat bitwiseNot(Mat source)
{
Mat resultMat = new Mat();
Cv2.BitwiseNot(source, resultMat, new Mat());
return resultMat;
}

/// <summary>
/// 美颜磨皮 双边滤波
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat bilateralFilter(Mat source)
{
Mat resultMat = new Mat();
Cv2.BilateralFilter(source, resultMat, 15, 35d, 35d);
return resultMat;
}

/// <summary>
/// 逆时针旋转90
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat rotate90Counter(Mat source)
{
Mat resultMat = new Mat();
Cv2.Rotate(source, resultMat, RotateFlags.Rotate90Counterclockwise);
return resultMat;
}

/// <summary>
/// 顺时针旋转90
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Mat rotate90(Mat source)
{
Mat resultMat = new Mat();
Cv2.Rotate(source, resultMat, RotateFlags.Rotate90Clockwise);
return resultMat;
}

/// <summary>
///区域是否涂卡
/// </summary>
/// <param name="source"></param>
/// <param name="rect"></param>
/// <returns></returns>
public static bool isSmearCard(Mat source, Rect rect)
{
Mat matTemp = new Mat(source, rect);
int count = Cv2.CountNonZero(matTemp);
int total = rect.Width * rect.Height;
double rate = 1.0f * (total - count) / total;
return rate > 0.3;
}
}
}

获取边界

代码

CvContoursUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
using OpenCvSharp;

using System;
using System.Collections.Generic;

using Z.Common;

namespace Z.OpenCV
{
public class CvContoursUtils
{
/// <summary>
/// 获取四个顶点(优化版本)
/// </summary>
/// <param name="mat"></param>
/// <returns></returns>
public static Point[] GetAnchorPointArr(Mat mat)
{
// 忽略周围的像素
Point[] potArr = new Point[4];
for (int i = 0; i < 4; i++)
{
potArr[i] = new Point(-1, -1);
}
// 距离四个角的距离
//左上 右上 右下 左下
int[] spaceArr =
{
-1,
-1,
-1,
-1
};
int rows = mat.Rows;
int cols = mat.Cols;
//获取定位块
Point[][] pointAll = FindContours(mat);
List<Point[]> rectVec2 = new List<Point[]>();
foreach (Point[] pointArr in pointAll)
{
if (IsSquare(pointArr))
{
rectVec2.Add(pointArr);
}
}
Mat img7 = new Mat(
mat.Rows,
mat.Cols,
MatType.CV_8UC3,
new Scalar(
255,
255,
255
)
);
Cv2.DrawContours(
img7,
rectVec2,
-1,
new Scalar(
0,
0,
255
)
);
foreach (Point[] points in rectVec2)
{
foreach (Point point in points)
{
int x = point.X;
int y = point.Y;
if (spaceArr[0] == -1)
{
potArr[0].X = x;
potArr[0].Y = y;
potArr[1].X = x;
potArr[1].Y = y;
potArr[2].X = x;
potArr[2].Y = y;
potArr[3].X = x;
potArr[3].Y = y;
spaceArr[0] = GetSpace(
0,
0,
x,
y
);
spaceArr[1] = GetSpace(
cols,
0,
x,
y
);
spaceArr[2] = GetSpace(
cols,
rows,
x,
y
);
spaceArr[3] = GetSpace(
0,
rows,
x,
y
);
}
else
{
int s0 = GetSpace(
0,
0,
x,
y
);
int s1 = GetSpace(
cols,
0,
x,
y
);
int s2 = GetSpace(
cols,
rows,
x,
y
);
int s3 = GetSpace(
0,
rows,
x,
y
);
if (s0 < spaceArr[0])
{
spaceArr[0] = s0;
potArr[0].X = x;
potArr[0].Y = y;
}
if (s1 < spaceArr[1])
{
spaceArr[1] = s1;
potArr[1].X = x;
potArr[1].Y = y;
}
if (s2 < spaceArr[2])
{
spaceArr[2] = s2;
potArr[2].X = x;
potArr[2].Y = y;
}
if (s3 < spaceArr[3])
{
spaceArr[3] = s3;
potArr[3].X = x;
potArr[3].Y = y;
}
}
}
}
return potArr;
}

static bool IsSquare(Point[] pointArr)
{
Rect rect2 = Cv2.BoundingRect(pointArr);
//区域必须大于10 小于100 并且近似于正方形的
int width = rect2.Width;
int height = rect2.Height;
if (width < 10 || height < 10 || width > 100 || height > 100)
{
return false;
}
double contourArea = Cv2.ContourArea(pointArr);
//用面积大于长乘以宽的0.8来判断近似矩形
if (contourArea < 0.8 * width * height)
{
return false;
}
double ratio = 1.0 * width / height;
return Math.Abs(1 - ratio) <= 0.2; // 长宽比例误差小于0.2认为是正方形
}

// 判断外接矩形是否为正方形
static bool IsSquare2(Point[] pointArr)
{
RotatedRect boundingRect = Cv2.MinAreaRect(pointArr);
Point2f[] vertices = boundingRect.Points();
// 编写逻辑判断外接矩形是否为正方形
// 根据外接矩形的长宽比例来判断是否为正方形
double width = vertices[0].DistanceTo(vertices[1]);
double height = vertices[1].DistanceTo(vertices[2]);
//宽高小于10的排除
if (width < 10 || height < 10 || width > 100 || height > 100)
{
return false;
}
double contourArea = Cv2.ContourArea(pointArr);
if (contourArea < 0.8 * width * height)
{
return false;
}
double ratio = width / height;
return Math.Abs(1 - ratio) <= 0.2; // 长宽比例误差小于0.2认为是正方形
}

/// <summary>
/// 计算两点之间的距离
/// </summary>
/// <param name="x1"></param>
/// <param name="y1"></param>
/// <param name="x2"></param>
/// <param name="y2"></param>
/// <returns></returns>
private static int GetSpace
(
int x1,
int y1,
int x2,
int y2
)
{
int xspace = Math.Abs(x1 - x2);
int yspace = Math.Abs(y1 - y2);
return (int)Math.Sqrt(Math.Pow(xspace, 2) + Math.Pow(yspace, 2));
}

/// <summary>
/// 轮廓识别,使用最外轮廓发抽取轮廓RETR_EXTERNAL,轮廓识别方法为CHAIN_APPROX_SIMPLE
/// </summary>
/// <param name="source"></param>
/// <returns></returns>
public static Point[][] FindContours(Mat source)
{
Cv2.FindContours(
source,
out Point[][] contours,
out HierarchyIndex[] _,
RetrievalModes.List,
ContourApproximationModes.ApproxSimple
);
return contours;
}
}
}

BoundingRect 和 MinAreaRect

BoundingRect 和 MinAreaRect的区别?

1
2
Rect rect2 = Cv2.BoundingRect(pointArr);
RotatedRect boundingRect = Cv2.MinAreaRect(pointArr);

BoundingRectMinAreaRect 都是 OpenCV 中用于获取轮廓外接矩形的函数,它们之间有一些区别:

  1. BoundingRect

    • BoundingRect 函数计算轮廓的最小外接矩形,该外接矩形是与轴对齐的,即矩形的边与图像的 x 和 y 轴平行。
    • 外接矩形的长和宽是通过轮廓的最小外接矩形的水平(水平间距)和垂直(垂直间距)方向上的最大值和最小值计算而得。
    • 返回的外接矩形通常保留了一定程度的“多余”部分,即可能会包含一些轮廓之外的区域。
  2. MinAreaRect

    • MinAreaRect 函数计算包围轮廓的最小面积矩形,该矩形是不能旋转的(非水平或垂直)。
    • 返回的外接矩形通常紧贴着轮廓,不包含过多的“多余”部分,因此更接近于轮廓的实际形状。
    • MinAreaRect 函数返回一个 RotatedRect 对象,包含了最小面积矩形的中心点、宽度、高度和旋转角度等信息。

在实际使用中,如果需要获取紧贴着轮廓的外接矩形且不考虑矩形的旋转,建议使用 BoundingRect 函数;如果需要获取紧贴着轮廓的最小面积矩形(可能具有旋转),则应该使用 MinAreaRect 函数。

希望这个对比能帮助你理解 BoundingRectMinAreaRect 函数的区别。如果有任何其他问题,请随时告诉我。

透视变形

CvPerspectiveUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using OpenCvSharp;

using System.Collections.Generic;

namespace Z.OpenCV
{
public class CvPerspectiveUtils
{
/// <summary>
/// 透视变换/顶点变换
/// </summary>
/// <param name="src"></param>
/// <param name="points"></param>
/// <returns></returns>
public static Mat WarpPerspective(Mat src, Point[] points)
{
//设置原图变换顶点
List<Point2f> affinePoints0 = new List<Point2f>()
{
points[0],
points[1],
points[2],
points[3]
};
//设置目标图像变换顶点
List<Point2f> affinePoints1 = new List<Point2f>()
{
new Point(0, 0),
new Point(src.Width, 0),
new Point(src.Width, src.Height),
new Point(0, src.Height)
};
//计算变换矩阵
Mat trans = Cv2.GetPerspectiveTransform(affinePoints0, affinePoints1);
//矩阵仿射变换
Mat dst = new Mat();
Cv2.WarpPerspective(
src,
dst,
trans,
new Size() { Height = src.Rows, Width = src.Cols }
);
return dst;
}

/// <summary>
/// 透视变换
/// </summary>
/// <param name="src"></param>
/// <param name="points"></param>
/// <returns></returns>
public static Mat WarpPerspective2(Mat src, Point[] points)
{
//设置原图变换顶点
List<Point2f> affinePoints0 = new List<Point2f>()
{
points[0],
points[1],
points[2],
points[3]
};
//设置目标图像变换顶点
List<Point2f> affinePoints1 = new List<Point2f>()
{
new Point(0, 0),
new Point(src.Width, 0),
new Point(src.Width, src.Height),
new Point(0, src.Height)
};
//计算变换矩阵
Mat trans = Cv2.GetAffineTransform(affinePoints0, affinePoints1);

//矩阵仿射变换
Mat dst = new Mat();
Cv2.WarpAffine(
src,
dst,
trans,
new Size() { Height = src.Rows, Width = src.Cols }
);
return dst;
}
}
}

在 C# 中使用 OpenCV 时,GetPerspectiveTransformGetAffineTransform 是两种不同的函数,用于计算两种不同类型的转换矩阵。

  1. GetPerspectiveTransform

    • GetPerspectiveTransform 函数用于计算透视变换矩阵,该矩阵可以将一个四边形区域映射到另一个四边形区域,实现透视变换。
    • 在图像处理中,透视变换可用于校正图像中的透视畸变,如将斜切或倾斜的物体调整为正常视角。
    • 透视变换需要至少4个对应的点对来计算透视变换矩阵。
  2. GetAffineTransform

    • GetAffineTransform 函数用于计算仿射变换矩阵,该矩阵可以将一个平行四边形区域映射到另一个平行四边形区域,实现平移、旋转和缩放等变换。
    • 仿射变换通常用于实现简单的图像变换,如图像平移、旋转、缩放等操作。
    • 仿射变换需要至少3个对应的点对来计算仿射变换矩阵。

因此,GetPerspectiveTransform 用于计算透视变换矩阵,而 GetAffineTransform 用于计算仿射变换矩阵,它们的主要区别在于变换的类型和所需的点对数量。根据具体的图像处理需求,选择适合的函数来进行变换操作。

简而言之

如果是平行四边形到矩形就用GetAffineTransform,如果是梯形到矩形要用GetPerspectiveTransform

查看代码执行时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Diagnostics;

//定义一个计时对象
System.Diagnostics.Stopwatch oTime = new System.Diagnostics.Stopwatch();
//开始计时
oTime.Start();

//测试的代码
// ...

//结束计时
oTime.Stop();

//输出运行时间。
Console.WriteLine("程序的运行时间:{0} 秒", oTime.Elapsed.TotalSeconds);
Console.WriteLine("程序的运行时间:{0} 毫秒", oTime.Elapsed.TotalMilliseconds);

计时实例可以使用多次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
System.Diagnostics.Stopwatch oTime = new System.Diagnostics.Stopwatch();
oTime.Start();
var result = CvContoursUtils.getAllPoints(mat4);
foreach (var point in result)
{
Console.WriteLine(point);
}
oTime.Stop();
Console.WriteLine("程序的运行时间:{0} 毫秒", oTime.Elapsed.TotalMilliseconds);

oTime.Start();
var result2 = CvContoursUtils.getAllPoints2(mat4);
foreach (var point in result2)
{
Console.WriteLine(point);
}
oTime.Stop();
Console.WriteLine("程序的运行时间:{0} 毫秒", oTime.Elapsed.TotalMilliseconds);