使用OpenCV识别自定义二维码——加码、解码、识别和绘制算法重构的过程记录
1. 珊瑚码定义
这种二维码是在某金融机构打码机上使用的一种形似二维码但是又不遵循二维码标准的图形码。其形状在正方形四个角上有回型图案,中间为一个大型回字图案。在生物学上来看比较像簇生珊瑚,故暂时命名为珊瑚码。
按照逆向结果,此码可以最大为65x65黑白块,但是在该机构只使用了带ECC的27x27大小图形码,下文专门以此规格进行介绍。
如果下文中内容涉及侵权,请联系作者。因无法联系打码机软件作者,本文仅从学术讨论记录重构过程,并不涉及商业使用。
谢绝转载,特别是CSDN之流。本文发布在CNBLOGS博客园,作者cnblogs.com/charset。
1.1 逆向过程
本身逆向使用了IDA Pro,使用过程中也发现了伪代码生成逻辑不正确的情况,只能从原始汇编代码手动跟踪。但是大多数场景下伪代码模式可以使用,因为从GDI函数和RS函数入口可分析代码需要做和正在做的事情,从而将整个软件行为串接起来,分析主要的加码和解码函数入手即可。
需求是运行在Windows系统上的打码程序需要移植去你懂的平台,但是程序已经无人维护并且无源代码,只能从逆向入手。
本文作者拿到的DLL可以分为两类,加码和解码。从逆向软件给出的结果来看,软件开发平台以MFC为主,并携带了如CImage.dll和ReedSolomon.dll等辅助动态库,完成图像格式转换和ECC码计算。基本上看该软件原始作者应该是自己实现了如内存管理、XML解析/生成、MD5算法、数据库对接等功能,而不是按照现在编程思想引用各种组件完成。对主要的动态库进行逆向,可以看出主要攻克比特流加码逻辑、解码逻辑、绘制过程、识别过程、纠错算法等方面即可,本文以这些内容的解析和重构过程记录下整个过程,并对关键组件的实现进行描述。由于未取得原软件作者授权,代码实现不会公开,如需讨论请联系本文作者。
1.2 得与失
本文作者之前的逆向仅做过一些小型软件,本次从汇编以及OpenCV现学现卖,收获很大,并且按照要求重构为Java8工程。
可惜在逆向和重构过程中,牙龈感染去看医生被诊断为牙隐裂,可能牙齿保不住了。
2. 数据结构说明
2.1 图像示例
#####11111111111111111##### # #11111111111111111# # # # #55555555555555555# # # # #11111111111111111# # #####11111111111111111##### 111111111111111111111111111 111111111111111111111111111 1111111 1111111 1111111 ########### 1111111 1111111 # # 1111111 1111111 # ####### # 1111111 1111111 # # # # 1111111 1111111 # # # # 1111111 1111111 # # # # 1111111 1111111 # # # # 1111111 1111111 # # # # 1111111 1111111 # ####### # 1111111 1111111 # # 1111111 1111111 ########### 1111111 1111111 1111111 111111111111111111111111111 111111111111111111111111111 #####11111111111111111##### # #11111111111111111# # # # #11111111111111111# # # # #11111111111111111# # #####11111111111111111#####
2.2 图像规范
| 符号 | 规律 |
|---|---|
| # | 黑色 |
| 空格 | 白色 |
| 1 | 数据域 |
| 5 | 明暗相间识别域 |
数据域按照比特的值是1置为黑色,0置为白色。
明暗相间识别域以白-黑的间隔进行填充。这个域是代表此面向上,用于图形的旋转校正。
实际上使用的#以>=3的数字替代,空格以0替代。
3 加码过程
3.1 比特流存储器
假设所有的数据域操作都通过比特流存取器完成。下文以BitStream来表示,并给出操作原语。
public class BitStream {
readonly List<byte> buffer = [];
int writeBytePos = 0;
int writeBitPos = 0;
int readBytePos = 0;
int readBitPos = 0;
public int WriteBytePos => writeBytePos;
public int WriteBitPos => writeBitPos;
public int ReadBytePos => readBytePos;
public int ReadBitPos => readBitPos;
public void Write(byte b, int len) { ... }
public byte Read(int len) { ... }
public void ResetRead() { ... }
public void ResetWrite() { ... }
public void Rewind() { ... }
public void Reset() { ... }
public byte this[int index] {
get {
if (index < 0 || index >= buffer.Count) throw new IndexOutOfRangeException();
return buffer[index];
}
}
3.1.1 Write方法
void Write(byte b, int len);
Write方法将b从高位向BitStream写入len个位。len应当在1至8之间。
同时写指针位置向后移动len,如果该操作需要扩充比特流存取器则自动扩充1个字节。
3.1.2 Read方法
byte Read(int len);
Read方法将len个位写入byte类型,从低往高填充。len应当在1至8之间。
同时读指针位置向后移动len。
3.1.3 ToArray方法
int[] ToArray();
byte[] ToArray();
ToArray方法将当前存放的比特流按照顺序按比特写入数组。提供int[]的返回值主要对接Reed Solomon的纠错API。
3.2 加码算法
加码算法使用3种加码策略:纯数字模式、字典模式、字符模式。对于数字和字符来说,这几种模式加码使用的位数不同,算法使用了带回溯的动态规划算法,使得加码所需的位数最少。(原公司仅使用例如最大/最小连续数字等判断方式,计算的加码模式不一定是最优。)
所有模式均以4个位的模式码开头,并写入每个模式可对应的内容长度。如果模式为0b0000,则加码器结束。
3.2.1 纯数字模式
纯数字模式所需的位最小,优先以纯数字加码。
3.2.2 字典模式
当组内字符串仅包含数字、大写英文字母、空格、$%*+-./:时使用字典模式。
字典模式并未在场景中使用,因为转账支票的类型都是小写字母。故可省略。
3.2.3 字符模式
当组内字符串无法满足纯数字模式和字典模式,只能使用字符模式。
以每个字符的ASCII值写入。
3.2.4 停止模式
加码结束后,写入mode = 0b0000,并填充完成当前所占字节。
3.2.5 ECC
纠错校验算法使用Reed Solomon算法。
伽罗华域配置为0x11d多项式生成,256域大小,0多项式生成因子。
static readonly GenericGF field = new(0x11d, 256, 0);
static readonly ReedSolomonEncoder encoder = new(field);
static readonly ReedSolomonDecoder decoder = new(field);
Java版本代码从zxing实现中拷贝,不采用整个包。C#版本代码使用了STH1123.ReedSolomon库。
该金融机构该场景中使用21个字节生成34个纠错码。
示例是解码过程
bitStream.ResetWrite();
int bitIndex = 0;
foreach (var datum in data) bitStream.Write((byte)((bitIndex++ % 3 == 0) ^ (datum > sample)? 0x80 : 0), 1);
int[] dataAndEcc = new int[TOTAL_SIZE];
for (int i = 0; i < TOTAL_SIZE; i++) dataAndEcc[i] = bitStream[i];
try {
var ecc = decoder.Decode(dataAndEcc, CORRECT_SIZE);
//...
} catch (Exception) {
return "ECC错误";
}
3.2.6 图像绘制
加码完成后,使用BitStream中按位,在SmallMatrix中从左到右,从上到下进行遍历,仅填充当前是1的单元。
图像的位有一个特殊的转置算法,并不像其他算法那样填充。
注意C#使用GDI+有快速图像生成算法,Java就使用BufferedImage和ImageIO。生成图片之后,送给打码机进行打印。
4. 解码
解码过程是加码的逆过程,不在此展开赘述。
5. 识别
原识别算法采用的纯GDI分析,重构后的识别算法使用OpenCV库编写的算法达到快速效果。
读取图片后的灰度化、区域识别、去噪等均是普遍做法。
public static string RecognizeInternal(string file, string expectedPattern = DEFAULT_PATTERN) {
var denoised = Prepare(file, new Rect(50, 20, 150, 140), true);
var detectedRegions = DetectRegionsOptimized(denoised);
var ar = AnnotateBackDegree(denoised, detectedRegions, false);
if (string.IsNullOrEmpty(expectedPattern)) expectedPattern = DEFAULT_PATTERN;
Regex recognizedRegex = new(expectedPattern);
for (int i = 3; i <= 7; i++) {
float percentage = i / 10F;
var recognized = Decode(ar.blackRegions, percentage);
if (!recognizedRegex.IsMatch(recognized)) continue;
return recognized;
}
return "ECC错误";
}
5.1 栅格化数据
将近似正方形区域标记出来后,将宽和高都除以该场景所用的值,对每个区域计算其黑度,即黑色像素占区域的大小。一开始使用的矩形区域,计算出来由于打印机扫描件等原因不是很理想,所以换成圆区域。
区域黑度的阈值从30%-70%依次进行检测,每次递进10%。
首先判断间隔带(即在SmallMatrix中单元为5的格子),如果间隔带不存在则将矩阵逆时针转动90度再进行判断,转动3次还未能定位间隔带,则报错这个图案不是珊瑚码。
for (int row = 0; row < BLOCK_SIZE; row++) {
for (int col = 0; col < BLOCK_SIZE; col++) {
Point point = new((int)(Math.Ceiling(horX1 + col * gapX + gapX / 2)), (int)(Math.Ceiling(verY1 + row * gapY + gapY / 2)));
int radius = (int)(Math.Min(gapY, gapX) / 2);
float p = BlackPixelRateInCircle(mat, point, radius);
ar.matrix[row][col] = p;
}
}
5.2 解码
不赘述。根据BitStream中的内容结合ECC进行解码,解码函数提供了一个预期正则,可辅助判断是否解码成功。
6. 移植到UOS
后记:有需求需要将该逻辑移植到UOS客户端使用,并且调用者是纯C/C++写的客户端。不能在USO终端安装JRE,所以得使用一个非Java解决方案。于是就想到了使用NET8+AOT来进行移植。
6.1 Emgu.CV vs OpenCVSharp
原先使用C#+RoslynPad写了一个可以跑的原型,没什么难度就将逻辑移植过来了。不过一开始使用了Emgu.CV发现其中封装的函数比较好,但是发现Emgu.CV的License是Dual License:LGPLv3+商业授权,每一种授权都不是友好,于是又将部分函数使用OpenCVSharp4的API代替。构建的时候发生了问题:纯类库不可以交叉编译。
6.2 在Linux下编译C#程序
那么在UOS下安装了NET8 SDK,将工程拷贝过去,开启AOT。得到两个so,其中一个是libOpenCVSharpExtension.so,是OpenCV的桥接动态库。兴冲冲的写了一个Python脚本验证一下,结果说这个动态库无法加载。粗略的找了一下原因发现是一些系统库没装,逐一安装后发现libIlmImf-2_5.so.25在UOS的源上没有。难道要自己编译安装,ldd以后发现更加欲哭无泪的事情,libOpenCVSharpExtension.so依赖的glibc版本太高。编译组件几乎是不可能的事情了。
6.3 柳暗花明
OpenCVSharp官网上说可以在Linux下自己编译获得libOpenCVSharpExtension.so,但是也遇到了麻烦,一些头文件的位置不正确,预感到修改opencv的版本后续会带来更加复杂的问题。
结果在github上以libOpenCVSharpExtension查找之后,果然找到了一个库在glibc 2.2.8下可以使用。在nuget上引用后,下载到了一个30M左右的动态库,而且没有缺少依赖库。然后Python程序也可以跑起来了。记录一下暴露函数以及在Linux下跑和C下跑所使用的方法。
[UnmanagedCallersOnly(EntryPoint = "Recognize", CallConvs = new[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })]
public static unsafe int recognize_text(byte* filePath, byte* outputBuffer, int bufferSize) {
try {
string? file = Marshal.PtrToStringUTF8((nint)filePath);
if (string.IsNullOfEmpty(file)) return -1;
string result = RecognizeInternal(file);
byte[] utf8 = Encoding.UTF8.GetBytes(result);
if (utf8.Length >= bufferSize) return -2;
fixed (byte* src = utf8)
Buffer.MemoryCopy(src, outputBuffer, bufferSize, utf8.Length);
outputBuffer[utf8.Length] = 0;
return utf8.Length;
} catch {
return -3;
}
}
import ctypes
import os
lib = ctypes.CDLL("CoralCodeHelper.so")
# 设置函数签名
lib.Recognize.argtypes = [ ctypes.c_char_p, ctypes.POINTER(ctypes.c_char), ctypes.c_int ]
lib.Recognize.restype = ctypes.c_int
def recognize(file_path: str) -> str:
input_bytes = file_path.encode('utf-8')
buffer_size = 128
output_buffer = ctypes.create_string_buffer(buffer_size)
result = lib.Recognize(input_bytes, output_buffer, buffer_size)
if result < 0:
raise RuntimeError(f"Recognition failed with error code: {result}")
return output_buffer.value.decode('utf-8')
try:
text = recognize("1.jpg")
print("Result:", text)
except Exception as e:
print("Error:", e)
/*调用方法*/
typedef int (*FUNC)(const char*, char*, int);
void* handle = dlopen("CoralCodeHelper.so", RTLD_LAZY);
FUNC recognize = (FUNC)dlsym(handle, "Recognize");
char output_buffer[128];
int result = recoginze(图片文件路径, output_buffer, sizeof(output_buffer));
dlclose(handle);
/*
result:-1 文件路径为空,-2 缓冲区过小,-3其他异常
>0: 实际识别文本长度
*/

浙公网安备 33010602011771号