Grayscale and Indexed Color PNG Images on Android

I recently had to improve the quality of heads up navigation images on an embedded Android system and found an interesting solution using uncommon color modes for the images on Android.

The mapping SDK used provided an image like this, 300-500 pixels, full color, with a byte each for red, green, blue, and alpha when decompressed into bitmap form:

Example image, 16KB

The previous implementation scaled the image down to 30x30 using the Android APIs before sending it to the embedded system and scaling it back up to 100x100 for display:

Example image, 3KB

This looked horrible, but the small file size was needed so the image could be sent quickly, parsed quickly, and displayed quickly with minimum processing time and battery use. A heads up navigation image that shows up 3 seconds late isn’t nearly as useful as one that shows up in half a second.

When suitable, indexed color and grayscale can half the byte size of your image and half it again without impacting quality. These are difficult to generate natively on Android, however, because there are no constants or arguments for the Bitmap and PNG compression utilities for these modes. Fortunately someone wrote up a really good example using the PNGJ library and it works fine on Android.

Here’s the indexed color version, 100x100, 2KB:

And for these signs, grayscale is fine, so grayscale version I wrote, 100x100, 1KB:

This allows full resolution instead of blocky scaling, but still keeps the size down for quick, low battery usage display. I extracted the code from the Android app and put it in a sample Eclipse Java project here: https://github.com/lnanek/ExampleGenerateLowColorPngImage

Relevant code:

private static int extractLuminance(final int r, final int g, final int b) { return (int) (0.299 * r + 0.587 * g + 0.114 * b); } public static void toGrayscale(final String inputFilename, final String outputFilename, final boolean preserveMetaData) { // Read input final PngReader inputPngReader = new PngReader(new File(inputFilename)); System.out.println("Read input: " + inputPngReader.toString()); // Confirm compatible final int inputChannels = inputPngReader.imgInfo.channels; if (inputChannels < 3 || inputPngReader.imgInfo.bitDepth != 8) { throw new RuntimeException("This method is for RGB8/RGBA8 images"); } // Setup output final ImageInfo outputImageSettings = new ImageInfo( inputPngReader.imgInfo.cols, inputPngReader.imgInfo.rows, 8, false, true, false); final PngWriter outputPngWriter = new PngWriter( new File(outputFilename), outputImageSettings, true); final ImageLineInt outputImageLine = new ImageLineInt( outputImageSettings); // Copy meta data if desired if (preserveMetaData) { outputPngWriter.copyChunksFrom(inputPngReader.getChunksList(), ChunkCopyBehaviour.COPY_ALL_SAFE); } // For each row of input for (int rowIndex = 0; rowIndex < inputPngReader.imgInfo.rows; rowIndex++) { final IImageLine inputImageLine = inputPngReader.readRow(); final int[] scanline = ((ImageLineInt) inputImageLine) .getScanline(); // to save typing // For each column for (int columnIndex = 0; columnIndex < inputPngReader.imgInfo.cols; columnIndex++) { outputImageLine.getScanline()[columnIndex] = extractLuminance( scanline[columnIndex * inputChannels], scanline[columnIndex * inputChannels + 1], scanline[columnIndex * inputChannels] + 2); } outputPngWriter.writeRow(outputImageLine, rowIndex); } inputPngReader.end(); // it's recommended to end the reader first, in // case there are trailing chunks to read outputPngWriter.end(); }

So take each line of the input image, calculate the brightness from the red, green, and blue, then output that to a new image. In the future, if I can adapt the indexed color version to average the colors and get them down to only 16, instead of 256, it may be possible to get an even better result!