Tinting Photographs
The above composition illustrates the type of rendering you might want to do in a postcard application where the user provides a picture and can select borders, adjust the picture tones, and add some text. On the Web, the user might be able to print the postcard or email it to family or friends.
Our example combines several techniques. First, the original color picture and the background texture are given a different look by the ToneAdjustmentOp filter; we will see how critical the settings of this filter are. Second, the center picture is masked to blend with the background around the edges. Third, an additional shadow is added around the image edges (on the outside), to reinforce the sense of border. That last effect uses a trick to speed up the convolution operation it uses.
We explain each of these techniques in turn, following the order in which layers are stacked in the composition.
Layer Stack
Figure 15.1 shows the different layers that make up the composition, with the most relevant control parameters. The composition uses ToneAdjustmentOp to filter both the background texture and the main image displayed in the center. The ToneAdjustmentOp object is built with the highlightColor, midtoneColor, and shadowColor parameters.
Message layer
-
message defines the text displayed in the composition.
-
messageFont, messageFillColor and messageStrokeColor control how the text is rendered.
-
messageAnchor and messageAdjustment define the text placement in the composition.
Message separation layer
- Reuses the text block created for the message layer.
messageSeparationColor defines the color used to fill the text.
message SeparationOffset defines by how much the separation text is offset under the message layer.
Image layer
-
imageFile controls the image displayed.
- The image mask is built so that 5% of the ediges are fuzzy.
Image shadow layer
imageShadowRadius defines how wide the shadow is.
imageShadowColor defines the shadow color.
iamgeShadowWidth defines the amount of extra space taken by the shadow, in addition to the image size.
Background texture (not a layer)
textureImageFile controls the image used for the background texture.
- The background texture is filtered with
ToneAdjustmentOp before it is used to build a TexturePain.
Getting the Raw Material
The composition uses two images: one is displayed in the center and one is a background texture.
BufferedImage image
= Toolbox.loadImage(
imageFile, BufferedImage.TYPE_INT_ARGB);
BufferedImage textureImage
= Toolbox.loadImage(
textureImageFile,
BufferedImage.TYPE_INT_RGB);
The two images are filtered by ToneAdjustmentOp, which is initialized with five colors. We are enforcing that black maps to black, and white to white.
Color tones[] = {Color.black,
shadowColor,
midtoneColor,
highlightColor,
Color.white };
float toneIntervals[] = {1, 1, 1, 1};
ToneAdjustmentOp toneAdjustment
= new ToneAdjustmentOp(tones,
//Colors used to tint image
toneIntervals, true);
// Use source ColorModel
Remember that the last construction parameter controls the behavior when a null destination is passed to the filter. In that case and by default, the destination image uses an IndexColorModel instance because its internal implementation allows it to create this result very efficiently. However, this format does not support alpha compositing. The boolean constructor can force ToneAdjustmentOp to use the source ColorModel for the destination. In our example, because we have loaded our images as premultiplied ARGB, the output of the filtering will have the same format.
Computing the Composition Size
The composition size is based on center image size, leaving some margins around it.
int iw = image.getWidth();
int ih = image.getHeight();
Dimension size = new Dimension(
iw + 2*marginSize, ih + 2*marginSize);
LayerComposition cmp =
new LayerComposition(size);
In previous chapters, we saw different strategies for using an image in a background: trimming, scaling, etc. Here, we use yet another strategy: we build a TexturePaint with the textureImage after it has been filtered.
textureImage =
toneAdjustment.filter(
textureImage, textureImage);
TexturePaint texturePaint
= new TexturePaint(
textureImage, cmp.getBounds());
cmp.setBackgroundPaint(texturePaint);
Because we associate the composition bounds to the TexturePaint, textureImage will be scaled up or down to fit into that rectangle. Note that it might be shrunk along one direction and magnified along the other if the background image does not have the same aspect ratio as the composition.
Now that we have created our LayerComposition and defined how its background should be filled, let us create the various Layers.
Creating the Composition Layers
Now that we have created the raw material and that we have computed the composition size, we start creating the composition layers.
Antiquing a Photograph with Fuzzy Edges
The ToneAdjustmentOp filter we used for the background texture is used again to give an antique look to the center image.
ImageLayer imageLayer = new ImageLayer(
cmp, image, Position.CENTER);
imageLayer.setImageFilter(toneAdjustment);

Figure 15.3 Image mask
To create the fuzzy edges, we build a mask that is transparent around the edges and that we attach to imageLayer.
BufferedImage mask = makeImageMask(iw, ih);
Rectangle imageRect = new Rectangle(
marginSize, marginSize, iw, ih);
imageLayer.setLayerMask(mask, imageRect);
Note how we specify the imageRect to match the image's position in the composition. Remember that the mask is positioned in device space.
Figure 15.3 illustrates the mask built by the makeImageMask method.
The following code segment shows how that method is implemented.
private BufferedImage makeImageMask(
int iw, int ih){
BufferedImage mask
= new BufferedImage(
iw, ih, BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = mask.createGraphics();
g.setPaint(Color.black);
g.fillRect(0, 0, iw, ih);
GeneralPath triangle = new GeneralPath();
triangle.moveTo(0, 0);
triangle.lineTo(iw/2, ih/2);
triangle.lineTo(0, ih);
triangle.closePath();
GradientPaint fuzzyEdgePaint
= new GradientPaint(
0, 0, Color.black,
iw*.05f, 0, Color.white);
g.setPaint(fuzzyEdgePaint);
g.fill(triangle);
g.rotate(Math.PI, iw/2, ih/2);
g.fill(triangle);
g.setTransform(new AffineTransform());
triangle = new GeneralPath();
triangle.moveTo(0, 0);
triangle.lineTo(iw/2, ih/2);
triangle.lineTo(iw, 0);
triangle.closePath();
fuzzyEdgePaint = new GradientPaint(
0, 0, Color.black,
0, ih*.05f,
Color.white);
g.setPaint(fuzzyEdgePaint);
g.fill(triangle);
g.rotate(Math.PI, iw/2, ih/2);
g.fill(triangle);
return mask;
}
This is a good example both of the Graphics2D Paint context attribute being transformed. The fuzzyEdgePaint object is defined in user space, using the triangle Shape's metrics. When the AffineTransform attribute in g's graphic context (see g.rotate(..)) is set to a rotation, the transformation applies to the triangle shape we fill (see g.fill(triangle)), but the Paint is also rotated: the gradient rotates with the shape.
As we explained in Chapter 9 (see "Custom Paints" on page 304), the Paint implementer must program this behavior. We see in the next chapter that this property can be a powerful tool to create striking effects.
Fast Blur to Create a Border Effect
To reinforce the notion of border, we add another Layer to cast a shadow around the edges of the center image. Creating this effect involves the mechanisms we explained in Chapter 11: create a ShapeLayer that fills a Shape with the shadow color and attach a ConvolveOp filter to the ShapeLayer object.
Usually, the larger the convolution kernel, the better the quality of the shadow. However, the larger the kernel, the more computation intensive the filtering becomes.
What Can be Done to Reduce the Computation Time?
In our example, we use a trick that consists in scaling down the image, convolving the smaller image with a smaller kernel (faster), and then scaling the image up again. Here is what the code does to create a single filter that chains those operations.
AffineTransform shrinkShadowTxf
= AffineTransform.getScaleInstance(
1/shadowScaleFactor,
1/shadowScaleFactor);
AffineTransform blowUpShadowTxf
= AffineTransform.getScaleInstance(
shadowScaleFactor,
shadowScaleFactor);
AffineTransformOp shrinkShadow
= new AffineTransformOp(
shrinkShadowTxf, null);
AffineTransformOp blowUpShadow
= new AffineTransformOp(blowUpShadowTxf,
AffineTransformOp.TYPE_BILINEAR);
GaussianKernel blurKernel
= new GaussianKernel((int)(
imageShadowBlurRadius/shadowScaleFactor));
ConvolveOp shadowBlur = new ConvolveOp(
blurKernel);
CompositeOp compositeOp = new CompositeOp(
new BufferedImageOp[]{
shrinkShadow, shadowBlur,
blowUpShadow });
Notice these important points about this code.
- The convolution applies on a smaller image and with a proportionally smaller kernel as well. That is why we divided the blur radius by the scale factor when we built the
GaussianKernel object. Applying the convolution with a smaller kernel and on a smaller image explains the performance gain.
- Experience shows that using a bilinear interpolation for the second
AffineTransformOp filter provides more acceptable results than does the nearest neighbor interpolation.
- This technique does not create an accurate result: the result is not identical to applying a larger convolution on the original image. However, for a shadow, the approximation is acceptable.
- The technique is only worthwhile with very large kernels. It adds a bilinear interpolation to the convolution processa costly operation. Therefore, it is only useful if the processing saved by the smaller convolution on a smaller image offsets processing added by the interpolation.
This technique also speeds up computation of large elevation maps for the LightOp filter and also creation of glows, as presented in Chapter 12. Another way to speed up convolution is to apply a technique called kernel separation. This technique can be applied when the convolution kernel is the product of 2 one-dimensional kernels, as in:
The GaussianKernel class represents a kernel with such characteristics. The class contains a method that will return the 2 one-dimensional kernels into which it can be separated. For example, we could write:
GaussianKernel kernel =
new GaussianKernel(kernelRadius);
Kernel seperatedKernels[] =
kernel.separateKernel();
ConvolveOp convolveA =
new ConvolveOp(separatedKernels[0]);
ConvolveOp convolveB =
new ConvolveOp(separatedKernels[1]);
CompositeOp convolve =
new CompositeOp(convolveA, convolveB);
At the time this book was written, a bug in the implementation prevented effective use of one-dimensional kernels, so they were not used in the book. However, by the time this book is printed, the latest version of the Java platform will have addressed this issue, and one-dimensional kernels should be a workable alternative to speed up convolution.
Making Text Legible Again
On several occasions we said that it is important to make text legible, and we discussed some techniques, such as adjusting brightness or color, to address that need. In this example, we use a simple technique to make text legible: we render it twice, with contrasting colors and at slightly different locations. This technique almost guarantees a sharp contrast on most of the text, enough to make it easily readable. Here is how we implement the technique.
//
// First, process the miter limit,
// to avoid spikes in the stroked text
float miterLimit = Float.MAX_VALUE;
miterLimitAngle %= 180;
if(miterLimitAngle<0)
miterLimitAngle *= -1;
if(miterLimitAngle!=0)
miterLimit = (float)(1/Math.sin((
Math.PI*miterLimitAngle)/(360.0)));
//
// Build a BasicStroke with no spikes
//
BasicStroke messageStroke
= new BasicStroke(messageStrokeWidth,
BasicStroke.CAP_ROUND,
BasicStroke.JOIN_MITER,
miterLimit);
//
// Build renderers for the text and
// the separation layer
FillRenderer textFill = new FillRenderer(
messageFillColor);
StrokeRenderer textStroke
= new StrokeRenderer( messageStrokeColor,
messageStroke);
CompositeRenderer textPainter
= new CompositeRenderer(textStroke,
textFill);
FillRenderer messageSeparationPainter
= new FillRenderer(messageSeparationColor);
//
// Compute text position
//
Position textPosition =
new Position(
messageAnchor,
messageAdjustment.width,
messageAdjustment.height);
//
// Build text layer
//
int noWrapping = -1;
TextLayer messageLayer
= new TextLayer(cmp, message, messageFont,
textPainter, textPosition,
noWrapping,
messageAlignment);
//
// Build text separation layer
//
// Extract text shape, at its final position
Shape textBlock =
messageLayer.createTransformedShape();
ShapeLayer messageSeparationLayer
= new ShapeLayer(cmp, textBlock,
messageSeparationPainter);
AffineTransform messageSeparationAdjustment
= AffineTransform.getTranslateInstance(
messageSeparationOffset.width,
messageSeparationOffset.height);
messageSeparationLayer.setTransform(
messageSeparationAdjustment);
Conclusion
We have seen how to mix different techniques to tint a picture, create a fancy border effect, and to make text legible in yet another way.
The ToneAdjustmentOp filter that we used again can be used for various purposes; we used it in Chapter 2 to tint images and create buttons. There are situations where it might be important to give a common feel to a set of images, and ToneAdjustmentOp can be used to that end.
Figure 15.4 illustrates how different settings for the filter create different moods.
Note how the pictures where low saturation colors have been used seem to have an antique look (for example, lower-left and right images). Opposing those images, where more saturated colors are used, the image has a more dynamic and modern feel.
Creating a sense of unity in a design is an important aspect to achieve attractive results. ToneAdjustmentOp is one of the many tools that we can use along with simpler techniques such as proper font selection and consistent color usage.
GLF Download
Chapter 15
Back to Reader Survey