├── .github ├── FUNDING.yaml └── workflows │ └── test.yaml ├── go.mod ├── LICENSE ├── README.md ├── cameron_test.go └── cameron.go /.github/FUNDING.yaml: -------------------------------------------------------------------------------- 1 | github: aofei 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/aofei/cameron 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go: 11 | - 1.13.x 12 | - 1.14.x 13 | - 1.15.x 14 | - 1.16.x 15 | - 1.17.x 16 | - 1.18.x 17 | - 1.19.x 18 | - 1.20.x 19 | - 1.21.x 20 | - 1.22.x 21 | - 1.23.x 22 | - 1.24.x 23 | - 1.25.x 24 | steps: 25 | - name: Check out code 26 | uses: actions/checkout@v5 27 | - name: Set up Go 28 | uses: actions/setup-go@v5 29 | with: 30 | go-version: ${{matrix.go}} 31 | - name: Download Go modules 32 | run: go mod download 33 | - name: Test Go code 34 | run: go test -v -race -covermode atomic -coverprofile coverage.out ./... 35 | - name: Upload code coverage 36 | uses: codecov/codecov-action@v5 37 | with: 38 | token: ${{secrets.CODECOV_TOKEN}} 39 | disable_search: true 40 | files: coverage.out 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Aofei Sheng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cameron 2 | 3 | [![Test](https://github.com/aofei/cameron/actions/workflows/test.yaml/badge.svg)](https://github.com/aofei/cameron/actions/workflows/test.yaml) 4 | [![codecov](https://codecov.io/gh/aofei/cameron/branch/master/graph/badge.svg)](https://codecov.io/gh/aofei/cameron) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/aofei/cameron)](https://goreportcard.com/report/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) 7 | 8 | An avatar generator for Go. 9 | 10 | Fun fact, Cameron 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)). 12 | 13 | ## Features 14 | 15 | - [Identicon](https://en.wikipedia.org/wiki/Identicon) 16 | 17 | ## Installation 18 | 19 | To use this project programmatically, `go get` it: 20 | 21 | ```bash 22 | go get github.com/aofei/cameron 23 | ``` 24 | 25 | ## Quickstart 26 | 27 | Create a file named `cameron.go`: 28 | 29 | ```go 30 | package main 31 | 32 | import ( 33 | "image/png" 34 | "net/http" 35 | 36 | "github.com/aofei/cameron" 37 | ) 38 | 39 | func main() { 40 | http.ListenAndServe("localhost:8080", http.HandlerFunc(identicon)) 41 | } 42 | 43 | func identicon(rw http.ResponseWriter, req *http.Request) { 44 | img := cameron.Identicon([]byte(req.RequestURI), 70) 45 | rw.Header().Set("Content-Type", "image/png") 46 | png.Encode(rw, img) 47 | } 48 | ``` 49 | 50 | Then run it: 51 | 52 | ```bash 53 | go run cameron.go 54 | ``` 55 | 56 | Finally, visit `http://localhost:8080` with different paths. 57 | 58 | ## Community 59 | 60 | If you have any questions or ideas about this project, feel free to discuss them 61 | [here](https://github.com/aofei/cameron/discussions). 62 | 63 | ## Contributing 64 | 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). 67 | 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/). 70 | 71 | ## License 72 | 73 | This project is licensed under the [MIT License](LICENSE). 74 | -------------------------------------------------------------------------------- /cameron_test.go: -------------------------------------------------------------------------------- 1 | package cameron 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "strconv" 10 | "testing" 11 | ) 12 | 13 | func TestIdenticon(t *testing.T) { 14 | t.Run("GitHub", func(t *testing.T) { 15 | for _, tt := range []struct { 16 | name string 17 | userID int 18 | wantDigest string 19 | }{ 20 | {"aofei", 5037285, "442b264a5b375f8b79b533199a26ab61"}, 21 | {"github", 9919, "5b768fe2c5e4b47fa819424c555d9787"}, 22 | {"octocat", 583231, "36212fba11a3ded8440d440086ef0290"}, 23 | } { 24 | t.Run(tt.name, func(t *testing.T) { 25 | img := Identicon([]byte(strconv.Itoa(tt.userID)), 70) 26 | 27 | h := md5.New() 28 | if err := png.Encode(h, img); err != nil { 29 | t.Fatalf("unexpected error %v", err) 30 | } 31 | digest := h.Sum(nil) 32 | 33 | got := hex.EncodeToString(digest) 34 | if got != tt.wantDigest { 35 | t.Errorf("got %s, want %s", got, tt.wantDigest) 36 | } 37 | }) 38 | } 39 | }) 40 | 41 | t.Run("EmptyInputs", func(t *testing.T) { 42 | img := Identicon(nil, 0) 43 | got := img.Bounds() 44 | want := image.Rect(0, 0, 6, 6) 45 | if got != want { 46 | t.Errorf("got %v, want %v", got, want) 47 | } 48 | }) 49 | } 50 | 51 | func TestHSLToNRGBA(t *testing.T) { 52 | for _, tt := range []struct { 53 | name string 54 | h, s, l float64 55 | want color.NRGBA 56 | }{ 57 | {"Red", 0, 100, 50, color.NRGBA{255, 0, 0, 255}}, 58 | {"Green", 120, 100, 50, color.NRGBA{0, 255, 0, 255}}, 59 | {"Blue", 240, 100, 50, color.NRGBA{0, 0, 255, 255}}, 60 | {"Black", 0, 0, 0, color.NRGBA{0, 0, 0, 255}}, 61 | {"White", 0, 0, 100, color.NRGBA{255, 255, 255, 255}}, 62 | } { 63 | t.Run(tt.name, func(t *testing.T) { 64 | got := hslToNRGBA(tt.h, tt.s, tt.l) 65 | if got != tt.want { 66 | t.Errorf("got %v, want %v", got, tt.want) 67 | } 68 | }) 69 | } 70 | } 71 | 72 | func TestHueToRGB(t *testing.T) { 73 | const ( 74 | p = 0.0 75 | q = 1.0 76 | tol = 1e-9 77 | ) 78 | for _, tt := range []struct { 79 | name string 80 | t float64 81 | want float64 82 | }{ 83 | {"DefaultBranchAtZero", 0, p}, // t == 0 84 | {"FirstBranch", 1.0 / 12.0, p + (q-p)*6*(1.0/12.0)}, // t < 1/6 85 | {"SecondBranch", 0.25, q}, // 1/6 <= t < 1/2 86 | {"ThirdBranch", 0.6, p + (q-p)*(2.0/3.0-0.6)*6}, // 1/2 <= t < 2/3 87 | {"WrapNegativeT", -0.2, p}, // -0.2 wraps to 0.8 88 | {"WrapTOverOne", 1.2, q}, // 1.2 wraps to 0.2 89 | } { 90 | t.Run(tt.name, func(t *testing.T) { 91 | got := hueToRGB(p, q, tt.t) 92 | if diff := got - tt.want; diff < -tol || diff > tol { 93 | t.Errorf("got %g, want %g", got, tt.want) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /cameron.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package cameron implements an avatar generator for Go. 3 | */ 4 | package cameron 5 | 6 | import ( 7 | "bytes" 8 | "crypto/md5" 9 | "image" 10 | "image/color" 11 | "math" 12 | ) 13 | 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 in 17 | // production. 18 | // 19 | // Note that the final image is a square of 6*cell pixels from a 5x5 grid plus a 20 | // 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 | } 26 | 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. 32 | } 33 | 34 | // Build the 5x5 symmetry mask. 35 | // 36 | // The first 15 nibbles decide the left half of the grid: 37 | // - Nibbles 0-4 fill the center column (index 2) 38 | // - Nibbles 5-9 fill the column immediately left of center (index 1) 39 | // - Nibbles 10-14 fill the leftmost 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 complete 44 | // 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] 56 | } 57 | 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) 75 | 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 with five pattern cells plus 82 | // a 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) 97 | } 98 | } 99 | } 100 | return img 101 | } 102 | 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 114 | } 115 | p := 2*l - q 116 | 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 | } 146 | } 147 | --------------------------------------------------------------------------------