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
+ ];
+}