Files
xiaoxia-saas/apps/worker/worker_app/tasks/asset_analyzer.py
T
xiaoxia 560856cf22
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Deploy / Deploy Staging (push) Failing after 210h26m26s
CI/CD Pipeline / Frontend Lint (push) Failing after 210h27m0s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 210h27m7s
fix: resolve all flake8 errors and apply Prettier formatting (#131)
2026-06-30 18:26:53 +08:00

803 lines
25 KiB
Python
Executable File

"""
视频素材分析器 - 基于 FFmpeg + NumPy 的轻量级智能分析
提供:
1. 素材分类 - 基于视频特征的多维度分析
2. 质量评分 - 基于分辨率、帧率、码率、清晰度、稳定性的综合评分
"""
from __future__ import annotations
import json
import logging
import math
import os
import subprocess
import tempfile
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from PIL import Image
from packages.domain.classification import AssetClassification
logger = logging.getLogger(__name__)
@dataclass
class VideoInfo:
"""视频基本信息"""
width: int = 0
height: int = 0
fps: float = 0.0
duration: float = 0.0
bitrate: int = 0
codec: str = ""
has_audio: bool = False
file_size: int = 0
@dataclass
class ColorAnalysis:
"""色彩分析结果"""
dominant_hue: float = 0.0 # 主色调 (0-360)
green_ratio: float = 0.0 # 绿色占比
warm_ratio: float = 0.0 # 暖色调占比
cool_ratio: float = 0.0 # 冷色调占比
avg_saturation: float = 0.0
avg_brightness: float = 0.0
@dataclass
class MotionAnalysis:
"""运动分析结果"""
motion_score: float = 0.0 # 运动幅度 (0-1)
scene_changes: int = 0 # 场景切换次数
@dataclass
class AudioAnalysis:
"""音频分析结果"""
has_audio: bool = False
speech_ratio: float = 0.0 # 人声比例
music_ratio: float = 0.0 # 音乐比例
ambient_ratio: float = 0.0 # 环境音比例
@dataclass
class ClassificationResult:
"""分类结果"""
category: AssetClassification
confidence: float
scores: dict[str, float] = field(default_factory=dict)
@dataclass
class QualityScore:
"""质量评分结果"""
total: float
resolution_score: float = 0.0
fps_score: float = 0.0
bitrate_score: float = 0.0
clarity_score: float = 0.0
stability_score: float = 0.0
class AssetAnalyzer:
"""
轻量级视频素材分析器
使用 FFmpeg + NumPy 进行视频特征分析,不依赖外部 AI API。
"""
def __init__(self, video_path: str, temp_dir: str | None = None):
"""
初始化分析器
Args:
video_path: 视频文件路径
temp_dir: 临时目录,用于存储提取的帧
"""
self.video_path = video_path
self._video_info: VideoInfo | None = None
self._frames: list[np.ndarray] | None = None
self._temp_dir = temp_dir or tempfile.mkdtemp(prefix="asset_analyzer_")
def __del__(self):
"""清理临时文件"""
self._cleanup_temp_dir()
def _cleanup_temp_dir(self):
"""清理临时目录"""
try:
import shutil
if os.path.exists(self._temp_dir):
shutil.rmtree(self._temp_dir)
except Exception:
pass
def get_video_info(self) -> VideoInfo:
"""获取视频基本信息"""
if self._video_info is not None:
return self._video_info
info = VideoInfo()
try:
cmd = [
"ffprobe",
"-v",
"quiet",
"-print_format",
"json",
"-show_format",
"-show_streams",
self.video_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
data = json.loads(result.stdout)
streams = data.get("streams", [])
format_info = data.get("format", {})
for stream in streams:
if stream.get("codec_type") == "video":
info.width = int(stream.get("width", 0))
info.height = int(stream.get("height", 0))
info.codec = stream.get("codec_name", "")
# 解析帧率
fps_str = stream.get("r_frame_rate", "0/1")
if "/" in fps_str:
num, denom = fps_str.split("/")
info.fps = float(num) / float(denom) if float(denom) != 0 else 0.0
else:
info.fps = float(fps_str)
elif stream.get("codec_type") == "audio":
info.has_audio = True
info.duration = float(format_info.get("duration", 0))
info.bitrate = int(format_info.get("bit_rate", 0))
info.file_size = int(format_info.get("size", 0))
except Exception as e:
logger.warning(f"Failed to get video info: {e}")
self._video_info = info
return info
def extract_frames(self, count: int = 10, max_frames: int = 30) -> list[np.ndarray]:
"""
从视频中均匀抽取帧
Args:
count: 抽取的帧数
Returns:
帧数据列表 (RGB 格式)
"""
if self._frames is not None:
return self._frames
frames = []
info = self.get_video_info()
if info.duration <= 0:
logger.warning("Video duration is 0, cannot extract frames")
return frames
try:
# 计算采样间隔
interval = max(1.0, info.duration / count)
for i in range(count):
timestamp = i * interval
# 提取单帧为 PNG
output_path = os.path.join(self._temp_dir, f"frame_{i:03d}.png")
cmd = [
"ffmpeg",
"-y", # 覆盖输出文件
"-ss",
str(timestamp),
"-i",
self.video_path,
"-vframes",
"1",
"-q:v",
"2", # 高质量
"-f",
"image2",
output_path,
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0 and os.path.exists(output_path):
# 读取帧并转换为 numpy 数组
img = self._load_image_as_array(output_path)
if img is not None:
frames.append(img)
except Exception as e:
logger.warning(f"Failed to extract frames: {e}")
self._frames = frames
return frames
def _load_image_as_array(self, path: str) -> np.ndarray | None:
"""加载图片为 numpy 数组 (RGB 格式)"""
try:
from PIL import Image
img = Image.open(path)
if img.mode != "RGB":
img = img.convert("RGB")
return np.array(img)
except Exception as e:
logger.warning(f"Failed to load image {path}: {e}")
return None
def analyze_color_distribution(self, frames: list[np.ndarray] | None = None) -> ColorAnalysis:
"""
分析色彩分布 (HSV 空间)
Returns:
ColorAnalysis 对象
"""
if frames is None:
frames = self.extract_frames()
if not frames:
return ColorAnalysis()
result = ColorAnalysis()
all_hsv = []
try:
for frame in frames:
# RGB 转 HSV
rgb = frame.astype(float) / 255.0
r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
maxc = np.maximum(np.maximum(r, g), b)
minc = np.minimum(np.minimum(r, g), b)
v = maxc
s = np.where(maxc > 0, (maxc - minc) / maxc, 0)
# 计算色相
rc = np.where(maxc == r, (maxc - g - (maxc - b)) / (maxc - minc + 1e-10), 0)
gc = np.where(maxc == g, 2.0 + (maxc - b - (maxc - r)) / (maxc - minc + 1e-10), 0)
bc = np.where(maxc == b, 4.0 + (maxc - r - (maxc - g)) / (maxc - minc + 1e-10), 0)
h = (rc + gc + bc) * 60
h = np.where(h < 0, h + 360, h)
frame_hsv = np.stack([h.flatten(), s.flatten(), v.flatten()], axis=1)
all_hsv.append(frame_hsv)
if all_hsv:
all_hsv = np.vstack(all_hsv)
# 主色调
result.dominant_hue = float(np.median(all_hsv[:, 0]))
# 饱和度
result.avg_saturation = float(np.mean(all_hsv[:, 1]))
# 亮度
result.avg_brightness = float(np.mean(all_hsv[:, 2]))
# 计算颜色比例
# 绿色: 60-180 度
green_mask = (all_hsv[:, 0] >= 60) & (all_hsv[:, 0] <= 180)
result.green_ratio = float(np.mean(green_mask))
# 暖色调 (红/黄/橙): 0-60, 300-360 度
warm_mask = (all_hsv[:, 0] <= 60) | (all_hsv[:, 0] >= 300)
result.warm_ratio = float(np.mean(warm_mask))
# 冷色调 (蓝/青): 180-300 度
cool_mask = (all_hsv[:, 0] >= 180) & (all_hsv[:, 0] <= 300)
result.cool_ratio = float(np.mean(cool_mask))
except Exception as e:
logger.warning(f"Failed to analyze color distribution: {e}")
return result
def analyze_motion(self, frames: list[np.ndarray] | None = None) -> MotionAnalysis:
"""
分析画面运动幅度
Returns:
MotionAnalysis 对象
"""
if frames is None:
frames = self.extract_frames()
if len(frames) < 2:
return MotionAnalysis()
result = MotionAnalysis()
motion_scores = []
scene_changes = 0
try:
for i in range(len(frames) - 1):
# 计算相邻帧差异
diff = np.abs(frames[i + 1].astype(float) - frames[i].astype(float))
mean_diff = np.mean(diff) / 255.0
motion_scores.append(mean_diff)
# 检测场景切换 (帧差异 > 30%)
if mean_diff > 0.3:
scene_changes += 1
if motion_scores:
# 使用中位数避免异常值影响
result.motion_score = float(np.median(motion_scores))
# 归一化到 0-1
result.motion_score = min(1.0, result.motion_score * 5)
result.scene_changes = scene_changes
except Exception as e:
logger.warning(f"Failed to analyze motion: {e}")
return result
def analyze_audio(self) -> AudioAnalysis:
"""
分析音频特征
Returns:
AudioAnalysis 对象
"""
result = AudioAnalysis()
info = self.get_video_info()
if not info.has_audio:
return result
result.has_audio = True
try:
# 提取音频并分析频率特征
audio_path = os.path.join(self._temp_dir, "audio.wav")
cmd = [
"ffmpeg",
"-y",
"-i",
self.video_path,
"-vn", # 不要视频
"-ac",
"1", # 单声道
"-ar",
"8000", # 降低采样率
"-f",
"wav",
audio_path,
]
result_audio = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30,
)
if result_audio.returncode == 0 and os.path.exists(audio_path):
# 读取音频数据
import struct
with open(audio_path, "rb") as f:
# 跳过 WAV 头
f.read(44)
audio_data = f.read()
if len(audio_data) >= 2:
# 转换为 numpy 数组
audio_samples = np.array(struct.unpack(f"<{len(audio_data)//2}h", audio_data), dtype=float)
audio_samples = audio_samples / 32768.0
if len(audio_samples) > 0:
# 简单频谱分析
fft = np.abs(np.fft.rfft(audio_samples[: min(len(audio_samples), 8000)]))
freqs = np.fft.rfftfreq(min(len(audio_samples), 8000), 1 / 8000)
# 人声频率: 300-3400 Hz
speech_mask = (freqs >= 300) & (freqs <= 3400)
speech_energy = np.mean(fft[speech_mask]) if speech_mask.any() else 0
# 音乐低频: 60-250 Hz
bass_mask = (freqs >= 60) & (freqs <= 250)
bass_energy = np.mean(fft[bass_mask]) if bass_mask.any() else 0
# 环境音 (高频): > 4000 Hz
high_mask = freqs > 4000
high_energy = np.mean(fft[high_mask]) if high_mask.any() else 0
total_energy = speech_energy + bass_energy + high_energy + 1e-10
result.speech_ratio = float(speech_energy / total_energy)
result.music_ratio = float(bass_energy / total_energy)
result.ambient_ratio = float(high_energy / total_energy)
except Exception as e:
logger.warning(f"Failed to analyze audio: {e}")
return result
def classify(self) -> ClassificationResult:
"""
综合分析得出分类结果
Returns:
ClassificationResult 对象
"""
# 提取分析数据
frames = self.extract_frames()
color = self.analyze_color_distribution(frames)
motion = self.analyze_motion(frames)
audio = self.analyze_audio()
# 计算各类别得分
scores = self._calculate_category_scores(color, motion, audio)
# 找最高分
if not scores:
return ClassificationResult(
category=AssetClassification.OTHER,
confidence=0.3,
scores={},
)
best_category = max(scores.items(), key=lambda x: x[1])
category = AssetClassification(best_category[0])
confidence = min(0.95, max(0.3, best_category[1]))
return ClassificationResult(
category=category,
confidence=confidence,
scores=scores,
)
def _calculate_category_scores(
self,
color: ColorAnalysis,
motion: MotionAnalysis,
audio: AudioAnalysis,
) -> dict[str, float]:
"""
计算各类别的置信度得分
Args:
color: 色彩分析结果
motion: 运动分析结果
audio: 音频分析结果
Returns:
各类别得分字典
"""
scores = {}
# 1. 风景 (scenic) - 绿色、户外、自然
scenic_score = 0.0
if color.green_ratio > 0.3:
scenic_score += 0.4 * color.green_ratio
if color.avg_saturation > 0.3:
scenic_score += 0.2 * color.avg_saturation
if color.avg_brightness > 0.4:
scenic_score += 0.2
if motion.motion_score > 0.1 and motion.motion_score < 0.5:
scenic_score += 0.2 # 适度运动(如云朵、树叶)
if not audio.has_audio or audio.ambient_ratio > 0.5:
scenic_score += 0.2 # 自然环境音
scores[AssetClassification.SCENIC.value] = min(1.0, scenic_score)
# 2. 产品 (product) - 中等亮度、均匀色彩、低运动
product_score = 0.0
if 0.3 < color.avg_brightness < 0.7:
product_score += 0.3
if color.avg_saturation < 0.5:
product_score += 0.2
if motion.motion_score < 0.15:
product_score += 0.4 # 低运动 = 产品展示
if color.cool_ratio > 0.3:
product_score += 0.2 # 冷色调 = 科技感
scores[AssetClassification.PRODUCT.value] = min(1.0, product_score)
# 3. 人物 (person) - 中等运动、有时有人声
person_score = 0.0
if 0.1 < motion.motion_score < 0.4:
person_score += 0.3 # 适度运动
if audio.has_audio and audio.speech_ratio > 0.3:
person_score += 0.5 # 有人声
if color.avg_brightness > 0.3:
person_score += 0.2
scores[AssetClassification.PERSON.value] = min(1.0, person_score)
# 4. 动物 (animal) - 高运动、有时自然音
animal_score = 0.0
if motion.motion_score > 0.3:
animal_score += 0.4 # 高运动
if motion.scene_changes > 2:
animal_score += 0.2
if audio.has_audio and (audio.ambient_ratio > 0.3 or audio.speech_ratio > 0.2):
animal_score += 0.3
scores[AssetClassification.ANIMAL.value] = min(1.0, animal_score)
# 5. 美食 (food) - 暖色调、高饱和度
food_score = 0.0
if color.warm_ratio > 0.4:
food_score += 0.5
if color.avg_saturation > 0.5:
food_score += 0.3
if 0.4 < color.avg_brightness < 0.8:
food_score += 0.2
scores[AssetClassification.FOOD.value] = min(1.0, food_score)
# 6. 科技 (tech) - 冷色调、低饱和度、低运动
tech_score = 0.0
if color.cool_ratio > 0.4:
tech_score += 0.4
if color.avg_saturation < 0.4:
tech_score += 0.3
if motion.motion_score < 0.2:
tech_score += 0.3
scores[AssetClassification.TECH.value] = min(1.0, tech_score)
# 7. 运动 (sport) - 高运动
sport_score = 0.0
if motion.motion_score > 0.4:
sport_score += 0.6
if motion.scene_changes > 3:
sport_score += 0.2
if color.avg_brightness > 0.4:
sport_score += 0.2
scores[AssetClassification.SPORT.value] = min(1.0, sport_score)
# 8. 音乐 (music) - 有节奏性音乐
music_score = 0.0
if audio.has_audio and audio.music_ratio > 0.4:
music_score += 0.6
# 纯视觉判断:色彩丰富但非自然
if color.avg_saturation > 0.5 and color.green_ratio < 0.2:
music_score += 0.3
scores[AssetClassification.MUSIC.value] = min(1.0, music_score)
# 9. 其他 (other) - 默认最低分
scores[AssetClassification.OTHER.value] = 0.1
return scores
def calculate_quality_score(self) -> QualityScore:
"""
计算视频质量综合评分 (0-100)
评分维度:
1. 分辨率得分 (25分)
2. 帧率得分 (20分)
3. 码率得分 (20分)
4. 清晰度得分 (20分) - Laplacian 方差
5. 稳定性得分 (15分) - 帧间位移方差
"""
info = self.get_video_info()
frames = self.extract_frames()
# 1. 分辨率得分
resolution_score = self._score_resolution(info.width, info.height)
# 2. 帧率得分
fps_score = self._score_framerate(info.fps)
# 3. 码率得分
bitrate_score = self._score_bitrate(info.bitrate)
# 4. 清晰度得分
clarity_score = self._score_clarity(frames)
# 5. 稳定性得分
stability_score = self._score_stability(frames)
total = resolution_score + fps_score + bitrate_score + clarity_score + stability_score
return QualityScore(
total=round(min(100, max(0, total)), 1),
resolution_score=resolution_score,
fps_score=fps_score,
bitrate_score=bitrate_score,
clarity_score=clarity_score,
stability_score=stability_score,
)
def _score_resolution(self, width: int, height: int) -> float:
"""分辨率评分 (满分 25)"""
pixels = width * height
if pixels >= 3840 * 2160: # 4K
return 25.0
elif pixels >= 2560 * 1440: # 2K
return 22.0
elif pixels >= 1920 * 1080: # 1080p
return 20.0
elif pixels >= 1280 * 720: # 720p
return 15.0
elif pixels >= 854 * 480: # 480p
return 8.0
else:
return 3.0
def _score_framerate(self, fps: float) -> float:
"""帧率评分 (满分 20)"""
if fps >= 60:
return 20.0
elif fps >= 30:
return 15.0
elif fps >= 24:
return 10.0
elif fps >= 15:
return 7.0
else:
return 5.0
def _score_bitrate(self, bitrate: int) -> float:
"""码率评分 (满分 20)"""
bitrate_mbps = bitrate / 1_000_000
if bitrate_mbps > 10:
return 20.0
elif bitrate_mbps >= 5:
return 15.0
elif bitrate_mbps >= 2:
return 10.0
elif bitrate_mbps >= 0.5:
return 5.0
else:
return 3.0
def _score_clarity(self, frames: list[np.ndarray]) -> float:
"""
清晰度评分 (满分 20)
使用 Laplacian 方差评估画面清晰度
高方差 = 细节丰富 = 高分
"""
if not frames:
return 10.0 # 默认中等分
try:
variances = []
for frame in frames[:5]: # 只分析前 5 帧
if len(frame.shape) == 3:
# 转灰度
gray = np.dot(frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
else:
gray = frame
# Laplacian 算子
laplacian = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
# 手动计算卷积
from scipy import signal
laplacian_img = signal.convolve2d(gray.astype(float), laplacian, mode="same")
variance = np.var(laplacian_img)
variances.append(variance)
# 归一化方差到 0-20 分
avg_variance = np.mean(variances)
# 根据经验值调整
score = min(20.0, avg_variance / 100)
return float(score)
except ImportError:
# 如果没有 scipy,使用简化方法
return 10.0
except Exception:
return 10.0
def _score_stability(self, frames: list[np.ndarray]) -> float:
"""
稳定性评分 (满分 15)
分析帧间位移方差
画面稳定 = 高分
剧烈抖动 = 低分
"""
if len(frames) < 2:
return 10.0 # 默认中等分
try:
displacements = []
for i in range(len(frames) - 1):
# 缩小帧以加速处理
scale = 0.25
new_h = int(frames[i].shape[0] * scale)
new_w = int(frames[i].shape[1] * scale)
frame1_small = np.array(Image.fromarray(frames[i]).resize((new_w, new_h)))
new_h2 = int(frames[i + 1].shape[0] * scale)
new_w2 = int(frames[i + 1].shape[1] * scale)
frame2_small = np.array(Image.fromarray(frames[i + 1]).resize((new_w2, new_h2)))
# 简单位移检测:灰度差
gray1 = np.mean(frame1_small, axis=2) if len(frame1_small.shape) == 3 else frame1_small
gray2 = np.mean(frame2_small, axis=2) if len(frame2_small.shape) == 3 else frame2_small
diff = np.abs(gray2.astype(float) - gray1.astype(float))
displacement = np.mean(diff) / 255.0
displacements.append(displacement)
# 高位移方差 = 不稳定
if displacements:
displacement_variance = np.var(displacements)
# 归一化
instability = min(1.0, displacement_variance * 10)
score = 15.0 * (1.0 - instability)
return float(max(0.0, score))
return 10.0
except Exception:
return 10.0
def classify_asset_real(video_path: str) -> tuple[str, float]:
"""
真实分类入口函数
Args:
video_path: 视频文件路径
Returns:
(分类类别, 置信度)
"""
try:
analyzer = AssetAnalyzer(video_path)
result = analyzer.classify()
return result.category.value, result.confidence
except Exception as e:
logger.warning(f"Classification failed, using fallback: {e}")
return AssetClassification.OTHER.value, 0.3
def calculate_quality_score_real(video_path: str) -> float:
"""
质量评分入口函数
Args:
video_path: 视频文件路径
Returns:
质量评分 (0-100)
"""
try:
analyzer = AssetAnalyzer(video_path)
result = analyzer.calculate_quality_score()
return result.total
except Exception as e:
logger.warning(f"Quality scoring failed, using fallback: {e}")
return 50.0