Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions pixel/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand Down
63 changes: 63 additions & 0 deletions pixel/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
})
Expand Down
32 changes: 31 additions & 1 deletion pixel/pixel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down