flask service
This commit is contained in:
parent
d444dd0672
commit
a00caaa24b
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] ?? 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([
|
||||
'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.
|
|
@ -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)}")
|
||||
# ==============================================================================
|
||||
# 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)
|
||||
|
||||
# Label klasifikasi kopi kamu
|
||||
labels = ['Honey', 'Natural', 'Washed']
|
||||
# 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))
|
||||
|
||||
# 4. Prediksi
|
||||
preds = model.predict(img_array, verbose=0)
|
||||
class_idx = np.argmax(preds[0])
|
||||
confidence = float(np.max(preds[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)
|
||||
|
||||
print(f"📥 Prediksi: {labels[class_idx]} ({confidence*100:.2f}%)")
|
||||
# --- LOGIKA PENYARINGAN STATUS ---
|
||||
|
||||
# 1. Kasus: Gambar Tidak Jelas (Entropy Terlalu Tinggi)
|
||||
if entropy > 0.85:
|
||||
return jsonify({
|
||||
'label': labels[class_idx],
|
||||
'confidence': f"{confidence * 100:.2f}%",
|
||||
'status': 'success'
|
||||
})
|
||||
"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
|
||||
|
||||
# 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({
|
||||
"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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
<Bean
|
||||
class="w-5 h-5 text-white"
|
||||
:stroke-width="2.8"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -81,32 +81,46 @@
|
|||
<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="predictionResult.status === 'DITOLAK' ? 'flex flex-col md:flex-row items-center justify-center gap-8' : ''">
|
||||
|
||||
<!-- 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-emerald-400 font-bold text-xs uppercase tracking-widest">Confidence: {{ predictionResult.confidence }}</span>
|
||||
<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="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'"
|
||||
:class="statusInfo?.badgeClass"
|
||||
>
|
||||
<div :class="['w-1.5 h-1.5 rounded-full', parseFloat(predictionResult.confidence.replace('%', '')) >= 90 ? 'bg-emerald-400' : 'bg-red-400']"></div>
|
||||
<div :class="['w-1.5 h-1.5 rounded-full', statusInfo?.dotClass]"></div>
|
||||
<span class="font-bold text-xs uppercase tracking-widest">
|
||||
{{ parseFloat(predictionResult.confidence.replace('%', '')) >= 90 ? 'Berhasil' : 'Gagal' }}
|
||||
{{ 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" />
|
||||
</div>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -39,8 +40,8 @@ const chartConfig = computed(() => ({
|
|||
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 '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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue