Skip to content

Commit 253f8e3

Browse files
authored
pixel: add Grayscale2bit color (#817)
* pixel: add GrayScale2bit color * pixel: fix spelling of 'GrayScale' to 'Grayscale'
1 parent c710cc8 commit 253f8e3

File tree

3 files changed

+115
-1
lines changed

3 files changed

+115
-1
lines changed

pixel/image.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,20 @@ func (img Image[T]) setPixel(index int, c T) {
149149
}
150150

151151
return
152+
case zeroColor.BitsPerPixel() == 2:
153+
// Grayscale2bit.
154+
offset := index / 4 // 4 pixels per byte
155+
shift := 6 - (index%4)*2 // bits: 6, 4, 2, 0
156+
157+
ptr := (*byte)(unsafe.Add(img.data, offset))
158+
159+
raw := *(*uint8)(unsafe.Pointer(&c))
160+
gray := raw & 0b11
161+
162+
mask := byte(0b11 << shift)
163+
*ptr = (*ptr &^ mask) | (gray << shift)
164+
return
165+
152166
case zeroColor.BitsPerPixel()%8 == 0:
153167
// Each color starts at a whole byte offset.
154168
// This is the easy case.
@@ -206,6 +220,13 @@ func (img Image[T]) Get(x, y int) T {
206220
ptr := (*byte)(unsafe.Add(img.data, offset))
207221
c = ((*ptr >> (7 - uint8(bits))) & 0x1) > 0
208222
return any(c).(T)
223+
case zeroColor.BitsPerPixel() == 2:
224+
// Grayscale2bit.
225+
offset := index / 4 // 4 pixels per byte
226+
shift := 6 - (index%4)*2 // bits: 6, 4, 2, 0
227+
ptr := (*byte)(unsafe.Add(img.data, offset))
228+
value := ((*ptr) >> shift) & 0b11
229+
return any(Grayscale2bit(value)).(T)
209230
case zeroColor.BitsPerPixel()%8 == 0:
210231
// Colors like RGB565, RGB888, etc.
211232
offset := index * int(unsafe.Sizeof(zeroColor))

pixel/image_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,66 @@ func TestImageRGB444BE(t *testing.T) {
107107
}
108108
}
109109

110+
func TestImageGrayscale2bit(t *testing.T) {
111+
image := pixel.NewImage[pixel.Grayscale2bit](128, 64)
112+
113+
if width, height := image.Size(); width != 128 || height != 64 {
114+
t.Errorf("image.Size(): expected 128, 64 but got %d, %d", width, height)
115+
}
116+
117+
// Define test colors representing 4 Grayscale levels.
118+
testColors := []color.RGBA{
119+
{R: 0x00, G: 0x00, B: 0x00, A: 0xff}, // black
120+
{R: 0x55, G: 0x55, B: 0x55, A: 0xff}, // dark gray
121+
{R: 0xaa, G: 0xaa, B: 0xaa, A: 0xff}, // light gray
122+
{R: 0xff, G: 0xff, B: 0xff, A: 0xff}, // white
123+
}
124+
125+
// Single pixel roundtrip test at a fixed coordinate.
126+
for _, c := range testColors {
127+
encoded := pixel.NewColor[pixel.Grayscale2bit](c.R, c.G, c.B)
128+
image.Set(5, 3, encoded)
129+
actual := image.Get(5, 3).RGBA()
130+
if actual != c {
131+
t.Errorf("failed to roundtrip color: expected %v but got %v", c, actual)
132+
}
133+
}
134+
135+
// Multi-coordinate test across the image.
136+
for x := 0; x < 8; x++ {
137+
for y, c := range testColors {
138+
encoded := pixel.NewColor[pixel.Grayscale2bit](c.R, c.G, c.B)
139+
image.Set(x, y, encoded)
140+
actual := image.Get(x, y).RGBA()
141+
if actual != c {
142+
t.Errorf("Set/Get mismatch at (%d,%d): expected %v but got %v", x, y, c, actual)
143+
}
144+
}
145+
}
146+
}
147+
148+
func TestNewGrayscale2bitMapping(t *testing.T) {
149+
testCases := []struct {
150+
input color.RGBA
151+
expect pixel.Grayscale2bit
152+
}{
153+
{color.RGBA{R: 0x00, G: 0x00, B: 0x00}, 0}, // 0
154+
{color.RGBA{R: 0x3F, G: 0x3F, B: 0x3F}, 0}, // 63
155+
{color.RGBA{R: 0x40, G: 0x40, B: 0x40}, 1}, // 64
156+
{color.RGBA{R: 0x7F, G: 0x7F, B: 0x7F}, 1}, // 127
157+
{color.RGBA{R: 0x80, G: 0x80, B: 0x80}, 2}, // 128
158+
{color.RGBA{R: 0xBF, G: 0xBF, B: 0xBF}, 2}, // 191
159+
{color.RGBA{R: 0xC0, G: 0xC0, B: 0xC0}, 3}, // 192
160+
{color.RGBA{R: 0xFF, G: 0xFF, B: 0xFF}, 3}, // 255
161+
}
162+
for _, tc := range testCases {
163+
actual := pixel.NewColor[pixel.Grayscale2bit](tc.input.R, tc.input.G, tc.input.B)
164+
if actual != tc.expect {
165+
t.Errorf("NewGrayscale2bit(%#v) = %d, want %d", tc.input, actual, tc.expect)
166+
}
167+
}
168+
}
169+
110170
func TestImageMonochrome(t *testing.T) {
111171
image := pixel.NewImage[pixel.Monochrome](128, 64)
112172
if width, height := image.Size(); width != 128 || height != 64 {
@@ -236,6 +296,9 @@ func TestImageNoise(t *testing.T) {
236296
t.Run("RGB444BE", func(t *testing.T) {
237297
testImageNoiseN[pixel.RGB444BE](t)
238298
})
299+
t.Run("Grayscale2bit", func(t *testing.T) {
300+
testImageNoiseN[pixel.Grayscale2bit](t)
301+
})
239302
t.Run("Monochrome", func(t *testing.T) {
240303
testImageNoiseN[pixel.Monochrome](t)
241304
})

pixel/pixel.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
// particular display. Each pixel is at least 1 byte in size.
1717
// The color format is sRGB (or close to it) in all cases except for 1-bit.
1818
type Color interface {
19-
RGB888 | RGB565BE | RGB555 | RGB444BE | Monochrome
19+
RGB888 | RGB565BE | RGB555 | RGB444BE | Grayscale2bit | Monochrome
2020

2121
BaseColor
2222
}
@@ -50,6 +50,8 @@ func NewColor[T Color](r, g, b uint8) T {
5050
return any(NewRGB555(r, g, b)).(T)
5151
case RGB444BE:
5252
return any(NewRGB444BE(r, g, b)).(T)
53+
case Grayscale2bit:
54+
return any(NewGrayscale2bit(r, g, b)).(T)
5355
case Monochrome:
5456
return any(NewMonochrome(r, g, b)).(T)
5557
default:
@@ -204,6 +206,34 @@ func (c RGB444BE) RGBA() color.RGBA {
204206
return color
205207
}
206208

209+
// Grayscale2bit represents a 2-bit Grayscale value (4 levels: black, dark gray, light gray, white).
210+
type Grayscale2bit uint8
211+
212+
func NewGrayscale2bit(r, g, b uint8) Grayscale2bit {
213+
// Convert RGB to luminance using standard weights (approximation of human perception)
214+
// Use shift-based operations to reduce processing time.
215+
// luminance := (299*uint32(r) + 587*uint32(g) + 114*uint32(b)) / 1000
216+
luminance := (77*uint32(r) + 150*uint32(g) + 29*uint32(b)) >> 8
217+
// Map to 2-bit value: 0–63 => 0, 64–127 => 1, 128–191 => 2, 192–255 => 3
218+
return Grayscale2bit((luminance >> 6) & 0b11)
219+
}
220+
221+
func (c Grayscale2bit) BitsPerPixel() int {
222+
return 2
223+
}
224+
225+
func (c Grayscale2bit) RGBA() color.RGBA {
226+
// Expand 2-bit Grayscale back to 8-bit (0–255) using multiplication
227+
// 0 → 0x00, 1 → 0x55, 2 → 0xAA, 3 → 0xFF (i.e., multiply by 85)
228+
gray := uint8(c&0b11) * 85
229+
return color.RGBA{
230+
R: gray,
231+
G: gray,
232+
B: gray,
233+
A: 255,
234+
}
235+
}
236+
207237
type Monochrome bool
208238

209239
func NewMonochrome(r, g, b uint8) Monochrome {

0 commit comments

Comments
 (0)