292 lines
9.4 KiB
Python
292 lines
9.4 KiB
Python
"""小番茄图片混淆 —— 纯算法测试。
|
||||
|
|
|
|||
|
|
utils/obfuscation.py 无 nonebot 依赖,用 importlib 按文件路径裸加载
|
|||
|
|
(先例 test_rate_limit.py),避免拉起 NoneBot 运行时。
|
|||
|
|
|
|||
|
|
核心断言:
|
|||
|
|
- `_gilbert_positions` 与参考实现(逐点 append 版 gilbert2d,原样抄录)逐元素
|
|||
|
|
一致 —— 这是「与网页版互解」的关键(曲线必须同一个);
|
|||
|
|
- 位置序列恒为 0..total-1 的排列;
|
|||
|
|
- encrypt / decrypt 互为逆操作(数组级 + PIL 级),偏移公式与参考一致。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import importlib.util
|
|||
|
|
import io
|
|||
|
|
import math
|
|||
|
|
import random
|
|||
|
|
import sys
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
import pytest
|
|||
|
|
from PIL import Image
|
|||
|
|
|
|||
|
|
_MODULE_PATH = (
|
|||
|
|
Path(__file__).resolve().parents[1]
|
|||
|
|
/ "hexi"
|
|||
|
|
/ "plugins"
|
|||
|
|
/ "nonebot_plugin_cherry_tomato"
|
|||
|
|
/ "utils"
|
|||
|
|
/ "obfuscation.py"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.fixture(scope="module")
|
|||
|
|
def obf():
|
|||
|
|
"""以独立模块名加载 obfuscation.py,避免触发插件包 __init__ 的依赖链。"""
|
|||
|
|
spec = importlib.util.spec_from_file_location(
|
|||
|
|
"hexi_cherry_tomato_obfuscation_test", _MODULE_PATH
|
|||
|
|
)
|
|||
|
|
assert spec is not None and spec.loader is not None
|
|||
|
|
module = importlib.util.module_from_spec(spec)
|
|||
|
|
sys.modules[spec.name] = module
|
|||
|
|
spec.loader.exec_module(module)
|
|||
|
|
yield module
|
|||
|
|
sys.modules.pop(spec.name, None)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
# 参考实现(Cherry-tomato-image-obfuscation 逐点 append 版),
|
|||
|
|
# 原样抄录,仅作对照,不执行于生产路径
|
|||
|
|
# ─────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _naive_gilbert2d(width: int, height: int) -> list[tuple[int, int]]:
|
|||
|
|
coordinates: list[tuple[int, int]] = []
|
|||
|
|
if width >= height:
|
|||
|
|
_naive_generate2d(0, 0, width, 0, 0, height, coordinates)
|
|||
|
|
else:
|
|||
|
|
_naive_generate2d(0, 0, 0, height, width, 0, coordinates)
|
|||
|
|
return coordinates
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _naive_sign(x):
|
|||
|
|
if x > 0:
|
|||
|
|
return 1
|
|||
|
|
elif x < 0:
|
|||
|
|
return -1
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _naive_generate2d(x, y, ax, ay, bx, by, coordinates):
|
|||
|
|
w = abs(ax + ay)
|
|||
|
|
h = abs(bx + by)
|
|||
|
|
|
|||
|
|
dax = _naive_sign(ax)
|
|||
|
|
day = _naive_sign(ay)
|
|||
|
|
dbx = _naive_sign(bx)
|
|||
|
|
dby = _naive_sign(by)
|
|||
|
|
|
|||
|
|
if h == 1:
|
|||
|
|
for _ in range(w):
|
|||
|
|
coordinates.append((x, y))
|
|||
|
|
x += dax
|
|||
|
|
y += day
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if w == 1:
|
|||
|
|
for _ in range(h):
|
|||
|
|
coordinates.append((x, y))
|
|||
|
|
x += dbx
|
|||
|
|
y += dby
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
ax2 = ax // 2
|
|||
|
|
ay2 = ay // 2
|
|||
|
|
bx2 = bx // 2
|
|||
|
|
by2 = by // 2
|
|||
|
|
|
|||
|
|
w2 = abs(ax2 + ay2)
|
|||
|
|
h2 = abs(bx2 + by2)
|
|||
|
|
|
|||
|
|
if 2 * w > 3 * h:
|
|||
|
|
if (w2 % 2) and (w > 2):
|
|||
|
|
ax2 += dax
|
|||
|
|
ay2 += day
|
|||
|
|
_naive_generate2d(x, y, ax2, ay2, bx, by, coordinates)
|
|||
|
|
_naive_generate2d(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by, coordinates)
|
|||
|
|
else:
|
|||
|
|
if (h2 % 2) and (h > 2):
|
|||
|
|
bx2 += dbx
|
|||
|
|
by2 += dby
|
|||
|
|
_naive_generate2d(x, y, bx2, by2, ax2, ay2, coordinates)
|
|||
|
|
_naive_generate2d(x + bx2, y + by2, ax, ay, bx - bx2, by - by2, coordinates)
|
|||
|
|
_naive_generate2d(
|
|||
|
|
x + (ax - dax) + (bx2 - dbx),
|
|||
|
|
y + (ay - day) + (by2 - dby),
|
|||
|
|
-bx2,
|
|||
|
|
-by2,
|
|||
|
|
-(ax - ax2),
|
|||
|
|
-(ay - ay2),
|
|||
|
|
coordinates,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _naive_positions(width: int, height: int) -> list[int]:
|
|||
|
|
return [x + y * width for x, y in _naive_gilbert2d(width, height)]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _build_sizes() -> list[tuple[int, int]]:
|
|||
|
|
sizes: list[tuple[int, int]] = [
|
|||
|
|
(1, 1),
|
|||
|
|
(1, 2),
|
|||
|
|
(2, 1),
|
|||
|
|
(1, 3),
|
|||
|
|
(3, 1),
|
|||
|
|
(2, 2),
|
|||
|
|
(3, 3),
|
|||
|
|
(7, 5),
|
|||
|
|
(5, 7),
|
|||
|
|
(1, 33),
|
|||
|
|
(33, 1),
|
|||
|
|
(1, 400),
|
|||
|
|
(400, 1),
|
|||
|
|
(128, 96),
|
|||
|
|
(200, 50),
|
|||
|
|
(50, 200),
|
|||
|
|
(333, 37),
|
|||
|
|
(256, 256),
|
|||
|
|
]
|
|||
|
|
primes = (2, 3, 5, 13, 31, 61, 79)
|
|||
|
|
sizes += [(p, q) for p in primes for q in primes]
|
|||
|
|
rng = random.Random(20261010)
|
|||
|
|
sizes += [(rng.randint(1, 80), rng.randint(1, 80)) for _ in range(220)]
|
|||
|
|
seen: set[tuple[int, int]] = set()
|
|||
|
|
unique: list[tuple[int, int]] = []
|
|||
|
|
for size in sizes:
|
|||
|
|
if size not in seen:
|
|||
|
|
seen.add(size)
|
|||
|
|
unique.append(size)
|
|||
|
|
return unique
|
|||
|
|
|
|||
|
|
|
|||
|
|
_SIZES = _build_sizes()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("size", _SIZES, ids=[f"{w}x{h}" for w, h in _SIZES])
|
|||
|
|
def test_positions_match_reference(obf, size):
|
|||
|
|
"""任何尺寸下位置序列都与参考逐点实现完全一致(互解的前提)。"""
|
|||
|
|
width, height = size
|
|||
|
|
pos = obf._gilbert_positions(width, height)
|
|||
|
|
assert pos.dtype == np.int32
|
|||
|
|
assert pos.tolist() == _naive_positions(width, height)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("size", _SIZES, ids=[f"{w}x{h}" for w, h in _SIZES])
|
|||
|
|
def test_positions_is_permutation(obf, size):
|
|||
|
|
"""位置序列必须是 0..total-1 的排列(不重不漏)。"""
|
|||
|
|
width, height = size
|
|||
|
|
pos = obf._gilbert_positions(width, height)
|
|||
|
|
assert sorted(pos.tolist()) == list(range(width * height))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_gilbert_offset_formula(obf):
|
|||
|
|
"""黄金比例偏移与参考/JS 公式一致。"""
|
|||
|
|
for total in (1, 2, 3, 6, 10, 1920 * 1080, 8_000_000):
|
|||
|
|
assert obf.gilbert_offset(total) == round((math.sqrt(5) - 1) / 2 * total)
|
|||
|
|
assert obf.gilbert_offset(10) == 6 # 0.618...*10 = 6.18 → 6(与 JS 一致)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("size", [(3, 2), (17, 5), (40, 33), (64, 64)])
|
|||
|
|
def test_encrypt_decrypt_array_roundtrip(obf, size):
|
|||
|
|
"""数组级 encrypt∘decrypt 恒等;encrypt 不改输入、确实重排了像素。"""
|
|||
|
|
width, height = size
|
|||
|
|
rng = np.random.default_rng(20261010)
|
|||
|
|
src = rng.integers(0, 256, size=(width * height, 3), dtype=np.uint8)
|
|||
|
|
original = src.copy()
|
|||
|
|
|
|||
|
|
enc = obf.encrypt_pixels(src, width, height)
|
|||
|
|
assert np.array_equal(src, original) # 输入未被改写
|
|||
|
|
assert not np.array_equal(enc, src) # 偏移非 0,随机图必被重排
|
|||
|
|
|
|||
|
|
dec = obf.decrypt_pixels(enc, width, height)
|
|||
|
|
assert np.array_equal(dec, src)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("size", [(3, 2), (13, 9), (40, 33)])
|
|||
|
|
def test_encrypt_decrypt_pil_roundtrip(obf, size):
|
|||
|
|
"""PIL 级 round-trip:obfuscate 输出为 RGB、无 EXIF,解混淆还原原图。"""
|
|||
|
|
width, height = size
|
|||
|
|
rng = np.random.default_rng(7)
|
|||
|
|
arr = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
|
|||
|
|
img = Image.fromarray(arr, "RGB")
|
|||
|
|
|
|||
|
|
enc, scaled, extra = obf.obfuscate(img, decrypt=False, max_pixels=10**9)
|
|||
|
|
assert enc.mode == "RGB"
|
|||
|
|
assert scaled is False
|
|||
|
|
assert extra is False
|
|||
|
|
assert not np.array_equal(np.asarray(enc), arr)
|
|||
|
|
|
|||
|
|
dec, _, _ = obf.obfuscate(enc, decrypt=True, max_pixels=10**9)
|
|||
|
|
assert np.array_equal(np.asarray(dec), arr)
|
|||
|
|
assert not dec.getexif() # 输出统一不带 EXIF
|
|||
|
|
|
|||
|
|
# JPEG 编码可解码、尺寸不变
|
|||
|
|
data = obf.to_jpeg_bytes(dec, 95)
|
|||
|
|
with Image.open(io.BytesIO(data)) as reopened:
|
|||
|
|
assert reopened.size == (width, height)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_obfuscate_applies_exif_orientation(obf):
|
|||
|
|
"""带 EXIF 旋转的照片先规范化方向:输出宽高对调(显示方向不突变)。"""
|
|||
|
|
img = Image.new("RGB", (13, 9), (10, 20, 30))
|
|||
|
|
exif = Image.Exif()
|
|||
|
|
exif[0x0112] = 6 # Orientation=6:显示时需顺时针旋转 90°(宽高对调)
|
|||
|
|
buf = io.BytesIO()
|
|||
|
|
img.save(buf, format="JPEG", exif=exif)
|
|||
|
|
buf.seek(0)
|
|||
|
|
with Image.open(buf) as photo:
|
|||
|
|
assert photo.size == (13, 9)
|
|||
|
|
assert photo.getexif().get(0x0112) == 6
|
|||
|
|
out, _, _ = obf.obfuscate(photo, decrypt=False, max_pixels=10**9)
|
|||
|
|
assert out.size == (9, 13)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_fit_within(obf):
|
|||
|
|
"""fit_within:未超限原样返回;超限等比缩小且至少 1 像素。"""
|
|||
|
|
img = Image.new("RGB", (4000, 3000))
|
|||
|
|
assert obf.fit_within(img, 12_000_000) is img
|
|||
|
|
|
|||
|
|
out = obf.fit_within(img, 2_000_000)
|
|||
|
|
assert out.width * out.height <= 2_000_000
|
|||
|
|
assert abs(out.width / out.height - 4000 / 3000) < 0.01 # 等比
|
|||
|
|
|
|||
|
|
tiny = obf.fit_within(img, 1)
|
|||
|
|
assert tiny.width >= 1 and tiny.height >= 1
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_obfuscate_scale_flag_only_for_encrypt(obf):
|
|||
|
|
"""加密超限缩放并置 scaled;解密绝不缩放(超大由 pipeline 拒绝兜底)。"""
|
|||
|
|
img = Image.new("RGB", (60, 40))
|
|||
|
|
|
|||
|
|
enc, scaled, _ = obf.obfuscate(img, decrypt=False, max_pixels=1000)
|
|||
|
|
assert scaled is True
|
|||
|
|
assert enc.width * enc.height <= 1000
|
|||
|
|
|
|||
|
|
dec, scaled2, _ = obf.obfuscate(img, decrypt=True, max_pixels=1000)
|
|||
|
|
assert scaled2 is False
|
|||
|
|
assert dec.size == img.size
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_obfuscate_extra_frames_flag(obf):
|
|||
|
|
"""多帧图只取第一帧并置 extra_frames;单帧图不置。"""
|
|||
|
|
frames = [
|
|||
|
|
Image.new("RGB", (8, 6), (255, 0, 0)),
|
|||
|
|
Image.new("RGB", (8, 6), (0, 0, 255)),
|
|||
|
|
]
|
|||
|
|
buf = io.BytesIO()
|
|||
|
|
frames[0].save(buf, format="GIF", save_all=True, append_images=frames[1:])
|
|||
|
|
buf.seek(0)
|
|||
|
|
with Image.open(buf) as gif:
|
|||
|
|
out, _, extra = obf.obfuscate(gif, decrypt=False, max_pixels=10**9)
|
|||
|
|
assert extra is True
|
|||
|
|
assert out.size == (8, 6)
|
|||
|
|
# 首帧是纯红:均匀图重排后仍是纯红(同时证明没拿第二帧的蓝色)
|
|||
|
|
assert out.getpixel((0, 0)) == (255, 0, 0)
|
|||
|
|
|
|||
|
|
_, _, extra2 = obf.obfuscate(
|
|||
|
|
Image.new("RGB", (8, 6)), decrypt=False, max_pixels=10**9
|
|||
|
|
)
|
|||
|
|
assert extra2 is False
|