WebCoding.md

WebCoding.md — RenUniversal Web 後端

這是 AI Coding Agent 讀的啟動規格檔(bootstrap spec)。開始動任何 Python / HTML / JS 程式碼前,先完整讀完本檔。本檔的決策是鎖定的,不得因「更好的想法」而擅自更動;若你認為某決策有誤,先停下來提出質疑,不要直接改。


0. 你是誰、你在做什麼

你是本專案 Web 後端的實作 agent。RenUniversal 的 Python 後端(backend/)是讓桌機 / 任意平台零安裝執行姿態監控的主要路徑——使用者不需要 iPhone,只需要 make run 即可取得完整功能。

本端的核心職責

  1. backend/stream_server.py(Flask):接收瀏覽器與手機的 HTTP 請求,提供即時 MJPEG 影像串流、SSE 狀態推送,以及所有 REST API。
  2. backend/core/pipeline.pyAgentPipeline):30 FPS 驅動相機擷取 → MediaPipe 推理 → 技能/事件評估 → SharedState 更新。整個流程跑在獨立的背景 Thread,與 Flask 的請求線程完全分離。
  3. backend/core/action_engine.pyActionEngine):掃描 skills/ 目錄,動態載入每個技能包,每幀呼叫 evaluate_all()
  4. backend/core/event_engine.pyEventEngine):掃描 events/ 目錄,使用 safe_eval.py 的 AST 白名單布林求值器,把技能狀態組合成複合事件。
  5. web/(Jinja2 模板 + Tailwind CDN):所有前端頁面。

不要動 iOS Swift 程式碼RenUniversal/RenUniversal/)。除非任務明確指定,你只操作 backend/web/skills/events/ 目錄。


1. 開發方法

工作循環固定為:

  1. 讀規格 — 讀本檔對應章節,確認要做的功能屬於哪個 phase、是否在當前範圍內。
  2. 確認契約 — 你動的模組必須符合第 4 節的型別契約(Pydantic schema)。Schema 優先於實作;不得為了實作方便偷改 Pydantic 模型。
  3. 最小切片 — 一次只實作一個可驗證的最小功能,能跑、能測。
  4. 驗證 — 每個功能都要有對應的手動驗證方式(第 6 節驗收標準)。
  5. 停在 gate — 到達 phase gate 時停下來回報,不要自動往下一個 phase 衝。

若某功能不在當前 phase 範圍,就算你覺得「順手加一下」很合理,也不准建。


2. 技術選型(已鎖定)

選型 為何
Web 框架 Flask 3.x(threaded=True 同步 API 與 MJPEG 串流用 Thread 隔離效果最佳;FastAPI 的 async 對本專案的 blocking I/O(cv2、MediaPipe)沒有優勢(見 §9.1)
視覺推理 MediaPipe Python SDK(mediapipe>=0.10.0 跨平台、Face Mesh 468 點 + Pose 33 點同一個推理迴圈
資料驗證 Pydantic v2(BaseModelField 所有 API request/response 有型別保障;safe_eval.py 不需要 Pydantic 但整體生態統一
影像處理 OpenCV 4.x MJPEG encode、臉部馬賽克、帧疊加、相機讀取
日誌 Loguru(from loguru import logger 比 stdlib logging 更少樣板、彩色輸出、結構化
前端 Jinja2 模板 + Tailwind CSS CDN(tailwind.js,內建於 web/ 零前端工具鏈,修一個 HTML 存檔即生效
內網穿透 localhost.run(SSH tunnel,--enable-tunnel 零安裝,ssh -R 80:localhost:8080 [email protected] 一行完成
Python 版本 3.9 ≤ x ≤ 3.12(不支援 3.13+ MediaPipe wheel 目前只到 3.12(見 §10 坑 1)

不要引入任何非 requirements.txt 的 Python 套件,除非使用者明確要求並討論過相容性。


3. 系統架構設計原則

3.1 Threading 模型

主執行緒 (Flask + Werkzeug)
  ├── GET /          → render_template(monitor.html)
  ├── GET /live      → generate_mjpeg_stream() [blocking generator]
  ├── GET /api/status/stream  → SSE generator
  ├── POST /api/skills/create → create_skill()
  └── ... 其餘 REST 端點

背景執行緒 (daemon)
  └── capture_loop()
        └── AgentPipeline.run_cycle()   ← 唯一讀寫相機與 SharedState 的地方
              ├── VideoCaptureSkill.get_frame()
              ├── MediaPipe FaceLandmarker + PoseLandmarker
              ├── ActionEngine.evaluate_all()
              ├── EventEngine.evaluate_all()
              └── SharedState.update_frame() / update_status()

所有 SharedState 的讀寫都有 threading.Lock。Flask 請求線程讀 state 時走 state.get_status()(取 lock 後回傳副本),不直接操作內部欄位。違反此原則會導致競態條件(race condition),在高 FPS 下難以復現、難以除錯。

3.2 Plugin 架構(Skills & Events)

skills/                        events/
  slouch/                        bad_posture/
    config.json                    config.json   ← rule_syntax: "lean OR turn OR tilt"
  lean/
    config.json
  lean_custom/
    config.json
    logic.py   ← 有 logic.py → 載入 ActionDetector class

3.3 SharedState 是唯一真相來源

# backend/core/state.py  (已實作)
state.get_status()          → 讀狀態(thread-safe,回傳 DetectorStatus 副本)
state.update_status(...)    → 寫狀態(thread-safe)
state.get_frame()           → 讀最新標注影格(thread-safe)
state.update_frame(frame)   → 寫最新影格(thread-safe)
state.save_prefs(dict)      → 寫使用者偏好(thread-safe,同步持久化到 preferences.json)
state.update_network_frame(frame, source_id)  → 接收手機/網路副鏡頭影格

禁止在 capture_loop() 以外的地方直接呼叫 pipeline.run_cycle(),也禁止直接讀取 pipeline 的成員變數作為狀態來源——全部透過 state 取得。

3.4 MJPEG 串流與 SSE 的不同用途

路由 格式 用途
/live/video_feed MJPEG (multipart/x-mixed-replace) 實時相機畫面顯示於 <img> 標籤
/api/status/stream SSE (text/event-stream) 低頻率(10 Hz)推送 JSON 狀態,驅動 Web UI 的數值更新
/status JSON REST 單次查詢完整狀態(含 prefslocal_ip

不要讓 MJPEG 串流做狀態同步,也不要讓 SSE 夾帶影格資料。兩條路徑各司其職,混合會破壞瀏覽器端的解碼效率。


4. 介面契約(鎖定,實作必須符合)

4.1 DetectorStatus(backend/core/schema.py

class DetectorStatus(BaseModel):
    ratio: float                   # 鼻下巴比率(百分比)
    nose_chin_ratio: float         # 原始比率
    is_bad_posture: bool
    down_count: int
    fps: int
    connected: bool
    calibrating: bool
    calibration_progress: int      # 0–100
    is_turning: bool
    baseline_eye_dist: float
    threshold: float               # 百分比單位(UI 顯示)
    yaw_tolerance: float           # 百分比單位(UI 顯示)
    sway_threshold: float
    lean_threshold: float
    is_active: bool
    latency_ms: int
    is_swaying: bool
    is_leaning_forward: bool
    sway_ratio: float
    lean_ratio: float
    camera_source: Union[str, List[str]]
    public_url: Optional[str]
    flip_enabled: bool
    privacy_mode: bool
    active_skills: dict            # {skill_name: bool}
    active_events: dict            # {event_name: bool}
    metrics: dict                  # {skill_name: float}(比率數值,供 UI 顯示)
    trigger_counts: dict           # {name: int}

不得移除任何欄位。前端 JS(web/js/monitor.js、各 HTML 內的 <script> 區塊)直接解構這個 JSON,移除欄位等同在前端撒謷。新增欄位必須給預設值,確保舊版前端不會因找不到 key 而 crash。

4.2 SettingsUpdate 與 ControlCommand

class SettingsUpdate(BaseModel):
    threshold: Optional[float]
    yaw_tolerance: Optional[float]
    sway_threshold: Optional[float]
    lean_threshold: Optional[float]
    camera_source: Optional[Union[str, List[str]]]
    flip_enabled: Optional[bool]
    privacy_mode: Optional[bool]

class ControlCommand(BaseModel):
    active: bool

SettingsUpdate 的所有欄位都是 Optional,伺服器只更新有出現的欄位。不要把非 Optional 欄位加進來,會讓只想更新一個設定的 API 呼叫變成必填整包。

4.3 Skill config.json 結構

{
    "name": "slouch",
    "description": "人類可讀說明",
    "enabled": true,
    "requirements": {
        "face_mesh": true,
        "pose": false
    },
    "rule_syntax": "f1,f152 >< num=20%",
    "default_preferences": {
        "slouch_threshold": 0.2
    }
}

rule_syntaxGenericActionDetector 讀取的唯一判定來源。舊版有一個 rules: [] list 欄位(已棄用),程式碼有向後相容處理(若 rule_syntax 為空則從 rules[0] fallback),但新建技能一律用 rule_syntax 欄位,不要再寫 rules

4.4 Event config.json 結構

{
    "name": "bad_posture",
    "description": "人類可讀說明",
    "enabled": true,
    "rule_syntax": "lean OR turn OR tilt",
    "rules": ["lean OR turn OR tilt"]
}

rule_syntaxEventEngine 讀取的布林運算式,由 safe_eval.py 求值。合法語法:技能名稱、ANDORNOT!)、括號。禁止在 event rule_syntax 裡寫點位距離語法f1,f152 >< ...),那是 skill 層的格式;event 層只能引用已存在的 skill 名稱。


5. 目錄結構

backend/
  stream_server.py         # Flask App 進入點:所有路由、auth、MJPEG、SSE、skill/event CRUD API
  core/
    pipeline.py            # AgentPipeline:30 FPS 主驅動迴圈(背景 Thread)
    action_engine.py       # ActionEngine:掃描 skills/,熱載入,evaluate_all()
    event_engine.py        # EventEngine:掃描 events/,safe_eval 布林求值
    generic_skill_detector.py  # GenericActionDetector:解析 rule_syntax 的無程式碼偵測器
    state.py               # SharedState:thread-safe 狀態中心 + 偏好持久化
    schema.py              # Pydantic 模型:DetectorStatus / SettingsUpdate / ControlCommand
    safe_eval.py           # AST 白名單布林求值器(取代 eval(),防 RCE)
    landmarks.py           # get_landmark_coord():f<n>/p<n> 點位 ID → 像素座標
    skill_template.py      # 進階 logic.py 可繼承的基底,提供預計算特徵字串
  models/
    face_landmarker.task   # MediaPipe Face Landmarker 模型(不進版控,首次 setup 下載)
    pose_landmarker_lite.task  # MediaPipe Pose Landmarker 模型(同上)
  services/
    calibration_wizard/    # CalibrationWizardSkill:30 幀採樣 + 基準計算
    video_capture/         # VideoCaptureSkill:相機讀取(本地 + 網路手機串流)
  requirements.txt

web/
  base.html                # 所有頁面繼承的 Jinja2 base(nav、頭部、Tailwind)
  monitor.html             # 主監控頁(即時 MJPEG + SSE 狀態儀表板)
  camera.html              # 相機設定(切換鏡頭、隱私模式、翻轉)
  skills.html              # 技能管理(新增、編輯、啟用/停用、刪除)
  events.html              # 事件管理(複合規則)
  apps_launcher.html       # 網頁版 App 啟動器
  mpmap.html               # MediaPipe 地標點選工具(500+ 點位互動預覽)
  MobileCamera.html        # 手機網路副鏡頭頁(UserMedia → POST /upload_frame)
  js/
    monitor.js             # 監控頁的 SSE 監聽 + DOM 更新邏輯
  tailwind.js              # Tailwind CSS CDN 本地快取(offline 可用)
  apps/
    Game.html              # 內建遊戲 App
    Statistics.html        # 統計工具 App

skills/                    # 技能插件目錄(每個子目錄是一個技能包)
  slouch/config.json
  lean/config.json
  turn/config.json
  tilt/config.json
  <custom>/config.json     # UI 建立的技能
  <custom>/logic.py        # 進階技能(可選)

events/                    # 複合事件目錄
  bad_posture/config.json
  ctar_tuck/config.json
  <custom>/config.json

preferences.json           # 使用者偏好(gitignored,runtime 自動建立)
start.py                   # 一鍵啟動包裝腳本(自動偵測 venv python)
Makefile                   # make setup / make run / make stop / make doctor

6. 建構順序與驗收標準

依序做,每步做完自我驗證再往下:

  1. 環境建置make setup(或 python3 -m venv .venv && pip install -r requirements.txt)能跑完無 error。MediaPipe models 能從 make setup 下載到 backend/models/驗收make run 後能在 http://127.0.0.1:8080 看到監控頁。
  2. 相機串流 — MJPEG stream 在 /live 端點順暢,不卡頓、無明顯延遲。驗收:瀏覽器開 monitor.html,能看到即時相機畫面與 FPS 顯示 ≥ 25。
  3. 校準流程/recalibrate POST 後,calibrating: true → 幾秒後 calibrating: false, calibration_progress: 100驗收:網頁校準按鈕後狀態列顯示「校準完成」。
  4. 技能評估 — 內建 slouch/lean/turn/tilt 四個技能,active_skills/status 的 JSON 裡出現且能正確觸發與解除。驗收:低頭時 slouch: true,回正後 false;在 skills.html 能看到四個技能的即時狀態切換。
  5. 事件評估bad_posture(OR)與 ctar_tuck(slouch AND NOT turn AND NOT lean)正確計算。驗收lean 單獨觸發時 bad_posture: trueslouch 亮、turn/lean 都滅時 ctar_tuck: true
  6. 技能 CRUD APIPOST /api/skills/create/toggle/delete 正確操作 skills/ 目錄,並在回應後立即觸發 load_action_skills() 熱載入。驗收:在 skills.html 建立一個新技能 → 即時出現在監控頁的觸發狀態列表,不需要重啟。
  7. 手機副鏡頭 — 手機開啟 /mobile,瀏覽器要求相機權限後持續 POST /upload_frame,主機端 pipeline 切換 camera_source=phone 後能正確讀取手機影格做推理。驗收:在不同 Wi-Fi 設備上(需 LAN 可達)能看到主機監控頁的鏡頭切換到手機畫面。
  8. 隱私模式privacy_mode: true 時,MJPEG 串流中的人臉區域被高斯模糊(包含 150% 外擴邊界),但 /status 仍然正確回報姿態觸發狀態(判定不受模糊影響)。驗收:在 camera.html 切換隱私模式,MJPEG 串流即時反映模糊/不模糊。
  9. Basic Authmake run -- --host 0.0.0.0 時,若未提供 --auth,伺服器應自動產生帳密並顯示在 log,未認證的請求回 401。驗收:無 --auth 啟動,瀏覽器直接輸入 IP 出現 Basic Auth 對話框;帶 --auth user:pass 啟動則直接放行。
  10. --cli 無頭模式python backend/stream_server.py --cli 能跑起來,觸發狀態變化時自動 print JSON 到 stdout。驗收python backend/stream_server.py --cli | head -20 在低頭後看到含 "triggered" 的 JSON 輸出。

7. 程式碼規範


8. 反腐化紅線(違反即回報,不得自作主張)

  1. 在 Flask 路由函數裡直接讀寫 pipeline 的 private 成員pipeline.is_calibratedpipeline.baseline_eye_distance 等)取得狀態——這些欄位只有 capture_loop() Thread 持有,在路由 Thread 讀它們是未加 lock 的競態讀取。
  2. 在 event rule_syntax 裡寫點位距離語法(如 f1,f152 >< num=20%)——EventEngine 不會解析這個格式,safe_eval_bool() 會拒絕任何非 True/False/AND/OR/NOT/括號 的內容。
  3. 使用 eval()exec() 求值任何來自使用者輸入的字串(包含 rule_syntax、技能名稱、設定值)。
  4. capture_loop() 背景 Thread 以外呼叫 pipeline.run_cycle()
  5. 在沒有 state.frame_lock 保護的情況下操作 state.frame
  6. 引入任何 requirements.txt 以外的 Python 套件,不含明確討論。
  7. 直接把 preferences.json 路徑硬寫成絕對路徑SharedState 預設 prefs_path="preferences.json"(相對 CWD),其他地方同理,保持相對路徑。

9. 歷史決策紀錄

9.1 為何選 Flask 而非 FastAPI(定案)

MediaPipe 的 Python API 是同步 blocking call,每幀推理需要 10–30 ms。若用 FastAPI(asyncio 架構),就不得不把推理丟到 thread pool executor(asyncio.run_in_executor),再把結果送回 async 上下文——等同把 Flask 的 Thread 模型包一層 async 皮,複雜度上升而沒有任何吞吐量增益。本專案同時線上的連線數極少(1–3 人),Flask 的 threaded=True 已完全足夠。FastAPI 的 Pydantic 整合優勢在這裡可以直接在 Flask 裡也用 Pydantic,沒有任何東西非 FastAPI 不可。

9.2 capture_loop() 為何要跑在獨立 Thread 而非 Flask 請求週期內

MJPEG 的 generator 函數(generate_mjpeg_stream())是在 Flask 請求的線程裡執行的,它直接從 state.get_frame() 讀已處理好的影格,每幀只需 30 ms 的等待(time.sleep(0.033))。若改成「request 觸發 → 同步推理 → 回傳」,一個 MJPEG 連線會佔用一個 Thread 做全部的 MediaPipe 推理,兩個瀏覽器標籤同時開 /live 就要跑兩份推理,CPU 翻倍;現在的設計無論幾個瀏覽器標籤,推理只跑一份。

9.3 safe_eval.py:為何不用 eval() 求值事件規則(資安決策)

事件規則(lean OR turn AND NOT tilt)必須支援使用者透過 Web UI 或 API 自由輸入。若用 eval() 直接執行,攻擊者(或使用者自己不小心)可以輸入 __import__('os').system('rm -rf /') 之類的 payload,在伺服器上執行任意指令(RCE)。

safe_eval.py 的做法:

  1. 先把所有技能名稱(leanturn...)替換為 TrueFalse 字面值。
  2. ast.parse(expr, mode="eval") 產生 AST。
  3. 遍歷 AST,白名單只允許 BoolOp / UnaryOp / And / Or / Not / Constant / Load,任何其他 AST 節點(CallAttributeNameBinOp 等)一律 raise ValueError,呼叫端捕捉後回傳 False(安全失敗)。

這是本專案最重要的資安邊界,不得繞過。

9.4 網路來源 TTL 驅逐與 16 來源上限(資安決策)

/upload_frame 接受含 ?source=xxx 的手機副鏡頭推送,state.update_network_frame(frame, source_id) 會把 source_id 作為 key 存在 network_frames dict 裡。若不加限制,攻擊者可以用無限多個 source_id 灌滿記憶體(DoS)。

防護機制(已實作於 state.py):

9.5 Basic Auth 自動產生邏輯(資安決策)

當使用者以 --host 0.0.0.0--enable-tunnel 讓服務對外可達,卻忘記設定 --auth,裸奔的服務會讓任何人都能看到相機畫面。

因此:若偵測到 host != '127.0.0.1'enable_tunnel 為 true,且沒有明確的 --authstream_server.py 會用 secrets.choice() 生成 12 字元隨機密碼(大小寫字母 + 數字),強制啟用 Basic Auth,並把帳密顯示在 log 裡。使用者看到 log 就能取得存取方式,不會被鎖死。

不要移除這個自動保護機制,即使某些使用者覺得「麻煩」。

9.6 隱私打碼的 150% 外擴邊界(使用者體驗決策)

臉部馬賽克若只蓋臉部追蹤點的精確邊界框,會漏掉頭髮邊緣、耳朵、脖子,反而顯得不完整又詭異。測試後定案以 face_bbox * 1.5 為打碼區域。代價是偶爾會蓋到背景區域,但使用者普遍認為「過度遮蓋比遮不住好」。

實作位於 backend/core/pipeline.py 的 privacy blur 區段,使用 cv2.GaussianBlur()不要改成精確邊界框,那是退化,不是優化。

9.7 --cli 無頭模式設計

臨床場景有時不需要 Web 介面,只需要「觸發狀態改變時輸出 JSON 到 stdout,讓其他程式(HMI 軟體、串列埠橋接器)用 pipe 讀取」。--cli flag 讓 capture_loop() 跑在主線程,不啟動 Flask,觸發狀態集合(current_triggered)變化時才輸出一行 JSON。

這個 flag 的使用情境是整合進其他系統而非互動式使用,不需要為它加複雜的配置。


10. 已知坑與變通方案

坑 1:MediaPipe wheel 不支援 Python 3.13+

MediaPipe 官方 wheel 截至目前(2026 年 7 月)最高支援 Python 3.12。Makefile 的 Python 版本掃描已把 3.13+ 排除在候選清單外(只掃 3.9–3.12)。若使用者的系統預設 python3 是 3.13(macOS Sequoia 預設),make setup 會找到 Homebrew 安裝的 3.12 並使用它。

若使用者環境完全沒有 3.9–3.12,make doctor 會顯示錯誤並提示安裝 Homebrew [email protected]不要嘗試讓 MediaPipe 在 3.13 上跑,沒有 wheel、從源碼編譯在 Apple Silicon 上很容易出問題。

坑 2:preferences.json 的數值有雙單位(% 和 ratio)

DetectorStatus(前端顯示)使用百分比單位(threshold: 20.0 代表 20%),但 preferences.json(持久化)使用0.0–1.0 的小數 ratiothreshold_ratio: 0.20)。SharedState.save_prefs() 有雙向轉換邏輯,Flask 路由傳入的 SettingsUpdate.threshold 是百分比,存到 prefs 時自動除以 100。

典型的錯誤是在路由或 pipeline 裡自己做一次 / 100.0,導致 threshold 變成原來的 1/100。所有單位轉換都在 save_prefs() 裡統一處理,其他地方一律傳百分比(與 DetectorStatus 一致)。

坑 3:裸 request.get_json() 在空 body 時返回 None 導致 500

Flask 的 request.get_json() 在以下情況回傳 None

若後面直接 data['key'] 就會 TypeError: 'NoneType' object is not subscriptable,Flask 把它當 500 回給前端。正確做法是所有寫入 API 統一用已實作的 get_json_or_400() helper:

def get_json_or_400():
    data = request.get_json(silent=True)
    if not isinstance(data, dict):
        return None, (jsonify({"error": "Request body must be a valid JSON object"}), 400)
    return data, None

坑 4:skills/ 路徑是相對 CWD,不是相對 stream_server.py

ActionEngine(skills_dir="skills")EventEngine(events_dir="events") 使用相對路徑,解析基準是啟動 process 時的工作目錄(CWD)。

正確啟動方式永遠是在 project rootpython start.pymake runpython backend/stream_server.py),不要 cd backend/ 後啟動。若需要修正,PROJECT_ROOTstream_server.py 最上方已定義,技能路徑應改用 os.path.join(PROJECT_ROOT, 'skills') 的絕對路徑形式。

坑 5:--enable-tunnel(localhost.run)連線是公網可達的

--enable-tunnel 透過 SSH Reverse Tunnel 把 8080 port 曝露到 *.lhr.life 的公開網址。這意味著任何人都可以連這個網址,不只是同一個 Wi-Fi 上的人。

這個 flag 的設計是短暫使用(例如:跨 Wi-Fi 的臨床測試場景),不是長期開著。Auto Basic Auth 機制(§9.5)保護了這個端點,但連線本身是未加密的 HTTP(8080),Basic Auth 帳密在 LAN 以外傳輸是明文。若需要加密,改用 HTTPS(8443,self-signed)。


11. 啟動與部署快速參考

# 一般使用(僅本機)
make run
# 或
python start.py

# 手機副鏡頭(區網可達)
python backend/stream_server.py --host 0.0.0.0 --port 8080
# 系統自動產生 Basic Auth 帳密,看 log

# 自訂 Basic Auth
python backend/stream_server.py --host 0.0.0.0 --auth admin:mysecretpass

# 跨 Wi-Fi 存取(公網 tunnel,謹慎使用)
python backend/stream_server.py --enable-tunnel

# 關閉預設隱私模式(展示、測試用途)
python backend/stream_server.py --disable-privacy

# 無頭 CLI 模式(串接外部程式)
python backend/stream_server.py --cli | jq '.'

# 環境診斷
make doctor

HTTPS 雙埠:伺服器啟動後同時在 8080(HTTP)和 8443(HTTPS,self-signed)監聽。手機瀏覽器的 getUserMedia(相機 API)需要 Secure Context,必須走 8443。首次連線需要在手機上信任自簽憑證。


12. 待規劃(尚未排入)

以下功能使用者曾提及但尚未進入開發,不要提前做:

本檔撰於 2026 年 7 月,版本 v1.3.4。