Files
sansenhoshiandClaude Code fb15b976aa feat(cherry-tomato): 新增图片混淆/解混淆插件(小番茄)
Gilbert 曲线像素重排,可与网页版互解;`混淆` / `解混淆` 命令
(回复图片或图文同条消息发送),附 tests/test_cherry_tomato.py

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:27 +08:00

258 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""小番茄图片混淆 —— 纯算法层(无 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()