tests/test_follow_faces.py
Rama master · réplica de lectura del repositorio original.
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from PIL import Image
from peach.follow_faces import (
FACE_DISTANCE,
Face,
FollowFaceIndex,
RETRY_SECONDS,
Signature,
annotate_group,
face_clusters,
same_face,
)
from peach.follow_stream import FollowMediaResolver
from peach.http import HttpResponse
def _png(draw) -> bytes:
image = Image.new("RGB", (64, 48), (40, 40, 40))
draw(image)
buffer = io.BytesIO()
image.save(buffer, "PNG")
return buffer.getvalue()
def _left_bright(image):
image.paste((230, 230, 230), (0, 0, 32, 48))
def _stripes(image):
for x in range(0, 64, 8):
image.paste((200, 60, 60), (x, 0, x + 4, 48))
def _left_bright_recoloured(image):
# 明暗走向与 `_left_bright` 一致(dHash 相同),只把一块换了颜色:同一个姿势的另一套衣服。
image.paste((230, 230, 230), (0, 0, 32, 48))
image.paste((255, 230, 150), (8, 12, 24, 36))
def _signature(draw) -> Signature:
with Image.open(io.BytesIO(_png(draw))) as image:
return Signature.of(image)
class SameFaceTests(unittest.TestCase):
def setUp(self):
self.pose = _signature(_left_bright)
self.other_outfit = _signature(_left_bright_recoloured)
self.other_scene = _signature(_stripes)
def test_the_same_address_is_the_same_face_without_a_signature(self):
self.assertTrue(same_face(Face("a", "https://x/1.jpg", None, None),
Face("b", "https://x/1.jpg", None, None)))
self.assertFalse(same_face(Face("a", "https://x/1.jpg", None, None),
Face("b", "https://x/2.jpg", None, None)))
def test_near_identical_frames_with_matching_durations_are_one_face(self):
self.assertTrue(same_face(Face("a", "1", 30.0, self.pose), Face("b", "2", 30.8, self.pose)))
# 时长都已知却差得多,是两段不同的内容。
self.assertFalse(same_face(Face("a", "1", 30.0, self.pose), Face("b", "2", 45.0, self.pose)))
self.assertFalse(same_face(Face("a", "1", 30.0, self.pose),
Face("b", "2", 30.0, self.other_scene)))
def test_a_recoloured_alt_with_the_same_outline_is_a_different_face(self):
# dHash 只看明暗走向,单靠它会把穿衣版与 nude 版判成同一张;色块把换了颜色的那一块抓出来。
distance, color = self.pose.distances(self.other_outfit)
self.assertLessEqual(distance, FACE_DISTANCE)
self.assertGreater(color, 20)
self.assertFalse(same_face(Face("a", "1", 30.0, self.pose),
Face("a", "2", 30.0, self.other_outfit)))
self.assertFalse(same_face(Face("a", "1", None, self.pose),
Face("a", "2", None, self.other_outfit)))
def test_signatures_survive_the_cache_encoding(self):
self.assertEqual(Signature.decode(self.pose.encode()), self.pose)
self.assertIsNone(Signature.decode("zz"))
self.assertIsNone(Signature.decode(self.pose.encode()[:40]))
def test_clusters_follow_the_first_member_of_each_face(self):
faces = [Face("a", "1", None, self.pose), Face("a", "2", None, self.other_scene),
Face("b", "3", None, self.pose)]
self.assertEqual(face_clusters(faces), [0, 1, 0])
class AnnotateGroupTests(unittest.TestCase):
"""`/api/follow` 每一组上的 `stack`:翻哪几张、封面上写几个什么。"""
def setUp(self):
self.pose = _signature(_left_bright)
self.other = _signature(_stripes)
self.recoloured = _signature(_left_bright_recoloured)
@staticmethod
def member(ident, provider, thumb, duration=30.0, kind="video", media=(), variant="main"):
return {"id": ident, "provider": provider, "thumb_url": thumb, "duration": duration,
"media_kind": kind, "media_items": list(media), "variant_kind": variant}
def annotate(self, members, signatures, hashes=None):
index = mock.Mock()
index.lookup.side_effect = lambda urls, providers=None: {
url: signatures[url] for url in urls if url in signatures}
group = {"primary": members[0], "variants": members[1:], "duplicates": []}
return annotate_group(group, index, hashes)["stack"]
def test_the_same_file_on_one_site_is_one_medium(self):
# 3989 那种:同一个作者在 pawchive 的三个帖子(720p/1080p/4K)附的是同一个 gif。
members = [self.member(ident, "pawchive", "https://a/sunset.gif", None, "image",
variant="alt") for ident in (1, 2, 3)]
members.append(self.member(4, "rule34video", "https://v/4.jpg", 47.0, variant="alt"))
stack = self.annotate(members, {"https://a/sunset.gif": self.pose,
"https://v/4.jpg": self.other},
{(1, None): "sha256:aa", (2, None): "sha256:aa",
(3, None): "sha256:aa"})
self.assertEqual((stack["media"], stack["copies"], stack["kind"]), (2, 4, "mixed"))
def test_one_upload_collected_by_two_sources_counts_as_one_medium(self):
members = [self.member(ident, "rule34video", f"/follow-cover?id={ident}")
for ident in (1, 2, 3)]
for member, external_id in zip(members, ("first", "first", "second")):
member["external_id"] = external_id
stack = self.annotate(members, {})
self.assertEqual((stack["media"], stack["copies"]), (2, 3))
self.assertEqual(len(stack["faces"]), 2)
def test_a_post_identity_preserves_its_separate_media_slots(self):
members = [self.member(ident, "fanbox", None, None, "image", media=[
{"index": index, "media_kind": "video", "thumb_url": f"/follow-cover?id={ident}&media={index}"}
for index in (0, 2)]) for ident in (1, 2)]
for member in members:
member["external_id"] = "post-1"
stack = self.annotate(members, {})
self.assertEqual((stack["media"], stack["copies"]), (2, 4))
self.assertEqual(len(stack["faces"]), 2)
def test_site_identity_is_namespaced_by_provider(self):
members = [self.member(1, "rule34video", "https://a/1.jpg"),
self.member(2, "rule34xxx", "https://b/1.jpg")]
for member in members:
member["external_id"] = "123"
self.assertEqual(self.annotate(members, {})["media"], 2)
def test_archive_post_numbers_are_scoped_by_service_and_creator(self):
members = [self.member(ident, "kemono", f"https://img/{ident}.jpg") for ident in (1, 2, 3)]
for member, page in zip(members, ("https://kemono.cr/patreon/user/1/post/123",
"https://kemono.cr/fanbox/user/2/post/123",
"https://kemono.cr/patreon/user/1/post/123")):
member.update(external_id="123", url=page)
self.assertEqual(self.annotate(members, {})["media"], 2)
def test_known_different_files_preserve_media_despite_matching_post_identity(self):
members = [self.member(ident, "kemono", f"https://img/{ident}.jpg") for ident in (1, 2)]
for member in members:
member.update(external_id="123", url="https://kemono.cr/patreon/user/1/post/123")
stack = self.annotate(members, {}, {(1, None): "sha256:old", (2, None): "sha256:new"})
self.assertEqual(stack["media"], 2)
def test_archive_videos_without_thumbnails_merge_only_by_file_hash(self):
# 归档站的视频没有缩略图:哈希相同就是同一个,跨站同站都一样;不同就各算一个,
# 也不拿别的画面去猜。
post = lambda ident, provider: self.member(ident, provider, None, None) # noqa: E731
stack = self.annotate([post(1, "kemono"), post(2, "pawchive"), post(3, "pawchive"),
post(4, "pawchive")], {},
{(1, None): "sha256:aa", (2, None): "sha256:aa",
(3, None): "sha256:aa", (4, None): "sha256:bb"})
self.assertEqual((stack["media"], stack["copies"]), (2, 4))
self.assertEqual(stack["faces"], [])
def test_media_inside_posts_merge_by_file_hash(self):
def post(ident, provider):
return self.member(ident, provider, None, None, "image", media=[
{"index": 0, "media_kind": "video", "thumb_url": None},
{"index": 1, "media_kind": "image", "thumb_url": f"https://{provider}/c.png"}])
stack = self.annotate([post(1, "kemono"), post(2, "pawchive")], {},
{(1, 0): "sha256:video", (1, 1): "sha256:cover",
(2, 0): "sha256:video", (2, 1): "sha256:cover"})
self.assertEqual((stack["media"], stack["copies"]), (2, 4))
# 翻卡也认同一个文件:两个站的封面图是同一张,只翻一次。
self.assertEqual(len(stack["faces"]), 1)
def test_a_post_cover_on_another_site_flips_once(self):
# 6993 那种:帖子自身的哈希取视频,缩略图却是帖里那张图;另一个站上同一张图只有
# 帖里那一份能凭哈希认出来,也要归进同一个画面。
def post(ident, provider):
cover = f"https://{provider}/c.png"
return self.member(ident, provider, cover, None, media=[
{"index": 0, "media_kind": "video", "thumb_url": None},
{"index": 1, "media_kind": "image", "thumb_url": cover}])
hashes = {}
for ident in (1, 2):
hashes.update({(ident, None): "sha256:video", (ident, 0): "sha256:video",
(ident, 1): "sha256:cover"})
stack = self.annotate([post(1, "kemono"), post(2, "pawchive")], {}, hashes)
self.assertEqual((stack["media"], stack["copies"]), (2, 4))
self.assertEqual(len(stack["faces"]), 1)
def test_a_different_hash_on_one_site_is_never_merged_by_its_picture(self):
# 同站的差分与另一个分辨率的文件,8×8 签名与原图几乎一致(实测色块差 0.3–2.0),
# 计数不按画面合并;翻卡照样只翻一次。
stack = self.annotate([self.member(1, "kemono", "https://a/1.png", None, "image"),
self.member(2, "kemono", "https://a/2.png", None, "image")],
{"https://a/1.png": self.pose, "https://a/2.png": self.pose},
{(1, None): "sha256:aa", (2, None): "sha256:bb"})
self.assertEqual((stack["media"], stack["copies"]), (2, 2))
self.assertEqual(len(stack["faces"]), 1)
def test_the_same_file_joins_regardless_of_version_labels(self):
# 版本标签是各站按标题判的:remake 帖子(alt)附了原图,同站跨站都还是那一个文件。
same_site = [self.member(1, "pawchive", None, None, "image"),
self.member(2, "pawchive", None, None, "image", variant="alt"),
self.member(3, "pawchive", None, None, "image", variant="wip")]
hashes = {(ident, None): "sha256:aa" for ident in (1, 2, 3)}
stack = self.annotate(same_site, {}, hashes)
self.assertEqual((stack["media"], stack["copies"]), (1, 3))
cross_site = self.annotate([self.member(1, "kemono", None, None, "image"),
self.member(2, "pawchive", None, None, "image", variant="alt")],
{}, {(1, None): "sha256:aa", (2, None): "sha256:aa"})
self.assertEqual((cross_site["media"], cross_site["copies"]), (1, 2))
def test_an_alt_with_its_own_file_stays_a_separate_medium(self):
# 标签不促成合并:alt 自己的文件与 main 哈希不同,就是另一个媒体。
stack = self.annotate([self.member(1, "pawchive", None, None, "image"),
self.member(2, "pawchive", None, None, "image", variant="alt")],
{}, {(1, None): "sha256:aa", (2, None): "sha256:bb"})
self.assertEqual((stack["media"], stack["copies"]), (2, 2))
def test_one_video_on_two_sites_is_one_medium_from_two_sources(self):
stack = self.annotate([self.member(1, "rule34video", "https://a/1.jpg"),
self.member(2, "rule34xxx", "https://b/2.jpg", 30.4)],
{"https://a/1.jpg": self.pose, "https://b/2.jpg": self.pose})
self.assertEqual((stack["media"], stack["copies"], stack["kind"]), (1, 2, "video"))
self.assertEqual([face["thumb_url"] for face in stack["faces"]], ["https://a/1.jpg"])
def test_the_same_frame_on_one_site_still_counts_twice(self):
# 同一个站里画面相近的是那个站自己的两个版本(4K 与 1080p 两个帖子),各算一个;
# 翻卡照样只翻一次。
stack = self.annotate([self.member(1, "kemono", "https://a/1.jpg"),
self.member(2, "kemono", "https://a/2.jpg")],
{"https://a/1.jpg": self.pose, "https://a/2.jpg": self.pose})
self.assertEqual((stack["media"], stack["copies"]), (2, 2))
self.assertEqual(len(stack["faces"]), 1)
def test_distinct_videos_are_counted_once_each_even_when_one_is_mirrored(self):
stack = self.annotate([self.member(1, "rule34video", "https://a/1.jpg"),
self.member(2, "rule34xxx", "https://b/2.jpg"),
self.member(3, "rule34video", "https://a/3.jpg", 12.0),
self.member(4, "rule34video", "https://a/4.jpg", 30.0)],
{"https://a/1.jpg": self.pose, "https://b/2.jpg": self.pose,
"https://a/3.jpg": self.other, "https://a/4.jpg": self.recoloured})
self.assertEqual((stack["media"], stack["copies"]), (3, 4))
self.assertEqual(len(stack["faces"]), 3)
def test_a_mirror_never_absorbs_two_versions_from_the_same_site(self):
# 跨站那一份若把同站两条串成一簇,计数就把同站的两个版本吞掉了。
stack = self.annotate([self.member(1, "kemono", "https://a/1.jpg"),
self.member(2, "kemono", "https://a/2.jpg"),
self.member(3, "pawchive", "https://b/3.jpg")],
{url: self.pose for url in
("https://a/1.jpg", "https://a/2.jpg", "https://b/3.jpg")})
self.assertEqual(stack["media"], 2)
def test_post_media_are_the_merged_pieces_and_images_mix_with_videos(self):
post = [{"index": i, "media_kind": "image", "thumb_url": f"https://a/p{i}.jpg"}
for i in range(3)]
stack = self.annotate([self.member(1, "kemono", "https://a/p0.jpg", None, "image", post),
self.member(2, "kemono", "https://a/v.jpg")], {})
# 签名还没取得:只按地址认,帖子封面与首图是同一张。
self.assertEqual((stack["media"], stack["copies"], stack["kind"]), (4, 4, "mixed"))
self.assertEqual([face["media_kind"] for face in stack["faces"]],
["image", "image", "image", "video"])
def test_a_single_piece_is_not_a_stack(self):
group = {"primary": self.member(1, "kemono", "https://a/1.jpg"),
"variants": [], "duplicates": []}
self.assertIsNone(annotate_group(group)["stack"])
class FollowFaceIndexTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.now = 1_000_000.0
self.requests = []
self.bodies = {"https://img.example.com/a.png": _png(_left_bright)}
public = mock.patch("peach.follow_faces.resolves_publicly", return_value=True)
public.start()
self.addCleanup(public.stop)
def transport(self, request, timeout, limit):
self.requests.append(request.url)
body = self.bodies.get(request.url)
return HttpResponse(200 if body else 404, {}, body or b"")
def index(self, **kwargs):
return FollowFaceIndex(self.root / "faces", self.transport, clock=lambda: self.now,
sleeper=lambda seconds: None, background=False, **kwargs)
def test_unknown_thumbnails_are_queued_and_filled_later(self):
index = self.index()
url = "https://img.example.com/a.png"
self.assertEqual(index.lookup([url]), {})
self.assertEqual(index.drain(), 1)
self.assertEqual(index.lookup([url]), {url: _signature(_left_bright)})
# 落了盘:换一个实例照样查得到,不再取第二次。
self.assertEqual(self.index().lookup([url]), {url: _signature(_left_bright)})
self.assertEqual(self.requests, [url])
def test_failures_wait_a_day_before_the_next_try(self):
index = self.index()
url = "https://img.example.com/missing.png"
index.lookup([url])
index.drain()
index.lookup([url])
self.assertEqual(index.drain(), 0)
self.now += RETRY_SECONDS + 1
index.lookup([url])
self.assertEqual(index.drain(), 1)
self.assertEqual(self.requests, [url, url])
def test_a_refused_thumbnail_pauses_its_source_without_a_day_long_miss(self):
"""缩略图被 429 挡回来:整站冷却,冷却期间同站的不再取;冷却过了照常补上。
被拒不是「取不到」,记成失败就要等一天才再算这一批。
"""
refused = {"https://img.example.com/a.png"}
clock = [0.0]
gate = FollowMediaResolver(transport=None, clock=lambda: clock[0])
def transport(request, timeout, limit):
self.requests.append(request.url)
if request.url in refused:
return HttpResponse(429, {"Retry-After": "60"}, b"")
return HttpResponse(200, {}, self.bodies[request.url])
gate.transport = transport
self.bodies["https://img.example.com/b.png"] = _png(_stripes)
index = self.index(gate=gate)
urls = ["https://img.example.com/a.png", "https://img.example.com/b.png"]
providers = dict.fromkeys(urls, "kemono")
index.lookup(urls, providers)
index.drain()
self.assertEqual(self.requests, ["https://img.example.com/a.png"],
"第一张被拒后,同站第二张不再请求")
self.assertGreater(gate.cooling("kemono"), 0)
refused.clear()
clock[0] += 61
index.lookup(urls, providers)
index.drain()
self.assertEqual(index.lookup(urls), {"https://img.example.com/a.png": _signature(_left_bright),
"https://img.example.com/b.png": _signature(_stripes)})
def test_only_public_https_and_cached_covers_are_fetched(self):
index = self.index()
index.lookup(["http://img.example.com/a.png", "https://192.0.2.10/a.png",
"/follow-cover?id=7"])
self.assertEqual(index.drain(), 0)
self.assertEqual(self.requests, [])
def test_video_covers_are_read_from_the_cover_cache(self):
covers = self.root / "covers"
covers.mkdir()
(covers / "7-abc.jpg").write_bytes(_png(_stripes))
(covers / "7-abc.123.456.tmp.jpg").write_bytes(b"partial")
index = self.index(cover_root=covers)
index.lookup(["/follow-cover?id=7", "/follow-cover?id=8"])
index.drain()
self.assertEqual(index.lookup(["/follow-cover?id=7", "/follow-cover?id=8"]),
{"/follow-cover?id=7": _signature(_stripes)})
self.assertEqual(self.requests, [])
stored = json.loads((self.root / "faces" / "face-v1.json").read_text(encoding="utf-8"))
self.assertEqual(len(stored["entries"]), 2)
def test_each_video_in_a_post_reads_its_own_cover(self):
# 帖子里第二个视频的首帧是 `<id>-m<序号>-<指纹>.jpg`;第一个视频不能读到它,
# 它也不能读到第一个视频的,否则两段不同的视频签名一模一样。
covers = self.root / "covers"
covers.mkdir()
(covers / "7-abc.jpg").write_bytes(_png(_stripes))
(covers / "7-m2-def.jpg").write_bytes(_png(_left_bright))
index = self.index(cover_root=covers)
urls = ["/follow-cover?id=7", "/follow-cover?id=7&media=2", "/follow-cover?id=7&media=3"]
index.lookup(urls)
index.drain()
self.assertEqual(index.lookup(urls), {"/follow-cover?id=7": _signature(_stripes),
"/follow-cover?id=7&media=2": _signature(_left_bright)})
if __name__ == "__main__":
unittest.main()