前言
最近做一個(gè)小工具需要用到OCR签钩,一開(kāi)始用的是tesseract.js這個(gè)庫(kù),經(jīng)測(cè)試以后發(fā)現(xiàn)識(shí)別速度實(shí)在太慢凯亮,識(shí)別一張圖片基本都要耗時(shí)幾百毫秒甚至1~2秒边臼,而我的需求對(duì)于檢測(cè)實(shí)時(shí)性比較高,只有另尋他法假消。tesseract.js速度慢可能是因?yàn)樗趙asm移植柠并,性能損耗比較大;之后我嘗試直接使用Tesseract native版本,對(duì)比之下速度快了非常多臼予,于是決定使用node Napi 構(gòu)建本地模塊來(lái)使用tesseract鸣戴。
相關(guān)代碼倉(cāng)庫(kù)在https://github.com/ColorfulHorse/Ruminer,ocr native 模塊在native/ocr目錄下
編譯Tesseract
由于Tesseract并沒(méi)有給我們提供編譯好的動(dòng)態(tài)庫(kù)粘拾,所以需要自己從源碼編譯窄锅,官方文檔建議的三種方式中 Software Network 和 CPPAN 我試過(guò)之后都遇到了一些問(wèn)題,最后使用Vcpkg來(lái)編譯缰雇。其實(shí)也不是非要用包管理器來(lái)幫助編譯入偷,只不過(guò)一般庫(kù)都有一些子依賴,需要依次下載編譯子依賴械哟,不僅麻煩而且容易漏疏之,而包管理器會(huì)自動(dòng)幫我們下載編譯目標(biāo)庫(kù)的子依賴。
Vcpkg是微軟開(kāi)源的一個(gè)c/c++包管理工具暇咆,使用比較簡(jiǎn)單锋爪,參照文檔下載編譯安裝:
git clone https://github.com/microsoft/vcpkg
.\vcpkg\bootstrap-vcpkg.bat
之后將Vcpkg配置到環(huán)境變量方便使用,同時(shí)執(zhí)行vcpkg integrate install
將它集成到全局爸业。
運(yùn)行vcpkg search tesseract
可以看到Vcpkg已經(jīng)收錄了tesseract庫(kù)其骄,有三個(gè)版本,由于我們不需要訓(xùn)練數(shù)據(jù)也不需要交叉編譯扯旷,直接運(yùn)行vcpkg install tesseract:x64-windows
安裝windows 64位版本就可以了(如果需要編譯其他平臺(tái)的版本參照文檔另行修改)拯爽。
注意點(diǎn)
Vcpkg初次安裝庫(kù)時(shí)需要下載一些組件,比如cmake薄霜、nuget這些某抓,如果覺(jué)得下載太慢可以set http_proxy=代理
設(shè)置臨時(shí)代理,或者自己手動(dòng)復(fù)制鏈接下載相應(yīng)文件復(fù)制到vcpkg\downloads目錄惰瓜。然后編譯安裝這個(gè)過(guò)程也比較慢,等就是了汉矿。
安裝完成以后相關(guān)文件會(huì)在vcpkg\packages\tesseract_x64-windows下崎坊,把需要用到的.lib .dll 以及頭文件拿出來(lái)用就可以了。
構(gòu)建項(xiàng)目
安裝node-gyp
- 安裝Python洲拇,設(shè)置到環(huán)境變量
- 安裝Visual Studio或者windows-build-tools奈揍,我這里安裝的是VS2019,推薦安裝VS一勞永逸
- 安裝node-gyp赋续,
npm install -g node-gyp
男翰,它的作用相當(dāng)于cmake, 用來(lái)構(gòu)建本地模塊纽乱。
配置本地模塊
1.建立相關(guān)目錄
在項(xiàng)目目錄建立相關(guān)文件夾蛾绎,將tesseract41.lib文件、tesseract頭文件拷貝到目錄中;由于tesseract依賴leptonica庫(kù)進(jìn)行圖片處理租冠,我們也需要用到其中一些函數(shù)鹏倘,所以需要將leptonica的頭文件一并拷貝過(guò)去,所需文件和模塊目錄如圖
2. 下載Tesseract語(yǔ)言包
tesseract檢測(cè)不同語(yǔ)言需要相應(yīng)的語(yǔ)言包顽爹,官方提供了一些訓(xùn)練好的語(yǔ)言包纤泵,下載地址在https://github.com/tesseract-ocr/tessdata
將需要用到的語(yǔ)言包下載放入public目錄
3. 編寫(xiě)binding.gyp配置文件
node-gyp根據(jù)binding.gpy文件來(lái)構(gòu)建c/c++代碼,相當(dāng)于Cmake的CMakeLists镜粤,這里貼一份我的配置捏题,復(fù)制的時(shí)候記得把注釋刪掉
{
'targets': [
{
# 模塊名稱
'target_name': 'ocr',
"cflags!": [ "-fno-exceptions", "-fPIC" ],
"cflags_cc!": [ "-fno-exceptions", "-fPIC" ],
"ldflags": [
# 將當(dāng)前目錄加入到動(dòng)態(tài)庫(kù)搜索路徑,打包時(shí)將需要.dll文件全部拷貝到exe文件所在目錄
"-Wl,-rpath,'$$ORIGIN'"
],
"sources": [
# 需要編譯的源文件
'src/lib.cpp',
'src/index.cpp'
],
"include_dirs": [
# 頭文件目錄
"<!@(node -p \"require('node-addon-api').include\")",
"src/include"
],
"dependencies":[
# node本身需要的依賴
"<!(node -p \"require('node-addon-api').gyp\")"
],
'defines': [
'NAPI_DISABLE_CPP_EXCEPTIONS'
],
"conditions": [
["OS=='win'",
{
"libraries": [
# 需要鏈接的依賴庫(kù)肉渴,我這里另外用到了opencv做前處理
"-l<(module_root_dir)/libs/tesseract41.lib",
"-l<(module_root_dir)/libs/opencv_imgproc.lib",
"-l<(module_root_dir)/libs/opencv_features2d.lib"
]
}
]
]
}
]
}
編寫(xiě)代碼調(diào)用tesseract
我這邊的圖像源來(lái)自屏幕錄制涉馅,每幾百毫秒捕獲一次屏幕截圖,轉(zhuǎn)成base64字符串丟到c++層調(diào)用tesseract進(jìn)行識(shí)別黄虱,然后回傳結(jié)果稚矿,代碼如下。
lib.h
#ifndef OCR_LIB_H
#define OCR_LIB_H
#include "include/tesseract/baseapi.h"
#include "include/leptonica/allheaders.h"
#include <string>
using namespace std;
using namespace tesseract;
void init(string path);
int loadLanguage(string lang);
string recognize(string base64);
void destroy();
#endif
lib.cpp
#include "include/lib.h"
TessBaseAPI *api = nullptr;
string dataPath = "";
// 初始化
void init(string path) {
if (api == nullptr) {
api = new TessBaseAPI();
}
dataPath = path;
}
// 加載語(yǔ)言包
int loadLanguage(string lang) {
return api->Init(dataPath.c_str(), lang.c_str());
}
// 檢測(cè)圖片中的文字
string recognize(string base64) {
l_int32 size;
l_uint8* source = decodeBase64(base64.c_str(), strlen(base64.c_str()), &size);
PIX * pix = pixReadMem(source, size);
lept_free(source);
api->SetImage(pix);
api->SetSourceResolution(96);
string result = api->GetUTF8Text();
pixDestroy(&pix);
return result;
}
void destroy() {
if (api != nullptr) {
api->End();
delete api;
api = nullptr;
}
dataPath = "";
}
index.cpp 這里用來(lái)暴露接口提供給js層
#include <napi.h>
#include <string>
#include "include/lib.h"
using namespace Napi;
void Initialize(const CallbackInfo& info) {
Env env = info.Env();
init(info[0].ToString());
}
Number LoadLang(const CallbackInfo& info) {
Env env = info.Env();
int ret = loadLanguage(info[0].ToString());
return Napi::Number::New(env, ret);
}
String Recognize(const CallbackInfo& info) {
Env env = info.Env();
string text = recognize(info[0].ToString());
String res = Napi::String::New(env, text);
delete[] text.c_str();
return res;
}
void Destroy(const CallbackInfo& info) {
destroy();
}
// 設(shè)置類(lèi)似于 exports = {key:value}的模塊導(dǎo)出
Object Init(Env env, Object exports) {
exports["init"] = Function::New(env, Initialize);
exports["loadLanguage"] = Function::New(env, LoadLang);
exports["recognize"] = Function::New(env, Recognize);
exports["destroy"] = Function::New(env, Destroy);
return exports;
}
NODE_API_MODULE(ocr, Init)
編譯生成庫(kù)文件
寫(xiě)好邏輯代碼之后打開(kāi)命令行切換路徑到binding.gyp文字所在目錄捻浦,執(zhí)行node-gyp configure
晤揣, node-gyp build
;順利的話將會(huì)在build/Release目錄下生成ocr.node庫(kù)文件朱灿,而它需要依賴的dll文件vcpkg也會(huì)幫我們拷貝過(guò)來(lái)昧识,非常方便。
編寫(xiě)js/ts文件調(diào)用本地模塊
index.ts
import { loadAddonFile } from '@/utils/NativeUtil'
const ocr = loadAddonFile('src/native/ocr/build/Release/ocr.node', 'ocr.node')
export default {
init: (langPath: string) => {
ocr.init(langPath)
},
loadLanguage: (lang: string): number => {
return ocr.loadLanguage(lang)
},
recognize: (base64: string): Array<string> => {
return ocr.recognize(base64)
},
destroy: () => {
return ocr.destroy()
}
}
注意點(diǎn)
如何使用本地模塊盗扒,正常情況直接`require(xxx.node)就可以了跪楞,由于我的項(xiàng)目使用vue-cli-plugin-electron-builder構(gòu)建,所以導(dǎo)入時(shí)要做一些路徑處理
import path from 'path'
declare const __non_webpack_require__: any
export function loadAddonFile(devSrc: string, productSrc: string) {
if (process.env.NODE_ENV !== 'production') {
// 開(kāi)發(fā)環(huán)境從項(xiàng)目目錄導(dǎo)入
// eslint-disable-next-line
return __non_webpack_require__(path.join(process.cwd(), devSrc))
} else {
// 生產(chǎn)環(huán)境從打包根目錄導(dǎo)入
// eslint-disable-next-line
return __non_webpack_require__(path.join(process.resourcesPath, '../' + productSrc))
}
}
另外還要將.dll文件拷貝到打包后生成的.exe文件目錄下侣灶,否則會(huì)找不到庫(kù)甸祭,electron-builder配置如下
builderOptions: {
productName: 'xxx',
appId: 'xxx',
copyright: 'xxx',
extraFiles: [
{
// 拷貝dll庫(kù)文件到打包根目錄
from: 'src/native/ocr/build/Release',
to: '.'
}
]
}
一切完成后就可以使用測(cè)試一下是否能夠正常運(yùn)行了,如果不正常大概率是導(dǎo)入路徑不正確或者缺少一些dll庫(kù)文件褥影。
提升識(shí)別準(zhǔn)確度
經(jīng)過(guò)一些測(cè)試發(fā)現(xiàn)池户,tesseract基本上只能識(shí)別出文字顏色和背景顏色有明顯區(qū)別,而且背景顏色比較單一的圖片凡怎,例如白底黑字黑底白字這種校焦,我猜測(cè)它對(duì)于圖片只是做了簡(jiǎn)單的二值化。但是我的需求比較復(fù)雜一點(diǎn)统倒,有時(shí)候文字背景比較復(fù)雜寨典,背景和文字顏色差別也不是很明顯,所以需要對(duì)圖片做一些前處理提升準(zhǔn)確度房匆。這里使用OpenCV來(lái)做一些簡(jiǎn)單的處理耸成,先使用MSER算法配合一些形態(tài)學(xué)操作檢測(cè)文本區(qū)域报亩,然后裁剪文字區(qū)域進(jìn)行Otsu二值化,最后丟給tesseract進(jìn)行檢測(cè)墓猎。
基本流程
- 原圖轉(zhuǎn)換為灰度圖
- 對(duì)灰度圖做MSER+和MSER-
- 將MSER+和MSER-檢測(cè)到的區(qū)域填充成白色
- 將MSER+和MSER-的結(jié)果圖取交集
- 交集圖做MORPH_CLOSE閉運(yùn)算操作捆昏,消除鄰近區(qū)域之間的空隙
- 查找區(qū)域輪廓,將原圖中符合要求的區(qū)域裁剪出來(lái)
修改后的代碼如下
lib.h
#ifndef OCR_LIB_H
#define OCR_LIB_H
#include "include/tesseract/baseapi.h"
#include "include/leptonica/allheaders.h"
#include <string>
#include "opencv2/opencv.hpp"
using namespace std;
using namespace tesseract;
void init(string path);
int loadLanguage(string lang);
vector<string> recognize(string base64);
void destroy();
cv::Mat pixToMat(Pix *pix);
Pix *mat8ToPix(cv::Mat *mat8);
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
std::string base64_decode(std::string const& encoded_string);
std::vector<cv::Rect> getRect(cv::Mat srcImage);
#endif
lib.cpp
#include "include/lib.h"
TessBaseAPI *api = nullptr;
string dataPath = "";
void init(string path) {
if (api == nullptr) {
api = new TessBaseAPI();
}
dataPath = path;
}
int loadLanguage(string lang) {
int ret = api->Init(dataPath.c_str(), lang.c_str(), OEM_LSTM_ONLY);;
return ret;
}
vector<string> recognize(string base64) {
string decoded_string = base64_decode(base64);
vector<uchar> data(decoded_string.begin(), decoded_string.end());
cv::Mat img = cv::imdecode(data, cv::IMREAD_UNCHANGED);
cv::Mat gray;
cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY);
vector<cv::Rect> rects = getRect(gray);
vector<string> res;
for (size_t i = 0; i < rects.size(); i++) {
cv::Rect rect = rects[i];
cv::Mat area(gray, rect);
// 二值化
cv::threshold(area, area, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);
PIX * pix = mat8ToPix(&area);
api->SetImage(pix);
api->SetSourceResolution(96);
string result = api->GetUTF8Text();
pixDestroy(&pix);
res.push_back(result);
}
return res;
}
void destroy() {
if (api != nullptr) {
api->End();
delete api;
api = nullptr;
}
dataPath = "";
}
/**
* Mat灰度圖轉(zhuǎn)Pix
*/
Pix *mat8ToPix(cv::Mat *mat8) {
Pix *pixd = pixCreate(mat8->size().width, mat8->size().height, 8);
for(int y=0; y<mat8->rows; y++) {
for(int x=0; x<mat8->cols; x++) {
pixSetPixel(pixd, x, y, (l_uint32) mat8->at<uchar>(y,x));
}
}
return pixd;
}
static inline bool is_base64(unsigned char c) {
return (isalnum(c) || (c == '+') || (c == '/'));
}
/**
* base64解碼
*/
std::string base64_decode(std::string const& encoded_string) {
int in_len = encoded_string.size();
int i = 0;
int j = 0;
int in_ = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (encoded_string[in_] != '=') && is_base64(encoded_string[in_])) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i == 4) {
for (i = 0; i < 4; i++)
char_array_4[i] = base64_chars.find(char_array_4[i]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; (i < 3); i++)
ret += char_array_3[i];
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++)
char_array_4[j] = 0;
for (j = 0; j < 4; j++)
char_array_4[j] = base64_chars.find(char_array_4[j]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (j = 0; (j < i - 1); j++) ret += char_array_3[j];
}
return ret;
}
/**
* 檢測(cè)文本區(qū)域
*/
std::vector<cv::Rect> getRect(cv::Mat gray) {
cv::Mat gray_neg;
// 取反值灰度
gray_neg = 255 - gray;
std::vector<vector<cv::Point> > regContours;
std::vector<vector<cv::Point> > charContours;//點(diǎn)集
// 創(chuàng)建MSER對(duì)象
// _max_variation 最大變化率大于此值的將被忽略
// _min_diversity 兩個(gè)區(qū)域的區(qū)別小于此值將被忽略
cv::Ptr<cv::MSER> mesr1 = cv::MSER::create(5, 20, 5000, 0.5, 0.3);
cv::Ptr<cv::MSER> mesr2 = cv::MSER::create(5, 20, 400, 0.1, 0.3);
std::vector<cv::Rect> bboxes1;
std::vector<cv::Rect> bboxes2;
// MSER+ 檢測(cè)
mesr1->detectRegions(gray, regContours, bboxes1);
// MSER-操作
mesr2->detectRegions(gray_neg, charContours, bboxes2);
cv::Mat mserMapMat = cv::Mat::zeros(gray.size(), CV_8UC1);
cv::Mat mserNegMapMat = cv::Mat::zeros(gray.size(), CV_8UC1);
for (size_t i = 1; i < regContours.size(); i++) {
// 根據(jù)檢測(cè)區(qū)域點(diǎn)生成mser+結(jié)果
const std::vector<cv::Point>& r = regContours[i];
for (size_t j = 0; j < r.size(); j++) {
cv::Point pt = r[j];
mserMapMat.at<unsigned char>(pt) = 255;
}
}
//MSER- 檢測(cè)
for (size_t i = 1; i < charContours.size(); i++) {
// 根據(jù)檢測(cè)區(qū)域點(diǎn)生成mser-結(jié)果
const std::vector<cv::Point>& r = charContours[i];
for (size_t j = 0; j < r.size(); j++) {
cv::Point pt = r[j];
mserNegMapMat.at<unsigned char>(pt) = 255;
}
}
cv::Mat mserResMat;
mserResMat = mserMapMat;
mserResMat = mserMapMat & mserNegMapMat; // mser+與mser-位與操作
// 開(kāi)運(yùn)算
cv::Mat mserClosedMat;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(20, 20));
cv::morphologyEx(mserResMat, mserClosedMat,
cv::MORPH_CLOSE, kernel);
// 尋找外部輪廓
std::vector<std::vector<cv::Point> > plate_contours;
cv::findContours(mserClosedMat, plate_contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE, cv::Point(0, 0));
// 候選區(qū)域判斷輸出
std::vector<cv::Rect> candidates;
for (size_t i = 0; i != plate_contours.size(); ++i) {
// 求解最小外界矩形
cv::Rect rect = cv::boundingRect(plate_contours[i]);
// 寬高比例
double wh_ratio = rect.width / double(rect.height);
if (wh_ratio > 0.5) {
// 忽略太小的區(qū)域
if (rect.width > 50) {
// 區(qū)域加一些間隔以免字符不完整
const int margin = 5;
int l = rect.x - margin < 0 ? 0 : rect.x - margin;
int t = rect.y - margin < 0 ? 0 : rect.y - margin;
int r = l + rect.width + margin > gray.cols ? gray.cols : l + rect.width + margin;
int b = t + rect.height + margin > gray.rows ? gray.rows : t + rect.height + margin;
cv::Rect rec(l, t, r - l, b - t);
candidates.push_back(rec);
}
}
}
return candidates;
}
index.cpp導(dǎo)出函數(shù)也要做一些修改
Array Recognize(const CallbackInfo& info) {
Env env = info.Env();
vector<string> textList = recognize(info[0].ToString());
Array array = Array::New(env);
for (size_t idx = 0; idx < textList.size(); idx++) {
// The HandleScope is recommended especially when the loop has many
// iterations.
Napi::HandleScope scope(env);
array[idx] = Napi::String::New(env, textList[idx]);
}
return array;
}
加入這些優(yōu)化之后在一定程度上提升了文字識(shí)別的準(zhǔn)確率,但是如果用于識(shí)別自然場(chǎng)景中的文字恐怕還是無(wú)能為力,這就要涉及到深度學(xué)習(xí)領(lǐng)域了革半,不在本文討論范圍內(nèi)讹剔。
結(jié)語(yǔ)
在electron中構(gòu)建node本地模塊還是有不少坑的,一方面對(duì)windows的庫(kù)鏈接不太熟悉,gyp也不熟悉,一方面node-addon的文檔也不太容易讀,浪費(fèi)了不少時(shí)間遍烦,對(duì)一個(gè)半吊子來(lái)說(shuō)也可以了;好在最后基本把設(shè)想的東西都完成了躺枕,收獲也很多服猪。