From 7eaae92bbf5be2da810dde915681e3aff41b9fab Mon Sep 17 00:00:00 2001 From: Ynse Hoornenborg Date: Sun, 28 Mar 2021 22:00:45 +0200 Subject: [PATCH] Grayscale Jpeg encoding --- .../Jpeg/Components/Encoder/L8ToYConverter.cs | 49 +++++ .../LuminanceForwardConverter{TPixel}.cs | 59 ++++++ .../Formats/Jpeg/IJpegEncoderOptions.cs | 9 +- src/ImageSharp/Formats/Jpeg/JpegColorType.cs | 21 ++ src/ImageSharp/Formats/Jpeg/JpegEncoder.cs | 20 ++ .../Formats/Jpeg/JpegEncoderCore.cs | 188 ++++++++++++------ src/ImageSharp/Formats/Jpeg/JpegSubsample.cs | 7 +- .../Formats/Jpg/JpegEncoderTests.cs | 12 +- 8 files changed, 298 insertions(+), 67 deletions(-) create mode 100644 src/ImageSharp/Formats/Jpeg/Components/Encoder/L8ToYConverter.cs create mode 100644 src/ImageSharp/Formats/Jpeg/Components/Encoder/LuminanceForwardConverter{TPixel}.cs create mode 100644 src/ImageSharp/Formats/Jpeg/JpegColorType.cs diff --git a/src/ImageSharp/Formats/Jpeg/Components/Encoder/L8ToYConverter.cs b/src/ImageSharp/Formats/Jpeg/Components/Encoder/L8ToYConverter.cs new file mode 100644 index 000000000..6d787c58f --- /dev/null +++ b/src/ImageSharp/Formats/Jpeg/Components/Encoder/L8ToYConverter.cs @@ -0,0 +1,49 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Runtime.CompilerServices; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Jpeg.Components.Encoder +{ + /// + /// Provides 8-bit lookup tables for converting from L8 to Y colorspace. + /// + internal unsafe struct L8ToYConverter + { + /// + /// Initializes + /// + /// The initialized + public static L8ToYConverter Create() + { + L8ToYConverter converter = default; + return converter; + } + + /// + /// Optimized method to allocates the correct y, cb, and cr values to the DCT blocks from the given r, g, b values. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ConvertPixelInto( + int l, + ref Block8x8F yResult, + int i) => yResult[i] = l; + + public void Convert(Span l8Span, ref Block8x8F yBlock) + { + ref L8 l8Start = ref l8Span[0]; + + for (int i = 0; i < 64; i++) + { + ref L8 c = ref Unsafe.Add(ref l8Start, i); + + this.ConvertPixelInto( + c.PackedValue, + ref yBlock, + i); + } + } + } +} diff --git a/src/ImageSharp/Formats/Jpeg/Components/Encoder/LuminanceForwardConverter{TPixel}.cs b/src/ImageSharp/Formats/Jpeg/Components/Encoder/LuminanceForwardConverter{TPixel}.cs new file mode 100644 index 000000000..0b6cde826 --- /dev/null +++ b/src/ImageSharp/Formats/Jpeg/Components/Encoder/LuminanceForwardConverter{TPixel}.cs @@ -0,0 +1,59 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using SixLabors.ImageSharp.Advanced; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Formats.Jpeg.Components.Encoder +{ + /// + /// On-stack worker struct to efficiently encapsulate the TPixel -> L8 -> Y conversion chain of 8x8 pixel blocks. + /// + /// The pixel type to work on + internal ref struct LuminanceForwardConverter + where TPixel : unmanaged, IPixel + { + /// + /// The Y component + /// + public Block8x8F Y; + + /// + /// The converter + /// + private L8ToYConverter converter; + + /// + /// Temporal 8x8 block to hold TPixel data + /// + private GenericBlock8x8 pixelBlock; + + /// + /// Temporal RGB block + /// + private GenericBlock8x8 l8Block; + + public static LuminanceForwardConverter Create() + { + var result = default(LuminanceForwardConverter); + result.converter = L8ToYConverter.Create(); + return result; + } + + /// + /// Converts a 8x8 image area inside 'pixels' at position (x,y) placing the result members of the structure () + /// + public void Convert(ImageFrame frame, int x, int y, ref RowOctet currentRows) + { + this.pixelBlock.LoadAndStretchEdges(frame.PixelBuffer, x, y, ref currentRows); + + Span l8Span = this.l8Block.AsSpanUnsafe(); + PixelOperations.Instance.ToL8(frame.GetConfiguration(), this.pixelBlock.AsSpanUnsafe(), l8Span); + + ref Block8x8F yBlock = ref this.Y; + + this.converter.Convert(l8Span, ref yBlock); + } + } +} diff --git a/src/ImageSharp/Formats/Jpeg/IJpegEncoderOptions.cs b/src/ImageSharp/Formats/Jpeg/IJpegEncoderOptions.cs index ecd64a782..cceed407c 100644 --- a/src/ImageSharp/Formats/Jpeg/IJpegEncoderOptions.cs +++ b/src/ImageSharp/Formats/Jpeg/IJpegEncoderOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Six Labors. +// Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. namespace SixLabors.ImageSharp.Formats.Jpeg @@ -20,5 +20,10 @@ namespace SixLabors.ImageSharp.Formats.Jpeg /// /// The subsample ratio of the jpg image. JpegSubsample? Subsample { get; } + + /// + /// Gets the color type. + /// + JpegColorType? ColorType { get; } } -} \ No newline at end of file +} diff --git a/src/ImageSharp/Formats/Jpeg/JpegColorType.cs b/src/ImageSharp/Formats/Jpeg/JpegColorType.cs new file mode 100644 index 000000000..73b3215d6 --- /dev/null +++ b/src/ImageSharp/Formats/Jpeg/JpegColorType.cs @@ -0,0 +1,21 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +namespace SixLabors.ImageSharp.Formats.Jpeg +{ + /// + /// Provides enumeration of available JPEG color types. + /// + public enum JpegColorType : byte + { + /// + /// YCbCr (luminance, blue chroma, red chroma) color as defined in the ITU-T T.871 specification. + /// + YCbCr = 0, + + /// + /// Single channel, luminance. + /// + Luminance = 1 + } +} diff --git a/src/ImageSharp/Formats/Jpeg/JpegEncoder.cs b/src/ImageSharp/Formats/Jpeg/JpegEncoder.cs index b549bd8a3..6842cfe15 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegEncoder.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegEncoder.cs @@ -25,6 +25,11 @@ namespace SixLabors.ImageSharp.Formats.Jpeg /// public JpegSubsample? Subsample { get; set; } + /// + /// Gets or sets the color type, that will be used to encode the image. + /// + public JpegColorType? ColorType { get; set; } + /// /// Encodes the image to the specified stream from the . /// @@ -35,6 +40,7 @@ namespace SixLabors.ImageSharp.Formats.Jpeg where TPixel : unmanaged, IPixel { var encoder = new JpegEncoderCore(this); + this.EnrichColorType(); encoder.Encode(image, stream); } @@ -50,7 +56,21 @@ namespace SixLabors.ImageSharp.Formats.Jpeg where TPixel : unmanaged, IPixel { var encoder = new JpegEncoderCore(this); + this.EnrichColorType(); return encoder.EncodeAsync(image, stream, cancellationToken); } + + /// + /// If ColorType was not set, set it based on the given . + /// + private void EnrichColorType() + where TPixel : unmanaged, IPixel + { + if (this.ColorType == null) + { + bool isGrayscale = typeof(TPixel) == typeof(L8) || typeof(TPixel) == typeof(L16); + this.ColorType = isGrayscale ? JpegColorType.Luminance : JpegColorType.YCbCr; + } + } } } diff --git a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs index d26fbb936..bca03b109 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegEncoderCore.cs @@ -57,6 +57,11 @@ namespace SixLabors.ImageSharp.Formats.Jpeg /// private readonly int? quality; + /// + /// Gets or sets the subsampling method to use. + /// + private readonly JpegColorType? colorType; + /// /// The accumulated bits to write to the stream. /// @@ -90,6 +95,7 @@ namespace SixLabors.ImageSharp.Formats.Jpeg { this.quality = options.Quality; this.subsample = options.Subsample; + this.colorType = options.ColorType; } /// @@ -115,42 +121,6 @@ namespace SixLabors.ImageSharp.Formats.Jpeg 8, 8, 8, }; - /// - /// Gets the SOS (Start Of Scan) marker "\xff\xda" followed by 12 bytes: - /// - the marker length "\x00\x0c", - /// - the number of components "\x03", - /// - component 1 uses DC table 0 and AC table 0 "\x01\x00", - /// - component 2 uses DC table 1 and AC table 1 "\x02\x11", - /// - component 3 uses DC table 1 and AC table 1 "\x03\x11", - /// - the bytes "\x00\x3f\x00". Section B.2.3 of the spec says that for - /// sequential DCTs, those bytes (8-bit Ss, 8-bit Se, 4-bit Ah, 4-bit Al) - /// should be 0x00, 0x3f, 0x00<<4 | 0x00. - /// - // The C# compiler emits this as a compile-time constant embedded in the PE file. - // This is effectively compiled down to: return new ReadOnlySpan(&data, length) - // More details can be found: https://github.com/dotnet/roslyn/pull/24621 - private static ReadOnlySpan SosHeaderYCbCr => new byte[] - { - JpegConstants.Markers.XFF, JpegConstants.Markers.SOS, - - // Marker - 0x00, 0x0c, - - // Length (high byte, low byte), must be 6 + 2 * (number of components in scan) - 0x03, // Number of components in a scan, 3 - 0x01, // Component Id Y - 0x00, // DC/AC Huffman table - 0x02, // Component Id Cb - 0x11, // DC/AC Huffman table - 0x03, // Component Id Cr - 0x11, // DC/AC Huffman table - 0x00, // Ss - Start of spectral selection. - 0x3f, // Se - End of spectral selection. - 0x00 - - // Ah + Ah (Successive approximation bit position high + low) - }; - /// /// Gets the unscaled quantization tables in zig-zag order. Each /// encoder copies and scales the tables according to its quality parameter. @@ -212,10 +182,16 @@ namespace SixLabors.ImageSharp.Formats.Jpeg this.outputStream = stream; ImageMetadata metadata = image.Metadata; + // Compute number of components based on color type in options. + int componentCount = (this.colorType == JpegColorType.Luminance) ? 1 : 3; + // System.Drawing produces identical output for jpegs with a quality parameter of 0 and 1. int qlty = Numerics.Clamp(this.quality ?? metadata.GetJpegMetadata().Quality, 1, 100); this.subsample ??= qlty >= 91 ? JpegSubsample.Ratio444 : JpegSubsample.Ratio420; + // Force SubSample into Grayscale for single component ColorType. + this.subsample = (componentCount == 1) ? JpegSubsample.Grayscale : this.subsample; + // Convert from a quality rating to a scaling factor. int scale; if (qlty < 50) @@ -229,10 +205,10 @@ namespace SixLabors.ImageSharp.Formats.Jpeg // Initialize the quantization tables. InitQuantizationTable(0, scale, ref this.luminanceQuantTable); - InitQuantizationTable(1, scale, ref this.chrominanceQuantTable); - - // Compute number of components based on input image type. - const int componentCount = 3; + if (componentCount > 1) + { + InitQuantizationTable(1, scale, ref this.chrominanceQuantTable); + } // Write the Start Of Image marker. this.WriteApplicationHeader(metadata); @@ -250,7 +226,7 @@ namespace SixLabors.ImageSharp.Formats.Jpeg this.WriteDefineHuffmanTables(componentCount); // Write the image data. - this.WriteStartOfScan(image, cancellationToken); + this.WriteStartOfScan(image, componentCount, cancellationToken); // Write the End Of Image marker. this.buffer[0] = JpegConstants.Markers.XFF; @@ -468,6 +444,55 @@ namespace SixLabors.ImageSharp.Formats.Jpeg } } + /// + /// Encodes the image with no chroma, just luminance. + /// + /// The pixel format. + /// The pixel accessor providing access to the image pixels. + /// The token to monitor for cancellation. + /// The reference to the emit buffer. + private void EncodeGrayscale(Image pixels, CancellationToken cancellationToken, ref byte emitBufferBase) + where TPixel : unmanaged, IPixel + { + // TODO: Need a JpegScanEncoder class or struct that encapsulates the scan-encoding implementation. (Similar to JpegScanDecoder.) + // (Partially done with YCbCrForwardConverter) + Block8x8F temp1 = default; + Block8x8F temp2 = default; + + Block8x8F onStackLuminanceQuantTable = this.luminanceQuantTable; + + var unzig = ZigZag.CreateUnzigTable(); + + // ReSharper disable once InconsistentNaming + int prevDCY = 0; + + var pixelConverter = LuminanceForwardConverter.Create(); + ImageFrame frame = pixels.Frames.RootFrame; + Buffer2D pixelBuffer = frame.PixelBuffer; + RowOctet currentRows = default; + + for (int y = 0; y < pixels.Height; y += 8) + { + cancellationToken.ThrowIfCancellationRequested(); + currentRows.Update(pixelBuffer, y); + + for (int x = 0; x < pixels.Width; x += 8) + { + pixelConverter.Convert(frame, x, y, ref currentRows); + + prevDCY = this.WriteBlock( + QuantIndex.Luminance, + prevDCY, + ref pixelConverter.Y, + ref temp1, + ref temp2, + ref onStackLuminanceQuantTable, + ref unzig, + ref emitBufferBase); + } + } + } + /// /// Writes the application header containing the JFIF identifier plus extra data. /// @@ -898,6 +923,14 @@ namespace SixLabors.ImageSharp.Formats.Jpeg switch (this.subsample) { + case JpegSubsample.Grayscale: + subsamples = stackalloc byte[] + { + 0x11, + 0x00, + 0x00 + }; + break; case JpegSubsample.Ratio444: subsamples = stackalloc byte[] { @@ -926,26 +959,13 @@ namespace SixLabors.ImageSharp.Formats.Jpeg this.buffer[4] = (byte)(width & 0xff); // (2 bytes, Hi-Lo), must be > 0 if DNL not supported this.buffer[5] = (byte)componentCount; - // Number of components (1 byte), usually 1 = Gray scaled, 3 = color YCbCr or YIQ, 4 = color CMYK) - if (componentCount == 1) + for (int i = 0; i < componentCount; i++) { - this.buffer[6] = 1; + int i3 = 3 * i; + this.buffer[i3 + 6] = (byte)(i + 1); - // No subsampling for grayscale images. - this.buffer[7] = 0x11; - this.buffer[8] = 0x00; - } - else - { - for (int i = 0; i < componentCount; i++) - { - int i3 = 3 * i; - this.buffer[i3 + 6] = (byte)(i + 1); - - // We use 4:2:0 chroma subsampling by default. - this.buffer[i3 + 7] = subsamples[i]; - this.buffer[i3 + 8] = chroma[i]; - } + this.buffer[i3 + 7] = subsamples[i]; + this.buffer[i3 + 8] = chroma[i]; } this.outputStream.Write(this.buffer, 0, (3 * (componentCount - 1)) + 9); @@ -956,16 +976,60 @@ namespace SixLabors.ImageSharp.Formats.Jpeg /// /// The pixel format. /// The pixel accessor providing access to the image pixels. + /// The number of components in a pixel. /// The token to monitor for cancellation. - private void WriteStartOfScan(Image image, CancellationToken cancellationToken) + private void WriteStartOfScan(Image image, int componentCount, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { // TODO: Need a JpegScanEncoder class or struct that encapsulates the scan-encoding implementation. (Similar to JpegScanDecoder.) - // TODO: We should allow grayscale writing. - this.outputStream.Write(SosHeaderYCbCr); + Span componentId = stackalloc byte[] + { + 0x01, + 0x02, + 0x03 + }; + Span huffmanId = stackalloc byte[] + { + 0x00, + 0x11, + 0x11 + }; + + // Write the SOS (Start Of Scan) marker "\xff\xda" followed by 12 bytes: + // - the marker length "\x00\x0c", + // - the number of components "\x03", + // - component 1 uses DC table 0 and AC table 0 "\x01\x00", + // - component 2 uses DC table 1 and AC table 1 "\x02\x11", + // - component 3 uses DC table 1 and AC table 1 "\x03\x11", + // - the bytes "\x00\x3f\x00". Section B.2.3 of the spec says that for + // sequential DCTs, those bytes (8-bit Ss, 8-bit Se, 4-bit Ah, 4-bit Al) + // should be 0x00, 0x3f, 0x00<<4 | 0x00. + this.buffer[0] = JpegConstants.Markers.XFF; + this.buffer[1] = JpegConstants.Markers.SOS; + + // Length (high byte, low byte), must be 6 + 2 * (number of components in scan) + int sosSize = 6 + (2 * componentCount); + this.buffer[2] = 0x00; + this.buffer[3] = (byte)sosSize; + this.buffer[4] = (byte)componentCount; // Number of components in a scan + for (int i = 0; i < componentCount; i++) + { + int i2 = 2 * i; + this.buffer[i2 + 5] = componentId[i]; // Component Id + this.buffer[i2 + 6] = huffmanId[i]; // DC/AC Huffman table + } + + this.buffer[sosSize - 1] = 0x00; // Ss - Start of spectral selection. + this.buffer[sosSize] = 0x3f; // Se - End of spectral selection. + this.buffer[sosSize + 1] = 0x00; // Ah + Ah (Successive approximation bit position high + low) + this.outputStream.Write(this.buffer, 0, sosSize + 2); + ref byte emitBufferBase = ref MemoryMarshal.GetReference(this.emitBuffer); switch (this.subsample) { + case JpegSubsample.Grayscale: + this.EncodeGrayscale(image, cancellationToken, ref emitBufferBase); + break; case JpegSubsample.Ratio444: this.Encode444(image, cancellationToken, ref emitBufferBase); break; diff --git a/src/ImageSharp/Formats/Jpeg/JpegSubsample.cs b/src/ImageSharp/Formats/Jpeg/JpegSubsample.cs index 6597e0ccb..3989bef7a 100644 --- a/src/ImageSharp/Formats/Jpeg/JpegSubsample.cs +++ b/src/ImageSharp/Formats/Jpeg/JpegSubsample.cs @@ -1,4 +1,4 @@ -// Copyright (c) Six Labors. +// Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. namespace SixLabors.ImageSharp.Formats.Jpeg @@ -8,6 +8,11 @@ namespace SixLabors.ImageSharp.Formats.Jpeg /// public enum JpegSubsample { + /// + /// Only luminance - No chrome channels. + /// + Grayscale, + /// /// High Quality - Each of the three Y'CbCr components have the same sample rate, /// thus there is no chroma subsampling. diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs index 6481c711f..522c8a40b 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs @@ -83,6 +83,12 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg public void EncodeBaseline_WorksWithDifferentSizes(TestImageProvider provider, JpegSubsample subsample, int quality) where TPixel : unmanaged, IPixel => TestJpegEncoderCore(provider, subsample, quality); + [Theory] + [WithFile(TestImages.Png.BikeGrayscale, nameof(BitsPerPixel_Quality), PixelTypes.L8)] + [WithSolidFilledImages(nameof(BitsPerPixel_Quality), 1, 1, 100, 100, 100, 255, PixelTypes.L8)] + public void EncodeBaseline_GrayscaleWorksWithDifferentSizes(TestImageProvider provider, JpegSubsample subsample, int quality) + where TPixel : unmanaged, IPixel => TestJpegEncoderCore(provider, subsample, quality, JpegColorType.Luminance); + [Theory] [WithTestPatternImages(nameof(BitsPerPixel_Quality), 48, 48, PixelTypes.Rgba32 | PixelTypes.Bgra32)] public void EncodeBaseline_IsNotBoundToSinglePixelType(TestImageProvider provider, JpegSubsample subsample, int quality) @@ -101,7 +107,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg : ImageComparer.TolerantPercentage(5f); provider.LimitAllocatorBufferCapacity().InBytesSqrt(200); - TestJpegEncoderCore(provider, subsample, 100, comparer); + TestJpegEncoderCore(provider, subsample, 100, JpegColorType.YCbCr, comparer); } /// @@ -131,6 +137,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg TestImageProvider provider, JpegSubsample subsample, int quality = 100, + JpegColorType colorType = JpegColorType.YCbCr, ImageComparer comparer = null) where TPixel : unmanaged, IPixel { @@ -142,7 +149,8 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg var encoder = new JpegEncoder { Subsample = subsample, - Quality = quality + Quality = quality, + ColorType = colorType }; string info = $"{subsample}-Q{quality}";