515 lines
14 KiB
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';
|
|
}
|