Image blending (or image mixing) is the process of combining two images to produce a new target image. Images can be mixed in many different ways, usually called blend modes.
In this topic, we'll learn how to programmatically perform the basic blending techniques which can be found in every image processing software. For simplicity, we will only focus on blending images of the same size and in 24-bit color schemes, without the use of the alpha channel.
Color components values
We've already worked with the Color class (the java.awt package) and used its constructor with integer numbers. However, for image transformation, the float representation is much more convenient. The Color class has taken care of it, and each of the Red, Green, and Blue component colors can be represented as a float number that lies between 0 and 1. The 0 value represents an integer value of 0, while 1 represents an integer value of 255.
Here is the Color class constructor with integers (0 – 255):
Color(red: Int, green: Int, blue: Int)
And here is the Color class constructor with floats (values between 0 and 1):
Color(red: Float, green: Float, blue: Float)
A Color instance can be initialized both ways, and the colors can be retrieved either as integers:
val color = Color(0.1f, 0.5f, 1.0f)
val red: Int = color.red // equal to 26
val green: Int = color.green // equal to 128
val blue: Int = color.blue // equal to 255
or as floats, by using the Color class getColorComponents(compArray: FloatArray) method as follows:
val color = Color(0.1f, 0.5f, 1.0f)
// Use getRGBComponents() with null parameter
val (red: Float, green: Float, blue: Float) = color.getRGBComponents(null)
// First create a float array and then call getRGBComponents()
val compArray = FloatArray(3)
color.getColorComponents(compArray)
The parameter compArray is an array that this method fills with the color and alpha components of the Color instance. If this parameter is null, then getRGBComponents() creates an array with the color components and returns it.
Single pixel blending
In all the cases we are going to discuss, each target pixel is produced by combining just one pixel from each blending image. So, the target pixel color is a function of two pixel colors – one from each input image. For each blending mode, we will write a function like the following:
val targetPixelColor: Color = blendPixels(firstPixelColor, secondPixelColor, otherParameter)
where targetPixelColor is the Color instance of the target pixel, firstPixelColor and SecondPixelColor are the Color instances of the two input pixels, and otherParameter is a possible additional parameter needed for certain blending methods.
The blendPixels() function has to retrieve each color value (Red, Green, and Blue) from the input Color parameters and calculate new color values for the target pixel. Then, a new Color instance is created and returned.
Of course, there is no requirement for using such a function, since the code can be inlined for better performance, but it is used here just for educational purposes.
The code for almost all cases is exactly the same, and the only part that is changed is the implementation of this function. Below is a sample code for generic image blending, with the assumption that both input images have exactly the same dimensions. This code is simplified and doesn't have any checking.
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
fun main() {
// Create a BufferedImage instance for the first input file
val inputFile1 = File("image1.png")
val image1: BufferedImage = ImageIO.read(inputFile1)
// Create a BufferedImage instance for the second input file
val inputFile2 = File("image2.png")
val image2: BufferedImage = ImageIO.read(inputFile2)
// Create a BufferedImage instance for the output file
val outputImage: BufferedImage = BufferedImage(image1.width, image1.height, BufferedImage.TYPE_INT_RGB)
// For every pixel in the same position in the two input images
for (x in 0 until image1.width) {
for (y in 0 until image1.height) {
// Read the Color of the pixel in the position (x, y) of the first image
val color1 = Color(image1.getRGB(x, y))
// Read the Color of the pixel in the position (x, y) of the second image
val color2 = Color(image2.getRGB(x, y))
// Calculate the new Color of the target pixel
val color = blendPixels(color1, color2)
// Set the Color of the pixel in the position (x, y) in the target image
outputImage.setRGB(x, y, color.rgb)
}
}
// Save the output image
val outputFile = File("out.png")
ImageIO.write(outputImage, "png", outputFile)
}
fun blendPixels(firstPixelColor: Color, secondPixelColor: Color): Color {
// Blend function is implemented here
}Normal or linear blend
In normal blending, each pixel color value (Red, Green, and Blue) is a linear combination of the input image's corresponding pixel color values. For this method, the weight parameter is needed. This takes values between 0 and 1 and denotes the impact of each input image on the output image. If the weight value is 1, then only the first image is output. If it is 0, then only the second image is output. For any other value, the images are blended together. Here is the linear blending function:
fun linearBlend(firstPixelColor: Color, secondPixelColor: Color, weight: Float): Color {
val (red1: Float, green1: Float, blue1: Float) = firstPixelColor.getRGBComponents(null)
val (red2: Float, green2: Float, blue2: Float) = secondPixelColor.getRGBComponents(null)
return Color(
weight * red1 + (1 - weight) * red2,
weight * green1 + (1 - weight) * green2,
weight * blue1 + (1 - weight) * blue2
)
}
Look at an example of two images linearly blended with the weight equal to 0.5, which means that both input pixels equally contribute to the target image.
Considering the order of operations, we can use integer arithmetic. In the following code, the weight is expressed as an integer percentage between 0 and 100 and only integer operations are used. In this case, multiplication has to be performed before division:
fun linearBlend(firstPixelColor: Color, secondPixelColor: Color, weight: Int): Color {
return Color(
(weight * firstPixelColor.red + (100 - weight) * secondPixelColor.red) / 100,
(weight * firstPixelColor.green + (100 - weight) * secondPixelColor.green) / 100,
(weight * firstPixelColor.blue + (100 - weight) * secondPixelColor.blue) / 100
)
}Gradient blend
Linear blend can be used with a varying weight parameter, based on the position of the pixels. This can create a gradient effect from one side of the target image to the other – either horizontally, vertically, or diagonally. At one end, the first image prevails, while at the other end, the second image prevails. Below is an example code for creating horizontal blending. The blending function stays the same, but the weight is calculated based on the horizontal position of each pixel.
for (x in 0 until image1.width) {
for (y in 0 until image1.height) {
val color1 = Color(image1.getRGB(x, y))
val color2 = Color(image2.getRGB(x, y))
// Calculate the weight for each pixel
var weight = x.toFloat / image1.width.toFloat()
val color = linearBlend(color1, color2, weight)
outputImage.setRGB(x, y, color.rgb)
}
}
Below is an example of a horizontal linear gradient blend. On the left side of the target image, the first image prevails, while on the right side, the second image prevails.
Multiply blend
In this blend mode, both input colors (Red, Green, or Blue as floats within the 0 – 1 range) are multiplied together. For example, if red1 and red2 are the red color components of the two corresponding pixels, then the red component red of the target pixel is red = red1 * red2.
When the color of a pixel is multiplied by another color that is black, then the result is black. When the color of a pixel is multiplied by another color that is white, then the pixel color remains unchanged. The outcome of this blending is always darker than the original images.
Below you can see the multiply blend function and a blending example.
fun multiplyBlend(firstPixelColor: Color, secondPixelColor: Color): Color {
val (red1: Float, green1: Float, blue1: Float) = firstPixelColor.getRGBComponents(null)
val (red2: Float, green2: Float, blue2: Float) = secondPixelColor.getRGBComponents(null)
return Color(red1 * red2, green1 * green2, blue1 * blue2)
}
Multiply blending can be used to darken an image by blending it with itself or by blending it with the solid gray color. Take a look at an example of an image that has been multiply blended with itself:
And here is an example of an image that has been multiply blended with the solid gray color Color(0.5f, 0.5f, 0.5f):
Screen blend
The screen mode is the exact opposite of the multiply mode. In this blend mode, both corresponding colors are inverted, multiplied together, and then inverted again. Here, inversion means color inversion. That is, subtract the color value from 1 if it is a float in the range 0 to 1; or from 255 if it is an integer in the range of 0 to 255. This has the effect of the getting the negative color of the original. For example, if red1 and red2 are the red color components of the two corresponding pixels, then the red component red of the target pixel is red = 1 - ((1 - red1) * (1 - red2)).
When the color of a pixel is multiplied by another color that is white, the result is white. When the color of a pixel is multiplied by another color that is black, then the pixel color remains unchanged. The outcome of this blending is always lighter than the original images.
Take a look at the screen blend function and a blending example:
fun screenBlend(firstPixelColor: Color, secondPixelColor: Color): Color {
val (red1: Float, green1: Float, blue1: Float) = firstPixelColor.getRGBComponents(null)
val (red2: Float, green2: Float, blue2: Float) = secondPixelColor.getRGBComponents(null)
return Color(
1 - ((1 - red1) * (1 - red2)),
1 - ((1 - green1) * (1 - green2)),
1 - ((1 - blue1) * (1 - blue2))
)
}
Screen blending can be used to lighten an image by blending it with itself or by blending it with the solid gray color. Below is an example of an image that has been screen blended with itself:
And here is an example of an image that has been screen blended with the solid gray color Color(0.2f, 0.2f, 0.2f):
Extending the basic blend methods
The above-mentioned blending methods can be used with images with different sizes or just subsets of the total corresponding pixels. Moreover, the alpha channel of an image can be used together with the blending methods. The options are unlimited.
For example, here we join together two images (along the horizontal axis) and use gradient blending for a small section between them so as to create a smooth transition.
The left side of the target image is the left side of the first image, while the right side of the target image is the right side of the second image. In the middle, gradient blending is applied.
Conclusion
Image blending is a really huge area of image processing, and our account is far from exhaustive. In this topic, we've learned some basic image blending methods, which, however, can be used to produce amazing outcomes.