Reading a Single TIFF Pixel Without Any TIFF Tools

Patrick Armstrong
Planet Stories
Published in
9 min readFeb 21, 2019

In my last post, I explored reading TIFF metadata with basic command line tools, with a specific goal of using no TIFF-specific tooling to do this. This time, my goal was to be able to read a single pixel of TIFF image data, again without using any TIFF-specific tools like GDAL or ImageMagick.

Like last time, I will work with a sample image of Los Angeles from our February 2018 Global Basemap.

An image of Los Angeles from Planet’s February 2018 Basemap. ©2018 Planet Labs Inc, CC BY-SA 4.0.

Let’s download it:

$ curl -O https://oldpatricka.com/planet/la.tif

To begin understanding this file, we’re going to decode all of the TIFF tags with a simple dumper tool. You may think this is cheating, but we already learned how to read these tags without special tools in the last post, so we are simply automating what we learned last time. You can get the dumper tool here. Let’s run the script:

$ go run tiff-dump.go la.tif

Version: 42
Byte Order: little endian
IFD 0:<Tag: (0x0100/00256) ImageWidth Type: (03) Short Count: 1 Offset: 0 Value: 4096 FieldTypeSpace: "Default" TagSpaceSet: "Default.Baseline">

You can view the full output of this in this gist.

Reading all the relevant TIFF tags

The tag dump should give us all the information we need to read a pixel out of the image. I’ll summarize the values that are important for us to read a pixel below:

  • ImageWidth: 4096 The width of the image.
  • ImageLength: 4096 The height of the image.
  • PhotometricInterpretation: 2 This is the color space of the image, which means whether the image is black and white, RGB, etc. The value 2 means that this is RGB data.
  • SamplesPerPixel: 4 The number of color channels per pixel. The first three are easy to understand, RGB, but what is the 4th? Well, we can figure that out with:
  • ExtraSamples: 2 The value is set to 2, which means “Associated alpha data (with pre-multiplied color)”. So the fourth colour channel is an alpha channel. What does an alpha channel mean when we’re representing geospatial data? How can part of the earth be transparent? After speaking with fellow Planeteer Kelsey Jordahl, I learned that it really is an alpha channel: but in this case, a transparent pixel means no data! Since Planet doesn’t image in places like the middle of the Pacific Ocean, there needs to be a good way to indicate no data in mosaics.
  • BitsPerSample: [8,8,8,8] This means that each sample is stored with 8 bits.
  • SampleFormat: [1, 1, 1, 1] This means that each sample is unsigned integer data, which means each pixel value is a number from 0–255.
  • PlanarConfiguration: 2 This means that Red,Green,Blue, and Alpha data are stored separately. This is called the Planar Format. Every pixel of colour data is written by writing all of the red values, all of the green values, all the blue values, then all of the alpha values. For example, imagine a 2x2 TIFF file with the same TIFF settings as our sample image. Imagine that each square is a 100% color value for each color sample, and the final value is black, like this:
4x4 pixel TIFF file

The pixel data would be written as in the following diagram. We will pretend it isn’t compressed for now, but we will discuss how compression works later:

                +-------------------------------------------------+
Byte Offset | 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
| |
Color | R R R R G G G G B B B B A A A A |
| |
Value | FF 00 00 00 00 FF 00 00 00 00 FF 00 FF FF FF FF |
+-------------------------------------------------+

Now that we understand how the pixel data is laid out, why don’t we try to read that first pixel?

We‘re close to being able to do that, but we need to understand a few more things to be able to get at that pixel. Let’s go through a few more tags to complete our understanding of this image:

  • Compression: 5 As with many image formats, image data in TIFFs can be compressed. As is this is TIFF, there are many ways this can happen. The Compression value for this image is set to 5, which is LZW compression.
  • Predictor: 2 This value is related to compression, but not quite the same thing. The value for this image is 2, which means “Horizontal differencing”. What does this mean, and why does TIFF use this? The idea is that adjacent pixels in an image tend to resemble one another. Look at all that blue water in the sample image for example, or imagine a farmer’s field. TIFF takes advantage of this fact with a preprocessing step, prior to compressing the image. The TIFF encoder changes each pixel value to the difference from the previous pixel, rather than an absolute value. For example, if the first value was 100% red, it would be 255, and if the second and third pixels were also 100% red, they would both be stored as 0. If the hypothesis that adjacent pixels often resemble one another is true, then the data compressor can do a better job of compressing the data, since it is better able to compress long strings of contiguous values. Explaining why exactly this is true is a bit out of scope of this blog post, but you can read about it at the LZW reference linked above.
  • TileWidth: 512 The image is divided into tiles, 512 pixels wide.
  • TileLength: 512 The tile height, in pixels again.
  • TileOffsets: [16329752, …] The byte offsets of each tile in the image, by sample, so the red values for the first tile start at TileOffsets[0], and the red values for the second tile start at TileOffsets[1][1]. To get the Green values, you need to go to TileOffset[nTiles]. The total image size is 4096x4096px, which is a grid of 8x8 tiles, so there are 64 tiles. So the green pixel values are at TileOffset[64], blue at TileOffset[128], and alpha at TileOffset[192].
  • TileByteCounts: [242542, …] How big each tile is. So like TileOffsets, the red tile is TileByteCounts[0] bytes long, green is TileByteCounts[64], blue is TileByteCounts[128], and alpha at TileByteCounts[192]. You may be thinking that all tiles would be the same size, since they are all 512x512 pixels, 1 byte per sample, which would be 262,144 bytes per sample per tile. Remember that these pixel blocks are compressed, so the red sample of the first tile is 242542 bytes, which is a 1.08 compression ratio.

That is everything we need to know to decode a pixel!

Actually Reading That Pixel

To read our pixel, we will need to extract the LZW compressed RGBA samples for the first tile.

First, let’s extract the red sample. The first offset is the first element of the TileOffsets array, which is 16329752, and the length of the sample we get from first element of the TileByteCounts array, which is 242542. We can extract this with the dd command, which allows us to chop out a section of bytes from a file.

$ dd if=la.tif of=tile0r.lzw bs=1 skip=16329752 count=242542
242542+0 records in
242542+0 records out
242542 bytes transferred in 1.464022 secs (165668 bytes/sec)

This is the LZW compressed chunk of red pixels of the first tile. Let’s extract the other colors as well.

The green data begins at the 65th element of the offsets array (because the TileOffsets tag told use there are 64 tiles), which is 30064290. The 65th element of the tile byte counts array is 231121, so knowing that, we can now extract the green data from the file:

$ dd if=la.tif of=tile0g.lzw bs=1 skip=30064290 count=231121
231121+0 records in
231121+0 records out
231121 bytes transferred in 1.332498 secs (173449 bytes/sec)

The blue data begins at the 129th element of the offsets array, which is 43028302. The 129th element of the tile byte counts array is 230472, so now we can extract the blue data:

$ dd if=la.tif of=tile0b.lzw bs=1 skip=43028302 count=230472
230472+0 records in
230472+0 records out
230472 bytes transferred in 1.347660 secs (171016 bytes/sec)

Finally, the Alpha data begins at the 193rd element of the offsets array, which is 56042297. The 193rd element of the tile byte counts array is 1721, so now we can extract the alpha data:

$ dd if=la.tif of=tile0a.lzw bs=1 skip=56042297 count=1721
1721+0 records in
1721+0 records out
1721 bytes transferred in 0.021988 secs (78269 bytes/sec)

Sharp eyed readers may have noticed that this is much smaller than the other channels. Well, since all pixels are visible in this image, we expect all of these values to be 255 (fully opaque), so as you can imagine this compresses well.

We now have four chunks of bytes, we need to LZW decompress these files. Now, my Mac does include a utility that can do this (compress), but since the TIFF LZW spec is slightly different from the Original LZW Spec we can’t use this as-is. Luckily, Go has an implementation of the TIFF variant of the LZW spec, and we can write a very simple program to decompress LZW. We can run it like so:

$ cat tile0r.lzw | go run tiff-lzw.go > tile0r.dif
262144 bytes decompressed
$ cat tile0g.lzw | go run tiff-lzw.go > tile0g.dif
262144 bytes decompressed
$ cat tile0b.lzw | go run tiff-lzw.go > tile0b.dif
262144 bytes decompressed
$ cat tile0a.lzw | go run tiff-lzw.go > tile0a.dif
262144 bytes decompressed

Now we have decompressed the raw pixel data from our file! To check our work, let’s look at the alpha file to see what it looks like. We should expect that since our image has no transparency, every pixel should be 255 (FF in hex). Let’s take a peek at the first 8 bytes of the alpha channel:

$ xxd -l 8 tile0a.dif
00000000: ff00 0000 0000 0000

So what’s going on here? The first byte looks right: FF in hex, 255 in decimal, or fully opaque, but the following bytes are all 00. Why is that? We know that our image isn’t mostly transparent.

If you remember back to when we were looking at this file’s tags above you may remember the Predictor tag, which stated that this file uses horizontal differencing so the file compresses better. So, taking that into acount, we can read this as “the first pixel is FF (fully opaque), and the next is that same value less 00, which is also FF”, and so on for the whole line.

Unfortunately there also isn’t a command line tool that can process this for us, so again, we write another simple go program to handle this for us.

Now we can dediff all of our colour channels:

$ cat tile0r.dif | go run tiff-hdiff.go > tile0r.raw
262144 bytes dediffed
$ cat tile0g.dif | go run tiff-hdiff.go > tile0g.raw
262144 bytes dediffed
$ cat tile0b.dif | go run tiff-hdiff.go > tile0b.raw
262144 bytes dediffed
$ cat tile0a.dif | go run tiff-hdiff.go > tile0a.raw
262144 bytes dediffed

Now that we’ve dediffed everything, we should have our raw pixels! Let’s check back on the alpha channel for a sanity check:

$ xxd -l 8 tile0a.raw
00000000: ffff ffff ffff ffff ……..

That looks right! Fully opaque! I think we can also trust that we’ve handled the other channels correctly too, so let’s figure out what that first pixel is!

$ xxd -l 8 tile0r.raw
00000000: 5840 3735 393b 3e3d X@759;>=
$ xxd -l 8 tile0g.raw
00000000: 5544 3d3b 3d40 4140 UD=;=@A@
$ xxd -l 8 tile0b.raw
00000000: 4434 312f 2e31 3030 D41/.100

So if we look at the first byte for each colour (the first two digits), we have 58, 55, 44. If we append those numbers, we get #585544 in web format, which can also be represented as rgb(88, 85, 68). That’s it! We’ve figured out the pixel value for the first pixel in the TIFF!

Checking Our Work

So how can we be sure we’ve arrived at the correct value? One way is just to open our TIFF in an image editor (I used Acorn) and visually inspect it with the dropper tool:

Inspecting the first pixel with Acorn.

The overlay shows the same values we found manually. This gives us some confidence that we know what we’re doing!

Another way to check is ImageMagick on the command line. If you’ve never used ImageMagick before, I highly recommend it. You can do an incredible number of image operations on the command line using it. We will just use it to read the colour value of the first pixel (convert is part of the ImageMagick suite):

$ convert -quiet ‘la.tif[0]’ -crop 1x1+0+0 -depth 8 txt:
# ImageMagick pixel enumeration: 1,1,65535,srgba
0,0: (22616,21845,17476,65535) #585544FF srgba(88,85,68,1)

Again, we found the same colour values! So we can be quite confident we’ve done everything correctly, with two different tools producing the same results we did.

So that’s it! We were able to read a TIFF file’s pixel data without any dedicated image tools!

--

--