Update app.py
Browse files
app.py
CHANGED
|
@@ -20,17 +20,16 @@ from deepface import DeepFace
|
|
| 20 |
warnings.filterwarnings("ignore")
|
| 21 |
|
| 22 |
# ==========================================
|
| 23 |
-
#
|
| 24 |
# ==========================================
|
| 25 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
| 26 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 27 |
|
| 28 |
-
# ---
|
| 29 |
df = pd.DataFrame()
|
| 30 |
try:
|
| 31 |
-
# 確保你有 restaurants.csv 在專案根目錄
|
| 32 |
df = pd.read_csv('restaurants.csv', engine='python')
|
| 33 |
-
df.columns = df.columns.str.strip()
|
| 34 |
|
| 35 |
def classify_mood(row):
|
| 36 |
name_str = str(row.get('Name', ''))
|
|
@@ -38,10 +37,10 @@ try:
|
|
| 38 |
text = (name_str + " " + rag_str).lower()
|
| 39 |
tags = []
|
| 40 |
rules = {
|
| 41 |
-
"開心/慶祝": ["牛排", "steak", "pizza", "炸", "
|
| 42 |
-
"傷心/疲憊": ["粥", "湯", "warm", "
|
| 43 |
-
"生氣/發洩": ["辣", "spicy", "麻辣", "鍋", "curry", "
|
| 44 |
-
"平靜/放鬆": ["cafe", "coffee", "tea", "
|
| 45 |
}
|
| 46 |
for mood, keywords in rules.items():
|
| 47 |
for kw in keywords:
|
|
@@ -52,26 +51,21 @@ try:
|
|
| 52 |
|
| 53 |
if 'Mood_Tags' not in df.columns:
|
| 54 |
df['Mood_Tags'] = df.apply(classify_mood, axis=1)
|
| 55 |
-
|
| 56 |
-
print("✅ 成功讀取 restaurants.csv 並建立 Mood_Tags")
|
| 57 |
except Exception as e:
|
| 58 |
-
# 如果找不到檔案或讀取失敗,這裡會提醒
|
| 59 |
print(f"⚠️ 讀取 restaurants.csv 失敗: {e}")
|
| 60 |
|
| 61 |
-
# ---
|
| 62 |
df_prompts = pd.DataFrame()
|
| 63 |
PROMPT_COL_NAME = 'Visual_prompt'
|
| 64 |
-
|
| 65 |
try:
|
| 66 |
if not df.empty:
|
| 67 |
df_prompts = df.copy()
|
| 68 |
if 'Name' in df_prompts.columns:
|
| 69 |
df_prompts.set_index('Name', inplace=True)
|
| 70 |
-
except
|
| 71 |
-
print(f"⚠️ 處理 Prompt 資料發生錯誤: {e}")
|
| 72 |
|
| 73 |
-
|
| 74 |
-
# --- RAG 建置 ---
|
| 75 |
retriever = None
|
| 76 |
if not df.empty and 'RAG_Content' in df.columns:
|
| 77 |
try:
|
|
@@ -82,16 +76,14 @@ if not df.empty and 'RAG_Content' in df.columns:
|
|
| 82 |
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 83 |
vectorstore = FAISS.from_documents(docs, embeddings)
|
| 84 |
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
|
| 85 |
-
except
|
| 86 |
-
print(f"⚠️ RAG 初始化失敗: {e}")
|
| 87 |
|
| 88 |
# ==========================================
|
| 89 |
-
#
|
| 90 |
# ==========================================
|
| 91 |
|
| 92 |
def get_restaurant_data(mood_score_str, food_choice):
|
| 93 |
if df.empty: return None, True
|
| 94 |
-
# 定義分數與 Mood_Tags 的對應關係 (與舊版維持一致)
|
| 95 |
score_map = {
|
| 96 |
"1 (心情差)": ["傷心/疲憊", "生氣/發洩"],
|
| 97 |
"2 (不太好)": ["傷心/疲憊", "生氣/發洩"],
|
|
@@ -105,11 +97,10 @@ def get_restaurant_data(mood_score_str, food_choice):
|
|
| 105 |
elif food_choice == "吃麵": food_keyword = "麵"
|
| 106 |
|
| 107 |
candidates = df.copy()
|
| 108 |
-
if 'Mood_Tags' not in candidates.columns: return df.sample(1).iloc[0], True
|
| 109 |
-
|
| 110 |
if target_moods:
|
| 111 |
pattern = "|".join(target_moods)
|
| 112 |
-
|
|
|
|
| 113 |
|
| 114 |
if food_keyword:
|
| 115 |
candidates = candidates[
|
|
@@ -120,23 +111,11 @@ def get_restaurant_data(mood_score_str, food_choice):
|
|
| 120 |
if candidates.empty: return df.sample(1).iloc[0], True
|
| 121 |
return candidates.sample(1).iloc[0], False
|
| 122 |
|
| 123 |
-
# 注意:因為移除了測驗,這裡我們用 '無測驗' 作為預設結果
|
| 124 |
def generate_content_with_groq(restaurant_name, restaurant_detail, user_diary, mood_score):
|
| 125 |
-
if not GROQ_API_KEY: return "⚠️ 請設定 GROQ_API_KEY
|
| 126 |
client = OpenAI(api_key=GROQ_API_KEY, base_url="https://api.groq.com/openai/v1")
|
| 127 |
system_prompt = "你是一個幽默、懂吃且善解人意的 AI 朋友。請根據使用者的日記和心情來推薦餐廳。"
|
| 128 |
-
user_msg = f""
|
| 129 |
-
【使用者狀態】
|
| 130 |
-
心情分數:{mood_score}
|
| 131 |
-
心情日記:{user_diary}
|
| 132 |
-
【推薦餐廳】
|
| 133 |
-
名稱:{restaurant_name}
|
| 134 |
-
資料:{restaurant_detail}
|
| 135 |
-
請完成任務:
|
| 136 |
-
【回應內容】:用繁體中文寫一段溫暖有趣的回覆。
|
| 137 |
-
- 結合「日記」與「心情」給予回應。
|
| 138 |
-
- 推薦上述餐廳,說明為什麼這家店適合他。
|
| 139 |
-
"""
|
| 140 |
try:
|
| 141 |
response = client.chat.completions.create(
|
| 142 |
model="llama-3.3-70b-versatile",
|
|
@@ -153,24 +132,18 @@ def generate_image_huggingface(prompt):
|
|
| 153 |
return hf_client.text_to_image(prompt=prompt, model="stabilityai/stable-diffusion-xl-base-1.0")
|
| 154 |
except: return None
|
| 155 |
|
| 156 |
-
# --- 主邏輯 Agent ---
|
| 157 |
-
# 移除了 quiz_state 參數
|
| 158 |
def mood_agent_logic(score_input, food_input, diary_input, debug_mode):
|
| 159 |
-
# 確保 score_input 有值
|
| 160 |
if not score_input:
|
| 161 |
-
yield "⚠️ 請先選擇心情分數
|
| 162 |
return
|
| 163 |
|
| 164 |
restaurant, is_random = get_restaurant_data(score_input, food_input)
|
| 165 |
if restaurant is None:
|
| 166 |
-
yield "資料庫讀取錯誤
|
| 167 |
return
|
| 168 |
|
| 169 |
name = str(restaurant['Name'])
|
| 170 |
url = str(restaurant.get('URL', f'https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(name)}'))
|
| 171 |
-
note = "(隨機推薦)" if is_random else ""
|
| 172 |
-
|
| 173 |
-
# 1. 處理 RAG
|
| 174 |
rag_info = str(restaurant.get('RAG_Content', ''))
|
| 175 |
if retriever:
|
| 176 |
try:
|
|
@@ -178,29 +151,17 @@ def mood_agent_logic(score_input, food_input, diary_input, debug_mode):
|
|
| 178 |
if docs: rag_info = "\n".join([d.page_content for d in docs])
|
| 179 |
except: pass
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
if isinstance(csv_prompt, pd.Series): csv_prompt = csv_prompt.iloc[0]
|
| 190 |
-
if pd.notna(csv_prompt) and str(csv_prompt).strip() != "":
|
| 191 |
-
img_prompt = str(csv_prompt)
|
| 192 |
-
prompt_source = "✅ CSV 檔案"
|
| 193 |
-
except: pass
|
| 194 |
-
|
| 195 |
-
# 3. 呼叫 LLM (移除了 quiz_state)
|
| 196 |
ai_text = generate_content_with_groq(name, rag_info, diary_input, score_input)
|
| 197 |
-
|
| 198 |
-
#
|
| 199 |
-
debug_text = ""
|
| 200 |
-
if debug_mode:
|
| 201 |
-
debug_text = f"\n\n---\n**🛠️ Prompt 來源**: {prompt_source}\n**Prompt**: `{img_prompt}`"
|
| 202 |
-
|
| 203 |
-
final_response = f"### 🍽️ 推薦:{name} {note}\n\n{ai_text}{debug_text}"
|
| 204 |
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>'
|
| 205 |
|
| 206 |
yield final_response, None, map_html
|
|
@@ -208,16 +169,11 @@ def mood_agent_logic(score_input, food_input, diary_input, debug_mode):
|
|
| 208 |
yield final_response, image_output, map_html
|
| 209 |
|
| 210 |
# ==========================================
|
| 211 |
-
#
|
| 212 |
# ==========================================
|
| 213 |
|
| 214 |
-
|
| 215 |
-
emotion_text_obj = {
|
| 216 |
-
'angry': '生氣', 'disgust': '噁心', 'fear': '害怕',
|
| 217 |
-
'happy': '開心', 'sad': '難過', 'surprise': '驚訝', 'neutral': '正常'
|
| 218 |
-
}
|
| 219 |
|
| 220 |
-
# 繪圖函式 (維持不變)
|
| 221 |
def putText(img, x, y, text, size=50, color=(255, 255, 255)):
|
| 222 |
try:
|
| 223 |
fontpath = 'NotoSansTC-VariableFont_wght.ttf'
|
|
@@ -228,64 +184,68 @@ def putText(img, x, y, text, size=50, color=(255, 255, 255)):
|
|
| 228 |
displayText = emotion_text_obj.get(text, text)
|
| 229 |
draw.text((x, y), displayText, fill=color, font=font)
|
| 230 |
return np.array(imgPil)
|
| 231 |
-
except:
|
| 232 |
-
return img
|
| 233 |
|
| 234 |
-
#
|
| 235 |
-
def
|
| 236 |
-
# 切換狀態 (True <-> False)
|
| 237 |
-
new_state = not is_recording
|
| 238 |
-
|
| 239 |
-
if new_state:
|
| 240 |
-
# 如果變成錄製中,按鈕變成紅色「停止」
|
| 241 |
-
return new_state, gr.Button(value="🛑 停止錄製 (鎖定結果)", variant="stop")
|
| 242 |
-
else:
|
| 243 |
-
# 如果變成停止,按鈕變成綠色「開始」
|
| 244 |
-
return new_state, gr.Button(value="📸 開始即時偵測", variant="secondary")
|
| 245 |
-
|
| 246 |
-
# ★ 修改:偵測邏輯 (加入 is_recording 判斷)
|
| 247 |
-
def detect_emotion_stream(frame, is_recording):
|
| 248 |
-
# 如果沒有在錄製,或是沒有畫面,就什麼都不更新 (讓畫面凍結在最後一幀)
|
| 249 |
-
if not is_recording or frame is None:
|
| 250 |
-
return gr.update(), gr.update()
|
| 251 |
-
|
| 252 |
detected_emotion = "neutral"
|
| 253 |
mapped_score = "3 (普通)"
|
| 254 |
-
|
| 255 |
try:
|
| 256 |
-
# 1. 辨識情緒
|
| 257 |
analyze = DeepFace.analyze(frame, actions=['emotion'], enforce_detection=False)
|
| 258 |
if isinstance(analyze, list): analyze = analyze[0]
|
| 259 |
detected_emotion = analyze['dominant_emotion']
|
| 260 |
|
| 261 |
-
# 2. 畫在圖片上
|
| 262 |
frame = putText(frame, 20, 40, detected_emotion)
|
| 263 |
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
elif detected_emotion
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
elif detected_emotion in ['sad', 'fear']:
|
| 272 |
-
mapped_score = "2 (不太好)"
|
| 273 |
-
elif detected_emotion in ['angry', 'disgust']:
|
| 274 |
-
mapped_score = "1 (心情差)"
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
# ==========================================
|
| 282 |
-
#
|
| 283 |
# ==========================================
|
| 284 |
|
| 285 |
with gr.Blocks(title="AI 心情食堂") as demo:
|
| 286 |
|
| 287 |
-
#
|
| 288 |
-
|
|
|
|
| 289 |
|
| 290 |
with gr.Column(visible=True) as main_app_col:
|
| 291 |
gr.Markdown(f"## 🍱 AI 心情食堂導航")
|
|
@@ -294,16 +254,19 @@ with gr.Blocks(title="AI 心情食堂") as demo:
|
|
| 294 |
with gr.Row():
|
| 295 |
# 左側:輸入區
|
| 296 |
with gr.Column(scale=1):
|
|
|
|
| 297 |
|
| 298 |
-
gr.Markdown("### 📸 步驟 1:心情偵測 (選用)")
|
| 299 |
with gr.Row():
|
| 300 |
# 左邊:即時鏡頭
|
| 301 |
webcam_input = gr.Image(sources=["webcam"], label="即時鏡頭", streaming=True)
|
| 302 |
-
# 右邊:結果
|
| 303 |
-
captured_image = gr.Image(label="
|
| 304 |
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
gr.Markdown("### 📝 步驟 2:確認與補充")
|
| 309 |
score_input = gr.Radio(
|
|
@@ -312,35 +275,42 @@ with gr.Blocks(title="AI 心情食堂") as demo:
|
|
| 312 |
value="3 (普通)"
|
| 313 |
)
|
| 314 |
food_input = gr.Radio(["吃飯", "吃麵", "隨便"], label="2. 想吃什麼", value="隨便")
|
| 315 |
-
diary_input = gr.Textbox(lines=3, label="3. 心情日記
|
| 316 |
|
| 317 |
-
debug_mode_btn = gr.Checkbox(label="🔧
|
| 318 |
submit_btn = gr.Button("🚀 送出給 Agent", variant="primary")
|
| 319 |
|
| 320 |
-
# 右側:
|
| 321 |
with gr.Column(scale=1):
|
| 322 |
agent_output = gr.Markdown(label="AI 回應")
|
| 323 |
image_output = gr.Image(label="AI 推薦美食圖", type="pil", width=400)
|
| 324 |
map_output = gr.HTML(label="地圖導航")
|
| 325 |
|
| 326 |
-
#
|
| 327 |
-
|
| 328 |
-
#
|
| 329 |
-
|
| 330 |
-
fn=
|
| 331 |
-
inputs=[
|
| 332 |
-
outputs=[
|
| 333 |
)
|
| 334 |
|
| 335 |
-
#
|
| 336 |
-
# 它會檢查 recording_state,如果是 True 才會更新右邊的圖
|
| 337 |
webcam_input.stream(
|
| 338 |
-
fn=
|
| 339 |
-
inputs=[webcam_input,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
outputs=[captured_image, score_input]
|
| 341 |
)
|
| 342 |
|
| 343 |
-
#
|
| 344 |
submit_btn.click(
|
| 345 |
fn=mood_agent_logic,
|
| 346 |
inputs=[score_input, food_input, diary_input, debug_mode_btn],
|
|
|
|
| 20 |
warnings.filterwarnings("ignore")
|
| 21 |
|
| 22 |
# ==========================================
|
| 23 |
+
# 0. 環境變數與 RAG 初始化 (維持不變)
|
| 24 |
# ==========================================
|
| 25 |
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
|
| 26 |
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 27 |
|
| 28 |
+
# --- 資料讀取 ---
|
| 29 |
df = pd.DataFrame()
|
| 30 |
try:
|
|
|
|
| 31 |
df = pd.read_csv('restaurants.csv', engine='python')
|
| 32 |
+
df.columns = df.columns.str.strip()
|
| 33 |
|
| 34 |
def classify_mood(row):
|
| 35 |
name_str = str(row.get('Name', ''))
|
|
|
|
| 37 |
text = (name_str + " " + rag_str).lower()
|
| 38 |
tags = []
|
| 39 |
rules = {
|
| 40 |
+
"開心/慶祝": ["牛排", "steak", "pizza", "炸", "甜點", "蛋糕", "buffet", "bistro"],
|
| 41 |
+
"傷心/疲憊": ["粥", "湯", "warm", "麵", "小吃", "豆花", "soup"],
|
| 42 |
+
"生氣/發洩": ["辣", "spicy", "麻辣", "鍋", "curry", "燒肉", "bbq", "fry"],
|
| 43 |
+
"平靜/放鬆": ["cafe", "coffee", "tea", "素食", "brunch", "sushi", "居酒屋"]
|
| 44 |
}
|
| 45 |
for mood, keywords in rules.items():
|
| 46 |
for kw in keywords:
|
|
|
|
| 51 |
|
| 52 |
if 'Mood_Tags' not in df.columns:
|
| 53 |
df['Mood_Tags'] = df.apply(classify_mood, axis=1)
|
| 54 |
+
print("✅ 成功讀取 restaurants.csv")
|
|
|
|
| 55 |
except Exception as e:
|
|
|
|
| 56 |
print(f"⚠️ 讀取 restaurants.csv 失敗: {e}")
|
| 57 |
|
| 58 |
+
# --- Prompt 資料庫 ---
|
| 59 |
df_prompts = pd.DataFrame()
|
| 60 |
PROMPT_COL_NAME = 'Visual_prompt'
|
|
|
|
| 61 |
try:
|
| 62 |
if not df.empty:
|
| 63 |
df_prompts = df.copy()
|
| 64 |
if 'Name' in df_prompts.columns:
|
| 65 |
df_prompts.set_index('Name', inplace=True)
|
| 66 |
+
except: pass
|
|
|
|
| 67 |
|
| 68 |
+
# --- RAG ---
|
|
|
|
| 69 |
retriever = None
|
| 70 |
if not df.empty and 'RAG_Content' in df.columns:
|
| 71 |
try:
|
|
|
|
| 76 |
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
|
| 77 |
vectorstore = FAISS.from_documents(docs, embeddings)
|
| 78 |
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
|
| 79 |
+
except: pass
|
|
|
|
| 80 |
|
| 81 |
# ==========================================
|
| 82 |
+
# 1. 核心功能函式 (餐廳推薦 - 維持不變)
|
| 83 |
# ==========================================
|
| 84 |
|
| 85 |
def get_restaurant_data(mood_score_str, food_choice):
|
| 86 |
if df.empty: return None, True
|
|
|
|
| 87 |
score_map = {
|
| 88 |
"1 (心情差)": ["傷心/疲憊", "生氣/發洩"],
|
| 89 |
"2 (不太好)": ["傷心/疲憊", "生氣/發洩"],
|
|
|
|
| 97 |
elif food_choice == "吃麵": food_keyword = "麵"
|
| 98 |
|
| 99 |
candidates = df.copy()
|
|
|
|
|
|
|
| 100 |
if target_moods:
|
| 101 |
pattern = "|".join(target_moods)
|
| 102 |
+
if 'Mood_Tags' in candidates.columns:
|
| 103 |
+
candidates = candidates[candidates['Mood_Tags'].str.contains(pattern, regex=True, na=False)]
|
| 104 |
|
| 105 |
if food_keyword:
|
| 106 |
candidates = candidates[
|
|
|
|
| 111 |
if candidates.empty: return df.sample(1).iloc[0], True
|
| 112 |
return candidates.sample(1).iloc[0], False
|
| 113 |
|
|
|
|
| 114 |
def generate_content_with_groq(restaurant_name, restaurant_detail, user_diary, mood_score):
|
| 115 |
+
if not GROQ_API_KEY: return "⚠️ 請設定 GROQ_API_KEY"
|
| 116 |
client = OpenAI(api_key=GROQ_API_KEY, base_url="https://api.groq.com/openai/v1")
|
| 117 |
system_prompt = "你是一個幽默、懂吃且善解人意的 AI 朋友。請根據使用者的日記和心情來推薦餐廳。"
|
| 118 |
+
user_msg = f"心情:{mood_score}\n日記:{user_diary}\n餐廳:{restaurant_name}\n資料:{restaurant_detail}\n請推薦這家餐廳並給予安慰或慶祝。"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
try:
|
| 120 |
response = client.chat.completions.create(
|
| 121 |
model="llama-3.3-70b-versatile",
|
|
|
|
| 132 |
return hf_client.text_to_image(prompt=prompt, model="stabilityai/stable-diffusion-xl-base-1.0")
|
| 133 |
except: return None
|
| 134 |
|
|
|
|
|
|
|
| 135 |
def mood_agent_logic(score_input, food_input, diary_input, debug_mode):
|
|
|
|
| 136 |
if not score_input:
|
| 137 |
+
yield "⚠️ 請先選擇心情分數!", None, ""
|
| 138 |
return
|
| 139 |
|
| 140 |
restaurant, is_random = get_restaurant_data(score_input, food_input)
|
| 141 |
if restaurant is None:
|
| 142 |
+
yield "資料庫讀取錯誤", None, ""
|
| 143 |
return
|
| 144 |
|
| 145 |
name = str(restaurant['Name'])
|
| 146 |
url = str(restaurant.get('URL', f'https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(name)}'))
|
|
|
|
|
|
|
|
|
|
| 147 |
rag_info = str(restaurant.get('RAG_Content', ''))
|
| 148 |
if retriever:
|
| 149 |
try:
|
|
|
|
| 151 |
if docs: rag_info = "\n".join([d.page_content for d in docs])
|
| 152 |
except: pass
|
| 153 |
|
| 154 |
+
img_prompt = f"Delicious food from {name}, cinematic lighting, 8k"
|
| 155 |
+
if not df_prompts.empty and name in df_prompts.index:
|
| 156 |
+
try:
|
| 157 |
+
val = df_prompts.loc[name, PROMPT_COL_NAME]
|
| 158 |
+
if isinstance(val, pd.Series): val = val.iloc[0]
|
| 159 |
+
if pd.notna(val): img_prompt = str(val)
|
| 160 |
+
except: pass
|
| 161 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
ai_text = generate_content_with_groq(name, rag_info, diary_input, score_input)
|
| 163 |
+
debug_text = f"\n\nPrompt: `{img_prompt}`" if debug_mode else ""
|
| 164 |
+
final_response = f"### 🍽️ 推薦:{name}\n\n{ai_text}{debug_text}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
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>'
|
| 166 |
|
| 167 |
yield final_response, None, map_html
|
|
|
|
| 169 |
yield final_response, image_output, map_html
|
| 170 |
|
| 171 |
# ==========================================
|
| 172 |
+
# 2. 情緒辨識整合模組
|
| 173 |
# ==========================================
|
| 174 |
|
| 175 |
+
emotion_text_obj = {'angry': '生氣', 'disgust': '噁心', 'fear': '害怕', 'happy': '開心', 'sad': '難過', 'surprise': '驚訝', 'neutral': '正常'}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
|
|
|
| 177 |
def putText(img, x, y, text, size=50, color=(255, 255, 255)):
|
| 178 |
try:
|
| 179 |
fontpath = 'NotoSansTC-VariableFont_wght.ttf'
|
|
|
|
| 184 |
displayText = emotion_text_obj.get(text, text)
|
| 185 |
draw.text((x, y), displayText, fill=color, font=font)
|
| 186 |
return np.array(imgPil)
|
| 187 |
+
except: return img
|
|
|
|
| 188 |
|
| 189 |
+
# 核心辨識函式 (重構以供重複使用)
|
| 190 |
+
def process_emotion(frame):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
detected_emotion = "neutral"
|
| 192 |
mapped_score = "3 (普通)"
|
|
|
|
| 193 |
try:
|
|
|
|
| 194 |
analyze = DeepFace.analyze(frame, actions=['emotion'], enforce_detection=False)
|
| 195 |
if isinstance(analyze, list): analyze = analyze[0]
|
| 196 |
detected_emotion = analyze['dominant_emotion']
|
| 197 |
|
|
|
|
| 198 |
frame = putText(frame, 20, 40, detected_emotion)
|
| 199 |
|
| 200 |
+
if detected_emotion == 'happy': mapped_score = "5 (超棒)"
|
| 201 |
+
elif detected_emotion == 'surprise': mapped_score = "4 (不錯)"
|
| 202 |
+
elif detected_emotion == 'neutral': mapped_score = "3 (普通)"
|
| 203 |
+
elif detected_emotion in ['sad', 'fear']: mapped_score = "2 (不太好)"
|
| 204 |
+
elif detected_emotion in ['angry', 'disgust']: mapped_score = "1 (心情差)"
|
| 205 |
+
except: pass
|
| 206 |
+
return frame, mapped_score
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
+
# 單次手動截圖
|
| 209 |
+
def manual_detect(frame):
|
| 210 |
+
if frame is None: return None, None
|
| 211 |
+
return process_emotion(frame)
|
| 212 |
+
|
| 213 |
+
# ★ 新增:切換錄製狀態按鈕邏輯
|
| 214 |
+
def toggle_recording_state(is_recording):
|
| 215 |
+
# 切換 True/False
|
| 216 |
+
new_state = not is_recording
|
| 217 |
+
# 根據新狀態改變按鈕文字
|
| 218 |
+
if new_state:
|
| 219 |
+
return new_state, "⏹️ 停止錄製 (即時偵測中)"
|
| 220 |
+
else:
|
| 221 |
+
return new_state, "🔴 開始錄製 (即時偵測)"
|
| 222 |
+
|
| 223 |
+
# ★ 新增:自動串流偵測 (包含限速邏輯)
|
| 224 |
+
def auto_stream_detect(frame, is_recording, last_time):
|
| 225 |
+
# 如果沒開錄製,或畫面空的,就跳過不做事 (節省資源)
|
| 226 |
+
if not is_recording or frame is None:
|
| 227 |
+
return gr.skip(), gr.skip(), last_time
|
| 228 |
|
| 229 |
+
# 限速:距離上次執行不到 1.0 秒,就跳過
|
| 230 |
+
current_time = time.time()
|
| 231 |
+
if current_time - last_time < 1.0:
|
| 232 |
+
return gr.skip(), gr.skip(), last_time
|
| 233 |
+
|
| 234 |
+
# 執行辨識
|
| 235 |
+
processed_frame, score = process_emotion(frame)
|
| 236 |
+
|
| 237 |
+
# 回傳:更新圖片, 更新分數, 更新最後執行時間
|
| 238 |
+
return processed_frame, score, current_time
|
| 239 |
|
| 240 |
# ==========================================
|
| 241 |
+
# 3. Gradio 介面建構
|
| 242 |
# ==========================================
|
| 243 |
|
| 244 |
with gr.Blocks(title="AI 心情食堂") as demo:
|
| 245 |
|
| 246 |
+
# 定義狀態變數 (看不見的變數)
|
| 247 |
+
is_recording = gr.State(False) # 是否正在錄製
|
| 248 |
+
last_process_time = gr.State(0) # 上次偵測的時間
|
| 249 |
|
| 250 |
with gr.Column(visible=True) as main_app_col:
|
| 251 |
gr.Markdown(f"## 🍱 AI 心情食堂導航")
|
|
|
|
| 254 |
with gr.Row():
|
| 255 |
# 左側:輸入區
|
| 256 |
with gr.Column(scale=1):
|
| 257 |
+
gr.Markdown("### 📸 步驟 1:心情偵測")
|
| 258 |
|
|
|
|
| 259 |
with gr.Row():
|
| 260 |
# 左邊:即時鏡頭
|
| 261 |
webcam_input = gr.Image(sources=["webcam"], label="即時鏡頭", streaming=True)
|
| 262 |
+
# 右邊:偵測結果
|
| 263 |
+
captured_image = gr.Image(label="偵測結果", interactive=False)
|
| 264 |
|
| 265 |
+
with gr.Row():
|
| 266 |
+
# 按鈕 1:錄製開關 (會變色變字)
|
| 267 |
+
record_btn = gr.Button("🔴 開始錄製 (即時偵測)", variant="stop")
|
| 268 |
+
# 按鈕 2:單次截圖 (維持不變)
|
| 269 |
+
snapshot_btn = gr.Button("📸 單次截圖", variant="secondary")
|
| 270 |
|
| 271 |
gr.Markdown("### 📝 步驟 2:確認與補充")
|
| 272 |
score_input = gr.Radio(
|
|
|
|
| 275 |
value="3 (普通)"
|
| 276 |
)
|
| 277 |
food_input = gr.Radio(["吃飯", "吃麵", "隨便"], label="2. 想吃什麼", value="隨便")
|
| 278 |
+
diary_input = gr.Textbox(lines=3, label="3. 心情日記", placeholder="選填...")
|
| 279 |
|
| 280 |
+
debug_mode_btn = gr.Checkbox(label="🔧 除錯資訊", value=False)
|
| 281 |
submit_btn = gr.Button("🚀 送出給 Agent", variant="primary")
|
| 282 |
|
| 283 |
+
# 右側:輸出區
|
| 284 |
with gr.Column(scale=1):
|
| 285 |
agent_output = gr.Markdown(label="AI 回應")
|
| 286 |
image_output = gr.Image(label="AI 推薦美食圖", type="pil", width=400)
|
| 287 |
map_output = gr.HTML(label="地圖導航")
|
| 288 |
|
| 289 |
+
# --- 事件綁定 ---
|
| 290 |
+
|
| 291 |
+
# 1. 錄製按鈕邏輯:點擊 -> 切換狀態 -> 改變按鈕文字
|
| 292 |
+
record_btn.click(
|
| 293 |
+
fn=toggle_recording_state,
|
| 294 |
+
inputs=[is_recording],
|
| 295 |
+
outputs=[is_recording, record_btn]
|
| 296 |
)
|
| 297 |
|
| 298 |
+
# 2. 串流偵測邏輯:鏡頭畫面改變 -> 檢查狀態 -> (如果錄製中) 執行偵測 -> 更新右邊圖片
|
|
|
|
| 299 |
webcam_input.stream(
|
| 300 |
+
fn=auto_stream_detect,
|
| 301 |
+
inputs=[webcam_input, is_recording, last_process_time],
|
| 302 |
+
outputs=[captured_image, score_input, last_process_time],
|
| 303 |
+
show_progress=False # 隱藏進度條,讓畫面更順暢
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# 3. 單次截圖邏輯 (維持不變)
|
| 307 |
+
snapshot_btn.click(
|
| 308 |
+
fn=manual_detect,
|
| 309 |
+
inputs=[webcam_input],
|
| 310 |
outputs=[captured_image, score_input]
|
| 311 |
)
|
| 312 |
|
| 313 |
+
# 4. 送出 Agent
|
| 314 |
submit_btn.click(
|
| 315 |
fn=mood_agent_logic,
|
| 316 |
inputs=[score_input, food_input, diary_input, debug_mode_btn],
|