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 老邋遢
-完-