Introduction

Today, I will show you my implementation of matrix multiplication C# and how to use it to apply basic transformations to images like rotation, stretching, flipping, and modifying color density.
Please note that this is not an image processing class. Rather, this article demonstrates in C# three of the core linear algebra concepts, matrix multiplication, dot product, and transformation matrices.

Source Code

Source code for this article is available on GitHub on the following repository.
This implementation is also included in the linear algebra problems component, Elsheimy.Components.Linears, available on,

Matrix Multiplication

The math behind matrix multiplication is very straightforward. Very easy explanations can be found here and here.
Let’s get directly to the code and start with our main function:
  1. public static double[,] Multiply(double[,] matrix1, double[,] matrix2) {
  2. // cahing matrix lengths for better performance
  3. var matrix1Rows = matrix1.GetLength(0);
  4. var matrix1Cols = matrix1.GetLength(1);
  5. var matrix2Rows = matrix2.GetLength(0);
  6. var matrix2Cols = matrix2.GetLength(1);
  7. // checking if product is defined
  8. if (matrix1Cols != matrix2Rows)
  9. throw new InvalidOperationException
  10. ("Product is undefined. n columns of first matrix must equal to n rows of second matrix");
  11. // creating the final product matrix
  12. double[,] product = new double[matrix1Rows, matrix2Cols];
  13. // looping through matrix 1 rows
  14. for (int matrix1_row = 0; matrix1_row < matrix1Rows; matrix1_row++) {
  15. // for each matrix 1 row, loop through matrix 2 columns
  16. for (int matrix2_col = 0; matrix2_col < matrix2Cols; matrix2_col++) {
  17. // loop through matrix 1 columns to calculate the dot product
  18. for (int matrix1_col = 0; matrix1_col < matrix1Cols; matrix1_col++) {
  19. product[matrix1_row, matrix2_col] +=
  20. matrix1[matrix1_row, matrix1_col] *
  21. matrix2[matrix1_col, matrix2_col];
  22. }
  23. }
  24. }
  25. return product;
  26. }
We started by fetching matrix row and column counts using Array.GetLength() and stored them inside variables to use them later. There’s a performance hit when calling Array.GetLength() that’s why we stored its results inside variables rather than calling the function multiple times. The performance part of this code is covered later in this article.
Next, we guaranteed that the product is defined by comparing the matrix1 number of columns to the matrix2 number of rows. An exception is thrown if the product is undefined.
Photo Credit: MathwareHouse
Then we created the final product matrix using the row and column lengths of the original matrices.
After that, we used three loops to move through matrix vectors and to calculate the dot product.
Photo Credit: PurpleMath

Transformations

Now we can use our multiplication algorithm to create image transformation matrices that can be applied to any point (X, Y) or color (ARGB) to modify it. We will start by defining our abstract IImageTransformation interface that has two members: CreateTransformationMatrix() and IsColorTransformation. The first one returns the relevant transformation matrix, the second indicates if this transformation can be applied to colors (true) or points (false).
  1. public interface IImageTransformation {
  2. double[,] CreateTransformationMatrix();
  3. bool IsColorTransformation { get; }
  4. }
Rotation Transformation
The 2D rotation matrix is defined as:
Photo Credit: Academo
Our code is very clear:
  1. public class RotationImageTransformation : IImageTransformation {
  2. public double AngleDegrees { get; set; }
  3. public double AngleRadians {
  4. get { return DegreesToRadians(AngleDegrees); }
  5. set { AngleDegrees = RadiansToDegrees(value); }
  6. }
  7. public bool IsColorTransformation { get { return false; } }
  8. public static double DegreesToRadians(double degree)
  9. { return degree * Math.PI / 180; }
  10. public static double RadiansToDegrees(double radians)
  11. { return radians / Math.PI * 180; }
  12. public double[,] CreateTransformationMatrix() {
  13. double[,] matrix = new double[2, 2];
  14. matrix[0, 0] = Math.Cos(AngleRadians);
  15. matrix[1, 0] = Math.Sin(AngleRadians);
  16. matrix[0, 1] = -1 * Math.Sin(AngleRadians);
  17. matrix[1, 1] = Math.Cos(AngleRadians);
  18. return matrix;
  19. }
  20. public RotationImageTransformation() { }
  21. public RotationImageTransformation(double angleDegree) {
  22. this.AngleDegrees = angleDegree;
  23. }
  24. }
As you can see in this code, Sin() and Cos() accept angels in radians, that’s why we have used two extra functions to convert between radians and degrees to keep things simple to the user.
A very nice explanation and example of 2D rotation matrices is available here.
Stretching/Scaling Transformation
The second transformation we have is the factor-scaling transformation. It works by scaling X/Y by the required factor. It is defined as:
  1. public class StretchImageTransformation : IImageTransformation {
  2. public double HorizontalStretch { get; set; }
  3. public double VerticalStretch { get; set; }
  4. public bool IsColorTransformation { get { return false; } }
  5. public double[,] CreateTransformationMatrix() {
  6. double[,] matrix = Matrices.CreateIdentityMatrix(2);
  7. matrix[0, 0] += HorizontalStretch;
  8. matrix[1, 1] += VerticalStretch;
  9. return matrix;
  10. }
  11. public StretchImageTransformation() { }
  12. public StretchImageTransformation(double horizStretch, double vertStretch) {
  13. this.HorizontalStretch = horizStretch;
  14. this.VerticalStretch = vertStretch;
  15. }
  16. }
Identity Matrix
The previous code requires the use of an identity matrix. Here’s the code that defines CreateIdentityMatrix(),
  1. public static double[,] CreateIdentityMatrix(int length) {
  2. double[,] matrix = new double[length, length];
  3. for (int i = 0, j = 0; i < length; i++, j++)
  4. matrix[i, j] = 1;
  5. return matrix;
  6. }
Flipping Transformation
The third transformation we have is the flipping transformation. It works by negating the X and Y members to flip the vector over the vertical and horizontal axis respectively.
  1. public class FlipImageTransformation : IImageTransformation {
  2. public bool FlipHorizontally { get; set; }
  3. public bool FlipVertically { get; set; }
  4. public bool IsColorTransformation { get { return false; } }
  5. public double[,] CreateTransformationMatrix() {
  6. // identity matrix
  7. double[,] matrix = Matrices.CreateIdentityMatrix(2);
  8. if (FlipHorizontally)
  9. matrix[0, 0] *= -1;
  10. if (FlipVertically)
  11. matrix[1, 1] *= -1;
  12. return matrix;
  13. }
  14. public FlipImageTransformation() { }
  15. public FlipImageTransformation(bool flipHoriz, bool flipVert) {
  16. this.FlipHorizontally = flipHoriz;
  17. this.FlipVertically = flipVert;
  18. }
  19. }
Color Density Transformation
The last transformation we have is the color density transformation. It works by defining different scaling factors to color components (Alpha, Red, Green, and Blue). For example, if you want to make the color 50% transparent we would scale Alpha by 0.5. If you want to remove the Red color completely you could scale it by 0. And so on.
  1. public class DensityImageTransformation : IImageTransformation {
  2. public double AlphaDensity { get; set; }
  3. public double RedDensity { get; set; }
  4. public double GreenDensity { get; set; }
  5. public double BlueDensity { get; set; }
  6. public bool IsColorTransformation { get { return true; } }
  7. public double[,] CreateTransformationMatrix() {
  8. // identity matrix
  9. double[,] matrix = new double[,]{
  10. { AlphaDensity, 0, 0, 0},
  11. { 0, RedDensity, 0, 0},
  12. { 0, 0, GreenDensity, 0},
  13. { 0, 0, 0, BlueDensity},
  14. };
  15. return matrix;
  16. }
  17. public DensityImageTransformation() { }
  18. public DensityImageTransformation(double alphaDensity,
  19. double redDensity,
  20. double greenDensity,
  21. double blueDensity) {
  22. this.AlphaDensity = alphaDensity;
  23. this.RedDensity = redDensity;
  24. this.GreenDensity = greenDensity;
  25. this.BlueDensity = blueDensity;
  26. }
  27. }

Connecting Things Together

Now it is time to define the processes and procedures that connect things together. Here’s the full code. An explanation follows:
  1. /// <summary>
  2. /// Applies image transformations to an image file
  3. /// </summary>
  4. public static Bitmap Apply(string file, IImageTransformation[] transformations) {
  5. using (Bitmap bmp = (Bitmap)Bitmap.FromFile(file)) {
  6. return Apply(bmp, transformations);
  7. }
  8. }
  9. /// <summary>
  10. /// Applies image transformations bitmap object
  11. /// </summary>
  12. public static Bitmap Apply(Bitmap bmp, IImageTransformation[] transformations) {
  13. // defining an array to store new image data
  14. PointColor[] points = new PointColor[bmp.Width * bmp.Height];
  15. // filtering transformations
  16. var pointTransformations =
  17. transformations.Where(s => s.IsColorTransformation == false).ToArray();
  18. var colorTransformations =
  19. transformations.Where(s => s.IsColorTransformation == true).ToArray();
  20. double[,] pointTransMatrix =
  21. CreateTransformationMatrix(pointTransformations, 2); // x, y
  22. double[,] colorTransMatrix =
  23. CreateTransformationMatrix(colorTransformations, 4); // a, r, g, b
  24. // saving some stats to adjust the image later
  25. int minX = 0, minY = 0;
  26. int maxX = 0, maxY = 0;
  27. // scanning points and applying transformations
  28. int idx = 0;
  29. for (int x = 0; x < bmp.Width; x++) { // row by row
  30. for (int y = 0; y < bmp.Height; y++) { // column by column
  31. // applying the point transformations
  32. var product =
  33. Matrices.Multiply(pointTransMatrix, new double[,] { { x }, { y } });
  34. var newX = (int)product[0, 0];
  35. var newY = (int)product[1, 0];
  36. // saving stats
  37. minX = Math.Min(minX, newX);
  38. minY = Math.Min(minY, newY);
  39. maxX = Math.Max(maxX, newX);
  40. maxY = Math.Max(maxY, newY);
  41. // applying color transformations
  42. Color clr = bmp.GetPixel(x, y); // current color
  43. var colorProduct = Matrices.Multiply(
  44. colorTransMatrix,
  45. new double[,] { { clr.A }, { clr.R }, { clr.G }, { clr.B } });
  46. clr = Color.FromArgb(
  47. GetValidColorComponent(colorProduct[0, 0]),
  48. GetValidColorComponent(colorProduct[1, 0]),
  49. GetValidColorComponent(colorProduct[2, 0]),
  50. GetValidColorComponent(colorProduct[3, 0])
  51. ); // new color
  52. // storing new data
  53. points[idx] = new PointColor() {
  54. X = newX,
  55. Y = newY,
  56. Color = clr
  57. };
  58. idx++;
  59. }
  60. }
  61. // new bitmap width and height
  62. var width = maxX - minX + 1;
  63. var height = maxY - minY + 1;
  64. // new image
  65. var img = new Bitmap(width, height);
  66. foreach (var pnt in points)
  67. img.SetPixel(
  68. pnt.X - minX,
  69. pnt.Y - minY,
  70. pnt.Color);
  71. return img;
  72. }
  73. /// <summary>
  74. /// Returns color component between 0 and 255
  75. /// </summary>
  76. private static byte GetValidColorComponent(double c) {
  77. c = Math.Max(byte.MinValue, c);
  78. c = Math.Min(byte.MaxValue, c);
  79. return (byte)c;
  80. }
  81. /// <summary>
  82. /// Combines transformations to create single transformation matrix
  83. /// </summary>
  84. private static double[,] CreateTransformationMatrix
  85. (IImageTransformation[] vectorTransformations, int dimensions) {
  86. double[,] vectorTransMatrix =
  87. Matrices.CreateIdentityMatrix(dimensions);
  88. // combining transformations works by multiplying them
  89. foreach (var trans in vectorTransformations)
  90. vectorTransMatrix =
  91. Matrices.Multiply(vectorTransMatrix, trans.CreateTransformationMatrix());
  92. return vectorTransMatrix;
  93. }
We started by defining two overloads of Apply() function. One that accepts image file name and transformation list and the other accepts a Bitmap object and the transformation list to apply to that image.
Inside the Apply() function, we filtered transformations into two groups, those that work on point locations (X and Y) and those that work on colors. We also used the CreateTransformationMatrix() function for each group to combine the transformations into a single transformation matrix.
After that, we started scanning the image and applying the transformations to points and colors respectively. Notice that we had to ensure that the transformed color components are byte-sized. After applying the transformations we saved data in an array for later usage.
During the scanning process, we recorded our minimum and maximum X and Y values. This will help to set the new image size and shift the points as needed. Some transformations like stretching might increase or decrease image size.
Finally, we created the new Bitmap object and set the point data after shifting them.

Creating the Client

Our client application is simple. Here’s a screenshot of our form,
Let’s have a look at the code behind it:
  1. private string _file;
  2. private Stopwatch _stopwatch;
  3. public ImageTransformationsForm() {
  4. InitializeComponent();
  5. }
  6. private void BrowseButton_Click(object sender, EventArgs e) {
  7. string file = OpenFile();
  8. if (file != null) {
  9. this.FileTextBox.Text = file;
  10. _file = file;
  11. }
  12. }
  13. public static string OpenFile() {
  14. OpenFileDialog dlg = new OpenFileDialog();
  15. dlg.CheckFileExists = true;
  16. if (dlg.ShowDialog() == DialogResult.OK)
  17. return dlg.FileName;
  18. return null;
  19. }
  20. private void ApplyButton_Click(object sender, EventArgs e) {
  21. if (_file == null)
  22. return;
  23. DisposePreviousImage();
  24. RotationImageTransformation rotation =
  25. new RotationImageTransformation((double)this.AngleNumericUpDown.Value);
  26. StretchImageTransformation stretch =
  27. new StretchImageTransformation(
  28. (double)this.HorizStretchNumericUpDown.Value / 100,
  29. (double)this.VertStretchNumericUpDown.Value / 100);
  30. FlipImageTransformation flip =
  31. new FlipImageTransformation(this.FlipHorizontalCheckBox.Checked, this.FlipVerticalCheckBox.Checked);
  32. DensityImageTransformation density =
  33. new DensityImageTransformation(
  34. (double)this.AlphaNumericUpDown.Value / 100,
  35. (double)this.RedNumericUpDown.Value / 100,
  36. (double)this.GreenNumericUpDown.Value / 100,
  37. (double)this.BlueNumericUpDown.Value / 100
  38. );
  39. StartStopwatch();
  40. var bmp = ImageTransformer.Apply(_file,
  41. new IImageTransformation[] { rotation, stretch, flip, density });
  42. StopStopwatch();
  43. this.ImagePictureBox.Image = bmp;
  44. }
  45. private void StartStopwatch() {
  46. if (_stopwatch == null)
  47. _stopwatch = new Stopwatch();
  48. else
  49. _stopwatch.Reset();
  50. _stopwatch.Start();
  51. }
  52. private void StopStopwatch() {
  53. _stopwatch.Stop();
  54. this.ExecutionTimeLabel.Text = $"Total execution time is {_stopwatch.ElapsedMilliseconds} milliseconds";
  55. }
  56. private void DisposePreviousImage() {
  57. if (this.ImagePictureBox.Image != null) {
  58. var tmpImg = this.ImagePictureBox.Image;
  59. this.ImagePictureBox.Image = null;
  60. tmpImg.Dispose();
  61. }
  62. }
The code is straightforward. The only thing to mention is that it has always been a good practice to call Dispose() on disposable objects to ensure best performance.

Performance Notes

In our core Multiply() method, we mentioned that calling Array.GetLength() involves a huge performance impact. I tried to check the logic behind Array.GetLength() with no success. The method is natively implemented, and I could not view its code using common disassembly tools. However, by benchmarking the two scenarios (code with a bunch of calls to Array.GetLength() and another code with only a single call to the same function) I found that the single call code is 2x faster than the other.
Another way to improve the performance of our Multiply() method is to use unsafe code. By accessing array contents directly you achieve superior processing performance.
Here’s our new and updated unsafe code:
  1. public static double[,] MultiplyUnsafe(double[,] matrix1, double[,] matrix2) {
  2. // cahing matrix lengths for better performance
  3. var matrix1Rows = matrix1.GetLength(0);
  4. var matrix1Cols = matrix1.GetLength(1);
  5. var matrix2Rows = matrix2.GetLength(0);
  6. var matrix2Cols = matrix2.GetLength(1);
  7. // checking if product is defined
  8. if (matrix1Cols != matrix2Rows)
  9. throw new InvalidOperationException
  10. ("Product is undefined. n columns of first matrix must equal to n rows of second matrix");
  11. // creating the final product matrix
  12. double[,] product = new double[matrix1Rows, matrix2Cols];
  13. unsafe
  14. {
  15. // fixing pointers to matrices
  16. fixed (
  17. double* pProduct = product,
  18. pMatrix1 = matrix1,
  19. pMatrix2 = matrix2) {
  20. int i = 0;
  21. // looping through matrix 1 rows
  22. for (int matrix1_row = 0; matrix1_row < matrix1Rows; matrix1_row++) {
  23. // for each matrix 1 row, loop through matrix 2 columns
  24. for (int matrix2_col = 0; matrix2_col < matrix2Cols; matrix2_col++) {
  25. // loop through matrix 1 columns to calculate the dot product
  26. for (int matrix1_col = 0; matrix1_col < matrix1Cols; matrix1_col++) {
  27. var val1 = *(pMatrix1 + (matrix1Rows * matrix1_row) + matrix1_col);
  28. var val2 = *(pMatrix2 + (matrix2Cols * matrix1_col) + matrix2_col);
  29. *(pProduct + i) += val1 * val2;
  30. }
  31. i++;
  32. }
  33. }
  34. }
  35. }
  36. return product;
  37. }
Unsafe code will not compile unless you enable it from the Build tab in the Project Settings page.
The following figure shows the difference between the three Multiply() scenarios when multiplying the 1000x1000 matrix by itself. The tests ran on my dying Core [email protected] 6GB RAM 1GB Intel Graphics laptop.
I am not covering any performance improvements in the client or the Apply() method as it is not the core focus of the article.

Conclusion

This was my implementation of matrix multiplication. Feel free to send me your feedback and comments over the code and to update it as needed.