2a2dfad137
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 39s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 48s
AI Code Review / AI Code Review (pull_request) Successful in 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m14s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m21s
CI/CD Pipeline / Integration Tests (push) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m39s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 3m0s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Validate - Security (push) Successful in 5m15s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m34s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m20s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m3s
CI/CD Pipeline / Unit Tests (push) Successful in 8m36s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m2s
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 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 26s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m34s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m47s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m58s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m22s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 5m0s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 6m55s
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
274 lines
11 KiB
Python
274 lines
11 KiB
Python
"""Issue #1658: pHash 阈值校准 + 颜色直方图融合 — 单元测试.
|
||
|
||
在 #1659(动态抽帧+滑动窗口)与 #1660(查重率)已合入 develop 的基础上,
|
||
本测试覆盖 #1658 的最小增量改动:
|
||
|
||
1. PHASH_THRESHOLD 由 10 收紧到 8(核心校准)
|
||
2. 融合权重常量 MATCH_RATIO_THRESHOLD / PHASH_WEIGHT / HISTOGRAM_WEIGHT 实际生效
|
||
(不再是硬编码魔法数字)
|
||
3. VideoDeduplicator._compute_fusion_score 统一融合得分方法:
|
||
- 无直方图数据时回退中性值 0.5
|
||
- DB NULL(None)显式回退空列表,不崩溃
|
||
- 全零直方图(全黑视频)为有效数据,参与 Bhattacharyya 计算
|
||
- 返回 0~1 原始得分,判重由调用方与 DUPLICATE_THRESHOLD 比较
|
||
4. Bhattacharyya 系数对上游异常负值有 sqrt domain 防御
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
|
||
def _mock_module(**attrs):
|
||
"""Create a mock module with __spec__ to avoid AttributeError."""
|
||
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 ──
|
||
_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(
|
||
SQLAlchemyGeneratedVideoRepository=MagicMock
|
||
)
|
||
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_mod # noqa: E402
|
||
from video_processing.dedup import ( # noqa: E402
|
||
DUPLICATE_THRESHOLD,
|
||
HISTOGRAM_WEIGHT,
|
||
MATCH_RATIO_THRESHOLD,
|
||
PHASH_WEIGHT,
|
||
VideoDeduplicator,
|
||
)
|
||
|
||
# ── Restore sys.modules immediately after import ──
|
||
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
|
||
|
||
|
||
# ── 测试夹具 ─────────────────────────────────────────────────────
|
||
|
||
_UNIFORM_HIST = [1.0 / 96] * 96 # 归一化均匀直方图,sum=1.0,自相似度≈1.0
|
||
_ZERO_HIST = [0.0] * 96 # 全黑视频的全零直方图(有效数据)
|
||
|
||
|
||
# ── TestThresholdCalibration:#1658 核心校准 ────────────────────
|
||
|
||
|
||
class TestThresholdCalibration:
|
||
"""pHash 阈值由 10 收紧到 8(Issue #1658)。"""
|
||
|
||
def test_phash_threshold_is_8(self):
|
||
"""PHASH_THRESHOLD 必须为 8(旧值 10 会放过 8~9 汉明距离的不同视频)。"""
|
||
assert VideoDeduplicator.PHASH_THRESHOLD == 8
|
||
|
||
def test_match_ratio_threshold_constant(self):
|
||
assert MATCH_RATIO_THRESHOLD == 0.7
|
||
|
||
def test_duplicate_threshold_constant(self):
|
||
assert DUPLICATE_THRESHOLD == 0.70
|
||
|
||
def test_fusion_weights(self):
|
||
assert PHASH_WEIGHT == 0.7
|
||
assert HISTOGRAM_WEIGHT == 0.3
|
||
|
||
def test_threshold_tightening_excludes_distance_8_and_9(self):
|
||
"""距离 8、9 的帧:旧阈值 10 下算匹配,新阈值 8 下不算匹配。
|
||
|
||
场景:5 个关键帧距离为 [7, 7, 7, 9, 9]。
|
||
- 旧阈值 10:5 帧全部 < 10 → match_ratio = 1.0(误放过)
|
||
- 新阈值 8:仅 3 帧 < 8 → match_ratio = 0.6 < 0.7(正确跳过)
|
||
"""
|
||
distances = [7, 7, 7, 9, 9]
|
||
|
||
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 # 被帧比例门槛拦截
|
||
|
||
|
||
# ── TestComputeFusionScore:统一融合得分方法 ────────────────────
|
||
|
||
|
||
class TestComputeFusionScore:
|
||
"""_compute_fusion_score(median_distance, histograms_a, histograms_b)。"""
|
||
|
||
def test_no_histogram_falls_back_to_neutral_05(self):
|
||
"""双方均无直方图 → hist_similarity 回退 0.5。
|
||
|
||
d=0: 0.7*1.0 + 0.3*0.5 = 0.85
|
||
"""
|
||
score = VideoDeduplicator._compute_fusion_score(0, [], [])
|
||
assert score == pytest.approx(0.85, abs=1e-6)
|
||
|
||
def test_none_histograms_treated_as_empty(self):
|
||
"""DB NULL(None)必须显式回退空列表,不得 len(None) 崩溃。"""
|
||
score_none = VideoDeduplicator._compute_fusion_score(0, [], None)
|
||
score_empty = VideoDeduplicator._compute_fusion_score(0, [], [])
|
||
assert score_none == pytest.approx(score_empty, abs=1e-9)
|
||
assert score_none == pytest.approx(0.85, abs=1e-6)
|
||
|
||
def test_none_histograms_on_query_side_no_crash(self):
|
||
"""查询侧直方图为 None 时同样不崩溃。"""
|
||
score = VideoDeduplicator._compute_fusion_score(0, None, [_UNIFORM_HIST])
|
||
# 查询侧无直方图 → 平均相似度为 0(无 ha 可匹配)→ 0.7*1.0 + 0.3*0 = 0.7
|
||
assert score == pytest.approx(0.7, abs=1e-6)
|
||
|
||
def test_identical_uniform_histograms_score_near_1(self):
|
||
"""完全相同的归一化直方图:Bhattacharyya≈1.0 → 融合分≈1.0。"""
|
||
score = VideoDeduplicator._compute_fusion_score(0, [_UNIFORM_HIST], [_UNIFORM_HIST])
|
||
assert score == pytest.approx(1.0, abs=1e-6)
|
||
|
||
def test_all_zero_histogram_is_valid_data(self):
|
||
"""全零直方图(全黑视频)是有效数据,Bhattacharyya=0,不得走 0.5 回退。
|
||
|
||
若错误地用 `if histograms_b` 之外的 `or []` 把全零列表清空,
|
||
会错误回退到 0.5,把全黑视频的相似度抬高 0.15。
|
||
d=0 时:正确行为 hist_sim=0 → 0.7*1.0 + 0.3*0 = 0.7;
|
||
若全零直方图被错误清空回退 0.5 → 0.85。
|
||
"""
|
||
score = VideoDeduplicator._compute_fusion_score(0, [_ZERO_HIST], [_ZERO_HIST])
|
||
assert score == pytest.approx(0.7, abs=1e-6)
|
||
# 与错误回退值 0.85 明确区分开
|
||
assert abs(score - 0.85) > 0.1
|
||
# 注:d=0 时 phash 满分 0.7 恰达 DUPLICATE_THRESHOLD,全黑+完全相同 phash 仍判重,符合预期
|
||
assert score >= DUPLICATE_THRESHOLD - 1e-9
|
||
|
||
def test_score_range_within_0_1(self):
|
||
for d in (0, 8, 16, 32, 64):
|
||
score = VideoDeduplicator._compute_fusion_score(d, [_UNIFORM_HIST], [_UNIFORM_HIST])
|
||
assert 0.0 <= score <= 1.0
|
||
|
||
def test_formula_matches_weights(self):
|
||
"""得分 = PHASH_WEIGHT * (1 - d/64) + HISTOGRAM_WEIGHT * hist_sim。"""
|
||
d = 6 # phash_sim = 1 - 6/64 = 0.90625
|
||
score = VideoDeduplicator._compute_fusion_score(d, [], []) # hist 回退 0.5
|
||
expected = PHASH_WEIGHT * (1 - d / 64) + HISTOGRAM_WEIGHT * 0.5
|
||
assert score == pytest.approx(expected, abs=1e-9)
|
||
# 0.7*0.90625 + 0.15 = 0.634375 + 0.15 = 0.784375
|
||
assert score == pytest.approx(0.784375, abs=1e-6)
|
||
|
||
|
||
# ── TestBhattacharyyaDefense:负值/异常输入防御 ─────────────────
|
||
|
||
|
||
class TestBhattacharyyaDefense:
|
||
"""Bhattacharyya 系数对异常输入的防御。"""
|
||
|
||
def test_negative_values_do_not_raise(self):
|
||
"""上游异常负值不得触发 sqrt domain error(max(0.0, ai*bi) 保护)。"""
|
||
bad_hist = [-0.01] * 96 # 异常负值
|
||
coeff = VideoDeduplicator._bhattacharyya_coefficient(bad_hist, _UNIFORM_HIST)
|
||
# 负值乘积被钳为 0,系数为 0 而不是抛 ValueError
|
||
assert coeff == pytest.approx(0.0, abs=1e-9)
|
||
|
||
def test_normal_histograms_coefficient_near_1(self):
|
||
coeff = VideoDeduplicator._bhattacharyya_coefficient(_UNIFORM_HIST, _UNIFORM_HIST)
|
||
assert coeff == pytest.approx(1.0, abs=1e-6)
|
||
|
||
def test_disjoint_histograms_coefficient_0(self):
|
||
"""完全不重叠的直方图(前半 vs 后半非零)系数为 0。"""
|
||
hist_a = [0.0] * 96
|
||
hist_b = [0.0] * 96
|
||
for i in range(48):
|
||
hist_a[i] = 1.0 / 48
|
||
for i in range(48, 96):
|
||
hist_b[i] = 1.0 / 48
|
||
coeff = VideoDeduplicator._bhattacharyya_coefficient(hist_a, hist_b)
|
||
assert coeff == pytest.approx(0.0, abs=1e-9)
|
||
|
||
|
||
# ── TestHistogramSimilarityEdgeCases ────────────────────────────
|
||
|
||
|
||
class TestHistogramSimilarityEdgeCases:
|
||
"""_compute_histogram_similarity 的边界行为。"""
|
||
|
||
def test_empty_either_side_returns_0(self):
|
||
assert VideoDeduplicator._compute_histogram_similarity([], [_UNIFORM_HIST]) == 0.0
|
||
assert VideoDeduplicator._compute_histogram_similarity([_UNIFORM_HIST], []) == 0.0
|
||
|
||
def test_best_match_per_histogram(self):
|
||
"""每个查询直方图取与目标集合的最佳匹配,再取平均。"""
|
||
h1 = _UNIFORM_HIST
|
||
h2 = [0.0] * 96
|
||
h2[0] = 1.0 # 与均匀直方图完全不重叠
|
||
# 查询侧两张直方图:h1 最佳匹配≈1.0,h2 最佳匹配≈sqrt(1/96)≈0.102
|
||
sim = VideoDeduplicator._compute_histogram_similarity([h1, h2], [h1])
|
||
assert sim == pytest.approx((1.0 + (1.0 / 96) ** 0.5) / 2, abs=1e-3)
|