Java实现印刷体转手写体—妈妈再也不用担心我被罚抄作业了
郑重声明
因本文中涉及到爬虫程序,该爬虫源码仅用于交流学习
如果要使用本文中的技术或源码,请务必严格遵守每个网站根目录下的robots.txt爬虫协议
因擅自用作其他用途而产生的法律风险请自行承担!
缘起
随着人工智能、深度学习的发展,OCR(通用文字识别)技术开始逐渐兴起
从一开始的印刷体识别,到手写体识别,识别的准确率随着学习数据的增长和学习模型的完善而越来越高
从初中进入少年编程班时候就开始幻想机器如果能像人类一样思考是怎样一番光景
但是没有想到这一天来得这么快
碍于笔者水平有限,不能带大家去复刻OCR背后的技术
那么今天,我们就来做一个“反人工智能程序”(简称“人工智障”)
简言之就是反其道而行之,OCR将手写文字转成了文本文件,我们此刻就要做一个把文本转成以假乱真手写体的程序
至于用处。。。
那可大有用处!
小时候只要犯了错就会被老师罚抄课本,而且动不动就是5遍10遍,给我的九年义务教育留下了深深的阴影,相信大家也感同身受(别人家的孩子请绕道)
那时候就在想:要是能有一个自动帮我抄课本的机器人该有多好!
作为连变色手机壳都能做出来的程序员们,天下没有能难倒我们的需求。
今天我们就一起来实现一个抄课本神器。
当然,我们离实现小时候的梦想还差一个时光机。(又是什么狗血穿越剧 - -)
废话不多说,我们进入正题!
开始开发
在开始开发之前,我们首先需要解决几个问题:
- 文字怎么转成手写体(重点)?
- 怎么把手写体“写到纸上”?
- 怎么让手写体看起来尽量逼真?
我这里提供一下自己的拙见,如果有更好的解决方案欢迎指正:
-
文字怎么转成手写体(重点)?
首先就是上网找找有没有现成的“轮子”
笔者度娘了一下“手写字体在线生成器”,除去第一条广告,第二条结果笔者认为是不二之选
点击进入的效果大概是这样
就决定是它了!
-
怎么把手写体“写到纸上”?
这个难不倒我,因为之前有用Java代码绘制海报的经历,也算半个熟手
决定用Java AWT 来处理绘制问题
-
怎么让手写体看起来尽量逼真?
这个需要对自己平时写字有个观察
- 同样的字写两遍大小、笔顺都不相同
- 因为体态的原因,同一行字会略有倾斜
- 总之就是四个字:“乱就完了”
综上所述,处理方式也很简单——加个随机数
分析完毕,现在我们先来对“第一字体网”的请求来进行分析,然后写个简单的“爬虫”
-
首先打开浏览器,摁下F12,发送一个生成文字请求,分析请求
经过一段时间的观察,得出结论: 图中红框部分就是我们要的请求地址
点开可以看见我们的参数都是以form-data的形式传给后台的
-
接下来就是清洗数据,得到我们想要的结果
对返回结果进行分析,可以看出返回的是一整个页面
而我们需要的仅仅是生成后的这张图片
而这张图片所在元素的id为
imgResult
对整个请求流程分析完毕,我们接下来就可以开始创建项目撸代码了
-
新建一个SpringBoot项目text-generator(也可以是一个Maven工程或者是Java程序)
这是一个典型SpringBoot项目结构,
如果对SpringBoot不熟悉的小伙伴可以移步到我的Spring系列手写教案
-
首先我们需要编辑一下resources下的yml配置文件,将我们上面得出的结论给写到配置里
# 服务器运行端口 server: port: 80 # 请求地址 url: http://m.diyiziti.com/shouxie # 画布(纸张)大小 canvas: width: 720 height: 1000
-
接下来我们需要配置一个RestTemplate用于发送请求
/** * @Description * @Author LaoQin * @Date 2020/03/15 22:42 **/ @Configuration public class RestConfig { @Bean public RestTemplate restTemplate(){ return new RestTemplate(); } }
-
然后我们需要写一个文字转图片的工具类(请求我们上面的接口)
/** * @Description 文字转手写体图片 * @Author LaoQin * @Date 2020/03/15 22:40 **/ @Component public class TextToImg { @Autowired RestTemplate restTemplate; @Value("${url}") String url; /** * @Author LaoQin * @Description //TODO 将文字转成手写文字并返回 * @Date 22:47 2020/03/15 * @Param [text] 要转写的文字 * @return java.io.File 返回的文件 **/ public InputStream textToImg(String text,Integer fontInfoId,Integer fontSize,Integer imageWidth,Integer ImageHeight,String fontColor) throws IOException { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); MultiValueMap<String,Object> map = new LinkedMultiValueMap<>(); map.add("Content",text); map.add("FontInfoId",fontInfoId); map.add("ActionCategory",1); map.add("FontSize",fontSize); map.add("ImageWidth",imageWidth); map.add("ImageHeight",ImageHeight); map.add("FontColor",fontColor); map.add("ImageBgColor",""); System.out.println("请求参数:"+map); HttpEntity entity = new HttpEntity<>(map,headers); String result = restTemplate.postForObject(url, entity, String.class); Document document = Jsoup.parse(result); Element element = document.getElementById("imgResult"); String src = element.attr("src"); //new一个URL对象 URL url = new URL(src); //打开链接 HttpURLConnection conn = (HttpURLConnection)url.openConnection(); //设置请求方式为"GET" conn.setRequestMethod("GET"); //超时响应时间为5秒 conn.setConnectTimeout(5 * 1000); //通过输入流获取图片数据 InputStream inStream = conn.getInputStream(); return inStream; } }
代码里有注释,这里我说一下核心流程:
- 发送一个携带各项参数(详见代码)的Post请求,获得一个网页
- 将网页用Jsoup解析,并且获取id为imgResult的img元素的src属性
其核心代码为
Document document = Jsoup.parse(result); Element element = document.getElementById("imgResult"); String src = element.attr("src");
- 接下来就是将获取到的src(图片地址)作为请求地址,发送一个Get请求并获取到输入流返回
其核心代码为
//new一个URL对象 URL url = new URL(src); //打开链接 HttpURLConnection conn = (HttpURLConnection)url.openConnection(); //设置请求方式为"GET" conn.setRequestMethod("GET"); //超时响应时间为5秒 conn.setConnectTimeout(5 * 1000); //通过输入流获取图片数据 InputStream inStream = conn.getInputStream(); return inStream;
-
下一步我们需要建立Ctrl层和Service层
Ctrl层的代码为
/** * @Description * @Author LaoQin * @Date 2020/03/15 23:03 **/ @RestController public class BaseCtrl { @Autowired BaseService baseService; @PostMapping(value = "text") public String test(String title,String text,String color,HttpServletResponse response) throws Exception { baseService.text(title,text,color); return null; } }
Service接口代码为
public interface BaseService{ void text(String title, String text, String color) throws Exception; }
ServiceImpl代码为
package com.scj.text.generator.serviceImpl; import com.scj.text.generator.service.BaseService; import com.scj.text.generator.util.TextToImg; import com.sun.image.codec.jpeg.JPEGCodec; import com.sun.image.codec.jpeg.JPEGImageEncoder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; import java.util.Random; @Service public class BaseServiceImpl implements BaseService { @Autowired TextToImg textToImg; @Value("${canvas.width}") int canvasWidth; @Value("${canvas.height}") int canvasHeight; private final int[] FONT_LIST = {455,464,465,81};//预设字体列表 private final int FONT_SIZE = 36;//预设字体大小 private static int pageNum = 0;//当前页数 @Override public void text(String title, String text, String color) throws Exception { //创建Image BufferedImage image = new BufferedImage(canvasWidth,canvasHeight,BufferedImage.TYPE_INT_RGB); File file = new File("D:\\img-output\\img"+pageNum+".png"); if(!file.exists()){//没有则创建 File folder = new File("D:\\img-output"); folder.mkdir();//创建文件夹 file.createNewFile(); } //创建输出流 FileOutputStream out = new FileOutputStream(file); //创建画笔 Graphics g = image.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0,0,canvasWidth,canvasHeight); //画标题 //算出起笔位置 int beginX = (canvasWidth-FONT_SIZE*(title.length()))/2; if(beginX<0){ System.out.println("标题超限,自动移到行首"); beginX = 0; } System.out.println("起笔位置为:"+beginX); //创建随机数 Random random = new Random(); if(title==null){ title = ""; } for(int i=0;i<title.length();i++){ char c = title.charAt(i); InputStream inputStream; int randomNum = random.nextInt(FONT_LIST.length); //读取图片 try { inputStream = textToImg.textToImg(c+"",FONT_LIST[randomNum],FONT_SIZE,FONT_SIZE*2,FONT_SIZE*2,color); }catch (Exception e){ //触发重试机制 System.out.println("3秒后重试。。。"); Thread.sleep(3000); i--; continue; } BufferedImage fontImg = ImageIO.read(inputStream); //绘制合成图像 g.drawImage(fontImg,beginX,0,FONT_SIZE,FONT_SIZE,null); beginX+=FONT_SIZE/2; } int x = 0; int y = "".equals(title)?10:(FONT_SIZE/2+20); //将正文字符串拆分 for(int i=0;i<text.length();i++){ char c = text.charAt(i); //判断是否遇到换行符 if(c=='\n'){ System.out.println("遇到换行符!"); x = 0; y += (FONT_SIZE/2+10); //换纸 if(y+FONT_SIZE>canvasHeight){ pageNum++; //释放资源 g.dispose(); JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out); encoder.encode(image); //关闭流 out.close(); System.out.println("完成!"); text("",text.substring(i+1),color); return; } continue; } String str = c+""; int flag = 0;//是否遇到数字 int count = 1;//遇到的数字长度 //判断是否遇到数字 if(Character.isDigit(c)){ System.out.println("遇到数字!"); flag = 1; while(Character.isDigit(text.charAt(i+count))){ count++; } System.out.println("提取长度为"+count); str = text.substring(i,i+count); System.out.println("提取数字为"+str); i+=(count-1); } //生成随机数 int randomNum = random.nextInt(FONT_LIST.length); int randomX = random.nextInt(5); int randomY = random.nextInt(5)-3; int fontSize = FONT_SIZE+randomNum; int imgWidth = fontSize*2; InputStream inputStream; //读取图片 try { inputStream = textToImg.textToImg(str,flag==1?462:FONT_LIST[randomNum],flag==1?fontSize/4*3:fontSize,flag==0?imgWidth:imgWidth*count/2,imgWidth,color); }catch (Exception e){ //触发重试机制 System.out.println("3秒后重试。。。"); Thread.sleep(3000); i--; continue; } BufferedImage fontImg = ImageIO.read(inputStream); //绘制合成图像 g.drawImage(fontImg,count>4?x-30:x+randomX,y+randomY,flag==0?FONT_SIZE:FONT_SIZE*count/2,FONT_SIZE,null); if(count>2){ x+=(imgWidth-FONT_SIZE*3/2)*count/2-10; }else{ x+=(imgWidth-FONT_SIZE*3/2); } if(x+FONT_SIZE>canvasWidth){ x = 0; y += (imgWidth-FONT_SIZE*3/2+10); if(y+FONT_SIZE>canvasHeight){ //换纸 pageNum++; //释放资源 g.dispose(); JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out); encoder.encode(image); //关闭流 out.close(); System.out.println("完成!"); text("",text.substring(i+1),color); return; } } } //释放资源 g.dispose(); JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out); encoder.encode(image); //关闭流 out.close(); System.out.println("完成!"); } }
因为只是demo,并没有及时去重构自己的代码,让代码有一些“坏味道”,但是不影响核心思路,各位看官凑合先看着
这里说一下思路,这也是这个程序中最关键的代码
-
首先需要注入我们刚才写的工具类和配置文件里的内容
其核心代码如下
@Autowired TextToImg textToImg; @Value("${canvas.width}") int canvasWidth; @Value("${canvas.height}") int canvasHeight;
-
然后上第一字体网寻找几个和我笔迹比较接近的手写字体,大家切换字体的时候在chrome控制台>network下就能看到id,和我笔迹比较相仿的我找了4个,分别是{455,464,465,81},将其定义为常量
其核心代码如下
private final int[] FONT_LIST = {455,464,465,81};//预设字体列表
-
接下来是设置字体大小,因为我们的画布是700*1000的大小,再加上我平时写字都写的特别小,所以我的字体大小为36,当然一般正常的笔迹在这个大小的画布上应该是48~60,大家可以根据自己的习惯调整
其核心代码如下
private final int FONT_SIZE = 36;//预设字体大小
-
接下来我们则要创建画布、画笔和最终输出的文件,这里默认输出到D盘下的img-output文件夹,且文件名为img+当前页数+.png后缀
其核心代码如下
//创建Image BufferedImage image = new BufferedImage(canvasWidth,canvasHeight,BufferedImage.TYPE_INT_RGB); File file = new File("D:\\img-output\\img"+pageNum+".png"); if(!file.exists()){//没有则创建 File folder = new File("D:\\img-output"); folder.mkdir();//创建文件夹 file.createNewFile(); } //创建输出流 FileOutputStream out = new FileOutputStream(file); //创建画笔 Graphics g = image.createGraphics(); g.setColor(Color.WHITE); g.fillRect(0,0,canvasWidth,canvasHeight); //画标题 //算出起笔位置 int beginX = (canvasWidth-FONT_SIZE*(title.length()))/2; if(beginX<0){ System.out.println("标题超限,自动移到行首"); beginX = 0; } System.out.println("起笔位置为:"+beginX);
-
接下来需要创建一个随机数去随机选择我们的字体、大小、x和y坐标偏移(不然太板正看着太假),同时因为网络不可靠的缘故,我们需要在请求时去捕获异常,建立请求失败的重试机制(默认3秒后重试)
以下是核心代码
//生成随机数 int randomNum = random.nextInt(FONT_LIST.length); int randomX = random.nextInt(5); int randomY = random.nextInt(5)-3; int fontSize = FONT_SIZE+randomNum; int imgWidth = fontSize*2; InputStream inputStream; //读取图片 try { inputStream = textToImg.textToImg(str,flag==1?462:FONT_LIST[randomNum],flag==1?fontSize/4*3:fontSize,flag==0?imgWidth:imgWidth*count/2,imgWidth,color); }catch (Exception e){ //触发重试机制 System.out.println("3秒后重试。。。"); Thread.sleep(3000); i--; continue; } BufferedImage fontImg = ImageIO.read(inputStream); //绘制合成图像 g.drawImage(fontImg,count>4?x-30:x+randomX,y+randomY,flag==0?FONT_SIZE:FONT_SIZE*count/2,FONT_SIZE,null); if(count>2){ x+=(imgWidth-FONT_SIZE*3/2)*count/2-10; }else{ x+=(imgWidth-FONT_SIZE*3/2); }
-
我们还需要判断画笔当前位置,如果y坐标加上字体大小超过画布高度,我们就认为再写字就会超出画布,这时候就需要“换纸”了,同样x坐标+字体大小超过画布宽度我们则需要换行书写
换纸本质就是把已经书写内容截断后“交给”下一页继续书写,即一个递归调用
其核心代码如下
if(x+FONT_SIZE>canvasWidth){ x = 0; y += (imgWidth-FONT_SIZE*3/2+10); if(y+FONT_SIZE>canvasHeight){ //换纸 pageNum++; //释放资源 g.dispose(); JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out); encoder.encode(image); //关闭流 out.close(); System.out.println("完成!"); text("",text.substring(i+1),color);//递归书写 return; } }
-
最后别忘了释放资源
核心代码如下
//释放资源 g.dispose(); JPEGImageEncoder encoder = JPEGCodec.createJPEGEncoder(out); encoder.encode(image); //关闭流 out.close(); System.out.println("完成!");
到这里我们想要的效果就实现完毕了
我们来测试一下效果如何~
-
测试效果
我们使用Postman发送一个post请求来测试最终效果
首先我在网络上找到了一篇小学生课文《少年闰土》节选,相信这篇课文会勾起很多90后的童年回忆
《少年闰土》
深蓝的天空中挂着一轮金黄的圆月,下面是海边的沙地,都种着一望无际的碧绿的西瓜。其间有一个十一二岁的少年,项带银圈,手捏一柄钢叉,向一匹猹尽力地刺去。那猹却将身一扭,反从他的胯下逃走了。
这少年便是闰土。我认识他时,也不过十多岁,离现在将有三十年了;那时我的父亲还在世,家景也好,我正是一个少爷。那一年,我家是一件大祭祀的值年。这祭祀,说是三十多年才能轮到一回,所以很郑重。
正月里供像,供品很多,祭器很讲究,拜的人也很多,祭器也很要防偷去。我家只有一个忙月(我们这里给人做工的分三种:整年给一定人家做工的叫长工;按日给人做工的叫短工;自己也种地,只在过年过节以及收租时候来给一定的人家做工的称忙月),忙不过来,他便对父亲说,可以叫他的儿子闰土来管祭器的。
我的父亲允许了;我也很高兴,因为我早听到闰土这名字,而且知道他和我仿佛年纪,闰月生的,五行缺土,所以他的父亲叫他闰土。他是能装弶捉小鸟雀的。
我于是日日盼望新年,新年到,闰土也就到了。好容易到了年末,有一日,母亲告诉我,闰土来了,我便飞跑地去看。他正在厨房里,紫色的圆脸,头戴一顶小毡帽,颈上套一个明晃晃的银项圈,这可见他的父亲十分爱他,怕他死去,所以在神佛面前许下愿心,用圈子将他套住了。他见人很怕羞,只是不怕我,没有旁人的时候,便和我说话,于是不到半日,我们便熟识了。
我们那时候不知道谈些什么,只记得闰土很高兴,说是上城之后,见了许多没有见过的东西。
第二日,我便要他捕鸟。他说:“这不能。须大雪下了才好,我们沙地上,下了雪,我扫出一块空地来,用短棒支起一个大竹匾,撒下秕谷,看鸟雀来吃时,我远远地将缚在棒上的绳子只一拉,那鸟雀就罩在竹匾下了。什么都有:稻鸡,角鸡,鹁鸪,蓝背……”
我于是又很盼望下雪。
闰土又对我说:“现在太冷,你夏天到我们这里来。我们日里到海边捡贝壳去,红的绿的都有,鬼见怕也有,观音手也有。晚上我和爹管西瓜去,你也去。”
“管贼吗?”
“不是。走路的人口渴了摘一个瓜吃,我们这里是不算偷的。要管的是獾猪,刺猬,猹。月亮地下,你听,啦啦地响了,猹在咬瓜了。你便捏了胡叉,轻轻地走去……”
我那时并不知道这所谓猹的是怎么一件东西——便是现在也不知道——只是无端地觉得状如小狗而很凶猛。
“它不咬人吗?”
“有胡叉呢。走到了,看见猹了,你便刺。这畜生很伶俐,倒向你奔来,反从胯下窜了。它的皮毛是油一般的滑……”
我素不知道天下有这许多新鲜事:海边有如许五色的贝壳;西瓜有这样危险的经历,我先前单知道它在水果店里出卖罢了。
“我们沙地里,潮汛要来的时候,就有许多跳鱼儿只是跳,都有青蛙似的两个脚……”
啊!闰土的心里有无穷无尽的稀奇的事,都是我往常的一朋友所不知道的。闰土在海边时,他们都和我一样,只看见院子里高墙上的四角的天空。
可惜正月过去了,闰土须回家里去。我急得大哭,他也躲到厨房里,哭着不肯出门,但终于被他父亲带走了。他后来还托他的父亲带给我一包贝壳和几支很好看的鸟毛,我也曾送他一两次东西,但从此没有再见面。
我在朦胧中,眼前又展开一片海边碧绿的沙地来,上面深蓝的天空中挂着一轮金黄的圆月。
我们看看这篇文章最终会转写成为什么样子:
postman截图
请求过程中
最终生成效果
开源地址和总结
这个程序虽然不是很完美,但是也算圆了小时候的一个幻想
我想编程对于一个程序员可能不仅仅是一个谋生手段
我们也可以在其中收获许许多多别的东西
项目我已开源至github,感兴趣的小伙伴可以自己试试,因为是demo,难免会有不足,欢迎各位大佬指正。
感谢各位看官赏识,感兴趣小伙伴可以点个关注,我会不定时在博客里更新自己在工作和生活中的一些所见所闻所感。~from 老邋遢
-完-
转载:https://blog.csdn.net/itkfdektxa/article/details/104948446