From eab5be509e19b132bd8db35fcdaf93a92a8375cb Mon Sep 17 00:00:00 2001 From: MPCoreDeveloper Date: Sun, 15 Nov 2026 10:28:16 +0100 Subject: [PATCH] Upsample subsampled JPEG chroma with libjpeg 3:1 filter JpegDecoder.Sample replaces nearest-neighbour sampling with the triangular filter libjpeg calls fancy: an even output position is three parts of its sample and one of the neighbour before it, an odd one three parts and one of the neighbour after it, with the same rounding. A 4:2:0 fixture now matches Pillow within two levels rather than the six the nearest filter needed, and the test tolerance tightens to two. --- CHANGELOG.md | 5 +- docs/adr/0005-image-filter-decoders.md | 9 ++- .../Pdf/JpegDecoder.cs | 68 ++++++++++++++++++- .../SharpCorePDF.Tests/PdfJpegFilterTests.cs | 8 +-- 4 files changed, 76 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ffea78..fa14d44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,9 @@ against an empty feed. - **Baseline JPEG images are decoded.** `DCTDecode` now yields samples — `Gray8` for one component, `Rgb24` for three (YCbCr converted) and `Cmyk32` for four with the Adobe YCCK transform — while `Data` keeps the stored JPEG and `DecodedPixels` carries the samples, so a caller can have either. Baseline and - extended sequential (SOF0/SOF1) are decoded; progressive (SOF2) and the fancy chroma upsampling a - subsampled fixture needs are the next step, per ADR-005. + extended sequential (SOF0/SOF1) are decoded, and a subsampled component is upsampled with libjpeg's + 3:1 fancy filter, so a 4:2:0 fixture matches a reference decoder within two levels. Progressive (SOF2) + is the next step, per ADR-005. ## [1.8.0] - 2026-11-14 diff --git a/docs/adr/0005-image-filter-decoders.md b/docs/adr/0005-image-filter-decoders.md index ecd6323..8ba0209 100644 --- a/docs/adr/0005-image-filter-decoders.md +++ b/docs/adr/0005-image-filter-decoders.md @@ -132,10 +132,9 @@ their own fixtures and the same ±2 tolerance. The baseline decoder is implemented: `PdfJpegFilterTests` decodes both fixtures within two levels of Pillow, and the contract above holds — `Data` is the stored JPEG, `DecodedPixels` the samples. The variants -the mutation survivors asked for are there too: `jpeg-420.jpg` (4:2:0, nearest upsampling and a documented -edge tolerance), `jpeg-cmyk.jpg` (four components, inverted as the `/Decode` array states) and -`jpeg-restart.jpg` (restart markers). The fancy upsampling that matches Pillow at every edge, and SOF2, -are the next tests. +the mutation survivors asked for are there too: `jpeg-420.jpg` (4:2:0, decoded with libjpeg's 3:1 fancy +filter and within two levels of Pillow), `jpeg-cmyk.jpg` (four components, inverted as the `/Decode` array +states) and `jpeg-restart.jpg` (restart markers). SOF2 is the next slice. ## Status of the slices @@ -143,6 +142,6 @@ are the next tests. | --- | --- | | `RunLengthDecode` | **Implemented** (2026-11-14) | | `CCITTFaxDecode` | **Implemented** (2026-11-15) | -| `DCTDecode` | **Baseline (SOF0/SOF1) implemented** (2026-11-15); progressive (SOF2) next | +| `DCTDecode` | **Baseline (SOF0/SOF1) implemented, fancy upsampling** (2026-11-15); progressive (SOF2) next | | `JPXDecode` | Planned | | `JBIG2Decode` | Planned | diff --git a/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs b/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs index 64dce0b..8987af2 100644 --- a/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs +++ b/src/SharpCorePDF.Infrastructure/Pdf/JpegDecoder.cs @@ -612,9 +612,16 @@ private bool BuildOutput(int maxBytes, out byte[]? pixels, out string? format) for (var component = 0; component < count; component++) { var source = components[component]; - var sx = (x * source.H) / maxH; - var sy = (y * source.V) / maxV; - samples[component] = source.Plane[(sy * source.Stride) + sx]; + samples[component] = Sample( + source.Plane, + source.Stride, + source.Stride == 0 ? 0 : source.Plane.Length / source.Stride, + x, + y, + source.H, + source.V, + maxH, + maxV); } if (count == 1) @@ -663,6 +670,61 @@ private bool BuildOutput(int maxBytes, out byte[]? pixels, out string? format) return true; } + /// + /// Reads one sample, upsampling a subsampled component with the 3:1 filter libjpeg calls fancy: an + /// even output position takes three parts of its sample and one of the neighbour before it, an odd one + /// three parts and one of the neighbour after it, rounded as libjpeg rounds. + /// + private static int Sample( + byte[] plane, + int stride, + int rows, + int x, + int y, + int horizontal, + int vertical, + int maxH, + int maxV) + { + if (horizontal == maxH && vertical == maxV) + { + return plane[(y * stride) + x]; + } + + var sx = (x * horizontal) / maxH; + var sy = (y * vertical) / maxV; + var fancyX = horizontal * 2 == maxH; + var fancyY = vertical * 2 == maxV; + + if (!fancyX && !fancyY) + { + return plane[(sy * stride) + sx]; + } + + var left = Math.Max(sx - 1, 0); + var right = Math.Min(sx + 1, stride - 1); + + var centre = Column(sx); + var before = Column(left); + var after = Column(right); + + return x % 2 == 0 + ? ((3 * centre) + before + 1) >> 2 + : ((3 * centre) + after + 2) >> 2; + + int Column(int column) + { + if (!fancyY) + { + return plane[(sy * stride) + column]; + } + + return y % 2 == 0 + ? ((3 * plane[(sy * stride) + column]) + plane[(Math.Max(sy - 1, 0) * stride) + column] + 1) >> 2 + : ((3 * plane[(sy * stride) + column]) + plane[(Math.Min(sy + 1, rows - 1) * stride) + column] + 2) >> 2; + } + } + private static byte Clamp(double value) => (byte)Math.Clamp((int)Math.Round(value), 0, 255); diff --git a/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs b/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs index ceedee4..79f2ecd 100644 --- a/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs +++ b/tests/SharpCorePDF.Tests/PdfJpegFilterTests.cs @@ -50,18 +50,18 @@ public void Decodes_a_baseline_gray_jpeg_within_two_levels() } /// - /// A 4:2:0 JPEG halves the chroma in both directions. The decoder upsamples with the nearest sample, - /// where Pillow uses the fancy filter, so the tolerance is the edge difference rather than two levels. + /// A 4:2:0 JPEG halves the chroma in both directions; the decoder upsamples with the same 3:1 filter + /// libjpeg (and so Pillow) uses, which keeps the comparison inside the two-level tolerance. /// [Fact] - public void Decodes_a_subsampled_jpeg_within_the_upsampling_tolerance() + public void Decodes_a_subsampled_jpeg_within_two_levels() { Assert.True(JpegDecoder.TryDecode(Rgb420, 1 << 20, out var pixels, out var format, out var width, out var height)); Assert.Equal("Rgb24", format); Assert.Equal(32, width); Assert.Equal(32, height); - Assert.InRange(MaxDifference(pixels!, Rgb420Expected), 0, 6); + Assert.InRange(MaxDifference(pixels!, Rgb420Expected), 0, 2); } ///