Select Page
用 Telegram 遠端操控 Claude Code:完整踩坑教學

用 Telegram 遠端操控 Claude Code:完整踩坑教學

從 MCP failed 到 connected,一步步解決 Windows 上的 Channels 整合問題

April 2026·Claude Code v2.1.109·適用平台:Windows

目錄

  1. 前言:Claude Code Channels 是什麼
  2. 前置需求 claude.ai 登入、Bot 設定
  3. 安裝與啟動 plugin install、–channels 旗標
  4. 常見錯誤與解法 Auth 衝突、MCP failed、Bun
  5. 確認成功運作
  6. 已知限制與現況

前言:Claude Code Channels 是什麼

Claude Code Channels 是 Anthropic 在 2026 年 3 月推出的實驗性功能,讓你可以透過 Telegram(或 Discord)把訊息推送進正在執行的 Claude Code session。

實際的應用場景:你在外出時用手機傳一句「跑一下測試,告訴我有沒有失敗」,你的電腦上的 Claude Code 就會收到、執行,然後把結果回傳到 Telegram。

注意Channels 目前仍是 Research Preview(實驗性功能),Windows 上有已知的穩定性問題。本文記錄的是截至 v2.1.109 的實際狀況。

前置需求

  • Claude Code 已安裝且版本 ≥ v2.1.109
    用 npm update -g @anthropic-ai/claude-code 更新
  • 使用 claude.ai 帳號登入(Pro 或 Max)
    Channels 不支援純 API Key 認證,必須用 claude.ai 帳號
  • 在 Telegram 建立 Bot(透過 @BotFather)
    取得形如 123456789:AAHfiqks... 的 Bot Token
  • 安裝 Bun 執行環境(Windows 必須)
    Telegram plugin 使用 Bun 執行,這是最常被忽略的步驟

安裝 Bun(Windows 必做)

這是 Windows 上最容易卡關的地方。Telegram plugin 的 MCP server 以 Bun 執行,沒有 Bun 就會直接顯示 MCP · ✗ failed

在 PowerShell 中執行:

powershell -c "irm bun.sh/install.ps1 | iex"

安裝完後關閉並重新開啟 PowerShell(讓 PATH 生效),確認安裝成功:

bun --version
# 應該輸出版本號,例如:1.x.x

解決 Auth 衝突

若啟動時看到這個警告:

⚠ Auth conflict: Both a token (claude.ai) and an API key
  (/login managed key) are set.

這代表同時存在兩種認證方式。Channels 功能只支援 claude.ai 登入,需要清除衝突:

claude /logout

登出後重新用 claude.ai 帳號登入,確認登入後只有一種認證方式存在。

安裝與設定 Telegram Plugin

啟動 Claude Code,在對話中執行以下指令:

/plugin install telegram@claude-plugins-official

安裝完成後設定 Bot Token:

/telegram:configure <你的 Bot Token>

設定存取權限(建議啟動後先用 pairing 模式,再切換成 allowlist):

/telegram:access

啟動 Channels

claude --channels plugin:telegram@claude-plugins-official

啟動後會看到:

Listening for channel messages from: plugin:telegram@claude-plugins-official
Experimental · inbound messages will be pushed into this session

驗證是否成功

在 Claude Code 裡執行 /plugin list,確認顯示:

telegram Plugin · claude-plugins-official · ✔ enabled
└ telegram MCP · ✔ connected       ← 這行是關鍵!

成功標誌看到 telegram MCP · ✔ connected 就代表設定完成,可以去 Telegram 傳訊息測試了。

常見錯誤排查

telegram MCP · ✗ failed

原因:Bun 未安裝或不在 PATH 中。
解法:安裝 Bun(irm bun.sh/install.ps1 | iex),重新開啟 PowerShell 後再試。

Auth conflict 警告

原因:同時存在 claude.ai token 和 API key。
解法:執行 claude /logout 清除衝突,選擇一種登入方式。Channels 需要 claude.ai 登入。

傳訊息沒有回應(MCP 顯示 connected)

原因:你的 Telegram 使用者 ID 不在 allowlist,或是 pairing 尚未完成。
解法:執行 /telegram:access 確認存取設定,或重新執行 pairing 流程。

已知限制(截至 v2.1.109)

這個功能仍在快速迭代,以下是目前的已知狀況:

  • Channels 只在 session 開啟時運作,關掉 Claude Code 就收不到訊息
  • 如需長時間監聽,建議搭配 tmux 或讓終端機持續開著
  • Windows 上的穩定性比 macOS/Linux 差,偶爾需要重啟
  • 目前是 Research Preview,API 隨時可能變更

參考資訊

https://code.claude.com/docs/zh-TW/channels

第三方 telegram

Gemma 4 越獄版完整解析:解鎖 AI 限制、釋放 31B 模型最大能力!

在 AI 模型快速演進的時代,由 Google 推出的 Gemma 系列模型 一直備受關注,但對許多進階開發者來說,官方版本的限制(安全策略、回應過濾)往往成為發揮模型潛力的瓶頸,有了越獄版本,模型就再也不會回答你說「這個問題我不能回答了」。

這篇文章將帶你深入了解——
👉 越獄版本 Gemma 4(Gemma-4-31B-JANG_4M-CRACK)是什麼?
👉 它如何突破限制?是否值得使用?
👉 在本地 AI 架構(如 Ollama)中的實戰價值

🧠 什麼是 Gemma 4 越獄版?

所謂「越獄版」或「Crack 版」,指的是:

👉 移除或弱化模型原本的安全限制(alignment / guardrails)

這個版本來自 Hugging Face 上的開源模型:
👉 Hugging Face 社群釋出的
Gemma-4-31B-JANG_4M-CRACK

並可透過:
👉 Ollama 直接部署本地推論


⚙️ 越獄版 vs 官方版差異

項目官方 Gemma 4越獄版 Gemma 4
安全限制高(嚴格過濾)低(大幅放寬)
回答自由度非常高
敏感內容處理拒答或模糊直接回答
適合用途商業應用研究 / 測試 / 私有 AI
風險

💣 為什麼有人需要「越獄模型」?

對你這種在做 AI Agent / 本地 LLM 架構的人來說,關鍵原因只有一個:

👉「控制權」

1️⃣ 做 AI Agent(LangChain / AutoGen)

  • 官方模型:常被拒答
  • 越獄模型:可完整執行任務

👉 尤其是:

  • 自動寫程式
  • 資料抓取
  • 系統操作

🧪 越獄版的核心改動(技術面)

這類模型通常做了以下處理:

🔹 1. 去除 RLHF 對齊限制

  • 移除「拒答機制」
  • 降低安全分類器權重

🔹 2. 訓練資料調整(JANG_4M)

  • 加入大量 unrestricted instruction data
  • 強化「服從 prompt」能力

🔹 3. Prompt Injection 抗性降低

👉 反而變成「完全服從」


🚀 在 Ollama 中部署

你可以直接用:

ollama run SiliconBasedWorld/Gemma-4-31B-JANG_4M-CRACK

⚠️ 建議設定(for 128G)

export OLLAMA_NUM_PARALLEL=4
export OLLAMA_MAX_LOADED_MODELS=3
export OLLAMA_KEEP_ALIVE=-1
export OLLAMA_FLASH_ATTENTION=1

Hermes Agent 完整實測:自我進化 AI Agent 架構,全面取代 OpenClaw! – 雨

Claude Code 教學:最完整的免費互動式學習網站,從零到插件開發一次學會

Claude Code 教學:最完整的免費互動式學習網站,從零到插件開發一次學會

在 AI 開發工具快速演進的時代,Claude Code 正逐漸成為開發者與 AI Agent 架構中的核心工具。然而,多數人卡在同一個問題:

👉「文件看懂了,但就是不會用」

如果你也遇到這個問題,那麼這個教學網站會是目前最有效的解法之一👇

👉 Learn Claude Code 教學平台


🎯 為什麼這個網站值得學?

這個網站最大的核心理念只有一句話:

「Learn Claude Code by doing, not reading」

也就是——用做的學,而不是用看的學

它提供:

  • ✅ 完整 11 個學習模組(從 beginner → advanced)
  • ✅ 瀏覽器內建終端機(不用安裝)
  • ✅ 可直接生成設定檔(CLAUDE.md / hooks / plugins)
  • ✅ 每章節都有測驗+錯誤解析

👉 重點:學完可以直接上 production,不只是看懂概念


🧠 教學架構:真正「由淺入深」的學習路線

這個平台的設計非常接近實務開發流程:

🔰 初學者階段(建立基礎)

  1. Slash Commands(指令操作)
  2. Memory & CLAUDE.md(記憶與設定)
  3. Project Setup(專案初始化)
  4. Commands Deep Dive(指令進階)

👉 幫你打好 Claude Code 的「操作基礎」


⚙️ 中階能力(開始做系統)

  1. Skills(技能模組)
  2. Hooks(自動觸發邏輯)
  3. MCP Servers(外部資料整合)
  4. Subagents(子代理)

👉 開始打造 AI Agent 系統


🚀 進階實戰(Production 等級)

  1. Advanced Features
  2. Workflows
  3. Plugins

👉 直接進入「可商用」的 AI 系統設計


⚡ 最大亮點:邊學邊做,立即實作

1️⃣ 瀏覽器就是你的開發環境

不需要:

  • ❌ 安裝 Claude Code
  • ❌ 設定 API Key
  • ❌ 處理環境問題

👉 直接開網頁就能練習指令


2️⃣ 超強 Config Builder

你只要填表單,它會幫你產生:

  • CLAUDE.md
  • Skills
  • Hooks
  • MCP Server 設定
  • Plugins

👉 直接 copy 到專案就能用


3️⃣ Quiz 機制(真的會學會)

不像一般教學只是:

👉 對 / 錯

這裡是:

👉 ❌ 錯了 → 告訴你「為什麼錯」

這點對理解 Claude Code 非常關鍵。


🧩 適合哪些人?

這個教學網站特別適合:

  • 🔹 想學 Claude Code 的新手
  • 🔹 想做 AI Agent / 自動化系統的人
  • 🔹 已經會用,但不懂 hooks / MCP / skills 的開發者
  • 🔹 想快速做出 AI SaaS 或內部工具的人

🧠 為什麼這種學習方式更有效?

傳統學習方式:

文件 → 理解 → 嘗試 → 卡住 → 放棄

這個平台:

操作 → 立即回饋 → 修正 → 建立理解

👉 這其實就是「工程師最有效的學習方式」

AI 取代分析師?Claude Financial Services Plugins 完整解析

AI 取代分析師?Claude Financial Services Plugins 完整解析

一、什麼是 Claude for Financial Services Plugins?

Claude 是由 Anthropic 推出的 AI 助手,而 Claude for Financial Services Plugins 則是專為金融產業打造的擴充工具組。

該插件系統讓 Claude 不只是聊天工具,而是直接進化為:

👉 投資分析師
👉 財務建模專家
👉 研究助理
👉 Deal sourcing 引擎

透過整合多個金融資料供應商與自動化工作流,Claude 能夠執行完整的金融分析流程。


⚙️ 核心亮點:41 個技能 + 38 個斜線指令

這套系統最強大的地方在於:

🔹 41 個自動觸發技能(Skills)

當你輸入自然語言時,Claude 會自動判斷並執行對應任務,例如:

  • 自動建立 DCF 模型
  • 解析財報(10-K、10-Q)
  • 預測公司未來現金流
  • 計算估值(WACC、IRR)
  • 分析市場趨勢

👉 幾乎等於「AI 自動跑完整投資分析流程」


🔹 38 個 Slash Commands(斜線指令)

你也可以直接用指令操作,例如:

/dcf
/valuation
/earnings-analysis
/company-profile
/deal-sourcing

👉 類似「金融版 CLI + Copilot」


🧠 Claude 可以做哪些金融工作?

以下是實際應用場景:


📊 1️⃣ DCF 建模(Discounted Cash Flow)

https://media.wallstreetprep.com/uploads/2018/04/13150201/dcf1.jpg
https://cdn.corporatefinanceinstitute.com/assets/Discounted-Cash-Flow-DCF-Formula-2.png
https://cdn.corporatefinanceinstitute.com/assets/Valuation-Modeling-in-Excel.png

Claude 可自動:

  • 抓取公司財務數據
  • 預測未來營收與現金流
  • 計算折現率(WACC)
  • 輸出估值結果

👉 傳統需要 2–3 小時 → 現在幾分鐘完成


📑 2️⃣ 財報分析(Financial Statements Analysis)

https://cdn.corporatefinanceinstitute.com/assets/Net-Income-and-Retained-Earnings-1024x696.png
https://www.geckoboard.com/uploads/Cashflow-dashboard-example.png
https://media.wallstreetprep.com/uploads/2021/12/09124400/FB-Table-of-Contents.jpg

Claude 可自動解析:

  • Income Statement
  • Balance Sheet
  • Cash Flow

並產出:

  • 關鍵指標(ROE、毛利率)
  • 成長趨勢
  • 風險警示

🏢 3️⃣ 公司研究(Equity Research)

https://s3.amazonaws.com/thumbnails.venngage.com/template/ff356a68-3ca0-4696-a175-0e5b3354d9b3.png
https://cdn.corporatefinanceinstitute.com/assets/Comparable-company-analysis.png
https://www.marketsandmarkets.com/Images/graph-analytics-market1.jpg

4

輸入公司名稱即可:

  • 自動產出公司報告
  • 分析競爭對手
  • 市場定位與護城河
  • 投資建議(Bull / Bear case)

🔍 4️⃣ Deal Sourcing(投資機會發掘)

https://cdn.prod.website-files.com/65d48bc2b64ae3248b634894/66ebd82b61399462d231a183_66ebd7d8921e5c7861746860_Guide%2520to%2520Increasing%2520Venture%2520Capital%2520Deal%2520Flow%2520in%25202024_1.png
https://images.squarespace-cdn.com/content/v1/5cd8b72c65a707a8fbfe11d2/36700356-3692-4fbb-9fcd-18f25fbdf949/Startup%2BFunding%2BStages.png
https://cdn.prod.website-files.com/5a710020b54d350001949426/60a22b4667b6fc9dfcb7a0ef_Deal%20Sourcing%20to%20Closing.jpeg

Claude 能:

  • 搜尋潛在投資標的
  • 篩選符合條件公司
  • 分析市場機會
  • 建立 pipeline

👉 對 VC / PE / 投資銀行極具價值


🔗 整合 11 個資料供應商

根據官方設計,Claude plugins 已整合多個金融數據來源,例如:

  • 市場數據(股價、交易量)
  • 財報資料
  • 宏觀經濟指標
  • 公司基本面資料

👉 AI 不再「幻想」,而是基於真實數據分析


🧩 技術架構概念(你會有興趣的重點)

從工程角度看,這其實是一個:

Claude (LLM)
   ↓
Plugin Orchestrator
   ↓
Skills (41 modules)
   ↓
Data Providers (11 sources)
   ↓
Structured Financial Output

👉 本質就是「金融版 AI Agent」


⚡ 為什麼這個東西很關鍵?

這代表一個重大趨勢:

🔥 AI 正在取代「重複性高的金融分析工作」

過去:

  • 分析師花 80% 時間整理資料
  • 20% 做判斷

現在:

  • AI 做 80% 分析
  • 人類專注策略與決策

🚀 未來發展

Claude Financial Plugins 只是開始,接下來可能會出現:

  • 自動生成投資簡報(Pitch Deck)
  • 即時交易策略 AI
  • 完整 AI 投資顧問

👉 最終形態:AI 投資銀行

相關文章

CherryNio AI 評測:一站式整合 AI 平台,省下所有訂閱費用

CherryNio AI 評測:一站式整合 AI 平台,省下所有訂閱費用

CherryNio AI(CherryChat.org) 是一個提供 一站式整合 AI 服務平台,聚合了多個頂級大語言模型,如 Sora2、GPT-5、Claude 4.5、Gemini 2.5 Pro 等,讓使用者在同一個介面內即可呼叫不同模型進行聊天、翻譯、分析與客製化應用。

CherryNio 不僅是一個 AI 聊天介面,還能透過 API 金鑰中轉與整合服務,讓開發者在自己的應用中也能使用這些模型。


📌 為什麼 CherryNio AI 可以替代所有 AI 訂閱?

你可能會為 ChatGPT、Gemini、Claude、甚至 Perplexity 分別付費訂閱。但 CherryNio AI 將這些 AI 能力整合在同一個平台,用更彈性的付費方式替代多個訂閱,大幅降低成本並提升效率。


🧪 案例一:沉浸式翻譯

透過 CherryNio 的 沉浸式翻譯功能(類似瀏覽器翻譯插件),你可以把外語內容即時翻譯並呈現在同一個視窗中,不需跳來跳去切換工具。這對長篇網頁閱讀與即時對話翻譯超級實用。


🛒 案例二:Nano Banana

Nano Banana 是影片中提到的一個實際使用案例,可理解為結合 CherryNio 的 AI 能力,用以 生成或優化產品描述/創意寫作等工作流程,展現平台在不同任務上的彈性應用。


🖱 案例三:Cursor 替代品

許多使用者會用 Cursor 來進行程式碼輔助、資料分析等 AI 工作。CherryNio 提供整合式接口與多模型支援,讓你可以在單一平台內呼叫不同模型執行類似 Cursor 的任務,不再需要額外訂閱 Cursor


🔍 案例四:Perplexity 替代品

Perplexity 是一個主打資料檢索與摘要的 AI 工具。在 CherryNio 中,只要選擇合適的模型和 prompt,就可以達到類似的效果:從大量資料中萃取資訊與整理答案,甚至結合多個模型輸出更豐富的答案。


📚 案例五:本地知識庫

CherryNio 支援建立 本地知識庫或整合 API 查詢功能,讓使用者能夠基於自有資料來源進行檢索與對話。這對於企業內部知識管理、客服智能回覆甚至技術文檔搜索都非常有幫助,更是一種 替代雲端知識庫訂閱的方式


💡 使用模式與付費方式

CherryNio AI 的付費方式通常不是傳統的年費訂閱,而是 透過 Token 或套餐方式彈性付費,讓使用者按需支付,減少不必要的訂閱浪費。

參考資料

https://chat.cherrychat.org

Llama-3.2-90B-Vision 實戰:本地端影片偵探與多模態分析完整解析

Llama-3.2-90B-Vision 實戰:本地端影片偵探與多模態分析完整解析

前言:為什麼要做「影片偵探」?

隨著監控攝影機、行車紀錄器、門禁與智慧製造設備的大量部署,「影片」早已成為企業與政府最重要的資料來源之一。然而,影片資料無法被傳統文字搜尋系統理解,長時間的人工作業也帶來極高成本。

這正是「影片偵探(Video Detective)」的價值所在——
讓 AI 看懂影片內容、理解畫面語意、並能以自然語言回答問題

而在 2024 年 9 月 25 日,Meta 推出的 Llama-3.2-90B-Vision(Vision 3.2 90B),正式讓「本地端、多模態、可商業應用」的影片理解系統成為可能。


Llama-3.2-90B-Vision 模型簡介

Vision 3.2 90B 是 Meta 在 Llama 3.2 系列中最強大的開源多模態模型(VLM),也是目前開源社群中少數能與封閉模型正面競爭的選擇。

核心規格與特點

  • 模型規模
    900 億個參數(90B),為 Llama 3.2 系列最大版本
  • 視覺處理能力
    • 圖像理解
    • 圖像描述(Image Captioning)
    • 視覺問答(VQA)
    • 圖表與截圖解析(Excel / PDF / 監控畫面)
  • 技術架構
    採用 Adapter Weights,將視覺編碼器整合至 Llama 3 語言模型
  • 上下文長度
    高達 128K tokens,可處理長影片摘要與多畫面分析
  • 效能表現
    在多模態基準測試中,接近 GPT-4o-mini、Claude 3 Haiku
  • 硬體需求
    通常需要多張 NVIDIA A100 / H100 GPU 進行推理

官方資源:


什麼是「影片偵探」?

「影片偵探」並不是單一模型,而是一套 多階段 AI 架構

  1. 影格抽取(Frame Sampling)
  2. 物件 / 人臉偵測(YOLO / yolo face)
  3. 視覺語意理解(Llama-3.2-90B-Vision)
  4. 語意摘要與事件推論
  5. 自然語言查詢與回應

使用者可以直接詢問:

「這段影片中,有沒有可疑人物?」
「哪一段出現未授權進入?」
「請總結這 2 小時監控畫面的重點事件」


為何選擇 Llama-3.2-90B-Vision?

1️⃣ 本地端部署(On-Premise)

對於以下場景尤其重要:

  • 政府監控系統
  • 金融機構
  • 工廠產線
  • 企業內部資安影像

資料不出內網、不上雲端,符合資安與法規要求。

透過 Ollama 即可在本地部署 Vision 3.2:

👉 https://ollama.com/library/llama3.2-vision:90b

參考教學:


2️⃣ 高度客製化的影片理解流程

你可以自由設計:

  • 先用 YOLO / yolo face 做人臉與物件偵測
  • 再將關鍵影格丟給 Llama-3.2-90B-Vision 理解語意
  • 根據產業需求自訂 Prompt 與規則

例如:

  • 工地安全(是否未戴安全帽)
  • 店面防盜(可疑行為模式)
  • 智慧製造(異常操作流程)

3️⃣ 可合法商業應用的開源模型

與多數封閉 API 不同,Llama 3.2 系列具備商業使用條款,適合:

  • SaaS 影片分析平台
  • 企業內部 AI 系統
  • 客製化專案與整合案

同時避免:

  • API 呼叫成本失控
  • 雲端延遲
  • 資料外洩風險

架構範例:影片偵探系統設計

線上體驗與 API 使用方式

如果暫時沒有高階 GPU,也可以使用 NVIDIA 官方提供的線上版本:


實作系統

影片偵探 Prompt 範例(可直接用)

Prompt 1:單張關鍵影格「事件描述 + 可疑度」

用途:把 YOLO 的偵測結果 + 影格一起丟給 Vision 3.2,產出可索引的敘述。
(強烈建議把 YOLO metadata 一起餵,讓 VLM 不用「瞎猜」類別與位置)

你是一個「影片偵探」AI,負責為監控影格做事件描述與風險判讀。
請根據:
1) 影格畫面內容
2) 我提供的偵測結果(物件類別、bbox、信心值)
輸出結構化 JSON,方便後續索引與查詢。

規則:
- 不要編造畫面中不存在的事物。
- 若畫面資訊不足,請在 unsure 字段說明原因。
- suspicious_score 0~100:越高越可疑,但必須用畫面線索與偵測結果解釋。
- 需同時輸出一段適合全文檢索的繁體中文摘要 summary_zh。

輸入:
timestamp: {{timestamp}}
camera_id: {{camera_id}}
detections: {{yolo_detections_json}}
image: <提供影像>

輸出 JSON 欄位:
{
  "timestamp": "...",
  "camera_id": "...",
  "scene_summary": "...",
  "entities": [
    {"type": "...", "count": 1, "notable": "..."}
  ],
  "actions": ["..."],
  "suspicious_score": 0,
  "suspicious_reasons": ["..."],
  "unsure": ["..."],
  "summary_zh": "..."
}

Prompt 2:多張影格「跨時間軸事件摘要」

用途:把同一事件的多張關鍵影格(例如 5~12 張)一次丟給 128K context,做「時間序列推論」

你是一個監控「影片偵探」AI。以下提供同一段時間序列的多張關鍵影格,
每張都有 timestamp 與偵測結果。請完成:

1) 以時間順序描述發生了什麼事(避免臆測)。
2) 是否存在可疑行為?若有,指出關鍵時間點與依據。
3) 產出適合儲存到事件資料庫的 JSON(含 event_type、start/end、關聯track_id)。

輸入格式(重複多次):
- frame_i.timestamp: ...
- frame_i.detections: ...
- frame_i.image: <圖>

輸出:
- timeline_bullets(條列,含時間)
- event_json:
{
  "event_type": "...",
  "start_time": "...",
  "end_time": "...",
  "actors": [{"track_id":"...","type":"person/vehicle","notes":"..."}],
  "evidence": [{"timestamp":"...","what":"..."}],
  "risk_level": "low/medium/high",
  "search_keywords_zh": ["..."]
}

Prompt 3:使用者查詢「找片段 + 佐證」

用途:RAG 從索引庫找出候選事件後,叫模型做最後整合回答。

你是影片偵探助理。你會收到:
- 使用者問題
- 檢索得到的候選事件清單(含摘要、時間、相機、佐證影格描述)

請:
1) 直接回答問題(繁體中文)
2) 附上最相關的事件時間範圍(start~end)
3) 列出 1~3 個佐證點(引用候選事件的 evidence)
4) 若資訊不足,說明缺什麼

輸入:
question: {{user_question}}
candidates: {{retrieved_events_json}}

輸出:
{
  "answer_zh": "...",
  "best_matches": [
    {"camera_id":"...","time_range":"...","why":"...","evidence":["..."]}
  ],
  "missing_info": ["..."]
}

YOLO + Vision 3.2 實作流程範例

下面是一個「可落地」的最小可行流程(MVP)

Step 0:選擇部署模式

  • 本地端(推薦):Ollama 跑 llama3.2-vision:90b(需強 GPU/多卡)
  • 先用線上 API 驗證流程:NVIDIA 的 Llama-3.2-90B-Vision Instruct(把 pipeline 跑通再回本地)

Step 1:影片 → 影格抽取(Frame Sampling)

策略建議:

  • 監控:每秒 1~2 張 + 場景切分(scene change)
  • 行車:依速度/光流變化調整取樣
  • 事件型:偵測到人/車才加密取樣

Python(OpenCV)骨架:

import cv2
import os

def extract_frames(video_path, out_dir, fps_sample=1):
    os.makedirs(out_dir, exist_ok=True)
    cap = cv2.VideoCapture(video_path)
    fps = cap.get(cv2.CAP_PROP_FPS) or 30
    step = max(int(fps / fps_sample), 1)

    idx = 0
    frame_id = 0
    while True:
        ok, frame = cap.read()
        if not ok:
            break
        if frame_id % step == 0:
            out_path = os.path.join(out_dir, f"frame_{idx:06d}.jpg")
            cv2.imwrite(out_path, frame)
            idx += 1
        frame_id += 1

    cap.release()
    return idx

Step 2:影格 → YOLO / yolo face 偵測(產生 metadata)

可以用:

  • YOLOv8 / YOLOv10 做物件、人、車
  • yolo face(或更強的人臉模型)做臉部框
  • 之後再加 ByteTrack/DeepSORT 形成 track_id

偵測輸出建議格式(JSON):

[
  {"type":"person","conf":0.92,"bbox":[x1,y1,x2,y2]},
  {"type":"car","conf":0.88,"bbox":[x1,y1,x2,y2]},
  {"type":"face","conf":0.81,"bbox":[x1,y1,x2,y2]}
]

Step 3:挑關鍵影格(降低 VLM 成本)

常見挑選法:

  • 有偵測到 person/vehicle 才送 VLM
  • 同一 track_id 每 N 秒只取 1 張代表影格
  • 有事件觸發(越線/闖入/停留過久)才送 VLM

Step 4:送進 Vision 3.2 做語意理解(Ollama 版本)

若你用 Ollama,本質上是:影像 + prompt +(可選)metadata → 回傳描述 JSON。

概念請長這樣:

  1. 把影格讀成 base64 或直接用 Ollama SDK 的 image 欄位
  2. prompt 內含 timestamp、camera_id、detections
  3. 要求模型「輸出 JSON」

依你使用的 Ollama client 調整:

import json

def build_prompt(ts, cam, detections):
    return f"""
你是一個影片偵探AI。請依據影像與偵測結果輸出JSON(繁體中文)。

timestamp: {ts}
camera_id: {cam}
detections: {json.dumps(detections, ensure_ascii=False)}

輸出JSON欄位:
{{
  "timestamp": "...",
  "camera_id": "...",
  "scene_summary": "...",
  "actions": ["..."],
  "suspicious_score": 0,
  "suspicious_reasons": ["..."],
  "summary_zh": "..."
}}
"""

# 送給 Ollama 的地方,依你使用的套件/HTTP API 填上:
# response = ollama.chat(model="llama3.2-vision:90b", messages=[...], images=[...])

Step 5:事件彙整 + 建索引(讓你能「用中文搜影片」)

你至少要存三種資料:

  1. frame-level:每張影格摘要 + detections
  2. event-level:跨影格彙整(start/end、actors、evidence)
  3. 索引:
    • 向量索引(embedding)→ 語意搜尋
    • 關鍵字索引(全文)→ 精準查詢(例如「紅色帽子」「白色車」)

Step 6:查詢(RAG)→ 回答 + 佐證時間軸

流程:

  • 使用者問:「昨天晚上 10 點到 11 點,有沒有人在後門徘徊?」
  • 檢索 event/frame 索引找候選
  • 把候選 evidence 丟給 Prompt 3 做最終回答
  • 回傳:時間範圍 + 相機 + 佐證描述 +(可選)影格連結

你可以直接複製用的「最小資料結構」

frame_record

{
  "frame_id": "cam01_20251216_220501_000123",
  "camera_id": "cam01",
  "timestamp": "2025-12-16T22:05:01",
  "image_path": "/frames/cam01/frame_000123.jpg",
  "detections": [],
  "vlm_json": {},
  "summary_zh": "..."
}

event_record

{
  "event_id": "evt_20251216_cam01_0007",
  "camera_id": "cam01",
  "start_time": "2025-12-16T22:05:00",
  "end_time": "2025-12-16T22:06:30",
  "event_type": "徘徊/闖入/越線/遺留物/群聚",
  "actors": [{"track_id":"p12","type":"person"}],
  "evidence": [
    {"timestamp":"2025-12-16T22:05:15","what":"人物在後門附近停留,反覆靠近門把"}
  ],
  "risk_level": "medium",
  "search_keywords_zh": ["後門","徘徊","可疑人物"]
}

事件規則怎麼定義(越線 / 闖入 / 停留)

A. 共同前置:座標系與 ROI 設定

建議每台攝影機都做一份設定檔(JSON/YAML):

  • ROI 多邊形:例如「後門區域」「禁入區」「收銀台區」
  • Tripwire 線段:例如「門檻線」「圍籬線」
  • 方向向量(可選):判斷越線方向(外→內 / 內→外)

camera_rules.json(示例)

重點:事件規則「不要靠 VLM 猜」,VLM 用在「補語意」,規則用在「可重現、可商業合規」。

B. 越線(Tripwire Crossing)

定義:同一個 track_id 的「腳點/中心點」從線段一側移動到另一側,且跨越時間在合理範圍。

實作要點

  • 取 bbox 底部中心點(foot point)更準:
    foot = ((x1+x2)/2, y2)
  • 記錄上一幀 foot 在直線的「符號」(line side sign)
  • sign 改變(+ → – 或 – → +)= 可能越線
  • 若需要方向:檢查 crossing 前後的位置落在哪個 side

事件輸出

  • event_type: 越線
  • evidence: crossing 發生的 timestamp + 當下影格 id

C. 闖入(Intrusion into Restricted Zone)

定義:目標(person/vehicle)進入「restricted_zone」ROI,且停留超過 N 幀或 N 秒(避免抖動誤判)。

實作要點

  • 以 foot point 是否在多邊形內判斷(比中心點穩)
  • debounce:例如連續 5 幀在區域內才算「進入成立」
  • 退出同理:連續 5 幀不在區域內才算退出

事件輸出

  • event_type: 闖入
  • start_time = 進入成立時間
  • end_time = 退出成立時間

D. 停留 / 徘徊(Loitering)

定義:在指定 zone 內停留超過 min_seconds,且移動速度低於門檻(或移動距離很小但在區域內反覆)。

實作要點

  • per track_id 維護:
    • time_in_zone
    • path_length(foot 點累積距離)
    • avg_speed = path_length / time_in_zone
  • 停留成立條件(示例):
    • time_in_zone >= 20savg_speed <= 15 px/s
  • 可擴充「徘徊」:
    • 在 zone 內來回穿越同一條小線段超過 K 次
    • 或在 zone 內出入(進/出)頻繁

2) 追蹤器接 YOLO(track_id 穩定)

A. 建議組合

  • 偵測:YOLO(person/vehicle/bag…)
  • 追蹤:ByteTrack(速度快、穩)或 DeepSORT(更重但 ReID 強)
  • ID 穩定策略
    1. 只追固定類別:例如只追 person/vehicle(別把雜訊類也追)
    2. 信心值門檻 + NMS:減少跳框
    3. 遮擋容忍:追蹤器允許短暫 lost 再接回
    4. ReID(選配):若常遮擋、跨鏡頭才需要

B. 最小資料流(你系統該長這樣)

frame
  -> YOLO detections: [x1,y1,x2,y2, conf, cls]
  -> Tracker.update(detections)
  -> tracks: [track_id, bbox, cls, conf, state]
  -> event_rules.update(tracks, timestamp)

C. 追蹤輸出建議格式

[
  {"track_id":"p12","type":"person","conf":0.92,"bbox":[...],"foot":[...]}
]

關鍵:後續事件規則都以 track_id 為核心。VLM 的 actors 也引用 track_id,才能串起「時間序列」。

3) 向量索引與查詢(embedding schema、RAG prompt)

A. 你要索引什麼?(兩層:frame-level + event-level)

  • frame-level:細碎、量大,用於精確定位
  • event-level:濃縮、量小,用於查詢與回覆(最重要)

B. 建議的 Embedding Schema(event-level)

每個 event 存:

  • id, camera_id, start_time, end_time
  • event_type
  • actors(含 track_id、類別、特徵詞)
  • summary_zh(VLM + 規則融合)
  • evidence[](時間戳 + 發生描述 + frame_id)
  • keywords_zh[](用於 BM25)
  • embedding(由 summary_zh + keywords_zh 拼成)

event_embedding_text 建議組合

[事件類型] 闖入
[地點] cam01 後門 restricted_zone
[摘要] 一名人物在22:05進入禁入區,停留約35秒後離開。
[關鍵詞] 後門, 禁入區, 闖入, 可疑人物

C. 檢索策略(Hybrid Search)

  1. 關鍵字/BM25:找「後門」「白色車」「紅帽」這種明確詞
  2. 向量/語意:找「徘徊」「鬼鬼祟祟」「疑似踩點」這種抽象語意
  3. 合併 rerank:取 topK events,再丟給 LLM 做最終回答

D. RAG Prompt(查詢 → 回答 + 時間軸 + 點影格)

「可商業用」模板(配之前的 Prompt 3 進化版):

你是影片偵探助理。你會收到:
- 使用者問題 question(繁體中文)
- 候選事件 candidates(由索引檢索得到,含 summary_zh、時間、相機、evidence、frame_link)
你的任務是產生「可行動」的答案:

要求:
1) 先用 3~6 句回答使用者,務必引用 candidates 的內容,不要臆測。
2) 產生時間軸 timeline(依時間排序),每點包含:start~end、camera、event_type、1句描述、frame_link(若有)。
3) 如果 candidates 不足以回答,回 missing_info,並說明需要補哪段時間/哪個相機/哪種事件資料。
4) 全部輸出 JSON,方便前端直接渲染。

輸入:
question: {{question}}
candidates: {{candidates_json}}

輸出 JSON:
{
  "answer_zh": "...",
  "timeline": [
    {
      "camera_id": "...",
      "time_range": "YYYY-MM-DD HH:MM:SS ~ HH:MM:SS",
      "event_type": "...",
      "description": "...",
      "evidence": ["..."],
      "frame_links": ["..."]
    }
  ],
  "missing_info": ["..."]
}

4) 簡單 Web UI(查詢 → 回傳時間軸 → 點開影格)

我給你一個最小 MVP 架構:

A. API 設計(後端)

  • GET /api/search?q=...&from=...&to=...&camera=...
    • 回傳 RAG 結果 JSON(answer_zh + timeline)
  • GET /frames/<frame_id>.jpg
    • 靜態提供影格圖(或簽名 URL)

回傳 JSON(示例)

{
  "answer_zh":"在 22:05~22:06 期間,cam01 後門區域出現 1 次闖入事件...",
  "timeline":[
    {
      "camera_id":"cam01",
      "time_range":"2025-12-16 22:05:00 ~ 22:06:30",
      "event_type":"闖入",
      "description":"人物 p12 進入 restricted_zone 停留約35秒後離開。",
      "evidence":["22:05:15 人物靠近門把","22:06:10 人物離開區域"],
      "frame_links":["/frames/cam01_20251216_220515_000130.jpg"]
    }
  ],
  "missing_info":[]
}

B. 前端(純 HTML+JS 就能跑)

把這段存成 index.html,指向你的 API:

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>影片偵探 Demo</title>
  <style>
    body { font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif; margin: 16px; }
    .row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
    input, select, button { padding: 8px; }
    .card { border: 1px solid #ddd; border-radius: 12px; padding: 12px; margin: 10px 0; }
    .timeline-item { cursor: pointer; padding: 10px; border-radius: 10px; }
    .timeline-item:hover { background: #f5f5f5; }
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    img { max-width: 100%; border-radius: 12px; border: 1px solid #eee; }
    pre { background:#fafafa; padding:10px; border-radius:10px; overflow:auto; }
  </style>
</head>
<body>
  <h1>影片偵探 Demo</h1>

  <div class="row">
    <input id="q" size="40" placeholder="例如:後門有沒有人徘徊?白色車出現在哪?" />
    <input id="from" type="datetime-local" />
    <input id="to" type="datetime-local" />
    <select id="camera">
      <option value="">全部相機</option>
      <option value="cam01">cam01</option>
      <option value="cam02">cam02</option>
    </select>
    <button id="btn">查詢</button>
  </div>

  <div class="grid">
    <div>
      <div class="card">
        <h3>答案</h3>
        <div id="answer">尚未查詢</div>
      </div>

      <div class="card">
        <h3>時間軸</h3>
        <div id="timeline"></div>
      </div>

      <div class="card">
        <h3>除錯(回傳 JSON)</h3>
        <pre id="raw">{}</pre>
      </div>
    </div>

    <div>
      <div class="card">
        <h3>影格預覽</h3>
        <div id="frameInfo">點選左側時間軸項目查看影格</div>
        <img id="frame" src="" alt="" style="display:none;" />
      </div>
    </div>
  </div>

<script>
  const $ = (id) => document.getElementById(id);

  function toISOParam(dtLocal) {
    // 直接傳給後端即可(你可在後端再轉時區)
    return dtLocal ? encodeURIComponent(dtLocal) : "";
  }

  function renderTimeline(items) {
    const root = $("timeline");
    root.innerHTML = "";
    if (!items || items.length === 0) {
      root.innerHTML = "<div>沒有找到事件</div>";
      return;
    }
    items.forEach((it, idx) => {
      const div = document.createElement("div");
      div.className = "timeline-item";
      div.innerHTML = `
        <div><b>${it.time_range}</b>|${it.camera_id}|${it.event_type}</div>
        <div>${it.description}</div>
      `;
      div.onclick = () => {
        const link = (it.frame_links && it.frame_links[0]) ? it.frame_links[0] : "";
        $("frameInfo").innerText = link ? `影格:${link}` : "此事件沒有影格連結";
        if (link) {
          $("frame").src = link;
          $("frame").style.display = "block";
        } else {
          $("frame").style.display = "none";
        }
      };
      root.appendChild(div);
    });
  }

  $("btn").onclick = async () => {
    const q = $("q").value.trim();
    const from = toISOParam($("from").value);
    const to = toISOParam($("to").value);
    const camera = encodeURIComponent($("camera").value);

    const url = `/api/search?q=${encodeURIComponent(q)}&from=${from}&to=${to}&camera=${camera}`;
    const res = await fetch(url);
    const data = await res.json();

    $("answer").innerText = data.answer_zh || "";
    renderTimeline(data.timeline || []);
    $("raw").innerText = JSON.stringify(data, null, 2);
  };
</script>
</body>
</html>

最小可跑的 Flask 後端實作(MVP)

最小可跑的 Flask 後端實作(MVP)

  • GET /api/search:用最簡單的「關鍵字 + 語意(可選)」搜尋事件,回傳前端需要的 answer_zh + timeline JSON
  • GET /frames/<path>:提供影格圖片(靜態檔)
  • 內建一份 data/events.json 假資料,讓你不用先把整個 pipeline 做完也能 Demo 起來
  • 前端就用你前面那份 index.html(我也一起放進 static/

你可以直接照以下檔案建立專案。

專案結構

video-detective-mvp/
  app.py
  requirements.txt
  data/
    events.json
  frames/
    cam01_20251216_220515_000130.jpg   (放你自己的影格檔)
  static/
    index.html

requirements.txt

Flask==3.0.3

data/events.json(假資料,可自行新增)

[
  {
    "event_id": "evt_20251216_cam01_0007",
    "camera_id": "cam01",
    "start_time": "2025-12-16T22:05:00",
    "end_time": "2025-12-16T22:06:30",
    "event_type": "闖入",
    "actors": [{"track_id":"p12","type":"person"}],
    "summary_zh": "一名人物進入後門禁入區,停留約35秒後離開。",
    "keywords_zh": ["後門","禁入區","闖入","可疑人物"],
    "evidence": [
      {"timestamp":"2025-12-16T22:05:15","what":"人物靠近門把並進入禁入區"},
      {"timestamp":"2025-12-16T22:06:10","what":"人物離開禁入區"}
    ],
    "frame_links": ["/frames/cam01_20251216_220515_000130.jpg"]
  },
  {
    "event_id": "evt_20251216_cam02_0011",
    "camera_id": "cam02",
    "start_time": "2025-12-16T21:40:10",
    "end_time": "2025-12-16T21:41:05",
    "event_type": "越線",
    "actors": [{"track_id":"v03","type":"vehicle"}],
    "summary_zh": "一輛車跨越門檻線由外往內進入區域。",
    "keywords_zh": ["越線","車輛","進入","門檻線"],
    "evidence": [
      {"timestamp":"2025-12-16T21:40:22","what":"車輛前輪跨越門檻線"}
    ],
    "frame_links": []
  },
  {
    "event_id": "evt_20251216_cam01_0009",
    "camera_id": "cam01",
    "start_time": "2025-12-16T23:10:00",
    "end_time": "2025-12-16T23:11:00",
    "event_type": "停留",
    "actors": [{"track_id":"p33","type":"person"}],
    "summary_zh": "一名人物在後門區域停留約60秒,移動緩慢,疑似徘徊。",
    "keywords_zh": ["後門","停留","徘徊","可疑"],
    "evidence": [
      {"timestamp":"2025-12-16T23:10:20","what":"人物在後門附近停留且來回踱步"}
    ],
    "frame_links": []
  }
]

static/index.html(直接用你前面 UI;這裡給一份可直接用的)

這份會呼叫 /api/search,點時間軸會開影格。

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>影片偵探 Demo</title>
  <style>
    body { font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif; margin: 16px; }
    .row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
    input, select, button { padding: 8px; }
    .card { border: 1px solid #ddd; border-radius: 12px; padding: 12px; margin: 10px 0; }
    .timeline-item { cursor: pointer; padding: 10px; border-radius: 10px; }
    .timeline-item:hover { background: #f5f5f5; }
    .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    img { max-width: 100%; border-radius: 12px; border: 1px solid #eee; }
    pre { background:#fafafa; padding:10px; border-radius:10px; overflow:auto; }
  </style>
</head>
<body>
  <h1>影片偵探 Demo</h1>

  <div class="row">
    <input id="q" size="42" placeholder="例如:後門有沒有人徘徊?禁入區闖入?越線?" />
    <input id="from" type="datetime-local" />
    <input id="to" type="datetime-local" />
    <select id="camera">
      <option value="">全部相機</option>
      <option value="cam01">cam01</option>
      <option value="cam02">cam02</option>
    </select>
    <button id="btn">查詢</button>
  </div>

  <div class="grid">
    <div>
      <div class="card">
        <h3>答案</h3>
        <div id="answer">尚未查詢</div>
      </div>

      <div class="card">
        <h3>時間軸</h3>
        <div id="timeline"></div>
      </div>

      <div class="card">
        <h3>除錯(回傳 JSON)</h3>
        <pre id="raw">{}</pre>
      </div>
    </div>

    <div>
      <div class="card">
        <h3>影格預覽</h3>
        <div id="frameInfo">點選左側時間軸項目查看影格</div>
        <img id="frame" src="" alt="" style="display:none;" />
      </div>
    </div>
  </div>

<script>
  const $ = (id) => document.getElementById(id);

  function renderTimeline(items) {
    const root = $("timeline");
    root.innerHTML = "";
    if (!items || items.length === 0) {
      root.innerHTML = "<div>沒有找到事件</div>";
      return;
    }
    items.forEach((it) => {
      const div = document.createElement("div");
      div.className = "timeline-item";
      div.innerHTML = `
        <div><b>${it.time_range}</b>|${it.camera_id}|${it.event_type}</div>
        <div>${it.description}</div>
      `;
      div.onclick = () => {
        const link = (it.frame_links && it.frame_links[0]) ? it.frame_links[0] : "";
        $("frameInfo").innerText = link ? `影格:${link}` : "此事件沒有影格連結";
        if (link) {
          $("frame").src = link;
          $("frame").style.display = "block";
        } else {
          $("frame").style.display = "none";
        }
      };
      root.appendChild(div);
    });
  }

  $("btn").onclick = async () => {
    const q = $("q").value.trim();
    const from = $("from").value;
    const to = $("to").value;
    const camera = $("camera").value;

    const url = `/api/search?q=${encodeURIComponent(q)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&camera=${encodeURIComponent(camera)}`;
    const res = await fetch(url);
    const data = await res.json();

    $("answer").innerText = data.answer_zh || "";
    renderTimeline(data.timeline || []);
    $("raw").innerText = JSON.stringify(data, null, 2);
  };
</script>
</body>
</html>

app.py(Flask 後端:搜尋 + 回傳 timeline + 靜態影格)

from __future__ import annotations

import json
import os
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from flask import Flask, jsonify, request, send_from_directory


BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_PATH = os.path.join(BASE_DIR, "data", "events.json")
FRAMES_DIR = os.path.join(BASE_DIR, "frames")


def parse_dt(s: str) -> Optional[datetime]:
    """
    Accepts:
      - "" -> None
      - HTML datetime-local: "2025-12-16T22:05"
      - ISO: "2025-12-16T22:05:00"
    """
    if not s:
        return None
    s = s.strip()
    for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"):
        try:
            return datetime.strptime(s, fmt)
        except ValueError:
            pass
    # Fallback: try fromisoformat (may accept seconds/microseconds)
    try:
        return datetime.fromisoformat(s)
    except ValueError:
        return None


def load_events() -> List[Dict[str, Any]]:
    if not os.path.exists(DATA_PATH):
        return []
    with open(DATA_PATH, "r", encoding="utf-8") as f:
        return json.load(f)


def dt_in_range(start: datetime, end: datetime, q_from: Optional[datetime], q_to: Optional[datetime]) -> bool:
    if q_from and end < q_from:
        return False
    if q_to and start > q_to:
        return False
    return True


def text_score(query_tokens: List[str], event: Dict[str, Any]) -> int:
    """
    Minimal keyword scoring:
      - match in event_type, summary_zh, keywords_zh, evidence.what
    """
    blob_parts = []
    blob_parts.append(str(event.get("event_type", "")))
    blob_parts.append(str(event.get("summary_zh", "")))
    blob_parts.extend(event.get("keywords_zh", []) or [])
    for ev in event.get("evidence", []) or []:
        blob_parts.append(str(ev.get("what", "")))

    blob = " ".join(blob_parts).lower()
    score = 0
    for t in query_tokens:
        if not t:
            continue
        if t in blob:
            score += 10
    return score


def tokenize_zh(query: str) -> List[str]:
    """
    MVP 簡化版 tokenization:
    - 以空白切
    - 再把常見關鍵詞直接保留(你可改成 jieba/ckip/hfl 分詞)
    """
    q = (query or "").strip().lower()
    if not q:
        return []
    tokens = [x for x in q.replace(",", " ").replace(",", " ").split() if x]
    # 也把一些常見事件詞補入(避免使用者只打「徘徊」但資料用「停留」)
    synonyms = {
        "徘徊": ["停留"],
        "停留": ["徘徊"],
        "闖入": ["入侵", "禁入"],
        "越線": ["跨線", "穿越"],
        "後門": ["門口"]
    }
    expanded = []
    for t in tokens:
        expanded.append(t)
        for s in synonyms.get(t, []):
            expanded.append(s)
    return expanded


def build_timeline_item(e: Dict[str, Any]) -> Dict[str, Any]:
    def fmt_range(s: str, t: str) -> str:
        try:
            sdt = datetime.fromisoformat(s)
            tdt = datetime.fromisoformat(t)
            return f"{sdt:%Y-%m-%d %H:%M:%S} ~ {tdt:%Y-%m-%d %H:%M:%S}"
        except Exception:
            return f"{s} ~ {t}"

    evidence = []
    for ev in e.get("evidence", []) or []:
        ts = ev.get("timestamp", "")
        what = ev.get("what", "")
        if ts and what:
            evidence.append(f"{ts} {what}")
        elif what:
            evidence.append(what)

    return {
        "camera_id": e.get("camera_id", ""),
        "time_range": fmt_range(e.get("start_time", ""), e.get("end_time", "")),
        "event_type": e.get("event_type", ""),
        "description": e.get("summary_zh", ""),
        "evidence": evidence[:3],
        "frame_links": e.get("frame_links", []) or [],
    }


def build_answer(query: str, matched: List[Dict[str, Any]]) -> str:
    if not query.strip():
        return "請輸入查詢內容(例如:後門 徘徊、禁入區 闖入、越線)。"
    if not matched:
        return "沒有找到符合條件的事件。你可以嘗試放寬時間範圍、改用關鍵詞(例如:後門、闖入、停留、越線)。"

    # MVP:用最前面幾筆拼一段可讀答案(之後你可接 LLM/RAG)
    top = matched[:3]
    parts = []
    parts.append(f"找到 {len(matched)} 筆候選事件,最相關的如下:")
    for e in top:
        parts.append(f"- {e.get('camera_id','')}|{e.get('event_type','')}|{e.get('summary_zh','')}")
    return "\n".join(parts)


app = Flask(__name__, static_folder="static", static_url_path="/")


@app.get("/")
def index():
    # 直接用 static/index.html
    return app.send_static_file("index.html")


@app.get("/frames/<path:filename>")
def frames(filename: str):
    # 提供影格圖片
    return send_from_directory(FRAMES_DIR, filename)


@app.get("/api/search")
def api_search():
    q = request.args.get("q", "")
    camera = request.args.get("camera", "").strip()
    q_from = parse_dt(request.args.get("from", ""))
    q_to = parse_dt(request.args.get("to", ""))

    events = load_events()
    tokens = tokenize_zh(q)

    # 先做基本篩選(camera + time range)
    filtered = []
    for e in events:
        if camera and e.get("camera_id") != camera:
            continue
        s = parse_dt(e.get("start_time", "")) or datetime.min
        t = parse_dt(e.get("end_time", "")) or datetime.min
        if not dt_in_range(s, t, q_from, q_to):
            continue
        filtered.append(e)

    # 關鍵字打分排序(MVP)
    scored: List[Tuple[int, Dict[str, Any]]] = []
    for e in filtered:
        score = text_score(tokens, e)
        if tokens and score <= 0:
            continue
        scored.append((score, e))

    scored.sort(key=lambda x: x[0], reverse=True)
    matched = [e for _, e in scored][:20]

    resp = {
        "answer_zh": build_answer(q, matched),
        "timeline": [build_timeline_item(e) for e in matched],
        "missing_info": [] if matched else ["建議擴大時間範圍或指定相機,並使用關鍵字:後門、闖入、停留、越線。"],
    }
    return jsonify(resp)


if __name__ == "__main__":
    os.makedirs(FRAMES_DIR, exist_ok=True)
    app.run(host="0.0.0.0", port=5000, debug=True)

啟動方式

cd video-detective-mvp
python -m venv .venv
# Windows: .venv\Scripts\activate
source .venv/bin/activate

pip install -r requirements.txt
python app.py

打開瀏覽器:http://localhost:5000/ ,MVP 已經能跑 UI + 查詢 + 時間軸 + 點影格。接下來可以逐步替換:

build_answer() 換成 RAG(把 topK candidates 丟給 Llama-3.2-90B-Vision / 或先用任一 LLM)

data/events.json 換成資料庫(SQLite / PostgreSQL)

text_score() 換成 Hybrid Search

BM25(全文索引) + 向量索引(Qdrant/pgvector)

進階實用版本

SQLite 儲存 events(含初始化與匯入)
向量欄位介面(先 stub,未來可無痛換 Qdrant / pgvector)
事件詳情 API(/api/event/<id>,支援多影格 / 證據)
✅ SQLite(事件資料庫)
✅ Qdrant(Hybrid:向量語意 + 關鍵字)
/api/search + /api/event/<id> + /frames/...
✅ 前端 UI(查詢 → 時間軸 → 點事件 → 顯示 evidence + 多影格縮圖/預覽)
✅ ByteTrack「真正接法」我也給你兩種:可跑的簡易 IoU Tracker(保底) + ByteTrack
✅ Flask(單一服務)
✅ 影片抽幀(或直接讀 frames 資料夾)
✅ YOLO 偵測(Ultralytics)
✅ 追蹤(保底可跑 IoU Tracker;之後你只要替換 tracker 類別就能上 ByteTrack)
✅ 事件規則引擎(越線 / 闖入 / 停留)
✅ 自動寫入 SQLite(events.db)
✅ 自動重建 Qdrant 向量索引(可選:Qdrant 有開就做,沒開就跳過)
✅ 前端 UI 立刻能查到事件並點開影格

之後再補

cooldown 去重機制(避免事件爆量)

事件合併(同一個 track 的闖入/停留合併成單一 event,帶 start/end)

把 VLM(Ollama Vision 90B)用於事件摘要:只對「關鍵事件」抽 1~3 張影格做語意補強(成本更可控)


結語:Vision 3.2 90B 為開源影片 AI 帶來新世代

Llama-3.2-90B-Vision 不只是「能看圖的 LLM」,而是讓:

  • 影片搜尋
  • 監控分析
  • 行為理解
  • 事件推論

真正進入 本地端、可控、可商業化 的時代,對於想打造「影片偵探」的團隊而言,它是目前開源世界中最具實力的選擇之一

參考資料

https://mermaid.live