字符分割
这一篇主要讨论如何利用图像处理方法分割银行卡号字符。
由上一篇的结果我们可以得到完整银行卡号条目的图像,假如不加入序列识别能力的神经网络 (LSTM 等),那么就需要对这一卡号字符序列进行切分,再使用一般的分类器识别出单个数字 (0-9)。但这里包含两种类型的字体,凹凸卡号代表的是 Farrington-7B,是用于信用卡的一种特殊字体;平面卡号是普通的印刷体。所以分割得到单个图像数字后,要提供这两种类型的图像数字训练数据给分类器做训练,这一内容放到下一篇 (3) 去讲。
给出我们的卡号定位原图
字符分割前的预处理
字符分割难就难在凹凸浮雕类型字符的分割,如果是平面印刷字符可以用局部或固定阈值法处理,效果很好,背景噪声几乎没有,不论光照强弱。
下图展示了采用全局固定阈值法进行二值化后的效果。
而浮雕类的卡号效果就天壤之别了..对比一下
所以对于浮雕字体我采用了特殊的处理方法,是他们尽量变得粗大,至于粘合的字符,后面可以使用相对应算法作分割。
同样的,灰度化,简化图像信息,得到 dst。
由 dst 依次通过 Top-hat 变换、形态学梯度、OTSU 二值化,得到 dst0。
由 dst 依次通过 Black-hat 变换、形态学梯度、中值滤波、OTSU 二值化得到 dst1。
dst0 与 dst1 都是 0 或 1 二值图像,每一点像素做或预算(加法),得到 dst2。
最后 dst2 执行闭操作和膨胀处理。
以上代码
public class CVRegion extends ImgSeparator {
....
private CardFonts.FontType fontType;
....
public void digitSeparate() throws Exception {
Mat binDigits = new Mat(grayMat, getRectOfDigitRow()).clone();
/*** CardFonts 判断字体类型
* 包含三种 UNKNOWN, LIGHT_FONT, BLACK_FONT
*/
CardFonts fonts = CVFontType.getFontType(binDigits);
CardFonts.FontType type = fonts.getType();
// 浮雕类型字体
if (type == CardFonts.FontType.LIGHT_FONT) {
Mat sqKernel = Imgproc.getStructuringElement(
Imgproc.MORPH_RECT, new Size(5, 5));
Mat dst0 = new Mat();
Imgproc.morphologyEx(binDigits, dst0, Imgproc.MORPH_TOPHAT,
sqKernel);
Imgproc.morphologyEx(dst0, dst0, Imgproc.MORPH_GRADIENT,
sqKernel);
Imgproc.threshold(dst0, dst0, 0, 255, Imgproc.THRESH_BINARY |
Imgproc.THRESH_OTSU);
Imgproc.medianBlur(dst0, dst0, 3);
Mat dst1 = new Mat();
Imgproc.morphologyEx(binDigits, dst1, Imgproc.MORPH_BLACKHAT,
sqKernel);
Imgproc.morphologyEx(dst1, dst1, Imgproc.MORPH_GRADIENT,
sqKernel);
Imgproc.medianBlur(dst1, dst1, 3);
Imgproc.threshold(dst1, dst1, 0, 255, Imgproc.THRESH_BINARY |
Imgproc.THRESH_OTSU);
Core.bitwise_or(dst0, dst1, dst1);
Imgproc.morphologyEx(dst1, dst1, Imgproc.MORPH_CLOSE,
Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3)));
Imgproc.dilate(dst1, binDigits,
Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(1, 5)));
}
if (type == CardFonts.FontType.BLACK_FONT ||
type == CardFonts.FontType.UNKNOWN) {
binDigits = fonts.getFonts();
}
this.binDigitRegion = binDigits;
this.fontType = type;
// 先忽略
setSingleDigits();
}
}
利用 Top-hat 和 Black-hat 变换可以突出图中较亮和较暗区域的特征,这使得浮雕数字的受光侧和背光侧在梯度变换中能被更好地感知,增强其边缘特征,从而降低了背景纹理噪声的影响。
得到边缘轮廓的特征就能描述字符所在区域了.这时候使用阈值分割算法对图像进行二值化,采用固定阈值法还是局部阈值化方法这是一个问题,为了对比它们之间的效果,我在这里放了两张图。
比较 adaptive 和 OTSU 可以看出,虽然 adaptive 能将字符较完整地描绘出来,但引入了许多椒盐噪点,即使使用滤波器也不能很好地去除。这会给我后面的字符切割增加很多困难,因为这样的背景噪声是千变万化的,它们干扰了算法对字符区域的判断。
OTSU 的效果很好,几乎可以除去背景噪声的干扰。数字没有被完整刻画出来没有关系,我们需要的是它的大概位置,只要给出部分信息即可。但同样可以在这基础上进行强化,也就是增加较暗区域特征,再利用二值图加法操作对其进行”补充“。
增强后的数字特征更为完整,再利用膨胀操作可以减少字符的断裂,避免一个数字一分为二,对分割造成困扰。
分割算法
- 竖直坐标投影法
坐标投影法是众多字符分割研究方法中较为简单高效的一种。它主要用于二值图中字符无粘连或粘连部位较薄弱、背景噪声较细微的场景。
该方法将二值图同一纵坐标上白色像素点进行累加,x方向上逐个记录累计值,用公式表示为:
其中 H 为图像高度,G(x, y) 代表 (x, y) 处像素值.
这样一来就构成了横坐标位置-纵坐标白色像素数目的二维对应关系图,也即投影的散点图。在噪声干扰较小或字符粘连较轻微的情况下,字符区域的白色像素会明显多于字符间隙中的白色像素。
这投射到散点图上便会形成波峰与波谷。波谷区域就是字符间的空隙区,相邻的波谷间为单个字符区域。但若是字符间的间隙存在过大的斑点噪声,或字符间粘连较重,投影图中将难以分辨波峰与波谷,也即无法分辨出单个字符区域与字符的间隙区域。所以,单纯的坐标投影对银行卡号分割,特别是凹凸类字符的分割效果十分不理想。
-
连通域分割法
通过标记二值图中的连通域,提取其外接矩形轮廓,便能分离出单个卡号字符。如果出现的噪声都是远小于字符的椒盐噪声,就可以在提取矩形轮廓后依靠矩形宽高加以区分。
难点分析
图像中的数字偶尔会发生断裂的情况,这些算法处理不了断裂的字符。断裂使得算法将字符的一小部分当作成一个整体来看,这是不合理的。其次还有字符粘连的问题,将多个字符当作成一个整体。改进算法
为了解决断裂和粘连问题,可以利用卡号数字大小一致的特点做判断和整合。
如果有先验条件,已知字体的宽度,那么遇到宽度过小的区域,可以认为是断裂造成的,将其左右部分结合,整合后的区域若宽度在指定范围内,则完成该字符的分割。这里称之为窗口分割。
窗口的宽度大小取决于连通域的宽度中方差最小的一组宽度的平均值(最小方差法),设为字体的已知宽度。在完成一个字符的分割后,向右移动继续进行后续的分割。在所有可行的整合方案内,找到宽度方差最小的作为最终的字符整合方案。这就解决了断裂问题。
至于粘连问题也是如法炮制。如果宽度过大,则在字符内的合理区间中找到最薄弱的位置进行左右切分,将粘连字符一分为二。这里不采用最优解。
- 代码实现
public class CVRegion extends ImgSeparator {
....
public void setSingleDigits() throws Exception {
if (fontType == CardFonts.FontType.BLACK_FONT) {
// 印刷类字体不需要使用窗口分割法
return;
}
// 垂直坐标投影,获得每一个 x 坐标映射数量
int []x = calcHistOfXY(binDigitRegion, true);
// 添加每一个 x 坐标作链表节点
int cur = 0;
List<Integer> cutting = new LinkedList<>();
if (x[cur] > 0)
cutting.add(cur);
while (true) {
int next = findNext(x, cur);
if (next >= x.length)
break;
cutting.add(next);
cur = next;
}
// 最小方差法获得窗口宽度
int ref = getDigitWidth(cutting);
if (ref < 0)
return;
SplitList splitList = new SplitList(cutting, ref);
/**
* 先分割粘连字符,后合并断裂字符
*/
split(splitList);
final int upperWidth = (int)(1.2f * ref);
final int lowerWidth = (int)(0.6f * ref);
// remove Node that is a complete digit before merging
SplitList output = splitList.out(upperWidth, lowerWidth);
// crack into several fragment to merge into a complete digit
List<SplitList> buckets = splitList.crack(upperWidth);
for (SplitList elem : buckets) {
// 整合单一字符
merge(elem);
output.addAll(elem.toNodeList());
}
// sort Nodes by its id, ensure the origin order of card numbers
output.sort();
/**
定位到每个数字所在位置后,将它们拷贝到新 Mat 数组中存放
*/
paintDigits(output.toSimpleList());
}
public []int calcHistOfXY(Mat m, boolean axisX) {
int []calc;
int rows = m.rows();
int cols = m.cols();
byte buff[] = new byte[rows * cols];
m.get(0, 0, buff);
if (axisX) {
calc = new int[cols];
for (int i = 0; i < cols; i++) {
for (int j = 0; j < rows; j++)
calc[i] += (buff[i + j * cols] & 0x1);
}
} else {
calc = new int[rows];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++)
calc[i] += (buff[i * cols + j] & 0x1);
}
}
return calc;
}
// 分割粘连的字符
public void split(SplitList splitList) {
int rows = binDigitRegion.rows();
int cols = binDigitRegion.cols();
byte buff[] = new byte[cols * rows];
binDigitRegion.get(0, 0, buff);
int upperWidth = (int)(1.38f * splitList.getStandardWidth());
int lowerWidth = (int)(0.8f * splitList.getStandardWidth());
int window = upperWidth - lowerWidth;
for (int i = 0; i < splitList.size(); i++) {
SplitList.Node node = splitList.get(i);
if (node.width() > upperWidth) {
int x = node.getStartPointX() + lowerWidth;
int spx = splitX(buff, x, x + window);
if (spx > 0) {
splitList.split(i, spx);
}
}
}
}
public void merge(SplitList splitList) throws Exception {
int min = Integer.MAX_VALUE;
String solution = "";
System.err.println("merge size: " + splitList.size());
if (splitList.size() > 10) {
throw new Exception("CVRegion error: splitList.size() is too large and over time limit to merge in function merge(SplitList spl).");
}
List<String> box = new ArrayList<>();
permutations(splitList.size(), 0, "", box);
for (int i = 0; i < box.size(); i++) {
String s = box.get(i);
int splIndex = 0;
int score = 0;
for (int j = 0; j < s.length(); j++) {
int val = s.charAt(j) - '0';
int distance = splitList.dist(splIndex, splIndex +val -1);
splIndex += val;
score += Math.abs(distance -splitList.getStandardWidth());
}
if (score < min) {
min = score;
solution = s;
}
}
for (int c = 0, spl = 0; c < solution.length(); c++) {
int val = solution.charAt(c) - '0';
splitList.join(spl, spl + val - 1);
spl++;
}
}
private int splitX(byte []buff, int si, int ei) {
int max = 0;
int index = 0;
int rows = binDigitRegion.rows();
int cols = binDigitRegion.cols();
for (int x = si; x <= ei; x++) {
int len = 0;
for (int y = 0; y < rows; y++)
if (buff[y * cols + x] == 0)
len++;
if (max < len) {
max = len;
index = x;
}
}
return index;
}
....
}
上面 merge 方法中的 permutations 我要说明一下,这是一个排列组合的做法,也就是说现在需要找到合理的合并方式,现在有 n 个节点(字符块),需要找到它们所有的组合方式。当然,只有是相邻的节点之间能组合在一起。
但这样枚举的话复杂度很高,需要进行剪枝。我们考虑字符最多断裂成 3 块,如果真的出现,其实也代表着二值化效果很差,继续处理没有什么意义,当然这样的情况很罕见。所以,不考虑合并 >3 块的情况,这样可以大大缩小解空间。
private void permutations(int total, int n, String solution, List<String> box) {
if (total < n)
return;
if (total == n) {
box.add(solution.substring(1) + n);
return;
}
solution += n;
permutations(total - n, 3, solution, box);
permutations(total - n, 2, solution, box);
permutations(total - n, 1, solution, box);
}
继续给出前面涉及的方法代码
public abstract class ImgSeparator implements RectSeparator, DigitSeparator{
....
protected int findNext(int v[], int index) {
int i;
boolean get = false;
if (index < v.length && v[index] != 0)
get = true;
for (i = index; i < v.length; i++) {
if (get && v[i] == 0)
break;
if (!get && v[i] != 0)
break;
}
return i;
}
/**
* descending sort
* @param a array with index and value
*/
private void sortMap(int [][]a) {
for (int i = 0; i < a[1].length - 1; i++) {
int k = i;
for (int j = i + 1; j < a[1].length; j++) {
if (a[1][k] < a[1][j]) {
k = j;
}
}
if (k != i) {
swap(a[0], i, k);
swap(a[1], i, k);
}
}
}
/**
* get the average width of id region digits
* @param cutting
* @return
*/
protected int getDigitWidth(List<Integer> cutting) throws Exception {
if ((cutting.size() & 0x1) == 1) {
System.err.println("ImgSeparator error: cutting.size() cannot be odd number in function getDigitWidth(List<Integer> c");
cutting.remove(cutting.size() - 1);
}
final int window = 5;
int [][]width = new int[2][cutting.size() >> 1];
if (width[0].length <= window) {
return -1;
}
for (int i = 1, j = 0; i < cutting.size(); i+= 2, j++) {
width[1][j] = cutting.get(i) - cutting.get(i - 1);
width[0][j] = j;
}
sortMap(width);
int ms = -1;
float m = Float.MAX_VALUE;
int sum = 0;
for (int i = 0; i < window; i++)
sum += width[1][i];
for (int i = window; i < width[0].length; i++) {
float diff = 0;
if (i > window)
sum += (- width[1][i - window - 1] + width[1][i]);
float avg = sum / window;
for (int j = 0; j < window; j++) {
diff += Math.pow(width[1][i - j] - avg, 2);
}
// get the min square difference
if (diff < m) {
ms = i - window;
m = diff;
}
}
int corrWidth = 0;
for (int i = window; i > 0; i--)
corrWidth += width[1][ms + i - 1];
return corrWidth / window;
}
protected void paintDigits(List<Integer> cuttingList) {
for (int i = 1; i < cuttingList.size(); i++) {
if ((i & 0x1) == 0)
continue;
int x1 = cuttingList.get(i - 1);
int x2 = cuttingList.get(i);
Mat crop = new Mat(grayMat, new Rect(x1 + rectOfDigitRow.x,
rectOfDigitRow.y, x2 - x1, rectOfDigitRow.height));
byte buff[] = new byte[crop.rows() * crop.cols()];
crop.get(0, 0, buff);
Mat dst = Mat.zeros(new Size(rectOfDigitRow.width,
rectOfDigitRow.height), grayMat.type());
byte out[] = new byte[dst.cols() * dst.rows()];
for (int j = 0; j < crop.rows(); j++)
System.arraycopy(buff, j * crop.cols(), out, j *dst.cols(),
crop.cols());
dst.put(0, 0, out);
dst = CVGrayTransfer.resizeMat(dst, 380);
matListOfDigit.add(dst);
}
}
....
}
这里实现了特殊的数据结构链表 SplitList,用于断裂和合并节点。
import java.util.*;
/**
* Created by chenqiu on 3/9/19.
*/
public class SplitList {
private List<Node> sList;
private int standardWidth;
public SplitList(){
this.sList = new LinkedList<>();
standardWidth = 0;
}
public SplitList(List<Integer> cutting, int standardWidth) {
this.sList = new LinkedList<>();
int id = 0;
for (int i = 0; i < cutting.size(); i += 2) {
Node n = new Node(id, cutting.get(i), cutting.get(i + 1));
id += 20;
sList.add(n);
}
this.standardWidth = standardWidth;
}
public SplitList(SplitList copy, int start, int end) {
this.sList = new LinkedList<>();
for (; start <= end; start++) {
Node n = copy.get(start).clone();
this.sList.add(n);
}
this.standardWidth = copy.standardWidth;
}
public int getStandardWidth() {
return standardWidth;
}
public class Node {
private int id;
private int width;
private int x1;
private int x2;
public Node(int id, int x1, int x2) {
this.id = id;
this.x1 = x1;
this.x2 = x2;
this.width = x2 - x1;
}
public int width() {
return width;
}
public int getStartPointX() {
return x1;
}
public int getEndPointX() {
return x2;
}
public int id() {
return id;
}
public Node clone() {
return new Node(this.id, this.x1, this.x2);
}
public String toString() {
return "[Node: [id: " + id + ", x1: " + x1 + ", x2: " + x2 +
"]]";
}
}
/**
* division of itself
* @param gap
* @return
*/
public List<SplitList> crack(int gap) {
List<SplitList> rstSet = new ArrayList<>(30);
Node cur, next = sList.get(0);
int start = 0;
for (int i = 0; i < sList.size() - 1; i++) {
cur = next;
next = sList.get(i + 1);
if (next.x2 - cur.x1 > gap) {
rstSet.add(rstSet.size(), new SplitList(this, start, i));
start = i + 1;
}
}
rstSet.add(rstSet.size(),new SplitList(this,start,sList.size()-1));
this.sList = null; // release this
return rstSet;
}
/**
* split the sticky digits character in one Node
* @throws Exception
*/
public void split(int index, int x){
Node loc = sList.get(index);
sList.add(index + 1, new Node(loc.id + 1, x + 1, loc.x2));
loc.x2 = x;
// update width
loc.width = loc.x2 - loc.x1;
}
public void join(int si, int ei) {
if (si >= ei)
return;
Node prev = sList.get(si);
Node last = null;
int count = ei - si;
for (int i = 0; i < count; i++) {
last = sList.remove(si + 1);
}
prev.x2 = last.x2;
prev.width = prev.x2 - prev.x1;
}
public int size() {
return sList.size();
}
public void sort() {
Collections.sort(this.sList, new Comparator<Node>() {
@Override
public int compare(Node o1, Node o2) {
return o1.id - o2.id;
}
});
}
public Node get(int index) {
return sList.get(index);
}
public void addAll(List<Node> nodeList) {
for (Node node : nodeList) {
sList.add(node);
}
}
public int dist(int si, int ei) {
if (ei <= si)
return 0;
return sList.get(ei).x2 - sList.get(si).x1;
}
/**
* remove Nodes have been merged, and clear fragments at the same time
* @param window
* @param lowerWidth the thresh whether it should be regarded as fragments
* @return
*/
public SplitList out(int window, int lowerWidth) {
SplitList rstList = new SplitList();
Node cur, next, prev = null;
cur = sList.get(0);
next = sList.get(1);
int i;
for (i = 1; i < sList.size() - 1; ) {
prev = cur;
cur = next;
next = sList.get(i + 1);
if (cur.x2 - prev.x1 > window && next.x2 - cur.x1 > window) {
sList.remove(i);
if (lowerWidth < cur.width())
rstList.sList.add(cur);
continue;
}
i++;
}
if (cur.x2 - prev.x1 > window && next.x2 - cur.x1 > window) {
// second Node to last
sList.remove(i);
if (lowerWidth < next.width())
rstList.sList.add(next);
}
rstList.standardWidth = this.standardWidth;
return rstList;
}
public List<Node> toNodeList() {
return this.sList;
}
public List<Integer> toSimpleList() {
List<Integer> rstList = new ArrayList<>();
for (int i = 0; i < sList.size(); i++) {
Node node = sList.get(i);
rstList.add(node.x1);
rstList.add(node.x2);
}
return rstList;
}
}
字符分割完成!!
接下来来看看分割效果如何~
public class CardOCR {
....
public static void main(String []args) {
String fileName= "/Users/xxx.jpg";
Mat gray = CVGrayTransfer.grayTransferBeforeScale(fileName);
Producer producer = new Producer(gray);
// 定位卡号矩形区域
Rect mainRect = producer.findMainRect();
// 设置矩形区域
producer.setRectOfDigitRow(mainRect);
List<Mat> normalizedImg = null;
try {
producer.digitSeparate();
Mat dst = producer.getMatListOfDigit().get(0).clone();
if (producer.getFontType()==CardFonts.FontType.LIGHT_FONT){
normalizedImg = resizeDataSetImg(
producer.getMatListOfDigit());
// 分割后的字符竖直拼接成一张图
Core.vconcat(normalizedImg, dst);
}
// imshow("digits", dst);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
大小归一化
*/
public static List<Mat> resizeDataSetImg(List<Mat> set) {
List<Mat> rstList = new ArrayList<>();
for (Mat m : set) {
rstList.add(CVGrayTransfer.resizeMat(m, standardWidth,
(int)(standardWidth * aspectRation)));
}
return rstList;
}
}
可能有小伙伴注意到没有给出如何判断字体类型的方法,我们来接着往下看吧!《Java + OpenCV 实现银行卡号识别 (3)》