306 lines
9.2 KiB
Dart
306 lines
9.2 KiB
Dart
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:tflite_flutter/tflite_flutter.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import '../models/prediction_result.dart';
|
|
|
|
class ClassifierService {
|
|
Interpreter? _interpreter;
|
|
List<String>? _labels;
|
|
|
|
Future<void> loadModel() async {
|
|
try {
|
|
_interpreter = await Interpreter.fromAsset(
|
|
'assets/model/best_model_epoch15.tflite',
|
|
);
|
|
final labelData = await rootBundle.loadString('assets/model/label.txt');
|
|
_labels = labelData
|
|
.split(RegExp(r'\r?\n'))
|
|
.where((l) => l.trim().isNotEmpty)
|
|
.toList();
|
|
print("✅ Model Load Success");
|
|
} catch (e) {
|
|
print("❌ Model Load Failed: $e");
|
|
}
|
|
}
|
|
|
|
/// REPLIKASI TOTAL LOGIKA PYTHON
|
|
img.Image syncPreprocessing(img.Image image) {
|
|
img.Image fixedImage = img.bakeOrientation(image);
|
|
|
|
// 2. Hitung Dimensi Target Zoom 80%
|
|
double zoomFactor = 0.8;
|
|
int targetW = (fixedImage.width * zoomFactor).toInt();
|
|
int targetH = (fixedImage.height * zoomFactor).toInt();
|
|
|
|
// 3. Tentukan Titik Tengah Absolut
|
|
int centerX = fixedImage.width ~/ 2;
|
|
int centerY = fixedImage.height ~/ 2;
|
|
|
|
// 4. Hitung Sisi-Sisi Potongan
|
|
int left = centerX - (targetW ~/ 2);
|
|
int top = centerY - (targetH ~/ 2);
|
|
|
|
// 5. Eksekusi Manual Crop
|
|
img.Image cropped = img.copyCrop(
|
|
fixedImage,
|
|
x: left,
|
|
y: top,
|
|
width: targetW,
|
|
height: targetH,
|
|
);
|
|
|
|
int side = (cropped.width < cropped.height)
|
|
? cropped.width
|
|
: cropped.height;
|
|
int startX = (cropped.width - side) ~/ 2;
|
|
int startY = (cropped.height - side) ~/ 2;
|
|
|
|
img.Image squareImage = img.copyCrop(
|
|
cropped,
|
|
x: startX,
|
|
y: startY,
|
|
width: side,
|
|
height: side,
|
|
);
|
|
|
|
// 7. Final Resize ke 224x224
|
|
return img.copyResize(
|
|
squareImage,
|
|
width: 224,
|
|
height: 224,
|
|
interpolation: img.Interpolation.cubic,
|
|
);
|
|
}
|
|
|
|
Future<PredictionResult> classify(File imageFile) async {
|
|
if (_interpreter == null) return _errorResult("Model belum dimuat");
|
|
|
|
final bytes = await imageFile.readAsBytes();
|
|
final decodedImage = img.decodeImage(bytes);
|
|
|
|
if (decodedImage == null) return _errorResult("Gambar rusak");
|
|
|
|
// PROSES: Samakan dengan dataset Python
|
|
img.Image finalImage = syncPreprocessing(decodedImage);
|
|
|
|
// Persiapan Tensor Input
|
|
var input = Float32List(1 * 224 * 224 * 3);
|
|
int buffer = 0;
|
|
for (int y = 0; y < 224; y++) {
|
|
for (int x = 0; x < 224; x++) {
|
|
var pixel = finalImage.getPixel(x, y);
|
|
input[buffer++] = pixel.r.toDouble();
|
|
input[buffer++] = pixel.g.toDouble();
|
|
input[buffer++] = pixel.b.toDouble();
|
|
}
|
|
}
|
|
|
|
var output = List.filled(1 * 6, 0.0).reshape([1, 6]);
|
|
_interpreter!.run(
|
|
input.buffer.asFloat32List().reshape([1, 224, 224, 3]),
|
|
output,
|
|
);
|
|
|
|
return _processResults(output[0]);
|
|
}
|
|
|
|
// --- Helper Functions ---
|
|
|
|
Future<File> preprocessImageForDisplay(File imageFile) async {
|
|
final bytes = await imageFile.readAsBytes();
|
|
final decodedImage = img.decodeImage(bytes);
|
|
if (decodedImage == null) return imageFile;
|
|
|
|
img.Image processed = syncPreprocessing(decodedImage);
|
|
|
|
final tempPath =
|
|
"${Directory.systemTemp.path}/view_${DateTime.now().millisecondsSinceEpoch}.jpg";
|
|
File(tempPath).writeAsBytesSync(img.encodeJpg(processed, quality: 95));
|
|
return File(tempPath);
|
|
}
|
|
|
|
PredictionResult _errorResult(String msg) => PredictionResult(
|
|
jenisKopi: "Error",
|
|
tingkatRoasting: msg,
|
|
akurasi: 0.0,
|
|
top3Predictions: [],
|
|
isValid: false,
|
|
);
|
|
|
|
PredictionResult _processResults(List<dynamic> results) {
|
|
List<Map<String, dynamic>> temp = [];
|
|
for (int i = 0; i < _labels!.length; i++) {
|
|
temp.add({"label": _labels![i], "score": results[i]});
|
|
}
|
|
temp.sort((a, b) => b['score'].compareTo(a['score']));
|
|
|
|
if (temp[0]['score'] < 0.5) return _errorResult("Kopi tidak dikenali");
|
|
|
|
List<String> split = temp[0]['label'].split(" ");
|
|
return PredictionResult(
|
|
jenisKopi: split[0],
|
|
tingkatRoasting: split.length > 1 ? split.sublist(1).join(" ") : "",
|
|
akurasi: temp[0]['score'] * 100,
|
|
top3Predictions: temp.take(3).toList(),
|
|
isValid: true,
|
|
);
|
|
}
|
|
}
|
|
// import 'dart:io';
|
|
// import 'dart:typed_data';
|
|
// import 'package:flutter/services.dart';
|
|
// import 'package:tflite_flutter/tflite_flutter.dart';
|
|
// import 'package:image/image.dart' as img;
|
|
// import '../models/prediction_result.dart';
|
|
|
|
// class ClassifierService {
|
|
// Interpreter? _interpreter;
|
|
// List<String>? _labels;
|
|
|
|
// Future<void> loadModel() async {
|
|
// try {
|
|
// // Pastikan file model di assets sudah sesuai dengan versi terbaru
|
|
// _interpreter = await Interpreter.fromAsset(
|
|
// 'assets/model/best_model_epoch15.tflite',
|
|
// );
|
|
|
|
// final labelData = await rootBundle.loadString('assets/model/label.txt');
|
|
// // Perbaikan: Gunakan split RegExp untuk menangani \n atau \r\n agar label bersih
|
|
// _labels = labelData
|
|
// .split(RegExp(r'\r?\n'))
|
|
// .where((label) => label.trim().isNotEmpty)
|
|
// .toList();
|
|
|
|
// print("✅ Model dan ${_labels?.length} Label berhasil dimuat!");
|
|
// } catch (e) {
|
|
// print("❌ Gagal memuat model: $e");
|
|
// }
|
|
// }
|
|
|
|
// bool _checkImageAuthenticity(img.Image image) {
|
|
// int rSum = 0, gSum = 0, bSum = 0;
|
|
// int sampleStep = 10;
|
|
// int count = 0;
|
|
|
|
// int startX = (image.width * 0.25).toInt();
|
|
// int startY = (image.height * 0.25).toInt();
|
|
// int endX = (image.width * 0.75).toInt();
|
|
// int endY = (image.height * 0.75).toInt();
|
|
|
|
// for (int y = startY; y < endY; y += sampleStep) {
|
|
// for (int x = startX; x < endX; x += sampleStep) {
|
|
// var pixel = image.getPixel(x, y);
|
|
// rSum += pixel.r.toInt();
|
|
// gSum += pixel.g.toInt();
|
|
// bSum += pixel.b.toInt();
|
|
// count++;
|
|
// }
|
|
// }
|
|
|
|
// if (count == 0) return false;
|
|
|
|
// double avgR = rSum / count;
|
|
// double avgG = gSum / count;
|
|
// double avgB = bSum / count;
|
|
// double brightness = (0.299 * avgR + 0.587 * avgG + 0.114 * avgB);
|
|
|
|
// if (brightness < 20 || brightness > 235) return false;
|
|
// if (avgG > avgR * 1.3 || avgB > avgR * 1.3) return false;
|
|
// return true;
|
|
// }
|
|
|
|
// Future<PredictionResult> classify(File imageFile) async {
|
|
// if (_interpreter == null) {
|
|
// return PredictionResult(
|
|
// jenisKopi: "Error",
|
|
// tingkatRoasting: "Model belum dimuat",
|
|
// akurasi: 0.0,
|
|
// top3Predictions: [],
|
|
// isValid: false,
|
|
// );
|
|
// }
|
|
|
|
// final bytes = await imageFile.readAsBytes();
|
|
// final decodedImage = img.decodeImage(bytes);
|
|
|
|
// if (decodedImage == null || !_checkImageAuthenticity(decodedImage)) {
|
|
// return PredictionResult(
|
|
// jenisKopi: "Invalid",
|
|
// tingkatRoasting: "",
|
|
// akurasi: 0.0,
|
|
// top3Predictions: [],
|
|
// isValid: false,
|
|
// );
|
|
// }
|
|
|
|
// // --- SINKRONISASI PREPROCESSING ---
|
|
// // Menggunakan copyResizeCropSquare agar identik dengan ImageOps.fit (Center Crop)
|
|
// // Interpolation.cubic memberikan kualitas tajam mirip LANCZOS di PIL
|
|
// var resized = img.copyResizeCropSquare(
|
|
// decodedImage,
|
|
// size: 224,
|
|
// interpolation: img.Interpolation.cubic,
|
|
// );
|
|
|
|
// // --- INPUT TENSOR ---
|
|
// // Menggunakan Float32List untuk menjamin presisi data saat masuk ke TFLite
|
|
// var input = Float32List(1 * 224 * 224 * 3);
|
|
// var buffer = 0;
|
|
|
|
// for (var y = 0; y < 224; y++) {
|
|
// for (var x = 0; x < 224; x++) {
|
|
// var pixel = resized.getPixel(x, y);
|
|
|
|
// // Nilai mentah 0-255 sesuai setingan Python (tanpa normalisasi manual)
|
|
// input[buffer++] = pixel.r.toDouble();
|
|
// input[buffer++] = pixel.g.toDouble();
|
|
// input[buffer++] = pixel.b.toDouble();
|
|
// }
|
|
// }
|
|
|
|
// // Output buffer sesuai jumlah kelas (6)
|
|
// var output = List.filled(1 * 6, 0.0).reshape([1, 6]);
|
|
|
|
// // Jalankan Inferensi
|
|
// _interpreter!.run(
|
|
// input.buffer.asFloat32List().reshape([1, 224, 224, 3]),
|
|
// output,
|
|
// );
|
|
|
|
// List<double> results = List<double>.from(output[0]);
|
|
|
|
// // Mapping & Ranking
|
|
// List<Map<String, dynamic>> temp = [];
|
|
// for (int i = 0; i < _labels!.length; i++) {
|
|
// temp.add({"label": _labels![i].trim(), "score": results[i]});
|
|
// }
|
|
|
|
// temp.sort((a, b) => b['score'].compareTo(a['score']));
|
|
|
|
// double topScore = temp[0]['score'];
|
|
|
|
// // Threshold keyakinan model (Sesuai Python CONFIDENCE_THRESHOLD = 0.5)
|
|
// if (topScore < 0.50) {
|
|
// return PredictionResult(
|
|
// jenisKopi: "Unknown",
|
|
// tingkatRoasting: "",
|
|
// akurasi: topScore * 100,
|
|
// top3Predictions: temp.take(3).toList(),
|
|
// isValid: false,
|
|
// );
|
|
// }
|
|
|
|
// // Memisahkan Jenis Kopi dan Roasting (Contoh: "Arabica Dark")
|
|
// List<String> split = temp[0]['label'].split(" ");
|
|
// return PredictionResult(
|
|
// jenisKopi: split[0],
|
|
// tingkatRoasting: split.length > 1 ? split.sublist(1).join(" ") : "",
|
|
// akurasi: topScore * 100,
|
|
// top3Predictions: temp.take(3).toList(),
|
|
// isValid: true,
|
|
// );
|
|
// }
|
|
// }
|