PhilipL commited on
Commit
31784d9
·
verified ·
1 Parent(s): bf01366

Upload 6 files

Browse files
Files changed (6) hide show
  1. app.py +405 -0
  2. blaze_face_short_range.tflite +3 -0
  3. emotion.py +623 -0
  4. mood_food_guide.csv +6 -0
  5. packages.txt +16 -0
  6. requirements.txt +49 -0
app.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ from openai import OpenAI
3
+ from huggingface_hub import InferenceClient
4
+ import os
5
+ import sys
6
+ import subprocess
7
+ import time
8
+ import requests
9
+ import urllib.parse
10
+ import pandas as pd
11
+ from langchain_huggingface import HuggingFaceEmbeddings
12
+ from langchain_community.vectorstores import FAISS
13
+ from PIL import Image
14
+ from dotenv import load_dotenv
15
+
16
+ # ==========================================
17
+ # Step 0: 環境安裝檢查 (僅保留 Playwright)
18
+ # ==========================================
19
+ def install_playwright():
20
+ """確保 Playwright 瀏覽器核心有安裝"""
21
+ try:
22
+ import playwright
23
+ except ImportError:
24
+ print("⚠️ 偵測到缺少 playwright,正在強制安裝...")
25
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "playwright"])
26
+
27
+ # 設定瀏覽器路徑
28
+ os.environ["PLAYWRIGHT_BROWSERS_PATH"] = os.path.join(os.getcwd(), "playwright_browsers")
29
+
30
+ print("🔄 檢查 Chromium 瀏覽器...")
31
+ try:
32
+ # 檢查是否已安裝,若無則安裝
33
+ if not os.path.exists(os.environ["PLAYWRIGHT_BROWSERS_PATH"]):
34
+ subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
35
+ except Exception as e:
36
+ print(f"⚠️ 瀏覽器安裝警告: {e}")
37
+
38
+ # 執行安裝
39
+ install_playwright()
40
+
41
+ # 延遲匯入
42
+ from playwright.sync_api import sync_playwright
43
+
44
+ load_dotenv()
45
+
46
+ # ==========================================
47
+ # 模組載入 (emotion 模組)
48
+ # ==========================================
49
+ import emotion
50
+
51
+ # ==========================================
52
+ # 0. 環境變數 & 1. 資料初始化
53
+ # ==========================================
54
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
55
+ HF_TOKEN = os.getenv("HF_TOKEN")
56
+
57
+ global_df = None
58
+ global_mood_df = None
59
+ global_retriever = None
60
+ rag_initialized = False
61
+
62
+ def init_static_data():
63
+ """初始化靜態的心情資料:讀取外部 CSV"""
64
+ global global_mood_df
65
+ if global_mood_df is None:
66
+ csv_path = 'mood_food_guide.csv'
67
+ if os.path.exists(csv_path):
68
+ try:
69
+ # 讀取 CSV,確保欄位正確
70
+ global_mood_df = pd.read_csv(csv_path)
71
+ print(f"✅ 成功載入心情指南:{csv_path}")
72
+ except Exception as e:
73
+ print(f"❌ 讀取 CSV 失敗: {e}")
74
+ global_mood_df = pd.DataFrame() # 建立空表防止後續報錯
75
+ else:
76
+ print(f"⚠️ 警告:找不到 {csv_path},請確認檔案已上傳至 Space。")
77
+ global_mood_df = pd.DataFrame()
78
+
79
+ # ==========================================
80
+ # API 金鑰檢查工具函式
81
+ # ==========================================
82
+ def check_api_key_status(name, key):
83
+ """檢查 API Key 是否存在,並回傳部分內容以供辨識"""
84
+ if not key:
85
+ return "❌ 未設定 (Not Set)"
86
+
87
+ # 遮罩處理,只顯示前後幾碼
88
+ if len(key) > 8:
89
+ masked = f"{key[:4]}...{key[-4:]}"
90
+ else:
91
+ masked = "******"
92
+ return f"✅ 已設定 ({masked})"
93
+
94
+ # ==========================================
95
+ # 2. Google Maps 爬蟲功能
96
+ # ==========================================
97
+ def sync_google_maps(url):
98
+ global global_df, global_retriever, rag_initialized
99
+ clean_url = url.strip() if url else ""
100
+ if not clean_url or "http" not in clean_url.lower():
101
+ yield "❌ 請輸入有效的 Google Maps 分享連結。"
102
+ return
103
+
104
+ try:
105
+ yield "🚀 [1/4] 啟動瀏覽器..."
106
+ with sync_playwright() as p:
107
+ browser = p.chromium.launch(headless=True)
108
+ page = browser.new_page()
109
+ yield "🌐 [2/4] 連線中..."
110
+ page.goto(clean_url, wait_until="domcontentloaded", timeout=60000)
111
+ yield "⏳ [3/4] 等待列表加載 (約 10 秒)..."
112
+ time.sleep(10)
113
+ yield "📄 [4/4] 解析餐廳資訊..."
114
+
115
+ titles = page.locator('div.fontHeadlineSmall').all_inner_texts()
116
+ details = page.locator('div.fontBodyMedium').all_inner_texts()
117
+
118
+ restaurant_list = []
119
+ for i, name in enumerate(titles):
120
+ name = name.strip()
121
+ if name:
122
+ addr = details[i].strip() if i < len(details) else ""
123
+ search_query = f"{name} {addr}"
124
+ restaurant_list.append({
125
+ "Name": name,
126
+ "Address": addr,
127
+ "Category": "未分類",
128
+ "RAG_Content": f"餐廳:{name},資訊:{addr}",
129
+ "URL": f"https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(search_query)}"
130
+ })
131
+ browser.close()
132
+
133
+ if not restaurant_list:
134
+ yield "⚠️ 找不到餐廳。請確認連結格式正確且已公開。"
135
+ return
136
+
137
+ global_df = pd.DataFrame(restaurant_list)
138
+
139
+ try:
140
+ embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
141
+ vectorstore = FAISS.from_texts(global_df['RAG_Content'].tolist(), embeddings, metadatas=global_df.to_dict('records'))
142
+ global_retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
143
+ except Exception as e:
144
+ print(f"RAG 初始化警告: {e}")
145
+
146
+ rag_initialized = True
147
+ yield f"✅ 同步成功!已載入 {len(restaurant_list)} 間餐廳。"
148
+ except Exception as e:
149
+ yield f"❌ 系統錯誤:{str(e)}"
150
+
151
+ # ==========================================
152
+ # 3. 核心功能 (AI Agent)
153
+ # ==========================================
154
+ def get_restaurant_data(mood_score_str, food_choice):
155
+ init_static_data() # 確保心情指南已載入
156
+
157
+ if global_df is None or global_df.empty:
158
+ return None, True, "", "資料庫未載入,無建議"
159
+
160
+ # 解析分數 (例如 "3 (普通)" -> 3)
161
+ try: score = int(str(mood_score_str).split(' ')[0])
162
+ except: score = 3
163
+
164
+ # 從 CSV 資料中查找對應的推薦類別與原因
165
+ rec_categories = ""
166
+ mood_reason = "隨意探索"
167
+
168
+ if global_mood_df is not None and not global_mood_df.empty:
169
+ mood_info = global_mood_df[global_mood_df['分數'] == score]
170
+ if not mood_info.empty:
171
+ rec_categories = mood_info.iloc[0]['推薦料理類別']
172
+ mood_reason = mood_info.iloc[0]['原因']
173
+
174
+ candidates = global_df.copy()
175
+ food_keyword = "飯" if food_choice == "吃飯" else "麵" if food_choice == "吃麵" else ""
176
+
177
+ is_random = False
178
+ if food_keyword:
179
+ filtered = candidates[candidates['Name'].str.contains(food_keyword, case=False, na=False) | candidates['RAG_Content'].str.contains(food_keyword, case=False, na=False)]
180
+ if not filtered.empty:
181
+ candidates = filtered
182
+ else:
183
+ is_random = True
184
+
185
+ if candidates.empty:
186
+ result = global_df.sample(1).iloc[0]; is_random = True
187
+ else:
188
+ result = candidates.sample(1).iloc[0]
189
+
190
+ return result, is_random, rec_categories, mood_reason
191
+
192
+ def generate_content_with_groq(restaurant_name, restaurant_detail, user_diary, mood_score, mood_guide_reason, debug_mode=False):
193
+ if not GROQ_API_KEY: return "⚠️ 請設定 GROQ_API_KEY", ""
194
+
195
+ client = OpenAI(api_key=GROQ_API_KEY, base_url="https://api.groq.com/openai/v1")
196
+
197
+ system_prompt = "你是一個幽默、懂吃且善解人意的 AI 朋友。請根據使用者的日記、心情以及「心情美食指南」來推薦餐廳。"
198
+ user_msg = f"""
199
+ 【狀態】心情分數:{mood_score},日記:{user_diary}
200
+ 【心情美食指南建議】
201
+ 因為分數是 {mood_score},建議吃這類食物的原因是:「{mood_guide_reason}」。
202
+
203
+ 【推薦餐廳】
204
+ 名稱:{restaurant_name}
205
+ 資料:{restaurant_detail}
206
+
207
+ 任務:
208
+ 請用繁體中文寫一段溫暖有趣的回覆:
209
+ 1. 先回應他的日記與測驗人設。
210
+ 2. 引用「心情美食指南」的原因,告訴他為什麼現在適合吃這家餐廳。
211
+ 3. 介紹這家餐廳的特色。
212
+ """
213
+
214
+ debug_log = ""
215
+ if debug_mode:
216
+ debug_log = f"### 🔧 Groq Prompt Debug\n**System:** {system_prompt}\n**User:** {user_msg}"
217
+
218
+ try:
219
+ response = client.chat.completions.create(model="llama-3.3-70b-versatile", messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}])
220
+ return response.choices[0].message.content, debug_log
221
+ except Exception as e:
222
+ return f"Groq Error: {str(e)}", debug_log
223
+
224
+ def generate_image_huggingface(prompt):
225
+ if not HF_TOKEN:
226
+ return None, "HF_TOKEN 未設定"
227
+
228
+ try:
229
+ hf_client = InferenceClient(token=HF_TOKEN)
230
+ image = hf_client.text_to_image(
231
+ prompt=prompt,
232
+ negative_prompt="blurry, low quality, distortion, text, watermark",
233
+ model="stabilityai/stable-diffusion-xl-base-1.0"
234
+ )
235
+ return image, None # 成功時,錯誤訊息為 None
236
+ except Exception as e:
237
+ # 回傳具體錯誤訊息
238
+ return None, str(e)
239
+
240
+ def mood_agent_logic(score_input, food_input, diary_input, debug_mode):
241
+ if not rag_initialized:
242
+ yield "⚠️ 請先同步地圖清單!", None, "", gr.update()
243
+ return
244
+
245
+ restaurant, is_random, rec_categories, mood_reason = get_restaurant_data(score_input, food_input)
246
+ if restaurant is None:
247
+ yield "資料庫讀取錯誤", None, "", gr.update()
248
+ return
249
+
250
+ name = restaurant['Name']; url = restaurant['URL']
251
+ info = restaurant.get('RAG_Content', '')
252
+ rag_info = str(restaurant.get('RAG_Content', ''))
253
+
254
+ img_prompt = f"Delicious food photography of {name}, {info}. high quality, photorealistic, 8k, cinematic lighting, appetizing, restaurant atmosphere, 50mm lens"
255
+
256
+ ai_text, groq_debug_log = generate_content_with_groq(name, rag_info, diary_input, score_input, mood_reason, debug_mode)
257
+
258
+ prefix = ""
259
+ if is_random and food_input != "隨便":
260
+ prefix = f"> 💡 **溫馨提示**:清單中暫無『{food_input}』,已從現有名單挑選最適合的店!\n\n"
261
+
262
+ # 組合完整的除錯資訊,包含 API Key 狀態與錯誤訊息
263
+ full_debug_log = ""
264
+ if debug_mode:
265
+ # 1. 檢查 Key 狀態
266
+ groq_status = check_api_key_status("GROQ_API_KEY", GROQ_API_KEY)
267
+ hf_status = check_api_key_status("HF_TOKEN", HF_TOKEN)
268
+
269
+ api_debug_block = f"""
270
+ ### 🔑 API 金鑰與系統狀態
271
+ - **GROQ_API_KEY**: {groq_status}
272
+ - **HF_TOKEN**: {hf_status}
273
+ - **RAG 狀態**: {"✅ 已初始化" if rag_initialized else "❌ 未初始化"}
274
+ """
275
+ full_debug_log = api_debug_block + "\n" + groq_debug_log + f"\n\n### 🎨 Image Prompt Debug\n{img_prompt}"
276
+
277
+ debug_output_update = gr.update(value=full_debug_log, visible=debug_mode)
278
+
279
+ final_response = f"{prefix}### 🍽️ 推薦:{name}\n\n{ai_text}"
280
+ map_html = f'<div style="text-align:center"><a href="{url}" target="_blank" style="background:#4CAF50;color:white;padding:8px 16px;border-radius:20px;text-decoration:none">🗺️ Google Map 導航</a></div>'
281
+
282
+ # 先回傳文字
283
+ yield final_response, None, map_html, debug_output_update
284
+
285
+ # 呼叫圖片生成並捕捉錯誤
286
+ image_output, img_error = generate_image_huggingface(img_prompt)
287
+
288
+ # 如果有圖片錯誤且在除錯模式,追加錯誤訊息到 log
289
+ if img_error and debug_mode:
290
+ full_debug_log += f"\n\n⚠️ **Hugging Face 圖片生成失敗:**\n{img_error}"
291
+ debug_output_update = gr.update(value=full_debug_log)
292
+
293
+ yield final_response, image_output, map_html, debug_output_update
294
+
295
+ # ==========================================
296
+ # 4. 橋接邏輯
297
+ # ==========================================
298
+ def _score_to_radio_value(score):
299
+ mapping = {1: "1 (心情差)", 2: "2 (不太好)", 3: "3 (普通)", 4: "4 (不錯)", 5: "5 (超棒)"}
300
+ try: score = int(score)
301
+ except: score = 3
302
+ return mapping.get(score, "3 (普通)")
303
+
304
+ def bridge_start_click(st):
305
+ try:
306
+ res = emotion.on_restart(st)
307
+ return res[1], res[0], gr.update(visible=True), res[5], res[4], gr.update(visible=False)
308
+ except: return gr.update(), gr.update(), gr.update(), gr.update(), st, gr.update()
309
+
310
+ def bridge_stop_click(st):
311
+ try:
312
+ res = emotion.on_stop(st)
313
+ return res[1], res[0], gr.update(visible=False), res[5], res[4]
314
+ except: return gr.update(), gr.update(), gr.update(), gr.update(), st
315
+
316
+ def bridge_predict_frame(frame, st):
317
+ try:
318
+ res = emotion.on_stream(frame, st)
319
+ out_cam = res[0]; out_result = res[1]; out_st = res[4]; out_btn_start = res[5]
320
+ score_update = gr.update(); btn_go_visible = gr.update(visible=False)
321
+ out_btn_stop = gr.update()
322
+
323
+ if hasattr(out_st, 'finished') and out_st.finished and hasattr(out_st, 'final_score'):
324
+ new_val = _score_to_radio_value(out_st.final_score)
325
+ score_update = gr.update(value=new_val)
326
+ out_btn_stop = gr.update(visible=False)
327
+ btn_go_visible = gr.update(visible=True)
328
+
329
+ return out_cam, out_result, out_st, out_btn_stop, out_btn_start, score_update, btn_go_visible
330
+ except Exception as e:
331
+ return frame, gr.update(), st, gr.update(), gr.update(), gr.update(), gr.update()
332
+
333
+ def bridge_predict_upload(img, st):
334
+ try:
335
+ res = emotion.on_upload(img, st)
336
+ out_result, out_st = res[0], res[2]
337
+ score_update = gr.update()
338
+ if hasattr(out_st, 'finished') and out_st.finished and hasattr(out_st, 'final_score'):
339
+ new_val = _score_to_radio_value(out_st.final_score)
340
+ score_update = gr.update(value=new_val)
341
+ return out_result, score_update, gr.Tabs(selected=1), out_st
342
+ except: return gr.update(), gr.update(), gr.Tabs(), st
343
+
344
+ # ==========================================
345
+ # 5. Gradio 介面
346
+ # ==========================================
347
+ css_ = "#app_container { max-width: 960px; margin: 0 auto; }"
348
+ if hasattr(emotion, 'css'): css_ += "\n" + emotion.css
349
+
350
+ with gr.Blocks(title="AI 心情食堂", css=css_) as demo:
351
+ st_state = gr.State(emotion.AppState())
352
+
353
+ with gr.Tabs() as tabs:
354
+ # Tab 1
355
+ with gr.TabItem("😊 情緒辨識 (Step 1)", id=0):
356
+ with gr.Column(elem_id="app_container"):
357
+ gr.Markdown("### 第一步:測測你的心情能量\n讓 AI 看看你的表情,自動幫你決定心情分數!")
358
+ with gr.Row():
359
+ btn_start = gr.Button("📸 開啟攝影機辨識", variant="primary")
360
+ btn_stop = gr.Button("⏹️ 停止", variant="secondary", visible=False)
361
+ cam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="攝影機畫面", visible=False)
362
+ result_markdown = gr.Markdown(emotion._hint_html("請按「開啟攝影機辨識」並允許瀏覽器使用相機。"))
363
+ btn_go_dining = gr.Button("🚀 確定心情,來找餐廳!", variant="primary", visible=False, size="lg")
364
+
365
+ # Tab 2
366
+ with gr.TabItem("🍽️ AI 心情食堂 (Step 2)", id=1):
367
+ with gr.Column():
368
+ gr.Markdown(f"## 🔗 載入你的口袋名單")
369
+ with gr.Row():
370
+ map_url = gr.Textbox(label="Google Maps Saved Lists 連結", placeholder="請貼上清單的分享連結...", scale=3)
371
+ sync_btn = gr.Button("🔄 同步清單", variant="secondary", scale=1)
372
+ sync_msg = gr.Markdown("ℹ️ 尚未同步資料庫")
373
+ gr.Markdown("---")
374
+ gr.Markdown(f"## 🍱 今天想吃點什麼?")
375
+ with gr.Row():
376
+ with gr.Column(scale=1):
377
+ score_input = gr.Radio(["1 (心情差)", "2 (不太好)", "3 (普通)", "4 (不錯)", "5 (超棒)"], label="1. 心情分數 (由 Tab 1 自動填入)", value="3 (普通)")
378
+ food_input = gr.Radio(["吃飯", "吃麵", "隨便"], label="2. 想吃什麼", value="隨便")
379
+ diary_input = gr.Textbox(lines=4, label="3. 心情日記", placeholder="寫下今天發生的事...")
380
+
381
+ debug_mode_btn = gr.Checkbox(label="🔧 開啟除錯模式", value=False)
382
+ submit_btn = gr.Button("🍱 送出給 Agent", variant="primary")
383
+ debug_output = gr.Markdown(label="除錯資訊 (Debug Log)", visible=False)
384
+
385
+ with gr.Column(scale=1):
386
+ agent_output = gr.Markdown(label="AI 回應")
387
+ image_output = gr.Image(label="AI 推薦美食圖", type="pil", width=400)
388
+ map_output = gr.HTML(label="地圖導航")
389
+
390
+ # 事件
391
+ sync_btn.click(fn=sync_google_maps, inputs=[map_url], outputs=[sync_msg])
392
+ btn_start.click(fn=bridge_start_click, inputs=[st_state], outputs=[result_markdown, cam, btn_stop, btn_start, st_state, btn_go_dining])
393
+ btn_stop.click(fn=bridge_stop_click, inputs=[st_state], outputs=[result_markdown, cam, btn_stop, btn_start, st_state])
394
+ cam.stream(fn=bridge_predict_frame, inputs=[cam, st_state], outputs=[cam, result_markdown, st_state, btn_stop, btn_start, score_input, btn_go_dining])
395
+ btn_go_dining.click(fn=lambda: gr.Tabs(selected=1), inputs=None, outputs=tabs)
396
+
397
+ # debug_mode
398
+ submit_btn.click(
399
+ fn=mood_agent_logic,
400
+ inputs=[score_input, food_input, diary_input, debug_mode_btn],
401
+ outputs=[agent_output, image_output, map_output, debug_output]
402
+ )
403
+
404
+ if __name__ == "__main__":
405
+ demo.launch(ssr_mode=False)
blaze_face_short_range.tflite ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b4578f35940bf5a1a655214a1cce5cab13eba73c1297cd78e1a04c2380b0152f
3
+ size 229746
emotion.py ADDED
@@ -0,0 +1,623 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import threading
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional, List, Tuple, Dict
5
+
6
+ import gradio as gr
7
+ import numpy as np
8
+ import cv2
9
+
10
+ import mediapipe as mp
11
+ from mediapipe.tasks import python
12
+ from mediapipe.tasks.python import vision
13
+
14
+ from deepface import DeepFace
15
+
16
+
17
+ # =========================
18
+ # 參數(可微調)
19
+ # =========================
20
+ ANALYZE_EVERY_SEC = 1.5 # ⭐ 建議 1.5~2.0:HF CPU 比較順
21
+ EMA_ALPHA = 0.45
22
+ CONF_TH = 40.0 # DeepFace emotion confidence 門檻
23
+ SWITCH_CONFIRM_SEC = 2.5 # 連續穩定多久才算完成
24
+ ZOOM_FACTOR = 1.5 # ✅ 保留:只用在「顯示」
25
+ UI_TARGET_W = 720 # ✅ 回傳畫面縮小寬度,提升順暢度
26
+
27
+ # MediaPipe face detector 門檻(太高很容易漏)
28
+ MP_MIN_DET_CONF = 0.3
29
+
30
+ # 模型路徑(確認 repo 中模型真的在這裡)
31
+ MP_MODEL_PATH = "blaze_face_short_range.tflite"
32
+
33
+
34
+ # =========================
35
+ # 情緒中文 + 分數映射(7 -> 5)
36
+ # =========================
37
+ EMO_ZH = {
38
+ "angry": "生氣",
39
+ "disgust": "厭惡",
40
+ "fear": "害怕",
41
+ "happy": "開心",
42
+ "sad": "難過",
43
+ "surprise": "驚訝",
44
+ "neutral": "平靜",
45
+ "unknown": "未知",
46
+ }
47
+
48
+ EMO_TO_SCORE = {
49
+ "angry": 1,
50
+ "disgust": 1,
51
+ "fear": 1,
52
+ "sad": 2,
53
+ "neutral": 3,
54
+ "surprise": 4,
55
+ "happy": 5,
56
+ "unknown": 3,
57
+ }
58
+
59
+ SCORE_LABELS = {
60
+ 1: "1(心情差)",
61
+ 2: "2(不太好)",
62
+ 3: "3(普通)",
63
+ 4: "4(不錯)",
64
+ 5: "5(超棒)",
65
+ }
66
+ SCORE_CHOICES = [SCORE_LABELS[i] for i in [1, 2, 3, 4, 5]]
67
+
68
+
69
+ # =========================
70
+ # 狀態
71
+ # =========================
72
+ @dataclass
73
+ class AppState:
74
+ mode: str = "攝影機辨識"
75
+
76
+ running: bool = True
77
+ finished: bool = False
78
+ last_analyze_t: float = 0.0
79
+
80
+ # smoothing
81
+ ema_scores: Optional[np.ndarray] = None
82
+ emo_keys: List[str] = field(default_factory=list)
83
+
84
+ # stable/candidate
85
+ stable_emo: str = "unknown"
86
+ candidate_emo: Optional[str] = None
87
+ candidate_since: Optional[float] = None
88
+
89
+ # final
90
+ final_emo: Optional[str] = None
91
+ final_conf: Optional[float] = None
92
+ final_score: int = 3
93
+
94
+
95
+ # =========================
96
+ # UI HTML
97
+ # =========================
98
+ def _hint_html(msg: str) -> str:
99
+ return f"""
100
+ <div style="border-radius:14px;padding:14px;border:1px dashed #DADADA;background:#FAFAFA;color:#000000;">
101
+ {msg}
102
+ </div>
103
+ """
104
+
105
+ def _camera_hint_before_start() -> str:
106
+ return _hint_html(
107
+ "請先按「Click to access webcam / 允許」開啟攝影機;"
108
+ "接著,請按「錄製(Record)」開始。"
109
+ )
110
+
111
+ def _camera_hint_need_face() -> str:
112
+ return _hint_html(
113
+ "⚠️ 目前未偵測到人臉,請將臉部移至畫面中央、靠近鏡頭,並避免瀏海遮住眉眼,才可進行情緒辨識。"
114
+ )
115
+
116
+ def _camera_hint_done() -> str:
117
+ return _hint_html("已完成辨識,可按「重新攝影機辨識」再次進行辨識。")
118
+
119
+ def _camera_hint_stopped() -> str:
120
+ return _hint_html("已停止辨識。可按「重新攝影機辨識」再試一次。")
121
+
122
+ def _score_radio_value(score: int) -> str:
123
+ score = int(score)
124
+ score = min(5, max(1, score))
125
+ return SCORE_LABELS[score]
126
+
127
+ def _result_card_html(emo_key: str, conf: Optional[float]) -> str:
128
+ zh = EMO_ZH.get(emo_key, emo_key)
129
+ score = EMO_TO_SCORE.get(emo_key, 3)
130
+ conf_txt = "" if conf is None else f"{conf:.1f}%"
131
+ score_desc = SCORE_LABELS[score].split("(")[1].rstrip(")")
132
+
133
+ return f"""
134
+ <div style="
135
+ border-radius:16px;
136
+ padding:20px;
137
+ border:1px solid #E6E6E6;
138
+ background:#FFFFFF;
139
+ color:#000000;
140
+ ">
141
+ <div style="font-size:14px;color:#000000;margin-bottom:8px;">
142
+ 辨識結果
143
+ </div>
144
+
145
+ <div style="
146
+ font-size:42px;
147
+ font-weight:800;
148
+ line-height:1.1;
149
+ margin-bottom:14px;
150
+ color:#000000;
151
+ ">
152
+ {zh}
153
+ </div>
154
+
155
+ <div style="font-size:16px;color:#000000;margin-bottom:6px;">
156
+ 心情分數:{score}({score_desc})
157
+ </div>
158
+
159
+ <div style="font-size:14px;color:#000000;">
160
+ 信心值:{conf_txt}
161
+ </div>
162
+ </div>
163
+ """
164
+
165
+
166
+ # =========================
167
+ # 影像處理(保命 + 顯示優化)
168
+ # =========================
169
+ def _to_rgb_uint8(frame: np.ndarray) -> np.ndarray:
170
+ """統一成 MediaPipe Tasks 最穩格式:RGB、uint8、0~255、contiguous。"""
171
+ if frame is None:
172
+ return frame
173
+
174
+ # RGBA -> RGB
175
+ if frame.ndim == 3 and frame.shape[2] == 4:
176
+ frame = frame[:, :, :3]
177
+
178
+ # 灰階 -> RGB
179
+ if frame.ndim == 2:
180
+ frame = np.stack([frame, frame, frame], axis=-1)
181
+
182
+ # dtype / range 修正
183
+ if frame.dtype != np.uint8:
184
+ mx = float(np.max(frame)) if frame.size else 0.0
185
+ if mx <= 1.5: # 多半是 0~1
186
+ frame = (frame * 255.0).clip(0, 255).astype(np.uint8)
187
+ else: # 多半是 0~255 但 float
188
+ frame = np.clip(frame, 0, 255).astype(np.uint8)
189
+
190
+ return np.ascontiguousarray(frame)
191
+
192
+ def _zoom_center(frame: np.ndarray, zoom: float) -> np.ndarray:
193
+ if frame is None or zoom <= 1.0:
194
+ return frame
195
+ h, w = frame.shape[:2]
196
+ new_w = int(w / zoom)
197
+ new_h = int(h / zoom)
198
+ start_x = (w - new_w) // 2
199
+ start_y = (h - new_h) // 2
200
+ cropped = frame[start_y : start_y + new_h, start_x : start_x + new_w]
201
+ return np.ascontiguousarray(cropped)
202
+
203
+ def _resize_for_ui(frame_rgb: np.ndarray, target_w: int = UI_TARGET_W) -> np.ndarray:
204
+ """回傳給 Gradio 的畫面縮到固定寬度,減少傳輸/渲染負擔。"""
205
+ if frame_rgb is None:
206
+ return frame_rgb
207
+ h, w = frame_rgb.shape[:2]
208
+ if w <= target_w:
209
+ return frame_rgb
210
+ scale = target_w / w
211
+ new_h = max(1, int(h * scale))
212
+ return cv2.resize(frame_rgb, (target_w, new_h), interpolation=cv2.INTER_AREA)
213
+
214
+
215
+ # =========================
216
+ # MediaPipe Tasks Face Detector
217
+ # =========================
218
+ BaseOptions = python.BaseOptions
219
+ FaceDetector = vision.FaceDetector
220
+ FaceDetectorOptions = vision.FaceDetectorOptions
221
+ VisionRunningMode = vision.RunningMode
222
+
223
+ _face_detector = FaceDetector.create_from_options(
224
+ FaceDetectorOptions(
225
+ base_options=BaseOptions(model_asset_path=MP_MODEL_PATH),
226
+ running_mode=VisionRunningMode.IMAGE,
227
+ min_detection_confidence=MP_MIN_DET_CONF,
228
+ )
229
+ )
230
+
231
+ def mp_tasks_detect_and_draw(frame_rgb: np.ndarray):
232
+ """
233
+ - 最大臉策略(bbox 面積最大)
234
+ - 有臉:畫白框 + 紅點 keypoints
235
+ - 回 bbox_px (x, y, w, h) 供 ROI 裁切
236
+ """
237
+ frame_rgb = _to_rgb_uint8(frame_rgb)
238
+ h, w = frame_rgb.shape[:2]
239
+
240
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame_rgb)
241
+ result = _face_detector.detect(mp_image)
242
+
243
+ if not result.detections:
244
+ return frame_rgb, None
245
+
246
+ best_det = None
247
+ best_area = -1.0
248
+ best_bbox = None
249
+
250
+ for det in result.detections:
251
+ bbox = det.bounding_box
252
+ area = float(bbox.width) * float(bbox.height)
253
+ if area > best_area:
254
+ best_area = area
255
+ best_det = det
256
+ best_bbox = bbox
257
+
258
+ if best_det is None or best_bbox is None:
259
+ return frame_rgb, None
260
+
261
+ lx = int(best_bbox.origin_x)
262
+ ly = int(best_bbox.origin_y)
263
+ bw = int(best_bbox.width)
264
+ bh = int(best_bbox.height)
265
+
266
+ lx = max(0, min(w - 1, lx))
267
+ ly = max(0, min(h - 1, ly))
268
+ bw = max(1, min(w - lx, bw))
269
+ bh = max(1, min(h - ly, bh))
270
+
271
+ bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
272
+ cv2.rectangle(bgr, (lx, ly), (lx + bw, ly + bh), (255, 255, 255), 3)
273
+
274
+ for kp in best_det.keypoints:
275
+ cx = int(kp.x * w)
276
+ cy = int(kp.y * h)
277
+ cv2.circle(bgr, (cx, cy), 6, (0, 0, 255), -1)
278
+
279
+ drawn = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
280
+ return drawn, (lx, ly, bw, bh)
281
+
282
+
283
+ # =========================
284
+ # 背景推論(避免卡頓) + ✅ 沒臉時清空
285
+ # =========================
286
+ _ANALYZE_LOCK = threading.Lock()
287
+ _ANALYZE_THREAD: Optional[threading.Thread] = None
288
+ _PENDING_FACE: Optional[np.ndarray] = None
289
+ _LAST_ANALYZE: Dict[str, Optional[float]] = {"ts": 0.0, "conf": None, "emo": "unknown"}
290
+
291
+ def _clear_last_analyze():
292
+ """✅ 沒有人臉時,清空上一筆情緒,避免殘留顯示(你影片遇到的 bug)"""
293
+ global _LAST_ANALYZE
294
+ _LAST_ANALYZE = {"ts": 0.0, "conf": None, "emo": "unknown"}
295
+
296
+ def _analyze_worker():
297
+ global _PENDING_FACE, _ANALYZE_THREAD, _LAST_ANALYZE
298
+
299
+ while True:
300
+ with _ANALYZE_LOCK:
301
+ face = _PENDING_FACE
302
+ _PENDING_FACE = None
303
+
304
+ if face is None:
305
+ break
306
+
307
+ try:
308
+ r = DeepFace.analyze(
309
+ img_path=face,
310
+ actions=["emotion"],
311
+ enforce_detection=False,
312
+ detector_backend="skip", # ⭐ 你已裁 ROI,別再偵測一次
313
+ )
314
+ if isinstance(r, list):
315
+ r = r[0]
316
+ emo_dict = r.get("emotion", None)
317
+
318
+ if isinstance(emo_dict, dict) and len(emo_dict) > 0:
319
+ top_key = max(emo_dict, key=lambda k: float(emo_dict[k]))
320
+ top_conf = float(emo_dict[top_key])
321
+ _LAST_ANALYZE = {"ts": time.time(), "emo": top_key, "conf": top_conf}
322
+ except Exception:
323
+ pass
324
+
325
+ _ANALYZE_THREAD = None
326
+
327
+ def _enqueue_analyze(face_roi: np.ndarray):
328
+ global _PENDING_FACE, _ANALYZE_THREAD
329
+
330
+ with _ANALYZE_LOCK:
331
+ _PENDING_FACE = face_roi
332
+
333
+ if _ANALYZE_THREAD is None:
334
+ _ANALYZE_THREAD = threading.Thread(target=_analyze_worker, daemon=True)
335
+ _ANALYZE_THREAD.start()
336
+
337
+
338
+ # =========================
339
+ # state helper
340
+ # =========================
341
+ def _reset_state_for_camera(st: AppState) -> AppState:
342
+ st.running = True
343
+ st.finished = False
344
+ st.last_analyze_t = 0.0
345
+
346
+ st.ema_scores = None
347
+ st.emo_keys = []
348
+
349
+ st.stable_emo = "unknown"
350
+ st.candidate_emo = None
351
+ st.candidate_since = None
352
+
353
+ st.final_emo = None
354
+ st.final_conf = None
355
+ st.final_score = 3
356
+
357
+ _clear_last_analyze()
358
+ return st
359
+
360
+
361
+ # =========================
362
+ # streaming callback
363
+ # =========================
364
+ def on_stream(frame_rgb: np.ndarray, st: AppState):
365
+ # stopped / finished
366
+ if st.finished or (not st.running):
367
+ cam_visible = False
368
+
369
+ if st.final_emo:
370
+ result_html = _result_card_html(st.final_emo, st.final_conf)
371
+ mood_val = _score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))
372
+ hint_html = _camera_hint_done()
373
+ else:
374
+ result_html = _hint_html("尚未完成辨識。")
375
+ mood_val = _score_radio_value(3)
376
+ hint_html = _camera_hint_stopped()
377
+
378
+ return (
379
+ gr.update(value=None, visible=cam_visible),
380
+ gr.update(value=result_html),
381
+ gr.update(value=mood_val),
382
+ gr.update(value=hint_html),
383
+ st,
384
+ gr.update(value="重新攝影機辨識"),
385
+ )
386
+
387
+ # no frame yet
388
+ if frame_rgb is None:
389
+ return (
390
+ gr.update(value=None, visible=True),
391
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
392
+ gr.update(value=_score_radio_value(3)),
393
+ gr.update(value=_camera_hint_before_start()),
394
+ st,
395
+ gr.update(value="重新攝影機辨識"),
396
+ )
397
+
398
+ frame_rgb = _to_rgb_uint8(frame_rgb)
399
+
400
+ # ✅ 偵測用原圖
401
+ frame_drawn, bbox_px = mp_tasks_detect_and_draw(frame_rgb)
402
+
403
+ # ✅ 顯示用 zoom
404
+ frame_show = _zoom_center(frame_drawn if bbox_px is not None else frame_rgb, ZOOM_FACTOR)
405
+ frame_show = _resize_for_ui(frame_show, UI_TARGET_W)
406
+
407
+ # ✅ 沒有臉:清空殘留情緒 + 重置候選狀態
408
+ if bbox_px is None:
409
+ _clear_last_analyze()
410
+
411
+ st.stable_emo = "unknown"
412
+ st.candidate_emo = None
413
+ st.candidate_since = None
414
+ st.ema_scores = None
415
+ st.emo_keys = []
416
+
417
+ return (
418
+ gr.update(value=frame_show, visible=True),
419
+ gr.update(value=_hint_html("尚未偵測到人臉")),
420
+ gr.update(value=_score_radio_value(3)),
421
+ gr.update(value=_camera_hint_need_face()),
422
+ st,
423
+ gr.update(value="重新攝影機辨識"),
424
+ )
425
+
426
+ now = time.time()
427
+
428
+ # 到分析時間:只排隊給背景 thread
429
+ if now - st.last_analyze_t >= ANALYZE_EVERY_SEC:
430
+ st.last_analyze_t = now
431
+
432
+ x, y, bw, bh = bbox_px
433
+ pad_x = int(bw * 0.10)
434
+ pad_y = int(bh * 0.10)
435
+ H, W = frame_rgb.shape[:2]
436
+ x1 = max(0, x - pad_x)
437
+ y1 = max(0, y - pad_y)
438
+ x2 = min(W, x + bw + pad_x)
439
+ y2 = min(H, y + bh + pad_y)
440
+ face_roi = frame_rgb[y1:y2, x1:x2].astype(np.uint8)
441
+
442
+ _enqueue_analyze(face_roi)
443
+
444
+ # ✅ Gate:只有「有臉」才讀取 _LAST_ANALYZE(避免殘留)
445
+ top_key = _LAST_ANALYZE.get("emo", "unknown") or "unknown"
446
+ top_conf = _LAST_ANALYZE.get("conf", None)
447
+
448
+ # 尚未推論到結果:先顯示 unknown
449
+ if top_conf is None:
450
+ show = st.stable_emo or "unknown"
451
+ return (
452
+ gr.update(value=frame_show, visible=True),
453
+ gr.update(value=_result_card_html(show, None)),
454
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
455
+ gr.update(value=_camera_hint_before_start()),
456
+ st,
457
+ gr.update(value="重新攝影機辨識"),
458
+ )
459
+
460
+ # 低信心就不切換
461
+ if top_conf < CONF_TH:
462
+ show = st.stable_emo or "unknown"
463
+ return (
464
+ gr.update(value=frame_show, visible=True),
465
+ gr.update(value=_result_card_html(show, None)),
466
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(show, 3))),
467
+ gr.update(value=_camera_hint_before_start()),
468
+ st,
469
+ gr.update(value="重新攝影機辨識"),
470
+ )
471
+
472
+ # === EMA smoothing(用 7 類別向量)===
473
+ all_keys = ["angry", "disgust", "fear", "happy", "sad", "surprise", "neutral"]
474
+ vec = np.zeros((len(all_keys),), dtype=np.float32)
475
+ if top_key in all_keys:
476
+ vec[all_keys.index(top_key)] = float(top_conf)
477
+
478
+ if st.ema_scores is None or st.emo_keys != all_keys:
479
+ st.ema_scores = vec
480
+ st.emo_keys = all_keys
481
+ else:
482
+ st.ema_scores = EMA_ALPHA * vec + (1.0 - EMA_ALPHA) * st.ema_scores
483
+
484
+ stable_idx = int(np.argmax(st.ema_scores))
485
+ stable_key = st.emo_keys[stable_idx] if st.emo_keys else "unknown"
486
+ st.stable_emo = stable_key
487
+
488
+ # 延遲確認,避免跳動
489
+ if st.candidate_emo != stable_key:
490
+ st.candidate_emo = stable_key
491
+ st.candidate_since = now
492
+ else:
493
+ if st.candidate_since is None:
494
+ st.candidate_since = now
495
+
496
+ confirmed = (
497
+ st.candidate_since is not None
498
+ and (now - st.candidate_since) >= SWITCH_CONFIRM_SEC
499
+ )
500
+
501
+ # ✅ 完成一次辨識後停止
502
+ if confirmed and st.final_emo is None:
503
+ st.final_emo = stable_key
504
+ st.final_conf = float(top_conf) if top_conf is not None else None
505
+ st.final_score = EMO_TO_SCORE.get(stable_key, 3)
506
+
507
+ st.running = False
508
+ st.finished = True
509
+
510
+ return (
511
+ gr.update(value=None, visible=False),
512
+ gr.update(value=_result_card_html(stable_key, st.final_conf)),
513
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
514
+ gr.update(value=_camera_hint_done()),
515
+ st,
516
+ gr.update(value="重新攝影機辨識"),
517
+ )
518
+
519
+ return (
520
+ gr.update(value=frame_show, visible=True),
521
+ gr.update(value=_result_card_html(stable_key, float(top_conf))),
522
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(stable_key, 3))),
523
+ gr.update(value=_camera_hint_before_start()),
524
+ st,
525
+ gr.update(value="重新攝影機辨識"),
526
+ )
527
+
528
+
529
+ # =========================
530
+ # Buttons
531
+ # =========================
532
+ def on_restart(st: AppState):
533
+ st = _reset_state_for_camera(st)
534
+ return (
535
+ gr.update(value=None, visible=True),
536
+ gr.update(value=_hint_html("等待影像…(尚未開始串流)")),
537
+ gr.update(value=_score_radio_value(3)),
538
+ gr.update(value=_camera_hint_before_start()),
539
+ st,
540
+ gr.update(value="重新攝影機辨識"),
541
+ )
542
+
543
+ def on_stop(st: AppState):
544
+ st.running = False
545
+ st.finished = True
546
+
547
+ if st.final_emo is None:
548
+ return (
549
+ gr.update(value=None, visible=False),
550
+ gr.update(value=_hint_html("已停止辨識。")),
551
+ gr.update(value=_score_radio_value(3)),
552
+ gr.update(value=_camera_hint_stopped()),
553
+ st,
554
+ gr.update(value="重新攝影機辨識"),
555
+ )
556
+
557
+ return (
558
+ gr.update(value=None, visible=False),
559
+ gr.update(value=_result_card_html(st.final_emo, st.final_conf)),
560
+ gr.update(value=_score_radio_value(EMO_TO_SCORE.get(st.final_emo, 3))),
561
+ gr.update(value=_camera_hint_done()),
562
+ st,
563
+ gr.update(value="重新攝影機辨識"),
564
+ )
565
+
566
+
567
+ # =========================
568
+ # Gradio UI
569
+ # =========================
570
+ css = ""
571
+
572
+ if __name__ == "__main__":
573
+ with gr.Blocks(title="Emotion Detector", css=css) as demo:
574
+ st = gr.State(AppState())
575
+
576
+ gr.Markdown("## 情緒辨識")
577
+ hint = gr.HTML(_camera_hint_before_start())
578
+
579
+ cam = gr.Image(
580
+ sources=["webcam"],
581
+ streaming=True,
582
+ type="numpy",
583
+ label="攝影機畫面",
584
+ visible=True,
585
+ )
586
+
587
+ with gr.Row():
588
+ btn_restart = gr.Button("重新攝影機辨識", variant="primary")
589
+ btn_stop = gr.Button("停止", variant="secondary")
590
+
591
+ gr.Markdown("### 辨識結果")
592
+ result = gr.HTML(_hint_html("等待影像…(尚未開始串流)"))
593
+
594
+ gr.Markdown("### 心情分數(系統判定)")
595
+ mood = gr.Radio(
596
+ choices=SCORE_CHOICES,
597
+ value=_score_radio_value(3),
598
+ label="心情分數",
599
+ interactive=False,
600
+ )
601
+
602
+ cam.stream(
603
+ fn=on_stream,
604
+ inputs=[cam, st],
605
+ outputs=[cam, result, mood, hint, st, btn_restart],
606
+ show_progress="minimal",
607
+ )
608
+
609
+ btn_restart.click(
610
+ fn=on_restart,
611
+ inputs=[st],
612
+ outputs=[cam, result, mood, hint, st, btn_restart],
613
+ show_progress="minimal",
614
+ )
615
+
616
+ btn_stop.click(
617
+ fn=on_stop,
618
+ inputs=[st],
619
+ outputs=[cam, result, mood, hint, st, btn_restart],
620
+ show_progress="minimal",
621
+ )
622
+
623
+ demo.launch(server_name="0.0.0.0", server_port=7860, share=False)
mood_food_guide.csv ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ 分數,心情狀態,需求,推薦料理類別,原因
2
+ 1,糟透了,"極度需要安慰、溫暖、擁抱感,或者是快速的多巴胺",日式料理(拉麵/壽喜燒/關東煮)、甜點/點心,"當心情跌落谷底,溫暖軟糯的碳水化合物像擁抱;糖分能快速上升血糖帶來短暫快樂,是「人生好難」的解藥。"
3
+ 2,糟糕,"發洩壓力、需要刺激、想要大口吃肉或重口味轉移注意力",韓式料理(炸雞/年糕/部隊鍋)、泰式料理,"辣味帶來的痛覺能刺激腦內啡分泌,酸辣重口能瞬間醒腦,暫時沖刷煩惱。"
4
+ 3,普通,"維持日常機能、追求安穩、健康、不想踩雷也不想太負擔",台式/中式(滷肉飯/水餃/乾麵)、健康餐(水煮/波奇碗),"這是刻在基因裡的安全感味道;或是選擇清淡原型食物,作為一種「自我保養」。"
5
+ 4,不錯,"有些許興致、願意嘗試豐富的香料、適合輕鬆的社交",港式料理、印度料理/馬來西亞料理,"適合花時間慢慢吃的點心,或欣賞香料層次豐富的異國風味,適合心情好且感官敏銳時享用。"
6
+ 5,太棒了,"慶祝、獎勵自己、高熱量、高爽度、大份量分享",美式/西式(牛排/漢堡/披薩/義大利麵),"標準的「慶祝食物」,伴隨著高熱量和飽足感,是大腦認定最高級的「獎勵」。"
packages.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ libnss3
2
+ libnspr4
3
+ libatk1.0-0t64
4
+ libatk-bridge2.0-0t64
5
+ libcups2t64
6
+ libdrm2
7
+ libxkbcommon0
8
+ libxcomposite1
9
+ libxdamage1
10
+ libxext6
11
+ libxfixes3
12
+ libxrandr2
13
+ libgbm1
14
+ libasound2t64
15
+ libpango-1.0-0
16
+ libcairo2
requirements.txt ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================================
2
+ # 1. 核心與資料處理 (Core & Data)
3
+ # =========================================
4
+ python-dotenv
5
+ requests
6
+ numpy
7
+ pandas
8
+
9
+ # =========================================
10
+ # 2. 使用者介面 (UI Framework)
11
+ # =========================================
12
+ # 採用您指定的版本,確保組員的 emotion.py 樣式相容
13
+ gradio==4.44.1
14
+
15
+ # =========================================
16
+ # 3. 網路爬蟲 (Web Scraping)
17
+ # =========================================
18
+ playwright
19
+
20
+ # =========================================
21
+ # 4. RAG 與 LLM 應用 (AI Logic)
22
+ # =========================================
23
+ openai
24
+ huggingface_hub
25
+ langchain
26
+ langchain-community
27
+ langchain-huggingface
28
+ sentence-transformers
29
+ # 向量資料庫 (務必使用 CPU 版)
30
+ faiss-cpu
31
+
32
+ # =========================================
33
+ # 5. 情緒辨識與影像處理 (Computer Vision)
34
+ # =========================================
35
+ # emotion.py 依賴的核心庫
36
+ deepface==0.0.92
37
+ mtcnn==0.1.1
38
+ pillow==10.4.0
39
+ mediapipe
40
+
41
+ # 雲端環境必須使用 headless 版本,避免 GUI 錯誤
42
+ opencv-python-headless==4.10.0.84
43
+
44
+ # =========================================
45
+ # 6. 深度學習底層 (Deep Learning Backend)
46
+ # =========================================
47
+ tensorflow==2.15.1
48
+ tf-keras==2.15.1
49
+ keras==2.15.0