.netvb.netimagegraphicsgdi+

Analyze colors of an Image


I have part of an Image cropped out, and 2 Color Ranges (H/S/L) defined via 12 trackbars. I also have a "Precision/Speed" slider ranging from 1 to 10.

I need to analyze how many pixels of the Image fall into each of the specified Color Ranges.
Based on the precision/speed slider, I skip some rows/pixels.

Its working great but its too slow. With high precision (trackbar value = 1), it takes about 550 ms.
With low precision but high speed (trackbar value = 10) it takes about 5 ms.

Is there a way to speed this code up? Ideally I would need it to be 5 times faster.

 For y As Integer = 0 To 395
    If y Mod 2 = 0 Then
        startpixel = tbval / 2
    Else
        startpixel = 0
    End If

    If y Mod tbval = 0 Then
        For x As Integer = 0 To 1370
            If x Mod tbval - startpixel = 0 Then
                analyzedpixels = analyzedpixels + 1

                Dim pColor As Color = crop.GetPixel(x, y)
                Dim h As Integer = pColor.GetHue
                Dim s As Integer = pColor.GetSaturation * 100
                Dim l As Integer = pColor.GetBrightness * 100

                'verify if it is part of the first color

                If h >= h1min And h <= h1max And s >= s1min And s <= s1max And l >= l1min And l <= l1max Then
                    color1pixels = color1pixels + 1
                End If

                If h >= h2min And h <= h2max And s >= s2min And s <= s2max And l >= l2min And l <= l2max Then
                    color2pixels = color2pixels + 1
                End If
            End If
        Next
    End If
Next

EDIT:

This is the working code..

Dim rect As New Rectangle(0, 0, crop.Width, crop.Height)
Dim bdata As Imaging.BitmapData = crop.LockBits(rect, Imaging.ImageLockMode.ReadOnly, crop.PixelFormat)

Dim ptr As IntPtr = bdata.Scan0
Dim bytes As Integer = Math.Abs(bdata.Stride) * crop.Height
Dim rgbValues As Byte() = New Byte(bytes - 1) {}
System.Runtime.InteropServices.Marshal.Copy(ptr, rgbValues, 0, bytes)

For i As Integer = 0 To crop.Height - 1

    If i Mod 2 = 0 Then
        startpixel = tbval / 2
    Else
        startpixel = 0
    End If

    If i Mod tbval = 0 Then
        For j As Integer = 0 To crop.Width - 1
            If j Mod tbval - startpixel = 0 Then

                analyzedpixels = analyzedpixels + 1
                Dim position = (bdata.Stride * i) + j * 4
                Dim c = Color.FromArgb(BitConverter.ToInt32(rgbValues, position))
                Dim h As Integer = c.GetHue
                Dim s As Integer = c.GetSaturation * 100
                Dim l As Integer = c.GetBrightness * 100

                If h >= h1min And h <= h1max And s >= s1min And s <= s1max And l >= l1min And l <= l1max Then
                    color1pixels = color1pixels + 1
                End If

                If h >= h2min And h <= h2max And s >= s2min And s <= s2max And l >= l2min And l <= l2max Then
                    color2pixels = color2pixels + 1
                End If
            End If
            stride += 4
        Next
    End If
Next

crop.UnlockBits(bdata)

Solution

  • When performing sequential operations on a Bitmap's color data, the Bitmap.LockBits method can provide a huge increase in performace, since the Bitmap data needs to be loaded in memory just once, as opposed to sequential GetPixel/SetPixel calls: each call will load a partial section of the Bitmap data in memory and then discard it, to repeat the process when these methods are called again.

    If a single call to GetPixel/SetPixel is needed instead, these methods may have a performace advantage over Bitmap.LockBits(). But, in this case, performace is not a factor, in practice.

    How Bitmap.LockBits() works:

    This is the function call:

    public BitmapData LockBits (Rectangle rect, ImageLockMode flags, PixelFormat format);
    // VB.Net
    Public LockBits (rect As Rectangle, flags As ImageLockMode, format As PixelFormat) As BitmapData
    

    Important notes about the Stride:

    As mentioned before, the Stride (also called scan-line) represents the number of bytes that compose a single line of pixels. Because of hardware alignment requirements, it's always rounded up to a 4-bytes boundary (an integer number multiple of 4, i.e., DWORD-aligned).

    Stride =  [Bitmap Width] * [bytes per Color]
    Stride += (Stride Mod 4) * [bytes per Color]
    

    This is one of the reasons why we always work with Bitmaps created with PixelFormat.Format32bppArgb: the Bitmap's Stride is always already aligned to the required boundary.

    The more generic formula to calculate the stride is defined as:

    Stride = (((([Bitmap Width] * [Bit Count]) + 31) & ~31) >> 3)
    ' So...
    [Bitmap Size] = Math.Abs([Bitmap Height]) * Stride
    

    What if the Bitmap's format is instead PixelFormat.Format24bppRgb (3 bytes per Color)?

    If the Bitmap's Width multiplied by the Bytes per Pixels is not a multiple of 4, the Stride will be padded with 0s to fill the gap.

    A Bitmap of size (100 x 100) will have no padding in both 32 bit and 24 bit formats:

    100 * 3 = 300 : 300 Mod 4 = 0 : Stride = 300
    100 * 4 = 400 : 400 Mod 4 = 0 : Stride = 400
    

    It will be different for a Bitmap of size (99 x 100):

    99 * 3 = 297 : 297 Mod 4 = 1 : Stride = 297 + ((297 Mod 4) * 3) = 300
    99 * 4 = 396 : 396 Mod 4 = 0 : Stride = 396
    

    The Stride of a 24 bit Bitmap is padded adding 3 bytes (set to 0) to fill the boundary.

    It's not a problem when we inspect/modify internal values accessing single Pixels by their coordinates, similar to how SetPixel/GetPixel operate: the position of a Pixel will always be found correctly.

    Suppose we need to inspect/change a Pixel at position (98, 70) in a Bitmap of size (99 x 100).
    Considering only the bytes per pixel. The pixel position inside the Buffer is:

    [Bitmap] = new Bitmap(99, 100, PixelFormat = Format24bppRgb)
    
    [Bytes x pixel] = Image.GetPixelFormatSize([Bitmap].PixelFormat) / 8
    [Pixel] = new Point(98, 70)
    [Pixel Position] = ([Pixel].Y * [BitmapData.Stride]) + ([Pixel].X * [Bytes x pixel])
    [Color] = Color.FromArgb([Pixel Position] + 2, [Pixel Position] + 1, [Pixel Position])
    

    Multiplying the Pixel's vertical position by the width of the scan line, the position inside the buffer will always be correct: the padded size is included in the calculation.
    The Pixel Color at the next position, (0, 71), will return the expected results:

    It will be different when reading color bytes sequentially.
    The first scan line will return valid results up to the last Pixel (the last 3 bytes): the next 3 bytes will return the value of the bytes used to round the Stride, all set to 0.

    This might also not be a problem. For example, applying a filter, each sequence of bytes that represent a pixel is read and modified using the values of the filter's matrix: we would just modify a sequence of 3 bytes that won't be considered when the Bitmap is rendered.

    But it does matter if we are searching for specific sequences of pixels: reading a non-existent pixel Color may compromise the result and/or unbalance an algorithm.
    The same when performing statistical analysis on a Bitmap's colors.

    Of course, we could add a check in the loop: if [Position] Mod [BitmapData].Width = 0 : continue.
    But this adds a new calculation to each iteration.

    Operations in practice

    The simple solution (the more common one) is to create a new Bitmap with a format of PixelFormat.Format32bppArgb, so the Stride will be always correctly aligned:

    Imports System.Drawing
    Imports System.Drawing.Imaging
    Imports System.Runtime.InteropServices
    
    Private Function CopyTo32BitArgb(image As Image) As Bitmap
        Dim imageCopy As New Bitmap(image.Width, image.Height, PixelFormat.Format32bppArgb)
        imageCopy.SetResolution(image.HorizontalResolution, image.VerticalResolution)
    
        For Each propItem As PropertyItem In image.PropertyItems
            imageCopy.SetPropertyItem(propItem)
        Next
    
        Using g As Graphics = Graphics.FromImage(imageCopy)
            g.DrawImage(image,
                New Rectangle(0, 0, imageCopy.Width, imageCopy.Height),
                New Rectangle(0, 0, image.Width, image.Height),
                GraphicsUnit.Pixel)
            g.Flush()
        End Using
        Return imageCopy
    End Function
    

    This generates a byte-compatible Bitmap with the same DPI definition; the Image.PropertyItems are also copied from the source image.

    To test it, let's apply a sepia tone filter to an Image, using a copy of it to perform all the modifications needed to the Bitmap data:

    Public Function BitmapFilterSepia(source As Image) As Bitmap
        Dim imageCopy As Bitmap = CopyTo32BitArgb(source)
        Dim imageData As BitmapData = imageCopy.LockBits(New Rectangle(0, 0, source.Width, source.Height),
            ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb)
    
        Dim buffer As Byte() = New Byte(Math.Abs(imageData.Stride) * imageCopy.Height - 1) {}
        Marshal.Copy(imageData.Scan0, buffer, 0, buffer.Length)
    
        Dim bytesPerPixel = Image.GetPixelFormatSize(source.PixelFormat) \ 8;
        Dim red As Single = 0, green As Single = 0, blue As Single = 0
    
        Dim pos As Integer = 0
        While pos < buffer.Length
            Dim color As Color = Color.FromArgb(BitConverter.ToInt32(buffer, pos))
            ' Dim h = color.GetHue()
            ' Dim s = color.GetSaturation()
            ' Dim l = color.GetBrightness()
    
            red = buffer(pos) * 0.189F + buffer(pos + 1) * 0.769F + buffer(pos + 2) * 0.393F
            green = buffer(pos) * 0.168F + buffer(pos + 1) * 0.686F + buffer(pos + 2) * 0.349F
            blue = buffer(pos) * 0.131F + buffer(pos + 1) * 0.534F + buffer(pos + 2) * 0.272F
    
            buffer(pos + 2) = CType(Math.Min(Byte.MaxValue, red), Byte)
            buffer(pos + 1) = CType(Math.Min(Byte.MaxValue, green), Byte)
            buffer(pos) = CType(Math.Min(Byte.MaxValue, blue), Byte)
            pos += bytesPerPixel
        End While
    
        Marshal.Copy(buffer, 0, imageData.Scan0, buffer.Length)
        imageCopy.UnlockBits(imageData)
        imageData = Nothing
        Return imageCopy
    End Function
    

    Bitmap.LockBits is not always necessarily the best choice available.
    The same procedure to apply a filter could also be performed quite easily using the ColorMatrix class, which allows to apply a 5x5 matrix transformation to a Bitmap, using just a simple array of float (Single) values.

    For example, let's apply a Grayscale filter using the ColorMatrix class and a well-known 5x5 Matrix:

    Public Function BitmapMatrixFilterGreyscale(source As Image) As Bitmap
        ' A copy of the original is not needed but maybe desirable anyway 
        ' Dim imageCopy As Bitmap = CopyTo32BitArgb(source)
        Dim filteredImage = New Bitmap(source.Width, source.Height, source.PixelFormat)
        filteredImage.SetResolution(source.HorizontalResolution, source.VerticalResolution)
    
        Dim grayscaleMatrix As New ColorMatrix(New Single()() {
            New Single() {0.2126F, 0.2126F, 0.2126F, 0, 0},
            New Single() {0.7152F, 0.7152F, 0.7152F, 0, 0},
            New Single() {0.0722F, 0.0722F, 0.0722F, 0, 0},
            New Single() {0, 0, 0, 1, 0},
            New Single() {0, 0, 0, 0, 1}
       })
    
        Using g As Graphics = Graphics.FromImage(filteredImage), attributes = New ImageAttributes()
            attributes.SetColorMatrix(grayscaleMatrix)
            g.DrawImage(source, New Rectangle(0, 0, source.Width, source.Height),
                        0, 0, source.Width, source.Height, GraphicsUnit.Pixel, attributes)
        End Using
        Return filteredImage
    End Function