feat(events): 每个事件生成对应时间点画面截图 - oracle_db.mark_video_processed 返回 event_ids;video_processor 按事件时间戳-视频起始时间偏移跳帧截图存 ev_{id}.jpg;新增 /api/oracle/event/{id}/thumb 接口;fam-ui 事件列表每条显示对应截图
This commit is contained in:
@@ -130,6 +130,21 @@ def video_thumb(video_id):
|
|||||||
return send_file(path, mimetype='image/jpeg')
|
return send_file(path, mimetype='image/jpeg')
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route('/api/oracle/event/<int:event_id>/thumb', methods=['GET'])
|
||||||
|
def event_thumb(event_id):
|
||||||
|
"""返回事件对应时间点的画面截图(token 校验)。
|
||||||
|
|
||||||
|
由 video_processor 处理成功后按事件时间戳定位视频帧生成:
|
||||||
|
/opt/fam-edge/thumbs/ev_{event_id}.jpg
|
||||||
|
"""
|
||||||
|
if not _check_token():
|
||||||
|
return jsonify({"error": "unauthorized"}), 401
|
||||||
|
path = f"/opt/fam-edge/thumbs/ev_{event_id}.jpg"
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return jsonify({"error": "thumb_not_found"}), 404
|
||||||
|
return send_file(path, mimetype='image/jpeg')
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route('/health', methods=['GET'])
|
@api_bp.route('/health', methods=['GET'])
|
||||||
def health():
|
def health():
|
||||||
"""健康检查"""
|
"""健康检查"""
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ class OracleDB:
|
|||||||
return cur.fetchall()
|
return cur.fetchall()
|
||||||
|
|
||||||
def mark_video_processed(self, video_id: int, summary: str, events: List[dict],
|
def mark_video_processed(self, video_id: int, summary: str, events: List[dict],
|
||||||
people: List[str], compute_provider: str):
|
people: List[str], compute_provider: str) -> List[int]:
|
||||||
|
"""落库视频结果;返回新插入事件的 id 列表(与 events 参数一一对应,供事件截图用)"""
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
self._conn.execute(
|
self._conn.execute(
|
||||||
"UPDATE videos SET status='done', summary_json=?, events_json=?, "
|
"UPDATE videos SET status='done', summary_json=?, events_json=?, "
|
||||||
@@ -166,14 +167,17 @@ class OracleDB:
|
|||||||
compute_provider, now, now, video_id))
|
compute_provider, now, now, video_id))
|
||||||
# 事件落独立表,便于 NAS 拉取
|
# 事件落独立表,便于 NAS 拉取
|
||||||
self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,))
|
self._conn.execute("DELETE FROM events WHERE video_id=?", (video_id,))
|
||||||
|
event_ids: List[int] = []
|
||||||
for ev in events:
|
for ev in events:
|
||||||
self._conn.execute(
|
cur = self._conn.execute(
|
||||||
"INSERT INTO events (video_id, ts, description, person_list_json, "
|
"INSERT INTO events (video_id, ts, description, person_list_json, "
|
||||||
"is_attention_event) VALUES (?,?,?,?,?)",
|
"is_attention_event) VALUES (?,?,?,?,?)",
|
||||||
(video_id, ev.get('timestamp', ''), ev.get('description', ''),
|
(video_id, ev.get('timestamp', ''), ev.get('description', ''),
|
||||||
json.dumps(ev.get('people', []), ensure_ascii=False),
|
json.dumps(ev.get('people', []), ensure_ascii=False),
|
||||||
1 if ev.get('is_attention_event') else 0))
|
1 if ev.get('is_attention_event') else 0))
|
||||||
|
event_ids.append(cur.lastrowid)
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
|
return event_ids
|
||||||
|
|
||||||
def mark_video_failed(self, video_id: int, error: str = ''):
|
def mark_video_failed(self, video_id: int, error: str = ''):
|
||||||
now = _now_iso()
|
now = _now_iso()
|
||||||
|
|||||||
@@ -139,11 +139,12 @@ class VideoProcessor:
|
|||||||
"is_attention_event": bool(ev.get('is_attention_event', False)),
|
"is_attention_event": bool(ev.get('is_attention_event', False)),
|
||||||
})
|
})
|
||||||
|
|
||||||
self.db.mark_video_processed(video_id, summary, norm_events, people, provider)
|
event_ids = self.db.mark_video_processed(video_id, summary, norm_events, people, provider)
|
||||||
# 抽首帧生成缩略图(供前端展示;失败不影响主流程)
|
# 缩略图 + 每个事件对应时间点的画面截图(供前端展示;失败不影响主流程)
|
||||||
row = self.db.get_video_by_id(video_id)
|
row = self.db.get_video_by_id(video_id)
|
||||||
if row and row['local_path']:
|
if row and row['local_path']:
|
||||||
self._generate_thumb(video_id, row['local_path'])
|
self._generate_thumb(video_id, row['local_path'])
|
||||||
|
self._generate_event_thumbs(video_id, row['local_path'], norm_events, event_ids)
|
||||||
|
|
||||||
# 更新 people 表(标签级,待 person_service 合并)
|
# 更新 people 表(标签级,待 person_service 合并)
|
||||||
for p in people:
|
for p in people:
|
||||||
@@ -152,16 +153,18 @@ class VideoProcessor:
|
|||||||
logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, "
|
logger.info(f"[video_id={video_id}] 已落库: summary={len(summary)}字, "
|
||||||
f"events={len(norm_events)}, people={people}")
|
f"events={len(norm_events)}, people={people}")
|
||||||
|
|
||||||
|
def _thumbs_dir(self) -> str:
|
||||||
|
db_path = self.config.get('oracle_db', {}).get(
|
||||||
|
'path', '/opt/fam-edge/data/oracle.db')
|
||||||
|
d = os.path.abspath(os.path.join(os.path.dirname(db_path), '..', 'thumbs'))
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
def _generate_thumb(self, video_id: int, video_path: str) -> bool:
|
def _generate_thumb(self, video_id: int, video_path: str) -> bool:
|
||||||
"""抽视频首帧生成 JPEG 缩略图(/opt/fam-edge/thumbs/{video_id}.jpg)"""
|
"""抽视频首帧生成 JPEG 缩略图(/opt/fam-edge/thumbs/{video_id}.jpg)"""
|
||||||
try:
|
try:
|
||||||
import cv2
|
import cv2
|
||||||
db_path = self.config.get('oracle_db', {}).get(
|
out = os.path.join(self._thumbs_dir(), f"{video_id}.jpg")
|
||||||
'path', '/opt/fam-edge/data/oracle.db')
|
|
||||||
thumbs_dir = os.path.abspath(os.path.join(
|
|
||||||
os.path.dirname(db_path), '..', 'thumbs'))
|
|
||||||
os.makedirs(thumbs_dir, exist_ok=True)
|
|
||||||
out = os.path.join(thumbs_dir, f"{video_id}.jpg")
|
|
||||||
cap = cv2.VideoCapture(video_path)
|
cap = cv2.VideoCapture(video_path)
|
||||||
try:
|
try:
|
||||||
ok, frame = cap.read()
|
ok, frame = cap.read()
|
||||||
@@ -179,3 +182,53 @@ class VideoProcessor:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"抽帧异常 video_id={video_id}: {e}")
|
logger.warning(f"抽帧异常 video_id={video_id}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _generate_event_thumbs(self, video_id: int, video_path: str,
|
||||||
|
events: List[dict], event_ids: List[int]):
|
||||||
|
"""按事件时间戳定位视频帧,生成事件画面截图 ev_{event_id}.jpg"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
from datetime import datetime as _dt
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"事件截图依赖缺失 video_id={video_id}: {e}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
start_str = self.db.get_video_by_id(video_id)['event_start_time'] or ''
|
||||||
|
start_dt = None
|
||||||
|
if start_str:
|
||||||
|
try:
|
||||||
|
start_dt = _dt.strptime(start_str, '%Y-%m-%d %H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
thumbs = self._thumbs_dir()
|
||||||
|
cap = cv2.VideoCapture(video_path)
|
||||||
|
try:
|
||||||
|
for ev, eid in zip(events, event_ids):
|
||||||
|
offset = 0.0
|
||||||
|
ts = str(ev.get('timestamp', ''))
|
||||||
|
if start_dt and ts:
|
||||||
|
try:
|
||||||
|
ev_dt = _dt.strptime(ts[:19], '%Y-%m-%d %H:%M:%S')
|
||||||
|
offset = (ev_dt - start_dt).total_seconds()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if offset < 0:
|
||||||
|
offset = 0.0
|
||||||
|
cap.set(cv2.CAP_PROP_POS_MSEC, int(offset * 1000))
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
logger.warning(f"事件截图失败 ev_{eid}: 无法读取 offset={offset:.0f}s")
|
||||||
|
continue
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
if w > 640:
|
||||||
|
frame = cv2.resize(frame, (640, int(h * 640 / w)))
|
||||||
|
out = os.path.join(thumbs, f"ev_{eid}.jpg")
|
||||||
|
cv2.imwrite(out, frame, [cv2.IMWRITE_JPEG_QUALITY, 65])
|
||||||
|
logger.info(f"事件截图已生成 ev_{eid}.jpg (offset={offset:.0f}s)")
|
||||||
|
finally:
|
||||||
|
cap.release()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"事件截图异常 video_id={video_id}: {e}")
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ def page_header(icon: str, title: str, sub: str = ''):
|
|||||||
|
|
||||||
|
|
||||||
def render_event_list(events: list):
|
def render_event_list(events: list):
|
||||||
"""渲染事件时间线:左相对时间 + 右事件卡(人物/关注徽章 + 描述)"""
|
"""渲染事件时间线:左相对时间 + 右事件卡(事件画面截图 + 人物/关注徽章 + 描述)"""
|
||||||
items = []
|
items = []
|
||||||
for e in events:
|
for e in events:
|
||||||
ts = parse_ts(e.get('ts'))
|
ts = parse_ts(e.get('ts'))
|
||||||
@@ -288,6 +288,16 @@ def render_event_list(events: list):
|
|||||||
persons = parse_persons(e.get('person_list_json'))
|
persons = parse_persons(e.get('person_list_json'))
|
||||||
attention = bool(e.get('is_attention_event'))
|
attention = bool(e.get('is_attention_event'))
|
||||||
desc = e.get('description') or '(无描述)'
|
desc = e.get('description') or '(无描述)'
|
||||||
|
eid = e.get('id')
|
||||||
|
|
||||||
|
# 事件对应时间点的画面截图(Oracle 带 token 接口;加载失败自动隐藏)
|
||||||
|
img_html = ''
|
||||||
|
if _oracle_url and eid:
|
||||||
|
img_html = (
|
||||||
|
f'<img src="{_oracle_url}/api/oracle/event/{eid}/thumb?token={_oracle_token}" '
|
||||||
|
f'style="width:100%;max-height:200px;object-fit:cover;border-radius:8px;'
|
||||||
|
f'margin-bottom:6px;" '
|
||||||
|
f'onerror="this.style.display=\'none\'"/>')
|
||||||
|
|
||||||
badges = ''.join(
|
badges = ''.join(
|
||||||
f'<span class="bdg bdg-person">{esc(p)}</span>' for p in sorted(persons))
|
f'<span class="bdg bdg-person">{esc(p)}</span>' for p in sorted(persons))
|
||||||
@@ -298,7 +308,7 @@ def render_event_list(events: list):
|
|||||||
f'<div class="ev-item {"attention" if attention else ""}">'
|
f'<div class="ev-item {"attention" if attention else ""}">'
|
||||||
f'<div class="ev-time">{esc(time_label)}'
|
f'<div class="ev-time">{esc(time_label)}'
|
||||||
f'{f"<span class=cam>{esc(camera)}</span>" if camera else ""}</div>'
|
f'{f"<span class=cam>{esc(camera)}</span>" if camera else ""}</div>'
|
||||||
f'<div class="ev-card">{badges}'
|
f'<div class="ev-card">{img_html}{badges}'
|
||||||
f'<div class="ev-desc">{esc(desc)}</div></div>'
|
f'<div class="ev-desc">{esc(desc)}</div></div>'
|
||||||
f'</div>'
|
f'</div>'
|
||||||
)
|
)
|
||||||
@@ -546,7 +556,7 @@ if page == "🕒 事件时间轴":
|
|||||||
try:
|
try:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""SELECT e.ts, e.description, e.person_list_json,
|
"""SELECT e.id, e.ts, e.description, e.person_list_json,
|
||||||
e.is_attention_event, v.camera_name
|
e.is_attention_event, v.camera_name
|
||||||
FROM sync_events e
|
FROM sync_events e
|
||||||
JOIN sync_videos v ON e.video_id = v.id
|
JOIN sync_videos v ON e.video_id = v.id
|
||||||
|
|||||||
Reference in New Issue
Block a user