OCR: embedded, cross-platform ONNX backend with pluggable fallback
Make on-device OCR a pluggable local service so it runs locally on every platform (not just Windows), aimed at GoodNotes/Notability-class handwriting on low-power hardware (e.g. Zen2 APU, CPU/iGPU). - New OcrBackend abstraction (lib/services/ocr/): selector prefers an embedded ONNX recognition backend, falling back to the OS-native backend (Windows WinRT), and to a clean no-op when neither is available. - OnnxRecognitionBackend: flutter_onnxruntime session from a bundled asset, dart:ui preprocessing (resize to 48px, CHW float32, normalized), pure-Dart CTC greedy decode. Fully guarded — absent model/dict is a no-op; never throws. - ocr_engine.dart kept as a thin facade (recognizeImage) delegating to the selector, so ocr_service.dart is unchanged. - CtcDecoder unit-tested (6 tests). flutter analyze clean; all tests pass. - Model is not committed; tool/fetch_ocr_model.sh + assets/models/ocr/README.md document fetching PP-OCRv4 rec + dict on the dev machine. - CI: forward HTTPS_PROXY to the Windows build so CMake can fetch the ONNX Runtime native lib behind the GFW; README documents the system-install alternative. PP-OCR geometry/blank assumptions documented for on-device tuning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
92
lib/services/ocr/ctc_decoder.dart
Normal file
92
lib/services/ocr/ctc_decoder.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
/// Pure-Dart CTC (Connectionist Temporal Classification) greedy decoder.
|
||||
///
|
||||
/// Decodes per-timestep class logits into a string by taking the argmax at
|
||||
/// each timestep, collapsing consecutive duplicate classes, dropping the
|
||||
/// blank class, and mapping the remaining class indices to characters.
|
||||
///
|
||||
/// Index mapping note (PaddleOCR PP-OCR rec convention with [blankIndex] == 0):
|
||||
/// the CTC blank occupies class index 0, so the character dictionary is
|
||||
/// shifted by one. The character for class index `k` (k >= 1) is
|
||||
/// `charset[k - 1]`. If [blankIndex] != 0, this exact shift may not apply and
|
||||
/// the mapping should be reviewed for the specific exported model.
|
||||
class CtcDecoder {
|
||||
CtcDecoder(this.charset, {this.blankIndex = 0});
|
||||
|
||||
/// The character dictionary (without the blank entry).
|
||||
final List<String> charset;
|
||||
|
||||
/// The class index reserved for the CTC blank symbol.
|
||||
final int blankIndex;
|
||||
|
||||
/// Decode `[T][C]` logits into a string.
|
||||
///
|
||||
/// For each timestep the argmax over the `C` classes is taken; consecutive
|
||||
/// duplicate indices are collapsed and the blank index is dropped. Remaining
|
||||
/// indices are mapped to characters via the dictionary shift described in the
|
||||
/// class docs. Out-of-range indices are skipped.
|
||||
String decode(List<List<double>> logits) {
|
||||
final buffer = StringBuffer();
|
||||
var previousIndex = -1;
|
||||
|
||||
for (final row in logits) {
|
||||
if (row.isEmpty) {
|
||||
previousIndex = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// argmax over the classes of this timestep.
|
||||
var bestIndex = 0;
|
||||
var bestValue = row[0];
|
||||
for (var c = 1; c < row.length; c++) {
|
||||
if (row[c] > bestValue) {
|
||||
bestValue = row[c];
|
||||
bestIndex = c;
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse consecutive duplicates.
|
||||
if (bestIndex == previousIndex) {
|
||||
continue;
|
||||
}
|
||||
previousIndex = bestIndex;
|
||||
|
||||
// Drop the blank class.
|
||||
if (bestIndex == blankIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final ch = _charForIndex(bestIndex);
|
||||
if (ch != null) {
|
||||
buffer.write(ch);
|
||||
}
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
/// Reshape a flat row-major `[T*C]` list into `[T][C]` and decode it.
|
||||
String decodeFlat(List<double> flat, int timeSteps, int numClasses) {
|
||||
if (timeSteps <= 0 || numClasses <= 0) return '';
|
||||
final logits = <List<double>>[];
|
||||
for (var t = 0; t < timeSteps; t++) {
|
||||
final start = t * numClasses;
|
||||
final end = start + numClasses;
|
||||
if (end > flat.length) break;
|
||||
logits.add(flat.sublist(start, end));
|
||||
}
|
||||
return decode(logits);
|
||||
}
|
||||
|
||||
/// Map a class index to its character, applying the blank shift. Returns null
|
||||
/// for the blank index or out-of-range indices.
|
||||
String? _charForIndex(int index) {
|
||||
if (index == blankIndex) return null;
|
||||
|
||||
// With blankIndex == 0 the dictionary is shifted by one: class index k
|
||||
// maps to charset[k - 1]. For other blank positions we fall back to a
|
||||
// direct index, which may need adjustment per the exported model.
|
||||
final mapped = blankIndex == 0 ? index - 1 : index;
|
||||
if (mapped < 0 || mapped >= charset.length) return null;
|
||||
return charset[mapped];
|
||||
}
|
||||
}
|
||||
30
lib/services/ocr/native_ocr_backend.dart
Normal file
30
lib/services/ocr/native_ocr_backend.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'ocr_backend.dart';
|
||||
|
||||
/// Platform OCR backend. Uses the Windows built-in OCR engine exposed through
|
||||
/// the native `badnote/ocr` MethodChannel.
|
||||
class NativeOcrBackend implements OcrBackend {
|
||||
static const _channel = MethodChannel('badnote/ocr');
|
||||
|
||||
@override
|
||||
String get name => 'native';
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async => Platform.isWindows;
|
||||
|
||||
@override
|
||||
Future<String?> recognize(Uint8List pngBytes) async {
|
||||
if (!Platform.isWindows) return null;
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('recognize', pngBytes);
|
||||
final text = result?.trim();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
lib/services/ocr/ocr_backend.dart
Normal file
19
lib/services/ocr/ocr_backend.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// A pluggable local OCR backend.
|
||||
///
|
||||
/// Implementations turn a PNG image into recognized text. The app selects an
|
||||
/// available backend via [OcrBackends]; absence of any backend is a clean
|
||||
/// no-op (recognition returns null).
|
||||
abstract class OcrBackend {
|
||||
/// Short identifier used for logging/selection (e.g. 'native', 'onnx').
|
||||
String get name;
|
||||
|
||||
/// Whether this backend can run on the current device/build. May perform a
|
||||
/// lazy initialization attempt (e.g. loading a model) the first time.
|
||||
Future<bool> isAvailable();
|
||||
|
||||
/// Recognize text from a PNG image. Returns null when nothing is recognized
|
||||
/// or the backend is unavailable. Implementations must never throw.
|
||||
Future<String?> recognize(Uint8List pngBytes);
|
||||
}
|
||||
45
lib/services/ocr/ocr_backends.dart
Normal file
45
lib/services/ocr/ocr_backends.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'native_ocr_backend.dart';
|
||||
import 'ocr_backend.dart';
|
||||
import 'onnx_recognition_backend.dart';
|
||||
|
||||
/// Selects and caches the active local OCR backend.
|
||||
///
|
||||
/// Preference order: the embedded ONNX recognition backend if its model is
|
||||
/// bundled and loads, otherwise the native platform backend, otherwise none.
|
||||
class OcrBackends {
|
||||
OcrBackends._();
|
||||
|
||||
static OcrBackend? _active;
|
||||
static bool _resolved = false;
|
||||
|
||||
/// Resolve (once) and return the preferred available backend, or null when
|
||||
/// no backend is available on this device/build.
|
||||
static Future<OcrBackend?> active() async {
|
||||
if (_resolved) return _active;
|
||||
|
||||
final candidates = <OcrBackend>[
|
||||
OnnxRecognitionBackend(),
|
||||
NativeOcrBackend(),
|
||||
];
|
||||
|
||||
for (final backend in candidates) {
|
||||
if (await backend.isAvailable()) {
|
||||
_active = backend;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_resolved = true;
|
||||
return _active;
|
||||
}
|
||||
|
||||
/// Recognize text using the active backend. Returns null when no backend is
|
||||
/// available or nothing was recognized.
|
||||
static Future<String?> recognize(Uint8List png) async {
|
||||
final backend = await active();
|
||||
if (backend == null) return null;
|
||||
return backend.recognize(png);
|
||||
}
|
||||
}
|
||||
204
lib/services/ocr/onnx_recognition_backend.dart
Normal file
204
lib/services/ocr/onnx_recognition_backend.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_onnxruntime/flutter_onnxruntime.dart';
|
||||
|
||||
import 'ctc_decoder.dart';
|
||||
import 'ocr_backend.dart';
|
||||
|
||||
/// ONNX-based text recognition backend.
|
||||
///
|
||||
/// NOTE: assumes PP-OCRv4 mobile rec (input 3x48xW, CTC blank=0). Verify
|
||||
/// on-device; the dictionary/blank convention may need adjustment per the exact
|
||||
/// exported model.
|
||||
///
|
||||
/// The model and dictionary are bundled as assets and are optional: if either
|
||||
/// is missing this backend reports unavailable and recognition is a clean
|
||||
/// no-op (returns null). It never throws out of [recognize].
|
||||
class OnnxRecognitionBackend implements OcrBackend {
|
||||
static const _modelAsset = 'assets/models/ocr/rec.onnx';
|
||||
static const _dictAsset = 'assets/models/ocr/ppocr_keys_v1.txt';
|
||||
|
||||
// Rec model input geometry.
|
||||
static const _targetHeight = 48;
|
||||
static const _minWidth = 16;
|
||||
static const _maxWidth = 320;
|
||||
|
||||
OrtSession? _session;
|
||||
CtcDecoder? _decoder;
|
||||
|
||||
bool _initAttempted = false;
|
||||
bool _available = false;
|
||||
|
||||
@override
|
||||
String get name => 'onnx';
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async {
|
||||
await _ensureInit();
|
||||
return _available;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> recognize(Uint8List pngBytes) async {
|
||||
await _ensureInit();
|
||||
final session = _session;
|
||||
final decoder = _decoder;
|
||||
if (!_available || session == null || decoder == null) return null;
|
||||
|
||||
OrtValue? input;
|
||||
Map<String, OrtValue>? outputs;
|
||||
try {
|
||||
final pre = await _preprocess(pngBytes);
|
||||
if (pre == null) return null;
|
||||
|
||||
final inputName = session.inputNames[0];
|
||||
input = await OrtValue.fromList(pre.data, [
|
||||
1,
|
||||
3,
|
||||
_targetHeight,
|
||||
pre.width,
|
||||
]);
|
||||
outputs = await session.run({inputName: input});
|
||||
|
||||
final out = outputs[session.outputNames[0]];
|
||||
if (out == null) return null;
|
||||
|
||||
// Expected output shape: [1, T, C].
|
||||
final shape = out.shape;
|
||||
if (shape.length != 3) return null;
|
||||
final timeSteps = shape[1];
|
||||
final numClasses = shape[2];
|
||||
|
||||
// asFlattenedList() returns the data flat (row-major); asList() would
|
||||
// return a list nested per the output shape.
|
||||
final flat = (await out.asFlattenedList())
|
||||
.map((v) => (v as num).toDouble())
|
||||
.toList();
|
||||
final text = decoder.decodeFlat(flat, timeSteps, numClasses).trim();
|
||||
if (text.isEmpty) return null;
|
||||
return text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
} finally {
|
||||
if (input != null) {
|
||||
await input.dispose();
|
||||
}
|
||||
if (outputs != null) {
|
||||
for (final t in outputs.values) {
|
||||
await t.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily load the dictionary and create the inference session. On any
|
||||
/// failure the backend is marked unavailable.
|
||||
Future<void> _ensureInit() async {
|
||||
if (_initAttempted) return;
|
||||
_initAttempted = true;
|
||||
try {
|
||||
final dictRaw = await rootBundle.loadString(_dictAsset);
|
||||
final charset = dictRaw
|
||||
.split('\n')
|
||||
.map((line) => line.replaceAll('\r', ''))
|
||||
.toList();
|
||||
// Drop a single trailing empty entry from a final newline, then append a
|
||||
// space character as PP-OCR does.
|
||||
if (charset.isNotEmpty && charset.last.isEmpty) {
|
||||
charset.removeLast();
|
||||
}
|
||||
charset.add(' ');
|
||||
|
||||
final ort = OnnxRuntime();
|
||||
final session = await ort.createSessionFromAsset(
|
||||
_modelAsset,
|
||||
options: OrtSessionOptions(
|
||||
intraOpNumThreads: 2,
|
||||
providers: [OrtProvider.CPU],
|
||||
),
|
||||
);
|
||||
|
||||
_session = session;
|
||||
_decoder = CtcDecoder(charset, blankIndex: 0);
|
||||
_available = true;
|
||||
} catch (_) {
|
||||
_session = null;
|
||||
_decoder = null;
|
||||
_available = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode and preprocess the PNG into the CHW Float32 tensor the rec model
|
||||
/// expects. Returns null on any decode failure.
|
||||
Future<_PreprocessResult?> _preprocess(Uint8List pngBytes) async {
|
||||
final codec = await ui.instantiateImageCodec(pngBytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final src = frame.image;
|
||||
try {
|
||||
final origW = src.width;
|
||||
final origH = src.height;
|
||||
if (origW <= 0 || origH <= 0) return null;
|
||||
|
||||
// Width that preserves aspect ratio at the target height, clamped.
|
||||
final scaledW = (_targetHeight * origW / origH).round();
|
||||
final targetW = scaledW.clamp(_minWidth, _maxWidth);
|
||||
|
||||
// Render the resized image onto a white canvas. If the scaled width is
|
||||
// narrower than the target, the right side stays white (padding).
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(recorder);
|
||||
final paintWidth = scaledW < targetW ? scaledW : targetW;
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(0, 0, targetW.toDouble(), _targetHeight.toDouble()),
|
||||
ui.Paint()..color = const ui.Color(0xFFFFFFFF),
|
||||
);
|
||||
canvas.drawImageRect(
|
||||
src,
|
||||
ui.Rect.fromLTWH(0, 0, origW.toDouble(), origH.toDouble()),
|
||||
ui.Rect.fromLTWH(0, 0, paintWidth.toDouble(), _targetHeight.toDouble()),
|
||||
ui.Paint(),
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final resized = await picture.toImage(targetW, _targetHeight);
|
||||
picture.dispose();
|
||||
|
||||
try {
|
||||
final byteData = await resized.toByteData(
|
||||
format: ui.ImageByteFormat.rawRgba,
|
||||
);
|
||||
if (byteData == null) return null;
|
||||
final rgba = byteData.buffer.asUint8List();
|
||||
|
||||
// Layout CHW (3 x H x W), normalize (v/255 - 0.5) / 0.5, RGB only.
|
||||
final hw = _targetHeight * targetW;
|
||||
final data = Float32List(3 * hw);
|
||||
for (var y = 0; y < _targetHeight; y++) {
|
||||
for (var x = 0; x < targetW; x++) {
|
||||
final pixel = (y * targetW + x) * 4;
|
||||
final r = rgba[pixel] / 255.0;
|
||||
final g = rgba[pixel + 1] / 255.0;
|
||||
final b = rgba[pixel + 2] / 255.0;
|
||||
final idx = y * targetW + x;
|
||||
data[idx] = (r - 0.5) / 0.5;
|
||||
data[hw + idx] = (g - 0.5) / 0.5;
|
||||
data[2 * hw + idx] = (b - 0.5) / 0.5;
|
||||
}
|
||||
}
|
||||
return _PreprocessResult(data, targetW);
|
||||
} finally {
|
||||
resized.dispose();
|
||||
}
|
||||
} finally {
|
||||
src.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PreprocessResult {
|
||||
_PreprocessResult(this.data, this.width);
|
||||
|
||||
final Float32List data;
|
||||
final int width;
|
||||
}
|
||||
@@ -1,21 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'ocr/ocr_backends.dart';
|
||||
|
||||
/// Platform OCR backend. Uses Windows built-in OCR on desktop Windows.
|
||||
/// Local OCR entry point. Delegates to a pluggable backend (embedded ONNX
|
||||
/// recognition when a model is bundled, otherwise the native platform OCR).
|
||||
///
|
||||
/// The static API is kept for back-compat with [OcrService].
|
||||
class OcrEngine {
|
||||
static const _channel = MethodChannel('badnote/ocr');
|
||||
|
||||
/// Recognize text from a PNG image. Returns null when unavailable or empty.
|
||||
static Future<String?> recognizeImage(Uint8List pngBytes) async {
|
||||
if (!Platform.isWindows) return null;
|
||||
try {
|
||||
final result = await _channel.invokeMethod<String>('recognize', pngBytes);
|
||||
final text = result?.trim();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return text;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
static Future<String?> recognizeImage(Uint8List pngBytes) {
|
||||
return OcrBackends.recognize(pngBytes);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user