santi9462's picture
Update app.py
f77fae6 verified
# 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)