diff --git a/src/ImageSharp/Formats/Qoi/QoiDecoder.cs b/src/ImageSharp/Formats/Qoi/QoiDecoder.cs
index 2736f9df3b..a54095dfc6 100644
--- a/src/ImageSharp/Formats/Qoi/QoiDecoder.cs
+++ b/src/ImageSharp/Formats/Qoi/QoiDecoder.cs
@@ -1,6 +1,8 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.ImageSharp.PixelFormats;
+
namespace SixLabors.ImageSharp.Formats.Qoi;
internal class QoiDecoder : ImageDecoder
{
@@ -10,6 +12,7 @@ internal class QoiDecoder : ImageDecoder
public static QoiDecoder Instance { get; } = new();
+ ///
protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
Guard.NotNull(options, nameof(options));
@@ -23,11 +26,12 @@ internal class QoiDecoder : ImageDecoder
return image;
}
+ ///
protected override Image Decode(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
{
Guard.NotNull(options, nameof(options));
Guard.NotNull(stream, nameof(stream));
- throw new NotImplementedException();
+ return this.Decode(options, stream, cancellationToken);
}
protected override ImageInfo Identify(DecoderOptions options, Stream stream, CancellationToken cancellationToken)
diff --git a/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs b/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs
index af07050558..279dbd2331 100644
--- a/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs
+++ b/src/ImageSharp/Formats/Qoi/QoiDecoderCore.cs
@@ -141,11 +141,11 @@ internal class QoiDecoderCore : IImageDecoderInternals
private static void ThrowInvalidImageContentException()
=> throw new InvalidImageContentException("The image is not a valid QOI image.");
- private void ProcessPixels(BufferedReadStream stream, Buffer2D pixels)
+ private void ProcessPixels(Stream stream, Buffer2D pixels)
where TPixel : unmanaged, IPixel
{
Rgba32[] previouslySeenPixels = new Rgba32[64];
- Rgba32 previousPixel = new (0,0,0,255);
+ Rgba32 previousPixel = new(0,0,0,255);
// We save the pixel to avoid loosing the fully opaque black pixel
// See https://github.com/phoboslab/qoi/issues/258
@@ -258,12 +258,28 @@ internal class QoiDecoderCore : IImageDecoderInternals
ThrowInvalidImageContentException();
return;
}
+
break;
}
+
pixels[j,i] = pixel;
previousPixel = readPixel;
}
}
+
+ // Check stream end
+ for (int i = 0; i < 7; i++)
+ {
+ if (stream.ReadByte() != 0)
+ {
+ ThrowInvalidImageContentException();
+ }
+ }
+
+ if (stream.ReadByte() != 1)
+ {
+ ThrowInvalidImageContentException();
+ }
}
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
index e4aa0177bf..526a84524b 100644
--- a/src/ImageSharp/Formats/Qoi/QoiEncoder.cs
+++ b/src/ImageSharp/Formats/Qoi/QoiEncoder.cs
@@ -1,13 +1,19 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using SixLabors.ImageSharp.Advanced;
+
namespace SixLabors.ImageSharp.Formats.Qoi;
+///
+/// Image encoder for writing an image to a stream as a QOI image
+///
public class QoiEncoder : ImageEncoder
{
///
protected override void Encode(Image image, Stream stream, CancellationToken cancellationToken)
{
- throw new NotImplementedException();
+ QoiEncoderCore encoder = new(image.GetConfiguration(), this);
+ encoder.Encode(image, stream, cancellationToken);
}
}
diff --git a/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs b/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs
new file mode 100644
index 0000000000..463a9be7c0
--- /dev/null
+++ b/src/ImageSharp/Formats/Qoi/QoiEncoderCore.cs
@@ -0,0 +1,219 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers.Binary;
+using System.Runtime.InteropServices.ComTypes;
+using SixLabors.ImageSharp.Memory;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Formats.Qoi;
+
+///
+/// Image encoder for writing an image to a stream as a QOi image
+///
+public class QoiEncoderCore : IImageEncoderInternals
+{
+ ///
+ /// The global configuration.
+ ///
+ private Configuration configuration;
+
+ ///
+ /// The encoder with options.
+ ///
+ private readonly QoiEncoder encoder;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The configuration.
+ /// The encoder with options.
+ public QoiEncoderCore(Configuration configuration, QoiEncoder encoder)
+ {
+ this.configuration = configuration;
+ this.encoder = encoder;
+ }
+
+ ///
+ public void Encode(Image image, Stream stream, CancellationToken cancellationToken)
+ where TPixel : unmanaged, IPixel
+ {
+ Guard.NotNull(image, nameof(image));
+ Guard.NotNull(stream, nameof(stream));
+
+ WriteHeader(image, stream);
+ WritePixels(image, stream);
+ WriteEndOfStream(stream);
+ stream.Flush();
+ }
+
+ private static void WriteHeader(Image image, Stream stream)
+ {
+ // Get metadata
+ Span width = stackalloc byte[4];
+ Span height = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(width, (uint)image.Width);
+ BinaryPrimitives.WriteUInt32BigEndian(height, (uint)image.Height);
+ QoiChannels qoiChannels = image.PixelType.BitsPerPixel == 24 ? QoiChannels.Rgb : QoiChannels.Rgba;
+
+ // I need to check this, how do I check it with the pixel type or metadata of the original image?
+ const QoiColorSpace qoiColorSpace = QoiColorSpace.SrgbWithLinearAlpha;
+
+ // Write header to the stream
+ stream.Write(QoiConstants.Magic);
+ stream.Write(width);
+ stream.Write(height);
+ stream.WriteByte((byte)qoiChannels);
+ stream.WriteByte((byte)qoiColorSpace);
+ }
+
+ private static void WritePixels(Image image, Stream stream) where TPixel : unmanaged, IPixel
+ {
+ // Start image encoding
+ Rgba32[] previouslySeenPixels = new Rgba32[64];
+ Rgba32 previousPixel = new(0, 0, 0, 255);
+ int pixelArrayPosition = GetArrayPosition(previousPixel);
+ previouslySeenPixels[pixelArrayPosition] = previousPixel;
+
+ Buffer2D pixels = image.Frames[0].PixelBuffer;
+ Rgba32 currentRgba32 = new();
+ for (int i = 0; i < pixels.Height; i++)
+ {
+ for (int j = 0; j < pixels.Width && i < pixels.Height; j++)
+ {
+ // We get the RGBA value from pixels
+ TPixel currentPixel = pixels[j, i];
+ currentPixel.ToRgba32(ref currentRgba32);
+
+ // First, we check if the current pixel is equal to the previous one
+ // If so, we do a QOI_OP_RUN
+ if (currentRgba32.Equals(previousPixel))
+ {
+ /* It looks like this isn't an error, but this makes possible that
+ * files start with a QOI_OP_RUN if their first pixel is a fully opaque
+ * black. However, the decoder of this project takes that into consideration
+ *
+ * To further details, see https://github.com/phoboslab/qoi/issues/258,
+ * and we should discuss what to do about this approach and
+ * if it's correct
+ */
+ byte repetitions = 0;
+ do
+ {
+ repetitions++;
+ j++;
+ if (j == pixels.Width)
+ {
+ j = 0;
+ i++;
+ }
+
+ if (i == pixels.Height)
+ {
+ break;
+ }
+
+ currentPixel = pixels[j, i];
+ currentPixel.ToRgba32(ref currentRgba32);
+ } while (currentRgba32.Equals(previousPixel) && repetitions < 62);
+
+ j--;
+ stream.WriteByte((byte)((byte)QoiChunkEnum.QOI_OP_RUN | (repetitions - 1)));
+
+ /* If it's a QOI_OP_RUN, we don't overwrite the previous pixel since
+ * it will be taken and compared on the next iteration
+ */
+ continue;
+ }
+
+ // else, we check if it exists in the previously seen pixels
+ // If so, we do a QOI_OP_INDEX
+ pixelArrayPosition = GetArrayPosition(currentRgba32);
+ if (previouslySeenPixels[pixelArrayPosition].Equals(currentPixel))
+ {
+ stream.WriteByte((byte)pixelArrayPosition);
+ }
+ else
+ {
+ // else, we check if the difference is less than -2..1
+ // Since it wasn't found on the previously seen pixels, we save it
+ previouslySeenPixels[pixelArrayPosition] = currentRgba32;
+
+ sbyte diffRed = (sbyte)(currentRgba32.R - previousPixel.R),
+ diffGreen = (sbyte)(currentRgba32.G - previousPixel.G),
+ diffBlue = (sbyte)(currentRgba32.B - previousPixel.B);
+
+ // If so, we do a QOI_OP_DIFF
+ if (diffRed is > -3 and < 2 &&
+ diffGreen is > -3 and < 2 &&
+ diffBlue is > -3 and < 2 &&
+ currentRgba32.A == previousPixel.A)
+ {
+ // Bottom limit is -2, so we add 2 to make it equal to 0
+ byte dr = (byte)(diffRed + 2),
+ dg = (byte)(diffGreen + 2),
+ db = (byte)(diffBlue + 2),
+ valueToWrite = (byte)((byte)QoiChunkEnum.QOI_OP_DIFF | (dr << 4) | (dg << 2) | db);
+ stream.WriteByte(valueToWrite);
+ }
+ else
+ {
+ // else, we check if the green difference is less than -32..31 and the rest -8..7
+ // If so, we do a QOI_OP_LUMA
+ sbyte diffRedGreen = (sbyte)(diffRed - diffGreen),
+ diffBlueGreen = (sbyte)(diffBlue - diffGreen);
+ if (diffGreen is > -33 and < 8 &&
+ diffRedGreen is > -9 and < 8 &&
+ diffBlueGreen is > -9 and < 8 &&
+ currentRgba32.A == previousPixel.A)
+ {
+ byte dr_dg = (byte)(diffRedGreen + 8),
+ db_dg = (byte)(diffBlueGreen + 8),
+ byteToWrite1 = (byte)((byte)QoiChunkEnum.QOI_OP_LUMA | (diffGreen + 32)),
+ byteToWrite2 = (byte)((dr_dg << 4) | db_dg);
+ stream.WriteByte(byteToWrite1);
+ stream.WriteByte(byteToWrite2);
+ }
+ else
+ {
+ // else, we check if the alpha is equal to the previous pixel
+ // If so, we do a QOI_OP_RGB
+ if (currentRgba32.A == previousPixel.A)
+ {
+ stream.WriteByte((byte)QoiChunkEnum.QOI_OP_RGB);
+ stream.WriteByte(currentRgba32.R);
+ stream.WriteByte(currentRgba32.G);
+ stream.WriteByte(currentRgba32.B);
+ }
+ else
+ {
+ // else, we do a QOI_OP_RGBA
+ stream.WriteByte((byte)QoiChunkEnum.QOI_OP_RGBA);
+ stream.WriteByte(currentRgba32.R);
+ stream.WriteByte(currentRgba32.G);
+ stream.WriteByte(currentRgba32.B);
+ stream.WriteByte(currentRgba32.A);
+ }
+ }
+ }
+ }
+
+ previousPixel = currentRgba32;
+ }
+ }
+ }
+
+ private static void WriteEndOfStream(Stream stream)
+ {
+ // Write bytes to end stream
+ for (int i = 0; i < 7; i++)
+ {
+ stream.WriteByte(0);
+ }
+
+ stream.WriteByte(1);
+ }
+
+ private static int GetArrayPosition(Rgba32 pixel)
+ => ((pixel.R * 3) + (pixel.G * 5) + (pixel.B * 7) + (pixel.A * 11)) % 64;
+}
diff --git a/tests/ImageSharp.Tests/Formats/Qoi/QoiEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Qoi/QoiEncoderTests.cs
new file mode 100644
index 0000000000..98331a8ffe
--- /dev/null
+++ b/tests/ImageSharp.Tests/Formats/Qoi/QoiEncoderTests.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Formats.Qoi;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison;
+
+namespace SixLabors.ImageSharp.Tests.Formats.Qoi;
+
+[Trait("Format", "Qoi")]
+[ValidateDisposedMemoryAllocations]
+public class QoiEncoderTests
+{
+ [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)]
+ private static void Encode(TestImageProvider provider) where TPixel : unmanaged, IPixel
+ {
+ using Image image = provider.GetImage();
+ using MemoryStream stream = new();
+ QoiEncoder encoder = new();
+ image.Save(stream, encoder);
+ stream.Position = 0;
+ using Image encodedImage = (Image)Image.Load(stream);
+ ImageComparingUtils.CompareWithReferenceDecoder(provider, encodedImage);
+ }
+}