ad76eaa56f
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 7s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 7s
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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 28s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 25s
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 / 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 / Validate - Python (mypy + alembic) (pull_request) Successful in 2m16s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m10s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m25s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 2m43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m3s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m30s
AI Code Review / AI Code Review (pull_request) Successful in 4m20s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m42s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m25s
764 lines
31 KiB
Python
764 lines
31 KiB
Python
"""Issue #1658: pHash 阈值校准 + 颜色直方图融合 — 完整单元测试。
|
||
|
||
测试覆盖:
|
||
1. PHASH_THRESHOLD 从 10 收紧到 8
|
||
2. 中位数替代均值(抵抗异常值)
|
||
3. 帧匹配比例条件 (≥70%)
|
||
4. Bhattacharyya 系数计算
|
||
5. 直方图融合逻辑
|
||
6. _check_fusion_duplicate 共用方法
|
||
7. check_duplicate / check_batch_duplicate 集成
|
||
8. compute_duplicate_rate 融合逻辑
|
||
9. 向后兼容(无直方图数据时不崩溃)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import numpy as np
|
||
import pytest
|
||
|
||
|
||
def _mock_module(**attrs):
|
||
"""Create a mock module with __spec__ to avoid AttributeError."""
|
||
m = MagicMock()
|
||
m.__spec__ = None
|
||
if "__path__" not in attrs:
|
||
m.__path__ = []
|
||
for k, v in attrs.items():
|
||
setattr(m, k, v)
|
||
return m
|
||
|
||
|
||
# ── Mock setup ──
|
||
_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.adapters.sqlalchemy_impl.models",
|
||
"packages.shared.config",
|
||
"packages.shared.storage",
|
||
]
|
||
}
|
||
|
||
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.adapters.sqlalchemy_impl.models"] = _mock_module(
|
||
VideoFingerprintChunkModel=MagicMock(),
|
||
GeneratedVideoModel=MagicMock(),
|
||
)
|
||
sys.modules["packages.shared.config"] = _mock_module(get_shared_settings=MagicMock(return_value=MagicMock()))
|
||
sys.modules["packages.shared.storage"] = _mock_module()
|
||
|
||
import video_processing.dedup as _dedup_module # noqa: E402
|
||
|
||
# Import while mocks active
|
||
from video_processing.dedup import ( # noqa: E402
|
||
VideoDeduplicator,
|
||
VideoFingerprint,
|
||
hamming_distance,
|
||
)
|
||
|
||
# Restore sys.modules
|
||
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
|
||
|
||
|
||
# ── Helpers ──
|
||
def _make_fingerprint(phashes: list[str], histograms: list[list[float]] | None = None) -> VideoFingerprint:
|
||
"""构造测试用 VideoFingerprint."""
|
||
if histograms is None:
|
||
histograms = [[0.5] * 96 for _ in phashes]
|
||
return VideoFingerprint(
|
||
md5="test_md5",
|
||
keyframe_phashes=phashes,
|
||
color_histograms=histograms,
|
||
duration=30.0,
|
||
resolution=(1920, 1080),
|
||
)
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 1. 常量验证
|
||
# ══════════════════════════════════════════════
|
||
class TestConstants:
|
||
"""Issue #1658 常量验证。"""
|
||
|
||
def test_phash_threshold_is_8(self):
|
||
"""PHASH_THRESHOLD 应为 8(从 10 收紧)。"""
|
||
assert VideoDeduplicator.PHASH_THRESHOLD == 8
|
||
|
||
def test_match_ratio_threshold(self):
|
||
assert VideoDeduplicator.MATCH_RATIO_THRESHOLD == 0.7
|
||
|
||
def test_duplicate_threshold(self):
|
||
assert VideoDeduplicator.DUPLICATE_THRESHOLD == 0.70
|
||
|
||
def test_phash_weight(self):
|
||
assert VideoDeduplicator.PHASH_WEIGHT == 0.7
|
||
|
||
def test_histogram_weight(self):
|
||
assert VideoDeduplicator.HISTOGRAM_WEIGHT == 0.3
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 2. pHash 阈值变更测试
|
||
# ══════════════════════════════════════════════
|
||
class TestPHashThresholdChange:
|
||
"""pHash 阈值从 10 收紧到 8 的行为验证。"""
|
||
|
||
def test_distance_9_not_matching(self):
|
||
"""距离=9:旧阈值10会判匹配,新阈值8不匹配。"""
|
||
assert VideoDeduplicator.PHASH_THRESHOLD == 8
|
||
# 距离 9 > 8,不应匹配(strict <)
|
||
assert 9 >= VideoDeduplicator.PHASH_THRESHOLD
|
||
|
||
def test_distance_8_is_boundary(self):
|
||
"""距离=8 等于阈值,不匹配(严格小于)。"""
|
||
assert 8 >= VideoDeduplicator.PHASH_THRESHOLD # 不满足 d < THRESHOLD
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 3. 中位数 vs 均值
|
||
# ══════════════════════════════════════════════
|
||
class TestMedianVsMean:
|
||
"""中位数抵抗异常值。"""
|
||
|
||
def test_median_resists_outlier(self):
|
||
"""距离 [3, 3, 3, 3, 30]:均值=8.4,中位数=3。"""
|
||
import statistics
|
||
|
||
distances = [3, 3, 3, 3, 30]
|
||
mean_val = sum(distances) / len(distances)
|
||
median_val = statistics.median(distances)
|
||
assert mean_val == pytest.approx(8.4)
|
||
assert median_val == 3
|
||
|
||
def test_median_used_in_fusion(self):
|
||
"""验证 _check_fusion_duplicate 使用中位数。"""
|
||
d = VideoDeduplicator()
|
||
# 10帧: 9帧距离=2, 1帧距离=50
|
||
phashes_new = [f"{i:016x}" for i in range(10)]
|
||
# existing: 与 phashes_new 前9个完全相同,第10个完全不同
|
||
phashes_existing = phashes_new[:9] + ["ffffffffffffffff"]
|
||
|
||
fp = _make_fingerprint(
|
||
phashes_new,
|
||
[[1.0, 0.0, 0.5] * 32 for _ in range(10)],
|
||
)
|
||
existing_hist = [[1.0, 0.0, 0.5] * 32 for _ in range(10)]
|
||
|
||
result = d._check_fusion_duplicate(fp, {}, phashes_existing, existing_hist)
|
||
# 中位数距离应为 0(9帧距离0,1帧距离>0,中位数=0)
|
||
if result:
|
||
assert result["_debug"]["median_distance"] == 0
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 4. 帧匹配比例测试
|
||
# ══════════════════════════════════════════════
|
||
class TestMatchRatio:
|
||
"""帧匹配比例 ≥ 70% 才通过。"""
|
||
|
||
def test_insufficient_match_ratio(self):
|
||
"""10帧中只有5帧距离<8 → match_ratio=0.5 < 0.7 → 不判重复。"""
|
||
d = VideoDeduplicator()
|
||
# 构造: 5帧完全匹配(距离0), 5帧完全不同(距离64)
|
||
new_phashes = ["0" * 16] * 5 + ["a" * 16] * 5
|
||
existing_phashes = ["0" * 16] # 只有一帧
|
||
|
||
fp = _make_fingerprint(new_phashes)
|
||
result = d._check_fusion_duplicate(fp, {}, existing_phashes, [[0.5] * 96])
|
||
# 5/10 = 0.5 < 0.7,应返回 None
|
||
assert result is None
|
||
|
||
def test_sufficient_match_ratio(self):
|
||
"""10帧中8帧距离<8 → match_ratio=0.8 ≥ 0.7 → 通过。"""
|
||
d = VideoDeduplicator()
|
||
new_phashes = ["0" * 16] * 8 + ["a" * 16] * 2
|
||
existing_phashes = ["0" * 16]
|
||
|
||
fp = _make_fingerprint(
|
||
new_phashes,
|
||
[[1.0, 0.0, 0.5] * 32 for _ in range(10)],
|
||
)
|
||
existing_hist = [[1.0, 0.0, 0.5] * 32]
|
||
|
||
result = d._check_fusion_duplicate(fp, {}, existing_phashes, existing_hist)
|
||
# 8/10 = 0.8 ≥ 0.7,应该通过(取决于combined_score)
|
||
if result is not None:
|
||
assert result["_debug"]["match_ratio"] == 0.8
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 5. Bhattacharyya 系数测试
|
||
# ══════════════════════════════════════════════
|
||
class TestBhattacharyyaCoefficient:
|
||
"""Bhattacharyya 系数计算验证。"""
|
||
|
||
def test_identical_histograms(self):
|
||
"""相同直方图 → 1.0。"""
|
||
h = [0.5, 0.3, 0.2]
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient(h, h)
|
||
# Σ √(a[i]*a[i]) = Σ a[i] = 1.0 (如果已归一化)
|
||
assert bc == pytest.approx(sum(h))
|
||
|
||
def test_all_zeros(self):
|
||
"""全零直方图 → 0.0。"""
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([0.0, 0.0], [0.0, 0.0])
|
||
assert bc == 0.0
|
||
|
||
def test_known_value(self):
|
||
"""已知值验证: [1,0] vs [0,1] → 0。"""
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0], [0.0, 1.0])
|
||
assert bc == pytest.approx(0.0)
|
||
|
||
def test_known_value_partial(self):
|
||
"""已知值: [0.5, 0.5] vs [0.5, 0.5] → √0.25 + √0.25 = 1.0。"""
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([0.5, 0.5], [0.5, 0.5])
|
||
assert bc == pytest.approx(1.0)
|
||
|
||
def test_different_lengths(self):
|
||
"""不同长度取最小长度对齐。"""
|
||
bc = VideoDeduplicator._bhattacharyya_coefficient([1.0, 0.0, 0.0], [1.0, 0.0])
|
||
# 对齐到2个元素: √1 + √0 = 1.0
|
||
assert bc == pytest.approx(1.0)
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 6. 直方图相似度(_compute_histogram_similarity)
|
||
# ══════════════════════════════════════════════
|
||
class TestComputeHistogramSimilarity:
|
||
"""_compute_histogram_similarity 测试。"""
|
||
|
||
def test_empty_inputs(self):
|
||
"""空输入 → 0.0。"""
|
||
assert VideoDeduplicator._compute_histogram_similarity([], [[0.5]]) == 0.0
|
||
assert VideoDeduplicator._compute_histogram_similarity([[0.5]], []) == 0.0
|
||
|
||
def test_identical_histograms(self):
|
||
"""相同直方图组。"""
|
||
h = [[0.5, 0.3, 0.2]]
|
||
sim = VideoDeduplicator._compute_histogram_similarity(h, h)
|
||
assert sim == pytest.approx(sum(h[0]))
|
||
|
||
def test_best_match_selection(self):
|
||
"""多帧时取最佳匹配。"""
|
||
ha = [[1.0, 0.0], [0.0, 1.0]]
|
||
hb = [[0.0, 1.0]]
|
||
# 第一帧 [1,0] vs [0,1] → bc=0
|
||
# 第二帧 [0,1] vs [0,1] → bc=1.0
|
||
# 平均 = (0 + 1.0) / 2 = 0.5
|
||
sim = VideoDeduplicator._compute_histogram_similarity(ha, hb)
|
||
assert sim == pytest.approx(0.5)
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 7. 融合逻辑测试
|
||
# ══════════════════════════════════════════════
|
||
class TestFusionLogic:
|
||
"""_check_fusion_duplicate 融合判定测试。"""
|
||
|
||
def test_high_phash_high_hist_is_duplicate(self):
|
||
"""pHash高相似 + 直方图高相似 → 判重复。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10
|
||
hist = [[0.5] * 96] * 10
|
||
|
||
fp = _make_fingerprint(phashes, hist)
|
||
result = d._check_fusion_duplicate(fp, {}, phashes, hist)
|
||
# 完全相同 → combined_score = 0.7*1.0 + 0.3*~1.0 = ~1.0 > 0.70
|
||
assert result is not None
|
||
assert result["similarity"] > 0.70
|
||
|
||
def test_high_phash_low_hist_depends_on_score(self):
|
||
"""pHash高相似 + 直方图低相似 → 看 combined_score。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10
|
||
fp_hist = [[1.0, 0.0] * 48] # 与 existing 完全不同
|
||
existing_hist = [[0.0, 1.0] * 48]
|
||
|
||
fp = _make_fingerprint(phashes, fp_hist)
|
||
result = d._check_fusion_duplicate(fp, {}, phashes, existing_hist)
|
||
# phash_similarity=1.0, hist_similarity≈0
|
||
# combined = 0.7*1.0 + 0.3*0 = 0.7 ≥ 0.70 → 刚好通过
|
||
if result:
|
||
assert result["_debug"]["phash_similarity"] == 1.0
|
||
assert result["_debug"]["hist_similarity"] == pytest.approx(0.0, abs=0.01)
|
||
|
||
def test_no_existing_phashes_returns_none(self):
|
||
"""无已有 pHash → 返回 None。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16])
|
||
result = d._check_fusion_duplicate(fp, {}, [], [])
|
||
assert result is None
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 8. 向后兼容测试
|
||
# ══════════════════════════════════════════════
|
||
class TestBackwardCompatibility:
|
||
"""无直方图数据时不崩溃。"""
|
||
|
||
def test_no_histogram_fallback(self):
|
||
"""已有视频无分片直方图 → hist_similarity 回退到 0.0,不崩溃。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10
|
||
fp = _make_fingerprint(phashes, [[0.5] * 96] * 10)
|
||
# existing_histograms 为空列表
|
||
result = d._check_fusion_duplicate(fp, {}, phashes, [])
|
||
# 应该不崩溃,hist_similarity=0.0(无数据时统一为0)
|
||
if result:
|
||
assert result["_debug"]["hist_similarity"] == 0.0
|
||
|
||
def test_no_histogram_combined_score(self):
|
||
"""无直方图时的 combined_score = 0.7 * phash + 0.3 * 0.0。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10 # 完全相同
|
||
fp = _make_fingerprint(phashes, [[0.5] * 96] * 10)
|
||
result = d._check_fusion_duplicate(fp, {}, phashes, [])
|
||
if result:
|
||
expected = 0.7 * 1.0 + 0.3 * 0.0 # = 0.7
|
||
assert result["similarity"] == pytest.approx(expected)
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 8b. _compute_fusion_score 纯计算测试(无阈值过滤)
|
||
# ══════════════════════════════════════════════
|
||
class TestComputeFusionScore:
|
||
"""_compute_fusion_score 返回原始得分,不经过阈值过滤。"""
|
||
|
||
# 归一化直方图(sum=1.0),Bhattacharyya 系数范围 [0, 1]
|
||
_NORM_HIST = [1.0 / 96] * 96
|
||
|
||
def test_returns_score_below_threshold(self):
|
||
"""相似度低于 DUPLICATE_THRESHOLD 时仍返回得分(不被过滤)。"""
|
||
d = VideoDeduplicator()
|
||
# 10 帧新指纹,只有 3 帧能在已有指纹中找到匹配(距离<8)
|
||
# → match_ratio = 0.3 < MATCH_RATIO_THRESHOLD(0.7)
|
||
new_phashes = ["0" * 16] * 10
|
||
existing_phashes = ["0" * 16] * 3 + ["f" * 16] * 7 # 7 帧完全不同
|
||
hist = [self._NORM_HIST] * 10
|
||
fp = _make_fingerprint(new_phashes, hist)
|
||
|
||
# _compute_min_distances: 每帧找最近的已有帧
|
||
# "0"*16 到 "0"*16 → 0, 到 "f"*16 → 64, min=0 ✓ (< 8)
|
||
# 所以 10 帧新指纹的 min_distance 全是 0(因为已有里总有 "0"*16)
|
||
# match_ratio = 10/10 = 1.0 —— 这不行
|
||
# 需要让新指纹中有些帧在已有里找不到近的
|
||
# 改为:新指纹 7 帧 "a"*16 + 3 帧 "0"*16,已有 10 帧 "0"*16
|
||
new_phashes = ["a" * 16] * 7 + ["0" * 16] * 3
|
||
existing_phashes = ["0" * 16] * 10
|
||
fp = _make_fingerprint(new_phashes, hist)
|
||
|
||
score = d._compute_fusion_score(fp, existing_phashes, hist)
|
||
assert score is not None
|
||
assert "similarity" in score
|
||
assert "match_ratio" in score
|
||
# "a"="1010..." vs "0"="0000..." → hamming_distance = 32 (>8, 不匹配)
|
||
# 7/10 帧不匹配 → match_ratio = 0.3 < 0.7
|
||
assert score["match_ratio"] < 0.7
|
||
# 但 similarity 仍然有值(不会被过滤为 None)
|
||
assert score["similarity"] > 0
|
||
|
||
def test_returns_score_for_completely_different(self):
|
||
"""完全不同也返回得分(phash=0 + hist 贡献)。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16], [self._NORM_HIST])
|
||
existing_phashes = ["f" * 16]
|
||
existing_histograms = [self._NORM_HIST]
|
||
|
||
score = d._compute_fusion_score(fp, existing_phashes, existing_histograms)
|
||
assert score is not None
|
||
# phash 完全不同 → phash_similarity=0, hist_similarity=1.0
|
||
assert score["phash_similarity"] == 0.0
|
||
assert score["hist_similarity"] == pytest.approx(1.0)
|
||
# combined = 0.7*0 + 0.3*1 = 0.3
|
||
assert score["similarity"] == pytest.approx(0.3)
|
||
|
||
def test_empty_phashes_returns_none(self):
|
||
"""空 pHash 列表 → None。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16])
|
||
assert d._compute_fusion_score(fp, [], []) is None
|
||
|
||
def test_no_fingerprint_phashes_returns_none(self):
|
||
"""新指纹无 pHash → None。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint([])
|
||
assert d._compute_fusion_score(fp, ["0" * 16], [self._NORM_HIST]) is None
|
||
|
||
def test_check_fusion_still_filters(self):
|
||
"""_check_fusion_duplicate 仍然有阈值过滤(与 _compute_fusion_score 对比验证)。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16], [self._NORM_HIST])
|
||
existing_phashes = ["f" * 16]
|
||
existing_histograms = [self._NORM_HIST]
|
||
|
||
# _compute_fusion_score 返回原始得分 0.3
|
||
score = d._compute_fusion_score(fp, existing_phashes, existing_histograms)
|
||
assert score is not None
|
||
assert score["similarity"] == pytest.approx(0.3)
|
||
|
||
# _check_fusion_duplicate 过滤后返回 None(0.3 < DUPLICATE_THRESHOLD 0.7)
|
||
result = d._check_fusion_duplicate(fp, {}, existing_phashes, existing_histograms)
|
||
assert result is None
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 9. check_duplicate 集成测试
|
||
# ══════════════════════════════════════════════
|
||
class TestCheckDuplicateIntegration:
|
||
"""check_duplicate 集成测试(mock DB)。"""
|
||
|
||
def test_md5_exact_match(self):
|
||
"""MD5 精确匹配。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16])
|
||
fp.md5 = "exact_md5"
|
||
|
||
mock_video = MagicMock()
|
||
mock_video.id = "vid_1"
|
||
mock_video.video_fingerprint = {"md5": "exact_md5", "keyframe_phashes": ["0" * 16]}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [mock_video]
|
||
|
||
mock_session = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
result = d.check_duplicate(fp, "proj_1", mock_session)
|
||
|
||
assert result is not None
|
||
assert result["reason"] == "exact_md5_match"
|
||
assert result["similarity"] == 1.0
|
||
|
||
def test_fusion_match_returns_correct_reason(self):
|
||
"""融合匹配返回 phash_histogram_fusion reason。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10
|
||
hist = [[0.5] * 96] * 10
|
||
fp = _make_fingerprint(phashes, hist)
|
||
fp.md5 = "different_md5"
|
||
|
||
mock_video = MagicMock()
|
||
mock_video.id = "vid_1"
|
||
mock_video.video_fingerprint = {
|
||
"md5": "other_md5",
|
||
"keyframe_phashes": phashes,
|
||
"color_histograms": hist,
|
||
}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [mock_video]
|
||
|
||
mock_session = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.object(d, "_get_existing_chunks", return_value=[]):
|
||
result = d.check_duplicate(fp, "proj_1", mock_session)
|
||
|
||
assert result is not None
|
||
assert result["reason"] == "phash_histogram_fusion"
|
||
|
||
def test_no_duplicate_returns_none(self):
|
||
"""完全不相似 → None。"""
|
||
d = VideoDeduplicator()
|
||
# 全部帧距离都很大
|
||
new_phashes = ["0" * 16] * 10
|
||
existing_phashes = ["f" * 16] * 10
|
||
fp = _make_fingerprint(new_phashes)
|
||
|
||
mock_video = MagicMock()
|
||
mock_video.id = "vid_1"
|
||
mock_video.video_fingerprint = {
|
||
"md5": "other_md5",
|
||
"keyframe_phashes": existing_phashes,
|
||
"color_histograms": [[0.5] * 96] * 10,
|
||
}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_project.return_value = [mock_video]
|
||
|
||
mock_session = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.object(d, "_get_existing_chunks", return_value=[]):
|
||
result = d.check_duplicate(fp, "proj_1", mock_session)
|
||
|
||
# 所有帧距离=64,match_ratio=0 < 0.7 → None
|
||
assert result is None
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 10. check_batch_duplicate 集成测试
|
||
# ══════════════════════════════════════════════
|
||
class TestCheckBatchDuplicateIntegration:
|
||
"""check_batch_duplicate 集成测试。"""
|
||
|
||
def test_batch_fusion_reason_has_prefix(self):
|
||
"""批次内融合匹配 reason 带 batch_ 前缀。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10
|
||
hist = [[0.5] * 96] * 10
|
||
fp = _make_fingerprint(phashes, hist)
|
||
|
||
mock_video = MagicMock()
|
||
mock_video.id = "vid_other"
|
||
mock_video.video_fingerprint = {
|
||
"md5": "other_md5",
|
||
"keyframe_phashes": phashes,
|
||
"color_histograms": hist,
|
||
}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [mock_video]
|
||
|
||
mock_session = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.object(d, "_get_existing_chunks", return_value=[]):
|
||
result = d.check_batch_duplicate(fp, "batch_1", "vid_self", mock_session)
|
||
|
||
assert result is not None
|
||
assert result["reason"] == "batch_phash_histogram_fusion"
|
||
|
||
def test_batch_skips_self(self):
|
||
"""批次内排除自身。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16])
|
||
|
||
mock_video = MagicMock()
|
||
mock_video.id = "vid_self"
|
||
mock_video.video_fingerprint = {"md5": "x", "keyframe_phashes": ["0" * 16]}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo.list_by_batch.return_value = [mock_video]
|
||
|
||
mock_session = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
result = d.check_batch_duplicate(fp, "batch_1", "vid_self", mock_session)
|
||
|
||
assert result is None
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 11. compute_duplicate_rate 融合逻辑测试
|
||
# ══════════════════════════════════════════════
|
||
class TestComputeDuplicateRate:
|
||
"""compute_duplicate_rate 融合逻辑测试。"""
|
||
|
||
def test_md5_exact_returns_100(self):
|
||
"""MD5 精确匹配 → 100.0。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16])
|
||
fp.md5 = "exact_md5"
|
||
|
||
mock_model = MagicMock()
|
||
mock_domain = MagicMock()
|
||
mock_domain.id = "vid_1"
|
||
mock_domain.video_fingerprint = {"md5": "exact_md5", "keyframe_phashes": ["0" * 16]}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo._to_domain.return_value = mock_domain
|
||
|
||
mock_session = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_query.filter.return_value = mock_query
|
||
mock_query.order_by.return_value = mock_query
|
||
mock_query.limit.return_value = mock_query
|
||
mock_query.all.return_value = [mock_model]
|
||
|
||
mock_generated_model = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.object(d, "_get_existing_chunks", return_value=[]):
|
||
# Patch the import inside the method
|
||
with patch.dict(
|
||
sys.modules,
|
||
{
|
||
"packages.adapters.sqlalchemy_impl.models": _mock_module(
|
||
GeneratedVideoModel=mock_generated_model
|
||
)
|
||
},
|
||
):
|
||
# Need to patch session.query to return mock_query
|
||
mock_session.query.return_value = mock_query
|
||
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
|
||
|
||
assert result == 100.0
|
||
|
||
def test_fusion_score_returns_percentage(self):
|
||
"""融合分数转为百分比。"""
|
||
d = VideoDeduplicator()
|
||
phashes = ["0" * 16] * 10
|
||
hist = [[0.5] * 96] * 10
|
||
fp = _make_fingerprint(phashes, hist)
|
||
fp.md5 = "different_md5"
|
||
|
||
mock_model = MagicMock()
|
||
mock_domain = MagicMock()
|
||
mock_domain.id = "vid_1"
|
||
mock_domain.video_fingerprint = {
|
||
"md5": "other_md5",
|
||
"keyframe_phashes": phashes,
|
||
"color_histograms": hist,
|
||
}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo._to_domain.return_value = mock_domain
|
||
|
||
mock_session = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_query.filter.return_value = mock_query
|
||
mock_query.order_by.return_value = mock_query
|
||
mock_query.limit.return_value = mock_query
|
||
mock_query.all.return_value = [mock_model]
|
||
mock_session.query.return_value = mock_query
|
||
|
||
mock_generated_model = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.object(d, "_get_existing_chunks", return_value=[]):
|
||
with patch.dict(
|
||
sys.modules,
|
||
{
|
||
"packages.adapters.sqlalchemy_impl.models": _mock_module(
|
||
GeneratedVideoModel=mock_generated_model
|
||
)
|
||
},
|
||
):
|
||
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
|
||
|
||
# 完全相同 → similarity≈1.0 → rate≈100
|
||
assert result > 0
|
||
|
||
def test_completely_different_phash_returns_low_score(self):
|
||
"""pHash 完全不同但直方图相同 → 返回低分但不是 0(不经过阈值过滤)。
|
||
|
||
修复前:compute_duplicate_rate 复用 _check_fusion_duplicate,低于阈值返回 0.0。
|
||
修复后:使用 _compute_fusion_score 获取原始得分,如实返回相似度。
|
||
distance=64 → phash_sim=0.0, hist_sim=1.0 → combined=0.3 → rate=30.0
|
||
"""
|
||
d = VideoDeduplicator()
|
||
norm_hist = [1.0 / 96] * 96
|
||
fp = _make_fingerprint(["0" * 16] * 10, [norm_hist] * 10)
|
||
|
||
mock_model = MagicMock()
|
||
mock_domain = MagicMock()
|
||
mock_domain.id = "vid_1"
|
||
mock_domain.video_fingerprint = {
|
||
"md5": "other_md5",
|
||
"keyframe_phashes": ["f" * 16] * 10,
|
||
"color_histograms": [norm_hist] * 10,
|
||
}
|
||
|
||
mock_repo = MagicMock()
|
||
mock_repo._to_domain.return_value = mock_domain
|
||
|
||
mock_session = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_query.filter.return_value = mock_query
|
||
mock_query.order_by.return_value = mock_query
|
||
mock_query.limit.return_value = mock_query
|
||
mock_query.all.return_value = [mock_model]
|
||
mock_session.query.return_value = mock_query
|
||
|
||
mock_generated_model = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.object(d, "_get_existing_chunks", return_value=[]):
|
||
with patch.dict(
|
||
sys.modules,
|
||
{
|
||
"packages.adapters.sqlalchemy_impl.models": _mock_module(
|
||
GeneratedVideoModel=mock_generated_model
|
||
)
|
||
},
|
||
):
|
||
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
|
||
|
||
# phash 完全不同(0.0) + 直方图完全相同(1.0) → 0.7*0 + 0.3*1 = 0.3 → 30.0
|
||
assert result == 30.0
|
||
|
||
def test_no_other_videos_returns_zero(self):
|
||
"""无其他视频 → 0.0。"""
|
||
d = VideoDeduplicator()
|
||
fp = _make_fingerprint(["0" * 16])
|
||
|
||
mock_repo = MagicMock()
|
||
mock_session = MagicMock()
|
||
mock_query = MagicMock()
|
||
mock_query.filter.return_value = mock_query
|
||
mock_query.order_by.return_value = mock_query
|
||
mock_query.limit.return_value = mock_query
|
||
mock_query.all.return_value = []
|
||
mock_session.query.return_value = mock_query
|
||
|
||
mock_generated_model = MagicMock()
|
||
with patch.object(_dedup_module, "SQLAlchemyGeneratedVideoRepository", return_value=mock_repo):
|
||
with patch.dict(
|
||
sys.modules,
|
||
{"packages.adapters.sqlalchemy_impl.models": _mock_module(GeneratedVideoModel=mock_generated_model)},
|
||
):
|
||
result = d.compute_duplicate_rate(fp, "proj_1", None, mock_session)
|
||
|
||
assert result == 0.0
|
||
|
||
|
||
# ══════════════════════════════════════════════
|
||
# 12. _average_histogram_similarity 已删除验证
|
||
# ══════════════════════════════════════════════
|
||
class TestOldMethodDeleted:
|
||
"""验证旧方法已被删除。"""
|
||
|
||
def test_average_histogram_similarity_removed(self):
|
||
"""_average_histogram_similarity 不再存在。"""
|
||
assert not hasattr(VideoDeduplicator, "_average_histogram_similarity")
|