Select Page

使用 Claude Code 搭配 LM Studio 與 Ollama:打造零 API 成本 AI 開發環境

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 qwen3-coder

或:

ollama pull deepseek-coder-v2

Step 4:啟動模型

ollama run qwen3-coder

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

Claude Code 即會透過 Ollama 使用本地模型。


方法二:使用 ollama launch claude

這是 Ollama 官方提供的整合方式。

官方文件:

Ollama Claude Code Integration 文件


安裝方式

更新 Ollama:

ollama update

執行:

ollama launch claude

這會:

  • 自動設定 Claude Code
  • 自動串接 Anthropic-compatible API
  • 使用本地模型

官方支援模型

目前官方文件中提到可搭配:

  • qwen3.5
  • glm-5
  • kimi-k2.5

等模型。


方法三:使用 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

啟用方式

通常:

OLLAMA_WEB_SEARCH=true

或:

export OLLAMA_WEB_SEARCH=true

依照官方文件設定即可。


推薦 Local LLM 模型

程式開發最佳選擇

模型推薦度特點
Qwen3-Coder★★★★★Coding 能力極強
DeepSeek Coder V2★★★★★開源熱門
GLM-5★★★★☆中文能力佳
Kimi K2.5★★★★☆長上下文
Gemma 3★★★☆☆輕量快速

Claude Code 常用指令

啟動 Claude Code

claude

指定 API

ANTHROPIC_BASE_URL=http://localhost:11434 claude

指定模型

ANTHROPIC_MODEL=qwen3-coder claude

查看 Ollama 模型

ollama list

啟動 Ollama Server

ollama serve

Ollama vs LM Studio 比較

功能OllamaLM Studio
CLI普通
GUI基本非常完整
Windows普通非常好
API
Docker普通
GPU 管理CLIGUI
新手友善中等

Claude Code + Local LLM 的實際優勢

1. 幾乎零成本

不再需要:

  • Anthropic API
  • OpenAI API
  • Token 費用

2. 完全私有化

原始碼不離開本機。

非常適合:

  • 企業
  • 資安環境
  • NDA 專案
  • 內網系統

3. 多模型自由切換

你可以:

  • 今天用 Qwen
  • 明天用 DeepSeek
  • 後天用 Kimi

不受平台限制。


我的實際建議

如果你是:

新手

建議:

LM Studio + Claude Code

因為 GUI 最簡單。


Linux / DevOps / AI 工程師

建議:

Ollama + Claude Code

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 開發者與工程團隊來說,這將是非常重要的開發趨勢。


下載資源

官方網站


參考資料

Ollama + Qwen 3.6 怎麼選?27B、35B、MXFP8、NVFP4 完整比較與推薦

最新的 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

這是官方最新預設版本。

特色:

  • 通用型
  • 支援圖片
  • 適合聊天與分析
  • 多模態能力

適合:

  • OpenWebUI
  • AI 助理
  • OCR
  • 圖片分析

但:

不是最強的 Coding 版本。


qwen3.6:27b

27B = 270億參數。

這是目前非常熱門的甜蜜點。

優點:

  • Coding 能力很強
  • 推理速度快
  • VRAM 壓力較低
  • 多人共享容易

非常適合:

  • Continue
  • Claude Code
  • VSCode AI
  • Agent Workflow
  • 本地 Copilot

qwen3.6:35b

35B = 350億參數。

這類模型:

推理能力更強。

尤其在:

  • 大型專案理解
  • 架構設計
  • Refactor
  • 多檔案分析

會比 27B 更好。

但缺點:

  • 更吃 VRAM
  • 速度較慢
  • 成本較高

什麼是 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
  • 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 浮點量化格式。

特色:

  • 更省 VRAM
  • 更快
  • 可多人共享
  • 吞吐量高

但:

推理品質會稍微下降。

比較適合:

  • SaaS 平台
  • 多人 AI IDE
  • 高併發 Agent

目前學術研究也開始針對 NVFP4 做最佳化。


BF16

例如:

qwen3.6:27b-coding-bf16

這幾乎是:

接近原始精度。

優點:

  • 品質最高
  • reasoning 最穩
  • hallucination 較少

缺點:

  • 超級吃 VRAM
  • 非常耗記憶體
  • 多人共享困難

適合:

  • 單人高品質開發
  • 研究用途
  • 極限推理

MLX 是什麼?

MLX 是 Apple Silicon 專用。

例如:

  • M1
  • M2
  • M3
  • M4

什麼是 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 理解更強
  • 複雜任務更穩

缺點:

  • 比較慢
  • VRAM 需求更高

🥉 多人 SaaS 推薦:qwen3.6:27b-coding-nvfp4

適合:

  • 多人共享
  • SaaS
  • AI IDE
  • 高併發 Agent

優點:

  • 非常省 VRAM
  • 吞吐量高
  • 可同時服務多人

但:

品質會略低於 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 模型之一。

參考資料

Nvidia Spark Ollama 的滿血設定

🧱 直接給「滿血設定」

你現在 service 改成這個👇(最重要)

[Unit]
Description=Ollama Service
After=network-online.target[Service]
ExecStart=/usr/local/bin/ollama serve
User=ollama
Group=ollama
Restart=always
RestartSec=3# ===== 核心 =====
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_KEEP_ALIVE=-1"# ===== GPU 強制 =====
Environment="OLLAMA_GPU_LAYERS=999"# ===== 記憶體優化 =====
Environment="OLLAMA_KV_CACHE_TYPE=q8_0"
Environment="OLLAMA_FLASH_ATTENTION=1"# ===== Spark專用 tuning =====
Environment="OLLAMA_NUM_PARALLEL=2"
Environment="OLLAMA_MAX_LOADED_MODELS=2"
Environment="OLLAMA_MAX_QUEUE=512"# ===== CPU 控制 =====
Environment="OMP_NUM_THREADS=20"# ===== PATH =====
Environment="PATH=/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"[Install]
WantedBy=multi-user.target

🔁 套用

sudo systemctl daemon-reexec
sudo systemctl daemon-reload
sudo systemctl restart ollama

🔍 一定要做驗證

systemctl show ollama | grep OLLAMA

👉 要看到全部變數


🧠 模型才是性能關鍵

🔥 Spark 正確用法

用途模型
最快llama3:8b
平衡llama3:13b
大模型30B(單一)

✅ 先做這個測試(非常重要)

ollama run llama3

👉 再看:

nvidia-smi

🔥 讓 GPU 真的滿血(關鍵3件事)

① 模型「完全進 GPU」

👉 關鍵判斷:

ollama ps

看到:

100% GPU

👉 才算成功


② context 不要亂開

👉 Ollama 預設 4096

👉 你如果開到:

  • 32K / 128K

👉 = 直接 CPU fallback


👉 建議:

ollama run llama3 --num_ctx 4096

③ 不要多模型併發

Spark 特性:

👉 記憶體大,但 bandwidth 普通

👉 所以:

OLLAMA_NUM_PARALLEL=2

是最佳解


🧠 Spark 的本質

👉 DGX Spark

  • 128GB memory ✔
  • 超大模型可跑 ✔
  • ❌ 不是高吞吐 GPU

👉 正確定位:

🔥 大模型單機推理機


🧪 接下就會看到的改善

調完後:

指標改善
GPU Util0% → 80%+
token/sec↑ 3~10倍
latency↓ 50%以上
CPU

🎯 注意事項

👉 ❗不要讓使用者直接打 Ollama

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! – 雨


Deprecated: explode(): Passing null to parameter #2 ($string) of type string is deprecated in C:\inetpub\raintips\wp-content\themes\Divi\core\functions.php on line 1613

Deprecated: explode(): Passing null to parameter #2 ($string) of type string is deprecated in C:\inetpub\raintips\wp-content\themes\Divi\core\functions.php on line 1613

Deprecated: explode(): Passing null to parameter #2 ($string) of type string is deprecated in C:\inetpub\raintips\wp-content\themes\Divi\core\functions.php on line 1613
Llama-3.2-90B-Vision 實戰:本地端影片偵探與多模態分析完整解析

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


Deprecated: explode(): Passing null to parameter #2 ($string) of type string is deprecated in C:\inetpub\raintips\wp-content\themes\Divi\core\functions.php on line 1613

Deprecated: explode(): Passing null to parameter #2 ($string) of type string is deprecated in C:\inetpub\raintips\wp-content\themes\Divi\core\functions.php on line 1613

Deprecated: explode(): Passing null to parameter #2 ($string) of type string is deprecated in C:\inetpub\raintips\wp-content\themes\Divi\core\functions.php on line 1613

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

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

這正是「影片偵探(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