diff --git a/csharp/ThumbHash.cs b/csharp/ThumbHash.cs new file mode 100644 index 0000000..05eae8e --- /dev/null +++ b/csharp/ThumbHash.cs @@ -0,0 +1,704 @@ +using System.Text; +using System.Buffers; +using System.Buffers.Binary; +using System.IO.Compression; + +/// +/// ThumbHash .NET implementation based on https://github.com/evanw/thumbhash +/// +public static class ThumbHashConvert +{ + /// + /// 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 Span FromRgba(int w, int h, ReadOnlySpan rgba) + { + byte[] thumbHashBuffer = new byte[32]; + + int n = FromRgba(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 FromRgba(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) + { + 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); + + int outputSize = acStart + (acCount + 1) / 2; + + 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) + { + 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(output, acStart, acIndex); + acIndex = channelP.WriteTo(output, acStart, acIndex); + acIndex = channelQ.WriteTo(output, acStart, acIndex); + + if (hasAlpha) + { + channelA!.WriteTo(output, acStart, acIndex); + } + + return outputSize; + } + 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. + /// 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, bool compress = true) + { + (int w, int h, byte[] rgba) = ToRgba(hash); + + return RgbaToDataUrl(w, h, rgba, compress); + } + + /// + /// 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. + /// 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); + 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 = 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); + + 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) ToAverageRgba(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 ToApproximateAspectRatio(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 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. + /// 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; + + void WriteCrc(Span crcTarget, ReadOnlySpan data, ReadOnlySpan additionalData) + { + uint c = 0xFFFFFFFF; + + foreach (byte b in data) + { + c ^= b; + c = (c >> 4) ^ CRC_TABLE[c & 15]; + 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); + } + + if (compress) + { + using MemoryStream pngStream = new(totalSize); + + pngStream.Write(SIGNATURE); + + Span ihdr = stackalloc byte[13]; + + BinaryPrimitives.WriteInt32BigEndian(ihdr[0..], w); + BinaryPrimitives.WriteInt32BigEndian(ihdr[4..], h); + + ihdr[08] = 8; // Bit depth + ihdr[09] = 6; // Color type (RGBA) + ihdr[10] = 0; // Compression + ihdr[11] = 0; // Filter + ihdr[12] = 0; // Interlace + + void WriteChunk(Stream outputStream, ReadOnlySpan type, ReadOnlySpan data) + { + Span lengthBuffer = stackalloc byte[4]; + Span crcHash = stackalloc byte[4]; + + BinaryPrimitives.WriteInt32BigEndian(lengthBuffer, data.Length); + + outputStream.Write(lengthBuffer); + outputStream.Write(type); + outputStream.Write(data); + + WriteCrc(crcHash, type, data); + + outputStream.Write(crcHash); + } + + WriteChunk(pngStream, "IHDR"u8, ihdr); + + long lengthPosition = pngStream.Position; + + pngStream.Write(stackalloc byte[4]); + + long typePosition = pngStream.Position; + + pngStream.Write("IDAT"u8); + + long dataStart = pngStream.Position; + + // 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) + { + zlib.WriteByte(0); // Above mentioned required filter type: None. + + zlib.Write(rgba.Slice(y * w * 4, w * 4)); + } + } + + long dataEnd = pngStream.Position; + + 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); + + Span crcHash = stackalloc byte[4]; + + WriteCrc(crcHash, pngStream.GetBuffer().AsSpan((int)typePosition, (int)(dataEnd - typePosition)), []); + + pngStream.Seek(dataEnd, SeekOrigin.Begin); + + pngStream.Write(crcHash); + + WriteChunk(pngStream, "IEND"u8, ReadOnlySpan.Empty); + + return pngStream.ToArray(); + } + + 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) + { + 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 + { + 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 + ]; +}