294 lines
7.8 KiB
Dart
294 lines
7.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:ui' as ui;
|
|
import 'dart:ui' show Rect, Size;
|
|
import 'package:camera/camera.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
|
|
|
|
/// Structured text block with bounding box info
|
|
class OcrTextBlock {
|
|
final String text;
|
|
final List<OcrTextLine> lines;
|
|
final Rect boundingBox;
|
|
final List<ui.Offset> cornerPoints;
|
|
final String? recognizedLanguage;
|
|
|
|
OcrTextBlock({
|
|
required this.text,
|
|
required this.lines,
|
|
required this.boundingBox,
|
|
required this.cornerPoints,
|
|
this.recognizedLanguage,
|
|
});
|
|
|
|
/// Create from ML Kit TextBlock
|
|
factory OcrTextBlock.fromMlKit(TextBlock block) {
|
|
return OcrTextBlock(
|
|
text: block.text,
|
|
lines: block.lines.map((l) => OcrTextLine.fromMlKit(l)).toList(),
|
|
boundingBox: block.boundingBox,
|
|
cornerPoints: block.cornerPoints
|
|
.map((p) => ui.Offset(p.x.toDouble(), p.y.toDouble()))
|
|
.toList(),
|
|
recognizedLanguage: block.recognizedLanguages.isNotEmpty
|
|
? block.recognizedLanguages.first.split('-').first
|
|
: null,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Text line within a block
|
|
class OcrTextLine {
|
|
final String text;
|
|
final List<OcrTextElement> elements;
|
|
final Rect boundingBox;
|
|
final List<ui.Offset> cornerPoints;
|
|
|
|
OcrTextLine({
|
|
required this.text,
|
|
required this.elements,
|
|
required this.boundingBox,
|
|
required this.cornerPoints,
|
|
});
|
|
|
|
factory OcrTextLine.fromMlKit(TextLine line) {
|
|
return OcrTextLine(
|
|
text: line.text,
|
|
elements: line.elements.map((e) => OcrTextElement.fromMlKit(e)).toList(),
|
|
boundingBox: line.boundingBox,
|
|
cornerPoints: line.cornerPoints
|
|
.map((p) => ui.Offset(p.x.toDouble(), p.y.toDouble()))
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Individual text element (word)
|
|
class OcrTextElement {
|
|
final String text;
|
|
final Rect boundingBox;
|
|
final List<ui.Offset> cornerPoints;
|
|
|
|
OcrTextElement({
|
|
required this.text,
|
|
required this.boundingBox,
|
|
required this.cornerPoints,
|
|
});
|
|
|
|
factory OcrTextElement.fromMlKit(TextElement element) {
|
|
return OcrTextElement(
|
|
text: element.text,
|
|
boundingBox: element.boundingBox,
|
|
cornerPoints: element.cornerPoints
|
|
.map((p) => ui.Offset(p.x.toDouble(), p.y.toDouble()))
|
|
.toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Complete OCR result
|
|
class OcrResult {
|
|
final String fullText;
|
|
final List<OcrTextBlock> blocks;
|
|
final Size imageSize;
|
|
final DateTime timestamp;
|
|
final String? imagePath;
|
|
|
|
OcrResult({
|
|
required this.fullText,
|
|
required this.blocks,
|
|
required this.imageSize,
|
|
required this.timestamp,
|
|
this.imagePath,
|
|
});
|
|
|
|
/// Check if any text was found
|
|
bool get hasText => fullText.isNotEmpty;
|
|
|
|
/// Get all recognized languages
|
|
Set<String> get languages =>
|
|
blocks.where((b) => b.recognizedLanguage != null)
|
|
.map((b) => b.recognizedLanguage!)
|
|
.toSet();
|
|
|
|
/// Get total number of words
|
|
int get wordCount =>
|
|
blocks.expand((b) => b.lines).expand((l) => l.elements).length;
|
|
}
|
|
|
|
/// OCR service using ML Kit Text Recognition
|
|
class OcrService {
|
|
TextRecognizer? _textRecognizer;
|
|
bool _isProcessing = false;
|
|
|
|
/// Get or create text recognizer
|
|
TextRecognizer get textRecognizer {
|
|
_textRecognizer ??= TextRecognizer(script: TextRecognitionScript.latin);
|
|
return _textRecognizer!;
|
|
}
|
|
|
|
/// Whether OCR processing is currently running
|
|
bool get isProcessing => _isProcessing;
|
|
|
|
/// Process an image file and extract structured text
|
|
Future<OcrResult> processImageFile(String imagePath) async {
|
|
if (_isProcessing) {
|
|
throw OcrException('OCR processing already in progress');
|
|
}
|
|
|
|
_isProcessing = true;
|
|
try {
|
|
final file = File(imagePath);
|
|
if (!await file.exists()) {
|
|
throw OcrException('Image file not found: $imagePath');
|
|
}
|
|
|
|
final inputImage = InputImage.fromFilePath(imagePath);
|
|
final recognizedText = await textRecognizer.processImage(inputImage);
|
|
|
|
// Get image dimensions
|
|
final bytes = await file.readAsBytes();
|
|
final codec = await ui.instantiateImageCodec(bytes);
|
|
final frame = await codec.getNextFrame();
|
|
final imageSize = Size(
|
|
frame.image.width.toDouble(),
|
|
frame.image.height.toDouble(),
|
|
);
|
|
|
|
return OcrResult(
|
|
fullText: recognizedText.text,
|
|
blocks: recognizedText.blocks
|
|
.map((b) => OcrTextBlock.fromMlKit(b))
|
|
.toList(),
|
|
imageSize: imageSize,
|
|
timestamp: DateTime.now(),
|
|
imagePath: imagePath,
|
|
);
|
|
} catch (e) {
|
|
if (e is OcrException) rethrow;
|
|
throw OcrException('Failed to process image: $e');
|
|
} finally {
|
|
_isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/// Process camera image for live preview scanning
|
|
Future<OcrResult?> processCameraImage(
|
|
CameraImage image,
|
|
CameraDescription camera,
|
|
int sensorOrientation,
|
|
) async {
|
|
if (_isProcessing) return null;
|
|
|
|
_isProcessing = true;
|
|
try {
|
|
final inputImage = _inputImageFromCameraImage(
|
|
image,
|
|
camera,
|
|
sensorOrientation,
|
|
);
|
|
|
|
if (inputImage == null) {
|
|
return null;
|
|
}
|
|
|
|
final recognizedText = await textRecognizer.processImage(inputImage);
|
|
|
|
return OcrResult(
|
|
fullText: recognizedText.text,
|
|
blocks: recognizedText.blocks
|
|
.map((b) => OcrTextBlock.fromMlKit(b))
|
|
.toList(),
|
|
imageSize: Size(image.width.toDouble(), image.height.toDouble()),
|
|
timestamp: DateTime.now(),
|
|
);
|
|
} catch (e) {
|
|
debugPrint('OCR processing error: $e');
|
|
return null;
|
|
} finally {
|
|
_isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/// Convert CameraImage to InputImage for ML Kit
|
|
InputImage? _inputImageFromCameraImage(
|
|
CameraImage image,
|
|
CameraDescription camera,
|
|
int sensorOrientation,
|
|
) {
|
|
// Get rotation based on platform
|
|
final rotation = _getRotation(camera, sensorOrientation);
|
|
if (rotation == null) return null;
|
|
|
|
// Get image format
|
|
final format = InputImageFormatValue.fromRawValue(image.format.raw);
|
|
if (format == null ||
|
|
(Platform.isAndroid && format != InputImageFormat.nv21) ||
|
|
(Platform.isIOS && format != InputImageFormat.bgra8888)) {
|
|
return null;
|
|
}
|
|
|
|
// Only single plane supported
|
|
if (image.planes.isEmpty) return null;
|
|
|
|
final plane = image.planes.first;
|
|
|
|
return InputImage.fromBytes(
|
|
bytes: plane.bytes,
|
|
metadata: InputImageMetadata(
|
|
size: Size(image.width.toDouble(), image.height.toDouble()),
|
|
rotation: rotation,
|
|
format: format,
|
|
bytesPerRow: plane.bytesPerRow,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Get image rotation for ML Kit
|
|
InputImageRotation? _getRotation(
|
|
CameraDescription camera,
|
|
int sensorOrientation,
|
|
) {
|
|
if (Platform.isIOS) {
|
|
return InputImageRotationValue.fromRawValue(sensorOrientation);
|
|
} else if (Platform.isAndroid) {
|
|
// Compensate for camera and device orientation
|
|
var rotationCompensation = sensorOrientation;
|
|
|
|
if (camera.lensDirection == CameraLensDirection.front) {
|
|
rotationCompensation = (sensorOrientation + 360) % 360;
|
|
}
|
|
|
|
return InputImageRotationValue.fromRawValue(rotationCompensation);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Extract just the text from an image (simple API)
|
|
Future<String> extractText(String imagePath) async {
|
|
final result = await processImageFile(imagePath);
|
|
return result.fullText;
|
|
}
|
|
|
|
/// Get all text blocks from an image
|
|
Future<List<OcrTextBlock>> getTextBlocks(String imagePath) async {
|
|
final result = await processImageFile(imagePath);
|
|
return result.blocks;
|
|
}
|
|
|
|
/// Cleanup resources
|
|
void dispose() {
|
|
_textRecognizer?.close();
|
|
_textRecognizer = null;
|
|
}
|
|
}
|
|
|
|
class OcrException implements Exception {
|
|
final String message;
|
|
OcrException(this.message);
|
|
|
|
@override
|
|
String toString() => 'OcrException: $message';
|
|
}
|