Python3图片隐写术

效果展示

2018-02-23 12-58-51屏幕截图.png

可以看到“施法”前后的图片肉眼看不出区别,然而图片却真是的隐藏了一些数据在里面。

程序的实现

先导入 Pillow 模块:

from PIL import Image
  • 编码
    我们首先设计将隐藏信息编码到图片中的函数encodeDataInImage(),其有两个参数,分别是用作载体的图片对象和需要被隐藏的字符串。也就是说我们可以这样调用它:
encodeDataInImage(Image.open("steganographia.png"), '你好世界,Hello World!')

encodeDataInImage()函数如下:

def encodeDataInImage(image, data):
    """
    将字符串编码到图片中
    """
    # 获得最低有效位为 0 的图片副本
    evenImage = makeImageEven(image) 
    # 将需要被隐藏的字符串转换成二进制字符串
    binary = ''.join(map(constLenBin, bytearray(data, 'utf-8'))) 
    if len(binary) > len(image.getdata()) * 4:
        # 如果不可能编码全部数据,跑出异常
        raise Exception("Error: Can't encode more than" + len(evenImage.getdata()) * 4 + " bits in this image. ")
    # 将binary中的二进制字符串信息编码进像素里
    encodedPixels = [(r+int(binary[index*4+0]), g+int(binary[index*4+1]), b+int(binary[index*4+2]), t+int(binary[index*4+3])) if index*4 < len(binary) else (r,g,b,t) for index,(r,g,b,t) in enumerate(list(evenImage.getdata()))]
    # 创建新图片以存放编码后的像素
    encodedImage = Image.new(evenImage.mode, evenImage.size)
    # 添加编码后的数据
    encodedImage.putdata(encodedPixels)
    return encodedImage

makeImageEven()函数的实现如下:

def makeImageEven(image):
    """
    取得一个 PIL 图像并且更改所有值为偶数(使最低有效位为0)
    """
    #得到一个这样的列表:[(r,g,b,t),(r,g,b,t)...]
    pixels = list(image.getdata())
    # 更改所有值为偶数(魔法般的移位)
    evenPixels = [(r>>1<<1,g>>1<<1,b>>1<<1,t>>1<<1) for [r,g,b,t] in pixels]
    # 创建一个相同大小的图片副本
    evenImage = Image.new(image.mode, image.size)
    # 把上面的像素放入到图片副本
    evenImage.putdata(evenPixels)
    return evenImage

encodeDataInImage()中,bytearray()将字符串转换为整数值序列(数字范围是0到2^8-1),数值序列由字符串的字节数据转换而来,如下图:

2018-02-23 15-28-45屏幕截图.png

utf-8编码的中文字符一个就占了3个字节,那么四个字符共占3x4=12个字节,于是共有12个数字。(可以在右下角切换到中文输入法,这样就能输入中文了)
然后map(constLenBin,Bytearray(data, 'utf-8'))对数值序列中的每一个值应用constLenBin()函数,将十进制数值学列转换为二进制自字符转序列。

def constLenBin(int):
    """
    内置函数bin()的替代,返回固定长度的二进制字符串
    """
    #去掉bin()返回的二进制字符串中的'0b',并在左边补足'0'直到字符串长度为8
    binary = "0"*(8-(len(bin(int))-2))+bin(int).replace('0b','')
    return binary

在这里bin()的作用是将一个int值转换为二进制字符串。详见https://docs.python.org/3/library/functions.html#bin

  • 解码
    decodeImage()返回图片解码后的隐藏文字,其接受一个图片对象参数。
def decodeImage(image):
    """
    解码隐藏数据
    """
    pixels = list(image.getdata()) #获得像素列表
    #提取图片中所有最低有效位中的数据
    binary = ''.join([str(int(r>>1<<1!=r))+str(int(g>>1<<1!=g))+str(int(b>>1<<1!=b))+str(int(t>>1<<1!=t)) for (r,g,b,t) in pixels])
    #找到数据截止处的索引
    locationDoubleNull = binary.find('0000000000000000')
    endIndex = locationDoubleNull+(8-(locationDoubleNull %8)) if locationDoubleNull%8 != 0 else locationDoubleNull
    data = binaryToString(binary[0:endIndex])
    return data

找到数据截止处所用的字符串0000000000000000很有意思,它的长度为16,而不是直觉上的8,因为两个包含数据的字节的接触部分可能有8个0。
binaryToString()函数将提取出来的二进制字符串转换为隐藏的文本:

def binaryToString(binary):
    """
    从二进制字符串转为 UTF-8 字符串
    """
    index = 0
    string = []
    rec = lambda x, i: x[2:8] + (rec(x[8:], i-1) if i > 1 else '') if x else ''
    fun = lambda x, i: x[i+1:8] + rec(x[8:], i-1)
    while index + 1 < len(binary):
        chartype = binary[index:].index('0') # 存放字符所占字节数,一个字节的字符会存为0
        length = chartype*8 if chartype else 8
        string.append(chr(int(fun(binary[index:index+length],chartype),2)))
        index += length
    return ''.join(string)

要看明白这个,必须要先搞懂UTF-8编码的方式,可以在wikipedia上了解utf-8编码https://zh.wikipedia.org/wiki/UTF-8
utf-8是UNICODE的一种变长度的编码表达方式,也就是说一个字符串中,不同的字符所占的字节数不一定相同,这就给我们的工作带来了一点复杂度,如果我们要支持中文的话。

选区_001.png

在上图中,只有x所在的位置(也即是字节中第一个0之后的数据)存储的是真正的字符数据,因此我们使用下面两个匿名函数来提取出这些数据:

rec = lambda x, i: x[2:8] + (rec(x[8:], i-1) if i > 1 else '') if x else ''
fun = lambda x, i: x[i+1:8] + rec(x[8:], i-1)

fun()接受2个参数,第一个参数为表示一个字符的二进制字符串,这个二进制字符串可能有不同的长度(8\16\24...48);第二个参数为这个字符占多少个字节。
lambda x, i: x[x+1:8] + rec(x[8:], i-1)x[i+1:8]获得第一个字节的数据,然后调用rec(),以递归的方式提取后面字节中的数据。
这里要提一句,rec = lambda x, i: x[2:8] + (rec(x[8:], i-1) if i > 1 else ' ') if x else ' ',你可能对在表达式里面引用了 rec 感到不可理解,的确,严格意义上这样是不能实现递归的,但在python里这样是可以的,这就是python的语法糖了。
使用lambda表达式写递归从来不是一件简单的事,因为匿名函数引用自身并不简单,大家可以参考一下大牛刘未鹏的博文:http://blog.csdn.net/pongba/article/details/1336028
我们注意到,字符的字节数据中,第一个字节开头1的数目便是字符所占的字节数:

chartype = binary[index:].index('0')

string.append(chr(int(fun(binary[index:index+length],chartype),2)))这一行中用到的函数int()以及chr的作用如下:
int():接受两个参数,第一个参数为数字字符串,第二个参数为这个字符串代表的数字的进度。详见:https://docs.python.org/3/library/functions.html#int
chr():接受一个参数,参数为int值,返回Unicode码点为这个int值的字符。见下图:

2018-02-23 18-21-11屏幕截图.png

while循环的最后我们将当前字符的索引增加当前字符的长度,得到下一个字符的索引。
这样,我们可以识别出二进制字符串中哪些部分代表哪些字符了,然后就能调用fun()取得各个字符的数据了。

  • 测试效果
    输入下面的命令获得测试用的图片:
$ wget http://labfile.oss.aliyuncs.com/courses/651/coffee.png

在我们的代码后添加这两行:

encodeDataInImage(Image.open("coffee.png"), '哆啦A梦的世界,Doraemon World!').save('encodeImage.png')
print(decodeImage(Image.open("encodeImage.png")))

假设你的源代码文件为 steganography.py:

$ python3 steganography

它应该打印出“哆啦A梦的世界,Doraemon World!”

  • 完整代码
from PIL import Image


def makeImageEven(image):
    """
    取得一个 PIL 图像并且更改所有值为偶数(使最低有效位为0)
    """
    #得到一个这样的列表:[(r,g,b,t),(r,g,b,t)...]
    pixels = list(image.getdata())
    # 更改所有值为偶数(魔法般的移位)
    evenPixels = [(r>>1<<1,g>>1<<1,b>>1<<1,t>>1<<1) for [r,g,b,t] in pixels]
    # 创建一个相同大小的图片副本
    evenImage = Image.new(image.mode, image.size)
    # 把上面的像素放入到图片副本
    evenImage.putdata(evenPixels)
    return evenImage

def constLenBin(int):
    """
    内置函数bin()的替代,返回固定长度的二进制字符串
    """
    #去掉bin()返回的二进制字符串中的'0b',并在左边补足'0'直到字符串长度为8
    binary = "0"*(8-(len(bin(int))-2))+bin(int).replace('0b','')
    return binary


def encodeDataInImage(image, data):
    """
    将字符串编码到图片中
    """
    # 获得最低有效位为 0 的图片副本
    evenImage = makeImageEven(image) 
    # 将需要被隐藏的字符串转换成二进制字符串
    binary = ''.join(map(constLenBin, bytearray(data, 'utf-8'))) 
    if len(binary) > len(image.getdata()) * 4:
        # 如果不可能编码全部数据,跑出异常
        raise Exception("Error: Can't encode more than" + len(evenImage.getdata()) * 4 + " bits in this image. ")
    # 将binary中的二进制字符串信息编码进像素里
    encodedPixels = [(r+int(binary[index*4+0]), g+int(binary[index*4+1]), b+int(binary[index*4+2]), t+int(binary[index*4+3])) if index*4 < len(binary) else (r,g,b,t) for index,(r,g,b,t) in enumerate(list(evenImage.getdata()))]
    # 创建新图片以存放编码后的像素
    encodedImage = Image.new(evenImage.mode, evenImage.size)
    # 添加编码后的数据
    encodedImage.putdata(encodedPixels)
    return encodedImage


def binaryToString(binary):
    """
    从二进制字符串转为 UTF-8 字符串
    """
    index = 0
    string = []
    rec = lambda x, i: x[2:8] + (rec(x[8:], i-1) if i > 1 else '') if x else ''
    fun = lambda x, i: x[i+1:8] + rec(x[8:], i-1)
    while index + 1 < len(binary):
        chartype = binary[index:].index('0') # 存放字符所占字节数,一个字节的字符会存为0
        length = chartype*8 if chartype else 8
        string.append(chr(int(fun(binary[index:index+length],chartype),2)))
        index += length
    return ''.join(string)

def decodeImage(image):
    """
    解码隐藏数据
    """
    pixels = list(image.getdata()) #获得像素列表
    #提取图片中所有最低有效位中的数据
    binary = ''.join([str(int(r>>1<<1!=r))+str(int(g>>1<<1!=g))+str(int(b>>1<<1!=b))+str(int(t>>1<<1!=t)) for (r,g,b,t) in pixels])
    #找到数据截止处的索引
    locationDoubleNull = binary.find('0000000000000000')
    endIndex = locationDoubleNull+(8-(locationDoubleNull %8)) if locationDoubleNull%8 != 0 else locationDoubleNull
    data = binaryToString(binary[0:endIndex])
    return data

encodeDataInImage(Image.open("coffee.png"), '哆啦A梦的世界,Doraemon World!').save('encodeImage.png')
print(decodeImage(Image.open("encodeImage.png")))
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271

推荐阅读更多精彩内容

  • Lua 5.1 参考手册 by Roberto Ierusalimschy, Luiz Henrique de F...
    苏黎九歌阅读 13,517评论 0 38
  • 一、Java 简介 Java是由Sun Microsystems公司于1995年5月推出的Java面向对象程序设计...
    子非鱼_t_阅读 4,082评论 1 44
  • 小明是一家中小企业的行政专员,近年来,公司传统的低效办公模式严重影响了公司的运营,公司领导层决定改变办公模式,强化...
    职也阅读 238评论 0 0
  • 今天是5月15号,离2018考研还有222天。 如果仔细来分析一下: 1:一本专八词汇 需要120天的复习时间。(...
    浮光掠影59阅读 234评论 0 0
  • 上一次同样的季节,午夜的风把东边的窗户掀开,橘黄的光顺势涌了进来,朦胧的月光撒下斑斑点点的光辉摩挲着叶片的脉络,桌...
    冷影1277阅读 238评论 0 0