169 lines
6.3 KiB
C#
169 lines
6.3 KiB
C#
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<PaletteEntry> 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<object[]> AllCombinations()
|
|
{
|
|
foreach (var algo in Enum.GetValues<DitherAlgorithm>())
|
|
{
|
|
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<DitherAlgorithm>())
|
|
{
|
|
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<Rgba32>(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<Rgba32>(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<Rgba32> 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");
|
|
}
|