Fundamentals of Computer Science I: Media Computing (CS151.01 2008S)

Pixel Maps: A Technique for Storing Images in Files


Summary: We consider ways to store a representation of an image in a file. In particular, we consider how to store an image by writing each pixel, in turn, to the a file, and how to restore such an image from a file. Along the way, we revisit the issue of how to represent certain kinds of values.

Introduction

For much of this semester, we've been looking at how one creates or modifies existing images. But what happens after an image is created? Typically, people save their images to disk to look at them later, to update them later, or to share them with others. Right now, we treat saving and loading images as primitive operations. You load an image with image-load. You save an image using the Save menu item in The GIMP. (Why did we not provide a procedure for saving images? Because there are too many possible variants to that procedure.)

However, now that you know how to write files, you can also write procedures that save and restore images. Doing so requires that you think about how to store colors and how to map the pixels in an image into colors (yes, there is an obvious answer), and that you can restore images from whatever form you've chosen.

Writing and Reading Colors

Let's start with the core operation necessary for writing images: writing and reading individual colors. Writing a color should be fairly straightforward: We just use the write procedure to write the color value. We could then use the read function to restore the color. Let's try that.

> (define out (open-output-file "colors.txt"))
> (write color-blue out)
> (close-output-port out)

What's in that file? We can look by opening it in DrFu, by opening it in a text editor, or with some of the simple input functions, such as read.

65535

Since the file looks okay, we can try reading the color back from the file.

> (define in (open-input-file "colors.txt"))
> (define color (read in))
> (close-input-port in)
> (rgb->string color)
"0/0/255"

Things look like they're working fine, so let's try something a little bit more complex. Let's write two colors to the file.

> (define out (open-output-file "colors.txt"))
> (write color-white out)
> (write color-blue out)
> (close-output-port out)

Okay, what does the file look like now? Let's see.

-165535

That's a bit odd, isn't it? We see the -1, which represents white, and we see the 65535, which seems to represent blue, but there's nothing to designate where the first color ends and the second color begins. This could just as easily be -16 and 5535 or -165 and 535 or .... So, what does DrFu think it is?

> (define in (open-input-file "colors.txt"))
> (define color (read in))
> color
-165535
> (rgb->string color)
"255/253/121"
> (rgb->cname color)
"popcorn"
> (define color (read in))
> color
<eof>
> (close-input-port in)

No, that doesn't look very good, does it? We've combined two colors into one, but the one color we ended up with isn't very sensible. That's certainly not going to be a useful technique if we want to restore the original colors in our image. However, the solution is simple: We just put a space or carriage return between colors.

> (define out (open-output-file "colors.txt"))
> (write color-white out)
> (newline out)
> (write color-blue out)
> (newline out)
> (close-output-port out)

Now, what does the file look like?

-1
65535

That's much better. Now, let's make sure that we can read the values back from the file.

> (define in (open-input-file "colors.txt"))
> (define color (read in))
> color
-1
> (rgb->string color)
"255/255/255"
> (define color (read in))
> color
65535
> (rgb->string color)
> (define color (read in))
> color
<eof>
> (close-input-port in)

Okay, that's much better. Let's now encapsulate those ideas into two procedures, one to write colors and one to read colors.

;;; Procedure:
;;;   rgb-write
;;; Parameters:
;;;   color, an rgb color
;;;   port, the port to which to write the color
;;; Purpose:
;;;   Write the color to the specified port
;;; Preconditions:
;;;   port is open for writing.
;;; Postconditions:
;;;   When read with rgb-read, the data just appended to the file
;;;   will give color.
 (define rgb-write
  (lambda (color port)
    (write color port)
    (newline port)))

Let's see what happens when we write a few colors to a file.

> (define colors (open-output-file "colors.txt"))
> (rgb-write color-aquamarine colors)
> (rgb-write color-bright-gold colors)
> (rgb-write color-firebrick colors)
> (rgb-write color-mandarian-orange colors)
> (rgb-write color-off-white-green colors)
> (rgb-write color-quartz colors)
> (rgb-write color-teal colors)
> (close-output-port colors)

Okay, what does the file look like?

1893438463
-640083457
-1910299649
-461884417
-855651073
-640027649
882866687

That's not very readable, is it? Arguably, it doesn't need to be, as long as the computer can read it back. And the computer can read it back. Here's a procedure that does just that. Since we've written with write, we can simply read the value back directly with read. For uniformity (and so we can change the implementation later), we write a separate rgb-read function.

;;; Procedure:
;;;   rgb-read
;;; Parameters:
;;;   port, the name of an input port
;;; Purpose:
;;;   Reads an RGB color from the file.
;;; Produces:
;;;   color, an RGB color (or <eof>, if the port is at the end of
;;;     the file)
;;; Preconditions:
;;;   port is open for reading.
;;;   port is at the place in a file after which the next data was
;;;     written by rgb-write.
;;; Postconditions:
;;;   color is the color written by the call to rgb-write mentioned
;;;     in the preconditions.
 (define rgb-read
  (lambda (port)
    (read port)))

Now, let's try reading back from the file.

> (define colors (open-input-file "colors.txt"))
colors
> (define color (read colors))
color
> color
1893438463
> (rgb->string color)
"112/219/147"
> (rgb->cname color)
"aquamarine"
> (define color (read colors))
color
> color
-640083457
> (rgb->string color)
"217/217/25"
> (rgb->cname color)
"bright gold"
> (define color (read colors))
color
> color
-1910299649
> (rgb->string color)
"142/35/35"
> (rgb->cname color)
"firebrick"
> (define color (read colors))
color
> color
-461884417
> (rgb->string color)
"228/120/51"
> (rgb->cname color)
"mandarian orange"
> (define color (read colors))
color
> color
-855651073
> (rgb->string color)
"204/255/204"
> (rgb->cname color)
"off-white green"
> (define color (read colors))
color
> color
-640027649
> (rgb->string color)
"217/217/243"
> (rgb->cname color)
"quartz"
> (define color (read colors))
color
> color
882866687
> (rgb->string color)
"52/159/121"
> (rgb->cname color)
"teal"
> (define color (read colors))
color
> color
<eof>
> (close-input-port colors)
#t

As the preceding suggests, we can certainly print the values from the file using rgb->string and rgb->cname. But what if we wanted the file to be readable by both human and computer? If our goal is to make things readable, we should write colors in a way that at least some humans can read them. For example, we can instead write the red, green, and blue components, separated by spaces.

(define rgb-write
  (lambda (color port)
    (write (rgb-red color) port)
    (display " " port)
    (write (rgb-green color) port)
    (display " " port)
    (write (rgb-blue color) port)
    (newline port)))

The output file produced from this procedure is clearly much more readable.

112 219 147
217 217 25
142 35 35
228 120 51
204 255 204
217 217 243
52 159 121

Of course, we now need a slightly more complicated mechanism for reading. In particular, we need to read each component separately, and then combine them together. The following procedure does just that. (See, there's a reason we wrote and used rgb-read above, rather than just using read.)

(define rgb-read
  (lambda (port)
    (let* ((red (read port))
           (green (read port))
           (blue (read port)))
       (rgb-new red green blue))))

However, there is a bug in this procedure. Can you tell what it is? The problem is that we have no way to tell when we've reached the end of the file.

Now what should we do? We should check to make sure that none of the components is the eof object. Fortunately, because of the design of read, if red is the eof object, then so are green and blue. Similarly, if green is the eof object, then so is blue. Hence, we only need to check if blue is the eof object. What should we do if it is? Well, the standard is that procedures that read values from files return the eof object at the end of file, so we will also return the eof object (conveniently stored in blue).

(define rgb-read
  (lambda (port)
    (let* ((red (read port))
           (green (read port))
           (blue (read port)))
       (if (eof-object? blue) 
           blue
           (rgb-new red green blue)))))

Writing Pixmaps to Files

We now know how to write colors, but what about whole images? Let's start with a four-by-three image. What should we do? Write the first row, then the second row, and then the third row.

> (define stored-image (open-output-file "small-image.pixmap"))
> (rgb-write (image-get-pixel canvas 0 0) stored-image)
> (rgb-write (image-get-pixel canvas 1 0) stored-image)
> (rgb-write (image-get-pixel canvas 2 0) stored-image)
> (rgb-write (image-get-pixel canvas 3 0) stored-image)
> (rgb-write (image-get-pixel canvas 0 1) stored-image)
> (rgb-write (image-get-pixel canvas 1 1) stored-image)
> (rgb-write (image-get-pixel canvas 2 1) stored-image)
> (rgb-write (image-get-pixel canvas 3 1) stored-image)
> (rgb-write (image-get-pixel canvas 0 2) stored-image)
> (rgb-write (image-get-pixel canvas 1 2) stored-image)
> (rgb-write (image-get-pixel canvas 2 2) stored-image)
> (rgb-write (image-get-pixel canvas 3 2) stored-image)

Of course, this works only for a four-by-three image. As has been our practice throughout this course, we should think about how to generalize what we just did, so that (a) we can avoid writing repetitious code and (b) we can write code that will work for different sizes of images. What happened in the preceding? We wrote the first row of the image and then we wrote the second row. How did we know when we were done with the first row? When we'd written the last column in that row. What did we do next? We added 1 to the row and reset the column to 0. How did we know that we were done with the image? When we'd finished the last row.

In effect, we need to recurse over two values, the current column and the current row. As we just noted, most of the time we just increment the column. When we reach the end of a row (when the column is greater than or equal to the width), we move on to the next row (incrementing the row and resetting the column).

We'll also find it useful to name the width and height of the image and to name the open port. We can do all three with a let.

Putting it all together, we get

;;; Procedure:
;;;   image-write-pixmap
;;; Parameters:
;;;   image, an image
;;;   filename, a string
;;; Purpose:
;;;   Writes the pixmap information on image to the file.
;;; Produces:
;;;   [Nothing; Called for the side effect.]
;;; Preconditions:
;;;   filename is a valid file name.
;;; Postconditions:
;;;   The file named by filename now contains a sequence of integers,
;;;   one for each RGB color in image.
(define image-write-pixmap
  (lambda (image filename)
    (let ((width (image-width image))
          (height (image-height image))
          (port (open-output-file filename)))
      (let kernel ((col 0)
                   (row 0))
         (cond
           ((>= row height)
            (close-output-port port)
            image)
           ((>= col width)
            (kernel 0 (+ row 1)))
           (else
            (rgb-write (image-get-pixel image col row) port)
            (kernel (+ col 1) row)))))))

Reading Pixmaps from Files

We've saved all of the pixels from the image into a file. Now we need a way to read them back. Once again, let's start by writing code that does each pixel by hand. That is, we'll read a color and then set the appropriate pixel to that color. How do we know what pixel to set? We set them in the same order that we wrote them (we do them a row at a time, traversing each row from left to right).

> (define picture (open-input-file "small-image.pixmap"))
> (image-set-pixel! canvas 0 0 (rgb-read picture))
> (image-set-pixel! canvas 1 0 (rgb-read picture))
> (image-set-pixel! canvas 2 0 (rgb-read picture))
> (image-set-pixel! canvas 3 0 (rgb-read picture))
> (image-set-pixel! canvas 0 1 (rgb-read picture))
> (image-set-pixel! canvas 1 1 (rgb-read picture))
> (image-set-pixel! canvas 2 1 (rgb-read picture))
> (image-set-pixel! canvas 3 1 (rgb-read picture))
> (image-set-pixel! canvas 0 2 (rgb-read picture))
> (image-set-pixel! canvas 1 2 (rgb-read picture))
> (image-set-pixel! canvas 2 2 (rgb-read picture))
> (image-set-pixel! canvas 3 2 (rgb-read picture))
> (close-input-port picture)

Of course, nothing about the file tells us the size of the image. Hence, we could just as easily read the file back into a 6x2 image.

> (define picture (open-input-file "small-image.pixmap"))
> (image-set-pixel! canvas 0 0 (rgb-read picture))
> (image-set-pixel! canvas 1 0 (rgb-read picture))
> (image-set-pixel! canvas 2 0 (rgb-read picture))
> (image-set-pixel! canvas 3 0 (rgb-read picture))
> (image-set-pixel! canvas 4 0 (rgb-read picture))
> (image-set-pixel! canvas 5 0 (rgb-read picture))
> (image-set-pixel! canvas 0 1 (rgb-read picture))
> (image-set-pixel! canvas 1 1 (rgb-read picture))
> (image-set-pixel! canvas 2 1 (rgb-read picture))
> (image-set-pixel! canvas 3 1 (rgb-read picture))
> (image-set-pixel! canvas 4 1 (rgb-read picture))
> (image-set-pixel! canvas 5 1 (rgb-read picture))
> (close-input-port picture)

Is it a problem that we can read an image into a different shape of image? Let's say “No” for right now, since it lets us get some interesting effects by reading images back from files into a different region. (You'll try doing so on the lab.) We could also choose to write the size of the image to the beginning of the file.

We are now ready to generalize. As in the case of reading pixmaps, we need to open ports, determine width and height, and then read one color at a time.

;;; Procedure:
;;;   image-read-pixmap!
;;; Parameters:
;;;   image, an image
;;;   filename, a string
;;; Purpose:
;;;   Read pixmap data from the specified file, storing the results
;;;   into image.
;;; Produces:
;;;   [Nothing; called for the side effect]
;;; Preconditions:
;;;   filename names a file.
;;;   That file was created by image-write-pixmap.
;;;   That file contains (image-width image)x(image-height image)
;;;     colors.
;;; Postconditions:
;;;   image has been updated to contain the image described by file.
(define image-read-pixmap!
  (lambda (image filename)
    (let ((width (image-width image))
          (height (image-height image))
          (port (open-input-file filename)))
      (let kernel ((col 0)
                   (row 0))
         (cond
           ((> (+ col row) (+ (- width 1) (- height 1)))
            (let ((next-color (rgb-read port)))
              (close-input-port port)
              (if (not (eof-object? next-color))
                  (throw "image-read-pixmap!: Data remain in file after image was competely read")))
            image)
           ((eof-object? (peek-char port))
            (close-input-port port)
            (throw "image-read-pixmap!: Premature end of file."))
           ((>= col width)
            (kernel 0 (+ row 1)))
           (else
            (let ((next-color (rgb-read port)))
              (cond 
                ((eof-object? next-color)
                 (close-input-port port)
                 (throw "image-read-pixmap!: Premature end of file."))
                (else
                 (image-set-pixel! image col row next-color)
                 (kernel (+ col 1) row))))))))))

You'll note that we've added a bit of error checking here. If the file is the wrong size (too few colors or too many colors), it still updates the image. In the lab, we consider how to ensure that the image has exactly the same dimensions.

A Note on Program Design: The Value of Encapsulation

You'll note that image-write-pixmap calls rgb-write rather than using instructions to write the components directly. Similarly, image-read-pixmap uses rgb-read rather than three calls to read to get the three components.

Why did we make that choice? Since we're still thinking about how we store images, it makes it much easier to change our minds. If we come up with another way to store colors (and we will), rather than changing all the procedures that write and read colors, we need only change two function: rgb-write and rgb-read.

More generally, when you design a new data type or a new use of an old data type, it is helpful to encapsulate the algorithms you want into a library of procedures, and to only use those procedures to directly manipulate the data. We have used this technique before (e.g., for spots) and will use it again. While it may be tempting to just use the body of one of these library procedures in your code (as many of you did for spots), by using the library procedures you make it much easier to update your design.

Creative Commons License

Samuel A. Rebelsky, rebelsky@grinnell.edu

Copyright (c) 2007-8 Janet Davis, Matthew Kluber, and Samuel A. Rebelsky. (Selected materials copyright by John David Stone and Henry Walker and used by permission.)

This material is based upon work partially supported by the National Science Foundation under Grant No. CCLI-0633090. Any opinions, findings, and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation.

This work is licensed under a Creative Commons Attribution-NonCommercial 2.5 License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc/2.5/ or send a letter to Creative Commons, 543 Howard Street, 5th Floor, San Francisco, California, 94105, USA.