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

View File

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

Binary file not shown.

View File

@ -1,72 +1,129 @@
import os import os
import io
import numpy as np import numpy as np
import tensorflow as tf
from flask import Flask, request, jsonify 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 PIL import Image
from rembg import remove
from flask_cors import CORS
app = Flask(__name__) 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__)) # 1. KONFIGURASI MODEL & KELAS
MODEL_PATH = os.path.join(BASE_DIR, 'model_efficientNet.keras') # ==============================================================================
# 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) if os.path.exists(MODEL_PATH):
def preprocess_input(x): model = load_model(MODEL_PATH)
return x 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 # 2. FUNGSI PREPROCESSING (Sesuai Standar MobileNetV2 + Rembg)
model = tf.keras.models.load_model( # ==============================================================================
MODEL_PATH, def preprocess_robust_mobilenet(input_img):
custom_objects={'preprocess_input': preprocess_input}, # A. Hapus Background (Mengubah objek acak menjadi transparan)
compile=False, output_rgba = remove(input_img)
safe_mode=False # Kunci agar Keras 3 mau menerima config Keras lama
) # B. Auto-Crop ke Bounding Box (Menghilangkan sisa ruang kosong)
print("BERHASIL: Model AI readyy!") bbox = output_rgba.getbbox()
except Exception as e: if bbox:
print(f"GAGAL: {str(e)}") output_rgba = output_rgba.crop(bbox)
# Label klasifikasi kopi kamu # C. Center Padding (Membuat kanvas hitam persegi 1:1)
labels = ['Honey', 'Natural', 'Washed'] 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']) @app.route('/predict', methods=['POST'])
def predict(): def predict():
if 'image' not in request.files:
return jsonify({"status": "ERROR", "message": "File gambar tidak ditemukan"}), 400
image_file = request.files['image']
try: 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 # Jalankan Preprocessing
img = Image.open(file.stream).convert('RGB') processed_img = preprocess_robust_mobilenet(img)
img = img.resize((224, 224))
# 2. Konversi ke Array # Prediksi menggunakan model
img_array = np.array(img).astype('float32') 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 # Hitung Entropy (Mengukur tingkat kebingungan model)
# Ini akan menangani scaling warna agar sama persis dengan saat training entropy = -np.sum(preds * np.log(preds + 1e-9))
img_array = tf.keras.applications.efficientnet.preprocess_input(img_array)
img_array = np.expand_dims(img_array, axis=0) # 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 # 2. Kasus: Model Ditolak (Confidence < 70%)
preds = model.predict(img_array, verbose=0) if confidence < 0.70:
class_idx = np.argmax(preds[0]) return jsonify({
confidence = float(np.max(preds[0])) "status": "DITOLAK",
"label": "Tidak terdeteksi",
print(f"📥 Prediksi: {labels[class_idx]} ({confidence*100:.2f}%)") "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({ return jsonify({
'label': labels[class_idx], "status": "BERHASIL",
'confidence': f"{confidence * 100:.2f}%", "label": predicted_class,
'status': 'success' # 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: except Exception as e:
print(f"❌ ERROR PREDIKSI: {str(e)}") return jsonify({"status": "ERROR", "message": str(e)}), 500
return jsonify({'error': str(e)}), 500
if __name__ == '__main__': if __name__ == '__main__':
# Jalankan di port 5001 agar tidak diblokir Windows AirPlay # Jalankan pada port 5001 (sesuaikan dengan settingan Laravel/Vue kamu)
app.run(host='127.0.0.1', port=5001, debug=True) 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> <template>
<div <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>
<div class="ml-1 grid flex-1 text-left text-sm"> <div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold" <span class="mb-0.5 truncate leading-tight font-semibold"
>Laravel Starter Kit</span >GREENS</span
> >
</div> </div>
</template> </template>

View File

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { HTMLAttributes } from 'vue'; import type { HTMLAttributes } from 'vue';
import { Bean } from 'lucide-vue-next';
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
@ -13,17 +15,8 @@ defineProps<Props>();
</script> </script>
<template> <template>
<svg <Bean
xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-white"
viewBox="0 0 40 42" :stroke-width="2.8"
: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>
</template> </template>

View File

@ -48,7 +48,7 @@ const userNavItems: NavItem[] = [
icon: LayoutGrid, icon: LayoutGrid,
}, },
{ {
title: 'Klasifikasi', title: 'Riwayat Klasifikasi',
href: route('classifications.index'), href: route('classifications.index'),
icon: ScanSearch, 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> </script>
<template> <template>
@ -106,7 +95,6 @@ const footerNavItems: NavItem[] = [
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavFooter :items="footerNavItems" />
<NavUser /> <NavUser />
</SidebarFooter> </SidebarFooter>
</Sidebar> </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"> <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 <div
v-if="predictionResult" 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="mt-8 p-8 rounded-3xl text-center relative overflow-hidden transition-colors duration-500 shadow-lg"
:class="parseFloat(predictionResult.confidence.replace('%', '')) >= 90 :class="statusInfo?.cardClass"
? 'bg-emerald-600 shadow-emerald-500/50'
: 'bg-red-600 shadow-red-500/50'"
> >
<div class="relative z-10"> <div class="relative z-10">
<p class="text-xs font-black text-emerald-100 uppercase tracking-[0.3em] mb-2">Hasil Klasifikasi AI</p> <div :class="predictionResult.status === 'DITOLAK' ? 'flex flex-col md:flex-row items-center justify-center gap-8' : ''">
<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 <!-- KIRI: CONTOH FOTO JIKA DITOLAK -->
class="px-4 py-1.5 rounded-full backdrop-blur-md border flex items-center gap-2" <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">
:class="parseFloat(predictionResult.confidence.replace('%', '')) >= 90 <p class="text-sm text-white font-bold mb-3 text-center">Contoh Foto yang Benar:</p>
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-200' <img src="/pict_example.jpg" alt="Contoh Foto Biji Kopi" class="w-full rounded-xl shadow-sm border border-white/20" loading="lazy" />
: '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>
</div> </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>
</div> </div>
<Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" /> <Bean class="absolute -right-10 -bottom-10 w-40 h-40 text-white/10 rotate-12" />
@ -118,16 +132,40 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3' import { router } from '@inertiajs/vue3'
import { route } from 'ziggy-js' import { route } from 'ziggy-js'
import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next' import { Upload, Plus, X, Loader2, Bean } from 'lucide-vue-next'
const imagePreview = ref<string | null>(null) const imagePreview = ref<string | null>(null)
const selectedFile = ref<File | 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 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 handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement
const file = target.files?.[0] const file = target.files?.[0]
@ -167,7 +205,9 @@ const startClassification = async () => {
const data = await response.json() const data = await response.json()
predictionResult.value = { predictionResult.value = {
label: data.label, 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) // Refresh data jika di Inertia context (dashboard)
@ -176,6 +216,12 @@ const startClassification = async () => {
} }
} catch (error: any) { } catch (error: any) {
console.error(error) 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 { } finally {
isLoading.value = false isLoading.value = false
} }

View File

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