简介:这份压缩包面向需要在 Windows 64 位环境下把 Tesseract OCR 集成进 Qt 项目的开发者,提供已编译好的头文件、动态库、静态库与 CMake 配置,省去自行编译和依赖排查的成本。包内共 916 个文件,约 39.32MB,以 546 个 .h 头文件、72 个 .dll 动态库、71 个 .cmake 构建脚本、50 个 .lib 导入库及 48 个 .pc 配置文件为主,并附带 exe 工具、pdb 调试符号、usage 示例和 json/txt 说明,目录覆盖 libxml2、WebP 等第三方依赖的 CMake 目标配置。已有 1126 人学习下载,适合具备一定 Qt/C++ 基础、需要快速验证 OCR 功能或搭建文字识别原型的开发者。拿到后可直接对照 include、lib 与 bin 目录接入工程,借助现成 CMake 配置减少链接错误,并通过示例与配置文件理解 Tesseract 及依赖库的组织方式,为后续语言包替换和发布部署留出清晰入口。
1. Qt OCR 想在 Windows 上跑起来,卡人的常常不是算法
Qt 桌面端接 Tesseract 做 OCR,卡住的地方大多不在识别算法,而在那份 tesseract 的 windows 64 位编译版本——库对不对、运行库一不一致、语言包全不全。前阵子有人在群里贴了段调用代码,编译零错误,运行时 Init 直接返回 -1,日志只有一行Error opening data file;翻到工程目录才发现,他挂的是几年前一份 32 位静态库,语言包还是旧格式。这类问题的根子不在代码,在二进制包本身:Qt 在 Windows 上默认走 MSVC 64 位,Tesseract 又依赖 leptonica、libjpeg、libpng、zlib 一串原生依赖,自己从源码编一遍,光 CMake 参数和第三方库就能耗掉半天,还得跟字符集、C++ 标准、运行库模式逐个对齐。这份 qt+tesseract 的 windows 64 位编译版本把 include、导入库、运行时 DLL 和 tessdata 打成一包,省掉的是编译环节,保留的是完整 API——你可以按 PSM 调版面分析,可以换语言包,可以自己控制像素格式。适合已经在写 Qt 桌面端、需要离线 OCR 的开发者,也适合手里有图片批处理流程、想加一道文字提取环节的工程团队。
2. 拆包与链接:include、lib、dll 三层怎么接进 Qt 工程
拿到一个编译包,第一件事不是写代码,是把目录结构看明白。这类 Windows 64 位包通常长成这样,各目录职责分明,缺一层就出问题:
| 路径 | 内容 | 是否随程序分发 |
|---|---|---|
include/tesseract/ | baseapi.h、publictypes.h、capi.h 等 C++ 接口头 | 否,仅编译期 |
include/leptonica/ | allheaders.h、pix.h 等图像结构头 | 否,仅编译期 |
lib/ | libtesseract*.lib、leptonica.lib 导入库 | 否,仅链接期 |
bin/ | libtesseract*.dll、leptonica.dll 及其依赖 | 是,必须和 exe 放一起 |
tessdata/ | eng.traineddata、chi_sim.traineddata 语言模型 | 是,可以外置目录 |
这里有个选择要先定:用动态库还是静态库。静态链接看着省事,不用分发 DLL,但 leptonica 内部还拽着 libpng、libjpeg、zlib,静态链接时这些符号一旦和工程里别的第三方库撞名,报错信息会非常难看;而且 MSVC 静态库对/MD和/MT极度敏感,一个用错就是满屏 LNK2005。我一般用动态库版本,DLL 缺失时的报错直白得多,排查成本低一个数量级。
2.1 先验证头文件和 DLL 是不是同一份源码编的
动手写工程之前,花两分钟确认版本一致性。头文件和 DLL 不同源是后期最难查的一类问题——编译能过,运行时节次性崩溃,栈还落在库里。查版本宏和位数:
# 1. 看头文件里的版本宏,确认 API 大版本 grep -n "TESSERACT_VERSION" include/tesseract/baseapi.h | head -5 # 2. 在 VS 开发者命令行里确认 DLL 的目标架构 dumpbin /headers bin/libtesseract*.dll | findstr /i "machine" # 3. 顺便列出 DLL 的外部依赖,看看有没有漏拷的 dumpbin /dependents bin/libtesseract*.dll第一条命令输出里通常能看到类似#define TESSERACT_VERSION 0x050000的十六进制值,前两位就是主版本。第二条命令必须看到machine (x64),如果显示x86,那就是 32 位包,后面链接到 64 位 Qt 工程必然在启动时直接 0xc000007b 崩掉,跟你写的代码一行关系都没有。第三条命令列出的是 DLL 的隐式依赖,常见的是leptonica.dll、zlib1.dll、libpng16.dll,这些都要在bin/目录里找得到,找不到就得补。
2.2 qmake 与 CMake 两套写法
qmake 的工程文件写法直接,三行就够,注意LIBS里-l后面跟的是去掉扩展名的库名,包里的文件叫libtesseract505.lib就得写-llibtesseract505,写错了链接器只给你一句cannot find -lxxx:
QT += core gui widgets CONFIG += c++17 TESS_ROOT = $$PWD/3rdparty/tesseract INCLUDEPATH += $$TESS_ROOT/include \ $$TESS_ROOT/include/tesseract \ $$TESS_ROOT/include/leptonica win32 { # 库名按包内 .lib 实际文件名去掉扩展名填写 LIBS += -L$$TESS_ROOT/lib -llibtesseract -lleptonica } # 构建后把运行期 DLL 自动拷到 exe 同目录,避免每次手动复制 win32 { CONFIG(debug, debug|release): DLL_DEST = $$shell_path($$OUT_PWD/debug) else: DLL_DEST = $$shell_path($$OUT_PWD/release) QMAKE_POST_LINK += $$quote(cmd /c copy /Y "$$shell_path($$TESS_ROOT/bin)\\*.dll" "$$DLL_DEST\\") }INCLUDEPATH之所以要写三层,是因为头文件里的 include 路径风格不统一:baseapi.h内部用的是#include <tesseract/publictypes.h>,而 leptonica 的头用的是#include <allheaders.h>,只有把include/tesseract和include/leptonica都加进去,两种写法才都能命中。QMAKE_POST_LINK里那个路径判断针对的是影子构建的默认目录布局,如果你在工程里自定义过DESTDIR,DLL_DEST要跟着改,否则 DLL 拷到别处,程序照样找不到。
用 CMake 的话结构差不多,只是把LIBS换成 target 级别的属性,顺带把 tessdata 也一起同步过去:
cmake_minimum_required(VERSION 3.16) project(ocr_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED) set(TESS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/tesseract") add_executable(ocr_demo main.cpp mainwindow.cpp) target_include_directories(ocr_demo PRIVATE "${TESS_ROOT}/include" "${TESS_ROOT}/include/tesseract" "${TESS_ROOT}/include/leptonica") target_link_directories(ocr_demo PRIVATE "${TESS_ROOT}/lib") target_link_libraries(ocr_demo PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets libtesseract leptonica) # 构建后同步 DLL 和语言包,保证从构建目录直接能跑 add_custom_command(TARGET ocr_demo POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${TESS_ROOT}/bin" "$<TARGET_FILE_DIR:ocr_demo>" COMMAND ${CMAKE_COMMAND} -E copy_directory "${TESS_ROOT}/tessdata" "$<TARGET_FILE_DIR:ocr_demo>/tessdata" COMMENT "copy tesseract runtime and tessdata")target_link_directories需要 CMake 3.13 以上,老工程还在 3.5 的话换成link_directories放在add_executable之前,作用域是全局的,容易污染别的 target。用 Qt6 的话把find_package和命名空间前缀整体换掉即可,链接库名不变。$<TARGET_FILE_DIR:ocr_demo>这个生成器表达式比手写路径稳,多配置生成器下 Debug 和 Release 各自拷到各自目录。
2.3 运行期的三件事:DLL、tessdata、工作目录
编译链接过了,双击 exe 报「找不到 libtesseract5xx.dll」,这是最常见的第一道坎。Qt 自带的windeployqt只处理 Qt 自己的 DLL,不认识 tesseract:
# 先把 Qt 依赖收齐 windeployqt --release --no-translations build/release/ocr_demo.exe # 再补 tesseract 与 leptonica 的 dll(xcopy 比手拖可靠) xcopy /Y /I "3rdparty\tesseract\bin\*.dll" "build\release\"--no-translations是省体积的,跟 OCR 无关;--no-opengl-sw视你的界面模块而定,纯 QWidget 工程可以加。真正决定能不能启动的是第二条命令,缺 leptonica.dll 时 Windows 的报错会是「找不到 libtesseract5xx.dll」,因为依赖是链式的,它不会告诉你缺的是下一层——用dumpbin /dependents逐层查才是正路。
tessdata 的定位有三种写法,效果和可移植性差别不小:
| 方式 | 写法 | 适用场景 |
|---|---|---|
| Init 显式传路径 | api->Init("D:/app/tessdata", "chi_sim") | 路径固定、单机部署,最可控 |
| 环境变量 | qputenv("TESSDATA_PREFIX", appDir) | 多进程共用一套语言包 |
| 默认查找 | api->Init(nullptr, "chi_sim") | 依赖 exe 同级 tessdata 目录 |
// 方式一:显式指定,路径由程序自己算,不受启动目录影响 const QString dataPath = QCoreApplication::applicationDirPath() + "/tessdata"; if (api->Init(dataPath.toUtf8().constData(), "chi_sim+eng") != 0) { qWarning() << "Init failed, check tessdata:" << dataPath; } // 方式二:设环境变量,Init 第一个参数传 nullptr qputenv("TESSDATA_PREFIX", QCoreApplication::applicationDirPath().toUtf8()); api->Init(nullptr, "chi_sim");第一种最省心,但要注意Init的第一个参数在不同小版本里对「传 tessdata 目录本身还是传它的父目录」处理不完全一致,遇到 Init 返回 -1 就两种都试一遍,能读到chi_sim.traineddata为准。第二种方式里TESSDATA_PREFIX指向的是 tessdata 的父目录,不是 tessdata 本身,这点和很多人直觉相反。还有一个高频翻车点:把 traineddata 打进 qrc 资源文件里,:/tessdata/chi_sim.traineddata这种路径 Tesseract 读不了,因为它走的是标准文件 IO,不是 Qt 的虚拟文件系统。真要用资源文件,就得在首次启动时把 traineddata 释放到QStandardPaths::AppDataLocation下的真实目录,再把这个目录传给 Init。
3. 最小闭环:QImage 进、QString 出的完整识别链路
Qt 里拿到图像通常是 QImage,Tesseract 要的是 leptonica 的 Pix。这一层转换是整条链路上最容易出错的地方,也是最容易被网上抄来的代码坑到的地方。转错了不会崩,只会安静地返回空串或者乱码,让你以为语言包坏了。
3.1 QImage 到 Pix:位布局对齐比什么都重要
leptonica 的 32bpp Pix 在内存里按0xRRGGBB的位布局组织,而QImage::Format_RGB32的内存字节序在小端机器上正好是B,G,R,0xFF,读成 32 位整数就是0xFFRRGGBB——两者的 RGB 位段位置天然一致。这就是为什么可以直接按行整块 memcpy,而不用逐像素调用pixSetRGBPixel:
// QImage -> Pix,调用方负责 pixDestroy Pix *qImageToPix(const QImage &src) { if (src.isNull()) return nullptr; // 统一转成 RGB32,保证位布局和 leptonica 32bpp 对齐 QImage img = src.convertToFormat(QImage::Format_RGB32); const int w = img.width(); const int h = img.height(); Pix *pix = pixCreate(w, h, 32); if (!pix) return nullptr; pixSetSpp(pix, 3); // 告诉 leptonica 按 3 通道处理,不带 alpha l_uint32 *data = pixGetData(pix); const int wpl = pixGetWpl(pix); // 每行的 l_uint32 个数,可能大于 w const int srcStride = img.bytesPerLine(); for (int y = 0; y < h; ++y) { const uchar *line = img.constScanLine(y); // 逐行拷贝,避免 wpl 与 bytesPerLine 不等时越界 memcpy(data + static_cast<size_t>(y) * wpl, line, static_cast<size_t>(w) * sizeof(l_uint32)); } return pix; }几个参数值得说明。pixSetSpp(pix, 3)这一步不能省,默认 spp 是 1,leptonica 在后续pixConvertTo8时会按单通道灰度理解内存,结果就是把颜色值当亮度,识别率高才怪。pixGetWpl返回的是每行占用的 32 位字数,出于内存对齐考虑它可能大于图像宽度,所以拷贝时源侧按w * 4字节、目的侧按wpl步进,不能图省事用wpl == w的假设一次拷完整个缓冲区。srcStride同理,QImage::bytesPerLine在宽度不是 4 的倍数时会补 padding,虽然 RGB32 本身每像素 4 字节天然对齐,但显式取用比硬算安全。
还有一个隐蔽的坑:constScanLine返回的是共享数据的指针,如果这个 QImage 是从别的线程传过来的浅拷贝,在转换过程中原对象被改写会触发 detach,指针可能失效。稳妥做法是转换前先img.detach(),或者干脆在转换函数入口用QImage img = src.convertToFormat(...)产生独立副本——上面代码里的convertToFormat在格式不一致时会返回新副本,但如果传进来的本来就是 RGB32,它仍然共享数据,所以多线程场景下建议显式 detach 一次。
3.2 一次完整识别:Init、SetImage、GetUTF8Text、释放
把转换和识别串起来,一个函数就能跑通端到端流程。注意每个 API 的返回值语义和内存归属:
// 返回空串表示失败,err 里带可读原因 QString recognizeText(const QImage &image, QString *err) { tesseract::TessBaseAPI api; const QString dataPath = QCoreApplication::applicationDirPath() + "/tessdata"; if (api.Init(dataPath.toUtf8().constData(), "chi_sim+eng") != 0) { if (err) *err = QStringLiteral("Init failed: tessdata or chi_sim.traineddata missing"); return QString(); } api.SetPageSegMode(tesseract::PSM_SINGLE_BLOCK); // 整块文本,先给个稳的默认 api.SetVariable("preserve_interword_spaces", "1"); // 保留词间空格,表格类版面有用 Pix *pix = qImageToPix(image); if (!pix) { if (err) *err = QStringLiteral("convert QImage to Pix failed"); return QString(); } api.SetImage(pix); // 不接管所有权,pix 仍归调用方 char *utf8 = api.GetUTF8Text(); // 内部 new[],必须自己 delete[] QString text = QString::fromUtf8(utf8 ? utf8 : ""); delete[] utf8; pixDestroy(&pix); // 用 pixDestroy,不要 delete api.End(); // 与 Init 配对,析构前显式调用更清晰 return text; }Init返回 0 才是成功,非 0 时不要指望后续调用有结果,直接报错退出。第二个参数用+拼多种语言,chi_sim+eng表示中文简体加英文,代价是初始化变慢、内存翻倍,纯中文票据场景直接用chi_sim更快也更准——chi_sim的训练数据本身就包含数字和常见英文字符。
GetUTF8Text返回的是内部new char[]出来的缓冲区,调用方负责delete[],用free或者交给智能指针的默认 deleter 都是未定义行为。返回的字节流是 UTF-8,转 QString 必须用fromUtf8,用fromLocal8Bit在中文 Windows 上会因为 GBK 解码产生乱码,而且乱得很有迷惑性——部分汉字正常、部分变成问号。
End()和析构函数都会释放内部资源,但多次复用同一个实例时,显式End()后再Init()是官方推荐的换语言包方式。如果只是换图,SetImage就够了,不需要重新 Init,这一点直接决定批量处理的吞吐。
3.3 参数怎么设:先 PSM,再分辨率,最后才是变量
Tesseract 的可调项很多,但调参有明确的优先级。乱调变量的结果通常是某几张图变好、整体变差,然后你也不知道是哪一项起的作用。
| 调用 | 常用取值 | 什么时候用 |
|---|---|---|
SetPageSegMode | PSM_AUTO(3) | 版面未知,先跑一遍看效果 |
PSM_SINGLE_BLOCK(6) | 截图、票面等单块文本,最常用 | |
PSM_SINGLE_LINE(7) | 已经裁好单行,比如验证码、编号 | |
PSM_SPARSE_TEXT(11) | 稀疏文字、带大量留白的海报 | |
SetOcrEngineMode | OEM_LSTM_ONLY(1) | 只用 LSTM,速度快,4.x 以后推荐 |
OEM_DEFAULT(3) | 让引擎自己选,兼容老模型 | |
SetSourceResolution | 300 | 图像实际 DPI,影响 LSTM 的行高归一化 |
SetVariable | user_defined_dpi=300 | 图片没带 DPI 元数据时的补救 |
调参顺序我一般这么走:先用PSM_AUTO跑一遍看整体,确认文字有没有被漏掉;漏了多半是版面分析把区域判成了图片,换成PSM_SINGLE_BLOCK或者先把目标区域裁出来;然后看字高,小于 20 像素的行识别率会断崖式下跌,这时候该做的是放大而不是调变量;最后才动SetVariable里的细项。SetSourceResolution和user_defined_dpi二选一即可,前者是 API,后者是变量,效果接近,同时设也没坏处。
有个流传很广但实际不好用的技巧:tessedit_char_whitelist。在 LSTM 引擎下这项的支持并不完整,设成0123456789之后仍然可能吐字母出来,尤其在低质量图像上。做纯数字识别(金额、编号)时,我现在的做法是正常识别 + 正则过滤 + 置信度筛选,比指望白名单可靠。
4. 中文与预处理:让 chi_sim 从「能用」到「敢用」
英文识别开箱即用,中文不是。同一个引擎、同一张图,eng能到 95% 的字准,chi_sim可能只有 70%,差别全在语言模型和图像质量上。这一章讲的就是把这两个变量按住。
4.1 语言包选型:标准版、best 版、fast 版的取舍
同一门语言通常有三种训练数据可选,文件名一样,体积和特性差很多:
| 版本 | 相对体积 | 速度 | 精度 | 适用 |
|---|---|---|---|---|
tessdata(标准) | 中 | 中 | 中 | 通用场景默认选择 |
tessdata_best | 大 | 慢 | 高 | 离线批处理、对准确率敏感 |
tessdata_fast | 小 | 快 | 略低 | 实时预览、移动端、大图粗筛 |
三者的 API 完全一致,换包就是换文件,代码一行不用改,这一点很值——先用 fast 版把流程跑通、把预处理调好,等效果稳定了再换 best 版看提升幅度,成本极低。中文还有个容易忽略的点:chi_sim和chi_sim_vert是两个模型,竖排文本(古籍、部分票据)必须用后者,拿chi_sim去识别竖排会得到一堆乱序字符,看着像识别失败,其实是方向不对。
中英混排场景里,chi_sim+eng不一定比chi_sim好。双语言会让解码时的候选空间翻倍,纯中文段落里偶尔会蹦出形近的英文字母。判断标准很简单:如果文档里英文占比低于 10%,直接用chi_sim;超过就叠加。真要精确控制,可以先用eng跑一遍判断英文比例,再决定用哪个语言组合,代价是两倍耗时。
4.2 预处理流水线:放大、灰度、二值化,顺序不能乱
中文 OCR 的准确率对字高极其敏感。经验阈值是 20 像素:低于这个数,LSTM 的行高归一化会把笔画糊成一团;高于 40 像素再往上加收益递减。所以第一步永远是判断尺寸并放大,而不是直接丢给引擎:
// 按目标字高估算放大倍数,字高一般取图像高度的 1/30 做粗估 QImage upscaleForOcr(const QImage &src, int targetCharHeight = 32) { if (src.isNull()) return src; // 粗估当前字高,至少放大 2 倍,最多 4 倍,避免内存爆炸 const int estCharHeight = qMax(1, src.height() / 30); int factor = qBound(2, (targetCharHeight + estCharHeight - 1) / estCharHeight, 4); return src.scaled(src.width() * factor, src.height() * factor, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); }放大用SmoothTransformation而不是FastTransformation,后者是最近邻插值,边缘会出现锯齿和阶梯,二值化之后这些阶梯会变成实心噪点,反而降低识别率。代价是耗时,1080p 图放大两倍大约多花几十毫秒,相比识别本身的几百毫秒可以接受。
二值化用 Otsu 自适应阈值,比固定阈值 128 稳得多,因为票据扫描件、手机拍照、屏幕截图这三类来源的整体亮度差很远:
// Otsu 全局阈值二值化,输入必须是 Grayscale8 QImage binarizeOtsu(const QImage &gray) { Q_ASSERT(gray.format() == QImage::Format_Grayscale8); int hist[256] = {0}; for (int y = 0; y < gray.height(); ++y) { const uchar *line = gray.constScanLine(y); for (int x = 0; x < gray.width(); ++x) ++hist[line[x]]; } const int total = gray.width() * gray.height(); double sum = 0.0; for (int <p> <a href="https://download.csdn.net/download/u010000915/87791304" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>