flask service

This commit is contained in:
kizadl 2026-05-14 14:01:02 +07:00
parent d444dd0672
commit a00caaa24b
15 changed files with 484 additions and 119 deletions

View File

@ -7,6 +7,7 @@
use Illuminate\Support\Facades\Auth;
use App\Models\Classification; // Import model Classification
use Illuminate\Support\Facades\DB;
use App\Models\User;
class DashboardController extends Controller
{
@ -41,6 +42,8 @@ public function index()
$totalBerhasil = (clone $query)->where('status', 'Berhasil')->count();
$successRate = $totalScan > 0 ? ($totalBerhasil / $totalScan) * 100 : 0;
$totalUser = User::where('role', 'user')->count();
return inertia('admin/Dashboard', [
'user' => $user,
'chartData' => $chartData,
@ -48,6 +51,7 @@ public function index()
'totalScan' => $totalScan,
'avgAccuracy' => round($avgAccuracy, 2),
'successRate' => round($successRate, 2),
'totalUser' => $totalUser,
]);
}
}

View File

@ -28,7 +28,7 @@ public function predict(Request $request)
$image = $request->file('image');
try {
// 1. Kirim ke AI Server untuk prediksi
// 1. Kirim ke AI Server (Flask)
$response = Http::attach(
'image',
file_get_contents($image),
@ -41,27 +41,31 @@ public function predict(Request $request)
$data = $response->json();
// 2. Jika sukses, simpan gambar dan hasil ke database
// 2. SIMPAN KE DB (BAIK BERHASIL MAUPUN DITOLAK)
$path = $image->store('classifications', 'public');
$confidence = (float) filter_var($data['confidence'], FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
$status = $confidence >= 90 ? 'Berhasil' : 'Gagal';
$confidence = (float) filter_var($data['confidence'] ?? 0, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION);
// Tentukan status untuk Database
$dbStatus = ($data['status'] === 'BERHASIL') ? 'Berhasil' : 'Ditolak';
Classification::create([
'user_id' => auth()->id() ?? null,
'image_path' => $path,
'result' => $data['label'],
'result' => $data['label'] ?? 'Unknown',
'confidence' => $confidence,
'status' => $status,
'status' => $dbStatus,
]);
// 3. KEMBALIKAN KE VUE (Bawa serta 'status' asli dari Flask)
return response()->json([
'label' => $data['label'],
'confidence' => $data['confidence'],
'message' => 'Hasil klasifikasi berhasil disimpan'
'label' => $data['label'] ?? 'Unknown',
'confidence' => $data['confidence'] ?? '0',
'message' => $data['pesan'] ?? 'Hasil klasifikasi selesai.',
'status' => $data['status'] // INI PENTING UNTUK SINKRONISASI
]);
} catch (\Exception $e) {
return response()->json(['error' => 'Terjadi kesalahan: ' . $e->getMessage()], 500);
return response()->json(['error' => 'Terjadi kesalahan internal: ' . $e->getMessage()], 500);
}
}
public function destroy(Classification $classification)

Binary file not shown.

View File

@ -1,72 +1,129 @@
import os
import io
import numpy as np
import tensorflow as tf
from flask import Flask, request, jsonify
from flask_cors import CORS
from tensorflow.keras.models import load_model
from tensorflow.keras.preprocessing.image import img_to_array
from tensorflow.keras.applications.mobilenet_v2 import preprocess_input # WAJIB UNTUK MOBILENETV2
from PIL import Image
from rembg import remove
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
CORS(app) # Agar bisa diakses dari Frontend Vue.js atau Laravel
# 1. SETUP PATH MODEL
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, 'model_efficientNet.keras')
# ==============================================================================
# 1. KONFIGURASI MODEL & KELAS
# ==============================================================================
# Pastikan file model .keras hasil training sudah dipindahkan ke folder ini
MODEL_PATH = 'Arsitektur_MobileNetV2.keras'
# 2. DUMMY PREPROCESS (Agar tidak error saat load Lambda layer)
def preprocess_input(x):
return x
if os.path.exists(MODEL_PATH):
model = load_model(MODEL_PATH)
print(f"✅ Model {MODEL_PATH} berhasil dimuat.")
else:
print(f"❌ ERROR: File {MODEL_PATH} tidak ditemukan!")
print("⏳ Sedang memuat 'Otak AI'...")
# Urutan kelas sesuai dengan training di Colab
classes = ['honey', 'natural', 'wash']
try:
# Menggunakan parameter Keras 3 untuk memuat model lama
model = tf.keras.models.load_model(
MODEL_PATH,
custom_objects={'preprocess_input': preprocess_input},
compile=False,
safe_mode=False # Kunci agar Keras 3 mau menerima config Keras lama
)
print("BERHASIL: Model AI readyy!")
except Exception as e:
print(f"GAGAL: {str(e)}")
# Label klasifikasi kopi kamu
labels = ['Honey', 'Natural', 'Washed']
# ==============================================================================
# 2. FUNGSI PREPROCESSING (Sesuai Standar MobileNetV2 + Rembg)
# ==============================================================================
def preprocess_robust_mobilenet(input_img):
# A. Hapus Background (Mengubah objek acak menjadi transparan)
output_rgba = remove(input_img)
# B. Auto-Crop ke Bounding Box (Menghilangkan sisa ruang kosong)
bbox = output_rgba.getbbox()
if bbox:
output_rgba = output_rgba.crop(bbox)
# C. Center Padding (Membuat kanvas hitam persegi 1:1)
max_dim = max(output_rgba.size)
black_bg = Image.new("RGB", (max_dim, max_dim), (0, 0, 0))
# Hitung posisi agar biji kopi tepat di tengah
paste_x = (max_dim - output_rgba.size[0]) // 2
paste_y = (max_dim - output_rgba.size[1]) // 2
# Tempelkan gambar menggunakan mask (untuk menjaga transparansi)
black_bg.paste(output_rgba, (paste_x, paste_y), mask=output_rgba.split()[3])
# D. Resize Standar MobileNetV2 (224x224)
final_img = black_bg.resize((224, 224))
# E. Konversi ke Array & Normalisasi Khusus MobileNetV2 (-1 hingga 1)
img_array = img_to_array(final_img)
img_array = np.expand_dims(img_array, axis=0)
img_array = preprocess_input(img_array) # INI KUNCINYA
return img_array
# ==============================================================================
# 3. ENDPOINT API PREDIKSI
# ==============================================================================
@app.route('/predict', methods=['POST'])
def predict():
if 'image' not in request.files:
return jsonify({"status": "ERROR", "message": "File gambar tidak ditemukan"}), 400
image_file = request.files['image']
try:
file = request.files['image']
# Load gambar sebagai RGBA agar rembg bekerja maksimal
img = Image.open(image_file.stream).convert("RGBA")
# 1. Load Gambar & Resize ke 224x224
img = Image.open(file.stream).convert('RGB')
img = img.resize((224, 224))
# Jalankan Preprocessing
processed_img = preprocess_robust_mobilenet(img)
# 2. Konversi ke Array
img_array = np.array(img).astype('float32')
# Prediksi menggunakan model
preds = model.predict(processed_img, verbose=0)[0]
confidence = float(np.max(preds))
predicted_class = classes[np.argmax(preds)]
# 3. JURUS SAKTI: Gunakan preprocessing asli EfficientNet
# Ini akan menangani scaling warna agar sama persis dengan saat training
img_array = tf.keras.applications.efficientnet.preprocess_input(img_array)
img_array = np.expand_dims(img_array, axis=0)
# Hitung Entropy (Mengukur tingkat kebingungan model)
entropy = -np.sum(preds * np.log(preds + 1e-9))
# Detail Probabilitas untuk ditampilkan di Frontend
prob_details = {}
for i, cls_name in enumerate(classes):
prob_details[cls_name] = round(float(preds[i]) * 100, 2)
# --- LOGIKA PENYARINGAN STATUS ---
# 1. Kasus: Gambar Tidak Jelas (Entropy Terlalu Tinggi)
if entropy > 0.85:
return jsonify({
"status": "DITOLAK",
"label": "Tidak terdeteksi",
"confidence": str(round(confidence * 100, 2)),
"pesan": "Sistem bingung. Pastikan foto hanya berisi satu biji kopi dengan latar belakang yang tidak terlalu ramai.",
"details": prob_details
}), 200
# 4. Prediksi
preds = model.predict(img_array, verbose=0)
class_idx = np.argmax(preds[0])
confidence = float(np.max(preds[0]))
print(f"📥 Prediksi: {labels[class_idx]} ({confidence*100:.2f}%)")
# 2. Kasus: Model Ditolak (Confidence < 70%)
if confidence < 0.70:
return jsonify({
"status": "DITOLAK",
"label": "Tidak terdeteksi",
"confidence": str(round(confidence * 100, 2)),
"pesan": f"Sistem ditolak. Tingkat keyakinan hanya {str(round(confidence * 100, 2))}%.",
"details": prob_details
}), 200
# 3. Kasus: Berhasil (Sukses, Confidence >= 70%)
return jsonify({
'label': labels[class_idx],
'confidence': f"{confidence * 100:.2f}%",
'status': 'success'
})
"status": "BERHASIL",
"label": predicted_class,
# DIBUNGKUS STRING AGAR .replace() DI VUE.JS TIDAK ERROR
"confidence": str(round(confidence * 100, 2)),
"pesan": f"Biji kopi berhasil diidentifikasi sebagai proses {predicted_class.upper()}.",
"details": prob_details
}), 200
except Exception as e:
print(f"❌ ERROR PREDIKSI: {str(e)}")
return jsonify({'error': str(e)}), 500
return jsonify({"status": "ERROR", "message": str(e)}), 500
if __name__ == '__main__':
# Jalankan di port 5001 agar tidak diblokir Windows AirPlay
app.run(host='127.0.0.1', port=5001, debug=True)
# Jalankan pada port 5001 (sesuaikan dengan settingan Laravel/Vue kamu)
app.run(host='0.0.0.0', port=5001, debug=False)

72
backend-ai/app.py_old Normal file
View File

@ -0,0 +1,72 @@
import os
import io
import numpy as np
import tensorflow as tf
from flask import Flask, request, jsonify
from flask_cors import CORS
from PIL import Image
app = Flask(__name__)
CORS(app)
# 1. SETUP PATH MODEL
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_PATH = os.path.join(BASE_DIR, 'model_efficientNet.keras')
# 2. DUMMY PREPROCESS (Agar tidak error saat load Lambda layer)
def preprocess_input(x):
return x
print("⏳ Sedang memuat 'Otak AI'...")
try:
# Menggunakan parameter Keras 3 untuk memuat model lama
model = tf.keras.models.load_model(
MODEL_PATH,
custom_objects={'preprocess_input': preprocess_input},
compile=False,
safe_mode=False # Kunci agar Keras 3 mau menerima config Keras lama
)
print("BERHASIL: Model AI readyy!")
except Exception as e:
print(f"GAGAL: {str(e)}")
# Label klasifikasi kopi kamu
labels = ['Honey', 'Natural', 'Washed']
@app.route('/predict', methods=['POST'])
def predict():
try:
file = request.files['image']
# 1. Load Gambar & Resize ke 224x224
img = Image.open(file.stream).convert('RGB')
img = img.resize((224, 224))
# 2. Konversi ke Array
img_array = np.array(img).astype('float32')
# 3. JURUS SAKTI: Gunakan preprocessing asli EfficientNet
# Ini akan menangani scaling warna agar sama persis dengan saat training
img_array = tf.keras.applications.efficientnet.preprocess_input(img_array)
img_array = np.expand_dims(img_array, axis=0)
# 4. Prediksi
preds = model.predict(img_array, verbose=0)
class_idx = np.argmax(preds[0])
confidence = float(np.max(preds[0]))
print(f"📥 Prediksi: {labels[class_idx]} ({confidence*100:.2f}%)")
return jsonify({
'label': labels[class_idx],
'confidence': f"{confidence * 100:.2f}%",
'status': 'success'
})
except Exception as e:
print(f"❌ ERROR PREDIKSI: {str(e)}")
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
# Jalankan di port 5001 agar tidak diblokir Windows AirPlay
app.run(host='127.0.0.1', port=5001, debug=True)

81
backend-ai/app.py_old2 Normal file
View File

@ -0,0 +1,81 @@
import os
import numpy as np
from flask import Flask, request, jsonify
from tensorflow.keras.models import load_model
from tensorflow.keras.preprocessing.image import img_to_array
from PIL import Image
from rembg import remove
from flask_cors import CORS
app = Flask(__name__)
CORS(app) # Izinkan semua origin mengakses API ini
# 1. Load Model (Pastikan file ini satu folder dengan app.py nanti)
MODEL_PATH = 'best_model.keras'
model = load_model(MODEL_PATH)
classes = ['honey', 'natural', 'washed']
# 2. Fungsi Preprocessing (Harus sama persis dengan saat training)
def preprocess_image(input_img):
# Hapus background & buat latar hitam
output_rgba = remove(input_img)
black_bg = Image.new("RGB", output_rgba.size, (0, 0, 0))
black_bg.paste(output_rgba, mask=output_rgba.split()[3])
# Resize ke 224x224 sesuai EfficientNetB0
final_img = black_bg.resize((224, 224))
# Konversi ke Array dan tambah dimensi batch (1, 224, 224, 3)
img_array = img_to_array(final_img)
img_array = np.expand_dims(img_array, axis=0)
return img_array
@app.route('/predict', methods=['POST'])
def predict():
# Menyesuaikan dengan formData.append('image', ...) dari Scanner.vue
if 'image' not in request.files:
return jsonify({"status": "ERROR", "message": "File gambar tidak ditemukan"}), 400
image_file = request.files['image']
try:
img = Image.open(image_file.stream).convert("RGBA")
processed_img = preprocess_image(img)
# Prediksi
preds = model.predict(processed_img)[0]
confidence = float(np.max(preds))
predicted_class = classes[np.argmax(preds)]
# Hitung Entropy (Mengukur tingkat kebingungan model)
entropy = -np.sum(preds * np.log(preds + 1e-9))
# --- LOGIKA PENYARINGAN (Threshold & Entropy) ---
# Jika entropy > 0.8, berarti model bingung (probabilitas terbagi-bagi)
if entropy > 0.85:
return jsonify({
"status": "DITOLAK",
"pesan": "Sistem bingung. Mohon pastikan foto adalah biji kopi tunggal yang jelas.",
"entropy_score": round(entropy, 4)
}), 200
# Jika keyakinan di bawah 75% (threshold)
if confidence < 0.70:
return jsonify({
"status": "TIDAK YAKIN",
"pesan": f"Model menduga {predicted_class}, tapi kurang yakin ({confidence*100:.1f}%).",
"confidence": str(round(confidence * 100, 2)) # <--- BUNGKUS DENGAN str() DI SINI
}), 200
# Lolos verifikasi
return jsonify({
"status": "SUKSES",
"label": predicted_class,
"confidence": str(round(confidence * 100, 2)), # <--- BUNGKUS DENGAN str() DI SINI
"pesan": f"Biji kopi teridentifikasi sebagai {predicted_class}."
}), 200
except Exception as e:
return jsonify({"status": "ERROR", "message": str(e)}), 500
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5001)

109
backend-ai/app.py_old3 Normal file
View File

@ -0,0 +1,109 @@
import os
import numpy as np
from flask import Flask, request, jsonify
from tensorflow.keras.models import load_model
from tensorflow.keras.preprocessing.image import img_to_array
from PIL import Image
from rembg import remove
from flask_cors import CORS
app = Flask(__name__)
CORS(app)
# 1. Load Model
# Pastikan file model terbaru sudah kamu download dan ganti namanya menjadi ini
MODEL_PATH = 'train_part4.keras'
model = load_model(MODEL_PATH)
# Pastikan urutan kelas sesuai dengan test_generator.class_indices
# Tadi di Colab urutannya: honey, natural, wash (bukan washed)
classes = ['honey', 'natural', 'wash']
# 2. Fungsi Preprocessing Robust (Sesuai eksperimen terakhir di Colab)
def preprocess_image(input_img):
# A. Hapus Background
output_rgba = remove(input_img)
# B. Auto-Crop ke Bounding Box (Fokus ke biji kopi saja)
bbox = output_rgba.getbbox()
if bbox:
output_rgba = output_rgba.crop(bbox)
# C. Center Padding (Membuat kanvas hitam persegi)
max_dim = max(output_rgba.size)
black_bg = Image.new("RGB", (max_dim, max_dim), (0, 0, 0))
# Hitung posisi agar biji kopi di tengah
paste_x = (max_dim - output_rgba.size[0]) // 2
paste_y = (max_dim - output_rgba.size[1]) // 2
# Tempelkan gambar transparan ke latar hitam
black_bg.paste(output_rgba, (paste_x, paste_y), mask=output_rgba.split()[3])
# D. Resize ke 224x224 (Input EfficientNetB0)
final_img = black_bg.resize((224, 224))
# E. Konversi ke Array
img_array = img_to_array(final_img)
img_array = np.expand_dims(img_array, axis=0)
return img_array
@app.route('/predict', methods=['POST'])
def predict():
if 'image' not in request.files:
return jsonify({"status": "ERROR", "message": "File gambar tidak ditemukan"}), 400
image_file = request.files['image']
try:
# Load gambar asli sebagai RGBA agar rembg bekerja maksimal
img = Image.open(image_file.stream).convert("RGBA")
# Jalankan Preprocessing Robust
processed_img = preprocess_image(img)
# Prediksi
preds = model.predict(processed_img)[0]
confidence = float(np.max(preds))
predicted_class = classes[np.argmax(preds)]
# Hitung Entropy (Mengukur tingkat kebingungan model)
entropy = -np.sum(preds * np.log(preds + 1e-9))
prob_details = "Analisis Probabilitas Model:\n"
for i, cls_name in enumerate(classes):
prob_details += f"- {cls_name.capitalize()}: {round(float(preds[i]) * 100, 2)}%\n"
prob_details = prob_details.strip()
# --- LOGIKA PENYARINGAN (Threshold & Entropy) ---
# 1. Jika entropy tinggi (Model bingung parah)
if entropy > 0.85:
return jsonify({
"status": "DITOLAK",
"pesan": f"Sistem mendeteksi ketidakjelasan. Pastikan objek adalah biji kopi tunggal dengan pencahayaan cukup.\n\n{prob_details}",
"entropy_score": round(float(entropy), 4)
}), 200
# 2. Jika Keyakinan Rendah (Di bawah 70%)
if confidence < 0.70:
return jsonify({
"status": "TIDAK YAKIN",
"label": predicted_class,
"confidence": str(round(confidence * 100, 2)),
"pesan": f"Model menduga ini proses {predicted_class}, namun tingkat keyakinan rendah.\n\n{prob_details}"
}), 200
# 3. Lolos Verifikasi (Status SUKSES)
return jsonify({
"status": "SUKSES",
"label": predicted_class,
"confidence": str(round(confidence * 100, 2)),
"pesan": f"Biji kopi teridentifikasi sebagai proses {predicted_class}.\n\n{prob_details}"
}), 200
except Exception as e:
return jsonify({"status": "ERROR", "message": str(e)}), 500
if __name__ == '__main__':
# Pastikan port sesuai dengan yang dibuka di firewall server/local kamu
app.run(host='0.0.0.0', port=5001, debug=False)

BIN
backend-ai/best_model.keras Normal file

Binary file not shown.

Binary file not shown.

BIN
public/pict_example.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

View File

@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
class="flex aspect-square size-8 items-center justify-center rounded-md bg-green-600"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
<AppLogoIcon class="size-5 text-white" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold"
>Laravel Starter Kit</span
>GREENS</span
>
</div>
</template>

View File

@ -1,5 +1,7 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
import { Bean } from 'lucide-vue-next';
defineOptions({
inheritAttrs: false,
@ -13,17 +15,8 @@ defineProps<Props>();
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 42"
:class="className"
v-bind="$attrs"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>
<Bean
class="w-5 h-5 text-white"
:stroke-width="2.8"
/>
</template>

View File

@ -48,7 +48,7 @@ const userNavItems: NavItem[] = [
icon: LayoutGrid,
},
{
title: 'Klasifikasi',
title: 'Riwayat Klasifikasi',
href: route('classifications.index'),
icon: ScanSearch,
},
@ -73,18 +73,7 @@ const navItems = computed(() => {
// });
// });
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script>
<template>
@ -106,7 +95,6 @@ const footerNavItems: NavItem[] = [
</SidebarContent>
<SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser />
</SidebarFooter>
</Sidebar>

View File

@ -81,31 +81,45 @@
<transition enter-active-class="transition duration-500 ease-out" enter-from-class="transform scale-95 opacity-0" enter-to-class="transform scale-100 opacity-100">
<div
v-if="predictionResult"
class="mt-8 p-8 rounded-3xl shadow-[0_0_50px_-12px_rgba(16,185,129,0.5)] text-center relative overflow-hidden transition-colors duration-500"
:class="parseFloat(predictionResult.confidence.replace('%', '')) >= 90
? 'bg-emerald-600 shadow-emerald-500/50'
: 'bg-red-600 shadow-red-500/50'"
class="mt-8 p-8 rounded-3xl text-center relative overflow-hidden transition-colors duration-500 shadow-lg"
:class="statusInfo?.cardClass"
>
<div class="relative z-10">
<p class="text-xs font-black text-emerald-100 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
<h4 class="text-5xl font-black text-white italic mb-4 tracking-tighter">{{ predictionResult.label }}</h4>
<div class="flex flex-wrap justify-center gap-3">
<div class="px-4 py-1.5 bg-black/20 rounded-full backdrop-blur-md border border-white/10">
<span class="text-emerald-400 font-bold text-xs uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
</div>
<div :class="predictionResult.status === 'DITOLAK' ? 'flex flex-col md:flex-row items-center justify-center gap-8' : ''">
<div
class="px-4 py-1.5 rounded-full backdrop-blur-md border flex items-center gap-2"
:class="parseFloat(predictionResult.confidence.replace('%', '')) >= 90
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-200'
: 'bg-red-500/20 border-red-500/50 text-red-200'"
>
<div :class="['w-1.5 h-1.5 rounded-full', parseFloat(predictionResult.confidence.replace('%', '')) >= 90 ? 'bg-emerald-400' : 'bg-red-400']"></div>
<span class="font-bold text-xs uppercase tracking-widest">
{{ parseFloat(predictionResult.confidence.replace('%', '')) >= 90 ? 'Berhasil' : 'Gagal' }}
</span>
<!-- KIRI: CONTOH FOTO JIKA DITOLAK -->
<div v-if="predictionResult.status === 'DITOLAK'" class="flex-1 w-full max-w-sm mx-auto bg-black/20 p-6 rounded-2xl border border-white/10 text-left">
<p class="text-sm text-white font-bold mb-3 text-center">Contoh Foto yang Benar:</p>
<img src="/pict_example.jpg" alt="Contoh Foto Biji Kopi" class="w-full rounded-xl shadow-sm border border-white/20" loading="lazy" />
</div>
<!-- KANAN / TENGAH: HASIL PREDIKSI -->
<div class="flex-1 w-full max-w-sm mx-auto flex flex-col justify-center">
<p class="text-xs font-black text-white/70 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p>
<h4 class="text-5xl font-black text-white mb-4 tracking-tighter">{{ predictionResult.label }}</h4>
<div class="flex flex-wrap justify-center gap-3">
<div class="px-4 py-1.5 bg-black/20 rounded-full backdrop-blur-md border border-white/10">
<span class="text-white font-bold text-xs uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
</div>
<div
class="px-4 py-1.5 rounded-full backdrop-blur-md border flex items-center gap-2"
:class="statusInfo?.badgeClass"
>
<div :class="['w-1.5 h-1.5 rounded-full', statusInfo?.dotClass]"></div>
<span class="font-bold text-xs uppercase tracking-widest">
{{ statusInfo?.text }}
</span>
</div>
</div>
<!-- Menampilkan Probabilitas -->
<div v-if="predictionResult.details" class="mt-6 text-sm text-white/80 whitespace-pre-line text-left bg-black/20 p-4 rounded-xl border border-white/10 w-full">
{{ predictionResult.details }}
</div>
</div>
</div>
</div>
<Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" />
@ -118,16 +132,40 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import { route } from 'ziggy-js'
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
const imagePreview = ref<string | null>(null)
const selectedFile = ref<File | null>(null)
const predictionResult = ref<{ label: string, confidence: string } | null>(null)
const predictionResult = ref<{ label: string, confidence: string, details?: string, status?: string } | null>(null)
const isLoading = ref(false)
const statusInfo = computed(() => {
if (!predictionResult.value) return null
// Membaca status langsung dari Backend!
const statusAI = predictionResult.value.status
if (statusAI === 'BERHASIL') {
return {
text: 'Berhasil',
cardClass: 'bg-emerald-600 shadow-[0_0_50px_-12px_rgba(16,185,129,0.5)]',
badgeClass: 'bg-emerald-500/20 border-emerald-500/50 text-emerald-200',
dotClass: 'bg-emerald-400'
}
} else {
// Berlaku untuk 'DITOLAK'
return {
text: 'Ditolak',
cardClass: 'bg-red-600 shadow-[0_0_50px_-12px_rgba(220,38,38,0.5)]',
badgeClass: 'bg-red-500/20 border-red-500/50 text-red-200',
dotClass: 'bg-red-400'
}
}
})
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
@ -167,7 +205,9 @@ const startClassification = async () => {
const data = await response.json()
predictionResult.value = {
label: data.label,
confidence: data.confidence
confidence: data.confidence + '%', // Tambah % biar rapi di UI
details: data.message,
status: data.status // Simpan status dari Laravel/Flask
}
// Refresh data jika di Inertia context (dashboard)
@ -176,6 +216,12 @@ const startClassification = async () => {
}
} catch (error: any) {
console.error(error)
predictionResult.value = {
label: 'Gagal',
confidence: '0%',
details: error.message || 'Terjadi kesalahan saat klasifikasi',
status: 'DITOLAK' // Beri status DITOLAK agar UI langsung memerah
}
} finally {
isLoading.value = false
}

View File

@ -29,7 +29,8 @@ const props = defineProps<{
recentHistory: Array<any>,
totalScan: number,
avgAccuracy: number,
successRate: number
successRate: number,
totalUser: number,
}>();
// Konfigurasi Warna & Data untuk Pie Chart
@ -38,9 +39,9 @@ const chartConfig = computed(() => ({
datasets: [{
backgroundColor: props.chartData.map(item => {
switch (item.result?.toLowerCase()) {
case 'honey': return '#f59e0b'; // Amber-500
case 'washed': return '#3b82f6'; // Blue-500
case 'natural': return '#10b981'; // Emerald-500
case 'honey': return '#f59e0b'; // Amber-500
case 'wash': return '#10b981'; // Blue-500
case 'natural': return '#713600'; // Emerald-500
default: return '#94a3b8'; // Slate-400
}
}),
@ -86,7 +87,17 @@ const chartOptions = {
</CardHeader>
</Card>
<div class="grid gap-4 md:grid-cols-3">
<div class="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Pengguna</CardTitle>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{{ totalUser }}</div>
<p class="text-xs text-muted-foreground">Akumulasi seluruh pengguna</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Scan</CardTitle>