[重构] 运动事件驱动架构 - 整段素材按 ss_motion_events 分割运动片段再分析(不再整段送云端): oracle_db 加 motion_event_id/camera_id 列 + get_motion_events_in_range/has_unfinished_motion_in_range/get_video_by_motion_event_id; video_processor 素材→分割/片段→分析双分支(ffmpeg -c:v copy -c:a aac 保留音频); video_queue 片段入队; config 加 motion_segment 块

This commit is contained in:
ericwyuan
2026-08-22 12:19:17 +08:00
parent 2d9d96de56
commit a1523b46c8
5 changed files with 372 additions and 59 deletions

View File

@@ -7,6 +7,13 @@ def _db(tmp_path):
return OracleDB(str(tmp_path / "oracle.db"))
def _set_heartbeat_age(db, age_sec):
"""把心跳时间戳直接改写成"距现在 age_sec 秒前",用于测试新鲜度阈值边界。"""
from datetime import datetime, timedelta, timezone
ts = (datetime.now(timezone(timedelta(hours=8))) - timedelta(seconds=age_sec))
db.set_cursor('motion_heartbeat_at', ts.strftime('%Y-%m-%d %H:%M:%S'))
def test_upsert_person_same_gender_merges_into_one_row(tmp_path):
db = _db(tmp_path)
db.upsert_person("人物A", features={"gender": "", "hair": "短发黑色"})
@@ -70,3 +77,107 @@ def test_upsert_person_no_features_never_triggers_split(tmp_path):
rows = db._conn.execute("SELECT * FROM people").fetchall()
assert len(rows) == 1
assert rows[0]["appearances"] == 2
# ----------------------------------------------------------------------
# 运动侦测事件NAS 推送)
# ----------------------------------------------------------------------
def test_record_motion_events_upserts_by_event_id(tmp_path):
db = _db(tmp_path)
n = db.record_motion_events([
{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 5},
{"event_id": 2, "camera_id": 2, "event_type": 10, "start_time": 2000, "duration": 3},
])
assert n == 2
rows = db._conn.execute("SELECT * FROM ss_motion_events ORDER BY event_id").fetchall()
assert len(rows) == 2
# 重复推送同一个 event_id幂等应该更新而不是新增一行
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1000, "duration": 99}])
rows = db._conn.execute("SELECT * FROM ss_motion_events").fetchall()
assert len(rows) == 2
updated = db._conn.execute(
"SELECT duration FROM ss_motion_events WHERE event_id=1").fetchone()
assert updated['duration'] == 99
def test_record_motion_events_skips_missing_event_id(tmp_path):
db = _db(tmp_path)
n = db.record_motion_events([{"camera_id": 2, "start_time": 1000}])
assert n == 0
def test_heartbeat_age_none_when_never_recorded(tmp_path):
db = _db(tmp_path)
assert db.get_motion_heartbeat_age_sec() is None
def test_heartbeat_age_near_zero_right_after_recording(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
age = db.get_motion_heartbeat_age_sec()
assert age is not None and age < 5
def test_has_motion_in_range_local_fails_open_without_heartbeat(tmp_path):
"""核心诉求: 从未收到过心跳冷启动NAS 推送链路还没接上)必须 fail-open
不能因为本地表是空的就悄悄跳过分析。"""
db = _db(tmp_path)
assert db.has_motion_in_range_local(1000, 2000) is None
def test_has_motion_in_range_local_fails_open_when_heartbeat_stale(tmp_path):
"""核心诉求: 表里有大量历史运动事件(曾经推送链路是健康的),但心跳已经
过期太久NAS 服务挂了/网络断了/DSM Webhook 规则被误关)——这时候不能信任
"查询结果是 0 条 = 确认无运动"必须当作链路已死fail-open。"""
db = _db(tmp_path)
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 500, "duration": 10}])
_set_heartbeat_age(db, 1000) # 超过默认阈值 900s
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
def test_has_motion_in_range_local_trusts_result_when_heartbeat_fresh(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is False
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is True
def test_has_motion_in_range_local_respects_heartbeat_boundary(tmp_path):
db = _db(tmp_path)
_set_heartbeat_age(db, 899)
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is not None
_set_heartbeat_age(db, 901)
assert db.has_motion_in_range_local(2000, 3000, max_heartbeat_age_sec=900) is None
def test_has_motion_in_range_local_overlap_semantics(tmp_path):
"""事件区间 [start_time, start_time+duration] 只要和查询窗口有重叠就算命中,
不要求事件完全落在窗口内部(也不要求窗口完全覆盖事件)。"""
db = _db(tmp_path)
db.record_motion_heartbeat()
# 事件在窗口开始之前就开始,但持续到窗口内 -> 应该命中
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 10, "start_time": 1990, "duration": 20}])
assert db.has_motion_in_range_local(2000, 3000) is True
def test_has_motion_in_range_local_ignores_non_motion_event_type(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
db.record_motion_events(
[{"event_id": 1, "camera_id": 2, "event_type": 99, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000) is False
def test_has_motion_in_range_local_filters_by_camera_id(tmp_path):
db = _db(tmp_path)
db.record_motion_heartbeat()
db.record_motion_events(
[{"event_id": 1, "camera_id": 99, "event_type": 10, "start_time": 2500, "duration": 5}])
assert db.has_motion_in_range_local(2000, 3000, camera_id=2) is False
assert db.has_motion_in_range_local(2000, 3000, camera_id=99) is True