feat: 新增视频分析流水线、Web界面、孤儿清理工具;修复健壮性问题
- 新增 analysis/ 模块:MOG2活动检测、YOLO人物跟踪、MediaPipe摔倒检测、Ollama活动打标、缩略图抽取 - 新增 webapp/:Flask事件浏览界面,按需HEVC→H264转码缓存 - 新增 analyze.py:7阶段状态机流水线(scan→backfill→motion→person→fall→activity→thumbnail) - 新增 cleanup_orphans.py:清理文件已删除的数据库孤儿记录 - 修复:motion/person/activity三阶段加单视频异常兜底,避免批次中断 - 修复:ollama_client加健康检查和3次重试,num_ctx从8192降到2048 - 修复:transcode_cache加fcntl文件锁,防止并发转码同一视频 - 修复:SQLite开WAL模式+busy_timeout,支持读写并发 - 修复:sync.py ffmpeg路径改为shutil.which查找,删除automate.py死代码PYTHON - 修复:thumbnail失败加3次重试上限,避免无限重试损坏文件 - 新增:requirements.lock锁定版本、README文档 - 更新:.gitignore排除.playwright-mcp、截图、备份文件
This commit is contained in:
+106
@@ -0,0 +1,106 @@
|
||||
"""本地事件展示网页:纯只读查询 analysis.db + 播放原始录像文件,
|
||||
不写入任何数据,可以在 analyze.py 跑到一半时随时启动查看已完成的部分。
|
||||
"""
|
||||
from flask import Flask, jsonify, render_template, request, send_file
|
||||
|
||||
from analysis.config import PROJECT_ROOT
|
||||
from analysis.db import get_connection
|
||||
from webapp import transcode_cache
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
EVENT_TYPES = ["person_presence", "fall", "activity"]
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/events")
|
||||
def api_events():
|
||||
event_type = request.args.get("event_type")
|
||||
capture_date = request.args.get("date") # YYYYMMDD
|
||||
|
||||
query = """
|
||||
SELECT e.id, e.video_id, e.event_type, e.label, e.start_offset_sec,
|
||||
e.end_offset_sec, e.timestamp_abs, e.confidence, e.thumbnail_path,
|
||||
v.capture_date, v.file_name
|
||||
FROM events e JOIN videos v ON v.id = e.video_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
if event_type in EVENT_TYPES:
|
||||
query += " AND e.event_type = ?"
|
||||
params.append(event_type)
|
||||
if capture_date:
|
||||
query += " AND v.capture_date = ?"
|
||||
params.append(capture_date)
|
||||
query += " ORDER BY e.timestamp_abs DESC, e.id DESC LIMIT 500"
|
||||
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
events = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"video_id": r["video_id"],
|
||||
"event_type": r["event_type"],
|
||||
"label": r["label"],
|
||||
"start_offset_sec": r["start_offset_sec"],
|
||||
"end_offset_sec": r["end_offset_sec"],
|
||||
"timestamp_abs": r["timestamp_abs"],
|
||||
"confidence": r["confidence"],
|
||||
"capture_date": r["capture_date"],
|
||||
"file_name": r["file_name"],
|
||||
"thumbnail_url": f"/media/thumb/{r['id']}" if r["thumbnail_path"] else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return jsonify(events)
|
||||
|
||||
|
||||
@app.route("/api/stats")
|
||||
def api_stats():
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT event_type, COUNT(*) AS c FROM events GROUP BY event_type"
|
||||
).fetchall()
|
||||
return jsonify({r["event_type"]: r["c"] for r in rows})
|
||||
|
||||
|
||||
@app.route("/media/<int:video_id>/stream")
|
||||
def media_stream(video_id):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute("SELECT file_path FROM videos WHERE id = ?", (video_id,)).fetchone()
|
||||
if row is None:
|
||||
return "视频不存在", 404
|
||||
|
||||
try:
|
||||
play_path = transcode_cache.ensure_transcoded(video_id, PROJECT_ROOT / row["file_path"])
|
||||
except RuntimeError as e:
|
||||
return f"转码失败:{e}", 500
|
||||
return send_file(play_path, conditional=True)
|
||||
|
||||
|
||||
@app.route("/api/cache/stats")
|
||||
def api_cache_stats():
|
||||
return jsonify(transcode_cache.cache_stats())
|
||||
|
||||
|
||||
@app.route("/api/cache/clear", methods=["POST"])
|
||||
def api_cache_clear():
|
||||
return jsonify(transcode_cache.clear_cache())
|
||||
|
||||
|
||||
@app.route("/media/thumb/<int:event_id>")
|
||||
def media_thumb(event_id):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute("SELECT thumbnail_path FROM events WHERE id = ?", (event_id,)).fetchone()
|
||||
if row is None or not row["thumbnail_path"]:
|
||||
return "缩略图不存在", 404
|
||||
return send_file(PROJECT_ROOT / row["thumbnail_path"], conditional=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="127.0.0.1", port=5050, debug=False)
|
||||
@@ -0,0 +1,108 @@
|
||||
const eventTypeFilter = document.getElementById("event-type-filter");
|
||||
const dateFilter = document.getElementById("date-filter");
|
||||
const eventList = document.getElementById("event-list");
|
||||
const player = document.getElementById("player");
|
||||
const playerInfo = document.getElementById("player-info");
|
||||
const statsEl = document.getElementById("stats");
|
||||
const cacheInfoEl = document.getElementById("cache-info");
|
||||
const clearCacheBtn = document.getElementById("clear-cache-btn");
|
||||
|
||||
const EVENT_TYPE_LABELS = {
|
||||
person_presence: "人物出现",
|
||||
fall: "摔倒",
|
||||
activity: "活动",
|
||||
};
|
||||
|
||||
function formatEventLabel(event) {
|
||||
const typeName = EVENT_TYPE_LABELS[event.event_type] || event.event_type;
|
||||
return event.label ? `${typeName} · ${event.label}` : typeName;
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const res = await fetch("/api/stats");
|
||||
const stats = await res.json();
|
||||
const parts = Object.entries(stats).map(
|
||||
([type, count]) => `${EVENT_TYPE_LABELS[type] || type}: ${count}`
|
||||
);
|
||||
statsEl.textContent = parts.join(" | ");
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function loadCacheStats() {
|
||||
const res = await fetch("/api/cache/stats");
|
||||
const stats = await res.json();
|
||||
cacheInfoEl.textContent = `转码缓存:${stats.file_count} 个文件,共占用 ${formatBytes(stats.total_size_bytes)}`;
|
||||
}
|
||||
|
||||
clearCacheBtn.addEventListener("click", async () => {
|
||||
if (!confirm("确认清空所有转码缓存吗?清空后再次播放这些视频需要重新转码。")) return;
|
||||
const res = await fetch("/api/cache/clear", { method: "POST" });
|
||||
const result = await res.json();
|
||||
alert(`已清理 ${result.deleted} 个文件,释放 ${formatBytes(result.freed_bytes)} 空间`);
|
||||
loadCacheStats();
|
||||
});
|
||||
|
||||
async function loadEvents() {
|
||||
const params = new URLSearchParams();
|
||||
if (eventTypeFilter.value) params.set("event_type", eventTypeFilter.value);
|
||||
if (dateFilter.value) params.set("date", dateFilter.value.replaceAll("-", ""));
|
||||
|
||||
const res = await fetch(`/api/events?${params.toString()}`);
|
||||
const events = await res.json();
|
||||
renderEventList(events);
|
||||
}
|
||||
|
||||
function renderEventList(events) {
|
||||
eventList.innerHTML = "";
|
||||
for (const event of events) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "event-item";
|
||||
|
||||
const thumb = document.createElement("img");
|
||||
thumb.className = "event-thumb";
|
||||
thumb.alt = formatEventLabel(event);
|
||||
if (event.thumbnail_url) thumb.src = event.thumbnail_url;
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "event-info";
|
||||
info.innerHTML = `
|
||||
<div class="event-type">${formatEventLabel(event)}</div>
|
||||
<div class="event-time">${event.timestamp_abs || event.capture_date}</div>
|
||||
`;
|
||||
|
||||
item.appendChild(thumb);
|
||||
item.appendChild(info);
|
||||
item.addEventListener("click", () => playEvent(event));
|
||||
eventList.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function playEvent(event) {
|
||||
const isSameVideo = player.dataset.videoId === String(event.video_id);
|
||||
player.dataset.videoId = event.video_id;
|
||||
|
||||
const seekAndPlay = () => {
|
||||
player.currentTime = event.start_offset_sec || 0;
|
||||
player.play();
|
||||
};
|
||||
|
||||
if (isSameVideo) {
|
||||
seekAndPlay();
|
||||
} else {
|
||||
player.src = `/media/${event.video_id}/stream`;
|
||||
player.addEventListener("loadedmetadata", () => { seekAndPlay(); loadCacheStats(); }, { once: true });
|
||||
}
|
||||
playerInfo.textContent = `${event.file_name} · ${formatEventLabel(event)}`;
|
||||
}
|
||||
|
||||
eventTypeFilter.addEventListener("change", loadEvents);
|
||||
dateFilter.addEventListener("change", loadEvents);
|
||||
|
||||
loadStats();
|
||||
loadCacheStats();
|
||||
loadEvents();
|
||||
@@ -0,0 +1,96 @@
|
||||
body {
|
||||
font-family: -apple-system, "PingFang SC", sans-serif;
|
||||
margin: 20px;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#stats {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.cache-panel {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.cache-panel button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.player-panel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#player {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
#player-info {
|
||||
margin-top: 8px;
|
||||
color: #555;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
flex: 1;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.event-thumb {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
object-fit: cover;
|
||||
background: #ddd;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-type {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>录像事件回放</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>录像事件回放</h1>
|
||||
|
||||
<div class="filters">
|
||||
<label>事件类型
|
||||
<select id="event-type-filter">
|
||||
<option value="">全部</option>
|
||||
<option value="person_presence">人物出现</option>
|
||||
<option value="fall">摔倒</option>
|
||||
<option value="activity">活动</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>日期
|
||||
<input type="date" id="date-filter">
|
||||
</label>
|
||||
<span id="stats"></span>
|
||||
</div>
|
||||
|
||||
<div class="cache-panel">
|
||||
<span id="cache-info"></span>
|
||||
<button id="clear-cache-btn">清空转码缓存</button>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="player-panel">
|
||||
<video id="player" controls></video>
|
||||
<div id="player-info"></div>
|
||||
</div>
|
||||
<div id="event-list" class="event-list"></div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,96 @@
|
||||
"""网页播放的按需转码缓存:源文件mp4容器HEVC轨道sample entry编码标签缺失,
|
||||
浏览器<video>标签无法识别,第一次点击播放时用ffmpeg(VideoToolbox硬件加速)转码成
|
||||
标准H264并缓存到 TRANSCODED_DIR,之后复用缓存不重复转码;不预先转码全部录像。
|
||||
"""
|
||||
import fcntl
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from analysis.config import TRANSCODED_DIR
|
||||
|
||||
|
||||
def cached_path(video_id):
|
||||
return TRANSCODED_DIR / f"{video_id}.mp4"
|
||||
|
||||
|
||||
def _lock_path(video_id):
|
||||
return TRANSCODED_DIR / f"{video_id}.lock"
|
||||
|
||||
|
||||
def ensure_transcoded(video_id, source_path):
|
||||
"""返回可直接播放的文件路径。已有缓存直接复用,否则先转码再缓存。
|
||||
|
||||
用 fcntl 文件锁防止两个请求同时转码同一个视频:先到的转码,后到的等锁释放后
|
||||
直接读缓存。锁文件在转码完成后删除。
|
||||
"""
|
||||
output_path = cached_path(video_id)
|
||||
if output_path.exists():
|
||||
return output_path
|
||||
|
||||
TRANSCODED_DIR.mkdir(exist_ok=True)
|
||||
lock_file = _lock_path(video_id)
|
||||
lock_fd = open(lock_file, "w")
|
||||
|
||||
try:
|
||||
# 阻塞等待锁,最多等 10 分钟(转码大文件可能慢)
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
|
||||
# 拿到锁后再检查一次:可能前面的进程已经转码完成
|
||||
if output_path.exists():
|
||||
return output_path
|
||||
|
||||
tmp_path = output_path.with_suffix(".mp4.tmp")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-i", str(source_path),
|
||||
"-c:v", "h264_videotoolbox", "-b:v", "1500k",
|
||||
"-c:a", "aac",
|
||||
"-movflags", "+faststart",
|
||||
"-f", "mp4", # 输出文件名带.tmp后缀,ffmpeg无法从扩展名猜出格式,需显式指定
|
||||
"-y", str(tmp_path),
|
||||
],
|
||||
capture_output=True, timeout=600,
|
||||
)
|
||||
if result.returncode != 0 or not tmp_path.exists():
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise RuntimeError(result.stderr.decode("utf-8", errors="ignore"))
|
||||
|
||||
tmp_path.rename(output_path) # 转码完成后原子改名,避免并发请求读到半成品文件
|
||||
return output_path
|
||||
finally:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
lock_fd.close()
|
||||
lock_file.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def cache_stats():
|
||||
if not TRANSCODED_DIR.exists():
|
||||
return {"file_count": 0, "total_size_bytes": 0, "files": []}
|
||||
|
||||
files = []
|
||||
total = 0
|
||||
for f in TRANSCODED_DIR.glob("*.mp4"):
|
||||
size = f.stat().st_size
|
||||
total += size
|
||||
files.append({"video_id": int(f.stem), "size_bytes": size})
|
||||
|
||||
return {"file_count": len(files), "total_size_bytes": total, "files": files}
|
||||
|
||||
|
||||
def clear_cache():
|
||||
if not TRANSCODED_DIR.exists():
|
||||
return {"deleted": 0, "freed_bytes": 0}
|
||||
|
||||
deleted = 0
|
||||
freed = 0
|
||||
for f in TRANSCODED_DIR.glob("*.mp4"):
|
||||
freed += f.stat().st_size
|
||||
f.unlink()
|
||||
deleted += 1
|
||||
# 清理残留的 .tmp 和 .lock 文件
|
||||
for f in TRANSCODED_DIR.glob("*.tmp"):
|
||||
f.unlink(missing_ok=True)
|
||||
for f in TRANSCODED_DIR.glob("*.lock"):
|
||||
f.unlink(missing_ok=True)
|
||||
|
||||
return {"deleted": deleted, "freed_bytes": freed}
|
||||
Reference in New Issue
Block a user