# -*- coding: utf-8 -*- """横版封套里正封取景框的判据。 图都是 Pillow 当场合成的,不依赖真实封面:折痕是一条画上去的竖线,位置已知, 所以「找到的那一列对不对」有确定答案可比。 """ from __future__ import annotations import functools import io import json import tempfile import unittest from unittest.mock import patch from pathlib import Path from PIL import Image from peach import images, jav_poster_crop from peach.jav_poster_crop import CENTER, FOLD, MANUAL, MANUAL_SOURCE, NONE, RATIO class CompressedSpineTests(unittest.TestCase): def test_a_face_left_of_the_cut_keeps_its_right_side(self): face = {'cx': .5, 'cy': .2, 'w': .1, 'h': .2, 'score': .9} box = jav_poster_crop.crop_record('STARS-350', 840, 563, [0.] * 840, face=face)['box'] self.assertEqual(box, {'x0': 0, 'y0': 0, 'x1': 840, 'y1': 563, 'method': NONE}) def test_blurred_padding_around_a_centered_front_is_excluded(self): profile = jav_poster_crop.ColumnProfile([.02] * 224 + [.2] * 394 + [.02] * 222, [0.] * 840) profile.seams[224] = profile.seams[618] = .95 face = {'cx': .487, 'cy': .231, 'w': .135, 'h': .242, 'score': .93} box = jav_poster_crop.crop_record('HHL-018', 840, 563, profile, face=face)['box'] self.assertEqual(box, {'x0': 225, 'y0': 0, 'x1': 618, 'y1': 563, 'method': CENTER}) def test_busy_side_pictures_do_not_form_blurred_padding(self): profile = jav_poster_crop.ColumnProfile([.2] * 840, [0.] * 840) profile.seams[224] = profile.seams[618] = .95 self.assertIsNone(jav_poster_crop.padded_center_panel(840, 563, profile)) def test_both_boundaries_are_required_for_blurred_padding(self): profile = jav_poster_crop.ColumnProfile([.02] * 224 + [.2] * 394 + [.02] * 222, [0.] * 840) profile.seams[224] = .95 self.assertIsNone(jav_poster_crop.padded_center_panel(840, 563, profile)) def test_soft_spine_edge_is_separated_from_the_lettering(self): record = json.loads((Path(__file__).parent / 'fixtures/cover-profiles/ipzz-477.json').read_text(encoding='utf-8')) profile = jav_poster_crop.ColumnProfile(record['gradient'], record['seams']) box = jav_poster_crop.front_panel_box(*record['px'], profile, code='IPZZ-477') self.assertEqual(box['method'], FOLD) self.assertGreaterEqual(box['x0'], 422) self.assertLessEqual(box['x0'], 423) def sleeve(width: int, height: int, fold: int | None) -> Image.Image: """一张封套:左边是背面,右边是正面,中间可选一条书脊折痕。""" image = Image.new("RGB", (width, height), (200, 200, 200)) if fold is None: return image for x in range(fold, min(fold + 3, width)): for y in range(height): image.putpixel((x, y), (0, 0, 0)) for x in range(min(fold + 3, width), width): for y in range(height): image.putpixel((x, y), (150, 150, 150)) return image def centred(width: int, height: int, x0: int, x1: int, right: tuple[int, int, int] = (60, 60, 60)) -> Image.Image: """16:9 拼图:左右两块剧照,正中 `x0`~`x1` 是正封。""" image = Image.new("RGB", (width, height), (90, 90, 90)) image.paste((200, 200, 200), (x0, 0, x1, height)) image.paste(right, (x1, 0, width, height)) return image def edge_left(width: int, height: int) -> Image.Image: """折痕带之外有一条强边,带内平整:这张图没有可采信的折痕。""" image = Image.new("RGB", (width, height), (200, 200, 200)) for x in range(0, int(width * 0.1)): for y in range(height): image.putpixel((x, y), (10, 10, 10)) return image def gradient_of(image: Image.Image): import cv2 import numpy array = cv2.cvtColor(numpy.array(image), cv2.COLOR_RGB2BGR) return jav_poster_crop.column_gradient(array) def opencv_available() -> bool: try: import cv2 # noqa: F401 import numpy # noqa: F401 except ImportError: return False return True @functools.lru_cache(maxsize=3) def batch_script(name='poster_crop_boxes'): """按文件路径载入封面维护脚本。""" import importlib.util root = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location( name, root / 'scripts' / (name + '.py')) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module class CropShapeTests(unittest.TestCase): """框的形状:从折痕一路到源图的右下角,满高,中间不另取子区域。""" def assert_reaches_the_corner(self, box: dict, width: int, height: int): self.assertEqual((box["x1"], box["y0"], box["y1"]), (width, 0, height), f"框不是「折痕到右缘、满高」:{box}") self.assertGreater(box["x1"], box["x0"]) self.assertGreaterEqual(box["x0"], 0) def test_the_box_runs_from_the_fold_to_the_bottom_right_corner(self): for width, height in ((800, 539), (2184, 1464), (1000, 674), (760, 600)): with self.subTest(size=(width, height)): box = jav_poster_crop.front_panel_box(width, height) self.assertEqual(box["method"], RATIO) self.assert_reaches_the_corner(box, width, height) def test_the_prior_puts_the_fold_where_the_front_panel_has_its_real_shape(self): """没有梯度可用时,正封的形状就是先验本身——按高度从右缘量回去。""" for width, height in ((800, 539), (2184, 1464)): with self.subTest(size=(width, height)): box = jav_poster_crop.front_panel_box(width, height, [0.0] * width) self.assertEqual(box["method"], RATIO) self.assertAlmostEqual((box["x1"] - box["x0"]) / height, jav_poster_crop.PANEL_ASPECT, places=2) def test_a_degenerate_size_gives_an_empty_box_and_no_method(self): self.assertEqual(jav_poster_crop.front_panel_box(0, 0), {"x0": 0, "y0": 0, "x1": 0, "y1": 0, "method": NONE}) class MethodTests(unittest.TestCase): """各档各自的触发条件。""" @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_a_visible_spine_is_found_and_the_front_starts_there(self): width, height, fold = 800, 540, 420 profile = gradient_of(sleeve(width, height, fold)) self.assertIsNotNone(profile) found = jav_poster_crop.fold_column(width, height, profile) self.assertIsNotNone(found, "画上去的折痕没被找到") # 那条三列宽的黑线整条留在框外,正封从它右边开始。 self.assertGreaterEqual(found, fold + 3, found) self.assertLessEqual(found - (fold + 3), 2, found) box = jav_poster_crop.front_panel_box(width, height, profile) self.assertEqual(box["method"], FOLD) self.assertEqual((box["x0"], box["x1"], box["y0"], box["y1"]), (found, width, 0, height)) @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_without_a_spine_the_front_falls_back_to_the_prior_shape(self): width, height = 800, 540 profile = gradient_of(edge_left(width, height)) self.assertIsNone(jav_poster_crop.fold_column(width, height, profile)) box = jav_poster_crop.front_panel_box(width, height, profile) self.assertEqual(box["method"], RATIO) self.assertEqual(box["x0"], round(width - jav_poster_crop.PANEL_ASPECT * height)) def test_a_missing_gradient_falls_back_to_the_prior_shape(self): """OpenCV 不在时取景仍然给得出框,只是退化成先验几何。""" box = jav_poster_crop.front_panel_box(800, 540, None) self.assertEqual(box["method"], RATIO) def test_a_portrait_or_square_image_is_not_cropped(self): for width, height in ((600, 900), (800, 800), (900, 800)): with self.subTest(size=(width, height)): box = jav_poster_crop.front_panel_box(width, height) self.assertEqual(box, {"x0": 0, "y0": 0, "x1": width, "y1": height, "method": NONE}) def test_a_sixteen_by_nine_still_is_not_cropped(self): """整幅都是画面的官方剧照没有「正面那一块」,按先验切会裁出半张背景。""" box = jav_poster_crop.front_panel_box(1920, 1080) self.assertEqual(box["method"], NONE) @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_a_centred_front_panel_in_a_still_is_framed_between_its_seams(self): """「剧照 | 正封 | 剧照」的 16:9 拼图:框在两条满高拼接缝之间,满高。""" width, height = 1348, 758 box = jav_poster_crop.front_panel_box( width, height, gradient_of(centred(width, height, 408, 940))) self.assertEqual(box["method"], CENTER) self.assertEqual((box["y0"], box["y1"]), (0, height)) self.assertLessEqual(abs(box["x0"] - 408), 1, box) self.assertLessEqual(abs(box["x1"] - 940), 1, box) @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_one_full_height_seam_is_enough_and_the_other_side_mirrors_it(self): """一侧剧照压暗后和正封边缘同色,那一侧没有缝;居中是版式本身,按对称补上。""" width, height = 1348, 758 box = jav_poster_crop.front_panel_box( width, height, gradient_of(centred(width, height, 408, 940, right=(200, 200, 200)))) self.assertEqual(box["method"], CENTER) self.assertEqual(box["x1"], width - box["x0"]) @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_a_strong_edge_that_stops_short_is_content_not_a_seam(self): """边位上的强边只占半截高度:那是画面里的人或物,不是拼接缝。""" width, height = 1348, 758 image = Image.new("RGB", (width, height), (200, 200, 200)) image.paste((20, 20, 20), (0, 0, 408, height // 2)) box = jav_poster_crop.front_panel_box(width, height, gradient_of(image)) self.assertEqual(box["method"], NONE) def test_a_still_without_seam_evidence_is_not_cropped(self): """只给列梯度、没有接缝覆盖率的调用方,16:9 那一档照旧不给框。""" profile = [0.0] * 1348 profile[408] = 1.0 self.assertEqual(jav_poster_crop.front_panel_box(1348, 758, profile)["method"], NONE) def test_the_window_peak_must_be_a_real_cliff(self): """窗里总有一个最大值,它不够陡就只是噪声的最高点,按它切会切进画面。""" profile = [0.0] * 800 profile[80] = 1.0 profile[425] = 0.1 self.assertIsNone(jav_poster_crop.fold_column(800, 540, profile)) profile[425] = 0.9 self.assertIsNotNone(jav_poster_crop.fold_column(800, 540, profile)) def test_a_stronger_edge_outside_the_window_never_wins(self): """窗外那道边正是误判的来路:画面里的强边、书脊的另一条边都在窗外。""" profile = [0.0] * 800 profile[330] = 1.0 # 正封会连着整条书脊 profile[470] = 1.0 # 切进正封,大标题被削掉一截 self.assertIsNone(jav_poster_crop.fold_column(800, 539, profile)) profile[418] = 0.5 # 一列宽的峭壁,斜坡下一列就落回基线 self.assertEqual(jav_poster_crop.fold_column(800, 539, profile), 419) def test_the_window_is_measured_off_the_height_not_the_width(self): """同一个正封形状在低清和高清封套上都要认得出,尽管像素位置差着上千。""" for width, height in ((800, 539), (2184, 1464)): with self.subTest(size=(width, height)): fold = round(width - 0.71 * height) profile = [0.0] * width profile[fold] = 1.0 self.assertEqual(jav_poster_crop.fold_column(width, height, profile), fold + 1) def test_the_cut_lands_past_the_slope_not_in_the_middle_of_it(self): """折痕是一道有宽度的斜坡,梯度的峰在最陡处,书脊最后几列还在它右边。""" profile = [0.3] * 800 # 画面忙的封套,处处都有梯度响应 for column, value in zip(range(417, 422), (0.35, 0.48, 0.39, 0.44, 0.24)): profile[column] = value self.assertEqual(jav_poster_crop.fold_column(800, 539, profile), 421) def test_the_walk_past_the_slope_is_bounded(self): """梯度一路不落回基线时不能一直走下去,最多走源图宽的 1%。""" profile = [0.0] * 800 profile[418] = 1.0 for column in range(419, 500): profile[column] = 0.5 # 斜坡右边一直不落回基线 self.assertEqual(jav_poster_crop.fold_column(800, 539, profile), 426) @staticmethod def banded_fold(closing_edge: int) -> list[float]: """ABF-328 的折痕照着量出来的梯度:书脊边、七列灰色阴影、另一道边才到正封。""" profile = [0.3] * 2184 profile[1139], profile[1140] = 0.73, 0.56 for column in range(1141, 1147): profile[column] = 0.2 profile[closing_edge - 1], profile[closing_edge] = 0.56, 0.59 profile[closing_edge + 1] = 0.06 return profile def test_a_shadow_band_beside_the_fold_stays_outside_the_front(self): """两道边之间梯度短暂落回基线;停在那儿,卡片左缘就留下一道灰线。""" self.assertEqual(jav_poster_crop.fold_column(2184, 1477, self.banded_fold(1148)), 1149) def test_an_edge_well_inside_the_front_is_content_not_the_band(self): """离折痕超过一道边的宽度,那是正封里的标题字或人物边缘,不是阴影带的另一侧。""" self.assertEqual(jav_poster_crop.fold_column(2184, 1477, self.banded_fold(1160)), 1141) def test_a_busy_front_beside_a_weak_fold_is_not_mistaken_for_the_band(self): """折痕本身偏弱时,正封里一片花哨的画面处处都和它相当,那些边都不够峭壁。""" profile = [0.1] * 2184 profile[100] = 1.0 # 窗外画面里最强的那道边 profile[1139] = 0.4 for column in range(1141, 1150): profile[column] = 0.3 if column % 2 else 0.32 self.assertEqual(jav_poster_crop.fold_column(2184, 1477, profile), 1140) def test_a_thick_spine_puts_both_of_its_edges_in_the_window(self): """书脊厚到两条边都落进窗里时按形状挑:左边那条更强,右边那条才是折痕。""" profile = [0.0] * 3762 profile[1939] = 0.57 # 书脊左缘,挨着封底的留白 profile[1978] = 0.46 # 书脊右缘,正封从这里开始 self.assertEqual(jav_poster_crop.fold_column(3762, 2535, profile), 1979) def test_an_edge_too_weak_to_be_a_rival_never_gets_to_vote(self): """形状更像正封不足以让一条弱边胜出:强度差太多的根本不进候选。""" profile = [0.0] * 3762 profile[1939] = 0.57 profile[1978] = 0.20 # 不到窗内最强边的七成 self.assertEqual(jav_poster_crop.fold_column(3762, 2535, profile), 1940) def test_a_profile_that_does_not_match_the_image_is_ignored(self): self.assertIsNone(jav_poster_crop.fold_column(800, 540, [1.0] * 400)) class SeamBeyondTests(unittest.TestCase): """厚书脊里的文字边压过了折痕时,切点右边那条满高缝才是折痕。 ABW-147 的形状:3762×2535,书脊 1922~1978 列,窗内最强的边是书脊文字的 1939, 书脊右缘 1978 的梯度不到它的七成、进不了候选,可它从上贯到下,文字边不满高。 """ WIDTH, HEIGHT = 3762, 2535 TEXT_EDGE, SPINE_EDGE = 1939, 1978 def profile(self, seam_at_text: float = 0.36, spine_edge: int = SPINE_EDGE, spine_strength: float = 0.66, spine_seam: float = 0.91): gradient = [0.0] * self.WIDTH seams = [0.0] * self.WIDTH gradient[self.TEXT_EDGE], seams[self.TEXT_EDGE] = 0.99, seam_at_text gradient[spine_edge], seams[spine_edge] = spine_strength, spine_seam return jav_poster_crop.ColumnProfile(gradient, seams) def test_a_full_height_seam_right_of_a_cut_that_is_not_one_becomes_the_fold(self): self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile()), self.SPINE_EDGE + 1) def test_a_cut_that_is_itself_a_full_height_seam_stays_where_it_is(self): """正封里紧挨折痕的边框、色带同样满高;折痕已经满高就没有理由再往右走。""" self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(seam_at_text=0.9)), self.TEXT_EDGE + 1) def test_a_seam_outside_the_window_is_content_not_the_fold(self): """窗外的满高缝切出来的正封形状不对,那是正封里的一条竖线。""" self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(spine_edge=2100)), self.TEXT_EDGE + 1) def test_a_seam_left_of_the_cut_is_never_looked_at(self): """书脊左缘也是满高缝,往左看会把整条书脊带回框里。""" self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(spine_edge=1900, spine_strength=0.5)), self.TEXT_EDGE + 1) def test_a_faint_seam_that_is_no_cliff_does_not_pull_the_cut(self): """淡淡的印刷分隔线能满高,但过不了峭壁那一关。""" self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(spine_strength=0.2)), self.TEXT_EDGE + 1) def test_a_bare_gradient_without_seams_behaves_as_before(self): self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, list(self.profile())), self.TEXT_EDGE + 1) @unittest.skipUnless(opencv_available(), "需要 OpenCV") def test_a_lettered_spine_is_cut_at_its_far_edge_from_the_image_itself(self): """从画面量:书脊底色上一列竖排文字,文字边和书脊右缘都在窗里,正封从右缘起。""" width, height, spine, edge = 800, 538, 400, 430 image = Image.new("RGB", (width, height), (40, 40, 40)) # 封底和书脊一样暗,左缘弱 image.paste((20, 20, 20), (spine, 0, edge, height)) image.paste((120, 120, 120), (edge, 0, width, height)) for y in range(0, height, 6): # 书脊里的白色文字:窗内最强、却不满高的边 image.paste((255, 255, 255), (spine + 12, y, spine + 15, min(y + 4, height))) box = jav_poster_crop.front_panel_box(width, height, gradient_of(image)) self.assertEqual(box["method"], FOLD) self.assertGreaterEqual(box["x0"], edge) self.assertLessEqual(box["x0"], edge + 3) class SharpFoldTests(unittest.TestCase): """书脊里竖排片名是一片宽的高地,折痕是一条窄线:按突出度折痕才是窗里最显眼的那条。 DDK-023 的形状:800×539,折痕窗 390~433,书脊片名的高地 391~408 最高 0.50, 折痕 419 列只有 0.34(不到高地七成),满高覆盖 0.68(不到 `_seam_beyond` 的 0.75)。 """ WIDTH, HEIGHT, FOLD_AT = 800, 539, 419 def profile(self, fold_seam: float = 0.68): gradient = [0.1] * self.WIDTH seams = [0.3] * self.WIDTH gradient[3] = 1.0 # 封底左缘,全图最强 for column in range(391, 409): # 片名那片高地 gradient[column] = 0.5 - abs(column - 401) * 0.01 gradient[self.FOLD_AT], gradient[self.FOLD_AT + 1] = 0.34, 0.28 seams[self.FOLD_AT] = fold_seam return jav_poster_crop.ColumnProfile(gradient, seams) def test_a_narrow_fold_beside_a_lettered_plateau_is_the_fold(self): self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile()), self.FOLD_AT + 2) def test_a_narrow_edge_that_is_not_full_height_is_front_lettering(self): """正封里紧挨折痕的大字也是窄尖峰,但只占几行,不进候选。""" self.assertLess(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(fold_seam=0.3)), self.FOLD_AT) class SharpBeyondTests(unittest.TestCase): """书脊和正封同底色时折痕只在书脊内容所在的行里存在:切点右边那根孤立尖峰才是折痕。 IPZ-602 的形状:800×536,折痕窗 393~436,书脊内一块带边框的面板与竖排文字形成 390~409 的高地(最高 0.51,满高覆盖 0.5 上下),按形状选中高地右端、切在 410; 折痕 420 列梯度 0.24、满高覆盖 0.20,左边面板内部 0.06~0.11、右边正封留白 0.02~0.04, 周围 16 列中位数 0.065,折痕是它的 3.7 倍。合成剖面左侧取 0.07、右侧 0.02(3.4 倍)。 """ WIDTH, HEIGHT, FOLD_AT = 800, 536, 420 def profile(self, left: float = 0.07, right: float = 0.02, panel_seam: float = 0.5, seams: bool = True): gradient = [0.05] * self.WIDTH coverage = [0.1] * self.WIDTH gradient[3] = 1.0 # 封底左缘,全图最强 for column in range(390, 410): # 面板边框与文字那片高地 gradient[column] = 0.4 coverage[column] = panel_seam gradient[396], gradient[404] = 0.51, 0.45 for column in range(410, self.FOLD_AT): # 面板内部到折痕 gradient[column] = left gradient[self.FOLD_AT], gradient[self.FOLD_AT + 1] = 0.24, 0.14 coverage[self.FOLD_AT] = 0.2 for column in range(self.FOLD_AT + 2, 440): # 正封左缘 gradient[column] = right return jav_poster_crop.ColumnProfile(gradient, coverage) if seams else gradient def test_an_isolated_edge_past_a_bordered_panel_is_the_fold(self): self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile()), self.FOLD_AT + 2) def test_an_edge_that_rises_into_front_content_is_the_start_of_lettering(self): """左边留白、右边忙:那是正封内容的起笔,切点已经在折痕上(PPT-018 的金色片名)。 右侧取 0.12 而不是更高:周围中位数是左右两侧合起来算的,右侧再高,尖峰就先在 孤立度上被拦下,方向判据反而没被测到。 """ self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(left=0.02, right=0.12)), 410) def test_an_edge_among_other_strokes_is_not_isolated(self): """周围中位数压不下去的尖峰是大字里的一笔,哪怕左边比右边忙(MIMK-009 的 423 列)。""" self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(left=0.12, right=0.09)), 410) def test_a_cut_that_is_already_a_full_height_seam_stays(self): """高地本身满高就是折痕,右边再孤立的尖峰也是正封内容。""" self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(panel_seam=0.8)), 410) def test_a_bare_gradient_without_seams_behaves_as_before(self): self.assertEqual(jav_poster_crop.fold_column(self.WIDTH, self.HEIGHT, self.profile(seams=False)), 410) class CodeShapeTests(unittest.TestCase): """哪些番号的封面是横版封套。判据全部走 `catalog_rules` 现有函数。""" def test_studio_and_amateur_codes_are_sleeves(self): for code in ("ABW-232", "abw-232", "278GYAN-017", "259LUXU-1475"): with self.subTest(code=code): self.assertTrue(jav_poster_crop.crops_to_portrait(code)) def test_finished_landscape_artwork_is_not_cropped(self): for code in ("HEYZO-1380", "FC2-PPV-1812235", "FC2-1812235", "040221-001", "092620-001"): with self.subTest(code=code): self.assertFalse(jav_poster_crop.crops_to_portrait(code)) def test_non_jav_and_korean_codes_are_not_cropped(self): for code in ("", None, "20211103_JENNIFERMENDEZ", "HHD800", "AR-101", "NOAH-101"): with self.subTest(code=code): self.assertFalse(jav_poster_crop.crops_to_portrait(code)) def test_a_western_release_is_stopped_by_the_shape_of_its_artwork(self): """欧美片的编号和厂牌番号同形,番号这一关拦不住它,宽高比这一关拦得住。 它们的封面是成品横版剧照,落在 16:9 一档,所以走的是 `none`。 """ self.assertTrue(jav_poster_crop.crops_to_portrait("BLACKED-2021")) self.assertEqual( jav_poster_crop.crop_record("BLACKED-2021", 1920, 1080)["box"]["method"], NONE) class WideSleeveTests(unittest.TestCase): """Blu-ray 模板的宽封套:厂牌开门,比例决定走不走,正封形状另有一套先验。""" WIDE = (750, 419) def test_only_the_listed_labels_open_the_door(self): for code in ("CWPBD-126", "cwpbd-119", "SMBD-110"): with self.subTest(code=code): self.assertTrue(jav_poster_crop.is_wide_sleeve_label(code)) for code in ("IDBD-446", "PBD-390", "ABW-232", "259LUXU-1475", "", None): with self.subTest(code=code): self.assertFalse(jav_poster_crop.is_wide_sleeve_label(code)) def test_a_wide_sleeve_of_a_listed_label_is_cut_at_its_own_prior(self): """没有梯度时按宽封套的先验从右缘量回去,不是 DVD 那一档的 0.704。""" width, height = self.WIDE box = jav_poster_crop.front_panel_box(width, height, None, "CWPBD-126") self.assertEqual(box["method"], RATIO) self.assertEqual(box["x0"], round(width - jav_poster_crop.WIDE_PANEL_ASPECT * height)) self.assertEqual((box["x1"], box["y0"], box["y1"]), (width, 0, height)) def test_the_same_image_without_the_label_is_still_a_still(self): """比例一样的图,厂牌不在名单里就照旧当 16:9 剧照:这一档全靠厂牌开门。""" width, height = self.WIDE for code in ("ABW-232", None): with self.subTest(code=code): box = jav_poster_crop.front_panel_box(width, height, None, code) self.assertEqual(box["method"], NONE) def test_a_listed_label_with_a_dvd_shaped_cover_walks_the_dvd_path(self): """厂牌只开门,不定切法:同一厂牌哪一期换成 DVD 比例的封套,仍按那一档的形状切; 比例超出宽封套区间的仍是剧照。""" record = jav_poster_crop.crop_record("CWPBD-126", 800, 540, [0.0] * 800) self.assertEqual(record["box"]["method"], RATIO) self.assertEqual(record["box"]["x0"], round(800 - jav_poster_crop.PANEL_ASPECT * 540)) self.assertEqual(jav_poster_crop.crop_record("SMBD-110", 1900, 1000)["box"]["method"], NONE) @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_a_spine_inside_the_narrow_window_is_found(self): width, height = self.WIDE fold = round(width - 0.855 * height) profile = gradient_of(sleeve(width, height, fold)) box = jav_poster_crop.front_panel_box(width, height, profile, "SMBD-172") self.assertEqual(box["method"], FOLD) # 那条三列宽的黑线整条留在框外,正封从它右边开始。 self.assertGreaterEqual(box["x0"], fold + 3, box) self.assertLessEqual(box["x0"] - (fold + 3), 2, box) @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_an_edge_outside_the_narrow_window_is_content_not_the_spine(self): """书脊上竖排的片名字边比书脊右缘强,但它切出的形状不在窗里,不按它切。""" width, height = self.WIDE text_edge = round(width - 0.90 * height) profile = gradient_of(sleeve(width, height, text_edge)) box = jav_poster_crop.front_panel_box(width, height, profile, "CWPBD-126") self.assertEqual(box["method"], RATIO) self.assertEqual(box["x0"], round(width - jav_poster_crop.WIDE_PANEL_ASPECT * height)) def test_the_record_routes_the_code_into_the_shape_decision(self): record = jav_poster_crop.crop_record("SMBD-110", *self.WIDE, [0.0] * self.WIDE[0]) self.assertEqual(record["box"]["method"], RATIO) self.assertEqual(record["px"], list(self.WIDE)) class RecordTests(unittest.TestCase): """边车的内容、读写与失效判定。""" def setUp(self): self.root = Path(tempfile.mkdtemp()).resolve() self.cover = self.root / "ABW-232.jpg" sleeve(800, 540, 420).save(self.cover) def test_a_shape_that_is_not_cropped_never_touches_the_gradient(self): """番号形态先判:不该裁的图连解码都不做。""" calls = [] def source(): calls.append(1) return [0.0] * 800 record = jav_poster_crop.crop_record("HEYZO-1380", 800, 540, source) self.assertEqual(record["box"]["method"], NONE) self.assertEqual(calls, []) def test_the_record_carries_the_version_the_size_and_the_box(self): record = jav_poster_crop.crop_record("ABW-232", 800, 540, [0.0] * 800) self.assertEqual(record["version"], jav_poster_crop.ALGORITHM_VERSION) self.assertEqual(record["px"], [800, 540]) self.assertEqual(record["box"]["method"], RATIO) def test_the_sidecar_sits_beside_the_cover_and_round_trips(self): record = jav_poster_crop.crop_record("ABW-232", 800, 540, [0.0] * 800) path = jav_poster_crop.write_sidecar(self.cover, record) self.assertEqual(path.name, "ABW-232.poster.json") self.assertEqual(jav_poster_crop.read_sidecar(self.cover), record) self.assertTrue(jav_poster_crop.is_current(record, 800, 540)) def test_an_outdated_version_or_a_replaced_cover_forces_a_recount(self): record = jav_poster_crop.crop_record("ABW-232", 800, 540, [0.0] * 800) self.assertFalse(jav_poster_crop.is_current(record, 2184, 1464), "封面被更大的那张换掉之后,旧框落在新图上是一块错位的区域") stale = dict(record, version="poster-crop-v0") self.assertFalse(jav_poster_crop.is_current(stale, 800, 540)) self.assertFalse(jav_poster_crop.is_current(None, 800, 540)) def test_a_broken_sidecar_reads_as_missing(self): jav_poster_crop.sidecar_path(self.cover).write_text("{not json", encoding="utf-8") self.assertIsNone(jav_poster_crop.read_sidecar(self.cover)) class ProjectionTests(unittest.TestCase): """API 字段的投影:只有真的算出框的那一档才给值。""" def test_a_real_box_projects_with_the_source_size(self): record = jav_poster_crop.crop_record("ABW-232", 800, 540, [0.0] * 800) projected = jav_poster_crop.projection(record) self.assertEqual(projected["px"], [800, 540]) self.assertEqual(projected["method"], RATIO) self.assertEqual((projected["x1"], projected["y0"], projected["y1"]), (800, 0, 540)) def test_everything_unusable_projects_to_none(self): for record in (None, {}, {"version": "poster-crop-v0"}, jav_poster_crop.crop_record("HEYZO-1380", 800, 540), {"version": jav_poster_crop.ALGORITHM_VERSION, "px": [800, 540], "box": {"x0": 0, "x1": 0, "y0": 0, "y1": 0, "method": RATIO}}, {"version": jav_poster_crop.ALGORITHM_VERSION, "px": [800, 540], "box": {"x0": 0, "x1": 900, "y0": 0, "y1": 540, "method": RATIO}}): with self.subTest(record=record): self.assertIsNone(jav_poster_crop.projection(record)) class ManualBoxTests(unittest.TestCase): """人在详情页自己框的那一块,与算出来的框共用同一份边车。""" def test_a_hand_drawn_box_records_its_four_edges_and_its_source(self): record = jav_poster_crop.manual_record(800, 540, {"x0": 200, "y0": 40, "x1": 600, "y1": 500}) self.assertEqual(record["box"], {"x0": 200, "y0": 40, "x1": 600, "y1": 500, "method": MANUAL}) self.assertEqual(record["px"], [800, 540]) self.assertEqual(record["source"], MANUAL_SOURCE) self.assertTrue(jav_poster_crop.is_manual(record)) self.assertEqual(jav_poster_crop.projection(record)["method"], MANUAL) def test_a_box_that_is_not_a_box_is_refused_before_it_reaches_the_disk(self): for box in (None, {}, {"x0": 0, "y0": 0, "x1": 0, "y1": 100}, {"x0": "左", "y0": 0, "x1": 100, "y1": 100}, # 夹回图里之后只剩一条线:整幅右侧之外的框没有内容可取。 {"x0": 900, "y0": 0, "x1": 1000, "y1": 100}): with self.subTest(box=box): self.assertIsNone(jav_poster_crop.manual_record(800, 540, box)) def test_edges_outside_the_image_are_pulled_back_in(self): record = jav_poster_crop.manual_record(800, 540, {"x0": -20, "y0": -5, "x1": 1200, "y1": 900}) self.assertEqual(record["box"]["x0"], 0) self.assertEqual((record["box"]["x1"], record["box"]["y1"]), (800, 540)) def test_a_hand_drawn_box_survives_an_algorithm_bump_but_not_a_new_cover(self): """手工框后面没有算法,改判据不构成重算它的理由;换了封面它就作废。""" record = jav_poster_crop.manual_record(800, 540, {"x0": 200, "y0": 0, "x1": 600, "y1": 540}) stale = dict(record, version="poster-crop-v0") self.assertTrue(jav_poster_crop.is_current(stale, 800, 540)) self.assertIsNotNone(jav_poster_crop.projection(stale)) # 算出来的框换个版本号就该重算,两档判据不能混。 computed = dict(jav_poster_crop.crop_record("ABW-232", 800, 540, [0.0] * 800), version="poster-crop-v0") self.assertFalse(jav_poster_crop.is_current(computed, 800, 540)) self.assertIsNone(jav_poster_crop.projection(computed)) # 封面被更大的那张换掉:框描述的是另一张图,手工框也一样作废。 self.assertFalse(jav_poster_crop.is_current(record, 1600, 1080)) class CropBytesTests(unittest.TestCase): """按框切出新字节:原图不动,格式只在 PNG 与 JPEG 之间取一个。""" @staticmethod def payload(image: Image.Image, fmt: str) -> bytes: buffer = io.BytesIO() image.save(buffer, format=fmt) return buffer.getvalue() def test_a_jpeg_crop_comes_back_as_a_jpeg_of_the_boxed_size(self): body = self.payload(Image.new("RGB", (800, 540), (10, 120, 200)), "JPEG") cropped = images.crop_to_box(body, (200, 40, 600, 500)) self.assertEqual(images.measure_image_size(cropped), (400, 460)) with Image.open(io.BytesIO(cropped)) as opened: self.assertEqual(opened.format, "JPEG") def test_a_png_stays_a_png_and_transparency_lands_on_white(self): source = Image.new("RGBA", (100, 100), (0, 0, 0, 0)) cropped = images.crop_to_box(self.payload(source, "PNG"), (0, 0, 50, 50)) with Image.open(io.BytesIO(cropped)) as opened: self.assertEqual(opened.format, "PNG") # 同一张透明图存成 JPEG 那一路要铺白底,否则透明处落成黑块。 flattened = images.crop_to_box(self.payload(source.convert("RGBA"), "WEBP"), (0, 0, 50, 50)) with Image.open(io.BytesIO(flattened)) as opened: self.assertEqual(opened.convert("RGB").getpixel((10, 10)), (255, 255, 255)) def test_a_box_outside_the_image_or_a_junk_payload_yields_nothing(self): body = self.payload(Image.new("RGB", (100, 100)), "JPEG") self.assertIsNone(images.crop_to_box(body, (0, 0, 200, 50))) self.assertIsNone(images.crop_to_box(body, (50, 0, 50, 50))) self.assertIsNone(images.crop_to_box(b"not an image", (0, 0, 10, 10))) class BatchScriptTests(unittest.TestCase): """批处理只写边车、只处理该处理的那些图。""" def setUp(self): self.script = batch_script() self.root = Path(tempfile.mkdtemp()).resolve() self.cover = self.root / "ABW-232.jpg" sleeve(800, 540, 420).save(self.cover) self.before = self.cover.read_bytes() def run_script(self, *argv): return self.script.main([*argv, "--covers", str(self.root)]) def test_a_dry_run_writes_nothing(self): self.assertEqual(self.run_script(), 0) self.assertFalse(jav_poster_crop.sidecar_path(self.cover).exists()) def test_online_upgrade_checks_official_sources_without_local_snapshots(self): from peach.jav_cover_fetch import Candidate script = batch_script('upgrade_audited_covers') candidate = Candidate('pics.dmm.co.jp', 'https://pics.dmm.co.jp/a.jpg', '') report = self.root / 'report' with patch.object(script, 'best_cover', return_value=(candidate, (2184, 1468), self.before)) as fetch: with patch.object(script, 'install_cover') as install: result = script.upgrade(self.cover, [], object(), report, live=True, metadata_root=self.root / 'metadata') self.assertEqual(result['status'], 'installed') self.assertEqual(fetch.call_args.args[1], 'ABW-232') self.assertEqual(fetch.call_args.kwargs['metadata_root'], self.root / 'metadata') self.assertEqual(install.call_args.kwargs['evidence']['resolver'], 'official-live-upgrade-v1') self.assertEqual((report / 'originals/ABW-232.jpg').read_bytes(), self.before) def test_upgrade_resets_connections_only_for_retryable_source_failures(self): from peach.jav_cover_fetch import NotFound, Unavailable from unittest.mock import Mock script = batch_script('upgrade_audited_covers') for error, expected in ((NotFound('HTTP 404'), False), (Unavailable('HTTP 503'), True)): transport = Mock() result = script.failure_result('ABW-232', error, transport) self.assertEqual(result['retryable'], expected) self.assertEqual(transport.renew.call_count, int(expected)) def test_javdb_quarantine_preserves_bytes_and_resumes_without_duplicates(self): covers = self.root / 'generated/covers' covers.mkdir(parents=True) target = covers / 'MIDV-377.jpg' target.write_bytes(self.before) evidence = target.with_suffix('.scraping.json') evidence.write_text(json.dumps({'source': 'javdb', 'source_url': 'https://c0.jdbstatic.com/a.jpg'}), encoding='utf-8') official = covers / 'SPONE-001.jpg' official.write_bytes(self.before) report = self.root / 'review/quarantine' args = ['--data-root', str(self.root), '--report', str(report)] script = batch_script('quarantine_javdb_covers') self.assertEqual(script.main(args), 0) self.assertTrue(target.exists()) self.assertEqual(script.main([*args, '--apply']), 0) self.assertFalse(target.exists()) self.assertFalse(evidence.exists()) self.assertEqual((report / 'originals/MIDV-377.jpg').read_bytes(), self.before) self.assertEqual(official.read_bytes(), self.before) self.assertEqual(script.main([*args, '--apply']), 0) result = json.loads((report / 'manifest.json').read_text(encoding='utf-8')) self.assertEqual((result['matched'], result['quarantined']), (0, 1)) def test_quarantine_collision_does_not_partially_move_an_item(self): covers = self.root / 'generated/covers' covers.mkdir(parents=True) target = covers / 'MIDV-377.jpg' target.write_bytes(self.before) evidence = target.with_suffix('.scraping.json') evidence.write_text(json.dumps({'source': 'javdb'}), encoding='utf-8') report = self.root / 'review/quarantine' (report / 'originals').mkdir(parents=True) (report / 'originals/MIDV-377.scraping.json').write_text('{}', encoding='utf-8') with self.assertRaises(FileExistsError): batch_script('quarantine_javdb_covers').main([ '--data-root', str(self.root), '--report', str(report), '--apply']) self.assertEqual(target.read_bytes(), self.before) self.assertTrue(evidence.exists()) def test_audit_keeps_manual_frames_and_reports_unknown_provenance(self): manual = jav_poster_crop.manual_record(800, 540, {'x0': 420, 'y0': 0, 'x1': 800, 'y1': 540}) jav_poster_crop.write_sidecar(self.cover, manual) script = batch_script('audit_cover_artwork') row, proposed, _, _ = script.inspect(self.cover, self.root / 'metadata') self.assertEqual(proposed, manual) self.assertEqual(row['method'], MANUAL) self.assertTrue('missing_provenance' in row['reasons']) self.assertEqual(self.cover.read_bytes(), self.before) def test_face_crossing_the_spine_is_preserved_by_offline_reframing(self): target = self.root / 'SPONE-001.jpg' Image.new('RGB', (800, 600), 'gray').save(target) target.with_suffix('.face.json').write_text(json.dumps({ 'px': [800, 600], 'face': {'cx': .555, 'cy': .331, 'w': .257, 'h': .422, 'score': .918}, }), encoding='utf-8') row, proposed, _, layout = batch_script('audit_cover_artwork').inspect(target, self.root / 'metadata') self.assertEqual((proposed['box']['method'], layout), (NONE, 'still')) self.assertEqual(row['x0'], 0) def test_offline_reframing_preserves_a_centered_front_with_padding(self): from peach.cover_artwork import FACE_ALGORITHM_VERSION target = self.root / 'HHL-018.jpg' Image.new('RGB', (840, 563), 'gray').save(target) target.with_suffix('.face.json').write_text(json.dumps({ 'version': FACE_ALGORITHM_VERSION, 'px': [840, 563], 'face': {'cx': .487, 'cy': .231, 'w': .135, 'h': .242, 'score': .93}, }), encoding='utf-8') profile = jav_poster_crop.ColumnProfile([.02] * 224 + [.2] * 394 + [.02] * 222, [0.] * 840) profile.seams[224] = profile.seams[618] = .95 with patch.object(jav_poster_crop, 'file_gradient', return_value=profile): row, proposed, _, layout = batch_script('audit_cover_artwork').inspect(target, self.root / 'metadata') self.assertEqual((proposed['box']['method'], layout), (CENTER, '')) self.assertEqual((row['x0'], proposed['box']['x1']), (225, 618)) def test_audit_refreshes_legacy_front_only_faces_before_classifying_the_cover(self): from peach.face_detect import Face from peach.cover_artwork import FACE_ALGORITHM_VERSION from unittest.mock import Mock target = self.root / 'SPONE-001.jpg' Image.new('RGB', (800, 600), 'gray').save(target) target.with_suffix('.face.json').write_text(json.dumps({ 'px': [800, 600], 'face': {'cx': .8, 'cy': .3, 'w': .06, 'h': .1, 'score': .9}, }), encoding='utf-8') detector = Mock() detector.detect.return_value = [Face(.555, .331, .257, .422, .918), Face(.8, .3, .06, .1, .9)] row, proposed, face, layout = batch_script('audit_cover_artwork').inspect(target, self.root / 'metadata', detector=detector) self.assertEqual((proposed['box']['method'], layout), (NONE, 'still')) self.assertEqual((face['version'], face['face']['cx']), (FACE_ALGORITHM_VERSION, .555)) self.assertNotIn('stale_face', row['reasons']) def test_face_cache_requires_current_algorithm_and_image_dimensions(self): from peach.cover_artwork import FACE_ALGORITHM_VERSION script = batch_script('detect_cover_faces') target = self.cover.with_suffix('.face.json') for version, size, expected in (('old', [800, 540], False), (FACE_ALGORITHM_VERSION, [1, 1], False), (FACE_ALGORITHM_VERSION, [800, 540], True)): target.write_text(json.dumps({'version': version, 'px': size}), encoding='utf-8') self.assertEqual(script.current_face(self.cover), expected) def test_apply_writes_the_sidecar_and_leaves_the_image_untouched(self): self.assertEqual(self.run_script("--apply"), 0) record = json.loads( jav_poster_crop.sidecar_path(self.cover).read_text(encoding="utf-8")) self.assertEqual(record["px"], [800, 540]) self.assertIn(record["box"]["method"], (FOLD, RATIO)) self.assertEqual(self.cover.read_bytes(), self.before, "取景是边车元数据,原图一个字节都不动") @unittest.skipUnless(opencv_available(), "缺 vision 依赖组") def test_a_centred_still_is_counted_in_its_own_bucket(self): """16:9 居中拼图是单独一档,批处理照样计数、写边车,不因为认不得这档停下。""" still = self.root / "PASN-027.jpg" centred(1348, 758, 408, 940).save(still) self.assertEqual(self.run_script("--apply"), 0) self.assertEqual(jav_poster_crop.read_sidecar(still)["box"]["method"], CENTER) def test_a_current_sidecar_is_skipped_and_a_stale_one_is_redone(self): self.run_script("--apply") self.assertEqual(self.script.pending([self.cover], False), []) stale = jav_poster_crop.read_sidecar(self.cover) jav_poster_crop.write_sidecar(self.cover, dict(stale, version="poster-crop-v0")) self.assertEqual(self.script.pending([self.cover], False), [(self.cover, (800, 540))]) def test_an_unreadable_cover_is_counted_and_never_given_a_box(self): broken = self.root / "BAD-001.jpg" broken.write_bytes(b"not an image") self.assertEqual(self.run_script("--apply"), 0) self.assertFalse(jav_poster_crop.sidecar_path(broken).exists()) class ImageSizeTests(unittest.TestCase): def test_the_size_probe_reports_none_for_junk(self): root = Path(tempfile.mkdtemp()).resolve() broken = root / "x.jpg" broken.write_bytes(b"nope") self.assertIsNone(batch_script().image_size(broken)) good = root / "y.jpg" buffer = io.BytesIO() Image.new("RGB", (40, 30)).save(buffer, "JPEG") good.write_bytes(buffer.getvalue()) self.assertEqual(batch_script().image_size(good), (40, 30)) if __name__ == "__main__": unittest.main()