The Java2D API lets you create drawings that are made up of lines, curves, and areas. It is a “vector” API because you specify the mathematical properties of the shapes. However, for processing images that are made up of pixels, you want to work with a “raster” of color data. The following sections show you how to process raster images in Java.
1. Readers and Writers for Images
The javax.imageio package contains out-of-the-box support for reading and writing several common file formats, as well as a framework that enables third parties to add readers and writers for other formats. The GIF, JPEG, PNG, BMP (Windows bitmap), and WBMP (wireless bitmap) file formats are supported.
The basics of the library are extremely straightforward. To load an image, use the static read method of the ImagelO class:
Fite f = . . .;
BufferedImage image = ImagelO.read(f);
The ImageIO class picks an appropriate reader, based on the file type. It may consult the file extension and the “magic number” at the beginning of the file for that purpose. If no suitable reader can be found or the reader can’t decode the file contents, the read method returns null.
Writing an image to a file is just as simple:
File f = . . .;
String format = . . .;
ImageIO.write(image, format, f);
Here the format string is a string identifying the image format, such as “JPEG” or “PNG”. The ImageIO class picks an appropriate writer and saves the file.
1.1. Obtaining Readers and Writers for Image File Types
For more advanced image reading and writing operations that go beyond the static read and write methods of the ImageIO class, you first need to get the appropriate ImageReader and ImageWriter objects. The ImageIO class enumerates readers and writers that match one of the following:
- An image format (such as “JPEG”)
- A file suffix (such as “jpg”)
- A MIME type (such as “image/jpeg”)
For example, you can obtain a reader that reads JPEG files as follows:
ImageReader reader = null;
Iterator<ImageReader> iter = ImageIO.getImageReadersByFormatName(“JPEG”);
if (iter.hasNext()) reader = iter.next();
The getImageReadersBySuffix and getImageReadersByMIMEType methods enumerate readers that match a file extension or MIME type.
It is possible that the ImageIO class can locate multiple readers that can all read a particular image type. In that case, you have to pick one of them, but it isn’t clear how you can decide which one is the best. To find out more information about a reader, obtain its service provider interface:
ImageReaderSpi spi = reader.getOriginatingProvider();
Then you can get the vendor name and version number:
String vendor = spi.getVendor();
String version = spi.getVersion();
Perhaps that information can help you decide among the choices—or you might just present a list of readers to your program users and let them choose. For now, we assume that the first enumerated reader is adequate.
In the sample program in Listing 11.20, we want to find all file suffixes of all available readers so that we can use them in a file filter. Use the static ImageIO.getReaderFileSuffixes method for this purpose:
String[] extensions = ImageIO.getWriterFileSuffixes();
chooser.setFileFilter(new FileNameExtensionFilter(“Image files”, extensions));
For saving files, we have to work harder. We’d like to present the user with a menu of all supported image types. Unfortunately, the getWriterFormatNames of the ImageIO class returns a rather curious list with redundant names, such as
jpg, BMP, bmp, JPG, jpeg, wbmp, png, JPEG, PNG, WBMP, GIF, gif
That’s not something one would want to present in a menu. What is needed is a list of “preferred” format names. We supply a helper method getWriterFormats for this purpose (see Listing 11.20). We look up the first writer associated with each format name. Then we ask it what its format names are, in the hope that it will list the most popular one first. Indeed, for the JPEG writer, this works fine—it lists “JPEG” before the other options. (The PNG writer, on the other hand, lists “png” in lower case before “PNG”. We hope this behavior will be addressed at some point in the future. For now, we force all-lowercase names to upper case.) Once we pick a preferred name, we remove all alternate names from the original set. We keep going until all format names are handled.
1.2. Reading and Writing Files with Multiple Images
Some files—in particular, animated GIF files—contain multiple images. The read method of the ImageIO class reads a single image. To read multiple images, turn the input source (for example, an input stream or file) into an ImageInputStream.
InputStream in = . .
ImageInputStream imageIn = ImageIO.createImageInputStream(in);
Then, attach the image input stream to the reader:
reader.setInput(imageIn, true);
The second parameter indicates that the input is in “seek forward only” mode. Otherwise, random access is used, either by buffering stream input as it is read or by using random file access. Random access is required for certain operations. For example, to find out the number of images in a GIF file, you need to read the entire file. If you then want to fetch an image, the input must be read again.
This consideration is only important if you read from a stream, if the input contains multiple images, and if the image format doesn’t have the information that you request (such as the image count) in the header. If you read from a file, simply use
Fite f = . . .;
ImageInputStream imageIn = ImageIO.createImageInputStream(f);
reader.setInput(imageIn);
Once you have a reader, you can read the images in the input by calling
BufferedImage image = reader.read(index);
where index is the image index, starting with 0.
If the input is in the “seek forward only” mode, you keep reading images until the read method throws an IndexOutOfBoundsException. Otherwise, you can call the getNumImages method:
int n = reader.getNumImages(true);
Here, the parameter indicates that you allow a search of the input to determine the number of images. That method throws an IttegatStateException if the input is in the “seek forward only” mode. Alternatively, you can set the “allow search” parameter to false. Then the getNumImages method returns -1 if it can’t determine the number of images without a search. In that case, you’ll have to switch to Plan B and keep reading images until you get an IndexOutOfBoundsException.
Some files contain thumbnails—smaller versions of an image for preview purposes. You can get the number of thumbnails of an image with the call
int count = reader.getNumThumbnails(index);
Then you get a particular index as
BufferedImage thumbnail = reader.getThumbnait(index, thumbnaitIndex);
Sometimes you may want to get the image size before actually getting the image—in particular, if the image is huge or comes from a slow network connection. Use the calls
int width = reader.getWidth(index);
int height = reader.getHeight(index);
to get the dimensions of an image with a given index.
To write a file with multiple images, you first need an ImageWriter. The ImagelO class can enumerate the writers capable of writing a particular image format:
String format = . . .;
ImageWriter writer = null;
Iterator<ImageWriter> iter = ImageIO.getImageWritersByFormatName(format);
if (iter.hasNext()) writer = iter.next();
Next, turn an output stream or file into an ImageOutputStream and attach it to the writer. For example,
File f = . . .;
ImageOutputStream imageOut = ImageIO.createImageOutputStream(f);
writer.setOutput(imageOut);
You must wrap each image into an IIOImage object. You can optionally supply a list of thumbnails and image metadata (such as compression algorithms and color information). In this example, we just use null for both; see the API documentation for additional information.
var iioImage = new IIOImage(images[i], null, null);
To write out the first image, use the write method:
writer.write(new IIOImage(images[0], null, null));
For subsequent images, use
if (writer.canInsertImage(i))
writer.writeInsert(i, iioImage, null);
The third parameter can contain an ImageWriteParam object to set image writing details such as tiling and compression; use null for default values.
Not all file formats can handle multiple images. In that case, the canInsertImage method returns false for i > 0, and only a single image is saved.
The program in Listing 11.20 lets you load and save files in the formats for which the Java library supplies readers and writers. The program displays multiple images (see Figure 11.55), but not thumbnails.
2. Image Manipulation
Suppose you have an image and you would like to improve its appearance. You then need to access the individual pixels of the image and replace them with other pixels. Or perhaps you want to compute the pixels of an image from scratch—for example, to show the result of physical measurements or a mathematical computation. The BufferedImage class gives you control over the pixels in an image, and the classes that implement the BufferedImageOp interface let you transform images.
2.1. Constructing Raster Images
Most of the images that you manipulate are simply read in from an image file—they were either produced by a device such as a digital camera or scanner, or constructed by a drawing program. In this section, we’ll show you a different technique for constructing an image—namely, building it up a pixel at a time.
To create an image, construct a BufferedImage object in the usual way.
image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Now, call the getRaster method to obtain an object of type WritabteRaster. You will use this object to access and modify the pixels of the image.
WritabteRaster raster = image.getRaster();
The setPixet method lets you set an individual pixel. The complexity here is that you can’t simply set the pixel to a Cotor value. You must know how the buffered image specifies color values. That depends on the type of the image. If your image has a type of TYPE_INT_ARGB, then each pixel is described by four values—red, green, blue, and alpha, each between 0 and 255. You have to supply them in an array of four integers:
int[] black = { 0, 0, 0, 255 };
raster.setPixet(i, j, black);
In the lingo of the Java 2D API, these values are called the sample values of the pixel.
You can supply a batch of pixels with the setPixels method. Specify the starting pixel position and the width and height of the rectangle that you want to set. Then, supply an array that contains the sample values for all pixels. For example, if your buffered image has a type of TYPE_INT_ARGB, supply the red, green, blue, and alpha values of the first pixel, then the red, green, blue, and alpha values for the second pixel, and so on.
var pixels = new int[4 * width * height];
pixels[0] = . . .; // red value for first pixel
pixels[1] = . . .; // green value for first pixel
pixels[2] = . . .; // blue value for first pixel
pixels[3] = . . .; // alpha value for first pixel
…
raster.setPixels(x, y, width, height, pixels);
Conversely, to read a pixel, use the getPixel method. Supply an array of four integers to hold the sample values.
var sample = new int[4];
raster.getPixel(x, y, sample);
var color = new Color(sample[0], sample[1], sample[2], sample[3]);
You can read multiple pixels with the getPixels method.
raster.getPixels(x, y, width, height, samples);
If you use an image type other than TYPE_INT_ARGB and you know how that type represents pixel values, you can still use the getPixel/setPixel methods. However, you have to know the encoding of the sample values in the particular image type.
If you need to manipulate an image with an arbitrary, unknown image type, then you have to work a bit harder. Every image type has a color model that can translate between sample value arrays and the standard RGB color model.
The getColorModel method returns the color model:
ColorModel model = image.getColorModelO;
To find the color value of a pixel, call the getDataElements method of the Raster class. That call returns an Object that contains a color-model-specific description of the color value.
Object data = raster.getDataElements(x, y, null);
The color model can translate the object to standard ARGB values. The getRGB method returns an int value that has the alpha, red, green, and blue values packed in four blocks of eight bits each. You can construct a Color value out of that integer with the Color(int argb, boolean hasAlpha) constructor:
int argb = model.getRGB(data);
var color = new Color(argb, true);
To set a pixel to a particular color, reverse these steps. The getRGB method of the Color class yields an int value with the alpha, red, green, and blue values. Supply that value to the getDataElements method of the ColorModel class. The return value is an Object that contains the color-model-specific description of the color value. Pass the object to the setDataElements method of the WritableRaster class.
int argb = color.getRGB();
Object data = model.getDataElements(argb, null);
raster.setDataElements(x, y, data);
To illustrate how to use these methods to build an image from individual pixels, we bow to tradition and draw a Mandelbrot set, as shown in Figure 11.56.
The idea of the Mandelbrot set is that each point of the plane is associated with a sequence of numbers. If that sequence stays bounded, you color the point. If it “escapes to infinity,” you leave it transparent.
Here is how you can construct the simplest Mandelbrot set. For each point (a, b), look at sequences that start with (x, y) = (0, 0) and iterate:
xnew = x2 – y2 + a
ynew = x2 – y2 + a
It turns out that if x or y ever gets larger than 2, then the sequence escapes to infinity. Only the pixels that correspond to points (a, b) leading to a bounded sequence are colored. (The formulas for the number sequences come ultimately from the mathematics of complex numbers; we’ll just take them for granted.)
Listing 11.21 shows the code. In this program, we demonstrate how to use the CotorModet class for translating Color values into pixel data. That process is independent of the image type. Just for fun, change the color type of the buffered image to TYPE_BYTE_GRAY. You don’t need to change any other code— the color model of the image automatically takes care of the conversion from colors to sample values.
2.2. Filtering Images
In the preceding section, you saw how to build up an image from scratch. However, often you want to access image data for a different reason: You already have an image and you want to improve it in some way.
Of course, you can use the getPixel/getDataElements methods that you saw in the preceding section to read the image data, manipulate them, and write them back. Fortunately, the Java 2D API already supplies a number of filters that carry out common image processing operations for you.
The image manipulations all implement the BufferedImageOp interface. After you construct the operation, simply call the filter method to transform an image into another.
BufferedImageOp op = . . .;
BufferedImage filteredImage
= new BufferedImage(image.getWidth(), image.getHeight(), image.getTypef));
op.filter(image, filteredImage);
Some operations can transform an image in place (op.filterfimage, image)), but most can’t.
Five classes implement the BufferedImageOp interface:
AffineTransformOp
RescateOp
LookupOp
CotorConvertOp
ConvotveOp
The AffineTransformOp carries out an affine transformation on the pixels. For example, here is how you can rotate an image about its center:
AffineTransform transform = AffineTransform.getRotateInstance(Math.toRadians(angte),
image.getWidth() / 2, image.getHeight() / 2);
var op = new AffineTransformOp(transform, interpolation);
op.fitter(image, fitteredImage);
The AffineTransformOp constructor requires an affine transform and an interpolation strategy. Interpolation is necessary to determine the target image pixels if the source pixels are transformed somewhere between target pixels. For example, if you rotate source pixels, they will generally not fall exactly onto target pixels. There are three interpolation strategies: AffineTransformOp.TYPE_BICUBIC, AffineTransformOp.TYPE_BILINEAR, and AffineTransformOp.TYPE_NEAREST_NEIGHBOR. Bicubic interpolation takes a bit longer but looks better than the other two.
The program in Listing 11.22 lets you rotate an image by 5 degrees (see Figure 11.57).
The RescateOp carries out a rescaling operation
xnew = a · x + b
for each of the color components in the image. (Alpha components are not affected.) The effect of rescaling with a > 1 is to brighten the image. Construct the RescateOp by specifying the scaling parameters and optional rendering hints. In Listing 11.22, we use:
float a = 1.1f;
float b = 20.0f;
var op = new RescaleOp(a, b, null);
You can also supply separate scaling values for each color component—see the API notes.
The LookupOp operation lets you specify an arbitrary mapping of sample values. Supply a table that specifies how each value should be mapped. In the example program, we compute the negative of all colors, changing the color c to 255 – c.
The LookupOp constructor requires an object of type LookupTable and a map of optional hints. The LookupTable class is abstract, with two concrete subclasses: ByteLookupTable and ShortLookupTable. Since RGB color values are bytes, a ByteLookupTable should suffice. However, because of the bug described in http://bugs.sun.com /bugdatabase/view_bug.do?bug_id=6183251, we will use a ShortLookupTable instead. Here is how we construct the LookupOp for the example program:
var negative = new short[256];
for (int i = 0; i < 256; i++) negative[i] = (short) (255 – i);
var table = new ShortLookupTable(0, negative);
var op = new LookupOp(table, null);
The lookup is applied to each color component separately, but not to the alpha component. You can also supply different lookup tables for each color component—see the API notes.
The ColorConvertOp is useful for color space conversions. We do not discuss it here.
The most powerful of the transformations is the ConvolveOp, which carries out a mathematical convolution. We won’t get too deeply into the mathematical details, but the basic idea is simple. Consider, for example, the blur filter (see Figure 11.58).
The blurring is achieved by replacing each pixel with the average value from the pixel and its eight neighbors. Intuitively, it makes sense why this operation
would blur out the picture. Mathematically, the averaging can be expressed as a convolution operation with the following kernel:
The kernel of a convolution is a matrix that tells what weights should be applied to the neighboring values. The kernel above produces a blurred image. A different kernel carries out edge detection, locating the areas of color changes:
Edge detection is an important technique for analyzing photographic images (see Figure 11.59).
To construct a convolution operation, first set up an array of the values for the kernel and construct a Kernel object. Then, construct a ConvotveOp object from the kernel and use it for filtering.
float[] elements =
{
0.0f, -1.0f, 0.0f,
-1.0f, 4.f, -1.0f,
0.0f, -1.0f, 0.0f
};
var kernel = new Kernel(3, 3, elements);
var op = new ConvolveOp(kernel);
op.filter(image, filteredImage);
The program in Listing 11.22 allows a user to load in a GIF or JPEG image and carry out the image manipulations that we discussed. Thanks to the power of the operations provided by Java 2D API, the program is very simple.
Source: Horstmann Cay S. (2019), Core Java. Volume II – Advanced Features, Pearson; 11th edition.