From 2ba70b8a80ce821820f4c54caef8276148944692 Mon Sep 17 00:00:00 2001 From: Glitched Polygons GmbH <37942667+GlitchedPolygons@users.noreply.github.com> Date: Thu, 7 May 2026 15:15:05 +0000 Subject: [PATCH 1/5] Added csharp/ThumbHash.cs Works with .NET 10+ --- csharp/ThumbHash.cs | 568 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 csharp/ThumbHash.cs diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs new file mode 100644 index 0000000..9022358 --- /dev/null +++ b/csharp/ThumbHash.cs @@ -0,0 +1,568 @@ +using System.Text; +using System.Buffers; +using System.Buffers.Binary; + +/// +/// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash +/// +public static class ThumbHash +{ + /// + /// Encodes an RGBA image to a ThumbHash. + /// + /// 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. + /// RGB should not be premultiplied by A. + /// ThumbHash bytes. + /// Thrown if or is greater than 100. + public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) + { + // Encoding an image larger than 100x100 is slow with no benefit. + if (w > 100 || h > 100) + { + throw new ArgumentException($"{w}x{h} is larger than the maximum allowed input resolution of 100x100."); + } + + float averageR = 0; + float averageG = 0; + float averageB = 0; + float averageA = 0; + + int resolution = w * h; + + for (int i = 0, j = 0; i < resolution; ++i, j += 4) + { + float alpha = (rgba[j + 3] & 255) / 255.0f; + + averageR += alpha / 255.0f * (rgba[j] & 255); + averageG += alpha / 255.0f * (rgba[j + 1] & 255); + averageB += alpha / 255.0f * (rgba[j + 2] & 255); + averageA += alpha; + } + + if (averageA > 0) + { + averageR /= averageA; + averageG /= averageA; + averageB /= averageA; + } + + bool hasAlpha = averageA < resolution; + + int luminanceLimit = hasAlpha ? 5 : 7; // Use fewer luminance bits if there's an alpha. + + int lx = (int)Math.Max(1, Math.Round(luminanceLimit * w / (float)Math.Max(w, h), MidpointRounding.AwayFromZero)); + int ly = (int)Math.Max(1, Math.Round(luminanceLimit * h / (float)Math.Max(w, h), MidpointRounding.AwayFromZero)); + + float[] lpqaBuffer = ArrayPool.Shared.Rent(resolution * 4); + + try + { + Span l = lpqaBuffer.AsSpan(resolution * 0, resolution); // luminance + Span p = lpqaBuffer.AsSpan(resolution * 1, resolution); // yellow - blue + Span q = lpqaBuffer.AsSpan(resolution * 2, resolution); // red - green + Span a = lpqaBuffer.AsSpan(resolution * 3, resolution); // alpha + + // Convert the image from RGBA to LPQA (composite atop the average color). + for (int i = 0, j = 0; i < resolution; ++i, j += 4) + { + float alpha = (rgba[j + 3] & 255) / 255.0f; + float r = averageR * (1.0f - alpha) + alpha / 255.0f * (rgba[j] & 255); + float g = averageG * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 1] & 255); + float b = averageB * (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. + Channel channelL = new Channel(Math.Max(3, lx), Math.Max(3, ly)).Encode(w, h, l); + Channel channelP = new Channel(3, 3).Encode(w, h, p); + Channel channelQ = new Channel(3, 3).Encode(w, h, q); + Channel? channelA = hasAlpha ? new Channel(5, 5).Encode(w, h, a) : null; + + bool isLandscape = w > h; + + int header24 = + (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero) + | + ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6) + | + ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12) + | + ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18) + | + (hasAlpha ? 1 << 23 : 0); + + int header16 = + (isLandscape ? ly : lx) + | + ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3) + | + ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9) + | + (isLandscape ? 1 << 15 : 0); + + int acStart = hasAlpha ? 6 : 5; + + int acCount = channelL.AC.Length + channelP.AC.Length + channelQ.AC.Length + (hasAlpha ? channelA!.AC.Length : 0); + + byte[] hash = new byte[acStart + (acCount + 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)((int)Math.Round(15.0f * channelA!.DC) | ((int)Math.Round(15.0f * channelA.Scale, MidpointRounding.AwayFromZero) << 4)); + } + + int acIndex = 0; + + acIndex = channelL.WriteTo(hash, acStart, acIndex); + acIndex = channelP.WriteTo(hash, acStart, acIndex); + acIndex = channelQ.WriteTo(hash, acStart, acIndex); + + if (hasAlpha) + { + channelA!.WriteTo(hash, acStart, acIndex); + } + + return hash; + } + finally + { + ArrayPool.Shared.Return(lpqaBuffer); + } + } + + /// + /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element. + /// + /// ThumbHash bytes to convert into a data URL. + /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...] + public static string ThumbHashToDataUrl(ReadOnlySpan hash) + { + (int w, int h, byte[] rgba) = ThumbHashToRgba(hash); + + return RgbaToDataUrl(w, h, rgba); + } + + /// + /// Decodes a ThumbHash to an RGBA image. RGB is not to be premultiplied by A. + /// + /// The bytes of the ThumbHash. + /// The width, height, and pixels (rgba) of the rendered placeholder image. + public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash) + { + // Read the constants. + int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16); + int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8); + float l_dc = (header24 & 63) / 63.0f; + float p_dc = ((header24 >> 6) & 63) / 31.5f - 1.0f; + float q_dc = ((header24 >> 12) & 63) / 31.5f - 1.0f; + float l_scale = ((header24 >> 18) & 31) / 31.0f; + bool hasAlpha = (header24 >> 23) != 0; + float p_scale = ((header16 >> 3) & 63) / 63.0f; + float q_scale = ((header16 >> 9) & 63) / 63.0f; + bool isLandscape = (header16 >> 15) != 0; + int lx = Math.Max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7); + int ly = Math.Max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7); + float a_dc = hasAlpha ? (hash[5] & 15) / 15.0f : 1.0f; + float a_scale = ((hash[5] >> 4) & 15) / 15.0f; + + // Read the varying factors (boost saturation by 1.25x to compensate for quantization). + int ac_start = hasAlpha ? 6 : 5; + int ac_index = 0; + + Channel l_channel = new Channel(lx, ly); + Channel p_channel = new Channel(3, 3); + Channel q_channel = new Channel(3, 3); + Channel? a_channel = null; + + 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); + } + + float[] l_ac = l_channel.AC; + float[] p_ac = p_channel.AC; + float[] q_ac = q_channel.AC; + float[]? a_ac = hasAlpha ? a_channel!.AC : null; + + // Decode using the DCT into RGB. + float ratio = ThumbHashToApproximateAspectRatio(hash); + + int w = (int)Math.Round(ratio > 1.0f ? 32.0f : 32.0f * ratio, MidpointRounding.AwayFromZero); + int h = (int)Math.Round(ratio > 1.0f ? 32.0f / ratio : 32.0f, MidpointRounding.AwayFromZero); + + byte[] rgba = new byte[w * h * 4]; + + int cx_stop = Math.Max(lx, hasAlpha ? 5 : 3); + int cy_stop = Math.Max(ly, hasAlpha ? 5 : 3); + + Span fx = stackalloc float[cx_stop]; + Span fy = stackalloc float[cy_stop]; + + for (int y = 0, i = 0; y < h; ++y) + { + for (int x = 0; x < w; ++x, i += 4) + { + float l = l_dc, p = p_dc, q = q_dc, a = a_dc; + + // Precompute the coefficients + for (int cx = 0; cx < cx_stop; ++cx) + { + fx[cx] = (float)Math.Cos(Math.PI / w * (x + 0.5f) * cx); + } + + for (int 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) + { + float fy2 = fy[cy] * 2.0f; + for (int 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) + { + float fy2 = fy[cy] * 2.0f; + + for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; ++cx, ++j) + { + float 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) + { + float fy2 = fy[cy] * 2.0f; + for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; ++cx, ++j) + { + a += a_ac![j] * fx[cx] * fy2; + } + } + } + + // Convert to RGB. + float b = l - 2.0f / 3.0f * p; + float r = (3.0f * l - b + q) / 2.0f; + float g = r - q; + + rgba[i] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, r), MidpointRounding.AwayFromZero)); + rgba[i + 1] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, g), MidpointRounding.AwayFromZero)); + rgba[i + 2] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, b), MidpointRounding.AwayFromZero)); + rgba[i + 3] = (byte)Math.Max(0, Math.Round(255.0f * Math.Min(1, a), MidpointRounding.AwayFromZero)); + } + } + + return (w, h, rgba); + } + + /// + /// Extracts the average color from a ThumbHash. RGB is not to be premultiplied by A. + /// + /// ThumbHash bytes. + /// A tuple containing the RGBA values for the average color. Each value ranges from 0 to 1. + public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan hash) + { + int header = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16); + float l = (header & 63) / 63.0f; + float p = ((header >> 6) & 63) / 31.5f - 1.0f; + float q = ((header >> 12) & 63) / 31.5f - 1.0f; + bool hasAlpha = header >> 23 != 0; + float a = hasAlpha ? (hash[5] & 15) / 15.0f : 1.0f; + float b = l - 2.0f / 3.0f * p; + float r = (3.0f * l - b + q) / 2.0f; + float g = r - q; + + return + ( + Math.Max(0.0f, Math.Min(1.0f, r)), + Math.Max(0.0f, Math.Min(1.0f, g)), + Math.Max(0.0f, Math.Min(1.0f, b)), + a + ); + } + + /// + /// Extracts the approximate aspect ratio of the original image. + /// + /// The bytes of the ThumbHash. + /// The approximate aspect ratio (width / height). + public static float ThumbHashToApproximateAspectRatio(ReadOnlySpan hash) + { + byte header = hash[3]; + bool hasAlpha = (hash[2] & 0x80) != 0; + bool isLandscape = (hash[4] & 0x80) != 0; + + int lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7; + int ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7; + + return lx / (float)ly; + } + + /// + /// Converts an image of x RGBA pixels into a "data:" URL string, ready to be used in an <img> tag as the value for the src attribute. + /// + /// 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. + /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...] + public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba) + { + int rowDataLength = w * 4; + int rowWithFilter = rowDataLength + 1; + int idatBodyLength = 6 + h * (5 + rowWithFilter); + int totalSize = 57 + idatBodyLength; + + byte[] buffer = ArrayPool.Shared.Rent(totalSize); + + Span span = buffer.AsSpan(0, totalSize); + + void WriteCrc(ReadOnlySpan data, Span crcTarget) + { + uint c = 0xFFFFFFFF; + + foreach (byte b in data) + { + c ^= b; + c = (c >> 4) ^ CRC_TABLE[c & 15]; + c = (c >> 4) ^ CRC_TABLE[c & 15]; + } + + BinaryPrimitives.WriteUInt32BigEndian(crcTarget, ~c); + } + + try + { + SIGNATURE.CopyTo(span); + + BinaryPrimitives.WriteInt32BigEndian(span[8..], 13); + + "IHDR"u8.CopyTo(span[12..]); + + BinaryPrimitives.WriteInt32BigEndian(span[16..], w); + BinaryPrimitives.WriteInt32BigEndian(span[20..], h); + + span[24] = 8; + span[25] = 6; + span.Slice(26, 3).Clear(); + + WriteCrc(span.Slice(12, 17), span.Slice(29, 4)); + + BinaryPrimitives.WriteInt32BigEndian(span[33..], idatBodyLength); + + "IDAT"u8.CopyTo(span[37..]); + + int p = 41; + + span[p++] = 0x78; + span[p++] = 0x01; + + int a = 1, b = 0; + int rgbaIndex = 0; + + for (int y = 0; y < h; ++y) + { + span[p++] = (byte)(y + 1 < h ? 0 : 1); + + BinaryPrimitives.WriteUInt16LittleEndian(span[p..], (ushort)rowWithFilter); + BinaryPrimitives.WriteUInt16LittleEndian(span[(p + 2)..], (ushort)~rowWithFilter); + + p += 4; + + span[p++] = 0; + + b = (b + a) % 65521; + + for (int x = 0; x < rowDataLength; ++x) + { + byte u = rgba[rgbaIndex++]; + span[p++] = u; + a = (a + u) % 65521; + b = (b + a) % 65521; + } + } + + BinaryPrimitives.WriteInt16BigEndian(span[p..], (short)b); + BinaryPrimitives.WriteInt16BigEndian(span[(p + 2)..], (short)a); + p += 4; + + WriteCrc(span.Slice(37, idatBodyLength + 4), span.Slice(p, 4)); + p += 4; + + BinaryPrimitives.WriteInt32BigEndian(span[p..], 0); + "IEND"u8.CopyTo(span[(p + 4)..]); + + BinaryPrimitives.WriteUInt32BigEndian(span[(p + 8)..], 0xAE426082); + + StringBuilder sb = new(32 + (int)Math.Ceiling(span.Length / 3.0) * 4); + + sb.Append("data:image/png;base64,"); + sb.Append(Convert.ToBase64String(span)); + + return sb.ToString(); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private class Channel + { + private readonly int nx; + private readonly int ny; + + public float Scale { get; private set; } + public float DC { get; private set; } + public float[] AC { get; } + + public Channel(int nx, int ny) + { + this.nx = nx; + this.ny = ny; + int n = 0; + + for (int cy = 0; cy < ny; ++cy) + { + for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); ++cx) + { + ++n; + } + } + + AC = new float[n]; + } + + public Channel Encode(int w, int h, ReadOnlySpan channel) + { + int n = 0; + float[] fx = new float[w]; + + for (int cy = 0; cy < ny; ++cy) + { + for (int cx = 0; cx * ny < nx * (ny - cy); ++cx) + { + float f = 0; + + for (int x = 0; x < w; ++x) + { + fx[x] = (float)Math.Cos(Math.PI / w * cx * (x + 0.5f)); + } + + for (int y = 0; y < h; ++y) + { + float fy = (float)Math.Cos(Math.PI / h * cy * (y + 0.5f)); + + for (int 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 (int i = 0; i < AC.Length; ++i) + { + AC[i] = 0.5f + 0.5f / Scale * AC[i]; + } + } + + return this; + } + + public int Decode(ReadOnlySpan hash, int start, int index, float scale) + { + for (int i = 0; i < AC.Length; ++i) + { + int data = hash[start + (index >> 1)] >> ((index & 1) << 2); + AC[i] = ((data & 15) / 7.5f - 1.0f) * scale; + ++index; + } + + return index; + } + + public int WriteTo(Span hash, int start, int index) + { + foreach (float v in AC) + { + hash[start + (index >> 1)] |= (byte)((int)Math.Round(15.0f * v, MidpointRounding.AwayFromZero) << ((index & 1) << 2)); + ++index; + } + + return index; + } + } + + private static readonly uint[] CRC_TABLE = + [ + 0, + 498536548, + 997073096, + 651767980, + 1994146192, + 1802195444, + 1303535960, + 1342533948, + 3988292384, + 4027552580, + 3604390888, + 3412177804, + 2607071920, + 2262029012, + 2685067896, + 3183342108 + ]; + + private static readonly byte[] SIGNATURE = + [ + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10 + ]; +} From da9b8cded6c5b32ae48ff4baa6212d06c09f9972 Mon Sep 17 00:00:00 2001 From: Raphael Beck Date: Sat, 9 May 2026 00:05:13 +0200 Subject: [PATCH 2/5] Added more memory-friendly overload that makes use of Span. --- csharp/ThumbHash.cs | 95 ++++++++++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs index 9022358..856865f 100644 --- a/csharp/ThumbHash.cs +++ b/csharp/ThumbHash.cs @@ -16,8 +16,32 @@ public static class ThumbHash /// RGB should not be premultiplied by A. /// ThumbHash bytes. /// Thrown if or is greater than 100. - public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) + public static Span RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) { + byte[] thumbHashBuffer = new byte[32]; + + int n = RgbaToThumbHash(w, h, rgba, thumbHashBuffer); + + return thumbHashBuffer.AsSpan(0, n); + } + + /// + /// Encodes an RGBA image to a ThumbHash. + /// + /// 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. + /// Output byte buffer of at least 32B in size into which the ThumbHash should be written. + /// RGB should not be premultiplied by A. + /// Number of ThumbHash bytes written into . + /// Thrown if or is greater than 100 or the buffer size is insufficient (must be at least 32 bytes). + public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span output) + { + if (output.Length < 32) + { + throw new ArgumentException("Output buffer is too small. Please allocate at least 32 bytes of storage!", nameof(output)); + } + // Encoding an image larger than 100x100 is slow with no benefit. if (w > 100 || h > 100) { @@ -86,54 +110,54 @@ public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) bool isLandscape = w > h; int header24 = - (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero) - | - ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6) - | - ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12) - | - ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18) - | - (hasAlpha ? 1 << 23 : 0); + (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero) + | + ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6) + | + ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12) + | + ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18) + | + (hasAlpha ? 1 << 23 : 0); int header16 = - (isLandscape ? ly : lx) - | - ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3) - | - ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9) - | - (isLandscape ? 1 << 15 : 0); + (isLandscape ? ly : lx) + | + ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3) + | + ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9) + | + (isLandscape ? 1 << 15 : 0); int acStart = hasAlpha ? 6 : 5; int acCount = channelL.AC.Length + channelP.AC.Length + channelQ.AC.Length + (hasAlpha ? channelA!.AC.Length : 0); - byte[] hash = new byte[acStart + (acCount + 1) / 2]; + int outputSize = acStart + (acCount + 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); + output[0] = (byte)header24; + output[1] = (byte)(header24 >> 8); + output[2] = (byte)(header24 >> 16); + output[3] = (byte)header16; + output[4] = (byte)(header16 >> 8); if (hasAlpha) { - hash[5] = (byte)((int)Math.Round(15.0f * channelA!.DC) | ((int)Math.Round(15.0f * channelA.Scale, MidpointRounding.AwayFromZero) << 4)); + output[5] = (byte)((int)Math.Round(15.0f * channelA!.DC) | ((int)Math.Round(15.0f * channelA.Scale, MidpointRounding.AwayFromZero) << 4)); } int acIndex = 0; - acIndex = channelL.WriteTo(hash, acStart, acIndex); - acIndex = channelP.WriteTo(hash, acStart, acIndex); - acIndex = channelQ.WriteTo(hash, acStart, acIndex); + acIndex = channelL.WriteTo(output, acStart, acIndex); + acIndex = channelP.WriteTo(output, acStart, acIndex); + acIndex = channelQ.WriteTo(output, acStart, acIndex); if (hasAlpha) { - channelA!.WriteTo(hash, acStart, acIndex); + channelA!.WriteTo(output, acStart, acIndex); } - return hash; + return outputSize; } finally { @@ -142,10 +166,10 @@ public static byte[] RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) } /// - /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element. + /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element. /// /// ThumbHash bytes to convert into a data URL. - /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...] + /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. public static string ThumbHashToDataUrl(ReadOnlySpan hash) { (int w, int h, byte[] rgba) = ThumbHashToRgba(hash); @@ -302,9 +326,9 @@ public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan hash) /// 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. - /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...] + /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba) { int rowDataLength = w * 4; @@ -446,6 +470,7 @@ public Channel(int nx, int ny) { this.nx = nx; this.ny = ny; + int n = 0; for (int cy = 0; cy < ny; ++cy) From 76858373c5673a2b003f2702a0c56456c45466ff Mon Sep 17 00:00:00 2001 From: Glitched Polygons GmbH <37942667+GlitchedPolygons@users.noreply.github.com> Date: Sun, 10 May 2026 20:26:17 +0200 Subject: [PATCH 3/5] Update ThumbHash.cs --- csharp/ThumbHash.cs | 69 +++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs index 856865f..44f96a8 100644 --- a/csharp/ThumbHash.cs +++ b/csharp/ThumbHash.cs @@ -5,7 +5,7 @@ /// /// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash /// -public static class ThumbHash +public static class ThumbHashConvert { /// /// Encodes an RGBA image to a ThumbHash. @@ -16,11 +16,11 @@ public static class ThumbHash /// RGB should not be premultiplied by A. /// ThumbHash bytes. /// Thrown if or is greater than 100. - public static Span RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) + public static Span FromRgba(int w, int h, ReadOnlySpan rgba) { byte[] thumbHashBuffer = new byte[32]; - int n = RgbaToThumbHash(w, h, rgba, thumbHashBuffer); + int n = FromRgba(w, h, rgba, thumbHashBuffer); return thumbHashBuffer.AsSpan(0, n); } @@ -35,7 +35,7 @@ public static Span RgbaToThumbHash(int w, int h, ReadOnlySpan rgba) /// RGB should not be premultiplied by A. /// Number of ThumbHash bytes written into . /// Thrown if or is greater than 100 or the buffer size is insufficient (must be at least 32 bytes). - public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span output) + public static int FromRgba(int w, int h, ReadOnlySpan rgba, Span output) { if (output.Length < 32) { @@ -110,24 +110,24 @@ public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span h; int header24 = - (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero) - | - ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6) - | - ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12) - | - ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18) - | - (hasAlpha ? 1 << 23 : 0); + (int)Math.Round(63.0f * channelL.DC, MidpointRounding.AwayFromZero) + | + ((int)Math.Round(31.5f + 31.5f * channelP.DC, MidpointRounding.AwayFromZero) << 6) + | + ((int)Math.Round(31.5f + 31.5f * channelQ.DC, MidpointRounding.AwayFromZero) << 12) + | + ((int)Math.Round(31.0f * channelL.Scale, MidpointRounding.AwayFromZero) << 18) + | + (hasAlpha ? 1 << 23 : 0); int header16 = - (isLandscape ? ly : lx) - | - ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3) - | - ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9) - | - (isLandscape ? 1 << 15 : 0); + (isLandscape ? ly : lx) + | + ((int)Math.Round(63.0f * channelP.Scale, MidpointRounding.AwayFromZero) << 3) + | + ((int)Math.Round(63.0f * channelQ.Scale, MidpointRounding.AwayFromZero) << 9) + | + (isLandscape ? 1 << 15 : 0); int acStart = hasAlpha ? 6 : 5; @@ -166,13 +166,14 @@ public static int RgbaToThumbHash(int w, int h, ReadOnlySpan rgba, Span - /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element. + /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element. /// /// ThumbHash bytes to convert into a data URL. /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. - public static string ThumbHashToDataUrl(ReadOnlySpan hash) + /// Thrown if the passed is empty. + public static string ToDataUrl(ReadOnlySpan hash) { - (int w, int h, byte[] rgba) = ThumbHashToRgba(hash); + (int w, int h, byte[] rgba) = ToRgba(hash); return RgbaToDataUrl(w, h, rgba); } @@ -182,8 +183,14 @@ public static string ThumbHashToDataUrl(ReadOnlySpan hash) /// /// The bytes of the ThumbHash. /// The width, height, and pixels (rgba) of the rendered placeholder image. - public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash) + /// Thrown if the passed is empty. + public static(int, int, byte[]) ToRgba(ReadOnlySpan hash) { + if (hash.IsEmpty) + { + throw new ArgumentException("The passed ThumbHash is empty.", nameof(hash)); + } + // Read the constants. int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16); int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8); @@ -225,7 +232,7 @@ public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash) float[]? a_ac = hasAlpha ? a_channel!.AC : null; // Decode using the DCT into RGB. - float ratio = ThumbHashToApproximateAspectRatio(hash); + float ratio = ToApproximateAspectRatio(hash); int w = (int)Math.Round(ratio > 1.0f ? 32.0f : 32.0f * ratio, MidpointRounding.AwayFromZero); int h = (int)Math.Round(ratio > 1.0f ? 32.0f / ratio : 32.0f, MidpointRounding.AwayFromZero); @@ -311,7 +318,7 @@ public static(int, int, byte[]) ThumbHashToRgba(ReadOnlySpan hash) /// /// ThumbHash bytes. /// A tuple containing the RGBA values for the average color. Each value ranges from 0 to 1. - public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan hash) + public static(float, float, float, float) ToAverageRgba(ReadOnlySpan hash) { int header = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16); float l = (header & 63) / 63.0f; @@ -326,9 +333,9 @@ public static(float, float, float, float) ThumbHashToAverageRgba(ReadOnlySpan /// The bytes of the ThumbHash. /// The approximate aspect ratio (width / height). - public static float ThumbHashToApproximateAspectRatio(ReadOnlySpan hash) + public static float ToApproximateAspectRatio(ReadOnlySpan hash) { byte header = hash[3]; bool hasAlpha = (hash[2] & 0x80) != 0; @@ -354,7 +361,7 @@ public static float ThumbHashToApproximateAspectRatio(ReadOnlySpan hash) /// /// 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 pixels in the input image, row-by-row. Must have w*h*4 elements. /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba) { From 318cb08dbb2a00233e3c0c9a445eeb99aea30835 Mon Sep 17 00:00:00 2001 From: Glitched Polygons GmbH <37942667+GlitchedPolygons@users.noreply.github.com> Date: Sun, 10 May 2026 23:43:02 +0000 Subject: [PATCH 4/5] Update ThumbHash.cs --- csharp/ThumbHash.cs | 209 +++++++++++++++++++++++++++++++++----------- 1 file changed, 156 insertions(+), 53 deletions(-) diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs index 44f96a8..c638481 100644 --- a/csharp/ThumbHash.cs +++ b/csharp/ThumbHash.cs @@ -1,6 +1,7 @@ using System.Text; using System.Buffers; using System.Buffers.Binary; +using System.IO.Compression; /// /// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash @@ -190,7 +191,7 @@ public static(int, int, byte[]) ToRgba(ReadOnlySpan hash) { throw new ArgumentException("The passed ThumbHash is empty.", nameof(hash)); } - + // Read the constants. int header24 = (hash[0] & 255) | ((hash[1] & 255) << 8) | ((hash[2] & 255) << 16); int header16 = (hash[3] & 255) | ((hash[4] & 255) << 8); @@ -357,24 +358,21 @@ public static float ToApproximateAspectRatio(ReadOnlySpan hash) } /// - /// Converts an image of x RGBA pixels into a "data:" URL string, ready to be used in an <img> tag as the value for the src attribute. + /// Converts an image of x into PNG-encoded RGBA pixels. /// /// 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. - /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. - public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba) + /// Whether the output PNG should be losslessly compressed or just raw, uncompressed, PNG-encoded RGBA pixels. + /// PNG bytes. + public static byte[] RgbaToPngBytes(int w, int h, ReadOnlySpan rgba, bool compress = true) { int rowDataLength = w * 4; int rowWithFilter = rowDataLength + 1; int idatBodyLength = 6 + h * (5 + rowWithFilter); int totalSize = 57 + idatBodyLength; - byte[] buffer = ArrayPool.Shared.Rent(totalSize); - - Span span = buffer.AsSpan(0, totalSize); - - void WriteCrc(ReadOnlySpan data, Span crcTarget) + void WriteCrc(Span crcTarget, ReadOnlySpan data, ReadOnlySpan additionalData) { uint c = 0xFFFFFFFF; @@ -385,83 +383,188 @@ void WriteCrc(ReadOnlySpan data, Span crcTarget) c = (c >> 4) ^ CRC_TABLE[c & 15]; } + foreach (byte b in additionalData) + { + c ^= b; + c = (c >> 4) ^ CRC_TABLE[c & 15]; + c = (c >> 4) ^ CRC_TABLE[c & 15]; + } + BinaryPrimitives.WriteUInt32BigEndian(crcTarget, ~c); } - try + if (compress) { - SIGNATURE.CopyTo(span); + using MemoryStream pngStream = new(totalSize); - BinaryPrimitives.WriteInt32BigEndian(span[8..], 13); + pngStream.Write(SIGNATURE); - "IHDR"u8.CopyTo(span[12..]); + Span ihdr = stackalloc byte[13]; - BinaryPrimitives.WriteInt32BigEndian(span[16..], w); - BinaryPrimitives.WriteInt32BigEndian(span[20..], h); + BinaryPrimitives.WriteInt32BigEndian(ihdr[0..], w); + BinaryPrimitives.WriteInt32BigEndian(ihdr[4..], h); - span[24] = 8; - span[25] = 6; - span.Slice(26, 3).Clear(); + ihdr[08] = 8; // Bit depth + ihdr[09] = 6; // Color type (RGBA) + ihdr[10] = 0; // Compression + ihdr[11] = 0; // Filter + ihdr[12] = 0; // Interlace - WriteCrc(span.Slice(12, 17), span.Slice(29, 4)); + void WriteChunk(Stream outputStream, ReadOnlySpan type, ReadOnlySpan data) + { + Span lengthBuffer = stackalloc byte[4]; + Span crcHash = stackalloc byte[4]; - BinaryPrimitives.WriteInt32BigEndian(span[33..], idatBodyLength); + BinaryPrimitives.WriteInt32BigEndian(lengthBuffer, data.Length); - "IDAT"u8.CopyTo(span[37..]); + outputStream.Write(lengthBuffer); + outputStream.Write(type); + outputStream.Write(data); - int p = 41; + WriteCrc(crcHash, type, data); - span[p++] = 0x78; - span[p++] = 0x01; + outputStream.Write(crcHash); + } - int a = 1, b = 0; - int rgbaIndex = 0; + WriteChunk(pngStream, "IHDR"u8, ihdr); - for (int y = 0; y < h; ++y) - { - span[p++] = (byte)(y + 1 < h ? 0 : 1); + long lengthPosition = pngStream.Position; - BinaryPrimitives.WriteUInt16LittleEndian(span[p..], (ushort)rowWithFilter); - BinaryPrimitives.WriteUInt16LittleEndian(span[(p + 2)..], (ushort)~rowWithFilter); + pngStream.Write(stackalloc byte[4]); - p += 4; + long typePosition = pngStream.Position; - span[p++] = 0; + pngStream.Write("IDAT"u8); - b = (b + a) % 65521; + long dataStart = pngStream.Position; - for (int x = 0; x < rowDataLength; ++x) + // PNG requires a filter byte (0 for "None") at the start of every scanline. + using (ZLibStream zlib = new(pngStream, CompressionLevel.Optimal, true)) + { + for (int y = 0; y < h; ++y) { - byte u = rgba[rgbaIndex++]; - span[p++] = u; - a = (a + u) % 65521; - b = (b + a) % 65521; + zlib.WriteByte(0); // Above mentioned required filter type: None. + + zlib.Write(rgba.Slice(y * w * 4, w * 4)); } } - BinaryPrimitives.WriteInt16BigEndian(span[p..], (short)b); - BinaryPrimitives.WriteInt16BigEndian(span[(p + 2)..], (short)a); - p += 4; + long dataEnd = pngStream.Position; - WriteCrc(span.Slice(37, idatBodyLength + 4), span.Slice(p, 4)); - p += 4; + long compressedSize = dataEnd - dataStart; + + if (typePosition >= int.MaxValue || compressedSize >= int.MaxValue) + { + throw new OutOfMemoryException(); + } + + pngStream.Seek(lengthPosition, SeekOrigin.Begin); + + BinaryPrimitives.WriteInt32BigEndian(ihdr[0..4], (int)compressedSize); + + pngStream.Write(ihdr[0..4]); + + pngStream.Seek(typePosition, SeekOrigin.Begin); - BinaryPrimitives.WriteInt32BigEndian(span[p..], 0); - "IEND"u8.CopyTo(span[(p + 4)..]); + Span crcHash = stackalloc byte[4]; - BinaryPrimitives.WriteUInt32BigEndian(span[(p + 8)..], 0xAE426082); + WriteCrc(crcHash, pngStream.GetBuffer().AsSpan((int)typePosition, (int)(dataEnd - typePosition)), []); - StringBuilder sb = new(32 + (int)Math.Ceiling(span.Length / 3.0) * 4); + pngStream.Seek(dataEnd, SeekOrigin.Begin); - sb.Append("data:image/png;base64,"); - sb.Append(Convert.ToBase64String(span)); + pngStream.Write(crcHash); - return sb.ToString(); + WriteChunk(pngStream, "IEND"u8, ReadOnlySpan.Empty); + + return pngStream.ToArray(); } - finally + + byte[] pngBytes = new byte[totalSize]; + + Span pngBytesSpan = pngBytes; + + SIGNATURE.CopyTo(pngBytesSpan); + + BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[8..], 13); + + "IHDR"u8.CopyTo(pngBytesSpan[12..]); + + BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[16..], w); + BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[20..], h); + + pngBytesSpan[24] = 8; + pngBytesSpan[25] = 6; + pngBytesSpan.Slice(26, 3).Clear(); + + WriteCrc(pngBytesSpan.Slice(29, 4), pngBytesSpan.Slice(12, 17), []); + + BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[33..], idatBodyLength); + + "IDAT"u8.CopyTo(pngBytesSpan[37..]); + + int p = 41; + + pngBytesSpan[p++] = 0x78; + pngBytesSpan[p++] = 0x01; + + int a = 1, b = 0; + int rgbaIndex = 0; + + for (int y = 0; y < h; ++y) { - ArrayPool.Shared.Return(buffer); + pngBytesSpan[p++] = (byte)(y + 1 < h ? 0 : 1); + + BinaryPrimitives.WriteUInt16LittleEndian(pngBytesSpan[p..], (ushort)rowWithFilter); + BinaryPrimitives.WriteUInt16LittleEndian(pngBytesSpan[(p + 2)..], (ushort)~rowWithFilter); + + p += 4; + + pngBytesSpan[p++] = 0; + + b = (b + a) % 65521; + + for (int x = 0; x < rowDataLength; ++x) + { + byte u = rgba[rgbaIndex++]; + pngBytesSpan[p++] = u; + a = (a + u) % 65521; + b = (b + a) % 65521; + } } + + BinaryPrimitives.WriteInt16BigEndian(pngBytesSpan[p..], (short)b); + BinaryPrimitives.WriteInt16BigEndian(pngBytesSpan[(p + 2)..], (short)a); + p += 4; + + WriteCrc(pngBytesSpan.Slice(p, 4), pngBytesSpan.Slice(37, idatBodyLength + 4), []); + p += 4; + + BinaryPrimitives.WriteInt32BigEndian(pngBytesSpan[p..], 0); + "IEND"u8.CopyTo(pngBytesSpan[(p + 4)..]); + + BinaryPrimitives.WriteUInt32BigEndian(pngBytesSpan[(p + 8)..], 0xAE426082); + + return pngBytes; + } + + /// + /// Converts an image of x RGBA pixels into a "data:" URL string, ready to be used in an <img> tag as the value for the src attribute. + /// + /// 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. + /// Whether the output data URL should be losslessly compressed PNG or just raw, uncompressed, PNG-encoded RGBA pixels. + /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. + public static string RgbaToDataUrl(int w, int h, ReadOnlySpan rgba, bool compress = true) + { + Span pngBytes = RgbaToPngBytes(w, h, rgba, compress); + + StringBuilder sb = new(32 + (int)Math.Ceiling(pngBytes.Length / 3.0) * 4); + + sb.Append("data:image/png;base64,"); + sb.Append(Convert.ToBase64String(pngBytes)); + + return sb.ToString(); } private class Channel From e9a71498144c06be02e6afcedd268d2e9740b3ce Mon Sep 17 00:00:00 2001 From: Glitched Polygons GmbH <37942667+GlitchedPolygons@users.noreply.github.com> Date: Mon, 11 May 2026 00:21:17 +0000 Subject: [PATCH 5/5] Update ThumbHash.cs --- csharp/ThumbHash.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs index c638481..05eae8e 100644 --- a/csharp/ThumbHash.cs +++ b/csharp/ThumbHash.cs @@ -170,13 +170,14 @@ public static int FromRgba(int w, int h, ReadOnlySpan rgba, Span out /// Decodes a ThumbHash into a "data:" URL string, ready to be used as an src attribute value in an <img> element. /// /// ThumbHash bytes to convert into a data URL. + /// Whether the output PNG should be losslessly compressed or just raw, uncompressed, PNG-encoded RGBA pixels. /// Data URL, ready to be used as an src attribute value in an <img> element. E.g. data:image/png;base64,iVBORw0KGgoAAAAN [etc...]. /// Thrown if the passed is empty. - public static string ToDataUrl(ReadOnlySpan hash) + public static string ToDataUrl(ReadOnlySpan hash, bool compress = true) { (int w, int h, byte[] rgba) = ToRgba(hash); - return RgbaToDataUrl(w, h, rgba); + return RgbaToDataUrl(w, h, rgba, compress); } ///