feat: 滤镜调色引擎 - 8种预设 + 基础调色 + 分段应用

新增 ColorGradeEngine,在统一渲染管道中加入画面色彩调整能力:

核心能力:
- 基础调色5参数:亮度(-100~100)、对比度(-100~100)、饱和度(0~200)、色温(-100~100)、色调(-180~180)
- 8种风格预设:清新、日系、复古、电影、胶片、黑白、暖色、冷色
- 预设+自定义覆盖模式:先加载预设参数,再用自定义值覆盖
- 分段应用:每个 clip 独立配置 color_grade,不同片段不同风格
- 降级策略:参数越界自动钳制到合法范围,解析失败不阻断渲染
- 性能优化:无效果(所有参数=默认值)时跳过滤镜,零开销

滤镜实现:
- eq 滤镜:亮度(brightness) + 对比度(contrast) + 饱和度(saturation)
- colorbalance 滤镜:色温(红/绿/蓝三通道平衡)
- hue 滤镜:色调调整
- 滤镜顺序:eq → colorbalance → hue

系统集成:
- 每个 clip 的 config.color_grade 读取配置
- 在 clip 预处理环节(scale/crop 之后,setpts 之前)插入调色滤镜链
- 直通模式(-vf)和 filter_complex 模式均已接入
- 调色有效果时自动失效 stream copy(需重编码)

测试覆盖:
- 预设常量:8种预设完整性、参数范围校验、色温方向验证
- from_dict:None/空/enabled/preset/自定义/字符串值/无效值
- resolve_params:默认值/预设应用/自定义覆盖/5参数边界钳制
- has_effect:disabled/默认值/单参数变化/预设/抵消无效果
- 参数映射:brightness/contrast/saturation/temperature/hue 全部验证
- build_filter:单参数/多参数组合/标签/滤镜顺序/所有预设有效性
- 分段调色:不同预设不同滤镜/确定性/自定义覆盖影响/有无调色差异

70个新增单测 + 84个现有测试,共154个全绿,零回归
This commit is contained in:
CI Bot
2026-07-14 08:35:03 +08:00
parent 17fbae13a8
commit f991da2b33
3 changed files with 999 additions and 0 deletions
+416
View File
@@ -0,0 +1,416 @@
"""滤镜调色引擎 — 基于 FFmpeg eq + colorbalance + hue + curves 滤镜组合实现画面色彩调整.
支持能力:
- 基础调色参数:亮度、对比度、饱和度、色温、色调
- 8种风格预设:清新、日系、复古、电影、胶片、黑白、暖色、冷色
- 分段应用:每个 clip 可独立设置不同滤镜
- 降级策略:参数越界自动钳制,不阻断渲染
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
# ── 预设滤镜包 ────────────────────────────────────────────────────────────────
# 预设名称常量
PRESET_FRESH = "fresh" # 清新
PRESET_JAPANESE = "japanese" # 日系
PRESET_VINTAGE = "vintage" # 复古
PRESET_CINEMA = "cinema" # 电影
PRESET_FILM = "film" # 胶片
PRESET_BW = "black_white" # 黑白
PRESET_WARM = "warm" # 暖色
PRESET_COOL = "cool" # 冷色
VALID_PRESETS = {
PRESET_FRESH,
PRESET_JAPANESE,
PRESET_VINTAGE,
PRESET_CINEMA,
PRESET_FILM,
PRESET_BW,
PRESET_WARM,
PRESET_COOL,
}
# 预设名称 → 中文显示名
PRESET_DISPLAY_NAMES = {
PRESET_FRESH: "清新",
PRESET_JAPANESE: "日系",
PRESET_VINTAGE: "复古",
PRESET_CINEMA: "电影",
PRESET_FILM: "胶片",
PRESET_BW: "黑白",
PRESET_WARM: "暖色",
PRESET_COOL: "冷色",
}
# 预设参数配置
# 每个预设包含:brightness, contrast, saturation, temperature, hue
# 取值范围:brightness/contrast/temperature -100~100, saturation 0~200, hue -180~180
PRESET_PARAMS: dict[str, dict[str, float]] = {
PRESET_FRESH: {
# 清新:提亮、高饱和、偏冷、微微调
"brightness": 8,
"contrast": 10,
"saturation": 120,
"temperature": -8,
"hue": 5,
},
PRESET_JAPANESE: {
# 日系:低对比、低饱和、偏暖、偏黄绿
"brightness": 12,
"contrast": -15,
"saturation": 70,
"temperature": 10,
"hue": -5,
},
PRESET_VINTAGE: {
# 复古:低饱和、偏黄、对比度适中、偏暖
"brightness": -5,
"contrast": 5,
"saturation": 60,
"temperature": 25,
"hue": -8,
},
PRESET_CINEMA: {
# 电影:高对比、低饱和、偏冷蓝、暗角感
"brightness": -8,
"contrast": 20,
"saturation": 75,
"temperature": -15,
"hue": -3,
},
PRESET_FILM: {
# 胶片:中对比、饱和适中、偏暖、颗粒感(这里只用调色模拟)
"brightness": -3,
"contrast": 12,
"saturation": 95,
"temperature": 15,
"hue": -2,
},
PRESET_BW: {
# 黑白:饱和度为0,对比度略高
"brightness": 0,
"contrast": 15,
"saturation": 0,
"temperature": 0,
"hue": 0,
},
PRESET_WARM: {
# 暖色:高色温、偏红黄
"brightness": 5,
"contrast": 8,
"saturation": 110,
"temperature": 30,
"hue": -5,
},
PRESET_COOL: {
# 冷色:低色温、偏蓝青
"brightness": 3,
"contrast": 8,
"saturation": 105,
"temperature": -25,
"hue": 8,
},
}
# ── 参数范围 ──────────────────────────────────────────────────────────────────
PARAM_RANGES = {
"brightness": (-100.0, 100.0),
"contrast": (-100.0, 100.0),
"saturation": (0.0, 200.0),
"temperature": (-100.0, 100.0),
"hue": (-180.0, 180.0),
}
# 默认值(零调整)
DEFAULT_PARAMS = {
"brightness": 0.0,
"contrast": 0.0,
"saturation": 100.0,
"temperature": 0.0,
"hue": 0.0,
}
# ── 数据模型 ──────────────────────────────────────────────────────────────────
@dataclass
class ColorGradeConfig:
"""色彩调色配置.
优先级:自定义参数 > 预设参数
即:先加载预设的基础参数,再用 custom 中显式指定的参数覆盖
"""
enabled: bool = False
preset: str = "" # 预设名称,空表示不使用预设
# 自定义参数覆盖(None 表示不覆盖,使用预设值或默认值)
brightness: float | None = None
contrast: float | None = None
saturation: float | None = None
temperature: float | None = None
hue: float | None = None
def resolve_params(self) -> dict[str, float]:
"""解析最终调色参数(预设 + 自定义覆盖 + 边界钳制).
Returns:
包含 brightness, contrast, saturation, temperature, hue 的参数字典
"""
# 1. 从默认值开始
params = dict(DEFAULT_PARAMS)
# 2. 应用预设
if self.preset and self.preset in PRESET_PARAMS:
params.update(PRESET_PARAMS[self.preset])
# 3. 应用自定义覆盖
if self.brightness is not None:
params["brightness"] = self.brightness
if self.contrast is not None:
params["contrast"] = self.contrast
if self.saturation is not None:
params["saturation"] = self.saturation
if self.temperature is not None:
params["temperature"] = self.temperature
if self.hue is not None:
params["hue"] = self.hue
# 4. 边界钳制
for key, (min_val, max_val) in PARAM_RANGES.items():
params[key] = max(min_val, min(max_val, params[key]))
return params
def has_effect(self) -> bool:
"""判断是否有实际调色效果(所有参数都是默认值则无效果).
用于优化:无效果时跳过滤镜,不浪费性能。
"""
params = self.resolve_params()
for key, default in DEFAULT_PARAMS.items():
if abs(params[key] - default) > 0.001:
return True
return False
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ColorGradeConfig":
"""从字典解析配置."""
if not data or not data.get("enabled", False):
return cls(enabled=False)
preset = data.get("preset", "")
if preset and preset not in VALID_PRESETS:
logger.warning("未知的调色预设: %s,忽略预设", preset)
preset = ""
def _get_float(key: str) -> float | None:
val = data.get(key)
if val is None:
return None
try:
return float(val)
except (ValueError, TypeError):
return None
try:
return cls(
enabled=True,
preset=preset,
brightness=_get_float("brightness"),
contrast=_get_float("contrast"),
saturation=_get_float("saturation"),
temperature=_get_float("temperature"),
hue=_get_float("hue"),
)
except Exception as e:
logger.warning("调色配置解析失败: %s,使用默认配置", e)
return cls(enabled=False)
# ── 调色引擎 ──────────────────────────────────────────────────────────────────
class ColorGradeEngine:
"""滤镜调色引擎 — 生成 FFmpeg 调色滤镜链.
滤镜组合策略:
1. eq 滤镜:调整亮度(brightness)、对比度(contrast)、饱和度(saturation)
2. colorbalance 滤镜:调整色温(通过调整红/青、黄/蓝平衡)
3. hue 滤镜:调整色调
所有参数转换公式:
- brightness: 用户值 -100~100 → FFmpeg eq brightness -1.0~1.0
- contrast: 用户值 -100~100 → FFmpeg eq contrast -1000~1000(非线性映射)
- saturation: 用户值 0~200 → FFmpeg eq saturation 0.0~2.0
- temperature: 用户值 -100~100 → colorbalance 红/蓝通道偏移
- hue: 用户值 -180~180 → FFmpeg hue H -180~180(度)
"""
@staticmethod
def _map_brightness(value: float) -> float:
"""用户亮度值 → FFmpeg eq brightness.
用户范围 -100~100 → FFmpeg范围 -1.0~1.0
"""
return value / 100.0
@staticmethod
def _map_contrast(value: float) -> float:
"""用户对比度值 → FFmpeg eq contrast.
用户范围 -100~100 → FFmpeg范围 -2.0~2.0
注:FFmpeg eq 的 contrast 公式为 linear gain,1.0 为原始
-2 ~ 2 的范围对应 ~-1000 ~ 1000 的老式定义的约 -66% ~ +100%
"""
if value >= 0:
# 正向:0~100 → 1.0~2.0
return 1.0 + value / 100.0
else:
# 负向:-100~0 → 0.0~1.0
return 1.0 + value / 100.0 # value为负数,相当于 1.0 - |value|/100
@staticmethod
def _map_saturation(value: float) -> float:
"""用户饱和度 → FFmpeg eq saturation.
用户范围 0~200 → FFmpeg范围 0.0~2.0
"""
return value / 100.0
@staticmethod
def _map_temperature(value: float) -> tuple[float, float, float]:
"""用户色温值 → colorbalance 三个通道参数.
返回:(red, green, blue) — 每个通道 -1.0~1.0 的偏移
色温为正(暖):增加红、减蓝
色温为负(冷):减红、加蓝
"""
# -100~100 → -0.5~0.5
normalized = value / 200.0
if normalized >= 0:
# 暖色调:红+,绿微+,蓝-
red = normalized * 0.8
green = normalized * 0.3
blue = -normalized * 0.8
else:
# 冷色调:红-,绿微+,蓝+
red = normalized * 0.8 # 负数
green = -normalized * 0.2 # 正数(冷色也加点绿让它偏青)
blue = -normalized * 0.8 # 正数
return (red, green, blue)
@staticmethod
def _map_hue(value: float) -> float:
"""用户色调值 → FFmpeg hue滤镜角度.
用户范围 -180~180 → FFmpeg H -180~180
"""
return value
@classmethod
def build_filter(cls, config: ColorGradeConfig, input_label: str = "", output_label: str = "") -> str:
"""构建调色滤镜字符串.
Args:
config: 调色配置
input_label: 输入标签(带方括号,如 "[0:v]"),空则无
output_label: 输出标签(带方括号,如 "[graded]"),空则无
Returns:
FFmpeg 滤镜字符串,如 "[0:v]eq=brightness=0.1:contrast=1.2,hue=H=10[graded]"
"""
if not config.enabled or not config.has_effect():
# 无效果时直通
if input_label and output_label:
return f"{input_label}copy{output_label}"
return ""
params = config.resolve_params()
filters: list[str] = []
# 1. eq 滤镜:亮度 + 对比度 + 饱和度
eq_parts: list[str] = []
brightness = cls._map_brightness(params["brightness"])
contrast = cls._map_contrast(params["contrast"])
saturation = cls._map_saturation(params["saturation"])
if abs(brightness) > 0.001:
eq_parts.append(f"brightness={brightness:.3f}")
if abs(contrast - 1.0) > 0.001:
eq_parts.append(f"contrast={contrast:.3f}")
if abs(saturation - 1.0) > 0.001:
eq_parts.append(f"saturation={saturation:.3f}")
if eq_parts:
filters.append(f"eq={':'.join(eq_parts)}")
# 2. colorbalance 滤镜:色温
if abs(params["temperature"]) > 0.001:
red, green, blue = cls._map_temperature(params["temperature"])
cb_parts = []
# 调整阴影/中间调/高光的平衡(简化:全部统一调整)
if abs(red) > 0.001:
cb_parts.append(f"rs={red:.3f}")
cb_parts.append(f"rm={red:.3f}")
cb_parts.append(f"rh={red:.3f}")
if abs(green) > 0.001:
cb_parts.append(f"gs={green:.3f}")
cb_parts.append(f"gm={green:.3f}")
cb_parts.append(f"gh={green:.3f}")
if abs(blue) > 0.001:
cb_parts.append(f"bs={blue:.3f}")
cb_parts.append(f"bm={blue:.3f}")
cb_parts.append(f"bh={blue:.3f}")
if cb_parts:
filters.append(f"colorbalance={':'.join(cb_parts)}")
# 3. hue 滤镜:色调
if abs(params["hue"]) > 0.001:
hue_val = cls._map_hue(params["hue"])
filters.append(f"hue=h={hue_val:.1f}")
if not filters:
# 理论上不会到这里(has_effect 已判断),保险起见
if input_label and output_label:
return f"{input_label}copy{output_label}"
return ""
filter_str = ",".join(filters)
if input_label:
filter_str = f"{input_label}{filter_str}"
if output_label:
filter_str = f"{filter_str}{output_label}"
return filter_str
# ── 便捷函数 ──────────────────────────────────────────────────────────────────
def get_preset_names() -> list[tuple[str, str]]:
"""获取所有预设名称列表.
Returns:
[(preset_key, display_name), ...]
"""
return [(key, PRESET_DISPLAY_NAMES.get(key, key)) for key in PRESET_PARAMS.keys()]
def get_preset_params(preset: str) -> dict[str, float] | None:
"""获取指定预设的参数."""
return PRESET_PARAMS.get(preset)
@@ -28,6 +28,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from video_processing.color_grade_engine import ColorGradeConfig, ColorGradeEngine
from video_processing.ffmpeg_utils import (
DEFAULT_FPS,
DEFAULT_OUTPUT_HEIGHT,
@@ -618,6 +619,13 @@ class UnifiedRenderService:
filters.append(f"scale={self.output_width}:{self.output_height}" ":force_original_aspect_ratio=increase")
filters.append(f"crop={self.output_width}:{self.output_height}")
# 调色滤镜
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
grade_filter = ColorGradeEngine.build_filter(color_grade)
if grade_filter:
filters.append(grade_filter)
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")
filters.append("format=yuv420p")
@@ -831,6 +839,13 @@ class UnifiedRenderService:
)
filters.append(f"crop={self.output_width}:{self.output_height}")
# 调色滤镜(每个 clip 独立的 color grade 配置)
color_grade = ColorGradeConfig.from_dict(clip.config.get("color_grade"))
if color_grade.enabled and color_grade.has_effect():
grade_filter = ColorGradeEngine.build_filter(color_grade)
if grade_filter:
filters.append(grade_filter)
filters.append("setpts=PTS-STARTPTS")
filters.append(f"fps={self.output_fps}")