feat(cherry-tomato): 新增图片混淆/解混淆插件(小番茄)
Gilbert 曲线像素重排,可与网页版互解;`混淆` / `解混淆` 命令 (回复图片或图文同条消息发送),附 tests/test_cherry_tomato.py Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
968b3d1cfe
commit
fb15b976aa
9 files changed
+862
No files matched your search
@@ -0,0 +1,10 @@
|
||||
"""小番茄图片混淆 —— 纯工具层(无 nonebot 依赖,可单测)。"""
|
||||
|
||||
from .obfuscation import ( # noqa: F401
|
||||
decrypt_pixels,
|
||||
encrypt_pixels,
|
||||
fit_within,
|
||||
gilbert_offset,
|
||||
obfuscate,
|
||||
to_jpeg_bytes,
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""小番茄图片混淆 —— 纯算法层(无 nonebot / 无配置依赖,可裸加载单测)。
|
||||
|
||||
算法与 singularpoint.cn/hideImg1.html、GitHub ok8634673/Cherry-tomato-image-obfuscation
|
||||
一致:Gilbert(广义 Hilbert)空间填充曲线 + 黄金比例偏移做像素重排,混淆/解混淆
|
||||
互为逆操作,混淆结果可与网页版互解(关键:曲线与偏移逐像素一致、尺寸不变)。
|
||||
|
||||
性能:朴素实现要逐像素 append 出 total 个坐标(12MP 约 17s)。这里改为「小块模板
|
||||
缓存」:曲线递归到 w*h <= _TILE_AREA 的轴对齐子矩形时不再下拆,直接拼接该
|
||||
「形状+朝向」的相对坐标模板(模板本身用参考递归生成 + lru_cache,共 8 种朝向),
|
||||
每个模板实例只做一条向量化赋值。1920x1080 生成位置数组约 0.03s。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import io
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
# 递归到该面积的子矩形改用缓存模板(模板数量与命中率的折中)
|
||||
_TILE_AREA = 1024
|
||||
# 模板缓存条数:形状+朝向空间有限,512 条足够任何尺寸复用
|
||||
_TILE_CACHE_SIZE = 512
|
||||
|
||||
|
||||
def _sign(x: int) -> int:
|
||||
if x > 0:
|
||||
return 1
|
||||
if x < 0:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
def _collect(x, y, ax, ay, bx, by, coords) -> None:
|
||||
"""参考实现(gilbert2d)的递归本体,逐点收集坐标(仅用于生成模板)。
|
||||
|
||||
与参考实现逐行一致;子问题恒为轴对齐矩形(a、b 各沿一轴),起点平移到
|
||||
(0, 0) 后形状不变,故可用「(w,h)+方向」作模板键。
|
||||
"""
|
||||
w = abs(ax + ay)
|
||||
h = abs(bx + by)
|
||||
|
||||
dax = _sign(ax)
|
||||
day = _sign(ay)
|
||||
dbx = _sign(bx)
|
||||
dby = _sign(by)
|
||||
|
||||
if h == 1:
|
||||
for _ in range(w):
|
||||
coords.append((x, y))
|
||||
x += dax
|
||||
y += day
|
||||
return
|
||||
|
||||
if w == 1:
|
||||
for _ in range(h):
|
||||
coords.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
|
||||
_collect(x, y, ax2, ay2, bx, by, coords)
|
||||
_collect(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by, coords)
|
||||
else:
|
||||
if (h2 % 2) and (h > 2):
|
||||
bx2 += dbx
|
||||
by2 += dby
|
||||
_collect(x, y, bx2, by2, ax2, ay2, coords)
|
||||
_collect(x + bx2, y + by2, ax, ay, bx - bx2, by - by2, coords)
|
||||
_collect(
|
||||
x + (ax - dax) + (bx2 - dbx),
|
||||
y + (ay - day) + (by2 - dby),
|
||||
-bx2,
|
||||
-by2,
|
||||
-(ax - ax2),
|
||||
-(ay - ay2),
|
||||
coords,
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=_TILE_CACHE_SIZE)
|
||||
def _tile(
|
||||
w: int, h: int, dax: int, day: int, dbx: int, dby: int
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""生成「a 长 w 朝 (dax,day)、b 长 h 朝 (dbx,dby)」子矩形的相对坐标模板。
|
||||
|
||||
返回 (lx, ly) 两个 int32 数组。起点取 (0,0),与参考递归逐点一致。
|
||||
"""
|
||||
coords: list[tuple[int, int]] = []
|
||||
_collect(0, 0, w * dax, w * day, h * dbx, h * dby, coords)
|
||||
lx = np.fromiter((c[0] for c in coords), dtype=np.int32, count=len(coords))
|
||||
ly = np.fromiter((c[1] for c in coords), dtype=np.int32, count=len(coords))
|
||||
return lx, ly
|
||||
|
||||
|
||||
def _emit(x, y, ax, ay, bx, by, pos: np.ndarray, k: int, width: int) -> int:
|
||||
"""把子矩形 (x,y,ax,ay,bx,by) 的曲线坐标写入 pos[k:],返回新的写入位置。
|
||||
|
||||
结构与参考递归完全相同,只在叶子处换用模板 + 一条向量化赋值:
|
||||
相对坐标 (lx,ly) 平移到 (x+lx, y+ly),展平下标 = (x+y*width) + lx + ly*width。
|
||||
"""
|
||||
w = abs(ax + ay)
|
||||
h = abs(bx + by)
|
||||
|
||||
if h == 1 or w == 1 or w * h <= _TILE_AREA:
|
||||
lx, ly = _tile(w, h, _sign(ax), _sign(ay), _sign(bx), _sign(by))
|
||||
n = lx.shape[0]
|
||||
pos[k : k + n] = (x + y * width) + lx + ly * width
|
||||
return k + n
|
||||
|
||||
dax = _sign(ax)
|
||||
day = _sign(ay)
|
||||
dbx = _sign(bx)
|
||||
dby = _sign(by)
|
||||
|
||||
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
|
||||
k = _emit(x, y, ax2, ay2, bx, by, pos, k, width)
|
||||
k = _emit(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by, pos, k, width)
|
||||
else:
|
||||
if (h2 % 2) and (h > 2):
|
||||
bx2 += dbx
|
||||
by2 += dby
|
||||
k = _emit(x, y, bx2, by2, ax2, ay2, pos, k, width)
|
||||
k = _emit(x + bx2, y + by2, ax, ay, bx - bx2, by - by2, pos, k, width)
|
||||
k = _emit(
|
||||
x + (ax - dax) + (bx2 - dbx),
|
||||
y + (ay - day) + (by2 - dby),
|
||||
-bx2,
|
||||
-by2,
|
||||
-(ax - ax2),
|
||||
-(ay - ay2),
|
||||
pos,
|
||||
k,
|
||||
width,
|
||||
)
|
||||
return k
|
||||
|
||||
|
||||
def _gilbert_positions(width: int, height: int) -> np.ndarray:
|
||||
"""整图 Gilbert 曲线的展平像素下标序列:pos[i] = 曲线第 i 步的 (x+y*width)。
|
||||
|
||||
是 0..total-1 的一个排列;int32 足够(total < 2^31 由调用方上限保证)。
|
||||
"""
|
||||
total = width * height
|
||||
if total >= 2**31:
|
||||
raise ValueError(f"图片像素数过大:{width}x{height}")
|
||||
pos = np.empty(total, dtype=np.int32)
|
||||
if width >= height:
|
||||
n = _emit(0, 0, width, 0, 0, height, pos, 0, width)
|
||||
else:
|
||||
n = _emit(0, 0, 0, height, width, 0, pos, 0, width)
|
||||
if n != total: # pragma: no cover - 模板/递归出错时的兜底自检
|
||||
raise RuntimeError(f"Gilbert 曲线生成异常:期望 {total} 点,实际 {n}")
|
||||
return pos
|
||||
|
||||
|
||||
def gilbert_offset(total: int) -> int:
|
||||
"""黄金比例偏移,与参考实现(JS: Math.round((√5-1)/2*W*H))一致。
|
||||
|
||||
与 JS Math.round 的差异只存在于不可能精确命中的 .5 边界,忽略。
|
||||
"""
|
||||
return round((math.sqrt(5) - 1) / 2 * total)
|
||||
|
||||
|
||||
def encrypt_pixels(src: np.ndarray, width: int, height: int) -> np.ndarray:
|
||||
"""混淆:(N,3) RGB 数组重排,dst[curve[i+offset]] = src[curve[i]]。
|
||||
|
||||
以位置数组 pos 数组化:dst[np.roll(pos, -offset)] = src[pos]。
|
||||
"""
|
||||
pos = _gilbert_positions(width, height)
|
||||
dst = np.empty_like(src)
|
||||
dst[np.roll(pos, -gilbert_offset(width * height))] = src[pos]
|
||||
return dst
|
||||
|
||||
|
||||
def decrypt_pixels(src: np.ndarray, width: int, height: int) -> np.ndarray:
|
||||
"""解混淆:encrypt 的逆操作,dst[curve[i]] = src[curve[i+offset]]。"""
|
||||
pos = _gilbert_positions(width, height)
|
||||
dst = np.empty_like(src)
|
||||
dst[pos] = src[np.roll(pos, -gilbert_offset(width * height))]
|
||||
return dst
|
||||
|
||||
|
||||
def fit_within(img: Image.Image, max_pixels: int) -> Image.Image:
|
||||
"""超过 max_pixels 时等比缩小(长宽至少 1 像素);未超限原样返回。"""
|
||||
w, h = img.size
|
||||
if w * h <= max_pixels:
|
||||
return img
|
||||
scale = math.sqrt(max_pixels / (w * h))
|
||||
new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
|
||||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def obfuscate(
|
||||
img: Image.Image, *, decrypt: bool, max_pixels: int
|
||||
) -> tuple[Image.Image, bool, bool]:
|
||||
"""整图混淆/解混淆。
|
||||
|
||||
- 先 ImageOps.exif_transpose 把手机照片的 EXIF 旋转规范化到显示方向
|
||||
(否则输出丢 EXIF 后显示方向会突变),再 convert("RGB");
|
||||
- 多帧图(GIF/动图)只取第一帧,返回 extra_frames=True 供提示;
|
||||
- 加密超过 max_pixels 等比缩小(scaled=True);解密绝不缩放(调用方限硬上限);
|
||||
- 输出是全新 RGB 图,不带 EXIF。
|
||||
|
||||
Returns: (结果图, 是否缩放, 是否丢弃了多余帧)
|
||||
"""
|
||||
extra_frames = getattr(img, "n_frames", 1) > 1
|
||||
# in_place=False 时 exif_transpose 不会返回 None,判空只为过类型检查
|
||||
transposed = ImageOps.exif_transpose(img)
|
||||
img = (transposed or img).convert("RGB")
|
||||
|
||||
scaled = False
|
||||
if not decrypt:
|
||||
before = img.width * img.height
|
||||
img = fit_within(img, max_pixels)
|
||||
scaled = img.width * img.height < before
|
||||
|
||||
width, height = img.size
|
||||
src = np.asarray(img, dtype=np.uint8).reshape(-1, 3)
|
||||
if decrypt:
|
||||
dst = decrypt_pixels(src, width, height)
|
||||
else:
|
||||
dst = encrypt_pixels(src, width, height)
|
||||
out = Image.fromarray(dst.reshape(height, width, 3), "RGB")
|
||||
return out, scaled, extra_frames
|
||||
|
||||
|
||||
def to_jpeg_bytes(img: Image.Image, quality: int) -> bytes:
|
||||
"""按给定质量编码为 JPEG(网页版输出也是 JPEG,质量默认最高档)。"""
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="JPEG", quality=quality)
|
||||
return buf.getvalue()
|
||||
Reference in new issue
Block a user