amoriai/lib/services/ocr_service.dart

360 lines
11 KiB
Dart

// import 'dart:io'; // Not needed for OCR service
import 'package:flutter/foundation.dart';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import '../core/constants/app_constants.dart';
class OCRService {
static final OCRService _instance = OCRService._internal();
factory OCRService() => _instance;
OCRService._internal();
final TextRecognizer _textRecognizer = TextRecognizer();
// Extract text from image
Future<OCRResult> extractTextFromImage(String imagePath) async {
try {
final inputImage = InputImage.fromFilePath(imagePath);
final recognizedText = await _textRecognizer.processImage(inputImage);
if (recognizedText.text.isEmpty) {
throw Exception('Tidak ada teks yang terdeteksi dalam gambar');
}
// Process and clean the extracted text
final processedText = _processExtractedText(recognizedText);
final menuItems = _parseMenuItems(processedText);
return OCRResult(
rawText: recognizedText.text,
processedText: processedText,
menuItems: menuItems,
confidence: _calculateAverageConfidence(recognizedText),
isSuccess: true,
);
} catch (e) {
debugPrint('OCR extraction error: $e');
return OCRResult(
rawText: '',
processedText: '',
menuItems: [],
confidence: 0.0,
isSuccess: false,
error: e.toString(),
);
}
}
// Process and clean extracted text by merging lines on the same Y axis
String _processExtractedText(RecognizedText recognizedText) {
List<TextLine> allLines = [];
// Kumpulkan semua baris dari semua blok
for (TextBlock block in recognizedText.blocks) {
for (TextLine line in block.lines) {
if (line.text.trim().isNotEmpty && _getLineConfidence(line) > AppConstants.ocrConfidenceThreshold) {
allLines.add(line);
}
}
}
if (allLines.isEmpty) return '';
// Urutkan semua baris dari atas ke bawah (berdasarkan koordinat Y / top)
allLines.sort((a, b) => a.boundingBox.top.compareTo(b.boundingBox.top));
List<String> mergedLines = [];
List<TextLine> currentLineGroup = [allLines.first];
for (int i = 1; i < allLines.length; i++) {
final line = allLines[i];
final prevLine = currentLineGroup.first; // Pakai patokan baris pertama di grup
// Jika selisih Y (top) kurang dari 15 pixel, berarti mereka di baris yang sama (sejajar)
if ((line.boundingBox.top - prevLine.boundingBox.top).abs() < 15) {
currentLineGroup.add(line);
} else {
// Selesai satu baris, urutkan dari kiri ke kanan (berdasarkan X / left)
currentLineGroup.sort((a, b) => a.boundingBox.left.compareTo(b.boundingBox.left));
String merged = currentLineGroup.map((e) => e.text.trim()).join(' ');
mergedLines.add(_cleanOCRText(merged));
currentLineGroup = [line];
}
}
// Tambahkan grup terakhir
if (currentLineGroup.isNotEmpty) {
currentLineGroup.sort((a, b) => a.boundingBox.left.compareTo(b.boundingBox.left));
String merged = currentLineGroup.map((e) => e.text.trim()).join(' ');
mergedLines.add(_cleanOCRText(merged));
}
return mergedLines.join('\n');
}
// Clean common OCR errors
String _cleanOCRText(String text) {
// Memperbaiki OCR yang salah membaca huruf menjadi angka (atau sebaliknya) pada KATA,
// tapi hindari merusak harga. Kita hapus replace 0 ke O karena fatal merusak angka harga.
text = text.replaceAll(RegExp(r'[|]'), 'I');
// Remove excessive whitespace
text = text.replaceAll(RegExp(r'\s+'), ' ');
// Fix common price patterns
text = text.replaceAll(RegExp(r'Rp\s*(\d)'), 'Rp \$1');
text = text.replaceAll(RegExp(r'(\d)\s*k'), '\$1.000');
return text.trim();
}
// Parse menu items from processed text
List<MenuItemOCR> _parseMenuItems(String text) {
List<MenuItemOCR> menuItems = [];
List<String> lines = text.split('\n');
for (int i = 0; i < lines.length; i++) {
String line = lines[i].trim();
// Skip empty lines
if (line.isEmpty) continue;
// Try to identify menu items with prices
MenuItemOCR? menuItem = _parseMenuItemLine(line);
if (menuItem != null) {
// Look for description in next line if available
if (i + 1 < lines.length) {
String nextLine = lines[i + 1].trim();
if (!_containsPrice(nextLine) && nextLine.length > 10) {
menuItem = menuItem.copyWith(description: nextLine);
i++; // Skip the description line in next iteration
}
}
menuItems.add(menuItem);
}
}
return menuItems;
}
// Parse individual menu item line
MenuItemOCR? _parseMenuItemLine(String line) {
// Pattern for menu items with prices (with or without Rp)
// Matches: Rp 20.000, 20.000, 25.000, 20000, 25k, 25K
final pricePattern = RegExp(
r'(?:Rp\.?\s*)?(\d{1,3}(?:[.,]\d{3})+(?:[.,]\d{2})?|\d{4,7}|\d{1,3}\s*[kK])\b',
caseSensitive: false
);
final match = pricePattern.firstMatch(line);
if (match != null) {
String priceStr = match.group(1)!;
double price = _parsePrice(priceStr);
// Extract menu name (text before price)
String name = line.substring(0, match.start).trim();
name = name.replaceAll(RegExp(r'[^\w\s]$'), ''); // Remove trailing punctuation
if (name.isNotEmpty && name.length > 2) {
return MenuItemOCR(
name: name,
price: price,
description: '',
category: _guessCategory(name),
);
}
}
return null;
}
// Parse price string to double
double _parsePrice(String priceStr) {
// Remove dots and commas, then parse
String cleanPrice = priceStr.replaceAll(RegExp(r'[.,]'), '');
// Handle 'k' suffix (thousands)
if (priceStr.toLowerCase().contains('k')) {
cleanPrice = cleanPrice.replaceAll(RegExp(r'k', caseSensitive: false), '000');
}
return double.tryParse(cleanPrice) ?? 0.0;
}
// Check if line contains price
bool _containsPrice(String line) {
return RegExp(
r'(?:Rp\.?\s*)?(?:\d{1,3}(?:[.,]\d{3})+|\d{4,7}|\d{1,3}\s*[kK])\b',
caseSensitive: false
).hasMatch(line);
}
// Guess category based on menu name
String _guessCategory(String name) {
String lowerName = name.toLowerCase();
if (lowerName.contains('kopi') || lowerName.contains('coffee') ||
lowerName.contains('espresso') || lowerName.contains('latte') ||
lowerName.contains('cappuccino') || lowerName.contains('americano')) {
return 'Kopi';
} else if (lowerName.contains('teh') || lowerName.contains('tea') ||
lowerName.contains('matcha') || lowerName.contains('green tea')) {
return 'Teh';
} else if (lowerName.contains('jus') || lowerName.contains('juice') ||
lowerName.contains('smoothie') || lowerName.contains('milkshake')) {
return 'Minuman';
} else if (lowerName.contains('nasi') || lowerName.contains('mie') ||
lowerName.contains('ayam') || lowerName.contains('sate') ||
lowerName.contains('gado') || lowerName.contains('soto')) {
return 'Makanan Utama';
} else if (lowerName.contains('cake') || lowerName.contains('kue') ||
lowerName.contains('roti') || lowerName.contains('donat') ||
lowerName.contains('cookies') || lowerName.contains('pie')) {
return 'Dessert';
} else if (lowerName.contains('snack') || lowerName.contains('keripik') ||
lowerName.contains('gorengan') || lowerName.contains('bakso')) {
return 'Snack';
}
return 'Lainnya';
}
// Calculate average confidence from recognized text
double _calculateAverageConfidence(RecognizedText recognizedText) {
if (recognizedText.blocks.isEmpty) return 0.0;
double totalConfidence = 0.0;
int elementCount = 0;
for (TextBlock block in recognizedText.blocks) {
for (TextLine line in block.lines) {
totalConfidence += _getLineConfidence(line);
elementCount++;
}
}
return elementCount > 0 ? totalConfidence / elementCount : 0.0;
}
// Get confidence for a text line
double _getLineConfidence(TextLine line) {
// MLKit doesn't provide confidence directly, so we estimate based on text quality
String text = line.text;
// Basic heuristics for text quality
double confidence = 1.0;
// Penalize very short text
if (text.length < 3) confidence *= 0.5;
// Penalize text with many special characters
int specialChars = RegExp(r'[^\w\s]').allMatches(text).length;
if (specialChars > text.length * 0.3) confidence *= 0.7;
// Penalize text with inconsistent case
if (RegExp(r'[a-z][A-Z]').hasMatch(text)) confidence *= 0.8;
return confidence.clamp(0.0, 1.0);
}
// Validate OCR result
bool validateOCRResult(OCRResult result) {
if (!result.isSuccess) {
return false;
}
// If we have extracted text but no menu items, still allow processing
// The AI can work with raw text even without structured menu items
if (result.rawText.isNotEmpty && result.rawText.length > 10) {
return true;
}
// Check if we have reasonable menu items
int validItems = result.menuItems.where((item) =>
item.name.length > 2 &&
item.price > 0 &&
item.price < 1000000 // Reasonable price limit
).length;
return validItems >= 1;
}
// Dispose resources
void dispose() {
_textRecognizer.close();
}
}
// OCR Result model
class OCRResult {
final String rawText;
final String processedText;
final List<MenuItemOCR> menuItems;
final double confidence;
final bool isSuccess;
final String? error;
OCRResult({
required this.rawText,
required this.processedText,
required this.menuItems,
required this.confidence,
required this.isSuccess,
this.error,
});
bool get hasMenuItems => menuItems.isNotEmpty;
int get menuItemCount => menuItems.length;
String get summary {
if (!isSuccess) return 'OCR gagal: ${error ?? "Unknown error"}';
return 'Ditemukan ${menuItems.length} item menu dengan confidence ${(confidence * 100).toStringAsFixed(1)}%';
}
}
// Menu Item from OCR
class MenuItemOCR {
final String name;
final double price;
final String description;
final String category;
MenuItemOCR({
required this.name,
required this.price,
required this.description,
required this.category,
});
MenuItemOCR copyWith({
String? name,
double? price,
String? description,
String? category,
}) {
return MenuItemOCR(
name: name ?? this.name,
price: price ?? this.price,
description: description ?? this.description,
category: category ?? this.category,
);
}
String get formattedPrice {
return 'Rp ${price.toStringAsFixed(0).replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]}.',
)}';
}
Map<String, dynamic> toJson() {
return {
'name': name,
'price': price,
'description': description,
'category': category,
};
}
}