by Stephen Ingram and K.C. Hopson
This chapter teaches you the more advanced concepts involved in Java images. It leads off by introducing Java's fundamental image model. It then explores image filters and explains two advanced filters, including a special-effects filter. The chapter ends by using a custom filter to present Mandelbrot sets (a visually striking image from chaos theory).
To really appreciate the power behind Java images, you must understand the consumer/producer model in detail. Powerful graphics applications use the advantages of this model to perform their visual wizardry. In particular, you can write effective image filters only if you understand the underlying model.
True to Java's object-oriented design, images are not presented by a single class. Rather, a richer and more specialized class system is used. The designers of Java wanted an extensible image system that would allow image manipulation as well as support for a wide variety of image formats. The best way to meet these requirements was to devolve image presentation into its requisite parts. An image producer understands a particular image format, but is unaware of the details behind image display. Instead, the producer renders its contents through an image consumer interface. By separating production from consumption, Java allows multiple image filters to be inserted between the base image and its eventual display.
The ImageProducer interface has the following methods:
Notice that all the methods require an ImageConsumer object. There are no back doors; an ImageProducer can output only through an associated ImageConsumer. A given producer can have multiple objects as client consumers, although this is not usually the case. Typically, as soon as a consumer registers itself with a producer using addConsumer(), the image data is immediately delivered through the consumer's interface.
The first three producers' methods involve attachment of consumers. The remaining two producers provide applications with methods for causing a producer to start painting. Usually, it is the AWT that is the caller, not the actual user applets.
The ImageProducer interface is clean and straightforward, but the ImageConsumer is quite a bit more complex. It has the following methods:
Figure 42.1 shows the normal progression of calls to the ImageConsumer interface. Several methods are optional: setProperties(), setHints(), and setColorModel(). The core methods are first setDimensions(), followed by one or more calls to setPixels(). Finally, when there are no more setPixels() calls, imageComplete() is invoked.
Figure 42.1: Normal flow of calls to an ImageConsumer interface.
Each image has fixed rectangular dimensions, which are passed
in setDimensions(). The consumer
has to save this data for future reference. The setProperties()
method has no discernible use right now, and most consumers don't
do anything with it. The hintflags
parameter, however, is a different story. Hints are supposed
to give clues about the format of the producer's data. Table 42.1
lists the values for hintflags.
Flag Name | Meaning |
RANDOMPIXELORDER=1 | No assumptions should be made about the delivery of pixels. |
TOPDOWNLEFTRIGHT=2 | Pixel delivery will paint in top to bottom, left to right. |
COMPLETESCANLINES=4 | Pixels will be delivered in multiples of complete rows. |
SINGLEPASS=8 | Pixels will be delivered in a single pass. No pixel will appear in more than one setPixel() call. |
SINGLEFRAME=16 | The image consists of a single static frame. |
When all the pixel information has been transmitted, the producer calls imageComplete(status). The status parameter has one of three values: IMAGEERROR=1, SINGLEFRAMEDONE=2, or STATICFRAMEDONE=3.
SINGLEFRAMEDONE indicates that additional frames will follow; for example, a video camera would use this technique. Special-effect filters could also use SINGLEFRAMEDONE. STATICFRAMEDONE is used to indicate that no more pixels will be transmitted for the image. The consumer should remove itself from the producer after receiving STATICFRAMEDONE.
Two setPixels() calls provide the image data. Keep in mind that the image size was set in advance by setDimensions(). The array within setPixels() calls does not necessarily contain all the pixels within an image. In fact, the arrays usually contain only a rectangular subset of the total image. Figure 42.2 shows a rectangle of setPixels() within an entire image.
Figure 42.2: The relationship of setPixels( ) calls to an entire image.
The row size of the array is the scansize. The width and height (w and h) parameters indicate the usable pixels within the array, and the offset (off) contains the starting index. It is up to the consumer to map the passed array onto the entire image. The subimage's location within the total image is contained in the x and y parameters.
The ColorModel contains all needed color information for the image. The call to setColorModel() is purely informational because each setPixels() call passes a specific ColorModel parameter. No assumptions should be made about the ColorModel from setColorModel() calls.
Image filters sit between an ImageProducer and an ImageConsumer and must implement both these interfaces. Java supplies two separate classes for using filters: FilteredImageSource and ImageFilter.
The FilteredImageSource class implements the ImageProducer interface, which allows the class to masquerade as a real producer. When a consumer attaches to the FilteredImageSource, it's stored in an instance of the current filter. The filter class object is then given to the actual ImageProducer. When the image is rendered through the filter's interface, the data is altered before being forwarded to the actual ImageConsumer. Figure 42.3 shows the filtering operation.
Figure 42.3: Image filtering classes.
The following is the constructor for FilteredImageSource:
FilteredImageSource(ImageProducer orig, ImageFilter imgf);
Producer and filter are stored until a consumer attaches itself to the FilterImageSource. The following lines show how an application sets up a filter chain:
// Create the filter ImageFilter filter = new SomeFilter(); // Use the filter to get a producer ImageProducer p = new FilteredImageSource(myImage.getSource(), filter); // Use the producer to create the image Image img = createImage(p);
Filters always extend the ImageFilter class, which implements all the methods for an ImageConsumer. In fact, the ImageFilter class is itself a pass-through filter. It passes the data without alteration but otherwise acts as a normal image filter. The FilteredImageSource class works only with ImageFilter and its subclasses. Using ImageFilter as a base frees you from having to implement a method you have no use for, such as setProperties(). ImageFilter also implements one additional method:
public void resendTopDownLeftRight(ImageProducer ip);
When a FilteredImageSource
gets a request to resend through its ImageProducer
interface, it calls the ImageFilter
instead of the actual producer. ImageFilter's
default resend function calls the producer and requests a repaint.
There are times when the filter does not want to have the image
regenerated, so it can override this call and simply do nothing.
One example of this type of filter is described in "Dynamic
Image Filter: FXFilter,"
later in this chapter. A special-effects filter can simply remove
or obscure certain parts of an underlying image. To perform the
effect, the filter must merely know the image dimensions, not
the specific pixels it will be overwriting. setPixel()
calls are safely ignored, but the producer must be prevented from
repainting. If your filter does not implement setPixels()
calls, a subsequent resend request will destroy the filter's changes
by writing directly to the consumer.
Note |
If setPixels() is not overridden in your filter, you will probably want to override resendTopDownLeftRight()to prevent the image from being regenerated after your filter has altered the image. |
Rotation is a common image manipulation. Unfortunately, there is no standard Java filter for performing this operation. This apparent oversight provides us with an excellent opportunity to develop your first filter.
Static filters perform their manipulations and then issue a STATICFRAMEDONE. Because all the alterations are applied at one time, the filter is said to be static. If a filter applies its changes in stages, it is considered to be a dynamic filter.
To perform image rotation, you must use some math. You can perform the rotation of points with the following formulas:
new_x = x * cos(angle) - y * sin(angle) new_y = y * cos(angle) + x * sin(angle)
Rotation is around the z-axis. Positive angles cause counterclockwise rotation; negative angles cause clockwise rotation. These formulas are defined for Cartesian coordinates. The Java screen is actually inverted, so the positive y-axis runs down the screen, not up. To compensate for this, invert the sign of the sine coefficients:
new_x = x * cos(angle) + y * sin(angle) new_y = y * cos(angle) - x * sin(angle)
Additionally, the sine and cosine functions compute the angle in radians. The following formula converts degrees to radians:
radians = degrees * PI/180;
This works because there are 2*PI radians in a circle. That's all the math you'll need; now you can set up the ImageConsumer routines.
The setDimensions() call tells you the total size of the image. Record the size and allocate an array to hold all the pixels. Because this filter will rotate the image, the size may change. In an extreme case, the size could grow much larger than the original image because images are rectangular. If you rotate a rectangle 45 degrees, a new rectangle must be computed that contains all the pixels from the rotated image (see Figure 42.4).
Figure 42.4: The new bounding rectangle after rotation.
To calculate the new bounding rectangle, each vertex of the original image must be rotated. After rotation, the new coordinate is checked for minimum and maximum x and y values. After all four points are rotated, you'll know what the new bounding rectangle is. Record this information as rotation space, and inform the consumer of the size after rotation.
The setPixels() calls are very straightforward. Simply translate the pixel color into an RGB value and store it in the original image array allocated in setDimensions().
The imageComplete() method performs all the work. After the image is final, populate a new rotation space array and return it to the consumer through the consumer's setPixels() routine. Finally, invoke the consumer's imageComplete() method. Listing 42.1 shows the entire filter.
Listing 42.1. The SpinFilter
class.
import java.awt.*; import java.awt.image.*; public class SpinFilter extends ImageFilter { private double angle; private double cos, sin; private Rectangle rotatedSpace; private Rectangle originalSpace; private ColorModel defaultRGBModel; private int inPixels[], outPixels[]; SpinFilter(double angle) { this.angle = angle * (Math.PI / 180); cos = Math.cos(this.angle); sin = Math.sin(this.angle); defaultRGBModel = ColorModel.getRGBdefault(); } private void transform(int x, int y, double out[]) { out[0] = (x * cos) + (y * sin); out[1] = (y * cos) - (x * sin); } private void transformBack(int x, int y, double out[]) { out[0] = (x * cos) - (y * sin); out[1] = (y * cos) + (x * sin); } public void transformSpace(Rectangle rect) { double out[] = new double[2]; double minx = Double.MAX_VALUE; double miny = Double.MAX_VALUE; double maxx = Double.MIN_VALUE; double maxy = Double.MIN_VALUE; int w = rect.width; int h = rect.height; int x = rect.x; int y = rect.y; for ( int i = 0; i < 4; i++ ) { switch (i) { case 0: transform(x + 0, y + 0, out); break; case 1: transform(x + w, y + 0, out); break; case 2: transform(x + 0, y + h, out); break; case 3: transform(x + w, y + h, out); break; } minx = Math.min(minx, out[0]); miny = Math.min(miny, out[1]); maxx = Math.max(maxx, out[0]); maxy = Math.max(maxy, out[1]); } rect.x = (int) Math.floor(minx); rect.y = (int) Math.floor(miny); rect.width = (int) Math.ceil(maxx) - rect.x; rect.height = (int) Math.ceil(maxy) - rect.y; } /** * Tell the consumer the new dimensions based on our * rotation of coordinate space. * @see ImageConsumer#setDimensions */ public void setDimensions(int width, int height) { originalSpace = new Rectangle(0, 0, width, height); rotatedSpace = new Rectangle(0, 0, width, height); transformSpace(rotatedSpace); inPixels = new int[originalSpace.width * originalSpace.height]; consumer.setDimensions(rotatedSpace.width, rotatedSpace.height); } /** * Tell the consumer that we use the defaultRGBModel color model * NOTE: This overrides whatever color model is used underneath us. * @param model contains the color model of the image or filter * beneath us (preceding us) * @see ImageConsumer#setColorModel */ public void setColorModel(ColorModel model) { consumer.setColorModel(defaultRGBModel); } /** * Set the pixels in our image array from the passed * array of bytes. Xlate the pixels into our default * color model (RGB). * @see ImageConsumer#setPixels */ public void setPixels(int x, int y, int w, int h, ColorModel model, byte pixels[], int off, int scansize) { int index = y * originalSpace.width + x; int srcindex = off; int srcinc = scansize - w; int indexinc = originalSpace.width - w; for ( int dy = 0; dy < h; dy++ ) { for ( int dx = 0; dx < w; dx++ ) { inPixels[index++] = model.getRGB(pixels[srcindex++] & 0xff); } srcindex += srcinc; index += indexinc; } } /** * Set the pixels in our image array from the passed * array of integers. Xlate the pixels into our default * color model (RGB). * @see ImageConsumer#setPixels */ public void setPixels(int x, int y, int w, int h, ColorModel model, int pixels[], int off, int scansize) { int index = y * originalSpace.width + x; int srcindex = off; int srcinc = scansize - w; int indexinc = originalSpace.width - w; for ( int dy = 0; dy < h; dy++ ) { for ( int dx = 0; dx < w; dx++ ) { inPixels[index++] = model.getRGB(pixels[srcindex++]); } srcindex += srcinc; index += indexinc; } } /** * Notification that the image is complete and there will * be no further setPixel calls. * @see ImageConsumer#imageComplete */ public void imageComplete(int status) { if (status == IMAGEERROR || status == IMAGEABORTED) { consumer.imageComplete(status); return; } double point[] = new double[2]; int srcwidth = originalSpace.width; int srcheight = originalSpace.height; int outwidth = rotatedSpace.width; int outheight = rotatedSpace.height; int outx, outy, srcx, srcy; outPixels = new int[outwidth * outheight]; outx = rotatedSpace.x; outy = rotatedSpace.y; double end[] = new double[2]; int index = 0; for ( int y = 0; y < outheight; y++ ) { for ( int x = 0; x < outwidth; x++) { // find the originalSpace point transformBack(outx + x, outy + y, point); srcx = (int)Math.round(point[0]); srcy = (int)Math.round(point[1]); // if this point is within the original image // retreive its pixel value and store in output // else write a zero into the space. (0 alpha = transparent) if ( srcx < 0 || srcx >= srcwidth || srcy < 0 || srcy >= srcheight ) { outPixels[index++] = 0; } else { outPixels[index++] = inPixels[(srcy * srcwidth) + srcx]; } } } // write the entire new image to the consumer consumer.setPixels(0, 0, outwidth, outheight, defaultRGBModel, outPixels, 0, outwidth); // tell consumer we are done consumer.imageComplete(status); } }
The rotation is complex. First, as Figure 42.4 shows (earlier in this chapter), the rotated object is not completely within the screen's boundary. All the rotated pixels must be translated back in relation to the origin. You can do this easily by assuming that the coordinates of rotated space are really 0,0-the trick is how the array is populated. An iteration is made along each row in rotated space. For each pixel in the row, the rotation is inverted. This yields the position of this pixel within the original space. If the pixel lies within the original image, grab its color and store it in rotated space; if it isn't, store a transparent color.
The SpinFilter just described is static; the FXFilter described in this section is dynamic. A static filter alters an image and sends STATICIMAGEDONE when the alteration is done; a dynamic filter makes the effect take place over multiple frames, much like an animation. The FXFilter has four effects: wipe left, wipe right, wipe from center out, and dissolve. Each effect operates by erasing the image in stages. The filter calls imageComplete() many times, but instead of passing STATICIMAGEDONE, it specifies SINGLEFRAMEDONE.
Because each effect is simply a matter of writing a block of a particular color, there is no need to refer to the pixels in the original image. Because you don't need to use the setPixels() method, the filter functions very quickly.
Each of the wipes operates by moving a column of erased pixels over the length of the image. The width of the column is calculated to yield the number of configured iterations. The dissolve works by erasing a rectangular block at random places throughout the image. Of all the effects, dissolve is the slowest to execute because it has to calculate each random location.
In setHints(), the consumer is told that the filter will send random pixels. This causes the consumer to call resendTopDownLeftRight() when the image is complete. The filter must intercept the call to avoid having the just-erased image repainted by the producer in pristine form.
The filter has two constructors. If you don't specify a color, the image dissolves into transparency, allowing you to phase one image into a second image. You can also specify an optional color, which causes the image to gradually change into the passed color. You can dissolve an image into the background by passing the background color in the filter constructor. The number of iterations and paints is completely configurable. There is no hard-and-fast formula for performing these effects, so feel free to alter the values to get the result you want. Listing 42.2 contains the source for the FXFilter filter.
Listing 42.2. The special-effects filter.
import java.awt.*; import java.awt.image.*; import java.util.*; public class FXFilter extends ImageFilter { private int outwidth, outheight; private ColorModel defaultRGBModel; private int dissolveColor; private int iterations = 50; private int paintsPer = 2; private static final int SCALER = 25; private static final int MINIMUM_BLOCK = 7; private int dissolve_w, dissolve_h; private boolean sizeSet = false; private Thread runThread; public static final int DISSOLVE = 0; public static final int WIPE_LR = 1; public static final int WIPE_RL = 2; public static final int WIPE_C = 3; private int type = DISSOLVE; /** * Dissolve to transparent constructor */ FXFilter() { defaultRGBModel = ColorModel.getRGBdefault(); dissolveColor = 0; } /** * Dissolve to the passed color constructor * @param dcolor contains the color to dissolve to */ FXFilter(Color dcolor) { this(); dissolveColor = dcolor.getRGB(); } /** * Set the type of effect to perform. */ public void setType(int t) { switch (t) { case DISSOLVE: type = t; break; case WIPE_LR: type = t; break; case WIPE_RL: type = t; break; case WIPE_C: type = t; break; } } /** * Set the size of the dissolve blocks (pixels removed). */ public void setDissolveSize(int w, int h) { if ( w < MINIMUM_BLOCK ) w = MINIMUM_BLOCK; if ( h < MINIMUM_BLOCK ) w = MINIMUM_BLOCK; dissolve_w = w; dissolve_h = h; sizeSet = true; } /** * Set the dissolve paramters. (Optional, will default to 200 & 2) * @param num contains the number of times to loop. * @param paintsPerNum contains the number of blocks to remove per paint */ public void setIterations(int num, int paintsPerNum) { iterations = num; paintsPer = paintsPerNum; } /** * @see ImageConsumer#setDimensions */ public void setDimensions(int width, int height) { outwidth = width; outheight = height; consumer.setDimensions(width, height); } /** * Don't tell consumer we send complete frames. * Tell them we send random blocks. * @see ImageConsumer#setHints */ public void setHints(int hints) { consumer.setHints(ImageConsumer.RANDOMPIXELORDER); } /** * Override this method to keep the producer * from refreshing our dissolved image */ public void resendTopDownLeftRight(ImageProducer ip) { } /** * Notification that the image is complete and there will * be no further setPixel calls. * @see ImageConsumer#imageComplete */ public void imageComplete(int status) { if (status == IMAGEERROR || status == IMAGEABORTED) { consumer.imageComplete(status); return; } if ( status == SINGLEFRAMEDONE ) { runThread = new RunFilter(this); runThread.start(); } else filter(); } public void filter() { switch ( type ) { case DISSOLVE: dissolve(); break; case WIPE_LR: wipeLR(); break; case WIPE_RL: wipeRL(); break; case WIPE_C: wipeC(); break; default: dissolve(); break; } consumer.imageComplete(STATICIMAGEDONE); } /** * Wipe the image from left to right */ public void wipeLR() { int xw = outwidth / iterations; if ( xw <= 0 ) xw = 1; int total = xw * outheight; int dissolvePixels[] = new int[total]; for ( int x = 0; x < total; x++ ) dissolvePixels[x] = dissolveColor; for ( int t = 0; t < (outwidth - xw); t += xw ) { consumer.setPixels(t, 0, xw, outheight, defaultRGBModel, dissolvePixels, 0, xw); // tell consumer we are done with this frame consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); } } /** * Wipe the image from right to left */ public void wipeRL() { int xw = outwidth / iterations; if ( xw <= 0 ) xw = 1; int total = xw * outheight; int dissolvePixels[] = new int[total]; for ( int x = 0; x < total; x++ ) dissolvePixels[x] = dissolveColor; for ( int t = outwidth - xw - 1; t >= 0; t -= xw ) { consumer.setPixels(t, 0, xw, outheight, defaultRGBModel, dissolvePixels, 0, xw); // tell consumer we are done with this frame consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); } } /** * Wipe the image from the center out */ public void wipeC() { int times = outwidth / 2; int xw = times / iterations; if ( xw <= 0 ) xw = 1; int total = xw * outheight; int dissolvePixels[] = new int[total]; for ( int x = 0; x < total; x++ ) dissolvePixels[x] = dissolveColor; int x1 = outwidth /2; int x2 = outwidth /2; while ( x2 < (outwidth - xw) ) { consumer.setPixels(x1, 0, xw, outheight, defaultRGBModel, dissolvePixels, 0, xw); consumer.setPixels(x2, 0, xw, outheight, defaultRGBModel, dissolvePixels, 0, xw); // tell consumer we are done with this frame consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); x1 -= xw; x2 += xw; } } /** * Dissolve the image */ public void dissolve() { // Is the image too small to dissolve? if ( outwidth < MINIMUM_BLOCK && outheight < MINIMUM_BLOCK ) { return; } consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); if ( !sizeSet ) { // Calculate the dissolve block size dissolve_w = (outwidth * SCALER) / (iterations * paintsPer); dissolve_h = (outheight * SCALER) / (iterations * paintsPer); // Minimum block size if ( dissolve_w < MINIMUM_BLOCK ) dissolve_w = MINIMUM_BLOCK; if ( dissolve_h < MINIMUM_BLOCK ) dissolve_h = MINIMUM_BLOCK; } // Initialize the dissolve pixel array int total = dissolve_w * dissolve_h; int[] dissolvePixels = new int[total]; for ( int i = 0; i < total; i++ ) dissolvePixels[i] = dissolveColor; int pos; double apos; for ( int t = 0; t < iterations; t++ ) { for ( int px = 0; px < paintsPer; px++ ) { // remove some pixels apos = Math.random() * outwidth; int xpos = (int)Math.floor(apos); apos = Math.random() * outheight; int ypos = (int)Math.floor(apos); if ( xpos - dissolve_w >= outwidth ) xpos = outwidth - dissolve_w - 1; if ( ypos - dissolve_h >= outheight ) ypos = outheight - dissolve_h - 1; consumer.setPixels(xpos, ypos, dissolve_w, dissolve_h, defaultRGBModel, dissolvePixels, 0, dissolve_w); } // tell consumer we are done with this frame consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); } } } class RunFilter extends Thread { FXFilter fx = null; RunFilter(FXFilter f) { fx = f; } public void run() { fx.filter(); } }
You need RunFilter for image producers created from a memory image source. GIF and JPEG images both spawn a thread for their producers. Because the filter must loop within the imageComplete() method, you need a separate thread for the production. Memory images do not spawn a separate thread for their producers, so the filter has to spawn its own.
The only way to differentiate the producers is to key on their
status. GIF and JPEG image producers send STATICIMAGEDONE,
and memory images send SINGLEFRAMEDONE.
Note |
If you spawn an additional thread for GIF and JPEG images, you won't be able to display the image at all. Producers that are already a separate thread must be operated within their existing threads. |
The variables SCALER and MINIMUM_BLOCK apply only to dissolves. Because a dissolve paints into random locations, there are many overlapping squares. If the blocks are sized to exactly cover the image over the configured number of iterations, the image won't come close to dissolving. The SCALER parameter specifies what multiple of an image the blocks should be constructed to cover. Increasing the value yields larger dissolve blocks and guarantees a complete dissolve. A value that's too large will erase the image too quickly and ruin the effect, but a value that's too small will not dissolve enough of the image. A middle value completely dissolves the image, but a dissolve is most effective when most of the image is erased in the beginning stages of the effect.
A new project, one that views the Mandelbrot set, gives you examples of some of the more advanced concepts you have been introduced to. The Mandelbrot set is the most spectacular example of fractals, which represent one of the hot scientific topics of recent years. With the applets in this chapter, you can view or generate an original Mandelbrot image and zoom in and out of it to produce new portions of the set.
Because the Mandelbrot set can take a while to generate (it requires millions of calculations), you have a chance to combine threads and image filters so that you can view the set as it's being generated. You may also want to save the Mandelbrot images. The BmpClass file, found on the CD-ROM that accompanies this book, converts a BMP-formatted file into a Java image.
There are two applets in the Mandelbrot example. The first applet, MandelApp, generates the full Mandelbrot set. Depending on your computer, this can take a little while; for example, on a 486DX2-50 PC, it takes a couple minutes. When the image is complete (as indicated by a message on the browser's status bar), you can save the image to a BMP formatted file by clicking anywhere on the applet's display area. The file will be called mandel.bmp. Remember to run this applet from a program, such as the applet viewer, that lets applets write to disk.
The other applet, MandelAppZoom, is more fully featured. It begins by loading the Mandelbrot bitmap specified by an HTML applet parameter tag. The default mandel1 corresponds to a BMP file and a data file that specifies x-y parameter values included on this book's CD-ROM.
Once the image is displayed, you can pick regions to zoom in on by clicking on a point in the image and dragging the mouse to the endpoint of the region you want to display. Type z or Z on the keyboard, and the applet creates the image representing the new region of the Mandelbrot set. The key to this applet is patience; the calculations can take a little while to set up and run. The applet tries to help your patience by updating the status bar to indicate what is going on. Furthermore, the image filter displays each column of the set as the calculations advance.
If you select a region that doesn't appear to have anything interesting to show when you zoom on it, you can stop the calculation by typing a or A on the keyboard. The applet takes a moment to wrap up, but then you can proceed. When you have problems finding an interesting region to look at, try increasing the size of the highlighted area. This action yields a bigger area that is generated, giving you a better feel for what should be inspected. You get the best results by working with medium-sized highlighted regions, rather than large or small ones.
The zoom applet maintains a cache of processed images so that
you can move back and forth among the processed images. Table
42.2 lists the text codes for using the zoom applet. If you want
to use the file-saving capabilities of this program, you must
run it from something that does not prevent file saving, such
as the applet viewer program. You can also run the program in
a browser like Netscape; however, the applet will be able to do
everything except save the images as files.
Action | |
Abort current Mandelbrot calculation | |
Go to previous image | |
Go to next image | |
Remove all but full image from memory | |
Go to next image | |
Go to previous image | |
Save the current image to a BMP file prefixed by tempMandel | |
Zoom in on currently highlighted region |
Because the Mandelbrot set can take quite a while to generate, it was designed by combining a calculation thread with an image filter so that you can see the results as they are generated. However, understanding how the classes interrelate is a little tricky. Figure 42.5 shows the workflow involved in producing a Mandelbrot image. Understanding this flow is the key to understanding this project.
Figure 42.5: The workflow involved in producing a Mandelbrot image.
The process begins when an applet displaying Mandelbrot sets constructs a Mandelbrot object. The Mandelbrot object, in turn, creates an instance of the CalculatorImage class. The Mandelbrot set passes itself as a part of the CalculatorImage constructor. It is referenced as a CalculatorProducer object, an interface that the Mandelbrot class implements. This interface implementation is used to communicate with the image filter.
In the next step, the applet requests a Mandelbrot image. This is initiated by calling the getImage() method of the Mandelbrot object, which in turn leads to a call to a like-named method of the CalculatorImage object. At this point, the CalculatorImage object first creates a color palette by using an instance of the ImageColorModel class and then creates a MemoryImageSource object. This object, which implements ImageProducer, produces an image initialized to all zeros (black); it's combined with an instance of the CalculatorFilter class to produce a FilteredImageSource.
When the MemoryImageSource object produces its empty image, it is passed to the CalculatorFilter, which takes the opportunity to produce the calculated image. It does this by kicking off the thread of the image to be calculated. The CalculatorFilter doesn't know that it is the Mandelbrot set that's calculated; it just knows that some calculation must occur in the CalculatorProducer object in which it has a reference.
Once the Mandelbrot thread is started, it begins the long calculations to produce a Mandelbrot set. Whenever it finishes a section of the set, it notifies the filter with new data through the CalculatorFilterNotify interface. The filter, in turn, lets the viewing applet know that it has new data to display by updating the corresponding ImageConsumer, which causes the applet's imageUpdate() method to be called. This call causes a repaint, and the new image data is displayed. This process repeats until the full image is created.
As you have probably observed, this is a complicated process. The Calculator classes here are meant to provide a generic approach toward manipulating images that need long calculations. You can replace the Mandelbrot class with some other calculation thread that implements CalculatorProducer, and everything should work. A good exercise is to replace Mandelbrot with another fractal calculation or some other scientific imaging calculation. (I found that replacing Mandelbrot with a Julia fractal class calculation was very easy.)
Some of the classes in this project are now presented with their full source code to explain the advanced image processing. The other classes can be found on the CD-ROM that accompanies this book.
The Mandelbrot class, shown in Listing 42.3, calculates the Mandelbrot set. It implements the Runnable interface (so it can run as a thread) and also implements the CalculatorProducer interface (so it can update an image filter of progress made in its calculations).
Listing 42.3. The Mandelbrot
class.
import java.awt.image.*; import java.awt.Image; import java.lang.*; // Class for producing a Mandelbrot set image... public class Mandelbrot implements Runnable, CalculatorProducer { int width; // The dimensions of the image... int height; CalculateFilterNotify filter; // Keeps track of image production... int pix[]; // Pixels used to construct image... CalculatorImage img; // General Mandelbrot parameters... int numColors = 256; int maxIterations = 512; int maxSize = 4; double RealMax,ImagineMax,RealMin,ImagineMin; // Define sizes to build... private Boolean stopCalc = new Boolean(false); // Stop calculations... // Create standard Mandelbrot set public Mandelbrot(int width,int height) { this.width = width; this.height = height; RealMax = 1.20; // Default starting sizes... RealMin = -2.0; ImagineMax = 1.20; ImagineMin = -1.20; } // Create zoom of Mandelbrot set public Mandelbrot(int width,int height,double RealMax,double RealMin, double ImagineMax,double ImagineMin) { this.width = width; this.height = height; this.RealMax = RealMax; // Default starting sizes... this.RealMin = RealMin; this.ImagineMax = ImagineMax; this.ImagineMin = ImagineMin; } // Start producing the Mandelbrot set... public Image getImage() { img = new CalculatorImage(width,height,this); return img.getImage(); } // Start thread to produce data... public void start(int pix[],CalculateFilterNotify filter) { this.pix = pix; this.filter = filter; new Thread(this).start(); } // See if user wants to stop before completion... public void stop() { synchronized (stopCalc) { stopCalc = Boolean.TRUE; } System.out.println("GOT STOP!"); } // Create data here... public void run() { // Establish Mandelbrot parameters... double Q[] = new double[height]; // Pixdata is for image filter updates... int pixdata[] = new int[height]; double P,diffP,diffQ, x, y, x2, y2; int color, row, column,index; System.out.println("RealMax = " + RealMax + " RealMin = " + RealMin + " ImagineMax = " + ImagineMax + " ImagineMin = " + ImagineMin); // Setup calculation parameters... diffP = (RealMax - RealMin)/(width); diffQ = (ImagineMax - ImagineMin)/(height); Q[0] = ImagineMax; color = 0; // Setup delta parameters... for (row = 1; row < height; row++) Q[row] = Q[row-1] - diffQ; P = RealMin; // Start calculating! for (column = 0; column < width; column++) { for (row = 0; row < height; row++) { x = y = x2 = y2 = 0.0; color = 1; while ((color < maxIterations) && ((x2 + y2) < maxSize)) { x2 = x * x; y2 = y * y; y = (2*x*y) + Q[row]; x = x2 - y2 + P; ++color; } // plot... index = (row * width) + column; pix[index] = (int)(color % numColors); pixdata[row] = pix[index]; } // end row // Update column after each iteration... filter.dataUpdateColumn(column,pixdata); P += diffP; // See if we were told to stop... synchronized (stopCalc) { if (stopCalc == Boolean.TRUE) { column = width; System.out.println("RUN: Got stop calc!"); } } // end sync } // end col // Tell filter that we're done producing data... System.out.println("FILTER: Data Complete!"); filter.setComplete(); } // Save the Mandelbrot set as a BMP file... public void saveBMP(String filename) { img.saveBMP(filename,pix); } }
There are two constructors for the Mandelbrot class. The default constructor produces the full Mandelbrot set and takes the dimensions of the image to calculate. The Real and Imagine variables in the constructors and the run() method are used to map the x-y axis to the real and imaginary portions of c in the following formula:
zn+1=zn2 + c
The other constructor is used to zoom in on a user-defined mapping.
A few other variables are worth noting. The variable maxIterations represents when to stop calculating a number. If this number (set to 512 in the code) is reached, the starting value of c takes a long time to head toward infinity. The variable maxSize is a simpler indicator of how quickly the current value grows. How the current calculation is related to these variables is mapped to a specific color; the higher the number, the slower the growth. If you have a fast computer, you can adjust these variables to get a richer or duller expression of the Mandelbrot set.
Once the thread is started (by the CalculatorFilter object through the start() method), the run() method calculates the Mandelbrot values and stores a color corresponding to the growth rate of the current complex number into a pixel array. When a column is complete, it uses the CalculateFilterNotify interface to let the related filter know that new data has been produced. It also checks to see whether you want to abort the calculation. Note how it synchronizes the stopCalc boolean object in the run() and stop() methods.
The calculation can take a while to complete (it takes a couple of minutes on a 486-based PC). Nevertheless, this performance is quite a testament to Java! With other interpreted, portable languages, you may be tempted to use the reset button because the calculations would take so long. With Java, you get fast visual feedback on how the set unfolds.
A good exercise is to save any partially developed Mandelbrot set; you can use the saveBMP() method here. You also need some kind of data file to indicate where the calculation was stopped.
The CalculateFilterNotify interface defines the methods needed to update an image filter that works with a calculation thread. As shown in Listing 42.4, the "data" methods are used for conveying a new batch of data to the filter. The setComplete() method indicates that the calculations are complete.
Listing 42.4. The CalculateFilterNotify
interface.
/* Interface for defining methods for updating a Calulator Filter... */ public interface CalculateFilterNotify { public void dataUpdate(); // Update everything... public void dataUpdateRow(int row); // Update one row... public void dataUpdateColumn(int col,int pixdata[]); // Update one column... public void setComplete(); }
The CalculatorProducer interface, shown in Listing 42.5, defines the method called when a calculation filter is ready to kick off a thread that produces the data used to generate an image. The CalculateFilterNotify object passed to the start() method is called by the producer whenever new data is yielded.
Listing 42.5. The CalculatorProducer
interface.
// Interface for a large calculation to produce image... interface CalculatorProducer { public void start(int pix[],CalculateFilterNotify cf); }
The CalculatorFilter class in Listing 42.6 is a subclass of ImageFilter. Its purpose is to receive image data produced by some long calculation (like the Mandelbrot set) and update any consumer of the new data's image. The CalculatorProducer, indicated by the variable cp, is what produces the data.
Listing 42.6. The CalculatorFilter
class.
import java.awt.image.*; import java.awt.Image; import java.awt.Toolkit; import java.lang.*; public class CalculatorFilter extends ImageFilter implements CalculateFilterNotify { private ColorModel defaultRGBModel; private int width, height; private int pix[]; private boolean complete = false; private CalculatorProducer cp; private boolean cpStart = false; public CalculatorFilter(ColorModel cm,CalculatorProducer cp) { defaultRGBModel = cm; this.cp = cp; } public void setDimensions(int width, int height) { this.width = width; this.height = height; pix = new int[width * height]; consumer.setDimensions(width,height); } public void setColorModel(ColorModel model) { consumer.setColorModel(defaultRGBModel); } public void setHints(int hints) { consumer.setHints(ImageConsumer.RANDOMPIXELORDER); } public void resendTopDownLeftRight(ImageProducer p) { } public void setPixels(int x, int y, int w, int h, ColorModel model, int pixels[],int off,int scansize) { } public void imageComplete(int status) { if (!cpStart) { cpStart = true; dataUpdate(); // Show empty pixels... cp.start(pix,this); } // end if if (complete) consumer.imageComplete(ImageConsumer.STATICIMAGEDONE); } // Called externally to notify that more data has been created // Notify consumer so they can repaint... public void dataUpdate() { consumer.setPixels(0,0,width,height, defaultRGBModel,pix,0,width); consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); } // External call to update a specific pixel row... public void dataUpdateRow(int row) { // The key thing here is the second to last parameter (offset) // which states where to start getting data from the pix array... consumer.setPixels(0,row,width,1, defaultRGBModel,pix,(width * row),width); consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); } // External call to update a specific pixel column... public void dataUpdateColumn(int col,int pixdata[]) { // The key thing here is the second to last parameter (offset) // which states where to start getting data from the pix array... consumer.setPixels(col,0,1,height, defaultRGBModel,pixdata,0,1); consumer.imageComplete(ImageConsumer.SINGLEFRAMEDONE); } // Called from external calculating program when data has // finished being calculated... public void setComplete() { complete = true; consumer.setPixels(0,0,width,height, defaultRGBModel,pix,0,width); consumer.imageComplete(ImageConsumer.STATICIMAGEDONE); } }
Because the ImageFilter class was explained earlier in this chapter, issues related to this class are not repeated here. However, a couple of things should be pointed out. When the image is first requested, the filter gets the dimensions the consumer wants by calling the setDimensions() method. At this point, the CalculatorFilter will allocate a large array holding the color values for each pixel.
When the original ImageProducer is finished creating the original image, the filter's imageComplete() method is called, but the filter must override this method. In this case, the CalculatorFilter starts the CalculatorProducer thread, passing it the pixel array to put in its updates. Whenever the CalculatorProducer has new data, it calls one of the four methods specified by the CalculateFilterNotify interface: dataUpdate(), dataUpdateRow(), dataUpdateColumn(), or setComplete(). The dataUpdateColumn() method is called by the Mandelbrot calculation because it operates on a column basis. In each of these cases, the filter updates the appropriate consumer pixels by using the setPixels() method, then calls the consumer's imageComplete() method to indicate the nature of the change. For the three "data" methods, the updates are only partial, so a SINGLEFRAMEDONE flag is sent. The setComplete() method, on the other hand, indicates that everything is complete, so it sets a STATICIMAGEDONE flag.
The CalculatorImage class, shown in Listing 42.7, is the glue between the CalculatorProducer class that produces the image data and the CalculatorFilter that manages it.
Listing 42.7. The CalculatorImage
class.
// This class takes a CalculatorProducer and sets up the // environment for creating a calculated image. Ties the // producer to the CalculatorFilter so that incremental updates // can be made... public class CalculatorImage { int width; // The dimensions of the image... int height; CalculatorProducer cp; // What produces the image data... IndexColorModel palette; // The colors of the image... // Create Palette only once per session... static IndexColorModel prvPalette = null; int numColors = 256; // Number of colors in palette... // User defines how big of an image they want... public CalculatorImage(int width,int height,CalculatorProducer cp) { this.width = width; this.height = height; this.cp = cp; } // Start producing the Calculator image... public synchronized Image getImage() { // Hook into the filter... createPalette(); ImageProducer p = new FilteredImageSource( new MemoryImageSource(width,height,palette, (new int[width * height]),0,width), new CalculatorFilter(palette,cp)); // Return the image... return Toolkit.getDefaultToolkit().createImage(p); } // Create a 256 color palette... // Use Default color model... void createPalette() { // Create palette only once per session... if (prvPalette != null) { palette = prvPalette; return; } // Create a palette out of random RGB combinations... byte blues[], reds[], greens[]; reds = new byte[numColors]; blues = new byte[numColors]; greens = new byte[numColors]; // First and last entries are black and white... blues[0] = reds[0] = greens[0] = (byte)0; blues[255] = reds[255] = greens[255] = (byte)255; // Fill in other entries... for ( int x = 1; x < 254; x++ ){ reds[x] = (byte)(255 * Math.random()); blues[x] = (byte)(255 * Math.random()); greens[x] = (byte)(255 * Math.random()); } // Create Index Color Model... palette = new IndexColorModel(8,256,reds,greens,blues); prvPalette = palette; } // Save the image set as a BMP file... public void saveBMP(String filename,int pix[]) { try { BmpImage.saveBitmap(filename,palette, pix,width,height); } catch (IOException ioe) { System.out.println("Error saving file!"); } } }
When an image is requested with the getImage() method, the CalculatorImage creates a color palette through an instance of the ImageColorModel class and then creates a MemoryImageSource object. This ImageProducer object produces an image initialized to all zeros (black). It is combined with an instance of the CalculatorFilter class to produce a FilteredImageSource. When the createImage() method of the AWT class is called, production of the calculated image begins.
The color palette is a randomly generated series of pixel values. Depending on your luck, these colors can be attractive or uninspiring. The createPalette() method is a good place to create a custom set of colors for this applet-if you want to have some control over its appearance. You should replace the random colors with hard-coded RGB values; you may also want to download a URL file that specifies a special color map.
This chapter covered advanced image concepts and demonstrated how to write and use image filters, rotation concepts, and special effects. Finally, a Mandelbrot applet was developed to illustrate the principles explained in this chapter.
Images give Java tremendous flexibility. Once you master image concepts, the endless possibilities of the Java graphic system are yours to explore.