[重构] 运动事件驱动架构 - 整段素材按 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:
@@ -47,18 +47,24 @@ video_processing:
|
|||||||
# 降级顺序:先 gemini 整视频,失败再 nvidia 整视频;两者都失败 -> 标记 failed
|
# 降级顺序:先 gemini 整视频,失败再 nvidia 整视频;两者都失败 -> 标记 failed
|
||||||
vision_order: ["gemini", "nvidia"]
|
vision_order: ["gemini", "nvidia"]
|
||||||
|
|
||||||
# 运动侦测预过滤(2026-08-22 重构,2026-08-22 补心跳):
|
# 运动事件(2026-08-22 运动事件驱动架构):
|
||||||
# - 甲骨文【不再反向访问 NAS】。原 dsm_motion_client(甲骨文主动查 SS API)已停用,
|
# - NAS 端 fam-core MotionNotifier 轮询 SS 事件后,POST 推送到甲骨文
|
||||||
# 改由 NAS 端 fam-core 的 MotionNotifier 接收 SS Webhook 后,主动 POST 推送到
|
# /api/ss/motion,落库 ss_motion_events(start_time/duration 为 Unix epoch);
|
||||||
# 甲骨文 /api/ss/motion,落库 ss_motion_events;NAS 侧还会定期用空 events 单纯
|
# 并定期用空 events 调接口当心跳,证明推送链路存活。
|
||||||
# 调一次这个接口当心跳(不要求真的有事件),证明推送链路还活着。
|
# - video_processor 不再整段分析:整段素材按 ss_motion_events 中【已结束】的
|
||||||
# - video_processor 分析前调用 db.has_motion_in_range_local()(本地运动事件表)做
|
# 运动事件分割成运动片段(motion_segment 配置),只分析运动片段。
|
||||||
# 预过滤:窗口内无运动事件则跳过云端分析(compute_provider=skipped_no_motion)。
|
|
||||||
# - 安全阀门: 距上次心跳超过 max_heartbeat_age_sec 没更新(NAS 服务挂了/网络断了/
|
|
||||||
# DSM Webhook 规则被误关,历史数据表非空但推送链路已经死了),一律 fail-open
|
|
||||||
# (照常分析),不能仅凭"表里曾经有数据"就信任"这段时间确认无运动"的结论。
|
|
||||||
dsm_motion_prefilter:
|
dsm_motion_prefilter:
|
||||||
max_heartbeat_age_sec: 900 # 心跳超过 15 分钟没更新 -> 判定推送链路可能已死,fail-open
|
max_heartbeat_age_sec: 900 # 心跳超过 15 分钟没更新 -> 判定推送链路可能已死
|
||||||
|
|
||||||
|
# 运动片段分割(运动事件驱动架构,2026-08-22 新增):
|
||||||
|
# 整段素材视频(rclone 同步落地)按 ss_motion_events 分割成运动片段再分析。
|
||||||
|
# 只分割已结束事件(start_time+duration 落在当前时刻附近),进行中的事件
|
||||||
|
# 等结束后的下一轮再分割;素材保持 pending 直到窗口内事件全部结束。
|
||||||
|
motion_segment:
|
||||||
|
clips_dir: "/opt/fam-edge/motion_clips" # 分割产物目录
|
||||||
|
keep_audio: true # 保留音频(pcm_alaw -> aac 64k 转码);false 则 -an 去音频
|
||||||
|
min_duration_sec: 1 # 短于该时长的事件不分割
|
||||||
|
unfinished_grace_sec: 10 # start_time+duration 距当前 ≤ 该秒视为"已结束"容差
|
||||||
|
|
||||||
# 智能问答降级链(与视频分析独立):Gemini -> NVIDIA -> 本地 Ollama
|
# 智能问答降级链(与视频分析独立):Gemini -> NVIDIA -> 本地 Ollama
|
||||||
models:
|
models:
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ class OracleDB:
|
|||||||
]:
|
]:
|
||||||
if col not in pe_cols:
|
if col not in pe_cols:
|
||||||
c.execute(ddl)
|
c.execute(ddl)
|
||||||
|
# 兼容旧库:videos 表补运动片段字段(运动事件驱动架构加)
|
||||||
|
vid_cols = [r[1] for r in c.execute("PRAGMA table_info(videos)").fetchall()]
|
||||||
|
for col, ddl in [
|
||||||
|
('motion_event_id', "ALTER TABLE videos ADD COLUMN motion_event_id INTEGER"),
|
||||||
|
('camera_id', "ALTER TABLE videos ADD COLUMN camera_id INTEGER"),
|
||||||
|
]:
|
||||||
|
if col not in vid_cols:
|
||||||
|
c.execute(ddl)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -244,19 +252,45 @@ class OracleDB:
|
|||||||
"ORDER BY start_time DESC LIMIT ?", (int(limit),)).fetchall()
|
"ORDER BY start_time DESC LIMIT ?", (int(limit),)).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def record_motion_heartbeat(self):
|
||||||
|
"""NAS 侧推送链路心跳(不管有没有真实事件,每隔几分钟都应该调一次)。
|
||||||
|
|
||||||
|
单独的心跳信号,跟"表里有没有历史数据"是两回事:表非空只能说明"曾经收到
|
||||||
|
过推送",推送链路后来整个挂掉(NAS 服务崩溃/网络断开/DSM 侧 Webhook 规则
|
||||||
|
被误关)之后,表依然非空,has_motion_in_range_local() 原来的判断方式会
|
||||||
|
误以为"链路健康、这段时间确认无运动"从而错误跳过分析——心跳新鲜度检查就是
|
||||||
|
堵这个漏洞的。
|
||||||
|
"""
|
||||||
|
self.set_cursor('motion_heartbeat_at', _now_iso())
|
||||||
|
|
||||||
|
def get_motion_heartbeat_age_sec(self) -> Optional[float]:
|
||||||
|
"""距上次心跳过了多少秒;从未收到过心跳返回 None。"""
|
||||||
|
v = self.get_cursor('motion_heartbeat_at')
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
hb = datetime.strptime(v, '%Y-%m-%d %H:%M:%S').replace(
|
||||||
|
tzinfo=timezone(timedelta(hours=8)))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return (datetime.now(timezone(timedelta(hours=8))) - hb).total_seconds()
|
||||||
|
|
||||||
def has_motion_in_range_local(self, start_ts: int, end_ts: int,
|
def has_motion_in_range_local(self, start_ts: int, end_ts: int,
|
||||||
camera_id: int = None) -> Optional[bool]:
|
camera_id: int = None,
|
||||||
|
max_heartbeat_age_sec: float = 900) -> Optional[bool]:
|
||||||
"""本地运动预过滤:判断 [start_ts, end_ts] 窗口内是否存在运动事件。
|
"""本地运动预过滤:判断 [start_ts, end_ts] 窗口内是否存在运动事件。
|
||||||
|
|
||||||
替代原 dsm_motion_client 反向访问 NAS 的做法。返回:
|
替代原 dsm_motion_client 反向访问 NAS 的做法。返回:
|
||||||
- None : 本地运动事件表为空(冷启动,尚未收到 NAS 推送),调用方
|
- None : 推送链路心跳缺失或过期(从未收到过 / 距上次心跳超过
|
||||||
必须 fail-open(照常送云端分析),不能当作"无运动"跳过。
|
max_heartbeat_age_sec),说明当前无法确认 NAS -> Oracle 这条
|
||||||
- True/False : 窗口内确有/确无运动事件。
|
推送链路是否存活——调用方必须 fail-open(照常送云端分析),
|
||||||
|
不能当作"无运动"跳过。心跳新鲜(链路确认存活)时才信任查询结果,
|
||||||
|
不再仅凭"表是否曾经非空"判断(那样链路挂了也测不出来)。
|
||||||
|
- True/False : 链路确认存活时,窗口内确有/确无运动事件。
|
||||||
start_ts/end_ts 为 Unix epoch(与 SS 事件 start_time 同源,时区无关)。
|
start_ts/end_ts 为 Unix epoch(与 SS 事件 start_time 同源,时区无关)。
|
||||||
"""
|
"""
|
||||||
total = self._conn.execute(
|
age = self.get_motion_heartbeat_age_sec()
|
||||||
"SELECT COUNT(*) c FROM ss_motion_events").fetchone()['c']
|
if age is None or age > max_heartbeat_age_sec:
|
||||||
if total == 0:
|
|
||||||
return None
|
return None
|
||||||
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
|
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
|
||||||
"WHERE event_type = 10 "
|
"WHERE event_type = 10 "
|
||||||
@@ -269,6 +303,53 @@ class OracleDB:
|
|||||||
cnt = self._conn.execute(sql, params).fetchone()['c']
|
cnt = self._conn.execute(sql, params).fetchone()['c']
|
||||||
return cnt > 0
|
return cnt > 0
|
||||||
|
|
||||||
|
def get_motion_events_in_range(self, start_ts: int, end_ts: int,
|
||||||
|
camera_id: int = None,
|
||||||
|
finished_grace_sec: int = 10) -> List[Dict]:
|
||||||
|
"""窗口内【已结束】的运动事件(运动片段分割用)。
|
||||||
|
|
||||||
|
返回按 start_time 升序的 [{event_id, camera_id, start_time, duration,
|
||||||
|
thumbnail_url}]。只返回已结束事件:SS 事件 duration 在动作进行中会显示 0、
|
||||||
|
结束才回填真实时长,因此只分割 start_time+duration 已落在当前时刻
|
||||||
|
(含 finished_grace_sec 秒容差)的事件,进行中的等下轮结束后再处理。
|
||||||
|
"""
|
||||||
|
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
|
||||||
|
sql = ("SELECT event_id, camera_id, event_type, start_time, duration, thumbnail_url "
|
||||||
|
"FROM ss_motion_events "
|
||||||
|
"WHERE event_type = 10 "
|
||||||
|
"AND start_time <= ? "
|
||||||
|
"AND (start_time + COALESCE(duration,0)) >= ? "
|
||||||
|
"AND COALESCE(duration,0) > 0 "
|
||||||
|
"AND (start_time + COALESCE(duration,0)) <= ? + ?")
|
||||||
|
params = [end_ts, start_ts, now_ts, int(finished_grace_sec)]
|
||||||
|
if camera_id is not None:
|
||||||
|
sql += " AND camera_id = ?"
|
||||||
|
params.append(camera_id)
|
||||||
|
sql += " ORDER BY start_time ASC"
|
||||||
|
return [dict(r) for r in self._conn.execute(sql, params).fetchall()]
|
||||||
|
|
||||||
|
def has_unfinished_motion_in_range(self, start_ts: int, end_ts: int,
|
||||||
|
camera_id: int = None) -> bool:
|
||||||
|
"""窗口内是否存在【未结束】的运动事件(duration 可能还在增长)。"""
|
||||||
|
now_ts = int(datetime.now(timezone(timedelta(hours=8))).timestamp())
|
||||||
|
sql = ("SELECT COUNT(*) c FROM ss_motion_events "
|
||||||
|
"WHERE event_type = 10 "
|
||||||
|
"AND start_time <= ? "
|
||||||
|
"AND (start_time + COALESCE(duration,0)) >= ? "
|
||||||
|
"AND (start_time + COALESCE(duration,0)) > ?")
|
||||||
|
params = [end_ts, start_ts, now_ts]
|
||||||
|
if camera_id is not None:
|
||||||
|
sql += " AND camera_id = ?"
|
||||||
|
params.append(camera_id)
|
||||||
|
return self._conn.execute(sql, params).fetchone()['c'] > 0
|
||||||
|
|
||||||
|
def get_video_by_motion_event_id(self, motion_event_id: int):
|
||||||
|
"""按运动事件 id 查是否已生成对应运动片段(幂等去重)。"""
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"SELECT * FROM videos WHERE motion_event_id=? LIMIT 1",
|
||||||
|
(int(motion_event_id),))
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
def get_queue_status(self) -> Dict:
|
def get_queue_status(self) -> Dict:
|
||||||
"""实时队列/处理状态(前端服务状态卡用)。"""
|
"""实时队列/处理状态(前端服务状态卡用)。"""
|
||||||
total = self._conn.execute("SELECT COUNT(*) c FROM videos").fetchone()['c']
|
total = self._conn.execute("SELECT COUNT(*) c FROM videos").fetchone()['c']
|
||||||
@@ -302,23 +383,29 @@ class OracleDB:
|
|||||||
|
|
||||||
def ensure_video(self, filename: str, local_path: str,
|
def ensure_video(self, filename: str, local_path: str,
|
||||||
camera_name: str = '', event_start_time: str = '',
|
camera_name: str = '', event_start_time: str = '',
|
||||||
duration_sec: float = 0.0, drive_file_id: str = '') -> int:
|
duration_sec: float = 0.0, drive_file_id: str = '',
|
||||||
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。"""
|
motion_event_id: int = None, camera_id: int = None) -> int:
|
||||||
|
"""视频进入监听目录时登记;已存在则更新路径。返回 video_id。
|
||||||
|
|
||||||
|
motion_event_id: 运动片段关联的 SS 运动事件 id(非空=运动片段,非素材)。
|
||||||
|
"""
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
row = self.get_video_by_filename(filename)
|
row = self.get_video_by_filename(filename)
|
||||||
if row:
|
if row:
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"UPDATE videos SET local_path=?, camera_name=?, event_start_time=?, "
|
"UPDATE videos SET local_path=?, camera_name=?, event_start_time=?, "
|
||||||
"duration_sec=?, updated_at=? WHERE id=?",
|
"duration_sec=?, motion_event_id=?, camera_id=?, updated_at=? WHERE id=?",
|
||||||
(local_path, camera_name, event_start_time, duration_sec, now, row['id']))
|
(local_path, camera_name, event_start_time, duration_sec,
|
||||||
|
motion_event_id, camera_id, now, row['id']))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return row['id']
|
return row['id']
|
||||||
cur = self._conn.execute(
|
cur = self._conn.execute(
|
||||||
"INSERT INTO videos (drive_file_id, filename, local_path, camera_name, "
|
"INSERT INTO videos (drive_file_id, filename, local_path, camera_name, "
|
||||||
"duration_sec, event_start_time, status, created_at, updated_at) "
|
"duration_sec, event_start_time, motion_event_id, camera_id, "
|
||||||
"VALUES (?,?,?,?,?,?, 'pending', ?, ?)",
|
"status, created_at, updated_at) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?, 'pending', ?, ?)",
|
||||||
(drive_file_id, filename, local_path, camera_name, duration_sec,
|
(drive_file_id, filename, local_path, camera_name, duration_sec,
|
||||||
event_start_time, now, now))
|
event_start_time, motion_event_id, camera_id, now, now))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,16 @@ class VideoProcessor:
|
|||||||
'file_validate', True))
|
'file_validate', True))
|
||||||
self.parse_start = self.config.get('gdrive_sync', {}).get(
|
self.parse_start = self.config.get('gdrive_sync', {}).get(
|
||||||
'parse_start_from_filename', True)
|
'parse_start_from_filename', True)
|
||||||
|
# 运动预过滤"无运动"结论的可信心跳时限:NAS 推送心跳超过这么久没更新,
|
||||||
|
# 就认为推送链路可能已经挂了,fail-open(照常分析)
|
||||||
|
self.motion_max_heartbeat_age_sec = float(self.config.get(
|
||||||
|
'dsm_motion_prefilter', {}).get('max_heartbeat_age_sec', 900))
|
||||||
|
# 运动片段分割(运动事件驱动架构):整段素材按 ss_motion_events 分割片段再分析
|
||||||
|
seg = self.config.get('motion_segment', {})
|
||||||
|
self.motion_clips_dir = seg.get('clips_dir', '/opt/fam-edge/motion_clips')
|
||||||
|
self.motion_keep_audio = bool(seg.get('keep_audio', True))
|
||||||
|
self.motion_min_duration = float(seg.get('min_duration_sec', 1))
|
||||||
|
self.motion_grace_sec = int(seg.get('unfinished_grace_sec', 10))
|
||||||
adapters = build_adapters(self.config.get('models', []))
|
adapters = build_adapters(self.config.get('models', []))
|
||||||
self.vision_adapters: Dict[str, BaseModelAdapter] = {
|
self.vision_adapters: Dict[str, BaseModelAdapter] = {
|
||||||
a.provider_name: a for a in adapters if a.get_role() == 'vision'}
|
a.provider_name: a for a in adapters if a.get_role() == 'vision'}
|
||||||
@@ -206,16 +216,21 @@ class VideoProcessor:
|
|||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
def process_video(self, video_id: int, filename: str, local_path: str,
|
def process_video(self, video_id: int, filename: str, local_path: str,
|
||||||
timeout_multiplier: float = 1.0) -> bool:
|
timeout_multiplier: float = 1.0):
|
||||||
"""处理一个视频记录,返回是否成功。
|
"""处理一个视频记录。返回 (ok: bool, new_clip_ids: List[int])。
|
||||||
|
|
||||||
|
- 素材整段视频(无 motion_event_id):不再整段分析,而是按 ss_motion_events
|
||||||
|
里【已结束】的运动事件分割成运动片段,返回片段 video_id 列表(调用方负责
|
||||||
|
入队分析);素材记录在全部事件结束后标记 done,仍有未结束事件时保持
|
||||||
|
pending 由生产者下轮重试。
|
||||||
|
- 运动片段(有 motion_event_id):本身即运动时段,直接云端分析(跳过预过滤)。
|
||||||
|
|
||||||
timeout_multiplier: 云端模型消费的超时放大倍数(如 2 = 在配置 timeout 上 ×2)。
|
timeout_multiplier: 云端模型消费的超时放大倍数(如 2 = 在配置 timeout 上 ×2)。
|
||||||
每次调用前临时放大对应 adapter.timeout,调用后恢复,避免影响其他调用方。
|
|
||||||
"""
|
"""
|
||||||
if not os.path.isfile(local_path):
|
if not os.path.isfile(local_path):
|
||||||
logger.error(f"[video_id={video_id}] 文件不存在,跳过: {local_path}")
|
logger.error(f"[video_id={video_id}] 文件不存在,跳过: {local_path}")
|
||||||
self.db.mark_video_failed(video_id, "file_missing")
|
self.db.mark_video_failed(video_id, "file_missing")
|
||||||
return False
|
return False, []
|
||||||
|
|
||||||
# 处理前二次确认文件有效性(防止登记后文件被破坏/截断;校验结果落库)
|
# 处理前二次确认文件有效性(防止登记后文件被破坏/截断;校验结果落库)
|
||||||
if self.file_validate:
|
if self.file_validate:
|
||||||
@@ -224,41 +239,29 @@ class VideoProcessor:
|
|||||||
logger.error(f"[video_id={video_id}] 文件校验失败({verr}),标记 failed: {local_path}")
|
logger.error(f"[video_id={video_id}] 文件校验失败({verr}),标记 failed: {local_path}")
|
||||||
self.db.set_video_file_status(video_id, False, verr)
|
self.db.set_video_file_status(video_id, False, verr)
|
||||||
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
|
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
|
||||||
return False
|
return False, []
|
||||||
if vmeta:
|
if vmeta:
|
||||||
self.db.set_video_file_status(video_id, True, '', vmeta)
|
self.db.set_video_file_status(video_id, True, '', vmeta)
|
||||||
|
else:
|
||||||
|
vmeta = None
|
||||||
|
|
||||||
camera_name = self.db.get_video_by_filename(filename)['camera_name'] or ''
|
vrow = self.db.get_video_by_filename(filename)
|
||||||
event_start = ''
|
is_motion_clip = bool(vrow and vrow.get('motion_event_id'))
|
||||||
if self.parse_start:
|
camera_name = (vrow or {}).get('camera_name') or ''
|
||||||
|
event_start = (vrow or {}).get('event_start_time') or ''
|
||||||
|
if not event_start and self.parse_start:
|
||||||
event_start = _parse_event_start_from_filename(filename)
|
event_start = _parse_event_start_from_filename(filename)
|
||||||
# 回写解析到的开始时间
|
|
||||||
if event_start:
|
if event_start:
|
||||||
self.db.set_event_start_time(video_id, event_start)
|
self.db.set_event_start_time(video_id, event_start)
|
||||||
|
|
||||||
# 运动侦测预过滤(v2: 改用 NAS 推送来的本地运动事件,不再反向访问 NAS):
|
if not is_motion_clip:
|
||||||
# 这段时间窗口里没有任何运动事件,就跳过云端分析(省配额)。
|
# ---- 素材整段视频:分割成运动片段,不整段分析 ----
|
||||||
# 本地运动事件表为空(冷启动,尚未收到 NAS 推送)一律 fail-open(照常分析),
|
return self._segment_source_video(
|
||||||
# 绝不能因为这层可选优化漏检真实事件。
|
video_id, local_path, event_start, camera_name, vmeta)
|
||||||
duration_sec = (vmeta or {}).get('duration_sec') if self.file_validate else None
|
|
||||||
if event_start and duration_sec:
|
|
||||||
try:
|
|
||||||
# event_start 是北京时间墙钟,转成与 SS 事件同源的 Unix epoch
|
|
||||||
start_dt = datetime.strptime(event_start, '%Y-%m-%d %H:%M:%S').replace(
|
|
||||||
tzinfo=timezone(timedelta(hours=8)))
|
|
||||||
start_ts = int(start_dt.timestamp())
|
|
||||||
end_ts = start_ts + int(duration_sec)
|
|
||||||
has_motion = self.db.has_motion_in_range_local(start_ts, end_ts)
|
|
||||||
except ValueError:
|
|
||||||
has_motion = None
|
|
||||||
if has_motion is False:
|
|
||||||
logger.info(f"[video_id={video_id}] 运动预过滤:该时段无运动,跳过云端分析")
|
|
||||||
self.db.mark_video_processed(
|
|
||||||
video_id, "(自动跳过:该时段未检测到运动)", [], [], 'skipped_no_motion')
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
# ---- 运动片段:本身即运动时段,直接云端分析(跳过运动预过滤)----
|
||||||
known = self.db.get_known_members_context()
|
known = self.db.get_known_members_context()
|
||||||
logger.info(f"[video_id={video_id}] 开始整视频分析: {filename} "
|
logger.info(f"[video_id={video_id}] 开始运动片段分析: {filename} "
|
||||||
f"(event_start={event_start}, known_members={'有' if known else '无'})")
|
f"(event_start={event_start}, known_members={'有' if known else '无'})")
|
||||||
|
|
||||||
last_err = "no_vision_adapter"
|
last_err = "no_vision_adapter"
|
||||||
@@ -274,7 +277,7 @@ class VideoProcessor:
|
|||||||
logger.info(f"[video_id={video_id}] {adapter.provider_name} 超时 "
|
logger.info(f"[video_id={video_id}] {adapter.provider_name} 超时 "
|
||||||
f"{orig_timeout}s -> {adapter.timeout}s (×{timeout_multiplier})")
|
f"{orig_timeout}s -> {adapter.timeout}s (×{timeout_multiplier})")
|
||||||
try:
|
try:
|
||||||
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 整视频分析")
|
logger.info(f"[video_id={video_id}] 尝试 {adapter.provider_name} 运动片段分析")
|
||||||
result = adapter.analyze_video(local_path, known, event_start)
|
result = adapter.analyze_video(local_path, known, event_start)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[video_id={video_id}] {adapter.provider_name} 异常: {e}")
|
logger.error(f"[video_id={video_id}] {adapter.provider_name} 异常: {e}")
|
||||||
@@ -284,14 +287,117 @@ class VideoProcessor:
|
|||||||
adapter.timeout = orig_timeout
|
adapter.timeout = orig_timeout
|
||||||
if result:
|
if result:
|
||||||
self._store_result(video_id, result)
|
self._store_result(video_id, result)
|
||||||
return True
|
return True, []
|
||||||
else:
|
else:
|
||||||
last_err = f"{adapter.provider_name}_failed"
|
last_err = f"{adapter.provider_name}_failed"
|
||||||
logger.warning(f"[video_id={video_id}] {adapter.provider_name} 未返回结果,降级下一模型")
|
logger.warning(f"[video_id={video_id}] {adapter.provider_name} 未返回结果,降级下一模型")
|
||||||
|
|
||||||
logger.error(f"[video_id={video_id}] 所有视觉模型失败,标记 failed: {last_err}")
|
logger.error(f"[video_id={video_id}] 所有视觉模型失败,标记 failed: {last_err}")
|
||||||
self.db.mark_video_failed(video_id, last_err)
|
self.db.mark_video_failed(video_id, last_err)
|
||||||
return False
|
return False, []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 运动片段分割(运动事件驱动架构)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _segment_source_video(self, video_id: int, local_path: str, event_start: str,
|
||||||
|
camera_name: str, vmeta: dict):
|
||||||
|
"""整段素材 -> 按已结束运动事件分割运动片段。返回 (ok, clip_ids)。
|
||||||
|
|
||||||
|
素材视频不再整段送云端分析;仅按运动事件窗口切出片段交给下游分析。
|
||||||
|
"""
|
||||||
|
if not event_start:
|
||||||
|
logger.info(f"[video_id={video_id}] 素材无开始时间,无法对齐运动事件,标记 done")
|
||||||
|
self.db.mark_video_processed(
|
||||||
|
video_id, "(素材无开始时间,无法分割运动片段)", [], [], 'motion_segment')
|
||||||
|
return True, []
|
||||||
|
try:
|
||||||
|
start_dt = datetime.strptime(event_start, '%Y-%m-%d %H:%M:%S').replace(
|
||||||
|
tzinfo=timezone(timedelta(hours=8)))
|
||||||
|
start_ts = int(start_dt.timestamp())
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"[video_id={video_id}] 素材开始时间解析失败: {event_start}")
|
||||||
|
self.db.mark_video_processed(
|
||||||
|
video_id, "(素材开始时间解析失败,无法分割)", [], [], 'motion_segment')
|
||||||
|
return True, []
|
||||||
|
if vmeta is None:
|
||||||
|
ok, verr, vmeta = validate_video(local_path)
|
||||||
|
if not ok:
|
||||||
|
self.db.set_video_file_status(video_id, False, verr)
|
||||||
|
self.db.mark_video_failed(video_id, f"invalid_file:{verr}")
|
||||||
|
return False, []
|
||||||
|
dur_sec = float((vmeta or {}).get('duration_sec') or 0)
|
||||||
|
end_ts = start_ts + int(dur_sec) if dur_sec > 0 else start_ts + 3600
|
||||||
|
clip_ids = self._segment_motion_clips(local_path, start_ts, end_ts, camera_name)
|
||||||
|
if self.db.has_unfinished_motion_in_range(start_ts, end_ts):
|
||||||
|
# 仍有进行中的事件(duration 未定型):保持 pending,producer 下轮重试
|
||||||
|
logger.info(f"[video_id={video_id}] 素材仍有未结束运动事件,保持 pending 下轮再分割")
|
||||||
|
return True, clip_ids
|
||||||
|
self.db.mark_video_processed(
|
||||||
|
video_id, f"(整段素材已分割 {len(clip_ids)} 段运动片段)", [], [], 'motion_segment')
|
||||||
|
return True, clip_ids
|
||||||
|
|
||||||
|
def _segment_motion_clips(self, src_path: str, start_ts: int, end_ts: int,
|
||||||
|
camera_name: str) -> List[int]:
|
||||||
|
"""按窗口内已结束运动事件从整段素材分割运动片段。返回片段 video_id 列表。
|
||||||
|
|
||||||
|
幂等:已按 motion_event_id 生成过片段的跳过;片段文件已存在的跳过分割。
|
||||||
|
"""
|
||||||
|
clips: List[int] = []
|
||||||
|
for me in self.db.get_motion_events_in_range(
|
||||||
|
start_ts, end_ts, finished_grace_sec=self.motion_grace_sec):
|
||||||
|
eid = me['event_id']
|
||||||
|
if self.db.get_video_by_motion_event_id(eid):
|
||||||
|
continue
|
||||||
|
dur = float(me.get('duration') or 0)
|
||||||
|
if dur < self.motion_min_duration:
|
||||||
|
continue
|
||||||
|
offset = float(me['start_time'] - start_ts)
|
||||||
|
if offset < -5 or offset > (end_ts - start_ts) + 5:
|
||||||
|
continue # 事件窗口与素材窗口无重叠
|
||||||
|
offset = max(0.0, offset)
|
||||||
|
os.makedirs(self.motion_clips_dir, exist_ok=True)
|
||||||
|
clip_fn = f"motion_{eid}_{me['start_time']}.mp4"
|
||||||
|
clip_path = os.path.join(self.motion_clips_dir, clip_fn)
|
||||||
|
if not os.path.isfile(clip_path):
|
||||||
|
if not self._run_segment_ffmpeg(src_path, offset, dur, clip_path):
|
||||||
|
logger.error(f"运动片段分割失败: {clip_fn}")
|
||||||
|
continue
|
||||||
|
start_iso = datetime.fromtimestamp(
|
||||||
|
me['start_time'], tz=timezone(timedelta(hours=8))
|
||||||
|
).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
cid = self.db.ensure_video(
|
||||||
|
filename=clip_fn, local_path=clip_path, camera_name=camera_name,
|
||||||
|
event_start_time=start_iso, duration_sec=dur,
|
||||||
|
drive_file_id=f"motion_{eid}", motion_event_id=eid,
|
||||||
|
camera_id=me.get('camera_id'))
|
||||||
|
self.db.record_activity('segment', 'clip', f"{clip_fn} (event={eid})")
|
||||||
|
clips.append(cid)
|
||||||
|
return clips
|
||||||
|
|
||||||
|
def _run_segment_ffmpeg(self, src_path: str, offset: float, dur: float,
|
||||||
|
out_path: str) -> bool:
|
||||||
|
"""ffmpeg 从整段素材切运动片段:-ss/-t 定位,视频 copy 免转码,
|
||||||
|
音频按配置保留(pcm_alaw -> aac 转码)或丢弃(-an)。"""
|
||||||
|
import shutil
|
||||||
|
ffmpeg = shutil.which('ffmpeg') or 'ffmpeg'
|
||||||
|
args = ['-y', '-hide_banner', '-loglevel', 'error',
|
||||||
|
'-ss', f'{offset:.3f}', '-i', src_path, '-t', f'{dur:.3f}',
|
||||||
|
'-c:v', 'copy']
|
||||||
|
if self.motion_keep_audio:
|
||||||
|
args += ['-c:a', 'aac', '-b:a', '64k']
|
||||||
|
else:
|
||||||
|
args += ['-an']
|
||||||
|
args += ['-avoid_negative_ts', 'make_zero', out_path]
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(args, capture_output=True, timeout=300)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.error(f"ffmpeg 分割失败: "
|
||||||
|
f"{proc.stderr.decode(errors='ignore')[:200]}")
|
||||||
|
return False
|
||||||
|
return os.path.isfile(out_path) and os.path.getsize(out_path) > 0
|
||||||
|
except (subprocess.TimeoutExpired, OSError) as e:
|
||||||
|
logger.error(f"ffmpeg 分割异常: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def _store_result(self, video_id: int, result: Dict):
|
def _store_result(self, video_id: int, result: Dict):
|
||||||
events = result.get('events', [])
|
events = result.get('events', [])
|
||||||
|
|||||||
@@ -214,9 +214,12 @@ class VideoQueue:
|
|||||||
"started_at": None}
|
"started_at": None}
|
||||||
self.db.record_activity('queue', 'process_start', f"video {video_id} {fn}")
|
self.db.record_activity('queue', 'process_start', f"video {video_id} {fn}")
|
||||||
try:
|
try:
|
||||||
ok = processor.process_video(
|
ok, clip_ids = processor.process_video(
|
||||||
video_id, fn, row['local_path'],
|
video_id, fn, row['local_path'],
|
||||||
timeout_multiplier=self.timeout_multiplier)
|
timeout_multiplier=self.timeout_multiplier)
|
||||||
|
# 素材整段视频分割出的运动片段入队分析(片段不在监听目录,需手动入队)
|
||||||
|
for cid in (clip_ids or []):
|
||||||
|
self._enqueue(cid)
|
||||||
if ok:
|
if ok:
|
||||||
self._stats["consumed_ok"] += 1
|
self._stats["consumed_ok"] += 1
|
||||||
self.db.record_activity('queue', 'process_done', f"video {video_id} {fn}")
|
self.db.record_activity('queue', 'process_done', f"video {video_id} {fn}")
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ def _db(tmp_path):
|
|||||||
return OracleDB(str(tmp_path / "oracle.db"))
|
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):
|
def test_upsert_person_same_gender_merges_into_one_row(tmp_path):
|
||||||
db = _db(tmp_path)
|
db = _db(tmp_path)
|
||||||
db.upsert_person("人物A", features={"gender": "男", "hair": "短发黑色"})
|
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()
|
rows = db._conn.execute("SELECT * FROM people").fetchall()
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0]["appearances"] == 2
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user