
简介一套基于Python 3.6.8与MySQL 5.7的图像信息隐藏技术毕业设计源码面向计算机相关专业学生及需要实现隐写术小项目的开发者。项目采用B/S架构前端使用HTML/CSS/JavaScript构建交互界面后端通过Python实现图片载体的信息嵌入与提取流程并配合数据库完成用户与记录管理可直观演示隐写术在不改变载体视觉特征的前提下传递机密信息的过程。压缩包共322个文件约37.16MB包含36个Python核心代码、22个CSS与34个JavaScript前端资源、76个GIF和52个PNG展示素材以及SQL数据库脚本和详细说明文档目录结构清晰便于按模块阅读和二次开发。已有78人学习浏览资料完整覆盖前后端设计、数据库表结构与算法实现既能支撑毕业设计答辩演示也可作为课程综合实践或信息安全入门研究的参考。1. 图像信息隐藏技术能做到什么不只是一个隐藏文件的算法而是一个能答辩的 Web 系统图像信息隐藏技术在 Python 毕业设计里最常见的误解是“把隐写算法写完就收工”。实际上一个以“完整前后端 mysql 说明文档 LW”为交付形态的毕设项目评委更看重你能不能把“藏”这件事做成一套前端能传图、后端能处理、数据库能留痕的可演示系统。这个方向的落地难点不是 LSB 替换那几行代码而是图像格式约定、容量边界、前后端文件传输和数据库字段设计这些容易被忽略的细节。本文按“选型 → 搭骨架 → 写算法 → 排错 → 验收”的顺序讲清楚适合正在选题或想快速复现一个能过的毕设项目的从业者。2. 空域 LSB 与频域 DCT 的选择容量、压缩敏感性和实现代价2.1 LSB 最低位替换的三分钟自检步骤LSBLeast Significant Bit最低有效位是空域隐写最直观的做法把像素值写成二进制后最低一位对图像亮度的影响只有 1/255人眼基本感知不到。比如像素值 200 的二进制是 11001000最低位改成 0 变成 11001000 还是 200改成 1 则是 11001001 即 201。把秘密消息转成比特流逐个写进载体图像每个像素通道的最低位这就是整个算法的核心。在写正式函数前我建议先用一段三分钟的自检代码把“最低位替换到底改了多少像素”看明白import numpy as np from PIL import Image carrier np.array(Image.open(cover.png).convert(RGB)) modified carrier.copy() # 把 R 通道最低位全部清零 modified[..., 0] 0xFE diff np.abs(carrier.astype(int) - modified.astype(int)) print(最大像素差:, diff.max()) print(像素值变化边界:, np.unique(carrier[..., 0] % 2), -, np.unique(modified[..., 0] % 2))这段代码的逻辑是复制一份载体只把红色通道最低位置 0其余通道不动。diff.max()的结果必然是 1说明整张图每个像素最多产生一个灰度级的变化。np.unique(... % 2)则用来确认最低位确实被清掉。这样跑一遍后面写嵌入函数时不会再对“为什么看不见变化”产生怀疑。这里有两个参数需要先定死一是图像必须统一转成 RGB 三通道否则 RGBA 或灰度图会让后续 flatten 后的比特位错位二是copy()必须用因为后面正式嵌入时如果直接改原数组会导致原始载体被破坏没法做 PSNR 对比。2.2 嵌入容量怎么算一张 512×512 彩图能藏多少字节容量是选题评审最爱问的问题。容量公式很简单容量(bit) 宽 × 高 × 通道数 × 每像素替换位数。以最常见的 512×512 RGB 图、每像素替换 1 bit 为例512 × 512 × 3 786432 bit 98304 字节去掉 4 字节长度头大约能容纳 98300 个英文 ASCII 字符如果消息是 UTF-8 中文一个字占 3 字节大约只能装 32700 个字。表面看容量很大但大部分毕设提交的测试文本不会超过几千字所以这个容量对场景是够用的。分辨率通道每像素替换位数可用比特可容纳英文约可容纳中文约512×512317864329830032700512×512321572864196600655001024×10243131457283932001310001024×1024326291456786400262100每像素替换位数从 1 提到 2容量翻倍但视觉损失也会变大PSNR 大约下降 6dB。毕设场景里 1 bit 足够2 bit 一般只用来展示“容量可以调”不建议作为默认值。另一个关键结论载体格式必须选 PNG 或 BMP不能选 JPEG。JPEG 的离散余弦变换和量化步骤会直接抹掉最低位信息这也是后面避坑章会重点展开的问题。2.3 频域 DCT 值不值得引入鲁棒性和实现成本的取舍很多同学会在开题时纠结要不要用 DCT 域隐写DCT离散余弦变换是 JPEG 压缩的核心把图像分成 8×8 块每块做 DCT 变换后在频域系数里嵌入消息。优点是抗压缩能力强JPEG 重新保存后消息仍能提取缺点是代码量从几十行变成几百行还要处理量化表、块边界和系数选择策略。我的建议是如果目标是稳妥过答辩LSB 空域方案就够了因为选题评审关注的是信息隐藏系统的完整链路而不是算法复杂度。把 LSB 的嵌入、提取、容量校验、数据库记录做扎实比堆一个跑不通的 DCT 实现更有说服力。如果你确实想把 DCT 作为亮点可以把它做成一个“可选嵌入模式”界面上提供空域/频域切换后端分别调两套函数测试时用同一张图两种模式各嵌一次对比提取成功率和 PSNR。这样既展示了能力边界又不至于让主流程被 DCT 的调试拖垮。3. 前后端分离 MySQL 的骨架Flask 接口、文件上传和数据库表设计3.1 技术选型为什么后端选 Flask 而不是 Django这套系统的后端常见做法是 Flask因为隐写算法的处理逻辑集中在一个模块里Flask 的路由声明式写法让整个接口看起来非常干净。Django 自带 admin 后台确实方便但它的 ORM、中间件、应用拆分对一个小型毕设来说太重而且答辩时老师大概率只问“你的接口怎么设计的”并不会关心你有没有用满 Django 的特性。前端技术栈不必纠结。用 Vue 单页应用可以用原生 HTML JavaScript 也可以。这个系统只有两个核心页面嵌入操作页和提取操作页我一般建议直接用原生页面配合 fetch减少依赖也方便在 LW 文档里贴代码。整体目录结构建议按前后端分离的方式组织stego-system/ ├─ backend/ │ ├─ app.py # Flask 接口 │ ├─ stego_core.py # LSB 嵌入/提取算法 │ ├─ db.py # MySQL 连接 │ └─ requirements.txt ├─ frontend/ │ ├─ embed.html # 嵌入页 │ └─ extract.html # 提取页 ├─ sql/ │ └─ init.sql # 建表脚本 └─ LW/ └─ 说明文档.md前后端分离的边界要划清楚前端只负责把用户选择的文件和文本框内容拼成 multipart 请求发出去后端只返回 JSON提取和嵌入结果通过接口透传。数据库不存图像本身只存嵌入记录、密钥种子和文件路径这样整个系统的职责链路在答辩时一句话就能讲明白。3.2 MySQL 表结构设计用户、嵌入记录和密钥字段数据库在这个项目里不是核心算法模块但它承担“留痕”功能每次嵌入都记录用户、文件名、消息长度和使用的密钥种子答辩时打开表能看到一条条可追溯记录比空口说“能跑”有说服力得多。建表脚本可以省掉不必要字段但以下字段建议保留CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE stego_record ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, file_name VARCHAR(255) NOT NULL, file_size INT NOT NULL, secret_length INT NOT NULL, key_seed VARCHAR(64) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES user(id) );key_seed字段存的是嵌入时用随机数生成的种子对应的是扩展后的“随机位置嵌入”功能不按顺序把消息写进最低位而是用伪随机序列决定嵌入位置。这里不展开实现但字段先留出来后面加功能不用改表。file_size用 INT 存字节数secret_length存明文字符数而非字节数这样统计比较直观。如果你按 mysql 安装配置教程装完 MySQL 8.0 后Python 连接报一些看起来像 SSL 的错误不要先怀疑数据库配置优先检查认证插件。MySQL 8 默认的caching_sha2_password和旧驱动兼容性不好常见处理是创建用户时指定mysql_native_password或者升级到新版本 PyMySQL。这类问题在“现象 → 原因 → 解决”的排错逻辑里属于十分钟能定位的小坑。3.3 前后端文件传输约定FormData 上传和三段式返回文件传输是前后端分离项目实战里最容易踩的环节。嵌入接口约定用 POST multipart/form-data前端不要把文件转成 base64 再发 JSON那样会让请求体膨胀 33%还会让后端多一层解码。正确做法是用 FormData 把 File 对象和文本字段放在一起提交const form new FormData(); form.append(image, fileInput.files[0]); // 原始文件禁止 canvas 重绘 form.append(secret, secretInput.value); form.append(seed, randomSeedInput.value || ); const resp await fetch(/api/embed, { method: POST, body: form }); const data await resp.json(); if (data.code 0) { document.getElementById(stegoImg).src /api/stego/ data.stego_id; }这里的核心参数是image字段名后端request.files.get(image)必须与之一致否则 Flask 拿到的是 None 又不会报明显错误只会返回 400排查时容易被误导。返回体采用三段式结构code表示业务状态、msg放错误信息、data放成功后的记录 id 或图片访问路径。前端不用自己拼 base64 图片 URL而是用后端落盘后的静态路径这也是为第五章的 base64 性能坑提前做的规避。4. LSB 嵌入与提取的 Python 实现从消息编码到接口返回4.1 把秘密消息序列化成比特流长度头与大端序嵌入前必须解决一个关键问题提取端怎么知道秘密消息有多长最简单的是用结束标记比如在消息末尾拼###END###遇到这个标记就停止提取。但这个方案有个暗坑如果消息正文里恰好包含这个标记提取会提前结束。更可靠的做法是加 4 字节长度头把消息长度写进前 32 个比特提取时先读头部再读正文。import struct def _message_to_bits(message: str) - str: payload message.encode(utf-8) header struct.pack(I, len(payload)) return .join(f{b:08b} for b in header payload)代码逻辑分三步先把字符串按 UTF-8 编码成字节再用struct.pack(I, ...)把字节数写成 4 字节大端无符号整数最后把“长度头 正文”逐字节转成 8 位二进制字符串。这里用大端序是网络字节序的惯例后面提取端用同样的I解包就不会出错。为什么不用小端两种都能用但保持前后一致比选哪个更重要而这个项目里同时涉及 MySQL 存储大端与十六进制字符串的显示顺序一致方便人工核验长度值。4.2 嵌入函数最低位替换、容量校验和位宽参数拿到比特流后嵌入函数只需要做三件事检查容量、替换最低位、返回新图像数组。import numpy as np def lsb_embed(carrier: np.ndarray, message: str, bits_per_channel: int 1) - np.ndarray: bit_str _message_to_bits(message) h, w, c carrier.shape capacity h * w * c * bits_per_channel if len(bit_str) capacity: raise ValueError( f消息过长需要 {len(bit_str)} bit f容量仅 {capacity} bit可用约 {capacity // 8} 字节 ) flat carrier.flatten() for i, bit in enumerate(bit_str): shift bits_per_channel - 1 if shift 0: flat[i] (flat[i] 0xFE) | int(bit) else: flat[i] (flat[i] (0xFF ^ 0x03)) | (int(bit) shift) return flat.reshape(carrier.shape)逻辑说明先取容量并比较避免消息被静默截断carrier.flatten()把三维数组按行主序拉平这样后续替换的最低比特位按照“第 0 行第 0 列 R、G、B、第 0 行第 1 列 R、G、B……”的顺序排列最后reshape(carrier.shape)恢复原始尺寸。bits_per_channel默认是 1改成 2 时容量翻倍但嵌入的比特要放进最低两位所以掩码变成0x03并按位左移 1 位后做或运算。参数这里有一个容易看走眼的地方flat[i]是np.uint8类型的标量按位运算后仍是np.uint8直接赋值没有问题。如果载体是从 PIL 读出的普通uint8数组这一步不会有类型异常但如果你自己构造了float64数组按位运算会直接报 TypeError。所以接口层必须保证carrier.dtype np.uint8。4.3 提取函数先读长度、再按位还原、最后解码提取是嵌入的逆过程不需要知道消息内容只需要从图像数组的最低比特位抠出二进制串先读前 32 位得到长度再根据长度切出正文。def lsb_extract(stego: np.ndarray, bits_per_channel: int 1) - str: flat stego.flatten() bits [] for i in range(len(flat)): if bits_per_channel 1: bits.append(str(flat[i] 0x01)) else: bits.append(f{(flat[i] 0x03):02b}) bit_str .join(bits) length int(bit_str[:32], 2) if length 0 or length * 8 32 len(bit_str): raise ValueError(未检测到有效消息可能图像未嵌入或格式不符) payload_bits bit_str[32:32 length * 8] payload bytearray( int(payload_bits[i:i 8], 2) for i in range(0, len(payload_bits), 8) ) return bytes(payload).decode(utf-8)这一段有几层校验逻辑先读长度再判断长度是否合法。最典型的情况是提取一张从未嵌入过的图像这时前 32 位是随机像素的最低比特拼出来可能是一个巨大的长度值length * 8 32 len(bit_str)这个条件会立刻把它拦下来避免后面bytearray去切一个越界的切片。decode(utf-8)如果遇到非法字节会抛 UnicodeDecodeError所以在接口层要捕获并返回“图片不是有效载密图”的提示而不是 500 错误。4.4 把算法接进 Flask 接口图片内存对象不落盘的完整链路算法模块写好后接口层只是一个薄薄的壳。嵌入接口的关键是把 PIL 读入的图像对象转成 NumPy 数组调用上面的lsb_embed再落盘保存最后把记录写入 MySQL。import os import base64 from uuid import uuid4 from flask import Flask, request, jsonify from PIL import Image import numpy as np from io import BytesIO app Flask(__name__) app.post(/api/embed) def embed_api(): f request.files.get(image) secret request.form.get(secret, ) seed request.form.get(seed, ) if f is None or not secret: return jsonify(code1, msg图片和消息不能为空), 400 img Image.open(f.stream).convert(RGB) carrier np.array(img) try: stego lsb_embed(carrier, secret) except ValueError as e: return jsonify(code1, msgstr(e)), 400 stego_img Image.fromarray(stego) stego_id uuid4().hex save_path os.path.join(uploads, stego_id .png) stego_img.save(save_path, formatPNG) # 写数据库这里省略连接池实际用 pymysql 插入 stego_record return jsonify(code0, stego_idstego_id, path/api/stego/ stego_id)逻辑说明request.files.get(image)从 multipart 里拿文件Image.open只在内存里解析不落原图convert(RGB)强制三通道这一步直接规避 RGBA 通道数不一致的问题lsb_embed抛出的 ValueError 被捕获后转成 400 响应把“消息过长”这类用户可理解的错误返回给前端。保存时固定用 PNG 格式因为 PNG 是无损压缩能保证写入最低位的信息在落盘后原样保留而 JPEG 保存会破坏隐藏内容。验证接口是否通可以不先开前端页面直接一行 curl 测试curl -F imagecover.png -F secret毕设验证消息 http://127.0.0.1:5000/api/embed返回的 JSON 里拿到stego_id后再写一个提取接口对uploads目录下的文件做逆操作整条链路就闭环了。这里我习惯把上传目录和静态访问路径分开/uploads用于落盘/api/stego/id用于前端展示这样文件物理路径不暴露在前端代码里答辩时对“路径穿越”“越权访问”这类问题也有个说辞。5. 避坑清单LSB 毕设里最容易翻车的 5 个位置现象、原因、解决5.1 现象嵌入后图片能保存能显示提取却乱码原因图片不是标准 RGB 三通道。手机截图或带透明底的 PNG 是 RGBA 四通道灰度图是单通道算法按三通道flatten嵌入提取时却可能按四通道读取比特位序列完全错位解码结果自然是一堆乱码。解决办法是在入口统一图像模式嵌入和提取两端都执行Image.open(path).convert(RGB)不要只在嵌入端转提取端也必须一致。这条是我自己踩过的真实翻车现场耗时最长原因却最简单。5.2 现象浏览器预览好好的服务器上一提取就失败原因前端做了图片压缩。很多前端实现会在上传前用canvas.toDataURL(image/jpeg)把图片转一遍目的是减小体积但 JPEG 压缩会丢掉最低位信息。嵌入用的载体经过压缩后最低位已经不是算法写入时的值了。解决上传组件不做任何压缩、不转格式、不重绘File对象原样进 FormData。如果一定要限制大小统一限制在 PNG 文件上做体积判断而不是把图像重采样后再发。5.3 现象MySQL 存密钥报 Incorrect string value原因密钥或种子如果是原生的 bytes 类型直接塞进 VARCHAR 字段MySQL 的 utf8mb4 字符集会拒绝一部分非法字节序列。解决把密钥种子转成十六进制字符串再入库读取时先hex再转回 bytes。这个坑在表设计时就要预防key_seed VARCHAR(64)存的就是 32 位十六进制字符串而不是原始字节。另一个 MySQL 相关的老坑是连接阶段报Authentication plugin caching_sha2_password cannot be loaded之类的问题多数不是数据库坏了而是客户端驱动版本不够升级到较新的 PyMySQL 或给 MySQL 用户指定mysql_native_password即可。5.4 现象消息超过容量时被静默截断答辩演示返工原因实现里只判断了len(secret)字符串长度没判断编码后的字节长度。中文在 UTF-8 下一个字占 3 字节同样字符数产生的比特流可能差三倍限长判断形同虚设。消息一旦超过容量嵌入循环只处理前 N 位提取端按长度头读到的数据和正文对不上解码抛 UnicodeDecodeError。解决就是 4.2 节里那段len(bit_str) capacity的检查并且把错误信息带出可用容量让前端能直接提示用户“消息过长最多可输入 XXX 字”。5.5 现象96KB 图片回显时前端内存飙升原因后端把载密图转 base64 通过 JSON 返回base64 会让字节流膨胀约 33%如果前端再把这段字符串拼到data:image/png;base64,...放进 img 标签浏览器相当于同时持有原始 base64 字符串和解码后的位图数据页面一多就卡。解决后端落盘后直接返回静态访问路径前端只把路径赋给img.src。如果不想落盘也要至少用/api/stego/id这个接口配合send_file按需读取而不是一股脑塞进 JSON。6. 进阶验收PSNR、直方图和嵌入容量给答辩准备三组可复现数字6.1 用 PSNR 把“看起来没区别”变成数字答辩时最容易被追问的就是“你怎么证明嵌入前后图像质量没有明显下降”。PSNR 是通用的客观指标计算方式是把原图和载密图逐像素做均方误差再换算成峰值信噪比。一轮 LSB 嵌入跑下来PSNR 通常在 50dB 上下这个数值远超 30dB 的常见可接受线可以作为你验收脚本里的硬性指标。def psnr(original: np.ndarray, stego: np.ndarray) - float: mse np.mean((original.astype(float) - stego.astype(float)) ** 2) if mse 0: return float(inf) return round(10 * np.log10(255.0 ** 2 / mse), 2)对应地可以顺手把嵌入位数调成 2 再算一次 PSNR展示容量翻倍后 PSNR 约下降 6dB 的规律这组对比数据很有说服力。6.2 用直方图对照找出嵌入分布的异常PSNR 看整体直方图看分布。嵌入前后两张图的灰度直方图应该高度重合只在部分灰阶上出现微小的起伏。如果直方图出现明显的周期性阶梯说明你的嵌入位参数没控制好或者原图本身是调色板 PNG图像模式被错误地当成 RGB 处理。用 matplotlib 把两条直方图叠着画把图存进 LW 文档答辩时一张图就能解释清楚“为什么人眼察觉不到变化”。6.3 一套容量测算表答辩现场直接念我把常用分辨率的容量算好放进表格答辩时可以直接引用512×512 彩色图嵌入 1bit 能容纳约 98300 个英文字符或 32700 个中文字符分辨率翻倍容量翻四倍嵌入位数从 1 调到 2 容量翻倍但 PSNR 会降。这个表也决定了前端输入框的maxlength该怎么设别让用户输入一万字然后后端报“消息过长”的错。等效换算成文件大小一张 512×512 的 PNG 载密图可以直接塞进一个约 96KB 的文本文件这就是这个方案能力的边界。回头看我这个项目第一次返工就是在格式约定上吃了亏前端把图转了一遍 JPEG所有后续调试都被“提取出来乱码”带偏方向。后来养成的习惯是先把输入输出格式写进接口文档的第一行再写算法。这个习惯帮我省下的时间远超当时找 bug 用的三天。希望帮到你。本文还有配套的精品资源点击获取