text_sight 0.2.1
text_sight: ^0.2.1 copied to clipboard
Live, on-device text recognition — Apple Vision on iOS, ML Kit on Android. The text-scanning sibling to mobile_scanner.
Live, on-device text recognition for Flutter. Apple Vision on iOS, ML Kit on Android. Like
mobile_scanner, but for text instead of barcodes.
- Why text_sight?
- A quick taste
- API at a glance
- Platform support
- Install
- The recognition model
- Performance
- Going deeper
Why text_sight? #
Most cross-platform OCR plugins run Google ML Kit on both platforms, which quietly drags
GoogleMLKit into your iOS build. text_sight doesn't.
| No ML framework in your iOS app | iOS recognition is Apple Vision, which is already part of the OS. No GoogleMLKit, nothing third-party to ship, so your iOS build stays smaller. CI fails the moment one sneaks in. |
| Android fetches its model on demand | The ML Kit model stays unbundled by default, so your APK carries a ~260 KB stub instead of the whole thing. Want it baked in? One line of Gradle. |
fast or accurate, per call |
Pick low latency or better reading, and switch mid-session. iOS only, since ML Kit's Latin recognizer has no such knob. |
| No permission package | requestCameraPermission() goes straight to AVFoundation and the Android permission flow. Already using permission_handler? That still works. |
| The same boxes everywhere | Every box is normalized [0, 1] from the top-left on both platforms, so your overlay never branches on Platform. |
| Camera optional | One-shot recognition reads bytes or a file path. No camera, no permission, runs anywhere. |
Nothing recognition-related ever reaches your pubspec.yaml, so the two platforms can't bleed into
each other.
A quick taste #
Live camera #
Point the camera at some text:
final controller = TextSightController();
TextSightView(
controller: controller,
onResult: (capture) => capture.lines.forEach((line) => print(line.text)),
overlayBuilder: (context, capture, constraints) => /* paint line.boundingBox */,
);
await controller.requestCameraPermission(); // prompts via the OS
await controller.start();
A single image #
No camera, no permission, works on a simulator:
final capture = await TextSight.recognizeImage(bytes); // or .recognizePath('/photo.jpg')
Tweak it mid-session #
Hand the controller a region of interest to scan just a scan-box, or change the recognition level, languages, or torch while the session runs.
TextSightController(options: TextSightOptions(roi: Rect.fromLTWH(0.1, 0.4, 0.8, 0.2)));
Backgrounding the app pauses the session on its own and picks it back up when you return.
One Android thing worth knowing up front: the model downloads on first use, so give it a head start when the user opens your scanner, or that first scan comes back empty.
The example app #
The example/ app is the place to look next. Live overlay, torch, region-of-interest,
permissions, and the one-shot screen, all wired up and ready to crib from.
![]() Android · ML Kit |
![]() iOS · Apple Vision |
API at a glance #
One import gets you everything: package:text_sight/text_sight.dart.
| Type | What it's for |
|---|---|
TextSightView + TextSightController |
the live camera path |
TextSight |
the static one-shot, on bytes or a file path |
TextSightOptions |
level, languages, region of interest |
TextSightCapture and RecognizedLine |
results: text, normalized box, confidence |
TextSightModel |
Android model readiness, ensureReady() plus a stream |
RecognitionLevel |
fast or accurate (iOS) |
CameraPermissionStatus |
granted, denied, permanently denied |
RecognizedElement |
reserved. Always null in v1, word-level results come later |
Platform support #
| Platform | Minimum | Engine |
|---|---|---|
| iOS | 15.0 | Apple Vision. RecognizeTextRequest on 18+, VNRecognizeTextRequest on 15 to 17 |
| Android | API 24 | ML Kit Text Recognition v2 (Latin) |
The right Vision API is picked for you. Android reads Latin script only for now. Live scanning needs a real device, since the iOS Simulator has no camera, but the one-shot runs anywhere.
Gotchas #
⚠️ iOS 15 and 16: the preview and recognition don't follow device rotation. Those versions predate
AVCaptureDevice.RotationCoordinator, so live capture isn't rotated to match how the phone is held. iOS 17+ is fine, and one-shot recognition is fine everywhere (it reads the image's own orientation). It's a deliberate trade-off for a device population we don't expect to see. If it affects you, open an issue and a proper fallback will follow.
Hosting Flutter inside a native iOS app?
Call controller.dispose() a step ahead of releasing the FlutterEngine or dismissing its
FlutterViewController, not in the same breath. dispose() hands the native side a teardown
request and returns before it finishes, so it isn't a barrier. The plugin also releases the camera
on engine detach, which covers the usual routes, but a host that keeps the engine alive forever gets
neither, and a live session that outlives its FlutterViewController can crash the app.
Plain Flutter apps need none of this.
Install #
flutter pub add text_sight
iOS #
Add a camera-usage string to ios/Runner/Info.plist. This is required, because iOS kills the
app the moment the camera is requested without it.
<key>NSCameraUsageDescription</key>
<string>Used to recognize text from the camera.</string>
Then call controller.requestCameraPermission() before controller.start(), or
checkCameraPermission() if you want a priming screen first.
Android #
Nothing to do. The manifest already has what it needs.
The recognition model #
On iOS there's nothing to do. Vision ships with the OS, so there's no download and no waiting.
Android is the interesting one. The ML Kit model is unbundled by default: about 260 KB in your APK, with the real model pulled from Google Play Services the first time you use it. That's on purpose, since most apps don't need OCR the second they launch. The catch is that a scan started before the model lands comes back empty.
Give it a nudge #
Call this when the user opens your scanner:
final state = await TextSightModel.ensureReady();
if (state is ModelUnavailable) {
// No Play Services, or the download didn't make it. Tell the user, maybe offer a retry.
}
Call it as often as you like. On iOS it returns right away, and on Android it does too once the model is around.
Show the download #
Want a progress bar? Listen to the readiness stream. It's a sealed type, so the compiler makes sure you've handled every case:
TextSightModel.readiness.listen((state) {
final label = switch (state) {
ModelReady() => 'Ready to scan',
ModelDownloading(:final progress) => 'Downloading… ${((progress ?? 0) * 100).round()}%',
ModelUnavailable(:final reason) => 'Model unavailable ($reason)',
};
});
The example/ scanner does exactly this: ensureReady() to gate, the stream for a real
download bar.
Or just bundle it #
Ship the model inside your APK instead. Instant, offline, no Play Services. One line in your app's
android/gradle.properties:
com.lahaluhem.text_sight.useBundled=true
Now ensureReady() returns immediately and ModelUnavailable never shows up. You trade size for
it:
| Mode | App size | First use | Offline | Needs Play Services |
|---|---|---|---|---|
| Unbundled (default) | ~260 KB | downloads on demand | after first download | yes |
| Bundled | ~4 MB per script, per arch | instant | yes | no |
Performance #
Captured on a physical Galaxy S24 and iPhone 16 in profile mode. Your hardware will differ, and full
method and numbers are in benchmark/.
One image #
What TextSight.recognizeImage costs, by page density. Read the line count beside each bar: a level
that recognizes nothing returns fast, which would otherwise look like a win.

On Android the level is a no-op, since ML Kit's Latin recognizer has no accuracy dial, so its two
bars land on top of each other. On iOS fast is roughly 4x quicker than accurate but skips text
below 1/32 of the image height, so it reads less, then nothing, as pages get denser
(#58).
Live camera #
Recognized frames per second over a fixed window, both phones pointed at the same page.
| Platform | Level | Frame | Captures/s | Paced by |
|---|---|---|---|---|
| iOS | fast |
1080x1920 | 30.0 | the camera |
| iOS | accurate |
1080x1920 | 4.0 | the recognizer |
| Android | fast |
480x640 | 5.9 | the recognizer |
| Android | accurate |
480x640 | 6.6 | the recognizer |
Hitting the camera's own frame rate means recognition is keeping up and the camera is the limit.
That is where iOS fast sits, at 30/s on a 30 fps camera. Everything else is paced by the
recognizer.
Don't read this as iOS versus Android. The two sides recognize at different resolutions, so they
are not doing the same work per frame. iOS asks for .high and gets 1080p; Android's analysis falls
through to CameraX's 640x480 default, 6.7x fewer pixels
(#61). On the same scene Android resolved 11
lines per capture against iOS's 21. These also depend entirely on what the camera sees, so treat
them as directional either way.
The transport is not the bottleneck #
Results cross from native to Dart as a small per-frame map. Decoding one on the UI isolate costs microseconds: worst case on the slower of the two phones, a dense 127-line frame, is 87 µs, or 0.5% of a 60 fps frame budget. So the recognizer's own work sets the pace.

Those three are host-measured, for the finer sweep a phone run does not produce. Leaner wire formats win big in percent and stay tiny in absolute microseconds, which is why the self-describing map stays.
Going deeper #
Coordinate handling, the per-line confidence contract, how region-of-interest differs across platforms, and what's next: all in APPENDIX.md.


