DEV Community

Erik
Erik

Posted on • Edited on

Let's Hide a Secret Message in an Image with Python and OpenCV

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]
  ]
]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
codess_aus profile image
Michelle Mei-Ling Sandford

//decode should be this, not as shown above as there is no decode attribute defined:

import stego
encoded_image = "EncodedImage.png"
stego.decode_image(encoded_image)


Collapse
 
erikwhiting88 profile image
Erik

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.

Collapse
 
iceorfiresite profile image
Ice or Fire

Interesting post. Thanks for sharing!