From c3619af107b7ec20abb9bb4140e78ea9f6e144dd Mon Sep 17 00:00:00 2001 From: sago35 Date: Mon, 23 Jun 2025 18:02:06 +0900 Subject: [PATCH 1/2] pixel: add GrayScale2bit color --- pixel/image.go | 21 +++++++++++++++ pixel/image_test.go | 63 +++++++++++++++++++++++++++++++++++++++++++++ pixel/pixel.go | 32 ++++++++++++++++++++++- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/pixel/image.go b/pixel/image.go index b3ef56259..b49ea3a14 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..db195ddd0 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..fa6550abe 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 { From 9796e62b3255e2dfacf0dd887a072cb475c898d5 Mon Sep 17 00:00:00 2001 From: sago35 Date: Fri, 14 Nov 2025 17:56:52 +0900 Subject: [PATCH 2/2] pixel: fix spelling of 'GrayScale' to 'Grayscale' --- pixel/image.go | 6 +++--- pixel/image_test.go | 22 +++++++++++----------- pixel/pixel.go | 20 ++++++++++---------- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/pixel/image.go b/pixel/image.go index b49ea3a14..77b2f90dd 100644 --- a/pixel/image.go +++ b/pixel/image.go @@ -150,7 +150,7 @@ func (img Image[T]) setPixel(index int, c T) { return case zeroColor.BitsPerPixel() == 2: - // GrayScale2bit. + // Grayscale2bit. offset := index / 4 // 4 pixels per byte shift := 6 - (index%4)*2 // bits: 6, 4, 2, 0 @@ -221,12 +221,12 @@ func (img Image[T]) Get(x, y int) T { c = ((*ptr >> (7 - uint8(bits))) & 0x1) > 0 return any(c).(T) case zeroColor.BitsPerPixel() == 2: - // GrayScale2bit. + // 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) + 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 db195ddd0..342f81b1e 100644 --- a/pixel/image_test.go +++ b/pixel/image_test.go @@ -107,14 +107,14 @@ func TestImageRGB444BE(t *testing.T) { } } -func TestImageGrayScale2bit(t *testing.T) { - image := pixel.NewImage[pixel.GrayScale2bit](128, 64) +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. + // 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 @@ -124,7 +124,7 @@ func TestImageGrayScale2bit(t *testing.T) { // Single pixel roundtrip test at a fixed coordinate. for _, c := range testColors { - encoded := pixel.NewColor[pixel.GrayScale2bit](c.R, c.G, c.B) + 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 { @@ -135,7 +135,7 @@ func TestImageGrayScale2bit(t *testing.T) { // 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) + 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 { @@ -145,10 +145,10 @@ func TestImageGrayScale2bit(t *testing.T) { } } -func TestNewGrayScale2bitMapping(t *testing.T) { +func TestNewGrayscale2bitMapping(t *testing.T) { testCases := []struct { input color.RGBA - expect pixel.GrayScale2bit + expect pixel.Grayscale2bit }{ {color.RGBA{R: 0x00, G: 0x00, B: 0x00}, 0}, // 0 {color.RGBA{R: 0x3F, G: 0x3F, B: 0x3F}, 0}, // 63 @@ -160,9 +160,9 @@ func TestNewGrayScale2bitMapping(t *testing.T) { {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) + 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) + t.Errorf("NewGrayscale2bit(%#v) = %d, want %d", tc.input, actual, tc.expect) } } } @@ -296,8 +296,8 @@ 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("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 fa6550abe..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 | GrayScale2bit | Monochrome + RGB888 | RGB565BE | RGB555 | RGB444BE | Grayscale2bit | Monochrome BaseColor } @@ -50,8 +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 Grayscale2bit: + return any(NewGrayscale2bit(r, g, b)).(T) case Monochrome: return any(NewMonochrome(r, g, b)).(T) default: @@ -206,24 +206,24 @@ 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 +// 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 { +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) + return Grayscale2bit((luminance >> 6) & 0b11) } -func (c GrayScale2bit) BitsPerPixel() int { +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 +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{