tests/test_face_detect.py
Rama master · réplica de lectura del repositorio original.
import hashlib
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from peach import face_detect
from peach.face_detect import (
MODEL_SHA256,
FaceModelUnavailable,
ensure_model,
)
class EnsureModelTests(unittest.TestCase):
"""模型是二进制资产,不进 Git;取一次就得能复现同一份。"""
def test_a_matching_local_model_is_reused_without_network(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp).resolve() / "yunet.onnx"
payload = b"model-bytes"
target.write_bytes(payload)
with patch.object(face_detect, "MODEL_SHA256",
hashlib.sha256(payload).hexdigest()), \
patch.object(face_detect, "read_url") as opener:
self.assertEqual(ensure_model(target), target)
opener.assert_not_called()
def test_a_half_written_model_is_replaced_not_trusted(self):
"""半个下载同样留下一个文件。
只看「文件在不在」的话,下一步 `FaceDetectorYN_create` 会抛一个和网络
毫无关系的错,排查会从模型格式开始找——而真正的原因是上一次没下完。
"""
with tempfile.TemporaryDirectory() as temp:
target = Path(temp).resolve() / "yunet.onnx"
target.write_bytes(b"half")
with self.assertRaises(FaceModelUnavailable):
ensure_model(target, allow_download=False)
self.assertFalse(target.exists(), "校验不过的文件必须删掉,不能留着下次再骗一次")
def test_a_download_whose_digest_differs_is_refused(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp).resolve() / "yunet.onnx"
with patch.object(face_detect, "read_url", return_value=b"not-the-model"):
with self.assertRaises(FaceModelUnavailable) as caught:
ensure_model(target)
self.assertIn("校验不符", str(caught.exception))
self.assertFalse(target.exists())
def test_the_pinned_digest_is_a_real_sha256(self):
# 定版是这条复用记录的全部意义:地址钉在 opencv_zoo 的一个提交上,哈希再核一遍内容。
self.assertEqual(len(MODEL_SHA256), 64)
self.assertTrue(all(ch in "0123456789abcdef" for ch in MODEL_SHA256))
self.assertIn("media.githubusercontent.com", face_detect.MODEL_URL,
"opencv_zoo 用 Git LFS,raw 域名只会回一个指针文件")
self.assertIn(face_detect.ZOO_COMMIT, face_detect.MODEL_URL)
class ModelLoadingTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.model = Path(temporary.name).resolve() / "中文模型" / "检出.onnx"
self.model.parent.mkdir()
self.payload = b"verified-yunet-model"
self.model.write_bytes(self.payload)
digest = patch.object(face_detect, "MODEL_SHA256", hashlib.sha256(self.payload).hexdigest())
digest.start()
self.addCleanup(digest.stop)
def test_unicode_model_path_is_read_as_onnx_bytes(self):
import cv2
import numpy
with patch.object(cv2, "FaceDetectorYN_create") as create:
detector = face_detect.FaceDetector(self.model, score=0.72, nms=0.12, allow_download=False)
args = create.call_args.args
self.assertEqual(args[0], "ONNX")
self.assertIsInstance(args[1], numpy.ndarray)
self.assertEqual((args[1].dtype, args[1].ndim, args[1].tobytes()),
(numpy.dtype("uint8"), 1, self.payload))
self.assertEqual((args[2].dtype, args[2].size), (numpy.dtype("uint8"), 0))
self.assertEqual(args[3:], ((320, 320), face_detect.READ_FLOOR, 0.12, 5000))
self.assertEqual(detector.model_path, self.model)
self.assertIs(detector._detector, create.return_value)
def test_model_read_error_after_verification_reaches_the_caller(self):
import cv2
failure = PermissionError(13, "model read denied")
with patch.object(Path, "read_bytes", side_effect=[self.payload, failure]), \
patch.object(cv2, "FaceDetectorYN_create") as create, \
self.assertRaises(PermissionError) as caught:
face_detect.FaceDetector(self.model, allow_download=False)
self.assertIs(caught.exception, failure)
create.assert_not_called()
def test_an_onnx_import_error_is_not_reported_as_no_faces(self):
import cv2
failure = cv2.error("invalid ONNX buffer")
with patch.object(cv2, "FaceDetectorYN_create", side_effect=failure), \
self.assertRaises(cv2.error) as caught:
face_detect.FaceDetector(self.model, allow_download=False)
self.assertIs(caught.exception, failure)
def test_mismatched_model_bytes_never_reach_opencv(self):
import cv2
self.model.write_bytes(b"partial-model")
with patch.object(cv2, "FaceDetectorYN_create") as create, self.assertRaises(FaceModelUnavailable):
face_detect.FaceDetector(self.model, allow_download=False)
create.assert_not_called()
class BundledModelTests(unittest.TestCase):
"""随包那份先认:在且哈希对得上就不联网、不往数据根写;对不上就退回原来的取法。"""
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name).resolve()
self.bundle = self.root / "program" / "tools" / "face-models"
self.bundle.mkdir(parents=True)
self.data = self.root / "data" / "yunet" / MODEL_PATH_NAME
self.payload = b"bundled-yunet"
for patcher in (
patch.dict(face_detect.os.environ, {face_detect.BUNDLE_ENV: str(self.bundle)}),
patch.object(face_detect, "MODEL_PATH", self.data),
patch.object(face_detect, "MODEL_SHA256", hashlib.sha256(self.payload).hexdigest()),
patch.object(face_detect, "_verified", {}),
patch.object(face_detect, "read_url", side_effect=AssertionError("随包有模型时不该联网"))):
patcher.start()
self.addCleanup(patcher.stop)
def test_a_matching_bundled_model_is_used_offline_without_touching_the_data_root(self):
(self.bundle / MODEL_PATH_NAME).write_bytes(self.payload)
seen = []
before = face_detect.downloads
with patch.object(face_detect, "observer", lambda *args: seen.append(args)):
self.assertEqual(ensure_model(), self.bundle / MODEL_PATH_NAME)
self.assertFalse(self.data.exists(), "随包那份不往数据根复制")
self.assertEqual((seen, face_detect.downloads), ([], before), "随包命中不是联网取到的")
def test_a_mismatched_bundled_model_is_kept_and_the_data_root_copy_is_used(self):
(self.bundle / MODEL_PATH_NAME).write_bytes(b"tampered")
self.data.parent.mkdir(parents=True)
self.data.write_bytes(self.payload)
with self.assertLogs("peach.face_detect", "WARNING"):
self.assertEqual(ensure_model(), self.data)
self.assertEqual((self.bundle / MODEL_PATH_NAME).read_bytes(), b"tampered", "程序目录那份归安装包管,不删")
def test_without_any_copy_the_download_fallback_still_runs(self):
with patch.object(face_detect, "read_url", return_value=self.payload) as read:
self.assertEqual(ensure_model(), self.data)
read.assert_called_once()
def test_a_verified_bundled_model_is_hashed_once_per_process(self):
(self.bundle / MODEL_PATH_NAME).write_bytes(self.payload)
with patch.object(face_detect, "_digest", wraps=face_detect._digest) as digest:
for _ in range(3):
face_detect.bundled_model(MODEL_PATH_NAME, face_detect.MODEL_SHA256)
self.assertEqual(digest.call_count, 1)
def test_a_mismatched_bundled_model_is_hashed_and_reported_once_per_process(self):
"""活动中心轮询状态时,一份坏掉的随包模型不能每次都重算 37 MB、刷一条警告。"""
(self.bundle / MODEL_PATH_NAME).write_bytes(b"tampered")
with patch.object(face_detect, "_digest", wraps=face_detect._digest) as digest, \
self.assertLogs("peach.face_detect", "WARNING") as logs:
for _ in range(3):
self.assertIsNone(face_detect.bundled_model(MODEL_PATH_NAME, face_detect.MODEL_SHA256))
self.assertEqual((digest.call_count, len(logs.records)), (1, 1))
def test_the_frozen_desktop_package_reads_its_program_directory(self):
program = self.root / "Peach"
with patch.dict(face_detect.os.environ, {face_detect.BUNDLE_ENV: ""}), \
patch.object(face_detect.sys, "frozen", True, create=True), \
patch.object(face_detect.sys, "executable", str(program / "Peach.exe")):
self.assertEqual(face_detect.bundled_dir(), program.resolve() / "tools" / "face-models")
with patch.dict(face_detect.os.environ, {face_detect.BUNDLE_ENV: ""}):
self.assertIsNone(face_detect.bundled_dir(), "源码运行没有随包目录")
MODEL_PATH_NAME = face_detect.MODEL_PATH.name
class DetectorContractTests(unittest.TestCase):
def test_scripts_do_not_reach_for_the_removed_cascade_api(self):
"""OpenCV 5 的 wheel 里没有 `CascadeClassifier`,也没有级联 XML。
两个脚本此前一起门都进不去,954 张封面 0 个 sidecar——「人脸取景没生效」
的成因就在这里,不在取景公式上。
"""
root = Path(__file__).resolve().parents[1]
for name in ("detect_cover_faces.py", "detect_avatar_faces.py"):
source = (root / "scripts" / name).read_text(encoding="utf-8")
self.assertNotIn("CascadeClassifier", source, name)
self.assertNotIn("haarcascades", source, name)
self.assertIn("FaceDetector()", source, name)
def test_normalised_face_is_clamped_into_the_frame(self):
detector = face_detect.FaceDetector.__new__(face_detect.FaceDetector)
face = face_detect.Face(cx=0.8, cy=0.2, width=0.25, height=0.3, score=0.9)
self.assertAlmostEqual(face.area, 0.075, places=6)
self.assertIsInstance(detector, face_detect.FaceDetector)
class _Model:
"""假的 YuNet:按送检尺寸回不同的框,用来看几个尺度的读数怎么合成一个分。
`reads` 的键是送检时的长边,值是 `(cx, cy, w, h, score)`,坐标按那一档的像素给。
"""
def __init__(self, reads):
self._reads = reads
self._size = None
def setInputSize(self, size): # noqa: N802 - 对齐 OpenCV 的拼法
self._size = size
def detect(self, _image):
import numpy
cols, rows = self._size
rows_out = []
for box in self._reads.get(max(cols, rows), []):
cx, cy, width, height, score = box
rows_out.append([cx - width / 2, cy - height / 2, width, height]
+ [0.0] * 10 + [score])
if not rows_out:
return 1, None
return 1, numpy.array(rows_out, dtype="float32")
class _Cv2:
"""只提供 `detect` 用得到的那一个函数。"""
@staticmethod
def resize(image, size):
import numpy
cols, rows = size
return numpy.zeros((rows, cols, 3), dtype=image.dtype)
def _detector_over(reads, *, score=face_detect.DEFAULT_SCORE):
import numpy
detector = face_detect.FaceDetector.__new__(face_detect.FaceDetector)
detector._cv2 = _Cv2()
detector.score = score
detector._detector = _Model(reads)
return detector, numpy.zeros((1440, 2560, 3), dtype="uint8")
class DetectAcrossScalesTests(unittest.TestCase):
"""一张脸的分是它在几个尺度上读数的中位数,不是最高的那一次。"""
def test_a_face_read_on_every_scale_keeps_its_middling_score(self):
detector, image = _detector_over({
320: [(160, 90, 40, 50, 0.90)],
640: [(320, 180, 80, 100, 0.85)],
1280: [(640, 360, 160, 200, 0.30)],
})
faces = detector.detect(image)
self.assertEqual(len(faces), 1)
self.assertEqual(faces[0].score, 0.85)
self.assertAlmostEqual(faces[0].cx, 0.5, places=2)
def test_a_box_that_only_one_scale_believes_is_dropped(self):
"""实测封面 `SRN-104`:罩住整个身体的框在 320 上 0.85,另两档只有 0.31、0.49。
取最高分它和真正的脸打平,再按面积一比就赢了;取中位数它根本进不了名单。
"""
detector, image = _detector_over({
320: [(160, 90, 150, 160, 0.85)],
640: [(320, 180, 300, 320, 0.31)],
1280: [(640, 360, 600, 640, 0.49)],
})
self.assertEqual(detector.detect(image), [])
def test_a_face_missing_from_one_scale_still_counts(self):
detector, image = _detector_over({
320: [(160, 90, 40, 50, 0.88)],
640: [(320, 180, 80, 100, 0.91)],
})
faces = detector.detect(image)
self.assertEqual(len(faces), 1)
self.assertEqual(faces[0].score, 0.88)
def test_a_small_image_is_only_sent_once(self):
"""三档都缩不下去时只检一次,那一次的读数就是分——补 0 会把小图的脸全判掉。"""
import numpy
detector = face_detect.FaceDetector.__new__(face_detect.FaceDetector)
detector._cv2 = _Cv2()
detector.score = face_detect.DEFAULT_SCORE
detector._detector = _Model({200: [(100, 60, 40, 50, 0.72)]})
faces = detector.detect(numpy.zeros((120, 200, 3), dtype="uint8"))
self.assertEqual([face.score for face in faces], [0.72])
def test_two_faces_side_by_side_stay_two_faces(self):
detector, image = _detector_over({
320: [(80, 90, 40, 50, 0.80), (240, 90, 40, 50, 0.70)],
640: [(160, 180, 80, 100, 0.82), (480, 180, 80, 100, 0.74)],
1280: [(320, 360, 160, 200, 0.81), (960, 360, 160, 200, 0.72)],
})
faces = detector.detect(image)
self.assertEqual(sorted(face.score for face in faces), [0.72, 0.81])
class MainFaceTests(unittest.TestCase):
"""挑主角那张脸:分数先卡一道,再在剩下的里取最大。"""
@staticmethod
def face(cx, cy, width, height, score):
return face_detect.Face(cx=cx, cy=cy, width=width, height=height, score=score)
def test_a_big_but_weak_box_loses_to_the_real_face(self):
"""实测 `performer-8218`:大出一倍多的那个框罩在胸口,分只有 0.798。
只按面积挑,圆头像就取景在胸口——脸在画面上四分之一处。
"""
chest = self.face(0.389, 0.482, 0.427, 0.313, 0.798)
head = self.face(0.389, 0.262, 0.202, 0.170, 0.928)
self.assertEqual(face_detect.main_face([chest, head]), head)
def test_a_small_sharp_bystander_does_not_beat_the_subject(self):
"""实测 `performer-8540`:右上角还检出一个 0.066×0.052 的小框。
分数相当时仍按面积挑,否则背景里那张小而清晰的脸会抢走取景。
"""
subject = self.face(0.608, 0.207, 0.215, 0.152, 0.930)
bystander = self.face(0.694, 0.050, 0.066, 0.052, 0.925)
self.assertEqual(face_detect.main_face([subject, bystander]), subject)
def test_no_face_is_no_face(self):
self.assertIsNone(face_detect.main_face([]))
if __name__ == "__main__":
unittest.main()