360 lines
11 KiB
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,
|
|
};
|
|
}
|
|
}
|