| import base64 |
| import io |
| import os |
| from fastapi import FastAPI, UploadFile, File |
| from fastapi.middleware.cors import CORSMiddleware |
| from ultralytics import YOLO |
| import cv2 |
| import numpy as np |
| from PIL import Image |
| from dotenv import load_dotenv |
| from groq import Groq |
|
|
| load_dotenv() |
| |
|
|
| |
| |
| groq_client = Groq(api_key=os.getenv("GROQ_API_KEY", "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")) |
|
|
| app = FastAPI() |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| try: |
| model = YOLO('../model/waste_segmentor_yolo26/waste_yolo26seg_best.pt') |
| except Exception as e: |
| print(f"Error loading model: {e}") |
| model = None |
|
|
| |
| E_WASTE_CLASSES = { |
| "tv", "laptop", "mouse", "remote", "keyboard", "cell phone", |
| "microwave", "oven", "toaster", "refrigerator", "hair drier" |
| } |
|
|
| |
| coco_to_waste = { |
| "bottle": {"cat": "Anorganik", "color": "#eab308", "desc": "Botol plastik atau kaca yang bisa didaur ulang."}, |
| "wine glass": {"cat": "Anorganik", "color": "#eab308", "desc": "Pecahan kaca bisa didaur ulang."}, |
| "cup": {"cat": "Anorganik", "color": "#eab308", "desc": "Gelas plastik atau kertas."}, |
| "fork": {"cat": "Anorganik", "color": "#eab308", "desc": "Bisa didaur ulang jika logam/plastik."}, |
| "knife": {"cat": "Anorganik", "color": "#eab308", "desc": "Logam, kumpulkan di bank sampah."}, |
| "spoon": {"cat": "Anorganik", "color": "#eab308", "desc": "Bisa didaur ulang."}, |
| "bowl": {"cat": "Anorganik", "color": "#eab308", "desc": "Mangkuk bisa didaur ulang."}, |
| "banana": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa buah, bisa dijadikan kompos."}, |
| "apple": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa buah, bisa dijadikan kompos."}, |
| "sandwich": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa makanan, bisa dijadikan kompos."}, |
| "orange": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa buah, bisa dijadikan kompos."}, |
| "broccoli": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa sayur, bisa dijadikan kompos."}, |
| "carrot": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa sayur, bisa dijadikan kompos."}, |
| "hot dog": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa makanan basah."}, |
| "pizza": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa makanan basah."}, |
| "donut": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa makanan basah."}, |
| "cake": {"cat": "Organik", "color": "#16a34a", "desc": "Sisa makanan basah."}, |
| "tv": {"cat": "B3", "color": "#dc2626", "desc": "E-Waste, mengandung komponen berbahaya."}, |
| "laptop": {"cat": "B3", "color": "#dc2626", "desc": "E-Waste, mengandung logam berat dan baterai."}, |
| "mouse": {"cat": "B3", "color": "#dc2626", "desc": "E-Waste elektronik."}, |
| "remote": {"cat": "B3", "color": "#dc2626", "desc": "E-Waste elektronik."}, |
| "keyboard": {"cat": "B3", "color": "#dc2626", "desc": "E-Waste elektronik."}, |
| "cell phone": {"cat": "B3", "color": "#dc2626", "desc": "E-Waste, mengandung baterai li-ion."}, |
| "microwave": {"cat": "B3", "color": "#dc2626", "desc": "Limbah elektronik besar."}, |
| "oven": {"cat": "B3", "color": "#dc2626", "desc": "Limbah elektronik besar."}, |
| "toaster": {"cat": "B3", "color": "#dc2626", "desc": "Limbah elektronik."}, |
| "refrigerator": {"cat": "B3", "color": "#dc2626", "desc": "Limbah elektronik besar (freon berbahaya)."}, |
| "backpack": {"cat": "Residu", "color": "#475569", "desc": "Bahan kain campuran sulit didaur ulang."}, |
| "umbrella": {"cat": "Residu", "color": "#475569", "desc": "Bahan campuran sulit didaur."}, |
| "handbag": {"cat": "Residu", "color": "#475569", "desc": "Bahan campuran kulit/kain sulit didaur."}, |
| "tie": {"cat": "Residu", "color": "#475569", "desc": "Kain tekstil."}, |
| "suitcase": {"cat": "Residu", "color": "#475569", "desc": "Bahan keras campuran."}, |
| "book": {"cat": "Anorganik", "color": "#eab308", "desc": "Kertas bisa didaur ulang."}, |
| "vase": {"cat": "Residu", "color": "#475569", "desc": "Keramik sulit didaur ulang."}, |
| "scissors": {"cat": "Anorganik", "color": "#eab308", "desc": "Logam tajam."}, |
| "teddy bear": {"cat": "Residu", "color": "#475569", "desc": "Bahan kain dan busa."}, |
| "hair drier": {"cat": "B3", "color": "#dc2626", "desc": "E-waste elektronik."}, |
| "toothbrush": {"cat": "Residu", "color": "#475569", "desc": "Plastik keras bercampur sikat, sulit didaur."} |
| } |
|
|
| |
| class_categories = { |
| 0: {"cat": "B3", "color": "#ef4444", "desc": "Limbah Bahan Berbahaya dan Beracun."}, |
| 1: {"cat": "B3", "color": "#ef4444", "desc": "Limbah elektronik yang mengandung logam berat."}, |
| 2: {"cat": "B3", "color": "#ef4444", "desc": "Sisa cat yang berbahaya bagi lingkungan."}, |
| 3: {"cat": "B3", "color": "#ef4444", "desc": "Bahan kimia pembasmi hama."}, |
| 4: {"cat": "Residu", "color": "#6b7280", "desc": "Sulit didaur ulang dan tidak dapat dikomposkan."}, |
| 5: {"cat": "Residu", "color": "#6b7280", "desc": "Sulit didaur ulang dan tidak dapat dikomposkan."}, |
| 6: {"cat": "Residu", "color": "#6b7280", "desc": "Plastik lembaran atau pembungkus yang sulit didaur ulang."}, |
| 7: {"cat": "Residu", "color": "#6b7280", "desc": "Sulit didaur ulang dan tidak dapat dikomposkan."}, |
| 8: {"cat": "Residu", "color": "#6b7280", "desc": "Styrofoam tidak dapat terurai secara alami."}, |
| 9: {"cat": "Organik", "color": "#22c55e", "desc": "Bisa dijadikan kompos (green material)."}, |
| 10: {"cat": "Organik", "color": "#22c55e", "desc": "Bisa dijadikan kompos untuk kalsium tanah."}, |
| 11: {"cat": "Organik", "color": "#22c55e", "desc": "Bisa dijadikan kompos (green material)."}, |
| 12: {"cat": "Organik", "color": "#22c55e", "desc": "Bisa dijadikan kompos."}, |
| 13: {"cat": "Organik", "color": "#22c55e", "desc": "Sisa potongan tanaman untuk kompos (brown/green material)."}, |
| 14: {"cat": "Anorganik", "color": "#eab308", "desc": "Bisa didaur ulang, kumpulkan di bank sampah."}, |
| 15: {"cat": "Anorganik", "color": "#eab308", "desc": "Bisa didaur ulang, kumpulkan di bank sampah."}, |
| 16: {"cat": "Anorganik", "color": "#eab308", "desc": "Bisa didaur ulang, pastikan tetap kering."}, |
| 17: {"cat": "Anorganik", "color": "#eab308", "desc": "Bisa didaur ulang, kumpulkan di bank sampah."} |
| } |
|
|
| @app.post("/predict") |
| async def predict(file: UploadFile = File(...)): |
| if not model: |
| return {"error": "Model not loaded properly. Please check server logs."} |
| |
| try: |
| contents = await file.read() |
| nparr = np.frombuffer(contents, np.uint8) |
| img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) |
| |
| if img is None: |
| return {"error": "Invalid image format or image could not be read."} |
| |
| |
| |
| max_dim = 640 |
| h, w = img.shape[:2] |
| if max(h, w) > max_dim: |
| scale = max_dim / max(h, w) |
| img = cv2.resize(img, (int(w * scale), int(h * scale))) |
|
|
| |
| try: |
| |
| |
| results = model(img, imgsz=320) |
| except Exception as model_err: |
| print(f"YOLO Model Inference Error: {str(model_err)}") |
| return {"error": f"AI model inference failed: {str(model_err)}"} |
| |
| |
| predictions = [] |
| yolo_proposals = [] |
| annotated_img = img.copy() |
| detected_items = set() |
| |
| if results is not None: |
| for r in results: |
| |
| if hasattr(r, 'boxes') and r.boxes is not None: |
| for box in r.boxes: |
| try: |
| class_id = int(box.cls[0]) |
| conf = float(box.conf[0]) |
| |
| |
| class_name = "Unknown" |
| if hasattr(model, 'names') and class_id in model.names: |
| class_name = model.names[class_id] |
| |
| |
| if len(model.names) > 20: |
| if class_name not in coco_to_waste: |
| continue |
| cat_info = coco_to_waste[class_name] |
| else: |
| cat_info = class_categories.get(class_id, {"cat": "Unknown", "color": "#9ca3af", "desc": "Tidak diketahui."}) |
| |
| detected_items.add(class_name) |
| |
| |
| yolo_proposals.append({ |
| "box": tuple(map(int, box.xyxy[0])), |
| "class_id": class_id, |
| "class_name": class_name, |
| "confidence": conf, |
| "category": cat_info["cat"], |
| "raw_class_name": class_name, |
| "color": cat_info["color"], |
| "description": cat_info["desc"] |
| }) |
| except Exception as box_err: |
| print(f"Error parsing detection box: {str(box_err)}") |
| continue |
| |
| |
| bypass_llm = False |
| if len(yolo_proposals) > 0: |
| highest_conf = max([p["confidence"] for p in yolo_proposals]) |
| if highest_conf >= 0.50: |
| bypass_llm = True |
|
|
| |
| gemini_analysis = "Analisis AI belum siap." |
| benda_terdeteksi = "Objek Tak Dikenal" |
| fallback_cat = None |
| fallback_color = "#9ca3af" |
| status_yolo = "SALAH" |
|
|
| try: |
| if bypass_llm: |
| status_yolo = "BENAR" |
| gemini_analysis = "Status YOLO: BENAR\nBypass API: YOLO memiliki tingkat keyakinan sangat tinggi (>50%)." |
| else: |
| |
| _, img_encoded = cv2.imencode('.jpg', img) |
| base64_image = base64.b64encode(img_encoded).decode('utf-8') |
| |
| yolo_result_str = ", ".join(detected_items) if detected_items else "Tidak ada objek yang terdeteksi dengan jelas" |
| |
| prompt = ( |
| f"Sistem deteksi awal (YOLO) mendeteksi objek ini sebagai: '{yolo_result_str}'. " |
| "PERINGATAN KRITIS: Jangan langsung percaya pada hasil YOLO! YOLO sering salah menebak bentuk (contoh: puntung rokok dikira wortel/carrot, botol dikira vas). " |
| "Gunakan penglihatan visualmu sendiri. Perhatikan tekstur, abu, dan bentuk aslinya. " |
| "ATURAN WAJIB KATEGORI B3 (E-WASTE): Semua barang elektronik WAJIB masuk kategori B3, termasuk tapi tidak terbatas pada: " |
| "TV/monitor/layar, HP/smartphone/handphone, laptop, keyboard, mouse, remote, microwave, oven, toaster, kulkas/refrigerator, hair dryer, " |
| "dan SEMUA perangkat elektronik lainnya. Barang elektronik TIDAK BOLEH masuk ANORGANIK. " |
| "Berikan analisis akhir dengan format teks persis seperti di bawah ini:\n" |
| "Status YOLO: [Isi dengan BENAR jika tebakan YOLO 100% akurat, atau SALAH jika YOLO bias/salah tebak]\n" |
| "Nama Benda: [Isi dengan nama asli benda/sampah di gambar, misal: Puntung Rokok, Botol Plastik, dll, max 5 kata]\n" |
| "Tips: [Isi dengan 1 tips cerdas cara membuang/mendaur ulangnya]\n" |
| "Kategori: [Pilih SATU saja: ORGANIK, ANORGANIK, B3, RESIDU. Catatan: Puntung rokok/popok/tisu kotor wajib masuk RESIDU. Semua elektronik WAJIB B3]" |
| ) |
| |
| |
| response = groq_client.chat.completions.create( |
| model="meta-llama/llama-4-scout-17b-16e-instruct", |
| messages=[ |
| { |
| "role": "user", |
| "content": [ |
| {"type": "text", "text": prompt}, |
| { |
| "type": "image_url", |
| "image_url": { |
| "url": f"data:image/jpeg;base64,{base64_image}", |
| } |
| } |
| ] |
| } |
| ], |
| temperature=0.4, |
| max_tokens=1024, |
| ) |
| |
| gemini_analysis = response.choices[0].message.content.strip() |
| |
| |
| tips_cerdas = "Daur ulang atau buang ke tempat sampah yang sesuai." |
| for line in gemini_analysis.split('\n'): |
| line = line.strip() |
| line_lower = line.lower() |
| |
| if line_lower.startswith("status yolo:") or line_lower.startswith("**status yolo"): |
| val = line.split(":", 1)[1].strip().upper().replace("*", "") |
| if "BENAR" in val: |
| status_yolo = "BENAR" |
| elif line_lower.startswith("nama benda:") or line_lower.startswith("**nama benda"): |
| parts = line.split(":", 1) |
| if len(parts) > 1: |
| benda_terdeteksi = parts[1].strip().replace("*", "") |
| elif line_lower.startswith("tips:") or line_lower.startswith("**tips"): |
| parts = line.split(":", 1) |
| if len(parts) > 1: |
| tips_cerdas = parts[1].strip().replace("*", "") |
| elif line_lower.startswith("kategori:") or line_lower.startswith("**kategori"): |
| parts = line.split(":", 1) |
| if len(parts) > 1: |
| cat_str = parts[1].strip().upper().replace("*", "") |
| if "B3" in cat_str: |
| fallback_cat = "B3" |
| fallback_color = "#dc2626" |
| elif "ANORGANIK" in cat_str: |
| fallback_cat = "Anorganik" |
| fallback_color = "#eab308" |
| elif "ORGANIK" in cat_str: |
| fallback_cat = "Organik" |
| fallback_color = "#16a34a" |
| elif "RESIDU" in cat_str: |
| fallback_cat = "Residu" |
| fallback_color = "#475569" |
| |
| |
| if fallback_cat is None: |
| text_upper = gemini_analysis.upper() |
| if "B3" in text_upper: |
| fallback_cat = "B3" |
| fallback_color = "#dc2626" |
| elif "ANORGANIK" in text_upper: |
| fallback_cat = "Anorganik" |
| fallback_color = "#eab308" |
| elif "ORGANIK" in text_upper: |
| fallback_cat = "Organik" |
| fallback_color = "#16a34a" |
| elif "RESIDU" in text_upper: |
| fallback_cat = "Residu" |
| fallback_color = "#475569" |
|
|
| |
| |
| yolo_detected_ewaste = detected_items & E_WASTE_CLASSES |
| if yolo_detected_ewaste and fallback_cat != "B3": |
| print(f"[E-WASTE OVERRIDE] YOLO detected e-waste items: {yolo_detected_ewaste}, but LLM said '{fallback_cat}'. Forcing B3.") |
| fallback_cat = "B3" |
| fallback_color = "#dc2626" |
| |
| |
| if status_yolo == "BENAR" and len(yolo_proposals) > 0: |
| |
| for prop in yolo_proposals: |
| hex_color = prop["color"].lstrip('#') |
| bgr_color = tuple(int(hex_color[i:i+2], 16) for i in (4, 2, 0)) |
| x1, y1, x2, y2 = prop["box"] |
| |
| |
| cv2.rectangle(annotated_img, (x1, y1), (x2, y2), bgr_color, 3) |
| |
| |
| label = f"{prop['raw_class_name'].title()} ({prop['category']}) {prop['confidence']:.2f}" |
| (w, h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2) |
| cv2.rectangle(annotated_img, (x1, y1 - 25), (x1 + w, y1), bgr_color, -1) |
| cv2.putText(annotated_img, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) |
|
|
| predictions.append({ |
| "class_id": prop["class_id"], |
| "class_name": prop["class_name"], |
| "confidence": prop["confidence"], |
| "category": prop["category"], |
| "color": prop["color"], |
| "description": prop["description"] |
| }) |
| else: |
| |
| |
| if not fallback_cat: |
| fallback_cat = "Anorganik" |
| fallback_color = "#eab308" |
|
|
| predictions.append({ |
| "class_id": 99, |
| "class_name": benda_terdeteksi.title(), |
| "confidence": 0.99, |
| "category": fallback_cat, |
| "color": fallback_color, |
| "description": tips_cerdas |
| }) |
| except Exception as gemini_err: |
| print(f"Groq API Error: {str(gemini_err)}") |
| gemini_analysis = "Gagal memanggil API Groq. Pastikan GROQ_API_KEY sudah dimasukkan di file .env" |
| |
| for prop in yolo_proposals: |
| predictions.append({ |
| "class_id": prop["class_id"], |
| "class_name": prop["class_name"], |
| "confidence": prop["confidence"], |
| "category": prop["category"], |
| "color": prop["color"], |
| "description": prop["description"] |
| }) |
| |
| |
| if len(predictions) == 0: |
| is_rate_limit = "429" in str(gemini_err) or "quota" in str(gemini_err).lower() |
| error_desc = "API Groq melampaui limit gratis (429). Mohon ganti API Key di .env" if is_rate_limit else f"Sistem terganggu: {str(gemini_err)[:50]}" |
| predictions.append({ |
| "class_id": 99, |
| "class_name": "Deteksi Gagal (Limit API)", |
| "confidence": 0.50, |
| "category": "Residu", |
| "color": "#475569", |
| "description": error_desc |
| }) |
| |
| |
| _, buffer = cv2.imencode('.jpg', annotated_img) |
| img_base64 = base64.b64encode(buffer).decode('utf-8') |
|
|
| return { |
| "predictions": predictions, |
| "image_base64": img_base64, |
| "gemini_analysis": gemini_analysis |
| } |
| except Exception as e: |
| print(f"Error during prediction: {str(e)}") |
| return {"error": f"Internal server error processing image: {str(e)}"} |
|
|
| @app.get("/") |
| def read_root(): |
| return {"message": "KomposIn ML API is running"} |
|
|