b3608f534e
- 新增 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、截图、备份文件
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
"""在 motion_filter 找出的活动窗口内跑 YOLO11n 检测 + ByteTrack 跟踪,
|
|
统计人物出现次数,写入 events 表(event_type='person_presence')。
|
|
|
|
一个 track(同一个人从出现到消失的连续跟踪片段)对应一条 person_presence
|
|
事件。只在活动窗口内跑,空场景(motion_windows_json 为空)直接跳过,不跑模型。
|
|
"""
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
|
|
import cv2
|
|
|
|
from analysis.config import (
|
|
PERSON_MODEL_NAME,
|
|
PERSON_DEVICE,
|
|
PERSON_CONF_THRESHOLD,
|
|
PERSON_SAMPLE_STRIDE,
|
|
PERSON_MIN_TRACK_DURATION_SEC,
|
|
PROJECT_ROOT,
|
|
)
|
|
from analysis.db import get_all_videos, insert_event, update_video_status
|
|
|
|
_model = None
|
|
|
|
|
|
def _get_model():
|
|
"""YOLO模型只在真正用到时才加载(避免只跑无活动视频时也触发权重下载/加载)。"""
|
|
global _model
|
|
if _model is None:
|
|
from ultralytics import YOLO
|
|
|
|
_model = YOLO(PERSON_MODEL_NAME)
|
|
return _model
|
|
|
|
|
|
def _track_windows(file_path, windows, fps):
|
|
"""在给定的活动窗口内跑检测+跟踪,返回 [(track_id, start_sec, end_sec, max_conf), ...]。"""
|
|
model = _get_model()
|
|
cap = cv2.VideoCapture(str(file_path))
|
|
if not cap.isOpened():
|
|
return []
|
|
|
|
spans = {} # (window_index, track_id) -> [min_t, max_t, max_conf]
|
|
|
|
for window_index, (start_sec, end_sec) in enumerate(windows):
|
|
start_frame = int(start_sec * fps)
|
|
end_frame = int(end_sec * fps)
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
|
|
|
frame_idx = start_frame
|
|
first_call = True
|
|
while frame_idx <= end_frame:
|
|
ret, frame = cap.read()
|
|
if not ret:
|
|
break
|
|
|
|
if (frame_idx - start_frame) % PERSON_SAMPLE_STRIDE == 0:
|
|
# persist=False 强制在每个窗口开头重置跟踪器状态,跟踪器内部的track_id计数器
|
|
# 也会跟着从1重新开始——不同窗口间同一个数字的track_id并不代表同一个人,
|
|
# 所以下面的 spans key 必须带上 window_index,否则会把不同窗口的片段错误合并
|
|
results = model.track(
|
|
frame, persist=not first_call, classes=[0], conf=PERSON_CONF_THRESHOLD,
|
|
tracker="bytetrack.yaml", device=PERSON_DEVICE, verbose=False,
|
|
)
|
|
first_call = False
|
|
|
|
boxes = results[0].boxes
|
|
if boxes is not None and boxes.id is not None:
|
|
t = frame_idx / fps
|
|
for track_id, conf in zip(boxes.id.tolist(), boxes.conf.tolist()):
|
|
key = (window_index, int(track_id))
|
|
if key not in spans:
|
|
spans[key] = [t, t, conf]
|
|
else:
|
|
spans[key][1] = t
|
|
spans[key][2] = max(spans[key][2], conf)
|
|
|
|
frame_idx += 1
|
|
|
|
cap.release()
|
|
return [
|
|
(track_id, start, end, conf)
|
|
for (_window_index, track_id), (start, end, conf) in spans.items()
|
|
if (end - start) >= PERSON_MIN_TRACK_DURATION_SEC
|
|
]
|
|
|
|
|
|
def process_video(video):
|
|
"""返回 (track_count, failed)。failed=True 表示该视频处理异常已标记 person_failed。"""
|
|
try:
|
|
windows = json.loads(video["motion_windows_json"] or "[]")
|
|
except (json.JSONDecodeError, TypeError):
|
|
windows = []
|
|
if not windows:
|
|
update_video_status(video["id"], "person_done")
|
|
return 0, False
|
|
|
|
file_path = PROJECT_ROOT / video["file_path"]
|
|
if not file_path.exists():
|
|
update_video_status(video["id"], "person_failed", error_message="文件不存在")
|
|
return 0, True
|
|
|
|
try:
|
|
fps = video["fps"] or 15.0
|
|
tracks = _track_windows(file_path, windows, fps)
|
|
except Exception as e:
|
|
update_video_status(video["id"], "person_failed", error_message=str(e))
|
|
return 0, True
|
|
|
|
start_time = None
|
|
if video["start_time"]:
|
|
try:
|
|
start_time = datetime.strptime(video["start_time"], "%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
pass # 只精确到天的粗粒度值("YYYY-MM-DD"),无法算出绝对时间戳,timestamp_abs留空
|
|
|
|
for track_id, start_sec, end_sec, conf in tracks:
|
|
timestamp_abs = (start_time + timedelta(seconds=start_sec)).isoformat() if start_time else None
|
|
insert_event({
|
|
"video_id": video["id"],
|
|
"event_type": "person_presence",
|
|
"label": None,
|
|
"start_offset_sec": start_sec,
|
|
"end_offset_sec": end_sec,
|
|
"timestamp_abs": timestamp_abs,
|
|
"confidence": conf,
|
|
"track_id": track_id,
|
|
"thumbnail_path": None,
|
|
"extra_json": None,
|
|
"created_at": datetime.now().isoformat(),
|
|
})
|
|
|
|
update_video_status(video["id"], "person_done")
|
|
return len(tracks), False
|
|
|
|
|
|
def process_all(limit=None):
|
|
videos = get_all_videos(status="motion_done")
|
|
if limit:
|
|
videos = videos[:limit]
|
|
|
|
processed = 0
|
|
total_tracks = 0
|
|
failed = 0
|
|
for video in videos:
|
|
tracks, is_failed = process_video(video)
|
|
total_tracks += tracks
|
|
if is_failed:
|
|
failed += 1
|
|
processed += 1
|
|
|
|
return {"processed": processed, "total_tracks": total_tracks, "failed": failed}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("===== 开始统计人物出现次数 =====")
|
|
result = process_all()
|
|
print(f"→ 处理 {result['processed']} 个视频,共识别出 {result['total_tracks']} 段人物出现")
|
|
print("===== 统计完成 =====")
|