589 lines
18 KiB
Plaintext
589 lines
18 KiB
Plaintext
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:tflite_flutter/tflite_flutter.dart';
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:google_fonts/google_fonts.dart';
|
|
import '../../widgets/color.dart';
|
|
|
|
class KlasifikasiScreen extends StatefulWidget {
|
|
const KlasifikasiScreen({super.key});
|
|
|
|
@override
|
|
State<KlasifikasiScreen> createState() => _KlasifikasiScreenState();
|
|
}
|
|
|
|
class _KlasifikasiScreenState extends State<KlasifikasiScreen> {
|
|
File? _image;
|
|
Interpreter? _interpreter;
|
|
List<String>? _labels;
|
|
bool _isProcessing = false;
|
|
|
|
String _jenisKopi = "";
|
|
String _tingkatRoasting = "";
|
|
double _akurasi = 0.0;
|
|
List<Map<String, dynamic>> _top3Predictions = [];
|
|
|
|
bool _isValidCoffee = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadModel();
|
|
}
|
|
|
|
Future<void> _loadModel() async {
|
|
try {
|
|
_interpreter = await Interpreter.fromAsset(
|
|
'assets/model/best_model_epoch15.tflite',
|
|
);
|
|
_labels = [
|
|
"Arabika Dark",
|
|
"Arabika Light",
|
|
"Arabika Medium",
|
|
"Robusta Dark",
|
|
"Robusta Light",
|
|
"Robusta Medium",
|
|
];
|
|
} catch (e) {
|
|
debugPrint("Gagal memuat model: $e");
|
|
}
|
|
}
|
|
|
|
// FUNGSI BARU: Mengecek apakah gambar terlalu gelap atau terlalu polos (hitam/putih saja)
|
|
bool _isImageValid(img.Image image) {
|
|
double totalBrightness = 0;
|
|
List<int> pixels = image.getBytes();
|
|
|
|
// Hitung rata-rata kecerahan (Luminance)
|
|
for (int i = 0; i < pixels.length; i += 4) {
|
|
int r = pixels[i];
|
|
int g = pixels[i + 1];
|
|
int b = pixels[i + 2];
|
|
totalBrightness += (0.299 * r + 0.587 * g + 0.114 * b);
|
|
}
|
|
|
|
double avgBrightness = totalBrightness / (image.width * image.height);
|
|
|
|
// Jika rata-rata kecerahan < 30 (terlalu gelap/hitam) atau > 245 (terlalu putih)
|
|
if (avgBrightness < 30 || avgBrightness > 245) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<void> _pickImage(ImageSource source) async {
|
|
final picker = ImagePicker();
|
|
final pickedFile = await picker.pickImage(source: source);
|
|
|
|
if (pickedFile != null) {
|
|
setState(() {
|
|
_image = File(pickedFile.path);
|
|
_isProcessing = true;
|
|
_jenisKopi = "";
|
|
_isValidCoffee = true;
|
|
});
|
|
_classifyImage(_image!);
|
|
}
|
|
}
|
|
|
|
Future<void> _classifyImage(File image) async {
|
|
if (_interpreter == null) return;
|
|
|
|
var imageBytes = await image.readAsBytes();
|
|
var decodedImage = img.decodeImage(imageBytes);
|
|
if (decodedImage == null) return;
|
|
|
|
// 1. CEK VALIDASI AWAL (Mencegah foto hitam/polos diproses model)
|
|
if (!_isImageValid(decodedImage)) {
|
|
setState(() {
|
|
_isValidCoffee = false;
|
|
_jenisKopi = "Invalid";
|
|
_isProcessing = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
var resized = img.copyResize(decodedImage, width: 224, height: 224);
|
|
var input = List.generate(
|
|
1,
|
|
(i) => List.generate(
|
|
224,
|
|
(j) => List.generate(224, (k) => List.generate(3, (l) => 0.0)),
|
|
),
|
|
);
|
|
|
|
for (var y = 0; y < 224; y++) {
|
|
for (var x = 0; x < 224; x++) {
|
|
var pixel = resized.getPixel(x, y);
|
|
input[0][y][x][0] = pixel.r.toDouble();
|
|
input[0][y][x][1] = pixel.g.toDouble();
|
|
input[0][y][x][2] = pixel.b.toDouble();
|
|
}
|
|
}
|
|
|
|
var output = List.filled(1 * 6, 0.0).reshape([1, 6]);
|
|
_interpreter!.run(input, output);
|
|
List<double> results = List<double>.from(output[0]);
|
|
|
|
List<Map<String, dynamic>> tempPredictions = [];
|
|
for (int i = 0; i < _labels!.length; i++) {
|
|
tempPredictions.add({"label": _labels![i], "score": results[i]});
|
|
}
|
|
tempPredictions.sort((a, b) => b['score'].compareTo(a['score']));
|
|
|
|
setState(() {
|
|
_top3Predictions = tempPredictions.take(3).toList();
|
|
double confidence = _top3Predictions[0]['score'];
|
|
|
|
// 2. CEK THRESHOLD (Mencegah objek luar kopi yang mirip tetap terdeteksi)
|
|
// Naikkan ke 0.85 agar lebih ketat
|
|
if (confidence < 0.85) {
|
|
_isValidCoffee = false;
|
|
_jenisKopi = "Unknown";
|
|
} else {
|
|
_isValidCoffee = true;
|
|
String bestMatch = _top3Predictions[0]['label'];
|
|
List<String> splitLabel = bestMatch.split(" ");
|
|
_jenisKopi = splitLabel[0];
|
|
_tingkatRoasting = splitLabel.length > 1 ? splitLabel[1] : "";
|
|
_akurasi = confidence * 100;
|
|
}
|
|
_isProcessing = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: AppColors.background,
|
|
appBar: AppBar(
|
|
backgroundColor: AppColors.cardWhite,
|
|
surfaceTintColor: Colors.transparent,
|
|
elevation: 0,
|
|
title: Row(
|
|
children: [
|
|
Image.asset(
|
|
'assets/images/Logo_Coffee_Scan.png',
|
|
width: 30,
|
|
errorBuilder: (c, e, s) =>
|
|
const Icon(Icons.coffee, color: AppColors.brownMain),
|
|
),
|
|
const SizedBox(width: 10),
|
|
RichText(
|
|
text: TextSpan(
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
children: const [
|
|
TextSpan(
|
|
text: 'Coffee',
|
|
style: TextStyle(color: AppColors.brownMain),
|
|
),
|
|
TextSpan(
|
|
text: 'Scan',
|
|
style: TextStyle(color: AppColors.greenMain),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
bottom: false,
|
|
child: SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
padding: const EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
top: 16,
|
|
bottom: 180,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildInputCard(),
|
|
const SizedBox(height: 25),
|
|
if (_isProcessing)
|
|
const Center(
|
|
child: CircularProgressIndicator(color: AppColors.brownMain),
|
|
),
|
|
if (!_isProcessing && _jenisKopi.isNotEmpty)
|
|
_isValidCoffee
|
|
? _buildResultSection()
|
|
: _buildInvalidObjectCard(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInputCard() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.heroCokelat,
|
|
borderRadius: BorderRadius.circular(25),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 110,
|
|
height: 110,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: _image == null
|
|
? const Icon(Icons.image_search, size: 40, color: Colors.grey)
|
|
: ClipRRect(
|
|
borderRadius: BorderRadius.circular(20),
|
|
child: Image.file(_image!, fit: BoxFit.cover),
|
|
),
|
|
),
|
|
const SizedBox(width: 15),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
_buildActionButton(
|
|
Icons.camera_alt_rounded,
|
|
"Buka Kamera",
|
|
() => _pickImage(ImageSource.camera),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildActionButton(
|
|
Icons.photo_library_rounded,
|
|
"Unggah File",
|
|
() => _pickImage(ImageSource.gallery),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButton(IconData icon, String label, VoidCallback onTap) {
|
|
return ElevatedButton.icon(
|
|
onPressed: onTap,
|
|
icon: Icon(icon, size: 18),
|
|
label: Text(
|
|
label,
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.buttonCream,
|
|
foregroundColor: AppColors.textDark,
|
|
elevation: 0,
|
|
minimumSize: const Size(double.infinity, 45),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInvalidObjectCard() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(25),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red[50],
|
|
borderRadius: BorderRadius.circular(25),
|
|
border: Border.all(color: Colors.red.shade200),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 60),
|
|
const SizedBox(height: 15),
|
|
Text(
|
|
"Objek Tidak Valid",
|
|
style: GoogleFonts.montserrat(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
color: Colors.red[900],
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
"Gambar terlalu gelap, polos, atau bukan biji kopi yang dikenali. Silakan gunakan foto yang lebih jelas.",
|
|
textAlign: TextAlign.center,
|
|
style: GoogleFonts.montserrat(fontSize: 13, color: Colors.red[700]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildResultSection() {
|
|
return Column(
|
|
children: [
|
|
const Icon(Icons.check_circle, color: AppColors.greenSuccess, size: 70),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
"Hasil Klasifikasi",
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 25),
|
|
Row(
|
|
children: [
|
|
_buildDetailBox(
|
|
"Jenis Biji Kopi",
|
|
_jenisKopi,
|
|
Icons.coffee_rounded,
|
|
),
|
|
const SizedBox(width: 12),
|
|
_buildDetailBox(
|
|
"Tingkat Roasting",
|
|
_tingkatRoasting,
|
|
Icons.local_fire_department_rounded,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
_buildAccuracyMainCard(),
|
|
const SizedBox(height: 20),
|
|
_buildTop3AnalisisCard(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAccuracyMainCard() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.greenLight,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Akurasi Prediksi",
|
|
style: GoogleFonts.montserrat(
|
|
color: AppColors.greenSuccess,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
"${_akurasi.toStringAsFixed(0)}%",
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 32,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.greenSuccess,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Icon(
|
|
Icons.analytics_rounded,
|
|
color: AppColors.greenSuccess,
|
|
size: 50,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTop3AnalisisCard() {
|
|
List<Map<String, dynamic>> filteredPredictions = _top3Predictions
|
|
.where((pred) => (pred['score'] * 100) >= 0.1)
|
|
.toList();
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(25),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 15),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Analisis Kemungkinan",
|
|
style: GoogleFonts.montserrat(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
const SizedBox(height: 25),
|
|
Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 120,
|
|
height: 120,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 120,
|
|
height: 120,
|
|
child: CircularProgressIndicator(
|
|
value: 1.0,
|
|
strokeWidth: 12,
|
|
color: Colors.grey[100],
|
|
),
|
|
),
|
|
if (filteredPredictions.length > 2)
|
|
SizedBox(
|
|
width: 120,
|
|
height: 120,
|
|
child: CircularProgressIndicator(
|
|
value: filteredPredictions[2]['score'],
|
|
strokeWidth: 12,
|
|
strokeCap: StrokeCap.round,
|
|
color: AppColors.rank3,
|
|
),
|
|
),
|
|
if (filteredPredictions.length > 1)
|
|
SizedBox(
|
|
width: 120,
|
|
height: 120,
|
|
child: CircularProgressIndicator(
|
|
value: filteredPredictions[1]['score'],
|
|
strokeWidth: 12,
|
|
strokeCap: StrokeCap.round,
|
|
color: AppColors.rank2,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 120,
|
|
height: 120,
|
|
child: CircularProgressIndicator(
|
|
value: filteredPredictions[0]['score'],
|
|
strokeWidth: 12,
|
|
strokeCap: StrokeCap.round,
|
|
color: AppColors.rank1,
|
|
),
|
|
),
|
|
Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
"${_akurasi.toStringAsFixed(0)}%",
|
|
style: GoogleFonts.montserrat(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 18,
|
|
color: AppColors.rank1,
|
|
),
|
|
),
|
|
Text(
|
|
"Match",
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 9,
|
|
color: AppColors.textGrey,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 15),
|
|
Expanded(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(filteredPredictions.length, (index) {
|
|
Color itemColor = index == 0
|
|
? AppColors.rank1
|
|
: (index == 1 ? AppColors.rank2 : AppColors.rank3);
|
|
return _buildTop3Item(
|
|
filteredPredictions[index],
|
|
itemColor,
|
|
index == 0,
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTop3Item(Map<String, dynamic> pred, Color color, bool isTop) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
pred['label'],
|
|
maxLines: 1,
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 11,
|
|
color: isTop ? AppColors.textDark : AppColors.textGrey,
|
|
fontWeight: isTop ? FontWeight.bold : FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
"${(pred['score'] * 100).toStringAsFixed(1)}%",
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailBox(String title, String value, IconData icon) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 10),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.buttonCream.withOpacity(0.4),
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(icon, color: AppColors.brownMain, size: 28),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
title,
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 10,
|
|
color: AppColors.textGrey,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
textAlign: TextAlign.center,
|
|
style: GoogleFonts.montserrat(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.brownMain,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|