Skip to content

Commit bfa061c

Browse files
committed
refactor: reimplement Identicon for pixel-perfect match with GitHub
Signed-off-by: Aofei Sheng <aofei@aofeisheng.com>
1 parent 1f9381b commit bfa061c

File tree

7 files changed

+258
-133
lines changed

7 files changed

+258
-133
lines changed

.github/workflows/test.yaml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
name: Test
2+
on:
3+
push:
4+
branches:
5+
- "**"
6+
pull_request:
7+
branches:
8+
- "**"
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
go:
15+
- 1.13.x
16+
- 1.14.x
17+
- 1.15.x
18+
- 1.16.x
19+
- 1.17.x
20+
- 1.18.x
21+
- 1.19.x
22+
- 1.20.x
23+
- 1.21.x
24+
- 1.22.x
25+
- 1.23.x
26+
- 1.24.x
27+
steps:
28+
- name: Check out code
29+
uses: actions/checkout@v4
30+
- name: Set up Go
31+
uses: actions/setup-go@v5
32+
with:
33+
go-version: ${{matrix.go}}
34+
- name: Download Go modules
35+
run: go mod download
36+
- name: Test Go code
37+
run: go test -v -race -covermode atomic -coverprofile coverage.out ./...
38+
- name: Upload code coverage
39+
uses: codecov/codecov-action@v5
40+
with:
41+
token: ${{secrets.CODECOV_TOKEN}}
42+
disable_search: true
43+
files: coverage.out

.github/workflows/test.yml

Lines changed: 0 additions & 30 deletions
This file was deleted.

README.md

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,35 @@
11
# Cameron
22

3-
[![GitHub Actions](https://github.com/aofei/cameron/workflows/Test/badge.svg)](https://github.com/aofei/cameron)
3+
[![Test](https://github.com/aofei/cameron/actions/workflows/test.yaml/badge.svg)](https://github.com/aofei/cameron/actions/workflows/test.yaml)
44
[![codecov](https://codecov.io/gh/aofei/cameron/branch/master/graph/badge.svg)](https://codecov.io/gh/aofei/cameron)
55
[![Go Report Card](https://goreportcard.com/badge/github.com/aofei/cameron)](https://goreportcard.com/report/github.com/aofei/cameron)
6-
[![PkgGoDev](https://pkg.go.dev/badge/github.com/aofei/cameron)](https://pkg.go.dev/github.com/aofei/cameron)
6+
[![Go Reference](https://pkg.go.dev/badge/github.com/aofei/cameron.svg)](https://pkg.go.dev/github.com/aofei/cameron)
77

88
An avatar generator for Go.
99

10-
Oh, by the way, the name of this project came from the
11-
[Avatar](https://en.wikipedia.org/wiki/Avatar_(2009_film))'s director
12-
[James Cameron](https://en.wikipedia.org/wiki/James_Cameron).
10+
**Fun fact:** this project is named after [James Cameron](https://en.wikipedia.org/wiki/James_Cameron), the director of
11+
[Avatar](https://en.wikipedia.org/wiki/Avatar_(2009_film)).
1312

1413
## Features
1514

16-
* [Identicon](https://en.wikipedia.org/wiki/Identicon)
15+
- [Identicon](https://en.wikipedia.org/wiki/Identicon)
1716

1817
## Installation
1918

20-
Open your terminal and execute
19+
To use this project programmatically, `go get` it:
2120

2221
```bash
23-
$ go get github.com/aofei/cameron
22+
go get github.com/aofei/cameron
2423
```
2524

26-
done.
27-
28-
> The only requirement is the [Go](https://go.dev), at least v1.13.
29-
3025
## Quick Start
3126

32-
Create a file named `cameron.go`
27+
Create a file named `cameron.go`:
3328

3429
```go
3530
package main
3631

3732
import (
38-
"bytes"
3933
"image/png"
4034
"net/http"
4135

@@ -47,39 +41,33 @@ func main() {
4741
}
4842

4943
func identicon(rw http.ResponseWriter, req *http.Request) {
50-
buf := bytes.Buffer{}
51-
png.Encode(&buf, cameron.Identicon([]byte(req.RequestURI), 540, 60))
44+
img := cameron.Identicon([]byte(req.RequestURI), 70)
5245
rw.Header().Set("Content-Type", "image/png")
53-
buf.WriteTo(rw)
46+
png.Encode(rw, img)
5447
}
5548
```
5649

57-
and run it
50+
Then run it:
5851

5952
```bash
60-
$ go run cameron.go
53+
go run cameron.go
6154
```
6255

63-
then visit `http://localhost:8080` with different paths.
56+
Finally, visit `http://localhost:8080` with different paths.
6457

6558
## Community
6659

67-
If you want to discuss Cameron, or ask questions about it, simply post questions
68-
or ideas [here](https://github.com/aofei/cameron/issues).
60+
If you have any questions or ideas about this project, feel free to discuss them
61+
[here](https://github.com/aofei/cameron/discussions).
6962

7063
## Contributing
7164

72-
If you want to help build Cameron, simply follow
73-
[this](https://github.com/aofei/cameron/wiki/Contributing) to send pull requests
74-
[here](https://github.com/aofei/cameron/pulls).
65+
If you would like to contribute to this project, please submit issues [here](https://github.com/aofei/cameron/issues)
66+
or pull requests [here](https://github.com/aofei/cameron/pulls).
7567

76-
## TODOs
77-
78-
* [ ] Add support for cartoon avatar
79-
* [ ] Add support for simulation avatar
68+
When submitting a pull request, please make sure its commit messages adhere to
69+
[Conventional Commits 1.0.0](https://www.conventionalcommits.org/en/v1.0.0/).
8070

8171
## License
8272

83-
This project is licensed under the MIT License.
84-
85-
License can be found [here](LICENSE).
73+
This project is licensed under the [MIT License](LICENSE).

cameron.go

Lines changed: 123 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,68 +5,142 @@ package cameron
55

66
import (
77
"bytes"
8+
"crypto/md5"
89
"image"
910
"image/color"
10-
11-
"github.com/cespare/xxhash/v2"
11+
"math"
1212
)
1313

14-
// Identicon returns an identicon avatar based on the data with the length and
15-
// blockLength.
16-
func Identicon(data []byte, length, blockLength int) image.Image {
17-
digest := xxhash.Sum64(data)
18-
img := image.NewPaletted(
19-
image.Rect(0, 0, length, length),
20-
color.Palette{
21-
color.NRGBA{
22-
R: byte(digest),
23-
G: byte(digest >> 8),
24-
B: byte(digest >> 16),
25-
A: 0xff,
26-
},
27-
color.NRGBA{
28-
R: 0xff ^ byte(digest),
29-
G: 0xff ^ byte(digest>>8),
30-
B: 0xff ^ byte(digest>>16),
31-
A: 0xff,
32-
},
33-
},
34-
)
14+
// Identicon returns an identicon avatar as an [image.Image] that is visually
15+
// identical to https://github.com/identicons/{login}.png. All geometric rules,
16+
// color calculations, and pixel layouts match the implementation GitHub uses
17+
// in production.
18+
//
19+
// Note that the final image is a square of 6*cell pixels: five grid
20+
// columns/rows plus a half-cell margin on every side.
21+
func Identicon(data []byte, cell int) image.Image {
22+
digest := md5.Sum(data)
23+
if cell < 1 {
24+
cell = 1
25+
}
3526

36-
if blockLength > length {
37-
blockLength = length
27+
// Split the 16-byte digest into 32 individual 4-bit nibbles.
28+
var nib [32]byte
29+
for i := 0; i < 16; i++ {
30+
nib[2*i] = digest[i] >> 4 // High 4 bits.
31+
nib[2*i+1] = digest[i] & 0x0F // Low 4 bits.
3832
}
3933

40-
columnsCount := length / blockLength
41-
padding := blockLength / 2
42-
if length%blockLength != 0 {
43-
padding = (length - blockLength*columnsCount) / 2
44-
} else if columnsCount > 1 {
45-
columnsCount--
46-
} else {
47-
padding = 0
34+
// Build the 5×5 symmetry mask.
35+
//
36+
// The first 15 nibbles decide the left half of the grid:
37+
// - Nibbles 0–4 fill the center column (column index 2).
38+
// - Nibbles 5–9 fill the column immediately left of center (index 1).
39+
// - Nibbles 10–14 fill the left-most column (index 0).
40+
//
41+
// A pixel is set only when its nibble value is even.
42+
//
43+
// Once the left half is filled, copy it to columns 3 and 4 to
44+
// complete the grid and guarantee horizontal symmetry.
45+
var mask [5][5]bool
46+
for i := 0; i < 15; i++ {
47+
if nib[i]%2 == 0 {
48+
row := i % 5
49+
col := 2 - i/5
50+
mask[row][col] = true
51+
}
52+
}
53+
for r := 0; r < 5; r++ {
54+
mask[r][3] = mask[r][1]
55+
mask[r][4] = mask[r][0]
4856
}
4957

50-
filled := columnsCount == 1
51-
pixels := bytes.Repeat([]byte{1}, blockLength)
52-
for i, ri, ci := 0, 0, 0; i < columnsCount*(columnsCount+1)/2; i++ {
53-
if filled || digest>>uint(i%64)&1 == 1 {
54-
for i := 0; i < blockLength; i++ {
55-
x := padding + ri*blockLength
56-
y := padding + ci*blockLength + i
57-
copy(img.Pix[img.PixOffset(x, y):], pixels)
58+
// Derive the foreground color from HSL.
59+
//
60+
// The final 7 nibbles are interpreted as HHHSSLL, where:
61+
// - HHH (12 bits) maps to hue in [0, 360) degrees.
62+
// - SS (8 bits) maps to saturation in [45, 65] percent.
63+
// - LL (8 bits) maps to lightness in [55, 75] percent.
64+
var v uint32
65+
for i := 25; i < 32; i++ {
66+
v = (v << 4) | uint32(nib[i])
67+
}
68+
hueBits := v >> 16
69+
satBits := (v >> 8) & 0xFF
70+
lgtBits := v & 0xFF
71+
h := float64(hueBits) * 360 / 4095
72+
s := 65.0 - float64(satBits)*20/255
73+
l := 75.0 - float64(lgtBits)*20/255
74+
fg := hslToNRGBA(h, s, l)
5875

59-
x = padding + (columnsCount-1-ri)*blockLength
60-
copy(img.Pix[img.PixOffset(x, y):], pixels)
76+
// Use a light gray background as in GitHub's implementation.
77+
bg := color.NRGBA{R: 240, G: 240, B: 240, A: 255}
78+
79+
// Allocate the palette-based image and fill it.
80+
//
81+
// The bitmap is six logical cells per side: five pattern cells plus a
82+
// half-cell margin on each edge. Using a palette keeps memory small.
83+
size := 6 * cell
84+
img := image.NewPaletted(image.Rect(0, 0, size, size), color.Palette{bg, fg})
85+
margin := cell / 2 // Half-cell margin in pixels.
86+
rowBuf := bytes.Repeat([]byte{1}, cell) // Palette index 1 is fg.
87+
for r := 0; r < 5; r++ {
88+
for c := 0; c < 5; c++ {
89+
if !mask[r][c] {
90+
continue
91+
}
92+
x := margin + c*cell
93+
y := margin + r*cell
94+
for dy := 0; dy < cell; dy++ {
95+
off := img.PixOffset(x, y+dy)
96+
copy(img.Pix[off:], rowBuf)
6197
}
6298
}
99+
}
100+
return img
101+
}
63102

64-
ci++
65-
if ci == columnsCount {
66-
ci = 0
67-
ri++
68-
}
103+
// hslToNRGBA converts HSL values to an opaque [color.NRGBA].
104+
func hslToNRGBA(h, s, l float64) color.NRGBA {
105+
h /= 360
106+
s /= 100
107+
l /= 100
108+
109+
var q float64
110+
if l < 0.5 {
111+
q = l * (1 + s)
112+
} else {
113+
q = l + s - l*s
69114
}
115+
p := 2*l - q
70116

71-
return img
117+
r := hueToRGB(p, q, h+1.0/3.0)
118+
g := hueToRGB(p, q, h)
119+
b := hueToRGB(p, q, h-1.0/3.0)
120+
return color.NRGBA{
121+
R: uint8(math.Round(r * 255)),
122+
G: uint8(math.Round(g * 255)),
123+
B: uint8(math.Round(b * 255)),
124+
A: 255,
125+
}
126+
}
127+
128+
// hueToRGB converts a hue offset t into a single RGB component.
129+
func hueToRGB(p, q, t float64) float64 {
130+
if t < 0 {
131+
t += 1
132+
}
133+
if t > 1 {
134+
t -= 1
135+
}
136+
switch {
137+
case t < 1.0/6.0:
138+
return p + (q-p)*6*t
139+
case t < 1.0/2.0:
140+
return q
141+
case t < 2.0/3.0:
142+
return p + (q-p)*(2.0/3.0-t)*6
143+
default:
144+
return p
145+
}
72146
}

0 commit comments

Comments
 (0)