最近整理了一个挺有意思的小项目:自制的 RGB 加解密法。思路不复杂,就是拿一张图片的 RGB 像素值,去对文本做加解密。你输入一段文字,程序读图的像素,把像素展开成字节流,再和文本字节做异或;解密时,用同一张图的像素还原。标题里的“鬼脑发力”挺贴切,因为它不算正经密码算法,更像一个把文本和图片强行缝合在一起的实验玩具。
先给结论:这玩意能跑,能加解密,过程也挺好玩,但绝不能用来保护真实数据。它真正适合的场景是学习、练手、做课堂作业,或者作为一个有话题性的开源小项目。下面按我实际跑通的过程,把思路、代码、坑和开源发布建议都拆开讲。
1. 这套 RGB 加解密到底在做什么,适合谁玩
1.1 名字听起来很玄,本质是字节流变换
“RGB 加解密”这个叫法,容易让人误以为是一种新的密码学标准。实际拆开看,就是把三样东西串起来:
- 文本:要加密的内容,先编码成字节。
- 图片:每个像素都有 R、G、B 三个分量,取值都是 0 到 255。
- 字节流:把像素分量拉直,就得到一串数字,本质上就是字节。
加密时,程序把文本字节和图片像素字节做某种可逆运算,得到密文;解密时,再拿同一张图片的像素字节做逆运算,把密文变回原文。整个过程不神秘,核心就是字节和字节之间的变换。
1.2 适合人群和学习价值
这个项目适合三类人:
- Python 初学者:可以从头到尾看到文本、字节、进制、异或、图片像素这些概念怎么在实际代码里串起来。
- 图像处理入门者:想搞清楚 PIL、OpenCV 怎么读取像素,RGB 和 BGR 到底有什么区别。
- 想给开源仓库攒一个有趣项目的人:这个主题有话题性,写 README 时比较好讲清楚。
不推荐把它当安全工具用。后面我会专门解释原因。
2. 核心思路拆解:文本、字节、RGB 三者怎么串起来
2.1 从文本到字节
Python 里一行代码就能把字符串转成字节:
plain = "你好,世界" data = plain.encode("utf-8") print(data)加密之后再想还原,就调用decode("utf-8")。这里用 UTF-8 而不是 GBK,是为了让中文在不同系统之间保持一致,也避免表情符号这类字符编码失败。
2.2 从 RGB 到字节
PIL 读一张图,每个像素是 (R, G, B) 三元组:
from PIL import Image img = Image.open("key.png").convert("RGB") pixels = list(img.getdata()) print(pixels[:3])三元组展开后就是一长串 0 到 255 的整数,可以直接当成字节来用。
2.3 两种方向:RGB 当钥匙,还是 RGB 当密文
我整理思路时发现“RGB 加解密”可以往两个方向做,都有意思:
| 方向 | 思路 | 密文形态 |
|---|---|---|
| RGB 当钥匙 | 用图片像素字节生成密钥流,和文本字节做异或 | 一段 HEX 字符串 |
| RGB 当密文 | 把文本加解密后的结果按三个字节一组写进像素 | 一张长得像噪点的 PNG 图片 |
两种方向代码都不长,下面分别给出一个可运行的示例版本。
3. 本地环境准备:Python 读取图片 RGB 值的正确姿势
3.1 安装依赖
本地环境我用的是 Python 3.10,核心依赖只需要 Pillow。顺手装上 NumPy 和 OpenCV,方便对比不同读取方式。
pip install pillow numpy opencv-python如果只是跑下面的示例,执行pip install pillow就够了。
3.2 三种读取 RGB 的方式
方式一,PIL:
from PIL import Image img = Image.open("key.png").convert("RGB") pixels = list(img.getdata())方式二,NumPy:
import numpy as np arr = np.array(Image.open("key.png").convert("RGB")) print(arr.shape) # (height, width, 3)方式三,OpenCV:
import cv2 img = cv2.imread("key.png") rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)3.3 RGB 和 BGR 的坑
OpenCV 默认读出来是 BGR,不是 RGB。如果直接把img[:, :, 0]当 R 通道,通道顺序就反了。解决办法是cv2.cvtColor(img, cv2.COLOR_BGR2RGB),或者用img[:, :, ::-1]把通道倒过来。
还有一个更隐蔽的坑:很多图片查看器显示正常,但像素实际做过色彩管理。做实验时最好统一用 PNG,不要用 JPEG。JPEG 是有损压缩,同一张图保存两次,像素值就可能变,密钥就变了。我用的是自己生成的 256x256 纯色测试图,先把格式问题排除掉。
4. 第一版实现:把图片 RGB 当密钥流,对文本做异或
4.1 生成密钥流的思路
最简单的做法是把图片像素字节拉长重复,和文本字节逐位异或。但这样做有一个明显弱点:如果图片是纯色块或大面积渐变,密钥流会有规律,密文会出现可观察的重复模式。
我用的思路是:先把图像素字节做 SHA-256,得到 32 字节种子,再用种子加计数器不断做哈希,展开成足够长的密钥流。这个思路接近“用哈希构造流密码”,但只是为了好玩,不代表它安全。
import hashlib def derive_key_stream(pixel_bytes: bytes, data_len: int) -> bytes: seed = hashlib.sha256(pixel_bytes).digest() stream = b"" counter = 0 while len(stream) < data_len: stream += hashlib.sha256(seed + counter.to_bytes(8, "big")).digest() counter += 1 return stream[:data_len]counter.to_bytes(8, "big")的作用是让每次哈希输入都不同,避免生成重复块。
4.2 加密和解密函数
from PIL import Image def load_pixel_bytes(image_path: str, size=(256, 256)) -> bytes: img = Image.open(image_path).convert("RGB").resize(size) pixels = list(img.getdata()) return bytes(v for pixel in pixels for v in pixel) def xor_encrypt(text: str, image_path: str) -> str: data = text.encode("utf-8") stream = derive_key_stream(load_pixel_bytes(image_path), len(data)) cipher = bytes([a ^ b for a, b in zip(data, stream)]) return cipher.hex() def xor_decrypt(hex_cipher: str, image_path: str) -> str: cipher = bytes.fromhex(hex_cipher) stream = derive_key_stream(load_pixel_bytes(image_path), len(cipher)) data = bytes([a ^ b for a, b in zip(cipher, stream)]) return data.decode("utf-8")加密和解密都依赖同一个image_path。解密必须使用同一张图,换成任何其他图片,密钥流就不同,解出来就是乱码。
4.3 用一张测试图验证
key_image = "key.png" origin = "这是一段用来测试 RGB 加解密的中文文本。" cipher_text = xor_encrypt(origin, key_image) print(cipher_text) plain_text = xor_decrypt(cipher_text, key_image) print(plain_text)如果输出末尾能看到原始中文,说明流程通了。我建议先跑这一版,再去看下一版,因为这一版已经把“图片像素”和“文本字节”的关系讲清楚了。
5. 第二版实现:把密文写进 RGB 像素,生成一张噪点图
5.1 文本转 RGB 图片
第二版更视觉化:把文本字节按三个一组塞进像素的 R、G、B 通道,生成一张图片。为了还原时不丢尾部空字节,我在开头用 4 个字节记录原始数据长度。
import struct from PIL import Image def text_to_rgb_image(text: str, output_path: str, width=128): data = text.encode("utf-8") header = struct.pack(">I", len(data)) payload = header + data if len(payload) % 3 != 0: payload += b"\x00" * (3 - len(payload) % 3) pixels = [tuple(payload[i:i+3]) for i in range(0, len(payload), 3)] height = (len(pixels) + width - 1) // width pixels += [(0, 0, 0)] * (width * height - len(pixels)) img = Image.new("RGB", (width, height)) img.putdata(pixels) img.save(output_path) print(f"output: {output_path}, size: {width}x{height}")struct.pack(">I", len(data))用 4 字节大端整数存长度。这一步很重要,否则尾部填充的\x00会被当成正常数据,导致中文解码失败。
5.2 从 RGB 图片还原文本
def rgb_image_to_text(image_path: str) -> str: img = Image.open(image_path).convert("RGB") pixels = list(img.getdata()) payload = b"".join(bytes(pixel) for pixel in pixels) data_len = struct.unpack(">I", payload[:4])[0] data = payload[4:4 + data_len] return data.decode("utf-8")逻辑是先读长度,再按长度截取,剩下的填充字节直接丢弃。
5.3 验证方法和边界
origin = "Hello, RGB Cipher! 这是一张会说话的图片。" text_to_rgb_image(origin, "cipher.png", width=100) restored = rgb_image_to_text("cipher.png") print(restored == origin) # True生成出来的 cipher.png 看起来就是一堆彩色噪点,因为任意字节映射到 RGB 后基本没有视觉规律。这个效果本身也适合当演示素材。
边界情况有三点:
- 空文本:长度是 0,生成一张只有头部 4 个字节的图,可以正常运行。
- 非 ASCII 文本:只要编码用 UTF-8,中文、日文、表情符号都能放。
- 超长文本:图片会变高,建议控制文本长度在几 KB 级别,或者适当加大 width。
6. 性能和安全性:和 AES、3DES 比,差距在哪里
6.1 速度差距很大,而且不在一个量级
有人会拿这个项目和“3des 与 aes 加解密速度对比”放一起讨论。实际上,AES 在主流 CPU 上有硬件加速,速度非常快;3DES 属于老算法,虽然不如 AES,但也比纯 Python 循环快很多。
而这个 RGB 方案,每次加解密要经历:
- 打开图片文件;
- 转 RGB、缩放、展开像素;
- 做 SHA-256 展开密钥流;
- 再逐字节异或。
其中图片 I/O 和哈希展开都是 Python 层耗时。我拿一张 256x256 的 PNG 当密钥,加密几千字节文本,耗时在毫秒级到几十毫秒之间。单次看不算慢,但放到批量任务里,几百次调用叠加起来就很明显。
一句话:这个项目适合教学和实验,不适合做高性能数据通道。
6.2 为什么不建议用于真实安全场景
必须把话说清楚:这是自制算法,不是密码学意义上的安全方案。
问题至少有四层:
- 算法没有公开审计。我写的时候优先保证“能跑通、能还原”,没有考虑侧信道、时序攻击、选择明文攻击这些模型。
- 密钥本质是一张图片。图片可能被复制,也可能带有可预测内容,密钥熵不稳定。
- 没有完整性校验。密文被篡改,解密出来就是乱码,程序不会主动发现“数据被改了”。
- 没有标准填充和认证机制。工程上要求的安全边界,这个项目都没有。
真实保护数据,建议用成熟库:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM或者直接用 GPG、age 这类工具。自制算法最大的价值是帮你理解加解密的基本原理,而不是替代标准算法。
6.3 哪些场景可以放心玩
- 课堂作业和课程设计,把原理讲清楚。
- 给朋友做一个“用照片解锁文本”的小谜题。
- 用文本生成艺术噪点图,再还原文字,作为编程分享的演示。
- 作为学习开源项目维护的载体,练习 README、许可证、issue 管理。
7. 开源发布:仓库结构、许可证和 README 建议
7.1 仓库结构
既然是开源项目,就不要只丢一个 .py 文件。我建议仓库结构这样放:
rgb-cipher/ ├── README.md ├── LICENSE ├── requirements.txt ├── rgb_cipher.py ├── examples/ │ ├── key.png │ ├── demo_encode.py │ └── demo_decode.py ├── tests/ │ └── test_rgb_cipher.py └── .gitignore.gitignore至少要排除__pycache__/、.venv/、.idea/、.vscode/。不然别人克隆下来,第一眼看到一堆缓存文件,观感很差。
7.2 许可证怎么选
仓库里有代码,就要有 LICENSE 文件。常见选择:
| 许可证 | 特点 | 适合场景 |
|---|---|---|
| MIT | 允许商用、修改、再发布,只要保留版权声明 | 玩具项目、教学项目 |
| Apache-2.0 | 类似 MIT,额外包含专利授权条款 | 想考虑专利风险的开发者 |
| GPL-3.0 | 要求衍生作品同样开源 | 希望别人改完也必须开源 |
这类 RGB 加解密项目,我建议直接选 MIT。最省事,别人拿去改、拿去演示,都不需要专门来问你。
7.3 README 怎么写
README 至少要回答四个问题:
- 这个项目能干什么:用文本生成 RGB 图片,或用图片当密钥做异或加解密。
- 怎么安装:给出
pip install -r requirements.txt。 - 怎么运行:给出完整命令行示例。
- 有什么限制:不是安全算法,不要用于真实数据。
可以放一张生成出来的噪点图当截图,视觉上很直观。
8. 常见报错与排查顺序
8.1 图片打不开、像素读不全
先看路径。路径有中文、空格,或者相对路径和运行目录不一致,都容易报FileNotFoundError。其次看图片格式,统一换成 PNG 再试。最后看图片模式,Image.open()之后一定要.convert("RGB"),否则灰度图或 RGBA 图的通道数不一样,展开成字节时会出问题。
8.2 解密出来乱码
乱码是这类项目最高频的问题,按这个顺序排查:
- 密钥流不一致:换过图片、图片被缩放、图片被重新保存,都会导致像素值变化。
- 长度不一致:密文 HEX 被截断或拼接错误。
- 编码不一致:加密时用 UTF-8,解密时也要用 UTF-8。
- 填充处理错误:第二版里如果没读头部长度,直接用
rstrip(b"\x00"),文本末尾原本有\x00时会被误删。
我一般会先打印密文长度和密钥流长度,再打印解密后的字节。如果能看到b'\xe4\xbd\xa0'这样中文 UTF-8 前缀,说明字节流对上了,问题出在最后的 decode 环节。
8.3 和图片处理相关的其他坑
如果做图像处理时遇到“RGB 转 YCbCr 为负数”的问题,先别急着当异常处理。用 BT.601 或 BT.709 的矩阵做 RGB 转 YCbCr 时,Cb、Cr 在部分色域下出现负数是正常的。显示时一般要位移或裁剪到 0 到 255,但做计算时不要随便把负数裁掉,否则颜色会偏。
放到这个项目里的提示是:凡是基于像素做加解密,像素值必须严格保持 0 到 255 的原始字节范围,不要做任何裁剪、归一化、色彩空间转换。任何一步改变像素值,都等于改变密钥。
8.4 内存和性能问题
如果把一张几千万像素的大图直接list(img.getdata()),内存会涨得很快。建议先resize((256, 256))再用。如果文本很长,密钥流展开时间也会明显上升,可以先跑小段文本确认逻辑正确,再考虑扩展。
这轮玩下来的感受是:所谓“RGB 加解密”,本质就是文本字节和图片字节互相转换的实验。它最大的价值不是发明了新算法,而是把编码、字节、异或、像素、文件 I/O 这些零散知识点串在了一起。如果想继续深入,可以把两个方向合并:先用图片像素生成密钥流,再把密文写进另一张图片,玩法会更有意思。唯一要记住的是,真正要保护数据时,请回到 AES-GCM 这类标准方案,而不是自己拍脑袋拼一个字节变换。