10
\$\begingroup\$

I've been trying to write an image scaler in C# with a focus on improved performance over the GDI+ scaler.

My current bicubic implementation when running with a single thread is slightly slower than the GDI+ HighQualityBicubic interpolation mode, and multi threaded performance is over 3 times faster. Note that this is running a 64bit .Net 4.6 release build with the optimise flag set. However, according to this post, GDI+ doesn't scale each axis separately, which means that it should be possible to significantly improve the performance of my scaler.

Horizontal Scale:

    public static Bitmap BicubicHorizontalUpscale(Bitmap inBmp, int newWidth, int maxDegreeOfParallelism)
    {
        if (inBmp.PixelFormat != PixelFormat.Format24bppRgb)
        {
            throw new Exception("PixelFormat incorrect. Must be Format24bppRgb.");
        }

        if (inBmp.Width == newWidth)
        {
            return (Bitmap)inBmp.Clone();
        }

        Bitmap outBmp = new Bitmap(newWidth, inBmp.Height, PixelFormat.Format24bppRgb);

        unsafe
        {
            BitmapData inData = inBmp.LockBits(new Rectangle(0, 0, inBmp.Width, inBmp.Height), ImageLockMode.ReadOnly, inBmp.PixelFormat);
            BitmapData outData = outBmp.LockBits(new Rectangle(0, 0, outBmp.Width, outBmp.Height), ImageLockMode.WriteOnly, outBmp.PixelFormat);

            int bpp = Bitmap.GetPixelFormatSize(PixelFormat.Format24bppRgb) / 8;
            float xRatio = inBmp.Width / (float)outBmp.Width;
            int lastInPixelX = inBmp.Width - 1;
            int outBmpWidth = outBmp.Width;
            byte* outScan0 = (byte*)outData.Scan0;
            byte* inScan0 = (byte*)inData.Scan0;
            int outDataStride = outData.Stride;
            int intDataStride = inData.Stride;

            float centerPreCalc = xRatio * 0.5f - 0.5f;     //Precalculate part of x position. Originally intX = (outX + 0.5f) * xRatio - 0.5f.
                                                            //Removes one subtraction operation from the inner loop.

            Parallel.For(0, outBmp.Height, new ParallelOptions { MaxDegreeOfParallelism = maxDegreeOfParallelism }, y =>
            {
                byte* outRow = outScan0 + (y * outDataStride);
                byte* inRow = inScan0 + (y * intDataStride);

                for (int outX = 0; outX < outBmpWidth; outX++)
                {
                    float center = outX * xRatio + centerPreCalc;

                    int inX2 = (int)center;   //Center pixel
                    int inX1 = inX2 - 1;
                    int inX3 = inX2 + 1;
                    int inX4 = inX2 + 2;

                    float diff2 = center - inX2;
                    float diff3 = inX3 - center;
                    float diff4 = inX4 - center;
                    float diff4Min2 = diff4 - 2;

                    //Weights are using simplified catmull-rom formula.
                    float weight2 = (1.5f * diff2 - 2.5f) * (diff2 * diff2) + 1;
                    float weight3 = (1.5f * diff3 - 2.5f) * (diff3 * diff3) + 1;
                    float weight4 = -0.5f * (diff4Min2 * diff4Min2) * (diff4 - 1);
                    float weight1 = 1 - weight2 - weight3 - weight4;    //Because the weights will add up to 1.
                                                                        //Weight one is chosen to calculate this way as it is the most expensive
                                                                        //due to originally containing an if statement.

                    int col1 = Math.Max(inX1, 0) * bpp;
                    int col2 = inX2 * bpp;
                    int col3 = Math.Min(inX3, lastInPixelX) * bpp;
                    int col4 = Math.Min(inX4, lastInPixelX) * bpp;

                    float inB1 = inRow[col1];
                    float inG1 = inRow[col1 + 1];
                    float inR1 = inRow[col1 + 2];
                    float inB2 = inRow[col2];
                    float inG2 = inRow[col2 + 1];
                    float inR2 = inRow[col2 + 2];
                    float inB3 = inRow[col3];
                    float inG3 = inRow[col3 + 1];
                    float inR3 = inRow[col3 + 2];
                    float inB4 = inRow[col4];
                    float inG4 = inRow[col4 + 1];
                    float inR4 = inRow[col4 + 2];

                    float outB = inB1 * weight1 + inB2 * weight2 + inB3 * weight3 + inB4 * weight4;
                    float outG = inG1 * weight1 + inG2 * weight2 + inG3 * weight3 + inG4 * weight4;
                    float outR = inR1 * weight1 + inR2 * weight2 + inR3 * weight3 + inR4 * weight4;

                    *outRow++ = clampFloat(outB);
                    *outRow++ = clampFloat(outG);
                    *outRow++ = clampFloat(outR);
                }
            });

            outBmp.UnlockBits(outData);
            inBmp.UnlockBits(inData);
        }

        return outBmp;
    }

Vertical Scale:

    public static Bitmap BicubicVerticalUpscale(Bitmap inBmp, int newHeight, int maxDegreeOfParallelism)
    {
        if (inBmp.PixelFormat != PixelFormat.Format24bppRgb)
        {
            throw new Exception("PixelFormat incorrect. Must be Format24bppRgb.");
        }

        if (inBmp.Height == newHeight)
        {
            return (Bitmap)inBmp.Clone();
        }

        Bitmap outBmp = new Bitmap(inBmp.Width, newHeight, PixelFormat.Format24bppRgb);

        unsafe
        {
            BitmapData inData = inBmp.LockBits(new Rectangle(0, 0, inBmp.Width, inBmp.Height), ImageLockMode.ReadOnly, inBmp.PixelFormat);
            BitmapData outData = outBmp.LockBits(new Rectangle(0, 0, outBmp.Width, outBmp.Height), ImageLockMode.ReadWrite, outBmp.PixelFormat);

            int bpp = Bitmap.GetPixelFormatSize(PixelFormat.Format24bppRgb) / 8;
            float yRatio = inBmp.Height / (float)outBmp.Height;
            int lastInPixelY = inBmp.Height - 1;
            int outBmpWidth = outBmp.Width;
            byte* outBmpDataScan0 = (byte*)outData.Scan0;
            byte* inBmpDataScan0 = (byte*)inData.Scan0;
            int outDataStride = outData.Stride;
            int intDataStride = inData.Stride;

            Parallel.For(0, outBmp.Height, new ParallelOptions { MaxDegreeOfParallelism = maxDegreeOfParallelism }, outY =>
            {
                byte* outRow = outBmpDataScan0 + (outY * outDataStride);

                float center = (outY + 0.5f) * yRatio - 0.5f;

                int inY2 = (int)center;   //Center pixel
                int inY1 = inY2 - 1;
                int inY3 = inY2 + 1;
                int inY4 = inY2 + 2;

                byte* inRow1 = inBmpDataScan0 + (Math.Max(inY1, 0) * intDataStride);
                byte* inRow2 = inBmpDataScan0 + (inY2 * intDataStride);
                byte* inRow3 = inBmpDataScan0 + (Math.Min(inY3, lastInPixelY) * intDataStride);
                byte* inRow4 = inBmpDataScan0 + (Math.Min(inY4, lastInPixelY) * intDataStride);

                float diff2 = center - inY2;
                float diff3 = inY3 - center;
                float diff4 = inY4 - center;
                float diff4Min2 = diff4 - 2;

                //Weights are using simplified catmull-rom formulas.
                float weight2 = (1.5f * diff2 - 2.5f) * (diff2 * diff2) + 1;
                float weight3 = (1.5f * diff3 - 2.5f) * (diff3 * diff3) + 1;
                float weight4 = -0.5f * (diff4Min2 * diff4Min2) * (diff4 - 1);
                float weight1 = 1 - weight2 - weight3 - weight4;    //Because the weights will add up to 1.
                                                                    //Weight one is chosen to calculate this way as it is the most expensive
                                                                    //due to originally containing an if statement.

                for (int x = 0; x < outBmpWidth; x++)
                {
                    int colB = x * bpp;
                    int colG = colB + 1;
                    int colR = colB + 2;

                    float inB1 = inRow1[colB];
                    float inG1 = inRow1[colG];
                    float inR1 = inRow1[colR];
                    float inB2 = inRow2[colB];
                    float inG2 = inRow2[colG];
                    float inR2 = inRow2[colR];
                    float inB3 = inRow3[colB];
                    float inG3 = inRow3[colG];
                    float inR3 = inRow3[colR];
                    float inB4 = inRow4[colB];
                    float inG4 = inRow4[colG];
                    float inR4 = inRow4[colR];

                    float outB = inB1 * weight1 + inB2 * weight2 + inB3 * weight3 + inB4 * weight4;
                    float outG = inG1 * weight1 + inG2 * weight2 + inG3 * weight3 + inG4 * weight4;
                    float outR = inR1 * weight1 + inR2 * weight2 + inR3 * weight3 + inR4 * weight4;

                    *outRow++ = clampFloat(outB);
                    *outRow++ = clampFloat(outG);
                    *outRow++ = clampFloat(outR);
                }
            });

            outBmp.UnlockBits(outData);
            inBmp.UnlockBits(inData);
        }

        return outBmp;
    }

Calling and clamping methods:

    public static Bitmap Bicubic(Bitmap inBmp, Size newSize, int maxDegreeOfParallelism)
    {
        Bitmap horizontalScaleBmp = BicubicHorizontalUpscale(inBmp, newSize.Width, maxDegreeOfParallelism);
        Bitmap verticalScaleBmp = BicubicVerticalUpscale(horizontalScaleBmp, newSize.Height, maxDegreeOfParallelism);
        horizontalScaleBmp.Dispose();

        return verticalScaleBmp;
    }

    private static byte clampFloat(float val)
    {
        if (val < 0)
        {
            return 0;
        }
        if (val > 255)
        {
            return 255;
        }

        return (byte)val;
    }

The only two obvious things that I can think of are integer math and using a simd library such as System.Numerics.Vector. Although, on my pc integer math isn't faster than floating point math, and I couldn't manage to use the vector class without reducing performance.

I think that a Lookup Table would be slower than my current weight function calculations.

\$\endgroup\$
2
\$\begingroup\$

I can see a potential micro-optimization that you could try:

            for (int outX = 0; outX < outBmpWidth; outX++)
            {
                float center = outX * xRatio + centerPreCalc;

It's obvious what's happening there, you start at centerPreCalc then move up by 1 * xRatio (or just xRatio) each iteration.

float center = centerPreCalc - xRatio;

for (int outX = 0; outX < outBmpWidth; outX++)
{
    center += xRatio;

We initialize center to centerPreCalc - xRatio because each iteration will add xRatio to center, including the first iteration. (If you move the center += xRatio line to the end of the for, you can remove - xRatio.)

This can have a performance impact with the significant number of iterations you are performing due to the lack of a multiplication operator. Instead, we reduced the code to a single addition instead of an addition and a multiplication.

This also accesses fewer variables on the stack, which should also help.


As far as:

I couldn't manage to use the vector class without reducing performance

Absolutely correct. Using an additional type for the colours (as I assume that's what you were using a Vector for) will be slower, it's more method calls that have to be made.


You could consider trying to push the compiler to do more inlining on some of your smaller methods (it may be doing it already, but you can also coerce it).

private static byte clampFloat(float val)
{
    if (val < 0)
    {
        return 0;
    }
    if (val > 255)
    {
        return 255;
    }

    return (byte)val;
}

Could be written as:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static byte clampFloat(float val)
{
    return val < 0 ? 0 : val > 255 ? 255 : (byte)val;
}

This should help convince the compiler to inline the IL for that method everywhere it can, which can help improve performance. (It may be doing that already, you don't have the IL posted so we can't tell as it is.)


    if (inBmp.PixelFormat != PixelFormat.Format24bppRgb)
    {
        throw new Exception("PixelFormat incorrect. Must be Format24bppRgb.");
    }

That should be an ArgumentException. (The inBmp argument caused the error.)


You should be explicit with your constant types. I don't know if the compiler will always convert 1 to a float, you may be seeing slight performance impacts from that. So when you do float val = otherVal - 1, you should consider float val = otherVal - 1f.


Other than that, the only issue with your code that I have is magic numbers, and a lack of constants for them. (What is 1.5f, 2.5f, etc?)

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.