diff --git a/pixel/image.go b/pixel/image.go index b3ef56259..77b2f90dd 100644 --- a/pixel/image.go +++ b/pixel/image.go @@ -149,6 +149,20 @@ func (img Image[T]) setPixel(index int, c T) { } return + case zeroColor.BitsPerPixel() == 2: + // Grayscale2bit. + offset := index / 4 // 4 pixels per byte + shift := 6 - (index%4)*2 // bits: 6, 4, 2, 0 + + ptr := (*byte)(unsafe.Add(img.data, offset)) + + raw := *(*uint8)(unsafe.Pointer(&c)) + gray := raw & 0b11 + + mask := byte(0b11 << shift) + *ptr = (*ptr &^ mask) | (gray << shift) + return + case zeroColor.BitsPerPixel()%8 == 0: // Each color starts at a whole byte offset. // This is the easy case. @@ -206,6 +220,13 @@ func (img Image[T]) Get(x, y int) T { ptr := (*byte)(unsafe.Add(img.data, offset)) c = ((*ptr >> (7 - uint8(bits))) & 0x1) > 0 return any(c).(T) + case zeroColor.BitsPerPixel() == 2: + // Grayscale2bit. + offset := index / 4 // 4 pixels per byte + shift := 6 - (index%4)*2 // bits: 6, 4, 2, 0 + ptr := (*byte)(unsafe.Add(img.data, offset)) + value := ((*ptr) >> shift) & 0b11 + return any(Grayscale2bit(value)).(T) case zeroColor.BitsPerPixel()%8 == 0: // Colors like RGB565, RGB888, etc. offset := index * int(unsafe.Sizeof(zeroColor)) diff --git a/pixel/image_test.go b/pixel/image_test.go index 5636f3de2..342f81b1e 100644 --- a/pixel/image_test.go +++ b/pixel/image_test.go @@ -107,6 +107,66 @@ func TestImageRGB444BE(t *testing.T) { } } +func TestImageGrayscale2bit(t *testing.T) { + image := pixel.NewImage[pixel.Grayscale2bit](128, 64) + + if width, height := image.Size(); width != 128 || height != 64 { + t.Errorf("image.Size(): expected 128, 64 but got %d, %d", width, height) + } + + // Define test colors representing 4 Grayscale levels. + testColors := []color.RGBA{ + {R: 0x00, G: 0x00, B: 0x00, A: 0xff}, // black + {R: 0x55, G: 0x55, B: 0x55, A: 0xff}, // dark gray + {R: 0xaa, G: 0xaa, B: 0xaa, A: 0xff}, // light gray + {R: 0xff, G: 0xff, B: 0xff, A: 0xff}, // white + } + + // Single pixel roundtrip test at a fixed coordinate. + for _, c := range testColors { + encoded := pixel.NewColor[pixel.Grayscale2bit](c.R, c.G, c.B) + image.Set(5, 3, encoded) + actual := image.Get(5, 3).RGBA() + if actual != c { + t.Errorf("failed to roundtrip color: expected %v but got %v", c, actual) + } + } + + // Multi-coordinate test across the image. + for x := 0; x < 8; x++ { + for y, c := range testColors { + encoded := pixel.NewColor[pixel.Grayscale2bit](c.R, c.G, c.B) + image.Set(x, y, encoded) + actual := image.Get(x, y).RGBA() + if actual != c { + t.Errorf("Set/Get mismatch at (%d,%d): expected %v but got %v", x, y, c, actual) + } + } + } +} + +func TestNewGrayscale2bitMapping(t *testing.T) { + testCases := []struct { + input color.RGBA + expect pixel.Grayscale2bit + }{ + {color.RGBA{R: 0x00, G: 0x00, B: 0x00}, 0}, // 0 + {color.RGBA{R: 0x3F, G: 0x3F, B: 0x3F}, 0}, // 63 + {color.RGBA{R: 0x40, G: 0x40, B: 0x40}, 1}, // 64 + {color.RGBA{R: 0x7F, G: 0x7F, B: 0x7F}, 1}, // 127 + {color.RGBA{R: 0x80, G: 0x80, B: 0x80}, 2}, // 128 + {color.RGBA{R: 0xBF, G: 0xBF, B: 0xBF}, 2}, // 191 + {color.RGBA{R: 0xC0, G: 0xC0, B: 0xC0}, 3}, // 192 + {color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF}, 3}, // 255 + } + for _, tc := range testCases { + actual := pixel.NewColor[pixel.Grayscale2bit](tc.input.R, tc.input.G, tc.input.B) + if actual != tc.expect { + t.Errorf("NewGrayscale2bit(%#v) = %d, want %d", tc.input, actual, tc.expect) + } + } +} + func TestImageMonochrome(t *testing.T) { image := pixel.NewImage[pixel.Monochrome](128, 64) if width, height := image.Size(); width != 128 || height != 64 { @@ -236,6 +296,9 @@ func TestImageNoise(t *testing.T) { t.Run("RGB444BE", func(t *testing.T) { testImageNoiseN[pixel.RGB444BE](t) }) + t.Run("Grayscale2bit", func(t *testing.T) { + testImageNoiseN[pixel.Grayscale2bit](t) + }) t.Run("Monochrome", func(t *testing.T) { testImageNoiseN[pixel.Monochrome](t) }) diff --git a/pixel/pixel.go b/pixel/pixel.go index b4582a6d5..de069d37f 100644 --- a/pixel/pixel.go +++ b/pixel/pixel.go @@ -16,7 +16,7 @@ import ( // particular display. Each pixel is at least 1 byte in size. // The color format is sRGB (or close to it) in all cases except for 1-bit. type Color interface { - RGB888 | RGB565BE | RGB555 | RGB444BE | Monochrome + RGB888 | RGB565BE | RGB555 | RGB444BE | Grayscale2bit | Monochrome BaseColor } @@ -50,6 +50,8 @@ func NewColor[T Color](r, g, b uint8) T { return any(NewRGB555(r, g, b)).(T) case RGB444BE: return any(NewRGB444BE(r, g, b)).(T) + case Grayscale2bit: + return any(NewGrayscale2bit(r, g, b)).(T) case Monochrome: return any(NewMonochrome(r, g, b)).(T) default: @@ -204,6 +206,34 @@ func (c RGB444BE) RGBA() color.RGBA { return color } +// Grayscale2bit represents a 2-bit Grayscale value (4 levels: black, dark gray, light gray, white). +type Grayscale2bit uint8 + +func NewGrayscale2bit(r, g, b uint8) Grayscale2bit { + // Convert RGB to luminance using standard weights (approximation of human perception) + // Use shift-based operations to reduce processing time. + // luminance := (299*uint32(r) + 587*uint32(g) + 114*uint32(b)) / 1000 + luminance := (77*uint32(r) + 150*uint32(g) + 29*uint32(b)) >> 8 + // Map to 2-bit value: 0–63 => 0, 64–127 => 1, 128–191 => 2, 192–255 => 3 + return Grayscale2bit((luminance >> 6) & 0b11) +} + +func (c Grayscale2bit) BitsPerPixel() int { + return 2 +} + +func (c Grayscale2bit) RGBA() color.RGBA { + // Expand 2-bit Grayscale back to 8-bit (0–255) using multiplication + // 0 → 0x00, 1 → 0x55, 2 → 0xAA, 3 → 0xFF (i.e., multiply by 85) + gray := uint8(c&0b11) * 85 + return color.RGBA{ + R: gray, + G: gray, + B: gray, + A: 255, + } +} + type Monochrome bool func NewMonochrome(r, g, b uint8) Monochrome {