Files
xiaoxia-saas/tests/unit/test_phash_threshold_calibration_1658.py
T
xiaoxia cf83c0df9f
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m48s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m1s
CI/CD Pipeline / Unit Tests (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 / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 26s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 23s
CI/CD Pipeline / Validate - Style (push) Successful in 2m41s
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 / Build Staging API Image (push) Successful in 31s
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 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 / Build Staging Worker Image (push) Successful in 47s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m12s
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
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (push) Successful in 5m24s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m29s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 53s
AI Code Review / AI Code Review (pull_request) Successful in 6m27s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m54s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m35s
CI/CD Pipeline / Unit Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) 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
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
fix(dedup): pHash阈值二次校准12→16 + 时序对齐允许±1反向抖动,修复降重同源对漏检 (#1702) (#1709)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 11:30:11 +08:00

281 lines
12 KiB
Python
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.
"""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_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 阈值校准(#1658 收紧到 8,#1702 两轮真实数据重校准 12→16)。
#1702 第一轮 staging 离线实验:同帧两次 2-5% 随机裁剪距离 4~10;同源成片
(密集 1s 采样)<=12 命中 4/11、异源成片最小距离 24 → 初定 12。
#1702 第二轮(证据视频 B->A 仍漏检)扩样本到该用户 15 个真实成片实测:
同源降重对中位数距离 14、<=16 命中 8/11=0.73;异源 13 个候选 <=16 命中
全 0、每帧全局最近邻最小距离 18 → 校准为 16(与异源仍有 >=2bit 裕度)。
"""
def test_phash_threshold_is_calibrated(self):
assert VideoDeduplicator.PHASH_THRESHOLD == PHASH_THRESHOLD == 16
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_matching_semantics(self):
"""阈值比较统一为 <=(帧匹配与片段匹配同一口径)。
场景:5 个关键帧距离为 [10, 14, 16, 18, 26]。
- <=16#1702 二次校准阈值):3 帧匹配 → 0.6 < 0.7,被帧比例门槛
拦截(异源安全边界:真实数据异源最近邻最小距离 18,<=16 命中 0
- 距离正好 16 的同源降重帧应算匹配(< 与 <= 口径统一)
"""
distances = [10, 14, 16, 18, 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
# 异源安全边界(实测最小距离 18)及以上绝不匹配
assert not any(d <= VideoDeduplicator.PHASH_THRESHOLD for d in (18, 24, 26, 30))
# ── 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 NULLNone)必须显式回退空列表,不得 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 errormax(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)