diff --git a/.gitattributes b/.gitattributes index 193767fcee5bc61540196fca6d0b6ec512e4f495..215ac357f1f78ba90c3ae465a05b57733034510d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -38,3 +38,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.wav filter=lfs diff=lfs merge=lfs -text *.mp3 filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.avif filter=lfs diff=lfs merge=lfs -text +*.apk filter=lfs diff=lfs merge=lfs -text +*.JPEG filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index 8f040e48cc16c63e4e552345b5aa4cba0a4c349a..ea4a85eaaf10b15f7b004bfbad85a9d404be17d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.11-slim -# Install system dependencies required by OpenCV headless, Pygame, and MediaPipe +# System deps for OpenCV headless, Pygame, MediaPipe + CJK fonts RUN apt-get update && apt-get install -y --no-install-recommends \ libglib2.0-0 \ libgl1 \ @@ -8,31 +8,33 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libgles2 \ libegl1 \ fonts-wqy-zenhei \ - fontconfig \ + fonts-wqy-microhei \ + fonts-noto-cjk \ && rm -rf /var/lib/apt/lists/* -# Set up environment variables ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ SDL_VIDEODRIVER=dummy \ SDL_AUDIODRIVER=dummy \ - PORT=7860 + PORT=7860 \ + MAX_CONCURRENT_RUNNERS=3 \ + SESSION_TIMEOUT_SEC=900 \ + PROJECT_CACHE_TTL_SEC=10 -# Create working directory and copy dependencies WORKDIR /app COPY requirements.txt . -# Install dependencies +# Base image without TensorFlow (smaller / faster cold start). +# To include Keras demos: COPY requirements-ml.txt . && pip install -r requirements-ml.txt RUN pip install --no-cache-dir -r requirements.txt -# Copy application files COPY . . -# Create directory for projects if it doesn't exist -RUN mkdir -p /app/projects +RUN mkdir -p /app/projects /app/models /app/static \ + && chmod -R 777 /app -# Set permissions for Hugging Face Spaces compatibility (runs as UID 1000) -RUN chmod -R 777 /app +# Healthcheck for orchestrators +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % __import__('os').environ.get('PORT','7860'))" || exit 1 -# Run server.py on port specified by environment CMD uvicorn server:app --host 0.0.0.0 --port $PORT diff --git a/models/README.md b/models/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a9b16418d4cc758578b58cc016246bea2b6bcec9 --- /dev/null +++ b/models/README.md @@ -0,0 +1,20 @@ +# Shared MediaPipe models + +Large `.task` model files live here once and are linked into student project +directories at runtime by `runner.py` when missing. + +| File | Used by | +|------|---------| +| `hand_landmarker.task` | Hand / pinch / gesture projects | +| `face_landmarker.task` | Face mesh / eye-distance projects | + +Student code may still use: + +```python +os.path.join(os.path.dirname(__file__), "hand_landmarker.task") +``` + +The runner creates a symlink (or hard link) from the project folder to this +directory before executing `main.py`. + +Do not commit duplicate copies under `projects/**`. diff --git "a/projects/2026Spring\345\260\210\351\241\214\347\217\255/\350\203\241\346\203\237\345\252\227/\350\207\211\351\203\250\350\276\250\350\255\230/face_landmarker.task" b/models/face_landmarker.task similarity index 100% rename from "projects/2026Spring\345\260\210\351\241\214\347\217\255/\350\203\241\346\203\237\345\252\227/\350\207\211\351\203\250\350\276\250\350\255\230/face_landmarker.task" rename to models/face_landmarker.task diff --git "a/projects/2026Spring\345\260\210\351\241\214\347\217\255/\350\203\241\346\203\237\345\252\227/\346\211\213\351\203\250\350\276\250\350\255\230/hand_landmarker.task" b/models/hand_landmarker.task similarity index 100% rename from "projects/2026Spring\345\260\210\351\241\214\347\217\255/\350\203\241\346\203\237\345\252\227/\346\211\213\351\203\250\350\276\250\350\255\230/hand_landmarker.task" rename to models/hand_landmarker.task diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/DataFrame\346\214\207\344\273\244\347\273\203\344\271\240/d1_1_pandas_intro.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/DataFrame\346\214\207\344\273\244\347\273\203\344\271\240/d1_1_pandas_intro.py" new file mode 100644 index 0000000000000000000000000000000000000000..8498f11a8b54d718d72b66f1c86f0ddb12979161 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/DataFrame\346\214\207\344\273\244\347\273\203\344\271\240/d1_1_pandas_intro.py" @@ -0,0 +1,37 @@ +# d1_1_pandas_intro.py +# Day 1 學員填空:Pandas 基礎(完成 TODO 後執行) +# cd examples/Day1/student && python3 d1_1_pandas_intro.py +# 參考答案:../Teacher/d1_1_pandas_intro.py + +import pandas as pd + +df = pd.read_csv("taipei_weather_2026.csv") +print("=== 數據載入成功! ===") + +print("\n--- 1. head ---") +print(df.head()) + +print("\n--- 2. info ---") +df.info() + +print("\n--- 3. describe ---") +print(df.describe()) + +print("\n--- 4. Temperature 前 5 筆 ---") +print(df['Temperature'].head()) + +print("\n--- 5. Date 與 Rainfall ---") +print(df[['Rainfall', 'Date']].head()) + +print("\n--- 挑戰:最高溫與平均濕度 ---") +max_temp = df['Temperature'].max() +avg_humidity = df['Humidity'].mean() +min_rainfall = df['Rainfall'].min() +print(f"最高溫度:{max_temp}°C") +print(f"平均濕度:{avg_humidity:.2f}%") +print(f"最低降雨:{min_rainfall:.2f}%") + +print("\n--- whether it is good for egyptians") + +egy = df[(df['Temperature'] > 27) & (df['Rainfall'] == 0)] +print(egy) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/DataFrame\346\214\207\344\273\244\347\273\203\344\271\240/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/DataFrame\346\214\207\344\273\244\347\273\203\344\271\240/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/DataFrame\346\214\207\344\273\244\347\273\203\344\271\240/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/Day1/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/Day1/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/Day1/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/Day1/taipei_weather_2026.png" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/Day1/taipei_weather_2026.png" new file mode 100644 index 0000000000000000000000000000000000000000..a0ef498a8d36ed10e6e1b10835d501a77b21d78c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/Day1/taipei_weather_2026.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9833c461c3bbf504f26e23582cb95fc4f9077c49a7a214435b29a5d0ac94a107 +size 276991 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/ai_api\344\270\262\346\216\245\347\273\203\344\271\240/d2_1_gemini.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/ai_api\344\270\262\346\216\245\347\273\203\344\271\240/d2_1_gemini.py" new file mode 100644 index 0000000000000000000000000000000000000000..7f56d7320a3e87a1fe7221075233544b5f31dec1 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/ai_api\344\270\262\346\216\245\347\273\203\344\271\240/d2_1_gemini.py" @@ -0,0 +1,41 @@ +# d2_1_gemini.py — Day2 學員填空 +# cd examples/Day2/Student && python3 d2_1_gemini.py +# 參考答案:../Teacher/d2_1_gemini.py + +import os +from google import genai + +# === 【學員填空練習點 1:設置與載入 API Key】 === +# TODO: 嘗試從環境變數 GEMINI_API_KEY 取得 API Key。如果未設定,則使用 input() 提示手動輸入。 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6LgriztRWiwQOdkK1wXmmfs4AzlZKSQsP2X0cnjzaMCCg") + +if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY。") + print(" 請先設定:export GEMINI_API_KEY=\"your_key\"") + print(" 或於下方手動輸入(僅供本機測試,勿寫死在檔案中上傳):") + api_key = input("API Key: ").strip() + if not api_key: + print("[結束] 未提供 API Key。可改練習 d2_1_ollama_local.py。") + exit(0) + +# === 【學員填空練習點 2:設定模型名稱】 === +# TODO: 設定使用的 Gemini 模型名稱(例如 'gemini-3.1-flash-lite') +model_name = "gemini-3.1-flash-lite" + +print(f"正在連線至 Google Cloud Model ({model_name})...") + +# === 【學員填空練習點 3:初始化 Gemini Client】 === +# TODO: 呼叫 genai.Client 並傳入 api_key 參數來初始化 Client 物件,指派給 client 變數 +client = genai.Client(api_key=api_key) + +# === 【學員填空練習點 4:發送內容生成請求】 === +# TODO: 使用 client.models.generate_content 呼叫模型,傳入 model 參數與 contents 提示詞 +# 提示詞內容:請模型以繁體中文解釋什麼是『API 串接』,並用餐廳點餐作為比喻,字數約 100 字。 +response = client.models.generate_content(model=model_name, contents="请用中文解释为什么泡面很好吃,字数一百字以内") +# === 【學員填空練習點 5:讀取並印出回應】 === +# TODO: 從 response 物件中取得 text 欄位內容,並列印出來。 +print("\n=== AI 回應結果 ===") +# 請在下方實作列印邏輯: +print(response.text) +# for i in range(10): +# print(response.text) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock&AI_api\345\217\214\346\250\241\345\274\217/d2_3_chatbot_memory.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock&AI_api\345\217\214\346\250\241\345\274\217/d2_3_chatbot_memory.py" new file mode 100644 index 0000000000000000000000000000000000000000..32ebc5bf66fa42e8ea1e31329897c8fe436f73c5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock&AI_api\345\217\214\346\250\241\345\274\217/d2_3_chatbot_memory.py" @@ -0,0 +1,95 @@ +# d2_3_chatbot_memory.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (Gemini / Local Mock 雙模) + +import os +from google import genai + +# 優先讀取環境變數 GEMINI_MODEL,預設為 gemini-3.1-flash-lite +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get( + "GEMINI_API_KEY", "") + + +def local_mock_response(user_input, history): + user_input_lower = user_input.lower() + + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "營業時間" in user_input_lower or "時間" in user_input_lower: + return "我們觀測站的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + # elif "XX" in user_input_lower or "XX" in user_input_lower: + # return "XXXXXXXXXXXXXXXXX" + + + else: + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (支援對話上下文記憶)") + print("=" * 50) + + use_mock = False + chat_session = None + + if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY,將自動啟用「離線模擬模式 (Local Mock Mode)」。") + print(" (如需連線雲端模型,請設定 GEMINI_API_KEY 環境變數)") + use_mock = True + else: + try: + print(f"正在連線 Google Gemini 雲端客服伺服器 ({GEMINI_MODEL})...") + client = genai.Client(api_key=api_key) + # TODO: 設定系統指令 (System Instruction / System Prompt) + # system_instruction = ( + # "你是一位專業的得能教育客服人員,語氣溫和客氣," + # "XXXXXXXXXXXXXXXXXXXXXXX" + # ) + + chat_session = client.chats.create( + model=GEMINI_MODEL, + config={'system_instruction': system_instruction} + ) + print("雲端客服系統已就緒!") + except Exception as e: + print(f"連線雲端客服失敗 ({e}),切換至離線模擬模式...") + use_mock = True + + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + if use_mock: + ai_reply = local_mock_response(user_input, local_history) + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + else: + response = chat_session.send_message(user_input) + ai_reply = response.text + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock&AI_api\345\217\214\346\250\241\345\274\217/d2_4_chatbot_memory.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock&AI_api\345\217\214\346\250\241\345\274\217/d2_4_chatbot_memory.py" new file mode 100644 index 0000000000000000000000000000000000000000..20ff7a1187c6062f5d0f30f195ecabbbd1bb3e85 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock&AI_api\345\217\214\346\250\241\345\274\217/d2_4_chatbot_memory.py" @@ -0,0 +1,129 @@ +# d2_3_chatbot_memory.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (Gemini / Local Mock 雙模) + +import os +from google import genai + +# 優先讀取環境變數 GEMINI_MODEL,預設為 gemini-3.1-flash-lite +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get( + "GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + + +def local_mock_response(user_input, history): + user_input_lower = user_input.lower() + + if "營業時間" in user_input_lower or "時間" in user_input_lower: + return "我们冰淇淋店的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + elif "地址" in user_input_lower or "地点" in user_input_lower or "地方" in user_input_lower: + return "我们的店在求毛星球毛笔城市QQ镇喜喜街167号一楼。" + elif "冰淇淋的口味" in user_input_lower or "口味" in user_input_lower: + return "我们的冰淇淋的口味有草莓,芒果,巧克力,咖啡,蓝莓,汽水和芭乐。" + elif "冰淇淋的价格" in user_input_lower or "价格" in user_input_lower or "冰淇淋怎么卖" in user_input_lower: + return "一球100元,两球170元,三球250元,五球400元,十球750元。" + elif "有打折吗" in user_input_lower or "有优惠吗" in user_input_lower or "有进行促销活动吗" in user_input_lower or "有活动吗" in user_input_lower or "折扣" in user_input_lower: + return "我们有和吃鸡炸鸡店合作,去那里消费满1000元即可换一球冰淇淋。" + elif "help" in user_input_lower or "帮助" in user_input_lower: + return "\n輸入「營業時間、時間」即可知道營業時間\n輸入「地址、地點、地方」即可知道地址\n輸入「冰淇淋的口味、口味」即可知道口味\n輸入「冰淇淋的價格、價格、冰淇淋怎麼賣」即可知道價格\n輸入「有打折嗎、有優惠嗎、有進行促銷活動嗎、有活動嗎、折扣」即可知道優惠資訊\n輸入「停車場、停車、附近有沒有停車場」即可知道停車資訊\n輸入「可以客製化嗎、客製化的服務、客製化」即可知道客製化服務\n輸入「客製化的價錢、客製化要多少錢」即可知道客製化價格\n輸入「推薦、招牌」即可知道推薦口味\n輸入「外帶、內用」即可知道用餐方式\n輸入「付款、支付、可以刷卡」即可知道付款方式。" + elif "你好" in user_input_lower or "hello" in user_input_lower or "hi" in user_input_lower or "Hi" in user_input_lower: + return "你好!请问您需要什么帮助吗?" + elif "停车场" in user_input_lower or "停车" in user_input_lower or "附近有没有停车场" in user_input_lower: + return "我们的店附近有三个停车场,步行三分钟即可到达我们的店。" + elif "可以克制化吗" in user_input_lower or "客制化的服务" in user_input_lower or "客制化" in user_input_lower: + return "我们的店可以克制化冰淇淋,可以选择彩糖、珍珠、草莓酱、巧克力酱、布丁、奶油和炼乳。" + elif "客制化的价钱" in user_input_lower or "客制化要多少钱" in user_input_lower or "客制化要多少钱?" in user_input_lower: + return "客制化每一种材料都要再加15元。" + elif "推薦" in user_input_lower or "招牌" in user_input_lower: + return "推薦您試試草莓、巧克力和芭樂口味,都是熱門選擇。" + elif "外帶" in user_input_lower or "內用" in user_input_lower: + return "我們提供內用及外帶服務。" + elif "付款" in user_input_lower or "支付" in user_input_lower or "可以刷卡" in user_input_lower: + return "我們接受現金、信用卡及行動支付。" + elif "谢谢" in user_input_lower or "Thank you" in user_input_lower: + return "不客气!还需要什么帮助吗?" + + else: + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 吃吃吃冰淇淋店客服對話系統 (支援對話上下文記憶)") + print("=" * 50) + + use_mock = False + chat_session = None + + if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY,將自動啟用「離線模擬模式 (Local Mock Mode)」。") + print(" (如需連線雲端模型,請設定 GEMINI_API_KEY 環境變數)") + use_mock = True + else: + try: + print(f"正在連線 Google Gemini 雲端客服伺服器 ({GEMINI_MODEL})...") + client = genai.Client(api_key=api_key) + # TODO: 設定系統指令 (System Instruction / System Prompt) + system_instruction = ( + "你是一位冰淇淋店的客服,负责提供资讯" + "我们冰淇淋店的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + "我们的店在求毛星球毛笔城市QQ镇喜喜街167号一楼。" + "冰淇淋的价格一球100元,两球170元,三球250元,五球400元,十球750元。" + "我们的店有进行优惠,有和吃鸡炸鸡店合作,去那里消费满1000元即可换一球冰淇淋。" + "这是我们的资讯:輸入「營業時間、時間」即可知道營業時間,輸入「地址、地點、地方」即可知道地址,輸入「冰淇淋的口味、口味」即可知道口味,輸入「冰淇淋的價格、價格、冰淇淋怎麼賣」即可知道價格,輸入「有打折嗎、有優惠嗎、有進行促銷活動嗎、有活動嗎、折扣」即可知道優惠資訊,輸入「停車場、停車、附近有沒有停車場」即可知道停車資訊,輸入「可以客製化嗎、客製化的服務、客製化」即可知道客製化服務,輸入「客製化的價錢、客製化要多少錢」即可知道客製化價格,輸入「推薦、招牌」即可知道推薦口味,輸入「外帶、內用」即可知道用餐方式,輸入「付款、支付、可以刷卡」即可知道付款方式。" + "你应该亲切的向客户问好。" + "我们的店附近有三个停车场,步行三分钟即可到达我们的店。" + "我们的店可以克制化冰淇淋,可以选择彩糖、珍珠、草莓酱、巧克力酱、布丁、奶油和炼乳。" + "客制化每一种材料都要再加15元。" + "我們提供內用及外帶服務。" + "我們接受現金、信用卡及行動支付。" + ) + + chat_session = client.chats.create( + model=GEMINI_MODEL, + config={'system_instruction': system_instruction} + ) + print("雲端客服系統已就緒!") + except Exception as e: + print(f"連線雲端客服失敗 ({e}),切換至離線模擬模式...") + use_mock = True + + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + if use_mock: + ai_reply = local_mock_response(user_input, local_history) + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + else: + response = chat_session.send_message(user_input) + ai_reply = response.text + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock_\345\205\263\351\224\256\350\257\215\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock_\345\205\263\351\224\256\350\257\215\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" new file mode 100644 index 0000000000000000000000000000000000000000..659ddaf8705bb6d811ffc751973aebbd7e79cb25 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock_\345\205\263\351\224\256\350\257\215\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" @@ -0,0 +1,84 @@ +# d2_3_chatbot_memory_mock.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (純 Local Mock 教師版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + _ 根據使用者輸入與歷史對話紀錄,模擬客服助理的自動回覆。 + 在沒有網路或 API 金鑰時,如何透過關鍵字比對 + 與對話歷史長度(計算對話輪數)來模擬一個具有「記憶」能力的對話流程。 + """ + user_input_lower = user_input.lower() + + # 關鍵字比對:模擬常見的客服問答 + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "營業時間" in user_input_lower or "時間" in user_input_lower: + return "我们冰淇淋店的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + elif "地址" in user_input_lower or "地点" in user_input_lower or "地方" in user_input_lower: + return "我们的店在求毛星球毛笔城市QQ镇喜喜街167号一楼。" + elif "冰淇淋的口味" in user_input_lower or "口味" in user_input_lower: + return "我们的冰淇淋的口味有草莓,芒果,巧克力,咖啡,蓝莓,汽水和芭乐。" + elif "冰淇淋的价格" in user_input_lower or "价格" in user_input_lower or "冰淇淋怎么卖" in user_input_lower: + return "一球100元,两球170元,三球250元,五球400元,十球750元。" + elif "有打折吗" in user_input_lower or "有优惠吗" in user_input_lower or "有进行促销活动吗" in user_input_lower or "有活动吗" in user_input_lower or "折扣" in user_input_lower: + return "我们有和吃鸡炸鸡店合作,去那里消费满1000元即可换一球冰淇淋。" + elif "help" in user_input_lower or "帮助" in user_input_lower: + return "\n輸入「營業時間、時間」即可知道營業時間\n輸入「地址、地點、地方」即可知道地址\n輸入「冰淇淋的口味、口味」即可知道口味\n輸入「價格、冰淇淋怎麼卖、冰淇淋的价格」即可知道價格\n輸入「折扣、優惠、活動」即可知道優惠資訊。" + elif "你好" in user_input_lower or "hello" in user_input_lower or "hi" in user_input_lower or "Hi" in user_input_lower: + return "你好!请问您需要什么帮助吗?" + + else: + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 吃吃吃冰淇淋店客服對話系統 (純離線模擬模式 - 支援對話記憶)") + print("=" * 50) + print("[提示] 目前為純 Local Mock 模式,不需設定環境變數與連線雲端 API。") + print("-" * 50) + + # 用於儲存對話歷史的串列 + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + print("如果你不知道要输入什么,输入help或者帮助。") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + # 結束條件判斷 + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + # 呼叫模擬回覆函式 + ai_reply = local_mock_response(user_input, local_history) + + # 將本次對話記錄寫入歷史紀錄中,維持記憶狀態 + # Why: 每次成功的問答都必須 append 進 history,以便下一輪對話時能正確累計輪數。 + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + # 處理使用者按下 Ctrl+C 強行結束的情境,避免程式崩潰 + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock_\345\257\206\345\256\244\351\200\203\350\204\261\346\270\270\346\210\217/d2_3_chatbot_memory_game_template.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock_\345\257\206\345\256\244\351\200\203\350\204\261\346\270\270\346\210\217/d2_3_chatbot_memory_game_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..7b53f82e1a1cd14216a4cdcb3368943cb08f43e8 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/mock_\345\257\206\345\256\244\351\200\203\350\204\261\346\270\270\346\210\217/d2_3_chatbot_memory_game_template.py" @@ -0,0 +1,118 @@ +# d2_3_chatbot_memory_game_template.py +# Day 2: 生成式 AI 串接實戰 - 離線 Mock 遊戲設計模板 (學生填空版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬 NPC 或是系統的對話反應。 + + Why: + 此檔案為困難版對話記憶解謎的設計模板。你將學習到: + 1. 前置狀態限制:必須先問過「線索一」,才能查到「線索二」... + 2. 動態 Flag:Flag 答案中的某個線索會隨提問次數(turns)改變。 + """ + user_input_lower = user_input.strip().lower() + + # 計算目前是第幾輪對話 + turns = len(history) // 2 + 1 + + # TODO: 設計你的動態變數(例如 turns * 5,或是 current_weather 等) + # 並設定最終預期的正確 Flag 答案(必須包含此動態變數) + expected_flag = "flag}.{...add_8_sprinkles_ice_cream_Chocolate_flavored...}.{," + + # 從歷史中判斷先前是否已取得前置線索(在 history 中,AI model 是否曾回覆過特定字眼) + # TODO: 依據你設計的線索關鍵字,修改以下 history 的 any 比對條件 + asked_clue1 = any("Chocolate_flavored" in h["text"] for h in history if h["role"] == "model") + asked_clue2 = any("_ice_cream_" in h["text"] for h in history if h["role"] == "model") + + # 1. 驗證 Flag 邏輯 + if user_input_lower == expected_flag: + return f"🎉 恭喜你!成功破解案件並獲得了神秘的冰淇淋配方!你在第 {turns} 回合成功通關!" + + elif "flag{" in user_input_lower: + return ( + f"❌ Flag 驗證失敗!\n" + f"提示:最後一個線索會隨著提問次數動態改變。\n" + f"你現在是在第 {turns} 次提問輸入,因此當前的動態線索應該是 turns{turns} 喔!\n" + f"請重新計算並拼湊最新的 Flag!" + ) + + # 2. 劇情關鍵字問答分支(打造你的 NPC 對話樹!) + # TODO: 依據你的故事背景,修改並設計以下 if-elif-else 結構 + if "flag" in user_input_lower or "答案" in user_input_lower: + return "NPC:『想要解開這起謎題,你必須向我打聽案子的「原因」、「重要物品」與「凶器」!』" + + elif "原因" in user_input_lower or "谁做的" in user_input_lower: + # 線索一:靜態線索 + return "【線索一】\n这个案件是暖暖火锅店做的,\n因为他说每次顾客都说吃完了火锅很冷,\n可是他们吃完就去吃冰淇淋,所以才会感觉很冷。\n有一天,他就去了吃吃吃冰淇淋店复仇,\n让他们的老板踩到掉到地上的巧克力冰淇淋,并把他绑起来了。\nChocolate_flavored,记住这个口味!" + + elif "重要物品" in user_input_lower or "物品" in user_input_lower or "重要的物品" in user_input_lower: + # 線索二:有前置限制,必須先取得線索一 + if not asked_clue1: + return "NPC:『你連发生了什么事都不知道,我才不告訴你重要物品是什么呢!』" + return "【線索二】\n他在绑架了老板以后,他就把神秘的冰淇淋配方藏了起来。\n_ice_cream_, 记住这个线索!" + + elif "凶器" in user_input_lower or "武器" in user_input_lower or "線索三" in user_input_lower: + # 線索三:動態線索,包含 turns 變數,且必須先取得線索二 + if not asked_clue1: + return "NPC:『你連发生了什么事都不知道,我才不告訴你凶器是什么呢!』" + if not asked_clue2: + return "NPC:『你必須先查出「重要的物品」,我才會告訴你凶器是什么。』" + return f"【線索三】犯人用的凶器是一种配料,在里面加了安眠药,所以最后一个线索是add_8_sprinkles。" + elif "hint" in user_input_lower: + return "321" + + elif "拼湊" in user_input_lower or "結合" in user_input_lower or "怎麼組合" in user_input_lower: + return "提示:將【線索一】【線索二】【當下提問的線索三】依序結合,並用小寫的 flag}.{... ...}.{,包起來。" + + # 3. 兜底回覆(當玩家輸入沒有匹配到任何關鍵字時) + else: + return ( + f"NPC:『你在說什麼?我聽不懂。收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?』" + ) + + + +def main(): + # TODO: 修改為你的遊戲標題與故事背景 + print("=" * 60) + print(" 🕵️ 我設計的第一個具解謎遊戲 - 冰淇淋店店长失踪案 🕵️") + print("=" * 60) + print("本次密室逃脱体验与吃吃吃冰淇淋店合作") + print("=" * 60) + print("[故事簡介] 你是一位偵探,必須利用歷史對話記憶拼湊線索。") + print("傳說中,有一家聞名世界的冰淇淋店,憑藉一份神秘配方製作出無人能複製的美味冰淇淋。\n然而,某天店長突然失蹤,珍貴的冰淇淋秘方也隨之消失,只留下一間被上鎖的店鋪和一連串神秘線索。\n你們是一群受邀前來協助調查的人,當踏入店內的瞬間,大門突然自動鎖上,並響起一段錄音:\n「只有找到真正的秘方繼承者,才能打開大門離開這裡。」\n店內藏有許多與冰淇淋、口味、價格、優惠活動及客製化相關的謎題。\n你們必須仔細觀察每個角落、破解一道道機關,蒐集所有線索,\n找到失落的冰淇淋秘方,才能成功逃離這間神秘的冰淇淋店。") + print("依序追查主角、地點與動態蹤跡。祝你逃脱成功!") + print("-" * 60) + + local_history = [] + + print("\n遊戲已啟動!請輸入你想問 NPC 的話(輸入 'quit' 結束遊戲):") + print("-" * 60) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("遊戲結束,感謝遊玩!") + break + + ai_reply = local_mock_response(user_input, local_history) + + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[NPC]: {ai_reply}") + + except KeyboardInterrupt: + print("\n遊戲強行終止!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/requirements.txt" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/requirements.txt" new file mode 100644 index 0000000000000000000000000000000000000000..ac7f00648560530b9d1168986de056f2bdfd6a45 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/requirements.txt" @@ -0,0 +1,6 @@ +pandas>=2.0.0 +matplotlib>=3.7.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +opencv-python>=4.8.0 +google-generativeai>=0.8.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\270\211\346\214\207\346\215\217\346\270\270\346\210\217/d5_pinch_challenge_solution.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\270\211\346\214\207\346\215\217\346\270\270\346\210\217/d5_pinch_challenge_solution.py" new file mode 100644 index 0000000000000000000000000000000000000000..4fab835be0c692d02f761eedc81c00545d94e078 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\270\211\346\214\207\346\215\217\346\270\270\346\210\217/d5_pinch_challenge_solution.py" @@ -0,0 +1,217 @@ +""" +Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (三指捏合挑戰 Template 填空版) +================================================================== +本週挑戰目標: + 1. 實作「三指捏合 (Three-Finger Pinch)」: + 必須同時滿足「大拇指 (4) 與食指 (8) 的距離 < Threshold」 + 以及「大拇指 (4) 與中指 (12) 的距離 < Threshold」才判定為 Pinch + 2. 用三指碰觸 Pygame 畫面上的按鈕 + 捏合 = 魔法點擊 + 3. 使用邊緣觸發 (Edge Detection) 防止重複觸發 + 4. 新增 30 秒限時機制,時間到顯示最終得分 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import random + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 10 - 三指魔法手勢按鈕 (填空版) ") +clock = pygame.time.Clock() +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# 攝影機 +cap = cv2.VideoCapture(0) + +# 【TODO 1】初始化 MediaPipe 手勢偵測器 (Hand Landmarker) +\ + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=os.path.join('hand_landmarker.task')) + options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) + detector = vision.HandLandmarker.create_from_options(options) +except Exception as e: + print(f"Error loading model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 2. 遊戲變數 +# ============================================ +# 手指座標 +finger_x, finger_y = 400, 300 # 食指 +thumb_x, thumb_y = 400, 300 # 大拇指 +middle_x, middle_y = 400, 300 # 中指 + +# 距離變數 +dist_thumb_index = 0.0 +dist_thumb_middle = 0.0 + +# Pinch 相關變數 +is_pinching = False +was_pinching = False # 邊緣觸發 +PINCH_THRESHOLD = 50 # 距離臨界值 + +# 按鈕設定 +button_rect = pygame.Rect(300, 250, 200, 100) +button_colors = [(100, 100, 220), (220, 100, 100), + (100, 220, 100), (220, 180, 50)] +current_color_index = 0 + +# 點擊計數器 +click_count = 0 + +# --- 限時機制變數 --- +START_TIME = pygame.time.get_ticks() +GAME_DURATION = 30 # 遊戲限制時間(秒) +game_state = "PLAYING" # 狀態機 + +# ============================================ +# 3. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機與手勢偵測 --- + success, img = cap.read() + is_pinching = False # 每幀重設狀態,防止手離屏時卡死 + + if success: + img = cv2.flip(img, 1) # 鏡像翻轉 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + # === 【TODO 2:挑戰關卡三之手部安全偵測】 === + # 1. 判斷手部是否在畫面中 (results.hand_landmarks 是否有資料) + # 2. 如果在畫面中,取得食指 (8)、大拇指 (4)、中指 (12) 的像素座標 + # 3. 如果手不在畫面中,將 is_pinching 設為 False + # + hand_in_frame = False + if results.hand_landmarks: + hand_in_frame = True + for hand_lms in results.hand_landmarks: + # 食指 (8) + lm8 = hand_lms[8] + finger_x = int(lm8.x * WIDTH) + finger_y = int(lm8.y * HEIGHT) + + # 大拇指 (4) + lm4 = hand_lms[4] + thumb_x = int(lm4.x * WIDTH) + thumb_y = int(lm4.y * HEIGHT) + + # 中指 (12) + lm12 = hand_lms[12] + middle_x = int(lm12.x * WIDTH) + middle_y = int(lm12.y * HEIGHT) + + # === 【TODO 3:挑戰關卡一之三指捏合判定】 === + # 提示:計算食指與大拇指距離,以及中指與大拇指距離 + # 兩組距離皆小於 PINCH_THRESHOLD 時,is_pinching 才為 True + dist_thumb_index = math.hypot(finger_x - thumb_x, finger_y - thumb_y) + dist_thumb_middle = math.hypot(middle_x - thumb_x, middle_y - thumb_y) + is_pinching = (dist_thumb_index < PINCH_THRESHOLD) and (dist_thumb_middle < PINCH_THRESHOLD) + + dist_thumb_index = math.hypot(finger_x - thumb_x, finger_y - thumb_y) + dist_thumb_middle = math.hypot(middle_x - thumb_x, middle_y - thumb_y) + is_pinching = (dist_thumb_index < PINCH_THRESHOLD) and (dist_thumb_middle < PINCH_THRESHOLD) + + # --- 狀態機邏輯處理 --- + if game_state == "PLAYING": + # 1. 計算剩餘時間 + current_ticks = pygame.time.get_ticks() + elapsed_seconds = (current_ticks - START_TIME) / 1000 + remaining_time = max(0, GAME_DURATION - elapsed_seconds) + + # 2. 檢查時間是否到了 + if remaining_time <= 0: + game_state = "GAME_OVER" + + # 3. 邊緣觸發點擊判斷 + if is_pinching and not was_pinching: + finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10) + if finger_rect.colliderect(button_rect): + click_count += 1 + current_color_index = (current_color_index + 1) % len(button_colors) + + # === 【TODO 4:挑戰關卡二之按鈕隨機移動】 === + # 提示:當點擊發生時,讓按鈕的 x 和 y 座標隨機分配在畫面內 + + button_rect.x = random.randint(0, WIDTH - button_rect.width) + button_rect.y = random.randint(0, HEIGHT - button_rect.height) + print(f" 魔法三指點擊!第 {click_count} 次") + + # 更新上一幀狀態 + was_pinching = is_pinching + + # --- 繪製畫面 --- + screen.fill((20, 20, 35)) + font = pygame.font.SysFont(None, 32) + large_font = pygame.font.SysFont(None, 64) + + if game_state == "PLAYING": + # 繪製按鈕 + btn_color = button_colors[current_color_index] + pygame.draw.rect(screen, btn_color, button_rect, border_radius=16) + + btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255)) + screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2, + button_rect.centery - btn_text.get_height() // 2)) + + # 顯示倒數計時 + time_text = font.render(f"Time Left: {remaining_time:.1f}s", True, (255, 220, 100)) + screen.blit(time_text, (WIDTH - time_text.get_width() - 20, 10)) + + # 畫手指游標 + cursor_color = (50, 255, 50) if is_pinching else (255, 50, 50) + pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12) # 食指 + pygame.draw.circle(screen, (255, 255, 255), (thumb_x, thumb_y), 12) # 大拇指 + pygame.draw.circle(screen, (100, 150, 255), (middle_x, middle_y), 12) # 中指 + + # === 【TODO 5:挑戰關卡三之畫面提示】 === + # 提示:當手不在畫面中時,在畫面上方顯示警告文字提醒學生將手部放回 + + if not hand_in_frame: + warning_text = font.render("OUT OF FRAME - PLEASE PUT HANDS BACK", True, (255, 100, 100)) + screen.blit(warning_text, (WIDTH // 2 - warning_text.get_width() // 2, 100)) + + # 顯示調試數值 + small_font = pygame.font.SysFont(None, 24) + status = small_font.render(f"Finger: ({finger_x}, {finger_y})", True, (150, 150, 150)) + screen.blit(status, (10, 10)) + + dist_text = small_font.render(f"Dist Thumb-Index: {dist_thumb_index:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text, (10, 35)) + + dist_text2 = small_font.render(f"Dist Thumb-Middle: {dist_thumb_middle:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text2, (10, 60)) + + elif game_state == "GAME_OVER": + # 繪製遊戲結束畫面 + over_text = large_font.render("GAME OVER", True, (255, 50, 50)) + score_text = font.render(f"Total Clicks: {click_count} times", True, (255, 255, 255)) + + screen.blit(over_text, (WIDTH // 2 - over_text.get_width() // 2, HEIGHT // 2 - 50)) + screen.blit(score_text, (WIDTH // 2 - score_text.get_width() // 2, HEIGHT // 2 + 20)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\272\272\350\204\270\350\276\250\350\257\206/d3_2_face_detection.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\272\272\350\204\270\350\276\250\350\257\206/d3_2_face_detection.py" new file mode 100644 index 0000000000000000000000000000000000000000..886a3f9c470f0ab50070575fb0e8b8f1d74dc9c7 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\272\272\350\204\270\350\276\250\350\257\206/d3_2_face_detection.py" @@ -0,0 +1,128 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=1, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + cv2.circle(frame, (x + w // 2, y + h // 2), 15, (0, 0, 255), -1) + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "a big idiot", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\272\272\350\204\270\350\276\250\350\257\206/d3_2_face_detection2.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\272\272\350\204\270\350\276\250\350\257\206/d3_2_face_detection2.py" new file mode 100644 index 0000000000000000000000000000000000000000..30cdaa2fc5028dd86ce497ffdf1fb6b8b5952ff5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\344\272\272\350\204\270\350\276\250\350\257\206/d3_2_face_detection2.py" @@ -0,0 +1,139 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + #1. 擷取人臉區域 (Slice) + face_roi = frame[y:y+h, x:x+w] + + #2. 縮小人臉圖像 (例如縮小到 15x15 像素 + small = cv2.resize(face_roi, (15, 15), interpolation=cv2.INTER_LINEAR) + + #3. 放大回原本的大小 (使用 NEAREST 內插法保留顆粒感) + mosaic = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + + #4. 放回原畫面中 + frame[y:y+h, x:x+w] = mosaic + + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "dumboo dumboo", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/coffee_sales_analysis.png" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/coffee_sales_analysis.png" new file mode 100644 index 0000000000000000000000000000000000000000..f63e75f9e1330b3d153c8acc51fa84bef6c93d86 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/coffee_sales_analysis.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1184cf7f0cba901cad5055d26ca3d2aa48498f9cf998dabc8e0824a2316187da +size 392403 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/coffee_sales_data.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/coffee_sales_data.csv" new file mode 100644 index 0000000000000000000000000000000000000000..9034a63a93b67363c54e7f155c9cb5daf483f886 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/coffee_sales_data.csv" @@ -0,0 +1,15 @@ +Date,Sales_Amount,Customer_Count,Coffee_Cups_Sold,Dessert_Sold,Promotion_Active +2026-07-01,15200,120,85,30,No +2026-07-02,16800,135,95,35,No +2026-07-03,24500,190,140,55,Yes +2026-07-04,28000,210,165,68,Yes +2026-07-05,18500,145,110,40,No +2026-07-06,14200,110,80,25,No +2026-07-07,15900,125,90,28,No +2026-07-08,17200,138,100,32,No +2026-07-09,26000,205,150,60,Yes +2026-07-10,29500,225,175,75,Yes +2026-07-11,21000,160,120,45,No +2026-07-12,19800,150,115,42,No +2026-07-13,15000,118,85,26,No +2026-07-14,16500,130,92,30,No diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/d1_4_visualization_coffee.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/d1_4_visualization_coffee.py" new file mode 100644 index 0000000000000000000000000000000000000000..21bdf330eca1a6a8ef832bb30de0041f76adc79c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\222\226\345\225\241\345\272\227\345\210\206\346\236\220\345\233\276\350\241\250/d1_4_visualization_coffee.py" @@ -0,0 +1,115 @@ +# d1_4_visualization_coffee.py +# Day 1: 資料科學與數據視覺化 - 咖啡廳營運數據進階分析(學員填空) +# cd examples/Day1/student && python3 d1_4_visualization_coffee.py +# 參考答案:../Teacher/d1_4_visualization_coffee.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + + +def load_csv(filename="coffee_sales_data.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + # TODO: 1. 使用 os.path.join 將當前腳本所在的目錄與檔案名稱拼接成絕對路徑 + # 2. 使用 os.path.exists 判斷路徑是否存在,若存在則用 pd.read_csv 讀取並回傳 + path = os.path.join(os.path.dirname(__file__), filename) + if os.path.exists(path): + return pd.read_csv(path) + else: + print("❌ 找不到 CSV 檔案!請確認 coffee_sales_data.csv 與本腳本在同一目錄。") + exit(1) + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 1 列 2 行 (1x2) 的子圖畫布,尺寸大小為寬 15 英吋、高 6 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + + +# ========================================== +# 1. 子圖 1 (ax1):營業額與咖啡銷量趨勢對比 (雙 Y 軸) +# ========================================== +# === 【學員填空練習點 3:繪製雙 Y 軸折線圖】 === +# TODO: +# 1. 在 ax1 (左軸) 繪製「營業額」折線圖:X軸為 df['Date'], Y軸為 df['Sales_Amount'] +# 設定參數:點形 marker='o', 顏色 color='#b87e6b' (鐵鏽紅), 圖例 label='營業額 (NTD)', 線寬 linewidth=2 +# 2. 呼叫 ax1.twinx() 建立共用 X 軸的右側 Y 軸物件,指派給 ax1_twin +# 3. 在 ax1_twin (右軸) 繪製「咖啡銷量」折線圖:X軸為 df['Date'], Y軸為 df['Coffee_Cups_Sold'] +# 設定參數:點形 marker='s' (正方形), 顏色 color='#5f7186' (石板藍), 虛線 linestyle='--', 圖例 label='咖啡銷量 (杯)' +# 4. 分別設定左軸與右軸的 Y 軸標籤與顏色以示區隔 (提示:使用 set_ylabel 與 tick_params) + +# 請在下方完成 ax1 雙 Y 軸折線圖的繪製: +line1 = ax1.plot(df['Date'], df['Sales_Amount'], marker='o', color="#640f91", label='营业额 (NTD)', linewidth=2 ) +ax1_twin = ax1.twinx() +line2 = ax1_twin.plot(df['Date'], df['Coffee_Cups_Sold'], marker='s', color= "#19077D", linestyle='--', label='咖啡销量 (杯)', linewidth=2) + +ax1.set_ylabel("营业额 (NTD)", color='#640f91', fontsize=12) +ax1_twin.set_ylabel("咖啡销量 (杯)", color='#19077D', fontsize=12) +ax1.tick_params(axis='y', labelcolor='#640f91') +ax1_twin.tick_params(axis='y', labelcolor='#19077D') + +# === 【學員填空練習點 4:雙 Y 軸圖例合併】 === +# TODO: +# 1. 由於雙 Y 軸屬於不同的 Axes 物件,若直接呼叫 ax1.legend() 只會顯示左軸的圖例。 +# 請獲取兩軸的 lines 與 labels,合併後統一顯示在 ax1 的左上角 (loc='upper left') +# 提示: +# lines1, labels1 = ax1.get_legend_handles_labels() +# lines2, labels2 = ax1_twin.get_legend_handles_labels() +# ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') + +# 請在下方完成圖例合併: +lines = line1 + line2 +labels = [l.get_label() for l in lines] +ax1.legend(lines, labels, loc='upper left') + +# 設定 ax1 基礎格式 +ax1.set_title("咖啡廳每日營業額與咖啡銷量趨勢", fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") +ax1.grid(True, linestyle='--', alpha=0.5) +ax1.tick_params(axis='x', rotation=45) + + +# ========================================== +# 2. 子圖 2 (ax2):促銷活動對客流量的影響 (數據聚合長條圖) +# ========================================== +# === 【學員填空練習點 5:數據聚合與長條圖繪製】 === +# TODO: +# 1. 使用 groupby 依據 'Promotion_Active' 欄位分組,並計算 'Customer_Count' 的平均值 (mean) +# 提示:promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +# 2. 在 ax2 上繪製長條圖,以 promo_avg.index 作為 X 軸,promo_avg.values 作為 Y 軸 +# 設定參數:顏色 color=['#e6e2d8', '#b87e6b'],外框顏色 edgecolor='#3d3a36',寬度 width=0.5 +# 3. 在長條圖上方顯示具體的平均數值 (提示:使用 ax2.bar_label 或是跑迴圈加文字) + +# 請在下方完成資料聚合與長條圖繪製: +promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +bars = ax2.bar(promo_avg.index, promo_avg.values, color=["#15c81e", "#1a54f3"], edgecolor="#bd10cd", width=0.5) +ax2.bar_label(bars,fmt='%.1f 人', label_type='edge', padding=3, fontweight='bold') + +# 設定 ax2 基礎格式 +ax2.set_title("促銷活動對每日平均客流量的影響", fontsize=14, fontweight='bold') +ax2.set_xlabel("是否舉辦促銷活動") +ax2.set_ylabel("平均來客數 (人)") +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) + + +plt.tight_layout() # 自動調整間距,避免文字重疊 + +# === 【學員填空練習點 6:圖表儲存與顯示】 === +# TODO: 1. 呼叫 savefig 將畫布儲存為檔名 "coffee_sales_analysis.png",並設定 dpi=300 +# 2. 呼叫 show 顯示圖表 +# (⚠️ 注意:不要在 print 或 savefig 內使用不支援 cp950 的 emoji) + +output_image = "coffee_sales_analysis.png" +# 請在下方完成圖表存檔與顯示: +plt.savefig(output_image, dpi=300) +plt.show() \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\230\264\345\267\264\344\276\246\346\265\213/step3_mouth_ratio.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\230\264\345\267\264\344\276\246\346\265\213/step3_mouth_ratio.py" new file mode 100644 index 0000000000000000000000000000000000000000..5ed81565194a1bf3b44ffcd1b9c2c35f48a7acad --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\230\264\345\267\264\344\276\246\346\265\213/step3_mouth_ratio.py" @@ -0,0 +1,113 @@ +""" +Week 11 - Step 2: Mouth Opening Ratio Detection (Template 填空版) +============================================================= +本步驟目標: + 1. 找出特定的臉部特徵點:眼角(33, 263)與嘴唇(13, 14) + 2. 計算兩眼距離,當作臉寬基準比例尺,防止人靠近或遠離鏡頭造成的誤判 (歸一化) + 3. 計算嘴巴張開比例:嘴巴距離 / 兩眼距離 + 4. 在 OpenCV 畫面上即時顯示比例與嘴巴開合狀態 (OPEN / CLOSED) +""" +import cv2 +import mediapipe as mp +import math +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker initialized successfully.") +except Exception as e: + print(f"Error: Failed to initialize detector: {e}") + sys.exit() + +# 特徵點索引定義 +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 + +# 嘴巴張開的比例閾值 (超過代表張開) +THRESHOLD = 0.25 + +print("Press 'q' in the window to exit.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + break + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + + # 影像處理 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 【TODO 1】取得這 4 個特徵點在畫面中的 (x, y) 像素座標,並存在 pts 字典中 + pts = {} + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM]: + pt = face_lms[idx] + pts[idx] = (int(pt.x * img_w), int(pt.y * img_h)) + + + # 【TODO 2】計算兩眼外角距離 (做為基準比例尺) + p_lx, p_ly = pts[LEFT_EYE_OUTER] + p_rx, p_ry = pts[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + + if base_dist > 0: + # 【TODO 3】計算嘴巴張開的實際像素距離,並除以眼距算出「嘴巴張開比例 (mouth_ratio)」 + p_top_x, p_top_y = pts[LIP_TOP] + p_bot_x, p_bot_y = pts[LIP_BOTTOM] + mouth_dist = math.hypot(p_bot_x - p_top_x, p_bot_y - p_top_y) + mouth_ratio = mouth_dist / base_dist + + + # 【TODO 4】判斷嘴巴狀態是否大於 THRESHOLD,並在畫面上繪製量測輔助線 + # 提示:用 cv2.line 畫出眼部基準線 (藍色) 和嘴巴測量線 (橘色) + is_open = mouth_ratio > THRESHOLD + cv2.line(img, (p_lx, p_ly), (p_rx, p_ry), (255, 0, 0), 2) # 藍色眼線 + cv2.line(img, (p_top_x, p_top_y), (p_bot_x, p_bot_y), (0, 165, 255), 2) # 橘色嘴線 + + + # 畫出特徵點小黃圓圈 + for idx, pos in pts.items(): + cv2.circle(img, pos, 5, (0, 255, 255), -1) + + # 顯示比例與開合狀態文字 + cv2.putText(img, f"Mouth Ratio: {mouth_ratio:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + status_text = "OPEN" if is_open else "CLOSED" + status_color = (0, 255, 0) if is_open else (0, 0, 255) + cv2.putText(img, f"State: {status_text}", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, status_color, 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + cv2.imshow("Mouth Ratio Detector - Press 'q' to quit", img) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_contours.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..9c2a4d56b95af663951079cb2c66a1f4a4a53767 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6907f35956ef9e214f1798384103446364224eda4b9013ea1804460a2c2c1d64 +size 189945 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_edges.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..c43e2b247690663b3043a09b327184409d5decaf --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8625e1b5fb07bd6acd2165c794e6bfac0367111413a546acdd331cef340b2640 +size 199693 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_gray.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..1d38dca6bb3327e6179867613f00279a50dcd27d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\204\347\220\206\350\277\207\347\232\204\347\205\247\347\211\207/output_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85375871beed2bf92c34132701aa4677a849d43214d4d8292c8e3f78298e5165 +size 135109 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\251\346\260\224\350\265\204\346\226\231\345\233\276\350\241\250/d1_3_visualization.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\251\346\260\224\350\265\204\346\226\231\345\233\276\350\241\250/d1_3_visualization.py" new file mode 100644 index 0000000000000000000000000000000000000000..081acf6e7fe9a40f490bb83cafbc1a57c9164ae7 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\251\346\260\224\350\265\204\346\226\231\345\233\276\350\241\250/d1_3_visualization.py" @@ -0,0 +1,87 @@ +# d1_3_visualization.py +# Day 1: 資料科學與數據視覺化 - Matplotlib 繪圖基礎(學員填空) +# cd examples/Day1/student && python3 d1_3_visualization.py +# 參考答案:../Teacher/d1_3_visualization.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + +def load_csv(filename="taipei_weather_2026.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + # TODO: 1. 使用 os.path.join 將當前腳本所在的目錄與檔案名稱拼接成絕對路徑 + # 提示: 當前腳本目錄為 os.path.dirname(__file__) + # 2. 使用 os.path.exists 判斷路徑是否存在,若存在則用 pd.read_csv 讀取並回傳 + path = os.path.join(os.path.dirname(__file__), filename) + + if os.path.exists(path): + return pd.read_csv(path) + else: + print("error: no files found") + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 2 列 1 行 (2x1) 的子圖畫布,尺寸大小為寬 10 英吋、高 8 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8)) + +# 1. 繪製折線圖 (Line Plot) - 每日溫度 +# === 【學員填空練習點 3:繪製每日溫度折線圖】 === +# TODO: 1. 在 ax1 畫布上以 ax1.plot() 繪製折線圖,X軸傳入 df['Date'],Y軸傳入 df['Temperature'] +# 設定參數:帶有圓點 marker='o',顏色 color='crimson',圖例 label='溫度 (°C)',線寬 linewidth=2 +# 2. 使用 ax1.set_title 設定標題 "台北觀測站每日溫度趨勢圖" +# 3. 設定 ax1.set_xlabel("日期") 與 ax1.set_ylabel("數值") +# 4. 設定網格 ax1.grid(True, linestyle='--', alpha=0.5) +# 5. 呼叫 ax1.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax1.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax1 的溫度折線圖繪製: +ax1.plot(df['Date'], df['Temperature'], marker='o', color= 'crimson',label='温度 (°C)', linewidth=2) +ax1.set_title("台北观测站每日温度趋势图", fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") + +ax1.set_ylabel("数值") +ax1.grid(True,linestyle="--", alpha=0.5) +ax1.legend() +ax1.tick_params(axis='x', rotation=45) +ax1.plot(df['Date'], df['Humidity'], marker='o', color='lime', label='湿度 (%)', linewidth=2) +ax1.legend() + +# 2. 繪製條形圖 (Bar Plot) - 每日降雨量 +# === 【學員填空練習點 4:繪製每日降雨量長條圖】 === +# TODO: 1. 在 ax2 畫布上以 ax2.bar() 繪製長條圖,X軸傳入 df['Date'],Y軸傳入 df['Rainfall'] +# 設定參數:顏色 color='skyblue',外框顏色 edgecolor='royalblue',圖例 label='降雨量 (mm)' +# 2. 使用 ax2.set_title 設定標題 "台北觀測站每日降雨量分佈" +# 3. 設定 ax2.set_xlabel("日期") 與 ax2.set_ylabel("降雨量 (mm)") +# 4. 設定 Y 軸網格 ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +# 5. 呼叫 ax2.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax2.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax2 的降雨量長條圖繪製: +ax2.bar(df['Date'], df['Rainfall'], color='turquoise',edgecolor = 'turquoise',label='降雨量 (mm)') +ax2.set_title("台北每天降雨量长条图", fontsize=14, fontweight='bold') +ax2.set_xlabel("日期") +ax2.set_ylabel("降雨量") +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +ax2.legend() +ax2.tick_params(axis='x', rotation=45) + +plt.tight_layout() # 自動調整間距,避免文字重疊 + +# === 【學員填空練習點 5:圖表自動儲存與顯示】 === +# TODO: 1. 呼叫 plt.savefig 將畫布儲存為檔名 "taipei_weather_2026.png",並設定高畫質 dpi=300 +# (⚠️ 注意:savefig 必須在 show() 之前呼叫) +# 2. 呼叫 plt.show() 將圖表視窗顯示在畫面上 +# +# 請在下方完成圖表存檔與顯示: +plt.savefig("taipei_weather_2026.png", dpi=300) +plt.show() \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\251\346\260\224\350\265\204\346\226\231\345\233\276\350\241\250/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\251\346\260\224\350\265\204\346\226\231\345\233\276\350\241\250/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\244\251\346\260\224\350\265\204\346\226\231\345\233\276\350\241\250/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\275\261\345\203\217\345\244\204\347\220\206/d3_1_opencv_basic.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\275\261\345\203\217\345\244\204\347\220\206/d3_1_opencv_basic.py" new file mode 100644 index 0000000000000000000000000000000000000000..ca5eb71bb0d5daecfb2c765b5bee8521a8a65dc4 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\345\275\261\345\203\217\345\244\204\347\220\206/d3_1_opencv_basic.py" @@ -0,0 +1,70 @@ +# d3_1_opencv_basic.py +# Day 3: 電腦視覺與影像辨識 - OpenCV 影像處理基礎 (學員練習版) +# 練習指引:依序找到程式碼中的 TODO 1 到 TODO 5,將其下方的程式碼「解除註釋(刪除行首的 # )」並執行,觀察影像處理管線中每一步的變化。 + +import cv2 +import numpy as np + +# 1. 影像讀取 (Read) +img = cv2.imread("onepiece(2).jpeg") +if img is None: + print("[錯誤] 無法讀取 jpg") + exit(1) + +# 預設灰階影像為原始彩色影像,避免未解開註釋時程式出錯 +gray = img + +# === 【重要觀念 TODO 1:灰階轉換 (Grayscale Conversion)】 === +# 觀念重點:大多數電腦視覺演算法在處理單通道(黑白)影像時速度更快。 +# 將 BGR 彩色圖像轉換為單通道灰階圖像,可以減少 2/3 的資料量,且能過濾掉無用的顏色雜訊。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察變為灰階後的影像。 +gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + +# 預設模糊影像為灰階影像,避免未解開註釋時程式出錯 +blurred = gray + +# === 【重要觀念 TODO 2:高斯模糊平均來平 (Gaussian Blur)】 === +# 觀念重點:高斯模糊是透過像素加權滑影像,主要目的是「消除噪點和細微反光」。 +# 這可以避免後續邊緣偵測時把細微的噪輪廓,讓偵測出來的邊緣更加平滑。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察影像邊緣的柔焦效果。 +blurred = cv2.GaussianBlur(gray, (5, 5), 0) + +# 預設邊緣影像為全黑畫布,避免未解開註釋時點誤判為程式出錯 +edges = np.zeros(img.shape[:2], dtype=np.uint8) + +# === 【重要觀念 TODO 3:Canny 邊緣偵測 (Canny Edge Detection)】 === +# 觀念重點:Canny 演算法會找出影像中明暗變化劇烈的分界線。它使用雙閥值法: +# 高於高閥值(150)的 100% 判定為邊緣;介於高低閥值(50~150)之間的若連接強邊緣則保留;其餘捨棄。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察提取出的白色線條輪廓。 +edges = cv2.Canny(blurred, 10, 80) + +# 預設無偵測到輪廓,且繪製圖像為原始圖像,避免未解開註釋時程式出錯 +contours = [] +contour_img = img.copy() + +# === 【重要觀念 TODO 4:尋找與繪製輪廓 (Find & Draw Contours)】 === +# 觀念重點:`findContours` 會在二值化(邊緣)影像中尋找相連的邊界,並建立幾何結構。 +# `drawContours` 則會將這些幾何結構繪製到指定的影像上(此處使用綠色框繪製於原始彩色影像)。 +# 請解開下方所有註釋 (刪除行首的 #) 並執行,觀察圖形外框被綠線框起來的結果。 +contours, hierarchy = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) +print(f"在影像中偵測到 {len(contours)} 個獨立輪廓特徵。") +contour_img = img.copy() +cv2.drawContours(contour_img, contours, -1, (173,216,203), 1) +alpha=1 + +# === 【重要觀念 TODO 5:視窗顯示與釋放 (Window Display)】 === +# 觀念重點:使用 cv2.imshow 顯示各步驟處理結果,並用 waitKey(0) 讓程式停下等待按鍵, +# 最後使用 destroyAllWindows 釋放視窗資源。 +# 請解開下方所有註釋 (刪除行首的 #),以觀看影像處理的成果! +print("\n正在顯示影像處理結果(請點擊影像視窗並按下任意鍵關閉)...") +cv2.imshow("Original Shapes", img) +cv2.imshow("Grayscale", gray) +cv2.imshow("Canny Edges", edges) +cv2.imshow("Contours Output", contour_img) + +success_gray = cv2.imwrite("output_gray.jpg", gray) +success_edges = cv2.imwrite("output_edges.jpg", edges) +success_contour = cv2.imwrite("output_contours.jpg", contour_img) + +cv2.waitKey(0) +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\346\211\213\346\214\207\347\224\273\347\224\273\346\270\270\346\210\217/draw_game_student.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\346\211\213\346\214\207\347\224\273\347\224\273\346\270\270\346\210\217/draw_game_student.py" new file mode 100644 index 0000000000000000000000000000000000000000..96c0eff596ce08501d575376830004b6d0501690 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\346\211\213\346\214\207\347\224\273\347\224\273\346\270\270\346\210\217/draw_game_student.py" @@ -0,0 +1,167 @@ +import cv2 +import numpy as np +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# ============================================ +# Week 9 - 簡單學生版:支援多色切換的 AR 空中寫字板 (純黑畫布版) +# ============================================ + +# === 【重要觀念 TODO 1:MediaPipe 手部偵測模型初始化】 === +# 觀念重點: +# 1. BaseOptions 用於設定模型檔案的路徑,這裡使用下載好的 'hand_landmarker.task'。 +# 2. HandLandmarkerOptions 用於設定手部偵測的細項設定,例如 num_hands=1 代表最多偵測一隻手。 +# 3. 最後透過 vision.HandLandmarker.create_from_options 建立手部偵測器物件。 +# 請解開下方三行程式碼的註釋: +base_options = python.BaseOptions(model_asset_path='hand_landmarker.task') +options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) +detector = vision.HandLandmarker.create_from_options(options) + +cap = cv2.VideoCapture(0) +canvas = None +px, py = 0, 0 + +# 定義幾種高對比的顏色 (格式為 B, G, R) +COLORS = { + 'red': (0, 0, 255), + 'orange': (0, 165, 255), + 'yellow': (0, 255, 255), + 'green': (0, 255, 0), + 'cyan': (255, 255, 0), + 'dark_blue': (255, 0, 0), + 'light_purple': (164, 97, 237), + 'purple': (63, 0, 127), + 'magenta': (255, 0, 255), +} + +# 預設使用螢光綠 +current_color = COLORS['green'] + +print("系統啟動中...") +print("【操作指南】") +print("☝️ 單伸食指:下筆寫字") +print("✌️ 食指+中指:懸停移動 (不寫字)") +print("⌨️ 按下 '1~9':切換畫筆顏色") +print("⌨️ 按下 'e':清空畫面") +print("⌨️ 按下 'q':結束程式") + +while True: + success, img = cap.read() + if not success: + break + + # === 【重要觀念 TODO 2:攝影機影像水平翻轉】 === + # 觀念重點: + # 1. 攝影機預設取得的影像與我們的真實動作是相反的(非鏡像)。 + # 2. 在互動式 AR 應用中,為了讓使用者的左手移動時,畫面中的手也往左移動, + # 必須使用 cv2.flip(img, 1) 將影像水平翻轉,以提供符合直覺的「鏡像體驗」。 + # 請解開下方一行程式碼的註釋: + img = cv2.flip(img, 1) + + h, w, c = img.shape + + # 初始化畫布 + if canvas is None: + canvas = np.zeros((h, w, c), dtype=np.uint8) + + # 為了維持畫筆提示點(或懸停圓圈)的動態顯示,且不破壞原本的 canvas 紀錄, + # 我們在每一訊框都建立一個純黑的暫時畫布來呈現最終畫面。 + display_img = np.zeros((h, w, c), dtype=np.uint8) + + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.hand_landmarks: + hand_lms = results.hand_landmarks[0] + + index_tip = hand_lms[8] + index_pip = hand_lms[6] + middle_tip = hand_lms[12] + middle_pip = hand_lms[10] + + cx, cy = int(index_tip.x * w), int(index_tip.y * h) + + index_up = index_tip.y < index_pip.y + middle_up = middle_tip.y < middle_pip.y + + if index_up and not middle_up: + # 狀態:【繪圖模式】 + if px == 0 and py == 0: + px, py = cx, cy + + # === 【重要觀念 TODO 3:在 OpenCV Canvas 上繪製軌跡】 === + # 觀念重點: + # 1. cv2.line 用於在影像上繪製直線。 + # 2. 參數說明:畫布(canvas)、起點(px, py)、終點(cx, cy)、顏色(current_color)、線寬(8)。 + # 3. 藉由在每一訊框連續繪製前一個位置到當前位置的線段,即可形成平滑的寫字軌跡。 + # 請解開下方一行程式碼的註釋: + cv2.line(canvas, (px, py), (cx, cy), current_color, 8) + + px, py = cx, cy + + # 筆頭提示點直接畫在暫時的顯示畫布上,避免在黑畫布上找不到筆尖 + cv2.circle(display_img, (cx, cy), 10, current_color, cv2.FILLED) + + elif index_up and middle_up: + # 狀態:【懸停模式】 + px, py = 0, 0 + # 懸停提示圈畫在暫時的顯示畫布上,移動時才不會在黑背景中迷路 + cv2.circle(display_img, (cx, cy), 12, (255, 255, 255), 2) + + else: + px, py = 0, 0 + else: + px, py = 0, 0 + + # 為什麼改用 canvas 與 display_img 疊加? + # 原本是與相機影像 (img) 疊加,現在改與純黑的 display_img 疊加, + # 這樣畫面上就只會留下寫字的軌跡以及當前的筆頭/懸停提示,完全遮蔽人像背景。 + + # === 【重要觀念 TODO 4:AR 畫面疊加與顯示】 === + # 觀念重點: + # 1. cv2.add 用於將兩個相同大小與類型的影像矩陣進行相加。 + # 2. 這裡將寫字痕跡畫布 (canvas) 與當前的筆頭/懸停提示畫布 (display_img) 疊加。 + # 3. 疊加後使用 cv2.imshow 在視窗中顯示最終成果。 + # 請解開下方兩行程式碼的註釋: + final_output = cv2.add(canvas, display_img) + cv2.imshow("Hand Tracking - AR Canvas", final_output) + + # 鍵盤監聽與控制邏輯 + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('e'): + canvas = np.zeros((h, w, c), dtype=np.uint8) + + elif key == ord('1'): + current_color = COLORS['red'] + print("已切換為:红色") + elif key == ord('2'): + current_color = COLORS['orange'] + print("已切換為:橘色") + elif key == ord('3'): + current_color = COLORS['yellow'] + print("已切換為:黃色") + elif key == ord('4'): + current_color = COLORS['green'] + print("已切換為:綠色") + elif key == ord('5'): + current_color = COLORS['cyan'] + print("已切換為:青色") + elif key == ord('6'): + current_color = COLORS['dark_blue'] + print("已切換為:深蓝色") + elif key == ord('7'): + current_color = COLORS['light_purple'] + print("已切換為:浅紫色") + elif key == ord('8'): + current_color = COLORS['purple'] + print("已切換為:紫色") + elif key == ord('9'): + current_color = COLORS['magenta'] + print("已切換為:粉色") + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\205\247\347\211\207/onepiece(2).jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\205\247\347\211\207/onepiece(2).jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..d462e2442bb100a26a13c0dd9f1f539222ec45a8 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\205\247\347\211\207/onepiece(2).jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16063edf453095349df30308f927b018b8b6eaf10cdcff3fd64dbe3e5c6c0cf6 +size 80213 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\205\247\347\211\207/onepiece.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\205\247\347\211\207/onepiece.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..d3562b3bc79c333aa64c102f68b31b40c8712f42 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\205\247\347\211\207/onepiece.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:737a93e3e54652d08eca4b148bf126686bfe5b1647c314d33d2eee42919d7a38 +size 168241 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex1_books.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex1_books.py" new file mode 100644 index 0000000000000000000000000000000000000000..7b20bb8c1ce86a4d012d8b072db8a037636431d9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex1_books.py" @@ -0,0 +1,94 @@ +# d4_1_ex1_books.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex1_books.py +# 參考答案:../d4_1_ex1_books.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:專供爬蟲練習的圖書商城沙盒 +URL = "http://books.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至圖書沙盒網站: {URL} ...") + +try: + # 發送 GET 請求取得網頁 HTML + response = requests.get(URL, headers=HEADERS, timeout=10) + + # 檢查 HTTP 狀態碼是否為 200 (OK),非 200 時引發 HTTPError 異常 + response.raise_for_status() + + # 解決編碼問題:強制將解碼設定為 utf-8 (避免 requests 預設 ISO-8859-1 解碼導致英鎊符號亂碼且在 Windows 印出時崩潰) + response.encoding = response.apparent_encoding + + print("連線成功!開始解析書籍資料...\n") + + # 初始化 BeautifulSoup 解析器 + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有書籍的容器。每本書都被包在一個
標籤中,且該標籤的 class 固定為 "product_pod" + books = soup.find_all("article", class_="product_pod") + + print(f"成功在本頁找到 {len(books)} 本書籍:") + print("=" * 70) + + for idx, book in enumerate(books, 1): + # === 【學員填空練習點 3:提取書籍名稱與連結】 === + # TODO: + # 1. 尋找

底下的 標籤 + # 2. 取得 的 "title" 屬性(因為畫面上長度限制,.get_text() 可能會被 ellipsis 截斷) + # 3. 取得 的 "href" 屬性並使用 urljoin 拼接成完整網址 + title_link = book.find("h3").find("a") + title = title_link.get("title") # 取得屬性 'title' 的值,避免拿到被截斷的文字 + + relative_url = title_link.get("href") + detail_url = urljoin(URL, relative_url) + + # === 【學員填空練習點 4:提取書籍價格】 === + # 提示:為了避免 Windows (CP950) 終端機不支援英鎊符號 '£' 而編碼崩潰,請加上 .replace("£", "GBP") 將其替換 + # 觀察:價格存放在 class 為 "price_color" 的

標籤中,格式如 "£51.77" + # 技巧:將英鎊符號 '£' 替換為 'GBP',避免在 Windows (CP950) 終端機印出時因不支援該字元而崩潰 + price_text = book.find("p", class_="price_color").get_text().replace("£", "GBP") + + # === 【學員填空練習點 5:提取評價星等 (Rating)】 === + # TODO: + # 1. 尋找 class 為 "star-rating" 的

標籤 + # 2. 取得該標籤的 "class" 屬性值(這會是一個串列,如 ['star-rating', 'Three']) + # 3. 提取該串列的第二個元素作為星等字串 (若存在) + + rating_tag = book.find("p", class_="star-rating") + rating_classes = rating_tag.get("class") + rating = "無" + if len(rating_classes) > 1: + rating = rating_classes[1] # 提取例如 'Three' (代表三顆星) + + # === 【學員填空練習點 6:提取庫存狀態】 === + # TODO: 尋找 class 為 "instock availability" 的

標籤,取得文字後去除首尾空白 (.strip()) + stock_text = book.find("p", class_="instock availability").get_text().strip() + + # 印出解析結果 + print(f"【編號 {idx}】") + print(f"[書名] {title}") + print(f"[價格] {price_text}") + print(f"[評價] {rating} 星") + print(f"[狀態] {stock_text}") + print(f"[連結] {detail_url}") + print("-" * 70) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] HTTP 連線異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex2_ptt.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex2_ptt.py" new file mode 100644 index 0000000000000000000000000000000000000000..4c0ad885893936a77642ecaba8a3ac6c53c8e88a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex2_ptt.py" @@ -0,0 +1,81 @@ +# d4_1_ex2_ptt.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex2_ptt.py +# 參考答案:../d4_1_ex2_ptt.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:PTT Python 討論板首頁 +URL = "https://www.ptt.cc/bbs/Python/index.html" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則 PTT 會拒絕請求並回傳 403 錯誤 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在抓取 PTT Python 板最新文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章區塊】 === + # TODO: 觀察網頁 HTML 結構,找出所有包裹文章資訊的

標籤(其 class 名稱為何?) + posts = soup.find_all("div", class_="r-ent") + + print(f"\n成功取得 {len(posts)} 筆文章項目(含已刪除與置底公告):") + print("=" * 80) + + for idx, post in enumerate(posts, 1): + # === 【學員填空練習點 3:提取文章標題與連結並防錯 (Null Check)】 === + # TODO: + # 1. 尋找 class 為 "title" 的
標籤 + # 2. 在該
底下尋找 標籤 (即文章連結) + # 3. ⚠️ 重要思考:若文章已刪除,將不會有 標籤。請撰寫條件判斷: + # - 若 標籤存在:提取其文字,並使用 urljoin 拼接 href 屬性得到完整網址。 + # - 若 標籤不存在:直接將 title_div 的文字作為標題,並將網址設為 "無 (文章已刪除)"。 + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get("title") + relative_url = title_link.get("href") + post_url = urljoin(URL, relative_url) + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + + # === 【學員填空練習點 4:提取推文數】 === + # TODO: 尋找 class 為 "nrec" 的
標籤。 + # 技巧:使用 .get_text().strip() 取得文字。若無文字(0 推),請將變數預設為 "0"。 + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + + # === 【學員填空練習點 5:提取作者與發文日期】 === + # TODO: 分別尋找 class 為 "author" 與 "date" 的
標籤,並提取純文字(記得使用 .strip() 去除空白) + author = post.find("div", class_="author").get_text().strip() + date = post.find("div", class_="date").get_text().strip() + + # 輸出單篇文章資訊 + print(f"[{idx}] 推文數: {nrec:3s} | 發文日期: {date} | 作者: {author}") + print(f" 標題: {title}") + print(f" 網址: {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] PTT 伺服器拒絕或找不到網頁:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex3_ithelp.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex3_ithelp.py" new file mode 100644 index 0000000000000000000000000000000000000000..c0299624ce527f07bab03c30f85184640b1ba40c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220/d4_1_ex3_ithelp.py" @@ -0,0 +1,98 @@ +# d4_1_ex3_ithelp.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex3_ithelp.py +# 參考答案:../d4_1_ex3_ithelp.py + +import requests +from bs4 import BeautifulSoup + +# 目標網站:iT 邦幫忙技術文章首頁 +URL = "https://ithelp.ithome.com.tw/articles?tab=tech" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則可能被伺服器拒絕連線 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至 iT 邦幫忙並抓取技術文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + print("連線成功!解析文章列表中...\n") + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章卡片區塊】 === + # TODO: 尋找所有包裹文章資訊的
標籤(其 class 為 "qa-list") + articles = soup.find_all("div", class_="qa-list") + + print(f"成功取得前 {len(articles)} 篇熱門技術文章:") + print("=" * 80) + + for idx, art in enumerate(articles, 1): + # === 【學員填空練習點 3:提取標題與超連結】 === + # TODO: + # 1. 在 art 中尋找 class 為 "qa-list__title-link" 的 標籤 + # 2. 提取其純文字內容並去除首尾空白 (.strip()),作為標題 + # 3. 提取其 "href" 屬性,作為文章連結 + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + # === 【學員填空練習點 4:提取作者名稱】 === + # TODO: + # 1. 尋找 class 為 "qa-list__info" 的
標籤 + # 2. 在該
下尋找 class 為 "qa-list__info-link" 的 標籤 + # 3. 提取其文字內容作為作者名稱 + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + # === 【學員填空練習點 5:提取發文時間】 === + # TODO: 尋找 class 為 "qa-list__info-time" 的 標籤,並提取其 "title" 屬性值(因 title 屬性中包含完整時間秒數) + time_link = art.find("div", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + # === 【學員填空練習點 6:利用 List 索引提取多項數據】 === + # TODO: + # 1. 使用 find_all 找出卡片中所有 class 為 "qa-condition" 的 標籤 + # 2. 當抓取到的數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + conditions = art.find_all("a", class_="qa-condition") + + likes = "0" + comments = "0" + views = "0" + + if len(conditions) >= 3: + # 請在此處填入利用 conditions[0]、conditions[1]、conditions[2] 尋找 span 的代碼 + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + # 格式化輸出 + print(f"【排名 {idx}】") + print(f"[標題] {title}") + print(f"[作者] {author} | [時間] {post_time}") + print(f"[數據] Like: {likes} | 回應: {comments} | 瀏覽/訂閱: {views}") + print(f"[連結] {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] 伺服器回應異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_1_web_scraping.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_1_web_scraping.py" new file mode 100644 index 0000000000000000000000000000000000000000..26792f288eaa50ea59f82a077985c21c5578d14d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_1_web_scraping.py" @@ -0,0 +1,68 @@ +# d4_1_web_scraping.py — Day4 學員填空 +# cd examples/Day4/student && python3 d4_1_web_scraping.py +# 參考答案:../d4_1_web_scraping.py + +import requests +from bs4 import BeautifulSoup + +# 本範例抓取專供網頁爬蟲教學使用的模擬沙盒網站:Quotes to Scrape +URL = "http://quotes.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線並抓取網站內容: {URL} ...") + +try: + # === 【學員填空練習點 2:連線請求與下載網頁】 === + # TODO: 1. 使用 requests.get 發送 GET 請求,帶入 headers=HEADERS 與設定 timeout=5 + # TODO: 2. 判斷 response.status_code 是否為 200 (下載成功) + response = requests.get(URL, headers=HEADERS, timeout=5) + + if response and response.status_code == 200: + print("網頁下載成功!開始解析 HTML 結構...") + + # === 【學員填空練習點 3:初始化 BeautifulSoup 解析器】 === + # TODO: 使用 BeautifulSoup 解析 response.text,初始化 html.parser + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 4:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有名言與作者的 div 標籤(其 class 為 'quote') + quote_divs = soup.find_all("div", class_="quote") + + print(f"\n成功解析!本日精選名言如下(共 {len(quote_divs)} 筆):") + print("=" * 60) + + # === 【學員填空練習點 5:迴圈遍歷提取內容與防錯】 === + # TODO: + # 1. 遍歷 quote_divs,並使用 enumerate 取得索引 idx(從 1 開始) + # 2. 用 .find() 尋找 span 且 class 為 'text' 的名言元素 + # 3. 用 .find() 尋找 small 且 class 為 'author' 的作者元素 + # 4. 進行 None 防錯處理,確保兩者皆非空值後,使用 .get_text() 提取純文字並印出 + for idx, quote_div in enumerate(quote_divs, 1): + text_el = quote_div.find("span", class_="text") + author_el = quote_div.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + + print(f"[{idx}] \"{text}\"") + print(f" -- by {author}\n") + + print("=" * 60) + print("提醒:請尊重 robots.txt 與網站使用條款,勿對目標網站發送高頻請求。") + else: + status = response.status_code if response else "未知" + print(f"[錯誤] 無法抓取網頁,伺服器回應狀態碼:{status}") + +except requests.exceptions.RequestException as e: + print(f"[錯誤] 連線失敗:{e}") + print("請確認電腦的網路連線是否通暢。") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_2_agent_task_chain.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_2_agent_task_chain.py" new file mode 100644 index 0000000000000000000000000000000000000000..00097ba707ea6242699529b0269de4855352fbff --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_2_agent_task_chain.py" @@ -0,0 +1,141 @@ +# d4_2_agent_task_chain.py +# Day 4: 自動化任務鏈 (Task Chain / 簡化版 Agent 工作流) +# 說明:本範例是「固定管線」——步驟由我們寫死,不是模型自主 function calling。 +# 目的是讓學生先理解:工具 + LLM + 記憶狀態 如何串成可交付的自動化流程。 + +import os +import requests +from bs4 import BeautifulSoup + +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + + +def plan_steps(topic): + """規劃階段:對學生展示任務拆解(固定計畫,非 LLM 自主規劃)。""" + steps = [ + f"1. 使用爬蟲工具擷取主題「{topic}」相關名言", + "2. 將原始文字作為 context 送入 LLM 摘要與翻譯", + "3. 將 Markdown 報告寫入本機檔案", + ] + print("[Planning] 任務計畫:") + for s in steps: + print(" ", s) + return steps + + +def tool_scrape_topic_quotes(topic): + """ + 工具 A:抓取 Quotes to Scrape 的標籤頁面內容。 + 例如 topic = 'love', 'life', 'inspirational' 等。 + """ + url = f"http://quotes.toscrape.com/tag/{topic}/" + print(f"[Tool] 執行網頁資料檢索。目標網址: {url}") + try: + response = requests.get(url, headers=HEADERS, timeout=5) + if response.status_code == 200: + soup = BeautifulSoup(response.text, "html.parser") + quote_divs = soup.find_all("div", class_="quote") + results = [] + for q in quote_divs: + text_el = q.find("span", class_="text") + author_el = q.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + results.append(f"名言: {text} | 作者: {author}") + return "\n".join(results) if results else "找不到與此主題相關的內容。" + return f"網頁讀取錯誤,代碼: {response.status_code}" + except Exception as e: + return f"工具執行異常: {e}" + + +def tool_save_report(filename, content): + """工具 B:將報告寫入本機檔案。""" + with open(filename, "w", encoding="utf-8") as f: + f.write(content) + return filename + + +def run_task_chain(topic): + print("=" * 60) + print(f"啟動自動化任務鏈。目標:整理與分析關於『{topic}』的文獻報告") + print("(模式:固定 Pipeline,非自主 Tool-Calling Agent)") + print("=" * 60) + + # 步驟 0: 規劃(教學展示) + plan_steps(topic) + + # 步驟 1: 呼叫檢索工具 + raw_data = tool_scrape_topic_quotes(topic) + print(f"\n[步驟 1] 檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 原始數據預覽 ---") + preview = raw_data[:200] + ("..." if len(raw_data) > 200 else "") + print(preview) + print("--------------------") + + # 工作記憶:保存中間狀態 + memory = {"topic": topic, "raw_data": raw_data, "report": ""} + + # 步驟 2: LLM 分析(或 Mock 降級) + if not api_key: + print("\n[步驟 2] 未偵測到 API Key。啟動模擬生成中...") + report_content = ( + f"# 模擬關於《{topic}》的名人名言分析報告\n\n" + f"## 一、 數據檢索摘要\n" + f"本次任務共檢索到數筆關於 {topic} 的語錄。以下為代表性文獻:\n" + f"- 模擬數據:\"Love is all, love is new.\" -- John Lennon\n\n" + f"## 二、 核心思想分析\n" + f"本機模擬分析指出,此主題的語錄主要著眼於人與人之間的連結與心靈啟發。\n" + f"*(注意:此為 Mock 離線生成報告,設定 GEMINI_API_KEY 以啟用 Gemini 真實生成。)*\n" + ) + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行深度分析與排版...") + try: + import google.generativeai as genai # 僅雲端模式需要 + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(GEMINI_MODEL) + + prompt = ( + f"請幫我分析以下關於『{topic}』的原始英文名言數據,並用繁體中文撰寫一份 Markdown 報告。\n" + f"報告必須包含三個部分:\n" + f"1. 導言:說明這個主題的核心意義。\n" + f"2. 名言分析:挑選原始資料中 2-3 句具代表性的句子進行翻譯與深度賞析。\n" + f"3. 結論:對學生的啟發與建議。\n\n" + f"=== 原始數據 ===\n" + f"{raw_data}" + ) + + response = model.generate_content(prompt) + report_content = response.text + print("[步驟 2] 雲端分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e}。轉為模擬輸出。") + report_content = f"# 分析失敗\n\n錯誤詳情:{e}\n" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"agent_report_{topic}.md" + try: + saved = tool_save_report(output_filename, report_content) + print(f"\n[步驟 3] 任務報告生成成功!已儲存為檔案: {saved}") + print("=" * 60) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + + +if __name__ == "__main__": + # 可更換為: 'love', 'life', 'inspirational', 'humor' 等 + test_topic = "s" + run_task_chain(test_topic) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_2_ex2_crawler_ai.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_2_ex2_crawler_ai.py" new file mode 100644 index 0000000000000000000000000000000000000000..f4e46c1409927f15a2eef9bdae4d30ab738be660 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\210\254\350\231\253\345\210\206\346\236\220\344\270\216AI\346\212\245\345\221\212/d4_2_ex2_crawler_ai.py" @@ -0,0 +1,231 @@ +# d4_2_ex2_crawler_ai.py +# Day 4: 爬蟲與 AI 分析任務鏈 (學生填空版) +# 執行方式:cd examples/Day4/student && python d4_2_ex2_crawler_ai.py + +import os +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin +from google import genai + +# 設定預設使用的 Gemini 模型 +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +def plan_steps(source_type): + """規劃階段:展示任務拆解流程。""" + steps = [ + f"步驟 1: 啟動爬蟲工具擷取『{source_type.upper()}』熱門文章與輿情數據", + "步驟 2: 將爬取資料作為 Context,呼叫 Gemini 進行輿情趨勢與熱門話題分析", + "步驟 3: 自動將 AI 生成的繁體中文分析報告以 UTF-8 編碼存為 Markdown 檔案" + ] + print("\n[Planning] 任務計畫已啟動:") + for s in steps: + print(f" -> {s}") + return steps + +def tool_scrape_ptt(): + """工具 A:抓取 PTT Python 板最新文章資訊。""" + url = "https://www.ptt.cc/bbs/Tennis/index.html" + print(f"\n[Tool] 正在爬取 PTT Python 板文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + posts = soup.find_all("div", class_="r-ent") + + results = [] + for post in posts: + + # === 【學員填空練習點 1:PTT 文章標題與連結提取及防錯 (Null Check)】 === + # TODO 1: 若文章已被作者或板主刪除,title_div 底下將會「沒有 標籤」 + # 1. 請在 title_div 底下尋找 標籤 (指派給 title_link 變數) + # 2. 撰寫條件判斷:若 title_link 存在,提取其純文字並用 urljoin 拼接成完整網址。 + # 若不存在,直接將 title_div 的純文字作為 title,且將連結 post_url 設為 "無 (文章已刪除)"。 + + # 請在此處實作 TODO 1 的邏輯... + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get_text().strip() + relative_url = title_link.get("href") + post_url = urljoin(url, relative_url) + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + if not nrec: + nrec = "0" + + author = post.find("div", class_="author").get_text().strip() if post.find("div", class_="author") else "未知作者" + date = post.find("div", class_="date").get_text().strip() if post.find("div", class_="date") else "未知日期" + + results.append(f"標題: {title} | 作者: {author} | 日期: {date} | 推文數: {nrec} | 網址: {post_url}") + + return "\n".join(results) if results else "找不到任何文章項目。" + except Exception as e: + return f"[PTT 爬蟲錯誤] 執行異常: {e}" + +def tool_scrape_ithelp(): + """工具 B:抓取 iT 邦幫忙熱門技術文章。""" + url = "https://ithelp.ithome.com.tw/articles?tab=tech" + print(f"\n[Tool] 正在爬取 iT 邦幫忙技術文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + articles = soup.find_all("div", class_="qa-list") + + results = [] + for art in articles: + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + time_link = art.find("a", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + conditions = art.find_all("a", class_="qa-condition") + likes, comments, views = "0", "0", "0" + + # === 【學員填空練習點 2:利用 List 索引提取多項數據】 === + # TODO 2: 當抓取到的 conditions 數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + if len(conditions) >= 3: + # 請在下方填入提取 Like、回應與瀏覽數的代碼: + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + results.append( + f"標題: {title} | 作者: {author} | 時間: {post_time} | " + f"Like: {likes} | 回應: {comments} | 瀏覽: {views} | 網址: {post_url}" + ) + + return "\n".join(results) if results else "找不到任何技術文章。" + except Exception as e: + return f"[iT邦幫忙爬蟲錯誤] 執行異常: {e}" + +def tool_save_report(filename, content): + """工具 C:將報告以 UTF-8 編碼寫入本機檔案。""" + print(f"\n[Tool] 正在將分析報告儲存至檔案: {filename} ...") + # === 【學員填空練習點 6:寫入檔案編碼處理】 === + # TODO 6: 請使用 with open 開啟檔案,以 "utf-8" 編碼寫入 content,並回傳 filename。 + # 提示:必須傳入 encoding="utf-8" 以避免 Windows 系統的中文亂碼 + with open(filename, "w", encoding="utf-8") as f: + f.write(content) + # 請在此處實作 TODO 6 ... + return filename + +def run_analysis_chain(source_type): + print("=" * 70) + print(f"啟動自動化輿情任務鏈。目標來源:『{source_type.upper()}』數據分析") + print("=" * 70) + + # 步驟 0: 展示規劃 + plan_steps(source_type) + + # 步驟 1: 執行對應的爬蟲工具 + if source_type.lower() == "ptt": + raw_data = tool_scrape_ptt() + elif source_type.lower() == "ithelp": + raw_data = tool_scrape_ithelp() + else: + print("[錯誤] 未支援的數據來源。僅支援 'ptt' 或 'ithelp'") + return + + print(f"\n[步驟 1] 資料檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 數據預覽 (前 200 字) ---") + print(raw_data[:200] + ("..." if len(raw_data) > 200 else "")) + print("----------------------------") + + # === 【學員填空練習點 3:任務鏈工作記憶與態狀維護】 === + # TODO 3: 請建立名為 memory 的字典 (dict),包含三個鍵值: + # "source" 指向來源 source_type、"raw_data" 指向原始爬取資料 raw_data、"report" 設為空字串 "" 用以儲存後續 AI 報告。 + memory = { + "source": source_type, + "raw_data": raw_data, + "report": "" + } + + # 步驟 2: AI 分析 (或 Mock 降級防禦) + if not api_key: + print("\n[步驟 2] 未偵測到環境變數 GEMINI_API_KEY,自動啟動 Mock 離線降級機制...") + # === 【學員填空練習點 5:Mock 降級防衛機制】 === + # TODO 5: 請設計一段模擬的 Markdown 格式分析報告字串,指派給 report_content。 + # 報告內容至少需要包含標題與數據摘要,避免程式在無 Key 時報錯中斷。 + report_content = ( + f"#模拟与情分析报告 - 数据来源:{source_type.upper()}\n\n" + ) + print("[步驟 2] Mock 報告模擬生成完畢。") + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行數據分析與排版...") + try: + # === 【學員填空練習點 4:新版 google-genai SDK 初始化與調用】 === + # TODO 4: + # 1. 呼叫 genai.Client 並傳入 api_key 來初始化 client 物件。 + # 2. 使用 client.models.generate_content 發送生成請求。 + # 傳入參數 model=GEMINI_MODEL,contents 則為準備好的 Prompt。 + client = genai.Client(api_key=api_key) + prompt = ( + f"請幫我分析以下來自 {source_type.upper()} 的最新網頁抓取文章列表,並用繁體中文撰寫一份 Markdown 网球分析報告。\n" + f"報告必須包含以下部分:\n" + f"1. 说网球界的名人。\n" + f"2. 说网球在哪时候被发明的。\n" + f"3. 说网球对身体有什么帮助。\n" + f"4. 说一些最精彩的比赛。\n\n" + f"=== 原始爬蟲數據 ===\n" + f"{raw_data}" + ) + + # 請在此處實作 TODO 4 ... + resopnse = client.models.generate_content( + model=GEMINI_MODEL, + contents=prompt + ) + report_content = "" + print("[步驟 2] 雲端 Gemini 分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e},自動轉為錯誤備份輸出。") + report_content = f"# 輿情分析失敗\n\n錯誤詳情:{e}\n\n=== 原始數據 ===\n{raw_data}" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"ai_analysis_report_{source_type}.md" + try: + saved_file = tool_save_report(output_filename, memory["report"]) + print(f"\n[步驟 3] 任務報告生成成功!已儲存至: {saved_file}") + print("=" * 70) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + +if __name__ == "__main__": + # 可以切換為 "ptt" 或 "ithelp" 進行測試 + run_analysis_chain("ptt") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\234\274\347\235\233\350\267\235\347\246\273\344\276\246\346\265\213/step0_eyeguard_template.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\234\274\347\235\233\350\267\235\347\246\273\344\276\246\346\265\213/step0_eyeguard_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..b21522bd482959dc1a7ba0aaa8bb3eb40382a5a7 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\347\234\274\347\235\233\350\267\235\347\246\273\344\276\246\346\265\213/step0_eyeguard_template.py" @@ -0,0 +1,156 @@ +""" +Week 12 - AI 生活工具箱:護眼大師 (Template 填空版) +===================================================== +本週目標: + 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案 + 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近 + 3. 在 Pygame 畫面上顯示即時狀態與警報 + 4. 加入防呆機制(try-except、None 檢查) + +本範例:護眼大師 EyeGuard + - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近) + - 超過安全閾值時發出警報文字 + 閃爍效果 + - 適用場景:打電動、寫作業時防止坐姿太差靠太近 + +注意: + - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。 +""" +import pygame +import cv2 +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision +import math +import sys +import os + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 48) +small_font = pygame.font.SysFont(None, 28) +big_font = pygame.font.SysFont(None, 72) + +# ============================================ +# 2. 攝影機與 AI 模組 +# ============================================ +cap = cv2.VideoCapture(0) +# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_face_video.mp4") + +# 【TODO 1】初始化 MediaPipe 臉部偵測 (Face Landmarker) +base_options = python.BaseOptions(model_asset_path=os.path.join('face_landmarker.task')) +options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +detector = vision.FaceLandmarker.create_from_options(options) + +# 提示:左眼的 Landmark 索引為 33,右眼為 263 + +# ============================================ +# 3. 遊戲變數 +# ============================================ +eye_distance = 0 +DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了! + +# 【TODO 2】警報狀態變數 +is_too_close = False +was_too_close = False # 邊緣觸發 +warning_timer = 0 # 警告閃爍計時器 + +# 統計數據 +total_warnings = 0 +safe_seconds = 0 +frame_counter = 0 + +# ============================================ +# 4. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機 --- + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + frame_counter += 1 + + # 【TODO 3】臉部偵測與兩眼距離計算 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + # + # # 取得左眼 (33) 和右眼 (263) 座標 + left_eye = face_lms[33] + right_eye = face_lms[263] + # + lx, ly = int(left_eye.x * WIDTH), int(left_eye.y * HEIGHT) + rx, ry = int(right_eye.x * WIDTH), int(right_eye.y * HEIGHT) + + # 計算兩眼之間的像素距離 + eye_distance = math.hypot(rx - lx, ry - ly) + + # 【TODO 4】判斷是否太近 + is_too_close = eye_distance > DANGER_THRESHOLD + + # 【TODO 5】邊緣觸發:剛進入危險區才播放警報 + if is_too_close and not was_too_close: + total_warnings += 1 + warning_timer = 60 # 閃爍 60 幀 + print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!") + + was_too_close = is_too_close + + # --- 繪製畫面 --- + # 【TODO 6】根據狀態切換背景顏色 + if is_too_close: + if warning_timer > 0 and warning_timer % 4 < 2: + screen.fill((80, 20, 20)) # 閃爍紅色 + else: + screen.fill((50, 15, 15)) # 暗紅色 + warning_timer = max(0, warning_timer - 1) + else: + screen.fill((15, 30, 20)) # 安全綠色 + screen.fill((20, 20, 35)) # 預設背景 + + # 【TODO 7】繪製距離量表 + # 提示:用 pygame.draw.rect 畫一個長條,長度 = eye_distance 的比例 + # 安全時綠色,危險時紅色 + if is_too_close: + pygame.draw.rect(screen, (255, 80, 80), (50, HEIGHT - 50, min(eye_distance * 2, WIDTH - 100), 20)) + else: + pygame.draw.rect(screen, (80, 200, 100), (50, HEIGHT - 50, min(eye_distance * 2, WIDTH - 100), 20)) + # 【TODO 8】如果太近,顯示大字警告 + if is_too_close: + warn_text = big_font.render("⚠️ TOO CLOSE!", True, (255, 80, 80)) + screen.blit(warn_text, (WIDTH//2 - warn_text.get_width()//2, HEIGHT//2 - 40)) + else: + safe_text = font.render("✅ Safe Distance", True, (80, 200, 100)) + screen.blit(safe_text, (WIDTH//2 - safe_text.get_width()//2, HEIGHT//2 - 20)) + + # 顯示數值 + info = small_font.render(f"Eye Distance: {eye_distance:.0f} px | Threshold: {DANGER_THRESHOLD}", True, (150, 150, 180)) + screen.blit(info, (10, 10)) + + stats = small_font.render(f"Warnings: {total_warnings}", True, (150, 150, 180)) + screen.blit(stats, (10, 40)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/face_mesh_connections.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/face_mesh_connections.py" new file mode 100644 index 0000000000000000000000000000000000000000..d44fb79be4bbfa929f876c2b0a22dc3e659492ae --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/face_mesh_connections.py" @@ -0,0 +1,500 @@ +# Copyright 2021 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe FaceMesh connections.""" + +FACEMESH_LIPS = frozenset([(61, 146), (146, 91), (91, 181), (181, 84), (84, 17), + (17, 314), (314, 405), (405, 321), (321, 375), + (375, 291), (61, 185), (185, 40), (40, 39), (39, 37), + (37, 0), (0, 267), + (267, 269), (269, 270), (270, 409), (409, 291), + (78, 95), (95, 88), (88, 178), (178, 87), (87, 14), + (14, 317), (317, 402), (402, 318), (318, 324), + (324, 308), (78, 191), (191, 80), (80, 81), (81, 82), + (82, 13), (13, 312), (312, 311), (311, 310), + (310, 415), (415, 308)]) + +FACEMESH_LEFT_EYE = frozenset([(263, 249), (249, 390), (390, 373), (373, 374), + (374, 380), (380, 381), (381, 382), (382, 362), + (263, 466), (466, 388), (388, 387), (387, 386), + (386, 385), (385, 384), (384, 398), (398, 362)]) + +FACEMESH_LEFT_IRIS = frozenset([(474, 475), (475, 476), (476, 477), + (477, 474)]) + +FACEMESH_LEFT_EYEBROW = frozenset([(276, 283), (283, 282), (282, 295), + (295, 285), (300, 293), (293, 334), + (334, 296), (296, 336)]) + +FACEMESH_RIGHT_EYE = frozenset([(33, 7), (7, 163), (163, 144), (144, 145), + (145, 153), (153, 154), (154, 155), (155, 133), + (33, 246), (246, 161), (161, 160), (160, 159), + (159, 158), (158, 157), (157, 173), (173, 133)]) + +FACEMESH_RIGHT_EYEBROW = frozenset([(46, 53), (53, 52), (52, 65), (65, 55), + (70, 63), (63, 105), (105, 66), (66, 107)]) + +FACEMESH_RIGHT_IRIS = frozenset([(469, 470), (470, 471), (471, 472), + (472, 469)]) + +FACEMESH_FACE_OVAL = frozenset([(10, 338), (338, 297), (297, 332), (332, 284), + (284, 251), (251, 389), (389, 356), (356, 454), + (454, 323), (323, 361), (361, 288), (288, 397), + (397, 365), (365, 379), (379, 378), (378, 400), + (400, 377), (377, 152), (152, 148), (148, 176), + (176, 149), (149, 150), (150, 136), (136, 172), + (172, 58), (58, 132), (132, 93), (93, 234), + (234, 127), (127, 162), (162, 21), (21, 54), + (54, 103), (103, 67), (67, 109), (109, 10)]) + +FACEMESH_NOSE = frozenset([(168, 6), (6, 197), (197, 195), (195, 5), + (5, 4), (4, 1), (1, 19), (19, 94), (94, 2), (98, 97), + (97, 2), (2, 326), (326, 327), (327, 294), + (294, 278), (278, 344), (344, 440), (440, 275), + (275, 4), (4, 45), (45, 220), (220, 115), (115, 48), + (48, 64), (64, 98)]) + +FACEMESH_CONTOURS = frozenset().union(*[ + FACEMESH_LIPS, FACEMESH_LEFT_EYE, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYE, + FACEMESH_RIGHT_EYEBROW, FACEMESH_FACE_OVAL +]) + +FACEMESH_IRISES = frozenset().union(*[FACEMESH_LEFT_IRIS, FACEMESH_RIGHT_IRIS]) + +FACEMESH_TESSELATION = frozenset([ + (127, 34), (34, 139), (139, 127), (11, 0), (0, 37), (37, 11), + (232, 231), (231, 120), (120, 232), (72, 37), (37, 39), (39, 72), + (128, 121), (121, 47), (47, 128), (232, 121), (121, 128), (128, 232), + (104, 69), (69, 67), (67, 104), (175, 171), (171, 148), (148, 175), + (118, 50), (50, 101), (101, 118), (73, 39), (39, 40), (40, 73), + (9, 151), (151, 108), (108, 9), (48, 115), (115, 131), (131, 48), + (194, 204), (204, 211), (211, 194), (74, 40), (40, 185), (185, 74), + (80, 42), (42, 183), (183, 80), (40, 92), (92, 186), (186, 40), + (230, 229), (229, 118), (118, 230), (202, 212), (212, 214), (214, 202), + (83, 18), (18, 17), (17, 83), (76, 61), (61, 146), (146, 76), + (160, 29), (29, 30), (30, 160), (56, 157), (157, 173), (173, 56), + (106, 204), (204, 194), (194, 106), (135, 214), (214, 192), (192, 135), + (203, 165), (165, 98), (98, 203), (21, 71), (71, 68), (68, 21), + (51, 45), (45, 4), (4, 51), (144, 24), (24, 23), (23, 144), + (77, 146), (146, 91), (91, 77), (205, 50), (50, 187), (187, 205), + (201, 200), (200, 18), (18, 201), (91, 106), (106, 182), (182, 91), + (90, 91), (91, 181), (181, 90), (85, 84), (84, 17), (17, 85), + (206, 203), (203, 36), (36, 206), (148, 171), (171, 140), (140, 148), + (92, 40), (40, 39), (39, 92), (193, 189), (189, 244), (244, 193), + (159, 158), (158, 28), (28, 159), (247, 246), (246, 161), (161, 247), + (236, 3), (3, 196), (196, 236), (54, 68), (68, 104), (104, 54), + (193, 168), (168, 8), (8, 193), (117, 228), (228, 31), (31, 117), + (189, 193), (193, 55), (55, 189), (98, 97), (97, 99), (99, 98), + (126, 47), (47, 100), (100, 126), (166, 79), (79, 218), (218, 166), + (155, 154), (154, 26), (26, 155), (209, 49), (49, 131), (131, 209), + (135, 136), (136, 150), (150, 135), (47, 126), (126, 217), (217, 47), + (223, 52), (52, 53), (53, 223), (45, 51), (51, 134), (134, 45), + (211, 170), (170, 140), (140, 211), (67, 69), (69, 108), (108, 67), + (43, 106), (106, 91), (91, 43), (230, 119), (119, 120), (120, 230), + (226, 130), (130, 247), (247, 226), (63, 53), (53, 52), (52, 63), + (238, 20), (20, 242), (242, 238), (46, 70), (70, 156), (156, 46), + (78, 62), (62, 96), (96, 78), (46, 53), (53, 63), (63, 46), + (143, 34), (34, 227), (227, 143), (123, 117), (117, 111), (111, 123), + (44, 125), (125, 19), (19, 44), (236, 134), (134, 51), (51, 236), + (216, 206), (206, 205), (205, 216), (154, 153), (153, 22), (22, 154), + (39, 37), (37, 167), (167, 39), (200, 201), (201, 208), (208, 200), + (36, 142), (142, 100), (100, 36), (57, 212), (212, 202), (202, 57), + (20, 60), (60, 99), (99, 20), (28, 158), (158, 157), (157, 28), + (35, 226), (226, 113), (113, 35), (160, 159), (159, 27), (27, 160), + (204, 202), (202, 210), (210, 204), (113, 225), (225, 46), (46, 113), + (43, 202), (202, 204), (204, 43), (62, 76), (76, 77), (77, 62), + (137, 123), (123, 116), (116, 137), (41, 38), (38, 72), (72, 41), + (203, 129), (129, 142), (142, 203), (64, 98), (98, 240), (240, 64), + (49, 102), (102, 64), (64, 49), (41, 73), (73, 74), (74, 41), + (212, 216), (216, 207), (207, 212), (42, 74), (74, 184), (184, 42), + (169, 170), (170, 211), (211, 169), (170, 149), (149, 176), (176, 170), + (105, 66), (66, 69), (69, 105), (122, 6), (6, 168), (168, 122), + (123, 147), (147, 187), (187, 123), (96, 77), (77, 90), (90, 96), + (65, 55), (55, 107), (107, 65), (89, 90), (90, 180), (180, 89), + (101, 100), (100, 120), (120, 101), (63, 105), (105, 104), (104, 63), + (93, 137), (137, 227), (227, 93), (15, 86), (86, 85), (85, 15), + (129, 102), (102, 49), (49, 129), (14, 87), (87, 86), (86, 14), + (55, 8), (8, 9), (9, 55), (100, 47), (47, 121), (121, 100), + (145, 23), (23, 22), (22, 145), (88, 89), (89, 179), (179, 88), + (6, 122), (122, 196), (196, 6), (88, 95), (95, 96), (96, 88), + (138, 172), (172, 136), (136, 138), (215, 58), (58, 172), (172, 215), + (115, 48), (48, 219), (219, 115), (42, 80), (80, 81), (81, 42), + (195, 3), (3, 51), (51, 195), (43, 146), (146, 61), (61, 43), + (171, 175), (175, 199), (199, 171), (81, 82), (82, 38), (38, 81), + (53, 46), (46, 225), (225, 53), (144, 163), (163, 110), (110, 144), + (52, 65), (65, 66), (66, 52), (229, 228), (228, 117), (117, 229), + (34, 127), (127, 234), (234, 34), (107, 108), (108, 69), (69, 107), + (109, 108), (108, 151), (151, 109), (48, 64), (64, 235), (235, 48), + (62, 78), (78, 191), (191, 62), (129, 209), (209, 126), (126, 129), + (111, 35), (35, 143), (143, 111), (117, 123), (123, 50), (50, 117), + (222, 65), (65, 52), (52, 222), (19, 125), (125, 141), (141, 19), + (221, 55), (55, 65), (65, 221), (3, 195), (195, 197), (197, 3), + (25, 7), (7, 33), (33, 25), (220, 237), (237, 44), (44, 220), + (70, 71), (71, 139), (139, 70), (122, 193), (193, 245), (245, 122), + (247, 130), (130, 33), (33, 247), (71, 21), (21, 162), (162, 71), + (170, 169), (169, 150), (150, 170), (188, 174), (174, 196), (196, 188), + (216, 186), (186, 92), (92, 216), (2, 97), (97, 167), (167, 2), + (141, 125), (125, 241), (241, 141), (164, 167), (167, 37), (37, 164), + (72, 38), (38, 12), (12, 72), (38, 82), (82, 13), (13, 38), + (63, 68), (68, 71), (71, 63), (226, 35), (35, 111), (111, 226), + (101, 50), (50, 205), (205, 101), (206, 92), (92, 165), (165, 206), + (209, 198), (198, 217), (217, 209), (165, 167), (167, 97), (97, 165), + (220, 115), (115, 218), (218, 220), (133, 112), (112, 243), (243, 133), + (239, 238), (238, 241), (241, 239), (214, 135), (135, 169), (169, 214), + (190, 173), (173, 133), (133, 190), (171, 208), (208, 32), (32, 171), + (125, 44), (44, 237), (237, 125), (86, 87), (87, 178), (178, 86), + (85, 86), (86, 179), (179, 85), (84, 85), (85, 180), (180, 84), + (83, 84), (84, 181), (181, 83), (201, 83), (83, 182), (182, 201), + (137, 93), (93, 132), (132, 137), (76, 62), (62, 183), (183, 76), + (61, 76), (76, 184), (184, 61), (57, 61), (61, 185), (185, 57), + (212, 57), (57, 186), (186, 212), (214, 207), (207, 187), (187, 214), + (34, 143), (143, 156), (156, 34), (79, 239), (239, 237), (237, 79), + (123, 137), (137, 177), (177, 123), (44, 1), (1, 4), (4, 44), + (201, 194), (194, 32), (32, 201), (64, 102), (102, 129), (129, 64), + (213, 215), (215, 138), (138, 213), (59, 166), (166, 219), (219, 59), + (242, 99), (99, 97), (97, 242), (2, 94), (94, 141), (141, 2), + (75, 59), (59, 235), (235, 75), (24, 110), (110, 228), (228, 24), + (25, 130), (130, 226), (226, 25), (23, 24), (24, 229), (229, 23), + (22, 23), (23, 230), (230, 22), (26, 22), (22, 231), (231, 26), + (112, 26), (26, 232), (232, 112), (189, 190), (190, 243), (243, 189), + (221, 56), (56, 190), (190, 221), (28, 56), (56, 221), (221, 28), + (27, 28), (28, 222), (222, 27), (29, 27), (27, 223), (223, 29), + (30, 29), (29, 224), (224, 30), (247, 30), (30, 225), (225, 247), + (238, 79), (79, 20), (20, 238), (166, 59), (59, 75), (75, 166), + (60, 75), (75, 240), (240, 60), (147, 177), (177, 215), (215, 147), + (20, 79), (79, 166), (166, 20), (187, 147), (147, 213), (213, 187), + (112, 233), (233, 244), (244, 112), (233, 128), (128, 245), (245, 233), + (128, 114), (114, 188), (188, 128), (114, 217), (217, 174), (174, 114), + (131, 115), (115, 220), (220, 131), (217, 198), (198, 236), (236, 217), + (198, 131), (131, 134), (134, 198), (177, 132), (132, 58), (58, 177), + (143, 35), (35, 124), (124, 143), (110, 163), (163, 7), (7, 110), + (228, 110), (110, 25), (25, 228), (356, 389), (389, 368), (368, 356), + (11, 302), (302, 267), (267, 11), (452, 350), (350, 349), (349, 452), + (302, 303), (303, 269), (269, 302), (357, 343), (343, 277), (277, 357), + (452, 453), (453, 357), (357, 452), (333, 332), (332, 297), (297, 333), + (175, 152), (152, 377), (377, 175), (347, 348), (348, 330), (330, 347), + (303, 304), (304, 270), (270, 303), (9, 336), (336, 337), (337, 9), + (278, 279), (279, 360), (360, 278), (418, 262), (262, 431), (431, 418), + (304, 408), (408, 409), (409, 304), (310, 415), (415, 407), (407, 310), + (270, 409), (409, 410), (410, 270), (450, 348), (348, 347), (347, 450), + (422, 430), (430, 434), (434, 422), (313, 314), (314, 17), (17, 313), + (306, 307), (307, 375), (375, 306), (387, 388), (388, 260), (260, 387), + (286, 414), (414, 398), (398, 286), (335, 406), (406, 418), (418, 335), + (364, 367), (367, 416), (416, 364), (423, 358), (358, 327), (327, 423), + (251, 284), (284, 298), (298, 251), (281, 5), (5, 4), (4, 281), + (373, 374), (374, 253), (253, 373), (307, 320), (320, 321), (321, 307), + (425, 427), (427, 411), (411, 425), (421, 313), (313, 18), (18, 421), + (321, 405), (405, 406), (406, 321), (320, 404), (404, 405), (405, 320), + (315, 16), (16, 17), (17, 315), (426, 425), (425, 266), (266, 426), + (377, 400), (400, 369), (369, 377), (322, 391), (391, 269), (269, 322), + (417, 465), (465, 464), (464, 417), (386, 257), (257, 258), (258, 386), + (466, 260), (260, 388), (388, 466), (456, 399), (399, 419), (419, 456), + (284, 332), (332, 333), (333, 284), (417, 285), (285, 8), (8, 417), + (346, 340), (340, 261), (261, 346), (413, 441), (441, 285), (285, 413), + (327, 460), (460, 328), (328, 327), (355, 371), (371, 329), (329, 355), + (392, 439), (439, 438), (438, 392), (382, 341), (341, 256), (256, 382), + (429, 420), (420, 360), (360, 429), (364, 394), (394, 379), (379, 364), + (277, 343), (343, 437), (437, 277), (443, 444), (444, 283), (283, 443), + (275, 440), (440, 363), (363, 275), (431, 262), (262, 369), (369, 431), + (297, 338), (338, 337), (337, 297), (273, 375), (375, 321), (321, 273), + (450, 451), (451, 349), (349, 450), (446, 342), (342, 467), (467, 446), + (293, 334), (334, 282), (282, 293), (458, 461), (461, 462), (462, 458), + (276, 353), (353, 383), (383, 276), (308, 324), (324, 325), (325, 308), + (276, 300), (300, 293), (293, 276), (372, 345), (345, 447), (447, 372), + (352, 345), (345, 340), (340, 352), (274, 1), (1, 19), (19, 274), + (456, 248), (248, 281), (281, 456), (436, 427), (427, 425), (425, 436), + (381, 256), (256, 252), (252, 381), (269, 391), (391, 393), (393, 269), + (200, 199), (199, 428), (428, 200), (266, 330), (330, 329), (329, 266), + (287, 273), (273, 422), (422, 287), (250, 462), (462, 328), (328, 250), + (258, 286), (286, 384), (384, 258), (265, 353), (353, 342), (342, 265), + (387, 259), (259, 257), (257, 387), (424, 431), (431, 430), (430, 424), + (342, 353), (353, 276), (276, 342), (273, 335), (335, 424), (424, 273), + (292, 325), (325, 307), (307, 292), (366, 447), (447, 345), (345, 366), + (271, 303), (303, 302), (302, 271), (423, 266), (266, 371), (371, 423), + (294, 455), (455, 460), (460, 294), (279, 278), (278, 294), (294, 279), + (271, 272), (272, 304), (304, 271), (432, 434), (434, 427), (427, 432), + (272, 407), (407, 408), (408, 272), (394, 430), (430, 431), (431, 394), + (395, 369), (369, 400), (400, 395), (334, 333), (333, 299), (299, 334), + (351, 417), (417, 168), (168, 351), (352, 280), (280, 411), (411, 352), + (325, 319), (319, 320), (320, 325), (295, 296), (296, 336), (336, 295), + (319, 403), (403, 404), (404, 319), (330, 348), (348, 349), (349, 330), + (293, 298), (298, 333), (333, 293), (323, 454), (454, 447), (447, 323), + (15, 16), (16, 315), (315, 15), (358, 429), (429, 279), (279, 358), + (14, 15), (15, 316), (316, 14), (285, 336), (336, 9), (9, 285), + (329, 349), (349, 350), (350, 329), (374, 380), (380, 252), (252, 374), + (318, 402), (402, 403), (403, 318), (6, 197), (197, 419), (419, 6), + (318, 319), (319, 325), (325, 318), (367, 364), (364, 365), (365, 367), + (435, 367), (367, 397), (397, 435), (344, 438), (438, 439), (439, 344), + (272, 271), (271, 311), (311, 272), (195, 5), (5, 281), (281, 195), + (273, 287), (287, 291), (291, 273), (396, 428), (428, 199), (199, 396), + (311, 271), (271, 268), (268, 311), (283, 444), (444, 445), (445, 283), + (373, 254), (254, 339), (339, 373), (282, 334), (334, 296), (296, 282), + (449, 347), (347, 346), (346, 449), (264, 447), (447, 454), (454, 264), + (336, 296), (296, 299), (299, 336), (338, 10), (10, 151), (151, 338), + (278, 439), (439, 455), (455, 278), (292, 407), (407, 415), (415, 292), + (358, 371), (371, 355), (355, 358), (340, 345), (345, 372), (372, 340), + (346, 347), (347, 280), (280, 346), (442, 443), (443, 282), (282, 442), + (19, 94), (94, 370), (370, 19), (441, 442), (442, 295), (295, 441), + (248, 419), (419, 197), (197, 248), (263, 255), (255, 359), (359, 263), + (440, 275), (275, 274), (274, 440), (300, 383), (383, 368), (368, 300), + (351, 412), (412, 465), (465, 351), (263, 467), (467, 466), (466, 263), + (301, 368), (368, 389), (389, 301), (395, 378), (378, 379), (379, 395), + (412, 351), (351, 419), (419, 412), (436, 426), (426, 322), (322, 436), + (2, 164), (164, 393), (393, 2), (370, 462), (462, 461), (461, 370), + (164, 0), (0, 267), (267, 164), (302, 11), (11, 12), (12, 302), + (268, 12), (12, 13), (13, 268), (293, 300), (300, 301), (301, 293), + (446, 261), (261, 340), (340, 446), (330, 266), (266, 425), (425, 330), + (426, 423), (423, 391), (391, 426), (429, 355), (355, 437), (437, 429), + (391, 327), (327, 326), (326, 391), (440, 457), (457, 438), (438, 440), + (341, 382), (382, 362), (362, 341), (459, 457), (457, 461), (461, 459), + (434, 430), (430, 394), (394, 434), (414, 463), (463, 362), (362, 414), + (396, 369), (369, 262), (262, 396), (354, 461), (461, 457), (457, 354), + (316, 403), (403, 402), (402, 316), (315, 404), (404, 403), (403, 315), + (314, 405), (405, 404), (404, 314), (313, 406), (406, 405), (405, 313), + (421, 418), (418, 406), (406, 421), (366, 401), (401, 361), (361, 366), + (306, 408), (408, 407), (407, 306), (291, 409), (409, 408), (408, 291), + (287, 410), (410, 409), (409, 287), (432, 436), (436, 410), (410, 432), + (434, 416), (416, 411), (411, 434), (264, 368), (368, 383), (383, 264), + (309, 438), (438, 457), (457, 309), (352, 376), (376, 401), (401, 352), + (274, 275), (275, 4), (4, 274), (421, 428), (428, 262), (262, 421), + (294, 327), (327, 358), (358, 294), (433, 416), (416, 367), (367, 433), + (289, 455), (455, 439), (439, 289), (462, 370), (370, 326), (326, 462), + (2, 326), (326, 370), (370, 2), (305, 460), (460, 455), (455, 305), + (254, 449), (449, 448), (448, 254), (255, 261), (261, 446), (446, 255), + (253, 450), (450, 449), (449, 253), (252, 451), (451, 450), (450, 252), + (256, 452), (452, 451), (451, 256), (341, 453), (453, 452), (452, 341), + (413, 464), (464, 463), (463, 413), (441, 413), (413, 414), (414, 441), + (258, 442), (442, 441), (441, 258), (257, 443), (443, 442), (442, 257), + (259, 444), (444, 443), (443, 259), (260, 445), (445, 444), (444, 260), + (467, 342), (342, 445), (445, 467), (459, 458), (458, 250), (250, 459), + (289, 392), (392, 290), (290, 289), (290, 328), (328, 460), (460, 290), + (376, 433), (433, 435), (435, 376), (250, 290), (290, 392), (392, 250), + (411, 416), (416, 433), (433, 411), (341, 463), (463, 464), (464, 341), + (453, 464), (464, 465), (465, 453), (357, 465), (465, 412), (412, 357), + (343, 412), (412, 399), (399, 343), (360, 363), (363, 440), (440, 360), + (437, 399), (399, 456), (456, 437), (420, 456), (456, 363), (363, 420), + (401, 435), (435, 288), (288, 401), (372, 383), (383, 353), (353, 372), + (339, 255), (255, 249), (249, 339), (448, 261), (261, 255), (255, 448), + (133, 243), (243, 190), (190, 133), (133, 155), (155, 112), (112, 133), + (33, 246), (246, 247), (247, 33), (33, 130), (130, 25), (25, 33), + (398, 384), (384, 286), (286, 398), (362, 398), (398, 414), (414, 362), + (362, 463), (463, 341), (341, 362), (263, 359), (359, 467), (467, 263), + (263, 249), (249, 255), (255, 263), (466, 467), (467, 260), (260, 466), + (75, 60), (60, 166), (166, 75), (238, 239), (239, 79), (79, 238), + (162, 127), (127, 139), (139, 162), (72, 11), (11, 37), (37, 72), + (121, 232), (232, 120), (120, 121), (73, 72), (72, 39), (39, 73), + (114, 128), (128, 47), (47, 114), (233, 232), (232, 128), (128, 233), + (103, 104), (104, 67), (67, 103), (152, 175), (175, 148), (148, 152), + (119, 118), (118, 101), (101, 119), (74, 73), (73, 40), (40, 74), + (107, 9), (9, 108), (108, 107), (49, 48), (48, 131), (131, 49), + (32, 194), (194, 211), (211, 32), (184, 74), (74, 185), (185, 184), + (191, 80), (80, 183), (183, 191), (185, 40), (40, 186), (186, 185), + (119, 230), (230, 118), (118, 119), (210, 202), (202, 214), (214, 210), + (84, 83), (83, 17), (17, 84), (77, 76), (76, 146), (146, 77), + (161, 160), (160, 30), (30, 161), (190, 56), (56, 173), (173, 190), + (182, 106), (106, 194), (194, 182), (138, 135), (135, 192), (192, 138), + (129, 203), (203, 98), (98, 129), (54, 21), (21, 68), (68, 54), + (5, 51), (51, 4), (4, 5), (145, 144), (144, 23), (23, 145), + (90, 77), (77, 91), (91, 90), (207, 205), (205, 187), (187, 207), + (83, 201), (201, 18), (18, 83), (181, 91), (91, 182), (182, 181), + (180, 90), (90, 181), (181, 180), (16, 85), (85, 17), (17, 16), + (205, 206), (206, 36), (36, 205), (176, 148), (148, 140), (140, 176), + (165, 92), (92, 39), (39, 165), (245, 193), (193, 244), (244, 245), + (27, 159), (159, 28), (28, 27), (30, 247), (247, 161), (161, 30), + (174, 236), (236, 196), (196, 174), (103, 54), (54, 104), (104, 103), + (55, 193), (193, 8), (8, 55), (111, 117), (117, 31), (31, 111), + (221, 189), (189, 55), (55, 221), (240, 98), (98, 99), (99, 240), + (142, 126), (126, 100), (100, 142), (219, 166), (166, 218), (218, 219), + (112, 155), (155, 26), (26, 112), (198, 209), (209, 131), (131, 198), + (169, 135), (135, 150), (150, 169), (114, 47), (47, 217), (217, 114), + (224, 223), (223, 53), (53, 224), (220, 45), (45, 134), (134, 220), + (32, 211), (211, 140), (140, 32), (109, 67), (67, 108), (108, 109), + (146, 43), (43, 91), (91, 146), (231, 230), (230, 120), (120, 231), + (113, 226), (226, 247), (247, 113), (105, 63), (63, 52), (52, 105), + (241, 238), (238, 242), (242, 241), (124, 46), (46, 156), (156, 124), + (95, 78), (78, 96), (96, 95), (70, 46), (46, 63), (63, 70), + (116, 143), (143, 227), (227, 116), (116, 123), (123, 111), (111, 116), + (1, 44), (44, 19), (19, 1), (3, 236), (236, 51), (51, 3), + (207, 216), (216, 205), (205, 207), (26, 154), (154, 22), (22, 26), + (165, 39), (39, 167), (167, 165), (199, 200), (200, 208), (208, 199), + (101, 36), (36, 100), (100, 101), (43, 57), (57, 202), (202, 43), + (242, 20), (20, 99), (99, 242), (56, 28), (28, 157), (157, 56), + (124, 35), (35, 113), (113, 124), (29, 160), (160, 27), (27, 29), + (211, 204), (204, 210), (210, 211), (124, 113), (113, 46), (46, 124), + (106, 43), (43, 204), (204, 106), (96, 62), (62, 77), (77, 96), + (227, 137), (137, 116), (116, 227), (73, 41), (41, 72), (72, 73), + (36, 203), (203, 142), (142, 36), (235, 64), (64, 240), (240, 235), + (48, 49), (49, 64), (64, 48), (42, 41), (41, 74), (74, 42), + (214, 212), (212, 207), (207, 214), (183, 42), (42, 184), (184, 183), + (210, 169), (169, 211), (211, 210), (140, 170), (170, 176), (176, 140), + (104, 105), (105, 69), (69, 104), (193, 122), (122, 168), (168, 193), + (50, 123), (123, 187), (187, 50), (89, 96), (96, 90), (90, 89), + (66, 65), (65, 107), (107, 66), (179, 89), (89, 180), (180, 179), + (119, 101), (101, 120), (120, 119), (68, 63), (63, 104), (104, 68), + (234, 93), (93, 227), (227, 234), (16, 15), (15, 85), (85, 16), + (209, 129), (129, 49), (49, 209), (15, 14), (14, 86), (86, 15), + (107, 55), (55, 9), (9, 107), (120, 100), (100, 121), (121, 120), + (153, 145), (145, 22), (22, 153), (178, 88), (88, 179), (179, 178), + (197, 6), (6, 196), (196, 197), (89, 88), (88, 96), (96, 89), + (135, 138), (138, 136), (136, 135), (138, 215), (215, 172), (172, 138), + (218, 115), (115, 219), (219, 218), (41, 42), (42, 81), (81, 41), + (5, 195), (195, 51), (51, 5), (57, 43), (43, 61), (61, 57), + (208, 171), (171, 199), (199, 208), (41, 81), (81, 38), (38, 41), + (224, 53), (53, 225), (225, 224), (24, 144), (144, 110), (110, 24), + (105, 52), (52, 66), (66, 105), (118, 229), (229, 117), (117, 118), + (227, 34), (34, 234), (234, 227), (66, 107), (107, 69), (69, 66), + (10, 109), (109, 151), (151, 10), (219, 48), (48, 235), (235, 219), + (183, 62), (62, 191), (191, 183), (142, 129), (129, 126), (126, 142), + (116, 111), (111, 143), (143, 116), (118, 117), (117, 50), (50, 118), + (223, 222), (222, 52), (52, 223), (94, 19), (19, 141), (141, 94), + (222, 221), (221, 65), (65, 222), (196, 3), (3, 197), (197, 196), + (45, 220), (220, 44), (44, 45), (156, 70), (70, 139), (139, 156), + (188, 122), (122, 245), (245, 188), (139, 71), (71, 162), (162, 139), + (149, 170), (170, 150), (150, 149), (122, 188), (188, 196), (196, 122), + (206, 216), (216, 92), (92, 206), (164, 2), (2, 167), (167, 164), + (242, 141), (141, 241), (241, 242), (0, 164), (164, 37), (37, 0), + (11, 72), (72, 12), (12, 11), (12, 38), (38, 13), (13, 12), + (70, 63), (63, 71), (71, 70), (31, 226), (226, 111), (111, 31), + (36, 101), (101, 205), (205, 36), (203, 206), (206, 165), (165, 203), + (126, 209), (209, 217), (217, 126), (98, 165), (165, 97), (97, 98), + (237, 220), (220, 218), (218, 237), (237, 239), (239, 241), (241, 237), + (210, 214), (214, 169), (169, 210), (140, 171), (171, 32), (32, 140), + (241, 125), (125, 237), (237, 241), (179, 86), (86, 178), (178, 179), + (180, 85), (85, 179), (179, 180), (181, 84), (84, 180), (180, 181), + (182, 83), (83, 181), (181, 182), (194, 201), (201, 182), (182, 194), + (177, 137), (137, 132), (132, 177), (184, 76), (76, 183), (183, 184), + (185, 61), (61, 184), (184, 185), (186, 57), (57, 185), (185, 186), + (216, 212), (212, 186), (186, 216), (192, 214), (214, 187), (187, 192), + (139, 34), (34, 156), (156, 139), (218, 79), (79, 237), (237, 218), + (147, 123), (123, 177), (177, 147), (45, 44), (44, 4), (4, 45), + (208, 201), (201, 32), (32, 208), (98, 64), (64, 129), (129, 98), + (192, 213), (213, 138), (138, 192), (235, 59), (59, 219), (219, 235), + (141, 242), (242, 97), (97, 141), (97, 2), (2, 141), (141, 97), + (240, 75), (75, 235), (235, 240), (229, 24), (24, 228), (228, 229), + (31, 25), (25, 226), (226, 31), (230, 23), (23, 229), (229, 230), + (231, 22), (22, 230), (230, 231), (232, 26), (26, 231), (231, 232), + (233, 112), (112, 232), (232, 233), (244, 189), (189, 243), (243, 244), + (189, 221), (221, 190), (190, 189), (222, 28), (28, 221), (221, 222), + (223, 27), (27, 222), (222, 223), (224, 29), (29, 223), (223, 224), + (225, 30), (30, 224), (224, 225), (113, 247), (247, 225), (225, 113), + (99, 60), (60, 240), (240, 99), (213, 147), (147, 215), (215, 213), + (60, 20), (20, 166), (166, 60), (192, 187), (187, 213), (213, 192), + (243, 112), (112, 244), (244, 243), (244, 233), (233, 245), (245, 244), + (245, 128), (128, 188), (188, 245), (188, 114), (114, 174), (174, 188), + (134, 131), (131, 220), (220, 134), (174, 217), (217, 236), (236, 174), + (236, 198), (198, 134), (134, 236), (215, 177), (177, 58), (58, 215), + (156, 143), (143, 124), (124, 156), (25, 110), (110, 7), (7, 25), + (31, 228), (228, 25), (25, 31), (264, 356), (356, 368), (368, 264), + (0, 11), (11, 267), (267, 0), (451, 452), (452, 349), (349, 451), + (267, 302), (302, 269), (269, 267), (350, 357), (357, 277), (277, 350), + (350, 452), (452, 357), (357, 350), (299, 333), (333, 297), (297, 299), + (396, 175), (175, 377), (377, 396), (280, 347), (347, 330), (330, 280), + (269, 303), (303, 270), (270, 269), (151, 9), (9, 337), (337, 151), + (344, 278), (278, 360), (360, 344), (424, 418), (418, 431), (431, 424), + (270, 304), (304, 409), (409, 270), (272, 310), (310, 407), (407, 272), + (322, 270), (270, 410), (410, 322), (449, 450), (450, 347), (347, 449), + (432, 422), (422, 434), (434, 432), (18, 313), (313, 17), (17, 18), + (291, 306), (306, 375), (375, 291), (259, 387), (387, 260), (260, 259), + (424, 335), (335, 418), (418, 424), (434, 364), (364, 416), (416, 434), + (391, 423), (423, 327), (327, 391), (301, 251), (251, 298), (298, 301), + (275, 281), (281, 4), (4, 275), (254, 373), (373, 253), (253, 254), + (375, 307), (307, 321), (321, 375), (280, 425), (425, 411), (411, 280), + (200, 421), (421, 18), (18, 200), (335, 321), (321, 406), (406, 335), + (321, 320), (320, 405), (405, 321), (314, 315), (315, 17), (17, 314), + (423, 426), (426, 266), (266, 423), (396, 377), (377, 369), (369, 396), + (270, 322), (322, 269), (269, 270), (413, 417), (417, 464), (464, 413), + (385, 386), (386, 258), (258, 385), (248, 456), (456, 419), (419, 248), + (298, 284), (284, 333), (333, 298), (168, 417), (417, 8), (8, 168), + (448, 346), (346, 261), (261, 448), (417, 413), (413, 285), (285, 417), + (326, 327), (327, 328), (328, 326), (277, 355), (355, 329), (329, 277), + (309, 392), (392, 438), (438, 309), (381, 382), (382, 256), (256, 381), + (279, 429), (429, 360), (360, 279), (365, 364), (364, 379), (379, 365), + (355, 277), (277, 437), (437, 355), (282, 443), (443, 283), (283, 282), + (281, 275), (275, 363), (363, 281), (395, 431), (431, 369), (369, 395), + (299, 297), (297, 337), (337, 299), (335, 273), (273, 321), (321, 335), + (348, 450), (450, 349), (349, 348), (359, 446), (446, 467), (467, 359), + (283, 293), (293, 282), (282, 283), (250, 458), (458, 462), (462, 250), + (300, 276), (276, 383), (383, 300), (292, 308), (308, 325), (325, 292), + (283, 276), (276, 293), (293, 283), (264, 372), (372, 447), (447, 264), + (346, 352), (352, 340), (340, 346), (354, 274), (274, 19), (19, 354), + (363, 456), (456, 281), (281, 363), (426, 436), (436, 425), (425, 426), + (380, 381), (381, 252), (252, 380), (267, 269), (269, 393), (393, 267), + (421, 200), (200, 428), (428, 421), (371, 266), (266, 329), (329, 371), + (432, 287), (287, 422), (422, 432), (290, 250), (250, 328), (328, 290), + (385, 258), (258, 384), (384, 385), (446, 265), (265, 342), (342, 446), + (386, 387), (387, 257), (257, 386), (422, 424), (424, 430), (430, 422), + (445, 342), (342, 276), (276, 445), (422, 273), (273, 424), (424, 422), + (306, 292), (292, 307), (307, 306), (352, 366), (366, 345), (345, 352), + (268, 271), (271, 302), (302, 268), (358, 423), (423, 371), (371, 358), + (327, 294), (294, 460), (460, 327), (331, 279), (279, 294), (294, 331), + (303, 271), (271, 304), (304, 303), (436, 432), (432, 427), (427, 436), + (304, 272), (272, 408), (408, 304), (395, 394), (394, 431), (431, 395), + (378, 395), (395, 400), (400, 378), (296, 334), (334, 299), (299, 296), + (6, 351), (351, 168), (168, 6), (376, 352), (352, 411), (411, 376), + (307, 325), (325, 320), (320, 307), (285, 295), (295, 336), (336, 285), + (320, 319), (319, 404), (404, 320), (329, 330), (330, 349), (349, 329), + (334, 293), (293, 333), (333, 334), (366, 323), (323, 447), (447, 366), + (316, 15), (15, 315), (315, 316), (331, 358), (358, 279), (279, 331), + (317, 14), (14, 316), (316, 317), (8, 285), (285, 9), (9, 8), + (277, 329), (329, 350), (350, 277), (253, 374), (374, 252), (252, 253), + (319, 318), (318, 403), (403, 319), (351, 6), (6, 419), (419, 351), + (324, 318), (318, 325), (325, 324), (397, 367), (367, 365), (365, 397), + (288, 435), (435, 397), (397, 288), (278, 344), (344, 439), (439, 278), + (310, 272), (272, 311), (311, 310), (248, 195), (195, 281), (281, 248), + (375, 273), (273, 291), (291, 375), (175, 396), (396, 199), (199, 175), + (312, 311), (311, 268), (268, 312), (276, 283), (283, 445), (445, 276), + (390, 373), (373, 339), (339, 390), (295, 282), (282, 296), (296, 295), + (448, 449), (449, 346), (346, 448), (356, 264), (264, 454), (454, 356), + (337, 336), (336, 299), (299, 337), (337, 338), (338, 151), (151, 337), + (294, 278), (278, 455), (455, 294), (308, 292), (292, 415), (415, 308), + (429, 358), (358, 355), (355, 429), (265, 340), (340, 372), (372, 265), + (352, 346), (346, 280), (280, 352), (295, 442), (442, 282), (282, 295), + (354, 19), (19, 370), (370, 354), (285, 441), (441, 295), (295, 285), + (195, 248), (248, 197), (197, 195), (457, 440), (440, 274), (274, 457), + (301, 300), (300, 368), (368, 301), (417, 351), (351, 465), (465, 417), + (251, 301), (301, 389), (389, 251), (394, 395), (395, 379), (379, 394), + (399, 412), (412, 419), (419, 399), (410, 436), (436, 322), (322, 410), + (326, 2), (2, 393), (393, 326), (354, 370), (370, 461), (461, 354), + (393, 164), (164, 267), (267, 393), (268, 302), (302, 12), (12, 268), + (312, 268), (268, 13), (13, 312), (298, 293), (293, 301), (301, 298), + (265, 446), (446, 340), (340, 265), (280, 330), (330, 425), (425, 280), + (322, 426), (426, 391), (391, 322), (420, 429), (429, 437), (437, 420), + (393, 391), (391, 326), (326, 393), (344, 440), (440, 438), (438, 344), + (458, 459), (459, 461), (461, 458), (364, 434), (434, 394), (394, 364), + (428, 396), (396, 262), (262, 428), (274, 354), (354, 457), (457, 274), + (317, 316), (316, 402), (402, 317), (316, 315), (315, 403), (403, 316), + (315, 314), (314, 404), (404, 315), (314, 313), (313, 405), (405, 314), + (313, 421), (421, 406), (406, 313), (323, 366), (366, 361), (361, 323), + (292, 306), (306, 407), (407, 292), (306, 291), (291, 408), (408, 306), + (291, 287), (287, 409), (409, 291), (287, 432), (432, 410), (410, 287), + (427, 434), (434, 411), (411, 427), (372, 264), (264, 383), (383, 372), + (459, 309), (309, 457), (457, 459), (366, 352), (352, 401), (401, 366), + (1, 274), (274, 4), (4, 1), (418, 421), (421, 262), (262, 418), + (331, 294), (294, 358), (358, 331), (435, 433), (433, 367), (367, 435), + (392, 289), (289, 439), (439, 392), (328, 462), (462, 326), (326, 328), + (94, 2), (2, 370), (370, 94), (289, 305), (305, 455), (455, 289), + (339, 254), (254, 448), (448, 339), (359, 255), (255, 446), (446, 359), + (254, 253), (253, 449), (449, 254), (253, 252), (252, 450), (450, 253), + (252, 256), (256, 451), (451, 252), (256, 341), (341, 452), (452, 256), + (414, 413), (413, 463), (463, 414), (286, 441), (441, 414), (414, 286), + (286, 258), (258, 441), (441, 286), (258, 257), (257, 442), (442, 258), + (257, 259), (259, 443), (443, 257), (259, 260), (260, 444), (444, 259), + (260, 467), (467, 445), (445, 260), (309, 459), (459, 250), (250, 309), + (305, 289), (289, 290), (290, 305), (305, 290), (290, 460), (460, 305), + (401, 376), (376, 435), (435, 401), (309, 250), (250, 392), (392, 309), + (376, 411), (411, 433), (433, 376), (453, 341), (341, 464), (464, 453), + (357, 453), (453, 465), (465, 357), (343, 357), (357, 412), (412, 343), + (437, 343), (343, 399), (399, 437), (344, 360), (360, 440), (440, 344), + (420, 437), (437, 456), (456, 420), (360, 420), (420, 363), (363, 360), + (361, 401), (401, 288), (288, 361), (265, 372), (372, 353), (353, 265), + (390, 339), (339, 249), (249, 390), (339, 448), (448, 255), (255, 339)]) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/step1_facemesh.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/step1_facemesh.py" new file mode 100644 index 0000000000000000000000000000000000000000..33a62fd7dffe57091f640903108f175bac654a5b --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/step1_facemesh.py" @@ -0,0 +1,111 @@ +""" +Week 11 - Step 1: Face Mesh Tracking with Connections (Template 填空版) +=================================================================== +本步驟目標: + 1. 使用 OpenCV 開啟視訊鏡頭 + 2. 載入 MediaPipe 臉部特徵偵測器 (face_landmarker.task) + 3. 偵測人臉,並在臉上畫出綠色的 3D 特效網格線與黃色特徵點 +""" +import cv2 +import mediapipe as mp +import numpy as np +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the examples folder.") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker Tasks API initialized successfully.") +except Exception as e: + print(f"Error initializing detector: {e}") + sys.exit() + +# 匯入臉部網格連接點定義 +try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + from face_mesh_connections import FACEMESH_TESSELATION +except ImportError: + FACEMESH_TESSELATION = [] + +# 繪圖模式:0 = 僅畫網格線, 1 = 僅畫點點, 2 = 點線都畫 +draw_mode = 0 + +print("Press 'q' in the window to exit, or 'm' to toggle display modes.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + print("Error: Failed to read frame.") + break + + # 【TODO 1】畫面水平翻轉,並將 BGR 轉成 MediaPipe 需要的 RGB 格式 + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + + + # 執行臉部偵測 + results = detector.detect(mp_image) + + # 【TODO 2】如果偵測到臉部,畫出 3D 特效網格線 + # 提示:取出 results.face_landmarks[0],並用迴圈巡覽 FACEMESH_TESSELATION + # 點的座標 (lm.x, lm.y) 是 0~1 的比例,需要乘上 img_w 和 img_h 換算成像素座標 + if results.face_landmarks: + face_lms = results.face_landmarks[0] + num_lms = len(face_lms) + + if draw_mode == 0 or draw_mode == 2: + for start_idx, end_idx in FACEMESH_TESSELATION: + if start_idx < num_lms and end_idx < num_lms: + lm1 = face_lms[start_idx] + lm2 = face_lms[end_idx] + pt1 = (int(lm1.x * img_w), int(lm1.y * img_h)) + pt2 = (int(lm2.x * img_w), int(lm2.y * img_h)) + cv2.line(img, pt1, pt2, (224, 224, 0), 1) # 青綠色線條 + + + # 【TODO 3】畫出臉部 468 個黃色特徵點點 + if draw_mode == 1 or draw_mode == 2: + for lm in face_lms: + cx = int(lm.x * img_w) + cy = int(lm.y * img_h) + cv2.circle(img, (cx, cy), 1, (0, 255, 255), -1) # 黃色小圓點 + + + + mode_str = "Mesh" if draw_mode == 0 else "Dots" if draw_mode == 1 else "Both" + cv2.putText(img, f"Face Detected | Mode: {mode_str}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + # 顯示操作提示文字 + cv2.putText(img, "Press 'm' to toggle mode | 'q' to quit", (10, img_h - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) + + cv2.imshow("Face Mesh Connections", img) + + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('m'): + draw_mode = (draw_mode + 1) % 3 + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/step2_facemesh_complete.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/step2_facemesh_complete.py" new file mode 100644 index 0000000000000000000000000000000000000000..f96bc596ecb2d03ade9db51b672972f8138a8718 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\204\270\351\203\250\344\276\246\346\265\213/step2_facemesh_complete.py" @@ -0,0 +1,362 @@ +""" +Week 11 - Face Mesh Expression Detection (Complete Template 填空版) +=========================================================== +本步驟目標: + 1. 將 MediaPipe Face Landmarker 與 Pygame 完美結合,打造科幻儀表板 + 2. 計算臉部指標:嘴巴張開比例與眼睛胖瘦比例 (EAR) + 3. 實作「雙門檻滯後防吵鎖」,避免警報器反覆爆音 + 4. 實作「跳幀推理優化」,解決 AI 推理造成的畫面卡頓 + 5. 整合音效播放功能 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import time + +# ============================================ +# 1. 初始化 Pygame & 音效 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 11 - Face Mesh Expression Detector ") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 36) +small_font = pygame.font.SysFont(None, 24) +title_font = pygame.font.SysFont(None, 48) + +# 載入警告音效 (shoot.wav) +try: + trigger_sound = pygame.mixer.Sound(os.path.join(os.path.dirname(__file__), "shoot.wav")) + print("Success: Loaded sound file shoot.wav") +except FileNotFoundError: + trigger_sound = None + print("Warning: Could not find sound file shoot.wav") + +# ============================================ +# 2. 初始化 MediaPipe Face Landmarker +# ============================================ +model_path = os.path.join(os.path.dirname(__file__), 'face_landmarker.task') +if not os.path.exists(model_path): + # 若在當前資料夾找不到,就去上一層找 + model_path = os.path.join(os.path.dirname(__file__), '..', 'face_landmarker.task') + if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the directory.") + pygame.quit() + sys.exit() + +# 【TODO 1】初始化 MediaPipe Face Landmarker 偵測器 +# try: +# from mediapipe.tasks import python +# from mediapipe.tasks.python import vision +# base_options = python.BaseOptions(model_asset_path=model_path) +# options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +# detector = vision.FaceLandmarker.create_from_options(options) +# print("Success: Loaded MediaPipe detector model") +# except Exception as e: +# print(f"Error: Failed to load model: {e}") +# pygame.quit() +# sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Success: Loaded MediaPipe detector model") +except Exception as e: + print(f"Error: Failed to load model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 3. 臉部特徵點索引定義 +# ============================================ +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 +LEFT_EYE_TOP = 159 +LEFT_EYE_BOTTOM = 145 +LEFT_EYE_RIGHT = 133 + +# ============================================ +# 4. 門檻值與狀態變數 +# ============================================ +# 嘴巴雙門檻防吵鎖 +MOUTH_OPEN_THRESHOLD = 0.35 +MOUTH_CLOSE_THRESHOLD = 0.15 + +# 眼睛雙門檻防吵鎖 +EAR_BLINK_THRESHOLD = 0.18 +EAR_OPEN_THRESHOLD = 0.25 + +is_mouth_open = False +was_mouth_open = False + +is_blinking = False +was_blinking = False + +mouth_open_count = 0 +blink_count = 0 + +# 跳幀優化變數:每 3 影格才做一次 AI 推理 +frame_counter = 0 +FRAME_SKIP = 3 +detector_results = None + +# 開啟攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + pygame.quit() + sys.exit() + +# ============================================ +# 5. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + frame_counter += 1 + + plot_points = {} + + # 【TODO 2】實作跳幀推理優化 (每 FRAME_SKIP 影格才跑一次 AI) + # if frame_counter % FRAME_SKIP == 0 or detector_results is None: + # try: + # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + # detector_results = detector.detect(mp_image) + # except Exception as e: + # print(f"Error: Inference exception: {e}") + + if frame_counter % FRAME_SKIP == 0 or detector_results is None: + try: + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + detector_results = detector.detect(mp_image) + except Exception as e: + print(f"Error: Inference exception: {e}") + + # 【TODO 3】特徵點座標映射與比例計算 + # 1. 取得對應點在 Pygame 視窗 (WIDTH, HEIGHT) 中的像素位置並存入 plot_points + # 2. 計算眼寬基準線 base_dist + # 3. 計算歸一化嘴巴比例 mouth_ratio + # 4. 計算眼睛胖瘦比 ear (提示:垂直高度 eye_h / 水平寬度 eye_w) + # if detector_results and detector_results.face_landmarks: + # face_lms = detector_results.face_landmarks[0] + # + # for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + # pt = face_lms[idx] + # plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + # + # p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + # p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + # base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + # + # if base_dist > 0: + # p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + # p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + # mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + # mouth_ratio = mouth_dist / base_dist + # + # p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + # p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + # p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + # + # eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + # eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + # ear = eye_h / eye_w if eye_w > 0 else 0 + + if detector_results and detector_results.face_landmarks: + face_lms = detector_results.face_landmarks[0] + + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + pt = face_lms[idx] + plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + + p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + if base_dist > 0: + p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + mouth_ratio = mouth_dist / base_dist + + p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + + eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + ear = eye_h / eye_w if eye_w > 0 else 0 + + # 【TODO 4】實作雙門檻防吵鎖 (滯後判斷邏輯) + # if mouth_ratio > MOUTH_OPEN_THRESHOLD: + # is_mouth_open = True + # elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + # is_mouth_open = False + # + # if ear < EAR_BLINK_THRESHOLD: + # is_blinking = True + # elif ear > EAR_OPEN_THRESHOLD: + # is_blinking = False + + if mouth_ratio > MOUTH_OPEN_THRESHOLD: + is_mouth_open = True + elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + is_mouth_open = False + + if ear < EAR_BLINK_THRESHOLD: + is_blinking = True + elif ear > EAR_OPEN_THRESHOLD: + is_blinking = False + + # 【TODO 5】實作邊緣觸發判定 (只有在剛張嘴或剛閉眼的那一瞬間,播放一次警告音效) + # if is_mouth_open and not was_mouth_open: + # mouth_open_count += 1 + # if trigger_sound: + # trigger_sound.play() + # + # if is_blinking and not was_blinking: + # blink_count += 1 + # + # was_mouth_open = is_mouth_open + # was_blinking = is_blinking + + if is_mouth_open and not was_mouth_open: + mouth_open_count += 1 + if trigger_sound: + trigger_sound.play() + + if is_blinking and not was_blinking: + blink_count += 1 + + was_mouth_open = is_mouth_open + was_blinking = is_blinking + + else: + is_mouth_open = False + is_blinking = False + was_mouth_open = False + was_blinking = False + mouth_ratio = 0.0 + ear = 0.30 + + # ============================================ + # 6. 渲染 Pygame 畫面與 UI 元素 + # ============================================ + # 【TODO 6】根據狀態切換背景顏色 (安全時深色,張嘴時深綠色,眨眼時深藍色) + # if is_mouth_open: + # screen.fill((16, 44, 28)) + # elif is_blinking: + # screen.fill((15, 30, 54)) + # else: + # screen.fill((20, 20, 30)) + + if is_mouth_open: + screen.fill((16, 44, 28)) + elif is_blinking: + screen.fill((15, 30, 54)) + else: + screen.fill((20, 20, 30)) + + # 【TODO 7】畫出眼寬基準線、眼睛高度線與嘴唇高度線 + # 提示:用 pygame.draw.line 在螢幕上將點位連線 + # if plot_points: + # pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + # pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + # pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + if plot_points: + pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + # 畫特徵點小圓圈 + for idx, pos in plot_points.items(): + color = (255, 255, 255) + if idx in [LIP_TOP, LIP_BOTTOM]: + color = (245, 158, 11) + elif idx in [LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_OUTER, LEFT_EYE_RIGHT]: + color = (59, 130, 246) + pygame.draw.circle(screen, color, pos, 6) + + # 繪製儀表板 HUD 卡片 + pygame.draw.rect(screen, (30, 30, 45), (30, 30, 340, 220), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (30, 30, 340, 220), width=2, border_radius=12) + + screen.blit(font.render("Face Mesh Dashboard", True, (255, 255, 255)), (50, 45)) + + m_color = (34, 197, 94) if is_mouth_open else (245, 158, 11) + screen.blit(small_font.render(f"Mouth Ratio: {mouth_ratio:.2f}", True, m_color), (50, 90)) + pygame.draw.rect(screen, (50, 50, 65), (50, 110, 200, 12), border_radius=4) + pygame.draw.rect(screen, m_color, (50, 110, int(min(mouth_ratio, 1.0) * 200), 12), border_radius=4) + + e_color = (59, 130, 246) if not is_blinking else (239, 68, 68) + screen.blit(small_font.render(f"Eye EAR: {ear:.2f}", True, e_color), (50, 140)) + pygame.draw.rect(screen, (50, 50, 65), (50, 160, 200, 12), border_radius=4) + pygame.draw.rect(screen, e_color, (50, 160, int(min(ear, 0.5) * 400), 12), border_radius=4) + + # 繪製閾值指示白線 + pygame.draw.line(screen, (255, 255, 255), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 108), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 124), 2) + pygame.draw.line(screen, (255, 255, 255), (50 + int(EAR_BLINK_THRESHOLD * 400), 158), (50 + int(EAR_BLINK_THRESHOLD * 400), 174), 2) + + # 繪製統計卡片 + pygame.draw.rect(screen, (30, 30, 45), (WIDTH - 320, 30, 290, 110), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (WIDTH - 320, 30, 290, 110), width=2, border_radius=12) + screen.blit(small_font.render(f"Mouth Open Count: {mouth_open_count}", True, (255, 255, 255)), (WIDTH - 300, 45)) + screen.blit(small_font.render(f"Blink/Close Count: {blink_count}", True, (255, 255, 255)), (WIDTH - 300, 75)) + screen.blit(small_font.render(f"Hysteresis Status: ACTIVE", True, (34, 197, 94)), (WIDTH - 300, 105)) + + # 顯示置中警告狀態大字 + if is_mouth_open: + status_lbl = title_font.render("MOUTH OPENED", True, (34, 197, 94)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + elif is_blinking: + status_lbl = title_font.render("BLINKING", True, (59, 130, 246)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + else: + if not plot_points: + status_lbl = title_font.render("NO FACE DETECTED", True, (239, 68, 68)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 - 20)) + else: + status_lbl = title_font.render("NORMAL", True, (200, 200, 200)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + + # 顯示底部效能狀態文字 + status_text = small_font.render( + f"Frame: {frame_counter} | Skip: {FRAME_SKIP} frames | FPS: {clock.get_fps():.0f}", + True, (100, 100, 120) + ) + screen.blit(status_text, (20, HEIGHT - 35)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\256\276\350\256\241AI\350\247\222\350\211\262&\345\234\272\346\231\257/d2_2_prompt_engineering.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\256\276\350\256\241AI\350\247\222\350\211\262&\345\234\272\346\231\257/d2_2_prompt_engineering.py" new file mode 100644 index 0000000000000000000000000000000000000000..aa4416ca9cf3e31f75e5bd8dfccaf1726610946a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\256\276\350\256\241AI\350\247\222\350\211\262&\345\234\272\346\231\257/d2_2_prompt_engineering.py" @@ -0,0 +1,56 @@ +# d2_2_prompt_engineering.py +# Day 2: 生成式 AI 串接實戰 - Prompt 工程 (系統指令與結構化輸出) +import os +from google import genai + +# 取得 API 金鑰 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") +if not api_key: + print("[提示] 請輸入您的 Google AI Studio API 金鑰:") + api_key = input("API Key: ").strip() + +client = genai.Client(api_key=api_key) + +# 1. 設置系統指令 (System Instruction / System Prompt) +# TODO 1: +system_prompt = "你是AI國中英語老師,请输入你想要翻译的中文单字" + +print("正在初始化强制英語教學模型...") + +# 2. Few-shot (少樣本) 引導與對話演練 +# TODO 2: +print(f"\n[學生輸入]: ") +user_word = input().strip() +prompt = f"請幫我翻譯這個詞:'{user_word}'。" + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=prompt, + config={'system_instruction': system_prompt} +) + +print("\n[AI 老師回應]:") +print(response.text) + + +# 3. 結構化 JSON 輸出 (Structured Output) 範例 +print("\n" + "="*40) +print("進階挑戰:要求模型回傳 JSON 格式") +print("="*40) + +# 我們可以在 prompt 中要求,並透過 configuration 設定 JSON 輸出格式 +# TODO 3: +json_prompt = ( + f"請將中文單字 '{user_word}' 的翻譯結果整理成 JSON 格式,包含以下三個欄位:\n" + "- 'english': 英文單字\n" + "- 'kk_phonetic': KK音標\n" + "- 'example_sentence': 適合國中生理解的英文例句與中文翻譯" +) + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=json_prompt, + config={"response_mime_type": "application/json"} +) +print("\n[AI 回傳的原始 JSON]:") +print(response.text) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\265\204\346\226\231\345\274\202\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\265\204\346\226\231\345\274\202\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" new file mode 100644 index 0000000000000000000000000000000000000000..e72604d3a640783a17fec2457a4e9ee954a37eb3 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\265\204\346\226\231\345\274\202\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" @@ -0,0 +1,38 @@ +# d1_2_data_cleaning.py +# Day 1 學員填空:資料清洗(完成 TODO) +# cd examples/Day1/student && python3 d1_2_data_cleaning.py +# 參考答案:../Teacher/d1_2_data_cleaning.py + +import pandas as pd +import numpy as np + +# 與 Teacher 一致:第 5 天高溫有雨,方便篩選示範 +raw_data = { + "Date": ["2026-07-01", "2026-07-02", "2026-07-03", "2026-07-04", "2026-07-05"], + "Temperature": [28.5, np.nan, 30.2, 31.5, 30.8], + "Humidity": [82, 80, np.nan, 70, 90], + "Rainfall": [5.2, 0.0, 0.0, np.nan, 25.4], +} + +df = pd.DataFrame(raw_data) +print("=== 原始含有缺失值的數據 ===") +print(df) + +df_cleaned = df.copy() +mean_temp = df_cleaned["Temperature"].mean() +df_cleaned["Temperature"] = df_cleaned["Temperature"].fillna(mean_temp) +df_cleaned["Humidity"] = df_cleaned["Humidity"].ffill() +df_cleaned["Rainfall"] = df_cleaned["Rainfall"].fillna(0.0) + +print("\n=== 清洗與填補後的數據 ===") +print(df_cleaned) + +print("\n=== 篩選:溫度>29 且 降雨>0 ===") +high_temp_and_rain = df_cleaned[(df_cleaned['Temperature'] > 29) & (df_cleaned['Rainfall'] > 0)] +print(high_temp_and_rain) + +print("\n=== 新增 Is_Hot(溫度 >= 30)===") +df_cleaned['Is_Hot'] = df_cleaned["Temperature"] >= 30 +print(df_cleaned) +df_cleaned['rainy'] = df_cleaned["Rainfall"] > 0 +print(df_cleaned) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\265\204\346\226\231\345\274\202\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\265\204\346\226\231\345\274\202\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\345\256\227\347\251\216/\350\265\204\346\226\231\345\274\202\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" new file mode 100644 index 0000000000000000000000000000000000000000..ee76f4034a9e8f8b5ea7fc947f0c08f9ef7778ec --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" @@ -0,0 +1,34 @@ +# d1_1_pandas_intro.py +# Day 1 學員填空:Pandas 基礎(完成 TODO 後執行) +# cd examples/Day1/student && python3 d1_1_pandas_intro.py +# 參考答案:../Teacher/d1_1_pandas_intro.py + +import pandas as pd + +df = pd.read_csv("taipei_weather_2026.csv") +print("=== 數據載入成功! ===") + +print("\n--- 1. head ---") +print(df.head()) + +print("\n--- 2. info ---") +df.info() + +print("\n--- 3. describe ---") +print(df.describe()) + +print("\n--- 4. Temperature 前 5 筆 ---") +print(df['Temperature'].head()) + +print("\n--- 5. Date 與 Rainfall ---") +print(df[['Date', 'Rainfall']].head()) + +print("\n--- 挑戰:最高溫與平均濕度 ---") +max_temp = df['Temperature'].max() +avg_humidity = df['Humidity'].mean() +print(f"最高溫度:{max_temp}°C") +print(f"平均濕度:{avg_humidity:.2f}%") +min_Rainfall = df['Rainfall'].min() +print(f"平均濕度:{min_Rainfall:.2f}%") +egyptian_good_weather =df[(df["Temperature"]>27) & (df["Rainfall"]==0)] +print(egyptian_good_weather) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" new file mode 100644 index 0000000000000000000000000000000000000000..04d8e32a00d4299d5a04b71cf3bed2e19e90df82 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" @@ -0,0 +1,41 @@ +# d2_1_gemini.py — Day2 學員填空 +# cd examples/Day2/Student && python3 d2_1_gemini.py +# 參考答案:../Teacher/d2_1_gemini.py + +import os +from google import genai + +# === 【學員填空練習點 1:設置與載入 API Key】 === +# TODO: 嘗試從環境變數 GEMINI_API_KEY 取得 API Key。如果未設定,則使用 input() 提示手動輸入。 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6J-S5dtl4YCuomoO9V4L5YAgTzLh9E8vJUuScN0c4VyKg") + +if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY。") + print(" 請先設定:export GEMINI_API_KEY=\"your_key\"") + print(" 或於下方手動輸入(僅供本機測試,勿寫死在檔案中上傳):") + api_key = input("API Key: ").strip() + if not api_key: + print("[結束] 未提供 API Key。可改練習 d2_1_ollama_local.py。") + exit(0) + +# === 【學員填空練習點 2:設定模型名稱】 === +# TODO: 設定使用的 Gemini 模型名稱(例如 'gemini-3.1-flash-lite') +model_name = "gemini-3.1-flash-lite" + +print(f"正在連線至 Google Cloud Model ({model_name})...") + +# === 【學員填空練習點 3:初始化 Gemini Client】 === +# TODO: 呼叫 genai.Client 並傳入 api_key 參數來初始化 Client 物件,指派給 client 變數 +client = genai.Client(api_key=api_key) + +# === 【學員填空練習點 4:發送內容生成請求】 === +# TODO: 使用 client.models.generate_content 呼叫模型,傳入 model 參數與 contents 提示詞 +# 提示詞內容:請模型以繁體中文解釋什麼是『API 串接』,並用餐廳點餐作為比喻,字數約 100 字。 +response = client.models.generate_content(model= model_name, contents="請以繁體中文解釋什麼是『AI』,字數約 100 字。") + + +# === 【學員填空練習點 5:讀取並印出回應】 === +# TODO: 從 response 物件中取得 text 欄位內容,並列印出來。 +print("\n=== AI 回應結果 ===") +# 請在下方實作列印邏輯: +print(response.text) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/draw_game/draw_game_student.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/draw_game/draw_game_student.py" new file mode 100644 index 0000000000000000000000000000000000000000..b777bbc46068e41183e1c0be1f6f7e89bbefb488 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/draw_game/draw_game_student.py" @@ -0,0 +1,147 @@ +import cv2 +import numpy as np +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# ============================================ +# Week 9 - 簡單學生版:支援多色切換的 AR 空中寫字板 (純黑畫布版) +# ============================================ + +# === 【重要觀念 TODO 1:MediaPipe 手部偵測模型初始化】 === +# 觀念重點: +# 1. BaseOptions 用於設定模型檔案的路徑,這裡使用下載好的 'hand_landmarker.task'。 +# 2. HandLandmarkerOptions 用於設定手部偵測的細項設定,例如 num_hands=1 代表最多偵測一隻手。 +# 3. 最後透過 vision.HandLandmarker.create_from_options 建立手部偵測器物件。 +# 請解開下方三行程式碼的註釋: +base_options = python.BaseOptions(model_asset_path='hand_landmarker.task') +options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) +detector = vision.HandLandmarker.create_from_options(options) + +cap = cv2.VideoCapture(0) +canvas = None +px, py = 0, 0 + +# 定義幾種高對比的顏色 (格式為 B, G, R) +COLORS = { + 'purple': (191,161,196), # 紫色 + 'turquoise': (128,217,255), # 青綠色 + 'skyblue': (138,202,229), # 天蓝色 + 'pink': (213,110,237) # 粉色 +} + +# 預設使用螢光綠 +current_color = COLORS['purple'] + +print("系統啟動中...") +print("【操作指南】") +print("☝️ 單伸食指:下筆寫字") +print("✌️ 食指+中指:懸停移動 (不寫字)") +print("⌨️ 按下 '1~4':切換畫筆顏色") +print("⌨️ 按下 's':清空畫面") +print("⌨️ 按下 'f':結束程式") + +while True: + success, img = cap.read() + if not success: + break + + # === 【重要觀念 TODO 2:攝影機影像水平翻轉】 === + # 觀念重點: + # 1. 攝影機預設取得的影像與我們的真實動作是相反的(非鏡像)。 + # 2. 在互動式 AR 應用中,為了讓使用者的左手移動時,畫面中的手也往左移動, + # 必須使用 cv2.flip(img, 1) 將影像水平翻轉,以提供符合直覺的「鏡像體驗」。 + # 請解開下方一行程式碼的註釋: + img = cv2.flip(img, 1) + + h, w, c = img.shape + + # 初始化畫布 + if canvas is None: + canvas = np.zeros((h, w, c), dtype=np.uint8) + + # 為了維持畫筆提示點(或懸停圓圈)的動態顯示,且不破壞原本的 canvas 紀錄, + # 我們在每一訊框都建立一個純黑的暫時畫布來呈現最終畫面。 + display_img = np.zeros((h, w, c), dtype=np.uint8) + + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.hand_landmarks: + hand_lms = results.hand_landmarks[0] + + index_tip = hand_lms[8] + index_pip = hand_lms[6] + middle_tip = hand_lms[12] + middle_pip = hand_lms[10] + + cx, cy = int(index_tip.x * w), int(index_tip.y * h) + + index_up = index_tip.y < index_pip.y + middle_up = middle_tip.y < middle_pip.y + + if index_up and not middle_up: + # 狀態:【繪圖模式】 + if px == 0 and py == 0: + px, py = cx, cy + + # === 【重要觀念 TODO 3:在 OpenCV Canvas 上繪製軌跡】 === + # 觀念重點: + # 1. cv2.line 用於在影像上繪製直線。 + # 2. 參數說明:畫布(canvas)、起點(px, py)、終點(cx, cy)、顏色(current_color)、線寬(8)。 + # 3. 藉由在每一訊框連續繪製前一個位置到當前位置的線段,即可形成平滑的寫字軌跡。 + # 請解開下方一行程式碼的註釋: + cv2.line(canvas, (px, py), (cx, cy), current_color, 8) + + px, py = cx, cy + + # 筆頭提示點直接畫在暫時的顯示畫布上,避免在黑畫布上找不到筆尖 + cv2.circle(display_img, (cx, cy), 10, current_color, cv2.FILLED) + + elif index_up and middle_up: + # 狀態:【懸停模式】 + px, py = 0, 0 + # 懸停提示圈畫在暫時的顯示畫布上,移動時才不會在黑背景中迷路 + cv2.circle(display_img, (cx, cy), 12, (255, 255, 255), 2) + + else: + px, py = 0, 0 + else: + px, py = 0, 0 + + # 為什麼改用 canvas 與 display_img 疊加? + # 原本是與相機影像 (img) 疊加,現在改與純黑的 display_img 疊加, + # 這樣畫面上就只會留下寫字的軌跡以及當前的筆頭/懸停提示,完全遮蔽人像背景。 + + # === 【重要觀念 TODO 4:AR 畫面疊加與顯示】 === + # 觀念重點: + # 1. cv2.add 用於將兩個相同大小與類型的影像矩陣進行相加。 + # 2. 這裡將寫字痕跡畫布 (canvas) 與當前的筆頭/懸停提示畫布 (display_img) 疊加。 + # 3. 疊加後使用 cv2.imshow 在視窗中顯示最終成果。 + # 請解開下方兩行程式碼的註釋: + final_output = cv2.add(canvas, display_img) + cv2.imshow("Hand Tracking - AR Canvas", final_output) + + # 鍵盤監聽與控制邏輯 + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('c'): + canvas = np.zeros((h, w, c), dtype=np.uint8) + + elif key == ord('1'): + current_color = COLORS['purple'] + print("已切換為:紫色") + elif key == ord('2'): + current_color = COLORS['turquoise'] + print("已切換為:青綠色") + elif key == ord('3'): + current_color = COLORS['skyblue'] + print("已切換為:蓝色") + elif key == ord('4'): + current_color = COLORS['pink'] + print("已切換為:粉色") + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Boa.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Boa.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..612dd2ad7ed036cd87ba3aef9576b49fc0b9d83f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Boa.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13395d211888bc201f3fda8362245cab13512f9096cdfdb06d16c92b63739eec +size 218899 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Fandom.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Fandom.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..49ae77505046285ed88f465be99aacd362bf73b4 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Fandom.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:484e07ce68cc8245070e8a0395e8c33241f7e2e1909b6044d37977c9a48449f8 +size 34525 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Mermaid.webp" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Mermaid.webp" new file mode 100644 index 0000000000000000000000000000000000000000..5e35e97a7c8e582733a3d6fc65cbe5dcd90e8cdf --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Mermaid.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0b8b969d4a22c1304d0e77730989af93f6aa65c0f481bd797e5838e58e68d145 +size 193022 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Nami.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Nami.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..ca028be10193d588f9b9dbfffd6265a66bb5808f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Nami.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e22c43ab096ea47d9c49eed83cc8e34c17c1c52423eee1f10bf08579b2eebb45 +size 74008 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Peach_Girls.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Peach_Girls.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..e03634e688bd9af4c658c850f896773b38749502 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Peach_Girls.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2125d6123648c047fcb67817d055f6281517e2c5962d9a30fdb74ba680a5911 +size 269224 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Perona.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Perona.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..5dc39824cb487d4f6233ec7f555ab7f0deb39a86 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Perona.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4e84f132e96bac14ca53a5f56f7811bd7d6953f7f926ed623817e5c45059793a +size 11966 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Rebecca.webp" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Rebecca.webp" new file mode 100644 index 0000000000000000000000000000000000000000..1de3d2c0d3724a4a71509f0fe94427c226c06308 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Rebecca.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9face7c8685e0e56e7dbb82f983fbec2f7f0820699ba30e60b955cc0d5b2bdc4 +size 349208 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Vivi.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Vivi.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..99e50ef7405523b4a3370977a7ea17f7f8317153 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/Vivi.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ddcda0fe8770c6a9bf77f3da92ea5edce91131c19b6e41a38481795b062c539 +size 80108 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/beach_Girls.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/beach_Girls.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..c6ebcbc1d3c4577da7a30a0d5057c4a01a8b312d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/beach_Girls.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e673dad33daa4a86e00de9914488c8adcfe0d0672d9f664b14ea535fb473ffc +size 342321 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/cbof7uqwz3k41.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/cbof7uqwz3k41.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..c6ebcbc1d3c4577da7a30a0d5057c4a01a8b312d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image/cbof7uqwz3k41.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e673dad33daa4a86e00de9914488c8adcfe0d0672d9f664b14ea535fb473ffc +size 342321 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_contours.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_contours.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..5f3b9f71df9ec710f42a19517bcd1ba26296c670 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_contours.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65786488a93c094fb52f2c9c867ed65723115d540380e236e47ee58632f6e922 +size 592530 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_edges.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_edges.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..3357cf54660a1f2025f0be96f51d351b4dfd2dec --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_edges.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6f69c037f3588373dbb409f3cae50431d33292e469fdd3e14d8a40b86ab8af3 +size 647652 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_gray.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_gray.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..b00be82b548b703646169a8fbb9702b9df41630f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Beach_Girls_gray.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63940cb9894bfab27b83cb64bf481f83b95ef749b382a77c817f3cf77d233a8a +size 352277 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_contours.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_contours.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..ba92c5a80da3a1b1c8ea5553c5093429dfae7bf8 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_contours.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78e19c63441f3bfea02c2469f3c74e5842e8b30736ba3de4e6283ba54b427c5c +size 531405 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_edges.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_edges.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..2f5905b1a5df9ac3462cb5034ce7b1ebb42fe264 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_edges.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f1468985056233cc87961f7fce2b77887284e7610dfdb46c03f537eeb964e42 +size 511404 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_gray.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_gray.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..824ebba9af57ed5ee1247ae30f1e9d9a12ee9ffd --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Boa_gray.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63d995f62a7a29bf63720278232dd013ee35822971c3c8c4c74e361eefc67d21 +size 316327 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_contours.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..8d0e15699e9a084f0ab99edc655d5b049f7dabb8 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67f3906b154ef0194feb68c557660b43a0e07ac3206d9216572d6c3fb7c4763f +size 106435 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_edges.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..1770e8f740126d57aea20a4ccdcfbf994451d555 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb702d26a8fb2d09e7b90010ee41cd3cbe00667a1ef669ea14720e431e407ca3 +size 146709 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_gray.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..9118d24126b77bbb2d074e2a7f684642e4b5ef9a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Fandom_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6809127b78f8b0f19a940d49cbb3eb5db47a6e7f33b4a2fe56eddeb6ce34e6dc +size 61162 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_contours.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..fe7694a880aa1ad66bbc38f5a09d734f3306ca69 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:980703fc0ae2d0325f46ff5d3ff339c0e854f04180fb86983cdc09131f1570a7 +size 207869 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_edges.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..7becc9d4654e20669046d015fb390d03a1177547 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a16f825946b6c9a1397db9c420ba3076bdc20c6a6017201662461d6dce2c4b4 +size 181085 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_gray.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..c8f7d21334f237840e5236e79fd782908c7addc1 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Nami_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5deb4973ebd00bd9bbc8e649c232c8713b86f8a83b37936de5f995464bcb2082 +size 103321 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_contours.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..f239958d1791e9e50f7320892b55d4f950b27820 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff90c5c15a3ae02de29beedaf9a7e2cda980e9eab66fefdf6b504b56030b0548 +size 158905 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_edges.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..54b6f176c7b634ab62fb47173c3fb017d7c1ea17 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb422968697422dce38c92394366e9553c9c93726971386403d1db1654e62a07 +size 171253 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_gray.jpg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..eab0e6b63f9414906aa4d42e3a3d7d2b8d5ffb88 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Peach_Girls_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2be5aeababe0dc1ba20d9cd7389c22f9fb262854d9b0e3db68c38cc1736a875 +size 114550 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_contours.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_contours.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..a22d1ebfabbcfbbccc247d4d49ba9298e76deebf --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_contours.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bbf8e35e71dd7e30c3ab8238013c25c59322fb871d6a6e314fb6e1af7f09078 +size 35472 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_edges.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_edges.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..2026be75268f651243396155dc535ffc42f5b870 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_edges.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30161b35b096d0309a01164fae429d531332c066ab7641a006a8960685d8a489 +size 41979 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_gray.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_gray.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..ebe5fecc8f3cea43ec79cfbf626822635b4dabbd --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Perona_gray.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00ae378e632245f2ddc5d4976f026ef893068121e008cac7854da8834d50479b +size 22931 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_contours.webp" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_contours.webp" new file mode 100644 index 0000000000000000000000000000000000000000..e3c48f6d4b45124296addc3ab3e1f96fac9039ed --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_contours.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f7296021076eff914a67d86bb793bfd385940cc84d68baac4ed4aa1ea4513f8 +size 1613968 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_edges.webp" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_edges.webp" new file mode 100644 index 0000000000000000000000000000000000000000..b86a7a0b6cfa511f5e4705c3208d94eb2730a504 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_edges.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28d428e83b775a22f8f1ddb55e861d237dd150e5e962a78ca1b615e4a384bcb8 +size 52382 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_gray.webp" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_gray.webp" new file mode 100644 index 0000000000000000000000000000000000000000..0215b03123f72ac2150def8c939c02e16a22b0f2 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Rebecca_gray.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93058d92eb1a335eda296186ca1e3f1b6a9b3e2b900b949ac9c53037663dcea0 +size 669904 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_contours.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_contours.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..6d4b1105c4569e2c560b64ae85e13eadc675b15b --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_contours.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca85b99fb006c421fc575d5148aa02a4865ad627bcb47fc993abd4657210c315 +size 204020 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_edges.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_edges.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..271df6f23777bd1322515dc3b903589553538a3e --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_edges.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:645112416b9933a6964a8ec992293ffa514b445b2f59e4ab7cddd3680ca67fc3 +size 208054 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_gray.jpeg" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_gray.jpeg" new file mode 100644 index 0000000000000000000000000000000000000000..39a96f923f8f7a4d9acfe89e37900cb055b5b379 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/image_create/Vivi_gray.jpeg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ede204006e396d393e86ab2bed24b31dceb030adab5e4d7ce7de692c05cc6c92 +size 121053 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock&AI_\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock&AI_\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" new file mode 100644 index 0000000000000000000000000000000000000000..e7e7201ede25510c0d19d22d893462c91882bcbc --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock&AI_\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" @@ -0,0 +1,113 @@ +# d2_3_chatbot_memory.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (Gemini / Local Mock 雙模) + +import os +from google import genai + +# 優先讀取環境變數 GEMINI_MODEL,預設為 gemini-3.1-flash-lite +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get( + "GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + + +def local_mock_response(user_input, history): + user_input_lower = user_input.lower() + + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "在哪" in user_input_lower or "地点" in user_input_lower: + return "這起案件的地点在饭店,接下來去查查『地上的脚印』吧!" + elif "线索" in user_input_lower or "地上的脚印" in user_input_lower: + return "哇!地上的脚印指向了饭店的 'ROOM_238',請記好這串字,接下來該查明『小偷的是誰』了!" + elif "小偷的是誰" in user_input_lower or "是誰" in user_input_lower: + return "小偷居然是'David_8538',接下來我們該查明时间了!" + elif "时间" in user_input_lower: + return "下午3:30" + elif "提示" in user_input_lower: + return "你可以问我'地点'、'时间'、'小偷的動機'或是'犯人'。" + elif "你好" in user_input_lower: + return "大偵探!我们需要你幫忙找出小偷!" + elif "小偷的動機" in user_input_lower: + return "受害者其實是David以前的朋友,David對他懷恨在心,因為有一次她和David參加了一個游泳比賽,她為了得到第一名,拿了一杯在太陽下曬了兩個小時的奶茶給David喝,還騙他那是溫奶茶,害David一直拉肚子,沒辦法去參加游泳比賽,最後她成功拿到了第一名,而David卻一點機會也沒有。長大之後David就一直想要報復,但是一直找不到任何機會,這次是她第一次來David開的饭店,David就偷了她的東西,當作報復。" + else: + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (支援對話上下文記憶)") + print("=" * 50) + + use_mock = False + chat_session = None + + if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY,將自動啟用「離線模擬模式 (Local Mock Mode)」。") + print(" (如需連線雲端模型,請設定 GEMINI_API_KEY 環境變數)") + use_mock = True + else: + try: + print(f"正在連線 Google Gemini 雲端客服伺服器 ({GEMINI_MODEL})...") + client = genai.Client(api_key=api_key) + # TODO: 設定系統指令 (System Instruction / System Prompt) + system_instruction = ( + "你是一位專業的侦探,語氣溫和客氣," + "小偷是David_8538" + "這起案件的地点在饭店" + "地上的脚印指向了饭店的 'ROOM_238'" + "时间是下午3:30" + "小偷跟受害者有仇" + "小偷是饭店经理" + "受害者其實是偷的老朋友" + "小偷趁著飯店清潔工的交班空檔,进到受害者的房間偷走他的東西" + "小偷偷走了她最喜歡的珍珠項鍊" + "ROOM_238剛好位於走廊盡頭,是唯一避開所有監視器的死角" + ) + + chat_session = client.chats.create( + model=GEMINI_MODEL, + config={'system_instruction': system_instruction} + ) + print("雲端客服系統已就緒!") + except Exception as e: + print(f"連線雲端客服失敗 ({e}),切換至離線模擬模式...") + use_mock = True + + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + if use_mock: + ai_reply = local_mock_response(user_input, local_history) + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + else: + response = chat_session.send_message(user_input) + ai_reply = response.text + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock_game/d2_3_chatbot_memory_game_template.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock_game/d2_3_chatbot_memory_game_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..1d25bebcbceb4055735890de8b8e1f1d882c8dc1 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock_game/d2_3_chatbot_memory_game_template.py" @@ -0,0 +1,103 @@ +# d2_3_chatbot_memory_game_template.py +# Day 2: 生成式 AI 串接實戰 - 離線 Mock 遊戲設計模板 (學生填空版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬 NPC 或是系統的對話反應。 + + Why: + 此檔案為困難版對話記憶解謎的設計模板。你將學習到: + 1. 前置狀態限制:必須先問過「線索一」,才能查到「線索二」... + 2. 動態 Flag:Flag 答案中的某個線索會隨提問次數(turns)改變。 + """ + user_input_lower = user_input.strip().lower() + + # 計算目前是第幾輪對話 + turns = len(history) // 2 + 1 + + # TODO: 設計你的動態變數(例如 turns * 5,或是 current_weather 等) + # 並設定最終預期的正確 Flag 答案(必須包含此動態變數) + expected_flag = "flag{room_238__david_8538_3_30}".lower() + # 從歷史中判斷先前是否已取得前置線索(在 history 中,AI model 是否曾回覆過特定字眼) + # TODO: 依據你設計的線索關鍵字,修改以下 history 的 any 比對條件 + asked_clue1 = any("Room_238" in h["text"] for h in history if h["role"] == "model") + asked_clue2 = any("_David_8538" in h["text"] for h in history if h["role"] == "model") + + # 1. 驗證 Flag 邏輯 + if user_input_lower == expected_flag: + return f"🎉 恭喜你!成功破解案件並獲得了真正的 Flag!你在第 {turns} 回合成功通關!" + + elif "flag{" in user_input_lower: + return ( + f"❌ Flag 驗證失敗!\n" + f"提示:最後一個線索會隨著提問次數動態改變。\n" + f"你現在是在第 {turns} 次提問輸入,因此當前的動態線索應該是 turns{turns} 喔!\n" + f"請重新計算並拼湊最新的 Flag!" + ) + + # 2. 劇情關鍵字問答分支(打造你的 NPC 對話樹!) + # TODO: 依據你的故事背景,修改並設計以下 if-elif-else 結構 + if "在哪" in user_input_lower or "地点" in user_input_lower: + return "這起案件的地点在饭店,接下來去查查『地上的脚印』吧!" + elif "线索" in user_input_lower or "地上的脚印" in user_input_lower: + return "哇!地上的脚印指向了饭店的 'ROOM_238',請記好這串字,接下來該查明『小偷的是誰』了!" + elif "小偷的是誰" in user_input_lower or "是誰" in user_input_lower: + return "小偷居然是'_David_8538',接下來我們該查明时间了!" + elif "时间" in user_input_lower: + return "下午3:30(_3_30)" + elif "提示" in user_input_lower: + return "你可以问我'地点'、'时间'、'小偷的動機'或是'犯人'。" + elif "你好" in user_input_lower: + return "大偵探!我们需要你幫忙找出小偷!" + elif "小偷的動機" in user_input_lower: + return "受害者其實是David以前的朋友,David對他懷恨在心,因為有一次她和David參加了一個游泳比賽,她為了得到第一名,拿了一杯在太陽下曬了兩個小時的奶茶給David喝,還騙他那是溫奶茶,害David一直拉肚子,沒辦法去參加游泳比賽,最後她成功拿到了第一名,而David卻一點機會也沒有。長大之後David就一直想要報復,但是一直找不到任何機會,這次是她第一次來David開的饭店,David就偷了她的東西,當作報復。" + else: + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + # TODO: 修改為你的遊戲標題與故事背景 + print("=" * 60) + print(" 🕵️ 找出兇手 🕵️") + print("=" * 60) + print("[故事簡介] 你是一位偵探,必須利用歷史對話記憶拼湊線索。") + print(" 依序追查犯人、作案手法、地點與时间。") + print("-" * 60) + + local_history = [] + + print("\n遊戲已啟動!請輸入你想問 NPC 的話(輸入 'quit' 結束遊戲):") + print("-" * 60) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("遊戲結束,感謝遊玩!") + break + + ai_reply = local_mock_response(user_input, local_history) + + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[NPC]: {ai_reply}") + + except KeyboardInterrupt: + print("\n遊戲強行終止!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock_\345\205\263\351\224\256\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock_\345\205\263\351\224\256\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" new file mode 100644 index 0000000000000000000000000000000000000000..7eb223d2a185b6bb0c741d220149849d7d56310a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/mock_\345\205\263\351\224\256\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" @@ -0,0 +1,79 @@ +# d2_3_chatbot_memory_mock.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (純 Local Mock 教師版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬客服助理的自動回覆。 + 在沒有網路或 API 金鑰時,如何透過關鍵字比對 + 與對話歷史長度(計算對話輪數)來模擬一個具有「記憶」能力的對話流程。 + """ + user_input_lower = user_input.lower() + + # 關鍵字比對:模擬常見的客服問答 + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "在哪" in user_input_lower or "地点" in user_input_lower: + return "這起案件的地点在饭店,接下來去查查『地上的脚印』吧!" + elif "线索" in user_input_lower or "地上的脚印" in user_input_lower: + return "哇!地上的脚印指向了饭店的 'ROOM_238',請記好這串字,接下來該查明『小偷的是誰』了!" + elif "小偷的是誰" in user_input_lower or "是誰" in user_input_lower: + return "小偷居然是'David_8538',接下來我們該查明时间了!" + elif "时间" in user_input_lower: + return "下午3:30" + elif "提示" in user_input_lower: + return "你可以问我'地点'、'时间'或是'犯人'。" + elif "你好" in user_input_lower: + return "大偵探!我们偷需要你幫忙找出小!" + else: + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (純離線模擬模式 - 支援對話記憶)") + print("=" * 50) + print("[提示] 目前為純 Local Mock 模式,不需設定環境變數與連線雲端 API。") + print("-" * 50) + + # 用於儲存對話歷史的串列 + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + # 結束條件判斷 + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + # 呼叫模擬回覆函式 + ai_reply = local_mock_response(user_input, local_history) + + # 將本次對話記錄寫入歷史紀錄中,維持記憶狀態 + # Why: 每次成功的問答都必須 append 進 history,以便下一輪對話時能正確累計輪數。 + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + # 處理使用者按下 Ctrl+C 強行結束的情境,避免程式崩潰 + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/requirements.txt" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/requirements.txt" new file mode 100644 index 0000000000000000000000000000000000000000..ac7f00648560530b9d1168986de056f2bdfd6a45 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/requirements.txt" @@ -0,0 +1,6 @@ +pandas>=2.0.0 +matplotlib>=3.7.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +opencv-python>=4.8.0 +google-generativeai>=0.8.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\270\211\346\214\207\346\215\217\351\201\212\346\210\262/d5_pinch_challenge_solution.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\270\211\346\214\207\346\215\217\351\201\212\346\210\262/d5_pinch_challenge_solution.py" new file mode 100644 index 0000000000000000000000000000000000000000..ff37e0664180dfbe9495fc794b6b619b1261973c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\270\211\346\214\207\346\215\217\351\201\212\346\210\262/d5_pinch_challenge_solution.py" @@ -0,0 +1,205 @@ +""" +Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (三指捏合挑戰 Template 填空版) +================================================================== +本週挑戰目標: + 1. 實作「三指捏合 (Three-Finger Pinch)」: + 必須同時滿足「大拇指 (4) 與食指 (8) 的距離 < Threshold」 + 以及「大拇指 (4) 與中指 (12) 的距離 < Threshold」才判定為 Pinch + 2. 用三指碰觸 Pygame 畫面上的按鈕 + 捏合 = 魔法點擊 + 3. 使用邊緣觸發 (Edge Detection) 防止重複觸發 + 4. 新增 30 秒限時機制,時間到顯示最終得分 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import random +from mediapipe.tasks import python +from mediapipe.tasks.python import vision +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 10 - 三指魔法手勢按鈕 (填空版) ") +clock = pygame.time.Clock() + +# 攝影機 +cap = cv2.VideoCapture(0) + +try: + base_options = python.BaseOptions(model_asset_path='hand_landmarker.task') + options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) + detector = vision.HandLandmarker.create_from_options(options) +except Exception as e: + print(f"Error loading model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 2. 遊戲變數 +# ============================================ +# 手指座標 +finger_x, finger_y = 400, 300 # 食指 +thumb_x, thumb_y = 400, 300 # 大拇指 +middle_x, middle_y = 400, 300 # 中指 + +# 距離變數 +dist_thumb_index = 0.0 +dist_thumb_middle = 0.0 + +# Pinch 相關變數 +is_pinching = False +was_pinching = False # 邊緣觸發 +PINCH_THRESHOLD =40 # 距離臨界值 + +# 按鈕設定 +button_rect = pygame.Rect(300, 250, 200, 100) +button_colors = [(247, 151, 236), (3, 252, 252), + (52, 244, 3), (255, 255, 255)] +current_color_index = 0 + +# 點擊計數器 +click_count = 0 + +# --- 限時機制變數 --- +START_TIME = pygame.time.get_ticks() +GAME_DURATION = 30 # 遊戲限制時間(秒) +game_state = "PLAYING" # 狀態機 + +# ============================================ +# 3. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機與手勢偵測 --- + success, img = cap.read() + is_pinching = False # 每幀重設狀態,防止手離屏時卡死 + + if success: + img = cv2.flip(img, 1) # 鏡像翻轉 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + # === 【TODO 2:挑戰關卡三之手部安全偵測】 === + # 1. 判斷手部是否在畫面中 (results.hand_landmarks 是否有資料) + # 2. 如果在畫面中,取得食指 (8)、大拇指 (4)、中指 (12) 的像素座標 + # 3. 如果手不在畫面中,將 is_pinching 設為 False + # + hand_in_frame = False + if results.hand_landmarks: + hand_in_frame = True + for hand_lms in results.hand_landmarks: + # 食指 (8) + lm8 = hand_lms[8] + finger_x = int(lm8.x * WIDTH) + finger_y = int(lm8.y * HEIGHT) + + # 大拇指 (4) + lm4 = hand_lms[4] + thumb_x = int(lm4.x * WIDTH) + thumb_y = int(lm4.y * HEIGHT) + + # 中指 (12) + lm12 = hand_lms[12] + middle_x = int(lm12.x * WIDTH) + middle_y = int(lm12.y * HEIGHT) + + # === 【TODO 3:挑戰關卡一之三指捏合判定】 === + # 提示:計算食指與大拇指距離,以及中指與大拇指距離 + # 兩組距離皆小於 PINCH_THRESHOLD 時,is_pinching 才為 True + dist_thumb_index = math.hypot(finger_x - thumb_x, finger_y - thumb_y) + dist_thumb_middle = math.hypot(middle_x - thumb_x, middle_y - thumb_y) + is_pinching = (dist_thumb_index < PINCH_THRESHOLD) and (dist_thumb_middle < PINCH_THRESHOLD) + + # --- 狀態機邏輯處理 --- + if game_state == "PLAYING": + # 1. 計算剩餘時間 + current_ticks = pygame.time.get_ticks() + elapsed_seconds = (current_ticks - START_TIME) / 1000 + remaining_time = max(0, GAME_DURATION - elapsed_seconds) + + # 2. 檢查時間是否到了 + if remaining_time <= 0: + game_state = "GAME_OVER" + + # 3. 邊緣觸發點擊判斷 + if is_pinching and not was_pinching: + finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10) + if finger_rect.colliderect(button_rect): + click_count += 1 + current_color_index = (current_color_index + 1) % len(button_colors) + + # === 【TODO 4:挑戰關卡二之按鈕隨機移動】 === + # 提示:當點擊發生時,讓按鈕的 x 和 y 座標隨機分配在畫面內 + button_rect.x = random.randint(0, WIDTH - button_rect.width) + button_rect.y = random.randint(0, HEIGHT - button_rect.height) + print(f" 魔法三指點擊!第 {click_count} 次") + + # 更新上一幀狀態 + was_pinching = is_pinching + + # --- 繪製畫面 --- + screen.fill((20, 20, 35)) + font = pygame.font.SysFont(None, 32) + large_font = pygame.font.SysFont(None, 64) + + if game_state == "PLAYING": + # 繪製按鈕 + btn_color = button_colors[current_color_index] + pygame.draw.rect(screen, btn_color, button_rect, border_radius=16) + + btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255)) + screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2, + button_rect.centery - btn_text.get_height() // 2)) + + # 顯示倒數計時 + time_text = font.render(f"Time Left: {remaining_time:.1f}s", True, (255, 220, 100)) + screen.blit(time_text, (WIDTH - time_text.get_width() - 20, 10)) + + # 畫手指游標 + cursor_color = (50, 255, 50) if is_pinching else (255, 50, 50) + pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12) # 食指 + pygame.draw.circle(screen, (255, 255, 255), (thumb_x, thumb_y), 12) # 大拇指 + pygame.draw.circle(screen, (100, 150, 255), (middle_x, middle_y), 12) # 中指 + + # === 【TODO 5:挑戰關卡三之畫面提示】 === + # 提示:當手不在畫面中時,在畫面上方顯示警告文字提醒學生將手部放回 + if not hand_in_frame: + warning_text = font.render("OUT OF FRAME - PLEASE PUT HANDS BACK", True, (255, 255, 255)) + screen.blit(warning_text, (WIDTH // 2 - warning_text.get_width() // 2, 100)) + + # 顯示調試數值 + small_font = pygame.font.SysFont(None, 24) + status = small_font.render(f"Finger: ({finger_x}, {finger_y})", True, (150, 150, 150)) + screen.blit(status, (10, 10)) + + dist_text = small_font.render(f"Dist Thumb-Index: {dist_thumb_index:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text, (10, 35)) + + dist_text2 = small_font.render(f"Dist Thumb-Middle: {dist_thumb_middle:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text2, (10, 60)) + + elif game_state == "GAME_OVER": + # 繪製遊戲結束畫面 + over_text = large_font.render("GAME OVER", True, (255, 50, 50)) + score_text = font.render(f"Total Clicks: {click_count} times", True, (255, 255, 255)) + + screen.blit(over_text, (WIDTH // 2 - over_text.get_width() // 2, HEIGHT // 2 - 50)) + screen.blit(score_text, (WIDTH // 2 - score_text.get_width() // 2, HEIGHT // 2 + 20)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" new file mode 100644 index 0000000000000000000000000000000000000000..5085df4d7ae3f9278a7ed18a68daeb01f9c660aa --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" @@ -0,0 +1,129 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + # frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + cv2.circle(frame, (x + w // 2, y + h // 2), 15, (0, 0, 255), -1) + + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "alien number two", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" new file mode 100644 index 0000000000000000000000000000000000000000..e66d0f01037e083916d1617826751a7f791baee9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" @@ -0,0 +1,139 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + # 1. 擷取人臉區域 (Slice) + face_roi = frame[y:y+h, x:x+w] + + # 2. 縮小人臉圖像 (例如縮小到 15x15 像素) + small = cv2.resize(face_roi, (15, 15), interpolation=cv2.INTER_LINEAR) + + # 3. 放大回原本的大小 (使用 NEAREST 內插法保留顆粒感) + mosaic = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + + # 4. 放回原畫面中 + frame[y:y+h, x:x+w] = mosaic + + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "Dumbee Dumbee ", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/coffee_sales_analysis.png" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/coffee_sales_analysis.png" new file mode 100644 index 0000000000000000000000000000000000000000..4aed05f43a35b5e1968b78af6d8b5310af1cb93f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/coffee_sales_analysis.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59946ddc8203b2827942fbb461475e383cb9a0e6e13cff0439b216226ae2a59e +size 402632 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/coffee_sales_data.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/coffee_sales_data.csv" new file mode 100644 index 0000000000000000000000000000000000000000..9034a63a93b67363c54e7f155c9cb5daf483f886 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/coffee_sales_data.csv" @@ -0,0 +1,15 @@ +Date,Sales_Amount,Customer_Count,Coffee_Cups_Sold,Dessert_Sold,Promotion_Active +2026-07-01,15200,120,85,30,No +2026-07-02,16800,135,95,35,No +2026-07-03,24500,190,140,55,Yes +2026-07-04,28000,210,165,68,Yes +2026-07-05,18500,145,110,40,No +2026-07-06,14200,110,80,25,No +2026-07-07,15900,125,90,28,No +2026-07-08,17200,138,100,32,No +2026-07-09,26000,205,150,60,Yes +2026-07-10,29500,225,175,75,Yes +2026-07-11,21000,160,120,45,No +2026-07-12,19800,150,115,42,No +2026-07-13,15000,118,85,26,No +2026-07-14,16500,130,92,30,No diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/d1_4_visualization_coffee.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/d1_4_visualization_coffee.py" new file mode 100644 index 0000000000000000000000000000000000000000..73f2378e69a878a2c47431ede103843dce020072 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220/d1_4_visualization_coffee.py" @@ -0,0 +1,113 @@ +# d1_4_visualization_coffee.py +# Day 1: 資料科學與數據視覺化 - 咖啡廳營運數據進階分析(學員填空) +# cd examples/Day1/student && python3 d1_4_visualization_coffee.py +# 參考答案:../Teacher/d1_4_visualization_coffee.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + + +def load_csv(filename="coffee_sales_data.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + # TODO: 1. 使用 os.path.join 將當前腳本所在的目錄與檔案名稱拼接成絕對路徑 + # 2. 使用 os.path.exists 判斷路徑是否存在,若存在則用 pd.read_csv 讀取並回傳 + path = os.path.join(os.path.dirname(__file__), filename) + if os.path.exists(path): + return pd.read_csv(path) + else: + print("❌ 找不到 CSV 檔案!請確認 coffee_sales_data.csv 與本腳本在同一目錄。") + exit(1) + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 1 列 2 行 (1x2) 的子圖畫布,尺寸大小為寬 15 英吋、高 6 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + + +# ========================================== +# 1. 子圖 1 (ax1):營業額與咖啡銷量趨勢對比 (雙 Y 軸) +# ========================================== +# === 【學員填空練習點 3:繪製雙 Y 軸折線圖】 === +# TODO: +# 1. 在 ax1 (左軸) 繪製「營業額」折線圖:X軸為 df['Date'], Y軸為 df['Sales_Amount'] +# 設定參數:點形 marker='o', 顏色 color='#b87e6b' (鐵鏽紅), 圖例 label='營業額 (NTD)', 線寬 linewidth=2 +# 2. 呼叫 ax1.twinx() 建立共用 X 軸的右側 Y 軸物件,指派給 ax1_twin +# 3. 在 ax1_twin (右軸) 繪製「咖啡銷量」折線圖:X軸為 df['Date'], Y軸為 df['Coffee_Cups_Sold'] +# 設定參數:點形 marker='s' (正方形), 顏色 color='#5f7186' (石板藍), 虛線 linestyle='--', 圖例 label='咖啡銷量 (杯)' +# 4. 分別設定右軸的 Y 軸標籤與顏色以示區隔 (提示:使用 set_ylabel 與 tick_params) + +# 請在下方完成 ax1 雙 Y 軸折線圖的繪製: +line1 = ax1.plot(df['Date'], df['Sales_Amount'], marker='o', color='#E6E6FA', label='營業額 (NTD)', linewidth=2) +ax1_twin = ax1.twinx() +line2 = ax1_twin.plot(df['Date'], df['Coffee_Cups_Sold'], marker='s', color='#0000CD', linestyle='--', label='咖啡銷量 (杯)', linewidth=2) + +ax1.set_ylabel("營業額 (NTD)", color='#E6E6FA', fontsize=12) +ax1.tick_params(axis='y', labelcolor='#E6E6FA') +ax1_twin.set_ylabel("咖啡銷量 (杯)", color='#0000CD', fontsize=12) +ax1_twin.tick_params(axis='y', labelcolor='#0000CD') +# === 【學員填空練習點 4:雙 Y 軸圖例合併】 === +# TODO: +# 1. 由於雙 Y 軸屬於不同的 Axes 物件,若直接呼叫 ax1.legend() 只會顯示左軸的圖例。 +# 請獲取兩軸的 lines 與 labels,合併後統一顯示在 ax1 的左上角 (loc='upper left') +# 提示: +# lines1, labels1 = ax1.get_legend_handles_labels() +# lines2, labels2 = ax1_twin.get_legend_handles_labels() +# ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') + +# 請在下方完成圖例合併: +lines = line1 + line2 +labels = [l.get_label() for l in lines] +ax1.legend(lines, labels, loc='upper left') +# 設定 ax1 基礎格式 +ax1.set_title("咖啡廳每日營業額與咖啡銷量趨勢", fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") +ax1.grid(True, linestyle='--', alpha=0.5) +ax1.tick_params(axis='x', rotation=45) + + +# ========================================== +# 2. 子圖 2 (ax2):促銷活動對客流量的影響 (數據聚合長條圖) +# ========================================== +# === 【學員填空練習點 5:數據聚合與長條圖繪製】 === +# TODO: +# 1. 使用 groupby 依據 'Promotion_Active' 欄位分組,並計算 'Customer_Count' 的平均值 (mean) +# 提示:promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +# 2. 在 ax2 上繪製長條圖,以 promo_avg.index 作為 X 軸,promo_avg.values 作為 Y 軸 +# 設定參數:顏色 color=['#e6e2d8', '#b87e6b'],外框顏色 edgecolor='#3d3a36',寬度 width=0.5 +# 3. 在長條圖上方顯示具體的平均數值 (提示:使用 ax2.bar_label 或是跑迴圈加文字) + +# 請在下方完成資料聚合與長條圖繪製: +promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +bars = ax2.bar(promo_avg.index, promo_avg.values, color=['#40E0D0','#FFB6C1'], edgecolor='#87CEEB', width=0.5) +ax2.bar_label(bars, fmt='% 1f 人', label_type='edge', padding=3, fontweight='bold') + +# 設定 ax2 基礎格式 +ax2.set_title("促銷活動對每日平均客流量的影響", fontsize=14, fontweight='bold') +ax2.set_xlabel("是否舉辦促銷活動") +ax2.set_ylabel("平均來客數 (人)") +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) + + +plt.tight_layout() # 自動調整間距,避免文字重疊 + +# === 【學員填空練習點 6:圖表儲存與顯示】 === +# TODO: 1. 呼叫 savefig 將畫布儲存為檔名 "coffee_sales_analysis.png",並設定 dpi=300 +# 2. 呼叫 show 顯示圖表 +# (⚠️ 注意:不要在 print 或 savefig 內使用不支援 cp950 的 emoji) + +output_image = "coffee_sales_analysis.png" +# 請在下方完成圖表存檔與顯示: +plt.savefig(output_image, dpi=300) +plt.show() \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\230\264\345\267\264\347\234\274\347\235\233\351\226\213\345\220\210\345\201\265\346\270\254/step3_mouth_ratio.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\230\264\345\267\264\347\234\274\347\235\233\351\226\213\345\220\210\345\201\265\346\270\254/step3_mouth_ratio.py" new file mode 100644 index 0000000000000000000000000000000000000000..ea598d0d25283489995aafd31dbf69631e18b9eb --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\230\264\345\267\264\347\234\274\347\235\233\351\226\213\345\220\210\345\201\265\346\270\254/step3_mouth_ratio.py" @@ -0,0 +1,104 @@ +import cv2 +import mediapipe as mp +import math +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker initialized successfully.") +except Exception as e: + print(f"Error: Failed to initialize detector: {e}") + sys.exit() + +# 特徵點索引定義 +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 + +# 嘴巴張開的比例閾值 (超過代表張開) +THRESHOLD = 0.25 + +print("Press 'q' in the window to exit.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + break + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + + # 影像處理 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 【TODO 1】取得這 4 個特徵點在畫面中的 (x, y) 像素座標,並存在 pts 字典中 + pts = {} + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM]: + pt = face_lms[idx] + pts[idx] = (int(pt.x * img_w), int(pt.y * img_h)) + + + # 【TODO 2】計算兩眼外角距離 (做為基準比例尺) + p_lx, p_ly = pts[LEFT_EYE_OUTER] + p_rx, p_ry = pts[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + + if base_dist > 0: + # 【TODO 3】計算嘴巴張開的實際像素距離,並除以眼距算出「嘴巴張開比例 (mouth_ratio)」 + p_top_x, p_top_y = pts[LIP_TOP] + p_bot_x, p_bot_y = pts[LIP_BOTTOM] + mouth_dist = math.hypot(p_bot_x - p_top_x, p_bot_y - p_top_y) + mouth_ratio = mouth_dist / base_dist + + + # 【TODO 4】判斷嘴巴狀態是否大於 THRESHOLD,並在畫面上繪製量測輔助線 + # 提示:用 cv2.line 畫出眼部基準線 (藍色) 和嘴巴測量線 (橘色) + is_open = mouth_ratio > THRESHOLD + cv2.line(img, (p_lx, p_ly), (p_rx, p_ry), (255, 0, 0), 2) # 藍色眼線 + cv2.line(img, (p_top_x, p_top_y), (p_bot_x, p_bot_y), (0, 165, 255), 2) # 橘色嘴線 + + + # 畫出特徵點小黃圓圈 + for idx, pos in pts.items(): + cv2.circle(img, pos, 5, (0, 255, 255), -1) + + # 顯示比例與開合狀態文字 + cv2.putText(img, f"Mouth Ratio: {mouth_ratio:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + status_text = "OPEN" if is_open else "CLOSED" + status_color = (0, 255, 0) if is_open else (0, 0, 255) + cv2.putText(img, f"State: {status_text}", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, status_color, 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + cv2.imshow("Mouth Ratio Detector - Press 'q' to quit", img) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\230\264\345\267\264\351\226\213\345\220\210\345\201\265\346\270\254\345\231\250/step2_facemesh_complete.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\230\264\345\267\264\351\226\213\345\220\210\345\201\265\346\270\254\345\231\250/step2_facemesh_complete.py" new file mode 100644 index 0000000000000000000000000000000000000000..662ec00bf23d8dfa72100126c7fcd43e9cacffca --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\230\264\345\267\264\351\226\213\345\220\210\345\201\265\346\270\254\345\231\250/step2_facemesh_complete.py" @@ -0,0 +1,362 @@ +""" +Week 11 - Face Mesh Expression Detection (Complete Template 填空版) +=========================================================== +本步驟目標: + 1. 將 MediaPipe Face Landmarker 與 Pygame 完美結合,打造科幻儀表板 + 2. 計算臉部指標:嘴巴張開比例與眼睛胖瘦比例 (EAR) + 3. 實作「雙門檻滯後防吵鎖」,避免警報器反覆爆音 + 4. 實作「跳幀推理優化」,解決 AI 推理造成的畫面卡頓 + 5. 整合音效播放功能 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import time + +# ============================================ +# 1. 初始化 Pygame & 音效 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 11 - Face Mesh Expression Detector ") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 36) +small_font = pygame.font.SysFont(None, 24) +title_font = pygame.font.SysFont(None, 48) + +# 載入警告音效 (shoot.wav) +try: + trigger_sound = pygame.mixer.Sound(os.path.join(os.path.dirname(__file__), "shoot.wav")) + print("Success: Loaded sound file shoot.wav") +except FileNotFoundError: + trigger_sound = None + print("Warning: Could not find sound file shoot.wav") + +# ============================================ +# 2. 初始化 MediaPipe Face Landmarker +# ============================================ +model_path = os.path.join(os.path.dirname(__file__), 'face_landmarker.task') +if not os.path.exists(model_path): + # 若在當前資料夾找不到,就去上一層找 + model_path = os.path.join(os.path.dirname(__file__), '..', 'face_landmarker.task') + if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the directory.") + pygame.quit() + sys.exit() + +# 【TODO 1】初始化 MediaPipe Face Landmarker 偵測器 +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Success: Loaded MediaPipe detector model") +except Exception as e: + print(f"Error: Failed to load model: {e}") + pygame.quit() + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Success: Loaded MediaPipe detector model") +except Exception as e: + print(f"Error: Failed to load model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 3. 臉部特徵點索引定義 +# ============================================ +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 +LEFT_EYE_TOP = 159 +LEFT_EYE_BOTTOM = 145 +LEFT_EYE_RIGHT = 133 + +# ============================================ +# 4. 門檻值與狀態變數 +# ============================================ +# 嘴巴雙門檻防吵鎖 +MOUTH_OPEN_THRESHOLD = 0.35 +MOUTH_CLOSE_THRESHOLD = 0.15 + +# 眼睛雙門檻防吵鎖 +EAR_BLINK_THRESHOLD = 0.18 +EAR_OPEN_THRESHOLD = 0.25 + +is_mouth_open = False +was_mouth_open = False + +is_blinking = False +was_blinking = False + +mouth_open_count = 0 +blink_count = 0 + +# 跳幀優化變數:每 3 影格才做一次 AI 推理 +frame_counter = 0 +FRAME_SKIP = 3 +detector_results = None + +# 開啟攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + pygame.quit() + sys.exit() + +# ============================================ +# 5. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + frame_counter += 1 + + plot_points = {} + + # 【TODO 2】實作跳幀推理優化 (每 FRAME_SKIP 影格才跑一次 AI) + # if frame_counter % FRAME_SKIP == 0 or detector_results is None: + # try: + # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + # detector_results = detector.detect(mp_image) + # except Exception as e: + # print(f"Error: Inference exception: {e}") + + if frame_counter % FRAME_SKIP == 0 or detector_results is None: + try: + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + detector_results = detector.detect(mp_image) + except Exception as e: + print(f"Error: Inference exception: {e}") + + # 【TODO 3】特徵點座標映射與比例計算 + # 1. 取得對應點在 Pygame 視窗 (WIDTH, HEIGHT) 中的像素位置並存入 plot_points + # 2. 計算眼寬基準線 base_dist + # 3. 計算歸一化嘴巴比例 mouth_ratio + # 4. 計算眼睛胖瘦比 ear (提示:垂直高度 eye_h / 水平寬度 eye_w) + # if detector_results and detector_results.face_landmarks: + # face_lms = detector_results.face_landmarks[0] + # + # for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + # pt = face_lms[idx] + # plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + # + # p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + # p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + # base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + # + # if base_dist > 0: + # p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + # p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + # mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + # mouth_ratio = mouth_dist / base_dist + # + # p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + # p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + # p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + # + # eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + # eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + # ear = eye_h / eye_w if eye_w > 0 else 0 + + if detector_results and detector_results.face_landmarks: + face_lms = detector_results.face_landmarks[0] + + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + pt = face_lms[idx] + plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + + p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + if base_dist > 0: + p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + mouth_ratio = mouth_dist / base_dist + + p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + + eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + ear = eye_h / eye_w if eye_w > 0 else 0 + + # 【TODO 4】實作雙門檻防吵鎖 (滯後判斷邏輯) + # if mouth_ratio > MOUTH_OPEN_THRESHOLD: + # is_mouth_open = True + # elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + # is_mouth_open = False + # + # if ear < EAR_BLINK_THRESHOLD: + # is_blinking = True + # elif ear > EAR_OPEN_THRESHOLD: + # is_blinking = False + + if mouth_ratio > MOUTH_OPEN_THRESHOLD: + is_mouth_open = True + elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + is_mouth_open = False + + if ear < EAR_BLINK_THRESHOLD: + is_blinking = True + elif ear > EAR_OPEN_THRESHOLD: + is_blinking = False + + # 【TODO 5】實作邊緣觸發判定 (只有在剛張嘴或剛閉眼的那一瞬間,播放一次警告音效) + # if is_mouth_open and not was_mouth_open: + # mouth_open_count += 1 + # if trigger_sound: + # trigger_sound.play() + # + # if is_blinking and not was_blinking: + # blink_count += 1 + # + # was_mouth_open = is_mouth_open + # was_blinking = is_blinking + + if is_mouth_open and not was_mouth_open: + mouth_open_count += 1 + if trigger_sound: + trigger_sound.play() + + if is_blinking and not was_blinking: + blink_count += 1 + + was_mouth_open = is_mouth_open + was_blinking = is_blinking + + else: + is_mouth_open = False + is_blinking = False + was_mouth_open = False + was_blinking = False + mouth_ratio = 0.0 + ear = 0.30 + + # ============================================ + # 6. 渲染 Pygame 畫面與 UI 元素 + # ============================================ + # 【TODO 6】根據狀態切換背景顏色 (安全時深色,張嘴時深綠色,眨眼時深藍色) + # if is_mouth_open: + # screen.fill((16, 44, 28)) + # elif is_blinking: + # screen.fill((15, 30, 54)) + # else: + # screen.fill((20, 20, 30)) + + if is_mouth_open: + screen.fill((16, 44, 28)) + elif is_blinking: + screen.fill((15, 30, 54)) + else: + screen.fill((20, 20, 30)) + + # 【TODO 7】畫出眼寬基準線、眼睛高度線與嘴唇高度線 + # 提示:用 pygame.draw.line 在螢幕上將點位連線 + # if plot_points: + # pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + # pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + # pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + if plot_points: + pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + # 畫特徵點小圓圈 + for idx, pos in plot_points.items(): + color = (255, 255, 255) + if idx in [LIP_TOP, LIP_BOTTOM]: + color = (245, 158, 11) + elif idx in [LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_OUTER, LEFT_EYE_RIGHT]: + color = (59, 130, 246) + pygame.draw.circle(screen, color, pos, 6) + + # 繪製儀表板 HUD 卡片 + pygame.draw.rect(screen, (30, 30, 45), (30, 30, 340, 220), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (30, 30, 340, 220), width=2, border_radius=12) + + screen.blit(font.render("Face Mesh Dashboard", True, (255, 255, 255)), (50, 45)) + + m_color = (34, 197, 94) if is_mouth_open else (245, 158, 11) + screen.blit(small_font.render(f"Mouth Ratio: {mouth_ratio:.2f}", True, m_color), (50, 90)) + pygame.draw.rect(screen, (50, 50, 65), (50, 110, 200, 12), border_radius=4) + pygame.draw.rect(screen, m_color, (50, 110, int(min(mouth_ratio, 1.0) * 200), 12), border_radius=4) + + e_color = (59, 130, 246) if not is_blinking else (239, 68, 68) + screen.blit(small_font.render(f"Eye EAR: {ear:.2f}", True, e_color), (50, 140)) + pygame.draw.rect(screen, (50, 50, 65), (50, 160, 200, 12), border_radius=4) + pygame.draw.rect(screen, e_color, (50, 160, int(min(ear, 0.5) * 400), 12), border_radius=4) + + # 繪製閾值指示白線 + pygame.draw.line(screen, (255, 255, 255), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 108), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 124), 2) + pygame.draw.line(screen, (255, 255, 255), (50 + int(EAR_BLINK_THRESHOLD * 400), 158), (50 + int(EAR_BLINK_THRESHOLD * 400), 174), 2) + + # 繪製統計卡片 + pygame.draw.rect(screen, (30, 30, 45), (WIDTH - 320, 30, 290, 110), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (WIDTH - 320, 30, 290, 110), width=2, border_radius=12) + screen.blit(small_font.render(f"Mouth Open Count: {mouth_open_count}", True, (255, 255, 255)), (WIDTH - 300, 45)) + screen.blit(small_font.render(f"Blink/Close Count: {blink_count}", True, (255, 255, 255)), (WIDTH - 300, 75)) + screen.blit(small_font.render(f"Hysteresis Status: ACTIVE", True, (34, 197, 94)), (WIDTH - 300, 105)) + + # 顯示置中警告狀態大字 + if is_mouth_open: + status_lbl = title_font.render("MOUTH OPENED", True, (34, 197, 94)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + elif is_blinking: + status_lbl = title_font.render("BLINKING", True, (59, 130, 246)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + else: + if not plot_points: + status_lbl = title_font.render("NO FACE DETECTED", True, (239, 68, 68)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 - 20)) + else: + status_lbl = title_font.render("NORMAL", True, (200, 200, 200)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + + # 顯示底部效能狀態文字 + status_text = small_font.render( + f"Frame: {frame_counter} | Skip: {FRAME_SKIP} frames | FPS: {clock.get_fps():.0f}", + True, (100, 100, 120) + ) + screen.blit(status_text, (20, HEIGHT - 35)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\244\251\346\260\243\350\263\207\346\226\231\345\234\226\350\241\250/d1_3_visualization.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\244\251\346\260\243\350\263\207\346\226\231\345\234\226\350\241\250/d1_3_visualization.py" new file mode 100644 index 0000000000000000000000000000000000000000..b61de8fef1d17cc8aa320d2cd5997a795f5bdb81 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\244\251\346\260\243\350\263\207\346\226\231\345\234\226\350\241\250/d1_3_visualization.py" @@ -0,0 +1,87 @@ +# d1_3_visualization.py +# Day 1: 資料科學與數據視覺化 - Matplotlib 繪圖基礎(學員填空) +# cd examples/Day1/student && python3 d1_3_visualization.py +# 參考答案:../Teacher/d1_3_visualization.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + +def load_csv(filename="taipei_weather_2026.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + # TODO: 1. 使用 os.path.join 將當前腳本所在的目錄與檔案名稱拼接成絕對路徑 + # 提示: 當前腳本目錄為 os.path.dirname(__file__) + # 2. 使用 os.path.exists 判斷路徑是否存在,若存在則用 pd.read_csv 讀取並回傳 + path = os.path.join(os.path.dirname(__file__), filename) + + if os.path.exists(path): + # 請在此實作載入邏輯: + return pd.read_csv(path) + else: + print("files not found") + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 2 列 1 行 (2x1) 的子圖畫布,尺寸大小為寬 10 英吋、高 8 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8)) + +# 1. 繪製折線圖 (Line Plot) - 每日溫度 +# === 【學員填空練習點 3:繪製每日溫度折線圖】 === +# TODO: 1. 在 ax1 畫布上以 ax1.plot() 繪製折線圖,X軸傳入 df['Date'],Y軸傳入 df['Temperature'] +# 設定參數:帶有圓點 marker='o',顏色 color='crimson',圖例 label='溫度 (°C)',線寬 linewidth=2 +# 2. 使用 ax1.set_title 設定標題 "台北觀測站每日溫度趨勢圖" +# 3. 設定 ax1.set_xlabel("日期") 與 ax1.set_ylabel("數值") +# 4. 設定網格 ax1.grid(True, linestyle='--', alpha=0.5) +# 5. 呼叫 ax1.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax1.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax1 的溫度折線圖繪製: +ax1.plot(df['Date'], df['Humidity'], marker='s', color='blue'label='濕度',linewidth=2) +ax1.plot(df['Date'], df['Temperature'], marker='o', color='crimson', label='溫度(度)',linewidth=2) +ax1.set_title("台北觀測站每日溫度趨勢圖",fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") +ax1.set_ylabel("數值") +ax1.grid(True, linestyle='--',alpha=0.5) +ax1.legend() +ax1.tick_params(axis='x',rotation=45) + +# 2. 繪製條形圖 (Bar Plot) - 每日降雨量 +# === 【學員填空練習點 4:繪製每日降雨量長條圖】 === +# TODO: 1. 在 ax2 畫布上以 ax2.bar() 繪製長條圖,X軸傳入 df['Date'],Y軸傳入 df['Rainfall'] +# 設定參數:顏色 color='skyblue',外框顏色 edgecolor='royalblue',圖例 label='降雨量 (mm)' +# 2. 使用 ax2.set_title 設定標題 "台北觀測站每日降雨量分佈" +# 3. 設定 ax2.set_xlabel("日期") 與 ax2.set_ylabel("降雨量 (mm)") +# 4. 設定 Y 軸網格 ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +# 5. 呼叫 ax2.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax2.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax2 的降雨量長條圖繪製: +ax2.bar(df['Date'], df['Rainfall'], color='turquoise', edgecolor='skyblue', label='降雨量 (mm)') +ax2.set_title("台北觀測站每日降雨量分佈",fontsize=14, fontweight='bold') +ax2.set_xlabel("日期") +ax2.set_ylabel("降雨量") +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +ax2.legend() +ax2.tick_params(axis='x',rotation=45) + +plt.tight_layout() # 自動調整間距,避免文字重疊 + +# === 【學員填空練習點 5:圖表自動儲存與顯示】 === +# TODO: 1. 呼叫 plt.savefig 將畫布儲存為檔名 "taipei_weather_2026.png",並設定高畫質 dpi=300 +# (⚠️ 注意:savefig 必須在 show() 之前呼叫) +# 2. 呼叫 plt.show() 將圖表視窗顯示在畫面上 +# +# 請在下方完成圖表存檔與顯示: +plt.savefig("taipei_weather_2026.png", dpi=300) +plt.show() \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\244\251\346\260\243\350\263\207\346\226\231\345\234\226\350\241\250/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\244\251\346\260\243\350\263\207\346\226\231\345\234\226\350\241\250/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\244\251\346\260\243\350\263\207\346\226\231\345\234\226\350\241\250/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" new file mode 100644 index 0000000000000000000000000000000000000000..cf5df015aac87494579b472b9aac674ac501099f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" @@ -0,0 +1,69 @@ +# d3_1_opencv_basic.py +# Day 3: 電腦視覺與影像辨識 - OpenCV 影像處理基礎 (學員練習版) +# 練習指引:依序找到程式碼中的 TODO 1 到 TODO 5,將其下方的程式碼「解除註釋(刪除行首的 # )」並執行,觀察影像處理管線中每一步的變化。 + +import cv2 +import numpy as np + +# 1. 影像讀取 (Read) +img = cv2.imread("Rebecca.webp") +if img is None: + print("Rebecca webp") + exit(1) + +# 預設灰階影像為原始彩色影像,避免未解開註釋時程式出錯 +gray = img + +# === 【重要觀念 TODO 1:灰階轉換 (Grayscale Conversion)】 === +# 觀念重點:大多數電腦視覺演算法在處理單通道(黑白)影像時速度更快。 +# 將 BGR 彩色圖像轉換為單通道灰階圖像,可以減少 2/3 的資料量,且能過濾掉無用的顏色雜訊。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察變為灰階後的影像。 +gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + +# 預設模糊影像為灰階影像,避免未解開註釋時程式出錯 +blurred = gray + +# === 【重要觀念 TODO 2:高斯模糊 (Gaussian Blur)】 === +# 觀念重點:高斯模糊是透過像素加權平均來平滑影像,主要目的是「消除噪點和細微反光」。 +# 這可以避免後續邊緣偵測時把細微的噪點誤判為輪廓,讓偵測出來的邊緣更加平滑。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察影像邊緣的柔焦效果。 +# blurred = cv2.GaussianBlur(gray, (5, 5), 0) + +# 預設邊緣影像為全黑畫布,避免未解開註釋時程式出錯 +edges = np.zeros(img.shape[:2], dtype=np.uint8) + +# === 【重要觀念 TODO 3:Canny 邊緣偵測 (Canny Edge Detection)】 === +# 觀念重點:Canny 演算法會找出影像中明暗變化劇烈的分界線。它使用雙閥值法: +# 高於高閥值(150)的 100% 判定為邊緣;介於高低閥值(50~150)之間的若連接強邊緣則保留;其餘捨棄。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察提取出的白色線條輪廓。 +edges = cv2.Canny(blurred, 120, 150) + +# 預設無偵測到輪廓,且繪製圖像為原始圖像,避免未解開註釋時程式出錯 +contours = [] +contour_img = img.copy() + +# === 【重要觀念 TODO 4:尋找與繪製輪廓 (Find & Draw Contours)】 === +# 觀念重點:`findContours` 會在二值化(邊緣)影像中尋找相連的邊界,並建立幾何結構。 +# `drawContours` 則會將這些幾何結構繪製到指定的影像上(此處使用綠色框繪製於原始彩色影像)。 +# 請解開下方所有註釋 (刪除行首的 #) 並執行,觀察圖形外框被綠線框起來的結果。 +contours, hierarchy = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) +print(f"在影像中偵測到 {len(contours)} 個獨立輪廓特徵。") +contour_img = img.copy() +cv2.drawContours(contour_img, contours, -1, (249, 204, 202),1) + +# === 【重要觀念 TODO 5:視窗顯示與釋放 (Window Display)】 === +# 觀念重點:使用 cv2.imshow 顯示各步驟處理結果,並用 waitKey(0) 讓程式停下等待按鍵, +# 最後使用 destroyAllWindows 釋放視窗資源。 +# 請解開下方所有註釋 (刪除行首的 #),以觀看影像處理的成果! +# print("\n正在顯示影像處理結果(請點擊影像視窗並按下任意鍵關閉)...") +# cv2.imshow("Original Shapes", img) +cv2.imshow("Grayscale", gray) +cv2.imshow("Canny Edges", edges) +cv2.imshow("Contours Output", contour_img) + +success_gray = cv2.imwrite("Rebecca_gray.webp", gray) +success_edges = cv2.imwrite("Rebecca_edges.webp", edges) +success_contour = cv2.imwrite("Rebecca_contours.webp", contour_img) + +cv2.waitKey(0) +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220&ai\345\240\261\345\221\212_PTT/d4_2_ex2_crawler_ai.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220&ai\345\240\261\345\221\212_PTT/d4_2_ex2_crawler_ai.py" new file mode 100644 index 0000000000000000000000000000000000000000..aee5762e4402839d4cc31e91c7535f0eb7ed1fed --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220&ai\345\240\261\345\221\212_PTT/d4_2_ex2_crawler_ai.py" @@ -0,0 +1,230 @@ +# d4_2_ex2_crawler_ai.py +# Day 4: 爬蟲與 AI 分析任務鏈 (學生填空版) +# 執行方式:cd examples/Day4/student && python d4_2_ex2_crawler_ai.py + +import os +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin +from google import genai + +# 設定預設使用的 Gemini 模型 +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +def plan_steps(source_type): + """規劃階段:展示任務拆解流程。""" + steps = [ + f"步驟 1: 啟動爬蟲工具擷取『{source_type.upper()}』熱門文章與輿情數據", + "步驟 2: 將爬取資料作為 Context,呼叫 Gemini 進行輿情趨勢與熱門話題分析", + "步驟 3: 自動將 AI 生成的繁體中文分析報告以 UTF-8 編碼存為 Markdown 檔案" + ] + print("\n[Planning] 任務計畫已啟動:") + for s in steps: + print(f" -> {s}") + return steps + +def tool_scrape_ptt(): + """工具 A:抓取 PTT Python 板最新文章資訊。""" + url = "https://www.ptt.cc/bbs/Python/index.html" + print(f"\n[Tool] 正在爬取 PTT Python 板文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + posts = soup.find_all("div", class_="r-ent") + + results = [] + for post in posts: + + # === 【學員填空練習點 1:PTT 文章標題與連結提取及防錯 (Null Check)】 === + # TODO 1: 若文章已被作者或板主刪除,title_div 底下將會「沒有 標籤」 + # 1. 請在 title_div 底下尋找 標籤 (指派給 title_link 變數) + # 2. 撰寫條件判斷:若 title_link 存在,提取其純文字並用 urljoin 拼接成完整網址。 + # 若不存在,直接將 title_div 的純文字作為 title,且將連結 post_url 設為 "無 (文章已刪除)"。 + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get_text().strip() + relative_url = title_link.get("href") + post_url = urljoin(url, relative_url) + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + if not nrec: + nrec = "0" + + author = post.find("div", class_="author").get_text().strip() if post.find("div", class_="author") else "未知作者" + date = post.find("div", class_="date").get_text().strip() if post.find("div", class_="date") else "未知日期" + + results.append(f"標題: {title} | 作者: {author} | 日期: {date} | 推文數: {nrec} | 網址: {post_url}") + + return "\n".join(results) if results else "找不到任何文章項目。" + except Exception as e: + return f"[PTT 爬蟲錯誤] 執行異常: {e}" + +def tool_scrape_ithelp(): + """工具 B:抓取 iT 邦幫忙熱門技術文章。""" + url = "https://ithelp.ithome.com.tw/articles?tab=tech" + print(f"\n[Tool] 正在爬取 iT 邦幫忙技術文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + articles = soup.find_all("div", class_="qa-list") + + results = [] + for art in articles: + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + time_link = art.find("a", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + conditions = art.find_all("a", class_="qa-condition") + likes, comments, views = "0", "0", "0" + + # === 【學員填空練習點 2:利用 List 索引提取多項數據】 === + # TODO 2: 當抓取到的 conditions 數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + if len(conditions) >= 3: + # 請在下方填入提取 Like、回應與瀏覽數的代碼: + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + results.append( + f"標題: {title} | 作者: {author} | 時間: {post_time} | " + f"Like: {likes} | 回應: {comments} | 瀏覽: {views} | 網址: {post_url}" + ) + + return "\n".join(results) if results else "找不到任何技術文章。" + except Exception as e: + return f"[iT邦幫忙爬蟲錯誤] 執行異常: {e}" + +def tool_save_report(filename, content): + """工具 C:將報告以 UTF-8 編碼寫入本機檔案。""" + print(f"\n[Tool] 正在將分析報告儲存至檔案: {filename} ...") + # === 【學員填空練習點 6:寫入檔案編碼處理】 === + # TODO 6: 請使用 with open 開啟檔案,以 "utf-8" 編碼寫入 content,並回傳 filename。 + # 提示:必須傳入 encoding="utf-8" 以避免 Windows 系統的中文亂碼 + + with open(filename, "w", encoding = "utf-8") as f: + f.write(content) + # 請在此處實作 TODO 6 ... + return filename + +def run_analysis_chain(source_type): + print("=" * 70) + print(f"啟動自動化輿情任務鏈。目標來源:『{source_type.upper()}』數據分析") + print("=" * 70) + + # 步驟 0: 展示規劃 + plan_steps(source_type) + + # 步驟 1: 執行對應的爬蟲工具 + if source_type.lower() == "ptt": + raw_data = tool_scrape_ptt() + elif source_type.lower() == "ithelp": + raw_data = tool_scrape_ithelp() + else: + print("[錯誤] 未支援的數據來源。僅支援 'ptt' 或 'ithelp'") + return + + print(f"\n[步驟 1] 資料檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 數據預覽 (前 200 字) ---") + print(raw_data[:200] + ("..." if len(raw_data) > 200 else "")) + print("----------------------------") + + # === 【學員填空練習點 3:任務鏈工作記憶與狀態維護】 === + # TODO 3: 請建立名為 memory 的字典 (dict),包含三個鍵值: + # "source" 指向來源 source_type、"raw_data" 指向原始爬取資料 raw_data、"report" 設為空字串 "" 用以儲存後續 AI 報告。 + memory = { + "source": source_type, + "raw_data":raw_data, + "report": "" + } + + # 步驟 2: AI 分析 (或 Mock 降級防禦) + if not api_key: + print("\n[步驟 2] 未偵測到環境變數 GEMINI_API_KEY,自動啟動 Mock 離線降級機制...") + # === 【學員填空練習點 5:Mock 降級防衛機制】 === + # TODO 5: 請設計一段模擬的 Markdown 格式分析報告字串,指派給 report_content。 + # 報告內容至少需要包含標題與數據摘要,避免程式在無 Key 時報錯中斷。 + report_content = ( + f"# 模擬與情分析報告 - 數據來源:{source_type.upper()}\n\n" + ) + print("[步驟 2] Mock 報告模擬生成完畢。") + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行數據分析與排版...") + try: + # === 【學員填空練習點 4:新版 google-genai SDK 初始化與調用】 === + # TODO 4: + # 1. 呼叫 genai.Client 並傳入 api_key 來初始化 client 物件。 + # 2. 使用 client.models.generate_content 發送生成請求。 + # 傳入參數 model=GEMINI_MODEL,contents 則為準備好的 Prompt。 + client = genai.Client(api_key=api_key) + prompt = ( + f"請幫我分析以下來自 {source_type.upper()} 的最新網頁抓取文章列表,並用繁體中文撰寫一份 Markdown 輿情分析報告。\n" + f"報告必須包含以下部分:\n" + f"1. 最受歡迎的角色\n" + f"2. 草帽海賊團的成員\n" + f"3. 故事中重要的人物" + f"4. 海賊船千陽號\n\n" + f"=== 原始爬蟲數據 ===\n" + f"{raw_data}" + ) + + # 請在此處實作 TODO 4 ... + response = client.models.generate_content( + model=GEMINI_MODEL, + contents=prompt + ) + report_content = "" + print("[步驟 2] 雲端 Gemini 分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e},自動轉為錯誤備份輸出。") + report_content = f"# 輿情分析失敗\n\n錯誤詳情:{e}\n\n=== 原始數據 ===\n{raw_data}" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"ai_analysis_report_{source_type}.md" + try: + saved_file = tool_save_report(output_filename, memory["report"]) + print(f"\n[步驟 3] 任務報告生成成功!已儲存至: {saved_file}") + print("=" * 70) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + +if __name__ == "__main__": + # 可以切換為 "ptt" 或 "ithelp" 進行測試 + run_analysis_chain("ptt") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220&ai\345\240\261\345\221\212_\345\220\215\350\250\200\345\210\206\346\236\220/d4_2_agent_task_chain.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220&ai\345\240\261\345\221\212_\345\220\215\350\250\200\345\210\206\346\236\220/d4_2_agent_task_chain.py" new file mode 100644 index 0000000000000000000000000000000000000000..b9dadab11c7ec58bf81b57c6635b66b2e5b85a2f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220&ai\345\240\261\345\221\212_\345\220\215\350\250\200\345\210\206\346\236\220/d4_2_agent_task_chain.py" @@ -0,0 +1,141 @@ +# d4_2_agent_task_chain.py +# Day 4: 自動化任務鏈 (Task Chain / 簡化版 Agent 工作流) +# 說明:本範例是「固定管線」——步驟由我們寫死,不是模型自主 function calling。 +# 目的是讓學生先理解:工具 + LLM + 記憶狀態 如何串成可交付的自動化流程。 + +import os +import requests +from bs4 import BeautifulSoup + +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + + +def plan_steps(topic): + """規劃階段:對學生展示任務拆解(固定計畫,非 LLM 自主規劃)。""" + steps = [ + f"1. 使用爬蟲工具擷取主題「{topic}」相關名言", + "2. 將原始文字作為 context 送入 LLM 摘要與翻譯", + "3. 將 Markdown 報告寫入本機檔案", + ] + print("[Planning] 任務計畫:") + for s in steps: + print(" ", s) + return steps + + +def tool_scrape_topic_quotes(topic): + """ + 工具 A:抓取 Quotes to Scrape 的標籤頁面內容。 + 例如 topic = 'love', 'life', 'inspirational' 等。 + """ + url = f"http://quotes.toscrape.com/tag/{topic}/" + print(f"[Tool] 執行網頁資料檢索。目標網址: {url}") + try: + response = requests.get(url, headers=HEADERS, timeout=5) + if response.status_code == 200: + soup = BeautifulSoup(response.text, "html.parser") + quote_divs = soup.find_all("div", class_="quote") + results = [] + for q in quote_divs: + text_el = q.find("span", class_="text") + author_el = q.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + results.append(f"名言: {text} | 作者: {author}") + return "\n".join(results) if results else "找不到與此主題相關的內容。" + return f"網頁讀取錯誤,代碼: {response.status_code}" + except Exception as e: + return f"工具執行異常: {e}" + + +def tool_save_report(filename, content): + """工具 B:將報告寫入本機檔案。""" + with open(filename, "w", encoding="utf-8") as f: + f.write(content) + return filename + + +def run_task_chain(topic): + print("=" * 60) + print(f"啟動自動化任務鏈。目標:整理與分析關於『{topic}』的文獻報告") + print("(模式:固定 Pipeline,非自主 Tool-Calling Agent)") + print("=" * 60) + + # 步驟 0: 規劃(教學展示) + plan_steps(topic) + + # 步驟 1: 呼叫檢索工具 + raw_data = tool_scrape_topic_quotes(topic) + print(f"\n[步驟 1] 檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 原始數據預覽 ---") + preview = raw_data[:200] + ("..." if len(raw_data) > 200 else "") + print(preview) + print("--------------------") + + # 工作記憶:保存中間狀態 + memory = {"topic": topic, "raw_data": raw_data, "report": ""} + + # 步驟 2: LLM 分析(或 Mock 降級) + if not api_key: + print("\n[步驟 2] 未偵測到 API Key。啟動模擬生成中...") + report_content = ( + f"# 模擬關於《{topic}》的名人名言分析報告\n\n" + f"## 一、 數據檢索摘要\n" + f"本次任務共檢索到數筆關於 {topic} 的語錄。以下為代表性文獻:\n" + f"- 模擬數據:\"Love is all, love is new.\" -- John Lennon\n\n" + f"## 二、 核心思想分析\n" + f"本機模擬分析指出,此主題的語錄主要著眼於人與人之間的連結與心靈啟發。\n" + f"*(注意:此為 Mock 離線生成報告,設定 GEMINI_API_KEY 以啟用 Gemini 真實生成。)*\n" + ) + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行深度分析與排版...") + try: + import google.generativeai as genai # 僅雲端模式需要 + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(GEMINI_MODEL) + + prompt = ( + f"請幫我分析以下關於『{topic}』的原始英文名言數據,並用繁體中文撰寫一份 Markdown 報告。\n" + f"報告必須包含三個部分:\n" + f"1. 導言:說明這個主題的核心意義。\n" + f"2. 名言分析:挑選原始資料中 2-3 句具代表性的句子進行翻譯與深度賞析。\n" + f"3. 結論:對學生的啟發與建議。\n\n" + f"=== 原始數據 ===\n" + f"{raw_data}" + ) + + response = model.generate_content(prompt) + report_content = response.text + print("[步驟 2] 雲端分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e}。轉為模擬輸出。") + report_content = f"# 分析失敗\n\n錯誤詳情:{e}\n" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"agent_report_{topic}.md" + try: + saved = tool_save_report(output_filename, report_content) + print(f"\n[步驟 3] 任務報告生成成功!已儲存為檔案: {saved}") + print("=" * 60) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + + +if __name__ == "__main__": + # 可更換為: 'love', 'life', 'inspirational', 'humor' 等 + test_topic = "inspirational" + run_task_chain(test_topic) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_PTT/d4_1_ex2_ptt.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_PTT/d4_1_ex2_ptt.py" new file mode 100644 index 0000000000000000000000000000000000000000..4c0ad885893936a77642ecaba8a3ac6c53c8e88a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_PTT/d4_1_ex2_ptt.py" @@ -0,0 +1,81 @@ +# d4_1_ex2_ptt.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex2_ptt.py +# 參考答案:../d4_1_ex2_ptt.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:PTT Python 討論板首頁 +URL = "https://www.ptt.cc/bbs/Python/index.html" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則 PTT 會拒絕請求並回傳 403 錯誤 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在抓取 PTT Python 板最新文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章區塊】 === + # TODO: 觀察網頁 HTML 結構,找出所有包裹文章資訊的
標籤(其 class 名稱為何?) + posts = soup.find_all("div", class_="r-ent") + + print(f"\n成功取得 {len(posts)} 筆文章項目(含已刪除與置底公告):") + print("=" * 80) + + for idx, post in enumerate(posts, 1): + # === 【學員填空練習點 3:提取文章標題與連結並防錯 (Null Check)】 === + # TODO: + # 1. 尋找 class 為 "title" 的
標籤 + # 2. 在該
底下尋找 標籤 (即文章連結) + # 3. ⚠️ 重要思考:若文章已刪除,將不會有 標籤。請撰寫條件判斷: + # - 若 標籤存在:提取其文字,並使用 urljoin 拼接 href 屬性得到完整網址。 + # - 若 標籤不存在:直接將 title_div 的文字作為標題,並將網址設為 "無 (文章已刪除)"。 + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get("title") + relative_url = title_link.get("href") + post_url = urljoin(URL, relative_url) + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + + # === 【學員填空練習點 4:提取推文數】 === + # TODO: 尋找 class 為 "nrec" 的
標籤。 + # 技巧:使用 .get_text().strip() 取得文字。若無文字(0 推),請將變數預設為 "0"。 + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + + # === 【學員填空練習點 5:提取作者與發文日期】 === + # TODO: 分別尋找 class 為 "author" 與 "date" 的
標籤,並提取純文字(記得使用 .strip() 去除空白) + author = post.find("div", class_="author").get_text().strip() + date = post.find("div", class_="date").get_text().strip() + + # 輸出單篇文章資訊 + print(f"[{idx}] 推文數: {nrec:3s} | 發文日期: {date} | 作者: {author}") + print(f" 標題: {title}") + print(f" 網址: {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] PTT 伺服器拒絕或找不到網頁:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_books/d4_1_ex1_books.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_books/d4_1_ex1_books.py" new file mode 100644 index 0000000000000000000000000000000000000000..7b20bb8c1ce86a4d012d8b072db8a037636431d9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_books/d4_1_ex1_books.py" @@ -0,0 +1,94 @@ +# d4_1_ex1_books.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex1_books.py +# 參考答案:../d4_1_ex1_books.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:專供爬蟲練習的圖書商城沙盒 +URL = "http://books.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至圖書沙盒網站: {URL} ...") + +try: + # 發送 GET 請求取得網頁 HTML + response = requests.get(URL, headers=HEADERS, timeout=10) + + # 檢查 HTTP 狀態碼是否為 200 (OK),非 200 時引發 HTTPError 異常 + response.raise_for_status() + + # 解決編碼問題:強制將解碼設定為 utf-8 (避免 requests 預設 ISO-8859-1 解碼導致英鎊符號亂碼且在 Windows 印出時崩潰) + response.encoding = response.apparent_encoding + + print("連線成功!開始解析書籍資料...\n") + + # 初始化 BeautifulSoup 解析器 + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有書籍的容器。每本書都被包在一個
標籤中,且該標籤的 class 固定為 "product_pod" + books = soup.find_all("article", class_="product_pod") + + print(f"成功在本頁找到 {len(books)} 本書籍:") + print("=" * 70) + + for idx, book in enumerate(books, 1): + # === 【學員填空練習點 3:提取書籍名稱與連結】 === + # TODO: + # 1. 尋找

底下的 標籤 + # 2. 取得 的 "title" 屬性(因為畫面上長度限制,.get_text() 可能會被 ellipsis 截斷) + # 3. 取得 的 "href" 屬性並使用 urljoin 拼接成完整網址 + title_link = book.find("h3").find("a") + title = title_link.get("title") # 取得屬性 'title' 的值,避免拿到被截斷的文字 + + relative_url = title_link.get("href") + detail_url = urljoin(URL, relative_url) + + # === 【學員填空練習點 4:提取書籍價格】 === + # 提示:為了避免 Windows (CP950) 終端機不支援英鎊符號 '£' 而編碼崩潰,請加上 .replace("£", "GBP") 將其替換 + # 觀察:價格存放在 class 為 "price_color" 的

標籤中,格式如 "£51.77" + # 技巧:將英鎊符號 '£' 替換為 'GBP',避免在 Windows (CP950) 終端機印出時因不支援該字元而崩潰 + price_text = book.find("p", class_="price_color").get_text().replace("£", "GBP") + + # === 【學員填空練習點 5:提取評價星等 (Rating)】 === + # TODO: + # 1. 尋找 class 為 "star-rating" 的

標籤 + # 2. 取得該標籤的 "class" 屬性值(這會是一個串列,如 ['star-rating', 'Three']) + # 3. 提取該串列的第二個元素作為星等字串 (若存在) + + rating_tag = book.find("p", class_="star-rating") + rating_classes = rating_tag.get("class") + rating = "無" + if len(rating_classes) > 1: + rating = rating_classes[1] # 提取例如 'Three' (代表三顆星) + + # === 【學員填空練習點 6:提取庫存狀態】 === + # TODO: 尋找 class 為 "instock availability" 的

標籤,取得文字後去除首尾空白 (.strip()) + stock_text = book.find("p", class_="instock availability").get_text().strip() + + # 印出解析結果 + print(f"【編號 {idx}】") + print(f"[書名] {title}") + print(f"[價格] {price_text}") + print(f"[評價] {rating} 星") + print(f"[狀態] {stock_text}") + print(f"[連結] {detail_url}") + print("-" * 70) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] HTTP 連線異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_ithelp/d4_1_ex3_ithelp.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_ithelp/d4_1_ex3_ithelp.py" new file mode 100644 index 0000000000000000000000000000000000000000..c0299624ce527f07bab03c30f85184640b1ba40c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_ithelp/d4_1_ex3_ithelp.py" @@ -0,0 +1,98 @@ +# d4_1_ex3_ithelp.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex3_ithelp.py +# 參考答案:../d4_1_ex3_ithelp.py + +import requests +from bs4 import BeautifulSoup + +# 目標網站:iT 邦幫忙技術文章首頁 +URL = "https://ithelp.ithome.com.tw/articles?tab=tech" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則可能被伺服器拒絕連線 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至 iT 邦幫忙並抓取技術文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + print("連線成功!解析文章列表中...\n") + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章卡片區塊】 === + # TODO: 尋找所有包裹文章資訊的

標籤(其 class 為 "qa-list") + articles = soup.find_all("div", class_="qa-list") + + print(f"成功取得前 {len(articles)} 篇熱門技術文章:") + print("=" * 80) + + for idx, art in enumerate(articles, 1): + # === 【學員填空練習點 3:提取標題與超連結】 === + # TODO: + # 1. 在 art 中尋找 class 為 "qa-list__title-link" 的 標籤 + # 2. 提取其純文字內容並去除首尾空白 (.strip()),作為標題 + # 3. 提取其 "href" 屬性,作為文章連結 + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + # === 【學員填空練習點 4:提取作者名稱】 === + # TODO: + # 1. 尋找 class 為 "qa-list__info" 的
標籤 + # 2. 在該
下尋找 class 為 "qa-list__info-link" 的 標籤 + # 3. 提取其文字內容作為作者名稱 + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + # === 【學員填空練習點 5:提取發文時間】 === + # TODO: 尋找 class 為 "qa-list__info-time" 的 標籤,並提取其 "title" 屬性值(因 title 屬性中包含完整時間秒數) + time_link = art.find("div", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + # === 【學員填空練習點 6:利用 List 索引提取多項數據】 === + # TODO: + # 1. 使用 find_all 找出卡片中所有 class 為 "qa-condition" 的 標籤 + # 2. 當抓取到的數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + conditions = art.find_all("a", class_="qa-condition") + + likes = "0" + comments = "0" + views = "0" + + if len(conditions) >= 3: + # 請在此處填入利用 conditions[0]、conditions[1]、conditions[2] 尋找 span 的代碼 + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + # 格式化輸出 + print(f"【排名 {idx}】") + print(f"[標題] {title}") + print(f"[作者] {author} | [時間] {post_time}") + print(f"[數據] Like: {likes} | 回應: {comments} | 瀏覽/訂閱: {views}") + print(f"[連結] {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] 伺服器回應異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_\345\220\215\350\250\200\347\266\262\347\253\231/d4_1_web_scraping.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_\345\220\215\350\250\200\347\266\262\347\253\231/d4_1_web_scraping.py" new file mode 100644 index 0000000000000000000000000000000000000000..26792f288eaa50ea59f82a077985c21c5578d14d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\210\254\350\237\262\345\210\206\346\236\220_\345\220\215\350\250\200\347\266\262\347\253\231/d4_1_web_scraping.py" @@ -0,0 +1,68 @@ +# d4_1_web_scraping.py — Day4 學員填空 +# cd examples/Day4/student && python3 d4_1_web_scraping.py +# 參考答案:../d4_1_web_scraping.py + +import requests +from bs4 import BeautifulSoup + +# 本範例抓取專供網頁爬蟲教學使用的模擬沙盒網站:Quotes to Scrape +URL = "http://quotes.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線並抓取網站內容: {URL} ...") + +try: + # === 【學員填空練習點 2:連線請求與下載網頁】 === + # TODO: 1. 使用 requests.get 發送 GET 請求,帶入 headers=HEADERS 與設定 timeout=5 + # TODO: 2. 判斷 response.status_code 是否為 200 (下載成功) + response = requests.get(URL, headers=HEADERS, timeout=5) + + if response and response.status_code == 200: + print("網頁下載成功!開始解析 HTML 結構...") + + # === 【學員填空練習點 3:初始化 BeautifulSoup 解析器】 === + # TODO: 使用 BeautifulSoup 解析 response.text,初始化 html.parser + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 4:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有名言與作者的 div 標籤(其 class 為 'quote') + quote_divs = soup.find_all("div", class_="quote") + + print(f"\n成功解析!本日精選名言如下(共 {len(quote_divs)} 筆):") + print("=" * 60) + + # === 【學員填空練習點 5:迴圈遍歷提取內容與防錯】 === + # TODO: + # 1. 遍歷 quote_divs,並使用 enumerate 取得索引 idx(從 1 開始) + # 2. 用 .find() 尋找 span 且 class 為 'text' 的名言元素 + # 3. 用 .find() 尋找 small 且 class 為 'author' 的作者元素 + # 4. 進行 None 防錯處理,確保兩者皆非空值後,使用 .get_text() 提取純文字並印出 + for idx, quote_div in enumerate(quote_divs, 1): + text_el = quote_div.find("span", class_="text") + author_el = quote_div.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + + print(f"[{idx}] \"{text}\"") + print(f" -- by {author}\n") + + print("=" * 60) + print("提醒:請尊重 robots.txt 與網站使用條款,勿對目標網站發送高頻請求。") + else: + status = response.status_code if response else "未知" + print(f"[錯誤] 無法抓取網頁,伺服器回應狀態碼:{status}") + +except requests.exceptions.RequestException as e: + print(f"[錯誤] 連線失敗:{e}") + print("請確認電腦的網路連線是否通暢。") diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\234\274\347\235\233\350\267\235\351\233\242\345\201\265\346\270\254/step0_eyeguard_template.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\234\274\347\235\233\350\267\235\351\233\242\345\201\265\346\270\254/step0_eyeguard_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..e773ab60196d7f89560f04444a46f214ec945a10 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\234\274\347\235\233\350\267\235\351\233\242\345\201\265\346\270\254/step0_eyeguard_template.py" @@ -0,0 +1,156 @@ +""" +Week 12 - AI 生活工具箱:護眼大師 (Template 填空版) +===================================================== +本週目標: + 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案 + 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近 + 3. 在 Pygame 畫面上顯示即時狀態與警報 + 4. 加入防呆機制(try-except、None 檢查) + +本範例:護眼大師 EyeGuard + - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近) + - 超過安全閾值時發出警報文字 + 閃爍效果 + - 適用場景:打電動、寫作業時防止坐姿太差靠太近 + +注意: + - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。 +""" +import pygame +import cv2 +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision +import math +import sys +import os + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 48) +small_font = pygame.font.SysFont(None, 28) +big_font = pygame.font.SysFont(None, 72) + +# ============================================ +# 2. 攝影機與 AI 模組 +# ============================================ +cap = cv2.VideoCapture(0) +# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_face_video.mp4") + +# 【TODO 1】初始化 MediaPipe 臉部偵測 (Face Landmarker) +base_options = python.BaseOptions(model_asset_path=os.path.join('face_landmarker.task')) +options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +detector = vision.FaceLandmarker.create_from_options(options) + +# 提示:左眼的 Landmark 索引為 33,右眼為 263 + +# ============================================ +# 3. 遊戲變數 +# ============================================ +eye_distance = 0 +DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了! + +# 【TODO 2】警報狀態變數 +is_too_close = False +was_too_close = False # 邊緣觸發 +warning_timer = 0 # 警告閃爍計時器 + +# 統計數據 +total_warnings = 0 +safe_seconds = 0 +frame_counter = 0 + +# ============================================ +# 4. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機 --- + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + frame_counter += 1 + + # 【TODO 3】臉部偵測與兩眼距離計算 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 取得左眼 (33) 和右眼 (263) 座標 + left_eye = face_lms[33] + right_eye = face_lms[263] + + lx, ly = int(left_eye.x * WIDTH), int(left_eye.y * HEIGHT) + rx, ry = int(right_eye.x * WIDTH), int(right_eye.y * HEIGHT) + + # 計算兩眼之間的像素距離 + eye_distance = math.hypot(rx - lx, ry - ly) + + # 【TODO 4】判斷是否太近 + is_too_close = eye_distance > DANGER_THRESHOLD + + # 【TODO 5】邊緣觸發:剛進入危險區才播放警報 + if is_too_close and not was_too_close: + total_warnings += 1 + warning_timer = 60 # 閃爍 60 幀 + print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!") + + was_too_close = is_too_close + + # --- 繪製畫面 --- + #【TODO 6】根據狀態切換背景顏色 + if is_too_close: + if warning_timer > 0 and warning_timer % 4 < 2: + screen.fill((80, 20, 20)) # 閃爍紅色 + else: + screen.fill((50, 15, 15)) # 暗紅色 + warning_timer = max(0, warning_timer - 1) + else: + screen.fill((15, 30, 20)) # 安全綠色 + screen.fill((20, 20, 35)) # 預設背景 + + # 【TODO 7】繪製距離量表 + # 提示:用 pygame.draw.rect 畫一個長條,長度 = eye_distance 的比例 + # 安全時綠色,危險時紅色 + if is_too_close: + pygame.draw.rect(screen, (255, 80, 80), (50, HEIGHT - 50, min(eye_distance * 2, WIDTH - 100), 20)) + else: + pygame.draw.rect(screen, (80, 200, 100), (50, HEIGHT - 50, min(eye_distance * 2, WIDTH - 100), 20)) + # 【TODO 8】如果太近,顯示大字警告 + if is_too_close: + warn_text = big_font.render("⚠️ TOO CLOSE!", True, (255, 80, 80)) + screen.blit(warn_text, (WIDTH//2 - warn_text.get_width()//2, HEIGHT//2 - 40)) + else: + safe_text = font.render("✅ Safe Distance", True, (80, 200, 100)) + screen.blit(safe_text, (WIDTH//2 - safe_text.get_width()//2, HEIGHT//2 - 20)) + + # 顯示數值 + info = small_font.render(f"Eye Distance: {eye_distance:.0f} px | Threshold: {DANGER_THRESHOLD}", True, (150, 150, 180)) + screen.blit(info, (10, 10)) + + stats = small_font.render(f"Warnings: {total_warnings}", True, (150, 150, 180)) + screen.blit(stats, (10, 40)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\271\252\350\243\275\350\207\211\351\203\2503D\346\250\241\345\236\213/face_mesh_connections.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\271\252\350\243\275\350\207\211\351\203\2503D\346\250\241\345\236\213/face_mesh_connections.py" new file mode 100644 index 0000000000000000000000000000000000000000..d44fb79be4bbfa929f876c2b0a22dc3e659492ae --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\271\252\350\243\275\350\207\211\351\203\2503D\346\250\241\345\236\213/face_mesh_connections.py" @@ -0,0 +1,500 @@ +# Copyright 2021 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe FaceMesh connections.""" + +FACEMESH_LIPS = frozenset([(61, 146), (146, 91), (91, 181), (181, 84), (84, 17), + (17, 314), (314, 405), (405, 321), (321, 375), + (375, 291), (61, 185), (185, 40), (40, 39), (39, 37), + (37, 0), (0, 267), + (267, 269), (269, 270), (270, 409), (409, 291), + (78, 95), (95, 88), (88, 178), (178, 87), (87, 14), + (14, 317), (317, 402), (402, 318), (318, 324), + (324, 308), (78, 191), (191, 80), (80, 81), (81, 82), + (82, 13), (13, 312), (312, 311), (311, 310), + (310, 415), (415, 308)]) + +FACEMESH_LEFT_EYE = frozenset([(263, 249), (249, 390), (390, 373), (373, 374), + (374, 380), (380, 381), (381, 382), (382, 362), + (263, 466), (466, 388), (388, 387), (387, 386), + (386, 385), (385, 384), (384, 398), (398, 362)]) + +FACEMESH_LEFT_IRIS = frozenset([(474, 475), (475, 476), (476, 477), + (477, 474)]) + +FACEMESH_LEFT_EYEBROW = frozenset([(276, 283), (283, 282), (282, 295), + (295, 285), (300, 293), (293, 334), + (334, 296), (296, 336)]) + +FACEMESH_RIGHT_EYE = frozenset([(33, 7), (7, 163), (163, 144), (144, 145), + (145, 153), (153, 154), (154, 155), (155, 133), + (33, 246), (246, 161), (161, 160), (160, 159), + (159, 158), (158, 157), (157, 173), (173, 133)]) + +FACEMESH_RIGHT_EYEBROW = frozenset([(46, 53), (53, 52), (52, 65), (65, 55), + (70, 63), (63, 105), (105, 66), (66, 107)]) + +FACEMESH_RIGHT_IRIS = frozenset([(469, 470), (470, 471), (471, 472), + (472, 469)]) + +FACEMESH_FACE_OVAL = frozenset([(10, 338), (338, 297), (297, 332), (332, 284), + (284, 251), (251, 389), (389, 356), (356, 454), + (454, 323), (323, 361), (361, 288), (288, 397), + (397, 365), (365, 379), (379, 378), (378, 400), + (400, 377), (377, 152), (152, 148), (148, 176), + (176, 149), (149, 150), (150, 136), (136, 172), + (172, 58), (58, 132), (132, 93), (93, 234), + (234, 127), (127, 162), (162, 21), (21, 54), + (54, 103), (103, 67), (67, 109), (109, 10)]) + +FACEMESH_NOSE = frozenset([(168, 6), (6, 197), (197, 195), (195, 5), + (5, 4), (4, 1), (1, 19), (19, 94), (94, 2), (98, 97), + (97, 2), (2, 326), (326, 327), (327, 294), + (294, 278), (278, 344), (344, 440), (440, 275), + (275, 4), (4, 45), (45, 220), (220, 115), (115, 48), + (48, 64), (64, 98)]) + +FACEMESH_CONTOURS = frozenset().union(*[ + FACEMESH_LIPS, FACEMESH_LEFT_EYE, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYE, + FACEMESH_RIGHT_EYEBROW, FACEMESH_FACE_OVAL +]) + +FACEMESH_IRISES = frozenset().union(*[FACEMESH_LEFT_IRIS, FACEMESH_RIGHT_IRIS]) + +FACEMESH_TESSELATION = frozenset([ + (127, 34), (34, 139), (139, 127), (11, 0), (0, 37), (37, 11), + (232, 231), (231, 120), (120, 232), (72, 37), (37, 39), (39, 72), + (128, 121), (121, 47), (47, 128), (232, 121), (121, 128), (128, 232), + (104, 69), (69, 67), (67, 104), (175, 171), (171, 148), (148, 175), + (118, 50), (50, 101), (101, 118), (73, 39), (39, 40), (40, 73), + (9, 151), (151, 108), (108, 9), (48, 115), (115, 131), (131, 48), + (194, 204), (204, 211), (211, 194), (74, 40), (40, 185), (185, 74), + (80, 42), (42, 183), (183, 80), (40, 92), (92, 186), (186, 40), + (230, 229), (229, 118), (118, 230), (202, 212), (212, 214), (214, 202), + (83, 18), (18, 17), (17, 83), (76, 61), (61, 146), (146, 76), + (160, 29), (29, 30), (30, 160), (56, 157), (157, 173), (173, 56), + (106, 204), (204, 194), (194, 106), (135, 214), (214, 192), (192, 135), + (203, 165), (165, 98), (98, 203), (21, 71), (71, 68), (68, 21), + (51, 45), (45, 4), (4, 51), (144, 24), (24, 23), (23, 144), + (77, 146), (146, 91), (91, 77), (205, 50), (50, 187), (187, 205), + (201, 200), (200, 18), (18, 201), (91, 106), (106, 182), (182, 91), + (90, 91), (91, 181), (181, 90), (85, 84), (84, 17), (17, 85), + (206, 203), (203, 36), (36, 206), (148, 171), (171, 140), (140, 148), + (92, 40), (40, 39), (39, 92), (193, 189), (189, 244), (244, 193), + (159, 158), (158, 28), (28, 159), (247, 246), (246, 161), (161, 247), + (236, 3), (3, 196), (196, 236), (54, 68), (68, 104), (104, 54), + (193, 168), (168, 8), (8, 193), (117, 228), (228, 31), (31, 117), + (189, 193), (193, 55), (55, 189), (98, 97), (97, 99), (99, 98), + (126, 47), (47, 100), (100, 126), (166, 79), (79, 218), (218, 166), + (155, 154), (154, 26), (26, 155), (209, 49), (49, 131), (131, 209), + (135, 136), (136, 150), (150, 135), (47, 126), (126, 217), (217, 47), + (223, 52), (52, 53), (53, 223), (45, 51), (51, 134), (134, 45), + (211, 170), (170, 140), (140, 211), (67, 69), (69, 108), (108, 67), + (43, 106), (106, 91), (91, 43), (230, 119), (119, 120), (120, 230), + (226, 130), (130, 247), (247, 226), (63, 53), (53, 52), (52, 63), + (238, 20), (20, 242), (242, 238), (46, 70), (70, 156), (156, 46), + (78, 62), (62, 96), (96, 78), (46, 53), (53, 63), (63, 46), + (143, 34), (34, 227), (227, 143), (123, 117), (117, 111), (111, 123), + (44, 125), (125, 19), (19, 44), (236, 134), (134, 51), (51, 236), + (216, 206), (206, 205), (205, 216), (154, 153), (153, 22), (22, 154), + (39, 37), (37, 167), (167, 39), (200, 201), (201, 208), (208, 200), + (36, 142), (142, 100), (100, 36), (57, 212), (212, 202), (202, 57), + (20, 60), (60, 99), (99, 20), (28, 158), (158, 157), (157, 28), + (35, 226), (226, 113), (113, 35), (160, 159), (159, 27), (27, 160), + (204, 202), (202, 210), (210, 204), (113, 225), (225, 46), (46, 113), + (43, 202), (202, 204), (204, 43), (62, 76), (76, 77), (77, 62), + (137, 123), (123, 116), (116, 137), (41, 38), (38, 72), (72, 41), + (203, 129), (129, 142), (142, 203), (64, 98), (98, 240), (240, 64), + (49, 102), (102, 64), (64, 49), (41, 73), (73, 74), (74, 41), + (212, 216), (216, 207), (207, 212), (42, 74), (74, 184), (184, 42), + (169, 170), (170, 211), (211, 169), (170, 149), (149, 176), (176, 170), + (105, 66), (66, 69), (69, 105), (122, 6), (6, 168), (168, 122), + (123, 147), (147, 187), (187, 123), (96, 77), (77, 90), (90, 96), + (65, 55), (55, 107), (107, 65), (89, 90), (90, 180), (180, 89), + (101, 100), (100, 120), (120, 101), (63, 105), (105, 104), (104, 63), + (93, 137), (137, 227), (227, 93), (15, 86), (86, 85), (85, 15), + (129, 102), (102, 49), (49, 129), (14, 87), (87, 86), (86, 14), + (55, 8), (8, 9), (9, 55), (100, 47), (47, 121), (121, 100), + (145, 23), (23, 22), (22, 145), (88, 89), (89, 179), (179, 88), + (6, 122), (122, 196), (196, 6), (88, 95), (95, 96), (96, 88), + (138, 172), (172, 136), (136, 138), (215, 58), (58, 172), (172, 215), + (115, 48), (48, 219), (219, 115), (42, 80), (80, 81), (81, 42), + (195, 3), (3, 51), (51, 195), (43, 146), (146, 61), (61, 43), + (171, 175), (175, 199), (199, 171), (81, 82), (82, 38), (38, 81), + (53, 46), (46, 225), (225, 53), (144, 163), (163, 110), (110, 144), + (52, 65), (65, 66), (66, 52), (229, 228), (228, 117), (117, 229), + (34, 127), (127, 234), (234, 34), (107, 108), (108, 69), (69, 107), + (109, 108), (108, 151), (151, 109), (48, 64), (64, 235), (235, 48), + (62, 78), (78, 191), (191, 62), (129, 209), (209, 126), (126, 129), + (111, 35), (35, 143), (143, 111), (117, 123), (123, 50), (50, 117), + (222, 65), (65, 52), (52, 222), (19, 125), (125, 141), (141, 19), + (221, 55), (55, 65), (65, 221), (3, 195), (195, 197), (197, 3), + (25, 7), (7, 33), (33, 25), (220, 237), (237, 44), (44, 220), + (70, 71), (71, 139), (139, 70), (122, 193), (193, 245), (245, 122), + (247, 130), (130, 33), (33, 247), (71, 21), (21, 162), (162, 71), + (170, 169), (169, 150), (150, 170), (188, 174), (174, 196), (196, 188), + (216, 186), (186, 92), (92, 216), (2, 97), (97, 167), (167, 2), + (141, 125), (125, 241), (241, 141), (164, 167), (167, 37), (37, 164), + (72, 38), (38, 12), (12, 72), (38, 82), (82, 13), (13, 38), + (63, 68), (68, 71), (71, 63), (226, 35), (35, 111), (111, 226), + (101, 50), (50, 205), (205, 101), (206, 92), (92, 165), (165, 206), + (209, 198), (198, 217), (217, 209), (165, 167), (167, 97), (97, 165), + (220, 115), (115, 218), (218, 220), (133, 112), (112, 243), (243, 133), + (239, 238), (238, 241), (241, 239), (214, 135), (135, 169), (169, 214), + (190, 173), (173, 133), (133, 190), (171, 208), (208, 32), (32, 171), + (125, 44), (44, 237), (237, 125), (86, 87), (87, 178), (178, 86), + (85, 86), (86, 179), (179, 85), (84, 85), (85, 180), (180, 84), + (83, 84), (84, 181), (181, 83), (201, 83), (83, 182), (182, 201), + (137, 93), (93, 132), (132, 137), (76, 62), (62, 183), (183, 76), + (61, 76), (76, 184), (184, 61), (57, 61), (61, 185), (185, 57), + (212, 57), (57, 186), (186, 212), (214, 207), (207, 187), (187, 214), + (34, 143), (143, 156), (156, 34), (79, 239), (239, 237), (237, 79), + (123, 137), (137, 177), (177, 123), (44, 1), (1, 4), (4, 44), + (201, 194), (194, 32), (32, 201), (64, 102), (102, 129), (129, 64), + (213, 215), (215, 138), (138, 213), (59, 166), (166, 219), (219, 59), + (242, 99), (99, 97), (97, 242), (2, 94), (94, 141), (141, 2), + (75, 59), (59, 235), (235, 75), (24, 110), (110, 228), (228, 24), + (25, 130), (130, 226), (226, 25), (23, 24), (24, 229), (229, 23), + (22, 23), (23, 230), (230, 22), (26, 22), (22, 231), (231, 26), + (112, 26), (26, 232), (232, 112), (189, 190), (190, 243), (243, 189), + (221, 56), (56, 190), (190, 221), (28, 56), (56, 221), (221, 28), + (27, 28), (28, 222), (222, 27), (29, 27), (27, 223), (223, 29), + (30, 29), (29, 224), (224, 30), (247, 30), (30, 225), (225, 247), + (238, 79), (79, 20), (20, 238), (166, 59), (59, 75), (75, 166), + (60, 75), (75, 240), (240, 60), (147, 177), (177, 215), (215, 147), + (20, 79), (79, 166), (166, 20), (187, 147), (147, 213), (213, 187), + (112, 233), (233, 244), (244, 112), (233, 128), (128, 245), (245, 233), + (128, 114), (114, 188), (188, 128), (114, 217), (217, 174), (174, 114), + (131, 115), (115, 220), (220, 131), (217, 198), (198, 236), (236, 217), + (198, 131), (131, 134), (134, 198), (177, 132), (132, 58), (58, 177), + (143, 35), (35, 124), (124, 143), (110, 163), (163, 7), (7, 110), + (228, 110), (110, 25), (25, 228), (356, 389), (389, 368), (368, 356), + (11, 302), (302, 267), (267, 11), (452, 350), (350, 349), (349, 452), + (302, 303), (303, 269), (269, 302), (357, 343), (343, 277), (277, 357), + (452, 453), (453, 357), (357, 452), (333, 332), (332, 297), (297, 333), + (175, 152), (152, 377), (377, 175), (347, 348), (348, 330), (330, 347), + (303, 304), (304, 270), (270, 303), (9, 336), (336, 337), (337, 9), + (278, 279), (279, 360), (360, 278), (418, 262), (262, 431), (431, 418), + (304, 408), (408, 409), (409, 304), (310, 415), (415, 407), (407, 310), + (270, 409), (409, 410), (410, 270), (450, 348), (348, 347), (347, 450), + (422, 430), (430, 434), (434, 422), (313, 314), (314, 17), (17, 313), + (306, 307), (307, 375), (375, 306), (387, 388), (388, 260), (260, 387), + (286, 414), (414, 398), (398, 286), (335, 406), (406, 418), (418, 335), + (364, 367), (367, 416), (416, 364), (423, 358), (358, 327), (327, 423), + (251, 284), (284, 298), (298, 251), (281, 5), (5, 4), (4, 281), + (373, 374), (374, 253), (253, 373), (307, 320), (320, 321), (321, 307), + (425, 427), (427, 411), (411, 425), (421, 313), (313, 18), (18, 421), + (321, 405), (405, 406), (406, 321), (320, 404), (404, 405), (405, 320), + (315, 16), (16, 17), (17, 315), (426, 425), (425, 266), (266, 426), + (377, 400), (400, 369), (369, 377), (322, 391), (391, 269), (269, 322), + (417, 465), (465, 464), (464, 417), (386, 257), (257, 258), (258, 386), + (466, 260), (260, 388), (388, 466), (456, 399), (399, 419), (419, 456), + (284, 332), (332, 333), (333, 284), (417, 285), (285, 8), (8, 417), + (346, 340), (340, 261), (261, 346), (413, 441), (441, 285), (285, 413), + (327, 460), (460, 328), (328, 327), (355, 371), (371, 329), (329, 355), + (392, 439), (439, 438), (438, 392), (382, 341), (341, 256), (256, 382), + (429, 420), (420, 360), (360, 429), (364, 394), (394, 379), (379, 364), + (277, 343), (343, 437), (437, 277), (443, 444), (444, 283), (283, 443), + (275, 440), (440, 363), (363, 275), (431, 262), (262, 369), (369, 431), + (297, 338), (338, 337), (337, 297), (273, 375), (375, 321), (321, 273), + (450, 451), (451, 349), (349, 450), (446, 342), (342, 467), (467, 446), + (293, 334), (334, 282), (282, 293), (458, 461), (461, 462), (462, 458), + (276, 353), (353, 383), (383, 276), (308, 324), (324, 325), (325, 308), + (276, 300), (300, 293), (293, 276), (372, 345), (345, 447), (447, 372), + (352, 345), (345, 340), (340, 352), (274, 1), (1, 19), (19, 274), + (456, 248), (248, 281), (281, 456), (436, 427), (427, 425), (425, 436), + (381, 256), (256, 252), (252, 381), (269, 391), (391, 393), (393, 269), + (200, 199), (199, 428), (428, 200), (266, 330), (330, 329), (329, 266), + (287, 273), (273, 422), (422, 287), (250, 462), (462, 328), (328, 250), + (258, 286), (286, 384), (384, 258), (265, 353), (353, 342), (342, 265), + (387, 259), (259, 257), (257, 387), (424, 431), (431, 430), (430, 424), + (342, 353), (353, 276), (276, 342), (273, 335), (335, 424), (424, 273), + (292, 325), (325, 307), (307, 292), (366, 447), (447, 345), (345, 366), + (271, 303), (303, 302), (302, 271), (423, 266), (266, 371), (371, 423), + (294, 455), (455, 460), (460, 294), (279, 278), (278, 294), (294, 279), + (271, 272), (272, 304), (304, 271), (432, 434), (434, 427), (427, 432), + (272, 407), (407, 408), (408, 272), (394, 430), (430, 431), (431, 394), + (395, 369), (369, 400), (400, 395), (334, 333), (333, 299), (299, 334), + (351, 417), (417, 168), (168, 351), (352, 280), (280, 411), (411, 352), + (325, 319), (319, 320), (320, 325), (295, 296), (296, 336), (336, 295), + (319, 403), (403, 404), (404, 319), (330, 348), (348, 349), (349, 330), + (293, 298), (298, 333), (333, 293), (323, 454), (454, 447), (447, 323), + (15, 16), (16, 315), (315, 15), (358, 429), (429, 279), (279, 358), + (14, 15), (15, 316), (316, 14), (285, 336), (336, 9), (9, 285), + (329, 349), (349, 350), (350, 329), (374, 380), (380, 252), (252, 374), + (318, 402), (402, 403), (403, 318), (6, 197), (197, 419), (419, 6), + (318, 319), (319, 325), (325, 318), (367, 364), (364, 365), (365, 367), + (435, 367), (367, 397), (397, 435), (344, 438), (438, 439), (439, 344), + (272, 271), (271, 311), (311, 272), (195, 5), (5, 281), (281, 195), + (273, 287), (287, 291), (291, 273), (396, 428), (428, 199), (199, 396), + (311, 271), (271, 268), (268, 311), (283, 444), (444, 445), (445, 283), + (373, 254), (254, 339), (339, 373), (282, 334), (334, 296), (296, 282), + (449, 347), (347, 346), (346, 449), (264, 447), (447, 454), (454, 264), + (336, 296), (296, 299), (299, 336), (338, 10), (10, 151), (151, 338), + (278, 439), (439, 455), (455, 278), (292, 407), (407, 415), (415, 292), + (358, 371), (371, 355), (355, 358), (340, 345), (345, 372), (372, 340), + (346, 347), (347, 280), (280, 346), (442, 443), (443, 282), (282, 442), + (19, 94), (94, 370), (370, 19), (441, 442), (442, 295), (295, 441), + (248, 419), (419, 197), (197, 248), (263, 255), (255, 359), (359, 263), + (440, 275), (275, 274), (274, 440), (300, 383), (383, 368), (368, 300), + (351, 412), (412, 465), (465, 351), (263, 467), (467, 466), (466, 263), + (301, 368), (368, 389), (389, 301), (395, 378), (378, 379), (379, 395), + (412, 351), (351, 419), (419, 412), (436, 426), (426, 322), (322, 436), + (2, 164), (164, 393), (393, 2), (370, 462), (462, 461), (461, 370), + (164, 0), (0, 267), (267, 164), (302, 11), (11, 12), (12, 302), + (268, 12), (12, 13), (13, 268), (293, 300), (300, 301), (301, 293), + (446, 261), (261, 340), (340, 446), (330, 266), (266, 425), (425, 330), + (426, 423), (423, 391), (391, 426), (429, 355), (355, 437), (437, 429), + (391, 327), (327, 326), (326, 391), (440, 457), (457, 438), (438, 440), + (341, 382), (382, 362), (362, 341), (459, 457), (457, 461), (461, 459), + (434, 430), (430, 394), (394, 434), (414, 463), (463, 362), (362, 414), + (396, 369), (369, 262), (262, 396), (354, 461), (461, 457), (457, 354), + (316, 403), (403, 402), (402, 316), (315, 404), (404, 403), (403, 315), + (314, 405), (405, 404), (404, 314), (313, 406), (406, 405), (405, 313), + (421, 418), (418, 406), (406, 421), (366, 401), (401, 361), (361, 366), + (306, 408), (408, 407), (407, 306), (291, 409), (409, 408), (408, 291), + (287, 410), (410, 409), (409, 287), (432, 436), (436, 410), (410, 432), + (434, 416), (416, 411), (411, 434), (264, 368), (368, 383), (383, 264), + (309, 438), (438, 457), (457, 309), (352, 376), (376, 401), (401, 352), + (274, 275), (275, 4), (4, 274), (421, 428), (428, 262), (262, 421), + (294, 327), (327, 358), (358, 294), (433, 416), (416, 367), (367, 433), + (289, 455), (455, 439), (439, 289), (462, 370), (370, 326), (326, 462), + (2, 326), (326, 370), (370, 2), (305, 460), (460, 455), (455, 305), + (254, 449), (449, 448), (448, 254), (255, 261), (261, 446), (446, 255), + (253, 450), (450, 449), (449, 253), (252, 451), (451, 450), (450, 252), + (256, 452), (452, 451), (451, 256), (341, 453), (453, 452), (452, 341), + (413, 464), (464, 463), (463, 413), (441, 413), (413, 414), (414, 441), + (258, 442), (442, 441), (441, 258), (257, 443), (443, 442), (442, 257), + (259, 444), (444, 443), (443, 259), (260, 445), (445, 444), (444, 260), + (467, 342), (342, 445), (445, 467), (459, 458), (458, 250), (250, 459), + (289, 392), (392, 290), (290, 289), (290, 328), (328, 460), (460, 290), + (376, 433), (433, 435), (435, 376), (250, 290), (290, 392), (392, 250), + (411, 416), (416, 433), (433, 411), (341, 463), (463, 464), (464, 341), + (453, 464), (464, 465), (465, 453), (357, 465), (465, 412), (412, 357), + (343, 412), (412, 399), (399, 343), (360, 363), (363, 440), (440, 360), + (437, 399), (399, 456), (456, 437), (420, 456), (456, 363), (363, 420), + (401, 435), (435, 288), (288, 401), (372, 383), (383, 353), (353, 372), + (339, 255), (255, 249), (249, 339), (448, 261), (261, 255), (255, 448), + (133, 243), (243, 190), (190, 133), (133, 155), (155, 112), (112, 133), + (33, 246), (246, 247), (247, 33), (33, 130), (130, 25), (25, 33), + (398, 384), (384, 286), (286, 398), (362, 398), (398, 414), (414, 362), + (362, 463), (463, 341), (341, 362), (263, 359), (359, 467), (467, 263), + (263, 249), (249, 255), (255, 263), (466, 467), (467, 260), (260, 466), + (75, 60), (60, 166), (166, 75), (238, 239), (239, 79), (79, 238), + (162, 127), (127, 139), (139, 162), (72, 11), (11, 37), (37, 72), + (121, 232), (232, 120), (120, 121), (73, 72), (72, 39), (39, 73), + (114, 128), (128, 47), (47, 114), (233, 232), (232, 128), (128, 233), + (103, 104), (104, 67), (67, 103), (152, 175), (175, 148), (148, 152), + (119, 118), (118, 101), (101, 119), (74, 73), (73, 40), (40, 74), + (107, 9), (9, 108), (108, 107), (49, 48), (48, 131), (131, 49), + (32, 194), (194, 211), (211, 32), (184, 74), (74, 185), (185, 184), + (191, 80), (80, 183), (183, 191), (185, 40), (40, 186), (186, 185), + (119, 230), (230, 118), (118, 119), (210, 202), (202, 214), (214, 210), + (84, 83), (83, 17), (17, 84), (77, 76), (76, 146), (146, 77), + (161, 160), (160, 30), (30, 161), (190, 56), (56, 173), (173, 190), + (182, 106), (106, 194), (194, 182), (138, 135), (135, 192), (192, 138), + (129, 203), (203, 98), (98, 129), (54, 21), (21, 68), (68, 54), + (5, 51), (51, 4), (4, 5), (145, 144), (144, 23), (23, 145), + (90, 77), (77, 91), (91, 90), (207, 205), (205, 187), (187, 207), + (83, 201), (201, 18), (18, 83), (181, 91), (91, 182), (182, 181), + (180, 90), (90, 181), (181, 180), (16, 85), (85, 17), (17, 16), + (205, 206), (206, 36), (36, 205), (176, 148), (148, 140), (140, 176), + (165, 92), (92, 39), (39, 165), (245, 193), (193, 244), (244, 245), + (27, 159), (159, 28), (28, 27), (30, 247), (247, 161), (161, 30), + (174, 236), (236, 196), (196, 174), (103, 54), (54, 104), (104, 103), + (55, 193), (193, 8), (8, 55), (111, 117), (117, 31), (31, 111), + (221, 189), (189, 55), (55, 221), (240, 98), (98, 99), (99, 240), + (142, 126), (126, 100), (100, 142), (219, 166), (166, 218), (218, 219), + (112, 155), (155, 26), (26, 112), (198, 209), (209, 131), (131, 198), + (169, 135), (135, 150), (150, 169), (114, 47), (47, 217), (217, 114), + (224, 223), (223, 53), (53, 224), (220, 45), (45, 134), (134, 220), + (32, 211), (211, 140), (140, 32), (109, 67), (67, 108), (108, 109), + (146, 43), (43, 91), (91, 146), (231, 230), (230, 120), (120, 231), + (113, 226), (226, 247), (247, 113), (105, 63), (63, 52), (52, 105), + (241, 238), (238, 242), (242, 241), (124, 46), (46, 156), (156, 124), + (95, 78), (78, 96), (96, 95), (70, 46), (46, 63), (63, 70), + (116, 143), (143, 227), (227, 116), (116, 123), (123, 111), (111, 116), + (1, 44), (44, 19), (19, 1), (3, 236), (236, 51), (51, 3), + (207, 216), (216, 205), (205, 207), (26, 154), (154, 22), (22, 26), + (165, 39), (39, 167), (167, 165), (199, 200), (200, 208), (208, 199), + (101, 36), (36, 100), (100, 101), (43, 57), (57, 202), (202, 43), + (242, 20), (20, 99), (99, 242), (56, 28), (28, 157), (157, 56), + (124, 35), (35, 113), (113, 124), (29, 160), (160, 27), (27, 29), + (211, 204), (204, 210), (210, 211), (124, 113), (113, 46), (46, 124), + (106, 43), (43, 204), (204, 106), (96, 62), (62, 77), (77, 96), + (227, 137), (137, 116), (116, 227), (73, 41), (41, 72), (72, 73), + (36, 203), (203, 142), (142, 36), (235, 64), (64, 240), (240, 235), + (48, 49), (49, 64), (64, 48), (42, 41), (41, 74), (74, 42), + (214, 212), (212, 207), (207, 214), (183, 42), (42, 184), (184, 183), + (210, 169), (169, 211), (211, 210), (140, 170), (170, 176), (176, 140), + (104, 105), (105, 69), (69, 104), (193, 122), (122, 168), (168, 193), + (50, 123), (123, 187), (187, 50), (89, 96), (96, 90), (90, 89), + (66, 65), (65, 107), (107, 66), (179, 89), (89, 180), (180, 179), + (119, 101), (101, 120), (120, 119), (68, 63), (63, 104), (104, 68), + (234, 93), (93, 227), (227, 234), (16, 15), (15, 85), (85, 16), + (209, 129), (129, 49), (49, 209), (15, 14), (14, 86), (86, 15), + (107, 55), (55, 9), (9, 107), (120, 100), (100, 121), (121, 120), + (153, 145), (145, 22), (22, 153), (178, 88), (88, 179), (179, 178), + (197, 6), (6, 196), (196, 197), (89, 88), (88, 96), (96, 89), + (135, 138), (138, 136), (136, 135), (138, 215), (215, 172), (172, 138), + (218, 115), (115, 219), (219, 218), (41, 42), (42, 81), (81, 41), + (5, 195), (195, 51), (51, 5), (57, 43), (43, 61), (61, 57), + (208, 171), (171, 199), (199, 208), (41, 81), (81, 38), (38, 41), + (224, 53), (53, 225), (225, 224), (24, 144), (144, 110), (110, 24), + (105, 52), (52, 66), (66, 105), (118, 229), (229, 117), (117, 118), + (227, 34), (34, 234), (234, 227), (66, 107), (107, 69), (69, 66), + (10, 109), (109, 151), (151, 10), (219, 48), (48, 235), (235, 219), + (183, 62), (62, 191), (191, 183), (142, 129), (129, 126), (126, 142), + (116, 111), (111, 143), (143, 116), (118, 117), (117, 50), (50, 118), + (223, 222), (222, 52), (52, 223), (94, 19), (19, 141), (141, 94), + (222, 221), (221, 65), (65, 222), (196, 3), (3, 197), (197, 196), + (45, 220), (220, 44), (44, 45), (156, 70), (70, 139), (139, 156), + (188, 122), (122, 245), (245, 188), (139, 71), (71, 162), (162, 139), + (149, 170), (170, 150), (150, 149), (122, 188), (188, 196), (196, 122), + (206, 216), (216, 92), (92, 206), (164, 2), (2, 167), (167, 164), + (242, 141), (141, 241), (241, 242), (0, 164), (164, 37), (37, 0), + (11, 72), (72, 12), (12, 11), (12, 38), (38, 13), (13, 12), + (70, 63), (63, 71), (71, 70), (31, 226), (226, 111), (111, 31), + (36, 101), (101, 205), (205, 36), (203, 206), (206, 165), (165, 203), + (126, 209), (209, 217), (217, 126), (98, 165), (165, 97), (97, 98), + (237, 220), (220, 218), (218, 237), (237, 239), (239, 241), (241, 237), + (210, 214), (214, 169), (169, 210), (140, 171), (171, 32), (32, 140), + (241, 125), (125, 237), (237, 241), (179, 86), (86, 178), (178, 179), + (180, 85), (85, 179), (179, 180), (181, 84), (84, 180), (180, 181), + (182, 83), (83, 181), (181, 182), (194, 201), (201, 182), (182, 194), + (177, 137), (137, 132), (132, 177), (184, 76), (76, 183), (183, 184), + (185, 61), (61, 184), (184, 185), (186, 57), (57, 185), (185, 186), + (216, 212), (212, 186), (186, 216), (192, 214), (214, 187), (187, 192), + (139, 34), (34, 156), (156, 139), (218, 79), (79, 237), (237, 218), + (147, 123), (123, 177), (177, 147), (45, 44), (44, 4), (4, 45), + (208, 201), (201, 32), (32, 208), (98, 64), (64, 129), (129, 98), + (192, 213), (213, 138), (138, 192), (235, 59), (59, 219), (219, 235), + (141, 242), (242, 97), (97, 141), (97, 2), (2, 141), (141, 97), + (240, 75), (75, 235), (235, 240), (229, 24), (24, 228), (228, 229), + (31, 25), (25, 226), (226, 31), (230, 23), (23, 229), (229, 230), + (231, 22), (22, 230), (230, 231), (232, 26), (26, 231), (231, 232), + (233, 112), (112, 232), (232, 233), (244, 189), (189, 243), (243, 244), + (189, 221), (221, 190), (190, 189), (222, 28), (28, 221), (221, 222), + (223, 27), (27, 222), (222, 223), (224, 29), (29, 223), (223, 224), + (225, 30), (30, 224), (224, 225), (113, 247), (247, 225), (225, 113), + (99, 60), (60, 240), (240, 99), (213, 147), (147, 215), (215, 213), + (60, 20), (20, 166), (166, 60), (192, 187), (187, 213), (213, 192), + (243, 112), (112, 244), (244, 243), (244, 233), (233, 245), (245, 244), + (245, 128), (128, 188), (188, 245), (188, 114), (114, 174), (174, 188), + (134, 131), (131, 220), (220, 134), (174, 217), (217, 236), (236, 174), + (236, 198), (198, 134), (134, 236), (215, 177), (177, 58), (58, 215), + (156, 143), (143, 124), (124, 156), (25, 110), (110, 7), (7, 25), + (31, 228), (228, 25), (25, 31), (264, 356), (356, 368), (368, 264), + (0, 11), (11, 267), (267, 0), (451, 452), (452, 349), (349, 451), + (267, 302), (302, 269), (269, 267), (350, 357), (357, 277), (277, 350), + (350, 452), (452, 357), (357, 350), (299, 333), (333, 297), (297, 299), + (396, 175), (175, 377), (377, 396), (280, 347), (347, 330), (330, 280), + (269, 303), (303, 270), (270, 269), (151, 9), (9, 337), (337, 151), + (344, 278), (278, 360), (360, 344), (424, 418), (418, 431), (431, 424), + (270, 304), (304, 409), (409, 270), (272, 310), (310, 407), (407, 272), + (322, 270), (270, 410), (410, 322), (449, 450), (450, 347), (347, 449), + (432, 422), (422, 434), (434, 432), (18, 313), (313, 17), (17, 18), + (291, 306), (306, 375), (375, 291), (259, 387), (387, 260), (260, 259), + (424, 335), (335, 418), (418, 424), (434, 364), (364, 416), (416, 434), + (391, 423), (423, 327), (327, 391), (301, 251), (251, 298), (298, 301), + (275, 281), (281, 4), (4, 275), (254, 373), (373, 253), (253, 254), + (375, 307), (307, 321), (321, 375), (280, 425), (425, 411), (411, 280), + (200, 421), (421, 18), (18, 200), (335, 321), (321, 406), (406, 335), + (321, 320), (320, 405), (405, 321), (314, 315), (315, 17), (17, 314), + (423, 426), (426, 266), (266, 423), (396, 377), (377, 369), (369, 396), + (270, 322), (322, 269), (269, 270), (413, 417), (417, 464), (464, 413), + (385, 386), (386, 258), (258, 385), (248, 456), (456, 419), (419, 248), + (298, 284), (284, 333), (333, 298), (168, 417), (417, 8), (8, 168), + (448, 346), (346, 261), (261, 448), (417, 413), (413, 285), (285, 417), + (326, 327), (327, 328), (328, 326), (277, 355), (355, 329), (329, 277), + (309, 392), (392, 438), (438, 309), (381, 382), (382, 256), (256, 381), + (279, 429), (429, 360), (360, 279), (365, 364), (364, 379), (379, 365), + (355, 277), (277, 437), (437, 355), (282, 443), (443, 283), (283, 282), + (281, 275), (275, 363), (363, 281), (395, 431), (431, 369), (369, 395), + (299, 297), (297, 337), (337, 299), (335, 273), (273, 321), (321, 335), + (348, 450), (450, 349), (349, 348), (359, 446), (446, 467), (467, 359), + (283, 293), (293, 282), (282, 283), (250, 458), (458, 462), (462, 250), + (300, 276), (276, 383), (383, 300), (292, 308), (308, 325), (325, 292), + (283, 276), (276, 293), (293, 283), (264, 372), (372, 447), (447, 264), + (346, 352), (352, 340), (340, 346), (354, 274), (274, 19), (19, 354), + (363, 456), (456, 281), (281, 363), (426, 436), (436, 425), (425, 426), + (380, 381), (381, 252), (252, 380), (267, 269), (269, 393), (393, 267), + (421, 200), (200, 428), (428, 421), (371, 266), (266, 329), (329, 371), + (432, 287), (287, 422), (422, 432), (290, 250), (250, 328), (328, 290), + (385, 258), (258, 384), (384, 385), (446, 265), (265, 342), (342, 446), + (386, 387), (387, 257), (257, 386), (422, 424), (424, 430), (430, 422), + (445, 342), (342, 276), (276, 445), (422, 273), (273, 424), (424, 422), + (306, 292), (292, 307), (307, 306), (352, 366), (366, 345), (345, 352), + (268, 271), (271, 302), (302, 268), (358, 423), (423, 371), (371, 358), + (327, 294), (294, 460), (460, 327), (331, 279), (279, 294), (294, 331), + (303, 271), (271, 304), (304, 303), (436, 432), (432, 427), (427, 436), + (304, 272), (272, 408), (408, 304), (395, 394), (394, 431), (431, 395), + (378, 395), (395, 400), (400, 378), (296, 334), (334, 299), (299, 296), + (6, 351), (351, 168), (168, 6), (376, 352), (352, 411), (411, 376), + (307, 325), (325, 320), (320, 307), (285, 295), (295, 336), (336, 285), + (320, 319), (319, 404), (404, 320), (329, 330), (330, 349), (349, 329), + (334, 293), (293, 333), (333, 334), (366, 323), (323, 447), (447, 366), + (316, 15), (15, 315), (315, 316), (331, 358), (358, 279), (279, 331), + (317, 14), (14, 316), (316, 317), (8, 285), (285, 9), (9, 8), + (277, 329), (329, 350), (350, 277), (253, 374), (374, 252), (252, 253), + (319, 318), (318, 403), (403, 319), (351, 6), (6, 419), (419, 351), + (324, 318), (318, 325), (325, 324), (397, 367), (367, 365), (365, 397), + (288, 435), (435, 397), (397, 288), (278, 344), (344, 439), (439, 278), + (310, 272), (272, 311), (311, 310), (248, 195), (195, 281), (281, 248), + (375, 273), (273, 291), (291, 375), (175, 396), (396, 199), (199, 175), + (312, 311), (311, 268), (268, 312), (276, 283), (283, 445), (445, 276), + (390, 373), (373, 339), (339, 390), (295, 282), (282, 296), (296, 295), + (448, 449), (449, 346), (346, 448), (356, 264), (264, 454), (454, 356), + (337, 336), (336, 299), (299, 337), (337, 338), (338, 151), (151, 337), + (294, 278), (278, 455), (455, 294), (308, 292), (292, 415), (415, 308), + (429, 358), (358, 355), (355, 429), (265, 340), (340, 372), (372, 265), + (352, 346), (346, 280), (280, 352), (295, 442), (442, 282), (282, 295), + (354, 19), (19, 370), (370, 354), (285, 441), (441, 295), (295, 285), + (195, 248), (248, 197), (197, 195), (457, 440), (440, 274), (274, 457), + (301, 300), (300, 368), (368, 301), (417, 351), (351, 465), (465, 417), + (251, 301), (301, 389), (389, 251), (394, 395), (395, 379), (379, 394), + (399, 412), (412, 419), (419, 399), (410, 436), (436, 322), (322, 410), + (326, 2), (2, 393), (393, 326), (354, 370), (370, 461), (461, 354), + (393, 164), (164, 267), (267, 393), (268, 302), (302, 12), (12, 268), + (312, 268), (268, 13), (13, 312), (298, 293), (293, 301), (301, 298), + (265, 446), (446, 340), (340, 265), (280, 330), (330, 425), (425, 280), + (322, 426), (426, 391), (391, 322), (420, 429), (429, 437), (437, 420), + (393, 391), (391, 326), (326, 393), (344, 440), (440, 438), (438, 344), + (458, 459), (459, 461), (461, 458), (364, 434), (434, 394), (394, 364), + (428, 396), (396, 262), (262, 428), (274, 354), (354, 457), (457, 274), + (317, 316), (316, 402), (402, 317), (316, 315), (315, 403), (403, 316), + (315, 314), (314, 404), (404, 315), (314, 313), (313, 405), (405, 314), + (313, 421), (421, 406), (406, 313), (323, 366), (366, 361), (361, 323), + (292, 306), (306, 407), (407, 292), (306, 291), (291, 408), (408, 306), + (291, 287), (287, 409), (409, 291), (287, 432), (432, 410), (410, 287), + (427, 434), (434, 411), (411, 427), (372, 264), (264, 383), (383, 372), + (459, 309), (309, 457), (457, 459), (366, 352), (352, 401), (401, 366), + (1, 274), (274, 4), (4, 1), (418, 421), (421, 262), (262, 418), + (331, 294), (294, 358), (358, 331), (435, 433), (433, 367), (367, 435), + (392, 289), (289, 439), (439, 392), (328, 462), (462, 326), (326, 328), + (94, 2), (2, 370), (370, 94), (289, 305), (305, 455), (455, 289), + (339, 254), (254, 448), (448, 339), (359, 255), (255, 446), (446, 359), + (254, 253), (253, 449), (449, 254), (253, 252), (252, 450), (450, 253), + (252, 256), (256, 451), (451, 252), (256, 341), (341, 452), (452, 256), + (414, 413), (413, 463), (463, 414), (286, 441), (441, 414), (414, 286), + (286, 258), (258, 441), (441, 286), (258, 257), (257, 442), (442, 258), + (257, 259), (259, 443), (443, 257), (259, 260), (260, 444), (444, 259), + (260, 467), (467, 445), (445, 260), (309, 459), (459, 250), (250, 309), + (305, 289), (289, 290), (290, 305), (305, 290), (290, 460), (460, 305), + (401, 376), (376, 435), (435, 401), (309, 250), (250, 392), (392, 309), + (376, 411), (411, 433), (433, 376), (453, 341), (341, 464), (464, 453), + (357, 453), (453, 465), (465, 357), (343, 357), (357, 412), (412, 343), + (437, 343), (343, 399), (399, 437), (344, 360), (360, 440), (440, 344), + (420, 437), (437, 456), (456, 420), (360, 420), (420, 363), (363, 360), + (361, 401), (401, 288), (288, 361), (265, 372), (372, 353), (353, 265), + (390, 339), (339, 249), (249, 390), (339, 448), (448, 255), (255, 339)]) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\271\252\350\243\275\350\207\211\351\203\2503D\346\250\241\345\236\213/step1_facemesh.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\271\252\350\243\275\350\207\211\351\203\2503D\346\250\241\345\236\213/step1_facemesh.py" new file mode 100644 index 0000000000000000000000000000000000000000..3726abe12ccf2361cc010201ec660049850b2803 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\347\271\252\350\243\275\350\207\211\351\203\2503D\346\250\241\345\236\213/step1_facemesh.py" @@ -0,0 +1,115 @@ +""" +Week 11 - Step 1: Face Mesh Tracking with Connections (Template 填空版) +=================================================================== +本步驟目標: + 1. 使用 OpenCV 開啟視訊鏡頭 + 2. 載入 MediaPipe 臉部特徵偵測器 (face_landmarker.task) + 3. 偵測人臉,並在臉上畫出綠色的 3D 特效網格線與黃色特徵點 +""" +import cv2 +import mediapipe as mp +import numpy as np +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the examples folder.") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker Tasks API initialized successfully.") +except Exception as e: + print(f"Error initializing detector: {e}") + sys.exit() + +# 匯入臉部網格連接點定義 +try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + from face_mesh_connections import FACEMESH_TESSELATION +except ImportError: + FACEMESH_TESSELATION = [] + +# 繪圖模式:0 = 僅畫網格線, 1 = 僅畫點點, 2 = 點線都畫 +draw_mode = 0 + +print("Press 'q' in the window to exit, or 'm' to toggle display modes.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + print("Error: Failed to read frame.") + break + + # 【TODO 1】畫面水平翻轉,並將 BGR 轉成 MediaPipe 需要的 RGB 格式 + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + + # 暫時為了讓未填空時不當機的預設程式碼: + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + + # 執行臉部偵測 + results = detector.detect(mp_image) + + # 【TODO 2】如果偵測到臉部,畫出 3D 特效網格線 + # 提示:取出 results.face_landmarks[0],並用迴圈巡覽 FACEMESH_TESSELATION + # 點的座標 (lm.x, lm.y) 是 0~1 的比例,需要乘上 img_w 和 img_h 換算成像素座標 + + # 預設執行代碼 (填寫完後學生會將此替換或解除上方註解) + if results.face_landmarks: + face_lms = results.face_landmarks[0] + num_lms = len(face_lms) + + if draw_mode == 0 or draw_mode == 2: + for start_idx, end_idx in FACEMESH_TESSELATION: + if start_idx < num_lms and end_idx < num_lms: + lm1 = face_lms[start_idx] + lm2 = face_lms[end_idx] + pt1 = (int(lm1.x * img_w), int(lm1.y * img_h)) + pt2 = (int(lm2.x * img_w), int(lm2.y * img_h)) + cv2.line(img, pt1, pt2, (224, 224, 0), 1) + + # 【TODO 3】畫出臉部 468 個黃色特徵點點 + if draw_mode == 1 or draw_mode == 2: + for lm in face_lms: + cx = int(lm.x * img_w) + cy = int(lm.y * img_h) + cv2.circle(img, (cx, cy), 1, (0, 255, 255), -1) # 黃色小圓點 + + mode_str = "Mesh" if draw_mode == 0 else "Dots" if draw_mode == 1 else "Both" + cv2.putText(img, f"Face Detected | Mode: {mode_str}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + # 顯示操作提示文字 + cv2.putText(img, "Press 'm' to toggle mode | 'q' to quit", (10, img_h - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) + + cv2.imshow("Face Mesh Connections", img) + + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('m'): + draw_mode = (draw_mode + 1) % 3 + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\250\255\350\250\210AI\350\211\262&\345\234\272\346\231\257/d2_2_prompt_engineering.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\250\255\350\250\210AI\350\211\262&\345\234\272\346\231\257/d2_2_prompt_engineering.py" new file mode 100644 index 0000000000000000000000000000000000000000..fff4b0721b7e4622a22a0c51a2c018cde4f8c8fd --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\250\255\350\250\210AI\350\211\262&\345\234\272\346\231\257/d2_2_prompt_engineering.py" @@ -0,0 +1,58 @@ +# d2_2_prompt_engineering.py +# Day 2: 生成式 AI 串接實戰 - Prompt 工程 (系統指令與結構化輸出) +import os +from google import genai + +# 取得 API 金鑰 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") +if not api_key: + print("[提示] 請輸入您的 Google AI Studio API 金鑰:") + api_key = input("API Key: ").strip() + +client = genai.Client(api_key=api_key) + +# 1. 設置系統指令 (System Instruction / System Prompt) +# TODO 1: +system_prompt = "你是国中的英文老師,記得教學的時候要有耐心!" + +print("正在初始化英語教學助理模型...") + +# 2. Few-shot (少樣本) 引導與對話演練 +# TODO 2: +user_word = "淘宝" + +print(f"\n[學生輸入]: {user_word}") +prompt = f"請幫我翻譯這個詞:'{user_word}'。" + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=prompt, + config={'system_instruction': system_prompt} +) + +print("\n[AI 老師回應]:") +print(response.text) + + +# 3. 結構化 JSON 輸出 (Structured Output) 範例 +print("\n" + "="*40) +print("進階挑戰:要求模型回傳 JSON 格式") +print("="*40) + +# 我們可以在 prompt 中要求,並透過 configuration 設定 JSON 輸出格式 +# TODO 3: +json_prompt = ( + f"請將中文單字 '{user_word}' 的翻譯結果整理成 JSON 格式,包含以下三個欄位:\n" + "- 'english': 英文單字\n" + "- 'kk_phonetic': KK音標\n" + "- 'example_sentence': 適合國中生理解的英文例句與中文翻譯" + +) + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=json_prompt, + config={"response_mime_type": "application/json"} +) +print("\n[AI 回傳的原始 JSON]:") +print(response.text) diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" new file mode 100644 index 0000000000000000000000000000000000000000..edd07079a11215f92c13efc9a93744a7f511a1e2 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" @@ -0,0 +1,38 @@ +# d1_2_data_cleaning.py +# Day 1 學員填空:資料清洗(完成 TODO) +# cd examples/Day1/student && python3 d1_2_data_cleaning.py +# 參考答案:../Teacher/d1_2_data_cleaning.py + +import pandas as pd +import numpy as np + +# 與 Teacher 一致:第 5 天高溫有雨,方便篩選示範 +raw_data = { + "Date": ["2026-07-01", "2026-07-02", "2026-07-03", "2026-07-04", "2026-07-05"], + "Temperature": [28.5, np.nan, 30.2, 31.5, 30.8], + "Humidity": [82, 80, np.nan, 70, 90], + "Rainfall": [5.2, 0.0, 0.0, np.nan, 25.4], +} + +df = pd.DataFrame(raw_data) +print("=== 原始含有缺失值的數據 ===") +print(df) + +df_cleaned = df.copy() +mean_temp = df_cleaned["Temperature"].mean() +df_cleaned["Temperature"].fillna(mean_temp) +df_cleaned["Humidity"] = df_cleaned["Humidity"].ffill() +df_cleaned["Rainfall"] = df_cleaned["Rainfall"].fillna(0.0) + +print("\n=== 清洗與填補後的數據 ===") +print(df_cleaned) + +print("\n=== 篩選:溫度>29 且 降雨>0 ===") +high_temp_and_rain = df_cleaned[(df_cleaned["Temperature"]>29) & (df_cleaned["Rainfall"]>0)] +print(high_temp_and_rain) + +print("\n=== 新增 Is_Hot(溫度 >= 30)===") +df_cleaned['Is_Hot'] = df_cleaned["Temperature"]>30 +print(df_cleaned) +df_cleaned['Is_Rainy'] = df_cleaned["Rainfall"]>0 +print(df_cleaned) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.png" "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.png" new file mode 100644 index 0000000000000000000000000000000000000000..aca2d0409ab329c2ed0486a90a376c91171e4b43 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\345\212\211\346\237\224\344\275\221/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35e1b548669e0f8fb562a816d13c1bdd27fe4a1f77bf929f3e94ea0df83f6582 +size 292096 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" new file mode 100644 index 0000000000000000000000000000000000000000..da7e7a63caa1e0fd3e3305c640e97645bb599b5a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" @@ -0,0 +1,34 @@ +# d1_1_pandas_intro.py +# Day 1 學員填空:Pandas 基礎(完成 TODO 後執行) +# cd examples/Day1/student && python3 d1_1_pandas_intro.py +# 參考答案:../Teacher/d1_1_pandas_intro.py + +import pandas as pd + +df = pd.read_csv("taipei_weather_2026.csv") +print("=== 數據載入成功! ===") + +print("\n--- 1. head ---") +print(df.head()) + +print("\n--- 2. info ---") +df.info() + +print("\n--- 3. describe ---") +print(df.describe()) + +print("\n--- 4. Temperature 前 5 筆 ---") +print(df['Temperature'].head()) + +print("\n--- 5. Date 與 Rainfall ---") +print(df[["Date","Rainfall" ]].head()) + +print("\n--- 挑戰:最高溫與平均濕度 ---") +max_temp = df['Temperature'].max() +avg_humidity = df['Humidity'].mean() +min_rainfall = df['Rainfall'].min() +print(f"最高溫度:{max_temp}°C") +print(f"平均濕度:{avg_humidity:.2f}%") +print(f"最小降雨量:{min_rainfall}mm") +df['inidian_can_come'] = (df["Temperature"] > 27) & (df["Rainfall"] ==0.0) +print(df) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/agent_report_love.md" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/agent_report_love.md" new file mode 100644 index 0000000000000000000000000000000000000000..d1718cb56a6cfbd08fc0136e4f1e43ae83b70031 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/agent_report_love.md" @@ -0,0 +1,44 @@ +這是一份關於「愛(Love)」的深度分析報告。 + +--- + +# 關於「愛」的哲學省思:從名言探討愛的本質與生命價值 + +## 1. 導言 +「愛」是人類情感中最複雜且深邃的領域。它不僅僅是浪漫的悸動,更是友誼的支撐、自我認知的鏡像,以及面對生命試煉時的力量來源。透過不同的文學家、哲學家與藝文人士對愛的剖析,我們可以發現愛並非一個單純的「結論」,而是一個持續演進、包含痛苦與成長的「過程」。本報告將透過原始數據的分析,帶領讀者重新審視愛在我們生命中的定位。 + +--- + +## 2. 名言分析 + +以下挑選三句具代表性的名言進行翻譯與賞析: + +### 第一句:關於「真實與自愛」 +* **原文**:“It is better to be hated for what you are than to be loved for what you are not.” +* **作者**:André Gide +* **翻譯**:「因真實的自己而被討厭,好過因虛假的自己而被愛。」 +* **深度賞析**:這句話觸及了愛的首要條件——誠實。許多人在關係中為了討好對方或迎合社會期待,會戴上「面具」。然而,建立在謊言或偽裝上的愛是脆弱且空虛的。Gide 提醒我們,真正的愛需要以「自我」為基礎,唯有敢於展現真實的缺陷與個性,我們才能吸引到真正接納我們的人。 + +### 第二句:關於「愛的本質與冷漠」 +* **原文**:“The opposite of love is not hate, it's indifference.” +* **作者**:Elie Wiesel +* **翻譯**:「愛的反面不是恨,而是冷漠。」 +* **深度賞析**:這是一個極具力量的觀點。恨往往源於強烈的情感投注(甚至是愛之深責之切),而「冷漠」則是徹底的隔離與無視。當一個人對另一方不再產生任何情緒反應時,連結便徹底斷裂。這句話警示我們:在經營關係時,溝通的痛苦或爭執有時是必要的,因為那代表我們仍在乎彼此;最該恐懼的,是兩人間變得無話可說、毫無波瀾。 + +### 第三句:關於「愛的境界與融合」 +* **原文**:“I love you without knowing how, or when, or from where... in which there is no I or you, so intimate that your hand upon my chest is my hand.” +* **作者**:Pablo Neruda +* **翻譯**:「我愛你,不知道如何愛、何時愛、從何處愛起……愛得如此單純,沒有矜持。我這樣愛你,是因為我不知道其他的愛法,在這種愛裡,沒有『我』與『你』之分,親暱到你的手放在我胸口,就像是我自己的手,親暱到當我入睡時,閉上的是你的雙眼。」 +* **深度賞析**:Neruda 描述的是一種靈魂合一的境界。這種愛超越了感官的追求,進入了一種「共生」的哲學高度。這裡的「沒有我與你」並非喪失自我,而是一種極致的同理與共鳴。它告訴我們,真愛是能夠跨越個體界限,將對方的感受視為自身感受的深刻連結。 + +--- + +## 3. 結論:對學生的啟發與建議 + +透過這些關於愛的論述,我們能為成長中的學生提出以下幾點建議: + +1. **先學會成為「完整」的個體**:如 Marilyn Monroe 所言,生命是由自己創造的。在追求愛之前,請先確保自己擁有自我價值感。如果你無法愛真實的自己,那麼你在關係中將永遠在索求認同,而非分享愛。 +2. **勇敢面對破碎與失敗**:愛並非童話,它包含心碎、爭吵與成長。正如 James Baldwin 所述,「愛是一場戰鬥」,請不要因為害怕受傷而拒絕去愛。挫折是學習如何愛人與被愛的必要代價。 +3. **珍視生活中的「深度友誼」**:Nietzsche 指出婚姻的不幸常源於友誼的匱乏。這提醒我們,無論未來的伴侶是誰,良好的溝通、尊重與友情才是關係長久的基石。在學生時期,請多花時間經營那些在你人生低谷時仍選擇留下來的朋友,這些連結將是你一生最堅實的後盾。 + +**總結:** 愛是一種能力,而非運氣。保持真誠、拒絕冷漠、並勇於在跌倒中學習,你將能定義出屬於你自己的愛的模樣。 \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/ai_analysis_ptt.md" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/ai_analysis_ptt.md" new file mode 100644 index 0000000000000000000000000000000000000000..77e02dffca836224965dcc210aec0e6241f37583 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/ai_analysis_ptt.md" @@ -0,0 +1,20 @@ +根據您提供的 PTT NBA 板近期爬蟲數據,以下為針對該內容的分析報告: + +### 1. 輿情概述 +在本次提供的數據區間內,**並未出現任何關於球員正式交易的公告或新聞**。目前板上的討論核心聚焦在「花邊新聞」(如球員個人表現與言論)以及「板務公告」(如板規更新、徵人啟事與選秀情報彙整)。目前市場上並無重大球員轉隊或交易案發生。 + +### 2. 熱門話題 +根據數據中的推文數與討論度,最具代表性的話題如下: + +* **Adebayo 自評年度最佳表現 (推文數:21)**:這是本次數據中討論度最高的議題。網友針對 Adebayo 聲稱單場 83 分應獲得年度最佳表現展開討論。此類話題通常會引發關於「數據膨脹」、「當代籃球與過往年代比較」以及該球員在聯盟中定位的辯論。 +* **選秀情報與 FA (自由球員) 相關規範 (推文數:12)**:由於正值選秀情報與自由球員市場的相關討論期,板務人員特別公告相關發文規範。這顯示目前 NBA 板的使用者正高度關注休賽季的新秀動態與球員流動,屬於季節性的熱門需求。 + +### 3. 奪冠熱門 +基於本次提供的原始爬蟲數據,**資料中並未提及關於下賽季奪冠熱門球隊的相關分析、賠率或球評觀點**。目前的討論熱點尚未集中在爭冠級別的球隊競爭上。 + +### 4. 最佳新秀 +基於本次提供的原始爬蟲數據,**資料中並未出現任何關於「年度最佳新秀」(Rookie of the Year)的預測或討論**。目前的資訊僅停留在「選秀情報」的彙整階段,尚未進一步深入到新秀實力的排名與評估。 + +--- +**總結建議:** +目前的 PTT NBA 板正處於賽季間隙(休賽季),討論重心多為板務與球員軼事。若需獲取即時的「交易資訊」或「奪冠熱門分析」,建議可持續關注後續關於自由球員簽約(FA Signing)及夏季聯賽(Summer League)的相關板務更新。 \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/ai_analysis_report_ptt.md" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/ai_analysis_report_ptt.md" new file mode 100644 index 0000000000000000000000000000000000000000..a9c33915a51e625a52d330f064b6540d57f955a7 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/ai_analysis_report_ptt.md" @@ -0,0 +1 @@ +response.text \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/d4_1_web_scraping.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/d4_1_web_scraping.py" new file mode 100644 index 0000000000000000000000000000000000000000..26792f288eaa50ea59f82a077985c21c5578d14d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/Day4/d4_1_web_scraping.py" @@ -0,0 +1,68 @@ +# d4_1_web_scraping.py — Day4 學員填空 +# cd examples/Day4/student && python3 d4_1_web_scraping.py +# 參考答案:../d4_1_web_scraping.py + +import requests +from bs4 import BeautifulSoup + +# 本範例抓取專供網頁爬蟲教學使用的模擬沙盒網站:Quotes to Scrape +URL = "http://quotes.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線並抓取網站內容: {URL} ...") + +try: + # === 【學員填空練習點 2:連線請求與下載網頁】 === + # TODO: 1. 使用 requests.get 發送 GET 請求,帶入 headers=HEADERS 與設定 timeout=5 + # TODO: 2. 判斷 response.status_code 是否為 200 (下載成功) + response = requests.get(URL, headers=HEADERS, timeout=5) + + if response and response.status_code == 200: + print("網頁下載成功!開始解析 HTML 結構...") + + # === 【學員填空練習點 3:初始化 BeautifulSoup 解析器】 === + # TODO: 使用 BeautifulSoup 解析 response.text,初始化 html.parser + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 4:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有名言與作者的 div 標籤(其 class 為 'quote') + quote_divs = soup.find_all("div", class_="quote") + + print(f"\n成功解析!本日精選名言如下(共 {len(quote_divs)} 筆):") + print("=" * 60) + + # === 【學員填空練習點 5:迴圈遍歷提取內容與防錯】 === + # TODO: + # 1. 遍歷 quote_divs,並使用 enumerate 取得索引 idx(從 1 開始) + # 2. 用 .find() 尋找 span 且 class 為 'text' 的名言元素 + # 3. 用 .find() 尋找 small 且 class 為 'author' 的作者元素 + # 4. 進行 None 防錯處理,確保兩者皆非空值後,使用 .get_text() 提取純文字並印出 + for idx, quote_div in enumerate(quote_divs, 1): + text_el = quote_div.find("span", class_="text") + author_el = quote_div.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + + print(f"[{idx}] \"{text}\"") + print(f" -- by {author}\n") + + print("=" * 60) + print("提醒:請尊重 robots.txt 與網站使用條款,勿對目標網站發送高頻請求。") + else: + status = response.status_code if response else "未知" + print(f"[錯誤] 無法抓取網頁,伺服器回應狀態碼:{status}") + +except requests.exceptions.RequestException as e: + print(f"[錯誤] 連線失敗:{e}") + print("請確認電腦的網路連線是否通暢。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" new file mode 100644 index 0000000000000000000000000000000000000000..e3598c40ef2d9e557454983fe80df1790c37ff57 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" @@ -0,0 +1,41 @@ +# d2_1_gemini.py — Day2 學員填空 +# cd examples/Day2/Student && python3 d2_1_gemini.py +# 參考答案:../Teacher/d2_1_gemini.py + +import os +from google import genai + +# === 【學員填空練習點 1:設置與載入 API Key】 === +# TODO: 嘗試從環境變數 GEMINI_API_KEY 取得 API Key。如果未設定,則使用 input() 提示手動輸入。 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY。") + print(" 請先設定:export GEMINI_API_KEY=\"your_key\"") + print(" 或於下方手動輸入(僅供本機測試,勿寫死在檔案中上傳):") + api_key = input("API Key: ").strip() + if not api_key: + print("[結束] 未提供 API Key。可改練習 d2_1_ollama_local.py。") + exit(0) + +# === 【學員填空練習點 2:設定模型名稱】 === +# TODO: 設定使用的 Gemini 模型名稱(例如 'gemini-3.1-flash-lite') +model_name = "gemini-3.1-flash-lite" + +print(f"正在連線至 Google Cloud Model ({model_name})...") + +# === 【學員填空練習點 3:初始化 Gemini Client】 === +# TODO: 呼叫 genai.Client 並傳入 api_key 參數來初始化 Client 物件,指派給 client 變數 +client = genai.Client(api_key=api_key) + +# === 【學員填空練習點 4:發送內容生成請求】 === +# TODO: 使用 client.models.generate_content 呼叫模型,傳入 model 參數與 contents 提示詞 +# 提示詞內容:請模型以繁體中文解釋什麼是『API 串接』,並用餐廳點餐作為比喻,字數約 100 字。 +response = client.models.generate_content(model=model_name, +contents= "What's ur favorite game?") + +# === 【學員填空練習點 5:讀取並印出回應】 === +# TODO: 從 response 物件中取得 text 欄位內容,並列印出來。 +print("\n=== AI 回應結果 ===") +# 請在下方實作列印邏輯: +print(response.text) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/draw_game/draw_game_student.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/draw_game/draw_game_student.py" new file mode 100644 index 0000000000000000000000000000000000000000..b2a0d86d39841078d372347745542228819ebd50 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/draw_game/draw_game_student.py" @@ -0,0 +1,147 @@ +import cv2 +import numpy as np +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# ============================================ +# Week 9 - 簡單學生版:支援多色切換的 AR 空中寫字板 (純黑畫布版) +# ============================================ + +# === 【重要觀念 TODO 1:MediaPipe 手部偵測模型初始化】 === +# 觀念重點: +# 1. BaseOptions 用於設定模型檔案的路徑,這裡使用下載好的 'hand_landmarker.task'。 +# 2. HandLandmarkerOptions 用於設定手部偵測的細項設定,例如 num_hands=1 代表最多偵測一隻手。 +# 3. 最後透過 vision.HandLandmarker.create_from_options 建立手部偵測器物件。 +# 請解開下方三行程式碼的註釋: +base_options = python.BaseOptions(model_asset_path='hand_landmarker.task') +options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) +detector = vision.HandLandmarker.create_from_options(options) + +cap = cv2.VideoCapture(0) +canvas = None +px, py = 0, 0 + +# 定義幾種高對比的顏色 (格式為 B, G, R) +COLORS = { + 'golden': (34,87,247), # 金色 + 'cyan': (255, 255, 0), # 青藍色 + 'yellow': (0, 255, 255), # 亮黃色 + 'red': (0, 0, 255) # 正紅色 +} + +# 預設使用螢光綠 +current_color = COLORS['golden'] + +print("系統啟動中...") +print("【操作指南】") +print("☝️ 單伸食指:下筆寫字") +print("✌️ 食指+中指:懸停移動 (不寫字)") +print("⌨️ 按下 '1~4':切換畫筆顏色") +print("⌨️ 按下 'e':清空畫面") +print("⌨️ 按下 'q':結束程式") + +while True: + success, img = cap.read() + if not success: + break + + # === 【重要觀念 TODO 2:攝影機影像水平翻轉】 === + # 觀念重點: + # 1. 攝影機預設取得的影像與我們的真實動作是相反的(非鏡像)。 + # 2. 在互動式 AR 應用中,為了讓使用者的左手移動時,畫面中的手也往左移動, + # 必須使用 cv2.flip(img, 1) 將影像水平翻轉,以提供符合直覺的「鏡像體驗」。 + # 請解開下方一行程式碼的註釋: + img = cv2.flip(img, 1) + + h, w, c = img.shape + + # 初始化畫布 + if canvas is None: + canvas = np.zeros((h, w, c), dtype=np.uint8) + + # 為了維持畫筆提示點(或懸停圓圈)的動態顯示,且不破壞原本的 canvas 紀錄, + # 我們在每一訊框都建立一個純黑的暫時畫布來呈現最終畫面。 + display_img = np.zeros((h, w, c), dtype=np.uint8) + + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.hand_landmarks: + hand_lms = results.hand_landmarks[0] + + index_tip = hand_lms[8] + index_pip = hand_lms[6] + middle_tip = hand_lms[12] + middle_pip = hand_lms[10] + + cx, cy = int(index_tip.x * w), int(index_tip.y * h) + + index_up = index_tip.y < index_pip.y + middle_up = middle_tip.y < middle_pip.y + + if index_up and not middle_up: + # 狀態:【繪圖模式】 + if px == 0 and py == 0: + px, py = cx, cy + + # === 【重要觀念 TODO 3:在 OpenCV Canvas 上繪製軌跡】 === + # 觀念重點: + # 1. cv2.line 用於在影像上繪製直線。 + # 2. 參數說明:畫布(canvas)、起點(px, py)、終點(cx, cy)、顏色(current_color)、線寬(8)。 + # 3. 藉由在每一訊框連續繪製前一個位置到當前位置的線段,即可形成平滑的寫字軌跡。 + # 請解開下方一行程式碼的註釋: + cv2.line(canvas, (px, py), (cx, cy), current_color, 8) + + px, py = cx, cy + + # 筆頭提示點直接畫在暫時的顯示畫布上,避免在黑畫布上找不到筆尖 + cv2.circle(display_img, (cx, cy), 10, current_color, cv2.FILLED) + + elif index_up and middle_up: + # 狀態:【懸停模式】 + px, py = 0, 0 + # 懸停提示圈畫在暫時的顯示畫布上,移動時才不會在黑背景中迷路 + cv2.circle(display_img, (cx, cy), 12, (255, 255, 255), 2) + + else: + px, py = 0, 0 + else: + px, py = 0, 0 + + # 為什麼改用 canvas 與 display_img 疊加? + # 原本是與相機影像 (img) 疊加,現在改與純黑的 display_img 疊加, + # 這樣畫面上就只會留下寫字的軌跡以及當前的筆頭/懸停提示,完全遮蔽人像背景。 + + # === 【重要觀念 TODO 4:AR 畫面疊加與顯示】 === + # 觀念重點: + # 1. cv2.add 用於將兩個相同大小與類型的影像矩陣進行相加。 + # 2. 這裡將寫字痕跡畫布 (canvas) 與當前的筆頭/懸停提示畫布 (display_img) 疊加。 + # 3. 疊加後使用 cv2.imshow 在視窗中顯示最終成果。 + # 請解開下方兩行程式碼的註釋: + final_output = cv2.add(canvas, display_img) + cv2.imshow("Hand Tracking - AR Canvas", final_output) + + # 鍵盤監聽與控制邏輯 + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('c'): + canvas = np.zeros((h, w, c), dtype=np.uint8) + + elif key == ord('1'): + current_color = COLORS['green'] + print("已切換為:螢光綠") + elif key == ord('2'): + current_color = COLORS['cyan'] + print("已切換為:青藍色") + elif key == ord('3'): + current_color = COLORS['yellow'] + print("已切換為:亮黃色") + elif key == ord('4'): + current_color = COLORS['red'] + print("已切換為:正紅色") + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/3F.webp" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/3F.webp" new file mode 100644 index 0000000000000000000000000000000000000000..973d2fe4c6a401a65bf9a6485019516ce4e5611c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/3F.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31fea88b7ebdddfd05553cb1f44474ce8277cd99a4a7b6f9553194e394ec4376 +size 28714 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/500x500.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/500x500.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..9aaad531dafb33e4f1884de3f7ed870c4e67df13 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/500x500.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ef4c651b42950965b7887ad3539e82a8e2b1d3f0a3948d6bf25e772acde3f759 +size 20787 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/G.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/G.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..79c93c58b77392ca14af4428671ae13d69ffcbc8 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/G.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:963b695bbd5e65110d66fe981c887c44419cf45a0cefd16bab8982fc94781b3f +size 20736 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/LBJ.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/LBJ.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..137b70c9e58cf33fd98acf0e246d8633088a203e --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/LBJ.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b77198b3749ddfc36ef8a2955c40bb1de2bcbeb25de9e77fa09be15569726984 +size 15339 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/S.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/S.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..1691b32658ab1cd9138c29e11ce489199ca0637e --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/S.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fc1f642963f91b9b8f08d14e21abd8c553687d3ce91c30d93fbe0d1ad6a3b06 +size 18680 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/b.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/b.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..df731924769ab06d848f1d1057db0487494058cd --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/b.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e836d34649890afcb4145b48927d1ff3ae432611015ceeb8eadb8ead8aabdb7b +size 72102 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/c.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/c.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..ea4c22428df7671f841ba6f93643f4897b4245d0 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/c.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:850661a09010452c6930339ebc2a96d41e7c1136b5d55da2913fb7eb7124bbd4 +size 15796 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/dog.png" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/dog.png" new file mode 100644 index 0000000000000000000000000000000000000000..a478bb4203201161fef97af4d952fd3518eb9e66 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/dog.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6f69b8959f9c6777c52b28563290a89a7139a22392e701231cac7646cfd2b26 +size 238899 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (2).jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (2).jpg" new file mode 100644 index 0000000000000000000000000000000000000000..8769ef2fbdbb2de3a023513aaeae7d4ac4ffd0c2 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (2).jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:303c2d09c02cf161c8b4b2b28548247deced3ef58713e6fea261bd8e9e07eee1 +size 30809 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (5).jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (5).jpg" new file mode 100644 index 0000000000000000000000000000000000000000..09b61f876627cc1ab5e052e97a237dde40fab01d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (5).jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83dcff88012f158c7590ad3ea009f596ab76341c9669a29b8b7c4e58689d54f2 +size 31065 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (6).jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (6).jpg" new file mode 100644 index 0000000000000000000000000000000000000000..22caaecf86c2cfe60b3fc14031d3040553cf4f97 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images (6).jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:576ef4616a8dbbb8f195d5566d68cd19cc5a858dae7d24cd10dfe9abe6166d40 +size 44319 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..78db733b4ce4cd1783cd261fd228c4a7934ecb2c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/images.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:104922026ef54819d8294689dd9f4eafca4106d28b2e9ca0c7fd5d6aba92642f +size 74425 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/r.webp" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/r.webp" new file mode 100644 index 0000000000000000000000000000000000000000..4856e9a1b36306ec8fe909908c1e312296838357 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/r.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4baa52e9cde4e5195e0a0e22d18d30c3e1c11632fc71f4c45329ca793ff4fb43 +size 21456 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/w.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/w.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..a0d771621227aa9b2606f63e8cdec1383f186c67 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image/w.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17d1486bb6a4d70031da12624d479adba3fb55472cf92abd9fe049922989abd8 +size 17237 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/G.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/G.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..aa0a8f1c21debaea345ccf7532aceef4c0fbba0c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/G.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd1527ee9a547ac3877ceeb5817a0f321e08d0fe9b81eb049d03f8862ef67b2f +size 69668 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/G.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/G.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..f9e7ee278443807a04ad63c61cb012925b2ff8ad --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/G.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9ca10aa4b898e7d40204ed389fc295542af0e09cf115c67cd91e4b09689900d3 +size 35763 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..5250f727dddb60b83e4961abe0eccec3d9201cad --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a838a13ec030b33b20b54ed1b6e77941f64b2c15916341b567f9cfb7738dafb +size 22797 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..5844cf5b2cd2fa1a20b90fce707a66c2e5262f60 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43846f6ac68d3e464a50eceabe1b850cd0144e717d5ce62729488cc2f4e69640 +size 5960 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..625a9402b2961712a318ea8b5021a75e2f5765de --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/LBJ.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:673ece1b2fe2047e30fa28322a9d263ff41249736c2569ef65a84e3f89940be7 +size 14943 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..a8e8c7205e021d2d11c1689d6743ddd6d938f3ae --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:23c02a6cde2516070384329d59696f655e531db35c7c90dc703cd33e1d7297ff +size 52822 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..b3b2dc021ce147dfb0bf0b28e7c7fc6ab77a76cd --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c5a54ff63fd8bce6729be302331a463ab72b827086602aec682a5355dfea3276 +size 50882 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..c93906ed4cacc10b48f308654d02e7fad3e946eb --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/S.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f77ba5e39f8de007a32af59c69d3101f9d98b19073dcfe9da1c8ef96343bce1a +size 21524 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..7c288ae8754b93f736d1988fb7d52d115186b1f5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72d10d1e740e257436c743bb505b16873feae31e5ec8185aa64f713a50c8dc2d +size 177915 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..3191b0930734c6be4ec88793e0fd9b88c488b16c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad653d2b67735cc0f97e283d959ee14476f9f359180d4dc08122c3a256a5798f +size 147897 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..301a1d258fa41aa4a1d56d477d58cc5c24db865f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/b.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2006fb433c53f9bcfdee32e877f48e2f9702089addbfcd45d4108ff776327e51 +size 94736 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..bfdf4f4151feb132b73b99064b9788a03abaa234 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:786755a6d1df9ca2b8f3726e91a6c87d798aa1f37aaad3ec9e3c9130e2b3fa60 +size 73449 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..c8a4c15ef39713d228c655b51885d56cefda9b71 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10d42e2a9745b308838c42472da3e9578d6e01e0d8abbbfa5fd23cf701a647d2 +size 44111 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..9cbe4f1b080673aa54b0cf964f6faa1d87150fb3 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/dog.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1f7cf474391b5ac337ddd3c7154570ce90d5c0b9c0190049478158d515586c9 +size 42731 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_contours.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..309c5b9c9d3183d56b04cd7116d565d9183f218f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eda96c7ed2614516fa80c0a015210310f97991564dfa3d1f150968bb7fe0d0ea +size 40194 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..561328f64507ba2ff73e9976e753701bc91ede3f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3c41d2f0cbeccbadcbe823472d50df499b551c54b8aa70872d993eca322c09a +size 33355 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..c93906ed4cacc10b48f308654d02e7fad3e946eb --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/images_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f77ba5e39f8de007a32af59c69d3101f9d98b19073dcfe9da1c8ef96343bce1a +size 21524 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..3535089844d554df1966ac899e9faab44092d679 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c7c61fba755409c1fd25f6dc525d5d46fe8f8370968f11fb6d774659783bc35 +size 81637 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..3414eda0295f97def3eb6564a5f45179b2e86609 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f07519ab2c16c22f351e5c586f80934406f3f0ae570bcddd9ee830ce50edf8b8 +size 73891 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..1bb7e40db0df3185133b50bbf3d5a4a9820bbdce --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/r.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b7da475489dc88d7dbffc196e96c8806daae8bdebf24e071c769c902e60b5368 +size 47860 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.contour.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.contour.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..8901d39f3eb72f85bde7f31edb2c7b04e8386dc5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.contour.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a52179284ab9849a93eeb6851ed4c8a51a3bfcf4360e8ffd3cc1ca2b21c5021f +size 65674 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..7c8f1a90a28bdc9f88729f278346936379631d0f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:53697744e1d539f4b6f954d7c5ae3c21417899c5046b52bc37c24727a88ad3ed +size 36332 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..67075fcf736b9a91d9f85a7da88e63d67fc0d584 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/image_create/w.gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8936798465d5e76fed40ef0d28b1b674e697fc19b3d628fad7fbf6cb37f889c2 +size 31133 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock and AI_api\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock and AI_api\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" new file mode 100644 index 0000000000000000000000000000000000000000..8ea8eb9eaaa0b11ca9af7557f6cfc3feec926ae4 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock and AI_api\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" @@ -0,0 +1,103 @@ +# d2_3_chatbot_memory.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (Gemini / Local Mock 雙模) + +import os +from google import genai + +# 優先讀取環境變數 GEMINI_MODEL,預設為 gemini-3.1-flash-lite +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get( + "GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + + +def local_mock_response(user_input, history): + user_input_lower = user_input.lower() + + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "上學時間" in user_input_lower or "時間" in user_input_lower: + return "我們的上課時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + elif "籃球課" in user_input_lower or "籃球課價格" in user_input_lower: + return "一堂課為1000NTD" + elif "月票" in user_input_lower or "月票價格" in user_input_lower: + return "一個月有十堂課總價8500NTD。" + elif "教練" in user_input_lower or "教練人選與價格" in user_input_lower: + return "黑人教練1200NTD(因為他會灌籃) 麒洋67教練900NTD(他比較菜,而且他不會投籃,所以他不會教)。" + elif "報名" in user_input_lower or "如何報名" in user_input_lower: + return "去網站(ILOVEblackman.scammer.com)即可報名。" + elif "聯絡" in user_input_lower or "聯絡方式" in user_input_lower: + return "播打(0800676741419487)。" + elif "掰掰" in user_input_lower or "bye bye" in user_input_lower: + return "bye bye, u js got hard scammed.。" + else: + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (支援對話上下文記憶)") + print("=" * 50) + + use_mock = False + chat_session = None + + if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY,將自動啟用「離線模擬模式 (Local Mock Mode)」。") + print(" (如需連線雲端模型,請設定 GEMINI_API_KEY 環境變數)") + use_mock = True + else: + try: + print(f"正在連線 Google Gemini 雲端客服伺服器 ({GEMINI_MODEL})...") + client = genai.Client(api_key=api_key) + #TODO: 設定系統指令 (System Instruction / System Prompt) + system_instruction = ( + "我們一堂課為1000NTD,我們一個月有十堂課總價8500NTD, 我們黑人教練1200NTD(因為他會灌籃) 麒洋67教練900NTD(他比較菜,而且他不會投籃,所以他不會教)。, 我們, " + "去網站(ILOVEblackman.scammer.com)即可報名,請播打(0800676741419487)" + ) + + chat_session = client.chats.create( + model=GEMINI_MODEL, + config={'system_instruction': system_instruction} + ) + print("雲端客服系統已就緒!") + except Exception as e: + print(f"連線雲端客服失敗 ({e}),切換至離線模擬模式...") + use_mock = True + + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + if use_mock: + ai_reply = local_mock_response(user_input, local_history) + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + else: + response = chat_session.send_message(user_input) + ai_reply = response.text + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock_game/d2_3_chatbot_memory_game_template.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock_game/d2_3_chatbot_memory_game_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..4ac6b6f97ec0fd70b05a69556f6b3b991e58d4e2 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock_game/d2_3_chatbot_memory_game_template.py" @@ -0,0 +1,112 @@ +# d2_3_chatbot_memory_game_template.py +# Day 2: 生成式 AI 串接實戰 - 離線 Mock 遊戲設計模板 (學生填空版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬 NPC 或是系統的對話反應。 + + Why: + 此檔案為困難版對話記憶解謎的設計模板。你將學習到: + 1. 前置狀態限制:必須先問過「線索一」,才能查到「線索二」... + 2. 動態 Flag:Flag 答案中的某個線索會隨提問次數(turns)改變。 + """ + user_input_lower = user_input.strip().lower() + + # 計算目前是第幾輪對話 + turns = len(history) // 2 + 1 + + # TODO: 設計你的動態變數(例如 turns * 5,或是 current_weather 等) + # 並設定最終預期的正確 Flag 答案(必須包含此動態變數) + expected_flag = f"flag{{你是一位偵探你的名字是有感筆電的朋友的朋友的朋友的朋友的朋友的朋友的朋友的朋友的朋友的老師的爸爸的媽媽的外孫{turns}}}".lower() + + # 從歷史中判斷先前是否已取得前置線索(在 history 中,AI model 是否曾回覆過特定字眼) + # TODO: 依據你設計的線索關鍵字,修改以下 history 的 any 比對條件 + asked_clue1 = any("RaBOX6741" in h["text"] for h in history if h["role"] == "model") + asked_clue2 = any("_ROOM8787_" in h["text"] for h in history if h["role"] == "model") + + # 1. 驗證 Flag 邏輯 + if user_input_lower == expected_flag: + return f"🎉 恭喜你!成功破解案件並獲得了真正的 Flag!你在第 {turns} 回合成功通關!" + + elif "flag{" in user_input_lower: + return ( + f"❌ Flag 驗證失敗!\n" + f"提示:最後一個線索會隨著提問次數動態改變。\n" + f"你現在是在第 {turns} 次提問輸入,因此當前的動態線索應該是 turns{turns} 喔!\n" + f"請重新計算並拼湊最新的 Flag!" + ) + + # 2. 劇情關鍵字問答分支(打造你的 NPC 對話樹!) + # TODO: 依據你的故事背景,修改並設計以下 if-elif-else 結構 + if "flag" in user_input_lower or "答案" in user_input_lower: + return "NPC:『想要找回寶藏,你必須向我打聽案子的「主角」、「地點」與「犯人動態蹤跡」!』" + + elif "主角" in user_input_lower or "是誰" in user_input_lower: + # 線索一:靜態線索 + return "【線索一】這起案件的調查主角是著名的偵探,他的名字叫 'RaBOX6741'。請記好這串英文,接下來去查查案發的『地點』吧!" + + elif "地點" in user_input_lower or "哪裡" in user_input_lower or "案發現場" in user_input_lower: + # 線索二:有前置限制,必須先取得線索一 + if not asked_clue1: + return "NPC:『你連案件主角是誰都不知道,我才不告訴你案發現場在哪呢!』" + return "【線索二】寶藏就是在博物館的 '_ROOM8787_'。請記好這串字,接下來該查明犯人的動態『蹤跡』了!" + + elif "犯人" in user_input_lower or "蹤跡" in user_input_lower or "線索三" in user_input_lower: + # 線索三:動態線索,包含 turns 變數,且必須先取得線索二 + if not asked_clue1: + return "NPC:『你連案件主角是誰都不知道,我才不告訴你犯人的事呢!』" + if not asked_clue2: + return "NPC:『你必須先查出「地點」案發現場,我才會告訴你犯人逃跑的蹤跡。』" + return f"【線索三】犯人偷完寶藏時留在廁所裡馬桶的水管的最新蹤跡代號為:'ILOVEMan_94I6787{turns}'。注意,此代號會隨提問次數動態改變!" + + elif "拼湊" in user_input_lower or "結合" in user_input_lower or "怎麼組合" in user_input_lower: + return f"提示:將【線索一】【線索二】【當下提問的線索三】依序結合,並用小寫的 flag{{...}} 包起來。目前第 {turns} 回合的線索三為 '_turns{turns}'。" + + # 3. 兜底回覆(當玩家輸入沒有匹配到任何關鍵字時) + else: + return ( + f"NPC:『你在說什麼?我聽不懂。收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?』" + ) + + +def main(): + # TODO: 修改為你的遊戲標題與故事背景 + print("=" * 60) + print(" 🕵️ 我設計的第一個具記憶解謎遊戲 - 消失的寶藏 🕵️") + print("=" * 60) + print("[故事簡介] 你是一位偵探你的名字是有感筆電的朋友的朋友的朋友的朋友的朋友的朋友的朋友的朋友的朋友的老師的爸爸的媽媽的外孫,必須幫助博物館來找回消失的寶藏。") + print(" 你需要找到線索才能找回寶藏。") + print("-" * 60) + + local_history = [] + + print("\n遊戲已啟動!請輸入你想問 NPC 的話(輸入 'quit' 結束遊戲):") + print("-" * 60) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("遊戲結束,感謝遊玩!") + break + + ai_reply = local_mock_response(user_input, local_history) + + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[NPC]: {ai_reply}") + + except KeyboardInterrupt: + print("\n遊戲強行終止!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock_\351\227\234\351\215\265\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock_\351\227\234\351\215\265\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" new file mode 100644 index 0000000000000000000000000000000000000000..665285e436cf34e1e31b843091554eba19fe0452 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/mock_\351\227\234\351\215\265\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" @@ -0,0 +1,74 @@ +# d2_3_chatbot_memory_mock.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (純 Local Mock 教師版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬客服助理的自動回覆。 + 在沒有網路或 API 金鑰時,如何透過關鍵字比對 + 與對話歷史長度(計算對話輪數)來模擬一個具有「記憶」能力的對話流程。 + """ + user_input_lower = user_input.lower() + + # 關鍵字比對:模擬常見的客服問答 + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "上學時間" in user_input_lower or "時間" in user_input_lower: + return "我們的上課時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + elif "考不好" in user_input_lower or "可得好" in user_input_lower: + return "考不好被罰,考得的好有獎勵" + elif "沒認真複習" in user_input_lower or "認真複習" in user_input_lower: + return "沒認真複習會考不好 , 認真複習就會考好" + + else: + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (純離線模擬模式 - 支援對話記憶)") + print("=" * 50) + print("[提示] 目前為純 Local Mock 模式,不需設定環境變數與連線雲端 API。") + print("-" * 50) + + # 用於儲存對話歷史的串列 + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + # 結束條件判斷 + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + # 呼叫模擬回覆函式 + ai_reply = local_mock_response(user_input, local_history) + + # 將本次對話記錄寫入歷史紀錄中,維持記憶狀態 + # Why: 每次成功的問答都必須 append 進 history,以便下一輪對話時能正確累計輪數。 + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + # 處理使用者按下 Ctrl+C 強行結束的情境,避免程式崩潰 + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/requirements.txt" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/requirements.txt" new file mode 100644 index 0000000000000000000000000000000000000000..ac7f00648560530b9d1168986de056f2bdfd6a45 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/requirements.txt" @@ -0,0 +1,6 @@ +pandas>=2.0.0 +matplotlib>=3.7.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +opencv-python>=4.8.0 +google-generativeai>=0.8.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/step/\345\230\264\345\267\264/step2_facemesh_complete.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/step/\345\230\264\345\267\264/step2_facemesh_complete.py" new file mode 100644 index 0000000000000000000000000000000000000000..f96bc596ecb2d03ade9db51b672972f8138a8718 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/step/\345\230\264\345\267\264/step2_facemesh_complete.py" @@ -0,0 +1,362 @@ +""" +Week 11 - Face Mesh Expression Detection (Complete Template 填空版) +=========================================================== +本步驟目標: + 1. 將 MediaPipe Face Landmarker 與 Pygame 完美結合,打造科幻儀表板 + 2. 計算臉部指標:嘴巴張開比例與眼睛胖瘦比例 (EAR) + 3. 實作「雙門檻滯後防吵鎖」,避免警報器反覆爆音 + 4. 實作「跳幀推理優化」,解決 AI 推理造成的畫面卡頓 + 5. 整合音效播放功能 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import time + +# ============================================ +# 1. 初始化 Pygame & 音效 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 11 - Face Mesh Expression Detector ") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 36) +small_font = pygame.font.SysFont(None, 24) +title_font = pygame.font.SysFont(None, 48) + +# 載入警告音效 (shoot.wav) +try: + trigger_sound = pygame.mixer.Sound(os.path.join(os.path.dirname(__file__), "shoot.wav")) + print("Success: Loaded sound file shoot.wav") +except FileNotFoundError: + trigger_sound = None + print("Warning: Could not find sound file shoot.wav") + +# ============================================ +# 2. 初始化 MediaPipe Face Landmarker +# ============================================ +model_path = os.path.join(os.path.dirname(__file__), 'face_landmarker.task') +if not os.path.exists(model_path): + # 若在當前資料夾找不到,就去上一層找 + model_path = os.path.join(os.path.dirname(__file__), '..', 'face_landmarker.task') + if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the directory.") + pygame.quit() + sys.exit() + +# 【TODO 1】初始化 MediaPipe Face Landmarker 偵測器 +# try: +# from mediapipe.tasks import python +# from mediapipe.tasks.python import vision +# base_options = python.BaseOptions(model_asset_path=model_path) +# options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +# detector = vision.FaceLandmarker.create_from_options(options) +# print("Success: Loaded MediaPipe detector model") +# except Exception as e: +# print(f"Error: Failed to load model: {e}") +# pygame.quit() +# sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Success: Loaded MediaPipe detector model") +except Exception as e: + print(f"Error: Failed to load model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 3. 臉部特徵點索引定義 +# ============================================ +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 +LEFT_EYE_TOP = 159 +LEFT_EYE_BOTTOM = 145 +LEFT_EYE_RIGHT = 133 + +# ============================================ +# 4. 門檻值與狀態變數 +# ============================================ +# 嘴巴雙門檻防吵鎖 +MOUTH_OPEN_THRESHOLD = 0.35 +MOUTH_CLOSE_THRESHOLD = 0.15 + +# 眼睛雙門檻防吵鎖 +EAR_BLINK_THRESHOLD = 0.18 +EAR_OPEN_THRESHOLD = 0.25 + +is_mouth_open = False +was_mouth_open = False + +is_blinking = False +was_blinking = False + +mouth_open_count = 0 +blink_count = 0 + +# 跳幀優化變數:每 3 影格才做一次 AI 推理 +frame_counter = 0 +FRAME_SKIP = 3 +detector_results = None + +# 開啟攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + pygame.quit() + sys.exit() + +# ============================================ +# 5. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + frame_counter += 1 + + plot_points = {} + + # 【TODO 2】實作跳幀推理優化 (每 FRAME_SKIP 影格才跑一次 AI) + # if frame_counter % FRAME_SKIP == 0 or detector_results is None: + # try: + # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + # detector_results = detector.detect(mp_image) + # except Exception as e: + # print(f"Error: Inference exception: {e}") + + if frame_counter % FRAME_SKIP == 0 or detector_results is None: + try: + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + detector_results = detector.detect(mp_image) + except Exception as e: + print(f"Error: Inference exception: {e}") + + # 【TODO 3】特徵點座標映射與比例計算 + # 1. 取得對應點在 Pygame 視窗 (WIDTH, HEIGHT) 中的像素位置並存入 plot_points + # 2. 計算眼寬基準線 base_dist + # 3. 計算歸一化嘴巴比例 mouth_ratio + # 4. 計算眼睛胖瘦比 ear (提示:垂直高度 eye_h / 水平寬度 eye_w) + # if detector_results and detector_results.face_landmarks: + # face_lms = detector_results.face_landmarks[0] + # + # for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + # pt = face_lms[idx] + # plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + # + # p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + # p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + # base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + # + # if base_dist > 0: + # p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + # p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + # mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + # mouth_ratio = mouth_dist / base_dist + # + # p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + # p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + # p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + # + # eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + # eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + # ear = eye_h / eye_w if eye_w > 0 else 0 + + if detector_results and detector_results.face_landmarks: + face_lms = detector_results.face_landmarks[0] + + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + pt = face_lms[idx] + plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + + p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + if base_dist > 0: + p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + mouth_ratio = mouth_dist / base_dist + + p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + + eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + ear = eye_h / eye_w if eye_w > 0 else 0 + + # 【TODO 4】實作雙門檻防吵鎖 (滯後判斷邏輯) + # if mouth_ratio > MOUTH_OPEN_THRESHOLD: + # is_mouth_open = True + # elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + # is_mouth_open = False + # + # if ear < EAR_BLINK_THRESHOLD: + # is_blinking = True + # elif ear > EAR_OPEN_THRESHOLD: + # is_blinking = False + + if mouth_ratio > MOUTH_OPEN_THRESHOLD: + is_mouth_open = True + elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + is_mouth_open = False + + if ear < EAR_BLINK_THRESHOLD: + is_blinking = True + elif ear > EAR_OPEN_THRESHOLD: + is_blinking = False + + # 【TODO 5】實作邊緣觸發判定 (只有在剛張嘴或剛閉眼的那一瞬間,播放一次警告音效) + # if is_mouth_open and not was_mouth_open: + # mouth_open_count += 1 + # if trigger_sound: + # trigger_sound.play() + # + # if is_blinking and not was_blinking: + # blink_count += 1 + # + # was_mouth_open = is_mouth_open + # was_blinking = is_blinking + + if is_mouth_open and not was_mouth_open: + mouth_open_count += 1 + if trigger_sound: + trigger_sound.play() + + if is_blinking and not was_blinking: + blink_count += 1 + + was_mouth_open = is_mouth_open + was_blinking = is_blinking + + else: + is_mouth_open = False + is_blinking = False + was_mouth_open = False + was_blinking = False + mouth_ratio = 0.0 + ear = 0.30 + + # ============================================ + # 6. 渲染 Pygame 畫面與 UI 元素 + # ============================================ + # 【TODO 6】根據狀態切換背景顏色 (安全時深色,張嘴時深綠色,眨眼時深藍色) + # if is_mouth_open: + # screen.fill((16, 44, 28)) + # elif is_blinking: + # screen.fill((15, 30, 54)) + # else: + # screen.fill((20, 20, 30)) + + if is_mouth_open: + screen.fill((16, 44, 28)) + elif is_blinking: + screen.fill((15, 30, 54)) + else: + screen.fill((20, 20, 30)) + + # 【TODO 7】畫出眼寬基準線、眼睛高度線與嘴唇高度線 + # 提示:用 pygame.draw.line 在螢幕上將點位連線 + # if plot_points: + # pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + # pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + # pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + if plot_points: + pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + # 畫特徵點小圓圈 + for idx, pos in plot_points.items(): + color = (255, 255, 255) + if idx in [LIP_TOP, LIP_BOTTOM]: + color = (245, 158, 11) + elif idx in [LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_OUTER, LEFT_EYE_RIGHT]: + color = (59, 130, 246) + pygame.draw.circle(screen, color, pos, 6) + + # 繪製儀表板 HUD 卡片 + pygame.draw.rect(screen, (30, 30, 45), (30, 30, 340, 220), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (30, 30, 340, 220), width=2, border_radius=12) + + screen.blit(font.render("Face Mesh Dashboard", True, (255, 255, 255)), (50, 45)) + + m_color = (34, 197, 94) if is_mouth_open else (245, 158, 11) + screen.blit(small_font.render(f"Mouth Ratio: {mouth_ratio:.2f}", True, m_color), (50, 90)) + pygame.draw.rect(screen, (50, 50, 65), (50, 110, 200, 12), border_radius=4) + pygame.draw.rect(screen, m_color, (50, 110, int(min(mouth_ratio, 1.0) * 200), 12), border_radius=4) + + e_color = (59, 130, 246) if not is_blinking else (239, 68, 68) + screen.blit(small_font.render(f"Eye EAR: {ear:.2f}", True, e_color), (50, 140)) + pygame.draw.rect(screen, (50, 50, 65), (50, 160, 200, 12), border_radius=4) + pygame.draw.rect(screen, e_color, (50, 160, int(min(ear, 0.5) * 400), 12), border_radius=4) + + # 繪製閾值指示白線 + pygame.draw.line(screen, (255, 255, 255), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 108), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 124), 2) + pygame.draw.line(screen, (255, 255, 255), (50 + int(EAR_BLINK_THRESHOLD * 400), 158), (50 + int(EAR_BLINK_THRESHOLD * 400), 174), 2) + + # 繪製統計卡片 + pygame.draw.rect(screen, (30, 30, 45), (WIDTH - 320, 30, 290, 110), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (WIDTH - 320, 30, 290, 110), width=2, border_radius=12) + screen.blit(small_font.render(f"Mouth Open Count: {mouth_open_count}", True, (255, 255, 255)), (WIDTH - 300, 45)) + screen.blit(small_font.render(f"Blink/Close Count: {blink_count}", True, (255, 255, 255)), (WIDTH - 300, 75)) + screen.blit(small_font.render(f"Hysteresis Status: ACTIVE", True, (34, 197, 94)), (WIDTH - 300, 105)) + + # 顯示置中警告狀態大字 + if is_mouth_open: + status_lbl = title_font.render("MOUTH OPENED", True, (34, 197, 94)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + elif is_blinking: + status_lbl = title_font.render("BLINKING", True, (59, 130, 246)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + else: + if not plot_points: + status_lbl = title_font.render("NO FACE DETECTED", True, (239, 68, 68)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 - 20)) + else: + status_lbl = title_font.render("NORMAL", True, (200, 200, 200)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + + # 顯示底部效能狀態文字 + status_text = small_font.render( + f"Frame: {frame_counter} | Skip: {FRAME_SKIP} frames | FPS: {clock.get_fps():.0f}", + True, (100, 100, 120) + ) + screen.blit(status_text, (20, HEIGHT - 35)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\270\211\346\214\207\346\215\217\351\201\212\346\210\262/d5_pinch_challenge_solution.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\270\211\346\214\207\346\215\217\351\201\212\346\210\262/d5_pinch_challenge_solution.py" new file mode 100644 index 0000000000000000000000000000000000000000..88abf5a7de30aeab0aaf0a6532b8ed1e53e2767f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\270\211\346\214\207\346\215\217\351\201\212\346\210\262/d5_pinch_challenge_solution.py" @@ -0,0 +1,216 @@ +""" +Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (三指捏合挑戰 Template 填空版) +================================================================== +本週挑戰目標: + 1. 實作「三指捏合 (Three-Finger Pinch)」: + 必須同時滿足「大拇指 (4) 與食指 (8) 的距離 < Threshold」 + 以及「大拇指 (4) 與中指 (12) 的距離 < Threshold」才判定為 Pinch + 2. 用三指碰觸 Pygame 畫面上的按鈕 + 捏合 = 魔法點擊 + 3. 使用邊緣觸發 (Edge Detection) 防止重複觸發 + 4. 新增 30 秒限時機制,時間到顯示最終得分 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import random + +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 10 - 三指魔法手勢按鈕 (填空版) ") +clock = pygame.time.Clock() + +# 攝影機 +cap = cv2.VideoCapture(0) + +# 【TODO 1】初始化 MediaPipe 手勢偵測器 (Hand Landmarker) + +try: + base_options = python.BaseOptions(model_asset_path=os.path.join('hand_landmarker.task')) + options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) + detector = vision.HandLandmarker.create_from_options(options) +except Exception as e: + print(f"Error loading model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 2. 遊戲變數 +# ============================================ +# 手指座標 +finger_x, finger_y = 400, 300 # 食指 +thumb_x, thumb_y = 400, 300 # 大拇指 +middle_x, middle_y = 400, 300 # 中指 + +# 距離變數 +dist_thumb_index = 0.0 +dist_thumb_middle = 0.0 + +# Pinch 相關變數 +is_pinching = False +was_pinching = False # 邊緣觸發 +PINCH_THRESHOLD = 40 # 距離臨界值 + +# 按鈕設定 +button_rect = pygame.Rect(300, 250, 200, 100) +button_colors = [(3, 11, 255), (234,255,3), + (100, 220, 100), (220, 180, 50)] +current_color_index = 0 + +# 點擊計數器 +click_count = 0 + +# --- 限時機制變數 --- +START_TIME = pygame.time.get_ticks() +GAME_DURATION = 30 # 遊戲限制時間(秒) +game_state = "PLAYING" # 狀態機 + +# ============================================ +# 3. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機與手勢偵測 --- + success, img = cap.read() + is_pinching = False # 每幀重設狀態,防止手離屏時卡死 + + if success: + img = cv2.flip(img, 1) # 鏡像翻轉 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + # === 【TODO 2:挑戰關卡三之手部安全偵測】 === + # 1. 判斷手部是否在畫面中 (results.hand_landmarks 是否有資料) + # 2. 如果在畫面中,取得食指 (8)、大拇指 (4)、中指 (12) 的像素座標 + # 3. 如果手不在畫面中,將 is_pinching 設為 False + # + hand_in_frame = False + if results.hand_landmarks: + hand_in_frame = True + for hand_lms in results.hand_landmarks: + # 食指 (8) + lm8 = hand_lms[8] + finger_x = int(lm8.x * WIDTH) + finger_y = int(lm8.y * HEIGHT) + + # 大拇指 (4) + lm4 = hand_lms[4] + thumb_x = int(lm4.x * WIDTH) + thumb_y = int(lm4.y * HEIGHT) + + # 中指 (12) + lm12 = hand_lms[12] + middle_x = int(lm12.x * WIDTH) + middle_y = int(lm12.y * HEIGHT) + + + # === 【TODO 3:挑戰關卡一之三指捏合判定】 === + # 提示:計算食指與大拇指距離,以及中指與大拇指距離 + # 兩組距離皆小於 PINCH_THRESHOLD 時,is_pinching 才為 True + dist_thumb_index = math.hypot(finger_x - thumb_x, finger_y - thumb_y) + dist_thumb_middle = math.hypot(middle_x - thumb_x, middle_y - thumb_y) + is_pinching = (dist_thumb_index < PINCH_THRESHOLD) and (dist_thumb_middle < PINCH_THRESHOLD) + + + + # --- 狀態機邏輯處理 --- + if game_state == "PLAYING": + # 1. 計算剩餘時間 + current_ticks = pygame.time.get_ticks() + elapsed_seconds = (current_ticks - START_TIME) / 1000 + remaining_time = max(0, GAME_DURATION - elapsed_seconds) + + # 2. 檢查時間是否到了 + if remaining_time <= 0: + game_state = "GAME_OVER" + + # 3. 邊緣觸發點擊判斷 + if is_pinching and not was_pinching: + finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10) + if finger_rect.colliderect(button_rect): + click_count += 1 + current_color_index = (current_color_index + 1) % len(button_colors) + + # === 【TODO 4:挑戰關卡二之按鈕隨機移動】 === + # 提示:當點擊發生時,讓按鈕的 x 和 y 座標隨機分配在畫面內 + button_rect.x = random.randint(0, WIDTH - button_rect.width) + button_rect.y = random.randint(0, HEIGHT - button_rect.height) + + + print(f" 魔法三指點擊!第 {click_count} 次") + + # 更新上一幀狀態 + was_pinching = is_pinching + + # --- 繪製畫面 --- + screen.fill((20, 20, 35)) + font = pygame.font.SysFont(None, 32) + large_font = pygame.font.SysFont(None, 64) + + if game_state == "PLAYING": + # 繪製按鈕 + btn_color = button_colors[current_color_index] + pygame.draw.rect(screen, btn_color, button_rect, border_radius=16) + + btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255)) + screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2, + button_rect.centery - btn_text.get_height() // 2)) + + # 顯示倒數計時 + time_text = font.render(f"Time Left: {remaining_time:.1f}s", True, (255, 220, 100)) + screen.blit(time_text, (WIDTH - time_text.get_width() - 20, 10)) + + # 畫手指游標 + cursor_color = (50, 255, 50) if is_pinching else (255, 50, 50) + pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12) # 食指 + pygame.draw.circle(screen, (255, 255, 255), (thumb_x, thumb_y), 12) # 大拇指 + pygame.draw.circle(screen, (100, 150, 255), (middle_x, middle_y), 12) # 中指 + + # === 【TODO 5:挑戰關卡三之畫面提示】 === + # 提示:當手不在畫面中時,在畫面上方顯示警告文字提醒學生將手部放回 + if not hand_in_frame: + warning_text = font.render("OUT OF FRAME - PLEASE PUT HANDS BACK", True, (255, 100, 100)) + screen.blit(warning_text, (WIDTH // 2 - warning_text.get_width() // 2, 100)) + + + + # 顯示調試數值 + small_font = pygame.font.SysFont(None, 24) + status = small_font.render(f"Finger: ({finger_x}, {finger_y})", True, (150, 150, 150)) + screen.blit(status, (10, 10)) + + dist_text = small_font.render(f"Dist Thumb-Index: {dist_thumb_index:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text, (10, 35)) + + dist_text2 = small_font.render(f"Dist Thumb-Middle: {dist_thumb_middle:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text2, (10, 60)) + + elif game_state == "GAME_OVER": + # 繪製遊戲結束畫面 + over_text = large_font.render("GAME OVER", True, (255, 50, 50)) + score_text = font.render(f"Total Clicks: {click_count} times", True, (255, 255, 255)) + + screen.blit(over_text, (WIDTH // 2 - over_text.get_width() // 2, HEIGHT // 2 - 50)) + screen.blit(score_text, (WIDTH // 2 - score_text.get_width() // 2, HEIGHT // 2 + 20)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" new file mode 100644 index 0000000000000000000000000000000000000000..0ce0726eb6eb839afdcf3f8cfaef9f17ad9404db --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" @@ -0,0 +1,128 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + cv2.circle(frame, (x+w//2,y+h//2+20),25, (0, 0, 255), -1) + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "I'm a joker", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" new file mode 100644 index 0000000000000000000000000000000000000000..5b1bb7d0a86620719e9c3d769fcf2eae647aa8ba --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" @@ -0,0 +1,139 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=5, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + # 1. 擷取人臉區域 (Slice) + face_roi = frame[y:y+h, x:x+w] + + # 2. 縮小人臉圖像 (例如縮小到 15x15 像素) + small = cv2.resize(face_roi, (15, 15), interpolation=cv2.INTER_LINEAR) + + # 3. 放大回原本的大小 (使用 NEAREST 內插法保留顆粒感) + mosaic = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + + # 4. 放回原畫面中 + frame[y:y+h, x:x+w] = mosaic + + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "Joker's face", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\205\251\347\234\274/step0_eyeguard_template.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\205\251\347\234\274/step0_eyeguard_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..4e00c38d711c83e6e480c3031ee76023923e48b6 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\205\251\347\234\274/step0_eyeguard_template.py" @@ -0,0 +1,156 @@ +""" +Week 12 - AI 生活工具箱:護眼大師 (Template 填空版) +===================================================== +本週目標: + 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案 + 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近 + 3. 在 Pygame 畫面上顯示即時狀態與警報 + 4. 加入防呆機制(try-except、None 檢查) + +本範例:護眼大師 EyeGuard + - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近) + - 超過安全閾值時發出警報文字 + 閃爍效果 + - 適用場景:打電動、寫作業時防止坐姿太差靠太近 + +注意: + - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。 +""" +import pygame +import cv2 +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision +import math +import sys +import os + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 48) +small_font = pygame.font.SysFont(None, 28) +big_font = pygame.font.SysFont(None, 72) + +# ============================================ +# 2. 攝影機與 AI 模組 +# ============================================ +cap = cv2.VideoCapture(0) +# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_face_video.mp4") + +# 【TODO 1】初始化 MediaPipe 臉部偵測 (Face Landmarker) +base_options = python.BaseOptions(model_asset_path=os.path.join('face_landmarker.task')) +options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +detector = vision.FaceLandmarker.create_from_options(options) + +# 提示:左眼的 Landmark 索引為 33,右眼為 263 + +# ============================================ +# 3. 遊戲變數 +# ============================================ +eye_distance = 0 +DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了! + +# 【TODO 2】警報狀態變數 +is_too_close = False +was_too_close = False # 邊緣觸發 +warning_timer = 0 # 警告閃爍計時器 + +# 統計數據 +total_warnings = 0 +safe_seconds = 0 +frame_counter = 0 + +# ============================================ +# 4. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機 --- + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + frame_counter += 1 + + # 【TODO 3】臉部偵測與兩眼距離計算 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 取得左眼 (33) 和右眼 (263) 座標 + left_eye = face_lms[33] + right_eye = face_lms[263] + + lx, ly = int(left_eye.x * WIDTH), int(left_eye.y * HEIGHT) + rx, ry = int(right_eye.x * WIDTH), int(right_eye.y * HEIGHT) + + # 計算兩眼之間的像素距離 + eye_distance = math.hypot(rx - lx, ry - ly) + + #【TODO 4】判斷是否太近 + is_too_close = eye_distance > DANGER_THRESHOLD + + #【TODO 5】邊緣觸發:剛進入危險區才播放警報 + if is_too_close and not was_too_close: + total_warnings += 1 + warning_timer = 60 # 閃爍 60 幀 + print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!") + + was_too_close = is_too_close + + # --- 繪製畫面 --- + #【TODO 6】根據狀態切換背景顏色 + if is_too_close: + if warning_timer > 0 and warning_timer % 4 < 2: + screen.fill((80, 20, 20)) # 閃爍紅色 + else: + screen.fill((50, 15, 15)) # 暗紅色 + warning_timer = max(0, warning_timer - 1) + else: + screen.fill((15, 30, 20)) # 安全綠色 + screen.fill((20, 20, 35)) # 預設背景 + + # 【TODO 7】繪製距離量表 + # 提示:用 pygame.draw.rect 畫一個長條,長度 = eye_distance 的比例 + # 安全時綠色,危險時紅色 + if is_too_close: + pygame.draw.rect(screen, (255, 80, 80), (50, HEIGHT - 50, min(eye_distance * 2, WIDTH - 100), 20)) + else: + pygame.draw.rect(screen, (80, 200, 100), (50, HEIGHT - 50, min(eye_distance * 2, WIDTH - 100), 20)) + # 【TODO 8】如果太近,顯示大字警告 + if is_too_close: + warn_text = big_font.render("⚠️ TOO CLOSE!", True, (255, 80, 80)) + screen.blit(warn_text, (WIDTH//2 - warn_text.get_width()//2, HEIGHT//2 - 40)) + else: + safe_text = font.render("✅ Safe Distance", True, (80, 200, 100)) + screen.blit(safe_text, (WIDTH//2 - safe_text.get_width()//2, HEIGHT//2 - 20)) + + # 顯示數值 + info = small_font.render(f"Eye Distance: {eye_distance:.0f} px | Threshold: {DANGER_THRESHOLD}", True, (150, 150, 180)) + screen.blit(info, (10, 10)) + + stats = small_font.render(f"Warnings: {total_warnings}", True, (150, 150, 180)) + screen.blit(stats, (10, 40)) + + pygame.display.update() + clock.tick(30) + +# cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\205\251\347\234\274/step4_ear.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\205\251\347\234\274/step4_ear.py" new file mode 100644 index 0000000000000000000000000000000000000000..0838a438d59af80d35290548e0dd68b39699c4f6 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\205\251\347\234\274/step4_ear.py" @@ -0,0 +1,132 @@ +""" +Week 11 - Step 3: Eye Aspect Ratio (EAR) Blink Detection (Template 填空版) +====================================================================== +本步驟目標: + 1. 找出特定的左眼特徵點:眼角(33, 133)與上下眼瞼(159, 145) + 2. 計算眼睛高度與寬度的比值,得到眼睛胖瘦比例 (Eye Aspect Ratio, EAR) + 3. 當胖瘦比例小於閾值 (0.18) 時,判定為眨眼或閉眼 + 4. 在 OpenCV 畫面上即時顯示 EAR 值與眨眼狀態 (BLINK / OPEN) +""" +import cv2 +import mediapipe as mp +import math +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join(os.path.dirname(__file__), '..', 'face_landmarker.task') +if not os.path.exists(model_path): + # 若在當前資料夾找不到,就去上一層找 + model_path = os.path.join(os.path.dirname(__file__), 'face_landmarker.task') + if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker initialized successfully.") +except Exception as e: + print(f"Error: Failed to initialize detector: {e}") + sys.exit() + +# 左眼特徵點索引定義 +LEFT_EYE_OUTER = 33 +LEFT_EYE_INNER = 133 +LEFT_EYE_TOP = 159 +LEFT_EYE_BOTTOM = 145 + +# 眨眼/閉眼門檻值 (比例低於此值代表閉眼) +BLINK_THRESHOLD = 0.18 + +print("Press 'q' in the window to exit.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + break + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + + # 影像處理 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 【TODO 1】取得這 4 個左眼特徵點在畫面中的 (x, y) 像素座標,並存在 pts 字典中 + # pts = {} + # for idx in [LEFT_EYE_OUTER, LEFT_EYE_INNER, LEFT_EYE_TOP, LEFT_EYE_BOTTOM]: + # pt = face_lms[idx] + # pts[idx] = (int(pt.x * img_w), int(pt.y * img_h)) + + pts = {} + for idx in [LEFT_EYE_OUTER, LEFT_EYE_INNER, LEFT_EYE_TOP, LEFT_EYE_BOTTOM]: + pt = face_lms[idx] + pts[idx] = (int(pt.x * img_w), int(pt.y * img_h)) + + # 【TODO 2】計算眼睛的垂直高度與水平寬度像素距離 + # p_top_x, p_top_y = pts[LEFT_EYE_TOP] + # p_bot_x, p_bot_y = pts[LEFT_EYE_BOTTOM] + # eye_h = math.hypot(p_bot_x - p_top_x, p_bot_y - p_top_y) + # + # p_outer_x, p_outer_y = pts[LEFT_EYE_OUTER] + # p_inner_x, p_inner_y = pts[LEFT_EYE_INNER] + # eye_w = math.hypot(p_inner_x - p_outer_x, p_inner_y - p_outer_y) + + p_top_x, p_top_y = pts[LEFT_EYE_TOP] + p_bot_x, p_bot_y = pts[LEFT_EYE_BOTTOM] + eye_h = math.hypot(p_bot_x - p_top_x, p_bot_y - p_top_y) + + p_outer_x, p_outer_y = pts[LEFT_EYE_OUTER] + p_inner_x, p_inner_y = pts[LEFT_EYE_INNER] + eye_w = math.hypot(p_inner_x - p_outer_x, p_inner_y - p_outer_y) + + if eye_w > 0: + # 【TODO 3】計算眼睛的胖瘦比例 (ear = 垂直高度 / 水平寬度) 並判斷是否眨眼 (is_blinking) + # ear = eye_h / eye_w + # is_blinking = ear < BLINK_THRESHOLD + + ear = eye_h / eye_w + is_blinking = ear < BLINK_THRESHOLD + + # 【TODO 4】在畫面上畫出測量輔助線(水平寬度為藍色,垂直高度為橘色) + # cv2.line(img, (p_outer_x, p_outer_y), (p_inner_x, p_inner_y), (255, 0, 0), 2) # 藍色寬度線 + # cv2.line(img, (p_top_x, p_top_y), (p_bot_x, p_bot_y), (0, 165, 255), 2) # 橘色高度線 + + cv2.line(img, (p_outer_x, p_outer_y), (p_inner_x, p_inner_y), (255, 0, 0), 2) + cv2.line(img, (p_top_x, p_top_y), (p_bot_x, p_bot_y), (0, 165, 255), 2) + + # 畫出特徵點黃色圓圈 + for idx, pos in pts.items(): + cv2.circle(img, pos, 4, (0, 255, 255), -1) + + # 顯示 EAR 數值與眨眼狀態 + cv2.putText(img, f"Eye EAR: {ear:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + + status_text = "BLINK" if is_blinking else "OPEN" + status_color = (0, 0, 255) if is_blinking else (0, 255, 0) + cv2.putText(img, f"State: {status_text}", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, status_color, 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + cv2.imshow("EAR Blink Detector - Press 'q' to quit", img) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/coffee_sales_analysis.png" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/coffee_sales_analysis.png" new file mode 100644 index 0000000000000000000000000000000000000000..9f1d5a5a1cac9aa66bf89c9e3b7a4a6605c38a83 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/coffee_sales_analysis.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cfc1711d2526a403e641c43c763432e1188593036cb0f84005bd4dfdd07a8a61 +size 395023 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/coffee_sales_data.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/coffee_sales_data.csv" new file mode 100644 index 0000000000000000000000000000000000000000..9034a63a93b67363c54e7f155c9cb5daf483f886 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/coffee_sales_data.csv" @@ -0,0 +1,15 @@ +Date,Sales_Amount,Customer_Count,Coffee_Cups_Sold,Dessert_Sold,Promotion_Active +2026-07-01,15200,120,85,30,No +2026-07-02,16800,135,95,35,No +2026-07-03,24500,190,140,55,Yes +2026-07-04,28000,210,165,68,Yes +2026-07-05,18500,145,110,40,No +2026-07-06,14200,110,80,25,No +2026-07-07,15900,125,90,28,No +2026-07-08,17200,138,100,32,No +2026-07-09,26000,205,150,60,Yes +2026-07-10,29500,225,175,75,Yes +2026-07-11,21000,160,120,45,No +2026-07-12,19800,150,115,42,No +2026-07-13,15000,118,85,26,No +2026-07-14,16500,130,92,30,No diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/d1_4_visualization_coffee.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/d1_4_visualization_coffee.py" new file mode 100644 index 0000000000000000000000000000000000000000..96b51133d0c82f5e167e8e2aae5613ddc1c71999 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\222\226\345\225\241\345\272\227\347\207\237\346\224\266\345\210\206\346\236\220_\351\200\262\351\232\216\347\271\252\345\234\226/d1_4_visualization_coffee.py" @@ -0,0 +1,124 @@ +# d1_4_visualization_coffee.py +# Day 1: 資料科學與數據視覺化 - 咖啡廳營運數據進階分析(學員填空) +# cd examples/Day1/student && python3 d1_4_visualization_coffee.py +# 參考答案:../Teacher/d1_4_visualization_coffee.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + + +def load_csv(filename="coffee_sales_data.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + # TODO: 1. 使用 os.path.join 將當前腳本所在的目錄與檔案名稱拼接成絕對路徑 + # 2. 使用 os.path.exists 判斷路徑是否存在,若存在則用 pd.read_csv 讀取並回傳 + path = os.path.join(os.path.dirname(__file__), filename) + if os.path.exists(path): + return pd.read_csv(path) + else: + print("❌ 找不到 CSV 檔案!請確認 coffee_sales_data.csv 與本腳本在同一目錄。") + exit(1) + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 1 列 2 行 (1x2) 的子圖畫布,尺寸大小為寬 15 英吋、高 6 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + + +# ========================================== +# 1. 子圖 1 (ax1):營業額與咖啡銷量趨勢對比 (雙 Y 軸) +# ========================================== +# === 【學員填空練習點 3:繪製雙 Y 軸折線圖】 === +# 1. 在 ax1 (左軸) 繪製「營業額」折線圖:X軸為 df['Date'], Y軸為 df['Sales_Amount'] +# 設定參數:點形 marker='o', 顏色 color='#b87e6b' (鐵鏽紅), 圖例 label='營業額 (NTD)', 線寬 linewidth=2 +# 2. 呼叫 ax1.twinx() 建立共用 X 軸的右側 Y 軸物件,指派給 ax1_twin +# 3. 在 ax1_twin (右軸) 繪製「咖啡銷量」折線圖:X軸為 df['Date'], Y軸為 df['Coffee_Cups_Sold'] +# 設定參數:點形 marker='s' (正方形), 顏色 color='#5f7186' (石板藍), 虛線 linestyle='--', 圖例 label='咖啡銷量 (杯)' +# 4. 分別設定左軸與右軸的 Y 軸標籤與顏色以示區隔 (提示:使用 set_ylabel 與 tick_params) + +# 請在下方完成 ax1 雙 Y 軸折線圖的繪製: +line1 = ax1.plot(df['Date'], df['Sales_Amount'], marker='o', color='#b87e6b', label='營業額(NTD)', linewidth=2) +ax1_twin = ax1.twinx() +line2 = ax1_twin.plot(df['Date'], df['Coffee_Cups_Sold'], marker='s', color= '#5f7186', linestyle='--', label='咖啡銷量(杯)', linewidth=2) + + +ax1.set_ylabel("營業額(NTD)", color='#b87e6b', fontsize=12) +ax1.tick_params(axis='y', labelcolor='#b87e6b') +ax1_twin.set_ylabel('咖啡銷量(杯)', color='#5f7186', fontsize=12) +ax1_twin.tick_params(axis='y', labelcolor='#5f7186') + +# === 【學員填空練習點 4:雙 Y 軸圖例合併】 === +# TODO: +# 1. 由於雙 Y 軸屬於不同的 Axes 物件,若直接呼叫 ax1.legend() 只會顯示左軸的圖例。 +# 請獲取兩軸的 lines 與 labels,合併後統一顯示在 ax1 的左上角 (loc='upper left') +# 提示: +# lines1, labels1 = ax1.get_legend_handles_labels() +# lines2, labels2 = ax1_twin.get_legend_handles_labels() +# ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') + +# 請在下方完成圖例合併: +lines = line1 + line2 +labels = [l.get_label()for l in lines] +ax1.legend(lines, labels, loc='upper left') + + +# 設定 ax1 基礎格式 +ax1.set_title("咖啡廳每日營業額與咖啡銷量趨勢", fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") +ax1.grid(True, linestyle='--', alpha=0.5) +ax1.tick_params(axis='x', rotation=45) + + +# ========================================== +# 2. 子圖 2 (ax2):促銷活動對客流量的影響 (數據聚合長條圖) +# ========================================== +# === 【學員填空練習點 5:數據聚合與長條圖繪製】 === +# TODO: +# 1. 使用 groupby 依據 'Promotion_Active' 欄位分組,並計算 'Customer_Count' 的平均值 (mean) +# 提示:promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +# 2. 在 ax2 上繪製長條圖,以 promo_avg.index 作為 X 軸,promo_avg.values 作為 Y 軸 +# 設定參數:顏色 color=['#e6e2d8', '#b87e6b'],外框顏色 edgecolor='#3d3a36',寬度 width=0.5 +# 3. 在長條圖上方顯示具體的平均數值 (提示:使用 ax2.bar_label 或是跑迴圈加文字) + +# 請在下方完成資料聚合與長條圖繪製: +promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +bars = ax2.bar(promo_avg.index, promo_avg.values, color=['#e6e2d8', '#b87e6b'], edgecolor='#3d3a36', width=0.5) +ax2.bar_label(bars, fmt='%.1f人', label_type='edge', padding=3, fontweight='bold') + + + +# 設定 ax2 基礎格式 +ax2.set_title("促銷活動對每日平均客流量的影響", fontsize=14, fontweight='bold') +ax2.set_xlabel("是否舉辦促銷活動") +ax2.set_ylabel("平均來客數 (人)") +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) + + +plt.tight_layout() # 自動調整間距,避免文字重疊 + + + + +plt.savefig("coffee_sales_analysis.png", dpi=300) +plt.show() + +# === 【學員填空練習點 6:圖表儲存與顯示】 === +# TODO: 1. 呼叫 savefig 將畫布儲存為檔名 "coffee_sales_analysis.png",並設定 dpi=300 +# 2. 呼叫 show 顯示圖表 +# (⚠️ 注意:不要在 print 或 savefig 內使用不支援 cp950 的 emoji) + +output_image = "coffee_sales_analysis.png" +# 請在下方完成圖表存檔與顯示: + + diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\230\264\345\267\264\347\234\274\347\235\233/step3_mouth_ratio.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\230\264\345\267\264\347\234\274\347\235\233/step3_mouth_ratio.py" new file mode 100644 index 0000000000000000000000000000000000000000..a1a218a6777e5772816ac5202d52c0f03b3c8bfe --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\230\264\345\267\264\347\234\274\347\235\233/step3_mouth_ratio.py" @@ -0,0 +1,113 @@ +""" +Week 11 - Step 2: Mouth Opening Ratio Detection (Template 填空版) +============================================================= +本步驟目標: + 1. 找出特定的臉部特徵點:眼角(33, 263)與嘴唇(13, 14) + 2. 計算兩眼距離,當作臉寬基準比例尺,防止人靠近或遠離鏡頭造成的誤判 (歸一化) + 3. 計算嘴巴張開比例:嘴巴距離 / 兩眼距離 + 4. 在 OpenCV 畫面上即時顯示比例與嘴巴開合狀態 (OPEN / CLOSED) +""" +import cv2 +import mediapipe as mp +import math +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker initialized successfully.") +except Exception as e: + print(f"Error: Failed to initialize detector: {e}") + sys.exit() + +# 特徵點索引定義 +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 + +# 嘴巴張開的比例閾值 (超過代表張開) +THRESHOLD = 0.35 + +print("Press 'q' in the window to exit.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + break + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + + # 影像處理 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 【TODO 1】取得這 4 個特徵點在畫面中的 (x, y) 像素座標,並存在 pts 字典中 + pts = {} + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM]: + pt = face_lms[idx] + pts[idx] = (int(pt.x * img_w), int(pt.y * img_h)) + + + # 【TODO 2】計算兩眼外角距離 (做為基準比例尺) + p_lx, p_ly = pts[LEFT_EYE_OUTER] + p_rx, p_ry = pts[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + + if base_dist > 0: + # 【TODO 3】計算嘴巴張開的實際像素距離,並除以眼距算出「嘴巴張開比例 (mouth_ratio)」 + p_top_x, p_top_y = pts[LIP_TOP] + p_bot_x, p_bot_y = pts[LIP_BOTTOM] + mouth_dist = math.hypot(p_bot_x - p_top_x, p_bot_y - p_top_y) + mouth_ratio = mouth_dist / base_dist + + + # 【TODO 4】判斷嘴巴狀態是否大於 THRESHOLD,並在畫面上繪製量測輔助線 + # 提示:用 cv2.line 畫出眼部基準線 (藍色) 和嘴巴測量線 (橘色) + is_open = mouth_ratio > THRESHOLD + cv2.line(img, (p_lx, p_ly), (p_rx, p_ry), (255, 0, 0), 2) # 藍色眼線 + cv2.line(img, (p_top_x, p_top_y), (p_bot_x, p_bot_y), (0, 165, 255), 2) # 橘色嘴線 + + + # 畫出特徵點小黃圓圈 + for idx, pos in pts.items(): + cv2.circle(img, pos, 5, (0, 255, 255), -1) + + # 顯示比例與開合狀態文字 + cv2.putText(img, f"Mouth Ratio: {mouth_ratio:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + status_text = "OPEN" if is_open else "CLOSED" + status_color = (0, 255, 0) if is_open else (0, 0, 255) + cv2.putText(img, f"State: {status_text}", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, status_color, 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + cv2.imshow("Mouth Ratio Detector - Press 'q' to quit", img) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226&\351\225\267\346\242\235\345\234\226/d1_3_visualization.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226&\351\225\267\346\242\235\345\234\226/d1_3_visualization.py" new file mode 100644 index 0000000000000000000000000000000000000000..bb859e055006fd0a9c2ff2c1abe8439663e1b68d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226&\351\225\267\346\242\235\345\234\226/d1_3_visualization.py" @@ -0,0 +1,100 @@ +# d1_3_visualization.py +# Day 1: 資料科學與數據視覺化 - Matplotlib 繪圖基礎(學員填空) +# cd examples/Day1/student && python3 d1_3_visualization.py +# 參考答案:../Teacher/d1_3_visualization.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + + +def load_csv(filename="taipei_weather_2026.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + + # + # + path = os.path.join(os.path.dirname(__file__), filename) + + if os.path.exists(path): + + return pd.read_csv(path) + else: + print("找不到CSV檔") + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 2 列 1 行 (2x1) 的子圖畫布,尺寸大小為寬 10 英吋、高 8 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(2, 1, figsize = (10, 8)) + +# 1. 繪製折線圖 (Line Plot) - 每日溫度 +# === 【學員填空練習點 3:繪製每日溫度折線圖】 === +ax1.plot(df['Date'], df['Humidity'], marker= 's' , color='orange', label= '濕度(%)', linewidth=2) +ax1.plot(df['Date'], df['Temperature'], marker= 'o' , color='crimson', label= '溫度(C)', linewidth=2) +ax1.set_title ("台北觀測站美日溫度趨勢圖", fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") +ax1.set_ylabel("數值") +ax1.grid(True, linestyle='--', alpha=0.5) +ax1.legend() +ax1.tick_params(axis= 'x', rotation=45) +ax1.plot(df['Date'], df['Humidity'], marker= 's' , color='orange', label= '濕度(%)', linewidth=2) + + + + +ax2.bar(df['Date'], df['Rainfall'], color='skyblue', edgecolor='royalblue', label='降雨量(mm)') +ax2.set_title("台北觀測站每日降雨量分佈", fontsize=14, fontweight='bold') +ax2.set_xlabel("日期") +ax2.set_ylabel('降雨量(mm)') +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +ax2.legend() +ax2.tick_params(axis= 'x', rotation=45) + + + + +# 1. 設定參數:帶有圓點 marker='o',顏色 color='crimson',圖例 label='溫度 (°C)',線寬 linewidth=2 +# 2. 使用 ax1.set_title 設定標題 "台北觀測站每日溫度趨勢圖" +# 3. 設定 ax1.set_xlabel("日期") 與 ax1.set_ylabel("數值") +# 4. 設定網格 ax1.grid(True, linestyle='--', alpha=0.5) +# 5. 呼叫 ax1.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax1.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax1 的溫度折線圖繪製: + + +# 2. 繪製條形圖 (Bar Plot) - 每日降雨量 +# === 【學員填空練習點 4:繪製每日降雨量長條圖】 === +# TODO: 1. 在 ax2 畫布上以 ax2.bar() 繪製長條圖,X軸傳入 df['Date'],Y軸傳入 df['Rainfall'] +# 設定參數:顏色 color='skyblue',外框顏色 edgecolor='royalblue',圖例 label='降雨量 (mm)' +# 2. 使用 ax2.set_title 設定標題 "台北觀測站每日降雨量分佈" +# 3. 設定 ax2.set_xlabel("日期") 與 ax2.set_ylabel("降雨量 (mm)") +# 4. 設定 Y 軸網格 ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +# 5. 呼叫 ax2.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax2.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax2 的降雨量長條圖繪製: + + +plt.tight_layout() # 自動調整間距,避免文字重疊 + + + +plt.savefig("taipei_weather_2026.png", dpi=300) +plt.show() +# === 【學員填空練習點 5:圖表自動儲存與顯示】 === +# TODO: 1. 呼叫 plt.savefig 將畫布儲存為檔名 "taipei_weather_2026.png",並設定高畫質 dpi=300 +# (⚠️ 注意:savefig 必須在 show() 之前呼叫) +# 2. 呼叫 plt.show() 將圖表視窗顯示在畫面上 +# +# 請在下方完成圖表存檔與顯示: diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226&\351\225\267\346\242\235\345\234\226/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226&\351\225\267\346\242\235\345\234\226/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226&\351\225\267\346\242\235\345\234\226/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" new file mode 100644 index 0000000000000000000000000000000000000000..0a3e03d883fa3431f9506fa0d645699ce83935c5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" @@ -0,0 +1,69 @@ +# d3_1_opencv_basic.py +# Day 3: 電腦視覺與影像辨識 - OpenCV 影像處理基礎 (學員練習版) +# 練習指引:依序找到程式碼中的 TODO 1 到 TODO 5,將其下方的程式碼「解除註釋(刪除行首的 # )」並執行,觀察影像處理管線中每一步的變化。 + +import cv2 +import numpy as np + +# 1. 影像讀取 (Read) +img = cv2.imread("S.jpg") +if img is None: + print("[錯誤] 無法讀取 jpg") + exit(1) + +# 預設灰階影像為原始彩色影像,避免未解開註釋時程式出錯 +gray = img + +# === 【重要觀念 TODO 1:灰階轉換 (Grayscale Conversion)】 === +# 觀念重點:大多數電腦視覺演算法在處理單通道(黑白)影像時速度更快。 +# 將 BGR 彩色圖像轉換為單通道灰階圖像,可以減少 2/3 的資料量,且能過濾掉無用的顏色雜訊。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察變為灰階後的影像。 +gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + +# 預設模糊影像為灰階影像,避免未解開註釋時程式出錯 +blurred = gray + +# === 【重要觀念 TODO 2:高斯模糊 (Gaussian Blur)】 === +# 觀念重點:高斯模糊是透過像素加權平均來平滑影像,主要目的是「消除噪點和細微反光」。 +# 這可以避免後續邊緣偵測時把細微的噪點誤判為輪廓,讓偵測出來的邊緣更加平滑。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察影像邊緣的柔焦效果。 +blurred = cv2.GaussianBlur(gray, (5, 5), 0) + +# 預設邊緣影像為全黑畫布,避免未解開註釋時程式出錯 +edges = np.zeros(img.shape[:2], dtype=np.uint8) + +# === 【重要觀念 TODO 3:Canny 邊緣偵測 (Canny Edge Detection)】 === +# 觀念重點:Canny 演算法會找出影像中明暗變化劇烈的分界線。它使用雙閥值法: +# 高於高閥值(150)的 100% 判定為邊緣;介於高低閥值(50~150)之間的若連接強邊緣則保留;其餘捨棄。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察提取出的白色線條輪廓。 +edges = cv2.Canny(blurred, 10,11 ) + +# 預設無偵測到輪廓,且繪製圖像為原始圖像,避免未解開註釋時程式出錯 +contours = [] +contour_img = img.copy() + +# === 【重要觀念 TODO 4:尋找與繪製輪廓 (Find & Draw Contours)】 === +# 觀念重點:`findContours` 會在二值化(邊緣)影像中尋找相連的邊界,並建立幾何結構。 +# `drawContours` 則會將這些幾何結構繪製到指定的影像上(此處使用綠色框繪製於原始彩色影像)。 +# 請解開下方所有註釋 (刪除行首的 #) 並執行,觀察圖形外框被綠線框起來的結果。 +contours, hierarchy = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) +print(f"在影像中偵測到 {len(contours)} 個獨立輪廓特徵。") +contour_img = img.copy() +cv2.drawContours(contour_img, contours, -1, (0, 255, 0), 2) + +# === 【重要觀念 TODO 5:視窗顯示與釋放 (Window Display)】 === +# 觀念重點:使用 cv2.imshow 顯示各步驟處理結果,並用 waitKey(0) 讓程式停下等待按鍵, +# 最後使用 destroyAllWindows 釋放視窗資源。 +# 請解開下方所有註釋 (刪除行首的 #),以觀看影像處理的成果! +print("\n正在顯示影像處理結果(請點擊影像視窗並按下任意鍵關閉)...") +cv2.imshow("Original Shapes", img) +cv2.imshow("Grayscale", gray) +cv2.imshow("Canny Edges", edges) +cv2.imshow("Contours Output", contour_img) + +success_gray = cv2.imwrite("S.gray.jpg", gray) +success_edges = cv2.imwrite("S.edges.jpg", edges) +success_contour = cv2.imwrite("S.contour.jpg", contour_img) + +cv2.waitKey(0) +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex1_books.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex1_books.py" new file mode 100644 index 0000000000000000000000000000000000000000..e3722d718a124cba097e1ef8b0d5385d238534e5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex1_books.py" @@ -0,0 +1,94 @@ +# d4_1_ex1_books.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex1_books.py +# 參考答案:../d4_1_ex1_books.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:專供爬蟲練習的圖書商城沙盒 +URL = "http://books.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至圖書沙盒網站: {URL} ...") + +try: + # 發送 GET 請求取得網頁 HTML + response = requests.get(URL, headers=HEADERS, timeout=10) + + # 檢查 HTTP 狀態碼是否為 200 (OK),非 200 時引發 HTTPError 異常 + response.raise_for_status() + + # 解決編碼問題:強制將解碼設定為 utf-8 (避免 requests 預設 ISO-8859-1 解碼導致英鎊符號亂碼且在 Windows 印出時崩潰) + response.encoding = response.apparent_encoding + + print("連線成功!開始解析書籍資料...\n") + + # 初始化 BeautifulSoup 解析器 + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有書籍的容器。每本書都被包在一個
標籤中,且該標籤的 class 固定為 "product_pod" + books = soup.find_all("article", class_="product_pod") + + print(f"成功在本頁找到 {len(books)} 本書籍:") + print("=" * 70) + + for idx, book in enumerate(books, 1): + # === 【學員填空練習點 3:提取書籍名稱與連結】 === + # TODO: + # 1. 尋找

底下的 標籤 + # 2. 取得 的 "title" 屬性(因為畫面上長度限制,.get_text() 可能會被 ellipsis 截斷) + # 3. 取得 的 "href" 屬性並使用 urljoin 拼接成完整網址 + title_link = book.find("h3").find("a") + title = title_link.get("title") + + relative_url = title_link.get("href") + detail_url = urljoin(URL, relative_url) + + # === 【學員填空練習點 4:提取書籍價格】 === + # 提示:為了避免 Windows (CP950) 終端機不支援英鎊符號 '£' 而編碼崩潰,請加上 .replace("£", "GBP") 將其替換 + # 觀察:價格存放在 class 為 "price_color" 的

標籤中,格式如 "£51.77" + # 技巧:將英鎊符號 '£' 替換為 'GBP',避免在 Windows (CP950) 終端機印出時因不支援該字元而崩潰 + price_text = book.find("p", class_="price_color").get_text().replace("£", "GBP") + + # === 【學員填空練習點 5:提取評價星等 (Rating)】 === + # TODO: + # 1. 尋找 class 為 "star-rating" 的

標籤 + # 2. 取得該標籤的 "class" 屬性值(這會是一個串列,如 ['star-rating', 'Three']) + # 3. 提取該串列的第二個元素作為星等字串 (若存在) + + rating_tag = book.find("p", class_="star-rating") + rating_classes = rating_tag.get("class") + rating = "無" + if len(rating_classes) > 1: + rating = rating_classes[1] # 提取例如 'Three' (代表三顆星) + + # === 【學員填空練習點 6:提取庫存狀態】 === + # TODO: 尋找 class 為 "instock availability" 的

標籤,取得文字後去除首尾空白 (.strip()) + stock_text = book.find("p", class_="instock availability").get_text().strip() + + # 印出解析結果 + print(f"【編號 {idx}】") + print(f"[書名] {title}") + print(f"[價格] {price_text}") + print(f"[評價] {rating} 星") + print(f"[狀態] {stock_text}") + print(f"[連結] {detail_url}") + print("-" * 70) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] HTTP 連線異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex2_ptt.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex2_ptt.py" new file mode 100644 index 0000000000000000000000000000000000000000..4c0ad885893936a77642ecaba8a3ac6c53c8e88a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex2_ptt.py" @@ -0,0 +1,81 @@ +# d4_1_ex2_ptt.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex2_ptt.py +# 參考答案:../d4_1_ex2_ptt.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:PTT Python 討論板首頁 +URL = "https://www.ptt.cc/bbs/Python/index.html" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則 PTT 會拒絕請求並回傳 403 錯誤 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在抓取 PTT Python 板最新文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章區塊】 === + # TODO: 觀察網頁 HTML 結構,找出所有包裹文章資訊的

標籤(其 class 名稱為何?) + posts = soup.find_all("div", class_="r-ent") + + print(f"\n成功取得 {len(posts)} 筆文章項目(含已刪除與置底公告):") + print("=" * 80) + + for idx, post in enumerate(posts, 1): + # === 【學員填空練習點 3:提取文章標題與連結並防錯 (Null Check)】 === + # TODO: + # 1. 尋找 class 為 "title" 的
標籤 + # 2. 在該
底下尋找 標籤 (即文章連結) + # 3. ⚠️ 重要思考:若文章已刪除,將不會有 標籤。請撰寫條件判斷: + # - 若 標籤存在:提取其文字,並使用 urljoin 拼接 href 屬性得到完整網址。 + # - 若 標籤不存在:直接將 title_div 的文字作為標題,並將網址設為 "無 (文章已刪除)"。 + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get("title") + relative_url = title_link.get("href") + post_url = urljoin(URL, relative_url) + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + + # === 【學員填空練習點 4:提取推文數】 === + # TODO: 尋找 class 為 "nrec" 的
標籤。 + # 技巧:使用 .get_text().strip() 取得文字。若無文字(0 推),請將變數預設為 "0"。 + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + + # === 【學員填空練習點 5:提取作者與發文日期】 === + # TODO: 分別尋找 class 為 "author" 與 "date" 的
標籤,並提取純文字(記得使用 .strip() 去除空白) + author = post.find("div", class_="author").get_text().strip() + date = post.find("div", class_="date").get_text().strip() + + # 輸出單篇文章資訊 + print(f"[{idx}] 推文數: {nrec:3s} | 發文日期: {date} | 作者: {author}") + print(f" 標題: {title}") + print(f" 網址: {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] PTT 伺服器拒絕或找不到網頁:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex3_ithelp.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex3_ithelp.py" new file mode 100644 index 0000000000000000000000000000000000000000..c0299624ce527f07bab03c30f85184640b1ba40c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex3_ithelp.py" @@ -0,0 +1,98 @@ +# d4_1_ex3_ithelp.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex3_ithelp.py +# 參考答案:../d4_1_ex3_ithelp.py + +import requests +from bs4 import BeautifulSoup + +# 目標網站:iT 邦幫忙技術文章首頁 +URL = "https://ithelp.ithome.com.tw/articles?tab=tech" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則可能被伺服器拒絕連線 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至 iT 邦幫忙並抓取技術文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + print("連線成功!解析文章列表中...\n") + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章卡片區塊】 === + # TODO: 尋找所有包裹文章資訊的
標籤(其 class 為 "qa-list") + articles = soup.find_all("div", class_="qa-list") + + print(f"成功取得前 {len(articles)} 篇熱門技術文章:") + print("=" * 80) + + for idx, art in enumerate(articles, 1): + # === 【學員填空練習點 3:提取標題與超連結】 === + # TODO: + # 1. 在 art 中尋找 class 為 "qa-list__title-link" 的 標籤 + # 2. 提取其純文字內容並去除首尾空白 (.strip()),作為標題 + # 3. 提取其 "href" 屬性,作為文章連結 + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + # === 【學員填空練習點 4:提取作者名稱】 === + # TODO: + # 1. 尋找 class 為 "qa-list__info" 的
標籤 + # 2. 在該
下尋找 class 為 "qa-list__info-link" 的 標籤 + # 3. 提取其文字內容作為作者名稱 + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + # === 【學員填空練習點 5:提取發文時間】 === + # TODO: 尋找 class 為 "qa-list__info-time" 的 標籤,並提取其 "title" 屬性值(因 title 屬性中包含完整時間秒數) + time_link = art.find("div", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + # === 【學員填空練習點 6:利用 List 索引提取多項數據】 === + # TODO: + # 1. 使用 find_all 找出卡片中所有 class 為 "qa-condition" 的 標籤 + # 2. 當抓取到的數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + conditions = art.find_all("a", class_="qa-condition") + + likes = "0" + comments = "0" + views = "0" + + if len(conditions) >= 3: + # 請在此處填入利用 conditions[0]、conditions[1]、conditions[2] 尋找 span 的代碼 + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + # 格式化輸出 + print(f"【排名 {idx}】") + print(f"[標題] {title}") + print(f"[作者] {author} | [時間] {post_time}") + print(f"[數據] Like: {likes} | 回應: {comments} | 瀏覽/訂閱: {views}") + print(f"[連結] {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] 伺服器回應異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_web_scraping.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_web_scraping.py" new file mode 100644 index 0000000000000000000000000000000000000000..26792f288eaa50ea59f82a077985c21c5578d14d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_web_scraping.py" @@ -0,0 +1,68 @@ +# d4_1_web_scraping.py — Day4 學員填空 +# cd examples/Day4/student && python3 d4_1_web_scraping.py +# 參考答案:../d4_1_web_scraping.py + +import requests +from bs4 import BeautifulSoup + +# 本範例抓取專供網頁爬蟲教學使用的模擬沙盒網站:Quotes to Scrape +URL = "http://quotes.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線並抓取網站內容: {URL} ...") + +try: + # === 【學員填空練習點 2:連線請求與下載網頁】 === + # TODO: 1. 使用 requests.get 發送 GET 請求,帶入 headers=HEADERS 與設定 timeout=5 + # TODO: 2. 判斷 response.status_code 是否為 200 (下載成功) + response = requests.get(URL, headers=HEADERS, timeout=5) + + if response and response.status_code == 200: + print("網頁下載成功!開始解析 HTML 結構...") + + # === 【學員填空練習點 3:初始化 BeautifulSoup 解析器】 === + # TODO: 使用 BeautifulSoup 解析 response.text,初始化 html.parser + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 4:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有名言與作者的 div 標籤(其 class 為 'quote') + quote_divs = soup.find_all("div", class_="quote") + + print(f"\n成功解析!本日精選名言如下(共 {len(quote_divs)} 筆):") + print("=" * 60) + + # === 【學員填空練習點 5:迴圈遍歷提取內容與防錯】 === + # TODO: + # 1. 遍歷 quote_divs,並使用 enumerate 取得索引 idx(從 1 開始) + # 2. 用 .find() 尋找 span 且 class 為 'text' 的名言元素 + # 3. 用 .find() 尋找 small 且 class 為 'author' 的作者元素 + # 4. 進行 None 防錯處理,確保兩者皆非空值後,使用 .get_text() 提取純文字並印出 + for idx, quote_div in enumerate(quote_divs, 1): + text_el = quote_div.find("span", class_="text") + author_el = quote_div.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + + print(f"[{idx}] \"{text}\"") + print(f" -- by {author}\n") + + print("=" * 60) + print("提醒:請尊重 robots.txt 與網站使用條款,勿對目標網站發送高頻請求。") + else: + status = response.status_code if response else "未知" + print(f"[錯誤] 無法抓取網頁,伺服器回應狀態碼:{status}") + +except requests.exceptions.RequestException as e: + print(f"[錯誤] 連線失敗:{e}") + print("請確認電腦的網路連線是否通暢。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220and AI \345\240\261\345\221\212/d4_2_agent_task_chain.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220and AI \345\240\261\345\221\212/d4_2_agent_task_chain.py" new file mode 100644 index 0000000000000000000000000000000000000000..d41fb36c97b21ad5bc3efe39c3c6a67761af0ffd --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220and AI \345\240\261\345\221\212/d4_2_agent_task_chain.py" @@ -0,0 +1,141 @@ +# d4_2_agent_task_chain.py +# Day 4: 自動化任務鏈 (Task Chain / 簡化版 Agent 工作流) +# 說明:本範例是「固定管線」——步驟由我們寫死,不是模型自主 function calling。 +# 目的是讓學生先理解:工具 + LLM + 記憶狀態 如何串成可交付的自動化流程。 + +import os +import requests +from bs4 import BeautifulSoup + +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + + +def plan_steps(topic): + """規劃階段:對學生展示任務拆解(固定計畫,非 LLM 自主規劃)。""" + steps = [ + f"1. 使用爬蟲工具擷取主題「{topic}」相關名言", + "2. 將原始文字作為 context 送入 LLM 摘要與翻譯", + "3. 將 Markdown 報告寫入本機檔案", + ] + print("[Planning] 任務計畫:") + for s in steps: + print(" ", s) + return steps + + +def tool_scrape_topic_quotes(topic): + """ + 工具 A:抓取 Quotes to Scrape 的標籤頁面內容。 + 例如 topic = 'love', 'life', 'inspirational' 等。 + """ + url = f"http://quotes.toscrape.com/tag/{topic}/" + print(f"[Tool] 執行網頁資料檢索。目標網址: {url}") + try: + response = requests.get(url, headers=HEADERS, timeout=5) + if response.status_code == 200: + soup = BeautifulSoup(response.text, "html.parser") + quote_divs = soup.find_all("div", class_="quote") + results = [] + for q in quote_divs: + text_el = q.find("span", class_="text") + author_el = q.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + results.append(f"名言: {text} | 作者: {author}") + return "\n".join(results) if results else "找不到與此主題相關的內容。" + return f"網頁讀取錯誤,代碼: {response.status_code}" + except Exception as e: + return f"工具執行異常: {e}" + + +def tool_save_report(filename, content): + """工具 B:將報告寫入本機檔案。""" + with open(filename, "w", encoding="utf-8") as f: + f.write(content) + return filename + + +def run_task_chain(topic): + print("=" * 60) + print(f"啟動自動化任務鏈。目標:整理與分析關於『{topic}』的文獻報告") + print("(模式:固定 Pipeline,非自主 Tool-Calling Agent)") + print("=" * 60) + + # 步驟 0: 規劃(教學展示) + plan_steps(topic) + + # 步驟 1: 呼叫檢索工具 + raw_data = tool_scrape_topic_quotes(topic) + print(f"\n[步驟 1] 檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 原始數據預覽 ---") + preview = raw_data[:200] + ("..." if len(raw_data) > 200 else "") + print(preview) + print("--------------------") + + # 工作記憶:保存中間狀態 + memory = {"topic": topic, "raw_data": raw_data, "report": ""} + + # 步驟 2: LLM 分析(或 Mock 降級) + if not api_key: + print("\n[步驟 2] 未偵測到 API Key。啟動模擬生成中...") + report_content = ( + f"# 模擬關於《{topic}》的名人名言分析報告\n\n" + f"## 一、 數據檢索摘要\n" + f"本次任務共檢索到數筆關於 {topic} 的語錄。以下為代表性文獻:\n" + f"- 模擬數據:\"Love is all, love is new.\" -- John Lennon\n\n" + f"## 二、 核心思想分析\n" + f"本機模擬分析指出,此主題的語錄主要著眼於人與人之間的連結與心靈啟發。\n" + f"*(注意:此為 Mock 離線生成報告,設定 GEMINI_API_KEY 以啟用 Gemini 真實生成。)*\n" + ) + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行深度分析與排版...") + try: + import google.generativeai as genai # 僅雲端模式需要 + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(GEMINI_MODEL) + + prompt = ( + f"請幫我分析以下關於『{topic}』的原始英文名言數據,並用繁體中文撰寫一份 Markdown 報告。\n" + f"報告必須包含三個部分:\n" + f"1. 導言:說明這個主題的核心意義。\n" + f"2. 名言分析:挑選原始資料中 2-3 句具代表性的句子進行翻譯與深度賞析。\n" + f"3. 結論:對學生的啟發與建議。\n\n" + f"=== 原始數據 ===\n" + f"{raw_data}" + ) + + response = model.generate_content(prompt) + report_content = response.text + print("[步驟 2] 雲端分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e}。轉為模擬輸出。") + report_content = f"# 分析失敗\n\n錯誤詳情:{e}\n" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"agent_report_{topic}.md" + try: + saved = tool_save_report(output_filename, report_content) + print(f"\n[步驟 3] 任務報告生成成功!已儲存為檔案: {saved}") + print("=" * 60) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + + +if __name__ == "__main__": + # 可更換為: 'love', 'life', 'inspirational', 'humor' 等 + test_topic = "love" + run_task_chain(test_topic) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220and AI \345\240\261\345\221\212/d4_2_ex2_crawler_ai.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220and AI \345\240\261\345\221\212/d4_2_ex2_crawler_ai.py" new file mode 100644 index 0000000000000000000000000000000000000000..bb63b174f7029aa21e37a7ca700113e8ee252ba9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\347\210\254\350\237\262\345\210\206\346\236\220and AI \345\240\261\345\221\212/d4_2_ex2_crawler_ai.py" @@ -0,0 +1,231 @@ +# d4_2_ex2_crawler_ai.py +# Day 4: 爬蟲與 AI 分析任務鏈 (學生填空版) +# 執行方式:cd examples/Day4/student && python d4_2_ex2_crawler_ai.py + +import os +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin +from google import genai + +# 設定預設使用的 Gemini 模型 +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +def plan_steps(source_type): + """規劃階段:展示任務拆解流程。""" + steps = [ + f"步驟 1: 啟動爬蟲工具擷取『{source_type.upper()}』熱門文章與輿情數據", + "步驟 2: 將爬取資料作為 Context,呼叫 Gemini 進行輿情趨勢與熱門話題分析", + "步驟 3: 自動將 AI 生成的繁體中文分析報告以 UTF-8 編碼存為 Markdown 檔案" + ] + print("\n[Planning] 任務計畫已啟動:") + for s in steps: + print(f" -> {s}") + return steps + +def tool_scrape_ptt(): + """工具 A:抓取 PTT Python 板最新文章資訊。""" + url = "https://www.ptt.cc/bbs/NBA/index.html" + print(f"\n[Tool] 正在爬取 PTT Python 板文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + posts = soup.find_all("div", class_="r-ent") + + results = [] + for post in posts: + + + # === 【學員填空練習點 1:PTT 文章標題與連結提取及防錯 (Null Check)】 === + # TODO 1: 若文章已被作者或板主刪除,title_div 底下將會「沒有 標籤」 + # 1. 請在 title_div 底下尋找 標籤 (指派給 title_link 變數) + # 2. 撰寫條件判斷:若 title_link 存在,提取其純文字並用 urljoin 拼接成完整網址。 + # 若不存在,直接將 title_div 的純文字作為 title,且將連結 post_url 設為 "無 (文章已刪除)"。 + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get_text().strip() + relative_url = title_link.get("href") + post_url = urljoin(url, relative_url) if relative_url else "無 (文章連結缺失)" + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + if not nrec: + nrec = "0" + + author = post.find("div", class_="author").get_text().strip() if post.find("div", class_="author") else "未知作者" + date = post.find("div", class_="date").get_text().strip() if post.find("div", class_="date") else "未知日期" + + results.append(f"標題: {title} | 作者: {author} | 日期: {date} | 推文數: {nrec} | 網址: {post_url}") + + return "\n".join(results) if results else "找不到任何文章項目。" + except Exception as e: + return f"[PTT 爬蟲錯誤] 執行異常: {e}" + +def tool_scrape_ithelp(): + """工具 B:抓取 iT 邦幫忙熱門技術文章。""" + url = "https://ithelp.ithome.com.tw/articles?tab=tech" + print(f"\n[Tool] 正在爬取 iT 邦幫忙技術文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + articles = soup.find_all("div", class_="qa-list") + + results = [] + for art in articles: + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + time_link = art.find("a", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + conditions = art.find_all("a", class_="qa-condition") + likes, comments, views = "0", "0", "0" + + # === 【學員填空練習點 2:利用 List 索引提取多項數據】 === + # TODO 2: 當抓取到的 conditions 數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + if len(conditions) >= 3: + # 請在下方填入提取 Like、回應與瀏覽數的代碼: + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + results.append( + f"標題: {title} | 作者: {author} | 時間: {post_time} | " + f"Like: {likes} | 回應: {comments} | 瀏覽: {views} | 網址: {post_url}" + ) + + return "\n".join(results) if results else "找不到任何技術文章。" + except Exception as e: + return f"[iT邦幫忙爬蟲錯誤] 執行異常: {e}" + +def tool_save_report(filename, content): + """工具 C:將報告以 UTF-8 編碼寫入本機檔案。""" + print(f"\n[Tool] 正在將分析報告儲存至檔案: {filename} ...") + # === 【學員填空練習點 3:寫入檔案編碼處理】 === + # TODO 3: 請使用 with open 開啟檔案,以 "utf-8" 編碼寫入 content,並回傳 filename。 + # 提示:必須傳入 encoding="utf-8" 以避免 Windows 系統的中文亂碼 + + # 請在此處實作 TODO 6 ... + with open(filename, "w", encoding="utf-8") as f: + f.write(content) + return filename + +def run_analysis_chain(source_type): + print("=" * 70) + print(f"啟動自動化輿情任務鏈。目標來源:『{source_type.upper()}』數據分析") + print("=" * 70) + + # 步驟 0: 展示規劃 + plan_steps(source_type) + + # 步驟 1: 執行對應的爬蟲工具 + if source_type.lower() == "ptt": + raw_data = tool_scrape_ptt() + elif source_type.lower() == "ithelp": + raw_data = tool_scrape_ithelp() + else: + print("[錯誤] 未支援的數據來源。僅支援 'ptt' 或 'ithelp'") + return + + print(f"\n[步驟 1] 資料檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 數據預覽 (前 200 字) ---") + print(raw_data[:200] + ("..." if len(raw_data) > 200 else "")) + print("----------------------------") + + # === 【學員填空練習點 3:任務鏈工作記憶與狀態維護】 === + # TODO 3: 請建立名為 memory 的字典 (dict),包含三個鍵值: + # "source" 指向來源 source_type、"raw_data" 指向原始爬取資料 raw_data、"report" 設為空字串 "" 用以儲存後續 AI 報告。 + memory = { + "source": source_type, + "raw_data": raw_data, + "report":"" + } + + # 步驟 2: AI 分析 (或 Mock 降級防禦) + if not api_key: + print("\n[步驟 2] 未偵測到環境變數 GEMINI_API_KEY,自動啟動 Mock 離線降級機制...") + # === 【學員填空練習點 5:Mock 降級防衛機制】 === + # TODO 5: 請設計一段模擬的 Markdown 格式分析報告字串,指派給 report_content。 + # 報告內容至少需要包含標題與數據摘要,避免程式在無 Key 時報錯中斷。 + report_content = ( + f"#模擬輿論分析報告 - 數據來源:{source_type.upper()}\n\n" + ) + print("[步驟 2] Mock 報告模擬生成完畢。") + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行數據分析與排版...") + try: + # === 【學員填空練習點 4:新版 google-genai SDK 初始化與調用】 === + # TODO 4: + # 1. 呼叫 genai.Client 並傳入 api_key 來初始化 client 物件。 + # 2. 使用 client.models.generate_content 發送生成請求。 + # 傳入參數 model=GEMINI_MODEL,contents 則為準備好的 Prompt。 + client = genai.Client(api_key=api_key) + prompt = ( + f"請幫我分析以下來自 {source_type.upper()} 的最新網頁抓取文章列表,並用繁體中文撰寫一份 NBA 的交易資訊。\n" + f"報告必須包含以下部分:\n" + f"1. 輿情概述:說明目前誰被交易。\n" + f"2. 熱門話題:挑選原始資料中 2-3 個最具代表性或高關注度 (例如 Like/推文數多) 的標題,分析大家在討論什麼熱文交易案。\n" + f"3. 奪冠熱門:分析奪冠熱門有哪些球隊。\n" + f"4. 最佳新秀: 誰會是最佳新秀\n\n" + f"=== 原始爬蟲數據 ===\n" + f"{raw_data}" + ) + + # 請在此處實作 TODO 4 ... + response = client.models.generate_content( + model=GEMINI_MODEL, + contents=prompt, + ) + report_content = response.text if hasattr(response, "text") else str(response) + + print("[步驟 2] 雲端 Gemini 分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e},自動轉為錯誤備份輸出。") + report_content = f"# 輿情分析失敗\n\n錯誤詳情:{e}\n\n=== 原始數據 ===\n{raw_data}" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"ai_analysis_{source_type}.md" + try: + saved_file = tool_save_report(output_filename, memory["report"]) + print(f"\n[步驟 3] 任務報告生成成功!已儲存至: {saved_file}") + print("=" * 70) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + +if __name__ == "__main__": + # 可以切換為 "ptt" 或 "ithelp" 進行測試 + run_analysis_chain("ptt") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\207\2113d/face_mesh_connections.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\207\2113d/face_mesh_connections.py" new file mode 100644 index 0000000000000000000000000000000000000000..d44fb79be4bbfa929f876c2b0a22dc3e659492ae --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\207\2113d/face_mesh_connections.py" @@ -0,0 +1,500 @@ +# Copyright 2021 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe FaceMesh connections.""" + +FACEMESH_LIPS = frozenset([(61, 146), (146, 91), (91, 181), (181, 84), (84, 17), + (17, 314), (314, 405), (405, 321), (321, 375), + (375, 291), (61, 185), (185, 40), (40, 39), (39, 37), + (37, 0), (0, 267), + (267, 269), (269, 270), (270, 409), (409, 291), + (78, 95), (95, 88), (88, 178), (178, 87), (87, 14), + (14, 317), (317, 402), (402, 318), (318, 324), + (324, 308), (78, 191), (191, 80), (80, 81), (81, 82), + (82, 13), (13, 312), (312, 311), (311, 310), + (310, 415), (415, 308)]) + +FACEMESH_LEFT_EYE = frozenset([(263, 249), (249, 390), (390, 373), (373, 374), + (374, 380), (380, 381), (381, 382), (382, 362), + (263, 466), (466, 388), (388, 387), (387, 386), + (386, 385), (385, 384), (384, 398), (398, 362)]) + +FACEMESH_LEFT_IRIS = frozenset([(474, 475), (475, 476), (476, 477), + (477, 474)]) + +FACEMESH_LEFT_EYEBROW = frozenset([(276, 283), (283, 282), (282, 295), + (295, 285), (300, 293), (293, 334), + (334, 296), (296, 336)]) + +FACEMESH_RIGHT_EYE = frozenset([(33, 7), (7, 163), (163, 144), (144, 145), + (145, 153), (153, 154), (154, 155), (155, 133), + (33, 246), (246, 161), (161, 160), (160, 159), + (159, 158), (158, 157), (157, 173), (173, 133)]) + +FACEMESH_RIGHT_EYEBROW = frozenset([(46, 53), (53, 52), (52, 65), (65, 55), + (70, 63), (63, 105), (105, 66), (66, 107)]) + +FACEMESH_RIGHT_IRIS = frozenset([(469, 470), (470, 471), (471, 472), + (472, 469)]) + +FACEMESH_FACE_OVAL = frozenset([(10, 338), (338, 297), (297, 332), (332, 284), + (284, 251), (251, 389), (389, 356), (356, 454), + (454, 323), (323, 361), (361, 288), (288, 397), + (397, 365), (365, 379), (379, 378), (378, 400), + (400, 377), (377, 152), (152, 148), (148, 176), + (176, 149), (149, 150), (150, 136), (136, 172), + (172, 58), (58, 132), (132, 93), (93, 234), + (234, 127), (127, 162), (162, 21), (21, 54), + (54, 103), (103, 67), (67, 109), (109, 10)]) + +FACEMESH_NOSE = frozenset([(168, 6), (6, 197), (197, 195), (195, 5), + (5, 4), (4, 1), (1, 19), (19, 94), (94, 2), (98, 97), + (97, 2), (2, 326), (326, 327), (327, 294), + (294, 278), (278, 344), (344, 440), (440, 275), + (275, 4), (4, 45), (45, 220), (220, 115), (115, 48), + (48, 64), (64, 98)]) + +FACEMESH_CONTOURS = frozenset().union(*[ + FACEMESH_LIPS, FACEMESH_LEFT_EYE, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYE, + FACEMESH_RIGHT_EYEBROW, FACEMESH_FACE_OVAL +]) + +FACEMESH_IRISES = frozenset().union(*[FACEMESH_LEFT_IRIS, FACEMESH_RIGHT_IRIS]) + +FACEMESH_TESSELATION = frozenset([ + (127, 34), (34, 139), (139, 127), (11, 0), (0, 37), (37, 11), + (232, 231), (231, 120), (120, 232), (72, 37), (37, 39), (39, 72), + (128, 121), (121, 47), (47, 128), (232, 121), (121, 128), (128, 232), + (104, 69), (69, 67), (67, 104), (175, 171), (171, 148), (148, 175), + (118, 50), (50, 101), (101, 118), (73, 39), (39, 40), (40, 73), + (9, 151), (151, 108), (108, 9), (48, 115), (115, 131), (131, 48), + (194, 204), (204, 211), (211, 194), (74, 40), (40, 185), (185, 74), + (80, 42), (42, 183), (183, 80), (40, 92), (92, 186), (186, 40), + (230, 229), (229, 118), (118, 230), (202, 212), (212, 214), (214, 202), + (83, 18), (18, 17), (17, 83), (76, 61), (61, 146), (146, 76), + (160, 29), (29, 30), (30, 160), (56, 157), (157, 173), (173, 56), + (106, 204), (204, 194), (194, 106), (135, 214), (214, 192), (192, 135), + (203, 165), (165, 98), (98, 203), (21, 71), (71, 68), (68, 21), + (51, 45), (45, 4), (4, 51), (144, 24), (24, 23), (23, 144), + (77, 146), (146, 91), (91, 77), (205, 50), (50, 187), (187, 205), + (201, 200), (200, 18), (18, 201), (91, 106), (106, 182), (182, 91), + (90, 91), (91, 181), (181, 90), (85, 84), (84, 17), (17, 85), + (206, 203), (203, 36), (36, 206), (148, 171), (171, 140), (140, 148), + (92, 40), (40, 39), (39, 92), (193, 189), (189, 244), (244, 193), + (159, 158), (158, 28), (28, 159), (247, 246), (246, 161), (161, 247), + (236, 3), (3, 196), (196, 236), (54, 68), (68, 104), (104, 54), + (193, 168), (168, 8), (8, 193), (117, 228), (228, 31), (31, 117), + (189, 193), (193, 55), (55, 189), (98, 97), (97, 99), (99, 98), + (126, 47), (47, 100), (100, 126), (166, 79), (79, 218), (218, 166), + (155, 154), (154, 26), (26, 155), (209, 49), (49, 131), (131, 209), + (135, 136), (136, 150), (150, 135), (47, 126), (126, 217), (217, 47), + (223, 52), (52, 53), (53, 223), (45, 51), (51, 134), (134, 45), + (211, 170), (170, 140), (140, 211), (67, 69), (69, 108), (108, 67), + (43, 106), (106, 91), (91, 43), (230, 119), (119, 120), (120, 230), + (226, 130), (130, 247), (247, 226), (63, 53), (53, 52), (52, 63), + (238, 20), (20, 242), (242, 238), (46, 70), (70, 156), (156, 46), + (78, 62), (62, 96), (96, 78), (46, 53), (53, 63), (63, 46), + (143, 34), (34, 227), (227, 143), (123, 117), (117, 111), (111, 123), + (44, 125), (125, 19), (19, 44), (236, 134), (134, 51), (51, 236), + (216, 206), (206, 205), (205, 216), (154, 153), (153, 22), (22, 154), + (39, 37), (37, 167), (167, 39), (200, 201), (201, 208), (208, 200), + (36, 142), (142, 100), (100, 36), (57, 212), (212, 202), (202, 57), + (20, 60), (60, 99), (99, 20), (28, 158), (158, 157), (157, 28), + (35, 226), (226, 113), (113, 35), (160, 159), (159, 27), (27, 160), + (204, 202), (202, 210), (210, 204), (113, 225), (225, 46), (46, 113), + (43, 202), (202, 204), (204, 43), (62, 76), (76, 77), (77, 62), + (137, 123), (123, 116), (116, 137), (41, 38), (38, 72), (72, 41), + (203, 129), (129, 142), (142, 203), (64, 98), (98, 240), (240, 64), + (49, 102), (102, 64), (64, 49), (41, 73), (73, 74), (74, 41), + (212, 216), (216, 207), (207, 212), (42, 74), (74, 184), (184, 42), + (169, 170), (170, 211), (211, 169), (170, 149), (149, 176), (176, 170), + (105, 66), (66, 69), (69, 105), (122, 6), (6, 168), (168, 122), + (123, 147), (147, 187), (187, 123), (96, 77), (77, 90), (90, 96), + (65, 55), (55, 107), (107, 65), (89, 90), (90, 180), (180, 89), + (101, 100), (100, 120), (120, 101), (63, 105), (105, 104), (104, 63), + (93, 137), (137, 227), (227, 93), (15, 86), (86, 85), (85, 15), + (129, 102), (102, 49), (49, 129), (14, 87), (87, 86), (86, 14), + (55, 8), (8, 9), (9, 55), (100, 47), (47, 121), (121, 100), + (145, 23), (23, 22), (22, 145), (88, 89), (89, 179), (179, 88), + (6, 122), (122, 196), (196, 6), (88, 95), (95, 96), (96, 88), + (138, 172), (172, 136), (136, 138), (215, 58), (58, 172), (172, 215), + (115, 48), (48, 219), (219, 115), (42, 80), (80, 81), (81, 42), + (195, 3), (3, 51), (51, 195), (43, 146), (146, 61), (61, 43), + (171, 175), (175, 199), (199, 171), (81, 82), (82, 38), (38, 81), + (53, 46), (46, 225), (225, 53), (144, 163), (163, 110), (110, 144), + (52, 65), (65, 66), (66, 52), (229, 228), (228, 117), (117, 229), + (34, 127), (127, 234), (234, 34), (107, 108), (108, 69), (69, 107), + (109, 108), (108, 151), (151, 109), (48, 64), (64, 235), (235, 48), + (62, 78), (78, 191), (191, 62), (129, 209), (209, 126), (126, 129), + (111, 35), (35, 143), (143, 111), (117, 123), (123, 50), (50, 117), + (222, 65), (65, 52), (52, 222), (19, 125), (125, 141), (141, 19), + (221, 55), (55, 65), (65, 221), (3, 195), (195, 197), (197, 3), + (25, 7), (7, 33), (33, 25), (220, 237), (237, 44), (44, 220), + (70, 71), (71, 139), (139, 70), (122, 193), (193, 245), (245, 122), + (247, 130), (130, 33), (33, 247), (71, 21), (21, 162), (162, 71), + (170, 169), (169, 150), (150, 170), (188, 174), (174, 196), (196, 188), + (216, 186), (186, 92), (92, 216), (2, 97), (97, 167), (167, 2), + (141, 125), (125, 241), (241, 141), (164, 167), (167, 37), (37, 164), + (72, 38), (38, 12), (12, 72), (38, 82), (82, 13), (13, 38), + (63, 68), (68, 71), (71, 63), (226, 35), (35, 111), (111, 226), + (101, 50), (50, 205), (205, 101), (206, 92), (92, 165), (165, 206), + (209, 198), (198, 217), (217, 209), (165, 167), (167, 97), (97, 165), + (220, 115), (115, 218), (218, 220), (133, 112), (112, 243), (243, 133), + (239, 238), (238, 241), (241, 239), (214, 135), (135, 169), (169, 214), + (190, 173), (173, 133), (133, 190), (171, 208), (208, 32), (32, 171), + (125, 44), (44, 237), (237, 125), (86, 87), (87, 178), (178, 86), + (85, 86), (86, 179), (179, 85), (84, 85), (85, 180), (180, 84), + (83, 84), (84, 181), (181, 83), (201, 83), (83, 182), (182, 201), + (137, 93), (93, 132), (132, 137), (76, 62), (62, 183), (183, 76), + (61, 76), (76, 184), (184, 61), (57, 61), (61, 185), (185, 57), + (212, 57), (57, 186), (186, 212), (214, 207), (207, 187), (187, 214), + (34, 143), (143, 156), (156, 34), (79, 239), (239, 237), (237, 79), + (123, 137), (137, 177), (177, 123), (44, 1), (1, 4), (4, 44), + (201, 194), (194, 32), (32, 201), (64, 102), (102, 129), (129, 64), + (213, 215), (215, 138), (138, 213), (59, 166), (166, 219), (219, 59), + (242, 99), (99, 97), (97, 242), (2, 94), (94, 141), (141, 2), + (75, 59), (59, 235), (235, 75), (24, 110), (110, 228), (228, 24), + (25, 130), (130, 226), (226, 25), (23, 24), (24, 229), (229, 23), + (22, 23), (23, 230), (230, 22), (26, 22), (22, 231), (231, 26), + (112, 26), (26, 232), (232, 112), (189, 190), (190, 243), (243, 189), + (221, 56), (56, 190), (190, 221), (28, 56), (56, 221), (221, 28), + (27, 28), (28, 222), (222, 27), (29, 27), (27, 223), (223, 29), + (30, 29), (29, 224), (224, 30), (247, 30), (30, 225), (225, 247), + (238, 79), (79, 20), (20, 238), (166, 59), (59, 75), (75, 166), + (60, 75), (75, 240), (240, 60), (147, 177), (177, 215), (215, 147), + (20, 79), (79, 166), (166, 20), (187, 147), (147, 213), (213, 187), + (112, 233), (233, 244), (244, 112), (233, 128), (128, 245), (245, 233), + (128, 114), (114, 188), (188, 128), (114, 217), (217, 174), (174, 114), + (131, 115), (115, 220), (220, 131), (217, 198), (198, 236), (236, 217), + (198, 131), (131, 134), (134, 198), (177, 132), (132, 58), (58, 177), + (143, 35), (35, 124), (124, 143), (110, 163), (163, 7), (7, 110), + (228, 110), (110, 25), (25, 228), (356, 389), (389, 368), (368, 356), + (11, 302), (302, 267), (267, 11), (452, 350), (350, 349), (349, 452), + (302, 303), (303, 269), (269, 302), (357, 343), (343, 277), (277, 357), + (452, 453), (453, 357), (357, 452), (333, 332), (332, 297), (297, 333), + (175, 152), (152, 377), (377, 175), (347, 348), (348, 330), (330, 347), + (303, 304), (304, 270), (270, 303), (9, 336), (336, 337), (337, 9), + (278, 279), (279, 360), (360, 278), (418, 262), (262, 431), (431, 418), + (304, 408), (408, 409), (409, 304), (310, 415), (415, 407), (407, 310), + (270, 409), (409, 410), (410, 270), (450, 348), (348, 347), (347, 450), + (422, 430), (430, 434), (434, 422), (313, 314), (314, 17), (17, 313), + (306, 307), (307, 375), (375, 306), (387, 388), (388, 260), (260, 387), + (286, 414), (414, 398), (398, 286), (335, 406), (406, 418), (418, 335), + (364, 367), (367, 416), (416, 364), (423, 358), (358, 327), (327, 423), + (251, 284), (284, 298), (298, 251), (281, 5), (5, 4), (4, 281), + (373, 374), (374, 253), (253, 373), (307, 320), (320, 321), (321, 307), + (425, 427), (427, 411), (411, 425), (421, 313), (313, 18), (18, 421), + (321, 405), (405, 406), (406, 321), (320, 404), (404, 405), (405, 320), + (315, 16), (16, 17), (17, 315), (426, 425), (425, 266), (266, 426), + (377, 400), (400, 369), (369, 377), (322, 391), (391, 269), (269, 322), + (417, 465), (465, 464), (464, 417), (386, 257), (257, 258), (258, 386), + (466, 260), (260, 388), (388, 466), (456, 399), (399, 419), (419, 456), + (284, 332), (332, 333), (333, 284), (417, 285), (285, 8), (8, 417), + (346, 340), (340, 261), (261, 346), (413, 441), (441, 285), (285, 413), + (327, 460), (460, 328), (328, 327), (355, 371), (371, 329), (329, 355), + (392, 439), (439, 438), (438, 392), (382, 341), (341, 256), (256, 382), + (429, 420), (420, 360), (360, 429), (364, 394), (394, 379), (379, 364), + (277, 343), (343, 437), (437, 277), (443, 444), (444, 283), (283, 443), + (275, 440), (440, 363), (363, 275), (431, 262), (262, 369), (369, 431), + (297, 338), (338, 337), (337, 297), (273, 375), (375, 321), (321, 273), + (450, 451), (451, 349), (349, 450), (446, 342), (342, 467), (467, 446), + (293, 334), (334, 282), (282, 293), (458, 461), (461, 462), (462, 458), + (276, 353), (353, 383), (383, 276), (308, 324), (324, 325), (325, 308), + (276, 300), (300, 293), (293, 276), (372, 345), (345, 447), (447, 372), + (352, 345), (345, 340), (340, 352), (274, 1), (1, 19), (19, 274), + (456, 248), (248, 281), (281, 456), (436, 427), (427, 425), (425, 436), + (381, 256), (256, 252), (252, 381), (269, 391), (391, 393), (393, 269), + (200, 199), (199, 428), (428, 200), (266, 330), (330, 329), (329, 266), + (287, 273), (273, 422), (422, 287), (250, 462), (462, 328), (328, 250), + (258, 286), (286, 384), (384, 258), (265, 353), (353, 342), (342, 265), + (387, 259), (259, 257), (257, 387), (424, 431), (431, 430), (430, 424), + (342, 353), (353, 276), (276, 342), (273, 335), (335, 424), (424, 273), + (292, 325), (325, 307), (307, 292), (366, 447), (447, 345), (345, 366), + (271, 303), (303, 302), (302, 271), (423, 266), (266, 371), (371, 423), + (294, 455), (455, 460), (460, 294), (279, 278), (278, 294), (294, 279), + (271, 272), (272, 304), (304, 271), (432, 434), (434, 427), (427, 432), + (272, 407), (407, 408), (408, 272), (394, 430), (430, 431), (431, 394), + (395, 369), (369, 400), (400, 395), (334, 333), (333, 299), (299, 334), + (351, 417), (417, 168), (168, 351), (352, 280), (280, 411), (411, 352), + (325, 319), (319, 320), (320, 325), (295, 296), (296, 336), (336, 295), + (319, 403), (403, 404), (404, 319), (330, 348), (348, 349), (349, 330), + (293, 298), (298, 333), (333, 293), (323, 454), (454, 447), (447, 323), + (15, 16), (16, 315), (315, 15), (358, 429), (429, 279), (279, 358), + (14, 15), (15, 316), (316, 14), (285, 336), (336, 9), (9, 285), + (329, 349), (349, 350), (350, 329), (374, 380), (380, 252), (252, 374), + (318, 402), (402, 403), (403, 318), (6, 197), (197, 419), (419, 6), + (318, 319), (319, 325), (325, 318), (367, 364), (364, 365), (365, 367), + (435, 367), (367, 397), (397, 435), (344, 438), (438, 439), (439, 344), + (272, 271), (271, 311), (311, 272), (195, 5), (5, 281), (281, 195), + (273, 287), (287, 291), (291, 273), (396, 428), (428, 199), (199, 396), + (311, 271), (271, 268), (268, 311), (283, 444), (444, 445), (445, 283), + (373, 254), (254, 339), (339, 373), (282, 334), (334, 296), (296, 282), + (449, 347), (347, 346), (346, 449), (264, 447), (447, 454), (454, 264), + (336, 296), (296, 299), (299, 336), (338, 10), (10, 151), (151, 338), + (278, 439), (439, 455), (455, 278), (292, 407), (407, 415), (415, 292), + (358, 371), (371, 355), (355, 358), (340, 345), (345, 372), (372, 340), + (346, 347), (347, 280), (280, 346), (442, 443), (443, 282), (282, 442), + (19, 94), (94, 370), (370, 19), (441, 442), (442, 295), (295, 441), + (248, 419), (419, 197), (197, 248), (263, 255), (255, 359), (359, 263), + (440, 275), (275, 274), (274, 440), (300, 383), (383, 368), (368, 300), + (351, 412), (412, 465), (465, 351), (263, 467), (467, 466), (466, 263), + (301, 368), (368, 389), (389, 301), (395, 378), (378, 379), (379, 395), + (412, 351), (351, 419), (419, 412), (436, 426), (426, 322), (322, 436), + (2, 164), (164, 393), (393, 2), (370, 462), (462, 461), (461, 370), + (164, 0), (0, 267), (267, 164), (302, 11), (11, 12), (12, 302), + (268, 12), (12, 13), (13, 268), (293, 300), (300, 301), (301, 293), + (446, 261), (261, 340), (340, 446), (330, 266), (266, 425), (425, 330), + (426, 423), (423, 391), (391, 426), (429, 355), (355, 437), (437, 429), + (391, 327), (327, 326), (326, 391), (440, 457), (457, 438), (438, 440), + (341, 382), (382, 362), (362, 341), (459, 457), (457, 461), (461, 459), + (434, 430), (430, 394), (394, 434), (414, 463), (463, 362), (362, 414), + (396, 369), (369, 262), (262, 396), (354, 461), (461, 457), (457, 354), + (316, 403), (403, 402), (402, 316), (315, 404), (404, 403), (403, 315), + (314, 405), (405, 404), (404, 314), (313, 406), (406, 405), (405, 313), + (421, 418), (418, 406), (406, 421), (366, 401), (401, 361), (361, 366), + (306, 408), (408, 407), (407, 306), (291, 409), (409, 408), (408, 291), + (287, 410), (410, 409), (409, 287), (432, 436), (436, 410), (410, 432), + (434, 416), (416, 411), (411, 434), (264, 368), (368, 383), (383, 264), + (309, 438), (438, 457), (457, 309), (352, 376), (376, 401), (401, 352), + (274, 275), (275, 4), (4, 274), (421, 428), (428, 262), (262, 421), + (294, 327), (327, 358), (358, 294), (433, 416), (416, 367), (367, 433), + (289, 455), (455, 439), (439, 289), (462, 370), (370, 326), (326, 462), + (2, 326), (326, 370), (370, 2), (305, 460), (460, 455), (455, 305), + (254, 449), (449, 448), (448, 254), (255, 261), (261, 446), (446, 255), + (253, 450), (450, 449), (449, 253), (252, 451), (451, 450), (450, 252), + (256, 452), (452, 451), (451, 256), (341, 453), (453, 452), (452, 341), + (413, 464), (464, 463), (463, 413), (441, 413), (413, 414), (414, 441), + (258, 442), (442, 441), (441, 258), (257, 443), (443, 442), (442, 257), + (259, 444), (444, 443), (443, 259), (260, 445), (445, 444), (444, 260), + (467, 342), (342, 445), (445, 467), (459, 458), (458, 250), (250, 459), + (289, 392), (392, 290), (290, 289), (290, 328), (328, 460), (460, 290), + (376, 433), (433, 435), (435, 376), (250, 290), (290, 392), (392, 250), + (411, 416), (416, 433), (433, 411), (341, 463), (463, 464), (464, 341), + (453, 464), (464, 465), (465, 453), (357, 465), (465, 412), (412, 357), + (343, 412), (412, 399), (399, 343), (360, 363), (363, 440), (440, 360), + (437, 399), (399, 456), (456, 437), (420, 456), (456, 363), (363, 420), + (401, 435), (435, 288), (288, 401), (372, 383), (383, 353), (353, 372), + (339, 255), (255, 249), (249, 339), (448, 261), (261, 255), (255, 448), + (133, 243), (243, 190), (190, 133), (133, 155), (155, 112), (112, 133), + (33, 246), (246, 247), (247, 33), (33, 130), (130, 25), (25, 33), + (398, 384), (384, 286), (286, 398), (362, 398), (398, 414), (414, 362), + (362, 463), (463, 341), (341, 362), (263, 359), (359, 467), (467, 263), + (263, 249), (249, 255), (255, 263), (466, 467), (467, 260), (260, 466), + (75, 60), (60, 166), (166, 75), (238, 239), (239, 79), (79, 238), + (162, 127), (127, 139), (139, 162), (72, 11), (11, 37), (37, 72), + (121, 232), (232, 120), (120, 121), (73, 72), (72, 39), (39, 73), + (114, 128), (128, 47), (47, 114), (233, 232), (232, 128), (128, 233), + (103, 104), (104, 67), (67, 103), (152, 175), (175, 148), (148, 152), + (119, 118), (118, 101), (101, 119), (74, 73), (73, 40), (40, 74), + (107, 9), (9, 108), (108, 107), (49, 48), (48, 131), (131, 49), + (32, 194), (194, 211), (211, 32), (184, 74), (74, 185), (185, 184), + (191, 80), (80, 183), (183, 191), (185, 40), (40, 186), (186, 185), + (119, 230), (230, 118), (118, 119), (210, 202), (202, 214), (214, 210), + (84, 83), (83, 17), (17, 84), (77, 76), (76, 146), (146, 77), + (161, 160), (160, 30), (30, 161), (190, 56), (56, 173), (173, 190), + (182, 106), (106, 194), (194, 182), (138, 135), (135, 192), (192, 138), + (129, 203), (203, 98), (98, 129), (54, 21), (21, 68), (68, 54), + (5, 51), (51, 4), (4, 5), (145, 144), (144, 23), (23, 145), + (90, 77), (77, 91), (91, 90), (207, 205), (205, 187), (187, 207), + (83, 201), (201, 18), (18, 83), (181, 91), (91, 182), (182, 181), + (180, 90), (90, 181), (181, 180), (16, 85), (85, 17), (17, 16), + (205, 206), (206, 36), (36, 205), (176, 148), (148, 140), (140, 176), + (165, 92), (92, 39), (39, 165), (245, 193), (193, 244), (244, 245), + (27, 159), (159, 28), (28, 27), (30, 247), (247, 161), (161, 30), + (174, 236), (236, 196), (196, 174), (103, 54), (54, 104), (104, 103), + (55, 193), (193, 8), (8, 55), (111, 117), (117, 31), (31, 111), + (221, 189), (189, 55), (55, 221), (240, 98), (98, 99), (99, 240), + (142, 126), (126, 100), (100, 142), (219, 166), (166, 218), (218, 219), + (112, 155), (155, 26), (26, 112), (198, 209), (209, 131), (131, 198), + (169, 135), (135, 150), (150, 169), (114, 47), (47, 217), (217, 114), + (224, 223), (223, 53), (53, 224), (220, 45), (45, 134), (134, 220), + (32, 211), (211, 140), (140, 32), (109, 67), (67, 108), (108, 109), + (146, 43), (43, 91), (91, 146), (231, 230), (230, 120), (120, 231), + (113, 226), (226, 247), (247, 113), (105, 63), (63, 52), (52, 105), + (241, 238), (238, 242), (242, 241), (124, 46), (46, 156), (156, 124), + (95, 78), (78, 96), (96, 95), (70, 46), (46, 63), (63, 70), + (116, 143), (143, 227), (227, 116), (116, 123), (123, 111), (111, 116), + (1, 44), (44, 19), (19, 1), (3, 236), (236, 51), (51, 3), + (207, 216), (216, 205), (205, 207), (26, 154), (154, 22), (22, 26), + (165, 39), (39, 167), (167, 165), (199, 200), (200, 208), (208, 199), + (101, 36), (36, 100), (100, 101), (43, 57), (57, 202), (202, 43), + (242, 20), (20, 99), (99, 242), (56, 28), (28, 157), (157, 56), + (124, 35), (35, 113), (113, 124), (29, 160), (160, 27), (27, 29), + (211, 204), (204, 210), (210, 211), (124, 113), (113, 46), (46, 124), + (106, 43), (43, 204), (204, 106), (96, 62), (62, 77), (77, 96), + (227, 137), (137, 116), (116, 227), (73, 41), (41, 72), (72, 73), + (36, 203), (203, 142), (142, 36), (235, 64), (64, 240), (240, 235), + (48, 49), (49, 64), (64, 48), (42, 41), (41, 74), (74, 42), + (214, 212), (212, 207), (207, 214), (183, 42), (42, 184), (184, 183), + (210, 169), (169, 211), (211, 210), (140, 170), (170, 176), (176, 140), + (104, 105), (105, 69), (69, 104), (193, 122), (122, 168), (168, 193), + (50, 123), (123, 187), (187, 50), (89, 96), (96, 90), (90, 89), + (66, 65), (65, 107), (107, 66), (179, 89), (89, 180), (180, 179), + (119, 101), (101, 120), (120, 119), (68, 63), (63, 104), (104, 68), + (234, 93), (93, 227), (227, 234), (16, 15), (15, 85), (85, 16), + (209, 129), (129, 49), (49, 209), (15, 14), (14, 86), (86, 15), + (107, 55), (55, 9), (9, 107), (120, 100), (100, 121), (121, 120), + (153, 145), (145, 22), (22, 153), (178, 88), (88, 179), (179, 178), + (197, 6), (6, 196), (196, 197), (89, 88), (88, 96), (96, 89), + (135, 138), (138, 136), (136, 135), (138, 215), (215, 172), (172, 138), + (218, 115), (115, 219), (219, 218), (41, 42), (42, 81), (81, 41), + (5, 195), (195, 51), (51, 5), (57, 43), (43, 61), (61, 57), + (208, 171), (171, 199), (199, 208), (41, 81), (81, 38), (38, 41), + (224, 53), (53, 225), (225, 224), (24, 144), (144, 110), (110, 24), + (105, 52), (52, 66), (66, 105), (118, 229), (229, 117), (117, 118), + (227, 34), (34, 234), (234, 227), (66, 107), (107, 69), (69, 66), + (10, 109), (109, 151), (151, 10), (219, 48), (48, 235), (235, 219), + (183, 62), (62, 191), (191, 183), (142, 129), (129, 126), (126, 142), + (116, 111), (111, 143), (143, 116), (118, 117), (117, 50), (50, 118), + (223, 222), (222, 52), (52, 223), (94, 19), (19, 141), (141, 94), + (222, 221), (221, 65), (65, 222), (196, 3), (3, 197), (197, 196), + (45, 220), (220, 44), (44, 45), (156, 70), (70, 139), (139, 156), + (188, 122), (122, 245), (245, 188), (139, 71), (71, 162), (162, 139), + (149, 170), (170, 150), (150, 149), (122, 188), (188, 196), (196, 122), + (206, 216), (216, 92), (92, 206), (164, 2), (2, 167), (167, 164), + (242, 141), (141, 241), (241, 242), (0, 164), (164, 37), (37, 0), + (11, 72), (72, 12), (12, 11), (12, 38), (38, 13), (13, 12), + (70, 63), (63, 71), (71, 70), (31, 226), (226, 111), (111, 31), + (36, 101), (101, 205), (205, 36), (203, 206), (206, 165), (165, 203), + (126, 209), (209, 217), (217, 126), (98, 165), (165, 97), (97, 98), + (237, 220), (220, 218), (218, 237), (237, 239), (239, 241), (241, 237), + (210, 214), (214, 169), (169, 210), (140, 171), (171, 32), (32, 140), + (241, 125), (125, 237), (237, 241), (179, 86), (86, 178), (178, 179), + (180, 85), (85, 179), (179, 180), (181, 84), (84, 180), (180, 181), + (182, 83), (83, 181), (181, 182), (194, 201), (201, 182), (182, 194), + (177, 137), (137, 132), (132, 177), (184, 76), (76, 183), (183, 184), + (185, 61), (61, 184), (184, 185), (186, 57), (57, 185), (185, 186), + (216, 212), (212, 186), (186, 216), (192, 214), (214, 187), (187, 192), + (139, 34), (34, 156), (156, 139), (218, 79), (79, 237), (237, 218), + (147, 123), (123, 177), (177, 147), (45, 44), (44, 4), (4, 45), + (208, 201), (201, 32), (32, 208), (98, 64), (64, 129), (129, 98), + (192, 213), (213, 138), (138, 192), (235, 59), (59, 219), (219, 235), + (141, 242), (242, 97), (97, 141), (97, 2), (2, 141), (141, 97), + (240, 75), (75, 235), (235, 240), (229, 24), (24, 228), (228, 229), + (31, 25), (25, 226), (226, 31), (230, 23), (23, 229), (229, 230), + (231, 22), (22, 230), (230, 231), (232, 26), (26, 231), (231, 232), + (233, 112), (112, 232), (232, 233), (244, 189), (189, 243), (243, 244), + (189, 221), (221, 190), (190, 189), (222, 28), (28, 221), (221, 222), + (223, 27), (27, 222), (222, 223), (224, 29), (29, 223), (223, 224), + (225, 30), (30, 224), (224, 225), (113, 247), (247, 225), (225, 113), + (99, 60), (60, 240), (240, 99), (213, 147), (147, 215), (215, 213), + (60, 20), (20, 166), (166, 60), (192, 187), (187, 213), (213, 192), + (243, 112), (112, 244), (244, 243), (244, 233), (233, 245), (245, 244), + (245, 128), (128, 188), (188, 245), (188, 114), (114, 174), (174, 188), + (134, 131), (131, 220), (220, 134), (174, 217), (217, 236), (236, 174), + (236, 198), (198, 134), (134, 236), (215, 177), (177, 58), (58, 215), + (156, 143), (143, 124), (124, 156), (25, 110), (110, 7), (7, 25), + (31, 228), (228, 25), (25, 31), (264, 356), (356, 368), (368, 264), + (0, 11), (11, 267), (267, 0), (451, 452), (452, 349), (349, 451), + (267, 302), (302, 269), (269, 267), (350, 357), (357, 277), (277, 350), + (350, 452), (452, 357), (357, 350), (299, 333), (333, 297), (297, 299), + (396, 175), (175, 377), (377, 396), (280, 347), (347, 330), (330, 280), + (269, 303), (303, 270), (270, 269), (151, 9), (9, 337), (337, 151), + (344, 278), (278, 360), (360, 344), (424, 418), (418, 431), (431, 424), + (270, 304), (304, 409), (409, 270), (272, 310), (310, 407), (407, 272), + (322, 270), (270, 410), (410, 322), (449, 450), (450, 347), (347, 449), + (432, 422), (422, 434), (434, 432), (18, 313), (313, 17), (17, 18), + (291, 306), (306, 375), (375, 291), (259, 387), (387, 260), (260, 259), + (424, 335), (335, 418), (418, 424), (434, 364), (364, 416), (416, 434), + (391, 423), (423, 327), (327, 391), (301, 251), (251, 298), (298, 301), + (275, 281), (281, 4), (4, 275), (254, 373), (373, 253), (253, 254), + (375, 307), (307, 321), (321, 375), (280, 425), (425, 411), (411, 280), + (200, 421), (421, 18), (18, 200), (335, 321), (321, 406), (406, 335), + (321, 320), (320, 405), (405, 321), (314, 315), (315, 17), (17, 314), + (423, 426), (426, 266), (266, 423), (396, 377), (377, 369), (369, 396), + (270, 322), (322, 269), (269, 270), (413, 417), (417, 464), (464, 413), + (385, 386), (386, 258), (258, 385), (248, 456), (456, 419), (419, 248), + (298, 284), (284, 333), (333, 298), (168, 417), (417, 8), (8, 168), + (448, 346), (346, 261), (261, 448), (417, 413), (413, 285), (285, 417), + (326, 327), (327, 328), (328, 326), (277, 355), (355, 329), (329, 277), + (309, 392), (392, 438), (438, 309), (381, 382), (382, 256), (256, 381), + (279, 429), (429, 360), (360, 279), (365, 364), (364, 379), (379, 365), + (355, 277), (277, 437), (437, 355), (282, 443), (443, 283), (283, 282), + (281, 275), (275, 363), (363, 281), (395, 431), (431, 369), (369, 395), + (299, 297), (297, 337), (337, 299), (335, 273), (273, 321), (321, 335), + (348, 450), (450, 349), (349, 348), (359, 446), (446, 467), (467, 359), + (283, 293), (293, 282), (282, 283), (250, 458), (458, 462), (462, 250), + (300, 276), (276, 383), (383, 300), (292, 308), (308, 325), (325, 292), + (283, 276), (276, 293), (293, 283), (264, 372), (372, 447), (447, 264), + (346, 352), (352, 340), (340, 346), (354, 274), (274, 19), (19, 354), + (363, 456), (456, 281), (281, 363), (426, 436), (436, 425), (425, 426), + (380, 381), (381, 252), (252, 380), (267, 269), (269, 393), (393, 267), + (421, 200), (200, 428), (428, 421), (371, 266), (266, 329), (329, 371), + (432, 287), (287, 422), (422, 432), (290, 250), (250, 328), (328, 290), + (385, 258), (258, 384), (384, 385), (446, 265), (265, 342), (342, 446), + (386, 387), (387, 257), (257, 386), (422, 424), (424, 430), (430, 422), + (445, 342), (342, 276), (276, 445), (422, 273), (273, 424), (424, 422), + (306, 292), (292, 307), (307, 306), (352, 366), (366, 345), (345, 352), + (268, 271), (271, 302), (302, 268), (358, 423), (423, 371), (371, 358), + (327, 294), (294, 460), (460, 327), (331, 279), (279, 294), (294, 331), + (303, 271), (271, 304), (304, 303), (436, 432), (432, 427), (427, 436), + (304, 272), (272, 408), (408, 304), (395, 394), (394, 431), (431, 395), + (378, 395), (395, 400), (400, 378), (296, 334), (334, 299), (299, 296), + (6, 351), (351, 168), (168, 6), (376, 352), (352, 411), (411, 376), + (307, 325), (325, 320), (320, 307), (285, 295), (295, 336), (336, 285), + (320, 319), (319, 404), (404, 320), (329, 330), (330, 349), (349, 329), + (334, 293), (293, 333), (333, 334), (366, 323), (323, 447), (447, 366), + (316, 15), (15, 315), (315, 316), (331, 358), (358, 279), (279, 331), + (317, 14), (14, 316), (316, 317), (8, 285), (285, 9), (9, 8), + (277, 329), (329, 350), (350, 277), (253, 374), (374, 252), (252, 253), + (319, 318), (318, 403), (403, 319), (351, 6), (6, 419), (419, 351), + (324, 318), (318, 325), (325, 324), (397, 367), (367, 365), (365, 397), + (288, 435), (435, 397), (397, 288), (278, 344), (344, 439), (439, 278), + (310, 272), (272, 311), (311, 310), (248, 195), (195, 281), (281, 248), + (375, 273), (273, 291), (291, 375), (175, 396), (396, 199), (199, 175), + (312, 311), (311, 268), (268, 312), (276, 283), (283, 445), (445, 276), + (390, 373), (373, 339), (339, 390), (295, 282), (282, 296), (296, 295), + (448, 449), (449, 346), (346, 448), (356, 264), (264, 454), (454, 356), + (337, 336), (336, 299), (299, 337), (337, 338), (338, 151), (151, 337), + (294, 278), (278, 455), (455, 294), (308, 292), (292, 415), (415, 308), + (429, 358), (358, 355), (355, 429), (265, 340), (340, 372), (372, 265), + (352, 346), (346, 280), (280, 352), (295, 442), (442, 282), (282, 295), + (354, 19), (19, 370), (370, 354), (285, 441), (441, 295), (295, 285), + (195, 248), (248, 197), (197, 195), (457, 440), (440, 274), (274, 457), + (301, 300), (300, 368), (368, 301), (417, 351), (351, 465), (465, 417), + (251, 301), (301, 389), (389, 251), (394, 395), (395, 379), (379, 394), + (399, 412), (412, 419), (419, 399), (410, 436), (436, 322), (322, 410), + (326, 2), (2, 393), (393, 326), (354, 370), (370, 461), (461, 354), + (393, 164), (164, 267), (267, 393), (268, 302), (302, 12), (12, 268), + (312, 268), (268, 13), (13, 312), (298, 293), (293, 301), (301, 298), + (265, 446), (446, 340), (340, 265), (280, 330), (330, 425), (425, 280), + (322, 426), (426, 391), (391, 322), (420, 429), (429, 437), (437, 420), + (393, 391), (391, 326), (326, 393), (344, 440), (440, 438), (438, 344), + (458, 459), (459, 461), (461, 458), (364, 434), (434, 394), (394, 364), + (428, 396), (396, 262), (262, 428), (274, 354), (354, 457), (457, 274), + (317, 316), (316, 402), (402, 317), (316, 315), (315, 403), (403, 316), + (315, 314), (314, 404), (404, 315), (314, 313), (313, 405), (405, 314), + (313, 421), (421, 406), (406, 313), (323, 366), (366, 361), (361, 323), + (292, 306), (306, 407), (407, 292), (306, 291), (291, 408), (408, 306), + (291, 287), (287, 409), (409, 291), (287, 432), (432, 410), (410, 287), + (427, 434), (434, 411), (411, 427), (372, 264), (264, 383), (383, 372), + (459, 309), (309, 457), (457, 459), (366, 352), (352, 401), (401, 366), + (1, 274), (274, 4), (4, 1), (418, 421), (421, 262), (262, 418), + (331, 294), (294, 358), (358, 331), (435, 433), (433, 367), (367, 435), + (392, 289), (289, 439), (439, 392), (328, 462), (462, 326), (326, 328), + (94, 2), (2, 370), (370, 94), (289, 305), (305, 455), (455, 289), + (339, 254), (254, 448), (448, 339), (359, 255), (255, 446), (446, 359), + (254, 253), (253, 449), (449, 254), (253, 252), (252, 450), (450, 253), + (252, 256), (256, 451), (451, 252), (256, 341), (341, 452), (452, 256), + (414, 413), (413, 463), (463, 414), (286, 441), (441, 414), (414, 286), + (286, 258), (258, 441), (441, 286), (258, 257), (257, 442), (442, 258), + (257, 259), (259, 443), (443, 257), (259, 260), (260, 444), (444, 259), + (260, 467), (467, 445), (445, 260), (309, 459), (459, 250), (250, 309), + (305, 289), (289, 290), (290, 305), (305, 290), (290, 460), (460, 305), + (401, 376), (376, 435), (435, 401), (309, 250), (250, 392), (392, 309), + (376, 411), (411, 433), (433, 376), (453, 341), (341, 464), (464, 453), + (357, 453), (453, 465), (465, 357), (343, 357), (357, 412), (412, 343), + (437, 343), (343, 399), (399, 437), (344, 360), (360, 440), (440, 344), + (420, 437), (437, 456), (456, 420), (360, 420), (420, 363), (363, 360), + (361, 401), (401, 288), (288, 361), (265, 372), (372, 353), (353, 265), + (390, 339), (339, 249), (249, 390), (339, 448), (448, 255), (255, 339)]) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\207\2113d/step1_facemesh.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\207\2113d/step1_facemesh.py" new file mode 100644 index 0000000000000000000000000000000000000000..5784c4c7b468e14274619427f8f8753d7441ed0b --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\207\2113d/step1_facemesh.py" @@ -0,0 +1,129 @@ +""" +Week 11 - Step 1: Face Mesh Tracking with Connections (Template 填空版) +=================================================================== +本步驟目標: + 1. 使用 OpenCV 開啟視訊鏡頭 + 2. 載入 MediaPipe 臉部特徵偵測器 (face_landmarker.task) + 3. 偵測人臉,並在臉上畫出綠色的 3D 特效網格線與黃色特徵點 +""" +import cv2 +import mediapipe as mp +import numpy as np +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the examples folder.") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker Tasks API initialized successfully.") +except Exception as e: + print(f"Error initializing detector: {e}") + sys.exit() + +# 匯入臉部網格連接點定義 +try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + from face_mesh_connections import FACEMESH_TESSELATION +except ImportError: + FACEMESH_TESSELATION = [] + +# 繪圖模式:0 = 僅畫網格線, 1 = 僅畫點點, 2 = 點線都畫 +draw_mode = 0 + +print("Press 'q' in the window to exit, or 'm' to toggle display modes.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + print("Error: Failed to read frame.") + break + + # 【TODO 1】畫面水平翻轉,並將 BGR 轉成 MediaPipe 需要的 RGB 格式 + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + + + + # 執行臉部偵測 + results = detector.detect(mp_image) + + # 【TODO 2】如果偵測到臉部,畫出 3D 特效網格線 + # 提示:取出 results.face_landmarks[0],並用迴圈巡覽 FACEMESH_TESSELATION + # 點的座標 (lm.x, lm.y) 是 0~1 的比例,需要乘上 img_w 和 img_h 換算成像素座標 + if results.face_landmarks: + face_lms = results.face_landmarks[0] + num_lms = len(face_lms) + + if draw_mode == 0 or draw_mode == 2: + for start_idx, end_idx in FACEMESH_TESSELATION: + if start_idx < num_lms and end_idx < num_lms: + lm1 = face_lms[start_idx] + lm2 = face_lms[end_idx] + pt1 = (int(lm1.x * img_w), int(lm1.y * img_h)) + pt2 = (int(lm2.x * img_w), int(lm2.y * img_h)) + cv2.line(img, pt1, pt2, (224, 224, 0), 1) # 青綠色線條 + + # 預設執行代碼 (填寫完後學生會將此替換或解除上方註解) + if results.face_landmarks: + face_lms = results.face_landmarks[0] + num_lms = len(face_lms) + + if draw_mode == 0 or draw_mode == 2: + for start_idx, end_idx in FACEMESH_TESSELATION: + if start_idx < num_lms and end_idx < num_lms: + lm1 = face_lms[start_idx] + lm2 = face_lms[end_idx] + pt1 = (int(lm1.x * img_w), int(lm1.y * img_h)) + pt2 = (int(lm2.x * img_w), int(lm2.y * img_h)) + cv2.line(img, pt1, pt2, (224, 224, 0), 1) + + # 【TODO 3】畫出臉部 468 個黃色特徵點點 + if draw_mode == 1 or draw_mode == 2: + for lm in face_lms: + cx = int(lm.x * img_w) + cy = int(lm.y * img_h) + cv2.circle(img, (cx, cy), 1, (0, 255, 255), -1) # 黃色小圓點 + + if draw_mode == 1 or draw_mode == 2: + for lm in face_lms: + cx = int(lm.x * img_w) + cy = int(lm.y * img_h) + cv2.circle(img, (cx, cy), 1, (0, 255, 255), -1) + + mode_str = "Mesh" if draw_mode == 0 else "Dots" if draw_mode == 1 else "Both" + cv2.putText(img, f"Face Detected | Mode: {mode_str}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + # 顯示操作提示文字 + cv2.putText(img, "Press 'm' to toggle mode | 'q' to quit", (10, img_h - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) + + cv2.imshow("Face Mesh Connections", img) + + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('m'): + draw_mode = (draw_mode + 1) % 3 + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\250\255\350\250\210AI\350\247\222\350\211\262and \345\240\264\346\231\257/d2_2_prompt_engineering.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\250\255\350\250\210AI\350\247\222\350\211\262and \345\240\264\346\231\257/d2_2_prompt_engineering.py" new file mode 100644 index 0000000000000000000000000000000000000000..b19f164b34db109a68c211edd92197c89052db72 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\250\255\350\250\210AI\350\247\222\350\211\262and \345\240\264\346\231\257/d2_2_prompt_engineering.py" @@ -0,0 +1,58 @@ +# d2_2_prompt_engineering.py +# Day 2: 生成式 AI 串接實戰 - Prompt 工程 (系統指令與結構化輸出) +import os +from google import genai + +# 取得 API 金鑰 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JvbUqLW-wcddeMducmta0aGE2QtcrmF4Kfd8nnpZ9mzg") +if not api_key: + print("[提示] 請輸入您的 Google AI Studio API 金鑰:") + api_key = input("API Key: ").strip() + +client = genai.Client(api_key=api_key) + +# 1. 設置系統指令 (System Instruction / System Prompt) +# TODO 1: +system_prompt = "你是國中英語老師, 你很專業但是你又是老屁孩" + +print("正在初始化英語教學助理模型...") + +# 2. Few-shot (少樣本) 引導與對話演練 +# TODO 2: +user_word = "六七" + +print(f"\n[學生輸入]: {user_word}") +prompt = f"請幫我翻譯這個詞:'{user_word}'。" + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=prompt, + config={'system_instruction': system_prompt} +) + +print("\n[AI 老師回應]:") +print(response.text) + + +# 3. 結構化 JSON 輸出 (Structured Output) 範例 +print("\n" + "="*40) +print("進階挑戰:要求模型回傳 JSON 格式") +print("="*40) + +# 我們可以在 prompt 中要求,並透過 configuration 設定 JSON 輸出格式 +# TODO 3: +json_prompt = ( + f"請將中文單字 '{user_word}' 的翻譯結果整理成 JSON 格式,包含以下三個欄位:\n" + "- 'english': 英文單字\n" + "- 'kk_phonetic': KK音標\n" + "- 'example_sentence': 適合國中生理解的英文例句與中文翻譯" + "- '用法':在什麼時機可以使用這個字" + ) + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=json_prompt, + config={"response_mime_type": "application/json"} +) +print("\n[AI 回傳的原始 JSON]:") +print(response.text) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" new file mode 100644 index 0000000000000000000000000000000000000000..367de022beea192cb18b39770669ce6608b32b0c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" @@ -0,0 +1,38 @@ +# d1_2_data_cleaning.py +# Day 1 學員填空:資料清洗(完成 TODO) +# cd examples/Day1/student && python3 d1_2_data_cleaning.py +# 參考答案:../Teacher/d1_2_data_cleaning.py + +import pandas as pd +import numpy as np + +# 與 Teacher 一致:第 5 天高溫有雨,方便篩選示範 +raw_data = { + "Date": ["2026-07-01", "2026-07-02", "2026-07-03", "2026-07-04", "2026-07-05"], + "Temperature": [28.5, np.nan, 30.2, 31.5, 30.8], + "Humidity": [82, 80, np.nan, 70, 90], + "Rainfall": [5.2, 0.0, 0.0, np.nan, 25.4], +} + +df = pd.DataFrame(raw_data) +print("=== 原始含有缺失值的數據 ===") +print(df) + +df_cleaned = df.copy() +mean_temp = df_cleaned["Temperature"].mean() +df_cleaned["Temperature"] = df_cleaned["Temperature"].fillna(mean_temp) +df_cleaned["Humidity"] = df_cleaned["Humidity"].ffill() +df_cleaned["Rainfall"] = df_cleaned["Rainfall"].fillna(0.0) + +print("\n=== 清洗與填補後的數據 ===") +print(df_cleaned) + +print("\n=== 篩選:溫度>29 且 降雨>0 ===") +high_temp_and_rain = df_cleaned[(df_cleaned["Temperature"] > 29) & (df_cleaned["Rainfall"] > 29)] +print(high_temp_and_rain) + +print("\n=== 新增 Is_Hot(溫度 >= 30)===") +df_cleaned['Is_Hot'] = df_cleaned ['Temperature'] >= 30 +print(df_cleaned) +df_cleaned['Is_rainy'] = df_cleaned ['Rainfall']> 0 +print(df_cleaned) \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\347\245\272\351\210\236/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/3d_model_face/face_mesh_connections.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/3d_model_face/face_mesh_connections.py" new file mode 100644 index 0000000000000000000000000000000000000000..d44fb79be4bbfa929f876c2b0a22dc3e659492ae --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/3d_model_face/face_mesh_connections.py" @@ -0,0 +1,500 @@ +# Copyright 2021 The MediaPipe Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""MediaPipe FaceMesh connections.""" + +FACEMESH_LIPS = frozenset([(61, 146), (146, 91), (91, 181), (181, 84), (84, 17), + (17, 314), (314, 405), (405, 321), (321, 375), + (375, 291), (61, 185), (185, 40), (40, 39), (39, 37), + (37, 0), (0, 267), + (267, 269), (269, 270), (270, 409), (409, 291), + (78, 95), (95, 88), (88, 178), (178, 87), (87, 14), + (14, 317), (317, 402), (402, 318), (318, 324), + (324, 308), (78, 191), (191, 80), (80, 81), (81, 82), + (82, 13), (13, 312), (312, 311), (311, 310), + (310, 415), (415, 308)]) + +FACEMESH_LEFT_EYE = frozenset([(263, 249), (249, 390), (390, 373), (373, 374), + (374, 380), (380, 381), (381, 382), (382, 362), + (263, 466), (466, 388), (388, 387), (387, 386), + (386, 385), (385, 384), (384, 398), (398, 362)]) + +FACEMESH_LEFT_IRIS = frozenset([(474, 475), (475, 476), (476, 477), + (477, 474)]) + +FACEMESH_LEFT_EYEBROW = frozenset([(276, 283), (283, 282), (282, 295), + (295, 285), (300, 293), (293, 334), + (334, 296), (296, 336)]) + +FACEMESH_RIGHT_EYE = frozenset([(33, 7), (7, 163), (163, 144), (144, 145), + (145, 153), (153, 154), (154, 155), (155, 133), + (33, 246), (246, 161), (161, 160), (160, 159), + (159, 158), (158, 157), (157, 173), (173, 133)]) + +FACEMESH_RIGHT_EYEBROW = frozenset([(46, 53), (53, 52), (52, 65), (65, 55), + (70, 63), (63, 105), (105, 66), (66, 107)]) + +FACEMESH_RIGHT_IRIS = frozenset([(469, 470), (470, 471), (471, 472), + (472, 469)]) + +FACEMESH_FACE_OVAL = frozenset([(10, 338), (338, 297), (297, 332), (332, 284), + (284, 251), (251, 389), (389, 356), (356, 454), + (454, 323), (323, 361), (361, 288), (288, 397), + (397, 365), (365, 379), (379, 378), (378, 400), + (400, 377), (377, 152), (152, 148), (148, 176), + (176, 149), (149, 150), (150, 136), (136, 172), + (172, 58), (58, 132), (132, 93), (93, 234), + (234, 127), (127, 162), (162, 21), (21, 54), + (54, 103), (103, 67), (67, 109), (109, 10)]) + +FACEMESH_NOSE = frozenset([(168, 6), (6, 197), (197, 195), (195, 5), + (5, 4), (4, 1), (1, 19), (19, 94), (94, 2), (98, 97), + (97, 2), (2, 326), (326, 327), (327, 294), + (294, 278), (278, 344), (344, 440), (440, 275), + (275, 4), (4, 45), (45, 220), (220, 115), (115, 48), + (48, 64), (64, 98)]) + +FACEMESH_CONTOURS = frozenset().union(*[ + FACEMESH_LIPS, FACEMESH_LEFT_EYE, FACEMESH_LEFT_EYEBROW, FACEMESH_RIGHT_EYE, + FACEMESH_RIGHT_EYEBROW, FACEMESH_FACE_OVAL +]) + +FACEMESH_IRISES = frozenset().union(*[FACEMESH_LEFT_IRIS, FACEMESH_RIGHT_IRIS]) + +FACEMESH_TESSELATION = frozenset([ + (127, 34), (34, 139), (139, 127), (11, 0), (0, 37), (37, 11), + (232, 231), (231, 120), (120, 232), (72, 37), (37, 39), (39, 72), + (128, 121), (121, 47), (47, 128), (232, 121), (121, 128), (128, 232), + (104, 69), (69, 67), (67, 104), (175, 171), (171, 148), (148, 175), + (118, 50), (50, 101), (101, 118), (73, 39), (39, 40), (40, 73), + (9, 151), (151, 108), (108, 9), (48, 115), (115, 131), (131, 48), + (194, 204), (204, 211), (211, 194), (74, 40), (40, 185), (185, 74), + (80, 42), (42, 183), (183, 80), (40, 92), (92, 186), (186, 40), + (230, 229), (229, 118), (118, 230), (202, 212), (212, 214), (214, 202), + (83, 18), (18, 17), (17, 83), (76, 61), (61, 146), (146, 76), + (160, 29), (29, 30), (30, 160), (56, 157), (157, 173), (173, 56), + (106, 204), (204, 194), (194, 106), (135, 214), (214, 192), (192, 135), + (203, 165), (165, 98), (98, 203), (21, 71), (71, 68), (68, 21), + (51, 45), (45, 4), (4, 51), (144, 24), (24, 23), (23, 144), + (77, 146), (146, 91), (91, 77), (205, 50), (50, 187), (187, 205), + (201, 200), (200, 18), (18, 201), (91, 106), (106, 182), (182, 91), + (90, 91), (91, 181), (181, 90), (85, 84), (84, 17), (17, 85), + (206, 203), (203, 36), (36, 206), (148, 171), (171, 140), (140, 148), + (92, 40), (40, 39), (39, 92), (193, 189), (189, 244), (244, 193), + (159, 158), (158, 28), (28, 159), (247, 246), (246, 161), (161, 247), + (236, 3), (3, 196), (196, 236), (54, 68), (68, 104), (104, 54), + (193, 168), (168, 8), (8, 193), (117, 228), (228, 31), (31, 117), + (189, 193), (193, 55), (55, 189), (98, 97), (97, 99), (99, 98), + (126, 47), (47, 100), (100, 126), (166, 79), (79, 218), (218, 166), + (155, 154), (154, 26), (26, 155), (209, 49), (49, 131), (131, 209), + (135, 136), (136, 150), (150, 135), (47, 126), (126, 217), (217, 47), + (223, 52), (52, 53), (53, 223), (45, 51), (51, 134), (134, 45), + (211, 170), (170, 140), (140, 211), (67, 69), (69, 108), (108, 67), + (43, 106), (106, 91), (91, 43), (230, 119), (119, 120), (120, 230), + (226, 130), (130, 247), (247, 226), (63, 53), (53, 52), (52, 63), + (238, 20), (20, 242), (242, 238), (46, 70), (70, 156), (156, 46), + (78, 62), (62, 96), (96, 78), (46, 53), (53, 63), (63, 46), + (143, 34), (34, 227), (227, 143), (123, 117), (117, 111), (111, 123), + (44, 125), (125, 19), (19, 44), (236, 134), (134, 51), (51, 236), + (216, 206), (206, 205), (205, 216), (154, 153), (153, 22), (22, 154), + (39, 37), (37, 167), (167, 39), (200, 201), (201, 208), (208, 200), + (36, 142), (142, 100), (100, 36), (57, 212), (212, 202), (202, 57), + (20, 60), (60, 99), (99, 20), (28, 158), (158, 157), (157, 28), + (35, 226), (226, 113), (113, 35), (160, 159), (159, 27), (27, 160), + (204, 202), (202, 210), (210, 204), (113, 225), (225, 46), (46, 113), + (43, 202), (202, 204), (204, 43), (62, 76), (76, 77), (77, 62), + (137, 123), (123, 116), (116, 137), (41, 38), (38, 72), (72, 41), + (203, 129), (129, 142), (142, 203), (64, 98), (98, 240), (240, 64), + (49, 102), (102, 64), (64, 49), (41, 73), (73, 74), (74, 41), + (212, 216), (216, 207), (207, 212), (42, 74), (74, 184), (184, 42), + (169, 170), (170, 211), (211, 169), (170, 149), (149, 176), (176, 170), + (105, 66), (66, 69), (69, 105), (122, 6), (6, 168), (168, 122), + (123, 147), (147, 187), (187, 123), (96, 77), (77, 90), (90, 96), + (65, 55), (55, 107), (107, 65), (89, 90), (90, 180), (180, 89), + (101, 100), (100, 120), (120, 101), (63, 105), (105, 104), (104, 63), + (93, 137), (137, 227), (227, 93), (15, 86), (86, 85), (85, 15), + (129, 102), (102, 49), (49, 129), (14, 87), (87, 86), (86, 14), + (55, 8), (8, 9), (9, 55), (100, 47), (47, 121), (121, 100), + (145, 23), (23, 22), (22, 145), (88, 89), (89, 179), (179, 88), + (6, 122), (122, 196), (196, 6), (88, 95), (95, 96), (96, 88), + (138, 172), (172, 136), (136, 138), (215, 58), (58, 172), (172, 215), + (115, 48), (48, 219), (219, 115), (42, 80), (80, 81), (81, 42), + (195, 3), (3, 51), (51, 195), (43, 146), (146, 61), (61, 43), + (171, 175), (175, 199), (199, 171), (81, 82), (82, 38), (38, 81), + (53, 46), (46, 225), (225, 53), (144, 163), (163, 110), (110, 144), + (52, 65), (65, 66), (66, 52), (229, 228), (228, 117), (117, 229), + (34, 127), (127, 234), (234, 34), (107, 108), (108, 69), (69, 107), + (109, 108), (108, 151), (151, 109), (48, 64), (64, 235), (235, 48), + (62, 78), (78, 191), (191, 62), (129, 209), (209, 126), (126, 129), + (111, 35), (35, 143), (143, 111), (117, 123), (123, 50), (50, 117), + (222, 65), (65, 52), (52, 222), (19, 125), (125, 141), (141, 19), + (221, 55), (55, 65), (65, 221), (3, 195), (195, 197), (197, 3), + (25, 7), (7, 33), (33, 25), (220, 237), (237, 44), (44, 220), + (70, 71), (71, 139), (139, 70), (122, 193), (193, 245), (245, 122), + (247, 130), (130, 33), (33, 247), (71, 21), (21, 162), (162, 71), + (170, 169), (169, 150), (150, 170), (188, 174), (174, 196), (196, 188), + (216, 186), (186, 92), (92, 216), (2, 97), (97, 167), (167, 2), + (141, 125), (125, 241), (241, 141), (164, 167), (167, 37), (37, 164), + (72, 38), (38, 12), (12, 72), (38, 82), (82, 13), (13, 38), + (63, 68), (68, 71), (71, 63), (226, 35), (35, 111), (111, 226), + (101, 50), (50, 205), (205, 101), (206, 92), (92, 165), (165, 206), + (209, 198), (198, 217), (217, 209), (165, 167), (167, 97), (97, 165), + (220, 115), (115, 218), (218, 220), (133, 112), (112, 243), (243, 133), + (239, 238), (238, 241), (241, 239), (214, 135), (135, 169), (169, 214), + (190, 173), (173, 133), (133, 190), (171, 208), (208, 32), (32, 171), + (125, 44), (44, 237), (237, 125), (86, 87), (87, 178), (178, 86), + (85, 86), (86, 179), (179, 85), (84, 85), (85, 180), (180, 84), + (83, 84), (84, 181), (181, 83), (201, 83), (83, 182), (182, 201), + (137, 93), (93, 132), (132, 137), (76, 62), (62, 183), (183, 76), + (61, 76), (76, 184), (184, 61), (57, 61), (61, 185), (185, 57), + (212, 57), (57, 186), (186, 212), (214, 207), (207, 187), (187, 214), + (34, 143), (143, 156), (156, 34), (79, 239), (239, 237), (237, 79), + (123, 137), (137, 177), (177, 123), (44, 1), (1, 4), (4, 44), + (201, 194), (194, 32), (32, 201), (64, 102), (102, 129), (129, 64), + (213, 215), (215, 138), (138, 213), (59, 166), (166, 219), (219, 59), + (242, 99), (99, 97), (97, 242), (2, 94), (94, 141), (141, 2), + (75, 59), (59, 235), (235, 75), (24, 110), (110, 228), (228, 24), + (25, 130), (130, 226), (226, 25), (23, 24), (24, 229), (229, 23), + (22, 23), (23, 230), (230, 22), (26, 22), (22, 231), (231, 26), + (112, 26), (26, 232), (232, 112), (189, 190), (190, 243), (243, 189), + (221, 56), (56, 190), (190, 221), (28, 56), (56, 221), (221, 28), + (27, 28), (28, 222), (222, 27), (29, 27), (27, 223), (223, 29), + (30, 29), (29, 224), (224, 30), (247, 30), (30, 225), (225, 247), + (238, 79), (79, 20), (20, 238), (166, 59), (59, 75), (75, 166), + (60, 75), (75, 240), (240, 60), (147, 177), (177, 215), (215, 147), + (20, 79), (79, 166), (166, 20), (187, 147), (147, 213), (213, 187), + (112, 233), (233, 244), (244, 112), (233, 128), (128, 245), (245, 233), + (128, 114), (114, 188), (188, 128), (114, 217), (217, 174), (174, 114), + (131, 115), (115, 220), (220, 131), (217, 198), (198, 236), (236, 217), + (198, 131), (131, 134), (134, 198), (177, 132), (132, 58), (58, 177), + (143, 35), (35, 124), (124, 143), (110, 163), (163, 7), (7, 110), + (228, 110), (110, 25), (25, 228), (356, 389), (389, 368), (368, 356), + (11, 302), (302, 267), (267, 11), (452, 350), (350, 349), (349, 452), + (302, 303), (303, 269), (269, 302), (357, 343), (343, 277), (277, 357), + (452, 453), (453, 357), (357, 452), (333, 332), (332, 297), (297, 333), + (175, 152), (152, 377), (377, 175), (347, 348), (348, 330), (330, 347), + (303, 304), (304, 270), (270, 303), (9, 336), (336, 337), (337, 9), + (278, 279), (279, 360), (360, 278), (418, 262), (262, 431), (431, 418), + (304, 408), (408, 409), (409, 304), (310, 415), (415, 407), (407, 310), + (270, 409), (409, 410), (410, 270), (450, 348), (348, 347), (347, 450), + (422, 430), (430, 434), (434, 422), (313, 314), (314, 17), (17, 313), + (306, 307), (307, 375), (375, 306), (387, 388), (388, 260), (260, 387), + (286, 414), (414, 398), (398, 286), (335, 406), (406, 418), (418, 335), + (364, 367), (367, 416), (416, 364), (423, 358), (358, 327), (327, 423), + (251, 284), (284, 298), (298, 251), (281, 5), (5, 4), (4, 281), + (373, 374), (374, 253), (253, 373), (307, 320), (320, 321), (321, 307), + (425, 427), (427, 411), (411, 425), (421, 313), (313, 18), (18, 421), + (321, 405), (405, 406), (406, 321), (320, 404), (404, 405), (405, 320), + (315, 16), (16, 17), (17, 315), (426, 425), (425, 266), (266, 426), + (377, 400), (400, 369), (369, 377), (322, 391), (391, 269), (269, 322), + (417, 465), (465, 464), (464, 417), (386, 257), (257, 258), (258, 386), + (466, 260), (260, 388), (388, 466), (456, 399), (399, 419), (419, 456), + (284, 332), (332, 333), (333, 284), (417, 285), (285, 8), (8, 417), + (346, 340), (340, 261), (261, 346), (413, 441), (441, 285), (285, 413), + (327, 460), (460, 328), (328, 327), (355, 371), (371, 329), (329, 355), + (392, 439), (439, 438), (438, 392), (382, 341), (341, 256), (256, 382), + (429, 420), (420, 360), (360, 429), (364, 394), (394, 379), (379, 364), + (277, 343), (343, 437), (437, 277), (443, 444), (444, 283), (283, 443), + (275, 440), (440, 363), (363, 275), (431, 262), (262, 369), (369, 431), + (297, 338), (338, 337), (337, 297), (273, 375), (375, 321), (321, 273), + (450, 451), (451, 349), (349, 450), (446, 342), (342, 467), (467, 446), + (293, 334), (334, 282), (282, 293), (458, 461), (461, 462), (462, 458), + (276, 353), (353, 383), (383, 276), (308, 324), (324, 325), (325, 308), + (276, 300), (300, 293), (293, 276), (372, 345), (345, 447), (447, 372), + (352, 345), (345, 340), (340, 352), (274, 1), (1, 19), (19, 274), + (456, 248), (248, 281), (281, 456), (436, 427), (427, 425), (425, 436), + (381, 256), (256, 252), (252, 381), (269, 391), (391, 393), (393, 269), + (200, 199), (199, 428), (428, 200), (266, 330), (330, 329), (329, 266), + (287, 273), (273, 422), (422, 287), (250, 462), (462, 328), (328, 250), + (258, 286), (286, 384), (384, 258), (265, 353), (353, 342), (342, 265), + (387, 259), (259, 257), (257, 387), (424, 431), (431, 430), (430, 424), + (342, 353), (353, 276), (276, 342), (273, 335), (335, 424), (424, 273), + (292, 325), (325, 307), (307, 292), (366, 447), (447, 345), (345, 366), + (271, 303), (303, 302), (302, 271), (423, 266), (266, 371), (371, 423), + (294, 455), (455, 460), (460, 294), (279, 278), (278, 294), (294, 279), + (271, 272), (272, 304), (304, 271), (432, 434), (434, 427), (427, 432), + (272, 407), (407, 408), (408, 272), (394, 430), (430, 431), (431, 394), + (395, 369), (369, 400), (400, 395), (334, 333), (333, 299), (299, 334), + (351, 417), (417, 168), (168, 351), (352, 280), (280, 411), (411, 352), + (325, 319), (319, 320), (320, 325), (295, 296), (296, 336), (336, 295), + (319, 403), (403, 404), (404, 319), (330, 348), (348, 349), (349, 330), + (293, 298), (298, 333), (333, 293), (323, 454), (454, 447), (447, 323), + (15, 16), (16, 315), (315, 15), (358, 429), (429, 279), (279, 358), + (14, 15), (15, 316), (316, 14), (285, 336), (336, 9), (9, 285), + (329, 349), (349, 350), (350, 329), (374, 380), (380, 252), (252, 374), + (318, 402), (402, 403), (403, 318), (6, 197), (197, 419), (419, 6), + (318, 319), (319, 325), (325, 318), (367, 364), (364, 365), (365, 367), + (435, 367), (367, 397), (397, 435), (344, 438), (438, 439), (439, 344), + (272, 271), (271, 311), (311, 272), (195, 5), (5, 281), (281, 195), + (273, 287), (287, 291), (291, 273), (396, 428), (428, 199), (199, 396), + (311, 271), (271, 268), (268, 311), (283, 444), (444, 445), (445, 283), + (373, 254), (254, 339), (339, 373), (282, 334), (334, 296), (296, 282), + (449, 347), (347, 346), (346, 449), (264, 447), (447, 454), (454, 264), + (336, 296), (296, 299), (299, 336), (338, 10), (10, 151), (151, 338), + (278, 439), (439, 455), (455, 278), (292, 407), (407, 415), (415, 292), + (358, 371), (371, 355), (355, 358), (340, 345), (345, 372), (372, 340), + (346, 347), (347, 280), (280, 346), (442, 443), (443, 282), (282, 442), + (19, 94), (94, 370), (370, 19), (441, 442), (442, 295), (295, 441), + (248, 419), (419, 197), (197, 248), (263, 255), (255, 359), (359, 263), + (440, 275), (275, 274), (274, 440), (300, 383), (383, 368), (368, 300), + (351, 412), (412, 465), (465, 351), (263, 467), (467, 466), (466, 263), + (301, 368), (368, 389), (389, 301), (395, 378), (378, 379), (379, 395), + (412, 351), (351, 419), (419, 412), (436, 426), (426, 322), (322, 436), + (2, 164), (164, 393), (393, 2), (370, 462), (462, 461), (461, 370), + (164, 0), (0, 267), (267, 164), (302, 11), (11, 12), (12, 302), + (268, 12), (12, 13), (13, 268), (293, 300), (300, 301), (301, 293), + (446, 261), (261, 340), (340, 446), (330, 266), (266, 425), (425, 330), + (426, 423), (423, 391), (391, 426), (429, 355), (355, 437), (437, 429), + (391, 327), (327, 326), (326, 391), (440, 457), (457, 438), (438, 440), + (341, 382), (382, 362), (362, 341), (459, 457), (457, 461), (461, 459), + (434, 430), (430, 394), (394, 434), (414, 463), (463, 362), (362, 414), + (396, 369), (369, 262), (262, 396), (354, 461), (461, 457), (457, 354), + (316, 403), (403, 402), (402, 316), (315, 404), (404, 403), (403, 315), + (314, 405), (405, 404), (404, 314), (313, 406), (406, 405), (405, 313), + (421, 418), (418, 406), (406, 421), (366, 401), (401, 361), (361, 366), + (306, 408), (408, 407), (407, 306), (291, 409), (409, 408), (408, 291), + (287, 410), (410, 409), (409, 287), (432, 436), (436, 410), (410, 432), + (434, 416), (416, 411), (411, 434), (264, 368), (368, 383), (383, 264), + (309, 438), (438, 457), (457, 309), (352, 376), (376, 401), (401, 352), + (274, 275), (275, 4), (4, 274), (421, 428), (428, 262), (262, 421), + (294, 327), (327, 358), (358, 294), (433, 416), (416, 367), (367, 433), + (289, 455), (455, 439), (439, 289), (462, 370), (370, 326), (326, 462), + (2, 326), (326, 370), (370, 2), (305, 460), (460, 455), (455, 305), + (254, 449), (449, 448), (448, 254), (255, 261), (261, 446), (446, 255), + (253, 450), (450, 449), (449, 253), (252, 451), (451, 450), (450, 252), + (256, 452), (452, 451), (451, 256), (341, 453), (453, 452), (452, 341), + (413, 464), (464, 463), (463, 413), (441, 413), (413, 414), (414, 441), + (258, 442), (442, 441), (441, 258), (257, 443), (443, 442), (442, 257), + (259, 444), (444, 443), (443, 259), (260, 445), (445, 444), (444, 260), + (467, 342), (342, 445), (445, 467), (459, 458), (458, 250), (250, 459), + (289, 392), (392, 290), (290, 289), (290, 328), (328, 460), (460, 290), + (376, 433), (433, 435), (435, 376), (250, 290), (290, 392), (392, 250), + (411, 416), (416, 433), (433, 411), (341, 463), (463, 464), (464, 341), + (453, 464), (464, 465), (465, 453), (357, 465), (465, 412), (412, 357), + (343, 412), (412, 399), (399, 343), (360, 363), (363, 440), (440, 360), + (437, 399), (399, 456), (456, 437), (420, 456), (456, 363), (363, 420), + (401, 435), (435, 288), (288, 401), (372, 383), (383, 353), (353, 372), + (339, 255), (255, 249), (249, 339), (448, 261), (261, 255), (255, 448), + (133, 243), (243, 190), (190, 133), (133, 155), (155, 112), (112, 133), + (33, 246), (246, 247), (247, 33), (33, 130), (130, 25), (25, 33), + (398, 384), (384, 286), (286, 398), (362, 398), (398, 414), (414, 362), + (362, 463), (463, 341), (341, 362), (263, 359), (359, 467), (467, 263), + (263, 249), (249, 255), (255, 263), (466, 467), (467, 260), (260, 466), + (75, 60), (60, 166), (166, 75), (238, 239), (239, 79), (79, 238), + (162, 127), (127, 139), (139, 162), (72, 11), (11, 37), (37, 72), + (121, 232), (232, 120), (120, 121), (73, 72), (72, 39), (39, 73), + (114, 128), (128, 47), (47, 114), (233, 232), (232, 128), (128, 233), + (103, 104), (104, 67), (67, 103), (152, 175), (175, 148), (148, 152), + (119, 118), (118, 101), (101, 119), (74, 73), (73, 40), (40, 74), + (107, 9), (9, 108), (108, 107), (49, 48), (48, 131), (131, 49), + (32, 194), (194, 211), (211, 32), (184, 74), (74, 185), (185, 184), + (191, 80), (80, 183), (183, 191), (185, 40), (40, 186), (186, 185), + (119, 230), (230, 118), (118, 119), (210, 202), (202, 214), (214, 210), + (84, 83), (83, 17), (17, 84), (77, 76), (76, 146), (146, 77), + (161, 160), (160, 30), (30, 161), (190, 56), (56, 173), (173, 190), + (182, 106), (106, 194), (194, 182), (138, 135), (135, 192), (192, 138), + (129, 203), (203, 98), (98, 129), (54, 21), (21, 68), (68, 54), + (5, 51), (51, 4), (4, 5), (145, 144), (144, 23), (23, 145), + (90, 77), (77, 91), (91, 90), (207, 205), (205, 187), (187, 207), + (83, 201), (201, 18), (18, 83), (181, 91), (91, 182), (182, 181), + (180, 90), (90, 181), (181, 180), (16, 85), (85, 17), (17, 16), + (205, 206), (206, 36), (36, 205), (176, 148), (148, 140), (140, 176), + (165, 92), (92, 39), (39, 165), (245, 193), (193, 244), (244, 245), + (27, 159), (159, 28), (28, 27), (30, 247), (247, 161), (161, 30), + (174, 236), (236, 196), (196, 174), (103, 54), (54, 104), (104, 103), + (55, 193), (193, 8), (8, 55), (111, 117), (117, 31), (31, 111), + (221, 189), (189, 55), (55, 221), (240, 98), (98, 99), (99, 240), + (142, 126), (126, 100), (100, 142), (219, 166), (166, 218), (218, 219), + (112, 155), (155, 26), (26, 112), (198, 209), (209, 131), (131, 198), + (169, 135), (135, 150), (150, 169), (114, 47), (47, 217), (217, 114), + (224, 223), (223, 53), (53, 224), (220, 45), (45, 134), (134, 220), + (32, 211), (211, 140), (140, 32), (109, 67), (67, 108), (108, 109), + (146, 43), (43, 91), (91, 146), (231, 230), (230, 120), (120, 231), + (113, 226), (226, 247), (247, 113), (105, 63), (63, 52), (52, 105), + (241, 238), (238, 242), (242, 241), (124, 46), (46, 156), (156, 124), + (95, 78), (78, 96), (96, 95), (70, 46), (46, 63), (63, 70), + (116, 143), (143, 227), (227, 116), (116, 123), (123, 111), (111, 116), + (1, 44), (44, 19), (19, 1), (3, 236), (236, 51), (51, 3), + (207, 216), (216, 205), (205, 207), (26, 154), (154, 22), (22, 26), + (165, 39), (39, 167), (167, 165), (199, 200), (200, 208), (208, 199), + (101, 36), (36, 100), (100, 101), (43, 57), (57, 202), (202, 43), + (242, 20), (20, 99), (99, 242), (56, 28), (28, 157), (157, 56), + (124, 35), (35, 113), (113, 124), (29, 160), (160, 27), (27, 29), + (211, 204), (204, 210), (210, 211), (124, 113), (113, 46), (46, 124), + (106, 43), (43, 204), (204, 106), (96, 62), (62, 77), (77, 96), + (227, 137), (137, 116), (116, 227), (73, 41), (41, 72), (72, 73), + (36, 203), (203, 142), (142, 36), (235, 64), (64, 240), (240, 235), + (48, 49), (49, 64), (64, 48), (42, 41), (41, 74), (74, 42), + (214, 212), (212, 207), (207, 214), (183, 42), (42, 184), (184, 183), + (210, 169), (169, 211), (211, 210), (140, 170), (170, 176), (176, 140), + (104, 105), (105, 69), (69, 104), (193, 122), (122, 168), (168, 193), + (50, 123), (123, 187), (187, 50), (89, 96), (96, 90), (90, 89), + (66, 65), (65, 107), (107, 66), (179, 89), (89, 180), (180, 179), + (119, 101), (101, 120), (120, 119), (68, 63), (63, 104), (104, 68), + (234, 93), (93, 227), (227, 234), (16, 15), (15, 85), (85, 16), + (209, 129), (129, 49), (49, 209), (15, 14), (14, 86), (86, 15), + (107, 55), (55, 9), (9, 107), (120, 100), (100, 121), (121, 120), + (153, 145), (145, 22), (22, 153), (178, 88), (88, 179), (179, 178), + (197, 6), (6, 196), (196, 197), (89, 88), (88, 96), (96, 89), + (135, 138), (138, 136), (136, 135), (138, 215), (215, 172), (172, 138), + (218, 115), (115, 219), (219, 218), (41, 42), (42, 81), (81, 41), + (5, 195), (195, 51), (51, 5), (57, 43), (43, 61), (61, 57), + (208, 171), (171, 199), (199, 208), (41, 81), (81, 38), (38, 41), + (224, 53), (53, 225), (225, 224), (24, 144), (144, 110), (110, 24), + (105, 52), (52, 66), (66, 105), (118, 229), (229, 117), (117, 118), + (227, 34), (34, 234), (234, 227), (66, 107), (107, 69), (69, 66), + (10, 109), (109, 151), (151, 10), (219, 48), (48, 235), (235, 219), + (183, 62), (62, 191), (191, 183), (142, 129), (129, 126), (126, 142), + (116, 111), (111, 143), (143, 116), (118, 117), (117, 50), (50, 118), + (223, 222), (222, 52), (52, 223), (94, 19), (19, 141), (141, 94), + (222, 221), (221, 65), (65, 222), (196, 3), (3, 197), (197, 196), + (45, 220), (220, 44), (44, 45), (156, 70), (70, 139), (139, 156), + (188, 122), (122, 245), (245, 188), (139, 71), (71, 162), (162, 139), + (149, 170), (170, 150), (150, 149), (122, 188), (188, 196), (196, 122), + (206, 216), (216, 92), (92, 206), (164, 2), (2, 167), (167, 164), + (242, 141), (141, 241), (241, 242), (0, 164), (164, 37), (37, 0), + (11, 72), (72, 12), (12, 11), (12, 38), (38, 13), (13, 12), + (70, 63), (63, 71), (71, 70), (31, 226), (226, 111), (111, 31), + (36, 101), (101, 205), (205, 36), (203, 206), (206, 165), (165, 203), + (126, 209), (209, 217), (217, 126), (98, 165), (165, 97), (97, 98), + (237, 220), (220, 218), (218, 237), (237, 239), (239, 241), (241, 237), + (210, 214), (214, 169), (169, 210), (140, 171), (171, 32), (32, 140), + (241, 125), (125, 237), (237, 241), (179, 86), (86, 178), (178, 179), + (180, 85), (85, 179), (179, 180), (181, 84), (84, 180), (180, 181), + (182, 83), (83, 181), (181, 182), (194, 201), (201, 182), (182, 194), + (177, 137), (137, 132), (132, 177), (184, 76), (76, 183), (183, 184), + (185, 61), (61, 184), (184, 185), (186, 57), (57, 185), (185, 186), + (216, 212), (212, 186), (186, 216), (192, 214), (214, 187), (187, 192), + (139, 34), (34, 156), (156, 139), (218, 79), (79, 237), (237, 218), + (147, 123), (123, 177), (177, 147), (45, 44), (44, 4), (4, 45), + (208, 201), (201, 32), (32, 208), (98, 64), (64, 129), (129, 98), + (192, 213), (213, 138), (138, 192), (235, 59), (59, 219), (219, 235), + (141, 242), (242, 97), (97, 141), (97, 2), (2, 141), (141, 97), + (240, 75), (75, 235), (235, 240), (229, 24), (24, 228), (228, 229), + (31, 25), (25, 226), (226, 31), (230, 23), (23, 229), (229, 230), + (231, 22), (22, 230), (230, 231), (232, 26), (26, 231), (231, 232), + (233, 112), (112, 232), (232, 233), (244, 189), (189, 243), (243, 244), + (189, 221), (221, 190), (190, 189), (222, 28), (28, 221), (221, 222), + (223, 27), (27, 222), (222, 223), (224, 29), (29, 223), (223, 224), + (225, 30), (30, 224), (224, 225), (113, 247), (247, 225), (225, 113), + (99, 60), (60, 240), (240, 99), (213, 147), (147, 215), (215, 213), + (60, 20), (20, 166), (166, 60), (192, 187), (187, 213), (213, 192), + (243, 112), (112, 244), (244, 243), (244, 233), (233, 245), (245, 244), + (245, 128), (128, 188), (188, 245), (188, 114), (114, 174), (174, 188), + (134, 131), (131, 220), (220, 134), (174, 217), (217, 236), (236, 174), + (236, 198), (198, 134), (134, 236), (215, 177), (177, 58), (58, 215), + (156, 143), (143, 124), (124, 156), (25, 110), (110, 7), (7, 25), + (31, 228), (228, 25), (25, 31), (264, 356), (356, 368), (368, 264), + (0, 11), (11, 267), (267, 0), (451, 452), (452, 349), (349, 451), + (267, 302), (302, 269), (269, 267), (350, 357), (357, 277), (277, 350), + (350, 452), (452, 357), (357, 350), (299, 333), (333, 297), (297, 299), + (396, 175), (175, 377), (377, 396), (280, 347), (347, 330), (330, 280), + (269, 303), (303, 270), (270, 269), (151, 9), (9, 337), (337, 151), + (344, 278), (278, 360), (360, 344), (424, 418), (418, 431), (431, 424), + (270, 304), (304, 409), (409, 270), (272, 310), (310, 407), (407, 272), + (322, 270), (270, 410), (410, 322), (449, 450), (450, 347), (347, 449), + (432, 422), (422, 434), (434, 432), (18, 313), (313, 17), (17, 18), + (291, 306), (306, 375), (375, 291), (259, 387), (387, 260), (260, 259), + (424, 335), (335, 418), (418, 424), (434, 364), (364, 416), (416, 434), + (391, 423), (423, 327), (327, 391), (301, 251), (251, 298), (298, 301), + (275, 281), (281, 4), (4, 275), (254, 373), (373, 253), (253, 254), + (375, 307), (307, 321), (321, 375), (280, 425), (425, 411), (411, 280), + (200, 421), (421, 18), (18, 200), (335, 321), (321, 406), (406, 335), + (321, 320), (320, 405), (405, 321), (314, 315), (315, 17), (17, 314), + (423, 426), (426, 266), (266, 423), (396, 377), (377, 369), (369, 396), + (270, 322), (322, 269), (269, 270), (413, 417), (417, 464), (464, 413), + (385, 386), (386, 258), (258, 385), (248, 456), (456, 419), (419, 248), + (298, 284), (284, 333), (333, 298), (168, 417), (417, 8), (8, 168), + (448, 346), (346, 261), (261, 448), (417, 413), (413, 285), (285, 417), + (326, 327), (327, 328), (328, 326), (277, 355), (355, 329), (329, 277), + (309, 392), (392, 438), (438, 309), (381, 382), (382, 256), (256, 381), + (279, 429), (429, 360), (360, 279), (365, 364), (364, 379), (379, 365), + (355, 277), (277, 437), (437, 355), (282, 443), (443, 283), (283, 282), + (281, 275), (275, 363), (363, 281), (395, 431), (431, 369), (369, 395), + (299, 297), (297, 337), (337, 299), (335, 273), (273, 321), (321, 335), + (348, 450), (450, 349), (349, 348), (359, 446), (446, 467), (467, 359), + (283, 293), (293, 282), (282, 283), (250, 458), (458, 462), (462, 250), + (300, 276), (276, 383), (383, 300), (292, 308), (308, 325), (325, 292), + (283, 276), (276, 293), (293, 283), (264, 372), (372, 447), (447, 264), + (346, 352), (352, 340), (340, 346), (354, 274), (274, 19), (19, 354), + (363, 456), (456, 281), (281, 363), (426, 436), (436, 425), (425, 426), + (380, 381), (381, 252), (252, 380), (267, 269), (269, 393), (393, 267), + (421, 200), (200, 428), (428, 421), (371, 266), (266, 329), (329, 371), + (432, 287), (287, 422), (422, 432), (290, 250), (250, 328), (328, 290), + (385, 258), (258, 384), (384, 385), (446, 265), (265, 342), (342, 446), + (386, 387), (387, 257), (257, 386), (422, 424), (424, 430), (430, 422), + (445, 342), (342, 276), (276, 445), (422, 273), (273, 424), (424, 422), + (306, 292), (292, 307), (307, 306), (352, 366), (366, 345), (345, 352), + (268, 271), (271, 302), (302, 268), (358, 423), (423, 371), (371, 358), + (327, 294), (294, 460), (460, 327), (331, 279), (279, 294), (294, 331), + (303, 271), (271, 304), (304, 303), (436, 432), (432, 427), (427, 436), + (304, 272), (272, 408), (408, 304), (395, 394), (394, 431), (431, 395), + (378, 395), (395, 400), (400, 378), (296, 334), (334, 299), (299, 296), + (6, 351), (351, 168), (168, 6), (376, 352), (352, 411), (411, 376), + (307, 325), (325, 320), (320, 307), (285, 295), (295, 336), (336, 285), + (320, 319), (319, 404), (404, 320), (329, 330), (330, 349), (349, 329), + (334, 293), (293, 333), (333, 334), (366, 323), (323, 447), (447, 366), + (316, 15), (15, 315), (315, 316), (331, 358), (358, 279), (279, 331), + (317, 14), (14, 316), (316, 317), (8, 285), (285, 9), (9, 8), + (277, 329), (329, 350), (350, 277), (253, 374), (374, 252), (252, 253), + (319, 318), (318, 403), (403, 319), (351, 6), (6, 419), (419, 351), + (324, 318), (318, 325), (325, 324), (397, 367), (367, 365), (365, 397), + (288, 435), (435, 397), (397, 288), (278, 344), (344, 439), (439, 278), + (310, 272), (272, 311), (311, 310), (248, 195), (195, 281), (281, 248), + (375, 273), (273, 291), (291, 375), (175, 396), (396, 199), (199, 175), + (312, 311), (311, 268), (268, 312), (276, 283), (283, 445), (445, 276), + (390, 373), (373, 339), (339, 390), (295, 282), (282, 296), (296, 295), + (448, 449), (449, 346), (346, 448), (356, 264), (264, 454), (454, 356), + (337, 336), (336, 299), (299, 337), (337, 338), (338, 151), (151, 337), + (294, 278), (278, 455), (455, 294), (308, 292), (292, 415), (415, 308), + (429, 358), (358, 355), (355, 429), (265, 340), (340, 372), (372, 265), + (352, 346), (346, 280), (280, 352), (295, 442), (442, 282), (282, 295), + (354, 19), (19, 370), (370, 354), (285, 441), (441, 295), (295, 285), + (195, 248), (248, 197), (197, 195), (457, 440), (440, 274), (274, 457), + (301, 300), (300, 368), (368, 301), (417, 351), (351, 465), (465, 417), + (251, 301), (301, 389), (389, 251), (394, 395), (395, 379), (379, 394), + (399, 412), (412, 419), (419, 399), (410, 436), (436, 322), (322, 410), + (326, 2), (2, 393), (393, 326), (354, 370), (370, 461), (461, 354), + (393, 164), (164, 267), (267, 393), (268, 302), (302, 12), (12, 268), + (312, 268), (268, 13), (13, 312), (298, 293), (293, 301), (301, 298), + (265, 446), (446, 340), (340, 265), (280, 330), (330, 425), (425, 280), + (322, 426), (426, 391), (391, 322), (420, 429), (429, 437), (437, 420), + (393, 391), (391, 326), (326, 393), (344, 440), (440, 438), (438, 344), + (458, 459), (459, 461), (461, 458), (364, 434), (434, 394), (394, 364), + (428, 396), (396, 262), (262, 428), (274, 354), (354, 457), (457, 274), + (317, 316), (316, 402), (402, 317), (316, 315), (315, 403), (403, 316), + (315, 314), (314, 404), (404, 315), (314, 313), (313, 405), (405, 314), + (313, 421), (421, 406), (406, 313), (323, 366), (366, 361), (361, 323), + (292, 306), (306, 407), (407, 292), (306, 291), (291, 408), (408, 306), + (291, 287), (287, 409), (409, 291), (287, 432), (432, 410), (410, 287), + (427, 434), (434, 411), (411, 427), (372, 264), (264, 383), (383, 372), + (459, 309), (309, 457), (457, 459), (366, 352), (352, 401), (401, 366), + (1, 274), (274, 4), (4, 1), (418, 421), (421, 262), (262, 418), + (331, 294), (294, 358), (358, 331), (435, 433), (433, 367), (367, 435), + (392, 289), (289, 439), (439, 392), (328, 462), (462, 326), (326, 328), + (94, 2), (2, 370), (370, 94), (289, 305), (305, 455), (455, 289), + (339, 254), (254, 448), (448, 339), (359, 255), (255, 446), (446, 359), + (254, 253), (253, 449), (449, 254), (253, 252), (252, 450), (450, 253), + (252, 256), (256, 451), (451, 252), (256, 341), (341, 452), (452, 256), + (414, 413), (413, 463), (463, 414), (286, 441), (441, 414), (414, 286), + (286, 258), (258, 441), (441, 286), (258, 257), (257, 442), (442, 258), + (257, 259), (259, 443), (443, 257), (259, 260), (260, 444), (444, 259), + (260, 467), (467, 445), (445, 260), (309, 459), (459, 250), (250, 309), + (305, 289), (289, 290), (290, 305), (305, 290), (290, 460), (460, 305), + (401, 376), (376, 435), (435, 401), (309, 250), (250, 392), (392, 309), + (376, 411), (411, 433), (433, 376), (453, 341), (341, 464), (464, 453), + (357, 453), (453, 465), (465, 357), (343, 357), (357, 412), (412, 343), + (437, 343), (343, 399), (399, 437), (344, 360), (360, 440), (440, 344), + (420, 437), (437, 456), (456, 420), (360, 420), (420, 363), (363, 360), + (361, 401), (401, 288), (288, 361), (265, 372), (372, 353), (353, 265), + (390, 339), (339, 249), (249, 390), (339, 448), (448, 255), (255, 339)]) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/3d_model_face/step1_facemesh.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/3d_model_face/step1_facemesh.py" new file mode 100644 index 0000000000000000000000000000000000000000..c869cf44f1b0f001aa70da2de5fa464a9962d5c3 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/3d_model_face/step1_facemesh.py" @@ -0,0 +1,111 @@ +""" +Week 11 - Step 1: Face Mesh Tracking with Connections (Template 填空版) +=================================================================== +本步驟目標: + 1. 使用 OpenCV 開啟視訊鏡頭 + 2. 載入 MediaPipe 臉部特徵偵測器 (face_landmarker.task) + 3. 偵測人臉,並在臉上畫出綠色的 3D 特效網格線與黃色特徵點 +""" +import cv2 +import mediapipe as mp +import numpy as np +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the examples folder.") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker Tasks API initialized successfully.") +except Exception as e: + print(f"Error initializing detector: {e}") + sys.exit() + +# 匯入臉部網格連接點定義 +try: + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + from face_mesh_connections import FACEMESH_TESSELATION +except ImportError: + FACEMESH_TESSELATION = [] + +# 繪圖模式:0 = 僅畫網格線, 1 = 僅畫點點, 2 = 點線都畫 +draw_mode = 0 + +print("Press 'q' in the window to exit, or 'm' to toggle display modes.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + print("Error: Failed to read frame.") + break + + # 【TODO 1】畫面水平翻轉,並將 BGR 轉成 MediaPipe 需要的 RGB 格式 + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + + + # 執行臉部偵測 + results = detector.detect(mp_image) + + # 【TODO 2】如果偵測到臉部,畫出 3D 特效網格線 + # 提示:取出 results.face_landmarks[0],並用迴圈巡覽 FACEMESH_TESSELATION + # 點的座標 (lm.x, lm.y) 是 0~1 的比例,需要乘上 img_w 和 img_h 換算成像素座標 + if results.face_landmarks: + face_lms = results.face_landmarks[0] + num_lms = len(face_lms) + + if draw_mode == 0 or draw_mode == 2: + for start_idx, end_idx in FACEMESH_TESSELATION: + if start_idx < num_lms and end_idx < num_lms: + lm1 = face_lms[start_idx] + lm2 = face_lms[end_idx] + pt1 = (int(lm1.x * img_w), int(lm1.y * img_h)) + pt2 = (int(lm2.x * img_w), int(lm2.y * img_h)) + cv2.line(img, pt1, pt2, (224, 224, 0), 1) # 青綠色線條 + + + # 【TODO 3】畫出臉部 468 個黃色特徵點點 + # if draw_mode == 1 or draw_mode == 2: + + if draw_mode == 1 or draw_mode == 2: + for lm in face_lms: + cx = int(lm.x * img_w) + cy = int(lm.y * img_h) + cv2.circle(img, (cx, cy), 1, (0, 255, 255), -1) + + mode_str = "Mesh" if draw_mode == 0 else "Dots" if draw_mode == 1 else "Both" + cv2.putText(img, f"Face Detected | Mode: {mode_str}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + # 顯示操作提示文字 + cv2.putText(img, "Press 'm' to toggle mode | 'q' to quit", (10, img_h - 20), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) + + cv2.imshow("Face Mesh Connections", img) + + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('m'): + draw_mode = (draw_mode + 1) % 3 + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" new file mode 100644 index 0000000000000000000000000000000000000000..2eb731a53ce01cb4f5ce185ec06c76e019ef80c4 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/d1_1_pandas_intro.py" @@ -0,0 +1,38 @@ +# d1_1_pandas_intro.py +# Day 1 學員填空:Pandas 基礎(完成 TODO 後執行) +# cd examples/Day1/student && python3 d1_1_pandas_intro.py +# 參考答案:../Teacher/d1_1_pandas_intro.py + +import pandas as pd +import numpy as np + +df = pd.read_csv("taipei_weather_2026.csv") +print("=== 數據載入成功! ===") +df = pd.DataFrame(df) +print("\n--- 1. head ---") +print(df.head()) + +print("\n--- 2. info ---") +df.info() + +print("\n--- 3. describe ---") +print(df.describe()) + +print("\n--- 4. Temperature 前 5 筆 ---") +print(df['Temperature'].head()) + +print("\n--- 5. Date 與 Rainfall ---") +print(df[["Date", "Rainfall"]].head()) + +print("\n--- 挑戰:最高溫與平均濕度 ---") +max_temp = df['Temperature'].max() +avg_humidity = df['Humidity'].mean() +min_rainfall = df['Rainfall'].min() +print(f"最高溫度:{max_temp}°C") +print(f"平均濕度:{avg_humidity:.2f}%") +print(f"最小降雨量:{min_rainfall}mm") + +df['Egypt_Can_Come'] = (df['Temperature'] > 27.0) & (df['Rainfall'] == 0.0) +print(df) + + diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/DataFrame\346\214\207\344\273\244\347\267\264\347\277\222/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" new file mode 100644 index 0000000000000000000000000000000000000000..c3c4db6288f7275f35d87d907709d726e184b3db --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/ai_api\344\270\262\346\216\245\347\267\264\347\277\222/d2_1_gemini.py" @@ -0,0 +1,43 @@ +# d2_1_gemini.py — Day2 學員填空 +# cd examples/Day2/Student && python3 d2_1_gemini.py +# 參考答案:../Teacher/d2_1_gemini.py + +import os +from google import genai + +# === 【學員填空練習點 1:設置與載入 API Key】 === +# TODO: 嘗試從環境變數 GEMINI_API_KEY 取得 API Key。如果未設定,則使用 input() 提示手動輸入。 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JynZRgTmhZIMkFFN5GHSFClFfC3sraiSQ4Pb8d9v0V6Q") + +if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY。") + print(" 請先設定:export GEMINI_API_KEY=\"your_key\"") + print(" 或於下方手動輸入(僅供本機測試,勿寫死在檔案中上傳):") + api_key = input("API Key: ").strip() + if not api_key: + print("[結束] 未提供 API Key。可改練習 d2_1_ollama_local.py。") + exit(0) + +# === 【學員填空練習點 2:設定模型名稱】 === +# TODO: 設定使用的 Gemini 模型名稱(例如 'gemini-3.1-flash-lite') +model_name = "gemini-3.1-flash-lite" + +print(f"正在連線至 Google Cloud Model ({model_name})...") + +# === 【學員填空練習點 3:初始化 Gemini Client】 === +# TODO: 呼叫 genai.Client 並傳入 api_key 參數來初始化 Client 物件,指派給 client 變數 +client = genai.Client(api_key=api_key) + +# === 【學員填空練習點 4:發送內容生成請求】 === +# TODO: 使用 client.models.generate_content 呼叫模型,傳入 model 參數與 contents 提示詞 +# 提示詞內容:請模型以繁體中文解釋什麼是『API 串接』,並用餐廳點餐作為比喻,字數約 100 字。 +response = client.models.generate_content( + model=model_name, + contents = "請以繁體中文解釋什麼是『API 串接』,並用餐廳點餐作為比喻,字數約 100 字。" +) + +# === 【學員填空練習點 5:讀取並印出回應】 === +# TODO: 從 response 物件中取得 text 欄位內容,並列印出來。 +print("\n=== AI 回應結果 ===") +# 請在下方實作列印邏輯: +print(response.text) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/coffe_shop_sales_draw/coffee_sales_data.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/coffe_shop_sales_draw/coffee_sales_data.csv" new file mode 100644 index 0000000000000000000000000000000000000000..9034a63a93b67363c54e7f155c9cb5daf483f886 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/coffe_shop_sales_draw/coffee_sales_data.csv" @@ -0,0 +1,15 @@ +Date,Sales_Amount,Customer_Count,Coffee_Cups_Sold,Dessert_Sold,Promotion_Active +2026-07-01,15200,120,85,30,No +2026-07-02,16800,135,95,35,No +2026-07-03,24500,190,140,55,Yes +2026-07-04,28000,210,165,68,Yes +2026-07-05,18500,145,110,40,No +2026-07-06,14200,110,80,25,No +2026-07-07,15900,125,90,28,No +2026-07-08,17200,138,100,32,No +2026-07-09,26000,205,150,60,Yes +2026-07-10,29500,225,175,75,Yes +2026-07-11,21000,160,120,45,No +2026-07-12,19800,150,115,42,No +2026-07-13,15000,118,85,26,No +2026-07-14,16500,130,92,30,No diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/coffe_shop_sales_draw/d1_4_visualization_coffee.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/coffe_shop_sales_draw/d1_4_visualization_coffee.py" new file mode 100644 index 0000000000000000000000000000000000000000..bc551b681de42163edc3afcd7b82e7695f7a8941 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/coffe_shop_sales_draw/d1_4_visualization_coffee.py" @@ -0,0 +1,125 @@ +# d1_4_visualization_coffee.py +# Day 1: 資料科學與數據視覺化 - 咖啡廳營運數據進階分析(學員填空) +# cd examples/Day1/student && python3 d1_4_visualization_coffee.py +# 參考答案:../Teacher/d1_4_visualization_coffee.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + + +def load_csv(filename="coffee_sales_data.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + # TODO: 1. 使用 os.path.join 將當前腳本所在的目錄與檔案名稱拼接成絕對路徑 + # 2. 使用 os.path.exists 判斷路徑是否存在,若存在則用 pd.read_csv 讀取並回傳 + path = os.path.join(os.path.dirname(__file__), filename) + if os.path.exists(path): + return pd.read_csv(path) + else: + print("❌ 找不到 CSV 檔案!請確認 coffee_sales_data.csv 與本腳本在同一目錄。") + exit(1) + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 1 列 2 行 (1x2) 的子圖畫布,尺寸大小為寬 15 英吋、高 6 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6)) + + +# ========================================== +# 1. 子圖 1 (ax1):營業額與咖啡銷量趨勢對比 (雙 Y 軸) +# ========================================== +# === 【學員填空練習點 3:繪製雙 Y 軸折線圖】 === +# TODO: +# 1. 在 ax1 (左軸) 繪製「營業額」折線圖:X軸為 df['Date'], Y軸為 df['Sales_Amount'] +# 設定參數:點形 marker='o', 顏色 color='#b87e6b' (鐵鏽紅), 圖例 label='營業額 (NTD)', 線寬 linewidth=2 +# 2. 呼叫 ax1.twinx() 建立共用 X 軸的右側 Y 軸物件,指派給 ax1_twin +# 3. 在 ax1_twin (右軸) 繪製「咖啡銷量」折線圖:X軸為 df['Date'], Y軸為 df['Coffee_Cups_Sold'] +# 設定參數:點形 marker='s' (正方形), 顏色 color='#5f7186' (石板藍), 虛線 linestyle='--', 圖例 label='咖啡銷量 (杯)' +# 4. 分別設定左軸與右軸的 Y 軸標籤與顏色以示區隔 (提示:使用 set_ylabel 與 tick_params) + +# 請在下方完成 ax1 雙 Y 軸折線圖的繪製: +line1 = ax1.plot(df['Date'], df['Sales_Amount'], marker='o', color='#b87e6b', label='營業額 (NTD)', linewidth=2) +ax1_twin = ax1.twinx() +line2 = ax1_twin.plot(df['Date'],df['Coffee_Cups_Sold'], marker = 's', color = '#5f7186', linestyle = '--', label = '咖啡銷量 (杯)', linewidth=2) + + + + + +# === 【學員填空練習點 4:雙 Y 軸圖例合併】 === +# TODO: +# 1. 由於雙 Y 軸屬於不同的 Axes 物件,若直接呼叫 ax1.legend() 只會顯示左軸的圖例。 +# 請獲取兩軸的 lines 與 labels,合併後統一顯示在 ax1 的左上角 (loc='upper left') +# 提示: +# lines1, labels1 = ax1.get_legend_handles_labels() +# lines2, labels2 = ax1_twin.get_legend_handles_labels() +# ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper left') + +# 請在下方完成圖例合併: +ax1.set_ylabel("營業額 (NTD)", color='#b87e6b', fontsize=12) +ax1_twin.set_ylabel("咖啡銷量 (杯)", color='#5f7186', fontsize=12) +ax1.tick_params(axis='y', labelcolor='#b87e6b') +ax1_twin.tick_params(axis='y', labelcolor='#5f7186') + + + +lines = line1 + line2 +lables = [l.get_label() for l in lines] +ax1.legend(lines, lables, loc = 'upper left') + +# 設定 ax1 基礎格式 +ax1.set_title("咖啡廳每日營業額與咖啡銷量趨勢", fontsize=14, fontweight='bold') +ax1.set_xlabel("日期") +ax1.grid(True, linestyle='--', alpha=0.5) +ax1.tick_params(axis='x', rotation=45) + + +# ========================================== +# 2. 子圖 2 (ax2):促銷活動對客流量的影響 (數據聚合長條圖) +# ========================================== +# === 【學員填空練習點 5:數據聚合與長條圖繪製】 === +# TODO: +# 1. 使用 groupby 依據 'Promotion_Active' 欄位分組,並計算 'Customer_Count' 的平均值 (mean) +# 提示:promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +# 2. 在 ax2 上繪製長條圖,以 promo_avg.index 作為 X 軸,promo_avg.values 作為 Y 軸 +# 設定參數:顏色 color=['#e6e2d8', '#b87e6b'],外框顏色 edgecolor='#3d3a36',寬度 width=0.5 +# 3. 在長條圖上方顯示具體的平均數值 (提示:使用 ax2.bar_label 或是跑迴圈加文字) + +# 請在下方完成資料聚合與長條圖繪製: +promo_avg = df.groupby('Promotion_Active')['Customer_Count'].mean() +bars = ax2.bar(promo_avg.index, promo_avg.values, color=['#e6e2d8', '#b87e6b'], edgecolor='#3d3a36', width=0.5) +ax2.bar_label(bars, fmt='%.1f人', label_type='edge', padding=3, fontweight='bold') + + + +# 設定 ax2 基礎格式 +ax2.set_title("促銷活動對每日平均客流量的影響", fontsize=14, fontweight='bold') +ax2.set_xlabel("是否舉辦促銷活動") +ax2.set_ylabel("平均來客數 (人)") +ax2.grid(True, axis='y', linestyle='--', alpha=0.5) + + +plt.tight_layout() # 自動調整間距,避免文字重疊 + +# === 【學員填空練習點 6:圖表儲存與顯示】 === +# TODO: 1. 呼叫 savefig 將畫布儲存為檔名 "coffee_sales_analysis.png",並設定 dpi=300 +# 2. 呼叫 show 顯示圖表 +# (⚠️ 注意:不要在 print 或 savefig 內使用不支援 cp950 的 emoji) + +output_image = "coffee_sales_analysis.png" +# 請在下方完成圖表存檔與顯示: +plt.savefig('coffee_sales_analysis.png', dpi=300) +plt.show() + + diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/draw_game/draw_game_student.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/draw_game/draw_game_student.py" new file mode 100644 index 0000000000000000000000000000000000000000..6474b500b920eb18bd4536cf7d599abd8d404278 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/draw_game/draw_game_student.py" @@ -0,0 +1,148 @@ +import cv2 +import numpy as np +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# ============================================ +# Week 9 - 簡單學生版:支援多色切換的 AR 空中寫字板 (純黑畫布版) +# ============================================ + +# === 【重要觀念 TODO 1:MediaPipe 手部偵測模型初始化】 === +# 觀念重點: +# 1. BaseOptions 用於設定模型檔案的路徑,這裡使用下載好的 'hand_landmarker.task'。 +# 2. HandLandmarkerOptions 用於設定手部偵測的細項設定,例如 num_hands=1 代表最多偵測一隻手。 +# 3. 最後透過 vision.HandLandmarker.create_from_options 建立手部偵測器物件。 +# 請解開下方三行程式碼的註釋: +base_options = python.BaseOptions(model_asset_path='hand_landmarker.task') +options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) +detector = vision.HandLandmarker.create_from_options(options) + +cap = cv2.VideoCapture(0) +canvas = None +px, py = 0, 0 + +# 定義幾種高對比的顏色 (格式為 B, G, R) +COLORS = { + 'purple': (212,255,255), # 紫色 + 'cyan': (255, 255, 0), # 青藍色 + 'yellow': (0, 255, 255), # 亮黃色 + 'red': (0, 0, 255) # 正紅色 + +} + +# 預設使用螢光綠 +current_color = COLORS['purple'] + +print("系統啟動中...") +print("【操作指南】") +print("☝️ 單伸食指:下筆寫字") +print("✌️ 食指+中指:懸停移動 (不寫字)") +print("⌨️ 按下 '1~4':切換畫筆顏色") +print("⌨️ 按下 'q':清空畫面") +print("⌨️ 按下 'e':結束程式") + +while True: + success, img = cap.read() + if not success: + break + + # === 【重要觀念 TODO 2:攝影機影像水平翻轉】 === + # 觀念重點: + # 1. 攝影機預設取得的影像與我們的真實動作是相反的(非鏡像)。 + # 2. 在互動式 AR 應用中,為了讓使用者的左手移動時,畫面中的手也往左移動, + # 必須使用 cv2.flip(img, 1) 將影像水平翻轉,以提供符合直覺的「鏡像體驗」。 + # 請解開下方一行程式碼的註釋: + img = cv2.flip(img, 1) + + h, w, c = img.shape + + # 初始化畫布 + if canvas is None: + canvas = np.zeros((h, w, c), dtype=np.uint8) + + # 為了維持畫筆提示點(或懸停圓圈)的動態顯示,且不破壞原本的 canvas 紀錄, + # 我們在每一訊框都建立一個純黑的暫時畫布來呈現最終畫面。 + display_img = np.zeros((h, w, c), dtype=np.uint8) + + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.hand_landmarks: + hand_lms = results.hand_landmarks[0] + + index_tip = hand_lms[8] + index_pip = hand_lms[6] + middle_tip = hand_lms[12] + middle_pip = hand_lms[10] + + cx, cy = int(index_tip.x * w), int(index_tip.y * h) + + index_up = index_tip.y < index_pip.y + middle_up = middle_tip.y < middle_pip.y + + if index_up and not middle_up: + # 狀態:【繪圖模式】 + if px == 0 and py == 0: + px, py = cx, cy + + # === 【重要觀念 TODO 3:在 OpenCV Canvas 上繪製軌跡】 === + # 觀念重點: + # 1. cv2.line 用於在影像上繪製直線。 + # 2. 參數說明:畫布(canvas)、起點(px, py)、終點(cx, cy)、顏色(current_color)、線寬(8)。 + # 3. 藉由在每一訊框連續繪製前一個位置到當前位置的線段,即可形成平滑的寫字軌跡。 + # 請解開下方一行程式碼的註釋: + cv2.line(canvas, (px, py), (cx, cy), current_color, 8) + + px, py = cx, cy + + # 筆頭提示點直接畫在暫時的顯示畫布上,避免在黑畫布上找不到筆尖 + cv2.circle(display_img, (cx, cy), 10, current_color, cv2.FILLED) + + elif index_up and middle_up: + # 狀態:【懸停模式】 + px, py = 0, 0 + # 懸停提示圈畫在暫時的顯示畫布上,移動時才不會在黑背景中迷路 + cv2.circle(display_img, (cx, cy), 12, (255, 255, 255), 2) + + else: + px, py = 0, 0 + else: + px, py = 0, 0 + + # 為什麼改用 canvas 與 display_img 疊加? + # 原本是與相機影像 (img) 疊加,現在改與純黑的 display_img 疊加, + # 這樣畫面上就只會留下寫字的軌跡以及當前的筆頭/懸停提示,完全遮蔽人像背景。 + + # === 【重要觀念 TODO 4:AR 畫面疊加與顯示】 === + # 觀念重點: + # 1. cv2.add 用於將兩個相同大小與類型的影像矩陣進行相加。 + # 2. 這裡將寫字痕跡畫布 (canvas) 與當前的筆頭/懸停提示畫布 (display_img) 疊加。 + # 3. 疊加後使用 cv2.imshow 在視窗中顯示最終成果。 + # 請解開下方兩行程式碼的註釋: + final_output = cv2.add(canvas, display_img) + cv2.imshow("Hand Tracking - AR Canvas", final_output) + + # 鍵盤監聽與控制邏輯 + key = cv2.waitKey(1) & 0xFF + if key == ord('q'): + break + elif key == ord('e'): + canvas = np.zeros((h, w, c), dtype=np.uint8) + + elif key == ord('1'): + current_color = COLORS['green'] + print("已切換為:螢光綠") + elif key == ord('2'): + current_color = COLORS['cyan'] + print("已切換為:青藍色") + elif key == ord('3'): + current_color = COLORS['yellow'] + print("已切換為:亮黃色") + elif key == ord('4'): + current_color = COLORS['red'] + print("已切換為:正紅色") + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/2544.avif" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/2544.avif" new file mode 100644 index 0000000000000000000000000000000000000000..1fff429c68f66585402f0171a624afedd50630e1 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/2544.avif" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:67f2c3b4c7ddc498e52c459f95c987a370bcfd08b4157f1119569fa1516038ad +size 113811 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/3F.webp" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/3F.webp" new file mode 100644 index 0000000000000000000000000000000000000000..973d2fe4c6a401a65bf9a6485019516ce4e5611c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/3F.webp" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31fea88b7ebdddfd05553cb1f44474ce8277cd99a4a7b6f9553194e394ec4376 +size 28714 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/gay.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/gay.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..5f8c83e8cc3c751c73a6e1298e5873412aa7b165 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/gay.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ccb692658042216e1460d3d63e3662397a604b6f59026264fbe2df768b9647e +size 25673 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/images.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/images.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..10af09ee9957ec3496abbfb5115384eeebba39d9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image/images.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6b8b81cb593133e38c7721e28d1cd00f4ef8f2c875cde519a4f77cbadd01d5e +size 17370 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_contours.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..be57c0989ead18e9c18ed27f37179ae9cc0531e8 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7a77f89618cf1f69b0dcc318ad7b4d1802f15a1b85f1748e50ce9aa4d68be2a +size 82147 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..614995d792414c5c2c95405f3c7861da15af388c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:196b098a4799d84f464d13a57a7be94609a55cd58bff495221084db22534df6d +size 61576 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..769df3277e2db6a40bafc291b62f314f00f10254 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/67_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a973ac117ea0dd5fc675753c4bb4c84ee098f60f5553a93059b46df39e8a4ee +size 43519 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_contours.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_contours.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..80d36c07102404782b18a9414b743b384608e5c7 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_contours.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d17d06f7de1e7f69068971a74ed13496a073b087f4e5cc3cab8bf02f4bdc9312 +size 54418 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_edges.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_edges.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..9b41bf8271acddbc3a620b6befc6defe0da38da2 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_edges.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f7e39f4e7030fff799c876d0e615a6fa8db093fb22dba060722511a93457388 +size 32758 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_gray.jpg" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_gray.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..5d9c9108eab5ab80a5b9999d4edf4bab7f77f084 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/image_create/mabppe_gray.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05b1603fe62ebf5f8c517cc5dea423d1428116828a7d009b074f68d59107535d +size 30648 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_and_AI_api\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_and_AI_api\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" new file mode 100644 index 0000000000000000000000000000000000000000..5ddf7415e666d0e0124a24fae0b6de58f6f03eba --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_and_AI_api\351\233\231\346\250\241\345\274\217/d2_4_chatbot_memory.py" @@ -0,0 +1,120 @@ +# d2_3_chatbot_memory.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (Gemini / Local Mock 雙模) + +import os +from google import genai + +# 優先讀取環境變數 GEMINI_MODEL,預設為 gemini-3.1-flash-lite +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get( + "GEMINI_API_KEY", "AQ.Ab8RN6JynZRgTmhZIMkFFN5GHSFClFfC3sraiSQ4Pb8d9v0V6Q") + + +def local_mock_response(user_input, history): + user_input_lower = user_input.lower() + + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "營業時間" in user_input_lower or "時間" in user_input_lower: + return "我們的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + elif "課程" in user_input_lower or "課程資訊" in user_input_lower: + return "數學課早上9點到12點,周二到周五,英語課下午1點到3點,周三到五. 國文課下午3點到5點,周一到周五." + elif"價格" in user_input_lower or "多少錢" in user_input_lower: + return "我們的課程價格為每堂課 700 元,有折扣優惠。" + elif "優惠" in user_input_lower or "折扣" in user_input_lower: + return "目前我們提供新學員首次報名可享有 9 折優惠,購買套票有8折優惠。" + elif "套票價格" in user_input_lower or "套票" in user_input_lower: + return "套票為 10 堂課,價格為 5600 元,原價為7000元。" + elif "你好" in user_input_lower or "您好" in user_input_lower: + return "您好!我是補習班客服,請問是要看課程資訊還是報名流程呢? 還是營業時間, 也有試聽課程的體驗喔!" + elif "報名流程" in user_input_lower or "報名" in user_input_lower: + return "報名流程很簡單,您可以先選擇課程,然後上官網填寫報名表格(Ilovelearning67.com),最後完成付款即可。請問要看課程資訊嗎?" + elif "再見" in user_input_lower or "bye" in user_input_lower: + return "感謝您的尋問,再見!" + elif "試聽" in user_input_lower or "試聽課程" in user_input_lower: + return "我們有提供每門課程一次免費試聽的機會喔!歡迎提供您的姓名與電話,我們會請專人為您安排。" + elif "師資" in user_input_lower or "老師" in user_input_lower or "誰上課" in user_input_lower: + return "我們的數學老師畢業於台大數學系,英語老師是從美國來台10年的黑人,有TOEIC證書,國文老師畢業於政大中文系。" + elif "付款" in user_input_lower or "繳費" in user_input_lower or "刷卡" in user_input_lower or "匯款" in user_input_lower: + return "我們支援多種付款方式,包含線上刷卡、ATM 轉帳以及超商條碼繳費,您可以選擇最便利的方式付款喔!" + else: + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (支援對話上下文記憶)") + print("=" * 50) + + use_mock = False + chat_session = None + + if not api_key: + print("[提示] 未檢測到環境變數 GEMINI_API_KEY,將自動啟用「離線模擬模式 (Local Mock Mode)」。") + print(" (如需連線雲端模型,請設定 GEMINI_API_KEY 環境變數)") + use_mock = True + else: + try: + print(f"正在連線 Google Gemini 雲端客服伺服器 ({GEMINI_MODEL})...") + client = genai.Client(api_key=api_key) + # TODO: 設定系統指令 (System Instruction / System Prompt) + system_instruction = ( + "你是一位專業的補習班客服人員,語氣溫和客氣" + "你需要回答使用者關於課程資訊、價格、優惠、套票價格、營業時間等問題" + "親切" + "我們的課程價格為每堂課 700 元,有折扣優惠" + "我們的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + "我們提供新學員首次報名可享有 9 折優惠,購買套票有8折優惠。" + "我們的課程價格為每堂課 700 元,有折扣優惠。" + "套票為 10 堂課,價格為 5600 元,原價為7000元。" + "我們有提供每門課程一次免費試聽的機會喔!歡迎提供您的姓名與電話,我們會請專人為您安排。" + "我們支援多種付款方式,包含線上刷卡、ATM 轉帳以及超商條碼繳費" + "我們的數學老師畢業於台大數學系,英語老師是從美國來台10年的黑人,有TOEIC證書,國文老師畢業於政大中文系。" + ) + + chat_session = client.chats.create( + model=GEMINI_MODEL, + config={'system_instruction': system_instruction} + ) + print("雲端客服系統已就緒!") + except Exception as e: + print(f"連線雲端客服失敗 ({e}),切換至離線模擬模式...") + use_mock = True + + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + if use_mock: + ai_reply = local_mock_response(user_input, local_history) + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + else: + response = chat_session.send_message(user_input) + ai_reply = response.text + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_game/d2_3_chatbot_memory_game_template.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_game/d2_3_chatbot_memory_game_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..81aead66ad91be513c942da4595c05dbfa7bf7e4 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_game/d2_3_chatbot_memory_game_template.py" @@ -0,0 +1,112 @@ +# d2_3_chatbot_memory_game_template.py +# Day 2: 生成式 AI 串接實戰 - 離線 Mock 遊戲設計模板 (學生填空版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬 NPC 或是系統的對話反應。 + + Why: + 此檔案為困難版對話記憶解謎的設計模板。你將學習到: + 1. 前置狀態限制:必須先問過「線索一」,才能查到「線索二」... + 2. 動態 Flag:Flag 答案中的某個線索會隨提問次數(turns)改變。 + """ + user_input_lower = user_input.strip().lower() + + # 計算目前是第幾輪對話 + turns = len(history) // 2 + 1 + + # TODO: 設計你的動態變數(例如 turns * 5,或是 current_weather 等) + # 並設定最終預期的正確 Flag 答案(必須包含此動態變數) + expected_flag = f"flag{{Wemakemoney_Smart_Handgun_{turns}}}".lower() + + # 從歷史中判斷先前是否已取得前置線索(在 history 中,AI model 是否曾回覆過特定字眼) + # TODO: 依據你設計的線索關鍵字,修改以下 history 的 any 比對條件 + asked_clue1 = any("Smart" in h["text"] for h in history if h["role"] == "model") + asked_clue2 = any("Wemakemoney" in h["text"] for h in history if h["role"] == "model") + + # 1. 驗證 Flag 邏輯 + if user_input_lower == expected_flag: + return f"🎉 恭喜你!成功破解案件並獲得了真正的 Flag!你在第 {turns} 回合成功通關!" + + elif "flag{" in user_input_lower: + return ( + f"❌ Flag 驗證失敗!\n" + f"提示:最後一個線索會隨著提問次數動態改變。\n" + f"你現在是在第 {turns} 次提問輸入,因此當前的動態線索應該是 turns{turns} 喔!\n" + f"請重新計算並拼湊最新的 Flag!" + ) + + # 2. 劇情關鍵字問答分支(打造你的 NPC 對話樹!) + # TODO: 依據你的故事背景,修改並設計以下 if-elif-else 結構 + if "flag" in user_input_lower or "help" in user_input_lower: + return "NPC:『想要解開這起謎題,你必須向我打聽案子的「主角」、「地點」與「兇器」!』" + + elif "主角" in user_input_lower or "是誰" in user_input_lower: + # 線索一:靜態線索 + return "【線索一】這起案件的調查主角是著名的偵探,他的名字叫 'Smart'。請記好這串英文,接下來去查查案發的『地點』吧!" + + elif "地點" in user_input_lower or "哪裡" in user_input_lower or "案發現場" in user_input_lower: + # 線索二:有前置限制,必須先取得線索一 + if not asked_clue1: + return "NPC:『你沒給我密語,我不知道你是好是壞!』" + return "【線索二】案發現場就在blackstreet的'Wemakemoney'銀行。請記好這串字,接下來該查犯人的拿凶器!" + + elif "犯人" in user_input_lower or "蹤跡" in user_input_lower or "線索三" in user_input_lower: + # 線索三:動態線索,包含 turns 變數,且必須先取得線索二 + if not asked_clue1: + return "NPC:『你沒給我密語,我不知道你是好是壞!』" + if not asked_clue2: + return "NPC:『你必須先查出「地點」案發現場。』" + return f"【線索三】犯人逃跑時留在地上留的手槍:'_handgun{turns}'。注意,此代號會隨提問次數動態改變!" + + elif "拼湊" in user_input_lower or "結合" in user_input_lower or "怎麼組合" in user_input_lower: + return f"提示:將【線索一】【線索二】【當下提問的線索三】依序結合,並用小寫的 flag{{...}} 包起來。目前第 {turns} 回合的線索三為 '_turns{turns}'。" + + # 3. 兜底回覆(當玩家輸入沒有匹配到任何關鍵字時) + else: + return ( + f"NPC:『你在說什麼?我聽不懂。收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?』" + ) + + +def main(): + # TODO: 修改為你的遊戲標題與故事背景 + print("=" * 60) + print(" 🕵️ Mr.Smart記憶解謎遊戲 - 🕵️") + print("=" * 60) + print("[故事簡介] 你是一位偵探名叫Smart。 你接到了一個神秘案件關於銀行搶劫案,你需要找到不同的線索才能破解這個案件。") + print(" 你可以向 NPC 詢問關於案件的主角、地點、犯人蹤跡等資訊,並將線索拼湊成正確的 Flag 才能成功通關!") + print("-" * 60) + + local_history = [] + + print("\n遊戲已啟動!請輸入你想問 NPC 的話(輸入 'quit' 結束遊戲):") + print("-" * 60) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + if user_input.lower() in ["quit", "exit"]: + print("遊戲結束,感謝遊玩!") + break + + ai_reply = local_mock_response(user_input, local_history) + + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[NPC]: {ai_reply}") + + except KeyboardInterrupt: + print("\n遊戲強行終止!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_\351\227\234\351\215\265\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_\351\227\234\351\215\265\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" new file mode 100644 index 0000000000000000000000000000000000000000..a6d71fc0e95f3cf9a7413a9c0218a7260a307c07 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/mock_\351\227\234\351\215\265\350\251\236\346\212\223\345\217\226/d2_3_chatbot_memory_mock.py" @@ -0,0 +1,80 @@ +# d2_3_chatbot_memory_mock.py +# Day 2: 生成式 AI 串接實戰 - 具對話記憶的智慧客服機器人 (純 Local Mock 教師版) + +def local_mock_response(user_input: str, history: list) -> str: + """ + 根據使用者輸入與歷史對話紀錄,模擬客服助理的自動回覆。 + 在沒有網路或 API 金鑰時,如何透過關鍵字比對 + 與對話歷史長度(計算對話輪數)來模擬一個具有「記憶」能力的對話流程。 + """ + user_input_lower = user_input.lower() + + # 關鍵字比對:模擬常見的客服問答 + # TODO: 處理 營業時間 / 你好 / 再見,其餘回兜底句 + if "營業時間" in user_input_lower or "時間" in user_input_lower: + return "我們觀測站的營業時間為週一至週五的上午 9:00 至下午 6:00,週末休息。" + elif "課程" in user_input_lower or "課程資訊" in user_input_lower: + return "數學課早上9點到12點,周二到周五,英語課下午1點到3點,周三到五. 國文課下午3點到5點,周一到周五." + elif"價格" in user_input_lower or "多少錢" in user_input_lower: + return "我們的課程價格為每堂課 700 元,若購買套票則享有折扣優惠。" + elif "優惠" in user_input_lower or "折扣" in user_input_lower: + return "目前我們提供新學員首次報名可享有 9 折優惠,購買套票有8折優惠。" + elif "套票價格" in user_input_lower or "套票" in user_input_lower: + return "套票為 10 堂課,價格為 5600 元,原價為7000元。" + elif "你好" in user_input_lower or "您好" in user_input_lower: + return "您好!我是補習班客服,請問是要看課程資訊還是報名流程呢?" + elif "報名流程" in user_input_lower or "報名" in user_input_lower: + return "報名流程很簡單,您可以先選擇課程,然後上官網填寫報名表格(Ilovelearning67.com),最後完成付款即可。請問要看課程資訊嗎?" + # 計算目前是第幾輪對話(一輪包含使用者輸入與客服回覆,共 2 個元素) + # Why: 藉由傳入的 history 串列長度除以 2,即可動態得知目前互動的輪數,模擬記憶狀態。 + turns = len(history) // 2 + 1 + return ( + f"(模擬模式)收到您的訊息:『{user_input}』。" + f"這是我與您的第 {turns} 次對話,請問還有其他需要協助的嗎?" + ) + + +def main(): + print("=" * 50) + print(" 得能 AI 智慧客服對話系統 (純離線模擬模式 - 支援對話記憶)") + print("=" * 50) + print("[提示] 目前為純 Local Mock 模式,不需設定環境變數與連線雲端 API。") + print("-" * 50) + + # 用於儲存對話歷史的串列 + local_history = [] + + print("\n系統啟動完成!請輸入您要諮詢的問題(輸入 'quit' 或 'exit' 結束對話):") + print("-" * 50) + + while True: + try: + user_input = input("\n[您]: ").strip() + if not user_input: + continue + + # 結束條件判斷 + if user_input.lower() in ["quit", "exit"]: + print("對話結束,感謝您的使用!") + break + + # 呼叫模擬回覆函式 + ai_reply = local_mock_response(user_input, local_history) + + # 將本次對話記錄寫入歷史紀錄中,維持記憶狀態 + # Why: 每次成功的問答都必須 append 進 history,以便下一輪對話時能正確累計輪數。 + local_history.append({"role": "user", "text": user_input}) + local_history.append({"role": "model", "text": ai_reply}) + + print(f"[AI 客服]: {ai_reply}") + + except KeyboardInterrupt: + # 處理使用者按下 Ctrl+C 強行結束的情境,避免程式崩潰 + print("\n對話強行終止。再見!") + break + except Exception as e: + print(f"\n[錯誤] 發生異常:{e}") + + +if __name__ == "__main__": + main() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/open_mouth_close/step2_facemesh_complete.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/open_mouth_close/step2_facemesh_complete.py" new file mode 100644 index 0000000000000000000000000000000000000000..f96bc596ecb2d03ade9db51b672972f8138a8718 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/open_mouth_close/step2_facemesh_complete.py" @@ -0,0 +1,362 @@ +""" +Week 11 - Face Mesh Expression Detection (Complete Template 填空版) +=========================================================== +本步驟目標: + 1. 將 MediaPipe Face Landmarker 與 Pygame 完美結合,打造科幻儀表板 + 2. 計算臉部指標:嘴巴張開比例與眼睛胖瘦比例 (EAR) + 3. 實作「雙門檻滯後防吵鎖」,避免警報器反覆爆音 + 4. 實作「跳幀推理優化」,解決 AI 推理造成的畫面卡頓 + 5. 整合音效播放功能 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import time + +# ============================================ +# 1. 初始化 Pygame & 音效 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 11 - Face Mesh Expression Detector ") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 36) +small_font = pygame.font.SysFont(None, 24) +title_font = pygame.font.SysFont(None, 48) + +# 載入警告音效 (shoot.wav) +try: + trigger_sound = pygame.mixer.Sound(os.path.join(os.path.dirname(__file__), "shoot.wav")) + print("Success: Loaded sound file shoot.wav") +except FileNotFoundError: + trigger_sound = None + print("Warning: Could not find sound file shoot.wav") + +# ============================================ +# 2. 初始化 MediaPipe Face Landmarker +# ============================================ +model_path = os.path.join(os.path.dirname(__file__), 'face_landmarker.task') +if not os.path.exists(model_path): + # 若在當前資料夾找不到,就去上一層找 + model_path = os.path.join(os.path.dirname(__file__), '..', 'face_landmarker.task') + if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + print("Please make sure face_landmarker.task is in the directory.") + pygame.quit() + sys.exit() + +# 【TODO 1】初始化 MediaPipe Face Landmarker 偵測器 +# try: +# from mediapipe.tasks import python +# from mediapipe.tasks.python import vision +# base_options = python.BaseOptions(model_asset_path=model_path) +# options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +# detector = vision.FaceLandmarker.create_from_options(options) +# print("Success: Loaded MediaPipe detector model") +# except Exception as e: +# print(f"Error: Failed to load model: {e}") +# pygame.quit() +# sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Success: Loaded MediaPipe detector model") +except Exception as e: + print(f"Error: Failed to load model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 3. 臉部特徵點索引定義 +# ============================================ +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 +LEFT_EYE_TOP = 159 +LEFT_EYE_BOTTOM = 145 +LEFT_EYE_RIGHT = 133 + +# ============================================ +# 4. 門檻值與狀態變數 +# ============================================ +# 嘴巴雙門檻防吵鎖 +MOUTH_OPEN_THRESHOLD = 0.35 +MOUTH_CLOSE_THRESHOLD = 0.15 + +# 眼睛雙門檻防吵鎖 +EAR_BLINK_THRESHOLD = 0.18 +EAR_OPEN_THRESHOLD = 0.25 + +is_mouth_open = False +was_mouth_open = False + +is_blinking = False +was_blinking = False + +mouth_open_count = 0 +blink_count = 0 + +# 跳幀優化變數:每 3 影格才做一次 AI 推理 +frame_counter = 0 +FRAME_SKIP = 3 +detector_results = None + +# 開啟攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + pygame.quit() + sys.exit() + +# ============================================ +# 5. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + running = False + + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + frame_counter += 1 + + plot_points = {} + + # 【TODO 2】實作跳幀推理優化 (每 FRAME_SKIP 影格才跑一次 AI) + # if frame_counter % FRAME_SKIP == 0 or detector_results is None: + # try: + # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + # mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + # detector_results = detector.detect(mp_image) + # except Exception as e: + # print(f"Error: Inference exception: {e}") + + if frame_counter % FRAME_SKIP == 0 or detector_results is None: + try: + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + detector_results = detector.detect(mp_image) + except Exception as e: + print(f"Error: Inference exception: {e}") + + # 【TODO 3】特徵點座標映射與比例計算 + # 1. 取得對應點在 Pygame 視窗 (WIDTH, HEIGHT) 中的像素位置並存入 plot_points + # 2. 計算眼寬基準線 base_dist + # 3. 計算歸一化嘴巴比例 mouth_ratio + # 4. 計算眼睛胖瘦比 ear (提示:垂直高度 eye_h / 水平寬度 eye_w) + # if detector_results and detector_results.face_landmarks: + # face_lms = detector_results.face_landmarks[0] + # + # for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + # pt = face_lms[idx] + # plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + # + # p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + # p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + # base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + # + # if base_dist > 0: + # p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + # p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + # mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + # mouth_ratio = mouth_dist / base_dist + # + # p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + # p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + # p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + # + # eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + # eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + # ear = eye_h / eye_w if eye_w > 0 else 0 + + if detector_results and detector_results.face_landmarks: + face_lms = detector_results.face_landmarks[0] + + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM, LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_RIGHT]: + pt = face_lms[idx] + plot_points[idx] = (int(pt.x * WIDTH), int(pt.y * HEIGHT)) + + p_lx, p_ly = plot_points[LEFT_EYE_OUTER] + p_rx, p_ry = plot_points[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + if base_dist > 0: + p_m_top_x, p_m_top_y = plot_points[LIP_TOP] + p_m_bot_x, p_m_bot_y = plot_points[LIP_BOTTOM] + mouth_dist = math.hypot(p_m_bot_x - p_m_top_x, p_m_bot_y - p_m_top_y) + mouth_ratio = mouth_dist / base_dist + + p_e_top_x, p_e_top_y = plot_points[LEFT_EYE_TOP] + p_e_bot_x, p_e_bot_y = plot_points[LEFT_EYE_BOTTOM] + p_e_right_x, p_e_right_y = plot_points[LEFT_EYE_RIGHT] + + eye_h = math.hypot(p_e_bot_x - p_e_top_x, p_e_bot_y - p_e_top_y) + eye_w = math.hypot(p_e_right_x - p_lx, p_e_right_y - p_ly) + ear = eye_h / eye_w if eye_w > 0 else 0 + + # 【TODO 4】實作雙門檻防吵鎖 (滯後判斷邏輯) + # if mouth_ratio > MOUTH_OPEN_THRESHOLD: + # is_mouth_open = True + # elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + # is_mouth_open = False + # + # if ear < EAR_BLINK_THRESHOLD: + # is_blinking = True + # elif ear > EAR_OPEN_THRESHOLD: + # is_blinking = False + + if mouth_ratio > MOUTH_OPEN_THRESHOLD: + is_mouth_open = True + elif mouth_ratio < MOUTH_CLOSE_THRESHOLD: + is_mouth_open = False + + if ear < EAR_BLINK_THRESHOLD: + is_blinking = True + elif ear > EAR_OPEN_THRESHOLD: + is_blinking = False + + # 【TODO 5】實作邊緣觸發判定 (只有在剛張嘴或剛閉眼的那一瞬間,播放一次警告音效) + # if is_mouth_open and not was_mouth_open: + # mouth_open_count += 1 + # if trigger_sound: + # trigger_sound.play() + # + # if is_blinking and not was_blinking: + # blink_count += 1 + # + # was_mouth_open = is_mouth_open + # was_blinking = is_blinking + + if is_mouth_open and not was_mouth_open: + mouth_open_count += 1 + if trigger_sound: + trigger_sound.play() + + if is_blinking and not was_blinking: + blink_count += 1 + + was_mouth_open = is_mouth_open + was_blinking = is_blinking + + else: + is_mouth_open = False + is_blinking = False + was_mouth_open = False + was_blinking = False + mouth_ratio = 0.0 + ear = 0.30 + + # ============================================ + # 6. 渲染 Pygame 畫面與 UI 元素 + # ============================================ + # 【TODO 6】根據狀態切換背景顏色 (安全時深色,張嘴時深綠色,眨眼時深藍色) + # if is_mouth_open: + # screen.fill((16, 44, 28)) + # elif is_blinking: + # screen.fill((15, 30, 54)) + # else: + # screen.fill((20, 20, 30)) + + if is_mouth_open: + screen.fill((16, 44, 28)) + elif is_blinking: + screen.fill((15, 30, 54)) + else: + screen.fill((20, 20, 30)) + + # 【TODO 7】畫出眼寬基準線、眼睛高度線與嘴唇高度線 + # 提示:用 pygame.draw.line 在螢幕上將點位連線 + # if plot_points: + # pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + # pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + # pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + if plot_points: + pygame.draw.line(screen, (100, 100, 150), plot_points[LEFT_EYE_OUTER], plot_points[RIGHT_EYE_OUTER], 2) + pygame.draw.line(screen, (245, 158, 11), plot_points[LIP_TOP], plot_points[LIP_BOTTOM], 3) + pygame.draw.line(screen, (59, 130, 246), plot_points[LEFT_EYE_TOP], plot_points[LEFT_EYE_BOTTOM], 3) + + # 畫特徵點小圓圈 + for idx, pos in plot_points.items(): + color = (255, 255, 255) + if idx in [LIP_TOP, LIP_BOTTOM]: + color = (245, 158, 11) + elif idx in [LEFT_EYE_TOP, LEFT_EYE_BOTTOM, LEFT_EYE_OUTER, LEFT_EYE_RIGHT]: + color = (59, 130, 246) + pygame.draw.circle(screen, color, pos, 6) + + # 繪製儀表板 HUD 卡片 + pygame.draw.rect(screen, (30, 30, 45), (30, 30, 340, 220), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (30, 30, 340, 220), width=2, border_radius=12) + + screen.blit(font.render("Face Mesh Dashboard", True, (255, 255, 255)), (50, 45)) + + m_color = (34, 197, 94) if is_mouth_open else (245, 158, 11) + screen.blit(small_font.render(f"Mouth Ratio: {mouth_ratio:.2f}", True, m_color), (50, 90)) + pygame.draw.rect(screen, (50, 50, 65), (50, 110, 200, 12), border_radius=4) + pygame.draw.rect(screen, m_color, (50, 110, int(min(mouth_ratio, 1.0) * 200), 12), border_radius=4) + + e_color = (59, 130, 246) if not is_blinking else (239, 68, 68) + screen.blit(small_font.render(f"Eye EAR: {ear:.2f}", True, e_color), (50, 140)) + pygame.draw.rect(screen, (50, 50, 65), (50, 160, 200, 12), border_radius=4) + pygame.draw.rect(screen, e_color, (50, 160, int(min(ear, 0.5) * 400), 12), border_radius=4) + + # 繪製閾值指示白線 + pygame.draw.line(screen, (255, 255, 255), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 108), (50 + int(MOUTH_OPEN_THRESHOLD * 200), 124), 2) + pygame.draw.line(screen, (255, 255, 255), (50 + int(EAR_BLINK_THRESHOLD * 400), 158), (50 + int(EAR_BLINK_THRESHOLD * 400), 174), 2) + + # 繪製統計卡片 + pygame.draw.rect(screen, (30, 30, 45), (WIDTH - 320, 30, 290, 110), border_radius=12) + pygame.draw.rect(screen, (50, 50, 70), (WIDTH - 320, 30, 290, 110), width=2, border_radius=12) + screen.blit(small_font.render(f"Mouth Open Count: {mouth_open_count}", True, (255, 255, 255)), (WIDTH - 300, 45)) + screen.blit(small_font.render(f"Blink/Close Count: {blink_count}", True, (255, 255, 255)), (WIDTH - 300, 75)) + screen.blit(small_font.render(f"Hysteresis Status: ACTIVE", True, (34, 197, 94)), (WIDTH - 300, 105)) + + # 顯示置中警告狀態大字 + if is_mouth_open: + status_lbl = title_font.render("MOUTH OPENED", True, (34, 197, 94)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + elif is_blinking: + status_lbl = title_font.render("BLINKING", True, (59, 130, 246)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + else: + if not plot_points: + status_lbl = title_font.render("NO FACE DETECTED", True, (239, 68, 68)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 - 20)) + else: + status_lbl = title_font.render("NORMAL", True, (200, 200, 200)) + screen.blit(status_lbl, (WIDTH // 2 - status_lbl.get_width() // 2, HEIGHT // 2 + 100)) + + # 顯示底部效能狀態文字 + status_text = small_font.render( + f"Frame: {frame_counter} | Skip: {FRAME_SKIP} frames | FPS: {clock.get_fps():.0f}", + True, (100, 100, 120) + ) + screen.blit(status_text, (20, HEIGHT - 35)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/open_mouth_close/step3_mouth_ratio.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/open_mouth_close/step3_mouth_ratio.py" new file mode 100644 index 0000000000000000000000000000000000000000..a1a218a6777e5772816ac5202d52c0f03b3c8bfe --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/open_mouth_close/step3_mouth_ratio.py" @@ -0,0 +1,113 @@ +""" +Week 11 - Step 2: Mouth Opening Ratio Detection (Template 填空版) +============================================================= +本步驟目標: + 1. 找出特定的臉部特徵點:眼角(33, 263)與嘴唇(13, 14) + 2. 計算兩眼距離,當作臉寬基準比例尺,防止人靠近或遠離鏡頭造成的誤判 (歸一化) + 3. 計算嘴巴張開比例:嘴巴距離 / 兩眼距離 + 4. 在 OpenCV 畫面上即時顯示比例與嘴巴開合狀態 (OPEN / CLOSED) +""" +import cv2 +import mediapipe as mp +import math +import sys +import os + +# 1. 初始化攝影機 +cap = cv2.VideoCapture(0) +if not cap.isOpened(): + print("Error: Could not open camera.") + sys.exit() + +# 2. 載入 MediaPipe 臉部偵測器模型 (Face Landmarker) +model_path = os.path.join('face_landmarker.task') +if not os.path.exists(model_path): + print(f"Error: Model file not found at {model_path}") + sys.exit() + +try: + from mediapipe.tasks import python + from mediapipe.tasks.python import vision + base_options = python.BaseOptions(model_asset_path=model_path) + options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) + detector = vision.FaceLandmarker.create_from_options(options) + print("Face Landmarker initialized successfully.") +except Exception as e: + print(f"Error: Failed to initialize detector: {e}") + sys.exit() + +# 特徵點索引定義 +LEFT_EYE_OUTER = 33 +RIGHT_EYE_OUTER = 263 +LIP_TOP = 13 +LIP_BOTTOM = 14 + +# 嘴巴張開的比例閾值 (超過代表張開) +THRESHOLD = 0.35 + +print("Press 'q' in the window to exit.") + +# 3. 主迴圈 +while True: + success, img = cap.read() + if not success: + break + + img = cv2.flip(img, 1) + img_h, img_w, _ = img.shape + + # 影像處理 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 【TODO 1】取得這 4 個特徵點在畫面中的 (x, y) 像素座標,並存在 pts 字典中 + pts = {} + for idx in [LEFT_EYE_OUTER, RIGHT_EYE_OUTER, LIP_TOP, LIP_BOTTOM]: + pt = face_lms[idx] + pts[idx] = (int(pt.x * img_w), int(pt.y * img_h)) + + + # 【TODO 2】計算兩眼外角距離 (做為基準比例尺) + p_lx, p_ly = pts[LEFT_EYE_OUTER] + p_rx, p_ry = pts[RIGHT_EYE_OUTER] + base_dist = math.hypot(p_rx - p_lx, p_ry - p_ly) + + + if base_dist > 0: + # 【TODO 3】計算嘴巴張開的實際像素距離,並除以眼距算出「嘴巴張開比例 (mouth_ratio)」 + p_top_x, p_top_y = pts[LIP_TOP] + p_bot_x, p_bot_y = pts[LIP_BOTTOM] + mouth_dist = math.hypot(p_bot_x - p_top_x, p_bot_y - p_top_y) + mouth_ratio = mouth_dist / base_dist + + + # 【TODO 4】判斷嘴巴狀態是否大於 THRESHOLD,並在畫面上繪製量測輔助線 + # 提示:用 cv2.line 畫出眼部基準線 (藍色) 和嘴巴測量線 (橘色) + is_open = mouth_ratio > THRESHOLD + cv2.line(img, (p_lx, p_ly), (p_rx, p_ry), (255, 0, 0), 2) # 藍色眼線 + cv2.line(img, (p_top_x, p_top_y), (p_bot_x, p_bot_y), (0, 165, 255), 2) # 橘色嘴線 + + + # 畫出特徵點小黃圓圈 + for idx, pos in pts.items(): + cv2.circle(img, pos, 5, (0, 255, 255), -1) + + # 顯示比例與開合狀態文字 + cv2.putText(img, f"Mouth Ratio: {mouth_ratio:.2f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) + status_text = "OPEN" if is_open else "CLOSED" + status_color = (0, 255, 0) if is_open else (0, 0, 255) + cv2.putText(img, f"State: {status_text}", (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, status_color, 2) + else: + cv2.putText(img, "No Face Detected", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) + + cv2.imshow("Mouth Ratio Detector - Press 'q' to quit", img) + + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/requirements.txt" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/requirements.txt" new file mode 100644 index 0000000000000000000000000000000000000000..ac7f00648560530b9d1168986de056f2bdfd6a45 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/requirements.txt" @@ -0,0 +1,6 @@ +pandas>=2.0.0 +matplotlib>=3.7.0 +requests>=2.31.0 +beautifulsoup4>=4.12.0 +opencv-python>=4.8.0 +google-generativeai>=0.8.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\270\211\346\214\207/d5_pinch_challenge_solution.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\270\211\346\214\207/d5_pinch_challenge_solution.py" new file mode 100644 index 0000000000000000000000000000000000000000..88abf5a7de30aeab0aaf0a6532b8ed1e53e2767f --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\270\211\346\214\207/d5_pinch_challenge_solution.py" @@ -0,0 +1,216 @@ +""" +Week 10 - 魔法手勢:Pinch 偵測與手勢按鈕 (三指捏合挑戰 Template 填空版) +================================================================== +本週挑戰目標: + 1. 實作「三指捏合 (Three-Finger Pinch)」: + 必須同時滿足「大拇指 (4) 與食指 (8) 的距離 < Threshold」 + 以及「大拇指 (4) 與中指 (12) 的距離 < Threshold」才判定為 Pinch + 2. 用三指碰觸 Pygame 畫面上的按鈕 + 捏合 = 魔法點擊 + 3. 使用邊緣觸發 (Edge Detection) 防止重複觸發 + 4. 新增 30 秒限時機制,時間到顯示最終得分 +""" +import pygame +import cv2 +import mediapipe as mp +import math +import sys +import os +import random + +from mediapipe.tasks import python +from mediapipe.tasks.python import vision + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 10 - 三指魔法手勢按鈕 (填空版) ") +clock = pygame.time.Clock() + +# 攝影機 +cap = cv2.VideoCapture(0) + +# 【TODO 1】初始化 MediaPipe 手勢偵測器 (Hand Landmarker) + +try: + base_options = python.BaseOptions(model_asset_path=os.path.join('hand_landmarker.task')) + options = vision.HandLandmarkerOptions(base_options=base_options, num_hands=1) + detector = vision.HandLandmarker.create_from_options(options) +except Exception as e: + print(f"Error loading model: {e}") + pygame.quit() + sys.exit() + +# ============================================ +# 2. 遊戲變數 +# ============================================ +# 手指座標 +finger_x, finger_y = 400, 300 # 食指 +thumb_x, thumb_y = 400, 300 # 大拇指 +middle_x, middle_y = 400, 300 # 中指 + +# 距離變數 +dist_thumb_index = 0.0 +dist_thumb_middle = 0.0 + +# Pinch 相關變數 +is_pinching = False +was_pinching = False # 邊緣觸發 +PINCH_THRESHOLD = 40 # 距離臨界值 + +# 按鈕設定 +button_rect = pygame.Rect(300, 250, 200, 100) +button_colors = [(3, 11, 255), (234,255,3), + (100, 220, 100), (220, 180, 50)] +current_color_index = 0 + +# 點擊計數器 +click_count = 0 + +# --- 限時機制變數 --- +START_TIME = pygame.time.get_ticks() +GAME_DURATION = 30 # 遊戲限制時間(秒) +game_state = "PLAYING" # 狀態機 + +# ============================================ +# 3. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機與手勢偵測 --- + success, img = cap.read() + is_pinching = False # 每幀重設狀態,防止手離屏時卡死 + + if success: + img = cv2.flip(img, 1) # 鏡像翻轉 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + # === 【TODO 2:挑戰關卡三之手部安全偵測】 === + # 1. 判斷手部是否在畫面中 (results.hand_landmarks 是否有資料) + # 2. 如果在畫面中,取得食指 (8)、大拇指 (4)、中指 (12) 的像素座標 + # 3. 如果手不在畫面中,將 is_pinching 設為 False + # + hand_in_frame = False + if results.hand_landmarks: + hand_in_frame = True + for hand_lms in results.hand_landmarks: + # 食指 (8) + lm8 = hand_lms[8] + finger_x = int(lm8.x * WIDTH) + finger_y = int(lm8.y * HEIGHT) + + # 大拇指 (4) + lm4 = hand_lms[4] + thumb_x = int(lm4.x * WIDTH) + thumb_y = int(lm4.y * HEIGHT) + + # 中指 (12) + lm12 = hand_lms[12] + middle_x = int(lm12.x * WIDTH) + middle_y = int(lm12.y * HEIGHT) + + + # === 【TODO 3:挑戰關卡一之三指捏合判定】 === + # 提示:計算食指與大拇指距離,以及中指與大拇指距離 + # 兩組距離皆小於 PINCH_THRESHOLD 時,is_pinching 才為 True + dist_thumb_index = math.hypot(finger_x - thumb_x, finger_y - thumb_y) + dist_thumb_middle = math.hypot(middle_x - thumb_x, middle_y - thumb_y) + is_pinching = (dist_thumb_index < PINCH_THRESHOLD) and (dist_thumb_middle < PINCH_THRESHOLD) + + + + # --- 狀態機邏輯處理 --- + if game_state == "PLAYING": + # 1. 計算剩餘時間 + current_ticks = pygame.time.get_ticks() + elapsed_seconds = (current_ticks - START_TIME) / 1000 + remaining_time = max(0, GAME_DURATION - elapsed_seconds) + + # 2. 檢查時間是否到了 + if remaining_time <= 0: + game_state = "GAME_OVER" + + # 3. 邊緣觸發點擊判斷 + if is_pinching and not was_pinching: + finger_rect = pygame.Rect(finger_x - 5, finger_y - 5, 10, 10) + if finger_rect.colliderect(button_rect): + click_count += 1 + current_color_index = (current_color_index + 1) % len(button_colors) + + # === 【TODO 4:挑戰關卡二之按鈕隨機移動】 === + # 提示:當點擊發生時,讓按鈕的 x 和 y 座標隨機分配在畫面內 + button_rect.x = random.randint(0, WIDTH - button_rect.width) + button_rect.y = random.randint(0, HEIGHT - button_rect.height) + + + print(f" 魔法三指點擊!第 {click_count} 次") + + # 更新上一幀狀態 + was_pinching = is_pinching + + # --- 繪製畫面 --- + screen.fill((20, 20, 35)) + font = pygame.font.SysFont(None, 32) + large_font = pygame.font.SysFont(None, 64) + + if game_state == "PLAYING": + # 繪製按鈕 + btn_color = button_colors[current_color_index] + pygame.draw.rect(screen, btn_color, button_rect, border_radius=16) + + btn_text = font.render(f"Clicks: {click_count}", True, (255, 255, 255)) + screen.blit(btn_text, (button_rect.centerx - btn_text.get_width() // 2, + button_rect.centery - btn_text.get_height() // 2)) + + # 顯示倒數計時 + time_text = font.render(f"Time Left: {remaining_time:.1f}s", True, (255, 220, 100)) + screen.blit(time_text, (WIDTH - time_text.get_width() - 20, 10)) + + # 畫手指游標 + cursor_color = (50, 255, 50) if is_pinching else (255, 50, 50) + pygame.draw.circle(screen, cursor_color, (finger_x, finger_y), 12) # 食指 + pygame.draw.circle(screen, (255, 255, 255), (thumb_x, thumb_y), 12) # 大拇指 + pygame.draw.circle(screen, (100, 150, 255), (middle_x, middle_y), 12) # 中指 + + # === 【TODO 5:挑戰關卡三之畫面提示】 === + # 提示:當手不在畫面中時,在畫面上方顯示警告文字提醒學生將手部放回 + if not hand_in_frame: + warning_text = font.render("OUT OF FRAME - PLEASE PUT HANDS BACK", True, (255, 100, 100)) + screen.blit(warning_text, (WIDTH // 2 - warning_text.get_width() // 2, 100)) + + + + # 顯示調試數值 + small_font = pygame.font.SysFont(None, 24) + status = small_font.render(f"Finger: ({finger_x}, {finger_y})", True, (150, 150, 150)) + screen.blit(status, (10, 10)) + + dist_text = small_font.render(f"Dist Thumb-Index: {dist_thumb_index:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text, (10, 35)) + + dist_text2 = small_font.render(f"Dist Thumb-Middle: {dist_thumb_middle:.0f} px", True, (150, 150, 200)) + screen.blit(dist_text2, (10, 60)) + + elif game_state == "GAME_OVER": + # 繪製遊戲結束畫面 + over_text = large_font.render("GAME OVER", True, (255, 50, 50)) + score_text = font.render(f"Total Clicks: {click_count} times", True, (255, 255, 255)) + + screen.blit(over_text, (WIDTH // 2 - over_text.get_width() // 2, HEIGHT // 2 - 50)) + screen.blit(score_text, (WIDTH // 2 - score_text.get_width() // 2, HEIGHT // 2 + 20)) + + pygame.display.update() + clock.tick(30) + +cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" new file mode 100644 index 0000000000000000000000000000000000000000..4348e184a95cb25acbd3c0561ba33a128af02b5e --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection.py" @@ -0,0 +1,128 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=1, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + cv2.circle(frame, (x + w // 2, y + h // 2), 20, (0, 0, 255), -1) + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "Sigma Face", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" new file mode 100644 index 0000000000000000000000000000000000000000..1fe9eddb8d0e7aa99299f86a8c7cbdbb7f6f0864 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\344\272\272\350\207\211\350\276\250\350\255\230/d3_2_face_detection2.py" @@ -0,0 +1,139 @@ +# d3_2_face_detection.py +# Day 3: 電腦視覺與影像辨識 - Haar Cascade 即時人臉偵測與相機串流 (學員練習版) +import cv2 +import os +import urllib.request + +# 1. 取得 Haar Cascade 臉部特徵分類器模型檔案 +XML_FILE = "haarcascade_frontalface_default.xml" +XML_URL = ( + "https://raw.githubusercontent.com/opencv/opencv/master/" + "data/haarcascades/haarcascade_frontalface_default.xml" +) + +# 優先使用 OpenCV 內建路徑,其次本機檔案,最後才下載 + + +def load_face_cascade(): + bundled = os.path.join(cv2.data.haarcascades, + "haarcascade_frontalface_default.xml") + if os.path.exists(bundled): + print(f"使用 OpenCV 內建分類器:{bundled}") + return cv2.CascadeClassifier(bundled) + + if not os.path.exists(XML_FILE): + print("未檢測到臉部特徵分類模型檔,正在從 OpenCV 官方來源下載...") + try: + urllib.request.urlretrieve(XML_URL, XML_FILE) + print("分類器下載完成!") + except Exception as e: + print(f"[錯誤] 下載模型檔失敗:{e}") + print("請手動下載 haarcascade_frontalface_default.xml 並置於同目錄下。") + exit(1) + + cascade = cv2.CascadeClassifier(XML_FILE) + if cascade.empty(): + print("[錯誤] 分類器載入失敗,XML 可能損毀。") + exit(1) + return cascade + + +face_cascade = load_face_cascade() + +# 2. 啟動視訊鏡頭 (0 代表預設鏡頭) +# 無鏡頭時可改為影片:cv2.VideoCapture("test_video.mp4") +print("正在開啟視訊鏡頭,請稍候...") +cap = cv2.VideoCapture(0) + +if not cap.isOpened(): + print("[錯誤] 無法開啟視訊鏡頭!請確認鏡頭權限已開啟且未被其他程式佔用。") + print("備案:將 VideoCapture(0) 改為 VideoCapture(\"test_video.mp4\") 使用影片測試。") + exit(1) + +print("\n視訊串流已啟動!") +print("提示:點擊視窗後,按下鍵盤上的 'q' 鍵即可退出程式。") +print("-" * 50) + +while True: + ret, frame = cap.read() + if not ret: + print("[警告] 無法讀取鏡頭畫面,正在重新嘗試...") + continue + + # === 【重要觀念 TODO 1:水平翻轉鏡頭畫面】 === + # 觀念重點:預設讀取的鏡頭畫面是左右相反的(像看著別人)。 + # 使用 cv2.flip(frame, 1) 可以將畫面水平翻轉,使其符合我們照鏡子的視覺習慣。 + # 請解開下方註釋 (刪除行首的 #) 並執行,觀察鏡頭畫面的翻轉變化。 + frame = cv2.flip(frame, 1) + + # 預設使用原始畫面,避免未解開註釋時程式出錯 + gray = frame + + # === 【重要觀念 TODO 2:灰階轉換 (色彩空間轉換)】 === + # 觀念重點:人臉特徵偵測 (如 Haar Cascade) 主要依賴亮度與對比資訊,不需要色彩資訊。 + # 將影像轉換為灰階 (BGR to GRAY) 可以減少 2/3 的資料量,大幅提升偵測速度並減少干擾。 + # 請解開下方註釋 (刪除行首的 #),將畫面轉換為灰階。 + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + + # 預設無偵測到人臉,避免未解開註釋時程式出錯 + faces = [] + + # === 【重要觀念 TODO 3:使用 Haar Cascade 分類器進行人臉偵測】 === + # 觀念重點:detectMultiScale 會在影像中尋找臉部,回傳每張臉的 (x, y, w, h) 座標。 + # - scaleFactor=1.1: 每次搜尋時將影像縮小 10%,用來偵測不同大小(距離)的人臉。 + # - minNeighbors=5: 判定同一區域為人臉的鄰近候選框數量,數值愈高愈嚴格,能減少誤判。 + # - minSize=(60, 60): 忽略小於此尺寸的偵測框,避免遠處背景雜訊干擾。 + # 請解開下方所有註釋 (刪除行首的 #) 來啟用人臉偵測功能。 + faces = face_cascade.detectMultiScale( + gray, + scaleFactor=1.1, + minNeighbors=1, + minSize=(60, 60), + ) + + # === 【重要觀念 TODO 4:繪製人臉偵測外框與文字標記】 === + # 觀念重點:當模型找出人臉 (x, y, w, h) 之後,我們可以在畫面上把結果畫出来。 + # - cv2.rectangle: 畫矩形,需要指定左上角 (x, y) 與右下角 (x+w, y+h)、BGR 顏色與框線粗細。 + # - cv2.putText: 寫文字,常用於分類結果或標記名稱。 + # 請解開下方所有註釋 (刪除行首的 #),將偵測到的結果畫在畫面上。 + for (x, y, w, h) in faces: + 1. #擷取人臉區域 (Slice) + face_roi = frame[y:y+h, x:x+w] + + 2. #縮小人臉圖像 (例如縮小到 15x15 像素) + small = cv2.resize(face_roi, (15, 15), interpolation=cv2.INTER_LINEAR) + + 3. #放大回原本的大小 (使用 NEAREST 內插法保留顆粒感) + mosaic = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + + 4. #放回原畫面中 + frame[y:y+h, x:x+w] = mosaic + + cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 100, 0), 3) + cv2.putText( + frame, "Human Face", (x, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 100, 0), 2, + ) + + # 畫面左上角顯示目前狀態:有抓到臉就顯示數量,否則顯示搜尋中 + if len(faces) > 0: + cv2.putText( + frame, f"Faces Detected: {len(faces)}", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2, + ) + else: + cv2.putText( + frame, "Searching Face...", (20, 40), + cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2, + ) + + # 把本回合處理後的影格送到視窗,形成即時串流效果 + cv2.imshow("Smart Face Detection Camera", frame) + + # waitKey(1) 既檢測按鍵,也驅動視窗重繪 + if cv2.waitKey(1) & 0xFF == ord('q'): + break + +cap.release() +cv2.destroyAllWindows() +print("視訊鏡頭已關閉,資源已安全釋放。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\205\251\347\234\274/step0_eyeguard_template.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\205\251\347\234\274/step0_eyeguard_template.py" new file mode 100644 index 0000000000000000000000000000000000000000..4cd9603129e33fc7e6e87f3034ef4ac68225cca5 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\205\251\347\234\274/step0_eyeguard_template.py" @@ -0,0 +1,156 @@ +""" +Week 12 - AI 生活工具箱:護眼大師 (Template 填空版) +===================================================== +本週目標: + 1. 將 AI 視覺技術整合進一個「解決生活痛點」的完整專案 + 2. 使用 MediaPipe 臉部偵測兩眼距離,判斷是否離螢幕太近 + 3. 在 Pygame 畫面上顯示即時狀態與警報 + 4. 加入防呆機制(try-except、None 檢查) + +本範例:護眼大師 EyeGuard + - 偵測臉部兩眼之間的距離(兩眼距離越大 = 離鏡頭越近) + - 超過安全閾值時發出警報文字 + 閃爍效果 + - 適用場景:打電動、寫作業時防止坐姿太差靠太近 + +注意: + - 確保 face_landmarker.task 檔案和這個腳本放在同一個資料夾。 +""" +import pygame +import cv2 +import mediapipe as mp +from mediapipe.tasks import python +from mediapipe.tasks.python import vision +import math +import sys +import os + +# ============================================ +# 1. 初始化 +# ============================================ +pygame.init() +pygame.mixer.init() +WIDTH, HEIGHT = 800, 600 +screen = pygame.display.set_mode((WIDTH, HEIGHT)) +pygame.display.set_caption("Week 12 - 護眼大師 EyeGuard 👁️🛡️") +clock = pygame.time.Clock() + +font = pygame.font.SysFont(None, 48) +small_font = pygame.font.SysFont(None, 28) +big_font = pygame.font.SysFont(None, 72) + +# ============================================ +# 2. 攝影機與 AI 模組 +# ============================================ +cap = cv2.VideoCapture(0) +# 沒有攝影機就改成影片:cap = cv2.VideoCapture("backup_face_video.mp4") + +# 【TODO 1】初始化 MediaPipe 臉部偵測 (Face Landmarker) +base_options = python.BaseOptions(model_asset_path=os.path.join('face_landmarker.task')) +options = vision.FaceLandmarkerOptions(base_options=base_options, num_faces=1) +detector = vision.FaceLandmarker.create_from_options(options) + +# 提示:左眼的 Landmark 索引為 33,右眼為 263 + +# ============================================ +# 3. 遊戲變數 +# ============================================ +eye_distance = 0 +DANGER_THRESHOLD = 120 # 兩眼像素距離超過此值 = 太近了! + +# 【TODO 2】警報狀態變數 +is_too_close = False +was_too_close = False # 邊緣觸發 +warning_timer = 0 # 警告閃爍計時器 + +# 統計數據 +total_warnings = 0 +safe_seconds = 0 +frame_counter = 0 + +# ============================================ +# 4. 主迴圈 +# ============================================ +running = True +while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + # --- 讀取攝影機 --- + success, img = cap.read() + if not success: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + continue + + img = cv2.flip(img, 1) + frame_counter += 1 + + # 【TODO 3】臉部偵測與兩眼距離計算 + img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=img_rgb) + results = detector.detect(mp_image) + + if results.face_landmarks: + face_lms = results.face_landmarks[0] + + # 取得左眼 (33) 和右眼 (263) 座標 + left_eye = face_lms[33] + right_eye = face_lms[263] + + lx, ly = int(left_eye.x * WIDTH), int(left_eye.y * HEIGHT) + rx, ry = int(right_eye.x * WIDTH), int(right_eye.y * HEIGHT) + + # 計算兩眼之間的像素距離 + eye_distance = math.hypot(rx - lx, ry - ly) + + # 【TODO 4】判斷是否太近 + is_too_close = eye_distance > DANGER_THRESHOLD + + #【TODO 5】邊緣觸發:剛進入危險區才播放警報 + if is_too_close and not was_too_close: + total_warnings += 1 + warning_timer = 60 # 閃爍 60 幀 + print(f"⚠️ 第 {total_warnings} 次警告:你離螢幕太近了!") + + was_too_close = is_too_close + + # --- 繪製畫面 --- + # 【TODO 6】根據狀態切換背景顏色 + if is_too_close: + if warning_timer > 0 and warning_timer % 4 < 2: + screen.fill((80, 20, 20)) # 閃爍紅色 + else: + screen.fill((50, 15, 15)) # 暗紅色 + warning_timer = max(0, warning_timer - 1) + else: + screen.fill((15, 30, 20)) # 安全綠色 + screen.fill((20, 20, 35)) # 預設背景 + + # 【TODO 7】繪製距離量表 + # 提示:用 pygame.draw.rect 畫一個長條,長度 = eye_distance 的比例 + # 安全時綠色,危險時紅色 + if is_too_close: + pygame.draw.rect(screen,(255,80,80), (50, HEIGHT - 50, min(eye_distance*2, WIDTH - 100), 20)) + else : + pygame.draw.rect(screen,(80,200,100), (50, HEIGHT - 50, min(eye_distance*2, WIDTH - 100), 20)) + # 【TODO 8】如果太近,顯示大字警告 + if is_too_close: + warn_text = big_font.render("⚠️ TOO CLOSE!", True, (255, 80, 80)) + screen.blit(warn_text, (WIDTH//2 - warn_text.get_width()//2, HEIGHT//2 - 40)) + else: + safe_text = font.render("✅ Safe Distance", True, (80, 200, 100)) + screen.blit(safe_text, (WIDTH//2 - safe_text.get_width()//2, HEIGHT//2 - 20)) + + # 顯示數值 + info = small_font.render(f"Eye Distance: {eye_distance:.0f} px | Threshold: {DANGER_THRESHOLD}", True, (150, 150, 180)) + screen.blit(info, (10, 10)) + + stats = small_font.render(f"Warnings: {total_warnings}", True, (150, 150, 180)) + screen.blit(stats, (10, 40)) + + pygame.display.update() + clock.tick(30) + +# cap.release() +pygame.quit() +sys.exit() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\234\226\345\203\217\347\271\252\350\243\275\347\265\220\346\236\234/coffee_sales_analysis.png" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\234\226\345\203\217\347\271\252\350\243\275\347\265\220\346\236\234/coffee_sales_analysis.png" new file mode 100644 index 0000000000000000000000000000000000000000..af19deb53683adbb8d52b7fc6282866646b2d55d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\234\226\345\203\217\347\271\252\350\243\275\347\265\220\346\236\234/coffee_sales_analysis.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98bab0c96897def02b52965fec8fbb525b2ff8b7cbd1ffb0c484c0b3534c297a +size 393334 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\234\226\345\203\217\347\271\252\350\243\275\347\265\220\346\236\234/taipei_weather_2026.png" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\234\226\345\203\217\347\271\252\350\243\275\347\265\220\346\236\234/taipei_weather_2026.png" new file mode 100644 index 0000000000000000000000000000000000000000..a7d6d6c5100e13b4271d9aa2347f6acd6cf4b3f9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\234\226\345\203\217\347\271\252\350\243\275\347\265\220\346\236\234/taipei_weather_2026.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68351931c53f8ed88af2bed75cc93c4cdc1567a551508e25ec38821c0f1a37d7 +size 277046 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226and\351\225\267\346\242\235\345\234\226/d1_3_visualization.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226and\351\225\267\346\242\235\345\234\226/d1_3_visualization.py" new file mode 100644 index 0000000000000000000000000000000000000000..107064dc32369d7a9e0d43b084c16090ef21525a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226and\351\225\267\346\242\235\345\234\226/d1_3_visualization.py" @@ -0,0 +1,92 @@ +# d1_3_visualization.py +# Day 1: 資料科學與數據視覺化 - Matplotlib 繪圖基礎(學員填空) +# cd examples/Day1/student && python3 d1_3_visualization.py +# 參考答案:../Teacher/d1_3_visualization.py + +import os +import pandas as pd +import matplotlib.pyplot as plt + + +def load_csv(filename="taipei_weather_2026.csv"): + """安全載入 CSV 的輔助函式,用以防範在不同執行路徑下相對路徑讀檔失敗的問題""" + # === 【學員填空練習點 1:CSV 檔案安全讀取】 === + path = os.path.join(os.path.dirname(__file__), filename) + if os.path.exists(path): + + return pd.read_csv(path) + else : + print("SCV檔案不存在") + + + + +df = load_csv() + +# 設定中文字型與負號顯示防禦,避免圖表出現亂碼方塊 +plt.rcParams['font.sans-serif'] = [ + 'PingFang TC', 'Heiti TC', 'Arial Unicode MS', + 'Microsoft JhengHei', 'Noto Sans CJK TC', 'SimHei', +] +plt.rcParams['axes.unicode_minus'] = False + +# === 【學員填空練習點 2:建立子圖畫布 (Subplots)】 === +# TODO: 使用 plt.subplots 建立一個 2 列 1 行 (2x1) 的子圖畫布,尺寸大小為寬 10 英吋、高 8 英吋 +# 並指派給 fig 與 (ax1, ax2) +fig, (ax1, ax2) = plt.subplots(2, 1, figsize =(10, 8)) + +# 1. 繪製折線圖 (Line Plot) - 每日溫度 +# === 【學員填空練習點 3:繪製每日溫度折線圖】 === +# TODO: 1. 在 ax1 畫布上以 ax1.plot() 繪製折線圖,X軸傳入 df['Date'],Y軸傳入 df['Temperature'] +# 設定參數:帶有圓點 marker='o',顏色 color='crimson',圖例 label='溫度 (°C)',線寬 linewidth=2 +# 2. 使用 ax1.set_title 設定標題 "台北觀測站每日溫度趨勢圖" +# 3. 設定 ax1.set_xlabel("日期") 與 ax1.set_ylabel("數值") +# 4. 設定網格 ax1.grid(True, linestyle='--', alpha=0.5) +# 5. 呼叫 ax1.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax1.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax1 的溫度折線圖繪製: +ax1.plot(df['Date'], df['Temperature'], marker='o', color='crimson', label='溫度 (C)', linewidth=2) +ax1.plot(df['Date'], df['Humidity'], marker='s', color='orange', label='濕度 (%)', linewidth=2) +ax1.set_title("台北觀測站每日溫度趨勢圖", fontsize=14, fontweight = 'bold') +ax1.set_xlabel("日期") +ax1.set_ylabel("數值") +ax1.grid(True, linestyle='--', alpha=0.5) +ax1.legend() +ax1.tick_params(axis='x', rotation = 45) + + + + + +# 2. 繪製條形圖 (Bar Plot) - 每日降雨量 +# === 【學員填空練習點 4:繪製每日降雨量長條圖】 === +# TODO: 1. 在 ax2 畫布上以 ax2.bar() 繪製長條圖,X軸傳入 df['Date'],Y軸傳入 df['Rainfall'] +# 設定參數:顏色 color='skyblue',外框顏色 edgecolor='royalblue',圖例 label='降雨量 (mm)' +# 2. 使用 ax2.set_title 設定標題 "台北觀測站每日降雨量分佈" +# 3. 設定 ax2.set_xlabel("日期") 與 ax2.set_ylabel("降雨量 (mm)") +# 4. 設定 Y 軸網格 ax2.grid(True, axis='y', linestyle='--', alpha=0.5) +# 5. 呼叫 ax2.legend() 顯示圖例 +# 6. 設定 X軸刻度旋轉 ax2.tick_params(axis='x', rotation=45) +# +# 請在下方完成 ax2 的降雨量長條圖繪製: +ax2.bar(df['Date'], df['Rainfall'], color='skyblue',edgecolor='royalblue', label='降雨量 (mm)') +ax2.set_title("台北觀測站每日降雨量分佈", fontsize=14, fontweight = 'bold') +ax2.set_xlabel("日期") +ax2.set_ylabel("降雨量 (mm)") +ax2.grid(True, axis = 'y', linestyle='--', alpha=0.5) +ax2.legend() +ax2.tick_params(axis='x', rotation=45) + + + +plt.tight_layout() # 自動調整間距,避免文字重疊 + +# === 【學員填空練習點 5:圖表自動儲存與顯示】 === +# TODO: 1. 呼叫 plt.savefig 將畫布儲存為檔名 "taipei_weather_2026.png",並設定高畫質 dpi=300 +# (⚠️ 注意:savefig 必須在 show() 之前呼叫) +# 2. 呼叫 plt.show() 將圖表視窗顯示在畫面上 +# +# 請在下方完成圖表存檔與顯示: +plt.savefig("taipei_weather_2026.png", dpi=300) +plt.show() \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226and\351\225\267\346\242\235\345\234\226/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226and\351\225\267\346\242\235\345\234\226/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\244\251\346\260\243\350\263\207\346\226\231_\347\233\264\347\267\232\345\234\226and\351\225\267\346\242\235\345\234\226/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" new file mode 100644 index 0000000000000000000000000000000000000000..24a49ad0190737d2ec76670520c16d138fdd435b --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\345\275\261\345\203\217\350\231\225\347\220\206/d3_1_opencv_basic.py" @@ -0,0 +1,69 @@ +# d3_1_opencv_basic.py +# Day 3: 電腦視覺與影像辨識 - OpenCV 影像處理基礎 (學員練習版) +# 練習指引:依序找到程式碼中的 TODO 1 到 TODO 5,將其下方的程式碼「解除註釋(刪除行首的 # )」並執行,觀察影像處理管線中每一步的變化。 + +import cv2 +import numpy as np + +# 1. 影像讀取 (Read) +img = cv2.imread("gay.jpg") +if img is None: + print("[錯誤] 無法讀取 jpg") + exit(1) + +# 預設灰階影像為原始彩色影像,避免未解開註釋時程式出錯 +gray = img + +# === 【重要觀念 TODO 1:灰階轉換 (Grayscale Conversion)】 === +# 觀念重點:大多數電腦視覺演算法在處理單通道(黑白)影像時速度更快。 +# 將 BGR 彩色圖像轉換為單通道灰階圖像,可以減少 2/3 的資料量,且能過濾掉無用的顏色雜訊。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察變為灰階後的影像。 +gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + +# 預設模糊影像為灰階影像,避免未解開註釋時程式出錯 +blurred = gray + +# === 【重要觀念 TODO 2:高斯模糊 (Gaussian Blur)】 === +# 觀念重點:高斯模糊是透過像素加權平均來平滑影像,主要目的是「消除噪點和細微反光」。 +# 這可以避免後續邊緣偵測時把細微的噪點誤判為輪廓,讓偵測出來的邊緣更加平滑。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察影像邊緣的柔焦效果。 +blurred = cv2.GaussianBlur(gray, (5, 5), 0) + +# 預設邊緣影像為全黑畫布,避免未解開註釋時程式出錯 +edges = np.zeros(img.shape[:2], dtype=np.uint8) + +# === 【重要觀念 TODO 3:Canny 邊緣偵測 (Canny Edge Detection)】 === +# 觀念重點:Canny 演算法會找出影像中明暗變化劇烈的分界線。它使用雙閥值法: +# 高於高閥值(150)的 100% 判定為邊緣;介於高低閥值(50~150)之間的若連接強邊緣則保留;其餘捨棄。 +# 請解開下方註釋 (刪除行首的 #) 並執行,觀察提取出的白色線條輪廓。 +edges = cv2.Canny(blurred, 150, 150) + +# 預設無偵測到輪廓,且繪製圖像為原始圖像,避免未解開註釋時程式出錯 +contours = [] +contour_img = img.copy() + +# === 【重要觀念 TODO 4:尋找與繪製輪廓 (Find & Draw Contours)】 === +# 觀念重點:`findContours` 會在二值化(邊緣)影像中尋找相連的邊界,並建立幾何結構。 +# `drawContours` 則會將這些幾何結構繪製到指定的影像上(此處使用綠色框繪製於原始彩色影像)。 +# 請解開下方所有註釋 (刪除行首的 #) 並執行,觀察圖形外框被綠線框起來的結果。 +contours, hierarchy = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) +print(f"在影像中偵測到 {len(contours)} 個獨立輪廓特徵。") +contour_img = img.copy() +cv2.drawContours(contour_img, contours, -1, (0, 255, 0), 1) + +# === 【重要觀念 TODO 5:視窗顯示與釋放 (Window Display)】 === +# 觀念重點:使用 cv2.imshow 顯示各步驟處理結果,並用 waitKey(0) 讓程式停下等待按鍵, +# 最後使用 destroyAllWindows 釋放視窗資源。 +# 請解開下方所有註釋 (刪除行首的 #),以觀看影像處理的成果! +print("\n正在顯示影像處理結果(請點擊影像視窗並按下任意鍵關閉)...") +cv2.imshow("Grayscale", gray) +cv2.imshow("Original Shapes", img) +cv2.imshow("Canny Edges", edges) +cv2.imshow("Contours Output", contour_img) + +success_gray = cv2.imwrite("67_gray.jpg", gray) +success_edges = cv2.imwrite("67_edges.jpg", edges) +success_contour = cv2.imwrite("67_contours.jpg", contour_img) + +cv2.waitKey(0) +cv2.destroyAllWindows() diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex1_books.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex1_books.py" new file mode 100644 index 0000000000000000000000000000000000000000..7b20bb8c1ce86a4d012d8b072db8a037636431d9 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex1_books.py" @@ -0,0 +1,94 @@ +# d4_1_ex1_books.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex1_books.py +# 參考答案:../d4_1_ex1_books.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:專供爬蟲練習的圖書商城沙盒 +URL = "http://books.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至圖書沙盒網站: {URL} ...") + +try: + # 發送 GET 請求取得網頁 HTML + response = requests.get(URL, headers=HEADERS, timeout=10) + + # 檢查 HTTP 狀態碼是否為 200 (OK),非 200 時引發 HTTPError 異常 + response.raise_for_status() + + # 解決編碼問題:強制將解碼設定為 utf-8 (避免 requests 預設 ISO-8859-1 解碼導致英鎊符號亂碼且在 Windows 印出時崩潰) + response.encoding = response.apparent_encoding + + print("連線成功!開始解析書籍資料...\n") + + # 初始化 BeautifulSoup 解析器 + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有書籍的容器。每本書都被包在一個
標籤中,且該標籤的 class 固定為 "product_pod" + books = soup.find_all("article", class_="product_pod") + + print(f"成功在本頁找到 {len(books)} 本書籍:") + print("=" * 70) + + for idx, book in enumerate(books, 1): + # === 【學員填空練習點 3:提取書籍名稱與連結】 === + # TODO: + # 1. 尋找

底下的 標籤 + # 2. 取得 的 "title" 屬性(因為畫面上長度限制,.get_text() 可能會被 ellipsis 截斷) + # 3. 取得 的 "href" 屬性並使用 urljoin 拼接成完整網址 + title_link = book.find("h3").find("a") + title = title_link.get("title") # 取得屬性 'title' 的值,避免拿到被截斷的文字 + + relative_url = title_link.get("href") + detail_url = urljoin(URL, relative_url) + + # === 【學員填空練習點 4:提取書籍價格】 === + # 提示:為了避免 Windows (CP950) 終端機不支援英鎊符號 '£' 而編碼崩潰,請加上 .replace("£", "GBP") 將其替換 + # 觀察:價格存放在 class 為 "price_color" 的

標籤中,格式如 "£51.77" + # 技巧:將英鎊符號 '£' 替換為 'GBP',避免在 Windows (CP950) 終端機印出時因不支援該字元而崩潰 + price_text = book.find("p", class_="price_color").get_text().replace("£", "GBP") + + # === 【學員填空練習點 5:提取評價星等 (Rating)】 === + # TODO: + # 1. 尋找 class 為 "star-rating" 的

標籤 + # 2. 取得該標籤的 "class" 屬性值(這會是一個串列,如 ['star-rating', 'Three']) + # 3. 提取該串列的第二個元素作為星等字串 (若存在) + + rating_tag = book.find("p", class_="star-rating") + rating_classes = rating_tag.get("class") + rating = "無" + if len(rating_classes) > 1: + rating = rating_classes[1] # 提取例如 'Three' (代表三顆星) + + # === 【學員填空練習點 6:提取庫存狀態】 === + # TODO: 尋找 class 為 "instock availability" 的

標籤,取得文字後去除首尾空白 (.strip()) + stock_text = book.find("p", class_="instock availability").get_text().strip() + + # 印出解析結果 + print(f"【編號 {idx}】") + print(f"[書名] {title}") + print(f"[價格] {price_text}") + print(f"[評價] {rating} 星") + print(f"[狀態] {stock_text}") + print(f"[連結] {detail_url}") + print("-" * 70) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] HTTP 連線異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex2_ptt.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex2_ptt.py" new file mode 100644 index 0000000000000000000000000000000000000000..4c0ad885893936a77642ecaba8a3ac6c53c8e88a --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex2_ptt.py" @@ -0,0 +1,81 @@ +# d4_1_ex2_ptt.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex2_ptt.py +# 參考答案:../d4_1_ex2_ptt.py + +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +# 目標網站:PTT Python 討論板首頁 +URL = "https://www.ptt.cc/bbs/Python/index.html" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則 PTT 會拒絕請求並回傳 403 錯誤 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在抓取 PTT Python 板最新文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章區塊】 === + # TODO: 觀察網頁 HTML 結構,找出所有包裹文章資訊的

標籤(其 class 名稱為何?) + posts = soup.find_all("div", class_="r-ent") + + print(f"\n成功取得 {len(posts)} 筆文章項目(含已刪除與置底公告):") + print("=" * 80) + + for idx, post in enumerate(posts, 1): + # === 【學員填空練習點 3:提取文章標題與連結並防錯 (Null Check)】 === + # TODO: + # 1. 尋找 class 為 "title" 的
標籤 + # 2. 在該
底下尋找 標籤 (即文章連結) + # 3. ⚠️ 重要思考:若文章已刪除,將不會有 標籤。請撰寫條件判斷: + # - 若 標籤存在:提取其文字,並使用 urljoin 拼接 href 屬性得到完整網址。 + # - 若 標籤不存在:直接將 title_div 的文字作為標題,並將網址設為 "無 (文章已刪除)"。 + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get("title") + relative_url = title_link.get("href") + post_url = urljoin(URL, relative_url) + else: + # 文章已刪除 + title_div = post.find("div", class_="title") + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + + # === 【學員填空練習點 4:提取推文數】 === + # TODO: 尋找 class 為 "nrec" 的
標籤。 + # 技巧:使用 .get_text().strip() 取得文字。若無文字(0 推),請將變數預設為 "0"。 + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + + # === 【學員填空練習點 5:提取作者與發文日期】 === + # TODO: 分別尋找 class 為 "author" 與 "date" 的
標籤,並提取純文字(記得使用 .strip() 去除空白) + author = post.find("div", class_="author").get_text().strip() + date = post.find("div", class_="date").get_text().strip() + + # 輸出單篇文章資訊 + print(f"[{idx}] 推文數: {nrec:3s} | 發文日期: {date} | 作者: {author}") + print(f" 標題: {title}") + print(f" 網址: {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] PTT 伺服器拒絕或找不到網頁:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex3_ithelp.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex3_ithelp.py" new file mode 100644 index 0000000000000000000000000000000000000000..c0299624ce527f07bab03c30f85184640b1ba40c --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_ex3_ithelp.py" @@ -0,0 +1,98 @@ +# d4_1_ex3_ithelp.py — Day 4 學員填空 +# cd examples/Day4/student && python d4_1_ex3_ithelp.py +# 參考答案:../d4_1_ex3_ithelp.py + +import requests +from bs4 import BeautifulSoup + +# 目標網站:iT 邦幫忙技術文章首頁 +URL = "https://ithelp.ithome.com.tw/articles?tab=tech" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 填入瀏覽器的 User-Agent,否則可能被伺服器拒絕連線 +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線至 iT 邦幫忙並抓取技術文章: {URL} ...") + +try: + response = requests.get(URL, headers=HEADERS, timeout=10) + response.raise_for_status() + + print("連線成功!解析文章列表中...\n") + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 2:定位文章卡片區塊】 === + # TODO: 尋找所有包裹文章資訊的
標籤(其 class 為 "qa-list") + articles = soup.find_all("div", class_="qa-list") + + print(f"成功取得前 {len(articles)} 篇熱門技術文章:") + print("=" * 80) + + for idx, art in enumerate(articles, 1): + # === 【學員填空練習點 3:提取標題與超連結】 === + # TODO: + # 1. 在 art 中尋找 class 為 "qa-list__title-link" 的 標籤 + # 2. 提取其純文字內容並去除首尾空白 (.strip()),作為標題 + # 3. 提取其 "href" 屬性,作為文章連結 + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + title = title_link.get_text().strip() + post_url = title_link.get("href").strip() + + # === 【學員填空練習點 4:提取作者名稱】 === + # TODO: + # 1. 尋找 class 為 "qa-list__info" 的
標籤 + # 2. 在該
下尋找 class 為 "qa-list__info-link" 的 標籤 + # 3. 提取其文字內容作為作者名稱 + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + # === 【學員填空練習點 5:提取發文時間】 === + # TODO: 尋找 class 為 "qa-list__info-time" 的 標籤,並提取其 "title" 屬性值(因 title 屬性中包含完整時間秒數) + time_link = art.find("div", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + # === 【學員填空練習點 6:利用 List 索引提取多項數據】 === + # TODO: + # 1. 使用 find_all 找出卡片中所有 class 為 "qa-condition" 的 標籤 + # 2. 當抓取到的數量大於等於 3 時,利用 List 索引取得對應的資訊: + # - 索引 0 的項目底下的 "qa-condition__count" :Like 數 + # - 索引 1 的項目底下的 "qa-condition__count" :回應數 + # - 索引 2 的項目底下的 "qa-condition__count" :瀏覽數 + conditions = art.find_all("a", class_="qa-condition") + + likes = "0" + comments = "0" + views = "0" + + if len(conditions) >= 3: + # 請在此處填入利用 conditions[0]、conditions[1]、conditions[2] 尋找 span 的代碼 + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + # 格式化輸出 + print(f"【排名 {idx}】") + print(f"[標題] {title}") + print(f"[作者] {author} | [時間] {post_time}") + print(f"[數據] Like: {likes} | 回應: {comments} | 瀏覽/訂閱: {views}") + print(f"[連結] {post_url}") + print("-" * 80) + +except requests.exceptions.HTTPError as http_err: + print(f"[錯誤] 伺服器回應異常:{http_err}") +except requests.exceptions.RequestException as e: + print(f"[錯誤] 網路請求失敗:{e}") +except Exception as ex: + print(f"[錯誤] 解析資料時發生非預期錯誤:{ex}") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_web_scraping.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_web_scraping.py" new file mode 100644 index 0000000000000000000000000000000000000000..26792f288eaa50ea59f82a077985c21c5578d14d --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220/d4_1_web_scraping.py" @@ -0,0 +1,68 @@ +# d4_1_web_scraping.py — Day4 學員填空 +# cd examples/Day4/student && python3 d4_1_web_scraping.py +# 參考答案:../d4_1_web_scraping.py + +import requests +from bs4 import BeautifulSoup + +# 本範例抓取專供網頁爬蟲教學使用的模擬沙盒網站:Quotes to Scrape +URL = "http://quotes.toscrape.com/" + +# === 【學員填空練習點 1:模擬瀏覽器 Header】 === +# TODO: 建立 headers 字典,填入 "User-Agent" 來模擬真實瀏覽器(降低被簡易防爬機制拒絕的機率) +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + +print(f"正在連線並抓取網站內容: {URL} ...") + +try: + # === 【學員填空練習點 2:連線請求與下載網頁】 === + # TODO: 1. 使用 requests.get 發送 GET 請求,帶入 headers=HEADERS 與設定 timeout=5 + # TODO: 2. 判斷 response.status_code 是否為 200 (下載成功) + response = requests.get(URL, headers=HEADERS, timeout=5) + + if response and response.status_code == 200: + print("網頁下載成功!開始解析 HTML 結構...") + + # === 【學員填空練習點 3:初始化 BeautifulSoup 解析器】 === + # TODO: 使用 BeautifulSoup 解析 response.text,初始化 html.parser + soup = BeautifulSoup(response.text, "html.parser") + + # === 【學員填空練習點 4:定位 HTML 標籤區塊】 === + # TODO: 找出網頁中所有名言與作者的 div 標籤(其 class 為 'quote') + quote_divs = soup.find_all("div", class_="quote") + + print(f"\n成功解析!本日精選名言如下(共 {len(quote_divs)} 筆):") + print("=" * 60) + + # === 【學員填空練習點 5:迴圈遍歷提取內容與防錯】 === + # TODO: + # 1. 遍歷 quote_divs,並使用 enumerate 取得索引 idx(從 1 開始) + # 2. 用 .find() 尋找 span 且 class 為 'text' 的名言元素 + # 3. 用 .find() 尋找 small 且 class 為 'author' 的作者元素 + # 4. 進行 None 防錯處理,確保兩者皆非空值後,使用 .get_text() 提取純文字並印出 + for idx, quote_div in enumerate(quote_divs, 1): + text_el = quote_div.find("span", class_="text") + author_el = quote_div.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + + print(f"[{idx}] \"{text}\"") + print(f" -- by {author}\n") + + print("=" * 60) + print("提醒:請尊重 robots.txt 與網站使用條款,勿對目標網站發送高頻請求。") + else: + status = response.status_code if response else "未知" + print(f"[錯誤] 無法抓取網頁,伺服器回應狀態碼:{status}") + +except requests.exceptions.RequestException as e: + print(f"[錯誤] 連線失敗:{e}") + print("請確認電腦的網路連線是否通暢。") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/agent_report_world.md" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/agent_report_world.md" new file mode 100644 index 0000000000000000000000000000000000000000..bec04fb897dd1317c5402a55f72b1e1e3323204b --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/agent_report_world.md" @@ -0,0 +1,35 @@ +這是一份關於「世界與思維」主題的分析報告: + +# 世界與思維:從心境重塑現實的深度解析 + +--- + +### 1. 導言:核心意義 +「世界」不僅僅是指我們腳下的土地或周遭的物理環境,它更是一個由人類認知與集體意識所建構的綜合體。愛因斯坦的名言點出了核心邏輯:現實並非客觀地獨立於我們存在,而是我們看待事物的方式與思維模式的投射。當我們試圖改變周遭環境、社會現狀或是人際關係時,若沒有先調整內在的思維架構,這些改變往往只能流於表象,無法觸及根本。 + +--- + +### 2. 名言分析 + +**原文:** +> “The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.” — Albert Einstein + +**中文翻譯:** +> 「我們所創造的世界,是一個思維過程的產物。如果不改變我們的思維,世界就無法被改變。」 + +**深度賞析:** +這句話精準地揭示了「主觀建構論」的本質。 +* **思維即過程:** 愛因斯坦將世界定義為一個「過程」(process),暗示了世界是不斷流動與演進的。我們的思維模式像是一面濾鏡,過濾了混亂的宇宙資訊,並賦予其意義。 +* **改變的順序:** 這句話指出了改變的「優先順序」。許多人陷入痛苦是因為試圖改變「世界」(外界環境),卻忽略了源頭——自身的認知架構。真正的轉機,往往發生在我們意識到「問題不在於現實,而在於我如何解釋這份現實」的瞬間。唯有升維思考,環境的困境才會轉化為成長的轉機。 + +--- + +### 3. 結論:對學生的啟發與建議 + +作為一名學生,面對學業壓力、人際關係或對未來的焦慮,這句名言提供了極具價值的實踐方針: + +1. **培養「成長型思維」(Growth Mindset):** 不要將失敗視為世界的終局,而應視為思維更新的契機。當你感到無力時,試著問自己:「我現在的思維模式是否限制了我看見其他的可能性?」 +2. **擁抱主動權:** 這句話聽起來沉重,實則充滿希望。它意味著你不需要等待世界對你友善,你就能透過調整看待困難的角度,成為自己世界的主人。 +3. **終身學習的重要性:** 學習不僅是知識的累積,更是思維邊界的拓展。透過閱讀、批判性思考與多元體驗,你不僅是在充實頭腦,更是在「重塑」你所居住的世界。 + +**結語:** 你眼中所見的世界,正是你內在狀態的折射。若想讓世界變得更廣闊、更美好,請先從每天更新自己的思考方式開始。 \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/ai_analysis_report_ptt.md" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/ai_analysis_report_ptt.md" new file mode 100644 index 0000000000000000000000000000000000000000..dcb6ffc04fefa8a9549ebc1b582463b8cfc4951b --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/ai_analysis_report_ptt.md" @@ -0,0 +1,30 @@ +根據您提供的 PTT PokemonGO 看板最新爬蟲數據,以下為您整理的近期重要活動分析與建議: + +### 1. 最近重要的活動有什麼 +根據數據,近期活動相當密集,重點整理如下: +* **週年慶相關:** 正進行中的「週年皮卡丘慶典」(7/13-7/20),以及即將到來的「究極解鎖:10週年版」(7/21-7/27)。 +* **大型年度活動:** 「Pokémon GO Fest 2026:全球」活動。 +* **團戰日與補考:** 7/18 有「雷丘極致超級團體戰日」、7/26 有「Go Fest 團戰補考」。 +* **八月份預告:** 8/1 的「超極巨化轟擂金剛猩對戰日」、8/18-8/24 的「潑水節」(刺梭魚、古月鳥)。 + +### 2. 該準備什麼 +* **道具資源:** 團戰日(如 7/18、8/22)通常需要消耗大量的團體戰入場券,建議預留金幣購買禮盒,並準備足夠的「遠距團體戰券」或「特級團體戰券」。 +* **社交與空間:** 如數據所示,補給品與空間非常重要,記得清理包包空間(寶可夢儲存空間與背包道具上限)。 +* **隊伍配置:** 由於涉及多種屬性的團戰(雷丘、轟擂金剛猩、寶石海星),建議依照不同屬性預先排好進攻隊伍,避免臨場手忙腳亂。 + +### 3. 有什麼東西是限定的 +* **活動限定寶可夢:** 「週年皮卡丘」、「刺梭魚」、「古月鳥」以及慶典期間出現的特定色違寶可夢。 +* **免費入場券:** 團戰日活動期間,通常會提供當日限定的免費入場券(如 8/22 寶石海星團戰日可獲得 6 張免費券),務必於活動時間內用完。 +* **極巨化/超極巨化:** 轟擂金剛猩等相關挑戰屬於期間限定,建議優先參與。 + +### 4. 有什麼值得蒐藏的東西 +* **色違(Shiny)寶可夢:** 從看板閒聊討論可看出,玩家對色違率相當關注,特別是週年慶期間的特殊裝扮寶可夢,是極具收藏價值的目標。 +* **合體/傳說寶可夢:** 雖然合體酋雷姆目前 PvP 被暫時禁用(等待修正),但這類強勢寶可夢依然是高端玩家的首選。 +* **Z 狗(基格爾德)細胞:** 雖然這不是短期的活動,但從看板發問可以看出,長期蒐集 Z 狗細胞來完成其形態變化是核心玩家的終極目標。 + +### 5. 會有超級列空座嗎 +**根據目前提供的數據,並未提及「超級列空座」的回歸資訊。** +目前的焦點集中在「10 週年慶典」、「轟擂金剛猩」以及「Pokémon GO Fest 2026」。如果有關於超級列空座的突發情報,通常會在看板的[情報]類文章中迅速出現,建議您後續持續關注看板置底公告,或是等待 Niantic 官方的下一波大型活動預告。 + +--- +**小提醒:** 近期有玩家提到在特定熱門點(如北車)團戰狀況較多,若要參與實體團戰日,建議避開過度擁擠的區域,或是提前準備好替代的團戰方案。 \ No newline at end of file diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/d4_2_agent_task_chain.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/d4_2_agent_task_chain.py" new file mode 100644 index 0000000000000000000000000000000000000000..fd39682a1a822f231b28e8b64cd01f90c739e558 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/d4_2_agent_task_chain.py" @@ -0,0 +1,141 @@ +# d4_2_agent_task_chain.py +# Day 4: 自動化任務鏈 (Task Chain / 簡化版 Agent 工作流) +# 說明:本範例是「固定管線」——步驟由我們寫死,不是模型自主 function calling。 +# 目的是讓學生先理解:工具 + LLM + 記憶狀態 如何串成可交付的自動化流程。 + +import os +import requests +from bs4 import BeautifulSoup + +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JynZRgTmhZIMkFFN5GHSFClFfC3sraiSQ4Pb8d9v0V6Q") + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + + +def plan_steps(topic): + """規劃階段:對學生展示任務拆解(固定計畫,非 LLM 自主規劃)。""" + steps = [ + f"1. 使用爬蟲工具擷取主題「{topic}」相關名言", + "2. 將原始文字作為 context 送入 LLM 摘要與翻譯", + "3. 將 Markdown 報告寫入本機檔案", + ] + print("[Planning] 任務計畫:") + for s in steps: + print(" ", s) + return steps + + +def tool_scrape_topic_quotes(topic): + """ + 工具 A:抓取 Quotes to Scrape 的標籤頁面內容。 + 例如 topic = 'love', 'life', 'inspirational' 等。 + """ + url = f"http://quotes.toscrape.com/tag/{topic}/" + print(f"[Tool] 執行網頁資料檢索。目標網址: {url}") + try: + response = requests.get(url, headers=HEADERS, timeout=5) + if response.status_code == 200: + soup = BeautifulSoup(response.text, "html.parser") + quote_divs = soup.find_all("div", class_="quote") + results = [] + for q in quote_divs: + text_el = q.find("span", class_="text") + author_el = q.find("small", class_="author") + if text_el is None or author_el is None: + continue + text = text_el.get_text() + author = author_el.get_text() + results.append(f"名言: {text} | 作者: {author}") + return "\n".join(results) if results else "找不到與此主題相關的內容。" + return f"網頁讀取錯誤,代碼: {response.status_code}" + except Exception as e: + return f"工具執行異常: {e}" + + +def tool_save_report(filename, content): + """工具 B:將報告寫入本機檔案。""" + with open(filename, "w", encoding="utf-8") as f: + f.write(content) + return filename + + +def run_task_chain(topic): + print("=" * 60) + print(f"啟動自動化任務鏈。目標:整理與分析關於『{topic}』的文獻報告") + print("(模式:固定 Pipeline,非自主 Tool-Calling Agent)") + print("=" * 60) + + # 步驟 0: 規劃(教學展示) + plan_steps(topic) + + # 步驟 1: 呼叫檢索工具 + raw_data = tool_scrape_topic_quotes(topic) + print(f"\n[步驟 1] 檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 原始數據預覽 ---") + preview = raw_data[:200] + ("..." if len(raw_data) > 200 else "") + print(preview) + print("--------------------") + + # 工作記憶:保存中間狀態 + memory = {"topic": topic, "raw_data": raw_data, "report": ""} + + # 步驟 2: LLM 分析(或 Mock 降級) + if not api_key: + print("\n[步驟 2] 未偵測到 API Key。啟動模擬生成中...") + report_content = ( + f"# 模擬關於《{topic}》的名人名言分析報告\n\n" + f"## 一、 數據檢索摘要\n" + f"本次任務共檢索到數筆關於 {topic} 的語錄。以下為代表性文獻:\n" + f"- 模擬數據:\"Love is all, love is new.\" -- John Lennon\n\n" + f"## 二、 核心思想分析\n" + f"本機模擬分析指出,此主題的語錄主要著眼於人與人之間的連結與心靈啟發。\n" + f"*(注意:此為 Mock 離線生成報告,設定 GEMINI_API_KEY 以啟用 Gemini 真實生成。)*\n" + ) + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行深度分析與排版...") + try: + import google.generativeai as genai # 僅雲端模式需要 + + genai.configure(api_key=api_key) + model = genai.GenerativeModel(GEMINI_MODEL) + + prompt = ( + f"請幫我分析以下關於『{topic}』的原始英文名言數據,並用繁體中文撰寫一份 Markdown 報告。\n" + f"報告必須包含三個部分:\n" + f"1. 導言:說明這個主題的核心意義。\n" + f"2. 名言分析:挑選原始資料中 2-3 句具代表性的句子進行翻譯與深度賞析。\n" + f"3. 結論:對學生的啟發與建議。\n\n" + f"=== 原始數據 ===\n" + f"{raw_data}" + ) + + response = model.generate_content(prompt) + report_content = response.text + print("[步驟 2] 雲端分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e}。轉為模擬輸出。") + report_content = f"# 分析失敗\n\n錯誤詳情:{e}\n" + + memory["report"] = report_content + + # 步驟 3: 自動存檔 + output_filename = f"agent_report_{topic}.md" + try: + saved = tool_save_report(output_filename, report_content) + print(f"\n[步驟 3] 任務報告生成成功!已儲存為檔案: {saved}") + print("=" * 60) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + + +if __name__ == "__main__": + # 可更換為: 'love', 'life', 'inspirational', 'humor' 等 + test_topic = "world" + run_task_chain(test_topic) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/d4_2_ex2_crawler_ai.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/d4_2_ex2_crawler_ai.py" new file mode 100644 index 0000000000000000000000000000000000000000..bcc7bc24788d54745af8e8640a2c785dee418878 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\347\210\254\350\237\262\345\210\206\346\236\220and ai\345\240\261\345\221\212/d4_2_ex2_crawler_ai.py" @@ -0,0 +1,220 @@ +# d4_2_ex2_crawler_ai.py +# Day 4: 爬蟲與 AI 分析任務鏈 (學生填空版) +# 執行方式:cd examples/Day4/student && python d4_2_ex2_crawler_ai.py + +import os +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin +from google import genai + +# 設定預設使用的 Gemini 模型 +GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite") +api_key = os.environ.get("GEMINI_API_KEY","AQ.Ab8RN6JynZRgTmhZIMkFFN5GHSFClFfC3sraiSQ4Pb8d9v0V6Q") + + +HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ) +} + + +def plan_steps(source_type): + """規劃階段:展示任務拆解流程。""" + steps = [ + f"步驟 1: 啟動爬蟲工具擷取『{source_type.upper()}』熱門文章與輿情數據", + "步驟 2: 將爬取資料作為 Context,呼叫 Gemini 進行輿情趨勢與熱門話題分析", + "步驟 3: 自動將 AI 生成的繁體中文分析報告以 UTF-8 編碼存為 Markdown 檔案" + ] + print("\n[Planning] 任務計畫已啟動:") + for s in steps: + print(f" -> {s}") + return steps + + +def tool_scrape_ptt(): + """工具 A:抓取 PTT PokemonGo 板最新文章資訊。""" + url = "https://www.ptt.cc/bbs/PokemonGo/index.html" + print(f"\n[Tool] 正在爬取 PTT PokemonGo 板文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + posts = soup.find_all("div", class_="r-ent") + + results = [] + for post in posts: + title_div = post.find("div", class_="title") + title_link = title_div.find("a") if title_div else None + + if title_link: + title = title_link.get_text().strip() + relative_url = title_link.get("href") + post_url = urljoin(url, relative_url) if relative_url else "無 (連結缺失)" + else: + title = title_div.get_text().strip() if title_div else "(已刪除)" + post_url = "無 (文章已刪除)" + + nrec_div = post.find("div", class_="nrec") + nrec = nrec_div.get_text().strip() if nrec_div else "0" + if not nrec: + nrec = "0" + + author = post.find("div", class_="author").get_text().strip() if post.find("div", class_="author") else "未知作者" + date = post.find("div", class_="date").get_text().strip() if post.find("div", class_="date") else "未知日期" + + results.append( + f"標題: {title} | 作者: {author} | 日期: {date} | 推文數: {nrec} | 網址: {post_url}" + ) + + return "\n".join(results) if results else "找不到任何文章項目。" + except Exception as e: + return f"[PTT 爬蟲錯誤] 執行異常: {e}" + + +def tool_scrape_ithelp(): + """工具 B:抓取 iT 邦幫忙熱門技術文章。""" + url = "https://ithelp.ithome.com.tw/articles?tab=tech" + print(f"\n[Tool] 正在爬取 iT 邦幫忙技術文章: {url} ...") + try: + response = requests.get(url, headers=HEADERS, timeout=10) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + articles = soup.find_all("div", class_="qa-list") + + results = [] + for art in articles: + title_link = art.find("a", class_="qa-list__title-link") + if not title_link: + continue + + title = title_link.get_text().strip() + post_url = title_link.get("href", "").strip() or "無 (連結缺失)" + + info_div = art.find("div", class_="qa-list__info") + author = "未知作者" + if info_div: + author_link = info_div.find("a", class_="qa-list__info-link") + if author_link: + author = author_link.get_text().strip() + + time_link = art.find("a", class_="qa-list__info-time") + post_time = time_link.get("title") if time_link else "未知時間" + + conditions = art.find_all("a", class_="qa-condition") + likes, comments, views = "0", "0", "0" + + if len(conditions) >= 3: + likes = conditions[0].find("span", class_="qa-condition__count").get_text().strip() + comments = conditions[1].find("span", class_="qa-condition__count").get_text().strip() + views = conditions[2].find("span", class_="qa-condition__count").get_text().strip() + + results.append( + f"標題: {title} | 作者: {author} | 時間: {post_time} | " + f"Like: {likes} | 回應: {comments} | 瀏覽: {views} | 網址: {post_url}" + ) + + return "\n".join(results) if results else "找不到任何技術文章。" + except Exception as e: + return f"[iT邦幫忙爬蟲錯誤] 執行異常: {e}" + + +def tool_save_report(filename, content): + """工具 C:將報告以 UTF-8 編碼寫入本機檔案。""" + print(f"\n[Tool] 正在將分析報告儲存至檔案: {filename} ...") + with open(filename, "w", encoding="utf-8") as f: + f.write(str(content)) + return filename + + +def run_analysis_chain(source_type): + print("=" * 70) + print(f"啟動自動化輿情任務鏈。目標來源:『{source_type.upper()}』數據分析") + print("=" * 70) + + plan_steps(source_type) + + if source_type.lower() == "ptt": + raw_data = tool_scrape_ptt() + elif source_type.lower() == "ithelp": + raw_data = tool_scrape_ithelp() + else: + print("[錯誤] 未支援的數據來源。僅支援 'ptt' 或 'ithelp'") + return + + print(f"\n[步驟 1] 資料檢索完成!取得原始數據長度: {len(raw_data)} 字元。") + print("--- 數據預覽 (前 200 字) ---") + print(raw_data[:200] + ("..." if len(raw_data) > 200 else "")) + print("----------------------------") + + memory = { + "source": source_type, + "raw_data": raw_data, + "report": "" + } + + if not api_key: + print("\n[步驟 2] 未偵測到環境變數 GEMINI_API_KEY,自動啟動 Mock 離線降級機制...") + report_content = ( + f"# 模擬輿情分析報告\n\n" + f"數據來源: {source_type.upper()}\n\n" + f"## 摘要\n" + f"- 已成功取得爬取資料\n" + f"- 目前為離線 Mock 模式\n" + ) + print("[步驟 2] Mock 報告模擬生成完畢。") + else: + print(f"\n[步驟 2] 正在調用 Gemini ({GEMINI_MODEL}) 進行數據分析與排版...") + try: + client = genai.Client(api_key=api_key) + + prompt = ( + f"請幫我分析以下來自 {source_type.upper()} 的最新網頁抓取文章列表,並用繁體中文撰寫一份最近重要的活動。\n" + f"報告必須包含以下部分:\n" + f"1. 說明最近重要的活動有什麼\n" + f"2. 該準備什麼。\n" + f"3. 有什麼東西是限定的。\n" + f"4. 有什麼值得蒐藏的東西\n" + f"5. 會有超級列空座嗎\n" + f"=== 原始爬蟲數據 ===\n" + f"{raw_data}" + ) + + response = client.models.generate_content( + model=GEMINI_MODEL, + contents=prompt + ) + + try: + response_text = response.text + except Exception: + response_text = None + + if isinstance(response_text, str) and response_text.strip(): + report_content = response_text + else: + report_content = str(response) + + print("[步驟 2] 雲端 Gemini 分析完成!") + except Exception as e: + print(f"[錯誤] AI 分析失敗: {e},自動轉為錯誤備份輸出。") + report_content = f"# 輿情分析失敗\n\n錯誤詳情:{e}\n\n=== 原始數據 ===\n{raw_data}" + + memory["report"] = report_content + + output_filename = f"ai_analysis_report_{source_type}.md" + try: + saved_file = tool_save_report(output_filename, memory["report"]) + print(f"\n[步驟 3] 任務報告生成成功!已儲存至: {saved_file}") + print("=" * 70) + except Exception as e: + print(f"[錯誤] 儲存報告失敗: {e}") + + +if __name__ == "__main__": + run_analysis_chain("ptt") diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\250\255\350\250\210AI\350\247\222\350\211\262and\345\240\264\346\231\257/d2_2_prompt_engineering.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\250\255\350\250\210AI\350\247\222\350\211\262and\345\240\264\346\231\257/d2_2_prompt_engineering.py" new file mode 100644 index 0000000000000000000000000000000000000000..9936416cb5297c13c2c45377afbc8a138864eed7 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\250\255\350\250\210AI\350\247\222\350\211\262and\345\240\264\346\231\257/d2_2_prompt_engineering.py" @@ -0,0 +1,59 @@ +# d2_2_prompt_engineering.py +# Day 2: 生成式 AI 串接實戰 - Prompt 工程 (系統指令與結構化輸出) +import os +from google import genai + +# 取得 API 金鑰 +api_key = os.environ.get("GEMINI_API_KEY", "AQ.Ab8RN6JynZRgTmhZIMkFFN5GHSFClFfC3sraiSQ4Pb8d9v0V6Q") +if not api_key: + print("[提示] 請輸入您的 Google AI Studio API 金鑰:") + api_key = input("API Key: ").strip() + +client = genai.Client(api_key=api_key) + +# 1. 設置系統指令 (System Instruction / System Prompt) +# TODO 1: +system_prompt = "你是國中英語老師,請用簡單易懂的方式解釋英文單字,專業且有耐心,並提供 KK音標與例句" + + +print("正在初始化英語教學助理模型...") + +# 2. Few-shot (少樣本) 引導與對話演練 +# TODO 2: +user_word = "黑人" + +print(f"\n[學生輸入]: {user_word}") +prompt = f"請幫我翻譯這個詞:'{user_word}'。" + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=prompt, + config={'system_instruction': system_prompt} +) + +print("\n[AI 老師回應]:") +print(response.text) + + +# 3. 結構化 JSON 輸出 (Structured Output) 範例 +print("\n" + "="*40) +print("進階挑戰:要求模型回傳 JSON 格式") +print("="*40) + +# 我們可以在 prompt 中要求,並透過 configuration 設定 JSON 輸出格式 +# TODO 3: +json_prompt = ( + f"請將中文單字 '{user_word}' 的翻譯結果整理成 JSON 格式,包含以下三個欄位:\n" + "- 'english': 英文單字\n" + "- 'kk_phonetic': KK音標\n" + "- 'example_sentence': 適合國中生理解的英文例句與中文翻譯" + "- '用法': 詞性與用法說明\n" + ) + +response = client.models.generate_content( + model='gemini-3.1-flash-lite', + contents=json_prompt, + config={"response_mime_type": "application/json"} +) +print("\n[AI 回傳的原始 JSON]:") +print(response.text) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" new file mode 100644 index 0000000000000000000000000000000000000000..225abf577dc3f735fef5b7c130a57859501e1929 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/d1_2_data_cleaning.py" @@ -0,0 +1,40 @@ +# d1_2_data_cleaning.py +# Day 1 學員填空:資料清洗(完成 TODO) +# cd examples/Day1/student && python3 d1_2_data_cleaning.py +# 參考答案:../Teacher/d1_2_data_cleaning.py + +import pandas as pd +import numpy as np + +# 與 Teacher 一致:第 5 天高溫有雨,方便篩選示範 +raw_data = { + "Date": ["2026-07-01", "2026-07-02", "2026-07-03", "2026-07-04", "2026-07-05"], + "Temperature": [28.5, np.nan, 30.2, 31.5, 30.8], + "Humidity": [82, 80, np.nan, 70, 90], + "Rainfall": [5.2, 0.0, 0.0, np.nan, 25.4], +} + +df = pd.DataFrame(raw_data) +print("=== 原始含有缺失值的數據 ===") +print(df) + +df_cleaned = df.copy() +mean_temp = df_cleaned["Temperature"].mean() +df_cleaned["Temperature"] = df_cleaned["Temperature"].fillna(mean_temp) +df_cleaned["Humidity"] = df_cleaned["Humidity"].ffill() +df_cleaned["Rainfall"] = df_cleaned["Rainfall"].fillna(0.0) + +print("\n=== 清洗與填補後的數據 ===") +print(df_cleaned) + +print("\n=== 篩選:溫度>29 且 降雨>0 ===") + +high_temp_and_rain = df_cleaned[(df_cleaned["Temperature"] > 29.0) & (df_cleaned["Rainfall"] > 0.0)] +print(high_temp_and_rain) + +print("\n=== 新增 Is_Hot(溫度 >= 30)===") +df_cleaned['Is_Hot'] = df_cleaned['Temperature'] >= 30 +print(df_cleaned) + +df_cleaned['Is_rainy'] = df_cleaned['Rainfall'] > 0 +print(df_cleaned) diff --git "a/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" new file mode 100644 index 0000000000000000000000000000000000000000..1e6c1201f87cf590ce7e6d105f469ac69fb24540 --- /dev/null +++ "b/projects/2026_AI\347\207\2371/\346\233\276\351\272\222\346\264\213/\350\263\207\346\226\231\347\225\260\345\270\270\345\200\274\346\270\205\346\264\227/taipei_weather_2026.csv" @@ -0,0 +1,16 @@ +Date,Temperature,Humidity,Rainfall +2026-07-01,28.5,82,5.2 +2026-07-02,29.0,80,0.0 +2026-07-03,30.2,75,0.0 +2026-07-04,31.5,70,0.0 +2026-07-05,27.8,90,25.4 +2026-07-06,26.5,95,42.0 +2026-07-07,28.0,85,12.5 +2026-07-08,29.5,78,0.0 +2026-07-09,30.8,72,0.0 +2026-07-10,32.0,68,0.0 +2026-07-11,28.2,88,18.0 +2026-07-12,27.0,92,30.5 +2026-07-13,29.2,80,2.1 +2026-07-14,30.0,76,0.0 +2026-07-15,31.2,71,0.0 diff --git "a/projects/DEMO/Student1/Android\346\270\254\350\251\246\345\260\210\346\241\210/test_app.apk" "b/projects/DEMO/Student1/Android\346\270\254\350\251\246\345\260\210\346\241\210/test_app.apk" index c7756e72df7d506d74d959fb94f70648671f700c..cf6b4958271e7400ffeb1faa98de25efb56f1cd1 100644 --- "a/projects/DEMO/Student1/Android\346\270\254\350\251\246\345\260\210\346\241\210/test_app.apk" +++ "b/projects/DEMO/Student1/Android\346\270\254\350\251\246\345\260\210\346\241\210/test_app.apk" @@ -1 +1,3 @@ -This is a dummy APK file content to test the arcade hub APK download and scanner features. +version https://git-lfs.github.com/spec/v1 +oid sha256:4112d7a9392041616f7b94461f34dd653dd1e6cea41de47e96e43eff4a1981b2 +size 91 diff --git "a/projects/DEMO/Student1/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/hand_landmarker.task" "b/projects/DEMO/Student1/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/hand_landmarker.task" deleted file mode 100644 index 5ecab741879892d97c2f90bbf03bf55d7213db7c..0000000000000000000000000000000000000000 --- "a/projects/DEMO/Student1/\346\211\213\345\213\242\346\215\217\345\220\210\346\216\247\345\210\266/hand_landmarker.task" +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1 -size 7819105 diff --git "a/projects/DEMO/Student1/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hand_landmarker.task" "b/projects/DEMO/Student1/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hand_landmarker.task" deleted file mode 100644 index 5ecab741879892d97c2f90bbf03bf55d7213db7c..0000000000000000000000000000000000000000 --- "a/projects/DEMO/Student1/\346\211\213\345\213\242\351\233\267\345\260\204\347\231\274\345\260\204/hand_landmarker.task" +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1 -size 7819105 diff --git "a/projects/DEMO/Student1/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/face_landmarker.task" "b/projects/DEMO/Student1/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/face_landmarker.task" deleted file mode 100644 index fedb14de6d2b6708a56c04ae259783e23404c1aa..0000000000000000000000000000000000000000 --- "a/projects/DEMO/Student1/\350\246\226\345\212\233\350\255\267\347\234\274\350\241\233\345\243\253/face_landmarker.task" +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:64184e229b263107bc2b804c6625db1341ff2bb731874b0bcc2fe6544e0bc9ff -size 3758596 diff --git a/projects/DEMO/Student3/apk/Melody_2023_Final.apk b/projects/DEMO/Student3/apk/Melody_2023_Final.apk new file mode 100644 index 0000000000000000000000000000000000000000..216674985d9a923d802a6da172195049f41d65ea --- /dev/null +++ b/projects/DEMO/Student3/apk/Melody_2023_Final.apk @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d89872f7883958a11464ce52f426ae15fc71c3c13668e50475085d09a69e7a2 +size 4427172 diff --git "a/projects/DEMO/Student3/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/hand_landmarker.task" "b/projects/DEMO/Student3/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/hand_landmarker.task" deleted file mode 100644 index 5ecab741879892d97c2f90bbf03bf55d7213db7c..0000000000000000000000000000000000000000 --- "a/projects/DEMO/Student3/\346\211\213\345\213\242\347\211\271\345\276\265\345\210\206\351\241\236/hand_landmarker.task" +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1 -size 7819105 diff --git "a/projects/DEMO/Student3/\346\211\213\351\203\250\350\276\250\350\255\230/hand_landmarker.task" "b/projects/DEMO/Student3/\346\211\213\351\203\250\350\276\250\350\255\230/hand_landmarker.task" deleted file mode 100644 index 5ecab741879892d97c2f90bbf03bf55d7213db7c..0000000000000000000000000000000000000000 --- "a/projects/DEMO/Student3/\346\211\213\351\203\250\350\276\250\350\255\230/hand_landmarker.task" +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fbc2a30080c3c557093b5ddfc334698132eb341044ccee322ccf8bcf3607cde1 -size 7819105 diff --git a/requirements-ml.txt b/requirements-ml.txt new file mode 100644 index 0000000000000000000000000000000000000000..befb2e35e3c5e9fa8a5eb7338007472ffb542048 --- /dev/null +++ b/requirements-ml.txt @@ -0,0 +1,4 @@ +# Optional: install only when running Keras / TensorFlow student demos +-r requirements.txt +tensorflow>=2.13.0,<3.0.0 +keras>=2.13.0,<4.0.0 diff --git a/requirements.txt b/requirements.txt index fe68b9b2bf72e189908610c32f13e626b1b38b5a..61d4fde24fbe3b9d946508e60ff07c42d3f90eaf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,16 @@ -fastapi>=0.100.0 -uvicorn[standard]>=0.22.0 -starlette<1.0.0 -jinja2>=3.1.2 -websockets>=11.0.3 -numpy>=1.24.0 -opencv-python-headless>=4.7.0.0 -pygame-ce>=2.3.0 -tensorflow -keras -mediapipe +# Core web stack +fastapi>=0.100.0,<1.0.0 +uvicorn[standard]>=0.22.0,<1.0.0 +starlette>=0.27.0,<1.0.0 +jinja2>=3.1.2,<4.0.0 +websockets>=11.0.3,<16.0.0 + +# Student project runtime +numpy>=1.24.0,<3.0.0 +opencv-python-headless>=4.7.0.0,<5.0.0 +pygame-ce>=2.3.0,<3.0.0 +mediapipe>=0.10.0,<0.11.0 +requests>=2.28.0,<3.0.0 + +# Optional ML extras (Keras / Teachable Machine demos): +# pip install -r requirements-ml.txt diff --git a/runner.py b/runner.py index 1b9d104eea8a64c278e59fd1e040284f9bd7a050..7bb11e47be0c9e0161e9a0f39c88ca5a02899e29 100644 --- a/runner.py +++ b/runner.py @@ -1,38 +1,63 @@ -import os -import sys +""" +Headless runner for student Pygame / OpenCV projects. + +Connects back to the server over localhost TCP, monkeypatches display/input +APIs, and streams JPEG frames + logs to the browser. +""" +from __future__ import annotations + import argparse +import io +import json +import os +import queue import socket +import sys +import tempfile import threading -import queue import time -import json + import numpy as np -# We parse command-line arguments first before importing pygame/opencv +# Parse CLI before importing pygame/opencv (env must be set first) parser = argparse.ArgumentParser() parser.add_argument("--project_path", required=True, help="Path to the student's main.py") parser.add_argument("--port", type=int, required=True, help="Internal TCP communication port") +parser.add_argument( + "--models_dir", + default=None, + help="Shared MediaPipe / model assets directory", +) args = parser.parse_args() -# Setup Headless environment for Pygame os.environ["SDL_VIDEODRIVER"] = "dummy" os.environ["SDL_AUDIODRIVER"] = "dummy" -# ----------------- IPC Communication Channels ----------------- -send_queue = queue.Queue() -cv2_keys = queue.Queue() -stdin_queue = queue.Queue() +# ----------------- IPC ----------------- +send_queue: queue.Queue = queue.Queue() +cv2_keys: queue.Queue = queue.Queue() +stdin_queue: queue.Queue = queue.Queue() + +# Adaptive frame pacing: start at ~30 FPS, back off under pressure +_min_frame_interval = 0.033 +_max_frame_interval = 0.08 +_current_frame_interval = 0.033 +JPEG_QUALITY = 55 class KeyStateWrapper: def __init__(self): self._keys = {} + def __getitem__(self, key): return self._keys.get(key, False) + def __setitem__(self, key, value): self._keys[key] = bool(value) + def __len__(self): return 512 + shared_keys = KeyStateWrapper() shared_mouse_pos = (0, 0) shared_mouse_buttons = (False, False, False) @@ -41,8 +66,8 @@ shared_webcam_frame = None last_pygame_frame_time = 0.0 last_cv2_frame_time = 0.0 -# Key map: JavaScript event.key name -> Pygame key constant -import pygame # Import pygame now that SDL environment is set +import pygame # noqa: E402 — after SDL env + KEY_MAP = { "ArrowLeft": pygame.K_LEFT, "ArrowRight": pygame.K_RIGHT, @@ -67,135 +92,161 @@ KEY_MAP = { "5": pygame.K_5, "6": pygame.K_6, "7": pygame.K_7, "8": pygame.K_8, "9": pygame.K_9, } -# ----------------- TCP Network Threads ----------------- + +def _adjust_frame_interval(queue_depth: int) -> None: + global _current_frame_interval + if queue_depth > 2: + _current_frame_interval = min(_max_frame_interval, _current_frame_interval * 1.15) + elif queue_depth == 0: + _current_frame_interval = max(_min_frame_interval, _current_frame_interval * 0.95) + + +# ----------------- TCP threads ----------------- def reader_thread_func(sock): global shared_webcam_frame, shared_keys, shared_mouse_pos, shared_mouse_buttons print("[Runner-TCP] Reader thread started.") try: while True: - # 1. Read message type (4 bytes) type_bytes = sock.recv(4) if not type_bytes or len(type_bytes) < 4: break - msg_type = type_bytes.decode('utf-8', errors='ignore') - - # 2. Read payload length (4 bytes) + msg_type = type_bytes.decode("utf-8", errors="ignore") + len_bytes = sock.recv(4) if not len_bytes or len(len_bytes) < 4: break - length = int.from_bytes(len_bytes, byteorder='big') - - # 3. Read payload + length = int.from_bytes(len_bytes, byteorder="big") + payload = bytearray() while len(payload) < length: chunk = sock.recv(length - len(payload)) if not chunk: break payload.extend(chunk) - + if len(payload) < length: break - - # 4. Dispatch payload - if msg_type == 'KEYS': - # Pygame keys mapping - data = json.loads(payload.decode('utf-8')) - event_type = data.get('type') # 'keydown' or 'keyup' - key_name = data.get('key') - - # Make single character key codes case-insensitive (e.g. for Caps Lock/Shift) + + if msg_type == "KEYS": + data = json.loads(payload.decode("utf-8")) + event_type = data.get("type") + key_name = data.get("key") if key_name and len(key_name) == 1: key_name = key_name.lower() - pg_key = KEY_MAP.get(key_name) if pg_key is not None: - if event_type == 'keydown': + if event_type == "keydown": shared_keys[pg_key] = True try: - pygame.event.post(pygame.event.Event(pygame.KEYDOWN, key=pg_key, unicode=data.get('unicode', ''))) + pygame.event.post( + pygame.event.Event( + pygame.KEYDOWN, + key=pg_key, + unicode=data.get("unicode", ""), + ) + ) except Exception: pass - elif event_type == 'keyup': + elif event_type == "keyup": shared_keys[pg_key] = False try: pygame.event.post(pygame.event.Event(pygame.KEYUP, key=pg_key)) except Exception: pass - - elif msg_type == 'MOUS': - # Pygame mouse mapping - data = json.loads(payload.decode('utf-8')) - # Mouse position scaling should be handled by client or here (we assume client coordinates match game coordinates) - shared_mouse_pos = (data.get('x', 0), data.get('y', 0)) - - # Update mouse button states - btn_left = data.get('btnLeft', False) - btn_middle = data.get('btnMiddle', False) - btn_right = data.get('btnRight', False) + + elif msg_type == "MOUS": + data = json.loads(payload.decode("utf-8")) + shared_mouse_pos = (data.get("x", 0), data.get("y", 0)) + btn_left = data.get("btnLeft", False) + btn_middle = data.get("btnMiddle", False) + btn_right = data.get("btnRight", False) shared_mouse_buttons = (btn_left, btn_middle, btn_right) - - # Post mouse move / click events try: - event_type = data.get('type') - if event_type == 'mousemove': - pygame.event.post(pygame.event.Event(pygame.MOUSEMOTION, pos=shared_mouse_pos, rel=(0, 0), buttons=shared_mouse_buttons)) - elif event_type == 'mousedown': - button_idx = data.get('button', 0) + 1 # JS is 0-indexed, pygame is 1-indexed - pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=shared_mouse_pos, button=button_idx)) - elif event_type == 'mouseup': - button_idx = data.get('button', 0) + 1 - pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=shared_mouse_pos, button=button_idx)) + event_type = data.get("type") + if event_type == "mousemove": + pygame.event.post( + pygame.event.Event( + pygame.MOUSEMOTION, + pos=shared_mouse_pos, + rel=(0, 0), + buttons=shared_mouse_buttons, + ) + ) + elif event_type == "mousedown": + button_idx = data.get("button", 0) + 1 + pygame.event.post( + pygame.event.Event( + pygame.MOUSEBUTTONDOWN, + pos=shared_mouse_pos, + button=button_idx, + ) + ) + elif event_type == "mouseup": + button_idx = data.get("button", 0) + 1 + pygame.event.post( + pygame.event.Event( + pygame.MOUSEBUTTONUP, + pos=shared_mouse_pos, + button=button_idx, + ) + ) except Exception: pass - - elif msg_type == 'CAMF': - # Webcam JPEG frame bytes + + elif msg_type == "CAMF": shared_webcam_frame = bytes(payload) - - elif msg_type == 'CV2K': - # OpenCV keypress ASCII value - data = json.loads(payload.decode('utf-8')) - key_code = data.get('key_code', -1) + + elif msg_type == "CV2K": + data = json.loads(payload.decode("utf-8")) + key_code = data.get("key_code", -1) if key_code != -1: cv2_keys.put(key_code) - - elif msg_type == 'INPT': - # Stdin input from browser terminal - text = payload.decode('utf-8', errors='ignore') + + elif msg_type == "INPT": + text = payload.decode("utf-8", errors="ignore") stdin_queue.put(text) - - elif msg_type == 'QUIT': - # Clean quit request for Pygame + + elif msg_type == "QUIT": try: pygame.event.post(pygame.event.Event(pygame.QUIT)) except Exception: pass - + except Exception as e: print(f"[Runner-TCP] Reader error: {e}", file=sys.__stderr__) finally: print("[Runner-TCP] Reader thread finished.", file=sys.__stderr__) - sock.close() + try: + sock.close() + except Exception: + pass + def writer_thread_func(sock): print("[Runner-TCP] Writer thread started.") try: while True: msg_type, payload = send_queue.get() - if msg_type == b'EXIT': + if msg_type == b"EXIT": + try: + sock.sendall(msg_type) + sock.sendall((0).to_bytes(4, byteorder="big")) + except Exception: + pass break - - # Send: Type (4 bytes) + Length (4 bytes) + Payload sock.sendall(msg_type) - sock.sendall(len(payload).to_bytes(4, byteorder='big')) + sock.sendall(len(payload).to_bytes(4, byteorder="big")) sock.sendall(payload) except Exception as e: print(f"[Runner-TCP] Writer error: {e}", file=sys.__stderr__) finally: print("[Runner-TCP] Writer thread finished.", file=sys.__stderr__) - sock.close() + try: + sock.close() + except Exception: + pass + -# Connect to Server TCP port sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: sock.connect(("127.0.0.1", args.port)) @@ -208,128 +259,205 @@ writer_thread = threading.Thread(target=writer_thread_func, args=(sock,), daemon reader_thread.start() writer_thread.start() -# ----------------- Redirecting stdout/stderr ----------------- + +# ----------------- Buffered stdout/stderr ----------------- class StreamToTCP: - def __init__(self, original_stream): + """Buffer log fragments and flush on newline or timer to reduce IPC chatter.""" + + def __init__(self, original_stream, flush_interval=0.05): self.original_stream = original_stream - + self.flush_interval = flush_interval + self._buf = [] + self._lock = threading.Lock() + self._last_flush = time.time() + self._stop = threading.Event() + self._flusher = threading.Thread(target=self._flush_loop, daemon=True) + self._flusher.start() + def write(self, buf): + if not buf: + return 0 self.original_stream.write(buf) self.original_stream.flush() - if buf: - send_queue.put((b'LOGS', buf.encode('utf-8'))) - + with self._lock: + self._buf.append(buf) + if "\n" in buf or sum(len(x) for x in self._buf) > 2048: + self._flush_unlocked() + return len(buf) + + def _flush_unlocked(self): + if not self._buf: + return + text = "".join(self._buf) + self._buf.clear() + self._last_flush = time.time() + if text: + send_queue.put((b"LOGS", text.encode("utf-8"))) + + def _flush_loop(self): + while not self._stop.wait(self.flush_interval): + with self._lock: + if self._buf and (time.time() - self._last_flush) >= self.flush_interval: + self._flush_unlocked() + def flush(self): self.original_stream.flush() + with self._lock: + self._flush_unlocked() + sys.stdout = StreamToTCP(sys.stdout) sys.stderr = StreamToTCP(sys.stderr) -# ----------------- Monkeypatching Stdin & input() ----------------- +# ----------------- Stdin / input() ----------------- import builtins class CustomStdin: def readline(self, limit=-1): return stdin_queue.get() + def read(self, size=-1): return stdin_queue.get() + def fileno(self): raise OSError("Not a real file descriptor") + def isatty(self): return True + sys.stdin = CustomStdin() original_input = builtins.input + + def custom_input(prompt=""): if prompt: sys.stdout.write(str(prompt)) sys.stdout.flush() - # Blocks until browser input is sent line = stdin_queue.get() - if line.endswith('\r\n'): + if line.endswith("\r\n"): return line[:-2] - elif line.endswith('\n') or line.endswith('\r'): + if line.endswith("\n") or line.endswith("\r"): return line[:-1] return line + builtins.input = custom_input -# ----------------- Monkeypatching Pygame ----------------- -# 1. Patch display set_mode +# ----------------- Pygame patches ----------------- original_set_mode = pygame.display.set_mode + + def custom_set_mode(size, flags=0, depth=0, display=0, vsync=0): surf = original_set_mode(size, flags, depth, display, vsync) print(f"[Pygame] 初始化視窗大小為: {size}") return surf + + pygame.display.set_mode = custom_set_mode -# 2. Patch flip and update to capture surface and stream it + +def _encode_surface_jpeg(surf) -> bytes | None: + """Encode pygame surface to JPEG via numpy + cv2 for controllable quality.""" + try: + import cv2 + + # pygame surface -> RGB array + arr = pygame.surfarray.array3d(surf) + # array3d is (w, h, 3); OpenCV wants (h, w, 3) BGR + arr = np.transpose(arr, (1, 0, 2)) + bgr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR) + # Optional downscale if very large + h, w = bgr.shape[:2] + max_w = 960 + if w > max_w: + scale = max_w / float(w) + bgr = cv2.resize(bgr, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + ok, jpeg = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) + if ok: + return jpeg.tobytes() + except Exception: + # Fallback to pygame save + try: + buffer = io.BytesIO() + pygame.image.save(surf, buffer, "JPEG") + return buffer.getvalue() + except Exception: + return None + return None + + def send_pygame_frame(): global last_pygame_frame_time now = time.time() - if now - last_pygame_frame_time < 0.033: # limit to ~30 FPS + qsize = send_queue.qsize() + _adjust_frame_interval(qsize) + if now - last_pygame_frame_time < _current_frame_interval: return - if send_queue.qsize() > 3: # drop frames if buffer is backed up + if qsize > 3: return last_pygame_frame_time = now - + surf = pygame.display.get_surface() - if surf: - import io - buffer = io.BytesIO() - try: - pygame.image.save(surf, buffer, "JPEG") - jpeg_bytes = buffer.getvalue() - # Send jpeg bytes to server - send_queue.put((b'FRME', jpeg_bytes)) - except Exception as e: - # Silence internal pygame exceptions during shutdown - pass + if not surf: + return + jpeg_bytes = _encode_surface_jpeg(surf) + if jpeg_bytes: + send_queue.put((b"FRME", jpeg_bytes)) + original_flip = pygame.display.flip original_update = pygame.display.update + def custom_flip(): send_pygame_frame() original_flip() + def custom_update(*args, **kwargs): send_pygame_frame() original_update(*args, **kwargs) + pygame.display.flip = custom_flip pygame.display.update = custom_update -# 3. Patch key and mouse input states + def custom_key_get_pressed(): return shared_keys + + pygame.key.get_pressed = custom_key_get_pressed + def custom_mouse_get_pos(): return shared_mouse_pos + + pygame.mouse.get_pos = custom_mouse_get_pos + def custom_mouse_get_pressed(num_buttons=3): return shared_mouse_buttons + + pygame.mouse.get_pressed = custom_mouse_get_pressed -# 4. Patch Pygame fonts for Chinese display fallback compatibility + def find_chinese_font_path(): paths = [ - # Linux (Debian/Ubuntu) "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/droid/DroidSansFallback.ttf", - # macOS "/System/Library/Fonts/Supplemental/Songti.ttc", "/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/PingFang.ttc", "/Library/Fonts/Arial Unicode.ttf", - # Windows "C:\\Windows\\Fonts\\msjh.ttc", "C:\\Windows\\Fonts\\msjhbd.ttc", "C:\\Windows\\Fonts\\msyh.ttc", @@ -338,24 +466,27 @@ def find_chinese_font_path(): for p in paths: if os.path.exists(p): return p - candidates = [ "wenquanyizenhei", "wenquanyimicrohei", "wqy-zenhei", "wqy-microhei", "microsoftjhenghei", "pingfangtc", "songti", "stheitimedium", - "arialunicodems", "droidsansfallback" + "arialunicodems", "droidsansfallback", ] for c in candidates: match = pygame.font.match_font(c) if match: return match - return None + original_SysFont = pygame.font.SysFont + + def custom_SysFont(name, size, bold=False, italic=False): name_lower = name.lower() if isinstance(name, str) else "" - is_chinese_request = any(x in name_lower for x in ["jheng", "hei", "kai", "ming", "song", "cjk", "wqy", "unicode", "yahei", "wenquanyi"]) or (name is None) - + is_chinese_request = any( + x in name_lower + for x in ["jheng", "hei", "kai", "ming", "song", "cjk", "wqy", "unicode", "yahei", "wenquanyi"] + ) or (name is None) if is_chinese_request: path = find_chinese_font_path() if path: @@ -363,11 +494,14 @@ def custom_SysFont(name, size, bold=False, italic=False): return pygame.font.Font(path, size) except Exception: pass - return original_SysFont(name, size, bold, italic) + + pygame.font.SysFont = custom_SysFont original_Font = pygame.font.Font + + def custom_Font(filename, size): if filename is None: path = find_chinese_font_path() @@ -377,62 +511,73 @@ def custom_Font(filename, size): except Exception: pass return original_Font(filename, size) + + pygame.font.Font = custom_Font +# ----------------- OpenCV patches ----------------- +import cv2 # noqa: E402 -# ----------------- Monkeypatching OpenCV (cv2) ----------------- -import cv2 -# 1. Patch imshow to capture frames def custom_imshow(winname, mat): global last_cv2_frame_time now = time.time() - if now - last_cv2_frame_time < 0.033: # limit to ~30 FPS + qsize = send_queue.qsize() + _adjust_frame_interval(qsize) + if now - last_cv2_frame_time < _current_frame_interval: return - if send_queue.qsize() > 3: # drop frames if buffer is backed up + if qsize > 3: return last_cv2_frame_time = now - if mat is not None: - try: - ret, jpeg = cv2.imencode('.jpg', mat, [cv2.IMWRITE_JPEG_QUALITY, 70]) - if ret: - send_queue.put((b'FRME', jpeg.tobytes())) - except Exception: - pass + if mat is None: + return + try: + frame = mat + h, w = frame.shape[:2] + max_w = 960 + if w > max_w: + scale = max_w / float(w) + frame = cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) + ret, jpeg = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY]) + if ret: + send_queue.put((b"FRME", jpeg.tobytes())) + except Exception: + pass + + cv2.imshow = custom_imshow -# 2. Patch waitKey to block on browser input queue + def custom_wait_key(delay=0): start_time = time.time() - timeout = (delay / 1000.0) if delay > 0 else float('inf') - + timeout = (delay / 1000.0) if delay > 0 else float("inf") while True: if not cv2_keys.empty(): return cv2_keys.get() - elapsed = time.time() - start_time - if elapsed >= timeout: + if time.time() - start_time >= timeout: break time.sleep(0.005) return -1 + + cv2.waitKey = custom_wait_key -# 3. Patch VideoCapture for Browser Webcam input + class MockVideoCapture: def __init__(self, source=0): self.source = source self.is_opened = True self.frame_counter = 0 - + def isOpened(self): return self.is_opened - + def read(self): global shared_webcam_frame if not self.is_opened: return False, None - - # If webcam frame has been received from browser + if shared_webcam_frame is not None: try: img_np = np.frombuffer(shared_webcam_frame, dtype=np.uint8) @@ -441,54 +586,93 @@ class MockVideoCapture: return True, frame except Exception as e: print(f"[OpenCV-Mock] 解碼 Webcam 畫面錯誤: {e}", file=sys.__stderr__) - - # Fallback: if no browser webcam is streaming yet, generate a dummy test frame - # to prevent student code from blocking or crashing + width, height = 640, 480 frame = np.zeros((height, width, 3), dtype=np.uint8) self.frame_counter += 1 - - # Draw bouncing circles as a placeholder t = time.time() cx = int(320 + 120 * np.sin(t * 3)) cy = int(240 + 80 * np.cos(t * 2)) cv2.circle(frame, (cx, cy), 40, (150, 80, 230), -1) - cv2.putText(frame, "Webcam Connecting...", (160, 240), - cv2.FONT_HERSHEY_SIMPLEX, 0.8, (240, 240, 240), 2) - cv2.putText(frame, "Please enable webcam in browser", (130, 280), - cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1) + cv2.putText( + frame, + "Webcam Connecting...", + (160, 240), + cv2.FONT_HERSHEY_SIMPLEX, + 0.8, + (240, 240, 240), + 2, + ) + cv2.putText( + frame, + "Please enable webcam in browser", + (130, 280), + cv2.FONT_HERSHEY_SIMPLEX, + 0.6, + (200, 200, 200), + 1, + ) return True, frame - + def release(self): self.is_opened = False - + def get(self, propId): if propId == cv2.CAP_PROP_FRAME_WIDTH: return 640 - elif propId == cv2.CAP_PROP_FRAME_HEIGHT: + if propId == cv2.CAP_PROP_FRAME_HEIGHT: return 480 - elif propId == cv2.CAP_PROP_FPS: + if propId == cv2.CAP_PROP_FPS: return 30 return 0 - + def set(self, propId, value): return True + original_VideoCapture = cv2.VideoCapture + + def custom_VideoCapture(source=0): - # Intercept index 0 (physical camera) - if isinstance(source, int) or source == '0' or source == 0: + if isinstance(source, int) or source == "0" or source == 0: print("[OpenCV] 攔截實體鏡頭 VideoCapture(0) 啟用瀏覽器 Webcam 串流。") return MockVideoCapture(source) - else: - # File paths or other urls go through original video capture - print(f"[OpenCV] 載入影片檔案: {source}") - return original_VideoCapture(source) + print(f"[OpenCV] 載入影片檔案: {source}") + return original_VideoCapture(source) + cv2.VideoCapture = custom_VideoCapture -# ----------------- Load & Execute Student Script ----------------- +# ----------------- Shared models + project setup ----------------- +def ensure_shared_models(project_dir: str, models_dir: str | None) -> None: + """Symlink common MediaPipe models from shared models/ if missing in project.""" + if not models_dir or not os.path.isdir(models_dir): + return + known = ( + "hand_landmarker.task", + "face_landmarker.task", + "pose_landmarker.task", + "gesture_recognizer.task", + ) + for name in known: + dest = os.path.join(project_dir, name) + src = os.path.join(models_dir, name) + if os.path.exists(dest) or not os.path.isfile(src): + continue + try: + os.symlink(src, dest) + print(f"[Runner] 連結共用模型: {name} -> {src}") + except OSError: + # Fallback: copy is too heavy; tell user models_dir path via env + try: + # Hard-link if same filesystem + os.link(src, dest) + print(f"[Runner] 硬連結共用模型: {name}") + except OSError as e: + print(f"[Runner] 無法連結模型 {name}: {e}(請將檔案放在專案目錄或 models/)") + + import runpy project_path = os.path.abspath(args.project_path) @@ -498,36 +682,67 @@ print(f"[Runner] 切換工作目錄至: {project_dir}") os.chdir(project_dir) sys.path.insert(0, project_dir) -# 🚀 Auto-generate dummy keras_model.h5 and labels.txt if missing in a Keras project +models_dir = args.models_dir +if not models_dir: + # Default: /models next to runner.py + models_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models") +ensure_shared_models(project_dir, models_dir) + +# Keras dummy assets → temp session dir (do not pollute student project) try: script_content = open(project_path, "r", errors="ignore").read() if "keras_model.h5" in script_content: + session_tmp = tempfile.mkdtemp(prefix="telearn_keras_") h5_path = os.path.join(project_dir, "keras_model.h5") labels_path = os.path.join(project_dir, "labels.txt") - - if not os.path.exists(h5_path): - print("[Runner] 偵測到 Keras 專案且缺乏 keras_model.h5,嘗試自動生成隨機 Dummy 模型...") + tmp_h5 = os.path.join(session_tmp, "keras_model.h5") + tmp_labels = os.path.join(session_tmp, "labels.txt") + + need_h5 = not os.path.exists(h5_path) + need_labels = not os.path.exists(labels_path) + + if need_h5 or need_labels: + print(f"[Runner] Keras demo 資源寫入暫存目錄: {session_tmp}") + + if need_h5: + print("[Runner] 偵測到 Keras 專案且缺乏 keras_model.h5,嘗試自動生成 Dummy 模型...") try: try: import keras except ImportError: - from tensorflow import keras - - model = keras.models.Sequential([ - keras.layers.Input(shape=(224, 224, 3)), - keras.layers.Flatten(), - keras.layers.Dense(3, activation='softmax') - ]) - model.save(h5_path) + from tensorflow import keras # type: ignore + + model = keras.models.Sequential( + [ + keras.layers.Input(shape=(224, 224, 3)), + keras.layers.Flatten(), + keras.layers.Dense(3, activation="softmax"), + ] + ) + model.save(tmp_h5) + try: + os.symlink(tmp_h5, h5_path) + except OSError: + # Last resort: only if project dir is writable and no existing file + if not os.path.exists(h5_path): + import shutil + + shutil.copy2(tmp_h5, h5_path) print("[Runner] 成功生成 dummy keras_model.h5") except Exception as ex: - print(f"[Runner] 無法自動生成模型(可能是本地缺乏 tensorflow/keras 套件):{ex}") - - if not os.path.exists(labels_path): + print(f"[Runner] 無法自動生成模型(可能未安裝 tensorflow/keras):{ex}") + + if need_labels: print("[Runner] 偵測到缺乏 labels.txt,自動生成預設分類標籤...") try: - with open(labels_path, "w", encoding="utf-8") as f: + with open(tmp_labels, "w", encoding="utf-8") as f: f.write("0 背景\n1 喝水中\n2 其他動作\n") + try: + os.symlink(tmp_labels, labels_path) + except OSError: + if not os.path.exists(labels_path): + with open(labels_path, "w", encoding="utf-8") as f: + f.write("0 背景\n1 喝水中\n2 其他動作\n") print("[Runner] 成功生成 labels.txt") except Exception as ex: print(f"[Runner] 無法自動生成 labels.txt:{ex}") @@ -536,17 +751,20 @@ except Exception as e: try: print(f"[Runner] 開始執行學生專案: {os.path.basename(project_path)}") - runpy.run_path(project_path, run_name='__main__') + runpy.run_path(project_path, run_name="__main__") except SystemExit as e: - print(f"[Runner] 專案正常結束 (SystemExit: {e})") -except Exception as e: +except Exception: import traceback - print(f"[Runner] 專案執行出錯:") + + print("[Runner] 專案執行出錯:") traceback.print_exc(file=sys.stderr) finally: - # Cleanup network connections print("[Runner] 清理資源,結束執行。") - send_queue.put((b'EXIT', b'')) - # Wait briefly for writer thread to flush out + try: + sys.stdout.flush() + sys.stderr.flush() + except Exception: + pass + send_queue.put((b"EXIT", b"")) time.sleep(0.5) diff --git a/server.py b/server.py index b1f284c6d4dd3d055869049ecfbf9c12513a1f53..96be22bada97436479cd245c288183531639c116 100644 --- a/server.py +++ b/server.py @@ -1,291 +1,412 @@ -import os -import sys -import json +""" +Telearn Arcade Hub — FastAPI server. + +Serves the project catalog, static assets, and WebSocket bridge that streams +headless Pygame/OpenCV student projects to the browser. +""" +from __future__ import annotations + +import argparse import asyncio +import hashlib +import json +import logging +import os +import signal import subprocess +import sys +import time +from typing import Any, Optional + from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect -from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -app = FastAPI(title="Student Pygame/OpenCV Arcade Hub") - -# Directory setup +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECTS_DIR = os.path.join(BASE_DIR, "projects") -os.makedirs(PROJECTS_DIR, exist_ok=True) - -# Templates setup TEMPLATES_DIR = os.path.join(BASE_DIR, "templates") +STATIC_DIR = os.path.join(BASE_DIR, "static") +MODELS_DIR = os.path.join(BASE_DIR, "models") + +MAX_CONCURRENT_RUNNERS = int(os.environ.get("MAX_CONCURRENT_RUNNERS", "3")) +SESSION_TIMEOUT_SEC = int(os.environ.get("SESSION_TIMEOUT_SEC", "900")) # 15 min +PROJECT_CACHE_TTL_SEC = float(os.environ.get("PROJECT_CACHE_TTL_SEC", "10")) +RUNNER_CONNECT_TIMEOUT_SEC = float(os.environ.get("RUNNER_CONNECT_TIMEOUT_SEC", "8")) + +os.makedirs(PROJECTS_DIR, exist_ok=True) os.makedirs(TEMPLATES_DIR, exist_ok=True) +os.makedirs(STATIC_DIR, exist_ok=True) +os.makedirs(MODELS_DIR, exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s [server] %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", +) +log = logging.getLogger("telearn.server") + +app = FastAPI(title="Student Pygame/OpenCV Arcade Hub") templates = Jinja2Templates(directory=TEMPLATES_DIR) -# ----------------- Scanning Projects ----------------- -def scan_projects(): +# Concurrency + live session metrics +runner_semaphore = asyncio.Semaphore(MAX_CONCURRENT_RUNNERS) +active_sessions: dict[str, dict[str, Any]] = {} +_sessions_lock = asyncio.Lock() +_metrics = { + "sessions_started": 0, + "sessions_rejected": 0, + "sessions_timed_out": 0, + "sessions_failed_connect": 0, +} + +# Project tree cache +_project_cache: dict[str, Any] = {"tree": None, "built_at": 0.0, "signature": None} + + +# --------------------------------------------------------------------------- +# Project scanning (cached) +# --------------------------------------------------------------------------- +def _dir_signature(root: str) -> str: + """Cheap signature: path + mtime + name for dirs up to depth 3.""" + parts: list[str] = [] + if not os.path.isdir(root): + return "" + try: + for class_name in sorted(os.listdir(root)): + class_path = os.path.join(root, class_name) + if not os.path.isdir(class_path) or class_name.startswith("."): + continue + try: + parts.append(f"{class_name}:{os.path.getmtime(class_path)}") + except OSError: + parts.append(class_name) + for student_name in sorted(os.listdir(class_path)): + student_path = os.path.join(class_path, student_name) + if not os.path.isdir(student_path) or student_name.startswith("."): + continue + try: + parts.append(f"{class_name}/{student_name}:{os.path.getmtime(student_path)}") + except OSError: + parts.append(f"{class_name}/{student_name}") + for project_name in sorted(os.listdir(student_path)): + project_path = os.path.join(student_path, project_name) + if not os.path.isdir(project_path) or project_name.startswith("."): + continue + try: + parts.append( + f"{class_name}/{student_name}/{project_name}:{os.path.getmtime(project_path)}" + ) + except OSError: + parts.append(f"{class_name}/{student_name}/{project_name}") + except OSError: + return "" + return "|".join(parts) + + +def _scan_projects_uncached() -> dict: """ - Scans the projects/ directory and returns a dictionary of: + Scan projects/ into: { class_name: { student_name: [ {name, type, url}, ... ] } } - Supports Python, HTML5, and External Links. + Priority: python > html > apk > external """ - tree = {} + tree: dict = {} if not os.path.exists(PROJECTS_DIR): return tree - + for class_name in sorted(os.listdir(PROJECTS_DIR)): class_path = os.path.join(PROJECTS_DIR, class_name) - if not os.path.isdir(class_path) or class_name.startswith('.'): + if not os.path.isdir(class_path) or class_name.startswith("."): continue - + tree[class_name] = {} for student_name in sorted(os.listdir(class_path)): student_path = os.path.join(class_path, student_name) - if not os.path.isdir(student_path) or student_name.startswith('.'): + if not os.path.isdir(student_path) or student_name.startswith("."): continue - + projects = [] for project_name in sorted(os.listdir(student_path)): project_dir = os.path.join(student_path, project_name) - if os.path.isdir(project_dir) and not project_name.startswith('.'): - # Check type priority: 1. Python, 2. HTML, 3. APK, 4. External Link - main_py = os.path.join(project_dir, "main.py") - index_html = os.path.join(project_dir, "index.html") - link_txt = os.path.join(project_dir, "link.txt") - - apk_files = [] - if os.path.isdir(project_dir): - try: - apk_files = [f for f in os.listdir(project_dir) if f.endswith(".apk") and not f.startswith('.')] - except Exception: - pass - - if os.path.exists(main_py): - projects.append({"name": project_name, "type": "python", "url": ""}) - elif os.path.exists(index_html): - projects.append({"name": project_name, "type": "html", "url": ""}) - elif apk_files: - projects.append({"name": project_name, "type": "apk", "url": apk_files[0]}) - elif os.path.exists(link_txt): - try: - with open(link_txt, "r", encoding="utf-8") as f: - url = f.read().strip() - if url: - projects.append({"name": project_name, "type": "external", "url": url}) - except Exception: - pass - + if not os.path.isdir(project_dir) or project_name.startswith("."): + continue + + main_py = os.path.join(project_dir, "main.py") + index_html = os.path.join(project_dir, "index.html") + link_txt = os.path.join(project_dir, "link.txt") + + apk_files = [] + try: + apk_files = [ + f + for f in os.listdir(project_dir) + if f.endswith(".apk") and not f.startswith(".") + ] + except OSError: + pass + + if os.path.exists(main_py): + projects.append({"name": project_name, "type": "python", "url": ""}) + elif os.path.exists(index_html): + projects.append({"name": project_name, "type": "html", "url": ""}) + elif apk_files: + projects.append({"name": project_name, "type": "apk", "url": apk_files[0]}) + elif os.path.exists(link_txt): + try: + with open(link_txt, "r", encoding="utf-8") as f: + url = f.read().strip() + if url: + projects.append({"name": project_name, "type": "external", "url": url}) + except OSError: + pass + if projects: tree[class_name][student_name] = projects - - # Clean up empty classes + if not tree[class_name]: del tree[class_name] - + return tree -from fastapi.responses import FileResponse -@app.get("/images/logo-light.png") -@app.get("/logo.png") -async def get_logo(): - """Serves the official Telearn logo.""" - return FileResponse(os.path.join(BASE_DIR, "templates", "logo-light.png")) +def scan_projects(force: bool = False) -> dict: + """Return cached project tree; refresh when TTL expires or directory signature changes.""" + now = time.time() + sig = _dir_signature(PROJECTS_DIR) + cache_age = now - _project_cache["built_at"] + if ( + not force + and _project_cache["tree"] is not None + and cache_age < PROJECT_CACHE_TTL_SEC + and _project_cache["signature"] == sig + ): + return _project_cache["tree"] + + tree = _scan_projects_uncached() + _project_cache["tree"] = tree + _project_cache["built_at"] = now + _project_cache["signature"] = sig + return tree -# ----------------- Hashing & Decoding Utilities ----------------- -import hashlib def get_id(name: str) -> str: - """Generates a stable 10-char hex hash ID for the given name.""" - return hashlib.sha256(name.encode('utf-8')).hexdigest()[:10] + """Stable 10-char hex hash for URL tokens.""" + return hashlib.sha256(name.encode("utf-8")).hexdigest()[:10] -def decode_class_student(class_name_or_id: str, student_name_or_id: str = None): - """ - Decodes ID back to real folder names for class and student. - Supports: - 1. Single token representing student_id (get_id(class/student)) or "class/student" - 2. Two arguments (class_name_or_id, student_name_or_id) - """ + +def decode_class_student( + class_name_or_id: str, student_name_or_id: Optional[str] = None +) -> tuple[Optional[str], Optional[str]]: + """Resolve hashed or raw class/student identifiers to folder names.""" all_projects = scan_projects() - - # Case A: We only have one argument (class_name_or_id) + if not student_name_or_id: - # Check if it matches a hashed student ID (get_id(class + "/" + student)) for c in all_projects: for s in all_projects[c]: if get_id(f"{c}/{s}") == class_name_or_id: return c, s - - # Check if it is a raw path "class_name/student_name" + if "/" in class_name_or_id: parts = class_name_or_id.split("/", 1) c, s = parts[0], parts[1] if c in all_projects and s in all_projects[c]: return c, s - - # Otherwise, treat class_name_or_id as a class-only identifier + real_class = None for c in all_projects: if get_id(c) == class_name_or_id: real_class = c break - if not real_class: - if class_name_or_id in all_projects: - real_class = class_name_or_id + if not real_class and class_name_or_id in all_projects: + real_class = class_name_or_id return real_class, None - # Case B: We have two arguments - # 1. Decode Class real_class = None for c in all_projects: if get_id(c) == class_name_or_id: real_class = c break - if not real_class: - if class_name_or_id in all_projects: - real_class = class_name_or_id - - # 2. Decode Student + if not real_class and class_name_or_id in all_projects: + real_class = class_name_or_id + real_student = None if real_class and real_class in all_projects: for s in all_projects[real_class]: if get_id(f"{real_class}/{s}") == student_name_or_id: real_student = s break - if not real_student: - if student_name_or_id in all_projects[real_class]: - real_student = student_name_or_id - + if not real_student and student_name_or_id in all_projects[real_class]: + real_student = student_name_or_id + return real_class, real_student -# ----------------- Custom Static Files Routing wrapper ----------------- + +def _safe_project_path(*parts: str) -> Optional[str]: + """Join under PROJECTS_DIR; return None if path escapes the root.""" + file_path = os.path.join(PROJECTS_DIR, *parts) + abs_path = os.path.abspath(file_path) + root = os.path.abspath(PROJECTS_DIR) + if not abs_path.startswith(root + os.sep) and abs_path != root: + return None + return abs_path + + +# --------------------------------------------------------------------------- +# Static / logo +# --------------------------------------------------------------------------- +if os.path.isdir(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/images/logo-light.png") +@app.get("/logo.png") +async def get_logo(): + return FileResponse(os.path.join(TEMPLATES_DIR, "logo-light.png")) + + +# Controlled project file serving only (no raw StaticFiles mount on projects/) @app.get("/static_projects/{student_token}/{project_name}/{path:path}") async def serve_static_project_file(student_token: str, project_name: str, path: str): real_class, real_student = decode_class_student(student_token) - - # If not a valid student, check if it's actually the old format: class_name/student_name/project_name/path + if not real_class or not real_student: + # Legacy: /static_projects/{class_id}/{student_id}/{project}/{path} parts = path.split("/", 1) if len(parts) == 2: - actual_project_name = parts[0] - actual_path = parts[1] + actual_project_name, actual_path = parts[0], parts[1] real_class, real_student = decode_class_student(student_token, project_name) if real_class and real_student: - file_path = os.path.join(PROJECTS_DIR, real_class, real_student, actual_project_name, actual_path) - # Prevent Path Traversal - if not os.path.abspath(file_path).startswith(os.path.abspath(PROJECTS_DIR)): - return HTMLResponse("Access Denied", status_code=403) - if os.path.exists(file_path) and not os.path.isdir(file_path): + file_path = _safe_project_path( + real_class, real_student, actual_project_name, actual_path + ) + if file_path and os.path.isfile(file_path): return FileResponse(file_path) return HTMLResponse("Not Found", status_code=404) - - file_path = os.path.join(PROJECTS_DIR, real_class, real_student, project_name, path) - # Prevent Path Traversal - if not os.path.abspath(file_path).startswith(os.path.abspath(PROJECTS_DIR)): - return HTMLResponse("Access Denied", status_code=403) - if not os.path.exists(file_path) or os.path.isdir(file_path): + + file_path = _safe_project_path(real_class, real_student, project_name, path) + if not file_path or not os.path.isfile(file_path): return HTMLResponse("File Not Found", status_code=404) - return FileResponse(file_path) -# Fallback direct mounting for direct access / other assets -app.mount("/static_projects", StaticFiles(directory=PROJECTS_DIR), name="static_projects") -# ----------------- HTTP Routes ----------------- +# --------------------------------------------------------------------------- +# HTTP routes +# --------------------------------------------------------------------------- +@app.get("/health") +async def health(): + return { + "status": "ok", + "active_sessions": len(active_sessions), + "max_concurrent_runners": MAX_CONCURRENT_RUNNERS, + "session_timeout_sec": SESSION_TIMEOUT_SEC, + } + + +@app.get("/api/metrics") +async def metrics(): + return { + "active_sessions": len(active_sessions), + "max_concurrent_runners": MAX_CONCURRENT_RUNNERS, + "sessions": list(active_sessions.values()), + **_metrics, + } + + @app.get("/", response_class=HTMLResponse) async def read_index(request: Request): - """Renders the class/student/project catalog selection page.""" - return templates.TemplateResponse("index.html", { - "request": request, - "restrict_class": "", - "restrict_student": "" - }) + return templates.TemplateResponse( + "index.html", + {"request": request, "restrict_class": "", "restrict_student": ""}, + ) + @app.get("/class/{class_name}", response_class=HTMLResponse) async def read_class(request: Request, class_name: str): - """Renders the catalog restricted to a specific class (supports ID or Name).""" real_class, _ = decode_class_student(class_name) if not real_class: return HTMLResponse("Class not found", status_code=404) - return templates.TemplateResponse("index.html", { - "request": request, - "restrict_class": real_class, - "restrict_student": "" - }) + return templates.TemplateResponse( + "index.html", + {"request": request, "restrict_class": real_class, "restrict_student": ""}, + ) + @app.get("/student/{student_token}", response_class=HTMLResponse) async def read_student(request: Request, student_token: str): - """Renders the catalog restricted to a specific student (supports combined ID or "class_name/student_name").""" real_class, real_student = decode_class_student(student_token) if not real_class or not real_student: return HTMLResponse("Student not found", status_code=404) - return templates.TemplateResponse("index.html", { - "request": request, - "restrict_class": real_class, - "restrict_student": real_student - }) + return templates.TemplateResponse( + "index.html", + { + "request": request, + "restrict_class": real_class, + "restrict_student": real_student, + }, + ) + @app.get("/student/{class_name}/{student_name}", response_class=HTMLResponse) async def read_student_old(request: Request, class_name: str, student_name: str): - """Redirects old student URLs to the new single-token URL.""" real_class, real_student = decode_class_student(class_name, student_name) if not real_class or not real_student: return HTMLResponse("Student not found", status_code=404) student_token = get_id(f"{real_class}/{real_student}") return RedirectResponse(url=f"/student/{student_token}", status_code=307) + @app.get("/api/projects", response_class=JSONResponse) async def get_projects(class_name: str = None, student_name: str = None): - """ - API endpoint to get the class-student-project structure. - Also returns class_ids and student_ids maps. - """ all_projects = scan_projects() - - # Pre-generate ID maps class_ids = {c: get_id(c) for c in all_projects} - student_ids = {f"{c}/{s}": get_id(f"{c}/{s}") for c in all_projects for s in all_projects[c]} - + student_ids = { + f"{c}/{s}": get_id(f"{c}/{s}") for c in all_projects for s in all_projects[c] + } + if class_name: real_class, real_student = decode_class_student(class_name, student_name) if real_class in all_projects: if real_student: if real_student in all_projects[real_class]: - filtered_projects = {real_class: {real_student: all_projects[real_class][real_student]}} + filtered = {real_class: {real_student: all_projects[real_class][real_student]}} else: - filtered_projects = {real_class: {}} + filtered = {real_class: {}} else: - filtered_projects = {real_class: all_projects[real_class]} + filtered = {real_class: all_projects[real_class]} else: - filtered_projects = {} + filtered = {} else: - filtered_projects = all_projects - - return { - "projects": filtered_projects, - "class_ids": class_ids, - "student_ids": student_ids - } + filtered = all_projects + + return {"projects": filtered, "class_ids": class_ids, "student_ids": student_ids} + @app.get("/run/{student_token}/{project_name}", response_class=HTMLResponse) async def run_project_page(request: Request, student_token: str, project_name: str): - """Renders the player viewport wrapper (supports combined ID or "class_name/student_name").""" real_class, real_student = decode_class_student(student_token) if not real_class or not real_student: return HTMLResponse("Project not found", status_code=404) - + proj_dir = os.path.join(PROJECTS_DIR, real_class, real_student, project_name) proj_type = "unknown" external_url = "" apk_name = "" apk_size = "" appetize_key = "" - + view_only = request.query_params.get("view") == "1" + apk_files = [] if os.path.isdir(proj_dir): try: - apk_files = [f for f in os.listdir(proj_dir) if f.endswith(".apk") and not f.startswith('.')] - except Exception: + apk_files = [ + f for f in os.listdir(proj_dir) if f.endswith(".apk") and not f.startswith(".") + ] + except OSError: pass - + if os.path.exists(os.path.join(proj_dir, "main.py")): proj_type = "python" elif os.path.exists(os.path.join(proj_dir, "index.html")): @@ -293,247 +414,400 @@ async def run_project_page(request: Request, student_token: str, project_name: s elif apk_files: proj_type = "apk" apk_name = apk_files[0] - # Check size of the apk file try: size_bytes = os.path.getsize(os.path.join(proj_dir, apk_name)) if size_bytes < 1024 * 1024: apk_size = f"{size_bytes / 1024:.1f} KB" else: apk_size = f"{size_bytes / (1024 * 1024):.1f} MB" - except Exception: + except OSError: pass - # Check appetize.txt appetize_path = os.path.join(proj_dir, "appetize.txt") if os.path.exists(appetize_path): try: with open(appetize_path, "r", encoding="utf-8") as f: appetize_key = f.read().strip() - except Exception: + except OSError: pass elif os.path.exists(os.path.join(proj_dir, "link.txt")): proj_type = "external" try: with open(os.path.join(proj_dir, "link.txt"), "r", encoding="utf-8") as f: external_url = f.read().strip() - except Exception: + except OSError: pass - return templates.TemplateResponse("player.html", { - "request": request, - "class_name": real_class, - "student_name": real_student, - "student_token": student_token, - "project_name": project_name, - "project_type": proj_type, - "external_url": external_url, - "apk_name": apk_name, - "apk_size": apk_size, - "appetize_key": appetize_key - }) + return templates.TemplateResponse( + "player.html", + { + "request": request, + "class_name": real_class, + "student_name": real_student, + "student_token": student_token, + "project_name": project_name, + "project_type": proj_type, + "external_url": external_url, + "apk_name": apk_name, + "apk_size": apk_size, + "appetize_key": appetize_key, + "view_only": view_only, + "session_timeout_sec": SESSION_TIMEOUT_SEC, + "max_concurrent_runners": MAX_CONCURRENT_RUNNERS, + }, + ) + @app.get("/run/{class_name}/{student_name}/{project_name}", response_class=HTMLResponse) -async def run_project_page_old(request: Request, class_name: str, student_name: str, project_name: str): - """Redirects old run URLs to the new single-token URL.""" +async def run_project_page_old( + request: Request, class_name: str, student_name: str, project_name: str +): real_class, real_student = decode_class_student(class_name, student_name) if not real_class or not real_student: return HTMLResponse("Project not found", status_code=404) student_token = get_id(f"{real_class}/{real_student}") - return RedirectResponse(url=f"/run/{student_token}/{project_name}", status_code=307) + qs = f"?{request.url.query}" if request.url.query else "" + return RedirectResponse( + url=f"/run/{student_token}/{project_name}{qs}", status_code=307 + ) + -# ----------------- WebSocket Communication Proxy ----------------- -async def play_project_ws_internal(websocket: WebSocket, real_class: str, real_student: str, project_name: str): +# --------------------------------------------------------------------------- +# Process helpers +# --------------------------------------------------------------------------- +def _kill_process_group(process: asyncio.subprocess.Process) -> None: + """Terminate the entire process group (runner + children).""" + if process.returncode is not None: + return + pid = process.pid + try: + if sys.platform == "win32": + process.terminate() + else: + try: + os.killpg(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + process.terminate() + except ProcessLookupError: + pass + + +async def _cleanup_process(process: asyncio.subprocess.Process) -> None: + if process is None or process.returncode is not None: + return + log.info("Cleaning up process group pid=%s", process.pid) + _kill_process_group(process) + try: + await asyncio.wait_for(process.wait(), timeout=2.0) + except asyncio.TimeoutError: + try: + if sys.platform == "win32": + process.kill() + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError, OSError): + process.kill() + except ProcessLookupError: + pass + try: + await process.wait() + except Exception: + pass + + +# --------------------------------------------------------------------------- +# WebSocket play session +# --------------------------------------------------------------------------- +async def play_project_ws_internal( + websocket: WebSocket, real_class: str, real_student: str, project_name: str +): if not real_class or not real_student: await websocket.accept() - await websocket.send_json({"type": "log", "data": "錯誤: 找不到該專案的執行路徑!\n"}) + await websocket.send_json( + {"type": "log", "data": "錯誤: 找不到該專案的執行路徑!\n"} + ) await websocket.close() return - await websocket.accept() - - project_path = os.path.join(PROJECTS_DIR, real_class, real_student, project_name, "main.py") + project_path = os.path.join( + PROJECTS_DIR, real_class, real_student, project_name, "main.py" + ) if not os.path.exists(project_path): - await websocket.send_json({"type": "log", "data": "錯誤: 找不到該專案的 main.py 檔案!\n"}) + await websocket.accept() + await websocket.send_json( + {"type": "log", "data": "錯誤: 找不到該專案的 main.py 檔案!\n"} + ) await websocket.close() return - # Start a local TCP Server with random port for the runner to connect back to - connected_event = asyncio.Event() - runner_reader = None - runner_writer = None - - async def handle_runner_conn(reader, writer): - nonlocal runner_reader, runner_writer - runner_reader = reader - runner_writer = writer - connected_event.set() - - tcp_server = await asyncio.start_server(handle_runner_conn, '127.0.0.1', 0) - tcp_port = tcp_server.sockets[0].getsockname()[1] - - # Start runner.py as a subprocess - runner_script = os.path.join(BASE_DIR, "runner.py") - cmd = [sys.executable, runner_script, "--project_path", project_path, "--port", str(tcp_port)] - - # We run the subprocess and redirect stdout/stderr so we don't pollute the main server logs, - # and to capture anything missed by monkeypatching - process = await asyncio.create_subprocess_exec( - *cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - - print(f"[Server] 啟動執行器進程: {process.pid},監聽本地連接埠: {tcp_port}") - - # Wait for the runner to connect to the TCP port (Timeout after 8 seconds) + # Concurrency gate: reject immediately if all runner slots are busy + acquired = False try: - await asyncio.wait_for(connected_event.wait(), timeout=8.0) - print(f"[Server] 執行器進程 {process.pid} 已成功建立 TCP 通訊連結。") + await asyncio.wait_for(runner_semaphore.acquire(), timeout=0.05) + acquired = True except asyncio.TimeoutError: - print(f"[Server] 錯誤: 執行器進程 {process.pid} 建立 TCP 連結逾時。") - await websocket.send_json({"type": "log", "data": "伺服器錯誤: 執行模擬器連線超時!\n"}) - process.terminate() - await process.wait() - tcp_server.close() - await tcp_server.wait_closed() - await websocket.close() + _metrics["sessions_rejected"] += 1 + await websocket.accept() + await websocket.send_json( + { + "type": "log", + "data": ( + f"目前展示名額已滿(最多同時 {MAX_CONCURRENT_RUNNERS} 個專案)。" + "請稍後再試。\n" + ), + } + ) + await websocket.send_json({"type": "status", "data": "busy"}) + await websocket.close(code=1013) return - # Tasks for bridging the connection - # 1. TCP -> WebSocket (Runner to Browser) - async def tcp_to_ws(): - try: - while True: - # Read Type (4 bytes) - type_bytes = await runner_reader.readexactly(4) - msg_type = type_bytes.decode('utf-8') - - # Read Length (4 bytes) - len_bytes = await runner_reader.readexactly(4) - length = int.from_bytes(len_bytes, byteorder='big') - - # Read Payload - payload = await runner_reader.readexactly(length) - - if msg_type == 'FRME': - # Video Frame JPEG bytes - await websocket.send_bytes(payload) - elif msg_type == 'LOGS': - # Log message UTF-8 text - log_text = payload.decode('utf-8', errors='ignore') - await websocket.send_json({"type": "log", "data": log_text}) - elif msg_type == 'EXIT': - print(f"[Server] 執行器 {process.pid} 發送 EXIT 訊號,結束連線。") - await websocket.send_json({"type": "status", "data": "stopped"}) - break - except asyncio.IncompleteReadError: - # Socket closed - print(f"[Server] 執行器 {process.pid} 連線中斷 (IncompleteReadError)") - except WebSocketDisconnect: - pass - except Exception as e: - print(f"[Server] tcp_to_ws 異常: {e}") + await websocket.accept() + session_id = f"{real_class}/{real_student}/{project_name}/{time.time():.0f}" + started_at = time.time() - # 2. WebSocket -> TCP (Browser to Runner) - async def ws_to_tcp(): - try: - while True: - data = await websocket.receive() - if "text" in data: - message = json.loads(data["text"]) - mtype = message.get("type") - - if mtype in ['keydown', 'keyup']: - # Forward keyboard inputs - payload = json.dumps(message).encode('utf-8') - runner_writer.write(b'KEYS' + len(payload).to_bytes(4, byteorder='big') + payload) - await runner_writer.drain() - - elif mtype in ['mousemove', 'mousedown', 'mouseup']: - # Forward mouse inputs - payload = json.dumps(message).encode('utf-8') - runner_writer.write(b'MOUS' + len(payload).to_bytes(4, byteorder='big') + payload) - await runner_writer.drain() - - elif mtype == 'cv2key': - # Forward cv2.waitKey keycode - payload = json.dumps({'key_code': message.get('keyCode')}).encode('utf-8') - runner_writer.write(b'CV2K' + len(payload).to_bytes(4, byteorder='big') + payload) - await runner_writer.drain() - - elif mtype == 'terminal_input': - # Forward terminal stdin text - text = message.get("data", "") - if not text.endswith('\n'): - text += '\n' - payload = text.encode('utf-8') - runner_writer.write(b'INPT' + len(payload).to_bytes(4, byteorder='big') + payload) - await runner_writer.drain() - - elif mtype == 'pygame_quit': - # Forward clean quit request to Pygame - runner_writer.write(b'QUIT' + (0).to_bytes(4, byteorder='big') + b'') - await runner_writer.drain() - - elif "bytes" in data: - # Binary data represents the browser webcam JPEG frame - payload = data["bytes"] - runner_writer.write(b'CAMF' + len(payload).to_bytes(4, byteorder='big') + payload) - await runner_writer.drain() - except WebSocketDisconnect: - print(f"[Server] 瀏覽器 WebSocket 連線已中斷。") - except Exception as e: - print(f"[Server] ws_to_tcp 異常: {e}") + async with _sessions_lock: + active_sessions[session_id] = { + "class": real_class, + "student": real_student, + "project": project_name, + "started_at": started_at, + } + _metrics["sessions_started"] += 1 + + process = None + tcp_server = None + runner_writer = None try: - # Run both bridging tasks concurrently - await asyncio.gather(tcp_to_ws(), ws_to_tcp()) - finally: - # --- Clean up resources --- - if 'process' in locals() and process: - print(f"[Server] 清理專案執行資源 (Process ID: {process.pid})") + connected_event = asyncio.Event() + runner_reader = None + + async def handle_runner_conn(reader, writer): + nonlocal runner_reader, runner_writer + runner_reader = reader + runner_writer = writer + connected_event.set() + + tcp_server = await asyncio.start_server(handle_runner_conn, "127.0.0.1", 0) + tcp_port = tcp_server.sockets[0].getsockname()[1] + + runner_script = os.path.join(BASE_DIR, "runner.py") + cmd = [ + sys.executable, + runner_script, + "--project_path", + project_path, + "--port", + str(tcp_port), + "--models_dir", + MODELS_DIR, + ] + + popen_kwargs: dict[str, Any] = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + } + if sys.platform != "win32": + popen_kwargs["start_new_session"] = True + + process = await asyncio.create_subprocess_exec(*cmd, **popen_kwargs) + log.info( + "Started runner pid=%s session=%s port=%s", + process.pid, + session_id, + tcp_port, + ) + + try: + await asyncio.wait_for( + connected_event.wait(), timeout=RUNNER_CONNECT_TIMEOUT_SEC + ) + log.info("Runner pid=%s connected via TCP", process.pid) + await websocket.send_json( + {"type": "status", "data": "running"} + ) + await websocket.send_json( + { + "type": "log", + "data": f"[系統] 專案執行器已就緒(最長 {SESSION_TIMEOUT_SEC // 60} 分鐘)。\n", + } + ) + except asyncio.TimeoutError: + _metrics["sessions_failed_connect"] += 1 + log.error("Runner pid=%s connect timeout", process.pid) + await websocket.send_json( + {"type": "log", "data": "伺服器錯誤: 執行模擬器連線超時!\n"} + ) + await websocket.send_json({"type": "status", "data": "error"}) + return + + async def tcp_to_ws(): try: - process.terminate() - try: - await asyncio.wait_for(process.wait(), timeout=1.0) - except asyncio.TimeoutError: - process.kill() - await process.wait() + while True: + type_bytes = await runner_reader.readexactly(4) + msg_type = type_bytes.decode("utf-8") + len_bytes = await runner_reader.readexactly(4) + length = int.from_bytes(len_bytes, byteorder="big") + payload = await runner_reader.readexactly(length) + + if msg_type == "FRME": + await websocket.send_bytes(payload) + elif msg_type == "LOGS": + log_text = payload.decode("utf-8", errors="ignore") + await websocket.send_json({"type": "log", "data": log_text}) + elif msg_type == "EXIT": + log.info("Runner pid=%s sent EXIT", process.pid) + await websocket.send_json({"type": "status", "data": "stopped"}) + break + except asyncio.IncompleteReadError: + log.info("Runner pid=%s TCP incomplete read", process.pid) + except WebSocketDisconnect: + pass except Exception as e: - print(f"[Server] 終止進程異常: {e}") - - if runner_writer: - runner_writer.close() + log.exception("tcp_to_ws error: %s", e) + + async def ws_to_tcp(): try: + while True: + data = await websocket.receive() + if "text" in data: + message = json.loads(data["text"]) + mtype = message.get("type") + + if mtype in ("keydown", "keyup"): + payload = json.dumps(message).encode("utf-8") + runner_writer.write( + b"KEYS" + len(payload).to_bytes(4, "big") + payload + ) + await runner_writer.drain() + elif mtype in ("mousemove", "mousedown", "mouseup"): + payload = json.dumps(message).encode("utf-8") + runner_writer.write( + b"MOUS" + len(payload).to_bytes(4, "big") + payload + ) + await runner_writer.drain() + elif mtype == "cv2key": + payload = json.dumps( + {"key_code": message.get("keyCode")} + ).encode("utf-8") + runner_writer.write( + b"CV2K" + len(payload).to_bytes(4, "big") + payload + ) + await runner_writer.drain() + elif mtype == "terminal_input": + text = message.get("data", "") + if not text.endswith("\n"): + text += "\n" + payload = text.encode("utf-8") + runner_writer.write( + b"INPT" + len(payload).to_bytes(4, "big") + payload + ) + await runner_writer.drain() + elif mtype == "pygame_quit": + runner_writer.write(b"QUIT" + (0).to_bytes(4, "big") + b"") + await runner_writer.drain() + elif "bytes" in data: + payload = data["bytes"] + runner_writer.write( + b"CAMF" + len(payload).to_bytes(4, "big") + payload + ) + await runner_writer.drain() + except WebSocketDisconnect: + log.info("Browser WebSocket disconnected session=%s", session_id) + except Exception as e: + log.exception("ws_to_tcp error: %s", e) + + async def session_watchdog(): + await asyncio.sleep(SESSION_TIMEOUT_SEC) + _metrics["sessions_timed_out"] += 1 + log.warning("Session timeout session=%s", session_id) + try: + await websocket.send_json( + { + "type": "log", + "data": f"\n[系統] 已達最長執行時間({SESSION_TIMEOUT_SEC // 60} 分鐘),自動結束。\n", + } + ) + await websocket.send_json({"type": "status", "data": "timeout"}) + except Exception: + pass + try: + await websocket.close(code=1000) + except Exception: + pass + + bridge = asyncio.gather(tcp_to_ws(), ws_to_tcp()) + watchdog = asyncio.create_task(session_watchdog()) + try: + await bridge + finally: + watchdog.cancel() + try: + await watchdog + except asyncio.CancelledError: + pass + + finally: + if process is not None: + await _cleanup_process(process) + if runner_writer is not None: + try: + runner_writer.close() await runner_writer.wait_closed() except Exception: pass - - tcp_server.close() - await tcp_server.wait_closed() - + if tcp_server is not None: + tcp_server.close() + await tcp_server.wait_closed() try: await websocket.close() except Exception: pass + async with _sessions_lock: + active_sessions.pop(session_id, None) + if acquired: + runner_semaphore.release() + log.info( + "Session ended session=%s duration=%.1fs", + session_id, + time.time() - started_at, + ) + @app.websocket("/ws/play/{student_token}/{project_name}") async def play_project_ws(websocket: WebSocket, student_token: str, project_name: str): real_class, real_student = decode_class_student(student_token) await play_project_ws_internal(websocket, real_class, real_student, project_name) + @app.websocket("/ws/play/{class_name}/{student_name}/{project_name}") -async def play_project_ws_old(websocket: WebSocket, class_name: str, student_name: str, project_name: str): +async def play_project_ws_old( + websocket: WebSocket, class_name: str, student_name: str, project_name: str +): real_class, real_student = decode_class_student(class_name, student_name) await play_project_ws_internal(websocket, real_class, real_student, project_name) -# Main entry point to run server via python command directly if wanted + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- if __name__ == "__main__": import uvicorn - import argparse - - parser = argparse.ArgumentParser() + + parser = argparse.ArgumentParser(description="Telearn Arcade Hub") parser.add_argument("--host", default="127.0.0.1", help="Server host") parser.add_argument("--port", type=int, default=8000, help="Server port") + parser.add_argument( + "--reload", + action="store_true", + default=os.environ.get("DEV_RELOAD", "").lower() in ("1", "true", "yes"), + help="Enable auto-reload (dev only)", + ) args = parser.parse_args() - - uvicorn.run("server:app", host=args.host, port=args.port, reload=True) + + uvicorn.run( + "server:app", + host=args.host, + port=args.port, + reload=args.reload, + ) diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000000000000000000000000000000000000..fb8b98109ab8cf4d4d984e55a5d6d0a14a150226 --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,595 @@ + :root { + --bg-color: #0c1222; + --panel-bg: #111827; + --border-color: #1f2937; + --border-hover: #374151; + --primary: #6366f1; + --secondary: #0ea5e9; + --accent: #a855f7; + --text-main: #f9fafb; + --text-muted: #9ca3af; + } + + * { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: 'Outfit', 'Noto Sans TC', sans-serif; + scroll-behavior: smooth; + } + + body { + background-color: var(--bg-color); + color: var(--text-main); + min-height: 100vh; + overflow-x: hidden; + display: flex; + flex-direction: column; + position: relative; + } + + /* Brand Navigation Bar */ + nav { + padding: 1rem 2rem; + border-bottom: 1px solid var(--border-color); + background: #0c1222; + display: flex; + align-items: center; + justify-content: space-between; + z-index: 10; + position: sticky; + top: 0; + width: 100%; + } + + .nav-container { + max-width: 1280px; + margin: 0 auto; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + } + + .brand-logo { + display: flex; + align-items: center; + gap: 14px; + text-decoration: none; + } + + .logo-img { + height: 36px; + width: auto; + transition: transform 0.2s ease; + } + + .brand-logo:hover .logo-img { + transform: scale(1.02); + } + + .brand-name { + font-size: 1.15rem; + font-weight: 700; + color: #ffffff; + transition: color 0.2s; + } + + .brand-logo:hover .brand-name { + color: #818cf8; + } + + .nav-links { + display: flex; + gap: 1.5rem; + } + + .nav-link { + color: var(--text-muted); + text-decoration: none; + font-size: 0.85rem; + font-weight: 600; + padding: 0.5rem 0.8rem; + border-radius: 8px; + transition: all 0.2s; + } + + .nav-link:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.03); + } + + /* Banner Header */ + header { + padding: 4rem 2rem 2.5rem; + text-align: center; + max-width: 800px; + margin: 0 auto; + position: relative; + z-index: 1; + } + + .badge-top { + display: inline-flex; + align-items: center; + padding: 0.5rem 1.2rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + border-radius: 30px; + margin-bottom: 1.5rem; + } + + .badge-top span { + color: #a5b4fc; + font-weight: 600; + font-size: 0.75rem; + letter-spacing: 1px; + text-transform: uppercase; + } + + header h1 { + font-size: 3.5rem; + font-weight: 800; + letter-spacing: -1px; + margin-bottom: 1.2rem; + line-height: 1.15; + color: #ffffff; + } + + @media (max-width: 768px) { + header h1 { + font-size: 2.5rem; + } + } + + header h1 span.gradient-text { + background: linear-gradient(135deg, #6366f1 30%, #a855f7 90%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + header p { + color: var(--text-muted); + font-size: 1.05rem; + font-weight: 400; + line-height: 1.6; + max-width: 600px; + margin: 0 auto; + } + + /* Link Generator Widget styling */ + .link-generator-panel { + background: var(--panel-bg); + border: 1px solid var(--border-color); + border-radius: 20px; + padding: 1.5rem 2rem; + max-width: 1350px; + width: calc(100% - 4rem); + margin: 0 auto 2rem; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + gap: 1rem; + position: relative; + z-index: 1; + } + + .generator-header { + display: flex; + align-items: center; + gap: 10px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + padding-bottom: 0.6rem; + } + + .generator-header h3 { + font-size: 0.95rem; + font-weight: 700; + color: #ffffff; + letter-spacing: 0.5px; + } + + .generator-body { + display: grid; + grid-template-columns: 1.2fr 1.2fr 2fr; + gap: 1.5rem; + align-items: flex-end; + } + + @media (max-width: 768px) { + .generator-body { + grid-template-columns: 1fr; + gap: 1rem; + } + .link-generator-panel { + width: calc(100% - 3rem); + padding: 1.2rem 1.5rem; + } + } + + .select-group { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .select-group label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + } + + .select-group select { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-color); + color: #ffffff; + padding: 0.7rem; + border-radius: 12px; + outline: none; + cursor: pointer; + transition: border-color 0.2s; + font-size: 0.85rem; + } + + .select-group select:hover:not(:disabled) { + border-color: var(--border-hover); + } + + .select-group select:focus { + border-color: var(--primary); + } + + .result-group { + display: flex; + gap: 0.8rem; + } + + .result-group input { + flex-grow: 1; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border-color); + color: var(--text-muted); + padding: 0.7rem; + border-radius: 12px; + font-size: 0.85rem; + outline: none; + } + + .result-group button { + background: var(--primary); + color: #ffffff; + border: none; + padding: 0.7rem 1.5rem; + border-radius: 12px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + font-size: 0.85rem; + white-space: nowrap; + } + + .result-group button:hover { + opacity: 0.9; + } + + /* Grid Columns Workspace */ + .container { + max-width: 1350px; + margin: 0 auto; + padding: 0 2rem 5rem; + width: 100%; + flex-grow: 1; + display: grid; + grid-template-columns: 1fr 1.2fr 1.8fr; + gap: 2rem; + position: relative; + z-index: 1; + } + + @media (max-width: 1024px) { + .container { + grid-template-columns: 1fr !important; + gap: 1.5rem; + padding: 0 1.5rem 3rem; + } + } + + /* Modern Dark Panels */ + .panel { + background: var(--panel-bg); + border: 1px solid var(--border-color); + border-radius: 24px; + padding: 2rem; + display: flex; + flex-direction: column; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.25); + transition: all 0.2s ease; + height: 620px; + overflow: hidden; /* Prevent parent scrolling */ + } + + @media (max-width: 1024px) { + .panel { + height: 420px; + } + } + + .panel-header { + display: flex; + align-items: center; + margin-bottom: 1.8rem; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + } + + .panel-header .badge { + width: 6px; + height: 6px; + border-radius: 50%; + margin-right: 12px; + background-color: var(--primary); + } + + .panel-header h2 { + font-size: 1rem; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: #ffffff; + } + + /* Card list */ + .list { + display: flex; + flex-direction: column; + gap: 0.8rem; + overflow-y: auto; + flex-grow: 1; + padding-right: 6px; + } + + /* Customize Scrollbars */ + .list::-webkit-scrollbar { + width: 5px; + } + .list::-webkit-scrollbar-track { + background: transparent; + } + .list::-webkit-scrollbar-thumb { + background: #1f2937; + border-radius: 10px; + } + .list::-webkit-scrollbar-thumb:hover { + background: #374151; + } + + /* Clean Card Selector */ + .item-card { + background: rgba(255, 255, 255, 0.01); + border: 1px solid rgba(255, 255, 255, 0.02); + border-left: 3px solid transparent; + border-radius: 14px; + padding: 1rem 1.2rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: space-between; + position: relative; + } + + .item-card:hover { + background: rgba(255, 255, 255, 0.03); + border-color: var(--border-hover); + } + + #panel-classes .item-card:hover, #panel-classes .item-card.active { + border-left-color: var(--primary); + } + #panel-classes .item-card.active { + background: rgba(99, 102, 241, 0.05); + border-color: rgba(99, 102, 241, 0.15); + } + + #panel-students .item-card:hover, #panel-students .item-card.active { + border-left-color: var(--accent); + } + #panel-students .item-card.active { + background: rgba(168, 85, 247, 0.05); + border-color: rgba(168, 85, 247, 0.15); + } + + /* Project Cards */ + .project-card { + background: rgba(255, 255, 255, 0.01); + border: 1px solid rgba(255, 255, 255, 0.02); + border-left: 3px solid transparent; + border-radius: 16px; + padding: 1.3rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + flex-direction: column; + gap: 1rem; + position: relative; + } + + .project-card:hover { + background: rgba(255, 255, 255, 0.03); + border-color: var(--border-hover); + } + + .project-card.hover-python:hover { + border-left-color: var(--primary); + border-color: rgba(99, 102, 241, 0.15); + } + + .project-card.hover-scratch:hover { + border-left-color: #f97316; + border-color: rgba(249, 115, 22, 0.15); + } + + .project-card.hover-makecode:hover { + border-left-color: #0ea5e9; + border-color: rgba(14, 165, 233, 0.15); + } + + .project-card.hover-html:hover { + border-left-color: #10b981; + border-color: rgba(16, 185, 129, 0.15); + } + + .project-card.hover-apk:hover { + border-left-color: #a855f7; + border-color: rgba(168, 85, 247, 0.15); + } + + .project-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + .project-title { + font-size: 1.05rem; + font-weight: 600; + color: #ffffff; + letter-spacing: 0.2px; + } + + /* Type Badges */ + .project-tag { + font-size: 0.68rem; + padding: 0.25rem 0.65rem; + border-radius: 30px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; + } + + .tag-python { + background: rgba(99, 102, 241, 0.08); + color: #a5b4fc; + border: 1px solid rgba(99, 102, 241, 0.15); + } + + .tag-scratch { + background: rgba(249, 115, 22, 0.08); + color: #fed7aa; + border: 1px solid rgba(249, 115, 22, 0.15); + } + + .tag-makecode { + background: rgba(14, 165, 233, 0.08); + color: #bae6fd; + border: 1px solid rgba(14, 165, 233, 0.15); + } + + .tag-html { + background: rgba(16, 185, 129, 0.08); + color: #a7f3d0; + border: 1px solid rgba(16, 185, 129, 0.15); + } + + .tag-apk { + background: rgba(168, 85, 247, 0.08); + color: #d8b4fe; + border: 1px solid rgba(168, 85, 247, 0.15); + } + + /* Action launch button */ + .btn-launch { + border: none; + color: #ffffff; + font-weight: 600; + padding: 0.75rem 1.2rem; + border-radius: 12px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + } + + .btn-launch:active { + transform: scale(0.99); + } + + .btn-launch-python { + background: var(--primary); + } + .btn-launch-python:hover { + background: #4f46e5; + } + + .btn-launch-scratch { + background: #ea580c; + } + .btn-launch-scratch:hover { + background: #d97706; + } + + .btn-launch-makecode { + background: #0284c7; + } + .btn-launch-makecode:hover { + background: #0369a1; + } + + .btn-launch-html { + background: #059669; + } + .btn-launch-html:hover { + background: #047857; + } + + .btn-launch-apk { + background: var(--accent); + } + .btn-launch-apk:hover { + background: #9333ea; + } + + /* Placeholder */ + .empty-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: var(--text-muted); + padding: 4rem 1rem; + height: 100%; + gap: 1rem; + font-weight: 400; + } + + .empty-placeholder svg { + width: 32px; + height: 32px; + stroke: currentColor; + opacity: 0.3; + } + + .empty-placeholder p { + font-size: 0.85rem; + line-height: 1.6; + } + + /* Footer */ + footer { + text-align: center; + padding: 1.8rem; + color: var(--text-muted); + font-size: 0.8rem; + border-top: 1px solid var(--border-color); + background: #0c1222; + position: relative; + z-index: 1; + margin-top: auto; + } + \ No newline at end of file diff --git a/static/css/player.css b/static/css/player.css new file mode 100644 index 0000000000000000000000000000000000000000..a2d3144c67b46be25fb26daefc2ca366f3c1de82 --- /dev/null +++ b/static/css/player.css @@ -0,0 +1,754 @@ + :root { + --bg-color: #0c1222; + --panel-bg: #111827; + --border-color: #1f2937; + --border-hover: #374151; + --primary: #6366f1; + --secondary: #0ea5e9; + --accent: #a855f7; + --text-main: #f9fafb; + --text-muted: #9ca3af; + --success: #10b981; + --danger: #ef4444; + } + + * { + box-sizing: border-box; + margin: 0; + padding: 0; + font-family: 'Outfit', 'Noto Sans TC', sans-serif; + } + + body { + background-color: var(--bg-color); + color: var(--text-main); + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + } + + /* Navigation Top Bar */ + .top-bar { + height: 64px; + border-bottom: 1px solid var(--border-color); + background: #0c1222; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2rem; + z-index: 10; + position: relative; + } + + .project-info { + display: flex; + align-items: center; + gap: 1rem; + } + + .brand-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + margin-right: 1.5rem; + } + + .logo-img { + height: 32px; + width: auto; + transition: transform 0.2s ease; + } + + .brand-logo:hover .logo-img { + transform: scale(1.02); + } + + .brand-name { + font-size: 1rem; + font-weight: 700; + color: #ffffff; + } + + .btn-back { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + color: var(--text-main); + padding: 0.5rem 1.1rem; + border-radius: 10px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; + } + + .btn-back:hover { + background: rgba(255, 255, 255, 0.05); + transform: translateX(-2px); + border-color: var(--border-hover); + } + + .project-title-wrapper { + display: flex; + flex-direction: column; + } + + .project-path { + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + } + + .project-name { + font-size: 1.05rem; + font-weight: 700; + color: #ffffff; + } + + /* Status Light */ + .status-wrapper { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + background: rgba(16, 185, 129, 0.06); + color: var(--success); + padding: 0.35rem 0.9rem; + border-radius: 30px; + border: 1px solid rgba(16, 185, 129, 0.15); + font-weight: 600; + } + + .status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background-color: var(--success); + animation: pulse 1.5s infinite; + } + + .status-stopped { + background: rgba(239, 68, 68, 0.06) !important; + color: var(--danger) !important; + border: 1px solid rgba(239, 68, 68, 0.15) !important; + } + .status-stopped .status-dot { + background-color: var(--danger) !important; + animation: none; + } + + /* Main Grid Wrapper */ + .main-container { + display: grid; + grid-template-columns: 1fr 380px; + flex-grow: 1; + overflow: hidden; + background-color: #080c14; + position: relative; + } + + /* Interactive Screen Area */ + .viewport-section { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + position: relative; + background: #080c14; + width: 100%; + height: 100%; + overflow-y: auto; + } + + .screen-frame { + position: relative; + background: #000; + border-radius: 16px; + padding: 8px; + box-shadow: + 0 0 0 1px var(--border-color), + 0 20px 40px rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + max-height: 85%; + aspect-ratio: 4/3; + overflow: hidden; + width: 100%; + z-index: 1; + } + + #game-screen { + max-width: 100%; + max-height: 100%; + border-radius: 8px; + display: block; + image-rendering: pixelated; + cursor: crosshair; + } + + /* Iframe Viewport for Scratch & HTML5 */ + .screen-frame.iframe-mode { + aspect-ratio: auto; + max-height: 100%; + height: 100%; + width: 100%; + padding: 0; + overflow: auto; + border-radius: 12px; + box-shadow: + 0 0 0 1px var(--border-color), + 0 20px 40px rgba(0, 0, 0, 0.6); + } + + .iframe-viewport { + width: 100%; + height: 100%; + border: none; + background-color: #000; + border-radius: 10px; + display: block; + } + + /* Guide Tip Overlay */ + .focus-prompt { + margin-top: 1.2rem; + font-size: 0.82rem; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.01); + padding: 0.4rem 1.2rem; + border-radius: 30px; + border: 1px solid var(--border-color); + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + z-index: 1; + } + + .focus-prompt.active { + color: var(--secondary); + border-color: rgba(14, 165, 233, 0.3); + background: rgba(14, 165, 233, 0.05); + } + + /* Right Panel styling */ + .control-panel { + border-left: 1px solid var(--border-color); + background: var(--panel-bg); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 1; + } + + /* Top Panel settings */ + .device-section { + padding: 1.5rem; + border-bottom: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 1rem; + } + + .device-title { + font-size: 0.8rem; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .btn-webcam { + background: rgba(255, 255, 255, 0.01); + border: 1px solid var(--border-color); + color: var(--text-main); + padding: 0.8rem 1.2rem; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 0.88rem; + } + + .btn-webcam:hover { + background: rgba(255, 255, 255, 0.03); + border-color: var(--border-hover); + } + + .btn-webcam.active { + background: rgba(14, 165, 233, 0.06); + border-color: var(--secondary); + color: var(--secondary); + } + + /* Layout modifications */ + .action-buttons { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.8rem; + } + + .btn-action { + padding: 0.75rem; + border-radius: 10px; + border: none; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 0.85rem; + } + + .btn-restart { + background: var(--primary); + color: #fff; + } + .btn-restart:hover { + opacity: 0.9; + } + + .btn-stop { + background: rgba(239, 68, 68, 0.05); + color: var(--danger); + border: 1px solid rgba(239, 68, 68, 0.15); + } + .btn-stop:hover { + background: var(--danger); + color: #fff; + } + + /* Terminal styling */ + .terminal-section { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 1.5rem; + } + + .terminal-header-bar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.8rem; + } + + .btn-clear { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; + } + .btn-clear:hover { + color: var(--text-main); + } + + .terminal-box { + background: #05080f; + border: 1px solid var(--border-color); + border-radius: 14px 14px 0 0; + border-bottom: none; + padding: 1rem; + flex-grow: 1; + overflow-y: auto; + font-family: 'Fira Code', monospace; + font-size: 0.8rem; + color: #e5e7eb; + line-height: 1.5; + white-space: pre-wrap; + } + + .terminal-box::-webkit-scrollbar { + width: 4px; + } + .terminal-box::-webkit-scrollbar-thumb { + background: #1f2937; + border-radius: 4px; + } + + .terminal-input-wrapper { + display: flex; + align-items: center; + background: #05080f; + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 14px 14px; + padding: 0.5rem 1rem; + gap: 8px; + } + + .terminal-prompt { + color: var(--primary); + font-family: 'Fira Code', monospace; + font-size: 0.8rem; + font-weight: 700; + user-select: none; + } + + #terminal-input-field { + flex-grow: 1; + background: transparent; + border: none; + outline: none; + color: #e5e7eb; + font-family: 'Fira Code', monospace; + font-size: 0.8rem; + caret-color: var(--primary); + } + + #terminal-input-field::placeholder { + color: #4b5563; + } + + @keyframes pulse { + 0% { opacity: 0.4; } + 50% { opacity: 1; } + 100% { opacity: 0.4; } + } + + /* Hidden media tools */ + #webcam-video, #webcam-canvas { + display: none; + } + + /* Responsive layout for smaller heights */ + @media (max-height: 800px) { + .viewport-section { + padding: 1rem; + } + .screen-frame:not(.iframe-mode) { + max-height: 80%; + } + .focus-prompt { + margin-top: 0.5rem; + padding: 0.3rem 1rem; + font-size: 0.75rem; + } + .device-section { + padding: 1rem; + gap: 0.75rem; + } + .terminal-section { + padding: 1rem; + } + } + + /* APK Card Tag / Button */ + .btn-launch-apk { + background: var(--accent) !important; + } + .btn-launch-apk:hover { + background: #9333ea !important; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(168, 85, 247, 0.25); + } + + /* Smartphone Mockup Container */ + .phone-mockup { + width: 320px; + height: 640px; + background: #000000; + border: 12px solid #1f2937; + border-radius: 40px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8), 0 0 0 2px rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + position: relative; + overflow: hidden; + flex-shrink: 0; + transition: all 0.3s ease; + } + + /* Notch / Island bezel */ + .phone-mockup::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 140px; + height: 25px; + background: #1f2937; + border-bottom-left-radius: 18px; + border-bottom-right-radius: 18px; + z-index: 10; + } + + .phone-speaker { + position: absolute; + top: 5px; + left: 50%; + transform: translateX(-50%); + width: 40px; + height: 4px; + background: #374151; + border-radius: 2px; + z-index: 11; + } + + .phone-camera { + position: absolute; + top: 4px; + left: calc(50% + 30px); + width: 6px; + height: 6px; + background: #111827; + border-radius: 50%; + z-index: 11; + } + + .phone-status-bar { + width: 100%; + height: 25px; + padding: 0 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.6); + position: absolute; + top: 0; + left: 0; + z-index: 5; + box-sizing: border-box; + } + + .phone-screen { + flex-grow: 1; + background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + color: #ffffff; + position: relative; + text-align: center; + gap: 1.5rem; + box-sizing: border-box; + } + + .phone-screen-iframe { + width: 100%; + height: 100%; + border: none; + background: #000; + border-radius: 28px; + } + + .android-icon-wrapper { + width: 80px; + height: 80px; + background: rgba(168, 85, 247, 0.15); + border: 2px solid rgba(168, 85, 247, 0.3); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + animation: pulse-glow 2s infinite ease-in-out; + } + + @keyframes pulse-glow { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(168, 85, 247, 0.4); + } + 50% { + transform: scale(1.05); + box-shadow: 0 0 20px 5px rgba(168, 85, 247, 0.2); + } + } + + /* APK Side Panel Layout and Elements */ + .apk-download-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-color); + padding: 1.2rem; + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 0.8rem; + align-items: center; + text-align: center; + box-sizing: border-box; + } + + .apk-info-text { + display: flex; + flex-direction: column; + gap: 2px; + } + + .apk-file-name { + font-weight: 700; + font-size: 0.95rem; + color: #ffffff; + word-break: break-all; + } + + .apk-file-size { + font-size: 0.75rem; + color: var(--text-muted); + } + + .qr-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + text-align: center; + border-top: 1px solid var(--border-color); + padding-top: 1.5rem; + box-sizing: border-box; + } + + .qr-code-wrapper { + background: #ffffff; + padding: 10px; + border-radius: 12px; + width: 160px; + height: 160px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5); + box-sizing: border-box; + } + + .qr-code-wrapper img { + width: 140px; + height: 140px; + display: block; + } + + .qr-tip { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; + max-width: 280px; + } + + .guide-section { + border-top: 1px solid var(--border-color); + padding-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.8rem; + box-sizing: border-box; + } + + .guide-item { + display: flex; + gap: 12px; + align-items: flex-start; + } + + .guide-step-number { + background: rgba(168, 85, 247, 0.15); + color: #c084fc; + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; + } + + .guide-text { + font-size: 0.82rem; + color: var(--text-muted); + line-height: 1.4; + } + + .guide-link { + color: #c084fc; + text-decoration: underline; + transition: color 0.2s; + } + + .guide-link:hover { + color: #d8b4fe; + } + +/* Loading overlay + webcam denied */ +.loading-overlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + background: rgba(8, 12, 20, 0.88); + color: #e5e7eb; + text-align: center; + padding: 1.5rem; + pointer-events: none; +} +.loading-overlay p { + font-size: 0.95rem; + font-weight: 600; + color: #f9fafb; +} +.loading-overlay .loading-sub { + font-size: 0.8rem; + font-weight: 400; + color: #9ca3af; + max-width: 22rem; +} +.loading-spinner { + width: 36px; + height: 36px; + border: 3px solid rgba(99, 102, 241, 0.2); + border-top-color: #6366f1; + border-radius: 50%; + animation: telearn-spin 0.8s linear infinite; +} +@keyframes telearn-spin { + to { transform: rotate(360deg); } +} +.webcam-denied { + position: absolute; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 25; + max-width: min(420px, 90%); + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.35); + color: #fecaca; + padding: 0.9rem 1.1rem; + border-radius: 12px; + font-size: 0.82rem; + line-height: 1.45; + text-align: center; +} +.webcam-denied strong { + color: #fca5a5; +} +.viewport-section { + position: relative; +} diff --git a/static/js/index.js b/static/js/index.js new file mode 100644 index 0000000000000000000000000000000000000000..5e12698185b64a59ecfbb1bc9cbb078f8f382c7d --- /dev/null +++ b/static/js/index.js @@ -0,0 +1,314 @@ + + const restrictClass = (window.TELEARN_INDEX && window.TELEARN_INDEX.restrictClass) || ""; + const restrictStudent = (window.TELEARN_INDEX && window.TELEARN_INDEX.restrictStudent) || ""; + + let projectData = {}; + let classIds = {}; + let studentIds = {}; + let activeClass = ""; + let activeStudent = ""; + + // Resize layout columns dynamically according to restrictions + function applyLayoutRestrictions() { + const container = document.querySelector('.container'); + const panelClasses = document.getElementById('panel-classes'); + const panelStudents = document.getElementById('panel-students'); + + if (restrictClass && restrictStudent) { + panelClasses.style.display = 'none'; + panelStudents.style.display = 'none'; + container.style.gridTemplateColumns = '1fr'; + document.querySelector('#panel-projects .panel-header h2').innerText = `${restrictStudent} 的專題列表`; + } else if (restrictClass) { + panelClasses.style.display = 'none'; + container.style.gridTemplateColumns = '1.2fr 1.8fr'; + document.querySelector('#panel-students .panel-header h2').innerText = `${restrictClass} 學生名單`; + } + } + + // Fetch data from backend + async function fetchProjects() { + applyLayoutRestrictions(); + try { + let url = '/api/projects'; + const params = []; + if (restrictClass) params.push(`class_name=${encodeURIComponent(restrictClass)}`); + if (restrictStudent) params.push(`student_name=${encodeURIComponent(restrictStudent)}`); + if (params.length > 0) url += '?' + params.join('&'); + + const response = await fetch(url); + const res = await response.json(); + projectData = res.projects; + classIds = res.class_ids; + studentIds = res.student_ids; + + if (restrictClass && restrictStudent) { + activeClass = restrictClass; + activeStudent = restrictStudent; + renderProjects(); + } else if (restrictClass) { + activeClass = restrictClass; + renderStudents(); + } else { + renderClasses(); + } + + // Initialize link generator if elements are present on the page (not in restricted view) + if (document.getElementById("link-generator")) { + initLinkGenerator(); + } + } catch (error) { + console.error("Error fetching projects:", error); + const targetId = (restrictClass && restrictStudent) ? 'project-list' : + (restrictClass ? 'student-list' : 'class-list'); + document.getElementById(targetId).innerHTML = ` +
+

載入專案清單失敗。請確認伺服器運作正常。

+
`; + } + } + + // Initialize Share Link Generator + function initLinkGenerator() { + const classSelect = document.getElementById("gen-class-select"); + if (!classSelect) return; + + classSelect.innerHTML = ''; + const classes = Object.keys(projectData); + classes.forEach(cls => { + const opt = document.createElement("option"); + opt.value = cls; + opt.textContent = cls; + classSelect.appendChild(opt); + }); + + classSelect.onchange = handleGeneratorClassChange; + } + + function handleGeneratorClassChange() { + const classSelect = document.getElementById("gen-class-select"); + const studentSelect = document.getElementById("gen-student-select"); + const urlResult = document.getElementById("gen-url-result"); + + const selectedClass = classSelect.value; + studentSelect.innerHTML = ''; + urlResult.value = ""; + + if (!selectedClass) { + studentSelect.disabled = true; + return; + } + + studentSelect.disabled = false; + const students = Object.keys(projectData[selectedClass] || {}); + students.forEach(std => { + const opt = document.createElement("option"); + opt.value = std; + opt.textContent = std; + studentSelect.appendChild(opt); + }); + + studentSelect.onchange = generateShareUrl; + generateShareUrl(); + } + + function generateShareUrl() { + const classSelect = document.getElementById("gen-class-select"); + const studentSelect = document.getElementById("gen-student-select"); + const urlResult = document.getElementById("gen-url-result"); + + const selectedClass = classSelect.value; + const selectedStudent = studentSelect.value; + + if (!selectedClass) { + urlResult.value = ""; + return; + } + + const classId = classIds[selectedClass]; + const baseUrl = window.location.origin; + + if (selectedStudent) { + const studentKey = `${selectedClass}/${selectedStudent}`; + const studentId = studentIds[studentKey]; + urlResult.value = `${baseUrl}/student/${studentId}`; + } else { + urlResult.value = `${baseUrl}/class/${classId}`; + } + } + + function copyGeneratedUrl() { + const urlResult = document.getElementById("gen-url-result"); + const btnCopy = document.getElementById("btn-copy-url"); + + if (!urlResult.value) return; + + urlResult.select(); + urlResult.setSelectionRange(0, 99999); + + navigator.clipboard.writeText(urlResult.value).then(() => { + const oldText = btnCopy.textContent; + btnCopy.textContent = "已複製!"; + btnCopy.style.background = "#10b981"; + setTimeout(() => { + btnCopy.textContent = oldText; + btnCopy.style.background = ""; + }, 2000); + }).catch(err => { + console.error("Failed to copy:", err); + }); + } + + // Render Class Cards + function renderClasses() { + const classList = document.getElementById('class-list'); + classList.innerHTML = ""; + const classes = Object.keys(projectData); + + if (classes.length === 0) { + classList.innerHTML = ` +
+

目錄下尚無任何學生專案。

+

請將學生專案資料夾置於 projects/ 下

+
`; + return; + } + + classes.forEach(cls => { + const card = document.createElement('div'); + card.className = `item-card ${activeClass === cls ? 'active' : ''}`; + card.innerHTML = ` + ${cls} + + `; + card.onclick = () => selectClass(cls); + classList.appendChild(card); + }); + } + + function selectClass(className) { + activeClass = className; + activeStudent = ""; + renderClasses(); + renderStudents(); + + document.getElementById('project-list').innerHTML = ` +
+ +

請先點選學生以瀏覽其專案

+
`; + } + + // Render Student Cards + function renderStudents() { + const studentList = document.getElementById('student-list'); + studentList.innerHTML = ""; + const students = Object.keys(projectData[activeClass] || {}); + + if (students.length === 0) { + studentList.innerHTML = ` +
+

無學生資料

+
`; + return; + } + + students.forEach(std => { + const card = document.createElement('div'); + card.className = `item-card ${activeStudent === std ? 'active' : ''}`; + card.innerHTML = ` + ${std} + + `; + card.onclick = () => selectStudent(std); + studentList.appendChild(card); + }); + } + + function selectStudent(studentName) { + activeStudent = studentName; + renderStudents(); + renderProjects(); + } + + // Render Project Cards with hover-classes matching category colors + function renderProjects() { + const projectList = document.getElementById('project-list'); + projectList.innerHTML = ""; + const projects = (projectData[activeClass] && projectData[activeClass][activeStudent]) || []; + + if (projects.length === 0) { + projectList.innerHTML = ` +
+

該名學生尚無可執行的專案項目。

+
`; + return; + } + + projects.forEach(proj => { + let tagText = "HTML5 Game"; + let tagClass = "tag-html"; + let hoverClass = "hover-html"; + let btnClass = "btn-launch-html"; + + if (proj.type === "python") { + tagText = "Python (Pygame/CV)"; + tagClass = "tag-python"; + hoverClass = "hover-python"; + btnClass = "btn-launch-python"; + } else if (proj.type === "external") { + const url = proj.url.toLowerCase(); + if (url.includes("scratch")) { + tagText = "Scratch Project"; + tagClass = "tag-scratch"; + hoverClass = "hover-scratch"; + btnClass = "btn-launch-scratch"; + } else if (url.includes("makecode")) { + tagText = "MakeCode Arcade"; + tagClass = "tag-makecode"; + hoverClass = "hover-makecode"; + btnClass = "btn-launch-makecode"; + } else { + tagText = "External App"; + tagClass = "tag-html"; + hoverClass = "hover-html"; + btnClass = "btn-launch-html"; + } + } else if (proj.type === "html") { + tagText = "HTML5 Offline"; + tagClass = "tag-html"; + hoverClass = "hover-html"; + btnClass = "btn-launch-html"; + } else if (proj.type === "apk") { + tagText = "Android App (APK)"; + tagClass = "tag-apk"; + hoverClass = "hover-apk"; + btnClass = "btn-launch-apk"; + } + + const card = document.createElement('div'); + card.className = `project-card ${hoverClass}`; + card.innerHTML = ` +
+ ${proj.name} + ${tagText} +
+ + `; + projectList.appendChild(card); + }); + } + + function launchProject(projectName) { + const studentKey = `${activeClass}/${activeStudent}`; + const studentId = studentIds[studentKey] || activeStudent; + + const url = `/run/${encodeURIComponent(studentId)}/${encodeURIComponent(projectName)}`; + window.open(url, '_blank'); + } + + window.onload = fetchProjects; + \ No newline at end of file diff --git a/static/js/player.js b/static/js/player.js new file mode 100644 index 0000000000000000000000000000000000000000..969a5edb23953ed822c22ed2fd754b5b6b9391eb --- /dev/null +++ b/static/js/player.js @@ -0,0 +1,498 @@ + + const cfg = window.TELEARN_PLAYER || {}; + const className = cfg.className || ""; + const studentName = cfg.studentName || ""; + const studentToken = cfg.studentToken || ""; + const projectName = cfg.projectName || ""; + const projectType = cfg.projectType || ""; + const externalUrl = cfg.externalUrl || ""; + const apkName = cfg.apkName || ""; + const apkSize = cfg.apkSize || ""; + const appetizeKey = cfg.appetizeKey || ""; + const viewOnly = !!cfg.viewOnly; + const sessionTimeoutSec = cfg.sessionTimeoutSec || 900; + + const viewportFrame = document.getElementById("viewport-frame"); + const focusPrompt = document.getElementById("focus-prompt-el"); + const consoleLogs = document.getElementById("console-logs"); + const statusWrapper = document.getElementById("server-status"); + const statusText = document.getElementById("status-text"); + const btnToggleWebcam = document.getElementById("btn-toggle-webcam"); + const webcamVideo = document.getElementById("webcam-video"); + const webcamCanvas = document.getElementById("webcam-canvas"); + const webcamCtx = webcamCanvas.getContext("2d"); + + let socket = null; + let oldUrl = null; + let webcamStream = null; + let webcamInterval = null; + let isWebcamStreaming = false; + let isFocused = false; + + // Auto-transform Scratch URLs into embed links + function formatEmbedUrl(url) { + if (url.includes("scratch.mit.edu/projects/")) { + const matches = url.match(/\/projects\/(\d+)/); + if (matches && matches[1]) { + return `https://scratch.mit.edu/projects/${matches[1]}/embed`; + } + } + return url; + } + + // Initialize view based on project type + function initViewport() { + const layout = document.getElementById("player-layout"); + const sidePanel = document.getElementById("side-panel"); + + if (projectType === "python") { + if (viewOnly) { + statusWrapper.classList.add("status-stopped"); + statusText.innerText = "僅供預覽"; + document.getElementById("python-device-section").style.display = "none"; + const img = document.createElement("div"); + img.style.cssText = "color:#94a3b8;text-align:center;padding:2rem;max-width:28rem;line-height:1.6;"; + img.innerHTML = `

${projectName}

+

班級:${className}
學生:${studentName}

+

此連結為「僅預覽」模式,不會在伺服器上執行程式。
+ 若要實際操作,請使用不含 ?view=1 的連結。

`; + viewportFrame.appendChild(img); + appendConsole("[系統] view=1 僅預覽模式,未啟動執行器。\n"); + } else { + const img = document.createElement("img"); + img.id = "game-screen"; + img.src = "data:image/svg+xml;utf8,正在與伺服器建立連接..."; + img.tabIndex = 1; + viewportFrame.appendChild(img); + focusPrompt.style.display = "flex"; + setLoading(true, "正在與伺服器建立連線…", "啟動 headless 執行器與模型載入中"); + setupPythonControls(img); + connectWebSocket(); + } + } else if (projectType === "apk") { + // Hide Python sections, show APK sections + document.getElementById("python-device-section").style.display = "none"; + document.getElementById("python-terminal-section").style.display = "none"; + statusWrapper.style.display = "none"; + + const apkInfoSec = document.getElementById("apk-info-section"); + apkInfoSec.style.display = "flex"; + + // Setup downloads & sizes + const dlUrl = window.location.origin + `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/${encodeURIComponent(apkName)}`; + document.getElementById("btn-apk-download").href = dlUrl; + document.getElementById("apk-file-label").innerText = apkName; + document.getElementById("apk-size-label").innerText = apkSize ? `檔案大小: ${apkSize}` : "已偵測到安裝檔"; + + // Generate QR code + const qrUrl = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(dlUrl)}`; + document.getElementById("apk-qr-image").src = qrUrl; + + // Setup Phone mockup + viewportFrame.classList.add("iframe-mode"); + viewportFrame.style.background = "transparent"; + viewportFrame.style.boxShadow = "none"; + viewportFrame.style.border = "none"; + viewportFrame.style.padding = "0"; + + const phone = document.createElement("div"); + phone.className = "phone-mockup"; + + const speaker = document.createElement("div"); + speaker.className = "phone-speaker"; + phone.appendChild(speaker); + + const camera = document.createElement("div"); + camera.className = "phone-camera"; + phone.appendChild(camera); + + const statusBar = document.createElement("div"); + statusBar.className = "phone-status-bar"; + statusBar.innerHTML = ` + 14:30 +
+ + +
+ `; + phone.appendChild(statusBar); + + const screen = document.createElement("div"); + screen.className = "phone-screen"; + + if (appetizeKey) { + const iframe = document.createElement("iframe"); + iframe.className = "phone-screen-iframe"; + iframe.src = `https://appetize.io/embed/${encodeURIComponent(appetizeKey)}?device=pixel4&scale=auto&autoplay=false&orientation=portrait&deviceColor=black`; + screen.appendChild(iframe); + } else { + screen.innerHTML = ` +
+ + + +
+
+

${projectName}

+

本專案為 Android 應用程式

+
+
+ 掃描此條碼以安裝至您的實體手機 + QR Code +
+
+ 下載 APK 安裝檔 + + `; + } + phone.appendChild(screen); + viewportFrame.appendChild(phone); + + if (appetizeKey) { + const helper = document.createElement("div"); + helper.style.marginTop = "1rem"; + helper.style.fontSize = "0.85rem"; + helper.style.color = "var(--text-muted)"; + helper.style.textAlign = "center"; + helper.style.maxWidth = "320px"; + helper.style.lineHeight = "1.5"; + helper.innerHTML = `⚠️ 因 Appetize.io 免費方案限制內嵌功能,若畫面顯示「未啟用內嵌」,請 點此在新分頁中打開模擬器。`; + viewportFrame.appendChild(helper); + } + } else { + // Web based game (HTML or External Link) + // Hide side controls and console log panel, render full viewport width + sidePanel.style.display = "none"; + layout.style.gridTemplateColumns = "1fr"; + statusWrapper.style.display = "none"; // Iframe handles itself + + viewportFrame.classList.add("iframe-mode"); + + const iframe = document.createElement("iframe"); + iframe.className = "iframe-viewport"; + iframe.allow = "autoplay; fullscreen; microphone; camera"; + + if (projectType === "html") { + iframe.src = `/static_projects/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}/index.html`; + } else if (projectType === "external") { + iframe.src = formatEmbedUrl(externalUrl); + } + + viewportFrame.appendChild(iframe); + } + } + + // Connect Pygame/OpenCV WebSocket server + function connectWebSocket() { + const loc = window.location; + const wsProto = loc.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${wsProto}//${loc.host}/ws/play/${encodeURIComponent(studentToken)}/${encodeURIComponent(projectName)}`; + + socket = new WebSocket(wsUrl); + socket.binaryType = "blob"; + + socket.onopen = () => { + statusWrapper.classList.remove("status-stopped"); + statusText.innerText = "已連線 (運作中)"; + appendConsole("[系統] 連線成功,學生專案已啟動。\n"); + setLoading(true, "專案執行中…", `最長執行時間約 ${Math.round(sessionTimeoutSec/60)} 分鐘`); + // Hide loading once first frame or after short delay + setTimeout(() => setLoading(false), 2500); + }; + + socket.onmessage = (event) => { + const gameScreen = document.getElementById("game-screen"); + if (event.data instanceof Blob) { + const url = URL.createObjectURL(event.data); + if (gameScreen) { + gameScreen.src = url; + } + if (oldUrl) { + URL.revokeObjectURL(oldUrl); + } + oldUrl = url; + setLoading(false); + } else { + try { + const data = JSON.parse(event.data); + if (data.type === "log") { + appendConsole(data.data); + } else if (data.type === "status") { + if (data.data === "stopped" || data.data === "timeout" || data.data === "error" || data.data === "busy") { + setStoppedStatus(data.data); + setLoading(false); + } else if (data.data === "running") { + statusWrapper.classList.remove("status-stopped"); + statusText.innerText = "已連線 (運作中)"; + } + } + } catch (e) { + console.error("JSON error:", e); + } + } + }; + + socket.onclose = () => { + setStoppedStatus(); + appendConsole("\n[系統] 與伺服器的通訊中斷。\n"); + }; + + socket.onerror = () => { + appendConsole(`\n[系統] 連線錯誤。\n`); + }; + } + + function setStoppedStatus(reason) { + statusWrapper.classList.add("status-stopped"); + const labels = { stopped: "已停止", timeout: "逾時結束", error: "錯誤", busy: "名額已滿" }; + statusText.innerText = labels[reason] || "已停止"; + stopWebcamStream(); + setLoading(false); + } + + function setLoading(show, title, sub) { + const el = document.getElementById("loading-overlay"); + if (!el) return; + el.style.display = show ? "flex" : "none"; + const t = document.getElementById("loading-text"); + const s = document.getElementById("loading-sub"); + if (title && t) t.textContent = title; + if (sub && s) s.textContent = sub; + } + + function showWebcamDenied(msg) { + const el = document.getElementById("webcam-denied"); + if (!el) return; + el.style.display = "block"; + const m = document.getElementById("webcam-denied-msg"); + if (m && msg) m.textContent = msg; + } + + function hideWebcamDenied() { + const el = document.getElementById("webcam-denied"); + if (el) el.style.display = "none"; + } + + function restartProject() { + appendConsole("\n[系統] 正在重新啟動專案...\n"); + if (socket) { + socket.close(); + } + setTimeout(connectWebSocket, 1000); + } + + function stopProjectElegant() { + appendConsole("\n[系統] 正在發送退出訊號並結束專案...\n"); + if (projectType === "python" && socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ + type: "pygame_quit" + })); + // Wait 1.5s before closing to let Pygame script do graceful cleanup + setTimeout(() => { + window.close(); + }, 1500); + } else { + window.close(); + } + } + + function appendConsole(text) { + if (consoleLogs) { + consoleLogs.textContent += text; + consoleLogs.scrollTop = consoleLogs.scrollHeight; + } + } + + function clearConsole() { + if (consoleLogs) consoleLogs.textContent = ""; + } + + // Setup mouse and keyboard input triggers for Python + function setupPythonControls(gameScreen) { + gameScreen.addEventListener("focus", () => { + isFocused = true; + focusPrompt.classList.add("active"); + focusPrompt.querySelector("span").innerText = "控制已啟用 (按 ESC 解除鎖定)"; + }); + + gameScreen.addEventListener("blur", () => { + isFocused = false; + focusPrompt.classList.remove("active"); + focusPrompt.querySelector("span").innerText = "點選畫面啟用鍵盤/滑鼠控制"; + }); + + gameScreen.addEventListener("click", (e) => { + gameScreen.focus(); + e.preventDefault(); + }); + + window.addEventListener("keydown", (e) => { + if (isFocused && ["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) { + e.preventDefault(); + } + }); + + window.addEventListener("keydown", (event) => { + if (!isFocused || !socket || socket.readyState !== WebSocket.OPEN) return; + + if (event.key === "Escape") { + gameScreen.blur(); + return; + } + + socket.send(JSON.stringify({ + type: "keydown", + key: event.key, + unicode: event.key.length === 1 ? event.key : "" + })); + + socket.send(JSON.stringify({ + type: "cv2key", + keyCode: event.key.charCodeAt(0) || -1 + })); + }); + + window.addEventListener("keyup", (event) => { + if (!isFocused || !socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ + type: "keyup", + key: event.key + })); + }); + + function sendMouseEvent(type, event) { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + + const rect = gameScreen.getBoundingClientRect(); + const scaleX = gameScreen.naturalWidth / rect.width; + const scaleY = gameScreen.naturalHeight / rect.height; + + const x = Math.round((event.clientX - rect.left) * scaleX); + const y = Math.round((event.clientY - rect.top) * scaleY); + + socket.send(JSON.stringify({ + type: type, + x: x, + y: y, + button: event.button, + btnLeft: (event.buttons & 1) === 1, + btnMiddle: (event.buttons & 4) === 4, + btnRight: (event.buttons & 2) === 2 + })); + } + + gameScreen.addEventListener("mousemove", (e) => sendMouseEvent("mousemove", e)); + gameScreen.addEventListener("mousedown", (e) => sendMouseEvent("mousedown", e)); + gameScreen.addEventListener("mouseup", (e) => sendMouseEvent("mouseup", e)); + gameScreen.addEventListener("dragstart", (e) => e.preventDefault()); + } + + // ----------------- Webcam Capture ----------------- + if (btnToggleWebcam) { + btnToggleWebcam.addEventListener("click", () => { + if (isWebcamStreaming) { + stopWebcamStream(); + } else { + startWebcamStream(); + } + }); + } + + async function startWebcamStream() { + try { + hideWebcamDenied(); + appendConsole("[系統] 正在取得本機相機鏡頭權限...\n"); + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error("此瀏覽器不支援 getUserMedia,請改用 Chrome/Edge,並以 HTTPS 或 localhost 開啟。"); + } + webcamStream = await navigator.mediaDevices.getUserMedia({ + video: { + width: { ideal: 480, max: 640 }, + height: { ideal: 360, max: 480 }, + frameRate: { ideal: 12, max: 15 } + }, + audio: false + }); + webcamVideo.srcObject = webcamStream; + // Match canvas to lower resolution for upload bandwidth + webcamCanvas.width = 480; + webcamCanvas.height = 360; + btnToggleWebcam.classList.add("active"); + btnToggleWebcam.innerText = "關閉視訊鏡頭"; + isWebcamStreaming = true; + appendConsole("[系統] Webcam 開啟成功,開始串流影格(480p)。\n"); + webcamInterval = setInterval(captureWebcamFrame, 80); + } catch (err) { + console.error("Camera error:", err); + const hint = (err && err.name === "NotAllowedError") + ? "您已拒絕攝影機權限。請點網址列旁的相機圖示允許後再試。若使用遠端網址,需 HTTPS。" + : `開啟鏡頭失敗: ${err.message || err}。請確認使用 HTTPS 或 localhost,並允許瀏覽器存取攝影機。系統將使用虛擬測試影像。`; + appendConsole(`[系統] ${hint}\n`); + showWebcamDenied(hint); + stopWebcamStream(); + } + } + + function stopWebcamStream() { + if (webcamInterval) { + clearInterval(webcamInterval); + webcamInterval = null; + } + if (webcamStream) { + webcamStream.getTracks().forEach(track => track.stop()); + webcamStream = null; + } + webcamVideo.srcObject = null; + if (btnToggleWebcam) { + btnToggleWebcam.classList.remove("active"); + btnToggleWebcam.innerText = "啟用 Webcam 影像串流"; + } + isWebcamStreaming = false; + } + + function captureWebcamFrame() { + if (!socket || socket.readyState !== WebSocket.OPEN) return; + if (webcamVideo.readyState === webcamVideo.HAVE_ENOUGH_DATA) { + webcamCtx.drawImage(webcamVideo, 0, 0, webcamCanvas.width, webcamCanvas.height); + webcamCanvas.toBlob((blob) => { + if (blob && socket.readyState === WebSocket.OPEN) { + socket.send(blob); + } + }, "image/jpeg", 0.5); + } + } + + window.onload = initViewport; + window.onbeforeunload = () => { + stopWebcamStream(); + if (socket) socket.close(); + }; + + // Setup Terminal Input event listener + const terminalInputField = document.getElementById("terminal-input-field"); + if (terminalInputField) { + terminalInputField.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + const text = terminalInputField.value; + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ + type: "terminal_input", + data: text + })); + appendConsole(`> ${text}\n`); + terminalInputField.value = ""; + } else { + appendConsole("\n[系統] 錯誤: 無法傳送輸入,連線已中斷。\n"); + } + } + }); + } + + // Focus input field when clicking console logs area + const consoleBox = document.getElementById("console-logs"); + if (consoleBox) { + consoleBox.addEventListener("click", () => { + if (terminalInputField) terminalInputField.focus(); + }); + } + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 01390dda77d00559f2456630c8fa384743b224b9..d1f4a905829029e3dc258d03537cef8bf40faf44 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,602 +6,7 @@ 得能教育專題展示場 - Telearn Project Showcase - + @@ -704,318 +109,11 @@ + diff --git a/templates/player.html b/templates/player.html index d9d5de7ec93df6749eb25bc9e5ee70634b866393..25ea4cf6a18b0b82fe79ef60c6d667ccf11cfabd 100644 --- a/templates/player.html +++ b/templates/player.html @@ -6,701 +6,7 @@ Telearn Arcade Player - {{ project_name }} - + @@ -731,6 +37,15 @@
+ +
@@ -826,436 +141,21 @@ +