by Rain Chu | 6 月 2, 2026 | AI, Ollama, 模型
想把 Ollama Client 安裝在 Windows 筆電上,但模型實際運行在另一台 AI 伺服器(例如 NVIDIA Spark、Linux GPU 主機)嗎?
本文教你如何透過 PowerShell 指定遠端 Ollama Server,讓本機直接使用遠端模型資源。
Ollama 遠端架構說明
一般情況下,Ollama 預設會連接本機:
但如果你的 AI 模型部署在另一台主機,例如:
則可以透過環境變數指定遠端伺服器。
Step 1:設定遠端 Ollama Host
開啟 PowerShell:
$Env:OLLAMA_HOST = "192.168.0.1:11434"
若使用 HTTP 格式也可以:
$Env:OLLAMA_HOST = "http://192.168.0.1:11434"
建議使用第二種寫法較完整。
Step 2:確認連線是否成功
執行:
若成功,將會看到遠端伺服器上的模型清單:
NAME ID SIZEclaude xxxxxx 45 GBkimi-k2.5:cloud xxxxxx 22 GBqwen3:32b xxxxxx 20 GBdeepseek-r1:70b xxxxxx 42 GB
若出現:
Error: connection refused
請確認:
- 遠端 Ollama 是否啟動
- 防火牆是否開放 11434 Port
- Ollama 是否監聽 0.0.0.0
Linux 可檢查:
sudo ss -tlnp | grep 11434
正常應看到:
Step 3:啟動 Claude
確認模型存在後:
系統將直接透過遠端 Ollama 執行 Claude。
Step 4:指定模型版本
例如使用 Kimi K2.5 Cloud 版本:
ollama launch claude --model kimi-k2.5:cloud
也可以切換成其他模型:
ollama launch claude --model qwen3:32b
ollama launch claude --model deepseek-r1:70b
ollama launch claude --model gemma3:27b
每次開機自動設定 OLLAMA_HOST
如果不想每次都輸入:
$Env:OLLAMA_HOST = "192.168.0.240:11434"
可永久寫入 Windows 使用者環境變數:
[System.Environment]::SetEnvironmentVariable( "OLLAMA_HOST", "http://192.168.0.240:11434", "User")
重新開啟 PowerShell 後生效。
驗證:
輸出:
http://192.168.0.240:11434
常見問題排除
無法連線
測試:
curl http://192.168.0.240:11434/api/tags
若有回傳 JSON 表示正常。
Linux Server 未開放外部連線
編輯 Ollama Service:
sudo systemctl edit ollama
加入:
[Service]Environment="OLLAMA_HOST=0.0.0.0:11434"
重新載入:
sudo systemctl daemon-reloadsudo systemctl restart ollama
查看目前設定
Windows:
Linux:
透過設定 OLLAMA_HOST,即可讓 Windows 電腦上的 Ollama Client 直接連接遠端 AI 伺服器,將模型運算交由高效能 GPU 主機處理,而本機僅作為操作介面。
這種架構特別適合:
- NVIDIA Spark AI 工作站
- 家用 GPU 伺服器
- 多人共用 Ollama Server
- 企業內部 AI 平台
- AI 開發與測試環境
只需一行指令:
$Env:OLLAMA_HOST = "192.168.0.240:11434"
即可讓你的 Windows PC 立即接管遠端 Ollama 的所有模型能力。
by Rain Chu | 5 月 25, 2026 | AI, claude, Ollama, 模型
Claude Code 最大特色之一,就是它能直接理解整個專案目錄、修改檔案、執行 CLI 指令,甚至自動修復程式碼問題。
但許多人最在意的是:
- API 費用太高
- 原始碼不想送雲端
- 想完全離線使用
- 希望使用自己的 Local LLM
現在透過 Ollama 官方網站 與 LM Studio 官方網站,已經可以讓 Claude Code 直接使用本地模型。
本篇文章會完整介紹:
- Claude Code 是什麼
- 如何讓 Claude Code 使用 Local LLM
- Ollama 與 LM Studio 差異
- 三種實作方式
- Web Search 功能啟用
- 常用 CLI 指令
- 適合的模型推薦
什麼是 Claude Code?
Claude 官方網站 的 Claude Code 是 Anthropic 推出的 AI Coding Agent。
它並不是單純聊天工具,而是:
- 能讀取整個專案
- 可修改程式碼
- 可執行 Terminal 指令
- 可自動修 Bug
- 可跨多檔案操作
- 支援 Agent Workflow
官方描述 Claude Code 是一個:
AI-powered coding assistant that helps you build features, fix bugs, and automate development tasks.
為什麼大家開始用 Local LLM?
Local LLM 的優勢非常明顯:
| 功能 | 雲端模型 | Local LLM |
|---|
| 隱私 | 程式碼送雲端 | 完全本地 |
| 費用 | API Token 收費 | 幾乎免費 |
| 離線 | 不可 | 可 |
| 速度 | 看網路 | 本機 GPU |
| 自訂模型 | 有限制 | 完全自由 |
尤其現在 Ollama 已支援 Anthropic Messages API,相容 Claude Code。
方法一:Claude + VSCode + Ollama / LM Studio
這是目前最多人使用的方法。
架構圖
Claude Code ↓VSCode Extension ↓Ollama / LM Studio ↓Local LLM
安裝流程
Step 1:安裝 Claude Code
官方下載:
Claude Download 官方下載頁面
Linux / macOS:
curl -fsSL https://claude.ai/install.sh | bash
Step 2:安裝 Ollama
官方網站:
Ollama 官方網站
Linux:
curl -fsSL https://ollama.com/install.sh | sh
Step 3:下載模型
推薦模型:
或:
ollama pull deepseek-coder-v2
Step 4:啟動模型
LM Studio 使用方式
如果你不喜歡 CLI,可以使用 LM Studio。
LM Studio 官方網站
LM Studio 特點:
- GUI 操作
- 支援 OpenAI API
- 支援本地 Server
- 支援 GPU Offload
- Windows 體驗很好
有些使用者甚至認為 LM Studio 在 Windows + iGPU 上比 Ollama 更方便。
Claude Code 連接 Ollama
設定環境變數:
export ANTHROPIC_BASE_URL=http://localhost:11434
export ANTHROPIC_AUTH_TOKEN=your_token
export CLAUDE_CODE_EFFORT_LEVEL=low
執行:
Claude Code 即會透過 Ollama 使用本地模型。
方法二:使用 ollama launch claude
這是 Ollama 官方提供的整合方式。
官方文件:
Ollama Claude Code Integration 文件
安裝方式
更新 Ollama:
執行:
這會:
- 自動設定 Claude Code
- 自動串接 Anthropic-compatible API
- 使用本地模型
官方支援模型
目前官方文件中提到可搭配:
等模型。
方法三:使用 free-claude-code Gateway
GitHub:
free-claude-code GitHub 專案
這個專案本質上是一個:
Claude Code Gateway Proxy
它能:
- 將 Claude Code API 轉向 Local LLM
- 模擬 Anthropic API
- 轉接 Ollama / OpenAI API
- 避免官方限制
適合使用情境
非常適合:
- 本地 AI 開發環境
- 多模型切換
- 企業內網
- 私有化部署
- AI Coding Lab
啟動方式
通常為:
git clone https://github.com/Alishahryar1/free-claude-codecd free-claude-codenpm installnpm start
再讓 Claude Code 指向 Gateway。
啟用 Ollama Web Search 功能
Ollama 現在已支援 Web Search。
官方文件:
Ollama Web Search 文件
Web Search 功能用途
可以讓 Local LLM:
- 搜尋最新資訊
- 查 Stack Overflow
- 查 GitHub
- 查文件
- 即時查詢
這對 Claude Code 非常重要。
因為 Coding Agent 若沒有 Web Search:
- 容易使用舊知識
- 不知道最新版套件
- 不知道最新 API
啟用方式
通常:
或:
export OLLAMA_WEB_SEARCH=true
依照官方文件設定即可。
推薦 Local LLM 模型
程式開發最佳選擇
| 模型 | 推薦度 | 特點 |
|---|
| Qwen3-Coder | ★★★★★ | Coding 能力極強 |
| DeepSeek Coder V2 | ★★★★★ | 開源熱門 |
| GLM-5 | ★★★★☆ | 中文能力佳 |
| Kimi K2.5 | ★★★★☆ | 長上下文 |
| Gemma 3 | ★★★☆☆ | 輕量快速 |
Claude Code 常用指令
啟動 Claude Code
指定 API
ANTHROPIC_BASE_URL=http://localhost:11434 claude
指定模型
ANTHROPIC_MODEL=qwen3-coder claude
查看 Ollama 模型
啟動 Ollama Server
Ollama vs LM Studio 比較
| 功能 | Ollama | LM Studio |
|---|
| CLI | 強 | 普通 |
| GUI | 基本 | 非常完整 |
| Windows | 普通 | 非常好 |
| API | 強 | 強 |
| Docker | 強 | 普通 |
| GPU 管理 | CLI | GUI |
| 新手友善 | 中等 | 高 |
Claude Code + Local LLM 的實際優勢
1. 幾乎零成本
不再需要:
- Anthropic API
- OpenAI API
- Token 費用
2. 完全私有化
原始碼不離開本機。
非常適合:
3. 多模型自由切換
你可以:
- 今天用 Qwen
- 明天用 DeepSeek
- 後天用 Kimi
不受平台限制。
我的實際建議
如果你是:
新手
建議:
因為 GUI 最簡單。
Linux / DevOps / AI 工程師
建議:
CLI 整合能力非常強。
企業環境
建議:
free-claude-code Gateway + Ollama
可做到:
- API Gateway
- 多模型管理
- 權限控管
- 私有化部署
結論
Claude Code 正在快速成為下一代 AI Coding Agent。
而現在最重要的變化是:
Claude Code 已經不再只能綁定官方 Claude API。
透過:
- Ollama
- LM Studio
- free-claude-code
- Anthropic-compatible API
你已經可以:
- 完全本地化
- 零 API 成本
- 自由切換模型
- 保護原始碼隱私
對於 AI 開發者與工程團隊來說,這將是非常重要的開發趨勢。
下載資源
官方網站
參考資料
by Rain Chu | 5 月 13, 2026 | AI, Ollama, 模型
最新的 Qwen 3.6,在 Ollama 上的表現,可以說是目前「本地 Coding 模型」中非常強勢的一個系列。
如果你正在使用:
- NVIDIA Spark
- RTX 顯卡
- Ollama
- OpenWebUI
- Continue
- Claude Code
- OpenHands
- Hermes Agent
- Cursor 類工具
- Apple
那麼 Qwen 3.6 幾乎一定值得研究。
這篇文章會完整解析:
- Qwen 3.6 每個版本差異
- 27B 與 35B 的差異
- MXFP8、NVFP4、BF16 是什麼
- 哪個最適合寫程式
- NVIDIA Spark 最推薦的配置
- Ollama 部署建議
- 多人 SaaS / AI Agent 最佳實務
什麼是 Qwen 3.6?
Qwen 是阿里巴巴推出的大型語言模型(LLM)系列。
最新的 Qwen 3.6,官方特別強調:
- Agentic Coding
- Repository-level Reasoning
- 長 Context 推理
- Thinking Preservation
也就是說:
它不只是會寫程式,而是開始能理解「整個專案」。
根據官方與 Ollama 頁面資訊,Qwen 3.6 在以下方面有明顯提升:
- 前端工作流理解
- 多檔案推理
- AI Agent Tool Calling
- 長上下文理解
- 歷史推理保留
- Repository 級別程式分析
為什麼 Qwen 3.6 很適合 Ollama?
Qwen 3.6 最大特色之一:
就是對本地部署非常友善。
目前 Ollama 已提供大量版本:
- 27B
- 35B-A3B
- Coding 版本
- Vision 版本
- MXFP8
- NVFP4
- BF16
- MLX
而且幾乎都支援:
- 256K Context
- 長文本推理
- 本地 AI Agent
- Coding Workflow
Qwen 3.6 各版本意思解析
qwen3.6:latest
這是官方最新預設版本。
特色:
適合:
但:
不是最強的 Coding 版本。
qwen3.6:27b
27B = 270億參數。
這是目前非常熱門的甜蜜點。
優點:
- Coding 能力很強
- 推理速度快
- VRAM 壓力較低
- 多人共享容易
非常適合:
- Continue
- Claude Code
- VSCode AI
- Agent Workflow
- 本地 Copilot
qwen3.6:35b
35B = 350億參數。
這類模型:
推理能力更強。
尤其在:
- 大型專案理解
- 架構設計
- Refactor
- 多檔案分析
會比 27B 更好。
但缺點:
什麼是 Coding 版本?
例如:
- qwen3.6:27b-coding-mxfp8
- qwen3.6:35b-a3b-coding-nvfp4
這些是:
專門針對寫程式優化的模型。
相較一般聊天模型:
它們更擅長:
- Python
- TypeScript
- Go
- Rust
- Docker
- Shell
- Kubernetes
- Debug
- Refactor
- AI Agent Tool Calling
官方也特別提到:
Qwen 3.6 在 Agentic Coding 與 Repository-level reasoning 上有大幅提升。
MXFP8、NVFP4、BF16 是什麼?
很多人看到:
會很混亂。
其實這些都是:
「量化格式」。
MXFP8
例如:
qwen3.6:27b-coding-mxfp8
這是 NVIDIA 新世代 FP8 格式。
特色:
- 品質高
- VRAM 使用合理
- 推理速度快
- 非常適合 NVIDIA GPU
目前很多人認為:
MXFP8 是本地 AI Coding 的最佳甜蜜點。
尤其適合:
- NVIDIA Spark
- RTX 4090
- RTX 5090
- 多 Agent Workflow
NVFP4
例如:
qwen3.6:27b-coding-nvfp4
這是 NVIDIA 的 4-bit 浮點量化格式。
特色:
但:
推理品質會稍微下降。
比較適合:
- SaaS 平台
- 多人 AI IDE
- 高併發 Agent
目前學術研究也開始針對 NVFP4 做最佳化。
BF16
例如:
qwen3.6:27b-coding-bf16
這幾乎是:
接近原始精度。
優點:
- 品質最高
- reasoning 最穩
- hallucination 較少
缺點:
適合:
MLX 是什麼?
MLX 是 Apple Silicon 專用。
例如:
什麼是 A3B?
例如:
qwen3.6:35b-a3b-coding-mxfp8
這代表:
MoE(Mixture of Experts)架構。
意思是:
模型總參數很大,但每次只啟用部分專家。
優點:
官方指出:
Qwen3.6-35B-A3B 僅啟動約 3B Active Parameters,但依然能超越部分大型 Dense 模型。
NVIDIA Spark 最推薦哪個?
如果你的環境是:
- NVIDIA Spark
- CUDA 13
- 128GB RAM
- Ollama
- OpenWebUI
- Continue
- Claude Code
- OpenHands
那我目前最推薦:
🥇 最推薦:qwen3.6:27b-coding-mxfp8
推薦原因:
- Coding 非常強
- 推理速度快
- VRAM 不容易爆
- Agent 很穩
- 長 Context 表現好
- 本地部署平衡最佳
這是目前真正的:
「Production Sweet Spot」。
🥈 高階推理推薦:qwen3.6:35b-a3b-coding-mxfp8
適合:
- AI Agent
- 大型專案
- 架構設計
- 多 Repo 分析
優點:
- reasoning 更強
- repository 理解更強
- 複雜任務更穩
缺點:
🥉 多人 SaaS 推薦:qwen3.6:27b-coding-nvfp4
適合:
- 多人共享
- SaaS
- AI IDE
- 高併發 Agent
優點:
但:
品質會略低於 MXFP8。
我自己的實戰看法
如果你是:
「真正要拿來工作」。
我目前認為:
Qwen 3.6 已經開始接近:
「本地版 Claude Code」。
尤其:
27B Coding MXFP8。
真的已經非常強。
它最大的優勢不是單純寫程式。
而是:
- 能理解整個 Repo
- 能做 Agent 工作流
- 能做長 Context reasoning
- 能做 Tool Calling
- 能理解大型專案
這跟以前單純「補程式碼」的模型完全不同。
Ollama 部署建議
安裝模型
ollama pull qwen3.6:27b-coding-mxfp8
執行模型
ollama run qwen3.6:27b-coding-mxfp8
開放 API
export OLLAMA_HOST=0.0.0.0:11434
NVIDIA Spark 最佳化建議
建議環境變數:
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_NUM_PARALLEL=4"
Environment="OLLAMA_MAX_LOADED_MODELS=3"
Environment="OLLAMA_MAX_QUEUE=1024"
Environment="OLLAMA_KEEP_ALIVE=-1"
Environment="OLLAMA_FLASH_ATTENTION=1"
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="OMP_NUM_THREADS=32"
適合搭配的工具
Qwen 3.6 很適合:
- Continue
- Claude Code
- OpenHands
- Hermes Agent
- OpenWebUI
- Cursor 類工具
- Browser-use
- AI Agent Workflow
結論
如果你現在想打造:
- 本地 AI Coding 環境
- AI Agent 平台
- 多人 AI IDE
- 本地 Claude Code
- Ollama SaaS
那麼:
Qwen 3.6 幾乎是目前最值得研究的一條路。
尤其:
qwen3.6:27b-coding-mxfp8
我認為:
這是目前 NVIDIA Spark 上:
最平衡、最實用、最值得長期使用的本地 Coding 模型之一。
參考資料
by rainchu | 12 月 17, 2025 | AI, Llama, Ollama, 模型
前言:為什麼要做「影片偵探」?
隨著監控攝影機、行車紀錄器、門禁與智慧製造設備的大量部署,「影片」早已成為企業與政府最重要的資料來源之一。然而,影片資料無法被傳統文字搜尋系統理解,長時間的人工作業也帶來極高成本。
這正是「影片偵探(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 架構:
- 影格抽取(Frame Sampling)
- 物件 / 人臉偵測(YOLO / yolo face)
- 視覺語意理解(Llama-3.2-90B-Vision)
- 語意摘要與事件推論
- 自然語言查詢與回應
使用者可以直接詢問:
「這段影片中,有沒有可疑人物?」
「哪一段出現未授權進入?」
「請總結這 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 使用方式
如果暫時沒有高階 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。
概念請長這樣:
- 把影格讀成 base64 或直接用 Ollama SDK 的 image 欄位
- prompt 內含 timestamp、camera_id、detections
- 要求模型「輸出 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:事件彙整 + 建索引(讓你能「用中文搜影片」)
你至少要存三種資料:
- frame-level:每張影格摘要 + detections
- event-level:跨影格彙整(start/end、actors、evidence)
- 索引:
- 向量索引(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 >= 20s 且 avg_speed <= 15 px/s
- 可擴充「徘徊」:
- 在 zone 內來回穿越同一條小線段超過 K 次
- 或在 zone 內出入(進/出)頻繁
2) 追蹤器接 YOLO(track_id 穩定)
A. 建議組合
- 偵測:YOLO(person/vehicle/bag…)
- 追蹤:ByteTrack(速度快、穩)或 DeepSORT(更重但 ReID 強)
- ID 穩定策略:
- 只追固定類別:例如只追 person/vehicle(別把雜訊類也追)
- 信心值門檻 + NMS:減少跳框
- 遮擋容忍:追蹤器允許短暫 lost 再接回
- 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)
- 關鍵字/BM25:找「後門」「白色車」「紅帽」這種明確詞
- 向量/語意:找「徘徊」「鬼鬼祟祟」「疑似踩點」這種抽象語意
- 合併 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
回傳 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
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
近期留言