pp_ocr 0.3.2
pp_ocr: ^0.3.2 copied to clipboard
Flutter plugin for offline OCR using PP-OCRv6 (PaddleOCR) with ONNX Runtime C++ inference. Supports text detection and recognition on Windows and Linux.
example/lib/main.dart
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'dart:developer' as developer;
import 'package:material_ui/material_ui.dart';
import 'package:pp_ocr/pp_ocr.dart';
/// 应用版本号(与根 pubspec.yaml 的 version 保持同步)。
const String kAppVersion = '0.3.2';
/// 应用程序入口函数
void main() {
runApp(const OcrExampleApp());
}
/// OCR 示例应用根组件
class OcrExampleApp extends StatelessWidget {
const OcrExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'PP-OCRv6 Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
home: const OcrDemoPage(),
);
}
}
/// OCR 演示页面(有状态组件)
class OcrDemoPage extends StatefulWidget {
const OcrDemoPage({super.key});
@override
State<OcrDemoPage> createState() => _OcrDemoPageState();
}
/// OCR 演示页面的状态管理类
class _OcrDemoPageState extends State<OcrDemoPage> {
// PaddleOcr 引擎实例
final PaddleOcr _ocr = PaddleOcr();
// 模型路径配置 - 自动解析项目根目录下 model/ 文件夹的绝对路径,
// 避免硬编码绝对路径,使项目可在任意路径(含中文)下运行。
// 如模型位于其他位置,可在界面输入框中修改。
final TextEditingController _detModelCtrl = TextEditingController(
text: _modelPath('det.onnx'),
);
final TextEditingController _recModelCtrl = TextEditingController(
text: _modelPath('inference.onnx'),
);
final TextEditingController _dictPathCtrl = TextEditingController(
text: _modelPath('ppocr_v6_dict.txt'),
);
/// 解析模型文件的绝对路径。
///
/// 按以下顺序探测,命中即用:
/// 1. 上一级目录的 `model/`(工作目录为 example/ 或部署机 exe 目录时,
/// 模型位于其上一级,如 `C:\flutter_build\model\det.onnx`);
/// 2. 可执行文件同级的 `model/`(部署形态,如
/// `C:\flutter_build\pp_ocr_example\model\det.onnx`);
/// 3. 都未命中时返回上一级形态路径作为默认值(可在界面修改)。
static String _modelPath(String name) {
final sep = Platform.isWindows ? r'\' : '/';
final parent = File('${Directory.current.path}$sep..${sep}model$sep$name');
if (parent.existsSync()) return parent.absolute.path;
final exeDir = File(Platform.resolvedExecutable).parent.path;
final besideExe = File('$exeDir${sep}model$sep$name');
if (besideExe.existsSync()) return besideExe.absolute.path;
return parent.absolute.path;
}
// OCR 引擎是否已初始化
bool _initialized = false;
// 是否正在执行加载/识别操作
bool _loading = false;
// 状态信息(显示在底部状态栏)
String? _statusMessage;
// 当前选择的图片路径
String? _imagePath;
// OCR 识别结果列表
List<OcrResult> _results = [];
// 原始图片宽度(用于精确绘制检测框)
double _imageWidth = 0;
// 原始图片高度(用于精确绘制检测框)
double _imageHeight = 0;
// 原生后端信息(FFI 时为 "pp_ocr x.y.z",MethodChannel 时为平台名)。
// 与 kAppVersion 对照可发现 Dart 包与原生库版本错配(如部署残留旧 DLL)。
String? _backendInfo;
@override
void initState() {
super.initState();
_checkModelFiles();
_maybeStartSelfTest();
_loadBackendInfo();
}
/// 获取原生后端版本信息(异步,不阻塞界面)。
///
/// FFI 可用时 [PaddleOcr.getPlatformVersion] 返回原生库版本字符串
/// (形如 "pp_ocr 0.3.2");FFI 不可用回退 MethodChannel 时返回
/// 平台名("Windows 10+" / "Linux")。据此可判断当前生效的后端。
Future<void> _loadBackendInfo() async {
try {
final v = await _ocr.getPlatformVersion();
if (!mounted) return;
setState(() => _backendInfo = (v == null || v.isEmpty) ? null : v);
} catch (_) {
// 获取失败不影响主流程;保持 null 即状态栏不显示。
}
}
/// 自动化自检入口。
///
/// 当环境变量 `PP_OCR_SELFTEST_IMAGE` 指向一个可读的图片时,
/// 启动后自动执行 initialize → recognizeImage,完全跳过需要人工
/// 交互的 pickImage 分支,便于无头环境复现问题。
void _maybeStartSelfTest() {
final selfTestImage = Platform.environment['PP_OCR_SELFTEST_IMAGE'];
if (selfTestImage == null || selfTestImage.isEmpty) return;
// ignore: avoid_print
print('[SELFTEST] image=$selfTestImage');
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _initOcr();
await _runSelfTestOnImage(selfTestImage);
});
}
Future<void> _runSelfTestOnImage(String path) async {
// 可选:先走一遍 pickImage 路径,复现“dialog → respond → 后续 FFI
// 调用”的完整时序(需搭配 PP_OCR_PICKIMAGE_FAKE_PATH)。默认不开启,
// 避免 dialog 阻塞无 X server 的环境。
final viaPick = Platform.environment['PP_OCR_SELFTEST_VIA_PICK'] == '1';
try {
if (viaPick) {
// ignore: avoid_print
print('[SELFTEST] pickImage step 0: _ocr.pickImage()');
final picked = await _ocr.pickImage();
// ignore: avoid_print
print('[SELFTEST] pickImage step 0 done: "$picked"');
if (picked == null || picked.isEmpty) {
// ignore: avoid_print
print('[SELFTEST] pickImage returned empty, abort self-test');
return;
}
}
// ignore: avoid_print
print('[SELFTEST] step 1: stat file');
final f = File(path);
final stat = await f.stat();
// ignore: avoid_print
print('[SELFTEST] file size=${stat.size} bytes');
// ignore: avoid_print
print('[SELFTEST] step 2: readAsBytes');
final bytes = await f.readAsBytes();
// ignore: avoid_print
print('[SELFTEST] read ${bytes.length} bytes');
// ignore: avoid_print
print('[SELFTEST] step 3: decodeImageFromList');
final decoded = await decodeImageFromList(bytes);
// ignore: avoid_print
print('[SELFTEST] decoded ${decoded.width}x${decoded.height}');
// ignore: avoid_print
print('[SELFTEST] step 4: _ocr.recognizeImage');
final results = await _ocr.recognizeImage(path);
// ignore: avoid_print
print('[SELFTEST] step 4 done: ${results.length} results');
// ignore: avoid_print
print('[SELFTEST] step 5: _ocr.recognizeImageBytes');
final results2 = await _ocr.recognizeImageBytes(bytes);
// ignore: avoid_print
print('[SELFTEST] step 5 done: ${results2.length} results');
setState(() {
_imagePath = path;
_imageWidth = decoded.width.toDouble();
_imageHeight = decoded.height.toDouble();
_results = results;
_statusMessage =
'[SELFTEST] OK: '
'file=${bytes.length}B decoded=${decoded.width}x${decoded.height} '
'results=${results.length}/${results2.length}';
});
// ignore: avoid_print
print('[SELFTEST] ALL STEPS PASSED');
} catch (e, st) {
// ignore: avoid_print
print('[SELFTEST] FAILED: $e');
// ignore: avoid_print
print(st);
setState(() => _statusMessage = '[SELFTEST] FAILED: $e');
}
}
/// 检查默认模型文件是否存在,缺失时在状态栏给出明确提示。
void _checkModelFiles() {
final paths = [
_detModelCtrl.text,
_recModelCtrl.text,
_dictPathCtrl.text,
].map((p) => p.trim()).where((p) => p.isNotEmpty).toList();
final missing = paths.where((p) => !File(p).existsSync()).map((p) {
final idx = p.lastIndexOf(RegExp(r'[\\/]'));
return idx > 0 ? p.substring(idx + 1) : p;
}).toList();
if (missing.isEmpty) return;
_statusMessage =
'未找到模型文件:${missing.join('、')}。'
'请将 model 文件夹放到程序目录的上一级,'
'或在上方输入框中修改路径。';
}
@override
void dispose() {
// 释放所有文本控制器
_detModelCtrl.dispose();
_recModelCtrl.dispose();
_dictPathCtrl.dispose();
super.dispose();
}
/// 初始化 OCR 引擎
///
/// 使用用户配置的模型路径调用 [PaddleOcr.initialize],
/// 成功后会将 [_initialized] 置为 true。
Future<void> _initOcr() async {
setState(() {
_loading = true;
_statusMessage = 'Initializing OCR engine...';
});
// 入口防呆:模型文件不存在时直接提示,不进入原生初始化。
final missing = [
_detModelCtrl.text.trim(),
_recModelCtrl.text.trim(),
_dictPathCtrl.text.trim(),
].where((p) => p.isNotEmpty && !File(p).existsSync()).toList();
if (missing.isNotEmpty) {
setState(() {
_loading = false;
_statusMessage = '初始化失败:模型文件不存在——${missing.join('\n')}';
});
return;
}
try {
final ok = await _ocr.initialize(
detModelPath: _detModelCtrl.text.trim(),
recModelPath: _recModelCtrl.text.trim(),
dictPath: _dictPathCtrl.text.trim(),
);
// 初始化后刷新原生后端信息(FFI 引擎此时才真正创建)。
await _loadBackendInfo();
setState(() {
_initialized = ok;
_statusMessage = ok
? 'OCR engine initialized successfully.'
: 'Failed to initialize OCR engine. Check model paths.';
});
} catch (e, st) {
// 记录初始化异常到开发者日志
developer.log('Init error', error: e, stackTrace: st);
setState(() {
_statusMessage =
'Initialization error: $e\n'
'Type: ${e.runtimeType}\n'
'Stack: ${st.toString().split('\n').take(5).join(' | ')}';
});
} finally {
setState(() => _loading = false);
}
}
/// 选择图片并执行 OCR 识别
///
/// 流程:
/// 1. 检查 OCR 引擎是否已初始化
/// 2. 调用 [PaddleOcr.pickImage] 让用户选择图片
/// 3. 解码图片获取原始尺寸(用于检测框叠加)
/// 4. 调用 [PaddleOcr.recognizeImage] 执行识别
Future<void> _pickAndRecognize() async {
if (!_initialized) {
// 未初始化时提示用户
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please initialize OCR engine first')),
);
return;
}
setState(() {
_loading = true;
_statusMessage = 'Selecting image...';
// 重置上次识别的状态
_results = [];
_imagePath = null;
_imageWidth = 0;
_imageHeight = 0;
});
try {
// ignore: avoid_print
print('[PICK] step A: await _ocr.pickImage()');
final path = await _ocr.pickImage();
// ignore: avoid_print
print('[PICK] step A done: path="$path"');
if (path == null || path.isEmpty) {
// 用户未选择图片
setState(() {
_statusMessage = 'No image selected.';
_loading = false;
});
return;
}
// ignore: avoid_print
print('[PICK] step B: readAsBytes');
// 加载图片尺寸,用于精确叠加检测框
final imageFile = File(path);
final bytes = await imageFile.readAsBytes();
// ignore: avoid_print
print('[PICK] step B done: ${bytes.length} bytes');
// ignore: avoid_print
print('[PICK] step C: decodeImageFromList');
final decodedImage = await decodeImageFromList(bytes);
// ignore: avoid_print
print('[PICK] step C done: ${decodedImage.width}x${decodedImage.height}');
setState(() {
_imagePath = path;
_imageWidth = decodedImage.width.toDouble();
_imageHeight = decodedImage.height.toDouble();
_statusMessage = 'Recognizing text... This may take a moment.';
});
// ignore: avoid_print
print('[PICK] step D: _ocr.recognizeImage');
// 执行 OCR 识别
final results = await _ocr.recognizeImage(path);
// ignore: avoid_print
print('[PICK] step D done: ${results.length} results');
setState(() {
_results = results;
_statusMessage =
'Recognition complete. Found ${results.length} text region(s).';
_loading = false;
});
} on FormatException catch (e) {
// 处理 UTF-8 解码异常(通常来自原生层返回的无效数据)
developer.log('FormatException in pickAndRecognize', error: e);
setState(() {
_statusMessage =
'FormatException: ${e.message}\n'
'Offset: ${e.offset}\n'
'Type: ${e.runtimeType}\n'
'This indicates invalid UTF-8 data from native code.\n'
'Check native stderr logs for hex dumps.';
_loading = false;
});
} catch (e, st) {
// 处理其他未预期的异常
developer.log('pickAndRecognize error', error: e, stackTrace: st);
setState(() {
_statusMessage =
'Error: $e\n'
'Type: ${e.runtimeType}\n'
'Stack: ${st.toString().split('\n').take(5).join(' | ')}';
_loading = false;
});
}
}
/// 状态栏常驻信息行:后端与版本、原生库版本、最近一次识别的图尺寸。
String get _infoLine {
final parts = <String>[
'app v$kAppVersion',
if (_backendInfo != null) 'native: $_backendInfo',
if (_imageWidth > 0 && _imageHeight > 0)
'image: ${_imageWidth.round()}x${_imageHeight.round()}',
];
return parts.join(' | ');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('PP-OCRv6 Offline OCR Demo'),
const SizedBox(width: 12),
Text(
'v$kAppVersion',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
],
),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(
children: [
// 模型配置面板
_buildConfigPanel(),
const Divider(height: 1),
// 主内容区域
Expanded(
child: Row(
children: [
// 图片显示区域
Expanded(flex: 3, child: _buildImagePanel()),
const VerticalDivider(width: 1),
// 识别结果列表
Expanded(flex: 2, child: _buildResultsPanel()),
],
),
),
// 底部状态栏:常驻信息行(后端/版本/图尺寸)+ 临时消息(如有)。
// 后端信息与 Dart 包版本对照,可发现 Dart 与原生库版本错配。
Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 140),
padding: const EdgeInsets.all(8),
color: Colors.grey[200],
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectableText(
_infoLine,
style: const TextStyle(
fontSize: 12, fontFamily: 'monospace'),
),
if (_statusMessage != null) ...[
const SizedBox(height: 4),
SelectableText(
_statusMessage!,
style: const TextStyle(
fontSize: 12, fontFamily: 'monospace'),
),
],
],
),
),
),
],
),
);
}
/// 构建模型配置面板
///
/// 包含检测模型路径、识别模型路径、字典路径的输入框,
/// 以及"初始化"和"选择图片并识别"两个按钮。
Widget _buildConfigPanel() {
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Model Configuration',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
const SizedBox(height: 8),
Row(
children: [
// 检测模型路径输入框
Expanded(
child: TextField(
controller: _detModelCtrl,
decoration: const InputDecoration(
labelText: 'Det Model Path (.onnx)',
isDense: true,
border: OutlineInputBorder(),
),
style: const TextStyle(fontSize: 12),
),
),
const SizedBox(width: 8),
// 识别模型路径输入框
Expanded(
child: TextField(
controller: _recModelCtrl,
decoration: const InputDecoration(
labelText: 'Rec Model Path (.onnx)',
isDense: true,
border: OutlineInputBorder(),
),
style: const TextStyle(fontSize: 12),
),
),
const SizedBox(width: 8),
// 字典路径输入框
Expanded(
child: TextField(
controller: _dictPathCtrl,
decoration: const InputDecoration(
labelText: 'Dict Path (.txt)',
isDense: true,
border: OutlineInputBorder(),
),
style: const TextStyle(fontSize: 12),
),
),
const SizedBox(width: 8),
// 初始化按钮(加载中显示进度指示器)
ElevatedButton.icon(
onPressed: _loading ? null : _initOcr,
icon: _loading && !_initialized
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.settings),
label: Text(_initialized ? 'Re-init' : 'Initialize'),
),
const SizedBox(width: 8),
// 选择图片并识别按钮(仅在已初始化且非加载状态时可用)
ElevatedButton.icon(
onPressed: _loading || !_initialized ? null : _pickAndRecognize,
icon: const Icon(Icons.image_search),
label: const Text('Pick & Recognize'),
),
],
),
],
),
);
}
/// 构建图片显示面板
///
/// 未选择图片时显示占位提示;
/// 选择图片后显示图片,并通过 [OcrBoxPainter] 叠加绘制检测框。
Widget _buildImagePanel() {
if (_imagePath == null) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.image_outlined, size: 80, color: Colors.grey),
SizedBox(height: 16),
Text(
'Click "Pick & Recognize" to select an image',
style: TextStyle(color: Colors.grey),
),
],
),
);
}
return Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: Stack(
children: [
// 显示选中的图片
Center(
child: Image.file(File(_imagePath!), fit: BoxFit.contain),
),
// 检测框叠加层
if (_results.isNotEmpty)
Positioned.fill(
child: CustomPaint(
painter: OcrBoxPainter(
imagePath: _imagePath!,
results: _results,
imageWidth: _imageWidth,
imageHeight: _imageHeight,
),
),
),
],
),
),
),
],
);
}
/// 构建识别结果列表面板
///
/// 显示所有识别到的文本区域,每项包含序号、文本内容和置信度。
Widget _buildResultsPanel() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Recognition Results (${_results.length})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
Expanded(
child: _results.isEmpty
? const Center(
child: Text(
'No results yet',
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
itemCount: _results.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final r = _results[index];
return Card(
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
dense: true,
// 序号头像
leading: CircleAvatar(
radius: 14,
child: Text(
'${index + 1}',
style: const TextStyle(fontSize: 11),
),
),
// 识别文本
title: Text(
r.text,
style: const TextStyle(fontSize: 13),
),
// 置信度(百分比形式)
subtitle: Text(
'Confidence: ${(r.confidence * 100).toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 11),
),
),
);
},
),
),
],
);
}
}
/// 自定义画笔,用于在图片上绘制 OCR 检测框
///
/// 根据 [imageWidth] 和 [imageHeight] 将检测框坐标从图片空间
/// 转换到画布空间,确保检测框与显示的图片精确对齐。
class OcrBoxPainter extends CustomPainter {
/// 图片路径
final String imagePath;
/// OCR 识别结果列表
final List<OcrResult> results;
/// 原始图片宽度
final double imageWidth;
/// 原始图片高度
final double imageHeight;
OcrBoxPainter({
required this.imagePath,
required this.results,
required this.imageWidth,
required this.imageHeight,
});
@override
void paint(Canvas canvas, Size size) {
if (imageWidth <= 0 || imageHeight <= 0) return;
// 检测框画笔配置:红色半透明描边
final paint = Paint()
..color = Colors.red.withValues(alpha: 0.7)
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
// 计算将图片等比缩放到画布的缩放比例。
//
// 图片的实际显示控件是 Center(child: Image.file(fit: BoxFit.contain))。
// RenderImage 在宽松约束下通过 constrainSizeAndAttemptToPreserveAspectRatio
// 计算显示尺寸——只会把原图"夹小"到约束内,**不会放大**小于面板的图。
// 因此这里的 scale 必须加上 1.0 上限,与显示行为严格一致:
// * 图 >= 面板:scale = min(sx, sy)(等比缩小铺满,与 contain 一致)
// * 图 < 面板:scale = 1(原图尺寸显示,框也 1:1 映射)
// 否则小图会被此公式"虚拟放大",框整体溢出图片显示区(画框错位 bug)。
final double scaleX = size.width / imageWidth;
final double scaleY = size.height / imageHeight;
final double scale =
math.min(1.0, math.min(scaleX, scaleY));
// 实际显示的图片尺寸(宽高比不同时会出现留白)
final double displayedW = imageWidth * scale;
final double displayedH = imageHeight * scale;
// 图片在画布中的居中偏移量
final double offsetX = (size.width - displayedW) / 2;
final double offsetY = (size.height - displayedH) / 2;
// 文本标签绘制器
final textPainter = TextPainter(textDirection: TextDirection.ltr);
for (final result in results) {
// 检测框至少需要 4 个点
if (result.box.length < 4) continue;
// 将检测框坐标从图片空间转换到画布空间
final path = ui.Path();
path.moveTo(
offsetX + result.box[0].dx * scale,
offsetY + result.box[0].dy * scale,
);
for (int i = 1; i < result.box.length; i++) {
path.lineTo(
offsetX + result.box[i].dx * scale,
offsetY + result.box[i].dy * scale,
);
}
path.close();
canvas.drawPath(path, paint);
// 绘制文本标签(位于检测框左上角上方)
textPainter.text = TextSpan(
text: result.text,
style: const TextStyle(
color: Colors.red,
fontSize: 10,
backgroundColor: Colors.white70,
),
);
textPainter.layout(maxWidth: size.width);
textPainter.paint(
canvas,
Offset(
offsetX + result.box[0].dx * scale,
offsetY + result.box[0].dy * scale - 14,
),
);
}
}
@override
bool shouldRepaint(covariant OcrBoxPainter oldDelegate) {
// 当结果列表、图片宽高发生变化时需要重绘
return oldDelegate.results != results ||
oldDelegate.imageWidth != imageWidth ||
oldDelegate.imageHeight != imageHeight;
}
}