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 or with (image-save
image filename).
Of course, these procedures obscure the underlying file representation
of images. Now that you know how to write files, you can also write
your own procedures that save and restore images in a representation
you design. 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 (rgb-new 255 0 255) out)
> (close-output-port out)
What's in that file? We can look by opening it in MediaScript, by
opening it in a text editor, or with some of the simple input functions,
such as read.
16711935
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)
"255/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.
> (delete-file "colors.txt")
> (define out (open-output-file "colors.txt"))
> (write (rgb-new 0 0 255) out)
> (write (rgb-new 255 0 255) out)
> (close-output-port out)
Okay, what does the file look like now? Let's see.
25516711935
That's a bit odd, isn't it? We see the 255, which seems to
represent
blue, and we see the 16711935, which seems to represent purple,
but there's nothing to designate where the first color ends and the second
color begins. This could just as easily be 2551 and 6711935 or 255167 and
11935 or .... So, what does our Scheme interpreter think it is?
> (define in (open-input-file "colors.txt"))
> (define color (read in))
> color
25516711935
> (rgb->string color)
rgb->string: expects type <rgb> for 1st parameter, given 25516711935
in (rgb->string 25516711935)
> (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 thing, but that thing isn't even a color.
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.
> (delete-file "colors.txt")
> (define out (open-output-file "colors.txt"))
> (write (rgb-new 0 0 255) out)
> (newline out)
> (write (rgb-new 255 0 255) out)
> (newline out)
> (close-output-port out)
Now, what does the file look like?
255
16711935
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
255
> (rgb->string color)
"0/0/255"
> (define color (read in))
> color
16711935
> (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. We'll
start with the one that writes colors.
Let's see what happens when we write a few colors to a file.
> (delete-file "colors.txt")
> (define colors (open-output-file "colors.txt"))
> (rgb-write (color->rgb "aquamarine") colors)
> (rgb-write (color->rgb "gold") colors)
> (rgb-write (color->rgb "firebrick") colors)
> (rgb-write (color->rgb "darkkhaki") colors)
> (rgb-write (color->rgb "lightseagreen") colors)
> (rgb-write (color->rgb "teal") colors)
> (rgb-write (color->rgb "bisque") colors)
> (close-output-port colors)
Okay, what does the file look like?
8388564
16766720
11674146
12433259
2142890
32896
16770244
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.
Now, let's try reading back from the file.
> (define colors (open-input-file "colors.txt"))
> (define color (rgb-read colors))
> color
8388564
> (rgb->string color)
"127/255/212"
> (rgb->color-name color)
"aquamarine"
> (define color (rgb-read colors))
> color
16766720
> (rgb->string color)
"255/215/0"
> (rgb->color-name color)
"gold"
> (define color (rgb-read colors))
> color
11674146
> (rgb->string color)
"178/34/34"
> (rgb->color-name color)
"firebrick"
> (define color (rgb-read colors))
> color
12433259
> (rgb->string color)
"189/183/107"
> (rgb->color-name color)
"darkkhaki"
> (define color (rgb-read colors))
> color
2142890
> (rgb->string color)
"32/178/170"
> (rgb->color-name color)
"lightseagreen"
> (define color (rgb-read colors))
> color
32896
> (rgb->string color)
"0/128/128"
> (rgb->color-name color)
"teal"
> (define color (rgb-read colors))
> color
16770244
> (rgb->string color)
"255/228/196"
> (rgb->color-name color)
"bisque"
> (define color (rgb-read colors))
> color
#<eof>
> (close-input-port colors)
As the preceding suggests, we can certainly print the
values from the file using rgb->string and
rgb->color-name. 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.
The output file produced from this procedure is clearly much more readable.
127 255 212
255 215 0
178 34 34
189 183 107
32 178 170
0 128 128
255 228 196
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.)
However, there is a bug in this procedure. Can you tell what it is?
We'll give you a minute to think about it.
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).
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
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.
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.
Speeding Up Writing and Reading Pixmaps
If you try the procedures above, you'll find that they're a bit pokey.
We can speed them up with two helpful image iteration procedures,
image-scan and
image-calculate-pixels!
image-scan scans through the image,
row by row, and applies a function to the column, row, and color for
each pixel. image-calculate-pixels! also scans
through the image, this time setting the pixel at each position to
the result of applying a function to the column and row of the pixel.
(In contrast to image-compute-pixels!, which
can visit the pixels in any order it finds convenient,
image-calculate-pixels! is guaranteed to do a
row-by-row scan.
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
(such as image-read-pixmap),
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.