From 20a9d2497311c09f3c0cc563b18b7cb5b0b724b4 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Sun, 25 Aug 2024 17:05:16 -0700 Subject: [PATCH] Add a grayscale format and tweak. --- doc/formats.md | 4 ++ lib/src/blend/porter_duff.dart | 4 +- lib/src/buffer.dart | 2 +- lib/src/codec/unpng.dart | 2 + lib/src/format.dart | 19 ++++++++ lib/src/format/gray8.dart | 81 +++++++++++++++++++++++++++++++ lib/src/format/grayscale.dart | 88 ++++++++++++++++++++++++++++++++++ lib/src/format/rgb.dart | 10 ---- lib/src/format/rgb888.dart | 10 ++++ test/buffer_test.dart | 2 +- test/format/gray8_test.dart | 82 +++++++++++++++++++++++++++++++ test/src/prelude.dart | 2 +- 12 files changed, 292 insertions(+), 14 deletions(-) create mode 100644 lib/src/format/gray8.dart create mode 100644 lib/src/format/grayscale.dart create mode 100644 test/format/gray8_test.dart diff --git a/doc/formats.md b/doc/formats.md index cf04825..3befa62 100644 --- a/doc/formats.md +++ b/doc/formats.md @@ -37,12 +37,16 @@ Name | Bits per pixel | Description ------------ | -------------- | ------------------------------------------------ [abgr8888][] | 32 | 4 channels @ 8 bits each [argb8888][] | 32 | 4 channels @ 8 bits each +[gray8][] | 8 | 1 channel @ 8 bits [rgba8888][] | 32 | 4 channels @ 8 bits each [abgr8888]: ../pxl/abgr8888-constant.html [argb8888]: ../pxl/argb8888-constant.html +[gray8]: ../pxl/gray8-constant.html [rgba8888]: ../pxl/rgba8888-constant.html +Grayscale formats use _luminance_ values to represent color. + ## Floating-point pixel formats All floating-point formats use the RGBA 128-bit format as a common intermediate diff --git a/lib/src/blend/porter_duff.dart b/lib/src/blend/porter_duff.dart index 7ec6b12..d6539e4 100644 --- a/lib/src/blend/porter_duff.dart +++ b/lib/src/blend/porter_duff.dart @@ -54,7 +54,9 @@ final class PorterDuff implements BlendMode { PixelFormat srcFormat, PixelFormat dstFormat, ) { - if (identical(srcFormat, floatRgba) && identical(dstFormat, floatRgba)) { + // Intentionally ignore the type check, we're performing it implicitly. + // ignore: unrelated_type_equality_checks + if (srcFormat == floatRgba && dstFormat == floatRgba) { return _blendFloatRgba as T Function(S src, T dst); } return (src, dst) { diff --git a/lib/src/buffer.dart b/lib/src/buffer.dart index 95a24c9..0ca28c9 100644 --- a/lib/src/buffer.dart +++ b/lib/src/buffer.dart @@ -135,7 +135,7 @@ abstract base mixin class Buffer { /// print(converted.data); // [0xFFFF0000, 0xFF00FF00, 0xFF0000FF] /// ``` Buffer mapConvert(PixelFormat format) { - if (identical(this.format, format)) { + if (format == this.format) { return this as Buffer; } return _MapBuffer( diff --git a/lib/src/codec/unpng.dart b/lib/src/codec/unpng.dart index 79a1112..8766aa9 100644 --- a/lib/src/codec/unpng.dart +++ b/lib/src/codec/unpng.dart @@ -87,10 +87,12 @@ final _pngSignature = Uint8List(8) ..[7] = 0x0A; Uint8List _encodeUncompressedPng(Buffer pixels) { + // coverage:ignore-start assert( pixels.format == abgr8888, 'Unsupported pixel format: ${pixels.format}', ); + // coverage:ignore-end final output = BytesBuilder(copy: false); // Write the PNG signature (https://www.w3.org/TR/png-3/#3PNGsignature). diff --git a/lib/src/format.dart b/lib/src/format.dart index 9629618..4ca4cb0 100644 --- a/lib/src/format.dart +++ b/lib/src/format.dart @@ -6,6 +6,8 @@ import 'package:pxl/src/internal.dart'; part 'format/abgr8888.dart'; part 'format/argb8888.dart'; part 'format/float_rgba.dart'; +part 'format/gray8.dart'; +part 'format/grayscale.dart'; part 'format/indexed.dart'; part 'format/rgb.dart'; part 'format/rgb888.dart'; @@ -199,3 +201,20 @@ abstract base mixin class PixelFormat { @override String toString() => name; } + +/// Converts RGB channels to a gray luminance value. +/// +/// The resulting value is in the range `[0, 255]`. +int _luminanceRgb888(int r, int g, int b) { + final weightedSum = (r & 0xFF) * 76 + (g & 0xFF) * 150 + (b & 0xFF) * 29; + return weightedSum ~/ 0xFF; +} + +/// Converts floating-point RGB channels to a gray luminance value. +/// +/// The resulting value is in the range `[0.0, 1.0]`. +(double gray, double alpha) _luminanceFloatRgba(Float32x4 pixel) { + final product = pixel * Float32x4(76 / 0xFF, 150 / 0xFF, 29 / 0xFF, 0.0); + final weightedSum = product.x + product.y + product.z; + return (weightedSum, pixel.w); +} diff --git a/lib/src/format/gray8.dart b/lib/src/format/gray8.dart new file mode 100644 index 0000000..6aa9a79 --- /dev/null +++ b/lib/src/format/gray8.dart @@ -0,0 +1,81 @@ +part of '../format.dart'; + +/// 8-bit grayscale pixel format. +/// +/// Colors in this format are represented as follows: +/// +/// Color | Value +/// --------------|------ +/// [Gray8.black] | `0x00` +/// [Gray8.white] | `0xFF` +/// +/// {@category Pixel Formats} +const gray8 = Gray8._(); + +/// 8-bit grayscale pixel format. +/// +/// For a singleton instance of this class, and further details, see [gray8]. +/// +/// {@category Pixel Formats} +final class Gray8 extends _GrayInt { + const Gray8._(); + + @override + String get name => 'GRAY8'; + + @override + int get bytesPerPixel => Uint8List.bytesPerElement; + + @override + int get maxGray => 0xFF; + + @override + int get max => maxGray; + + @override + int copyWith(int pixel, {int? gray}) { + var output = pixel; + if (gray != null) { + output = gray & 0xFF; + } + return output; + } + + @override + int copyWithNormalized(int pixel, {double? gray}) { + return copyWith( + pixel, + gray: gray != null ? (gray.clamp(0.0, 1.0) * 0xFF).floor() : null, + ); + } + + @override + int getGray(int pixel) => pixel & 0xFF; + + @override + int fromAbgr8888(int pixel) { + return _luminanceRgb888( + abgr8888.getRed(pixel), + abgr8888.getGreen(pixel), + abgr8888.getBlue(pixel), + ); + } + + @override + int toAbgr8888(int pixel) { + // Isolate the least significant 8 bits. + final value = pixel & 0xFF; + + // Replicate the value across all channels (R, G, B). + final asRgb = value * 0x010101; + + // Set the alpha channel to 0xFF. + return asRgb | 0xFF000000; + } + + @override + Float32x4 toFloatRgba(int pixel) { + final g = getGray(pixel) / 255.0; + return Float32x4(g, g, g, 1.0); + } +} diff --git a/lib/src/format/grayscale.dart b/lib/src/format/grayscale.dart new file mode 100644 index 0000000..505db29 --- /dev/null +++ b/lib/src/format/grayscale.dart @@ -0,0 +1,88 @@ +part of '../format.dart'; + +/// A mixin for pixel formats that represent _graysacle_ pixels. +base mixin Grayscale implements PixelFormat { + /// Creates a new pixel with the given channel values. + /// + /// The [gray] value is optional. + /// + /// If omitted, the channel value is set to the minimum value. + /// + /// ## Example + /// + /// ```dart + /// // Creating a full gray pixel. + /// final pixel = grayscale.create(gray: 0xFF); + /// ``` + P create({C? gray}) => copyWith(zero, gray: gray ?? minGray); + + @override + P copyWith(P pixel, {C? gray}); + + /// Creates a new pixel with the given channel value normalized to the range + /// `[0.0, 1.0]`. + /// + /// The [gray] value is optional. + /// + /// If omitted, the channel value is set to `0.0`. + /// + /// ## Example + /// + /// ```dart + /// // Creating a full gray pixel. + /// final pixel = grayscale.createNormalized(gray: 1.0); + /// ``` + P createNormalized({double gray = 0.0}) { + return copyWithNormalized(zero, gray: gray); + } + + @override + P copyWithNormalized(P pixel, {double? gray}); + + /// The minimum value for the gray channel. + C get minGray; + + /// The maximum value for the gray channel. + C get maxGray; + + /// Black pixel. + P get black; + + /// White pixel. + P get white => max; + + /// Returns the gray channel value of the [pixel]. + C getGray(P pixel); + + @override + P fromFloatRgba(Float32x4 pixel) { + final (g, _) = _luminanceFloatRgba(pixel); + return createNormalized(gray: g); + } +} + +abstract final class _GrayInt extends PixelFormat + with Grayscale { + const _GrayInt(); + + @override + double distance(int a, int b) => (a - b).abs().toDouble(); + + @override + double compare(int a, int b) => 1.0 - (a - b).abs() / maxGray.toDouble(); + + @override + @nonVirtual + int get zero => 0x0; + + @override + @nonVirtual + int get minGray => 0x0; + + @override + @nonVirtual + int clamp(int pixel) => pixel & max; + + @override + int get black => create(gray: minGray); +} diff --git a/lib/src/format/rgb.dart b/lib/src/format/rgb.dart index 64a5f15..97d83e2 100644 --- a/lib/src/format/rgb.dart +++ b/lib/src/format/rgb.dart @@ -144,16 +144,6 @@ abstract final class Rgb extends PixelFormat { /// Returns the blue channel value of the [pixel]. C getBlue(P pixel); - - @override - P fromFloatRgba(Float32x4 pixel) { - return copyWithNormalized( - zero, - red: pixel.x, - green: pixel.y, - blue: pixel.z, - ); - } } base mixin _Rgb8Int on Rgb { diff --git a/lib/src/format/rgb888.dart b/lib/src/format/rgb888.dart index 82cc988..8315264 100644 --- a/lib/src/format/rgb888.dart +++ b/lib/src/format/rgb888.dart @@ -89,6 +89,16 @@ final class Rgb888 extends Rgb with _Rgb8Int { ); } + @override + int fromFloatRgba(Float32x4 pixel) { + return copyWithNormalized( + zero, + red: pixel.x, + green: pixel.y, + blue: pixel.z, + ); + } + @override int getRed(int pixel) => (pixel >> 16) & 0xFF; diff --git a/test/buffer_test.dart b/test/buffer_test.dart index 02c8dc8..3b23103 100644 --- a/test/buffer_test.dart +++ b/test/buffer_test.dart @@ -229,7 +229,7 @@ void main() { test('buffer.format is pixels.format', () { final buffer = IntPixels(2, 2).mapRect(Rect.fromLTWH(1, 1, 1, 1)); - check(buffer.format).identicalTo(abgr8888); + check(buffer.format).equals(abgr8888); }); test('buffer.getUnsafe maps to pixels.getUnsafe', () { diff --git a/test/format/gray8_test.dart b/test/format/gray8_test.dart new file mode 100644 index 0000000..10d34a6 --- /dev/null +++ b/test/format/gray8_test.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'package:pxl/pxl.dart'; + +import '../src/prelude.dart'; + +void main() { + test('smoke test of GRAY8 <> ABGR8888', () { + check(gray8.name).equals('GRAY8'); + check(gray8.bytesPerPixel).equals(1); + check(gray8.maxGray).equals(255); + check(gray8.max).equals(255); + + check(gray8.getGray(0x00)).equals(0); + check(gray8.getGray(0x1d)).equals(29); + check(gray8.getGray(0x96)).equals(150); + + check(gray8.fromAbgr8888(abgr8888.black)).equals(0); + check(gray8.fromAbgr8888(abgr8888.blue)).equals(29); + check(gray8.fromAbgr8888(abgr8888.green)).equals(150); + check(gray8.fromAbgr8888(abgr8888.red)).equals(76); + check(gray8.fromAbgr8888(abgr8888.white)).equals(255); + + check(gray8.fromFloatRgba(floatRgba.black)).equals(0); + check(gray8.fromFloatRgba(floatRgba.blue)).equals(29); + check(gray8.fromFloatRgba(floatRgba.green)).equals(150); + check(gray8.fromFloatRgba(floatRgba.red)).equals(76); + check(gray8.fromFloatRgba(floatRgba.white)).equals(255); + + check(gray8.toAbgr8888(0)).equalsHex(abgr8888.black); + check(gray8.toAbgr8888(29)).equalsHex(0xff1d1d1d); + check(gray8.toAbgr8888(150)).equalsHex(0xff969696); + check(gray8.toAbgr8888(76)).equalsHex(0xff4c4c4c); + check(gray8.toAbgr8888(255)).equalsHex(abgr8888.white); + + check(gray8.toFloatRgba(0)).equals(floatRgba.black); + check(gray8.toFloatRgba(29)).equals( + Float32x4(0.113725, 0.113725, 0.113725, 1.0), + ); + check(gray8.toFloatRgba(150)).equals( + Float32x4(0.588235, 0.588235, 0.588235, 1.0), + ); + check(gray8.toFloatRgba(76)).equals( + Float32x4(0.298039, 0.298039, 0.298039, 1.0), + ); + check(gray8.toFloatRgba(255)).equals(floatRgba.white); + }); + + test('create', () { + check(gray8.create(gray: 0)).equals(gray8.black); + check(gray8.create(gray: 29)).equals(29); + check(gray8.create(gray: 150)).equals(150); + check(gray8.create(gray: 76)).equals(76); + check(gray8.create(gray: 255)).equals(gray8.white); + }); + + test('distance', () { + check(gray8.distance(0, 0)).equals(0); + check(gray8.distance(0, 29)).equals(29); + }); + + test('compare', () { + check(gray8.compare(0, 0)).equals(1.0); + check(gray8.compare(0, 29)).equals(0.8862745098039215); + check(gray8.compare(29, 0)).equals(0.8862745098039215); + check(gray8.compare(29, 29)).equals(1.0); + }); + + test('minGray', () { + check(gray8.minGray).equals(0); + }); + + test('clamp', () { + check(gray8.clamp(-1)).equals(255); + check(gray8.clamp(0)).equals(0); + check(gray8.clamp(29)).equals(29); + check(gray8.clamp(150)).equals(150); + check(gray8.clamp(76)).equals(76); + check(gray8.clamp(255)).equals(255); + check(gray8.clamp(256)).equals(0); + }); +} diff --git a/test/src/prelude.dart b/test/src/prelude.dart index 4ed1afb..5c423f8 100644 --- a/test/src/prelude.dart +++ b/test/src/prelude.dart @@ -29,7 +29,7 @@ extension Float32x4Checks on Subject { void equals(Float32x4 other) { context.expect(() => prefixFirst('equals ', literal(other)), (actual) { final result = actual.equal(other); - if (result.signMask == 0xF) return null; + if (result.signMask != 0) return null; return Rejection(which: ['are not equal']); }); }