DEV Community

Cover image for Enhancing Image Quality with Uniform Pixel Intensity Distribution
Rama Reksotinoyo
Rama Reksotinoyo

Posted on • Edited on

Enhancing Image Quality with Uniform Pixel Intensity Distribution

A week ago i saw my deprecated materials of image processing when i was in 5 semester on my bachelor degree, and was like, wow this method is gorgeous (in my eyes). I opened the code i wrote on that momment, and i didnt found anything cause i code on matlab, whose method is brutally abstracted (with all due respect with matlab coder).

What is histogram equalization
Histogram equalization is one of the technique for increase the contrass of the image, with this the distribution of the data is made uniform or the data is made to have an even uniform distribution. The mathematical calculation is by changing the degree of grayness of a pixel with a new degree of grayness with a transformation function

Goal

Image description
Image from mathwork by matlab

Step

  • First that i gonna do is to find the distribution of each pixels.
  • Find the probability density function.
  • Find the cumulative distribution function.
  • Calculate the cumulative distribution function with the maximum value, in this case the value is 2255 (8-bit).
  • Find the histogram equalizaiton level.
  • Decode the data and transform to image.

code
To make my objectives easier i'm using golang, to get there im using Image library as golang standaed library for image handling. If you are curious why I use go, because I am deepening my ability to use golang language. There may be some of my future writing content about rust because of this news.

Take a seat and get your coffee, this might be a bit boring.

type ImageData struct {
    Pixels [][]color.Gray
    Width  int
    Height int
}

type Histogram struct {
    Nk          map[uint8]int
    Pdf         map[uint8]float64
    Cdf         map[uint8]float64
    CdfMultiply map[uint8]float64
    HistEQ      map[uint8]int
}

type ImageProcessor struct {
}
Enter fullscreen mode Exit fullscreen mode

First, I set up a few objects that will represent the image and the method or step used, in this case I am using a grayscale image.

func (ip *ImageProcessor) LoadImage(filepath string) (*ImageData, error) {
    // gray, err := ioutils.LoadImage(filepath)

    file, err := os.Open(filepath)
    if err != nil {
        return nil, fmt.Errorf("failed to load file: %v", err)
    }
    defer file.Close()

    img, err := png.Decode(file)
    if err != nil {
        return nil, fmt.Errorf("failed to open png format: %v", err)
    }

    bounds := img.Bounds()
    width, height := bounds.Max.X, bounds.Max.Y

    pixels := make([][]color.Gray, height)
    for y := 0; y < height; y++ {
        pixels[y] = make([]color.Gray, width)
        for x := 0; x < width; x++ {
            pixels[y][x] = color.GrayModel.Convert(img.At(x, y)).(color.Gray)
        }
    }

    if err != nil {
        return nil, err
    }

    new_width := len(pixels[0])
    new_height := len(pixels)

    return &ImageData{
        Pixels: pixels,
        Width:  new_width,
        Height: new_height,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The LoadImage function, part of the ImageProcessor type, serves the purpose of reading an image file. It takes a file path as input, and then retrieve the image data, and calculates the width and height of the image. In case of any errors during the loading process, it returns nil along with the encountered error. Upon successful loading, it constructs and returns an ImageData struct containing the pixel information (in grayscale), as well as the width and height of the loaded image. In essence, this function facilitates the loading of image data from a specified file, offering crucial details about the pixel composition and dimensions of the image.

func (ip *ImageProcessor) Nk(pix []uint8) map[uint8]int {
    count := make(map[uint8]int)
    for i := 0; i <= 255; i++ {
        count[uint8(i)] = 0
    }
    for _, v := range pix {
        count[v]++
    }
    return count
}
Enter fullscreen mode Exit fullscreen mode

Furthermore, the Nk method aims to find the distribution of each pixel in the input image. Yes, it is just a function to find the number of each pixel, nothing special.

Then, the function above is the step to find pdf (probability density function). The pdf is useful to help calculate the probability that the value of x is in an interval. What needs to be underlined, pdf is not a probability, because it only looks at the height of the area under the curve on a distribution curve.

func (ip *ImageProcessor) Pdf(pix map[uint8]int, nPix int) map[uint8]float64 {
    prob := make(map[uint8]float64)

    var maxPixValue int
    for _, v := range pix {
        if v > maxPixValue {
            maxPixValue = v
        }
    }

    for i := 0; i <= maxPixValue; i++ {
        pixel := uint8(i)
        v, exists := pix[pixel]
        if exists {
            prob[pixel] = float64(v) / float64(nPix)
        } else {
            prob[pixel] = 0.0
        }
    }
    return prob
}
Enter fullscreen mode Exit fullscreen mode

Method above is designed to calculate the probability density function (PDF) of pixel intensities in a grayscale image. It takes two parameters: pix, a map representing the count of occurrences for each pixel intensity, and nPix, the total number of pixels in the image. The function iterates through possible pixel intensity values (from 0 to a specified maximum value, in this case, 215), calculating the probability of each intensity's occurrence in the image. If a given intensity is present in the pix map, it computes the probability as the ratio of its count to the total number of pixels (nPix). If the intensity is not found in the map, indicating no occurrences, the probability is set to 0.0. The resulting probabilities are stored in a map and returned, providing a representation of the likelihood distribution of pixel intensities in the grayscale image.

Next is the calculation of the cumulative distribution function (cdf). If pdf is the frequency or height of a value on a curve, then cdf is used to calculate the probability of the integral of the pdf function, in this case cdf in discrete space.

func (ip *ImageProcessor) Cdf(prob map[uint8]float64) map[uint8]float64 {
    sk := make(map[uint8]float64)
    var temp float64

    for i := 0; i <= len(prob)-1; i++ {
        pixel := uint8(i)
        if i == 0 {
            sk[pixel] = prob[pixel]
        } else {
            temp = sk[uint8(i-1)]
            sk[pixel] = temp + prob[pixel]
        }
    }
    return sk
}
Enter fullscreen mode Exit fullscreen mode

The entire pixel range from 0-255 will be stored in the map as keys, the value of each key is its cdf. So there will be values from 0 to ~1. The cdf function above is continued by multiplying each value by its maximum value. This is done to convert the CDF into a full range of values, i.e. from 0 to the desired maximum value. In this way, it is expected to expand the pixel intensity distribution so that it covers the entire possible range.

func (ip *ImageProcessor) HistogramEqualizationLevel(cdfMultiply map[uint8]float64) map[uint8]int {
    histEQ := make(map[uint8]int)

    for i := 0; i <= 255; i++ {
        histEQ[uint8(i)] = int(cdfMultiply[uint8(i)] + 0.5)
    }

    return histEQ
}
Enter fullscreen mode Exit fullscreen mode

The function above handle finding the desired histogram equalization level. The number 255 is a static number that I use because the maximum number of 8bit integers is 255, or values from 0-255. That's it.

The next step is the last step.

func (ip *ImageProcessor) ApplyHistogramEqualization(originalImage [][]color.Gray, eqLevel map[uint8]int) [][]color.Gray {
    height := len(originalImage)
    width := len(originalImage[0])

    equalizedImage := make([][]color.Gray, height)
    for y := 0; y < height; y++ {
        equalizedImage[y] = make([]color.Gray, width)
        copy(equalizedImage[y], originalImage[y])
    }

    for y := 0; y < height; y++ {
        for x := 0; x < width; x++ {
            pixel := originalImage[y][x]
            equalizedPixelValue := eqLevel[uint8(pixel.Y)]
            equalizedImage[y][x].Y = uint8(equalizedPixelValue)
        }
    }

    return equalizedImage
}

Enter fullscreen mode Exit fullscreen mode

Finally, there is the process of applying histogram equalization to the grayscale image. The first thing to do is to take the dimensions, followed by building an image with predetermined dimensions. The last process is the application of histogram equalization to the previously created image.

At this point, the histogram equalization process should have been completed. But in order to be formed as a.Gray image on the golang, I created a helper function for the equalized data to be applied to the grayscale image.

func (ip *ImageProcessor) CreateImageFromGrayPixels(pixels [][]color.Gray) *image.Gray {
    height := len(pixels)
    width := len(pixels[0])

    img := image.NewGray(image.Rect(0, 0, width, height))

    for y := 0; y < height; y++ {
        for x := 0; x < width; x++ {
            img.Set(x, y, pixels[y][x])
        }
    }

    return img
}
Enter fullscreen mode Exit fullscreen mode

Final output of the histogram equalization implementation below.

Image description

It can be seen in the output that the image appears sharper based on what the eye sees. The mathematical proof is that, in the histogram, the range changes from the original approximately 50-200 pixel variation, after equalization the histogram becomes 0 to 250, indicating that the pixel intensity distribution has been changed such that the contrast between the various pixel intensity values is enhanced. By increasing the contrast, the difference between dark and light areas in the image becomes more pronounced, so the image looks sharper and the details are better defined. This results in a more distinct difference between objects in the image, which makes it more recognizable to the human eye.

You can see the full of code on github

Top comments (0)