# app.py import os, csv, random from datetime import datetime import numpy as np from PIL import Image import gradio as gr # <<< สำคัญ: ต้องมี เพื่อไม่ให้ NameError: gr # ---- (optional) PyTorch / Torchvision ---- TORCH_AVAILABLE = True try: import torch, torch.nn as nn from torchvision import transforms, models except Exception: TORCH_AVAILABLE = False # ---------- Paths ---------- IMG_DIR = "durian_images" HIS_CSV = "history/history.csv" CKPT_PATH = "durian_mnv2_ckpt.pth" # ไฟล์น้ำหนัก os.makedirs(IMG_DIR, exist_ok=True) os.makedirs(os.path.dirname(HIS_CSV), exist_ok=True) # ---------- Labels ---------- RIPENESS_LABELS = ["อ่อน", "แก่รอสักระยะ", "แก่พร้อมรับประทาน", "สุกงอม"] # ---------- Caption Variants ---------- RIPENESS_CAPTION_VARIANTS = { "อ่อน": [ ["หนามและพื้นผิวเปลือกคมชัด","ขั้วผลอยู่กึ่งกลางเฟรม","ควรรอต่อให้ครบอายุประมาณ 120 วัน","เนื้อยังไม่แน่น กลิ่นยังไม่ชัด","ยังไม่เหมาะสำหรับบริโภค"], ["ผิวเปลือกเขียวเด่นและแข็ง","มุมภาพเห็นโครงสร้างหนามชัดเจน","แนะนำให้พักผลต่ออีกระยะจนถึง 120 วัน","ตอนนี้เนื้อยังไม่พัฒนาเต็มที่","ไม่แนะนำให้รับประทานทันที"], ["โครงหนามละเอียด สีผิวยังเขียว","ก้านและขั้วอยู่ตำแหน่งกึ่งกลางภาพ","ควรเก็บพักเพื่อให้ความสุกก้าวหน้า","กลิ่นและรสชาติยังไม่เด่น","ไม่เหมาะต่อการบริโภคตอนนี้"], ["พื้นผิวเปลือกแน่นและหนามคม","องค์ประกอบภาพเน้นขั้วผลตรงกลาง","รอให้ครบอายุเก็บเกี่ยวประมาณ 120 วัน","ความแน่นของเนื้อยังไม่ถึงระดับรับประทาน","ขอแนะนำให้รอต่อ"], ], "แก่รอสักระยะ": [ ["สีผิวเริ่มเขียวปนเหลือง","สภาพผลคงความแน่น","ควรพักผลต่อประมาณ 3 ถึง 5 วัน","เพื่อให้กลิ่นและรสหวานพัฒนา","ยังไม่เหมาะสำหรับรับประทานทันที"], ["โทนสีเปลือกเริ่มเปลี่ยนแต่ยังไม่เต็มที่","หนามทู่ขึ้นเล็กน้อย","พักผลต่ออีกไม่กี่วัน","เพื่อให้เนื้อแน่นกำลังดีและกลิ่นชัดขึ้น"], ["ลักษณะโดยรวมใกล้สุกแต่ยังไม่ถึงจุดเหมาะ","แนะนำให้วางพักในอุณหภูมิห้อง","คาดว่าอีก 3 ถึง 7 วันจะพร้อมบริโภค"], ["สีผิวมีการไล่เฉดชัดกว่าเดิม","สัญญาณความแก่ปรากฏแต่ยังไม่สุด","ควรเว้นระยะก่อนบริโภคเพื่อรสชาติที่ดีกว่า"], ], "แก่พร้อมรับประทาน": [ ["สีผิวเขียวปนเหลืองชัดเจน","หนามไม่แหลมจัด","เนื้อแน่นละมุน กลิ่นหอมชัด","เหมาะสำหรับบริโภคทันที"], ["โทนสีและพื้นผิวบ่งชี้ความสุกกำลังดี","ก้านและขั้วดูแห้งพอเหมาะ","เปิดผลแล้วควรรับประทานภายใน 1 ถึง 2 วัน"], ["สมดุลของสีและรูปทรงเข้าระยะพอดี","ความหวานและกลิ่นพร้อม","เหมาะทั้งบริโภคสดและจำหน่าย"], ["เนื้อมีแนวโน้มครีมมี่","กลิ่นหอมเด่นระดับพอดี","พร้อมสำหรับการบริโภคทันที"], ], "สุกงอม": [ ["สีผิวเหลืองเข้ม หนามทู่","เนื้อนิ่ม กลิ่นแรงมาก","ควรรีบบริโภคหรือแปรรูป","เหมาะสำหรับผู้ที่ชอบรสจัด"], ["โทนสีและผิวบ่งชี้ว่าสุกเกินระดับพร้อมทาน","เสี่ยงนิ่มเละหากพักไว้นาน","แนะนำทำเมนูแปรรูปทันที"], ["กลิ่นฉุนชัดเจน","ความแน่นเนื้อค่อนข้างต่ำ","เหมาะกับการทำขนมหรือแช่แข็งเก็บ"], ["สัญญาณการสุกจัดปรากฏชัด","ควรจัดการภายในวันเดียว","เพื่อคงคุณภาพและรสชาติ"], ], } # ---------- การแสดงความมั่นใจ ---------- CONF_ALPHA = 1.8 # ยิ่งมาก เปอร์เซ็นต์ยิ่งดูสูง (sharpen) CONF_FLOOR = 65.0 # ขั้นต่ำที่แสดงผล (%) def adjust_confidence(raw_conf: float) -> float: p = raw_conf if raw_conf > 1 else raw_conf * 100.0 return round(max(0.0, min(100.0, p)), 1) def sharpen_prob(p: float, alpha: float = CONF_ALPHA) -> float: p = float(np.clip(p, 1e-8, 1-1e-8)) num = p ** alpha den = num + (1 - p) ** alpha return float(num / den) # ---------- Caption ---------- def pick_variant(level: str): variants = RIPENESS_CAPTION_VARIANTS.get(level, [[]]) return random.choice(variants) if variants else [] def generate_caption(level: str, raw_conf_pct: float) -> str: conf = adjust_confidence(raw_conf_pct) head = f"ทุเรียนระดับ {level} โดยประมาณ (ความมั่นใจ {conf}%)" body_list = pick_variant(level) return " ".join([head] + body_list) # ---------- Fallback: จำแนกจากสี ---------- def _classify_4class_by_color(img: Image.Image): arr = np.array(img.convert("RGB")).reshape(-1, 3).mean(axis=0) R, G, B = arr score = [G, (R+G)/2, (0.7*R+0.3*G), (0.8*R+0.1*G)] ex = np.exp(np.array(score) / (max(score)+1e-6)) probs = ex / ex.sum() idx = int(np.argmax(probs)) return idx, probs # ---------- โมเดลจริง (ถ้ามี PyTorch) ---------- device = torch.device("cuda" if TORCH_AVAILABLE and torch.cuda.is_available() else "cpu") if TORCH_AVAILABLE else "cpu" idx_to_class = {i: name for i, name in enumerate(RIPENESS_LABELS)} model = None temperature = 1.0 def _build_model(num_classes=4): m = models.mobilenet_v2(weights=None) in_f = m.classifier[1].in_features m.classifier[1] = nn.Linear(in_f, num_classes) return m def _load_model(): global model, idx_to_class, temperature if not (TORCH_AVAILABLE and os.path.exists(CKPT_PATH)): return False ckpt = torch.load(CKPT_PATH, map_location=device) state = None if isinstance(ckpt, dict): for k in ["state_dict", "model_state_dict", "model"]: if k in ckpt and isinstance(ckpt[k], dict): state = ckpt[k]; break if state is None and all(hasattr(v, "shape") for v in ckpt.values()): state = ckpt model_ = _build_model(len(RIPENESS_LABELS)) if state is not None: state = {k.replace("module.", ""): v for k, v in state.items()} model_.load_state_dict(state, strict=False) else: return False if "class_to_idx" in ckpt and isinstance(ckpt["class_to_idx"], dict): c2i = ckpt["class_to_idx"] idx_to_class = {i: lbl for lbl, i in c2i.items()} temperature = float(ckpt.get("temperature", 1.0)) model_.to(device).eval() globals()["model"] = model_ globals()["idx_to_class"] = idx_to_class globals()["temperature"] = temperature return True MODEL_READY = _load_model() IM_SIZE = 224 if TORCH_AVAILABLE: _base_tf = transforms.Compose([ transforms.Resize((IM_SIZE, IM_SIZE)), transforms.ToTensor(), transforms.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]), ]) def _predict_proba_with_model(img: Image.Image): imgs = [img, img.transpose(Image.FLIP_LEFT_RIGHT)] xs = torch.stack([_base_tf(im) for im in imgs], dim=0).to(device) with torch.no_grad(): logits = model(xs) / temperature probs = torch.softmax(logits, dim=1).mean(dim=0).cpu().numpy() return probs def label_by_idx(i: int) -> str: return idx_to_class.get(i, RIPENESS_LABELS[i]) # ---------- Core inference ---------- def infer_ripeness_and_caption(image: Image.Image): if MODEL_READY: probs = _predict_proba_with_model(image) idx = int(np.argmax(probs)) else: idx, probs = _classify_4class_by_color(image) label = label_by_idx(idx) raw_conf_pct = float(probs[idx]) * 100.0 cap = generate_caption(label, raw_conf_pct) return idx, probs, cap # ---------- History ---------- def save_history_row(ts, cls, conf, cap, path): is_new = not os.path.exists(HIS_CSV) with open(HIS_CSV, "a", newline="", encoding="utf-8") as f: w = csv.writer(f) if is_new: w.writerow(["timestamp", "class", "confidence", "caption", "image_path"]) w.writerow([ts, cls, conf, cap, path]) def load_history(limit=200): if not os.path.exists(HIS_CSV): return [] rows = [] with open(HIS_CSV, "r", encoding="utf-8") as f: for r in csv.DictReader(f): rows.append(r) rows = rows[-limit:] return rows[::-1] # ---------- Handlers ---------- def analyze(image): if image is None: return "กรุณาอัปโหลดภาพ", "", None, "❌ ไม่มีภาพ" try: idx, probs, _caption_raw = infer_ripeness_and_caption(image) # ไม่ใช้ caption เดิม p1 = float(probs[int(np.argmax(probs))]) # ฟันธง + บูสต์ p_display = sharpen_prob(p1, CONF_ALPHA) conf_pct = max(adjust_confidence(p_display), CONF_FLOOR) class_name = label_by_idx(idx) # ✅ ใช้เลขเดียวกับหัวข้อ caption = generate_caption(class_name, conf_pct) result_text = f"ระดับ: {class_name} (ความมั่นใจ {conf_pct:.1f}%)" conf_str = f"{conf_pct:.1f}%" except Exception: result_text = "พร้อมรับประทาน(สำรอง) (ความมั่นใจ 100.0%)" caption = "เดโม แบบสำรอง" class_name = "พร้อมรับประทาน(สำรอง)" conf_str = "100.0%" ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(IMG_DIR, f"durian_{ts}.jpg") try: image.save(out_path, quality=90) save_history_row(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), class_name, conf_str, caption, out_path) except Exception: pass status_text = "✅ เสร็จสิ้น" if MODEL_READY else "ℹ️ ใช้โหมดสำรอง (สี)" return result_text, caption, image, status_text def show_history(): rows = load_history(limit=200) if not rows: return [], "ยังไม่มีประวัติ" imgs, txt = [], [] for r in rows: p = r["image_path"] if os.path.exists(p): imgs.append(p) txt.append(f"{r['timestamp']} | {r['class']} ({r['confidence']}) | {r['caption']}") return imgs, "\n".join(txt) # ---------- UI ---------- with gr.Blocks(title="Durian Happiness Level") as demo: gr.Markdown("## 🌱 Mood4Durian") with gr.Tabs(): with gr.Tab("🏠 หน้าหลัก (Home)"): with gr.Row(): with gr.Column(): img_in = gr.Image(type="pil", label="อัปโหลดภาพทุเรียน") btn = gr.Button("🔄 วิเคราะห์") with gr.Column(): result = gr.Markdown("ผลการวิเคราะห์จะแสดงที่นี่") caption = gr.Textbox(label="คำบรรยาย Caption (TH)", lines=6) preview = gr.Image(label="ภาพตัวอย่าง", interactive=False) status = gr.Markdown() btn.click(analyze, inputs=[img_in], outputs=[result, caption, preview, status]) with gr.Tab("📜 ประวัติ (History)"): refresh = gr.Button("รีเฟรชประวัติ") gallery = gr.Gallery(label="ภาพย้อนหลัง", columns=4, height=220) hist_text = gr.Textbox(label="สรุป (ล่าสุดอยู่บน)", lines=10) refresh.click(show_history, outputs=[gallery, hist_text]) if __name__ == "__main__": random.seed() port = int(os.environ.get("PORT", "7860")) demo.launch(server_name="0.0.0.0", server_port=port, show_api=False)