diff --git a/src/ImageSharp/Formats/Qoi/QoiChunkEnum.cs b/src/ImageSharp/Formats/Qoi/QoiChunkEnum.cs new file mode 100644 index 000000000..f877a4935 --- /dev/null +++ b/src/ImageSharp/Formats/Qoi/QoiChunkEnum.cs @@ -0,0 +1,14 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Qoi; + +public enum QoiChunkEnum +{ + QOI_OP_RGB = 0b11111110, + QOI_OP_RGBA = 0b11111111, + QOI_OP_INDEX = 0b00000000, + QOI_OP_DIFF = 0b01000000, + QOI_OP_LUMA = 0b10000000, + QOI_OP_RUN = 0b11000000 +} diff --git a/src/ImageSharp/Formats/Qoi/QoiConfigurationModule.cs b/src/ImageSharp/Formats/Qoi/QoiConfigurationModule.cs index ab0d0aa2c..ff40f7e17 100644 --- a/src/ImageSharp/Formats/Qoi/QoiConfigurationModule.cs +++ b/src/ImageSharp/Formats/Qoi/QoiConfigurationModule.cs @@ -12,7 +12,7 @@ public sealed class QoiConfigurationModule : IImageFormatConfigurationModule public void Configure(Configuration configuration) { configuration.ImageFormatsManager.SetDecoder(QoiFormat.Instance, QoiDecoder.Instance); - //configuration.ImageFormatsManager.SetEncoder(QoiFormat.Instance, new QoiEncoder()); + configuration.ImageFormatsManager.SetEncoder(QoiFormat.Instance, new QoiEncoder()); configuration.ImageFormatsManager.AddImageFormatDetector(new QoiImageFormatDetector()); } } diff --git a/src/ImageSharp/Formats/Qoi/QoiDecoder.cs b/src/ImageSharp/Formats/Qoi/QoiDecoder.cs index 175291de5..2736f9df3 100644 --- a/src/ImageSharp/Formats/Qoi/QoiDecoder.cs +++ b/src/ImageSharp/Formats/Qoi/QoiDecoder.cs @@ -1,8 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using SixLabors.ImageSharp.Formats.Png; - namespace SixLabors.ImageSharp.Formats.Qoi; internal class QoiDecoder : ImageDecoder { @@ -16,7 +14,13 @@ internal class QoiDecoder : ImageDecoder { Guard.NotNull(options, nameof(options)); Guard.NotNull(stream, nameof(stream)); - throw new NotImplementedException(); + + QoiDecoderCore decoder = new(options); + Image image = decoder.Decode(options.Configuration, stream, cancellationToken); + + ScaleToTargetSize(options, image); + + return image; } protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken) diff --git a/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs b/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs index 5cec6c244..11db0575b 100644 --- a/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs +++ b/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs @@ -44,14 +44,49 @@ internal class QoiDecoderCore : IImageDecoderInternals public Size Dimensions { get; } + /// public Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) - where TPixel : unmanaged, IPixel => throw new NotImplementedException(); + where TPixel : unmanaged, IPixel + { + // Process the header to get metadata + this.ProcessHeader(stream); + + // Create Image object + ImageMetadata metadata = new() + { + DecodedImageFormat = QoiFormat.Instance, + HorizontalResolution = this.header.Width, + VerticalResolution = this.header.Height, + ResolutionUnits = PixelResolutionUnit.AspectRatio + }; + Image image = new(this.configuration, (int)this.header.Width, (int)this.header.Height, metadata); + Buffer2D pixels = image.GetRootFramePixelBuffer(); + + this.ProcessPixels(stream, pixels); + + return image; + } + /// public ImageInfo Identify(BufferedReadStream stream, CancellationToken cancellationToken) { ImageMetadata metadata = new(); - QoiMetadata qoiMetadata = metadata.GetQoiMetadata(); + this.ProcessHeader(stream); + PixelTypeInfo pixelType = new(8 * (int)this.header.Channels); + Size size = new((int)this.header.Width, (int)this.header.Height); + + return new ImageInfo(pixelType, size, metadata); + } + + /// + /// Processes the 14-byte header to validate the image and save the metadata + /// in + /// + /// The stream where the bytes are being read + /// If the stream doesn't store a qoi image + private void ProcessHeader(Stream stream) + { Span magicBytes = stackalloc byte[4]; Span widthBytes = stackalloc byte[4]; Span heightBytes = stackalloc byte[4]; @@ -85,11 +120,6 @@ internal class QoiDecoderCore : IImageDecoderInternals $"The image has an invalid size: width = {width}, height = {height}"); } - qoiMetadata.Width = width; - qoiMetadata.Height = height; - - Size size = new((int)width, (int)height); - int channels = stream.ReadByte(); if (channels is -1 or (not 3 and not 4)) { @@ -97,7 +127,6 @@ internal class QoiDecoderCore : IImageDecoderInternals } PixelTypeInfo pixelType = new(8 * channels); - qoiMetadata.Channels = (QoiChannels)channels; int colorSpace = stream.ReadByte(); if (colorSpace is -1 or (not 0 and not 1)) @@ -105,12 +134,124 @@ internal class QoiDecoderCore : IImageDecoderInternals ThrowInvalidImageContentException(); } - qoiMetadata.ColorSpace = (QoiColorSpace)colorSpace; - - return new ImageInfo(pixelType, size, metadata); + this.header = new QoiHeader(width, height, (QoiChannels)channels, (QoiColorSpace)colorSpace); } [DoesNotReturn] private static void ThrowInvalidImageContentException() => throw new InvalidImageContentException("The image is not a valid QOI image."); + + private void ProcessPixels(BufferedReadStream stream, Buffer2D pixels) + where TPixel : unmanaged, IPixel + { + Rgba32[] previouslySeenPixels = new Rgba32[64]; + Rgba32 previousPixel = new (0,0,0,255); + for (int i = 0; i < this.header.Height; i++) + { + for (int j = 0; j < this.header.Width; j++) + { + byte operationByte = (byte)stream.ReadByte(); + byte[] pixelBytes; + Rgba32 readPixel; + TPixel pixel = new(); + int pixelArrayPosition; + switch ((QoiChunkEnum)operationByte) + { + case QoiChunkEnum.QOI_OP_RGB: + pixelBytes = new byte[3]; + if (stream.Read(pixelBytes) < 3) + { + ThrowInvalidImageContentException(); + } + + readPixel = previousPixel with { R = pixelBytes[0], G = pixelBytes[1], B = pixelBytes[2] }; + pixel.FromRgba32(readPixel); + pixelArrayPosition = this.GetArrayPosition(readPixel); + previouslySeenPixels[pixelArrayPosition] = readPixel; + break; + + case QoiChunkEnum.QOI_OP_RGBA: + pixelBytes = new byte[4]; + if (stream.Read(pixelBytes) < 4) + { + ThrowInvalidImageContentException(); + } + + readPixel = new Rgba32(pixelBytes[0], pixelBytes[1], pixelBytes[2], pixelBytes[3]); + pixel.FromRgba32(readPixel); + pixelArrayPosition = this.GetArrayPosition(readPixel); + previouslySeenPixels[pixelArrayPosition] = readPixel; + break; + + default: + switch ((QoiChunkEnum)(operationByte & 0b11000000)) + { + case QoiChunkEnum.QOI_OP_INDEX: + readPixel = previouslySeenPixels[operationByte]; + pixel.FromRgba32(readPixel); + break; + case QoiChunkEnum.QOI_OP_DIFF: + // Get each value + byte redDifference = (byte)((operationByte & 0b00110000) >> 4), + greenDifference = (byte)((operationByte & 0b00001100) >> 2), + blueDifference = (byte)(operationByte & 0b00000011); + readPixel = previousPixel with + { + R = (byte)((previousPixel.R + (redDifference - 2)) % 256), + G = (byte)((previousPixel.G + (greenDifference - 2)) % 256), + B = (byte)((previousPixel.B + (blueDifference - 2)) % 256) + }; + pixel.FromRgba32(readPixel); + pixelArrayPosition = this.GetArrayPosition(readPixel); + previouslySeenPixels[pixelArrayPosition] = readPixel; + break; + case QoiChunkEnum.QOI_OP_LUMA: + // Get difference green channel + byte diffGreen = (byte)(operationByte & 0b00111111), + currentGreen = (byte)((previousPixel.G + (diffGreen - 32)) % 256), + nextByte = (byte)stream.ReadByte(), + diffRedDG = (byte)(nextByte >> 4), + diffBlueDG = (byte)(nextByte & 0b00001111), + currentRed = (byte)((diffRedDG-8 + (diffGreen - 32) + previousPixel.R)%256), + currentBlue = (byte)((diffBlueDG-8 + (diffGreen - 32) + previousPixel.B)%256); + readPixel = previousPixel with { R = currentRed, B = currentBlue, G = currentGreen }; + pixel.FromRgba32(readPixel); + pixelArrayPosition = this.GetArrayPosition(readPixel); + previouslySeenPixels[pixelArrayPosition] = readPixel; + break; + case QoiChunkEnum.QOI_OP_RUN: + byte repetitions = (byte)(operationByte & 0b00111111); + if(repetitions is 62 or 63) + { + ThrowInvalidImageContentException(); + } + + readPixel = previousPixel; + pixel.FromRgba32(readPixel); + for (int k = -1; k < repetitions; k++, j++) + { + if (j == this.header.Width) + { + j = 0; + i++; + } + pixels[j,i] = pixel; + } + + j--; + continue; + + default: + ThrowInvalidImageContentException(); + return; + } + break; + } + pixels[j,i] = pixel; + previousPixel = readPixel; + } + } + } + + private int GetArrayPosition(Rgba32 pixel) => ((pixel.R * 3) + (pixel.G * 5) + (pixel.B * 7) + (pixel.A * 11)) % 64; } diff --git a/src/ImageSharp/Formats/Qoi/QoiEncoder.cs b/src/ImageSharp/Formats/Qoi/QoiEncoder.cs new file mode 100644 index 000000000..e4aa0177b --- /dev/null +++ b/src/ImageSharp/Formats/Qoi/QoiEncoder.cs @@ -0,0 +1,13 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +namespace SixLabors.ImageSharp.Formats.Qoi; + +public class QoiEncoder : ImageEncoder +{ + /// + protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} diff --git a/src/ImageSharp/Formats/Qoi/QoiImageFormatDetector.cs b/src/ImageSharp/Formats/Qoi/QoiImageFormatDetector.cs index 380e1a41d..d264ec5bc 100644 --- a/src/ImageSharp/Formats/Qoi/QoiImageFormatDetector.cs +++ b/src/ImageSharp/Formats/Qoi/QoiImageFormatDetector.cs @@ -1,9 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System.Buffers.Binary; using System.Diagnostics.CodeAnalysis; -using SixLabors.ImageSharp.Formats.Png; namespace SixLabors.ImageSharp.Formats.Qoi; diff --git a/tests/ImageSharp.Tests/Formats/Qoi/QoiDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Qoi/QoiDecoderTests.cs index cf432e82d..21b513e59 100644 --- a/tests/ImageSharp.Tests/Formats/Qoi/QoiDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Qoi/QoiDecoderTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using SixLabors.ImageSharp.PixelFormats; + namespace SixLabors.ImageSharp.Tests.Formats.Qoi; [Trait("Format", "Qoi")] @@ -26,4 +28,22 @@ public class QoiDecoderTests Assert.NotNull(imageInfo); Assert.Equal(imageInfo.Metadata.DecodedImageFormat, ImageSharp.Formats.Qoi.QoiFormat.Instance); } + + [Theory] + [WithFile(TestImages.Qoi.Dice, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.EdgeCase, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.Kodim10, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.Kodim23, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.QoiLogo, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.TestCard, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.TestCardRGBA, PixelTypes.Rgba32)] + [WithFile(TestImages.Qoi.Wikipedia008, PixelTypes.Rgba32)] + public void Decode(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + using Image image = provider.GetImage(); + image.DebugSave(provider); + + image.CompareToReferenceOutput(provider); + } } diff --git a/tests/ImageSharp.Tests/TestUtilities/TestEnvironment.Formats.cs b/tests/ImageSharp.Tests/TestUtilities/TestEnvironment.Formats.cs index b91b5631b..6c6f300d0 100644 --- a/tests/ImageSharp.Tests/TestUtilities/TestEnvironment.Formats.cs +++ b/tests/ImageSharp.Tests/TestUtilities/TestEnvironment.Formats.cs @@ -8,6 +8,7 @@ using SixLabors.ImageSharp.Formats.Gif; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Pbm; using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Qoi; using SixLabors.ImageSharp.Formats.Tga; using SixLabors.ImageSharp.Formats.Tiff; using SixLabors.ImageSharp.Formats.Webp; @@ -62,7 +63,8 @@ public static partial class TestEnvironment new PbmConfigurationModule(), new TgaConfigurationModule(), new WebpConfigurationModule(), - new TiffConfigurationModule()); + new TiffConfigurationModule(), + new QoiConfigurationModule()); IImageEncoder pngEncoder = IsWindows ? SystemDrawingReferenceEncoder.Png : new ImageSharpPngEncoderWithDefaultConfiguration(); IImageEncoder bmpEncoder = IsWindows ? SystemDrawingReferenceEncoder.Bmp : new BmpEncoder(); diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_dice.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_dice.png new file mode 100644 index 000000000..44b2fa118 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_dice.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e4a5cf4e80ed1e1106eceb3e873aecf7b8e0022dfe39aa4c0c64ffc41091f09 +size 243458 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_edgecase.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_edgecase.png new file mode 100644 index 000000000..f10fba3c2 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_edgecase.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b39ab87da8d615f1ebb1348789a767b6421771dfcf11cd0b7cac618a0b8d6f1 +size 918 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_kodim10.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_kodim10.png new file mode 100644 index 000000000..c038bcdcf --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_kodim10.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca18bd41b7d6db902e86c7a1be32ceb0989aaec0bf9fa94ca599887970b83e63 +size 598510 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_kodim23.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_kodim23.png new file mode 100644 index 000000000..1742ec80f --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_kodim23.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f6c7a229a652bfcaba998e713e169072475bea9bba35374be9219eb19c6ab42b +size 562295 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_qoi_logo.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_qoi_logo.png new file mode 100644 index 000000000..28e4bca77 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_qoi_logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:593549012cf9573c457c4de9161c347f1ae81d80c057ea70b89fbb197bdd028f +size 16953 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_testcard.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_testcard.png new file mode 100644 index 000000000..3ba2c266c --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_testcard.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ad1df5a4549a4860e00fbb53328208d4458e1961ae2fac290278c612432d1e7 +size 12299 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_testcard_rgba.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_testcard_rgba.png new file mode 100644 index 000000000..4b2c4c0c0 --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_testcard_rgba.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed62e82f1fed2bf16569298a61f792706a1b61e99026acefcbf8aeb0da6f6e08 +size 16075 diff --git a/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_wikipedia_008.png b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_wikipedia_008.png new file mode 100644 index 000000000..56b87a98f --- /dev/null +++ b/tests/Images/External/ReferenceOutput/QoiDecoderTests/Decode_Rgba32_wikipedia_008.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed7705c6ccb440f6bff77b0b9ac8275576d3f1c1fa4ecaa83ff80a72359e6f2f +size 1376202