feat: adaptive keyframe count based on video duration

- Replace fixed 5-8 keyframe limit with duration-based adaptive sizing
- Candidate frames: clamp(duration_min × 2, 30, 120)
- Keyframe cap: clamp(duration / 150s, 8, 30)
- 30min video → 12 keyframes (was 8), 60min → 24, 120min → 30
- Short videos (<12min) still get floor of 8 keyframes
- Add Ollama keep-alive config doc to PROGRESS.md (OLLAMA_KEEP_ALIVE=-1)
- Update config.yaml and config.yaml.example with new video params
This commit is contained in:
ericwyuan
2026-08-20 01:29:14 +08:00
parent 40944428d1
commit 727642d38b
5 changed files with 87 additions and 36 deletions

View File

@@ -3,13 +3,19 @@ Video-Preprocessor - 视频预处理
流程:
1. 下载视频(超时 60s
2. FFmpeg 等距粗抽 30 张候选帧
3. OpenCV 帧差分析筛选 5-8 张关键帧MSE 阈值)
2. 根据视频时长自适应计算候选帧数,FFmpeg 等距粗抽
3. 根据视频时长自适应计算关键帧数,OpenCV 帧差分析筛选MSE 阈值)
4. 压缩(长边 ≤ 1024pxJPEG 质量 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 间隔抽帧
- 帧差分析异常 -> 退化为等距抽 5
- 帧差分析异常 -> 退化为等距抽 min_key_frames
- OpenCV 压缩失败 -> 跳过该帧,记录 WARN
"""
import os
@@ -33,9 +39,13 @@ class VideoPreprocessor:
self.task_id = task_id
cfg = load_config()
video_cfg = cfg.get('video', {})
self.candidate_frames = video_cfg.get('candidate_frames', 30)
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 = video_cfg.get('max_key_frames', 8)
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)
@@ -43,6 +53,9 @@ class VideoPreprocessor:
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")
@@ -96,15 +109,23 @@ class VideoPreprocessor:
return 0.0
def extract_candidate_frames(self, video_path: str) -> List[str]:
"""等距粗抽候选帧"""
"""等距粗抽候选帧(数量随视频时长自适应)"""
os.makedirs(self.frames_dir, exist_ok=True)
duration = self._get_video_duration(video_path)
self.video_duration = duration
if duration > 0:
interval = duration / self.candidate_frames
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 间隔抽帧")
cmd = [
@@ -125,13 +146,29 @@ class VideoPreprocessor:
for f in os.listdir(self.frames_dir)
if f.endswith('.jpg')
])
log_task(logger, self.task_id, 'extract', f'粗抽 {len(frames)} 张候选帧')
log_task(logger, self.task_id, 'extract',
f'视频时长 {duration:.0f}s, 粗抽 {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]:
"""帧差分析筛选关键帧"""
if len(candidate_frames) <= self.min_key_frames:
return candidate_frames[:self.max_key_frames]
"""帧差分析筛选关键帧(数量随视频时长自适应)"""
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:
# 加载所有候选帧
@@ -142,7 +179,7 @@ class VideoPreprocessor:
images.append((path, img))
if len(images) < 2:
return candidate_frames[:self.max_key_frames]
return candidate_frames[:max_kf]
# 计算每帧与前一关键帧的 MSE
key_indices = [0] # 首帧必选
@@ -158,18 +195,18 @@ class VideoPreprocessor:
if key_indices[-1] != len(images) - 1:
key_indices.append(len(images) - 1)
# 若 < min_key_frames,从剩余中均匀补足
if len(key_indices) < self.min_key_frames:
# 若 < 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) // (self.min_key_frames - len(key_indices)))
step = max(1, len(remaining) // (min_kf - len(key_indices)))
for i in range(0, len(remaining), step):
if len(key_indices) >= self.min_key_frames:
if len(key_indices) >= min_kf:
break
key_indices.append(remaining[i])
key_indices.sort()
# 若 > max_key_frames,按差异值降序取前 N
if len(key_indices) > self.max_key_frames:
# 若 > max_kf,按差异值降序取前 N
if len(key_indices) > max_kf:
# 计算每个关键帧与前一帧的差异
diffs = []
for idx in key_indices[1:-1]: # 不含首末帧
@@ -178,7 +215,7 @@ class VideoPreprocessor:
diffs.sort(key=lambda x: x[1], reverse=True)
# 保留首末帧 + 差异最大的
keep = {0, len(images)-1}
for idx, _ in diffs[:self.max_key_frames - 2]:
for idx, _ in diffs[:max_kf - 2]:
keep.add(idx)
key_indices = sorted(keep)
@@ -187,9 +224,9 @@ class VideoPreprocessor:
return key_frames
except Exception as e:
logger.warning(f"[task_id={self.task_id}] 帧差分析异常: {e},退化为等距抽 5")
step = max(1, len(candidate_frames) // self.min_key_frames)
return candidate_frames[::step][:self.min_key_frames]
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"""