简介:一款将ZIP压缩文件伪装成JPEG/PNG图片的轻量小工具,面向信息安全爱好者及有隐私保护需求的个人用户。压缩包与图片格式巧妙结合,转换后可通过修改后缀名还原解压,还可对内容进行加密处理,适合隐秘存储或安全传递敏感文件。资源包共3个文件,包含1张jpg示例图片、1份html说明文档和1个bat启动脚本,整体仅43KB,轻量免安装,方便在任何Windows环境下直接使用。已有120人学习使用。下载后可获得可直接运行的bat脚本、图文操作说明和测试样例,能够快速掌握zip与jpg互转的完整流程;借助加密能力,用户可以将文档、图像、音频、视频等素材先行压缩,再伪装成图片隐蔽备份,避免被他人直接发现,适合在办公、跨境交流或个人隐私保护等场景中作为一种轻量级文件保护手段。
1. 把压缩文件变成图片:一个能救急的轻量小工具
你有没有遇到过这种场景:群里只允许发图片,压缩包发不出去;或者说某个工具的素材上传通道只认图片格式,而你的模板、范文、插件压缩包怎么也传不上去。这个工具解决的就是这类问题——它能把任意 zip、rar 压缩包无损编码成一张 PNG 图片,需要的时候再从图片里原样还原压缩包。本质是拿像素当字节的搬运工,不经过 Base64 转码,也没有有损压缩带来的数据破坏。适合手边有 Python 环境、又不想为一次传输去搭重型服务的开发者,新手按步骤跑通编码和解码也就十分钟的事。
2. 编码原理与选型:像素映射为什么比文本嵌入更靠谱
2.1 三条技术路线对比
把二进制数据塞进图片,常见思路有三种,我先把它们摆在一起对比,再说为什么最后选了像素直映射这条路。
| 方案 | 容量效率 | 可逆性 | 实现成本 | 坑点 |
|---|---|---|---|---|
| Base64 编码成字符串再绘制成图 | 膨胀约 33%,容量极低 | 差,需 OCR 识别文本 | 低 | OCR 识别率不稳定,几乎无法还原 |
| 二维码 / 彩色码片 | 单张上限几 KB 到几十 KB | 好,但容量受限 | 低 | 装不下稍微大点的压缩包 |
| 字节直映射到像素 RGB 通道 | 每像素 3 字节,容量可观 | 完全可逆 | 中等 | 图片格式必须选无损格式,否则数据被破坏 |
Base64 方案最直观,但有两个硬伤:一是文本膨胀率固定 33%,同样是 10MB 压缩包,编码图片理论尺寸会被撑大;二是把 Base64 字符画成图之后,要还原得靠 OCR 或者矢量匹配,一旦字体、背景、分辨率有一丁点不一致,还原就是玄学。二维码方案容量太小,一个 1024x1024 的二维码也只能装几 KB,稍微大点的压缩包就无能为力。
字节直映射的思路就干净很多:压缩包本身是一串二进制字节,每个字节恰好可以放进一个 RGB 像素的一个通道里——R、G、B 各存一个字节,一个像素就能携带 3 个字节。只要图片格式本身不引入有损压缩,这个映射就是严格可逆的。这也是我最终选择这条路线的原因。
2.2 为什么图片格式必须选 PNG 而不是 JPEG
这是整个工具里最容易翻车的地方,我特意单独拎出来说。JPEG 的压缩原理是先把图像转换到 YCbCr 色彩空间,再做离散余弦变换和量化,量化过程会丢弃高频信息。这意味着你存储的像素值在解码时大概率已经变了——比如写入的 R 通道值是 128,解出来变成 126 或者 131。对你用眼睛看图,这点偏差毫无感知,但对二进制数据来说就是致命错误,一个字节错位,整个压缩包的结构就废了。
PNG 走的是完全不同的路子:它用的是 Deflate 无损压缩,像素值存进去是什么,解出来就是什么,一个字节都不会丢。BMP 也可以,但完全不压缩,文件体积太大,没意义。所以这个工具里我硬性限制输出格式只能是 PNG,格式校验收在代码里,遇到非 PNG 文件直接拒绝解码。
2.3 容量、尺寸与边界计算
容量计算其实很简单:一张宽 W 高 H 的 RGB 图片,像素总数是 W×H,能携带的字节数是 3×W×H。一个 10MB 的压缩包按 10485760 字节算,需要的像素数是 3495254,塞进一张 2048×2048 的图(约 420 万像素)绰绰有余。如果你先压缩再编码,比如把一个 20MB 的素材目录压成 8MB 的 zip,那需要还原的原始字节数就是这个 8MB 的 zip 文件大小,后面的编码按这个体积来算。
尺寸选择上有个经验值:图片宽度建议取 1024 或 1536,宽高比控制在 1:2 到 2:1 之间。太扁或太方的图在部分图片预览工具里会被强制压缩预览,虽然不影响文件本身的像素数据,但肉眼检查时看不清细节。另外,PNG 规范里单张图片的宽度上限是 2^31-1,但实际 Pillow 和大部分解码器对超大图有内存限制,所以单张图我一般控制在 1 亿像素以内,超过就分卷成多张图。
3. 编码端实现:把压缩包字节塞进 PNG 的三步走
3.1 项目结构、依赖与头部格式设计
整个工具只有两个脚本,一个负责编码,一个负责解码,没有任何额外服务。依赖只有 Pillow 和 numpy,都是 Python 生态里最基础的库。
archive2img/ ├── archive_to_image.py # 编码:压缩包 → PNG ├── image_to_archive.py # 解码:PNG → 压缩包 └── requirements.txt # pillow, numpy编码前要先定义一个头部结构,否则解码端不知道图片里装的是什么、从哪个字节开始、原始文件名叫什么。我设计的是一个固定头加变长段的组合,按顺序依次是:
- 魔数 4 字节,固定为
A2P\0,用来快速判断一张图片是否由这个工具生成 - 版本号 1 字节,目前是 1,留作以后格式升级
- 原始文件名长度 2 字节,无符号短整型,最大支持 65535 字节的文件名
- 原始数据长度 8 字节,无符号长长整型,支持最大 16EB 的压缩包
- 原始文件名,UTF-8 编码的变长字节
- CRC32 校验值 4 字节,对原始数据区做校验
- 数据区,即压缩包本身的全部字节
头部固定部分是 15 字节(4+1+2+8),加上变长的文件名,整体就是完整的图片数据来源。版本号字段很重要,以后如果头部格式调整,解码端靠它决定用什么解析逻辑。
3.2 编码核心代码与参数说明
编码端核心逻辑不长,我在项目里是这样实现的:
import struct import zlib import os import numpy as np from PIL import Image MAGIC = b"A2P\0" VERSION = 1 def archive_to_image(src_path, dst_path, width=1024): # 读取压缩包原始字节 with open(src_path, "rb") as f: payload = f.read() # 组装头部:魔数、版本、文件名长度、数据长度 fname_bytes = os.path.basename(src_path).encode("utf-8") header = struct.pack("<4sBHQ", MAGIC, VERSION, len(fname_bytes), len(payload)) # 接上文件名和 CRC32 校验值 header += fname_bytes header += struct.pack("<I", zlib.crc32(payload)) # 整体数据 = 头 + 原始字节 data = header + payload # 每个像素 3 字节,数据长度需要补到 3 的整数倍 pad = (3 - len(data) % 3) % 3 data += b"\x00" * pad # 根据数据量计算图片高度,宽度由参数指定 total_pixels = len(data) // 3 height = (total_pixels + width - 1) // width fit_size = height * width * 3 if fit_size > len(data): data += b"\x00" * (fit_size - len(data)) # 转成 numpy 数组并调整为 RGB 图像 arr = np.frombuffer(data, dtype=np.uint8).reshape(height, width, 3) img = Image.fromarray(arr, mode="RGB") img.save(dst_path, format="PNG") print(f"encoded {src_path} -> {dst_path}, size={len(payload)} bytes, image={width}x{height}")这段代码处理了两个容易出错的细节。struct.pack("<4sBHQ", ...)里的<表示小端字节序,4s是 4 字节魔数,B是版本号,H是文件名长度,Q是数据长度。小端字节序在 x86 平台上最自然,解码端用同样的格式就能无损还原。
关于width参数,我默认给 1024,单位是像素。改大这个值会让图片更扁,改小会让图片更高,都不影响数据完整性,但会影响预览体验。数据末端的\x00填充不会污染数据区,因为解码端严格按头部记录的len(payload)来切数据,多余的填充字节会被丢弃。
3.3 命令行封装与日志输出
为了让新手能直接跑,我在编码脚本里用 argparse 包了一层命令行入口,实测跑通只需要一条命令:
python archive_to_image.py --src ./素材包.zip --dst ./素材包.png --width 1024参数含义分别是:--src指定压缩包路径,--dst指定输出图片路径,--width指定图片宽度。如果--dst不传,默认生成和源文件同名的.png文件,放在当前目录。
我一般还会把日志写进文件而不是只打印到终端,因为编码大文件时可能耗时几十秒,需要知道进度。实际使用中,跑完看一眼输出的size=... bytes和image=WxH的日志,就能判断数据是否完整进了图里。如果图片尺寸和预期差太多,那大概率是读取阶段就出了问题。
4. 解码端实现:从图片无损还原压缩包
4.1 解码流程与边界判断
解码是编码的逆操作,但有几个边界情况必须单独处理。先看核心代码:
import struct import zlib import os import numpy as np from PIL import Image MAGIC = b"A2P\0" def image_to_archive(img_path, out_dir="."): # 打开图片并强制转成 RGB 模式 img = Image.open(img_path).convert("RGB") arr = np.asarray(img) flat = arr.reshape(-1).tobytes() # 检查魔数,判断是不是本工具生成的图片 if flat[:4] != MAGIC: raise ValueError("not an archive image, magic mismatch") # 解析固定头:魔数、版本、文件名长度、数据长度 magic, version, name_len, data_len = struct.unpack("<4sBHQ", flat[:15]) if version != 1: raise ValueError(f"unsupported version: {version}") # 取出文件名、CRC32 和数据区 name_start = 15 name_end = name_start + name_len fname_bytes = flat[name_start:name_end] crc_start = name_end crc_end = crc_start + 4 crc_expected = struct.unpack("<I", flat[crc_start:crc_end])[0] data_start = crc_end data_end = data_start + data_len payload = flat[data_start:data_end] # 校验 CRC,确保数据未被破坏 crc_actual = zlib.crc32(payload) if crc_actual != crc_expected: raise ValueError("CRC32 mismatch, image data corrupted") # 写回原始压缩包 fname = fname_bytes.decode("utf-8") out_path = os.path.join(out_dir, fname) with open(out_path, "wb") as f: f.write(payload) print(f"decoded {img_path} -> {out_path}, restored {len(payload)} bytes")解码端最关键的判断逻辑在数据切片这步。因为编码时补过零,图片扁平化后的字节流长度一定大于或等于头部加数据区的长度,所以我严格按data_len切片,而不是直接拿全部字节写文件。这样哪怕图片末尾有多余像素,也不会污染还原出来的压缩包。
版本判断是个容易被新手忽略的点。图片里存的版本号编码时写死为 1,解码端发现不是 1 就直接拒绝,这是防止将来格式变更后旧版本工具做出错误解析的兜底方案。实际项目中这个字段帮过我省掉好多次排查时间。
4.2 完整性校验机制
CRC32 校验值我放在文件名后面、数据区前面,解码时先取出来,再对数据区计算一次 CRC32 做比对。这里我不推荐用 MD5 或 SHA256,因为 CRC32 在 zlib 里是内置函数,计算快,占空间只有 4 字节,对隐写场景完全够用。CRC32 的碰撞概率在误码检测场景里可以忽略,它主要防的是数据传输出错,不是防恶意篡改。
校验失败时,错误信息里会明确提示CRC32 mismatch。这时不要尝试强制忽略校验继续解包,因为数据已经错了,强行解压出来的 zip 大概率也是坏的。正确做法是检查图片有没有被二次压缩、裁剪或其他平台改动过,重新拿原图来解。
5. 避坑与常见问题:五个高频翻车点一次说清
5.1 PNG 被平台转成 JPG,解码全军覆没
现象:图片传到某个聊天工具再下载,文件名后缀还是.png,解码时却报 CRC32 mismatch,或者 magic 校验直接失败。
原因:许多平台的转码服务会把所有图片统一转成 JPEG 存储,后缀名没改但内部编码已经变成有损的了,像素值早被量化掉一层。我头一次遇到时看后缀判断,折腾了半天才发现是转码的问题。
解决:解码前先检查文件头。PNG 文件的前 8 个字节固定是\x89PNG\r\n\x1a\n,用十六进制看就是89 50 4E 47 0D 0A 1A 0A,一查便知。我在解码脚本里加了这个检查,不是 PNG 就直接报错并提示原因。
5.2 中文文件名还原出来乱码
现象:压缩包叫演示项目素材.zip,解码后写出来的文件名变成一串乱码。
原因:编码时我用os.path.basename(src_path).encode("utf-8"),解码时用fname_bytes.decode("utf-8"),问题通常出在手动修改代码时混用了 GBK 或其他编码,两边对不上。
解决:统一用 UTF-8 编码文件名,解码端不要想当然用系统默认编码。另外,name_len记录的是编码后的字节长度,不是字符个数,切片时按字节切,别按字符切,否则中文文件名会因为 UTF-8 多字节特性被切错位置。
5.3 图片被裁剪,数据错位到无法定位
现象:图片传给别人之前被裁剪过,解码时数据区偏移全部错位,CRC32 校验失败,甚至魔数都对不上。
原因:裁剪改变了图片宽高,展平后的像素顺序整体错位。如果裁掉的是左侧或右侧区域,那等于数据流被截断或移位。
解决:图片只能整张传输,不能做任何裁切缩放。如果确实需要二次分发,让对方拿到图之后先看图片尺寸是否和编码日志里的WxH一致,不一致就重新传。我在编码日志里都会打印尺寸,就是为了这个目的。
5.4 大文件编码时内存占用过高
现象:对一个 1GB 的压缩包跑编码,Python 进程直接占掉 3GB 内存,电脑卡住甚至被系统杀掉进程。
原因:整个流程把文件全部读进内存,转成 numpy 数组时还复制了一份,1GB 文件峰值内存能到 3-4 倍体积。
解决:大文件场景改用分块读写,按行写入图片而不是一次性 reshape。或者直接设定安全线,比如超过 200MB 就拒绝编码并提示用户先拆分成多个小压缩包。这个工具定位是轻量传输,不是大文件备份系统,该设上限就设上限。我现在的版本里限制单个文件不超过 512MB,超了强制走分卷逻辑。
5.5 尾部填充字节被误当成数据写入
现象:解码后文件能解压,但末尾多出一截无用数据,文件比对哈希不一致。
原因:图片像素总量必须覆盖全部字节,不足时补零。有些实现没按头部长度切数据,直接把整个扁平化字节流写进文件,把补的零也当成压缩包内容。
解决:严格用头部记录的data_len切片,补的字节一个都不要。我解码代码里data_end = data_start + data_len这行的data_len是编码时的原始压缩包长度,不是像素填充后的长度,这个变量是整段代码的命根子,改错就是数据损坏。
6. 进阶:批量转码、往返校验与工作流沉淀
6.1 批量转码:目录里的压缩包一次全转
实际使用中,我经常要处理一批压缩包,手动一个个跑命令太慢。批量封装的思路很简单,用 Python 脚本遍历目录,对每个 zip 调一次编码入口:
import os import subprocess src_dir = "./packages" for fname in os.listdir(src_dir): if fname.endswith(".zip"): src = os.path.join(src_dir, fname) dst = os.path.join(src_dir, fname.replace(".zip", ".png")) subprocess.run([ "python", "archive_to_image.py", "--src", src, "--dst", dst, "--width", "1536" ], check=True)这样跑完,一个目录里的所有素材包都变成了并排排列的 PNG 图片,既方便一次性传走,也方便在文件管理器里扫一眼大概知道内容体积。
6.2 往返校验:编码后立刻解码对比哈希
我强烈建议在自动化流程里把「编码-解码-哈希对比」做成一条龙,不要只转不验。脚本长这样:
import hashlib import archive_to_image import image_to_archive def sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() archive_to_image.archive_to_image("素材.zip", "素材.png", width=1024) image_to_archive.image_to_archive("素材.png", out_dir="./restored") assert sha256("素材.zip") == sha256("./restored/素材.zip") print("round-trip verification passed")这个断言能帮你抓住几乎所有编码、解码端的回归问题。我吃过一次亏:某次改了头部结构,编码端和解码端的字段顺序没同步改,图片生成正常、还原也不报错,但解出来的 zip 就是打不开。加了哈希对比之后,这类问题当场现形。
从那以后,每次改完这个工具,我都会强制把一批不同体积的压缩包跑一遍往返校验,再检查日志里的图片尺寸和 CRC 状态。这个习惯帮我省掉了大量「看着正常但实际是坏的」的隐形返工。希望这个流程也能帮到你。
本文还有配套的精品资源,点击获取