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:
+416
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user