diff --git a/apps/api/app/services/edit_plan_service.py b/apps/api/app/services/edit_plan_service.py index df2c5d248..fa244a9d7 100755 --- a/apps/api/app/services/edit_plan_service.py +++ b/apps/api/app/services/edit_plan_service.py @@ -945,8 +945,13 @@ class EditPlanService: # #1764:写入节奏模板 if idx < len(rhythm_templates_for_variants): config_update["rhythm_template"] = rhythm_templates_for_variants[idx] + # #1765:写入像素级扰动滤镜 + from packages.domain.variant_plan_selector import generate_pixel_perturbation + + pixel_pert = generate_pixel_perturbation(rng) + config_update["pixel_perturbation"] = pixel_pert self.update_plan_config(pid, config_update) - logger.info("变体 %d 视觉扰动: plan=%s perturbation=%s", idx, pid, perturbation) + logger.info("变体 %d 视觉扰动+像素扰动: plan=%s vis=%s pix=%s", idx, pid, perturbation, pixel_pert) except Exception: logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid) diff --git a/apps/worker/video_processing/unified_render_service.py b/apps/worker/video_processing/unified_render_service.py index 240c5559b..1f4360b2d 100755 --- a/apps/worker/video_processing/unified_render_service.py +++ b/apps/worker/video_processing/unified_render_service.py @@ -2227,12 +2227,17 @@ class UnifiedRenderService: perturbation = (self.plan.config or {}).get("visual_perturbation") or {} if not perturbation: return {} - return { + result = { "hflip": bool(perturbation.get("hflip", False)), "zoom_ratio": max(1.0, min(1.2, float(perturbation.get("zoom_ratio", 1.0) or 1.0))), "speed_factor": max(0.8, min(1.2, float(perturbation.get("speed_factor", 1.0) or 1.0))), "brightness_shift": max(-30, min(30, int(perturbation.get("brightness_shift", 0) or 0))), } + # #1765:同时读取像素级扰动滤镜 + pixel_pert = (self.plan.config or {}).get("pixel_perturbation") or {} + if pixel_pert: + result["pixel_perturbation"] = pixel_pert + return result def _apply_visual_perturbation_pre_scale(self, filters: list[str], perturbation: dict) -> None: # scale+pad 之前的扰动(hflip),就地修改 filters @@ -2251,6 +2256,48 @@ class UnifiedRenderService: if brightness != 0: filters.append(f"eq=brightness={brightness / 100.0:.3f}") + # #1765:追加像素级扰动滤镜 + pixel_pert = perturbation.get("pixel_perturbation") or {} + if pixel_pert: + self._apply_pixel_perturbation(filters, pixel_pert) + + def _apply_pixel_perturbation(self, filters: list[str], pixel_pert: dict) -> None: + """应用像素级扰动滤镜(Issue #1765)。 + + 滤镜参数幅度确保肉眼不可见(SSIM > 0.95),但能让同素材不同变体 + 在帧级产生 > 3% 的差异,降低平台查重风险。 + """ + filter_list = pixel_pert.get("filters") or [] + + for filt in filter_list: + if filt == "noise": + # 轻微噪声:noise=alls=0.015:allf=t+u + strength = pixel_pert.get("noise_strength", 0.015) + filters.append(f"noise=alls={strength}:allf=t+u") + + elif filt == "unsharp": + # 锐化/柔化:unsharp=3:3:amount + # amount > 0 锐化,< 0 柔化 + amount = pixel_pert.get("unsharp_amount", 0.0) + if abs(amount) > 0.01: + filters.append(f"unsharp=3:3:{amount:.2f}") + + elif filt == "curves": + # 对比度微调:curves 用 preset 或手动定义 + # 简单方案:用 eq=contrast 代替(curves 语法复杂) + contrast = pixel_pert.get("curves_contrast", 1.0) + if abs(contrast - 1.0) > 0.01: + filters.append(f"eq=contrast={contrast:.3f}") + + elif filt == "color_balance": + # RGB 通道偏移:color_balance=rs=...:gs=...:bs=... + r = pixel_pert.get("color_r", 0) + g = pixel_pert.get("color_g", 0) + b = pixel_pert.get("color_b", 0) + if r != 0 or g != 0 or b != 0: + # color_balance 参数范围 -1.0 ~ 1.0,这里用 /100 转换 + filters.append(f"color_balance=rs={r/100:.3f}:gs={g/100:.3f}:bs={b/100:.3f}") + @staticmethod def _clip_volume(clip: ResolvedClip) -> float: """获取 clip 的音量(config.volume)。缺省 1.0 原声,0.0 静音。""" diff --git a/packages/domain/variant_plan_selector.py b/packages/domain/variant_plan_selector.py index b497c95dd..1bd09cde7 100644 --- a/packages/domain/variant_plan_selector.py +++ b/packages/domain/variant_plan_selector.py @@ -321,6 +321,54 @@ def generate_visual_perturbation(rng: random.Random | None = None) -> dict: } +def generate_pixel_perturbation(rng: random.Random | None = None) -> dict: + """为一个变体生成像素级扰动滤镜参数(Issue #1765)。 + + 在现有视觉扰动(hflip/zoom/brightness)基础上,额外叠加 2-3 种 + 像素级滤镜,让同素材不同变体在帧级 SSIM 差异 > 3%,肉眼看不出差异。 + + 滤镜选项(随机选 2-3 种叠加): + - noise: 轻微噪声 (noise=alls=0.015:allf=t+u) + - unsharp: 锐化或柔化 (unsharp=3:3:-0.5 ~ 3:3:0.5) + - curves: 对比度微调 (curves 轻微调整) + - color_balance: RGB 通道偏移 (color_balance 微调) + + 返回 dict,可直接存入 plan.config["pixel_perturbation"]。 + 渲染侧读取后追加到 ffmpeg filter chain。 + """ + rng = rng or random.Random() + + # 可用滤镜池 + filter_options = ["noise", "unsharp", "curves", "color_balance"] + + # 随机选 2-3 种 + num_filters = rng.choice([2, 2, 3]) + selected = rng.sample(filter_options, num_filters) + + result: dict = {"filters": selected} + + # 为每种滤镜生成具体参数 + if "noise" in selected: + # 噪声强度 0.01~0.02(肉眼不可见) + result["noise_strength"] = round(rng.uniform(0.01, 0.02), 4) + + if "unsharp" in selected: + # 锐化/柔化:-0.5 ~ +0.5(正值锐化,负值柔化) + result["unsharp_amount"] = round(rng.uniform(-0.5, 0.5), 2) + + if "curves" in selected: + # 对比度微调:0.95 ~ 1.05 + result["curves_contrast"] = round(rng.uniform(0.95, 1.05), 3) + + if "color_balance" in selected: + # RGB 通道偏移:-5 ~ +5(极轻微色偏) + result["color_r"] = rng.choice([-5, -3, 0, 0, 3, 5]) + result["color_g"] = rng.choice([-5, -3, 0, 0, 3, 5]) + result["color_b"] = rng.choice([-5, -3, 0, 0, 3, 5]) + + return result + + def _base_clip_data(src: dict, *, asset_id: str, start: float, duration: float | None = None) -> dict: """从源片段构造落库 dict(保留骨架/转场/文案/速度,替换素材与起点)。""" return { diff --git a/tests/unit/test_pixel_perturbation.py b/tests/unit/test_pixel_perturbation.py new file mode 100644 index 000000000..0ba311d94 --- /dev/null +++ b/tests/unit/test_pixel_perturbation.py @@ -0,0 +1,112 @@ +"""像素级扰动滤镜单元测试(Issue #1765)。 + +覆盖: +- generate_pixel_perturbation:生成像素级扰动参数 +- 滤镜组合:2-3 种滤镜随机组合 +- 参数范围:肉眼不可见但帧级可检测 +- FFmpeg 滤镜语法生成 +""" + +from __future__ import annotations + +import random + +import pytest + +from packages.domain.variant_plan_selector import generate_pixel_perturbation + + +class TestGeneratePixelPerturbation: + """generate_pixel_perturbation 测试。""" + + def test_returns_dict(self): + """返回 dict。""" + result = generate_pixel_perturbation() + assert isinstance(result, dict) + + def test_has_filters_key(self): + """包含 filters 键。""" + result = generate_pixel_perturbation() + assert "filters" in result + + def test_filters_count_2_or_3(self): + """选 2-3 种滤镜。""" + for _ in range(50): + result = generate_pixel_perturbation() + assert len(result["filters"]) in [2, 3] + + def test_filters_from_valid_options(self): + """滤镜来自有效选项。""" + valid_options = {"noise", "unsharp", "curves", "color_balance"} + for _ in range(50): + result = generate_pixel_perturbation() + for f in result["filters"]: + assert f in valid_options + + def test_noise_parameters(self): + """noise 滤镜有正确参数范围。""" + for _ in range(20): + result = generate_pixel_perturbation() + if "noise" in result["filters"]: + strength = result.get("noise_strength", 0) + assert 0.01 <= strength <= 0.02 + + def test_unsharp_parameters(self): + """unsharp 滤镜有正确参数范围。""" + for _ in range(20): + result = generate_pixel_perturbation() + if "unsharp" in result["filters"]: + amount = result.get("unsharp_amount", 0) + assert -0.5 <= amount <= 0.5 + + def test_curves_parameters(self): + """curves 滤镜有正确参数范围。""" + for _ in range(20): + result = generate_pixel_perturbation() + if "curves" in result["filters"]: + contrast = result.get("curves_contrast", 1.0) + assert 0.95 <= contrast <= 1.05 + + def test_color_balance_parameters(self): + """color_balance 滤镜有正确参数范围。""" + valid_colors = [-5, -3, 0, 3, 5] + for _ in range(20): + result = generate_pixel_perturbation() + if "color_balance" in result["filters"]: + assert result.get("color_r") in valid_colors + assert result.get("color_g") in valid_colors + assert result.get("color_b") in valid_colors + + def test_same_seed_same_result(self): + """相同 seed 返回相同结果。""" + rng1 = random.Random(42) + rng2 = random.Random(42) + result1 = generate_pixel_perturbation(rng1) + result2 = generate_pixel_perturbation(rng2) + assert result1 == result2 + + def test_different_seeds_may_differ(self): + """不同 seed 可能返回不同结果。""" + results = set() + for seed in range(20): + rng = random.Random(seed) + result = generate_pixel_perturbation(rng) + results.add(tuple(result["filters"])) + # 20 个 seed 至少看到 3 种不同组合 + assert len(results) >= 3 + + +class TestPixelPerturbationAcceptance: + """Issue #1765 验收测试。""" + + def test_batch_3_variants_have_different_filters(self): + """批量 3 个变体有不同的滤镜组合。""" + results = [] + for seed in [100, 200, 300]: + rng = random.Random(seed) + result = generate_pixel_perturbation(rng) + results.append(tuple(result["filters"])) + + # 至少 2 种不同组合 + unique = len(set(results)) + assert unique >= 2, f"Expected >= 2 unique filter combos, got {unique}: {results}"