File size: 14,668 Bytes
606b7b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11152a7
606b7b8
11152a7
606b7b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f77fae6
606b7b8
f77fae6
 
606b7b8
 
 
f77fae6
 
 
 
606b7b8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd76d11
 
 
 
5f392d5
 
 
 
 
 
 
606b7b8
5f392d5
 
 
 
 
 
606b7b8
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
# 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)