引言

一小伙伴突然问我想不想换一种工作方式,我当时有些懵逼。后来听他介绍才知道是让我帮忙标记图片验证码上的文字及其位置,用作训练图像识别系统的素材。
我脑子里面就在想训练识别系统得要多少张图片才行啊,那么多靠人工去打标签根本不是我们两个人能完成的啊。
我有些不愿意,他就问我有什么想法,于是我就思考能不能跳过人工打标签这个过程,而且还能拥有一大堆训练素材呢。
于是,我就想是否可以通过随机生成训练素材,将生成素材时传入的参数记录下来,就可以形成一个素材的标签了。
小伙伴用的python,但是我对其并不是太熟,我最熟悉的还是Java,所以就有了本文中的产物。

准备工作

我找小伙伴要了一些训练素材,观察这些素材的特征。比如文字位置、字体、大小等信息。下面是小伙伴给我的一张素材:

f800db1644434c9bb06eb923ad790d98-4ebc867574cd4413b979c80b928a0b86.png

从这些素材中我大概归纳出这些素材用到的字体:
- 华文中宋
- 华文楷体
- 方正舒体
……

素材中的文字大小并不总是一样大,是在一个区间范围内随机变化;图片中文字位置也是随机出现;所有文字颜色都是主体黑色加上白色描边。

下面,我们将开始就这些特征进行开发编码。

开发

最初的想法就是分三个类:
2379db58893c4730b5f1a248bac48266-20181214192748.png

  • Word 图片中的每一个文字的具体信息,初始化时主要生成文字具体内容、确定字体、大小等。
  • Words 图片中所有文字的集合,将所有文字视为一个整体,然后统一确定每个文字的位置。
  • Drawer 主要是将Words中文字添加的指定的背景图片中去。如果有需要,还可以根据给定的背景图片,已经Words中每个文字的内容,进行动态的确定文字颜色。

随机中文字符生成——Word

该类主要用于生成一个随机中文字符,并初始化该文字的字体、大小、颜色等。同时进行计算该文字展示时的长度与宽度。

该类用于取值的中文字符库来源于Unicode码中的中文字符,采用取随机数的方式获取随机中文字符的编号,并转换为具体字符。

该类中所使用的字体来源于window系统中的所有默认中文字体。

package com.example.testimages.draw;

import lombok.Data;
import org.springframework.util.ResourceUtils;

import java.awt.*;
import java.awt.geom.Rectangle2D;
import java.io.*;
import java.util.ArrayList;
import java.util.Random;

/**
 * @author lethe
 * @version demo@2018.12.12
 */
@Data
public class Word {
    private static final Random RANDOM = new Random();
    // 保存系统当前读取到的字体集合
    private static final java.util.List<Font> FONTS = new ArrayList<>();
    // 中文字符Unicode开始位置
    private static final int CHAR_START = 0x4e00;
    // 中文字符Unicode结束位置
    private static final int CHAR_END = 0x9fa5;

    // 初始化字体库, 如果在字体库中未找到字体,则添加默认字体(黑体,加宽,40号)
    static {
        try {
            File root = ResourceUtils.getFile("classpath:font");
            File[] files = root.listFiles((dir, name) -> name.endsWith("ttf") || name.endsWith("TTF"));
            if(null != files) {
                for (File file : files) {
                    FileInputStream fis = new FileInputStream(file);
                    Font dynamicFont = Font.createFont(Font.TRUETYPE_FONT, fis);
                    FONTS.add(dynamicFont);
                }
            }else{
                throw new Exception("未读取到任何字体");
            }
        } catch (Exception e) {
            FONTS.add(new Font("黑体",Font.BOLD,40));
        }
    }


    // 字符
    private char ch;
    // 字体
    private Font font;
    // 大小
    private int size;
    // 文字图像的宽度
    private int width;
    // 文字图像的高度
    private int height;
    // 文字图像与底图左边距离
    private int left;
    // 文字图像与底图上边距离
    private int top;
    // 文字颜色
    private String color;

    /**
     * 随机生成word实例
     * 该随机实例将初始化文字内容、字体、大小
     *
     * @return 随机文字对象
     */
    public static Word random() {
        Word word = new Word();
        word.setCh(randomChar());
        // 随机大小
        word.setSize(40 + RANDOM.nextInt(40));
        Font font = getFont(word.getCh());
        word.setFont(font.deriveFont(Font.BOLD, word.getSize()));
        word.setColor("#000000");
        
        // 计算文字图像的长和宽
        FontMetrics metrics = new FontMetrics(word.getFont()) {
        };
        Rectangle2D bounds = metrics.getStringBounds(word.getCh() + "", null);
        word.setWidth((int) bounds.getWidth());
        word.setHeight((int) bounds.getHeight());
        
        return word;
    }

    /**
     * 为当前字符随机分配字体
     * 需要判断当前字体是否支持该字符的展示,
     * 如果支持则采用递归的方式重复分配,一直分配到可以展示为止
     * @param ch 需要展示的字符
     * @return 该字符的字体
     */
    private static Font getFont(char ch) {
        Font font = FONTS.get(RANDOM.nextInt(FONTS.size()));
        if (font.canDisplay(ch)) {
            return font;
        } else {
            return getFont(ch);
        }
    }

    /**
     * 随机从中文字符库中获取一个中文字符
     * @return 随机中文字符
     */
    private static char randomChar() {
        int re = CHAR_START + RANDOM.nextInt(CHAR_END - CHAR_START);
        return (char) re;
    }
}

验证码中文字符序列生成——Words

该类主要用于生成验证码上的中文字符序列,并对每个字符随机设置相应的位置参数。

60d93b9249994312bffcb9b7e7f5944e-test.png

该类中主要逻辑是为每个字符生成位置参数,并且需要判断字符放置在这个位置是否会与其他字符发生边界重叠问题。上图对两个矩形发生边界重叠所需条件进行了分析,可用于代码中判断两个字符是否出现遮挡。

package com.example.testimages.draw;

import lombok.Data;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

/**
 * @author lethe
 * @version demo@2018.12.12
 */
@Data
public class Words {
    private static final Random random = new Random();

    // 用于保存当前验证码中的所有字符对象
    private List<Word> words;
    // 用于保存验证码图片生成路径
    private String outPath;

    /**
     * 按照参数生成验证码的中文字符序列
     *
     * @param count 验证码中的中文字符数
     * @param maxHeight 验证码底图高度
     * @param maxWeight 验证码底图宽度
     * @return 中文字符序列,所有字符均已初始化字体、大小、颜色、位置参数
     */
    public static Words random(int count, int maxHeight, int maxWeight){
        Words words = new Words();
        words.words = new ArrayList<>(count);
        for (int i = 0; i < count; i++) {
            words.setPositionAndSave(Word.random(), words.words, maxHeight, maxWeight);
        }
        return words;
    }

    /**
     * 为新的字符设置位置参数,并加入到字符序列中去
     * 参数设置采用先生成随机位置参数,
     * 然后计算如果把文字放置在该位置是否会发生遮挡,
     * 如果会则采用递归的方式重新生成
     *
     * @param word 新生成的随机中文字符
     * @param wordList 已经生成位置参数的字符序列
     * @param maxHeight 验证码底图高度
     * @param maxWidth 验证码底图宽度
     */
    private void setPositionAndSave(Word word, List<Word> wordList, 
                                        int maxHeight, int maxWidth){
        // 生成随机位置参数,该位置参数所指定的是文字图像左上角的位置
        // 为防止文字图像超出边界无法完全显示,所以需要在底图的高宽上减去文字图像的高宽
        // 此处生成的位置参数均为正数,所以无需在后面再进行非负判断
        int rH = random.nextInt(maxHeight-word.getHeight());
        int rW = random.nextInt(maxWidth-word.getWidth());

        // 循环遍历已设置位置参数的中文字符列表,判断是否发生遮挡
        // 一旦出现遮挡,则跳过后续判断,直接递归重新生成位置参数
        boolean flag = true;
        for (Word w : wordList) {
            // 判断两个文字的边界是否有重叠部分
            if(rH>w.getTop()-word.getHeight() && rH<w.getTop()+w.getHeight()
                    && rW>w.getLeft()-word.getWidth() && rW<w.getLeft()+w.getWidth()){
                flag = false;
                break;
            }
        }
        if(flag) {
            word.setTop(rH);
            word.setLeft(rW);
            wordList.add(word);
        }else{
            setPositionAndSave(word, wordList, maxHeight, maxWidth);
        }
    }
}

验证码生成——Drawer

该类主要使用使用Java的Image图像库,将已经生成的随机中文序列添加到底图上。

该类重点问题有两个:
- 文字图像定位问题:每添加一个文字,都需要将光标还原到底图的左上角,否则将会一直累加下去,剩下的文字都将超出底图边界,不能完全显示。
- 文字图像存在基线(baseLine):本类中采用简单方法处理,即认定文字图像底部为基线,所以每次定位的top需要额外再减去一个文字的高度。

package com.example.testimages.draw;

import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.font.GlyphVector;
import java.awt.image.BufferedImage;
import java.io.FileOutputStream;

/**
 * @author lethe
 * @version demo@2018.12.13
 */
public class Drawer {
    /**
     * 在指定的底图上添加随机中文字符序列
     * @param count 中文字符数
     * @param inPath 底图路径
     * @param outPath 验证码生成路径
     * @return 包含验证生成路径、中文字符序列中每个中文的具体信息的Words对象
     */
    public static Words draw(int count, String inPath, String outPath){
        ImageIcon imgIcon = new ImageIcon(inPath);
        Image img = imgIcon.getImage();
        int width = img.getWidth(null);
        int height = img.getHeight(null);
        BufferedImage bufferedImage = new BufferedImage(width, height,
                BufferedImage.TYPE_INT_RGB);
        Words words = Words.random(count, height, width);
        Graphics2D g = bufferedImage.createGraphics();
        g.setBackground(Color.white);
        g.drawImage(img, 0, 0, null);

        // 用于记录每个字符放置后的位置
        int y = 0;
        int x = 0;

        // 循环向底图中输出中文字符序列中的字符
        for (Word word : words.getWords()) {
            // 将光标重新设定到图像的左上角
            g.drawString("",0,0);
            g.setColor(getColor(word.getColor()));
            GlyphVector v = word.getFont().createGlyphVector(g.getFontMetrics().getFontRenderContext(), word.getCh()+"");
            Shape shape = v.getOutline(0, 0);
            Rectangle bounds = shape.getBounds();
            // 减去上一个字符的位置数据,然后还需要减去文字图像`baseLine`的数据
            g.translate(word.getLeft()-bounds.x-x, word.getTop()-bounds.y-y);
            // 设置当前位置
            x = word.getLeft()-bounds.x;
            y = word.getTop()-bounds.y;
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g.fill(shape);
            g.setColor(Color.WHITE);
            g.setStroke(new BasicStroke(2));
            g.draw(shape);
        }
        g.dispose();

        // 图像文件输出
        try {
            FileOutputStream out = new FileOutputStream(outPath);
            ImageIO.write(bufferedImage, "JPEG", out);
            out.close();
            words.setOutPath(outPath);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return words;
    }

    /**
     * 将颜色字符串转换未Color对象
     * @param color 颜色字符串,如:#FFFFFF
     * @return Color对象
     */
    private static Color getColor(String color) {
        if (color.charAt(0) == '#') {
            color = color.substring(1);
        }
        if (color.length() != 6) {
            return null;
        }
        try {
            int r = Integer.parseInt(color.substring(0, 2), 16);
            int g = Integer.parseInt(color.substring(2, 4), 16);
            int b = Integer.parseInt(color.substring(4), 16);
            return new Color(r, g, b);
        } catch (NumberFormatException nfe) {
            return null;
        }
    }

}

测试

  • 测试代码
public static void main(String[] args) {
        String sourcePath = "C:/Users/zengh/Desktop/hanmeis/test2.jpg";
        for (int i = 0; i < 10; i++) {
            String outPath = "d:\\" + i + ".jpg";
            Words draw = Drawer.draw(5, sourcePath, outPath);
            System.out.println(draw);
        }
    }
  • 测试结果

578f9f55b7e543e896922d114e97d2f2-20181214191707.png

Words(
	outPath=d:\0.jpg, 
	words=[
		Word(ch=牀, font=java.awt.Font[family=等线,name=等线 Bold,style=bold,size=64], size=64, width=64, height=66, left=282, top=330, color=#000000), 
		Word(ch=婦, font=java.awt.Font[family=幼圆,name=幼圆,style=bold,size=62], size=62, width=65, height=68, left=1057, top=209, color=#000000), 
		Word(ch=阡, font=java.awt.Font[family=华文中宋,name=华文中宋,style=bold,size=52], size=52, width=55, height=68, left=893, top=186, color=#000000), 
		Word(ch=惇, font=java.awt.Font[family=华文仿宋,name=华文仿宋,style=bold,size=48], size=48, width=51, height=62, left=286, top=142, color=#000000)
	]
)

——————————————————————————
行路不知花开处,蓦然回首芷兰香。