# ════════════════════════════════════════════════════════════════ # SKYLINE LAND — main.py (CNN + VLM — نسخة موحدة آمنة) # ════════════════════════════════════════════════════════════════ import os, json, io, uuid, cv2, base64 import numpy as np from datetime import datetime from dotenv import load_dotenv from typing import Optional import tensorflow as tf import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt from PIL import Image from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from supabase import create_client, Client load_dotenv() # ════════════════════════════════════════════════════════════════ # DOWNLOAD MODEL FROM HUGGING FACE # ════════════════════════════════════════════════════════════════ from huggingface_hub import hf_hub_download, snapshot_download HF_REPO = "nadjla12/skyline-api" def download_models_if_needed(): if not os.path.exists("./models/skyline_savedmodel/saved_model.pb"): print("⏳ Downloading model from Hugging Face...") os.makedirs("./models/skyline_savedmodel/variables", exist_ok=True) # تحميل الملفات واحداً واحداً في المسار الصحيح files_to_download = [ ("models/skyline_savedmodel/saved_model.pb", "./models/skyline_savedmodel/saved_model.pb"), ("models/skyline_savedmodel/variables/variables.index", "./models/skyline_savedmodel/variables/variables.index"), ("models/skyline_savedmodel/variables/variables.data-00000-of-00001", "./models/skyline_savedmodel/variables/variables.data-00000-of-00001"), ] for repo_path, local_path in files_to_download: try: downloaded = hf_hub_download( repo_id=HF_REPO, filename=repo_path, local_dir="./", ) print(f"✅ Downloaded: {repo_path}") except Exception as e: print(f"⚠️ Failed to download {repo_path}: {e}") # تحميل expert_db.json try: hf_hub_download( repo_id=HF_REPO, filename="models/expert_db.json", local_dir="./", ) print("✅ Downloaded: expert_db.json") except Exception as e: print(f"⚠️ Failed to download expert_db.json: {e}") print("✅ Model download complete!") else: print("✅ Model already exists locally.") download_models_if_needed() # ════════════════════════════════════════════════════════════════ # SUPABASE # ════════════════════════════════════════════════════════════════ SUPABASE_URL = os.getenv("SUPABASE_URL") SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY") TABLE_NAME = os.getenv("TABLE_NAME", "diagnostics") GRADCAM_BUCKET = os.getenv("GRADCAM_BUCKET", "gradcam-results") MODEL_PATH = os.getenv("MODEL_PATH", "./models/skyline_savedmodel") EXPERT_DB_PATH = os.getenv("EXPERT_DB_PATH", "./models/expert_db.json") supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY) print("✅ Supabase client initialized.") # ════════════════════════════════════════════════════════════════ # GEMINI VLM # ════════════════════════════════════════════════════════════════ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "") GEMINI_READY = False gemini_model = None if GEMINI_API_KEY: try: import google.generativeai as genai genai.configure(api_key=GEMINI_API_KEY) gemini_model = genai.GenerativeModel("gemini-pro") GEMINI_READY = True print("✅ Gemini VLM initialized.") except Exception as e: print(f"⚠️ Gemini init failed (VLM disabled): {e}") else: print("⚠️ GEMINI_API_KEY not set — VLM endpoints will return fallback from CNN.") # ════════════════════════════════════════════════════════════════ # CNN MODEL # ════════════════════════════════════════════════════════════════ print("⏳ Loading CNN model...") _loaded = tf.saved_model.load(MODEL_PATH) _infer = _loaded.signatures['serving_default'] INPUT_KEY = 'input_layer_7' OUTPUT_KEY = None def _predict_raw(img_input: tf.Tensor) -> tf.Tensor: global OUTPUT_KEY result = _infer(**{INPUT_KEY: img_input}) if OUTPUT_KEY is None: OUTPUT_KEY = list(result.keys())[0] return result[OUTPUT_KEY] _test = tf.zeros((1, 224, 224, 3), dtype=tf.float32) _out = _predict_raw(_test) print(f"✅ CNN loaded — output shape: {_out.shape}") # ════════════════════════════════════════════════════════════════ # EXPERT DB # ════════════════════════════════════════════════════════════════ with open(EXPERT_DB_PATH, 'r', encoding='utf-8') as f: expert_db = json.load(f) CLASS_NAMES = list(expert_db.keys()) HEALTHY_CLASSES = {k for k in CLASS_NAMES if 'healthy' in k.lower()} print(f"✅ Expert DB: {len(CLASS_NAMES)} classes | {len(HEALTHY_CLASSES)} healthy") # ════════════════════════════════════════════════════════════════ # GRAD-CAM # ════════════════════════════════════════════════════════════════ def get_gradcam_and_visualizations(img_array: np.ndarray): img_float = img_array.astype(np.float32) inp = tf.keras.applications.efficientnet.preprocess_input(img_float[np.newaxis]) inp_var = tf.Variable(inp, trainable=True, dtype=tf.float32) with tf.GradientTape() as tape: tape.watch(inp_var) preds = _predict_raw(inp_var) pred_idx = int(tf.argmax(preds[0])) score = preds[:, pred_idx] grads = tape.gradient(score, inp_var) heatmap = tf.reduce_mean(tf.abs(grads[0]), axis=-1).numpy() if heatmap.max() > 0: heatmap = heatmap / heatmap.max() confidence = float(preds[0][pred_idx]) orig_buf = io.BytesIO() Image.fromarray(img_array.astype(np.uint8)).save(orig_buf, format='PNG') original_b64 = base64.b64encode(orig_buf.getvalue()).decode() hm_resized = cv2.resize(heatmap, (224, 224)) hm_colored = (plt.cm.jet(hm_resized)[:, :, :3] * 255).astype(np.uint8) hm_buf = io.BytesIO() Image.fromarray(hm_colored).save(hm_buf, format='PNG') heatmap_b64 = base64.b64encode(hm_buf.getvalue()).decode() overlay = np.clip(hm_colored * 0.55 + img_array * 0.45, 0, 255).astype(np.uint8) ov_buf = io.BytesIO() Image.fromarray(overlay).save(ov_buf, format='PNG') overlay_b64 = base64.b64encode(ov_buf.getvalue()).decode() return heatmap, pred_idx, confidence, original_b64, heatmap_b64, overlay_b64 def build_gradcam_png_bytes(heatmap_np, img_array, is_healthy) -> bytes: fig, axes = plt.subplots(1, 3, figsize=(15, 5), facecolor='white') for ax in axes: ax.axis('off') axes[0].imshow(img_array.astype(np.uint8)); axes[0].set_title('Original') if is_healthy: axes[1].imshow(img_array.astype(np.uint8)); axes[1].set_title('Healthy') axes[2].imshow(img_array.astype(np.uint8)); axes[2].set_title('Healthy') else: hm = cv2.resize(heatmap_np, (224, 224)) sup = np.clip(plt.cm.jet(hm)[:, :, :3] * 0.55 + img_array / 255.0 * 0.45, 0, 1) axes[1].imshow(hm, cmap='jet'); axes[1].set_title('Grad-CAM') axes[2].imshow(sup); axes[2].set_title('Overlay') buf = io.BytesIO() plt.tight_layout(); fig.savefig(buf, format='png', dpi=150, facecolor='white') plt.close(fig); buf.seek(0) return buf.read() # ════════════════════════════════════════════════════════════════ # STORAGE # ════════════════════════════════════════════════════════════════ def upload_gradcam_to_storage(png_bytes: bytes, filename: str) -> Optional[str]: try: path = f"overlays/{filename}" supabase.storage.from_(GRADCAM_BUCKET).upload( path=path, file=png_bytes, file_options={"content-type": "image/png", "upsert": "true"} ) url = supabase.storage.from_(GRADCAM_BUCKET).get_public_url(path) print(f"✅ Storage: {path}") return url except Exception as e: print(f"⚠️ Storage upload failed: {e}") return None # ════════════════════════════════════════════════════════════════ # HELPER — حفظ في Supabase # ════════════════════════════════════════════════════════════════ def save_diagnosis(record: dict) -> bool: try: result = supabase.table(TABLE_NAME).insert(record).execute() if result.data: print(f"✅ DB saved: {record.get('class_key')} | user={record.get('profile_id')} | conf={record.get('confidence')}%") return True print(f"⚠️ Insert returned no data") return False except Exception as e: print(f"⚠️ DB insert failed: {e}") return False # ════════════════════════════════════════════════════════════════ # FASTAPI APP # ════════════════════════════════════════════════════════════════ app = FastAPI(title="Skyline LAND API", version="3.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.get("/") def root(): return { "status": "Skyline LAND API ✅", "cnn_classes": len(CLASS_NAMES), "vlm_ready": GEMINI_READY, "version": "3.0.0", } @app.get("/health") def health(): db_ok = False try: supabase.table(TABLE_NAME).select("id").limit(1).execute() db_ok = True except: pass return { "api": "ok", "db": "ok" if db_ok else "error", "cnn": f"{len(CLASS_NAMES)} classes", "vlm": "ready" if GEMINI_READY else "disabled (no API key)", "bucket": GRADCAM_BUCKET, } @app.post("/predict") async def predict( file: UploadFile = File(...), user_id: Optional[str] = Form(None), ): if not file.content_type.startswith("image/"): raise HTTPException(400, "File must be an image") raw = await file.read() try: pil_img = Image.open(io.BytesIO(raw)).convert("RGB").resize((224, 224)) img_array = np.array(pil_img, dtype=np.uint8) except Exception as e: raise HTTPException(400, f"Cannot open image: {e}") heatmap_np, class_idx, conf_raw, original_b64, heatmap_b64, overlay_b64 = \ get_gradcam_and_visualizations(img_array) conf = round(conf_raw * 100, 2) class_key = CLASS_NAMES[class_idx] if class_idx < len(CLASS_NAMES) else f"class_{class_idx}" info = expert_db.get(class_key, {}) is_healthy = class_key in HEALTHY_CLASSES inp_np = tf.keras.applications.efficientnet.preprocess_input(img_array.astype(np.float32)[np.newaxis]) all_preds = _predict_raw(inp_np)[0].numpy() top3_idx = np.argsort(all_preds)[-3:][::-1] top3 = [ { "class": CLASS_NAMES[i], "name_en": expert_db.get(CLASS_NAMES[i], {}).get("en", CLASS_NAMES[i]), "conf": round(float(all_preds[i]) * 100, 2), } for i in top3_idx ] gradcam_url = None if not is_healthy: gradcam_png = build_gradcam_png_bytes(heatmap_np, img_array, is_healthy) gradcam_url = upload_gradcam_to_storage(gradcam_png, f"{uuid.uuid4()}.png") record = { "profile_id": user_id, "class_key": class_key, "disease_name_en": info.get("en", class_key), "disease_name_ar": info.get("ar", ""), "advice_en": info.get("adv_en", ""), "advice_ar": info.get("adv_ar", ""), "treatment_en": info.get("chem_en", ""), "treatment_ar": info.get("chem_ar", ""), "infection_type": info.get("infection_type", None), "is_healthy": is_healthy, "confidence": conf, "gradcam_url": gradcam_url, "analyzed_at": datetime.utcnow().isoformat(), } saved_to_db = save_diagnosis(record) return JSONResponse({ "success": True, "saved_to_db": saved_to_db, "class_key": class_key, "disease_name_en": record["disease_name_en"], "disease_name_ar": record["disease_name_ar"], "confidence": conf, "badge": "High" if conf >= 90 else "Good" if conf >= 75 else "Medium" if conf >= 60 else "Low", "is_healthy": is_healthy, "advice_en": record["advice_en"], "advice_ar": record["advice_ar"], "treatment_en": record["treatment_en"], "treatment_ar": record["treatment_ar"], "infection_type": record["infection_type"], "gradcam_url": gradcam_url, "original_b64": original_b64, "heatmap_b64": heatmap_b64, "overlay_b64": overlay_b64, "top3": top3, }) @app.post("/vlm-analyze") async def vlm_analyze( file: Optional[UploadFile] = File(None), lang: str = Form("en"), cnn_context: Optional[str] = Form(None), user_id: Optional[str] = Form(None), ): if not GEMINI_READY or gemini_model is None: fallback = _build_fallback_analysis(cnn_context, lang) return JSONResponse({"success": True, "analysis": fallback, "source": "cnn_fallback"}) if lang == "ar": prompt = """أنت خبير زراعي متخصص في أمراض النبات والمحاصيل. حلّل الصورة وقدّم تقريراً شاملاً ومنظماً يشمل: **1. التشخيص:** ما المرض أو الحالة الصحية التي تراها؟ **2. الأعراض المرئية:** صف ما يظهر في الصورة بدقة **3. السبب:** الممرض أو العامل البيئي المسؤول **4. درجة الخطورة:** منخفضة / متوسطة / مرتفعة — ولماذا؟ **5. خطر الانتشار:** هل يمكن أن يصيب نباتات مجاورة؟ **6. العلاج الفوري:** خطوات عملية يبدأ بها المزارع الآن **7. الوقاية:** كيف يتجنب تكرار المشكلة في المواسم القادمة أجب باللغة العربية الفصحى البسيطة المناسبة للمزارع الجزائري.""" else: prompt = """You are an expert agricultural plant pathologist. Analyze this plant/field image and provide a structured comprehensive report: **1. Diagnosis:** What disease or health condition is present? **2. Visible Symptoms:** Describe exactly what appears in the image **3. Cause:** Pathogen or environmental factor responsible **4. Severity Level:** Low / Medium / High — and why? **5. Spread Risk:** Can it infect neighboring plants? **6. Immediate Treatment:** Practical steps the farmer can take now **7. Prevention:** How to avoid recurrence in future seasons Be thorough, practical, and clear for a farming context.""" if cnn_context: try: ctx = json.loads(cnn_context) cnn_info = ( f"\n\n[CNN Pre-detection: '{ctx.get('disease_name_en')}' " f"({ctx.get('confidence', 0):.1f}% confidence) — " f"{'Healthy' if ctx.get('is_healthy') else 'Diseased'}]" ) prompt += cnn_info except: pass try: parts = [prompt] if file: raw = await file.read() parts.append({"mime_type": file.content_type or "image/jpeg", "data": raw}) response = gemini_model.generate_content(parts) return JSONResponse({"success": True, "analysis": response.text, "source": "gemini_vlm"}) except Exception as e: print(f"⚠️ Gemini VLM error: {e}") fallback = _build_fallback_analysis(cnn_context, lang) return JSONResponse({"success": True, "analysis": fallback, "source": "cnn_fallback"}) @app.post("/vlm-chat") async def vlm_chat( message: str = Form(...), lang: str = Form("en"), history: Optional[str] = Form(None), file: Optional[UploadFile] = File(None), cnn_context: Optional[str] = Form(None), ): if not GEMINI_READY or gemini_model is None: reply = _build_fallback_chat(message, cnn_context, lang) return JSONResponse({"success": True, "reply": reply, "source": "cnn_fallback"}) sys_prompt = "أنت خبير زراعي متخصص. أجب باللغة العربية فقط وبأسلوب بسيط مناسب للمزارع." if lang == "ar" else \ "You are an expert agricultural advisor. Reply only in English, clearly and practically for a farmer." if cnn_context: try: ctx = json.loads(cnn_context) sys_prompt += f" CNN detected: '{ctx.get('disease_name_en')}' ({ctx.get('confidence', 0):.1f}% confidence)." except: pass gemini_history = [ {"role": "user", "parts": [sys_prompt]}, {"role": "model", "parts": ["Understood. I'm your agricultural expert assistant."]}, ] if history: try: hist = json.loads(history) for msg in hist[-6:]: role = "user" if msg["role"] == "user" else "model" gemini_history.append({"role": role, "parts": [msg["content"]]}) except: pass try: chat = gemini_model.start_chat(history=gemini_history) parts = [message] if file: raw = await file.read() parts.append({"mime_type": file.content_type or "image/jpeg", "data": raw}) response = chat.send_message(parts) return JSONResponse({"success": True, "reply": response.text, "source": "gemini_vlm"}) except Exception as e: print(f"⚠️ Gemini chat error: {e}") reply = _build_fallback_chat(message, cnn_context, lang) return JSONResponse({"success": True, "reply": reply, "source": "cnn_fallback"}) @app.post("/chat") async def chat_simple(request: Request): try: body = await request.json() message = body.get("message", "") lang = body.get("lang", "en") history = body.get("history", []) initial_report = body.get("initial_report", "") if not GEMINI_READY or gemini_model is None: fb = _build_simple_chat_fallback(message, initial_report, lang) return JSONResponse({"success": True, "response": fb, "reply": fb, "source": "fallback"}) context = f"Previous diagnosis report: {initial_report[:500]}\n\n" if initial_report else "" system_prompt = f"أنت خبير زراعي متخصص في أمراض النبات.\n{context}أجب باللغة العربية فقط، بأسلوب بسيط وعملي." if lang == "ar" else \ f"You are an expert agricultural plant pathologist.\n{context}Answer in English only, clearly and practically." gemini_history = [ {"role": "user", "parts": [system_prompt]}, {"role": "model", "parts": ["Understood. I'm your agricultural expert assistant. How can I help you today?"]}, ] for msg in history[-10:]: role = "user" if msg.get("role") == "user" else "model" gemini_history.append({"role": role, "parts": [msg.get("content", "")]}) try: chat = gemini_model.start_chat(history=gemini_history) response = chat.send_message(message) return JSONResponse({"success": True, "response": response.text, "reply": response.text, "source": "gemini_vlm"}) except Exception as e: print(f"⚠️ Gemini chat error: {e}") fb = _build_simple_chat_fallback(message, initial_report, lang) return JSONResponse({"success": True, "response": fb, "reply": fb, "source": "error_fallback"}) except Exception as e: print(f"⚠️ Chat endpoint error: {e}") return JSONResponse({"success": False, "response": "Chat service temporarily unavailable.", "reply": "Chat service temporarily unavailable.", "source": "error"}) @app.get("/history") def history(profile_id: Optional[str] = None, limit: int = 20): try: query = supabase.table(TABLE_NAME).select( "id, profile_id, class_key, disease_name_en, disease_name_ar, confidence, is_healthy, gradcam_url, analyzed_at" ) if profile_id: query = query.eq("profile_id", profile_id) response = query.order("analyzed_at", desc=True).limit(limit).execute() return {"success": True, "count": len(response.data), "data": response.data} except Exception as e: raise HTTPException(500, f"DB error: {e}") def _build_fallback_analysis(cnn_context: Optional[str], lang: str) -> str: if not cnn_context: return "لا يمكن الاتصال بـ Gemini VLM حالياً." if lang == "ar" else "Gemini VLM is not available." try: ctx = json.loads(cnn_context) if lang == "ar": return (f"**التشخيص:** {ctx.get('disease_name_ar') or ctx.get('disease_name_en', '—')}\n\n" f"**نسبة الثقة:** {ctx.get('confidence', 0):.1f}%\n\n" f"**الحالة:** {'نبات سليم ✅' if ctx.get('is_healthy') else 'مصاب ⚠️'}\n\n" f"**النصيحة:** {ctx.get('advice_ar') or ctx.get('advice_en', 'راجع بروتوكول العلاج.')}\n\n" f"**العلاج:** {ctx.get('treatment_ar') or ctx.get('treatment_en', 'استشر خبيراً زراعياً.')}") else: return (f"**Diagnosis:** {ctx.get('disease_name_en', '—')}\n\n" f"**Confidence:** {ctx.get('confidence', 0):.1f}%\n\n" f"**Status:** {'Healthy ✅' if ctx.get('is_healthy') else 'Diseased ⚠️'}\n\n" f"**Advice:** {ctx.get('advice_en', 'Follow the treatment protocol.')}\n\n" f"**Treatment:** {ctx.get('treatment_en', 'Consult an agricultural expert.')}") except: return "Analysis based on CNN result." def _build_fallback_chat(message: str, cnn_context: Optional[str], lang: str) -> str: ctx = {} if cnn_context: try: ctx = json.loads(cnn_context) except: pass msg_lower = message.lower() ar = lang == "ar" if any(w in msg_lower for w in ["cause","سبب","ممرض","pathogen"]): return (f"سبب المرض: {ctx.get('disease_name_ar') or ctx.get('disease_name_en','غير محدد')}. نوع العدوى: {ctx.get('infection_type','غير محدد')}." if ar else f"Disease cause: {ctx.get('disease_name_en','Unknown')}. Infection type: {ctx.get('infection_type','Unknown')}.") if any(w in msg_lower for w in ["treat","علاج","cure","دواء","مبيد"]): return ctx.get('treatment_ar') or ctx.get('treatment_en') or ("راجع بروتوكول العلاج." if ar else "Refer to the CNN treatment protocol.") if any(w in msg_lower for w in ["prevent","وقاية","spread","انتشار"]): return "لمنع الانتشار: أزل النباتات المصابة، طبّق مبيدات وقائية." if ar else "To prevent spread: remove infected plants, apply preventive fungicides." return ctx.get('advice_ar') or ctx.get('advice_en') or ("اتبع التوصيات الموضحة في نتيجة التشخيص." if ar else "Follow the recommendations shown in the diagnosis result.") def _build_simple_chat_fallback(message: str, initial_report: str, lang: str) -> str: msg_lower = message.lower() if lang == "ar": if any(w in msg_lower for w in ["سبب", "لماذا"]): return "سبب المرض عادة ما يكون فطرياً أو بكتيرياً أو فيروسياً." if any(w in msg_lower for w in ["علاج", "مبيد"]): return f"العلاج الموصى به: {initial_report[:200]}" if initial_report else "العلاج يعتمد على نوع المرض." if any(w in msg_lower for w in ["منع", "وقاية"]): return "للوقاية: أزل النباتات المصابة، استخدم مبيدات وقائية." return "شكراً لسؤالك. للحصول على إجابة مفصلة، يرجى تشغيل خدمة Gemini VLM." else: if any(w in msg_lower for w in ["cause", "why"]): return "The disease is typically caused by fungal, bacterial, or viral pathogens." if any(w in msg_lower for w in ["treat", "cure", "pesticide"]): return f"Recommended treatment: {initial_report[:200]}" if initial_report else "Treatment depends on the disease type." if any(w in msg_lower for w in ["prevent", "spread"]): return "To prevent spread: remove infected plants, apply preventive fungicides." return "Thank you for your question. Please enable Gemini VLM service for detailed answers." if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)