diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 9c25633..f3779da 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -103,7 +103,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. ### [x] 3.2 `DitheringRegistry` - `IReadOnlyDictionary` 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)`. - 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. diff --git a/src/FrameProcessor/ImagePipeline/IImagePipeline.cs b/src/FrameProcessor/ImagePipeline/IImagePipeline.cs new file mode 100644 index 0000000..3f72ec0 --- /dev/null +++ b/src/FrameProcessor/ImagePipeline/IImagePipeline.cs @@ -0,0 +1,13 @@ +using FrameProcessor.Domain; + +namespace FrameProcessor.ImagePipeline; + +/// +/// 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. +/// +public interface IImagePipeline +{ + byte[] Process(Stream input, Frame frame); +} diff --git a/src/FrameProcessor/ImagePipeline/ImagePipeline.cs b/src/FrameProcessor/ImagePipeline/ImagePipeline.cs new file mode 100644 index 0000000..dc6e615 --- /dev/null +++ b/src/FrameProcessor/ImagePipeline/ImagePipeline.cs @@ -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(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 image, IReadOnlyList palette) + { + var map = new Dictionary(palette.Count); + foreach (var entry in palette) + { + map[entry.DisplayColor.ToPixel()] = entry.DeviceColor.ToPixel(); + } + + 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, + }; +}