October 15, 2007

Image Handling in Seam Apps Part III: Scaling

This is the third part of a tutorial about how to manage images in your Seam based web application. This tutorial consists of several parts:

Part One: The Database Model
Part Two: File Upload
Part Three: Scaling
Part Four: Displaying and Caching
Part Five: Restricting Access

This third installment shows you how to scale uploaded artworks to generate thumbnails of a certain size. The method used to scale the artwork is called the Progressive Bilinear Scaling approach and is taking from Filthy Rich Clients. With this approach the artwork is scaled iteratively toward the final size, by exactly 50 percent each time until the final iteration.

When it comes to scaling images in Java, one would call either drawImage or getScaledInstance. The drawImage method has the advantage of being fast, but as the scale magnitude increases the quality drops significantly. The scaleImage method provides reasonable quality downscales when the scale factor is greater than half the original artwork size. On the other hand, the getScaledInstance provides the best quality for large downscales, however, the method has some performance implications. With getScaledInstance the performance difference involved can easily be in orders of magnitude. With the Progressive Bilinear approach you get a decent downscaled artwork in fraction of the time required by the getScaledInstance method. I would strongly recommend reading chapter four from the Filthy Rich Clients book.

The Code

The orginal scale method discussed in the book takes accepts and returns an instance of BufferedImage. I have update the method to account for the fact that an artwork are uploaded and stored as an array of bytes. The original scaling algorithm has not been modified though.


@Stateless
@Name("uploadService")
public class UploadService implements Upload {

private byte[] data;

public void upload() {
byte[] thumbnailData = this.scale();
// see part ii of this tutorial for the complete method
}

private byte[] scale(){
InputStream inputStream = new ByteArrayInputStream(this.data);
BufferedImage uploadedImg = null;
try {
uploadedImg = ImageIO.read(inputStream);
} catch (IOException e) {
log.info("Could not craete a buffered image from the specified " +
"input stream: #0", e.getMessage());
e.printStackTrace();
}

int type = (uploadedImg.getTransparency() == Transparency.OPAQUE) ?
BufferedImage.TYPE_INT_RGB : BufferedImage.TYPE_INT_ARGB;

/*
* The uploaded image has to be passed to drawImage at the first
* iteration only. For the remaining iterations, the newly scaled image
* has to be passed instead. If you find this assignment a bit confusing
* you can always assign it a null and then add an if statement similar
* to the scratchImage below.
*/
BufferedImage scaledImage = (BufferedImage) uploadedImg;
BufferedImage scratchImage = null;
Graphics2D g2 = null;

int w = uploadedImg.getWidth();
int h = uploadedImg.getHeight();
int prevW = scaledImage.getWidth();
int prevH = scaledImage.getHeight();

// the default with of the thumbnail is set to 200
int targetWidth = 200;
double ratio = (double) targetWidth / w;
int targetHeight = (int) (h * ratio);

Object hint = RenderingHints.VALUE_INTERPOLATION_BILINEAR;

do {
if (w > targetWidth) {
w /= 2;
if (w < targetWidth) {
w = targetWidth;
}
}
if (h > targetHeight) {
h /= 2;
if (h < targetHeight) {
h = targetHeight;
}
}
if (scratchImage == null) {
// Use a single scratch buffer for all iterations
// and then copy to the final, correctly sized image
// before returning
scratchImage = new BufferedImage(w, h, type);
g2 = scratchImage.createGraphics();
}
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION , hint);
g2.drawImage(scaledImage, 0, 0, w, h, 0, 0, prevW, prevH, null);
prevW = w;
prevH = h;
scaledImage = scratchImage;
} while (w != targetWidth || h != targetHeight);
if (g2 != null) {
g2.dispose();
}
// If we used a scratch buffer that is larger than our
// target size, create an image of the right size and copy
// the results into it
if (targetWidth != scaledImage.getWidth() || targetHeight != scaledImage.getHeight()) {
scratchImage = new BufferedImage(targetWidth, targetHeight, type);
g2 = scratchImage.createGraphics();
g2.drawImage(scaledImage, 0, 0, null);
g2.dispose();
scaledImage = scratchImage;
}

String formatName = "";
if ("image/png".equals(contentType))
formatName = "png";
else if ("image/jpeg".equals(contentType))
formatName = "jpeg";

// convert the resulting image into an array of bytes
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
ImageIO.write(scaledImage, formatName, outputStream);
} catch (IOException e) {
log.info("Could not write the buffered image into the specified " +
"output stream: #0", e.getMessage());
e.printStackTrace();
}
return outputStream.toByteArray();
}
}

I scaled some artworks of different sizes, 480KB up to 15.7MB, and got these results:

Size Resolution Time
482 KB (1004x669) 270 ms
567 KB (1280x1024) 1523 ms
617 KB (640x904) 180 ms
747 KB (1024x768) 284 ms
856 KB (1400x1600) 1157 ms
995 KB (1606x1700) 723 ms
1313 KB (4673x3248) 30172 ms
1432 KB (2142x3018) 2311 ms
1539 KB (2142x3017) 1576 ms
1739 KB (2517x3200) 1863 ms
1903 KB (4504x2401) 2607 ms
2002 KB (1935x2708) 1300 ms
2320 KB (2399x3577) 2798 ms
2628 KB (2853x4020) 15772 ms
5.2 MB (1568x2256) 5475 ms
11.4 MB (3508x2500) 9443 ms
15.7 MB (3508x2500) 5600 ms

2 comments:

Anonymous said...

Hi,

fantastic tutorial! I made an experience with IE6 when I followed the example:

The section
...
String formatName = "";
if ("image/png".equals(contentType))
formatName = "png";
else if ("image/jpeg".equals(contentType))
formatName = "jpeg";
...
should be reviewed because my example images have "image/x-png" and "image/pjpeg" as contentType, on Firefox it's "image/png" and "image/jpeg".

I posted this also in the Seam forum.

Kind regards

Kai

Amilcar Pereira said...

Nice post!!!

But, for better quality you should replace:

ImageIO.write(scaledImage, formatName, outputStream);

The quality of the standard writter is terrible. It's better to use a method like:

Iterator iter = ImageIO.getImageWritersByFormatName(format);

ImageWriter writer = (ImageWriter)iter.next();

ImageWriteParam iwp = writer.getDefaultWriteParam();
iwp.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
iwp.setCompressionQuality(1); // an integer between 0 and 1
// 1 specifies minimum compression and maximum quality


ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
ImageOutputStream outputStream = new MemoryCacheImageOutputStream(byteOutputStream);


writer.setOutput(outputStream);
IIOImage image = new IIOImage(img, null, null);
try {
writer.write(null, image, iwp);
} catch (IOException e) {
log.info("Could not write the buffered image into the specified "
+ "output stream: #0", e.getMessage());
e.printStackTrace();
}
writer.dispose();

return byteOutputStream.toByteArray();

Best Regards,

Amilcar