Files
xiaoxia-saas/tests/unit/test_dedup_pure.py
T
xiaoxia fac80b1f77
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 22s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 9s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m22s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m25s
AI Code Review / AI Code Review (pull_request) Failing after 1m50s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m39s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m44s
CI/CD Pipeline / Validate - Style (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Security (pull_request) Has been cancelled
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been cancelled
feat(dedup): 动态抽帧 + 滑动窗口时序匹配 (#1659)
1. 动态抽帧策略 — detect_keyframe_timestamps()
   - 降采样到 320x240 逐帧灰度差异检测场景切换
   - 最小间隔过滤(保留差异最大的候选帧)
   - 数量裁剪到 [MIN_KEYFRAMES=5, MAX_KEYFRAMES=30]
   - 长视频(>3分钟)每 30 秒分段保底

2. 滑动窗口时序匹配 — find_duplicate_segments()
   - 逐帧最佳匹配 → 连续 run 检测(允许 MAX_GAP=2 间隙)
   - 最少 MIN_CONSECUTIVE_MATCHES=5 帧才报告
   - 返回 DuplicateSegment(query/target 时间范围 + 平均距离)

3. 查重算法升级
   - 均值距离 → 中位数距离(抵抗异常值)
   - 新增帧匹配比例条件(match_ratio >= 0.7)
   - Bhattacharyya 系数替代余弦相似度
   - pHash + 直方图加权融合(0.7/0.3)
   - 判定重复后附加 duplicate_segments 字段

4. 删除旧代码
   - 移除 SHORT_VIDEO_CHUNK_SEC/LONG_VIDEO_CHUNK_SEC 固定间隔
   - 移除 compute_chunk_interval()
   - 移除 _average_histogram_similarity()

5. 测试
   - 新增 test_dedup_v2.py: 34 个测试
   - 更新 test_dedup_engine.py/test_duplicate_rate.py/test_dedup_pure.py
   - 清理 test_fingerprint_chunks.py 中旧常量测试
2026-09-03 23:00:21 +08:00

249 lines
8.5 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""去重纯算法测试 — hamming_distance + histogram_similarity + VideoFingerprint."""
from __future__ import annotations
import sys
from unittest.mock import MagicMock
import numpy as np
import pytest
def _mock_module(**attrs):
"""Create a mock module with __spec__ to avoid AttributeError: __spec__."""
m = MagicMock()
m.__spec__ = None
for k, v in attrs.items():
setattr(m, k, v)
return m
# ── Module-level setup: mock deps, import dedup, then restore sys.modules ──
# This pattern ensures:
# 1. dedup is imported with mocks active (no db/celery/cv2 side effects)
# 2. sys.modules is restored immediately so other test files are not polluted
# 3. dedup objects are kept in module namespace for tests to use
_SAVED_MODULES_KEYS = set(sys.modules.keys())
_SAVED_MODULES_VALUES = {
k: sys.modules.get(k)
for k in [
"cv2",
"celery",
"sqlalchemy",
"sqlalchemy.orm",
"sqlalchemy.engine",
"sqlalchemy.ext",
"sqlalchemy.ext.declarative",
"worker_app.db",
"worker_app.celery_app",
"worker_app.core.config",
"packages.adapters.sqlalchemy_impl.session",
"packages.adapters.sqlalchemy_impl.generated_video_repository",
"packages.shared.config",
"packages.shared.storage",
]
}
# Set up mocks
sys.modules["cv2"] = _mock_module()
_mock_celery = MagicMock()
_mock_celery.Task = MagicMock
_mock_celery.Celery = MagicMock
_mock_celery.__spec__ = None
sys.modules["celery"] = _mock_celery
_mock_sqla = MagicMock()
_mock_sqla.__path__ = []
_mock_sqla.__spec__ = None
sys.modules["sqlalchemy"] = _mock_sqla
_mock_sqla_orm = MagicMock()
_mock_sqla_orm.__path__ = []
_mock_sqla_orm.__spec__ = None
_mock_sqla_orm.Session = MagicMock
sys.modules["sqlalchemy.orm"] = _mock_sqla_orm
sys.modules["sqlalchemy.engine"] = _mock_module()
sys.modules["sqlalchemy.ext"] = _mock_module()
sys.modules["sqlalchemy.ext.declarative"] = _mock_module()
sys.modules["worker_app.db"] = _mock_module(SessionLocal=MagicMock())
sys.modules["worker_app.celery_app"] = _mock_module(celery_app=MagicMock())
sys.modules["worker_app.core.config"] = _mock_module(get_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.adapters.sqlalchemy_impl.session"] = _mock_module(
Base=MagicMock(),
build_engine=MagicMock(),
build_session_factory=MagicMock(),
ensure_database_exists=MagicMock(),
initialize_database=MagicMock(),
)
sys.modules["packages.adapters.sqlalchemy_impl.generated_video_repository"] = _mock_module()
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
sys.modules["packages.shared.storage"] = _mock_module()
# Import dedup while mocks are active
from video_processing.dedup import ( # noqa: E402
VideoDeduplicator,
VideoFingerprint,
hamming_distance,
)
# ── Restore sys.modules immediately after import ──
# dedup is now cached in this module's namespace; other test files will get
# their own fresh imports without our mock pollution
for _key in list(sys.modules.keys()):
if _key not in _SAVED_MODULES_KEYS:
del sys.modules[_key]
for _key, _value in _SAVED_MODULES_VALUES.items():
if _value is not None:
sys.modules[_key] = _value
elif _key in sys.modules:
del sys.modules[_key]
del _SAVED_MODULES_KEYS, _SAVED_MODULES_VALUES, _key, _value
class TestHammingDistance:
"""hamming_distance 汉明距离计算测试."""
def test_identical_hashes_zero(self):
"""相同哈希距离为0."""
assert hamming_distance("ff", "ff") == 0
assert hamming_distance("00", "00") == 0
def test_all_different(self):
"""全不同的8bit哈希距离为8."""
assert hamming_distance("00", "ff") == 8
def test_single_bit_diff(self):
"""1个bit不同."""
# 0x01 = 00000001, 0x00 = 00000000 → 1 bit不同
assert hamming_distance("01", "00") == 1
def test_four_bits_diff(self):
"""4个bit不同."""
# 0x0F = 00001111, 0xF0 = 11110000 → 8 bits都不同
assert hamming_distance("0f", "f0") == 8
def test_longer_hashes(self):
"""更长的哈希(如64-bit pHash."""
# 两个完全不同的64-bit哈希
assert hamming_distance("0000000000000000", "ffffffffffffffff") == 64
def test_partial_difference(self):
"""部分bit不同."""
# a = 1010, 5 = 0101 → 4 bits不同(每个hex digit
assert hamming_distance("aa", "55") == 8
def test_case_insensitive(self):
"""十六进制不区分大小写."""
assert hamming_distance("FF", "ff") == 0
assert hamming_distance("AbC123", "aBc123") == 0
def test_different_length_hashes(self):
"""不同长度的哈希(短的前补零)."""
# "ff" = 0xff = 255, "0ff" = 0x0ff = 255
# int("ff", 16) = 255, int("0ff", 16) = 255
assert hamming_distance("ff", "0ff") == 0
class TestVideoFingerprint:
"""VideoFingerprint 数据结构测试."""
def test_to_dict_contains_all_fields(self):
"""to_dict返回完整字典."""
fp = VideoFingerprint(
md5="abc123",
keyframe_phashes=["hash1", "hash2"],
color_histograms=[[0.1, 0.2], [0.3, 0.4]],
duration=30.5,
resolution=(1920, 1080),
)
d = fp.to_dict()
assert d["md5"] == "abc123"
assert d["keyframe_phashes"] == ["hash1", "hash2"]
assert d["duration"] == 30.5
assert d["resolution"] == [1920, 1080]
assert "color_histograms" in d
def test_empty_phashes(self):
"""空关键帧列表."""
fp = VideoFingerprint(
md5="test",
keyframe_phashes=[],
color_histograms=[],
duration=0.0,
resolution=(0, 0),
)
d = fp.to_dict()
assert d["keyframe_phashes"] == []
assert d["color_histograms"] == []
class TestBhattacharyyaCoefficient:
"""_bhattacharyya_coefficient Bhattacharyya 系数测试."""
def test_identical_histograms(self):
"""完全相同的直方图系数为1.0."""
hist = [0.5, 0.5, 0.0, 0.3]
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))
def test_zero_histograms(self):
"""全零直方图系数为0."""
bc = VideoDeduplicator._bhattacharyya_coefficient([0.0, 0.0], [0.0, 0.0])
assert bc == 0.0
def test_orthogonal_histograms(self):
"""正交直方图(无重叠)系数为0."""
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0], [0.0, 1.0])
assert bc == pytest.approx(0.0)
def test_different_lengths(self):
"""不同长度直方图取最小长度对齐."""
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)
def test_known_value(self):
"""已知值验证."""
# [0.25, 0.25, 0.25, 0.25] vs [0.25, 0.25, 0.25, 0.25]
# BC = 4 * √(0.25 * 0.25) = 4 * 0.25 = 1.0
hist = [0.25, 0.25, 0.25, 0.25]
bc = VideoDeduplicator._bhattacharyya_coefficient(hist, hist)
assert bc == pytest.approx(1.0)
class TestComputeHistogramSimilarity:
"""_compute_histogram_similarity 多帧直方图相似度测试."""
def test_identical_histogram_groups(self):
"""完全相同的两组直方图."""
hist = [[0.5, 0.5], [0.3, 0.4]]
sim = VideoDeduplicator._compute_histogram_similarity(hist, hist)
# Each hist finds best match = itself
assert sim > 0.0
def test_empty_first(self):
"""第一组为空返回0."""
assert VideoDeduplicator._compute_histogram_similarity([], [[0.5]]) == 0.0
def test_empty_second(self):
"""第二组为空返回0."""
assert VideoDeduplicator._compute_histogram_similarity([[0.5]], []) == 0.0
def test_both_empty(self):
"""两组都为空返回0."""
assert VideoDeduplicator._compute_histogram_similarity([], []) == 0.0
def test_best_match_selection(self):
"""多帧时取最佳匹配."""
# ha[0] 与 hb[0] 正交,与 hb[1] 完全相同
a = [[1.0, 0.0]]
b = [[0.0, 1.0], [1.0, 0.0]]
sim = VideoDeduplicator._compute_histogram_similarity(a, b)
# Best match for [1,0]: max(BC([1,0],[0,1]), BC([1,0],[1,0])) = max(0, 1) = 1
assert sim == pytest.approx(1.0)