Normalize the brightness of an image
(using a imageAtomicMax)
Here’s an updated of the program above. To better simulate what I had in mind, instead of a static image it renders an animation of moving and sometimes overlapping circles. The circles slowly grow and shrink, because I wanted that sometimes there’s no overlap between them.
When that happens, all circles have the same brightness, and they get normalized to white.
When several circles overlap, the overlapping part is the brightest of the image, and that part turns white thanks to the program.
The program creates two render targets: one where we draw our animated graphics, and another to hold the brightest pixel. This one doesn’t need to be a render target, it could be a color buffer, but the render target makes it easy to clear. ColorBuffer does have a fill method, but it didn’t work for me with a UINT32_INT texture.
There are also two compute shaders. One is used to find the maximum brightness of the source image. The second compute shader is used to apply the normalization to the source image (instead of downloading the luminosity value to the CPU and using tint, as the original program above did).
Unrelated to the main concept: times acts a circular array to average the execution times printed to the console. Without doing this the values were jumping up and down, and I couldn’t figure out if this new approach was more or less efficient than using atomic counters. Now I know that the execution time went down for me, from 0.11 ms to 0.07 ms (give or take).
import org.openrndr.application
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.*
import org.openrndr.draw.font.BufferAccess
import org.openrndr.extensions.Screenshots
import org.openrndr.extra.imageFit.imageFit
import org.openrndr.extra.noise.simplex
import org.openrndr.math.IntVector3
import kotlin.math.sin
import kotlin.system.measureNanoTime
/**
* Real time normalization of brightness levels of an image using
* `imageAtomicMax`. See `atomicCompute03.kt` for an older approach.
*/
fun main() = application {
configure {
width = 640
height = 480 * 2
}
program {
// A render target holding the brightest pixel and a shortcut to its colorBuffer
val pixelRT = renderTarget(1, 1) {
colorBuffer(format = ColorFormat.R, type = ColorType.UINT32_INT)
}
val pixelCB = pixelRT.colorBuffer(0)
// A render target with our real-time visuals and a shortcut to its colorBuffer
val canvasRT = renderTarget(1920, 1080) {
colorBuffer(type = ColorType.FLOAT32)
depthBuffer()
}
val canvasCB = canvasRT.colorBuffer(0)
// The compute shader to update the buffer holding the brightest pixel
val csMax = computeStyle {
computeTransform = """
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
vec3 rgb = imageLoad(p_img, pixel).rgb;
// We multiply by 1000 because we work with uint, not floats.
uint lum = uint(dot(rgb, vec3(0.299, 0.587, 0.114)) * 1000.0);
// if lum > counter then counter = lum
imageAtomicMax(p_counter, ivec2(0), lum);
""".trimIndent()
}
csMax.workGroupSize = IntVector3(32, 24, 1)
// The compute shader to normalize the luminosity based on brightest pixel found
val csAdjust = computeStyle {
computeTransform = """
ivec2 pixel = ivec2(gl_GlobalInvocationID.xy);
vec4 rgba = imageLoad(p_img, pixel);
float lum = imageLoad(p_counter, ivec2(0)).r / 1000.0;
imageStore(p_img, pixel, rgba / lum);
""".trimIndent()
}
csAdjust.workGroupSize = IntVector3(32, 24, 1)
val times = mutableListOf<Double>()
extend(Screenshots())
extend {
// Update our real-time visuals
drawer.isolatedWithTarget(canvasRT) {
ortho(canvasRT)
clear(ColorRGBa.BLACK)
stroke = null
fill = ColorRGBa.WHITE.opacify(0.01)
repeat(25) {
circle(
simplex(it, seconds * 0.1, it * 0.1) * bounds.width * 0.5 + bounds.width * 0.5,
simplex(-it, seconds * 0.1, it * 0.1) * bounds.height * 0.5 + bounds.height * 0.5,
sin(seconds * 0.1) * 30.0 + 50.0
)
}
}
// Draw the non-adjusted image above
drawer.clear(ColorRGBa.PINK)
drawer.imageFit(canvasCB, drawer.bounds.sub(0.0, 0.0, 1.0, 0.5))
// Execute a compute shader to find the brightest pixel and store how long it took in milliseconds
val t = measureNanoTime {
csMax.image("img", canvasCB.imageBinding(0, ImageAccess.READ))
csMax.image("counter", pixelCB.imageBinding(0, BufferAccess.READ_WRITE))
csMax.execute(canvasCB.width / csMax.workGroupSize.x, canvasCB.height / csMax.workGroupSize.y)
}
// Print the average execution time of the last 10 runs in milliseconds: eventually 0.07 ms on AMD GPU
times.add(0, t / 1_000_000.0)
times.dropLast((times.size - 10).coerceAtLeast(0))
println(String.format("%.2f ms", times.average()))
// Execute a compute shader to normalize the pixel luminosities based on the found value
csAdjust.image("img", canvasCB.imageBinding(0, ImageAccess.READ_WRITE))
csAdjust.image("counter", pixelCB.imageBinding(0, BufferAccess.READ))
csAdjust.execute(canvasCB.width / csMax.workGroupSize.x, canvasCB.height / csMax.workGroupSize.y)
// Draw the adjusted image below
drawer.imageFit(canvasCB, drawer.bounds.sub(0.0, 0.5, 1.0, 1.0))
// Reset the counter before the next animation frame
drawer.isolatedWithTarget(pixelRT) {
ortho(pixelRT)
clear(ColorRGBa.TRANSPARENT)
}
}
}
}
This program only demonstrates the concept, but it’s definitely not perfect. There are some issues with it:
- A single bright pixel can alter the whole look. We might prefer to allow some white pixels in the final image. For performance, we could also scale down the texture before searching for the bright pixel: we probably don’t need to process every single pixel in the source image.
- There’s no interpolation in the brightness changes, which is unpleasant. It would be better if we were setting the target brightness and the system interpolated towards it.
- It might be nice to apply a luminosity curve after the normalization, to add or reduce contrast.