"""小番茄图片混淆 —— 纯算法层(无 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()