diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs
new file mode 100644
index 0000000..e7e3c48
--- /dev/null
+++ b/csharp/ThumbHash.cs
@@ -0,0 +1,375 @@
+using System;
+
+namespace MadeByEvan.ThumbHash
+{
+ ///
+ /// A very compact representation of a placeholder for an image. Store it inline with your data and show it while the real image is loading for a smoother loading experience. It's similar to BlurHash but with the following advantages:
+ ///
+ /// - Encodes more detail in the same space
+ /// - Much faster to encode and decode
+ /// - Also encodes the aspect ratio
+ /// - Gives more accurate colors
+ /// - Supports images with alpha
+ ///
+ ///
+ public static class ThumbHash
+ {
+ public const int MaxWidth = 100;
+ public const int MaxHeight = 100;
+
+ ///
+ /// Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
+ ///
+ /// The width of the input image. Must be ≤100px.
+ /// The height of the input image. Must be ≤100px.
+ /// The pixels in the input image, row-by-row. Must have w*h*4 elements.
+ /// The ThumbHash as a byte array.
+ ///
+ public static byte[] RGBAToThumbHash(int width, int height, byte[] rgba)
+ {
+ // Encoding an image larger than 100x100 is slow with no benefit
+ if (width > MaxWidth || height > MaxHeight)
+ {
+ throw new Exception($"{width}x{height} doesn't fit in {MaxWidth}x{MaxHeight}");
+ }
+ if (rgba.Length != width * height * 4)
+ {
+ throw new Exception("The length of the byte array should be Width * Height * 4");
+ }
+
+ // Determine the average color
+ float avg_r = 0, avg_g = 0, avg_b = 0, avg_a = 0;
+ for (int i = 0, j = 0; i < width * height; i++, j += 4)
+ {
+ var alpha = (rgba[j + 3] & 255) / 255.0f;
+ avg_r += alpha / 255.0f * (rgba[j] & 255);
+ avg_g += alpha / 255.0f * (rgba[j + 1] & 255);
+ avg_b += alpha / 255.0f * (rgba[j + 2] & 255);
+ avg_a += alpha;
+ }
+
+ if (avg_a > 0)
+ {
+ avg_r /= avg_a;
+ avg_g /= avg_a;
+ avg_b /= avg_a;
+ }
+
+ var hasAlpha = avg_a < width * height;
+ var l_limit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's alpha
+ var lx = (int)Math.Max(1, Math.Round(l_limit * width / (float)Math.Max(width, height)));
+ var ly = (int)Math.Max(1, Math.Round(l_limit * height / (float)Math.Max(width, height)));
+ var l = new float[width * height]; // luminance
+ var p = new float[width * height]; // yellow - blue
+ var q = new float[width * height]; // red - green
+ var a = new float[width * height]; // alpha
+
+ // Convert the image from RGBA to LPQA (composite atop the average color)
+ for (int i = 0, j = 0; i < width * height; i++, j += 4)
+ {
+ var alpha = (rgba[j + 3] & 255) / 255.0f;
+ var r = avg_r * (1.0f - alpha) + alpha / 255.0f * (rgba[j] & 255);
+ var g = avg_g * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 1] & 255);
+ var b = avg_b * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 2] & 255);
+ l[i] = (r + g + b) / 3.0f;
+ p[i] = (r + g) / 2.0f - b;
+ q[i] = r - g;
+ a[i] = alpha;
+ }
+
+ // Encode using the DCT into DC (constant) and normalized AC (varying) terms
+ int ny = Math.Max(3, ly);
+ var l_channel = new Channel(Math.Max(3, lx), ny).Encode(width, height, l);
+ var p_channel = new Channel(3, 3).Encode(width, height, p);
+ var q_channel = new Channel(3, 3).Encode(width, height, q);
+ var a_channel = hasAlpha ? new Channel(5, 5).Encode(width, height, a) : default;
+
+ // Write the constants
+ var isLandscape = width > height;
+ var header24 = (byte)Math.Round(63.0f * l_channel.dc)
+ | ((byte)Math.Round(31.5f + 31.5f * p_channel.dc) << 6)
+ | ((byte)Math.Round(31.5f + 31.5f * q_channel.dc) << 12)
+ | ((byte)Math.Round(31.0f * l_channel.scale) << 18)
+ | (hasAlpha ? 1 << 23 : 0);
+ var header16 = (isLandscape ? ly : lx)
+ | ((byte)Math.Round(63.0f * p_channel.scale) << 3)
+ | ((byte)Math.Round(63.0f * q_channel.scale) << 9)
+ | (isLandscape ? 1 << 15 : 0);
+ var ac_start = hasAlpha ? 6 : 5;
+ var ac_count = l_channel.ac.Length + p_channel.ac.Length + q_channel.ac.Length
+ + (hasAlpha ? a_channel.ac.Length : 0);
+ var hash = new byte[ac_start + (ac_count + 1) / 2];
+ hash[0] = (byte)header24;
+ hash[1] = (byte)(header24 >> 8);
+ hash[2] = (byte)(header24 >> 16);
+ hash[3] = (byte)header16;
+ hash[4] = (byte)(header16 >> 8);
+ if (hasAlpha)
+ hash[5] = (byte)((byte)Math.Round(15.0f * a_channel.dc)
+ | ((byte)Math.Round(15.0f * a_channel.scale) << 4));
+
+ // Write the varying factors
+ var ac_index = 0;
+ ac_index = l_channel.WriteTo(hash, ac_start, ac_index);
+ ac_index = p_channel.WriteTo(hash, ac_start, ac_index);
+ ac_index = q_channel.WriteTo(hash, ac_start, ac_index);
+ if (hasAlpha) a_channel.WriteTo(hash, ac_start, ac_index);
+ return hash;
+ }
+
+ ///
+ /// Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
+ ///
+ /// The bytes of the ThumbHash
+ /// The width, height, and pixels of the rendered placeholder image
+ public static RGBAImage ThumbHashToRGBA(byte[] hash)
+ {
+ // Read the constants
+ var header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
+ var header16 = (hash[3] & 255) | ((hash[4] & 255) << 8);
+ var l_dc = (header24 & 63) / 63.0f;
+ var p_dc = ((header24 >> 6) & 63) / 31.5f - 1.0f;
+ var q_dc = ((header24 >> 12) & 63) / 31.5f - 1.0f;
+ var l_scale = ((header24 >> 18) & 31) / 31.0f;
+ var hasAlpha = header24 >> 23 != 0;
+ var p_scale = ((header16 >> 3) & 63) / 63.0f;
+ var q_scale = ((header16 >> 9) & 63) / 63.0f;
+ var isLandscape = header16 >> 15 != 0;
+ var lx = Math.Max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
+ var ly = Math.Max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
+ var a_dc = hasAlpha ? (hash[5] & 15) / 15.0f : 1.0f;
+ var a_scale = ((hash[5] >> 4) & 15) / 15.0f;
+
+ // Read the varying factors (boost saturation by 1.25x to compensate for quantization)
+ var ac_start = hasAlpha ? 6 : 5;
+ var ac_index = 0;
+ var l_channel = new Channel(lx, ly);
+ var p_channel = new Channel(3, 3);
+ var q_channel = new Channel(3, 3);
+ Channel a_channel = default;
+ ac_index = l_channel.Decode(hash, ac_start, ac_index, l_scale);
+ ac_index = p_channel.Decode(hash, ac_start, ac_index, p_scale * 1.25f);
+ ac_index = q_channel.Decode(hash, ac_start, ac_index, q_scale * 1.25f);
+ if (hasAlpha)
+ {
+ a_channel = new Channel(5, 5);
+ a_channel.Decode(hash, ac_start, ac_index, a_scale);
+ }
+
+ var l_ac = l_channel.ac;
+ var p_ac = p_channel.ac;
+ var q_ac = q_channel.ac;
+ var a_ac = hasAlpha ? a_channel.ac : null;
+
+ // Decode using the DCT into RGB
+ var ratio = ThumbHashToApproximateAspectRatio(hash);
+ var w = (int)Math.Round(ratio > 1.0f ? 32.0f : 32.0f * ratio);
+ var h = (int)Math.Round(ratio > 1.0f ? 32.0f / ratio : 32.0f);
+ var rgba = new byte[w * h * 4];
+ var cx_stop = Math.Max(lx, hasAlpha ? 5 : 3);
+ var cy_stop = Math.Max(ly, hasAlpha ? 5 : 3);
+ var fx = new float[cx_stop];
+ var fy = new float[cy_stop];
+ for (int y = 0, i = 0; y < h; y++)
+ for (var x = 0; x < w; x++, i += 4)
+ {
+ float l = l_dc, p = p_dc, q = q_dc, a = a_dc;
+
+ // Precompute the coefficients
+ for (var cx = 0; cx < cx_stop; cx++)
+ fx[cx] = (float)Math.Cos(Math.PI / w * (x + 0.5f) * cx);
+ for (var cy = 0; cy < cy_stop; cy++)
+ fy[cy] = (float)Math.Cos(Math.PI / h * (y + 0.5f) * cy);
+
+ // Decode L
+ for (int cy = 0, j = 0; cy < ly; cy++)
+ {
+ var fy2 = fy[cy] * 2.0f;
+ for (var cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++)
+ l += l_ac[j] * fx[cx] * fy2;
+ }
+
+ // Decode P and Q
+ for (int cy = 0, j = 0; cy < 3; cy++)
+ {
+ var fy2 = fy[cy] * 2.0f;
+ for (var cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++)
+ {
+ var f = fx[cx] * fy2;
+ p += p_ac[j] * f;
+ q += q_ac[j] * f;
+ }
+ }
+
+ // Decode A
+ if (hasAlpha)
+ for (int cy = 0, j = 0; cy < 5; cy++)
+ {
+ var fy2 = fy[cy] * 2.0f;
+ for (var cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++)
+ a += a_ac[j] * fx[cx] * fy2;
+ }
+
+ // Convert to RGB
+ var b = l - 2.0f / 3.0f * p;
+ var r = (3.0f * l - b + q) / 2.0f;
+ var g = r - q;
+ rgba[i] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, r)));
+ rgba[i + 1] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, g)));
+ rgba[i + 2] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, b)));
+ rgba[i + 3] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, a)));
+ }
+
+ return new RGBAImage(w, h, rgba);
+ }
+
+ ///
+ /// Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
+ ///
+ /// The bytes of the ThumbHash
+ /// The RGBA values for the average color. Each value ranges from 0 to 1.
+
+ public static RGBA ThumbHashToAverageRGBA(byte[] hash)
+ {
+ var header = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16);
+ var l = (header & 63) / 63.0f;
+ var p = ((header >> 6) & 63) / 31.5f - 1.0f;
+ var q = ((header >> 12) & 63) / 31.5f - 1.0f;
+ var hasAlpha = header >> 23 != 0;
+ var a = hasAlpha ? (hash[5] & 15) / 15.0f : 1.0f;
+ var b = l - 2.0f / 3.0f * p;
+ var r = (3.0f * l - b + q) / 2.0f;
+ var g = r - q;
+ return new RGBA(
+ Math.Max(0, Math.Min(1, r)),
+ Math.Max(0, Math.Min(1, g)),
+ Math.Max(0, Math.Min(1, b)),
+ a);
+ }
+
+
+ ///
+ /// Extracts the approximate aspect ratio of the original image.
+ ///
+ /// The bytes of the ThumbHash
+ /// The approximate aspect ratio (i.e. width / height)
+ public static float ThumbHashToApproximateAspectRatio(byte[] hash)
+ {
+ var header = hash[3];
+ var hasAlpha = (hash[2] & 0x80) != 0;
+ var isLandscape = (hash[4] & 0x80) != 0;
+ var lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
+ var ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
+ return lx / (float)ly;
+ }
+
+ public struct RGBAImage
+ {
+ public readonly int Width;
+ public readonly int Height;
+ public readonly byte[] RGBA;
+
+ public RGBAImage(int width, int height, byte[] rgba)
+ {
+ Width = width;
+ Height = height;
+ RGBA = rgba;
+ }
+ }
+
+ public struct RGBA
+ {
+ public float R;
+ public float G;
+ public float B;
+ public float A;
+
+ public RGBA(float r, float g, float b, float a)
+ {
+ R = r;
+ G = g;
+ B = b;
+ A = a;
+ }
+ }
+
+ private struct Channel
+ {
+ private readonly int _nx;
+ private readonly int _ny;
+ public float dc;
+ public readonly float[] ac;
+ public float scale;
+
+ public Channel(int nx, int ny)
+ {
+ _nx = nx;
+ _ny = ny;
+ var n = 0;
+ for (var cy = 0; cy < ny; cy++)
+ for (var cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++)
+ n++;
+ ac = new float[n];
+ dc = 0;
+ scale = 0;
+ }
+
+ public Channel Encode(int w, int h, float[] channel)
+ {
+ var n = 0;
+ var fx = new float[w];
+ for (var cy = 0; cy < _ny; cy++)
+ for (var cx = 0; cx * _ny < _nx * (_ny - cy); cx++)
+ {
+ float f = 0;
+ for (var x = 0; x < w; x++)
+ fx[x] = (float)Math.Cos(Math.PI / w * cx * (x + 0.5f));
+ for (var y = 0; y < h; y++)
+ {
+ var fy = (float)Math.Cos(Math.PI / h * cy * (y + 0.5f));
+ for (var x = 0; x < w; x++)
+ f += channel[x + y * w] * fx[x] * fy;
+ }
+
+ f /= w * h;
+ if (cx > 0 || cy > 0)
+ {
+ ac[n++] = f;
+ scale = Math.Max(scale, Math.Abs(f));
+ }
+ else
+ {
+ dc = f;
+ }
+ }
+
+ if (scale > 0)
+ for (var i = 0; i < ac.Length; i++)
+ ac[i] = 0.5f + 0.5f / scale * ac[i];
+ return this;
+ }
+
+ public int Decode(byte[] hash, int start, int index, float scale)
+ {
+ for (var i = 0; i < ac.Length; i++)
+ {
+ var data = hash[start + (index >> 1)] >> ((index & 1) << 2);
+ ac[i] = ((data & 15) / 7.5f - 1.0f) * scale;
+ index++;
+ }
+
+ return index;
+ }
+
+ public int WriteTo(byte[] hash, int start, int index)
+ {
+ foreach (var v in ac)
+ {
+ hash[start + (index >> 1)] |= (byte)((byte)Math.Round(15.0f * v) << ((index & 1) << 2));
+ index++;
+ }
+ return index;
+ }
+ }
+ }
+}
diff --git a/csharp/ThumbHashUtil.cs b/csharp/ThumbHashUtil.cs
new file mode 100644
index 0000000..9eea0fa
--- /dev/null
+++ b/csharp/ThumbHashUtil.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+
+namespace MadeByEvan.ThumbHash
+{
+ ///
+ /// Utility class for working with in combination with
+ ///
+ public static class ThumbHashUtil
+ {
+ ///
+ /// Create ThumbHash byte[] from .
+ /// The image is resized to fit inside * if needed,
+ /// so creating an image from the resulting thumbhash can have different dimensions.
+ ///
+ ///
+ ///
+ /// ThumbHash as a byte[]
+ public static byte[] ImageToThumbHash(Image image, InterpolationMode interpolationMode = InterpolationMode.Default)
+ {
+ if (image.Width <= ThumbHash.MaxWidth && image.Height <= ThumbHash.MaxHeight)
+ {
+ var bytes = BitmapToByteArray((Bitmap)image);
+ return ThumbHash.RGBAToThumbHash(image.Width, image.Height, bytes);
+ }
+
+ var scale = Math.Min(ThumbHash.MaxWidth / (float)image.Width, ThumbHash.MaxHeight / (float)image.Height);
+ var size = new Size((int)Math.Floor(image.Width * scale), (int)Math.Floor(image.Height * scale));
+ using (var resized = new Bitmap(size.Width, size.Height))
+ {
+ using (var g = Graphics.FromImage(resized))
+ {
+ g.InterpolationMode = interpolationMode;
+ g.DrawImage(image, 0, 0, size.Width, size.Height);
+ }
+ var bytes = BitmapToByteArray(resized);
+ return ThumbHash.RGBAToThumbHash(size.Width, size.Height, bytes);
+ }
+ }
+
+ ///
+ /// Create from ThumbHash byte[]
+ ///
+ ///
+ ///
+ public static Image ThumbHashToImage(byte[] thumbHash)
+ {
+ var rgba = ThumbHash.ThumbHashToRGBA(thumbHash);
+ var image = new Bitmap(rgba.Width, rgba.Height);
+ int n = 0;
+ for (var y = 0; y < image.Height; y++)
+ {
+ for (var x = 0; x < image.Width; x++)
+ {
+ var color = Color.FromArgb(rgba.RGBA[n + 3], rgba.RGBA[n], rgba.RGBA[n + 1], rgba.RGBA[n + 2]);
+ image.SetPixel(x, y, color);
+ n += 4;
+ }
+ }
+ return image;
+ }
+
+ private static byte[] BitmapToByteArray(Bitmap image)
+ {
+ byte[] result = new byte[image.Width * image.Height * 4];
+ int n = 0;
+ for (var y = 0; y < image.Height; y++)
+ {
+ for (var x = 0; x < image.Width; x++)
+ {
+ var color = image.GetPixel(x, y);
+ result[n++] = color.R;
+ result[n++] = color.G;
+ result[n++] = color.B;
+ result[n++] = color.A;
+ }
+ }
+ return result;
+ }
+ }
+}