发票网爬虫
介绍
做这个项目的灵感,来自于我任职的上一家公司的一个OA流程的报销需求,那时候公司在大力推广自研 OA,使用 OA 流程替换以前的邮件通知(当时我们的报销流程是直接通过线上邮件的方式发送给上级,由上级统一审批之后再通过邮件往下走,在使用了 OA 流程之后,这个过程变得很简单)。 当时做这个需求的时候,需要对上传的发票进行识别(获取发票抬头、发票代码、发票号码,开票日期、消费项、总金额等)和验证(验证发票是否伪造)。
对于识别部分,我们是没有办法自己做的,所以使用了云厂商提供的 OCR 识别服务;验证部分,我们准备编写爬虫来实现,但对网站的源代码和接口进行了分析之后,发现该网站的源代码全部做了混淆(js混淆),而且还有我们常见的图片验证码。 最终这个需求里面的识别和查验全使用的云厂商提供的 API 服务。
在去年的某一天,机缘巧合下我又进入了那个网站,让我有了想攻破它的想法。
提示
国家税务总局全国增值税发票查验平台 是用来做发票真伪识别的,它属于政府网站,不像其它平台一样有提供 API 服务。
js混淆是一种反爬的常用手段,目的是增加源代码的分析难度;以这个网站为例,它的图片验证码和查验接口中有一个参数是前端生成的。
预期目标
我的目标是将这一整个发票查验的过程封装成一个接口,也就是只需要输入必要的参数,然后就能直接得到发票的识别结果,省去验证码的输入,达到可以批量处理的一个效果;还有一个最重要的目标,不使用任何第三方接口。
实现这一目标需要解决两个问题:反爬和图片验证码识别。
爬虫
前面说到了这个网站的源代码做了混淆,无法从源代码去入手分析;所以我采取了模拟浏览器操作的一种方式去解决,使用 Selenium 框架。
- 技术栈:Java、Selenium(模拟浏览器)、XPath(解析html内容)
Selenium 配置
package cn.youyo.service.invoice;
import cn.youyo.service.invoice.config.InvoiceProperties;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import java.util.List;
/**
* @author: owen.chi
* @email: owen.chi@etailerhub.com
* @datetime: 2024/11/28
* @description: chrome 浏览器管理
*/
@Slf4j
public class ChromeManager {
private static ChromeOptions options;
public static void init(InvoiceProperties properties) {
System.setProperty("webdriver.chrome.driver", properties.getChromeDriverPath());
// 新建一个浏览器
options = new ChromeOptions();
// 设置浏览器驱程序
options.setBinary(properties.getChromePath());
// 添加一个参数来禁用“AutomationControlled”标志
options.addArguments("--disable-blink-features=AutomationControlled");
// 无头浏览器模式
options.addArguments("--headless");
// 允许远程连接
options.addArguments("--remote-allow-origins=*");
// Exclude the collection of enable-automation switches
options.setExperimentalOption("excludeSwitches", List.of("enable-automation"));
// # 关闭 userAutomationExtension
options.setExperimentalOption("useAutomationExtension", false);
}
public static WebDriver newWebDriver() {
return new ChromeDriver(options);
}
}
Selenium 配置需要注意的是不能少了 options.addArguments("--remote-allow-origins=*");
,缺少该配置时,会出现在跑单元测试时是没有问题的,但是一旦集成到 SpringBoot 时,Selenium 启动的浏览器内核会拒绝访问。
Spider 爬虫
使用 Selenium 启动一个浏览器,然后通过提前编写的指令发送给浏览器,让它访问网站,并模拟填写查验表单进行查验。
package cn.youyo.service.invoice;
import ai.onnxruntime.OrtException;
import cn.hutool.core.date.StopWatch;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.*;
import org.openqa.selenium.support.ui.WebDriverWait;
import java.io.*;
import java.nio.file.Paths;
import java.time.*;
import java.util.*;
/**
* @author: owen.chi
* @email: owen.chi@etailerhub.com
* @datetime: 2024/11/28
* @description: 爬虫
*/
@Slf4j
public class Spider {
private static final List<String> colorList = List.of("红色", "蓝色", "黄色", "绿色");
private final BillParams params;
private final WebDriver driver;
private final JavascriptExecutor javascriptExecutor;
private final String imageSavePath;
private final String htmlSavePath;
private Spider(BillParams params, WebDriver driver, String imageSavePath, String htmlSavePath) {
... ...
this.javascriptExecutor = (JavascriptExecutor) driver;
... ...
}
public String runSpider() {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
String html = null;
try {
//打开页面
driver.navigate().to("https://inv-veri.chinatax.gov.cn/");
// 跳过隐私设置错误页面
this.skipErrorPage();
WebElement invoiceFormElement = new WebDriverWait(driver, Duration.ofSeconds(2)).until(d -> d.findElement(By.xpath("/html/body/div/div[2]/table")));
// 填写表单
invoiceFormElement.findElement(By.id("fpdm")).sendKeys(params.billCode);
invoiceFormElement.findElement(By.id("fphm")).sendKeys(params.billNum);
invoiceFormElement.findElement(By.id("kprq")).sendKeys(params.formatBillDate());
invoiceFormElement.findElement(By.id("kjje")).sendKeys(params.lastSixDigitsVerifyCode());
// 读取验证码
WebElement yzmImgElement = invoiceFormElement.findElement(By.id("yzm_img"));
String src = yzmImgElement.getAttribute("src");
while (!src.startsWith("data:image/png;base64,")) {
TimeUnit.MICROSECONDS.sleep(200);
src = yzmImgElement.getAttribute("src");
}
log.info("图片信息:{}", src);
// 验证码信息
String yzminfo = invoiceFormElement.findElement(By.id("yzminfo")).getText();
log.info("验证码说明:{}", yzminfo);
Optional<String> optional = colorList.stream().filter(yzminfo::contains).findFirst();
String extra = optional.orElse("全部");
// 验证码图片
String imgBase64 = src.split(",")[1];
// 验证码识别
String yzm = CaptchaHelper.ocr(imgBase64, extra);
log.info("验证码: {}", yzm);
// 保存到文件
this.imgWriteToFile(imgBase64, System.currentTimeMillis() + "_" + extra + "_" + yzm + ".png");
// 输入验证码
invoiceFormElement.findElement(By.id("yzm")).sendKeys(yzm);
// 找到查验按钮
WebElement checkfp = invoiceFormElement.findElement(By.id("checkfp"));
while (!checkfp.isDisplayed()) {
checkfp = invoiceFormElement.findElement(By.id("checkfp"));
TimeUnit.MICROSECONDS.sleep(100);
}
// 关闭广告
WebElement absElement = driver.findElement(By.xpath("/html/body/div/div[7]"));
javascriptExecutor.executeScript("arguments[0].parentNode.removeChild(arguments[0]);", absElement);
// 点击查验
checkfp.click();
// 处理最后结果
html = this.handleResult();
} catch (InterruptedException | IOException | OrtException e) {
log.error("抓取发票信息失败,错误信息:{}", e.getMessage());
} finally {
driver.quit();
}
stopWatch.stop();
log.info("耗时: {} 秒", stopWatch.getTotalTimeSeconds());
return html;
}
/**
* 跳过隐私设置错误页面
*/
private void skipErrorPage() {
String title = driver.getTitle();
log.info("页签名称:{}", title);
if (Objects.equals("隐私设置错误", title)) {
driver.findElement(By.id("details-button")).click();
driver.findElement(By.id("proceed-link")).click();
}
}
private String handleResult() {
String outerHtml;
try {
WebDriver iframe = new WebDriverWait(driver, Duration.ofSeconds(2)).until(d -> d.switchTo().frame("dialog-body"));
outerHtml = iframe.findElement(By.tagName("html")).getAttribute("outerHTML");
} catch (TimeoutException ignored) {
log.warn("发票可能超过当日查验次数!");
WebElement popupContainer = driver.findElement(By.id("popup_container"));
outerHtml = popupContainer.getAttribute("outerHTML");
}
if (outerHtml != null) {
// 内容压缩
outerHtml = outerHtml.replaceAll("\\s{2,}", "").replaceAll(">\\s+<", "><");
// 写入文件保存
this.htmlWriteToFile(params.billCode + "_" + params.billNum + "_" + System.currentTimeMillis() + ".html", outerHtml);
}
return outerHtml;
}
/** html保存到本地 */
private void htmlWriteToFile(String fileName, String content) {
... ...
}
/** 图片保存到本地 */
private void imgWriteToFile(String fileName, String imgBase64) {
... ...
}
public static SpiderBuilder builder() {
return new SpiderBuilder();
}
private static class BillParams {
/**
* 发票 code
*/
private String billCode;
/**
* 发票号码
*/
private String billNum;
/**
* 发票日期
*/
private LocalDate billDate;
/**
* 发票代码
*/
private String verifyCode;
public String formatBillDate() {
if (Objects.nonNull(this.billDate)) {
return this.billDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
}
return null;
}
public String lastSixDigitsVerifyCode() {
if (Objects.nonNull(this.verifyCode)) {
return this.verifyCode.substring(this.verifyCode.length() - 6);
}
return null;
}
}
public static class SpiderBuilder {
private final BillParams params;
private WebDriver driver;
private String imageSavePath;
private String htmlSavePath;
... ...
public Spider build() {
if (this.driver == null) {
// throw new IllegalArgumentException("缺少 web driver");
this.driver = ChromeManager.newWebDriver();
}
return new Spider(params, driver, imageSavePath, htmlSavePath);
}
}
}
因为是通过 Selenium 模拟浏览器操作获取结果,所以爬虫返回的结果其实是浏览器的 html 内容,而不是平常使用的 json 内容。我这里会把每次爬取时的结果和验证码图片保存本地。
图片验证码识别
为了实现图片验证码识别,使用的百度的飞桨框架训练了一个专门识别该网站验证码的 OCR 模型。
- 技术栈:Python、OpenCV、PaddlePaddle、SQLite
该网站的图片验证码大小 120x50,包含了数字、大写字母和中文,其中中文使用 6400 多个,总分类达到 6536 个。
配置文件
import json
import string
class Config:
# 图片信息配置 - 通道数、高度、宽度
IMAGE_SHAPE_C = 1
IMAGE_SHAPE_H = 50
IMAGE_SHAPE_W = 120
# 数据集图片中标签长度最大值设置 - 因图片中均为4个字符,故该处填写为4即可
LABEL_MAX_LEN = 6
ASCII_CHARACTERS = string.digits + string.ascii_uppercase
CHINESE_CHARACTERS = '阿啊哎哀唉埃挨癌矮艾爱碍安氨俺岸按案暗昂凹熬傲奥澳八巴叭吧拔把坝爸罢霸白百柏摆败拜班般颁斑搬板版办半伴扮瓣邦帮膀傍棒包胞宝饱保堡报抱豹暴爆卑杯悲碑北贝备背倍被辈奔本崩逼鼻比彼笔币必毕闭辟碧蔽壁避臂边编蝙鞭扁便变遍辨辩标表别宾滨冰兵丙柄饼并病拨波玻剥播脖伯驳泊勃博搏膊薄卜补捕不布步部擦猜才材财裁采彩踩菜蔡参餐残蚕惨灿仓苍舱藏操曹槽草册侧测策层叉插查茶察差拆柴缠产阐颤昌长肠尝偿常厂场畅倡唱抄超巢朝潮吵炒车扯彻撤尘臣沉陈闯衬称趁撑成呈承诚城乘惩程橙吃池驰迟持匙尺齿斥赤翅充冲虫崇抽仇绸愁筹酬丑瞅臭出初除厨础储楚处触川穿传船喘串窗床晨创吹垂锤春纯唇醇词瓷慈辞磁雌此次刺从匆葱聪丛凑粗促催脆翠村存寸措错搭达答打大呆代带待袋逮戴丹单担胆旦但诞弹淡蛋氮当挡党荡刀导岛倒蹈到盗道稻得德的灯登等邓凳瞪低堤滴迪敌笛底抵地弟帝递第颠典点电店垫淀殿雕吊钓调掉爹跌叠蝶丁叮盯钉顶订定丢东冬懂动冻洞都斗抖陡豆督毒读独堵赌杜肚度渡端短段断锻堆队对吨敦蹲盾顿多夺朵躲俄鹅额恶饿鳄恩儿而尔耳二发乏伐罚阀法帆番翻凡烦繁反返犯泛饭范贩方坊芳防妨房肪仿访纺放飞非啡菲肥废沸肺费分纷芬坟粉份奋愤粪丰风枫封疯峰锋蜂冯逢缝凤奉佛否夫肤孵弗伏扶服浮符幅福辐蝠抚府辅腐父付妇负附复赴副傅富赋腹覆该改钙盖溉概干甘杆肝赶敢感刚岗纲缸钢港高搞稿告戈哥胳鸽割歌阁革格葛隔个各给根跟更耕工弓公功攻供宫恭巩拱共贡勾沟钩狗构购够估咕姑孤菇古谷股骨鼓固故顾瓜刮挂拐怪关观官冠馆管贯惯灌罐光广归龟规硅轨鬼柜贵桂滚棍郭锅国果裹过哈孩海害含函寒韩罕喊汉汗旱杭航毫豪好号浩耗呵喝合何和河核荷盒贺褐赫鹤黑嘿痕很狠恨哼恒横衡轰哄红宏洪虹鸿侯喉猴吼后厚候乎呼忽狐胡壶湖葫糊蝴虎互户护花华哗滑化划画话桦怀淮坏欢还环缓幻唤换患荒慌皇黄煌晃灰恢挥辉徽回毁悔汇会绘惠慧昏婚浑魂混活火伙或货获祸惑霍击饥圾机肌鸡积基迹绩激及吉级即极急疾集辑籍几己挤脊计记纪忌技际剂季既济继寂寄加夹佳家嘉甲贾钾价驾架假嫁稼尖坚间肩艰兼监减剪检简碱见件建剑健舰渐践鉴键箭江姜将浆僵疆讲奖蒋匠降交郊娇浇骄胶焦礁角脚搅叫轿较教阶皆接揭街节劫杰洁结捷截竭姐解介戒届界借巾今斤金津筋仅紧锦尽劲近进晋浸禁京经茎惊晶睛精鲸井颈景警净径竞竟敬境静镜纠究九久酒旧救就舅居局菊橘举矩句巨拒具俱剧惧据距聚卷倦决绝觉掘嚼军君均菌俊峻卡开凯慨刊堪砍看康抗炕考烤靠科棵颗壳咳可渴克刻客课肯坑空孔恐控口扣枯哭苦库裤酷夸跨块快宽款狂况矿亏葵愧溃昆困扩括阔垃拉啦喇腊蜡辣来莱赖兰拦栏蓝篮览懒烂滥郎狼廊朗浪捞劳牢老乐勒雷蕾泪类累冷愣厘梨离莉犁璃黎礼李里哩理鲤力历厉立丽利励例隶粒俩连帘怜莲联廉脸练炼恋链良凉梁粮两亮辆量辽疗聊僚了料列劣烈猎裂邻林临淋磷灵玲凌铃陵羚零龄领岭令另溜刘流留硫瘤柳六龙笼隆垄拢楼漏露卢芦炉鲁陆录鹿碌路驴旅铝履律虑率绿氯滤卵乱掠略伦轮论罗萝逻螺裸洛络骆落妈麻马玛码蚂骂吗嘛埋买迈麦卖脉蛮满曼慢漫忙芒盲茫猫毛矛茅茂冒贸帽貌么没枚玫眉梅媒煤霉每美妹门闷们萌盟猛蒙孟梦弥迷谜米泌秘密蜜眠绵棉免勉面苗描秒妙庙灭民敏名明鸣命摸模膜摩磨蘑魔抹末沫陌莫漠墨默谋某母亩牡姆拇木目牧墓幕慕穆拿哪内那纳娜钠乃奶奈耐男南难囊恼脑闹呢嫩能尼泥你拟逆年念娘酿鸟尿捏您宁凝牛扭纽农浓弄奴努怒女暖挪诺哦欧偶爬帕怕拍排牌派攀盘判叛盼庞旁胖抛炮跑泡胚陪培赔佩配喷盆朋棚蓬鹏膨捧碰批披皮疲脾匹屁譬片偏篇骗漂飘瓢票拼贫频品平评凭苹屏瓶萍坡泼颇婆迫破剖扑铺葡蒲朴浦普谱七妻栖戚期欺漆齐其奇歧骑棋旗企岂启起气弃汽契砌器恰千迁牵铅谦签前钱潜浅遣欠枪腔强墙抢悄敲乔桥瞧巧切茄且窃亲侵秦琴禽勤青氢轻倾清情晴顷请庆穷丘秋蚯求球区曲驱屈躯趋取娶去趣圈全权泉拳犬劝券缺却雀确鹊裙群然燃染嚷壤让饶扰绕惹热人仁忍认任扔仍日绒荣容溶熔融柔肉如儒乳辱入软锐瑞润若弱撒洒萨塞赛三伞散桑嗓丧扫嫂色森僧杀沙纱刹砂傻啥晒山杉衫珊闪陕扇善伤商赏上尚梢烧稍少绍哨舌蛇舍设社射涉摄申伸身深神审婶肾甚渗慎升生声牲胜绳省圣盛剩尸失师诗施狮湿十什石时识实拾蚀食史使始驶士氏世市示式事侍势视试饰室是适逝释收手守首寿受兽售授瘦书抒叔枢殊疏舒输蔬熟暑署属鼠薯术束述树竖数刷耍衰摔甩帅双霜爽谁水税睡顺瞬说丝司私思斯撕死四寺似饲松耸宋送颂搜艘苏俗诉肃素速宿塑酸蒜算虽随髓岁遂碎穗孙损笋缩所索锁他它她塌塔踏胎台抬太态泰贪摊滩坛谈潭坦叹炭探碳汤唐堂塘糖躺趟涛掏逃桃陶淘萄讨套特疼腾藤梯踢啼提题蹄体替天添田甜填挑条跳贴铁厅听廷亭庭停蜓挺艇通同桐铜童统桶筒痛偷头投透突图徒涂途屠土吐兔团推腿退吞托拖脱驼妥拓唾挖哇蛙娃瓦歪外弯湾丸完玩顽挽晚碗万汪亡王网往忘旺望危威微为围违唯惟维伟伪尾纬委萎卫未位味胃谓喂慰魏温文纹闻蚊吻稳问翁窝我沃卧握乌污屋无吴吾五午伍武舞务物误悟雾夕西吸希析息牺悉惜晰稀溪锡熙嘻膝习席袭媳洗喜戏系细隙虾瞎峡狭辖霞下吓夏厦仙先纤掀鲜闲弦贤咸衔嫌显险县现线限宪陷献腺乡相香厢湘箱详祥翔享响想向巷项象像橡削消萧硝销小晓孝效校笑些歇协胁斜谐携鞋写泄泻卸屑械谢蟹心辛欣新信兴星猩刑行形型醒杏姓幸性凶兄匈胸雄熊休修羞朽秀绣袖嗅须虚需徐许序叙畜绪续蓄宣玄悬旋选穴学雪血寻巡询循训讯迅压呀鸦鸭牙芽崖哑雅亚咽烟淹延严言岩沿炎研盐颜衍掩眼演厌宴艳验焰雁燕央扬羊阳杨洋仰养氧痒样腰邀摇遥咬药要耀爷也冶野业叶页夜液一伊衣医依仪夷宜姨移遗疑乙已以矣蚁椅义亿忆艺议亦异役抑译易疫益谊逸意溢毅翼因阴音吟银引饮蚓隐印应英婴鹰迎盈营蝇赢影映硬哟拥永泳勇涌用优忧幽悠尤犹由邮油游友有又右幼诱于予余鱼娱渔愉愚与宇羽雨语玉吁育郁狱浴预域欲喻寓御裕遇愈誉豫元员园原圆袁援缘源远怨院愿曰约月岳钥悦阅跃越云匀允孕运晕韵蕴杂砸灾栽宰载再在咱暂赞脏葬遭糟早枣藻灶皂造噪燥躁则择泽责贼怎曾增赠渣扎眨炸摘宅窄债沾粘展占战站张章涨掌丈仗帐胀账障招找召兆赵照罩遮折哲者这浙针侦珍真诊枕阵振镇震争征挣睁蒸整正证郑政症之支汁芝枝知织肢脂蜘执直值职植殖止只旨址纸指趾至志制治质致智置中忠终钟肿种仲众重州舟周洲轴宙皱骤朱株珠诸猪蛛竹烛逐主煮嘱住助注贮驻柱祝著筑抓爪专砖转赚庄桩装壮状撞追准捉桌着仔兹姿资滋籽子紫字自宗综棕踪总纵走奏租足族阻组祖钻嘴最罪醉尊遵昨左作坐座做蔼隘庵鞍黯肮拗袄懊扒芭疤捌跋靶掰扳拌绊梆绑榜蚌谤磅镑苞褒雹鲍狈悖惫笨绷泵蹦匕鄙庇毙痹弊璧贬匾辫彪憋鳖瘪彬斌缤濒鬓秉禀菠舶渤跛簸哺怖埠簿睬惭沧糙厕蹭茬岔豺掺搀禅馋蝉铲猖敞钞嘲澈忱辰铛澄逞秤痴弛侈耻宠畴稠锄雏橱矗揣囱疮炊捶椿淳蠢戳绰祠赐醋簇窜篡崔摧悴粹搓撮挫瘩歹怠贷耽档叨捣祷悼蹬嘀涤缔蒂掂滇巅碘佃甸玷惦奠刁叼迭谍碟鼎董栋兜蚪逗痘睹妒镀缎兑墩盹囤钝咄哆踱垛堕舵惰跺讹娥峨蛾扼鄂愕遏噩饵贰筏矾妃匪诽吠吩氛焚忿讽敷芙拂俘袱甫斧俯脯咐缚尬丐柑竿尴秆橄赣冈肛杠羔膏糕镐疙搁蛤庚羹埂耿梗蚣躬汞苟垢沽辜雇寡卦褂乖棺逛闺瑰诡癸跪亥骇酣憨涵悍捍焊憾撼翰夯嚎皓禾烘弘弧唬沪猾徊槐宦涣焕痪凰惶蝗簧恍谎幌卉讳诲贿晦秽荤豁讥叽唧缉畸箕稽棘嫉妓祭鲫冀颊奸歼煎拣俭柬茧捡荐贱涧溅槛缰桨酱椒跤蕉侥狡绞饺矫剿缴窖酵秸睫芥诫藉襟谨荆兢靖窘揪灸玖韭臼疚拘驹鞠桔沮炬锯娟捐鹃绢眷诀倔崛爵钧骏竣咖揩楷勘坎慷糠扛亢拷铐坷苛磕蝌垦恳啃吭抠叩寇窟垮挎筷筐旷框眶盔窥魁馈坤捆廓睐婪澜揽缆榄琅榔唠姥涝烙酪垒磊肋擂棱狸漓篱吏沥俐荔栗砾痢雳镰敛粱谅晾寥嘹撩缭瞭咧琳鳞凛吝赁躏拎伶聆菱浏琉馏榴咙胧聋窿娄搂篓陋庐颅卤虏赂禄吕侣屡缕峦抡仑沦啰锣箩骡蟆馒瞒蔓莽锚卯昧媚魅氓朦檬锰咪靡眯觅缅瞄渺藐蔑皿闽悯冥铭谬馍摹茉寞沐募睦暮捺挠瑙呐馁妮匿溺腻捻撵碾聂孽拧狞柠泞钮脓疟虐懦糯殴鸥呕藕趴啪耙徘湃潘畔乓螃刨袍沛砰烹彭澎篷坯劈霹啤僻翩撇聘乒坪魄仆菩圃瀑曝柒凄祈脐崎鳍乞迄泣掐洽钳乾黔谴嵌歉呛跷锹侨憔俏峭窍翘撬怯钦芹擒寝沁卿蜻擎琼囚岖渠痊瘸冉瓤壬刃纫韧戎茸蓉榕冗揉蹂蠕汝褥蕊闰腮叁搔骚臊涩瑟鲨煞霎筛删煽擅赡裳晌捎勺奢赦呻绅沈笙甥矢屎恃拭柿嗜誓梳淑赎蜀曙恕庶墅漱蟀拴栓涮吮烁硕嗽嘶巳伺祀肆讼诵酥粟溯隋祟隧唆梭嗦琐蹋苔汰瘫痰谭檀毯棠膛倘淌烫滔誊剔屉剃涕惕恬舔迢帖彤瞳捅凸秃颓蜕褪屯豚臀驮鸵椭洼袜豌宛婉惋皖腕枉妄偎薇巍帷苇畏尉猬蔚瘟紊嗡涡蜗呜巫诬芜梧蜈侮捂鹉勿戊昔犀熄蟋徙匣侠暇馅羡镶宵潇箫霄嚣淆肖哮啸蝎邪挟懈芯锌薪馨衅腥汹锈戌墟旭恤酗婿絮轩喧癣炫绚渲靴薛勋熏旬驯汛逊殉丫押涯衙讶焉阎蜒檐砚唁谚堰殃秧鸯漾夭吆妖尧肴姚窑谣舀椰腋壹怡贻胰倚屹邑绎姻茵荫殷寅淫瘾莺樱鹦荧莹萤颖佣庸咏踊酉佑迂淤渝隅逾榆舆屿禹芋冤鸳渊猿苑粤耘陨酝哉赃凿蚤澡憎咋喳轧闸乍诈栅榨斋寨毡瞻斩盏崭辗栈绽彰樟杖昭沼肇辙蔗贞斟疹怔狰筝拯吱侄帜挚秩掷窒滞稚衷粥肘帚咒昼拄瞩蛀铸拽撰妆幢椎锥坠缀赘谆卓拙灼茁浊酌啄琢咨姊揍卒佐佘赊'
CHARACTERS = ASCII_CHARACTERS + CHINESE_CHARACTERS
# 分类数量设置 - 因数据集中共包含0~9共10种数字+分隔符,所以是11分类任务
CLASSIFY_NUM = len(CHARACTERS) + 1
FILL = len(CHARACTERS)
# 训练轮数
EPOCH = 100
# 每批次数据大小
BATCH_SIZE = 64
SQLITE_PATH='F:\\Workspace\\inv-veri\\captcha_db.db'
网络结构
网络结构使用的是 CNN + LSTM 的组合,CNN 部分还使用了自定义的残差网络。模型会预测出 10 正数,这 10 个正数代表的是整个字符集数组下标(为什么下标,因为字符本身不能参与运算,所以使用字符在整个分类串中的位置作为数值参与运算)。
import paddle
from config import Config
# 定义残差块
class ResidualBlock(paddle.nn.Layer):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
# 卷积一
self.conv1 = paddle.nn.Conv2D(in_channels, out_channels, kernel_size=3, stride=stride, padding=1)
self.bn1 = paddle.nn.BatchNorm2D(out_channels)
# 卷积二
self.conv2 = paddle.nn.Conv2D(out_channels, out_channels, kernel_size=3, padding=1)
self.bn2 = paddle.nn.BatchNorm2D(out_channels)
# 卷积三
self.conv3 = paddle.nn.Conv2D(out_channels, out_channels, kernel_size=5, padding=2)
self.bn3 = paddle.nn.BatchNorm2D(out_channels)
# 卷积四
# self.conv4 = paddle.nn.Conv2D(out_channels, out_channels, kernel_size=7, padding=3)
# self.bn4 = paddle.nn.BatchNorm2D(out_channels)
# 如果通道数不匹配,需要调整维度
if in_channels != out_channels or stride != 1:
self.shortcut = paddle.nn.Conv2D(in_channels, out_channels, kernel_size=1, stride=stride)
else:
self.shortcut = None
def forward(self, x):
identity = x # 残差连接
x = self.conv1(x)
x = self.bn1(x)
x = paddle.nn.functional.relu(x)
x = self.conv2(x)
x = self.bn2(x)
x = paddle.nn.functional.relu(x)
x = self.conv3(x)
x = self.bn3(x)
# 处理 shortcut 路径
if self.shortcut:
identity = self.shortcut(identity)
# 残差连接
x += identity
x = paddle.nn.functional.relu(x)
return x
# 定义带残差块的主网络
class ResNet3(paddle.nn.Layer):
def __init__(self, img_channel=Config.IMAGE_SHAPE_C, label_len=Config.LABEL_MAX_LEN, classes_num=Config.CLASSIFY_NUM, is_infer: bool = False):
"""
自定义残差网络
:param img_channel: 图片通道数量
:param label_len: label 最大长度
:param classes_num: 分类数量
:param is_infer: 预测模式
"""
super().__init__()
print('[*] use resnet model')
self.is_infer = is_infer
self.img_channel = img_channel
self.seq_len = label_len + 4
self.classes_num = classes_num
# 第一层卷积(不带残差)
self.conv1 = paddle.nn.Conv2D(in_channels=self.img_channel, out_channels=32, kernel_size=3, padding=1)
self.bn1 = paddle.nn.BatchNorm2D(32)
# 加入多个残差块
self.res_block1 = ResidualBlock(32, 64, stride=2)
self.res_block2 = ResidualBlock(64, 128, stride=2)
self.res_block3 = ResidualBlock(128, 256, stride=2)
self.res_block4 = ResidualBlock(256, 512, stride=1)
# 1x1 卷积压缩通道数
self.conv2 = paddle.nn.Conv2D(in_channels=512, out_channels=self.seq_len, kernel_size=1)
# dropout_cnn
self.dropout_cnn = paddle.nn.Dropout(p=0.1)
# 全连接层
self.linear = paddle.nn.Linear(in_features=105, out_features=256)
# dropout_linear
self.dropout_linear = paddle.nn.Dropout(p=0.2)
# 双向LSTM
self.lstm = paddle.nn.LSTM(input_size=256, hidden_size=128, direction="bidirectional")
# dropout_lstm
# self.dropout_lstm = paddle.nn.Dropout(p=0.3)
# 输出层
self.linear2 = paddle.nn.Linear(in_features=128 * 2, out_features=512)
self.linear3 = paddle.nn.Linear(in_features=512, out_features=1024)
self.linear4 = paddle.nn.Linear(in_features=1024, out_features=Config.CLASSIFY_NUM)
def forward(self, ipt):
x = self.conv1(ipt)
x = paddle.nn.functional.relu(self.bn1(x))
# 经过多个残差块
x = self.res_block1(x)
x = self.res_block2(x)
x = self.res_block3(x)
x = self.res_block4(x)
x = self.conv2(x)
x = paddle.nn.functional.relu(x)
# x = self.dropout_cnn(x)
x = paddle.tensor.flatten(x, 2)
x = self.linear(x)
x = paddle.nn.functional.relu(x)
# x = self.dropout_linear(x)
x = self.lstm(x)[0]
# x = self.dropout_lstm(x)
x = self.linear2(x)
x = self.linear3(x)
x = self.linear4(x)
if self.is_infer:
x = paddle.nn.functional.softmax(x)
x = paddle.argmax(x, axis=-1)
return x
提示
- CNN: 卷积神经网络,是一种深度学习模型,特别适用于处理具有网格结构的数据,如图像。
- RNN: 循环神经网络,是一种用于处理序列数据的神经网络,如图片的文字内容的排列顺序。
- LSTM: 长短期记忆网络,是一种特殊的循环神经网络(RNN),能够学习长期依赖信息。
训练数据
定义好网络结构后,就需要生成训练数据。为了生成和网站中近似的验证码图片,我花费了很多的时间在编码和寻找字体上。 其中字体的寻找是最麻烦的,虽然现在各大字体网站都有识图找字体的功能,但是目前手中的样本字体都是以验证码的方式呈现的,那些网站上的字体识别压根识别不了, 为此专门编写了一套程序对验证码图片进行了处理,处理过程大致就是根据图片中的文字颜色,对图片的像素进行模运算,保留文字的颜色,去除非文字颜色,然后在进行灰度处理转为黑底白字。 最后再对图片中的噪点进行清除尽可能只留下文字的样貌。
通过这样对验证码图片进行了处理之后,在拿到字体网站进行识别就好很多了,然后就是看那个网站有这个字体。
import random
from PIL import Image
def is_red(rgb):
[r, g, b] = rgb
return r > 200 and g < 110 and b < 100 and (b + g) < r
def is_blue(rgb):
[r, g, b] = rgb
return r < 100 and g < 100 and b > 200 or (r + g) < b
def is_yellow(rgb):
[r, g, b] = rgb
return r > 200 and g > 200 and b < 110
def is_black(rgb):
[r, g, b] = rgb
return r < 125 and g < 125 and b < 125 and (r * 0.21267 + g * 0.71516 + b * 0.067) < 125
def thresholding(img, color: str = ''):
"""
二值化,取出想要的文字部分
:param img: 验证码图片的文件路径
:return img: 加载后图像
"""
imgArray = img.load()
x, y = img.size
# 对于不同的验证码二值化不同的字符
if 'red' in color:
for i in range(y):
for j in range(x):
if is_red(imgArray[j, i]):
img.putpixel((j, i), (0, 0, 0))
else:
img.putpixel((j, i), (255, 255, 255))
elif 'blue' in color:
for i in range(y):
for j in range(x):
if is_blue(imgArray[j, i]):
img.putpixel((j, i), (0, 0, 0))
else:
img.putpixel((j, i), (255, 255, 255))
elif 'yellow' in color:
for i in range(y):
for j in range(x):
if is_yellow(imgArray[j, i]):
img.putpixel((j, i), (0, 0, 0))
else:
img.putpixel((j, i), (255, 255, 255))
else:
for i in range(y):
for j in range(x):
if is_black(imgArray[j, i]):
img.putpixel((j, i), (0, 0, 0))
else:
img.putpixel((j, i), (255, 255, 255))
return img
def thresholding_all(img):
"""
二值化,取出想要的文字部分
:param img: 验证码图片的文件路径
:return img: 加载后图像
"""
imgArray = img.load()
x, y = img.size
# 对于不同的验证码二值化不同的字符
for i in range(y):
for j in range(x):
if is_red(imgArray[j, i]) or is_blue(imgArray[j, i]) or is_yellow(imgArray[j, i]) or is_black(imgArray[j, i]):
img.putpixel((j, i), (0, 0, 0))
else:
img.putpixel((j, i), (255, 255, 255))
return img
def denoise(img):
"""
去除噪点
:param img:加载后图像
:return img
"""
imgArray = img.load()
w, h = img.size
for i in range(1, h - 1):
for j in range(1, w - 1):
count = 0
if imgArray[j, i - 1][0] == 255 and imgArray[j, i - 1][1] == 255 and imgArray[j, i - 1][2] == 255:
count = count + 1
if imgArray[j, i + 1][0] == 255 and imgArray[j, i + 1][1] == 255 and imgArray[j, i + 1][2] == 255:
count = count + 1
if imgArray[j - 1, i][0] == 255 and imgArray[j - 1, i][1] == 255 and imgArray[j - 1, i][2] == 255:
count = count + 1
if imgArray[j + 1, i][0] == 255 and imgArray[j + 1, i][1] == 255 and imgArray[j + 1, i][2] == 255:
count = count + 1
if imgArray[j + 1, i + 1][0] == 255 and imgArray[j + 1, i + 1][1] == 255 and imgArray[j + 1, i + 1][2] == 255:
count = count + 1
if imgArray[j + 1, i - 1][0] == 255 and imgArray[j + 1, i - 1][1] == 255 and imgArray[j + 1, i - 1][2] == 255:
count = count + 1
if imgArray[j - 1, i - 1][0] == 255 and imgArray[j - 1, i - 1][1] == 255 and imgArray[j - 1, i - 1][2] == 255:
count = count + 1
if imgArray[j - 1, i + 1][0] == 255 and imgArray[j - 1, i + 1][1] == 255 and imgArray[j - 1, i + 1][2] == 255:
count = count + 1
if count > 7:
# 如果一个点周围八个点有七个是空白,认为该点也应该是空白
img.putpixel((j, i), (255, 255, 255))
# 下面两个循环将图片最边缘一圈全部涂白
for i in range(w):
img.putpixel((i, 0), (255, 255, 255))
img.putpixel((i, h - 1), (255, 255, 255))
for i in range(h):
img.putpixel((0, i), (255, 255, 255))
img.putpixel((w - 1, i), (255, 255, 255))
return img
def reversal(image):
imgArray = image.load()
w, h = image.size
for i in range(h):
for j in range(w):
[r, g, b] = imgArray[j, i]
if r == g == b == 0:
image.putpixel((j, i), (255, 255, 255))
else:
image.putpixel((j, i), (0, 0, 0))
return image
def img_clean(file_path):
image = Image.open(file_path)
thresholding_img = thresholding_all(image)
denoise_img = denoise(thresholding_img)
reversal_img = reversal(denoise_img)
return reversal_img
def img_clean_by(file_path, color=''):
image = Image.open(file_path)
thresholding_img = thresholding(image, color)
denoise_img = denoise(thresholding_img)
reversal_img = reversal(denoise_img)
return reversal_img
if __name__ == '__main__':
img = Image.open('F:\\Workspace\\inv-veri\\v3\\ascii_fix_5\\00N6_1742191627.png')
img = thresholding(img, 'blue')
img.show()
img = denoise(img)
img.show()
img.save('./RE_蓝色_1741617241103.png')
img = reversal(img)
img.show()
验证码生成
有了字体之后那就编码生成相似的图片作为训练数据。 你可能会有疑问说为什么图片可以相似而字体不能,因为我们是做针对训练,我也有尝试过使用相似字体进行训练, 得到的模型用来识别真正的验证码效果很差,完全属于不可用状态;那难道就说明这条路是行不通的吗?也不是,要想通过这种方式去达到这种效果你需要更多的字体类型和训练数据。 但是我们这里是做针对训练,而不是训练这种超强的、识别字体类型很多的模型,找到一样的字体生成的内容进行训练有利于加速模型的收敛(收敛和高数中说到的函数收敛是一个意思)。
import math
import os
import random
import string
import time
from PIL import ImageDraw, ImageFont, Image
from config import Config
from data_factory.gen import Gen
from db.datasource import CaptchaDataSource
from img_helper import img_clean_o
def get_text_dimensions(ch, font):
'''获取文字的宽度和高度'''
ascent, descent = font.getmetrics()
(width, baseline), (offset_x, offset_y) = font.font.getsize(ch)
return width, ascent + descent
def text_rotation(text, font, text_color=(0, 0, 0)):
'''旋转文字'''
# 获取文字的宽度和高度
text_width, text_height = get_text_dimensions(text, font)
# print(text_width, text_height)
# 创建一个白色背景的图像,颜色为 (255, 255, 255)
image = Image.new('RGBA', (text_width, text_height), color=(255, 255, 255, 0))
# 创建一个可用于绘制到图像的对象
draw = ImageDraw.Draw(image)
# 添加文字到图像上,同时留出一些边缘空间
draw.text((0, 0), text, font=font, fill=text_color)
# 旋转45°
ratate_val = random.randrange(-50, 50, 10)
rotated_sub_image = image.rotate(ratate_val, expand=True, resample=Image.BICUBIC)
bbox = rotated_sub_image.getbbox()
if bbox:
# 将文字以外的空白区域删除
rotated_sub_image = rotated_sub_image.crop(bbox)
return rotated_sub_image
def add_distractions(image, image_width=Config.IMAGE_SHAPE_W, image_height=Config.IMAGE_SHAPE_H):
''' 添加干扰 '''
draw = ImageDraw.Draw(image)
# 添加噪点
noise_num = random.randint(100, 500)
for _ in range(noise_num):
draw.point((random.randint(0, image_width), random.randint(0, image_height)),
fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))
# 生成干扰线
noise_line_num = random.randint(1, 5)
for _ in range(noise_line_num):
x1, y1 = random.randint(0, image_width), random.randint(0, image_height)
x2, y2 = random.randint(0, image_width), random.randint(0, image_height)
draw.line((x1, y1, x2, y2),
fill=(random.randint(0, 255),
random.randint(0, 255),
random.randint(0, 255)),
width=1)
class InvoCapchaImage(Gen):
def __init__(self,
version,
text_type=None,
width=Config.IMAGE_SHAPE_W,
height=Config.IMAGE_SHAPE_H,
char_num=Config.LABEL_MAX_LEN,
characters=Config.CHARACTERS,
chinese_radio=0.2):
super(InvoCapchaImage, self).__init__(width=width, height=height, char_num=char_num, characters=characters)
self.version = version
self.text_type = text_type
self.ascii_char = string.digits + string.ascii_letters
self.chinese_char = '阿啊哎哀唉埃挨癌矮艾爱碍安氨俺岸按案暗昂凹熬傲奥澳八巴叭吧拔把坝爸罢霸白百柏摆败拜班般颁斑搬板版办半伴扮瓣邦帮膀傍棒包胞宝饱保堡报抱豹暴爆卑杯悲碑北贝备背倍被辈奔本崩逼鼻比彼笔币必毕闭辟碧蔽壁避臂边编蝙鞭扁便变遍辨辩标表别宾滨冰兵丙柄饼并病拨波玻剥播脖伯驳泊勃博搏膊薄卜补捕不布步部擦猜才材财裁采彩踩菜蔡参餐残蚕惨灿仓苍舱藏操曹槽草册侧测策层叉插查茶察差拆柴缠产阐颤昌长肠尝偿常厂场畅倡唱抄超巢朝潮吵炒车扯彻撤尘臣沉陈闯衬称趁撑成呈承诚城乘惩程橙吃池驰迟持匙尺齿斥赤翅充冲虫崇抽仇绸愁筹酬丑瞅臭出初除厨础储楚处触川穿传船喘串窗床晨创吹垂锤春纯唇醇词瓷慈辞磁雌此次刺从匆葱聪丛凑粗促催脆翠村存寸措错搭达答打大呆代带待袋逮戴丹单担胆旦但诞弹淡蛋氮当挡党荡刀导岛倒蹈到盗道稻得德的灯登等邓凳瞪低堤滴迪敌笛底抵地弟帝递第颠典点电店垫淀殿雕吊钓调掉爹跌叠蝶丁叮盯钉顶订定丢东冬懂动冻洞都斗抖陡豆督毒读独堵赌杜肚度渡端短段断锻堆队对吨敦蹲盾顿多夺朵躲俄鹅额恶饿鳄恩儿而尔耳二发乏伐罚阀法帆番翻凡烦繁反返犯泛饭范贩方坊芳防妨房肪仿访纺放飞非啡菲肥废沸肺费分纷芬坟粉份奋愤粪丰风枫封疯峰锋蜂冯逢缝凤奉佛否夫肤孵弗伏扶服浮符幅福辐蝠抚府辅腐父付妇负附复赴副傅富赋腹覆该改钙盖溉概干甘杆肝赶敢感刚岗纲缸钢港高搞稿告戈哥胳鸽割歌阁革格葛隔个各给根跟更耕工弓公功攻供宫恭巩拱共贡勾沟钩狗构购够估咕姑孤菇古谷股骨鼓固故顾瓜刮挂拐怪关观官冠馆管贯惯灌罐光广归龟规硅轨鬼柜贵桂滚棍郭锅国果裹过哈孩海害含函寒韩罕喊汉汗旱杭航毫豪好号浩耗呵喝合何和河核荷盒贺褐赫鹤黑嘿痕很狠恨哼恒横衡轰哄红宏洪虹鸿侯喉猴吼后厚候乎呼忽狐胡壶湖葫糊蝴虎互户护花华哗滑化划画话桦怀淮坏欢还环缓幻唤换患荒慌皇黄煌晃灰恢挥辉徽回毁悔汇会绘惠慧昏婚浑魂混活火伙或货获祸惑霍击饥圾机肌鸡积基迹绩激及吉级即极急疾集辑籍几己挤脊计记纪忌技际剂季既济继寂寄加夹佳家嘉甲贾钾价驾架假嫁稼尖坚间肩艰兼监减剪检简碱见件建剑健舰渐践鉴键箭江姜将浆僵疆讲奖蒋匠降交郊娇浇骄胶焦礁角脚搅叫轿较教阶皆接揭街节劫杰洁结捷截竭姐解介戒届界借巾今斤金津筋仅紧锦尽劲近进晋浸禁京经茎惊晶睛精鲸井颈景警净径竞竟敬境静镜纠究九久酒旧救就舅居局菊橘举矩句巨拒具俱剧惧据距聚卷倦决绝觉掘嚼军君均菌俊峻卡开凯慨刊堪砍看康抗炕考烤靠科棵颗壳咳可渴克刻客课肯坑空孔恐控口扣枯哭苦库裤酷夸跨块快宽款狂况矿亏葵愧溃昆困扩括阔垃拉啦喇腊蜡辣来莱赖兰拦栏蓝篮览懒烂滥郎狼廊朗浪捞劳牢老乐勒雷蕾泪类累冷愣厘梨离莉犁璃黎礼李里哩理鲤力历厉立丽利励例隶粒俩连帘怜莲联廉脸练炼恋链良凉梁粮两亮辆量辽疗聊僚了料列劣烈猎裂邻林临淋磷灵玲凌铃陵羚零龄领岭令另溜刘流留硫瘤柳六龙笼隆垄拢楼漏露卢芦炉鲁陆录鹿碌路驴旅铝履律虑率绿氯滤卵乱掠略伦轮论罗萝逻螺裸洛络骆落妈麻马玛码蚂骂吗嘛埋买迈麦卖脉蛮满曼慢漫忙芒盲茫猫毛矛茅茂冒贸帽貌么没枚玫眉梅媒煤霉每美妹门闷们萌盟猛蒙孟梦弥迷谜米泌秘密蜜眠绵棉免勉面苗描秒妙庙灭民敏名明鸣命摸模膜摩磨蘑魔抹末沫陌莫漠墨默谋某母亩牡姆拇木目牧墓幕慕穆拿哪内那纳娜钠乃奶奈耐男南难囊恼脑闹呢嫩能尼泥你拟逆年念娘酿鸟尿捏您宁凝牛扭纽农浓弄奴努怒女暖挪诺哦欧偶爬帕怕拍排牌派攀盘判叛盼庞旁胖抛炮跑泡胚陪培赔佩配喷盆朋棚蓬鹏膨捧碰批披皮疲脾匹屁譬片偏篇骗漂飘瓢票拼贫频品平评凭苹屏瓶萍坡泼颇婆迫破剖扑铺葡蒲朴浦普谱七妻栖戚期欺漆齐其奇歧骑棋旗企岂启起气弃汽契砌器恰千迁牵铅谦签前钱潜浅遣欠枪腔强墙抢悄敲乔桥瞧巧切茄且窃亲侵秦琴禽勤青氢轻倾清情晴顷请庆穷丘秋蚯求球区曲驱屈躯趋取娶去趣圈全权泉拳犬劝券缺却雀确鹊裙群然燃染嚷壤让饶扰绕惹热人仁忍认任扔仍日绒荣容溶熔融柔肉如儒乳辱入软锐瑞润若弱撒洒萨塞赛三伞散桑嗓丧扫嫂色森僧杀沙纱刹砂傻啥晒山杉衫珊闪陕扇善伤商赏上尚梢烧稍少绍哨舌蛇舍设社射涉摄申伸身深神审婶肾甚渗慎升生声牲胜绳省圣盛剩尸失师诗施狮湿十什石时识实拾蚀食史使始驶士氏世市示式事侍势视试饰室是适逝释收手守首寿受兽售授瘦书抒叔枢殊疏舒输蔬熟暑署属鼠薯术束述树竖数刷耍衰摔甩帅双霜爽谁水税睡顺瞬说丝司私思斯撕死四寺似饲松耸宋送颂搜艘苏俗诉肃素速宿塑酸蒜算虽随髓岁遂碎穗孙损笋缩所索锁他它她塌塔踏胎台抬太态泰贪摊滩坛谈潭坦叹炭探碳汤唐堂塘糖躺趟涛掏逃桃陶淘萄讨套特疼腾藤梯踢啼提题蹄体替天添田甜填挑条跳贴铁厅听廷亭庭停蜓挺艇通同桐铜童统桶筒痛偷头投透突图徒涂途屠土吐兔团推腿退吞托拖脱驼妥拓唾挖哇蛙娃瓦歪外弯湾丸完玩顽挽晚碗万汪亡王网往忘旺望危威微为围违唯惟维伟伪尾纬委萎卫未位味胃谓喂慰魏温文纹闻蚊吻稳问翁窝我沃卧握乌污屋无吴吾五午伍武舞务物误悟雾夕西吸希析息牺悉惜晰稀溪锡熙嘻膝习席袭媳洗喜戏系细隙虾瞎峡狭辖霞下吓夏厦仙先纤掀鲜闲弦贤咸衔嫌显险县现线限宪陷献腺乡相香厢湘箱详祥翔享响想向巷项象像橡削消萧硝销小晓孝效校笑些歇协胁斜谐携鞋写泄泻卸屑械谢蟹心辛欣新信兴星猩刑行形型醒杏姓幸性凶兄匈胸雄熊休修羞朽秀绣袖嗅须虚需徐许序叙畜绪续蓄宣玄悬旋选穴学雪血寻巡询循训讯迅压呀鸦鸭牙芽崖哑雅亚咽烟淹延严言岩沿炎研盐颜衍掩眼演厌宴艳验焰雁燕央扬羊阳杨洋仰养氧痒样腰邀摇遥咬药要耀爷也冶野业叶页夜液一伊衣医依仪夷宜姨移遗疑乙已以矣蚁椅义亿忆艺议亦异役抑译易疫益谊逸意溢毅翼因阴音吟银引饮蚓隐印应英婴鹰迎盈营蝇赢影映硬哟拥永泳勇涌用优忧幽悠尤犹由邮油游友有又右幼诱于予余鱼娱渔愉愚与宇羽雨语玉吁育郁狱浴预域欲喻寓御裕遇愈誉豫元员园原圆袁援缘源远怨院愿曰约月岳钥悦阅跃越云匀允孕运晕韵蕴杂砸灾栽宰载再在咱暂赞脏葬遭糟早枣藻灶皂造噪燥躁则择泽责贼怎曾增赠渣扎眨炸摘宅窄债沾粘展占战站张章涨掌丈仗帐胀账障招找召兆赵照罩遮折哲者这浙针侦珍真诊枕阵振镇震争征挣睁蒸整正证郑政症之支汁芝枝知织肢脂蜘执直值职植殖止只旨址纸指趾至志制治质致智置中忠终钟肿种仲众重州舟周洲轴宙皱骤朱株珠诸猪蛛竹烛逐主煮嘱住助注贮驻柱祝著筑抓爪专砖转赚庄桩装壮状撞追准捉桌着仔兹姿资滋籽子紫字自宗综棕踪总纵走奏租足族阻组祖钻嘴最罪醉尊遵昨左作坐座做蔼隘庵鞍黯肮拗袄懊扒芭疤捌跋靶掰扳拌绊梆绑榜蚌谤磅镑苞褒雹鲍狈悖惫笨绷泵蹦匕鄙庇毙痹弊璧贬匾辫彪憋鳖瘪彬斌缤濒鬓秉禀菠舶渤跛簸哺怖埠簿睬惭沧糙厕蹭茬岔豺掺搀禅馋蝉铲猖敞钞嘲澈忱辰铛澄逞秤痴弛侈耻宠畴稠锄雏橱矗揣囱疮炊捶椿淳蠢戳绰祠赐醋簇窜篡崔摧悴粹搓撮挫瘩歹怠贷耽档叨捣祷悼蹬嘀涤缔蒂掂滇巅碘佃甸玷惦奠刁叼迭谍碟鼎董栋兜蚪逗痘睹妒镀缎兑墩盹囤钝咄哆踱垛堕舵惰跺讹娥峨蛾扼鄂愕遏噩饵贰筏矾妃匪诽吠吩氛焚忿讽敷芙拂俘袱甫斧俯脯咐缚尬丐柑竿尴秆橄赣冈肛杠羔膏糕镐疙搁蛤庚羹埂耿梗蚣躬汞苟垢沽辜雇寡卦褂乖棺逛闺瑰诡癸跪亥骇酣憨涵悍捍焊憾撼翰夯嚎皓禾烘弘弧唬沪猾徊槐宦涣焕痪凰惶蝗簧恍谎幌卉讳诲贿晦秽荤豁讥叽唧缉畸箕稽棘嫉妓祭鲫冀颊奸歼煎拣俭柬茧捡荐贱涧溅槛缰桨酱椒跤蕉侥狡绞饺矫剿缴窖酵秸睫芥诫藉襟谨荆兢靖窘揪灸玖韭臼疚拘驹鞠桔沮炬锯娟捐鹃绢眷诀倔崛爵钧骏竣咖揩楷勘坎慷糠扛亢拷铐坷苛磕蝌垦恳啃吭抠叩寇窟垮挎筷筐旷框眶盔窥魁馈坤捆廓睐婪澜揽缆榄琅榔唠姥涝烙酪垒磊肋擂棱狸漓篱吏沥俐荔栗砾痢雳镰敛粱谅晾寥嘹撩缭瞭咧琳鳞凛吝赁躏拎伶聆菱浏琉馏榴咙胧聋窿娄搂篓陋庐颅卤虏赂禄吕侣屡缕峦抡仑沦啰锣箩骡蟆馒瞒蔓莽锚卯昧媚魅氓朦檬锰咪靡眯觅缅瞄渺藐蔑皿闽悯冥铭谬馍摹茉寞沐募睦暮捺挠瑙呐馁妮匿溺腻捻撵碾聂孽拧狞柠泞钮脓疟虐懦糯殴鸥呕藕趴啪耙徘湃潘畔乓螃刨袍沛砰烹彭澎篷坯劈霹啤僻翩撇聘乒坪魄仆菩圃瀑曝柒凄祈脐崎鳍乞迄泣掐洽钳乾黔谴嵌歉呛跷锹侨憔俏峭窍翘撬怯钦芹擒寝沁卿蜻擎琼囚岖渠痊瘸冉瓤壬刃纫韧戎茸蓉榕冗揉蹂蠕汝褥蕊闰腮叁搔骚臊涩瑟鲨煞霎筛删煽擅赡裳晌捎勺奢赦呻绅沈笙甥矢屎恃拭柿嗜誓梳淑赎蜀曙恕庶墅漱蟀拴栓涮吮烁硕嗽嘶巳伺祀肆讼诵酥粟溯隋祟隧唆梭嗦琐蹋苔汰瘫痰谭檀毯棠膛倘淌烫滔誊剔屉剃涕惕恬舔迢帖彤瞳捅凸秃颓蜕褪屯豚臀驮鸵椭洼袜豌宛婉惋皖腕枉妄偎薇巍帷苇畏尉猬蔚瘟紊嗡涡蜗呜巫诬芜梧蜈侮捂鹉勿戊昔犀熄蟋徙匣侠暇馅羡镶宵潇箫霄嚣淆肖哮啸蝎邪挟懈芯锌薪馨衅腥汹锈戌墟旭恤酗婿絮轩喧癣炫绚渲靴薛勋熏旬驯汛逊殉丫押涯衙讶焉阎蜒檐砚唁谚堰殃秧鸯漾夭吆妖尧肴姚窑谣舀椰腋壹怡贻胰倚屹邑绎姻茵荫殷寅淫瘾莺樱鹦荧莹萤颖佣庸咏踊酉佑迂淤渝隅逾榆舆屿禹芋冤鸳渊猿苑粤耘陨酝哉赃凿蚤澡憎咋喳轧闸乍诈栅榨斋寨毡瞻斩盏崭辗栈绽彰樟杖昭沼肇辙蔗贞斟疹怔狰筝拯吱侄帜挚秩掷窒滞稚衷粥肘帚咒昼拄瞩蛀铸拽撰妆幢椎锥坠缀赘谆卓拙灼茁浊酌啄琢咨姊揍卒佐佘赊'
self.all_char = self.chinese_char + self.ascii_char
self.chinese_radio = chinese_radio
# 字符采样频率
self.char_freq = {char: 1 for char in self.all_char} # 初始权重为1
def genImage(self, path, img_num):
# 检查文件夹是是否存在
if not os.path.exists(path):
os.makedirs(path)
image_map = {}
captchaDatasource = CaptchaDataSource()
for i in range(img_num):
if self.text_type == 'fix':
text = self.fix_len_text()
elif self.text_type == 'ascii':
text = self.ascii_text()
elif self.text_type == 'ascii_fix':
text = self.ascii_text()
else:
text = self.getText()
image = self.generate_captcha(text)
image = img_clean_o(image)
# 保存
image_name = f'{text}_{int(time.time())}.png'
image_map[image_name] = text
img_url = os.path.join(path, image_name)
image.save(img_url)
# 保存到数据库
captchaDatasource.insert(img_url, text, self.version)
if (i + 1) % 1000 == 0:
print('[*] 已生成 %d 张图片' % (i + 1))
print("[*] 图片创建结束")
return image_map
def getText(self):
# 汉字
max_len = random.randint(0, int(self.char_num * self.chinese_radio))
# ch_chars = random.choices(self.chinese_char, k=max_len)
ch_chars = random.choices(self.chinese_char, weights=[1 / self.char_freq[char] for char in self.chinese_char], k=max_len)
# 数字 + 字母
max_len = random.randint(max(max_len, 1), self.char_num - max_len)
# as_chars = random.choices(self.ascii_char, k=max_len)
as_chars = random.choices(self.ascii_char, weights=[1 / (self.char_freq[char]) for char in self.ascii_char], k=max_len)
char_list = ch_chars + as_chars
random.shuffle(char_list)
text = ''.join(char_list)
# 更新采样
self.update_char_freq(text)
return text
def ascii_text(self):
# 数字 + 字母
# max_len = random.randint(2, self.char_num)
# as_chars = random.choices(self.ascii_char, k=max_len)
char_list = random.choices(self.ascii_char, weights=[1 / (self.char_freq[char]) for char in self.ascii_char], k=self.char_num)
text = ''.join(char_list)
# 更新采样
self.update_char_freq(text)
return text
def ascii_fic_text(self):
# 数字 + 字母
# max_len = random.randint(2, self.char_num)
# as_chars = random.choices(self.ascii_char, k=max_len)
char_list = random.choices(self.ascii_char, weights=[1 / (self.char_freq[char]) for char in self.ascii_char], k=self.char_num)
text = ''.join(char_list)
# 更新采样
self.update_char_freq(text)
return text
def fix_len_text(self):
# 汉字
max_len = int(self.char_num * self.chinese_radio)
ch_chars = random.choices(self.chinese_char, k=max_len)
# 数字 + 字母
max_len = self.char_num - max_len
as_chars = random.choices(self.ascii_char, k=max_len)
char_list = ch_chars + as_chars
random.shuffle(char_list)
text = ''.join(char_list)
return text
def update_char_freq(self, text):
for c in text:
self.char_freq[c] += 1
def generate_captcha(self, text):
# 选择字体和大小
default_font = ImageFont.truetype('../assets/fonts/simsun.ttc', 30)
try:
font = ImageFont.truetype('../assets/fonts/actionj.ttf', 30)
except IOError:
font = default_font
text_images = []
max_width = 0
max_height = 0
total_width = 0
total_height = 0
# 随机颜色和更大偏移绘制轮廓字体
for i, char in enumerate(text):
f = font
if char in self.chinese_char:
f = default_font
text_color = random.choice(self.text_colors) # 轮廓颜色
ch_img = text_rotation(char, f, text_color)
max_width = max(max_width, ch_img.width)
max_height = max(max_height, ch_img.height)
total_width += ch_img.width
total_height += ch_img.height
text_images.append(ch_img)
# 创建一个白色背景的图像,颜色为 (255, 255, 255)
# background_color = (random.randint(150, 220), random.randint(150, 240), random.randint(150, 220))
background_color = random.choice(self.background_colors)
captcha_image = Image.new('RGB', (Config.IMAGE_SHAPE_W, Config.IMAGE_SHAPE_H), color=background_color)
offset = 0
# 剩余宽度
remaining_width = Config.IMAGE_SHAPE_W - total_width
interval = math.ceil(remaining_width / len(text))
# offset_left = offset_right = math.ceil(remaining_width / len(text))
if remaining_width < 0:
# 剩余宽度小于 0,表示图片不能正常防止这些文字,文字间需要有重叠
offset = math.ceil((-remaining_width) / len(text))
interval = 0
start_x = random.randint(0, max(remaining_width, 0) // 2) + 1
middle_y = (Config.IMAGE_SHAPE_H - max_height) // 2
for img in text_images:
# 防止超出预定宽度
if start_x + img.width > Config.IMAGE_SHAPE_W:
start_x = Config.IMAGE_SHAPE_W - img.width - 1
# 防止超出预定高度
start_y = random.randrange(max(middle_y - 5, 1), middle_y + 5, 2)
if start_y + img.height > Config.IMAGE_SHAPE_H:
start_y = Config.IMAGE_SHAPE_H - img.height - 1
captcha_image.paste(img, (start_x, start_y), img if img.mode in ('RGBA', 'LA') else None)
start_x += random.randint(img.width + interval - offset, img.width + interval - offset)
# 添加干扰
add_distractions(captcha_image)
return captcha_image
def gen_thread():
# gen = InvoCapchaImage(version=3, text_type='fix', char_num=2, chinese_radio=0.5)
# gen.genImage('F:\\Workspace\\inv-veri\\v3\\2_chars', 5000)
#
# gen = InvoCapchaImage(version=3, text_type='fix', char_num=3, chinese_radio=0.5)
# gen.genImage('F:\\Workspace\\inv-veri\\v3\\3_chars', 5000)
#
# gen = InvoCapchaImage(version=3, text_type='fix', char_num=4, chinese_radio=0.5)
# gen.genImage('F:\\Workspace\\inv-veri\\v3\\4_chars', 5000)
#
# gen = InvoCapchaImage(version=3, text_type='fix', char_num=5, chinese_radio=0.5)
# gen.genImage('F:\\Workspace\\inv-veri\\v3\\5_chars', 5000)
#
# gen = InvoCapchaImage(version=3, text_type='fix', char_num=6, chinese_radio=0.5)
# gen.genImage('F:\\Workspace\\inv-veri\\v3\\6_chars', 5000)
# gen = InvoCapchaImage(version=3, text_type=None, char_num=6, chinese_radio=0.5)
# gen.genImage('F:\\Workspace\\inv-veri\\v3\\dynamic', 10000)
for i in range(1, 6):
gen = InvoCapchaImage(version=3, text_type='fix', char_num=i, chinese_radio=0.5)
file_path = f'F:\\Workspace\\inv-veri\\v3\\{i}_chars'
if not os.path.exists(file_path):
os.makedirs(file_path)
gen.genImage(file_path, 5000)
if __name__ == '__main__':
gen_thread()
- 网站上的验证码

- 生成的验证码
加载训练数据
网站的每一次查验输入的验证码内容需要你按照提示进行输入,它会提示你应该输入图片中什么类型的文字,有全部(黑色)、红色、蓝色和黄色。 一张验证码图片中可能会包含这四种颜色,那么意味着需要训练四个模型分别识别这四种类型的图片内容。
我没有采取训练四个模型的方案,而是使用在寻找字体时编写的图片处理代码,对图片进行预处理,提取图片中指定颜色的文字,并转为单通道的灰度图(原图是RGB三通道)。 这样做的好处是可以只训练一个模型
,不好的地方是以后每次识别都需要使用这套代码对图片进行预处理
,另外通过预处理的图片会丢失掉一部分特征会影响模型的收敛
。
import random
import cv2
import numpy as np
from paddle.io import Dataset
from paddleocr import paddleocr
from config import Config
from db.datasource import CaptchaDataSource
from img_helper import data_augmentation
class SqliteDataset(Dataset):
"""从 SQLite 中读取数据"""
def __init__(self,
version=1,
shape=(Config.IMAGE_SHAPE_C, Config.IMAGE_SHAPE_W, Config.IMAGE_SHAPE_H),
label_max_len=Config.LABEL_MAX_LEN,
characters=Config.CHARACTERS,
fill_char=Config.FILL, ):
"""
数据读取Reader
:param version: 数据的版本
:param shape: 张量的大小
"""
super().__init__()
self.shape = shape
self.characters = characters
self.fill_char = fill_char
self.label_max_len = label_max_len
self.version = version
self.path_list = []
self.label_list = []
self.all_data()
def all_data(self):
print('[*] 正在加载数据')
captcha_source = CaptchaDataSource()
rows = captcha_source.selectAll(self.version)
for [_, img_url, text, _, _] in rows:
self.path_list.append(img_url)
self.label_list.append(text)
print('[*] 数据加载完毕')
captcha_source.close()
def get_image(self, file_path):
# 捕获异常 - 在发生异常时终止训练
try:
image = cv2.imdecode(np.fromfile(file_path, dtype=np.uint8), cv2.COLOR_BGR2GRAY)
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
image = cv2.resize(image, self.shape[1:3])
# 数据增强
image = data_augmentation(image)
# 归一化
image = (image.astype("float32").reshape((self.shape[0], self.shape[2], self.shape[1])) / 255)
return image
except Exception as e:
raise Exception(f'{file_path}"\t文件打开失败,请检查路径是否准确以及图像文件完整性,报错信息如下:\n{str(e)}')
def get_label(self, label):
label = list(label)
label = [self.characters.index(str(l).upper()) for l in label]
# 使用填充是为了让输入形状统一,但是在输出label长度时,需要输出实际长度
label.extend([self.fill_char] * (self.label_max_len - len(label)))
# 将label转化为Numpy的array格式
label = np.array(label, dtype="int32")
return label
def __getitem__(self, index):
file_path = self.path_list[index]
image = self.get_image(file_path)
# 下标转换
label = self.label_list[index]
actually_len = len(label)
label_arr = self.get_label(label)
return image, label_arr, actually_len
def __len__(self):
# 返回每个Epoch中图片数量
return len(self.path_list)
训练
import logging
import warnings
import paddle
from paddle.io import DataLoader
from callback import LogCallback
from config import Config
from ctc_loss import CTCLoss
from dataset.dynamic import DynamicDataset
from dataset.sqlite import SqliteDataset
from metric import Acc
from net.crnn import CrnnNet
from net.res3 import ResNet3
from predict.infer_reader import InferReader
warnings.filterwarnings("ignore")
logging.disable(logging.DEBUG) # 关闭DEBUG日志的打印
logging.disable(logging.WARNING) # 关闭WARNING日志的打印
# 定义输入层,shape中第0维使用-1则可以在预测时自由调节batch size
input_define = paddle.static.InputSpec(shape=[-1, Config.IMAGE_SHAPE_C, Config.IMAGE_SHAPE_H, Config.IMAGE_SHAPE_W], dtype="float32", name="img", )
label_define = paddle.static.InputSpec(shape=[-1, Config.LABEL_MAX_LEN], dtype="int64", name="label")
def train_from_sqlite(layer, save_dir, epoch=Config.EPOCH, batch_size=Config.BATCH_SIZE):
# 训练模式
# layer.train()
# 动转静训练(如果需要直接到处 onnx 就不要使用,不然 input_spec 会很奇怪)
# layer = paddle.jit.to_static(layer)
# 实例化模型
model = paddle.Model(layer, inputs=input_define, labels=label_define)
model.summary()
# 训练数据集
train_dateset = SqliteDataset(3)
# 余弦退火
cosine = paddle.optimizer.lr.CosineAnnealingDecay(learning_rate=1.0e-3, T_max=(train_dateset.__len__() // batch_size) * epoch)
# 定义优化器
optimizer = paddle.optimizer.Adam(
learning_rate=cosine,
beta1=0.9,
beta2=0.999,
parameters=model.parameters(),
weight_decay=paddle.regularizer.L2Decay(1.0e-5),
)
# 为模型配置运行环境并设置该优化策略
model.prepare(optimizer=optimizer,
loss=CTCLoss(),
metrics=[Acc()])
train_data_loader = DataLoader(train_dateset,
batch_size=batch_size,
shuffle=True,
num_workers=64,
# 开启缓冲读取数据
use_buffer_reader=True,
drop_last=False)
eval_data_loader = DataLoader(InferReader('F:\\Workspace\\inv-veri\\validation_set'), batch_size=batch_size)
# 执行训练
model.fit(
train_data=train_data_loader,
eval_data=eval_data_loader,
epochs=epoch,
save_dir=save_dir,
save_freq=1,
verbose=1,
drop_last=True,
callbacks=[LogCallback()]
)
# 导出 onnx 模型
paddle.onnx.export(layer,
f'{save_dir}/onnx/resnet3',
input_spec=[input_define],
opset_version=11)
if __name__ == '__main__':
train_from_sqlite(ResNet3(), save_dir='./output/v3.0.3', epoch=100)
演示
使用 paddlepaddle 训练得到的模型文件,不能直接在 Java 中集成使用,需要将模型转换为 onnx 格式,另外我们需要将训练时所使用图片预处理代码使用 Java 重写。
package cn.youyo.service.invoice.onnx;
import ai.onnxruntime.*;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONUtil;
import cn.youyo.service.invoice.ImgHelper;
import lombok.extern.slf4j.Slf4j;
import java.awt.image.BufferedImage;
import java.io.File;
import java.nio.FloatBuffer;
import java.nio.charset.Charset;
import java.util.*;
/**
* @author: owen.chi
* @email: owen.chi@etailerhub.com
* @datetime: 2024/11/20
* @description: 验证码识别
*/
@Slf4j
public class OcrService {
private final OrtEnvironment env;
private final OrtSession session;
private final JSONArray charsetArray;
private final Map<String, String> colorMap = Map.of(
"红色", "RED",
"蓝色", "BLUE",
"黄色", "YELLOW",
"绿色", "GREEN",
"全部", "BLACK"
);
public OcrService(String onnxModelPath, String charset) throws OrtException {
try {
this.env = OrtEnvironment.getEnvironment();
this.session = env.createSession(onnxModelPath);
this.charsetArray = JSONUtil.readJSONArray(new File(charset), Charset.defaultCharset());
} catch (OrtException e) {
log.error("OCR 模型创建失败", e);
throw e;
}
}
public String ocr(BufferedImage image) throws OrtException {
return this.ocr("全部", image);
}
public String ocr(String color, final BufferedImage image) throws OrtException {
color = this.colorMap.get(color);
BufferedImage copied = ImgHelper.copyImage(image);
// 阈值处理
ImgHelper.thresholding(copied, color);
// 去噪
ImgHelper.denoise(copied);
// 模型的输入矩阵
long[] shape = {1, 1, copied.getHeight(), copied.getWidth()};
// 二值化
FloatBuffer input = this.binarization(copied, shape);
// 识别
return this.ocr(input, shape);
}
/**
* 识别
*
* @param input 输入
* @param shape 张量形状
* @return 识别结果
* @throws OrtException 异常
*/
public String ocr(FloatBuffer input, long[] shape) throws OrtException {
OnnxTensor inputTensor = OnnxTensor.createTensor(env, input, shape);
Map<String, OnnxTensor> inputs = new HashMap<>();
inputs.put("img", inputTensor);
// 进行预测
OnnxTensor indexTensor = (OnnxTensor) session.run(inputs).get(0);
// 处理输出,输出张量(1, seq_len, classes_num)
List<Integer> preChars = new ArrayList<>();
float[][][] value = (float[][][]) indexTensor.getValue();
for (float[] floats : value[0]) {
preChars.add(argmax(floats));
}
StringBuilder words = new StringBuilder();
for (Integer i : preChars) {
words.append((String) charsetArray.get(i));
}
return words.toString();
}
/**
* 取最大值的下标
*
* @param arr 数组
* @return 下标
*/
private int argmax(float[] arr) {
int maxIndex = 0;
float maxValue = arr[0];
for (int i = 1; i < arr.length; i++) {
if (arr[i] > maxValue) {
maxValue = arr[i];
maxIndex = i;
}
}
return maxIndex;
}
/**
* 二值化
*
* @param img 图片
* @param shape 张量形状
* @return 数据
*/
private FloatBuffer binarization(BufferedImage img, long[] shape) {
float[] data = new float[(int) (shape[0] * shape[1] * shape[2] * shape[3])];
int offset = 0;
for (int y = 0; y < 50; y++) {
for (int x = 0; x < 120; x++) {
// 提取灰度值(0-255)
int rgb = img.getRGB(x, y) & 0xFF;
// 归一化到 [0,1]
data[offset++] = rgb / 255.0f;
}
}
return FloatBuffer.wrap(data);
}
}
验证码识别

发票查验


代码库
Java 代码:https://gitee.com/coder-you/youyo-service-dependency 在 youyo-service-invoice 模块中
总结
截至今日,这个项目我已经花了近两个月的时间了,但仍未达到我的预期目标,如果换算成进度来说,只完成了 50% 左右。爬虫一开始我是不想使用 Selenium 来完成的,虽然它很容易解决反爬的难题,但是也会引入新的问题,如响应速度、内存消耗等;不仅是爬虫,验证码识别一开始也是使用的第三方,然后才转到自己训练模型。 之所以采用这些方式,是因为我一开始也不知道如何去做这个事情,所以先用最简单的方法先完成一个初版,然后再分别进行细节的优化。
目前验证码识别已经训练得到了一个基本可用的模型,还有一个优化模型正在训练,这里我不得不说模型的训练真的很考验设备性能(GPU: RTX4060 8G, CPU: I5-14600KF),也考验你的耐心;我得到的初版可用模型使用 50W 的训练样本(一开始的分类字符很少),训练了一天;目前正在训练的模型使用了 100+W 的训练数据预期需要 5 天左右。在网上翻阅资料时,有训练同样模型的博主说,数据集至少百万以上,而且训练时间会比较久;而且最好是分四个模型(前面我也说过了,进行预处理之后,图片中的文字特征会又丢失的情况)。
接下来我会着手去处理爬虫,通过js反混淆工具尝试还原代码,结合浏览器开发者工具进行分析源代码,总之就是要替换掉 Selenium。