Spaces:
Sleeping
Sleeping
| # 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) | |