inou-mobile/lib/services/ocr_service.dart

515 lines
14 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_barcode_scanning/google_mlkit_barcode_scanning.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
/// Type of scan result
enum ScanResultType {
barcode,
text,
empty,
}
/// Alias for backward compatibility
typedef OcrResult = ScanResult;
/// Barcode data from scan
class BarcodeData {
final String value;
final String rawValue;
final BarcodeType type;
final BarcodeFormat format;
final Rect? boundingBox;
BarcodeData({
required this.value,
required this.rawValue,
required this.type,
required this.format,
this.boundingBox,
});
/// Human-readable format name
String get formatName {
switch (format) {
case BarcodeFormat.upca:
return 'UPC-A';
case BarcodeFormat.upce:
return 'UPC-E';
case BarcodeFormat.ean8:
return 'EAN-8';
case BarcodeFormat.ean13:
return 'EAN-13';
case BarcodeFormat.qrCode:
return 'QR Code';
case BarcodeFormat.dataMatrix:
return 'Data Matrix';
case BarcodeFormat.pdf417:
return 'PDF417';
case BarcodeFormat.code128:
return 'Code 128';
case BarcodeFormat.code39:
return 'Code 39';
case BarcodeFormat.code93:
return 'Code 93';
case BarcodeFormat.codabar:
return 'Codabar';
case BarcodeFormat.itf:
return 'ITF';
case BarcodeFormat.aztec:
return 'Aztec';
default:
return 'Unknown';
}
}
/// Check if this is a product barcode (UPC/EAN)
bool get isProductCode =>
format == BarcodeFormat.upca ||
format == BarcodeFormat.upce ||
format == BarcodeFormat.ean8 ||
format == BarcodeFormat.ean13;
/// Check if this could be an NDC (National Drug Code)
/// NDC is typically encoded as UPC-A or embedded in other formats
bool get couldBeNdc => isProductCode && value.length >= 10;
factory BarcodeData.fromMlKit(Barcode barcode) {
return BarcodeData(
value: barcode.displayValue ?? barcode.rawValue ?? '',
rawValue: barcode.rawValue ?? '',
type: barcode.type,
format: barcode.format,
boundingBox: barcode.boundingBox,
);
}
Map<String, dynamic> toJson() => {
'value': value,
'rawValue': rawValue,
'type': type.name,
'format': formatName,
'isProductCode': isProductCode,
'couldBeNdc': couldBeNdc,
};
}
/// 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,
});
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 scan result - can be barcode or text
class ScanResult {
final ScanResultType type;
final List<BarcodeData>? barcodes;
final String? fullText;
final List<OcrTextBlock>? textBlocks;
final Size imageSize;
final DateTime timestamp;
final String? imagePath;
ScanResult({
required this.type,
this.barcodes,
this.fullText,
this.textBlocks,
required this.imageSize,
required this.timestamp,
this.imagePath,
});
/// Check if any data was found
bool get hasData => type != ScanResultType.empty;
/// Check if barcode was found
bool get hasBarcode => type == ScanResultType.barcode && barcodes != null && barcodes!.isNotEmpty;
/// Check if text was found
bool get hasText => type == ScanResultType.text && fullText != null && fullText!.isNotEmpty;
/// Get primary barcode (first one found)
BarcodeData? get primaryBarcode => barcodes?.isNotEmpty == true ? barcodes!.first : null;
/// Get word count for text results
int get wordCount => textBlocks?.expand((b) => b.lines).expand((l) => l.elements).length ?? 0;
/// Get all recognized languages from text
Set<String> get languages =>
textBlocks
?.where((b) => b.recognizedLanguage != null)
.map((b) => b.recognizedLanguage!)
.toSet() ??
{};
/// User-friendly description of what was found
String get description {
switch (type) {
case ScanResultType.barcode:
final bc = primaryBarcode;
if (bc != null) {
return '${bc.formatName}: ${bc.value}';
}
return 'Barcode found';
case ScanResultType.text:
return '$wordCount words detected';
case ScanResultType.empty:
return 'Nothing detected';
}
}
/// Convert to JSON-friendly map
Map<String, dynamic> toJson() => {
'type': type.name,
'data': type == ScanResultType.barcode
? barcodes?.map((b) => b.toJson()).toList()
: fullText,
'wordCount': wordCount,
'timestamp': timestamp.toIso8601String(),
};
/// Create empty result
factory ScanResult.empty(Size imageSize) => ScanResult(
type: ScanResultType.empty,
imageSize: imageSize,
timestamp: DateTime.now(),
);
/// Create barcode result
factory ScanResult.fromBarcodes(
List<BarcodeData> barcodes,
Size imageSize, {
String? imagePath,
}) =>
ScanResult(
type: ScanResultType.barcode,
barcodes: barcodes,
imageSize: imageSize,
timestamp: DateTime.now(),
imagePath: imagePath,
);
/// Create text result
factory ScanResult.fromText(
String fullText,
List<OcrTextBlock> blocks,
Size imageSize, {
String? imagePath,
}) =>
ScanResult(
type: ScanResultType.text,
fullText: fullText,
textBlocks: blocks,
imageSize: imageSize,
timestamp: DateTime.now(),
imagePath: imagePath,
);
}
/// OCR service with barcode-first scanning strategy
/// Priority: Barcode → OCR text → Nothing found
class OcrService {
BarcodeScanner? _barcodeScanner;
TextRecognizer? _textRecognizer;
bool _isProcessing = false;
/// Barcode formats to scan for (medication-relevant)
static const List<BarcodeFormat> _barcodeFormats = [
BarcodeFormat.upca, // UPC-A (common for US products)
BarcodeFormat.upce, // UPC-E (compressed UPC)
BarcodeFormat.ean8, // EAN-8
BarcodeFormat.ean13, // EAN-13 (common for international)
BarcodeFormat.qrCode, // QR codes
BarcodeFormat.dataMatrix, // Data Matrix (common for pharma)
BarcodeFormat.pdf417, // PDF417 (driver's licenses, some pharma)
BarcodeFormat.code128, // Code 128 (logistics, pharma)
BarcodeFormat.code39, // Code 39 (older pharma)
];
/// Get or create barcode scanner
BarcodeScanner get barcodeScanner {
_barcodeScanner ??= BarcodeScanner(formats: _barcodeFormats);
return _barcodeScanner!;
}
/// Get or create text recognizer
TextRecognizer get textRecognizer {
_textRecognizer ??= TextRecognizer(script: TextRecognitionScript.latin);
return _textRecognizer!;
}
/// Whether processing is currently running
bool get isProcessing => _isProcessing;
/// Scan an image file - barcode first, then OCR
Future<ScanResult> scanImageFile(String imagePath) async {
if (_isProcessing) {
throw OcrException('Scan 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);
// 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(),
);
// STEP 1: Try barcode scanning first
final barcodes = await barcodeScanner.processImage(inputImage);
if (barcodes.isNotEmpty) {
debugPrint('Found ${barcodes.length} barcode(s)');
return ScanResult.fromBarcodes(
barcodes.map((b) => BarcodeData.fromMlKit(b)).toList(),
imageSize,
imagePath: imagePath,
);
}
// STEP 2: No barcode found, try text OCR
final recognizedText = await textRecognizer.processImage(inputImage);
if (recognizedText.text.isNotEmpty) {
debugPrint('Found ${recognizedText.blocks.length} text block(s)');
return ScanResult.fromText(
recognizedText.text,
recognizedText.blocks.map((b) => OcrTextBlock.fromMlKit(b)).toList(),
imageSize,
imagePath: imagePath,
);
}
// STEP 3: Nothing found
debugPrint('No barcode or text found');
return ScanResult.empty(imageSize);
} catch (e) {
if (e is OcrException) rethrow;
throw OcrException('Failed to scan image: $e');
} finally {
_isProcessing = false;
}
}
/// Scan camera image for live preview - barcode first, then OCR
Future<ScanResult?> scanCameraImage(
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 imageSize = Size(image.width.toDouble(), image.height.toDouble());
// Try barcode first
final barcodes = await barcodeScanner.processImage(inputImage);
if (barcodes.isNotEmpty) {
return ScanResult.fromBarcodes(
barcodes.map((b) => BarcodeData.fromMlKit(b)).toList(),
imageSize,
);
}
// Fall back to text OCR
final recognizedText = await textRecognizer.processImage(inputImage);
if (recognizedText.text.isNotEmpty) {
return ScanResult.fromText(
recognizedText.text,
recognizedText.blocks.map((b) => OcrTextBlock.fromMlKit(b)).toList(),
imageSize,
);
}
return ScanResult.empty(imageSize);
} catch (e) {
debugPrint('Live scan error: $e');
return null;
} finally {
_isProcessing = false;
}
}
/// Convert CameraImage to InputImage for ML Kit
InputImage? _inputImageFromCameraImage(
CameraImage image,
CameraDescription camera,
int sensorOrientation,
) {
final rotation = _getRotation(camera, sensorOrientation);
if (rotation == null) return null;
final format = InputImageFormatValue.fromRawValue(image.format.raw);
if (format == null ||
(Platform.isAndroid && format != InputImageFormat.nv21) ||
(Platform.isIOS && format != InputImageFormat.bgra8888)) {
return null;
}
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) {
var rotationCompensation = sensorOrientation;
if (camera.lensDirection == CameraLensDirection.front) {
rotationCompensation = (sensorOrientation + 360) % 360;
}
return InputImageRotationValue.fromRawValue(rotationCompensation);
}
return null;
}
// ========== Legacy API (for backward compatibility) ==========
/// Alias for scanImageFile (backward compatibility)
Future<ScanResult> processImageFile(String imagePath) => scanImageFile(imagePath);
/// Extract just the text from an image (simple API)
/// Note: This skips barcode scanning - use scanImageFile for full scan
Future<String> extractText(String imagePath) async {
final result = await scanImageFile(imagePath);
return result.fullText ?? '';
}
/// Get all text blocks from an image
Future<List<OcrTextBlock>> getTextBlocks(String imagePath) async {
final result = await scanImageFile(imagePath);
return result.textBlocks ?? [];
}
/// Cleanup resources
void dispose() {
_barcodeScanner?.close();
_barcodeScanner = null;
_textRecognizer?.close();
_textRecognizer = null;
}
}
class OcrException implements Exception {
final String message;
OcrException(this.message);
@override
String toString() => 'OcrException: $message';
}