feat(dedup): 增加文案+结构维度查重 (P2-后端3) #1762

Merged
xiaoxia merged 2 commits from feat/dedup-enhanced-text-structure into develop 2026-09-07 19:50:02 +08:00
2 changed files with 388 additions and 10 deletions
+181 -10
View File
@@ -7,6 +7,7 @@ import hashlib
import logging
import math
import os
import re
import statistics
import tempfile
from dataclasses import dataclass, field
@@ -56,8 +57,13 @@ MAX_GAP = 2 # 允许的最大间隙帧数
NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致)
# ── 融合判定常量 ────────────────────────────────────────────────
PHASH_WEIGHT = 0.7 # pHash 权重
HISTOGRAM_WEIGHT = 0.3 # 直方图权重
PHASH_WEIGHT = 0.7 # pHash 权重(视觉内部)
HISTOGRAM_WEIGHT = 0.3 # 直方图权重(视觉内部)
# ── 多维度查重融合权重(Issue #P2-后端3) ────────────────────────
VISUAL_WEIGHT = 0.5 # 视觉相似度权重(pHash+直方图)
TEXT_WEIGHT = 0.25 # 文案相似度权重(配音文本)
STRUCTURE_WEIGHT = 0.25 # 结构相似度权重(片段序列)
MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate)至少 70% 帧匹配
PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复
DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值
@@ -1045,6 +1051,110 @@ class VideoDeduplicator:
logger.info("check_batch_duplicate no match (batch=%s): best_fusion=%.3f", batch_id, best_score)
return None
# ── 文案 & 结构维度查重(Issue #P2-后端3) ────────────────────────
def _normalize_text(text: str) -> str:
"""文本标准化:去空白、转小写、去标点。"""
if not text:
return ""
# 去空白字符
text = re.sub(r"\s+", "", text)
# 转小写
text = text.lower()
# 去标点(只保留中文、字母、数字)
text = re.sub(r"[^\w\u4e00-\u9fff]", "", text)
return text
def compute_text_similarity(text1: str, text2: str) -> float:
"""计算两段文本的相似度(0~1)。
使用字符级 Jaccard 相似度:交集 / 并集。
适合短文本(配音脚本)的相似度比对。
Args:
text1: 第一段文本
text2: 第二段文本
Returns:
0~1 之间的相似度
"""
t1 = _normalize_text(text1)
t2 = _normalize_text(text2)
if not t1 and not t2:
return 1.0 # 都为空,视为完全相同
if not t1 or not t2:
return 0.0 # 一个为空,完全不同
# 字符级 Jaccard
set1 = set(t1)
set2 = set(t2)
intersection = set1 & set2
union = set1 | set2
if not union:
return 0.0
return len(intersection) / len(union)
def compute_structure_similarity(clips1: list[dict], clips2: list[dict]) -> float:
"""计算两个视频的结构相似度(0~1)。
结构维度包括:
1. 片段数差异(数量越接近越相似)
2. 片段类型序列(相同位置的片段类型是否一致)
3. 时长分布(各片段时长占比是否相似)
Args:
clips1: 第一个视频的片段列表,每项包含 {clip_type, duration}
clips2: 第二个视频的片段列表
Returns:
0~1 之间的相似度
"""
if not clips1 and not clips2:
return 1.0
if not clips1 or not clips2:
return 0.0
# 1. 片段数相似度(数量差异越大越低)
n1, n2 = len(clips1), len(clips2)
count_sim = min(n1, n2) / max(n1, n2)
# 2. 类型序列相似度(逐位比较,相同位置类型是否一致)
min_len = min(n1, n2)
type_matches = sum(1 for i in range(min_len) if clips1[i].get("clip_type") == clips2[i].get("clip_type"))
type_sim = type_matches / min_len if min_len > 0 else 0.0
# 3. 时长分布相似度(归一化后比较分布)
total1 = sum(c.get("duration", 0) for c in clips1)
total2 = sum(c.get("duration", 0) for c in clips2)
if total1 > 0 and total2 > 0:
# 归一化为占比
dist1 = [c.get("duration", 0) / total1 for c in clips1]
dist2 = [c.get("duration", 0) / total2 for c in clips2]
# 比较前 min_len 个片段的占比差异(L1 距离转相似度)
l1_dist = sum(abs(dist1[i] - dist2[i]) for i in range(min_len))
# 加上多出的片段占比
if n1 > n2:
l1_dist += sum(dist1[i] for i in range(n2, n1))
elif n2 > n1:
l1_dist += sum(dist2[i] for i in range(n1, n2))
# L1 距离范围 [0, 2],转为相似度 [0, 1]
duration_sim = 1.0 - (l1_dist / 2.0)
else:
duration_sim = 0.0
# 三维度加权:数量 0.3 + 类型 0.4 + 时长 0.3
return count_sim * 0.3 + type_sim * 0.4 + duration_sim * 0.3
def compute_duplicate_rate(
self,
fingerprint: VideoFingerprint,
@@ -1057,12 +1167,11 @@ class VideoDeduplicator:
) -> dict:
"""计算当前视频与已有视频的查重率百分比。
新公式(双指标加权):
- frame_match_rate = 汉明距离 < PHASH_THRESHOLD 的帧数 / 总帧数
- temporal_coverage_rate = 连续匹配片段总时长 / 视频总时长
- duplicate_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100
visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(归一化到 0~1
多维度融合公式(Issue #P2-后端3):
- visual_similarity = 0.7 * phash_sim + 0.3 * hist_sim(视觉维度)
- text_similarity = 文案 Jaccard 相似度(文案维度)
- structure_similarity = 片段序列相似度(结构维度)
- duplicate_rate = (visual*0.5 + text*0.25 + structure*0.25) * 100
对每个匹配视频都算,取最高 duplicate_rate。
@@ -1093,6 +1202,29 @@ class VideoDeduplicator:
match_count = 0
evaluated = 0
# Issue #P2-后端3: 加载当前视频的文案+结构数据
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel, GeneratedVideoModel
current_video_obj = (
session.query(GeneratedVideoModel).filter(GeneratedVideoModel.id == current_video_id).first()
if current_video_id
else None
)
current_plan_id = getattr(current_video_obj, "edit_plan_id", "") or ""
current_clips_data = []
current_text_content = ""
if current_plan_id:
current_clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.plan_id == current_plan_id)
.order_by(EditPlanClipModel.order)
.all()
)
current_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in current_clips]
# 拼接所有片段的文本内容
current_text_content = " ".join(c.text_content for c in current_clips if c.text_content)
for existing in existing_videos:
if current_video_id and existing.id == current_video_id:
continue
@@ -1160,8 +1292,47 @@ class VideoDeduplicator:
# Issue #1702: 去掉 "frame_match_rate<0.3 整条跳过" 硬门槛——
# 局部片段复用帧比例天然低;coverage 为主指标,0 匹配自然得 0 分。
# duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage
dup_rate = (min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6) * 100
# 视觉维度:0.4 * frame_match_rate + 0.6 * temporal_coverage
visual_sim = min(ev["frame_match_rate"], 1.0) * 0.4 + ev["temporal_coverage"] * 0.6
# Issue #P2-后端3: 文案+结构维度
existing_plan_id = getattr(existing, "edit_plan_id", "") or ""
existing_clips_data = []
existing_text_content = ""
if existing_plan_id:
existing_clips = (
session.query(EditPlanClipModel)
.filter(EditPlanClipModel.plan_id == existing_plan_id)
.order_by(EditPlanClipModel.order)
.all()
)
existing_clips_data = [{"clip_type": c.clip_type, "duration": c.duration} for c in existing_clips]
existing_text_content = " ".join(c.text_content for c in existing_clips if c.text_content)
# 计算文案相似度(有文案才算)
text_sim = (
compute_text_similarity(current_text_content, existing_text_content)
if (current_text_content and existing_text_content)
else 0.0
)
# 计算结构相似度(有片段才算)
structure_sim = (
compute_structure_similarity(current_clips_data, existing_clips_data)
if (current_clips_data and existing_clips_data)
else 0.0
)
# 多维度融合:visual*0.5 + text*0.25 + structure*0.25
# 如果文案/结构数据缺失,只用视觉维度(visual 权重提升到 1.0)
if current_text_content and existing_text_content and current_clips_data and existing_clips_data:
dup_rate = (
visual_sim * VISUAL_WEIGHT + text_sim * TEXT_WEIGHT + structure_sim * STRUCTURE_WEIGHT
) * 100
else:
# 降级:只有视觉维度
dup_rate = visual_sim * 100
# 全片重复计数与 check_duplicate 判定口径一致
if ev["fusion"] >= DUPLICATE_THRESHOLD and (
+207
View File
@@ -0,0 +1,207 @@
"""Tests for enhanced dedup: text + structure dimensions (Issue #P2-后端3)."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# Mock heavy deps before importing dedup module
# ---------------------------------------------------------------------------
_ORIGINAL_MODULES = dict(sys.modules)
_MOCKED_MODULE_NAMES: list[str] = []
def _mock_if_absent(name: str, mock_obj=None):
"""仅在模块不在 sys.modules 中时注入 mock,并记录以便清理。"""
if name not in sys.modules:
sys.modules[name] = mock_obj if mock_obj is not None else MagicMock()
_MOCKED_MODULE_NAMES.append(name)
# Mock heavy deps
_mock_if_absent("ffmpeg")
_mock_if_absent("ffmpeg.utils")
_mock_if_absent("worker_app.celery_app")
_mock_if_absent("worker_app.db")
_mock_if_absent("packages.adapters.sqlalchemy_impl.generated_video_repository")
_mock_if_absent("packages.adapters.sqlalchemy_impl.models")
_mock_if_absent("packages.shared.storage")
# Mock cv2 and numpy if not available
try:
import cv2 as _cv2
if not isinstance(_cv2, MagicMock):
_HAS_CV2 = True
else:
_HAS_CV2 = False
except ImportError:
_HAS_CV2 = False
_mock_if_absent("cv2")
_mock_if_absent("numpy")
import pytest
from apps.worker.video_processing.dedup import (
STRUCTURE_WEIGHT,
TEXT_WEIGHT,
VISUAL_WEIGHT,
compute_structure_similarity,
compute_text_similarity,
)
class TestTextSimilarity:
"""Tests for compute_text_similarity."""
def test_identical_texts(self):
"""相同文本返回 1.0。"""
assert compute_text_similarity("你好世界", "你好世界") == 1.0
def test_empty_texts(self):
"""都为空返回 1.0。"""
assert compute_text_similarity("", "") == 1.0
def test_one_empty(self):
"""一个为空返回 0.0。"""
assert compute_text_similarity("你好", "") == 0.0
assert compute_text_similarity("", "你好") == 0.0
def test_completely_different(self):
"""完全不同文本返回低相似度。"""
sim = compute_text_similarity("你好世界", "abcdefgh")
assert sim < 0.3
def test_partial_overlap(self):
"""部分重叠文本返回中等相似度。"""
sim = compute_text_similarity("今天天气真好", "今天天气不错")
assert 0.3 < sim < 0.9
def test_case_insensitive(self):
"""英文大小写不敏感。"""
sim = compute_text_similarity("Hello World", "hello world")
assert sim == 1.0
def test_whitespace_ignored(self):
"""空白字符被忽略。"""
sim = compute_text_similarity("你好 世界", "你好世界")
assert sim == 1.0
def test_punctuation_ignored(self):
"""标点符号被忽略。"""
sim = compute_text_similarity("你好,世界!", "你好世界")
assert sim == 1.0
def test_long_texts(self):
"""长文本也能计算。"""
t1 = "这是一段很长的配音文本,用于测试文案查重功能"
t2 = "这是一段较长的配音文字,用于测试文案去重功能"
sim = compute_text_similarity(t1, t2)
assert 0.0 <= sim <= 1.0
class TestStructureSimilarity:
"""Tests for compute_structure_similarity."""
def test_identical_structures(self):
"""完全相同结构返回 1.0。"""
clips = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
{"clip_type": "video", "duration": 8.0},
]
assert compute_structure_similarity(clips, clips) == 1.0
def test_empty_clips(self):
"""都为空返回 1.0。"""
assert compute_structure_similarity([], []) == 1.0
def test_one_empty(self):
"""一个为空返回 0.0。"""
clips = [{"clip_type": "video", "duration": 5.0}]
assert compute_structure_similarity(clips, []) == 0.0
assert compute_structure_similarity([], clips) == 0.0
def test_different_count(self):
"""片段数不同,相似度降低。"""
clips1 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
]
clips2 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
{"clip_type": "video", "duration": 3.0},
{"clip_type": "title", "duration": 1.0},
]
sim = compute_structure_similarity(clips1, clips2)
assert 0.0 < sim < 0.8
def test_different_types(self):
"""片段类型不同,类型相似度低。"""
clips1 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "video", "duration": 3.0},
]
clips2 = [
{"clip_type": "title", "duration": 5.0},
{"clip_type": "title", "duration": 3.0},
]
sim = compute_structure_similarity(clips1, clips2)
assert sim <= 0.6 # 类型全部不同,但数量和时长相同贡献 0.6
def test_different_duration_distribution(self):
"""时长分布不同,时长相似度低。"""
clips1 = [
{"clip_type": "video", "duration": 10.0}, # 占比 80%
{"clip_type": "title", "duration": 2.5}, # 占比 20%
]
clips2 = [
{"clip_type": "video", "duration": 2.0}, # 占比 20%
{"clip_type": "title", "duration": 8.0}, # 占比 80%
]
sim = compute_structure_similarity(clips1, clips2)
assert 0.7 < sim < 0.9 # 类型相同但时长分布不同,sim=0.82
def test_similar_structure(self):
"""相似结构返回较高相似度。"""
clips1 = [
{"clip_type": "video", "duration": 5.0},
{"clip_type": "title", "duration": 2.0},
{"clip_type": "video", "duration": 8.0},
]
clips2 = [
{"clip_type": "video", "duration": 5.5},
{"clip_type": "title", "duration": 2.2},
{"clip_type": "video", "duration": 7.5},
]
sim = compute_structure_similarity(clips1, clips2)
assert sim > 0.8
def test_single_clip(self):
"""单片段也能计算。"""
clips1 = [{"clip_type": "video", "duration": 10.0}]
clips2 = [{"clip_type": "video", "duration": 12.0}]
sim = compute_structure_similarity(clips1, clips2)
assert sim > 0.5 # 类型相同,数量相同,只是时长不同
class TestDimensionWeights:
"""Tests for dimension weight constants."""
def test_weights_sum_to_one(self):
"""多维度权重之和为 1.0。"""
assert abs(VISUAL_WEIGHT + TEXT_WEIGHT + STRUCTURE_WEIGHT - 1.0) < 1e-9
def test_visual_weight_is_half(self):
"""视觉权重为 0.5。"""
assert VISUAL_WEIGHT == 0.5
def test_text_weight_is_quarter(self):
"""文案权重为 0.25。"""
assert TEXT_WEIGHT == 0.25
def test_structure_weight_is_quarter(self):
"""结构权重为 0.25。"""
assert STRUCTURE_WEIGHT == 0.25