Today we're going to use the OpenCV library cv2
to encode and decode secret messages inside an image file. I don't mean we're going to make tiny text and hide it in the corner, we're going to dig deeper than that. Fire up your favorite editor and a Python console, this actually won't take too long.
Before We Begin
If you don't want to sit through me talking about how this works and what steganography is and kuzza kuzza kuzza then just head over to my Github gist here
How does this work??
I'm glad you asked. First, you need a quick crash course in pixels. Your screen is made up of pixels, and probably quite a few of them. My monitor resolution says "1920x1080" which means my screen is 1920 pixels across and 1080 pixels deep, and 2,073,600 pixels altogether. Each one of those pixels is capable of emitting a light within a color range. The color of light it emits is (normally) based on a combination of the colors red green and blue. The intensity of each color can be from 0 to 255. In other words, RGB(255, 255, 255) is white, RGB(0, 0, 0) is black, and RGB(113, 238, 184) is seafoam green.
Image files are basically a serialization of an image's pixels and RGB values. The file tells the computer which pixels to light up and with which color. When we use the cv2
function imread
and pass it an image file, that image file is translated into a numpy array containing the RGB value for each pixel in the image.
To illustrate, imagine we had a tiny tiny 4 pixel picture (2 pixels by 2 pixels) of a 4 tile black and white checkerboard. The numpy array would essentially look like this:
[
[
[255, 255, 255], [0, 0, 0]
],
[
[0, 0, 0], [255, 255, 255]
]
]
Note: This isn't an exact replication, there is sometimes other data in the array, but this is enough for your to understand
That's your pixel crash course, we'll come back to this in a bit.
What is Steganography?
Steganography is the practice of hiding secret data inside non-secret data. Most of the time, we're talking about hiding messages inside image files, but it doesn't have to be image files.
Let's say I want to share a secret recipe with you. I could pull up my favorite picture of you and me, encode the recipe into the picture, and post it on your Facebook wall or something. To the world, it looks like I just sent you a cool picture. But since you have the decoder, you run the image through it and now you know how to make my grandmother's world famous cookies.
Do you get what this is now? Good! Let's put it into practice.
The Code
Encode
Here's what we're going to do. First, we're going to take a secret message and translate it into unicode numbers with the ord()
function. We're going to do this with a function that returns a Python generator object:
def char_generator(message):
for c in message:
yield ord(c)
If you're not sure what a generator is, don't worry too much for this lesson. Basically a generator will give us values one at a time by using next(generator)
Ok, that was easy. Now, we'll want to take in our images and return them as numpy arrays. Luckily, we have the OpenCV cv2
library for that. You can install it with pip install opencv-python
and pip install opencv-contrib-python
. After those install, put import cv2
at the top of your script. Let's write the function that returns the numpy-ified image:
def get_image(image_location):
img = cv2.imread(image_location)
return img
Easy.
So now I have a big ol' array of RGB values for the picture, and a generator that spits out numbers from letters. What I want to do now is iterate through the image file and change one of the three RGB values with the unicode number of the secret message.
There's a problem though. If I iterate through and sequentially change each pixel in order, I run the risk of altering the image so much that it's obvious I've changed it. So I definitely don't want to change one pixel after another.
The pattern in which you decide to pick the pixels to alter is up to you, but I'm going to use the GCD of the image array. Behold:
def gcd(x, y):
while(y):
x, y = y, x % y
return x
All this is doing is picking the greatest common denominator of x and y. We're going to send the length and width of the image to this function and use it to decide which pixels we're going to manipulate. So if the GCD is 7, we're going to grab every seventh pixel and hide the next part of our secret message in it.
There's another problem though. How will we tell the decoder when we're done? Well, you can do whatever you want, but I'm going to set the next pixel to be picked to 0. Since the character represented by unicode 0 can't be imitated on a keyboard, our decoder can assume that a 0 means the message is over.
Ok, so let's write the actual image encoding function. We're going to pass it the image location and secret message. Then we'll make a numpy array of that image, create a generator object for the secret message, and calculate the GCD of the image. Finally, we'll write the ugliest nested loop ever to actually encode the picture. Observe:
def encode_image(image_location, msg):
img = get_image(image_location)
msg_gen = char_generator(msg)
pattern = gcd(len(img), len(img[0]))
for i in range(len(img)):
for j in range(len(img[0])):
if (i+1 * j+1) % pattern == 0:
try:
img[i-1][j-1][0] = next(msg_gen)
except StopIteration:
img[i-1][j-1][0] = 0
return img
I know, yuck right? Notice a couple of things about this function: We check and see that the product of the height and width coordinates are divisible by the GCD (the pattern
variable). Before that though, we actually add one to the iterators. Why? Because if we are on the first iteration of either the height or width, we are multiplying a zero. And 0 % anything
is 0, which means we'd be writing to one column or row only. This would make it obvious that the picture has been altered.
Anyway, if our pixel is a multiple of the GCD, we try
to get the next character in the secret message generator. When you call next
on a generator after it's generated everything, you get a StopIteration
exception, hence our except
block. When we hit that block, we know the message is over, so we set that value to zero.
This function does not include saving the new encoded image to disk, so let's fire up a python console. Start the console in the directory with the python script file (I called mine stego.py) and have an image file ready to manipulate. Mine was called "ErikAndFubar.png" (my profile picture).
>>> import stego, cv2
>>> file_location = "ErikAndFubar.png"
>>> secret_message = "Hello from inside the picture!"
>>> encoded_image = stego.encode_image(file_location, secret_message)
>>> cv2.imwrite("EncodedImage.png", encoded_image)
We didn't talk about cv2.imwrite
but if you can't tell, it's just saving the image file. After you save it, go look at the picture. I bet you can't tell there's anything different about it.
Decode
Now that we have our picture encoded, let's write the decoder function. We'll pass the function the file location, generate another numpy array out of it, figure out the GCD so we know where the secret bits are, then iterate through the picture again. Like dis:
def decode_image(img_loc):
img = get_image(img_loc)
pattern = gcd(len(img), len(img[0]))
message = ''
for i in range(len(img)):
for j in range(len(img[0])):
if (i-1 * j-1) % pattern == 0:
if img[i-1][j-1][0] != 0:
message = message + chr(img[i-1][j-1][0])
else:
return message
This loop is a little easier to reason with, but not by much. We iterate over the image-array again, stopping on the multiples of the GCD, and grabbing the numeric value of pixel's red value as long as it's not zero. When we have the value, we append the output of chr
to it, which takes a unicode number and returns the letter (So chr(103)
gives us 'g'
). If the value we grabbed is zero, we know we've gotten to the end of the message, so we return the message we got.
Let's check it out in the console:
>>> import stego
>>> encoded_image = "EncodedImage.png"
>>> stego.decode_image(encoded_image)
'Hello from inside the picture!'
Ta-da!
The entirety of the script:
import cv2
def char_generator(message):
for c in message:
yield ord(c)
def get_image(image_location):
img = cv2.imread(image_location)
return img
def gcd(x, y):
while(y):
x, y = y, x % y
return x
def encode_image(image_location, msg):
img = get_image(image_location)
msg_gen = char_generator(msg)
pattern = gcd(len(img), len(img[0]))
for i in range(len(img)):
for j in range(len(img[0])):
if (i+1 * j+1) % pattern == 0:
try:
img[i-1][j-1][0] = next(msg_gen)
except StopIteration:
img[i-1][j-1][0] = 0
return img
def decode_image(img_loc):
img = get_image(img_loc)
pattern = gcd(len(img), len(img[0]))
message = ''
for i in range(len(img)):
for j in range(len(img[0])):
if (i-1 * j-1) % pattern == 0:
if img[i-1][j-1][0] != 0:
message = message + chr(img[i-1][j-1][0])
else:
return message
Some Final Thoughts
We learned how to hide secret messages in pictures today, but is that all we learned? When I decided I wanted to try to learn how to do this, I didn't know about generators, nor did I know about the chr
and ord
functions. So I learned something from this little side project aside from just the thing I was trying to do. That's why side projects are important. If you keep them fun and interesting, you might learn something you didn't even know existed.
Also, this is not a very good encoder. We always change the first value of RGB (the red pixel) when really we should be switching it up. How would you change that?
On an even more basic level, we don't do any user validation like checking to see if there's actually a valid picture location being passed. Nor do we check to see if our picture is big enough to handle the length of the message. I leave these enhancements as an exercise for the reader.
Please don't hesitate to let me know if you have any questions!
Top comments (3)
//decode should be this, not as shown above as there is no decode attribute defined:
oh my gosh! I can't believe I missed that. Thanks for catching it and leaving a comment, I've fixed it in the article.
Interesting post. Thanks for sharing!