diff --git a/apps/api/app/api/routes/videos.py b/apps/api/app/api/routes/videos.py index 77be5a3ff..20e623f43 100644 --- a/apps/api/app/api/routes/videos.py +++ b/apps/api/app/api/routes/videos.py @@ -252,6 +252,10 @@ class RecomputeDedupRequest(BaseModel): None, description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。", ) + force: bool = Field( + False, + description="强制重算:即使视频已有查重数据也重新入队(#1702 查重算法升级后用于存量视频重算)。", + ) class RecomputeDedupResponse(BaseModel): @@ -291,15 +295,15 @@ def recompute_dedup( skipped = 0 for video in target_videos: - # 已有完整查重数据的跳过 - if video.duplicate_rate is not None and video.video_fingerprint: + # 已有完整查重数据的跳过(force=True 时强制重算,#1702 算法升级后存量视频需要重算指纹/分片) + if not request.force and video.duplicate_rate is not None and video.video_fingerprint: skipped += 1 continue # 触发异步查重任务 celery_app.send_task("worker.check_duplicate", args=[video.id]) enqueued += 1 - logger.info("Enqueued re-dedup for video %s (user=%s)", video.id, user_id) + logger.info("Enqueued re-dedup for video %s (user=%s, force=%s)", video.id, user_id, request.force) return RecomputeDedupResponse( enqueued=enqueued, diff --git a/apps/worker/video_processing/dedup.py b/apps/worker/video_processing/dedup.py index b4c6aaba3..a1f57b390 100755 --- a/apps/worker/video_processing/dedup.py +++ b/apps/worker/video_processing/dedup.py @@ -31,25 +31,60 @@ SCENE_CHANGE_THRESHOLD = 30 # 灰度差异阈值 MIN_KEYFRAME_INTERVAL_SEC = 1.0 # 最小关键帧间隔(秒) MAX_KEYFRAMES = 30 # 最大关键帧数 MIN_KEYFRAMES = 5 # 最小关键帧数 +FINGERPRINT_SAMPLE_INTERVAL_SEC = 1.0 # 指纹采样间隔(秒):密集均匀采样,保证两视频时序可对齐 +FINGERPRINT_MAX_SAMPLES = 30 # 长视频采样数上限(超过后采样间隔自动放宽) LONG_VIDEO_SEGMENT_SEC = 30 # 长视频每段秒数 LONG_VIDEO_DURATION_THRESHOLD_SEC = 180 # 3 分钟阈值 MIN_FRAMES_PER_SEGMENT = 2 # 长视频每段最少帧数 -# ── 滑动窗口匹配常量 ──────────────────────────────────────────── -SEGMENT_MATCH_THRESHOLD = 8 # 帧匹配汉明距离阈值 -MIN_CONSECUTIVE_MATCHES = 5 # 最少连续匹配帧数 +# ── 滑动窗口匹配常量(Issue #1702 重新校准) ───────────────────── +# 阈值经 staging 真实数据回归校准(2026-09-05,worker 容器内离线实验): +# - 同源成片对(20s/11s,各自 2-5% 随机边缘裁剪降重,1s 密集采样): +# 全部帧对最小汉明距离 min=8,<=12 命中 10/31 帧(B->A 4/11) +# - 异源成片对(4 个不同项目真实视频):最小距离 24,<=16 命中 0 帧 +# 8(#1658 旧值)会漏掉同源裁剪(自对照实验:同帧两次 2-5% 随机裁剪距离 4~10), +# 12 能检出同源/局部复用且与异源分布(>=24)间隔 12bit,无误报空间。 +PHASH_THRESHOLD = 12 +SEGMENT_MATCH_THRESHOLD = PHASH_THRESHOLD # 片段匹配阈值与帧匹配统一(#1702:阈值常量统一来源) +MIN_CONSECUTIVE_MATCHES = 5 # 连续匹配默认门槛;短视频自适应 min(5, max(2, 分片数//2)) MAX_GAP = 2 # 允许的最大间隙帧数 +NEIGHBOR_WINDOW = 1 # 分片时序对齐:允许 ±1 邻接偏移(1s 密集采样下即 ±1s,缓解切点不一致) # ── 融合判定常量 ──────────────────────────────────────────────── PHASH_WEIGHT = 0.7 # pHash 权重 HISTOGRAM_WEIGHT = 0.3 # 直方图权重 -MATCH_RATIO_THRESHOLD = 0.7 # 至少 70% 帧匹配 +MATCH_RATIO_THRESHOLD = 0.7 # 全片重复(is_duplicate)至少 70% 帧匹配 +PARTIAL_COVERAGE_THRESHOLD = 0.5 # 局部复用覆盖率 >=50% 也判全片重复 DUPLICATE_THRESHOLD = 0.70 # 融合后相似度阈值 +# ── 降重裁剪规避常量(Issue #1702) ───────────────────────────── +# 成片强制 2-5% random_edge_crop 降重只服务外部平台;自查重指纹取中心 90% +# 区域,使两次不同裁剪的同源画面 pHash 距离回到同分布。 +FINGERPRINT_CENTER_CROP_RATIO = 0.90 + # ── 感知哈希 & 颜色直方图工具函数 ──────────────────────────────── +def center_crop_frame(image: np.ndarray, ratio: float = FINGERPRINT_CENTER_CROP_RATIO) -> np.ndarray: + """取画面中心 ratio 比例区域(裁除四边边缘)。 + + 查重指纹用:random_edge_crop 降重(2-5% 四边随机裁剪)会让同源画面 pHash + 位翻转 12-16,污染自查重(Issue #1702)。算 pHash/颜色直方图前先居中裁除 + 边缘 10%,两次不同裁剪的同源画面中心区域基本重合,指纹不再被降重污染。 + 降重只服务外部平台,不影响内部查重。 + """ + if image is None or image.size == 0: + return image + h, w = image.shape[:2] + ch, cw = int(h * ratio), int(w * ratio) + if ch <= 0 or cw <= 0 or (ch >= h and cw >= w): + return image + y0 = (h - ch) // 2 + x0 = (w - cw) // 2 + return image[y0 : y0 + ch, x0 : x0 + cw] + + def compute_phash(image: np.ndarray, hash_size: int = 8) -> str: """计算图像的感知哈希(pHash),基于 DCT(离散余弦变换)。 @@ -101,11 +136,17 @@ def hamming_distance(hash1: str, hash2: str) -> int: def compute_color_histogram(image: np.ndarray, bins: int = 32) -> list[float]: - """Compute color histogram for an image.""" + """Compute BGR color histogram for an image. + + Issue #1702: 每个通道独立做 NORM_L1 归一化(通道内 Σ=1,是概率分布), + 三通道拼接存储。Bhattacharyya 系数对拼接向量直接 Σ√(a*b) 会得到 + 3 通道之和(范围 [0,3],实测 ~14.9 是旧 L2 归一化的错误结果), + 消费方 _bhattacharyya_coefficient 按通道数平均归一到 [0,1]。 + """ hist = [] for i in range(3): h = cv2.calcHist([image], [i], None, [bins], [0, 256]) - h = cv2.normalize(h, h).flatten() + h = cv2.normalize(h, h, norm_type=cv2.NORM_L1).flatten() hist.extend(h) return hist @@ -210,6 +251,30 @@ def detect_keyframe_timestamps( return keyframe_times +def sample_fingerprint_timestamps( + duration: float, + *, + interval_sec: float = FINGERPRINT_SAMPLE_INTERVAL_SEC, + max_samples: int = FINGERPRINT_MAX_SAMPLES, +) -> list[float]: + """指纹采样时间戳:固定间隔密集均匀采样(Issue #1702)。 + + 动态场景检测抽帧(#1659)在两个同源视频上会各自取到不同时刻,切点/取帧 + 错位让对齐帧的 pHash 距离都很大(实测同源对最小距离 12 且配对时序错乱)。 + 改为固定 1s 间隔均匀采样后,复用片段的帧时刻天然对齐,配合 ±1 邻接窗口 + 即可检出同源/局部复用。长视频(>max_samples*interval)自动放宽间隔到 + duration/max_samples,保证分片数有上限。 + """ + if duration <= 0: + return [] + step = interval_sec + n_uniform = int(duration / step) + if n_uniform > max_samples: + step = duration / max_samples + count = max(1, int(duration / step)) + return [step * (i + 0.5) for i in range(count)] + + # ── 数据类 ────────────────────────────────────────────────────── @@ -297,23 +362,32 @@ def find_duplicate_segments( target_chunks: list, *, match_threshold: int = SEGMENT_MATCH_THRESHOLD, - min_consecutive: int = MIN_CONSECUTIVE_MATCHES, + min_consecutive: Optional[int] = None, max_gap: int = MAX_GAP, + neighbor_window: int = NEIGHBOR_WINDOW, ) -> list[DuplicateSegment]: - """滑动窗口时序匹配:找出两组分片之间的重复片段。 + """滑动窗口时序匹配:找出两组分片之间的重复片段(Issue #1702 重构)。 算法: - 1. 对每个 query chunk,找到 target 中汉明距离最小的 chunk - 2. 距离 <= match_threshold 视为匹配 - 3. 找连续匹配的 run(允许 max_gap 帧间隙) - 4. 连续匹配数 >= min_consecutive 的 run 报告为重复片段 + 1. 构建 query×target 全量汉明距离矩阵;每个 query chunk 保留所有 + 距离 <= match_threshold 的候选 target 分片(与帧匹配判定同一阈值)。 + 2. 时序一致贪心对齐:沿 query 时序推进,run 内优先选择与上一匹配帧 + 目标序号连贯(0 <= delta <= neighbor_window+1,允许 ±1 邻接窗口 / + 时序偏移对齐,缓解场景切割导致的切点、取帧错位)的候选;同距时 + 偏好大索引,避免重复 hash 塌缩到 target 首帧。 + 3. 连贯匹配中允许 <= max_gap 帧间隙桥接;断裂后另起新 run——天然 + 支持局部片段复用(复用片段可出现在任意时序位置,各成独立片段)。 + 4. 连续匹配帧数 >= min_consecutive 的 run 报为重复片段。短视频自适应: + min_consecutive = min(5, max(2, len(query_chunks)//2));n=1 时 + 不形成片段,由调用方匹配帧回退兜底。 Args: query_chunks: 查询视频的分片列表(FingerprintChunk 或 dict) target_chunks: 目标视频的分片列表 - match_threshold: 汉明距离匹配阈值 - min_consecutive: 最少连续匹配帧数 + match_threshold: 汉明距离匹配阈值(统一常量 PHASH_THRESHOLD) + min_consecutive: 最少连续匹配帧数;None 时按短视频自适应 max_gap: 允许的最大间隙帧数 + neighbor_window: 时序对齐允许的目标分片序号邻接窗口 Returns: DuplicateSegment 列表 @@ -321,95 +395,93 @@ def find_duplicate_segments( if not query_chunks or not target_chunks: return [] - def _get_phash(chunk) -> str: + def _get(chunk, key): if isinstance(chunk, dict): - return chunk["phash_binary"] - return chunk.phash_binary + return chunk[key] + return getattr(chunk, key) - def _get_start(chunk) -> int: - if isinstance(chunk, dict): - return chunk["start_time_ms"] - return chunk.start_time_ms + n, m = len(query_chunks), len(target_chunks) + q_ph = [_get(c, "phash_binary") for c in query_chunks] + t_ph = [_get(c, "phash_binary") for c in target_chunks] - def _get_end(chunk) -> int: - if isinstance(chunk, dict): - return chunk["end_time_ms"] - return chunk.end_time_ms + # Step 1: 全量距离矩阵。每个 query chunk 保留所有 <= 阈值的候选 target, + # 按距离升序;同距时小索引优先(取最早的对齐位置,贪心连贯推进时最保守, + # 不会越过复用片段末端;重复 hash 的连续帧由 Step 2 的连贯性窗口约束)。 + candidates: list[list[tuple[int, int]]] = [] # 每 query 帧: [(target_idx, dist), ...] + for i in range(n): + dists = [hamming_distance(q_ph[i], t_ph[j]) for j in range(m)] + cand = [(j, d) for j, d in enumerate(dists) if d <= match_threshold] + cand.sort(key=lambda x: (x[1], x[0])) + candidates.append(cand) - # Step 1: 逐帧匹配 - frame_matches: list[tuple[bool, int, int]] = [] # (is_match, min_dist, best_target_idx) - for qc in query_chunks: - qc_phash = _get_phash(qc) - best_dist = 64 - best_idx = 0 - for j, tc in enumerate(target_chunks): - d = hamming_distance(qc_phash, _get_phash(tc)) - if d < best_dist: - best_dist = d - best_idx = j - frame_matches.append((best_dist <= match_threshold, best_dist, best_idx)) + # 短视频自适应连续匹配门槛(Issue #1702 工单公式): + # MIN_CONSECUTIVE_MATCHES = min(5, max(2, 分片数//2))。 + # n=1 时门槛为 2 不形成片段,由 _evaluate_candidate 的匹配帧回退 + # (temporal_coverage 按匹配帧占比估计)兜底检出,不回归。 + if min_consecutive is None: + min_consecutive = min(MIN_CONSECUTIVE_MATCHES, max(2, n // 2)) - # Step 2: 找连续匹配的 runs - runs: list[tuple[int, int]] = [] # list of (start_idx, end_idx) - run_start = None + # Step 2: 时序一致贪心对齐。 + # run 内偏好与上一匹配帧目标序号连贯(0 <= delta <= neighbor_window+1, + # 支持 ±1 邻接窗口/时序偏移对齐)的候选;无连贯候选时关闭旧 run。 + # 这天然支持局部片段复用:同一 query 视频中多个复用片段各自形成独立 run。 + frame_matches: list[tuple[bool, int, int]] = [] + runs: list[tuple[int, int]] = [] + run_start: Optional[int] = None + run_last_t: Optional[int] = None gap_count = 0 - for i, (is_match, _dist, _idx) in enumerate(frame_matches): - if is_match: + def _matching_count(a: int, b: int) -> int: + return sum(1 for k in range(a, b + 1) if frame_matches[k][0]) + + def _close_run(a: int, b: int) -> None: + if b >= a and _matching_count(a, b) >= min_consecutive: + runs.append((a, b)) + + for i in range(n): + cand = candidates[i] + if run_last_t is None: + chosen = cand[0] if cand else None + else: + chosen = next( + (c for c in cand if 0 <= c[0] - run_last_t <= neighbor_window + 1), + None, + ) + + if chosen is not None: + tidx, dist = chosen + frame_matches.append((True, dist, tidx)) if run_start is None: run_start = i - gap_count = 0 # 重置间隙 + gap_count = 0 + run_last_t = tidx else: + frame_matches.append((False, match_threshold + 1, -1)) if run_start is not None: gap_count += 1 if gap_count > max_gap: - # 中断当前 run - run_end = i - gap_count # 最后一个匹配帧的索引 - # 计算 run 内的实际匹配帧数(总跨度 - 间隙数) - total_gaps = sum(1 for k in range(run_start, run_end + 1) if not frame_matches[k][0]) - matching_count = (run_end - run_start + 1) - total_gaps - if matching_count >= min_consecutive: - runs.append((run_start, run_end)) - run_start = None - gap_count = 0 + # 非匹配帧从 i-gap_count+1 开始,run 结束于其前一帧 + _close_run(run_start, i - gap_count) + run_start, run_last_t, gap_count = None, None, 0 - # 处理末尾 run if run_start is not None: - last_idx = len(frame_matches) - 1 - # 回退找到最后一个匹配帧的位置(跳过尾部非匹配帧) + last_idx = n - 1 while last_idx >= run_start and not frame_matches[last_idx][0]: last_idx -= 1 - if last_idx >= run_start: - # 计算 run 内的总间隙数 - total_gaps = sum(1 for k in range(run_start, last_idx + 1) if not frame_matches[k][0]) - matching_count = (last_idx - run_start + 1) - total_gaps - if matching_count >= min_consecutive: - runs.append((run_start, last_idx)) + _close_run(run_start, last_idx) # Step 3: 构建 DuplicateSegment segments: list[DuplicateSegment] = [] for start, end in runs: - query_start = _get_start(query_chunks[start]) - query_end = _get_end(query_chunks[end]) - - # 取目标范围(按最佳匹配的目标 chunk 时间范围) target_indices = [frame_matches[k][2] for k in range(start, end + 1) if frame_matches[k][0]] - if target_indices: - t_min = min(target_indices) - t_max = max(target_indices) - target_start = _get_start(target_chunks[t_min]) - target_end = _get_end(target_chunks[t_max]) - else: - target_start = _get_start(target_chunks[0]) - target_end = _get_end(target_chunks[-1]) - - avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1)) / (end - start + 1) + t_min, t_max = min(target_indices), max(target_indices) + avg_dist = sum(frame_matches[k][1] for k in range(start, end + 1) if frame_matches[k][0]) / len(target_indices) segments.append( DuplicateSegment( - query_start_ms=query_start, - query_end_ms=query_end, - target_start_ms=target_start, - target_end_ms=target_end, + query_start_ms=_get(query_chunks[start], "start_time_ms"), + query_end_ms=_get(query_chunks[end], "end_time_ms"), + target_start_ms=_get(target_chunks[t_min], "start_time_ms"), + target_end_ms=_get(target_chunks[t_max], "end_time_ms"), avg_distance=avg_dist, ) ) @@ -423,7 +495,9 @@ def find_duplicate_segments( class VideoDeduplicator: """Video deduplication using multiple fingerprint methods.""" - PHASH_THRESHOLD = 8 # Issue #1658: pHash 汉明距离阈值由 10 收紧到 8,降低不同视频误判率 + # Issue #1702: 阈值统一来源为模块常量 PHASH_THRESHOLD(#1658 曾收紧到 8, + # 后经 staging 真实同源/异源指纹分布重新校准,见 test_phash_threshold_calibration_1702)。 + PHASH_THRESHOLD = PHASH_THRESHOLD HISTOGRAM_THRESHOLD = 0.85 @staticmethod @@ -447,27 +521,36 @@ class VideoDeduplicator: # 单帧不视为坏指纹(短视频或抽帧不足) if len(phashes) == 1: return False - # 多帧但所有 phash 完全相同 → 黑屏/纯色视频 + # Issue #1702: 旧逻辑"所有 phash 完全相同即判黑屏"会误杀短视频—— + # 11s 视频只有几个不同镜头时,相邻 1s 采样帧可能 phash 完全一致(内容 + # 连续但非黑屏)。黑屏的特征是「大量帧全部无内容」,要求至少 8 帧 + # 且相同帧占比 >=80% 才判坏;短视频(<8 帧)只有真正单值时交给 + # _bhattacharyya/融合分兜底,不因"帧都一样"直接跳过。 + if len(phashes) < 8: + return False unique = set(phashes) - if len(unique) == 1: + same_ratio = sum(1 for x in phashes if x == phashes[0]) / len(phashes) + if len(unique) == 1 and same_ratio >= 0.8: return True - # 多帧但所有 phash 之间的汉明距离都极小(<3)→ 近似黑屏 + # 多帧但所有唯一 phash 之间的汉明距离都极小(<3)且占比 >=80% → 近似黑屏 phash_list = list(unique) - if len(phash_list) >= 2: - all_distances = [] - for i in range(len(phash_list)): - for j in range(i + 1, len(phash_list)): - all_distances.append(hamming_distance(phash_list[i], phash_list[j])) + if len(phash_list) >= 2 and same_ratio >= 0.8: + all_distances = [ + hamming_distance(phash_list[i], phash_list[j]) + for i in range(len(phash_list)) + for j in range(i + 1, len(phash_list)) + ] if all_distances and max(all_distances) < 3: return True return False def compute_fingerprint(self, video_path: str) -> VideoFingerprint: - """Compute video fingerprint using dynamic keyframe detection. + """Compute video fingerprint using dense uniform sampling. - 使用 detect_keyframe_timestamps() 检测内容感知关键帧, - 在每个关键帧处取帧计算 pHash + color_histogram。 - 同时保留 MD5 计算和分片数据结构。 + Issue #1702: 使用 sample_fingerprint_timestamps() 固定 1s 间隔密集均匀 + 采样(替代动态场景检测抽帧),保证两个同源视频复用片段的帧时刻天然 + 对齐;每帧取中心 90% 区域(center_crop_frame)计算 pHash + color_histogram, + 绕开 random_edge_crop 降重裁剪污染;MD5 仍基于原始帧。 """ cap = cv2.VideoCapture(video_path) if not cap.isOpened(): @@ -481,8 +564,8 @@ class VideoDeduplicator: cap.release() - # 1. 检测关键帧时间戳 - keyframe_times = detect_keyframe_timestamps(video_path) + # 1. 固定间隔密集采样(Issue #1702:替代动态场景检测,保证跨视频时序对齐) + keyframe_times = sample_fingerprint_timestamps(duration) if not keyframe_times: return VideoFingerprint( @@ -506,12 +589,15 @@ class VideoDeduplicator: if not ret: continue - # MD5 计算 + # MD5 计算(基于原始帧,指纹文件级去重不受裁剪影响) _, buffer = cv2.imencode(".jpg", frame) md5_hash.update(buffer) - phash = compute_phash(frame) - hist = compute_color_histogram(frame) + # Issue #1702: pHash / 颜色直方图基于中心 90% 区域,绕开 random_edge_crop + # 降重裁剪对指纹的污染(降重只服务外部平台,不污染自查重)。 + fp_frame = center_crop_frame(frame) + phash = compute_phash(fp_frame) + hist = compute_color_histogram(fp_frame) # 计算分片时间范围(从前一个关键帧到下一个关键帧的中点) prev_boundary = keyframe_times[i - 1] * 1000 if i > 0 else 0 @@ -564,12 +650,22 @@ class VideoDeduplicator: @staticmethod def _bhattacharyya_coefficient(hist_a: list[float], hist_b: list[float]) -> float: - """Bhattacharyya 系数:Σ √(a[i] * b[i]),范围 [0, 1],1=完全相同。""" + """Bhattacharyya 系数(概率分布版,范围 [0,1],1=完全相同)。 + + Issue #1702: compute_color_histogram 输出 3 通道拼接、每通道独立 NORM_L1 + (单通道 Σ=1,三通道拼接向量 Σ=3)。旧实现直接 Σ√(a*b) 对三通道拼接向量 + 算出 ~3(旧 L2 归一化更是算出 ~14.9),不是合法的概率系数。 + 这里按两个直方图各自的总量归一:BC = Σ√(a*b) / √(Σa·Σb)。 + - 单通道概率分布(Σa=Σb=1):分母 1,与旧测试/教科书定义一致; + - 三通道拼接(Σa=Σb=3):分母 3,结果在 [0,1]。 + """ min_len = min(len(hist_a), len(hist_b)) - a = hist_a[:min_len] - b = hist_b[:min_len] - # 纯标准库计算(不依赖 numpy);max(0.0, ...) 防御上游异常负值导致 sqrt domain error - return float(sum(math.sqrt(max(0.0, ai * bi)) for ai, bi in zip(a, b, strict=False))) + a = [max(0.0, float(x)) for x in hist_a[:min_len]] + b = [max(0.0, float(x)) for x in hist_b[:min_len]] + # max(0.0, ...) 防御上游异常负值导致 sqrt domain error + coeff = sum(math.sqrt(ai * bi) for ai, bi in zip(a, b, strict=False)) + norm = math.sqrt(sum(a) * sum(b)) + return float(coeff / norm) if norm > 0 else 0.0 @staticmethod def _compute_histogram_similarity( @@ -611,6 +707,72 @@ class VideoDeduplicator: hist_similarity = VideoDeduplicator._compute_histogram_similarity(hist_a, hist_b) if hist_b else 0.5 return PHASH_WEIGHT * phash_similarity + HISTOGRAM_WEIGHT * hist_similarity + @staticmethod + def _evaluate_candidate( + fingerprint: VideoFingerprint, + existing_phashes: list[str], + existing_histograms: list, + existing_chunk_objects: list, + *, + query_duration_sec: float, + ) -> dict: + """评估新视频指纹与单个候选视频的相似度(Issue #1702 共享逻辑)。 + + 指标: + - min_distances / frame_match_rate:每个新分片到候选视频全局最近邻的汉明距离, + 分母取两视频分片数的较小值(支持局部片段复用:短视频复用长视频片段时不被长视频分母稀释)。 + - temporal_coverage:时序一致连续匹配片段总时长 / 新视频时长(局部复用主指标)。 + - fusion:pHash 中位数距离 + 颜色直方图的加权融合分。 + + Returns: + {frame_match_rate, temporal_coverage, segments, median_distance, + fusion, matching_frames, min_distances} + """ + query_phashes = fingerprint.keyframe_phashes or [] + if not query_phashes or not existing_phashes: + return { + "frame_match_rate": 0.0, + "temporal_coverage": 0.0, + "segments": [], + "median_distance": 64, + "fusion": 0.0, + "matching_frames": 0, + "min_distances": [], + } + + min_distances = [min(hamming_distance(ph, ep) for ep in existing_phashes) for ph in query_phashes] + matching_frames = sum(1 for d in min_distances if d <= PHASH_THRESHOLD) + # 分母取 min(两视频分片数):局部复用时(如 B 的 5 片复用 A 9 片中的若干片) + # 命中帧占比不因候选视频更长而被稀释。 + frame_match_rate = matching_frames / min(len(query_phashes), len(existing_phashes)) + + segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) + duration_ms = query_duration_sec * 1000 if query_duration_sec else 0 + if duration_ms > 0 and segments: + covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments) + temporal_coverage = min(covered_ms / duration_ms, 1.0) + elif matching_frames > 0: + # 无连续片段(时序连贯性不足)时,按匹配帧占比估计覆盖: + # 密集 1s 采样下每个分片≈1s 等权时间片,匹配帧数≈命中秒数。 + temporal_coverage = min(frame_match_rate, 1.0) + else: + temporal_coverage = 0.0 + + median_distance = statistics.median(min_distances) if min_distances else 64 + fusion = VideoDeduplicator._compute_fusion_score( + median_distance, fingerprint.color_histograms, existing_histograms + ) + + return { + "frame_match_rate": frame_match_rate, + "temporal_coverage": temporal_coverage, + "segments": segments, + "median_distance": median_distance, + "fusion": fusion, + "matching_frames": matching_frames, + "min_distances": min_distances, + } + def check_duplicate( self, fingerprint: VideoFingerprint, @@ -649,6 +811,9 @@ class VideoDeduplicator: else: existing_videos = video_repo.list_by_project(project_id) + best_score = 0.0 + best_result: Optional[dict] = None + for existing in existing_videos: if not existing.video_fingerprint: continue @@ -677,61 +842,70 @@ class VideoDeduplicator: if not existing_phashes: continue - # 计算每个新关键帧到已有关键帧的最小汉明距离 - min_distances = [] - for phash in fingerprint.keyframe_phashes: - distances = [hamming_distance(phash, ep) for ep in existing_phashes] - min_distances.append(min(distances)) - - # 帧匹配比例检查 - matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) - match_ratio = matching_frames / len(min_distances) if min_distances else 0 - if match_ratio < MATCH_RATIO_THRESHOLD: - continue - - # 中位数距离 - median_distance = statistics.median(min_distances) if min_distances else 64 - if median_distance >= self.PHASH_THRESHOLD: - continue - - # 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表) + # 直方图 / 分片对象(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表) if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] + existing_chunk_objects = chunk_data else: existing_histograms = ef.get("color_histograms") or [] + existing_chunk_objects = [ + {"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes + ] - combined_score = self._compute_fusion_score( - median_distance, fingerprint.color_histograms, existing_histograms + # Issue #1702: 统一评估每个候选(含局部片段复用),不再用 + # "frame_match_rate<0.7 整条跳过" 的硬门槛——局部复用(如 B 结尾 2s + # ≈ A 中间 2s)帧比例天然低,但 coverage 能检出。 + ev = self._evaluate_candidate( + fingerprint, + existing_phashes, + existing_histograms, + existing_chunk_objects, + query_duration_sec=fingerprint.duration, + ) + logger.debug( + "check_duplicate candidate=%s min_distances=%s frame_match_rate=%.3f " + "temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d", + existing.id, + ev["min_distances"], + ev["frame_match_rate"], + ev["temporal_coverage"], + ev["median_distance"], + ev["fusion"], + len(ev["segments"]), ) - if combined_score < DUPLICATE_THRESHOLD: - continue - - # 滑动窗口时序匹配:获取具体重复片段 - existing_chunk_objects = ( - chunk_data - if chunk_data - else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes] + # 全片重复判定:融合分过阈 且(帧匹配比例 >=70% 或 局部覆盖 >=50%) + is_full_duplicate = ev["fusion"] >= DUPLICATE_THRESHOLD and ( + ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD ) - segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) - - return { - "duplicate": True, - "duplicate_of": existing.id, - "reason": "phash_histogram_fusion", - "similarity": combined_score, - "duplicate_segments": [ - { - "query_start_ms": s.query_start_ms, - "query_end_ms": s.query_end_ms, - "target_start_ms": s.target_start_ms, - "target_end_ms": s.target_end_ms, - "avg_distance": round(s.avg_distance, 2), - } - for s in segments - ], - } + if is_full_duplicate and ev["fusion"] > best_score: + best_score = ev["fusion"] + best_result = { + "duplicate": True, + "duplicate_of": existing.id, + "reason": "phash_histogram_fusion", + "similarity": ev["fusion"], + "duplicate_segments": [ + { + "query_start_ms": s.query_start_ms, + "query_end_ms": s.query_end_ms, + "target_start_ms": s.target_start_ms, + "target_end_ms": s.target_end_ms, + "avg_distance": round(s.avg_distance, 2), + } + for s in ev["segments"] + ], + } + if best_result: + return best_result + logger.info( + "check_duplicate no match (project=%s scope=%s): %d candidates evaluated, best_fusion=%.3f", + project_id, + scope, + len(existing_videos), + best_score, + ) return None def check_batch_duplicate( @@ -763,6 +937,9 @@ class VideoDeduplicator: video_repo = SQLAlchemyGeneratedVideoRepository(session) batch_videos = video_repo.list_by_batch(batch_id) + best_score = 0.0 + best_result: Optional[dict] = None + for existing in batch_videos: if existing.id == current_video_id: continue @@ -796,59 +973,59 @@ class VideoDeduplicator: if not existing_phashes: continue - min_distances = [] - for phash in fingerprint.keyframe_phashes: - distances = [hamming_distance(phash, ep) for ep in existing_phashes] - min_distances.append(min(distances)) - - # 帧匹配比例检查 - matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) - match_ratio = matching_frames / len(min_distances) if min_distances else 0 - if match_ratio < MATCH_RATIO_THRESHOLD: - continue - - median_distance = statistics.median(min_distances) if min_distances else 64 - if median_distance >= self.PHASH_THRESHOLD: - continue - - # 直方图融合(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表) if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] + existing_chunk_objects = chunk_data else: existing_histograms = ef.get("color_histograms") or [] + existing_chunk_objects = [ + {"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes + ] - combined_score = self._compute_fusion_score( - median_distance, fingerprint.color_histograms, existing_histograms + ev = self._evaluate_candidate( + fingerprint, + existing_phashes, + existing_histograms, + existing_chunk_objects, + query_duration_sec=fingerprint.duration, + ) + logger.debug( + "check_batch_duplicate candidate=%s min_distances=%s frame_match_rate=%.3f " + "temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d", + existing.id, + ev["min_distances"], + ev["frame_match_rate"], + ev["temporal_coverage"], + ev["median_distance"], + ev["fusion"], + len(ev["segments"]), ) - if combined_score < DUPLICATE_THRESHOLD: - continue - - # 滑动窗口时序匹配 - existing_chunk_objects = ( - chunk_data - if chunk_data - else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes] + is_full_duplicate = ev["fusion"] >= DUPLICATE_THRESHOLD and ( + ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD ) - segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) - - return { - "duplicate": True, - "duplicate_of": existing.id, - "reason": "batch_phash_histogram_fusion", - "similarity": combined_score, - "duplicate_segments": [ - { - "query_start_ms": s.query_start_ms, - "query_end_ms": s.query_end_ms, - "target_start_ms": s.target_start_ms, - "target_end_ms": s.target_end_ms, - "avg_distance": round(s.avg_distance, 2), - } - for s in segments - ], - } + if is_full_duplicate and ev["fusion"] > best_score: + best_score = ev["fusion"] + best_result = { + "duplicate": True, + "duplicate_of": existing.id, + "reason": "batch_phash_histogram_fusion", + "similarity": ev["fusion"], + "duplicate_segments": [ + { + "query_start_ms": s.query_start_ms, + "query_end_ms": s.query_end_ms, + "target_start_ms": s.target_start_ms, + "target_end_ms": s.target_end_ms, + "avg_distance": round(s.avg_distance, 2), + } + for s in ev["segments"] + ], + } + if best_result: + return best_result + logger.info("check_batch_duplicate no match (batch=%s): best_fusion=%.3f", batch_id, best_score) return None def compute_duplicate_rate( @@ -897,8 +1074,7 @@ class VideoDeduplicator: max_duplicate_rate = 0.0 max_visual_similarity = 0.0 match_count = 0 - - total_duration_ms = fingerprint.duration if fingerprint.duration else 0 + evaluated = 0 for existing in existing_videos: if current_video_id and existing.id == current_video_id: @@ -933,57 +1109,63 @@ class VideoDeduplicator: if not existing_phashes or not fingerprint.keyframe_phashes: continue - min_distances = [] - for phash in fingerprint.keyframe_phashes: - distances = [hamming_distance(phash, ep) for ep in existing_phashes] - min_distances.append(min(distances)) - - # frame_match_rate - total_frames = len(min_distances) - if total_frames == 0: - continue - matching_frames = sum(1 for d in min_distances if d < self.PHASH_THRESHOLD) - frame_match_rate = matching_frames / total_frames - - # 帧匹配比例太低则跳过 - if frame_match_rate < 0.3: - continue - - # temporal_coverage_rate via find_duplicate_segments - existing_chunk_objects = ( - chunk_data - if chunk_data - else [{"phash_binary": p, "start_time_ms": 0, "end_time_ms": 0} for p in existing_phashes] - ) - segments = find_duplicate_segments(fingerprint.chunks, existing_chunk_objects) - - if total_duration_ms > 0 and segments: - covered_ms = sum(s.query_end_ms - s.query_start_ms for s in segments) - temporal_coverage_rate = min(covered_ms / total_duration_ms, 1.0) - else: - temporal_coverage_rate = 0.0 - - # duplicate_rate = 0.4 * frame_match_rate + 0.6 * temporal_coverage_rate - dup_rate = (frame_match_rate * 0.4 + temporal_coverage_rate * 0.6) * 100 - - # visual_similarity (融合相似度,归一化 0~1) - median_distance = statistics.median(min_distances) if min_distances else 64 + # 直方图 / 分片对象(chunk 表优先,回退 JSON 字段;JSON NULL 显式回退空列表) if chunk_data: existing_histograms = [c["color_histogram"] for c in chunk_data if c.get("color_histogram")] + existing_chunk_objects = chunk_data else: - # JSON NULL 显式回退空列表 existing_histograms = ef.get("color_histograms") or [] + existing_chunk_objects = [ + {"phash_binary": pp, "start_time_ms": 0, "end_time_ms": 0} for pp in existing_phashes + ] - visual_sim = self._compute_fusion_score(median_distance, fingerprint.color_histograms, existing_histograms) + # Issue #1702: 统一评估;frame_match_rate 分母为 min(两视频分片数), + # temporal_coverage 时长量纲在 _evaluate_candidate 内统一为毫秒。 + ev = self._evaluate_candidate( + fingerprint, + existing_phashes, + existing_histograms, + existing_chunk_objects, + query_duration_sec=fingerprint.duration, + ) + evaluated += 1 + logger.debug( + "compute_duplicate_rate candidate=%s min_distances=%s frame_match_rate=%.3f " + "temporal_coverage=%.3f median=%.1f fusion=%.3f segments=%d", + existing.id, + ev["min_distances"], + ev["frame_match_rate"], + ev["temporal_coverage"], + ev["median_distance"], + ev["fusion"], + len(ev["segments"]), + ) - # 判定是否为重复(融合分数超过阈值) - if visual_sim >= DUPLICATE_THRESHOLD: + # 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 + + # 全片重复计数与 check_duplicate 判定口径一致 + if ev["fusion"] >= DUPLICATE_THRESHOLD and ( + ev["frame_match_rate"] >= MATCH_RATIO_THRESHOLD or ev["temporal_coverage"] >= PARTIAL_COVERAGE_THRESHOLD + ): match_count += 1 if dup_rate > max_duplicate_rate: max_duplicate_rate = dup_rate - max_visual_similarity = visual_sim + max_visual_similarity = ev["fusion"] + logger.info( + "compute_duplicate_rate done (project=%s scope=%s): evaluated=%d max_rate=%.2f%% " + "max_visual_sim=%.3f matches=%d", + project_id, + scope, + evaluated, + max_duplicate_rate, + max_visual_similarity, + match_count, + ) return { "duplicate_rate": round(max(max_duplicate_rate, 0.0), 2), "visual_similarity": round(max_visual_similarity, 4), @@ -999,18 +1181,20 @@ def _save_fingerprint_chunks( session: Session, ) -> None: """将指纹分片数据批量写入 video_fingerprint_chunks 表。幂等:已有数据时跳过。""" - # 幂等检查:已有分片数据则跳过 - existing_count = ( - session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count() - ) - if existing_count > 0: - logger.debug("Fingerprint chunks already exist for video %s (%d chunks), skipping", video_id, existing_count) - return - if not fingerprint.chunks: logger.warning("No chunks in fingerprint for video %s, skipping chunk save", video_id) return + # Issue #1702: recompute-dedup 重算时指纹算法已变(中心裁剪 + 新阈值), + # 旧分片必须替换而非跳过(旧实现"有数据就跳过"导致重算不刷新分片表)。 + deleted = ( + session.query(VideoFingerprintChunkModel) + .filter(VideoFingerprintChunkModel.video_id == video_id) + .delete(synchronize_session=False) + ) + if deleted: + logger.info("Replaced %d stale fingerprint chunks for video %s", deleted, video_id) + chunk_models = fingerprint.to_chunk_models(video_id, project_id, user_id) session.bulk_save_objects(chunk_models) logger.info("Saved %d fingerprint chunks for video %s", len(chunk_models), video_id) @@ -1045,7 +1229,9 @@ def check_duplicate_task(self: Task, generated_video_id: str) -> dict: session, scope="user", user_id=video.user_id, - duration_sec=fingerprint.duration / 1000 if fingerprint.duration else 0, + # Issue #1702: fingerprint.duration 单位已经是秒,旧代码 /1000 导致 + # ±15% 时长预过滤窗口缩到 ~0.013s,scope=user 的跨项目查重永远返回 None。 + duration_sec=fingerprint.duration if fingerprint.duration else 0, ) video.video_fingerprint = fingerprint.to_dict() diff --git a/apps/worker/video_processing/dedup_helpers.py b/apps/worker/video_processing/dedup_helpers.py index b7e6d4965..6d96eb9cf 100755 --- a/apps/worker/video_processing/dedup_helpers.py +++ b/apps/worker/video_processing/dedup_helpers.py @@ -92,7 +92,8 @@ def create_video_record_and_dedup( logger.warning("Failed to save fingerprint chunks for %s: %s", video_id, chunk_err) # (a) 历史成片查重(跨项目全局 + 时长预过滤) - duration_sec = fingerprint.duration / 1000 if fingerprint.duration else 0 + # Issue #1702: fingerprint.duration 单位是秒,旧代码 /1000 让时长预过滤失效 + duration_sec = fingerprint.duration if fingerprint.duration else 0 duplicate_result = deduplicator.check_duplicate( fingerprint, project_id, diff --git a/tests/unit/test_bad_fingerprint_filter.py b/tests/unit/test_bad_fingerprint_filter.py index 05a18cb08..8e4ec78fe 100644 --- a/tests/unit/test_bad_fingerprint_filter.py +++ b/tests/unit/test_bad_fingerprint_filter.py @@ -85,17 +85,18 @@ class TestIsBadFingerprint: assert VideoDeduplicator._is_bad_fingerprint(["abcdef0123456789"]) is False def test_all_identical_phashes_is_bad(self): - """多帧但所有 phash 完全相同 → 黑屏/纯色视频。""" - phashes = ["aaaaaaaaaaaaaaaa"] * 5 + """>=8 帧且所有 phash 完全相同 → 黑屏/纯色视频(#1702:短帧不误杀)。""" + phashes = ["aaaaaaaaaaaaaaaa"] * 10 assert VideoDeduplicator._is_bad_fingerprint(phashes) is True - def test_two_identical_phashes_is_bad(self): - """两帧完全相同也视为坏指纹。""" - assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is True + def test_short_identical_phashes_not_bad(self): + """<8 帧完全相同不判坏——短视频内容连续时相邻采样帧 phash 天然相同(#1702)。""" + assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb"] * 5) is False + assert VideoDeduplicator._is_bad_fingerprint(["bbbbbbbbbbbbbbbb", "bbbbbbbbbbbbbbbb"]) is False def test_all_very_similar_phashes_is_bad(self): - """多帧 phash 之间的汉明距离都 < 3 → 近似黑屏。""" - phashes = ["0000000000000000", "0000000000000001", "0000000000000002"] + """>=8 帧 phash 之间的汉明距离都 < 3 且高占比 → 近似黑屏。""" + phashes = ["0000000000000000"] * 8 + ["0000000000000001", "0000000000000002"] assert VideoDeduplicator._is_bad_fingerprint(phashes) is True def test_diverse_phashes_is_good(self): @@ -122,7 +123,9 @@ class TestIsBadFingerprint: """已知黑屏视频的 phash 特征(全零或均匀分布)。""" assert VideoDeduplicator._is_bad_fingerprint(["0000000000000000"] * 10) is True assert VideoDeduplicator._is_bad_fingerprint(["ffffffffffffffff"] * 8) is True - assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 6) is True + assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 8) is True + # <8 帧不判坏(#1702 短视频保护) + assert VideoDeduplicator._is_bad_fingerprint(["9999999999999966"] * 5) is False # ── Helper ────────────────────────────────────────────────────── @@ -151,13 +154,13 @@ class TestCheckDuplicateBadFingerprint: deduplicator = VideoDeduplicator() mock_session = MagicMock() - black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 5) + black_screen = _make_existing_video("vid-black", "md5_black", ["aaaaaaaaaaaaaaaa"] * 10) mock_repo = MagicMock() mock_repo.list_by_user.return_value = [black_screen] fingerprint = VideoFingerprint( md5="md5_normal", - keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 5, + keyframe_phashes=["aaaaaaaaaaaaaaaa"] * 10, color_histograms=[], duration=10.0, resolution=(1280, 720), @@ -206,7 +209,7 @@ class TestCheckDuplicateBadFingerprint: deduplicator = VideoDeduplicator() mock_session = MagicMock() - black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 5) + black_screen = _make_existing_video("vid-black", "same_md5", ["aaaaaaaaaaaaaaaa"] * 10) mock_repo = MagicMock() mock_repo.list_by_user.return_value = [black_screen] @@ -283,7 +286,7 @@ class TestComputeDuplicateRateBadFingerprint: mock_session = MagicMock() videos = [ - _make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 5), + _make_existing_video("vid-b1", "md5_b1", ["aaaaaaaaaaaaaaaa"] * 10), _make_existing_video("vid-b2", "md5_b2", ["bbbbbbbbbbbbbbbb"] * 5), ] mock_repo = MagicMock() diff --git a/tests/unit/test_dedup_1702_zero_rate_fix.py b/tests/unit/test_dedup_1702_zero_rate_fix.py new file mode 100644 index 000000000..e8d2fdafd --- /dev/null +++ b/tests/unit/test_dedup_1702_zero_rate_fix.py @@ -0,0 +1,361 @@ +"""Issue #1702 — 查重率恒为 0% 修复:单测. + +覆盖验收要求: +1. 同源不同裁剪的两个视频能检出非 0 相似度(指纹中心裁剪绕开降重 + 阈值校准) +2. 局部片段复用(B 结尾 2s ≈ A 中间 2s)能检出 +3. 异源视频不误报(相似度接近 0) +4. N=1 现有流程不回归 +5. P1 确定性 bug:时长预过滤单位 /1000、直方图归一化、temporal_coverage 量纲、阈值比较统一 +6. P0:±1 邻接对齐、短视频自适应连续门槛 +7. P2:0 匹配也要落日志 +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.modules.setdefault("cv2", MagicMock()) + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "apps" / "worker")) +sys.path.insert(0, str(ROOT / "packages")) + + +from video_processing.dedup import ( # noqa: E402 + PHASH_THRESHOLD, + SEGMENT_MATCH_THRESHOLD, + FingerprintChunk, + VideoDeduplicator, + VideoFingerprint, + find_duplicate_segments, +) + +# ── helpers ──────────────────────────────────────────────────── + + +def _h(d: int) -> str: + """64-bit phash with exactly d bits set vs zero hash.""" + bits = ["0"] * 64 + for i in range(d): + bits[i] = "1" + return f"{int(''.join(bits), 2):016x}" + + +def _chunk(phash: str, t0: float, t1: float): + + return FingerprintChunk( + start_time_ms=int(t0 * 1000), + end_time_ms=int(t1 * 1000), + phash_binary=phash, + color_histogram=[], + frame_count=1, + ) + + +def _fingerprint(phashes, duration, chunks=None, md5="fp-md5-x"): + + return VideoFingerprint( + md5=md5, + keyframe_phashes=list(phashes), + color_histograms=[], + duration=duration, + resolution=(1280, 720), + chunks=chunks or [], + ) + + +def _video(vid, phashes, duration=10.0, project_id="proj1"): + from packages.domain import GeneratedVideo + + return GeneratedVideo( + id=vid, + project_id=project_id, + generation_task_id=f"task-{vid}", + name=f"video-{vid}.mp4", + file_url=f"https://example.com/{vid}.mp4", + file_size=1000, + duration=duration, + width=1280, + height=720, + fps=25.0, + video_fingerprint={"md5": f"md5-{vid}", "keyframe_phashes": list(phashes)}, + ) + + +def _rate(deduplicator, fp, videos, session=None): + session_magic = MagicMock() + # 分片表无数据 -> 回退 JSON keyframe_phashes + session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + repo = MockRepo.return_value + repo.list_by_project.return_value = videos + repo.list_by_user.return_value = videos + return deduplicator.compute_duplicate_rate(fp, "proj1", "new-vid", session_magic, scope="project") + + +def _check(deduplicator, fp, videos, scope="project", **kw): + session_magic = MagicMock() + session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + repo = MockRepo.return_value + repo.list_by_project.return_value = videos + repo.list_by_user.return_value = videos + return deduplicator.check_duplicate(fp, "proj1", session_magic, scope=scope, **kw) + + +# ── P0-1/P0-2: 同源不同裁剪(距离 6~10)检出非 0 ────────────── + + +class TestSameSourceDifferentCrop: + """同源成片:random_edge_crop 后 pHash 距离 6~10,应检出非 0 相似度。""" + + def test_same_source_high_similarity_detected(self): + + ddp = VideoDeduplicator() + # 新视频 5 个分片,每个 phash 与已有视频对应分片距离 6(< 阈值) + base = [_h(0) for _ in range(5)] + new = [_h(6) for _ in range(5)] + existing = _video("v-old", base, duration=11.0) + chunks = [_chunk(h, i * 2.2, (i + 1) * 2.2) for i, h in enumerate(new)] + fp = _fingerprint(new, 11.0, chunks=chunks) + + result = _rate(ddp, fp, [existing], MagicMock()) + assert result["duplicate_rate"] > 0 + assert result["visual_similarity"] > 0 + + def test_same_source_distance_at_threshold_still_detected(self): + """距离正好等于阈值(<=)也要算匹配——阈值比较统一为 <=。""" + + assert PHASH_THRESHOLD <= 12, "阈值应经校准保持在能检出同源裁剪的范围" + ddp = VideoDeduplicator() + base = [_h(0) for _ in range(6)] + new = [_h(PHASH_THRESHOLD) for _ in range(6)] + existing = _video("v-old", base, duration=12.0) + chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)] + fp = _fingerprint(new, 12.0, chunks=chunks) + + result = _rate(ddp, fp, [existing], MagicMock()) + assert result["duplicate_rate"] > 0 + + +# ── P0-2: 局部片段复用(B 结尾 2s ≈ A 中间 2s) ──────────────── + + +class TestPartialReuse: + def test_partial_reuse_tail_overlap_detected(self): + """新视频 6 片,最后 2 片命中已有视频中间 2 片(距离 4),其余不匹配。 + + 旧逻辑 frame_match_rate=2/6≈0.33(<0.3 硬跳过边界)+ MIN_CONSECUTIVE=5 + 导致完全检不出;新逻辑 coverage 为主指标 + 自适应门槛应检出。 + """ + + ddp = VideoDeduplicator() + # 已有 8 片:索引 3、4 是被复用的镜头 + old = [_h(20 + i) for i in range(8)] + # 新视频 6 片:最后 2 片对应 old[3], old[4],距离 4;其余距离 30 + new = [_h(50 + i) for i in range(4)] + [_h(4)] * 2 + # 让 new[4] 与 old[3] 距离 4、new[5] 与 old[4] 距离 4(构造近似) + new[4] = f"{int('1' * 4 + '0' * 60, 2):016x}" + new[5] = f"{int('1' * 4 + '0' * 60, 2):016x}" + old[3] = _h(0) + old[4] = _h(0) + + existing = _video("v-old", old, duration=16.0) + chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)] + fp = _fingerprint(new, 12.0, chunks=chunks) + + result = _rate(ddp, fp, [existing], MagicMock()) + # 局部复用:duplicate_rate 必须非 0 + assert result["duplicate_rate"] > 0 + + def test_short_video_adaptive_consecutive_threshold(self): + """11s/5 片短视频:MIN_CONSECUTIVE 自适应 min(5, max(2, 5//2))=2, + 2 片连续命中即报片段(旧值 5 让短视频永远无法报片段)。""" + + q = [ + FingerprintChunk(0, 2000, "f" * 16, []), + FingerprintChunk(2000, 4000, "0" * 16, []), + FingerprintChunk(4000, 6000, f"{int('11110000', 2):016x}", []), + ] + t = [ + FingerprintChunk(0, 2000, "f" * 16, []), + FingerprintChunk(2000, 4000, "0" * 16, []), + FingerprintChunk(4000, 6000, "e" * 16, []), + ] + # 3 片视频自适应门槛 = min(5, max(2, 3//2)) = 2 + segs = find_duplicate_segments(q, t) + assert len(segs) >= 1 + + +# ── P0-3: ±1 邻接窗口对齐 ───────────────────────────────────── + + +class TestNeighborAlignment: + def test_neighbor_window_absorbs_boundary_jitter(self): + """切点错位导致目标索引偏移 ±1 时,连续匹配不应被中断。""" + + q = [FingerprintChunk(i * 1000, (i + 1) * 1000, f"{i:016x}", []) for i in range(4)] + # 目标:前 3 片与 q 相同,但第 3 片最佳匹配偏移 +1(t[4]),t[3] 是无关内容 + t_hashes = [f"{i:016x}" for i in range(3)] + ["f" * 16, f"{3:016x}"] + t = [FingerprintChunk(i * 1000, (i + 1) * 1000, h, []) for i, h in enumerate(t_hashes)] + segs = find_duplicate_segments(q, t) + # q[0],q[1] 精确匹配 t[0],t[1];q[2]->t[2];q[3]->t[4](步进 2,窗口 ±1 内) + assert len(segs) >= 1 + assert segs[0].query_end_ms >= 3000 + + +# ── P0-5 / 验收:异源不误报 ─────────────────────────────────── + + +class TestDifferentSourceNoFalsePositive: + def test_unrelated_videos_near_zero(self): + + ddp = VideoDeduplicator() + # 异源:所有分片距离 >= 20 + old = [_h(40 + i * 3 % 20) for i in range(6)] + new = [_h(0 + i) for i in range(6)] + existing = _video("v-old", old, duration=12.0) + chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)] + fp = _fingerprint(new, 12.0, chunks=chunks) + + result = _rate(ddp, fp, [existing], MagicMock()) + assert result["duplicate_rate"] == 0 + assert result["visual_similarity"] < 0.7 + assert result["match_count"] == 0 + + def test_check_duplicate_returns_none_for_unrelated(self): + + ddp = VideoDeduplicator() + old = [_h(40 + i) for i in range(6)] + new = [_h(i) for i in range(6)] + existing = _video("v-old", old, duration=12.0) + fp = _fingerprint(new, 12.0) + + result = _check(ddp, fp, [existing]) + assert result is None + + +# ── N=1 不回归 ──────────────────────────────────────────────── + + +class TestSingleChunkNoRegression: + def test_single_chunk_identical_detected(self): + + ddp = VideoDeduplicator() + h = _h(2) + existing = _video("v-old", [h], duration=3.0) + chunks = [_chunk(h, 0, 3000)] + fp = _fingerprint([h], 3.0, chunks=chunks) + result = _rate(ddp, fp, [existing], MagicMock()) + assert result["duplicate_rate"] > 0 + + def test_single_chunk_md5_exact_match(self): + + ddp = VideoDeduplicator() + existing = _video("v-old", [_h(0)], duration=3.0) + existing.video_fingerprint["md5"] = "same" + fp = _fingerprint([_h(0)], 3.0, md5="same") + result = _check(ddp, fp, [existing]) + assert result is not None + assert result["reason"] == "exact_md5_match" + + +# ── P1-6: 时长预过滤单位 bug ────────────────────────────────── + + +class TestDurationPrefilterUnit: + def test_duration_sec_not_divided_by_1000(self): + """fingerprint.duration 单位是秒,传给 check_duplicate 不应再 /1000。 + + 旧 bug:duration/1000 → duration_max≈0.0135s,所有真实视频被过滤。 + """ + + ddp = VideoDeduplicator() + fp = _fingerprint([_h(0)], 13.5) + session_magic = MagicMock() + session_magic.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo: + repo = MockRepo.return_value + repo.list_by_user.return_value = [] + ddp.check_duplicate(fp, "proj1", session_magic, scope="user", user_id="u1", duration_sec=fp.duration) + _, kwargs = repo.list_by_user.call_args + # ±15% 窗口:13.5s -> [11.475, 15.525] + assert 11.0 < kwargs["duration_min"] < 12.0 + assert 15.0 < kwargs["duration_max"] < 16.0 + + +# ── P1-7: 颜色直方图归一化 ──────────────────────────────────── + + +class TestHistogramNormalization: + def test_bhattacharyya_coefficient_in_unit_range(self): + """Bhattacharyya 系数必须在 [0,1](旧 L2 + 3 通道拼接算出 ~14.9)。""" + + # 3 通道拼接、每通道概率分布(Σ=1) + hist_a = [0.5, 0.5] + [0.0] * 94 + [0.5, 0.5] + [0.0] * 94 + [0.5, 0.5] + [0.0] * 94 + # 长度裁剪到 96(3 通道 × 32 bins) + hist_a = ([0.5, 0.5] + [0.0] * 30) * 3 + hist_b = ([0.5, 0.5] + [0.0] * 30) * 3 + + coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b) + assert 0.0 <= coeff <= 1.0 + assert coeff > 0.99 # 完全相同 -> 1.0 + + def test_bhattacharyya_disjoint_hist_low(self): + + hist_a = ([1.0] + [0.0] * 31) * 3 + hist_b = ([0.0] * 31 + [1.0]) * 3 + coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b) + assert coeff < 0.05 + + +# ── P1-8: temporal_coverage 量纲 ────────────────────────────── + + +class TestTemporalCoverageUnits: + def test_coverage_uses_milliseconds(self): + """命中片段 6s / 视频 12s -> coverage=0.5;旧 bug 把 duration(秒)当毫秒, + covered_ms(6000)/duration(12) = 500 -> min(1.0)=1.0 误判 100% 覆盖。""" + + ddp = VideoDeduplicator() + old = [_h(0) for _ in range(6)] + new = [_h(0) for _ in range(3)] + [_h(30) for _ in range(3)] + existing = _video("v-old", old, duration=12.0) + # 新视频 12s,前 6s(3 片)与 old 相同 + chunks = [_chunk(h, i * 2, (i + 1) * 2) for i, h in enumerate(new)] + fp = _fingerprint(new, 12.0, chunks=chunks) + result = _rate(ddp, fp, [existing], MagicMock()) + # coverage 应约 0.5(3 片 × 2s = 6s / 12s),duplicate_rate ≈ (0.5*0.4 + 0.5*0.6)*100 = 50 + assert 30 < result["duplicate_rate"] < 70 + + +# ── P1-9: 阈值比较统一 ──────────────────────────────────────── + + +class TestThresholdConsistency: + def test_frame_and_segment_thresholds_same_source(self): + + assert SEGMENT_MATCH_THRESHOLD == PHASH_THRESHOLD + assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD + + +# ── P2: 0 匹配也要有日志痕迹 ────────────────────────────────── + + +class TestZeroMatchLogging: + def test_no_match_emits_info_log(self, caplog): + + ddp = VideoDeduplicator() + old = [_h(40 + i) for i in range(5)] + existing = _video("v-old", old, duration=10.0) + fp = _fingerprint([_h(i) for i in range(5)], 10.0) + + with caplog.at_level(logging.INFO, logger="video_processing.dedup"): + result = _check(ddp, fp, [existing]) + assert result is None + assert any("no match" in r.message for r in caplog.records) diff --git a/tests/unit/test_dedup_engine.py b/tests/unit/test_dedup_engine.py index e9ace015d..35ce57d0e 100644 --- a/tests/unit/test_dedup_engine.py +++ b/tests/unit/test_dedup_engine.py @@ -358,11 +358,11 @@ class TestVideoDeduplicatorCheckDuplicate: finally: self._restore_repo(mod, orig) - def test_first_match_returned(self, deduplicator, mock_session): - """返回第一个通过阈值的匹配(非最优匹配)。""" - # vid-1: 距离=2 bits(0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值 + def test_highest_score_match_returned(self, deduplicator, mock_session): + """Issue #1702: 遍历所有候选取融合分最高者(旧逻辑首个过阈即返回)。""" + # vid-1: 距离=1 bit(0x03 XOR 0x01 = 0x02 → 1 bit),通过阈值 vid1 = self._make_existing_video("vid-1", "md5_1", phashes=["0000000000000003"]) - # vid-2: 距离=0 bits(完全匹配) + # vid-2: 距离=0 bits(完全匹配),融合分更高 vid2 = self._make_existing_video("vid-2", "md5_2", phashes=["0000000000000001"]) mock_repo = MagicMock() @@ -380,8 +380,8 @@ class TestVideoDeduplicatorCheckDuplicate: try: result = deduplicator.check_duplicate(fingerprint, "proj-1", mock_session) assert result is not None - # 返回第一个通过阈值的匹配(vid-1 距离=1 < 10) - assert result["duplicate_of"] == "vid-1" + # 两个候选都过阈,返回融合分最高的 vid-2(距离 0 < 1) + assert result["duplicate_of"] == "vid-2" finally: self._restore_repo(mod, orig) diff --git a/tests/unit/test_dedup_pure.py b/tests/unit/test_dedup_pure.py index ab70cdb15..a80787daa 100755 --- a/tests/unit/test_dedup_pure.py +++ b/tests/unit/test_dedup_pure.py @@ -185,11 +185,14 @@ class TestBhattacharyyaCoefficient: """_bhattacharyya_coefficient Bhattacharyya 系数测试.""" def test_identical_histograms(self): - """完全相同的直方图系数为1.0.""" - hist = [0.5, 0.5, 0.0, 0.3] + """完全相同的直方图系数为1.0(#1702:按 Σ 归一,概率分布语义)。""" + hist = [0.5, 0.5, 0.0, 0.0] # Σ=1 的概率分布 bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist) - # Σ √(a[i]*a[i]) = Σ a[i] = 1.0 (normalized) - assert bc == pytest.approx(sum(h for h in hist)) + assert bc == pytest.approx(1.0) + # 非归一化输入也归一到 1.0(三通道拼接 Σ=3 的等价情形) + hist3 = [0.5, 0.5, 0.0, 0.3] + bc3 = VideoDeduplicator._bhattacharyya_coefficient(hist3, hist3) + assert bc3 == pytest.approx(1.0) def test_zero_histograms(self): """全零直方图系数为0.""" @@ -202,10 +205,10 @@ class TestBhattacharyyaCoefficient: assert bc == pytest.approx(0.0) def test_different_lengths(self): - """不同长度直方图取最小长度对齐.""" + """不同长度直方图取最小长度对齐,并按各自总量归一(#1702 概率分布语义)。""" + # 对齐到前 2 维:coeff = 2,norm = √(Σa·Σb) = √(2·2) = 2 → 1.0 bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 1.0, 0.0, 0.0], [1.0, 1.0]) - # 对齐到前2维: √(1*1) + √(1*1) = 2.0 - assert bc == pytest.approx(2.0) + assert bc == pytest.approx(1.0) def test_known_value(self): """已知值验证.""" diff --git a/tests/unit/test_dedup_v2.py b/tests/unit/test_dedup_v2.py index 8fe0e8540..ce3a91213 100644 --- a/tests/unit/test_dedup_v2.py +++ b/tests/unit/test_dedup_v2.py @@ -484,8 +484,10 @@ class TestBackwardCompatibility: chunks_b = [{"phash_binary": "aaaaaaaaaaaaaaaa", "start_time_ms": 0, "end_time_ms": 5000}] segments = find_duplicate_segments(chunks_a, chunks_b) - # 1 帧 < min_consecutive=5,不会报重复 - assert segments == [] + # Issue #1702: 自适应门槛 min(5, max(2, 1//2))=2,1 帧不成段; + # N=1 的检出由 _evaluate_candidate 匹配帧回退兜底(见 test_dedup_1702)。 + # 这里只要求不崩溃。 + assert isinstance(segments, list) # ── TestConstants ─────────────────────────────────────────────── @@ -495,8 +497,9 @@ class TestConstants: """常量值验证 — 使用已在模块顶部导入的常量,避免重新 import.""" def test_segment_match_threshold(self): - # 从已导入的 find_duplicate_segments 默认参数间接验证 - assert SEGMENT_MATCH_THRESHOLD == 8 + # Issue #1702: pHash 阈值经 staging 真实同源/异源指纹回归校准 + # (同源密集采样 min=8、异源 min=24),统一为模块常量 PHASH_THRESHOLD=12。 + assert SEGMENT_MATCH_THRESHOLD == 12 def test_min_consecutive_matches(self): assert MIN_CONSECUTIVE_MATCHES == 5 diff --git a/tests/unit/test_fingerprint_chunks.py b/tests/unit/test_fingerprint_chunks.py index 12635d72b..ccaf649b5 100644 --- a/tests/unit/test_fingerprint_chunks.py +++ b/tests/unit/test_fingerprint_chunks.py @@ -3,7 +3,7 @@ 覆盖: - 分片策略:60秒视频 → 30片,120秒视频 → 24片 - VideoFingerprint.to_chunk_models() 输出正确 -- _save_fingerprint_chunks 幂等性(已有数据跳过) +- _save_fingerprint_chunks 替换语义(Issue #1702:重算时先删旧分片再写入) - to_dict() 向后兼容 """ @@ -169,11 +169,15 @@ class TestVideoFingerprintToChunkModels: assert models == [] -class TestSaveFingerprintChunksIdempotent: - """测试 _save_fingerprint_chunks 幂等性。""" +class TestSaveFingerprintChunksReplace: + """测试 _save_fingerprint_chunks 替换语义(Issue #1702)。 - def test_save_skips_existing(self): - """已有分片数据时跳过写入。""" + 重算查重时指纹算法已升级(中心裁剪 + 新采样/阈值),旧分片必须先删除 + 再写入新分片,否则 recompute-dedup 永远读到旧指纹、修复对存量视频不生效。 + """ + + def test_save_replaces_existing(self): + """已有分片数据时:先删除旧分片,再写入新分片。""" fp = VideoFingerprint( md5="abc", keyframe_phashes=["a1b2"], @@ -186,16 +190,22 @@ class TestSaveFingerprintChunksIdempotent: ) session = MagicMock() - # Mock: 已有 1 条分片数据 - session.query.return_value.filter.return_value.count.return_value = 1 + # Mock: 删除旧分片返回 3(旧算法留下的 3 条分片) + session.query.return_value.filter.return_value.delete.return_value = 3 _save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session) - # bulk_save_objects 不应被调用 - session.bulk_save_objects.assert_not_called() + # 必须先执行删除 + session.query.return_value.filter.return_value.delete.assert_called_once() + # 新分片必须写入 + session.bulk_save_objects.assert_called_once() + saved_models = session.bulk_save_objects.call_args[0][0] + assert len(saved_models) == 1 + assert saved_models[0].video_id == "v1" + assert saved_models[0].phash_binary == "a1b2" def test_save_writes_new(self): - """无分片数据时写入。""" + """无旧分片时直接写入。""" fp = VideoFingerprint( md5="abc", keyframe_phashes=["a1b2"], @@ -208,12 +218,12 @@ class TestSaveFingerprintChunksIdempotent: ) session = MagicMock() - # Mock: 无分片数据 - session.query.return_value.filter.return_value.count.return_value = 0 + # Mock: 无旧分片 + session.query.return_value.filter.return_value.delete.return_value = 0 _save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session) - # bulk_save_objects 应被调用一次 + session.query.return_value.filter.return_value.delete.assert_called_once() session.bulk_save_objects.assert_called_once() saved_models = session.bulk_save_objects.call_args[0][0] assert len(saved_models) == 1 @@ -221,7 +231,7 @@ class TestSaveFingerprintChunksIdempotent: assert saved_models[0].phash_binary == "a1b2" def test_save_skips_no_chunks(self): - """指纹无 chunks 时跳过。""" + """指纹无 chunks 时跳过(不删不写)。""" fp = VideoFingerprint( md5="abc", keyframe_phashes=[], @@ -232,11 +242,11 @@ class TestSaveFingerprintChunksIdempotent: ) session = MagicMock() - session.query.return_value.filter.return_value.count.return_value = 0 _save_fingerprint_chunks(fp, video_id="v1", project_id="p1", user_id="u1", session=session) - # bulk_save_objects 不应被调用 + # 无 chunks:不查询、不删除、不写入 + session.query.assert_not_called() session.bulk_save_objects.assert_not_called() diff --git a/tests/unit/test_phash_threshold_calibration_1658.py b/tests/unit/test_phash_threshold_calibration_1658.py index 6e01b4a6a..dc15dc120 100644 --- a/tests/unit/test_phash_threshold_calibration_1658.py +++ b/tests/unit/test_phash_threshold_calibration_1658.py @@ -102,6 +102,7 @@ from video_processing.dedup import ( # noqa: E402 DUPLICATE_THRESHOLD, HISTOGRAM_WEIGHT, MATCH_RATIO_THRESHOLD, + PHASH_THRESHOLD, PHASH_WEIGHT, VideoDeduplicator, ) @@ -128,11 +129,15 @@ _ZERO_HIST = [0.0] * 96 # 全黑视频的全零直方图(有效数据) class TestThresholdCalibration: - """pHash 阈值由 10 收紧到 8(Issue #1658)。""" + """pHash 阈值校准(Issue #1658 收紧到 8,Issue #1702 经真实指纹分布重校准为 12)。 - def test_phash_threshold_is_8(self): - """PHASH_THRESHOLD 必须为 8(旧值 10 会放过 8~9 汉明距离的不同视频)。""" - assert VideoDeduplicator.PHASH_THRESHOLD == 8 + #1702 staging 离线实验:同帧两次 2-5% 随机裁剪距离 4~10;同源成片(密集 1s + 采样)最小距离 8、<=12 命中 10/31;异源成片最小距离 24。8 会漏检同源裁剪, + 12 检出同源且与异源分布(>=24)间隔充足。 + """ + + def test_phash_threshold_is_calibrated(self): + assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD == 12 def test_match_ratio_threshold_constant(self): assert MATCH_RATIO_THRESHOLD == 0.7 @@ -144,22 +149,21 @@ class TestThresholdCalibration: assert PHASH_WEIGHT == 0.7 assert HISTOGRAM_WEIGHT == 0.3 - def test_threshold_tightening_excludes_distance_8_and_9(self): - """距离 8、9 的帧:旧阈值 10 下算匹配,新阈值 8 下不算匹配。 + def test_threshold_matching_semantics(self): + """阈值比较统一为 <=(帧匹配与片段匹配同一口径)。 - 场景:5 个关键帧距离为 [7, 7, 7, 9, 9]。 - - 旧阈值 10:5 帧全部 < 10 → match_ratio = 1.0(误放过) - - 新阈值 8:仅 3 帧 < 8 → match_ratio = 0.6 < 0.7(正确跳过) + 场景:5 个关键帧距离为 [10, 12, 12, 24, 26]。 + - <=12(#1702 校准阈值):3 帧匹配 → 0.6 < 0.7 被帧比例门槛拦截异源 + - 距离 12 的同源裁剪帧应算匹配(< 与 <= 口径统一) """ - distances = [7, 7, 7, 9, 9] + distances = [10, 12, 12, 24, 26] + matched = sum(1 for d in distances if d <= VideoDeduplicator.PHASH_THRESHOLD) + assert matched == 3 + assert matched / len(distances) == 0.6 + assert matched / len(distances) < MATCH_RATIO_THRESHOLD - matched_old = sum(1 for d in distances if d < 10) - assert matched_old == 5 # 旧行为:全匹配 → 误判风险 - - matched_new = sum(1 for d in distances if d < VideoDeduplicator.PHASH_THRESHOLD) - assert matched_new == 3 - assert matched_new / len(distances) == 0.6 - assert matched_new / len(distances) < MATCH_RATIO_THRESHOLD # 被帧比例门槛拦截 + # 异源典型距离(>=24)绝不匹配 + assert not any(d <= VideoDeduplicator.PHASH_THRESHOLD for d in (24, 26, 30)) # ── TestComputeFusionScore:统一融合得分方法 ────────────────────