Created
September 8, 2020 07:49
-
-
Save trigger-segfault/6872978a837aa64994b266b0eafdfdca to your computer and use it in GitHub Desktop.
Two-way implementation for processing CatSystem HG-2/3 image encodings
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.Drawing; | |
| using System.Drawing.Imaging; | |
| using System.IO; | |
| using System.Runtime.InteropServices; | |
| // THIS IS A WORK IN PROGRESS | |
| // Class for creating and extracting HG-2/3 specific image encodings | |
| // This does not handle reading the container file format, nor Zlib compression. | |
| namespace TriggersTools.CatSystem2 { | |
| [Flags] | |
| public enum HgxOptions { | |
| None = 0, | |
| Flip = (1 << 0), // Flip vertically (applied after crop when encoding) | |
| Crop = (1 << 2), // Expand or Shrink | |
| } | |
| // A decompressed HG-2/HG-3 image slice | |
| public struct HgxSlice { | |
| #region Fields | |
| public int Index { get; set; } | |
| public int Length { get; set; } | |
| // public int SliceIndex { get; set; } | |
| // public int SliceLength { get; set; } | |
| // public int OrigDataLength { get; set; } | |
| // public int OrigCmdLength { get; set; } | |
| public byte[] Data { get; set; } | |
| public byte[] Cmd { get; set; } | |
| #endregion | |
| } | |
| public class HgxImage { | |
| #region Static Fields | |
| private static readonly uint[,] _weightTables; // uint[4, 256] | |
| private static readonly byte[,] _absTables; // byte[2, 256] - [0] Forward, [1] Inverse | |
| #endregion | |
| #region Fields | |
| public Size Size { get; set; } | |
| public int BitDepth { get; set; } | |
| public Size FullSize { get; set; } | |
| public Point Offset { get; set; } | |
| public bool HasTransparency { get; set; } | |
| public Point Base { get; set; } // image origin/base (applied based on full image size?) | |
| public HgxSlice[] Slices { get; set; } | |
| #endregion | |
| #region Properties | |
| public int Width => this.Size.Width; | |
| public int Height => this.Size.Height; | |
| public int FullWidth => this.FullSize.Width; | |
| public int FullHeight => this.FullSize.Height; | |
| public int OffsetX => this.Offset.X; | |
| public int OffsetY => this.Offset.Y; | |
| public int BaseX => this.Base.X; | |
| public int BaseY => this.Base.Y; | |
| public int ByteDepth => (this.BitDepth + 7) / 8; | |
| // In reality, CatSystem does not handle bitdepths that don't align to the byte | |
| // It calculates stride like so: | |
| public int Stride => (this.Size.Width * this.ByteDepth + 3) & ~0x3; | |
| //public int Stride => ((this.Size.Width * this.BitDepth + 7) / 8 + 3) & ~0x3; | |
| #endregion | |
| #region Constructors | |
| static HgxImage() { | |
| HgxImage._weightTables = HgxImage.InitWeightTables(); | |
| HgxImage._absTables = HgxImage.InitAbsoluteTables(); | |
| } | |
| public HgxImage(Size size, int bitDepth) | |
| : this(size, bitDepth, Point.Empty, size) | |
| { | |
| } | |
| public HgxImage(Size size, int bitDepth) | |
| : this(size, bitDepth, Point.Empty, size) | |
| { | |
| } | |
| public HgxImage(Size size, int bitDepth, Point offset, Size fullSize) { | |
| if (size.Width <= 0 || size.Height <= 0) | |
| throw new ArgumentException("Size width or height is less than or equal to zero", nameof(size)); | |
| if (bitDepth <= 0) | |
| throw new ArgumentException("BitDepth is less than or equal to zero", nameof(bitDepth)); | |
| if (fullSize.Width <= 0 || fullSize.Height <= 0) | |
| throw new ArgumentException("FullSize width or height is less than or equal to zero", nameof(fullSize)); | |
| this.Size = size; | |
| this.BitDepth = bitDepth; | |
| this.Offset = offset; | |
| this.FullSize = fullSize; | |
| this.HasTransparency = false; | |
| this.Base = Point.Empty; | |
| this.Slices = new HgxSlice[1]{ new HgxSlice() }; | |
| } | |
| #endregion | |
| #region Public Methods | |
| // operates on/modifies pixels buffer | |
| public void EncodePixels(byte[] pixels) { | |
| for (int i = 0; i < this.Slices.Length; i++) { | |
| this.Slices[i] = this.EncodeSlice(this.Slices[i], pixels); | |
| } | |
| } | |
| public void DecodePixels(byte[] pixels) { | |
| for (int i = 0; i < this.Slices.Length; i++) { | |
| this.DecodeSlice(this.Slices[i], pixels); | |
| } | |
| } | |
| public HgxSlice EncodeSlice(HgxSlice slice, byte[] pixels) { | |
| this.EncodeDelta(slice, pixels); | |
| this.EncodeBlocks(slice, pixels, out byte[] blocks); | |
| return this.EncodeZeroRunLength(slice, blocks); | |
| } | |
| public void DecodeSlice(HgxSlice slice, byte[] pixels) { | |
| this.DecodeZeroRunLength(slice, out byte[] blocks); | |
| this.DecodeBlocks(slice, pixels, blocks); | |
| this.DecodeDelta(slice, pixels); | |
| } | |
| public static HgxImage FromBitmap(Bitmap source, HgxOptions options, Point origin = default(Point)) { | |
| // SETUP | |
| int bitDepth; | |
| bool hasTransparency; | |
| PixelFormat format; | |
| Rectangle bounds = new Rectangle(0, 0, source.Width, source.Height); | |
| switch (source.PixelFormat) { | |
| case PixelFormat.Format24bppRgb: | |
| case PixelFormat.Format32bppRgb: | |
| format = PixelFormat.Format24bppRgb; | |
| hasTransparency = false; | |
| bitDepth = 24; | |
| break; | |
| case PixelFormat.Format32bppArgb: | |
| case PixelFormat.Format32bppPArgb: | |
| format = PixelFormat.Format32bppArgb; | |
| bitDepth = 32; | |
| if (options.HasFlag(HgxOptions.Crop)) { | |
| bounds = HgxImage.GetBoundingBox(source, out hasTransparency); | |
| } | |
| else { | |
| hasTransparency = HgxImage.HasTransparency(source); | |
| } | |
| break; | |
| default: | |
| throw new Exception("Unsupported pixel format"); | |
| } | |
| if (!options.HasFlag(HgxOptions.Flip)) { | |
| // This image type is stored flipped, so reverse the option | |
| target.RotateFlip(RotateFlipType.RotateNoneFlipY); | |
| if (options.HasFlag(HgxOptions.Crop)) { | |
| // Flip is applied after cropping, so update bounds | |
| int newY = source.Height - bounds.Bottom; | |
| bounds = new Rectangle(bounds.X, newY, bounds.Width, bounds.Height); | |
| } | |
| } | |
| byte[] pixels; | |
| BitmapData data = source.LockBits(bounds, ImageLockMode.ReadOnly, format); | |
| try { | |
| pixels = new byte[data.Stride * data.Height]; | |
| Marshal.Copy(data.Scan0, pixels, 0, pixels.Length); | |
| } finally { | |
| source.UnlockBits(data); | |
| } | |
| HgxImage encoder = new HgxImage(bounds.Size, bitDepth, source.Size, bounds.Location) { | |
| HasTransparency = hasTransparency, | |
| Base = origin, | |
| Slices = new HgxSlice[1]{ new HgxSlice{ | |
| Index = 0, | |
| Length = bounds.Height, | |
| }}, | |
| }; | |
| encoder.EncodePixels(pixels); | |
| return encoder; | |
| } | |
| public Bitmap ToBitmap(HgxOptions options) { | |
| // SETUP | |
| PixelFormat format; | |
| switch (this.BitDepth) { | |
| case 24: format = PixelFormat.Format24bppRgb; break; | |
| case 32: format = PixelFormat.Format32bppArgb; break; | |
| default: throw new Exception("Unsupported bit depth"); | |
| } | |
| if (this.Size == this.FullSize && this.Offset.IsEmpty) | |
| options &= ~HgxOptions.Crop; // Remove unnecessary crop option | |
| bool crop = options.HasFlag(HgxOptions.Crop); | |
| Size targetSize = (crop ? this.FullSize : this.Size); | |
| Point targetOffset = (crop ? this.Offset : Point.Empty); | |
| PixelFormat targetFormat = (crop ? PixelFormat.Format32bppArgb : format); | |
| // DECODING PROCESS | |
| byte[] pixels = new byte[this.Stride * this.Height]; | |
| this.DecodePixels(pixels); | |
| // HGX OPTIONS | |
| GCHandle handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); | |
| try { | |
| IntPtr scan0 = handle.AddrOfPinnedObject(); | |
| using (var intermediate = new Bitmap(this.Width, this.Height, this.Stride, format, scan0)) { | |
| Bitmap target = new Bitmap(targetSize.Width, targetSize.Height, targetFormat); | |
| try { | |
| using (Graphics g = Graphics.FromImage(targetUncrop)) { | |
| if (!options.HasFlag(HgxOptions.Flip)) | |
| // This image type is stored flipped, so reverse the option | |
| intermediate.RotateFlip(RotateFlipType.RotateNoneFlipY); | |
| g.DrawImageUnscaled(intermediate, targetOffset.X, targetOffset.Y); | |
| return target; | |
| } | |
| } | |
| catch { | |
| target.Dispose(); | |
| throw; | |
| } | |
| } | |
| } finally { | |
| // Thing to note that gave me headaches earlier: | |
| // Once this handle is freed, the bitmap loaded from | |
| // scan0 will be invalidated after garbage collection. | |
| handle.Free(); | |
| } | |
| } | |
| #endregion | |
| #region Private Decoding Methods | |
| private HgxSlice EncodeZeroRunLength(HgxSlice slice, byte[] buffer) { | |
| int length = buffer.Length; | |
| // first measure the size of the data buffer and cmd bits, and record the runs | |
| int dataLength = 0; | |
| long cmdBitLength = 1L; // 1 bit consumed for copyFlag | |
| cmdBitLength += BitBuffer.MeasureEliasGamma(length); | |
| List<int> runs = new List<int>(); // includes zero and non-zero runs | |
| bool copyFlag = (buffer[0] != 0); // is first run non-zero data? | |
| int offset = 0; | |
| while (offset < length) { | |
| int runLength = 1; | |
| if (copyFlag) { | |
| while (offset + runLength < length && buffer[offset + runLength] != 0) | |
| runLength++; | |
| dataLength += runLength; | |
| } | |
| else { | |
| while (offset + runLength < length && buffer[offset + runLength] == 0) | |
| runLength++; | |
| } | |
| runs.Add(runLength); | |
| cmdBitLength += BitBuffer.MeasureEliasGamma(runLength); | |
| offset += runLength; | |
| copyFlag = !copyFlag; | |
| } | |
| // now create the buffers and write the data | |
| data = new byte[dataLength]; | |
| cmd = new byte[checked((int) ((cmdBitLength + 7) / 8))]; | |
| copyFlag = (buffer[0] != 0); | |
| BitBuffer bitbuf = new BitBuffer(cmd, cmd.Length); | |
| bitbuf.WriteFlag(copyFlag); | |
| bitbuf.WriteEliasGamma(length); | |
| offset = 0; | |
| int dataOffset = 0; | |
| for (int j = 0; j < runs.Length; j++) { | |
| int runLength = runs[j]; | |
| if (copyFlag) { | |
| Array.Copy(buffer, offset, data, dataOffset, runLength); | |
| dataOffset += runLength; | |
| } | |
| bitbuf.WriteEliasGamma(runLength); | |
| offset += runLength; | |
| copyFlag = !copyFlag; | |
| } | |
| slice.Data = data; | |
| slice.Cmd = cmd; | |
| return slice; | |
| } | |
| private void DecodeZeroRunLength(HgxSlice slice, out byte[] buffer) { | |
| BitBuffer bitbuf = new BitBuffer(cmd, cmd.Length); | |
| int offset = 0; | |
| int dataOffset = 0; | |
| bool copyFlag = bitbuf.ReadFlag(); | |
| int length = bitbuf.ReadEliasGamma(); | |
| while (offset < length) { | |
| int runLength = bitbuf.ReadEliasGamma(); | |
| if (copyFlag) { | |
| Array.Copy(data, dataOffset, buffer, offset, runLength); | |
| dataOffset += runLength; | |
| } | |
| offset += runLength; | |
| copyFlag = !copyFlag; | |
| } | |
| } | |
| private void EncodeBlocks(HgxSlice slice, byte[] pixels, out byte[] blocks) { | |
| byte[,] absTables = HgxImage._absTables; // [0] for normalization | |
| int stride = this.Stride; | |
| int bufferStart = slice.Index * stride; | |
| int bufferEnd = (slice.Index + slice.Length) * stride; | |
| int sectLength = slice.Length * stride / 4; | |
| // First loop through the entire pixels slice and perform absolute transform | |
| for (int i = bufferStart; i < bufferEnd; i++) { | |
| //byte a = pixels[i]; | |
| //pixels[i] = unchecked((byte) ((a << 1) & 0xfe) ^ ((a & 0x80) != 0 ? (byte) 0xff : (byte) 0x00)); | |
| pixels[i] = absTables[0, pixels[i]]; | |
| } | |
| // Iterate through each section one at a time, each pass | |
| // through pixels encodes a different mask (section/block) of bytes | |
| int dst = 0; | |
| for (int k = 6, dst = 0; k >= 0; k-=2) { | |
| for (int i = 0, src = 0; i < sectLength; i++) { | |
| byte val = 0; | |
| // Take next 4 source bytes and convert to weighted section byte | |
| for (int j = 0; j < 8; j+=2) { | |
| byte b = pixels[src++]; | |
| val |= unchecked((byte) (((b >> k) & 0x03) << j)); | |
| } | |
| blocks[dst++] = val; | |
| } | |
| } | |
| } | |
| private void DecodeBlocks(HgxSlice slice, byte[] pixels, byte[] blocks) { | |
| uint[,] tables = HgxImage._weightTables; | |
| byte[,] absTables = HgxImage._absTables; // [1] for inverse normalization | |
| int stride = this.Stride; | |
| int bufferStart = slice.Index * stride; | |
| int sectLength = slice.Length * stride / 4; | |
| int sect0 = 0; | |
| int sect1 = sect0 + sectLength; | |
| int sect2 = sect0 + sectLength * 2; | |
| int sect3 = sect0 + sectLength * 3; | |
| for (int i = 0, j = bufferStart; i < sectLength; i++, j+=4) { | |
| uint val = tables[0, blocks[sect0 + i]] | tables[1, blocks[sect1 + i]] | | |
| tables[2, blocks[sect2 + i]] | tables[3, blocks[sect3 + i]]; | |
| pixels[j] = absTables[1, unchecked((byte) val)]; | |
| pixels[j + 1] = absTables[1, unchecked((byte) (val >> 8))]; | |
| pixels[j + 2] = absTables[1, unchecked((byte) (val >> 16))]; | |
| pixels[j + 3] = absTables[1, unchecked((byte) (val >> 24))]; | |
| } | |
| } | |
| private void EncodeDelta(HgxSlice slice, byte[] pixels) { | |
| int stride = this.Stride; | |
| int byteDepth = this.ByteDepth; | |
| int bufferStart = slice.Index * stride; | |
| int bufferEnd = (slice.Index + slice.Length) * stride; | |
| Array.Copy(pixels, bufferStart, pixels, 0, bufferStart - bufferEnd); | |
| pixels = (byte[]) pixels.Clone(); | |
| // delta RGBA channels of each previous stride in all but first row | |
| for (int xy = bufferEnd - 1; xy >= bufferStart + stride; xy--) { | |
| pixels[xy] = unchecked((byte) (pixels[xy] - pixels[xy - stride])); | |
| } | |
| // delta RGBA channels of each previous pixel in first row | |
| for (int x0 = bufferStart + stride - 1; x0 >= bufferStart + byteDepth; x0--) { | |
| pixels[x0] = unchecked((byte) (pixels[x0] - pixels[x0 - byteDepth])); | |
| } | |
| } | |
| private void DecodeDelta(HgxSlice slice, byte[] pixels) { | |
| int stride = this.Stride; | |
| int byteDepth = this.ByteDepth; | |
| int bufferStart = slice.Index * stride; | |
| int bufferEnd = (slice.Index + slice.Length) * stride; | |
| Array.Copy(buffer, 0, pixels, bufferStart, bufferStart - bufferEnd); | |
| // inverse delta RGBA channels of each previous pixel in first row | |
| for (int x0 = bufferStart + byteDepth; x0 < bufferStart + stride; x0++) { | |
| pixels[x0] = unchecked((byte) (pixels[x0] + pixels[x0 - byteDepth])); | |
| } | |
| // inverse delta RGBA channels of each previous stride in all but first row | |
| for (int xy = bufferStart + stride; xy < bufferEnd; xy++) { | |
| pixels[xy] = unchecked((byte) (pixels[xy] + pixels[xy - stride])); | |
| } | |
| } | |
| #endregion | |
| #region Private Static Decoding Methods | |
| private static uint[,] InitWeightTables() { | |
| uint[,] weightTables = new uint[4, 256]; | |
| for (uint i = 0; i < 256; i++) { | |
| // Creates pattern 0x03030303, where the masked bits | |
| // are like a stretched binary number in relation to i | |
| uint val = ((i & 0xc0) << 18) | ((i & 0x30) << 12) | | |
| ((i & 0x0c) << 6) | ((i & 0x03)); | |
| weightTables[3, i] = val; | |
| weightTables[2, i] = val << 2; | |
| weightTables[1, i] = val << 4; | |
| weightTables[0, i] = val << 6; | |
| } | |
| return tables; | |
| } | |
| /// <summary> | |
| /// Returns the absolute and inverse absolute value tables. byte[2, 256] | |
| /// Index 0 is absolute, index 1 is inverse absolute | |
| /// </summary> | |
| private static byte[,] InitAbsoluteTables() { | |
| byte[,] absTables = new byte[2, 256]; // [0] Forward, [1] Inverse | |
| for (int i = 0, j = 0; i < 128; i++, j+=2) { | |
| absTables[0, i] = unchecked((byte) j); | |
| absTables[0, 256 - i] = unchecked((byte) (j + 1)); | |
| absTables[1, j] = unchecked((byte) i); | |
| absTables[2, j + 1] = unchecked((byte) (256 - i)); | |
| } | |
| return absTables; | |
| } | |
| #endregion | |
| #region Static Helper Methods | |
| private static bool HasTransparency(Bitmap source) { | |
| Rectangle bounds = new Rectangle(0, 0, source.Width, source.Height); | |
| BitmapData data = source.LockBits(bounds, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); | |
| try { | |
| return HgxImage.HasTransparency(data, bounds); | |
| } finally { | |
| source.UnlockBits(data); | |
| } | |
| } | |
| private static unsafe bool HasTransparency(BitmapData data, Rectangle bounds) { | |
| byte* pixels = (byte*) data.Scan0.ToPointer(); | |
| //bool transparency = false; | |
| for (int y = bounds.Top; y < bounds.Bottom; y++) { | |
| for (int x = bounds.Left; x < bounds.Right; x++) { | |
| byte alpha = pixels[y * data.Stride + 4 * x + 3]; | |
| if (alpha != 255) { | |
| return true; | |
| } | |
| } | |
| } | |
| return false; | |
| } | |
| private static Rectangle GetBoundingBox(Bitmap source, out bool transparency) { | |
| Rectangle bounds = new Rectangle(0, 0, source.Width, source.Height); | |
| BitmapData data = source.LockBits(bounds, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); | |
| try { | |
| bounds = HgxImage.GetBoundingBox(data); | |
| transparency = HgxImage.HasTransparency(data, bounds); | |
| } finally { | |
| source.UnlockBits(data); | |
| } | |
| } | |
| private static unsafe Rectangle GetBoundingBox(BitmapData data) { | |
| byte* pixels = (byte*) data.Scan0.ToPointer(); | |
| int left = 0, top = 0; | |
| int right = data.Width, bottom = data.Height; | |
| // find left border | |
| bool stop = false; | |
| for (int x = 0; x < data.Width && !stop; x++) { | |
| for (int y = 0; y < data.Height; y++) { | |
| byte alpha = pixels[y * data.Stride + 4 * x + 3]; | |
| if (alpha != 0) { | |
| left = x; | |
| stop = true; | |
| break; | |
| } | |
| } | |
| } | |
| // non-transparent pixel not found, image is empty | |
| if (!stop) | |
| return Rectangle.Empty; | |
| // find top border | |
| stop = false; | |
| for (int y = 0; y < data.Height && !stop; y++) { | |
| for (int x = left; x < data.Width; x++) { | |
| byte alpha = pixels[y * data.Stride + 4 * x + 3]; | |
| if (alpha != 0) { | |
| top = y; | |
| stop = true; | |
| break; | |
| } | |
| } | |
| } | |
| // find right border | |
| stop = false; | |
| for (int x = data.Width - 1; x >= left && !stop; x--) { | |
| for (int y = top; y < data.Height; y++) { | |
| byte alpha = pixels[y * data.Stride + 4 * x + 3]; | |
| if (alpha != 0) { | |
| right = x + 1; | |
| stop = true; | |
| break; | |
| } | |
| } | |
| } | |
| // find bottom border | |
| stop = false; | |
| for (int y = data.Height - 1; y >= top && !stop; y--) { | |
| for (int x = left; x < right; x++) { | |
| byte alpha = pixels[y * data.Stride + 4 * x + 3]; | |
| if (alpha != 0) { | |
| bottom = y + 1; | |
| stop = true; | |
| break; | |
| } | |
| } | |
| } | |
| return Rectangle.FromLTRB(left, top, right, bottom); | |
| } | |
| #endregion | |
| #region Private Classes | |
| private struct BitBuffer { | |
| #region Fields | |
| private byte[] _buffer; // Entire buffer | |
| private int _bit; // Bit offset in working byte | |
| private int _index; // Byte offset of working byte | |
| private int _remaining; // Remaining buffer bytes (including working byte) | |
| #endregion | |
| #region Constructors | |
| public BitBuffer(byte[] buffer, int length) { | |
| if (buffer == null) | |
| throw new ArgumentException(nameof(buffer)); | |
| if (length < 0) | |
| throw new ArgumentOutOfRangeException(nameof(length), length, "Length is less than zero"); | |
| if (length > buffer.Length) | |
| throw new ArgumentOutOfRangeException(nameof(length), length, "Length is greater than Buffer length"); | |
| this._buffer = buffer; | |
| this._bit = 8; // set to check for EOF on next bit | |
| this._index = -1; // Incremented on next bit | |
| this._remaining = length + 1; // Decremented on next bit | |
| } | |
| #endregion | |
| #region Read Methods | |
| public bool ReadFlag() { | |
| if (this._bit > 7) { | |
| this._bit = 0; | |
| this._index++; | |
| if (--this._remaining <= 0) | |
| throw new IOException("Unexpected end of bit buffer reached"); | |
| } | |
| return ((this._buffer[this._index] >> this._bit++) & 0x1) != 0; | |
| } | |
| public int ReadEliasGamma() { | |
| int digits = 0; | |
| while (!this.ReadFlag()) | |
| digits++; | |
| int value = 1 << digits; | |
| while (--digits >= 0) { | |
| if (this.ReadFlag()) | |
| value |= 1 << digits; | |
| } | |
| return value; | |
| } | |
| #endregion | |
| #region Write Methods | |
| public void WriteFlag(bool flag) { | |
| if (this._bit > 7) { | |
| this._bit = 0; | |
| this._index++; | |
| if (--this._remaining <= 0) | |
| throw new IOException("Unexpected end of bit buffer reached"); | |
| } | |
| if (flag) this._buffer[this._index] |= unchecked((byte) (1 << this._bit++)); | |
| } | |
| public void WriteEliasGamma(int value) { | |
| int digits = 0; | |
| while ((value >> (digits + 1)) != 0) { | |
| digits++; | |
| this.WriteFlag(false); // More digits | |
| } | |
| this.WriteFlag(true); // End of digits | |
| while (--digits >= 0) { | |
| this.WriteFlag(((value >> digits) & 0x1) != 0 ? true : false); | |
| } | |
| } | |
| #endregion | |
| #region Static Methods | |
| public static int MeasureEliasGamma(int value) { | |
| int digits = 0; | |
| while ((value >> (digits + 1)) != 0) | |
| digits++; | |
| // 1 bit minimum plus 2 bits per digit | |
| return digits * 2 + 1; | |
| } | |
| #endregion | |
| } | |
| #endregion | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment