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:
@@ -13,11 +13,15 @@ server:
|
||||
port: 5000
|
||||
max_concurrent_tasks: 1
|
||||
|
||||
# 关键帧筛选参数
|
||||
# 关键帧筛选参数(自适应:帧数随视频时长动态计算)
|
||||
video:
|
||||
candidate_frames: 30
|
||||
min_key_frames: 5
|
||||
max_key_frames: 8
|
||||
candidate_per_minute: 2 # 每分钟粗抽候选帧数
|
||||
candidate_min: 30 # 候选帧下限(短视频保底)
|
||||
candidate_max: 120 # 候选帧上限(超长视频截断)
|
||||
key_frame_interval_sec: 150 # 关键帧间隔(秒),每2.5分钟1张
|
||||
min_key_frames: 5 # 关键帧下限(帧差不足时补足到此数)
|
||||
max_key_frames_floor: 8 # 关键帧上限的下限(短视频保底)
|
||||
max_key_frames_cap: 30 # 关键帧上限(超长视频截断)
|
||||
mse_threshold: 500
|
||||
jpeg_quality: 80
|
||||
max_long_edge: 1024
|
||||
|
||||
@@ -13,12 +13,16 @@ server:
|
||||
port: 5000
|
||||
max_concurrent_tasks: 1
|
||||
|
||||
# 关键帧筛选参数
|
||||
# 关键帧筛选参数(自适应:帧数随视频时长动态计算)
|
||||
video:
|
||||
candidate_frames: 30 # 粗抽候选帧数
|
||||
min_key_frames: 5 # 最少关键帧
|
||||
max_key_frames: 8 # 最多关键帧
|
||||
mse_threshold: 500 # 帧差阈值
|
||||
candidate_per_minute: 2 # 每分钟粗抽候选帧数
|
||||
candidate_min: 30 # 候选帧下限(短视频保底)
|
||||
candidate_max: 120 # 候选帧上限(超长视频截断)
|
||||
key_frame_interval_sec: 150 # 关键帧间隔(秒),每2.5分钟1张
|
||||
min_key_frames: 5 # 关键帧下限(帧差不足时补足到此数)
|
||||
max_key_frames_floor: 8 # 关键帧上限的下限(短视频保底)
|
||||
max_key_frames_cap: 30 # 关键帧上限(超长视频截断)
|
||||
mse_threshold: 500 # 帧差阈值
|
||||
jpeg_quality: 80
|
||||
max_long_edge: 1024
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@ Video-Preprocessor - 视频预处理
|
||||
|
||||
流程:
|
||||
1. 下载视频(超时 60s)
|
||||
2. FFmpeg 等距粗抽 30 张候选帧
|
||||
3. OpenCV 帧差分析筛选 5-8 张关键帧(MSE 阈值)
|
||||
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 间隔抽帧
|
||||
- 帧差分析异常 -> 退化为等距抽 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"""
|
||||
|
||||
Reference in New Issue
Block a user