3b949a464f
CI/CD Pipeline / Check if frontend-only change (push) Has been cancelled
CI/CD Pipeline / Validate - Code Quality (push) Has been cancelled
CI/CD Pipeline / Validate - Type Check (mypy) (push) Has been cancelled
CI/CD Pipeline / Validate - Migration (alembic) (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Lint (push) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (push) Has been cancelled
CI/CD Pipeline / PR Build API Image (push) Has been cancelled
CI/CD Pipeline / PR Build Web Image (push) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
668 lines
25 KiB
Python
Executable File
668 lines
25 KiB
Python
Executable File
"""asset_quality_scoring 纯逻辑单测 — 第89波.
|
|
|
|
测试评分纯函数,不依赖 FFmpeg/视频文件。
|
|
覆盖:分辨率评分、帧率评分、码率评分、清晰度评分、稳定性评分、
|
|
质量总评分、9分类评分、分类结果计算。
|
|
"""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from apps.worker.worker_app.tasks.asset_quality_scoring import (
|
|
AudioAnalysis,
|
|
ClassificationResult,
|
|
ColorAnalysis,
|
|
MotionAnalysis,
|
|
QualityScore,
|
|
VideoInfo,
|
|
calculate_category_scores,
|
|
calculate_quality_score,
|
|
classify_from_analysis,
|
|
score_bitrate,
|
|
score_clarity,
|
|
score_framerate,
|
|
score_resolution,
|
|
score_stability,
|
|
)
|
|
from packages.domain.classification import AssetClassification
|
|
|
|
# ── 分辨率评分 ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestScoreResolution:
|
|
"""分辨率评分边界测试."""
|
|
|
|
def test_4k_full_score(self):
|
|
"""4K 及以上满分 25."""
|
|
assert score_resolution(3840, 2160) == 25.0
|
|
assert score_resolution(4096, 2160) == 25.0
|
|
assert score_resolution(7680, 4320) == 25.0 # 8K
|
|
|
|
def test_2k_score(self):
|
|
"""2K 档 22 分."""
|
|
assert score_resolution(2560, 1440) == 22.0
|
|
assert score_resolution(3000, 1600) == 22.0
|
|
# 刚好低于 4K
|
|
assert score_resolution(3839, 2159) == 22.0
|
|
|
|
def test_1080p_score(self):
|
|
"""1080p 档 20 分."""
|
|
assert score_resolution(1920, 1080) == 20.0
|
|
assert score_resolution(2000, 1080) == 20.0
|
|
# 刚好低于 2K
|
|
assert score_resolution(2559, 1439) == 20.0
|
|
|
|
def test_720p_score(self):
|
|
"""720p 档 15 分."""
|
|
assert score_resolution(1280, 720) == 15.0
|
|
assert score_resolution(1280, 720) == 15.0
|
|
# 刚好低于 1080p
|
|
assert score_resolution(1919, 1079) == 15.0
|
|
# 1080x720 像素数 < 1280x720,掉到 480p 档
|
|
assert score_resolution(1080, 720) == 8.0
|
|
|
|
def test_480p_score(self):
|
|
"""480p 档 8 分."""
|
|
assert score_resolution(854, 480) == 8.0
|
|
assert score_resolution(854, 480) == 8.0
|
|
# 刚好低于 720p
|
|
assert score_resolution(1279, 719) == 8.0
|
|
# 720x480 像素数 < 854x480,掉到最低档
|
|
assert score_resolution(720, 480) == 3.0
|
|
|
|
def test_low_resolution_score(self):
|
|
"""低于 480p 给 3 分."""
|
|
assert score_resolution(640, 360) == 3.0
|
|
assert score_resolution(320, 240) == 3.0
|
|
assert score_resolution(0, 0) == 3.0
|
|
|
|
def test_non_standard_aspect_ratio(self):
|
|
"""非标准宽高比按像素总数计算."""
|
|
# 竖屏 1080x1920 像素数 = 1080p
|
|
assert score_resolution(1080, 1920) == 20.0
|
|
# 超宽屏
|
|
assert score_resolution(2560, 1080) == 20.0 # 像素≈2.7M < 2K(3.6M)
|
|
# 1x1 极低分辨率
|
|
assert score_resolution(1, 1) == 3.0
|
|
|
|
def test_negative_values(self):
|
|
"""负尺寸:负负得正按像素数算,一正一负 = 负数像素 = 最低档."""
|
|
# 一正一负 → 负像素总数 → < 480p → 3分
|
|
assert score_resolution(1920, -1080) == 3.0
|
|
assert score_resolution(-1920, 1080) == 3.0
|
|
# 都是 0 → 3分
|
|
assert score_resolution(0, 0) == 3.0
|
|
|
|
|
|
# ── 帧率评分 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestScoreFramerate:
|
|
"""帧率评分边界测试."""
|
|
|
|
def test_60fps_full_score(self):
|
|
"""60fps 及以上满分 20."""
|
|
assert score_framerate(60) == 20.0
|
|
assert score_framerate(120) == 20.0
|
|
assert score_framerate(240) == 20.0
|
|
|
|
def test_30fps_score(self):
|
|
"""30-59fps 给 15 分."""
|
|
assert score_framerate(30) == 15.0
|
|
assert score_framerate(59.9) == 15.0
|
|
assert score_framerate(59) == 15.0
|
|
|
|
def test_24fps_score(self):
|
|
"""24-29fps 给 10 分."""
|
|
assert score_framerate(24) == 10.0
|
|
assert score_framerate(29.97) == 10.0
|
|
assert score_framerate(25) == 10.0
|
|
|
|
def test_15fps_score(self):
|
|
"""15-23fps 给 7 分."""
|
|
assert score_framerate(15) == 7.0
|
|
assert score_framerate(23.9) == 7.0
|
|
assert score_framerate(20) == 7.0
|
|
|
|
def test_low_fps_score(self):
|
|
"""低于 15fps 给 5 分."""
|
|
assert score_framerate(10) == 5.0
|
|
assert score_framerate(1) == 5.0
|
|
assert score_framerate(0) == 5.0
|
|
|
|
def test_negative_fps(self):
|
|
"""负帧率按最低档."""
|
|
assert score_framerate(-30) == 5.0
|
|
|
|
def test_float_fps(self):
|
|
"""浮点帧率正确判断边界."""
|
|
# 29.97 (NTSC) < 30 → 24fps 档
|
|
assert score_framerate(29.97) == 10.0
|
|
assert score_framerate(23.976) == 7.0
|
|
assert score_framerate(59.94) == 15.0 # 59.94 < 60 → 30fps 档
|
|
assert score_framerate(30.0) == 15.0
|
|
|
|
|
|
# ── 码率评分 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestScoreBitrate:
|
|
"""码率评分边界测试."""
|
|
|
|
def test_high_bitrate_full_score(self):
|
|
"""10Mbps 以上满分 20."""
|
|
assert score_bitrate(10_000_001) == 20.0
|
|
assert score_bitrate(50_000_000) == 20.0
|
|
assert score_bitrate(100_000_000) == 20.0
|
|
|
|
def test_5mbps_score(self):
|
|
"""5-10Mbps 给 15 分."""
|
|
assert score_bitrate(5_000_000) == 15.0
|
|
assert score_bitrate(8_000_000) == 15.0
|
|
assert score_bitrate(10_000_000) == 15.0 # 刚好 10Mbps = 不 > 10
|
|
|
|
def test_2mbps_score(self):
|
|
"""2-5Mbps 给 10 分."""
|
|
assert score_bitrate(2_000_000) == 10.0
|
|
assert score_bitrate(3_000_000) == 10.0
|
|
assert score_bitrate(4_999_999) == 10.0
|
|
|
|
def test_05mbps_score(self):
|
|
"""0.5-2Mbps 给 5 分."""
|
|
assert score_bitrate(500_000) == 5.0
|
|
assert score_bitrate(1_000_000) == 5.0
|
|
assert score_bitrate(1_999_999) == 5.0
|
|
|
|
def test_low_bitrate_score(self):
|
|
"""低于 0.5Mbps 给 3 分."""
|
|
assert score_bitrate(499_999) == 3.0
|
|
assert score_bitrate(100_000) == 3.0
|
|
assert score_bitrate(0) == 3.0
|
|
|
|
def test_negative_bitrate(self):
|
|
"""负码率按最低档."""
|
|
assert score_bitrate(-5_000_000) == 3.0
|
|
|
|
def test_zero_bitrate(self):
|
|
"""0 码率 = 最低档."""
|
|
assert score_bitrate(0) == 3.0
|
|
|
|
|
|
# ── 清晰度评分 ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestScoreClarity:
|
|
"""清晰度评分测试."""
|
|
|
|
def test_empty_frames_default_score(self):
|
|
"""空帧列表给默认 10 分."""
|
|
assert score_clarity([]) == 10.0
|
|
|
|
def test_constant_image_low_clarity(self):
|
|
"""纯色图像比高细节图像清晰度低很多."""
|
|
# 纯灰色图像
|
|
gray_frame = np.full((100, 100), 128, dtype=np.uint8)
|
|
score_constant = score_clarity([gray_frame])
|
|
|
|
# 高细节随机图像
|
|
detail_frame = np.random.randint(0, 256, (100, 100), dtype=np.uint8)
|
|
score_detail = score_clarity([detail_frame])
|
|
|
|
# 纯色图应该显著低于高细节图
|
|
assert score_constant < score_detail
|
|
assert 0.0 <= score_constant <= 20.0
|
|
|
|
def test_edge_rich_image_high_clarity(self):
|
|
"""高频边缘图像有较高清晰度得分."""
|
|
# 棋盘格图案,边缘丰富
|
|
frame = np.zeros((100, 100), dtype=np.uint8)
|
|
for i in range(0, 100, 10):
|
|
for j in range(0, 100, 10):
|
|
if (i // 10 + j // 10) % 2 == 0:
|
|
frame[i : i + 10, j : j + 10] = 255
|
|
score = score_clarity([frame])
|
|
assert score > 1.0 # 应有一定清晰度
|
|
assert 0.0 <= score <= 20.0
|
|
|
|
def test_rgb_frame_converts_to_gray(self):
|
|
"""RGB 帧会被转灰度后计算."""
|
|
rgb_frame = np.random.randint(0, 256, (50, 50, 3), dtype=np.uint8)
|
|
score_rgb = score_clarity([rgb_frame])
|
|
# 对应灰度图
|
|
gray = np.dot(rgb_frame[..., :3], [0.299, 0.587, 0.114]).astype(np.uint8)
|
|
score_gray = score_clarity([gray])
|
|
# 两者应近似相等
|
|
assert abs(score_rgb - score_gray) < 0.01
|
|
|
|
def test_only_first_five_frames_analyzed(self):
|
|
"""只分析前 5 帧."""
|
|
# 10 帧:前 5 帧纯色,后 5 帧高对比度
|
|
frames = []
|
|
for _ in range(5):
|
|
frames.append(np.full((50, 50), 128, dtype=np.uint8))
|
|
for _ in range(5):
|
|
high_freq = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
|
frames.append(high_freq)
|
|
score_10 = score_clarity(frames)
|
|
score_5 = score_clarity(frames[:5])
|
|
# 前 5 帧相同,得分应相同
|
|
assert abs(score_10 - score_5) < 0.01
|
|
|
|
def test_score_within_bounds(self):
|
|
"""得分始终在 0-20 范围内."""
|
|
for _ in range(10):
|
|
frame = np.random.randint(0, 256, (30, 30, 3), dtype=np.uint8)
|
|
score = score_clarity([frame])
|
|
assert 0.0 <= score <= 20.0
|
|
|
|
def test_multiple_frames_averaged(self):
|
|
"""多帧取平均方差."""
|
|
# 第 1 帧低细节,第 2 帧高细节
|
|
low_detail = np.full((50, 50), 100, dtype=np.uint8)
|
|
high_detail = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
|
|
|
score_low = score_clarity([low_detail])
|
|
score_high = score_clarity([high_detail])
|
|
score_both = score_clarity([low_detail, high_detail])
|
|
|
|
# 混合得分应在两者之间
|
|
assert score_low <= score_both <= score_high
|
|
|
|
|
|
# ── 稳定性评分 ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestScoreStability:
|
|
"""稳定性评分测试."""
|
|
|
|
def test_single_frame_default_score(self):
|
|
"""不足 2 帧给默认 10 分."""
|
|
assert score_stability([]) == 10.0
|
|
assert score_stability([np.zeros((10, 10, 3), dtype=np.uint8)]) == 10.0
|
|
|
|
def test_identical_frames_max_stability(self):
|
|
"""完全相同的帧 = 高稳定性."""
|
|
frame = np.random.randint(50, 200, (100, 100, 3), dtype=np.uint8)
|
|
score = score_stability([frame, frame.copy()])
|
|
assert score > 10.0 # 应该接近满分 15
|
|
|
|
def test_very_different_frames_low_stability(self):
|
|
"""位移方差大的多帧序列 = 低稳定性."""
|
|
# 构造 4 帧:帧间位移差异大(有的帧相似、有的帧完全不同)
|
|
# 位移方差大 → 不稳定 → 低分
|
|
base = np.random.randint(100, 150, (100, 100, 3), dtype=np.uint8)
|
|
frames = [
|
|
base, # 帧0
|
|
base, # 帧1 (完全相同 → 位移0)
|
|
np.full_like(base, 255), # 帧2 (纯白 → 位移大)
|
|
base, # 帧3 (回到基准 → 位移又大)
|
|
]
|
|
score = score_stability(frames)
|
|
# 位移差异大 → 方差大 → 稳定性低
|
|
assert score < 10.0
|
|
assert 0.0 <= score <= 15.0
|
|
|
|
def test_score_within_bounds(self):
|
|
"""得分始终在 0-15 范围内."""
|
|
for _ in range(10):
|
|
f1 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
|
|
f2 = np.random.randint(0, 256, (40, 40, 3), dtype=np.uint8)
|
|
score = score_stability([f1, f2])
|
|
assert 0.0 <= score <= 15.0
|
|
|
|
def test_gray_frames_also_work(self):
|
|
"""灰度帧也能计算."""
|
|
f1 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
|
f2 = np.random.randint(0, 256, (50, 50), dtype=np.uint8)
|
|
score = score_stability([f1, f2])
|
|
assert 0.0 <= score <= 15.0
|
|
|
|
def test_multiple_frame_pairs(self):
|
|
"""多对帧取方差."""
|
|
base = np.random.randint(100, 150, (60, 60, 3), dtype=np.uint8)
|
|
# 5 帧相似的
|
|
frames = []
|
|
for i in range(5):
|
|
f = base.copy()
|
|
# 轻微变化
|
|
f = np.clip(f.astype(int) + np.random.randint(-5, 6, f.shape), 0, 255).astype(np.uint8)
|
|
frames.append(f)
|
|
score = score_stability(frames)
|
|
assert score > 5.0 # 相似帧应该有一定稳定性
|
|
|
|
|
|
# ── 质量总评分 ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCalculateQualityScore:
|
|
"""质量综合评分测试."""
|
|
|
|
def test_perfect_video_near_100(self):
|
|
"""完美参数的视频接近 100 分."""
|
|
info = VideoInfo(
|
|
width=3840,
|
|
height=2160,
|
|
fps=60,
|
|
bitrate=20_000_000,
|
|
)
|
|
# 用高细节帧提升清晰度分
|
|
frame = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
|
|
result = calculate_quality_score(info, [frame])
|
|
assert isinstance(result, QualityScore)
|
|
assert result.resolution_score == 25.0
|
|
assert result.fps_score == 20.0
|
|
assert result.bitrate_score == 20.0
|
|
assert 50.0 <= result.total <= 100.0
|
|
|
|
def test_low_quality_video(self):
|
|
"""低质量视频得分低."""
|
|
info = VideoInfo(
|
|
width=320,
|
|
height=240,
|
|
fps=10,
|
|
bitrate=100_000,
|
|
)
|
|
result = calculate_quality_score(info, [])
|
|
assert isinstance(result, QualityScore)
|
|
assert result.resolution_score == 3.0
|
|
assert result.fps_score == 5.0
|
|
assert result.bitrate_score == 3.0
|
|
# 无帧时清晰度和稳定性各给 10 分默认
|
|
assert result.clarity_score == 10.0
|
|
assert result.stability_score == 10.0
|
|
assert result.total == 31.0 # 3+5+3+10+10
|
|
|
|
def test_no_frames_uses_defaults(self):
|
|
"""不传 frames 时清晰度/稳定性给默认分."""
|
|
info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
|
|
result = calculate_quality_score(info)
|
|
assert result.clarity_score == 10.0
|
|
assert result.stability_score == 10.0
|
|
assert result.resolution_score == 20.0
|
|
assert result.fps_score == 15.0
|
|
assert result.bitrate_score == 15.0
|
|
assert result.total == 70.0
|
|
|
|
def test_total_capped_at_100(self):
|
|
"""总分不超过 100."""
|
|
info = VideoInfo(
|
|
width=7680,
|
|
height=4320,
|
|
fps=240,
|
|
bitrate=100_000_000,
|
|
)
|
|
# 即使所有维度都满,总分不超 100
|
|
result = calculate_quality_score(info, [])
|
|
assert result.total <= 100.0
|
|
|
|
def test_total_minimum_zero(self):
|
|
"""总分不低于 0."""
|
|
info = VideoInfo(width=0, height=0, fps=0, bitrate=0)
|
|
result = calculate_quality_score(info, [])
|
|
assert result.total >= 0.0
|
|
|
|
def test_total_is_rounded(self):
|
|
"""总分保留 1 位小数."""
|
|
info = VideoInfo(width=1920, height=1080, fps=30, bitrate=5_000_000)
|
|
result = calculate_quality_score(info, [])
|
|
# 检查是 1 位小数
|
|
assert round(result.total, 1) == result.total
|
|
|
|
|
|
# ── 分类评分 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCalculateCategoryScores:
|
|
"""分类评分计算测试."""
|
|
|
|
def test_scenic_high_green_and_motion(self):
|
|
"""绿色+适度运动+自然音 → 风景高分."""
|
|
color = ColorAnalysis(
|
|
green_ratio=0.5,
|
|
avg_saturation=0.5,
|
|
avg_brightness=0.6,
|
|
warm_ratio=0.2,
|
|
cool_ratio=0.3,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.3, scene_changes=1)
|
|
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.7)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.SCENIC.value] > 0.5
|
|
assert scores[AssetClassification.SCENIC.value] <= 1.0
|
|
|
|
def test_product_low_motion_cool_tone(self):
|
|
"""低运动+冷色调 → 产品高分."""
|
|
color = ColorAnalysis(
|
|
avg_brightness=0.5,
|
|
avg_saturation=0.3,
|
|
cool_ratio=0.5,
|
|
green_ratio=0.1,
|
|
warm_ratio=0.2,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
|
|
audio = AudioAnalysis(has_audio=False)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.PRODUCT.value] > 0.5
|
|
|
|
def test_person_with_speech(self):
|
|
"""有人声+适度运动 → 人物高分."""
|
|
color = ColorAnalysis(avg_brightness=0.5)
|
|
motion = MotionAnalysis(motion_score=0.25, scene_changes=1)
|
|
audio = AudioAnalysis(has_audio=True, speech_ratio=0.6)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.PERSON.value] > 0.5
|
|
|
|
def test_animal_high_motion(self):
|
|
"""高运动+多场景切换 → 动物高分."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis(motion_score=0.6, scene_changes=5)
|
|
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.5)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.ANIMAL.value] > 0.5
|
|
|
|
def test_food_warm_saturated(self):
|
|
"""暖色调+高饱和 → 美食高分."""
|
|
color = ColorAnalysis(
|
|
warm_ratio=0.6,
|
|
avg_saturation=0.7,
|
|
avg_brightness=0.6,
|
|
green_ratio=0.1,
|
|
cool_ratio=0.2,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
|
|
audio = AudioAnalysis(has_audio=False)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.FOOD.value] > 0.5
|
|
|
|
def test_tech_cool_low_saturation(self):
|
|
"""冷色调+低饱和+低运动 → 科技高分."""
|
|
color = ColorAnalysis(
|
|
cool_ratio=0.6,
|
|
avg_saturation=0.3,
|
|
avg_brightness=0.5,
|
|
green_ratio=0.1,
|
|
warm_ratio=0.2,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.1, scene_changes=0)
|
|
audio = AudioAnalysis(has_audio=False)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.TECH.value] > 0.5
|
|
|
|
def test_sport_high_motion(self):
|
|
"""高运动+多场景 → 运动高分."""
|
|
color = ColorAnalysis(avg_brightness=0.6)
|
|
motion = MotionAnalysis(motion_score=0.7, scene_changes=5)
|
|
audio = AudioAnalysis(has_audio=False)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.SPORT.value] > 0.5
|
|
|
|
def test_music_high_music_ratio(self):
|
|
"""高音乐比例 → 音乐高分."""
|
|
color = ColorAnalysis(avg_saturation=0.6, green_ratio=0.1)
|
|
motion = MotionAnalysis(motion_score=0.2)
|
|
audio = AudioAnalysis(has_audio=True, music_ratio=0.7)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.MUSIC.value] > 0.5
|
|
|
|
def test_other_has_base_score(self):
|
|
"""其他分类有基础分 0.1."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis()
|
|
audio = AudioAnalysis()
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert scores[AssetClassification.OTHER.value] == 0.1
|
|
|
|
def test_all_scores_within_bounds(self):
|
|
"""所有分类得分都在 0-1 范围内."""
|
|
color = ColorAnalysis(
|
|
green_ratio=0.9,
|
|
warm_ratio=0.9,
|
|
cool_ratio=0.9,
|
|
avg_saturation=0.99,
|
|
avg_brightness=0.99,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.9, scene_changes=100)
|
|
audio = AudioAnalysis(
|
|
has_audio=True,
|
|
speech_ratio=0.99,
|
|
music_ratio=0.99,
|
|
ambient_ratio=0.99,
|
|
)
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
for cat, score in scores.items():
|
|
assert 0.0 <= score <= 1.0, f"{cat} score {score} out of bounds"
|
|
|
|
def test_all_nine_categories_present(self):
|
|
"""返回 9 个分类的得分."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis()
|
|
audio = AudioAnalysis()
|
|
scores = calculate_category_scores(color, motion, audio)
|
|
assert len(scores) == 9
|
|
|
|
|
|
# ── 分类结果计算 ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestClassifyFromAnalysis:
|
|
"""分类结果计算测试."""
|
|
|
|
def test_returns_classification_result(self):
|
|
"""返回 ClassificationResult 对象."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis()
|
|
audio = AudioAnalysis()
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert isinstance(result, ClassificationResult)
|
|
assert isinstance(result.category, AssetClassification)
|
|
assert isinstance(result.confidence, float)
|
|
assert isinstance(result.scores, dict)
|
|
|
|
def test_highest_score_wins(self):
|
|
"""得分最高的分类胜出."""
|
|
# 构造明显偏向风景的特征
|
|
color = ColorAnalysis(
|
|
green_ratio=0.8,
|
|
avg_saturation=0.6,
|
|
avg_brightness=0.7,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.3)
|
|
audio = AudioAnalysis(has_audio=True, ambient_ratio=0.8)
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert result.category == AssetClassification.SCENIC
|
|
|
|
def test_confidence_within_bounds(self):
|
|
"""置信度在 0.3-0.95 范围内."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis()
|
|
audio = AudioAnalysis()
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert 0.3 <= result.confidence <= 0.95
|
|
|
|
def test_confidence_capped_at_095(self):
|
|
"""极高得分也被限制在 0.95."""
|
|
color = ColorAnalysis(
|
|
green_ratio=0.9,
|
|
avg_saturation=0.9,
|
|
avg_brightness=0.9,
|
|
warm_ratio=0.9,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.9, scene_changes=10)
|
|
audio = AudioAnalysis(
|
|
has_audio=True,
|
|
speech_ratio=0.9,
|
|
music_ratio=0.9,
|
|
ambient_ratio=0.9,
|
|
)
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert result.confidence <= 0.95
|
|
|
|
def test_confidence_floored_at_03(self):
|
|
"""极低得分也有 0.3 最低置信度."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis()
|
|
audio = AudioAnalysis()
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert result.confidence >= 0.3
|
|
|
|
def test_scores_dict_included(self):
|
|
"""结果中包含完整分数字典."""
|
|
color = ColorAnalysis()
|
|
motion = MotionAnalysis()
|
|
audio = AudioAnalysis()
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert len(result.scores) == 9
|
|
assert AssetClassification.OTHER.value in result.scores
|
|
|
|
def test_food_category_wins_on_warm_colors(self):
|
|
"""暖色调+高饱和 → 美食分类胜出."""
|
|
color = ColorAnalysis(
|
|
warm_ratio=0.7,
|
|
avg_saturation=0.8,
|
|
avg_brightness=0.6,
|
|
green_ratio=0.05,
|
|
)
|
|
motion = MotionAnalysis(motion_score=0.05)
|
|
audio = AudioAnalysis(has_audio=False)
|
|
result = classify_from_analysis(color, motion, audio)
|
|
assert result.category == AssetClassification.FOOD
|
|
|
|
|
|
# ── 数据类默认值 ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDataclassDefaults:
|
|
"""数据类默认值测试."""
|
|
|
|
def test_video_info_defaults(self):
|
|
info = VideoInfo()
|
|
assert info.width == 0
|
|
assert info.height == 0
|
|
assert info.fps == 0.0
|
|
assert info.bitrate == 0
|
|
assert info.has_audio is False
|
|
|
|
def test_color_analysis_defaults(self):
|
|
color = ColorAnalysis()
|
|
assert color.green_ratio == 0.0
|
|
assert color.avg_brightness == 0.0
|
|
assert color.dominant_hue == 0.0
|
|
|
|
def test_motion_analysis_defaults(self):
|
|
motion = MotionAnalysis()
|
|
assert motion.motion_score == 0.0
|
|
assert motion.scene_changes == 0
|
|
|
|
def test_audio_analysis_defaults(self):
|
|
audio = AudioAnalysis()
|
|
assert audio.has_audio is False
|
|
assert audio.speech_ratio == 0.0
|
|
assert audio.music_ratio == 0.0
|
|
|
|
def test_quality_score_requires_total(self):
|
|
with pytest.raises(TypeError):
|
|
QualityScore()
|
|
qs = QualityScore(total=50.0)
|
|
assert qs.total == 50.0
|
|
assert qs.resolution_score == 0.0
|