3.3 IImagePipeline + ImagePipeline

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:36:54 +02:00
parent 54755c3808
commit 5d0d5ed185
3 changed files with 97 additions and 1 deletions

View File

@@ -103,7 +103,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
### [x] 3.2 `DitheringRegistry` ### [x] 3.2 `DitheringRegistry`
- `IReadOnlyDictionary<DitherAlgorithm, IDither>` mapping each enum to the corresponding ImageSharp `KnownDitherings.*`. - `IReadOnlyDictionary<DitherAlgorithm, IDither>` mapping each enum to the corresponding ImageSharp `KnownDitherings.*`.
### [ ] 3.3 `IImagePipeline` + `ImagePipeline` ### [x] 3.3 `IImagePipeline` + `ImagePipeline`
- Single method: `byte[] Process(Stream input, Frame frame)`. - Single method: `byte[] Process(Stream input, Frame frame)`.
- Steps (`SPEC.md` §3.2): decode → resize (cover/center-crop to oriented resolution) → dither against display palette → remap pixels display→device → rotate 90° CW if portrait → encode indexed PNG (`PngColorType.Palette`, smallest bit depth fitting the palette, `PaletteQuantizer(deviceColors)`). - Steps (`SPEC.md` §3.2): decode → resize (cover/center-crop to oriented resolution) → dither against display palette → remap pixels display→device → rotate 90° CW if portrait → encode indexed PNG (`PngColorType.Palette`, smallest bit depth fitting the palette, `PaletteQuantizer(deviceColors)`).
- Output dimensions always match the frame's *declared* `width × height` regardless of orientation. - Output dimensions always match the frame's *declared* `width × height` regardless of orientation.

View File

@@ -0,0 +1,13 @@
using FrameProcessor.Domain;
namespace FrameProcessor.ImagePipeline;
/// <summary>
/// Converts an arbitrary input image into the indexed-PNG bytes a frame's firmware
/// expects: resized to the frame's resolution, dithered against the frame's display
/// palette, then remapped to the device-color palette.
/// </summary>
public interface IImagePipeline
{
byte[] Process(Stream input, Frame frame);
}

View File

@@ -0,0 +1,83 @@
using FrameProcessor.Domain;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
using SixLabors.ImageSharp.Processing.Processors.Quantization;
namespace FrameProcessor.ImagePipeline;
public sealed class ImagePipeline : IImagePipeline
{
public byte[] Process(Stream input, Frame frame)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(frame);
var dither = DitheringRegistry.All[frame.Dithering];
var displayPalette = PaletteFactory.BuildDisplay(frame.Palette);
var devicePalette = PaletteFactory.BuildDevice(frame.Palette);
var oriented = frame.Resolution.ForOrientation(frame.Orientation);
using var image = Image.Load<Rgba32>(input);
image.Mutate(ctx => ctx
.Resize(new ResizeOptions
{
Size = new Size(oriented.Width, oriented.Height),
Mode = ResizeMode.Crop,
Position = AnchorPositionMode.Center,
})
.Dither(dither, displayPalette));
RemapDisplayToDevice(image, frame.Palette);
if (frame.Orientation == Orientation.Portrait)
{
image.Mutate(ctx => ctx.Rotate(RotateMode.Rotate90));
}
var encoder = new PngEncoder
{
ColorType = PngColorType.Palette,
BitDepth = SmallestBitDepthFor(frame.Palette.Count),
Quantizer = new PaletteQuantizer(devicePalette),
};
using var output = new MemoryStream();
image.SaveAsPng(output, encoder);
return output.ToArray();
}
private static void RemapDisplayToDevice(Image<Rgba32> image, IReadOnlyList<PaletteEntry> palette)
{
var map = new Dictionary<Rgba32, Rgba32>(palette.Count);
foreach (var entry in palette)
{
map[entry.DisplayColor.ToPixel<Rgba32>()] = entry.DeviceColor.ToPixel<Rgba32>();
}
image.ProcessPixelRows(accessor =>
{
for (var y = 0; y < accessor.Height; y++)
{
var row = accessor.GetRowSpan(y);
for (var x = 0; x < row.Length; x++)
{
if (map.TryGetValue(row[x], out var mapped))
{
row[x] = mapped;
}
}
}
});
}
private static PngBitDepth SmallestBitDepthFor(int paletteCount) => paletteCount switch
{
<= 2 => PngBitDepth.Bit1,
<= 4 => PngBitDepth.Bit2,
<= 16 => PngBitDepth.Bit4,
_ => PngBitDepth.Bit8,
};
}