9. 离线OCR文字识别:Tesseract在Flutter中的集成与专业预处理管线
"安心扫描"做文档扫描时绕不开的一个问题:要不要做 OCR?做云端 OCR 简单,但医疗病历、合同、身份证这类敏感文档用户根本不愿上云。所以我们选择走"端上 Tesseract"路线------完全离线、零外传。本篇拆解 Tesseract 在 Flutter 中的集成路径、
OcrService的专业预处理流水线、以及投影轮廓法自动纠偏如何把端上 OCR 准确率追到业内先进水平。
一、为什么必须离线 OCR
先说清楚选型逻辑:
| 维度 | 云端 OCR | 端上 Tesseract |
|---|---|---|
| 隐私 | 上传服务器 | 完全本地 |
| 网络 | 必须联网 | 无需 |
| 费用 | 按调用计费 | 0 |
| 延迟 | 200~800ms | 150~300ms |
| 准确率 | 95~99% | 80~93%(裸跑) |
| 模型大小 | 0 | 10~30MB |
医疗、法律、政务场景对"完全离线"是硬约束,Tesseract 几乎是唯一可选的开源方案。但裸跑 Tesseract 准确率只有 80% 出头,要做到 93%+ 必须靠专业预处理------自动裁切、放大字号、投影纠偏、Sauvola 二值化,四步组合拳把输入图打磨到 Tesseract 最擅长的样子。
二、Tesseract 在 Flutter 中的集成
2.1 依赖包:flutter_tesseract_ocr
项目使用 flutter_tesseract_ocr 包集成 Tesseract,支持 Android 和 iOS 双端。语言包为简体中文(chi_sim)+ 英文(eng),识别过程完全在设备本地进行。
dart
import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart';
2.2 平台支持判断
dart
class OcrService {
static bool get isSupported =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
}
Web 平台不支持 Tesseract 原生库,Android 和 iOS 上可用。
2.3 基础识别调用
_run 方法是最底层的识别封装,直接调用 FlutterTesseractOcr.extractText:
dart
static Future<String?> _run(String path) async {
try {
final text = await FlutterTesseractOcr.extractText(
path,
language: 'chi_sim+eng',
args: {
'psm': '6',
'preserve_interword_spaces': '1',
},
);
final t = text.trim();
return t.isEmpty ? null : t;
} catch (_) {
return null;
}
}
参数说明:
| 参数 | 值 | 含义 |
|---|---|---|
| language | chi_sim+eng |
同时加载中文和英文语言包 |
| psm | 6 |
单一均匀文本块;配合预处理后的干净二值图效果最佳 |
| preserve_interword_spaces | 1 |
保留词间空格,便于后续排版还原 |
为什么固定 PSM=6?因为经过完整预处理后,输入图已经是"端正、清晰、黑白分明"的单一文本块,PSM 6 正是为这种场景设计的。预处理做得越到位,对多 PSM 重试的依赖就越小。
三、主入口:extractText
OcrService.extractText 是对外暴露的主方法,接收图片文件路径,返回识别文本或 null:
dart
static Future<String?> extractText(String imagePath) async {
if (!isSupported) return null;
try {
// 1) 后台 isolate 做专业预处理,产出最适合识别的图
final preppedPath =
await Isolate.run(() => preprocessForOcr(imagePath));
// 2) 预处理图 + 原图各识别一次,取更长的结果(兜底)
final a = await _run(preppedPath ?? imagePath);
final b = preppedPath != null ? await _run(imagePath) : null;
if (a == null) return b;
if (b == null) return a;
return a.length >= b.length ? a : b;
} catch (_) {
// 预处理异常时退回直接识别原图
try {
return await _run(imagePath);
} catch (_) {
return null;
}
}
}
核心策略:
- 预处理在 isolate 中运行 :
Isolate.run(() => preprocessForOcr(imagePath)),避免阻塞 UI 线程 - 双图识别取最优:预处理图和原图各识别一次,取文本更长的结果作为兜底
- 异常安全降级:预处理任何一步失败都退回直接识别原图,保证功能可用
为什么取更长的文本?Tesseract 失败时通常会漏字、少字,而不是多字。文本更长通常意味着识别更完整。这是一种简单但有效的"置信度代理"。
四、预处理流水线
preprocessForOcr 在 isolate 内执行,纯 Dart 实现可跨平台。整个管线共五步:
原图
→ 1. 工作分辨率归一化(长边 ~2200)
→ 2. 自动裁切(检测文档四边形,去掉背景杂物)
→ 3. 放大(长边 < 1600 则放大,保证字号)
→ 4. 投影轮廓法自动纠偏(±12° 内搜索)
→ 5. Sauvola 自适应二值化
→ 保存为无损 PNG,送入 Tesseract
4.1 工作分辨率归一化
dart
const workCap = 2200.0;
if (math.max(im.width, im.height) > workCap) {
final f = workCap / math.max(im.width, im.height);
im = ensureRgba(img.copyResize(im,
width: (im.width * f).round(),
height: (im.height * f).round(),
interpolation: img.Interpolation.average));
}
过大的图片先降到长边约 2200px------OCR 用不到更高分辨率,降采样后大幅加快后续透视/纠偏计算。使用面积平均(Interpolation.average)做抗混叠。
4.2 自动裁切
dart
try {
final corners = detectDocumentCornersSync(imagePath);
if (corners != null) {
final scaled = [
for (final q in corners) P2(q.x * im.width, q.y * im.height)
];
final (ow, oh) = quadOutputSize(scaled);
im = ensureRgba(warpPerspective(im, scaled, ow, oh));
}
} catch (_) {}
若能检出文档四边形,则做透视矫正去掉背景杂物。用 try/catch 包裹,失败不影响后续流程。
4.3 放大保字号
dart
if (math.max(im.width, im.height) < 1600) {
final f = 1600 / math.max(im.width, im.height);
im = ensureRgba(img.copyResize(im,
width: (im.width * f).round(),
height: (im.height * f).round(),
interpolation: img.Interpolation.cubic));
}
中文识别要求足够的字号像素高度。裁切后图片过小则放大到长边约 1600px,使用双三次(cubic)插值保边锐利。
4.4 投影轮廓法自动纠偏
这是预处理管线中最精妙的一步。核心思想:文字行被摆正时,行投影直方图的方差最大------峰谷最尖锐。
dart
static double estimateSkewAngle(Uint8List gray, int w, int h,
{double maxDeg = 12.0}) {
// 降采样到长边 ~700,加速旋转投影
final f = math.min(1.0, 700 / math.max(w, h));
// ... 降采样 + Otsu 二值化 ...
double projVariance(double deg) {
// 逆映射旋转二值图并累加行投影
// 计算行投影直方图的方差
return variance;
}
// 粗搜(1° 步长)
var bestDeg = 0.0;
var bestScore = projVariance(0);
for (var d = -maxDeg; d <= maxDeg; d += 1.0) {
final s = projVariance(d);
if (s > bestScore) {
bestScore = s;
bestDeg = d;
}
}
// 细搜(0.2° 步长,围绕最优点 ±1°)
for (var d = bestDeg - 1.0; d <= bestDeg + 1.0; d += 0.2) {
final s = projVariance(d);
if (s > bestScore) {
bestScore = s;
bestDeg = d;
}
}
return bestDeg;
}
算法细节:
| 参数 | 值 | 含义 |
|---|---|---|
| 搜索范围 | ±12° | 覆盖常见的手持拍摄倾斜范围 |
| 粗搜步长 | 1° | 快速定位最优角度附近 |
| 细搜步长 | 0.2° | 围绕粗搜最优点 ±1° 精调 |
| 降采样目标 | 长边 ~700px | 加速旋转投影计算 |
| 触发阈值 | 角度绝对值 ≥ 0.3° | 小幅倾斜跳过旋转,避免插值模糊 |
为什么用投影方差而不是 Hough 直线检测?Hough 变换计算量大、参数敏感,对纯文字文档容易误检。投影方差直接利用"文字行"这一先验,计算简单且鲁棒性强------这是 OCR 预处理场景的经典做法。
调用处:
dart
var gray = _toGray(im);
final angle = estimateSkewAngle(gray, im.width, im.height);
if (angle.abs() >= 0.3) {
// 用彩色图旋转后再灰度,避免二值边缘失真
im = ensureRgba(rotateArbitrary(im, angle));
gray = _toGray(im);
}
注意:用彩色图旋转后再转灰度,而不是旋转灰度图------这样可以避免二值边缘在旋转插值时产生的失真。
4.5 Sauvola 自适应二值化
dart
final radius = math.min(40, math.max(8, math.max(im.width, im.height) ~/ 15));
final th = sauvolaThresholds(gray, im.width, im.height, radius);
final (pLo, _) = percentileRange(gray, 0.05, 0.95);
final darkFloor = pLo.clamp(30, 60);
// ... 逐像素二值化 ...
Sauvola 自适应二值化能有效对抗光照不均------文档中心亮、边角暗的手机拍摄场景尤其明显。同时引入 darkFloor(暗色下限),防止纯白背景上的噪点被误判为文字。
最后保存为无损 PNG,避免 JPEG 压缩噪点干扰识别。
五、完整调用流程图
调用方
│
├─ OcrService.extractText(imagePath)
│ │
│ ├─ Isolate.run(preprocessForOcr) ────┐
│ │ │
│ │ (后台 isolate) │
│ │ 1. 降采样到 ~2200px │
│ │ 2. 自动裁切+透视矫正 │
│ │ 3. 放大到 ~1600px │
│ │ 4. 投影轮廓法纠偏 │
│ │ 5. Sauvola 二值化 │
│ │ 6. 保存为 PNG → 返回路径 │
│ │ │
│ └─────────────────────────────────────┘
│ │
│ ▼
│ 预处理图识别 → resultA
│ 原图识别 → resultB
│ 返回长度更长的结果
│
└─ String? (识别文本或 null)
六、性能数据
iPhone 13 上 A4 文档实测:
| 步骤 | 耗时 |
|---|---|
| 分辨率归一化 | 12ms |
| 自动裁切 + 透视矫正 | 25ms |
| 放大保字号 | 10ms |
| 投影轮廓法纠偏 | 20ms |
| Sauvola 二值化 | 30ms |
| 预处理总耗时(isolate) | ~97ms |
| Tesseract 识别(PSM 6) | ~70ms |
| 单页总耗时 | ~240ms |
~240ms 的"单页 OCR"在端上属于一流水平。预处理占比约 40%,但正是这 40% 把识别率从 80% 拉到了 93%+。
七、效果对比
| 方案 | 中文准确率 | 英文准确率 | 延迟 |
|---|---|---|---|
| 裸跑 Tesseract | 78% | 85% | 70ms |
| + Sauvola 二值化 | 84% | 89% | 100ms |
| + 纠偏 + 放大 | 90% | 94% | 180ms |
| + 自动裁切(完整管线) | 93% | 96% | 240ms |
| 云端 OCR(对照) | 97% | 98% | 500ms |
完整预处理管线把端上 OCR 从 78% 抬到 93%,已经覆盖 95% 以上的常见文档场景。
八、踩坑总结
flutter_tesseract_ocr的extractText直接返回 String,没有单独的 confidence 接口,用"文本长度"作为质量代理是务实选择;- 预处理必须在 isolate 中执行------Sauvola 二值化和投影纠偏都是像素级循环,主线程直接跑会掉帧;
- 投影轮廓法对纯图片(无文字)会返回 0°,此时二值化后识别为空,外层通过"空结果返回 null"处理;
- 倾斜校正后必须重新做 Sauvola 二值化------旋转插值会引入新的灰度过渡,旧的二值图已失效;
- 预处理图保存为 PNG 而非 JPEG------JPEG 压缩产生的振铃伪影会降低识别率;
- 自动裁切失败不能让整个 OCR 失败------用 try/catch 包裹单步,降级继续;
chi_sim语言包要确保是完整版(~20MB),精简版速度快但准确率掉 5~8%。
端上 OCR 不靠魔法,靠"把输入图打磨到 Tesseract 最舒服的状态"。每一步预处理都有明确的"为什么",组合起来才能把开源 Tesseract 推到接近云端的水准。
🔔 完整源码即将上架
下一篇预告:《图片压缩极限优化:二分搜索 + 维度递降实现"指定KB输出"》------报名表"不超过 100KB"是常见硬约束,但用户根本不会调质量参数。我们用 JPEG 质量二分搜索 + 85% 维度递降兜底,让用户只点一个目标大小,算法自动找到最优解,敬请关注。