diff --git a/app/Http/Controllers/Admin/DashboardController.php b/app/Http/Controllers/Admin/DashboardController.php index 1bd9ece..d29ccb6 100644 --- a/app/Http/Controllers/Admin/DashboardController.php +++ b/app/Http/Controllers/Admin/DashboardController.php @@ -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, ]); } } \ No newline at end of file diff --git a/app/Http/Controllers/Public/ClassificationController.php b/app/Http/Controllers/Public/ClassificationController.php index c956c67..a473a38 100644 --- a/app/Http/Controllers/Public/ClassificationController.php +++ b/app/Http/Controllers/Public/ClassificationController.php @@ -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) diff --git a/backend-ai/Arsitektur_MobileNetV2.keras b/backend-ai/Arsitektur_MobileNetV2.keras new file mode 100644 index 0000000..074de46 Binary files /dev/null and b/backend-ai/Arsitektur_MobileNetV2.keras differ diff --git a/backend-ai/app.py b/backend-ai/app.py index 173048c..166ae29 100644 --- a/backend-ai/app.py +++ b/backend-ai/app.py @@ -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) \ No newline at end of file + # Jalankan pada port 5001 (sesuaikan dengan settingan Laravel/Vue kamu) + app.run(host='0.0.0.0', port=5001, debug=False) \ No newline at end of file diff --git a/backend-ai/app.py_old b/backend-ai/app.py_old new file mode 100644 index 0000000..173048c --- /dev/null +++ b/backend-ai/app.py_old @@ -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) \ No newline at end of file diff --git a/backend-ai/app.py_old2 b/backend-ai/app.py_old2 new file mode 100644 index 0000000..6c4aac3 --- /dev/null +++ b/backend-ai/app.py_old2 @@ -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) \ No newline at end of file diff --git a/backend-ai/app.py_old3 b/backend-ai/app.py_old3 new file mode 100644 index 0000000..6f0fcbb --- /dev/null +++ b/backend-ai/app.py_old3 @@ -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) \ No newline at end of file diff --git a/backend-ai/best_model.keras b/backend-ai/best_model.keras new file mode 100644 index 0000000..36cc02e Binary files /dev/null and b/backend-ai/best_model.keras differ diff --git a/backend-ai/train_part4.keras b/backend-ai/train_part4.keras new file mode 100644 index 0000000..9c23c8a Binary files /dev/null and b/backend-ai/train_part4.keras differ diff --git a/public/pict_example.jpg b/public/pict_example.jpg new file mode 100644 index 0000000..c8c2e31 Binary files /dev/null and b/public/pict_example.jpg differ diff --git a/resources/js/components/AppLogo.vue b/resources/js/components/AppLogo.vue index bc79527..e7b9ae5 100644 --- a/resources/js/components/AppLogo.vue +++ b/resources/js/components/AppLogo.vue @@ -4,13 +4,13 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue'; diff --git a/resources/js/components/AppLogoIcon.vue b/resources/js/components/AppLogoIcon.vue index 9a26231..7bece55 100644 --- a/resources/js/components/AppLogoIcon.vue +++ b/resources/js/components/AppLogoIcon.vue @@ -1,5 +1,7 @@ + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index f181b0e..c15968e 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -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, - }, -]; +