using System.Runtime.CompilerServices; using FrameProcessor.Domain; using FrameProcessor.ImagePipeline; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Png; using SixLabors.ImageSharp.PixelFormats; namespace FrameProcessor.Tests; public class ImagePipelineTests { // Canonical Spectra 6 palette from SPEC ยง6.2. private static readonly IReadOnlyList Spectra6 = new[] { new PaletteEntry("black", Color.FromPixel(new Rgba32(0x1F, 0x22, 0x26, 0xFF)), Color.FromPixel(new Rgba32(0x00, 0x00, 0x00, 0xFF))), new PaletteEntry("white", Color.FromPixel(new Rgba32(0xB9, 0xC7, 0xC9, 0xFF)), Color.FromPixel(new Rgba32(0xFF, 0xFF, 0xFF, 0xFF))), new PaletteEntry("blue", Color.FromPixel(new Rgba32(0x23, 0x3F, 0x8E, 0xFF)), Color.FromPixel(new Rgba32(0x00, 0x00, 0xFF, 0xFF))), new PaletteEntry("green", Color.FromPixel(new Rgba32(0x35, 0x56, 0x3A, 0xFF)), Color.FromPixel(new Rgba32(0x00, 0xFF, 0x00, 0xFF))), new PaletteEntry("red", Color.FromPixel(new Rgba32(0x62, 0x20, 0x1E, 0xFF)), Color.FromPixel(new Rgba32(0xFF, 0x00, 0x00, 0xFF))), new PaletteEntry("yellow", Color.FromPixel(new Rgba32(0xC1, 0xBB, 0x1E, 0xFF)), Color.FromPixel(new Rgba32(0xFF, 0xFF, 0x00, 0xFF))), }; private static readonly Frame LandscapeFrame = new( FrameName.Parse("test"), MacAddress.Parse("aabbccddeeff"), new Resolution(1600, 1200), Orientation.Landscape, DitherAlgorithm.FloydSteinberg, Spectra6); private static readonly string[] InputNames = { "landscape.jpg", "portrait.jpg", "tiny-1x1.png", "non-divisible.jpg", }; public static IEnumerable AllCombinations() { foreach (var algo in Enum.GetValues()) { foreach (var input in InputNames) { yield return new object[] { algo, input }; } } } [Theory] [MemberData(nameof(AllCombinations))] public void Process_MatchesGoldenFixture(DitherAlgorithm algorithm, string inputName) { var inputPath = FixturePath("inputs", inputName); var expectedPath = FixturePath("expected", AlgorithmKey(algorithm), Path.ChangeExtension(inputName, ".png")); Assert.True(File.Exists(inputPath), $"Missing input fixture: {inputPath}"); Assert.True( File.Exists(expectedPath), $"Missing golden fixture: {expectedPath}. Regenerate by running 'UPDATE_GOLDEN=1 dotnet test'."); var frame = LandscapeFrame with { Dithering = algorithm }; var pipeline = new FrameProcessor.ImagePipeline.ImagePipeline(); using var stream = File.OpenRead(inputPath); var actual = pipeline.Process(stream, frame); var expected = File.ReadAllBytes(expectedPath); Assert.Equal(expected, actual); } [Fact] public void GenerateFixtures() { if (Environment.GetEnvironmentVariable("UPDATE_GOLDEN") != "1") { return; } WriteInputFixtures(); var pipeline = new FrameProcessor.ImagePipeline.ImagePipeline(); foreach (var algorithm in Enum.GetValues()) { foreach (var input in InputNames) { var frame = LandscapeFrame with { Dithering = algorithm }; using var stream = File.OpenRead(FixturePath("inputs", input)); var bytes = pipeline.Process(stream, frame); var outputPath = FixturePath( "expected", AlgorithmKey(algorithm), Path.ChangeExtension(input, ".png")); Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); File.WriteAllBytes(outputPath, bytes); } } } private static void WriteInputFixtures() { var inputsDir = FixturePath("inputs"); Directory.CreateDirectory(inputsDir); WriteJpeg(Path.Combine(inputsDir, "landscape.jpg"), 2000, 1000); WriteJpeg(Path.Combine(inputsDir, "portrait.jpg"), 1200, 1600); WriteJpeg(Path.Combine(inputsDir, "non-divisible.jpg"), 1234, 789); WriteTinyPng(Path.Combine(inputsDir, "tiny-1x1.png")); } private static void WriteJpeg(string path, int width, int height) { using var image = new Image(width, height); FillGradient(image); var encoder = new JpegEncoder { Quality = 90 }; using var fs = File.Create(path); image.SaveAsJpeg(fs, encoder); } private static void WriteTinyPng(string path) { using var image = new Image(1, 1); image[0, 0] = new Rgba32(0x80, 0x40, 0xC0, 0xFF); var encoder = new PngEncoder(); using var fs = File.Create(path); image.SaveAsPng(fs, encoder); } private static void FillGradient(Image image) { var w = image.Width; var h = image.Height; image.ProcessPixelRows(accessor => { for (var y = 0; y < accessor.Height; y++) { var row = accessor.GetRowSpan(y); for (var x = 0; x < row.Length; x++) { var r = (byte)(x * 255 / Math.Max(1, w - 1)); var g = (byte)(y * 255 / Math.Max(1, h - 1)); var b = (byte)(((x + y) * 255) / Math.Max(1, w + h - 2)); row[x] = new Rgba32(r, g, b, 0xFF); } } }); } private static string AlgorithmKey(DitherAlgorithm algorithm) => algorithm switch { DitherAlgorithm.FloydSteinberg => "floyd-steinberg", DitherAlgorithm.Atkinson => "atkinson", DitherAlgorithm.Stucki => "stucki", DitherAlgorithm.Jarvis => "jarvis", _ => throw new ArgumentOutOfRangeException(nameof(algorithm)), }; private static string FixturePath(params string[] parts) { var combined = new string[parts.Length + 1]; combined[0] = FixturesRoot(); Array.Copy(parts, 0, combined, 1, parts.Length); return Path.Combine(combined); } private static string FixturesRoot([CallerFilePath] string callerPath = "") => Path.Combine(Path.GetDirectoryName(callerPath)!, "Fixtures"); }