背景: Oracle Edge 机器为 UTC 时区,NAS 与视频均为北京时间,
网页要求统一北京时区。排查结论: NAS 端(北京时间)与 UI(直读
MariaDB)均正确,问题集中在 Edge 端三处。
1. queue_manager: 7 处 datetime('now','localtime') 在 UTC 机器上
写入 UTC 时间(比北京慢8h),全部改为 datetime('now','+8 hours')
2. preprocessor.compute_timestamps: event_start_time 缺失时 fallback
datetime.now() 用了 Edge 本地时间(UTC),改为北京时间
datetime.now(timezone(+8h));带时区的 ISO 输入统一转北京时间
3. nvidia_adapter._ts_to_seconds: 不支持生产格式
'YYYY-MM-DD HH:MM:SS'(split后int抛ValueError全部返回-1),
导致集锦视频永远为空、视频模式永远降级逐帧——上一轮引入的
bug,测试用 HH:MM:SS 格式未暴露。现支持两种格式;drawtext
标签与提示词同步为完整时间戳说明
数据修正: event 17(task 298 手动测试缺 event_start_time)的
frame_timestamp 全为 UTC,按视频文件名真实时间(04:34:10)重算;
Edge 队列存量时间戳 +8h。
验证: 单测三例通过(生产格式解析/fallback北京时间/ISO带时区转换);
task 41 新代码正确写入北京时间 17:36:52。
317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""
|
||
Video-Preprocessor - 视频预处理
|
||
|
||
流程:
|
||
1. 下载视频(超时 60s)
|
||
2. 根据视频时长自适应计算候选帧数,FFmpeg 等距粗抽
|
||
3. 根据视频时长自适应计算关键帧数,OpenCV 帧差分析筛选(MSE 阈值)
|
||
4. 压缩(长边 ≤ 1024px,JPEG 质量 80)
|
||
|
||
自适应规则:
|
||
- 候选帧: max(candidate_min, duration_min * candidate_per_minute), 上限 candidate_max
|
||
- 关键帧: max(min_key_frames, duration / key_frame_interval_sec), 上限 max_key_frames_cap
|
||
例: 30分钟视频 → 候选60张 → 关键帧12张(每2.5分钟1张)
|
||
例: 3分钟视频 → 候选30张 → 关键帧8张(保底)
|
||
|
||
异常兜底:
|
||
- ffprobe 失败 -> 退化为按 60s 间隔抽帧
|
||
- 帧差分析异常 -> 退化为等距抽 min_key_frames 帧
|
||
- OpenCV 压缩失败 -> 跳过该帧,记录 WARN
|
||
"""
|
||
import os
|
||
import time
|
||
import subprocess
|
||
import requests
|
||
import cv2
|
||
import numpy as np
|
||
from typing import List, Tuple, Optional
|
||
|
||
from ..logger import setup_logger, log_task
|
||
from ..config_loader import load_config
|
||
|
||
logger = setup_logger('fam-edge.preprocessor')
|
||
|
||
|
||
class VideoPreprocessor:
|
||
"""视频预处理器"""
|
||
|
||
def __init__(self, task_id: int):
|
||
self.task_id = task_id
|
||
cfg = load_config()
|
||
video_cfg = cfg.get('video', {})
|
||
self.candidate_per_minute = video_cfg.get('candidate_per_minute', 2)
|
||
self.candidate_min = video_cfg.get('candidate_min', 30)
|
||
self.candidate_max = video_cfg.get('candidate_max', 120)
|
||
self.key_frame_interval_sec = video_cfg.get('key_frame_interval_sec', 150)
|
||
self.min_key_frames = video_cfg.get('min_key_frames', 5)
|
||
self.max_key_frames_floor = video_cfg.get('max_key_frames_floor', 8)
|
||
self.max_key_frames_cap = video_cfg.get('max_key_frames_cap', 30)
|
||
self.mse_threshold = video_cfg.get('mse_threshold', 500)
|
||
self.jpeg_quality = video_cfg.get('jpeg_quality', 80)
|
||
self.max_long_edge = video_cfg.get('max_long_edge', 1024)
|
||
|
||
timeout_cfg = cfg.get('timeout', {})
|
||
self.download_timeout = timeout_cfg.get('download', 60)
|
||
|
||
# 视频时长(秒),在 extract_candidate_frames 中填充
|
||
self.video_duration = 0.0
|
||
|
||
# 临时目录
|
||
self.work_dir = f"/tmp/fam_media/task_{task_id}"
|
||
self.video_path = os.path.join(self.work_dir, f"video_{task_id}.mp4")
|
||
self.frames_dir = os.path.join(self.work_dir, "frames")
|
||
self.keyframes_dir = os.path.join(self.work_dir, "keyframes")
|
||
|
||
def download_video(self, video_url: str) -> str:
|
||
"""下载视频"""
|
||
os.makedirs(self.work_dir, exist_ok=True)
|
||
start = time.time()
|
||
log_task(logger, self.task_id, 'download', f'开始下载: {video_url}')
|
||
|
||
resp = requests.get(video_url, stream=True, timeout=self.download_timeout)
|
||
if resp.status_code != 200:
|
||
raise Exception(f"下载失败: HTTP {resp.status_code}")
|
||
|
||
with open(self.video_path, 'wb') as f:
|
||
for chunk in resp.iter_content(chunk_size=8192):
|
||
f.write(chunk)
|
||
|
||
duration_ms = int((time.time() - start) * 1000)
|
||
size_mb = os.path.getsize(self.video_path) / (1024 * 1024)
|
||
log_task(logger, self.task_id, 'download', f'下载完成: {size_mb:.1f}MB', duration_ms=duration_ms)
|
||
return self.video_path
|
||
|
||
def save_upload(self, file_storage) -> str:
|
||
"""保存推送模式上传的视频文件(multipart),替代 download_video"""
|
||
os.makedirs(self.work_dir, exist_ok=True)
|
||
start = time.time()
|
||
file_storage.save(self.video_path)
|
||
duration_ms = int((time.time() - start) * 1000)
|
||
size_mb = os.path.getsize(self.video_path) / (1024 * 1024)
|
||
log_task(logger, self.task_id, 'upload',
|
||
f'保存上传视频: {size_mb:.1f}MB', duration_ms=duration_ms)
|
||
return self.video_path
|
||
|
||
def _get_video_duration(self, video_path: str) -> float:
|
||
"""用 ffprobe 获取视频时长(秒)"""
|
||
try:
|
||
cmd = [
|
||
'ffprobe', '-v', 'error',
|
||
'-show_entries', 'format=duration',
|
||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||
video_path
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||
if result.returncode == 0:
|
||
return float(result.stdout.strip())
|
||
except Exception as e:
|
||
logger.warning(f"[task_id={self.task_id}] ffprobe 失败: {e}")
|
||
return 0.0
|
||
|
||
def extract_candidate_frames(self, video_path: str) -> List[str]:
|
||
"""等距粗抽候选帧(数量随视频时长自适应,使用快速 seek)"""
|
||
os.makedirs(self.frames_dir, exist_ok=True)
|
||
duration = self._get_video_duration(video_path)
|
||
self.video_duration = duration
|
||
|
||
if duration > 0:
|
||
duration_min = duration / 60
|
||
# 自适应候选帧数:每分钟 candidate_per_minute 张,保底 candidate_min,上限 candidate_max
|
||
candidate_count = min(
|
||
max(self.candidate_min, int(duration_min * self.candidate_per_minute)),
|
||
self.candidate_max
|
||
)
|
||
interval = duration / candidate_count
|
||
else:
|
||
# 兜底: 每 60s 抽一帧
|
||
interval = 60
|
||
candidate_count = 0
|
||
logger.warning(f"[task_id={self.task_id}] ffprobe 失败,退化为 60s 间隔抽帧")
|
||
|
||
# 快速 seek 逐帧提取(比 fps 滤镜快 6-8 倍,ARM CPU 上尤甚)
|
||
timestamps = [i * interval for i in range(candidate_count)] if candidate_count > 0 else []
|
||
if not timestamps:
|
||
# 兜底: 未知时长,用 ffprobe 不可用时按 60s 间隔
|
||
timestamps = [i * 60 for i in range(30)]
|
||
|
||
for i, ts in enumerate(timestamps):
|
||
output_path = os.path.join(self.frames_dir, f'frame_{i+1:04d}.jpg')
|
||
cmd = [
|
||
'ffmpeg', '-ss', f'{ts:.1f}',
|
||
'-i', video_path,
|
||
'-frames:v', '1',
|
||
'-q:v', '2',
|
||
output_path
|
||
]
|
||
try:
|
||
subprocess.run(cmd, capture_output=True, timeout=30, check=True)
|
||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||
logger.warning(f"[task_id={self.task_id}] seek 到 {ts:.1f}s 失败: {e}")
|
||
|
||
# 收集候选帧路径
|
||
frames = sorted([
|
||
os.path.join(self.frames_dir, f)
|
||
for f in os.listdir(self.frames_dir)
|
||
if f.endswith('.jpg')
|
||
])
|
||
log_task(logger, self.task_id, 'extract',
|
||
f'视频时长 {duration:.0f}s, 快速 seek 粗抽 {len(frames)} 张候选帧 (目标 {candidate_count})')
|
||
return frames
|
||
|
||
def _compute_adaptive_key_frame_counts(self) -> Tuple[int, int]:
|
||
"""根据视频时长自适应计算关键帧下限和上限"""
|
||
if self.video_duration > 0:
|
||
# 每隔 key_frame_interval_sec 秒 1 张关键帧
|
||
adaptive = int(self.video_duration / self.key_frame_interval_sec)
|
||
max_kf = min(max(self.max_key_frames_floor, adaptive), self.max_key_frames_cap)
|
||
else:
|
||
max_kf = self.max_key_frames_floor
|
||
min_kf = max(self.min_key_frames, max_kf // 2)
|
||
return min_kf, max_kf
|
||
|
||
def select_key_frames(self, candidate_frames: List[str]) -> List[str]:
|
||
"""帧差分析筛选关键帧(数量随视频时长自适应)"""
|
||
min_kf, max_kf = self._compute_adaptive_key_frame_counts()
|
||
log_task(logger, self.task_id, 'select_keyframes',
|
||
f'自适应关键帧: min={min_kf}, max={max_kf} (视频时长 {self.video_duration:.0f}s)')
|
||
|
||
if len(candidate_frames) <= min_kf:
|
||
return candidate_frames[:max_kf]
|
||
|
||
try:
|
||
# 加载所有候选帧
|
||
images = []
|
||
for path in candidate_frames:
|
||
img = cv2.imread(path)
|
||
if img is not None:
|
||
images.append((path, img))
|
||
|
||
if len(images) < 2:
|
||
return candidate_frames[:max_kf]
|
||
|
||
# 计算每帧与前一关键帧的 MSE
|
||
key_indices = [0] # 首帧必选
|
||
last_key_img = images[0][1]
|
||
|
||
for i in range(1, len(images)):
|
||
mse = self._compute_mse(last_key_img, images[i][1])
|
||
if mse > self.mse_threshold:
|
||
key_indices.append(i)
|
||
last_key_img = images[i][1]
|
||
|
||
# 末帧必选
|
||
if key_indices[-1] != len(images) - 1:
|
||
key_indices.append(len(images) - 1)
|
||
|
||
# 若 < min_kf,从剩余中均匀补足
|
||
if len(key_indices) < min_kf:
|
||
remaining = [i for i in range(len(images)) if i not in key_indices]
|
||
step = max(1, len(remaining) // (min_kf - len(key_indices)))
|
||
for i in range(0, len(remaining), step):
|
||
if len(key_indices) >= min_kf:
|
||
break
|
||
key_indices.append(remaining[i])
|
||
key_indices.sort()
|
||
|
||
# 若 > max_kf,按差异值降序取前 N
|
||
if len(key_indices) > max_kf:
|
||
# 计算每个关键帧与前一帧的差异
|
||
diffs = []
|
||
for idx in key_indices[1:-1]: # 不含首末帧
|
||
diff = self._compute_mse(images[idx-1][1], images[idx][1])
|
||
diffs.append((idx, diff))
|
||
diffs.sort(key=lambda x: x[1], reverse=True)
|
||
# 保留首末帧 + 差异最大的
|
||
keep = {0, len(images)-1}
|
||
for idx, _ in diffs[:max_kf - 2]:
|
||
keep.add(idx)
|
||
key_indices = sorted(keep)
|
||
|
||
key_frames = [images[i][0] for i in key_indices]
|
||
log_task(logger, self.task_id, 'select_keyframes', f'筛选 {len(key_frames)} 张关键帧')
|
||
return key_frames
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[task_id={self.task_id}] 帧差分析异常: {e},退化为等距抽 {min_kf} 帧")
|
||
step = max(1, len(candidate_frames) // min_kf)
|
||
return candidate_frames[::step][:min_kf]
|
||
|
||
def _compute_mse(self, img1, img2) -> float:
|
||
"""计算两帧的 MSE"""
|
||
# 转灰度并统一尺寸
|
||
h = min(img1.shape[0], img2.shape[0])
|
||
w = min(img1.shape[1], img2.shape[1])
|
||
g1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
|
||
g2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
|
||
g1 = cv2.resize(g1, (w, h))
|
||
g2 = cv2.resize(g2, (w, h))
|
||
diff = g1.astype(np.float64) - g2.astype(np.float64)
|
||
mse = np.mean(diff ** 2)
|
||
return float(mse)
|
||
|
||
def compress_frames(self, frame_paths: List[str]) -> List[str]:
|
||
"""压缩关键帧(长边 ≤ max_long_edge,JPEG 质量 80)"""
|
||
os.makedirs(self.keyframes_dir, exist_ok=True)
|
||
compressed = []
|
||
|
||
for i, path in enumerate(frame_paths):
|
||
out_path = os.path.join(self.keyframes_dir, f"keyframe_{i+1:02d}.jpg")
|
||
try:
|
||
img = cv2.imread(path)
|
||
if img is None:
|
||
logger.warning(f"[task_id={self.task_id}] 读取图片失败: {path}")
|
||
continue
|
||
|
||
h, w = img.shape[:2]
|
||
if max(h, w) > self.max_long_edge:
|
||
scale = self.max_long_edge / max(h, w)
|
||
img = cv2.resize(img, (int(w * scale), int(h * scale)))
|
||
|
||
cv2.imwrite(out_path, img, [cv2.IMWRITE_JPEG_QUALITY, self.jpeg_quality])
|
||
compressed.append(out_path)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[task_id={self.task_id}] 压缩失败 {path}: {e}")
|
||
continue
|
||
|
||
log_task(logger, self.task_id, 'compress', f'压缩 {len(compressed)} 张关键帧')
|
||
return compressed
|
||
|
||
def compute_timestamps(self, video_path: str, frame_count: int,
|
||
event_start_time: str) -> List[str]:
|
||
"""计算每帧的绝对时间戳 = 视频开始时间 + 帧偏移"""
|
||
from datetime import datetime, timedelta
|
||
|
||
duration = self._get_video_duration(video_path)
|
||
if duration <= 0:
|
||
duration = frame_count * 60 # 兜底
|
||
|
||
interval = duration / frame_count
|
||
from datetime import timedelta, timezone
|
||
# 统一北京时区: 视频均为北京时间录制,Edge 机器是 UTC,
|
||
# fallback 不能用本地 datetime.now()
|
||
try:
|
||
start_dt = datetime.fromisoformat(event_start_time.replace('Z', '+00:00'))
|
||
if start_dt.tzinfo is not None:
|
||
start_dt = start_dt.astimezone(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||
except Exception:
|
||
start_dt = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||
|
||
timestamps = []
|
||
for i in range(frame_count):
|
||
offset = interval * i
|
||
ts = start_dt + timedelta(seconds=offset)
|
||
timestamps.append(ts.strftime('%Y-%m-%d %H:%M:%S'))
|
||
|
||
return timestamps
|
||
|
||
def cleanup(self):
|
||
"""清理临时文件"""
|
||
import shutil
|
||
try:
|
||
if os.path.exists(self.work_dir):
|
||
shutil.rmtree(self.work_dir)
|
||
log_task(logger, self.task_id, 'cleanup', f'清理临时目录: {self.work_dir}')
|
||
except Exception as e:
|
||
logger.warning(f"[task_id={self.task_id}] 清理失败: {e}")
|